AtlatestRepositorykiln
1
(import (sigil test)2
(sigil sqlite)3
(sigil fs)4
(kiln db)5
(kiln scheduler)6
;; For the fail-fast group: these tests compile a REAL .kiln file7
;; and schedule its plan, rather than hand-writing `plan_form`8
;; strings. A hand-written plan_form would let the sweep pass while9
;; `fail-fast:` never actually reached the stored datum -- the gate10
;; would be measuring a fixture instead of the shipped path11
;; (grammar -> plan -> plan_form -> scheduler).12
(sigil core)13
(sigil string)14
(kiln nodeexec)15
(kiln lang errors)16
(kiln lang lint))18
;; ============================================================19
;; Helpers20
;; ============================================================22
(define test-jobs-dir #f)24
(define (make-test-env)25
(let ((db (sqlite-open ":memory:"))26
(dir (make-temp-directory)))27
(init-database! db)28
(set! test-jobs-dir dir)29
;; Create a repo30
(db-create-repo! db "codeberg/daviwil/sigil"31
"https://codeberg.org/daviwil/sigil.git"32
"whsec_test")33
db))35
(define (write-job-script! name content)36
(write-file-string (string-append test-jobs-dir "/" name) content))38
;; ============================================================39
;; Run Scheduling40
;; ============================================================42
(test-group "schedule-runs!"43
(test "creates runs for all jobs watching a repo"44
(let ((db (make-test-env)))45
(db-create-job! db "sigil-linux" "codeberg/daviwil/sigil" "main")46
(db-create-job! db "sigil-openbsd" "codeberg/daviwil/sigil" "main")47
(write-job-script! "sigil-linux.run" "#!/bin/sh\necho test\n")48
(write-job-script! "sigil-openbsd.run" "#!/bin/sh\necho test\n")50
(let ((ids (schedule-runs! db test-jobs-dir51
"codeberg/daviwil/sigil"52
"refs/heads/main" "sha-abc"53
'push #f #f)))54
(assert-equal 2 (length ids))55
;; Both should be pending56
(let ((r1 (db-get-run db (car ids)))57
(r2 (db-get-run db (cadr ids))))58
(assert-equal "pending" (assoc-ref 'status r1))59
(assert-equal "pending" (assoc-ref 'status r2))))60
(sqlite-close db)))62
(test "jobs with after dependencies get waiting status"63
(let ((db (make-test-env)))64
(db-create-job! db "sigil-linux" "codeberg/daviwil/sigil" "main")65
(db-create-job! db "sigil-release" "codeberg/daviwil/sigil" "main")66
(write-job-script! "sigil-linux.run" "#!/bin/sh\necho build\n")67
(write-job-script! "sigil-release.run"68
(string-append "#!/bin/sh\n"69
"#kiln: after sigil-linux\n"70
"echo release\n"))72
(let ((ids (schedule-runs! db test-jobs-dir73
"codeberg/daviwil/sigil"74
"refs/heads/main" "sha-def"75
'push #f #f)))76
(assert-equal 2 (length ids))77
;; Find the release run - it should be waiting78
(let loop ((ids ids))79
(when (pair? ids)80
(let ((run (db-get-run db (car ids))))81
(if (string=? "sigil-release" (assoc-ref 'job_name run))82
(assert-equal "waiting" (assoc-ref 'status run))83
(assert-equal "pending" (assoc-ref 'status run))))84
(loop (cdr ids)))))85
(sqlite-close db)))87
(test "fork PRs create untrusted runs"88
(let ((db (make-test-env)))89
(db-create-job! db "sigil-linux" "codeberg/daviwil/sigil" "main")90
(write-job-script! "sigil-linux.run" "#!/bin/sh\necho test\n")92
(let* ((ids (schedule-runs! db test-jobs-dir93
"codeberg/daviwil/sigil"94
"refs/pull/42/head" "pr-sha"95
'pull-request #t 42))96
(run (db-get-run db (car ids))))97
(assert-equal 0 (assoc-ref 'trusted run))98
(assert-equal "pull-request" (assoc-ref 'trigger_type run))99
(assert-equal 42 (assoc-ref 'pr_number run)))100
(sqlite-close db)))102
(test "push creates trusted runs"103
(let ((db (make-test-env)))104
(db-create-job! db "sigil-linux" "codeberg/daviwil/sigil" "main")105
(write-job-script! "sigil-linux.run" "#!/bin/sh\necho test\n")107
(let* ((ids (schedule-runs! db test-jobs-dir108
"codeberg/daviwil/sigil"109
"refs/heads/main" "push-sha"110
'push #f #f))111
(run (db-get-run db (car ids))))112
(assert-equal 1 (assoc-ref 'trusted run)))113
(sqlite-close db)))115
(test "increments run numbers per job"116
(let ((db (make-test-env)))117
(db-create-job! db "sigil-linux" "codeberg/daviwil/sigil" "main")118
(write-job-script! "sigil-linux.run" "#!/bin/sh\necho test\n")120
(schedule-runs! db test-jobs-dir "codeberg/daviwil/sigil"121
"refs/heads/main" "sha-1" 'push #f #f)122
(schedule-runs! db test-jobs-dir "codeberg/daviwil/sigil"123
"refs/heads/main" "sha-2" 'push #f #f)125
(let ((runs (db-list-runs db '(job . "sigil-linux"))))126
(assert-equal 2 (length runs))127
;; Most recent first (ORDER BY id DESC)128
(assert-equal 2 (assoc-ref 'number (car runs)))129
(assert-equal 1 (assoc-ref 'number (cadr runs))))130
(sqlite-close db))))132
;; ============================================================133
;; Fan-In134
;; ============================================================136
(test-group "check-fan-in!"137
(test "transitions waiting to pending when all predecessors pass"138
(let ((db (make-test-env)))139
(db-create-job! db "build-linux" "codeberg/daviwil/sigil" "main")140
(db-create-job! db "build-arm" "codeberg/daviwil/sigil" "main")141
(db-create-job! db "release" "codeberg/daviwil/sigil" "main")142
(write-job-script! "build-linux.run" "#!/bin/sh\necho build\n")143
(write-job-script! "build-arm.run" "#!/bin/sh\necho build\n")144
(write-job-script! "release.run"145
(string-append "#!/bin/sh\n"146
"#kiln: after build-linux build-arm\n"147
"echo release\n"))149
;; Schedule all runs150
(let ((ids (schedule-runs! db test-jobs-dir151
"codeberg/daviwil/sigil"152
"refs/heads/main" "sha-fan"153
'push #f #f)))154
;; Mark both builds as passed155
(for-each156
(lambda (id)157
(let ((run (db-get-run db id)))158
(when (not (string=? "release" (assoc-ref 'job_name run)))159
(db-update-run-status! db id "passed"))))160
ids)162
;; Check fan-in163
(let ((newly-pending (check-fan-in! db test-jobs-dir "sha-fan")))164
(assert-equal 1 (length newly-pending))165
;; The release run should now be pending166
(let ((release-run (db-get-run db (car newly-pending))))167
(assert-equal "pending" (assoc-ref 'status release-run))168
(assert-equal "release" (assoc-ref 'job_name release-run)))))169
(sqlite-close db)))171
(test "transitions waiting to skipped when predecessor fails"172
(let ((db (make-test-env)))173
(db-create-job! db "build-linux" "codeberg/daviwil/sigil" "main")174
(db-create-job! db "release" "codeberg/daviwil/sigil" "main")175
(write-job-script! "build-linux.run" "#!/bin/sh\necho build\n")176
(write-job-script! "release.run"177
(string-append "#!/bin/sh\n"178
"#kiln: after build-linux\n"179
"echo release\n"))181
(let ((ids (schedule-runs! db test-jobs-dir182
"codeberg/daviwil/sigil"183
"refs/heads/main" "sha-fail"184
'push #f #f)))185
;; Mark build as failed186
(for-each187
(lambda (id)188
(let ((run (db-get-run db id)))189
(when (string=? "build-linux" (assoc-ref 'job_name run))190
(db-update-run-status! db id "failed"))))191
ids)193
;; Check fan-in194
(let ((newly-pending (check-fan-in! db test-jobs-dir "sha-fail")))195
(assert-equal 0 (length newly-pending))196
;; The release run should be skipped197
(let ((waiting (db-waiting-runs-for-job db "sha-fail")))198
(assert-equal 0 (length waiting)))))199
(sqlite-close db)))201
(test "stays waiting when predecessors still running"202
(let ((db (make-test-env)))203
(db-create-job! db "build-linux" "codeberg/daviwil/sigil" "main")204
(db-create-job! db "build-arm" "codeberg/daviwil/sigil" "main")205
(db-create-job! db "release" "codeberg/daviwil/sigil" "main")206
(write-job-script! "build-linux.run" "#!/bin/sh\necho build\n")207
(write-job-script! "build-arm.run" "#!/bin/sh\necho build\n")208
(write-job-script! "release.run"209
(string-append "#!/bin/sh\n"210
"#kiln: after build-linux build-arm\n"211
"echo release\n"))213
(let ((ids (schedule-runs! db test-jobs-dir214
"codeberg/daviwil/sigil"215
"refs/heads/main" "sha-partial"216
'push #f #f)))217
;; Only mark one build as passed218
(for-each219
(lambda (id)220
(let ((run (db-get-run db id)))221
(when (string=? "build-linux" (assoc-ref 'job_name run))222
(db-update-run-status! db id "passed"))))223
ids)225
;; Check fan-in - should not transition226
(let ((newly-pending (check-fan-in! db test-jobs-dir "sha-partial")))227
(assert-equal 0 (length newly-pending))228
;; Release should still be waiting229
(let ((waiting (db-waiting-runs-for-job db "sha-partial")))230
(assert-equal 1 (length waiting)))))231
(sqlite-close db))))233
;; ============================================================234
;; Queue Management235
;; ============================================================237
(test-group "dequeue-run"238
(test "returns next pending run"239
(let ((db (make-test-env)))240
(db-create-job! db "sigil-linux" "codeberg/daviwil/sigil" "main")241
(write-job-script! "sigil-linux.run" "#!/bin/sh\necho test\n")243
(schedule-runs! db test-jobs-dir "codeberg/daviwil/sigil"244
"refs/heads/main" "sha-q" 'push #f #f)246
(let ((run (dequeue-run db 3)))247
(assert-true (pair? run))248
;; The RECORD is the row as it was selected, so it still reads249
;; "pending". The DATABASE does not -- see the claim test below.250
(assert-equal "pending" (assoc-ref 'status run))251
(assert-equal "sigil-linux" (assoc-ref 'job_name run)))252
(sqlite-close db)))254
;; THE REGRESSION TEST FOR THE DISPATCH RACE.255
;;256
;; Before `dequeue-run` claimed, this second call returned the SAME run: the257
;; row stayed `pending` until the goroutine the dispatcher spawned got round258
;; to writing `starting`, and under write-lock contention that write suspends259
;; the fiber and lets the drain loop dispatch the row again -- 66 times for260
;; one run in the measurement that found this, ending with a dead controller.261
;;262
;; It is written as "the second call must not hand back the first run"263
;; rather than "the row is now starting", because the second call is what the264
;; drain loop actually does and what actually went wrong.265
(test "claims the run, so a second dequeue cannot return it"266
(let ((db (make-test-env)))267
(db-create-job! db "sigil-linux" "codeberg/daviwil/sigil" "main")268
(write-job-script! "sigil-linux.run" "#!/bin/sh\necho test\n")270
(schedule-runs! db test-jobs-dir "codeberg/daviwil/sigil"271
"refs/heads/main" "sha-claim" 'push #f #f)273
(let ((first (dequeue-run db 3)))274
(assert-true (pair? first))275
;; With max-concurrent 3 the concurrency cap is nowhere near binding on276
;; one run, so a #f here is the claim talking and nothing else.277
(assert-false (dequeue-run db 3)))278
(sqlite-close db)))280
;; SPLIT FROM THE TEST ABOVE ON PURPOSE. The framework reports one assertion281
;; failure per test, so folding these in with the second-dequeue assertion282
;; meant a break in the product always blamed the second dequeue and hid283
;; whether the row had moved at all. Two tests, two diagnoses.284
(test "the claim is visible in the database, with started_at stamped"285
(let ((db (make-test-env)))286
(db-create-job! db "sigil-linux" "codeberg/daviwil/sigil" "main")287
(write-job-script! "sigil-linux.run" "#!/bin/sh\necho test\n")289
(schedule-runs! db test-jobs-dir "codeberg/daviwil/sigil"290
"refs/heads/main" "sha-claim2" 'push #f #f)292
(let* ((first (dequeue-run db 3))293
(row (db-get-run db (assoc-ref 'id first))))294
(assert-equal "starting" (assoc-ref 'status row))295
(assert-true (string? (assoc-ref 'started_at row))))296
(sqlite-close db)))298
(test "returns #f when at concurrency limit"299
(let ((db (make-test-env)))300
(db-create-job! db "sigil-linux" "codeberg/daviwil/sigil" "main")301
(write-job-script! "sigil-linux.run" "#!/bin/sh\necho test\n")303
;; Create a pending run304
(schedule-runs! db test-jobs-dir "codeberg/daviwil/sigil"305
"refs/heads/main" "sha-limit" 'push #f #f)306
;; Mark it as running307
(let ((run (dequeue-run db 3)))308
(db-update-run-status! db (assoc-ref 'id run) "running"))310
;; Create another pending run311
(schedule-runs! db test-jobs-dir "codeberg/daviwil/sigil"312
"refs/heads/main" "sha-limit2" 'push #f #f)314
;; With max_concurrent=1, should not dequeue315
(assert-false (dequeue-run db 1))316
;; With max_concurrent=3, should dequeue317
(assert-true (pair? (dequeue-run db 3)))318
(sqlite-close db)))320
(test "returns #f when no pending runs"321
(let ((db (make-test-env)))322
(assert-false (dequeue-run db 3))323
(sqlite-close db))))325
(test-group "count-active-runs"326
(test "counts running jobs"327
(let ((db (make-test-env)))328
(assert-equal 0 (count-active-runs db))329
(db-create-job! db "sigil-linux" "codeberg/daviwil/sigil" "main")330
(let ((id (db-create-run! db "sigil-linux" 1 "ref" "sha"331
"running" #t 'push)))332
(assert-equal 1 (count-active-runs db)))333
(sqlite-close db))))335
;; ============================================================336
;; fail-fast sibling cancellation337
;; ============================================================338
;;339
;; THESE TESTS COMPILE A REAL .kiln FILE AND SCHEDULE ITS PLAN. That is340
;; deliberate and it is the difference between a gate and a fixture. The341
;; sweep reads `fail-fast` back out of `runs.plan_form`, so a hand-written342
;; plan_form string would let every assertion below pass while the key343
;; never left the grammar -- the whole path (job-keys -> job record ->344
;; plan-node -> build-node-plan-form -> pf-fail-fast) would be untested by345
;; the tests that appear to cover it.346
;;347
;; `test-jobs/k2b-failfast.kiln` holds TWO fan-out jobs, only `alpha`348
;; carrying `fail-fast:`, because a GROUP-WIDE sweep passes every other349
;; assertion in this group and fails only the per-job one.351
(define (failfast-plan path)352
(let* ((sink (make-diag-sink))353
(plans (compile-file-plans path sink)))354
(if (diags-have-errors? sink)355
#f356
(car (filter (lambda (x) x) plans)))))358
(define (failfast-group db path sha)359
(car (schedule-plan-group! db (failfast-plan path)360
"local/test/test" "refs/heads/main" sha 'manual #t)))362
(define (group-db)363
(let ((db (sqlite-open ":memory:")))364
(init-database! db)365
db))367
(define (nstatus db gid node-id)368
(let ((r (db-group-node-run db gid node-id)))369
(and r (assoc-ref 'status r))))371
(define (nid db gid node-id)372
(assoc-ref 'id (db-group-node-run db gid node-id)))374
;; Drive one cell to a terminal status the way the runner does, then let375
;; the scheduler react. Every test below fails a cell through this, so376
;; none of them can accidentally cancel siblings by writing 'cancelled'377
;; itself.378
(define (fail-cell! db gid node-id)379
(db-update-run-status! db (nid db gid node-id) "failed")380
(advance-group! db gid))382
(test-group "fail-fast sibling cancellation"384
;; The plan compiles at all, and the key survives into the STORED form.385
;; Asserted separately from behaviour: if this breaks, every test below386
;; fails for a reason that has nothing to do with the sweep.387
(test "fail-fast reaches runs.plan_form for the declaring job only"388
(let* ((db (group-db))389
(gid (failfast-group db "test-jobs/k2b-failfast.kiln" "ff-form")))390
(assert-true391
(pf-fail-fast (parse-node-plan-form392
(assoc-ref 'plan_form393
(db-group-node-run db gid "alpha[target=a1]")))))394
(assert-false395
(pf-fail-fast (parse-node-plan-form396
(assoc-ref 'plan_form397
(db-group-node-run db gid "beta[target=b1]")))))398
(sqlite-close db)))400
(test "a red cell cancels its own job's still-pending siblings"401
(let* ((db (group-db))402
(gid (failfast-group db "test-jobs/k2b-failfast.kiln" "ff-basic")))403
(assert-equal "pending" (nstatus db gid "alpha[target=a2]"))404
(fail-cell! db gid "alpha[target=a1]")405
(assert-equal "cancelled" (nstatus db gid "alpha[target=a2]"))406
(assert-equal "cancelled" (nstatus db gid "alpha[target=a3]"))407
(sqlite-close db)))409
;; The failed cell itself must keep its own verdict. A sweep that took410
;; every row of the job would overwrite `failed` with `cancelled` and411
;; erase the only evidence of what went wrong.412
(test "the cell that failed keeps its failed status"413
(let* ((db (group-db))414
(gid (failfast-group db "test-jobs/k2b-failfast.kiln" "ff-self")))415
(fail-cell! db gid "alpha[target=a1]")416
(assert-equal "failed" (nstatus db gid "alpha[target=a1]"))417
(sqlite-close db)))419
;; RULE 2, THE RULING. A group-wide sweep passes every other test in420
;; this file and fails exactly this one.421
;;422
;; EVERY ASSERTION HERE IS AN ABSENCE, so the sweep is shown to have423
;; RUN before beta's survival is allowed to mean anything. Without that424
;; control the test passes against a deleted sweep, a fixture that425
;; failed to load, and an implementation that cancels nothing at all.426
(test "PER-JOB SCOPE: another job's pending cells are untouched"427
(let* ((db (group-db))428
(gid (failfast-group db "test-jobs/k2b-failfast.kiln" "ff-scope")))429
(fail-cell! db gid "alpha[target=a1]")430
;; positive control: the sweep is live on this call...431
(assert-equal "cancelled" (nstatus db gid "alpha[target=a2]"))432
;; ...and it did not reach past its own job.433
(assert-equal "pending" (nstatus db gid "beta[target=b1]"))434
(assert-equal "pending" (nstatus db gid "beta[target=b2]"))435
(sqlite-close db)))437
;; RULE 1. `gamma` carries fail-fast WITHOUT allow-failure and is the438
;; positive control: failing one of its cells in the same advance439
;; proves the sweep is live in this database, so alpha's siblings440
;; surviving means allow-failure SUPPRESSED it rather than nothing441
;; having happened.442
(test "allow-failure: #t suppresses the sweep entirely"443
(let* ((db (group-db))444
(gid (failfast-group db "test-jobs/k2b-failfast-allow.kiln" "ff-allow")))445
(db-update-run-status! db (nid db gid "gamma[target=g1]") "failed")446
(fail-cell! db gid "alpha[target=a1]")447
;; positive control: an unexcused fail-fast job IS swept here.448
(assert-equal "cancelled" (nstatus db gid "gamma[target=g2]"))449
;; the assertion under test.450
(assert-equal "pending" (nstatus db gid "alpha[target=a2]"))451
(assert-equal "pending" (nstatus db gid "alpha[target=a3]"))452
(sqlite-close db)))454
;; THE DEFAULT IS OFF AND MUST STAY OFF. `beta` declares no fail-fast,455
;; so a red beta cell cancels nothing -- not its own siblings and not456
;; alpha's. `alpha` is failed in the same advance as the positive457
;; control, for the same reason as the two tests above.458
(test "without fail-fast: a red cell cancels nothing"459
(let* ((db (group-db))460
(gid (failfast-group db "test-jobs/k2b-failfast.kiln" "ff-default")))461
(db-update-run-status! db (nid db gid "alpha[target=a1]") "failed")462
(fail-cell! db gid "beta[target=b1]")463
;; positive control: a job that DOES declare fail-fast is swept on464
;; this very call.465
(assert-equal "cancelled" (nstatus db gid "alpha[target=a2]"))466
;; beta declares none, so its own siblings survive.467
(assert-equal "pending" (nstatus db gid "beta[target=b2]"))468
(sqlite-close db)))470
;; A cell already executing is NOT cancelled: it holds a worker lease,471
;; and cancelling its row would leave that worker running a job whose472
;; row says it stopped.473
(test "a running sibling is left alone"474
(let* ((db (group-db))475
(gid (failfast-group db "test-jobs/k2b-failfast.kiln" "ff-running")))476
(db-update-run-status! db (nid db gid "alpha[target=a2]") "running")477
(fail-cell! db gid "alpha[target=a1]")478
(assert-equal "running" (nstatus db gid "alpha[target=a2]"))479
;; ...while the one that had NOT started is swept. Without this the480
;; test passes on an implementation that cancels nothing at all.481
(assert-equal "cancelled" (nstatus db gid "alpha[target=a3]"))482
(sqlite-close db)))484
;; RULE 3, DRIVEN THROUGH THE REAL `dequeue-run` RATHER THAN A MOCK.485
;; Claim-wins: a sibling claimed into `starting` between candidate-scan486
;; and the sweep survives it, so no worker is left executing a run whose487
;; row reads `cancelled`.488
(test "CLAIM RACE: a sibling claimed by dequeue-run survives the sweep"489
(let* ((db (group-db))490
(gid (failfast-group db "test-jobs/k2b-failfast.kiln" "ff-claim")))491
;; Two dequeues, the way the drain loop makes them: the first claims492
;; a1, the second claims a2. Both are `starting` before anything493
;; fails, which is the state the race is actually about.494
(let* ((first (dequeue-run db 5))495
(second (dequeue-run db 5)))496
;; Assert WHICH cells were claimed rather than assuming the queue497
;; order. If dequeue order ever changes, this says so instead of498
;; quietly testing a different pair of runs.499
(assert-equal "alpha[target=a1]" (assoc-ref 'node_id first))500
(assert-equal "alpha[target=a2]" (assoc-ref 'node_id second))501
(let ((in-flight (assoc-ref 'id second)))502
(assert-equal "starting" (assoc-ref 'status (db-get-run db in-flight)))503
;; a1 -- already claimed -- now fails, exactly as its runner504
;; would report it.505
(fail-cell! db gid "alpha[target=a1]")506
;; The sibling still executing keeps its claim and its worker.507
(assert-equal "starting" (assoc-ref 'status (db-get-run db in-flight)))508
;; ...and the one never claimed is swept. Without this the test509
;; passes on an implementation that cancels nothing at all.510
(assert-equal "cancelled" (nstatus db gid "alpha[target=a3]"))))511
(sqlite-close db)))513
;; Sweep-wins, the other half of the same window: a cancelled sibling is514
;; no longer claimable, so nothing is dispatched for it and nothing ever515
;; acquires a lease on its behalf.516
(test "CLAIM RACE: a swept sibling can no longer be dequeued"517
(let* ((db (group-db))518
(gid (failfast-group db "test-jobs/k2b-failfast.kiln" "ff-claim2")))519
(fail-cell! db gid "alpha[target=a1]")520
(assert-false (db-claim-pending-run! db (nid db gid "alpha[target=a2]")))521
;; Positive control for the assertion above: the claim primitive is522
;; working, it is the CANCELLATION that refused it. Without this, a523
;; db-claim-pending-run! that returned #f unconditionally would pass.524
(assert-true (db-claim-pending-run! db (nid db gid "beta[target=b1]")))525
(sqlite-close db)))527
;; A cancelled cell never executed, so it never posted a forge commit528
;; status and never held a lease -- `report-commit-status!` is only529
;; reached from the execute path. `started_at` is the artifact property530
;; that says so, rather than the absence of an HTTP call nobody watched.531
;;532
;; THE CONTROL IS THE TEST HERE. Adversarial review pointed out that533
;; asserting NULL `started_at` on a swept cell is tautological on its534
;; own: the sweep writes only `status` and `finished_at`, so the column535
;; is NULL by construction AND equally NULL on a do-nothing sweep. What536
;; makes the assertion mean something is showing, in the same group,537
;; that `started_at` is a column which DOES get set on a run that was538
;; dispatched -- so NULL is evidence of never-dispatched rather than539
;; evidence that nothing ever writes it.540
(test "swept siblings never started, while a dispatched run does"541
(let* ((db (group-db))542
(gid (failfast-group db "test-jobs/k2b-failfast.kiln" "ff-nostart")))543
;; Dispatch one cell for real, through the queue.544
(let ((claimed (dequeue-run db 5)))545
(assert-true (pair? claimed))546
;; positive control: the column can be non-NULL.547
(assert-true (string? (assoc-ref 'started_at548
(db-get-run db (assoc-ref 'id claimed))))))549
(fail-cell! db gid "alpha[target=a1]")550
(assert-equal "cancelled" (nstatus db gid "alpha[target=a3]"))551
(assert-false (assoc-ref 'started_at552
(db-get-run db (nid db gid "alpha[target=a3]"))))553
(sqlite-close db)))555
;; THE ORDERING ARM. `late` needs ONE cell -- `(alpha target: "a3")` --556
;; so its only predecessor is a cell that becomes terminal solely557
;; BECAUSE the sweep cancelled it.558
;;559
;; `gather` cannot test this and used to be claimed to: it needs the560
;; whole of alpha, so an edge runs from the failed cell to it and it is561
;; skipped by that cell's own failure whether the sweep ran or not.562
;; Sweep AFTER the fixpoint instead of before and `late` is stranded563
;; `waiting` forever -- a group with nothing runnable never triggers564
;; another advance. Found by adversarial review.565
(test "ORDERING: a dependent of only a SWEPT cell is skipped, not stranded"566
(let* ((db (group-db))567
(gid (failfast-group db "test-jobs/k2b-failfast.kiln" "ff-order")))568
(assert-equal "waiting" (nstatus db gid "late"))569
(fail-cell! db gid "alpha[target=a1]")570
(assert-equal "cancelled" (nstatus db gid "alpha[target=a3]"))571
(assert-equal "skipped" (nstatus db gid "late"))572
(sqlite-close db)))574
;; REGRESSION, HIGH, found by adversarial review and confirmed by probe.575
;;576
;; An undecidable `when:` clause lands a node in `errored`, NOT577
;; `skipped` -- and `errored` is a fail-fast trigger. Worse, the578
;; scheduler force-clears `allow-failure` on exactly those nodes, so579
;; `bad?` was unconditionally true there and the job's own580
;; `allow-failure: #t` could not suppress it. Two cells whose own gates581
;; said RUN were cancelled before their shared predecessor was even582
;; dequeued.583
;;584
;; Fixed by clearing `fail-fast` alongside `allow-failure` in585
;; `build-node-plan-form` when the gate could not be decided: neither586
;; key is a declaration about kiln failing to decide whether the job587
;; should run.588
(test "an UNDECIDABLE when: gate does not cancel its decidable siblings"589
(let* ((db (group-db))590
(gid (failfast-group db "test-jobs/k2b-failfast-undecidable.kiln"591
"ff-undec")))592
;; The mixed case really was produced: one cell errored at593
;; scheduling, and it is the one whose binding is not a ref string.594
(assert-equal "errored" (nstatus db gid "alpha[target=main]"))595
;; The cells whose gate opened must still be waiting on `prep`.596
(assert-equal "waiting" (nstatus db gid "alpha[target=refs/heads/main]"))597
(assert-equal "waiting" (nstatus db gid "alpha[target=refs/heads/dev]"))598
;; And `prep` itself, which had not run, is untouched.599
(assert-equal "pending" (nstatus db gid "prep"))600
(sqlite-close db)))602
;; The other half of the same fix: `fail-fast` must be ABSENT from the603
;; stored form of a gate-errored node, and PRESENT on its siblings.604
;; Asserted on the artifact the scheduler actually reads, so this fails605
;; if the clearing is ever moved to a consumer and missed by one.606
(test "an undecidable gate clears fail-fast in the STORED plan form"607
(let* ((db (group-db))608
(gid (failfast-group db "test-jobs/k2b-failfast-undecidable.kiln"609
"ff-undec2"))610
(pf-of (lambda (n) (parse-node-plan-form611
(assoc-ref 'plan_form612
(db-group-node-run db gid n))))))613
(assert-false (pf-fail-fast (pf-of "alpha[target=main]")))614
;; positive control: the key IS set on the cells kiln could decide,615
;; so the #f above is the clearing and not a serialisation failure.616
(assert-true (pf-fail-fast (pf-of "alpha[target=refs/heads/main]")))617
(sqlite-close db)))619
;; `cancelled` is written by three unrelated mechanisms and reads as one620
;; word on the run page. The swept cell has to say which one it was.621
(test "a swept cell's log says fail-fast cancelled it, and names the trigger"622
(let* ((db (group-db))623
(gid (failfast-group db "test-jobs/k2b-failfast.kiln" "ff-log"))624
(trigger-id (nid db gid "alpha[target=a1]")))625
(fail-cell! db gid "alpha[target=a1]")626
(let ((log (apply string-append627
(map (lambda (r) (string-append (assoc-ref 'line r) "\n"))628
(db-get-logs db (nid db gid "alpha[target=a2]"))))))629
(assert-true (and (string-find log "fail-fast") #t))630
;; It must name the SIBLING that caused it, not just the policy.631
(assert-true (and (string-find log "alpha[target=a1]") #t))632
(assert-true (and (string-find log (number->string trigger-id)) #t)))633
(sqlite-close db)))635
;; The sweep needs no code for dependents: `gather` needs alpha, and the636
;; existing cancelled -> skipped cascade in `predecessor-verdict` stops637
;; it. Asserted in ONE advance call, because a group with nothing638
;; runnable never triggers another one.639
(test "dependents of the swept fan-out are skipped in the SAME advance"640
(let* ((db (group-db))641
(gid (failfast-group db "test-jobs/k2b-failfast.kiln" "ff-deps")))642
(assert-equal "waiting" (nstatus db gid "gather"))643
(fail-cell! db gid "alpha[target=a1]")644
(assert-equal "skipped" (nstatus db gid "gather"))645
(sqlite-close db)))647
;; The vacuous-case guard in update-group-aggregate! must not regress: a648
;; group holding a real failure is `failed`, never `skipped` (nothing649
;; ran) and never `passed`.650
(test "group aggregate after a sweep is failed, not skipped or passed"651
(let* ((db (group-db))652
(gid (failfast-group db "test-jobs/k2b-failfast.kiln" "ff-agg")))653
;; Drive the untouched job to terminal so the group is fully decided;654
;; otherwise the aggregate is legitimately still "pending" and the655
;; assertion would be about the wrong thing.656
(db-update-run-status! db (nid db gid "beta[target=b1]") "passed")657
(db-update-run-status! db (nid db gid "beta[target=b2]") "passed")658
(fail-cell! db gid "alpha[target=a1]")659
(assert-true (group-terminal? db gid))660
(assert-equal "failed" (assoc-ref 'status (db-get-group db gid)))661
(sqlite-close db)))663
;; `advance-group!` is called on every terminal transition and its664
;; contract is idempotence. A second sweep must find nothing left to do665
;; rather than, say, re-cancelling a cell that has since been retried.666
(test "the sweep is idempotent across repeated advances"667
(let* ((db (group-db))668
(gid (failfast-group db "test-jobs/k2b-failfast.kiln" "ff-idem")))669
(fail-cell! db gid "alpha[target=a1]")670
(advance-group! db gid)671
(advance-group! db gid)672
(assert-equal "cancelled" (nstatus db gid "alpha[target=a2]"))673
(assert-equal "failed" (nstatus db gid "alpha[target=a1]"))674
(assert-equal "pending" (nstatus db gid "beta[target=b1]"))675
(sqlite-close db))))