@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/writer.ts
CHANGED
|
@@ -1,48 +1,88 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The writer (60): a replica plus the right to create log objects.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
2
|
+
* The writer (60): a replica plus the right to create log objects. Role
|
|
3
|
+
* is a field on the handle. `openWriter(options)` births the store;
|
|
4
|
+
* `openWriter(replica)` wraps a born replica and settles an inherited
|
|
5
|
+
* pending without drawing id leases. A replica never births —
|
|
6
|
+
* ManifestMissing is its refusal. One commit path and one loss path: a
|
|
7
|
+
* lost slot's byte-equal occupant is an ambiguous PUT absorbed; anything
|
|
8
|
+
* else discards the local directory, re-opens through the replica to the
|
|
9
|
+
* current tip, and re-judges the recorded ops once — the verdict IS a
|
|
10
|
+
* serial execution, performed. Each loop iteration races once at the
|
|
11
|
+
* then-tip, so a historical loss is structurally uncountable, and bounded
|
|
12
|
+
* live-tip losses surface as ErrContention carrying the terminal
|
|
13
|
+
* re-judgment's own violation or the racing tip.
|
|
11
14
|
*/
|
|
12
15
|
|
|
13
16
|
import * as crypto from "node:crypto"
|
|
14
|
-
import
|
|
17
|
+
import * as fs from "node:fs/promises"
|
|
18
|
+
import * as path from "node:path"
|
|
19
|
+
import {
|
|
20
|
+
type Fact,
|
|
21
|
+
type FreshKeys,
|
|
22
|
+
internalBlake3,
|
|
23
|
+
type MemberRelation,
|
|
24
|
+
type SchemaRelations,
|
|
25
|
+
type Violation
|
|
26
|
+
} from "@bjornpagen/bumbledb"
|
|
15
27
|
import * as errors from "@superbuilders/errors"
|
|
16
|
-
import {
|
|
28
|
+
import { regex } from "arkregex"
|
|
29
|
+
import type { Digest32 } from "#bytes.ts"
|
|
30
|
+
import { bytesEqual, checkedAddU64, digest32, digest32FromHex, utf8Encoder, utf8StrictDecoder } from "#bytes.ts"
|
|
31
|
+
import { chainSum } from "#chain.ts"
|
|
17
32
|
import type { Op } from "#codec.ts"
|
|
18
33
|
import { decodeBatch, encodeBatch } from "#codec.ts"
|
|
19
34
|
import type { Braid, RelationInfo } from "#descriptor.ts"
|
|
20
|
-
import {
|
|
35
|
+
import { descriptorOf } from "#descriptor.ts"
|
|
36
|
+
import { ErrSpanningCommit, refuseExhausted, refuseOverWidth, refuseSlotRetired, throwContention } from "#errors.ts"
|
|
21
37
|
import type { Generation } from "#keys.ts"
|
|
22
|
-
import {
|
|
23
|
-
|
|
38
|
+
import {
|
|
39
|
+
CKPT_SCRATCH_LEASE,
|
|
40
|
+
checkpointMdbKey,
|
|
41
|
+
ckptDocKey,
|
|
42
|
+
encodeCkptScratch,
|
|
43
|
+
generation,
|
|
44
|
+
idsKey,
|
|
45
|
+
LEASE_NAMESPACE,
|
|
46
|
+
logKey,
|
|
47
|
+
manifestKey
|
|
48
|
+
} from "#keys.ts"
|
|
49
|
+
import type { CheckpointFacts } from "#manifest.ts"
|
|
50
|
+
import { checkpointVector, parseCheckpoint, parseManifest, renderCheckpoint, renderManifest } from "#manifest.ts"
|
|
51
|
+
import type { Core, OpenReplicaOptions, Replica } from "#replica.ts"
|
|
24
52
|
import {
|
|
25
53
|
applyOps,
|
|
26
|
-
|
|
54
|
+
belowFloor,
|
|
27
55
|
chainEntry,
|
|
28
56
|
clearPending,
|
|
29
57
|
coreOf,
|
|
30
58
|
discardAndReopen,
|
|
59
|
+
entriesOf,
|
|
60
|
+
foldPending,
|
|
31
61
|
generationOf,
|
|
62
|
+
holdPending,
|
|
32
63
|
maxBigint,
|
|
64
|
+
openReplica,
|
|
65
|
+
pendingOf,
|
|
33
66
|
persistSidecar,
|
|
34
67
|
readdressPending,
|
|
35
68
|
withGate
|
|
36
69
|
} from "#replica.ts"
|
|
70
|
+
import type { ObjectStore } from "#store.ts"
|
|
37
71
|
import type { Value } from "#value.ts"
|
|
38
72
|
import { checkAgainst } from "#value.ts"
|
|
39
73
|
|
|
40
74
|
/** 10 owns the width: one CAS amortizes counter traffic 4096× below slot traffic. */
|
|
41
75
|
const LEASE_WIDTH = 4096n
|
|
76
|
+
const ID_LEASE_COUNTER = regex("^\\d+$")
|
|
42
77
|
|
|
43
78
|
/** The live-loss bound (60): consecutive losses at the live tip, history never counts. */
|
|
44
79
|
const LOSS_BOUND = 16
|
|
45
80
|
|
|
81
|
+
/** Writer id in the fixed-layout header: magic + version + flags + fingerprint + braid + braid_gen + prev. */
|
|
82
|
+
const WRITER_AT = 4 + 2 + 2 + 32 + 4 + 8 + 32
|
|
83
|
+
|
|
84
|
+
const BATCH_MAGIC = utf8Encoder.encode("BDBL")
|
|
85
|
+
|
|
46
86
|
type Durability = "published" | "local-pending"
|
|
47
87
|
|
|
48
88
|
type Commit<Rels extends SchemaRelations, R> =
|
|
@@ -84,9 +124,20 @@ interface Batch<Rels extends SchemaRelations> {
|
|
|
84
124
|
): readonly bigint[]
|
|
85
125
|
}
|
|
86
126
|
|
|
127
|
+
/** Ownership of a slot, read from the winner's header. */
|
|
128
|
+
interface Deposition {
|
|
129
|
+
readonly braid: Braid
|
|
130
|
+
readonly slot: Generation
|
|
131
|
+
readonly resident: bigint
|
|
132
|
+
readonly usurper: bigint
|
|
133
|
+
}
|
|
134
|
+
|
|
87
135
|
interface Writer<Rels extends SchemaRelations> {
|
|
88
|
-
|
|
89
|
-
|
|
136
|
+
readonly role: "writer"
|
|
137
|
+
readonly replica: Replica<Rels>
|
|
138
|
+
deposition(): Deposition | null
|
|
139
|
+
commit<R>(body: (batch: Batch<Rels>) => R | Promise<R>): Promise<Commit<Rels, R>>
|
|
140
|
+
commitSplit<R>(body: (batch: Batch<Rels>) => R | Promise<R>): Promise<CommitSplit<Rels, R>>
|
|
90
141
|
}
|
|
91
142
|
|
|
92
143
|
interface LeaseRange {
|
|
@@ -94,37 +145,81 @@ interface LeaseRange {
|
|
|
94
145
|
readonly end: bigint
|
|
95
146
|
}
|
|
96
147
|
|
|
148
|
+
/** The scream of an unbounded repair loop: the set of recent signatures,
|
|
149
|
+
* a warning every eighth attempt, and an alarm the moment any
|
|
150
|
+
* signature recurs. */
|
|
151
|
+
interface Scream {
|
|
152
|
+
attempt(signature: string): void
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function screamOf(context: string): Scream {
|
|
156
|
+
const seen = new Set<string>()
|
|
157
|
+
let attempts = 0
|
|
158
|
+
return {
|
|
159
|
+
attempt(signature) {
|
|
160
|
+
attempts += 1
|
|
161
|
+
if (seen.has(signature)) {
|
|
162
|
+
console.error(`bumbledb-log alarm: ${context} repair signature recurs: ${signature}`)
|
|
163
|
+
} else {
|
|
164
|
+
seen.add(signature)
|
|
165
|
+
}
|
|
166
|
+
if (attempts % 8 === 0) {
|
|
167
|
+
console.error(`bumbledb-log warning: ${context} repair attempt ${attempts}: ${signature}`)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
97
173
|
interface WriterState {
|
|
98
174
|
readonly writerId: bigint
|
|
99
175
|
readonly pools: Map<string, LeaseRange[]>
|
|
176
|
+
readonly scream: Scream
|
|
177
|
+
deposition: Deposition | null
|
|
100
178
|
}
|
|
101
179
|
|
|
102
|
-
const ErrLeaseDrained = errors.new("bumbledb-log lease pool drained mid-recording")
|
|
103
|
-
const leaseDemand = new WeakMap<Error, { relation: number; field: number }>()
|
|
104
|
-
|
|
105
180
|
function poolKey(relation: number, field: number): string {
|
|
106
181
|
return `${relation}:${field}`
|
|
107
182
|
}
|
|
108
183
|
|
|
184
|
+
function remainingOf(state: WriterState, relation: number, field: number): bigint {
|
|
185
|
+
const pool = state.pools.get(poolKey(relation, field)) ?? []
|
|
186
|
+
let remaining = 0n
|
|
187
|
+
for (const range of pool) {
|
|
188
|
+
remaining += range.end - range.next
|
|
189
|
+
}
|
|
190
|
+
return remaining
|
|
191
|
+
}
|
|
192
|
+
|
|
109
193
|
function drawIds(state: WriterState, relation: number, field: number, count: bigint): bigint[] {
|
|
194
|
+
if (count < 0n) {
|
|
195
|
+
throw errors.new(`id-lease count is unsigned: ${count}`)
|
|
196
|
+
}
|
|
197
|
+
if (count > LEASE_WIDTH) {
|
|
198
|
+
refuseOverWidth({ requested: count }, `id-lease draw ${count} exceeds the lease width ${LEASE_WIDTH}`)
|
|
199
|
+
}
|
|
200
|
+
if (count === 0n) {
|
|
201
|
+
return []
|
|
202
|
+
}
|
|
110
203
|
const pool = state.pools.get(poolKey(relation, field)) ?? []
|
|
204
|
+
const range = pool[0]
|
|
205
|
+
if (range === undefined || range.end - range.next < count) {
|
|
206
|
+
refuseExhausted(
|
|
207
|
+
{ relation, field },
|
|
208
|
+
`id-lease relation ${relation} field ${field} cannot draw ${count} from the cached block`
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
if (checkedAddU64(range.next, count) === undefined) {
|
|
212
|
+
refuseExhausted({ relation, field }, `id-lease relation ${relation} field ${field} would leave u64`)
|
|
213
|
+
}
|
|
111
214
|
const ids: bigint[] = []
|
|
112
215
|
let remaining = count
|
|
113
|
-
while (remaining > 0n) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
while (remaining > 0n && range.next < range.end) {
|
|
121
|
-
ids.push(range.next)
|
|
122
|
-
range.next += 1n
|
|
123
|
-
remaining -= 1n
|
|
124
|
-
}
|
|
125
|
-
if (range.next >= range.end) {
|
|
126
|
-
pool.shift()
|
|
127
|
-
}
|
|
216
|
+
while (remaining > 0n && range.next < range.end) {
|
|
217
|
+
ids.push(range.next)
|
|
218
|
+
range.next += 1n
|
|
219
|
+
remaining -= 1n
|
|
220
|
+
}
|
|
221
|
+
if (range.next >= range.end) {
|
|
222
|
+
pool.shift()
|
|
128
223
|
}
|
|
129
224
|
state.pools.set(poolKey(relation, field), pool)
|
|
130
225
|
return ids
|
|
@@ -149,13 +244,17 @@ async function acquireLease<Rels extends SchemaRelations>(
|
|
|
149
244
|
continue
|
|
150
245
|
}
|
|
151
246
|
const body = utf8StrictDecoder.decode(fetched.bytes)
|
|
152
|
-
if (
|
|
247
|
+
if (!ID_LEASE_COUNTER.test(body)) {
|
|
153
248
|
throw errors.new(`id-lease counter ${key} is not a canonical decimal: ${body}`)
|
|
154
249
|
}
|
|
155
250
|
const next = BigInt(body)
|
|
156
|
-
const
|
|
251
|
+
const end = checkedAddU64(next, LEASE_WIDTH)
|
|
252
|
+
if (end === undefined) {
|
|
253
|
+
refuseExhausted({ relation, field }, `id-lease relation ${relation} field ${field} would leave u64`)
|
|
254
|
+
}
|
|
255
|
+
const swapped = await core.store.putSwap(key, utf8Encoder.encode(String(end)), fetched.etag)
|
|
157
256
|
if (swapped.tag === "swapped") {
|
|
158
|
-
pushRange(state, relation, field, next,
|
|
257
|
+
pushRange(state, relation, field, next, end)
|
|
159
258
|
return
|
|
160
259
|
}
|
|
161
260
|
}
|
|
@@ -167,6 +266,19 @@ function pushRange(state: WriterState, relation: number, field: number, next: bi
|
|
|
167
266
|
state.pools.set(poolKey(relation, field), pool)
|
|
168
267
|
}
|
|
169
268
|
|
|
269
|
+
async function ensureFreshLeases<Rels extends SchemaRelations>(core: Core<Rels>, state: WriterState): Promise<void> {
|
|
270
|
+
for (const info of core.descriptor.relations) {
|
|
271
|
+
if (info.closed) {
|
|
272
|
+
continue
|
|
273
|
+
}
|
|
274
|
+
for (const [ordinal, field] of info.fields.entries()) {
|
|
275
|
+
if (field.fresh && remainingOf(state, info.id, ordinal) === 0n) {
|
|
276
|
+
await acquireLease(core, state, info.id, ordinal)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
170
282
|
interface Recording {
|
|
171
283
|
readonly ops: Op[]
|
|
172
284
|
}
|
|
@@ -254,29 +366,18 @@ function recorderOf<Rels extends SchemaRelations>(
|
|
|
254
366
|
return { batch, recording }
|
|
255
367
|
}
|
|
256
368
|
|
|
257
|
-
/** Runs the recording body
|
|
258
|
-
*
|
|
259
|
-
*
|
|
369
|
+
/** Runs the recording body exactly once. The body is awaited to
|
|
370
|
+
* completion before the batch is sealed; reserve draws from the
|
|
371
|
+
* cached block (OverWidth | Exhausted | Drawn). */
|
|
260
372
|
async function recordWithLeases<Rels extends SchemaRelations, R>(
|
|
261
373
|
core: Core<Rels>,
|
|
262
374
|
state: WriterState,
|
|
263
|
-
body: (batch: Batch<Rels>) => R
|
|
375
|
+
body: (batch: Batch<Rels>) => R | Promise<R>
|
|
264
376
|
): Promise<{ value: R; ops: Op[] }> {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
})
|
|
270
|
-
if (ran.error === undefined) {
|
|
271
|
-
return { value: ran.data, ops: recording.ops }
|
|
272
|
-
}
|
|
273
|
-
const demand = leaseDemand.get(ran.error) ?? leaseDemand.get(errors.cause(ran.error))
|
|
274
|
-
if (demand === undefined) {
|
|
275
|
-
throw ran.error
|
|
276
|
-
}
|
|
277
|
-
await acquireLease(core, state, demand.relation, demand.field)
|
|
278
|
-
}
|
|
279
|
-
throw errors.new("the id-lease pool could not satisfy the recording after 16 refills")
|
|
377
|
+
await ensureFreshLeases(core, state)
|
|
378
|
+
const { batch, recording } = recorderOf(core, state)
|
|
379
|
+
const value = await body(batch)
|
|
380
|
+
return { value, ops: recording.ops }
|
|
280
381
|
}
|
|
281
382
|
|
|
282
383
|
function braidsTouched<Rels extends SchemaRelations>(core: Core<Rels>, ops: readonly Op[]): Map<Braid, Op[]> {
|
|
@@ -297,9 +398,48 @@ function braidsTouched<Rels extends SchemaRelations>(core: Core<Rels>, ops: read
|
|
|
297
398
|
return partitioned
|
|
298
399
|
}
|
|
299
400
|
|
|
401
|
+
function u64leAt(bytes: Uint8Array, at: number): bigint | undefined {
|
|
402
|
+
if (bytes.length < at + 8) {
|
|
403
|
+
return undefined
|
|
404
|
+
}
|
|
405
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset + at, 8)
|
|
406
|
+
return view.getBigUint64(0, true)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** The usurper is a fact in the header. A body that refuses to decode
|
|
410
|
+
* does not hide the slot's owner. */
|
|
411
|
+
function headerWriter(bytes: Uint8Array): bigint | undefined {
|
|
412
|
+
if (bytes.length < BATCH_MAGIC.length || !bytesEqual(bytes.subarray(0, BATCH_MAGIC.length), BATCH_MAGIC)) {
|
|
413
|
+
return undefined
|
|
414
|
+
}
|
|
415
|
+
return u64leAt(bytes, WRITER_AT)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function headerTimestamp(bytes: Uint8Array): bigint | undefined {
|
|
419
|
+
return u64leAt(bytes, WRITER_AT + 8)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Header prev is 32 branded bytes. */
|
|
423
|
+
function digestPrev(prev: Digest32 | Uint8Array): Digest32 {
|
|
424
|
+
return digest32(prev)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function noteDeposition(state: WriterState, braid: Braid, slot: Generation, winnerBytes: Uint8Array): void {
|
|
428
|
+
if (state.deposition !== null) {
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
const usurper = headerWriter(winnerBytes)
|
|
432
|
+
if (usurper === undefined) {
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
state.deposition = { braid, slot, resident: state.writerId, usurper }
|
|
436
|
+
}
|
|
437
|
+
|
|
300
438
|
/** The terminal contention scream: the re-judgment's own rejection names
|
|
301
439
|
* the hot statement and carries the offending facts' raw values; an
|
|
302
|
-
* accepted-but-outraced terminal loss carries the racing tip.
|
|
440
|
+
* accepted-but-outraced terminal loss carries the racing tip. A
|
|
441
|
+
* rejection without a violation is refused — the empty statement is
|
|
442
|
+
* unrepresentable. */
|
|
303
443
|
function screamContention<Rels extends SchemaRelations>(
|
|
304
444
|
braid: Braid,
|
|
305
445
|
rejudged:
|
|
@@ -308,13 +448,16 @@ function screamContention<Rels extends SchemaRelations>(
|
|
|
308
448
|
): never {
|
|
309
449
|
if (rejudged.tag === "rejected") {
|
|
310
450
|
const violation = rejudged.violations[0]
|
|
451
|
+
if (violation === undefined) {
|
|
452
|
+
throw errors.new("a rejection carries at least one violation")
|
|
453
|
+
}
|
|
311
454
|
throwContention(
|
|
312
455
|
{
|
|
313
456
|
braid,
|
|
314
457
|
cause: {
|
|
315
458
|
kind: "hot-key",
|
|
316
|
-
statement: violation
|
|
317
|
-
determinants:
|
|
459
|
+
statement: violation.canonical,
|
|
460
|
+
determinants: violation.facts.map(function rawOf(offending) {
|
|
318
461
|
return offending.fact
|
|
319
462
|
})
|
|
320
463
|
}
|
|
@@ -329,15 +472,13 @@ function screamContention<Rels extends SchemaRelations>(
|
|
|
329
472
|
}
|
|
330
473
|
|
|
331
474
|
/**
|
|
332
|
-
* Publishes the applied pending batch:
|
|
333
|
-
* on Exists. A byte-equal
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
* on a net no-op (the publish law), or the serial Rejected. Each
|
|
340
|
-
* iteration races once at the then-tip; the bound counts iterations.
|
|
475
|
+
* Publishes the applied pending batch: refuse a below-floor create,
|
|
476
|
+
* then slot CAS, then the one loss path on Exists. A byte-equal
|
|
477
|
+
* occupant is our own ambiguous PUT, absorbed. Anything else carries
|
|
478
|
+
* the pending through a directory discard — re-persisted into the
|
|
479
|
+
* fresh sidecar before any re-judgment — re-opens to the current tip,
|
|
480
|
+
* and re-judges the recorded ops in one db.write. An occupant that
|
|
481
|
+
* then vanishes is retired, not a loop that forges the swept slot.
|
|
341
482
|
*/
|
|
342
483
|
async function publishPending<Rels extends SchemaRelations>(
|
|
343
484
|
core: Core<Rels>,
|
|
@@ -346,31 +487,46 @@ async function publishPending<Rels extends SchemaRelations>(
|
|
|
346
487
|
): Promise<Commit<Rels, undefined>> {
|
|
347
488
|
let losses = 0
|
|
348
489
|
for (;;) {
|
|
349
|
-
const pending = core
|
|
490
|
+
const pending = pendingOf(core)
|
|
350
491
|
if (pending === null) {
|
|
351
492
|
throw errors.new("publish reached with no pending batch")
|
|
352
493
|
}
|
|
353
494
|
const braid = pending.braid
|
|
495
|
+
// The floor is a write precondition: a slot at or below it is
|
|
496
|
+
// retired. A create must not touch the store (70/116/127).
|
|
497
|
+
if (belowFloor(core, braid, pending.gen)) {
|
|
498
|
+
refuseSlotRetired({ braid, slot: pending.gen }, "the slot is retired")
|
|
499
|
+
}
|
|
354
500
|
const created = await core.store.putCreate(logKey(core.prefix, braid, pending.gen), pending.bytes)
|
|
355
501
|
let winnerBytes: Uint8Array | null = null
|
|
356
|
-
if (created.tag
|
|
502
|
+
if (created.tag !== "created") {
|
|
357
503
|
const fetched = await core.store.get(logKey(core.prefix, braid, pending.gen))
|
|
358
504
|
if (fetched === null) {
|
|
359
|
-
|
|
505
|
+
// Exists then null: the occupant was swept. Refuse
|
|
506
|
+
// rather than loop back into putCreate.
|
|
507
|
+
refuseSlotRetired({ braid, slot: pending.gen }, "the slot is retired")
|
|
360
508
|
}
|
|
361
509
|
if (!bytesEqual(fetched.bytes, pending.bytes)) {
|
|
362
510
|
winnerBytes = fetched.bytes
|
|
363
511
|
}
|
|
364
512
|
}
|
|
365
513
|
if (winnerBytes === null) {
|
|
366
|
-
const
|
|
367
|
-
|
|
514
|
+
const timestamp = headerTimestamp(pending.bytes)
|
|
515
|
+
if (timestamp === undefined) {
|
|
516
|
+
throw errors.new("pending batch header is shorter than the fixed layout")
|
|
517
|
+
}
|
|
518
|
+
entriesOf(core).set(braid, {
|
|
519
|
+
g: pending.gen,
|
|
520
|
+
prev: digest32(new Uint8Array(internalBlake3(pending.bytes))),
|
|
521
|
+
ts: timestamp
|
|
522
|
+
})
|
|
368
523
|
await clearPending(core)
|
|
369
524
|
return { tag: "accepted", value: undefined, braid, generation: pending.gen, durability: "published" }
|
|
370
525
|
}
|
|
371
526
|
|
|
372
527
|
losses += 1
|
|
373
|
-
|
|
528
|
+
state.scream.attempt("slot occupant is not ours")
|
|
529
|
+
noteDeposition(state, braid, pending.gen, winnerBytes)
|
|
374
530
|
await discardAndReopen(core)
|
|
375
531
|
const before = generationOf(core)
|
|
376
532
|
const rejudged = applyOps(core, ops)
|
|
@@ -391,6 +547,8 @@ async function publishPending<Rels extends SchemaRelations>(
|
|
|
391
547
|
durability: "published"
|
|
392
548
|
}
|
|
393
549
|
}
|
|
550
|
+
const tip = chainEntry(core, braid)
|
|
551
|
+
entriesOf(core).set(braid, { g: tip.g, prev: digestPrev(tip.prev), ts: tip.ts })
|
|
394
552
|
await readdressPending(core, ops, state.writerId)
|
|
395
553
|
if (losses >= LOSS_BOUND) {
|
|
396
554
|
screamContention(braid, { tag: "outraced", tip: chainEntry(core, braid).g })
|
|
@@ -398,8 +556,9 @@ async function publishPending<Rels extends SchemaRelations>(
|
|
|
398
556
|
}
|
|
399
557
|
}
|
|
400
558
|
|
|
401
|
-
/** The commit discipline (60): encode, fsync
|
|
402
|
-
* publish only what advanced the generation.
|
|
559
|
+
/** The commit discipline (60): encode, fsync as Pending before any
|
|
560
|
+
* apply, judge locally, publish only what advanced the generation.
|
|
561
|
+
* Settled is written only after the verdict. */
|
|
403
562
|
async function disciplineCommit<Rels extends SchemaRelations>(
|
|
404
563
|
core: Core<Rels>,
|
|
405
564
|
state: WriterState,
|
|
@@ -411,18 +570,17 @@ async function disciplineCommit<Rels extends SchemaRelations>(
|
|
|
411
570
|
const bytes = encodeBatch(
|
|
412
571
|
core.descriptor,
|
|
413
572
|
{
|
|
414
|
-
fingerprint: core.descriptor.fingerprint,
|
|
573
|
+
fingerprint: digest32FromHex(core.descriptor.fingerprint),
|
|
415
574
|
braid,
|
|
416
575
|
braidGen: generation(entry.g + 1n),
|
|
417
|
-
prev: entry.prev,
|
|
576
|
+
prev: digestPrev(entry.prev),
|
|
418
577
|
writer: state.writerId,
|
|
419
578
|
timestamp
|
|
420
579
|
},
|
|
421
580
|
ops
|
|
422
581
|
)
|
|
423
|
-
|
|
424
|
-
core.
|
|
425
|
-
core.pendingApplied = false
|
|
582
|
+
// Pending → durable, before any apply.
|
|
583
|
+
holdPending(core, { braid, gen: generation(entry.g + 1n), bytes }, ops)
|
|
426
584
|
await persistSidecar(core)
|
|
427
585
|
|
|
428
586
|
const before = generationOf(core)
|
|
@@ -435,31 +593,223 @@ async function disciplineCommit<Rels extends SchemaRelations>(
|
|
|
435
593
|
await clearPending(core)
|
|
436
594
|
return { tag: "accepted", value: undefined, braid, generation: entry.g, durability: "published" }
|
|
437
595
|
}
|
|
438
|
-
core.pendingApplied = true
|
|
439
596
|
return publishPending(core, state, ops)
|
|
440
597
|
}
|
|
441
598
|
|
|
442
|
-
/**
|
|
599
|
+
/** An inherited pending is resolved-and-published by open. A slot the
|
|
600
|
+
* floor already covers is published (`Clear`), not re-judged (46). */
|
|
443
601
|
async function settleInheritedPending<Rels extends SchemaRelations>(
|
|
444
602
|
core: Core<Rels>,
|
|
445
603
|
state: WriterState
|
|
446
604
|
): Promise<void> {
|
|
447
|
-
|
|
605
|
+
const pending = pendingOf(core)
|
|
606
|
+
if (pending === null) {
|
|
607
|
+
return
|
|
608
|
+
}
|
|
609
|
+
if (
|
|
610
|
+
foldPending(
|
|
611
|
+
chainSum(core.chain),
|
|
612
|
+
generationOf(core),
|
|
613
|
+
null,
|
|
614
|
+
pending.bytes,
|
|
615
|
+
belowFloor(core, pending.braid, pending.gen)
|
|
616
|
+
).tag === "below-floor"
|
|
617
|
+
) {
|
|
618
|
+
await clearPending(core)
|
|
448
619
|
return
|
|
449
620
|
}
|
|
450
|
-
|
|
621
|
+
let ops = pending.ops
|
|
622
|
+
if (ops === null) {
|
|
623
|
+
const decoded = errors.trySync(function decodePending() {
|
|
624
|
+
return decodeBatch(core.descriptor, pending.bytes)
|
|
625
|
+
})
|
|
626
|
+
if (decoded.error) {
|
|
627
|
+
await clearPending(core)
|
|
628
|
+
return
|
|
629
|
+
}
|
|
630
|
+
ops = decoded.data.ops
|
|
631
|
+
}
|
|
632
|
+
const published = await publishPending(core, state, ops)
|
|
633
|
+
if (published.tag === "rejected") {
|
|
634
|
+
return
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
type PublishRefusal = "manifest-missing" | "manifest" | "checkpoint-doc-missing" | "checkpoint"
|
|
639
|
+
|
|
640
|
+
type Published =
|
|
641
|
+
| { readonly tag: "replaced" }
|
|
642
|
+
| { readonly tag: "kept"; readonly incumbent: Digest32 }
|
|
643
|
+
| { readonly tag: "refused"; readonly reason: PublishRefusal }
|
|
644
|
+
|
|
645
|
+
async function putCreateOnce(store: ObjectStore, key: ReturnType<typeof ckptDocKey>, bytes: Uint8Array): Promise<void> {
|
|
646
|
+
for (;;) {
|
|
647
|
+
const created = await store.putCreate(key, bytes)
|
|
648
|
+
if (created.tag === "created" || created.tag === "exists") {
|
|
649
|
+
return
|
|
650
|
+
}
|
|
651
|
+
const fetched = await store.get(key)
|
|
652
|
+
if (fetched !== null) {
|
|
653
|
+
return
|
|
654
|
+
}
|
|
655
|
+
}
|
|
451
656
|
}
|
|
452
657
|
|
|
453
|
-
|
|
658
|
+
/** The loser deletes its own `ckpt/{digest}` and `.mdb`. */
|
|
659
|
+
async function deleteOrphan(store: ObjectStore, prefix: string, digest: Digest32): Promise<void> {
|
|
660
|
+
await store.delete(ckptDocKey(prefix, digest))
|
|
661
|
+
await store.delete(checkpointMdbKey(prefix, digest))
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function scratchPath(dir: string): string {
|
|
665
|
+
return path.join(dir, LEASE_NAMESPACE, CKPT_SCRATCH_LEASE)
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async function claimScratch(dir: string, digest: Digest32): Promise<void> {
|
|
669
|
+
const target = scratchPath(dir)
|
|
670
|
+
await fs.mkdir(path.dirname(target), { recursive: true })
|
|
671
|
+
const handle = await fs.open(target, "w")
|
|
672
|
+
const written = await errors.try(
|
|
673
|
+
(async function writeLease() {
|
|
674
|
+
await handle.writeFile(encodeCkptScratch(digest))
|
|
675
|
+
await handle.sync()
|
|
676
|
+
})()
|
|
677
|
+
)
|
|
678
|
+
await handle.close()
|
|
679
|
+
if (written.error) {
|
|
680
|
+
await fs.rm(target, { force: true })
|
|
681
|
+
throw written.error
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async function releaseScratch(dir: string): Promise<void> {
|
|
686
|
+
await fs.rm(scratchPath(dir), { force: true })
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function casPublish(
|
|
690
|
+
store: ObjectStore,
|
|
691
|
+
prefix: string,
|
|
692
|
+
known: ReadonlySet<Braid>,
|
|
693
|
+
candidate: CheckpointFacts,
|
|
694
|
+
digest: Digest32,
|
|
695
|
+
bytes: Uint8Array
|
|
696
|
+
): Promise<Published> {
|
|
697
|
+
await putCreateOnce(store, ckptDocKey(prefix, digest), bytes)
|
|
698
|
+
for (;;) {
|
|
699
|
+
const fetched = await store.get(manifestKey(prefix))
|
|
700
|
+
if (fetched === null) {
|
|
701
|
+
return { tag: "refused", reason: "manifest-missing" }
|
|
702
|
+
}
|
|
703
|
+
const parsed = errors.trySync(function parse() {
|
|
704
|
+
return parseManifest(fetched.bytes)
|
|
705
|
+
})
|
|
706
|
+
if (parsed.error) {
|
|
707
|
+
return { tag: "refused", reason: "manifest" }
|
|
708
|
+
}
|
|
709
|
+
const incumbent = parsed.data.checkpoint
|
|
710
|
+
if (incumbent !== null && bytesEqual(incumbent, digest)) {
|
|
711
|
+
return { tag: "replaced" }
|
|
712
|
+
}
|
|
713
|
+
if (incumbent !== null) {
|
|
714
|
+
const doc = await store.get(ckptDocKey(prefix, incumbent))
|
|
715
|
+
if (doc === null) {
|
|
716
|
+
return { tag: "refused", reason: "checkpoint-doc-missing" }
|
|
717
|
+
}
|
|
718
|
+
const incumbentDoc = errors.trySync(function parseIncumbent() {
|
|
719
|
+
return parseCheckpoint(doc.bytes, known)
|
|
720
|
+
})
|
|
721
|
+
if (incumbentDoc.error) {
|
|
722
|
+
return { tag: "refused", reason: "checkpoint" }
|
|
723
|
+
}
|
|
724
|
+
if (checkpointVector(candidate).order(checkpointVector(incumbentDoc.data)) !== "after") {
|
|
725
|
+
return { tag: "kept", incumbent }
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
const next = renderManifest({ fingerprint: parsed.data.fingerprint, checkpoint: digest })
|
|
729
|
+
const swapped = await store.putSwap(manifestKey(prefix), next, fetched.etag)
|
|
730
|
+
if (swapped.tag === "swapped") {
|
|
731
|
+
return { tag: "replaced" }
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Publishes `candidate` under the checkpoint order. The digest is the
|
|
738
|
+
* blake3 of the full bytes, `prev` included. A scratch lease
|
|
739
|
+
* `{dir}/~lease/ckpt-scratch` names the candidate for the publish
|
|
740
|
+
* window; a crash leaves that lease and a successor sweep reclaims
|
|
741
|
+
* it. `Kept` and every refused publish delete the candidate's `ckpt`
|
|
742
|
+
* pair.
|
|
743
|
+
*/
|
|
744
|
+
async function publishCheckpoint(
|
|
745
|
+
store: ObjectStore,
|
|
746
|
+
prefix: string,
|
|
747
|
+
dir: string,
|
|
748
|
+
known: ReadonlySet<Braid>,
|
|
749
|
+
candidate: CheckpointFacts,
|
|
750
|
+
mdb: Uint8Array
|
|
751
|
+
): Promise<Published> {
|
|
752
|
+
const bytes = renderCheckpoint(candidate)
|
|
753
|
+
const digest = digest32(new Uint8Array(internalBlake3(bytes)))
|
|
754
|
+
await claimScratch(dir, digest)
|
|
755
|
+
const ran = await errors.try(
|
|
756
|
+
(async function publish() {
|
|
757
|
+
await putCreateOnce(store, checkpointMdbKey(prefix, digest), mdb)
|
|
758
|
+
return await casPublish(store, prefix, known, candidate, digest, bytes)
|
|
759
|
+
})()
|
|
760
|
+
)
|
|
761
|
+
if (ran.error) {
|
|
762
|
+
throw ran.error
|
|
763
|
+
}
|
|
764
|
+
if (ran.data.tag === "kept" || ran.data.tag === "refused") {
|
|
765
|
+
await deleteOrphan(store, prefix, digest)
|
|
766
|
+
}
|
|
767
|
+
await releaseScratch(dir)
|
|
768
|
+
return ran.data
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** Only the writer births a store: create-only PUT of a genesis manifest. */
|
|
772
|
+
async function birthStore(store: ObjectStore, prefix: string, fingerprint: Digest32): Promise<void> {
|
|
773
|
+
const key = manifestKey(prefix)
|
|
774
|
+
for (;;) {
|
|
775
|
+
const fetched = await store.get(key)
|
|
776
|
+
if (fetched !== null) {
|
|
777
|
+
return
|
|
778
|
+
}
|
|
779
|
+
const bytes = renderManifest({ fingerprint, checkpoint: null })
|
|
780
|
+
const created = await store.putCreate(key, bytes)
|
|
781
|
+
if (created.tag === "created") {
|
|
782
|
+
return
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function isReplica<Rels extends SchemaRelations>(
|
|
788
|
+
source: Replica<Rels> | OpenReplicaOptions<Rels>
|
|
789
|
+
): source is Replica<Rels> {
|
|
790
|
+
return typeof (source as Replica<Rels>).refresh === "function"
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** Wrap a born replica: settle an inherited pending; do not birth; do not draw id leases. */
|
|
794
|
+
async function writerOn<Rels extends SchemaRelations>(replica: Replica<Rels>): Promise<Writer<Rels>> {
|
|
454
795
|
const core = coreOf(replica)
|
|
455
796
|
const state: WriterState = {
|
|
456
797
|
writerId: crypto.randomBytes(8).readBigUInt64LE(),
|
|
457
|
-
pools: new Map()
|
|
798
|
+
pools: new Map(),
|
|
799
|
+
scream: screamOf("writer discard-and-re-pull"),
|
|
800
|
+
deposition: null
|
|
458
801
|
}
|
|
802
|
+
await withGate(core, async function openTransition() {
|
|
803
|
+
await settleInheritedPending(core, state)
|
|
804
|
+
})
|
|
459
805
|
return {
|
|
806
|
+
role: "writer",
|
|
807
|
+
replica,
|
|
808
|
+
deposition() {
|
|
809
|
+
return state.deposition
|
|
810
|
+
},
|
|
460
811
|
async commit(body) {
|
|
461
812
|
return withGate(core, async function commitBody() {
|
|
462
|
-
await settleInheritedPending(core, state)
|
|
463
813
|
const recorded = await recordWithLeases(core, state, body)
|
|
464
814
|
if (recorded.ops.length === 0) {
|
|
465
815
|
throw errors.new("commit recorded no ops — an empty transaction names no braid")
|
|
@@ -479,7 +829,6 @@ function openWriter<Rels extends SchemaRelations>(replica: Replica<Rels>): Write
|
|
|
479
829
|
|
|
480
830
|
async commitSplit(body) {
|
|
481
831
|
return withGate(core, async function splitBody() {
|
|
482
|
-
await settleInheritedPending(core, state)
|
|
483
832
|
const recorded = await recordWithLeases(core, state, body)
|
|
484
833
|
if (recorded.ops.length === 0) {
|
|
485
834
|
throw errors.new("commitSplit recorded no ops — an empty transaction names no braid")
|
|
@@ -505,5 +854,17 @@ function openWriter<Rels extends SchemaRelations>(replica: Replica<Rels>): Write
|
|
|
505
854
|
}
|
|
506
855
|
}
|
|
507
856
|
|
|
508
|
-
|
|
509
|
-
|
|
857
|
+
async function openWriter<Rels extends SchemaRelations>(replica: Replica<Rels>): Promise<Writer<Rels>>
|
|
858
|
+
async function openWriter<Rels extends SchemaRelations>(options: OpenReplicaOptions<Rels>): Promise<Writer<Rels>>
|
|
859
|
+
async function openWriter<Rels extends SchemaRelations>(
|
|
860
|
+
source: Replica<Rels> | OpenReplicaOptions<Rels>
|
|
861
|
+
): Promise<Writer<Rels>> {
|
|
862
|
+
if (isReplica(source)) {
|
|
863
|
+
return writerOn(source)
|
|
864
|
+
}
|
|
865
|
+
await birthStore(source.store, source.prefix, digest32FromHex(descriptorOf(source.theory).fingerprint))
|
|
866
|
+
return writerOn(await openReplica(source))
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
export type { Batch, BraidOutcome, Commit, CommitSplit, Deposition, Durability, Published, PublishRefusal, Writer }
|
|
870
|
+
export { openWriter, publishCheckpoint }
|