Commit915e812dRecorded1 Sep 2026Repositorykiln

scheduler: claim a run when dequeuing it, not after

Message

dequeue-run selected a pending run and returned it. The row stayed pending until the goroutine run-loop spawns wrote starting, so everything between those two points was a window in which the same row was dispatchable again -- and count-active-runs, which counts starting/running, could not see the dispatch either, so max_concurrent did not bound it.

The cooperative scheduler makes that window arbitrarily wide. sigil-sqlite forces busy_timeout=0 in its native layer and retries in Scheme with a sleep, which SUSPENDS the fiber. So while any other connection holds the SQLite write lock, the goroutine's claim-write suspends, the drain loop resumes, re-selects the same pending row, and spawns another goroutine for it. No backoff, no bound.

MEASURED, with the mechanism isolated from anything that found it: six queued runs, max_concurrent: 2, and one external connection holding the write lock for eight seconds via BEGIN IMMEDIATE.

  before   66 x  Starting node run=5      controller alive: NO
            1 x  Starting node run=1..4   health: 000
  after      1 x  Starting node run=1..4  controller alive: yes, health 200

The same measurement at a twenty-second hold -- past sigil-sqlite's five-second busy budget, so the claim itself raises rather than waiting -- also stays at one dispatch per run with the controller alive: the raise is caught by run-loop's guard, logged, and retried on the next one-second tick, which is a bounded retry rather than a spin.

This is not a test-only fault. Any second writer on the kiln database -- a kiln CLI subcommand that writes, a backup, a maintenance script, or a slow write on a loaded controller -- can livelock the live controller and kill it. It has been latent since the run loop was written; kiln's own CI surfaced it on the first outing of a gate that seeds 700 rows.

THE LOAD-BEARING PROPERTY IS NOT THE SQL, IT IS WHERE THE CLAIM HAPPENS. UPDATE ... WHERE status = 'pending' RETURNING id is one statement so that no second statement's changes() can be clobbered by another fiber, which is worth having; but what closes the window is that the claim completes in the DEQUEUING fiber before the goroutine is spawned. Any suspension inside the goroutine -- a DB lock, worker provisioning, an SSH round trip -- is now harmless, not just this one.

run-loop's existing comment already described this shape and guarded only the EXCEPTION route ("If run-job! crashed before marking the run terminal ... Otherwise the run stays 'pending', gets re-dequeued, and respawns in a tight CPU spin"). The route found here raises nothing, which is why 247 log lines of the dying controller held zero ERROR lines.

No new stranding class: a controller that dies between the claim and the execution leaves the row starting, and init-database! already errors orphaned starting rows on the next startup, for exactly this reason.

The regression test asks the question the drain loop asks -- can a second dequeue-run hand back the same run -- rather than asserting the row's new status, because the second call is what actually went wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0183EGLt1MaPpvhHCSZD4ma9

Changed
 src/kiln/db.sgl         | 41 +++++++++++++++++++++++++++++++++++++++++
 src/kiln/scheduler.sgl  | 24 ++++++++++++++++++++++--
 test/test-scheduler.sgl | 34 ++++++++++++++++++++++++++++++++++
 3 files changed, 97 insertions(+), 2 deletions(-)
