Skip to content

fix(auto-routing): resume scheduler run initialization without blocking dispatch - #7723

Merged
iscekic merged 1 commit into
mainfrom
fix/auto-routing-scheduler-init-budget
Oct 16, 2026
Merged

iscekic merged 1 commit into
mainfrom
fix/auto-routing-scheduler-init-budget

Conversation

@iscekic

@iscekic iscekic commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

dispatchScheduler initialized uninitialized decider runs before it claimed chunks, and scheduleRunChunks restarted materialization from the run's first lane on every pass. A run large enough that materialization outlasted one Worker invocation hit the wall-time limit on every tick before it wrote the scheduler_runs marker. No tick reached the claim step, so every other run stalled too.

Each materialization batch also ran a completed-case reuse UPDATE ... WHERE run_id = ? AND status = 'pending' AND id IN (...). On stats-less SQLite (production migrations, no ANALYZE) that plans through IX_scheduler_chunks_lane (status=? AND run_id=?), so every 50-chunk batch visited every pending chunk of the run.

Change

  • Claims first. Dispatch claims for initialized runs, then initializes. A pass that completes a run's initialization claims again, so the new run's chunks start without waiting for the next tick.
  • One initializer at a time. Initialization holds its own lease row (id 2 in scheduler_dispatch_lock, created on first use, no migration). Completion-driven dispatchers that find it held skip initialization instead of materializing the same run concurrently.
  • Bounded, resumable materialization. Each scheduleRunChunks call spends at most SCHEDULER_INIT_BUDGET_MS (20s) per run. It returns whether the run is initialized. Every batch commits on its own. The next pass reads COUNT(*)/MAX(chunk) per lane through IX_scheduler_chunks_run and resumes after each lane's written prefix. A lane whose rows are not a prefix is rewritten from chunk 0 under ON CONFLICT DO NOTHING.
  • Reuse per inserted chunk. The chunk INSERT computes completed or pending from case_results primary-key probes of that chunk's own cases. The per-batch UPDATE is gone.

Why a lane-prefix skip instead of a stored cursor

The resume point comes from the rows themselves. It needs no migration, and it holds even if the entry order changes between passes or two initializers overlap. The cost is one index-backed aggregate per pass over the run's chunks. That is a single read, not a write per already-materialized batch.

Invariants kept

  • The scheduler_runs marker commits only with the final batch, after every lane is complete. Only marked runs are dispatched. Partially initialized runs are never claimed.
  • The run's status is still checked before every batch. A run that stops running stops materializing, both within a pass and on later ticks.
  • Fully reused chunks are inserted completed before the marker. The existing trigger-guarded test still passes.

Tests

src/scheduler-initialization.test.ts drives the real dispatchScheduler against real SQLite with production migrations. Each D1 call advances a simulated clock by 250 ms, so a 50-chunk batch costs about 0.5 s. A tick past the 15-minute wall limit is killed mid-flight.

Before (branch base) After
Large run (58 × 3 × 576 = 100,224 chunks) Tick 1 killed at the wall limit with 89,800 chunks. Ticks 2–3 add 0, restarting from the first lane. Never initialized. Initialized after 52 ticks. Each tick adds 1,950 new chunks, and the last adds 774.
Other initialized run's claims 0 on every tick 12 on every tick
Simulated tick duration 900 s, killed 18.5–27.8 s, under the 60 s cron period
Real time 7.5–11.6 s per tick 2.7 s for all 52 ticks

The test fails on the branch base: steady claims on tick 1: expected 0 to be greater than 0.

Also added:

  • An initialization-lease test: a pass claims while another pass holds the lease, then initializes and claims the new run once the lease expires.
  • A stop test: status flips to failed mid-pass, then later ticks run.
  • EXPLAIN QUERY PLAN assertions: the chunk insert probes sqlite_autoindex_case_results_1 on all five key columns and touches no scheduler_chunks index. The lane-progress read uses IX_scheduler_chunks_run.

Verification

On a disposable gcloud VM (services/auto-routing-benchmark):

  • pnpm typecheck passes.
  • pnpm lint (oxlint) reports 0 warnings and 0 errors.
  • pnpm test passes: 41 files, 682 tests.
  • pnpm smoke:scheduler passes.

No deploy, queue, or D1 changes are made by this PR.

…ng dispatch

Dispatch materialized uninitialized runs before it claimed any chunk, and
every pass restarted materialization from the run's first lane. A run too
large to materialize within one Worker invocation never wrote its scheduler
marker, and no pass ever reached the claim step for the other runs.

- Claim for initialized runs first. Initialize afterwards under a dedicated
  lease row so concurrent dispatchers do not materialize the same run, and
  claim again when a pass completes a run's initialization.
- Bound each run's materialization pass to 20 seconds. Every batch commits
  on its own; the next pass reads each lane's written prefix and resumes
  after it. The scheduler_runs marker still commits only with the final
  batch, and a run that stops running stops materializing.
- Decide completed-case reuse per inserted chunk through case_results
  primary-key probes. The previous per-batch UPDATE planned through the
  (status, run_id) lane index on stats-less SQLite and visited every pending
  chunk of the run for each batch.
- Add a real-SQLite regression test on production migrations: a 100k-chunk
  run initializes over bounded ticks while another run is claimed on every
  tick.
@iscekic iscekic self-assigned this Oct 16, 2026
@kilo-code-bot

kilo-code-bot Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Files Reviewed (10 files)
  • services/auto-routing-benchmark/README.md
  • services/auto-routing-benchmark/src/admin.test.ts
  • services/auto-routing-benchmark/src/db-schema.ts
  • services/auto-routing-benchmark/src/profile-runs.test.ts
  • services/auto-routing-benchmark/src/run-dead-letter.test.ts
  • services/auto-routing-benchmark/src/run.ts
  • services/auto-routing-benchmark/src/scheduler-initialization.test.ts
  • services/auto-routing-benchmark/src/scheduler.test.ts
  • services/auto-routing-benchmark/src/scheduler.ts
  • services/auto-routing-benchmark/test/sqlite-d1.ts

Verified: the per-lane written-prefix resume (written === last + 1) is sound because batches commit contiguous slices of strictly ascending chunk ids, with the else 0 rewrite under ON CONFLICT DO NOTHING covering any gap shape; the scheduler_runs marker is appended only in the terminal batch after every lane's resumeAt..chunks-1 range is covered, so it cannot commit with missing chunks. Claim-before-initialize cannot double-claim (lane heads require the marker; claims flip pending→queued), the init lease (row 2) is disjoint from the dispatch lease (row 1), and the ?1..?8 reuse and CASE-based reuse probe match the removed per-batch UPDATE (unobservable gap only, since reuseDeciderCaseResults runs before initialization). No memory leaks, no mishandled scheduleRunChunks boolean returns, and no markdown image markup issues.


Reviewed by deepseek-v4.1-flash · Input: 120K · Output: 50.1K · Cached: 2M

Review guidance: REVIEW.md from base branch main

@iscekic
iscekic enabled auto-merge October 16, 2026 10:54
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