@serfab/cadre-core 1.6.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -6
- package/dist/cadre-node.d.ts +548 -137
- package/dist/cadre-node.js +1208 -383
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-authorization.d.ts +2 -2
- package/dist/control-authorization.js +1 -0
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.d.ts +6 -0
- package/dist/control-cohort.js +6 -0
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +46 -8
- package/dist/control-database.js +97 -18
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +37 -14
- package/dist/control-formation-recorder.js +60 -10
- package/dist/control-formation-recorder.js.map +1 -1
- package/dist/control-read-retry.d.ts +13 -12
- package/dist/control-read-retry.js +14 -12
- package/dist/control-read-retry.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +67 -5
- package/dist/control-schema.js.map +1 -1
- package/dist/control-stream.d.ts +9 -1
- package/dist/control-stream.js +24 -1
- package/dist/control-stream.js.map +1 -1
- package/dist/control-write-retry.d.ts +12 -2
- package/dist/control-write-retry.js +13 -2
- package/dist/control-write-retry.js.map +1 -1
- package/dist/delegate-admission.d.ts +11 -8
- package/dist/delegate-admission.js +19 -11
- package/dist/delegate-admission.js.map +1 -1
- package/dist/diagnostics/connection-path.d.ts +0 -7
- package/dist/diagnostics/connection-path.js +0 -7
- package/dist/diagnostics/connection-path.js.map +1 -1
- package/dist/formation-approval.d.ts +7 -0
- package/dist/formation-approval.js +9 -3
- package/dist/formation-approval.js.map +1 -1
- package/dist/fs-atomic.d.ts +11 -2
- package/dist/fs-atomic.js +13 -3
- package/dist/fs-atomic.js.map +1 -1
- package/dist/hibernation-manager.d.ts +111 -8
- package/dist/hibernation-manager.js +234 -49
- package/dist/hibernation-manager.js.map +1 -1
- package/dist/index.d.ts +13 -6
- package/dist/index.js +27 -5
- package/dist/index.js.map +1 -1
- package/dist/joined-strand-store.d.ts +177 -0
- package/dist/joined-strand-store.js +431 -0
- package/dist/joined-strand-store.js.map +1 -0
- package/dist/key-store-file.js +14 -5
- package/dist/key-store-file.js.map +1 -1
- package/dist/key-store.d.ts +6 -0
- package/dist/key-store.js.map +1 -1
- package/dist/link-budget.d.ts +367 -69
- package/dist/link-budget.js +375 -72
- package/dist/link-budget.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +30 -43
- package/dist/membership-connection-gater.js +31 -42
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/node-local-snapshot.d.ts +5 -4
- package/dist/node-local-snapshot.js +6 -3
- package/dist/node-local-snapshot.js.map +1 -1
- package/dist/peer-dial.d.ts +24 -4
- package/dist/peer-dial.js +74 -5
- package/dist/peer-dial.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +35 -15
- package/dist/peer-join-backfill.js +115 -55
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/push-fanout.d.ts +6 -0
- package/dist/push-fanout.js +6 -0
- package/dist/push-fanout.js.map +1 -1
- package/dist/relay-reservation.d.ts +3 -1
- package/dist/relay-reservation.js +3 -1
- package/dist/relay-reservation.js.map +1 -1
- package/dist/relay-server.d.ts +79 -0
- package/dist/relay-server.js +74 -0
- package/dist/relay-server.js.map +1 -0
- package/dist/seed-bootstrap.d.ts +57 -4
- package/dist/seed-bootstrap.js +107 -83
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/storage-scope.d.ts +33 -17
- package/dist/storage-scope.js +44 -23
- package/dist/storage-scope.js.map +1 -1
- package/dist/strand-addr-protocol.d.ts +58 -21
- package/dist/strand-addr-protocol.js +133 -85
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-database.d.ts +20 -2
- package/dist/strand-database.js +42 -5
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +59 -21
- package/dist/strand-first-sync-gate.js +58 -19
- package/dist/strand-first-sync-gate.js.map +1 -1
- package/dist/strand-formation-deadlines.d.ts +151 -0
- package/dist/strand-formation-deadlines.js +171 -0
- package/dist/strand-formation-deadlines.js.map +1 -0
- package/dist/strand-formation-manager.d.ts +63 -30
- package/dist/strand-formation-manager.js +67 -41
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +45 -35
- package/dist/strand-formation-protocol.js +59 -122
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +152 -6
- package/dist/strand-instance-manager.js +331 -42
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +6 -0
- package/dist/strand-membership-writer.js +11 -0
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-peer-book-file.d.ts +12 -0
- package/dist/strand-peer-book-file.js +37 -0
- package/dist/strand-peer-book-file.js.map +1 -0
- package/dist/strand-peer-book-protocol.d.ts +168 -0
- package/dist/strand-peer-book-protocol.js +356 -0
- package/dist/strand-peer-book-protocol.js.map +1 -0
- package/dist/strand-peer-book-swap.d.ts +104 -0
- package/dist/strand-peer-book-swap.js +284 -0
- package/dist/strand-peer-book-swap.js.map +1 -0
- package/dist/strand-peer-book.d.ts +159 -0
- package/dist/strand-peer-book.js +361 -0
- package/dist/strand-peer-book.js.map +1 -0
- package/dist/strand-peer-observer.d.ts +88 -0
- package/dist/strand-peer-observer.js +180 -0
- package/dist/strand-peer-observer.js.map +1 -0
- package/dist/strand-revocation-enforcer.js +2 -1
- package/dist/strand-revocation-enforcer.js.map +1 -1
- package/dist/strand-solicitation.d.ts +36 -20
- package/dist/strand-solicitation.js +4 -4
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-wake-protocol.d.ts +80 -23
- package/dist/strand-wake-protocol.js +89 -64
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/types.d.ts +181 -33
- package/dist/types.js +4 -3
- package/dist/types.js.map +1 -1
- package/package.json +11 -7
- package/src/cadre-node.ts +1316 -398
- package/src/control-authorization.ts +2 -1
- package/src/control-cohort.ts +6 -0
- package/src/control-database.ts +122 -18
- package/src/control-formation-recorder.ts +354 -305
- package/src/control-read-retry.ts +14 -12
- package/src/control-schema.ts +67 -5
- package/src/control-stream.ts +25 -1
- package/src/control-write-retry.ts +13 -2
- package/src/delegate-admission.ts +23 -13
- package/src/diagnostics/connection-path.ts +0 -7
- package/src/formation-approval.ts +610 -604
- package/src/fs-atomic.ts +14 -4
- package/src/hibernation-manager.ts +638 -437
- package/src/index.ts +91 -6
- package/src/joined-strand-store.ts +498 -0
- package/src/key-store-file.ts +14 -5
- package/src/key-store.ts +6 -0
- package/src/link-budget.ts +393 -74
- package/src/membership-connection-gater.ts +31 -45
- package/src/node-local-snapshot.ts +293 -289
- package/src/peer-dial.ts +81 -7
- package/src/peer-join-backfill.ts +120 -57
- package/src/push-fanout.ts +6 -0
- package/src/relay-reservation.ts +3 -1
- package/src/relay-server.ts +98 -0
- package/src/seed-bootstrap.ts +133 -91
- package/src/storage-scope.ts +44 -23
- package/src/strand-addr-protocol.ts +547 -458
- package/src/strand-database.ts +287 -242
- package/src/strand-first-sync-gate.ts +58 -19
- package/src/strand-formation-deadlines.ts +227 -0
- package/src/strand-formation-manager.ts +700 -647
- package/src/strand-formation-protocol.ts +103 -150
- package/src/strand-instance-manager.ts +387 -45
- package/src/strand-membership-writer.ts +12 -0
- package/src/strand-peer-book-file.ts +39 -0
- package/src/strand-peer-book-protocol.ts +466 -0
- package/src/strand-peer-book-swap.ts +338 -0
- package/src/strand-peer-book.ts +475 -0
- package/src/strand-peer-observer.ts +235 -0
- package/src/strand-revocation-enforcer.ts +2 -1
- package/src/strand-solicitation.ts +519 -500
- package/src/strand-wake-protocol.ts +401 -355
- package/src/types.ts +185 -33
|
@@ -84,24 +84,57 @@ export const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
|
|
|
84
84
|
*
|
|
85
85
|
* Every harness figure below was taken on one shape: one Windows developer machine, two
|
|
86
86
|
* relay-only `CadreNode`s (`listenAddrs: []`) on a shared loopback dedicated relay, with a
|
|
87
|
-
*
|
|
87
|
+
* one-way per-frame outbound delay applied to every websocket by
|
|
88
88
|
* `integration-tests/src/harness/ws-latency.ts` in `pipelined` mode — frames stay overlapped
|
|
89
89
|
* in flight, so the figure is latency and bandwidth stays unlimited; the harness's other mode
|
|
90
90
|
* (`serial`) is a per-socket frame-rate cap and its delays are NOT comparable
|
|
91
|
-
* (`docs/testing.md` → "Link latency").
|
|
92
|
-
*
|
|
91
|
+
* (`docs/testing.md` → "Link latency"). Two delays were used: 900 ms one-way, a round trip
|
|
92
|
+
* near 1.8 s, the band sereus supported first; and 1 500 ms one-way, the supported 3-second
|
|
93
|
+
* round trip. Times run from the `addStrand` call to writable.
|
|
93
94
|
*
|
|
94
|
-
* FRESH JOIN (2026-09-26), a machine holding nothing of the strand yet: 23, 27, 31
|
|
95
|
-
* over four runs at optimystic's 1000 ms cohort read deadline, and 35, 42 and 46 s
|
|
96
|
-
* runs at a 5000 ms one; the re-attach scenario below later took 38.7, 38.7 and
|
|
97
|
-
* the same fresh join.
|
|
98
|
-
* 5000 ms
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
95
|
+
* FRESH JOIN at 900 ms (2026-09-26), a machine holding nothing of the strand yet: 23, 27, 31
|
|
96
|
+
* and 41 s over four runs at optimystic's 1000 ms cohort read deadline, and 35, 42 and 46 s
|
|
97
|
+
* over three runs at a 5000 ms one; the re-attach scenario below later took 38.7, 38.7 and
|
|
98
|
+
* 56.9 s for the same fresh join. At that delay the 5000 ms band, 35-57 s, was the one in
|
|
99
|
+
* force: sereus then declared 5000 ms, because 1000 ms is shorter than one round trip on
|
|
100
|
+
* that link and leaves every cohort read with no answer to corroborate against. Widening that
|
|
101
|
+
* deadline makes a consult against a peer that cannot answer cost longer, and this phase runs
|
|
102
|
+
* several of those — which is why both bands are recorded here, and why a change to either
|
|
103
|
+
* number has to be weighed against the other.
|
|
103
104
|
*
|
|
104
|
-
*
|
|
105
|
+
* AT THE SUPPORTED LINK ON `@optimystic/*` 1.8.0 (2026-09-29), 1 500 ms one-way, the read
|
|
106
|
+
* deadline derived from the declared link at 7 000 ms (`cohortReadDeadlineMs` in
|
|
107
|
+
* `link-budget.ts`; the plugin's `COHORT_READ_DEADLINE_MS` is the same number), and cadre
|
|
108
|
+
* stating its declared link to Optimystic, the same scenario, all under the coordinator debug
|
|
109
|
+
* channel:
|
|
110
|
+
* - Fresh join: writable at 63.9 and 63.8 s over two runs, the row A wrote before the join
|
|
111
|
+
* readable at 88.0 s in both. B's strand node connected to A's at 18.3-18.4 s. No read was
|
|
112
|
+
* declined: every consult on B completed, 6.03-6.05 s after the one before it (two link
|
|
113
|
+
* round trips each). The time did not fall, because B's consults ran one after another and
|
|
114
|
+
* the gate opened only after the seventh.
|
|
115
|
+
* - Re-attach over the store B kept: no longer bimodal. All 3 runs held everything and came
|
|
116
|
+
* up writable at launch (9.2 s), and the row A wrote while B was away was readable at
|
|
117
|
+
* 35.6 s in each, read with one consult for the collection and its data block together. No
|
|
118
|
+
* read was declined during the attach. The empty-store arm was not re-run.
|
|
119
|
+
*
|
|
120
|
+
* AT THE SUPPORTED LINK ON `@optimystic/*` 1.7.0 (2026-09-29), same delay, deadline and
|
|
121
|
+
* scenario, before cadre stated its link to Optimystic:
|
|
122
|
+
* - Fresh join: writable at 52.1 and 82.1 s over two runs, and 63.6 s in a third run under
|
|
123
|
+
* the coordinator debug channel; a fourth run with the deadline set back to 5 000 ms took
|
|
124
|
+
* 70.2 s. B's strand node connected to A's at 18.2-18.3 s in every run at this delay. The
|
|
125
|
+
* row A wrote before the join was readable at 67-124 s.
|
|
126
|
+
* - Re-attach over the store B kept: bimodal again. 1 of 3 runs held everything and came up
|
|
127
|
+
* writable at launch (9.1 s; the row at 33.4 s). The other 2 were gated and writable at
|
|
128
|
+
* 75.7 and 78.7 s (the row at 120.8 and 175.0 s). The empty-store arm was not re-run; at
|
|
129
|
+
* 900 ms it matched a fresh join.
|
|
130
|
+
* - What that band was NOT: a measure of the 7 000 ms deadline. Every cohort consult in those
|
|
131
|
+
* runs ended 3.00-3.02 s after the one before it, at 5 000 and 7 000 alike, because
|
|
132
|
+
* Optimystic's then-fixed 3 000 ms request dial deadline also bounded the protocol
|
|
133
|
+
* negotiation on an already-open connection, and one negotiation is one link round trip —
|
|
134
|
+
* 3 s here. So it was the band of a join whose every consult failed and whose blocks
|
|
135
|
+
* arrived by peer-join backfill.
|
|
136
|
+
*
|
|
137
|
+
* RE-ATTACH at 900 ms (2026-09-26, the opt-in
|
|
105
138
|
* `integration-tests/src/scenarios/strand-reattach-first-sync-measure.integration.ts` — re-run
|
|
106
139
|
* it before changing this number): B attached once on an undelayed link, left with
|
|
107
140
|
* `stopStrand`, A wrote while it was away, the delay was raised, and B called `addStrand`
|
|
@@ -118,18 +151,23 @@ export const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
|
|
|
118
151
|
* connected to A's at 11 s in every arm, fresh join included, and a run with 20 missed
|
|
119
152
|
* writes fell inside the run-to-run spread of those with 1.
|
|
120
153
|
* This budget covers WRITABLE, not caught up: in the gated kept-store runs the row A wrote
|
|
121
|
-
* while B was away became readable only at 140-162 s.
|
|
154
|
+
* while B was away became readable only at 140-162 s (121-175 s at the supported link).
|
|
122
155
|
*
|
|
123
156
|
* Two samples from outside this harness, neither reproduced here and neither discarded: a
|
|
124
157
|
* reporter's re-attach of the same shape (optimystic #22; their harness, storage and delay
|
|
125
158
|
* injector are unknown to us) opened the gate at about 150 s, after the previous 120 s budget
|
|
126
159
|
* had already rejected; and a real Galaxy S7 joining fresh through relay.sereus.org took 178 s.
|
|
127
160
|
*
|
|
128
|
-
* 300 s clears the worst harness sample (
|
|
129
|
-
*
|
|
130
|
-
* 1.
|
|
131
|
-
*
|
|
132
|
-
*
|
|
161
|
+
* 300 s clears the worst harness sample (82 s, a fresh join at the supported link on 1.7.0; the
|
|
162
|
+
* worst on 1.8.0 is 64 s) by about 3.6x, the reporter's re-attach by 2x and the device join by
|
|
163
|
+
* about 1.7x. Kept at 300 s on 2026-09-29, and again after the 1.8.0 re-measure the same day,
|
|
164
|
+
* by the rule that re-measure applied and the next one should: keep it while the
|
|
165
|
+
* worst harness sample is at most 100 s, which holds a margin of at least 3x; otherwise raise
|
|
166
|
+
* it to 3x the worst sample, rounded up to the next whole minute. 240 s was rejected earlier:
|
|
167
|
+
* it clears the device join by only 1.35x, and that sample was not a re-attach. History: the
|
|
168
|
+
* original 30 s sat INSIDE the 1000 ms fresh band and refused about half of those joins while
|
|
169
|
+
* their sync was progressing normally; 120 s was sized from fresh joins only and refused the
|
|
170
|
+
* reporter's re-attach.
|
|
133
171
|
*
|
|
134
172
|
* NOTE: accepted tradeoff — what 300 s costs. This wait is what an app's `addStrand` sits in
|
|
135
173
|
* before it is told "not yet", so a strand none of whose members is reachable at all takes five
|
|
@@ -141,6 +179,7 @@ export const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
|
|
|
141
179
|
* "no peer at all" could then be reported at once and this budget would only ever be spent on a
|
|
142
180
|
* sync that is actually in progress — worth more the longer this budget gets.
|
|
143
181
|
*/
|
|
182
|
+
// eslint-disable-next-line no-restricted-syntax -- measured, not derived: the bands above, from integration-tests' strand-reattach-first-sync-measure.integration.ts; a whole first sync has no round-trip count
|
|
144
183
|
export const DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS = 300_000;
|
|
145
184
|
|
|
146
185
|
/** Embedder-facing tuning for the gate, threaded from `CadreNodeConfig.strandFirstSync`. */
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strand-formation deadlines, derived from the declared link round trip.
|
|
3
|
+
*
|
|
4
|
+
* The wire protocol lives in `strand-formation-protocol.ts`; this module is only the
|
|
5
|
+
* arithmetic it runs on: the ladder of deadlines one declaration yields
|
|
6
|
+
* ({@link formationDeadlines}) and the clamping of a caller's configured provisioning budget
|
|
7
|
+
* under its session ({@link resolveProvisionTimeoutMs}, {@link splitProvisionBudget}). It
|
|
8
|
+
* reaches `link-budget.ts` for the counts and `formation-approval.ts` for the hook's flat
|
|
9
|
+
* timeout, and nothing in the protocol, so both roles and the manager can import it freely.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import debug from 'debug';
|
|
13
|
+
import { DEFAULT_APPROVAL_TIMEOUT_MS } from './formation-approval.js';
|
|
14
|
+
import {
|
|
15
|
+
CIRCUIT_REQUEST_ROUND_TRIPS,
|
|
16
|
+
cohortReadDeadlineMs,
|
|
17
|
+
commitBudgetMs,
|
|
18
|
+
relayedStreamOpenBudgetMs,
|
|
19
|
+
resolveLinkRoundTripMs
|
|
20
|
+
} from './link-budget.js';
|
|
21
|
+
|
|
22
|
+
const log = debug('sereus:cadre:formation-deadlines');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Every deadline of one formation session, derived from one declared link round trip
|
|
26
|
+
* ({@link formationDeadlines}). Each waits on work that crosses the link between the two
|
|
27
|
+
* parties' machines, so each is a round-trip count times the declaration rather than a
|
|
28
|
+
* number chosen on a fast network; the initiator's and the responder's fields are derived
|
|
29
|
+
* together so the ordering between the roles holds by construction.
|
|
30
|
+
*/
|
|
31
|
+
export interface FormationDeadlines {
|
|
32
|
+
/** Initiator: open a possibly relayed connection and negotiate the formation protocol. */
|
|
33
|
+
dialMs: number;
|
|
34
|
+
/** Responder: from the handler starting to the contact frame arriving. */
|
|
35
|
+
awaitContactMs: number;
|
|
36
|
+
/**
|
|
37
|
+
* Responder: `validateToken` plus `validateDisclosure`, budgeted as two consulting control
|
|
38
|
+
* reads. Counted in the travel margin and the clamp room, not wrapped in a deadline of their
|
|
39
|
+
* own: the session bounds them.
|
|
40
|
+
*/
|
|
41
|
+
validationMs: number;
|
|
42
|
+
/** Responder: the provisioning hook's work budget, after which its signal aborts. */
|
|
43
|
+
provisionWorkMs: number;
|
|
44
|
+
/** Responder: the settle grace after the abort, which must contain one control commit. */
|
|
45
|
+
provisionGraceMs: number;
|
|
46
|
+
/**
|
|
47
|
+
* What the initiator's await-response holds over the responder's provisioning: the
|
|
48
|
+
* contact frame's trip out, the result frame's trip back, and the validation reads that
|
|
49
|
+
* run before the responder's provisioning clock starts.
|
|
50
|
+
*/
|
|
51
|
+
responseTravelMarginMs: number;
|
|
52
|
+
/** Initiator: the result frame read. */
|
|
53
|
+
initiatorAwaitResponseMs: number;
|
|
54
|
+
/** Both roles: the whole session. */
|
|
55
|
+
sessionMs: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Derive every formation deadline from the declared link round trip
|
|
60
|
+
* (`NetworkConfig.linkRoundTripMs`; the default when none is declared is
|
|
61
|
+
* `DECLARED_LINK_ROUND_TRIP_MS` in `link-budget.ts`). With `L` the declaration, `R` one
|
|
62
|
+
* cohort read at that link (`cohortReadDeadlineMs`, 2L: one request and its answer over an
|
|
63
|
+
* open circuit) and `K` one commit (`commitBudgetMs`, 20L, measured):
|
|
64
|
+
*
|
|
65
|
+
* dialMs = a relayed dial plus one protocol negotiation (5L + 2 000)
|
|
66
|
+
* awaitContactMs = one request over the open stream (2L)
|
|
67
|
+
* validationMs = 2R (4L)
|
|
68
|
+
* provisionWorkMs = approval hook (10 000, flat) + 2R + K (10 000 + 24L)
|
|
69
|
+
* provisionGraceMs = R + K (22L)
|
|
70
|
+
* responseTravelMarginMs = L + validationMs (5L)
|
|
71
|
+
* initiatorAwaitResponseMs = work + grace + margin (10 000 + 51L)
|
|
72
|
+
* sessionMs = dialMs + initiatorAwaitResponseMs (12 000 + 56L)
|
|
73
|
+
*
|
|
74
|
+
* Why each term:
|
|
75
|
+
*
|
|
76
|
+
* - **awaitContact**: the listener's handler starts when it answers the protocol
|
|
77
|
+
* negotiation; the answer reaches the joiner and the contact frame comes back, one round
|
|
78
|
+
* trip, budgeted as a request over an open stream (2) for the same headroom as every other
|
|
79
|
+
* one-exchange read in `link-budget.ts`.
|
|
80
|
+
* - **validation** and the reads inside **provisionWork** are budgeted at two consulting
|
|
81
|
+
* reads each, not one per read the code issues. What actually runs: validation is three
|
|
82
|
+
* control reads (`isTokenValid` reads the invite; `isTokenUsed` reads it again and counts
|
|
83
|
+
* its usages) plus whatever the disclosure validator does; the bound provisioning path
|
|
84
|
+
* (`StrandFormationManager.provisionAsResponder`) reads the invite and the strand row to
|
|
85
|
+
* resolve the host strand, the invite again, the approver's `ValidationKey` and the seat
|
|
86
|
+
* count around the outside approval hook (an HTTP call, so its own
|
|
87
|
+
* `DEFAULT_APPROVAL_TIMEOUT_MS` is link-independent and added flat), the strand row and its
|
|
88
|
+
* `StrandPartyKey` to issue the joiner's membership invite, and the host strand's stamp
|
|
89
|
+
* before the `FormationUsage` write; then one strand-database commit issues the invite.
|
|
90
|
+
* Every one of those is a read of this party's own control database, which in steady state
|
|
91
|
+
* touches held blocks and consults nobody (`cadre-consistency.md` → "Deadlines Over
|
|
92
|
+
* Optimystic's Reads and Commits"), so `R` is charged as headroom for the ones that do
|
|
93
|
+
* consult, not per read. Waking a hibernating host strand inside the issue is NOT counted
|
|
94
|
+
* either: `wakeHostStrandForFormation` cuts it off by design with a retryable rejection
|
|
95
|
+
* and leaves the wake running, so the joiner's retry finds the strand live.
|
|
96
|
+
* NOTE: if formation is seen timing out while the responder's control reads are consulting
|
|
97
|
+
* the cohort (a cold responder, a control database not yet converged), charge one `R` per
|
|
98
|
+
* read here rather than raising the flat headroom; the joiner is told 'Formation
|
|
99
|
+
* provisioning timed out' with its invite unspent, so the cost is a retry, not a spent
|
|
100
|
+
* invite.
|
|
101
|
+
* - **provisionGrace**: the window it exists for opens when the `FormationUsage` insert
|
|
102
|
+
* attempt passes its abort check. `ControlDatabase.redeemInvitation` and
|
|
103
|
+
* `recordFormationUsage` check the signal once per attempt inside the write lock, then run
|
|
104
|
+
* the seat read (`assertSeatRemains`) and the transaction, so the grace contains one read
|
|
105
|
+
* plus one commit. A retry after a failed attempt re-checks the abort, so only one attempt
|
|
106
|
+
* needs containing.
|
|
107
|
+
* - **responseTravelMargin**: half a round trip out, half back, plus the validation reads,
|
|
108
|
+
* which run on the initiator's await-response clock but before the responder's
|
|
109
|
+
* provisioning clock starts.
|
|
110
|
+
* - **session**: the initiator's dial plus its await-response. The responder's whole path
|
|
111
|
+
* (awaitContact + validation + work + grace = 10 000 + 52L) is inside it at every L.
|
|
112
|
+
*
|
|
113
|
+
* Ordering, strict at every L > 0, and pinned by `strand-formation-deadlines.spec.ts`:
|
|
114
|
+
* approval hook < provisionWork < work + grace < initiatorAwaitResponse < session, and the
|
|
115
|
+
* responder's whole path < session. Each layer can therefore fail and report before the
|
|
116
|
+
* layer above it gives up, and the work budget always outlasts the hook's own timeout, so a
|
|
117
|
+
* dead hook reports 'Formation approval unavailable, retry' rather than racing a
|
|
118
|
+
* provisioning timeout.
|
|
119
|
+
*
|
|
120
|
+
* **What this costs.** At the default declaration (3 500 ms) a joiner whose responder
|
|
121
|
+
* accepts the contact and then hangs waits up to 188.5 s, about three minutes, before it is
|
|
122
|
+
* told, where the fixed ladder this replaced told it after 15 s; the whole session is
|
|
123
|
+
* bounded at 208 s. A responder that is unreachable still fails at the dial, 19.5 s. A host
|
|
124
|
+
* that knows its party is on a fast link lowers `linkRoundTripMs` and gets roughly the old
|
|
125
|
+
* numbers back (at L = 100: dial 2.5 s, provisioning 14.6 s, await-response 15.1 s, session
|
|
126
|
+
* 17.6 s). Before this derivation, the fixed 5 s dial could not open a relayed connection
|
|
127
|
+
* above a 1.25-second round trip, and the fixed 2 s grace could not contain one commit at the
|
|
128
|
+
* supported link, so a joiner could be told 'timed out' over an invite that was in fact
|
|
129
|
+
* spent.
|
|
130
|
+
*/
|
|
131
|
+
export function formationDeadlines(linkRoundTripMs?: number): FormationDeadlines {
|
|
132
|
+
const link = resolveLinkRoundTripMs(linkRoundTripMs);
|
|
133
|
+
const readMs = cohortReadDeadlineMs(link);
|
|
134
|
+
const commitMs = commitBudgetMs(link);
|
|
135
|
+
const dialMs = relayedStreamOpenBudgetMs(link);
|
|
136
|
+
const validationMs = 2 * readMs;
|
|
137
|
+
const provisionWorkMs = DEFAULT_APPROVAL_TIMEOUT_MS + 2 * readMs + commitMs;
|
|
138
|
+
const provisionGraceMs = readMs + commitMs;
|
|
139
|
+
const responseTravelMarginMs = link + validationMs;
|
|
140
|
+
const initiatorAwaitResponseMs = provisionWorkMs + provisionGraceMs + responseTravelMarginMs;
|
|
141
|
+
return {
|
|
142
|
+
dialMs,
|
|
143
|
+
awaitContactMs: CIRCUIT_REQUEST_ROUND_TRIPS * link,
|
|
144
|
+
validationMs,
|
|
145
|
+
provisionWorkMs,
|
|
146
|
+
provisionGraceMs,
|
|
147
|
+
responseTravelMarginMs,
|
|
148
|
+
initiatorAwaitResponseMs,
|
|
149
|
+
sessionMs: dialMs + initiatorAwaitResponseMs
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Ceiling for a resolved provisioning budget.
|
|
155
|
+
*
|
|
156
|
+
* The session budget also has to cover what PRECEDES provisioning in the role
|
|
157
|
+
* (`precedingMs`: the initiator's dial; the responder's contact read plus its validation
|
|
158
|
+
* reads), so that whole span is held back rather than triggering only on a literal overrun.
|
|
159
|
+
*
|
|
160
|
+
* `reserveMs` is the EXTRA room only the responder holds back, so its own budget still
|
|
161
|
+
* lands strictly before the initiator's larger await-response budget even when both are
|
|
162
|
+
* clamped — without it, a configured budget large enough to clamp both roles puts the
|
|
163
|
+
* responder's reply on the wire at the moment the initiator's timeout fires. Capped at half
|
|
164
|
+
* the remaining room (like {@link splitProvisionBudget}) so a small session config still
|
|
165
|
+
* spends most of it working.
|
|
166
|
+
*/
|
|
167
|
+
function provisionCeilingMs(sessionTimeoutMs: number, precedingMs: number, reserveMs: number): number {
|
|
168
|
+
const roomMs = sessionTimeoutMs - precedingMs;
|
|
169
|
+
return Math.max(1, roomMs - Math.min(reserveMs, Math.floor(roomMs / 2)));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The extra room the responder's clamp holds back ({@link provisionCeilingMs}'s `reserveMs`).
|
|
174
|
+
*
|
|
175
|
+
* The initiator's clamped await-response is `session - dial`, and the responder's reply
|
|
176
|
+
* reaches it `responseTravelMarginMs` after provisioning ends, so provisioning must end by
|
|
177
|
+
* `session - dial - margin`. The responder's own room is `session - awaitContact -
|
|
178
|
+
* validation`; the difference between the two is what it must hold back on top. Computed
|
|
179
|
+
* from the responder's own derivation, since it cannot see the initiator's dial budget: the
|
|
180
|
+
* module doc of `link-budget.ts` says why both parties must declare the same link.
|
|
181
|
+
*/
|
|
182
|
+
export function responderClampReserveMs(deadlines: FormationDeadlines, awaitContactMs: number): number {
|
|
183
|
+
return Math.max(0, deadlines.dialMs + deadlines.responseTravelMarginMs - awaitContactMs - deadlines.validationMs);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Resolve a caller-supplied provisioning budget: `0`/negative means "unset" (use
|
|
188
|
+
* `defaultMs`). If the result would let provisioning outlive the session — no result
|
|
189
|
+
* frame is ever sent, exactly the failure this budget exists to prevent — clamp it to
|
|
190
|
+
* {@link provisionCeilingMs} and log a warning. A budget that exactly meets the ceiling is
|
|
191
|
+
* not clamped: the derived defaults sit there by construction.
|
|
192
|
+
*/
|
|
193
|
+
export function resolveProvisionTimeoutMs(
|
|
194
|
+
configured: number | undefined,
|
|
195
|
+
defaultMs: number,
|
|
196
|
+
sessionTimeoutMs: number,
|
|
197
|
+
precedingMs: number,
|
|
198
|
+
role: string,
|
|
199
|
+
reserveMs = 0
|
|
200
|
+
): number {
|
|
201
|
+
const requested = configured && configured > 0 ? configured : defaultMs;
|
|
202
|
+
const ceilingMs = provisionCeilingMs(sessionTimeoutMs, precedingMs, reserveMs);
|
|
203
|
+
if (requested > ceilingMs) {
|
|
204
|
+
log(
|
|
205
|
+
'%s provisionTimeoutMs %dms leaves no room under sessionTimeoutMs %dms (preceding %dms, reserve %dms); clamping to %dms',
|
|
206
|
+
role, requested, sessionTimeoutMs, precedingMs, reserveMs, ceilingMs
|
|
207
|
+
);
|
|
208
|
+
return ceilingMs;
|
|
209
|
+
}
|
|
210
|
+
return requested;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Split a resolved provisioning budget into the WORK budget and the trailing settle grace.
|
|
215
|
+
*
|
|
216
|
+
* The grace is carved OUT of the budget, never added on top, so the ladder in
|
|
217
|
+
* {@link formationDeadlines} is untouched: the listener aborts the provisioning hook when the
|
|
218
|
+
* work budget expires, then waits up to the grace for the work to settle anyway
|
|
219
|
+
* (`FormationListener.settleWithinGrace` in `strand-formation-protocol.ts`). `graceCeilingMs`
|
|
220
|
+
* is the derived grace, which contains one seat read plus one commit at the declared link; it
|
|
221
|
+
* is capped at half the budget so a small configured budget still spends at least half its
|
|
222
|
+
* time doing work.
|
|
223
|
+
*/
|
|
224
|
+
export function splitProvisionBudget(provisionTimeoutMs: number, graceCeilingMs: number): { workMs: number; graceMs: number } {
|
|
225
|
+
const graceMs = Math.min(graceCeilingMs, Math.floor(provisionTimeoutMs / 2));
|
|
226
|
+
return { workMs: provisionTimeoutMs - graceMs, graceMs };
|
|
227
|
+
}
|