@namzu/sdk 20.3.0 → 20.4.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 (50) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/dist/public-runtime.d.ts +1 -0
  3. package/dist/public-runtime.d.ts.map +1 -1
  4. package/dist/public-runtime.js +5 -0
  5. package/dist/public-runtime.js.map +1 -1
  6. package/dist/runtime/query/checkpoint.d.ts +27 -1
  7. package/dist/runtime/query/checkpoint.d.ts.map +1 -1
  8. package/dist/runtime/query/checkpoint.js +34 -4
  9. package/dist/runtime/query/checkpoint.js.map +1 -1
  10. package/dist/runtime/query/index.d.ts +21 -1
  11. package/dist/runtime/query/index.d.ts.map +1 -1
  12. package/dist/runtime/query/index.js +4 -0
  13. package/dist/runtime/query/index.js.map +1 -1
  14. package/dist/runtime/query/resume-run.d.ts +9 -1
  15. package/dist/runtime/query/resume-run.d.ts.map +1 -1
  16. package/dist/runtime/query/resume-run.js +2 -1
  17. package/dist/runtime/query/resume-run.js.map +1 -1
  18. package/dist/store/index.d.ts +1 -1
  19. package/dist/store/index.d.ts.map +1 -1
  20. package/dist/store/index.js +1 -1
  21. package/dist/store/index.js.map +1 -1
  22. package/dist/store/run/checkpoint-disk.d.ts +18 -2
  23. package/dist/store/run/checkpoint-disk.d.ts.map +1 -1
  24. package/dist/store/run/checkpoint-disk.js +50 -5
  25. package/dist/store/run/checkpoint-disk.js.map +1 -1
  26. package/dist/store/run/checkpoint-memory.d.ts +22 -2
  27. package/dist/store/run/checkpoint-memory.d.ts.map +1 -1
  28. package/dist/store/run/checkpoint-memory.js +99 -4
  29. package/dist/store/run/checkpoint-memory.js.map +1 -1
  30. package/dist/store/run/claim-disk.d.ts +130 -0
  31. package/dist/store/run/claim-disk.d.ts.map +1 -0
  32. package/dist/store/run/claim-disk.js +550 -0
  33. package/dist/store/run/claim-disk.js.map +1 -0
  34. package/dist/store/run/listing.d.ts +44 -1
  35. package/dist/store/run/listing.d.ts.map +1 -1
  36. package/dist/store/run/listing.js +92 -1
  37. package/dist/store/run/listing.js.map +1 -1
  38. package/dist/types/run/checkpoint-store.d.ts +178 -2
  39. package/dist/types/run/checkpoint-store.d.ts.map +1 -1
  40. package/package.json +1 -1
  41. package/src/public-runtime.ts +5 -0
  42. package/src/runtime/query/checkpoint.ts +42 -5
  43. package/src/runtime/query/index.ts +26 -1
  44. package/src/runtime/query/resume-run.ts +12 -2
  45. package/src/store/index.ts +4 -0
  46. package/src/store/run/checkpoint-disk.ts +70 -5
  47. package/src/store/run/checkpoint-memory.ts +118 -3
  48. package/src/store/run/claim-disk.ts +593 -0
  49. package/src/store/run/listing.ts +116 -1
  50. package/src/types/run/checkpoint-store.ts +189 -2
