@substrat-run/kernel 0.137.0 → 0.138.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/delivery-refusal.d.ts +21 -0
  2. package/dist/delivery-refusal.d.ts.map +1 -0
  3. package/dist/delivery-refusal.js +21 -0
  4. package/dist/delivery-refusal.js.map +1 -0
  5. package/dist/index.d.ts +18 -14
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +13 -9
  8. package/dist/index.js.map +1 -1
  9. package/dist/job-run.d.ts +309 -48
  10. package/dist/job-run.d.ts.map +1 -1
  11. package/dist/job-run.js +388 -80
  12. package/dist/job-run.js.map +1 -1
  13. package/dist/membership-executor.d.ts +97 -0
  14. package/dist/membership-executor.d.ts.map +1 -0
  15. package/dist/membership-executor.js +246 -0
  16. package/dist/membership-executor.js.map +1 -0
  17. package/dist/membership-fence.d.ts +57 -0
  18. package/dist/membership-fence.d.ts.map +1 -0
  19. package/dist/membership-fence.js +90 -0
  20. package/dist/membership-fence.js.map +1 -0
  21. package/dist/operation-series.d.ts +58 -0
  22. package/dist/operation-series.d.ts.map +1 -0
  23. package/dist/operation-series.js +63 -0
  24. package/dist/operation-series.js.map +1 -0
  25. package/dist/peer.d.ts +13 -8
  26. package/dist/peer.d.ts.map +1 -1
  27. package/dist/peer.js +13 -14
  28. package/dist/peer.js.map +1 -1
  29. package/dist/permission-eval.d.ts +39 -1
  30. package/dist/permission-eval.d.ts.map +1 -1
  31. package/dist/permission-eval.js +111 -18
  32. package/dist/permission-eval.js.map +1 -1
  33. package/dist/read-only-sql.d.ts +2 -26
  34. package/dist/read-only-sql.d.ts.map +1 -1
  35. package/dist/read-only-sql.js +13 -9
  36. package/dist/read-only-sql.js.map +1 -1
  37. package/dist/scope-copy.d.ts +18 -0
  38. package/dist/scope-copy.d.ts.map +1 -1
  39. package/dist/scope-copy.js +18 -0
  40. package/dist/scope-copy.js.map +1 -1
  41. package/dist/scope-host.d.ts +236 -9
  42. package/dist/scope-host.d.ts.map +1 -1
  43. package/dist/scope-host.js +41 -0
  44. package/dist/scope-host.js.map +1 -1
  45. package/dist/spine-restore.d.ts +7 -2
  46. package/dist/spine-restore.d.ts.map +1 -1
  47. package/dist/spine-restore.js +13 -5
  48. package/dist/spine-restore.js.map +1 -1
  49. package/dist/system-switch-record.d.ts +277 -77
  50. package/dist/system-switch-record.d.ts.map +1 -1
  51. package/dist/system-switch-record.js +322 -105
  52. package/dist/system-switch-record.js.map +1 -1
  53. package/dist/system-switch.d.ts +110 -14
  54. package/dist/system-switch.d.ts.map +1 -1
  55. package/dist/system-switch.js +92 -17
  56. package/dist/system-switch.js.map +1 -1
  57. package/dist/timeline.d.ts +23 -0
  58. package/dist/timeline.d.ts.map +1 -1
  59. package/dist/timeline.js +30 -0
  60. package/dist/timeline.js.map +1 -1
  61. package/dist/ulid.d.ts +5 -0
  62. package/dist/ulid.d.ts.map +1 -1
  63. package/dist/ulid.js +9 -0
  64. package/dist/ulid.js.map +1 -1
  65. package/dist/vertical-events.d.ts +7 -1
  66. package/dist/vertical-events.d.ts.map +1 -1
  67. package/dist/vertical-events.js +14 -0
  68. package/dist/vertical-events.js.map +1 -1
  69. package/package.json +4 -4
package/dist/job-run.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { dataSubjectId, substratError } from '@substrat-run/contracts';
2
2
  import { assertRowLimit, backoffAt, resolveRetryPolicy } from './scope-host.js';
