@optimystic/db-core 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +336 -336
- package/dist/src/btree/btree.d.ts +1 -1
- package/dist/src/btree/btree.d.ts.map +1 -1
- package/dist/src/btree/btree.js +5 -1
- package/dist/src/btree/btree.js.map +1 -1
- package/dist/src/cluster/structs.d.ts +14 -3
- package/dist/src/cluster/structs.d.ts.map +1 -1
- package/dist/src/collection/collection-type-registry.d.ts +3 -2
- package/dist/src/collection/collection-type-registry.d.ts.map +1 -1
- package/dist/src/collection/collection-type-registry.js.map +1 -1
- package/dist/src/collection/collection.d.ts +44 -0
- package/dist/src/collection/collection.d.ts.map +1 -1
- package/dist/src/collection/collection.js +134 -27
- package/dist/src/collection/collection.js.map +1 -1
- package/dist/src/collection/struct.d.ts +43 -1
- package/dist/src/collection/struct.d.ts.map +1 -1
- package/dist/src/collection/struct.js +42 -2
- package/dist/src/collection/struct.js.map +1 -1
- package/dist/src/collections/diary/diary.d.ts +6 -1
- package/dist/src/collections/diary/diary.d.ts.map +1 -1
- package/dist/src/collections/diary/diary.js +26 -19
- package/dist/src/collections/diary/diary.js.map +1 -1
- package/dist/src/collections/tree/tree.d.ts +15 -0
- package/dist/src/collections/tree/tree.d.ts.map +1 -1
- package/dist/src/collections/tree/tree.js +37 -12
- package/dist/src/collections/tree/tree.js.map +1 -1
- package/dist/src/network/i-key-network.d.ts +17 -0
- package/dist/src/network/i-key-network.d.ts.map +1 -1
- package/dist/src/network/index.d.ts +1 -0
- package/dist/src/network/index.d.ts.map +1 -1
- package/dist/src/network/index.js +1 -0
- package/dist/src/network/index.js.map +1 -1
- package/dist/src/network/stale-failure.d.ts +32 -0
- package/dist/src/network/stale-failure.d.ts.map +1 -0
- package/dist/src/network/stale-failure.js +41 -0
- package/dist/src/network/stale-failure.js.map +1 -0
- package/dist/src/network/struct.d.ts +48 -0
- package/dist/src/network/struct.d.ts.map +1 -1
- package/dist/src/network/struct.js +16 -1
- package/dist/src/network/struct.js.map +1 -1
- package/dist/src/testing/test-transactor.d.ts.map +1 -1
- package/dist/src/testing/test-transactor.js +6 -2
- package/dist/src/testing/test-transactor.js.map +1 -1
- package/dist/src/transaction/coordinator.d.ts.map +1 -1
- package/dist/src/transaction/coordinator.js +27 -15
- package/dist/src/transaction/coordinator.js.map +1 -1
- package/dist/src/transactor/network-transactor.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.js +93 -30
- package/dist/src/transactor/network-transactor.js.map +1 -1
- package/dist/src/transactor/transactor-source.d.ts.map +1 -1
- package/dist/src/transactor/transactor-source.js +9 -1
- package/dist/src/transactor/transactor-source.js.map +1 -1
- package/package.json +1 -1
- package/src/btree/btree.ts +4 -1
- package/src/cluster/structs.ts +14 -3
- package/src/collection/collection-type-registry.ts +3 -2
- package/src/collection/collection.ts +151 -28
- package/src/collection/struct.ts +38 -1
- package/src/collections/diary/diary.ts +67 -59
- package/src/collections/tree/tree.ts +63 -12
- package/src/network/i-key-network.ts +18 -0
- package/src/network/index.ts +1 -0
- package/src/network/stale-failure.ts +43 -0
- package/src/network/struct.ts +48 -0
- package/src/testing/test-transactor.ts +6 -2
- package/src/transaction/coordinator.ts +31 -15
- package/src/transactor/network-transactor.ts +97 -31
- package/src/transactor/transactor-source.ts +9 -1
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { StaleFailure } from "./struct.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The single rule for "is this non-success retryable after a re-read?" Both write paths and the
|
|
5
|
+
* transactor's aggregation call this — no consumer re-derives it.
|
|
6
|
+
*
|
|
7
|
+
* {@link StaleFailure.conflict} is authoritative when present. The `missing`/`pending` fallback
|
|
8
|
+
* covers producers that have not been taught the field, including a remote peer on an older build
|
|
9
|
+
* (the repo protocol is plain JSON, so an unset field simply arrives absent).
|
|
10
|
+
*/
|
|
11
|
+
export function isConflictFailure(failure: StaleFailure): boolean {
|
|
12
|
+
return failure.conflict ?? Boolean(failure.missing?.length || failure.pending?.length);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The single rule for picking one {@link StaleFailure.staleAt} out of several candidates.
|
|
17
|
+
*
|
|
18
|
+
* Highest `rev` wins: the losing writer's next request has to clear EVERY holder, so the largest
|
|
19
|
+
* confirmed revision is the binding constraint and any smaller one understates it. Ties keep the
|
|
20
|
+
* earlier candidate. Undefined entries (a block or batch with no confirmed number, or a peer that
|
|
21
|
+
* predates the field) contribute nothing, and an all-undefined input yields undefined so callers
|
|
22
|
+
* can omit the key rather than emit `staleAt: undefined`.
|
|
23
|
+
*
|
|
24
|
+
* Every site that has more than one candidate calls this — the producers scanning several blocks
|
|
25
|
+
* (`StorageRepo.pend`/`.commit`, `CoordinatorRepo.classifyStaleRejection`) as well as
|
|
26
|
+
* `NetworkTransactor` rebuilding one response from many per-batch ones. Uniformity is what makes
|
|
27
|
+
* the transactor's aggregate meaningful: if a producer reported an arbitrary block instead of its
|
|
28
|
+
* highest, taking the max across producers would still understate the constraint.
|
|
29
|
+
*
|
|
30
|
+
* NOTE: comparing revisions across blocks is only meaningful because one pend covers one
|
|
31
|
+
* collection, so every candidate comes from the same revision counter. If a pend is ever allowed
|
|
32
|
+
* to span collections, these numbers come from unrelated counters and selection must become
|
|
33
|
+
* per-collection.
|
|
34
|
+
*/
|
|
35
|
+
export function highestStaleAt(candidates: readonly StaleFailure['staleAt'][]): StaleFailure['staleAt'] {
|
|
36
|
+
let best: StaleFailure['staleAt'];
|
|
37
|
+
for (const candidate of candidates) {
|
|
38
|
+
if (candidate !== undefined && (best === undefined || candidate.rev > best.rev)) {
|
|
39
|
+
best = candidate;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return best;
|
|
43
|
+
}
|
package/src/network/struct.ts
CHANGED
|
@@ -71,6 +71,28 @@ export type StaleFailure = {
|
|
|
71
71
|
missing?: ActionTransforms[];
|
|
72
72
|
/** List of actions that are pending on the blocks touched by this pend */
|
|
73
73
|
pending?: ActionPending[];
|
|
74
|
+
/**
|
|
75
|
+
* Explicit retryability. True when this failure is an optimistic-concurrency loss — the
|
|
76
|
+
* requested revision was taken, or a rival pend holds the blocks — so a re-read, rebase and
|
|
77
|
+
* re-pend can win. Set it only when the producer genuinely classified the failure; leave it
|
|
78
|
+
* absent otherwise, and consumers fall back to inferring from `missing`/`pending`.
|
|
79
|
+
* Read it through `isConflictFailure` rather than testing it directly.
|
|
80
|
+
*/
|
|
81
|
+
conflict?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* The block that already occupies (or is past) the requested revision, and the revision the
|
|
84
|
+
* responder holds for it.
|
|
85
|
+
*
|
|
86
|
+
* CONFIRMED-ONLY: set this only when the producer read the revision out of its own storage.
|
|
87
|
+
* A producer that merely suspects staleness — or that learned of it from another peer's
|
|
88
|
+
* free-form reject text — must leave it absent. Absent means "no confirmed number", never
|
|
89
|
+
* "not stale".
|
|
90
|
+
*
|
|
91
|
+
* DIAGNOSTIC, NOT A RETRYABILITY SIGNAL: `conflict` (read via `isConflictFailure`) remains the
|
|
92
|
+
* single source of truth for "can a re-read and re-pend win?". Never branch retry decisions on
|
|
93
|
+
* the presence of this field.
|
|
94
|
+
*/
|
|
95
|
+
staleAt?: { blockId: BlockId; rev: number };
|
|
74
96
|
};
|
|
75
97
|
|
|
76
98
|
export type PendResult = PendSuccess | StaleFailure;
|
|
@@ -126,13 +148,39 @@ export type BlockGets = {
|
|
|
126
148
|
context?: ActionContext; // Latest if this is omitted
|
|
127
149
|
};
|
|
128
150
|
|
|
151
|
+
/** Why a repo could not establish whether a block exists. Present ONLY when the repo
|
|
152
|
+
* knows its own answer is a guess; an absent field is an authoritative answer. */
|
|
153
|
+
export type BlockUnavailableReason =
|
|
154
|
+
/** Records for this block exist here but it cannot be reconstructed locally — a
|
|
155
|
+
* revision was received with no base to apply it to, or its history is truncated. */
|
|
156
|
+
| 'unmaterializable'
|
|
157
|
+
/** Nothing is held locally and the cohort could not be consulted to confirm it. */
|
|
158
|
+
| 'peers-unreachable';
|
|
159
|
+
|
|
129
160
|
export type GetBlockResult = {
|
|
130
161
|
/** The retrieved block - undefined if the block was deleted */
|
|
131
162
|
block?: IBlock;
|
|
132
163
|
/** The latest and pending states of the repo that retrieved the block */
|
|
133
164
|
state: BlockActionState;
|
|
165
|
+
/** Set when this repo could not determine whether the block exists — its answer is a
|
|
166
|
+
* guess, not an authoritative absent. Every producer that omits it (including
|
|
167
|
+
* TestTransactor) keeps meaning "authoritative". */
|
|
168
|
+
unavailable?: BlockUnavailableReason;
|
|
134
169
|
};
|
|
135
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Thrown by a block read when the responsible repo could not determine whether the
|
|
173
|
+
* block exists. Distinct from "the block is absent" (undefined) and from a transport
|
|
174
|
+
* failure — this node's data is genuinely indeterminate and the caller must not treat
|
|
175
|
+
* it as empty. Not a StaleFailure: `Collection.sync` does not retry it.
|
|
176
|
+
*/
|
|
177
|
+
export class BlockUnavailableError extends Error {
|
|
178
|
+
constructor(readonly blockId: BlockId, readonly reason: BlockUnavailableReason) {
|
|
179
|
+
super(`Block ${blockId} is unavailable (${reason}): the repo could not determine whether it exists`);
|
|
180
|
+
this.name = 'BlockUnavailableError';
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
136
184
|
export type GetBlockResults = Record<BlockId, GetBlockResult>;
|
|
137
185
|
|
|
138
186
|
/**
|
|
@@ -189,10 +189,13 @@ export class TestTransactor implements ITransactor {
|
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
// Handle failure due to committed conflicts first
|
|
192
|
+
// Handle failure due to committed conflicts first.
|
|
193
|
+
// `conflict: true` on the three optimistic-concurrency returns below mirrors what
|
|
194
|
+
// StorageRepo.pend now emits, so consumers of this test transactor see the real shape.
|
|
193
195
|
if (missing.length > 0) {
|
|
194
196
|
return {
|
|
195
197
|
success: false,
|
|
198
|
+
conflict: true,
|
|
196
199
|
missing
|
|
197
200
|
};
|
|
198
201
|
}
|
|
@@ -200,7 +203,7 @@ export class TestTransactor implements ITransactor {
|
|
|
200
203
|
// Handle failure/retry due to pending conflicts
|
|
201
204
|
if (conflictingPendings.length > 0) {
|
|
202
205
|
if (policy === 'f') {
|
|
203
|
-
return { success: false, pending: conflictingPendings };
|
|
206
|
+
return { success: false, conflict: true, pending: conflictingPendings };
|
|
204
207
|
} else if (policy === 'r') {
|
|
205
208
|
// Simulate fetching pending transforms for 'r' policy
|
|
206
209
|
const pendingWithTransforms = conflictingPendings
|
|
@@ -217,6 +220,7 @@ export class TestTransactor implements ITransactor {
|
|
|
217
220
|
|
|
218
221
|
return {
|
|
219
222
|
success: false,
|
|
223
|
+
conflict: true,
|
|
220
224
|
pending: pendingWithTransforms
|
|
221
225
|
};
|
|
222
226
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ITransactor, BlockId, CollectionId, Transforms, PendRequest, CommitRequest, ActionId } from "../index.js";
|
|
2
2
|
import type { Transaction, ExecutionResult, ITransactionEngine, CollectionActions, ReadDependency } from "./transaction.js";
|
|
3
3
|
import type { PeerId } from "../network/types.js";
|
|
4
|
+
import { isConflictFailure } from "../network/stale-failure.js";
|
|
4
5
|
import type { Collection } from "../collection/collection.js";
|
|
5
6
|
import type { SyncOptions } from "../collection/index.js";
|
|
6
7
|
import { isTransactionExpired, clampPriority } from "./transaction.js";
|
|
@@ -21,15 +22,24 @@ const DefaultBaseBackoffMs = 100;
|
|
|
21
22
|
const DefaultMaxBackoffMs = 5000;
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
|
-
* A pend that failed. `conflict` marks the retryable class — an optimistic-concurrency collision
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
25
|
+
* A pend that failed. `conflict` marks the retryable class — an optimistic-concurrency collision that
|
|
26
|
+
* a re-read + re-pend can clear — as decided by `isConflictFailure` over the failure response. A hard
|
|
27
|
+
* rejection (storage full, policy) is NOT a conflict and is not worth re-driving. Thrown by
|
|
28
|
+
* {@link TransactionCoordinator.pendCollection} so the fan-out in pendPhase can settle it and read
|
|
29
|
+
* the flag off the rejection.
|
|
29
30
|
*/
|
|
30
31
|
class PendRejectedError extends Error {
|
|
31
|
-
constructor(
|
|
32
|
-
|
|
32
|
+
constructor(
|
|
33
|
+
collectionId: CollectionId,
|
|
34
|
+
readonly conflict: boolean,
|
|
35
|
+
reason?: string,
|
|
36
|
+
/** Confirmed revision the responder holds, from `StaleFailure.staleAt`. Folded into the
|
|
37
|
+
* message because pendPhase collapses this error to its `.message` string, which is the only
|
|
38
|
+
* form that reaches an embedder through the transaction result's `error` field. */
|
|
39
|
+
staleAt?: { blockId: BlockId; rev: number },
|
|
40
|
+
) {
|
|
41
|
+
super(`Pend failed for collection ${collectionId}: ${reason ?? (conflict ? 'stale conflict' : 'rejected')}`
|
|
42
|
+
+ (staleAt ? ` (block ${staleAt.blockId} at rev ${staleAt.rev})` : ''));
|
|
33
43
|
this.name = 'PendRejectedError';
|
|
34
44
|
}
|
|
35
45
|
}
|
|
@@ -181,9 +191,10 @@ export class TransactionCoordinator {
|
|
|
181
191
|
// Re-read fresh state before re-attempting so the next commit pends against current
|
|
182
192
|
// revisions (mirrors how Collection.sync calls updateInternal() before retrying).
|
|
183
193
|
// NOTE: refreshes EVERY registered collection, not only the participants of this
|
|
184
|
-
// transaction.
|
|
185
|
-
//
|
|
186
|
-
//
|
|
194
|
+
// transaction. Not free: a non-participant's update() throws CollectionHeaderVanishedError
|
|
195
|
+
// if its header momentarily reads absent while it holds a committed revision, aborting
|
|
196
|
+
// this retry. The registered set is small today; if that (or retry latency) ever bites,
|
|
197
|
+
// narrow this to the transaction's participating collections.
|
|
187
198
|
for (const collection of this.collections.values()) {
|
|
188
199
|
await collection.update();
|
|
189
200
|
}
|
|
@@ -929,11 +940,11 @@ export class TransactionCoordinator {
|
|
|
929
940
|
|
|
930
941
|
const pendResult = await this.transactor.pend(pendRequest);
|
|
931
942
|
if (!pendResult.success) {
|
|
932
|
-
//
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
|
|
936
|
-
throw new PendRejectedError(collectionId,
|
|
943
|
+
// Retryability comes from the response itself: a producer that classified the failure sets
|
|
944
|
+
// `conflict`, and only where no producer set it do we fall back to inferring from
|
|
945
|
+
// `missing`/`pending`. Either way a conflict is an optimistic-concurrency loss, clearable
|
|
946
|
+
// by a re-read; anything else is a hard rejection (storage/policy) that re-driving won't fix.
|
|
947
|
+
throw new PendRejectedError(collectionId, isConflictFailure(pendResult), pendResult.reason, pendResult.staleAt);
|
|
937
948
|
}
|
|
938
949
|
|
|
939
950
|
return { collectionId, blockIds: pendResult.blockIds };
|
|
@@ -1052,6 +1063,11 @@ export class TransactionCoordinator {
|
|
|
1052
1063
|
}
|
|
1053
1064
|
// Permanent stale failure: do not retry here. It IS a clean stale loss, though, so
|
|
1054
1065
|
// mark it retryable at the coordinator level (after a re-read advances the rev).
|
|
1066
|
+
// NOTE: deliberately does NOT consult `isConflictFailure` / `StaleFailure.conflict`
|
|
1067
|
+
// like the pend path does. Once the pend succeeded, a returned commit failure means
|
|
1068
|
+
// the revision slot moved, and no commit producer sets `conflict` today. If a commit
|
|
1069
|
+
// producer ever starts distinguishing hard commit rejections (validator policy,
|
|
1070
|
+
// storage fault) from lost races, gate `stale` on isConflictFailure here.
|
|
1055
1071
|
return {
|
|
1056
1072
|
collectionId,
|
|
1057
1073
|
committed: false,
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { peerIdFromString } from "../network/types.js";
|
|
2
2
|
import type { PeerId } from "../network/types.js";
|
|
3
|
-
import
|
|
3
|
+
import { highestStaleAt, isConflictFailure } from "../network/stale-failure.js";
|
|
4
|
+
import { BlockUnavailableError } from "../network/struct.js";
|
|
5
|
+
import type { ActionTransforms, ActionBlocks, BlockActionStatus, ITransactor, PendSuccess, StaleFailure, IKeyNetwork, BlockId, GetBlockResults, PendResult, CommitResult, PendRequest, IRepo, BlockGets, Transforms, CommitRequest, ActionId, RepoCommitRequest, ClusterNomineesResult, CollectionId, IBlock, CoordinatorIntent } from "../index.js";
|
|
4
6
|
import type { IBlockChangeNotifier, CollectionChangeListener } from "./change-notifier.js";
|
|
5
7
|
import { transformForBlockId, groupBy, concatTransforms, concatTransform, transformsFromTransform, blockIdsForTransforms, Log, Tracker, CacheSource, TransactorSource } from "../index.js";
|
|
6
8
|
import { blockIdToBytes } from "../utility/block-id-to-bytes.js";
|
|
@@ -106,11 +108,16 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
|
|
|
106
108
|
const t0 = Date.now();
|
|
107
109
|
log('get blockIds=%d', distinctBlockIds.length);
|
|
108
110
|
|
|
111
|
+
// `intent: 'read'` throughout this method: a read that can find no reachable
|
|
112
|
+
// coordinator may still be answered from the local replica (degraded but reported),
|
|
113
|
+
// where a write on the same evidence may not. See CoordinatorIntent.
|
|
109
114
|
const batches = await this.batchesForPayload<BlockId[], GetBlockResults>(
|
|
110
115
|
distinctBlockIds,
|
|
111
116
|
distinctBlockIds,
|
|
112
117
|
(gets, blockId, mergeWithGets) => [...(mergeWithGets ?? []), ...gets.filter(bid => bid === blockId)],
|
|
113
|
-
[]
|
|
118
|
+
[],
|
|
119
|
+
undefined,
|
|
120
|
+
'read'
|
|
114
121
|
);
|
|
115
122
|
|
|
116
123
|
const expiration = Date.now() + this.timeoutMs;
|
|
@@ -123,35 +130,40 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
|
|
|
123
130
|
batch => batch.payload,
|
|
124
131
|
(gets, blockId, mergeWithGets) => [...(mergeWithGets ?? []), ...gets.filter(bid => bid === blockId)],
|
|
125
132
|
expiration,
|
|
126
|
-
async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), options)
|
|
133
|
+
async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), { ...options, intent: 'read' })
|
|
127
134
|
);
|
|
128
135
|
} catch (e) {
|
|
129
136
|
error = e as Error;
|
|
130
137
|
}
|
|
131
138
|
|
|
132
139
|
// Second-chance retry: ONLY for a genuine no-response — a batch with no valid
|
|
133
|
-
// response,
|
|
134
|
-
// authoritative "absent" answer (a valid response that
|
|
135
|
-
// every requested block id, even one whose entry has only
|
|
136
|
-
// materialized `block`) is FINAL and must not retry. A block that
|
|
137
|
-
// does not exist yet surfaces as `{ state: {} }` (an entry that is
|
|
138
|
-
// retrying it doubles the round-trips on the common
|
|
139
|
-
// block exist?" probe. Cross-member reconciliation for a
|
|
140
|
-
// already happened one layer down: CoordinatorRepo.get detects
|
|
141
|
-
// consults cluster peers before it responds
|
|
142
|
-
//
|
|
143
|
-
//
|
|
140
|
+
// response, a response missing an entry for a requested block id, or an entry
|
|
141
|
+
// flagged `unavailable`. An authoritative "absent" answer (a valid response that
|
|
142
|
+
// carries an entry for every requested block id, even one whose entry has only
|
|
143
|
+
// `state` and no materialized `block`) is FINAL and must not retry. A block that
|
|
144
|
+
// genuinely does not exist yet surfaces as `{ state: {} }` (an entry that is
|
|
145
|
+
// present and unflagged) — retrying it doubles the round-trips on the common
|
|
146
|
+
// createOrOpen "does this block exist?" probe. Cross-member reconciliation for a
|
|
147
|
+
// missing block has already happened one layer down: CoordinatorRepo.get detects
|
|
148
|
+
// `isMissing` and consults cluster peers before it responds — and when that
|
|
149
|
+
// consult FAILS, the entry now says so via `unavailable` instead of posing as an
|
|
150
|
+
// authoritative absent. So by the time an unflagged absent reaches here there is
|
|
151
|
+
// nothing left for a transactor-level retry to discover, while a flagged entry
|
|
152
|
+
// earns the retry against a different peer that an absent deliberately does not.
|
|
153
|
+
// See tickets txn-perf-authoritative-notfound and repo-reports-unavailable-vs-absent.
|
|
144
154
|
const hasValidResponse = (b: CoordinatorBatch<BlockId[], GetBlockResults>) => {
|
|
145
155
|
return b.request?.isResponse === true && b.request.response != null;
|
|
146
156
|
};
|
|
147
157
|
|
|
148
158
|
// A batch is answered when its response carries an entry for EVERY requested
|
|
149
|
-
// block id
|
|
150
|
-
// "absent", which counts as
|
|
159
|
+
// block id and none of those entries is flagged `unavailable`. An entry present
|
|
160
|
+
// with only `state` (no `block`) is an authoritative "absent", which counts as
|
|
161
|
+
// answered — not a gap. An `unavailable` entry is the peer saying its own answer
|
|
162
|
+
// is a guess, so it does NOT count as answered.
|
|
151
163
|
const isAuthoritative = (b: CoordinatorBatch<BlockId[], GetBlockResults>) => {
|
|
152
164
|
if (!hasValidResponse(b)) return false;
|
|
153
165
|
const resp = b.request!.response! as GetBlockResults;
|
|
154
|
-
return b.payload.every(bid => resp[bid] !== undefined);
|
|
166
|
+
return b.payload.every(bid => resp[bid] !== undefined && resp[bid]!.unavailable === undefined);
|
|
155
167
|
};
|
|
156
168
|
|
|
157
169
|
// Retry only genuine no-response / partial-response batches. An authoritative
|
|
@@ -172,7 +184,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
|
|
|
172
184
|
b.payload,
|
|
173
185
|
(gets, blockId, mergeWithGets) => [...(mergeWithGets ?? []), ...gets.filter(id => id === blockId)],
|
|
174
186
|
Array.from(excluded),
|
|
175
|
-
async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), options)
|
|
187
|
+
async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), { ...options, intent: 'read' })
|
|
176
188
|
);
|
|
177
189
|
if (retries.length > 0) {
|
|
178
190
|
b.subsumedBy = [...(b.subsumedBy ?? []), ...retries];
|
|
@@ -182,7 +194,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
|
|
|
182
194
|
batch => batch.payload,
|
|
183
195
|
(gets, blockId, mergeWithGets) => [...(mergeWithGets ?? []), ...gets.filter(id => id === blockId)],
|
|
184
196
|
expiration,
|
|
185
|
-
async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), options)
|
|
197
|
+
async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), { ...options, intent: 'read' })
|
|
186
198
|
);
|
|
187
199
|
}
|
|
188
200
|
}));
|
|
@@ -199,16 +211,24 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
|
|
|
199
211
|
// Cache the completed batches that had actual responses (not just coordinator not found)
|
|
200
212
|
const completedBatches = Array.from(allBatches(batches, b => b.request?.isResponse as boolean && !isRecordEmpty(b.request!.response!)));
|
|
201
213
|
|
|
214
|
+
// Three-way ranking per block id: a materialized block beats an authoritative
|
|
215
|
+
// absent, which beats an `unavailable` guess — one peer that positively knows the
|
|
216
|
+
// block is absent outranks another that could not find out. Non-object junk ranks
|
|
217
|
+
// below everything so any real entry replaces it.
|
|
218
|
+
const rankOf = (r: unknown): number => {
|
|
219
|
+
if (!r || typeof r !== 'object') return -1;
|
|
220
|
+
const entry = r as GetBlockResults[BlockId];
|
|
221
|
+
if (entry.block != null) return 2;
|
|
222
|
+
return entry.unavailable === undefined ? 1 : 0;
|
|
223
|
+
};
|
|
224
|
+
|
|
202
225
|
// Create a lookup map from successful responses only
|
|
203
226
|
const resultEntries = new Map<string, any>();
|
|
204
227
|
for (const batch of completedBatches) {
|
|
205
228
|
const resp = batch.request!.response! as any;
|
|
206
229
|
for (const [bid, res] of Object.entries(resp)) {
|
|
207
230
|
const existing = resultEntries.get(bid);
|
|
208
|
-
|
|
209
|
-
const resHasBlock = res && typeof res === 'object' && 'block' in (res as any) && (res as any).block != null;
|
|
210
|
-
const existingHasBlock = existing && typeof existing === 'object' && 'block' in (existing as any) && (existing as any).block != null;
|
|
211
|
-
if (!existing || (resHasBlock && !existingHasBlock)) {
|
|
231
|
+
if (!existing || rankOf(res) > rankOf(existing)) {
|
|
212
232
|
resultEntries.set(bid, res);
|
|
213
233
|
}
|
|
214
234
|
}
|
|
@@ -245,6 +265,17 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
|
|
|
245
265
|
// Get block states from repos
|
|
246
266
|
const blockStates = await this.get({ blockIds: allBlockIds });
|
|
247
267
|
|
|
268
|
+
// A block whose repo could not determine whether it exists carries no status either:
|
|
269
|
+
// its empty `state` would read below as `aborted`, turning "I could not find out" into
|
|
270
|
+
// a definite verdict on someone's action. Fail loudly instead, like every other read of
|
|
271
|
+
// an unavailable block (see BlockUnavailableError).
|
|
272
|
+
for (const blockId of allBlockIds) {
|
|
273
|
+
const entry = blockStates[blockId];
|
|
274
|
+
if (entry?.unavailable !== undefined && entry.block == null) {
|
|
275
|
+
throw new BlockUnavailableError(blockId, entry.unavailable);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
248
279
|
// Determine status for each action ref
|
|
249
280
|
const results: BlockActionStatus[] = blockActions.map(ref => ({
|
|
250
281
|
...ref,
|
|
@@ -511,8 +542,33 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
|
|
|
511
542
|
const stale = Array.from(allBatches(batches, b => b.request?.isResponse as boolean && !b.request!.response!.success));
|
|
512
543
|
if (stale.length > 0) { // Any active stale failures should preempt reporting connection or other potential transient errors (we have information)
|
|
513
544
|
log('pend:stale actionId=%s staleCount=%d', blockAction.actionId, stale.length);
|
|
545
|
+
// Carry the first available reject reason through: `SyncRetryExhaustedError.lastReason`
|
|
546
|
+
// and the multi-collection writer's failure message both read it, and it is the only
|
|
547
|
+
// diagnostic that survives an exhausted retry budget.
|
|
548
|
+
const reason = stale.map(b => (b.request!.response! as StaleFailure).reason).find(r => r !== undefined);
|
|
549
|
+
// This response is REBUILT from the per-batch ones rather than forwarded, so
|
|
550
|
+
// retryability has to be carried explicitly or it is lost: a batch whose failure was
|
|
551
|
+
// a confirmed lost race can arrive with neither `missing` nor `pending` (see
|
|
552
|
+
// CoordinatorRepo.classifyStaleRejection), and the aggregate would then look like a
|
|
553
|
+
// hard rejection to `isConflictFailure`. Any conflicting batch makes the aggregate a
|
|
554
|
+
// conflict — the pend failed as a whole, and a re-read/rebase can clear it.
|
|
555
|
+
// NOTE: `some`, not `every`, so a pend whose batches mix a lost race with a genuine hard
|
|
556
|
+
// rejection is reported retryable and burns its (bounded, backed-off) retry budget before
|
|
557
|
+
// failing. Deliberate: an unclassified reason-only response from an older peer is
|
|
558
|
+
// indistinguishable from a hard rejection here, and `every` would refuse to retry a real
|
|
559
|
+
// race whenever one batch came from such a peer. Revisit if every producer sets `conflict`
|
|
560
|
+
// (then `every` is both safe and tighter), or if mixed-outcome pends show up as wasted
|
|
561
|
+
// retry latency in practice.
|
|
562
|
+
const conflict = stale.some(b => isConflictFailure(b.request!.response! as StaleFailure));
|
|
563
|
+
// Deliberately NOT first-wins like `reason` above — `highestStaleAt` takes the largest
|
|
564
|
+
// confirmed revision, which is the binding constraint on the client's next request.
|
|
565
|
+
// Its doc comment carries the rule and the one-pend-one-collection assumption it rests on.
|
|
566
|
+
const staleAt = highestStaleAt(stale.map(b => (b.request!.response! as StaleFailure).staleAt));
|
|
514
567
|
return {
|
|
515
568
|
success: false,
|
|
569
|
+
conflict,
|
|
570
|
+
...(reason === undefined ? {} : { reason }),
|
|
571
|
+
...(staleAt === undefined ? {} : { staleAt }),
|
|
516
572
|
missing: distinctBlockActionTransforms(stale.flatMap(b => (b.request!.response! as StaleFailure).missing).filter((x): x is ActionTransforms => x !== undefined)),
|
|
517
573
|
};
|
|
518
574
|
}
|
|
@@ -627,10 +683,17 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
|
|
|
627
683
|
const stale = Array.from(allBatches(tailBatches, b => b.request?.isResponse as boolean && !b.request!.response!.success));
|
|
628
684
|
if (stale.length > 0) {
|
|
629
685
|
// NOTE: a reason-only StaleFailure (success:false, no `missing`) lands here too and
|
|
630
|
-
// returns { missing: [], success:false } — the `reason`
|
|
631
|
-
// surfaced via `throw tailError`.
|
|
632
|
-
//
|
|
633
|
-
|
|
686
|
+
// returns { missing: [], success:false } — the `reason` PROSE is still dropped rather
|
|
687
|
+
// than surfaced via `throw tailError`. `staleAt` is carried, so the one machine-readable
|
|
688
|
+
// fact in that prose (which block is at which revision) now survives; only the free-form
|
|
689
|
+
// wording is lost. If the wording itself is ever needed, gate this branch on non-empty
|
|
690
|
+
// missing rather than reinstating it unconditionally.
|
|
691
|
+
const staleAt = highestStaleAt(stale.map(b => (b.request!.response! as StaleFailure).staleAt));
|
|
692
|
+
return {
|
|
693
|
+
missing: distinctBlockActionTransforms(stale.flatMap(b => (b.request!.response! as StaleFailure).missing).filter((x): x is ActionTransforms => x !== undefined)),
|
|
694
|
+
...(staleAt === undefined ? {} : { staleAt }),
|
|
695
|
+
success: false as const
|
|
696
|
+
};
|
|
634
697
|
}
|
|
635
698
|
throw tailError;
|
|
636
699
|
}
|
|
@@ -683,14 +746,16 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
|
|
|
683
746
|
getBlockPayload: (payload: TPayload, blockId: BlockId, mergeWithPayload: TPayload | undefined) => TPayload,
|
|
684
747
|
excludedPeers: PeerId[],
|
|
685
748
|
/** When set, prefer a coordinator this transaction already resolved at pend (see {@link resolveCoordinator}). */
|
|
686
|
-
actionId?: ActionId
|
|
749
|
+
actionId?: ActionId,
|
|
750
|
+
/** What the batches will be used for. Defaults to `'write'` — see {@link CoordinatorIntent}. */
|
|
751
|
+
intent: CoordinatorIntent = 'write'
|
|
687
752
|
): Promise<CoordinatorBatch<TPayload, TResponse>[]> {
|
|
688
753
|
return createBatchesForPayload<TPayload, TResponse>(
|
|
689
754
|
blockIds,
|
|
690
755
|
payload,
|
|
691
756
|
getBlockPayload,
|
|
692
757
|
excludedPeers,
|
|
693
|
-
async (blockId, options) => this.resolveCoordinator(blockId, options, actionId)
|
|
758
|
+
async (blockId, options) => this.resolveCoordinator(blockId, options, actionId, intent)
|
|
694
759
|
);
|
|
695
760
|
}
|
|
696
761
|
|
|
@@ -705,7 +770,8 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
|
|
|
705
770
|
private async resolveCoordinator(
|
|
706
771
|
blockId: BlockId,
|
|
707
772
|
options: { excludedPeers: PeerId[] },
|
|
708
|
-
actionId: ActionId | undefined
|
|
773
|
+
actionId: ActionId | undefined,
|
|
774
|
+
intent: CoordinatorIntent = 'write'
|
|
709
775
|
): Promise<PeerId> {
|
|
710
776
|
if (actionId !== undefined) {
|
|
711
777
|
const cached = this.txnCoordinatorCache.get(actionId)?.coordinators.get(blockId);
|
|
@@ -713,7 +779,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
|
|
|
713
779
|
return cached;
|
|
714
780
|
}
|
|
715
781
|
}
|
|
716
|
-
return this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), options);
|
|
782
|
+
return this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), { ...options, intent });
|
|
717
783
|
}
|
|
718
784
|
|
|
719
785
|
/**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomBytes } from '@noble/hashes/utils.js'
|
|
2
2
|
import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
|
|
3
3
|
import type { IBlock, BlockId, BlockHeader, ITransactor, ActionId, StaleFailure, ActionContext, BlockType, BlockSource, ReadPurpose, Transforms } from "../index.js";
|
|
4
|
+
import { BlockUnavailableError } from "../network/struct.js";
|
|
4
5
|
import type { ReadDependency } from "../transaction/transaction.js";
|
|
5
6
|
import { ReadDependencyCollector } from "../transaction/read-dependency-collector.js";
|
|
6
7
|
|
|
@@ -42,7 +43,14 @@ export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlo
|
|
|
42
43
|
// `result[id]` is undefined. Destructuring that would throw a TypeError.
|
|
43
44
|
const entry = result?.[id];
|
|
44
45
|
if (entry) {
|
|
45
|
-
const { block, state } = entry;
|
|
46
|
+
const { block, state, unavailable } = entry;
|
|
47
|
+
// An entry flagged `unavailable` with no block is the repo saying "I could not find
|
|
48
|
+
// out whether this exists" — an answer that must not be read as absent. Throw rather
|
|
49
|
+
// than return undefined, and record no read dependency (dependencies are recorded
|
|
50
|
+
// only for blocks that actually exist). A repo that omits the flag stays authoritative.
|
|
51
|
+
if (!block && unavailable) {
|
|
52
|
+
throw new BlockUnavailableError(id, unavailable);
|
|
53
|
+
}
|
|
46
54
|
// Record a read dependency only for a block that actually exists. A transactor may return a
|
|
47
55
|
// populated entry with `block: undefined` for a genuinely-missing block (TestTransactor does;
|
|
48
56
|
// the Network transactor always populates the key); recording there would add a phantom
|