@jarenjs/db 0.56.0 → 0.67.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/ARCHITECTURE.md +412 -56
- package/README.md +600 -57
- package/docs/HOSTS.md +269 -0
- package/docs/JOBS-FORMAT.md +293 -45
- package/docs/LIVE-FORMAT.md +169 -20
- package/docs/MIGRATION-FORMAT.md +142 -17
- package/docs/MODEL-FORMAT.md +752 -64
- package/docs/REPLICATION-FORMAT.md +208 -0
- package/package.json +21 -7
- package/schemas/jaren-model.draft-07.schema.json +224 -162
- package/schemas/jaren-model.schema.json +224 -162
- package/schemas/jaren-replication-snapshot.draft-07.schema.json +83 -0
- package/schemas/jaren-replication-snapshot.schema.json +83 -0
- package/schemas/jaren-replication.draft-07.schema.json +82 -0
- package/schemas/jaren-replication.schema.json +82 -0
- package/src/algebra.js +227 -9
- package/src/backup.js +161 -0
- package/src/cancellation.js +48 -0
- package/src/capture.js +230 -47
- package/src/cli.js +165 -59
- package/src/cursor.js +417 -0
- package/src/dag-job.js +154 -21
- package/src/ddl.js +102 -8
- package/src/dialect.js +268 -113
- package/src/dialects/expression-read.js +158 -0
- package/src/dialects/postgres.js +618 -0
- package/src/dialects/rtree-ddl.js +129 -0
- package/src/dialects/sqlite.js +244 -11
- package/src/document-files.js +311 -0
- package/src/document-steps.js +422 -0
- package/src/documents.js +335 -0
- package/src/driver.js +448 -61
- package/src/drivers/bun.js +37 -1
- package/src/drivers/indexeddb-snapshot.js +149 -0
- package/src/drivers/node-pool.js +11 -0
- package/src/drivers/node-worker-endpoint.js +105 -0
- package/src/drivers/node-worker.js +204 -0
- package/src/drivers/node.js +41 -7
- package/src/drivers/postgres.js +331 -0
- package/src/drivers/wasm-oo1.js +97 -0
- package/src/drivers/wasm-session.js +67 -0
- package/src/drivers/wasm.js +17 -83
- package/src/drivers/worker-pool.js +183 -0
- package/src/drivers/worker-protocol.js +79 -0
- package/src/drivers/worker-queue.js +60 -0
- package/src/emit.js +339 -48
- package/src/entity.js +20 -22
- package/src/errors.js +430 -19
- package/src/expression.js +284 -0
- package/src/graph.js +64 -8
- package/src/index.js +48 -17
- package/src/introspect.js +583 -0
- package/src/jobs.js +843 -107
- package/src/json-bytes.js +58 -0
- package/src/live-join.js +250 -0
- package/src/live-nested.js +120 -0
- package/src/live.js +18 -4
- package/src/logical-rows.js +90 -0
- package/src/maintenance.js +175 -0
- package/src/migrate.js +248 -181
- package/src/model.js +68 -0
- package/src/plan.js +1119 -138
- package/src/pragmas.js +314 -0
- package/src/profile.js +151 -3
- package/src/query.js +1634 -323
- package/src/replication-format.js +115 -0
- package/src/replication.js +332 -0
- package/src/residual.js +17 -0
- package/src/series.js +12 -4
- package/src/store.js +1567 -273
- package/src/tracker.js +203 -29
- package/src/udf.js +88 -7
- package/types/index.d.ts +1158 -27
- package/types/node-pool.d.ts +28 -0
- package/types/node-worker.d.ts +54 -0
- package/types/node.d.ts +69 -2
- package/types/postgres.d.ts +46 -0
- package/types/typed.d.ts +27 -4
- package/types/wasm.d.ts +14 -0
package/docs/JOBS-FORMAT.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
This document is normative. The key words MUST, MUST NOT, SHOULD and
|
|
4
4
|
MAY are to be interpreted as described in RFC 2119. Error codes join
|
|
5
|
-
the package's single runtime table (MODEL-FORMAT §7)
|
|
6
|
-
|
|
5
|
+
the package's single runtime table (MODEL-FORMAT §7) — the fence's five
|
|
6
|
+
are listed in §9.
|
|
7
7
|
|
|
8
8
|
## 1. Scope
|
|
9
9
|
|
|
@@ -37,31 +37,38 @@ restarting.
|
|
|
37
37
|
| `id` | TEXT primary key; caller-supplied or a UUID. **A caller-supplied id makes `enqueue` idempotent**: re-enqueueing an existing id changes nothing and answers the id — this is the idempotency key of §7 |
|
|
38
38
|
| `kind` | the registered handler name (the suite's named-registry discipline) |
|
|
39
39
|
| `payload` | the enqueue payload, JSON text; `null` stays null |
|
|
40
|
-
| `state` | `pending` → `leased` → `done`, or `failed` (awaiting retry) → `dead` (attempts exhausted) |
|
|
40
|
+
| `state` | `pending` → `leased` → `done`, or `failed` (awaiting retry) → `dead` (attempts exhausted); `cancelled` (terminal, set only by `cancel()`, §10) |
|
|
41
41
|
| `run_at` | epoch ms eligibility: scheduling and retry backoff are the same mechanism |
|
|
42
42
|
| `attempts` | claims so far; incremented AT claim, so a crashed attempt counts |
|
|
43
43
|
| `max_attempts` | per job, default 5 |
|
|
44
|
-
| `lease_until`, `lease_owner` | the lease (§3); null unless leased |
|
|
44
|
+
| `lease_until`, `lease_owner` | the lease (§3); null unless leased. The owner is DIAGNOSTICS: one worker reuses one owner for every attempt it makes, so it never guards anything |
|
|
45
|
+
| `lease_generation` | the attempt this row is on, incremented by every claim. It identifies the attempt, which the owner cannot |
|
|
46
|
+
| `lease_token` | the opaque fence one claim mints; the guard every settling call carries. Never handed out by `get` |
|
|
45
47
|
| `last_error` | the last failure's `message`, retained through retries and into `dead` |
|
|
46
48
|
| `result` | the completion value, JSON text (§7 records the DAG output here) |
|
|
47
49
|
| `created_at`, `updated_at` | epoch ms |
|
|
48
50
|
|
|
49
|
-
`_jaren_job_checkpoints` holds `(run_id, node_id, value)`
|
|
50
|
-
flow checkpoint store of §7, keyed by the JOB id (the run id
|
|
51
|
-
job id)
|
|
52
|
-
|
|
51
|
+
`_jaren_job_checkpoints` holds `(run_id, node_id, value, generation)`
|
|
52
|
+
rows — the flow checkpoint store of §7, keyed by the JOB id (the run id
|
|
53
|
+
IS the job id) and stamped with the generation that wrote them. Both
|
|
54
|
+
tables are created on open when `jobs` is requested;
|
|
55
|
+
neither appears in the model. A database written before the fence is
|
|
56
|
+
upgraded IN PLACE — the three columns are added behind a presence check,
|
|
57
|
+
because jobs rows are live work and a rebuild would drop a queue.
|
|
58
|
+
`enqueue`'s options are validated as
|
|
53
59
|
they are stored: `runAt` must be a finite epoch in milliseconds and
|
|
54
60
|
`maxAttempts` a positive integer (`TypeError`) — a `NaN` eligibility
|
|
55
61
|
was once stored, and that job was pending forever.
|
|
56
62
|
|
|
57
|
-
## 3. Leasing and exactly-once
|
|
63
|
+
## 3. Leasing, the fence, and exactly-once settlement
|
|
58
64
|
|
|
59
65
|
Claiming is ONE guarded statement — one statement is one transaction,
|
|
60
66
|
so two workers cannot claim the same job, without any distributed
|
|
61
|
-
lock:
|
|
67
|
+
lock — and it MINTS the fence the attempt will settle with:
|
|
62
68
|
|
|
63
69
|
```sql
|
|
64
70
|
UPDATE "_jaren_jobs" SET state='leased', lease_owner=?, lease_until=?,
|
|
71
|
+
lease_generation=lease_generation+1, lease_token=?,
|
|
65
72
|
attempts=attempts+1, updated_at=?
|
|
66
73
|
WHERE id = (SELECT id FROM "_jaren_jobs"
|
|
67
74
|
WHERE (state='pending' OR state='failed'
|
|
@@ -75,16 +82,105 @@ RETURNING *
|
|
|
75
82
|
whose kind has no registered handler on this worker is simply never
|
|
76
83
|
claimed by it** — it stays `pending` and shows in `counts()`, it is
|
|
77
84
|
NOT dead-lettered by an accident of rollout order.
|
|
78
|
-
- Every later transition is guarded by the lease:
|
|
79
|
-
`… WHERE id=? AND state='leased' AND lease_owner=?`. A worker whose
|
|
80
|
-
lease expired mid-run (a stall, a long GC, a laptop lid) finds its
|
|
81
|
-
completion matching ZERO rows and its result discarded — the job
|
|
82
|
-
belongs to whoever re-claimed it. Execution is therefore
|
|
83
|
-
AT-LEAST-ONCE; **completion is exactly-once**. Idempotency of side
|
|
84
|
-
effects is the handler's responsibility (§7).
|
|
85
85
|
- Eligibility order is `run_at, created_at, id` — oldest first,
|
|
86
86
|
deterministic. There are no priority classes (§8).
|
|
87
87
|
|
|
88
|
+
### The lease is a capability
|
|
89
|
+
|
|
90
|
+
`claim` answers the job record with a frozen `lease` beside it:
|
|
91
|
+
|
|
92
|
+
```jsonc
|
|
93
|
+
{ "jobId": "…", "token": "…", "generation": 3,
|
|
94
|
+
"attempt": 3, "owner": "worker-a", "expiresAt": 1730000000000 }
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
It is a **token, never an owner**. One worker mints one owner and reuses
|
|
98
|
+
it for every attempt it ever makes, so an owner cannot say *which*
|
|
99
|
+
attempt is speaking — and the two attempts that matter are exactly the
|
|
100
|
+
ones an owner cannot tell apart: the corpse of an expired attempt and
|
|
101
|
+
the live one that re-claimed the job. The `generation` names the attempt;
|
|
102
|
+
the `token` proves the caller holds it.
|
|
103
|
+
|
|
104
|
+
It is also **immutable**. `renew` answers a NEW lease and retires the one
|
|
105
|
+
it was given, so a reference someone kept across a renewal can never
|
|
106
|
+
quietly become valid again.
|
|
107
|
+
|
|
108
|
+
`get()` returns the record with `leaseGeneration` but **no token**: a
|
|
109
|
+
record anyone can read must not carry the capability to settle the job it
|
|
110
|
+
describes.
|
|
111
|
+
|
|
112
|
+
### Every settling call carries the fence
|
|
113
|
+
|
|
114
|
+
`renew`, `complete`, `fail` and a checkpoint `save` all guard on:
|
|
115
|
+
|
|
116
|
+
```sql
|
|
117
|
+
… WHERE id=? AND state='leased' AND lease_token=? AND lease_until > ?
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The owner appears nowhere in that clause, and the validity check is not
|
|
121
|
+
optional: without it, a lease thirty seconds dead still completed a job.
|
|
122
|
+
|
|
123
|
+
A guard that matches nothing is **never `false`**. It is a coded refusal
|
|
124
|
+
naming which of three things happened, because a caller that cannot tell
|
|
125
|
+
"already done" from "you are stale" guesses, and guesses wrong:
|
|
126
|
+
|
|
127
|
+
| code | what happened |
|
|
128
|
+
|---|---|
|
|
129
|
+
| `JD2065` | the job is not leased — unknown, or already settled by someone else |
|
|
130
|
+
| `JD2066` | the lease was superseded: another claim or renewal holds it now, and the message names both generations |
|
|
131
|
+
| `JD2067` | the lease expired before the call, and the message says by how long |
|
|
132
|
+
|
|
133
|
+
A fourth, `JD2068`, catches the pre-fence spelling: `complete(id, owner,
|
|
134
|
+
result)` is refused by name and pointed at `job.lease`.
|
|
135
|
+
|
|
136
|
+
Settling **twice from one attempt** is idempotent, not a refusal — a §7
|
|
137
|
+
handler completes transactionally and the worker's own completion lands
|
|
138
|
+
behind it, and the row's generation says whose settlement it carries.
|
|
139
|
+
|
|
140
|
+
### What exactly-once means here, and what it does not
|
|
141
|
+
|
|
142
|
+
Execution is AT-LEAST-ONCE. **Settlement is exactly-once against the
|
|
143
|
+
store**: exactly one attempt's result can ever land on the row, and every
|
|
144
|
+
other attempt is told, by code, that it is not the one.
|
|
145
|
+
|
|
146
|
+
That is not the same as making an *external* effect exactly-once. If a
|
|
147
|
+
handler sends an email, charges a card or writes to another system, the
|
|
148
|
+
fence cannot un-send it: a reclaimed job runs the handler again, and both
|
|
149
|
+
runs reach the outside world even though only one of them will ever
|
|
150
|
+
settle the row. Idempotency of side effects remains the handler's
|
|
151
|
+
responsibility — the fence guarantees the *record*, not the world.
|
|
152
|
+
|
|
153
|
+
### Renewal
|
|
154
|
+
|
|
155
|
+
`jobs.renew(lease, { leaseMs })` moves the expiry out and mints a new
|
|
156
|
+
token, keeping the same generation (a renewal is the same attempt, so its
|
|
157
|
+
checkpoints stay its own). It is what a handler that legitimately
|
|
158
|
+
outlives `leaseMs` uses instead of hoping; the worker does it
|
|
159
|
+
automatically (§6).
|
|
160
|
+
|
|
161
|
+
### Ownership: root calls and the transactional outbox
|
|
162
|
+
|
|
163
|
+
The handle decides whose transaction a job write belongs to, exactly as
|
|
164
|
+
it does for collections (MODEL-FORMAT §5.1):
|
|
165
|
+
|
|
166
|
+
- **Root `store.jobs.*`** finite calls take the store gate. An enqueue,
|
|
167
|
+
claim, checkpoint or settlement made while an unrelated application
|
|
168
|
+
transaction is open **waits for its commit** (or refuses under
|
|
169
|
+
`transactions: 'strict'`) and can never join its rollback.
|
|
170
|
+
- **`tx.jobs.*`** — the jobs view a transaction callback receives — runs
|
|
171
|
+
as that exact scope. This is the **transactional outbox** spelling: an
|
|
172
|
+
enqueue there becomes visible with the domain transaction's commit and
|
|
173
|
+
vanishes with its rollback, atomically. A retained `tx.jobs` view is
|
|
174
|
+
`JD2070` once its scope settles.
|
|
175
|
+
- **A worker is a root-owned long-lived component**, wherever it was
|
|
176
|
+
created: its claims, renewals, checkpoint stores and settlements take
|
|
177
|
+
the root gate even when an application transaction happens to be open,
|
|
178
|
+
and creating a worker through a transaction view does not bind its
|
|
179
|
+
future loop to that transaction.
|
|
180
|
+
- **A checkpoint store keeps the ownership of the jobs view that created
|
|
181
|
+
it** — root stores gate, transaction-scoped stores stay pinned to
|
|
182
|
+
their exact scope and cannot switch.
|
|
183
|
+
|
|
88
184
|
## 4. Retry and dead-lettering
|
|
89
185
|
|
|
90
186
|
A failed attempt (the handler threw or rejected) records
|
|
@@ -98,8 +194,9 @@ A failed attempt (the handler threw or rejected) records
|
|
|
98
194
|
|
|
99
195
|
Backoff is exponential with jitter:
|
|
100
196
|
`min(cap, base × 2^(attempts−1)) × (0.5 + random()/2)`, defaults
|
|
101
|
-
`base` 1 000 ms, `cap` 60 000 ms. `Math.random` is the
|
|
102
|
-
default; the `random` option
|
|
197
|
+
`base` 1 000 ms, `cap` 60 000 ms. `Math.random` is the platform
|
|
198
|
+
default; the `random` option — or the store's runtime record
|
|
199
|
+
(`@jarenjs/core/runtime`), where no explicit option is given — injects a deterministic source and every
|
|
103
200
|
test in this repository does.
|
|
104
201
|
|
|
105
202
|
## 5. Recovery
|
|
@@ -108,16 +205,25 @@ A worker that dies mid-job leaves a `leased` row whose `lease_until`
|
|
|
108
205
|
passes; the claim statement (§3) treats an expired lease exactly like
|
|
109
206
|
`pending`, so **recovery is not a separate sweeper — it is the next
|
|
110
207
|
claim**. The reclaimed attempt re-runs the handler; a §7 DAG job
|
|
111
|
-
resumes from its checkpoint rows rather than restarting
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
208
|
+
resumes from its checkpoint rows rather than restarting, and the
|
|
209
|
+
reclaimed attempt reads the checkpoints written up to and including its
|
|
210
|
+
own generation, so it resumes from its predecessor's work.
|
|
211
|
+
|
|
212
|
+
The reclaim also FENCES the attempt it displaced: the older token settles
|
|
213
|
+
nothing from that moment, and its settlement is refused `JD2066` rather
|
|
214
|
+
than silently discarded. A stale attempt therefore cannot mark the job
|
|
215
|
+
done over the live one's result, and cannot prune the live one's
|
|
216
|
+
checkpoints — a settlement deletes only generations at or below its own.
|
|
217
|
+
|
|
218
|
+
The lease duration (`leaseMs`, default 30 000 ms) is the recovery
|
|
219
|
+
latency ceiling. A handler that legitimately outlives it does not have to
|
|
220
|
+
be reclaimed: the worker renews while it runs (§6).
|
|
115
221
|
|
|
116
222
|
## 6. Workers and concurrency
|
|
117
223
|
|
|
118
224
|
`createWorker({ handlers, concurrency, pollInterval, leaseMs, owner,
|
|
119
|
-
backoffBase, backoffCap, stopGraceMs })` returns
|
|
120
|
-
stats() }`:
|
|
225
|
+
renew, onOutcome, backoffBase, backoffCap, stopGraceMs })` returns
|
|
226
|
+
`{ start(), stop(), stats(), leases() }`:
|
|
121
227
|
|
|
122
228
|
- `concurrency` (default 1, a positive integer — `TypeError`
|
|
123
229
|
otherwise, because a worker with zero loops would `start()` and never
|
|
@@ -136,11 +242,36 @@ stats() }`:
|
|
|
136
242
|
answering
|
|
137
243
|
`{ drained, inFlight }`; a loop the grace period could not drain is
|
|
138
244
|
**cancelled**, not left running — see §6.1;
|
|
139
|
-
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
245
|
+
- each in-flight attempt's lease is **renewed** while its handler runs,
|
|
246
|
+
at a third of `leaseMs`, so two renewals may fail before the attempt
|
|
247
|
+
is actually at risk. Every renewal replaces the lease. `renew: false`
|
|
248
|
+
turns it off for a handler that must not outlive its lease.
|
|
249
|
+
**A renewal that fails for a storage reason is not lease loss**: only
|
|
250
|
+
the three fence codes (`JD2065`/`JD2066`/`JD2067`) prove the attempt
|
|
251
|
+
no longer holds the job. On any other failure — a busy database, a
|
|
252
|
+
contended gate — the current immutable lease stays in force, the
|
|
253
|
+
handler's signal stays live, and the next renewal is armed; if the
|
|
254
|
+
lease truly expires first, the next fenced call answers `JD2067` and
|
|
255
|
+
that is the one recorded loss;
|
|
256
|
+
- an attempt whose lease is **lost** — superseded or expired — has its
|
|
257
|
+
signal aborted with the refusal as the reason, and is then barred from
|
|
258
|
+
settling. It is its own outcome: `lostSettlements`, never a completion
|
|
259
|
+
(which is how a corpse once reported success over work another attempt
|
|
260
|
+
was still doing) and never a failure (which would burn a retry the job
|
|
261
|
+
never spent);
|
|
262
|
+
- `onOutcome` is called once per settled attempt —
|
|
263
|
+
`{ outcome: 'completed' | 'failed' | 'lost', jobId, kind, attempt,
|
|
264
|
+
generation, code?, reason? }`. An observer that throws never affects
|
|
265
|
+
the loop;
|
|
266
|
+
- `leases()` lists the leases this worker holds right now, one per
|
|
267
|
+
in-flight attempt. In-flight state is keyed by the fence TOKEN, never
|
|
268
|
+
by the owner: one worker reuses one owner, and two attempts of one job
|
|
269
|
+
must not collide in its own bookkeeping;
|
|
270
|
+
- `stats()` reports claims, completions, failures, wakes, polls,
|
|
271
|
+
renewals, lost settlements, the in-flight handler count and
|
|
272
|
+
`claimErrors` — a claim statement the database refused (a read-only
|
|
273
|
+
file, a closed store), counted rather than swallowed, so a worker that
|
|
274
|
+
can never claim is visible instead of silently idle.
|
|
144
275
|
|
|
145
276
|
### 6.1 A handler cannot break the loop, and cannot hold shutdown
|
|
146
277
|
|
|
@@ -160,13 +291,20 @@ job:
|
|
|
160
291
|
|
|
161
292
|
```js
|
|
162
293
|
handlers: {
|
|
163
|
-
sync: async (payload, { job, signal,
|
|
294
|
+
sync: async (payload, { job, signal, checkpoints }) => {
|
|
164
295
|
const res = await fetch(url, { signal }); // cancelled on stop()
|
|
165
296
|
…
|
|
166
297
|
},
|
|
167
298
|
}
|
|
168
299
|
```
|
|
169
300
|
|
|
301
|
+
The same signal aborts for the other reason a handler must wind up: this
|
|
302
|
+
attempt no longer holds the job. Its `reason` is then the coded refusal
|
|
303
|
+
that says which — so a handler can tell "we are shutting down" from "you
|
|
304
|
+
have been superseded" without asking. `checkpoints` is the §7 store
|
|
305
|
+
already bound to this attempt, and it follows the attempt's current
|
|
306
|
+
lease, so a renewal does not strand it.
|
|
307
|
+
|
|
170
308
|
`stop({ graceMs })` aborts the signal, waits up to `graceMs`, and then
|
|
171
309
|
returns `{ drained, inFlight }` regardless. `store.close({ graceMs })`
|
|
172
310
|
does the same and **closes the connection either way**, then reports
|
|
@@ -185,6 +323,18 @@ re-registers the worker's wake-on-enqueue hook and its place in
|
|
|
185
323
|
`stopAll`), so a cancelled loop can never be revived as an extra
|
|
186
324
|
claimer.
|
|
187
325
|
|
|
326
|
+
`stop()` carries a second, UNCONDITIONAL obligation beside the
|
|
327
|
+
grace-bounded handler drain, and the two never share one unresolved
|
|
328
|
+
promise: **quiescence**. Before `stop()` resolves, every claim, renewal,
|
|
329
|
+
checkpoint and settlement the worker itself started is cancelled or
|
|
330
|
+
drained — a claim still queued behind an open application transaction
|
|
331
|
+
leaves the connection's queue on the shutdown signal instead of running
|
|
332
|
+
later against a closing store — and no poll or renewal timer stays
|
|
333
|
+
armed or re-arms. A grace timeout may detach a hostile handler
|
|
334
|
+
(`{ drained: false, inFlight }`); it may not leave a database operation
|
|
335
|
+
behind it. This is what makes `worker.stop()` followed by
|
|
336
|
+
`store.close()` release the database file deterministically.
|
|
337
|
+
|
|
188
338
|
## 7. The DAG composition
|
|
189
339
|
|
|
190
340
|
```js
|
|
@@ -202,19 +352,52 @@ await store.jobs.enqueue('sync-report', { input: { day: '2026-08-05' } });
|
|
|
202
352
|
`@jarenjs/db`'s manifest and import graph name `@jarenjs/flow`
|
|
203
353
|
nowhere, asserted by test.
|
|
204
354
|
- The job id is the run id. Node values save into
|
|
205
|
-
`_jaren_job_checkpoints` as the run progresses
|
|
206
|
-
|
|
207
|
-
statement** — one transaction, so
|
|
208
|
-
checkpoint rows of a finished job are
|
|
355
|
+
`_jaren_job_checkpoints` as the run progresses, each stamped with the
|
|
356
|
+
writing attempt's generation; `complete` records the DAG result AND
|
|
357
|
+
marks the job `done` **in one guarded statement** — one transaction, so
|
|
358
|
+
a failure leaves neither, and the checkpoint rows of a finished job are
|
|
359
|
+
pruned in the same breath. The prune deletes only generations at or
|
|
360
|
+
below the settling lease's, so a stale attempt cannot take a live one's
|
|
361
|
+
work with it.
|
|
209
362
|
- A reclaimed DAG job resumes: recorded nodes seed (FLOW-FORMAT
|
|
210
363
|
§7.6), the rest re-run. A task node with side effects MUST thread
|
|
211
364
|
an idempotency key (the job id is the natural one) into whatever it
|
|
212
365
|
touches; the queue cannot make a non-idempotent effect safe, and
|
|
213
|
-
does not pretend to.
|
|
214
|
-
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
366
|
+
does not pretend to (§3, *what exactly-once means here*).
|
|
367
|
+
- Every task's handler receives the run's signal, which is the job
|
|
368
|
+
handler's: a worker winding down inside its `graceMs`, or an attempt
|
|
369
|
+
whose lease has been lost, reaches every task rather than only the
|
|
370
|
+
outermost await.
|
|
371
|
+
- The runner forwards the worker options it declares — `concurrency`,
|
|
372
|
+
`pollInterval`, `leaseMs`, `owner`, `renew`, `onOutcome`, the backoff
|
|
373
|
+
pair and `stopGraceMs`. A `runner.stop()` with no override observes
|
|
374
|
+
the runner's declared `stopGraceMs` default; an explicit
|
|
375
|
+
`stop({ graceMs })` still wins.
|
|
376
|
+
- **A resume must be the same run.** THREE things are persisted beside a
|
|
377
|
+
run's checkpoints, under a reserved node id, and pruned with them: the
|
|
378
|
+
workflow document's revision, a hash of the run's input, and the
|
|
379
|
+
canonical map of DECLARED task versions (FLOW-FORMAT §7.8) with its
|
|
380
|
+
hash. A resume that disagrees with any of them is `JD2069` naming what
|
|
381
|
+
changed — each is identified separately, and a moved task version names
|
|
382
|
+
the task and both versions (`'draft' moved from version 1 to 2`).
|
|
383
|
+
A deploy that edits a dag document therefore does not have to drain old
|
|
384
|
+
jobs to be safe: the in-flight ones refuse by name.
|
|
385
|
+
|
|
386
|
+
The third component is what a document revision cannot see: a handler
|
|
387
|
+
reimplemented while its document stayed byte-equal. The host declares
|
|
388
|
+
that identity and the registry supplies it, so the queue can tell a
|
|
389
|
+
redeployed implementation from the one that wrote the checkpoints.
|
|
390
|
+
|
|
391
|
+
**The legacy rule is deterministic, and never reads unknown as equal.**
|
|
392
|
+
A run checkpointed by a release that did not record task identity has
|
|
393
|
+
no `taskVersionsHash`. If it recorded no node value yet, there is
|
|
394
|
+
nothing that could be replayed wrongly, so the identity is upgraded in
|
|
395
|
+
place and the run proceeds. If it DID record values, the implementation
|
|
396
|
+
that produced them cannot be confirmed and the resume is refused,
|
|
397
|
+
saying exactly that.
|
|
398
|
+
- A resume that DOES agree changes nothing: the recorded nodes are
|
|
399
|
+
restored rather than re-run, no checkpoint row is written, and none is
|
|
400
|
+
pruned.
|
|
218
401
|
|
|
219
402
|
## 8. Non-goals
|
|
220
403
|
|
|
@@ -225,21 +408,86 @@ await store.jobs.enqueue('sync-report', { input: { day: '2026-08-05' } });
|
|
|
225
408
|
processes over WAL are the supported topology.
|
|
226
409
|
- No priority classes in 0.1 (`run_at` ordering only), no cron or
|
|
227
410
|
recurring schedules (re-enqueue from a completed handler if
|
|
228
|
-
needed), no workflow-level compensation or sagas, no
|
|
229
|
-
|
|
230
|
-
|
|
411
|
+
needed), no workflow-level compensation or sagas, no cross-process
|
|
412
|
+
push — wake-on-write is same-process; everything else polls. A
|
|
413
|
+
per-job cancellation exists (§10) and aborts a handler's signal in the
|
|
414
|
+
process that holds the attempt; across processes the lease is still
|
|
415
|
+
the timeout story.
|
|
231
416
|
- Throughput is SQLite's single-writer throughput; the measured
|
|
232
417
|
numbers live in the execution notes, not in marketing.
|
|
233
418
|
|
|
234
419
|
## 9. Errors
|
|
235
420
|
|
|
236
|
-
|
|
421
|
+
The fence adds five, all in the package's single runtime table
|
|
422
|
+
(MODEL-FORMAT §7), all raised where a silent `false` used to be:
|
|
423
|
+
|
|
424
|
+
| code | raised when |
|
|
425
|
+
|---|---|
|
|
426
|
+
| `JD2065` | a settling call names a job that is not leased — unknown, or already settled by someone else |
|
|
427
|
+
| `JD2066` | the lease was superseded by a newer claim or renewal |
|
|
428
|
+
| `JD2067` | the lease expired before the call |
|
|
429
|
+
| `JD2068` | a settling call used the pre-fence `(id, owner)` spelling instead of the lease |
|
|
430
|
+
| `JD2069` | a resumed run disagrees with the workflow revision, the input hash or the declared task versions its checkpoints were written under (§7) |
|
|
431
|
+
|
|
432
|
+
Otherwise: API misuse (a malformed handler map, a
|
|
237
433
|
non-string kind, a worker started twice) is a `TypeError` at the
|
|
238
434
|
call, matching the capture and live precedents; storage failures ride
|
|
239
|
-
the
|
|
435
|
+
the store's one driver-failure classification (MODEL-FORMAT §7 — a
|
|
436
|
+
locked or read-only file arrives classed) and a coded error passes
|
|
437
|
+
through it
|
|
240
438
|
unchanged (`JD2063` after `close()`); `store.jobs` on a read-only
|
|
241
439
|
store is `JD0002` at first use, because the queue tables cannot be
|
|
242
440
|
created — named there, not a raw `SQLITE_READONLY` at the first
|
|
243
441
|
enqueue; a job's own failure is DATA — recorded in
|
|
244
442
|
`last_error` and the state machine of §4 — because a queue that
|
|
245
443
|
throws away its failure story has failed twice.
|
|
444
|
+
|
|
445
|
+
## 10. Administration
|
|
446
|
+
|
|
447
|
+
Four root operations let an operator inspect and steer the queue
|
|
448
|
+
without raw SQL. Each is a mechanism with a coded failure surface and
|
|
449
|
+
an honest second run; none of them decides WHEN — a retention horizon,
|
|
450
|
+
a cancellation policy, a retry policy are the host's.
|
|
451
|
+
|
|
452
|
+
- **`page({ state, kind, after, limit, signal, deadline })`** — a
|
|
453
|
+
keyset cursor over the queue by job id (the id to continue past in
|
|
454
|
+
`after`, at most `limit` items, default 100), filtered by a closed
|
|
455
|
+
state and a kind; each item is the record `get` answers. It is the
|
|
456
|
+
store's own cursor: admitted per pull under the store gate, cancelled
|
|
457
|
+
at row boundaries (`JD2072` / `JD2075`), classified by the driver's
|
|
458
|
+
streaming capability, every driver failure classified.
|
|
459
|
+
- **`cancel(id, { lease })`** — two cases, both fenced. A QUEUED job
|
|
460
|
+
(pending or failed) is settled as `cancelled` in one write; the
|
|
461
|
+
enqueue-time identity authorises. A CLAIMED job needs the CURRENT
|
|
462
|
+
lease its attempt holds (§3's rule, so no stranger settles another's
|
|
463
|
+
work): the fenced write settles the row, and an attempt of this
|
|
464
|
+
process is aborted through the handler's `signal` (its reason is the
|
|
465
|
+
coded `JD2065` naming the cancellation); the call resolves once that
|
|
466
|
+
attempt has wound up — the acknowledgement §3's settling calls named.
|
|
467
|
+
`true` when this call cancelled the job, `false` when it already was;
|
|
468
|
+
unknown or settled `JD2065`, claimed-without-lease `JD2068`, a stale
|
|
469
|
+
or expired lease `JD2066`/`JD2067`. A worker reports such an attempt
|
|
470
|
+
as `outcome: 'cancelled'` (neither a completion, a failure nor a loss)
|
|
471
|
+
and counts it under `stats().cancellations`; an attempt held by
|
|
472
|
+
another process meets the fence at its next settling call and records
|
|
473
|
+
a loss, as any superseded attempt does.
|
|
474
|
+
- **`requeue(id)`** — returns a failed, dead, cancelled or lease-expired
|
|
475
|
+
job to `pending` at the clock's instant (the queue's own order). The
|
|
476
|
+
attempt history is KEPT: `attempts` counts every claim the job ever
|
|
477
|
+
had and the next claim increments it, so `maxAttempts` still bounds
|
|
478
|
+
what follows (a dead job requeued gets exactly one more attempt before
|
|
479
|
+
it is dead again). `false` when the job already was pending; a live
|
|
480
|
+
lease refuses `JD2068` (requeue is not a steal); unknown or done
|
|
481
|
+
`JD2065`.
|
|
482
|
+
- **`sweep({ settledBefore, limit })`** — deletes settled jobs (`done`,
|
|
483
|
+
`dead`, `cancelled`) whose last change is older than the horizon,
|
|
484
|
+
oldest first, at most `limit` of them, together with their
|
|
485
|
+
checkpoints, in one transaction; answers `{ removed }`, and a second
|
|
486
|
+
identical sweep answers `{ removed: 0 }`. The horizon is REQUIRED: a
|
|
487
|
+
sweep with none is a retention policy. Live jobs' checkpoints and the
|
|
488
|
+
change log's watermark are untouched.
|
|
489
|
+
|
|
490
|
+
`cancelled` is a sixth job state, terminal like `done` and `dead`, set
|
|
491
|
+
only by `cancel()`; `counts()` reports it. A transaction view's `jobs`
|
|
492
|
+
(the outbox, §3) carries none of the four: an administration call is a
|
|
493
|
+
root call.
|