@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/README.md +5 -4
- package/package.json +4 -3
- package/src/braids.ts +17 -2
- package/src/bytes.ts +78 -10
- package/src/chain.ts +173 -56
- package/src/codec.ts +122 -41
- package/src/descriptor.ts +182 -107
- package/src/errors.ts +133 -5
- package/src/index.ts +15 -2
- package/src/keys.ts +161 -18
- package/src/manifest.ts +165 -73
- package/src/replica.ts +577 -264
- package/src/store-s3.ts +191 -36
- package/src/store.ts +459 -85
- package/src/tenants.ts +322 -26
- package/src/value.ts +65 -14
- package/src/vector.ts +173 -0
- package/src/writer.ts +453 -92
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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* `
|
|
15
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
|
128
|
-
|
|
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
|
|
131
|
-
const
|
|
132
|
-
|
|
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 === "
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
437
|
+
await sweepReserved(full)
|
|
438
|
+
continue
|
|
145
439
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
throw errors.new(`lockfile body is not a pid: ${lockPath}`)
|
|
440
|
+
if (!entry.isFile() || !isReservedName(entry.name)) {
|
|
441
|
+
continue
|
|
149
442
|
}
|
|
150
|
-
if (
|
|
151
|
-
await
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
161
|
-
await
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
272
|
-
|
|
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
|
-
|
|
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 }
|