3
+ import { ulid } from './ulid.js';
3
4
  /**
4
5
  * The fourth driver (#1577): long, resumable, coalesced work.
5
6
  *
@@ -60,23 +61,76 @@ import { assertRowLimit, backoffAt, resolveRetryPolicy } from './scope-host.js';
60
61
  * `_substrat_sweep_runs` is a RECEIPT and carries such a constraint; a cursor is not a
61
62
  * receipt, and the two must not be re-merged (#1571, #1572).
62
63
  *
63
- * ## One driver per scope at a time — a stated bound, not a mechanism
64
+ * ## One pass per run at a time — a lease (#2034)
64
65
  *
65
- * Coalescing stops duplicate RUNS. It does not stop two concurrent callers of
66
- * `runDueJobs` from picking the same run out of the due read and advancing it at the
67
- * same time: there is no lease, and a lease is not smuggled in here. The topology the
68
- * driver is built for has one tick per scope — `runPlatformSweep` enumerates scopes
69
- * and does one call each, a scope DO's alarm fires for its own scope — so the bound
70
- * is satisfied by construction rather than defended against.
66
+ * Coalescing stops duplicate RUNS. What stops two drives overlapping on one scope from
67
+ * running the same run's handler together is the CLAIM: before a pass is invoked, one
68
+ * compare-and-set (`JOB_RUN_CLAIM_SQL`) checks that the run is still `running` and due
69
+ * and, in the same statement, writes a lease — a `lease_owner` minted for that one pass,
70
+ * and `next_attempt_at` pushed out to the lease's expiry. Both adapters serialize a
71
+ * scope's writes (the DO's input gate, the pure host's turn queue), so exactly one
72
+ * claim can win; the loser's statement finds the run no longer due and changes nothing.
73
+ * The lease's expiry IS `next_attempt_at`, deliberately: a leased run is simply not
74
+ * due, so the due read, its index and its `JOB_RUN_DUE_AT` order need nothing new.
71
75
  *
72
- * What the overlap would cost, if a deployment did drive one scope twice at once: the
73
- * step ledger absorbs most of it (a step already committed returns its memo to both),
74
- * so the exposure is a step neither pass has finished yet, which both would run. That
75
- * is the same at-least-once residue an executor already has and which handlers already
76
- * have to absorb — but it is NOT what "only one walk per source at a time" promises,
77
- * so it is written down rather than implied. Adding a lease is a real design with a
78
- * real expiry question behind it (a leaked lease is a run nothing will ever touch
79
- * again), and it wants a consumer's numbers before it gets one.
76
+ * **One clock: the store's** (#2042 r4). Every lease time — the claim's due test and
77
+ * expiry, the BEGIN test, each renewal — is computed by the STORE as its statement runs
78
+ * (the DO's clock, the pure host's injected one), never by the drive. The drive only
79
+ * measures how long it has itself been waiting, on its own monotonic clock, from just
80
+ * before it sent the claim: the time left on its lease is `leaseMs` minus that, which
81
+ * counts the whole round trip and so can only err short. A skew between the drive's
82
+ * clock and the store's cannot move a lease either way.
83
+ *
84
+ * ## The commitment point: BEGIN
85
+ *
86
+ * A claim does not yet run anything. The drive first checks its lease still has more
87
+ * than `JOB_LEASE_ENTRY_MARGIN` of it left, then BEGINS the pass with a second
88
+ * compare-and-set (`JOB_RUN_BEGIN_SQL`): it holds only while the claim still owns the
89
+ * lease with more than the margin left by the store's clock, and it stamps
90
+ * `lease_began_at`. **The drive invokes the handler if and only if BEGIN wrote — and it
91
+ * does not second-guess BEGIN's reply.** BEGIN is the commitment: from that write on,
92
+ * the pass counts as having begun, whether or not its handler gets far.
93
+ *
94
+ * Why no check after BEGIN, and why no further write could replace one: whatever the
95
+ * drive does after an acknowledged write, the acknowledgement can arrive late. A check
96
+ * on BEGIN's reply that declined to invoke would leave a durable stamp for a pass that
97
+ * never ran — and a takeover would charge it, failing a `maxAttempts: 1` run with no
98
+ * invocation at all. Recording "the handler really started" in another write only moves
99
+ * the same race onto THAT write's reply. Some last write has to be the point the drive
100
+ * acts on unconditionally; it is BEGIN.
101
+ *
102
+ * **What that leaves, by construction, is at-least-once:** if BEGIN's reply takes longer
103
+ * than the lease it left — more than the margin, a quarter of the lease, minutes at the
104
+ * default — the lease can expire before the handler starts, another drive can take the
105
+ * run over, and both run until the stale pass reaches a step boundary or its outcome,
106
+ * where it learns and stops. The same holds for a pass that spends longer than the lease
107
+ * between two steps. Like a step body (see `JobPassContext.step`), that stretch is
108
+ * at-least-once, and the job's `leaseMs` is what keeps it from happening.
109
+ *
110
+ * Everything the pass writes after BEGIN — a step's ledger row, the outcome patch, the
111
+ * commit — is conditional on still holding the lease, so a holder that lost it changes
112
+ * nothing. Each step boundary RENEWS the lease (by the store's clock), so a pass longer
113
+ * than one lease is not taken over while it is still making progress.
114
+ *
115
+ * **A lease that expires without a patch is recovered.** The run is due again at the
116
+ * expiry, and the next claim takes it over. A takeover charges an attempt if and only if
117
+ * the lease had BEGUN (`lease_began_at`): that pass started and never reported — a crash
118
+ * between BEGIN and the handler included, which nothing can tell from a crash inside it —
119
+ * so `attempts` stays honest and a pass that keeps dying exhausts the job's policy and
120
+ * ends `failed` rather than looping forever. A claim that never began ran nothing, and
121
+ * costs nothing. The expiry has to outlast the longest stretch a pass spends between two
122
+ * steps, which is the job's own number: `registerJob`'s `leaseMs`, default `JOB_LEASE_MS`.
123
+ *
124
+ * ## Admission misses
125
+ *
126
+ * A claim that does not BEGIN — its reply came back with too little of the lease left, or
127
+ * BEGIN refused — is an ADMISSION MISS. No handler ran, so no attempt is charged; but the
128
+ * run counts CONSECUTIVE misses (`admission_misses`, cleared only by BEGIN), and ONE
129
+ * conditional store transaction (`JOB_RUN_MISS_SQL`) releases the lease, adds the miss to
130
+ * the count it finds, and sets the run's backoff (`admissionBackoffMs`) or, at
131
+ * `JOB_ADMISSION_MISS_MAX`, fails it with `JOB_LEASE_TOO_SHORT_NOTE`. The drive reports
132
+ * and logs a warning naming the job, its `leaseMs` and the delay it saw. No outcome patch
133
+ * ever writes the count, so no write built from an older snapshot can overwrite it.
80
134
  *
81
135
  * ## What this is NOT
82
136
  *
@@ -128,10 +182,23 @@ export const JOB_RUN_DDL = `
128
182
  started_at TEXT NOT NULL,
129
183
  updated_at TEXT NOT NULL,
130
184
  -- When the next pass may run, on the executor's own backoff curve. NULL = now
131
- -- (or terminal), which is why the due read tests IS NULL as well as <= now.
185
+ -- (or terminal), which is why the due read tests IS NULL as well as <= now. While
186
+ -- a pass holds the run (lease_owner set) it is the lease's expiry (#2034).
132
187
  next_attempt_at TEXT,
133
188
  -- When the run reached 'done' or 'failed'. NULL while it is still running.
134
- ended_at TEXT
189
+ ended_at TEXT,
190
+ -- #2034: the pass holding the run, minted per claim; NULL = nobody. Every write a
191
+ -- pass makes is conditional on it, and the pass's outcome clears it.
192
+ lease_owner TEXT,
193
+ -- #2034 (#2042 r2, r4): when the holder BEGAN its pass (JOB_RUN_BEGIN_SQL), the point
194
+ -- after which its handler runs; NULL = it has not, and a claim that never began is
195
+ -- taken over without costing an attempt.
196
+ lease_began_at TEXT,
197
+ -- #2042 r3: CONSECUTIVE claims that did not begin their pass (their answer came back
198
+ -- with too little of the lease left). NULL = none; cleared by BEGIN, written only by
199
+ -- JOB_RUN_MISS_SQL. Not attempts: no handler ran. At JOB_ADMISSION_MISS_MAX the run
200
+ -- fails, its lease too short for where it runs.
201
+ admission_misses INTEGER
135
202
  );
136
203
  -- The drive's read: WHERE status = 'running' AND (next_attempt_at IS NULL OR <= ?)
137
204
  -- ORDER BY id. Leading with status makes the live runs a seekable range over a
@@ -169,7 +236,7 @@ export const JOB_RUN_DDL = `
169
236
  `;
170
237
  /**
171
238
  * The write every pass outcome lands through, on both adapters — a compare-and-set on
172
- * `status = 'running'` (#1632).
239
+ * `status = 'running'` (#1632) and on the pass's lease (#2034), which it releases.
173
240
  *
174
241
  * A pass runs for as long as its handler takes, outside any lock, so a subject erasure
175
242
  * can settle the run `failed` and tombstone its payload, cursor and step memos while the
@@ -179,15 +246,107 @@ export const JOB_RUN_DDL = `
179
246
  * nothing else in the kernel moves a run out of that state except the pass itself and
180
247
  * the erasure. Same shape, same reason, as `settlePlatformRequest`'s CAS on `pending`.
181
248
  *
249
+ * The lease half: a pass whose lease another drive took over after it expired is no
250
+ * longer the run's, and its outcome — written over the new holder's work — is refused.
251
+ * `IS ?`, not `= ?`, so a caller passing NULL (a coordinator from before leases) still
252
+ * patches the unleased rows it drives.
253
+ *
254
+ * It never writes `admission_misses` (#2042 r4): only BEGIN clears it and only
255
+ * `JOB_RUN_MISS_SQL` adds to it.
256
+ *
182
257
  * Params: status, cursor, counters, attempts, last_error, updated_at, next_attempt_at,
183
- * ended_at, id.
258
+ * ended_at, id, lease_owner.
184
259
  */