Diff
src/kiln/db.sglmodified
@@ -45,6 +45,7 @@
45
db-get-run-by-name
46
db-list-runs
47
db-update-run-status!
+48
db-claim-pending-run!
49
db-update-run-server!
50
db-next-run-number
51
db-runs-for-sha
@@ -600,6 +601,46 @@
601
(else
602
(sqlite-run db "UPDATE runs SET status = ? WHERE id = ?" status id))))
603
+604
;;; CLAIM a pending run, atomically. #t if this caller got it.
+605
;;;
+606
;;; WHY THIS IS ONE STATEMENT AND NOT A READ FOLLOWED BY A WRITE. The
+607
;;; dispatcher used to select a pending run and leave it `pending` until
+608
;;; the goroutine it spawned wrote `starting`. Everything between those two
+609
;;; points is a window in which the run is dispatchable again, and the
+610
;;; cooperative scheduler makes that window arbitrarily wide: the status
+611
;;; write goes through sigil-sqlite's busy retry, which SLEEPS -- suspends
+612
;;; the fiber -- whenever another connection holds the SQLite write lock.
+613
;;; The drain loop then resumes, re-selects the same still-`pending` row,
+614
;;; and spawns another goroutine for it, with no backoff and no bound.
+615
;;;
+616
;;; Measured on 2026-09-01: an external writer holding the write lock for
+617
;;; eight seconds produced 66 dispatches of one run against one dispatch
+618
;;; each for its five neighbours, under `max_concurrent: 2`, and the
+619
;;; controller was dead by the end of it. See
+620
;;; [[topics/kiln-dequeue-claim-race]].
+621
;;;
+622
;;; `WHERE status = 'pending'` is what makes it a claim rather than a
+623
;;; write: the row moves out of the dispatchable set in the same statement
+624
;;; that decides it should. RETURNING reports whether this caller was the
+625
;;; one that moved it, without a second statement whose `changes()` another
+626
;;; fiber could have clobbered in between.
+627
;;;
+628
;;; It stamps `started_at` exactly as `db-update-run-status!` does for
+629
;;; `starting`, so a claimed run is indistinguishable from one the runner
+630
;;; had already begun -- which it is about to be. A controller that dies
+631
;;; between the claim and the execution leaves the row `starting`, and
+632
;;; `init-database!` already errors those on the next startup for exactly
+633
;;; this reason; this adds no new stranding class.
+634
(define (db-claim-pending-run! db id)
+635
(let ((row (sqlite-query-row db
+636
"UPDATE runs
+637
SET status = 'starting',
+638
started_at = COALESCE(started_at, datetime('now'))
+639
WHERE id = ? AND status = 'pending'
+640
RETURNING id"
+641
id)))
+642
(and row #t)))
+643
644
;;; Update a run's Hetzner server ID.
645
(define (db-update-run-server! db id server-id)
646
(sqlite-run db "UPDATE runs SET server_id = ? WHERE id = ?" server-id id))
src/kiln/scheduler.sglmodified
@@ -475,13 +475,28 @@
475
;; Queue Management
476
;; ============================================================
477
−478
;;; Get the next pending run to execute.
+478
;;; Get the next pending run to execute, AND CLAIM IT.
479
;;;
480
;;; Returns the run record or #f if no runs are pending or the
481
;;; concurrency limit is reached. Group-aware: a pending node run is
482
;;; skipped (and the next candidate considered) when its group already
483
;;; has `max-concurrent` node runs active, so one big group can't
484
;;; starve everything while still honoring the global cap.
+485
;;;
+486
;;; IT CLAIMS, AND THAT IS PART OF THE CONTRACT, NOT AN OPTIMISATION.
+487
;;; A run this returns has already moved to `starting`; a second call
+488
;;; cannot return it, and `count-active-runs` counts it from this moment
+489
;;; rather than from whenever the spawned goroutine gets around to writing
+490
;;; its status. Selecting without claiming left a window in which the same
+491
;;; `pending` row was dispatched repeatedly -- unbounded, with no backoff,
+492
;;; and past `max-concurrent`, because the concurrency count could not see
+493
;;; the dispatches either. Full mechanism and the measurement in
+494
;;; `db-claim-pending-run!`.
+495
;;;
+496
;;; The RECORD returned still reads `status = "pending"`: it is the row as
+497
;;; it was selected, and callers use its id / group_id / plan_form, never
+498
;;; its status. Returning the pre-claim row keeps the claim invisible to
+499
;;; everything except the queue.
500
(define (dequeue-run db max-concurrent)
501
(let ((active (count-active-runs db)))
502
(if (>= active max-concurrent)
@@ -510,7 +525,12 @@
525
(>= (db-active-node-count-for-group db gid)
526
max-concurrent))
527
(loop (cdr cs))
−513
run)))))))))
+528
;; A candidate that is no longer `pending` by the time
+529
;; we get here was taken by someone else (or cancelled
+530
;; mid-scan): move on rather than dispatch it.
+531
(if (db-claim-pending-run! db (assoc-ref 'id run))
+532
run
+533
(loop (cdr cs))))))))))))
534
535
;;; Count the number of currently running jobs (node runs included).
536
(define (count-active-runs db)
test/test-scheduler.sglmodified
@@ -234,10 +234,44 @@
234
235
(let ((run (dequeue-run db 3)))
236
(assert-true (pair? run))
+237
;; The RECORD is the row as it was selected, so it still reads
+238
;; "pending". The DATABASE does not -- see the claim test below.
239
(assert-equal "pending" (assoc-ref 'status run))
240
(assert-equal "sigil-linux" (assoc-ref 'job_name run)))
241
(sqlite-close db)))
242
+243
;; THE REGRESSION TEST FOR THE DISPATCH RACE.
+244
;;
+245
;; Before `dequeue-run` claimed, this second call returned the SAME run: the
+246
;; row stayed `pending` until the goroutine the dispatcher spawned got round
+247
;; to writing `starting`, and under write-lock contention that write suspends
+248
;; the fiber and lets the drain loop dispatch the row again -- 66 times for
+249
;; one run in the measurement that found this, ending with a dead controller.
+250
;;
+251
;; It is written as "the second call must not hand back the first run"
+252
;; rather than "the row is now starting", because the second call is what the
+253
;; drain loop actually does and what actually went wrong.
+254
(test "claims the run, so a second dequeue cannot return it"
+255
(let ((db (make-test-env)))
+256
(db-create-job! db "sigil-linux" "codeberg/daviwil/sigil" "main")
+257
(write-job-script! "sigil-linux.run" "#!/bin/sh\necho test\n")
+258
+259
(schedule-runs! db test-jobs-dir "codeberg/daviwil/sigil"
+260
"refs/heads/main" "sha-claim" 'push #f #f)
+261
+262
(let ((first (dequeue-run db 3)))
+263
(assert-true (pair? first))
+264
;; The claim is visible in the database, with started_at stamped
+265
;; exactly as the `starting` transition stamps it.
+266
(let ((row (db-get-run db (assoc-ref 'id first))))
+267
(assert-equal "starting" (assoc-ref 'status row))
+268
(assert-true (string? (assoc-ref 'started_at row))))
+269
;; And the queue no longer offers it. With max-concurrent 3 the
+270
;; concurrency cap is nowhere near binding, so a #f here is the claim
+271
;; talking and nothing else.
+272
(assert-false (dequeue-run db 3)))
+273
(sqlite-close db)))
+274
275
(test "returns #f when at concurrency limit"
276
(let ((db (make-test-env)))
277
(db-create-job! db "sigil-linux" "codeberg/daviwil/sigil" "main")