@substrat-run/kernel 0.136.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.
- package/dist/capability.d.ts +2 -0
- package/dist/capability.d.ts.map +1 -1
- package/dist/capability.js +2 -0
- package/dist/capability.js.map +1 -1
- package/dist/delivery-refusal.d.ts +21 -0
- package/dist/delivery-refusal.d.ts.map +1 -0
- package/dist/delivery-refusal.js +21 -0
- package/dist/delivery-refusal.js.map +1 -0
- package/dist/index.d.ts +23 -17
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -12
- package/dist/index.js.map +1 -1
- package/dist/invocation-log.d.ts +25 -1
- package/dist/invocation-log.d.ts.map +1 -1
- package/dist/invocation-log.js +45 -6
- package/dist/invocation-log.js.map +1 -1
- package/dist/job-run.d.ts +370 -47
- package/dist/job-run.d.ts.map +1 -1
- package/dist/job-run.js +472 -81
- package/dist/job-run.js.map +1 -1
- package/dist/lifecycle-flow.d.ts.map +1 -1
- package/dist/lifecycle-flow.js +2 -1
- package/dist/lifecycle-flow.js.map +1 -1
- package/dist/membership-executor.d.ts +97 -0
- package/dist/membership-executor.d.ts.map +1 -0
- package/dist/membership-executor.js +246 -0
- package/dist/membership-executor.js.map +1 -0
- package/dist/membership-fence.d.ts +57 -0
- package/dist/membership-fence.d.ts.map +1 -0
- package/dist/membership-fence.js +90 -0
- package/dist/membership-fence.js.map +1 -0
- package/dist/operation-series.d.ts +58 -0
- package/dist/operation-series.d.ts.map +1 -0
- package/dist/operation-series.js +63 -0
- package/dist/operation-series.js.map +1 -0
- package/dist/peer.d.ts +13 -8
- package/dist/peer.d.ts.map +1 -1
- package/dist/peer.js +13 -14
- package/dist/peer.js.map +1 -1
- package/dist/permission-eval.d.ts +48 -1
- package/dist/permission-eval.d.ts.map +1 -1
- package/dist/permission-eval.js +137 -18
- package/dist/permission-eval.js.map +1 -1
- package/dist/read-only-sql.d.ts +2 -26
- package/dist/read-only-sql.d.ts.map +1 -1
- package/dist/read-only-sql.js +13 -9
- package/dist/read-only-sql.js.map +1 -1
- package/dist/refusal-query.d.ts +4 -1
- package/dist/refusal-query.d.ts.map +1 -1
- package/dist/refusal-query.js +6 -4
- package/dist/refusal-query.js.map +1 -1
- package/dist/refusals.d.ts +80 -8
- package/dist/refusals.d.ts.map +1 -1
- package/dist/refusals.js +141 -22
- package/dist/refusals.js.map +1 -1
- package/dist/scope-copy.d.ts +18 -0
- package/dist/scope-copy.d.ts.map +1 -1
- package/dist/scope-copy.js +18 -0
- package/dist/scope-copy.js.map +1 -1
- package/dist/scope-host.d.ts +266 -15
- package/dist/scope-host.d.ts.map +1 -1
- package/dist/scope-host.js +41 -0
- package/dist/scope-host.js.map +1 -1
- package/dist/spine-restore.d.ts +7 -2
- package/dist/spine-restore.d.ts.map +1 -1
- package/dist/spine-restore.js +13 -5
- package/dist/spine-restore.js.map +1 -1
- package/dist/system-switch-record.d.ts +290 -74
- package/dist/system-switch-record.d.ts.map +1 -1
- package/dist/system-switch-record.js +353 -101
- package/dist/system-switch-record.js.map +1 -1
- package/dist/system-switch.d.ts +146 -20
- package/dist/system-switch.d.ts.map +1 -1
- package/dist/system-switch.js +135 -27
- package/dist/system-switch.js.map +1 -1
- package/dist/timeline.d.ts +23 -0
- package/dist/timeline.d.ts.map +1 -1
- package/dist/timeline.js +30 -0
- package/dist/timeline.js.map +1 -1
- package/dist/ulid.d.ts +5 -0
- package/dist/ulid.d.ts.map +1 -1
- package/dist/ulid.js +9 -0
- package/dist/ulid.js.map +1 -1
- package/dist/vertical-events.d.ts +7 -1
- package/dist/vertical-events.d.ts.map +1 -1
- package/dist/vertical-events.js +14 -0
- package/dist/vertical-events.js.map +1 -1
- 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
|
|
64
|
+
* ## One pass per run at a time — a lease (#2034)
|
|
64
65
|
*
|
|
65
|
-
* Coalescing stops duplicate RUNS.
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* and
|
|
70
|
-
*
|
|
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
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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,16 +182,33 @@ 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
|
|
138
205
|
-- table that RETAINS every finished run, so the cost tracks how much is in flight
|
|
139
206
|
-- rather than how much the scope has ever imported.
|
|
140
207
|
CREATE INDEX IF NOT EXISTS _substrat_job_runs_due ON _substrat_job_runs (status, next_attempt_at, id);
|
|
208
|
+
-- #1834: the drive's ORDER -- by when each run became due (JOB_RUN_DUE_AT), then id -- so a run
|
|
209
|
+
-- that waited (a deferral, a backoff) queues behind work that was due before it, instead of
|
|
210
|
+
-- heading every drive by its age. The expression is spelled exactly as the query spells it.
|
|
211
|
+
CREATE INDEX IF NOT EXISTS _substrat_job_runs_due_at ON _substrat_job_runs (status, COALESCE(next_attempt_at, started_at), id);
|
|
141
212
|
-- Coalescing's read, and the operator read's filter.
|
|
142
213
|
CREATE INDEX IF NOT EXISTS _substrat_job_runs_key ON _substrat_job_runs (module_id, job, instance, id);
|
|
143
214
|
-- The step ledger of the pass currently in flight. Rows are written as each step
|
|
@@ -165,7 +236,7 @@ export const JOB_RUN_DDL = `
|
|
|
165
236
|
`;
|
|
166
237
|
/**
|
|
167
238
|
* The write every pass outcome lands through, on both adapters — a compare-and-set on
|
|
168
|
-
* `status = 'running'` (#1632).
|
|
239
|
+
* `status = 'running'` (#1632) and on the pass's lease (#2034), which it releases.
|
|
169
240
|
*
|
|
170
241
|
* A pass runs for as long as its handler takes, outside any lock, so a subject erasure
|
|
171
242
|
* can settle the run `failed` and tombstone its payload, cursor and step memos while the
|
|
@@ -175,15 +246,107 @@ export const JOB_RUN_DDL = `
|
|
|
175
246
|
* nothing else in the kernel moves a run out of that state except the pass itself and
|
|
176
247
|
* the erasure. Same shape, same reason, as `settlePlatformRequest`'s CAS on `pending`.
|
|
177
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
|
+
*
|
|
178
257
|
* Params: status, cursor, counters, attempts, last_error, updated_at, next_attempt_at,
|
|
179
|
-
* ended_at, id.
|
|
258
|
+
* ended_at, id, lease_owner.
|
|
180
259
|
*/
|
|
181
260
|
export const JOB_RUN_PATCH_SQL = `UPDATE _substrat_job_runs
|
|
182
261
|
SET status = ?, cursor = ?, counters = ?, attempts = ?, last_error = ?,
|
|
183
|
-
updated_at = ?, next_attempt_at = ?, ended_at =
|
|
184
|
-
|
|
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 ?`;
|
|
265
|
+
/**
|
|
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`;
|
|
185
316
|
/**
|
|
186
|
-
*
|
|
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.
|
|
187
350
|
*
|
|
188
351
|
* The step half of `JOB_RUN_PATCH_SQL`'s CAS: a stale pass's step, finishing after an
|
|
189
352
|
* erasure settled the run, would otherwise write a fresh result carrying the person
|
|
@@ -191,11 +354,11 @@ export const JOB_RUN_PATCH_SQL = `UPDATE _substrat_job_runs
|
|
|
191
354
|
* `VALUES` so the guard and the write are one statement; the `WHERE` also resolves
|
|
192
355
|
* SQLite's parse ambiguity between a SELECT's trailing clause and `ON CONFLICT`.
|
|
193
356
|
*
|
|
194
|
-
* 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.
|
|
195
358
|
*/
|
|
196
359
|
export const JOB_STEP_RECORD_SQL = `INSERT INTO _substrat_job_steps (run_id, step, result, attempts, last_error, recorded_at)
|
|
197
360
|
SELECT ?, ?, ?, ?, ?, ?
|
|
198
|
-
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 ?)
|
|
199
362
|
ON CONFLICT (run_id, step) DO UPDATE SET result = excluded.result,
|
|
200
363
|
attempts = excluded.attempts,
|
|
201
364
|
last_error = excluded.last_error,
|
|
@@ -219,6 +382,50 @@ export function jobRunListLimit(limit) {
|
|
|
219
382
|
return JOB_RUN_LIST_LIMIT;
|
|
220
383
|
return Math.min(JOB_RUN_LIST_MAX, Math.max(1, Math.floor(limit)));
|
|
221
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
|
+
}
|
|
222
429
|
/** Runs one `runDueJobs` call picks up by default. */
|
|
223
430
|
export const JOB_DRIVE_LIMIT = 50;
|
|
224
431
|
/**
|
|
@@ -233,6 +440,41 @@ export const JOB_DRIVE_LIMIT = 50;
|
|
|
233
440
|
export const JOB_DRIVE_SCAN_MAX = 500;
|
|
234
441
|
/** `conflict` reason: two `step()` calls under one name in one pass. */
|
|
235
442
|
export const JOB_STEP_REUSED = 'job_step_reused';
|
|
443
|
+
/**
|
|
444
|
+
* #1834: the reason a host's system door gives a refusal that means "not now", never "no": the
|
|
445
|
+
* module is held off on this scope until its switch is applied again, or the scope kept restarting
|
|
446
|
+
* under the door. It is for a caller READING the refusal. It decides nothing: anyone can throw an
|
|
447
|
+
* error with this reason, so the job driver never defers on it (`deferral` below is how a host
|
|
448
|
+
* says which refusals are its own).
|
|
449
|
+
*/
|
|
450
|
+
export const SYSTEM_DOOR_WAIT = 'system_door_wait';
|
|
451
|
+
/**
|
|
452
|
+
* #1834: when a run became due — its `next_attempt_at`, or when it started if it has none — and the
|
|
453
|
+
* key the drive orders by. Ordering by id instead let an older run that keeps waiting (a deferral, a
|
|
454
|
+
* backoff) head every drive the moment it was due again, and take the turn of a run that had been
|
|
455
|
+
* due for longer. Spelled exactly as the `_substrat_job_runs_due_at` index spells it.
|
|
456
|
+
*/
|
|
457
|
+
export const JOB_RUN_DUE_AT = 'COALESCE(next_attempt_at, started_at)';
|
|
458
|
+
/**
|
|
459
|
+
* #1834: a pass that a host's door told to wait, carrying the pass it belongs to. Kernel-private,
|
|
460
|
+
* and checked against the CURRENT pass, so a handler that catches one and throws it again on a
|
|
461
|
+
* later pass is an ordinary failure there, not a wait.
|
|
462
|
+
*/
|
|
463
|
+
class PassDeferred extends Error {
|
|
464
|
+
pass;
|
|
465
|
+
cause;
|
|
466
|
+
constructor(pass, cause) {
|
|
467
|
+
super(message(cause));
|
|
468
|
+
this.pass = pass;
|
|
469
|
+
this.cause = cause;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* #1834: how long a deferred run waits before it is due again. Not an attempt: nothing counts and
|
|
474
|
+
* there is no backoff. It only stops a waiting run from heading the due order on every drive, where
|
|
475
|
+
* it would take the turn of a run behind it that could make progress.
|
|
476
|
+
*/
|
|
477
|
+
export const JOB_DEFER_MS = 60_000;
|
|
236
478
|
const message = (err) => (err instanceof Error ? err.message : String(err));
|
|
237
479
|
/** What a value is, for the refusal message: `a Uint8Array`, `a function`, `a Date`. */
|
|
238
480
|
function describe(value) {
|
|
@@ -363,6 +605,8 @@ export function jobRunOf(row) {
|
|
|
363
605
|
updatedAt: row.updated_at,
|
|
364
606
|
nextAttemptAt: row.next_attempt_at,
|
|
365
607
|
endedAt: row.ended_at,
|
|
608
|
+
leaseOwner: row.lease_owner ?? null,
|
|
609
|
+
admissionMisses: row.admission_misses ?? 0,
|
|
366
610
|
decodeError,
|
|
367
611
|
};
|
|
368
612
|
}
|
|
@@ -409,6 +653,7 @@ export async function startJobRun(store, input, mintId, now) {
|
|
|
409
653
|
updated_at: at,
|
|
410
654
|
next_attempt_at: null,
|
|
411
655
|
ended_at: null,
|
|
656
|
+
lease_owner: null,
|
|
412
657
|
};
|
|
413
658
|
// The row is built unconditionally — an id is minted and a start time stamped even
|
|
414
659
|
// when this call turns out to be a join. That is the price of doing the decision in
|
|
@@ -437,6 +682,33 @@ class JobStepFailure extends Error {
|
|
|
437
682
|
this.name = 'JobStepFailure';
|
|
438
683
|
}
|
|
439
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
|
+
}
|
|
440
712
|
/**
|
|
441
713
|
* Run ONE pass of one run, and write what happened.
|
|
442
714
|
*
|
|
@@ -455,7 +727,21 @@ class JobStepFailure extends Error {
|
|
|
455
727
|
* failure `runDueSchedules` already refuses for schedules.
|
|
456
728
|
*/
|
|
457
729
|
export async function runJobPass(options) {
|
|
458
|
-
const { store, run, handler, now, openScope } = options;
|
|
730
|
+
const { store, run, owner, handler, now, openScope } = options;
|
|
731
|
+
const leaseMs = options.leaseMs ?? JOB_LEASE_MS;
|
|
732
|
+
// This pass's token, and nothing else's: the door's refusals are tied to it.
|
|
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
|
+
};
|
|
739
|
+
/** Asked once per refusal, since the host consumes its mark; the answer then travels as `PassDeferred`. */
|
|
740
|
+
const deferral = (err) => {
|
|
741
|
+
if (err instanceof PassDeferred)
|
|
742
|
+
return err.pass === passToken ? err : null;
|
|
743
|
+
return options.deferral?.(err, passToken) === true ? new PassDeferred(passToken, err) : null;
|
|
744
|
+
};
|
|
459
745
|
const jobPolicy = resolveRetryPolicy(options.retry);
|
|
460
746
|
const usedThisPass = new Set();
|
|
461
747
|
let scope = null;
|
|
@@ -490,7 +776,7 @@ export async function runJobPass(options) {
|
|
|
490
776
|
count: (name, by = 1) => {
|
|
491
777
|
counters[name] = (counters[name] ?? 0) + by;
|
|
492
778
|
},
|
|
493
|
-
scope: () => (scope ??= openScope()),
|
|
779
|
+
scope: () => (scope ??= openScope(passToken)),
|
|
494
780
|
step: async (name, fn, retry) => {
|
|
495
781
|
if (usedThisPass.has(name)) {
|
|
496
782
|
// The determinism rule's mechanical half. The second call would read the
|
|
@@ -503,7 +789,10 @@ export async function runJobPass(options) {
|
|
|
503
789
|
}
|
|
504
790
|
usedThisPass.add(name);
|
|
505
791
|
const policy = resolveRetryPolicy(retry ?? options.retry);
|
|
506
|
-
|
|
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;
|
|
507
796
|
// A NON-NULL result is what means completed: a step that threw left its row
|
|
508
797
|
// with a null result and a raised count, and must run again.
|
|
509
798
|
if (prior && prior.result !== null)
|
|
@@ -523,14 +812,20 @@ export async function runJobPass(options) {
|
|
|
523
812
|
value = await fn();
|
|
524
813
|
}
|
|
525
814
|
catch (err) {
|
|
815
|
+
// #1834: the door's "not now" is not this step's failure, so it is not one of its attempts.
|
|
816
|
+
const wait = deferral(err);
|
|
817
|
+
if (wait)
|
|
818
|
+
throw wait;
|
|
526
819
|
const cause = message(err);
|
|
527
|
-
|
|
820
|
+
const at = now();
|
|
821
|
+
held(await store.recordStep(run.id, name, null, attempts, cause, at, owner, leaseMs));
|
|
528
822
|
throw new JobStepFailure(name, attempts, policy, cause);
|
|
529
823
|
}
|
|
530
824
|
// `undefined` becomes the JSON text 'null', not SQL NULL: a step done purely
|
|
531
825
|
// for its effect must still read as completed on the next pass.
|
|
532
826
|
const stored = JSON.stringify(value) ?? 'null';
|
|
533
|
-
|
|
827
|
+
const at = now();
|
|
828
|
+
held(await store.recordStep(run.id, name, stored, attempts, null, at, owner, leaseMs));
|
|
534
829
|
// RETURNED THROUGH THE STORED FORM, not as the raw value. The resume path
|
|
535
830
|
// returns `JSON.parse(row.result)`, so returning `value` here would hand the
|
|
536
831
|
// handler a `Date` on the first pass and the string `"2026-01-01T…"` on the
|
|
@@ -566,19 +861,37 @@ export async function runJobPass(options) {
|
|
|
566
861
|
// throw from the clear landed in the catch below, which wrote the OLD cursor
|
|
567
862
|
// back and filed an already-committed pass as failed, so the record a human
|
|
568
863
|
// reads to recover would have understated the run's own progress.
|
|
569
|
-
await store.commitPass(run.id, {
|
|
864
|
+
const committed = await store.commitPass(run.id, {
|
|
570
865
|
status: done ? 'done' : 'running',
|
|
571
866
|
cursor: keepsCursor ? run.cursor : JSON.stringify(result.cursor),
|
|
572
867
|
counters: JSON.stringify(counters),
|
|
573
868
|
attempts: 0,
|
|
574
869
|
lastError: null,
|
|
575
870
|
updatedAt: at,
|
|
871
|
+
// Cleared, never left at the lease's expiry: the lease ends with the pass (#2034).
|
|
576
872
|
nextAttemptAt: null,
|
|
577
873
|
endedAt: done ? at : null,
|
|
578
|
-
});
|
|
874
|
+
}, owner);
|
|
875
|
+
if (!committed)
|
|
876
|
+
return { status: 'superseded' };
|
|
579
877
|
return { status: done ? 'completed' : 'advanced' };
|
|
580
878
|
}
|
|
581
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' };
|
|
883
|
+
// #1834: the host's system door said wait, so the call did not run. The run keeps what the last
|
|
884
|
+
// commit wrote (its attempts and its last error included); only when it is next due moves.
|
|
885
|
+
if (deferral(err)) {
|
|
886
|
+
const at = now();
|
|
887
|
+
const patched = await settle(store, run, owner, at, {
|
|
888
|
+
status: 'running',
|
|
889
|
+
attempts: run.attempts,
|
|
890
|
+
lastError: run.last_error,
|
|
891
|
+
nextAttemptAt: plusMs(at, JOB_DEFER_MS),
|
|
892
|
+
});
|
|
893
|
+
return patched ? { status: 'deferred', error: message(err) } : { status: 'superseded' };
|
|
894
|
+
}
|
|
582
895
|
const stepFailure = err instanceof JobStepFailure ? err : null;
|
|
583
896
|
const policy = stepFailure?.policy ?? jobPolicy;
|
|
584
897
|
// The RUN's attempts counts consecutive failed passes — what an operator reads as
|
|
@@ -589,16 +902,15 @@ export async function runJobPass(options) {
|
|
|
589
902
|
const exhausted = against >= policy.maxAttempts;
|
|
590
903
|
const error = message(err);
|
|
591
904
|
const at = now();
|
|
592
|
-
await store
|
|
905
|
+
const patched = await settle(store, run, owner, at, {
|
|
593
906
|
status: exhausted ? 'failed' : 'running',
|
|
594
|
-
cursor: run.cursor,
|
|
595
|
-
counters: run.counters,
|
|
596
907
|
attempts,
|
|
597
908
|
lastError: error,
|
|
598
|
-
|
|
909
|
+
// The backoff, never the lease's expiry the claim wrote: the lease ends with the pass (#2034).
|
|
599
910
|
nextAttemptAt: exhausted ? null : backoffAt(against, policy, new Date(at)),
|
|
600
|
-
endedAt: exhausted ? at : null,
|
|
601
911
|
});
|
|
912
|
+
if (!patched)
|
|
913
|
+
return { status: 'superseded' };
|
|
602
914
|
return { status: exhausted ? 'failed' : 'retrying', error };
|
|
603
915
|
}
|
|
604
916
|
}
|
|
@@ -617,9 +929,21 @@ export async function runJobPass(options) {
|
|
|
617
929
|
* when the budget was applied to the query instead, a scope holding `limit` such rows
|
|
618
930
|
* at the head of the due order returned the same unrunnable batch on every call —
|
|
619
931
|
* nothing newer was ever reached, and the report said `attempted: 0` forever with no
|
|
620
|
-
* indication why. So the read
|
|
932
|
+
* indication why. So the read looks past them, bounded by `JOB_DRIVE_SCAN_MAX` rows
|
|
621
933
|
* examined so one scope full of orphans cannot turn a tick into a table scan.
|
|
622
934
|
*
|
|
935
|
+
* **Selection is ONE snapshot per drive** (#1834). The drive reads the keys of up to
|
|
936
|
+
* `JOB_DRIVE_SCAN_MAX` due runs in a single query, picks up to `limit` runnable ones
|
|
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.
|
|
941
|
+
*
|
|
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.
|
|
946
|
+
*
|
|
623
947
|
* The starvation was spotted while re-reading this file, judged unlikely and left
|
|
624
948
|
* alone — and then found independently by a reviewer. The judgement may even have
|
|
625
949
|
* been right; recording it only in the author's head was not, because a decision
|
|
@@ -633,70 +957,137 @@ export async function runDueJobRuns(options) {
|
|
|
633
957
|
completed: 0,
|
|
634
958
|
retrying: 0,
|
|
635
959
|
failed: 0,
|
|
960
|
+
superseded: 0,
|
|
961
|
+
deferred: 0,
|
|
636
962
|
errors: [],
|
|
963
|
+
warnings: [],
|
|
637
964
|
};
|
|
638
965
|
const maxPasses = Math.max(1, options.maxPasses ?? 1);
|
|
639
966
|
// Bound for the store's `LIMIT` (#1632): refused, not normalized, when it is not a positive integer.
|
|
640
967
|
const want = options.limit === undefined ? JOB_DRIVE_LIMIT : assertRowLimit('limit', options.limit);
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
//
|
|
645
|
-
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
while (runnable.length < want && scanned < JOB_DRIVE_SCAN_MAX) {
|
|
650
|
-
const batch = await options.store.due(options.now(), want, afterId);
|
|
651
|
-
if (batch.length === 0)
|
|
652
|
-
break;
|
|
653
|
-
scanned += batch.length;
|
|
654
|
-
for (const row of batch) {
|
|
655
|
-
if (options.handlerFor(row) && runnable.length < want)
|
|
656
|
-
runnable.push(row);
|
|
657
|
-
}
|
|
658
|
-
afterId = batch[batch.length - 1].id;
|
|
659
|
-
// A short batch is the end of the due set; another round trip would read nothing.
|
|
660
|
-
if (batch.length < want)
|
|
968
|
+
// ONE snapshot of the due keys, then up to `want` RUNNABLE ones from it, each id once. A
|
|
969
|
+
// run whose job this host does not register — another deployment's, or one whose
|
|
970
|
+
// registration was removed — is stepped over and NOT failed: failing it would destroy
|
|
971
|
+
// a resumable run because the wrong process happened to look at it.
|
|
972
|
+
const picked = [];
|
|
973
|
+
const seen = new Set();
|
|
974
|
+
for (const key of await options.store.dueKeys(options.now(), JOB_DRIVE_SCAN_MAX)) {
|
|
975
|
+
if (picked.length >= want)
|
|
661
976
|
break;
|
|
977
|
+
if (seen.has(key.id))
|
|
978
|
+
continue;
|
|
979
|
+
seen.add(key.id);
|
|
980
|
+
if (options.handlerFor(key))
|
|
981
|
+
picked.push(key);
|
|
662
982
|
}
|
|
663
|
-
|
|
664
|
-
|
|
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
|
+
};
|
|
1057
|
+
for (const key of picked) {
|
|
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)
|
|
1064
|
+
continue;
|
|
665
1065
|
report.attempted += 1;
|
|
666
|
-
let run = row;
|
|
667
1066
|
for (let pass = 0; pass < maxPasses; pass += 1) {
|
|
1067
|
+
const run = claimed.run;
|
|
668
1068
|
const outcome = await runJobPass({
|
|
669
1069
|
store: options.store,
|
|
670
1070
|
run,
|
|
1071
|
+
owner: claimed.owner,
|
|
1072
|
+
leaseMs: claimed.leaseMs,
|
|
671
1073
|
handler: registered.handler,
|
|
672
1074
|
retry: registered.retry,
|
|
673
1075
|
now: options.now,
|
|
674
|
-
openScope: () => options.openScope(run),
|
|
1076
|
+
openScope: (pass) => options.openScope(run, pass),
|
|
1077
|
+
deferral: options.deferral,
|
|
675
1078
|
});
|
|
676
|
-
|
|
677
|
-
|
|
1079
|
+
count(run.id, outcome);
|
|
1080
|
+
if (outcome.status !== 'advanced')
|
|
678
1081
|
break;
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
report.errors.push({ runId: run.id, error: outcome.error });
|
|
683
|
-
break;
|
|
684
|
-
}
|
|
685
|
-
if (outcome.status === 'retrying') {
|
|
686
|
-
report.retrying += 1;
|
|
687
|
-
report.errors.push({ runId: run.id, error: outcome.error });
|
|
688
|
-
break;
|
|
689
|
-
}
|
|
690
|
-
report.advanced += 1;
|
|
691
|
-
// A second pass in this call resumes from what the first one COMMITTED, read
|
|
692
|
-
// back rather than reconstructed: the cursor is the handler's value and the
|
|
693
|
-
// 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.
|
|
694
1085
|
if (pass + 1 >= maxPasses)
|
|
695
1086
|
break;
|
|
696
|
-
const
|
|
697
|
-
if (!
|
|
1087
|
+
const next = await acquire(run.id, registered);
|
|
1088
|
+
if (!next)
|
|
698
1089
|
break;
|
|
699
|
-
|
|
1090
|
+
claimed = next;
|
|
700
1091
|
}
|
|
701
1092
|
}
|
|
702
1093
|
return report;
|