@bjornpagen/bumbledb-log 0.20.0 → 0.20.2
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/dist/braids.d.ts +28 -0
- package/dist/braids.d.ts.map +1 -0
- package/{src/braids.ts → dist/braids.js} +22 -29
- package/dist/braids.js.map +1 -0
- package/dist/bytes.d.ts +26 -0
- package/dist/bytes.d.ts.map +1 -0
- package/dist/bytes.js +86 -0
- package/dist/bytes.js.map +1 -0
- package/dist/chain.d.ts +56 -0
- package/dist/chain.d.ts.map +1 -0
- package/dist/chain.js +137 -0
- package/dist/chain.js.map +1 -0
- package/dist/codec.d.ts +71 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/codec.js +179 -0
- package/dist/codec.js.map +1 -0
- package/dist/descriptor.d.ts +61 -0
- package/dist/descriptor.d.ts.map +1 -0
- package/dist/descriptor.js +160 -0
- package/dist/descriptor.js.map +1 -0
- package/dist/errors.d.ts +202 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +122 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +52 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +166 -0
- package/dist/keys.js.map +1 -0
- package/dist/manifest.d.ts +42 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +116 -0
- package/dist/manifest.js.map +1 -0
- package/dist/replica.d.ts +154 -0
- package/dist/replica.d.ts.map +1 -0
- package/dist/replica.js +820 -0
- package/dist/replica.js.map +1 -0
- package/dist/store-s3.d.ts +31 -0
- package/dist/store-s3.d.ts.map +1 -0
- package/dist/store-s3.js +299 -0
- package/dist/store-s3.js.map +1 -0
- package/dist/store.d.ts +129 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +577 -0
- package/dist/store.js.map +1 -0
- package/dist/tenants.d.ts +56 -0
- package/dist/tenants.d.ts.map +1 -0
- package/dist/tenants.js +333 -0
- package/dist/tenants.js.map +1 -0
- package/dist/tilde-family.json +21 -0
- package/dist/vector.d.ts +31 -0
- package/dist/vector.d.ts.map +1 -0
- package/dist/vector.js +87 -0
- package/dist/vector.js.map +1 -0
- package/dist/writer.d.ts +106 -0
- package/dist/writer.d.ts.map +1 -0
- package/dist/writer.js +677 -0
- package/dist/writer.js.map +1 -0
- package/package.json +12 -7
- package/src/bytes.ts +0 -115
- package/src/chain.ts +0 -181
- package/src/codec.ts +0 -252
- package/src/descriptor.ts +0 -240
- package/src/errors.ts +0 -309
- package/src/index.ts +0 -57
- package/src/keys.ts +0 -218
- package/src/manifest.ts +0 -161
- package/src/replica.ts +0 -1061
- package/src/store-s3.ts +0 -377
- package/src/store.ts +0 -727
- package/src/tenants.ts +0 -414
- package/src/vector.ts +0 -103
- package/src/writer.ts +0 -977
package/dist/writer.js
ADDED
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The writer (60): a replica plus the right to create log objects. Role
|
|
3
|
+
* is a field on the handle. `openWriter(options)` births the store;
|
|
4
|
+
* `openWriter(replica)` wraps a born replica and settles an inherited
|
|
5
|
+
* pending without drawing id leases. A replica never births —
|
|
6
|
+
* ManifestMissing is its refusal. One commit path and one loss path: a
|
|
7
|
+
* lost slot's byte-equal occupant is an ambiguous PUT absorbed; anything
|
|
8
|
+
* else discards the local directory, re-opens through the replica to the
|
|
9
|
+
* current tip, and re-judges the recorded ops once — the verdict IS a
|
|
10
|
+
* serial execution, performed. Each loop iteration races once at the
|
|
11
|
+
* then-tip, so a historical loss is structurally uncountable, and bounded
|
|
12
|
+
* live-tip losses surface as ErrContention carrying the terminal
|
|
13
|
+
* re-judgment's own violation or the racing tip.
|
|
14
|
+
*/
|
|
15
|
+
import * as crypto from "node:crypto";
|
|
16
|
+
import * as fs from "node:fs/promises";
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
import { internalBlake3, rowOf } from "@bjornpagen/bumbledb";
|
|
19
|
+
import * as errors from "@superbuilders/errors";
|
|
20
|
+
import { regex } from "arkregex";
|
|
21
|
+
import { bytesEqual, checkedAddU64, digest32, utf8Encoder, utf8StrictDecoder } from "#bytes.ts";
|
|
22
|
+
import { chainSum } from "#chain.ts";
|
|
23
|
+
import { decodeBatch, encodeBatch } from "#codec.ts";
|
|
24
|
+
import { descriptorOf } from "#descriptor.ts";
|
|
25
|
+
import { ErrRefillNeeded, ErrSpanningCommit, refillNeededOf, refuse, refuseExhausted, refuseOverWidth, refuseSlotRetired, throwContention, throwRefillNeeded } from "#errors.ts";
|
|
26
|
+
import { CKPT_SCRATCH_LEASE, checkpointMdbKey, ckptDocKey, encodeCkptScratch, generation, idsKey, LEASE_NAMESPACE, logKey, manifestKey } from "#keys.ts";
|
|
27
|
+
import { checkpointVector, parseCheckpoint, parseManifest, renderCheckpoint, renderManifest } from "#manifest.ts";
|
|
28
|
+
import { applyOps, belowFloor, chainEntry, clearPending, coreOf, discardAndReopen, entriesOf, foldPending, generationOf, holdPending, maxBigint, openReplica, pendingOf, persistSidecar, readdressPending, withGate } from "#replica.ts";
|
|
29
|
+
/** 10 owns the width: one CAS amortizes counter traffic 4096× below slot traffic. */
|
|
30
|
+
const LEASE_WIDTH = 4096n;
|
|
31
|
+
/** The counter body's one grammar (`conformance/v3/counter/`): a
|
|
32
|
+
* canonical u64 decimal — no sign, no leading zero, no trailing bytes. */
|
|
33
|
+
const ID_LEASE_COUNTER = regex("^(?:0|[1-9][0-9]*)$");
|
|
34
|
+
const U64_MAX = 0xffffffffffffffffn;
|
|
35
|
+
/** The live-loss bound (60): consecutive losses at the live tip, history never counts. */
|
|
36
|
+
const LOSS_BOUND = 16;
|
|
37
|
+
/** Writer id in the fixed-layout header: magic + version + flags + fingerprint + braid + braid_gen + prev. */
|
|
38
|
+
const WRITER_AT = 4 + 2 + 2 + 32 + 4 + 8 + 32;
|
|
39
|
+
const BATCH_MAGIC = utf8Encoder.encode("BDBL");
|
|
40
|
+
function screamOf(context) {
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
let attempts = 0;
|
|
43
|
+
return {
|
|
44
|
+
attempt(signature) {
|
|
45
|
+
attempts += 1;
|
|
46
|
+
if (seen.has(signature)) {
|
|
47
|
+
console.error(`bumbledb-log alarm: ${context} repair signature recurs: ${signature}`);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
seen.add(signature);
|
|
51
|
+
}
|
|
52
|
+
if (attempts % 8 === 0) {
|
|
53
|
+
console.error(`bumbledb-log warning: ${context} repair attempt ${attempts}: ${signature}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function poolKey(relation, field) {
|
|
59
|
+
return `${relation}:${field}`;
|
|
60
|
+
}
|
|
61
|
+
function remainingOf(state, relation, field) {
|
|
62
|
+
const pool = state.pools.get(poolKey(relation, field)) ?? [];
|
|
63
|
+
let remaining = 0n;
|
|
64
|
+
for (const range of pool) {
|
|
65
|
+
remaining += range.end - range.next;
|
|
66
|
+
}
|
|
67
|
+
return remaining;
|
|
68
|
+
}
|
|
69
|
+
/** The zero draw is the absence of a range (the engine's ruling). */
|
|
70
|
+
const EMPTY_RANGE = Object.freeze({
|
|
71
|
+
empty: true,
|
|
72
|
+
count: 0n,
|
|
73
|
+
at(_index) {
|
|
74
|
+
return undefined;
|
|
75
|
+
},
|
|
76
|
+
*[Symbol.iterator]() { }
|
|
77
|
+
});
|
|
78
|
+
/** A drawn range as the engine's FreshRange value: contiguous, frozen. */
|
|
79
|
+
function drawnRange(start, endExclusive) {
|
|
80
|
+
const count = endExclusive - start;
|
|
81
|
+
return Object.freeze({
|
|
82
|
+
empty: false,
|
|
83
|
+
start,
|
|
84
|
+
endExclusive,
|
|
85
|
+
count,
|
|
86
|
+
at(index) {
|
|
87
|
+
if (index < 0n || index >= count) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
return start + index;
|
|
91
|
+
},
|
|
92
|
+
*[Symbol.iterator]() {
|
|
93
|
+
for (let id = start; id < endExclusive; id++) {
|
|
94
|
+
yield id;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/** Untouched full-width blocks pooled for the key: the record loop's
|
|
100
|
+
* guarantee unit — k full blocks serve any k draws, each at most one
|
|
101
|
+
* width, whatever tails the draws abandon. */
|
|
102
|
+
function fullBlocksOf(state, relation, field) {
|
|
103
|
+
const pool = state.pools.get(poolKey(relation, field)) ?? [];
|
|
104
|
+
let full = 0;
|
|
105
|
+
for (const range of pool) {
|
|
106
|
+
if (range.end - range.next >= LEASE_WIDTH) {
|
|
107
|
+
full += 1;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return full;
|
|
111
|
+
}
|
|
112
|
+
/** `Lease.draw(count)` = OverWidth | Exhausted | Drawn, the log-Rust
|
|
113
|
+
* algebra: Exhausted names the u64 ceiling ONLY; a draw the pooled
|
|
114
|
+
* tail cannot serve abandons the tail (unique, never dense) and falls
|
|
115
|
+
* to the next block, and an empty pool signals a refill. */
|
|
116
|
+
function drawIds(state, draws, relation, field, count) {
|
|
117
|
+
if (count < 0n) {
|
|
118
|
+
throw errors.new(`id-lease count is unsigned: ${count}`);
|
|
119
|
+
}
|
|
120
|
+
if (count > LEASE_WIDTH) {
|
|
121
|
+
refuseOverWidth({ requested: count }, `id-lease draw ${count} exceeds the lease width ${LEASE_WIDTH}`);
|
|
122
|
+
}
|
|
123
|
+
if (count === 0n) {
|
|
124
|
+
return EMPTY_RANGE;
|
|
125
|
+
}
|
|
126
|
+
const key = poolKey(relation, field);
|
|
127
|
+
const drawn = (draws.get(key) ?? 0) + 1;
|
|
128
|
+
draws.set(key, drawn);
|
|
129
|
+
const pool = state.pools.get(key) ?? [];
|
|
130
|
+
while (pool.length > 0) {
|
|
131
|
+
const range = pool[0];
|
|
132
|
+
if (range === undefined) {
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
const end = checkedAddU64(range.next, count);
|
|
136
|
+
if (end === undefined) {
|
|
137
|
+
refuseExhausted({ relation, field }, `id-lease relation ${relation} field ${field} would leave u64`);
|
|
138
|
+
}
|
|
139
|
+
if (end <= range.end) {
|
|
140
|
+
const start = range.next;
|
|
141
|
+
range.next = end;
|
|
142
|
+
if (range.next >= range.end) {
|
|
143
|
+
pool.shift();
|
|
144
|
+
}
|
|
145
|
+
return drawnRange(start, end);
|
|
146
|
+
}
|
|
147
|
+
pool.shift();
|
|
148
|
+
}
|
|
149
|
+
throwRefillNeeded({ relation, field, requested: count }, `id-lease relation ${relation} field ${field} pool cannot serve ${count}`);
|
|
150
|
+
}
|
|
151
|
+
/** The counter parse: canonical decimal within u64 or a typed Counter
|
|
152
|
+
* refusal — leading zeros, signs, trailing bytes, non-UTF-8, and
|
|
153
|
+
* values past u64 all refuse under the one pinned identity. */
|
|
154
|
+
function parseCounter(key, bytes) {
|
|
155
|
+
const decoded = errors.trySync(function decodeCounter() {
|
|
156
|
+
return utf8StrictDecoder.decode(bytes);
|
|
157
|
+
});
|
|
158
|
+
if (decoded.error) {
|
|
159
|
+
refuse({ kind: "Counter", key }, `id-lease counter ${key} body is not UTF-8`);
|
|
160
|
+
}
|
|
161
|
+
if (!ID_LEASE_COUNTER.test(decoded.data)) {
|
|
162
|
+
refuse({ kind: "Counter", key }, `id-lease counter ${key} body is not a canonical decimal`);
|
|
163
|
+
}
|
|
164
|
+
const value = BigInt(decoded.data);
|
|
165
|
+
if (value > U64_MAX) {
|
|
166
|
+
refuse({ kind: "Counter", key }, `id-lease counter ${key} value leaves u64`);
|
|
167
|
+
}
|
|
168
|
+
return value;
|
|
169
|
+
}
|
|
170
|
+
/** `ids/{relation}/{field}`: birth claims [0, width); every later lease CAS-increments. */
|
|
171
|
+
async function acquireLease(core, state, relation, field) {
|
|
172
|
+
const key = idsKey(core.prefix, relation, field);
|
|
173
|
+
for (;;) {
|
|
174
|
+
const fetched = await core.store.get(key);
|
|
175
|
+
if (fetched === null) {
|
|
176
|
+
const created = await core.store.putCreate(key, utf8Encoder.encode(String(LEASE_WIDTH)));
|
|
177
|
+
if (created.tag === "created") {
|
|
178
|
+
pushRange(state, relation, field, 0n, LEASE_WIDTH);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const next = parseCounter(key, fetched.bytes);
|
|
184
|
+
const end = checkedAddU64(next, LEASE_WIDTH);
|
|
185
|
+
if (end === undefined) {
|
|
186
|
+
refuseExhausted({ relation, field }, `id-lease relation ${relation} field ${field} would leave u64`);
|
|
187
|
+
}
|
|
188
|
+
const swapped = await core.store.putSwap(key, utf8Encoder.encode(String(end)), fetched.etag);
|
|
189
|
+
if (swapped.tag === "swapped") {
|
|
190
|
+
pushRange(state, relation, field, next, end);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function pushRange(state, relation, field, next, end) {
|
|
196
|
+
const pool = state.pools.get(poolKey(relation, field)) ?? [];
|
|
197
|
+
pool.push({ next, end });
|
|
198
|
+
state.pools.set(poolKey(relation, field), pool);
|
|
199
|
+
}
|
|
200
|
+
async function ensureFreshLeases(core, state) {
|
|
201
|
+
for (const info of core.descriptor.relations) {
|
|
202
|
+
if (info.closed) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
for (const [ordinal, field] of info.fields.entries()) {
|
|
206
|
+
if (field.fresh && remainingOf(state, info.id, ordinal) === 0n) {
|
|
207
|
+
await acquireLease(core, state, info.id, ordinal);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function recorderOf(core, state, draws) {
|
|
213
|
+
const recording = { ops: [] };
|
|
214
|
+
function infoOf(name) {
|
|
215
|
+
const info = core.descriptor.relationByName.get(name);
|
|
216
|
+
if (info === undefined) {
|
|
217
|
+
throw errors.new(`relation ${name} is not a member of this theory`);
|
|
218
|
+
}
|
|
219
|
+
if (info.closed) {
|
|
220
|
+
throw errors.new(`relation ${name} is closed — sealed rows never change`);
|
|
221
|
+
}
|
|
222
|
+
return info;
|
|
223
|
+
}
|
|
224
|
+
// The cell judge is the engine's `rowOf` — one marshal per language.
|
|
225
|
+
// The recorder applies nothing, so `changed` is 0n: change is judged
|
|
226
|
+
// at commit, when the recording meets the store.
|
|
227
|
+
function record(op, relation, facts) {
|
|
228
|
+
const info = infoOf(relation.name);
|
|
229
|
+
const rows = [];
|
|
230
|
+
for (const fact of facts) {
|
|
231
|
+
rows.push(rowOf(relation.data, fact));
|
|
232
|
+
}
|
|
233
|
+
recording.ops.push({ op, relation: info.name, rows });
|
|
234
|
+
return Object.freeze({ submitted: BigInt(rows.length), changed: 0n });
|
|
235
|
+
}
|
|
236
|
+
const batch = {
|
|
237
|
+
insert(relation, facts) {
|
|
238
|
+
return record("insert", relation, facts);
|
|
239
|
+
},
|
|
240
|
+
delete(relation, facts) {
|
|
241
|
+
return record("delete", relation, facts);
|
|
242
|
+
},
|
|
243
|
+
reserve(relation, field, count) {
|
|
244
|
+
const info = infoOf(relation.name);
|
|
245
|
+
const ordinal = info.fields.findIndex(function byName(candidate) {
|
|
246
|
+
return candidate.name === field;
|
|
247
|
+
});
|
|
248
|
+
const declared = info.fields[ordinal];
|
|
249
|
+
if (declared === undefined || !declared.fresh) {
|
|
250
|
+
throw errors.new(`relation ${relation.name}: field ${field} is not a fresh cell`);
|
|
251
|
+
}
|
|
252
|
+
return drawIds(state, draws, info.id, ordinal, count);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
return { batch, recording };
|
|
256
|
+
}
|
|
257
|
+
/** Runs the recording body against the cached pool. A draw the pool
|
|
258
|
+
* cannot serve discards the attempt's recording, leases fresh blocks —
|
|
259
|
+
* one full block per draw the attempt made on the starved key — and
|
|
260
|
+
* runs the body again: the refill is a path, not an exhaustion
|
|
261
|
+
* (Exhausted names the u64 ceiling only). Ids drawn by a discarded
|
|
262
|
+
* attempt are abandoned — unique, never dense. Draw counts rise
|
|
263
|
+
* strictly between refills of one key, so a body with finitely many
|
|
264
|
+
* draws settles; a recurring signature screams. */
|
|
265
|
+
async function recordWithLeases(core, state, body) {
|
|
266
|
+
await ensureFreshLeases(core, state);
|
|
267
|
+
for (;;) {
|
|
268
|
+
const draws = new Map();
|
|
269
|
+
const { batch, recording } = recorderOf(core, state, draws);
|
|
270
|
+
const ran = await errors.try((async function runBody() {
|
|
271
|
+
return body(batch);
|
|
272
|
+
})());
|
|
273
|
+
if (!ran.error) {
|
|
274
|
+
return { value: ran.data, ops: recording.ops };
|
|
275
|
+
}
|
|
276
|
+
if (!errors.is(ran.error, ErrRefillNeeded)) {
|
|
277
|
+
throw ran.error;
|
|
278
|
+
}
|
|
279
|
+
const need = refillNeededOf(ran.error);
|
|
280
|
+
if (need === undefined) {
|
|
281
|
+
throw ran.error;
|
|
282
|
+
}
|
|
283
|
+
// Draws this attempt made on the starved key, the missed one included.
|
|
284
|
+
const drawn = draws.get(poolKey(need.relation, need.field)) ?? 0;
|
|
285
|
+
state.scream.attempt(`id-lease refill relation ${need.relation} field ${need.field} draws ${drawn}`);
|
|
286
|
+
while (fullBlocksOf(state, need.relation, need.field) < drawn) {
|
|
287
|
+
await acquireLease(core, state, need.relation, need.field);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
function braidsTouched(core, ops) {
|
|
292
|
+
const partitioned = new Map();
|
|
293
|
+
for (const op of ops) {
|
|
294
|
+
const info = core.descriptor.relationByName.get(op.relation);
|
|
295
|
+
const braid = info === undefined ? undefined : core.descriptor.braidOfRelation.get(info.id);
|
|
296
|
+
if (braid === undefined) {
|
|
297
|
+
throw errors.new(`relation ${op.relation} belongs to no braid`);
|
|
298
|
+
}
|
|
299
|
+
const bucket = partitioned.get(braid);
|
|
300
|
+
if (bucket === undefined) {
|
|
301
|
+
partitioned.set(braid, [op]);
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
bucket.push(op);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return partitioned;
|
|
308
|
+
}
|
|
309
|
+
function u64leAt(bytes, at) {
|
|
310
|
+
if (bytes.length < at + 8) {
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset + at, 8);
|
|
314
|
+
return view.getBigUint64(0, true);
|
|
315
|
+
}
|
|
316
|
+
/** The usurper is a fact in the header. A body that refuses to decode
|
|
317
|
+
* does not hide the slot's owner. The Rust writer machine sniffs the
|
|
318
|
+
* same fixed offset (`writer/loss.rs`); the batch grammar itself has
|
|
319
|
+
* one reader — the codec seat. */
|
|
320
|
+
function headerWriter(bytes) {
|
|
321
|
+
if (bytes.length < BATCH_MAGIC.length || !bytesEqual(bytes.subarray(0, BATCH_MAGIC.length), BATCH_MAGIC)) {
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
return u64leAt(bytes, WRITER_AT);
|
|
325
|
+
}
|
|
326
|
+
/** Header prev is 32 branded bytes. */
|
|
327
|
+
function digestPrev(prev) {
|
|
328
|
+
return digest32(prev);
|
|
329
|
+
}
|
|
330
|
+
function noteDeposition(state, braid, slot, winnerBytes) {
|
|
331
|
+
if (state.deposition !== null) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const usurper = headerWriter(winnerBytes);
|
|
335
|
+
if (usurper === undefined) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
state.deposition = { braid, slot, resident: state.writerId, usurper };
|
|
339
|
+
}
|
|
340
|
+
/** The terminal contention scream: the re-judgment's own rejection names
|
|
341
|
+
* the hot statement and carries the offending facts' raw values; an
|
|
342
|
+
* accepted-but-outraced terminal loss carries the racing tip. A
|
|
343
|
+
* rejection without a violation is refused — the empty statement is
|
|
344
|
+
* unrepresentable. */
|
|
345
|
+
function screamContention(braid, rejudged) {
|
|
346
|
+
if (rejudged.tag === "rejected") {
|
|
347
|
+
const violation = rejudged.violations[0];
|
|
348
|
+
if (violation === undefined) {
|
|
349
|
+
throw errors.new("a rejection carries at least one violation");
|
|
350
|
+
}
|
|
351
|
+
throwContention({
|
|
352
|
+
braid,
|
|
353
|
+
cause: {
|
|
354
|
+
kind: "hot-key",
|
|
355
|
+
statement: violation.canonical,
|
|
356
|
+
determinants: violation.facts.map(function rawOf(offending) {
|
|
357
|
+
return offending.fact;
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
}, `braid ${braid}: ${LOSS_BOUND} consecutive losses at the live tip and the terminal re-judgment rejected`);
|
|
361
|
+
}
|
|
362
|
+
throwContention({ braid, cause: { kind: "slot-race", tip: rejudged.tip } }, `braid ${braid}: ${LOSS_BOUND} consecutive losses at the live tip outraced accepted re-judgments`);
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Publishes the applied pending batch: refuse a below-floor create,
|
|
366
|
+
* then slot CAS, then the one loss path on Exists. A byte-equal
|
|
367
|
+
* occupant is our own ambiguous PUT, absorbed. Anything else carries
|
|
368
|
+
* the pending through a directory discard — re-persisted into the
|
|
369
|
+
* fresh sidecar before any re-judgment — re-opens to the current tip,
|
|
370
|
+
* and re-judges the recorded ops in one db.write. An occupant that
|
|
371
|
+
* then vanishes is retired, not a loop that forges the swept slot.
|
|
372
|
+
*/
|
|
373
|
+
async function publishPending(core, state, ops) {
|
|
374
|
+
let losses = 0;
|
|
375
|
+
for (;;) {
|
|
376
|
+
const pending = pendingOf(core);
|
|
377
|
+
if (pending === null) {
|
|
378
|
+
throw errors.new("publish reached with no pending batch");
|
|
379
|
+
}
|
|
380
|
+
const braid = pending.braid;
|
|
381
|
+
// The floor is a write precondition: a slot at or below it is
|
|
382
|
+
// retired. A create must not touch the store (70/116/127).
|
|
383
|
+
if (belowFloor(core, braid, pending.slot)) {
|
|
384
|
+
refuseSlotRetired({ braid, slot: pending.slot }, "the slot is retired");
|
|
385
|
+
}
|
|
386
|
+
const created = await core.store.putCreate(logKey(core.prefix, braid, pending.slot), pending.bytes);
|
|
387
|
+
let winnerBytes = null;
|
|
388
|
+
if (created.tag !== "created") {
|
|
389
|
+
const fetched = await core.store.get(logKey(core.prefix, braid, pending.slot));
|
|
390
|
+
if (fetched === null) {
|
|
391
|
+
// Exists then null: the occupant was swept. Refuse
|
|
392
|
+
// rather than loop back into putCreate.
|
|
393
|
+
refuseSlotRetired({ braid, slot: pending.slot }, "the slot is retired");
|
|
394
|
+
}
|
|
395
|
+
if (!bytesEqual(fetched.bytes, pending.bytes)) {
|
|
396
|
+
winnerBytes = fetched.bytes;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (winnerBytes === null) {
|
|
400
|
+
if (pending.ts === null) {
|
|
401
|
+
throw errors.new("publish reached with an undecoded pending timestamp");
|
|
402
|
+
}
|
|
403
|
+
entriesOf(core).set(braid, {
|
|
404
|
+
g: pending.slot,
|
|
405
|
+
prev: digest32(new Uint8Array(internalBlake3(pending.bytes))),
|
|
406
|
+
ts: pending.ts
|
|
407
|
+
});
|
|
408
|
+
await clearPending(core);
|
|
409
|
+
return { tag: "accepted", value: { slot: pending.slot, durability: "published" } };
|
|
410
|
+
}
|
|
411
|
+
losses += 1;
|
|
412
|
+
state.scream.attempt("slot occupant is not ours");
|
|
413
|
+
noteDeposition(state, braid, pending.slot, winnerBytes);
|
|
414
|
+
await discardAndReopen(core);
|
|
415
|
+
const before = generationOf(core);
|
|
416
|
+
const rejudged = applyOps(core, ops);
|
|
417
|
+
if (rejudged.tag === "rejected") {
|
|
418
|
+
await clearPending(core);
|
|
419
|
+
if (losses >= LOSS_BOUND) {
|
|
420
|
+
screamContention(braid, { tag: "rejected", violations: rejudged.violations });
|
|
421
|
+
}
|
|
422
|
+
return { tag: "rejected", violations: rejudged.violations };
|
|
423
|
+
}
|
|
424
|
+
if (rejudged.value.generation === before) {
|
|
425
|
+
await clearPending(core);
|
|
426
|
+
return { tag: "accepted", value: { slot: chainEntry(core, braid).g, durability: "published" } };
|
|
427
|
+
}
|
|
428
|
+
const tip = chainEntry(core, braid);
|
|
429
|
+
entriesOf(core).set(braid, { g: tip.g, prev: digestPrev(tip.prev), ts: tip.ts });
|
|
430
|
+
await readdressPending(core, ops, state.writerId);
|
|
431
|
+
if (losses >= LOSS_BOUND) {
|
|
432
|
+
screamContention(braid, { tag: "outraced", tip: chainEntry(core, braid).g });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
/** The commit discipline (60): encode, fsync as Pending before any
|
|
437
|
+
* apply, judge locally, publish only what advanced the generation.
|
|
438
|
+
* Settled is written only after the verdict. */
|
|
439
|
+
async function disciplineCommit(core, state, braid, ops) {
|
|
440
|
+
const entry = chainEntry(core, braid);
|
|
441
|
+
const timestamp = maxBigint(BigInt(Date.now()), entry.ts);
|
|
442
|
+
const bytes = encodeBatch(core.descriptor, {
|
|
443
|
+
braid,
|
|
444
|
+
braidGen: generation(entry.g + 1n),
|
|
445
|
+
prev: digestPrev(entry.prev),
|
|
446
|
+
writer: state.writerId,
|
|
447
|
+
timestamp
|
|
448
|
+
}, ops);
|
|
449
|
+
// Pending → durable, before any apply.
|
|
450
|
+
holdPending(core, { braid, slot: generation(entry.g + 1n), bytes }, ops, timestamp);
|
|
451
|
+
await persistSidecar(core);
|
|
452
|
+
const before = generationOf(core);
|
|
453
|
+
const outcome = applyOps(core, ops);
|
|
454
|
+
if (outcome.tag === "rejected") {
|
|
455
|
+
await clearPending(core);
|
|
456
|
+
return { tag: "rejected", violations: outcome.violations };
|
|
457
|
+
}
|
|
458
|
+
if (outcome.value.generation === before) {
|
|
459
|
+
await clearPending(core);
|
|
460
|
+
return { tag: "accepted", value: { slot: entry.g, durability: "published" } };
|
|
461
|
+
}
|
|
462
|
+
return publishPending(core, state, ops);
|
|
463
|
+
}
|
|
464
|
+
/** An inherited pending is resolved-and-published by open. A slot the
|
|
465
|
+
* floor already covers is published (`Clear`), not re-judged (46). */
|
|
466
|
+
async function settleInheritedPending(core, state) {
|
|
467
|
+
const pending = pendingOf(core);
|
|
468
|
+
if (pending === null) {
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (foldPending(chainSum(core.chain), generationOf(core), null, pending.bytes, belowFloor(core, pending.braid, pending.slot)).tag === "below-floor") {
|
|
472
|
+
await clearPending(core);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
let ops = pending.ops;
|
|
476
|
+
if (ops === null) {
|
|
477
|
+
const decoded = errors.trySync(function decodePending() {
|
|
478
|
+
return decodeBatch(core.descriptor, pending.bytes);
|
|
479
|
+
});
|
|
480
|
+
if (decoded.error) {
|
|
481
|
+
await clearPending(core);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
ops = decoded.data.ops;
|
|
485
|
+
// The decoded arm re-holds so publish reads the header facts once.
|
|
486
|
+
holdPending(core, { braid: pending.braid, slot: pending.slot, bytes: pending.bytes }, ops, decoded.data.header.timestamp);
|
|
487
|
+
}
|
|
488
|
+
const published = await publishPending(core, state, ops);
|
|
489
|
+
if (published.tag === "rejected") {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
async function putCreateOnce(store, key, bytes) {
|
|
494
|
+
for (;;) {
|
|
495
|
+
const created = await store.putCreate(key, bytes);
|
|
496
|
+
if (created.tag === "created" || created.tag === "exists") {
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const fetched = await store.get(key);
|
|
500
|
+
if (fetched !== null) {
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
/** The loser deletes its own `ckpt/{digest}` and `.mdb`. */
|
|
506
|
+
async function deleteOrphan(store, prefix, digest) {
|
|
507
|
+
await store.delete(ckptDocKey(prefix, digest));
|
|
508
|
+
await store.delete(checkpointMdbKey(prefix, digest));
|
|
509
|
+
}
|
|
510
|
+
function scratchPath(dir) {
|
|
511
|
+
return path.join(dir, LEASE_NAMESPACE, CKPT_SCRATCH_LEASE);
|
|
512
|
+
}
|
|
513
|
+
async function claimScratch(dir, digest) {
|
|
514
|
+
const target = scratchPath(dir);
|
|
515
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
516
|
+
const handle = await fs.open(target, "w");
|
|
517
|
+
const written = await errors.try((async function writeLease() {
|
|
518
|
+
await handle.writeFile(encodeCkptScratch(digest));
|
|
519
|
+
await handle.sync();
|
|
520
|
+
})());
|
|
521
|
+
await handle.close();
|
|
522
|
+
if (written.error) {
|
|
523
|
+
await fs.rm(target, { force: true });
|
|
524
|
+
throw written.error;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
async function releaseScratch(dir) {
|
|
528
|
+
await fs.rm(scratchPath(dir), { force: true });
|
|
529
|
+
}
|
|
530
|
+
async function casPublish(store, prefix, codec, candidate, digest, bytes) {
|
|
531
|
+
await putCreateOnce(store, ckptDocKey(prefix, digest), bytes);
|
|
532
|
+
for (;;) {
|
|
533
|
+
const fetched = await store.get(manifestKey(prefix));
|
|
534
|
+
if (fetched === null) {
|
|
535
|
+
return { tag: "refused", reason: "manifest-missing" };
|
|
536
|
+
}
|
|
537
|
+
const parsed = errors.trySync(function parse() {
|
|
538
|
+
return parseManifest(fetched.bytes);
|
|
539
|
+
});
|
|
540
|
+
if (parsed.error) {
|
|
541
|
+
return { tag: "refused", reason: "manifest" };
|
|
542
|
+
}
|
|
543
|
+
const incumbent = parsed.data.checkpoint;
|
|
544
|
+
if (incumbent !== null && bytesEqual(incumbent, digest)) {
|
|
545
|
+
return { tag: "replaced" };
|
|
546
|
+
}
|
|
547
|
+
if (incumbent !== null) {
|
|
548
|
+
const doc = await store.get(ckptDocKey(prefix, incumbent));
|
|
549
|
+
if (doc === null) {
|
|
550
|
+
return { tag: "refused", reason: "checkpoint-doc-missing" };
|
|
551
|
+
}
|
|
552
|
+
const incumbentDoc = errors.trySync(function parseIncumbent() {
|
|
553
|
+
return parseCheckpoint(codec, doc.bytes);
|
|
554
|
+
});
|
|
555
|
+
if (incumbentDoc.error) {
|
|
556
|
+
return { tag: "refused", reason: "checkpoint" };
|
|
557
|
+
}
|
|
558
|
+
if (checkpointVector(candidate).order(checkpointVector(incumbentDoc.data)) !== "after") {
|
|
559
|
+
return { tag: "kept", incumbent };
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
const next = renderManifest({ fingerprint: parsed.data.fingerprint, checkpoint: digest });
|
|
563
|
+
const swapped = await store.putSwap(manifestKey(prefix), next, fetched.etag);
|
|
564
|
+
if (swapped.tag === "swapped") {
|
|
565
|
+
return { tag: "replaced" };
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Publishes `candidate` under the checkpoint order. The digest is the
|
|
571
|
+
* blake3 of the full bytes, `prev` included. A scratch lease
|
|
572
|
+
* `{dir}/~lease/ckpt-scratch` names the candidate for the publish
|
|
573
|
+
* window; a crash leaves that lease and a successor sweep reclaims
|
|
574
|
+
* it. `Kept` and every refused publish delete the candidate's `ckpt`
|
|
575
|
+
* pair.
|
|
576
|
+
*/
|
|
577
|
+
async function publishCheckpoint(store, prefix, dir, theory, candidate, mdb) {
|
|
578
|
+
const codec = descriptorOf(theory).codec;
|
|
579
|
+
const bytes = renderCheckpoint(codec, candidate);
|
|
580
|
+
const digest = digest32(new Uint8Array(internalBlake3(bytes)));
|
|
581
|
+
await claimScratch(dir, digest);
|
|
582
|
+
const ran = await errors.try((async function publish() {
|
|
583
|
+
await putCreateOnce(store, checkpointMdbKey(prefix, digest), mdb);
|
|
584
|
+
return await casPublish(store, prefix, codec, candidate, digest, bytes);
|
|
585
|
+
})());
|
|
586
|
+
if (ran.error) {
|
|
587
|
+
throw ran.error;
|
|
588
|
+
}
|
|
589
|
+
if (ran.data.tag === "kept" || ran.data.tag === "refused") {
|
|
590
|
+
await deleteOrphan(store, prefix, digest);
|
|
591
|
+
}
|
|
592
|
+
await releaseScratch(dir);
|
|
593
|
+
return ran.data;
|
|
594
|
+
}
|
|
595
|
+
/** Only the writer births a store: create-only PUT of a genesis manifest. */
|
|
596
|
+
async function birthStore(store, prefix, fingerprint) {
|
|
597
|
+
const key = manifestKey(prefix);
|
|
598
|
+
for (;;) {
|
|
599
|
+
const fetched = await store.get(key);
|
|
600
|
+
if (fetched !== null) {
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
const bytes = renderManifest({ fingerprint, checkpoint: null });
|
|
604
|
+
const created = await store.putCreate(key, bytes);
|
|
605
|
+
if (created.tag === "created") {
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
function isReplica(source) {
|
|
611
|
+
return typeof source.refresh === "function";
|
|
612
|
+
}
|
|
613
|
+
/** Wrap a born replica: settle an inherited pending; do not birth; do not draw id leases. */
|
|
614
|
+
async function writerOn(replica) {
|
|
615
|
+
const core = coreOf(replica);
|
|
616
|
+
const state = {
|
|
617
|
+
writerId: crypto.randomBytes(8).readBigUInt64LE(),
|
|
618
|
+
pools: new Map(),
|
|
619
|
+
scream: screamOf("writer discard-and-re-pull"),
|
|
620
|
+
deposition: null
|
|
621
|
+
};
|
|
622
|
+
await withGate(core, async function openTransition() {
|
|
623
|
+
await settleInheritedPending(core, state);
|
|
624
|
+
});
|
|
625
|
+
return {
|
|
626
|
+
role: "writer",
|
|
627
|
+
replica,
|
|
628
|
+
deposition() {
|
|
629
|
+
return state.deposition;
|
|
630
|
+
},
|
|
631
|
+
async commit(body) {
|
|
632
|
+
return withGate(core, async function commitBody() {
|
|
633
|
+
const recorded = await recordWithLeases(core, state, body);
|
|
634
|
+
if (recorded.ops.length === 0) {
|
|
635
|
+
return { tag: "empty", value: recorded.value };
|
|
636
|
+
}
|
|
637
|
+
const partitioned = braidsTouched(core, recorded.ops);
|
|
638
|
+
if (partitioned.size > 1) {
|
|
639
|
+
throw errors.wrap(ErrSpanningCommit, `the recorded ops span braids ${[...partitioned.keys()].join(", ")}`);
|
|
640
|
+
}
|
|
641
|
+
const [braid, ops] = [...partitioned.entries()][0];
|
|
642
|
+
const outcome = await disciplineCommit(core, state, braid, ops);
|
|
643
|
+
if (outcome.tag === "rejected") {
|
|
644
|
+
return outcome;
|
|
645
|
+
}
|
|
646
|
+
return {
|
|
647
|
+
tag: "accepted",
|
|
648
|
+
value: { value: recorded.value, braid, slot: outcome.value.slot, durability: outcome.value.durability }
|
|
649
|
+
};
|
|
650
|
+
});
|
|
651
|
+
},
|
|
652
|
+
async commitSplit(body) {
|
|
653
|
+
return withGate(core, async function splitBody() {
|
|
654
|
+
const recorded = await recordWithLeases(core, state, body);
|
|
655
|
+
if (recorded.ops.length === 0) {
|
|
656
|
+
return { tag: "empty", value: recorded.value };
|
|
657
|
+
}
|
|
658
|
+
const partitioned = braidsTouched(core, recorded.ops);
|
|
659
|
+
const outcomes = [];
|
|
660
|
+
for (const [braid, ops] of partitioned) {
|
|
661
|
+
const admission = await disciplineCommit(core, state, braid, ops);
|
|
662
|
+
outcomes.push({ braid, admission });
|
|
663
|
+
}
|
|
664
|
+
return { tag: "split", value: recorded.value, outcomes };
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
async function openWriter(source) {
|
|
670
|
+
if (isReplica(source)) {
|
|
671
|
+
return writerOn(source);
|
|
672
|
+
}
|
|
673
|
+
await birthStore(source.store, source.prefix, digest32(descriptorOf(source.theory).fingerprintBytes));
|
|
674
|
+
return writerOn(await openReplica(source));
|
|
675
|
+
}
|
|
676
|
+
export { openWriter, publishCheckpoint };
|
|
677
|
+
//# sourceMappingURL=writer.js.map
|