Skip to content

Batch support: fix completion races, accounting, and API polish#3

Open
jpcamara wants to merge 22 commits into
batch-pocfrom
batch-review-fixes
Open

Batch support: fix completion races, accounting, and API polish#3
jpcamara wants to merge 22 commits into
batch-pocfrom
batch-review-fixes

Conversation

@jpcamara

@jpcamara jpcamara commented Jul 22, 2026

Copy link
Copy Markdown
Owner

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_at was set

check_completion bails when the batch isn't started yet, and nothing ever re-triggered it. The EmptyJob was also enqueued before enqueued_at was set, so a fast worker could finish it inside the gap and the batch would hang forever. Same window existed for enqueue_after_transaction_commit = true and for the legacy after_commit path.

Fix: start_batch now sets enqueued_at before enqueueing the EmptyJob, and calls check_completion at the end as a sweep for work that finished during the window. (A process crash between the jobs' commit and start_batch is still unrecoverable without a supervisor-side sweep — noted as follow-up.)

2. Bulk discards orphaned batch executions → stuck batches

Execution.discard_jobs uses delete_all, bypassing callbacks: SolidQueue::Queue#clear / Mission Control "discard all" on batch jobs left batch_executions rows pointing at deleted jobs, and the batch could never finish.

Fix (lock-free, zero core changes): Execution is untouched — byte-identical to upstream. Row cleanup is handled declaratively by new FK constraints with on_delete: :cascade (consistent with every other execution table), and completion detection is handled by Batch.sweep_stalled, run automatically by the dispatcher's maintenance timer — ConcurrencyMaintenance is generalized into Dispatcher::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: false disables the batch routine). ConcurrencyMaintenance remains 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_batch window 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, no SELECT FOR UPDATE anywhere — 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 its NOT EXISTS against 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. The empty_executions scope is now arel instead of raw SQL.

4. Progress counters double-counted failed jobs

Pre-finish completed_jobs was total - pending, which counts failed jobs (their tracking row is also gone), so progress_percentage could exceed 100% (3 jobs / 1 pending / 2 failed → 133%). Pre- and post-finish semantics also disagreed.

Fix: pre-finish completed_jobs is now total - pending - failed, consistent with the finished-batch definition. progress_percentage is now bounded.

5. Stale-state guard on mutable batches

Batch#enqueue only checked the in-memory finished?, so jobs could be added to a batch another process had just finished.

Fix (zero added locking): the existing total_jobs increment in BatchExecution.create_all_from_jobs gains an unfinished condition and raises AlreadyFinished on 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: :discard accounting differed between enqueue paths

perform_later counted a conflict-discarded job in total_jobs (as completed); perform_all_later didn't count it at all, because batch_all ran after dispatch on the survivors only.

Fix: prepare_all_for_execution now creates tracking rows before dispatching; a discarded job's row is cleaned up via dependent: :destroy exactly like the single-job path. Both paths now agree (discarded = counted + completed).

API changes

  • Batch membership is captured at enqueue-time, not initialize-time. A job instantiated outside the block and enqueued inside joins the batch, and vice versa. Implemented in ActiveJob::BatchId#enqueue plus Job.enqueue_all for perform_all_later, which bypasses per-job #enqueue. Capture-before-deferral under enqueue_after_transaction_commit is guaranteed structurally — the include is nested like Rails' own initializer so BatchId deterministically wraps outside EnqueueAfterTransactionCommit — 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 the initialize override and its (*args, **kwargs) signature risk.
  • active_job_batch_id kept, now documented as reserved — it's not read anywhere yet, but it's intentionally early: a provider-agnostic batch identifier (the batch analogue of solid_queue_jobs.active_job_id). Now assigned in before_create instead of after_initialize so loads don't pay for it, with a comment stating the intent and a test pinning the assignment.
  • BatchId#batch no longer re-queries on every call when the job has no batch.

Cleanups

  • Removed dead Batch#as_active_job.
  • The batchable execution concern moved to FailedExecution::Batchable (single-owner concerns live under their owner, like Job::Batchable), dropped its redundant is_a?(FailedExecution) check, and renamed the callback to say what it does (destroy_job_batch_execution).
  • Narrowed the rescue => e blocks in both batchable concerns to ActiveRecord::ActiveRecordError and included the error class in the log line — an unexpected error now propagates instead of silently leaving the batch stuck.

Docs

  • Fixed the README callback examples — they showed def perform(batch), but callbacks are enqueued with their original arguments only and would raise ArgumentError. Examples now use the batch accessor, matching the implementation.
  • Documented: counter semantics (every retry attempt counts toward total_jobs), discard semantics, manual-retry limitation, enqueue-time batch membership, the wait_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 AlreadyFinished via the lock-free guard, in-flight counters with failures (would have caught rails#4), start_batch sweep (regression for #1), sweep_stalled finishing 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 latent batch.batch_id call in BatchWithArgumentsJob (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_all would touch the shared Dispatching path).

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.

Scenario base locked final
s0: 5,000 non-batch job finishes 1,017/s 1,009/s 1,176/s
s1: 5,000 finishes across 1,000 batches 551/s 634/s 692/s
s2: 2,000 finishes, ONE batch (completion herd) 730/s 630/s 863/s
s3: 200 concurrent adds to ONE running batch 255/s* 147/s 218/s

* base measured without FKs (its intended schema).

  • No throughput regression from the lock-free rework anywhere; the completion herd (s2) is actually fastest on final. The locked design was measurably worst at both completion (s2) and adding (s3) — the row-lock concern was justified.
  • Stress testing caught a real deadlock: with the new FK, execution inserts take a shared lock on the batch row (FK validation) and the total_jobs increment then upgrades to exclusive — 163/200 concurrent adds deadlocked (base code 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 SHARE is compatible with non-key updates) but was verified clean too.
  • Sweep cost: 10,000 unfinished batches (1,000 stalled) swept in ~4s, most of it finishing the 1,000 — fine at the 300s default cadence.

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):

Adapter Result
SQLite 252/252 green
PostgreSQL 15 252/252 green
MySQL 8.0 252/252 green

(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

jpcamara and others added 5 commits July 21, 2026 23:15
- 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
@cursor

cursor Bot commented Jul 22, 2026

Copy link
Copy Markdown

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 and others added 17 commits July 22, 2026 08:30
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant