@bjornpagen/bumbledb-log 0.18.0 → 0.19.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/src/store.ts CHANGED
@@ -6,23 +6,36 @@
6
6
  * The one on-disk protocol, shared with the Rust driver: create-only
7
7
  * publishes an exclusive synced temp with link(2), where EEXIST is the
8
8
  * honest exists; the etag is the blake3 of the content, lowercase hex,
9
- * computed on every read and never stored; `putSwap` serializes under a
10
- * pid-lockfile beside the key, published with the same exclusive
11
- * temp-plus-link discipline so it can never exist without its body (the
12
- * owner pid) — a contender breaks the lock iff the owner is dead, which
13
- * is sound on one machine only, fsStore's load-bearing deployment law.
14
- * `created` and `swapped` resolve only after fsync of the object file
15
- * and its parent directory.
9
+ * computed on every read and never stored. The mutation lock is a
10
+ * fenced CAS lease `{holder, token, expires}` in the reserved
11
+ * namespace, acquired and broken only through the store's own CAS;
12
+ * a contender breaks a lease iff it is expired. Liveness is
13
+ * Alive | Dead | Unknown; Unknown never breaks a lease. `created` and
14
+ * `swapped` resolve only after fsync of the object file and its parent
15
+ * directory, including newly created ancestors. Temps and leftover
16
+ * expired leases are swept at open.
16
17
  */
17
18
 
18
19
  import * as fs from "node:fs/promises"
19
20
  import * as path from "node:path"
20
21
  import { internalBlake3 } from "@bjornpagen/bumbledb"
21
22
  import * as errors from "@superbuilders/errors"
22
- import { toHex } from "#bytes.ts"
23
+ import { regex } from "arkregex"
24
+ import { bytesEqual, toHex } from "#bytes.ts"
23
25
  import { wrapStore } from "#errors.ts"
24
26
  import type { StoreKey } from "#keys.ts"
25
- import { LOCK_SUFFIX } from "#keys.ts"
27
+
28
+ function reservedTemp(basename: string, pid: number, seq: number): string {
29
+ return `.${basename}.tmp.${pid}.${seq}`
30
+ }
31
+
32
+ function reservedLease(basename: string, token: bigint): string {
33
+ return `.${basename}.lease.${token}`
34
+ }
35
+
36
+ function isReservedName(name: string): boolean {
37
+ return name.startsWith(".")
38
+ }
26
39
 
27
40
  declare const etagBrand: unique symbol
28
41
  type Etag = string & { readonly [etagBrand]: typeof etagBrand }
