@optimystic/db-core 0.24.2 → 0.25.1

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
@@ -2,10 +2,12 @@ import { Log } from "../log/log.js";
2
2
  import { Atomic } from "../transform/atomic.js";
3
3
  import { Tracker } from "../transform/tracker.js";
4
4
  import { CacheSource } from "../transform/cache-source.js";
5
+ import { computeBlockContentDigests } from "../transform/digest.js";
5
6
  import { copyTransforms, isTransformsEmpty } from "../transform/helpers.js";
6
7
  import { TransactorSource } from "../transactor/transactor-source.js";
7
8
  import { BlockUnavailableError, BlockPossiblyStaleError } from "../network/struct.js";
8
9
  import { CollectionHeaderVanishedError, SyncRetryExhaustedError } from "./struct.js";
10
+ import { actionIdAt } from "./action.js";
9
11
  import { clampPriority } from "../transaction/transaction.js";
10
12
  import { ReadDependencyCollector } from "../transaction/read-dependency-collector.js";
11
13
  import { randomBytes } from '@noble/hashes/utils.js';
@@ -28,13 +30,45 @@ export class Collection {
28
30
  sourceCache;
29
31
  tracker;
30
32
  filterConflict;
33
+ instanceTag;
31
34
  pending = [];
32
35
  latchId;
36
+ /** The action id of a write currently in flight ON THIS INSTANCE'S BEHALF, or `undefined`
37
+ * outside a write. Read by {@link updateInternal}: if the committed log now carries an entry
38
+ * under this id, that action's work is already durable despite the failure answer that sent us
39
+ * back here — `NetworkTransactor.commit` commits the collection header and log tail BEFORE
40
+ * sweeping the remaining blocks, so a later sweep block confirming a conflict reports failure
41
+ * over an action whose log entry already landed. Such an entry is CONSUMED
42
+ * ({@link consumeOwnEntry}) rather than replayed, because replaying re-appends content the
43
+ * committed tail already carries, producing a duplicate entry under one action id at two
44
+ * revisions.
45
+ *
46
+ * The collection owns this fact rather than taking it as a `updateInternal` argument so that no
47
+ * refresh path can forget to supply it — {@link update} and {@link updateAndSync} are refreshes
48
+ * on behalf of a READER, the field is unset for them, and the consume branch cannot fire. Before
49
+ * this was a field, `TransactionCoordinator.commit`'s inter-attempt refresh went through
50
+ * `update()` and was therefore indistinguishable from a reader refresh even though the
51
+ * coordinator held the very id it was retrying.
52
+ *
53
+ * LIFETIME is the whole attempt CYCLE, not the latched span: it must survive the refresh
54
+ * BETWEEN a failed attempt and its retry, which is the only moment it is ever read. In
55
+ * {@link syncInternal} that cycle is contained inside the collection latch `sync()` holds; in
56
+ * `TransactionCoordinator.commit` the inter-attempt `update()` runs OUTSIDE the commit latch
57
+ * span by design (`Latches` is non-reentrant), so the coordinator's clear necessarily runs
58
+ * latch-free. That is safe: this is a single field write, {@link beginInFlightAction}'s
59
+ * disposer only clears an id it still owns, and the only reader runs under the latch — so the
60
+ * worst a foreign concurrent refresh can see is a cleared field (it stops consuming), never a
61
+ * field it should not have consumed. */
62
+ inFlightActionId;
33
63
  constructor(id, transactor, handlers, source,
34
64
  /** Cache of unmodified blocks from the source */
35
65
  sourceCache,
36
66
  /** Tracked Changes */
37
- tracker, filterConflict) {
67
+ tracker, filterConflict,
68
+ /** Short random tag naming THIS instance (see {@link newInstanceTag}). Open paths generate
69
+ * it BEFORE construction (so pre-construction diagnostics such as attachToLog can carry it);
70
+ * the default covers direct construction in tests. */
71
+ instanceTag = Collection.newInstanceTag()) {
38
72
  this.id = id;
39
73
  this.transactor = transactor;
40
74
  this.handlers = handlers;
@@ -42,7 +76,25 @@ export class Collection {
42
76
  this.sourceCache = sourceCache;
43
77
  this.tracker = tracker;
44
78
  this.filterConflict = filterConflict;
45
- this.latchId = `Collection:${this.id}`;
79
+ this.instanceTag = instanceTag;
80
+ // Instance-scoped, deliberately NOT shared across instances of one collection id. The
81
+ // latch protects per-instance state only — the tracker, the pending queue, and
82
+ // source.actionContext, none of which two instances over the same id share — while
83
+ // cross-instance races are resolved by the transactor's optimistic concurrency (that is
84
+ // the design; the old process-global `Collection:${id}` key serialized instances by
85
+ // accident). Instance scope is also what lets TransactionCoordinator hold this latch
86
+ // across its whole commit span: `Latches` is a non-reentrant FIFO mutex, and a rival
87
+ // writer driving a SECOND instance of the same id from inside transactor.pend (see
88
+ // CompetingWriterTransactor) would otherwise wait on the very latch the parked commit
89
+ // holds — a deadlock, not contention.
90
+ this.latchId = `Collection:${this.id}#${this.instanceTag}`;
91
+ }
92
+ /** A fresh instance tag: four random bytes rendered base64url — six characters, enough that
93
+ * two instances over one collection id do not collide by accident, short enough to ride on
94
+ * every trace line (same shape as the node tag in quereus-plugin-optimystic's
95
+ * collection-factory). Scopes {@link latchId} per instance and labels diagnostics. */
96
+ static newInstanceTag() {
97
+ return uint8ArrayToString(randomBytes(4), 'base64url');
46
98
  }
47
99
  /** Open an EXISTING collection.
48
100
  *
@@ -63,8 +115,11 @@ export class Collection {
63
115
  // that ignores the undefined cannot later sync a phantom collection into existence.
64
116
  return undefined;
65
117
  }
66
- await Collection.attachToLog(source, transactor, tracker, id, header);
67
- return new Collection(id, transactor, init.modules, source, sourceCache, tracker, init.filterConflict);
118
+ // Generated BEFORE attachToLog so log-attach-time diagnostics can name the instance
119
+ // the same way post-construction ones do.
120
+ const instanceTag = Collection.newInstanceTag();
121
+ await Collection.attachToLog(source, transactor, tracker, id, instanceTag, header);
122
+ return new Collection(id, transactor, init.modules, source, sourceCache, tracker, init.filterConflict, instanceTag);
68
123
  }
69
124
  /** Open an existing collection, or stage a fresh empty one in the local tracker when the
70
125
  * header is authoritatively absent. Nothing is written to storage until {@link sync}.
@@ -74,8 +129,10 @@ export class Collection {
74
129
  * any pure read path. */
75
130
  static async createOrOpen(transactor, id, init) {
76
131
  const { source, sourceCache, tracker, header } = await Collection.probeHeader(transactor, id);
132
+ // Pre-construction for the same reason as in open(): see the comment there.
133
+ const instanceTag = Collection.newInstanceTag();
77
134
  if (header) { // Collection already exists
78
- await Collection.attachToLog(source, transactor, tracker, id, header);
135
+ await Collection.attachToLog(source, transactor, tracker, id, instanceTag, header);
79
136
  }
80
137
  else { // Collection does not exist
81
138
  log('collection:invented id=%s — no committed header found; staging a fresh empty collection', id);
@@ -84,7 +141,7 @@ export class Collection {
84
141
  source.actionContext = undefined;
85
142
  await Log.open(tracker, id);
86
143
  }
87
- return new Collection(id, transactor, init.modules, source, sourceCache, tracker, init.filterConflict);
144
+ return new Collection(id, transactor, init.modules, source, sourceCache, tracker, init.filterConflict, instanceTag);
88
145
  }
89
146
  /** The per-instance read wiring every open path needs, plus the header probe result.
90
147
  * Shared by {@link open} and {@link createOrOpen} so the two cannot drift. */
@@ -103,7 +160,10 @@ export class Collection {
103
160
  * A header we just probed successfully but whose log will not open is a fault, not an
104
161
  * absence — throw rather than let the collection read as empty. (The re-read goes through
105
162
  * the tracker/cache, so it can disagree with the probe when storage is flaky mid-open.) */
106
- static async attachToLog(source, transactor, tracker, id, header) {
163
+ static async attachToLog(source, transactor, tracker, id,
164
+ /** The tag the calling open path minted for the Collection it is ABOUT to construct, so a
165
+ * diagnostic emitted here carries the same instance name as every post-construction one. */
166
+ instanceTag, header) {
107
167
  // Bootstrap ActionContext from the committed tail before walking the chain.
108
168
  // This allows the transactor to serve pending non-tail blocks during Log.open.
109
169
  await Collection.bootstrapContext(source, transactor, header);
@@ -114,7 +174,7 @@ export class Collection {
114
174
  // Monotonic, not an overwrite: getActionContext resolves undefined when the chain has no
115
175
  // tail or the tail block carries zero entries, and that must not erase the revision
116
176
  // bootstrapContext just read off the committed tail.
117
- Collection.advanceContext(source, id, await collectionLog.getActionContext());
177
+ Collection.advanceContext(source, id, instanceTag, 'attach', await collectionLog.getActionContext());
118
178
  }
119
179
  /** Adopt a freshly-read action context WITHOUT ever lowering the revision already held.
120
180
  *
@@ -124,18 +184,150 @@ export class Collection {
124
184
  * repeats the same doomed request because each retry re-runs the same losing read.
125
185
  *
126
186
  * Equal revisions still adopt `next`: the rev is unchanged but its `committed` list may be
127
- * more complete than what we hold. */
128
- static advanceContext(source, id, next) {
187
+ * more complete than what we hold.
188
+ *
189
+ * This is also the one seam where lineage divergence is observable: at every revision BOTH
190
+ * sides name an action for, the two ids must agree. Revision
191
+ * numbers are per-collection counters, so two separately-built copies under one id can each
192
+ * occupy the same revision with DIFFERENT actions while each stays internally self-consistent
193
+ * — {@link reportShortfall} structurally cannot see that (its two numbers come from one
194
+ * chain), and this is the only place two `committed` lists meet. Naming different actions at
195
+ * one revision proves the two sides are different lineages (`collection:lineage-divergence`;
196
+ * see docs/debugging.md § "Did the refresh itself fail to close the gap?"). WHICH two sides
197
+ * depends on the caller, and the line says so in `site=` — see {@link DivergenceSite}, which
198
+ * defines the two values and what each one indicts.
199
+ *
200
+ * Every line from here also carries `tag=`, the {@link Collection.instanceTag} of the handle
201
+ * reporting. One process routinely holds several handles on one collection id; without the
202
+ * tag, two handles' lines interleave into what reads like one handle contradicting itself.
203
+ *
204
+ * Logs, does not throw — same reasoning as {@link reportShortfall}: `update()` runs
205
+ * blanket-style over every registered collection between commit retries, and aborting here
206
+ * would promote a diagnosis to production behaviour before the line has ever been seen to
207
+ * fire in the wild. Adoption then proceeds unchanged, which means the line is a PER-DISCOVERY
208
+ * report, not a per-refresh one: adopting `next` overwrites the held lineage marker with the
209
+ * log's, so the next refresh of this instance compares log-to-log and stays silent — even
210
+ * though block content materialized under the old lineage may still be in caches. The line
211
+ * marks the refresh that first observed the disagreement.
212
+ *
213
+ * NOTE: adoption resolves the CONTEXT disagreement, not the content one — the read caches on
214
+ * this instance still hold blocks materialized under the old lineage, and since the revision
215
+ * did not change nothing re-reads them. Conditional today: no fork has been reproduced (see
216
+ * the still-open upstream reproducer), so this instrument exists to find out whether one
217
+ * happens at all. If the line is ever seen firing in the field, decide then whether a
218
+ * divergence should also drop the read cache (and whether to keep re-reporting per refresh)
219
+ * — that is a behaviour change, and this seam deliberately makes none.
220
+ *
221
+ * The comparison is {@link earliestFork}, not a single lookup at the current revision: the
222
+ * two `committed` lists overlap across several revisions, and the LOWEST one they disagree at
223
+ * is where the lineages actually parted — a fork below the current revision was previously
224
+ * silent. `forkRev=` names it, `heldAction=`/`readAction=` are the two ids AT it, and
225
+ * `heldRev=`/`readRev=` are the two contexts' own revisions, so the line says both where the
226
+ * split began and how far each side has since travelled.
227
+ *
228
+ * The refusal line reports its two action ids at `readRev=` — the read's revision — on BOTH
229
+ * sides, because that is the only revision the two can be compared at: `next` never names an
230
+ * action above its own revision, so looking each side up at its own revision would compare
231
+ * different revisions and print two different ids for one honest lineage. Equal ids there mean
232
+ * the read is an older view of THIS lineage (ordinary lag, correctly refused); different ids
233
+ * mean a fork; `none` on the held side means this handle's own list does not reach back to the
234
+ * read's revision — the signature of a context bootstrapped from an over-claiming tail (see
235
+ * the NOTE in {@link bootstrapContext}), which is exactly the case {@link earliestFork} has no
236
+ * shared revision to report on.
237
+ *
238
+ * Gated on `log.enabled`, like every {@link actionIdAt} caller: the comparison buys nothing
239
+ * when the line has no sink, and the lists — one entry per commit between context reads,
240
+ * truncated at each checkpoint — are only walked on a run that has the namespace turned on.
241
+ * Silence proves nothing either way, because a revision is only comparable when BOTH sides
242
+ * name an action for it: an invented collection has no context at all, a revision slot the log
243
+ * gave to a checkpoint or invalidation entry names none, and a revision older than the read
244
+ * log's most recent checkpoint has already fallen off the read side's list. */
245
+ static advanceContext(source, id, instanceTag, site, next) {
129
246
  const current = source.actionContext;
130
247
  if (next === undefined) {
131
248
  return; // The read learned nothing — keep what we already know.
132
249
  }
250
+ if (current !== undefined && log.enabled) {
251
+ const fork = Collection.earliestFork(current, next);
252
+ if (fork !== undefined) {
253
+ log('collection:lineage-divergence id=%s tag=%s site=%s forkRev=%d heldAction=%s readAction=%s heldRev=%d readRev=%d', id, instanceTag, site, fork.rev, fork.heldAction, fork.readAction, current.rev, next.rev);
254
+ }
255
+ }
133
256
  if (current !== undefined && next.rev < current.rev) {
134
- log('collection:context-not-lowered id=%s held=%d read=%d', id, current.rev, next.rev);
257
+ // The refusal itself is unconditional; only the id lookups that explain it are gated.
258
+ if (log.enabled) {
259
+ log('collection:context-not-lowered id=%s tag=%s site=%s heldRev=%d readRev=%d heldAction=%s readAction=%s', id, instanceTag, site, current.rev, next.rev, actionIdAt(current, next.rev) ?? 'none', actionIdAt(next, next.rev) ?? 'none');
260
+ }
135
261
  return;
136
262
  }
137
263
  source.actionContext = next;
138
264
  }
265
+ /** The EARLIEST revision the two contexts provably disagree about: the lowest revision both
266
+ * `committed` lists name an action for, where the two ids differ.
267
+ *
268
+ * Comparing only at the holder's current revision — what this used to do — misses a fork that
269
+ * began earlier and has since been overtaken by same-numbered commits on both sides, which is
270
+ * the shape a replica that forked and kept writing actually has. Taking the lowest disagreeing
271
+ * revision instead names the split point rather than an arbitrary later symptom of it.
272
+ *
273
+ * Revisions only one side names are skipped, not treated as disagreement: {@link actionIdAt}'s
274
+ * `undefined` is legitimate (checkpoint/invalidation slots, and revisions that predate the
275
+ * other side's most recent checkpoint), so a one-sided entry is missing evidence, not evidence
276
+ * of a fork.
277
+ *
278
+ * NOTE: linear in the two lists, which hold one entry per commit between context reads and
279
+ * truncate at each checkpoint. Every caller is `log.enabled`-gated, so this does not run at
280
+ * all on a normal run; if a non-diagnostic caller ever appears, index by revision instead. */
281
+ static earliestFork(held, read) {
282
+ // NOTE: a `committed` list carrying TWO entries at one revision would be a defect in its own
283
+ // right, and this keeps the last of them arbitrarily. Harmless while every caller is a
284
+ // diagnostic; if such a list is ever seen, report the duplicate rather than silently
285
+ // picking one.
286
+ const readIds = new Map(read.committed.map(entry => [entry.rev, entry.actionId]));
287
+ let earliest;
288
+ for (const entry of held.committed) {
289
+ const readAction = readIds.get(entry.rev);
290
+ if (readAction === undefined || readAction === entry.actionId) {
291
+ continue;
292
+ }
293
+ if (earliest === undefined || entry.rev < earliest.rev) {
294
+ earliest = { rev: entry.rev, heldAction: entry.actionId, readAction };
295
+ }
296
+ }
297
+ return earliest;
298
+ }
299
+ /** Report a refresh that failed to move FORWARDS past a revision it had already read for
300
+ * itself — the sibling of {@link advanceContext}'s `collection:context-not-lowered`, which
301
+ * reports a collection declining to move BACKWARDS.
302
+ *
303
+ * `tailRev` is what the committed log tail claimed is committed under this id; `after` is
304
+ * where a SEPARATE read path (the chain walk) actually landed. Landing below the claim means
305
+ * this refresh closed nothing, which from outside the class is otherwise indistinguishable
306
+ * from "there was nothing newer to adopt".
307
+ *
308
+ * This detects LAG, and only lag. It CANNOT see lineage divergence: both of its numbers
309
+ * come from the same chain — `tailRev` off the tail block this collection's own header
310
+ * names, `after` from a walk of that same chain — and a forked replica is internally
311
+ * self-consistent, its tail claiming exactly what its own walk reaches. Two copies of one
312
+ * collection id holding the same revision under different actions therefore keep this line
313
+ * silent forever. That case is `collection:lineage-divergence`, reported from
314
+ * {@link advanceContext}, which compares action ids — the one value comparable across
315
+ * copies — rather than revision counters.
316
+ *
317
+ * Carries the same `tag=` as {@link advanceContext}'s lines, and for the same reason: several
318
+ * handles on one collection id otherwise read as one self-contradicting handle.
319
+ *
320
+ * Logs, does not throw: `update()` is called blanket-style over every registered collection
321
+ * between commit retries, and a shortfall is not yet known to be illegitimate — an abort here
322
+ * would promote an unproven diagnosis to production behaviour. Deliberately does NOT adopt
323
+ * `tailRev` either: the two numbers come from different read paths, and papering over the
324
+ * disagreement destroys the evidence this line exists to produce. */
325
+ static reportShortfall(id, instanceTag, tailRev, before, after) {
326
+ if (tailRev === undefined || (after !== undefined && after >= tailRev)) {
327
+ return;
328
+ }
329
+ log('collection:context-short-of-tail id=%s tag=%s before=%s after=%s tail=%d', id, instanceTag, before ?? 'none', after ?? 'none', tailRev);
330
+ }
139
331
  async act(...actions) {
140
332
  const release = await Latches.acquire(this.latchId);
141
333
  try {
@@ -170,6 +362,55 @@ export class Collection {
170
362
  release();
171
363
  }
172
364
  }
365
+ /** Drops the pending actions this sync's OWN committed entry already made durable, instead of
366
+ * replaying them into a duplicate entry (see {@link inFlightActionId}).
367
+ *
368
+ * `addActions` wrote exactly the snapshot pending list under this action id, and the entry's
369
+ * actions are therefore the LEADING `entry.actions.length` items of `this.pending` — anything
370
+ * staged since is behind them, because {@link actInternal} appends. Under
371
+ * {@link syncInternal} nothing can even be staged mid-cycle (`act()` shares the collection
372
+ * latch); under `TransactionCoordinator.commit` the mark spans a latch-free inter-attempt
373
+ * window, so an `act()` there CAN grow `pending` — still only at the tail, so the slice stays
374
+ * right.
375
+ *
376
+ * The guard below is the load-bearing part: `slice` fails SILENTLY if that correspondence ever
377
+ * breaks, dropping actions that were never committed, so an entry longer than `pending` throws
378
+ * instead of losing work. (See the sibling note on `syncInternal`'s post-commit replay, which
379
+ * rests on the same invariant.) */
380
+ consumeOwnEntry(entry) {
381
+ if (entry.actions.length > this.pending.length) {
382
+ throw new Error(`Collection ${this.id}: own committed entry for action ${entry.actionId} holds `
383
+ + `${entry.actions.length} actions but only ${this.pending.length} are pending; `
384
+ + `consuming it would drop actions that were never committed`);
385
+ }
386
+ // `mutated` is unconditional, even for a zero-action entry: the tracker still holds this
387
+ // action's staged transforms, and only the replay at the end of `updateInternal` — which
388
+ // resets the tracker and re-stages just what remains — drops them. That reset is what turns
389
+ // `hasUnsyncedChanges()` false so the sync loop exits reporting the success the writer is
390
+ // owed (the action IS durable).
391
+ return { after: this.pending.slice(entry.actions.length), mutated: true };
392
+ }
393
+ /** Maps each pending action to its effective form against a remote entry: the original, a
394
+ * replacement, or dropped. A replacement or a discard changes the pending set; the tracker still
395
+ * holds the pre-filter transforms, so report it as mutated to force a replay that re-stages
396
+ * against the effective actions. Identity comparison per the contract: keep => same instance,
397
+ * replace => new instance.
398
+ * NOTE: a filterConflict hook that always allocates a fresh (but equal) instance instead of
399
+ * returning the same one forces a replay on every update — if that ever shows up as a hot path,
400
+ * compare by value/id here instead of by reference. */
401
+ filterAgainstEntry(entry) {
402
+ const before = this.pending;
403
+ const after = before
404
+ .map(p => this.doFilterConflict(p, entry.actions))
405
+ .filter((a) => a !== undefined);
406
+ return { after, mutated: after.length !== before.length || after.some((a, i) => a !== before[i]) };
407
+ }
408
+ /** Refresh this instance against the stored log: adopt the latest committed revision, resolve
409
+ * pending actions against everything that landed since, and replay them if anything conflicts.
410
+ *
411
+ * Takes no in-flight action id — it reads {@link inFlightActionId} off `this`, which is set for
412
+ * exactly the write attempt cycles that own one (see that field). Callers cannot get this wrong
413
+ * by omission. */
173
414
  async updateInternal() {
174
415
  // Start with a context that can see to the end of the log
175
416
  const source = new TransactorSource(this.id, this.transactor, undefined);
@@ -198,6 +439,13 @@ export class Collection {
198
439
  // Falling through means the header is genuinely absent AND we hold no revision: nothing
199
440
  // was ever committed under this id. Log.open reads the same block id, so it too resolves
200
441
  // undefined and everything below no-ops — correct here, rather than a masked failure.
442
+ // The revision the committed tail just claimed, captured before anything else can touch
443
+ // the local source. This is the authoritative "latest committed under this id" number,
444
+ // read straight off the tail block's state; the chain walk below arrives at its own
445
+ // number by a different path, and the two disagreeing is worth saying out loud (see the
446
+ // {@link reportShortfall} call after advanceContext). Stays undefined when there is no header, no
447
+ // tail, or a tail with no `latest` — all legitimate "nothing committed yet" states.
448
+ const tailRev = source.actionContext?.rev;
201
449
  // Get the latest entries from the log, starting from where we left off
202
450
  const actionContext = this.source.actionContext;
203
451
  const collectionLog = await Log.open(tracker, this.id);
@@ -205,19 +453,10 @@ export class Collection {
205
453
  // Process the entries and track the blocks they affect
206
454
  let anyConflicts = false;
207
455
  for (const entry of latest?.entries ?? []) {
208
- // Filter any pending actions that conflict with the remote actions. Each pending
209
- // action maps to its effective form: the original, a replacement, or dropped.
210
- const before = this.pending;
211
- const after = before
212
- .map(p => this.doFilterConflict(p, entry.actions))
213
- .filter((a) => a !== undefined);
214
- // A replacement or a discard changes the pending set; the tracker still holds the
215
- // pre-filter transforms, so force a replay to re-stage against the effective actions.
216
- // Identity comparison per the contract: keep => same instance, replace => new instance.
217
- // NOTE: a filterConflict hook that always allocates a fresh (but equal) instance instead
218
- // of returning the same one forces a replay on every update — if that ever shows up as a
219
- // hot path, compare by value/id here instead of by reference.
220
- const mutated = after.length !== before.length || after.some((a, i) => a !== before[i]);
456
+ const isOwnEntry = this.inFlightActionId !== undefined && entry.actionId === this.inFlightActionId;
457
+ const { after, mutated } = isOwnEntry
458
+ ? this.consumeOwnEntry(entry)
459
+ : this.filterAgainstEntry(entry);
221
460
  this.pending = after;
222
461
  this.sourceCache.clear(entry.blockIds);
223
462
  anyConflicts = anyConflicts || mutated || this.tracker.conflicts(new Set(entry.blockIds)).length > 0;
@@ -243,7 +482,8 @@ export class Collection {
243
482
  // content at this.actionContext.rev — if the cursor hasn't advanced yet, replay re-reads
244
483
  // at the revision we're leaving and refills the cache with stale content that nothing
245
484
  // will invalidate again (the log entry that would have cleared it was already consumed).
246
- Collection.advanceContext(this.source, this.id, latest?.context);
485
+ Collection.advanceContext(this.source, this.id, this.instanceTag, 'refresh', latest?.context);
486
+ Collection.reportShortfall(this.id, this.instanceTag, tailRev, actionContext?.rev, this.source.actionContext?.rev);
247
487
  // On conflicts, re-stage the pending actions against the adopted revision. The affected
248
488
  // blocks were already dropped from sourceCache above (per log entry / per invalidation),
249
489
  // so the replay's reads re-materialize from the transactor.
@@ -367,6 +607,53 @@ export class Collection {
367
607
  hasUnsyncedChanges() {
368
608
  return this.pending.length > 0 || !isTransformsEmpty(this.tracker.transforms);
369
609
  }
610
+ /** The committed revision this collection currently READS at, or `undefined` for an
611
+ * INVENTED collection that has never adopted a committed revision
612
+ * ({@link createOrOpen} found no header and staged a fresh empty one).
613
+ *
614
+ * Not the revision a pending write will land at: {@link getNextRev} is this plus one
615
+ * (`undefined` counting as 0), so a diagnostic that prints this value BEFORE a commit
616
+ * is naming the revision the commit will supersede, not the one it produces.
617
+ *
618
+ * DIAGNOSTIC ONLY — do not branch on this. Every block this collection reads is
619
+ * materialized at this revision ({@link TransactorSource.tryGet} passes it as the
620
+ * read context), and the revision advances ONLY through an explicit call on THIS
621
+ * instance — {@link update} or {@link sync} on the single-node path, or
622
+ * {@link recordCommitted} when a {@link TransactionCoordinator} commits this
623
+ * collection (the session/consensus path, where no `update()` is involved at all).
624
+ * Nothing moves it passively — not time, not another collection's
625
+ * commit, not a peer's notification. So a collection sitting here at a lagging
626
+ * revision silently serves an old root with no error, and two collections in one
627
+ * process can be at different revisions at the same instant. That gap is invisible
628
+ * from outside the class without this accessor, which is the whole reason it
629
+ * exists: `docs/debugging.md` (§ "Which revision did a read descend?") explains
630
+ * how an operator reads the difference. */
631
+ committedRevision() {
632
+ return this.source.actionContext?.rev;
633
+ }
634
+ /** The id of the action that PRODUCED the revision {@link committedRevision} reports —
635
+ * this collection's lineage marker at that revision — or `undefined` when the action
636
+ * context holds no entry at the current revision.
637
+ *
638
+ * `undefined` is legitimate, not an error: an INVENTED collection has no context at
639
+ * all, and otherwise {@link actionIdAt} resolves nothing at the current revision for
640
+ * the reasons listed there. A caller printing this must therefore carry a placeholder
641
+ * rather than invent an id. The contexts this class writes itself
642
+ * ({@link recordCommitted}, the inline bump in `syncInternal`,
643
+ * {@link bootstrapContext}) always do hold one.
644
+ *
645
+ * DIAGNOSTIC ONLY — do not branch on this. Its value is the one thing about a revision
646
+ * that IS comparable across collections and across nodes: a revision number is
647
+ * per-collection and says nothing on its own, so two nodes reporting the same
648
+ * collection id at the same revision are indistinguishable between "one collection,
649
+ * one node lagging" and "two separately-built collections each counting from 1". Equal
650
+ * action ids mean one lineage; different action ids at the same revision mean two.
651
+ * `docs/debugging.md` (§ "Which revision did a read descend?") spells out how an
652
+ * operator reads the pair. */
653
+ committedActionId() {
654
+ const context = this.source.actionContext;
655
+ return context === undefined ? undefined : actionIdAt(context, context.rev);
656
+ }
370
657
  /** Fold a just-committed set of transforms into this collection's read cache
371
658
  * so subsequent reads (and stages) through THIS instance observe the committed
372
659
  * state, mirroring what {@link sync} does inline after a successful transact.
@@ -389,15 +676,76 @@ export class Collection {
389
676
  }
390
677
  /** Record a just-committed action: append its ActionRev to the committed list
391
678
  * and advance the revision. Returns the new revision. Mirrors the inline bump
392
- * in {@link syncInternal}. */
393
- recordCommitted(actionId) {
394
- const rev = this.getNextRev();
679
+ * in {@link syncInternal} — which needs no such rev check because it computes and
680
+ * uses its `newRev` inside one latched span.
681
+ *
682
+ * @param rev - the revision this action was PENDED at, captured once (at the log
683
+ * append in `TransactionCoordinator.applyActionsToCollection`) and threaded through
684
+ * the pend/commit round trips. Storage assigned the action THAT number; recording it
685
+ * at any other would fork this instance's revision counter from storage permanently
686
+ * (context adoption is one-way — see {@link advanceContext}). With the coordinator
687
+ * holding this instance's latch across the whole commit span the mismatch cannot
688
+ * happen; the throw is the tripwire for any path that still bypasses the latch. */
689
+ recordCommitted(actionId, rev) {
690
+ const expected = this.getNextRev();
691
+ if (rev !== expected) {
692
+ throw new Error(`Collection ${this.id}: action ${actionId} was pended at rev ${rev} ` +
693
+ `but the collection now expects rev ${expected} — the collection was refreshed mid-commit`);
694
+ }
395
695
  this.source.actionContext = {
396
696
  committed: [...(this.source.actionContext?.committed ?? []), { actionId, rev }],
397
697
  rev,
398
698
  };
399
699
  return rev;
400
700
  }
701
+ /** Acquire this instance's latch — the same mutex {@link act}, {@link update},
702
+ * {@link sync}, and {@link updateAndSync} serialize behind — returning its release.
703
+ * Exists so a TransactionCoordinator can hold the latch across its WHOLE commit span
704
+ * (log append → pend → commit → local fold), keeping any refresh of this instance from
705
+ * interleaving with a mid-flight commit. `Latches` is non-reentrant: while holding this,
706
+ * the holder must not call any of those latched methods on this instance. The caller
707
+ * MUST call the release exactly once, in a `finally`. */
708
+ acquireLatch() {
709
+ return Latches.acquire(this.latchId);
710
+ }
711
+ /** Bracket a write attempt cycle on this instance under `actionId`, so a refresh taken between
712
+ * a failed attempt and its retry recognises that action's own already-durable log entry (see
713
+ * {@link inFlightActionId} for why, and for the lifetime this must span).
714
+ *
715
+ * The returned disposer clears the mark and MUST be called in a `finally` covering every exit
716
+ * from the retry cycle — return, retry exhaustion, partial commit, hard error, abort. A mark
717
+ * left behind would let a LATER, unrelated refresh consume a foreign entry that happens to
718
+ * carry the same id. The clear is id-guarded, so a disposer whose mark has since been replaced
719
+ * by another attempt is a no-op rather than wiping the newer one; disposers may therefore be
720
+ * called out of order and more than once. Shaped like {@link acquireLatch} deliberately: a
721
+ * disposer is harder to forget than a paired `end…` call.
722
+ *
723
+ * A concurrent READER's refresh landing in that latch-free window is fine, and is reachable
724
+ * today (`OptimysticModule` declares `concurrencyMode = 'reentrant-reads'`, so scans inside a
725
+ * transaction share one instance and only serialize on the latch). Consuming is a property of
726
+ * the (instance, action id) pair, not of who calls: whoever refreshes first drops exactly the
727
+ * durable entry's actions and the write's own later refresh then finds nothing new. The leading
728
+ * slice stays right because {@link actInternal} APPENDS, so anything staged after the entry was
729
+ * written survives it.
730
+ *
731
+ * NOTE: a concurrent WRITER is the hazard. The mark deliberately outlives the latch (see
732
+ * {@link inFlightActionId}), so two writes overlapping on ONE instance can trample each other's:
733
+ * a second write that acquires the latch between the first's failed attempt and its
734
+ * retry-refresh replaces the id, and the first's refresh then reads the SECOND write's id —
735
+ * consuming that write's durable entry and dropping pending actions of its own that never
736
+ * landed. Not reachable today: a coordinator commit and a `sync()` both run on one session call
737
+ * path, which is the same assumption the participant selection in
738
+ * `TransactionCoordinator.commitOnce` and its `rollback` already rest on. If a second writer is
739
+ * ever allowed to drive the SAME instance concurrently, this must become a per-attempt token (a
740
+ * mark object compared by identity, refusing to replace a live one) rather than a bare id. */
741
+ beginInFlightAction(actionId) {
742
+ this.inFlightActionId = actionId;
743
+ return () => {
744
+ if (this.inFlightActionId === actionId) {
745
+ this.inFlightActionId = undefined;
746
+ }
747
+ };
748
+ }
401
749
  /** Push our pending actions to the transactor */
402
750
  async sync(options) {
403
751
  const release = await Latches.acquire(this.latchId);
@@ -408,9 +756,24 @@ export class Collection {
408
756
  release();
409
757
  }
410
758
  }
759
+ /** Mints the one action id this sync reuses across all of its retry attempts, and owns it for
760
+ * the WHOLE cycle — including the inter-attempt refresh, which is the only thing that reads it
761
+ * (see {@link inFlightActionId}). `sync()`/`updateAndSync()` hold the collection latch across
762
+ * all of this, so the mark's lifetime is contained inside the latched span here; the disposer
763
+ * runs on every exit, including a throw out of retry exhaustion or an abort. */
411
764
  async syncInternal(options) {
412
765
  const bytes = randomBytes(16);
413
766
  const actionId = uint8ArrayToString(bytes, 'base64url');
767
+ const endInFlight = this.beginInFlightAction(actionId);
768
+ try {
769
+ await this.syncAttempts(actionId, options);
770
+ }
771
+ finally {
772
+ endInFlight();
773
+ }
774
+ }
775
+ /** The retry loop behind {@link syncInternal}, run with `actionId` already marked in flight. */
776
+ async syncAttempts(actionId, options) {
414
777
  const maxAttempts = options?.maxAttempts ?? DefaultMaxAttempts;
415
778
  const baseBackoffMs = options?.baseBackoffMs ?? PendingRetryDelayMs;
416
779
  const maxBackoffMs = options?.maxBackoffMs ?? DefaultMaxBackoffMs;
@@ -445,11 +808,21 @@ export class Collection {
445
808
  }
446
809
  const newRev = (this.source.actionContext?.rev ?? 0) + 1;
447
810
  const addResult = await collectionLog.addActions(pending, actionId, newRev, () => tracker.transformedBlockIds());
811
+ // Declare what each touched block will contain once committed, computed from this snapshot
812
+ // tracker (which layers over `this.sourceCache`, so the peek/getCachedRevision probes are
813
+ // live). Purely local — an id whose base is not already cached is simply omitted and falls
814
+ // back to corroboration on the member side. Computed AFTER the log append so the log tail
815
+ // and header transforms this attempt just staged are digested too.
816
+ // NOTE: recomputed from scratch on every retry attempt (the snapshot tracker is rebuilt each
817
+ // iteration), so a sync that loses N races pays N full hashing passes over its touched
818
+ // blocks. Unmeasured and cheap relative to the round trips it is retrying; if a
819
+ // high-contention sync ever shows digest hashing in a profile, memoize per (id, staged ops).
820
+ const blockDigests = await computeBlockContentDigests(tracker, tracker.transformedBlockIds());
448
821
  // Commit the action to the transactor. Carry the aged retry priority derived from the
449
822
  // consecutive-failure count so a sync that keeps losing concurrent races out-ranks fresh
450
823
  // (priority-0) rivals in the cluster's resolveRace (fairness-only; capped at MaxPriority).
451
824
  // First attempt has consecutiveFailures == 0, so priority 0 — the common pend is unchanged.
452
- const staleFailure = await this.source.transact(tracker.transforms, actionId, newRev, this.id, addResult.tailPath.block.header.id, clampPriority(consecutiveFailures));
825
+ const staleFailure = await this.source.transact(tracker.transforms, actionId, newRev, this.id, addResult.tailPath.block.header.id, clampPriority(consecutiveFailures), blockDigests);
453
826
  if (staleFailure) {
454
827
  consecutiveFailures++;
455
828
  lastReason = staleFailure.reason ?? lastReason;
@@ -474,7 +847,11 @@ export class Collection {
474
847
  // baseBackoffMs for that caller rather than reintroducing the zero-delay retry.
475
848
  const delay = jitteredBackoffMs(consecutiveFailures - 1, { baseMs: baseBackoffMs, capMs: maxBackoffMs }, options?.rand);
476
849
  await abortableDelay(delay, signal);
477
- // Fetch latest state - updateInternal() will call replayActions() if there are conflicts
850
+ // Fetch latest state - updateInternal() will call replayActions() if there are conflicts.
851
+ // This sync's actionId is marked in flight for the whole cycle (see syncInternal), so
852
+ // the refresh recognizes a log entry written by THIS action (a commit that landed
853
+ // durably but answered stale — see the entry loop in updateInternal) and consumes it
854
+ // rather than replaying it into a duplicate entry.
478
855
  await this.updateInternal();
479
856
  }
480
857
  else {
@@ -486,6 +863,16 @@ export class Collection {
486
863
  this.pending = this.pending.slice(pending.length);
487
864
  // Reset cache and replay any actions that were added during the action
488
865
  const transforms = tracker.reset();
866
+ // NOTE: this replay runs BEFORE the cache fold and the context bump below — the
867
+ // inverse of the order `updateInternal` and `TransactionCoordinator.commitOnce`
868
+ // both document as required, where the newly committed state must be visible
869
+ // before anything re-reads. Dormant today: `act()` and `syncInternal` take the
870
+ // same collection latch, so `this.pending` cannot grow during the transact above
871
+ // and the slice always leaves it empty, making this replay a no-op reset. If a
872
+ // path ever stages outside that latch, move the transformCache + actionContext
873
+ // lines above this replay — otherwise the replay re-reads at the superseded
874
+ // revision over a cache that has not yet seen the commit, and re-stages onto a
875
+ // root the commit already replaced.
489
876
  await this.replayActions();
490
877
  this.sourceCache.transformCache(transforms, newRev);
491
878
  this.source.actionContext = this.source.actionContext
@@ -571,6 +958,16 @@ export class Collection {
571
958
  throw new BlockPossiblyStaleError(tailId, tailEntry.unconfirmedAheadRev);
572
959
  }
573
960
  const tailState = tailEntry?.state;
961
+ // NOTE: this number is adopted on trust, and adoption is one-way (advanceContext never
962
+ // lowers it). A tail that over-claims therefore pins the collection at a revision its
963
+ // own log can never reach, permanently: every later refresh walks the log, reads the
964
+ // real (lower) revision, and is refused — so the instance emits
965
+ // `collection:context-not-lowered` forever while `collection:context-short-of-tail`
966
+ // stays silent (the held revision is at or above what the tail claims). No condition
967
+ // that makes a real tail over-claim has been demonstrated; this was seen only through a
968
+ // test double built to lie (see collection.spec.ts, 'a refresh that lands short of the
969
+ // tail it just read'). If an over-claiming tail is ever observed in the field, the fix
970
+ // belongs here — validate the claim against the log before pinning — not in the refresh.
574
971
  if (tailState?.latest) {
575
972
  source.actionContext = {
576
973
  committed: [{ actionId: tailState.latest.actionId, rev: tailState.latest.rev }],