@serfab/cadre-core 1.3.0 → 1.5.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 (90) hide show
  1. package/README.md +5 -3
  2. package/dist/cadre-node.d.ts +70 -34
  3. package/dist/cadre-node.js +197 -60
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/control-database.d.ts +24 -10
  6. package/dist/control-database.js +48 -16
  7. package/dist/control-database.js.map +1 -1
  8. package/dist/control-formation-recorder.d.ts +4 -4
  9. package/dist/control-formation-recorder.js +6 -6
  10. package/dist/control-formation-recorder.js.map +1 -1
  11. package/dist/control-read-retry.d.ts +12 -0
  12. package/dist/control-read-retry.js +12 -0
  13. package/dist/control-read-retry.js.map +1 -1
  14. package/dist/control-write-retry.d.ts +5 -4
  15. package/dist/control-write-retry.js +24 -7
  16. package/dist/control-write-retry.js.map +1 -1
  17. package/dist/index.d.ts +5 -4
  18. package/dist/index.js +10 -5
  19. package/dist/index.js.map +1 -1
  20. package/dist/link-budget.d.ts +182 -0
  21. package/dist/link-budget.js +195 -0
  22. package/dist/link-budget.js.map +1 -0
  23. package/dist/membership-connection-gater.d.ts +18 -5
  24. package/dist/membership-connection-gater.js +50 -10
  25. package/dist/membership-connection-gater.js.map +1 -1
  26. package/dist/peer-dial.d.ts +36 -17
  27. package/dist/peer-dial.js +37 -17
  28. package/dist/peer-dial.js.map +1 -1
  29. package/dist/peer-join-backfill.d.ts +58 -4
  30. package/dist/peer-join-backfill.js +165 -20
  31. package/dist/peer-join-backfill.js.map +1 -1
  32. package/dist/relay-reservation.d.ts +59 -11
  33. package/dist/relay-reservation.js +169 -43
  34. package/dist/relay-reservation.js.map +1 -1
  35. package/dist/seed-bootstrap.js +2 -1
  36. package/dist/seed-bootstrap.js.map +1 -1
  37. package/dist/storage-scope.d.ts +50 -12
  38. package/dist/storage-scope.js +85 -12
  39. package/dist/storage-scope.js.map +1 -1
  40. package/dist/strand-first-sync-gate.d.ts +43 -5
  41. package/dist/strand-first-sync-gate.js +55 -7
  42. package/dist/strand-first-sync-gate.js.map +1 -1
  43. package/dist/strand-formation-manager.js +2 -1
  44. package/dist/strand-formation-manager.js.map +1 -1
  45. package/dist/strand-id.d.ts +35 -0
  46. package/dist/strand-id.js +46 -0
  47. package/dist/strand-id.js.map +1 -0
  48. package/dist/strand-instance-manager.d.ts +27 -2
  49. package/dist/strand-instance-manager.js +75 -15
  50. package/dist/strand-instance-manager.js.map +1 -1
  51. package/dist/strand-membership-reconciler.d.ts +200 -45
  52. package/dist/strand-membership-reconciler.js +302 -86
  53. package/dist/strand-membership-reconciler.js.map +1 -1
  54. package/dist/strand-membership-writer.d.ts +3 -2
  55. package/dist/strand-membership-writer.js +67 -90
  56. package/dist/strand-membership-writer.js.map +1 -1
  57. package/dist/strand-network-config.d.ts +3 -3
  58. package/dist/strand-network-config.js +3 -3
  59. package/dist/strand-solicitation.js +2 -1
  60. package/dist/strand-solicitation.js.map +1 -1
  61. package/dist/strand-watcher.d.ts +45 -0
  62. package/dist/strand-watcher.js +74 -13
  63. package/dist/strand-watcher.js.map +1 -1
  64. package/dist/types.d.ts +190 -24
  65. package/dist/types.js +73 -4
  66. package/dist/types.js.map +1 -1
  67. package/package.json +8 -8
  68. package/src/cadre-node.ts +7445 -7302
  69. package/src/control-database.ts +48 -16
  70. package/src/control-formation-recorder.ts +6 -6
  71. package/src/control-read-retry.ts +214 -202
  72. package/src/control-write-retry.ts +24 -7
  73. package/src/index.ts +29 -2
  74. package/src/link-budget.ts +217 -0
  75. package/src/membership-connection-gater.ts +56 -11
  76. package/src/peer-dial.ts +39 -17
  77. package/src/peer-join-backfill.ts +192 -23
  78. package/src/relay-reservation.ts +1119 -951
  79. package/src/seed-bootstrap.ts +2 -1
  80. package/src/storage-scope.ts +163 -78
  81. package/src/strand-first-sync-gate.ts +55 -7
  82. package/src/strand-formation-manager.ts +647 -646
  83. package/src/strand-id.ts +48 -0
  84. package/src/strand-instance-manager.ts +1516 -1440
  85. package/src/strand-membership-reconciler.ts +334 -92
  86. package/src/strand-membership-writer.ts +1962 -1987
  87. package/src/strand-network-config.ts +250 -250
  88. package/src/strand-solicitation.ts +500 -499
  89. package/src/strand-watcher.ts +388 -325
  90. package/src/types.ts +192 -23
