@optimystic/db-core 1.0.0-beta.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/cluster/structs.d.ts +62 -16
- package/dist/src/cluster/structs.d.ts.map +1 -1
- package/dist/src/cluster/structs.js +5 -3
- package/dist/src/cluster/structs.js.map +1 -1
- package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.d.ts +2 -1
- package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.d.ts.map +1 -1
- package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.js +10 -3
- package/dist/src/cohort-topic/antidos/bootstrap-evidence-envelope.js.map +1 -1
- package/dist/src/cohort-topic/wire/codec.d.ts.map +1 -1
- package/dist/src/cohort-topic/wire/codec.js +8 -2
- package/dist/src/cohort-topic/wire/codec.js.map +1 -1
- package/dist/src/collection/collection.d.ts +467 -35
- package/dist/src/collection/collection.d.ts.map +1 -1
- package/dist/src/collection/collection.js +879 -149
- package/dist/src/collection/collection.js.map +1 -1
- package/dist/src/collection/struct.d.ts +109 -4
- package/dist/src/collection/struct.d.ts.map +1 -1
- package/dist/src/collection/struct.js +88 -1
- package/dist/src/collection/struct.js.map +1 -1
- package/dist/src/collections/diary/diary.d.ts +6 -2
- package/dist/src/collections/diary/diary.d.ts.map +1 -1
- package/dist/src/collections/diary/diary.js +5 -2
- package/dist/src/collections/diary/diary.js.map +1 -1
- package/dist/src/collections/tree/struct.d.ts +192 -5
- package/dist/src/collections/tree/struct.d.ts.map +1 -1
- package/dist/src/collections/tree/struct.js +134 -0
- package/dist/src/collections/tree/struct.js.map +1 -1
- package/dist/src/collections/tree/tree.d.ts +13 -3
- package/dist/src/collections/tree/tree.d.ts.map +1 -1
- package/dist/src/collections/tree/tree.js +109 -6
- package/dist/src/collections/tree/tree.js.map +1 -1
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/logger-registry.d.ts +57 -0
- package/dist/src/logger-registry.d.ts.map +1 -0
- package/dist/src/logger-registry.js +168 -0
- package/dist/src/logger-registry.js.map +1 -0
- package/dist/src/logger.d.ts.map +1 -1
- package/dist/src/logger.js +3 -0
- package/dist/src/logger.js.map +1 -1
- package/dist/src/matchmaking/wire.d.ts.map +1 -1
- package/dist/src/matchmaking/wire.js +8 -2
- package/dist/src/matchmaking/wire.js.map +1 -1
- package/dist/src/network/durability.d.ts +45 -0
- package/dist/src/network/durability.d.ts.map +1 -0
- package/dist/src/network/durability.js +114 -0
- package/dist/src/network/durability.js.map +1 -0
- package/dist/src/network/i-key-network.d.ts +14 -6
- package/dist/src/network/i-key-network.d.ts.map +1 -1
- package/dist/src/network/index.d.ts +3 -0
- package/dist/src/network/index.d.ts.map +1 -1
- package/dist/src/network/index.js +3 -0
- package/dist/src/network/index.js.map +1 -1
- package/dist/src/network/lineage.d.ts +39 -0
- package/dist/src/network/lineage.d.ts.map +1 -0
- package/dist/src/network/lineage.js +61 -0
- package/dist/src/network/lineage.js.map +1 -0
- package/dist/src/network/routing-key.d.ts +24 -0
- package/dist/src/network/routing-key.d.ts.map +1 -0
- package/dist/src/network/routing-key.js +9 -0
- package/dist/src/network/routing-key.js.map +1 -0
- package/dist/src/network/stale-failure.d.ts +5 -1
- package/dist/src/network/stale-failure.d.ts.map +1 -1
- package/dist/src/network/stale-failure.js +5 -1
- package/dist/src/network/stale-failure.js.map +1 -1
- package/dist/src/network/struct.d.ts +151 -8
- package/dist/src/network/struct.d.ts.map +1 -1
- package/dist/src/network/struct.js.map +1 -1
- package/dist/src/reactivity/wire.d.ts.map +1 -1
- package/dist/src/reactivity/wire.js +8 -2
- package/dist/src/reactivity/wire.js.map +1 -1
- package/dist/src/testing/test-transactor.d.ts +64 -7
- package/dist/src/testing/test-transactor.d.ts.map +1 -1
- package/dist/src/testing/test-transactor.js +150 -23
- package/dist/src/testing/test-transactor.js.map +1 -1
- package/dist/src/transaction/coordinator.d.ts +141 -21
- package/dist/src/transaction/coordinator.d.ts.map +1 -1
- package/dist/src/transaction/coordinator.js +408 -122
- package/dist/src/transaction/coordinator.js.map +1 -1
- package/dist/src/transaction/errors.d.ts +39 -10
- package/dist/src/transaction/errors.d.ts.map +1 -1
- package/dist/src/transaction/errors.js +34 -9
- package/dist/src/transaction/errors.js.map +1 -1
- package/dist/src/transaction/index.d.ts +1 -0
- package/dist/src/transaction/index.d.ts.map +1 -1
- package/dist/src/transaction/index.js.map +1 -1
- package/dist/src/transactor/block-floors.d.ts +78 -0
- package/dist/src/transactor/block-floors.d.ts.map +1 -0
- package/dist/src/transactor/block-floors.js +62 -0
- package/dist/src/transactor/block-floors.js.map +1 -0
- package/dist/src/transactor/change-notifier.d.ts +43 -0
- package/dist/src/transactor/change-notifier.d.ts.map +1 -1
- package/dist/src/transactor/change-notifier.js +3 -0
- package/dist/src/transactor/change-notifier.js.map +1 -1
- package/dist/src/transactor/index.d.ts +1 -0
- package/dist/src/transactor/index.d.ts.map +1 -1
- package/dist/src/transactor/index.js +1 -0
- package/dist/src/transactor/index.js.map +1 -1
- package/dist/src/transactor/network-transactor.d.ts +43 -6
- package/dist/src/transactor/network-transactor.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.js +279 -81
- package/dist/src/transactor/network-transactor.js.map +1 -1
- package/dist/src/transactor/transactor-source.d.ts +94 -4
- package/dist/src/transactor/transactor-source.d.ts.map +1 -1
- package/dist/src/transactor/transactor-source.js +120 -42
- package/dist/src/transactor/transactor-source.js.map +1 -1
- package/dist/src/transactor/transactor.d.ts +14 -1
- package/dist/src/transactor/transactor.d.ts.map +1 -1
- package/dist/src/transform/atomic.d.ts.map +1 -1
- package/dist/src/transform/atomic.js +12 -9
- package/dist/src/transform/atomic.js.map +1 -1
- package/dist/src/transform/base-pins.d.ts +50 -17
- package/dist/src/transform/base-pins.d.ts.map +1 -1
- package/dist/src/transform/base-pins.js +58 -11
- package/dist/src/transform/base-pins.js.map +1 -1
- package/dist/src/transform/cache-source.d.ts +75 -11
- package/dist/src/transform/cache-source.d.ts.map +1 -1
- package/dist/src/transform/cache-source.js +162 -36
- package/dist/src/transform/cache-source.js.map +1 -1
- package/dist/src/transform/digest.d.ts +8 -1
- package/dist/src/transform/digest.d.ts.map +1 -1
- package/dist/src/transform/digest.js +19 -9
- package/dist/src/transform/digest.js.map +1 -1
- package/dist/src/transform/helpers.d.ts +6 -2
- package/dist/src/transform/helpers.d.ts.map +1 -1
- package/dist/src/transform/helpers.js +8 -3
- package/dist/src/transform/helpers.js.map +1 -1
- package/dist/src/transform/tracker.d.ts +94 -14
- package/dist/src/transform/tracker.d.ts.map +1 -1
- package/dist/src/transform/tracker.js +222 -68
- package/dist/src/transform/tracker.js.map +1 -1
- package/dist/src/utility/structural-equals.d.ts +38 -0
- package/dist/src/utility/structural-equals.d.ts.map +1 -0
- package/dist/src/utility/structural-equals.js +77 -0
- package/dist/src/utility/structural-equals.js.map +1 -0
- package/package.json +2 -2
- package/src/cluster/structs.ts +298 -255
- package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +260 -253
- package/src/cohort-topic/wire/codec.ts +222 -216
- package/src/collection/collection.ts +996 -158
- package/src/collection/struct.ts +99 -4
- package/src/collections/diary/diary.ts +71 -68
- package/src/collections/tree/struct.ts +248 -26
- package/src/collections/tree/tree.ts +130 -9
- package/src/index.ts +2 -1
- package/src/logger-registry.ts +224 -0
- package/src/logger.ts +4 -0
- package/src/matchmaking/wire.ts +8 -2
- package/src/network/durability.ts +123 -0
- package/src/network/i-key-network.ts +60 -52
- package/src/network/index.ts +10 -7
- package/src/network/lineage.ts +80 -0
- package/src/network/routing-key.ts +27 -0
- package/src/network/stale-failure.ts +5 -1
- package/src/network/struct.ts +156 -8
- package/src/reactivity/wire.ts +230 -224
- package/src/testing/test-transactor.ts +152 -24
- package/src/transaction/coordinator.ts +470 -131
- package/src/transaction/errors.ts +34 -9
- package/src/transaction/index.ts +1 -0
- package/src/transactor/block-floors.ts +105 -0
- package/src/transactor/change-notifier.ts +129 -80
- package/src/transactor/index.ts +1 -0
- package/src/transactor/network-transactor.ts +297 -84
- package/src/transactor/transactor-source.ts +131 -42
- package/src/transactor/transactor.ts +13 -1
- package/src/transform/atomic.ts +25 -22
- package/src/transform/base-pins.ts +76 -18
- package/src/transform/cache-source.ts +327 -194
- package/src/transform/digest.ts +21 -10
- package/src/transform/helpers.ts +8 -3
- package/src/transform/tracker.ts +423 -270
- package/src/utility/structural-equals.ts +72 -0
- package/dist/src/utility/block-id-to-bytes.d.ts +0 -3
- package/dist/src/utility/block-id-to-bytes.d.ts.map +0 -1
- package/dist/src/utility/block-id-to-bytes.js +0 -7
- package/dist/src/utility/block-id-to-bytes.js.map +0 -1
- package/src/utility/block-id-to-bytes.ts +0 -8
|
@@ -2,12 +2,13 @@ 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
|
+
import { computeBlockContentDigests, baseRevsField } from "../transform/digest.js";
|
|
6
6
|
import { copyTransforms, isTransformsEmpty } from "../transform/helpers.js";
|
|
7
|
-
import { TransactorSource } from "../transactor/transactor-source.js";
|
|
8
|
-
import {
|
|
7
|
+
import { TransactorSource, answeredBlock, servedRevision } from "../transactor/transactor-source.js";
|
|
8
|
+
import { BlockFloors } from "../transactor/block-floors.js";
|
|
9
|
+
import { mergeDurability } from "../network/durability.js";
|
|
9
10
|
import { highestStaleAt } from "../network/stale-failure.js";
|
|
10
|
-
import { CollectionHeaderVanishedError, SyncRetryExhaustedError, SyncRevisionStalledError } from "./struct.js";
|
|
11
|
+
import { CollectionHeaderVanishedError, SyncRetryExhaustedError, SyncRevisionStalledError, TornActionError } from "./struct.js";
|
|
11
12
|
import { actionIdAt } from "./action.js";
|
|
12
13
|
import { clampPriority } from "../transaction/transaction.js";
|
|
13
14
|
import { ReadDependencyCollector } from "../transaction/read-dependency-collector.js";
|
|
@@ -39,42 +40,73 @@ export class Collection {
|
|
|
39
40
|
source;
|
|
40
41
|
sourceCache;
|
|
41
42
|
tracker;
|
|
43
|
+
floors;
|
|
42
44
|
filterConflict;
|
|
43
45
|
instanceTag;
|
|
44
46
|
pending = [];
|
|
45
47
|
latchId;
|
|
46
48
|
/** The action id of a write currently in flight ON THIS INSTANCE'S BEHALF, or `undefined`
|
|
47
49
|
* outside a write. Read by {@link updateInternal}: if the committed log now carries an entry
|
|
48
|
-
* under this id, that action's
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* committed tail already carries, producing a duplicate entry under one action id at two
|
|
54
|
-
* revisions.
|
|
50
|
+
* under this id, that action's LOG TAIL landed despite the failure answer that sent us back here
|
|
51
|
+
* — `NetworkTransactor.commit` commits the log tail BEFORE sweeping the remaining blocks, and
|
|
52
|
+
* reports failure both when the tail itself was refused after landing on a minority
|
|
53
|
+
* (`commit-not-durable`, in which case the sweep never ran) and when a later sweep block
|
|
54
|
+
* confirmed a conflict. Such an entry is never REPLAYED, because replaying re-appends content
|
|
55
|
+
* the committed tail already carries, producing a duplicate entry under one action id at two
|
|
56
|
+
* revisions. But it is not proof the write is saved either: the entry proves only that the tail
|
|
57
|
+
* landed, and the writer's own cancel has since dropped the pending records of every block that
|
|
58
|
+
* did not. So the refresh first FINISHES the action ({@link completeOwnEntry}) — landing the
|
|
59
|
+
* remaining blocks at the same action id and revision, from {@link inFlightAttempt} — and only
|
|
60
|
+
* then consumes the entry ({@link consumeOwnEntry}).
|
|
55
61
|
*
|
|
56
62
|
* The collection owns this fact rather than taking it as a `updateInternal` argument so that no
|
|
57
63
|
* refresh path can forget to supply it — {@link update} and {@link updateAndSync} are refreshes
|
|
58
64
|
* on behalf of a READER, the field is unset for them, and the consume branch cannot fire. Before
|
|
59
65
|
* this was a field, `TransactionCoordinator.commit`'s inter-attempt refresh went through
|
|
60
66
|
* `update()` and was therefore indistinguishable from a reader refresh even though the
|
|
61
|
-
* coordinator held the very id it was retrying.
|
|
67
|
+
* coordinator held the very id it was retrying. (It now goes through {@link refreshInFlight},
|
|
68
|
+
* which differs from `update()` only in reporting what the refresh saved — the field, not the
|
|
69
|
+
* method, is still what makes the refresh recognise the entry.)
|
|
62
70
|
*
|
|
63
71
|
* LIFETIME is the whole attempt CYCLE, not the latched span: it must survive the refresh
|
|
64
72
|
* BETWEEN a failed attempt and its retry, which is the only moment it is ever read. In
|
|
65
73
|
* {@link syncInternal} that cycle is contained inside the collection latch `sync()` holds; in
|
|
66
|
-
* `TransactionCoordinator.commit` the inter-attempt
|
|
74
|
+
* `TransactionCoordinator.commit` the inter-attempt refresh runs OUTSIDE the commit latch
|
|
67
75
|
* span by design (`Latches` is non-reentrant), so the coordinator's clear necessarily runs
|
|
68
76
|
* latch-free. That is safe: this is a single field write, {@link beginInFlightAction}'s
|
|
69
77
|
* disposer only clears an id it still owns, and the only reader runs under the latch — so the
|
|
70
78
|
* worst a foreign concurrent refresh can see is a cleared field (it stops consuming), never a
|
|
71
79
|
* field it should not have consumed. */
|
|
72
80
|
inFlightActionId;
|
|
81
|
+
/** The most recent FAILED attempt made under {@link inFlightActionId} — exactly what it sent,
|
|
82
|
+
* kept so a refresh that finds that attempt's own log entry can finish the action instead of
|
|
83
|
+
* assuming it is finished (see {@link completeOwnEntry}). Set by {@link retainInFlightAttempt},
|
|
84
|
+
* cleared with the mark by {@link beginInFlightAction}'s disposer, and meaningless without it.
|
|
85
|
+
*
|
|
86
|
+
* The transforms are retained VERBATIM, not rebuilt: a rebuilt attempt re-appends the log entry
|
|
87
|
+
* with a fresh timestamp, so its log tail would differ byte-for-byte from the one already
|
|
88
|
+
* stored under the same action id and revision — and any replica that had not yet stored the
|
|
89
|
+
* tail would then store the second version, leaving two contents under one `(action, revision)`.
|
|
90
|
+
*
|
|
91
|
+
* Only the latest attempt is kept. An own entry can only be visible at the revision of an
|
|
92
|
+
* attempt whose tail landed, and a later attempt at a DIFFERENT revision is only made after a
|
|
93
|
+
* refresh adopted somebody else's entry at the earlier one — which is proof the earlier tail did
|
|
94
|
+
* not land. {@link completeOwnEntry} still checks the revision and refuses on a mismatch. */
|
|
95
|
+
inFlightAttempt;
|
|
96
|
+
/** The log tail block id the most recent header read named. A refresh asks for this block in the
|
|
97
|
+
* same request as the header ({@link readLogEnds}): the tail id only changes when the tail block
|
|
98
|
+
* fills, so an idle refresh is one request rather than two. A stale value costs one extra request
|
|
99
|
+
* and nothing else. */
|
|
100
|
+
logTailId;
|
|
73
101
|
constructor(id, transactor, handlers, source,
|
|
74
102
|
/** Cache of unmodified blocks from the source */
|
|
75
103
|
sourceCache,
|
|
76
104
|
/** Tracked Changes */
|
|
77
|
-
tracker,
|
|
105
|
+
tracker,
|
|
106
|
+
/** What each block named by a walked log entry must be at least as new as — raised by
|
|
107
|
+
* {@link updateInternal}, and shared with every read source this handle builds (see
|
|
108
|
+
* {@link BlockFloors}). */
|
|
109
|
+
floors, filterConflict,
|
|
78
110
|
/** Short random tag naming THIS instance (see {@link newInstanceTag}). Open paths generate
|
|
79
111
|
* it BEFORE construction (so pre-construction diagnostics such as attachToLog can carry it);
|
|
80
112
|
* the default covers direct construction in tests. */
|
|
@@ -85,6 +117,7 @@ export class Collection {
|
|
|
85
117
|
this.source = source;
|
|
86
118
|
this.sourceCache = sourceCache;
|
|
87
119
|
this.tracker = tracker;
|
|
120
|
+
this.floors = floors;
|
|
88
121
|
this.filterConflict = filterConflict;
|
|
89
122
|
this.instanceTag = instanceTag;
|
|
90
123
|
// Instance-scoped, deliberately NOT shared across instances of one collection id. The
|
|
@@ -119,17 +152,20 @@ export class Collection {
|
|
|
119
152
|
* would instead stage a fresh empty collection, and reads through it would report an
|
|
120
153
|
* absent dataset as a legitimately empty one. */
|
|
121
154
|
static async open(transactor, id, init) {
|
|
122
|
-
|
|
155
|
+
// Generated BEFORE anything reads, so every diagnostic of this handle — the floors the probe
|
|
156
|
+
// wires up and the log-attach-time lines included — names the instance the same way
|
|
157
|
+
// post-construction ones do.
|
|
158
|
+
const instanceTag = Collection.newInstanceTag();
|
|
159
|
+
const { source, sourceCache, tracker, floors, header } = await Collection.probeHeader(transactor, id, instanceTag);
|
|
123
160
|
if (!header) {
|
|
124
161
|
// Return before anything is staged: the tracker's transforms stay empty, so a caller
|
|
125
162
|
// that ignores the undefined cannot later sync a phantom collection into existence.
|
|
126
163
|
return undefined;
|
|
127
164
|
}
|
|
128
|
-
// Generated BEFORE attachToLog so log-attach-time diagnostics can name the instance
|
|
129
|
-
// the same way post-construction ones do.
|
|
130
|
-
const instanceTag = Collection.newInstanceTag();
|
|
131
165
|
await Collection.attachToLog(source, transactor, tracker, id, instanceTag, header);
|
|
132
|
-
|
|
166
|
+
const collection = new Collection(id, transactor, init.modules, source, sourceCache, tracker, floors, init.filterConflict, instanceTag);
|
|
167
|
+
collection.logTailId = header.tailId;
|
|
168
|
+
return collection;
|
|
133
169
|
}
|
|
134
170
|
/** Open an existing collection, or stage a fresh empty one in the local tracker when the
|
|
135
171
|
* header is authoritatively absent. Nothing is written to storage until {@link sync}.
|
|
@@ -138,9 +174,9 @@ export class Collection {
|
|
|
138
174
|
* bootstrap path. The create branch logs `collection:invented`; prefer {@link open} on
|
|
139
175
|
* any pure read path. */
|
|
140
176
|
static async createOrOpen(transactor, id, init) {
|
|
141
|
-
const { source, sourceCache, tracker, header } = await Collection.probeHeader(transactor, id);
|
|
142
177
|
// Pre-construction for the same reason as in open(): see the comment there.
|
|
143
178
|
const instanceTag = Collection.newInstanceTag();
|
|
179
|
+
const { source, sourceCache, tracker, floors, header } = await Collection.probeHeader(transactor, id, instanceTag);
|
|
144
180
|
if (header) { // Collection already exists
|
|
145
181
|
await Collection.attachToLog(source, transactor, tracker, id, instanceTag, header);
|
|
146
182
|
}
|
|
@@ -151,20 +187,34 @@ export class Collection {
|
|
|
151
187
|
source.actionContext = undefined;
|
|
152
188
|
await Log.open(tracker, id);
|
|
153
189
|
}
|
|
154
|
-
|
|
190
|
+
const collection = new Collection(id, transactor, init.modules, source, sourceCache, tracker, floors, init.filterConflict, instanceTag);
|
|
191
|
+
collection.logTailId = header?.tailId;
|
|
192
|
+
return collection;
|
|
155
193
|
}
|
|
156
194
|
/** The per-instance read wiring every open path needs, plus the header probe result.
|
|
157
195
|
* Shared by {@link open} and {@link createOrOpen} so the two cannot drift. */
|
|
158
|
-
static async probeHeader(transactor, id) {
|
|
196
|
+
static async probeHeader(transactor, id, instanceTag) {
|
|
159
197
|
// Start with a context that has an infinite revision number to ensure that we always fetch the latest log information.
|
|
160
198
|
// One shared read-dependency collector feeds both the source (direct structural reads) and the cache (every
|
|
161
199
|
// cache hit/miss), so a block read from either layer records a dependency — cache hits included.
|
|
162
200
|
const collector = new ReadDependencyCollector();
|
|
163
|
-
const
|
|
201
|
+
const floors = Collection.newFloors(id, instanceTag);
|
|
202
|
+
const source = new TransactorSource(id, transactor, undefined, collector, floors);
|
|
164
203
|
const sourceCache = new CacheSource(source, undefined, collector);
|
|
165
204
|
const tracker = new Tracker(sourceCache);
|
|
166
205
|
const header = await source.tryGet(id);
|
|
167
|
-
return { source, sourceCache, tracker, header };
|
|
206
|
+
return { source, sourceCache, tracker, floors, header };
|
|
207
|
+
}
|
|
208
|
+
/** A new handle's floors: none yet (opening walks no entries), wired to report every below-floor
|
|
209
|
+
* answer any of the handle's read sources receives. The line is the only trace such an answer
|
|
210
|
+
* leaves — the read itself succeeds (see the accepted-tradeoff NOTE at
|
|
211
|
+
* `TransactorSource.mayRetain`) — so repeated lines for one block with `servedRev` short of
|
|
212
|
+
* `floorRev` are how an operator sees a machine that has not caught up, and lines that never
|
|
213
|
+
* stop are how they see a log entry whose blocks never landed. */
|
|
214
|
+
static newFloors(id, instanceTag) {
|
|
215
|
+
return new BlockFloors(({ blockId, floor, servedRev }) => {
|
|
216
|
+
log('collection:block-below-floor id=%s tag=%s block=%s floorRev=%d floorAction=%s servedRev=%d', id, instanceTag, blockId, floor.rev, floor.actionId, servedRev);
|
|
217
|
+
});
|
|
168
218
|
}
|
|
169
219
|
/** Walk an existing collection's log and point the source at its latest action context.
|
|
170
220
|
* A header we just probed successfully but whose log will not open is a fault, not an
|
|
@@ -176,7 +226,7 @@ export class Collection {
|
|
|
176
226
|
instanceTag, header) {
|
|
177
227
|
// Bootstrap ActionContext from the committed tail before walking the chain.
|
|
178
228
|
// This allows the transactor to serve pending non-tail blocks during Log.open.
|
|
179
|
-
|
|
229
|
+
Collection.bootstrapContext(source, header.tailId === undefined ? undefined : await Collection.readLogTail(transactor, header.tailId));
|
|
180
230
|
const collectionLog = await Log.open(tracker, id);
|
|
181
231
|
if (!collectionLog) {
|
|
182
232
|
throw new Error(`Log not found for collection ${id}`);
|
|
@@ -366,14 +416,213 @@ export class Collection {
|
|
|
366
416
|
async update() {
|
|
367
417
|
const release = await Latches.acquire(this.latchId);
|
|
368
418
|
try {
|
|
369
|
-
await this.updateInternal();
|
|
419
|
+
await this.updateInternal({});
|
|
370
420
|
}
|
|
371
421
|
finally {
|
|
372
422
|
release();
|
|
373
423
|
}
|
|
374
424
|
}
|
|
375
|
-
/**
|
|
376
|
-
*
|
|
425
|
+
/** The refresh `TransactionCoordinator.commit` runs between attempts: exactly {@link update},
|
|
426
|
+
* plus a report of whether it finished the write in flight on this instance's behalf (see
|
|
427
|
+
* {@link RefreshReport}). The coordinator needs that fact to tell its caller which participants
|
|
428
|
+
* are already saved when the commit later fails; a reader's `update()` has no write in flight
|
|
429
|
+
* and nothing to report.
|
|
430
|
+
*
|
|
431
|
+
* `report` is REQUIRED so a write path cannot refresh without learning what the refresh saved.
|
|
432
|
+
* It is filled in as the refresh goes, so it is accurate when this throws too.
|
|
433
|
+
*
|
|
434
|
+
* `lastChance` is REQUIRED for the same reason: only the caller knows whether its retry budget
|
|
435
|
+
* ends with this round, and a round that is the last must settle a half-landed write rather than
|
|
436
|
+
* ask for another (see {@link completeOwnEntry}). */
|
|
437
|
+
async refreshInFlight(report, lastChance) {
|
|
438
|
+
const release = await Latches.acquire(this.latchId);
|
|
439
|
+
try {
|
|
440
|
+
await this.updateInternal(report, lastChance);
|
|
441
|
+
}
|
|
442
|
+
finally {
|
|
443
|
+
release();
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
/** Finishes a half-landed write BEFORE its own log entry is consumed, so that consuming never
|
|
447
|
+
* reports a write as saved on the strength of its log entry alone.
|
|
448
|
+
*
|
|
449
|
+
* THE RULE: a write may be reported saved only if EVERY block its log entry names holds the
|
|
450
|
+
* write — at the write's own revision, or at a later revision that was BUILT FROM it. Finding the
|
|
451
|
+
* entry proves only that the log tail landed (see {@link inFlightActionId} for the two ways
|
|
452
|
+
* `NetworkTransactor.commit` answers failure over a stored tail). The writer's cancel has since
|
|
453
|
+
* removed the pending records of every block that did not land, so nothing else will ever land
|
|
454
|
+
* them — if this does not, the entry stands in the log, the blocks stay at their previous
|
|
455
|
+
* revision on every node, and readers materialize blocks, not log entries: the write is
|
|
456
|
+
* silently gone.
|
|
457
|
+
*
|
|
458
|
+
* Finishing is a plain re-send of the retained failed attempt ({@link inFlightAttempt}) — the
|
|
459
|
+
* SAME transforms, action id, revision and tail. Every storage tier treats a block that already
|
|
460
|
+
* holds exactly this action at exactly this revision as satisfied rather than as a rival
|
|
461
|
+
* (`isOwnRevision`: `StorageRepo.pend`/`.commit`, `ClusterMember`, `CoordinatorRepo`), so the
|
|
462
|
+
* re-send rolls forward precisely the blocks that are missing and is a no-op for the rest,
|
|
463
|
+
* including when nothing is missing at all. It is never sent at a new revision: the refresh has
|
|
464
|
+
* already seen the entry at this one, and a second revision would record the entry twice.
|
|
465
|
+
*
|
|
466
|
+
* A refused re-send is NOT yet an answer. Storage refuses it whenever any block has moved past
|
|
467
|
+
* the write's revision — and every later commit to the collection moves the log tail past it —
|
|
468
|
+
* which says a rival was there, not whether the rival built on this write or over it. That is
|
|
469
|
+
* asked separately, of the blocks' own history ({@link settleUnfinished}).
|
|
470
|
+
*
|
|
471
|
+
* Runs at the top of {@link updateInternal}, before that method has changed anything on this
|
|
472
|
+
* instance, so every throw from here leaves the collection exactly as the failed attempt left
|
|
473
|
+
* it — staged actions and transforms intact, revision not advanced.
|
|
474
|
+
*
|
|
475
|
+
* @param lastChance - the caller will not refresh again for this write (its retry budget ends
|
|
476
|
+
* with this round), so a refusal that could clear is settled now instead of asking for another
|
|
477
|
+
* round: the error that escapes then says whether the write can still land.
|
|
478
|
+
* @returns who holds the finished write, for the sync to report.
|
|
479
|
+
* @throws TornActionError — see {@link TornActionReason} for the three causes. Only an unsettled
|
|
480
|
+
* `completion-refused` (always `final: false`) is retried by the write paths.
|
|
481
|
+
*
|
|
482
|
+
* NOTE: the re-send costs a full pend and commit round even when every block had in fact landed
|
|
483
|
+
* (a lost or masked success). That is deliberate — it is the source of the durability the sync
|
|
484
|
+
* reports when nothing has superseded the write — and the case is rare: after a returned failure
|
|
485
|
+
* the network transactor has, by construction, NOT swept every block. If own-entry refreshes ever
|
|
486
|
+
* show up as a cost, ask `getLineage` first and skip the re-send when every block contains the
|
|
487
|
+
* write. */
|
|
488
|
+
async completeOwnEntry(entry, entryRev, lastChance) {
|
|
489
|
+
const attempt = this.inFlightAttempt;
|
|
490
|
+
const rev = entryRev ?? attempt?.rev;
|
|
491
|
+
if (attempt === undefined || rev === undefined || attempt.rev !== rev) {
|
|
492
|
+
// Nothing to finish the action WITH. That is only acceptable if there is nothing to
|
|
493
|
+
// finish. `getStatus` is the cheap first question; it judges a block by who holds its
|
|
494
|
+
// LATEST revision, so a block this action landed and a later action has since built on
|
|
495
|
+
// reads there as not committed — those are asked again, properly, by settleUnfinished.
|
|
496
|
+
// Both write paths retain an attempt before any refresh can run, so production only
|
|
497
|
+
// reaches this branch when the entry sits at a revision the retained attempt was not
|
|
498
|
+
// made at — a forked lineage.
|
|
499
|
+
const [status] = await this.transactor.getStatus([{ actionId: entry.actionId, blockIds: entry.blockIds }]);
|
|
500
|
+
if (entry.blockIds.every((_, i) => status?.statuses[i] === 'committed')) {
|
|
501
|
+
// Whole, and saved: the refresh reports it as finished (see RefreshReport), so the
|
|
502
|
+
// coordinator counts this participant as committed. Only WHO holds it is unknown.
|
|
503
|
+
// NOTE: a sync whose only commit was recognised here therefore answers `undefined` —
|
|
504
|
+
// the "nothing was written" answer — for a write that is saved, because the sync reads
|
|
505
|
+
// the report's durability, not the finished flag. Reachable only on the forked-lineage
|
|
506
|
+
// path above; if that path ever becomes ordinary, take the durability from
|
|
507
|
+
// `getLineage` (as settleUnfinished does) instead of stopping at the status read.
|
|
508
|
+
return undefined;
|
|
509
|
+
}
|
|
510
|
+
return await this.settleUnfinished(entry, rev, undefined, 'transforms-not-held', attempt === undefined
|
|
511
|
+
? 'no failed attempt is retained for this action'
|
|
512
|
+
: `the retained attempt was made at rev ${attempt.rev}`);
|
|
513
|
+
}
|
|
514
|
+
// NOTE: priority 0. The attempt's aged retry priority is a fairness hint for a race over a
|
|
515
|
+
// free revision; this revision is already this action's own, so there is no race to rank in.
|
|
516
|
+
// NOTE: this is a plain pend. When the failed attempt came from `TransactionCoordinator`, its
|
|
517
|
+
// pend carried `validation` (the transaction and its operations hash) and
|
|
518
|
+
// `superclusterNominees`; neither is retained, so the re-send carries neither. Harmless while
|
|
519
|
+
// no deployment hands members a transaction validator (and nothing reads the nominees on
|
|
520
|
+
// the receiving side at all). Once a validator is wired, members approve these
|
|
521
|
+
// blocks unchecked under `unvalidatablePendPolicy: 'accept'` and refuse them under `'reject'`
|
|
522
|
+
// (surfacing as a `completion-refused` TornActionError). Simply retaining and re-sending the
|
|
523
|
+
// pair is not obviously right either: a member re-executing the transaction after a sibling
|
|
524
|
+
// participant has landed no longer sees the state it was staged against. Tracked as an arm
|
|
525
|
+
// of tickets/backlog/feat-no-deployment-validates-transactions-at-pend.
|
|
526
|
+
const result = await this.source.transact(attempt.transforms, entry.actionId, rev, this.id, attempt.tailId, 0, attempt.blockDigests, attempt.baseRevs);
|
|
527
|
+
if (result.success) {
|
|
528
|
+
return result.durability;
|
|
529
|
+
}
|
|
530
|
+
// A refusal that CONFIRMS a committed revision under another action — `staleAt`, which every
|
|
531
|
+
// producer sets only after reading it out of its own storage and never for this action's own
|
|
532
|
+
// revision, or a non-empty `missing` list of committed rival transforms — will be repeated by
|
|
533
|
+
// every later re-send. Anything else (a rival merely PENDING on a block, a revision not yet
|
|
534
|
+
// held by a majority, a bare reason) can clear, and is worth the caller's next round.
|
|
535
|
+
// NOTE: `staleAt` is read here as "a rival committed", which is a second consumer of a field
|
|
536
|
+
// documented as never a retryability signal (docs/internals.md). It is the same kind of use
|
|
537
|
+
// `syncAttempts`' stall check makes: it can only END a retry, never start one.
|
|
538
|
+
const rivalConfirmed = result.staleAt !== undefined || (result.missing?.length ?? 0) > 0;
|
|
539
|
+
// NOTE: a confirmed rival settles at once, even with retry budget left: re-sending is
|
|
540
|
+
// pointless, but the lineage question is asked only this once, so a member that is silent
|
|
541
|
+
// just now makes the answer `final: false`. If unsettled outcomes ever show up often under
|
|
542
|
+
// contention, spend the remaining rounds re-asking `getLineage` before giving up.
|
|
543
|
+
if (!rivalConfirmed && !lastChance) {
|
|
544
|
+
// The refusal does not say which blocks lack the revision, so name every block the entry
|
|
545
|
+
// lists other than the tail — the entry being visible is what proves the tail holds it.
|
|
546
|
+
return this.throwTorn(entry, rev, entry.blockIds.filter(blockId => blockId !== attempt.tailId), 'completion-refused', false, result.reason ?? 'the re-send was refused');
|
|
547
|
+
}
|
|
548
|
+
return await this.settleUnfinished(entry, rev, attempt.tailId, rivalConfirmed ? 'rival-holds-revision' : 'completion-refused', result.reason ?? (rivalConfirmed ? 'a different action holds a later revision' : 'the re-send was refused'), result.staleAt);
|
|
549
|
+
}
|
|
550
|
+
/** Decides what a write that can no longer be finished BY RE-SENDING amounts to, and answers
|
|
551
|
+
* one of exactly three things — never a guess between them:
|
|
552
|
+
*
|
|
553
|
+
* - SAVED (returns): every block the entry names holds content built from the write, on a
|
|
554
|
+
* strict majority of its cohort (`ITransactor.getLineage`). The rival that refused the re-send
|
|
555
|
+
* had read this write and added to it. Returns who holds it.
|
|
556
|
+
* - TORN AND FINAL (throws, `final: true`): every block still missing the write answered, for its
|
|
557
|
+
* whole cohort, that it does not hold it — and the write's pending records were confirmed gone
|
|
558
|
+
* BEFORE the blocks were asked. The order is the point: a pending record left standing can be
|
|
559
|
+
* promoted by any later read that knows this write's log entry is committed
|
|
560
|
+
* (`StorageRepo.get`), so a block that answered "not reached" could still take the write
|
|
561
|
+
* afterwards. With the records gone first, nothing is left that could land it.
|
|
562
|
+
* - TORN, OUTCOME NOT ESTABLISHED (throws, `final: false`): anything else — the transactor
|
|
563
|
+
* cannot answer for lineage, a cohort did not all answer or contradicted itself, fewer than a
|
|
564
|
+
* majority hold the write, or the cancel could not be confirmed.
|
|
565
|
+
*
|
|
566
|
+
* `tailId` is the log block this write's entry was appended to, when known. It is never asked
|
|
567
|
+
* about: the refresh has just READ the entry out of it, which is direct evidence that the
|
|
568
|
+
* block's current content was built from the write, whatever any member's records can prove.
|
|
569
|
+
*
|
|
570
|
+
* NOTE: a write recognised as saved here reports the durability `getLineage` assembled. When
|
|
571
|
+
* the log block's own cohort could not vouch for it (members that took it as a replica), that is
|
|
572
|
+
* absent and the sync answers `undefined` for a saved write — the same wart the status-read
|
|
573
|
+
* branch of {@link completeOwnEntry} documents. If it is ever seen, fold the data blocks'
|
|
574
|
+
* reports alone rather than inventing one for the log block. */
|
|
575
|
+
async settleUnfinished(entry, rev, tailId, reason, detail, staleAt) {
|
|
576
|
+
const discharged = await this.dischargeOwnPendings(entry);
|
|
577
|
+
const lineage = rev === undefined ? undefined : await this.lineageOfOwnEntry(entry, rev);
|
|
578
|
+
const unsaved = entry.blockIds.filter((blockId, i) => blockId !== tailId && lineage?.blocks[i] !== 'contains');
|
|
579
|
+
if (lineage !== undefined && unsaved.length === 0) {
|
|
580
|
+
log('collection:own-entry-superseded-but-saved id=%s tag=%s action=%s rev=%d blocks=%d', this.id, this.instanceTag, entry.actionId, rev, entry.blockIds.length);
|
|
581
|
+
return lineage.durability;
|
|
582
|
+
}
|
|
583
|
+
const cannotLand = lineage !== undefined && unsaved.every(blockId => {
|
|
584
|
+
const answer = lineage.blocks[entry.blockIds.indexOf(blockId)];
|
|
585
|
+
return answer === 'excludes' || answer === 'behind';
|
|
586
|
+
});
|
|
587
|
+
return this.throwTorn(entry, rev ?? -1, unsaved, reason, discharged && cannotLand, detail, staleAt);
|
|
588
|
+
}
|
|
589
|
+
/** Cancels every pending record this write may have left, and says whether that is CONFIRMED.
|
|
590
|
+
* `ITransactor.cancel` returns only once the records are gone and throws otherwise; a throw here
|
|
591
|
+
* is reported as "not confirmed" rather than raised, because the caller is already reporting a
|
|
592
|
+
* torn write and that must not be displaced. Cancelling a block that holds no record is a no-op,
|
|
593
|
+
* so naming every block the entry lists is safe. */
|
|
594
|
+
async dischargeOwnPendings(entry) {
|
|
595
|
+
try {
|
|
596
|
+
await this.transactor.cancel({ actionId: entry.actionId, blockIds: entry.blockIds });
|
|
597
|
+
return true;
|
|
598
|
+
}
|
|
599
|
+
catch (err) {
|
|
600
|
+
log('collection:torn-cancel-unconfirmed id=%s tag=%s action=%s error=%s', this.id, this.instanceTag, entry.actionId, err instanceof Error ? err.message : String(err));
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
/** What the blocks' own history says about this write, or `undefined` when nothing can say: the
|
|
605
|
+
* transactor (or a wrapper around it) does not offer `getLineage`, or the question failed. */
|
|
606
|
+
async lineageOfOwnEntry(entry, rev) {
|
|
607
|
+
if (this.transactor.getLineage === undefined) {
|
|
608
|
+
return undefined;
|
|
609
|
+
}
|
|
610
|
+
try {
|
|
611
|
+
return await this.transactor.getLineage({ actionId: entry.actionId, blockIds: entry.blockIds, rev });
|
|
612
|
+
}
|
|
613
|
+
catch (err) {
|
|
614
|
+
log('collection:lineage-unanswered id=%s tag=%s action=%s rev=%d error=%s', this.id, this.instanceTag, entry.actionId, rev, err instanceof Error ? err.message : String(err));
|
|
615
|
+
return undefined;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
throwTorn(entry, rev, blockIds, reason, final, detail, staleAt) {
|
|
619
|
+
log('collection:torn id=%s tag=%s action=%s rev=%d reason=%s final=%s blocks=%o', this.id, this.instanceTag, entry.actionId, rev, reason, final, blockIds);
|
|
620
|
+
throw new TornActionError(this.id, entry.actionId, rev, blockIds, reason, final, detail, staleAt);
|
|
621
|
+
}
|
|
622
|
+
/** Drops the pending actions this sync's OWN committed entry made durable, instead of replaying
|
|
623
|
+
* them into a duplicate entry (see {@link inFlightActionId}). Only ever called once
|
|
624
|
+
* {@link completeOwnEntry} has returned for this entry — the entry alone is not proof the write
|
|
625
|
+
* is saved.
|
|
377
626
|
*
|
|
378
627
|
* `addActions` wrote exactly the snapshot pending list under this action id, and the entry's
|
|
379
628
|
* actions are therefore the LEADING `entry.actions.length` items of `this.pending` — anything
|
|
@@ -420,55 +669,100 @@ export class Collection {
|
|
|
420
669
|
*
|
|
421
670
|
* Takes no in-flight action id — it reads {@link inFlightActionId} off `this`, which is set for
|
|
422
671
|
* exactly the write attempt cycles that own one (see that field). Callers cannot get this wrong
|
|
423
|
-
* by omission.
|
|
424
|
-
|
|
672
|
+
* by omission.
|
|
673
|
+
*
|
|
674
|
+
* @param report - Filled in as the refresh goes (see {@link RefreshReport}): its
|
|
675
|
+
* `ownEntryFinished` is set the moment the refresh has found this write's own log entry and
|
|
676
|
+
* finished it ({@link completeOwnEntry}) — before the entry is consumed, so a later throw from
|
|
677
|
+
* here still leaves it set. Never set on a reader's refresh. A caller with no use for it passes `{}`.
|
|
678
|
+
* @param lastChance - Whether the write in flight will get no further refresh (see
|
|
679
|
+
* {@link completeOwnEntry}). Meaningless, and left false, on a reader's refresh.
|
|
680
|
+
* @throws TornActionError when it found that entry and could not finish the action — thrown
|
|
681
|
+
* before anything on this instance changed, and with `report` untouched. */
|
|
682
|
+
async updateInternal(report, lastChance = false) {
|
|
425
683
|
// Start with a context that can see to the end of the log
|
|
426
684
|
const source = new TransactorSource(this.id, this.transactor, undefined);
|
|
427
|
-
const tracker = new Tracker(source);
|
|
428
|
-
// Bootstrap context from committed tail so pending blocks are accessible.
|
|
429
|
-
// Read through tracker so Chain.open inside Log.open reuses the cached header.
|
|
430
685
|
// A header the storage layer could not retrieve throws BlockUnavailableError out of
|
|
431
686
|
// this read (it is not a StaleFailure, so sync's retry loop does not absorb it).
|
|
432
|
-
const
|
|
433
|
-
if (
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
687
|
+
const ends = await Collection.readLogEnds(this.transactor, this.id, this.logTailId);
|
|
688
|
+
if (!ends) {
|
|
689
|
+
if (this.source.actionContext) {
|
|
690
|
+
// An absent header is only believable for a collection that has never committed.
|
|
691
|
+
// We hold a committed revision, so the two answers contradict each other — surface it
|
|
692
|
+
// as a fault instead of no-opping into a forgotten revision and a rev-1 retry spin.
|
|
693
|
+
// NOTE: this aborts every caller of update(), including TransactionCoordinator's
|
|
694
|
+
// blanket refresh of ALL registered collections between commit retries — a
|
|
695
|
+
// non-participant with a momentarily-absent header now fails the whole retry rather
|
|
696
|
+
// than being skipped (the coordinator still refreshes the remaining collections first,
|
|
697
|
+
// so a participant that can be finished is). That is the intended loud failure; if it ever shows up as
|
|
698
|
+
// otherwise-healthy transactions aborting, narrow that refresh to the transaction's
|
|
699
|
+
// participants (see the note at coordinator.ts's update loop) rather than softening
|
|
700
|
+
// this throw.
|
|
701
|
+
throw new CollectionHeaderVanishedError(this.id, this.source.actionContext.rev);
|
|
702
|
+
}
|
|
703
|
+
// The header is genuinely absent AND we hold no revision: nothing was ever committed under
|
|
704
|
+
// this id, so there is no log to walk and nothing to adopt — correct here, rather than a
|
|
705
|
+
// masked failure.
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
this.logTailId = ends.header.tailId;
|
|
709
|
+
// Bootstrap context from committed tail so pending blocks are accessible.
|
|
710
|
+
Collection.bootstrapContext(source, ends.tail);
|
|
452
711
|
// The revision the committed tail just claimed, captured before anything else can touch
|
|
453
712
|
// the local source. This is the authoritative "latest committed under this id" number,
|
|
454
713
|
// read straight off the tail block's state; the chain walk below arrives at its own
|
|
455
714
|
// number by a different path, and the two disagreeing is worth saying out loud (see the
|
|
456
|
-
// {@link reportShortfall} call after advanceContext). Stays undefined when there is no
|
|
457
|
-
// tail, or a tail with no `latest` —
|
|
715
|
+
// {@link reportShortfall} call after advanceContext). Stays undefined when there is no
|
|
716
|
+
// tail, or a tail with no `latest` — both legitimate "nothing committed yet" states.
|
|
458
717
|
const tailRev = source.actionContext?.rev;
|
|
459
|
-
// Get the latest entries from the log, starting from where we left off
|
|
460
718
|
const actionContext = this.source.actionContext;
|
|
719
|
+
// A write's retry refresh always walks. Not needed for soundness — the write's own entry would
|
|
720
|
+
// sit above the held revision, which the tail test already refuses — but losing that entry
|
|
721
|
+
// loses the write, and a retry refresh is rare enough that the walk costs nothing that matters.
|
|
722
|
+
if (this.inFlightActionId === undefined && Collection.tailShowsNothingNewer(actionContext, ends.tail)) {
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
// One block cache for the whole refresh, seeded with the header and tail just read: Log.open,
|
|
726
|
+
// the entry walk and the invalidation walk each start again from the header and the tail, and
|
|
727
|
+
// would otherwise fetch both every time. The seed came from UNPINNED reads and is served to
|
|
728
|
+
// reads pinned at the tail's claim, which is sound: the tail's content at its own claimed
|
|
729
|
+
// revision is that pinned view, and the header changes only when the tail block fills, which
|
|
730
|
+
// Chain.getTail already tolerates by following `nextId` from whichever tail the header names.
|
|
731
|
+
// NOTE: the cache keeps its default size (128 blocks, about 4,000 log entries). The entry walk
|
|
732
|
+
// reads every log block back to the head (no checkpoints), newest first, so past that size
|
|
733
|
+
// the newest blocks are evicted first and the invalidation walk fetches them again. If logs
|
|
734
|
+
// get that long before checkpoints land, walk entries and invalidations in one pass.
|
|
735
|
+
const tracker = new Tracker(new CacheSource(source, undefined, undefined, ends.served));
|
|
736
|
+
// Get the latest entries from the log, starting from where we left off
|
|
461
737
|
const collectionLog = await Log.open(tracker, this.id);
|
|
462
738
|
const latest = collectionLog ? await collectionLog.getFrom(actionContext?.rev ?? 0) : undefined;
|
|
739
|
+
// This write's own entry, if its log tail landed despite the failure that sent us here.
|
|
740
|
+
// Decided ONCE, here: the mark can be cleared latch-free while the completion below is
|
|
741
|
+
// awaiting (see {@link inFlightActionId}), and an entry that was finished as our own must
|
|
742
|
+
// not then be run through the conflict filter as a stranger's and replayed.
|
|
743
|
+
const inFlightActionId = this.inFlightActionId;
|
|
744
|
+
const ownEntry = inFlightActionId === undefined
|
|
745
|
+
? undefined
|
|
746
|
+
: latest?.entries.find(entry => entry.actionId === inFlightActionId);
|
|
747
|
+
// Finish it BEFORE anything below changes this instance — see {@link completeOwnEntry}. A
|
|
748
|
+
// throw from here (the action cannot be finished, or not yet) therefore abandons the refresh
|
|
749
|
+
// with the staged actions, the tracker and the held revision exactly as they were.
|
|
750
|
+
// The entry's revision comes from the context the same walk built; an entry older than a
|
|
751
|
+
// checkpoint is not restated there, and completeOwnEntry falls back to the attempt's own.
|
|
752
|
+
const entryRevs = Collection.revisionsByAction(latest?.context);
|
|
753
|
+
if (ownEntry !== undefined) {
|
|
754
|
+
const durability = await this.completeOwnEntry(ownEntry, entryRevs.get(ownEntry.actionId), lastChance);
|
|
755
|
+
// Saved from here on, whatever below throws — record it before anything else can.
|
|
756
|
+
report.ownEntryFinished = { durability };
|
|
757
|
+
}
|
|
463
758
|
// Process the entries and track the blocks they affect
|
|
464
759
|
let anyConflicts = false;
|
|
465
760
|
for (const entry of latest?.entries ?? []) {
|
|
466
|
-
const isOwnEntry =
|
|
761
|
+
const isOwnEntry = entry === ownEntry;
|
|
467
762
|
const { after, mutated } = isOwnEntry
|
|
468
763
|
? this.consumeOwnEntry(entry)
|
|
469
764
|
: this.filterAgainstEntry(entry);
|
|
470
765
|
this.pending = after;
|
|
471
|
-
this.sourceCache.clear(entry.blockIds);
|
|
472
766
|
anyConflicts = anyConflicts || mutated || this.tracker.conflicts(new Set(entry.blockIds)).length > 0;
|
|
473
767
|
}
|
|
474
768
|
// React to durable invalidations that landed since we last synced. getFrom intentionally skips
|
|
@@ -478,33 +772,212 @@ export class Collection {
|
|
|
478
772
|
// base (docs/right-is-right.md §Client notification). De-duped across cascade children by reverted
|
|
479
773
|
// block; over-inclusive by design (over-invalidation just resubmits — it never wrongly retains).
|
|
480
774
|
const invalidations = collectionLog ? await collectionLog.getInvalidationsFrom(actionContext?.rev ?? 0) : [];
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
if (this.pending.length > 0) {
|
|
485
|
-
anyConflicts = true;
|
|
486
|
-
}
|
|
775
|
+
const revertedBlockIds = [...new Set(invalidations.flatMap(inv => inv.reverted.map(r => r.blockId)))];
|
|
776
|
+
if (invalidations.length > 0 && this.pending.length > 0) {
|
|
777
|
+
anyConflicts = true;
|
|
487
778
|
}
|
|
488
|
-
|
|
489
|
-
// context at all, and a log read that lags what we already committed yields an older one;
|
|
490
|
-
// neither is grounds for forgetting the revision we hold. This must happen BEFORE
|
|
491
|
-
// replayActions below: replay re-reads blocks through this.source, which materializes
|
|
492
|
-
// content at this.actionContext.rev — if the cursor hasn't advanced yet, replay re-reads
|
|
493
|
-
// at the revision we're leaving and refills the cache with stale content that nothing
|
|
494
|
-
// will invalidate again (the log entry that would have cleared it was already consumed).
|
|
495
|
-
Collection.advanceContext(this.source, this.id, this.instanceTag, 'refresh', latest?.context);
|
|
779
|
+
this.forgetAndAdopt(latest, entryRevs, revertedBlockIds);
|
|
496
780
|
Collection.reportShortfall(this.id, this.instanceTag, tailRev, actionContext?.rev, this.source.actionContext?.rev);
|
|
497
|
-
//
|
|
781
|
+
// Re-stage the pending actions against the adopted revision. The affected
|
|
498
782
|
// blocks were already dropped from sourceCache above (per log entry / per invalidation),
|
|
499
783
|
// so the replay's reads re-materialize from the transactor.
|
|
500
784
|
// NOTE: a throw out of replayActions leaves the tracker holding only the transforms
|
|
501
785
|
// replayed so far while `pending` still lists them all; the caller's error handling is
|
|
502
|
-
// expected to abort/reset the collection rather than keep staging.
|
|
786
|
+
// expected to abort/reset the collection rather than keep staging. The coordinator's partial
|
|
787
|
+
// report after a refresh-saved sibling does not (backlog: debt-a-failed-refresh-can-leave-a-
|
|
788
|
+
// collection-half-restaged). If replay ever gains a
|
|
503
789
|
// routinely-throwing read path, rebuild into a scratch tracker and swap on success.
|
|
504
|
-
if (anyConflicts) {
|
|
790
|
+
if (this.mustReplay(anyConflicts, actionContext)) {
|
|
505
791
|
await this.replayActions();
|
|
506
792
|
}
|
|
507
793
|
}
|
|
794
|
+
/** Forget every block the refresh saw change, floor the ones a log entry names, and adopt the
|
|
795
|
+
* revision the log is at — ONE synchronous step, which must stay free of any `await`.
|
|
796
|
+
*
|
|
797
|
+
* Reads are not latched, so one can run while a refresh is under way. Forgetting a block while
|
|
798
|
+
* this handle still reads at the revision it is LEAVING invites exactly the wrong re-read: the
|
|
799
|
+
* block comes back as it was at that revision (correctly — that is what was asked for, and no
|
|
800
|
+
* floor applies to a read below it), the cache keeps it, and the advance that follows turns it
|
|
801
|
+
* into old content nothing will ever clear, the entry that named it having been consumed. That
|
|
802
|
+
* needs no lagging machine; storage can be perfectly current. With no gap between the forgetting
|
|
803
|
+
* and the adopting, a concurrent read either lands before both and is forgotten with the rest,
|
|
804
|
+
* or lands after both and is judged at the adopted revision — against the floor
|
|
805
|
+
* ({@link TransactorSource.tryGet} reads its context when the answer arrives, not when it was
|
|
806
|
+
* asked for) and against the generation the clear moved (`CacheSource.stillWanted`).
|
|
807
|
+
*
|
|
808
|
+
* The advance is monotonic (see {@link advanceContext}): an empty or unopenable log yields no
|
|
809
|
+
* context at all, and a log read that lags what this handle already committed yields an older
|
|
810
|
+
* one; neither is grounds for forgetting the revision held. It also has to precede
|
|
811
|
+
* {@link replayActions}, which re-reads blocks through `this.source` at whatever revision the
|
|
812
|
+
* context names — replaying first would refill the cache at the revision being left. */
|
|
813
|
+
forgetAndAdopt(latest, entryRevs, revertedBlockIds) {
|
|
814
|
+
for (const entry of latest?.entries ?? []) {
|
|
815
|
+
this.sourceCache.clear(entry.blockIds);
|
|
816
|
+
this.raiseFloors(entry, entryRevs.get(entry.actionId));
|
|
817
|
+
}
|
|
818
|
+
this.sourceCache.clear(revertedBlockIds);
|
|
819
|
+
Collection.advanceContext(this.source, this.id, this.instanceTag, 'refresh', latest?.context);
|
|
820
|
+
}
|
|
821
|
+
/** The revision each action in `context` committed at, keyed by action id. `Log.getFrom` returns
|
|
822
|
+
* entries without their revisions; the context the same walk built is where they are restated. */
|
|
823
|
+
static revisionsByAction(context) {
|
|
824
|
+
return new Map((context?.committed ?? []).map(({ actionId, rev }) => [actionId, rev]));
|
|
825
|
+
}
|
|
826
|
+
/** Records that the walked `entry` changed the blocks it names, so a later answer for one of
|
|
827
|
+
* them that is older than the entry is recognised and never remembered (see {@link BlockFloors}).
|
|
828
|
+
* Beside the cache clear on purpose (see {@link forgetAndAdopt}): the clear is what sends the next
|
|
829
|
+
* read of these blocks back to storage, and that read is the one a lagging machine can answer too
|
|
830
|
+
* old — after which nothing would clear the block again, this entry having been consumed.
|
|
831
|
+
*
|
|
832
|
+
* NOTE: an entry whose revision the walk did not restate sets no floor. That is an entry older
|
|
833
|
+
* than the log's most recent checkpoint, and no checkpoint is written today
|
|
834
|
+
* (tickets/backlog/debt-the-collection-log-never-writes-a-checkpoint). Once they are, such an
|
|
835
|
+
* entry's blocks go unguarded unless `Log.getFrom` starts returning each entry's revision. */
|
|
836
|
+
raiseFloors(entry, rev) {
|
|
837
|
+
if (rev !== undefined) {
|
|
838
|
+
this.floors.raise(entry.blockIds, { rev, actionId: entry.actionId });
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
/** Whether the log tail a refresh just read proves the log holds nothing newer than `held` —
|
|
842
|
+
* the test that lets a refresh with nothing to find stop after one request instead of walking
|
|
843
|
+
* the log.
|
|
844
|
+
*
|
|
845
|
+
* Sound because every commit and every invalidation appends a log entry, and entries only ever
|
|
846
|
+
* go on the tail block. A tail block that is still the end of the chain (no `nextId`), whose
|
|
847
|
+
* newest entry is at the held revision and names the action `held` names there — and whose own
|
|
848
|
+
* claim (`state.latest`) says the same — has nothing above `held`. The walk would then find no
|
|
849
|
+
* entries, no invalidations, and a context {@link advanceContext} adopts at an unchanged
|
|
850
|
+
* revision, so it would change nothing.
|
|
851
|
+
*
|
|
852
|
+
* Says "no" — sending the refresh down the full walk — whenever the tail and the held context
|
|
853
|
+
* disagree in any way, so everything that walk reports still fires:
|
|
854
|
+
* - a claim above `held` (ordinary catch-up) or below it (a lagging read);
|
|
855
|
+
* - a newest entry that is not the claimed action, or not an action at all: the entries lag the
|
|
856
|
+
* claim (`collection:context-short-of-tail`, or `collection:context-not-lowered` for a handle
|
|
857
|
+
* pinned by an over-claiming tail), or an invalidation or checkpoint took the newest slot;
|
|
858
|
+
* - any action entry in the tail block naming a different action than `held` names at the same
|
|
859
|
+
* revision (`collection:lineage-divergence`, and the walk's adoption of the log's list).
|
|
860
|
+
*
|
|
861
|
+
* NOTE: the lineage comparison sees only the entries the tail block holds. A fork below them, on
|
|
862
|
+
* a log that has not moved, is not looked at again until a refresh finds something new — that
|
|
863
|
+
* walk compares the whole list. Walking every time to look is exactly the cost this avoids.
|
|
864
|
+
*
|
|
865
|
+
* NOTE: an invalidation entry in the newest slot never matches (it names no action), so every
|
|
866
|
+
* refresh after one walks the log until the next commit lands. Fine while disputes are rare; if
|
|
867
|
+
* they are not, match an invalidation slot against the held revision too. */
|
|
868
|
+
static tailShowsNothingNewer(held, tail) {
|
|
869
|
+
const claim = tail?.state.latest;
|
|
870
|
+
const block = tail?.block;
|
|
871
|
+
if (held === undefined || claim === undefined || !block || block.nextId !== undefined) {
|
|
872
|
+
return false;
|
|
873
|
+
}
|
|
874
|
+
const newest = block.entries[block.entries.length - 1];
|
|
875
|
+
return claim.rev === held.rev
|
|
876
|
+
&& newest?.rev === claim.rev
|
|
877
|
+
&& newest.action?.actionId === claim.actionId
|
|
878
|
+
&& actionIdAt(held, held.rev) === claim.actionId
|
|
879
|
+
&& !Collection.disagreesWithHeld(held, block.entries);
|
|
880
|
+
}
|
|
881
|
+
/** Whether any action entry in `entries` names a different action than `held` names at the same
|
|
882
|
+
* revision. A revision only one side names is missing evidence, not disagreement — the rule
|
|
883
|
+
* {@link earliestFork} applies too. */
|
|
884
|
+
static disagreesWithHeld(held, entries) {
|
|
885
|
+
const logged = new Map(entries.flatMap(entry => entry.action ? [[entry.rev, entry.action.actionId]] : []));
|
|
886
|
+
return held.committed.some(entry => {
|
|
887
|
+
const loggedAction = logged.get(entry.rev);
|
|
888
|
+
return loggedAction !== undefined && loggedAction !== entry.actionId;
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
/** Whether {@link updateInternal} must re-stage `pending` after adopting `latest`, given the
|
|
892
|
+
* context it held BEFORE the refresh.
|
|
893
|
+
*
|
|
894
|
+
* The answer is deliberately NOT just "a conflict was found". A conflict is detected by an
|
|
895
|
+
* incoming log entry naming a block this tracker already holds a transform for — so an action
|
|
896
|
+
* that changed NO block can never register one. A staged delete of a key this instance cannot
|
|
897
|
+
* see is exactly that action: the tree's `replace` handler misses on `find` and `deleteAt`
|
|
898
|
+
* returns false without writing. Gated on conflicts alone, such an action stayed in `pending`
|
|
899
|
+
* unapplied until the commit wrote a log entry listing it whose transforms did nothing — and
|
|
900
|
+
* readers materialize blocks, not log entries, so the action was lost on every node, silently
|
|
901
|
+
* and permanently. The invariant that has to hold is that **a pending action was applied
|
|
902
|
+
* against the revision it commits over**, so a mere revision advance is reason enough.
|
|
903
|
+
*
|
|
904
|
+
* The advance test rests on {@link advanceContext} being monotonic (it refuses to lower), which
|
|
905
|
+
* is what makes "the adopted rev differs from the held one" mean "it went up".
|
|
906
|
+
*
|
|
907
|
+
* The second conjunct is `pending.length` and deliberately NOT {@link hasUnsyncedChanges},
|
|
908
|
+
* which also counts tracker transforms. A collection {@link createOrOpen} just INVENTED holds
|
|
909
|
+
* its staged header/root in the tracker with NO pending action naming them, and
|
|
910
|
+
* {@link replayActions} resets the tracker before re-staging — so counting transforms here
|
|
911
|
+
* would drop those blocks and leave a brand-new collection unreadable (the same hazard
|
|
912
|
+
* {@link snapshotPending} documents). Nothing to re-stage means nothing to replay.
|
|
913
|
+
*
|
|
914
|
+
* NOTE: this makes a refresh that adopts a newer revision O(pending) rather than free, so a
|
|
915
|
+
* read taken mid-transaction while a rival keeps committing re-stages every action staged so
|
|
916
|
+
* far, on each such read. Measured as no change to the storage-op budgets
|
|
917
|
+
* (`index-backfill-cost.spec.ts`, `cold-apply-cost.spec.ts`), because the replay's reads are
|
|
918
|
+
* served from `sourceCache`. If a long transaction's reads ever show up as slow, narrow the
|
|
919
|
+
* replay to the actions whose reads the adopted entries actually invalidated — NOT back to
|
|
920
|
+
* conflicts alone, which is the defect above. */
|
|
921
|
+
mustReplay(anyConflicts, priorContext) {
|
|
922
|
+
if (anyConflicts) {
|
|
923
|
+
return true;
|
|
924
|
+
}
|
|
925
|
+
const adoptedRev = this.source.actionContext?.rev;
|
|
926
|
+
if (adoptedRev !== undefined && adoptedRev !== priorContext?.rev && this.pending.length > 0) {
|
|
927
|
+
return true;
|
|
928
|
+
}
|
|
929
|
+
// Third reason: a staged block's base has MOVED — the read cache no longer describes it at
|
|
930
|
+
// the revision its operations were computed against (see `Tracker.movedBases`). A refresh
|
|
931
|
+
// that adopted nothing and found no conflict can still be standing over such a block: a
|
|
932
|
+
// concurrent read served content the cache then kept at a newer revision, say. Re-staging
|
|
933
|
+
// is the only repair; declaring the pinned revision on the pend would merely get it refused.
|
|
934
|
+
return this.pending.length > 0 && this.logMovedBases(this.tracker.movedBases()) > 0;
|
|
935
|
+
}
|
|
936
|
+
/** Re-judge every staged update's pinned base against the read cache and re-stage the pending
|
|
937
|
+
* queue if any has moved. Runs immediately before each pend attempt — {@link syncAttempts} and
|
|
938
|
+
* the coordinator's commit span both call it — because that is the last moment before the
|
|
939
|
+
* base the pend declares is put on the wire, and nothing between a refused attempt and the
|
|
940
|
+
* re-pend reads the block again (the refresh may have moved nothing and so replayed nothing).
|
|
941
|
+
*
|
|
942
|
+
* Two steps. First, one read for each pinned block the cache does NOT retain — content handed
|
|
943
|
+
* through unkept (a below-floor answer, which the cache re-asks for on every read anyway): the
|
|
944
|
+
* pin names the revision last served, storage may have caught up since without the log moving,
|
|
945
|
+
* and only a read can tell. An evicted-but-kept base needs no read: its content met every floor
|
|
946
|
+
* the handle knows, so storage cannot have moved it without a log entry the next refresh will
|
|
947
|
+
* walk (and the pend would be refused as stale on revision alone). Then, one cache probe per
|
|
948
|
+
* pinned block ({@link Tracker.movedBases}) and a replay if any moved.
|
|
949
|
+
*
|
|
950
|
+
* Latch-free by contract, like {@link snapshotPending}: the caller holds this instance's latch
|
|
951
|
+
* ({@link replayActions} is always run under it).
|
|
952
|
+
*
|
|
953
|
+
* NOTE: reads are not latched, so a concurrent read can still move a base between this check
|
|
954
|
+
* and the pend. The pend then declares the pin's (old) revision, storage refuses it, and the
|
|
955
|
+
* next attempt's call here re-stages — one wasted round trip, never a wrong base. If that
|
|
956
|
+
* refusal ever shows up in practice, the closure is to hold the pend under the read latch.
|
|
957
|
+
*
|
|
958
|
+
* @returns whether the pending queue was re-staged. */
|
|
959
|
+
async restageIfBasesMoved() {
|
|
960
|
+
for (const id of this.tracker.unretainedBases()) {
|
|
961
|
+
// `navigation` never upgrades the purpose the original read recorded (value-wins), and
|
|
962
|
+
// the revision recorded is the one returned, which the replay below re-records anyway.
|
|
963
|
+
await this.sourceCache.tryGet(id, 'navigation');
|
|
964
|
+
}
|
|
965
|
+
if (this.pending.length === 0 || this.logMovedBases(this.tracker.movedBases()) === 0) {
|
|
966
|
+
return false;
|
|
967
|
+
}
|
|
968
|
+
await this.replayActions();
|
|
969
|
+
return true;
|
|
970
|
+
}
|
|
971
|
+
/** Report each moved base, naming the revision the operations were computed against and the
|
|
972
|
+
* one the cache describes now, and return how many there were. */
|
|
973
|
+
logMovedBases(moved) {
|
|
974
|
+
if (log.enabled) {
|
|
975
|
+
for (const blockId of moved) {
|
|
976
|
+
log('collection:restage-moved-base id=%s tag=%s block=%s pinnedRev=%s currentRev=%s', this.id, this.instanceTag, blockId, this.tracker.stagedBaseRevs([blockId])[blockId] ?? 'none', this.sourceCache.getCachedRevision(blockId) ?? 'none');
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
return moved.length;
|
|
980
|
+
}
|
|
508
981
|
/** Capture the current staged state — tracker transforms plus the pending
|
|
509
982
|
* action queue — so it can be restored later via {@link restorePending}.
|
|
510
983
|
*
|
|
@@ -521,15 +994,49 @@ export class Collection {
|
|
|
521
994
|
snapshotPending() {
|
|
522
995
|
return {
|
|
523
996
|
transforms: copyTransforms(this.tracker.transforms),
|
|
997
|
+
pins: this.tracker.pins.copy(),
|
|
524
998
|
pending: [...this.pending],
|
|
525
999
|
context: structuredClone(this.source.actionContext),
|
|
526
1000
|
};
|
|
527
1001
|
}
|
|
528
1002
|
/** Restore the staged state captured by {@link snapshotPending}, discarding any
|
|
529
1003
|
* mutations staged since. Reads through the collection then observe exactly the
|
|
530
|
-
* snapshot state again; storage is untouched because nothing was ever synced.
|
|
1004
|
+
* snapshot state again; storage is untouched because nothing was ever synced.
|
|
1005
|
+
*
|
|
1006
|
+
* A snapshot is only restorable VERBATIM onto the committed boundary it was captured
|
|
1007
|
+
* on. If this collection has since ADOPTED a newer committed revision — a rival's
|
|
1008
|
+
* commit folded in by a refresh while the snapshot's transaction was in flight, e.g.
|
|
1009
|
+
* the conflict replay that refused a guarded insert (TreeKeyTakenError) — the
|
|
1010
|
+
* snapshot's transforms describe block state at the OLD boundary, and reinstalling
|
|
1011
|
+
* them would shadow committed blocks with stale structure. The observed case: an
|
|
1012
|
+
* INVENTED collection's pre-commit header/root transforms restored over the rival's
|
|
1013
|
+
* now-committed collection make every later read descend an empty tree, silently
|
|
1014
|
+
* hiding the committed rows. When the snapshot's pending queue is empty (the
|
|
1015
|
+
* transaction-rollback shape: the capture predates the transaction's first stage),
|
|
1016
|
+
* the correct restore target IS the committed state — reset the tracker empty and
|
|
1017
|
+
* let reads flow through to the adopted revision.
|
|
1018
|
+
*
|
|
1019
|
+
* NOTE: a snapshot that carries PENDING actions across a moved boundary (a
|
|
1020
|
+
* mid-transaction savepoint captured before a mid-transaction refresh adopted a
|
|
1021
|
+
* rival's commit) still restores verbatim below — rebasing it would require an async
|
|
1022
|
+
* replay this synchronous method cannot run. That shape predates this guard and
|
|
1023
|
+
* keeps its old behaviour; if it is ever observed producing stale reads, the rebase
|
|
1024
|
+
* belongs in an async caller that can replay the pending queue (see replayActions).
|
|
1025
|
+
* The WRITE side of that shape is closed here: the snapshot's base pins are restored with
|
|
1026
|
+
* its transforms, so the restored operations name the bases they were computed on, and the
|
|
1027
|
+
* pre-pend re-validation (restageIfBasesMoved) finds those bases moved and re-stages. */
|
|
531
1028
|
restorePending(snapshot) {
|
|
1029
|
+
const capturedRev = snapshot.context?.rev;
|
|
1030
|
+
const currentRev = this.source.actionContext?.rev;
|
|
1031
|
+
const boundaryMoved = currentRev !== undefined && (capturedRev === undefined || currentRev > capturedRev);
|
|
1032
|
+
if (boundaryMoved && snapshot.pending.length === 0) {
|
|
1033
|
+
this.tracker.reset();
|
|
1034
|
+
this.pending = [];
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
532
1037
|
this.tracker.reset(copyTransforms(snapshot.transforms));
|
|
1038
|
+
if (snapshot.pins)
|
|
1039
|
+
this.tracker.pins.replaceWith(snapshot.pins);
|
|
533
1040
|
this.pending = [...snapshot.pending];
|
|
534
1041
|
}
|
|
535
1042
|
/** A read-only {@link Tracker} pinned to this collection's committed state AS OF the
|
|
@@ -585,7 +1092,9 @@ export class Collection {
|
|
|
585
1092
|
// pin does not occur on the paths that reach here.
|
|
586
1093
|
seed = seed.filter(([, , revision]) => revision <= pinRev);
|
|
587
1094
|
}
|
|
588
|
-
|
|
1095
|
+
// The view shares this handle's floors, so a block the last refresh saw change is not kept
|
|
1096
|
+
// too old by the view either. A view pinned below a floor is untouched by it.
|
|
1097
|
+
const pinnedSource = new TransactorSource(this.id, this.transactor, structuredClone(pinContext), collector, this.floors);
|
|
589
1098
|
const pinnedCache = new CacheSource(pinnedSource, undefined, collector, seed);
|
|
590
1099
|
return new Tracker(pinnedCache, copyTransforms(transforms));
|
|
591
1100
|
}
|
|
@@ -637,7 +1146,14 @@ export class Collection {
|
|
|
637
1146
|
* process can be at different revisions at the same instant. That gap is invisible
|
|
638
1147
|
* from outside the class without this accessor, which is the whole reason it
|
|
639
1148
|
* exists: `docs/debugging.md` (§ "Which revision did a read descend?") explains
|
|
640
|
-
* how an operator reads the difference.
|
|
1149
|
+
* how an operator reads the difference.
|
|
1150
|
+
*
|
|
1151
|
+
* The one exception is `undefined` itself, which is a STATE rather than a revision:
|
|
1152
|
+
* "this instance invented the collection and has never adopted a committed revision".
|
|
1153
|
+
* For the same reason (only this instance moves it), it stays true until this instance
|
|
1154
|
+
* updates, syncs or records a commit, so a caller holding a freshly opened instance may
|
|
1155
|
+
* branch on it — the Quereus adapter does, to leave an invented, never-written index tree
|
|
1156
|
+
* unflushed exactly as an unwritten table tree is left. Never branch on the NUMBER. */
|
|
641
1157
|
committedRevision() {
|
|
642
1158
|
return this.source.actionContext?.rev;
|
|
643
1159
|
}
|
|
@@ -756,18 +1272,47 @@ export class Collection {
|
|
|
756
1272
|
* ever allowed to drive the SAME instance concurrently, this must become a per-attempt token (a
|
|
757
1273
|
* mark object compared by identity, refusing to replace a live one) rather than a bare id. */
|
|
758
1274
|
beginInFlightAction(actionId) {
|
|
1275
|
+
if (this.inFlightActionId !== actionId) {
|
|
1276
|
+
// A different action's failed attempt cannot finish this one. Re-marking the SAME id (a
|
|
1277
|
+
// coordinator re-marks on every attempt) keeps what the previous attempt retained — that
|
|
1278
|
+
// is the attempt the next refresh may need.
|
|
1279
|
+
this.inFlightAttempt = undefined;
|
|
1280
|
+
}
|
|
759
1281
|
this.inFlightActionId = actionId;
|
|
760
1282
|
return () => {
|
|
761
1283
|
if (this.inFlightActionId === actionId) {
|
|
762
1284
|
this.inFlightActionId = undefined;
|
|
1285
|
+
this.inFlightAttempt = undefined;
|
|
763
1286
|
}
|
|
764
1287
|
};
|
|
765
1288
|
}
|
|
766
|
-
/**
|
|
1289
|
+
/** Keep what a FAILED attempt under `actionId` sent, so that if the refresh before its retry
|
|
1290
|
+
* finds that attempt's own log entry it can finish the action rather than assume it finished
|
|
1291
|
+
* (see {@link inFlightAttempt} and {@link completeOwnEntry}). Every write path that marks an
|
|
1292
|
+
* action in flight must call this for each attempt that fails, BEFORE the refresh that follows —
|
|
1293
|
+
* a path that does not still never reports a half-landed write as saved (the refresh then
|
|
1294
|
+
* refuses with a `transforms-not-held` {@link TornActionError}), but it cannot recover from one.
|
|
1295
|
+
*
|
|
1296
|
+
* A no-op unless `actionId` is the action currently marked, so a late call from an attempt whose
|
|
1297
|
+
* cycle has already ended cannot plant transforms under somebody else's mark. Latch-free and
|
|
1298
|
+
* synchronous, like the mark itself: `TransactionCoordinator` calls it from inside its commit
|
|
1299
|
+
* span, where it already holds this instance's latch. */
|
|
1300
|
+
retainInFlightAttempt(actionId, attempt) {
|
|
1301
|
+
if (this.inFlightActionId === actionId) {
|
|
1302
|
+
this.inFlightAttempt = attempt;
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
/** Push our pending actions to the transactor.
|
|
1306
|
+
*
|
|
1307
|
+
* @returns who holds what this sync committed, or `undefined` when NOTHING WAS WRITTEN — a sync
|
|
1308
|
+
* with no staged changes does no pend and no commit, so there is no durability to report and none
|
|
1309
|
+
* is fabricated. That is the one case a caller must handle; every other outcome either returns a
|
|
1310
|
+
* class or throws ({@link SyncRetryExhaustedError} for a write that never landed). Present a change
|
|
1311
|
+
* as saved only via `isFullyDurable`, never by comparing `quorum`. */
|
|
767
1312
|
async sync(options) {
|
|
768
1313
|
const release = await Latches.acquire(this.latchId);
|
|
769
1314
|
try {
|
|
770
|
-
await this.syncInternal(options);
|
|
1315
|
+
return await this.syncInternal(options);
|
|
771
1316
|
}
|
|
772
1317
|
finally {
|
|
773
1318
|
release();
|
|
@@ -783,13 +1328,16 @@ export class Collection {
|
|
|
783
1328
|
const actionId = uint8ArrayToString(bytes, 'base64url');
|
|
784
1329
|
const endInFlight = this.beginInFlightAction(actionId);
|
|
785
1330
|
try {
|
|
786
|
-
await this.syncAttempts(actionId, options);
|
|
1331
|
+
return await this.syncAttempts(actionId, options);
|
|
787
1332
|
}
|
|
788
1333
|
finally {
|
|
789
1334
|
endInFlight();
|
|
790
1335
|
}
|
|
791
1336
|
}
|
|
792
|
-
/** The retry loop behind {@link syncInternal}, run with `actionId` already marked in flight.
|
|
1337
|
+
/** The retry loop behind {@link syncInternal}, run with `actionId` already marked in flight.
|
|
1338
|
+
*
|
|
1339
|
+
* @returns the durability of what this sync committed, or `undefined` when the loop never ran a
|
|
1340
|
+
* commit (nothing staged). See {@link sync}. */
|
|
793
1341
|
async syncAttempts(actionId, options) {
|
|
794
1342
|
const maxAttempts = options?.maxAttempts ?? DefaultMaxAttempts;
|
|
795
1343
|
const baseBackoffMs = options?.baseBackoffMs ?? PendingRetryDelayMs;
|
|
@@ -817,6 +1365,11 @@ export class Collection {
|
|
|
817
1365
|
// The revision the PREVIOUS iteration would have requested, so the stall check can tell a
|
|
818
1366
|
// refresh that moved nowhere from one that is still climbing toward the confirmed number.
|
|
819
1367
|
let previousRequestedRev;
|
|
1368
|
+
// Who holds what this sync has committed so far. Stays `undefined` while nothing has been
|
|
1369
|
+
// committed, which is also the answer when the loop never runs at all — a sync with nothing
|
|
1370
|
+
// staged writes nothing, and there is no durability to fabricate for it. Failed attempts never
|
|
1371
|
+
// contribute: a refused attempt left nothing behind, so the answer is the committing attempt's.
|
|
1372
|
+
let durability;
|
|
820
1373
|
while (this.hasUnsyncedChanges()) {
|
|
821
1374
|
if (signal?.aborted) {
|
|
822
1375
|
throw makeAbortError(signal);
|
|
@@ -869,6 +1422,9 @@ export class Collection {
|
|
|
869
1422
|
// Else: the responder told us nothing new this round. No strike, and no reset either
|
|
870
1423
|
// — the budget stays bounded by maxAttempts.
|
|
871
1424
|
}
|
|
1425
|
+
// A pending action is never pended over a base that has moved under it — before EVERY
|
|
1426
|
+
// attempt, first and retries alike (see restageIfBasesMoved for why the retry needs it).
|
|
1427
|
+
await this.restageIfBasesMoved();
|
|
872
1428
|
// Snapshot the pending actions so that any new actions aren't assumed to be part of this action
|
|
873
1429
|
const pending = [...this.pending];
|
|
874
1430
|
// Create a snapshot tracker for the action, so that we can ditch the log changes if we have to retry the action.
|
|
@@ -900,47 +1456,118 @@ export class Collection {
|
|
|
900
1456
|
// blocks. Unmeasured and cheap relative to the round trips it is retrying; if a
|
|
901
1457
|
// high-contention sync ever shows digest hashing in a profile, memoize per (id, staged ops).
|
|
902
1458
|
const blockDigests = await computeBlockContentDigests(tracker, tracker.transformedBlockIds());
|
|
1459
|
+
// The base each update-only block's operations were computed against, for the pend (see
|
|
1460
|
+
// `PendRequest.baseRevs`): the tracker's pinned revisions, which `restageIfBasesMoved` at the
|
|
1461
|
+
// top of this iteration has just re-judged, so a moved base was re-staged before it is named.
|
|
1462
|
+
const baseRevs = tracker.stagedBaseRevs(tracker.transformedBlockIds());
|
|
903
1463
|
// Commit the action to the transactor. Carry the aged retry priority derived from the
|
|
904
1464
|
// consecutive-failure count so a sync that keeps losing concurrent races out-ranks fresh
|
|
905
1465
|
// (priority-0) rivals in the cluster's resolveRace (fairness-only; capped at MaxPriority).
|
|
906
1466
|
// First attempt has consecutiveFailures == 0, so priority 0 — the common pend is unchanged.
|
|
907
|
-
const
|
|
908
|
-
if (
|
|
1467
|
+
const attempt = await this.source.transact(tracker.transforms, actionId, newRev, this.id, addResult.tailPath.block.header.id, clampPriority(consecutiveFailures), blockDigests, baseRevs);
|
|
1468
|
+
if (!attempt.success) {
|
|
909
1469
|
consecutiveFailures++;
|
|
910
|
-
lastReason =
|
|
1470
|
+
lastReason = attempt.reason ?? lastReason;
|
|
911
1471
|
// Highest-wins, not last-wins: the next request has to clear EVERY holder, so a later
|
|
912
1472
|
// responder reporting a LOWER number understates the binding constraint. Same rule the
|
|
913
1473
|
// producers and the transactor's aggregation already use.
|
|
914
|
-
lastStaleAt = highestStaleAt([lastStaleAt,
|
|
915
|
-
lastFailureConfirmedStaleAt =
|
|
1474
|
+
lastStaleAt = highestStaleAt([lastStaleAt, attempt.staleAt]);
|
|
1475
|
+
lastFailureConfirmedStaleAt = attempt.staleAt !== undefined;
|
|
916
1476
|
// Give up once the consecutive no-progress budget is exhausted, so a transactor that
|
|
917
1477
|
// persistently rejects the sync can no longer hold the collection latch forever.
|
|
918
1478
|
// NOTE: this also bounds the legitimate `pending`-wait case (retrying the same action
|
|
919
1479
|
// while another commit is in flight), which used to retry indefinitely. Default 10
|
|
920
1480
|
// attempts ≈ 21s of exponential backoff. If a high-contention workload legitimately
|
|
921
1481
|
// needs to wait longer for a pending commit to clear, raise maxAttempts for that caller.
|
|
1482
|
+
// NOTE: no refresh follows the LAST budgeted attempt, so if that attempt's log tail
|
|
1483
|
+
// landed nobody finds out: the caller gets plain exhaustion over a log that holds an
|
|
1484
|
+
// entry for this write. The write is still never reported saved, which is the rule; what
|
|
1485
|
+
// is lost is the more specific name (TornActionError). The leftover entry itself is
|
|
1486
|
+
// tracked in tickets/backlog/bug-a-refused-write-can-leave-its-log-entry-behind.
|
|
922
1487
|
if (consecutiveFailures >= maxAttempts) {
|
|
923
1488
|
throw new SyncRetryExhaustedError(this.id, consecutiveFailures, lastReason, lastStaleAt);
|
|
924
1489
|
}
|
|
925
|
-
//
|
|
926
|
-
//
|
|
927
|
-
//
|
|
928
|
-
//
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
//
|
|
939
|
-
//
|
|
940
|
-
//
|
|
941
|
-
|
|
1490
|
+
// Keep exactly what this attempt sent. A refused commit is not proof nothing landed:
|
|
1491
|
+
// the log tail is committed first and can be stored while the answer is still a
|
|
1492
|
+
// failure, and `transact` has just cancelled every block that did not land. If the
|
|
1493
|
+
// refresh below finds this attempt's own entry, these are the transforms that finish
|
|
1494
|
+
// the action (see completeOwnEntry). The snapshot tracker is abandoned after this
|
|
1495
|
+
// iteration, so its transforms are handed over as-is, uncopied.
|
|
1496
|
+
this.retainInFlightAttempt(actionId, {
|
|
1497
|
+
rev: newRev,
|
|
1498
|
+
transforms: tracker.transforms,
|
|
1499
|
+
tailId: addResult.tailPath.block.header.id,
|
|
1500
|
+
...(blockDigests === undefined ? {} : { blockDigests }),
|
|
1501
|
+
...baseRevsField(baseRevs),
|
|
1502
|
+
});
|
|
1503
|
+
// Refresh, and keep refreshing while it reports that this write's own half-landed
|
|
1504
|
+
// action could not be finished YET. It must not fall through to a new attempt in that
|
|
1505
|
+
// state: a new attempt rebuilds the log entry (fresh timestamp) and would send a second
|
|
1506
|
+
// version of a log tail that is already stored under this action and revision. Each
|
|
1507
|
+
// round is a failure against the same no-progress budget as a refused attempt.
|
|
1508
|
+
for (;;) {
|
|
1509
|
+
// Back off before every retry (any stale failure — reason/missing/pending), growing
|
|
1510
|
+
// exponentially from the base delay up to the cap, with proportional random jitter so a
|
|
1511
|
+
// herd of clients that lost the same race does not re-collide on the next tick (see
|
|
1512
|
+
// utility/backoff.ts). The abortable sleep lets an aborted sync reject promptly instead
|
|
1513
|
+
// of finishing the sleep.
|
|
1514
|
+
// NOTE: the `missing`/`reason` conflict paths now pay this backoff too (they previously
|
|
1515
|
+
// retried with zero delay); that is what stops the persistent-`reason` hot spin. If a
|
|
1516
|
+
// high-contention workload ever shows this base delay as recovery latency, lower
|
|
1517
|
+
// baseBackoffMs for that caller rather than reintroducing the zero-delay retry.
|
|
1518
|
+
const delay = jitteredBackoffMs(consecutiveFailures - 1, { baseMs: baseBackoffMs, capMs: maxBackoffMs }, options?.rand);
|
|
1519
|
+
await abortableDelay(delay, signal);
|
|
1520
|
+
try {
|
|
1521
|
+
// Fetch latest state - updateInternal() will call replayActions() if there are conflicts.
|
|
1522
|
+
// This sync's actionId is marked in flight for the whole cycle (see syncInternal), so
|
|
1523
|
+
// the refresh recognizes a log entry written by THIS action (its log tail landed but
|
|
1524
|
+
// the commit answered failure), FINISHES that action from the attempt retained
|
|
1525
|
+
// above, and only then consumes the entry rather than replaying it into a duplicate.
|
|
1526
|
+
// The round that would spend the last of the budget settles a half-landed write
|
|
1527
|
+
// instead of asking for another round, so the error that escapes below says
|
|
1528
|
+
// whether the write can still land. (A deadline cannot be foreseen the same
|
|
1529
|
+
// way; a write given up on it escapes unsettled, as `final: false`.)
|
|
1530
|
+
const report = {};
|
|
1531
|
+
await this.updateInternal(report, consecutiveFailures + 1 >= maxAttempts);
|
|
1532
|
+
const completed = report.ownEntryFinished?.durability;
|
|
1533
|
+
if (completed !== undefined) {
|
|
1534
|
+
// The refresh made this sync's write durable: that is a commit, and it is
|
|
1535
|
+
// reported and counted exactly like one made by an attempt (see the success
|
|
1536
|
+
// branch below for why batches fold to the weakest).
|
|
1537
|
+
durability = durability === undefined ? completed : mergeDurability([durability, completed]);
|
|
1538
|
+
consecutiveFailures = 0;
|
|
1539
|
+
lastReason = undefined;
|
|
1540
|
+
lastStaleAt = undefined;
|
|
1541
|
+
lastFailureConfirmedStaleAt = false;
|
|
1542
|
+
consecutiveStalls = 0;
|
|
1543
|
+
}
|
|
1544
|
+
break;
|
|
1545
|
+
}
|
|
1546
|
+
catch (err) {
|
|
1547
|
+
if (!(err instanceof TornActionError) || err.reason !== 'completion-refused') {
|
|
1548
|
+
throw err;
|
|
1549
|
+
}
|
|
1550
|
+
// Refused for a cause that can clear. The error already names the write as torn,
|
|
1551
|
+
// which is the truth if the budget ends here — so it, not a plain exhaustion,
|
|
1552
|
+
// is what escapes: the log holds an entry for a write that was not saved.
|
|
1553
|
+
consecutiveFailures++;
|
|
1554
|
+
lastReason = err.detail;
|
|
1555
|
+
if (consecutiveFailures >= maxAttempts
|
|
1556
|
+
|| (deadlineMs !== undefined && Date.now() - startedAt >= deadlineMs)) {
|
|
1557
|
+
throw err;
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
942
1561
|
}
|
|
943
1562
|
else {
|
|
1563
|
+
// This attempt's commit landed, so its durability is the one to report. A sync that
|
|
1564
|
+
// commits ONCE — every sync that has a caller today — reports exactly that answer,
|
|
1565
|
+
// untouched. A sync whose loop commits more than one batch (the `hasUnsyncedChanges`
|
|
1566
|
+
// condition re-entering after a successful commit) folds the batches with
|
|
1567
|
+
// `mergeDurability`, whose scalar answer is the WEAKEST of them: one batch that only
|
|
1568
|
+
// reached the writer makes the whole sync only-on-the-writer, and reporting the last
|
|
1569
|
+
// batch's class instead would show such a write as saved.
|
|
1570
|
+
durability = durability === undefined ? attempt.durability : mergeDurability([durability, attempt.durability]);
|
|
944
1571
|
// Forward progress: reset the no-progress budget.
|
|
945
1572
|
consecutiveFailures = 0;
|
|
946
1573
|
lastReason = undefined;
|
|
@@ -968,17 +1595,81 @@ export class Collection {
|
|
|
968
1595
|
: { committed: [{ actionId, rev: newRev }], rev: newRev };
|
|
969
1596
|
}
|
|
970
1597
|
}
|
|
1598
|
+
return durability;
|
|
971
1599
|
}
|
|
1600
|
+
/** Refresh from the transactor, then push. Same return contract as {@link sync}: the durability of
|
|
1601
|
+
* what was committed, or `undefined` when nothing was staged and so nothing was written. */
|
|
972
1602
|
async updateAndSync(options) {
|
|
973
1603
|
const release = await Latches.acquire(this.latchId);
|
|
974
1604
|
try {
|
|
975
|
-
await this.updateInternal();
|
|
976
|
-
await this.syncInternal(options);
|
|
1605
|
+
await this.updateInternal({});
|
|
1606
|
+
return await this.syncInternal(options);
|
|
977
1607
|
}
|
|
978
1608
|
finally {
|
|
979
1609
|
release();
|
|
980
1610
|
}
|
|
981
1611
|
}
|
|
1612
|
+
/** Stage `actions` and flush them as one call that either takes effect or leaves NOTHING behind:
|
|
1613
|
+
* when the flush throws, the actions this call staged are unstaged again before the error escapes.
|
|
1614
|
+
*
|
|
1615
|
+
* For callers that own both halves of a write (`Tree.replace`, `Diary.append`). Staging with
|
|
1616
|
+
* {@link act} and flushing separately leaves a failed write's actions staged — deliberate for a
|
|
1617
|
+
* caller that means to call {@link sync} again, and a trap for one that does not: the actions
|
|
1618
|
+
* ride along, unasked, with its NEXT write, so a change the caller was told had failed shows up
|
|
1619
|
+
* later, and a caller that reacted to the failure by submitting it again stores it twice. That
|
|
1620
|
+
* is how a write reported torn was seen to "appear one write later" (the pending-record route
|
|
1621
|
+
* first suspected was ruled out: with the failed writer's collection discarded, 0 of 17 torn
|
|
1622
|
+
* rows ever appeared).
|
|
1623
|
+
*
|
|
1624
|
+
* One latch hold spans staging and flushing, so no other {@link act} on this instance can land
|
|
1625
|
+
* between them and the actions to take back are exactly the ones this call put there.
|
|
1626
|
+
*
|
|
1627
|
+
* The error is rethrown untouched. Unstaging says nothing about storage: a
|
|
1628
|
+
* {@link TornActionError} with `final: false` still means the write may be saved or may yet
|
|
1629
|
+
* land, and only the staged copy is gone. */
|
|
1630
|
+
async actAndSync(actions, options) {
|
|
1631
|
+
const release = await Latches.acquire(this.latchId);
|
|
1632
|
+
try {
|
|
1633
|
+
const stagedBefore = copyTransforms(this.tracker.transforms);
|
|
1634
|
+
const revBefore = this.source.actionContext?.rev;
|
|
1635
|
+
await this.actInternal(...actions);
|
|
1636
|
+
try {
|
|
1637
|
+
await this.updateInternal({});
|
|
1638
|
+
return await this.syncInternal(options);
|
|
1639
|
+
}
|
|
1640
|
+
catch (err) {
|
|
1641
|
+
await this.unstage(actions, stagedBefore, revBefore);
|
|
1642
|
+
throw err;
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
finally {
|
|
1646
|
+
release();
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
/** Takes `actions` back out of the staged queue and rebuilds the tracker without them (always
|
|
1650
|
+
* called under latch). `stagedBefore` / `revBefore` are the tracker's transforms and the held
|
|
1651
|
+
* revision from before the actions were staged.
|
|
1652
|
+
*
|
|
1653
|
+
* While the held revision has not moved, the earlier transforms are reinstated verbatim rather
|
|
1654
|
+
* than rebuilt by replay: an INVENTED collection keeps its header and root in the tracker with
|
|
1655
|
+
* no staged action naming them, and a replay (which resets the tracker first) would drop them —
|
|
1656
|
+
* the hazard {@link mustReplay} and {@link snapshotPending} document. Once the revision has
|
|
1657
|
+
* moved those transforms describe blocks at a revision this handle has left, so what remains is
|
|
1658
|
+
* re-staged against the adopted one, exactly as a refresh would have done. */
|
|
1659
|
+
async unstage(actions, stagedBefore, revBefore) {
|
|
1660
|
+
// NOTE: matched by identity, which is what `filterAgainstEntry` promises for a kept action.
|
|
1661
|
+
// A `filterConflict` hook that answers a REPLACEMENT instance for one of these actions would
|
|
1662
|
+
// leave the replacement staged here. No collection installs such a hook today (`Tree` and
|
|
1663
|
+
// `Diary` install none); if one ever does, carry a per-call token on the staged actions and
|
|
1664
|
+
// match on that instead.
|
|
1665
|
+
this.pending = this.pending.filter(staged => !actions.includes(staged));
|
|
1666
|
+
if (this.source.actionContext?.rev === revBefore) {
|
|
1667
|
+
this.tracker.reset(stagedBefore);
|
|
1668
|
+
}
|
|
1669
|
+
else {
|
|
1670
|
+
await this.replayActions();
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
982
1673
|
async *selectLog(forward = true) {
|
|
983
1674
|
const collectionLog = await Log.open(this.tracker, this.id);
|
|
984
1675
|
if (!collectionLog) {
|
|
@@ -1014,54 +1705,93 @@ export class Collection {
|
|
|
1014
1705
|
doFilterConflict(action, potential) {
|
|
1015
1706
|
return this.filterConflict ? this.filterConflict(action, potential) : action;
|
|
1016
1707
|
}
|
|
1708
|
+
/** The two blocks every refresh starts from — the collection header and the log tail block it
|
|
1709
|
+
* names — read unpinned ("latest"), in ONE request when `knownTailId` is the tail the header
|
|
1710
|
+
* names. Only when the header names a different tail (the known one filled, or none was known)
|
|
1711
|
+
* is that tail fetched in a second request; the out-of-date block's answer is dropped rather
|
|
1712
|
+
* than kept, because nothing proves it is current at the revision the refresh will pin to.
|
|
1713
|
+
*
|
|
1714
|
+
* Both answers pass {@link answeredBlock}'s checks as unpinned reads, header first, so a doubted
|
|
1715
|
+
* header or tail throws exactly as it would through {@link TransactorSource.tryGet}.
|
|
1716
|
+
*
|
|
1717
|
+
* NOTE: a batched get fails as a whole when any block in it gets no answer
|
|
1718
|
+
* (`NetworkTransactor.get` throws on a missing id), so an unreachable out-of-date tail fails a
|
|
1719
|
+
* refresh that would not have needed it. Harmless today: the log has no checkpoints, so a
|
|
1720
|
+
* refresh that finds a new tail walks back through the old one anyway. If checkpoints start
|
|
1721
|
+
* letting that walk stop short, read the known tail in its own request instead.
|
|
1722
|
+
*
|
|
1723
|
+
* @returns undefined when the header is authoritatively absent. */
|
|
1724
|
+
static async readLogEnds(transactor, id, knownTailId) {
|
|
1725
|
+
const results = await transactor.get({ blockIds: knownTailId === undefined ? [id] : [id, knownTailId] });
|
|
1726
|
+
const headerEntry = results?.[id];
|
|
1727
|
+
if (headerEntry === undefined) {
|
|
1728
|
+
return undefined;
|
|
1729
|
+
}
|
|
1730
|
+
const header = answeredBlock(id, headerEntry, undefined);
|
|
1731
|
+
if (!header) {
|
|
1732
|
+
return undefined;
|
|
1733
|
+
}
|
|
1734
|
+
const served = [[id, header, servedRevision(headerEntry)]];
|
|
1735
|
+
const tailId = header.tailId;
|
|
1736
|
+
if (tailId === undefined) {
|
|
1737
|
+
return { header, served };
|
|
1738
|
+
}
|
|
1739
|
+
const tail = tailId === knownTailId
|
|
1740
|
+
? Collection.checkedLogTail(tailId, results[tailId])
|
|
1741
|
+
: await Collection.readLogTail(transactor, tailId);
|
|
1742
|
+
if (tail?.block) {
|
|
1743
|
+
served.push([tailId, tail.block, servedRevision(tail)]);
|
|
1744
|
+
}
|
|
1745
|
+
return { header, tail, served };
|
|
1746
|
+
}
|
|
1747
|
+
/** An unpinned read of the log tail block, checked as {@link checkedLogTail} describes. */
|
|
1748
|
+
static async readLogTail(transactor, tailId) {
|
|
1749
|
+
return Collection.checkedLogTail(tailId, (await transactor.get({ blockIds: [tailId] }))?.[tailId]);
|
|
1750
|
+
}
|
|
1751
|
+
/** The repo's answer for the log tail, once it has passed {@link answeredBlock}'s unpinned-read
|
|
1752
|
+
* checks. The raw entry, not just the block, is what a refresh needs — {@link bootstrapContext}
|
|
1753
|
+
* reads `state.latest` off it — which is why the tail is read around {@link TransactorSource}
|
|
1754
|
+
* and has to be checked here.
|
|
1755
|
+
*
|
|
1756
|
+
* Both checks matter at this seam in particular. A tail the repo could not retrieve must not
|
|
1757
|
+
* degrade into "no context", which would leave the chain walk unable to see pending non-tail
|
|
1758
|
+
* blocks and the collection reading as if they did not exist. And this unpinned tail read is
|
|
1759
|
+
* the ONE seam where a lagging collection can learn a newer revision exists — every later data
|
|
1760
|
+
* read is pinned to the context seeded from it — so seeding from a tail the repo could not
|
|
1761
|
+
* confirm is current would freeze the collection at the stale revision with nothing ever
|
|
1762
|
+
* reporting a problem. A tail with no `state.latest` and no flag is a real answer (nothing
|
|
1763
|
+
* committed yet). */
|
|
1764
|
+
static checkedLogTail(tailId, entry) {
|
|
1765
|
+
if (entry) {
|
|
1766
|
+
answeredBlock(tailId, entry, undefined);
|
|
1767
|
+
}
|
|
1768
|
+
return entry;
|
|
1769
|
+
}
|
|
1017
1770
|
/** Bootstrap ActionContext from the committed tail block's state.
|
|
1018
1771
|
* The tail is always committed first (commit protocol guarantee), so it's readable
|
|
1019
1772
|
* with context=undefined. Its state.latest contains the ActionRev of the most recent
|
|
1020
1773
|
* committed action — exactly the proof needed for the transactor to serve pending
|
|
1021
|
-
* non-tail blocks during chain walks.
|
|
1022
|
-
*
|
|
1023
|
-
*
|
|
1024
|
-
*
|
|
1025
|
-
*
|
|
1026
|
-
*
|
|
1027
|
-
*
|
|
1028
|
-
*
|
|
1029
|
-
*
|
|
1030
|
-
*
|
|
1031
|
-
*
|
|
1032
|
-
*
|
|
1033
|
-
*
|
|
1034
|
-
* problem, so it throws the same way TransactorSource.tryGet does for its unpinned reads
|
|
1035
|
-
* (see the tradeoff NOTE there).
|
|
1774
|
+
* non-tail blocks during chain walks. A tail with no `latest` (or no tail) no-ops.
|
|
1775
|
+
*
|
|
1776
|
+
* NOTE: this number is adopted on trust, and adoption is one-way (advanceContext never
|
|
1777
|
+
* lowers it). A tail that over-claims therefore pins the collection at a revision its
|
|
1778
|
+
* own log can never reach, permanently: every later refresh walks the log (the claim's newest
|
|
1779
|
+
* entry never matches, so {@link tailShowsNothingNewer} never lets it skip), reads the
|
|
1780
|
+
* real (lower) revision, and is refused — so the instance emits
|
|
1781
|
+
* `collection:context-not-lowered` forever while `collection:context-short-of-tail`
|
|
1782
|
+
* stays silent (the held revision is at or above what the tail claims). No condition
|
|
1783
|
+
* that makes a real tail over-claim has been demonstrated; this was seen only through a
|
|
1784
|
+
* test double built to lie (see collection.spec.ts, 'a refresh that lands short of the
|
|
1785
|
+
* tail it just read'). If an over-claiming tail is ever observed in the field, the fix
|
|
1786
|
+
* belongs here — validate the claim against the log before pinning — not in the refresh.
|
|
1036
1787
|
*/
|
|
1037
|
-
static
|
|
1038
|
-
const
|
|
1039
|
-
if (
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
}
|
|
1045
|
-
if (tailEntry?.unconfirmedAheadRev !== undefined) {
|
|
1046
|
-
throw new BlockPossiblyStaleError(tailId, tailEntry.unconfirmedAheadRev);
|
|
1047
|
-
}
|
|
1048
|
-
const tailState = tailEntry?.state;
|
|
1049
|
-
// NOTE: this number is adopted on trust, and adoption is one-way (advanceContext never
|
|
1050
|
-
// lowers it). A tail that over-claims therefore pins the collection at a revision its
|
|
1051
|
-
// own log can never reach, permanently: every later refresh walks the log, reads the
|
|
1052
|
-
// real (lower) revision, and is refused — so the instance emits
|
|
1053
|
-
// `collection:context-not-lowered` forever while `collection:context-short-of-tail`
|
|
1054
|
-
// stays silent (the held revision is at or above what the tail claims). No condition
|
|
1055
|
-
// that makes a real tail over-claim has been demonstrated; this was seen only through a
|
|
1056
|
-
// test double built to lie (see collection.spec.ts, 'a refresh that lands short of the
|
|
1057
|
-
// tail it just read'). If an over-claiming tail is ever observed in the field, the fix
|
|
1058
|
-
// belongs here — validate the claim against the log before pinning — not in the refresh.
|
|
1059
|
-
if (tailState?.latest) {
|
|
1060
|
-
source.actionContext = {
|
|
1061
|
-
committed: [{ actionId: tailState.latest.actionId, rev: tailState.latest.rev }],
|
|
1062
|
-
rev: tailState.latest.rev,
|
|
1063
|
-
};
|
|
1064
|
-
}
|
|
1788
|
+
static bootstrapContext(source, tail) {
|
|
1789
|
+
const latest = tail?.state.latest;
|
|
1790
|
+
if (latest) {
|
|
1791
|
+
source.actionContext = {
|
|
1792
|
+
committed: [{ actionId: latest.actionId, rev: latest.rev }],
|
|
1793
|
+
rev: latest.rev,
|
|
1794
|
+
};
|
|
1065
1795
|
}
|
|
1066
1796
|
}
|
|
1067
1797
|
}
|