@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.
- package/dist/src/blocks/helpers.d.ts +5 -0
- package/dist/src/blocks/helpers.d.ts.map +1 -1
- package/dist/src/blocks/helpers.js +12 -0
- package/dist/src/blocks/helpers.js.map +1 -1
- package/dist/src/cluster/membership.d.ts +7 -0
- package/dist/src/cluster/membership.d.ts.map +1 -1
- package/dist/src/cluster/membership.js +12 -8
- package/dist/src/cluster/membership.js.map +1 -1
- package/dist/src/cluster/structs.d.ts +27 -1
- package/dist/src/cluster/structs.d.ts.map +1 -1
- package/dist/src/cluster/structs.js.map +1 -1
- package/dist/src/collection/action.d.ts +14 -0
- package/dist/src/collection/action.d.ts.map +1 -1
- package/dist/src/collection/action.js +16 -1
- package/dist/src/collection/action.js.map +1 -1
- package/dist/src/collection/collection.d.ts +273 -4
- package/dist/src/collection/collection.d.ts.map +1 -1
- package/dist/src/collection/collection.js +427 -30
- package/dist/src/collection/collection.js.map +1 -1
- package/dist/src/collections/tree/tree.d.ts +16 -1
- package/dist/src/collections/tree/tree.d.ts.map +1 -1
- package/dist/src/collections/tree/tree.js +19 -0
- package/dist/src/collections/tree/tree.js.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/network/i-repo.d.ts +11 -1
- package/dist/src/network/i-repo.d.ts.map +1 -1
- package/dist/src/network/stale-failure.d.ts +21 -0
- package/dist/src/network/stale-failure.d.ts.map +1 -1
- package/dist/src/network/stale-failure.js +22 -0
- package/dist/src/network/stale-failure.js.map +1 -1
- package/dist/src/network/struct.d.ts +66 -21
- package/dist/src/network/struct.d.ts.map +1 -1
- package/dist/src/network/struct.js.map +1 -1
- package/dist/src/testing/test-transactor.d.ts +22 -0
- package/dist/src/testing/test-transactor.d.ts.map +1 -1
- package/dist/src/testing/test-transactor.js +44 -5
- package/dist/src/testing/test-transactor.js.map +1 -1
- package/dist/src/transaction/coordinator.d.ts +20 -0
- package/dist/src/transaction/coordinator.d.ts.map +1 -1
- package/dist/src/transaction/coordinator.js +256 -113
- package/dist/src/transaction/coordinator.js.map +1 -1
- package/dist/src/transaction/operations-hash.d.ts +1 -1
- package/dist/src/transaction/operations-hash.js +1 -1
- package/dist/src/transaction/transaction.d.ts +4 -2
- package/dist/src/transaction/transaction.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.d.ts +21 -1
- package/dist/src/transactor/network-transactor.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.js +122 -29
- package/dist/src/transactor/network-transactor.js.map +1 -1
- package/dist/src/transactor/transactor-source.d.ts +11 -5
- package/dist/src/transactor/transactor-source.d.ts.map +1 -1
- package/dist/src/transactor/transactor-source.js +16 -8
- package/dist/src/transactor/transactor-source.js.map +1 -1
- package/dist/src/transform/cache-source.d.ts +13 -0
- package/dist/src/transform/cache-source.d.ts.map +1 -1
- package/dist/src/transform/cache-source.js +18 -0
- package/dist/src/transform/cache-source.js.map +1 -1
- package/dist/src/transform/digest.d.ts +18 -0
- package/dist/src/transform/digest.d.ts.map +1 -0
- package/dist/src/transform/digest.js +65 -0
- package/dist/src/transform/digest.js.map +1 -0
- package/dist/src/transform/index.d.ts +1 -0
- package/dist/src/transform/index.d.ts.map +1 -1
- package/dist/src/transform/index.js +1 -0
- package/dist/src/transform/index.js.map +1 -1
- package/dist/src/transform/tracker.d.ts +16 -0
- package/dist/src/transform/tracker.d.ts.map +1 -1
- package/dist/src/transform/tracker.js +40 -1
- package/dist/src/transform/tracker.js.map +1 -1
- package/dist/src/utility/canonical-json.d.ts +11 -0
- package/dist/src/utility/canonical-json.d.ts.map +1 -0
- package/dist/src/utility/canonical-json.js +15 -0
- package/dist/src/utility/canonical-json.js.map +1 -0
- package/dist/src/utility/lru-map.d.ts +2 -0
- package/dist/src/utility/lru-map.d.ts.map +1 -1
- package/dist/src/utility/lru-map.js +4 -0
- package/dist/src/utility/lru-map.js.map +1 -1
- package/package.json +2 -1
- package/src/blocks/helpers.ts +26 -13
- package/src/cluster/membership.ts +87 -85
- package/src/cluster/structs.ts +28 -1
- package/src/collection/action.ts +17 -0
- package/src/collection/collection.ts +1129 -688
- package/src/collections/tree/tree.ts +341 -320
- package/src/index.ts +24 -23
- package/src/network/i-repo.ts +59 -46
- package/src/network/stale-failure.ts +67 -43
- package/src/network/struct.ts +332 -270
- package/src/testing/test-transactor.ts +680 -638
- package/src/transaction/coordinator.ts +1266 -1110
- package/src/transaction/operations-hash.ts +1 -1
- package/src/transaction/transaction.ts +4 -2
- package/src/transactor/network-transactor.ts +123 -32
- package/src/transactor/transactor-source.ts +17 -9
- package/src/transform/cache-source.ts +20 -0
- package/src/transform/digest.ts +72 -0
- package/src/transform/index.ts +1 -0
- package/src/transform/tracker.ts +42 -1
- package/src/utility/canonical-json.ts +16 -0
- 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.
|
|
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
|
-
|
|
67
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
394
|
-
|
|
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 }],
|