@serfab/cadre-core 0.11.0 → 0.13.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 +3 -2
- package/dist/announce-addrs.d.ts +49 -0
- package/dist/announce-addrs.js +78 -0
- package/dist/announce-addrs.js.map +1 -0
- package/dist/cadre-node.d.ts +607 -53
- package/dist/cadre-node.js +1268 -157
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-database.d.ts +174 -23
- package/dist/control-database.js +317 -107
- package/dist/control-database.js.map +1 -1
- package/dist/control-read-retry.d.ts +103 -0
- package/dist/control-read-retry.js +145 -0
- package/dist/control-read-retry.js.map +1 -0
- package/dist/control-retry.d.ts +101 -0
- package/dist/control-retry.js +108 -0
- package/dist/control-retry.js.map +1 -0
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +50 -6
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +59 -35
- package/dist/control-write-retry.js +21 -92
- package/dist/control-write-retry.js.map +1 -1
- package/dist/delegate-admission.d.ts +17 -15
- package/dist/delegate-admission.js +17 -15
- package/dist/delegate-admission.js.map +1 -1
- package/dist/enrolled-machine-store-file.d.ts +13 -0
- package/dist/enrolled-machine-store-file.js +41 -0
- package/dist/enrolled-machine-store-file.js.map +1 -0
- package/dist/enrolled-machine-store.d.ts +81 -0
- package/dist/enrolled-machine-store.js +225 -0
- package/dist/enrolled-machine-store.js.map +1 -0
- package/dist/file-durable-slot.js +5 -3
- package/dist/file-durable-slot.js.map +1 -1
- package/dist/index.d.ts +10 -6
- package/dist/index.js +39 -12
- package/dist/index.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +226 -26
- package/dist/membership-connection-gater.js +316 -30
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/node-local-snapshot.d.ts +6 -0
- package/dist/node-local-snapshot.js +7 -1
- package/dist/node-local-snapshot.js.map +1 -1
- package/dist/{strand-backfill.d.ts → peer-join-backfill.d.ts} +49 -20
- package/dist/{strand-backfill.js → peer-join-backfill.js} +146 -56
- package/dist/peer-join-backfill.js.map +1 -0
- package/dist/peer-record.d.ts +26 -0
- package/dist/peer-record.js +32 -0
- package/dist/peer-record.js.map +1 -1
- package/dist/relay-addrs.d.ts +145 -22
- package/dist/relay-addrs.js +314 -31
- package/dist/relay-addrs.js.map +1 -1
- package/dist/relay-reservation.d.ts +20 -15
- package/dist/relay-reservation.js +18 -14
- package/dist/relay-reservation.js.map +1 -1
- package/dist/strand-addr-protocol.js +16 -0
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-database.d.ts +12 -0
- package/dist/strand-database.js +16 -0
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-formation-manager.d.ts +22 -9
- package/dist/strand-formation-manager.js +20 -12
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +83 -9
- package/dist/strand-formation-protocol.js +86 -10
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +151 -17
- package/dist/strand-instance-manager.js +237 -32
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +118 -14
- package/dist/strand-membership-writer.js +203 -15
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-network-config.d.ts +84 -0
- package/dist/strand-network-config.js +136 -0
- package/dist/strand-network-config.js.map +1 -0
- package/dist/strand-revocation-enforcer.d.ts +392 -0
- package/dist/strand-revocation-enforcer.js +533 -0
- package/dist/strand-revocation-enforcer.js.map +1 -0
- package/dist/strand-solicitation.d.ts +10 -3
- package/dist/strand-solicitation.js +12 -7
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-wake-protocol.d.ts +47 -2
- package/dist/strand-wake-protocol.js +76 -6
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/strand-watcher.js +6 -1
- package/dist/strand-watcher.js.map +1 -1
- package/dist/types.d.ts +282 -43
- package/dist/types.js +7 -1
- package/dist/types.js.map +1 -1
- package/package.json +16 -8
- package/src/announce-addrs.ts +93 -0
- package/src/cadre-node.ts +1366 -171
- package/src/control-database.ts +2628 -2406
- package/src/control-read-retry.ts +167 -0
- package/src/control-retry.ts +190 -0
- package/src/control-schema.ts +50 -6
- package/src/control-write-retry.ts +30 -130
- package/src/delegate-admission.ts +17 -15
- package/src/enrolled-machine-store-file.ts +43 -0
- package/src/enrolled-machine-store.ts +280 -0
- package/src/file-durable-slot.ts +5 -3
- package/src/index.ts +477 -407
- package/src/membership-connection-gater.ts +539 -204
- package/src/node-local-snapshot.ts +13 -1
- package/src/peer-addr-book.ts +251 -250
- package/src/{strand-backfill.ts → peer-join-backfill.ts} +187 -75
- package/src/peer-record.ts +33 -0
- package/src/relay-addrs.ts +436 -111
- package/src/relay-reservation.ts +828 -823
- package/src/strand-addr-protocol.ts +17 -0
- package/src/strand-database.ts +17 -0
- package/src/strand-formation-manager.ts +511 -491
- package/src/strand-formation-protocol.ts +808 -692
- package/src/strand-instance-manager.ts +899 -611
- package/src/strand-membership-writer.ts +232 -15
- package/src/strand-network-config.ts +172 -0
- package/src/strand-revocation-enforcer.ts +653 -0
- package/src/strand-solicitation.ts +484 -472
- package/src/strand-wake-protocol.ts +94 -8
- package/src/strand-watcher.ts +319 -314
- package/src/types.ts +1554 -1309
- package/dist/strand-backfill.js.map +0 -1
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { chainMessages, retryControlOperation } from './control-retry.js';
|
|
2
|
+
import type { ControlRetryOptions } from './control-retry.js';
|
|
3
|
+
import { isUncommittedTransactorAggregate } from './control-write-retry.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Bounded retry for TRANSIENT control-read failures — the classifier and policy behind
|
|
7
|
+
* `ControlDatabase.readRows`, the single funnel every control read passes through.
|
|
8
|
+
*
|
|
9
|
+
* Why this exists: control WRITES have absorbed transient cluster failures since
|
|
10
|
+
* `control-write-retry.ts` landed, but a read had no second chance — one stream reset
|
|
11
|
+
* during a scan ended the read outright (measured: a rigged single `eval` failure killed
|
|
12
|
+
* `queryRevokedStamps` after exactly one attempt while the same injection against `exec`
|
|
13
|
+
* was absorbed). This module gives reads the same bounded re-presentation with a SHORTER
|
|
14
|
+
* deadline and a NARROWER failure set, because the tightest caller deadline over a control
|
|
15
|
+
* read is the inbound admission gate's 2 s fail-open timeout
|
|
16
|
+
* (`ADMISSION_DECISION_TIMEOUT_MS`, `membership-connection-gater.ts`) — a read budget that
|
|
17
|
+
* does not fit inside it with headroom spends its retries after the gate has already
|
|
18
|
+
* admitted.
|
|
19
|
+
*
|
|
20
|
+
* This is deliberately NOT "the write classifier minus its commit veto" — the retriable
|
|
21
|
+
* set differs in both directions (see {@link isRetriableControlReadFailure}). The loop
|
|
22
|
+
* lives in `control-retry.ts`, shared with the write policy; this module owns only the
|
|
23
|
+
* read policy. And like the write side, classification is by TEXT: optimystic's
|
|
24
|
+
* `OptimysticVirtualTable` catches every scan-path error and rethrows
|
|
25
|
+
* `new Error('Query failed: ' + message)` with NO `cause`
|
|
26
|
+
* (`quereus-plugin-optimystic/src/optimystic-module.ts`), so the typed
|
|
27
|
+
* `BlockUnavailableError` and its `reason` field are destroyed before they reach this
|
|
28
|
+
* repo; Quereus then wraps that as `Error during query on table '<T>': …` preserving
|
|
29
|
+
* `cause` (`quereus/src/runtime/emit/scan.ts`). Every matcher fails CLOSED — an upstream
|
|
30
|
+
* rewording stops the retry engaging, it never makes it unsafe.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Attempts allowed per control read: the first, plus two retries. Attempts are cheap here —
|
|
35
|
+
* the observed transient failure (the transactor's read-phase aggregate off a stream still
|
|
36
|
+
* forming) surfaces in ~25 ms — and {@link CONTROL_READ_RETRY_BUDGET_MS} is what terminates
|
|
37
|
+
* the loop when an attempt is slow instead.
|
|
38
|
+
*/
|
|
39
|
+
export const CONTROL_READ_ATTEMPTS = 3;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Backoff before read retry N, jittered ±50% and capped at the largest base by the shared
|
|
43
|
+
* loop — so worst case 150 ms + 400 ms = 550 ms of total sleep (the second delay's
|
|
44
|
+
* upside jitter is clipped by the cap). Much shorter than the write list because the
|
|
45
|
+
* whole loop must fit under {@link CONTROL_READ_RETRY_BUDGET_MS}; the spec pins that
|
|
46
|
+
* relationship rather than the number.
|
|
47
|
+
*/
|
|
48
|
+
export const CONTROL_READ_RETRY_DELAYS_MS: readonly number[] = [100, 400];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Elapsed-time ceiling on the whole read-retry loop, checked after a failed attempt BEFORE
|
|
52
|
+
* sleeping — so one slow attempt (e.g. a `cohort-unreachable` read that burned the
|
|
53
|
+
* transactor's own deadline) terminates the loop rather than compounding.
|
|
54
|
+
*
|
|
55
|
+
* 1500 ms, sized against `ADMISSION_DECISION_TIMEOUT_MS` (2000 ms): the inbound admission
|
|
56
|
+
* gate reads the control DB and is FAIL-OPEN on both throw and timeout, so a read that
|
|
57
|
+
* outlives the gate's deadline spends its retries after the gate has already admitted an
|
|
58
|
+
* unplaced peer. Under the deadline, a read that succeeds on its second attempt lets the
|
|
59
|
+
* gate make the real decision instead. `control-read-retry.spec.ts` asserts the
|
|
60
|
+
* relationship so a future edit to either constant reddens instead of silently
|
|
61
|
+
* reintroducing the fail-open admit. NOTE: `queryCadrePeers` / `queryPeerRecord` each
|
|
62
|
+
* issue TWO network reads (the revoked-stamp filter plus the row scan) and the budget is
|
|
63
|
+
* per read, so a membership read can spend up to two budgets back to back — still inside
|
|
64
|
+
* 2 s only because each stays well under it. Do not raise these numbers without
|
|
65
|
+
* re-checking against `ADMISSION_DECISION_TIMEOUT_MS`.
|
|
66
|
+
*/
|
|
67
|
+
export const CONTROL_READ_RETRY_BUDGET_MS = 1_500;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The block-unavailability reasons a repeat read can actually improve on, matched against
|
|
71
|
+
* upstream's exact template `Block <id> is unavailable (<reason>): …`
|
|
72
|
+
* (`BlockUnavailableError`, `db-core/src/network/struct.ts`):
|
|
73
|
+
*
|
|
74
|
+
* - **`peers-unreachable`** — part of the cohort answered and part could not be asked, and
|
|
75
|
+
* OTHER coordinators are reachable, so asking one of them can still settle it. Upstream's
|
|
76
|
+
* own definition invites the second attempt.
|
|
77
|
+
* - **`cohort-unreachable`** — the marginal member of the set. Upstream says the answer
|
|
78
|
+
* will not improve until this node's connectivity does — but during bring-up connectivity
|
|
79
|
+
* DOES improve within a second (the boot-time failure shape recorded in
|
|
80
|
+
* `tickets/blocked/control-read-over-fresh-edge-stream-resets`), and the cost of being
|
|
81
|
+
* wrong is bounded by {@link CONTROL_READ_RETRY_BUDGET_MS}, which is the whole reason it
|
|
82
|
+
* is safe to include.
|
|
83
|
+
*
|
|
84
|
+
* Deliberately NOT matched:
|
|
85
|
+
*
|
|
86
|
+
* - **`claimed-elsewhere`** — a cohort peer positively claims the block exists and nobody
|
|
87
|
+
* could corroborate or acquire it. MEASURED not to clear: reissuing the same call every
|
|
88
|
+
* second for 60 s returned the identical error every time (2026-08-20, recorded in the
|
|
89
|
+
* fix ticket). Retrying spends the whole budget and fails anyway; the root cause is
|
|
90
|
+
* upstream and tracked by `tickets/blocked/block-held-by-only-one-machine-is-unreadable`
|
|
91
|
+
* — this retry must not paper over it.
|
|
92
|
+
* - **`unmaterializable`** — records are held locally but cannot be reassembled. A local
|
|
93
|
+
* data problem; a second read reads the same records.
|
|
94
|
+
* - **`Block <id> may be stale`** (`BlockPossiblyStaleError`) — about currency, not
|
|
95
|
+
* existence: a cohort claim no reachable coordinator could confirm or refute will not
|
|
96
|
+
* resolve inside a few hundred milliseconds of backoff.
|
|
97
|
+
* - Anything unmatched. The default is no retry.
|
|
98
|
+
*/
|
|
99
|
+
const RETRIABLE_BLOCK_UNAVAILABLE =
|
|
100
|
+
/Block \S+ is unavailable \((?:peers-unreachable|cohort-unreachable)\)/;
|
|
101
|
+
|
|
102
|
+
/** A block-unavailable read failure whose reason a repeat read can improve on. */
|
|
103
|
+
function isRetriableBlockUnavailable(message: string): boolean {
|
|
104
|
+
return RETRIABLE_BLOCK_UNAVAILABLE.test(message);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Pacing overrides for {@link retryControlRead} — the read twin of
|
|
109
|
+
* `ControlWriteRetryOptions`. Every field defaults to the shipped read policy
|
|
110
|
+
* ({@link CONTROL_READ_ATTEMPTS}, {@link CONTROL_READ_RETRY_DELAYS_MS},
|
|
111
|
+
* {@link isRetriableControlReadFailure}); field shapes and semantics live on the shared
|
|
112
|
+
* {@link ControlRetryOptions}.
|
|
113
|
+
*/
|
|
114
|
+
export type ControlReadRetryOptions = ControlRetryOptions;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Did this control read fail in a way a repeat read can improve on?
|
|
118
|
+
*
|
|
119
|
+
* Two classes, both meaning "the cluster could not be ASKED properly just now":
|
|
120
|
+
*
|
|
121
|
+
* - the transactor's read-phase aggregate (`Some peers did not complete: …[block:…]`),
|
|
122
|
+
* reused verbatim from the write classifier — a `get` batch formats the single-block
|
|
123
|
+
* `[block:` token, and a read can never produce the commit-phase `[blocks:` token, so
|
|
124
|
+
* the write side's indeterminate-commit veto has nothing to veto here (a read commits
|
|
125
|
+
* nothing and re-running it is always safe);
|
|
126
|
+
* - the block-unavailability reasons in {@link RETRIABLE_BLOCK_UNAVAILABLE} (and only
|
|
127
|
+
* those — see its comment for the measured exclusions).
|
|
128
|
+
*
|
|
129
|
+
* Classifies by MESSAGE, walking the `cause` chain with the shared `chainMessages`,
|
|
130
|
+
* because only text survives the trip out of optimystic (see the module comment).
|
|
131
|
+
* Anything that is not an `Error` is never retried.
|
|
132
|
+
*/
|
|
133
|
+
export function isRetriableControlReadFailure(error: unknown): boolean {
|
|
134
|
+
if (!(error instanceof Error)) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
return chainMessages(error).some(message =>
|
|
138
|
+
isUncommittedTransactorAggregate(message) || isRetriableBlockUnavailable(message));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Run `attempt` — a full drain of one control read — up to
|
|
143
|
+
* {@link CONTROL_READ_ATTEMPTS} times, retrying only failures
|
|
144
|
+
* {@link isRetriableControlReadFailure} calls transient, and only while
|
|
145
|
+
* {@link CONTROL_READ_RETRY_BUDGET_MS} has not elapsed.
|
|
146
|
+
*
|
|
147
|
+
* Reads are idempotent, so `attempt` needs no atomicity contract — but it must be a
|
|
148
|
+
* COLLECTING drain, not a live iterator: a half-consumed iterator cannot be retried
|
|
149
|
+
* without re-yielding rows the caller already saw, which is why
|
|
150
|
+
* `ControlDatabase.readRows` materializes before this loop ever sees a failure.
|
|
151
|
+
*
|
|
152
|
+
* On exhaustion the LAST error is rethrown unchanged. Log lines carry the
|
|
153
|
+
* `Control read [<label>] …` prefix so a read's retries can be attributed among the
|
|
154
|
+
* several reads in flight concurrently in a real party.
|
|
155
|
+
*/
|
|
156
|
+
export function retryControlRead<T>(
|
|
157
|
+
attempt: () => Promise<T>,
|
|
158
|
+
options: ControlReadRetryOptions = {}
|
|
159
|
+
): Promise<T> {
|
|
160
|
+
return retryControlOperation(attempt, {
|
|
161
|
+
attempts: CONTROL_READ_ATTEMPTS,
|
|
162
|
+
delaysMs: CONTROL_READ_RETRY_DELAYS_MS,
|
|
163
|
+
budgetMs: CONTROL_READ_RETRY_BUDGET_MS,
|
|
164
|
+
isRetriable: isRetriableControlReadFailure,
|
|
165
|
+
logPrefix: 'Control read',
|
|
166
|
+
}, options);
|
|
167
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import debug from 'debug';
|
|
2
|
+
import { unwrapError } from '@quereus/quereus';
|
|
3
|
+
|
|
4
|
+
const log = debug('sereus:cadre:control-db');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The bounded retry loop shared by the control plane's transient-failure policies:
|
|
8
|
+
* writes (`control-write-retry.ts`, behind `ControlDatabase.lockedWithRetry`) and reads
|
|
9
|
+
* (`control-read-retry.ts`, behind `ControlDatabase.readRows`). One loop, two policy
|
|
10
|
+
* modules — they differ in classifier, attempt count, backoff and elapsed budget, but the
|
|
11
|
+
* loop mechanics (jittered capped backoff, budget checked after a failed attempt BEFORE
|
|
12
|
+
* sleeping, last error rethrown unchanged) are deliberately identical so a fix to one
|
|
13
|
+
* cannot silently miss the other.
|
|
14
|
+
*
|
|
15
|
+
* The log prefix is part of the policy because the write funnel's lines are asserted
|
|
16
|
+
* byte-identically by `control-write-degraded-cohort-member.integration.ts` — extracting
|
|
17
|
+
* this loop must not move a single character of them.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** A retry policy: what to retry, how often, and under which elapsed-time ceiling. */
|
|
21
|
+
export interface ControlRetryPolicy {
|
|
22
|
+
/** Total attempts, first included (floored at 1 by the loop). */
|
|
23
|
+
attempts: number;
|
|
24
|
+
/**
|
|
25
|
+
* Backoff base before retry N (last entry repeats), jittered ±50% — so the jitter can
|
|
26
|
+
* never floor a delay to zero — and each single sleep additionally capped at the
|
|
27
|
+
* LARGEST delay in the list, so a `stop()` racing a backoff is never held up longer
|
|
28
|
+
* than that. There is no `AbortSignal` at this seam to cancel a sleep outright; the
|
|
29
|
+
* cap is the substitute, deliberately not plumbing one through every call path.
|
|
30
|
+
*/
|
|
31
|
+
delaysMs: readonly number[];
|
|
32
|
+
/**
|
|
33
|
+
* Elapsed-time ceiling on the whole loop, measured from the START of the first attempt
|
|
34
|
+
* and checked after a failed attempt BEFORE sleeping — so one slow attempt terminates
|
|
35
|
+
* the loop rather than compounding, and retry adds ZERO latency to the case where it
|
|
36
|
+
* cannot help.
|
|
37
|
+
*/
|
|
38
|
+
budgetMs: number;
|
|
39
|
+
/** Which failures are transient enough to re-present. */
|
|
40
|
+
isRetriable: (error: unknown) => boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Subject of every log line the loop emits (`<prefix>[ [<label>]] …`). Defaults to
|
|
43
|
+
* `'Control write'`, which keeps the write funnel's lines byte-identical to what it
|
|
44
|
+
* logged before this loop was shared out of `control-write-retry.ts`.
|
|
45
|
+
*/
|
|
46
|
+
logPrefix?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Per-call overrides applied over a policy. Production callers pass at most `label`
|
|
51
|
+
* (usually via the policy module's wrapper); specs inject a recorded `sleep`, a shorter
|
|
52
|
+
* delay list, or a fake clock so no test ever waits out a real backoff.
|
|
53
|
+
*/
|
|
54
|
+
export interface ControlRetryOptions {
|
|
55
|
+
/**
|
|
56
|
+
* Total attempts, first included. Default: the policy's, floored at 1 — below that
|
|
57
|
+
* the loop would run the body zero times and rethrow a `lastError` nobody set.
|
|
58
|
+
*/
|
|
59
|
+
attempts?: number;
|
|
60
|
+
/** Backoff base before retry N (last entry repeats). Default: the policy's. */
|
|
61
|
+
delaysMs?: readonly number[];
|
|
62
|
+
/** Which failures are transient enough to re-present. Default: the policy's classifier. */
|
|
63
|
+
isRetriable?: (error: unknown) => boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Operation label stamped into every log line this loop emits (rendered as
|
|
66
|
+
* `<prefix> [<label>] …`), and NOTHING else — no behavioural effect. The debug log is
|
|
67
|
+
* the only surface where the loop's decisions are observable (the rethrown error is
|
|
68
|
+
* unchanged and no attempt counter is exposed), and several operations retry
|
|
69
|
+
* CONCURRENTLY in a real party, so an unlabelled line cannot be attributed. The
|
|
70
|
+
* degraded-cohort scenario asserts on the write funnel's lines per-operation.
|
|
71
|
+
*/
|
|
72
|
+
label?: string;
|
|
73
|
+
/** The sleep primitive. Default: `setTimeout`. */
|
|
74
|
+
sleep?: (ms: number) => Promise<void>;
|
|
75
|
+
/** Clock for the elapsed-budget check. Default: `Date.now`. */
|
|
76
|
+
now?: () => number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Run `attempt` up to the policy's attempt count, retrying only failures its classifier
|
|
81
|
+
* calls transient, and only while its elapsed budget has not run out.
|
|
82
|
+
*
|
|
83
|
+
* `attempt` must be safe to re-run — for writes that means atomic (a failed cluster write
|
|
84
|
+
* rolls back, nothing half-applies; the contract on `ControlDatabase.withWriteLock`), for
|
|
85
|
+
* reads it holds trivially. The backoff sleeps happen with NO lock held; a caller that
|
|
86
|
+
* locks takes and releases its lock inside `attempt`.
|
|
87
|
+
*
|
|
88
|
+
* On exhaustion (attempts or budget) the LAST error is rethrown unchanged — never
|
|
89
|
+
* wrapped, so the exact messages downstream code and the integration scenarios assert on
|
|
90
|
+
* survive. A non-retriable failure propagates from the attempt that raised it.
|
|
91
|
+
*/
|
|
92
|
+
export async function retryControlOperation<T>(
|
|
93
|
+
attempt: () => Promise<T>,
|
|
94
|
+
policy: ControlRetryPolicy,
|
|
95
|
+
options: ControlRetryOptions = {}
|
|
96
|
+
): Promise<T> {
|
|
97
|
+
const attempts = Math.max(1, options.attempts ?? policy.attempts);
|
|
98
|
+
const delays = options.delaysMs ?? policy.delaysMs;
|
|
99
|
+
const isRetriable = options.isRetriable ?? policy.isRetriable;
|
|
100
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
101
|
+
const now = options.now ?? Date.now;
|
|
102
|
+
const prefix = policy.logPrefix ?? 'Control write';
|
|
103
|
+
// Empty when unlabelled, so an unlabelled line is byte-identical to what the write
|
|
104
|
+
// loop logged before labels existed.
|
|
105
|
+
const tag = options.label ? ` [${options.label}]` : '';
|
|
106
|
+
const start = now();
|
|
107
|
+
let lastError: unknown;
|
|
108
|
+
let attemptsMade = 0;
|
|
109
|
+
for (let attemptNumber = 1; attemptNumber <= attempts; attemptNumber++) {
|
|
110
|
+
attemptsMade = attemptNumber;
|
|
111
|
+
try {
|
|
112
|
+
const result = await attempt();
|
|
113
|
+
if (attemptNumber > 1) {
|
|
114
|
+
log('%s%s committed on attempt %d/%d', prefix, tag, attemptNumber, attempts);
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (!isRetriable(error)) {
|
|
119
|
+
// The ONLY trace that this funnel saw a failure and declined it. Without it the
|
|
120
|
+
// log is silent either way, so "the classifier vetoed this one" is
|
|
121
|
+
// indistinguishable from "the retry is not wired into this path at all".
|
|
122
|
+
log('%s%s failed non-transiently on attempt %d/%d, not retried here: %s',
|
|
123
|
+
prefix, tag, attemptNumber, attempts, error);
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
lastError = error;
|
|
127
|
+
if (attemptNumber === attempts) {
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
const elapsed = now() - start;
|
|
131
|
+
if (elapsed >= policy.budgetMs) {
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
const delay = jitteredDelay(delays, attemptNumber);
|
|
135
|
+
log('%s%s failed transiently (attempt %d/%d), retrying in %d ms: %s',
|
|
136
|
+
prefix, tag, attemptNumber, attempts, delay, error);
|
|
137
|
+
await sleep(delay);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
log('%s%s failed after %d/%d attempt(s): %s', prefix, tag, attemptsMade, attempts, lastError);
|
|
141
|
+
throw lastError;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Every message in the failure's `cause` chain — the shared substrate both classifiers
|
|
146
|
+
* match against, since the failures they care about are not recognisable by type (the
|
|
147
|
+
* typed errors are destroyed on the way out of optimystic; only text survives).
|
|
148
|
+
*
|
|
149
|
+
* `unwrapError` declares its `message` as `string`, but it follows `.cause` without checking
|
|
150
|
+
* what that holds — a chain link that is not an `Error` (a stream rejected with a bare string
|
|
151
|
+
* reason, an `AbortSignal.reason` that is a plain object) yields `undefined` there, and
|
|
152
|
+
* calling `.includes` on it would throw a `TypeError` out of a classifier, INSIDE
|
|
153
|
+
* {@link retryControlOperation}'s catch, replacing the real failure with a confusing one.
|
|
154
|
+
* Non-strings are dropped: a link nobody can read is a link that matches nothing, which is
|
|
155
|
+
* already the conservative answer.
|
|
156
|
+
*
|
|
157
|
+
* NOTE: a cause chain with a CYCLE would spin forever inside `unwrapError` itself. No error
|
|
158
|
+
* in this repo builds one; if a hang ever localises to a control retry path, look there.
|
|
159
|
+
*/
|
|
160
|
+
export function chainMessages(error: Error): string[] {
|
|
161
|
+
return unwrapError(error)
|
|
162
|
+
.map(({ message }) => message)
|
|
163
|
+
.filter(message => typeof message === 'string');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Backoff for the retry that follows attempt `attemptNumber`: the matching base delay
|
|
168
|
+
* (last entry repeats), jittered ±50%, then capped at the list's largest base so no single
|
|
169
|
+
* sleep exceeds it (see {@link ControlRetryPolicy.delaysMs} for why). `Math.random` is
|
|
170
|
+
* fine here — the jitter only de-synchronizes concurrent retriers, nothing is derived
|
|
171
|
+
* from it.
|
|
172
|
+
*
|
|
173
|
+
* NOTE: the cap bites on the LAST delay, whose base IS the largest, so ~half of those sleeps
|
|
174
|
+
* land exactly on the cap rather than spread — de-synchronization is only partial there.
|
|
175
|
+
* Harmless while retriers are a handful of party nodes whose attempts already start seconds
|
|
176
|
+
* apart; if a party ever retries in a tight synchronized herd, raise the cap above the
|
|
177
|
+
* largest base instead of jittering about it.
|
|
178
|
+
*/
|
|
179
|
+
function jitteredDelay(delays: readonly number[], attemptNumber: number): number {
|
|
180
|
+
if (delays.length === 0) {
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
const base = delays[Math.min(attemptNumber - 1, delays.length - 1)]!;
|
|
184
|
+
const jittered = base * (0.5 + Math.random());
|
|
185
|
+
return Math.min(jittered, Math.max(...delays));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function defaultSleep(ms: number): Promise<void> {
|
|
189
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
190
|
+
}
|
package/src/control-schema.ts
CHANGED
|
@@ -142,6 +142,17 @@ declare schema CadreControl {
|
|
|
142
142
|
Id text primary key, -- UUID
|
|
143
143
|
MemberPrivateKey text null unique, -- Our private key as a member of this strand
|
|
144
144
|
Type text, -- Types: 'o' = Open, 'c' = Closed -- Open can still control writes in the sApp, but only Closed controls reads
|
|
145
|
+
FounderOwnerKey text null, -- ed25519 (base64url) owner key of the MACHINE that published this row
|
|
146
|
+
-- (== insert context.OwnerKey; in the reference model each machine's
|
|
147
|
+
-- owner key is the key behind its PeerId, so this names the founding
|
|
148
|
+
-- machine — the one machine that runs the strand's one-time founder
|
|
149
|
+
-- bootstrap). cadre-core derives "am I the founder?" at launch by
|
|
150
|
+
-- comparing it to the node's own owner key (cadre-node.ts
|
|
151
|
+
-- launchStrand). Null on a consent-seated strand — the consent branch
|
|
152
|
+
-- of AuthorizedInsert carries no signature, so there is no
|
|
153
|
+
-- trustworthy signer to record (see the NOTE on that branch below).
|
|
154
|
+
-- Provenance, not content: cadre-node.ts strandRowMismatches
|
|
155
|
+
-- deliberately excludes it from the identical-content comparison.
|
|
145
156
|
StampId text not null unique, -- single-use authorization nonce (anti-replay).
|
|
146
157
|
-- \`unique\` holds over LIVE rows only; a removed row's stamp is retired
|
|
147
158
|
-- permanently into Revocation (NotRevoked below).
|
|
@@ -172,8 +183,16 @@ declare schema CadreControl {
|
|
|
172
183
|
-- Mirrors OwnerKey.NoUpdate / FormationInvite.Immutable.
|
|
173
184
|
constraint NoUpdate check on update (false),
|
|
174
185
|
constraint AuthorizedInsert check on insert (
|
|
175
|
-
-- Authorized by an owner signing over THIS row (Id, Type, MemberPrivateKey, StampId); single-use via unique StampId
|
|
176
|
-
|
|
186
|
+
-- Authorized by an owner signing over THIS row (Id, Type, MemberPrivateKey, StampId); single-use via unique StampId.
|
|
187
|
+
-- FounderOwnerKey persists WHO published the row, so the founding machine can later
|
|
188
|
+
-- recognise its own strand (founder derivation, cadre-node.ts launchStrand). The
|
|
189
|
+
-- equality pins the stored column to the VERIFIED signer — same shape as
|
|
190
|
+
-- CadrePeer.VouchOwner — so a writer cannot record a founder it is not. Deliberately
|
|
191
|
+
-- NOT added to the signed digest: the equality already binds it to the signature's
|
|
192
|
+
-- context.OwnerKey, and widening the digest would churn every existing
|
|
193
|
+
-- insertStrand field-order contract for nothing.
|
|
194
|
+
(exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.Strand', 'add', new.Id, new.Type, coalesce(new.MemberPrivateKey, ''), new.StampId), context.Signature, A.Key, 'ed25519'))
|
|
195
|
+
and new.FounderOwnerKey = context.OwnerKey)
|
|
177
196
|
|
|
178
197
|
-- or authorized WITHOUT a signature by a redemption record naming THIS EXACT ROW: a
|
|
179
198
|
-- FormationUsage row carrying both this strand's Id and its one-off StampId
|
|
@@ -240,6 +259,15 @@ declare schema CadreControl {
|
|
|
240
259
|
or (
|
|
241
260
|
new.Type = 'o'
|
|
242
261
|
and new.MemberPrivateKey is null
|
|
262
|
+
-- NOTE: a consent-seated strand records NO founder machine — this branch
|
|
263
|
+
-- carries no signature, so there is no trustworthy signer to persist.
|
|
264
|
+
-- Consequence: the restart-before-founding orphan shape (a relaunch cannot
|
|
265
|
+
-- derive founder-ness from the row) persists for consent-formed strands
|
|
266
|
+
-- only; the responder that provisions one must keep passing an explicit
|
|
267
|
+
-- founder flag at launch. Revisit if consent-formed strands ever hit the
|
|
268
|
+
-- empty-Header symptom in practice: FormationUsage names the redeeming
|
|
269
|
+
-- peer (PeerKey), which is the candidate derivation source.
|
|
270
|
+
and new.FounderOwnerKey is null
|
|
243
271
|
and exists (
|
|
244
272
|
select 1 from FormationUsage FU
|
|
245
273
|
where FU.StrandId = new.Id
|
|
@@ -487,7 +515,7 @@ declare schema CadreControl {
|
|
|
487
515
|
ExpiresAt datetime null,
|
|
488
516
|
TotalUses int null check (TotalUses >= 0),
|
|
489
517
|
ValidationUrl text null, -- Web hook - send disclosure, IP address...
|
|
490
|
-
StrandId text null, -- Host strand this invite binds to (provision-then-record). Non-null => the responder records consent against this pre-existing strand; null =>
|
|
518
|
+
StrandId text null, -- Host strand this invite binds to (provision-then-record). Non-null => the responder records consent against this pre-existing strand; null => unbound invite: the responder provisions a fresh open strand and atomically records its one consent row.
|
|
491
519
|
StampId text not null unique, -- single-use authorization nonce (anti-replay)
|
|
492
520
|
constraint AuthorizedInsert check on insert (
|
|
493
521
|
-- Authorized by an owner signing over THIS row
|
|
@@ -676,9 +704,25 @@ declare schema CadreControl {
|
|
|
676
704
|
|
|
677
705
|
-- Token is no longer a primary-key prefix (the key is the redemption nonce), so the
|
|
678
706
|
-- per-token filters — the cap count in Authorized above, and cadre-core's
|
|
679
|
-
-- countFormationUsage / isTokenUsed / hasOutstandingFormationInvite —
|
|
680
|
-
--
|
|
681
|
-
|
|
707
|
+
-- countFormationUsage / isTokenUsed / hasOutstandingFormationInvite — are served by a
|
|
708
|
+
-- table scan: a bare equality on a non-key column is a shape the optimystic vtab declines
|
|
709
|
+
-- to claim, so the engine applies the filter itself.
|
|
710
|
+
--
|
|
711
|
+
-- There WAS an \`index FormationUsageByToken on FormationUsage (Token)\` here, declared by
|
|
712
|
+
-- \`formation-unique-token-redesign\` purely to spare that scan. It was removed 2026-08-25
|
|
713
|
+
-- because the tripwire that change recorded ("an under-reporting descent would admit a
|
|
714
|
+
-- seat the invitation did not pay for — a failure mode a scan could not produce") fired:
|
|
715
|
+
-- a secondary-index descent on a second machine returns only the rows THAT machine wrote,
|
|
716
|
+
-- so a cap counted through it under-reports without bound across machines. Measured with
|
|
717
|
+
-- the engine's own \`index:seek\` trace — each machine's copy of the index sub-collection
|
|
718
|
+
-- sits at a different revision while their copies of the table itself agree, and the
|
|
719
|
+
-- refresh that runs immediately before the descent does not close the gap. Tracked in
|
|
720
|
+
-- \`tickets/blocked/secondary-index-seek-blind-to-sibling-rows.md\`, and upstream as
|
|
721
|
+
-- \`bug-index-subcollection-sits-one-revision-behind-on-the-sibling\`.
|
|
722
|
+
--
|
|
723
|
+
-- DO NOT re-declare it to speed the count up until that is fixed upstream. Re-adding this
|
|
724
|
+
-- one line is also how the reproducer comes back: it is what makes
|
|
725
|
+
-- \`strand-formation-concurrent-redemption\` fail.
|
|
682
726
|
|
|
683
727
|
-- Append-only retirement record for the one-off StampId nonces of removed OwnerKey /
|
|
684
728
|
-- CadrePeer / ValidationKey / Strand rows. Without it a removal was undoable: the add
|
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
const log = debug('sereus:cadre:control-db');
|
|
1
|
+
import { chainMessages, retryControlOperation } from './control-retry.js';
|
|
2
|
+
import type { ControlRetryOptions } from './control-retry.js';
|
|
5
3
|
|
|
6
4
|
/**
|
|
7
5
|
* Bounded retry for TRANSIENT control-write failures — the classifier and loop behind
|
|
@@ -29,7 +27,9 @@ const log = debug('sereus:cadre:control-db');
|
|
|
29
27
|
* list must be safe for all ~19 callers.
|
|
30
28
|
*
|
|
31
29
|
* Lives outside `control-database.ts` so a spec can drive the classifier against real engine
|
|
32
|
-
* errors without importing the whole database class.
|
|
30
|
+
* errors without importing the whole database class. The loop itself lives in
|
|
31
|
+
* `control-retry.ts`, shared with the READ policy (`control-read-retry.ts`) — this module
|
|
32
|
+
* owns the write policies: their classifiers, attempt counts, backoff and budget.
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
/**
|
|
@@ -167,8 +167,12 @@ const SUPER_MAJORITY_SHORTFALL_UNANSWERED =
|
|
|
167
167
|
* An aggregate whose details came out EMPTY (possible when `formatBatchStatuses` has no
|
|
168
168
|
* batches to format) carries neither token, matches nothing here, and is not retried — an
|
|
169
169
|
* unattributable failure is not a proven non-commit.
|
|
170
|
+
*
|
|
171
|
+
* Exported for the READ classifier (`control-read-retry.ts`), which reuses it verbatim: a
|
|
172
|
+
* `get` batch formats the same single-block `[block:` token, and a read can never produce
|
|
173
|
+
* the commit-phase `[blocks:` shape at all.
|
|
170
174
|
*/
|
|
171
|
-
function isUncommittedTransactorAggregate(message: string): boolean {
|
|
175
|
+
export function isUncommittedTransactorAggregate(message: string): boolean {
|
|
172
176
|
return TRANSACTOR_AGGREGATE.test(message) && message.includes(SINGLE_BLOCK_BATCH_TOKEN);
|
|
173
177
|
}
|
|
174
178
|
|
|
@@ -221,25 +225,6 @@ function isSelfCoordinationGraceRefusal(message: string): boolean {
|
|
|
221
225
|
return SELF_COORDINATION_GRACE_REFUSAL.test(message);
|
|
222
226
|
}
|
|
223
227
|
|
|
224
|
-
/**
|
|
225
|
-
* Every message in the failure's `cause` chain.
|
|
226
|
-
*
|
|
227
|
-
* `unwrapError` declares its `message` as `string`, but it follows `.cause` without checking what
|
|
228
|
-
* that holds — a chain link that is not an `Error` (a stream rejected with a bare string reason,
|
|
229
|
-
* an `AbortSignal.reason` that is a plain object) yields `undefined` there, and calling
|
|
230
|
-
* `.includes` on it would throw a `TypeError` out of this classifier, INSIDE `retryControlWrite`'s
|
|
231
|
-
* catch, replacing the real control-write failure with a confusing one. Non-strings are dropped:
|
|
232
|
-
* a link nobody can read is a link that matches nothing, which is already the conservative answer.
|
|
233
|
-
*
|
|
234
|
-
* NOTE: a cause chain with a CYCLE would spin forever inside `unwrapError` itself. No error in
|
|
235
|
-
* this repo builds one; if a hang ever localises to a control-write failure path, look there.
|
|
236
|
-
*/
|
|
237
|
-
function chainMessages(error: Error): string[] {
|
|
238
|
-
return unwrapError(error)
|
|
239
|
-
.map(({ message }) => message)
|
|
240
|
-
.filter(message => typeof message === 'string');
|
|
241
|
-
}
|
|
242
|
-
|
|
243
228
|
/**
|
|
244
229
|
* The error messages that mean "the cohort did not answer AND nothing committed", and nothing
|
|
245
230
|
* else. The observed one-shot failure was `registerSelf()` racing a connection still forming:
|
|
@@ -282,7 +267,8 @@ const RETRIABLE_SCHEMA_INIT_MATCHERS: readonly ((message: string) => boolean)[]
|
|
|
282
267
|
* Did this control write fail because the cluster cohort did not ANSWER — i.e. is
|
|
283
268
|
* re-presenting the SAME signed write a moment later the right response?
|
|
284
269
|
*
|
|
285
|
-
* Classifies by MESSAGE, walking the `cause` chain with
|
|
270
|
+
* Classifies by MESSAGE, walking the `cause` chain with the shared {@link chainMessages}
|
|
271
|
+
* (Quereus' own `unwrapError` underneath) —
|
|
286
272
|
* the failure surface is not recognisable by type. The transactor and the coordinator both throw bare `Error`s, and by
|
|
287
273
|
* the time one reaches `ControlDatabase` it is wrapped in a `QuereusError` (the real chain
|
|
288
274
|
* is `QuereusError` → `Error` → `Error`), so the match must work at any depth. Anything
|
|
@@ -336,41 +322,16 @@ function matchesRetriableMessage(
|
|
|
336
322
|
}
|
|
337
323
|
|
|
338
324
|
/**
|
|
339
|
-
* Policy and pacing for {@link retryControlWrite}. Production callers pass nothing
|
|
340
|
-
* policy such as {@link SCHEMA_INIT_RETRY_POLICY}); specs inject a recorded
|
|
341
|
-
* delay list, or a fake clock so no test ever waits out a real backoff.
|
|
325
|
+
* Policy and pacing overrides for {@link retryControlWrite}. Production callers pass nothing
|
|
326
|
+
* (or a named policy such as {@link SCHEMA_INIT_RETRY_POLICY}); specs inject a recorded
|
|
327
|
+
* `sleep`, a shorter delay list, or a fake clock so no test ever waits out a real backoff.
|
|
342
328
|
*
|
|
343
|
-
* Every field defaults to the shipped control-write policy
|
|
344
|
-
*
|
|
329
|
+
* Every field defaults to the shipped control-write policy ({@link CONTROL_WRITE_ATTEMPTS},
|
|
330
|
+
* {@link CONTROL_WRITE_RETRY_DELAYS_MS}, {@link isRetriableControlWriteFailure}), so an
|
|
331
|
+
* omitted field is byte-identical to the behaviour before per-call-site policies existed.
|
|
332
|
+
* The field shapes and semantics live on the shared {@link ControlRetryOptions}.
|
|
345
333
|
*/
|
|
346
|
-
export
|
|
347
|
-
/**
|
|
348
|
-
* Total attempts, first included. Default {@link CONTROL_WRITE_ATTEMPTS}, floored at 1 —
|
|
349
|
-
* below that the loop would run the body zero times and rethrow a `lastError` nobody set.
|
|
350
|
-
*/
|
|
351
|
-
attempts?: number;
|
|
352
|
-
/** Backoff base before retry N (last entry repeats). Default {@link CONTROL_WRITE_RETRY_DELAYS_MS}. */
|
|
353
|
-
delaysMs?: readonly number[];
|
|
354
|
-
/**
|
|
355
|
-
* Which failures are transient enough to re-present. Default
|
|
356
|
-
* {@link isRetriableControlWriteFailure}; the only other shipped value is
|
|
357
|
-
* {@link isRetriableSchemaInitFailure}, via {@link SCHEMA_INIT_RETRY_POLICY}.
|
|
358
|
-
*/
|
|
359
|
-
isRetriable?: (error: unknown) => boolean;
|
|
360
|
-
/**
|
|
361
|
-
* Operation label stamped into every log line this loop emits (rendered as
|
|
362
|
-
* `Control write [<label>] …`), and NOTHING else — no behavioural effect. The debug
|
|
363
|
-
* log is the only surface where the loop's decisions are observable (the rethrown
|
|
364
|
-
* error is unchanged and no attempt counter is exposed), and several writes retry
|
|
365
|
-
* CONCURRENTLY in a real party, so an unlabelled line cannot be attributed to a
|
|
366
|
-
* write. The degraded-cohort scenario asserts on these lines per-operation.
|
|
367
|
-
*/
|
|
368
|
-
label?: string;
|
|
369
|
-
/** The sleep primitive. Default: `setTimeout`. */
|
|
370
|
-
sleep?: (ms: number) => Promise<void>;
|
|
371
|
-
/** Clock for the elapsed-budget check. Default: `Date.now`. */
|
|
372
|
-
now?: () => number;
|
|
373
|
-
}
|
|
334
|
+
export type ControlWriteRetryOptions = ControlRetryOptions;
|
|
374
335
|
|
|
375
336
|
/**
|
|
376
337
|
* The one non-default retry policy: `ControlDatabase.loadSchema`'s distributed DDL, which
|
|
@@ -406,78 +367,17 @@ export const SCHEMA_INIT_RETRY_POLICY: Readonly<ControlWriteRetryOptions> = {
|
|
|
406
367
|
* so the exact messages downstream code and the degraded-cohort-member scenario assert on
|
|
407
368
|
* survive. A non-retriable failure propagates from the attempt that raised it.
|
|
408
369
|
*/
|
|
409
|
-
export
|
|
370
|
+
export function retryControlWrite<T>(
|
|
410
371
|
attempt: () => Promise<T>,
|
|
411
372
|
options: ControlWriteRetryOptions = {}
|
|
412
373
|
): Promise<T> {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
let lastError: unknown;
|
|
423
|
-
let attemptsMade = 0;
|
|
424
|
-
for (let attemptNumber = 1; attemptNumber <= attempts; attemptNumber++) {
|
|
425
|
-
attemptsMade = attemptNumber;
|
|
426
|
-
try {
|
|
427
|
-
const result = await attempt();
|
|
428
|
-
if (attemptNumber > 1) {
|
|
429
|
-
log('Control write%s committed on attempt %d/%d', tag, attemptNumber, attempts);
|
|
430
|
-
}
|
|
431
|
-
return result;
|
|
432
|
-
} catch (error) {
|
|
433
|
-
if (!isRetriable(error)) {
|
|
434
|
-
// The ONLY trace that this funnel saw a failure and declined it. Without it the
|
|
435
|
-
// log is silent either way, so "the classifier vetoed this one" is
|
|
436
|
-
// indistinguishable from "the retry is not wired into this path at all".
|
|
437
|
-
log('Control write%s failed non-transiently on attempt %d/%d, not retried here: %s',
|
|
438
|
-
tag, attemptNumber, attempts, error);
|
|
439
|
-
throw error;
|
|
440
|
-
}
|
|
441
|
-
lastError = error;
|
|
442
|
-
if (attemptNumber === attempts) {
|
|
443
|
-
break;
|
|
444
|
-
}
|
|
445
|
-
const elapsed = now() - start;
|
|
446
|
-
if (elapsed >= CONTROL_WRITE_RETRY_BUDGET_MS) {
|
|
447
|
-
break;
|
|
448
|
-
}
|
|
449
|
-
const delay = jitteredDelay(delays, attemptNumber);
|
|
450
|
-
log('Control write%s failed transiently (attempt %d/%d), retrying in %d ms: %s',
|
|
451
|
-
tag, attemptNumber, attempts, delay, error);
|
|
452
|
-
await sleep(delay);
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
log('Control write%s failed after %d/%d attempt(s): %s', tag, attemptsMade, attempts, lastError);
|
|
456
|
-
throw lastError;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
/**
|
|
460
|
-
* Backoff for the retry that follows attempt `attemptNumber`: the matching base delay
|
|
461
|
-
* (last entry repeats), jittered ±50%, then capped at the list's largest base so no single
|
|
462
|
-
* sleep exceeds it (see {@link CONTROL_WRITE_RETRY_DELAYS_MS} for why). `Math.random` is
|
|
463
|
-
* fine here — the jitter only de-synchronizes concurrent retriers, nothing is derived
|
|
464
|
-
* from it.
|
|
465
|
-
*
|
|
466
|
-
* NOTE: the cap bites on the LAST delay, whose base IS the largest, so ~half of those sleeps
|
|
467
|
-
* land exactly on the cap rather than spread — de-synchronization is only partial there.
|
|
468
|
-
* Harmless while retriers are a handful of party nodes whose attempts already start seconds
|
|
469
|
-
* apart; if a party ever retries in a tight synchronized herd, raise the cap above the
|
|
470
|
-
* largest base instead of jittering about it.
|
|
471
|
-
*/
|
|
472
|
-
function jitteredDelay(delays: readonly number[], attemptNumber: number): number {
|
|
473
|
-
if (delays.length === 0) {
|
|
474
|
-
return 0;
|
|
475
|
-
}
|
|
476
|
-
const base = delays[Math.min(attemptNumber - 1, delays.length - 1)]!;
|
|
477
|
-
const jittered = base * (0.5 + Math.random());
|
|
478
|
-
return Math.min(jittered, Math.max(...delays));
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
function defaultSleep(ms: number): Promise<void> {
|
|
482
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
374
|
+
return retryControlOperation(attempt, {
|
|
375
|
+
attempts: CONTROL_WRITE_ATTEMPTS,
|
|
376
|
+
delaysMs: CONTROL_WRITE_RETRY_DELAYS_MS,
|
|
377
|
+
budgetMs: CONTROL_WRITE_RETRY_BUDGET_MS,
|
|
378
|
+
isRetriable: isRetriableControlWriteFailure,
|
|
379
|
+
// The default prefix, named anyway: the degraded-cohort scenario asserts on
|
|
380
|
+
// `Control write [<label>] …` lines byte-for-byte.
|
|
381
|
+
logPrefix: 'Control write',
|
|
382
|
+
}, options);
|
|
483
383
|
}
|