@@ -38,9 +51,30 @@ interface Fetched {
38
51
 
39
52
  type Poll = { readonly tag: "unchanged" } | { readonly tag: "changed"; readonly fetched: Fetched }
40
53
 
41
- type Create = { readonly tag: "created"; readonly etag: Etag } | { readonly tag: "exists" }
54
+ type Create =
55
+ | { readonly tag: "created"; readonly etag: Etag }
56
+ | { readonly tag: "exists" }
57
+ | { readonly tag: "ambiguous" }
58
+
59
+ type Swap = { readonly tag: "swapped"; readonly etag: Etag } | { readonly tag: "moved" } | { readonly tag: "ambiguous" }
60
+
61
+ type Liveness = { readonly tag: "alive" } | { readonly tag: "dead" } | { readonly tag: "unknown" }
42
62
 
43
- type Swap = { readonly tag: "swapped"; readonly etag: Etag } | { readonly tag: "moved" }
63
+ interface Lease {
64
+ readonly holder: bigint
65
+ readonly token: bigint
66
+ readonly expires: bigint
67
+ }
68
+
69
+ type CreateProbe =
70
+ | { readonly tag: "landed"; readonly etag: Etag }
71
+ | { readonly tag: "lost"; readonly fetched: Fetched }
72
+ | { readonly tag: "absent" }
73
+
74
+ type SwapProbe =
75
+ | { readonly tag: "landed"; readonly etag: Etag }
76
+ | { readonly tag: "lost"; readonly fetched: Fetched }
77
+ | { readonly tag: "absent" }
44
78
 
45
79
  interface ObjectStore {
46
80
  /** GET; null on 404. */
@@ -55,8 +89,30 @@ interface ObjectStore {
55
89
  delete(key: StoreKey): Promise<void>
56
90
  }
57
91
 
58
- /** Ceiling of the jittered wait between probes of a live-held lock. */
59
- const LOCK_RETRY_MS = 10
92
+ interface FsLease {
93
+ readonly dir: string
94
+ readonly name: string
95
+ readonly holder: bigint
96
+ readonly token: bigint
97
+ readonly path: string
98
+ }
99
+
100
+ interface LeaseFile {
101
+ readonly path: string
102
+ readonly token: bigint
103
+ readonly lease: Lease | null
104
+ readonly readable: boolean
105
+ }
106
+
107
+ /** Ceiling of the jittered wait between probes of an unexpired lease. */
108
+ const LEASE_RETRY_MS = 10
109
+
110
+ /** Mutation-lease lifetime: long enough for one verb, short enough to expire after a crash. */
111
+ const MUTATION_LEASE_MS = 30_000
112
+
113
+ function ourHolder(): bigint {
114
+ return BigInt(process.pid)
115
+ }
60
116
 
61
117
  function contentEtag(bytes: Uint8Array): Etag {
62
118
  return etag(toHex(new Uint8Array(internalBlake3(bytes))))
@@ -66,6 +122,65 @@ function codeOf(error: Error): string | undefined {
66
122
  return (error as NodeJS.ErrnoException).code
67
123
  }
68
124
 
125
+ function encodeLease(lease: Lease): Uint8Array {
126
+ return new TextEncoder().encode(`${lease.holder}\n${lease.token}\n${lease.expires}\n`)
127
+ }
128
+
129
+ const SIGNED_DECIMAL = regex("^-?\\d+$")
130
+ const UNSIGNED_DECIMAL = regex("^\\d+$")
131
+
132
+ function parseLease(raw: string): Lease | null {
133
+ const lines = raw.trim().split("\n")
134
+ if (lines.length !== 3) {
135
+ return null
136
+ }
137
+ const [holderLine, tokenLine, expiresLine] = lines
138
+ if (holderLine === undefined || tokenLine === undefined || expiresLine === undefined) {
139
+ return null
140
+ }
141
+ if (!SIGNED_DECIMAL.test(holderLine) || !UNSIGNED_DECIMAL.test(tokenLine) || !SIGNED_DECIMAL.test(expiresLine)) {
142
+ return null
143
+ }
144
+ return { holder: BigInt(holderLine), token: BigInt(tokenLine), expires: BigInt(expiresLine) }
145
+ }
146
+
147
+ function leaseExpired(lease: Lease, nowMs: number): boolean {
148
+ return lease.expires <= BigInt(nowMs)
149
+ }
150
+
151
+ /** EPERM/unreadable/unparseable ⇒ Unknown. Expiry is a fact of the lease bytes. */
152
+ function livenessOf(lease: Lease | null, readable: boolean, nowMs: number): Liveness {
153
+ if (!readable || lease === null) {
154
+ return { tag: "unknown" }
155
+ }
156
+ if (leaseExpired(lease, nowMs)) {
157
+ return { tag: "dead" }
158
+ }
159
+ return { tag: "alive" }
160
+ }
161
+
162
+ /** A lease is breakable only when liveness is Dead. Unknown never breaks. */
163
+ function breakable(liveness: Liveness): boolean {
164
+ switch (liveness.tag) {
165
+ case "dead":
166
+ return true
167
+ case "alive":
168
+ return false
169
+ case "unknown":
170
+ return false
171
+ }
172
+ }
173
+
174
+ function isUnproved(error: Error): boolean {
175
+ const code = codeOf(error)
176
+ return code === "EIO" || code === "EINTR" || code === "ETIMEDOUT" || code === "EAGAIN" || code === "EBUSY"
177
+ }
178
+
179
+ /** A temp or parent that vanished mid-link is not Exists and not Created. */
180
+ function isVanished(error: Error): boolean {
181
+ return codeOf(error) === "ENOENT"
182
+ }
183
+
69
184
  async function fsyncDir(dir: string): Promise<void> {
70
185
  const handle = await fs.open(dir, "r")
71
186
  const synced = await errors.try(handle.sync())
@@ -75,15 +190,40 @@ async function fsyncDir(dir: string): Promise<void> {
75
190
  }
76
191
  }
77
192
 
193
+ async function ensureParent(target: string, root: string): Promise<void> {
194
+ const dir = path.dirname(target)
195
+ const resolvedRoot = path.resolve(root)
196
+ const ancestors: string[] = []
197
+ let cursor = dir
198
+ while (cursor.startsWith(resolvedRoot) && cursor !== resolvedRoot) {
199
+ ancestors.push(cursor)
200
+ cursor = path.dirname(cursor)
201
+ }
202
+ const missing: string[] = []
203
+ for (const ancestor of ancestors.reverse()) {
204
+ const st = await errors.try(fs.stat(ancestor))
205
+ if (st.error) {
206
+ if (codeOf(st.error) === "ENOENT") {
207
+ missing.push(ancestor)
208
+ continue
209
+ }
210
+ throw st.error
211
+ }
212
+ }
213
+ await fs.mkdir(dir, { recursive: true })
214
+ for (const created of missing) {
215
+ await fsyncDir(path.dirname(created))
216
+ }
217
+ }
218
+
78
219
  let tempSeq = 0
79
220
 
80
221
  /** Write `bytes` to a fresh `wx` temp file beside `target` and fsync it.
81
222
  * The caller publishes the synced temp atomically. */
82
- async function syncedTemp(target: string, bytes: Uint8Array): Promise<string> {
83
- const dir = path.dirname(target)
84
- await fs.mkdir(dir, { recursive: true })
223
+ async function syncedTemp(target: string, bytes: Uint8Array, root: string): Promise<string> {
224
+ await ensureParent(target, root)
85
225
  tempSeq += 1
86
- const temp = path.join(dir, `.${path.basename(target)}.tmp.${process.pid}.${tempSeq}`)
226
+ const temp = path.join(path.dirname(target), reservedTemp(path.basename(target), process.pid, tempSeq))
87
227
  const handle = await fs.open(temp, "wx")
88
228
  const written = await errors.try(
89
229
  (async function writeAll() {
@@ -114,56 +254,242 @@ async function publishLink(temp: string, dest: string): Promise<"linked" | "occu
114
254
  throw linked.error
115
255
  }
116
256
 
117
- function pidAlive(pid: number): boolean {
118
- const probed = errors.trySync(function probe() {
119
- process.kill(pid, 0)
120
- })
121
- if (probed.error === undefined) {
122
- return true
257
+ const LEASE_NAME_RE = regex("^\\.(.+)\\.lease\\.(\\d+)$")
258
+ const TEMP_NAME_RE = regex("^\\.(.+)\\.tmp\\.\\d+\\.\\d+$")
259
+
260
+ async function listLeaseFiles(dir: string, name: string): Promise<LeaseFile[]> {
261
+ const listed = await errors.try(fs.readdir(dir))
262
+ if (listed.error) {
263
+ if (codeOf(listed.error) === "ENOENT") {
264
+ return []
265
+ }
266
+ throw listed.error
267
+ }
268
+ const found: LeaseFile[] = []
269
+ for (const entry of listed.data) {
270
+ const match = LEASE_NAME_RE.exec(entry)
271
+ if (match === null || match[1] !== name || match[2] === undefined) {
272
+ continue
273
+ }
274
+ const filePath = path.join(dir, entry)
275
+ const read = await errors.try(fs.readFile(filePath, "utf8"))
276
+ if (read.error) {
277
+ if (codeOf(read.error) === "ENOENT") {
278
+ continue
279
+ }
280
+ found.push({ path: filePath, token: BigInt(match[2]), lease: null, readable: false })
281
+ continue
282
+ }
283
+ found.push({
284
+ path: filePath,
285
+ token: BigInt(match[2]),
286
+ lease: parseLease(read.data),
287
+ readable: true
288
+ })
289
+ }
290
+ return found
291
+ }
292
+
293
+ /** Drop a lease file only when its bytes still name this holder and token. */
294
+ async function casDrop(leasePath: string, expected: Lease): Promise<void> {
295
+ const read = await errors.try(fs.readFile(leasePath, "utf8"))
296
+ if (read.error) {
297
+ return
123
298
  }
124
- return codeOf(probed.error) !== "ESRCH"
299
+ const lease = parseLease(read.data)
300
+ if (lease === null) {
301
+ return
302
+ }
303
+ if (lease.holder !== expected.holder || lease.token !== expected.token) {
304
+ return
305
+ }
306
+ await fs.rm(leasePath, { force: true })
307
+ }
308
+
309
+ async function heldByUs(held: FsLease): Promise<boolean> {
310
+ const read = await errors.try(fs.readFile(held.path, "utf8"))
311
+ if (read.error) {
312
+ return false
313
+ }
314
+ const lease = parseLease(read.data)
315
+ if (lease === null) {
316
+ return false
317
+ }
318
+ return lease.holder === held.holder && lease.token === held.token
125
319
  }
126
320
 
127
- async function acquireLock(lockPath: string): Promise<void> {
128
- const body = new TextEncoder().encode(String(process.pid))
321
+ async function acquireFsLease(
322
+ dir: string,
323
+ name: string,
324
+ ttlMs: number,
325
+ contend: "wait" | "refuse" = "wait"
326
+ ): Promise<FsLease> {
327
+ await fs.mkdir(dir, { recursive: true })
129
328
  for (;;) {
130
- const temp = await syncedTemp(lockPath, body)
131
- const published = await errors.try(publishLink(temp, lockPath))
132
- await fs.rm(temp, { force: true })
329
+ const now = Date.now()
330
+ const incumbents = await listLeaseFiles(dir, name)
331
+ let highest = 0n
332
+ let blocked = false
333
+ const dead: LeaseFile[] = []
334
+ for (const incumbent of incumbents) {
335
+ if (incumbent.token > highest) {
336
+ highest = incumbent.token
337
+ }
338
+ const liveness = livenessOf(incumbent.lease, incumbent.readable, now)
339
+ switch (liveness.tag) {
340
+ case "dead":
341
+ dead.push(incumbent)
342
+ break
343
+ case "alive": {
344
+ const holder = incumbent.lease === null ? null : incumbent.lease.holder
345
+ if (holder === ourHolder()) {
346
+ blocked = true
347
+ } else {
348
+ blocked = true
349
+ }
350
+ break
351
+ }
352
+ case "unknown":
353
+ blocked = true
354
+ break
355
+ }
356
+ }
357
+ if (blocked) {
358
+ if (contend === "refuse") {
359
+ throw errors.new("replica directory has an owner")
360
+ }
361
+ await new Promise(function later(resolve) {
362
+ setTimeout(resolve, Math.random() * LEASE_RETRY_MS)
363
+ })
364
+ continue
365
+ }
366
+ const holder = ourHolder()
367
+ const token = highest + 1n
368
+ const dest = path.join(dir, reservedLease(name, token))
369
+ const body = encodeLease({
370
+ holder,
371
+ token,
372
+ expires: BigInt(now + ttlMs)
373
+ })
374
+ const wrote = await errors.try(syncedTemp(dest, body, dir))
375
+ if (wrote.error) {
376
+ if (isVanished(wrote.error) || isUnproved(wrote.error)) {
377
+ continue
378
+ }
379
+ throw wrote.error
380
+ }
381
+ const published = await errors.try(publishLink(wrote.data, dest))
382
+ await fs.rm(wrote.data, { force: true })
133
383
  if (published.error) {
384
+ if (isVanished(published.error) || isUnproved(published.error)) {
385
+ continue
386
+ }
134
387
  throw published.error
135
388
  }
136
- if (published.data === "linked") {
389
+ if (published.data === "occupied") {
390
+ continue
391
+ }
392
+ await fsyncDir(dir)
393
+ const held: FsLease = { dir, name, holder, token, path: dest }
394
+ for (const stale of dead) {
395
+ if (stale.lease !== null) {
396
+ await casDrop(stale.path, stale.lease)
397
+ }
398
+ }
399
+ return held
400
+ }
401
+ }
402
+
403
+ async function releaseFsLease(held: FsLease): Promise<void> {
404
+ const read = await errors.try(fs.readFile(held.path, "utf8"))
405
+ if (read.error) {
406
+ return
407
+ }
408
+ const lease = parseLease(read.data)
409
+ if (lease === null) {
410
+ return
411
+ }
412
+ if (lease.holder !== held.holder || lease.token !== held.token) {
413
+ return
414
+ }
415
+ await casDrop(held.path, lease)
416
+ const synced = await errors.try(fsyncDir(held.dir))
417
+ if (synced.error) {
418
+ return
419
+ }
420
+ }
421
+
422
+ async function sweepReserved(root: string): Promise<void> {
423
+ const listed = await errors.try(fs.readdir(root, { withFileTypes: true }))
424
+ if (listed.error) {
425
+ if (codeOf(listed.error) === "ENOENT") {
137
426
  return
138
427
  }
139
- const read = await errors.try(fs.readFile(lockPath, "utf8"))
140
- if (read.error) {
141
- if (codeOf(read.error) === "ENOENT") {
428
+ throw listed.error
429
+ }
430
+ const now = Date.now()
431
+ for (const entry of listed.data) {
432
+ const full = path.join(root, entry.name)
433
+ if (entry.isDirectory()) {
434
+ if (isReservedName(entry.name)) {
142
435
  continue
143
436
  }
144
- throw read.error
437
+ await sweepReserved(full)
438
+ continue
145
439
  }
146
- const owner = read.data.trim()
147
- if (!/^\d+$/.test(owner)) {
148
- throw errors.new(`lockfile body is not a pid: ${lockPath}`)
440
+ if (!entry.isFile() || !isReservedName(entry.name)) {
441
+ continue
149
442
  }
150
- if (pidAlive(Number.parseInt(owner, 10))) {
151
- await new Promise(function later(resolve) {
152
- setTimeout(resolve, Math.random() * LOCK_RETRY_MS)
153
- })
154
- } else {
155
- await fs.rm(lockPath, { force: true })
443
+ if (TEMP_NAME_RE.test(entry.name)) {
444
+ await fs.rm(full, { force: true })
445
+ continue
446
+ }
447
+ const match = LEASE_NAME_RE.exec(entry.name)
448
+ if (match === null) {
449
+ continue
450
+ }
451
+ const read = await errors.try(fs.readFile(full, "utf8"))
452
+ if (read.error) {
453
+ continue
454
+ }
455
+ const lease = parseLease(read.data)
456
+ const liveness = livenessOf(lease, true, now)
457
+ if (breakable(liveness) && lease !== null) {
458
+ await casDrop(full, lease)
156
459
  }
157
460
  }
158
461
  }
159
462
 
160
- async function releaseLock(lockPath: string): Promise<void> {
161
- await fs.rm(lockPath, { force: true })
463
+ async function resolveAmbiguousCreate(store: ObjectStore, key: StoreKey, attempted: Uint8Array): Promise<CreateProbe> {
464
+ const fetched = await store.get(key)
465
+ if (fetched === null) {
466
+ return { tag: "absent" }
467
+ }
468
+ if (bytesEqual(fetched.bytes, attempted)) {
469
+ return { tag: "landed", etag: fetched.etag }
470
+ }
471
+ return { tag: "lost", fetched }
472
+ }
473
+
474
+ async function resolveAmbiguousSwap(store: ObjectStore, key: StoreKey, attempted: Uint8Array): Promise<SwapProbe> {
475
+ const fetched = await store.get(key)
476
+ if (fetched === null) {
477
+ return { tag: "absent" }
478
+ }
479
+ if (bytesEqual(fetched.bytes, attempted)) {
480
+ return { tag: "landed", etag: fetched.etag }
481
+ }
482
+ return { tag: "lost", fetched }
483
+ }
484
+
485
+ function cloneFetched(fetched: Fetched): Fetched {
486
+ return { bytes: new Uint8Array(fetched.bytes), etag: fetched.etag }
162
487
  }
163
488
 
164
489
  /** The five verbs over one local directory. One machine is load-bearing. */
165
490
  function fsStore(root: string): ObjectStore {
166
491
  const rootPath = path.resolve(root)
492
+ const swept = sweepReserved(rootPath)
167
493
 
168
494
  function objectPath(key: StoreKey): string {
169
495
  return path.join(rootPath, ...key.split("/"))
@@ -181,8 +507,19 @@ function fsStore(root: string): ObjectStore {
181
507
  return { bytes, etag: contentEtag(bytes) }
182
508
  }
183
509
 
510
+ async function withKeyLease<T>(target: string, body: (held: FsLease) => Promise<T>): Promise<T> {
511
+ const held = await acquireFsLease(path.dirname(target), path.basename(target), MUTATION_LEASE_MS)
512
+ const ran = await errors.try(body(held))
513
+ await releaseFsLease(held)
514
+ if (ran.error) {
515
+ throw ran.error
516
+ }
517
+ return ran.data
518
+ }
519
+
184
520
  return {
185
521
  async get(key) {
522
+ await swept
186
523
  const target = objectPath(key)
187
524
  const read = await errors.try(readFetched(target))
188
525
  if (read.error) {
@@ -192,6 +529,7 @@ function fsStore(root: string): ObjectStore {
192
529
  },
193
530
 
194
531
  async getIfChanged(key, etag) {
532
+ await swept
195
533
  const target = objectPath(key)
196
534
  const read = await errors.try(readFetched(target))
197
535
  if (read.error) {
@@ -207,69 +545,104 @@ function fsStore(root: string): ObjectStore {
207
545
  },
208
546
 
209
547
  async putCreate(key, bytes) {
548
+ await swept
210
549
  const target = objectPath(key)
211
550
  const ran = await errors.try(
212
551
  (async function createBody(): Promise<Create> {
213
- const temp = await syncedTemp(target, bytes)
214
- const published = await errors.try(publishLink(temp, target))
215
- await fs.rm(temp, { force: true })
216
- if (published.error) {
217
- throw published.error
218
- }
219
- if (published.data === "occupied") {
220
- return { tag: "exists" }
221
- }
222
- await fsyncDir(path.dirname(target))
223
- return { tag: "created", etag: contentEtag(bytes) }
552
+ return await withKeyLease(target, async function underLease(held): Promise<Create> {
553
+ if (!(await heldByUs(held))) {
554
+ return { tag: "ambiguous" }
555
+ }
556
+ const temp = await syncedTemp(target, bytes, rootPath)
557
+ const published = await errors.try(publishLink(temp, target))
558
+ await fs.rm(temp, { force: true })
559
+ if (published.error) {
560
+ if (isUnproved(published.error) || isVanished(published.error)) {
561
+ return { tag: "ambiguous" }
562
+ }
563
+ throw published.error
564
+ }
565
+ if (published.data === "occupied") {
566
+ const st = await errors.try(fs.stat(target))
567
+ if (st.error) {
568
+ if (codeOf(st.error) === "ENOENT" || isUnproved(st.error)) {
569
+ return { tag: "ambiguous" }
570
+ }
571
+ throw st.error
572
+ }
573
+ if (st.data.isDirectory()) {
574
+ throw errors.new("key path is a directory")
575
+ }
576
+ return { tag: "exists" }
577
+ }
578
+ const synced = await errors.try(fsyncDir(path.dirname(target)))
579
+ if (synced.error) {
580
+ return { tag: "ambiguous" }
581
+ }
582
+ return { tag: "created", etag: contentEtag(bytes) }
583
+ })
224
584
  })()
225
585
  )
226
586
  if (ran.error) {
587
+ if (isUnproved(ran.error) || isVanished(ran.error)) {
588
+ return { tag: "ambiguous" }
589
+ }
227
590
  throw wrapStore(ran.error, `putCreate ${key}`)
228
591
  }
229
592
  return ran.data
230
593
  },
231
594
 
232
595
  async putSwap(key, bytes, etag) {
596
+ await swept
233
597
  const target = objectPath(key)
234
- const lock = `${target}${LOCK_SUFFIX}`
235
598
  const ran = await errors.try(
236
599
  (async function swapBody(): Promise<Swap> {
237
- await acquireLock(lock)
238
- const swapped = await errors.try(
239
- (async function underLock(): Promise<Swap> {
240
- const current = await readFetched(target)
241
- if (current === null || current.etag !== etag) {
242
- return { tag: "moved" }
600
+ return await withKeyLease(target, async function underLease(held): Promise<Swap> {
601
+ if (!(await heldByUs(held))) {
602
+ return { tag: "ambiguous" }
603
+ }
604
+ const current = await readFetched(target)
605
+ if (current === null || current.etag !== etag) {
606
+ return { tag: "moved" }
607
+ }
608
+ const temp = await syncedTemp(target, bytes, rootPath)
609
+ const renamed = await errors.try(fs.rename(temp, target))
610
+ if (renamed.error) {
611
+ await fs.rm(temp, { force: true })
612
+ if (isUnproved(renamed.error)) {
613
+ return { tag: "ambiguous" }
243
614
  }
244
- const temp = await syncedTemp(target, bytes)
245
- const renamed = await errors.try(fs.rename(temp, target))
246
- if (renamed.error) {
247
- await fs.rm(temp, { force: true })
248
- throw renamed.error
249
- }
250
- await fsyncDir(path.dirname(target))
251
- return { tag: "swapped", etag: contentEtag(bytes) }
252
- })()
253
- )
254
- await releaseLock(lock)
255
- if (swapped.error) {
256
- throw swapped.error
257
- }
258
- return swapped.data
615
+ throw renamed.error
616
+ }
617
+ const synced = await errors.try(fsyncDir(path.dirname(target)))
618
+ if (synced.error) {
619
+ return { tag: "ambiguous" }
620
+ }
621
+ return { tag: "swapped", etag: contentEtag(bytes) }
622
+ })
259
623
  })()
260
624
  )
261
625
  if (ran.error) {
626
+ if (isUnproved(ran.error)) {
627
+ return { tag: "ambiguous" }
628
+ }
262
629
  throw wrapStore(ran.error, `putSwap ${key}`)
263
630
  }
264
631
  return ran.data
265
632
  },
266
633
 
267
634
  async delete(key) {
635
+ await swept
268
636
  const target = objectPath(key)
269
637
  const ran = await errors.try(
270
638
  (async function deleteBody() {
271
- await fs.rm(target, { force: true })
272
- await fs.rm(`${target}${LOCK_SUFFIX}`, { force: true })
639
+ await withKeyLease(target, async function underLease(held) {
640
+ if (!(await heldByUs(held))) {
641
+ return
642
+ }
643
+ await fs.rm(target, { force: true })
644
+ await fsyncDir(path.dirname(target))
645
+ })
273
646
  })()
274
647
  )
275
648
  if (ran.error) {
@@ -284,13 +657,14 @@ function fsStore(root: string): ObjectStore {
284
657
  * and ephemeral dev inside this process, no persistence, no
285
658
  * cross-process claim, no configuration. Third `Etag` producer beside
286
659
  * `fsStore` and `s3Store`: blake3 of the content, `fsStore`'s mint,
287
- * carried as the same opaque brand.
660
+ * carried as the same opaque brand. Every read returns a fresh buffer.
288
661
  */
289
662
  function memStore(): ObjectStore {
290
663
  const objects = new Map<StoreKey, Fetched>()
291
664
  return {
292
665
  async get(key) {
293
- return objects.get(key) ?? null
666
+ const current = objects.get(key)
667
+ return current === undefined ? null : cloneFetched(current)
294
668
  },
295
669
 
296
670
  async getIfChanged(key, etag) {
@@ -301,7 +675,7 @@ function memStore(): ObjectStore {
301
675
  if (current.etag === etag) {
302
676
  return { tag: "unchanged" }
303
677
  }
304
- return { tag: "changed", fetched: current }
678
+ return { tag: "changed", fetched: cloneFetched(current) }
305
679
  },
306
680
 
307
681
  async putCreate(key, bytes) {
@@ -333,5 +707,5 @@ function memStore(): ObjectStore {
333
707
 
334
708
  export type { S3Config, S3Credentials } from "#store-s3.ts"
335
709
  export { s3Store } from "#store-s3.ts"
336
- export type { Create, Etag, Fetched, ObjectStore, Poll, Swap }
337
- export { etag, fsStore, memStore }
710
+ export type { Create, CreateProbe, Etag, Fetched, FsLease, Lease, Liveness, ObjectStore, Poll, Swap, SwapProbe }
711
+ export { acquireFsLease, etag, fsStore, memStore, releaseFsLease, resolveAmbiguousCreate, resolveAmbiguousSwap }