@serfab/cadre-core 0.9.0 → 0.10.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 (162) hide show
  1. package/README.md +4 -2
  2. package/dist/bootstrap-peer-store-file.d.ts +13 -0
  3. package/dist/bootstrap-peer-store-file.js +42 -0
  4. package/dist/bootstrap-peer-store-file.js.map +1 -0
  5. package/dist/bootstrap-peer-store.d.ts +73 -0
  6. package/dist/bootstrap-peer-store.js +128 -0
  7. package/dist/bootstrap-peer-store.js.map +1 -0
  8. package/dist/cadre-node.d.ts +918 -53
  9. package/dist/cadre-node.js +1843 -190
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/control-authorization.d.ts +93 -0
  12. package/dist/control-authorization.js +57 -0
  13. package/dist/control-authorization.js.map +1 -0
  14. package/dist/control-database.d.ts +990 -50
  15. package/dist/control-database.js +1459 -108
  16. package/dist/control-database.js.map +1 -1
  17. package/dist/control-formation-recorder.d.ts +89 -10
  18. package/dist/control-formation-recorder.js +129 -14
  19. package/dist/control-formation-recorder.js.map +1 -1
  20. package/dist/control-schema.d.ts +1 -1
  21. package/dist/control-schema.js +774 -210
  22. package/dist/control-schema.js.map +1 -1
  23. package/dist/control-stream.d.ts +38 -4
  24. package/dist/control-stream.js +70 -4
  25. package/dist/control-stream.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +151 -0
  27. package/dist/control-write-retry.js +411 -0
  28. package/dist/control-write-retry.js.map +1 -0
  29. package/dist/delegate-admission.d.ts +134 -0
  30. package/dist/delegate-admission.js +244 -0
  31. package/dist/delegate-admission.js.map +1 -0
  32. package/dist/device-token.d.ts +11 -11
  33. package/dist/device-token.js +14 -13
  34. package/dist/device-token.js.map +1 -1
  35. package/dist/ed25519-key.d.ts +26 -0
  36. package/dist/ed25519-key.js +56 -1
  37. package/dist/ed25519-key.js.map +1 -1
  38. package/dist/file-durable-slot.d.ts +20 -0
  39. package/dist/file-durable-slot.js +53 -0
  40. package/dist/file-durable-slot.js.map +1 -0
  41. package/dist/formation-approval.d.ts +157 -0
  42. package/dist/formation-approval.js +375 -0
  43. package/dist/formation-approval.js.map +1 -0
  44. package/dist/fs-atomic.d.ts +38 -0
  45. package/dist/fs-atomic.js +97 -0
  46. package/dist/fs-atomic.js.map +1 -0
  47. package/dist/identity-key.d.ts +49 -0
  48. package/dist/identity-key.js +81 -0
  49. package/dist/identity-key.js.map +1 -0
  50. package/dist/index.d.ts +16 -6
  51. package/dist/index.js +39 -5
  52. package/dist/index.js.map +1 -1
  53. package/dist/key-store-file.d.ts +2 -24
  54. package/dist/key-store-file.js +10 -77
  55. package/dist/key-store-file.js.map +1 -1
  56. package/dist/membership-connection-gater.d.ts +146 -0
  57. package/dist/membership-connection-gater.js +180 -0
  58. package/dist/membership-connection-gater.js.map +1 -0
  59. package/dist/node-local-snapshot.d.ts +109 -0
  60. package/dist/node-local-snapshot.js +182 -0
  61. package/dist/node-local-snapshot.js.map +1 -0
  62. package/dist/peer-authorization.d.ts +135 -18
  63. package/dist/peer-authorization.js +152 -21
  64. package/dist/peer-authorization.js.map +1 -1
  65. package/dist/peer-record.d.ts +21 -8
  66. package/dist/peer-record.js +26 -10
  67. package/dist/peer-record.js.map +1 -1
  68. package/dist/relay-addrs.d.ts +45 -0
  69. package/dist/relay-addrs.js +103 -0
  70. package/dist/relay-addrs.js.map +1 -0
  71. package/dist/relay-reservation.d.ts +266 -0
  72. package/dist/relay-reservation.js +613 -0
  73. package/dist/relay-reservation.js.map +1 -0
  74. package/dist/seed-bootstrap.d.ts +221 -56
  75. package/dist/seed-bootstrap.js +328 -188
  76. package/dist/seed-bootstrap.js.map +1 -1
  77. package/dist/seed-trust-policy.d.ts +47 -21
  78. package/dist/seed-trust-policy.js +24 -15
  79. package/dist/seed-trust-policy.js.map +1 -1
  80. package/dist/strand-addr-protocol.d.ts +42 -13
  81. package/dist/strand-addr-protocol.js +58 -52
  82. package/dist/strand-addr-protocol.js.map +1 -1
  83. package/dist/strand-backfill.d.ts +109 -0
  84. package/dist/strand-backfill.js +279 -0
  85. package/dist/strand-backfill.js.map +1 -0
  86. package/dist/strand-formation-manager.d.ts +38 -9
  87. package/dist/strand-formation-manager.js +120 -17
  88. package/dist/strand-formation-manager.js.map +1 -1
  89. package/dist/strand-formation-protocol.d.ts +103 -4
  90. package/dist/strand-formation-protocol.js +232 -4
  91. package/dist/strand-formation-protocol.js.map +1 -1
  92. package/dist/strand-instance-manager.d.ts +39 -1
  93. package/dist/strand-instance-manager.js +92 -14
  94. package/dist/strand-instance-manager.js.map +1 -1
  95. package/dist/strand-member-registry.d.ts +5 -5
  96. package/dist/strand-member-registry.js +18 -10
  97. package/dist/strand-member-registry.js.map +1 -1
  98. package/dist/strand-membership-writer.d.ts +482 -88
  99. package/dist/strand-membership-writer.js +757 -136
  100. package/dist/strand-membership-writer.js.map +1 -1
  101. package/dist/strand-solicitation.d.ts +99 -33
  102. package/dist/strand-solicitation.js +100 -52
  103. package/dist/strand-solicitation.js.map +1 -1
  104. package/dist/strand-transport-key.d.ts +40 -0
  105. package/dist/strand-transport-key.js +56 -0
  106. package/dist/strand-transport-key.js.map +1 -0
  107. package/dist/strand-wake-protocol.d.ts +9 -7
  108. package/dist/strand-wake-protocol.js +22 -46
  109. package/dist/strand-wake-protocol.js.map +1 -1
  110. package/dist/strand-watcher.d.ts +17 -2
  111. package/dist/strand-watcher.js +47 -3
  112. package/dist/strand-watcher.js.map +1 -1
  113. package/dist/trusted-owner-store-file.d.ts +12 -0
  114. package/dist/trusted-owner-store-file.js +39 -0
  115. package/dist/trusted-owner-store-file.js.map +1 -0
  116. package/dist/trusted-owner-store.d.ts +64 -0
  117. package/dist/trusted-owner-store.js +126 -0
  118. package/dist/trusted-owner-store.js.map +1 -0
  119. package/dist/types.d.ts +275 -20
  120. package/dist/types.js +13 -0
  121. package/dist/types.js.map +1 -1
  122. package/package.json +19 -8
  123. package/src/bootstrap-peer-store-file.ts +44 -0
  124. package/src/bootstrap-peer-store.ts +172 -0
  125. package/src/cadre-node.ts +1992 -202
  126. package/src/control-authorization.ts +114 -0
  127. package/src/control-database.ts +1704 -146
  128. package/src/control-formation-recorder.ts +172 -19
  129. package/src/control-schema.ts +786 -222
  130. package/src/control-stream.ts +81 -4
  131. package/src/control-write-retry.ts +473 -0
  132. package/src/delegate-admission.ts +302 -0
  133. package/src/device-token.ts +18 -13
  134. package/src/ed25519-key.ts +134 -69
  135. package/src/file-durable-slot.ts +61 -0
  136. package/src/formation-approval.ts +604 -0
  137. package/src/fs-atomic.ts +107 -0
  138. package/src/identity-key.ts +109 -0
  139. package/src/index.ts +136 -5
  140. package/src/key-store-file.ts +10 -81
  141. package/src/membership-connection-gater.ts +204 -0
  142. package/src/node-local-snapshot.ts +260 -0
  143. package/src/peer-authorization.ts +199 -21
  144. package/src/peer-record.ts +28 -10
  145. package/src/relay-addrs.ts +111 -0
  146. package/src/relay-reservation.ts +823 -0
  147. package/src/seed-bootstrap.ts +397 -197
  148. package/src/seed-trust-policy.ts +58 -25
  149. package/src/strand-addr-protocol.ts +84 -53
  150. package/src/strand-backfill.ts +366 -0
  151. package/src/strand-formation-manager.ts +145 -19
  152. package/src/strand-formation-protocol.ts +309 -8
  153. package/src/strand-instance-manager.ts +111 -14
  154. package/src/strand-member-registry.ts +18 -13
  155. package/src/strand-membership-writer.ts +1430 -640
  156. package/src/strand-solicitation.ts +155 -90
  157. package/src/strand-transport-key.ts +58 -0
  158. package/src/strand-wake-protocol.ts +29 -46
  159. package/src/strand-watcher.ts +66 -3
  160. package/src/trusted-owner-store-file.ts +41 -0
  161. package/src/trusted-owner-store.ts +171 -0
  162. package/src/types.ts +292 -21