185
260
  export const JOB_RUN_PATCH_SQL = `UPDATE _substrat_job_runs
186
261
  SET status = ?, cursor = ?, counters = ?, attempts = ?, last_error = ?,
187
- updated_at = ?, next_attempt_at = ?, ended_at = ?
188
- WHERE id = ? AND status = 'running'`;
262
+ updated_at = ?, next_attempt_at = ?, ended_at = ?,
263
+ lease_owner = NULL, lease_began_at = NULL
264
+ WHERE id = ? AND status = 'running' AND lease_owner IS ?`;
189
265
  /**
190
- * Record one step attempt — only while its run is still `running` (#1632).
266
+ * #2034: the claim — the ONE statement that decides which drive runs a due run's pass.
267
+ *
268
+ * Succeeds (one row changed) only where the run is still `running` and due, so of two
269
+ * drives that both picked it, one wins and the other changes nothing. The winner's
270
+ * lease is written in the same statement: `lease_owner`, and `next_attempt_at` pushed
271
+ * out to the lease's expiry, which is what takes the run out of the due read. Both the
272
+ * due test and the expiry are the STORE's time as it runs (#2042 r4): the adapter binds
273
+ * its own clock's now, never the drive's.
274
+ *
275
+ * A run due while it still carries a BEGUN lease (`lease_began_at`) is a pass that
276
+ * started and never reported, and taking it over counts that pass as failed: `attempts`
277
+ * + 1 and the note as `last_error`. A lease whose claim never began ran nothing and is
278
+ * taken over for free. SQLite evaluates every `SET` against the row as it was, so the
279
+ * `CASE`s read the previous lease.
280
+ *
281
+ * Returns the claimed row, as the claim left it (`RETURNING *`); no row = the claim lost.
282
+ *
283
+ * Params: lease_owner, next_attempt_at (store now + leaseMs), updated_at (store now),
284
+ * last_error (the takeover note), id, store now.
285
+ */
286
+ export const JOB_RUN_CLAIM_SQL = `UPDATE _substrat_job_runs
287
+ SET lease_owner = ?, next_attempt_at = ?, updated_at = ?, lease_began_at = NULL,
288
+ attempts = attempts + CASE WHEN lease_began_at IS NULL THEN 0 ELSE 1 END,
289
+ last_error = CASE WHEN lease_began_at IS NULL THEN last_error ELSE ? END
290
+ WHERE id = ? AND status = 'running' AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
291
+ RETURNING *`;
292
+ /**
293
+ * #2034 (#2042 r4): BEGIN a claimed pass — the commitment point (see the file header). A
294
+ * compare-and-set in the store's clock: it stamps `lease_began_at` and clears
295
+ * `admission_misses` only while `lease_owner` is still this claim's and the lease runs
296
+ * past the store's now plus the margin. The drive invokes the handler if and only if this
297
+ * wrote, and does not second-guess its reply.
298
+ *
299
+ * Params: lease_began_at (store now), id, lease_owner, store now + margin.
300
+ */
301
+ export const JOB_RUN_BEGIN_SQL = `UPDATE _substrat_job_runs SET lease_began_at = ?, admission_misses = NULL
302
+ WHERE id = ? AND status = 'running' AND lease_owner IS ? AND next_attempt_at > ?`;
303
+ /**
304
+ * #2042 r3, r4: an ADMISSION MISS, first statement — release a claim that did not begin and add
305
+ * one to the count it finds (`COALESCE(admission_misses, 0) + 1`), never writing a count read
306
+ * earlier. Only while `owner` still holds the run and has not begun. Returns the new count.
307
+ * Both adapters run it and `JOB_RUN_MISS_SETTLE_SQL` in ONE transaction.
308
+ *
309
+ * Params: updated_at (store now), id, lease_owner.
310
+ */
311
+ export const JOB_RUN_MISS_SQL = `UPDATE _substrat_job_runs
312
+ SET admission_misses = COALESCE(admission_misses, 0) + 1, updated_at = ?,
313
+ lease_owner = NULL, lease_began_at = NULL
314
+ WHERE id = ? AND status = 'running' AND lease_owner IS ? AND lease_began_at IS NULL
315
+ RETURNING admission_misses`;
316
+ /**
317
+ * #2042 r3, r4: an ADMISSION MISS, second statement, in the same transaction — the backoff or the
318
+ * failure that the NEW count calls for (`admissionMissOutcome`). Conditional on that count still
319
+ * being on the row, unleased.
320
+ *
321
+ * Params: status, next_attempt_at, ended_at, last_error (NULL keeps it), id, admission_misses.
322
+ */
323
+ export const JOB_RUN_MISS_SETTLE_SQL = `UPDATE _substrat_job_runs
324
+ SET status = ?, next_attempt_at = ?, ended_at = ?, last_error = COALESCE(?, last_error)
325
+ WHERE id = ? AND status = 'running' AND lease_owner IS NULL AND admission_misses = ?`;
326
+ /**
327
+ * #2042 r3: what a run's `misses`-th consecutive admission miss does to it, at the store's `now`:
328
+ * a backoff (`admissionBackoffMs`), or at `JOB_ADMISSION_MISS_MAX` a failure carrying `note`.
329
+ * The params of `JOB_RUN_MISS_SETTLE_SQL` before its id; shared so the adapters cannot differ.
330
+ */
331
+ export function admissionMissOutcome(misses, now, note) {
332
+ return misses >= JOB_ADMISSION_MISS_MAX
333
+ ? { status: 'failed', nextAttemptAt: null, endedAt: now, lastError: `${JOB_LEASE_TOO_SHORT_NOTE}: ${note}` }
334
+ : { status: 'running', nextAttemptAt: new Date(Date.parse(now) + admissionBackoffMs(misses)).toISOString(), endedAt: null, lastError: null };
335
+ }
336
+ /**
337
+ * #2034: renew a pass's lease at a step boundary — only while the pass still holds it, to the
338
+ * store's now plus the lease (#2042 r4: the store's clock, as everywhere a lease is timed).
339
+ * One row changed = still held, and the lease now runs to the new expiry; none = the
340
+ * pass lost the run (taken over, or settled by an erasure) and must stop.
341
+ *
342
+ * Params: next_attempt_at (the new expiry), id, lease_owner.
343
+ */
344
+ export const JOB_RUN_RENEW_SQL = `UPDATE _substrat_job_runs SET next_attempt_at = ?
345
+ WHERE id = ? AND status = 'running' AND lease_owner IS ?`;
346
+ /**
347
+ * Record one step attempt — only while its run is still `running` (#1632) and the pass
348
+ * still holds its lease (#2034). Both adapters run it after `JOB_RUN_RENEW_SQL`, in one
349
+ * transaction, and only when the renewal held.
191
350
  *
192
351
  * The step half of `JOB_RUN_PATCH_SQL`'s CAS: a stale pass's step, finishing after an
193
352
  * erasure settled the run, would otherwise write a fresh result carrying the person
@@ -195,11 +354,11 @@ export const JOB_RUN_PATCH_SQL = `UPDATE _substrat_job_runs
195
354
  * `VALUES` so the guard and the write are one statement; the `WHERE` also resolves
