Batch support: fix completion races, accounting, and API polish#3
Open
jpcamara wants to merge 22 commits into
Open
Batch support: fix completion races, accounting, and API polish#3jpcamara wants to merge 22 commits into
jpcamara wants to merge 22 commits into
Conversation
- Set enqueued_at before enqueueing the EmptyJob and sweep for completion at the end of start_batch, so batches whose jobs finish before the batch is marked started can't hang forever - Fold the finish CAS into check_completion's row lock so finishing, counter updates and callback enqueueing commit atomically; a crash can no longer leave a finished batch with no callbacks and zero counts - Re-check finished state under the batch row lock in Batch#enqueue so jobs can't be added to a batch a worker is concurrently finishing - Clean up batch executions in bulk discards (which delete jobs without callbacks) and check completion for affected batches afterwards - Stop double counting failed jobs in in-flight counters: completed_jobs now excludes failures, keeping progress_percentage bounded at 100% - Track batch membership before dispatch in prepare_all_for_execution so conflict-discarded jobs are accounted the same as in the single path - Drop the dead as_active_job helper and the redundant FailedExecution type check; narrow batch progress rescues to ActiveRecord errors Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
Jobs now 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. ActiveJob#enqueue runs inside the batch block even for deferred enqueues (enqueue_after_transaction_commit), and Job.enqueue_all captures the batch id itself for bulk enqueues, which bypass per-job enqueue. This also removes the initialize override and memoizes a nil batch lookup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
Batch executions get on_delete: :cascade foreign keys like every other execution table, as a DB-level backstop against orphaned tracking rows. The active_job_batch_id column was written but never read. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
- start_batch sweeping up work that finished before the batch started - Concurrently finished batches rejecting new jobs via the row lock - In-flight counters with failures staying bounded and consistent - Bulk discards completing the batch instead of leaving it pending - Enqueue-time batch membership in both directions - Conflict-discarded jobs counting the same for single and bulk enqueues - Explicit metadata kwarg; fix latent batch.batch_id call in test job Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
Callback examples showed perform(batch), but callbacks are enqueued with their original arguments only and access the batch via the accessor. Document counter semantics, discard and manual-retry behavior, enqueue-time membership, callback set() timing, 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
|
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. |
It's intentionally early rather than vestigial: a provider-agnostic batch identifier, the batch analogue of solid_queue_jobs.active_job_id, assigned from day one so existing batches already carry one when it's consumed. Documented as such, assigned in before_create rather than after_initialize so reads don't pay for it, and pinned with a test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
Keeps the original CAS design: batches finish via a single-statement update where exactly one finisher matches, and the batch row is never locked outside that once-per-batch moment. - Fold counters and callback enqueueing into the CAS transaction so a crash can't leave a finished batch without callbacks; losers still resolve with a 0-row update and no SELECT FOR UPDATE exists anywhere - Re-check executions with a current snapshot after winning the CAS: on PostgreSQL READ COMMITTED a blocked CAS re-evaluates NOT EXISTS against its original snapshot and could finish a batch that had just gained executions from a concurrent adder - Replace the enqueue row lock with an `unfinished` condition on the existing total_jobs increment: 0 rows means the batch finished concurrently, raising AlreadyFinished and rolling back the adder - Restore Execution to upstream, byte for byte: bulk discards rely on the foreign key cascade for tracking-row cleanup, and completion is picked up by Batch.sweep_stalled, which also covers processes that crash before starting their batch and completions whose callback enqueueing failed Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
Adds BatchMaintenance alongside ConcurrencyMaintenance: the dispatcher runs Batch.sweep_stalled every batch_maintenance_interval (default 300s), disableable with batch_maintenance: false. Also documents running the sweep as a recurring task for setups without a dispatcher. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
Found by stress testing 200 concurrent adds to one batch on MySQL: inserting a batch execution takes a shared lock on the batch row (foreign key validation), and the subsequent total_jobs increment upgrades it to exclusive. Two concurrent adders both holding shared and wanting exclusive deadlock; 163/200 adds failed with ActiveRecord::Deadlocked. Taking the exclusive lock first (increment, then insert) serializes adders cleanly: 0 errors at throughput on par with the FK-less schema, and the AlreadyFinished guard now fires before any insert work. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
Eight threads race check_completion on a completion-candidate batch; every interleaving must produce exactly one finish and one set of callbacks. Verified the test has teeth: removing the CAS's `unfinished` condition makes it fail with duplicate callbacks. The intent lives in the test so the implementation comments can be trimmed without losing the record of which lines are load-bearing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
- Concurrent adders to one batch: catches reverting the increment-before-insert order, which deadlocks on MySQL (verified: the reverted order fails with 33/40 adds deadlocked), and asserts exact accounting under contention on every adapter - A completion check racing a concurrent adder: reproduces the PostgreSQL READ COMMITTED stale-snapshot interleaving deterministically (verified: removing the post-CAS execution re-check wrongly finishes the batch) Every load-bearing line in batch completion now has a test that fails if it's removed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
Mechanism narration is gone; what remains is only what a failing test can't say: one-line pointers to remote coupling, the named platform gotchas (MySQL lock-upgrade deadlock, PostgreSQL stale-snapshot re-check), and the reserved-column intent. The full reasoning lives in the guarding tests and git history. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
Generalizes ConcurrencyMaintenance into Dispatcher::Maintenance: both routines run on the existing timer at concurrency_maintenance_interval, individually toggleable via the concurrency_maintenance and batch_maintenance options. Batch support no longer adds a maintenance thread or a worst-case database connection to the dispatcher — its resource profile is unchanged from before batches. Drops the batch_maintenance_interval option introduced earlier in this branch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
Dispatcher::ConcurrencyMaintenance shipped in released versions, so the maintenance consolidation preserves it: same name, same signature, same behavior (concurrency routines only), now implemented on the shared Dispatcher::Maintenance timer. Pinned with a test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
Replaces the arel NOT EXISTS construction with where.not(id: ...), which reads as plain Rails and produces the same anti-join plan. Notably NOT where.missing: joined relations make update_all on PostgreSQL move the scope's conditions into an IN subquery, so blocked finishers no longer re-check `unfinished` on wake-up and the completion CAS loses its exactly-once guarantee — the concurrent completion race test fails with 8 callbacks instead of 1. The scope must stay join-free. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
From a four-angle review (reuse, simplification, efficiency, altitude) of the branch: - progress_percentage computes (total - pending) / total directly: the failed count cancelled algebraically, so 3 COUNT queries become 1 - Per-job-finish completion checks select only the columns the guards need, skipping the serialized callback and metadata TEXT columns - finalize_completion uses update_columns, keeping the record in sync so enqueue_callback_jobs branches on failed_at? without a threaded param - jobs.failed.count reuses the existing Job.failed scope in both count sites; AlreadyFinished carries a default message raised bare from both guard sites - ActiveJob::BatchId#serialize only adds batch keys when set, saving dead JSON on every non-batch job row (pre-existing, fixed in passing) - Execution::Batchable inlined into FailedExecution, its only includer - start_batch is public now that sweep_stalled is a supported entry point; redundant .enqueued dropped before the enqueued_at range - Maintenance requires both routine kwargs (the shim is the single encoding of the legacy profile) and owns its metadata fragment; the synthesized always-equal batch_maintenance_interval metadata key becomes a batch_maintenance boolean - enqueue_all hoists the batch context lookup out of the per-job loop and drops a contradictory respond_to? guard - Rails.logger -> SolidQueue.logger in batch rescue paths; redundant test teardowns removed; duplicate dispatcher test folded into the existing one; counters test uses the production failed_with path Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
Batch.enqueue collects arbitrary keyword arguments as metadata again, as the README describes: Batch.enqueue(on_finish: SomeJob, user_id: 123). The explicit metadata: kwarg introduced earlier in this branch traded that ergonomic away to make reserved-kwarg typos raise; keeping the documented interface is the design call here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
The batch feature keeps core models additive: each gets batch behavior via a single Batchable include, with the logic segregated in the concern (Job::Batchable follows the same pattern with one includer). Inlining it into FailedExecution traded that structure for file count. Keeps the simplified form: intention-named callback, narrowed rescue, SolidQueue.logger. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
- finish_batch, sweep_stalled_batches and batch_progress_error events with LogSubscriber entries, matching how the rest of Solid Queue is observed; batch progress rescues emit events instead of logging directly (the only direct logger call sites in the gem) - Batch#status returns symbols like Job#status - BatchExecution lays out class methods before private instance methods like the other models - Comments compressed to sparse single-line breadcrumbs, matching house density; the reasoning stays in the guarding tests and git history Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
Execution::Batchable implied execution-family scope, but the concern is FailedExecution-specific by nature: failed executions are the only terminal execution type, so they're the only place batch tracking ends. Single-owner concerns live under their owner (Job::Batchable, Batch::Trackable); the Execution namespace is for shared ones like Dispatching. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
Three P2s from an independent Codex review of the branch: - start_batch is single-winner: the enqueued_at transition is a conditional update and the empty job enqueues in the same transaction, so concurrent sweepers can't inflate total_jobs with duplicate empty jobs or trip AlreadyFinished racing each other - Reused job instances join the currently active batch: the batch context takes precedence at enqueue time, falling back to prior membership so retries stay in their batch (previously ||= pinned the first batch forever) - The batch accessor no longer memoizes a stale nil: lookups are keyed by the current batch id, so a pre-enqueue read doesn't hide the batch assigned later Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
Enqueue-time batch capture only works if BatchId sits outside EnqueueAfterTransactionCommit in the ancestor chain: otherwise capture runs after the deferral, when the batch block has already exited, and jobs silently miss their batch. That ordering previously fell out of incidental on_load timing; nesting the include like Rails' own enqueue_after_transaction_commit initializer makes it deterministic, and an ancestor-order test pins it on Rails 7.2+. This was the original reason batch capture lived in initialize; with the ordering guaranteed and tested, enqueue-time capture keeps its semantics without inheriting that hazard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz
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 rails#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
https://claude.ai/code/session_01P39PSB74nP3a8B2F7Z5xvz