@@ -9,6 +9,12 @@
9
9
  * abort-on-timeout read-to-EOF helper so a misbehaving peer that opens a stream
10
10
  * and never half-closes its write end cannot pin a receiver forever.
11
11
  *
12
+ * The sender side of all three protocols is the same exchange — dial under a
13
+ * deadline, write one frame, half-close, read one response, reset the stream on
14
+ * any failure — so that shape lives here too, as {@link withDeadline} +
15
+ * {@link exchangeFrame}; each protocol supplies only its dial options, its
16
+ * request object, and its response decoder.
17
+ *
12
18
  * Dependency-free by design: it imports nothing from the protocol modules, so
13
19
  * the import graph stays acyclic (`control-stream` ← `seed-bootstrap` ←
14
20
  * `strand-wake-protocol`). In particular it returns RAW bytes from
@@ -37,10 +43,13 @@ export function writeFrame(stream: ControlStream, obj: unknown): void {
37
43
 
38
44
  /**
39
45
  * Reject if `op` does not settle within `ms`. On timeout, invoke `onTimeout`
40
- * (used by the sender to abort the in-flight dial/stream so it does not leak)
41
- * before rejecting. A throwing `onTimeout` is swallowed so it cannot mask the
42
- * timeout rejection. The rejection message is exactly `<label> timed out after
43
- * <ms>ms`, so callers fold any domain prefix into `label`.
46
+ * before rejecting — a hook for releasing whatever the op is parked on. A
47
+ * throwing `onTimeout` is swallowed so it cannot mask the timeout rejection. The
48
+ * rejection message is exactly `<label> timed out after <ms>ms`, so callers fold
49
+ * any domain prefix into `label`.
50
+ *
51
+ * Senders want {@link withDeadline}, which wires `onTimeout` to an
52
+ * `AbortController` so the in-flight dial/stream is cancelled rather than leaked.
44
53
  */
45
54
  export function withTimeout<T>(
46
55
  ms: number,
@@ -61,6 +70,74 @@ export function withTimeout<T>(
61
70
  });
62
71
  }