196
355
  * SQLite's parse ambiguity between a SELECT's trailing clause and `ON CONFLICT`.
197
356
  *
198
- * Params: run_id, step, result, attempts, last_error, recorded_at, run_id.
357
+ * Params: run_id, step, result, attempts, last_error, recorded_at, run_id, lease_owner.
199
358
  */
200
359
  export const JOB_STEP_RECORD_SQL = `INSERT INTO _substrat_job_steps (run_id, step, result, attempts, last_error, recorded_at)
201
360
  SELECT ?, ?, ?, ?, ?, ?
202
- WHERE EXISTS (SELECT 1 FROM _substrat_job_runs WHERE id = ? AND status = 'running')
361
+ WHERE EXISTS (SELECT 1 FROM _substrat_job_runs WHERE id = ? AND status = 'running' AND lease_owner IS ?)
203
362
  ON CONFLICT (run_id, step) DO UPDATE SET result = excluded.result,
204
363
  attempts = excluded.attempts,
205
364
  last_error = excluded.last_error,
@@ -223,6 +382,50 @@ export function jobRunListLimit(limit) {
223
382
  return JOB_RUN_LIST_LIMIT;
224
383
  return Math.min(JOB_RUN_LIST_MAX, Math.max(1, Math.floor(limit)));
225
384
  }
385
+ /**
386
+ * #2034: how long a pass's lease lasts by default — from its claim, and again from each
387
+ * step boundary, which renews it. A job whose pass can spend longer than this between
388
+ * two steps registers its own `leaseMs`; otherwise its run is taken over while still
389
+ * working. Fifteen minutes is the longest a hosted alarm or cron invocation runs, so a
390
+ * hosted pass that has gone longer without a step has been stopped anyway.
391
+ */
392
+ export const JOB_LEASE_MS = 15 * 60_000;
393
+ /** #2034: the shortest `leaseMs` a job may register — below it, the BEGIN margin is noise. */
394
+ export const JOB_LEASE_MIN_MS = 100;
395
+ /**
396
+ * #2034: the share of its lease a claim must still have left to BEGIN its pass — by the drive's own
397
+ * count when the claim's reply arrives, and by the store's clock as BEGIN runs. Time spent between
398
+ * the claim's write and its reply — a slow round trip, a paused isolate — is time another drive can
399
+ * spend taking the run over once the lease has expired. It is also the bound on the at-least-once
400
+ * window: a double run needs BEGIN's reply to take longer than this share of the lease.
401
+ */
402
+ export const JOB_LEASE_ENTRY_MARGIN = 0.25;
403
+ /**
404
+ * #2042 r3: consecutive admission misses after which a run fails rather than being claimed again.
405
+ * A claim that does not begin in time costs no attempt, so without a bound a run whose lease is too
406
+ * short for where it runs would be claimed, missed and released forever, with nothing reporting it.
407
+ */
408
+ export const JOB_ADMISSION_MISS_MAX = 10;
409
+ /** #2042 r3: the wait after a first admission miss; it doubles per miss, up to `JOB_ADMISSION_BACKOFF_MAX_MS`. */
410
+ export const JOB_ADMISSION_BACKOFF_BASE_MS = 1_000;
411
+ /** #2042 r3: the longest wait between two admission attempts. */
412
+ export const JOB_ADMISSION_BACKOFF_MAX_MS = 5 * 60_000;
413
+ /** #2042 r3: how long a run waits after its `misses`-th consecutive admission miss. */
414
+ export function admissionBackoffMs(misses) {
415
+ return Math.min(JOB_ADMISSION_BACKOFF_MAX_MS, JOB_ADMISSION_BACKOFF_BASE_MS * 2 ** Math.max(0, misses - 1));
416
+ }
417
+ /** #2042 r3: the start of `last_error` on a run failed for missing its admission `JOB_ADMISSION_MISS_MAX` times. */
418
+ export const JOB_LEASE_TOO_SHORT_NOTE = 'lease too short for this environment';
419
+ /** #2034: `last_error` of a run whose expired lease a later claim took over. */
420
+ export const JOB_LEASE_EXPIRED_NOTE = 'interrupted: the pass holding this run stopped reporting, and its lease expired';
421
+ /** #2034: a job's `leaseMs`, refused at registration unless it is an integer of at least `JOB_LEASE_MIN_MS`. */
422
+ export function assertLeaseMs(leaseMs) {
423
+ if (leaseMs === undefined)
424
+ return;
425
+ if (!Number.isSafeInteger(leaseMs) || leaseMs < JOB_LEASE_MIN_MS) {
426
+ throw substratError('validation_failed', `leaseMs must be an integer of at least ${JOB_LEASE_MIN_MS} milliseconds, got ${String(leaseMs)}`, { errors: [{ path: 'leaseMs', message: `must be an integer of at least ${JOB_LEASE_MIN_MS}` }] });
427
+ }
428
+ }
226
429
  /** Runs one `runDueJobs` call picks up by default. */
227
430
  export const JOB_DRIVE_LIMIT = 50;
228
431
  /**
@@ -402,6 +605,8 @@ export function jobRunOf(row) {
402
605
  updatedAt: row.updated_at,
403
606
  nextAttemptAt: row.next_attempt_at,
404
607
  endedAt: row.ended_at,
608
+ leaseOwner: row.lease_owner ?? null,
609
+ admissionMisses: row.admission_misses ?? 0,
405
610
  decodeError,
406
611
  };
407
612
  }
@@ -448,6 +653,7 @@ export async function startJobRun(store, input, mintId, now) {
448
653
  updated_at: at,
449
654
  next_attempt_at: null,
450
655
  ended_at: null,
656
+ lease_owner: null,
451
657
  };
452
658
  // The row is built unconditionally — an id is minted and a start time stamped even
453
659
  // when this call turns out to be a join. That is the price of doing the decision in
@@ -476,6 +682,33 @@ class JobStepFailure extends Error {
476
682
  this.name = 'JobStepFailure';
477
683
  }
478
684
  }
685
+ /**
686
+ * #2034: the pass no longer holds its run's lease, found at a step boundary. Kernel-private and
687
+ * tied to its pass, like `PassDeferred`, so a handler that catches it cannot turn it into anything
688
+ * that writes: the pass stops, and the outcome is `superseded`.
689
+ */
690
+ class LeaseLost extends Error {
691
+ pass;
692
+ constructor(pass) {
693
+ super('this pass no longer holds the run: its lease was taken over or the run was settled');
694
+ this.pass = pass;
695
+ }
696
+ }
697
+ /** The ISO instant `ms` after `at` — a lease's expiry, a deferral's end. */
698
+ const plusMs = (at, ms) => new Date(Date.parse(at) + ms).toISOString();
699
+ /**
700
+ * Write a pass's outcome onto its run, keeping what the last commit wrote (cursor, counters) and
701
+ * stamping `ended_at` when the outcome is terminal. False = refused: `owner` no longer holds the run.
702
+ */
703
+ function settle(store, run, owner, at, outcome) {
704
+ return store.patch(run.id, {
705
+ ...outcome,
706
+ cursor: run.cursor,
707
+ counters: run.counters,
708
+ updatedAt: at,
709
+ endedAt: outcome.status === 'running' ? null : at,
710
+ }, owner);
711
+ }
479
712
  /**
480
713
  * Run ONE pass of one run, and write what happened.
481
714
  *
@@ -494,9 +727,15 @@ class JobStepFailure extends Error {
494
727
  * failure `runDueSchedules` already refuses for schedules.
495
728
  */
496
729
  export async function runJobPass(options) {
497
- const { store, run, handler, now, openScope } = options;
730
+ const { store, run, owner, handler, now, openScope } = options;
731
+ const leaseMs = options.leaseMs ?? JOB_LEASE_MS;
498
732
  // This pass's token, and nothing else's: the door's refusals are tied to it.
499
733
  const passToken = {};
734
+ /** #2034: a write refused because the lease is gone stops the pass where it stands. */
735
+ const held = (applied) => {
736
+ if (!applied)
737
+ throw new LeaseLost(passToken);
738
+ };
500
739
  /** Asked once per refusal, since the host consumes its mark; the answer then travels as `PassDeferred`. */
501
740
  const deferral = (err) => {
502
741
  if (err instanceof PassDeferred)
@@ -550,7 +789,10 @@ export async function runJobPass(options) {
550
789
  }
551
790
  usedThisPass.add(name);
552
791
  const policy = resolveRetryPolicy(retry ?? options.retry);
553
- const prior = await store.step(run.id, name);
792
+ // #2034: a step boundary renews the lease, and a pass that lost it runs no further step.
793
+ const begun = await store.beginStep(run.id, name, owner, leaseMs);
794
+ held(begun.held);
795
+ const prior = begun.row;
554
796
  // A NON-NULL result is what means completed: a step that threw left its row
555
797
  // with a null result and a raised count, and must run again.
556
798
  if (prior && prior.result !== null)
@@ -575,13 +817,15 @@ export async function runJobPass(options) {
575
817
  if (wait)
576
818
  throw wait;
577
819
  const cause = message(err);
578
- await store.recordStep(run.id, name, null, attempts, cause, now());
820
+ const at = now();
821
+ held(await store.recordStep(run.id, name, null, attempts, cause, at, owner, leaseMs));
579
822
  throw new JobStepFailure(name, attempts, policy, cause);
580
823
  }
581
824
  // `undefined` becomes the JSON text 'null', not SQL NULL: a step done purely
582
825
  // for its effect must still read as completed on the next pass.
583
826
  const stored = JSON.stringify(value) ?? 'null';
584
- await store.recordStep(run.id, name, stored, attempts, null, now());
827
+ const at = now();
828
+ held(await store.recordStep(run.id, name, stored, attempts, null, at, owner, leaseMs));
585
829
  // RETURNED THROUGH THE STORED FORM, not as the raw value. The resume path
586
830
  // returns `JSON.parse(row.result)`, so returning `value` here would hand the
587
831
  // handler a `Date` on the first pass and the string `"2026-01-01T…"` on the
@@ -617,34 +861,36 @@ export async function runJobPass(options) {
617
861
  // throw from the clear landed in the catch below, which wrote the OLD cursor
618
862
  // back and filed an already-committed pass as failed, so the record a human
619
863
  // reads to recover would have understated the run's own progress.
620
- await store.commitPass(run.id, {
864
+ const committed = await store.commitPass(run.id, {
621
865
  status: done ? 'done' : 'running',
622
866
  cursor: keepsCursor ? run.cursor : JSON.stringify(result.cursor),
623
867
  counters: JSON.stringify(counters),
624
868
  attempts: 0,
625
869
  lastError: null,
626
870
  updatedAt: at,
871
+ // Cleared, never left at the lease's expiry: the lease ends with the pass (#2034).
627
872
  nextAttemptAt: null,
628
873
  endedAt: done ? at : null,
629
- });
874
+ }, owner);
875
+ if (!committed)
876
+ return { status: 'superseded' };
630
877
  return { status: done ? 'completed' : 'advanced' };
631
878
  }
632
879
  catch (err) {
880
+ // #2034: this pass lost the run at a step boundary. Nothing to write: any write would be refused.
881
+ if (err instanceof LeaseLost && err.pass === passToken)
882
+ return { status: 'superseded' };
633
883
  // #1834: the host's system door said wait, so the call did not run. The run keeps what the last
634
884
  // commit wrote (its attempts and its last error included); only when it is next due moves.
635
885
  if (deferral(err)) {
636
886
  const at = now();
637
- await store.patch(run.id, {
887
+ const patched = await settle(store, run, owner, at, {
638
888
  status: 'running',
639
- cursor: run.cursor,
640
- counters: run.counters,
641
889
  attempts: run.attempts,
642
890
  lastError: run.last_error,
643
- updatedAt: at,
644
- nextAttemptAt: new Date(Date.parse(at) + JOB_DEFER_MS).toISOString(),
645
- endedAt: null,
891
+ nextAttemptAt: plusMs(at, JOB_DEFER_MS),
646
892
  });
647
- return { status: 'deferred', error: message(err) };
893
+ return patched ? { status: 'deferred', error: message(err) } : { status: 'superseded' };
648
894
  }
649
895
  const stepFailure = err instanceof JobStepFailure ? err : null;
650
896
  const policy = stepFailure?.policy ?? jobPolicy;
@@ -656,16 +902,15 @@ export async function runJobPass(options) {
656
902
  const exhausted = against >= policy.maxAttempts;
657
903
  const error = message(err);
658
904
  const at = now();
659
- await store.patch(run.id, {
905
+ const patched = await settle(store, run, owner, at, {
660
906
  status: exhausted ? 'failed' : 'running',
661
- cursor: run.cursor,
662
- counters: run.counters,
663
907
  attempts,
664
908
  lastError: error,
665
- updatedAt: at,
909
+ // The backoff, never the lease's expiry the claim wrote: the lease ends with the pass (#2034).
666
910
  nextAttemptAt: exhausted ? null : backoffAt(against, policy, new Date(at)),
667
- endedAt: exhausted ? at : null,
668
911
  });
912
+ if (!patched)
913
+ return { status: 'superseded' };
669
914
  return { status: exhausted ? 'failed' : 'retrying', error };
670
915
  }
671
916
  }
@@ -689,14 +934,15 @@ export async function runJobPass(options) {
689
934
  *
690
935
  * **Selection is ONE snapshot per drive** (#1834). The drive reads the keys of up to
691
936
  * `JOB_DRIVE_SCAN_MAX` due runs in a single query, picks up to `limit` runnable ones
692
- * (each id once), and re-reads each row just before it runs it, skipping one that is no
693
- * longer `running` or no longer due. A cursor across several reads met rows that another
694
- * writer had moved in between, and ran one twice or skipped it. A run that moves, or
695
- * becomes due, after the snapshot is the NEXT drive's business, never this one's.
937
+ * (each id once), and CLAIMS each one just before it runs it (#2034), skipping one the
938
+ * claim finds no longer `running` or no longer due. A cursor across several reads met rows
939
+ * that another writer had moved in between, and ran one twice or skipped it. A run that
940
+ * moves, or becomes due, after the snapshot is the NEXT drive's business, never this one's.
696
941
  *
697
- * The re-read is a READ, not a reservation: it does not make overlapping drives safe. Two
698
- * drives on one scope at once can both re-read a row as due and both run its handler. The
699
- * one-driver-per-scope bound in the file header still applies.
942
+ * The claim is a reservation, not a read: of two drives overlapping on one scope that both
943
+ * picked a run, exactly one wins it, and only the winner invokes the handler (see the
944
+ * file header). A second pass in the same drive (`maxPasses`) claims again, with a fresh
945
+ * owner, so it holds a lease of its own rather than one the first pass released.
700
946
  *
701
947
  * The starvation was spotted while re-reading this file, judged unlikely and left
702
948
  * alone — and then found independently by a reviewer. The judgement may even have
@@ -711,8 +957,10 @@ export async function runDueJobRuns(options) {
711
957
  completed: 0,
712
958
  retrying: 0,
713
959
  failed: 0,
960
+ superseded: 0,
714
961
  deferred: 0,
715
962
  errors: [],
963
+ warnings: [],
716
964
  };
717
965
  const maxPasses = Math.max(1, options.maxPasses ?? 1);
718
966
  // Bound for the store's `LIMIT` (#1632): refused, not normalized, when it is not a positive integer.
@@ -732,54 +980,114 @@ export async function runDueJobRuns(options) {
732
980
  if (options.handlerFor(key))
733
981
  picked.push(key);
734
982
  }
983
+ /** Tally one pass's outcome; a deferral's error is the door's, and never on the record. */
984
+ const count = (runId, outcome) => {
985
+ report[outcome.status] += 1;
986
+ if (outcome.error !== undefined && outcome.status !== 'deferred')
987
+ report.errors.push({ runId, error: outcome.error });
988
+ };
989
+ const monotonic = options.monotonic ?? (() => performance.now());
990
+ /**
991
+ * #2034: take run `id` for one pass, or null — under an owner minted for this pass alone (#2042
992
+ * r1). Claim it; a takeover of a BEGUN lease the job's policy has no attempt left for fails it
993
+ * here without invoking; otherwise BEGIN, the commitment point (see the file header). A claim
994
+ * whose reply left less than the margin of the lease by this drive's own monotonic count (from
995
+ * just before it was sent, so the whole round trip is counted), or whose BEGIN the store
996
+ * refused, is an admission miss: it releases the run through the store's miss transaction and
997
+ * runs nothing.
998
+ */
999
+ const acquire = async (id, registered) => {
1000
+ const leaseMs = registered.leaseMs ?? JOB_LEASE_MS;
1001
+ const owner = ulid();
1002
+ const sent = monotonic();
1003
+ const won = await options.store.claim(id, owner, leaseMs);
1004
+ if (!won)
1005
+ return null;
1006
+ // An expired lease taken over is a pass that began and never reported, so it was an attempt (the
1007
+ // claim counted it). Judged against the job's policy like any failure no step owns: a pass that
1008
+ // keeps dying ends the run here, rather than being retried at every expiry forever.
1009
+ if (won.takeover && won.run.attempts >= resolveRetryPolicy(registered.retry).maxAttempts) {
1010
+ report.attempted += 1;
1011
+ const settled = await settle(options.store, won.run, owner, options.now(), {
1012
+ status: 'failed',
1013
+ attempts: won.run.attempts,
1014
+ lastError: JOB_LEASE_EXPIRED_NOTE,
1015
+ nextAttemptAt: null,
1016
+ });
1017
+ count(id, settled ? { status: 'failed', error: JOB_LEASE_EXPIRED_NOTE } : { status: 'superseded' });
1018
+ return null;
1019
+ }
1020
+ const margin = leaseMs * JOB_LEASE_ENTRY_MARGIN;
1021
+ if (leaseMs - (monotonic() - sent) > margin && (await options.store.begin(id, owner, margin))) {
1022
+ // BEGIN wrote: the pass is committed, and runs — its reply is not second-guessed.
1023
+ return { ...won, owner, leaseMs };
1024
+ }
1025
+ // An admission miss (#2042 r3, r4): no handler ran, so no attempt is charged; the store counts
1026
+ // the miss and sets the backoff, or past JOB_ADMISSION_MISS_MAX fails the run.
1027
+ const observed = Math.round(monotonic() - sent);
1028
+ const describe = (misses) => `job ${won.run.module_id}/${won.run.job}: claim not begun in time — leaseMs ${leaseMs}, margin ` +
1029
+ `${margin} ms, claim and begin took ${observed} ms (admission miss ${misses} of ${JOB_ADMISSION_MISS_MAX})`;
1030
+ // The note a failure records names the miss it is: the count the store is about to reach.
1031
+ const missed = await options.store.miss(id, owner, describe((won.run.admission_misses ?? 0) + 1));
1032
+ if (!missed) {
1033
+ report.superseded += 1; // it lost the run meanwhile: the holder now owns it, and its count
1034
+ return null;
1035
+ }
1036
+ const warning = describe(missed.misses);
1037
+ report.warnings.push({ runId: id, warning });
1038
+ console.log(JSON.stringify({
1039
+ substrat: 'job-admission-miss',
1040
+ runId: id,
1041
+ job: `${won.run.module_id}/${won.run.job}`,
1042
+ leaseMs,
1043
+ marginMs: margin,
1044
+ observedMs: observed,
1045
+ misses: missed.misses,
1046
+ max: JOB_ADMISSION_MISS_MAX,
1047
+ }));
1048
+ if (missed.failed) {
1049
+ report.failed += 1;
1050
+ report.errors.push({ runId: id, error: `${JOB_LEASE_TOO_SHORT_NOTE}: ${warning}` });
1051
+ }
1052
+ else {
1053
+ report.superseded += 1;
1054
+ }
1055
+ return null;
1056
+ };
735
1057
  for (const key of picked) {
736
- // The re-read: the row as it is NOW. One that was finished, or moved past this moment, since
737
- // the snapshot is skipped; it is the next drive's. This reserves nothing (see above).
738
- const row = await options.store.get(key.id);
739
- if (!row || row.status !== 'running' || (row.next_attempt_at !== null && row.next_attempt_at > options.now())) {
1058
+ const registered = options.handlerFor(key);
1059
+ // The run is this drive's only if it is still running and due NOW by the store's clock, and its
1060
+ // pass begins; then no other drive's until the lease ends. One that was finished, moved, or
1061
+ // claimed by another drive since the snapshot is skipped; it is the next drive's.
1062
+ let claimed = await acquire(key.id, registered);
1063
+ if (!claimed)
740
1064
  continue;
741
- }
742
- const registered = options.handlerFor(row);
743
1065
  report.attempted += 1;
744
- let run = row;
745
1066
  for (let pass = 0; pass < maxPasses; pass += 1) {
1067
+ const run = claimed.run;
746
1068
  const outcome = await runJobPass({
747
1069
  store: options.store,
748
1070
  run,
1071
+ owner: claimed.owner,
1072
+ leaseMs: claimed.leaseMs,
749
1073
  handler: registered.handler,
750
1074
  retry: registered.retry,
751
1075
  now: options.now,
752
1076
  openScope: (pass) => options.openScope(run, pass),
753
1077
  deferral: options.deferral,
754
1078
  });
755
- if (outcome.status === 'completed') {
756
- report.completed += 1;
757
- break;
758
- }
759
- if (outcome.status === 'failed') {
760
- report.failed += 1;
761
- report.errors.push({ runId: run.id, error: outcome.error });
762
- break;
763
- }
764
- if (outcome.status === 'deferred') {
765
- report.deferred += 1;
766
- break;
767
- }
768
- if (outcome.status === 'retrying') {
769
- report.retrying += 1;
770
- report.errors.push({ runId: run.id, error: outcome.error });
1079
+ count(run.id, outcome);
1080
+ if (outcome.status !== 'advanced')
771
1081
  break;
772
- }
773
- report.advanced += 1;
774
- // A second pass in this call resumes from what the first one COMMITTED, read
775
- // back rather than reconstructed: the cursor is the handler's value and the
776
- // store is the only thing that knows it survived the write.
1082
+ // A second pass in this call resumes from what the first one COMMITTED, as its own claim
1083
+ // reads it back: the cursor is the handler's value and the store is the only thing that
1084
+ // knows it survived the write. Another drive may have claimed it in between; then it is theirs.
777
1085
  if (pass + 1 >= maxPasses)
778
1086
  break;
779
- const fresh = await options.store.get(run.id);
780
- if (!fresh || fresh.status !== 'running')
1087
+ const next = await acquire(run.id, registered);
1088
+ if (!next)
781
1089
  break;
782
- run = fresh;
1090
+ claimed = next;
783
1091
  }
784
1092
  }
785
1093
  return report;