@optimystic/db-core 0.16.3 → 0.18.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 +34 -0
- package/dist/src/collection/collection.d.ts.map +1 -1
- package/dist/src/collection/collection.js +86 -23
- package/dist/src/collection/collection.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/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 +11 -0
- package/dist/src/network/stale-failure.d.ts.map +1 -0
- package/dist/src/network/stale-failure.js +12 -0
- package/dist/src/network/stale-failure.js.map +1 -0
- package/dist/src/network/struct.d.ts +31 -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 +16 -10
- 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 +64 -18
- 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 +102 -24
- package/src/collections/diary/diary.ts +67 -59
- package/src/collections/tree/tree.ts +63 -12
- package/src/network/index.ts +1 -0
- package/src/network/stale-failure.ts +13 -0
- package/src/network/struct.ts +34 -0
- package/src/testing/test-transactor.ts +6 -2
- package/src/transaction/coordinator.ts +16 -10
- package/src/transactor/network-transactor.ts +64 -18
- package/src/transactor/transactor-source.ts +9 -1
|
@@ -1,5 +1,6 @@
|
|
|
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
5
|
import { SyncRetryExhaustedError } from "./index.js";
|
|
5
6
|
import type { ReadDependency } from "../transaction/transaction.js";
|
|
@@ -9,6 +10,9 @@ import { randomBytes } from '@noble/hashes/utils.js';
|
|
|
9
10
|
import { toString as uint8ArrayToString } from 'uint8arrays/to-string';
|
|
10
11
|
import { Latches } from "../utility/latches.js";
|
|
11
12
|
import { jitteredBackoffMs, abortableDelay, makeAbortError } from "../utility/backoff.js";
|
|
13
|
+
import { createLogger } from "../logger.js";
|
|
14
|
+
|
|
15
|
+
const log = createLogger('collection');
|
|
12
16
|
|
|
13
17
|
/** Default base backoff (and historical fixed delay) between sync retries, in ms. */
|
|
14
18
|
const PendingRetryDelayMs = 100;
|
|
@@ -57,7 +61,59 @@ export class Collection<TAction> implements ICollection<TAction> {
|
|
|
57
61
|
this.latchId = `Collection:${this.id}`;
|
|
58
62
|
}
|
|
59
63
|
|
|
60
|
-
|
|
64
|
+
/** Open an EXISTING collection.
|
|
65
|
+
*
|
|
66
|
+
* Resolves to `undefined` when the header block probe comes back empty — an
|
|
67
|
+
* authoritatively absent header, meaning nothing has ever been committed under this id.
|
|
68
|
+
* A header the storage layer could not RETRIEVE (a revision this node cannot
|
|
69
|
+
* reconstruct, an unreachable cohort) is not absent: the probe throws
|
|
70
|
+
* {@link BlockUnavailableError} instead of resolving `undefined`, so an unreachable
|
|
71
|
+
* collection can never be mistaken for a nonexistent one.
|
|
72
|
+
*
|
|
73
|
+
* Use this wherever reading — not creating — is what was meant. {@link createOrOpen}
|
|
74
|
+
* would instead stage a fresh empty collection, and reads through it would report an
|
|
75
|
+
* absent dataset as a legitimately empty one. */
|
|
76
|
+
static async open<TAction>(transactor: ITransactor, id: CollectionId, init: CollectionInitOptions<TAction>): Promise<Collection<TAction> | undefined> {
|
|
77
|
+
const { source, sourceCache, tracker, header } = await Collection.probeHeader(transactor, id);
|
|
78
|
+
if (!header) {
|
|
79
|
+
// Return before anything is staged: the tracker's transforms stay empty, so a caller
|
|
80
|
+
// that ignores the undefined cannot later sync a phantom collection into existence.
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
await Collection.attachToLog<TAction>(source, transactor, tracker, id, header);
|
|
84
|
+
return new Collection(id, transactor, init.modules, source, sourceCache, tracker, init.filterConflict);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Open an existing collection, or stage a fresh empty one in the local tracker when the
|
|
88
|
+
* header is authoritatively absent. Nothing is written to storage until {@link sync}.
|
|
89
|
+
*
|
|
90
|
+
* Correct only where inventing a collection is genuinely intended — a first write, a
|
|
91
|
+
* bootstrap path. The create branch logs `collection:invented`; prefer {@link open} on
|
|
92
|
+
* any pure read path. */
|
|
93
|
+
static async createOrOpen<TAction>(transactor: ITransactor, id: CollectionId, init: CollectionInitOptions<TAction>): Promise<Collection<TAction>> {
|
|
94
|
+
const { source, sourceCache, tracker, header } = await Collection.probeHeader(transactor, id);
|
|
95
|
+
|
|
96
|
+
if (header) { // Collection already exists
|
|
97
|
+
await Collection.attachToLog<TAction>(source, transactor, tracker, id, header);
|
|
98
|
+
} else { // Collection does not exist
|
|
99
|
+
log('collection:invented id=%s — no committed header found; staging a fresh empty collection', id);
|
|
100
|
+
const headerBlock = init.createHeaderBlock(id, tracker);
|
|
101
|
+
tracker.insert(headerBlock);
|
|
102
|
+
source.actionContext = undefined;
|
|
103
|
+
await Log.open<Action<TAction>>(tracker, id);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return new Collection(id, transactor, init.modules, source, sourceCache, tracker, init.filterConflict);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The per-instance read wiring every open path needs, plus the header probe result.
|
|
110
|
+
* Shared by {@link open} and {@link createOrOpen} so the two cannot drift. */
|
|
111
|
+
private static async probeHeader(transactor: ITransactor, id: CollectionId): Promise<{
|
|
112
|
+
source: TransactorSource<IBlock>,
|
|
113
|
+
sourceCache: CacheSource<IBlock>,
|
|
114
|
+
tracker: Tracker<IBlock>,
|
|
115
|
+
header: CollectionHeaderBlock | undefined,
|
|
116
|
+
}> {
|
|
61
117
|
// Start with a context that has an infinite revision number to ensure that we always fetch the latest log information.
|
|
62
118
|
// One shared read-dependency collector feeds both the source (direct structural reads) and the cache (every
|
|
63
119
|
// cache hit/miss), so a block read from either layer records a dependency — cache hits included.
|
|
@@ -66,22 +122,29 @@ export class Collection<TAction> implements ICollection<TAction> {
|
|
|
66
122
|
const sourceCache = new CacheSource(source, undefined, collector);
|
|
67
123
|
const tracker = new Tracker(sourceCache);
|
|
68
124
|
const header = await source.tryGet(id) as CollectionHeaderBlock | undefined;
|
|
125
|
+
return { source, sourceCache, tracker, header };
|
|
126
|
+
}
|
|
69
127
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
128
|
+
/** Walk an existing collection's log and point the source at its latest action context.
|
|
129
|
+
* A header we just probed successfully but whose log will not open is a fault, not an
|
|
130
|
+
* absence — throw rather than let the collection read as empty. (The re-read goes through
|
|
131
|
+
* the tracker/cache, so it can disagree with the probe when storage is flaky mid-open.) */
|
|
132
|
+
private static async attachToLog<TAction>(
|
|
133
|
+
source: TransactorSource<IBlock>,
|
|
134
|
+
transactor: ITransactor,
|
|
135
|
+
tracker: Tracker<IBlock>,
|
|
136
|
+
id: CollectionId,
|
|
137
|
+
header: CollectionHeaderBlock,
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
// Bootstrap ActionContext from the committed tail before walking the chain.
|
|
140
|
+
// This allows the transactor to serve pending non-tail blocks during Log.open.
|
|
141
|
+
await Collection.bootstrapContext(source, transactor, header);
|
|
74
142
|
|
|
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);
|
|
143
|
+
const collectionLog = await Log.open<Action<TAction>>(tracker, id);
|
|
144
|
+
if (!collectionLog) {
|
|
145
|
+
throw new Error(`Log not found for collection ${id}`);
|
|
82
146
|
}
|
|
83
|
-
|
|
84
|
-
return new Collection(id, transactor, init.modules, source, sourceCache, tracker, init.filterConflict);
|
|
147
|
+
source.actionContext = await collectionLog.getActionContext();
|
|
85
148
|
}
|
|
86
149
|
|
|
87
150
|
async act(...actions: Action<TAction>[]) {
|
|
@@ -129,6 +192,10 @@ export class Collection<TAction> implements ICollection<TAction> {
|
|
|
129
192
|
|
|
130
193
|
// Bootstrap context from committed tail so pending blocks are accessible.
|
|
131
194
|
// Read through tracker so Chain.open inside Log.open reuses the cached header.
|
|
195
|
+
// A header the storage layer could not retrieve throws BlockUnavailableError out of
|
|
196
|
+
// this read (it is not a StaleFailure, so sync's retry loop does not absorb it) —
|
|
197
|
+
// so an ABSENT header here is authoritative: nothing was ever committed under this
|
|
198
|
+
// id, and the silent no-op below is correct rather than a masked failure.
|
|
132
199
|
const header = await tracker.tryGet(this.id) as CollectionHeaderBlock | undefined;
|
|
133
200
|
if (header) {
|
|
134
201
|
await Collection.bootstrapContext(source, this.transactor, header);
|
|
@@ -136,8 +203,8 @@ export class Collection<TAction> implements ICollection<TAction> {
|
|
|
136
203
|
|
|
137
204
|
// Get the latest entries from the log, starting from where we left off
|
|
138
205
|
const actionContext = this.source.actionContext;
|
|
139
|
-
const
|
|
140
|
-
const latest =
|
|
206
|
+
const collectionLog = await Log.open<Action<TAction>>(tracker, this.id);
|
|
207
|
+
const latest = collectionLog ? await collectionLog.getFrom(actionContext?.rev ?? 0) : undefined;
|
|
141
208
|
|
|
142
209
|
// Process the entries and track the blocks they affect
|
|
143
210
|
let anyConflicts = false;
|
|
@@ -166,7 +233,7 @@ export class Collection<TAction> implements ICollection<TAction> {
|
|
|
166
233
|
// read — drop the reverted blocks from the read cache and replay pending work against the reverted
|
|
167
234
|
// base (docs/right-is-right.md §Client notification). De-duped across cascade children by reverted
|
|
168
235
|
// block; over-inclusive by design (over-invalidation just resubmits — it never wrongly retains).
|
|
169
|
-
const invalidations =
|
|
236
|
+
const invalidations = collectionLog ? await collectionLog.getInvalidationsFrom(actionContext?.rev ?? 0) : [];
|
|
170
237
|
if (invalidations.length > 0) {
|
|
171
238
|
const revertedBlockIds = [...new Set(invalidations.flatMap(inv => inv.reverted.map(r => r.blockId)))];
|
|
172
239
|
this.sourceCache.clear(revertedBlockIds);
|
|
@@ -316,12 +383,12 @@ export class Collection<TAction> implements ICollection<TAction> {
|
|
|
316
383
|
const tracker = new Tracker(this.sourceCache, snapshot);
|
|
317
384
|
|
|
318
385
|
// Add the action to the log (in local tracking space)
|
|
319
|
-
const
|
|
320
|
-
if (!
|
|
386
|
+
const collectionLog = await Log.open<Action<TAction>>(tracker, this.id);
|
|
387
|
+
if (!collectionLog) {
|
|
321
388
|
throw new Error(`Log not found for collection ${this.id}`);
|
|
322
389
|
}
|
|
323
390
|
const newRev = (this.source.actionContext?.rev ?? 0) + 1;
|
|
324
|
-
const addResult = await
|
|
391
|
+
const addResult = await collectionLog.addActions(pending, actionId, newRev, () => tracker.transformedBlockIds());
|
|
325
392
|
|
|
326
393
|
// Commit the action to the transactor. Carry the aged retry priority derived from the
|
|
327
394
|
// consecutive-failure count so a sync that keeps losing concurrent races out-ranks fresh
|
|
@@ -381,11 +448,11 @@ export class Collection<TAction> implements ICollection<TAction> {
|
|
|
381
448
|
}
|
|
382
449
|
|
|
383
450
|
async *selectLog(forward = true): AsyncIterableIterator<Action<TAction>> {
|
|
384
|
-
const
|
|
385
|
-
if (!
|
|
451
|
+
const collectionLog = await Log.open<Action<TAction>>(this.tracker, this.id);
|
|
452
|
+
if (!collectionLog) {
|
|
386
453
|
throw new Error(`Log not found for collection ${this.id}`);
|
|
387
454
|
}
|
|
388
|
-
for await (const entry of
|
|
455
|
+
for await (const entry of collectionLog.select(undefined, forward)) {
|
|
389
456
|
if (entry.action) {
|
|
390
457
|
// NOTE: copy-then-reverse to avoid mutating the stored log entry array.
|
|
391
458
|
// Once tsconfig targets ES2023, `entry.action.actions.toReversed()` is cleaner.
|
|
@@ -425,6 +492,13 @@ export class Collection<TAction> implements ICollection<TAction> {
|
|
|
425
492
|
* with context=undefined. Its state.latest contains the ActionRev of the most recent
|
|
426
493
|
* committed action — exactly the proof needed for the transactor to serve pending
|
|
427
494
|
* non-tail blocks during chain walks.
|
|
495
|
+
*
|
|
496
|
+
* This read goes to the transactor directly rather than through {@link TransactorSource},
|
|
497
|
+
* so it has to honour the `unavailable` flag itself: a tail the repo could not retrieve
|
|
498
|
+
* must not degrade into "no context", which would leave the chain walk unable to see
|
|
499
|
+
* pending non-tail blocks and the collection reading as if they did not exist. A tail
|
|
500
|
+
* with no `state.latest` and NO flag is a real answer (nothing committed yet) and still
|
|
501
|
+
* no-ops.
|
|
428
502
|
*/
|
|
429
503
|
private static async bootstrapContext(
|
|
430
504
|
source: TransactorSource<IBlock>,
|
|
@@ -434,7 +508,11 @@ export class Collection<TAction> implements ICollection<TAction> {
|
|
|
434
508
|
const tailId = header.tailId;
|
|
435
509
|
if (tailId) {
|
|
436
510
|
const tailResult = await transactor.get({ blockIds: [tailId] });
|
|
437
|
-
const
|
|
511
|
+
const tailEntry = tailResult?.[tailId];
|
|
512
|
+
if (tailEntry?.unavailable !== undefined && tailEntry.block == null) {
|
|
513
|
+
throw new BlockUnavailableError(tailId, tailEntry.unavailable);
|
|
514
|
+
}
|
|
515
|
+
const tailState = tailEntry?.state;
|
|
438
516
|
if (tailState?.latest) {
|
|
439
517
|
source.actionContext = {
|
|
440
518
|
committed: [{ actionId: tailState.latest.actionId, rev: tailState.latest.rev }],
|
|
@@ -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> {
|
package/src/network/index.ts
CHANGED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { StaleFailure } from "./struct.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The single rule for "is this non-success retryable after a re-read?" Both write paths and the
|
|
5
|
+
* transactor's aggregation call this — no consumer re-derives it.
|
|
6
|
+
*
|
|
7
|
+
* {@link StaleFailure.conflict} is authoritative when present. The `missing`/`pending` fallback
|
|
8
|
+
* covers producers that have not been taught the field, including a remote peer on an older build
|
|
9
|
+
* (the repo protocol is plain JSON, so an unset field simply arrives absent).
|
|
10
|
+
*/
|
|
11
|
+
export function isConflictFailure(failure: StaleFailure): boolean {
|
|
12
|
+
return failure.conflict ?? Boolean(failure.missing?.length || failure.pending?.length);
|
|
13
|
+
}
|
package/src/network/struct.ts
CHANGED
|
@@ -71,6 +71,14 @@ export type StaleFailure = {
|
|
|
71
71
|
missing?: ActionTransforms[];
|
|
72
72
|
/** List of actions that are pending on the blocks touched by this pend */
|
|
73
73
|
pending?: ActionPending[];
|
|
74
|
+
/**
|
|
75
|
+
* Explicit retryability. True when this failure is an optimistic-concurrency loss — the
|
|
76
|
+
* requested revision was taken, or a rival pend holds the blocks — so a re-read, rebase and
|
|
77
|
+
* re-pend can win. Set it only when the producer genuinely classified the failure; leave it
|
|
78
|
+
* absent otherwise, and consumers fall back to inferring from `missing`/`pending`.
|
|
79
|
+
* Read it through `isConflictFailure` rather than testing it directly.
|
|
80
|
+
*/
|
|
81
|
+
conflict?: boolean;
|
|
74
82
|
};
|
|
75
83
|
|
|
76
84
|
export type PendResult = PendSuccess | StaleFailure;
|
|
@@ -126,13 +134,39 @@ export type BlockGets = {
|
|
|
126
134
|
context?: ActionContext; // Latest if this is omitted
|
|
127
135
|
};
|
|
128
136
|
|
|
137
|
+
/** Why a repo could not establish whether a block exists. Present ONLY when the repo
|
|
138
|
+
* knows its own answer is a guess; an absent field is an authoritative answer. */
|
|
139
|
+
export type BlockUnavailableReason =
|
|
140
|
+
/** Records for this block exist here but it cannot be reconstructed locally — a
|
|
141
|
+
* revision was received with no base to apply it to, or its history is truncated. */
|
|
142
|
+
| 'unmaterializable'
|
|
143
|
+
/** Nothing is held locally and the cohort could not be consulted to confirm it. */
|
|
144
|
+
| 'peers-unreachable';
|
|
145
|
+
|
|
129
146
|
export type GetBlockResult = {
|
|
130
147
|
/** The retrieved block - undefined if the block was deleted */
|
|
131
148
|
block?: IBlock;
|
|
132
149
|
/** The latest and pending states of the repo that retrieved the block */
|
|
133
150
|
state: BlockActionState;
|
|
151
|
+
/** Set when this repo could not determine whether the block exists — its answer is a
|
|
152
|
+
* guess, not an authoritative absent. Every producer that omits it (including
|
|
153
|
+
* TestTransactor) keeps meaning "authoritative". */
|
|
154
|
+
unavailable?: BlockUnavailableReason;
|
|
134
155
|
};
|
|
135
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Thrown by a block read when the responsible repo could not determine whether the
|
|
159
|
+
* block exists. Distinct from "the block is absent" (undefined) and from a transport
|
|
160
|
+
* failure — this node's data is genuinely indeterminate and the caller must not treat
|
|
161
|
+
* it as empty. Not a StaleFailure: `Collection.sync` does not retry it.
|
|
162
|
+
*/
|
|
163
|
+
export class BlockUnavailableError extends Error {
|
|
164
|
+
constructor(readonly blockId: BlockId, readonly reason: BlockUnavailableReason) {
|
|
165
|
+
super(`Block ${blockId} is unavailable (${reason}): the repo could not determine whether it exists`);
|
|
166
|
+
this.name = 'BlockUnavailableError';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
136
170
|
export type GetBlockResults = Record<BlockId, GetBlockResult>;
|
|
137
171
|
|
|
138
172
|
/**
|
|
@@ -189,10 +189,13 @@ export class TestTransactor implements ITransactor {
|
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
// Handle failure due to committed conflicts first
|
|
192
|
+
// Handle failure due to committed conflicts first.
|
|
193
|
+
// `conflict: true` on the three optimistic-concurrency returns below mirrors what
|
|
194
|
+
// StorageRepo.pend now emits, so consumers of this test transactor see the real shape.
|
|
193
195
|
if (missing.length > 0) {
|
|
194
196
|
return {
|
|
195
197
|
success: false,
|
|
198
|
+
conflict: true,
|
|
196
199
|
missing
|
|
197
200
|
};
|
|
198
201
|
}
|
|
@@ -200,7 +203,7 @@ export class TestTransactor implements ITransactor {
|
|
|
200
203
|
// Handle failure/retry due to pending conflicts
|
|
201
204
|
if (conflictingPendings.length > 0) {
|
|
202
205
|
if (policy === 'f') {
|
|
203
|
-
return { success: false, pending: conflictingPendings };
|
|
206
|
+
return { success: false, conflict: true, pending: conflictingPendings };
|
|
204
207
|
} else if (policy === 'r') {
|
|
205
208
|
// Simulate fetching pending transforms for 'r' policy
|
|
206
209
|
const pendingWithTransforms = conflictingPendings
|
|
@@ -217,6 +220,7 @@ export class TestTransactor implements ITransactor {
|
|
|
217
220
|
|
|
218
221
|
return {
|
|
219
222
|
success: false,
|
|
223
|
+
conflict: true,
|
|
220
224
|
pending: pendingWithTransforms
|
|
221
225
|
};
|
|
222
226
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ITransactor, BlockId, CollectionId, Transforms, PendRequest, CommitRequest, ActionId } from "../index.js";
|
|
2
2
|
import type { Transaction, ExecutionResult, ITransactionEngine, CollectionActions, ReadDependency } from "./transaction.js";
|
|
3
3
|
import type { PeerId } from "../network/types.js";
|
|
4
|
+
import { isConflictFailure } from "../network/stale-failure.js";
|
|
4
5
|
import type { Collection } from "../collection/collection.js";
|
|
5
6
|
import type { SyncOptions } from "../collection/index.js";
|
|
6
7
|
import { isTransactionExpired, clampPriority } from "./transaction.js";
|
|
@@ -21,11 +22,11 @@ const DefaultBaseBackoffMs = 100;
|
|
|
21
22
|
const DefaultMaxBackoffMs = 5000;
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
|
-
* A pend that failed. `conflict` marks the retryable class — an optimistic-concurrency collision
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
25
|
+
* A pend that failed. `conflict` marks the retryable class — an optimistic-concurrency collision that
|
|
26
|
+
* a re-read + re-pend can clear — as decided by `isConflictFailure` over the failure response. A hard
|
|
27
|
+
* rejection (storage full, policy) is NOT a conflict and is not worth re-driving. Thrown by
|
|
28
|
+
* {@link TransactionCoordinator.pendCollection} so the fan-out in pendPhase can settle it and read
|
|
29
|
+
* the flag off the rejection.
|
|
29
30
|
*/
|
|
30
31
|
class PendRejectedError extends Error {
|
|
31
32
|
constructor(collectionId: CollectionId, readonly conflict: boolean, reason?: string) {
|
|
@@ -929,11 +930,11 @@ export class TransactionCoordinator {
|
|
|
929
930
|
|
|
930
931
|
const pendResult = await this.transactor.pend(pendRequest);
|
|
931
932
|
if (!pendResult.success) {
|
|
932
|
-
//
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
|
|
936
|
-
throw new PendRejectedError(collectionId,
|
|
933
|
+
// Retryability comes from the response itself: a producer that classified the failure sets
|
|
934
|
+
// `conflict`, and only where no producer set it do we fall back to inferring from
|
|
935
|
+
// `missing`/`pending`. Either way a conflict is an optimistic-concurrency loss, clearable
|
|
936
|
+
// by a re-read; anything else is a hard rejection (storage/policy) that re-driving won't fix.
|
|
937
|
+
throw new PendRejectedError(collectionId, isConflictFailure(pendResult), pendResult.reason);
|
|
937
938
|
}
|
|
938
939
|
|
|
939
940
|
return { collectionId, blockIds: pendResult.blockIds };
|
|
@@ -1052,6 +1053,11 @@ export class TransactionCoordinator {
|
|
|
1052
1053
|
}
|
|
1053
1054
|
// Permanent stale failure: do not retry here. It IS a clean stale loss, though, so
|
|
1054
1055
|
// mark it retryable at the coordinator level (after a re-read advances the rev).
|
|
1056
|
+
// NOTE: deliberately does NOT consult `isConflictFailure` / `StaleFailure.conflict`
|
|
1057
|
+
// like the pend path does. Once the pend succeeded, a returned commit failure means
|
|
1058
|
+
// the revision slot moved, and no commit producer sets `conflict` today. If a commit
|
|
1059
|
+
// producer ever starts distinguishing hard commit rejections (validator policy,
|
|
1060
|
+
// storage fault) from lost races, gate `stale` on isConflictFailure here.
|
|
1055
1061
|
return {
|
|
1056
1062
|
collectionId,
|
|
1057
1063
|
committed: false,
|