@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/replica.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The replica
|
|
2
|
+
* The replica: a local store that is a materialized view of the
|
|
3
3
|
* braids' prefixes, plus the loop that keeps it current. Disposable by
|
|
4
|
-
* construction — the
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* construction — the sidecar is a floor cache with one wholeness
|
|
5
|
+
* check; recovery IS the catch-up loop (L10). Generation is a total
|
|
6
|
+
* function of the chain: Settled sums the vector, Pending is that sum
|
|
7
|
+
* plus one. A replica that finds no manifest refuses; only a writer
|
|
8
|
+
* births a store.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import * as fs from "node:fs/promises"
|
|
@@ -13,30 +13,63 @@ import * as path from "node:path"
|
|
|
13
13
|
import type { Db, Fact, MemberRelation, Schema, SchemaRelations, WriteOutcome } from "@bjornpagen/bumbledb"
|
|
14
14
|
import { internalBlake3, Db as SdkDb } from "@bjornpagen/bumbledb"
|
|
15
15
|
import * as errors from "@superbuilders/errors"
|
|
16
|
-
import {
|
|
17
|
-
import type {
|
|
18
|
-
import {
|
|
16
|
+
import { parse } from "#braids.ts"
|
|
17
|
+
import type { Digest32 } from "#bytes.ts"
|
|
18
|
+
import { bytesEqual, digest32, hex32, saturatingAddU64, toHex } from "#bytes.ts"
|
|
19
|
+
import type { Chain, ChainEntry, Pending } from "#chain.ts"
|
|
20
|
+
import { chainGeneration, chainSum, readSidecar, writeSidecar } from "#chain.ts"
|
|
19
21
|
import type { Op } from "#codec.ts"
|
|
20
22
|
import { decodeBatch, encodeBatch, verifyChain } from "#codec.ts"
|
|
21
23
|
import type { Braid, Descriptor, RelationInfo } from "#descriptor.ts"
|
|
22
24
|
import { descriptorOf } from "#descriptor.ts"
|
|
23
|
-
import {
|
|
25
|
+
import { ErrRefused, ErrReplayDiverged, refuse, refuseManifestMissing, wrapStore } from "#errors.ts"
|
|
24
26
|
import type { Generation } from "#keys.ts"
|
|
25
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
CKPT_SCRATCH_LEASE,
|
|
29
|
+
checkpointMdbKey,
|
|
30
|
+
ckptDocKey,
|
|
31
|
+
generation,
|
|
32
|
+
LEASE_NAMESPACE,
|
|
33
|
+
logKey,
|
|
34
|
+
manifestKey,
|
|
35
|
+
parseCkptScratch,
|
|
36
|
+
TEMP_NAMESPACE
|
|
37
|
+
} from "#keys.ts"
|
|
26
38
|
import type { CheckpointFacts } from "#manifest.ts"
|
|
27
|
-
import { parseCheckpoint, parseManifest
|
|
39
|
+
import { auditCatalog, parseCheckpoint, parseManifest } from "#manifest.ts"
|
|
28
40
|
import type { Etag, ObjectStore } from "#store.ts"
|
|
29
41
|
import type { Value } from "#value.ts"
|
|
42
|
+
import { Vector } from "#vector.ts"
|
|
30
43
|
|
|
31
|
-
const ZERO_HASH =
|
|
44
|
+
const ZERO_HASH = digest32(new Uint8Array(32))
|
|
32
45
|
|
|
33
|
-
/** The gc-safety heartbeat cadence
|
|
46
|
+
/** The gc-safety heartbeat cadence: every N-th pass re-reads the manifest. */
|
|
34
47
|
const HEARTBEAT_PASSES = 16
|
|
35
48
|
|
|
36
|
-
/** The re-poll cadence
|
|
37
|
-
* read-your-writes waiter in `waitFor` is this number's one consumer. */
|
|
49
|
+
/** The re-poll cadence of waitFor, its one consumer. */
|
|
38
50
|
const WAIT_FOR_POLL_MS = 20
|
|
39
51
|
|
|
52
|
+
type ReplicaState =
|
|
53
|
+
| { readonly tag: "bootstrapped" }
|
|
54
|
+
| { readonly tag: "checkpoint-seeded"; readonly catalog: Digest32 }
|
|
55
|
+
| { readonly tag: "sidecar-resumed"; readonly floor: ReadonlyMap<Braid, Generation> }
|
|
56
|
+
|
|
57
|
+
type RefreshOutcome =
|
|
58
|
+
| { readonly tag: "advanced"; readonly vector: ReadonlyMap<Braid, Generation> }
|
|
59
|
+
| { readonly tag: "wedged"; readonly braid: Braid; readonly cause: string }
|
|
60
|
+
| { readonly tag: "reseed"; readonly cause: string }
|
|
61
|
+
| { readonly tag: "refused"; readonly detail: string }
|
|
62
|
+
|
|
63
|
+
type Step =
|
|
64
|
+
| { readonly tag: "applied" }
|
|
65
|
+
| { readonly tag: "tip" }
|
|
66
|
+
| { readonly tag: "wedged"; readonly braid: Braid; readonly cause: string }
|
|
67
|
+
| { readonly tag: "reseed"; readonly cause: string }
|
|
68
|
+
|
|
69
|
+
type ApplyPhase = "open" | "steady"
|
|
70
|
+
|
|
71
|
+
type SlotApply = { readonly tag: "applied"; readonly generation: bigint } | { readonly tag: "discard" }
|
|
72
|
+
|
|
40
73
|
interface OpenReplicaOptions<Rels extends SchemaRelations> {
|
|
41
74
|
readonly store: ObjectStore
|
|
42
75
|
readonly prefix: string
|
|
@@ -58,17 +91,19 @@ interface Core<Rels extends SchemaRelations> {
|
|
|
58
91
|
readonly theory: Schema<Rels>
|
|
59
92
|
readonly descriptor: Descriptor
|
|
60
93
|
db: Db<Rels>
|
|
61
|
-
chain
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
94
|
+
/** Settled|Pending. Generation is chainGeneration(this.chain) — a
|
|
95
|
+
* total function of the sum. There is no pending Option beside the
|
|
96
|
+
* vector and no 0|1 addend a reader can skip (§30). */
|
|
97
|
+
chain: Chain
|
|
65
98
|
manifestEtag: Etag | null
|
|
66
99
|
checkpoint: CheckpointFacts | null
|
|
67
|
-
checkpointDigest:
|
|
100
|
+
checkpointDigest: Digest32 | null
|
|
68
101
|
passes: number
|
|
69
102
|
closed: boolean
|
|
70
103
|
storeName: string
|
|
71
104
|
gate: Promise<unknown>
|
|
105
|
+
wedged: Map<Braid, string>
|
|
106
|
+
provenance: ReplicaState
|
|
72
107
|
}
|
|
73
108
|
|
|
74
109
|
const cores = new WeakMap<object, Core<SchemaRelations>>()
|
|
@@ -99,8 +134,12 @@ function blake3Hex(bytes: Uint8Array): string {
|
|
|
99
134
|
return toHex(new Uint8Array(internalBlake3(bytes)))
|
|
100
135
|
}
|
|
101
136
|
|
|
137
|
+
function blake3Digest(bytes: Uint8Array): Digest32 {
|
|
138
|
+
return digest32(new Uint8Array(internalBlake3(bytes)))
|
|
139
|
+
}
|
|
140
|
+
|
|
102
141
|
function sidecarPath<Rels extends SchemaRelations>(core: Core<Rels>): string {
|
|
103
|
-
return path.join(core.dir, "chain
|
|
142
|
+
return path.join(core.dir, "chain")
|
|
104
143
|
}
|
|
105
144
|
|
|
106
145
|
let storeSequence = 0
|
|
@@ -118,6 +157,16 @@ function storePath<Rels extends SchemaRelations>(core: Core<Rels>): string {
|
|
|
118
157
|
return path.join(core.dir, core.storeName)
|
|
119
158
|
}
|
|
120
159
|
|
|
160
|
+
/** A wire braid id is a braid of this theory or a typed refuse. */
|
|
161
|
+
function braidOf(theory: Schema<SchemaRelations> | Schema<never>, raw: Braid): Braid {
|
|
162
|
+
const id = Number.parseInt(raw.slice(1), 16)
|
|
163
|
+
const parsed = parse(theory, id)
|
|
164
|
+
if (parsed === undefined) {
|
|
165
|
+
refuse({ kind: "UnknownBraid", braid: Number.isFinite(id) ? id : 0 }, `unknown braid ${raw}`)
|
|
166
|
+
}
|
|
167
|
+
return parsed
|
|
168
|
+
}
|
|
169
|
+
|
|
121
170
|
function zeroChain(descriptor: Descriptor): Map<Braid, ChainEntry> {
|
|
122
171
|
const chain = new Map<Braid, ChainEntry>()
|
|
123
172
|
for (const id of descriptor.braidMembers.keys()) {
|
|
@@ -126,10 +175,15 @@ function zeroChain(descriptor: Descriptor): Map<Braid, ChainEntry> {
|
|
|
126
175
|
return chain
|
|
127
176
|
}
|
|
128
177
|
|
|
178
|
+
function entriesOf<Rels extends SchemaRelations>(core: Core<Rels>): Map<Braid, ChainEntry> {
|
|
179
|
+
return core.chain.entries as Map<Braid, ChainEntry>
|
|
180
|
+
}
|
|
181
|
+
|
|
129
182
|
function chainEntry<Rels extends SchemaRelations>(core: Core<Rels>, braid: Braid): ChainEntry {
|
|
130
|
-
const
|
|
183
|
+
const id = braidOf(core.theory, braid)
|
|
184
|
+
const entry = entriesOf(core).get(id)
|
|
131
185
|
if (entry === undefined) {
|
|
132
|
-
throw errors.new(`braid ${
|
|
186
|
+
throw errors.new(`braid ${id} is not derived from this theory`)
|
|
133
187
|
}
|
|
134
188
|
return entry
|
|
135
189
|
}
|
|
@@ -140,29 +194,134 @@ function generationOf<Rels extends SchemaRelations>(core: Core<Rels>): bigint {
|
|
|
140
194
|
})
|
|
141
195
|
}
|
|
142
196
|
|
|
143
|
-
function
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
sum += entry.g
|
|
197
|
+
function digestOfCatalog(raw: unknown): Digest32 {
|
|
198
|
+
if (raw instanceof Uint8Array) {
|
|
199
|
+
return digest32(raw)
|
|
147
200
|
}
|
|
148
|
-
|
|
201
|
+
throw errors.new(`catalogDigest is not a digest: ${typeof raw}`)
|
|
149
202
|
}
|
|
150
203
|
|
|
151
|
-
|
|
152
|
-
|
|
204
|
+
/** The opened store's catalog digest. The SDK may expose it on the
|
|
205
|
+
* handle or on the read instance; either spelling is the computed claim. */
|
|
206
|
+
function catalogDigestOf<Rels extends SchemaRelations>(core: Core<Rels>): Digest32 {
|
|
207
|
+
const handle = core.db as unknown as { catalogDigest?: () => unknown }
|
|
208
|
+
if (typeof handle.catalogDigest === "function") {
|
|
209
|
+
return digestOfCatalog(handle.catalogDigest())
|
|
210
|
+
}
|
|
211
|
+
return core.db.read(function readCatalog(instance) {
|
|
212
|
+
const carrier = instance as { catalogDigest?: unknown }
|
|
213
|
+
if (typeof carrier.catalogDigest === "function") {
|
|
214
|
+
return digestOfCatalog(carrier.catalogDigest())
|
|
215
|
+
}
|
|
216
|
+
throw errors.new("the opened store does not expose catalogDigest")
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function chainOf<Rels extends SchemaRelations>(core: Core<Rels>): Chain {
|
|
221
|
+
return core.chain
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Pending-arm payload: recorded ops ride on the batch, not a third
|
|
225
|
+
* Option beside the vector. Wire Pending is {braid,gen,bytes}; ops is
|
|
226
|
+
* the in-memory field of that arm (§30). */
|
|
227
|
+
type HeldBatch = Pending & { readonly ops: readonly Op[] | null }
|
|
228
|
+
|
|
229
|
+
function holdPending<Rels extends SchemaRelations>(core: Core<Rels>, batch: Pending, ops: readonly Op[] | null): void {
|
|
230
|
+
const held: HeldBatch = { braid: batch.braid, gen: batch.gen, bytes: batch.bytes, ops }
|
|
231
|
+
core.chain = { tag: "pending", entries: entriesOf(core), batch: held }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function pendingOf<Rels extends SchemaRelations>(core: Core<Rels>): HeldBatch | null {
|
|
235
|
+
if (core.chain.tag !== "pending") {
|
|
236
|
+
return null
|
|
237
|
+
}
|
|
238
|
+
return core.chain.batch as HeldBatch
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function settleHeld<Rels extends SchemaRelations>(core: Core<Rels>): void {
|
|
242
|
+
core.chain = { tag: "settled", entries: entriesOf(core) }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The local vector equals the published checkpoint vector and the
|
|
246
|
+
* chain is Settled — the seed/open floor the catalog claim is audited
|
|
247
|
+
* against. */
|
|
248
|
+
function atCheckpointFloor<Rels extends SchemaRelations>(core: Core<Rels>): boolean {
|
|
249
|
+
if (core.checkpoint === null || core.chain.tag === "pending") {
|
|
250
|
+
return false
|
|
251
|
+
}
|
|
252
|
+
for (const [braid, head] of core.checkpoint.braids) {
|
|
253
|
+
if (chainEntry(core, braid).g !== head.g) {
|
|
254
|
+
return false
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return true
|
|
153
258
|
}
|
|
154
259
|
|
|
155
260
|
async function persistSidecar<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
|
|
156
|
-
await writeSidecar(sidecarPath(core),
|
|
261
|
+
await writeSidecar(sidecarPath(core), chainOf(core))
|
|
157
262
|
}
|
|
158
263
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
264
|
+
/** Writes the checkpoint store file and fsyncs the file and its parent
|
|
265
|
+
* directory — the mdb is durable before the sidecar. */
|
|
266
|
+
async function writeCheckpointMdb(dir: string, bytes: Uint8Array): Promise<void> {
|
|
267
|
+
const data = path.join(dir, "data.mdb")
|
|
268
|
+
const handle = await fs.open(data, "w")
|
|
269
|
+
const written = await errors.try(
|
|
270
|
+
(async function writeAll() {
|
|
271
|
+
await handle.writeFile(bytes)
|
|
272
|
+
await handle.sync()
|
|
273
|
+
})()
|
|
274
|
+
)
|
|
275
|
+
await handle.close()
|
|
276
|
+
if (written.error) {
|
|
277
|
+
throw errors.wrap(written.error, `write checkpoint store ${data}`)
|
|
278
|
+
}
|
|
279
|
+
const parent = await fs.open(dir, "r")
|
|
280
|
+
const synced = await errors.try(parent.sync())
|
|
281
|
+
await parent.close()
|
|
282
|
+
if (synced.error) {
|
|
283
|
+
throw errors.wrap(synced.error, `fsync checkpoint store directory ${dir}`)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function settle<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
|
|
288
|
+
settleHeld(core)
|
|
163
289
|
await persistSidecar(core)
|
|
164
290
|
}
|
|
165
291
|
|
|
292
|
+
async function clearPending<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
|
|
293
|
+
await settle(core)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function disposed<Rels extends SchemaRelations>(core: Core<Rels>): void {
|
|
297
|
+
if (core.closed) {
|
|
298
|
+
throw errors.new("replica is disposed")
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function adoptChain<Rels extends SchemaRelations>(core: Core<Rels>, chain: Chain): void {
|
|
303
|
+
const entries = new Map<Braid, ChainEntry>()
|
|
304
|
+
for (const [raw, entry] of chain.entries) {
|
|
305
|
+
entries.set(braidOf(core.theory, raw), entry)
|
|
306
|
+
}
|
|
307
|
+
for (const id of core.descriptor.braidMembers.keys()) {
|
|
308
|
+
if (!entries.has(id)) {
|
|
309
|
+
entries.set(id, { g: generation(0n), prev: ZERO_HASH, ts: 0n })
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (chain.tag === "pending") {
|
|
313
|
+
const held: HeldBatch = {
|
|
314
|
+
braid: braidOf(core.theory, chain.batch.braid),
|
|
315
|
+
gen: chain.batch.gen,
|
|
316
|
+
bytes: chain.batch.bytes,
|
|
317
|
+
ops: null
|
|
318
|
+
}
|
|
319
|
+
core.chain = { tag: "pending", entries, batch: held }
|
|
320
|
+
} else {
|
|
321
|
+
core.chain = { tag: "settled", entries }
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
166
325
|
/** Positional decoded row → the SDK's named fact, handles lifted for closed refs. */
|
|
167
326
|
function factOf<Rels extends SchemaRelations>(
|
|
168
327
|
core: Core<Rels>,
|
|
@@ -214,15 +373,12 @@ function applyOps<Rels extends SchemaRelations>(core: Core<Rels>, ops: readonly
|
|
|
214
373
|
})
|
|
215
374
|
}
|
|
216
375
|
|
|
217
|
-
type ApplyPhase = "open" | "steady"
|
|
218
|
-
|
|
219
|
-
type SlotApply = { readonly tag: "applied"; readonly generation: bigint } | { readonly tag: "discard" }
|
|
220
|
-
|
|
221
376
|
/**
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
* whole,
|
|
377
|
+
* Decode, verify the chain, `db.write` the batch, then refuse a
|
|
378
|
+
* first-applied no-op against the identity. The sidecar advances
|
|
379
|
+
* only after that check; a rejected replay is phase-scoped:
|
|
380
|
+
* discard before the store has proven itself whole,
|
|
381
|
+
* `ErrReplayDiverged` after.
|
|
226
382
|
*/
|
|
227
383
|
async function applySlot<Rels extends SchemaRelations>(
|
|
228
384
|
core: Core<Rels>,
|
|
@@ -241,83 +397,182 @@ async function applySlot<Rels extends SchemaRelations>(
|
|
|
241
397
|
}
|
|
242
398
|
throw errors.wrap(ErrReplayDiverged, `braid ${braid} slot ${slot} writer ${decoded.header.writer}`)
|
|
243
399
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const expected = chainSum(core) + pendingTerm(core)
|
|
247
|
-
if (outcome.value.generation < expected) {
|
|
400
|
+
const identity = chainSum(core.chain) - entry.g + slot
|
|
401
|
+
if (outcome.value.generation < identity) {
|
|
248
402
|
refuse(
|
|
249
403
|
{ kind: "NoOpSlot", braid, slot, writer: decoded.header.writer },
|
|
250
404
|
`braid ${braid} slot ${slot}: a first-applied slot changed nothing — publish-law violation by writer ${decoded.header.writer}`
|
|
251
405
|
)
|
|
252
406
|
}
|
|
407
|
+
entriesOf(core).set(braid, { g: slot, prev: blake3Digest(bytes), ts: decoded.header.timestamp })
|
|
408
|
+
await persistSidecar(core)
|
|
253
409
|
return { tag: "applied", generation: outcome.value.generation }
|
|
254
410
|
}
|
|
255
411
|
|
|
256
412
|
/**
|
|
257
|
-
* The
|
|
258
|
-
*
|
|
413
|
+
* The published checkpoint vector is the one floor: a slot at or
|
|
414
|
+
* below it is retired, and a create must not touch the store.
|
|
259
415
|
*/
|
|
260
|
-
function
|
|
416
|
+
function belowFloor<Rels extends SchemaRelations>(core: Core<Rels>, braid: Braid, slot: Generation): boolean {
|
|
417
|
+
const id = braidOf(core.theory, braid)
|
|
261
418
|
if (core.checkpoint === null) {
|
|
262
419
|
return false
|
|
263
420
|
}
|
|
264
|
-
const floor = core.checkpoint.braids.get(
|
|
421
|
+
const floor = core.checkpoint.braids.get(id)
|
|
265
422
|
if (floor === undefined) {
|
|
266
423
|
return false
|
|
267
424
|
}
|
|
268
|
-
return
|
|
425
|
+
return slot <= floor.g
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* The gc floor rule: below the current checkpoint's vector a 404 is a
|
|
430
|
+
* collected hole, at or above it the honest tip.
|
|
431
|
+
*/
|
|
432
|
+
function holeAt<Rels extends SchemaRelations>(core: Core<Rels>, braid: Braid, next: Generation): boolean {
|
|
433
|
+
return belowFloor(core, braid, next)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Classification of a pending batch against occupant, store
|
|
438
|
+
* generation, and the floor. BelowFloor is published (`Clear`) — the
|
|
439
|
+
* slot is already in the trusted history, not a candidate to re-judge.
|
|
440
|
+
*/
|
|
441
|
+
type PendingFold =
|
|
442
|
+
| { readonly tag: "ours" }
|
|
443
|
+
| { readonly tag: "theirs-unapplied" }
|
|
444
|
+
| { readonly tag: "theirs-applied" }
|
|
445
|
+
| { readonly tag: "absent-unapplied" }
|
|
446
|
+
| { readonly tag: "absent-applied" }
|
|
447
|
+
| { readonly tag: "below-floor" }
|
|
448
|
+
| { readonly tag: "phantom" }
|
|
449
|
+
|
|
450
|
+
function foldPending(
|
|
451
|
+
sum: bigint,
|
|
452
|
+
generation: bigint,
|
|
453
|
+
occupant: Uint8Array | null,
|
|
454
|
+
pendingBytes: Uint8Array,
|
|
455
|
+
covered: boolean
|
|
456
|
+
): PendingFold {
|
|
457
|
+
if (covered) {
|
|
458
|
+
return { tag: "below-floor" }
|
|
459
|
+
}
|
|
460
|
+
if (occupant !== null && bytesEqual(occupant, pendingBytes)) {
|
|
461
|
+
return { tag: "ours" }
|
|
462
|
+
}
|
|
463
|
+
if (occupant !== null) {
|
|
464
|
+
return generation === sum ? { tag: "theirs-unapplied" } : { tag: "theirs-applied" }
|
|
465
|
+
}
|
|
466
|
+
if (generation === sum) {
|
|
467
|
+
return { tag: "absent-unapplied" }
|
|
468
|
+
}
|
|
469
|
+
if (generation === saturatingAddU64(sum, 1n)) {
|
|
470
|
+
return { tag: "absent-applied" }
|
|
471
|
+
}
|
|
472
|
+
return { tag: "phantom" }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function isCorruption(error: unknown): boolean {
|
|
476
|
+
if (!(error instanceof Error)) {
|
|
477
|
+
return false
|
|
478
|
+
}
|
|
479
|
+
return errors.is(error, ErrReplayDiverged) || errors.is(error, ErrRefused)
|
|
269
480
|
}
|
|
270
481
|
|
|
271
|
-
|
|
482
|
+
/**
|
|
483
|
+
* One braid, one slot. Catch-up, refresh, waitFor, and open all call
|
|
484
|
+
* this; a hot braid cannot drain past one step per round.
|
|
485
|
+
*/
|
|
486
|
+
async function stepBraid<Rels extends SchemaRelations>(
|
|
272
487
|
core: Core<Rels>,
|
|
273
488
|
braid: Braid,
|
|
274
489
|
phase: ApplyPhase
|
|
275
|
-
): Promise<
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
if (core
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
}
|
|
294
|
-
core.pending = null
|
|
295
|
-
core.pendingOps = null
|
|
296
|
-
core.pendingApplied = false
|
|
297
|
-
await persistSidecar(core)
|
|
490
|
+
): Promise<Step> {
|
|
491
|
+
disposed(core)
|
|
492
|
+
const id = braidOf(core.theory, braid)
|
|
493
|
+
const wedged = core.wedged.get(id)
|
|
494
|
+
if (wedged !== undefined) {
|
|
495
|
+
return { tag: "wedged", braid: id, cause: wedged }
|
|
496
|
+
}
|
|
497
|
+
const next = generation(chainEntry(core, id).g + 1n)
|
|
498
|
+
const fetched = await core.store.get(logKey(core.prefix, id, next))
|
|
499
|
+
if (fetched === null) {
|
|
500
|
+
if (holeAt(core, id, next)) {
|
|
501
|
+
return { tag: "reseed", cause: "gap-below-floor" }
|
|
502
|
+
}
|
|
503
|
+
return { tag: "tip" }
|
|
504
|
+
}
|
|
505
|
+
const chain = core.chain
|
|
506
|
+
if (chain.tag === "pending" && id === chain.batch.braid && next === chain.batch.gen) {
|
|
507
|
+
if (!bytesEqual(fetched.bytes, chain.batch.bytes)) {
|
|
508
|
+
return { tag: "reseed", cause: "lost-pending-fork" }
|
|
298
509
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
510
|
+
await settle(core)
|
|
511
|
+
}
|
|
512
|
+
const applied = await errors.try(applySlot(core, id, next, fetched.bytes, phase))
|
|
513
|
+
if (applied.error) {
|
|
514
|
+
if (phase === "steady" && isCorruption(applied.error)) {
|
|
515
|
+
core.wedged.set(id, applied.error.message)
|
|
516
|
+
return { tag: "wedged", braid: id, cause: applied.error.message }
|
|
302
517
|
}
|
|
518
|
+
throw applied.error
|
|
519
|
+
}
|
|
520
|
+
if (applied.data.tag === "discard") {
|
|
521
|
+
return { tag: "reseed", cause: "rejected-in-open" }
|
|
303
522
|
}
|
|
523
|
+
return { tag: "applied" }
|
|
304
524
|
}
|
|
305
525
|
|
|
306
|
-
|
|
526
|
+
/**
|
|
527
|
+
* One pass: heartbeat, one slot per braid, wholeness, disposed. The
|
|
528
|
+
* same function refresh, waitFor, catch-up, and open execute.
|
|
529
|
+
*/
|
|
530
|
+
async function runPass<Rels extends SchemaRelations>(
|
|
307
531
|
core: Core<Rels>,
|
|
532
|
+
braids: readonly Braid[],
|
|
308
533
|
phase: ApplyPhase
|
|
309
|
-
): Promise<
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
534
|
+
): Promise<RefreshOutcome> {
|
|
535
|
+
disposed(core)
|
|
536
|
+
core.passes += 1
|
|
537
|
+
if (core.passes % HEARTBEAT_PASSES === 0) {
|
|
538
|
+
await refreshManifest(core)
|
|
539
|
+
}
|
|
540
|
+
const remaining = new Set(braids)
|
|
541
|
+
while (remaining.size > 0) {
|
|
542
|
+
disposed(core)
|
|
543
|
+
let progressed = false
|
|
544
|
+
for (const braid of braids) {
|
|
545
|
+
if (!remaining.has(braid)) {
|
|
546
|
+
continue
|
|
547
|
+
}
|
|
548
|
+
const step = await stepBraid(core, braid, phase)
|
|
549
|
+
if (step.tag === "applied") {
|
|
550
|
+
progressed = true
|
|
551
|
+
} else if (step.tag === "tip" || step.tag === "wedged") {
|
|
552
|
+
remaining.delete(braid)
|
|
553
|
+
} else {
|
|
554
|
+
return { tag: "reseed", cause: step.cause }
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
if (!progressed) {
|
|
558
|
+
break
|
|
314
559
|
}
|
|
315
560
|
}
|
|
316
|
-
|
|
561
|
+
if (!wholenessHolds(core)) {
|
|
562
|
+
return { tag: "reseed", cause: "wholeness" }
|
|
563
|
+
}
|
|
564
|
+
for (const [braid, cause] of core.wedged) {
|
|
565
|
+
return { tag: "wedged", braid, cause }
|
|
566
|
+
}
|
|
567
|
+
return { tag: "advanced", vector: vectorOf(core) }
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function allBraids<Rels extends SchemaRelations>(core: Core<Rels>): Braid[] {
|
|
571
|
+
return [...core.descriptor.braidMembers.keys()]
|
|
317
572
|
}
|
|
318
573
|
|
|
319
574
|
function wholenessHolds<Rels extends SchemaRelations>(core: Core<Rels>): boolean {
|
|
320
|
-
return generationOf(core) ===
|
|
575
|
+
return generationOf(core) === chainGeneration(core.chain)
|
|
321
576
|
}
|
|
322
577
|
|
|
323
578
|
async function adoptManifest<Rels extends SchemaRelations>(
|
|
@@ -326,37 +581,44 @@ async function adoptManifest<Rels extends SchemaRelations>(
|
|
|
326
581
|
etag: Etag
|
|
327
582
|
): Promise<void> {
|
|
328
583
|
const manifest = parseManifest(bytes)
|
|
329
|
-
if (manifest.fingerprint
|
|
584
|
+
if (!bytesEqual(manifest.fingerprint, digest32(core.descriptor.fingerprintBytes))) {
|
|
330
585
|
refuse(
|
|
331
|
-
{
|
|
586
|
+
{
|
|
587
|
+
kind: "FingerprintMismatch",
|
|
588
|
+
carried: hex32(manifest.fingerprint),
|
|
589
|
+
expected: core.descriptor.fingerprint
|
|
590
|
+
},
|
|
332
591
|
"the store's manifest names a different theory"
|
|
333
592
|
)
|
|
334
593
|
}
|
|
335
|
-
core.manifestEtag = etag
|
|
336
594
|
if (manifest.checkpoint === null) {
|
|
337
595
|
core.checkpoint = null
|
|
338
596
|
core.checkpointDigest = null
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
597
|
+
} else if (core.checkpointDigest === null || !bytesEqual(manifest.checkpoint, core.checkpointDigest)) {
|
|
598
|
+
const facts = await core.store.get(ckptDocKey(core.prefix, manifest.checkpoint))
|
|
599
|
+
if (facts === null) {
|
|
600
|
+
throw errors.new(`manifest points at absent checkpoint ${hex32(manifest.checkpoint)}`)
|
|
601
|
+
}
|
|
602
|
+
const checkpoint = parseCheckpoint(facts.bytes)
|
|
603
|
+
for (const id of checkpoint.braids.keys()) {
|
|
604
|
+
braidOf(core.theory, id)
|
|
605
|
+
}
|
|
606
|
+
const carried = [...checkpoint.braids.keys()].sort()
|
|
607
|
+
const derived = [...core.descriptor.braidMembers.keys()].sort()
|
|
608
|
+
if (carried.join(",") !== derived.join(",")) {
|
|
609
|
+
refuse({ kind: "CheckpointBraids", carried, derived }, "checkpoint braid set drifted from the derived braids")
|
|
610
|
+
}
|
|
611
|
+
core.checkpoint = checkpoint
|
|
612
|
+
core.checkpointDigest = manifest.checkpoint
|
|
353
613
|
}
|
|
354
|
-
|
|
355
|
-
|
|
614
|
+
// The pointer is adopted only after the checkpoint it names is in
|
|
615
|
+
// hand. A failed fetch leaves the old etag and the old floor (40/67).
|
|
616
|
+
core.manifestEtag = etag
|
|
356
617
|
}
|
|
357
618
|
|
|
358
|
-
|
|
359
|
-
|
|
619
|
+
/** A replica never births a manifest. Absence is ManifestMissing. */
|
|
620
|
+
async function refreshManifest<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
|
|
621
|
+
if (core.manifestEtag !== null) {
|
|
360
622
|
const poll = await core.store.getIfChanged(manifestKey(core.prefix), core.manifestEtag)
|
|
361
623
|
if (poll.tag === "unchanged") {
|
|
362
624
|
return
|
|
@@ -365,94 +627,102 @@ async function refreshManifest<Rels extends SchemaRelations>(core: Core<Rels>, f
|
|
|
365
627
|
return
|
|
366
628
|
}
|
|
367
629
|
const fetched = await core.store.get(manifestKey(core.prefix))
|
|
368
|
-
if (fetched
|
|
369
|
-
|
|
370
|
-
return
|
|
630
|
+
if (fetched === null) {
|
|
631
|
+
refuseManifestMissing("the store has no manifest")
|
|
371
632
|
}
|
|
372
|
-
|
|
373
|
-
const created = await core.store.putCreate(manifestKey(core.prefix), birth)
|
|
374
|
-
if (created.tag === "created") {
|
|
375
|
-
core.manifestEtag = created.etag
|
|
376
|
-
core.checkpoint = null
|
|
377
|
-
core.checkpointDigest = null
|
|
378
|
-
return
|
|
379
|
-
}
|
|
380
|
-
const reread = await core.store.get(manifestKey(core.prefix))
|
|
381
|
-
if (reread === null) {
|
|
382
|
-
throw errors.new("manifest vanished between create refusal and re-read")
|
|
383
|
-
}
|
|
384
|
-
await adoptManifest(core, reread.bytes, reread.etag)
|
|
633
|
+
await adoptManifest(core, fetched.bytes, fetched.etag)
|
|
385
634
|
}
|
|
386
635
|
|
|
387
636
|
/** Bootstraps a fresh local store: from the current checkpoint when one
|
|
388
|
-
* exists, else `Db.create` at the zero vector.
|
|
637
|
+
* exists, else `Db.create` at the zero vector. Always holds Settled —
|
|
638
|
+
* a prior Pending arm cannot survive beside the new vector. */
|
|
389
639
|
async function initializeStore<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
|
|
390
640
|
core.storeName = freshStoreName()
|
|
391
641
|
const target = storePath(core)
|
|
392
642
|
await fs.rm(target, { recursive: true, force: true })
|
|
393
643
|
await fs.mkdir(core.dir, { recursive: true })
|
|
394
644
|
if (core.checkpoint !== null && core.checkpointDigest !== null) {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
"checkpoint bytes do not hash to their own name"
|
|
408
|
-
)
|
|
409
|
-
}
|
|
410
|
-
await fs.mkdir(target, { recursive: true })
|
|
411
|
-
await fs.writeFile(path.join(target, "data.mdb"), mdb.bytes)
|
|
412
|
-
core.db = await SdkDb.open(target, core.theory)
|
|
413
|
-
core.chain = new Map(
|
|
414
|
-
[...core.checkpoint.braids.entries()].map(function seed([braid, head]) {
|
|
645
|
+
const mdb = await core.store.get(checkpointMdbKey(core.prefix, core.checkpointDigest))
|
|
646
|
+
if (mdb === null) {
|
|
647
|
+
throw errors.new(`checkpoint ${hex32(core.checkpointDigest)} names an absent .mdb`)
|
|
648
|
+
}
|
|
649
|
+
await fs.mkdir(target, { recursive: true })
|
|
650
|
+
await writeCheckpointMdb(target, mdb.bytes)
|
|
651
|
+
core.db = await SdkDb.open(target, core.theory)
|
|
652
|
+
core.chain = {
|
|
653
|
+
tag: "settled",
|
|
654
|
+
entries: new Map(
|
|
655
|
+
[...core.checkpoint.braids.entries()].map(function seed([raw, head]) {
|
|
656
|
+
const braid = braidOf(core.theory, raw)
|
|
415
657
|
return [braid, { g: head.g, prev: head.hash, ts: head.ts }] as const
|
|
416
658
|
})
|
|
417
659
|
)
|
|
418
|
-
let sum = 0n
|
|
419
|
-
for (const head of core.checkpoint.braids.values()) {
|
|
420
|
-
sum += head.g
|
|
421
|
-
}
|
|
422
|
-
const generation = generationOf(core)
|
|
423
|
-
if (generation !== sum) {
|
|
424
|
-
throw errors.new(`checkpoint store opened at generation ${generation}, its vector sums to ${sum}`)
|
|
425
|
-
}
|
|
426
|
-
await persistSidecar(core)
|
|
427
|
-
return
|
|
428
660
|
}
|
|
661
|
+
const opened = generationOf(core)
|
|
662
|
+
if (opened !== chainGeneration(core.chain)) {
|
|
663
|
+
throw errors.new(
|
|
664
|
+
`checkpoint store opened at generation ${opened}, chain generation is ${chainGeneration(core.chain)}`
|
|
665
|
+
)
|
|
666
|
+
}
|
|
667
|
+
auditCatalog(core.checkpoint, catalogDigestOf(core))
|
|
668
|
+
core.provenance = { tag: "checkpoint-seeded", catalog: core.checkpointDigest }
|
|
669
|
+
await persistSidecar(core)
|
|
670
|
+
return
|
|
429
671
|
}
|
|
430
672
|
const created = await SdkDb.create(target, core.theory)
|
|
431
673
|
if (created.tag === "rejected") {
|
|
432
674
|
throw errors.new("the theory's ground axioms were rejected at bootstrap")
|
|
433
675
|
}
|
|
434
676
|
core.db = created.value
|
|
435
|
-
core.chain = zeroChain(core.descriptor)
|
|
677
|
+
core.chain = { tag: "settled", entries: zeroChain(core.descriptor) }
|
|
678
|
+
core.provenance = { tag: "bootstrapped" }
|
|
436
679
|
await persistSidecar(core)
|
|
437
680
|
}
|
|
438
681
|
|
|
682
|
+
/** The scream tracks the set of repair signatures; a recurrence alarms. */
|
|
683
|
+
function screamOf(context: string): { attempt(signature: string): void } {
|
|
684
|
+
const seen = new Set<string>()
|
|
685
|
+
let attempts = 0
|
|
686
|
+
return {
|
|
687
|
+
attempt(signature: string): void {
|
|
688
|
+
attempts += 1
|
|
689
|
+
if (seen.has(signature)) {
|
|
690
|
+
console.error(`bumbledb-log alarm: ${context} repair signature recurs: ${signature}`)
|
|
691
|
+
} else {
|
|
692
|
+
seen.add(signature)
|
|
693
|
+
}
|
|
694
|
+
if (attempts % 8 === 0) {
|
|
695
|
+
console.error(`bumbledb-log warning: ${context} repair attempt ${attempts}: ${signature}`)
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
439
701
|
/** The disposable law: the directory is cache, never truth. The local
|
|
440
702
|
* LMDB path rotates because the engine has no close verb — the old
|
|
441
|
-
* environment is left for GC while the fresh pull takes a new path.
|
|
703
|
+
* environment is left for GC while the fresh pull takes a new path.
|
|
704
|
+
* initializeStore writes Settled, so the open-identity is
|
|
705
|
+
* chainGeneration(core.chain) — there is no addend a reader can skip. */
|
|
442
706
|
async function discardAndReopen<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
|
|
443
|
-
|
|
707
|
+
const scream = screamOf("replica discard-and-re-pull")
|
|
708
|
+
for (;;) {
|
|
444
709
|
const old = storePath(core)
|
|
445
710
|
await fs.rm(old, { recursive: true, force: true })
|
|
711
|
+
core.wedged.clear()
|
|
446
712
|
await initializeStore(core)
|
|
447
|
-
const outcome = await
|
|
448
|
-
if (outcome === "
|
|
713
|
+
const outcome = await runPass(core, allBraids(core), "open")
|
|
714
|
+
if (outcome.tag === "reseed") {
|
|
715
|
+
scream.attempt(outcome.cause)
|
|
449
716
|
continue
|
|
450
717
|
}
|
|
451
|
-
if (
|
|
718
|
+
if (outcome.tag === "refused") {
|
|
719
|
+
throw errors.wrap(ErrRefused, outcome.detail)
|
|
720
|
+
}
|
|
721
|
+
if (generationOf(core) === chainGeneration(core.chain)) {
|
|
452
722
|
return
|
|
453
723
|
}
|
|
724
|
+
scream.attempt("wholeness")
|
|
454
725
|
}
|
|
455
|
-
throw errors.new("the store failed to reach wholeness after repeated re-pulls")
|
|
456
726
|
}
|
|
457
727
|
|
|
458
728
|
async function newestStoreDir(dir: string): Promise<string | null> {
|
|
@@ -475,6 +745,26 @@ async function newestStoreDir(dir: string): Promise<string | null> {
|
|
|
475
745
|
return newest
|
|
476
746
|
}
|
|
477
747
|
|
|
748
|
+
/**
|
|
749
|
+
* Open reclaims the reserved `~tmp`/`~lease` namespace. The known
|
|
750
|
+
* document `{dir}/~lease/ckpt-scratch` names a crash-stranded
|
|
751
|
+
* candidate; when that digest is not the live head the ckpt pair is
|
|
752
|
+
* deleted with the lease.
|
|
753
|
+
*/
|
|
754
|
+
async function sweepReservedKeys<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
|
|
755
|
+
const lease = path.join(core.dir, LEASE_NAMESPACE, CKPT_SCRATCH_LEASE)
|
|
756
|
+
const read = await errors.try(fs.readFile(lease))
|
|
757
|
+
if (read.error === undefined) {
|
|
758
|
+
const digest = parseCkptScratch(read.data)
|
|
759
|
+
if (digest !== null && (core.checkpointDigest === null || !bytesEqual(digest, core.checkpointDigest))) {
|
|
760
|
+
await core.store.delete(ckptDocKey(core.prefix, digest))
|
|
761
|
+
await core.store.delete(checkpointMdbKey(core.prefix, digest))
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
await fs.rm(path.join(core.dir, TEMP_NAMESPACE), { recursive: true, force: true })
|
|
765
|
+
await fs.rm(path.join(core.dir, LEASE_NAMESPACE), { recursive: true, force: true })
|
|
766
|
+
}
|
|
767
|
+
|
|
478
768
|
/** The disposable law says cache directories do not hoard corpses: every
|
|
479
769
|
* rotated `store-*` LMDB dir except the adopted one is dead — left by a
|
|
480
770
|
* crashed process or a prior rotation — and is swept at open. */
|
|
@@ -492,35 +782,48 @@ async function sweepRotations<Rels extends SchemaRelations>(core: Core<Rels>): P
|
|
|
492
782
|
}
|
|
493
783
|
|
|
494
784
|
/**
|
|
495
|
-
* Pending recovery
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
* born-no-op publishes nothing; anything else is a real unpublished
|
|
499
|
-
* commit that catch-up and the tip attempt will place.
|
|
785
|
+
* Pending recovery: apply the resurrected batch. A batch rejected here
|
|
786
|
+
* was never acked; a born-no-op settles; anything else stays Pending
|
|
787
|
+
* so generation(chain) accounts for the unpublished apply.
|
|
500
788
|
*/
|
|
501
789
|
async function resolvePendingAtOpen<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
|
|
502
|
-
|
|
790
|
+
const chain = core.chain
|
|
791
|
+
if (chain.tag === "settled") {
|
|
792
|
+
return
|
|
793
|
+
}
|
|
794
|
+
// A slot the floor already covers is published (`Clear`), not re-judged (46).
|
|
795
|
+
if (
|
|
796
|
+
foldPending(
|
|
797
|
+
chainSum(core.chain),
|
|
798
|
+
generationOf(core),
|
|
799
|
+
null,
|
|
800
|
+
chain.batch.bytes,
|
|
801
|
+
belowFloor(core, chain.batch.braid, chain.batch.gen)
|
|
802
|
+
).tag === "below-floor"
|
|
803
|
+
) {
|
|
804
|
+
await settle(core)
|
|
503
805
|
return
|
|
504
806
|
}
|
|
505
807
|
const decoded = errors.trySync(function decodePending() {
|
|
506
|
-
return decodeBatch(core.descriptor,
|
|
808
|
+
return decodeBatch(core.descriptor, chain.batch.bytes)
|
|
507
809
|
})
|
|
508
810
|
if (decoded.error) {
|
|
509
|
-
await
|
|
811
|
+
await settle(core)
|
|
510
812
|
return
|
|
511
813
|
}
|
|
512
|
-
const before = generationOf(core)
|
|
513
814
|
const outcome = applyOps(core, decoded.data.ops)
|
|
514
815
|
if (outcome.tag === "rejected") {
|
|
515
|
-
await
|
|
816
|
+
await settle(core)
|
|
516
817
|
return
|
|
517
818
|
}
|
|
518
|
-
|
|
519
|
-
|
|
819
|
+
// Store matches chainGeneration of the held Pending (sum+1) when the
|
|
820
|
+
// apply is in the db — just now, or already. A born-no-op leaves the
|
|
821
|
+
// store at the Settled generation, so the identity fails and we settle.
|
|
822
|
+
if (generationOf(core) !== chainGeneration(core.chain)) {
|
|
823
|
+
await settle(core)
|
|
520
824
|
return
|
|
521
825
|
}
|
|
522
|
-
core.
|
|
523
|
-
core.pendingApplied = true
|
|
826
|
+
holdPending(core, chain.batch, decoded.data.ops)
|
|
524
827
|
}
|
|
525
828
|
|
|
526
829
|
/**
|
|
@@ -528,18 +831,26 @@ async function resolvePendingAtOpen<Rels extends SchemaRelations>(core: Core<Rel
|
|
|
528
831
|
* unopenable (or discarded), so the sidecar's pending is resolved
|
|
529
832
|
* against a freshly caught-up store — a byte-equal published slot
|
|
530
833
|
* absorbs it (L10's idempotent replay), and everything else takes the
|
|
531
|
-
* one loss path's re-judgment at the tip.
|
|
834
|
+
* one loss path's re-judgment at the tip. A slot the floor already
|
|
835
|
+
* covers is published (`Clear`), not re-judged (46).
|
|
532
836
|
*/
|
|
533
837
|
async function resolveColdPending<Rels extends SchemaRelations>(core: Core<Rels>, pending: Pending): Promise<void> {
|
|
838
|
+
const braid = braidOf(core.theory, pending.braid)
|
|
839
|
+
if (
|
|
840
|
+
foldPending(chainSum(core.chain), generationOf(core), null, pending.bytes, belowFloor(core, braid, pending.gen))
|
|
841
|
+
.tag === "below-floor"
|
|
842
|
+
) {
|
|
843
|
+
await settle(core)
|
|
844
|
+
return
|
|
845
|
+
}
|
|
534
846
|
const decoded = errors.trySync(function decodePending() {
|
|
535
847
|
return decodeBatch(core.descriptor, pending.bytes)
|
|
536
848
|
})
|
|
537
849
|
if (decoded.error) {
|
|
538
850
|
return
|
|
539
851
|
}
|
|
540
|
-
const braid = decoded.data.header.braid
|
|
541
852
|
const slot = decoded.data.header.braidGen
|
|
542
|
-
if (core.
|
|
853
|
+
if (entriesOf(core).has(braid) && chainEntry(core, braid).g >= slot) {
|
|
543
854
|
const published = await core.store.get(logKey(core.prefix, braid, slot))
|
|
544
855
|
if (published !== null && bytesEqual(published.bytes, pending.bytes)) {
|
|
545
856
|
return
|
|
@@ -548,8 +859,7 @@ async function resolveColdPending<Rels extends SchemaRelations>(core: Core<Rels>
|
|
|
548
859
|
const before = generationOf(core)
|
|
549
860
|
const outcome = applyOps(core, decoded.data.ops)
|
|
550
861
|
if (outcome.tag === "accepted" && outcome.value.generation > before) {
|
|
551
|
-
core
|
|
552
|
-
core.pendingApplied = true
|
|
862
|
+
holdPending(core, { ...pending, braid }, decoded.data.ops)
|
|
553
863
|
await readdressPending(core, decoded.data.ops, decoded.data.header.writer)
|
|
554
864
|
}
|
|
555
865
|
}
|
|
@@ -563,42 +873,56 @@ async function openCore<Rels extends SchemaRelations>(options: OpenReplicaOption
|
|
|
563
873
|
theory: options.theory,
|
|
564
874
|
descriptor,
|
|
565
875
|
db: undefined as unknown as Db<Rels>,
|
|
566
|
-
chain: zeroChain(descriptor),
|
|
567
|
-
pending: null,
|
|
568
|
-
pendingOps: null,
|
|
569
|
-
pendingApplied: false,
|
|
876
|
+
chain: { tag: "settled", entries: zeroChain(descriptor) },
|
|
570
877
|
manifestEtag: null,
|
|
571
878
|
checkpoint: null,
|
|
572
879
|
checkpointDigest: null,
|
|
573
880
|
passes: 0,
|
|
574
881
|
closed: false,
|
|
575
882
|
storeName: freshStoreName(),
|
|
576
|
-
gate: Promise.resolve()
|
|
883
|
+
gate: Promise.resolve(),
|
|
884
|
+
wedged: new Map(),
|
|
885
|
+
provenance: { tag: "bootstrapped" }
|
|
577
886
|
}
|
|
578
887
|
await fs.mkdir(core.dir, { recursive: true })
|
|
579
|
-
await
|
|
888
|
+
const fetched = await core.store.get(manifestKey(core.prefix))
|
|
889
|
+
if (fetched === null) {
|
|
890
|
+
refuseManifestMissing("the store has no manifest")
|
|
891
|
+
}
|
|
892
|
+
await adoptManifest(core, fetched.bytes, fetched.etag)
|
|
580
893
|
|
|
581
894
|
const existing = await newestStoreDir(core.dir)
|
|
582
895
|
const sidecar = await readSidecar(sidecarPath(core))
|
|
583
896
|
let opened = false
|
|
584
897
|
let coldPending: Pending | null = null
|
|
585
|
-
if (
|
|
898
|
+
if (sidecar.tag === "fault") {
|
|
899
|
+
throw wrapStore(sidecar.io, `read sidecar ${sidecarPath(core)}`)
|
|
900
|
+
}
|
|
901
|
+
if (sidecar.tag === "read") {
|
|
902
|
+
for (const id of sidecar.chain.entries.keys()) {
|
|
903
|
+
braidOf(core.theory, id)
|
|
904
|
+
}
|
|
905
|
+
if (sidecar.chain.tag === "pending") {
|
|
906
|
+
braidOf(core.theory, sidecar.chain.batch.braid)
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
if (sidecar.tag === "corrupt") {
|
|
910
|
+
coldPending = null
|
|
911
|
+
} else if (existing !== null && sidecar.tag === "read") {
|
|
586
912
|
core.storeName = existing
|
|
587
913
|
const openedDb = await errors.try(SdkDb.open(storePath(core), options.theory))
|
|
588
|
-
if (openedDb.error === undefined
|
|
914
|
+
if (openedDb.error === undefined) {
|
|
589
915
|
core.db = openedDb.data
|
|
590
|
-
core
|
|
591
|
-
|
|
592
|
-
if (!core.chain.has(braid)) {
|
|
593
|
-
core.chain.set(braid, { g: generation(0n), prev: ZERO_HASH, ts: 0n })
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
core.pending = sidecar.pending
|
|
916
|
+
adoptChain(core, sidecar.chain)
|
|
917
|
+
core.provenance = { tag: "sidecar-resumed", floor: vectorOf(core) }
|
|
597
918
|
opened = true
|
|
598
919
|
}
|
|
599
920
|
}
|
|
600
921
|
if (!opened) {
|
|
601
|
-
|
|
922
|
+
if (sidecar.tag === "read" && sidecar.chain.tag === "pending") {
|
|
923
|
+
coldPending = sidecar.chain.batch
|
|
924
|
+
braidOf(core.theory, coldPending.braid)
|
|
925
|
+
}
|
|
602
926
|
await initializeStore(core)
|
|
603
927
|
}
|
|
604
928
|
|
|
@@ -606,29 +930,31 @@ async function openCore<Rels extends SchemaRelations>(options: OpenReplicaOption
|
|
|
606
930
|
await resolvePendingAtOpen(core)
|
|
607
931
|
}
|
|
608
932
|
|
|
609
|
-
const outcome = await
|
|
610
|
-
if (outcome === "
|
|
611
|
-
coldPending = core.pending
|
|
612
|
-
await clearPending(core)
|
|
933
|
+
const outcome = await runPass(core, allBraids(core), "open")
|
|
934
|
+
if (outcome.tag === "reseed" || outcome.tag === "refused" || !wholenessHolds(core)) {
|
|
935
|
+
coldPending = core.chain.tag === "pending" ? core.chain.batch : coldPending
|
|
613
936
|
await discardAndReopen(core)
|
|
614
937
|
}
|
|
615
938
|
|
|
616
939
|
if (coldPending !== null) {
|
|
617
940
|
await resolveColdPending(core, coldPending)
|
|
618
941
|
}
|
|
942
|
+
if (atCheckpointFloor(core)) {
|
|
943
|
+
auditCatalog(core.checkpoint, catalogDigestOf(core))
|
|
944
|
+
}
|
|
619
945
|
await sweepRotations(core)
|
|
946
|
+
await sweepReservedKeys(core)
|
|
620
947
|
return core
|
|
621
948
|
}
|
|
622
949
|
|
|
623
950
|
/**
|
|
624
|
-
* The steady-state discard route: a
|
|
625
|
-
*
|
|
626
|
-
*
|
|
627
|
-
*
|
|
951
|
+
* The steady-state discard route: a contested pending slot surrenders
|
|
952
|
+
* the directory. The pending batch rides in memory — the sidecar is
|
|
953
|
+
* not settled ahead of the re-judgment — and takes the one loss path
|
|
954
|
+
* at the fresh tip.
|
|
628
955
|
*/
|
|
629
956
|
async function repairDiscard<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
|
|
630
|
-
const coldPending = core.pending
|
|
631
|
-
await clearPending(core)
|
|
957
|
+
const coldPending = core.chain.tag === "pending" ? core.chain.batch : null
|
|
632
958
|
await discardAndReopen(core)
|
|
633
959
|
if (coldPending !== null) {
|
|
634
960
|
await resolveColdPending(core, coldPending)
|
|
@@ -648,21 +974,22 @@ async function readdressPending<Rels extends SchemaRelations>(
|
|
|
648
974
|
): Promise<void> {
|
|
649
975
|
const first = ops[0]
|
|
650
976
|
if (first === undefined) {
|
|
651
|
-
await
|
|
977
|
+
await settle(core)
|
|
652
978
|
return
|
|
653
979
|
}
|
|
654
980
|
const relation = core.descriptor.relationByName.get(first.relation)
|
|
655
|
-
const
|
|
656
|
-
if (
|
|
657
|
-
await
|
|
981
|
+
const raw = relation === undefined ? undefined : core.descriptor.braidOfRelation.get(relation.id)
|
|
982
|
+
if (raw === undefined) {
|
|
983
|
+
await settle(core)
|
|
658
984
|
return
|
|
659
985
|
}
|
|
986
|
+
const braid = braidOf(core.theory, raw)
|
|
660
987
|
const entry = chainEntry(core, braid)
|
|
661
988
|
const timestamp = maxBigint(BigInt(Date.now()), entry.ts)
|
|
662
989
|
const bytes = encodeBatch(
|
|
663
990
|
core.descriptor,
|
|
664
991
|
{
|
|
665
|
-
fingerprint: core.descriptor.
|
|
992
|
+
fingerprint: digest32(core.descriptor.fingerprintBytes),
|
|
666
993
|
braid,
|
|
667
994
|
braidGen: generation(entry.g + 1n),
|
|
668
995
|
prev: entry.prev,
|
|
@@ -671,9 +998,7 @@ async function readdressPending<Rels extends SchemaRelations>(
|
|
|
671
998
|
},
|
|
672
999
|
ops
|
|
673
1000
|
)
|
|
674
|
-
core
|
|
675
|
-
core.pendingOps = ops
|
|
676
|
-
core.pendingApplied = true
|
|
1001
|
+
holdPending(core, { braid, gen: generation(entry.g + 1n), bytes }, ops)
|
|
677
1002
|
await persistSidecar(core)
|
|
678
1003
|
}
|
|
679
1004
|
|
|
@@ -683,35 +1008,39 @@ function maxBigint(a: bigint, b: bigint): bigint {
|
|
|
683
1008
|
|
|
684
1009
|
function vectorOf<Rels extends SchemaRelations>(core: Core<Rels>): ReadonlyMap<Braid, Generation> {
|
|
685
1010
|
const vector = new Map<Braid, Generation>()
|
|
686
|
-
for (const [id, entry] of core.chain) {
|
|
1011
|
+
for (const [id, entry] of core.chain.entries) {
|
|
687
1012
|
vector.set(id, entry.g)
|
|
688
1013
|
}
|
|
689
1014
|
return vector
|
|
690
1015
|
}
|
|
691
1016
|
|
|
692
|
-
function
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
1017
|
+
async function refreshPass<Rels extends SchemaRelations>(core: Core<Rels>, braid?: Braid): Promise<RefreshOutcome> {
|
|
1018
|
+
const braids = braid === undefined ? allBraids(core) : [braidOf(core.theory, braid)]
|
|
1019
|
+
const outcome = await runPass(core, braids, "steady")
|
|
1020
|
+
if (outcome.tag === "reseed") {
|
|
1021
|
+
await repairDiscard(core)
|
|
1022
|
+
return { tag: "reseed", cause: outcome.cause }
|
|
697
1023
|
}
|
|
698
|
-
return
|
|
1024
|
+
return outcome
|
|
699
1025
|
}
|
|
700
1026
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
chainEntry(core, braid)
|
|
708
|
-
if ((await catchUpBraid(core, braid, "steady")) === "discard") {
|
|
709
|
-
await repairDiscard(core)
|
|
1027
|
+
/** waitFor is refresh with a predicate: the same pass, then the check. */
|
|
1028
|
+
async function refreshUntil<Rels extends SchemaRelations>(core: Core<Rels>, predicate: () => boolean): Promise<void> {
|
|
1029
|
+
for (;;) {
|
|
1030
|
+
disposed(core)
|
|
1031
|
+
if (predicate()) {
|
|
1032
|
+
return
|
|
710
1033
|
}
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
1034
|
+
await withGate(core, async function waitPass() {
|
|
1035
|
+
disposed(core)
|
|
1036
|
+
await refreshPass(core)
|
|
1037
|
+
})
|
|
1038
|
+
if (predicate()) {
|
|
1039
|
+
return
|
|
1040
|
+
}
|
|
1041
|
+
await new Promise(function later(resolve) {
|
|
1042
|
+
setTimeout(resolve, WAIT_FOR_POLL_MS)
|
|
1043
|
+
})
|
|
715
1044
|
}
|
|
716
1045
|
}
|
|
717
1046
|
|
|
@@ -719,9 +1048,7 @@ async function openReplica<Rels extends SchemaRelations>(options: OpenReplicaOpt
|
|
|
719
1048
|
const core = await openCore(options)
|
|
720
1049
|
const replica: Replica<Rels> = {
|
|
721
1050
|
get db() {
|
|
722
|
-
|
|
723
|
-
throw errors.new("replica is disposed")
|
|
724
|
-
}
|
|
1051
|
+
disposed(core)
|
|
725
1052
|
return core.db
|
|
726
1053
|
},
|
|
727
1054
|
get vector() {
|
|
@@ -729,9 +1056,7 @@ async function openReplica<Rels extends SchemaRelations>(options: OpenReplicaOpt
|
|
|
729
1056
|
},
|
|
730
1057
|
async refresh(braid?: Braid) {
|
|
731
1058
|
return withGate(core, async function refreshBody() {
|
|
732
|
-
|
|
733
|
-
throw errors.new("replica is disposed")
|
|
734
|
-
}
|
|
1059
|
+
disposed(core)
|
|
735
1060
|
await refreshPass(core, braid)
|
|
736
1061
|
return vectorOf(core)
|
|
737
1062
|
})
|
|
@@ -740,24 +1065,9 @@ async function openReplica<Rels extends SchemaRelations>(options: OpenReplicaOpt
|
|
|
740
1065
|
for (const braid of vector.keys()) {
|
|
741
1066
|
chainEntry(core, braid)
|
|
742
1067
|
}
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
}
|
|
747
|
-
await withGate(core, async function waitPass() {
|
|
748
|
-
for (const braid of vector.keys()) {
|
|
749
|
-
if ((await catchUpBraid(core, braid, "steady")) === "discard") {
|
|
750
|
-
await repairDiscard(core)
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
})
|
|
754
|
-
if (dominates(vectorOf(core), vector)) {
|
|
755
|
-
return
|
|
756
|
-
}
|
|
757
|
-
await new Promise(function later(resolve) {
|
|
758
|
-
setTimeout(resolve, WAIT_FOR_POLL_MS)
|
|
759
|
-
})
|
|
760
|
-
}
|
|
1068
|
+
await refreshUntil(core, function reached() {
|
|
1069
|
+
return Vector.from(vectorOf(core)).dominates(Vector.from(vector))
|
|
1070
|
+
})
|
|
761
1071
|
},
|
|
762
1072
|
async [Symbol.asyncDispose]() {
|
|
763
1073
|
await withGate(core, async function disposeBody() {
|
|
@@ -770,21 +1080,24 @@ async function openReplica<Rels extends SchemaRelations>(options: OpenReplicaOpt
|
|
|
770
1080
|
return replica
|
|
771
1081
|
}
|
|
772
1082
|
|
|
773
|
-
export type { ApplyPhase, Core, OpenReplicaOptions, Replica, SlotApply }
|
|
1083
|
+
export type { ApplyPhase, Core, OpenReplicaOptions, PendingFold, RefreshOutcome, Replica, ReplicaState, SlotApply }
|
|
774
1084
|
export {
|
|
775
1085
|
applyOps,
|
|
776
1086
|
applySlot,
|
|
1087
|
+
belowFloor,
|
|
777
1088
|
blake3Hex,
|
|
778
|
-
catchUpBraid,
|
|
779
1089
|
chainEntry,
|
|
780
|
-
|
|
1090
|
+
chainOf,
|
|
781
1091
|
clearPending,
|
|
782
1092
|
coreOf,
|
|
783
1093
|
discardAndReopen,
|
|
1094
|
+
entriesOf,
|
|
1095
|
+
foldPending,
|
|
784
1096
|
generationOf,
|
|
1097
|
+
holdPending,
|
|
785
1098
|
maxBigint,
|
|
786
1099
|
openReplica,
|
|
787
|
-
|
|
1100
|
+
pendingOf,
|
|
788
1101
|
persistSidecar,
|
|
789
1102
|
readdressPending,
|
|
790
1103
|
vectorOf,
|