Batch support: fix completion races, accounting, and API polish - #3
Open
jpcamara wants to merge 17 commits into
Open
Batch support: fix completion races, accounting, and API polish#3jpcamara wants to merge 17 commits into
jpcamara wants to merge 17 commits into
Conversation
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
jpcamara
force-pushed
the
batch-review-fixes
branch
from
July 23, 2026 12:55
dd88f13 to
ea4a85d
Compare
|
@jpcamara do you need to test this one in your app before you can merge it to the original PR? |
Batches could get permanently stuck or lose callbacks through several races, all fixed without locking the batch row outside a single once-per-batch moment: - Completion keeps the single-statement CAS (exactly one finisher matches; losers resolve with a 0-row update) but shares its transaction with counters and callback enqueueing, so a crash can't leave a finished batch without callbacks - After winning the CAS, executions are re-checked with a current snapshot: on PostgreSQL READ COMMITTED a blocked finisher re-evaluates NOT EXISTS against its original snapshot and could otherwise finish a batch that just gained jobs from a concurrent adder - Adding jobs to a finished batch is prevented lock-free: the existing total_jobs increment gains an `unfinished` condition, so it and the completion CAS contend on the same row and exactly one wins; losing rolls back the enqueue transaction with AlreadyFinished - The increment runs before inserting tracking rows: execution inserts take a shared lock on the batch row (FK validation) and upgrading to exclusive deadlocked concurrent adders on MySQL (163/200 in stress tests); exclusive-first serializes them cleanly - start_batch marks the batch started before enqueueing the empty job and sweeps for completion afterwards, closing the window where jobs finish before enqueued_at is set and nothing ever finishes the batch; the start transition is single-winner so concurrent sweepers can't enqueue duplicate empty jobs - Batch.sweep_stalled is the safety net for work that can't finish through the normal flow: bulk-discarded jobs, processes that died before starting their batch, and completions whose callback enqueueing failed - Completion and sweeps emit finish_batch and sweep_stalled_batches events Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
In-flight counters double counted failures: failed jobs have also lost their batch execution, so completed_jobs (total - pending) included them while failed_jobs counted them again, letting progress_percentage exceed 100% and report completion with jobs still running. Progress is now (total - pending) / total, which needs one COUNT instead of three and is consistent before and after finishing. Also: failed counts reuse the Job.failed scope, status returns symbols like Job#status, and the empty_executions scope stays join-free so update_all keeps it in the completion CAS's own WHERE clause. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
Jobs join the batch that's active when they're enqueued, not when they're instantiated: a job built outside the block and enqueued inside it joins the batch, and vice versa. The active context takes precedence so reused instances rebind to the current batch, falling back to prior membership so retries stay in theirs. Bulk enqueues bypass per-job enqueue, so Job.enqueue_all captures the context itself. Capture must run before Rails 7.2's enqueue_after_transaction_commit deferral or deferred jobs would silently miss their batch — the include is nested like Rails' own initializer so BatchId deterministically wraps outside EnqueueAfterTransactionCommit. This hazard is why capture originally lived in initialize; with the ordering guaranteed, enqueue time keeps the better semantics without the initialize override. Also: serialize only writes batch keys when set (sparing dead JSON on every non-batch job), and the batch accessor memoizes by id so a nil read before enqueue doesn't hide a batch assigned later. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
- Tracking rows are created before dispatching in the bulk path, so jobs discarded by concurrency conflicts clean up via dependent: :destroy and count the same as in the single-job path (previously perform_later counted them and perform_all_later didn't) - Batch executions get on_delete: :cascade foreign keys like every other execution table; bulk discards delete jobs without callbacks, and the cascade removes their tracking rows declaratively so the sweep can finish the batch (Execution itself stays untouched) - The failure-side concern moves to FailedExecution::Batchable: single-owner concerns live under their owner like Job::Batchable, and failed executions are the only terminal execution type, so they're the only place batch tracking ends - Tracking failures emit batch_progress_error events instead of swallowing errors with a bare log line, and rescue only ActiveRecord::ActiveRecordError so unexpected errors propagate Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
ConcurrencyMaintenance generalizes into Dispatcher::Maintenance running both concurrency and batch routines on one timer at concurrency_maintenance_interval, individually toggleable via the concurrency_maintenance and batch_maintenance options — batch support adds no maintenance thread or worst-case database connection to the dispatcher. ConcurrencyMaintenance remains as a compatibility shim with its original signature and behavior, so no shipped API is removed. The LogSubscriber renders the new batch events: finish_batch, sweep_stalled_batches and batch_progress_error. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
Every load-bearing line in batch completion has a test that fails if it's removed, verified by sabotage (breaking each guard makes its test go red): - Concurrent completion checks finish a batch exactly once, with exactly one set of callbacks (kills the CAS conditions if weakened; also caught a where.missing refactor that silently moved `unfinished` out of the UPDATE's own WHERE on PostgreSQL) - Concurrent adders keep exact accounting and hit no deadlocks (the reverted statement order fails with 33/40 adds deadlocked on MySQL) - A completion check racing a concurrent adder does not finish the batch (deterministically reproduces the PostgreSQL stale-snapshot interleaving; removing the post-CAS re-check wrongly finishes it) - start_batch sweeps up jobs that finished before the batch was started, and is single-winner against stale instances - sweep_stalled finishes bulk-discarded batches and starts batches whose creating process died - In-flight counters stay bounded and consistent with failures present - Batch membership follows enqueue-time context in both directions, reused instances rebind, the accessor doesn't cache a stale nil, and BatchId stays ahead of EnqueueAfterTransactionCommit in the ancestor chain - Conflict-discarded jobs count identically for single and bulk enqueues - Dispatcher batch maintenance is optional and rides the shared timer; ConcurrencyMaintenance keeps its original signature Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
Fixes the callback examples — callbacks are enqueued with their original arguments and use the batch accessor; the documented perform(batch) signature raised ArgumentError. Documents enqueue-time batch membership, counter semantics (retry attempts count toward total_jobs), discard and manual-retry behavior, callback set() timing, batch maintenance and the recurring-task alternative, clearing batches, and a migration snippet for existing installations. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
Found by exercising real batches in a Resque-default dummy app: Batch::EmptyJob inherits the host's ApplicationJob and enqueued to Redis, and callback jobs enqueue via their class's current adapter in the worker process, so they leaked to the app default too. EmptyJob now pins its adapter, and callbacks enqueue directly through SolidQueue::Job so they stay in Solid Queue and atomic with the finishing transaction regardless of adapter configuration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
test_empty_batches_fire_callbacks waited 2 seconds where every other test in the file waits 5, and it's the one batch test that flakes on CI: four empty batches each need an empty job claimed, performed and finished, then a callback job claimed and performed, which doesn't reliably fit 2 seconds on shared runners with SQLite's single writer (the outermost batch's callback misses the window). The only batch-owned failure across the full starburstlabs CI matrix; the remaining red legs are upstream (continuation test on rails_main, concurrency-controls and forked-lifecycle timing flakes) and fail on the base branch too. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
The persistent rails-7.1 + SQLite CI failures (present on the base branch for months) fit one mechanism: removing a batch's tracking row raises inside the finishing transaction (SQLite can't retry busy errors mid-transaction), the batchable rescue swallows it, and the resolved job leaves a live tracking row behind — the batch can never finish, its callback never fires, and it's never clearable. Widening test waits didn't help because the batch wasn't slow, it was stuck. sweep_stalled now repairs the leak: a tracking row whose job is finished or failed is always a leak (they only resolve together in one transaction), so the sweep destroys such rows with no staleness threshold and the destroy re-triggers the completion check. The lifecycle tests run dispatcher maintenance every second so repairs land within their wait windows on busy CI runners; a deterministic regression test constructs both leak flavors directly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
Two fixes from evaluating every CI failure across the matrix: - Edge Rails defers bulk enqueues past all open transactions (enqueue_after_transaction_commit now covers enqueue_all), so perform_all_later inside a batch block pushed its jobs after the block's transaction committed and the batch context was gone: the jobs silently lost their batch, which looked empty and completed with just its empty job. Membership is now seeded at instantiation again — the original design, whose enqueue-timing hazard instinct keeps being right — and rebound at enqueue time when a context is active. Deterministically reproduced and fixed under the rails_main gemfile. - A failed finishing transaction (SQLite can't retry busy errors mid-transaction) left batches empty-but-unfinished, and the sweep's 5-minute staleness threshold kept it from repairing them in any useful window. Completion checks are idempotent and single-winner, so that threshold is now a 3-second grace covering only the started-but-empty-job-still-deferred gap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
Enqueueing callback jobs directly through SolidQueue::Job skipped their before/around/after_enqueue hooks and ignored aborts — the one regression an independent review found in the cumulative diff. The callback chain now wraps the direct enqueue, so hooks run and :abort prevents the enqueue, while callbacks keep their solid_queue pinning and atomicity with the finishing transaction. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
jpcamara
force-pushed
the
batch-review-fixes
branch
from
July 29, 2026 11:52
0f2d7e6 to
cbfa930
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements every finding from the deep review of rails#142, one commit per theme. Each item below maps to a review finding.
Correctness fixes
1. Batches could get permanently stuck when jobs finished before
enqueued_atwas setcheck_completionbails when the batch isn't started yet, and nothing ever re-triggered it. The EmptyJob was also enqueued beforeenqueued_atwas set, so a fast worker could finish it inside the gap and the batch would hang forever. Same window existed forenqueue_after_transaction_commit = trueand for the legacyafter_commitpath.Fix:
start_batchnow setsenqueued_atbefore enqueueing the EmptyJob, and callscheck_completionat the end as a sweep for work that finished during the window. (A process crash between the jobs' commit andstart_batchis still unrecoverable without a supervisor-side sweep — noted as follow-up.)2. Bulk discards orphaned batch executions → stuck batches
Execution.discard_jobsusesdelete_all, bypassing callbacks:SolidQueue::Queue#clear/ Mission Control "discard all" on batch jobs leftbatch_executionsrows pointing at deleted jobs, and the batch could never finish.Fix (lock-free, zero core changes):
Executionis untouched — byte-identical to upstream. Row cleanup is handled declaratively by new FK constraints withon_delete: :cascade(consistent with every other execution table), and completion detection is handled byBatch.sweep_stalled, run automatically by the dispatcher's maintenance timer —ConcurrencyMaintenanceis generalized intoDispatcher::Maintenance, so concurrency and batch maintenance share one timer thread and one worst-case database connection (the dispatcher's resource profile is unchanged from before batches;batch_maintenance: falsedisables the batch routine).ConcurrencyMaintenanceremains as a compatibility shim with its original signature and behavior, so no shipped API is removed. Also documented as a recurring task for setups without a dispatcher. The sweep also closes the crash-before-start_batchwindow and retries completions whose callback enqueueing failed.3. Callbacks could be lost between the finish CAS and callback enqueue
The
update_all(finished_at:)CAS committed immediately; the counts + callbacks happened afterwards in a separate transaction. A crash (or a callback class that fails to deserialize) in between left the batch finished with zero counts and no callbacks, permanently.Fix (CAS preserved): the single-statement CAS stays exactly as designed — losers still resolve with a 0-row
UPDATE, noSELECT FOR UPDATEanywhere — but CAS + counters + callback enqueueing now share one transaction, so they commit atomically. The batch row is only locked by the CAS winner, once per batch lifetime, for the few milliseconds of finalization. Bonus fix: on PostgreSQL READ COMMITTED, a CAS that waits on a concurrent adder re-evaluates itsNOT EXISTSagainst the original snapshot and could finish a batch with freshly committed executions (this existed in the original design too; MySQL's current reads and SQLite's single writer are immune) — a current-snapshot re-check after winning the CAS closes it. Theempty_executionsscope is now arel instead of raw SQL.4. Progress counters double-counted failed jobs
Pre-finish
completed_jobswastotal - pending, which counts failed jobs (their tracking row is also gone), soprogress_percentagecould exceed 100% (3 jobs / 1 pending / 2 failed → 133%). Pre- and post-finish semantics also disagreed.Fix: pre-finish
completed_jobsis nowtotal - pending - failed, consistent with the finished-batch definition.progress_percentageis now bounded.5. Stale-state guard on mutable batches
Batch#enqueueonly checked the in-memoryfinished?, so jobs could be added to a batch another process had just finished.Fix (zero added locking): the existing
total_jobsincrement inBatchExecution.create_all_from_jobsgains anunfinishedcondition and raisesAlreadyFinishedon 0 rows, rolling back the enqueue transaction (jobs and tracking rows included). That statement already touched the batch row for one statement's duration; the WHERE clause is free. Interleaving resolves correctly in both directions: if completion's CAS wins, the adder's increment matches 0 rows and rolls back; if the adder wins, the CAS re-evaluates and sees the new executions.6.
on_conflict: :discardaccounting differed between enqueue pathsperform_latercounted a conflict-discarded job intotal_jobs(as completed);perform_all_laterdidn't count it at all, becausebatch_allran after dispatch on the survivors only.Fix:
prepare_all_for_executionnow creates tracking rows before dispatching; a discarded job's row is cleaned up viadependent: :destroyexactly like the single-job path. Both paths now agree (discarded = counted + completed).API changes
ActiveJob::BatchId#enqueueplusJob.enqueue_allforperform_all_later, which bypasses per-job#enqueue. Capture-before-deferral underenqueue_after_transaction_commitis guaranteed structurally — the include is nested like Rails' own initializer soBatchIddeterministically wraps outsideEnqueueAfterTransactionCommit— and pinned by an ancestor-order test plus the deferred-enqueue lifecycle test (verified on Rails 7.1 and 7.2 locally). This also removes theinitializeoverride and its(*args, **kwargs)signature risk.active_job_batch_idkept, now documented as reserved — it's not read anywhere yet, but it's intentionally early: a provider-agnostic batch identifier (the batch analogue ofsolid_queue_jobs.active_job_id). Now assigned inbefore_createinstead ofafter_initializeso loads don't pay for it, with a comment stating the intent and a test pinning the assignment.BatchId#batchno longer re-queries on every call when the job has no batch.Cleanups
Batch#as_active_job.FailedExecution::Batchable(single-owner concerns live under their owner, likeJob::Batchable), dropped its redundantis_a?(FailedExecution)check, and renamed the callback to say what it does (destroy_job_batch_execution).rescue => eblocks in both batchable concerns toActiveRecord::ActiveRecordErrorand included the error class in the log line — an unexpected error now propagates instead of silently leaving the batch stuck.Docs
def perform(batch), but callbacks are enqueued with their original arguments only and would raiseArgumentError. Examples now use thebatchaccessor, matching the implementation.total_jobs), discard semantics, manual-retry limitation, enqueue-time batch membership, thewait_until:-resolved-at-creation caveat for callback jobs,Batch.clear_finished_in_batches, and a migration snippet for existing installs.Tests
New coverage: stale-instance
AlreadyFinishedvia the lock-free guard, in-flight counters with failures (would have caught #4),start_batchsweep (regression for #1),sweep_stalledfinishing bulk-discarded batches and starting crashed-before-start batches (regression for #2), optional dispatcher batch maintenance, enqueue-time membership in both directions, and single-vs-bulk conflict-discard consistency (regression for rails#6). Also fixed the latentbatch.batch_idcall inBatchWithArgumentsJob(method doesn't exist; never executed).Not implemented, flagged in review as acceptable: re-linking manually retried jobs to their batch (documented instead — doing it for
retry_allwould touch the sharedDispatchingpath).Stress testing (MySQL 8.0 / PG 15, 8 threads, local)
Compared three versions:
base(original batch-poc),locked(an abandoned row-lock design),final(this PR). Simulated worker finishes / concurrent adds directly against the models.* base measured without FKs (its intended schema).
final. Thelockeddesign was measurably worst at both completion (s2) and adding (s3) — the row-lock concern was justified.total_jobsincrement then upgrades to exclusive — 163/200 concurrent adds deadlocked (basecode deadlocked identically with the FK present: it's FK x statement-order, not specific to this PR's logic). Fixed by incrementing before inserting (exclusive-first); after the fix: 0 errors, 2001/2001 jobs accounted, throughput on par with the FK-less schema. PG was immune (FOR KEY SHAREis compatible with non-key updates) but was verified clean too.Design constraints honored throughout: the batch row is never locked on the per-job hot path — completion keeps the original CAS shape (single-statement, once-per-batch), the mutable-batch guard rides an existing statement, and core Solid Queue files are touched only where the PR already touched them (plus the dispatcher's established maintenance seam for the sweeper).
Verified locally on all three adapters (models + unit + integration suites):
(One MySQL run flaked in
AsyncProcessesLifecycleTest— the known-flaky supervisor lifecycle tests from upstream main, unrelated to batch code; green in isolation and on rerun.)🤖 Generated with Claude Code