@bjornpagen/bumbledb-log 0.17.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 +26 -18
- package/package.json +5 -3
- package/src/braids.ts +19 -7
- package/src/bytes.ts +79 -11
- package/src/chain.ts +179 -63
- package/src/codec.ts +141 -62
- package/src/descriptor.ts +392 -348
- package/src/errors.ts +140 -10
- package/src/index.ts +23 -8
- package/src/keys.ts +212 -15
- package/src/manifest.ts +171 -75
- package/src/replica.ts +606 -295
- package/src/store-s3.ts +377 -0
- package/src/store.ts +524 -105
- package/src/tenants.ts +322 -26
- package/src/value.ts +80 -29
- package/src/vector.ts +173 -0
- package/src/writer.ts +484 -120
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,29 +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 {
|
|
19
|
-
import type {
|
|
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"
|
|
21
|
+
import type { Op } from "#codec.ts"
|
|
20
22
|
import { decodeBatch, encodeBatch, verifyChain } from "#codec.ts"
|
|
21
|
-
import type {
|
|
23
|
+
import type { Braid, Descriptor, RelationInfo } from "#descriptor.ts"
|
|
22
24
|
import { descriptorOf } from "#descriptor.ts"
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
+
import { ErrRefused, ErrReplayDiverged, refuse, refuseManifestMissing, wrapStore } from "#errors.ts"
|
|
26
|
+
import type { Generation } from "#keys.ts"
|
|
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"
|
|
25
38
|
import type { CheckpointFacts } from "#manifest.ts"
|
|
26
|
-
import { parseCheckpoint, parseManifest
|
|
27
|
-
import type { ObjectStore } from "#store.ts"
|
|
28
|
-
import type {
|
|
39
|
+
import { auditCatalog, parseCheckpoint, parseManifest } from "#manifest.ts"
|
|
40
|
+
import type { Etag, ObjectStore } from "#store.ts"
|
|
41
|
+
import type { Value } from "#value.ts"
|
|
42
|
+
import { Vector } from "#vector.ts"
|
|
29
43
|
|
|
30
|
-
const ZERO_HASH =
|
|
44
|
+
const ZERO_HASH = digest32(new Uint8Array(32))
|
|
31
45
|
|
|
32
|
-
/** The gc-safety heartbeat cadence
|
|
46
|
+
/** The gc-safety heartbeat cadence: every N-th pass re-reads the manifest. */
|
|
33
47
|
const HEARTBEAT_PASSES = 16
|
|
34
48
|
|
|
35
|
-
/** The re-poll cadence
|
|
36
|
-
* read-your-writes waiter in `waitFor` is this number's one consumer. */
|
|
49
|
+
/** The re-poll cadence of waitFor, its one consumer. */
|
|
37
50
|
const WAIT_FOR_POLL_MS = 20
|
|
38
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
|
+
|
|
39
73
|
interface OpenReplicaOptions<Rels extends SchemaRelations> {
|
|
40
74
|
readonly store: ObjectStore
|
|
41
75
|
readonly prefix: string
|
|
@@ -45,9 +79,9 @@ interface OpenReplicaOptions<Rels extends SchemaRelations> {
|
|
|
45
79
|
|
|
46
80
|
interface Replica<Rels extends SchemaRelations> extends AsyncDisposable {
|
|
47
81
|
readonly db: Db<Rels>
|
|
48
|
-
readonly vector: ReadonlyMap<
|
|
49
|
-
refresh(braid?:
|
|
50
|
-
waitFor(vector: ReadonlyMap<
|
|
82
|
+
readonly vector: ReadonlyMap<Braid, Generation>
|
|
83
|
+
refresh(braid?: Braid): Promise<ReadonlyMap<Braid, Generation>>
|
|
84
|
+
waitFor(vector: ReadonlyMap<Braid, Generation>): Promise<void>
|
|
51
85
|
}
|
|
52
86
|
|
|
53
87
|
interface Core<Rels extends SchemaRelations> {
|
|
@@ -55,19 +89,21 @@ interface Core<Rels extends SchemaRelations> {
|
|
|
55
89
|
readonly prefix: string
|
|
56
90
|
readonly dir: string
|
|
57
91
|
readonly theory: Schema<Rels>
|
|
58
|
-
readonly descriptor:
|
|
92
|
+
readonly descriptor: Descriptor
|
|
59
93
|
db: Db<Rels>
|
|
60
|
-
chain
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
manifestEtag:
|
|
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
|
|
98
|
+
manifestEtag: Etag | null
|
|
65
99
|
checkpoint: CheckpointFacts | null
|
|
66
|
-
checkpointDigest:
|
|
100
|
+
checkpointDigest: Digest32 | null
|
|
67
101
|
passes: number
|
|
68
102
|
closed: boolean
|
|
69
103
|
storeName: string
|
|
70
104
|
gate: Promise<unknown>
|
|
105
|
+
wedged: Map<Braid, string>
|
|
106
|
+
provenance: ReplicaState
|
|
71
107
|
}
|
|
72
108
|
|
|
73
109
|
const cores = new WeakMap<object, Core<SchemaRelations>>()
|
|
@@ -98,8 +134,12 @@ function blake3Hex(bytes: Uint8Array): string {
|
|
|
98
134
|
return toHex(new Uint8Array(internalBlake3(bytes)))
|
|
99
135
|
}
|
|
100
136
|
|
|
137
|
+
function blake3Digest(bytes: Uint8Array): Digest32 {
|
|
138
|
+
return digest32(new Uint8Array(internalBlake3(bytes)))
|
|
139
|
+
}
|
|
140
|
+
|
|
101
141
|
function sidecarPath<Rels extends SchemaRelations>(core: Core<Rels>): string {
|
|
102
|
-
return path.join(core.dir, "chain
|
|
142
|
+
return path.join(core.dir, "chain")
|
|
103
143
|
}
|
|
104
144
|
|
|
105
145
|
let storeSequence = 0
|
|
@@ -117,18 +157,33 @@ function storePath<Rels extends SchemaRelations>(core: Core<Rels>): string {
|
|
|
117
157
|
return path.join(core.dir, core.storeName)
|
|
118
158
|
}
|
|
119
159
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
+
|
|
170
|
+
function zeroChain(descriptor: Descriptor): Map<Braid, ChainEntry> {
|
|
171
|
+
const chain = new Map<Braid, ChainEntry>()
|
|
172
|
+
for (const id of descriptor.braidMembers.keys()) {
|
|
173
|
+
chain.set(id, { g: generation(0n), prev: ZERO_HASH, ts: 0n })
|
|
124
174
|
}
|
|
125
175
|
return chain
|
|
126
176
|
}
|
|
127
177
|
|
|
128
|
-
function
|
|
129
|
-
|
|
178
|
+
function entriesOf<Rels extends SchemaRelations>(core: Core<Rels>): Map<Braid, ChainEntry> {
|
|
179
|
+
return core.chain.entries as Map<Braid, ChainEntry>
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function chainEntry<Rels extends SchemaRelations>(core: Core<Rels>, braid: Braid): ChainEntry {
|
|
183
|
+
const id = braidOf(core.theory, braid)
|
|
184
|
+
const entry = entriesOf(core).get(id)
|
|
130
185
|
if (entry === undefined) {
|
|
131
|
-
throw errors.new(`braid ${
|
|
186
|
+
throw errors.new(`braid ${id} is not derived from this theory`)
|
|
132
187
|
}
|
|
133
188
|
return entry
|
|
134
189
|
}
|
|
@@ -139,34 +194,139 @@ function generationOf<Rels extends SchemaRelations>(core: Core<Rels>): bigint {
|
|
|
139
194
|
})
|
|
140
195
|
}
|
|
141
196
|
|
|
142
|
-
function
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
sum += entry.g
|
|
197
|
+
function digestOfCatalog(raw: unknown): Digest32 {
|
|
198
|
+
if (raw instanceof Uint8Array) {
|
|
199
|
+
return digest32(raw)
|
|
146
200
|
}
|
|
147
|
-
|
|
201
|
+
throw errors.new(`catalogDigest is not a digest: ${typeof raw}`)
|
|
148
202
|
}
|
|
149
203
|
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
152
258
|
}
|
|
153
259
|
|
|
154
260
|
async function persistSidecar<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
|
|
155
|
-
await writeSidecar(sidecarPath(core),
|
|
261
|
+
await writeSidecar(sidecarPath(core), chainOf(core))
|
|
156
262
|
}
|
|
157
263
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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)
|
|
162
289
|
await persistSidecar(core)
|
|
163
290
|
}
|
|
164
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
|
+
|
|
165
325
|
/** Positional decoded row → the SDK's named fact, handles lifted for closed refs. */
|
|
166
326
|
function factOf<Rels extends SchemaRelations>(
|
|
167
327
|
core: Core<Rels>,
|
|
168
328
|
relation: RelationInfo,
|
|
169
|
-
row: readonly
|
|
329
|
+
row: readonly Value[]
|
|
170
330
|
): Record<string, unknown> {
|
|
171
331
|
const fact: Record<string, unknown> = {}
|
|
172
332
|
relation.fields.forEach(function liftCell(field, ordinal) {
|
|
@@ -191,7 +351,7 @@ function factOf<Rels extends SchemaRelations>(
|
|
|
191
351
|
}
|
|
192
352
|
|
|
193
353
|
/** One `db.write` applying ops in listed order, rows in listed order. */
|
|
194
|
-
function applyOps<Rels extends SchemaRelations>(core: Core<Rels>, ops: readonly
|
|
354
|
+
function applyOps<Rels extends SchemaRelations>(core: Core<Rels>, ops: readonly Op[]): WriteOutcome<Rels, number> {
|
|
195
355
|
return core.db.write(function applyBatch(tx) {
|
|
196
356
|
for (const op of ops) {
|
|
197
357
|
const info = core.descriptor.relationByName.get(op.relation)
|
|
@@ -213,20 +373,17 @@ function applyOps<Rels extends SchemaRelations>(core: Core<Rels>, ops: readonly
|
|
|
213
373
|
})
|
|
214
374
|
}
|
|
215
375
|
|
|
216
|
-
type ApplyPhase = "open" | "steady"
|
|
217
|
-
|
|
218
|
-
type SlotApply = { readonly tag: "applied"; readonly generation: bigint } | { readonly tag: "discard" }
|
|
219
|
-
|
|
220
376
|
/**
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
* 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.
|
|
225
382
|
*/
|
|
226
383
|
async function applySlot<Rels extends SchemaRelations>(
|
|
227
384
|
core: Core<Rels>,
|
|
228
|
-
braid:
|
|
229
|
-
slot:
|
|
385
|
+
braid: Braid,
|
|
386
|
+
slot: Generation,
|
|
230
387
|
bytes: Uint8Array,
|
|
231
388
|
phase: ApplyPhase
|
|
232
389
|
): Promise<SlotApply> {
|
|
@@ -240,122 +397,228 @@ async function applySlot<Rels extends SchemaRelations>(
|
|
|
240
397
|
}
|
|
241
398
|
throw errors.wrap(ErrReplayDiverged, `braid ${braid} slot ${slot} writer ${decoded.header.writer}`)
|
|
242
399
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const expected = chainSum(core) + pendingTerm(core)
|
|
246
|
-
if (outcome.value.generation < expected) {
|
|
400
|
+
const identity = chainSum(core.chain) - entry.g + slot
|
|
401
|
+
if (outcome.value.generation < identity) {
|
|
247
402
|
refuse(
|
|
248
403
|
{ kind: "NoOpSlot", braid, slot, writer: decoded.header.writer },
|
|
249
404
|
`braid ${braid} slot ${slot}: a first-applied slot changed nothing — publish-law violation by writer ${decoded.header.writer}`
|
|
250
405
|
)
|
|
251
406
|
}
|
|
407
|
+
entriesOf(core).set(braid, { g: slot, prev: blake3Digest(bytes), ts: decoded.header.timestamp })
|
|
408
|
+
await persistSidecar(core)
|
|
252
409
|
return { tag: "applied", generation: outcome.value.generation }
|
|
253
410
|
}
|
|
254
411
|
|
|
255
412
|
/**
|
|
256
|
-
* The
|
|
257
|
-
*
|
|
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.
|
|
258
415
|
*/
|
|
259
|
-
function
|
|
416
|
+
function belowFloor<Rels extends SchemaRelations>(core: Core<Rels>, braid: Braid, slot: Generation): boolean {
|
|
417
|
+
const id = braidOf(core.theory, braid)
|
|
260
418
|
if (core.checkpoint === null) {
|
|
261
419
|
return false
|
|
262
420
|
}
|
|
263
|
-
const floor = core.checkpoint.braids.get(
|
|
421
|
+
const floor = core.checkpoint.braids.get(id)
|
|
264
422
|
if (floor === undefined) {
|
|
265
423
|
return false
|
|
266
424
|
}
|
|
267
|
-
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)
|
|
268
480
|
}
|
|
269
481
|
|
|
270
|
-
|
|
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>(
|
|
271
487
|
core: Core<Rels>,
|
|
272
|
-
braid:
|
|
488
|
+
braid: Braid,
|
|
273
489
|
phase: ApplyPhase
|
|
274
|
-
): Promise<
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if (core
|
|
285
|
-
|
|
286
|
-
// means our slot was already published and the pending ends
|
|
287
|
-
// here; any other occupant is a lost race, and the store —
|
|
288
|
-
// carrying the pending's effects on a stale base — is
|
|
289
|
-
// discarded so the one loss path re-judges at the tip (L10).
|
|
290
|
-
if (!bytesEqual(fetched.bytes, core.pending.bytes)) {
|
|
291
|
-
return "discard"
|
|
292
|
-
}
|
|
293
|
-
core.pending = null
|
|
294
|
-
core.pendingOps = null
|
|
295
|
-
core.pendingApplied = false
|
|
296
|
-
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" }
|
|
297
502
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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" }
|
|
301
509
|
}
|
|
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 }
|
|
517
|
+
}
|
|
518
|
+
throw applied.error
|
|
519
|
+
}
|
|
520
|
+
if (applied.data.tag === "discard") {
|
|
521
|
+
return { tag: "reseed", cause: "rejected-in-open" }
|
|
302
522
|
}
|
|
523
|
+
return { tag: "applied" }
|
|
303
524
|
}
|
|
304
525
|
|
|
305
|
-
|
|
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>(
|
|
306
531
|
core: Core<Rels>,
|
|
532
|
+
braids: readonly Braid[],
|
|
307
533
|
phase: ApplyPhase
|
|
308
|
-
): Promise<
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
313
559
|
}
|
|
314
560
|
}
|
|
315
|
-
|
|
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()]
|
|
316
572
|
}
|
|
317
573
|
|
|
318
574
|
function wholenessHolds<Rels extends SchemaRelations>(core: Core<Rels>): boolean {
|
|
319
|
-
return generationOf(core) ===
|
|
575
|
+
return generationOf(core) === chainGeneration(core.chain)
|
|
320
576
|
}
|
|
321
577
|
|
|
322
578
|
async function adoptManifest<Rels extends SchemaRelations>(
|
|
323
579
|
core: Core<Rels>,
|
|
324
580
|
bytes: Uint8Array,
|
|
325
|
-
etag:
|
|
581
|
+
etag: Etag
|
|
326
582
|
): Promise<void> {
|
|
327
583
|
const manifest = parseManifest(bytes)
|
|
328
|
-
if (manifest.fingerprint
|
|
584
|
+
if (!bytesEqual(manifest.fingerprint, digest32(core.descriptor.fingerprintBytes))) {
|
|
329
585
|
refuse(
|
|
330
|
-
{
|
|
586
|
+
{
|
|
587
|
+
kind: "FingerprintMismatch",
|
|
588
|
+
carried: hex32(manifest.fingerprint),
|
|
589
|
+
expected: core.descriptor.fingerprint
|
|
590
|
+
},
|
|
331
591
|
"the store's manifest names a different theory"
|
|
332
592
|
)
|
|
333
593
|
}
|
|
334
|
-
core.manifestEtag = etag
|
|
335
594
|
if (manifest.checkpoint === null) {
|
|
336
595
|
core.checkpoint = null
|
|
337
596
|
core.checkpointDigest = null
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
352
613
|
}
|
|
353
|
-
|
|
354
|
-
|
|
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
|
|
355
617
|
}
|
|
356
618
|
|
|
357
|
-
|
|
358
|
-
|
|
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) {
|
|
359
622
|
const poll = await core.store.getIfChanged(manifestKey(core.prefix), core.manifestEtag)
|
|
360
623
|
if (poll.tag === "unchanged") {
|
|
361
624
|
return
|
|
@@ -364,94 +627,102 @@ async function refreshManifest<Rels extends SchemaRelations>(core: Core<Rels>, f
|
|
|
364
627
|
return
|
|
365
628
|
}
|
|
366
629
|
const fetched = await core.store.get(manifestKey(core.prefix))
|
|
367
|
-
if (fetched
|
|
368
|
-
|
|
369
|
-
return
|
|
370
|
-
}
|
|
371
|
-
const birth = renderManifest({ fingerprint: core.descriptor.fingerprint, checkpoint: null })
|
|
372
|
-
const created = await core.store.putCreate(manifestKey(core.prefix), birth)
|
|
373
|
-
if (created.tag === "created") {
|
|
374
|
-
core.manifestEtag = created.etag
|
|
375
|
-
core.checkpoint = null
|
|
376
|
-
core.checkpointDigest = null
|
|
377
|
-
return
|
|
378
|
-
}
|
|
379
|
-
const reread = await core.store.get(manifestKey(core.prefix))
|
|
380
|
-
if (reread === null) {
|
|
381
|
-
throw errors.new("manifest vanished between create refusal and re-read")
|
|
630
|
+
if (fetched === null) {
|
|
631
|
+
refuseManifestMissing("the store has no manifest")
|
|
382
632
|
}
|
|
383
|
-
await adoptManifest(core,
|
|
633
|
+
await adoptManifest(core, fetched.bytes, fetched.etag)
|
|
384
634
|
}
|
|
385
635
|
|
|
386
636
|
/** Bootstraps a fresh local store: from the current checkpoint when one
|
|
387
|
-
* 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. */
|
|
388
639
|
async function initializeStore<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
|
|
389
640
|
core.storeName = freshStoreName()
|
|
390
641
|
const target = storePath(core)
|
|
391
642
|
await fs.rm(target, { recursive: true, force: true })
|
|
392
643
|
await fs.mkdir(core.dir, { recursive: true })
|
|
393
644
|
if (core.checkpoint !== null && core.checkpointDigest !== null) {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
"checkpoint bytes do not hash to their own name"
|
|
407
|
-
)
|
|
408
|
-
}
|
|
409
|
-
await fs.mkdir(target, { recursive: true })
|
|
410
|
-
await fs.writeFile(path.join(target, "data.mdb"), mdb.bytes)
|
|
411
|
-
core.db = await SdkDb.open(target, core.theory)
|
|
412
|
-
core.chain = new Map(
|
|
413
|
-
[...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)
|
|
414
657
|
return [braid, { g: head.g, prev: head.hash, ts: head.ts }] as const
|
|
415
658
|
})
|
|
416
659
|
)
|
|
417
|
-
let sum = 0n
|
|
418
|
-
for (const head of core.checkpoint.braids.values()) {
|
|
419
|
-
sum += head.g
|
|
420
|
-
}
|
|
421
|
-
const generation = generationOf(core)
|
|
422
|
-
if (generation !== sum) {
|
|
423
|
-
throw errors.new(`checkpoint store opened at generation ${generation}, its vector sums to ${sum}`)
|
|
424
|
-
}
|
|
425
|
-
await persistSidecar(core)
|
|
426
|
-
return
|
|
427
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
|
|
428
671
|
}
|
|
429
672
|
const created = await SdkDb.create(target, core.theory)
|
|
430
673
|
if (created.tag === "rejected") {
|
|
431
674
|
throw errors.new("the theory's ground axioms were rejected at bootstrap")
|
|
432
675
|
}
|
|
433
676
|
core.db = created.value
|
|
434
|
-
core.chain = zeroChain(core.descriptor)
|
|
677
|
+
core.chain = { tag: "settled", entries: zeroChain(core.descriptor) }
|
|
678
|
+
core.provenance = { tag: "bootstrapped" }
|
|
435
679
|
await persistSidecar(core)
|
|
436
680
|
}
|
|
437
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
|
+
|
|
438
701
|
/** The disposable law: the directory is cache, never truth. The local
|
|
439
702
|
* LMDB path rotates because the engine has no close verb — the old
|
|
440
|
-
* 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. */
|
|
441
706
|
async function discardAndReopen<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
|
|
442
|
-
|
|
707
|
+
const scream = screamOf("replica discard-and-re-pull")
|
|
708
|
+
for (;;) {
|
|
443
709
|
const old = storePath(core)
|
|
444
710
|
await fs.rm(old, { recursive: true, force: true })
|
|
711
|
+
core.wedged.clear()
|
|
445
712
|
await initializeStore(core)
|
|
446
|
-
const outcome = await
|
|
447
|
-
if (outcome === "
|
|
713
|
+
const outcome = await runPass(core, allBraids(core), "open")
|
|
714
|
+
if (outcome.tag === "reseed") {
|
|
715
|
+
scream.attempt(outcome.cause)
|
|
448
716
|
continue
|
|
449
717
|
}
|
|
450
|
-
if (
|
|
718
|
+
if (outcome.tag === "refused") {
|
|
719
|
+
throw errors.wrap(ErrRefused, outcome.detail)
|
|
720
|
+
}
|
|
721
|
+
if (generationOf(core) === chainGeneration(core.chain)) {
|
|
451
722
|
return
|
|
452
723
|
}
|
|
724
|
+
scream.attempt("wholeness")
|
|
453
725
|
}
|
|
454
|
-
throw errors.new("the store failed to reach wholeness after repeated re-pulls")
|
|
455
726
|
}
|
|
456
727
|
|
|
457
728
|
async function newestStoreDir(dir: string): Promise<string | null> {
|
|
@@ -474,6 +745,26 @@ async function newestStoreDir(dir: string): Promise<string | null> {
|
|
|
474
745
|
return newest
|
|
475
746
|
}
|
|
476
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
|
+
|
|
477
768
|
/** The disposable law says cache directories do not hoard corpses: every
|
|
478
769
|
* rotated `store-*` LMDB dir except the adopted one is dead — left by a
|
|
479
770
|
* crashed process or a prior rotation — and is swept at open. */
|
|
@@ -491,35 +782,48 @@ async function sweepRotations<Rels extends SchemaRelations>(core: Core<Rels>): P
|
|
|
491
782
|
}
|
|
492
783
|
|
|
493
784
|
/**
|
|
494
|
-
* Pending recovery
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
* born-no-op publishes nothing; anything else is a real unpublished
|
|
498
|
-
* 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.
|
|
499
788
|
*/
|
|
500
789
|
async function resolvePendingAtOpen<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
|
|
501
|
-
|
|
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)
|
|
502
805
|
return
|
|
503
806
|
}
|
|
504
807
|
const decoded = errors.trySync(function decodePending() {
|
|
505
|
-
return decodeBatch(core.descriptor,
|
|
808
|
+
return decodeBatch(core.descriptor, chain.batch.bytes)
|
|
506
809
|
})
|
|
507
810
|
if (decoded.error) {
|
|
508
|
-
await
|
|
811
|
+
await settle(core)
|
|
509
812
|
return
|
|
510
813
|
}
|
|
511
|
-
const before = generationOf(core)
|
|
512
814
|
const outcome = applyOps(core, decoded.data.ops)
|
|
513
815
|
if (outcome.tag === "rejected") {
|
|
514
|
-
await
|
|
816
|
+
await settle(core)
|
|
515
817
|
return
|
|
516
818
|
}
|
|
517
|
-
|
|
518
|
-
|
|
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)
|
|
519
824
|
return
|
|
520
825
|
}
|
|
521
|
-
core.
|
|
522
|
-
core.pendingApplied = true
|
|
826
|
+
holdPending(core, chain.batch, decoded.data.ops)
|
|
523
827
|
}
|
|
524
828
|
|
|
525
829
|
/**
|
|
@@ -527,21 +831,26 @@ async function resolvePendingAtOpen<Rels extends SchemaRelations>(core: Core<Rel
|
|
|
527
831
|
* unopenable (or discarded), so the sidecar's pending is resolved
|
|
528
832
|
* against a freshly caught-up store — a byte-equal published slot
|
|
529
833
|
* absorbs it (L10's idempotent replay), and everything else takes the
|
|
530
|
-
* 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).
|
|
531
836
|
*/
|
|
532
|
-
async function resolveColdPending<Rels extends SchemaRelations>(
|
|
533
|
-
core
|
|
534
|
-
|
|
535
|
-
)
|
|
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
|
+
}
|
|
536
846
|
const decoded = errors.trySync(function decodePending() {
|
|
537
847
|
return decodeBatch(core.descriptor, pending.bytes)
|
|
538
848
|
})
|
|
539
849
|
if (decoded.error) {
|
|
540
850
|
return
|
|
541
851
|
}
|
|
542
|
-
const braid = decoded.data.header.braid
|
|
543
852
|
const slot = decoded.data.header.braidGen
|
|
544
|
-
if (core.
|
|
853
|
+
if (entriesOf(core).has(braid) && chainEntry(core, braid).g >= slot) {
|
|
545
854
|
const published = await core.store.get(logKey(core.prefix, braid, slot))
|
|
546
855
|
if (published !== null && bytesEqual(published.bytes, pending.bytes)) {
|
|
547
856
|
return
|
|
@@ -550,8 +859,7 @@ async function resolveColdPending<Rels extends SchemaRelations>(
|
|
|
550
859
|
const before = generationOf(core)
|
|
551
860
|
const outcome = applyOps(core, decoded.data.ops)
|
|
552
861
|
if (outcome.tag === "accepted" && outcome.value.generation > before) {
|
|
553
|
-
core
|
|
554
|
-
core.pendingApplied = true
|
|
862
|
+
holdPending(core, { ...pending, braid }, decoded.data.ops)
|
|
555
863
|
await readdressPending(core, decoded.data.ops, decoded.data.header.writer)
|
|
556
864
|
}
|
|
557
865
|
}
|
|
@@ -565,42 +873,56 @@ async function openCore<Rels extends SchemaRelations>(options: OpenReplicaOption
|
|
|
565
873
|
theory: options.theory,
|
|
566
874
|
descriptor,
|
|
567
875
|
db: undefined as unknown as Db<Rels>,
|
|
568
|
-
chain: zeroChain(descriptor),
|
|
569
|
-
pending: null,
|
|
570
|
-
pendingOps: null,
|
|
571
|
-
pendingApplied: false,
|
|
876
|
+
chain: { tag: "settled", entries: zeroChain(descriptor) },
|
|
572
877
|
manifestEtag: null,
|
|
573
878
|
checkpoint: null,
|
|
574
879
|
checkpointDigest: null,
|
|
575
880
|
passes: 0,
|
|
576
881
|
closed: false,
|
|
577
882
|
storeName: freshStoreName(),
|
|
578
|
-
gate: Promise.resolve()
|
|
883
|
+
gate: Promise.resolve(),
|
|
884
|
+
wedged: new Map(),
|
|
885
|
+
provenance: { tag: "bootstrapped" }
|
|
579
886
|
}
|
|
580
887
|
await fs.mkdir(core.dir, { recursive: true })
|
|
581
|
-
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)
|
|
582
893
|
|
|
583
894
|
const existing = await newestStoreDir(core.dir)
|
|
584
895
|
const sidecar = await readSidecar(sidecarPath(core))
|
|
585
896
|
let opened = false
|
|
586
|
-
let coldPending:
|
|
587
|
-
if (
|
|
897
|
+
let coldPending: Pending | null = null
|
|
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") {
|
|
588
912
|
core.storeName = existing
|
|
589
913
|
const openedDb = await errors.try(SdkDb.open(storePath(core), options.theory))
|
|
590
|
-
if (openedDb.error === undefined
|
|
914
|
+
if (openedDb.error === undefined) {
|
|
591
915
|
core.db = openedDb.data
|
|
592
|
-
core
|
|
593
|
-
|
|
594
|
-
if (!core.chain.has(braid)) {
|
|
595
|
-
core.chain.set(braid, { g: 0n, prev: ZERO_HASH, ts: 0n })
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
core.pending = sidecar.pending
|
|
916
|
+
adoptChain(core, sidecar.chain)
|
|
917
|
+
core.provenance = { tag: "sidecar-resumed", floor: vectorOf(core) }
|
|
599
918
|
opened = true
|
|
600
919
|
}
|
|
601
920
|
}
|
|
602
921
|
if (!opened) {
|
|
603
|
-
|
|
922
|
+
if (sidecar.tag === "read" && sidecar.chain.tag === "pending") {
|
|
923
|
+
coldPending = sidecar.chain.batch
|
|
924
|
+
braidOf(core.theory, coldPending.braid)
|
|
925
|
+
}
|
|
604
926
|
await initializeStore(core)
|
|
605
927
|
}
|
|
606
928
|
|
|
@@ -608,29 +930,31 @@ async function openCore<Rels extends SchemaRelations>(options: OpenReplicaOption
|
|
|
608
930
|
await resolvePendingAtOpen(core)
|
|
609
931
|
}
|
|
610
932
|
|
|
611
|
-
const outcome = await
|
|
612
|
-
if (outcome === "
|
|
613
|
-
coldPending = core.pending
|
|
614
|
-
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
|
|
615
936
|
await discardAndReopen(core)
|
|
616
937
|
}
|
|
617
938
|
|
|
618
939
|
if (coldPending !== null) {
|
|
619
940
|
await resolveColdPending(core, coldPending)
|
|
620
941
|
}
|
|
942
|
+
if (atCheckpointFloor(core)) {
|
|
943
|
+
auditCatalog(core.checkpoint, catalogDigestOf(core))
|
|
944
|
+
}
|
|
621
945
|
await sweepRotations(core)
|
|
946
|
+
await sweepReservedKeys(core)
|
|
622
947
|
return core
|
|
623
948
|
}
|
|
624
949
|
|
|
625
950
|
/**
|
|
626
|
-
* The steady-state discard route: a
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
*
|
|
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.
|
|
630
955
|
*/
|
|
631
956
|
async function repairDiscard<Rels extends SchemaRelations>(core: Core<Rels>): Promise<void> {
|
|
632
|
-
const coldPending = core.pending
|
|
633
|
-
await clearPending(core)
|
|
957
|
+
const coldPending = core.chain.tag === "pending" ? core.chain.batch : null
|
|
634
958
|
await discardAndReopen(core)
|
|
635
959
|
if (coldPending !== null) {
|
|
636
960
|
await resolveColdPending(core, coldPending)
|
|
@@ -645,37 +969,36 @@ async function repairDiscard<Rels extends SchemaRelations>(core: Core<Rels>): Pr
|
|
|
645
969
|
*/
|
|
646
970
|
async function readdressPending<Rels extends SchemaRelations>(
|
|
647
971
|
core: Core<Rels>,
|
|
648
|
-
ops: readonly
|
|
972
|
+
ops: readonly Op[],
|
|
649
973
|
writerId: bigint
|
|
650
974
|
): Promise<void> {
|
|
651
975
|
const first = ops[0]
|
|
652
976
|
if (first === undefined) {
|
|
653
|
-
await
|
|
977
|
+
await settle(core)
|
|
654
978
|
return
|
|
655
979
|
}
|
|
656
980
|
const relation = core.descriptor.relationByName.get(first.relation)
|
|
657
|
-
const
|
|
658
|
-
if (
|
|
659
|
-
await
|
|
981
|
+
const raw = relation === undefined ? undefined : core.descriptor.braidOfRelation.get(relation.id)
|
|
982
|
+
if (raw === undefined) {
|
|
983
|
+
await settle(core)
|
|
660
984
|
return
|
|
661
985
|
}
|
|
986
|
+
const braid = braidOf(core.theory, raw)
|
|
662
987
|
const entry = chainEntry(core, braid)
|
|
663
988
|
const timestamp = maxBigint(BigInt(Date.now()), entry.ts)
|
|
664
989
|
const bytes = encodeBatch(
|
|
665
990
|
core.descriptor,
|
|
666
991
|
{
|
|
667
|
-
fingerprint: core.descriptor.
|
|
992
|
+
fingerprint: digest32(core.descriptor.fingerprintBytes),
|
|
668
993
|
braid,
|
|
669
|
-
braidGen: entry.g + 1n,
|
|
994
|
+
braidGen: generation(entry.g + 1n),
|
|
670
995
|
prev: entry.prev,
|
|
671
996
|
writer: writerId,
|
|
672
997
|
timestamp
|
|
673
998
|
},
|
|
674
999
|
ops
|
|
675
1000
|
)
|
|
676
|
-
core
|
|
677
|
-
core.pendingOps = ops
|
|
678
|
-
core.pendingApplied = true
|
|
1001
|
+
holdPending(core, { braid, gen: generation(entry.g + 1n), bytes }, ops)
|
|
679
1002
|
await persistSidecar(core)
|
|
680
1003
|
}
|
|
681
1004
|
|
|
@@ -683,37 +1006,41 @@ function maxBigint(a: bigint, b: bigint): bigint {
|
|
|
683
1006
|
return a > b ? a : b
|
|
684
1007
|
}
|
|
685
1008
|
|
|
686
|
-
function vectorOf<Rels extends SchemaRelations>(core: Core<Rels>): ReadonlyMap<
|
|
687
|
-
const vector = new Map<
|
|
688
|
-
for (const [
|
|
689
|
-
vector.set(
|
|
1009
|
+
function vectorOf<Rels extends SchemaRelations>(core: Core<Rels>): ReadonlyMap<Braid, Generation> {
|
|
1010
|
+
const vector = new Map<Braid, Generation>()
|
|
1011
|
+
for (const [id, entry] of core.chain.entries) {
|
|
1012
|
+
vector.set(id, entry.g)
|
|
690
1013
|
}
|
|
691
1014
|
return vector
|
|
692
1015
|
}
|
|
693
1016
|
|
|
694
|
-
function
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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 }
|
|
699
1023
|
}
|
|
700
|
-
return
|
|
1024
|
+
return outcome
|
|
701
1025
|
}
|
|
702
1026
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
chainEntry(core, braid)
|
|
710
|
-
if ((await catchUpBraid(core, braid, "steady")) === "discard") {
|
|
711
|
-
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
|
|
712
1033
|
}
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
|
+
})
|
|
717
1044
|
}
|
|
718
1045
|
}
|
|
719
1046
|
|
|
@@ -721,19 +1048,15 @@ async function openReplica<Rels extends SchemaRelations>(options: OpenReplicaOpt
|
|
|
721
1048
|
const core = await openCore(options)
|
|
722
1049
|
const replica: Replica<Rels> = {
|
|
723
1050
|
get db() {
|
|
724
|
-
|
|
725
|
-
throw errors.new("replica is disposed")
|
|
726
|
-
}
|
|
1051
|
+
disposed(core)
|
|
727
1052
|
return core.db
|
|
728
1053
|
},
|
|
729
1054
|
get vector() {
|
|
730
1055
|
return vectorOf(core)
|
|
731
1056
|
},
|
|
732
|
-
async refresh(braid?:
|
|
1057
|
+
async refresh(braid?: Braid) {
|
|
733
1058
|
return withGate(core, async function refreshBody() {
|
|
734
|
-
|
|
735
|
-
throw errors.new("replica is disposed")
|
|
736
|
-
}
|
|
1059
|
+
disposed(core)
|
|
737
1060
|
await refreshPass(core, braid)
|
|
738
1061
|
return vectorOf(core)
|
|
739
1062
|
})
|
|
@@ -742,24 +1065,9 @@ async function openReplica<Rels extends SchemaRelations>(options: OpenReplicaOpt
|
|
|
742
1065
|
for (const braid of vector.keys()) {
|
|
743
1066
|
chainEntry(core, braid)
|
|
744
1067
|
}
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
}
|
|
749
|
-
await withGate(core, async function waitPass() {
|
|
750
|
-
for (const braid of vector.keys()) {
|
|
751
|
-
if ((await catchUpBraid(core, braid, "steady")) === "discard") {
|
|
752
|
-
await repairDiscard(core)
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
})
|
|
756
|
-
if (dominates(vectorOf(core), vector)) {
|
|
757
|
-
return
|
|
758
|
-
}
|
|
759
|
-
await new Promise(function later(resolve) {
|
|
760
|
-
setTimeout(resolve, WAIT_FOR_POLL_MS)
|
|
761
|
-
})
|
|
762
|
-
}
|
|
1068
|
+
await refreshUntil(core, function reached() {
|
|
1069
|
+
return Vector.from(vectorOf(core)).dominates(Vector.from(vector))
|
|
1070
|
+
})
|
|
763
1071
|
},
|
|
764
1072
|
async [Symbol.asyncDispose]() {
|
|
765
1073
|
await withGate(core, async function disposeBody() {
|
|
@@ -772,21 +1080,24 @@ async function openReplica<Rels extends SchemaRelations>(options: OpenReplicaOpt
|
|
|
772
1080
|
return replica
|
|
773
1081
|
}
|
|
774
1082
|
|
|
775
|
-
export type { ApplyPhase, Core, OpenReplicaOptions, Replica, SlotApply }
|
|
1083
|
+
export type { ApplyPhase, Core, OpenReplicaOptions, PendingFold, RefreshOutcome, Replica, ReplicaState, SlotApply }
|
|
776
1084
|
export {
|
|
777
1085
|
applyOps,
|
|
778
1086
|
applySlot,
|
|
1087
|
+
belowFloor,
|
|
779
1088
|
blake3Hex,
|
|
780
|
-
catchUpBraid,
|
|
781
1089
|
chainEntry,
|
|
782
|
-
|
|
1090
|
+
chainOf,
|
|
783
1091
|
clearPending,
|
|
784
1092
|
coreOf,
|
|
785
1093
|
discardAndReopen,
|
|
1094
|
+
entriesOf,
|
|
1095
|
+
foldPending,
|
|
786
1096
|
generationOf,
|
|
1097
|
+
holdPending,
|
|
787
1098
|
maxBigint,
|
|
788
1099
|
openReplica,
|
|
789
|
-
|
|
1100
|
+
pendingOf,
|
|
790
1101
|
persistSidecar,
|
|
791
1102
|
readdressPending,
|
|
792
1103
|
vectorOf,
|