@@ -0,0 +1,593 @@
1
+ /**
2
+ * A run claim on a filesystem, correct across PROCESSES.
3
+ *
4
+ * ## The fence is the filename
5
+ *
6
+ * A run's claims live at `{runDir}/claims/{fence}.json`. Taking the run means
7
+ * exclusively creating the next number; the kernel makes exactly one of any
8
+ * number of simultaneous callers the creator, and every other gets `EEXIST`.
9
+ * The current holding is the highest-numbered file.
10
+ *
11
+ * That single decision is the whole mechanism, and it is what the first
12
+ * version of this file got wrong. That version kept ONE mutable `claim.json`
13
+ * and protected it with a lock, which needed a stale-lock breaker, which was
14
+ * `unlink` followed by an exclusive create — two operations. An adversarial
15
+ * pass reproduced the consequence from separate processes: two workers both
16
+ * judge a stale guard breakable, the second unlinks the FIRST one's fresh
17
+ * guard, both end up inside the section believing they hold it, neither has
18
+ * written yet so neither can be the loser of a re-read, and both write the
19
+ * same fence. Twenty-six of three hundred runs went to two workers at an
20
+ * identical fence — and an identical fence fences nobody out, because the
21
+ * comparison is `<`.
22
+ *
23
+ * Numbering the files instead removes every one of those steps. There is no
24
+ * lock to go stale, so no breaker, so no window. Concurrency is decided by
25
+ * one `O_CREAT | O_EXCL`.
26
+ *
27
+ * ## Four properties this layout gives for free
28
+ *
29
+ * **Monotonic across release and deletion.** Fences are file names that stay,
30
+ * so releasing cannot rewind the counter. Deleting `claim.json` used to send
31
+ * the next caller down a fresh-claim path that minted fence 1 again — so a
32
+ * worker stalled at fence 1 could write alongside the new holder, and the
33
+ * documented `finally { releaseRun() }` did it on every pass. Releasing here
34
+ * appends a tombstone rather than removing anything.
35
+ *
36
+ * **Unreadable content cannot wedge the run.** The fence is in the name, so a
37
+ * damaged or half-written body never hides the ordering. A caller reads the
38
+ * highest number and takes the next one; the previous holder, alive or not,
39
+ * is fenced out by arithmetic. Refusing to take an unparseable claim was safe
40
+ * against double-writing and left the run permanently unclaimable, which is
41
+ * the failure a lease exists to prevent.
42
+ *
43
+ * **The write-time fence check needs no parsing at all.** It compares a
44
+ * number against a directory listing, so a corrupt body cannot make the check
45
+ * skip itself.
46
+ *
47
+ * **No `rename` and no `unlink` on the contended path.** Renaming over a path
48
+ * another process merely holds open for reading fails on non-POSIX
49
+ * filesystems — measured at 84% under two concurrent readers — and a listing
50
+ * sweep reads exactly these files. Creating a new name never collides with a
51
+ * reader.
52
+ *
53
+ * ## The name appears already complete, because `link` publishes it
54
+ *
55
+ * An exclusive create decides the winner, but `wx` is open-THEN-write: for an
56
+ * instant the winning name exists and is empty. A reader landing in it parses
57
+ * nothing, reports the holding expired, and a second worker takes the next
58
+ * fence. Their fences differ so the loser's first checkpoint is refused — and
59
+ * both have already restored the run and executed its tools by then, and tool
60
+ * side effects are fenced by nothing.
61
+ *
62
+ * So the body is written to a temporary name first and the fence name is
63
+ * created by `link`ing to it. `link` fails `EEXIST` when the destination
64
+ * exists, so it arbitrates exactly as `wx` did, and the destination it creates
65
+ * is a second name for a file that was already whole. There is no instant at
66
+ * which the fence name exists and its body does not.
67
+ *
68
+ * Measured from separate OS processes on both platform families:
69
+ *
70
+ * - `link` refused an existing destination 20,000/20,000 times. Six writers
71
+ * over 6,000 fences produced no fence with two winners and none with none.
72
+ * - Paired identical fixtures: `wx` showed an empty destination in 15,985 of
73
+ * 16,000 first observations, `link` in 0 of 16,000. All 156,000 frontier
74
+ * observations were `ENOENT` or a complete parseable body — none empty, none
75
+ * torn.
76
+ * - `rename` is disqualified, and not for the reason first assumed. It never
77
+ * reports `EEXIST`; it silently REPLACES, 20,000/20,000. It cannot arbitrate
78
+ * a race at all — two workers publishing one fence would both succeed and
79
+ * the second would erase the first. (Its `EPERM`-under-readers failure is
80
+ * real too, at 93.7% on the non-POSIX family, but the exclusivity failure
81
+ * disqualifies it first.)
82
+ *
83
+ * Cost: three syscalls rather than one, +1.0 ms per acquisition on the
84
+ * non-POSIX family and +0.04 ms on POSIX, for an operation that runs once per
85
+ * run plus renewals.
86
+ *
87
+ * ## What it still does not do
88
+ *
89
+ * It does not detect liveness. Nothing can from here: a stalled holder, a
90
+ * suspended container and a partitioned network are indistinguishable, and
91
+ * they are indistinguishable from the holder's own side too, which is why it
92
+ * keeps writing. The fence is the answer — the write is checked, not the
93
+ * writer.
94
+ */
95
+
96
+ import { link, mkdir, readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises'
97
+ import { join } from 'node:path'
98
+ import { NamzuError } from '../../types/errors/index.js'
99
+ import type { ClaimFence, ClaimRunOptions, RunClaim } from '../../types/run/checkpoint-store.js'
100
+
101
+ /** Directory holding one file per holding, named for its fence. */
102
+ const CLAIMS_DIR = 'claims'
103
+
104
+ /**
105
+ * How many times a caller re-reads and retries after losing the create race.
106
+ *
107
+ * Losing means somebody else took the number, and the next read sees their
108
+ * live claim and returns `null` — so this is bounded by genuine contention
109
+ * rather than by chance. A handful is generous; the loop exists so a burst of
110
+ * simultaneous takers does not report "held" to a caller that never actually
111
+ * raced the eventual holder.
112
+ */
113
+ const MAX_ATTEMPTS = 8
114
+
115
+ /**
116
+ * How many holdings to keep below the current fence.
117
+ *
118
+ * Append-only is right — a name that disappears can be re-issued — but
119
+ * unbounded is not, and every operation lists this directory. Measured: 0.14
120
+ * ms per operation at 10 holdings, 3.4 at 10,000, 78.6 at 200,000, and three
121
+ * processes contending on ONE run produced 4,772 files in eight seconds. A
122
+ * single holder renewing a 60-second lease produced 4,421. One busy run
123
+ * reaches the 78 ms regime in minutes and drags the checkpoint write path of
124
+ * every claimed run with it.
125
+ *
126
+ * Pruning BELOW the maximum is safe by the design own argument: a rewind
127
+ * requires removing the highest name, and nothing reads a lower one — the
128
+ * current holding is the maximum and the fence check compares against it. A
129
+ * few are kept rather than none so an operator can still see the recent
130
+ * handover history, which is the evidence a contested run is reconstructed
131
+ * from.
132
+ */
133
+ const KEEP_BELOW_MAX = 32
134
+
135
+ /**
136
+ * How old a scratch file must be before {@link prune} reclaims it.
137
+ *
138
+ * It brackets a `writeFile` of sixty-odd bytes and a `link`. Ten minutes is
139
+ * four orders of magnitude more than that takes, and the asymmetry is the
140
+ * point: reclaiming late costs a stale file until the next acquisition,
141
+ * reclaiming early fails a live publish that did nothing wrong.
142
+ */
143
+ const TEMP_TTL_MS = 10 * 60_000
144
+
145
+ function isErrno(err: unknown, code: string): boolean {
146
+ return typeof err === 'object' && err !== null && (err as NodeJS.ErrnoException).code === code
147
+ }
148
+
149
+ /**
150
+ * The stored body. The fence is the file name, not this.
151
+ *
152
+ * There is no `released` flag, and there was one. It existed so a listing
153
+ * could tell a clean release from a damaged record, and **nothing ever asked**
154
+ * — the only reader was an exported `isReleased` with no caller anywhere, not
155
+ * re-exported from the package and absent from the public surface baseline.
156
+ * `ClaimSummary`, which is the type a listing row actually carries, never
157
+ * gained a field for it, so the distinction the flag was written for was never
158
+ * available at the place it was meant to serve.
159
+ *
160
+ * Removed rather than wired, because the two states are the SAME ANSWER to
161
+ * every caller there is: released, damaged and mid-take all mean "take the
162
+ * next number". Wiring it would have added a public field and a fresh parity
163
+ * obligation between the two shipped stores to carry a difference no consumer
164
+ * can act on — surface to keep correct forever for nobody.
165
+ *
166
+ * An operator still has the distinction where operators actually work, on the
167
+ * disk: a tombstone is valid JSON with an empty `holder` and an `expiresAt` of
168
+ * 0, and a damaged record does not parse.
169
+ */
170
+ interface ClaimBody {
171
+ readonly holder: string
172
+ readonly expiresAt: number
173
+ }
174
+
175
+ function isClaimBody(value: unknown): value is ClaimBody {
176
+ if (typeof value !== 'object' || value === null) return false
177
+ const c = value as Partial<ClaimBody>
178
+ return (
179
+ typeof c.holder === 'string' && typeof c.expiresAt === 'number' && Number.isFinite(c.expiresAt)
180
+ )
181
+ }
182
+
183
+ /**
184
+ * A holding's name: its fence, and nothing else.
185
+ *
186
+ * **The destination name must be a pure function of the fence.** Putting the
187
+ * expiry in it too was tried, to close the window where `wx` has created the
188
+ * file and not yet written its body — and it silently destroyed the exclusion
189
+ * this whole design rests on. Two workers computing the same fence at
190
+ * different instants produce different names, so both creates succeed and
191
+ * both hold fence N. The race test caught it immediately: seventeen of three
192
+ * hundred runs went to two or three workers.
193
+ *
194
+ * **The temporary name must be the exact opposite — unique per attempt.** See
195
+ * {@link tempNameFor}. The two rules are mirror images, and the pair is the
196
+ * insight: an exclusive create is exclusive over the *exact* name, so the name
197
+ * that has to arbitrate must not vary, and the name that must never arbitrate
198
+ * must never repeat.
199
+ *
200
+ * Strict decimal, bounded to fifteen digits. `Number` accepts `0x10`, `" 7"`,
201
+ * `08` and `1e21`, and above 2^53 or in exponent form `fence + 1 === fence` —
202
+ * so a foreign writer could pin the counter and every taker after it would be
203
+ * issued a fence EQUAL to the current one, which fences nobody out. Names
204
+ * this store issues always match; anything else is not a holding, however
205
+ * numeric it looks.
206
+ */
207
+ const NAME = /^([0-9]{1,15})\.json$/
208
+
209
+ function nameFor(fence: ClaimFence): string {
210
+ return `${fence}.json`
211
+ }
212
+
213
+ /**
214
+ * The scratch name a body is written to before {@link publish} links it into
215
+ * place. Never a holding — the leading dot and the dashes cannot match
216
+ * {@link NAME}, so no listing can mistake one for a claim.
217
+ */
218
+ const TEMP = /^\.tmp-/
219
+
220
+ /** Distinguishes two attempts inside one process; the pid does the rest. */
221
+ let attempts = 0
222
+
223
+ /**
224
+ * A scratch name: unique per attempt, and deliberately NOT a function of the
225
+ * fence.
226
+ *
227
+ * This is the mirror of the rule on {@link NAME}, and it was measured by
228
+ * building it wrong on purpose. A temp named for the fence — the obvious
229
+ * choice, since that is what is being published — fails **silently** on POSIX:
230
+ * two workers write the same scratch path, one links the other's body, and 19
231
+ * of 20,000 fences published a body belonging to a process that did not win
232
+ * that name. The ledger looked perfect throughout: right count, no doubles,
233
+ * none missing, every body parseable. Only reading a body back and comparing
234
+ * its holder reveals it. On the non-POSIX family the same mistake crashed
235
+ * three of six writer processes with `EPERM`.
236
+ *
237
+ * So: pid to separate processes, a counter to separate attempts within one,
238
+ * and randomness to separate processes that share a pid across a container
239
+ * restart or a pid-namespace reuse.
240
+ *
241
+ * The fence is on the end as well, and it is the one part carrying no
242
+ * uniqueness — it is there so an operator reading a leaked scratch file can
243
+ * tell which publish abandoned it. Uniqueness comes entirely from the three
244
+ * parts before it, which is what keeps this the mirror of {@link NAME} rather
245
+ * than a second copy of it. Anything appended for a human must stay in that
246
+ * position: informative, and load-bearing for nothing.
247
+ *
248
+ * **The scratch file must live in the same directory as its destination.**
249
+ * `link` across filesystems fails `EXDEV` — measured, not assumed — so a temp
250
+ * directory anywhere else (`os.tmpdir()` being the tempting one) breaks every
251
+ * acquisition the moment the store's base directory is a mount of its own.
252
+ * That is a rule, not a preference.
253
+ */
254
+ function tempNameFor(fence: ClaimFence): string {
255
+ attempts += 1
256
+ return `.tmp-${process.pid}-${attempts}-${Math.random().toString(36).slice(2, 10)}-${fence}`
257
+ }
258
+
259
+ /**
260
+ * Codes a filesystem with no hard-link support answers `link` with.
261
+ *
262
+ * `EXDEV` is in the list as a bug report rather than a platform limit: it can
263
+ * only mean the scratch file was moved out of the claims directory, against
264
+ * the rule on {@link tempNameFor}.
265
+ */
266
+ const NO_LINK = new Set(['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EXDEV', 'EMLINK'])
267
+
268
+ /**
269
+ * Write `body` and make it visible at `fence` — completely, or not at all.
270
+ *
271
+ * Throws an `EEXIST` `ErrnoException` when another caller already published
272
+ * that fence. That is the arbitration, and it is the caller's ordinary signal,
273
+ * not a fault.
274
+ *
275
+ * Some filesystems — a few network and removable volumes — support no hard
276
+ * link at all. **This refuses rather than falling back**, per
277
+ * [refuse-do-not-degrade](../../../../../docs/conventions/refuse-do-not-degrade.md).
278
+ * The only available fallback is the `wx` publish this replaced, and that one
279
+ * carries the defect described in the module header: two workers restore and
280
+ * run the same run. A claim that silently becomes non-exclusive is worse than
281
+ * one that will not start, because the host cannot tell which it got — and a
282
+ * host told plainly that this volume cannot arbitrate can move the base
283
+ * directory or keep one writer per run. It is unmeasured, because no such
284
+ * volume was available to measure; the error says so rather than implying a
285
+ * diagnosis it did not make.
286
+ */
287
+ async function publish(claimsDir: string, fence: ClaimFence, body: ClaimBody): Promise<void> {
288
+ const tmp = join(claimsDir, tempNameFor(fence))
289
+
290
+ try {
291
+ await writeFile(tmp, JSON.stringify(body), { flag: 'wx' })
292
+ } catch (err) {
293
+ // A scratch name that already exists means the uniqueness rule on
294
+ // `tempNameFor` has been broken. Letting an `EEXIST` escape from HERE
295
+ // would read to the caller as "somebody else took this fence" — a lost
296
+ // race it never had — so it is renamed into what it actually is.
297
+ if (isErrno(err, 'EEXIST')) {
298
+ throw new NamzuError({
299
+ code: 'storage_error',
300
+ message: `acquireClaim: the scratch name ${tmp} already exists. Scratch names must be unique per attempt; a collision means two publishes are sharing one, which lets a claim publish a body it did not write. See the rule on \`tempNameFor\`.`,
301
+ details: { path: tmp, fence },
302
+ retryable: false,
303
+ })
304
+ }
305
+ // Anything else — no permission on the directory, a full disk, a
306
+ // read-only mount — escapes as a bare errno naming a dotted temporary
307
+ // file, with nothing in it about claims or runs. This write is the
308
+ // first thing that touches the directory, so it is where a
309
+ // misconfigured deployment surfaces, and `EPERM: open
310
+ // '…/.tmp-4131-2-k3f9a1x8-1'` is the least useful place to find out.
311
+ //
312
+ // Wrapped for the same reason as the `link` failure below it: the
313
+ // operator needs the run, the directory and the operation, not the
314
+ // scratch name this attempt happened to draw.
315
+ throw new NamzuError({
316
+ code: 'storage_error',
317
+ message: `acquireClaim: could not write a claim into ${claimsDir} (${(err as NodeJS.ErrnoException).code ?? 'unknown error'}). This is the run's claim directory, and taking a run writes to it — check that the process can create files there. Refusing rather than proceeding: a claim that cannot be recorded is a claim nobody else can be refused against.`,
318
+ details: { path: claimsDir, code: (err as NodeJS.ErrnoException).code, fence },
319
+ cause: err,
320
+ retryable: false,
321
+ })
322
+ }
323
+
324
+ try {
325
+ // The one decision. Exactly one caller creates this name, and the file
326
+ // it names is already whole.
327
+ await link(tmp, join(claimsDir, nameFor(fence)))
328
+ } catch (err) {
329
+ if (isErrno(err, 'EEXIST')) throw err
330
+ const code = (err as NodeJS.ErrnoException).code
331
+ if (code !== undefined && NO_LINK.has(code)) {
332
+ throw new NamzuError({
333
+ code: 'capability_unavailable',
334
+ message: `acquireClaim: this filesystem answered \`link\` with ${code}, so it cannot publish a run claim. The claim decides which of two workers owns a run by exclusively creating a hard link, and a filesystem without hard links cannot make that decision. Refusing rather than degrading: the only fallback is a non-atomic create, under which two workers both restore the run and both execute its tools with nothing fencing the side effects. Put the store's base directory on a filesystem with hard-link support (${claimsDir}), or run a single writer per run.`,
335
+ details: { path: claimsDir, code, fence },
336
+ retryable: false,
337
+ })
338
+ }
339
+ throw err
340
+ } finally {
341
+ // The link, if it landed, is an independent name for the same file.
342
+ // Failure here leaks a scratch file and nothing else; `prune` sweeps
343
+ // it, and no listing can mistake it for a holding.
344
+ await unlink(tmp).catch(() => undefined)
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Every holding this run has on record, newest first.
350
+ *
351
+ * Names only — no expiry. It used to report `expiresAt: 0` alongside each
352
+ * name, which read as data and was a placeholder: the expiry has never been in
353
+ * the name since the fence became the whole of it. A caller that wants a
354
+ * deadline has to read the body, and {@link readClaim} is the only thing that
355
+ * does.
356
+ */
357
+ async function listHoldings(runDir: string): Promise<{ name: string; fence: ClaimFence }[]> {
358
+ let names: string[]
359
+ try {
360
+ names = await readdir(join(runDir, CLAIMS_DIR))
361
+ } catch (err) {
362
+ if (isErrno(err, 'ENOENT')) return []
363
+ throw err
364
+ }
365
+
366
+ const found: { name: string; fence: ClaimFence }[] = []
367
+ for (const name of names) {
368
+ const match = NAME.exec(name)
369
+ if (!match) continue
370
+ found.push({ name, fence: Number(match[1]) })
371
+ }
372
+ return found.sort((a, b) => b.fence - a.fence)
373
+ }
374
+
375
+ /**
376
+ * The highest fence ever issued for this run, or 0 when it has never been
377
+ * claimed.
378
+ *
379
+ * Reads names only. This is deliberately the one question the contended path
380
+ * asks, because a name cannot be half-written: a file either exists or does
381
+ * not, where a body can be observed mid-write.
382
+ */
383
+ export async function currentFence(runDir: string): Promise<ClaimFence> {
384
+ const holdings = await listHoldings(runDir)
385
+ return holdings[0]?.fence ?? 0
386
+ }
387
+
388
+ /**
389
+ * Drop holdings far below the current fence.
390
+ *
391
+ * Only ever below the maximum. A rewind requires removing the HIGHEST name,
392
+ * and nothing reads a lower one — the current holding is the maximum and the
393
+ * write check compares against it — so this cannot re-issue a number. A
394
+ * window of recent handovers is kept, because that is the evidence a
395
+ * contested run gets reconstructed from.
396
+ *
397
+ * Failures are swallowed: pruning is housekeeping, and a run must not fail
398
+ * because a tidy-up lost a race with another worker doing the same tidy-up.
399
+ */
400
+ async function prune(claimsDir: string, max: ClaimFence): Promise<void> {
401
+ let names: string[]
402
+ try {
403
+ names = await readdir(claimsDir)
404
+ } catch {
405
+ return
406
+ }
407
+
408
+ const floor = max - KEEP_BELOW_MAX
409
+ // Real time, deliberately, and not the caller's `now`. A lease clock is
410
+ // injectable so a test can age a claim out in one tick; the age of a file
411
+ // on disk is a question about the wall clock, and judging one with the
412
+ // other would let a test with `now: 1000` sweep every scratch file on the
413
+ // volume.
414
+ const wallClock = Date.now()
415
+
416
+ for (const name of names) {
417
+ const match = NAME.exec(name)
418
+ if (match) {
419
+ if (floor <= 0) continue
420
+ if (Number(match[1]) >= floor) continue
421
+ await unlink(join(claimsDir, name)).catch(() => undefined)
422
+ continue
423
+ }
424
+ if (!TEMP.test(name)) continue
425
+
426
+ // A crash between the scratch write and its unlink leaves one behind.
427
+ // It can never be read as a holding — the name cannot match `NAME` —
428
+ // but nothing reclaimed it either, so a run whose worker crashes in
429
+ // that window accumulated scratch files forever.
430
+ //
431
+ // By age, because ownership is unknowable: another process may be
432
+ // publishing through this very file right now, and unlinking it would
433
+ // fail its `link` for no reason. The threshold is enormous relative to
434
+ // the work it brackets — a `writeFile` of sixty-odd bytes followed by a
435
+ // `link` — so a scratch file this old belongs to a process that is not
436
+ // coming back.
437
+ const path = join(claimsDir, name)
438
+ try {
439
+ const info = await stat(path)
440
+ if (wallClock - info.mtimeMs < TEMP_TTL_MS) continue
441
+ } catch {
442
+ continue
443
+ }
444
+ await unlink(path).catch(() => undefined)
445
+ }
446
+ }
447
+
448
+ /**
449
+ * The run's current holding, or `null` when it has never been claimed.
450
+ *
451
+ * Returns `null` for an unreadable body too, and that is safe HERE in a way
452
+ * it was not in the previous design: the fence is known from the name
453
+ * regardless, so an unreadable body means "somebody took this number and its
454
+ * details are unavailable", and the caller's response is to take the NEXT
455
+ * number rather than to give up. Nothing is inferred from the absence.
456
+ */
457
+ export async function readClaim(runDir: string): Promise<RunClaim | null> {
458
+ const [top] = await listHoldings(runDir)
459
+ if (!top) return null
460
+
461
+ // Fence from the name, expiry only from the body — and an expiry of 0 says
462
+ // "no deadline could be established", which every caller reads as expired.
463
+ //
464
+ // This comment used to claim the expiry came from the name too, and was
465
+ // therefore known the instant the file existed. It was stale by a
466
+ // redesign, and it described the file's one real defect as handled: under
467
+ // the old `wx` publish a reader could land on a created-but-empty file,
468
+ // fail to parse it, and report a LIVE holding expired — which invited a
469
+ // second worker onto a running run. Both restored it and executed its
470
+ // tools before either was refused at its first checkpoint.
471
+ //
472
+ // What makes falling back safe now is the publish, not the naming: `link`
473
+ // makes the fence name appear complete or not at all, so an unparseable
474
+ // body means a genuinely damaged record rather than one being written. A
475
+ // damaged record SHOULD read as reclaimable — refusing to take it is what
476
+ // leaves a run permanently unclaimable, which is the failure a lease
477
+ // exists to prevent — and the taker is safe regardless, because it takes
478
+ // the next fence and the write check compares numbers.
479
+ const claim: RunClaim = { holder: '', fence: top.fence, expiresAt: 0 }
480
+
481
+ let raw: string
482
+ try {
483
+ raw = await readFile(join(runDir, CLAIMS_DIR, top.name), 'utf-8')
484
+ } catch (err) {
485
+ // Gone between the listing and the read. The name already told us
486
+ // everything the taker needs.
487
+ //
488
+ // Only `ENOENT` is tolerated, and on the non-POSIX family that is not
489
+ // the only code a vanishing file produces: a file already unlinked but
490
+ // still open elsewhere is delete-pending, and a reader gets `EPERM` —
491
+ // 4.6% of misses, measured. It cannot be reached from here today,
492
+ // because `prune` only unlinks fences far below the top and this reads
493
+ // only the top. Widen either one and this throws where it should
494
+ // return. Whoever does that should extend this catch, not delete the
495
+ // comment.
496
+ if (isErrno(err, 'ENOENT')) return claim
497
+ throw err
498
+ }
499
+
500
+ try {
501
+ const parsed: unknown = JSON.parse(raw)
502
+ // The body is advisory now — it names the holder for an operator and
503
+ // distinguishes a clean release from a damaged record. The two facts
504
+ // the algorithm depends on are both in the name.
505
+ if (isClaimBody(parsed)) {
506
+ return {
507
+ holder: parsed.holder,
508
+ fence: top.fence,
509
+ expiresAt: parsed.expiresAt,
510
+ }
511
+ }
512
+ } catch {
513
+ // fall through: an unreadable body leaves the holding intact and
514
+ // anonymous, which is what `holder: ''` says.
515
+ }
516
+
517
+ return claim
518
+ }
519
+
520
+ /** Take or extend the run's claim. `null` when somebody else holds it. */
521
+ export async function acquireClaim(
522
+ runDir: string,
523
+ options: ClaimRunOptions,
524
+ ): Promise<RunClaim | null> {
525
+ const now = options.now ?? Date.now()
526
+ const claimsDir = join(runDir, CLAIMS_DIR)
527
+ await mkdir(claimsDir, { recursive: true })
528
+
529
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
530
+ const held = await readClaim(runDir)
531
+
532
+ // Held, live, and by somebody else. Not an error: two readers on one
533
+ // queue is the ordinary case.
534
+ if (held && now < held.expiresAt && held.holder !== options.holder) return null
535
+
536
+ const fence = (held?.fence ?? 0) + 1
537
+ const body: ClaimBody = {
538
+ holder: options.holder,
539
+ expiresAt: now + options.ttlMs,
540
+ }
541
+
542
+ try {
543
+ // One name, one winner, and the winner's body is already whole
544
+ // when the name appears. The expiry lives only in the body; see
545
+ // the note on NAME for why it must not appear in the name.
546
+ await publish(claimsDir, fence, body)
547
+ await prune(claimsDir, fence)
548
+ return { holder: options.holder, fence, expiresAt: body.expiresAt }
549
+ } catch (err) {
550
+ // Somebody else took this number. Re-read and decide again — they
551
+ // may hold it live, in which case the next pass returns `null`.
552
+ if (!isErrno(err, 'EEXIST')) throw err
553
+ }
554
+ }
555
+
556
+ // Lost the create race MAX_ATTEMPTS times without ever reading a live
557
+ // holder. Reporting "held" is the honest answer: something is taking this
558
+ // run repeatedly and this caller is not winning.
559
+ return null
560
+ }
561
+
562
+ /**
563
+ * Give up a holding early, so the run returns to the queue without waiting
564
+ * out its lease.
565
+ *
566
+ * Appends a tombstone at the next fence rather than deleting anything. The
567
+ * counter must never rewind: a worker stalled at an old fence has to stay
568
+ * fenced out forever, and removing the record would let a later claimer be
569
+ * issued a number that stalled worker already believes it holds.
570
+ *
571
+ * A stale fence releases nothing — a worker that stalled past its lease must
572
+ * not be able to hand away a run somebody else now holds.
573
+ */
574
+ export async function releaseClaim(runDir: string, fence: ClaimFence): Promise<void> {
575
+ const held = await readClaim(runDir)
576
+ if (!held || held.fence !== fence) return
577
+
578
+ const claimsDir = join(runDir, CLAIMS_DIR)
579
+ // Expiry 0 and no holder: free the instant it lands. That pair IS the
580
+ // tombstone — an empty holder with a zero expiry is not a shape a live
581
+ // claim can take, so it reads as a clean handover to anyone looking at the
582
+ // directory, without a flag no caller consumes. See {@link ClaimBody}.
583
+ //
584
+ // Published the same way as a claim, so nothing can catch it half-written.
585
+ const body: ClaimBody = { holder: '', expiresAt: 0 }
586
+ try {
587
+ await publish(claimsDir, fence + 1, body)
588
+ } catch (err) {
589
+ // Somebody already took the next fence, which means the run is claimed
590
+ // again and there is nothing to release.
591
+ if (!isErrno(err, 'EEXIST')) throw err
592
+ }
593
+ }