@optimystic/db-core 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 +336 -336
- package/dist/src/btree/btree.d.ts +1 -1
- package/dist/src/btree/btree.d.ts.map +1 -1
- package/dist/src/btree/btree.js +5 -1
- package/dist/src/btree/btree.js.map +1 -1
- package/dist/src/cluster/structs.d.ts +14 -3
- package/dist/src/cluster/structs.d.ts.map +1 -1
- package/dist/src/collection/collection-type-registry.d.ts +3 -2
- package/dist/src/collection/collection-type-registry.d.ts.map +1 -1
- package/dist/src/collection/collection-type-registry.js.map +1 -1
- package/dist/src/collection/collection.d.ts +44 -0
- package/dist/src/collection/collection.d.ts.map +1 -1
- package/dist/src/collection/collection.js +134 -27
- package/dist/src/collection/collection.js.map +1 -1
- package/dist/src/collection/struct.d.ts +43 -1
- package/dist/src/collection/struct.d.ts.map +1 -1
- package/dist/src/collection/struct.js +42 -2
- package/dist/src/collection/struct.js.map +1 -1
- package/dist/src/collections/diary/diary.d.ts +6 -1
- package/dist/src/collections/diary/diary.d.ts.map +1 -1
- package/dist/src/collections/diary/diary.js +26 -19
- package/dist/src/collections/diary/diary.js.map +1 -1
- package/dist/src/collections/tree/tree.d.ts +15 -0
- package/dist/src/collections/tree/tree.d.ts.map +1 -1
- package/dist/src/collections/tree/tree.js +37 -12
- package/dist/src/collections/tree/tree.js.map +1 -1
- package/dist/src/network/i-key-network.d.ts +17 -0
- package/dist/src/network/i-key-network.d.ts.map +1 -1
- package/dist/src/network/index.d.ts +1 -0
- package/dist/src/network/index.d.ts.map +1 -1
- package/dist/src/network/index.js +1 -0
- package/dist/src/network/index.js.map +1 -1
- package/dist/src/network/stale-failure.d.ts +32 -0
- package/dist/src/network/stale-failure.d.ts.map +1 -0
- package/dist/src/network/stale-failure.js +41 -0
- package/dist/src/network/stale-failure.js.map +1 -0
- package/dist/src/network/struct.d.ts +48 -0
- package/dist/src/network/struct.d.ts.map +1 -1
- package/dist/src/network/struct.js +16 -1
- package/dist/src/network/struct.js.map +1 -1
- package/dist/src/testing/test-transactor.d.ts.map +1 -1
- package/dist/src/testing/test-transactor.js +6 -2
- package/dist/src/testing/test-transactor.js.map +1 -1
- package/dist/src/transaction/coordinator.d.ts.map +1 -1
- package/dist/src/transaction/coordinator.js +27 -15
- package/dist/src/transaction/coordinator.js.map +1 -1
- package/dist/src/transactor/network-transactor.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.js +93 -30
- package/dist/src/transactor/network-transactor.js.map +1 -1
- package/dist/src/transactor/transactor-source.d.ts.map +1 -1
- package/dist/src/transactor/transactor-source.js +9 -1
- package/dist/src/transactor/transactor-source.js.map +1 -1
- package/package.json +1 -1
- package/src/btree/btree.ts +4 -1
- package/src/cluster/structs.ts +14 -3
- package/src/collection/collection-type-registry.ts +3 -2
- package/src/collection/collection.ts +151 -28
- package/src/collection/struct.ts +38 -1
- package/src/collections/diary/diary.ts +67 -59
- package/src/collections/tree/tree.ts +63 -12
- package/src/network/i-key-network.ts +18 -0
- package/src/network/index.ts +1 -0
- package/src/network/stale-failure.ts +43 -0
- package/src/network/struct.ts +48 -0
- package/src/testing/test-transactor.ts +6 -2
- package/src/transaction/coordinator.ts +31 -15
- package/src/transactor/network-transactor.ts +97 -31
- package/src/transactor/transactor-source.ts +9 -1
|
@@ -7,9 +7,10 @@ export interface CollectionTypeDescriptor {
|
|
|
7
7
|
blockType: BlockType;
|
|
8
8
|
/** Human-readable name (e.g. "Diary", "Tree") */
|
|
9
9
|
name: string;
|
|
10
|
-
/** Optional factory to open a collection with default settings
|
|
10
|
+
/** Optional factory to open a collection with default settings, bringing it into existence
|
|
11
|
+
* when nothing has ever been committed under the id (see {@link Collection.createOrOpen}).
|
|
11
12
|
* Not all types support this (e.g. Tree requires keyFromEntry/compare). */
|
|
12
|
-
|
|
13
|
+
createOrOpen?: (transactor: ITransactor, id: CollectionId) => Promise<ICollection<any>>;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
const collectionTypes = new Map<BlockType, CollectionTypeDescriptor>();
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { IBlock, Action, ActionType, ActionHandler, BlockId, ITransactor, BlockStore, Transforms, ActionId } from "../index.js";
|
|
2
2
|
import { Log, Atomic, Tracker, copyTransforms, CacheSource, isTransformsEmpty, TransactorSource } from "../index.js";
|
|
3
|
+
import { BlockUnavailableError } from "../network/struct.js";
|
|
3
4
|
import type { CollectionHeaderBlock, CollectionId, ICollection, SyncOptions } from "./index.js";
|
|
4
|
-
import { SyncRetryExhaustedError } from "./index.js";
|
|
5
|
+
import { CollectionHeaderVanishedError, SyncRetryExhaustedError } from "./index.js";
|
|
6
|
+
import type { ActionContext } from "./action.js";
|
|
5
7
|
import type { ReadDependency } from "../transaction/transaction.js";
|
|
6
8
|
import { clampPriority } from "../transaction/transaction.js";
|
|
7
9
|
import { ReadDependencyCollector } from "../transaction/read-dependency-collector.js";
|
|
@@ -9,6 +11,9 @@ import { randomBytes } from '@noble/hashes/utils.js';
|
|
|
9
11
|
import { toString as uint8ArrayToString } from 'uint8arrays/to-string';
|
|
10
12
|
import { Latches } from "../utility/latches.js";
|
|
11
13
|
import { jitteredBackoffMs, abortableDelay, makeAbortError } from "../utility/backoff.js";
|
|
14
|
+
import { createLogger } from "../logger.js";
|
|
15
|
+
|
|
16
|
+
const log = createLogger('collection');
|
|
12
17
|
|
|
13
18
|
/** Default base backoff (and historical fixed delay) between sync retries, in ms. */
|
|
14
19
|
const PendingRetryDelayMs = 100;
|
|
@@ -57,7 +62,59 @@ export class Collection<TAction> implements ICollection<TAction> {
|
|
|
57
62
|
this.latchId = `Collection:${this.id}`;
|
|
58
63
|
}
|
|
59
64
|
|
|
60
|
-
|
|
65
|
+
/** Open an EXISTING collection.
|
|
66
|
+
*
|
|
67
|
+
* Resolves to `undefined` when the header block probe comes back empty — an
|
|
68
|
+
* authoritatively absent header, meaning nothing has ever been committed under this id.
|
|
69
|
+
* A header the storage layer could not RETRIEVE (a revision this node cannot
|
|
70
|
+
* reconstruct, an unreachable cohort) is not absent: the probe throws
|
|
71
|
+
* {@link BlockUnavailableError} instead of resolving `undefined`, so an unreachable
|
|
72
|
+
* collection can never be mistaken for a nonexistent one.
|
|
73
|
+
*
|
|
74
|
+
* Use this wherever reading — not creating — is what was meant. {@link createOrOpen}
|
|
75
|
+
* would instead stage a fresh empty collection, and reads through it would report an
|
|
76
|
+
* absent dataset as a legitimately empty one. */
|
|
77
|
+
static async open<TAction>(transactor: ITransactor, id: CollectionId, init: CollectionInitOptions<TAction>): Promise<Collection<TAction> | undefined> {
|
|
78
|
+
const { source, sourceCache, tracker, header } = await Collection.probeHeader(transactor, id);
|
|
79
|
+
if (!header) {
|
|
80
|
+
// Return before anything is staged: the tracker's transforms stay empty, so a caller
|
|
81
|
+
// that ignores the undefined cannot later sync a phantom collection into existence.
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
await Collection.attachToLog<TAction>(source, transactor, tracker, id, header);
|
|
85
|
+
return new Collection(id, transactor, init.modules, source, sourceCache, tracker, init.filterConflict);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Open an existing collection, or stage a fresh empty one in the local tracker when the
|
|
89
|
+
* header is authoritatively absent. Nothing is written to storage until {@link sync}.
|
|
90
|
+
*
|
|
91
|
+
* Correct only where inventing a collection is genuinely intended — a first write, a
|
|
92
|
+
* bootstrap path. The create branch logs `collection:invented`; prefer {@link open} on
|
|
93
|
+
* any pure read path. */
|
|
94
|
+
static async createOrOpen<TAction>(transactor: ITransactor, id: CollectionId, init: CollectionInitOptions<TAction>): Promise<Collection<TAction>> {
|
|
95
|
+
const { source, sourceCache, tracker, header } = await Collection.probeHeader(transactor, id);
|
|
96
|
+
|
|
97
|
+
if (header) { // Collection already exists
|
|
98
|
+
await Collection.attachToLog<TAction>(source, transactor, tracker, id, header);
|
|
99
|
+
} else { // Collection does not exist
|
|
100
|
+
log('collection:invented id=%s — no committed header found; staging a fresh empty collection', id);
|
|
101
|
+
const headerBlock = init.createHeaderBlock(id, tracker);
|
|
102
|
+
tracker.insert(headerBlock);
|
|
103
|
+
source.actionContext = undefined;
|
|
104
|
+
await Log.open<Action<TAction>>(tracker, id);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return new Collection(id, transactor, init.modules, source, sourceCache, tracker, init.filterConflict);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The per-instance read wiring every open path needs, plus the header probe result.
|
|
111
|
+
* Shared by {@link open} and {@link createOrOpen} so the two cannot drift. */
|
|
112
|
+
private static async probeHeader(transactor: ITransactor, id: CollectionId): Promise<{
|
|
113
|
+
source: TransactorSource<IBlock>,
|
|
114
|
+
sourceCache: CacheSource<IBlock>,
|
|
115
|
+
tracker: Tracker<IBlock>,
|
|
116
|
+
header: CollectionHeaderBlock | undefined,
|
|
117
|
+
}> {
|
|
61
118
|
// Start with a context that has an infinite revision number to ensure that we always fetch the latest log information.
|
|
62
119
|
// One shared read-dependency collector feeds both the source (direct structural reads) and the cache (every
|
|
63
120
|
// cache hit/miss), so a block read from either layer records a dependency — cache hits included.
|
|
@@ -66,22 +123,53 @@ export class Collection<TAction> implements ICollection<TAction> {
|
|
|
66
123
|
const sourceCache = new CacheSource(source, undefined, collector);
|
|
67
124
|
const tracker = new Tracker(sourceCache);
|
|
68
125
|
const header = await source.tryGet(id) as CollectionHeaderBlock | undefined;
|
|
126
|
+
return { source, sourceCache, tracker, header };
|
|
127
|
+
}
|
|
69
128
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
129
|
+
/** Walk an existing collection's log and point the source at its latest action context.
|
|
130
|
+
* A header we just probed successfully but whose log will not open is a fault, not an
|
|
131
|
+
* absence — throw rather than let the collection read as empty. (The re-read goes through
|
|
132
|
+
* the tracker/cache, so it can disagree with the probe when storage is flaky mid-open.) */
|
|
133
|
+
private static async attachToLog<TAction>(
|
|
134
|
+
source: TransactorSource<IBlock>,
|
|
135
|
+
transactor: ITransactor,
|
|
136
|
+
tracker: Tracker<IBlock>,
|
|
137
|
+
id: CollectionId,
|
|
138
|
+
header: CollectionHeaderBlock,
|
|
139
|
+
): Promise<void> {
|
|
140
|
+
// Bootstrap ActionContext from the committed tail before walking the chain.
|
|
141
|
+
// This allows the transactor to serve pending non-tail blocks during Log.open.
|
|
142
|
+
await Collection.bootstrapContext(source, transactor, header);
|
|
74
143
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const headerBlock = init.createHeaderBlock(id, tracker);
|
|
79
|
-
tracker.insert(headerBlock);
|
|
80
|
-
source.actionContext = undefined;
|
|
81
|
-
await Log.open<Action<TAction>>(tracker, id);
|
|
144
|
+
const collectionLog = await Log.open<Action<TAction>>(tracker, id);
|
|
145
|
+
if (!collectionLog) {
|
|
146
|
+
throw new Error(`Log not found for collection ${id}`);
|
|
82
147
|
}
|
|
148
|
+
// Monotonic, not an overwrite: getActionContext resolves undefined when the chain has no
|
|
149
|
+
// tail or the tail block carries zero entries, and that must not erase the revision
|
|
150
|
+
// bootstrapContext just read off the committed tail.
|
|
151
|
+
Collection.advanceContext(source, id, await collectionLog.getActionContext());
|
|
152
|
+
}
|
|
83
153
|
|
|
84
|
-
|
|
154
|
+
/** Adopt a freshly-read action context WITHOUT ever lowering the revision already held.
|
|
155
|
+
*
|
|
156
|
+
* The revision a collection last committed at is knowledge it earned; a read that found
|
|
157
|
+
* nothing — or found an older view of the log — cannot un-earn it. Silently accepting the
|
|
158
|
+
* lower value makes the next sync ask for a revision that is long gone, and every retry
|
|
159
|
+
* repeats the same doomed request because each retry re-runs the same losing read.
|
|
160
|
+
*
|
|
161
|
+
* Equal revisions still adopt `next`: the rev is unchanged but its `committed` list may be
|
|
162
|
+
* more complete than what we hold. */
|
|
163
|
+
private static advanceContext(source: TransactorSource<IBlock>, id: CollectionId, next: ActionContext | undefined): void {
|
|
164
|
+
const current = source.actionContext;
|
|
165
|
+
if (next === undefined) {
|
|
166
|
+
return; // The read learned nothing — keep what we already know.
|
|
167
|
+
}
|
|
168
|
+
if (current !== undefined && next.rev < current.rev) {
|
|
169
|
+
log('collection:context-not-lowered id=%s held=%d read=%d', id, current.rev, next.rev);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
source.actionContext = next;
|
|
85
173
|
}
|
|
86
174
|
|
|
87
175
|
async act(...actions: Action<TAction>[]) {
|
|
@@ -129,15 +217,32 @@ export class Collection<TAction> implements ICollection<TAction> {
|
|
|
129
217
|
|
|
130
218
|
// Bootstrap context from committed tail so pending blocks are accessible.
|
|
131
219
|
// Read through tracker so Chain.open inside Log.open reuses the cached header.
|
|
220
|
+
// A header the storage layer could not retrieve throws BlockUnavailableError out of
|
|
221
|
+
// this read (it is not a StaleFailure, so sync's retry loop does not absorb it).
|
|
132
222
|
const header = await tracker.tryGet(this.id) as CollectionHeaderBlock | undefined;
|
|
133
223
|
if (header) {
|
|
134
224
|
await Collection.bootstrapContext(source, this.transactor, header);
|
|
225
|
+
} else if (this.source.actionContext) {
|
|
226
|
+
// An absent header is only believable for a collection that has never committed.
|
|
227
|
+
// We hold a committed revision, so the two answers contradict each other — surface it
|
|
228
|
+
// as a fault instead of no-opping into a forgotten revision and a rev-1 retry spin.
|
|
229
|
+
// NOTE: this aborts every caller of update(), including TransactionCoordinator's
|
|
230
|
+
// blanket refresh of ALL registered collections between commit retries — a
|
|
231
|
+
// non-participant with a momentarily-absent header now fails the whole retry rather
|
|
232
|
+
// than being skipped. That is the intended loud failure; if it ever shows up as
|
|
233
|
+
// otherwise-healthy transactions aborting, narrow that refresh to the transaction's
|
|
234
|
+
// participants (see the note at coordinator.ts's update loop) rather than softening
|
|
235
|
+
// this throw.
|
|
236
|
+
throw new CollectionHeaderVanishedError(this.id, this.source.actionContext.rev);
|
|
135
237
|
}
|
|
238
|
+
// Falling through means the header is genuinely absent AND we hold no revision: nothing
|
|
239
|
+
// was ever committed under this id. Log.open reads the same block id, so it too resolves
|
|
240
|
+
// undefined and everything below no-ops — correct here, rather than a masked failure.
|
|
136
241
|
|
|
137
242
|
// Get the latest entries from the log, starting from where we left off
|
|
138
243
|
const actionContext = this.source.actionContext;
|
|
139
|
-
const
|
|
140
|
-
const latest =
|
|
244
|
+
const collectionLog = await Log.open<Action<TAction>>(tracker, this.id);
|
|
245
|
+
const latest = collectionLog ? await collectionLog.getFrom(actionContext?.rev ?? 0) : undefined;
|
|
141
246
|
|
|
142
247
|
// Process the entries and track the blocks they affect
|
|
143
248
|
let anyConflicts = false;
|
|
@@ -166,7 +271,7 @@ export class Collection<TAction> implements ICollection<TAction> {
|
|
|
166
271
|
// read — drop the reverted blocks from the read cache and replay pending work against the reverted
|
|
167
272
|
// base (docs/right-is-right.md §Client notification). De-duped across cascade children by reverted
|
|
168
273
|
// block; over-inclusive by design (over-invalidation just resubmits — it never wrongly retains).
|
|
169
|
-
const invalidations =
|
|
274
|
+
const invalidations = collectionLog ? await collectionLog.getInvalidationsFrom(actionContext?.rev ?? 0) : [];
|
|
170
275
|
if (invalidations.length > 0) {
|
|
171
276
|
const revertedBlockIds = [...new Set(invalidations.flatMap(inv => inv.reverted.map(r => r.blockId)))];
|
|
172
277
|
this.sourceCache.clear(revertedBlockIds);
|
|
@@ -180,8 +285,10 @@ export class Collection<TAction> implements ICollection<TAction> {
|
|
|
180
285
|
await this.replayActions();
|
|
181
286
|
}
|
|
182
287
|
|
|
183
|
-
// Update our context to the latest
|
|
184
|
-
|
|
288
|
+
// Update our context to the latest — monotonically. An empty/unopenable log yields no
|
|
289
|
+
// context at all, and a log read that lags what we already committed yields an older one;
|
|
290
|
+
// neither is grounds for forgetting the revision we hold.
|
|
291
|
+
Collection.advanceContext(this.source, this.id, latest?.context);
|
|
185
292
|
}
|
|
186
293
|
|
|
187
294
|
/** Capture the current staged state — tracker transforms plus the pending
|
|
@@ -298,6 +405,9 @@ export class Collection<TAction> implements ICollection<TAction> {
|
|
|
298
405
|
// large multi-batch sync (which iterates many times committing progress) never trips it.
|
|
299
406
|
let consecutiveFailures = 0;
|
|
300
407
|
let lastReason: string | undefined;
|
|
408
|
+
// Last confirmed revision a responder reported holding. Purely diagnostic — it is reported
|
|
409
|
+
// in the exhaustion error and never consulted to decide whether to keep retrying.
|
|
410
|
+
let lastStaleAt: { blockId: BlockId; rev: number } | undefined;
|
|
301
411
|
|
|
302
412
|
while (this.pending.length || !isTransformsEmpty(this.tracker.transforms)) {
|
|
303
413
|
if (signal?.aborted) {
|
|
@@ -305,7 +415,7 @@ export class Collection<TAction> implements ICollection<TAction> {
|
|
|
305
415
|
}
|
|
306
416
|
// Progress-agnostic ceiling: give up if the wall-clock deadline passed.
|
|
307
417
|
if (deadlineMs !== undefined && Date.now() - startedAt >= deadlineMs) {
|
|
308
|
-
throw new SyncRetryExhaustedError(this.id, consecutiveFailures, lastReason ?? 'deadline exceeded');
|
|
418
|
+
throw new SyncRetryExhaustedError(this.id, consecutiveFailures, lastReason ?? 'deadline exceeded', lastStaleAt);
|
|
309
419
|
}
|
|
310
420
|
|
|
311
421
|
// Snapshot the pending actions so that any new actions aren't assumed to be part of this action
|
|
@@ -316,12 +426,12 @@ export class Collection<TAction> implements ICollection<TAction> {
|
|
|
316
426
|
const tracker = new Tracker(this.sourceCache, snapshot);
|
|
317
427
|
|
|
318
428
|
// Add the action to the log (in local tracking space)
|
|
319
|
-
const
|
|
320
|
-
if (!
|
|
429
|
+
const collectionLog = await Log.open<Action<TAction>>(tracker, this.id);
|
|
430
|
+
if (!collectionLog) {
|
|
321
431
|
throw new Error(`Log not found for collection ${this.id}`);
|
|
322
432
|
}
|
|
323
433
|
const newRev = (this.source.actionContext?.rev ?? 0) + 1;
|
|
324
|
-
const addResult = await
|
|
434
|
+
const addResult = await collectionLog.addActions(pending, actionId, newRev, () => tracker.transformedBlockIds());
|
|
325
435
|
|
|
326
436
|
// Commit the action to the transactor. Carry the aged retry priority derived from the
|
|
327
437
|
// consecutive-failure count so a sync that keeps losing concurrent races out-ranks fresh
|
|
@@ -331,6 +441,7 @@ export class Collection<TAction> implements ICollection<TAction> {
|
|
|
331
441
|
if (staleFailure) {
|
|
332
442
|
consecutiveFailures++;
|
|
333
443
|
lastReason = staleFailure.reason ?? lastReason;
|
|
444
|
+
lastStaleAt = staleFailure.staleAt ?? lastStaleAt;
|
|
334
445
|
// Give up once the consecutive no-progress budget is exhausted, so a transactor that
|
|
335
446
|
// persistently rejects the sync can no longer hold the collection latch forever.
|
|
336
447
|
// NOTE: this also bounds the legitimate `pending`-wait case (retrying the same action
|
|
@@ -338,7 +449,7 @@ export class Collection<TAction> implements ICollection<TAction> {
|
|
|
338
449
|
// attempts ≈ 21s of exponential backoff. If a high-contention workload legitimately
|
|
339
450
|
// needs to wait longer for a pending commit to clear, raise maxAttempts for that caller.
|
|
340
451
|
if (consecutiveFailures >= maxAttempts) {
|
|
341
|
-
throw new SyncRetryExhaustedError(this.id, consecutiveFailures, lastReason);
|
|
452
|
+
throw new SyncRetryExhaustedError(this.id, consecutiveFailures, lastReason, lastStaleAt);
|
|
342
453
|
}
|
|
343
454
|
// Back off before every retry (any stale failure — reason/missing/pending), growing
|
|
344
455
|
// exponentially from the base delay up to the cap, with proportional random jitter so a
|
|
@@ -357,6 +468,7 @@ export class Collection<TAction> implements ICollection<TAction> {
|
|
|
357
468
|
// Forward progress: reset the no-progress budget.
|
|
358
469
|
consecutiveFailures = 0;
|
|
359
470
|
lastReason = undefined;
|
|
471
|
+
lastStaleAt = undefined;
|
|
360
472
|
// Clear the pending actions that were part of this action
|
|
361
473
|
this.pending = this.pending.slice(pending.length);
|
|
362
474
|
// Reset cache and replay any actions that were added during the action
|
|
@@ -381,11 +493,11 @@ export class Collection<TAction> implements ICollection<TAction> {
|
|
|
381
493
|
}
|
|
382
494
|
|
|
383
495
|
async *selectLog(forward = true): AsyncIterableIterator<Action<TAction>> {
|
|
384
|
-
const
|
|
385
|
-
if (!
|
|
496
|
+
const collectionLog = await Log.open<Action<TAction>>(this.tracker, this.id);
|
|
497
|
+
if (!collectionLog) {
|
|
386
498
|
throw new Error(`Log not found for collection ${this.id}`);
|
|
387
499
|
}
|
|
388
|
-
for await (const entry of
|
|
500
|
+
for await (const entry of collectionLog.select(undefined, forward)) {
|
|
389
501
|
if (entry.action) {
|
|
390
502
|
// NOTE: copy-then-reverse to avoid mutating the stored log entry array.
|
|
391
503
|
// Once tsconfig targets ES2023, `entry.action.actions.toReversed()` is cleaner.
|
|
@@ -425,6 +537,13 @@ export class Collection<TAction> implements ICollection<TAction> {
|
|
|
425
537
|
* with context=undefined. Its state.latest contains the ActionRev of the most recent
|
|
426
538
|
* committed action — exactly the proof needed for the transactor to serve pending
|
|
427
539
|
* non-tail blocks during chain walks.
|
|
540
|
+
*
|
|
541
|
+
* This read goes to the transactor directly rather than through {@link TransactorSource},
|
|
542
|
+
* so it has to honour the `unavailable` flag itself: a tail the repo could not retrieve
|
|
543
|
+
* must not degrade into "no context", which would leave the chain walk unable to see
|
|
544
|
+
* pending non-tail blocks and the collection reading as if they did not exist. A tail
|
|
545
|
+
* with no `state.latest` and NO flag is a real answer (nothing committed yet) and still
|
|
546
|
+
* no-ops.
|
|
428
547
|
*/
|
|
429
548
|
private static async bootstrapContext(
|
|
430
549
|
source: TransactorSource<IBlock>,
|
|
@@ -434,7 +553,11 @@ export class Collection<TAction> implements ICollection<TAction> {
|
|
|
434
553
|
const tailId = header.tailId;
|
|
435
554
|
if (tailId) {
|
|
436
555
|
const tailResult = await transactor.get({ blockIds: [tailId] });
|
|
437
|
-
const
|
|
556
|
+
const tailEntry = tailResult?.[tailId];
|
|
557
|
+
if (tailEntry?.unavailable !== undefined && tailEntry.block == null) {
|
|
558
|
+
throw new BlockUnavailableError(tailId, tailEntry.unavailable);
|
|
559
|
+
}
|
|
560
|
+
const tailState = tailEntry?.state;
|
|
438
561
|
if (tailState?.latest) {
|
|
439
562
|
source.actionContext = {
|
|
440
563
|
committed: [{ actionId: tailState.latest.actionId, rev: tailState.latest.rev }],
|
package/src/collection/struct.ts
CHANGED
|
@@ -40,13 +40,50 @@ export class SyncRetryExhaustedError extends Error {
|
|
|
40
40
|
readonly collectionId: CollectionId,
|
|
41
41
|
readonly attempts: number,
|
|
42
42
|
readonly lastReason?: string,
|
|
43
|
+
/** The last confirmed revision a responder reported holding, if any responder reported one
|
|
44
|
+
* (see `StaleFailure.staleAt`). Absent whenever no rejection carried a confirmed number —
|
|
45
|
+
* which is normal, not a signal that the failure was something other than a lost race. */
|
|
46
|
+
readonly staleAt?: { blockId: BlockId; rev: number },
|
|
43
47
|
) {
|
|
44
48
|
super(`sync for collection ${collectionId} exhausted ${attempts} retries` +
|
|
45
|
-
(lastReason ? `: ${lastReason}` : '')
|
|
49
|
+
(lastReason ? `: ${lastReason}` : '') +
|
|
50
|
+
(staleAt ? `, last seen block ${staleAt.blockId} at rev ${staleAt.rev}` : ''));
|
|
46
51
|
this.name = 'SyncRetryExhaustedError';
|
|
47
52
|
}
|
|
48
53
|
}
|
|
49
54
|
|
|
55
|
+
/** Thrown when a collection that already holds a committed revision reads its own header
|
|
56
|
+
* block as authoritatively absent.
|
|
57
|
+
*
|
|
58
|
+
* The two facts contradict each other: this client has proof that something was committed
|
|
59
|
+
* under this id (it holds the revision it committed at, or the one it read off the log tail),
|
|
60
|
+
* and storage has just answered that nothing ever was. Exactly one of them is wrong, so this
|
|
61
|
+
* is a fault rather than an absence — the same reasoning `Collection.attachToLog` applies to a
|
|
62
|
+
* header that probes fine but whose log will not open.
|
|
63
|
+
*
|
|
64
|
+
* Deliberately NOT a `StaleFailure`: {@link ICollection.sync}'s retry loop only absorbs
|
|
65
|
+
* returned stale failures, so throwing this aborts the sync immediately with a named
|
|
66
|
+
* diagnosis instead of letting it spin the full retry budget re-requesting a revision it
|
|
67
|
+
* has silently forgotten.
|
|
68
|
+
*
|
|
69
|
+
* NOTE: durable invalidation restores reverted content to its as-if-absent state, so once the
|
|
70
|
+
* cascade runs end-to-end (docs/right-is-right.md § Durable Invalidation), reverting the commit
|
|
71
|
+
* that CREATED a collection would make its header legitimately absent for a client still holding
|
|
72
|
+
* that revision — a reversal, not a contradiction, which this message would misdiagnose.
|
|
73
|
+
* Aborting is still the right action there; if it ever fires for that reason, distinguish the
|
|
74
|
+
* two by checking the log for an invalidation of the held revision before wording the error. */
|
|
75
|
+
export class CollectionHeaderVanishedError extends Error {
|
|
76
|
+
constructor(
|
|
77
|
+
readonly collectionId: CollectionId,
|
|
78
|
+
/** The committed revision this collection held when the header read came back absent. */
|
|
79
|
+
readonly heldRev: number,
|
|
80
|
+
) {
|
|
81
|
+
super(`collection ${collectionId} holds committed revision ${heldRev}, but its header block `
|
|
82
|
+
+ `read as absent — storage reported that nothing was ever committed under this id`);
|
|
83
|
+
this.name = 'CollectionHeaderVanishedError';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
50
87
|
export interface ICollection<TAction> {
|
|
51
88
|
readonly id: CollectionId;
|
|
52
89
|
act(...actions: Action<TAction>[]): Promise<void>;
|
|
@@ -1,59 +1,67 @@
|
|
|
1
|
-
import { Collection, registerCollectionType } from "../../index.js";
|
|
2
|
-
import type { ITransactor, Action, BlockId, BlockStore, IBlock, CollectionInitOptions, CollectionId } from "../../index.js";
|
|
3
|
-
import { DiaryHeaderBlockType } from "./struct.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
1
|
+
import { Collection, registerCollectionType } from "../../index.js";
|
|
2
|
+
import type { ITransactor, Action, BlockId, BlockStore, IBlock, CollectionInitOptions, CollectionId } from "../../index.js";
|
|
3
|
+
import { DiaryHeaderBlockType } from "./struct.js";
|
|
4
|
+
|
|
5
|
+
/** A diary keeps every entry in the log itself, so the header block is the whole structure
|
|
6
|
+
* and the "append" handler has no blocks to touch. */
|
|
7
|
+
function diaryInit<TEntry>(): CollectionInitOptions<TEntry> {
|
|
8
|
+
return {
|
|
9
|
+
modules: {
|
|
10
|
+
"append": async (_action, _trx) => {
|
|
11
|
+
// Append-only diary doesn't need to modify any blocks
|
|
12
|
+
// All entries are stored in the log
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
createHeaderBlock: (id: BlockId, store: BlockStore<IBlock>) => ({
|
|
16
|
+
header: store.createBlockHeader(DiaryHeaderBlockType, id)
|
|
17
|
+
})
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class Diary<TEntry> {
|
|
22
|
+
private constructor(
|
|
23
|
+
private readonly collection: Collection<TEntry>
|
|
24
|
+
) {
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Open an existing diary, or stage a fresh empty one when nothing has ever been committed
|
|
28
|
+
* under this id. Attach-or-create — see {@link Collection.createOrOpen}. */
|
|
29
|
+
static async createOrOpen<TEntry>(network: ITransactor, id: CollectionId): Promise<Diary<TEntry>> {
|
|
30
|
+
const collection = await Collection.createOrOpen(network, id, diaryInit<TEntry>());
|
|
31
|
+
return new Diary<TEntry>(collection);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Open an EXISTING diary, or resolve to `undefined` when no header block has ever been
|
|
35
|
+
* committed under this id. Never brings a diary into existence — see {@link Collection.open}. */
|
|
36
|
+
static async open<TEntry>(network: ITransactor, id: CollectionId): Promise<Diary<TEntry> | undefined> {
|
|
37
|
+
const collection = await Collection.open(network, id, diaryInit<TEntry>());
|
|
38
|
+
return collection ? new Diary<TEntry>(collection) : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async append(data: TEntry): Promise<void> {
|
|
42
|
+
const action: Action<TEntry> = {
|
|
43
|
+
type: "append",
|
|
44
|
+
data: data
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
await this.collection.act(action);
|
|
48
|
+
await this.collection.updateAndSync();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Fetch the latest state from the network */
|
|
52
|
+
async update(): Promise<void> {
|
|
53
|
+
await this.collection.update();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async *select(forward = true): AsyncIterableIterator<TEntry> {
|
|
57
|
+
for await (const entry of this.collection.selectLog(forward)) {
|
|
58
|
+
yield entry.data;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
registerCollectionType({
|
|
64
|
+
blockType: DiaryHeaderBlockType,
|
|
65
|
+
name: "Diary",
|
|
66
|
+
createOrOpen: (transactor, id) => Collection.createOrOpen(transactor, id, diaryInit<unknown>()),
|
|
67
|
+
});
|
|
@@ -20,6 +20,13 @@ export interface TreeReadView<TKey, TEntry> {
|
|
|
20
20
|
isValid(path: Path<TKey, TEntry>): boolean;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/** Carries the read {@link BTree} from wherever it gets built (the `createHeaderBlock`
|
|
24
|
+
* callback on the create path, {@link Tree.attach} on the open path) to the `replace`
|
|
25
|
+
* handler, which needs the live instance to invalidate outstanding paths. */
|
|
26
|
+
interface BTreeHolder<TKey, TEntry> {
|
|
27
|
+
btree?: BTree<TKey, TEntry>;
|
|
28
|
+
}
|
|
29
|
+
|
|
23
30
|
export class Tree<TKey, TEntry> implements TreeReadView<TKey, TEntry> {
|
|
24
31
|
|
|
25
32
|
private constructor(
|
|
@@ -31,6 +38,24 @@ export class Tree<TKey, TEntry> implements TreeReadView<TKey, TEntry> {
|
|
|
31
38
|
) {
|
|
32
39
|
}
|
|
33
40
|
|
|
41
|
+
/** Open an EXISTING tree, or resolve to `undefined` when no header block has ever been
|
|
42
|
+
* committed under this id. Never brings a tree into existence — nothing is staged into the
|
|
43
|
+
* collection's tracker on the absent path, so a caller that ignores the `undefined` cannot
|
|
44
|
+
* later sync a phantom tree. Use on pure read paths; see {@link Collection.open}. */
|
|
45
|
+
static async open<TKey, TEntry>(
|
|
46
|
+
network: ITransactor,
|
|
47
|
+
id: CollectionId,
|
|
48
|
+
keyFromEntry = (entry: TEntry) => entry as unknown as TKey,
|
|
49
|
+
compare = (a: TKey, b: TKey) => a < b ? -1 : a > b ? 1 : 0,
|
|
50
|
+
/** See {@link Tree.createOrOpen}'s `nodeCapacity`. */
|
|
51
|
+
nodeCapacity?: number,
|
|
52
|
+
): Promise<Tree<TKey, TEntry> | undefined> {
|
|
53
|
+
const held: BTreeHolder<TKey, TEntry> = {};
|
|
54
|
+
const init = Tree.buildInit(id, keyFromEntry, compare, nodeCapacity, held);
|
|
55
|
+
const collection = await Collection.open<TreeReplaceAction<TKey, TEntry>>(network, id, init);
|
|
56
|
+
return collection ? Tree.attach(collection, held, keyFromEntry, compare, nodeCapacity) : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
34
59
|
static async createOrOpen<TKey, TEntry>(
|
|
35
60
|
network: ITransactor,
|
|
36
61
|
id: CollectionId,
|
|
@@ -49,10 +74,22 @@ export class Tree<TKey, TEntry> implements TreeReadView<TKey, TEntry> {
|
|
|
49
74
|
* the header and read it back on reopen rather than trusting the caller to re-supply it. */
|
|
50
75
|
nodeCapacity?: number,
|
|
51
76
|
): Promise<Tree<TKey, TEntry>> {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
77
|
+
const held: BTreeHolder<TKey, TEntry> = {};
|
|
78
|
+
const init = Tree.buildInit(id, keyFromEntry, compare, nodeCapacity, held);
|
|
79
|
+
const collection = await Collection.createOrOpen<TreeReplaceAction<TKey, TEntry>>(network, id, init);
|
|
80
|
+
return Tree.attach(collection, held, keyFromEntry, compare, nodeCapacity);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The collection wiring both open paths share. `held` carries the read btree between the
|
|
84
|
+
* `createHeaderBlock` callback (which must build it to obtain the root id) and {@link attach}. */
|
|
85
|
+
private static buildInit<TKey, TEntry>(
|
|
86
|
+
id: CollectionId,
|
|
87
|
+
keyFromEntry: (entry: TEntry) => TKey,
|
|
88
|
+
compare: (a: TKey, b: TKey) => number,
|
|
89
|
+
nodeCapacity: number | undefined,
|
|
90
|
+
held: BTreeHolder<TKey, TEntry>,
|
|
91
|
+
): CollectionInitOptions<TreeReplaceAction<TKey, TEntry>> {
|
|
92
|
+
return {
|
|
56
93
|
modules: {
|
|
57
94
|
"replace": async ({ data: actions }, trx) => {
|
|
58
95
|
// Write through the Atomic store the handler is handed (`trx`), NOT the captured
|
|
@@ -78,25 +115,39 @@ export class Tree<TKey, TEntry> implements TreeReadView<TKey, TEntry> {
|
|
|
78
115
|
// Mutations landed in `trx`, not the read btree, so its version counter never moved.
|
|
79
116
|
// Bump it to invalidate any Path a caller still holds — preserving the path-invalidation
|
|
80
117
|
// the previous in-place handler gave for free.
|
|
81
|
-
btree?.invalidatePaths();
|
|
118
|
+
held.btree?.invalidatePaths();
|
|
82
119
|
}
|
|
83
120
|
},
|
|
84
|
-
createHeaderBlock: (
|
|
121
|
+
createHeaderBlock: (hid: BlockId, store: BlockStore<IBlock>) => { // Only called if the collection does not exist
|
|
122
|
+
// Tricky bootstrapping here:
|
|
123
|
+
// We need the root id to initialize the collection header, so we create the btree here.
|
|
85
124
|
let rootId: BlockId;
|
|
86
|
-
btree = BTree.create<TKey, TEntry>(store, (_s, r) => {
|
|
125
|
+
held.btree = BTree.create<TKey, TEntry>(store, (_s, r) => {
|
|
87
126
|
rootId = r;
|
|
88
|
-
return new CollectionTrunk(store,
|
|
127
|
+
return new CollectionTrunk(store, hid);
|
|
89
128
|
}, keyFromEntry, compare, nodeCapacity);
|
|
90
129
|
return {
|
|
91
|
-
header: store.createBlockHeader(TreeHeaderBlockType,
|
|
130
|
+
header: store.createBlockHeader(TreeHeaderBlockType, hid),
|
|
92
131
|
rootId: rootId!,
|
|
93
132
|
}
|
|
94
133
|
}
|
|
95
134
|
};
|
|
135
|
+
}
|
|
96
136
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
137
|
+
/** Bind an opened collection to its read btree. On the create path `createHeaderBlock` already
|
|
138
|
+
* built one (it needed the root id for the header); on the open path it never ran, so build it
|
|
139
|
+
* over the collection's existing tracker. Either way the result is written back into `held` so
|
|
140
|
+
* the `replace` handler's path-invalidation targets the very btree reads go through. */
|
|
141
|
+
private static attach<TKey, TEntry>(
|
|
142
|
+
collection: Collection<TreeReplaceAction<TKey, TEntry>>,
|
|
143
|
+
held: BTreeHolder<TKey, TEntry>,
|
|
144
|
+
keyFromEntry: (entry: TEntry) => TKey,
|
|
145
|
+
compare: (a: TKey, b: TKey) => number,
|
|
146
|
+
nodeCapacity: number | undefined,
|
|
147
|
+
): Tree<TKey, TEntry> {
|
|
148
|
+
held.btree = held.btree
|
|
149
|
+
?? new BTree<TKey, TEntry>(collection.tracker, new CollectionTrunk(collection.tracker, collection.id), keyFromEntry, compare, nodeCapacity);
|
|
150
|
+
return new Tree<TKey, TEntry>(collection, held.btree, keyFromEntry, compare);
|
|
100
151
|
}
|
|
101
152
|
|
|
102
153
|
async replace(data: TreeReplaceAction<TKey, TEntry>): Promise<void> {
|
|
@@ -1,9 +1,27 @@
|
|
|
1
1
|
import type { PeerId } from "./types.js";
|
|
2
2
|
import type { ClusterPeers } from "../cluster/structs.js";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* What a caller intends to do with the coordinator it is asking for.
|
|
6
|
+
*
|
|
7
|
+
* The distinction matters only when a node is isolated and the only candidate left is
|
|
8
|
+
* itself. A read served from this node's own replica is at worst STALE — and the layers
|
|
9
|
+
* below already say so (a self-only cohort answers conclusively; an unreachable cohort
|
|
10
|
+
* comes back flagged unavailable). A write coordinated alone can instead diverge from the
|
|
11
|
+
* rest of the network, so it is held to the stricter bar.
|
|
12
|
+
*/
|
|
13
|
+
export type CoordinatorIntent = 'read' | 'write';
|
|
14
|
+
|
|
4
15
|
export type FindCoordinatorOptions = {
|
|
5
16
|
/** Peers that have already been tried (and failed) */
|
|
6
17
|
excludedPeers?: PeerId[];
|
|
18
|
+
/**
|
|
19
|
+
* What the caller intends to do with the coordinator. A read may fall back to this
|
|
20
|
+
* node's own replica when the network is unreachable; a write may not do so on the
|
|
21
|
+
* strength of the same evidence. Defaults to `'write'` (the conservative behavior)
|
|
22
|
+
* when unset, so callers that don't set it are unchanged.
|
|
23
|
+
*/
|
|
24
|
+
intent?: CoordinatorIntent;
|
|
7
25
|
};
|
|
8
26
|
|
|
9
27
|
|
package/src/network/index.ts
CHANGED