@bjornpagen/bumbledb-log 0.19.2 → 0.20.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/package.json +2 -2
- package/src/braids.ts +8 -6
- package/src/bytes.ts +5 -194
- package/src/chain.ts +67 -111
- package/src/codec.ts +150 -233
- package/src/descriptor.ts +67 -612
- package/src/errors.ts +91 -62
- package/src/index.ts +25 -21
- package/src/keys.ts +49 -62
- package/src/manifest.ts +81 -129
- package/src/replica.ts +82 -128
- package/src/store-s3.ts +1 -1
- package/src/store.ts +245 -229
- package/src/tenants.ts +19 -41
- package/src/vector.ts +5 -75
- package/src/writer.ts +275 -168
- package/src/value.ts +0 -336
package/src/writer.ts
CHANGED
|
@@ -17,10 +17,16 @@ import * as crypto from "node:crypto"
|
|
|
17
17
|
import * as fs from "node:fs/promises"
|
|
18
18
|
import * as path from "node:path"
|
|
19
19
|
import {
|
|
20
|
+
type Admission,
|
|
20
21
|
type Fact,
|
|
22
|
+
type FactValue,
|
|
21
23
|
type FreshKeys,
|
|
24
|
+
type FreshRange,
|
|
22
25
|
internalBlake3,
|
|
26
|
+
type LogCodecHandle,
|
|
23
27
|
type MemberRelation,
|
|
28
|
+
type MutationReport,
|
|
29
|
+
rowOf,
|
|
24
30
|
type SchemaRelations,
|
|
25
31
|
type Violation
|
|
26
32
|
} from "@bjornpagen/bumbledb"
|
|
@@ -31,10 +37,20 @@ import { bytesEqual, checkedAddU64, digest32, utf8Encoder, utf8StrictDecoder } f
|
|
|
31
37
|
import { chainSum } from "#chain.ts"
|
|
32
38
|
import type { Op } from "#codec.ts"
|
|
33
39
|
import { decodeBatch, encodeBatch } from "#codec.ts"
|
|
34
|
-
import type { Braid, RelationInfo } from "#descriptor.ts"
|
|
40
|
+
import type { Braid, RelationInfo, Theory } from "#descriptor.ts"
|
|
35
41
|
import { descriptorOf } from "#descriptor.ts"
|
|
36
|
-
import {
|
|
37
|
-
|
|
42
|
+
import {
|
|
43
|
+
ErrRefillNeeded,
|
|
44
|
+
ErrSpanningCommit,
|
|
45
|
+
refillNeededOf,
|
|
46
|
+
refuse,
|
|
47
|
+
refuseExhausted,
|
|
48
|
+
refuseOverWidth,
|
|
49
|
+
refuseSlotRetired,
|
|
50
|
+
throwContention,
|
|
51
|
+
throwRefillNeeded
|
|
52
|
+
} from "#errors.ts"
|
|
53
|
+
import type { Generation, StoreKey } from "#keys.ts"
|
|
38
54
|
import {
|
|
39
55
|
CKPT_SCRATCH_LEASE,
|
|
40
56
|
checkpointMdbKey,
|
|
@@ -68,12 +84,14 @@ import {
|
|
|
68
84
|
withGate
|
|
69
85
|
} from "#replica.ts"
|
|
70
86
|
import type { ObjectStore } from "#store.ts"
|
|
71
|
-
import type { Value } from "#value.ts"
|
|
72
|
-
import { checkAgainst } from "#value.ts"
|
|
73
87
|
|
|
74
88
|
/** 10 owns the width: one CAS amortizes counter traffic 4096× below slot traffic. */
|
|
75
89
|
const LEASE_WIDTH = 4096n
|
|
76
|
-
|
|
90
|
+
|
|
91
|
+
/** The counter body's one grammar (`conformance/v3/counter/`): a
|
|
92
|
+
* canonical u64 decimal — no sign, no leading zero, no trailing bytes. */
|
|
93
|
+
const ID_LEASE_COUNTER = regex("^(?:0|[1-9][0-9]*)$")
|
|
94
|
+
const U64_MAX = 0xffffffffffffffffn
|
|
77
95
|
|
|
78
96
|
/** The live-loss bound (60): consecutive losses at the live tip, history never counts. */
|
|
79
97
|
const LOSS_BOUND = 16
|
|
@@ -85,43 +103,55 @@ const BATCH_MAGIC = utf8Encoder.encode("BDBL")
|
|
|
85
103
|
|
|
86
104
|
type Durability = "published" | "local-pending"
|
|
87
105
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
type BraidOutcome<Rels extends SchemaRelations> =
|
|
99
|
-
| {
|
|
100
|
-
readonly tag: "accepted"
|
|
101
|
-
readonly braid: Braid
|
|
102
|
-
readonly generation: Generation
|
|
103
|
-
readonly durability: Durability
|
|
104
|
-
}
|
|
105
|
-
| { readonly tag: "rejected"; readonly braid: Braid; readonly violations: readonly Violation<Rels>[] }
|
|
106
|
-
|
|
107
|
-
interface CommitSplit<Rels extends SchemaRelations, R> {
|
|
106
|
+
/** Where an accepted commit landed on its braid: the slot (the braid
|
|
107
|
+
* position — never the store-wide sum) and how durable the batch is. */
|
|
108
|
+
interface Landing {
|
|
109
|
+
readonly slot: Generation
|
|
110
|
+
readonly durability: Durability
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The accepted payload of `commit`: the body's value plus the landing. */
|
|
114
|
+
interface CommitReceipt<R> extends Landing {
|
|
108
115
|
readonly value: R
|
|
109
|
-
readonly
|
|
116
|
+
readonly braid: Braid
|
|
110
117
|
}
|
|
111
118
|
|
|
119
|
+
/** The empty commit is not a commit: a body that records no ops names no
|
|
120
|
+
* braid, so the refusal is its own outcome — never a slot, never a
|
|
121
|
+
* thrown surprise. The body's value rides out, the way an abandoned
|
|
122
|
+
* write's payload does in the engine. */
|
|
123
|
+
interface EmptyCommit<R> {
|
|
124
|
+
readonly tag: "empty"
|
|
125
|
+
readonly value: R
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The engine's Admission sum carrying the log's receipt in its accepted
|
|
129
|
+
* arm; the rejected arm is the engine's violations, shell and payload. */
|
|
130
|
+
type Commit<Rels extends SchemaRelations, R> = Admission<Rels, CommitReceipt<R>> | EmptyCommit<R>
|
|
131
|
+
|
|
132
|
+
/** One braid's verdict inside a split commit: the engine's Admission
|
|
133
|
+
* carrying the landing, beside the braid it judged. */
|
|
134
|
+
interface BraidOutcome<Rels extends SchemaRelations> {
|
|
135
|
+
readonly braid: Braid
|
|
136
|
+
readonly admission: Admission<Rels, Landing>
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
type CommitSplit<Rels extends SchemaRelations, R> =
|
|
140
|
+
| { readonly tag: "split"; readonly value: R; readonly outcomes: readonly BraidOutcome<Rels>[] }
|
|
141
|
+
| EmptyCommit<R>
|
|
142
|
+
|
|
112
143
|
/**
|
|
113
|
-
* The recorder:
|
|
114
|
-
*
|
|
115
|
-
*
|
|
144
|
+
* The recorder: the engine `WriteTx`'s write surface, verbatim — insert,
|
|
145
|
+
* delete, and reserve carry the engine's own signatures, so a write-only
|
|
146
|
+
* body typechecks against both dialects. `contains`/`get` are absent by
|
|
147
|
+
* law: the journaled dialect is a pure recorder, and change is judged at
|
|
148
|
+
* commit, so a report's `changed` is 0n at record time. Reservations
|
|
149
|
+
* never appear in the log; the resulting inserts carry concrete values.
|
|
116
150
|
*/
|
|
117
151
|
interface Batch<Rels extends SchemaRelations> {
|
|
118
|
-
insert<Rel extends MemberRelation<Rels>>(relation: Rel, facts: Iterable<Fact<Rel>>):
|
|
119
|
-
delete<Rel extends MemberRelation<Rels>>(relation: Rel, facts: Iterable<Fact<Rel>>):
|
|
120
|
-
reserve<Rel extends MemberRelation<Rels>>(
|
|
121
|
-
relation: Rel,
|
|
122
|
-
field: FreshKeys<Rel> & string,
|
|
123
|
-
count: bigint
|
|
124
|
-
): readonly bigint[]
|
|
152
|
+
insert<Rel extends MemberRelation<Rels>>(relation: Rel, facts: Iterable<Fact<Rel>>): MutationReport
|
|
153
|
+
delete<Rel extends MemberRelation<Rels>>(relation: Rel, facts: Iterable<Fact<Rel>>): MutationReport
|
|
154
|
+
reserve<Rel extends MemberRelation<Rels>>(relation: Rel, field: FreshKeys<Rel> & string, count: bigint): FreshRange
|
|
125
155
|
}
|
|
126
156
|
|
|
127
157
|
/** Ownership of a slot, read from the winner's header. */
|
|
@@ -190,7 +220,63 @@ function remainingOf(state: WriterState, relation: number, field: number): bigin
|
|
|
190
220
|
return remaining
|
|
191
221
|
}
|
|
192
222
|
|
|
193
|
-
|
|
223
|
+
/** The zero draw is the absence of a range (the engine's ruling). */
|
|
224
|
+
const EMPTY_RANGE: FreshRange = Object.freeze({
|
|
225
|
+
empty: true,
|
|
226
|
+
count: 0n,
|
|
227
|
+
at(_index: bigint) {
|
|
228
|
+
return undefined
|
|
229
|
+
},
|
|
230
|
+
*[Symbol.iterator](): IterableIterator<bigint> {}
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
/** A drawn range as the engine's FreshRange value: contiguous, frozen. */
|
|
234
|
+
function drawnRange(start: bigint, endExclusive: bigint): FreshRange {
|
|
235
|
+
const count = endExclusive - start
|
|
236
|
+
return Object.freeze({
|
|
237
|
+
empty: false,
|
|
238
|
+
start,
|
|
239
|
+
endExclusive,
|
|
240
|
+
count,
|
|
241
|
+
at(index: bigint) {
|
|
242
|
+
if (index < 0n || index >= count) {
|
|
243
|
+
return undefined
|
|
244
|
+
}
|
|
245
|
+
return start + index
|
|
246
|
+
},
|
|
247
|
+
*[Symbol.iterator](): IterableIterator<bigint> {
|
|
248
|
+
for (let id = start; id < endExclusive; id++) {
|
|
249
|
+
yield id
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Untouched full-width blocks pooled for the key: the record loop's
|
|
256
|
+
* guarantee unit — k full blocks serve any k draws, each at most one
|
|
257
|
+
* width, whatever tails the draws abandon. */
|
|
258
|
+
function fullBlocksOf(state: WriterState, relation: number, field: number): number {
|
|
259
|
+
const pool = state.pools.get(poolKey(relation, field)) ?? []
|
|
260
|
+
let full = 0
|
|
261
|
+
for (const range of pool) {
|
|
262
|
+
if (range.end - range.next >= LEASE_WIDTH) {
|
|
263
|
+
full += 1
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return full
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** `Lease.draw(count)` = OverWidth | Exhausted | Drawn, the log-Rust
|
|
270
|
+
* algebra: Exhausted names the u64 ceiling ONLY; a draw the pooled
|
|
271
|
+
* tail cannot serve abandons the tail (unique, never dense) and falls
|
|
272
|
+
* to the next block, and an empty pool signals a refill. */
|
|
273
|
+
function drawIds(
|
|
274
|
+
state: WriterState,
|
|
275
|
+
draws: Map<string, number>,
|
|
276
|
+
relation: number,
|
|
277
|
+
field: number,
|
|
278
|
+
count: bigint
|
|
279
|
+
): FreshRange {
|
|
194
280
|
if (count < 0n) {
|
|
195
281
|
throw errors.new(`id-lease count is unsigned: ${count}`)
|
|
196
282
|
}
|
|
@@ -198,31 +284,55 @@ function drawIds(state: WriterState, relation: number, field: number, count: big
|
|
|
198
284
|
refuseOverWidth({ requested: count }, `id-lease draw ${count} exceeds the lease width ${LEASE_WIDTH}`)
|
|
199
285
|
}
|
|
200
286
|
if (count === 0n) {
|
|
201
|
-
return
|
|
202
|
-
}
|
|
203
|
-
const
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
)
|
|
287
|
+
return EMPTY_RANGE
|
|
288
|
+
}
|
|
289
|
+
const key = poolKey(relation, field)
|
|
290
|
+
const drawn = (draws.get(key) ?? 0) + 1
|
|
291
|
+
draws.set(key, drawn)
|
|
292
|
+
const pool = state.pools.get(key) ?? []
|
|
293
|
+
while (pool.length > 0) {
|
|
294
|
+
const range = pool[0]
|
|
295
|
+
if (range === undefined) {
|
|
296
|
+
break
|
|
297
|
+
}
|
|
298
|
+
const end = checkedAddU64(range.next, count)
|
|
299
|
+
if (end === undefined) {
|
|
300
|
+
refuseExhausted({ relation, field }, `id-lease relation ${relation} field ${field} would leave u64`)
|
|
301
|
+
}
|
|
302
|
+
if (end <= range.end) {
|
|
303
|
+
const start = range.next
|
|
304
|
+
range.next = end
|
|
305
|
+
if (range.next >= range.end) {
|
|
306
|
+
pool.shift()
|
|
307
|
+
}
|
|
308
|
+
return drawnRange(start, end)
|
|
309
|
+
}
|
|
310
|
+
pool.shift()
|
|
210
311
|
}
|
|
211
|
-
|
|
212
|
-
|
|
312
|
+
throwRefillNeeded(
|
|
313
|
+
{ relation, field, requested: count },
|
|
314
|
+
`id-lease relation ${relation} field ${field} pool cannot serve ${count}`
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The counter parse: canonical decimal within u64 or a typed Counter
|
|
319
|
+
* refusal — leading zeros, signs, trailing bytes, non-UTF-8, and
|
|
320
|
+
* values past u64 all refuse under the one pinned identity. */
|
|
321
|
+
function parseCounter(key: StoreKey, bytes: Uint8Array): bigint {
|
|
322
|
+
const decoded = errors.trySync(function decodeCounter() {
|
|
323
|
+
return utf8StrictDecoder.decode(bytes)
|
|
324
|
+
})
|
|
325
|
+
if (decoded.error) {
|
|
326
|
+
refuse({ kind: "Counter", key }, `id-lease counter ${key} body is not UTF-8`)
|
|
213
327
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
while (remaining > 0n && range.next < range.end) {
|
|
217
|
-
ids.push(range.next)
|
|
218
|
-
range.next += 1n
|
|
219
|
-
remaining -= 1n
|
|
328
|
+
if (!ID_LEASE_COUNTER.test(decoded.data)) {
|
|
329
|
+
refuse({ kind: "Counter", key }, `id-lease counter ${key} body is not a canonical decimal`)
|
|
220
330
|
}
|
|
221
|
-
|
|
222
|
-
|
|
331
|
+
const value = BigInt(decoded.data)
|
|
332
|
+
if (value > U64_MAX) {
|
|
333
|
+
refuse({ kind: "Counter", key }, `id-lease counter ${key} value leaves u64`)
|
|
223
334
|
}
|
|
224
|
-
|
|
225
|
-
return ids
|
|
335
|
+
return value
|
|
226
336
|
}
|
|
227
337
|
|
|
228
338
|
/** `ids/{relation}/{field}`: birth claims [0, width); every later lease CAS-increments. */
|
|
@@ -243,11 +353,7 @@ async function acquireLease<Rels extends SchemaRelations>(
|
|
|
243
353
|
}
|
|
244
354
|
continue
|
|
245
355
|
}
|
|
246
|
-
const
|
|
247
|
-
if (!ID_LEASE_COUNTER.test(body)) {
|
|
248
|
-
throw errors.new(`id-lease counter ${key} is not a canonical decimal: ${body}`)
|
|
249
|
-
}
|
|
250
|
-
const next = BigInt(body)
|
|
356
|
+
const next = parseCounter(key, fetched.bytes)
|
|
251
357
|
const end = checkedAddU64(next, LEASE_WIDTH)
|
|
252
358
|
if (end === undefined) {
|
|
253
359
|
refuseExhausted({ relation, field }, `id-lease relation ${relation} field ${field} would leave u64`)
|
|
@@ -283,44 +389,10 @@ interface Recording {
|
|
|
283
389
|
readonly ops: Op[]
|
|
284
390
|
}
|
|
285
391
|
|
|
286
|
-
function lowerFact<Rels extends SchemaRelations>(
|
|
287
|
-
core: Core<Rels>,
|
|
288
|
-
info: RelationInfo,
|
|
289
|
-
fact: Record<string, unknown>
|
|
290
|
-
): Value[] {
|
|
291
|
-
return info.fields.map(function lowerCell(field) {
|
|
292
|
-
const raw = fact[field.name]
|
|
293
|
-
if (raw === undefined) {
|
|
294
|
-
throw errors.new(`relation ${info.name}: fact is missing field ${field.name}`)
|
|
295
|
-
}
|
|
296
|
-
let value: Value
|
|
297
|
-
if (field.closedRef !== undefined) {
|
|
298
|
-
if (typeof raw !== "string") {
|
|
299
|
-
throw errors.new(`relation ${info.name} field ${field.name}: expected a ${field.closedRef} handle name`)
|
|
300
|
-
}
|
|
301
|
-
const roster = core.descriptor.relationByName.get(field.closedRef)
|
|
302
|
-
const id = roster?.handles.indexOf(raw) ?? -1
|
|
303
|
-
if (id === -1) {
|
|
304
|
-
throw errors.new(`relation ${info.name} field ${field.name}: "${raw}" is not in the ${field.closedRef} roster`)
|
|
305
|
-
}
|
|
306
|
-
value = BigInt(id)
|
|
307
|
-
} else if (typeof raw === "object" && raw !== null && !(raw instanceof Uint8Array)) {
|
|
308
|
-
const interval = raw as { start?: unknown; end?: unknown }
|
|
309
|
-
if (typeof interval.start !== "bigint" || typeof interval.end !== "bigint") {
|
|
310
|
-
throw errors.new(`relation ${info.name} field ${field.name}: expected an interval of bigints`)
|
|
311
|
-
}
|
|
312
|
-
value = { start: interval.start, end: interval.end }
|
|
313
|
-
} else {
|
|
314
|
-
value = raw as Value
|
|
315
|
-
}
|
|
316
|
-
checkAgainst(`relation ${info.name} field ${field.name}`, field.type, value)
|
|
317
|
-
return value
|
|
318
|
-
})
|
|
319
|
-
}
|
|
320
|
-
|
|
321
392
|
function recorderOf<Rels extends SchemaRelations>(
|
|
322
393
|
core: Core<Rels>,
|
|
323
|
-
state: WriterState
|
|
394
|
+
state: WriterState,
|
|
395
|
+
draws: Map<string, number>
|
|
324
396
|
): { batch: Batch<Rels>; recording: Recording } {
|
|
325
397
|
const recording: Recording = { ops: [] }
|
|
326
398
|
function infoOf(name: string): RelationInfo {
|
|
@@ -333,23 +405,28 @@ function recorderOf<Rels extends SchemaRelations>(
|
|
|
333
405
|
}
|
|
334
406
|
return info
|
|
335
407
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
408
|
+
// The cell judge is the engine's `rowOf` — one marshal per language.
|
|
409
|
+
// The recorder applies nothing, so `changed` is 0n: change is judged
|
|
410
|
+
// at commit, when the recording meets the store.
|
|
411
|
+
function record<Rel extends MemberRelation<Rels>>(
|
|
412
|
+
op: "insert" | "delete",
|
|
413
|
+
relation: Rel,
|
|
414
|
+
facts: Iterable<Fact<Rel>>
|
|
415
|
+
): MutationReport {
|
|
416
|
+
const info = infoOf(relation.name)
|
|
417
|
+
const rows: FactValue[][] = []
|
|
339
418
|
for (const fact of facts) {
|
|
340
|
-
|
|
341
|
-
throw errors.new(`relation ${relationName}: a fact is not an object`)
|
|
342
|
-
}
|
|
343
|
-
rows.push(lowerFact(core, info, fact as Record<string, unknown>))
|
|
419
|
+
rows.push(rowOf(relation.data, fact))
|
|
344
420
|
}
|
|
345
|
-
recording.ops.push({ op, relation:
|
|
421
|
+
recording.ops.push({ op, relation: info.name, rows })
|
|
422
|
+
return Object.freeze({ submitted: BigInt(rows.length), changed: 0n })
|
|
346
423
|
}
|
|
347
424
|
const batch: Batch<Rels> = {
|
|
348
425
|
insert(relation, facts) {
|
|
349
|
-
record("insert", relation
|
|
426
|
+
return record("insert", relation, facts)
|
|
350
427
|
},
|
|
351
428
|
delete(relation, facts) {
|
|
352
|
-
record("delete", relation
|
|
429
|
+
return record("delete", relation, facts)
|
|
353
430
|
},
|
|
354
431
|
reserve(relation, field, count) {
|
|
355
432
|
const info = infoOf(relation.name)
|
|
@@ -360,24 +437,51 @@ function recorderOf<Rels extends SchemaRelations>(
|
|
|
360
437
|
if (declared === undefined || !declared.fresh) {
|
|
361
438
|
throw errors.new(`relation ${relation.name}: field ${field} is not a fresh cell`)
|
|
362
439
|
}
|
|
363
|
-
return drawIds(state, info.id, ordinal, count)
|
|
440
|
+
return drawIds(state, draws, info.id, ordinal, count)
|
|
364
441
|
}
|
|
365
442
|
}
|
|
366
443
|
return { batch, recording }
|
|
367
444
|
}
|
|
368
445
|
|
|
369
|
-
/** Runs the recording body
|
|
370
|
-
*
|
|
371
|
-
*
|
|
446
|
+
/** Runs the recording body against the cached pool. A draw the pool
|
|
447
|
+
* cannot serve discards the attempt's recording, leases fresh blocks —
|
|
448
|
+
* one full block per draw the attempt made on the starved key — and
|
|
449
|
+
* runs the body again: the refill is a path, not an exhaustion
|
|
450
|
+
* (Exhausted names the u64 ceiling only). Ids drawn by a discarded
|
|
451
|
+
* attempt are abandoned — unique, never dense. Draw counts rise
|
|
452
|
+
* strictly between refills of one key, so a body with finitely many
|
|
453
|
+
* draws settles; a recurring signature screams. */
|
|
372
454
|
async function recordWithLeases<Rels extends SchemaRelations, R>(
|
|
373
455
|
core: Core<Rels>,
|
|
374
456
|
state: WriterState,
|
|
375
457
|
body: (batch: Batch<Rels>) => R | Promise<R>
|
|
376
458
|
): Promise<{ value: R; ops: Op[] }> {
|
|
377
459
|
await ensureFreshLeases(core, state)
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
460
|
+
for (;;) {
|
|
461
|
+
const draws = new Map<string, number>()
|
|
462
|
+
const { batch, recording } = recorderOf(core, state, draws)
|
|
463
|
+
const ran = await errors.try(
|
|
464
|
+
(async function runBody() {
|
|
465
|
+
return body(batch)
|
|
466
|
+
})()
|
|
467
|
+
)
|
|
468
|
+
if (!ran.error) {
|
|
469
|
+
return { value: ran.data, ops: recording.ops }
|
|
470
|
+
}
|
|
471
|
+
if (!errors.is(ran.error, ErrRefillNeeded)) {
|
|
472
|
+
throw ran.error
|
|
473
|
+
}
|
|
474
|
+
const need = refillNeededOf(ran.error)
|
|
475
|
+
if (need === undefined) {
|
|
476
|
+
throw ran.error
|
|
477
|
+
}
|
|
478
|
+
// Draws this attempt made on the starved key, the missed one included.
|
|
479
|
+
const drawn = draws.get(poolKey(need.relation, need.field)) ?? 0
|
|
480
|
+
state.scream.attempt(`id-lease refill relation ${need.relation} field ${need.field} draws ${drawn}`)
|
|
481
|
+
while (fullBlocksOf(state, need.relation, need.field) < drawn) {
|
|
482
|
+
await acquireLease(core, state, need.relation, need.field)
|
|
483
|
+
}
|
|
484
|
+
}
|
|
381
485
|
}
|
|
382
486
|
|
|
383
487
|
function braidsTouched<Rels extends SchemaRelations>(core: Core<Rels>, ops: readonly Op[]): Map<Braid, Op[]> {
|
|
@@ -407,7 +511,9 @@ function u64leAt(bytes: Uint8Array, at: number): bigint | undefined {
|
|
|
407
511
|
}
|
|
408
512
|
|
|
409
513
|
/** The usurper is a fact in the header. A body that refuses to decode
|
|
410
|
-
* does not hide the slot's owner.
|
|
514
|
+
* does not hide the slot's owner. The Rust writer machine sniffs the
|
|
515
|
+
* same fixed offset (`writer/loss.rs`); the batch grammar itself has
|
|
516
|
+
* one reader — the codec seat. */
|
|
411
517
|
function headerWriter(bytes: Uint8Array): bigint | undefined {
|
|
412
518
|
if (bytes.length < BATCH_MAGIC.length || !bytesEqual(bytes.subarray(0, BATCH_MAGIC.length), BATCH_MAGIC)) {
|
|
413
519
|
return undefined
|
|
@@ -415,10 +521,6 @@ function headerWriter(bytes: Uint8Array): bigint | undefined {
|
|
|
415
521
|
return u64leAt(bytes, WRITER_AT)
|
|
416
522
|
}
|
|
417
523
|
|
|
418
|
-
function headerTimestamp(bytes: Uint8Array): bigint | undefined {
|
|
419
|
-
return u64leAt(bytes, WRITER_AT + 8)
|
|
420
|
-
}
|
|
421
|
-
|
|
422
524
|
/** Header prev is 32 branded bytes. */
|
|
423
525
|
function digestPrev(prev: Digest32 | Uint8Array): Digest32 {
|
|
424
526
|
return digest32(prev)
|
|
@@ -484,7 +586,7 @@ async function publishPending<Rels extends SchemaRelations>(
|
|
|
484
586
|
core: Core<Rels>,
|
|
485
587
|
state: WriterState,
|
|
486
588
|
ops: readonly Op[]
|
|
487
|
-
): Promise<
|
|
589
|
+
): Promise<Admission<Rels, Landing>> {
|
|
488
590
|
let losses = 0
|
|
489
591
|
for (;;) {
|
|
490
592
|
const pending = pendingOf(core)
|
|
@@ -494,39 +596,38 @@ async function publishPending<Rels extends SchemaRelations>(
|
|
|
494
596
|
const braid = pending.braid
|
|
495
597
|
// The floor is a write precondition: a slot at or below it is
|
|
496
598
|
// retired. A create must not touch the store (70/116/127).
|
|
497
|
-
if (belowFloor(core, braid, pending.
|
|
498
|
-
refuseSlotRetired({ braid, slot: pending.
|
|
599
|
+
if (belowFloor(core, braid, pending.slot)) {
|
|
600
|
+
refuseSlotRetired({ braid, slot: pending.slot }, "the slot is retired")
|
|
499
601
|
}
|
|
500
|
-
const created = await core.store.putCreate(logKey(core.prefix, braid, pending.
|
|
602
|
+
const created = await core.store.putCreate(logKey(core.prefix, braid, pending.slot), pending.bytes)
|
|
501
603
|
let winnerBytes: Uint8Array | null = null
|
|
502
604
|
if (created.tag !== "created") {
|
|
503
|
-
const fetched = await core.store.get(logKey(core.prefix, braid, pending.
|
|
605
|
+
const fetched = await core.store.get(logKey(core.prefix, braid, pending.slot))
|
|
504
606
|
if (fetched === null) {
|
|
505
607
|
// Exists then null: the occupant was swept. Refuse
|
|
506
608
|
// rather than loop back into putCreate.
|
|
507
|
-
refuseSlotRetired({ braid, slot: pending.
|
|
609
|
+
refuseSlotRetired({ braid, slot: pending.slot }, "the slot is retired")
|
|
508
610
|
}
|
|
509
611
|
if (!bytesEqual(fetched.bytes, pending.bytes)) {
|
|
510
612
|
winnerBytes = fetched.bytes
|
|
511
613
|
}
|
|
512
614
|
}
|
|
513
615
|
if (winnerBytes === null) {
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
throw errors.new("pending batch header is shorter than the fixed layout")
|
|
616
|
+
if (pending.ts === null) {
|
|
617
|
+
throw errors.new("publish reached with an undecoded pending timestamp")
|
|
517
618
|
}
|
|
518
619
|
entriesOf(core).set(braid, {
|
|
519
|
-
g: pending.
|
|
620
|
+
g: pending.slot,
|
|
520
621
|
prev: digest32(new Uint8Array(internalBlake3(pending.bytes))),
|
|
521
|
-
ts:
|
|
622
|
+
ts: pending.ts
|
|
522
623
|
})
|
|
523
624
|
await clearPending(core)
|
|
524
|
-
return { tag: "accepted", value:
|
|
625
|
+
return { tag: "accepted", value: { slot: pending.slot, durability: "published" } }
|
|
525
626
|
}
|
|
526
627
|
|
|
527
628
|
losses += 1
|
|
528
629
|
state.scream.attempt("slot occupant is not ours")
|
|
529
|
-
noteDeposition(state, braid, pending.
|
|
630
|
+
noteDeposition(state, braid, pending.slot, winnerBytes)
|
|
530
631
|
await discardAndReopen(core)
|
|
531
632
|
const before = generationOf(core)
|
|
532
633
|
const rejudged = applyOps(core, ops)
|
|
@@ -539,13 +640,7 @@ async function publishPending<Rels extends SchemaRelations>(
|
|
|
539
640
|
}
|
|
540
641
|
if (rejudged.value.generation === before) {
|
|
541
642
|
await clearPending(core)
|
|
542
|
-
return {
|
|
543
|
-
tag: "accepted",
|
|
544
|
-
value: undefined,
|
|
545
|
-
braid,
|
|
546
|
-
generation: chainEntry(core, braid).g,
|
|
547
|
-
durability: "published"
|
|
548
|
-
}
|
|
643
|
+
return { tag: "accepted", value: { slot: chainEntry(core, braid).g, durability: "published" } }
|
|
549
644
|
}
|
|
550
645
|
const tip = chainEntry(core, braid)
|
|
551
646
|
entriesOf(core).set(braid, { g: tip.g, prev: digestPrev(tip.prev), ts: tip.ts })
|
|
@@ -564,13 +659,12 @@ async function disciplineCommit<Rels extends SchemaRelations>(
|
|
|
564
659
|
state: WriterState,
|
|
565
660
|
braid: Braid,
|
|
566
661
|
ops: readonly Op[]
|
|
567
|
-
): Promise<
|
|
662
|
+
): Promise<Admission<Rels, Landing>> {
|
|
568
663
|
const entry = chainEntry(core, braid)
|
|
569
664
|
const timestamp = maxBigint(BigInt(Date.now()), entry.ts)
|
|
570
665
|
const bytes = encodeBatch(
|
|
571
666
|
core.descriptor,
|
|
572
667
|
{
|
|
573
|
-
fingerprint: digest32(core.descriptor.fingerprintBytes),
|
|
574
668
|
braid,
|
|
575
669
|
braidGen: generation(entry.g + 1n),
|
|
576
670
|
prev: digestPrev(entry.prev),
|
|
@@ -580,7 +674,7 @@ async function disciplineCommit<Rels extends SchemaRelations>(
|
|
|
580
674
|
ops
|
|
581
675
|
)
|
|
582
676
|
// Pending → durable, before any apply.
|
|
583
|
-
holdPending(core, { braid,
|
|
677
|
+
holdPending(core, { braid, slot: generation(entry.g + 1n), bytes }, ops, timestamp)
|
|
584
678
|
await persistSidecar(core)
|
|
585
679
|
|
|
586
680
|
const before = generationOf(core)
|
|
@@ -591,7 +685,7 @@ async function disciplineCommit<Rels extends SchemaRelations>(
|
|
|
591
685
|
}
|
|
592
686
|
if (outcome.value.generation === before) {
|
|
593
687
|
await clearPending(core)
|
|
594
|
-
return { tag: "accepted", value:
|
|
688
|
+
return { tag: "accepted", value: { slot: entry.g, durability: "published" } }
|
|
595
689
|
}
|
|
596
690
|
return publishPending(core, state, ops)
|
|
597
691
|
}
|
|
@@ -612,7 +706,7 @@ async function settleInheritedPending<Rels extends SchemaRelations>(
|
|
|
612
706
|
generationOf(core),
|
|
613
707
|
null,
|
|
614
708
|
pending.bytes,
|
|
615
|
-
belowFloor(core, pending.braid, pending.
|
|
709
|
+
belowFloor(core, pending.braid, pending.slot)
|
|
616
710
|
).tag === "below-floor"
|
|
617
711
|
) {
|
|
618
712
|
await clearPending(core)
|
|
@@ -628,6 +722,13 @@ async function settleInheritedPending<Rels extends SchemaRelations>(
|
|
|
628
722
|
return
|
|
629
723
|
}
|
|
630
724
|
ops = decoded.data.ops
|
|
725
|
+
// The decoded arm re-holds so publish reads the header facts once.
|
|
726
|
+
holdPending(
|
|
727
|
+
core,
|
|
728
|
+
{ braid: pending.braid, slot: pending.slot, bytes: pending.bytes },
|
|
729
|
+
ops,
|
|
730
|
+
decoded.data.header.timestamp
|
|
731
|
+
)
|
|
631
732
|
}
|
|
632
733
|
const published = await publishPending(core, state, ops)
|
|
633
734
|
if (published.tag === "rejected") {
|
|
@@ -689,7 +790,7 @@ async function releaseScratch(dir: string): Promise<void> {
|
|
|
689
790
|
async function casPublish(
|
|
690
791
|
store: ObjectStore,
|
|
691
792
|
prefix: string,
|
|
692
|
-
|
|
793
|
+
codec: LogCodecHandle,
|
|
693
794
|
candidate: CheckpointFacts,
|
|
694
795
|
digest: Digest32,
|
|
695
796
|
bytes: Uint8Array
|
|
@@ -716,7 +817,7 @@ async function casPublish(
|
|
|
716
817
|
return { tag: "refused", reason: "checkpoint-doc-missing" }
|
|
717
818
|
}
|
|
718
819
|
const incumbentDoc = errors.trySync(function parseIncumbent() {
|
|
719
|
-
return parseCheckpoint(doc.bytes
|
|
820
|
+
return parseCheckpoint(codec, doc.bytes)
|
|
720
821
|
})
|
|
721
822
|
if (incumbentDoc.error) {
|
|
722
823
|
return { tag: "refused", reason: "checkpoint" }
|
|
@@ -745,17 +846,18 @@ async function publishCheckpoint(
|
|
|
745
846
|
store: ObjectStore,
|
|
746
847
|
prefix: string,
|
|
747
848
|
dir: string,
|
|
748
|
-
|
|
849
|
+
theory: Theory,
|
|
749
850
|
candidate: CheckpointFacts,
|
|
750
851
|
mdb: Uint8Array
|
|
751
852
|
): Promise<Published> {
|
|
752
|
-
const
|
|
853
|
+
const codec = descriptorOf(theory).codec
|
|
854
|
+
const bytes = renderCheckpoint(codec, candidate)
|
|
753
855
|
const digest = digest32(new Uint8Array(internalBlake3(bytes)))
|
|
754
856
|
await claimScratch(dir, digest)
|
|
755
857
|
const ran = await errors.try(
|
|
756
858
|
(async function publish() {
|
|
757
859
|
await putCreateOnce(store, checkpointMdbKey(prefix, digest), mdb)
|
|
758
|
-
return await casPublish(store, prefix,
|
|
860
|
+
return await casPublish(store, prefix, codec, candidate, digest, bytes)
|
|
759
861
|
})()
|
|
760
862
|
)
|
|
761
863
|
if (ran.error) {
|
|
@@ -812,7 +914,7 @@ async function writerOn<Rels extends SchemaRelations>(replica: Replica<Rels>): P
|
|
|
812
914
|
return withGate(core, async function commitBody() {
|
|
813
915
|
const recorded = await recordWithLeases(core, state, body)
|
|
814
916
|
if (recorded.ops.length === 0) {
|
|
815
|
-
|
|
917
|
+
return { tag: "empty" as const, value: recorded.value }
|
|
816
918
|
}
|
|
817
919
|
const partitioned = braidsTouched(core, recorded.ops)
|
|
818
920
|
if (partitioned.size > 1) {
|
|
@@ -823,7 +925,10 @@ async function writerOn<Rels extends SchemaRelations>(replica: Replica<Rels>): P
|
|
|
823
925
|
if (outcome.tag === "rejected") {
|
|
824
926
|
return outcome
|
|
825
927
|
}
|
|
826
|
-
return {
|
|
928
|
+
return {
|
|
929
|
+
tag: "accepted" as const,
|
|
930
|
+
value: { value: recorded.value, braid, slot: outcome.value.slot, durability: outcome.value.durability }
|
|
931
|
+
}
|
|
827
932
|
})
|
|
828
933
|
},
|
|
829
934
|
|
|
@@ -831,24 +936,15 @@ async function writerOn<Rels extends SchemaRelations>(replica: Replica<Rels>): P
|
|
|
831
936
|
return withGate(core, async function splitBody() {
|
|
832
937
|
const recorded = await recordWithLeases(core, state, body)
|
|
833
938
|
if (recorded.ops.length === 0) {
|
|
834
|
-
|
|
939
|
+
return { tag: "empty" as const, value: recorded.value }
|
|
835
940
|
}
|
|
836
941
|
const partitioned = braidsTouched(core, recorded.ops)
|
|
837
942
|
const outcomes: BraidOutcome<Rels>[] = []
|
|
838
943
|
for (const [braid, ops] of partitioned) {
|
|
839
|
-
const
|
|
840
|
-
|
|
841
|
-
outcomes.push({ tag: "rejected", braid, violations: outcome.violations })
|
|
842
|
-
} else {
|
|
843
|
-
outcomes.push({
|
|
844
|
-
tag: "accepted",
|
|
845
|
-
braid: outcome.braid,
|
|
846
|
-
generation: outcome.generation,
|
|
847
|
-
durability: outcome.durability
|
|
848
|
-
})
|
|
849
|
-
}
|
|
944
|
+
const admission = await disciplineCommit(core, state, braid, ops)
|
|
945
|
+
outcomes.push({ braid, admission })
|
|
850
946
|
}
|
|
851
|
-
return { value: recorded.value, outcomes }
|
|
947
|
+
return { tag: "split" as const, value: recorded.value, outcomes }
|
|
852
948
|
})
|
|
853
949
|
}
|
|
854
950
|
}
|
|
@@ -866,5 +962,16 @@ async function openWriter<Rels extends SchemaRelations>(
|
|
|
866
962
|
return writerOn(await openReplica(source))
|
|
867
963
|
}
|
|
868
964
|
|
|
869
|
-
export type {
|
|
965
|
+
export type {
|
|
966
|
+
Batch,
|
|
967
|
+
BraidOutcome,
|
|
968
|
+
Commit,
|
|
969
|
+
CommitReceipt,
|
|
970
|
+
CommitSplit,
|
|
971
|
+
Deposition,
|
|
972
|
+
Durability,
|
|
973
|
+
EmptyCommit,
|
|
974
|
+
Landing,
|
|
975
|
+
Writer
|
|
976
|
+
}
|
|
870
977
|
export { openWriter, publishCheckpoint }
|