Skip to content

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

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

Batch support: fix completion races, accounting, and API polish#3
jpcamara wants to merge 17 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 #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

@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
jpcamara force-pushed the batch-review-fixes branch from dd88f13 to ea4a85d Compare July 23, 2026 12:55
@rosa

rosa commented Jul 27, 2026

Copy link
Copy Markdown

@jpcamara do you need to test this one in your app before you can merge it to the original PR?

jpcamara and others added 12 commits July 29, 2026 07:51
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
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.

2 participants