@serfab/cadre-core 1.17.0 → 1.19.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 +3 -2
- package/dist/cadre-invite-protocol.d.ts +35 -13
- package/dist/cadre-invite-protocol.js +90 -25
- package/dist/cadre-invite-protocol.js.map +1 -1
- package/dist/cadre-node.d.ts +586 -255
- package/dist/cadre-node.js +1182 -566
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-cohort.d.ts +66 -9
- package/dist/control-cohort.js +67 -9
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +451 -193
- package/dist/control-database.js +701 -359
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +46 -43
- package/dist/control-formation-recorder.js +77 -78
- package/dist/control-formation-recorder.js.map +1 -1
- package/dist/control-protocol-guard.d.ts +6 -0
- package/dist/control-protocol-guard.js +14 -2
- package/dist/control-protocol-guard.js.map +1 -1
- package/dist/control-schema-version.d.ts +48 -31
- package/dist/control-schema-version.js +67 -45
- package/dist/control-schema-version.js.map +1 -1
- package/dist/control-schema.d.ts +8 -8
- package/dist/control-schema.js +16 -16
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +42 -7
- package/dist/control-write-retry.js +87 -19
- package/dist/control-write-retry.js.map +1 -1
- package/dist/enrolled-machine-store.d.ts +3 -3
- package/dist/enrolled-machine-store.js +3 -3
- package/dist/formation-token.d.ts +48 -0
- package/dist/formation-token.js +26 -0
- package/dist/formation-token.js.map +1 -0
- package/dist/index.d.ts +15 -14
- package/dist/index.js +17 -14
- package/dist/index.js.map +1 -1
- package/dist/joined-strand-store.d.ts +26 -9
- package/dist/joined-strand-store.js +63 -24
- package/dist/joined-strand-store.js.map +1 -1
- package/dist/link-budget.d.ts +9 -7
- package/dist/link-budget.js +9 -7
- package/dist/link-budget.js.map +1 -1
- package/dist/member-redemption.d.ts +15 -9
- package/dist/member-redemption.js +13 -8
- package/dist/member-redemption.js.map +1 -1
- package/dist/membership-view.d.ts +91 -0
- package/dist/membership-view.js +110 -0
- package/dist/membership-view.js.map +1 -0
- package/dist/owner-grant-protocol.d.ts +11 -7
- package/dist/owner-grant-protocol.js +31 -17
- package/dist/owner-grant-protocol.js.map +1 -1
- package/dist/peer-addr-book.d.ts +10 -1
- package/dist/peer-addr-book.js +4 -3
- package/dist/peer-addr-book.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +96 -30
- package/dist/peer-join-backfill.js +272 -128
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/pending-join-runner.d.ts +48 -18
- package/dist/pending-join-runner.js +74 -56
- package/dist/pending-join-runner.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +47 -40
- package/dist/seed-bootstrap.js +66 -61
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/self-address-update.d.ts +13 -0
- package/dist/self-address-update.js +19 -0
- package/dist/self-address-update.js.map +1 -0
- package/dist/strand-database.d.ts +23 -2
- package/dist/strand-database.js +26 -6
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +176 -89
- package/dist/strand-first-sync-gate.js +288 -122
- package/dist/strand-first-sync-gate.js.map +1 -1
- package/dist/strand-formation-deadlines.d.ts +9 -9
- package/dist/strand-formation-deadlines.js +9 -9
- package/dist/strand-formation-manager.d.ts +23 -17
- package/dist/strand-formation-manager.js +46 -44
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +5 -8
- package/dist/strand-formation-protocol.js +101 -16
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +26 -17
- package/dist/strand-instance-manager.js +119 -50
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-reconciler.d.ts +16 -13
- package/dist/strand-membership-reconciler.js +28 -22
- package/dist/strand-membership-reconciler.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +40 -28
- package/dist/strand-membership-writer.js +212 -153
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-move.js +5 -2
- package/dist/strand-move.js.map +1 -1
- package/dist/strand-revocation-enforcer.d.ts +134 -60
- package/dist/strand-revocation-enforcer.js +144 -71
- package/dist/strand-revocation-enforcer.js.map +1 -1
- package/dist/strand-solicitation.d.ts +50 -39
- package/dist/strand-solicitation.js +10 -3
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-watcher.d.ts +80 -13
- package/dist/strand-watcher.js +152 -48
- package/dist/strand-watcher.js.map +1 -1
- package/dist/table-change-watch.d.ts +63 -0
- package/dist/table-change-watch.js +209 -0
- package/dist/table-change-watch.js.map +1 -0
- package/dist/timeout-scheduler.d.ts +4 -3
- package/dist/timeout-scheduler.js +4 -3
- package/dist/timeout-scheduler.js.map +1 -1
- package/dist/types.d.ts +90 -49
- package/dist/types.js +4 -4
- package/dist/types.js.map +1 -1
- package/package.json +8 -8
- package/src/cadre-invite-protocol.ts +119 -31
- package/src/cadre-node.ts +1289 -588
- package/src/control-cohort.ts +86 -9
- package/src/control-database.ts +848 -403
- package/src/control-formation-recorder.ts +359 -353
- package/src/control-protocol-guard.ts +16 -2
- package/src/control-schema-version.ts +101 -51
- package/src/control-schema.ts +16 -16
- package/src/control-write-retry.ts +91 -19
- package/src/delegate-admission.ts +351 -351
- package/src/enrolled-machine-store.ts +3 -3
- package/src/formation-token.ts +63 -0
- package/src/index.ts +30 -18
- package/src/joined-strand-store.ts +69 -24
- package/src/link-budget.ts +9 -7
- package/src/member-redemption.ts +20 -13
- package/src/membership-view.ts +144 -0
- package/src/owner-grant-protocol.ts +33 -18
- package/src/peer-addr-book.ts +4 -3
- package/src/peer-join-backfill.ts +961 -761
- package/src/pending-join-runner.ts +76 -53
- package/src/seed-bootstrap.ts +72 -67
- package/src/self-address-update.ts +19 -0
- package/src/strand-database.ts +352 -322
- package/src/strand-first-sync-gate.ts +334 -134
- package/src/strand-formation-deadlines.ts +227 -227
- package/src/strand-formation-manager.ts +731 -725
- package/src/strand-formation-protocol.ts +122 -24
- package/src/strand-instance-manager.ts +2092 -2026
- package/src/strand-membership-reconciler.ts +31 -25
- package/src/strand-membership-writer.ts +2093 -2019
- package/src/strand-move.ts +5 -2
- package/src/strand-revocation-enforcer.ts +740 -654
- package/src/strand-solicitation.ts +61 -34
- package/src/strand-watcher.ts +175 -56
- package/src/table-change-watch.ts +285 -0
- package/src/timeout-scheduler.ts +29 -28
- package/src/types.ts +89 -51
|
@@ -11,16 +11,16 @@
|
|
|
11
11
|
* founder from a joiner; sereus can, because the launch knows whether it is founding.
|
|
12
12
|
*
|
|
13
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
|
|
15
|
-
* The founder writes the Header in its bootstrap; every other machine can only receive
|
|
16
|
-
* from a peer, so "no local Header" means "never synced". The gate therefore holds the
|
|
14
|
+
* `Strand.Header` row, and has not read every `Strand` and `App` table once, must not commit
|
|
15
|
+
* to it.** The founder writes the Header in its bootstrap; every other machine can only receive
|
|
16
|
+
* it from a peer, so "no local Header" means "never synced". The gate therefore holds the
|
|
17
17
|
* freshly initialized `StrandDatabase` back from the app — `StrandInstance.database` stays
|
|
18
|
-
* unset and the instance reports `'syncing'` — and probes
|
|
19
|
-
*
|
|
20
|
-
* and the instance goes `'active'`. A
|
|
21
|
-
*
|
|
22
|
-
* the first try and is never gated, so
|
|
23
|
-
* before keep working.
|
|
18
|
+
* unset and the instance reports `'syncing'` — and probes, whenever data lands in the strand's
|
|
19
|
+
* store and on a backstop timer, until the Header is readable AND a read of each other table
|
|
20
|
+
* settles, at which point the database is published and the instance goes `'active'`. A
|
|
21
|
+
* machine whose store already holds the Header and every other table (a founder, and normally
|
|
22
|
+
* a restart or a hibernation resume) passes the probe on the first try and is never gated, so
|
|
23
|
+
* offline-first writes on a machine that has synced before keep working.
|
|
24
24
|
*
|
|
25
25
|
* "Has synced before" is decided by what the local store actually holds, not by whether this
|
|
26
26
|
* machine once read the strand: a machine that left soon after its first sync can hold less
|
|
@@ -29,23 +29,28 @@
|
|
|
29
29
|
* it kept, without the `Strand.Header` collection it had read before it left. Gating it is
|
|
30
30
|
* right: writing into a collection this machine does not hold would fork it.
|
|
31
31
|
*
|
|
32
|
-
* Why the
|
|
33
|
-
* founder's
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
32
|
+
* Why the other tables too: the Header is ONE collection. It reaches a joiner ahead of the
|
|
33
|
+
* founder's other collections (peer-join backfill and pull-on-read deliver those separately),
|
|
34
|
+
* and a write to a table whose collection this machine has not yet fetched invents one exactly
|
|
35
|
+
* as a lone joiner did. Measured on the reference chat schema (2026-09-16, direct connections,
|
|
36
|
+
* Header-only probe): the joiner's participant + message written the instant `addStrand`
|
|
37
|
+
* resolved diverged in 3 of 4 runs. The same holds for the `Strand` tables, which reach a joiner
|
|
38
|
+
* by the same backfill: the membership loop writes `ConsumedInvite`, `Member` and `MemberPeer`
|
|
39
|
+
* about a second after publication, and a manager writes `Invite`, `CancelledInvite`, `Manager`
|
|
40
|
+
* and `Revocation`. Reading each table once pulls its collection while the founder is
|
|
41
|
+
* reachable, so the write that follows appends.
|
|
39
42
|
*
|
|
40
|
-
* Every probe is
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
43
|
+
* Every probe read is `select 1 from <table> limit 1` (`Strand.Header`, then each other `Strand`
|
|
44
|
+
* table, then each `App` table, skipping those already seen settled): opening the table fetches
|
|
45
|
+
* its collection header, and one row walks the root to the first leaf. Reads never commit a
|
|
46
|
+
* collection (one ruled authoritatively absent is staged empty in memory only, and an
|
|
47
|
+
* unreachable one throws), so probing is safe to repeat and a throw is just "not yet". Every
|
|
48
|
+
* table declared at founding has a collection from the start (the founder bootstrap commits the
|
|
49
|
+
* rest empty before its founding transaction writes the Header and the membership rows), so a
|
|
50
|
+
* probe read fetches it; a table a later schema version adds reads as absent until its first
|
|
51
|
+
* write creates the collection — see `docs/strands.md` ("Joining") for that residual.
|
|
47
52
|
*
|
|
48
|
-
* Owned by `StrandInstanceManager`: created in `
|
|
53
|
+
* Owned by `StrandInstanceManager`: created in `buildStrandDatabase` for every database build, armed
|
|
49
54
|
* only for a non-founder launch whose first probe fails, stopped and dropped in
|
|
50
55
|
* `releaseRuntime` (so a quiesce → resume rebuild re-probes over the same store), and opened
|
|
51
56
|
* by `publishDatabase` whenever the database is published without its loop — a launch that
|
|
@@ -67,12 +72,25 @@
|
|
|
67
72
|
import debug from 'debug';
|
|
68
73
|
import type { Database } from '@quereus/quereus';
|
|
69
74
|
import type { StrandDatabase } from './strand-database.js';
|
|
75
|
+
import { scheduleRuns, TABLE_CHANGE_COALESCE_MS, type TableChangeWatch } from './table-change-watch.js';
|
|
70
76
|
import { defaultTimeoutScheduler, type TimeoutScheduler } from './timeout-scheduler.js';
|
|
71
77
|
|
|
72
78
|
const log = debug('sereus:cadre:strand-first-sync');
|
|
73
79
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
80
|
+
/**
|
|
81
|
+
* How long a gated launch goes without probing when no data lands in the strand's store: the
|
|
82
|
+
* gate probes when data arrives, and this long after its last probe settled when none has. It
|
|
83
|
+
* covers a probe that failed for a reason no storage event follows — a consult that missed its
|
|
84
|
+
* deadline while the founder held the block, or a push that never comes.
|
|
85
|
+
*
|
|
86
|
+
* Why 2 s: arrivals wake the gate, so this timer is the fallback rather than the main path. At
|
|
87
|
+
* the supported 3 s link round trip one consult takes about 6 s (two round trips, per the 1.8.0
|
|
88
|
+
* measurements on {@link DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS}), so a 2 s idle gap after a failed
|
|
89
|
+
* probe adds at most a third of one probe round, and it is under 1% of that 360 s budget. Shorter
|
|
90
|
+
* brings back the cost of polling (the gate re-probed every 500 ms before it woke on arrivals);
|
|
91
|
+
* much longer (5 s) delays the case with no storage event by most of a probe round for nothing.
|
|
92
|
+
*/
|
|
93
|
+
export const DEFAULT_STRAND_FIRST_SYNC_BACKSTOP_MS = 2_000;
|
|
76
94
|
|
|
77
95
|
/**
|
|
78
96
|
* How long `CadreNode.addStrand` waits for a machine's first sync before rejecting with
|
|
@@ -104,6 +122,37 @@ export const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
|
|
|
104
122
|
* several of those — which is why both bands are recorded here, and why a change to either
|
|
105
123
|
* number has to be weighed against the other.
|
|
106
124
|
*
|
|
125
|
+
* AT THE SUPPORTED LINK ON `@optimystic/*` 1.17.0 (2026-10-21), 1 500 ms one-way, the same
|
|
126
|
+
* scenario, one run per arm, with the probe reading every `Strand` table as well as the `App`
|
|
127
|
+
* one (`debt-first-sync-gate-reads-every-strand-table`); the kept-store arm was not run:
|
|
128
|
+
* - Fresh join: writable at 28.7 s and NOT gated, as on 1.15.0.
|
|
129
|
+
* - Re-attach over an EMPTY store: writable at 105.8 and 105.9 s over two runs, the row A wrote
|
|
130
|
+
* while B was away readable at the same moment. B's first database build fails at 7.0 s (no
|
|
131
|
+
* seed peer has answered; `StrandInstanceManager.deferUnreachableJoin` retries it), B's strand
|
|
132
|
+
* node connects to A's at 15.1 s, the retried build's `connectToStrand` takes 21.2 s, and the
|
|
133
|
+
* launch's first probe then takes 72.6 s and passes with no read failing, so the gate never
|
|
134
|
+
* arms.
|
|
135
|
+
* - The same arm with the probe held to the Header and the `App` table (one run, otherwise the
|
|
136
|
+
* same build): that probe took 12.1 s, writable at 45.4 s, the row at 63.5 s. So the seven
|
|
137
|
+
* `Strand` reads cost about 60 s here, about 8.6 s each, against about 6 s for each of the
|
|
138
|
+
* baseline's two reads — more than the 42 s estimated from one 6 s consult per read.
|
|
139
|
+
*
|
|
140
|
+
* AT THE SUPPORTED LINK ON `@optimystic/*` 1.15.0 (2026-10-20), 1 500 ms one-way, the same
|
|
141
|
+
* scenario, one fresh-join run, with the one-row probes woken by data landing in the store
|
|
142
|
+
* (`first-sync-gate-probes-cheaply`; the full-count probes polled every 500 ms gave the same
|
|
143
|
+
* figures):
|
|
144
|
+
* - Fresh join: writable at 28.8 s, and NOT gated. The launch waits 27.3 s in
|
|
145
|
+
* `connectToStrand` for its seed peer (it hydrates the founder's catalog, 9 tables), and by
|
|
146
|
+
* then the store holds the strand: the first probe passed in 24 ms.
|
|
147
|
+
* - The strand case of `message-cost-accounting` (two relay-only parties on loopback) agrees:
|
|
148
|
+
* B's `addStrand` took 471 ms, with 13 `sync` streams to A, 4 block reads from the Header
|
|
149
|
+
* probe and 8 from the table probes. Its two tables are empty, so a full count would fetch
|
|
150
|
+
* the same blocks as a one-row read.
|
|
151
|
+
* - Neither arms the gate, so neither times the woken path. That was checked on
|
|
152
|
+
* `harness-topology`'s cross-party share (a joiner launched with no seed, dialed afterwards)
|
|
153
|
+
* with the backstop raised to 10 s: the founder's join backfill push opened the gate 2.1 s
|
|
154
|
+
* after it armed.
|
|
155
|
+
*
|
|
107
156
|
* AT THE SUPPORTED LINK ON `@optimystic/*` 1.8.0 (2026-09-29), 1 500 ms one-way, the read
|
|
108
157
|
* deadline derived from the declared link at 7 000 ms (`cohortReadDeadlineMs` in
|
|
109
158
|
* `link-budget.ts`; the plugin's `COHORT_READ_DEADLINE_MS` is the same number), and cadre
|
|
@@ -160,19 +209,19 @@ export const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
|
|
|
160
209
|
* injector are unknown to us) opened the gate at about 150 s, after the previous 120 s budget
|
|
161
210
|
* had already rejected; and a real Galaxy S7 joining fresh through relay.sereus.org took 178 s.
|
|
162
211
|
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
* about
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
* it clears the device join by only 1.35x,
|
|
170
|
-
* original 30 s sat INSIDE the 1000 ms fresh
|
|
171
|
-
*
|
|
172
|
-
* reporter's re-attach.
|
|
212
|
+
* 360 s clears the worst harness sample (105.9 s, a re-attach over an empty store at the
|
|
213
|
+
* supported link on 1.17.0) by about 3.4x, the reporter's re-attach by 2.4x and the device join
|
|
214
|
+
* by about 2x. Set by the rule every re-measure applies: keep it while the worst harness sample
|
|
215
|
+
* is at most 100 s, which holds a margin of at least 3x; otherwise raise it to 3x the worst
|
|
216
|
+
* sample, rounded up to the next whole minute. The 1.17.0 sample crossed 100 s, and 3x 105.9 s
|
|
217
|
+
* is 318 s, so 360 s (2026-10-21); the 1.7.0 and 1.8.0 re-measures (worst 82 s and 64 s) had
|
|
218
|
+
* kept the 300 s before it. 240 s was rejected earlier: it clears the device join by only 1.35x,
|
|
219
|
+
* and that sample was not a re-attach. History: the original 30 s sat INSIDE the 1000 ms fresh
|
|
220
|
+
* band and refused about half of those joins while their sync was progressing normally; 120 s
|
|
221
|
+
* was sized from fresh joins only and refused the reporter's re-attach.
|
|
173
222
|
*
|
|
174
|
-
* NOTE: accepted tradeoff — what
|
|
175
|
-
* before it is told "not yet", so a strand none of whose members is reachable at all takes
|
|
223
|
+
* NOTE: accepted tradeoff — what 360 s costs. This wait is what an app's `addStrand` sits in
|
|
224
|
+
* before it is told "not yet", so a strand none of whose members is reachable at all takes six
|
|
176
225
|
* minutes to report instead of two. The slow report was weighed against refusing attaches that
|
|
177
226
|
* were working, and the refusal is the worse failure. The cost is bounded — the rejection is
|
|
178
227
|
* retryable, the strand stays launched and keeps probing, and `strand:writable` fires the
|
|
@@ -182,7 +231,7 @@ export const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
|
|
|
182
231
|
* sync that is actually in progress — worth more the longer this budget gets.
|
|
183
232
|
*/
|
|
184
233
|
// 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
|
|
185
|
-
export const DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS =
|
|
234
|
+
export const DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS = 360_000;
|
|
186
235
|
|
|
187
236
|
/** Embedder-facing tuning for the gate, threaded from `CadreNodeConfig.strandFirstSync`. */
|
|
188
237
|
export interface StrandFirstSyncConfig {
|
|
@@ -192,8 +241,11 @@ export interface StrandFirstSyncConfig {
|
|
|
192
241
|
* {@link DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS}.
|
|
193
242
|
*/
|
|
194
243
|
timeoutMs?: number;
|
|
195
|
-
/**
|
|
196
|
-
|
|
244
|
+
/**
|
|
245
|
+
* While gated, probe this long after the last probe settled when no data has landed in the
|
|
246
|
+
* strand's store. Default {@link DEFAULT_STRAND_FIRST_SYNC_BACKSTOP_MS}.
|
|
247
|
+
*/
|
|
248
|
+
backstopMs?: number;
|
|
197
249
|
}
|
|
198
250
|
|
|
199
251
|
/**
|
|
@@ -223,15 +275,43 @@ export class StrandAwaitingFirstSyncError extends Error {
|
|
|
223
275
|
}
|
|
224
276
|
|
|
225
277
|
/**
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
278
|
+
* What one gate has established, kept for the gate's lifetime so a later probe reads only what
|
|
279
|
+
* is still missing: whether the Header is held, and the tables (qualified, `'App.Message'`)
|
|
280
|
+
* whose probe read settled. It only ever gains entries; a collection this machine holds stays
|
|
281
|
+
* held.
|
|
230
282
|
*/
|
|
231
|
-
|
|
283
|
+
interface FirstSyncProgress {
|
|
284
|
+
header: boolean;
|
|
285
|
+
tables: Set<string>;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** A table the probe reads. */
|
|
289
|
+
interface ProbedTable {
|
|
290
|
+
schema: string;
|
|
291
|
+
name: string;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const STRAND_HEADER: ProbedTable = { schema: 'Strand', name: 'Header' };
|
|
295
|
+
|
|
296
|
+
function qualifiedName(table: ProbedTable): string {
|
|
297
|
+
return `${table.schema}.${table.name}`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The read `openTables` in `quereus-plugin-sereus/src/empty-collections.ts` opens a collection with. */
|
|
301
|
+
function probeSql(table: ProbedTable): string {
|
|
302
|
+
return `select 1 from ${table.schema}."${table.name}" limit 1`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Whether this machine holds the strand's `Strand.Header` row — the "has synced at least once
|
|
307
|
+
* (or founded)" signal: held when the read returns a row. A throwing read (an unreachable cohort
|
|
308
|
+
* mid-sync, a store still hydrating) reports `false` with a log, since the caller's only
|
|
309
|
+
* response to either answer is "probe again".
|
|
310
|
+
*/
|
|
311
|
+
async function headerHeld(db: Database, label: string): Promise<boolean> {
|
|
232
312
|
try {
|
|
233
|
-
for await (const
|
|
234
|
-
return
|
|
313
|
+
for await (const _row of db.eval(probeSql(STRAND_HEADER))) {
|
|
314
|
+
return true;
|
|
235
315
|
}
|
|
236
316
|
return false;
|
|
237
317
|
} catch (error) {
|
|
@@ -241,78 +321,156 @@ export async function strandHeaderHeld(db: Database, label: string): Promise<boo
|
|
|
241
321
|
}
|
|
242
322
|
}
|
|
243
323
|
|
|
244
|
-
/** The tables the sApp declared in the `App` schema, by name; views excluded. */
|
|
245
|
-
function appTableNames(db: Database): string[] {
|
|
246
|
-
const app = db.schemaManager.getSchema('App');
|
|
247
|
-
return app ? Array.from(app.getAllTables()).filter((table) => !table.isView).map((table) => table.name) : [];
|
|
248
|
-
}
|
|
249
|
-
|
|
250
324
|
/**
|
|
251
|
-
* Whether a read of
|
|
252
|
-
*
|
|
253
|
-
*
|
|
325
|
+
* Whether a read of `table` settles on this machine — its collection has been fetched from the
|
|
326
|
+
* cohort, or is authoritatively absent. Only a throwing read (an unreachable cohort, a block not
|
|
327
|
+
* yet arrived) reports `false`; an empty table is a settled read, because the founder commits
|
|
328
|
+
* an empty collection for every declared table.
|
|
254
329
|
*/
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
break;
|
|
260
|
-
}
|
|
261
|
-
} catch (error) {
|
|
262
|
-
log('[%s] App.%s probe failed (treated as not yet synced): %s', label, table,
|
|
263
|
-
error instanceof Error ? error.message : String(error));
|
|
264
|
-
return false;
|
|
330
|
+
async function tableReadable(db: Database, table: ProbedTable, label: string): Promise<boolean> {
|
|
331
|
+
try {
|
|
332
|
+
for await (const _row of db.eval(probeSql(table))) {
|
|
333
|
+
break;
|
|
265
334
|
}
|
|
335
|
+
return true;
|
|
336
|
+
} catch (error) {
|
|
337
|
+
log('[%s] %s probe failed (treated as not yet synced): %s', label, qualifiedName(table),
|
|
338
|
+
error instanceof Error ? error.message : String(error));
|
|
339
|
+
return false;
|
|
266
340
|
}
|
|
267
|
-
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** The tables `schemaName` declares, views excluded; none when the schema is absent. */
|
|
344
|
+
function schemaTables(db: Database, schemaName: string): ProbedTable[] {
|
|
345
|
+
const schema = db.schemaManager.getSchema(schemaName);
|
|
346
|
+
return schema
|
|
347
|
+
? Array.from(schema.getAllTables()).filter((table) => !table.isView).map((table) => ({ schema: schemaName, name: table.name }))
|
|
348
|
+
: [];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Every table this machine may write, in probe order: the membership tables in `Strand` (a fixed
|
|
353
|
+
* set every strand has) and then the sApp's in `App`. The Header is left out, compared
|
|
354
|
+
* case-insensitively as SQL identifiers are, because it is read first and alone, and its read
|
|
355
|
+
* means something else (held = a row came back).
|
|
356
|
+
*/
|
|
357
|
+
function writableTables(db: Database): ProbedTable[] {
|
|
358
|
+
const header = STRAND_HEADER.name.toLowerCase();
|
|
359
|
+
return [
|
|
360
|
+
...schemaTables(db, STRAND_HEADER.schema).filter((table) => table.name.toLowerCase() !== header),
|
|
361
|
+
...schemaTables(db, 'App')
|
|
362
|
+
];
|
|
268
363
|
}
|
|
269
364
|
|
|
270
365
|
/**
|
|
271
|
-
* The gate's whole probe: the Header is held AND
|
|
272
|
-
*
|
|
273
|
-
*
|
|
366
|
+
* The gate's whole probe: the Header is held AND a read of every other `Strand` table and every
|
|
367
|
+
* `App` table has settled. Reads only what `progress` does not record yet, and records what it
|
|
368
|
+
* establishes.
|
|
369
|
+
*
|
|
370
|
+
* The Header is read first and alone: a joiner with no peer would otherwise pay one failing
|
|
371
|
+
* network read per table for nothing. The tables are then read one after another, stopping at
|
|
372
|
+
* the first that fails — the next probe starts there, and while the cohort is unreachable every
|
|
373
|
+
* read of a block this machine lacks fails the same way, each after a consult.
|
|
374
|
+
*
|
|
375
|
+
* NOTE: the reads are serialized (Quereus's default), not `readConcurrency: 'committed'`, though
|
|
376
|
+
* committed reads would let the table reads overlap. The Optimystic plugin's committed read
|
|
377
|
+
* serves the tree the table already holds and never refreshes it from the network, and schema
|
|
378
|
+
* apply initializes every table when the joiner has no catalog to hydrate, over an empty
|
|
379
|
+
* collection staged in memory when no peer could answer. A committed probe of such a table reads
|
|
380
|
+
* that empty collection as settled and opens the gate onto a fork. A serialized read refreshes
|
|
381
|
+
* the tree first (`Collection.update`), which adopts the founder's log once a peer holds it.
|
|
382
|
+
* Revisit if the plugin's committed read ever refreshes a tree it has never synced, or the gate
|
|
383
|
+
* can ask Optimystic directly whether a collection is held.
|
|
274
384
|
*/
|
|
275
|
-
|
|
276
|
-
|
|
385
|
+
async function strandFirstSyncComplete(db: Database, label: string, progress: FirstSyncProgress): Promise<boolean> {
|
|
386
|
+
if (!progress.header) {
|
|
387
|
+
if (!await headerHeld(db, label)) {
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
progress.header = true;
|
|
391
|
+
}
|
|
392
|
+
for (const table of writableTables(db)) {
|
|
393
|
+
const name = qualifiedName(table);
|
|
394
|
+
if (progress.tables.has(name)) {
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (!await tableReadable(db, table, label)) {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
progress.tables.add(name);
|
|
401
|
+
}
|
|
402
|
+
return true;
|
|
277
403
|
}
|
|
278
404
|
|
|
279
405
|
export interface StrandFirstSyncGateDeps {
|
|
280
406
|
/** Log tag naming which strand this gate holds (the strand id). */
|
|
281
407
|
label: string;
|
|
282
|
-
/** The initialized database being held back from the app until the
|
|
408
|
+
/** The initialized database being held back from the app until the first sync completes. */
|
|
283
409
|
database: StrandDatabase;
|
|
284
410
|
/**
|
|
285
|
-
* Called exactly once,
|
|
286
|
-
*
|
|
287
|
-
*
|
|
411
|
+
* Called exactly once, from the gate's own probe, when the first sync completes. NOT called
|
|
412
|
+
* by {@link StrandFirstSyncGate.open} — a caller that force-opens the gate (a founder
|
|
413
|
+
* bootstrap it just ran) publishes the database itself.
|
|
288
414
|
*/
|
|
289
415
|
onHeaderHeld: () => void;
|
|
290
|
-
/**
|
|
416
|
+
/**
|
|
417
|
+
* Subscribe `onChange` to every block landing in this strand's store — a push such as join
|
|
418
|
+
* backfill, a fetched block, a commit — and return the unsubscribe. `buildStrandDatabase`
|
|
419
|
+
* passes the strand node's `storageRepo.onAnyCollectionChange`. Omitted (test doubles, a node
|
|
420
|
+
* without it), the gate probes on its backstop alone.
|
|
421
|
+
*/
|
|
422
|
+
subscribeStorageChanges?: (onChange: () => void) => () => void;
|
|
423
|
+
/** Timer seam for the probe schedule; omit for real (unref'd) timeouts. */
|
|
291
424
|
scheduler?: TimeoutScheduler;
|
|
292
425
|
}
|
|
293
426
|
|
|
294
427
|
/**
|
|
295
|
-
* Holds one launch's `StrandDatabase` until
|
|
296
|
-
*
|
|
297
|
-
*
|
|
428
|
+
* Holds one launch's `StrandDatabase` until the first sync completes: the `Strand.Header` row is
|
|
429
|
+
* held and a read of every `Strand` and `App` table has settled. What a probe established is
|
|
430
|
+
* remembered for the gate's lifetime, so a later probe — the gate's own, after the caller's
|
|
431
|
+
* {@link probeOnce} — reads only what is still missing. A gate lives for one database build, so a
|
|
432
|
+
* hibernation wake or a deferred-join retry starts over, and over a store that holds everything
|
|
433
|
+
* every read answers locally.
|
|
434
|
+
*
|
|
435
|
+
* While armed it probes when data lands in the strand's store
|
|
436
|
+
* ({@link StrandFirstSyncGateDeps.subscribeStorageChanges}, coalesced over
|
|
437
|
+
* `TABLE_CHANGE_COALESCE_MS`), and {@link StrandFirstSyncConfig.backstopMs} after its last probe
|
|
438
|
+
* settled when nothing has. Probes run on `scheduleRuns`: one at a time, and data landing during
|
|
439
|
+
* one produces exactly one more.
|
|
440
|
+
*
|
|
441
|
+
* The wake source is the node's storage-wide change feed rather than a table watch
|
|
442
|
+
* (`StrandDatabase.watchTables`): the Optimystic plugin arms a table's replicated-change bridge
|
|
443
|
+
* only after the table initializes successfully, and the tables the gate waits on are the ones
|
|
444
|
+
* whose collection could not be fetched yet, so a table watch would not hear the push that
|
|
445
|
+
* delivers them. Each strand has its own libp2p node and store, so the feed hears this strand
|
|
446
|
+
* only.
|
|
298
447
|
*
|
|
299
448
|
* NOTE: a probe's reads dial the founder, through the relay when that is the only route, even
|
|
300
449
|
* while the founder is unreachable: one run of `joiner-first-commit-over-slow-relay` logged 17
|
|
301
450
|
* `hop connect denied` at the relay in about 10 s, each a few frames on the joiner's relay
|
|
302
|
-
* connection
|
|
303
|
-
*
|
|
451
|
+
* connection, when the gate re-probed every 500 ms and re-read every table. It now probes on
|
|
452
|
+
* arrivals and a 2 s backstop and skips what it holds, so expect fewer. If that connection ever
|
|
453
|
+
* shows saturated while no strand member is connected, hold the probe while the strand member
|
|
454
|
+
* redial (`strand-peer-redial.ts`) reports none connected.
|
|
304
455
|
*/
|
|
305
456
|
export class StrandFirstSyncGate {
|
|
306
457
|
private readonly scheduler: TimeoutScheduler;
|
|
307
|
-
private readonly
|
|
308
|
-
private
|
|
458
|
+
private readonly backstopMs: number;
|
|
459
|
+
private readonly progress: FirstSyncProgress = { header: false, tables: new Set() };
|
|
460
|
+
private probes: TableChangeWatch | undefined;
|
|
461
|
+
private unsubscribeStorage: (() => void) | undefined;
|
|
462
|
+
private listening = false;
|
|
463
|
+
/**
|
|
464
|
+
* Data landed since the last probe started (or, before any probe, since {@link listen}), so one
|
|
465
|
+
* is already due and will read it.
|
|
466
|
+
*/
|
|
467
|
+
private landedSinceProbe = false;
|
|
309
468
|
private stopped = false;
|
|
310
469
|
private opened = false;
|
|
311
|
-
private probing = false;
|
|
312
470
|
|
|
313
471
|
constructor(private readonly deps: StrandFirstSyncGateDeps, config?: StrandFirstSyncConfig) {
|
|
314
472
|
this.scheduler = deps.scheduler ?? defaultTimeoutScheduler;
|
|
315
|
-
this.
|
|
473
|
+
this.backstopMs = config?.backstopMs ?? DEFAULT_STRAND_FIRST_SYNC_BACKSTOP_MS;
|
|
316
474
|
}
|
|
317
475
|
|
|
318
476
|
/** The database this gate is holding back. */
|
|
@@ -320,24 +478,53 @@ export class StrandFirstSyncGate {
|
|
|
320
478
|
return this.deps.database;
|
|
321
479
|
}
|
|
322
480
|
|
|
323
|
-
/** True once the
|
|
481
|
+
/** True once the first sync completed (or the gate was force-opened); probing is then done. */
|
|
324
482
|
get isOpen(): boolean {
|
|
325
483
|
return this.opened;
|
|
326
484
|
}
|
|
327
485
|
|
|
328
|
-
/**
|
|
486
|
+
/** Whether a block landed since the last probe started, or since {@link listen} when none has. */
|
|
487
|
+
get heardDataSinceProbe(): boolean {
|
|
488
|
+
return this.landedSinceProbe;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Subscribe to the storage change feed ahead of {@link start}, so a block landing before the
|
|
493
|
+
* gate is armed is heard: {@link heardDataSinceProbe} reports it, and `start` then probes at
|
|
494
|
+
* once. For a first join, which skips the caller's first probe unless a member's push already
|
|
495
|
+
* landed while the database was initializing.
|
|
496
|
+
*/
|
|
497
|
+
listen(): void {
|
|
498
|
+
if (this.stopped || this.opened || this.listening) return;
|
|
499
|
+
this.listening = true;
|
|
500
|
+
this.unsubscribeStorage = this.subscribeStorage();
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Arm the probe schedule. It probes when data lands, or one backstop later; and at once when
|
|
505
|
+
* data already landed since the last probe (or since {@link listen}). Without `listen`, data
|
|
506
|
+
* that landed between the caller's probe and this call raised no event the gate heard, so
|
|
507
|
+
* that first backstop is what catches it.
|
|
508
|
+
*/
|
|
329
509
|
start(): void {
|
|
330
|
-
if (this.stopped || this.opened || this.
|
|
331
|
-
this.
|
|
332
|
-
|
|
333
|
-
this.
|
|
510
|
+
if (this.stopped || this.opened || this.probes !== undefined) return;
|
|
511
|
+
this.probes = scheduleRuns(() => this.probe(), {
|
|
512
|
+
label: `first-sync:${this.deps.label}`,
|
|
513
|
+
backstopMs: this.backstopMs,
|
|
514
|
+
scheduler: this.scheduler,
|
|
515
|
+
});
|
|
516
|
+
this.listen();
|
|
517
|
+
if (this.landedSinceProbe) {
|
|
518
|
+
this.probes.trigger(TABLE_CHANGE_COALESCE_MS);
|
|
519
|
+
}
|
|
520
|
+
log('[%s] first-sync gate armed: probing when data lands, and %dms after the last probe otherwise',
|
|
521
|
+
this.deps.label, this.backstopMs);
|
|
334
522
|
}
|
|
335
523
|
|
|
336
524
|
/**
|
|
337
|
-
* Open without
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
* invoked.
|
|
525
|
+
* Open without probing: the caller is publishing the database itself — a launch whose first
|
|
526
|
+
* probe passed (or a founder's), which never armed the schedule, or a founder bootstrap just
|
|
527
|
+
* run against the gated database. Stops probing; `onHeaderHeld` is NOT invoked.
|
|
341
528
|
*/
|
|
342
529
|
open(): void {
|
|
343
530
|
if (this.opened) return;
|
|
@@ -346,54 +533,67 @@ export class StrandFirstSyncGate {
|
|
|
346
533
|
log('[%s] database published by the caller; first-sync gate not needed or force-opened', this.deps.label);
|
|
347
534
|
}
|
|
348
535
|
|
|
349
|
-
/** Disarm the
|
|
536
|
+
/** Disarm: unsubscribe and drop the schedule; a probe already in flight completes and is then ignored. */
|
|
350
537
|
stop(): void {
|
|
351
538
|
this.stopped = true;
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
private schedule(): void {
|
|
359
|
-
this.timer = this.scheduler.setTimeout(() => {
|
|
360
|
-
this.timer = undefined;
|
|
361
|
-
void this.probe();
|
|
362
|
-
}, this.pollIntervalMs);
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
private async probe(): Promise<void> {
|
|
366
|
-
if (this.stopped || this.opened || this.probing) return;
|
|
367
|
-
this.probing = true;
|
|
368
|
-
const held = await this.probeHeld();
|
|
369
|
-
// Re-check after the await: a release or a force-open may have landed mid-read.
|
|
370
|
-
if (this.stopped || this.opened) return;
|
|
371
|
-
if (!held) {
|
|
372
|
-
this.schedule();
|
|
373
|
-
return;
|
|
374
|
-
}
|
|
375
|
-
this.opened = true;
|
|
376
|
-
this.stop();
|
|
377
|
-
log('[%s] first-sync gate opened: Strand.Header and every App table read from a peer', this.deps.label);
|
|
378
|
-
this.deps.onHeaderHeld();
|
|
539
|
+
this.unsubscribeStorage?.();
|
|
540
|
+
this.unsubscribeStorage = undefined;
|
|
541
|
+
this.probes?.dispose();
|
|
542
|
+
this.probes = undefined;
|
|
379
543
|
}
|
|
380
544
|
|
|
381
545
|
/**
|
|
382
|
-
* One probe's answer,
|
|
383
|
-
* guards —
|
|
384
|
-
* any failing read
|
|
385
|
-
*
|
|
386
|
-
* `timeoutMs` and never recover, which is the one failure this gate must not have.
|
|
546
|
+
* One probe's answer, reading only what this gate has not yet seen settled. A throw from
|
|
547
|
+
* OUTSIDE the probe's own read guards — a schema lookup, or `getDatabase()` itself — is
|
|
548
|
+
* reported as "not yet" like any failing read, so the caller's first probe cannot fail the
|
|
549
|
+
* launch over it and the gate's log says what happened.
|
|
387
550
|
*/
|
|
388
|
-
|
|
551
|
+
async probeOnce(): Promise<boolean> {
|
|
552
|
+
this.landedSinceProbe = false;
|
|
389
553
|
try {
|
|
390
|
-
return await strandFirstSyncComplete(this.deps.database.getDatabase(), this.deps.label);
|
|
554
|
+
return await strandFirstSyncComplete(this.deps.database.getDatabase(), this.deps.label, this.progress);
|
|
391
555
|
} catch (error) {
|
|
392
556
|
log('[%s] first-sync probe threw (treated as not yet synced): %s', this.deps.label,
|
|
393
557
|
error instanceof Error ? error.message : String(error));
|
|
394
558
|
return false;
|
|
395
|
-
} finally {
|
|
396
|
-
this.probing = false;
|
|
397
559
|
}
|
|
398
560
|
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* NOTE: a probe's own reads can land blocks (a fetched block, a read-repair), which fires this
|
|
564
|
+
* feed and schedules one follow-up probe. Each follow-up means a block newly arrived, so they
|
|
565
|
+
* stop when nothing new lands; if gated launches ever show probe storms in the logs, ignore
|
|
566
|
+
* events while a probe is in flight.
|
|
567
|
+
*/
|
|
568
|
+
private subscribeStorage(): (() => void) | undefined {
|
|
569
|
+
const subscribe = this.deps.subscribeStorageChanges;
|
|
570
|
+
if (!subscribe) {
|
|
571
|
+
log('[%s] no storage change feed on this node; probing every %dms only', this.deps.label, this.backstopMs);
|
|
572
|
+
return undefined;
|
|
573
|
+
}
|
|
574
|
+
return subscribe(() => this.onDataLanded());
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Called synchronously by the store after each block lands, so it only arms a timer. A push
|
|
579
|
+
* lands its blocks one by one; only the first since the last probe started arms one, since
|
|
580
|
+
* that probe has not started yet and will read the rest.
|
|
581
|
+
*/
|
|
582
|
+
private onDataLanded(): void {
|
|
583
|
+
if (this.landedSinceProbe) return;
|
|
584
|
+
this.landedSinceProbe = true;
|
|
585
|
+
this.probes?.trigger(TABLE_CHANGE_COALESCE_MS);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
private async probe(): Promise<void> {
|
|
589
|
+
if (this.stopped || this.opened) return;
|
|
590
|
+
log('[%s] first-sync probe (%s)', this.deps.label, this.landedSinceProbe ? 'data landed' : 'backstop');
|
|
591
|
+
const held = await this.probeOnce();
|
|
592
|
+
// Re-check after the await: a release or a force-open may have landed mid-read.
|
|
593
|
+
if (this.stopped || this.opened || !held) return;
|
|
594
|
+
this.opened = true;
|
|
595
|
+
this.stop();
|
|
596
|
+
log('[%s] first-sync gate opened: Strand.Header, every Strand table and every App table read from a peer', this.deps.label);
|
|
597
|
+
this.deps.onHeaderHeld();
|
|
598
|
+
}
|
|
399
599
|
}
|