@serfab/cadre-core 1.10.0 → 1.11.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/dist/cadre-node.d.ts +0 -27
- package/dist/cadre-node.js +2 -94
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-schema.js +1066 -1066
- package/dist/diagnostics/connection-path.d.ts +1 -18
- package/dist/diagnostics/connection-path.js +1 -20
- package/dist/diagnostics/connection-path.js.map +1 -1
- package/dist/identity-key.d.ts +0 -30
- package/dist/identity-key.js +7 -40
- package/dist/identity-key.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +6 -4
- package/dist/index.js.map +1 -1
- package/dist/relay-stun.d.ts +24 -0
- package/dist/relay-stun.js +51 -0
- package/dist/relay-stun.js.map +1 -0
- package/dist/seed-bootstrap.js +4 -4
- package/dist/strand-membership-writer.js +32 -32
- package/package.json +2 -2
- package/src/cadre-node.ts +8654 -8753
- package/src/control-authorization.ts +117 -117
- package/src/control-cohort.ts +128 -128
- package/src/control-formation-recorder.ts +353 -353
- package/src/control-read-retry.ts +216 -216
- package/src/control-schema.ts +1078 -1078
- package/src/control-write-retry.ts +567 -567
- package/src/diagnostics/connection-path.ts +1 -29
- package/src/formation-approval.ts +610 -610
- package/src/hibernation-manager.ts +638 -638
- package/src/identity-key.ts +7 -62
- package/src/index.ts +644 -647
- package/src/invitation-bootstrap.ts +40 -40
- package/src/joined-strand-store.ts +498 -498
- package/src/link-budget.ts +684 -684
- package/src/membership-connection-gater.ts +570 -570
- package/src/node-local-snapshot.ts +298 -298
- package/src/peer-dial.ts +315 -315
- package/src/peer-join-backfill.ts +710 -710
- package/src/pending-join-runner.ts +735 -735
- package/src/relay-reservation.ts +1121 -1121
- package/src/relay-server.ts +98 -98
- package/src/relay-stun.ts +58 -0
- package/src/seed-bootstrap.ts +1448 -1448
- package/src/strand-addr-protocol.ts +547 -547
- package/src/strand-database.ts +287 -287
- package/src/strand-first-sync-gate.ts +391 -391
- package/src/strand-formation-deadlines.ts +227 -227
- package/src/strand-formation-manager.ts +725 -725
- package/src/strand-fret-addrs.ts +145 -145
- package/src/strand-instance-manager.ts +1789 -1789
- package/src/strand-membership-writer.ts +1974 -1974
- package/src/strand-network-state-file.ts +40 -40
- package/src/strand-network-state.ts +243 -243
- package/src/strand-revocation-enforcer.ts +654 -654
- package/src/strand-solicitation.ts +532 -532
- package/src/strand-wake-protocol.ts +401 -401
- package/src/types.ts +2222 -2222
- package/dist/diagnostics/webrtc-turn-tracker.d.ts +0 -50
- package/dist/diagnostics/webrtc-turn-tracker.js +0 -196
- package/dist/diagnostics/webrtc-turn-tracker.js.map +0 -1
- package/src/diagnostics/webrtc-turn-tracker.ts +0 -222
|
@@ -1,391 +1,391 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The first-sync write gate for a JOINING machine.
|
|
3
|
-
*
|
|
4
|
-
* A machine that has never received anything from another member of a strand must not
|
|
5
|
-
* commit to it. With no strand peer connected, Optimystic's cohort for every block is the
|
|
6
|
-
* machine itself, so the first write to a table finds no collection locally and INVENTS
|
|
7
|
-
* one — exactly what a founder writing alone legitimately does. When the connection comes
|
|
8
|
-
* up, two independently created histories share one collection id: Optimystic keeps one and
|
|
9
|
-
* drops the other's commits, or never reconciles them at all. The joiner's own rows silently
|
|
10
|
-
* vanish (`joining-machine-writes-before-first-sync-fork-tables`). Optimystic cannot tell a
|
|
11
|
-
* founder from a joiner; sereus can, because the launch knows whether it is founding.
|
|
12
|
-
*
|
|
13
|
-
* The invariant this module enforces: **a machine that has never held this strand's
|
|
14
|
-
* `Strand.Header` row, and has not read every `App` table once, must not commit to it.**
|
|
15
|
-
* The founder writes the Header in its bootstrap; every other machine can only receive it
|
|
16
|
-
* from a peer, so "no local Header" means "never synced". The gate therefore holds the
|
|
17
|
-
* freshly initialized `StrandDatabase` back from the app — `StrandInstance.database` stays
|
|
18
|
-
* unset and the instance reports `'syncing'` — and probes on a cadence until the Header is
|
|
19
|
-
* readable AND a read of each app table settles, at which point the database is published
|
|
20
|
-
* and the instance goes `'active'`. A machine whose store already holds the Header and every
|
|
21
|
-
* app table (a founder, and normally a restart or a hibernation resume) passes the probe on
|
|
22
|
-
* the first try and is never gated, so offline-first writes on a machine that has synced
|
|
23
|
-
* before keep working.
|
|
24
|
-
*
|
|
25
|
-
* "Has synced before" is decided by what the local store actually holds, not by whether this
|
|
26
|
-
* machine once read the strand: a machine that left soon after its first sync can hold less
|
|
27
|
-
* than it read, and is then gated like a joiner. The re-attach measurement on
|
|
28
|
-
* {@link DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS} saw exactly that — a machine back over the store
|
|
29
|
-
* it kept, without the `Strand.Header` collection it had read before it left. Gating it is
|
|
30
|
-
* right: writing into a collection this machine does not hold would fork it.
|
|
31
|
-
*
|
|
32
|
-
* Why the app tables too: the Header is ONE collection. It reaches a joiner ahead of the
|
|
33
|
-
* founder's app-table collections (peer-join backfill and pull-on-read deliver those
|
|
34
|
-
* separately), and a write to a table whose collection this machine has not yet fetched
|
|
35
|
-
* invents one exactly as a lone joiner did. Measured on the reference chat schema
|
|
36
|
-
* (2026-09-16, direct connections, Header-only probe): the joiner's participant + message
|
|
37
|
-
* written the instant `addStrand` resolved diverged in 3 of 4 runs. Reading each table once
|
|
38
|
-
* pulls its collection while the founder is reachable, so the write that follows appends.
|
|
39
|
-
*
|
|
40
|
-
* Every probe is a read (`Strand.Header`, then `select count(1)` on each `App` table). Reads
|
|
41
|
-
* never invent a collection (Optimystic's `Collection.open` resolves undefined on an
|
|
42
|
-
* authoritatively absent header, and throws on an unreachable one), so probing is safe to
|
|
43
|
-
* repeat and a throw is just "not yet". A table nobody has written yet reads as absent on
|
|
44
|
-
* every machine; the first write to it still creates its collection — see `docs/strands.md`
|
|
45
|
-
* ("Joining") for that residual.
|
|
46
|
-
*
|
|
47
|
-
* Owned by `StrandInstanceManager`: created in `buildStrandRuntime` for every launch, armed
|
|
48
|
-
* only for a non-founder launch whose first probe fails, stopped and dropped in
|
|
49
|
-
* `releaseRuntime` (so a quiesce → resume rebuild re-probes over the same store), and opened
|
|
50
|
-
* by `publishDatabase` whenever the database is published without its loop — a launch that
|
|
51
|
-
* needed no gate, or a founder request that ran the bootstrap against the still-gated
|
|
52
|
-
* database (`foundExistingStrand`).
|
|
53
|
-
*
|
|
54
|
-
* NOTE: the gate covers app and membership tables, not the schema catalog. Every launch —
|
|
55
|
-
* joiner or founder — writes the catalog collection (`optimystic/schema` plus two
|
|
56
|
-
* hash-named blocks, measured 2026-09-16) alone, during `connectToStrand`'s schema apply,
|
|
57
|
-
* before any peer contact. That is the same "two histories under one id" shape this gate
|
|
58
|
-
* exists to prevent, and it is only safe because both sides derive byte-identical content
|
|
59
|
-
* from the same schema text. If the catalog ever carries per-machine or ordering-dependent
|
|
60
|
-
* content (optimystic's own `0.5-same-named-tables-in-two-schemas-share-storage` re-keys
|
|
61
|
-
* it), a joiner's catalog will fork against the founder's — gate the schema apply on the
|
|
62
|
-
* Header too, or have optimystic open the catalog read-only when a peer already holds it.
|
|
63
|
-
*/
|
|
64
|
-
|
|
65
|
-
import debug from 'debug';
|
|
66
|
-
import type { Database } from '@quereus/quereus';
|
|
67
|
-
import type { StrandDatabase } from './strand-database.js';
|
|
68
|
-
import { defaultTimeoutScheduler, type TimeoutScheduler } from './timeout-scheduler.js';
|
|
69
|
-
|
|
70
|
-
const log = debug('sereus:cadre:strand-first-sync');
|
|
71
|
-
|
|
72
|
-
/** How often a gated launch re-probes (`Strand.Header`, then every `App` table) while waiting for its first sync. */
|
|
73
|
-
export const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* How long `CadreNode.addStrand` waits for a machine's first sync before rejecting with
|
|
77
|
-
* {@link StrandAwaitingFirstSyncError}. It has to cover the slowest attach sereus carries,
|
|
78
|
-
* which is not a first join: a machine RE-attaching through a relay on a slow link can take
|
|
79
|
-
* longer than one joining for the first time.
|
|
80
|
-
*
|
|
81
|
-
* Measured over a DIRECT connection (2026-09-16): the joiner reads the founder's rows about
|
|
82
|
-
* 1.3 s after `addStrand` would previously have resolved. That is the fast case, and it is not
|
|
83
|
-
* what this budget has to cover.
|
|
84
|
-
*
|
|
85
|
-
* Every harness figure below was taken on one shape: one Windows developer machine, two
|
|
86
|
-
* relay-only `CadreNode`s (`listenAddrs: []`) on a shared loopback dedicated relay, with a
|
|
87
|
-
* one-way per-frame outbound delay applied to every websocket by
|
|
88
|
-
* `integration-tests/src/harness/ws-latency.ts` in `pipelined` mode — frames stay overlapped
|
|
89
|
-
* in flight, so the figure is latency and bandwidth stays unlimited; the harness's other mode
|
|
90
|
-
* (`serial`) is a per-socket frame-rate cap and its delays are NOT comparable
|
|
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.
|
|
94
|
-
*
|
|
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.
|
|
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
|
|
138
|
-
* `integration-tests/src/scenarios/strand-reattach-first-sync-measure.integration.ts` — re-run
|
|
139
|
-
* it before changing this number): B attached once on an undelayed link, left with
|
|
140
|
-
* `stopStrand`, A wrote while it was away, the delay was raised, and B called `addStrand`
|
|
141
|
-
* again.
|
|
142
|
-
* - Over an EMPTY store (one minted per launch, cadre's default with no storage provider):
|
|
143
|
-
* gated in all four runs and writable at 38.7-56.8 s, the same as a fresh join.
|
|
144
|
-
* - Over the store it KEPT (a phone with durable storage), bimodal. In 3 of 8 runs the store
|
|
145
|
-
* held everything and the launch came up writable (5.6 s, the launch itself). In the other
|
|
146
|
-
* 5 it lacked the `Strand.Header` collection B had read on its first attach, so B was gated
|
|
147
|
-
* like a joiner and became writable at 64.1-78.7 s — the slowest attach measured, about
|
|
148
|
-
* twice a fresh join. Why syncing over a partial store is slower sits inside optimystic
|
|
149
|
-
* (`tickets/blocked/report-reattach-over-partial-replica-to-optimystic`).
|
|
150
|
-
* - Neither a redial nor catching up on missed writes explains the time: B's strand node
|
|
151
|
-
* connected to A's at 11 s in every arm, fresh join included, and a run with 20 missed
|
|
152
|
-
* writes fell inside the run-to-run spread of those with 1.
|
|
153
|
-
* This budget covers WRITABLE, not caught up: in the gated kept-store runs the row A wrote
|
|
154
|
-
* while B was away became readable only at 140-162 s (121-175 s at the supported link).
|
|
155
|
-
*
|
|
156
|
-
* Two samples from outside this harness, neither reproduced here and neither discarded: a
|
|
157
|
-
* reporter's re-attach of the same shape (optimystic #22; their harness, storage and delay
|
|
158
|
-
* injector are unknown to us) opened the gate at about 150 s, after the previous 120 s budget
|
|
159
|
-
* had already rejected; and a real Galaxy S7 joining fresh through relay.sereus.org took 178 s.
|
|
160
|
-
*
|
|
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.
|
|
171
|
-
*
|
|
172
|
-
* NOTE: accepted tradeoff — what 300 s costs. This wait is what an app's `addStrand` sits in
|
|
173
|
-
* before it is told "not yet", so a strand none of whose members is reachable at all takes five
|
|
174
|
-
* minutes to report instead of two. The slow report was weighed against refusing attaches that
|
|
175
|
-
* were working, and the refusal is the worse failure. The cost is bounded — the rejection is
|
|
176
|
-
* retryable, the strand stays launched and keeps probing, and `strand:writable` fires the
|
|
177
|
-
* moment the sync lands, so an app that listens for the event rather than awaiting the call is
|
|
178
|
-
* unaffected either way. Revisit if the gate ever learns whether ANY other member is connected:
|
|
179
|
-
* "no peer at all" could then be reported at once and this budget would only ever be spent on a
|
|
180
|
-
* sync that is actually in progress — worth more the longer this budget gets.
|
|
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
|
|
183
|
-
export const DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS = 300_000;
|
|
184
|
-
|
|
185
|
-
/** Embedder-facing tuning for the gate, threaded from `CadreNodeConfig.strandFirstSync`. */
|
|
186
|
-
export interface StrandFirstSyncConfig {
|
|
187
|
-
/**
|
|
188
|
-
* Default wait for `CadreNode.addStrand` (and `whenStrandWritable`) before a machine whose
|
|
189
|
-
* first sync has not completed is reported as not yet writable. Default
|
|
190
|
-
* {@link DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS}.
|
|
191
|
-
*/
|
|
192
|
-
timeoutMs?: number;
|
|
193
|
-
/** Probe cadence while gated. Default {@link DEFAULT_STRAND_FIRST_SYNC_POLL_MS}. */
|
|
194
|
-
pollIntervalMs?: number;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Thrown by `CadreNode.addStrand` / `whenStrandWritable` when a machine has not received the
|
|
199
|
-
* strand's data from another member within the wait budget — because no other member is
|
|
200
|
-
* reachable, or because one is and the sync is still arriving over a slow link; the gate
|
|
201
|
-
* cannot tell the two apart. RETRYABLE: the strand stays launched and keeps probing, so a
|
|
202
|
-
* later `addStrand` (or `whenStrandWritable`) for the same strand completes the attach once
|
|
203
|
-
* the data has arrived; the `strand:writable` event fires at that moment too.
|
|
204
|
-
*/
|
|
205
|
-
export class StrandAwaitingFirstSyncError extends Error {
|
|
206
|
-
readonly strandId: string;
|
|
207
|
-
readonly waitedMs: number;
|
|
208
|
-
|
|
209
|
-
constructor(strandId: string, waitedMs: number) {
|
|
210
|
-
super(
|
|
211
|
-
`Strand ${strandId} is not writable yet: this machine has not yet received the strand's data ` +
|
|
212
|
-
`from another member (waited ${waitedMs} ms). Either no other member is reachable, or the ` +
|
|
213
|
-
'link to one is slow and the sync is still in progress. The strand stays launched and keeps ' +
|
|
214
|
-
'trying — call addStrand or whenStrandWritable(strandId) again to keep waiting, or listen ' +
|
|
215
|
-
"for the 'strand:writable' event."
|
|
216
|
-
);
|
|
217
|
-
this.name = 'StrandAwaitingFirstSyncError';
|
|
218
|
-
this.strandId = strandId;
|
|
219
|
-
this.waitedMs = waitedMs;
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Whether this machine holds the strand's `Strand.Header` row — the "has synced at least
|
|
225
|
-
* once (or founded)" signal. A read, never a write: it cannot invent the collection. A
|
|
226
|
-
* throwing read (an unreachable cohort mid-sync, a store still hydrating) reports `false`
|
|
227
|
-
* with a log, since the caller's only response to either answer is "probe again".
|
|
228
|
-
*/
|
|
229
|
-
export async function strandHeaderHeld(db: Database, label: string): Promise<boolean> {
|
|
230
|
-
try {
|
|
231
|
-
for await (const row of db.eval('select count(1) as Count from Strand.Header')) {
|
|
232
|
-
return ((row.Count as number) ?? 0) > 0;
|
|
233
|
-
}
|
|
234
|
-
return false;
|
|
235
|
-
} catch (error) {
|
|
236
|
-
log('[%s] Header probe failed (treated as not yet held): %s', label,
|
|
237
|
-
error instanceof Error ? error.message : String(error));
|
|
238
|
-
return false;
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/** The tables the sApp declared in the `App` schema, by name; views excluded. */
|
|
243
|
-
function appTableNames(db: Database): string[] {
|
|
244
|
-
const app = db.schemaManager.getSchema('App');
|
|
245
|
-
return app ? Array.from(app.getAllTables()).filter((table) => !table.isView).map((table) => table.name) : [];
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* Whether a read of every `App` table settles on this machine — the "each app collection
|
|
250
|
-
* has been fetched from the cohort, or is authoritatively absent" signal. Only a throwing
|
|
251
|
-
* read (an unreachable cohort) reports `false`; an empty table is a settled read.
|
|
252
|
-
*/
|
|
253
|
-
export async function appTablesReadable(db: Database, label: string): Promise<boolean> {
|
|
254
|
-
for (const table of appTableNames(db)) {
|
|
255
|
-
try {
|
|
256
|
-
for await (const _row of db.eval(`select count(1) as Count from App."${table}"`)) {
|
|
257
|
-
break;
|
|
258
|
-
}
|
|
259
|
-
} catch (error) {
|
|
260
|
-
log('[%s] App.%s probe failed (treated as not yet synced): %s', label, table,
|
|
261
|
-
error instanceof Error ? error.message : String(error));
|
|
262
|
-
return false;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
return true;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* The gate's whole probe: the Header is held AND every app table has been read once. The
|
|
270
|
-
* app tables are read only once the Header is — a joiner with no peer would otherwise pay
|
|
271
|
-
* one failing network read per table per probe for nothing.
|
|
272
|
-
*/
|
|
273
|
-
export async function strandFirstSyncComplete(db: Database, label: string): Promise<boolean> {
|
|
274
|
-
return await strandHeaderHeld(db, label) && await appTablesReadable(db, label);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
export interface StrandFirstSyncGateDeps {
|
|
278
|
-
/** Log tag naming which strand this gate holds (the strand id). */
|
|
279
|
-
label: string;
|
|
280
|
-
/** The initialized database being held back from the app until the Header is held. */
|
|
281
|
-
database: StrandDatabase;
|
|
282
|
-
/**
|
|
283
|
-
* Called exactly once, on the gate's own probe loop, when the first sync completes.
|
|
284
|
-
* NOT called by {@link StrandFirstSyncGate.open} — a caller that force-opens the gate
|
|
285
|
-
* (a founder bootstrap it just ran) publishes the database itself.
|
|
286
|
-
*/
|
|
287
|
-
onHeaderHeld: () => void;
|
|
288
|
-
/** Timer seam for the probe loop; omit for real (unref'd) timeouts. */
|
|
289
|
-
scheduler?: TimeoutScheduler;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* Holds one launch's `StrandDatabase` until {@link strandFirstSyncComplete}. Probes are
|
|
294
|
-
* sequential (the next is scheduled only after the previous read settles), so a slow
|
|
295
|
-
* network read never stacks probes.
|
|
296
|
-
*/
|
|
297
|
-
export class StrandFirstSyncGate {
|
|
298
|
-
private readonly scheduler: TimeoutScheduler;
|
|
299
|
-
private readonly pollIntervalMs: number;
|
|
300
|
-
private timer: unknown;
|
|
301
|
-
private stopped = false;
|
|
302
|
-
private opened = false;
|
|
303
|
-
private probing = false;
|
|
304
|
-
|
|
305
|
-
constructor(private readonly deps: StrandFirstSyncGateDeps, config?: StrandFirstSyncConfig) {
|
|
306
|
-
this.scheduler = deps.scheduler ?? defaultTimeoutScheduler;
|
|
307
|
-
this.pollIntervalMs = config?.pollIntervalMs ?? DEFAULT_STRAND_FIRST_SYNC_POLL_MS;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/** The database this gate is holding back. */
|
|
311
|
-
get database(): StrandDatabase {
|
|
312
|
-
return this.deps.database;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/** True once the Header was seen (or the gate was force-opened); the loop is then done. */
|
|
316
|
-
get isOpen(): boolean {
|
|
317
|
-
return this.opened;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/** Arm the probe loop. The first probe runs after one interval — the caller already probed once. */
|
|
321
|
-
start(): void {
|
|
322
|
-
if (this.stopped || this.opened || this.timer !== undefined) return;
|
|
323
|
-
this.schedule();
|
|
324
|
-
log('[%s] first-sync gate armed: no Strand.Header held yet; probing every %dms',
|
|
325
|
-
this.deps.label, this.pollIntervalMs);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* Open without the probe loop: the caller is publishing the database itself — a launch
|
|
330
|
-
* whose first probe passed (or a founder's), which never armed the loop, or a founder
|
|
331
|
-
* bootstrap just run against the gated database. Stops the loop; `onHeaderHeld` is NOT
|
|
332
|
-
* invoked.
|
|
333
|
-
*/
|
|
334
|
-
open(): void {
|
|
335
|
-
if (this.opened) return;
|
|
336
|
-
this.opened = true;
|
|
337
|
-
this.stop();
|
|
338
|
-
log('[%s] database published by the caller; first-sync gate not needed or force-opened', this.deps.label);
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/** Disarm the loop; a probe already in flight completes and is then ignored. */
|
|
342
|
-
stop(): void {
|
|
343
|
-
this.stopped = true;
|
|
344
|
-
if (this.timer !== undefined) {
|
|
345
|
-
this.scheduler.clearTimeout(this.timer);
|
|
346
|
-
this.timer = undefined;
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
private schedule(): void {
|
|
351
|
-
this.timer = this.scheduler.setTimeout(() => {
|
|
352
|
-
this.timer = undefined;
|
|
353
|
-
void this.probe();
|
|
354
|
-
}, this.pollIntervalMs);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
private async probe(): Promise<void> {
|
|
358
|
-
if (this.stopped || this.opened || this.probing) return;
|
|
359
|
-
this.probing = true;
|
|
360
|
-
const held = await this.probeHeld();
|
|
361
|
-
// Re-check after the await: a release or a force-open may have landed mid-read.
|
|
362
|
-
if (this.stopped || this.opened) return;
|
|
363
|
-
if (!held) {
|
|
364
|
-
this.schedule();
|
|
365
|
-
return;
|
|
366
|
-
}
|
|
367
|
-
this.opened = true;
|
|
368
|
-
this.stop();
|
|
369
|
-
log('[%s] first-sync gate opened: Strand.Header and every App table read from a peer', this.deps.label);
|
|
370
|
-
this.deps.onHeaderHeld();
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/**
|
|
374
|
-
* One probe's answer, with a throw from OUTSIDE {@link strandFirstSyncComplete}'s own read
|
|
375
|
-
* guards — the `App` schema lookup, or `getDatabase()` itself — reported as "not yet" like
|
|
376
|
-
* any failing read. The loop is scheduled from `void this.probe()`, so an escaping rejection
|
|
377
|
-
* would be unhandled AND leave nothing scheduled: the strand would stay gated for the whole
|
|
378
|
-
* `timeoutMs` and never recover, which is the one failure this gate must not have.
|
|
379
|
-
*/
|
|
380
|
-
private async probeHeld(): Promise<boolean> {
|
|
381
|
-
try {
|
|
382
|
-
return await strandFirstSyncComplete(this.deps.database.getDatabase(), this.deps.label);
|
|
383
|
-
} catch (error) {
|
|
384
|
-
log('[%s] first-sync probe threw (treated as not yet synced): %s', this.deps.label,
|
|
385
|
-
error instanceof Error ? error.message : String(error));
|
|
386
|
-
return false;
|
|
387
|
-
} finally {
|
|
388
|
-
this.probing = false;
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The first-sync write gate for a JOINING machine.
|
|
3
|
+
*
|
|
4
|
+
* A machine that has never received anything from another member of a strand must not
|
|
5
|
+
* commit to it. With no strand peer connected, Optimystic's cohort for every block is the
|
|
6
|
+
* machine itself, so the first write to a table finds no collection locally and INVENTS
|
|
7
|
+
* one — exactly what a founder writing alone legitimately does. When the connection comes
|
|
8
|
+
* up, two independently created histories share one collection id: Optimystic keeps one and
|
|
9
|
+
* drops the other's commits, or never reconciles them at all. The joiner's own rows silently
|
|
10
|
+
* vanish (`joining-machine-writes-before-first-sync-fork-tables`). Optimystic cannot tell a
|
|
11
|
+
* founder from a joiner; sereus can, because the launch knows whether it is founding.
|
|
12
|
+
*
|
|
13
|
+
* The invariant this module enforces: **a machine that has never held this strand's
|
|
14
|
+
* `Strand.Header` row, and has not read every `App` table once, must not commit to it.**
|
|
15
|
+
* The founder writes the Header in its bootstrap; every other machine can only receive it
|
|
16
|
+
* from a peer, so "no local Header" means "never synced". The gate therefore holds the
|
|
17
|
+
* freshly initialized `StrandDatabase` back from the app — `StrandInstance.database` stays
|
|
18
|
+
* unset and the instance reports `'syncing'` — and probes on a cadence until the Header is
|
|
19
|
+
* readable AND a read of each app table settles, at which point the database is published
|
|
20
|
+
* and the instance goes `'active'`. A machine whose store already holds the Header and every
|
|
21
|
+
* app table (a founder, and normally a restart or a hibernation resume) passes the probe on
|
|
22
|
+
* the first try and is never gated, so offline-first writes on a machine that has synced
|
|
23
|
+
* before keep working.
|
|
24
|
+
*
|
|
25
|
+
* "Has synced before" is decided by what the local store actually holds, not by whether this
|
|
26
|
+
* machine once read the strand: a machine that left soon after its first sync can hold less
|
|
27
|
+
* than it read, and is then gated like a joiner. The re-attach measurement on
|
|
28
|
+
* {@link DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS} saw exactly that — a machine back over the store
|
|
29
|
+
* it kept, without the `Strand.Header` collection it had read before it left. Gating it is
|
|
30
|
+
* right: writing into a collection this machine does not hold would fork it.
|
|
31
|
+
*
|
|
32
|
+
* Why the app tables too: the Header is ONE collection. It reaches a joiner ahead of the
|
|
33
|
+
* founder's app-table collections (peer-join backfill and pull-on-read deliver those
|
|
34
|
+
* separately), and a write to a table whose collection this machine has not yet fetched
|
|
35
|
+
* invents one exactly as a lone joiner did. Measured on the reference chat schema
|
|
36
|
+
* (2026-09-16, direct connections, Header-only probe): the joiner's participant + message
|
|
37
|
+
* written the instant `addStrand` resolved diverged in 3 of 4 runs. Reading each table once
|
|
38
|
+
* pulls its collection while the founder is reachable, so the write that follows appends.
|
|
39
|
+
*
|
|
40
|
+
* Every probe is a read (`Strand.Header`, then `select count(1)` on each `App` table). Reads
|
|
41
|
+
* never invent a collection (Optimystic's `Collection.open` resolves undefined on an
|
|
42
|
+
* authoritatively absent header, and throws on an unreachable one), so probing is safe to
|
|
43
|
+
* repeat and a throw is just "not yet". A table nobody has written yet reads as absent on
|
|
44
|
+
* every machine; the first write to it still creates its collection — see `docs/strands.md`
|
|
45
|
+
* ("Joining") for that residual.
|
|
46
|
+
*
|
|
47
|
+
* Owned by `StrandInstanceManager`: created in `buildStrandRuntime` for every launch, armed
|
|
48
|
+
* only for a non-founder launch whose first probe fails, stopped and dropped in
|
|
49
|
+
* `releaseRuntime` (so a quiesce → resume rebuild re-probes over the same store), and opened
|
|
50
|
+
* by `publishDatabase` whenever the database is published without its loop — a launch that
|
|
51
|
+
* needed no gate, or a founder request that ran the bootstrap against the still-gated
|
|
52
|
+
* database (`foundExistingStrand`).
|
|
53
|
+
*
|
|
54
|
+
* NOTE: the gate covers app and membership tables, not the schema catalog. Every launch —
|
|
55
|
+
* joiner or founder — writes the catalog collection (`optimystic/schema` plus two
|
|
56
|
+
* hash-named blocks, measured 2026-09-16) alone, during `connectToStrand`'s schema apply,
|
|
57
|
+
* before any peer contact. That is the same "two histories under one id" shape this gate
|
|
58
|
+
* exists to prevent, and it is only safe because both sides derive byte-identical content
|
|
59
|
+
* from the same schema text. If the catalog ever carries per-machine or ordering-dependent
|
|
60
|
+
* content (optimystic's own `0.5-same-named-tables-in-two-schemas-share-storage` re-keys
|
|
61
|
+
* it), a joiner's catalog will fork against the founder's — gate the schema apply on the
|
|
62
|
+
* Header too, or have optimystic open the catalog read-only when a peer already holds it.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
import debug from 'debug';
|
|
66
|
+
import type { Database } from '@quereus/quereus';
|
|
67
|
+
import type { StrandDatabase } from './strand-database.js';
|
|
68
|
+
import { defaultTimeoutScheduler, type TimeoutScheduler } from './timeout-scheduler.js';
|
|
69
|
+
|
|
70
|
+
const log = debug('sereus:cadre:strand-first-sync');
|
|
71
|
+
|
|
72
|
+
/** How often a gated launch re-probes (`Strand.Header`, then every `App` table) while waiting for its first sync. */
|
|
73
|
+
export const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* How long `CadreNode.addStrand` waits for a machine's first sync before rejecting with
|
|
77
|
+
* {@link StrandAwaitingFirstSyncError}. It has to cover the slowest attach sereus carries,
|
|
78
|
+
* which is not a first join: a machine RE-attaching through a relay on a slow link can take
|
|
79
|
+
* longer than one joining for the first time.
|
|
80
|
+
*
|
|
81
|
+
* Measured over a DIRECT connection (2026-09-16): the joiner reads the founder's rows about
|
|
82
|
+
* 1.3 s after `addStrand` would previously have resolved. That is the fast case, and it is not
|
|
83
|
+
* what this budget has to cover.
|
|
84
|
+
*
|
|
85
|
+
* Every harness figure below was taken on one shape: one Windows developer machine, two
|
|
86
|
+
* relay-only `CadreNode`s (`listenAddrs: []`) on a shared loopback dedicated relay, with a
|
|
87
|
+
* one-way per-frame outbound delay applied to every websocket by
|
|
88
|
+
* `integration-tests/src/harness/ws-latency.ts` in `pipelined` mode — frames stay overlapped
|
|
89
|
+
* in flight, so the figure is latency and bandwidth stays unlimited; the harness's other mode
|
|
90
|
+
* (`serial`) is a per-socket frame-rate cap and its delays are NOT comparable
|
|
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.
|
|
94
|
+
*
|
|
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.
|
|
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
|
|
138
|
+
* `integration-tests/src/scenarios/strand-reattach-first-sync-measure.integration.ts` — re-run
|
|
139
|
+
* it before changing this number): B attached once on an undelayed link, left with
|
|
140
|
+
* `stopStrand`, A wrote while it was away, the delay was raised, and B called `addStrand`
|
|
141
|
+
* again.
|
|
142
|
+
* - Over an EMPTY store (one minted per launch, cadre's default with no storage provider):
|
|
143
|
+
* gated in all four runs and writable at 38.7-56.8 s, the same as a fresh join.
|
|
144
|
+
* - Over the store it KEPT (a phone with durable storage), bimodal. In 3 of 8 runs the store
|
|
145
|
+
* held everything and the launch came up writable (5.6 s, the launch itself). In the other
|
|
146
|
+
* 5 it lacked the `Strand.Header` collection B had read on its first attach, so B was gated
|
|
147
|
+
* like a joiner and became writable at 64.1-78.7 s — the slowest attach measured, about
|
|
148
|
+
* twice a fresh join. Why syncing over a partial store is slower sits inside optimystic
|
|
149
|
+
* (`tickets/blocked/report-reattach-over-partial-replica-to-optimystic`).
|
|
150
|
+
* - Neither a redial nor catching up on missed writes explains the time: B's strand node
|
|
151
|
+
* connected to A's at 11 s in every arm, fresh join included, and a run with 20 missed
|
|
152
|
+
* writes fell inside the run-to-run spread of those with 1.
|
|
153
|
+
* This budget covers WRITABLE, not caught up: in the gated kept-store runs the row A wrote
|
|
154
|
+
* while B was away became readable only at 140-162 s (121-175 s at the supported link).
|
|
155
|
+
*
|
|
156
|
+
* Two samples from outside this harness, neither reproduced here and neither discarded: a
|
|
157
|
+
* reporter's re-attach of the same shape (optimystic #22; their harness, storage and delay
|
|
158
|
+
* injector are unknown to us) opened the gate at about 150 s, after the previous 120 s budget
|
|
159
|
+
* had already rejected; and a real Galaxy S7 joining fresh through relay.sereus.org took 178 s.
|
|
160
|
+
*
|
|
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.
|
|
171
|
+
*
|
|
172
|
+
* NOTE: accepted tradeoff — what 300 s costs. This wait is what an app's `addStrand` sits in
|
|
173
|
+
* before it is told "not yet", so a strand none of whose members is reachable at all takes five
|
|
174
|
+
* minutes to report instead of two. The slow report was weighed against refusing attaches that
|
|
175
|
+
* were working, and the refusal is the worse failure. The cost is bounded — the rejection is
|
|
176
|
+
* retryable, the strand stays launched and keeps probing, and `strand:writable` fires the
|
|
177
|
+
* moment the sync lands, so an app that listens for the event rather than awaiting the call is
|
|
178
|
+
* unaffected either way. Revisit if the gate ever learns whether ANY other member is connected:
|
|
179
|
+
* "no peer at all" could then be reported at once and this budget would only ever be spent on a
|
|
180
|
+
* sync that is actually in progress — worth more the longer this budget gets.
|
|
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
|
|
183
|
+
export const DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS = 300_000;
|
|
184
|
+
|
|
185
|
+
/** Embedder-facing tuning for the gate, threaded from `CadreNodeConfig.strandFirstSync`. */
|
|
186
|
+
export interface StrandFirstSyncConfig {
|
|
187
|
+
/**
|
|
188
|
+
* Default wait for `CadreNode.addStrand` (and `whenStrandWritable`) before a machine whose
|
|
189
|
+
* first sync has not completed is reported as not yet writable. Default
|
|
190
|
+
* {@link DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS}.
|
|
191
|
+
*/
|
|
192
|
+
timeoutMs?: number;
|
|
193
|
+
/** Probe cadence while gated. Default {@link DEFAULT_STRAND_FIRST_SYNC_POLL_MS}. */
|
|
194
|
+
pollIntervalMs?: number;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Thrown by `CadreNode.addStrand` / `whenStrandWritable` when a machine has not received the
|
|
199
|
+
* strand's data from another member within the wait budget — because no other member is
|
|
200
|
+
* reachable, or because one is and the sync is still arriving over a slow link; the gate
|
|
201
|
+
* cannot tell the two apart. RETRYABLE: the strand stays launched and keeps probing, so a
|
|
202
|
+
* later `addStrand` (or `whenStrandWritable`) for the same strand completes the attach once
|
|
203
|
+
* the data has arrived; the `strand:writable` event fires at that moment too.
|
|
204
|
+
*/
|
|
205
|
+
export class StrandAwaitingFirstSyncError extends Error {
|
|
206
|
+
readonly strandId: string;
|
|
207
|
+
readonly waitedMs: number;
|
|
208
|
+
|
|
209
|
+
constructor(strandId: string, waitedMs: number) {
|
|
210
|
+
super(
|
|
211
|
+
`Strand ${strandId} is not writable yet: this machine has not yet received the strand's data ` +
|
|
212
|
+
`from another member (waited ${waitedMs} ms). Either no other member is reachable, or the ` +
|
|
213
|
+
'link to one is slow and the sync is still in progress. The strand stays launched and keeps ' +
|
|
214
|
+
'trying — call addStrand or whenStrandWritable(strandId) again to keep waiting, or listen ' +
|
|
215
|
+
"for the 'strand:writable' event."
|
|
216
|
+
);
|
|
217
|
+
this.name = 'StrandAwaitingFirstSyncError';
|
|
218
|
+
this.strandId = strandId;
|
|
219
|
+
this.waitedMs = waitedMs;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Whether this machine holds the strand's `Strand.Header` row — the "has synced at least
|
|
225
|
+
* once (or founded)" signal. A read, never a write: it cannot invent the collection. A
|
|
226
|
+
* throwing read (an unreachable cohort mid-sync, a store still hydrating) reports `false`
|
|
227
|
+
* with a log, since the caller's only response to either answer is "probe again".
|
|
228
|
+
*/
|
|
229
|
+
export async function strandHeaderHeld(db: Database, label: string): Promise<boolean> {
|
|
230
|
+
try {
|
|
231
|
+
for await (const row of db.eval('select count(1) as Count from Strand.Header')) {
|
|
232
|
+
return ((row.Count as number) ?? 0) > 0;
|
|
233
|
+
}
|
|
234
|
+
return false;
|
|
235
|
+
} catch (error) {
|
|
236
|
+
log('[%s] Header probe failed (treated as not yet held): %s', label,
|
|
237
|
+
error instanceof Error ? error.message : String(error));
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The tables the sApp declared in the `App` schema, by name; views excluded. */
|
|
243
|
+
function appTableNames(db: Database): string[] {
|
|
244
|
+
const app = db.schemaManager.getSchema('App');
|
|
245
|
+
return app ? Array.from(app.getAllTables()).filter((table) => !table.isView).map((table) => table.name) : [];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Whether a read of every `App` table settles on this machine — the "each app collection
|
|
250
|
+
* has been fetched from the cohort, or is authoritatively absent" signal. Only a throwing
|
|
251
|
+
* read (an unreachable cohort) reports `false`; an empty table is a settled read.
|
|
252
|
+
*/
|
|
253
|
+
export async function appTablesReadable(db: Database, label: string): Promise<boolean> {
|
|
254
|
+
for (const table of appTableNames(db)) {
|
|
255
|
+
try {
|
|
256
|
+
for await (const _row of db.eval(`select count(1) as Count from App."${table}"`)) {
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
} catch (error) {
|
|
260
|
+
log('[%s] App.%s probe failed (treated as not yet synced): %s', label, table,
|
|
261
|
+
error instanceof Error ? error.message : String(error));
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The gate's whole probe: the Header is held AND every app table has been read once. The
|
|
270
|
+
* app tables are read only once the Header is — a joiner with no peer would otherwise pay
|
|
271
|
+
* one failing network read per table per probe for nothing.
|
|
272
|
+
*/
|
|
273
|
+
export async function strandFirstSyncComplete(db: Database, label: string): Promise<boolean> {
|
|
274
|
+
return await strandHeaderHeld(db, label) && await appTablesReadable(db, label);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export interface StrandFirstSyncGateDeps {
|
|
278
|
+
/** Log tag naming which strand this gate holds (the strand id). */
|
|
279
|
+
label: string;
|
|
280
|
+
/** The initialized database being held back from the app until the Header is held. */
|
|
281
|
+
database: StrandDatabase;
|
|
282
|
+
/**
|
|
283
|
+
* Called exactly once, on the gate's own probe loop, when the first sync completes.
|
|
284
|
+
* NOT called by {@link StrandFirstSyncGate.open} — a caller that force-opens the gate
|
|
285
|
+
* (a founder bootstrap it just ran) publishes the database itself.
|
|
286
|
+
*/
|
|
287
|
+
onHeaderHeld: () => void;
|
|
288
|
+
/** Timer seam for the probe loop; omit for real (unref'd) timeouts. */
|
|
289
|
+
scheduler?: TimeoutScheduler;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Holds one launch's `StrandDatabase` until {@link strandFirstSyncComplete}. Probes are
|
|
294
|
+
* sequential (the next is scheduled only after the previous read settles), so a slow
|
|
295
|
+
* network read never stacks probes.
|
|
296
|
+
*/
|
|
297
|
+
export class StrandFirstSyncGate {
|
|
298
|
+
private readonly scheduler: TimeoutScheduler;
|
|
299
|
+
private readonly pollIntervalMs: number;
|
|
300
|
+
private timer: unknown;
|
|
301
|
+
private stopped = false;
|
|
302
|
+
private opened = false;
|
|
303
|
+
private probing = false;
|
|
304
|
+
|
|
305
|
+
constructor(private readonly deps: StrandFirstSyncGateDeps, config?: StrandFirstSyncConfig) {
|
|
306
|
+
this.scheduler = deps.scheduler ?? defaultTimeoutScheduler;
|
|
307
|
+
this.pollIntervalMs = config?.pollIntervalMs ?? DEFAULT_STRAND_FIRST_SYNC_POLL_MS;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The database this gate is holding back. */
|
|
311
|
+
get database(): StrandDatabase {
|
|
312
|
+
return this.deps.database;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** True once the Header was seen (or the gate was force-opened); the loop is then done. */
|
|
316
|
+
get isOpen(): boolean {
|
|
317
|
+
return this.opened;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Arm the probe loop. The first probe runs after one interval — the caller already probed once. */
|
|
321
|
+
start(): void {
|
|
322
|
+
if (this.stopped || this.opened || this.timer !== undefined) return;
|
|
323
|
+
this.schedule();
|
|
324
|
+
log('[%s] first-sync gate armed: no Strand.Header held yet; probing every %dms',
|
|
325
|
+
this.deps.label, this.pollIntervalMs);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Open without the probe loop: the caller is publishing the database itself — a launch
|
|
330
|
+
* whose first probe passed (or a founder's), which never armed the loop, or a founder
|
|
331
|
+
* bootstrap just run against the gated database. Stops the loop; `onHeaderHeld` is NOT
|
|
332
|
+
* invoked.
|
|
333
|
+
*/
|
|
334
|
+
open(): void {
|
|
335
|
+
if (this.opened) return;
|
|
336
|
+
this.opened = true;
|
|
337
|
+
this.stop();
|
|
338
|
+
log('[%s] database published by the caller; first-sync gate not needed or force-opened', this.deps.label);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Disarm the loop; a probe already in flight completes and is then ignored. */
|
|
342
|
+
stop(): void {
|
|
343
|
+
this.stopped = true;
|
|
344
|
+
if (this.timer !== undefined) {
|
|
345
|
+
this.scheduler.clearTimeout(this.timer);
|
|
346
|
+
this.timer = undefined;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private schedule(): void {
|
|
351
|
+
this.timer = this.scheduler.setTimeout(() => {
|
|
352
|
+
this.timer = undefined;
|
|
353
|
+
void this.probe();
|
|
354
|
+
}, this.pollIntervalMs);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private async probe(): Promise<void> {
|
|
358
|
+
if (this.stopped || this.opened || this.probing) return;
|
|
359
|
+
this.probing = true;
|
|
360
|
+
const held = await this.probeHeld();
|
|
361
|
+
// Re-check after the await: a release or a force-open may have landed mid-read.
|
|
362
|
+
if (this.stopped || this.opened) return;
|
|
363
|
+
if (!held) {
|
|
364
|
+
this.schedule();
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
this.opened = true;
|
|
368
|
+
this.stop();
|
|
369
|
+
log('[%s] first-sync gate opened: Strand.Header and every App table read from a peer', this.deps.label);
|
|
370
|
+
this.deps.onHeaderHeld();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* One probe's answer, with a throw from OUTSIDE {@link strandFirstSyncComplete}'s own read
|
|
375
|
+
* guards — the `App` schema lookup, or `getDatabase()` itself — reported as "not yet" like
|
|
376
|
+
* any failing read. The loop is scheduled from `void this.probe()`, so an escaping rejection
|
|
377
|
+
* would be unhandled AND leave nothing scheduled: the strand would stay gated for the whole
|
|
378
|
+
* `timeoutMs` and never recover, which is the one failure this gate must not have.
|
|
379
|
+
*/
|
|
380
|
+
private async probeHeld(): Promise<boolean> {
|
|
381
|
+
try {
|
|
382
|
+
return await strandFirstSyncComplete(this.deps.database.getDatabase(), this.deps.label);
|
|
383
|
+
} catch (error) {
|
|
384
|
+
log('[%s] first-sync probe threw (treated as not yet synced): %s', this.deps.label,
|
|
385
|
+
error instanceof Error ? error.message : String(error));
|
|
386
|
+
return false;
|
|
387
|
+
} finally {
|
|
388
|
+
this.probing = false;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|