63
72
 
73
+ /**
74
+ * Run `op` under a `withTimeout` deadline that also *cancels* it: the signal
75
+ * handed to `op` is aborted immediately before the timeout rejection, so a
76
+ * sender can pass it to `dialProtocol` (cancelling an in-flight connect) and to
77
+ * {@link exchangeFrame} (resetting a live stream) instead of leaking either.
78
+ */
79
+ export function withDeadline<T>(
80
+ ms: number,
81
+ label: string,
82
+ op: (signal: AbortSignal) => Promise<T>,
83
+ ): Promise<T> {
84
+ const controller = new AbortController();
85
+ return withTimeout(ms, label, () => op(controller.signal), () => controller.abort());
86
+ }
87
+
88
+ /**
89
+ * Send one request frame on an open stream, half-close the write end, and read
90
+ * the response through `readResponse`.
91
+ *
92
+ * `signal` is the caller's deadline (see {@link withDeadline}): an abort resets
93
+ * the stream, releasing the otherwise unbounded response read. If the deadline
94
+ * already fired while the stream was being dialed, the freshly-opened stream is
95
+ * reset here rather than left dangling. Every reset — deadline, response-decode
96
+ * failure, or a `readResponse` that rejects without aborting (the size-cap path
97
+ * of {@link readStreamToEnd}) — goes through one idempotent `abort`, so the
98
+ * deadline listener and the error path cannot double-reset a stream.
99
+ *
100
+ * `readResponse` owns its own read bound, which is a backstop rather than the
101
+ * primary one: the caller's deadline started first and therefore fires first.
102
+ * It matters only when an abort does not release the read (e.g. a stream double
103
+ * whose `abort()` is a no-op).
104
+ */
105
+ export async function exchangeFrame<T>(
106
+ stream: ControlStream,
107
+ signal: AbortSignal,
108
+ request: unknown,
109
+ readResponse: (stream: ControlStream) => Promise<T>,
110
+ abortMessage: string,
111
+ ): Promise<T> {
112
+ let reset = false;
113
+ const resetStream = (err: Error): void => {
114
+ if (reset) return;
115
+ reset = true;
116
+ stream.abort(err);
117
+ };
118
+
119
+ const abortErr = new Error(abortMessage);
120
+ const onAbort = (): void => resetStream(abortErr);
121
+ if (signal.aborted) {
122
+ onAbort();
123
+ throw abortErr;
124
+ }
125
+ signal.addEventListener('abort', onAbort, { once: true });
126
+
127
+ try {
128
+ writeFrame(stream, request);
129
+ // close() half-closes the write end (EOF) while the read end stays open for
130
+ // the response — the libp2p 3.x request/response pattern.
131
+ await stream.close();
132
+ return await readResponse(stream);
133
+ } catch (err) {
134
+ resetStream(err instanceof Error ? err : new Error(String(err)));
135
+ throw err;
136
+ } finally {
137
+ signal.removeEventListener('abort', onAbort);
138
+ }
139
+ }
140
+
64
141
  /** Normalize a libp2p chunk (Uint8Array or Uint8ArrayList) to a flat Uint8Array. */