@@ -627,17 +627,35 @@ export class ControlDatabase {
627
627
  // - `apply schema` is a DIFF, not a replay. Quereus collects the live catalog, diffs
628
628
  // the declared schema against it, and only emits DDL for what is missing — which is
629
629
  // exactly why `initialize` hydrates persisted optimystic schemas BEFORE getting here.
630
- // Tables that already landed generate no statements on the second pass.
631
- // - a failed `create table` leaves the catalog CLEAN. `SchemaManager.createTable` calls
632
- // the module's `create` first and only registers the table on success, so the table
633
- // that failed is not in the catalog, and neither are the ones after it.
630
+ // A table the live catalog already lists generates no statements at all, which is what
631
+ // makes an apply over an already-complete schema (a warm start) a no-op.
632
+ // - a failed apply is taken back WHOLE (Quereus 4.20.0). The migration loop keeps an
633
+ // undo journal; when a step fails it runs that journal in reverse, re-renders the
634
+ // catalog, compares it against a fingerprint taken BEFORE the apply, and only then
635
+ // rethrows the step's own error. So attempt 2 does not resume where attempt 1 died —
636
+ // it re-emits the whole schema, because the catalog is back where the apply started.
634
637
  //
635
- // Together: attempt 2 re-emits exactly the failed table and its successors. No
636
- // `if not exists` juggling, no per-table loop, no statement splitting.
638
+ // Together: attempt 2 emits exactly the DDL the live catalog is missing, whatever
639
+ // attempt 1 reached. No `if not exists` juggling, no per-table loop, no statement
640
+ // splitting.
641
+ //
642
+ // That second bullet carries a dependency the pre-4.20.0 argument did not have. The
643
+ // unwind Quereus performs is the CATALOG's; whether STORAGE follows it is the optimystic
644
+ // plugin's doing. Quereus runs the forward steps, the undo steps and the verification
645
+ // all inside the module's `beginSchemaBatch`/`endSchemaBatch` pair, and the plugin puts
646
+ // each undo statement (`drop table`, `drop index if exists`) through its ordinary hooks
647
+ // in that one write batch, then commits the restored state. On a plugin build WITHOUT
648
+ // those batch hooks storage would be left holding objects the catalog no longer lists,
649
+ // and the re-run would diff against a catalog that disagrees with the blocks. So this
650
+ // argument is a statement about the pair, not about Quereus alone — pinned by
651
+ // `test/control-schema-apply-unwind.spec.ts`.
637
652
  //
638
653
  // Only the "cohort did not answer, nothing committed" class is retried; the classifier
639
654
  // vetoes indeterminate commits and does not match `Missing block`, which is a durable
640
655
  // convergence fault a retry cannot heal (tracked separately) and must keep propagating.
656
+ // The cases the unwind cannot cover — a failed undo statement, a post-unwind catalog
657
+ // that does not match its fingerprint, an irreversible step — are retried deliberately
658
+ // rather than vetoed; why, on `RETRIABLE_SCHEMA_INIT_MATCHERS`.
641
659
  //
642
660
  // This is the ONE call site on a non-default policy: the re-run safety argued above is
643
661
  // also what lets schema init absorb optimystic's self-coordination grace refusal, which
@@ -2977,16 +2995,30 @@ export class ControlDatabase {
2977
2995
  * Served by a seek through the `FormationUsageByToken` index rather than a full scan of
2978
2996
  * the table, which is append-only and grows for the life of the party.
2979
2997
  *
2980
- * NOTE: this count IS the seat cap (`enforceFormationUseCap`), so an under-report admits a
2981
- * seat the invitation never paid for — and reading it through a secondary index makes the
2982
- * cap depend on that index converging across machines. It did not, from 2026-08-04 to
2983
- * 2026-08-25: a descent on a second machine returned only the rows that machine had
2984
- * written, and the index was removed until the engine was fixed upstream (re-measured
2985
- * 2026-09-17, `complete/restore-formation-usage-token-index`). The live guard is the
2986
- * integration-tests scenario `strand-formation-concurrent-redemption`, which asserts both
2987
- * machines' views of a raced redemption. If it fails on BOTH views again, index
2988
- * convergence has regressed — fix the engine or take this read off the index, and do not
2989
- * weaken that scenario's assertions to get a green run.
2998
+ * This read is NOT the seat cap. The authoritative cap is the deferred `Authorized` CHECK
2999
+ * in `schemas/control.qsql` — `FI.TotalUses > (select count(1) from
3000
+ * committed.FormationUsage U where U.Token = new.Token)` — evaluated by the validating
3001
+ * cohort against the committed snapshot at commit time. Every caller of this method is a
3002
+ * permissive PRE-check that runs ahead of it, and a transiently short read costs each of
3003
+ * them only a worse outcome for the ATTEMPT, never a seat the invitation did not pay for:
3004
+ * {@link assertSeatRemains} loses its named exhaustion error and falls back to the CHECK's
3005
+ * generic refusal; `ControlFormationUsageRecorder.isTokenUsed` reports not-used and lets
3006
+ * the redemption proceed to the CHECK, which decides; {@link hasOutstandingFormationInvite}
3007
+ * holds the stranger-admission door open slightly longer.
3008
+ *
3009
+ * NOTE: index convergence still gates the cap — just at the CHECK, not here, since the
3010
+ * CHECK's own count is served by the same `FormationUsageByToken` index. That convergence
3011
+ * failed from 2026-08-04 to 2026-08-25: a descent on a second machine returned only the
3012
+ * rows that machine had written, and the index was removed until the engine was fixed
3013
+ * upstream (re-measured 2026-09-17, `complete/restore-formation-usage-token-index`). The
3014
+ * live guard is the integration-tests scenario `strand-formation-concurrent-redemption`,
3015
+ * which asserts both machines' views of a raced redemption. If it fails on BOTH views
3016
+ * again, index convergence has regressed — fix the engine or take this read off the index,
3017
+ * and do not weaken that scenario's assertions to get a green run. A failure on ONE view is
3018
+ * NOT automatically that scenario being slow: one-way convergence lag, and the 2026-08 defect
3019
+ * itself, both present that way whenever only the sibling node wrote the rows. What separates
3020
+ * them is the failure message, which prints both nodes' rows and counts — a sibling holding
3021
+ * rows the failing view is missing is a convergence problem, not a slow run.
2990
3022
  *
2991
3023
  * `retry: false` is passed only by {@link assertSeatRemains}, which runs INSIDE a
2992
3024
  * locked write body — same per-call opt-out, and for the same reason, as
@@ -1,6 +1,6 @@
1
1
  import debug from 'debug';
2
- import { randomBytes } from '@optimystic/quereus-plugin-crypto';
3
2
  import { FormationAbortedError, type ControlDatabase } from './control-database.js';
3
+ import { mintStrandId } from './strand-id.js';
4
4
  import {
5
5
  createHttpFormationApprover,
6
6
  verifyFormationApproval,
@@ -252,10 +252,10 @@ export class ControlFormationUsageRecorder implements FormationUsageRecorder {
252
252
  * Provision a NEW strand for an UNBOUND invite and record consent against it in ONE
253
253
  * transaction (the responder-provisions fallback, now single-use-enforced).
254
254
  *
255
- * Mints a fresh, globally-unique strand id from {@link randomBytes} — the same
256
- * cross-platform CSPRNG `control-database`'s `generateStampId` uses, NOT
257
- * `crypto.randomUUID` / `Date.now` / `Math.random` (not uniformly available across
258
- * node/browser/RN) — then delegates to {@link ControlDatabase.redeemInvitation}, whose
255
+ * Mints a fresh, globally-unique strand id with {@link mintStrandId} — an
256
+ * unguessable CSPRNG id, and one `strand-id.ts` has already checked is usable as a
257
+ * storage scope key and network name — then delegates to
258
+ * {@link ControlDatabase.redeemInvitation}, whose
259
259
  * single `begin … commit` inserts the consent-authorized `Strand` row AND the matching
260
260
  * `FormationUsage` row together (both deferred CHECKs see both rows at commit). That one
261
261
  * `FormationUsage` row makes the unbound redemption single-use exactly like the bound
@@ -289,7 +289,7 @@ export class ControlFormationUsageRecorder implements FormationUsageRecorder {
289
289
  if (signal?.aborted) {
290
290
  throw new FormationAbortedError(token, 'redemption');
291
291
  }
292
- const strandId = `strand-${randomBytes(128, 'hex') as string}`;
292
+ const strandId = mintStrandId();
293
293
  const invite = await this.controlDatabase.queryFormationInvite(token);
294
294
  const approval = await this.obtainApproval({
295
295
  token, usageStampId, strandId, peerKey, disclosure,
@@ -1,202 +1,214 @@
1
- import { BlockUnavailableError } from '@optimystic/db-core';
2
- import { causeChain, chainMessages, retryControlOperation } from './control-retry.js';
3
- import type { ControlRetryOptions } from './control-retry.js';
4
- import { isUncommittedTransactorAggregate } from './control-write-retry.js';
5
-
6
- /**
7
- * Bounded retry for TRANSIENT control-read failures — the classifier and policy behind
8
- * `ControlDatabase.readRows`, the single funnel every control read passes through.
9
- *
10
- * Why this exists: control WRITES have absorbed transient cluster failures since
11
- * `control-write-retry.ts` landed, but a read had no second chance — one stream reset
12
- * during a scan ended the read outright (measured: a rigged single `eval` failure killed
13
- * `queryRevokedStamps` after exactly one attempt while the same injection against `exec`
14
- * was absorbed). This module gives reads the same bounded re-presentation with a SHORTER
15
- * deadline and a NARROWER failure set, because the tightest caller deadline over a control
16
- * read is the inbound admission gate's 2 s fail-open timeout
17
- * (`ADMISSION_DECISION_TIMEOUT_MS`, `membership-connection-gater.ts`) — a read budget that
18
- * does not fit inside it with headroom spends its retries after the gate has already
19
- * admitted.
20
- *
21
- * This is deliberately NOT "the write classifier minus its commit veto" — the retriable
22
- * set differs in both directions (see {@link isRetriableControlReadFailure}). The loop
23
- * lives in `control-retry.ts`, shared with the write policy; this module owns only the
24
- * read policy. And like the write side, the retry classifies by TEXT. That was forced when
25
- * it was written — optimystic's scan path used to rethrow `new Error('Query failed: ' +
26
- * message)` with no `cause` — and is no longer: `OptimysticVirtualTable` now rethrows
27
- * through `rewrapAsQueryError` (`quereus-plugin-optimystic/src/optimystic-module.ts`),
28
- * which keeps the typed `BlockUnavailableError` on `cause`, and Quereus wraps that as
29
- * `Error during query on table '<T>': …`, again preserving `cause`
30
- * (`quereus/src/runtime/emit/scan.ts`). The text matchers still work because every wrap
31
- * embeds the inner message. Every matcher fails CLOSED — an upstream rewording stops the
32
- * retry engaging, it never makes it unsafe. {@link isCohortUnreachableRead}, which is not a
33
- * retry classifier, matches by type.
34
- */
35
-
36
- /**
37
- * Attempts allowed per control read: the first, plus two retries. Attempts are cheap here —
38
- * the observed transient failure (the transactor's read-phase aggregate off a stream still
39
- * forming) surfaces in ~25 ms — and {@link CONTROL_READ_RETRY_BUDGET_MS} is what terminates
40
- * the loop when an attempt is slow instead.
41
- */
42
- export const CONTROL_READ_ATTEMPTS = 3;
43
-
44
- /**
45
- * Backoff before read retry N, jittered ±50% and capped at the largest base by the shared
46
- * loop — so worst case 150 ms + 400 ms = 550 ms of total sleep (the second delay's
47
- * upside jitter is clipped by the cap). Much shorter than the write list because the
48
- * whole loop must fit under {@link CONTROL_READ_RETRY_BUDGET_MS}; the spec pins that
49
- * relationship rather than the number.
50
- */
51
- export const CONTROL_READ_RETRY_DELAYS_MS: readonly number[] = [100, 400];
52
-
53
- /**
54
- * Elapsed-time ceiling on the whole read-retry loop, checked after a failed attempt BEFORE
55
- * sleeping — so one slow attempt (e.g. a `cohort-unreachable` read that burned the
56
- * transactor's own deadline) terminates the loop rather than compounding.
57
- *
58
- * 1500 ms, sized against `ADMISSION_DECISION_TIMEOUT_MS` (2000 ms): the inbound admission
59
- * gate reads the control DB and is FAIL-OPEN on both throw and timeout, so a read that
60
- * outlives the gate's deadline spends its retries after the gate has already admitted an
61
- * unplaced peer. Under the deadline, a read that succeeds on its second attempt lets the
62
- * gate make the real decision instead. `control-read-retry.spec.ts` asserts the
63
- * relationship so a future edit to either constant reddens instead of silently
64
- * reintroducing the fail-open admit. NOTE: `queryCadrePeers` / `queryPeerRecord` each
65
- * issue TWO network reads (the revoked-stamp filter plus the row scan) and the budget is
66
- * per read, so a membership read can spend up to two budgets back to back — still inside
67
- * 2 s only because each stays well under it. Do not raise these numbers without
68
- * re-checking against `ADMISSION_DECISION_TIMEOUT_MS`.
69
- */
70
- export const CONTROL_READ_RETRY_BUDGET_MS = 1_500;
71
-
72
- /**
73
- * The block-unavailability reasons a repeat read can actually improve on, matched against
74
- * upstream's exact template `Block <id> is unavailable (<reason>): …`
75
- * (`BlockUnavailableError`, `db-core/src/network/struct.ts`):
76
- *
77
- * - **`peers-unreachable`** — part of the cohort answered and part could not be asked, and
78
- * OTHER coordinators are reachable, so asking one of them can still settle it. Upstream's
79
- * own definition invites the second attempt.
80
- * - **`cohort-unreachable`** — the marginal member of the set. Upstream says the answer
81
- * will not improve until this node's connectivity does — but during bring-up connectivity
82
- * DOES improve within a second (the boot-time failure shape recorded in
83
- * `tickets/blocked/control-read-over-fresh-edge-stream-resets`), and the cost of being
84
- * wrong is bounded by {@link CONTROL_READ_RETRY_BUDGET_MS}, which is the whole reason it
85
- * is safe to include.
86
- *
87
- * Deliberately NOT matched:
88
- *
89
- * - **`claimed-elsewhere`** — a cohort peer positively claims the block exists and nobody
90
- * could corroborate or acquire it. MEASURED not to clear: reissuing the same call every
91
- * second for 60 s returned the identical error every time (2026-08-20, recorded in the
92
- * fix ticket). Retrying spends the whole budget and fails anyway. The measured cause (a
93
- * block only one machine held) was fixed upstream by the solo-commit proof in
94
- * `@optimystic/db-p2p` 0.28.0 (`tickets/complete/block-held-by-only-one-machine-is-unreadable`),
95
- * so a `claimed-elsewhere` that surfaces now is a new defect — this retry must not hide it.
96
- * - **`unmaterializable`** — records are held locally but cannot be reassembled. A local
97
- * data problem; a second read reads the same records.
98
- * - **`Block <id> may be stale`** (`BlockPossiblyStaleError`) — about currency, not
99
- * existence: a cohort claim no reachable coordinator could confirm or refute will not
100
- * resolve inside a few hundred milliseconds of backoff.
101
- * - Anything unmatched. The default is no retry.
102
- */
103
- const RETRIABLE_BLOCK_UNAVAILABLE =
104
- /Block \S+ is unavailable \((?:peers-unreachable|cohort-unreachable)\)/;
105
-
106
- /** A block-unavailable read failure whose reason a repeat read can improve on. */
107
- function isRetriableBlockUnavailable(message: string): boolean {
108
- return RETRIABLE_BLOCK_UNAVAILABLE.test(message);
109
- }
110
-
111
- /**
112
- * Pacing overrides for {@link retryControlRead} — the read twin of
113
- * `ControlWriteRetryOptions`. Every field defaults to the shipped read policy
114
- * ({@link CONTROL_READ_ATTEMPTS}, {@link CONTROL_READ_RETRY_DELAYS_MS},
115
- * {@link isRetriableControlReadFailure}); field shapes and semantics live on the shared
116
- * {@link ControlRetryOptions}.
117
- */
118
- export type ControlReadRetryOptions = ControlRetryOptions;
119
-
120
- /**
121
- * Did this control read fail in a way a repeat read can improve on?
122
- *
123
- * Two classes, both meaning "the cluster could not be ASKED properly just now":
124
- *
125
- * - the transactor's read-phase aggregate (`Some peers did not complete: …[block:…]`),
126
- * reused verbatim from the write classifier — a `get` batch formats the single-block
127
- * `[block:` token, and a read can never produce the commit-phase `[blocks:` token, so
128
- * the write side's indeterminate-commit veto has nothing to veto here (a read commits
129
- * nothing and re-running it is always safe);
130
- * - the block-unavailability reasons in {@link RETRIABLE_BLOCK_UNAVAILABLE} (and only
131
- * those — see its comment for the measured exclusions).
132
- *
133
- * Classifies by MESSAGE, walking the `cause` chain with the shared `chainMessages` (why
134
- * text, in the module comment). Anything that is not an `Error` is never retried.
135
- */
136
- export function isRetriableControlReadFailure(error: unknown): boolean {
137
- if (!(error instanceof Error)) {
138
- return false;
139
- }
140
- return chainMessages(error).some(message =>
141
- isUncommittedTransactorAggregate(message) || isRetriableBlockUnavailable(message));
142
- }
143
-
144
- /**
145
- * Did this control read fail because no cohort member other than the answering node could
146
- * be asked about a block nobody here holds — a `BlockUnavailableError` with reason
147
- * `cohort-unreachable` anywhere on the `cause` chain?
148
- *
149
- * Not a retry classifier. It answers "is there a better answer to wait for?", and is used
150
- * by exactly one reader, `ControlDatabase.queryRevokedStamps`, to read an isolated node's
151
- * never-received `Revocation` block as "no revocations known" (the reasoning is in the NOTE
152
- * there). Upstream names this the one reason a caller may treat permissively
153
- * (`db-core/src/transactor/network-transactor.ts`). The other reasons do NOT match:
154
- * `peers-unreachable` means part of the cohort answered, and `claimed-elsewhere` /
155
- * `unmaterializable` mean the block is known to exist, so an empty answer could hide a
156
- * real revocation.
157
- *
158
- * Matched by TYPE, since the typed error survives upstream's rewraps (module comment).
159
- * Fails CLOSED: a non-`Error`, a text-only copy of the message, and an error built by a
160
- * second loaded copy of `@optimystic/db-core` (which `instanceof` cannot recognise) all
161
- * answer false, and the reader throws as it did before this existed.
162
- */
163
- export function isCohortUnreachableRead(error: unknown): boolean {
164
- return causeChain(error).some(link =>
165
- link instanceof BlockUnavailableError && link.reason === 'cohort-unreachable');
166
- }
167
-
168
- /**
169
- * Run `attempt` — a full drain of one control read — up to
170
- * {@link CONTROL_READ_ATTEMPTS} times, retrying only failures
171
- * {@link isRetriableControlReadFailure} calls transient, and only while
172
- * {@link CONTROL_READ_RETRY_BUDGET_MS} has not elapsed.
173
- *
174
- * Reads are idempotent, so `attempt` needs no atomicity contract — but it must be a
175
- * COLLECTING drain, not a live iterator: a half-consumed iterator cannot be retried
176
- * without re-yielding rows the caller already saw, which is why
177
- * `ControlDatabase.readRows` materializes before this loop ever sees a failure.
178
- *
179
- * On exhaustion the LAST error is rethrown unchanged. Log lines carry the
180
- * `Control read [<label>] …` prefix so a read's retries can be attributed among the
181
- * several reads in flight concurrently in a real party.
182
- *
183
- * No reader passes an `onAbandon` observer, deliberately, though the shared options type
184
- * offers one ({@link ControlRetryOptions.onAbandon}) and the loop would honour it. That hook
185
- * exists because an abandoned WRITE can have no caller at all — the background self-record
186
- * republish is `void`-ed with a `debug`-only catch, so giving up on it is invisible. Every
187
- * control read is awaited by the caller that issued it, so an abandoned read surfaces as that
188
- * caller's rejection; there is nothing here to lose silently. A fire-and-forget read would be
189
- * the reason to start passing one.
190
- */
191
- export function retryControlRead<T>(
192
- attempt: () => Promise<T>,
193
- options: ControlReadRetryOptions = {}
194
- ): Promise<T> {
195
- return retryControlOperation(attempt, {
196
- attempts: CONTROL_READ_ATTEMPTS,
197
- delaysMs: CONTROL_READ_RETRY_DELAYS_MS,
198
- budgetMs: CONTROL_READ_RETRY_BUDGET_MS,
199
- isRetriable: isRetriableControlReadFailure,
200
- logPrefix: 'Control read',
201
- }, options);
202
- }
1
+ import { BlockUnavailableError } from '@optimystic/db-core';
2
+ import { causeChain, chainMessages, retryControlOperation } from './control-retry.js';
3
+ import type { ControlRetryOptions } from './control-retry.js';
4
+ import { isUncommittedTransactorAggregate } from './control-write-retry.js';
5
+
6
+ /**
7
+ * Bounded retry for TRANSIENT control-read failures — the classifier and policy behind
8
+ * `ControlDatabase.readRows`, the single funnel every control read passes through.
9
+ *
10
+ * Why this exists: control WRITES have absorbed transient cluster failures since
11
+ * `control-write-retry.ts` landed, but a read had no second chance — one stream reset
12
+ * during a scan ended the read outright (measured: a rigged single `eval` failure killed
13
+ * `queryRevokedStamps` after exactly one attempt while the same injection against `exec`
14
+ * was absorbed). This module gives reads the same bounded re-presentation with a SHORTER
15
+ * deadline and a NARROWER failure set, because the tightest caller deadline over a control
16
+ * read is the inbound admission gate's 2 s fail-open timeout
17
+ * (`ADMISSION_DECISION_TIMEOUT_MS`, `membership-connection-gater.ts`) — a read budget that
18
+ * does not fit inside it with headroom spends its retries after the gate has already
19
+ * admitted.
20
+ *
21
+ * This is deliberately NOT "the write classifier minus its commit veto" — the retriable
22
+ * set differs in both directions (see {@link isRetriableControlReadFailure}). The loop
23
+ * lives in `control-retry.ts`, shared with the write policy; this module owns only the
24
+ * read policy. And like the write side, the retry classifies by TEXT. That was forced when
25
+ * it was written — optimystic's scan path used to rethrow `new Error('Query failed: ' +
26
+ * message)` with no `cause` — and is no longer: `OptimysticVirtualTable` now rethrows
27
+ * through `rewrapAsQueryError` (`quereus-plugin-optimystic/src/optimystic-module.ts`),
28
+ * which keeps the typed `BlockUnavailableError` on `cause`, and Quereus wraps that as
29
+ * `Error during query on table '<T>': …`, again preserving `cause`
30
+ * (`quereus/src/runtime/emit/scan.ts`). The text matchers still work because every wrap
31
+ * embeds the inner message. Every matcher fails CLOSED — an upstream rewording stops the
32
+ * retry engaging, it never makes it unsafe. {@link isCohortUnreachableRead}, which is not a
33
+ * retry classifier, matches by type.
34
+ */
35
+
36
+ /**
37
+ * Attempts allowed per control read: the first, plus two retries. Attempts are cheap here —
38
+ * the observed transient failure (the transactor's read-phase aggregate off a stream still
39
+ * forming) surfaces in ~25 ms — and {@link CONTROL_READ_RETRY_BUDGET_MS} is what terminates
40
+ * the loop when an attempt is slow instead.
41
+ */
42
+ export const CONTROL_READ_ATTEMPTS = 3;
43
+
44
+ /**
45
+ * Backoff before read retry N, jittered ±50% and capped at the largest base by the shared
46
+ * loop — so worst case 150 ms + 400 ms = 550 ms of total sleep (the second delay's
47
+ * upside jitter is clipped by the cap). Much shorter than the write list because the
48
+ * whole loop must fit under {@link CONTROL_READ_RETRY_BUDGET_MS}; the spec pins that
49
+ * relationship rather than the number.
50
+ */
51
+ export const CONTROL_READ_RETRY_DELAYS_MS: readonly number[] = [100, 400];
52
+
53
+ /**
54
+ * Elapsed-time ceiling on the whole read-retry loop, checked after a failed attempt BEFORE
55
+ * sleeping — so one slow attempt (e.g. a `cohort-unreachable` read that burned the
56
+ * transactor's own deadline) terminates the loop rather than compounding.
57
+ *
58
+ * 1500 ms, sized against `ADMISSION_DECISION_TIMEOUT_MS` (2000 ms): the inbound admission
59
+ * gate reads the control DB and is FAIL-OPEN on both throw and timeout, so a read that
60
+ * outlives the gate's deadline spends its retries after the gate has already admitted an
61
+ * unplaced peer. Under the deadline, a read that succeeds on its second attempt lets the
62
+ * gate make the real decision instead. `control-read-retry.spec.ts` asserts the
63
+ * relationship so a future edit to either constant reddens instead of silently
64
+ * reintroducing the fail-open admit. NOTE: `queryCadrePeers` / `queryPeerRecord` each
65
+ * issue TWO network reads (the revoked-stamp filter plus the row scan) and the budget is
66
+ * per read, so a membership read can spend up to two budgets back to back — still inside
67
+ * 2 s only because each stays well under it. Do not raise these numbers without
68
+ * re-checking against `ADMISSION_DECISION_TIMEOUT_MS`.
69
+ *
70
+ * NOTE: the obligation runs the other way too, and the number that dominates this budget is
71
+ * declared in another package. One attempt whose cohort consult finds a silent peer costs
72
+ * `clusterPolicy.cohortQueryTimeoutMs`, which sereus declares at 5000 ms
73
+ * (`COHORT_READ_DEADLINE_MS`, `quereus-plugin-sereus/src/cluster-size.ts`) for relayed phone
74
+ * links — more than three times this budget, so such an attempt is never retried and the
75
+ * admission gate above it has already fail-opened. That is a widened cost of a deliberate
76
+ * choice, not a hole (the stream gates still decide), and the whole ladder of cadre-core
77
+ * deadlines against Optimystic's is audited by
78
+ * `backlog/debt-cadre-deadlines-sized-against-old-optimystic-bounds`. What this budget still
79
+ * buys is the failure it was built for: the ~25 ms transactor read-phase aggregate off a
80
+ * stream still forming, which retries twice well inside 1500 ms.
81
+ */
82
+ export const CONTROL_READ_RETRY_BUDGET_MS = 1_500;
83
+
84
+ /**
85
+ * The block-unavailability reasons a repeat read can actually improve on, matched against
86
+ * upstream's exact template `Block <id> is unavailable (<reason>): …`
87
+ * (`BlockUnavailableError`, `db-core/src/network/struct.ts`):
88
+ *
89
+ * - **`peers-unreachable`** — part of the cohort answered and part could not be asked, and
90
+ * OTHER coordinators are reachable, so asking one of them can still settle it. Upstream's
91
+ * own definition invites the second attempt.
92
+ * - **`cohort-unreachable`** — the marginal member of the set. Upstream says the answer
93
+ * will not improve until this node's connectivity does — but during bring-up connectivity
94
+ * DOES improve within a second (the boot-time failure shape recorded in
95
+ * `tickets/blocked/control-read-over-fresh-edge-stream-resets`), and the cost of being
96
+ * wrong is bounded by {@link CONTROL_READ_RETRY_BUDGET_MS}, which is the whole reason it
97
+ * is safe to include.
98
+ *
99
+ * Deliberately NOT matched:
100
+ *
101
+ * - **`claimed-elsewhere`** — a cohort peer positively claims the block exists and nobody
102
+ * could corroborate or acquire it. MEASURED not to clear: reissuing the same call every
103
+ * second for 60 s returned the identical error every time (2026-08-20, recorded in the
104
+ * fix ticket). Retrying spends the whole budget and fails anyway. The measured cause (a
105
+ * block only one machine held) was fixed upstream by the solo-commit proof in
106
+ * `@optimystic/db-p2p` 0.28.0 (`tickets/complete/block-held-by-only-one-machine-is-unreadable`),
107
+ * so a `claimed-elsewhere` that surfaces now is a new defect — this retry must not hide it.
108
+ * - **`unmaterializable`** — records are held locally but cannot be reassembled. A local
109
+ * data problem; a second read reads the same records.
110
+ * - **`Block <id> may be stale`** (`BlockPossiblyStaleError`) — about currency, not
111
+ * existence: a cohort claim no reachable coordinator could confirm or refute will not
112
+ * resolve inside a few hundred milliseconds of backoff.
113
+ * - Anything unmatched. The default is no retry.
114
+ */
115
+ const RETRIABLE_BLOCK_UNAVAILABLE =
116
+ /Block \S+ is unavailable \((?:peers-unreachable|cohort-unreachable)\)/;
117
+
118
+ /** A block-unavailable read failure whose reason a repeat read can improve on. */
119
+ function isRetriableBlockUnavailable(message: string): boolean {
120
+ return RETRIABLE_BLOCK_UNAVAILABLE.test(message);
121
+ }
122
+
123
+ /**
124
+ * Pacing overrides for {@link retryControlRead} — the read twin of
125
+ * `ControlWriteRetryOptions`. Every field defaults to the shipped read policy
126
+ * ({@link CONTROL_READ_ATTEMPTS}, {@link CONTROL_READ_RETRY_DELAYS_MS},
127
+ * {@link isRetriableControlReadFailure}); field shapes and semantics live on the shared
128
+ * {@link ControlRetryOptions}.
129
+ */
130
+ export type ControlReadRetryOptions = ControlRetryOptions;
131
+
132
+ /**
133
+ * Did this control read fail in a way a repeat read can improve on?
134
+ *
135
+ * Two classes, both meaning "the cluster could not be ASKED properly just now":
136
+ *
137
+ * - the transactor's read-phase aggregate (`Some peers did not complete: …[block:…]`),
138
+ * reused verbatim from the write classifier — a `get` batch formats the single-block
139
+ * `[block:` token, and a read can never produce the commit-phase `[blocks:` token, so
140
+ * the write side's indeterminate-commit veto has nothing to veto here (a read commits
141
+ * nothing and re-running it is always safe);
142
+ * - the block-unavailability reasons in {@link RETRIABLE_BLOCK_UNAVAILABLE} (and only
143
+ * those — see its comment for the measured exclusions).
144
+ *
145
+ * Classifies by MESSAGE, walking the `cause` chain with the shared `chainMessages` (why
146
+ * text, in the module comment). Anything that is not an `Error` is never retried.
147
+ */
148
+ export function isRetriableControlReadFailure(error: unknown): boolean {
149
+ if (!(error instanceof Error)) {
150
+ return false;
151
+ }
152
+ return chainMessages(error).some(message =>
153
+ isUncommittedTransactorAggregate(message) || isRetriableBlockUnavailable(message));
154
+ }
155
+
156
+ /**
157
+ * Did this control read fail because no cohort member other than the answering node could
158
+ * be asked about a block nobody here holds — a `BlockUnavailableError` with reason
159
+ * `cohort-unreachable` anywhere on the `cause` chain?
160
+ *
161
+ * Not a retry classifier. It answers "is there a better answer to wait for?", and is used
162
+ * by exactly one reader, `ControlDatabase.queryRevokedStamps`, to read an isolated node's
163
+ * never-received `Revocation` block as "no revocations known" (the reasoning is in the NOTE
164
+ * there). Upstream names this the one reason a caller may treat permissively
165
+ * (`db-core/src/transactor/network-transactor.ts`). The other reasons do NOT match:
166
+ * `peers-unreachable` means part of the cohort answered, and `claimed-elsewhere` /
167
+ * `unmaterializable` mean the block is known to exist, so an empty answer could hide a
168
+ * real revocation.
169
+ *
170
+ * Matched by TYPE, since the typed error survives upstream's rewraps (module comment).
171
+ * Fails CLOSED: a non-`Error`, a text-only copy of the message, and an error built by a
172
+ * second loaded copy of `@optimystic/db-core` (which `instanceof` cannot recognise) all
173
+ * answer false, and the reader throws as it did before this existed.
174
+ */
175
+ export function isCohortUnreachableRead(error: unknown): boolean {
176
+ return causeChain(error).some(link =>
177
+ link instanceof BlockUnavailableError && link.reason === 'cohort-unreachable');
178
+ }
179
+
180
+ /**
181
+ * Run `attempt` — a full drain of one control read — up to
182
+ * {@link CONTROL_READ_ATTEMPTS} times, retrying only failures
183
+ * {@link isRetriableControlReadFailure} calls transient, and only while
184
+ * {@link CONTROL_READ_RETRY_BUDGET_MS} has not elapsed.
185
+ *
186
+ * Reads are idempotent, so `attempt` needs no atomicity contract — but it must be a
187
+ * COLLECTING drain, not a live iterator: a half-consumed iterator cannot be retried
188
+ * without re-yielding rows the caller already saw, which is why
189
+ * `ControlDatabase.readRows` materializes before this loop ever sees a failure.
190
+ *
191
+ * On exhaustion the LAST error is rethrown unchanged. Log lines carry the
192
+ * `Control read [<label>] …` prefix so a read's retries can be attributed among the
193
+ * several reads in flight concurrently in a real party.
194
+ *
195
+ * No reader passes an `onAbandon` observer, deliberately, though the shared options type
196
+ * offers one ({@link ControlRetryOptions.onAbandon}) and the loop would honour it. That hook
197
+ * exists because an abandoned WRITE can have no caller at all — the background self-record
198
+ * republish is `void`-ed with a `debug`-only catch, so giving up on it is invisible. Every
199
+ * control read is awaited by the caller that issued it, so an abandoned read surfaces as that
200
+ * caller's rejection; there is nothing here to lose silently. A fire-and-forget read would be
201
+ * the reason to start passing one.
202
+ */
203
+ export function retryControlRead<T>(
204
+ attempt: () => Promise<T>,
205
+ options: ControlReadRetryOptions = {}
206
+ ): Promise<T> {
207
+ return retryControlOperation(attempt, {
208
+ attempts: CONTROL_READ_ATTEMPTS,
209
+ delaysMs: CONTROL_READ_RETRY_DELAYS_MS,
210
+ budgetMs: CONTROL_READ_RETRY_BUDGET_MS,
211
+ isRetriable: isRetriableControlReadFailure,
212
+ logPrefix: 'Control read',
213
+ }, options);
214
+ }
@@ -384,9 +384,25 @@ const RETRIABLE_CONTROL_WRITE_MATCHERS: readonly ((message: string) => boolean)[
384
384
  * that landed is exactly the `UNIQUE constraint failed: CadrePeer.PeerId` failure that veto exists
385
385
  * to prevent.
386
386
  *
387
- * Schema init does not have that problem: `apply schema` is a diff rather than a replay and a
388
- * failed `create table` leaves the catalog clean, so a re-run emits only the tables that did not
389
- * land (the full argument is at the `loadSchema` call site in `control-database.ts`).
387
+ * Schema init does not have that problem: `apply schema` is a diff rather than a replay, and a
388
+ * failed apply is taken back whole — Quereus unwinds every step it ran and verifies the catalog
389
+ * against a pre-apply fingerprint — so a re-run emits exactly the DDL the live catalog is missing.
390
+ * The full argument, including the plugin-side dependency that unwind carries, is at the
391
+ * `loadSchema` call site in `control-database.ts`.
392
+ *
393
+ * RETRIED DELIBERATELY, not by oversight: the failures Quereus cannot take back. When the unwind
394
+ * does not complete — an undo statement threw, the post-unwind catalog did not match the pre-apply
395
+ * fingerprint, that catalog could not be re-collected to check, or a step the differ marks
396
+ * irreversible because it discards data (dropping a table or a column, narrowing a column's type —
397
+ * none of which a `CadreControl` diff generates today) poisoned the journal before it ran, leaving
398
+ * nothing unwound — Quereus keeps the failing step's own message, APPENDS the reason the
399
+ * schema could not be restored, and carries the original as `cause`. So a transient cause
400
+ * underneath still matches here, and neither veto ({@link reportsPossiblyStoredWrite},
401
+ * {@link reportsIndeterminateCommit}) looks for that reason text. Retrying over a partially migrated
402
+ * schema is safe for the same reason the ordinary re-run is: the plugin commits whatever its write
403
+ * batch holds, so storage and the catalog are left in step either way, and the next apply diffs
404
+ * against what is really there. Vetoing it instead would turn a healable transient outage into a
405
+ * dead start.
390
406
  */
391
407
  const RETRIABLE_SCHEMA_INIT_MATCHERS: readonly ((message: string) => boolean)[] = [
392
408
  ...RETRIABLE_CONTROL_WRITE_MATCHERS,
@@ -482,10 +498,11 @@ export type ControlWriteRetryOptions = ControlRetryOptions;
482
498
  * {@link CONTROL_WRITE_RETRY_BUDGET_MS}.
483
499
  *
484
500
  * NOTE: exactly one call site opts in today, and the re-run safety this policy assumes is that
485
- * site's, not a general property — `apply schema` is a diff and a failed `create table` leaves the
486
- * catalog clean. A second opt-in must re-derive that argument for its own write body first; if this
487
- * policy ever grows a third consumer, rename it for what the callers share rather than widening it
488
- * by default.
501
+ * site's, not a general property — `apply schema` is a diff, and a failed apply is unwound whole
502
+ * and verified against the pre-apply catalog (full argument at the `loadSchema` call site in
503
+ * `control-database.ts`). A second opt-in must re-derive that argument for its own write body
504
+ * first; if this policy ever grows a third consumer, rename it for what the callers share rather
505
+ * than widening it by default.
489
506
  */
490
507
  export const SCHEMA_INIT_RETRY_POLICY: Readonly<ControlWriteRetryOptions> = {
491
508
  attempts: SCHEMA_INIT_ATTEMPTS,