@serfab/cadre-core 0.13.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -6
- package/dist/bootstrap-peer-store-file.js +2 -2
- package/dist/bootstrap-peer-store.d.ts +32 -12
- package/dist/bootstrap-peer-store.js +41 -15
- package/dist/bootstrap-peer-store.js.map +1 -1
- package/dist/cadre-node.d.ts +558 -80
- package/dist/cadre-node.js +1060 -170
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-authorization.d.ts +2 -2
- package/dist/control-authorization.js +1 -0
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.d.ts +13 -26
- package/dist/control-cohort.js +0 -26
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +299 -44
- package/dist/control-database.js +469 -69
- package/dist/control-database.js.map +1 -1
- package/dist/control-read-retry.d.ts +40 -11
- package/dist/control-read-retry.js +48 -15
- package/dist/control-read-retry.js.map +1 -1
- package/dist/control-retry.d.ts +58 -3
- package/dist/control-retry.js +58 -3
- package/dist/control-retry.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +103 -19
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +80 -11
- package/dist/control-write-retry.js +168 -29
- package/dist/control-write-retry.js.map +1 -1
- package/dist/enrolled-machine-store.js +2 -2
- package/dist/index.d.ts +14 -7
- package/dist/index.js +33 -6
- package/dist/index.js.map +1 -1
- package/dist/node-local-snapshot.d.ts +15 -7
- package/dist/node-local-snapshot.js +23 -8
- package/dist/node-local-snapshot.js.map +1 -1
- package/dist/peer-dial.d.ts +143 -0
- package/dist/peer-dial.js +176 -0
- package/dist/peer-dial.js.map +1 -0
- package/dist/relay-addrs.d.ts +90 -52
- package/dist/relay-addrs.js +91 -59
- package/dist/relay-addrs.js.map +1 -1
- package/dist/relay-reservation.d.ts +71 -3
- package/dist/relay-reservation.js +116 -8
- package/dist/relay-reservation.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +30 -1
- package/dist/seed-bootstrap.js +62 -17
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/storage-scope.d.ts +62 -0
- package/dist/storage-scope.js +75 -0
- package/dist/storage-scope.js.map +1 -0
- package/dist/strand-database.d.ts +34 -16
- package/dist/strand-database.js +38 -24
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +152 -0
- package/dist/strand-first-sync-gate.js +208 -0
- package/dist/strand-first-sync-gate.js.map +1 -0
- package/dist/strand-formation-manager.d.ts +87 -5
- package/dist/strand-formation-manager.js +109 -5
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +21 -1
- package/dist/strand-formation-protocol.js +30 -0
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +243 -6
- package/dist/strand-instance-manager.js +470 -37
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-member-key.d.ts +20 -12
- package/dist/strand-member-key.js +20 -12
- package/dist/strand-member-key.js.map +1 -1
- package/dist/strand-member-registry.js +3 -0
- package/dist/strand-member-registry.js.map +1 -1
- package/dist/strand-membership-reconciler.d.ts +361 -0
- package/dist/strand-membership-reconciler.js +548 -0
- package/dist/strand-membership-reconciler.js.map +1 -0
- package/dist/strand-membership-writer.d.ts +131 -11
- package/dist/strand-membership-writer.js +396 -134
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-network-config.d.ts +46 -14
- package/dist/strand-network-config.js +79 -23
- package/dist/strand-network-config.js.map +1 -1
- package/dist/strand-solicitation.d.ts +10 -1
- package/dist/strand-solicitation.js +5 -0
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-wake-protocol.d.ts +11 -7
- package/dist/strand-wake-protocol.js +20 -52
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/strand-watcher.js +6 -0
- package/dist/strand-watcher.js.map +1 -1
- package/dist/timed-step.d.ts +12 -0
- package/dist/timed-step.js +28 -0
- package/dist/timed-step.js.map +1 -0
- package/dist/timeout-scheduler.d.ts +19 -0
- package/dist/timeout-scheduler.js +22 -0
- package/dist/timeout-scheduler.js.map +1 -0
- package/dist/types.d.ts +262 -30
- package/dist/types.js.map +1 -1
- package/package.json +8 -8
- package/src/bootstrap-peer-store-file.ts +2 -2
- package/src/bootstrap-peer-store.ts +69 -27
- package/src/cadre-node.ts +7301 -6353
- package/src/control-authorization.ts +2 -1
- package/src/control-cohort.ts +122 -130
- package/src/control-database.ts +537 -69
- package/src/control-read-retry.ts +202 -167
- package/src/control-retry.ts +297 -190
- package/src/control-schema.ts +103 -19
- package/src/control-write-retry.ts +525 -383
- package/src/enrolled-machine-store.ts +2 -2
- package/src/index.ts +83 -6
- package/src/node-local-snapshot.ts +25 -8
- package/src/peer-dial.ts +215 -0
- package/src/relay-addrs.ts +458 -436
- package/src/relay-reservation.ts +951 -828
- package/src/seed-bootstrap.ts +76 -18
- package/src/storage-scope.ts +78 -0
- package/src/strand-database.ts +50 -27
- package/src/strand-first-sync-gate.ts +258 -0
- package/src/strand-formation-manager.ts +140 -5
- package/src/strand-formation-protocol.ts +43 -1
- package/src/strand-instance-manager.ts +577 -37
- package/src/strand-member-key.ts +20 -12
- package/src/strand-member-registry.ts +3 -0
- package/src/strand-membership-reconciler.ts +680 -0
- package/src/strand-membership-writer.ts +527 -189
- package/src/strand-network-config.ts +102 -24
- package/src/strand-solicitation.ts +16 -1
- package/src/strand-wake-protocol.ts +21 -66
- package/src/strand-watcher.ts +6 -0
- package/src/timed-step.ts +28 -0
- package/src/timeout-scheduler.ts +28 -0
- package/src/types.ts +1789 -1554
package/src/control-retry.ts
CHANGED
|
@@ -1,190 +1,297 @@
|
|
|
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
|
-
/**
|
|
21
|
-
export
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
*
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* the
|
|
68
|
-
*
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
+
/** Which of the loop's two give-up exits fired. */
|
|
21
|
+
export type ControlRetryAbandonReason =
|
|
22
|
+
/** The classifier declined the failure as non-transient — no retry was attempted. */
|
|
23
|
+
| 'declined'
|
|
24
|
+
/** Every allowed attempt ran and failed transiently. */
|
|
25
|
+
| 'attempts'
|
|
26
|
+
/** The elapsed budget ran out before the next attempt could be started. */
|
|
27
|
+
| 'budget';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What the loop gave up on, handed to {@link ControlRetryOptions.onAbandon} exactly once per
|
|
31
|
+
* abandoned operation. Enough to attribute the loss without reading the debug log: which
|
|
32
|
+
* operation, how far it got, why it stopped and what it failed with.
|
|
33
|
+
*/
|
|
34
|
+
export interface ControlRetryAbandonment {
|
|
35
|
+
/** The operation label ({@link ControlRetryOptions.label}); absent when unlabelled. */
|
|
36
|
+
label?: string;
|
|
37
|
+
/** Attempts actually run, first included. */
|
|
38
|
+
attemptsMade: number;
|
|
39
|
+
/** Attempts the loop was allowed to run. */
|
|
40
|
+
attemptsAllowed: number;
|
|
41
|
+
/** Wall clock from the start of attempt 1 to the give-up, on the policy's clock. */
|
|
42
|
+
elapsedMs: number;
|
|
43
|
+
/** Which exit fired. */
|
|
44
|
+
reason: ControlRetryAbandonReason;
|
|
45
|
+
/** The error the loop is about to rethrow, unchanged. */
|
|
46
|
+
error: unknown;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Notified once per abandoned operation. Must not throw — the loop catches and logs if it does. */
|
|
50
|
+
export type ControlRetryAbandonListener = (abandonment: ControlRetryAbandonment) => void;
|
|
51
|
+
|
|
52
|
+
/** A retry policy: what to retry, how often, and under which elapsed-time ceiling. */
|
|
53
|
+
export interface ControlRetryPolicy {
|
|
54
|
+
/** Total attempts, first included (floored at 1 by the loop). */
|
|
55
|
+
attempts: number;
|
|
56
|
+
/**
|
|
57
|
+
* Backoff base before retry N (last entry repeats), jittered ±50% — so the jitter can
|
|
58
|
+
* never floor a delay to zero — and each single sleep additionally capped at the
|
|
59
|
+
* LARGEST delay in the list, so a `stop()` racing a backoff is never held up longer
|
|
60
|
+
* than that. There is no `AbortSignal` at this seam to cancel a sleep outright; the
|
|
61
|
+
* cap is the substitute, deliberately not plumbing one through every call path.
|
|
62
|
+
*/
|
|
63
|
+
delaysMs: readonly number[];
|
|
64
|
+
/**
|
|
65
|
+
* Elapsed-time ceiling on the whole loop, measured from the START of the first attempt
|
|
66
|
+
* and checked after a failed attempt BEFORE sleeping — so one slow attempt terminates
|
|
67
|
+
* the loop rather than compounding, and retry adds ZERO latency to the case where it
|
|
68
|
+
* cannot help.
|
|
69
|
+
*/
|
|
70
|
+
budgetMs: number;
|
|
71
|
+
/** Which failures are transient enough to re-present. */
|
|
72
|
+
isRetriable: (error: unknown) => boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Subject of every log line the loop emits (`<prefix>[ [<label>]] …`). Defaults to
|
|
75
|
+
* `'Control write'`, which keeps the write funnel's lines byte-identical to what it
|
|
76
|
+
* logged before this loop was shared out of `control-write-retry.ts`.
|
|
77
|
+
*/
|
|
78
|
+
logPrefix?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Per-call overrides applied over a policy. Production callers pass at most `label`
|
|
83
|
+
* (usually via the policy module's wrapper); specs inject a recorded `sleep`, a shorter
|
|
84
|
+
* delay list, or a fake clock so no test ever waits out a real backoff.
|
|
85
|
+
*/
|
|
86
|
+
export interface ControlRetryOptions {
|
|
87
|
+
/**
|
|
88
|
+
* Total attempts, first included. Default: the policy's, floored at 1 — below that
|
|
89
|
+
* the loop would run the body zero times and rethrow a `lastError` nobody set.
|
|
90
|
+
*/
|
|
91
|
+
attempts?: number;
|
|
92
|
+
/** Backoff base before retry N (last entry repeats). Default: the policy's. */
|
|
93
|
+
delaysMs?: readonly number[];
|
|
94
|
+
/** Which failures are transient enough to re-present. Default: the policy's classifier. */
|
|
95
|
+
isRetriable?: (error: unknown) => boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Operation label stamped into every log line this loop emits (rendered as
|
|
98
|
+
* `<prefix> [<label>] …`), and NOTHING else — no behavioural effect. The debug log is
|
|
99
|
+
* the only surface where the loop's decisions are observable (the rethrown error is
|
|
100
|
+
* unchanged and no attempt counter is exposed), and several operations retry
|
|
101
|
+
* CONCURRENTLY in a real party, so an unlabelled line cannot be attributed. The
|
|
102
|
+
* degraded-cohort scenario asserts on the write funnel's lines per-operation.
|
|
103
|
+
*/
|
|
104
|
+
label?: string;
|
|
105
|
+
/** The sleep primitive. Default: `setTimeout`. */
|
|
106
|
+
sleep?: (ms: number) => Promise<void>;
|
|
107
|
+
/** Clock for the elapsed-budget check. Default: `Date.now`. */
|
|
108
|
+
now?: () => number;
|
|
109
|
+
/**
|
|
110
|
+
* Notified when the loop GIVES UP on this call — both exits, once each, before the error
|
|
111
|
+
* is rethrown. Per CALL rather than per policy, because the only thing that wants it is
|
|
112
|
+
* per `ControlDatabase` instance: `lockedWithRetry` passes a closure over that database's
|
|
113
|
+
* single settable listener. One observer per call, like every other field here — a
|
|
114
|
+
* second caller does not fan out beside the first, it replaces it.
|
|
115
|
+
*
|
|
116
|
+
* Nothing on the READ path passes one, deliberately: every control read is awaited by the
|
|
117
|
+
* caller that issued it, so an abandoned read surfaces as that caller's rejection. The
|
|
118
|
+
* field is on the shared options type rather than the write-only one because the loop is
|
|
119
|
+
* shared; if a fire-and-forget read ever appears, it can use this seam as it stands.
|
|
120
|
+
*/
|
|
121
|
+
onAbandon?: ControlRetryAbandonListener;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Run `attempt` up to the policy's attempt count, retrying only failures its classifier
|
|
126
|
+
* calls transient, and only while its elapsed budget has not run out.
|
|
127
|
+
*
|
|
128
|
+
* `attempt` must be safe to re-run — for writes that means atomic (a failed cluster write
|
|
129
|
+
* rolls back, nothing half-applies; the contract on `ControlDatabase.withWriteLock`), for
|
|
130
|
+
* reads it holds trivially. The backoff sleeps happen with NO lock held; a caller that
|
|
131
|
+
* locks takes and releases its lock inside `attempt`.
|
|
132
|
+
*
|
|
133
|
+
* On exhaustion (attempts or budget) the LAST error is rethrown unchanged — never
|
|
134
|
+
* wrapped, so the exact messages downstream code and the integration scenarios assert on
|
|
135
|
+
* survive. A non-retriable failure propagates from the attempt that raised it.
|
|
136
|
+
*
|
|
137
|
+
* Both give-up exits also notify {@link ControlRetryOptions.onAbandon}, when the call
|
|
138
|
+
* supplies one, exactly once before the rethrow. The debug line each exit already wrote is
|
|
139
|
+
* off unless somebody set `DEBUG=`, and a background write has no caller to surface the
|
|
140
|
+
* rethrown error — so without an observer the loss is silent everywhere.
|
|
141
|
+
*/
|
|
142
|
+
export async function retryControlOperation<T>(
|
|
143
|
+
attempt: () => Promise<T>,
|
|
144
|
+
policy: ControlRetryPolicy,
|
|
145
|
+
options: ControlRetryOptions = {}
|
|
146
|
+
): Promise<T> {
|
|
147
|
+
const attempts = Math.max(1, options.attempts ?? policy.attempts);
|
|
148
|
+
const delays = options.delaysMs ?? policy.delaysMs;
|
|
149
|
+
const isRetriable = options.isRetriable ?? policy.isRetriable;
|
|
150
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
151
|
+
const now = options.now ?? Date.now;
|
|
152
|
+
const prefix = policy.logPrefix ?? 'Control write';
|
|
153
|
+
const onAbandon = options.onAbandon;
|
|
154
|
+
// Empty when unlabelled, so an unlabelled line is byte-identical to what the write
|
|
155
|
+
// loop logged before labels existed.
|
|
156
|
+
const tag = options.label ? ` [${options.label}]` : '';
|
|
157
|
+
const start = now();
|
|
158
|
+
let lastError: unknown;
|
|
159
|
+
let attemptsMade = 0;
|
|
160
|
+
// Which exit the loop leaves by. Only the budget check below moves it off the default,
|
|
161
|
+
// so an unmodified `break` is the attempts exit.
|
|
162
|
+
let exhaustedBy: ControlRetryAbandonReason = 'attempts';
|
|
163
|
+
for (let attemptNumber = 1; attemptNumber <= attempts; attemptNumber++) {
|
|
164
|
+
attemptsMade = attemptNumber;
|
|
165
|
+
try {
|
|
166
|
+
const result = await attempt();
|
|
167
|
+
if (attemptNumber > 1) {
|
|
168
|
+
log('%s%s committed on attempt %d/%d', prefix, tag, attemptNumber, attempts);
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (!isRetriable(error)) {
|
|
173
|
+
// The ONLY trace that this funnel saw a failure and declined it. Without it the
|
|
174
|
+
// log is silent either way, so "the classifier vetoed this one" is
|
|
175
|
+
// indistinguishable from "the retry is not wired into this path at all".
|
|
176
|
+
log('%s%s failed non-transiently on attempt %d/%d, not retried here: %s',
|
|
177
|
+
prefix, tag, attemptNumber, attempts, error);
|
|
178
|
+
notifyAbandoned(onAbandon, prefix, tag, {
|
|
179
|
+
...(options.label !== undefined ? { label: options.label } : {}),
|
|
180
|
+
attemptsMade, attemptsAllowed: attempts, elapsedMs: now() - start,
|
|
181
|
+
reason: 'declined', error
|
|
182
|
+
});
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
lastError = error;
|
|
186
|
+
if (attemptNumber === attempts) {
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
const elapsed = now() - start;
|
|
190
|
+
if (elapsed >= policy.budgetMs) {
|
|
191
|
+
exhaustedBy = 'budget';
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
const delay = jitteredDelay(delays, attemptNumber);
|
|
195
|
+
log('%s%s failed transiently (attempt %d/%d), retrying in %d ms: %s',
|
|
196
|
+
prefix, tag, attemptNumber, attempts, delay, error);
|
|
197
|
+
await sleep(delay);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
log('%s%s failed after %d/%d attempt(s): %s', prefix, tag, attemptsMade, attempts, lastError);
|
|
201
|
+
notifyAbandoned(onAbandon, prefix, tag, {
|
|
202
|
+
...(options.label !== undefined ? { label: options.label } : {}),
|
|
203
|
+
attemptsMade, attemptsAllowed: attempts, elapsedMs: now() - start,
|
|
204
|
+
reason: exhaustedBy, error: lastError
|
|
205
|
+
});
|
|
206
|
+
throw lastError;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Tell the observer the operation was abandoned, and never let that replace the failure.
|
|
211
|
+
*
|
|
212
|
+
* A throwing observer is a bug in the observer, not in the write: the loop is on its way to
|
|
213
|
+
* rethrowing the real error, and letting the observer's `TypeError` out instead would erase
|
|
214
|
+
* the very failure it was notified about. Logged and swallowed — the same treatment
|
|
215
|
+
* `CadreNode.emit` gives a throwing event handler.
|
|
216
|
+
*/
|
|
217
|
+
function notifyAbandoned(
|
|
218
|
+
listener: ControlRetryAbandonListener | undefined,
|
|
219
|
+
prefix: string,
|
|
220
|
+
tag: string,
|
|
221
|
+
abandonment: ControlRetryAbandonment
|
|
222
|
+
): void {
|
|
223
|
+
if (!listener) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
listener(abandonment);
|
|
228
|
+
} catch (listenerError) {
|
|
229
|
+
log('%s%s abandonment listener threw (the write\'s own failure is unaffected): %s',
|
|
230
|
+
prefix, tag, listenerError);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Every message in the failure's `cause` chain — the shared substrate both retry classifiers
|
|
236
|
+
* match against. They match text (why, in `control-read-retry.ts`'s module comment); every
|
|
237
|
+
* wrap on the way out of optimystic and Quereus embeds the inner message, so the text of
|
|
238
|
+
* the deepest failure is visible at every level.
|
|
239
|
+
*
|
|
240
|
+
* `unwrapError` declares its `message` as `string`, but it follows `.cause` without checking
|
|
241
|
+
* what that holds — a chain link that is not an `Error` (a stream rejected with a bare string
|
|
242
|
+
* reason, an `AbortSignal.reason` that is a plain object) yields `undefined` there, and
|
|
243
|
+
* calling `.includes` on it would throw a `TypeError` out of a classifier, INSIDE
|
|
244
|
+
* {@link retryControlOperation}'s catch, replacing the real failure with a confusing one.
|
|
245
|
+
* Non-strings are dropped: a link nobody can read is a link that matches nothing, which is
|
|
246
|
+
* already the conservative answer.
|
|
247
|
+
*
|
|
248
|
+
* NOTE: a cause chain with a CYCLE would spin forever inside `unwrapError` itself. No error
|
|
249
|
+
* in this repo builds one; if a hang ever localises to a control retry path, look there.
|
|
250
|
+
*/
|
|
251
|
+
export function chainMessages(error: Error): string[] {
|
|
252
|
+
return unwrapError(error)
|
|
253
|
+
.map(({ message }) => message)
|
|
254
|
+
.filter(message => typeof message === 'string');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* `error` and every `cause` below it, outermost first — the substrate for the classifiers that
|
|
259
|
+
* match by TYPE rather than text (the read side's cohort-unreachable check, the write side's
|
|
260
|
+
* possibly-stored veto). Stops at a non-`Error` link (it can carry no further `cause` worth
|
|
261
|
+
* trusting) and at a repeat, so a cyclic chain terminates.
|
|
262
|
+
*/
|
|
263
|
+
export function causeChain(error: unknown): Error[] {
|
|
264
|
+
const links: Error[] = [];
|
|
265
|
+
let current: unknown = error;
|
|
266
|
+
while (current instanceof Error && !links.includes(current)) {
|
|
267
|
+
links.push(current);
|
|
268
|
+
current = current.cause;
|
|
269
|
+
}
|
|
270
|
+
return links;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Backoff for the retry that follows attempt `attemptNumber`: the matching base delay
|
|
275
|
+
* (last entry repeats), jittered ±50%, then capped at the list's largest base so no single
|
|
276
|
+
* sleep exceeds it (see {@link ControlRetryPolicy.delaysMs} for why). `Math.random` is
|
|
277
|
+
* fine here — the jitter only de-synchronizes concurrent retriers, nothing is derived
|
|
278
|
+
* from it.
|
|
279
|
+
*
|
|
280
|
+
* NOTE: the cap bites on the LAST delay, whose base IS the largest, so ~half of those sleeps
|
|
281
|
+
* land exactly on the cap rather than spread — de-synchronization is only partial there.
|
|
282
|
+
* Harmless while retriers are a handful of party nodes whose attempts already start seconds
|
|
283
|
+
* apart; if a party ever retries in a tight synchronized herd, raise the cap above the
|
|
284
|
+
* largest base instead of jittering about it.
|
|
285
|
+
*/
|
|
286
|
+
function jitteredDelay(delays: readonly number[], attemptNumber: number): number {
|
|
287
|
+
if (delays.length === 0) {
|
|
288
|
+
return 0;
|
|
289
|
+
}
|
|
290
|
+
const base = delays[Math.min(attemptNumber - 1, delays.length - 1)]!;
|
|
291
|
+
const jittered = base * (0.5 + Math.random());
|
|
292
|
+
return Math.min(jittered, Math.max(...delays));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function defaultSleep(ms: number): Promise<void> {
|
|
296
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
297
|
+
}
|