@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.
Files changed (131) hide show
  1. package/README.md +20 -6
  2. package/dist/bootstrap-peer-store-file.js +2 -2
  3. package/dist/bootstrap-peer-store.d.ts +32 -12
  4. package/dist/bootstrap-peer-store.js +41 -15
  5. package/dist/bootstrap-peer-store.js.map +1 -1
  6. package/dist/cadre-node.d.ts +558 -80
  7. package/dist/cadre-node.js +1060 -170
  8. package/dist/cadre-node.js.map +1 -1
  9. package/dist/control-authorization.d.ts +2 -2
  10. package/dist/control-authorization.js +1 -0
  11. package/dist/control-authorization.js.map +1 -1
  12. package/dist/control-cohort.d.ts +13 -26
  13. package/dist/control-cohort.js +0 -26
  14. package/dist/control-cohort.js.map +1 -1
  15. package/dist/control-database.d.ts +299 -44
  16. package/dist/control-database.js +469 -69
  17. package/dist/control-database.js.map +1 -1
  18. package/dist/control-read-retry.d.ts +40 -11
  19. package/dist/control-read-retry.js +48 -15
  20. package/dist/control-read-retry.js.map +1 -1
  21. package/dist/control-retry.d.ts +58 -3
  22. package/dist/control-retry.js +58 -3
  23. package/dist/control-retry.js.map +1 -1
  24. package/dist/control-schema.d.ts +1 -1
  25. package/dist/control-schema.js +103 -19
  26. package/dist/control-schema.js.map +1 -1
  27. package/dist/control-write-retry.d.ts +80 -11
  28. package/dist/control-write-retry.js +168 -29
  29. package/dist/control-write-retry.js.map +1 -1
  30. package/dist/enrolled-machine-store.js +2 -2
  31. package/dist/index.d.ts +14 -7
  32. package/dist/index.js +33 -6
  33. package/dist/index.js.map +1 -1
  34. package/dist/node-local-snapshot.d.ts +15 -7
  35. package/dist/node-local-snapshot.js +23 -8
  36. package/dist/node-local-snapshot.js.map +1 -1
  37. package/dist/peer-dial.d.ts +143 -0
  38. package/dist/peer-dial.js +176 -0
  39. package/dist/peer-dial.js.map +1 -0
  40. package/dist/relay-addrs.d.ts +90 -52
  41. package/dist/relay-addrs.js +91 -59
  42. package/dist/relay-addrs.js.map +1 -1
  43. package/dist/relay-reservation.d.ts +71 -3
  44. package/dist/relay-reservation.js +116 -8
  45. package/dist/relay-reservation.js.map +1 -1
  46. package/dist/seed-bootstrap.d.ts +30 -1
  47. package/dist/seed-bootstrap.js +62 -17
  48. package/dist/seed-bootstrap.js.map +1 -1
  49. package/dist/storage-scope.d.ts +62 -0
  50. package/dist/storage-scope.js +75 -0
  51. package/dist/storage-scope.js.map +1 -0
  52. package/dist/strand-database.d.ts +34 -16
  53. package/dist/strand-database.js +38 -24
  54. package/dist/strand-database.js.map +1 -1
  55. package/dist/strand-first-sync-gate.d.ts +152 -0
  56. package/dist/strand-first-sync-gate.js +208 -0
  57. package/dist/strand-first-sync-gate.js.map +1 -0
  58. package/dist/strand-formation-manager.d.ts +87 -5
  59. package/dist/strand-formation-manager.js +109 -5
  60. package/dist/strand-formation-manager.js.map +1 -1
  61. package/dist/strand-formation-protocol.d.ts +21 -1
  62. package/dist/strand-formation-protocol.js +30 -0
  63. package/dist/strand-formation-protocol.js.map +1 -1
  64. package/dist/strand-instance-manager.d.ts +243 -6
  65. package/dist/strand-instance-manager.js +470 -37
  66. package/dist/strand-instance-manager.js.map +1 -1
  67. package/dist/strand-member-key.d.ts +20 -12
  68. package/dist/strand-member-key.js +20 -12
  69. package/dist/strand-member-key.js.map +1 -1
  70. package/dist/strand-member-registry.js +3 -0
  71. package/dist/strand-member-registry.js.map +1 -1
  72. package/dist/strand-membership-reconciler.d.ts +361 -0
  73. package/dist/strand-membership-reconciler.js +548 -0
  74. package/dist/strand-membership-reconciler.js.map +1 -0
  75. package/dist/strand-membership-writer.d.ts +131 -11
  76. package/dist/strand-membership-writer.js +396 -134
  77. package/dist/strand-membership-writer.js.map +1 -1
  78. package/dist/strand-network-config.d.ts +46 -14
  79. package/dist/strand-network-config.js +79 -23
  80. package/dist/strand-network-config.js.map +1 -1
  81. package/dist/strand-solicitation.d.ts +10 -1
  82. package/dist/strand-solicitation.js +5 -0
  83. package/dist/strand-solicitation.js.map +1 -1
  84. package/dist/strand-wake-protocol.d.ts +11 -7
  85. package/dist/strand-wake-protocol.js +20 -52
  86. package/dist/strand-wake-protocol.js.map +1 -1
  87. package/dist/strand-watcher.js +6 -0
  88. package/dist/strand-watcher.js.map +1 -1
  89. package/dist/timed-step.d.ts +12 -0
  90. package/dist/timed-step.js +28 -0
  91. package/dist/timed-step.js.map +1 -0
  92. package/dist/timeout-scheduler.d.ts +19 -0
  93. package/dist/timeout-scheduler.js +22 -0
  94. package/dist/timeout-scheduler.js.map +1 -0
  95. package/dist/types.d.ts +262 -30
  96. package/dist/types.js.map +1 -1
  97. package/package.json +8 -8
  98. package/src/bootstrap-peer-store-file.ts +2 -2
  99. package/src/bootstrap-peer-store.ts +69 -27
  100. package/src/cadre-node.ts +7301 -6353
  101. package/src/control-authorization.ts +2 -1
  102. package/src/control-cohort.ts +122 -130
  103. package/src/control-database.ts +537 -69
  104. package/src/control-read-retry.ts +202 -167
  105. package/src/control-retry.ts +297 -190
  106. package/src/control-schema.ts +103 -19
  107. package/src/control-write-retry.ts +525 -383
  108. package/src/enrolled-machine-store.ts +2 -2
  109. package/src/index.ts +83 -6
  110. package/src/node-local-snapshot.ts +25 -8
  111. package/src/peer-dial.ts +215 -0
  112. package/src/relay-addrs.ts +458 -436
  113. package/src/relay-reservation.ts +951 -828
  114. package/src/seed-bootstrap.ts +76 -18
  115. package/src/storage-scope.ts +78 -0
  116. package/src/strand-database.ts +50 -27
  117. package/src/strand-first-sync-gate.ts +258 -0
  118. package/src/strand-formation-manager.ts +140 -5
  119. package/src/strand-formation-protocol.ts +43 -1
  120. package/src/strand-instance-manager.ts +577 -37
  121. package/src/strand-member-key.ts +20 -12
  122. package/src/strand-member-registry.ts +3 -0
  123. package/src/strand-membership-reconciler.ts +680 -0
  124. package/src/strand-membership-writer.ts +527 -189
  125. package/src/strand-network-config.ts +102 -24
  126. package/src/strand-solicitation.ts +16 -1
  127. package/src/strand-wake-protocol.ts +21 -66
  128. package/src/strand-watcher.ts +6 -0
  129. package/src/timed-step.ts +28 -0
  130. package/src/timeout-scheduler.ts +28 -0
  131. package/src/types.ts +1789 -1554
@@ -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
- /** 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
- }
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
+ }