@optimystic/db-core 0.24.2 → 0.25.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.
Files changed (103) hide show
  1. package/dist/src/blocks/helpers.d.ts +5 -0
  2. package/dist/src/blocks/helpers.d.ts.map +1 -1
  3. package/dist/src/blocks/helpers.js +12 -0
  4. package/dist/src/blocks/helpers.js.map +1 -1
  5. package/dist/src/cluster/membership.d.ts +7 -0
  6. package/dist/src/cluster/membership.d.ts.map +1 -1
  7. package/dist/src/cluster/membership.js +12 -8
  8. package/dist/src/cluster/membership.js.map +1 -1
  9. package/dist/src/cluster/structs.d.ts +27 -1
  10. package/dist/src/cluster/structs.d.ts.map +1 -1
  11. package/dist/src/cluster/structs.js.map +1 -1
  12. package/dist/src/collection/action.d.ts +14 -0
  13. package/dist/src/collection/action.d.ts.map +1 -1
  14. package/dist/src/collection/action.js +16 -1
  15. package/dist/src/collection/action.js.map +1 -1
  16. package/dist/src/collection/collection.d.ts +273 -4
  17. package/dist/src/collection/collection.d.ts.map +1 -1
  18. package/dist/src/collection/collection.js +427 -30
  19. package/dist/src/collection/collection.js.map +1 -1
  20. package/dist/src/collections/tree/tree.d.ts +16 -1
  21. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  22. package/dist/src/collections/tree/tree.js +19 -0
  23. package/dist/src/collections/tree/tree.js.map +1 -1
  24. package/dist/src/index.d.ts +1 -0
  25. package/dist/src/index.d.ts.map +1 -1
  26. package/dist/src/index.js +1 -0
  27. package/dist/src/index.js.map +1 -1
  28. package/dist/src/network/i-repo.d.ts +11 -1
  29. package/dist/src/network/i-repo.d.ts.map +1 -1
  30. package/dist/src/network/stale-failure.d.ts +21 -0
  31. package/dist/src/network/stale-failure.d.ts.map +1 -1
  32. package/dist/src/network/stale-failure.js +22 -0
  33. package/dist/src/network/stale-failure.js.map +1 -1
  34. package/dist/src/network/struct.d.ts +66 -21
  35. package/dist/src/network/struct.d.ts.map +1 -1
  36. package/dist/src/network/struct.js.map +1 -1
  37. package/dist/src/testing/test-transactor.d.ts +22 -0
  38. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  39. package/dist/src/testing/test-transactor.js +44 -5
  40. package/dist/src/testing/test-transactor.js.map +1 -1
  41. package/dist/src/transaction/coordinator.d.ts +20 -0
  42. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  43. package/dist/src/transaction/coordinator.js +256 -113
  44. package/dist/src/transaction/coordinator.js.map +1 -1
  45. package/dist/src/transaction/operations-hash.d.ts +1 -1
  46. package/dist/src/transaction/operations-hash.js +1 -1
  47. package/dist/src/transaction/transaction.d.ts +4 -2
  48. package/dist/src/transaction/transaction.d.ts.map +1 -1
  49. package/dist/src/transactor/network-transactor.d.ts +21 -1
  50. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  51. package/dist/src/transactor/network-transactor.js +122 -29
  52. package/dist/src/transactor/network-transactor.js.map +1 -1
  53. package/dist/src/transactor/transactor-source.d.ts +11 -5
  54. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  55. package/dist/src/transactor/transactor-source.js +16 -8
  56. package/dist/src/transactor/transactor-source.js.map +1 -1
  57. package/dist/src/transform/cache-source.d.ts +13 -0
  58. package/dist/src/transform/cache-source.d.ts.map +1 -1
  59. package/dist/src/transform/cache-source.js +18 -0
  60. package/dist/src/transform/cache-source.js.map +1 -1
  61. package/dist/src/transform/digest.d.ts +18 -0
  62. package/dist/src/transform/digest.d.ts.map +1 -0
  63. package/dist/src/transform/digest.js +65 -0
  64. package/dist/src/transform/digest.js.map +1 -0
  65. package/dist/src/transform/index.d.ts +1 -0
  66. package/dist/src/transform/index.d.ts.map +1 -1
  67. package/dist/src/transform/index.js +1 -0
  68. package/dist/src/transform/index.js.map +1 -1
  69. package/dist/src/transform/tracker.d.ts +16 -0
  70. package/dist/src/transform/tracker.d.ts.map +1 -1
  71. package/dist/src/transform/tracker.js +40 -1
  72. package/dist/src/transform/tracker.js.map +1 -1
  73. package/dist/src/utility/canonical-json.d.ts +11 -0
  74. package/dist/src/utility/canonical-json.d.ts.map +1 -0
  75. package/dist/src/utility/canonical-json.js +15 -0
  76. package/dist/src/utility/canonical-json.js.map +1 -0
  77. package/dist/src/utility/lru-map.d.ts +2 -0
  78. package/dist/src/utility/lru-map.d.ts.map +1 -1
  79. package/dist/src/utility/lru-map.js +4 -0
  80. package/dist/src/utility/lru-map.js.map +1 -1
  81. package/package.json +2 -1
  82. package/src/blocks/helpers.ts +26 -13
  83. package/src/cluster/membership.ts +87 -85
  84. package/src/cluster/structs.ts +28 -1
  85. package/src/collection/action.ts +17 -0
  86. package/src/collection/collection.ts +1129 -688
  87. package/src/collections/tree/tree.ts +341 -320
  88. package/src/index.ts +24 -23
  89. package/src/network/i-repo.ts +59 -46
  90. package/src/network/stale-failure.ts +67 -43
  91. package/src/network/struct.ts +332 -270
  92. package/src/testing/test-transactor.ts +680 -638
  93. package/src/transaction/coordinator.ts +1266 -1110
  94. package/src/transaction/operations-hash.ts +1 -1
  95. package/src/transaction/transaction.ts +4 -2
  96. package/src/transactor/network-transactor.ts +123 -32
  97. package/src/transactor/transactor-source.ts +17 -9
  98. package/src/transform/cache-source.ts +20 -0
  99. package/src/transform/digest.ts +72 -0
  100. package/src/transform/index.ts +1 -0
  101. package/src/transform/tracker.ts +42 -1
  102. package/src/utility/canonical-json.ts +16 -0
  103. package/src/utility/lru-map.ts +5 -0