65
142
  function toBytes(chunk: Uint8Array | { subarray(): Uint8Array }): Uint8Array {
66
143
  return chunk instanceof Uint8Array ? chunk : chunk.subarray();
@@ -0,0 +1,473 @@
1
+ import debug from 'debug';
2
+ import { unwrapError } from '@quereus/quereus';
3
+
4
+ const log = debug('sereus:cadre:control-db');
5
+
6
+ /**
7
+ * Bounded retry for TRANSIENT control-write failures — the classifier and loop behind
8
+ * `ControlDatabase.lockedWithRetry`, the single funnel every local control write passes
9
+ * through.
10
+ *
11
+ * Why this exists: the control database replicates every block to the whole party, so a
12
+ * three-machine party commits control writes unanimously (`ceil(3 × 0.75) = 3` approvals) —
13
+ * and no `superMajorityThreshold` can lower that bar without violating Optimystic's
14
+ * partition-safety condition at the shipped admission fraction (the arithmetic lives in
15
+ * `docs/architecture.md` → "Replication cluster size"). Cadre therefore accepts unanimity
16
+ * and absorbs the transient half of its cost here: a write that failed because the cohort
17
+ * did not ANSWER (a stream reset mid-connection-formation, zero approvals) is re-presented
18
+ * a moment later, which the degraded-cohort-member scenario measured as safe (a failed
19
+ * write rolls back, nothing half-commits) and effective (the next write commits in ~1 s).
20
+ *
21
+ * A write somebody actually REJECTED is never retried — re-presenting it would re-present
22
+ * a spent signature against a cohort that already said no.
23
+ *
24
+ * TWO policies ship from here, and the split is deliberate: the default
25
+ * ({@link isRetriableControlWriteFailure}, {@link CONTROL_WRITE_ATTEMPTS}) sits under every
26
+ * control write, and {@link SCHEMA_INIT_RETRY_POLICY} is opted into by
27
+ * `ControlDatabase.loadSchema` alone. The widened one absorbs one extra failure class whose
28
+ * re-run safety holds for schema DDL and NOT for writes in general; anything added to the default
29
+ * list must be safe for all ~19 callers.
30
+ *
31
+ * Lives outside `control-database.ts` so a spec can drive the classifier against real engine
32
+ * errors without importing the whole database class.
33
+ */
34
+
35
+ /**
36
+ * Attempts allowed per control write: the first, plus two retries of a transiently-failed
37
+ * cluster commit. Bounded so a genuinely unreachable cohort terminates in the transactor's
38
+ * own error rather than spinning; two retries covers the observed failure (a stream still
39
+ * forming when the write fired) with margin.
40
+ */
41
+ export const CONTROL_WRITE_ATTEMPTS = 3;
42
+
43
+ /**
44
+ * Attempts allowed for {@link SCHEMA_INIT_RETRY_POLICY}, the one call site that also absorbs a
45
+ * self-coordination refusal.
46
+ *
47
+ * Two more than {@link CONTROL_WRITE_ATTEMPTS} because what this retry buys is wall-clock for a
48
+ * cold-starting node's bootstrap dials to land, and because the refusal is cheap to re-provoke
49
+ * relative to a cluster round-trip: `findCoordinator` already spends its own bounded wait before
50
+ * raising (3 attempts, 500 ms apart, `db-p2p/src/libp2p-key-network.ts` ~422) and then decides
51
+ * LOCALLY that it may not elect itself, so no peer is contacted and no cohort deadline is
52
+ * consumed.
53
+ *
54
+ * Note that the ~1 s `findCoordinator` spends internally is real, on top of this policy's own
55
+ * backoff — which is exactly why {@link CONTROL_WRITE_RETRY_BUDGET_MS} and not this count is what
56
+ * actually terminates the loop in the worst case.
57
+ */
58
+ export const SCHEMA_INIT_ATTEMPTS = 5;
59
+
60
+ /**
61
+ * Elapsed-time ceiling on the whole retry loop, measured from the START of the first
62
+ * attempt and checked after a failed attempt BEFORE sleeping.
63
+ *
64
+ * This budget — not the attempt count — is what makes the policy safe to sit under every
65
+ * control write. A transient failure (stream reset while a connection is still forming)
66
+ * surfaces in well under a second, so it is retried and the loop adds at most ~2.2 s. A
67
+ * genuinely silent cohort member fails at ~20 s (two 10 s `ClusterClient` response-deadline
68
+ * attempts, measured in `control-write-degraded-cohort-member.integration.ts`), which
69
+ * already exceeds this budget when attempt 1 returns — so that case is surfaced immediately
70
+ * and retry adds ZERO latency to the case where it cannot help.
71
+ */
72
+ export const CONTROL_WRITE_RETRY_BUDGET_MS = 10_000;
73
+
74
+ /**
75
+ * Backoff before retry N, jittered ±50% (so the first sleep is 125–375 ms — the jitter can
76
+ * never floor a delay to zero). Each single sleep is additionally capped at the LARGEST
77
+ * delay in the list, so a `stop()` racing a backoff is never held up by more than ~1 s.
78
+ * There is no `AbortSignal` at this seam to cancel a sleep outright; the cap is the
79
+ * substitute, deliberately not plumbing one through every write path.
80
+ */
81
+ const CONTROL_WRITE_RETRY_DELAYS_MS: readonly number[] = [250, 1_000];
82
+
83
+ /**
84
+ * Backoff for {@link SCHEMA_INIT_RETRY_POLICY}, jittered and capped the same way. Worst case
85
+ * ~4.6 s of sleep across four retries (375 + 750 + 1500 + 2000, each capped at the largest base),
86
+ * which stays inside {@link CONTROL_WRITE_RETRY_BUDGET_MS} — the budget still terminates the loop
87
+ * first if any single attempt is itself slow.
88
+ *
89
+ * Sized against measured facts, not guessed:
90
+ *
91
+ * - the self-coordination guard clears the moment the node has ONE connection again (both
92
+ * branches that raise `grace-period-not-elapsed` require `getConnections().length === 0`), not
93
+ * when the 30 s grace period expires — so this does NOT need to cover the grace period, only
94
+ * the gap until a bootstrap dial completes;
95
+ * - the guard's 30 s `gracePeriodMs` cannot be tuned from this repo anyway: no caller in either
96
+ * repo passes a `SelfCoordinationConfig`, so the default is always in force;
97
+ * - a node that has NEVER connected is waved through as a bootstrap node (network high-water
98
+ * mark of 1), so this retry only ever delays a node that HAS seen peers. One whose peers are
99
+ * gone for good still fails, ~4.6 s later — which is why the budget stays at the shared
100
+ * ceiling rather than stretching to cover the full 30 s window.
101
+ */
102
+ const SCHEMA_INIT_RETRY_DELAYS_MS: readonly number[] = [250, 500, 1_000, 2_000];
103
+
104
+ /** The network transactor's aggregate, raised when some cohort peers gave no usable answer. */
105
+ const TRANSACTOR_AGGREGATE = /Some peers did not complete:/;
106
+
107
+ /**
108
+ * How the aggregate's per-batch details name a SINGLE-block batch — the shape both the block
109
+ * read (`get`) and phase 1 (`pend`) format, and the only shape safe to re-present.
110
+ */
111
+ const SINGLE_BLOCK_BATCH_TOKEN = '[block:';
112
+
113
+ /**
114
+ * How the aggregate's per-batch details name a MULTI-block batch — the shape phase 2
115
+ * (`commitBlocks`) formats, and the marker of an outcome too indeterminate to retry.
116
+ */
117
+ const COMMIT_BATCH_TOKEN = '[blocks:';
118
+
119
+ /**
120
+ * The cluster coordinator's super-majority shortfall (`db-p2p/src/repo/cluster-coordinator.ts`),
121
+ * matched ONLY with a ZERO rejection count. The same message with a non-zero count means a
122
+ * member actually voted no (that branch carries `membership-not-admitted` rejections), and
123
+ * retrying it would re-present a spent signature to a cohort that already refused it. This one
124
+ * is raised while collecting PROMISES, before any commit, so re-presenting it is safe. A
125
+ * decisive rejection is a different error entirely (`ValidatorRejectionError`,
126
+ * `Transaction rejected by validators`), which this classifier also never matches.
127
+ */
128
+ const SUPER_MAJORITY_SHORTFALL_UNANSWERED =
129
+ /Failed to get super-majority: \d+\/\d+ approvals \(needed \d+, 0 rejections\)/;
130
+
131
+ /**
132
+ * Is this a transactor aggregate from a phase where nothing can have committed yet?
133
+ *
134
+ * The transactor raises `Some peers did not complete:` from three sites
135
+ * (`db-core/src/transactor/network-transactor.ts`), and only two of them are safe to
136
+ * re-present:
137
+ *
138
+ * - `get` (a block read, which a write body also performs) and `pend` (phase 1) fail before
139
+ * anything commits, so the write is known not to have landed;
140
+ * - `commitBlocks` (phase 2) is REACHABLE here and NOT safe. `commitBlock` throws the
141
+ * aggregate when a header/tail commit got NO response at all — precisely the transient
142
+ * class this retry targets — and that escapes `commit()`, survives `TransactorSource`'s
143
+ * cancel-and-rethrow (cancel reaches PENDING actions only; a peer that already committed
144
+ * stays committed) and `Collection.syncInternal` (which retries StaleFailure RETURN values,
145
+ * not throws), reaching this funnel QuereusError-wrapped. The tail commit is one batch to
146
+ * one coordinator running consensus internally, so a no-response there is INDETERMINATE:
147
+ * the commit may have completed with only the response lost. Re-running the write body over
148
+ * a write that landed turns a success into a constraint failure (e.g.
149
+ * `UNIQUE constraint failed: CadrePeer.PeerId`). Surfacing the transient error instead lets
150
+ * the caller re-read committed state and decide. Commit-phase messages are vetoed by
151
+ * {@link reportsIndeterminateCommit}, not here.
152
+ *
153
+ * The `cause` chain does not discriminate the phases — a commit-phase aggregate carries the
154
+ * same stream-reset/dial error a pend-phase one does. The per-batch DETAIL text does: `get`
155
+ * and `pend` format `<peerId>[block:<id>](<status>)`, `commitBlocks` formats
156
+ * `<peerId>[blocks:<count>](<status>)`. `[block:` cannot occur inside `[blocks:`, so the two
157
+ * tokens are disjoint and separate the phases.
158
+ *
159
+ * NOTE: the discriminator is a formatting detail of another repo. If Optimystic ever reformats
160
+ * those per-batch details, this fails CLOSED — the aggregate stops matching and control writes
161
+ * simply stop being retried, silently losing the absorption rather than doing anything unsafe.
162
+ * The guard against that is a scenario asserting the classifier against a live cluster failure
163
+ * (`control-write-retry-scenario-coverage`); if that lands and later reddens here, an upstream
164
+ * reformat is the first thing to check.
165
+ *
166
+ * An aggregate whose details came out EMPTY (possible when `formatBatchStatuses` has no
167
+ * batches to format) carries neither token, matches nothing here, and is not retried — an
168
+ * unattributable failure is not a proven non-commit.
169
+ */
170
+ function isUncommittedTransactorAggregate(message: string): boolean {
171
+ return TRANSACTOR_AGGREGATE.test(message) && message.includes(SINGLE_BLOCK_BATCH_TOKEN);
172
+ }
173
+
174
+ /**
175
+ * Does ANY message in the failure's `cause` chain describe a commit-phase batch — i.e. an
176
+ * outcome nobody can call committed or not?
177
+ *
178
+ * Vetoes the whole chain rather than the one message carrying the token, and vetoes it
179
+ * regardless of which matcher would otherwise have claimed it. The narrower per-message rule
180
+ * would already catch every shape the transactor is known to build (a commit-phase aggregate
181
+ * emits `[blocks:` for every batch it formats, and embeds its cause's message inline as
182
+ * `root: …`, so both tokens land in one string) — but "known to build" is an argument about
183
+ * another repo's error assembly, and this way an indeterminate commit anywhere in the chain
184
+ * costs a retry we could have had instead of risking a re-run over a write that landed.
185
+ */
186
+ function reportsIndeterminateCommit(messages: readonly string[]): boolean {
187
+ return messages.some(message => message.includes(COMMIT_BATCH_TOKEN));
188
+ }
189
+
190
+ /** The coordinator's shortfall, raised pre-commit, with nobody having voted no. */
191
+ function isUnansweredSuperMajorityShortfall(message: string): boolean {
192
+ return SUPER_MAJORITY_SHORTFALL_UNANSWERED.test(message);
193
+ }
194
+
195
+ /**
196
+ * Optimystic refusing to let a node elect ITSELF coordinator because its last connection dropped
197
+ * less than the guard's grace period ago (`Libp2pKeyPeerNetwork.shouldAllowSelfCoordination` →
198
+ * `findCoordinator`, `db-p2p/src/libp2p-key-network.ts`).
199
+ *
200
+ * Matched ONLY with the `grace-period-not-elapsed` reason. The same sentence is raised with three
201
+ * other reasons and none of them is worth a retry: `disabled` is static configuration, and
202
+ * `partition-detected` / `suspicious-shrinkage` describe a network condition that will not resolve
203
+ * inside a few seconds of backoff. The grace-period one WILL — it clears as soon as the node holds
204
+ * one connection again, which a cold-starting node dialling its bootstrap peers is actively
205
+ * working on.
206
+ *
207
+ * NOTE: matched by TEXT, not by type, and that is forced rather than chosen.
208
+ * `FindCoordinatorError` (`code: 'SELF_COORDINATION_BLOCKED'`) is exported from `@optimystic/db-p2p`
209
+ * and this package depends on it, but the error OBJECT does not survive the trip:
210
+ * `OptimysticVirtualTable.initialize` catches and rethrows as `new Error(message)` with no `cause`
211
+ * (`quereus-plugin-optimystic/src/optimystic-module.ts`), so only the text reaches this repo. Like
212
+ * every matcher here this fails CLOSED — a rewording upstream stops the retry engaging, it never
213
+ * makes it unsafe.
214
+ */
215
+ const SELF_COORDINATION_GRACE_REFUSAL =
216
+ /Self-coordination blocked: grace-period-not-elapsed\. No coordinator available for key\./;
217
+
218
+ /** Self-coordination refused because the node's last connection dropped moments ago. */
219
+ function isSelfCoordinationGraceRefusal(message: string): boolean {
220
+ return SELF_COORDINATION_GRACE_REFUSAL.test(message);
221
+ }
222
+
223
+ /**
224
+ * Every message in the failure's `cause` chain.
225
+ *
226
+ * `unwrapError` declares its `message` as `string`, but it follows `.cause` without checking what
227
+ * that holds — a chain link that is not an `Error` (a stream rejected with a bare string reason,
228
+ * an `AbortSignal.reason` that is a plain object) yields `undefined` there, and calling
229
+ * `.includes` on it would throw a `TypeError` out of this classifier, INSIDE `retryControlWrite`'s
230
+ * catch, replacing the real control-write failure with a confusing one. Non-strings are dropped:
231
+ * a link nobody can read is a link that matches nothing, which is already the conservative answer.
232
+ *
233
+ * NOTE: a cause chain with a CYCLE would spin forever inside `unwrapError` itself. No error in
234
+ * this repo builds one; if a hang ever localises to a control-write failure path, look there.
235
+ */
236
+ function chainMessages(error: Error): string[] {
237
+ return unwrapError(error)
238
+ .map(({ message }) => message)
239
+ .filter(message => typeof message === 'string');
240
+ }
241
+
242
+ /**
243
+ * The error messages that mean "the cohort did not answer AND nothing committed", and nothing
244
+ * else. The observed one-shot failure was `registerSelf()` racing a connection still forming:
245
+ * a read/pend-phase transactor aggregate with `cause=The stream has been reset`.
246
+ *
247
+ * Deliberately NOT matched: every constraint / authorization failure
248
+ * (`CHECK constraint failed:`, `UNIQUE constraint failed:`) — those reach the same funnel
249
+ * and must be retried zero times. In particular the lost-use-number messages
250
+ * (`isLostUseNumberRace` in `control-database.ts`) are constraint failures, so this
251
+ * classifier and that one are DISJOINT and the two retry loops can never multiply.
252
+ */
253
+ const RETRIABLE_CONTROL_WRITE_MATCHERS: readonly ((message: string) => boolean)[] = [
254
+ isUncommittedTransactorAggregate,
255
+ isUnansweredSuperMajorityShortfall,
256
+ ];
257
+
258
+ /**
259
+ * The above PLUS the self-coordination grace refusal — the classifier for schema init ONLY
260
+ * ({@link SCHEMA_INIT_RETRY_POLICY}), deliberately not folded into the list above.
261
+ *
262
+ * Why this class is safe HERE and not everywhere: a write refused at coordinator SELECTION never
263
+ * reached a peer, so re-presenting it is a proven non-commit. But `findCoordinator` is also
264
+ * reached from `NetworkTransactor.commitBlock` during PHASE 2 (`resolveCoordinator`,
265
+ * `db-core/src/transactor/network-transactor.ts`), and `commit()` commits the header block before
266
+ * the rest — so a general control write refused there can be refused AFTER something already
267
+ * committed. Worse, that error carries no `[blocks:` batch token, so
268
+ * {@link reportsIndeterminateCommit} would not veto it, and re-running an insert body over a write
269
+ * that landed is exactly the `UNIQUE constraint failed: CadrePeer.PeerId` failure that veto exists
270
+ * to prevent.
271
+ *
272
+ * Schema init does not have that problem: `apply schema` is a diff rather than a replay and a
273
+ * failed `create table` leaves the catalog clean, so a re-run emits only the tables that did not
274
+ * land (the full argument is at the `loadSchema` call site in `control-database.ts`).
275
+ */
276
+ const RETRIABLE_SCHEMA_INIT_MATCHERS: readonly ((message: string) => boolean)[] = [
277
+ ...RETRIABLE_CONTROL_WRITE_MATCHERS,
278
+ isSelfCoordinationGraceRefusal,
279
+ ];
280
+
281
+ /**
282
+ * Did this control write fail because the cluster cohort did not ANSWER — i.e. is
283
+ * re-presenting the SAME signed write a moment later the right response?
284
+ *
285
+ * Classifies by MESSAGE, walking the `cause` chain with Quereus' own {@link unwrapError} —
286
+ * the exact shape of `isLostUseNumberRace`, for the same reason: neither surface is
287
+ * recognisable by type. The transactor and the coordinator both throw bare `Error`s, and by
288
+ * the time one reaches `ControlDatabase` it is wrapped in a `QuereusError` (the real chain
289
+ * is `QuereusError` → `Error` → `Error`), so the match must work at any depth. Anything
290
+ * that is not an `Error` is never retried.
291
+ *
292
+ * {@link reportsIndeterminateCommit} vetoes the whole chain before any matcher runs, so a
293
+ * failure that reports a commit-phase batch anywhere is never retried on the strength of some
294
+ * other level looking transient.
295
+ *
296
+ * NOTE: this depends on engine/transactor error TEXT. The messages producible without a
297
+ * network are driven from the REAL engine in `control-formation-use-number-retry.spec.ts`, so
298
+ * a rewording reddens a spec rather than silently disabling the retry. The two RETRIABLE
299
+ * messages need a real multi-node cluster to produce and are still literals in
300
+ * `control-write-retry.spec.ts`; `control-write-retry-scenario-coverage` closes that gap.
301
+ */
302
+ export function isRetriableControlWriteFailure(error: unknown): boolean {
303
+ return matchesRetriableMessage(error, RETRIABLE_CONTROL_WRITE_MATCHERS);
304
+ }
305
+
306
+ /**
307
+ * {@link isRetriableControlWriteFailure} widened by exactly one class — the self-coordination
308
+ * grace refusal ({@link RETRIABLE_SCHEMA_INIT_MATCHERS}) — for `ControlDatabase.loadSchema`.
309
+ *
310
+ * Opt-in per call site, via {@link SCHEMA_INIT_RETRY_POLICY}. Every other control write keeps the
311
+ * default classifier untouched.
312
+ */
313
+ export function isRetriableSchemaInitFailure(error: unknown): boolean {
314
+ return matchesRetriableMessage(error, RETRIABLE_SCHEMA_INIT_MATCHERS);
315
+ }
316
+
317
+ /**
318
+ * Shared body of both classifiers: walk the `cause` chain, let
319
+ * {@link reportsIndeterminateCommit} veto it, then ask `matchers`. The veto runs BEFORE any
320
+ * matcher for every policy — a chain reporting a commit-phase batch is never retried, however
321
+ * transient some other level looks.
322
+ */
323
+ function matchesRetriableMessage(
324
+ error: unknown,
325
+ matchers: readonly ((message: string) => boolean)[]
326
+ ): boolean {
327
+ if (!(error instanceof Error)) {
328
+ return false;
329
+ }
330
+ const messages = chainMessages(error);
331
+ if (reportsIndeterminateCommit(messages)) {
332
+ return false;
333
+ }
334
+ return messages.some(message => matchers.some(matches => matches(message)));
335
+ }
336
+
337
+ /**
338
+ * Policy and pacing for {@link retryControlWrite}. Production callers pass nothing (or a named
339
+ * policy such as {@link SCHEMA_INIT_RETRY_POLICY}); specs inject a recorded `sleep`, a shorter
340
+ * delay list, or a fake clock so no test ever waits out a real backoff.
341
+ *
342
+ * Every field defaults to the shipped control-write policy, so an omitted field is byte-identical
343
+ * to the behaviour before per-call-site policies existed.
344
+ */
345
+ export interface ControlWriteRetryOptions {
346
+ /**
347
+ * Total attempts, first included. Default {@link CONTROL_WRITE_ATTEMPTS}, floored at 1 —
348
+ * below that the loop would run the body zero times and rethrow a `lastError` nobody set.
349
+ */
350
+ attempts?: number;
351
+ /** Backoff base before retry N (last entry repeats). Default {@link CONTROL_WRITE_RETRY_DELAYS_MS}. */
352
+ delaysMs?: readonly number[];
353
+ /**
354
+ * Which failures are transient enough to re-present. Default
355
+ * {@link isRetriableControlWriteFailure}; the only other shipped value is
356
+ * {@link isRetriableSchemaInitFailure}, via {@link SCHEMA_INIT_RETRY_POLICY}.
357
+ */
358
+ isRetriable?: (error: unknown) => boolean;
359
+ /** The sleep primitive. Default: `setTimeout`. */
360
+ sleep?: (ms: number) => Promise<void>;
361
+ /** Clock for the elapsed-budget check. Default: `Date.now`. */
362
+ now?: () => number;
363
+ }
364
+
365
+ /**
366
+ * The one non-default retry policy: `ControlDatabase.loadSchema`'s distributed DDL, which
367
+ * additionally absorbs optimystic's self-coordination grace refusal and gets more attempts over a
368
+ * longer backoff to do it (see {@link SCHEMA_INIT_ATTEMPTS} and
369
+ * {@link SCHEMA_INIT_RETRY_DELAYS_MS}). Still bounded by the shared
370
+ * {@link CONTROL_WRITE_RETRY_BUDGET_MS}.
371
+ *
372
+ * NOTE: exactly one call site opts in today, and the re-run safety this policy assumes is that
373
+ * site's, not a general property — `apply schema` is a diff and a failed `create table` leaves the
374
+ * catalog clean. A second opt-in must re-derive that argument for its own write body first; if this
375
+ * policy ever grows a third consumer, rename it for what the callers share rather than widening it
376
+ * by default.
377
+ */
378
+ export const SCHEMA_INIT_RETRY_POLICY: Readonly<ControlWriteRetryOptions> = {
379
+ attempts: SCHEMA_INIT_ATTEMPTS,
380
+ delaysMs: SCHEMA_INIT_RETRY_DELAYS_MS,
381
+ isRetriable: isRetriableSchemaInitFailure,
382
+ };
383
+
384
+ /**
385
+ * Run `attempt` up to {@link CONTROL_WRITE_ATTEMPTS} times (or `options.attempts`), retrying only
386
+ * failures the policy's classifier — {@link isRetriableControlWriteFailure} by default —
387
+ * calls transient, and only while {@link CONTROL_WRITE_RETRY_BUDGET_MS} has not elapsed.
388
+ *
389
+ * `attempt` must be atomic and re-runnable: a failed cluster write rolls back (nothing is
390
+ * half-applied), and re-running the body re-runs its reads too — `ControlDatabase`'s
391
+ * locked write bodies all satisfy this (see the contract on
392
+ * `ControlDatabase.withWriteLock`). The backoff sleeps happen with NO lock held; the caller
393
+ * takes and releases its lock inside `attempt`.
394
+ *
395
+ * On exhaustion (attempts or budget) the LAST error is rethrown unchanged — never wrapped,
396
+ * so the exact messages downstream code and the degraded-cohort-member scenario assert on
397
+ * survive. A non-retriable failure propagates from the attempt that raised it.
398
+ */
399
+ export async function retryControlWrite<T>(
400
+ attempt: () => Promise<T>,
401
+ options: ControlWriteRetryOptions = {}
402
+ ): Promise<T> {
403
+ const attempts = Math.max(1, options.attempts ?? CONTROL_WRITE_ATTEMPTS);
404
+ const delays = options.delaysMs ?? CONTROL_WRITE_RETRY_DELAYS_MS;
405
+ const isRetriable = options.isRetriable ?? isRetriableControlWriteFailure;
406
+ const sleep = options.sleep ?? defaultSleep;
407
+ const now = options.now ?? Date.now;
408
+ const start = now();
409
+ let lastError: unknown;
410
+ let attemptsMade = 0;
411
+ for (let attemptNumber = 1; attemptNumber <= attempts; attemptNumber++) {
412
+ attemptsMade = attemptNumber;
413
+ try {
414
+ const result = await attempt();
415
+ if (attemptNumber > 1) {
416
+ log('Control write committed on attempt %d/%d', attemptNumber, attempts);
417
+ }
418
+ return result;
419
+ } catch (error) {
420
+ if (!isRetriable(error)) {
421
+ // The ONLY trace that this funnel saw a failure and declined it. Without it the
422
+ // log is silent either way, so "the classifier vetoed this one" is
423
+ // indistinguishable from "the retry is not wired into this path at all".
424
+ // "here" is load-bearing: this fires for every non-transient failure, and some
425
+ // of them — the lost-use-number constraint failures — ARE retried by the OUTER
426
+ // loop (`ControlDatabase.withUseNumberRetry`) that this one nests inside.
427
+ log('Control write failed non-transiently on attempt %d/%d, not retried here: %s',
428
+ attemptNumber, attempts, error);
429
+ throw error;
430
+ }
431
+ lastError = error;
432
+ if (attemptNumber === attempts) {
433
+ break;
434
+ }
435
+ const elapsed = now() - start;
436
+ if (elapsed >= CONTROL_WRITE_RETRY_BUDGET_MS) {
437
+ break;
438
+ }
439
+ const delay = jitteredDelay(delays, attemptNumber);
440
+ log('Control write failed transiently (attempt %d/%d), retrying in %d ms: %s',
441
+ attemptNumber, attempts, delay, error);
442
+ await sleep(delay);
443
+ }
444
+ }
445
+ log('Control write failed after %d/%d attempt(s): %s', attemptsMade, attempts, lastError);
446
+ throw lastError;
447
+ }
448
+
449
+ /**
450
+ * Backoff for the retry that follows attempt `attemptNumber`: the matching base delay
451
+ * (last entry repeats), jittered ±50%, then capped at the list's largest base so no single
452
+ * sleep exceeds it (see {@link CONTROL_WRITE_RETRY_DELAYS_MS} for why). `Math.random` is
453
+ * fine here — the jitter only de-synchronizes concurrent retriers, nothing is derived
454
+ * from it.
455
+ *
456
+ * NOTE: the cap bites on the LAST delay, whose base IS the largest, so ~half of those sleeps
457
+ * land exactly on the cap rather than spread — de-synchronization is only partial there.
458
+ * Harmless while retriers are a handful of party nodes whose attempts already start seconds
459
+ * apart; if a party ever retries in a tight synchronized herd, raise the cap above the
460
+ * largest base instead of jittering about it.
461
+ */
462
+ function jitteredDelay(delays: readonly number[], attemptNumber: number): number {
463
+ if (delays.length === 0) {
464
+ return 0;
465
+ }
466
+ const base = delays[Math.min(attemptNumber - 1, delays.length - 1)]!;
467
+ const jittered = base * (0.5 + Math.random());
468
+ return Math.min(jittered, Math.max(...delays));
469
+ }
470
+
471
+ function defaultSleep(ms: number): Promise<void> {
472
+ return new Promise(resolve => setTimeout(resolve, ms));
473
+ }