@bjornpagen/bumbledb-log 0.17.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/src/writer.ts ADDED
@@ -0,0 +1,506 @@
1
+ /**
2
+ * The writer (60): a replica plus the right to create log objects. One
3
+ * commit path and one loss path: a lost slot's byte-equal occupant is
4
+ * an ambiguous PUT absorbed; anything else discards the local
5
+ * directory, re-opens through the replica to the current tip, and
6
+ * re-judges the recorded ops once — the verdict IS a serial execution,
7
+ * performed. Each loop iteration races once at the then-tip, so a
8
+ * historical loss is structurally uncountable, and bounded live-tip
9
+ * losses surface as ErrContention carrying the terminal re-judgment's
10
+ * own violation or the racing tip.
11
+ */
12
+
13
+ import * as crypto from "node:crypto"
14
+ import type { Fact, FreshKeys, MemberRelation, SchemaRelations, Violation } from "@bjornpagen/bumbledb"
15
+ import * as errors from "@superbuilders/errors"
16
+ import { bytesEqual, utf8Encoder, utf8StrictDecoder } from "#bytes.ts"
17
+ import type { BatchOp } from "#codec.ts"
18
+ import { decodeBatch, encodeBatch } from "#codec.ts"
19
+ import type { RelationInfo } from "#descriptor.ts"
20
+ import { ErrSpanningCommit, throwContention } from "#errors.ts"
21
+ import { idsKey, logKey } from "#keys.ts"
22
+ import type { Core, Replica } from "#replica.ts"
23
+ import {
24
+ applyOps,
25
+ blake3Hex,
26
+ chainEntry,
27
+ clearPending,
28
+ coreOf,
29
+ discardAndReopen,
30
+ generationOf,
31
+ maxBigint,
32
+ persistSidecar,
33
+ readdressPending,
34
+ withGate
35
+ } from "#replica.ts"
36
+ import type { LogValue } from "#value.ts"
37
+ import { checkAgainst } from "#value.ts"
38
+
39
+ /** 10 owns the width: one CAS amortizes counter traffic 4096× below slot traffic. */
40
+ const LEASE_WIDTH = 4096n
41
+
42
+ /** The live-loss bound (60): consecutive losses at the live tip, history never counts. */
43
+ const LOSS_BOUND = 16
44
+
45
+ type Durability = "published" | "local-pending"
46
+
47
+ type Commit<Rels extends SchemaRelations, R> =
48
+ | {
49
+ readonly tag: "accepted"
50
+ readonly value: R
51
+ readonly braid: string
52
+ readonly generation: bigint
53
+ readonly durability: Durability
54
+ }
55
+ | { readonly tag: "rejected"; readonly violations: readonly Violation<Rels>[] }
56
+
57
+ type BraidOutcome<Rels extends SchemaRelations> =
58
+ | { readonly tag: "accepted"; readonly braid: string; readonly generation: bigint; readonly durability: Durability }
59
+ | { readonly tag: "rejected"; readonly braid: string; readonly violations: readonly Violation<Rels>[] }
60
+
61
+ interface CommitSplit<Rels extends SchemaRelations, R> {
62
+ readonly value: R
63
+ readonly outcomes: readonly BraidOutcome<Rels>[]
64
+ }
65
+
66
+ /**
67
+ * The recorder: typed inserts and deletes as raw-valued ops, `reserve`
68
+ * drawing on the id lease (10) — reservations never appear in the log;
69
+ * the resulting inserts carry concrete values. Pure and synchronous.
70
+ */
71
+ interface LogBatch<Rels extends SchemaRelations> {
72
+ insert<Rel extends MemberRelation<Rels>>(relation: Rel, facts: Iterable<Fact<Rel>>): void
73
+ delete<Rel extends MemberRelation<Rels>>(relation: Rel, facts: Iterable<Fact<Rel>>): void
74
+ reserve<Rel extends MemberRelation<Rels>>(
75
+ relation: Rel,
76
+ field: FreshKeys<Rel> & string,
77
+ count: bigint
78
+ ): readonly bigint[]
79
+ }
80
+
81
+ interface Writer<Rels extends SchemaRelations> {
82
+ commit<R>(body: (batch: LogBatch<Rels>) => R): Promise<Commit<Rels, R>>
83
+ commitSplit<R>(body: (batch: LogBatch<Rels>) => R): Promise<CommitSplit<Rels, R>>
84
+ }
85
+
86
+ interface LeaseRange {
87
+ next: bigint
88
+ readonly end: bigint
89
+ }
90
+
91
+ interface WriterState {
92
+ readonly writerId: bigint
93
+ readonly pools: Map<string, LeaseRange[]>
94
+ }
95
+
96
+ const ErrLeaseDrained = errors.new("bumbledb-log lease pool drained mid-recording")
97
+ const leaseDemand = new WeakMap<Error, { relation: number; field: number }>()
98
+
99
+ function poolKey(relation: number, field: number): string {
100
+ return `${relation}:${field}`
101
+ }
102
+
103
+ function drawIds(state: WriterState, relation: number, field: number, count: bigint): bigint[] {
104
+ const pool = state.pools.get(poolKey(relation, field)) ?? []
105
+ const ids: bigint[] = []
106
+ let remaining = count
107
+ while (remaining > 0n) {
108
+ const range = pool[0]
109
+ if (range === undefined) {
110
+ const fault = errors.wrap(ErrLeaseDrained, `relation ${relation} field ${field} needs ${remaining} more ids`)
111
+ leaseDemand.set(fault, { relation, field })
112
+ throw fault
113
+ }
114
+ while (remaining > 0n && range.next < range.end) {
115
+ ids.push(range.next)
116
+ range.next += 1n
117
+ remaining -= 1n
118
+ }
119
+ if (range.next >= range.end) {
120
+ pool.shift()
121
+ }
122
+ }
123
+ state.pools.set(poolKey(relation, field), pool)
124
+ return ids
125
+ }
126
+
127
+ /** `ids/{relation}/{field}`: birth claims [0, width); every later lease CAS-increments. */
128
+ async function acquireLease<Rels extends SchemaRelations>(
129
+ core: Core<Rels>,
130
+ state: WriterState,
131
+ relation: number,
132
+ field: number
133
+ ): Promise<void> {
134
+ const key = idsKey(core.prefix, relation, field)
135
+ for (;;) {
136
+ const fetched = await core.store.get(key)
137
+ if (fetched === null) {
138
+ const created = await core.store.putCreate(key, utf8Encoder.encode(String(LEASE_WIDTH)))
139
+ if (created.tag === "created") {
140
+ pushRange(state, relation, field, 0n, LEASE_WIDTH)
141
+ return
142
+ }
143
+ continue
144
+ }
145
+ const body = utf8StrictDecoder.decode(fetched.bytes)
146
+ if (!/^\d+$/.test(body)) {
147
+ throw errors.new(`id-lease counter ${key} is not a canonical decimal: ${body}`)
148
+ }
149
+ const next = BigInt(body)
150
+ const swapped = await core.store.putSwap(key, utf8Encoder.encode(String(next + LEASE_WIDTH)), fetched.etag)
151
+ if (swapped.tag === "swapped") {
152
+ pushRange(state, relation, field, next, next + LEASE_WIDTH)
153
+ return
154
+ }
155
+ }
156
+ }
157
+
158
+ function pushRange(state: WriterState, relation: number, field: number, next: bigint, end: bigint): void {
159
+ const pool = state.pools.get(poolKey(relation, field)) ?? []
160
+ pool.push({ next, end })
161
+ state.pools.set(poolKey(relation, field), pool)
162
+ }
163
+
164
+ interface Recording {
165
+ readonly ops: BatchOp[]
166
+ }
167
+
168
+ function lowerFact<Rels extends SchemaRelations>(
169
+ core: Core<Rels>,
170
+ info: RelationInfo,
171
+ fact: Record<string, unknown>
172
+ ): LogValue[] {
173
+ return info.fields.map(function lowerCell(field) {
174
+ const raw = fact[field.name]
175
+ if (raw === undefined) {
176
+ throw errors.new(`relation ${info.name}: fact is missing field ${field.name}`)
177
+ }
178
+ let value: LogValue
179
+ if (field.closedRef !== undefined) {
180
+ if (typeof raw !== "string") {
181
+ throw errors.new(`relation ${info.name} field ${field.name}: expected a ${field.closedRef} handle name`)
182
+ }
183
+ const roster = core.descriptor.relationByName.get(field.closedRef)
184
+ const id = roster?.handles.indexOf(raw) ?? -1
185
+ if (id === -1) {
186
+ throw errors.new(`relation ${info.name} field ${field.name}: "${raw}" is not in the ${field.closedRef} roster`)
187
+ }
188
+ value = BigInt(id)
189
+ } else if (typeof raw === "object" && raw !== null && !(raw instanceof Uint8Array)) {
190
+ const interval = raw as { start?: unknown; end?: unknown }
191
+ if (typeof interval.start !== "bigint" || typeof interval.end !== "bigint") {
192
+ throw errors.new(`relation ${info.name} field ${field.name}: expected an interval of bigints`)
193
+ }
194
+ value = { start: interval.start, end: interval.end }
195
+ } else {
196
+ value = raw as LogValue
197
+ }
198
+ checkAgainst(`relation ${info.name} field ${field.name}`, field.type, value)
199
+ return value
200
+ })
201
+ }
202
+
203
+ function recorderOf<Rels extends SchemaRelations>(
204
+ core: Core<Rels>,
205
+ state: WriterState
206
+ ): { batch: LogBatch<Rels>; recording: Recording } {
207
+ const recording: Recording = { ops: [] }
208
+ function infoOf(name: string): RelationInfo {
209
+ const info = core.descriptor.relationByName.get(name)
210
+ if (info === undefined) {
211
+ throw errors.new(`relation ${name} is not a member of this theory`)
212
+ }
213
+ if (info.closed) {
214
+ throw errors.new(`relation ${name} is closed — sealed rows never change`)
215
+ }
216
+ return info
217
+ }
218
+ function record(op: "insert" | "delete", relationName: string, facts: Iterable<unknown>): void {
219
+ const info = infoOf(relationName)
220
+ const rows: LogValue[][] = []
221
+ for (const fact of facts) {
222
+ if (typeof fact !== "object" || fact === null) {
223
+ throw errors.new(`relation ${relationName}: a fact is not an object`)
224
+ }
225
+ rows.push(lowerFact(core, info, fact as Record<string, unknown>))
226
+ }
227
+ recording.ops.push({ op, relation: relationName, rows })
228
+ }
229
+ const batch: LogBatch<Rels> = {
230
+ insert(relation, facts) {
231
+ record("insert", relation.name, facts)
232
+ },
233
+ delete(relation, facts) {
234
+ record("delete", relation.name, facts)
235
+ },
236
+ reserve(relation, field, count) {
237
+ const info = infoOf(relation.name)
238
+ const ordinal = info.fields.findIndex(function byName(candidate) {
239
+ return candidate.name === field
240
+ })
241
+ const declared = info.fields[ordinal]
242
+ if (declared === undefined || !declared.fresh) {
243
+ throw errors.new(`relation ${relation.name}: field ${field} is not a fresh cell`)
244
+ }
245
+ return drawIds(state, info.id, ordinal, count)
246
+ }
247
+ }
248
+ return { batch, recording }
249
+ }
250
+
251
+ /** Runs the recording body, refilling the id-lease pool on a drained
252
+ * draw and re-running — recording is pure, so a re-run before any
253
+ * judgment invokes nothing twice that the store could observe. */
254
+ async function recordWithLeases<Rels extends SchemaRelations, R>(
255
+ core: Core<Rels>,
256
+ state: WriterState,
257
+ body: (batch: LogBatch<Rels>) => R
258
+ ): Promise<{ value: R; ops: BatchOp[] }> {
259
+ for (let attempt = 0; attempt < 16; attempt++) {
260
+ const { batch, recording } = recorderOf(core, state)
261
+ const ran = errors.trySync(function runBody() {
262
+ return body(batch)
263
+ })
264
+ if (ran.error === undefined) {
265
+ return { value: ran.data, ops: recording.ops }
266
+ }
267
+ const demand = leaseDemand.get(ran.error) ?? leaseDemand.get(errors.cause(ran.error))
268
+ if (demand === undefined) {
269
+ throw ran.error
270
+ }
271
+ await acquireLease(core, state, demand.relation, demand.field)
272
+ }
273
+ throw errors.new("the id-lease pool could not satisfy the recording after 16 refills")
274
+ }
275
+
276
+ function braidsTouched<Rels extends SchemaRelations>(
277
+ core: Core<Rels>,
278
+ ops: readonly BatchOp[]
279
+ ): Map<string, BatchOp[]> {
280
+ const partitioned = new Map<string, BatchOp[]>()
281
+ for (const op of ops) {
282
+ const info = core.descriptor.relationByName.get(op.relation)
283
+ const braid = info === undefined ? undefined : core.descriptor.braidOfRelation.get(info.id)
284
+ if (braid === undefined) {
285
+ throw errors.new(`relation ${op.relation} belongs to no braid`)
286
+ }
287
+ const bucket = partitioned.get(braid)
288
+ if (bucket === undefined) {
289
+ partitioned.set(braid, [op])
290
+ } else {
291
+ bucket.push(op)
292
+ }
293
+ }
294
+ return partitioned
295
+ }
296
+
297
+ /** The terminal contention scream: the re-judgment's own rejection names
298
+ * the hot statement and carries the offending facts' raw values; an
299
+ * accepted-but-outraced terminal loss carries the racing tip. */
300
+ function screamContention<Rels extends SchemaRelations>(
301
+ braid: string,
302
+ rejudged:
303
+ | { readonly tag: "rejected"; readonly violations: readonly Violation<Rels>[] }
304
+ | { readonly tag: "outraced"; readonly tip: bigint }
305
+ ): never {
306
+ if (rejudged.tag === "rejected") {
307
+ const violation = rejudged.violations[0]
308
+ throwContention(
309
+ {
310
+ braid,
311
+ cause: {
312
+ kind: "hot-key",
313
+ statement: violation === undefined ? "" : violation.canonical,
314
+ determinants: (violation?.facts ?? []).map(function rawOf(offending) {
315
+ return offending.fact
316
+ })
317
+ }
318
+ },
319
+ `braid ${braid}: ${LOSS_BOUND} consecutive losses at the live tip and the terminal re-judgment rejected`
320
+ )
321
+ }
322
+ throwContention(
323
+ { braid, cause: { kind: "slot-race", tip: rejudged.tip } },
324
+ `braid ${braid}: ${LOSS_BOUND} consecutive losses at the live tip outraced accepted re-judgments`
325
+ )
326
+ }
327
+
328
+ /**
329
+ * Publishes the applied pending batch: slot CAS, then the one loss path
330
+ * on Exists. A byte-equal occupant is our own ambiguous PUT, absorbed.
331
+ * Anything else carries the pending through a directory discard —
332
+ * re-persisted into the fresh sidecar before any re-judgment, so a
333
+ * crash mid-loss resolves it at the next open — re-opens to the
334
+ * current tip, and re-judges the recorded ops in one db.write: publish
335
+ * on accepted-and-state-changing, Accepted at the current generation
336
+ * on a net no-op (the publish law), or the serial Rejected. Each
337
+ * iteration races once at the then-tip; the bound counts iterations.
338
+ */
339
+ async function publishPending<Rels extends SchemaRelations>(
340
+ core: Core<Rels>,
341
+ state: WriterState,
342
+ ops: readonly BatchOp[]
343
+ ): Promise<Commit<Rels, undefined>> {
344
+ let losses = 0
345
+ for (;;) {
346
+ const pending = core.pending
347
+ if (pending === null) {
348
+ throw errors.new("publish reached with no pending batch")
349
+ }
350
+ const braid = pending.braid
351
+ const created = await core.store.putCreate(logKey(core.prefix, braid, pending.gen), pending.bytes)
352
+ let winnerBytes: Uint8Array | null = null
353
+ if (created.tag === "exists") {
354
+ const fetched = await core.store.get(logKey(core.prefix, braid, pending.gen))
355
+ if (fetched === null) {
356
+ continue
357
+ }
358
+ if (!bytesEqual(fetched.bytes, pending.bytes)) {
359
+ winnerBytes = fetched.bytes
360
+ }
361
+ }
362
+ if (winnerBytes === null) {
363
+ const header = decodeBatch(core.descriptor, pending.bytes).header
364
+ core.chain.set(braid, { g: pending.gen, prev: blake3Hex(pending.bytes), ts: header.timestamp })
365
+ await clearPending(core)
366
+ return { tag: "accepted", value: undefined, braid, generation: pending.gen, durability: "published" }
367
+ }
368
+
369
+ losses += 1
370
+ core.pendingApplied = false
371
+ await discardAndReopen(core)
372
+ const before = generationOf(core)
373
+ const rejudged = applyOps(core, ops)
374
+ if (rejudged.tag === "rejected") {
375
+ await clearPending(core)
376
+ if (losses >= LOSS_BOUND) {
377
+ screamContention(braid, { tag: "rejected", violations: rejudged.violations })
378
+ }
379
+ return { tag: "rejected", violations: rejudged.violations }
380
+ }
381
+ if (rejudged.value.generation === before) {
382
+ await clearPending(core)
383
+ return {
384
+ tag: "accepted",
385
+ value: undefined,
386
+ braid,
387
+ generation: chainEntry(core, braid).g,
388
+ durability: "published"
389
+ }
390
+ }
391
+ await readdressPending(core, ops, state.writerId)
392
+ if (losses >= LOSS_BOUND) {
393
+ screamContention(braid, { tag: "outraced", tip: chainEntry(core, braid).g })
394
+ }
395
+ }
396
+ }
397
+
398
+ /** The commit discipline (60): encode, fsync the pending, judge locally,
399
+ * publish only what advanced the generation. */
400
+ async function disciplineCommit<Rels extends SchemaRelations>(
401
+ core: Core<Rels>,
402
+ state: WriterState,
403
+ braid: string,
404
+ ops: readonly BatchOp[]
405
+ ): Promise<Commit<Rels, undefined>> {
406
+ const entry = chainEntry(core, braid)
407
+ const timestamp = maxBigint(BigInt(Date.now()), entry.ts)
408
+ const bytes = encodeBatch(
409
+ core.descriptor,
410
+ {
411
+ fingerprint: core.descriptor.fingerprint,
412
+ braid,
413
+ braidGen: entry.g + 1n,
414
+ prev: entry.prev,
415
+ writer: state.writerId,
416
+ timestamp
417
+ },
418
+ ops
419
+ )
420
+ core.pending = { braid, gen: entry.g + 1n, bytes }
421
+ core.pendingOps = ops
422
+ core.pendingApplied = false
423
+ await persistSidecar(core)
424
+
425
+ const before = generationOf(core)
426
+ const outcome = applyOps(core, ops)
427
+ if (outcome.tag === "rejected") {
428
+ await clearPending(core)
429
+ return { tag: "rejected", violations: outcome.violations }
430
+ }
431
+ if (outcome.value.generation === before) {
432
+ await clearPending(core)
433
+ return { tag: "accepted", value: undefined, braid, generation: entry.g, durability: "published" }
434
+ }
435
+ core.pendingApplied = true
436
+ return publishPending(core, state, ops)
437
+ }
438
+
439
+ /** A recovered pending owed from a previous life publishes before any new commit. */
440
+ async function settleInheritedPending<Rels extends SchemaRelations>(
441
+ core: Core<Rels>,
442
+ state: WriterState
443
+ ): Promise<void> {
444
+ if (core.pending === null || !core.pendingApplied || core.pendingOps === null) {
445
+ return
446
+ }
447
+ await publishPending(core, state, core.pendingOps)
448
+ }
449
+
450
+ function openWriter<Rels extends SchemaRelations>(replica: Replica<Rels>): Writer<Rels> {
451
+ const core = coreOf(replica)
452
+ const state: WriterState = {
453
+ writerId: crypto.randomBytes(8).readBigUInt64LE(),
454
+ pools: new Map()
455
+ }
456
+ return {
457
+ async commit(body) {
458
+ return withGate(core, async function commitBody() {
459
+ await settleInheritedPending(core, state)
460
+ const recorded = await recordWithLeases(core, state, body)
461
+ if (recorded.ops.length === 0) {
462
+ throw errors.new("commit recorded no ops — an empty transaction names no braid")
463
+ }
464
+ const partitioned = braidsTouched(core, recorded.ops)
465
+ if (partitioned.size > 1) {
466
+ throw errors.wrap(ErrSpanningCommit, `the recorded ops span braids ${[...partitioned.keys()].join(", ")}`)
467
+ }
468
+ const [braid, ops] = [...partitioned.entries()][0] as [string, BatchOp[]]
469
+ const outcome = await disciplineCommit(core, state, braid, ops)
470
+ if (outcome.tag === "rejected") {
471
+ return outcome
472
+ }
473
+ return { ...outcome, value: recorded.value }
474
+ })
475
+ },
476
+
477
+ async commitSplit(body) {
478
+ return withGate(core, async function splitBody() {
479
+ await settleInheritedPending(core, state)
480
+ const recorded = await recordWithLeases(core, state, body)
481
+ if (recorded.ops.length === 0) {
482
+ throw errors.new("commitSplit recorded no ops — an empty transaction names no braid")
483
+ }
484
+ const partitioned = braidsTouched(core, recorded.ops)
485
+ const outcomes: BraidOutcome<Rels>[] = []
486
+ for (const [braid, ops] of partitioned) {
487
+ const outcome = await disciplineCommit(core, state, braid, ops)
488
+ if (outcome.tag === "rejected") {
489
+ outcomes.push({ tag: "rejected", braid, violations: outcome.violations })
490
+ } else {
491
+ outcomes.push({
492
+ tag: "accepted",
493
+ braid: outcome.braid,
494
+ generation: outcome.generation,
495
+ durability: outcome.durability
496
+ })
497
+ }
498
+ }
499
+ return { value: recorded.value, outcomes }
500
+ })
501
+ }
502
+ }
503
+ }
504
+
505
+ export type { BraidOutcome, Commit, CommitSplit, Durability, LogBatch, Writer }
506
+ export { openWriter }