@@ -1,688 +1,1129 @@
1
- import type { IBlock, Action, ActionType, ActionHandler, BlockId, ITransactor, BlockStore, Transforms, ActionId } from "../index.js";
2
- import { Log } from "../log/log.js";
3
- import { Atomic } from "../transform/atomic.js";
4
- import { Tracker } from "../transform/tracker.js";
5
- import { CacheSource } from "../transform/cache-source.js";
6
- import { copyTransforms, isTransformsEmpty } from "../transform/helpers.js";
7
- import { TransactorSource } from "../transactor/transactor-source.js";
8
- import { BlockUnavailableError, BlockPossiblyStaleError } from "../network/struct.js";
9
- import type { CollectionHeaderBlock, CollectionId, ICollection, SyncOptions } from "./index.js";
10
- import { CollectionHeaderVanishedError, SyncRetryExhaustedError } from "./struct.js";
11
- import type { ActionContext } from "./action.js";
12
- import type { ReadDependency } from "../transaction/transaction.js";
13
- import { clampPriority } from "../transaction/transaction.js";
14
- import { ReadDependencyCollector } from "../transaction/read-dependency-collector.js";
15
- import { randomBytes } from '@noble/hashes/utils.js';
16
- import { toString as uint8ArrayToString } from 'uint8arrays/to-string';
17
- import { Latches } from "../utility/latches.js";
18
- import { jitteredBackoffMs, abortableDelay, makeAbortError } from "../utility/backoff.js";
19
- import { createLogger } from "../logger.js";
20
-
21
- const log = createLogger('collection');
22
-
23
- /** Default base backoff (and historical fixed delay) between sync retries, in ms. */
24
- const PendingRetryDelayMs = 100;
25
- /** Default max consecutive no-progress stale-failure retries before {@link Collection.sync} gives up. */
26
- const DefaultMaxAttempts = 10;
27
- /** Default ceiling on a single exponential-backoff sleep, in ms. */
28
- const DefaultMaxBackoffMs = 5000;
29
-
30
- export type CollectionInitOptions<TAction> = {
31
- modules: Record<ActionType, ActionHandler<TAction>>;
32
- createHeaderBlock: (id: BlockId, store: BlockStore<IBlock>) => IBlock;
33
- /** Called for each local action that is potentially in conflict with a remote action.
34
- * @param action - The local action to check
35
- * @param potential - The remote actions that are potentially in conflict
36
- * @returns The original action (return the same instance to keep it as-is), a replacement
37
- * action (return a new instance to apply instead of the original — it is re-staged via
38
- * the conflict replay), or undefined to discard this action
39
- */
40
- filterConflict?: (action: Action<TAction>, potential: Action<TAction>[]) => Action<TAction> | undefined
41
- }
42
-
43
- /** Options for building a committed read view (see {@link Collection.createReadTracker}
44
- * and `Tree.readView`). */
45
- export interface ReadViewOptions {
46
- /** Record read dependencies into the collection's shared collector.
47
- * Default false a pinned committed view is not part of any transaction's
48
- * conflict set, so its reads must not be able to fail the writer's commit
49
- * validation. Deferred-constraint safety does not depend on these reads:
50
- * validator peers re-execute the transaction's recorded statements against
51
- * their own committed state, so a constraint that no longer holds is caught
52
- * at validation regardless. */
53
- recordReads?: boolean;
54
- /** Pin the view to this committed boundary instead of the collection's CURRENT
55
- * action context. Pass a {@link CollectionSnapshot.context} so the view describes
56
- * the same committed moment the snapshot's transforms sat on — even when the
57
- * collection has committed further since (e.g. a multi-tree commit sweep that has
58
- * already flushed THIS tree but not its siblings). Blocks cached at revisions
59
- * newer than the pin are excluded from the view's warm seed and refetched from
60
- * the transactor at the pinned revision. Default: the current context. */
61
- pinContext?: ActionContext;
62
- }
63
-
64
- /** A point-in-time copy of a collection's staged (un-synced) state, produced by
65
- * {@link Collection.snapshotPending} and consumed by {@link Collection.restorePending}. */
66
- export interface CollectionSnapshot<TAction> {
67
- /** Deep-cloned tracker transforms at snapshot time. */
68
- transforms: Transforms;
69
- /** Pending actions queued at snapshot time. */
70
- pending: Action<TAction>[];
71
- /** The committed boundary (action context) the staged state sat on when captured.
72
- * `undefined` for a collection with no committed revision yet (an invented
73
- * collection whose header/root still live in the tracker). A read view built
74
- * from this snapshot pins to this boundary (see {@link ReadViewOptions.pinContext}),
75
- * so the view stays coherent with the snapshot's transforms even if the
76
- * collection commits further before the view is built. */
77
- context?: ActionContext;
78
- }
79
-
80
- export class Collection<TAction> implements ICollection<TAction> {
81
- private pending: Action<TAction>[] = [];
82
- private readonly latchId: string;
83
-
84
- protected constructor(
85
- public readonly id: CollectionId,
86
- public readonly transactor: ITransactor,
87
- private readonly handlers: Record<ActionType, ActionHandler<TAction>>,
88
- private readonly source: TransactorSource<IBlock>,
89
- /** Cache of unmodified blocks from the source */
90
- private readonly sourceCache: CacheSource<IBlock>,
91
- /** Tracked Changes */
92
- public readonly tracker: Tracker<IBlock>,
93
- private readonly filterConflict?: (action: Action<TAction>, potential: Action<TAction>[]) => Action<TAction> | undefined,
94
- ) {
95
- this.latchId = `Collection:${this.id}`;
96
- }
97
-
98
- /** Open an EXISTING collection.
99
- *
100
- * Resolves to `undefined` when the header block probe comes back empty — an
101
- * authoritatively absent header, meaning nothing has ever been committed under this id.
102
- * A header the storage layer could not RETRIEVE (a revision this node cannot
103
- * reconstruct, an unreachable cohort) is not absent: the probe throws
104
- * {@link BlockUnavailableError} instead of resolving `undefined`, so an unreachable
105
- * collection can never be mistaken for a nonexistent one.
106
- *
107
- * Use this wherever reading — not creating — is what was meant. {@link createOrOpen}
108
- * would instead stage a fresh empty collection, and reads through it would report an
109
- * absent dataset as a legitimately empty one. */
110
- static async open<TAction>(transactor: ITransactor, id: CollectionId, init: CollectionInitOptions<TAction>): Promise<Collection<TAction> | undefined> {
111
- const { source, sourceCache, tracker, header } = await Collection.probeHeader(transactor, id);
112
- if (!header) {
113
- // Return before anything is staged: the tracker's transforms stay empty, so a caller
114
- // that ignores the undefined cannot later sync a phantom collection into existence.
115
- return undefined;
116
- }
117
- await Collection.attachToLog<TAction>(source, transactor, tracker, id, header);
118
- return new Collection(id, transactor, init.modules, source, sourceCache, tracker, init.filterConflict);
119
- }
120
-
121
- /** Open an existing collection, or stage a fresh empty one in the local tracker when the
122
- * header is authoritatively absent. Nothing is written to storage until {@link sync}.
123
- *
124
- * Correct only where inventing a collection is genuinely intended — a first write, a
125
- * bootstrap path. The create branch logs `collection:invented`; prefer {@link open} on
126
- * any pure read path. */
127
- static async createOrOpen<TAction>(transactor: ITransactor, id: CollectionId, init: CollectionInitOptions<TAction>): Promise<Collection<TAction>> {
128
- const { source, sourceCache, tracker, header } = await Collection.probeHeader(transactor, id);
129
-
130
- if (header) { // Collection already exists
131
- await Collection.attachToLog<TAction>(source, transactor, tracker, id, header);
132
- } else { // Collection does not exist
133
- log('collection:invented id=%s no committed header found; staging a fresh empty collection', id);
134
- const headerBlock = init.createHeaderBlock(id, tracker);
135
- tracker.insert(headerBlock);
136
- source.actionContext = undefined;
137
- await Log.open<Action<TAction>>(tracker, id);
138
- }
139
-
140
- return new Collection(id, transactor, init.modules, source, sourceCache, tracker, init.filterConflict);
141
- }
142
-
143
- /** The per-instance read wiring every open path needs, plus the header probe result.
144
- * Shared by {@link open} and {@link createOrOpen} so the two cannot drift. */
145
- private static async probeHeader(transactor: ITransactor, id: CollectionId): Promise<{
146
- source: TransactorSource<IBlock>,
147
- sourceCache: CacheSource<IBlock>,
148
- tracker: Tracker<IBlock>,
149
- header: CollectionHeaderBlock | undefined,
150
- }> {
151
- // Start with a context that has an infinite revision number to ensure that we always fetch the latest log information.
152
- // One shared read-dependency collector feeds both the source (direct structural reads) and the cache (every
153
- // cache hit/miss), so a block read from either layer records a dependencycache hits included.
154
- const collector = new ReadDependencyCollector();
155
- const source = new TransactorSource(id, transactor, undefined, collector);
156
- const sourceCache = new CacheSource(source, undefined, collector);
157
- const tracker = new Tracker(sourceCache);
158
- const header = await source.tryGet(id) as CollectionHeaderBlock | undefined;
159
- return { source, sourceCache, tracker, header };
160
- }
161
-
162
- /** Walk an existing collection's log and point the source at its latest action context.
163
- * A header we just probed successfully but whose log will not open is a fault, not an
164
- * absence throw rather than let the collection read as empty. (The re-read goes through
165
- * the tracker/cache, so it can disagree with the probe when storage is flaky mid-open.) */
166
- private static async attachToLog<TAction>(
167
- source: TransactorSource<IBlock>,
168
- transactor: ITransactor,
169
- tracker: Tracker<IBlock>,
170
- id: CollectionId,
171
- header: CollectionHeaderBlock,
172
- ): Promise<void> {
173
- // Bootstrap ActionContext from the committed tail before walking the chain.
174
- // This allows the transactor to serve pending non-tail blocks during Log.open.
175
- await Collection.bootstrapContext(source, transactor, header);
176
-
177
- const collectionLog = await Log.open<Action<TAction>>(tracker, id);
178
- if (!collectionLog) {
179
- throw new Error(`Log not found for collection ${id}`);
180
- }
181
- // Monotonic, not an overwrite: getActionContext resolves undefined when the chain has no
182
- // tail or the tail block carries zero entries, and that must not erase the revision
183
- // bootstrapContext just read off the committed tail.
184
- Collection.advanceContext(source, id, await collectionLog.getActionContext());
185
- }
186
-
187
- /** Adopt a freshly-read action context WITHOUT ever lowering the revision already held.
188
- *
189
- * The revision a collection last committed at is knowledge it earned; a read that found
190
- * nothing — or found an older view of the log — cannot un-earn it. Silently accepting the
191
- * lower value makes the next sync ask for a revision that is long gone, and every retry
192
- * repeats the same doomed request because each retry re-runs the same losing read.
193
- *
194
- * Equal revisions still adopt `next`: the rev is unchanged but its `committed` list may be
195
- * more complete than what we hold. */
196
- private static advanceContext(source: TransactorSource<IBlock>, id: CollectionId, next: ActionContext | undefined): void {
197
- const current = source.actionContext;
198
- if (next === undefined) {
199
- return; // The read learned nothing keep what we already know.
200
- }
201
- if (current !== undefined && next.rev < current.rev) {
202
- log('collection:context-not-lowered id=%s held=%d read=%d', id, current.rev, next.rev);
203
- return;
204
- }
205
- source.actionContext = next;
206
- }
207
-
208
- async act(...actions: Action<TAction>[]) {
209
- const release = await Latches.acquire(this.latchId);
210
- try {
211
- await this.actInternal(...actions);
212
- } finally {
213
- release();
214
- }
215
- }
216
-
217
- private async actInternal(...actions: Action<TAction>[]) {
218
- await this.internalTransact(...actions);
219
- this.pending.push(...actions);
220
- }
221
-
222
- private async internalTransact(...actions: Action<TAction>[]) {
223
- const atomic = new Atomic(this.tracker);
224
-
225
- for (const action of actions) {
226
- const handler = this.handlers[action.type];
227
- if (!handler) {
228
- throw new Error(`No handler for action type ${action.type}`);
229
- }
230
- await handler(action, atomic);
231
- }
232
-
233
- atomic.commit();
234
- }
235
-
236
- /** Load external changes and update our context to the latest log revision - resolve any conflicts with our pending actions. */
237
- async update() {
238
- const release = await Latches.acquire(this.latchId);
239
- try {
240
- await this.updateInternal();
241
- } finally {
242
- release();
243
- }
244
- }
245
-
246
- private async updateInternal() {
247
- // Start with a context that can see to the end of the log
248
- const source = new TransactorSource(this.id, this.transactor, undefined);
249
- const tracker = new Tracker(source);
250
-
251
- // Bootstrap context from committed tail so pending blocks are accessible.
252
- // Read through tracker so Chain.open inside Log.open reuses the cached header.
253
- // A header the storage layer could not retrieve throws BlockUnavailableError out of
254
- // this read (it is not a StaleFailure, so sync's retry loop does not absorb it).
255
- const header = await tracker.tryGet(this.id) as CollectionHeaderBlock | undefined;
256
- if (header) {
257
- await Collection.bootstrapContext(source, this.transactor, header);
258
- } else if (this.source.actionContext) {
259
- // An absent header is only believable for a collection that has never committed.
260
- // We hold a committed revision, so the two answers contradict each other — surface it
261
- // as a fault instead of no-opping into a forgotten revision and a rev-1 retry spin.
262
- // NOTE: this aborts every caller of update(), including TransactionCoordinator's
263
- // blanket refresh of ALL registered collections between commit retries a
264
- // non-participant with a momentarily-absent header now fails the whole retry rather
265
- // than being skipped. That is the intended loud failure; if it ever shows up as
266
- // otherwise-healthy transactions aborting, narrow that refresh to the transaction's
267
- // participants (see the note at coordinator.ts's update loop) rather than softening
268
- // this throw.
269
- throw new CollectionHeaderVanishedError(this.id, this.source.actionContext.rev);
270
- }
271
- // Falling through means the header is genuinely absent AND we hold no revision: nothing
272
- // was ever committed under this id. Log.open reads the same block id, so it too resolves
273
- // undefined and everything below no-ops correct here, rather than a masked failure.
274
-
275
- // Get the latest entries from the log, starting from where we left off
276
- const actionContext = this.source.actionContext;
277
- const collectionLog = await Log.open<Action<TAction>>(tracker, this.id);
278
- const latest = collectionLog ? await collectionLog.getFrom(actionContext?.rev ?? 0) : undefined;
279
-
280
- // Process the entries and track the blocks they affect
281
- let anyConflicts = false;
282
- for (const entry of latest?.entries ?? []) {
283
- // Filter any pending actions that conflict with the remote actions. Each pending
284
- // action maps to its effective form: the original, a replacement, or dropped.
285
- const before = this.pending;
286
- const after = before
287
- .map(p => this.doFilterConflict(p, entry.actions))
288
- .filter((a): a is Action<TAction> => a !== undefined);
289
- // A replacement or a discard changes the pending set; the tracker still holds the
290
- // pre-filter transforms, so force a replay to re-stage against the effective actions.
291
- // Identity comparison per the contract: keep => same instance, replace => new instance.
292
- // NOTE: a filterConflict hook that always allocates a fresh (but equal) instance instead
293
- // of returning the same one forces a replay on every update — if that ever shows up as a
294
- // hot path, compare by value/id here instead of by reference.
295
- const mutated = after.length !== before.length || after.some((a, i) => a !== before[i]);
296
- this.pending = after;
297
- this.sourceCache.clear(entry.blockIds);
298
- anyConflicts = anyConflicts || mutated || this.tracker.conflicts(new Set(entry.blockIds)).length > 0;
299
- }
300
-
301
- // React to durable invalidations that landed since we last synced. getFrom intentionally skips
302
- // invalidation entries (they are not pending/committed actions), so surface them separately: an
303
- // invalidation reverted committed content this client may have read, so treat it like a stale
304
- // read drop the reverted blocks from the read cache and replay pending work against the reverted
305
- // base (docs/right-is-right.md §Client notification). De-duped across cascade children by reverted
306
- // block; over-inclusive by design (over-invalidation just resubmits it never wrongly retains).
307
- const invalidations = collectionLog ? await collectionLog.getInvalidationsFrom(actionContext?.rev ?? 0) : [];
308
- if (invalidations.length > 0) {
309
- const revertedBlockIds = [...new Set(invalidations.flatMap(inv => inv.reverted.map(r => r.blockId)))];
310
- this.sourceCache.clear(revertedBlockIds);
311
- if (this.pending.length > 0) {
312
- anyConflicts = true;
313
- }
314
- }
315
-
316
- // Update our context to the latest monotonically. An empty/unopenable log yields no
317
- // context at all, and a log read that lags what we already committed yields an older one;
318
- // neither is grounds for forgetting the revision we hold. This must happen BEFORE
319
- // replayActions below: replay re-reads blocks through this.source, which materializes
320
- // content at this.actionContext.rev if the cursor hasn't advanced yet, replay re-reads
321
- // at the revision we're leaving and refills the cache with stale content that nothing
322
- // will invalidate again (the log entry that would have cleared it was already consumed).
323
- Collection.advanceContext(this.source, this.id, latest?.context);
324
-
325
- // On conflicts, re-stage the pending actions against the adopted revision. The affected
326
- // blocks were already dropped from sourceCache above (per log entry / per invalidation),
327
- // so the replay's reads re-materialize from the transactor.
328
- // NOTE: a throw out of replayActions leaves the tracker holding only the transforms
329
- // replayed so far while `pending` still lists them all; the caller's error handling is
330
- // expected to abort/reset the collection rather than keep staging. If replay ever gains a
331
- // routinely-throwing read path, rebuild into a scratch tracker and swap on success.
332
- if (anyConflicts) {
333
- await this.replayActions();
334
- }
335
- }
336
-
337
- /** Capture the current staged state — tracker transforms plus the pending
338
- * action queue — so it can be restored later via {@link restorePending}.
339
- *
340
- * Use to bracket a unit of staged DML that may need to be rolled back. Unlike
341
- * a blanket "reset to empty", restoring this snapshot preserves any structural
342
- * baseline that predates the staged DML — most importantly a brand-new
343
- * collection's header/root blocks, which live in the tracker (uncommitted)
344
- * until the first sync. Resetting such a collection to empty would leave it
345
- * unreadable; restoring the snapshot returns it to its prior (readable) state.
346
- *
347
- * The returned snapshot is deep-cloned and independent of subsequent mutations.
348
- * Synchronous and latch-free: intended to bracket transaction-scoped staging,
349
- * when no concurrent act/sync is in flight. */
350
- snapshotPending(): CollectionSnapshot<TAction> {
351
- return {
352
- transforms: copyTransforms(this.tracker.transforms),
353
- pending: [...this.pending],
354
- context: structuredClone(this.source.actionContext),
355
- };
356
- }
357
-
358
- /** Restore the staged state captured by {@link snapshotPending}, discarding any
359
- * mutations staged since. Reads through the collection then observe exactly the
360
- * snapshot state again; storage is untouched because nothing was ever synced. */
361
- restorePending(snapshot: CollectionSnapshot<TAction>): void {
362
- this.tracker.reset(copyTransforms(snapshot.transforms));
363
- this.pending = [...snapshot.pending];
364
- }
365
-
366
- /** A read-only {@link Tracker} pinned to this collection's committed state AS OF the
367
- * moment of this call, seeded with a (deep-copied) set of pre-transaction transforms.
368
- * Reads through it observe exactly that revision plus exactly those transformsNOT
369
- * the mutations staged into this collection's live tracker afterward, and NOT commits
370
- * that fold into (or clear) the live read cache while the view is being walked. Used
371
- * to build a committed read view (see {@link Tree.readView}) that a scan can trust
372
- * from first row to last.
373
- *
374
- * The pinning has three legs, built in ONE synchronous block so they all describe the
375
- * same instant (do not introduce an await between them):
376
- * - a private {@link TransactorSource} whose action context is a deep copy FROZEN at
377
- * view-creation time, so a block first read after a later commit still materializes
378
- * at the pinned revision (the transactor honours `context.rev` on get);
379
- * - a private {@link CacheSource} nothing else references, so the live collection's
380
- * `transformCache`/`clear` cannot reach it;
381
- * - that private cache is seeded from the shared cache's current entries, so the
382
- * common committed read (deferred CHECK over a warm cache) stays warm instead of
383
- * refetching every block over the network.
384
- *
385
- * By default the view records NO read dependencies — it is not part of any
386
- * transaction's conflict set (see {@link ReadViewOptions.recordReads}).
387
- *
388
- * NOTE: each view holds up to the cache LRU budget (128) of cloned blocks plus
389
- * whatever it faults in. Views are per-scan and dropped when the scan ends; a very
390
- * long-lived committed scan pins that much memory.
391
- *
392
- * NOTE: an INVENTED collection (createOrOpen found no header, so `actionContext` is
393
- * undefined) pins to no revision its private source asks the transactor for the
394
- * latest. Harmless today because such a collection's blocks all live in the tracker
395
- * transforms, so a view never reaches storage; if invented collections ever gain
396
- * committed blocks not covered by their transforms, this view would follow storage
397
- * forward instead of staying pinned.
398
- *
399
- * When {@link ReadViewOptions.pinContext} is supplied, the view pins to THAT
400
- * boundary instead of the current context, and cache entries committed at a newer
401
- * revision are dropped from the seed (they would otherwise be served blindly — the
402
- * cache never checks a hit against the requested context). Dropped entries are
403
- * refetched from the transactor, which resolves the highest committed revision at
404
- * or below the pin. This is what lets a snapshot captured BEFORE a commit still
405
- * yield a coherent pre-commit view AFTER that commit folded into the shared cache
406
- * (a mid-sweep multi-tree commit being the motivating case). */
407
- createReadTracker(transforms: Transforms, options?: ReadViewOptions): Tracker<IBlock> {
408
- const collector = options?.recordReads ? this.source.getCollector() : undefined;
409
- const pinContext = options?.pinContext ?? this.source.actionContext;
410
- const pinRev = options?.pinContext?.rev;
411
- let seed = this.sourceCache.snapshotEntries();
412
- if (pinRev !== undefined) {
413
- // NOTE: entries whose revision the cache never learned read as 0 and pass this
414
- // filter. Committed blocks always carry a real revision (the transactor reports
415
- // it on load; transformCache stamps it on fold), so a rev-0 entry newer than the
416
- // pin does not occur on the paths that reach here.
417
- seed = seed.filter(([, , revision]) => revision <= pinRev);
418
- }
419
- const pinnedSource = new TransactorSource<IBlock>(
420
- this.id, this.transactor, structuredClone(pinContext), collector);
421
- const pinnedCache = new CacheSource<IBlock>(
422
- pinnedSource, undefined, collector, seed);
423
- return new Tracker(pinnedCache, copyTransforms(transforms));
424
- }
425
-
426
- /** The staged (not-yet-synced) actions queued by {@link act}.
427
- *
428
- * Exposed so a {@link TransactionCoordinator} can append them to the log at
429
- * commit time — mirroring what {@link sync} does internally — when the actions
430
- * were staged directly into this collection (e.g. through a Tree's stage())
431
- * rather than applied via the coordinator's own action path. */
432
- getPendingActions(): Action<TAction>[] {
433
- return this.pending;
434
- }
435
-
436
- /** Drop the staged actions after they have been committed through a
437
- * coordinator. Counterpart to {@link getPendingActions}; {@link sync} clears
438
- * its own pending inline, so this is only needed when commit was orchestrated
439
- * externally. */
440
- clearPendingActions(): void {
441
- this.pending = [];
442
- }
443
-
444
- /** Whether {@link sync} has anything to push: staged actions, or tracker transforms
445
- * that were never committed (an INVENTED collection's header/root blocks live there
446
- * until its first sync, with no pending action to name them).
447
- *
448
- * This is the exact predicate {@link syncInternal} loops on, so `false` means a sync
449
- * would commit nothing. Exposed so a caller that would otherwise flush
450
- * unconditionally can skip the round trip — note that {@link Tree.sync} routes
451
- * through {@link updateAndSync}, so an unnecessary flush still pays a full,
452
- * cache-bypassing {@link update} before discovering it has nothing to do. */
453
- hasUnsyncedChanges(): boolean {
454
- return this.pending.length > 0 || !isTransformsEmpty(this.tracker.transforms);
455
- }
456
-
457
- /** Fold a just-committed set of transforms into this collection's read cache
458
- * so subsequent reads (and stages) through THIS instance observe the committed
459
- * state, mirroring what {@link sync} does inline after a successful transact.
460
- *
461
- * Needed when commit was orchestrated externally (a coordinator): the tracker
462
- * is reset to empty, but the cache still holds the pre-commit blocks. Without
463
- * this, a collection that already had committed state (e.g. a pre-synced index
464
- * tree, or any collection on its second commit) keeps serving the stale prior
465
- * revision because {@link update} sees its rev is already current and refetches
466
- * nothing. Call BEFORE resetting the tracker (the transforms are read live).
467
- *
468
- * @param revision - the committed revision these transforms land at (from
469
- * {@link recordCommitted}), so cached read-dependency revisions advance to it. */
470
- applyCommittedToCache(transforms: Transforms, revision: number): void {
471
- this.sourceCache.transformCache(transforms, revision);
472
- }
473
-
474
- /** Next revision this collection would commit at (current committed rev + 1). */
475
- getNextRev(): number {
476
- return (this.source.actionContext?.rev ?? 0) + 1;
477
- }
478
-
479
- /** Record a just-committed action: append its ActionRev to the committed list
480
- * and advance the revision. Returns the new revision. Mirrors the inline bump
481
- * in {@link syncInternal}. */
482
- recordCommitted(actionId: ActionId): number {
483
- const rev = this.getNextRev();
484
- this.source.actionContext = {
485
- committed: [...(this.source.actionContext?.committed ?? []), { actionId, rev }],
486
- rev,
487
- };
488
- return rev;
489
- }
490
-
491
- /** Push our pending actions to the transactor */
492
- async sync(options?: SyncOptions) {
493
- const release = await Latches.acquire(this.latchId);
494
- try {
495
- await this.syncInternal(options);
496
- } finally {
497
- release();
498
- }
499
- }
500
-
501
- private async syncInternal(options?: SyncOptions) {
502
- const bytes = randomBytes(16);
503
- const actionId = uint8ArrayToString(bytes, 'base64url');
504
-
505
- const maxAttempts = options?.maxAttempts ?? DefaultMaxAttempts;
506
- const baseBackoffMs = options?.baseBackoffMs ?? PendingRetryDelayMs;
507
- const maxBackoffMs = options?.maxBackoffMs ?? DefaultMaxBackoffMs;
508
- const deadlineMs = options?.deadlineMs;
509
- const signal = options?.signal;
510
- const startedAt = Date.now();
511
-
512
- // Count of consecutive stale failures that made no forward progress. Reset to 0 on every
513
- // successful transact, so the cap bounds only a persistently-failing sync — a legitimate
514
- // large multi-batch sync (which iterates many times committing progress) never trips it.
515
- let consecutiveFailures = 0;
516
- let lastReason: string | undefined;
517
- // Last confirmed revision a responder reported holding. Purely diagnostic — it is reported
518
- // in the exhaustion error and never consulted to decide whether to keep retrying.
519
- let lastStaleAt: { blockId: BlockId; rev: number } | undefined;
520
-
521
- while (this.hasUnsyncedChanges()) {
522
- if (signal?.aborted) {
523
- throw makeAbortError(signal);
524
- }
525
- // Progress-agnostic ceiling: give up if the wall-clock deadline passed.
526
- if (deadlineMs !== undefined && Date.now() - startedAt >= deadlineMs) {
527
- throw new SyncRetryExhaustedError(this.id, consecutiveFailures, lastReason ?? 'deadline exceeded', lastStaleAt);
528
- }
529
-
530
- // Snapshot the pending actions so that any new actions aren't assumed to be part of this action
531
- const pending = [...this.pending];
532
-
533
- // Create a snapshot tracker for the action, so that we can ditch the log changes if we have to retry the action
534
- const snapshot = copyTransforms(this.tracker.transforms);
535
- const tracker = new Tracker(this.sourceCache, snapshot);
536
-
537
- // Add the action to the log (in local tracking space)
538
- const collectionLog = await Log.open<Action<TAction>>(tracker, this.id);
539
- if (!collectionLog) {
540
- throw new Error(`Log not found for collection ${this.id}`);
541
- }
542
- const newRev = (this.source.actionContext?.rev ?? 0) + 1;
543
- const addResult = await collectionLog.addActions(pending, actionId, newRev, () => tracker.transformedBlockIds());
544
-
545
- // Commit the action to the transactor. Carry the aged retry priority derived from the
546
- // consecutive-failure count so a sync that keeps losing concurrent races out-ranks fresh
547
- // (priority-0) rivals in the cluster's resolveRace (fairness-only; capped at MaxPriority).
548
- // First attempt has consecutiveFailures == 0, so priority 0 — the common pend is unchanged.
549
- const staleFailure = await this.source.transact(tracker.transforms, actionId, newRev, this.id, addResult.tailPath.block.header.id, clampPriority(consecutiveFailures));
550
- if (staleFailure) {
551
- consecutiveFailures++;
552
- lastReason = staleFailure.reason ?? lastReason;
553
- lastStaleAt = staleFailure.staleAt ?? lastStaleAt;
554
- // Give up once the consecutive no-progress budget is exhausted, so a transactor that
555
- // persistently rejects the sync can no longer hold the collection latch forever.
556
- // NOTE: this also bounds the legitimate `pending`-wait case (retrying the same action
557
- // while another commit is in flight), which used to retry indefinitely. Default 10
558
- // attempts 21s of exponential backoff. If a high-contention workload legitimately
559
- // needs to wait longer for a pending commit to clear, raise maxAttempts for that caller.
560
- if (consecutiveFailures >= maxAttempts) {
561
- throw new SyncRetryExhaustedError(this.id, consecutiveFailures, lastReason, lastStaleAt);
562
- }
563
- // Back off before every retry (any stale failure — reason/missing/pending), growing
564
- // exponentially from the base delay up to the cap, with proportional random jitter so a
565
- // herd of clients that lost the same race does not re-collide on the next tick (see
566
- // utility/backoff.ts). The abortable sleep lets an aborted sync reject promptly instead
567
- // of finishing the sleep.
568
- // NOTE: the `missing`/`reason` conflict paths now pay this backoff too (they previously
569
- // retried with zero delay); that is what stops the persistent-`reason` hot spin. If a
570
- // high-contention workload ever shows this base delay as recovery latency, lower
571
- // baseBackoffMs for that caller rather than reintroducing the zero-delay retry.
572
- const delay = jitteredBackoffMs(consecutiveFailures - 1, { baseMs: baseBackoffMs, capMs: maxBackoffMs }, options?.rand);
573
- await abortableDelay(delay, signal);
574
- // Fetch latest state - updateInternal() will call replayActions() if there are conflicts
575
- await this.updateInternal();
576
- } else {
577
- // Forward progress: reset the no-progress budget.
578
- consecutiveFailures = 0;
579
- lastReason = undefined;
580
- lastStaleAt = undefined;
581
- // Clear the pending actions that were part of this action
582
- this.pending = this.pending.slice(pending.length);
583
- // Reset cache and replay any actions that were added during the action
584
- const transforms = tracker.reset();
585
- await this.replayActions();
586
- this.sourceCache.transformCache(transforms, newRev);
587
- this.source.actionContext = this.source.actionContext
588
- ? { committed: [...this.source.actionContext.committed, { actionId, rev: newRev }], rev: newRev }
589
- : { committed: [{ actionId, rev: newRev }], rev: newRev };
590
- }
591
- }
592
- }
593
-
594
- async updateAndSync(options?: SyncOptions) {
595
- const release = await Latches.acquire(this.latchId);
596
- try {
597
- await this.updateInternal();
598
- await this.syncInternal(options);
599
- } finally {
600
- release();
601
- }
602
- }
603
-
604
- async *selectLog(forward = true): AsyncIterableIterator<Action<TAction>> {
605
- const collectionLog = await Log.open<Action<TAction>>(this.tracker, this.id);
606
- if (!collectionLog) {
607
- throw new Error(`Log not found for collection ${this.id}`);
608
- }
609
- for await (const entry of collectionLog.select(undefined, forward)) {
610
- if (entry.action) {
611
- // NOTE: copy-then-reverse to avoid mutating the stored log entry array.
612
- // Once tsconfig targets ES2023, `entry.action.actions.toReversed()` is cleaner.
613
- yield* forward ? entry.action.actions : [...entry.action.actions].reverse();
614
- }
615
- }
616
- }
617
-
618
- private async replayActions() {
619
- this.tracker.reset();
620
- // Replay pending actions against the fresh tracker state (always called under latch)
621
- for (const action of this.pending) {
622
- await this.internalTransact(action);
623
- }
624
- }
625
-
626
- getReadDependencies(): ReadDependency[] {
627
- return this.source.getReadDependencies();
628
- }
629
-
630
- clearReadDependencies(): void {
631
- this.source.clearReadDependencies();
632
- }
633
-
634
- /** Called for each local action that may be in conflict with a remote action (always called under latch).
635
- * @param action - The local action to check
636
- * @param potential - The remote actions that are potentially in conflict
637
- * @returns The effective action to keep: the original (unchanged), a replacement
638
- * instance (applied instead of the original), or undefined to discard it.
639
- */
640
- protected doFilterConflict(action: Action<TAction>, potential: Action<TAction>[]): Action<TAction> | undefined {
641
- return this.filterConflict ? this.filterConflict(action, potential) : action;
642
- }
643
-
644
- /** Bootstrap ActionContext from the committed tail block's state.
645
- * The tail is always committed first (commit protocol guarantee), so it's readable
646
- * with context=undefined. Its state.latest contains the ActionRev of the most recent
647
- * committed action exactly the proof needed for the transactor to serve pending
648
- * non-tail blocks during chain walks.
649
- *
650
- * This read goes to the transactor directly rather than through {@link TransactorSource},
651
- * so it has to honour the `unavailable` flag itself: a tail the repo could not retrieve
652
- * must not degrade into "no context", which would leave the chain walk unable to see
653
- * pending non-tail blocks and the collection reading as if they did not exist. A tail
654
- * with no `state.latest` and NO flag is a real answer (nothing committed yet) and still
655
- * no-ops.
656
- *
657
- * The same goes for `unconfirmedAheadRev`: this unpinned tail read is the ONE seam where a
658
- * lagging collection can learn a newer revision exists every later data read is pinned to
659
- * the context seeded here. Silently seeding from a tail the repo could not confirm is
660
- * current would freeze the collection at the stale revision with nothing ever reporting a
661
- * problem, so it throws the same way TransactorSource.tryGet does for its unpinned reads
662
- * (see the tradeoff NOTE there).
663
- */
664
- private static async bootstrapContext(
665
- source: TransactorSource<IBlock>,
666
- transactor: ITransactor,
667
- header: CollectionHeaderBlock,
668
- ): Promise<void> {
669
- const tailId = header.tailId;
670
- if (tailId) {
671
- const tailResult = await transactor.get({ blockIds: [tailId] });
672
- const tailEntry = tailResult?.[tailId];
673
- if (tailEntry?.unavailable !== undefined && tailEntry.block == null) {
674
- throw new BlockUnavailableError(tailId, tailEntry.unavailable);
675
- }
676
- if (tailEntry?.unconfirmedAheadRev !== undefined) {
677
- throw new BlockPossiblyStaleError(tailId, tailEntry.unconfirmedAheadRev);
678
- }
679
- const tailState = tailEntry?.state;
680
- if (tailState?.latest) {
681
- source.actionContext = {
682
- committed: [{ actionId: tailState.latest.actionId, rev: tailState.latest.rev }],
683
- rev: tailState.latest.rev,
684
- };
685
- }
686
- }
687
- }
688
- }
1
+ import type { IBlock, Action, ActionType, ActionHandler, BlockId, ITransactor, BlockStore, Transforms, ActionId } from "../index.js";
2
+ import { Log } from "../log/log.js";
3
+ import type { ActionEntry } from "../log/struct.js";
4
+ import { Atomic } from "../transform/atomic.js";
5
+ import { Tracker } from "../transform/tracker.js";
6
+ import { CacheSource } from "../transform/cache-source.js";
7
+ import { computeBlockContentDigests } from "../transform/digest.js";
8
+ import { copyTransforms, isTransformsEmpty } from "../transform/helpers.js";
9
+ import { TransactorSource } from "../transactor/transactor-source.js";
10
+ import { BlockUnavailableError, BlockPossiblyStaleError } from "../network/struct.js";
11
+ import type { CollectionHeaderBlock, CollectionId, ICollection, SyncOptions } from "./index.js";
12
+ import { CollectionHeaderVanishedError, SyncRetryExhaustedError } from "./struct.js";
13
+ import type { ActionContext } from "./action.js";
14
+ import { actionIdAt } from "./action.js";
15
+ import type { ReadDependency } from "../transaction/transaction.js";
16
+ import { clampPriority } from "../transaction/transaction.js";
17
+ import { ReadDependencyCollector } from "../transaction/read-dependency-collector.js";
18
+ import { randomBytes } from '@noble/hashes/utils.js';
19
+ import { toString as uint8ArrayToString } from 'uint8arrays/to-string';
20
+ import { Latches } from "../utility/latches.js";
21
+ import { jitteredBackoffMs, abortableDelay, makeAbortError } from "../utility/backoff.js";
22
+ import { createLogger } from "../logger.js";
23
+
24
+ const log = createLogger('collection');
25
+
26
+ /** Which of {@link Collection.advanceContext}'s two callers is reporting — printed as `site=` on
27
+ * every line it emits, because the two compare DIFFERENT pairs of things and a divergence means
28
+ * something different in each:
29
+ *
30
+ * - `refresh` ({@link Collection.updateInternal}) — this instance's own copy against the stored
31
+ * log. A divergence here indicts a forked REPLICA: two copies of one collection id built
32
+ * separately, each internally consistent.
33
+ * - `attach` ({@link Collection.attachToLog}, during open) the log tail block's claim about
34
+ * which action produced the latest revision, against a walk of that same tail's own chain. Both
35
+ * sides come from storage, so a divergence here indicts STORAGE being self-inconsistent about
36
+ * one revision, not a replica.
37
+ *
38
+ * Without this field the two are indistinguishable in a log, and they lead an operator to
39
+ * completely different places. */
40
+ type DivergenceSite = 'refresh' | 'attach';
41
+
42
+ /** The lowest revision two {@link ActionContext}s provably disagree at, and the action each names
43
+ * there what {@link Collection.earliestFork} reports and `collection:lineage-divergence` prints
44
+ * as `forkRev=` / `heldAction=` / `readAction=`. */
45
+ type LineageFork = { rev: number, heldAction: ActionId, readAction: ActionId };
46
+
47
+ /** Default base backoff (and historical fixed delay) between sync retries, in ms. */
48
+ const PendingRetryDelayMs = 100;
49
+ /** Default max consecutive no-progress stale-failure retries before {@link Collection.sync} gives up. */
50
+ const DefaultMaxAttempts = 10;
51
+ /** Default ceiling on a single exponential-backoff sleep, in ms. */
52
+ const DefaultMaxBackoffMs = 5000;
53
+
54
+ export type CollectionInitOptions<TAction> = {
55
+ modules: Record<ActionType, ActionHandler<TAction>>;
56
+ createHeaderBlock: (id: BlockId, store: BlockStore<IBlock>) => IBlock;
57
+ /** Called for each local action that is potentially in conflict with a remote action.
58
+ * @param action - The local action to check
59
+ * @param potential - The remote actions that are potentially in conflict
60
+ * @returns The original action (return the same instance to keep it as-is), a replacement
61
+ * action (return a new instance to apply instead of the original — it is re-staged via
62
+ * the conflict replay), or undefined to discard this action
63
+ */
64
+ filterConflict?: (action: Action<TAction>, potential: Action<TAction>[]) => Action<TAction> | undefined
65
+ }
66
+
67
+ /** Options for building a committed read view (see {@link Collection.createReadTracker}
68
+ * and `Tree.readView`). */
69
+ export interface ReadViewOptions {
70
+ /** Record read dependencies into the collection's shared collector.
71
+ * Default false a pinned committed view is not part of any transaction's
72
+ * conflict set, so its reads must not be able to fail the writer's commit
73
+ * validation. Deferred-constraint safety does not depend on these reads:
74
+ * validator peers re-execute the transaction's recorded statements against
75
+ * their own committed state, so a constraint that no longer holds is caught
76
+ * at validation regardless. */
77
+ recordReads?: boolean;
78
+ /** Pin the view to this committed boundary instead of the collection's CURRENT
79
+ * action context. Pass a {@link CollectionSnapshot.context} so the view describes
80
+ * the same committed moment the snapshot's transforms sat on — even when the
81
+ * collection has committed further since (e.g. a multi-tree commit sweep that has
82
+ * already flushed THIS tree but not its siblings). Blocks cached at revisions
83
+ * newer than the pin are excluded from the view's warm seed and refetched from
84
+ * the transactor at the pinned revision. Default: the current context. */
85
+ pinContext?: ActionContext;
86
+ }
87
+
88
+ /** A point-in-time copy of a collection's staged (un-synced) state, produced by
89
+ * {@link Collection.snapshotPending} and consumed by {@link Collection.restorePending}. */
90
+ export interface CollectionSnapshot<TAction> {
91
+ /** Deep-cloned tracker transforms at snapshot time. */
92
+ transforms: Transforms;
93
+ /** Pending actions queued at snapshot time. */
94
+ pending: Action<TAction>[];
95
+ /** The committed boundary (action context) the staged state sat on when captured.
96
+ * `undefined` for a collection with no committed revision yet (an invented
97
+ * collection whose header/root still live in the tracker). A read view built
98
+ * from this snapshot pins to this boundary (see {@link ReadViewOptions.pinContext}),
99
+ * so the view stays coherent with the snapshot's transforms even if the
100
+ * collection commits further before the view is built. */
101
+ context?: ActionContext;
102
+ }
103
+
104
+ export class Collection<TAction> implements ICollection<TAction> {
105
+ private pending: Action<TAction>[] = [];
106
+ private readonly latchId: string;
107
+
108
+ /** The action id of a write currently in flight ON THIS INSTANCE'S BEHALF, or `undefined`
109
+ * outside a write. Read by {@link updateInternal}: if the committed log now carries an entry
110
+ * under this id, that action's work is already durable despite the failure answer that sent us
111
+ * back here `NetworkTransactor.commit` commits the collection header and log tail BEFORE
112
+ * sweeping the remaining blocks, so a later sweep block confirming a conflict reports failure
113
+ * over an action whose log entry already landed. Such an entry is CONSUMED
114
+ * ({@link consumeOwnEntry}) rather than replayed, because replaying re-appends content the
115
+ * committed tail already carries, producing a duplicate entry under one action id at two
116
+ * revisions.
117
+ *
118
+ * The collection owns this fact rather than taking it as a `updateInternal` argument so that no
119
+ * refresh path can forget to supply it — {@link update} and {@link updateAndSync} are refreshes
120
+ * on behalf of a READER, the field is unset for them, and the consume branch cannot fire. Before
121
+ * this was a field, `TransactionCoordinator.commit`'s inter-attempt refresh went through
122
+ * `update()` and was therefore indistinguishable from a reader refresh even though the
123
+ * coordinator held the very id it was retrying.
124
+ *
125
+ * LIFETIME is the whole attempt CYCLE, not the latched span: it must survive the refresh
126
+ * BETWEEN a failed attempt and its retry, which is the only moment it is ever read. In
127
+ * {@link syncInternal} that cycle is contained inside the collection latch `sync()` holds; in
128
+ * `TransactionCoordinator.commit` the inter-attempt `update()` runs OUTSIDE the commit latch
129
+ * span by design (`Latches` is non-reentrant), so the coordinator's clear necessarily runs
130
+ * latch-free. That is safe: this is a single field write, {@link beginInFlightAction}'s
131
+ * disposer only clears an id it still owns, and the only reader runs under the latch — so the
132
+ * worst a foreign concurrent refresh can see is a cleared field (it stops consuming), never a
133
+ * field it should not have consumed. */
134
+ private inFlightActionId?: ActionId;
135
+
136
+ protected constructor(
137
+ public readonly id: CollectionId,
138
+ public readonly transactor: ITransactor,
139
+ private readonly handlers: Record<ActionType, ActionHandler<TAction>>,
140
+ private readonly source: TransactorSource<IBlock>,
141
+ /** Cache of unmodified blocks from the source */
142
+ private readonly sourceCache: CacheSource<IBlock>,
143
+ /** Tracked Changes */
144
+ public readonly tracker: Tracker<IBlock>,
145
+ private readonly filterConflict?: (action: Action<TAction>, potential: Action<TAction>[]) => Action<TAction> | undefined,
146
+ /** Short random tag naming THIS instance (see {@link newInstanceTag}). Open paths generate
147
+ * it BEFORE construction (so pre-construction diagnostics such as attachToLog can carry it);
148
+ * the default covers direct construction in tests. */
149
+ public readonly instanceTag: string = Collection.newInstanceTag(),
150
+ ) {
151
+ // Instance-scoped, deliberately NOT shared across instances of one collection id. The
152
+ // latch protects per-instance state only the tracker, the pending queue, and
153
+ // source.actionContext, none of which two instances over the same id sharewhile
154
+ // cross-instance races are resolved by the transactor's optimistic concurrency (that is
155
+ // the design; the old process-global `Collection:${id}` key serialized instances by
156
+ // accident). Instance scope is also what lets TransactionCoordinator hold this latch
157
+ // across its whole commit span: `Latches` is a non-reentrant FIFO mutex, and a rival
158
+ // writer driving a SECOND instance of the same id from inside transactor.pend (see
159
+ // CompetingWriterTransactor) would otherwise wait on the very latch the parked commit
160
+ // holds — a deadlock, not contention.
161
+ this.latchId = `Collection:${this.id}#${this.instanceTag}`;
162
+ }
163
+
164
+ /** A fresh instance tag: four random bytes rendered base64url six characters, enough that
165
+ * two instances over one collection id do not collide by accident, short enough to ride on
166
+ * every trace line (same shape as the node tag in quereus-plugin-optimystic's
167
+ * collection-factory). Scopes {@link latchId} per instance and labels diagnostics. */
168
+ private static newInstanceTag(): string {
169
+ return uint8ArrayToString(randomBytes(4), 'base64url');
170
+ }
171
+
172
+ /** Open an EXISTING collection.
173
+ *
174
+ * Resolves to `undefined` when the header block probe comes back empty an
175
+ * authoritatively absent header, meaning nothing has ever been committed under this id.
176
+ * A header the storage layer could not RETRIEVE (a revision this node cannot
177
+ * reconstruct, an unreachable cohort) is not absent: the probe throws
178
+ * {@link BlockUnavailableError} instead of resolving `undefined`, so an unreachable
179
+ * collection can never be mistaken for a nonexistent one.
180
+ *
181
+ * Use this wherever reading not creating is what was meant. {@link createOrOpen}
182
+ * would instead stage a fresh empty collection, and reads through it would report an
183
+ * absent dataset as a legitimately empty one. */
184
+ static async open<TAction>(transactor: ITransactor, id: CollectionId, init: CollectionInitOptions<TAction>): Promise<Collection<TAction> | undefined> {
185
+ const { source, sourceCache, tracker, header } = await Collection.probeHeader(transactor, id);
186
+ if (!header) {
187
+ // Return before anything is staged: the tracker's transforms stay empty, so a caller
188
+ // that ignores the undefined cannot later sync a phantom collection into existence.
189
+ return undefined;
190
+ }
191
+ // Generated BEFORE attachToLog so log-attach-time diagnostics can name the instance
192
+ // the same way post-construction ones do.
193
+ const instanceTag = Collection.newInstanceTag();
194
+ await Collection.attachToLog<TAction>(source, transactor, tracker, id, instanceTag, header);
195
+ return new Collection(id, transactor, init.modules, source, sourceCache, tracker, init.filterConflict, instanceTag);
196
+ }
197
+
198
+ /** Open an existing collection, or stage a fresh empty one in the local tracker when the
199
+ * header is authoritatively absent. Nothing is written to storage until {@link sync}.
200
+ *
201
+ * Correct only where inventing a collection is genuinely intended — a first write, a
202
+ * bootstrap path. The create branch logs `collection:invented`; prefer {@link open} on
203
+ * any pure read path. */
204
+ static async createOrOpen<TAction>(transactor: ITransactor, id: CollectionId, init: CollectionInitOptions<TAction>): Promise<Collection<TAction>> {
205
+ const { source, sourceCache, tracker, header } = await Collection.probeHeader(transactor, id);
206
+
207
+ // Pre-construction for the same reason as in open(): see the comment there.
208
+ const instanceTag = Collection.newInstanceTag();
209
+ if (header) { // Collection already exists
210
+ await Collection.attachToLog<TAction>(source, transactor, tracker, id, instanceTag, header);
211
+ } else { // Collection does not exist
212
+ log('collection:invented id=%s — no committed header found; staging a fresh empty collection', id);
213
+ const headerBlock = init.createHeaderBlock(id, tracker);
214
+ tracker.insert(headerBlock);
215
+ source.actionContext = undefined;
216
+ await Log.open<Action<TAction>>(tracker, id);
217
+ }
218
+
219
+ return new Collection(id, transactor, init.modules, source, sourceCache, tracker, init.filterConflict, instanceTag);
220
+ }
221
+
222
+ /** The per-instance read wiring every open path needs, plus the header probe result.
223
+ * Shared by {@link open} and {@link createOrOpen} so the two cannot drift. */
224
+ private static async probeHeader(transactor: ITransactor, id: CollectionId): Promise<{
225
+ source: TransactorSource<IBlock>,
226
+ sourceCache: CacheSource<IBlock>,
227
+ tracker: Tracker<IBlock>,
228
+ header: CollectionHeaderBlock | undefined,
229
+ }> {
230
+ // Start with a context that has an infinite revision number to ensure that we always fetch the latest log information.
231
+ // One shared read-dependency collector feeds both the source (direct structural reads) and the cache (every
232
+ // cache hit/miss), so a block read from either layer records a dependency — cache hits included.
233
+ const collector = new ReadDependencyCollector();
234
+ const source = new TransactorSource(id, transactor, undefined, collector);
235
+ const sourceCache = new CacheSource(source, undefined, collector);
236
+ const tracker = new Tracker(sourceCache);
237
+ const header = await source.tryGet(id) as CollectionHeaderBlock | undefined;
238
+ return { source, sourceCache, tracker, header };
239
+ }
240
+
241
+ /** Walk an existing collection's log and point the source at its latest action context.
242
+ * A header we just probed successfully but whose log will not open is a fault, not an
243
+ * absence — throw rather than let the collection read as empty. (The re-read goes through
244
+ * the tracker/cache, so it can disagree with the probe when storage is flaky mid-open.) */
245
+ private static async attachToLog<TAction>(
246
+ source: TransactorSource<IBlock>,
247
+ transactor: ITransactor,
248
+ tracker: Tracker<IBlock>,
249
+ id: CollectionId,
250
+ /** The tag the calling open path minted for the Collection it is ABOUT to construct, so a
251
+ * diagnostic emitted here carries the same instance name as every post-construction one. */
252
+ instanceTag: string,
253
+ header: CollectionHeaderBlock,
254
+ ): Promise<void> {
255
+ // Bootstrap ActionContext from the committed tail before walking the chain.
256
+ // This allows the transactor to serve pending non-tail blocks during Log.open.
257
+ await Collection.bootstrapContext(source, transactor, header);
258
+
259
+ const collectionLog = await Log.open<Action<TAction>>(tracker, id);
260
+ if (!collectionLog) {
261
+ throw new Error(`Log not found for collection ${id}`);
262
+ }
263
+ // Monotonic, not an overwrite: getActionContext resolves undefined when the chain has no
264
+ // tail or the tail block carries zero entries, and that must not erase the revision
265
+ // bootstrapContext just read off the committed tail.
266
+ Collection.advanceContext(source, id, instanceTag, 'attach', await collectionLog.getActionContext());
267
+ }
268
+
269
+ /** Adopt a freshly-read action context WITHOUT ever lowering the revision already held.
270
+ *
271
+ * The revision a collection last committed at is knowledge it earned; a read that found
272
+ * nothing or found an older view of the log cannot un-earn it. Silently accepting the
273
+ * lower value makes the next sync ask for a revision that is long gone, and every retry
274
+ * repeats the same doomed request because each retry re-runs the same losing read.
275
+ *
276
+ * Equal revisions still adopt `next`: the rev is unchanged but its `committed` list may be
277
+ * more complete than what we hold.
278
+ *
279
+ * This is also the one seam where lineage divergence is observable: at every revision BOTH
280
+ * sides name an action for, the two ids must agree. Revision
281
+ * numbers are per-collection counters, so two separately-built copies under one id can each
282
+ * occupy the same revision with DIFFERENT actions while each stays internally self-consistent
283
+ * {@link reportShortfall} structurally cannot see that (its two numbers come from one
284
+ * chain), and this is the only place two `committed` lists meet. Naming different actions at
285
+ * one revision proves the two sides are different lineages (`collection:lineage-divergence`;
286
+ * see docs/debugging.md § "Did the refresh itself fail to close the gap?"). WHICH two sides
287
+ * depends on the caller, and the line says so in `site=` — see {@link DivergenceSite}, which
288
+ * defines the two values and what each one indicts.
289
+ *
290
+ * Every line from here also carries `tag=`, the {@link Collection.instanceTag} of the handle
291
+ * reporting. One process routinely holds several handles on one collection id; without the
292
+ * tag, two handles' lines interleave into what reads like one handle contradicting itself.
293
+ *
294
+ * Logs, does not throw same reasoning as {@link reportShortfall}: `update()` runs
295
+ * blanket-style over every registered collection between commit retries, and aborting here
296
+ * would promote a diagnosis to production behaviour before the line has ever been seen to
297
+ * fire in the wild. Adoption then proceeds unchanged, which means the line is a PER-DISCOVERY
298
+ * report, not a per-refresh one: adopting `next` overwrites the held lineage marker with the
299
+ * log's, so the next refresh of this instance compares log-to-log and stays silent — even
300
+ * though block content materialized under the old lineage may still be in caches. The line
301
+ * marks the refresh that first observed the disagreement.
302
+ *
303
+ * NOTE: adoption resolves the CONTEXT disagreement, not the content one the read caches on
304
+ * this instance still hold blocks materialized under the old lineage, and since the revision
305
+ * did not change nothing re-reads them. Conditional today: no fork has been reproduced (see
306
+ * the still-open upstream reproducer), so this instrument exists to find out whether one
307
+ * happens at all. If the line is ever seen firing in the field, decide then whether a
308
+ * divergence should also drop the read cache (and whether to keep re-reporting per refresh)
309
+ * that is a behaviour change, and this seam deliberately makes none.
310
+ *
311
+ * The comparison is {@link earliestFork}, not a single lookup at the current revision: the
312
+ * two `committed` lists overlap across several revisions, and the LOWEST one they disagree at
313
+ * is where the lineages actually parted — a fork below the current revision was previously
314
+ * silent. `forkRev=` names it, `heldAction=`/`readAction=` are the two ids AT it, and
315
+ * `heldRev=`/`readRev=` are the two contexts' own revisions, so the line says both where the
316
+ * split began and how far each side has since travelled.
317
+ *
318
+ * The refusal line reports its two action ids at `readRev=` the read's revision — on BOTH
319
+ * sides, because that is the only revision the two can be compared at: `next` never names an
320
+ * action above its own revision, so looking each side up at its own revision would compare
321
+ * different revisions and print two different ids for one honest lineage. Equal ids there mean
322
+ * the read is an older view of THIS lineage (ordinary lag, correctly refused); different ids
323
+ * mean a fork; `none` on the held side means this handle's own list does not reach back to the
324
+ * read's revision — the signature of a context bootstrapped from an over-claiming tail (see
325
+ * the NOTE in {@link bootstrapContext}), which is exactly the case {@link earliestFork} has no
326
+ * shared revision to report on.
327
+ *
328
+ * Gated on `log.enabled`, like every {@link actionIdAt} caller: the comparison buys nothing
329
+ * when the line has no sink, and the lists one entry per commit between context reads,
330
+ * truncated at each checkpoint are only walked on a run that has the namespace turned on.
331
+ * Silence proves nothing either way, because a revision is only comparable when BOTH sides
332
+ * name an action for it: an invented collection has no context at all, a revision slot the log
333
+ * gave to a checkpoint or invalidation entry names none, and a revision older than the read
334
+ * log's most recent checkpoint has already fallen off the read side's list. */
335
+ private static advanceContext(
336
+ source: TransactorSource<IBlock>,
337
+ id: CollectionId,
338
+ instanceTag: string,
339
+ site: DivergenceSite,
340
+ next: ActionContext | undefined,
341
+ ): void {
342
+ const current = source.actionContext;
343
+ if (next === undefined) {
344
+ return; // The read learned nothing keep what we already know.
345
+ }
346
+ if (current !== undefined && log.enabled) {
347
+ const fork = Collection.earliestFork(current, next);
348
+ if (fork !== undefined) {
349
+ log('collection:lineage-divergence id=%s tag=%s site=%s forkRev=%d heldAction=%s readAction=%s heldRev=%d readRev=%d',
350
+ id, instanceTag, site, fork.rev, fork.heldAction, fork.readAction, current.rev, next.rev);
351
+ }
352
+ }
353
+ if (current !== undefined && next.rev < current.rev) {
354
+ // The refusal itself is unconditional; only the id lookups that explain it are gated.
355
+ if (log.enabled) {
356
+ log('collection:context-not-lowered id=%s tag=%s site=%s heldRev=%d readRev=%d heldAction=%s readAction=%s',
357
+ id, instanceTag, site, current.rev, next.rev,
358
+ actionIdAt(current, next.rev) ?? 'none', actionIdAt(next, next.rev) ?? 'none');
359
+ }
360
+ return;
361
+ }
362
+ source.actionContext = next;
363
+ }
364
+
365
+ /** The EARLIEST revision the two contexts provably disagree about: the lowest revision both
366
+ * `committed` lists name an action for, where the two ids differ.
367
+ *
368
+ * Comparing only at the holder's current revision what this used to do misses a fork that
369
+ * began earlier and has since been overtaken by same-numbered commits on both sides, which is
370
+ * the shape a replica that forked and kept writing actually has. Taking the lowest disagreeing
371
+ * revision instead names the split point rather than an arbitrary later symptom of it.
372
+ *
373
+ * Revisions only one side names are skipped, not treated as disagreement: {@link actionIdAt}'s
374
+ * `undefined` is legitimate (checkpoint/invalidation slots, and revisions that predate the
375
+ * other side's most recent checkpoint), so a one-sided entry is missing evidence, not evidence
376
+ * of a fork.
377
+ *
378
+ * NOTE: linear in the two lists, which hold one entry per commit between context reads and
379
+ * truncate at each checkpoint. Every caller is `log.enabled`-gated, so this does not run at
380
+ * all on a normal run; if a non-diagnostic caller ever appears, index by revision instead. */
381
+ private static earliestFork(held: ActionContext, read: ActionContext): LineageFork | undefined {
382
+ // NOTE: a `committed` list carrying TWO entries at one revision would be a defect in its own
383
+ // right, and this keeps the last of them arbitrarily. Harmless while every caller is a
384
+ // diagnostic; if such a list is ever seen, report the duplicate rather than silently
385
+ // picking one.
386
+ const readIds = new Map(read.committed.map(entry => [entry.rev, entry.actionId]));
387
+ let earliest: LineageFork | undefined;
388
+ for (const entry of held.committed) {
389
+ const readAction = readIds.get(entry.rev);
390
+ if (readAction === undefined || readAction === entry.actionId) {
391
+ continue;
392
+ }
393
+ if (earliest === undefined || entry.rev < earliest.rev) {
394
+ earliest = { rev: entry.rev, heldAction: entry.actionId, readAction };
395
+ }
396
+ }
397
+ return earliest;
398
+ }
399
+
400
+ /** Report a refresh that failed to move FORWARDS past a revision it had already read for
401
+ * itself the sibling of {@link advanceContext}'s `collection:context-not-lowered`, which
402
+ * reports a collection declining to move BACKWARDS.
403
+ *
404
+ * `tailRev` is what the committed log tail claimed is committed under this id; `after` is
405
+ * where a SEPARATE read path (the chain walk) actually landed. Landing below the claim means
406
+ * this refresh closed nothing, which from outside the class is otherwise indistinguishable
407
+ * from "there was nothing newer to adopt".
408
+ *
409
+ * This detects LAG, and only lag. It CANNOT see lineage divergence: both of its numbers
410
+ * come from the same chain — `tailRev` off the tail block this collection's own header
411
+ * names, `after` from a walk of that same chain — and a forked replica is internally
412
+ * self-consistent, its tail claiming exactly what its own walk reaches. Two copies of one
413
+ * collection id holding the same revision under different actions therefore keep this line
414
+ * silent forever. That case is `collection:lineage-divergence`, reported from
415
+ * {@link advanceContext}, which compares action ids the one value comparable across
416
+ * copies rather than revision counters.
417
+ *
418
+ * Carries the same `tag=` as {@link advanceContext}'s lines, and for the same reason: several
419
+ * handles on one collection id otherwise read as one self-contradicting handle.
420
+ *
421
+ * Logs, does not throw: `update()` is called blanket-style over every registered collection
422
+ * between commit retries, and a shortfall is not yet known to be illegitimate — an abort here
423
+ * would promote an unproven diagnosis to production behaviour. Deliberately does NOT adopt
424
+ * `tailRev` either: the two numbers come from different read paths, and papering over the
425
+ * disagreement destroys the evidence this line exists to produce. */
426
+ private static reportShortfall(id: CollectionId, instanceTag: string, tailRev: number | undefined, before: number | undefined, after: number | undefined): void {
427
+ if (tailRev === undefined || (after !== undefined && after >= tailRev)) {
428
+ return;
429
+ }
430
+ log('collection:context-short-of-tail id=%s tag=%s before=%s after=%s tail=%d',
431
+ id, instanceTag, before ?? 'none', after ?? 'none', tailRev);
432
+ }
433
+
434
+ async act(...actions: Action<TAction>[]) {
435
+ const release = await Latches.acquire(this.latchId);
436
+ try {
437
+ await this.actInternal(...actions);
438
+ } finally {
439
+ release();
440
+ }
441
+ }
442
+
443
+ private async actInternal(...actions: Action<TAction>[]) {
444
+ await this.internalTransact(...actions);
445
+ this.pending.push(...actions);
446
+ }
447
+
448
+ private async internalTransact(...actions: Action<TAction>[]) {
449
+ const atomic = new Atomic(this.tracker);
450
+
451
+ for (const action of actions) {
452
+ const handler = this.handlers[action.type];
453
+ if (!handler) {
454
+ throw new Error(`No handler for action type ${action.type}`);
455
+ }
456
+ await handler(action, atomic);
457
+ }
458
+
459
+ atomic.commit();
460
+ }
461
+
462
+ /** Load external changes and update our context to the latest log revision - resolve any conflicts with our pending actions. */
463
+ async update() {
464
+ const release = await Latches.acquire(this.latchId);
465
+ try {
466
+ await this.updateInternal();
467
+ } finally {
468
+ release();
469
+ }
470
+ }
471
+
472
+ /** Drops the pending actions this sync's OWN committed entry already made durable, instead of
473
+ * replaying them into a duplicate entry (see {@link inFlightActionId}).
474
+ *
475
+ * `addActions` wrote exactly the snapshot pending list under this action id, and the entry's
476
+ * actions are therefore the LEADING `entry.actions.length` items of `this.pending` — anything
477
+ * staged since is behind them, because {@link actInternal} appends. Under
478
+ * {@link syncInternal} nothing can even be staged mid-cycle (`act()` shares the collection
479
+ * latch); under `TransactionCoordinator.commit` the mark spans a latch-free inter-attempt
480
+ * window, so an `act()` there CAN grow `pending` — still only at the tail, so the slice stays
481
+ * right.
482
+ *
483
+ * The guard below is the load-bearing part: `slice` fails SILENTLY if that correspondence ever
484
+ * breaks, dropping actions that were never committed, so an entry longer than `pending` throws
485
+ * instead of losing work. (See the sibling note on `syncInternal`'s post-commit replay, which
486
+ * rests on the same invariant.) */
487
+ private consumeOwnEntry(entry: ActionEntry<Action<TAction>>) {
488
+ if (entry.actions.length > this.pending.length) {
489
+ throw new Error(
490
+ `Collection ${this.id}: own committed entry for action ${entry.actionId} holds `
491
+ + `${entry.actions.length} actions but only ${this.pending.length} are pending; `
492
+ + `consuming it would drop actions that were never committed`);
493
+ }
494
+ // `mutated` is unconditional, even for a zero-action entry: the tracker still holds this
495
+ // action's staged transforms, and only the replay at the end of `updateInternal` — which
496
+ // resets the tracker and re-stages just what remains — drops them. That reset is what turns
497
+ // `hasUnsyncedChanges()` false so the sync loop exits reporting the success the writer is
498
+ // owed (the action IS durable).
499
+ return { after: this.pending.slice(entry.actions.length), mutated: true };
500
+ }
501
+
502
+ /** Maps each pending action to its effective form against a remote entry: the original, a
503
+ * replacement, or dropped. A replacement or a discard changes the pending set; the tracker still
504
+ * holds the pre-filter transforms, so report it as mutated to force a replay that re-stages
505
+ * against the effective actions. Identity comparison per the contract: keep => same instance,
506
+ * replace => new instance.
507
+ * NOTE: a filterConflict hook that always allocates a fresh (but equal) instance instead of
508
+ * returning the same one forces a replay on every update — if that ever shows up as a hot path,
509
+ * compare by value/id here instead of by reference. */
510
+ private filterAgainstEntry(entry: ActionEntry<Action<TAction>>) {
511
+ const before = this.pending;
512
+ const after = before
513
+ .map(p => this.doFilterConflict(p, entry.actions))
514
+ .filter((a): a is Action<TAction> => a !== undefined);
515
+ return { after, mutated: after.length !== before.length || after.some((a, i) => a !== before[i]) };
516
+ }
517
+
518
+ /** Refresh this instance against the stored log: adopt the latest committed revision, resolve
519
+ * pending actions against everything that landed since, and replay them if anything conflicts.
520
+ *
521
+ * Takes no in-flight action id — it reads {@link inFlightActionId} off `this`, which is set for
522
+ * exactly the write attempt cycles that own one (see that field). Callers cannot get this wrong
523
+ * by omission. */
524
+ private async updateInternal() {
525
+ // Start with a context that can see to the end of the log
526
+ const source = new TransactorSource(this.id, this.transactor, undefined);
527
+ const tracker = new Tracker(source);
528
+
529
+ // Bootstrap context from committed tail so pending blocks are accessible.
530
+ // Read through tracker so Chain.open inside Log.open reuses the cached header.
531
+ // A header the storage layer could not retrieve throws BlockUnavailableError out of
532
+ // this read (it is not a StaleFailure, so sync's retry loop does not absorb it).
533
+ const header = await tracker.tryGet(this.id) as CollectionHeaderBlock | undefined;
534
+ if (header) {
535
+ await Collection.bootstrapContext(source, this.transactor, header);
536
+ } else if (this.source.actionContext) {
537
+ // An absent header is only believable for a collection that has never committed.
538
+ // We hold a committed revision, so the two answers contradict each other — surface it
539
+ // as a fault instead of no-opping into a forgotten revision and a rev-1 retry spin.
540
+ // NOTE: this aborts every caller of update(), including TransactionCoordinator's
541
+ // blanket refresh of ALL registered collections between commit retries — a
542
+ // non-participant with a momentarily-absent header now fails the whole retry rather
543
+ // than being skipped. That is the intended loud failure; if it ever shows up as
544
+ // otherwise-healthy transactions aborting, narrow that refresh to the transaction's
545
+ // participants (see the note at coordinator.ts's update loop) rather than softening
546
+ // this throw.
547
+ throw new CollectionHeaderVanishedError(this.id, this.source.actionContext.rev);
548
+ }
549
+ // Falling through means the header is genuinely absent AND we hold no revision: nothing
550
+ // was ever committed under this id. Log.open reads the same block id, so it too resolves
551
+ // undefined and everything below no-ops — correct here, rather than a masked failure.
552
+
553
+ // The revision the committed tail just claimed, captured before anything else can touch
554
+ // the local source. This is the authoritative "latest committed under this id" number,
555
+ // read straight off the tail block's state; the chain walk below arrives at its own
556
+ // number by a different path, and the two disagreeing is worth saying out loud (see the
557
+ // {@link reportShortfall} call after advanceContext). Stays undefined when there is no header, no
558
+ // tail, or a tail with no `latest` all legitimate "nothing committed yet" states.
559
+ const tailRev = source.actionContext?.rev;
560
+
561
+ // Get the latest entries from the log, starting from where we left off
562
+ const actionContext = this.source.actionContext;
563
+ const collectionLog = await Log.open<Action<TAction>>(tracker, this.id);
564
+ const latest = collectionLog ? await collectionLog.getFrom(actionContext?.rev ?? 0) : undefined;
565
+
566
+ // Process the entries and track the blocks they affect
567
+ let anyConflicts = false;
568
+ for (const entry of latest?.entries ?? []) {
569
+ const isOwnEntry = this.inFlightActionId !== undefined && entry.actionId === this.inFlightActionId;
570
+ const { after, mutated } = isOwnEntry
571
+ ? this.consumeOwnEntry(entry)
572
+ : this.filterAgainstEntry(entry);
573
+ this.pending = after;
574
+ this.sourceCache.clear(entry.blockIds);
575
+ anyConflicts = anyConflicts || mutated || this.tracker.conflicts(new Set(entry.blockIds)).length > 0;
576
+ }
577
+
578
+ // React to durable invalidations that landed since we last synced. getFrom intentionally skips
579
+ // invalidation entries (they are not pending/committed actions), so surface them separately: an
580
+ // invalidation reverted committed content this client may have read, so treat it like a stale
581
+ // read — drop the reverted blocks from the read cache and replay pending work against the reverted
582
+ // base (docs/right-is-right.md §Client notification). De-duped across cascade children by reverted
583
+ // block; over-inclusive by design (over-invalidation just resubmits it never wrongly retains).
584
+ const invalidations = collectionLog ? await collectionLog.getInvalidationsFrom(actionContext?.rev ?? 0) : [];
585
+ if (invalidations.length > 0) {
586
+ const revertedBlockIds = [...new Set(invalidations.flatMap(inv => inv.reverted.map(r => r.blockId)))];
587
+ this.sourceCache.clear(revertedBlockIds);
588
+ if (this.pending.length > 0) {
589
+ anyConflicts = true;
590
+ }
591
+ }
592
+
593
+ // Update our context to the latest — monotonically. An empty/unopenable log yields no
594
+ // context at all, and a log read that lags what we already committed yields an older one;
595
+ // neither is grounds for forgetting the revision we hold. This must happen BEFORE
596
+ // replayActions below: replay re-reads blocks through this.source, which materializes
597
+ // content at this.actionContext.rev — if the cursor hasn't advanced yet, replay re-reads
598
+ // at the revision we're leaving and refills the cache with stale content that nothing
599
+ // will invalidate again (the log entry that would have cleared it was already consumed).
600
+ Collection.advanceContext(this.source, this.id, this.instanceTag, 'refresh', latest?.context);
601
+
602
+ Collection.reportShortfall(this.id, this.instanceTag, tailRev, actionContext?.rev, this.source.actionContext?.rev);
603
+
604
+ // On conflicts, re-stage the pending actions against the adopted revision. The affected
605
+ // blocks were already dropped from sourceCache above (per log entry / per invalidation),
606
+ // so the replay's reads re-materialize from the transactor.
607
+ // NOTE: a throw out of replayActions leaves the tracker holding only the transforms
608
+ // replayed so far while `pending` still lists them all; the caller's error handling is
609
+ // expected to abort/reset the collection rather than keep staging. If replay ever gains a
610
+ // routinely-throwing read path, rebuild into a scratch tracker and swap on success.
611
+ if (anyConflicts) {
612
+ await this.replayActions();
613
+ }
614
+ }
615
+
616
+ /** Capture the current staged state — tracker transforms plus the pending
617
+ * action queue — so it can be restored later via {@link restorePending}.
618
+ *
619
+ * Use to bracket a unit of staged DML that may need to be rolled back. Unlike
620
+ * a blanket "reset to empty", restoring this snapshot preserves any structural
621
+ * baseline that predates the staged DML — most importantly a brand-new
622
+ * collection's header/root blocks, which live in the tracker (uncommitted)
623
+ * until the first sync. Resetting such a collection to empty would leave it
624
+ * unreadable; restoring the snapshot returns it to its prior (readable) state.
625
+ *
626
+ * The returned snapshot is deep-cloned and independent of subsequent mutations.
627
+ * Synchronous and latch-free: intended to bracket transaction-scoped staging,
628
+ * when no concurrent act/sync is in flight. */
629
+ snapshotPending(): CollectionSnapshot<TAction> {
630
+ return {
631
+ transforms: copyTransforms(this.tracker.transforms),
632
+ pending: [...this.pending],
633
+ context: structuredClone(this.source.actionContext),
634
+ };
635
+ }
636
+
637
+ /** Restore the staged state captured by {@link snapshotPending}, discarding any
638
+ * mutations staged since. Reads through the collection then observe exactly the
639
+ * snapshot state again; storage is untouched because nothing was ever synced. */
640
+ restorePending(snapshot: CollectionSnapshot<TAction>): void {
641
+ this.tracker.reset(copyTransforms(snapshot.transforms));
642
+ this.pending = [...snapshot.pending];
643
+ }
644
+
645
+ /** A read-only {@link Tracker} pinned to this collection's committed state AS OF the
646
+ * moment of this call, seeded with a (deep-copied) set of pre-transaction transforms.
647
+ * Reads through it observe exactly that revision plus exactly those transforms NOT
648
+ * the mutations staged into this collection's live tracker afterward, and NOT commits
649
+ * that fold into (or clear) the live read cache while the view is being walked. Used
650
+ * to build a committed read view (see {@link Tree.readView}) that a scan can trust
651
+ * from first row to last.
652
+ *
653
+ * The pinning has three legs, built in ONE synchronous block so they all describe the
654
+ * same instant (do not introduce an await between them):
655
+ * - a private {@link TransactorSource} whose action context is a deep copy FROZEN at
656
+ * view-creation time, so a block first read after a later commit still materializes
657
+ * at the pinned revision (the transactor honours `context.rev` on get);
658
+ * - a private {@link CacheSource} nothing else references, so the live collection's
659
+ * `transformCache`/`clear` cannot reach it;
660
+ * - that private cache is seeded from the shared cache's current entries, so the
661
+ * common committed read (deferred CHECK over a warm cache) stays warm instead of
662
+ * refetching every block over the network.
663
+ *
664
+ * By default the view records NO read dependencies — it is not part of any
665
+ * transaction's conflict set (see {@link ReadViewOptions.recordReads}).
666
+ *
667
+ * NOTE: each view holds up to the cache LRU budget (128) of cloned blocks plus
668
+ * whatever it faults in. Views are per-scan and dropped when the scan ends; a very
669
+ * long-lived committed scan pins that much memory.
670
+ *
671
+ * NOTE: an INVENTED collection (createOrOpen found no header, so `actionContext` is
672
+ * undefined) pins to no revision — its private source asks the transactor for the
673
+ * latest. Harmless today because such a collection's blocks all live in the tracker
674
+ * transforms, so a view never reaches storage; if invented collections ever gain
675
+ * committed blocks not covered by their transforms, this view would follow storage
676
+ * forward instead of staying pinned.
677
+ *
678
+ * When {@link ReadViewOptions.pinContext} is supplied, the view pins to THAT
679
+ * boundary instead of the current context, and cache entries committed at a newer
680
+ * revision are dropped from the seed (they would otherwise be served blindly — the
681
+ * cache never checks a hit against the requested context). Dropped entries are
682
+ * refetched from the transactor, which resolves the highest committed revision at
683
+ * or below the pin. This is what lets a snapshot captured BEFORE a commit still
684
+ * yield a coherent pre-commit view AFTER that commit folded into the shared cache
685
+ * (a mid-sweep multi-tree commit being the motivating case). */
686
+ createReadTracker(transforms: Transforms, options?: ReadViewOptions): Tracker<IBlock> {
687
+ const collector = options?.recordReads ? this.source.getCollector() : undefined;
688
+ const pinContext = options?.pinContext ?? this.source.actionContext;
689
+ const pinRev = options?.pinContext?.rev;
690
+ let seed = this.sourceCache.snapshotEntries();
691
+ if (pinRev !== undefined) {
692
+ // NOTE: entries whose revision the cache never learned read as 0 and pass this
693
+ // filter. Committed blocks always carry a real revision (the transactor reports
694
+ // it on load; transformCache stamps it on fold), so a rev-0 entry newer than the
695
+ // pin does not occur on the paths that reach here.
696
+ seed = seed.filter(([, , revision]) => revision <= pinRev);
697
+ }
698
+ const pinnedSource = new TransactorSource<IBlock>(
699
+ this.id, this.transactor, structuredClone(pinContext), collector);
700
+ const pinnedCache = new CacheSource<IBlock>(
701
+ pinnedSource, undefined, collector, seed);
702
+ return new Tracker(pinnedCache, copyTransforms(transforms));
703
+ }
704
+
705
+ /** The staged (not-yet-synced) actions queued by {@link act}.
706
+ *
707
+ * Exposed so a {@link TransactionCoordinator} can append them to the log at
708
+ * commit time — mirroring what {@link sync} does internally — when the actions
709
+ * were staged directly into this collection (e.g. through a Tree's stage())
710
+ * rather than applied via the coordinator's own action path. */
711
+ getPendingActions(): Action<TAction>[] {
712
+ return this.pending;
713
+ }
714
+
715
+ /** Drop the staged actions after they have been committed through a
716
+ * coordinator. Counterpart to {@link getPendingActions}; {@link sync} clears
717
+ * its own pending inline, so this is only needed when commit was orchestrated
718
+ * externally. */
719
+ clearPendingActions(): void {
720
+ this.pending = [];
721
+ }
722
+
723
+ /** Whether {@link sync} has anything to push: staged actions, or tracker transforms
724
+ * that were never committed (an INVENTED collection's header/root blocks live there
725
+ * until its first sync, with no pending action to name them).
726
+ *
727
+ * This is the exact predicate {@link syncInternal} loops on, so `false` means a sync
728
+ * would commit nothing. Exposed so a caller that would otherwise flush
729
+ * unconditionally can skip the round trip — note that {@link Tree.sync} routes
730
+ * through {@link updateAndSync}, so an unnecessary flush still pays a full,
731
+ * cache-bypassing {@link update} before discovering it has nothing to do. */
732
+ hasUnsyncedChanges(): boolean {
733
+ return this.pending.length > 0 || !isTransformsEmpty(this.tracker.transforms);
734
+ }
735
+
736
+ /** The committed revision this collection currently READS at, or `undefined` for an
737
+ * INVENTED collection that has never adopted a committed revision
738
+ * ({@link createOrOpen} found no header and staged a fresh empty one).
739
+ *
740
+ * Not the revision a pending write will land at: {@link getNextRev} is this plus one
741
+ * (`undefined` counting as 0), so a diagnostic that prints this value BEFORE a commit
742
+ * is naming the revision the commit will supersede, not the one it produces.
743
+ *
744
+ * DIAGNOSTIC ONLY — do not branch on this. Every block this collection reads is
745
+ * materialized at this revision ({@link TransactorSource.tryGet} passes it as the
746
+ * read context), and the revision advances ONLY through an explicit call on THIS
747
+ * instance — {@link update} or {@link sync} on the single-node path, or
748
+ * {@link recordCommitted} when a {@link TransactionCoordinator} commits this
749
+ * collection (the session/consensus path, where no `update()` is involved at all).
750
+ * Nothing moves it passively — not time, not another collection's
751
+ * commit, not a peer's notification. So a collection sitting here at a lagging
752
+ * revision silently serves an old root with no error, and two collections in one
753
+ * process can be at different revisions at the same instant. That gap is invisible
754
+ * from outside the class without this accessor, which is the whole reason it
755
+ * exists: `docs/debugging.md` (§ "Which revision did a read descend?") explains
756
+ * how an operator reads the difference. */
757
+ committedRevision(): number | undefined {
758
+ return this.source.actionContext?.rev;
759
+ }
760
+
761
+ /** The id of the action that PRODUCED the revision {@link committedRevision} reports —
762
+ * this collection's lineage marker at that revision — or `undefined` when the action
763
+ * context holds no entry at the current revision.
764
+ *
765
+ * `undefined` is legitimate, not an error: an INVENTED collection has no context at
766
+ * all, and otherwise {@link actionIdAt} resolves nothing at the current revision for
767
+ * the reasons listed there. A caller printing this must therefore carry a placeholder
768
+ * rather than invent an id. The contexts this class writes itself
769
+ * ({@link recordCommitted}, the inline bump in `syncInternal`,
770
+ * {@link bootstrapContext}) always do hold one.
771
+ *
772
+ * DIAGNOSTIC ONLY — do not branch on this. Its value is the one thing about a revision
773
+ * that IS comparable across collections and across nodes: a revision number is
774
+ * per-collection and says nothing on its own, so two nodes reporting the same
775
+ * collection id at the same revision are indistinguishable between "one collection,
776
+ * one node lagging" and "two separately-built collections each counting from 1". Equal
777
+ * action ids mean one lineage; different action ids at the same revision mean two.
778
+ * `docs/debugging.md` (§ "Which revision did a read descend?") spells out how an
779
+ * operator reads the pair. */
780
+ committedActionId(): ActionId | undefined {
781
+ const context = this.source.actionContext;
782
+ return context === undefined ? undefined : actionIdAt(context, context.rev);
783
+ }
784
+
785
+ /** Fold a just-committed set of transforms into this collection's read cache
786
+ * so subsequent reads (and stages) through THIS instance observe the committed
787
+ * state, mirroring what {@link sync} does inline after a successful transact.
788
+ *
789
+ * Needed when commit was orchestrated externally (a coordinator): the tracker
790
+ * is reset to empty, but the cache still holds the pre-commit blocks. Without
791
+ * this, a collection that already had committed state (e.g. a pre-synced index
792
+ * tree, or any collection on its second commit) keeps serving the stale prior
793
+ * revision because {@link update} sees its rev is already current and refetches
794
+ * nothing. Call BEFORE resetting the tracker (the transforms are read live).
795
+ *
796
+ * @param revision - the committed revision these transforms land at (from
797
+ * {@link recordCommitted}), so cached read-dependency revisions advance to it. */
798
+ applyCommittedToCache(transforms: Transforms, revision: number): void {
799
+ this.sourceCache.transformCache(transforms, revision);
800
+ }
801
+
802
+ /** Next revision this collection would commit at (current committed rev + 1). */
803
+ getNextRev(): number {
804
+ return (this.source.actionContext?.rev ?? 0) + 1;
805
+ }
806
+
807
+ /** Record a just-committed action: append its ActionRev to the committed list
808
+ * and advance the revision. Returns the new revision. Mirrors the inline bump
809
+ * in {@link syncInternal} — which needs no such rev check because it computes and
810
+ * uses its `newRev` inside one latched span.
811
+ *
812
+ * @param rev - the revision this action was PENDED at, captured once (at the log
813
+ * append in `TransactionCoordinator.applyActionsToCollection`) and threaded through
814
+ * the pend/commit round trips. Storage assigned the action THAT number; recording it
815
+ * at any other would fork this instance's revision counter from storage permanently
816
+ * (context adoption is one-way — see {@link advanceContext}). With the coordinator
817
+ * holding this instance's latch across the whole commit span the mismatch cannot
818
+ * happen; the throw is the tripwire for any path that still bypasses the latch. */
819
+ recordCommitted(actionId: ActionId, rev: number): number {
820
+ const expected = this.getNextRev();
821
+ if (rev !== expected) {
822
+ throw new Error(`Collection ${this.id}: action ${actionId} was pended at rev ${rev} ` +
823
+ `but the collection now expects rev ${expected} — the collection was refreshed mid-commit`);
824
+ }
825
+ this.source.actionContext = {
826
+ committed: [...(this.source.actionContext?.committed ?? []), { actionId, rev }],
827
+ rev,
828
+ };
829
+ return rev;
830
+ }
831
+
832
+ /** Acquire this instance's latch — the same mutex {@link act}, {@link update},
833
+ * {@link sync}, and {@link updateAndSync} serialize behind — returning its release.
834
+ * Exists so a TransactionCoordinator can hold the latch across its WHOLE commit span
835
+ * (log append → pend → commit → local fold), keeping any refresh of this instance from
836
+ * interleaving with a mid-flight commit. `Latches` is non-reentrant: while holding this,
837
+ * the holder must not call any of those latched methods on this instance. The caller
838
+ * MUST call the release exactly once, in a `finally`. */
839
+ acquireLatch(): Promise<() => void> {
840
+ return Latches.acquire(this.latchId);
841
+ }
842
+
843
+ /** Bracket a write attempt cycle on this instance under `actionId`, so a refresh taken between
844
+ * a failed attempt and its retry recognises that action's own already-durable log entry (see
845
+ * {@link inFlightActionId} for why, and for the lifetime this must span).
846
+ *
847
+ * The returned disposer clears the mark and MUST be called in a `finally` covering every exit
848
+ * from the retry cycle — return, retry exhaustion, partial commit, hard error, abort. A mark
849
+ * left behind would let a LATER, unrelated refresh consume a foreign entry that happens to
850
+ * carry the same id. The clear is id-guarded, so a disposer whose mark has since been replaced
851
+ * by another attempt is a no-op rather than wiping the newer one; disposers may therefore be
852
+ * called out of order and more than once. Shaped like {@link acquireLatch} deliberately: a
853
+ * disposer is harder to forget than a paired `end…` call.
854
+ *
855
+ * A concurrent READER's refresh landing in that latch-free window is fine, and is reachable
856
+ * today (`OptimysticModule` declares `concurrencyMode = 'reentrant-reads'`, so scans inside a
857
+ * transaction share one instance and only serialize on the latch). Consuming is a property of
858
+ * the (instance, action id) pair, not of who calls: whoever refreshes first drops exactly the
859
+ * durable entry's actions and the write's own later refresh then finds nothing new. The leading
860
+ * slice stays right because {@link actInternal} APPENDS, so anything staged after the entry was
861
+ * written survives it.
862
+ *
863
+ * NOTE: a concurrent WRITER is the hazard. The mark deliberately outlives the latch (see
864
+ * {@link inFlightActionId}), so two writes overlapping on ONE instance can trample each other's:
865
+ * a second write that acquires the latch between the first's failed attempt and its
866
+ * retry-refresh replaces the id, and the first's refresh then reads the SECOND write's id —
867
+ * consuming that write's durable entry and dropping pending actions of its own that never
868
+ * landed. Not reachable today: a coordinator commit and a `sync()` both run on one session call
869
+ * path, which is the same assumption the participant selection in
870
+ * `TransactionCoordinator.commitOnce` and its `rollback` already rest on. If a second writer is
871
+ * ever allowed to drive the SAME instance concurrently, this must become a per-attempt token (a
872
+ * mark object compared by identity, refusing to replace a live one) rather than a bare id. */
873
+ beginInFlightAction(actionId: ActionId): () => void {
874
+ this.inFlightActionId = actionId;
875
+ return () => {
876
+ if (this.inFlightActionId === actionId) {
877
+ this.inFlightActionId = undefined;
878
+ }
879
+ };
880
+ }
881
+
882
+ /** Push our pending actions to the transactor */
883
+ async sync(options?: SyncOptions) {
884
+ const release = await Latches.acquire(this.latchId);
885
+ try {
886
+ await this.syncInternal(options);
887
+ } finally {
888
+ release();
889
+ }
890
+ }
891
+
892
+ /** Mints the one action id this sync reuses across all of its retry attempts, and owns it for
893
+ * the WHOLE cycle — including the inter-attempt refresh, which is the only thing that reads it
894
+ * (see {@link inFlightActionId}). `sync()`/`updateAndSync()` hold the collection latch across
895
+ * all of this, so the mark's lifetime is contained inside the latched span here; the disposer
896
+ * runs on every exit, including a throw out of retry exhaustion or an abort. */
897
+ private async syncInternal(options?: SyncOptions) {
898
+ const bytes = randomBytes(16);
899
+ const actionId = uint8ArrayToString(bytes, 'base64url');
900
+
901
+ const endInFlight = this.beginInFlightAction(actionId);
902
+ try {
903
+ await this.syncAttempts(actionId, options);
904
+ } finally {
905
+ endInFlight();
906
+ }
907
+ }
908
+
909
+ /** The retry loop behind {@link syncInternal}, run with `actionId` already marked in flight. */
910
+ private async syncAttempts(actionId: ActionId, options?: SyncOptions) {
911
+ const maxAttempts = options?.maxAttempts ?? DefaultMaxAttempts;
912
+ const baseBackoffMs = options?.baseBackoffMs ?? PendingRetryDelayMs;
913
+ const maxBackoffMs = options?.maxBackoffMs ?? DefaultMaxBackoffMs;
914
+ const deadlineMs = options?.deadlineMs;
915
+ const signal = options?.signal;
916
+ const startedAt = Date.now();
917
+
918
+ // Count of consecutive stale failures that made no forward progress. Reset to 0 on every
919
+ // successful transact, so the cap bounds only a persistently-failing sync — a legitimate
920
+ // large multi-batch sync (which iterates many times committing progress) never trips it.
921
+ let consecutiveFailures = 0;
922
+ let lastReason: string | undefined;
923
+ // Last confirmed revision a responder reported holding. Purely diagnostic — it is reported
924
+ // in the exhaustion error and never consulted to decide whether to keep retrying.
925
+ let lastStaleAt: { blockId: BlockId; rev: number } | undefined;
926
+
927
+ while (this.hasUnsyncedChanges()) {
928
+ if (signal?.aborted) {
929
+ throw makeAbortError(signal);
930
+ }
931
+ // Progress-agnostic ceiling: give up if the wall-clock deadline passed.
932
+ if (deadlineMs !== undefined && Date.now() - startedAt >= deadlineMs) {
933
+ throw new SyncRetryExhaustedError(this.id, consecutiveFailures, lastReason ?? 'deadline exceeded', lastStaleAt);
934
+ }
935
+
936
+ // Snapshot the pending actions so that any new actions aren't assumed to be part of this action
937
+ const pending = [...this.pending];
938
+
939
+ // Create a snapshot tracker for the action, so that we can ditch the log changes if we have to retry the action
940
+ const snapshot = copyTransforms(this.tracker.transforms);
941
+ const tracker = new Tracker(this.sourceCache, snapshot);
942
+
943
+ // Add the action to the log (in local tracking space)
944
+ const collectionLog = await Log.open<Action<TAction>>(tracker, this.id);
945
+ if (!collectionLog) {
946
+ throw new Error(`Log not found for collection ${this.id}`);
947
+ }
948
+ const newRev = (this.source.actionContext?.rev ?? 0) + 1;
949
+ const addResult = await collectionLog.addActions(pending, actionId, newRev, () => tracker.transformedBlockIds());
950
+
951
+ // Declare what each touched block will contain once committed, computed from this snapshot
952
+ // tracker (which layers over `this.sourceCache`, so the peek/getCachedRevision probes are
953
+ // live). Purely local — an id whose base is not already cached is simply omitted and falls
954
+ // back to corroboration on the member side. Computed AFTER the log append so the log tail
955
+ // and header transforms this attempt just staged are digested too.
956
+ // NOTE: recomputed from scratch on every retry attempt (the snapshot tracker is rebuilt each
957
+ // iteration), so a sync that loses N races pays N full hashing passes over its touched
958
+ // blocks. Unmeasured and cheap relative to the round trips it is retrying; if a
959
+ // high-contention sync ever shows digest hashing in a profile, memoize per (id, staged ops).
960
+ const blockDigests = await computeBlockContentDigests(tracker, tracker.transformedBlockIds());
961
+
962
+ // Commit the action to the transactor. Carry the aged retry priority derived from the
963
+ // consecutive-failure count so a sync that keeps losing concurrent races out-ranks fresh
964
+ // (priority-0) rivals in the cluster's resolveRace (fairness-only; capped at MaxPriority).
965
+ // First attempt has consecutiveFailures == 0, so priority 0 — the common pend is unchanged.
966
+ const staleFailure = await this.source.transact(tracker.transforms, actionId, newRev, this.id, addResult.tailPath.block.header.id, clampPriority(consecutiveFailures), blockDigests);
967
+ if (staleFailure) {
968
+ consecutiveFailures++;
969
+ lastReason = staleFailure.reason ?? lastReason;
970
+ lastStaleAt = staleFailure.staleAt ?? lastStaleAt;
971
+ // Give up once the consecutive no-progress budget is exhausted, so a transactor that
972
+ // persistently rejects the sync can no longer hold the collection latch forever.
973
+ // NOTE: this also bounds the legitimate `pending`-wait case (retrying the same action
974
+ // while another commit is in flight), which used to retry indefinitely. Default 10
975
+ // attempts ≈ 21s of exponential backoff. If a high-contention workload legitimately
976
+ // needs to wait longer for a pending commit to clear, raise maxAttempts for that caller.
977
+ if (consecutiveFailures >= maxAttempts) {
978
+ throw new SyncRetryExhaustedError(this.id, consecutiveFailures, lastReason, lastStaleAt);
979
+ }
980
+ // Back off before every retry (any stale failure — reason/missing/pending), growing
981
+ // exponentially from the base delay up to the cap, with proportional random jitter so a
982
+ // herd of clients that lost the same race does not re-collide on the next tick (see
983
+ // utility/backoff.ts). The abortable sleep lets an aborted sync reject promptly instead
984
+ // of finishing the sleep.
985
+ // NOTE: the `missing`/`reason` conflict paths now pay this backoff too (they previously
986
+ // retried with zero delay); that is what stops the persistent-`reason` hot spin. If a
987
+ // high-contention workload ever shows this base delay as recovery latency, lower
988
+ // baseBackoffMs for that caller rather than reintroducing the zero-delay retry.
989
+ const delay = jitteredBackoffMs(consecutiveFailures - 1, { baseMs: baseBackoffMs, capMs: maxBackoffMs }, options?.rand);
990
+ await abortableDelay(delay, signal);
991
+ // Fetch latest state - updateInternal() will call replayActions() if there are conflicts.
992
+ // This sync's actionId is marked in flight for the whole cycle (see syncInternal), so
993
+ // the refresh recognizes a log entry written by THIS action (a commit that landed
994
+ // durably but answered stale — see the entry loop in updateInternal) and consumes it
995
+ // rather than replaying it into a duplicate entry.
996
+ await this.updateInternal();
997
+ } else {
998
+ // Forward progress: reset the no-progress budget.
999
+ consecutiveFailures = 0;
1000
+ lastReason = undefined;
1001
+ lastStaleAt = undefined;
1002
+ // Clear the pending actions that were part of this action
1003
+ this.pending = this.pending.slice(pending.length);
1004
+ // Reset cache and replay any actions that were added during the action
1005
+ const transforms = tracker.reset();
1006
+ // NOTE: this replay runs BEFORE the cache fold and the context bump below — the
1007
+ // inverse of the order `updateInternal` and `TransactionCoordinator.commitOnce`
1008
+ // both document as required, where the newly committed state must be visible
1009
+ // before anything re-reads. Dormant today: `act()` and `syncInternal` take the
1010
+ // same collection latch, so `this.pending` cannot grow during the transact above
1011
+ // and the slice always leaves it empty, making this replay a no-op reset. If a
1012
+ // path ever stages outside that latch, move the transformCache + actionContext
1013
+ // lines above this replay — otherwise the replay re-reads at the superseded
1014
+ // revision over a cache that has not yet seen the commit, and re-stages onto a
1015
+ // root the commit already replaced.
1016
+ await this.replayActions();
1017
+ this.sourceCache.transformCache(transforms, newRev);
1018
+ this.source.actionContext = this.source.actionContext
1019
+ ? { committed: [...this.source.actionContext.committed, { actionId, rev: newRev }], rev: newRev }
1020
+ : { committed: [{ actionId, rev: newRev }], rev: newRev };
1021
+ }
1022
+ }
1023
+ }
1024
+
1025
+ async updateAndSync(options?: SyncOptions) {
1026
+ const release = await Latches.acquire(this.latchId);
1027
+ try {
1028
+ await this.updateInternal();
1029
+ await this.syncInternal(options);
1030
+ } finally {
1031
+ release();
1032
+ }
1033
+ }
1034
+
1035
+ async *selectLog(forward = true): AsyncIterableIterator<Action<TAction>> {
1036
+ const collectionLog = await Log.open<Action<TAction>>(this.tracker, this.id);
1037
+ if (!collectionLog) {
1038
+ throw new Error(`Log not found for collection ${this.id}`);
1039
+ }
1040
+ for await (const entry of collectionLog.select(undefined, forward)) {
1041
+ if (entry.action) {
1042
+ // NOTE: copy-then-reverse to avoid mutating the stored log entry array.
1043
+ // Once tsconfig targets ES2023, `entry.action.actions.toReversed()` is cleaner.
1044
+ yield* forward ? entry.action.actions : [...entry.action.actions].reverse();
1045
+ }
1046
+ }
1047
+ }
1048
+
1049
+ private async replayActions() {
1050
+ this.tracker.reset();
1051
+ // Replay pending actions against the fresh tracker state (always called under latch)
1052
+ for (const action of this.pending) {
1053
+ await this.internalTransact(action);
1054
+ }
1055
+ }
1056
+
1057
+ getReadDependencies(): ReadDependency[] {
1058
+ return this.source.getReadDependencies();
1059
+ }
1060
+
1061
+ clearReadDependencies(): void {
1062
+ this.source.clearReadDependencies();
1063
+ }
1064
+
1065
+ /** Called for each local action that may be in conflict with a remote action (always called under latch).
1066
+ * @param action - The local action to check
1067
+ * @param potential - The remote actions that are potentially in conflict
1068
+ * @returns The effective action to keep: the original (unchanged), a replacement
1069
+ * instance (applied instead of the original), or undefined to discard it.
1070
+ */
1071
+ protected doFilterConflict(action: Action<TAction>, potential: Action<TAction>[]): Action<TAction> | undefined {
1072
+ return this.filterConflict ? this.filterConflict(action, potential) : action;
1073
+ }
1074
+
1075
+ /** Bootstrap ActionContext from the committed tail block's state.
1076
+ * The tail is always committed first (commit protocol guarantee), so it's readable
1077
+ * with context=undefined. Its state.latest contains the ActionRev of the most recent
1078
+ * committed action — exactly the proof needed for the transactor to serve pending
1079
+ * non-tail blocks during chain walks.
1080
+ *
1081
+ * This read goes to the transactor directly rather than through {@link TransactorSource},
1082
+ * so it has to honour the `unavailable` flag itself: a tail the repo could not retrieve
1083
+ * must not degrade into "no context", which would leave the chain walk unable to see
1084
+ * pending non-tail blocks and the collection reading as if they did not exist. A tail
1085
+ * with no `state.latest` and NO flag is a real answer (nothing committed yet) and still
1086
+ * no-ops.
1087
+ *
1088
+ * The same goes for `unconfirmedAheadRev`: this unpinned tail read is the ONE seam where a
1089
+ * lagging collection can learn a newer revision exists — every later data read is pinned to
1090
+ * the context seeded here. Silently seeding from a tail the repo could not confirm is
1091
+ * current would freeze the collection at the stale revision with nothing ever reporting a
1092
+ * problem, so it throws the same way TransactorSource.tryGet does for its unpinned reads
1093
+ * (see the tradeoff NOTE there).
1094
+ */
1095
+ private static async bootstrapContext(
1096
+ source: TransactorSource<IBlock>,
1097
+ transactor: ITransactor,
1098
+ header: CollectionHeaderBlock,
1099
+ ): Promise<void> {
1100
+ const tailId = header.tailId;
1101
+ if (tailId) {
1102
+ const tailResult = await transactor.get({ blockIds: [tailId] });
1103
+ const tailEntry = tailResult?.[tailId];
1104
+ if (tailEntry?.unavailable !== undefined && tailEntry.block == null) {
1105
+ throw new BlockUnavailableError(tailId, tailEntry.unavailable);
1106
+ }
1107
+ if (tailEntry?.unconfirmedAheadRev !== undefined) {
1108
+ throw new BlockPossiblyStaleError(tailId, tailEntry.unconfirmedAheadRev);
1109
+ }
1110
+ const tailState = tailEntry?.state;
1111
+ // NOTE: this number is adopted on trust, and adoption is one-way (advanceContext never
1112
+ // lowers it). A tail that over-claims therefore pins the collection at a revision its
1113
+ // own log can never reach, permanently: every later refresh walks the log, reads the
1114
+ // real (lower) revision, and is refused — so the instance emits
1115
+ // `collection:context-not-lowered` forever while `collection:context-short-of-tail`
1116
+ // stays silent (the held revision is at or above what the tail claims). No condition
1117
+ // that makes a real tail over-claim has been demonstrated; this was seen only through a
1118
+ // test double built to lie (see collection.spec.ts, 'a refresh that lands short of the
1119
+ // tail it just read'). If an over-claiming tail is ever observed in the field, the fix
1120
+ // belongs here — validate the claim against the log before pinning — not in the refresh.
1121
+ if (tailState?.latest) {
1122
+ source.actionContext = {
1123
+ committed: [{ actionId: tailState.latest.actionId, rev: tailState.latest.rev }],
1124
+ rev: tailState.latest.rev,
1125
+ };
1126
+ }
1127
+ }
1128
+ }
1129
+ }