@serfab/cadre-core 1.15.0 → 1.17.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 +1 -1
- package/dist/bootstrap-peer-store-file.js +1 -1
- package/dist/bootstrap-peer-store.d.ts +1 -1
- package/dist/bootstrap-peer-store.js +4 -4
- package/dist/cadre-invite-protocol.d.ts +55 -138
- package/dist/cadre-invite-protocol.js +94 -449
- package/dist/cadre-invite-protocol.js.map +1 -1
- package/dist/cadre-join-request.d.ts +15 -2
- package/dist/cadre-join-request.js +22 -3
- package/dist/cadre-join-request.js.map +1 -1
- package/dist/cadre-node.d.ts +530 -247
- package/dist/cadre-node.js +1046 -525
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-authorization.d.ts +36 -14
- package/dist/control-authorization.js +20 -3
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.d.ts +22 -23
- package/dist/control-cohort.js +24 -24
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +416 -136
- package/dist/control-database.js +757 -378
- package/dist/control-database.js.map +1 -1
- package/dist/control-protocol-guard.d.ts +5 -4
- package/dist/control-protocol-guard.js +6 -3
- package/dist/control-protocol-guard.js.map +1 -1
- package/dist/control-schema-version.d.ts +80 -0
- package/dist/control-schema-version.js +203 -0
- package/dist/control-schema-version.js.map +1 -0
- package/dist/control-schema.d.ts +45 -5
- package/dist/control-schema.js +254 -70
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +5 -5
- package/dist/control-write-retry.js +10 -8
- package/dist/control-write-retry.js.map +1 -1
- package/dist/delegate-admission.d.ts +13 -0
- package/dist/delegate-admission.js +27 -0
- package/dist/delegate-admission.js.map +1 -1
- package/dist/ed25519-key.d.ts +10 -7
- package/dist/ed25519-key.js +23 -20
- package/dist/ed25519-key.js.map +1 -1
- package/dist/edge-node/config.d.ts +122 -0
- package/dist/edge-node/config.js +199 -0
- package/dist/edge-node/config.js.map +1 -0
- package/dist/edge-node/dissolve.d.ts +95 -0
- package/dist/edge-node/dissolve.js +184 -0
- package/dist/edge-node/dissolve.js.map +1 -0
- package/dist/edge-node/edge-join.d.ts +44 -0
- package/dist/edge-node/edge-join.js +227 -0
- package/dist/edge-node/edge-join.js.map +1 -0
- package/dist/edge-node/edge-node.d.ts +295 -0
- package/dist/edge-node/edge-node.js +704 -0
- package/dist/edge-node/edge-node.js.map +1 -0
- package/dist/edge-node/index.d.ts +18 -0
- package/dist/edge-node/index.js +18 -0
- package/dist/edge-node/index.js.map +1 -0
- package/dist/edge-node/join-credential.d.ts +11 -0
- package/dist/edge-node/join-credential.js +11 -0
- package/dist/edge-node/join-credential.js.map +1 -0
- package/dist/edge-node/lazy-raw-storage.d.ts +40 -0
- package/dist/edge-node/lazy-raw-storage.js +134 -0
- package/dist/edge-node/lazy-raw-storage.js.map +1 -0
- package/dist/edge-node/node-local.d.ts +60 -0
- package/dist/edge-node/node-local.js +58 -0
- package/dist/edge-node/node-local.js.map +1 -0
- package/dist/edge-node/options.d.ts +83 -0
- package/dist/edge-node/options.js +110 -0
- package/dist/edge-node/options.js.map +1 -0
- package/dist/edge-node/owner-grant.d.ts +41 -0
- package/dist/edge-node/owner-grant.js +38 -0
- package/dist/edge-node/owner-grant.js.map +1 -0
- package/dist/edge-node/platform.d.ts +140 -0
- package/dist/edge-node/platform.js +12 -0
- package/dist/edge-node/platform.js.map +1 -0
- package/dist/edge-node/strand-move.d.ts +115 -0
- package/dist/edge-node/strand-move.js +223 -0
- package/dist/edge-node/strand-move.js.map +1 -0
- package/dist/edge-node/strands.d.ts +28 -0
- package/dist/edge-node/strands.js +60 -0
- package/dist/edge-node/strands.js.map +1 -0
- package/dist/index.d.ts +16 -7
- package/dist/index.js +26 -7
- package/dist/index.js.map +1 -1
- package/dist/joined-strand-store.d.ts +3 -3
- package/dist/joined-strand-store.js +3 -3
- package/dist/joined-strand-store.js.map +1 -1
- package/dist/key-store.d.ts +9 -4
- package/dist/key-store.js +9 -4
- package/dist/key-store.js.map +1 -1
- package/dist/machine-removal.d.ts +19 -39
- package/dist/machine-removal.js +24 -98
- package/dist/machine-removal.js.map +1 -1
- package/dist/member-redemption.d.ts +250 -0
- package/dist/member-redemption.js +485 -0
- package/dist/member-redemption.js.map +1 -0
- package/dist/owner-anchor-sync.d.ts +4 -4
- package/dist/owner-anchor-sync.js +16 -15
- package/dist/owner-anchor-sync.js.map +1 -1
- package/dist/owner-grant-protocol.d.ts +247 -0
- package/dist/owner-grant-protocol.js +330 -0
- package/dist/owner-grant-protocol.js.map +1 -0
- package/dist/owner-key-export.d.ts +70 -0
- package/dist/owner-key-export.js +279 -0
- package/dist/owner-key-export.js.map +1 -0
- package/dist/owner-key-flows.d.ts +55 -0
- package/dist/owner-key-flows.js +60 -0
- package/dist/owner-key-flows.js.map +1 -0
- package/dist/owner-key-store.d.ts +31 -0
- package/dist/owner-key-store.js +81 -0
- package/dist/owner-key-store.js.map +1 -0
- package/dist/owner-keys.d.ts +78 -0
- package/dist/owner-keys.js +133 -0
- package/dist/owner-keys.js.map +1 -0
- package/dist/owner-signer.d.ts +54 -0
- package/dist/owner-signer.js +51 -0
- package/dist/owner-signer.js.map +1 -0
- package/dist/peer-authorization.d.ts +83 -18
- package/dist/peer-authorization.js +123 -53
- package/dist/peer-authorization.js.map +1 -1
- package/dist/provider/rent-node.js +1 -1
- package/dist/provider/rent-node.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +131 -109
- package/dist/seed-bootstrap.js +260 -248
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.js +4 -4
- package/dist/strand-addr-protocol.d.ts +4 -1
- package/dist/strand-addr-protocol.js +8 -2
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-cohort.d.ts +3 -0
- package/dist/strand-cohort.js.map +1 -1
- package/dist/strand-database.d.ts +1 -0
- package/dist/strand-database.js +3 -0
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +4 -3
- package/dist/strand-first-sync-gate.js +4 -3
- package/dist/strand-first-sync-gate.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +1 -1
- package/dist/strand-membership-writer.d.ts +13 -2
- package/dist/strand-membership-writer.js +52 -16
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-move.d.ts +6 -5
- package/dist/strand-move.js +31 -30
- package/dist/strand-move.js.map +1 -1
- package/dist/strand-peer-redial.js +2 -3
- package/dist/strand-peer-redial.js.map +1 -1
- package/dist/types.d.ts +286 -103
- package/dist/types.js.map +1 -1
- package/package.json +16 -7
- package/src/bootstrap-peer-store-file.ts +44 -44
- package/src/bootstrap-peer-store.ts +214 -214
- package/src/cadre-invite-protocol.ts +131 -531
- package/src/cadre-join-request.ts +188 -167
- package/src/cadre-node.ts +1166 -541
- package/src/control-authorization.ts +47 -14
- package/src/control-cohort.ts +127 -128
- package/src/control-database.ts +940 -456
- package/src/control-protocol-guard.ts +7 -4
- package/src/control-schema-version.ts +251 -0
- package/src/control-schema.ts +265 -70
- package/src/control-write-retry.ts +569 -567
- package/src/delegate-admission.ts +37 -0
- package/src/ed25519-key.ts +174 -171
- package/src/edge-node/config.ts +277 -0
- package/src/edge-node/dissolve.ts +251 -0
- package/src/edge-node/edge-join.ts +319 -0
- package/src/edge-node/edge-node.ts +1045 -0
- package/src/edge-node/index.ts +59 -0
- package/src/edge-node/join-credential.ts +16 -0
- package/src/edge-node/lazy-raw-storage.ts +178 -0
- package/src/edge-node/node-local.ts +84 -0
- package/src/edge-node/options.ts +179 -0
- package/src/edge-node/owner-grant.ts +63 -0
- package/src/edge-node/platform.ts +151 -0
- package/src/edge-node/strand-move.ts +270 -0
- package/src/edge-node/strands.ts +72 -0
- package/src/index.ts +102 -5
- package/src/joined-strand-store.ts +4 -4
- package/src/key-store.ts +10 -4
- package/src/machine-removal.ts +26 -111
- package/src/member-redemption.ts +642 -0
- package/src/owner-anchor-sync.ts +19 -18
- package/src/owner-grant-protocol.ts +532 -0
- package/src/owner-key-export.ts +348 -0
- package/src/owner-key-flows.ts +109 -0
- package/src/owner-key-store.ts +85 -0
- package/src/owner-keys.ts +178 -0
- package/src/owner-signer.ts +94 -0
- package/src/peer-authorization.ts +558 -488
- package/src/provider/rent-node.ts +1 -1
- package/src/seed-bootstrap.ts +301 -312
- package/src/seed-trust-policy.ts +4 -4
- package/src/strand-addr-protocol.ts +547 -538
- package/src/strand-cohort.ts +4 -0
- package/src/strand-database.ts +3 -0
- package/src/strand-first-sync-gate.ts +4 -3
- package/src/strand-instance-manager.ts +1 -1
- package/src/strand-membership-writer.ts +61 -16
- package/src/strand-move.ts +45 -36
- package/src/strand-peer-redial.ts +2 -3
- package/src/types.ts +304 -103
|
@@ -1,567 +1,569 @@
|
|
|
1
|
-
import { CoordinatorPartialCommitError, SyncRetryExhaustedError, TornActionError } from '@optimystic/db-core';
|
|
2
|
-
import { PartialCommitError } from '@optimystic/quereus-plugin-optimystic';
|
|
3
|
-
import { causeChain, chainMessages, retryControlOperation } from './control-retry.js';
|
|
4
|
-
import type { ControlRetryOptions } from './control-retry.js';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Bounded retry for TRANSIENT control-write failures — the classifier and loop behind
|
|
8
|
-
* `ControlDatabase.lockedWithRetry`, the single funnel every local control write passes
|
|
9
|
-
* through.
|
|
10
|
-
*
|
|
11
|
-
* Why this exists: the control database replicates every block to the whole party, so a
|
|
12
|
-
* three-machine party commits control writes unanimously (`ceil(3 × 0.75) = 3` approvals) —
|
|
13
|
-
* and no `superMajorityThreshold` can lower that bar without violating Optimystic's
|
|
14
|
-
* partition-safety condition at the shipped admission fraction (the arithmetic lives in
|
|
15
|
-
* `docs/architecture.md` → "Replication cluster size"). Cadre therefore accepts unanimity
|
|
16
|
-
* and absorbs the transient half of its cost here: a write that failed because the cohort
|
|
17
|
-
* did not ANSWER (a stream reset mid-connection-formation, zero approvals) is re-presented
|
|
18
|
-
* a moment later, which the degraded-cohort-member scenario measured as safe (a failed
|
|
19
|
-
* write rolls back, nothing half-commits) and effective (the next write commits in ~1 s).
|
|
20
|
-
*
|
|
21
|
-
* A write somebody actually REJECTED is never retried on the strength of the rejection itself —
|
|
22
|
-
* no matcher here claims a rejection message, at any phase, because re-presenting one would
|
|
23
|
-
* re-present a spent signature against a cohort that already said no. Rejections are re-presented
|
|
24
|
-
* anyway when they arrive inside the transactor's promise-phase `[block:` aggregate, which
|
|
25
|
-
* {@link isUncommittedTransactorAggregate} claims on the WRAPPER alone, whatever the cause inside
|
|
26
|
-
* says. Safe because nothing has committed at that phase, and deliberately kept — one of the
|
|
27
|
-
* remaining promise-phase rejections is exactly the kind a re-presentation fixes. See the
|
|
28
|
-
* accepted-tradeoff `NOTE:` there for which, and why no blanket rejection veto is wanted.
|
|
29
|
-
*
|
|
30
|
-
* A failure that says some of the write may be STORED is a different matter, and never
|
|
31
|
-
* re-presented whatever text it carries: {@link reportsPossiblyStoredWrite} vetoes it by type
|
|
32
|
-
* before any matcher runs. The one half-landed write that IS re-presented is a torn write the
|
|
33
|
-
* library marks final — not saved and unable to land ({@link isFinalTornWrite}).
|
|
34
|
-
*
|
|
35
|
-
* TWO policies ship from here, and the split is deliberate: the default
|
|
36
|
-
* ({@link isRetriableControlWriteFailure}, {@link CONTROL_WRITE_ATTEMPTS}) sits under every
|
|
37
|
-
* control write, and {@link SCHEMA_INIT_RETRY_POLICY} is opted into by
|
|
38
|
-
* `ControlDatabase.
|
|
39
|
-
* re-run safety holds for schema DDL and NOT for writes in general; anything added to the default
|
|
40
|
-
* list must be safe for all ~19 callers.
|
|
41
|
-
*
|
|
42
|
-
* Lives outside `control-database.ts` so a spec can drive the classifier against real engine
|
|
43
|
-
* errors without importing the whole database class. The loop itself lives in
|
|
44
|
-
* `control-retry.ts`, shared with the READ policy (`control-read-retry.ts`) — this module
|
|
45
|
-
* owns the write policies: their classifiers, attempt counts, backoff and budget.
|
|
46
|
-
*/
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Attempts allowed per control write: the first, plus two retries of a transiently-failed
|
|
50
|
-
* cluster commit. Bounded so a genuinely unreachable cohort terminates in the transactor's
|
|
51
|
-
* own error rather than spinning; two retries covers the observed failure (a stream still
|
|
52
|
-
* forming when the write fired) with margin.
|
|
53
|
-
*/
|
|
54
|
-
export const CONTROL_WRITE_ATTEMPTS = 3;
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Attempts allowed for {@link SCHEMA_INIT_RETRY_POLICY}, the one call site that also absorbs a
|
|
58
|
-
* self-coordination refusal.
|
|
59
|
-
*
|
|
60
|
-
* Two more than {@link CONTROL_WRITE_ATTEMPTS} because what this retry buys is wall-clock for a
|
|
61
|
-
* cold-starting node's bootstrap dials to land, and because the refusal is cheap to re-provoke
|
|
62
|
-
* relative to a cluster round-trip: `findCoordinator` already spends its own bounded wait before
|
|
63
|
-
* raising (3 attempts, 500 ms apart, `db-p2p/src/libp2p-key-network.ts` ~422) and then decides
|
|
64
|
-
* LOCALLY that it may not elect itself, so no peer is contacted and no cohort deadline is
|
|
65
|
-
* consumed.
|
|
66
|
-
*
|
|
67
|
-
* Note that the ~1 s `findCoordinator` spends internally is real, on top of this policy's own
|
|
68
|
-
* backoff — which is exactly why {@link CONTROL_WRITE_RETRY_BUDGET_MS} and not this count is what
|
|
69
|
-
* actually terminates the loop in the worst case.
|
|
70
|
-
*/
|
|
71
|
-
export const SCHEMA_INIT_ATTEMPTS = 5;
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Elapsed-time ceiling on the whole retry loop, measured from the START of the first
|
|
75
|
-
* attempt and checked after a failed attempt BEFORE sleeping.
|
|
76
|
-
*
|
|
77
|
-
* This budget — not the attempt count — is what makes the policy safe to sit under every
|
|
78
|
-
* control write. A transient failure (stream reset while a connection is still forming)
|
|
79
|
-
* surfaces in well under a second, so it is retried and the loop adds at most ~2.2 s. A
|
|
80
|
-
* genuinely silent cohort member costs ~21 s per consensus round (two `ClusterClient`
|
|
81
|
-
* response-deadline attempts, 10.5 s each at the default declared link): one pend round plus
|
|
82
|
-
* the cancel discharge's three rounds, about 84 s for one write in
|
|
83
|
-
* `control-write-degraded-cohort-member.integration.ts`, which already exceeds this budget
|
|
84
|
-
* when attempt 1 returns — so that case is surfaced immediately and retry adds ZERO latency
|
|
85
|
-
* to the case where it cannot help.
|
|
86
|
-
*
|
|
87
|
-
* It cuts the loop off by design, so it does not grow with the link: its job is to end the
|
|
88
|
-
* retries of slow attempts, and the caller gets the last attempt's error unchanged. An attempt
|
|
89
|
-
* whose read phase consulted a silent peer ends at about one per-peer read deadline
|
|
90
|
-
* (`cohortReadDeadlineMs` in `link-budget.ts`: 7 s at the default declared link, 5 s before it
|
|
91
|
-
* was derived, 1 s at Optimystic's own default), so it still gets one retry inside this budget
|
|
92
|
-
* where it once got two; an attempt that runs into the `ClusterClient` response deadline
|
|
93
|
-
* (10.5 s at the default declared link) still gets none. See `docs/cadre-consistency.md` →
|
|
94
|
-
* "Deadlines Over Optimystic's Reads and Commits".
|
|
95
|
-
*
|
|
96
|
-
* NOTE: a failed commit attempt also pays a cancel discharge before its error returns
|
|
97
|
-
* (optimystic `TransactorSource.transact`'s catch) — bounded by six rounds and
|
|
98
|
-
* `abortOrCancelTimeoutMs`, which every collection this repo opens sets to the larger of 5 s
|
|
99
|
-
* and the RPC dial deadline: 42.5 s at the default declared link (`optimysticDialLimits` in
|
|
100
|
-
* `link-budget.ts`;
|
|
101
|
-
* `../optimystic/packages/quereus-plugin-optimystic/src/optimystic-adapter/collection-factory.ts`).
|
|
102
|
-
* One cancel that runs its full budget therefore ends the retries on its own, cutting the
|
|
103
|
-
* three-attempt policy to however many attempts came before it. A cancel only runs that long
|
|
104
|
-
* when a peer stays silent, and that attempt has already spent its pend round past this
|
|
105
|
-
* budget, so the cut loses nothing there. After a transient fault the cancel is either
|
|
106
|
-
* answered or gives up after its six rounds against a dead transport, in about a second
|
|
107
|
-
* either way (optimystic `NetworkTransactor.MAX_CANCEL_ROUNDS`). Not observed — every
|
|
108
|
-
* measured round of the transient-reset case committed on attempt 3 of 3 — so this is a
|
|
109
|
-
* condition to watch, not work to do: if that case ever starts failing with
|
|
110
|
-
* `failed after 2/3 attempt(s)`, the cancel discharge is where the time went.
|
|
111
|
-
*/
|
|
112
|
-
// eslint-disable-next-line no-restricted-syntax -- cuts off by design: it ends the retries of slow attempts rather than waiting them out; see docs/cadre-consistency.md → "Deadlines Over Optimystic's Reads and Commits"
|
|
113
|
-
export const CONTROL_WRITE_RETRY_BUDGET_MS = 10_000;
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Backoff before retry N, jittered ±50% (so the first sleep is 125–375 ms — the jitter can
|
|
117
|
-
* never floor a delay to zero). Each single sleep is additionally capped at the LARGEST
|
|
118
|
-
* delay in the list, so a `stop()` racing a backoff is never held up by more than ~1 s.
|
|
119
|
-
* There is no `AbortSignal` at this seam to cancel a sleep outright; the cap is the
|
|
120
|
-
* substitute, deliberately not plumbing one through every write path.
|
|
121
|
-
*/
|
|
122
|
-
const CONTROL_WRITE_RETRY_DELAYS_MS: readonly number[] = [250, 1_000];
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Backoff for {@link SCHEMA_INIT_RETRY_POLICY}, jittered and capped the same way. Worst case
|
|
126
|
-
* ~4.6 s of sleep across four retries (375 + 750 + 1500 + 2000, each capped at the largest base),
|
|
127
|
-
* which stays inside {@link CONTROL_WRITE_RETRY_BUDGET_MS} — the budget still terminates the loop
|
|
128
|
-
* first if any single attempt is itself slow.
|
|
129
|
-
*
|
|
130
|
-
* Sized against measured facts, not guessed:
|
|
131
|
-
*
|
|
132
|
-
* - the self-coordination guard clears the moment the node has ONE connection again (both
|
|
133
|
-
* branches that raise `grace-period-not-elapsed` require `getConnections().length === 0`), not
|
|
134
|
-
* when the 30 s grace period expires — so this does NOT need to cover the grace period, only
|
|
135
|
-
* the gap until a bootstrap dial completes;
|
|
136
|
-
* - the guard's 30 s `gracePeriodMs` cannot be tuned from this repo anyway: no caller in either
|
|
137
|
-
* repo passes a `SelfCoordinationConfig`, so the default is always in force;
|
|
138
|
-
* - a node that has NEVER connected is waved through as a bootstrap node (network high-water
|
|
139
|
-
* mark of 1), so this retry only ever delays a node that HAS seen peers. One whose peers are
|
|
140
|
-
* gone for good still fails, ~4.6 s later — which is why the budget stays at the shared
|
|
141
|
-
* ceiling rather than stretching to cover the full 30 s window.
|
|
142
|
-
*/
|
|
143
|
-
const SCHEMA_INIT_RETRY_DELAYS_MS: readonly number[] = [250, 500, 1_000, 2_000];
|
|
144
|
-
|
|
145
|
-
/** The network transactor's aggregate, raised when some cohort peers gave no usable answer. */
|
|
146
|
-
const TRANSACTOR_AGGREGATE = /Some peers did not complete:/;
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* How the aggregate's per-batch details name a SINGLE-block batch — the shape both the block
|
|
150
|
-
* read (`get`) and phase 1 (`pend`) format, and the only shape safe to re-present.
|
|
151
|
-
*/
|
|
152
|
-
const SINGLE_BLOCK_BATCH_TOKEN = '[block:';
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* How the aggregate's per-batch details name a MULTI-block batch — the shape phase 2
|
|
156
|
-
* (`commitBlocks`) formats, and the marker of an outcome too indeterminate to retry.
|
|
157
|
-
*/
|
|
158
|
-
const COMMIT_BATCH_TOKEN = '[blocks:';
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* The cluster coordinator's super-majority shortfall (`db-p2p/src/repo/cluster-coordinator.ts`),
|
|
162
|
-
* matched ONLY with a ZERO rejection count. The same message with a non-zero count means a
|
|
163
|
-
* member actually voted no (that branch carries `membership-not-admitted` rejections), and
|
|
164
|
-
* retrying it would re-present a spent signature to a cohort that already refused it. This one
|
|
165
|
-
* is raised while collecting PROMISES, before any commit, so re-presenting it is safe.
|
|
166
|
-
*
|
|
167
|
-
* This regex itself never matches a decisive rejection (`ValidatorRejectionError`, `Transaction
|
|
168
|
-
* rejected by validators`) — that text carries no `Failed to get super-majority:` prefix. But the
|
|
169
|
-
* composite classifier is not this matcher alone: a rejection raised while promises are still
|
|
170
|
-
* being collected reaches this repo wrapped in the transactor's `Some peers did not complete:
|
|
171
|
-
* …[block:…]` aggregate, and {@link isUncommittedTransactorAggregate} matches that wrapper on its
|
|
172
|
-
* own, whatever the cause inside it says — see the accepted-tradeoff `NOTE:` there.
|
|
173
|
-
*/
|
|
174
|
-
const SUPER_MAJORITY_SHORTFALL_UNANSWERED =
|
|
175
|
-
/Failed to get super-majority: \d+\/\d+ approvals \(needed \d+, 0 rejections\)/;
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Is this a transactor aggregate from a phase where nothing can have committed yet?
|
|
179
|
-
*
|
|
180
|
-
* The transactor raises `Some peers did not complete:` from three sites
|
|
181
|
-
* (`db-core/src/transactor/network-transactor.ts`), and only two of them are safe to
|
|
182
|
-
* re-present:
|
|
183
|
-
*
|
|
184
|
-
* - `get` (a block read, which a write body also performs) and `pend` (phase 1) fail before
|
|
185
|
-
* anything commits, so the write is known not to have landed;
|
|
186
|
-
* - `commitBlocks` (phase 2) is REACHABLE here and NOT safe. `commitBlock` throws the
|
|
187
|
-
* aggregate when a header/tail commit got NO response at all — precisely the transient
|
|
188
|
-
* class this retry targets — and that escapes `commit()`, survives `TransactorSource`'s
|
|
189
|
-
* cancel-and-rethrow (cancel reaches PENDING actions only; a peer that already committed
|
|
190
|
-
* stays committed) and `Collection.syncInternal` (which retries StaleFailure RETURN values,
|
|
191
|
-
* not throws), reaching this funnel QuereusError-wrapped. The tail commit is one batch to
|
|
192
|
-
* one coordinator running consensus internally, so a no-response there is INDETERMINATE:
|
|
193
|
-
* the commit may have completed with only the response lost. Re-running the write body over
|
|
194
|
-
* a write that landed turns a success into a constraint failure (e.g.
|
|
195
|
-
* `UNIQUE constraint failed: CadrePeer.PeerId`). Surfacing the transient error instead lets
|
|
196
|
-
* the caller re-read committed state and decide. Commit-phase messages are vetoed by
|
|
197
|
-
* {@link reportsIndeterminateCommit}, not here.
|
|
198
|
-
*
|
|
199
|
-
* The `cause` chain does not discriminate the phases — a commit-phase aggregate carries the
|
|
200
|
-
* same stream-reset/dial error a pend-phase one does. The per-batch DETAIL text does: `get`
|
|
201
|
-
* and `pend` format `<peerId>[block:<id>](<status>)`, `commitBlocks` formats
|
|
202
|
-
* `<peerId>[blocks:<count>](<status>)`. `[block:` cannot occur inside `[blocks:`, so the two
|
|
203
|
-
* tokens are disjoint and separate the phases.
|
|
204
|
-
*
|
|
205
|
-
* NOTE: the discriminator is a formatting detail of another repo. If Optimystic ever reformats
|
|
206
|
-
* those per-batch details, this fails CLOSED — the aggregate stops matching and control writes
|
|
207
|
-
* simply stop being retried, silently losing the absorption rather than doing anything unsafe.
|
|
208
|
-
* The guard against that is live: `control-write-degraded-cohort-member.integration.ts` asserts
|
|
209
|
-
* this classifier against the real degraded-cohort failure object and asserts the `[block:`
|
|
210
|
-
* token on the live aggregate; if it reddens there, an upstream reformat is the first thing to
|
|
211
|
-
* check.
|
|
212
|
-
*
|
|
213
|
-
* NOTE: the discriminator is the prefix AND the token within ONE message — never relax it to
|
|
214
|
-
* "contains `[block:`". Beyond the `get` and `pend` sites above, Optimystic renders `[block:` in
|
|
215
|
-
* one further place — a third formatter, outside the aggregate's three sites — `dischargeCancel`
|
|
216
|
-
* (`Cancel of action <id> did not discharge <n> block(s): …`), which does not raise the
|
|
217
|
-
* `Some peers did not complete:` prefix; only the conjunction keeps a cancel fault from
|
|
218
|
-
* classifying as a retriable get/pend (checked against optimystic `c56c2bd4`, 2026-09-16).
|
|
219
|
-
*
|
|
220
|
-
* NOTE: accepted tradeoff — a REJECTION raised while promises are still being collected reaches
|
|
221
|
-
* this repo wrapped in this same aggregate, and this matcher claims it on the wrapper alone,
|
|
222
|
-
* whatever the cause inside says. That re-presents such a rejection up to two more times inside
|
|
223
|
-
* the 10 s budget. Kept deliberately, and re-decided on 2026-09-17 against the current upstream
|
|
224
|
-
* behaviour rather than inherited:
|
|
225
|
-
*
|
|
226
|
-
* - Contention is no longer a rejection at all. "Another write holds this block right now"
|
|
227
|
-
* (`pending conflict`) is now a `held` verdict that counts toward neither approvals nor
|
|
228
|
-
* rejections (`validatePendOperations`,
|
|
229
|
-
* `../optimystic/packages/db-p2p/src/cluster/cluster-repo.ts`), so the rejection this tradeoff
|
|
230
|
-
* was originally written about does not arrive any more. It reaches this funnel as
|
|
231
|
-
* `SyncRetryExhaustedError` instead, which {@link reportsPossiblyStoredWrite} declines by type
|
|
232
|
-
* — upstream documents resubmitting after it as unsafe (the attempt that spent the budget may
|
|
233
|
-
* have left its log entry standing), and its collection sync has already spent ten retries on
|
|
234
|
-
* the race. The torn-write rule beside it: a `TornActionError` is re-presented only when
|
|
235
|
-
* `final` is true; any other torn write is declined.
|
|
236
|
-
* - What remains rejectable at the promise phase is stale revision, block-unavailable,
|
|
237
|
-
* membership-not-admitted, and a configured validator's refusal. Only the first is worth a
|
|
238
|
-
* retry — and it is worth one: this loop re-runs the WHOLE write body, reads included
|
|
239
|
-
* (`ControlDatabase.withWriteLock`'s contract), so attempt 2 presents a pend against the
|
|
240
|
-
* revision that made attempt 1 stale. A blanket "decline any chain reporting a validator
|
|
241
|
-
* rejection" rule would remove that retry to save two wasted attempts on the other three.
|
|
242
|
-
*
|
|
243
|
-
* Revisit if a re-presentation ever becomes expensive relative to what it buys — a rejection class
|
|
244
|
-
* that costs a full cohort round-trip to re-present, or a write body whose reads are no longer
|
|
245
|
-
* cheap — or when upstream offers a typed refusal surface to classify on instead of this text
|
|
246
|
-
* (`../optimystic/tickets/backlog/debt-a-downstream-repo-classifies-retries-by-parsing-our-error-text`).
|
|
247
|
-
* Contention is no longer the reason to revisit.
|
|
248
|
-
*
|
|
249
|
-
* An aggregate whose details came out EMPTY (possible when `formatBatchStatuses` has no
|
|
250
|
-
* batches to format) carries neither token, matches nothing here, and is not retried — an
|
|
251
|
-
* unattributable failure is not a proven non-commit.
|
|
252
|
-
*
|
|
253
|
-
* Exported for the READ classifier (`control-read-retry.ts`), which reuses it verbatim: a
|
|
254
|
-
* `get` batch formats the same single-block `[block:` token, and a read can never produce
|
|
255
|
-
* the commit-phase `[blocks:` shape at all.
|
|
256
|
-
*/
|
|
257
|
-
export function isUncommittedTransactorAggregate(message: string): boolean {
|
|
258
|
-
return TRANSACTOR_AGGREGATE.test(message) && message.includes(SINGLE_BLOCK_BATCH_TOKEN);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Does ANY message in the failure's `cause` chain describe a commit-phase batch — i.e. an
|
|
263
|
-
* outcome nobody can call committed or not?
|
|
264
|
-
*
|
|
265
|
-
* Vetoes the whole chain rather than the one message carrying the token, and vetoes it
|
|
266
|
-
* regardless of which matcher would otherwise have claimed it. The narrower per-message rule
|
|
267
|
-
* would already catch every shape the transactor is known to build (a commit-phase aggregate
|
|
268
|
-
* emits `[blocks:` for every batch it formats, and embeds its cause's message inline as
|
|
269
|
-
* `root: …`, so both tokens land in one string) — but "known to build" is an argument about
|
|
270
|
-
* another repo's error assembly, and this way an indeterminate commit anywhere in the chain
|
|
271
|
-
* costs a retry we could have had instead of risking a re-run over a write that landed.
|
|
272
|
-
*/
|
|
273
|
-
function reportsIndeterminateCommit(messages: readonly string[]): boolean {
|
|
274
|
-
return messages.some(message => message.includes(COMMIT_BATCH_TOKEN));
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* Does any link of the `cause` chain say some of this write may be stored, or may still be?
|
|
279
|
-
* Such a failure is never re-presented — a re-run could store the write twice.
|
|
280
|
-
*
|
|
281
|
-
* - `SyncRetryExhaustedError` (and its `SyncRevisionStalledError` subclass): upstream documents
|
|
282
|
-
* resubmitting as unsafe, because the attempt that spent the budget may have left its log
|
|
283
|
-
* entry standing.
|
|
284
|
-
* - `TornActionError` without `final: true`: the write's log entry is stored and nobody could
|
|
285
|
-
* establish that the rest can never land — it may be saved already, or land later.
|
|
286
|
-
* - `CoordinatorPartialCommitError` / `PartialCommitError`: another collection or tree of the
|
|
287
|
-
* same transaction is already durable, and re-running the whole body would apply it twice.
|
|
288
|
-
*
|
|
289
|
-
* Every one of these embeds text from elsewhere in its message (a responder's refusal, the
|
|
290
|
-
* underlying failure), which can carry the pend-phase aggregate's prefix and `[block:` token —
|
|
291
|
-
* so this veto runs before any text matcher.
|
|
292
|
-
*
|
|
293
|
-
* Matched by TYPE: each survives the bridge's and Quereus' rewraps on `cause`. An error built by a
|
|
294
|
-
* second loaded copy of `@optimystic/db-core` (or of the plugin, for `PartialCommitError`) fails
|
|
295
|
-
* `instanceof`, and then this veto does not fire — falling back to the text classifiers, which is
|
|
296
|
-
* how these failures were classified before the veto existed. The asymmetry with
|
|
297
|
-
* {@link isFinalTornWrite} is deliberate: there, a missed `instanceof` means no retry, which is the
|
|
298
|
-
* safe side. No text fallback parses `TornActionError`'s closing sentence, since upstream says its
|
|
299
|
-
* wording is for log lines only.
|
|
300
|
-
*
|
|
301
|
-
* NOTE: the plugin is registered through its `/plugin` entry (`control-database.ts`) but
|
|
302
|
-
* `PartialCommitError` is imported from its root entry; the two are one class only because the
|
|
303
|
-
* plugin's build emits both entries over one shared chunk. If upstream ever bundles the entries
|
|
304
|
-
* separately, this `instanceof` stops matching with every spec still green — import the class from
|
|
305
|
-
* the entry the plugin is registered through.
|
|
306
|
-
*/
|
|
307
|
-
function reportsPossiblyStoredWrite(links: readonly Error[]): boolean {
|
|
308
|
-
return links.some(link =>
|
|
309
|
-
link instanceof SyncRetryExhaustedError
|
|
310
|
-
|| (link instanceof TornActionError && link.final !== true)
|
|
311
|
-
|| link instanceof CoordinatorPartialCommitError
|
|
312
|
-
|| link instanceof PartialCommitError);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* A torn write the library marks FINAL: not saved, unable to land, its pending records confirmed
|
|
317
|
-
* cancelled — so submitting it again stores it once.
|
|
318
|
-
*
|
|
319
|
-
* Worth re-presenting, not merely safe to: upstream raises it mostly when a rival holds the
|
|
320
|
-
* revision this write's log entry claimed, which is contention, and the loop re-runs the write
|
|
321
|
-
* body's reads, so the next attempt builds on the rival's revision — the same argument that keeps
|
|
322
|
-
* stale-revision rejections retried (the accepted-tradeoff `NOTE:` on
|
|
323
|
-
* {@link isUncommittedTransactorAggregate}).
|
|
324
|
-
*
|
|
325
|
-
* NOTE: `CoordinatorStaleLossError` (db-core; "nothing durably committed, safe to re-drive") is not
|
|
326
|
-
* claimed, so it falls to the text matchers; it escapes only after the coordinator's own retry budget
|
|
327
|
-
* ran out. Seen abandoning a control write on 2026-09-18 (`control-delete-while-alone-convergence`,
|
|
328
|
-
* under load): a node's own storage refused its own pend as a `stale conflict` for the ~14 s the
|
|
329
|
-
* coordinator spent re-driving, then gave up. Still not claimed here — the failing attempt already
|
|
330
|
-
* ran past {@link CONTROL_WRITE_RETRY_BUDGET_MS}, so a retry from this loop would never get to run,
|
|
331
|
-
* and the refusal came from the node's OWN storage disagreeing with its own revision view, which does
|
|
332
|
-
* not change while the node is alone; re-driving the same write body again would hit the same
|
|
333
|
-
* refusal. The cause (a commit torn by a sibling that stopped mid-commit, leaving the node with a
|
|
334
|
-
* revision view its own storage disputes) is `tickets/blocked/forked-control-collection-sync-livelocks.md`
|
|
335
|
-
* → "Second trigger".
|
|
336
|
-
*/
|
|
337
|
-
function isFinalTornWrite(link: Error): boolean {
|
|
338
|
-
return link instanceof TornActionError && link.final === true;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/** The coordinator's shortfall, raised pre-commit, with nobody having voted no. */
|
|
342
|
-
function isUnansweredSuperMajorityShortfall(message: string): boolean {
|
|
343
|
-
return SUPER_MAJORITY_SHORTFALL_UNANSWERED.test(message);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/**
|
|
347
|
-
* Optimystic refusing to let a node elect ITSELF coordinator because its last connection dropped
|
|
348
|
-
* less than the guard's grace period ago (`Libp2pKeyPeerNetwork.shouldAllowSelfCoordination` →
|
|
349
|
-
* `findCoordinator`, `db-p2p/src/libp2p-key-network.ts`).
|
|
350
|
-
*
|
|
351
|
-
* Matched ONLY with the `grace-period-not-elapsed` reason. The same sentence is raised with three
|
|
352
|
-
* other reasons and none of them is worth a retry: `disabled` is static configuration, and
|
|
353
|
-
* `partition-detected` / `suspicious-shrinkage` describe a network condition that will not resolve
|
|
354
|
-
* inside a few seconds of backoff. The grace-period one WILL — it clears as soon as the node holds
|
|
355
|
-
* one connection again, which a cold-starting node dialling its bootstrap peers is actively
|
|
356
|
-
* working on.
|
|
357
|
-
*
|
|
358
|
-
* NOTE: matched by TEXT, not by type. `FindCoordinatorError` (`code: 'SELF_COORDINATION_BLOCKED'`)
|
|
359
|
-
* is exported from `@optimystic/db-p2p`, and when this matcher was written the error OBJECT did not
|
|
360
|
-
* survive the trip: `OptimysticVirtualTable.initialize` rethrew as `new Error(message)` with no
|
|
361
|
-
* `cause`. It now rethrows through `rewrapAsQueryError`, which keeps `cause`
|
|
362
|
-
* (`quereus-plugin-optimystic/src/optimystic-module.ts`); whether every Quereus wrapper above it on
|
|
363
|
-
* the schema-init path keeps it too has not been checked, and the text still arrives either way.
|
|
364
|
-
* Like every matcher here this fails CLOSED — a rewording upstream stops the retry engaging, it
|
|
365
|
-
* never makes it unsafe.
|
|
366
|
-
*/
|
|
367
|
-
const SELF_COORDINATION_GRACE_REFUSAL =
|
|
368
|
-
/Self-coordination blocked: grace-period-not-elapsed\. No coordinator available for key\./;
|
|
369
|
-
|
|
370
|
-
/** Self-coordination refused because the node's last connection dropped moments ago. */
|
|
371
|
-
function isSelfCoordinationGraceRefusal(message: string): boolean {
|
|
372
|
-
return SELF_COORDINATION_GRACE_REFUSAL.test(message);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/**
|
|
376
|
-
* The error messages that mean "the cohort did not answer AND nothing committed", and nothing
|
|
377
|
-
* else. The observed one-shot failure was `registerSelf()` racing a connection still forming:
|
|
378
|
-
* a read/pend-phase transactor aggregate with `cause=The stream has been reset`.
|
|
379
|
-
*
|
|
380
|
-
* Deliberately NOT matched: every constraint / authorization failure
|
|
381
|
-
* (`CHECK constraint failed:`, `UNIQUE constraint failed:`) — those reach the same funnel
|
|
382
|
-
* and must be retried zero times: the cohort REFUSED the write, so re-presenting it
|
|
383
|
-
* re-presents a spent signature and can never commit.
|
|
384
|
-
*/
|
|
385
|
-
const RETRIABLE_CONTROL_WRITE_MATCHERS: readonly ((message: string) => boolean)[] = [
|
|
386
|
-
isUncommittedTransactorAggregate,
|
|
387
|
-
isUnansweredSuperMajorityShortfall,
|
|
388
|
-
];
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* The above PLUS the self-coordination grace refusal — the classifier for schema init ONLY
|
|
392
|
-
* ({@link SCHEMA_INIT_RETRY_POLICY}), deliberately not folded into the list above.
|
|
393
|
-
*
|
|
394
|
-
* Why this class is safe HERE and not everywhere: a write refused at coordinator SELECTION never
|
|
395
|
-
* reached a peer, so re-presenting it is a proven non-commit. But `findCoordinator` is also
|
|
396
|
-
* reached from `NetworkTransactor.commitBlock` during PHASE 2 (`resolveCoordinator`,
|
|
397
|
-
* `db-core/src/transactor/network-transactor.ts`), and `commit()` commits the header block before
|
|
398
|
-
* the rest — so a general control write refused there can be refused AFTER something already
|
|
399
|
-
* committed. Worse, that error carries no `[blocks:` batch token, so
|
|
400
|
-
* {@link reportsIndeterminateCommit} would not veto it, and re-running an insert body over a write
|
|
401
|
-
* that landed is exactly the `UNIQUE constraint failed: CadrePeer.PeerId` failure that veto exists
|
|
402
|
-
* to prevent.
|
|
403
|
-
*
|
|
404
|
-
* Schema init does not have that problem: `apply schema` is a diff rather than a replay, and a
|
|
405
|
-
* failed apply is taken back whole — Quereus unwinds every step it ran and verifies the catalog
|
|
406
|
-
* against a pre-apply fingerprint — so a re-run emits exactly the DDL the live catalog is missing.
|
|
407
|
-
* The full argument, including the plugin-side dependency that unwind carries, is at the
|
|
408
|
-
* `
|
|
409
|
-
*
|
|
410
|
-
* RETRIED DELIBERATELY, not by oversight: the failures Quereus cannot take back. When the unwind
|
|
411
|
-
* does not complete — an undo statement threw, the post-unwind catalog did not match the pre-apply
|
|
412
|
-
* fingerprint, that catalog could not be re-collected to check, or a step the differ marks
|
|
413
|
-
* irreversible because it discards data (dropping a table or a column, narrowing a column's type —
|
|
414
|
-
* none of which a `CadreControl`
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
* {@link
|
|
516
|
-
*
|
|
517
|
-
*
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
* `
|
|
521
|
-
*
|
|
522
|
-
*
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
*
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
* its
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
1
|
+
import { CoordinatorPartialCommitError, SyncRetryExhaustedError, TornActionError } from '@optimystic/db-core';
|
|
2
|
+
import { PartialCommitError } from '@optimystic/quereus-plugin-optimystic';
|
|
3
|
+
import { causeChain, chainMessages, retryControlOperation } from './control-retry.js';
|
|
4
|
+
import type { ControlRetryOptions } from './control-retry.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Bounded retry for TRANSIENT control-write failures — the classifier and loop behind
|
|
8
|
+
* `ControlDatabase.lockedWithRetry`, the single funnel every local control write passes
|
|
9
|
+
* through.
|
|
10
|
+
*
|
|
11
|
+
* Why this exists: the control database replicates every block to the whole party, so a
|
|
12
|
+
* three-machine party commits control writes unanimously (`ceil(3 × 0.75) = 3` approvals) —
|
|
13
|
+
* and no `superMajorityThreshold` can lower that bar without violating Optimystic's
|
|
14
|
+
* partition-safety condition at the shipped admission fraction (the arithmetic lives in
|
|
15
|
+
* `docs/architecture.md` → "Replication cluster size"). Cadre therefore accepts unanimity
|
|
16
|
+
* and absorbs the transient half of its cost here: a write that failed because the cohort
|
|
17
|
+
* did not ANSWER (a stream reset mid-connection-formation, zero approvals) is re-presented
|
|
18
|
+
* a moment later, which the degraded-cohort-member scenario measured as safe (a failed
|
|
19
|
+
* write rolls back, nothing half-commits) and effective (the next write commits in ~1 s).
|
|
20
|
+
*
|
|
21
|
+
* A write somebody actually REJECTED is never retried on the strength of the rejection itself —
|
|
22
|
+
* no matcher here claims a rejection message, at any phase, because re-presenting one would
|
|
23
|
+
* re-present a spent signature against a cohort that already said no. Rejections are re-presented
|
|
24
|
+
* anyway when they arrive inside the transactor's promise-phase `[block:` aggregate, which
|
|
25
|
+
* {@link isUncommittedTransactorAggregate} claims on the WRAPPER alone, whatever the cause inside
|
|
26
|
+
* says. Safe because nothing has committed at that phase, and deliberately kept — one of the
|
|
27
|
+
* remaining promise-phase rejections is exactly the kind a re-presentation fixes. See the
|
|
28
|
+
* accepted-tradeoff `NOTE:` there for which, and why no blanket rejection veto is wanted.
|
|
29
|
+
*
|
|
30
|
+
* A failure that says some of the write may be STORED is a different matter, and never
|
|
31
|
+
* re-presented whatever text it carries: {@link reportsPossiblyStoredWrite} vetoes it by type
|
|
32
|
+
* before any matcher runs. The one half-landed write that IS re-presented is a torn write the
|
|
33
|
+
* library marks final — not saved and unable to land ({@link isFinalTornWrite}).
|
|
34
|
+
*
|
|
35
|
+
* TWO policies ship from here, and the split is deliberate: the default
|
|
36
|
+
* ({@link isRetriableControlWriteFailure}, {@link CONTROL_WRITE_ATTEMPTS}) sits under every
|
|
37
|
+
* control write, and {@link SCHEMA_INIT_RETRY_POLICY} is opted into by
|
|
38
|
+
* `ControlDatabase.applyControlSchema` alone. The widened one absorbs one extra failure class whose
|
|
39
|
+
* re-run safety holds for schema DDL and NOT for writes in general; anything added to the default
|
|
40
|
+
* list must be safe for all ~19 callers.
|
|
41
|
+
*
|
|
42
|
+
* Lives outside `control-database.ts` so a spec can drive the classifier against real engine
|
|
43
|
+
* errors without importing the whole database class. The loop itself lives in
|
|
44
|
+
* `control-retry.ts`, shared with the READ policy (`control-read-retry.ts`) — this module
|
|
45
|
+
* owns the write policies: their classifiers, attempt counts, backoff and budget.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Attempts allowed per control write: the first, plus two retries of a transiently-failed
|
|
50
|
+
* cluster commit. Bounded so a genuinely unreachable cohort terminates in the transactor's
|
|
51
|
+
* own error rather than spinning; two retries covers the observed failure (a stream still
|
|
52
|
+
* forming when the write fired) with margin.
|
|
53
|
+
*/
|
|
54
|
+
export const CONTROL_WRITE_ATTEMPTS = 3;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Attempts allowed for {@link SCHEMA_INIT_RETRY_POLICY}, the one call site that also absorbs a
|
|
58
|
+
* self-coordination refusal.
|
|
59
|
+
*
|
|
60
|
+
* Two more than {@link CONTROL_WRITE_ATTEMPTS} because what this retry buys is wall-clock for a
|
|
61
|
+
* cold-starting node's bootstrap dials to land, and because the refusal is cheap to re-provoke
|
|
62
|
+
* relative to a cluster round-trip: `findCoordinator` already spends its own bounded wait before
|
|
63
|
+
* raising (3 attempts, 500 ms apart, `db-p2p/src/libp2p-key-network.ts` ~422) and then decides
|
|
64
|
+
* LOCALLY that it may not elect itself, so no peer is contacted and no cohort deadline is
|
|
65
|
+
* consumed.
|
|
66
|
+
*
|
|
67
|
+
* Note that the ~1 s `findCoordinator` spends internally is real, on top of this policy's own
|
|
68
|
+
* backoff — which is exactly why {@link CONTROL_WRITE_RETRY_BUDGET_MS} and not this count is what
|
|
69
|
+
* actually terminates the loop in the worst case.
|
|
70
|
+
*/
|
|
71
|
+
export const SCHEMA_INIT_ATTEMPTS = 5;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Elapsed-time ceiling on the whole retry loop, measured from the START of the first
|
|
75
|
+
* attempt and checked after a failed attempt BEFORE sleeping.
|
|
76
|
+
*
|
|
77
|
+
* This budget — not the attempt count — is what makes the policy safe to sit under every
|
|
78
|
+
* control write. A transient failure (stream reset while a connection is still forming)
|
|
79
|
+
* surfaces in well under a second, so it is retried and the loop adds at most ~2.2 s. A
|
|
80
|
+
* genuinely silent cohort member costs ~21 s per consensus round (two `ClusterClient`
|
|
81
|
+
* response-deadline attempts, 10.5 s each at the default declared link): one pend round plus
|
|
82
|
+
* the cancel discharge's three rounds, about 84 s for one write in
|
|
83
|
+
* `control-write-degraded-cohort-member.integration.ts`, which already exceeds this budget
|
|
84
|
+
* when attempt 1 returns — so that case is surfaced immediately and retry adds ZERO latency
|
|
85
|
+
* to the case where it cannot help.
|
|
86
|
+
*
|
|
87
|
+
* It cuts the loop off by design, so it does not grow with the link: its job is to end the
|
|
88
|
+
* retries of slow attempts, and the caller gets the last attempt's error unchanged. An attempt
|
|
89
|
+
* whose read phase consulted a silent peer ends at about one per-peer read deadline
|
|
90
|
+
* (`cohortReadDeadlineMs` in `link-budget.ts`: 7 s at the default declared link, 5 s before it
|
|
91
|
+
* was derived, 1 s at Optimystic's own default), so it still gets one retry inside this budget
|
|
92
|
+
* where it once got two; an attempt that runs into the `ClusterClient` response deadline
|
|
93
|
+
* (10.5 s at the default declared link) still gets none. See `docs/cadre-consistency.md` →
|
|
94
|
+
* "Deadlines Over Optimystic's Reads and Commits".
|
|
95
|
+
*
|
|
96
|
+
* NOTE: a failed commit attempt also pays a cancel discharge before its error returns
|
|
97
|
+
* (optimystic `TransactorSource.transact`'s catch) — bounded by six rounds and
|
|
98
|
+
* `abortOrCancelTimeoutMs`, which every collection this repo opens sets to the larger of 5 s
|
|
99
|
+
* and the RPC dial deadline: 42.5 s at the default declared link (`optimysticDialLimits` in
|
|
100
|
+
* `link-budget.ts`;
|
|
101
|
+
* `../optimystic/packages/quereus-plugin-optimystic/src/optimystic-adapter/collection-factory.ts`).
|
|
102
|
+
* One cancel that runs its full budget therefore ends the retries on its own, cutting the
|
|
103
|
+
* three-attempt policy to however many attempts came before it. A cancel only runs that long
|
|
104
|
+
* when a peer stays silent, and that attempt has already spent its pend round past this
|
|
105
|
+
* budget, so the cut loses nothing there. After a transient fault the cancel is either
|
|
106
|
+
* answered or gives up after its six rounds against a dead transport, in about a second
|
|
107
|
+
* either way (optimystic `NetworkTransactor.MAX_CANCEL_ROUNDS`). Not observed — every
|
|
108
|
+
* measured round of the transient-reset case committed on attempt 3 of 3 — so this is a
|
|
109
|
+
* condition to watch, not work to do: if that case ever starts failing with
|
|
110
|
+
* `failed after 2/3 attempt(s)`, the cancel discharge is where the time went.
|
|
111
|
+
*/
|
|
112
|
+
// eslint-disable-next-line no-restricted-syntax -- cuts off by design: it ends the retries of slow attempts rather than waiting them out; see docs/cadre-consistency.md → "Deadlines Over Optimystic's Reads and Commits"
|
|
113
|
+
export const CONTROL_WRITE_RETRY_BUDGET_MS = 10_000;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Backoff before retry N, jittered ±50% (so the first sleep is 125–375 ms — the jitter can
|
|
117
|
+
* never floor a delay to zero). Each single sleep is additionally capped at the LARGEST
|
|
118
|
+
* delay in the list, so a `stop()` racing a backoff is never held up by more than ~1 s.
|
|
119
|
+
* There is no `AbortSignal` at this seam to cancel a sleep outright; the cap is the
|
|
120
|
+
* substitute, deliberately not plumbing one through every write path.
|
|
121
|
+
*/
|
|
122
|
+
const CONTROL_WRITE_RETRY_DELAYS_MS: readonly number[] = [250, 1_000];
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Backoff for {@link SCHEMA_INIT_RETRY_POLICY}, jittered and capped the same way. Worst case
|
|
126
|
+
* ~4.6 s of sleep across four retries (375 + 750 + 1500 + 2000, each capped at the largest base),
|
|
127
|
+
* which stays inside {@link CONTROL_WRITE_RETRY_BUDGET_MS} — the budget still terminates the loop
|
|
128
|
+
* first if any single attempt is itself slow.
|
|
129
|
+
*
|
|
130
|
+
* Sized against measured facts, not guessed:
|
|
131
|
+
*
|
|
132
|
+
* - the self-coordination guard clears the moment the node has ONE connection again (both
|
|
133
|
+
* branches that raise `grace-period-not-elapsed` require `getConnections().length === 0`), not
|
|
134
|
+
* when the 30 s grace period expires — so this does NOT need to cover the grace period, only
|
|
135
|
+
* the gap until a bootstrap dial completes;
|
|
136
|
+
* - the guard's 30 s `gracePeriodMs` cannot be tuned from this repo anyway: no caller in either
|
|
137
|
+
* repo passes a `SelfCoordinationConfig`, so the default is always in force;
|
|
138
|
+
* - a node that has NEVER connected is waved through as a bootstrap node (network high-water
|
|
139
|
+
* mark of 1), so this retry only ever delays a node that HAS seen peers. One whose peers are
|
|
140
|
+
* gone for good still fails, ~4.6 s later — which is why the budget stays at the shared
|
|
141
|
+
* ceiling rather than stretching to cover the full 30 s window.
|
|
142
|
+
*/
|
|
143
|
+
const SCHEMA_INIT_RETRY_DELAYS_MS: readonly number[] = [250, 500, 1_000, 2_000];
|
|
144
|
+
|
|
145
|
+
/** The network transactor's aggregate, raised when some cohort peers gave no usable answer. */
|
|
146
|
+
const TRANSACTOR_AGGREGATE = /Some peers did not complete:/;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* How the aggregate's per-batch details name a SINGLE-block batch — the shape both the block
|
|
150
|
+
* read (`get`) and phase 1 (`pend`) format, and the only shape safe to re-present.
|
|
151
|
+
*/
|
|
152
|
+
const SINGLE_BLOCK_BATCH_TOKEN = '[block:';
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* How the aggregate's per-batch details name a MULTI-block batch — the shape phase 2
|
|
156
|
+
* (`commitBlocks`) formats, and the marker of an outcome too indeterminate to retry.
|
|
157
|
+
*/
|
|
158
|
+
const COMMIT_BATCH_TOKEN = '[blocks:';
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The cluster coordinator's super-majority shortfall (`db-p2p/src/repo/cluster-coordinator.ts`),
|
|
162
|
+
* matched ONLY with a ZERO rejection count. The same message with a non-zero count means a
|
|
163
|
+
* member actually voted no (that branch carries `membership-not-admitted` rejections), and
|
|
164
|
+
* retrying it would re-present a spent signature to a cohort that already refused it. This one
|
|
165
|
+
* is raised while collecting PROMISES, before any commit, so re-presenting it is safe.
|
|
166
|
+
*
|
|
167
|
+
* This regex itself never matches a decisive rejection (`ValidatorRejectionError`, `Transaction
|
|
168
|
+
* rejected by validators`) — that text carries no `Failed to get super-majority:` prefix. But the
|
|
169
|
+
* composite classifier is not this matcher alone: a rejection raised while promises are still
|
|
170
|
+
* being collected reaches this repo wrapped in the transactor's `Some peers did not complete:
|
|
171
|
+
* …[block:…]` aggregate, and {@link isUncommittedTransactorAggregate} matches that wrapper on its
|
|
172
|
+
* own, whatever the cause inside it says — see the accepted-tradeoff `NOTE:` there.
|
|
173
|
+
*/
|
|
174
|
+
const SUPER_MAJORITY_SHORTFALL_UNANSWERED =
|
|
175
|
+
/Failed to get super-majority: \d+\/\d+ approvals \(needed \d+, 0 rejections\)/;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Is this a transactor aggregate from a phase where nothing can have committed yet?
|
|
179
|
+
*
|
|
180
|
+
* The transactor raises `Some peers did not complete:` from three sites
|
|
181
|
+
* (`db-core/src/transactor/network-transactor.ts`), and only two of them are safe to
|
|
182
|
+
* re-present:
|
|
183
|
+
*
|
|
184
|
+
* - `get` (a block read, which a write body also performs) and `pend` (phase 1) fail before
|
|
185
|
+
* anything commits, so the write is known not to have landed;
|
|
186
|
+
* - `commitBlocks` (phase 2) is REACHABLE here and NOT safe. `commitBlock` throws the
|
|
187
|
+
* aggregate when a header/tail commit got NO response at all — precisely the transient
|
|
188
|
+
* class this retry targets — and that escapes `commit()`, survives `TransactorSource`'s
|
|
189
|
+
* cancel-and-rethrow (cancel reaches PENDING actions only; a peer that already committed
|
|
190
|
+
* stays committed) and `Collection.syncInternal` (which retries StaleFailure RETURN values,
|
|
191
|
+
* not throws), reaching this funnel QuereusError-wrapped. The tail commit is one batch to
|
|
192
|
+
* one coordinator running consensus internally, so a no-response there is INDETERMINATE:
|
|
193
|
+
* the commit may have completed with only the response lost. Re-running the write body over
|
|
194
|
+
* a write that landed turns a success into a constraint failure (e.g.
|
|
195
|
+
* `UNIQUE constraint failed: CadrePeer.PeerId`). Surfacing the transient error instead lets
|
|
196
|
+
* the caller re-read committed state and decide. Commit-phase messages are vetoed by
|
|
197
|
+
* {@link reportsIndeterminateCommit}, not here.
|
|
198
|
+
*
|
|
199
|
+
* The `cause` chain does not discriminate the phases — a commit-phase aggregate carries the
|
|
200
|
+
* same stream-reset/dial error a pend-phase one does. The per-batch DETAIL text does: `get`
|
|
201
|
+
* and `pend` format `<peerId>[block:<id>](<status>)`, `commitBlocks` formats
|
|
202
|
+
* `<peerId>[blocks:<count>](<status>)`. `[block:` cannot occur inside `[blocks:`, so the two
|
|
203
|
+
* tokens are disjoint and separate the phases.
|
|
204
|
+
*
|
|
205
|
+
* NOTE: the discriminator is a formatting detail of another repo. If Optimystic ever reformats
|
|
206
|
+
* those per-batch details, this fails CLOSED — the aggregate stops matching and control writes
|
|
207
|
+
* simply stop being retried, silently losing the absorption rather than doing anything unsafe.
|
|
208
|
+
* The guard against that is live: `control-write-degraded-cohort-member.integration.ts` asserts
|
|
209
|
+
* this classifier against the real degraded-cohort failure object and asserts the `[block:`
|
|
210
|
+
* token on the live aggregate; if it reddens there, an upstream reformat is the first thing to
|
|
211
|
+
* check.
|
|
212
|
+
*
|
|
213
|
+
* NOTE: the discriminator is the prefix AND the token within ONE message — never relax it to
|
|
214
|
+
* "contains `[block:`". Beyond the `get` and `pend` sites above, Optimystic renders `[block:` in
|
|
215
|
+
* one further place — a third formatter, outside the aggregate's three sites — `dischargeCancel`
|
|
216
|
+
* (`Cancel of action <id> did not discharge <n> block(s): …`), which does not raise the
|
|
217
|
+
* `Some peers did not complete:` prefix; only the conjunction keeps a cancel fault from
|
|
218
|
+
* classifying as a retriable get/pend (checked against optimystic `c56c2bd4`, 2026-09-16).
|
|
219
|
+
*
|
|
220
|
+
* NOTE: accepted tradeoff — a REJECTION raised while promises are still being collected reaches
|
|
221
|
+
* this repo wrapped in this same aggregate, and this matcher claims it on the wrapper alone,
|
|
222
|
+
* whatever the cause inside says. That re-presents such a rejection up to two more times inside
|
|
223
|
+
* the 10 s budget. Kept deliberately, and re-decided on 2026-09-17 against the current upstream
|
|
224
|
+
* behaviour rather than inherited:
|
|
225
|
+
*
|
|
226
|
+
* - Contention is no longer a rejection at all. "Another write holds this block right now"
|
|
227
|
+
* (`pending conflict`) is now a `held` verdict that counts toward neither approvals nor
|
|
228
|
+
* rejections (`validatePendOperations`,
|
|
229
|
+
* `../optimystic/packages/db-p2p/src/cluster/cluster-repo.ts`), so the rejection this tradeoff
|
|
230
|
+
* was originally written about does not arrive any more. It reaches this funnel as
|
|
231
|
+
* `SyncRetryExhaustedError` instead, which {@link reportsPossiblyStoredWrite} declines by type
|
|
232
|
+
* — upstream documents resubmitting after it as unsafe (the attempt that spent the budget may
|
|
233
|
+
* have left its log entry standing), and its collection sync has already spent ten retries on
|
|
234
|
+
* the race. The torn-write rule beside it: a `TornActionError` is re-presented only when
|
|
235
|
+
* `final` is true; any other torn write is declined.
|
|
236
|
+
* - What remains rejectable at the promise phase is stale revision, block-unavailable,
|
|
237
|
+
* membership-not-admitted, and a configured validator's refusal. Only the first is worth a
|
|
238
|
+
* retry — and it is worth one: this loop re-runs the WHOLE write body, reads included
|
|
239
|
+
* (`ControlDatabase.withWriteLock`'s contract), so attempt 2 presents a pend against the
|
|
240
|
+
* revision that made attempt 1 stale. A blanket "decline any chain reporting a validator
|
|
241
|
+
* rejection" rule would remove that retry to save two wasted attempts on the other three.
|
|
242
|
+
*
|
|
243
|
+
* Revisit if a re-presentation ever becomes expensive relative to what it buys — a rejection class
|
|
244
|
+
* that costs a full cohort round-trip to re-present, or a write body whose reads are no longer
|
|
245
|
+
* cheap — or when upstream offers a typed refusal surface to classify on instead of this text
|
|
246
|
+
* (`../optimystic/tickets/backlog/debt-a-downstream-repo-classifies-retries-by-parsing-our-error-text`).
|
|
247
|
+
* Contention is no longer the reason to revisit.
|
|
248
|
+
*
|
|
249
|
+
* An aggregate whose details came out EMPTY (possible when `formatBatchStatuses` has no
|
|
250
|
+
* batches to format) carries neither token, matches nothing here, and is not retried — an
|
|
251
|
+
* unattributable failure is not a proven non-commit.
|
|
252
|
+
*
|
|
253
|
+
* Exported for the READ classifier (`control-read-retry.ts`), which reuses it verbatim: a
|
|
254
|
+
* `get` batch formats the same single-block `[block:` token, and a read can never produce
|
|
255
|
+
* the commit-phase `[blocks:` shape at all.
|
|
256
|
+
*/
|
|
257
|
+
export function isUncommittedTransactorAggregate(message: string): boolean {
|
|
258
|
+
return TRANSACTOR_AGGREGATE.test(message) && message.includes(SINGLE_BLOCK_BATCH_TOKEN);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Does ANY message in the failure's `cause` chain describe a commit-phase batch — i.e. an
|
|
263
|
+
* outcome nobody can call committed or not?
|
|
264
|
+
*
|
|
265
|
+
* Vetoes the whole chain rather than the one message carrying the token, and vetoes it
|
|
266
|
+
* regardless of which matcher would otherwise have claimed it. The narrower per-message rule
|
|
267
|
+
* would already catch every shape the transactor is known to build (a commit-phase aggregate
|
|
268
|
+
* emits `[blocks:` for every batch it formats, and embeds its cause's message inline as
|
|
269
|
+
* `root: …`, so both tokens land in one string) — but "known to build" is an argument about
|
|
270
|
+
* another repo's error assembly, and this way an indeterminate commit anywhere in the chain
|
|
271
|
+
* costs a retry we could have had instead of risking a re-run over a write that landed.
|
|
272
|
+
*/
|
|
273
|
+
function reportsIndeterminateCommit(messages: readonly string[]): boolean {
|
|
274
|
+
return messages.some(message => message.includes(COMMIT_BATCH_TOKEN));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Does any link of the `cause` chain say some of this write may be stored, or may still be?
|
|
279
|
+
* Such a failure is never re-presented — a re-run could store the write twice.
|
|
280
|
+
*
|
|
281
|
+
* - `SyncRetryExhaustedError` (and its `SyncRevisionStalledError` subclass): upstream documents
|
|
282
|
+
* resubmitting as unsafe, because the attempt that spent the budget may have left its log
|
|
283
|
+
* entry standing.
|
|
284
|
+
* - `TornActionError` without `final: true`: the write's log entry is stored and nobody could
|
|
285
|
+
* establish that the rest can never land — it may be saved already, or land later.
|
|
286
|
+
* - `CoordinatorPartialCommitError` / `PartialCommitError`: another collection or tree of the
|
|
287
|
+
* same transaction is already durable, and re-running the whole body would apply it twice.
|
|
288
|
+
*
|
|
289
|
+
* Every one of these embeds text from elsewhere in its message (a responder's refusal, the
|
|
290
|
+
* underlying failure), which can carry the pend-phase aggregate's prefix and `[block:` token —
|
|
291
|
+
* so this veto runs before any text matcher.
|
|
292
|
+
*
|
|
293
|
+
* Matched by TYPE: each survives the bridge's and Quereus' rewraps on `cause`. An error built by a
|
|
294
|
+
* second loaded copy of `@optimystic/db-core` (or of the plugin, for `PartialCommitError`) fails
|
|
295
|
+
* `instanceof`, and then this veto does not fire — falling back to the text classifiers, which is
|
|
296
|
+
* how these failures were classified before the veto existed. The asymmetry with
|
|
297
|
+
* {@link isFinalTornWrite} is deliberate: there, a missed `instanceof` means no retry, which is the
|
|
298
|
+
* safe side. No text fallback parses `TornActionError`'s closing sentence, since upstream says its
|
|
299
|
+
* wording is for log lines only.
|
|
300
|
+
*
|
|
301
|
+
* NOTE: the plugin is registered through its `/plugin` entry (`control-database.ts`) but
|
|
302
|
+
* `PartialCommitError` is imported from its root entry; the two are one class only because the
|
|
303
|
+
* plugin's build emits both entries over one shared chunk. If upstream ever bundles the entries
|
|
304
|
+
* separately, this `instanceof` stops matching with every spec still green — import the class from
|
|
305
|
+
* the entry the plugin is registered through.
|
|
306
|
+
*/
|
|
307
|
+
function reportsPossiblyStoredWrite(links: readonly Error[]): boolean {
|
|
308
|
+
return links.some(link =>
|
|
309
|
+
link instanceof SyncRetryExhaustedError
|
|
310
|
+
|| (link instanceof TornActionError && link.final !== true)
|
|
311
|
+
|| link instanceof CoordinatorPartialCommitError
|
|
312
|
+
|| link instanceof PartialCommitError);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* A torn write the library marks FINAL: not saved, unable to land, its pending records confirmed
|
|
317
|
+
* cancelled — so submitting it again stores it once.
|
|
318
|
+
*
|
|
319
|
+
* Worth re-presenting, not merely safe to: upstream raises it mostly when a rival holds the
|
|
320
|
+
* revision this write's log entry claimed, which is contention, and the loop re-runs the write
|
|
321
|
+
* body's reads, so the next attempt builds on the rival's revision — the same argument that keeps
|
|
322
|
+
* stale-revision rejections retried (the accepted-tradeoff `NOTE:` on
|
|
323
|
+
* {@link isUncommittedTransactorAggregate}).
|
|
324
|
+
*
|
|
325
|
+
* NOTE: `CoordinatorStaleLossError` (db-core; "nothing durably committed, safe to re-drive") is not
|
|
326
|
+
* claimed, so it falls to the text matchers; it escapes only after the coordinator's own retry budget
|
|
327
|
+
* ran out. Seen abandoning a control write on 2026-09-18 (`control-delete-while-alone-convergence`,
|
|
328
|
+
* under load): a node's own storage refused its own pend as a `stale conflict` for the ~14 s the
|
|
329
|
+
* coordinator spent re-driving, then gave up. Still not claimed here — the failing attempt already
|
|
330
|
+
* ran past {@link CONTROL_WRITE_RETRY_BUDGET_MS}, so a retry from this loop would never get to run,
|
|
331
|
+
* and the refusal came from the node's OWN storage disagreeing with its own revision view, which does
|
|
332
|
+
* not change while the node is alone; re-driving the same write body again would hit the same
|
|
333
|
+
* refusal. The cause (a commit torn by a sibling that stopped mid-commit, leaving the node with a
|
|
334
|
+
* revision view its own storage disputes) is `tickets/blocked/forked-control-collection-sync-livelocks.md`
|
|
335
|
+
* → "Second trigger".
|
|
336
|
+
*/
|
|
337
|
+
function isFinalTornWrite(link: Error): boolean {
|
|
338
|
+
return link instanceof TornActionError && link.final === true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** The coordinator's shortfall, raised pre-commit, with nobody having voted no. */
|
|
342
|
+
function isUnansweredSuperMajorityShortfall(message: string): boolean {
|
|
343
|
+
return SUPER_MAJORITY_SHORTFALL_UNANSWERED.test(message);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Optimystic refusing to let a node elect ITSELF coordinator because its last connection dropped
|
|
348
|
+
* less than the guard's grace period ago (`Libp2pKeyPeerNetwork.shouldAllowSelfCoordination` →
|
|
349
|
+
* `findCoordinator`, `db-p2p/src/libp2p-key-network.ts`).
|
|
350
|
+
*
|
|
351
|
+
* Matched ONLY with the `grace-period-not-elapsed` reason. The same sentence is raised with three
|
|
352
|
+
* other reasons and none of them is worth a retry: `disabled` is static configuration, and
|
|
353
|
+
* `partition-detected` / `suspicious-shrinkage` describe a network condition that will not resolve
|
|
354
|
+
* inside a few seconds of backoff. The grace-period one WILL — it clears as soon as the node holds
|
|
355
|
+
* one connection again, which a cold-starting node dialling its bootstrap peers is actively
|
|
356
|
+
* working on.
|
|
357
|
+
*
|
|
358
|
+
* NOTE: matched by TEXT, not by type. `FindCoordinatorError` (`code: 'SELF_COORDINATION_BLOCKED'`)
|
|
359
|
+
* is exported from `@optimystic/db-p2p`, and when this matcher was written the error OBJECT did not
|
|
360
|
+
* survive the trip: `OptimysticVirtualTable.initialize` rethrew as `new Error(message)` with no
|
|
361
|
+
* `cause`. It now rethrows through `rewrapAsQueryError`, which keeps `cause`
|
|
362
|
+
* (`quereus-plugin-optimystic/src/optimystic-module.ts`); whether every Quereus wrapper above it on
|
|
363
|
+
* the schema-init path keeps it too has not been checked, and the text still arrives either way.
|
|
364
|
+
* Like every matcher here this fails CLOSED — a rewording upstream stops the retry engaging, it
|
|
365
|
+
* never makes it unsafe.
|
|
366
|
+
*/
|
|
367
|
+
const SELF_COORDINATION_GRACE_REFUSAL =
|
|
368
|
+
/Self-coordination blocked: grace-period-not-elapsed\. No coordinator available for key\./;
|
|
369
|
+
|
|
370
|
+
/** Self-coordination refused because the node's last connection dropped moments ago. */
|
|
371
|
+
function isSelfCoordinationGraceRefusal(message: string): boolean {
|
|
372
|
+
return SELF_COORDINATION_GRACE_REFUSAL.test(message);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The error messages that mean "the cohort did not answer AND nothing committed", and nothing
|
|
377
|
+
* else. The observed one-shot failure was `registerSelf()` racing a connection still forming:
|
|
378
|
+
* a read/pend-phase transactor aggregate with `cause=The stream has been reset`.
|
|
379
|
+
*
|
|
380
|
+
* Deliberately NOT matched: every constraint / authorization failure
|
|
381
|
+
* (`CHECK constraint failed:`, `UNIQUE constraint failed:`) — those reach the same funnel
|
|
382
|
+
* and must be retried zero times: the cohort REFUSED the write, so re-presenting it
|
|
383
|
+
* re-presents a spent signature and can never commit.
|
|
384
|
+
*/
|
|
385
|
+
const RETRIABLE_CONTROL_WRITE_MATCHERS: readonly ((message: string) => boolean)[] = [
|
|
386
|
+
isUncommittedTransactorAggregate,
|
|
387
|
+
isUnansweredSuperMajorityShortfall,
|
|
388
|
+
];
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* The above PLUS the self-coordination grace refusal — the classifier for schema init ONLY
|
|
392
|
+
* ({@link SCHEMA_INIT_RETRY_POLICY}), deliberately not folded into the list above.
|
|
393
|
+
*
|
|
394
|
+
* Why this class is safe HERE and not everywhere: a write refused at coordinator SELECTION never
|
|
395
|
+
* reached a peer, so re-presenting it is a proven non-commit. But `findCoordinator` is also
|
|
396
|
+
* reached from `NetworkTransactor.commitBlock` during PHASE 2 (`resolveCoordinator`,
|
|
397
|
+
* `db-core/src/transactor/network-transactor.ts`), and `commit()` commits the header block before
|
|
398
|
+
* the rest — so a general control write refused there can be refused AFTER something already
|
|
399
|
+
* committed. Worse, that error carries no `[blocks:` batch token, so
|
|
400
|
+
* {@link reportsIndeterminateCommit} would not veto it, and re-running an insert body over a write
|
|
401
|
+
* that landed is exactly the `UNIQUE constraint failed: CadrePeer.PeerId` failure that veto exists
|
|
402
|
+
* to prevent.
|
|
403
|
+
*
|
|
404
|
+
* Schema init does not have that problem: `apply schema` is a diff rather than a replay, and a
|
|
405
|
+
* failed apply is taken back whole — Quereus unwinds every step it ran and verifies the catalog
|
|
406
|
+
* against a pre-apply fingerprint — so a re-run emits exactly the DDL the live catalog is missing.
|
|
407
|
+
* The full argument, including the plugin-side dependency that unwind carries, is at the
|
|
408
|
+
* `applyControlSchema` call site in `control-database.ts`.
|
|
409
|
+
*
|
|
410
|
+
* RETRIED DELIBERATELY, not by oversight: the failures Quereus cannot take back. When the unwind
|
|
411
|
+
* does not complete — an undo statement threw, the post-unwind catalog did not match the pre-apply
|
|
412
|
+
* fingerprint, that catalog could not be re-collected to check, or a step the differ marks
|
|
413
|
+
* irreversible because it discards data (dropping a table or a column, narrowing a column's type —
|
|
414
|
+
* none of which reaches a `CadreControl` apply: a cold store's diff is all creates, and
|
|
415
|
+
* `refuseIncompatibleControlStore` in `control-schema-version.ts` refuses a warm store whose diff
|
|
416
|
+
* holds anything but creates and tag changes before the apply runs) poisoned the journal before it
|
|
417
|
+
* ran, leaving nothing unwound — Quereus keeps the failing step's own message, APPENDS the reason the
|
|
418
|
+
* schema could not be restored, and carries the original as `cause`. So a transient cause
|
|
419
|
+
* underneath still matches here, and neither veto ({@link reportsPossiblyStoredWrite},
|
|
420
|
+
* {@link reportsIndeterminateCommit}) looks for that reason text. Retrying over a partially migrated
|
|
421
|
+
* schema is safe for the same reason the ordinary re-run is: the plugin commits whatever its write
|
|
422
|
+
* batch holds, so storage and the catalog are left in step either way, and the next apply diffs
|
|
423
|
+
* against what is really there. Vetoing it instead would turn a healable transient outage into a
|
|
424
|
+
* dead start.
|
|
425
|
+
*/
|
|
426
|
+
const RETRIABLE_SCHEMA_INIT_MATCHERS: readonly ((message: string) => boolean)[] = [
|
|
427
|
+
...RETRIABLE_CONTROL_WRITE_MATCHERS,
|
|
428
|
+
isSelfCoordinationGraceRefusal,
|
|
429
|
+
];
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Did this control write fail because the cluster cohort did not ANSWER — i.e. is
|
|
433
|
+
* re-presenting the SAME signed write a moment later the right response?
|
|
434
|
+
*
|
|
435
|
+
* Classifies the transient cases by MESSAGE, walking the `cause` chain with the shared
|
|
436
|
+
* {@link chainMessages} (Quereus' own `unwrapError` underneath) — the transactor and the
|
|
437
|
+
* coordinator both throw bare `Error`s, not recognisable by type. By the time one reaches
|
|
438
|
+
* `ControlDatabase` it is wrapped in a `QuereusError` (the real chain is `QuereusError` → `Error`
|
|
439
|
+
* → `Error`), so the match must work at any depth. Anything that is not an `Error` is never
|
|
440
|
+
* retried.
|
|
441
|
+
*
|
|
442
|
+
* Two vetoes run over the whole chain before any matcher: {@link reportsPossiblyStoredWrite}
|
|
443
|
+
* (by type — a spent sync budget, a non-final torn write, a partial commit) and
|
|
444
|
+
* {@link reportsIndeterminateCommit} (by text — a commit-phase batch). A chain either veto flags,
|
|
445
|
+
* at any level, is never retried on the strength of some other level looking transient. The one
|
|
446
|
+
* typed failure claimed as retriable is a final torn write ({@link isFinalTornWrite}).
|
|
447
|
+
*
|
|
448
|
+
* NOTE: this depends on engine/transactor error TEXT. The messages producible without a
|
|
449
|
+
* network are driven from the REAL engine in `control-formation-seat-budget.spec.ts`, so
|
|
450
|
+
* a rewording reddens a spec rather than silently disabling the retry. The two RETRIABLE
|
|
451
|
+
* messages need a real multi-node cluster to produce; the degraded-cohort scenario
|
|
452
|
+
* (`control-write-degraded-cohort-member.integration.ts`) asserts this classifier against
|
|
453
|
+
* both LIVE — the super-majority shortfall from a real silent member, and the transactor
|
|
454
|
+
* aggregate from a real stream reset, which the retry there absorbs end to end.
|
|
455
|
+
*/
|
|
456
|
+
export function isRetriableControlWriteFailure(error: unknown): boolean {
|
|
457
|
+
return matchesRetriableFailure(error, RETRIABLE_CONTROL_WRITE_MATCHERS);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* {@link isRetriableControlWriteFailure} widened by exactly one class — the self-coordination
|
|
462
|
+
* grace refusal ({@link RETRIABLE_SCHEMA_INIT_MATCHERS}) — for `ControlDatabase.applyControlSchema`.
|
|
463
|
+
*
|
|
464
|
+
* Opt-in per call site, via {@link SCHEMA_INIT_RETRY_POLICY}. Every other control write keeps the
|
|
465
|
+
* default classifier untouched.
|
|
466
|
+
*/
|
|
467
|
+
export function isRetriableSchemaInitFailure(error: unknown): boolean {
|
|
468
|
+
return matchesRetriableFailure(error, RETRIABLE_SCHEMA_INIT_MATCHERS);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Shared body of both classifiers: walk the `cause` chain, let {@link reportsPossiblyStoredWrite}
|
|
473
|
+
* and then {@link reportsIndeterminateCommit} veto it, then claim a final torn write
|
|
474
|
+
* ({@link isFinalTornWrite}), then ask `matchers`. The vetoes run BEFORE any matcher for every
|
|
475
|
+
* policy — a chain reporting a possibly-stored write or a commit-phase batch is never retried,
|
|
476
|
+
* however transient some other level looks, and a final torn write carried by a partial commit is
|
|
477
|
+
* still declined because a sibling collection landed.
|
|
478
|
+
*/
|
|
479
|
+
function matchesRetriableFailure(
|
|
480
|
+
error: unknown,
|
|
481
|
+
matchers: readonly ((message: string) => boolean)[]
|
|
482
|
+
): boolean {
|
|
483
|
+
if (!(error instanceof Error)) {
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
const links = causeChain(error);
|
|
487
|
+
if (reportsPossiblyStoredWrite(links)) {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
const messages = chainMessages(error);
|
|
491
|
+
if (reportsIndeterminateCommit(messages)) {
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
if (links.some(isFinalTornWrite)) {
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
return messages.some(message => matchers.some(matches => matches(message)));
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Policy and pacing overrides for {@link retryControlWrite}. Production callers pass nothing
|
|
502
|
+
* (or a named policy such as {@link SCHEMA_INIT_RETRY_POLICY}); specs inject a recorded
|
|
503
|
+
* `sleep`, a shorter delay list, or a fake clock so no test ever waits out a real backoff.
|
|
504
|
+
*
|
|
505
|
+
* Every field defaults to the shipped control-write policy ({@link CONTROL_WRITE_ATTEMPTS},
|
|
506
|
+
* {@link CONTROL_WRITE_RETRY_DELAYS_MS}, {@link isRetriableControlWriteFailure}), so an
|
|
507
|
+
* omitted field is byte-identical to the behaviour before per-call-site policies existed.
|
|
508
|
+
* The field shapes and semantics live on the shared {@link ControlRetryOptions}.
|
|
509
|
+
*/
|
|
510
|
+
export type ControlWriteRetryOptions = ControlRetryOptions;
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* The one non-default retry policy: `ControlDatabase.applyControlSchema`'s distributed DDL, which
|
|
514
|
+
* additionally absorbs optimystic's self-coordination grace refusal and gets more attempts over a
|
|
515
|
+
* longer backoff to do it (see {@link SCHEMA_INIT_ATTEMPTS} and
|
|
516
|
+
* {@link SCHEMA_INIT_RETRY_DELAYS_MS}). Still bounded by the shared
|
|
517
|
+
* {@link CONTROL_WRITE_RETRY_BUDGET_MS}.
|
|
518
|
+
*
|
|
519
|
+
* NOTE: exactly one call site opts in today, and the re-run safety this policy assumes is that
|
|
520
|
+
* site's, not a general property — `apply schema` is a diff, and a failed apply is unwound whole
|
|
521
|
+
* and verified against the pre-apply catalog (full argument at the `applyControlSchema` call site
|
|
522
|
+
* in `control-database.ts`). A second opt-in must re-derive that argument for its own write body
|
|
523
|
+
* first; if this policy ever grows a third consumer, rename it for what the callers share rather
|
|
524
|
+
* than widening it by default.
|
|
525
|
+
*/
|
|
526
|
+
export const SCHEMA_INIT_RETRY_POLICY: Readonly<ControlWriteRetryOptions> = {
|
|
527
|
+
attempts: SCHEMA_INIT_ATTEMPTS,
|
|
528
|
+
delaysMs: SCHEMA_INIT_RETRY_DELAYS_MS,
|
|
529
|
+
isRetriable: isRetriableSchemaInitFailure,
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Run `attempt` up to {@link CONTROL_WRITE_ATTEMPTS} times (or `options.attempts`), retrying only
|
|
534
|
+
* failures the policy's classifier — {@link isRetriableControlWriteFailure} by default —
|
|
535
|
+
* calls transient, and only while {@link CONTROL_WRITE_RETRY_BUDGET_MS} has not elapsed.
|
|
536
|
+
*
|
|
537
|
+
* `attempt` must be atomic and re-runnable: a failed cluster write rolls back (nothing is
|
|
538
|
+
* half-applied), and re-running the body re-runs its reads too — `ControlDatabase`'s
|
|
539
|
+
* locked write bodies all satisfy this (see the contract on
|
|
540
|
+
* `ControlDatabase.withWriteLock`). The backoff sleeps happen with NO lock held; the caller
|
|
541
|
+
* takes and releases its lock inside `attempt`.
|
|
542
|
+
*
|
|
543
|
+
* On exhaustion (attempts or budget) the LAST error is rethrown unchanged — never wrapped,
|
|
544
|
+
* so the exact messages downstream code and the degraded-cohort-member scenario assert on
|
|
545
|
+
* survive. A non-retriable failure propagates from the attempt that raised it.
|
|
546
|
+
*
|
|
547
|
+
* Either way — declined or exhausted — an `options.onAbandon` observer is notified exactly
|
|
548
|
+
* once before the rethrow (a `ControlRetryAbandonment`). Only WRITES pass one: an abandoned
|
|
549
|
+
* read throws to a caller that is awaiting it, so nothing is lost silently there
|
|
550
|
+
* (`control-read-retry.ts` says so at its own seam), while an abandoned write may have no
|
|
551
|
+
* caller at all — a background self-record republish is `void`-ed with a `debug` catch, so
|
|
552
|
+
* its failure reaches nobody unless something is listening here.
|
|
553
|
+
* `ControlDatabase.setControlWriteAbandonedListener` is the only thing that passes one in
|
|
554
|
+
* production; `retryControlWrite` itself neither supplies nor requires an observer.
|
|
555
|
+
*/
|
|
556
|
+
export function retryControlWrite<T>(
|
|
557
|
+
attempt: () => Promise<T>,
|
|
558
|
+
options: ControlWriteRetryOptions = {}
|
|
559
|
+
): Promise<T> {
|
|
560
|
+
return retryControlOperation(attempt, {
|
|
561
|
+
attempts: CONTROL_WRITE_ATTEMPTS,
|
|
562
|
+
delaysMs: CONTROL_WRITE_RETRY_DELAYS_MS,
|
|
563
|
+
budgetMs: CONTROL_WRITE_RETRY_BUDGET_MS,
|
|
564
|
+
isRetriable: isRetriableControlWriteFailure,
|
|
565
|
+
// The default prefix, named anyway: the degraded-cohort scenario asserts on
|
|
566
|
+
// `Control write [<label>] …` lines byte-for-byte.
|
|
567
|
+
logPrefix: 'Control write',
|
|
568
|
+
}, options);
|
|
569
|
+
}
|