@serfab/cadre-core 1.16.0 → 1.18.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 +4 -3
- package/dist/bootstrap-peer-store-file.js +1 -1
- package/dist/bootstrap-peer-store.d.ts +1 -1
- package/dist/bootstrap-peer-store.js +4 -4
- package/dist/cadre-invite-protocol.d.ts +81 -142
- package/dist/cadre-invite-protocol.js +168 -458
- package/dist/cadre-invite-protocol.js.map +1 -1
- package/dist/cadre-join-request.d.ts +15 -2
- package/dist/cadre-join-request.js +22 -3
- package/dist/cadre-join-request.js.map +1 -1
- package/dist/cadre-node.d.ts +1089 -475
- package/dist/cadre-node.js +2170 -1037
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-authorization.d.ts +36 -14
- package/dist/control-authorization.js +20 -3
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.d.ts +88 -32
- package/dist/control-cohort.js +91 -33
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +856 -318
- package/dist/control-database.js +1422 -701
- 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 +11 -4
- package/dist/control-protocol-guard.js +20 -5
- package/dist/control-protocol-guard.js.map +1 -1
- package/dist/control-schema-version.d.ts +97 -0
- package/dist/control-schema-version.js +225 -0
- package/dist/control-schema-version.js.map +1 -0
- package/dist/control-schema.d.ts +45 -5
- package/dist/control-schema.js +262 -78
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +45 -10
- package/dist/control-write-retry.js +92 -22
- package/dist/control-write-retry.js.map +1 -1
- package/dist/delegate-admission.d.ts +13 -0
- package/dist/delegate-admission.js +27 -0
- package/dist/delegate-admission.js.map +1 -1
- package/dist/ed25519-key.d.ts +10 -7
- package/dist/ed25519-key.js +23 -20
- package/dist/ed25519-key.js.map +1 -1
- package/dist/edge-node/config.d.ts +122 -0
- package/dist/edge-node/config.js +199 -0
- package/dist/edge-node/config.js.map +1 -0
- package/dist/edge-node/dissolve.d.ts +95 -0
- package/dist/edge-node/dissolve.js +184 -0
- package/dist/edge-node/dissolve.js.map +1 -0
- package/dist/edge-node/edge-join.d.ts +44 -0
- package/dist/edge-node/edge-join.js +227 -0
- package/dist/edge-node/edge-join.js.map +1 -0
- package/dist/edge-node/edge-node.d.ts +295 -0
- package/dist/edge-node/edge-node.js +704 -0
- package/dist/edge-node/edge-node.js.map +1 -0
- package/dist/edge-node/index.d.ts +18 -0
- package/dist/edge-node/index.js +18 -0
- package/dist/edge-node/index.js.map +1 -0
- package/dist/edge-node/join-credential.d.ts +11 -0
- package/dist/edge-node/join-credential.js +11 -0
- package/dist/edge-node/join-credential.js.map +1 -0
- package/dist/edge-node/lazy-raw-storage.d.ts +40 -0
- package/dist/edge-node/lazy-raw-storage.js +134 -0
- package/dist/edge-node/lazy-raw-storage.js.map +1 -0
- package/dist/edge-node/node-local.d.ts +60 -0
- package/dist/edge-node/node-local.js +58 -0
- package/dist/edge-node/node-local.js.map +1 -0
- package/dist/edge-node/options.d.ts +83 -0
- package/dist/edge-node/options.js +110 -0
- package/dist/edge-node/options.js.map +1 -0
- package/dist/edge-node/owner-grant.d.ts +41 -0
- package/dist/edge-node/owner-grant.js +38 -0
- package/dist/edge-node/owner-grant.js.map +1 -0
- package/dist/edge-node/platform.d.ts +140 -0
- package/dist/edge-node/platform.js +12 -0
- package/dist/edge-node/platform.js.map +1 -0
- package/dist/edge-node/strand-move.d.ts +115 -0
- package/dist/edge-node/strand-move.js +223 -0
- package/dist/edge-node/strand-move.js.map +1 -0
- package/dist/edge-node/strands.d.ts +28 -0
- package/dist/edge-node/strands.js +60 -0
- package/dist/edge-node/strands.js.map +1 -0
- 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 +27 -17
- package/dist/index.js +39 -17
- package/dist/index.js.map +1 -1
- package/dist/joined-strand-store.d.ts +29 -12
- package/dist/joined-strand-store.js +66 -27
- package/dist/joined-strand-store.js.map +1 -1
- package/dist/key-store.d.ts +9 -4
- package/dist/key-store.js +9 -4
- package/dist/key-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/machine-removal.d.ts +19 -39
- package/dist/machine-removal.js +24 -98
- package/dist/machine-removal.js.map +1 -1
- package/dist/member-redemption.d.ts +256 -0
- package/dist/member-redemption.js +490 -0
- package/dist/member-redemption.js.map +1 -0
- 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-anchor-sync.d.ts +4 -4
- package/dist/owner-anchor-sync.js +16 -15
- package/dist/owner-anchor-sync.js.map +1 -1
- package/dist/owner-grant-protocol.d.ts +251 -0
- package/dist/owner-grant-protocol.js +344 -0
- package/dist/owner-grant-protocol.js.map +1 -0
- package/dist/owner-key-export.d.ts +70 -0
- package/dist/owner-key-export.js +279 -0
- package/dist/owner-key-export.js.map +1 -0
- package/dist/owner-key-flows.d.ts +55 -0
- package/dist/owner-key-flows.js +60 -0
- package/dist/owner-key-flows.js.map +1 -0
- package/dist/owner-key-store.d.ts +31 -0
- package/dist/owner-key-store.js +81 -0
- package/dist/owner-key-store.js.map +1 -0
- package/dist/owner-keys.d.ts +78 -0
- package/dist/owner-keys.js +133 -0
- package/dist/owner-keys.js.map +1 -0
- package/dist/owner-signer.d.ts +54 -0
- package/dist/owner-signer.js +51 -0
- package/dist/owner-signer.js.map +1 -0
- 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-authorization.d.ts +83 -18
- package/dist/peer-authorization.js +123 -53
- package/dist/peer-authorization.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +89 -20
- package/dist/peer-join-backfill.js +265 -108
- 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/provider/rent-node.js +1 -1
- package/dist/provider/rent-node.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +162 -133
- package/dist/seed-bootstrap.js +300 -283
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.js +4 -4
- package/dist/strand-addr-protocol.d.ts +4 -1
- package/dist/strand-addr-protocol.js +8 -2
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-cohort.d.ts +3 -0
- package/dist/strand-cohort.js.map +1 -1
- package/dist/strand-database.d.ts +24 -2
- package/dist/strand-database.js +29 -6
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +176 -88
- package/dist/strand-first-sync-gate.js +288 -121
- 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 +27 -18
- 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 +30 -22
- package/dist/strand-membership-reconciler.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +45 -22
- package/dist/strand-membership-writer.js +226 -131
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-move.d.ts +6 -5
- package/dist/strand-move.js +36 -32
- package/dist/strand-move.js.map +1 -1
- package/dist/strand-peer-redial.js +2 -3
- package/dist/strand-peer-redial.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 +374 -150
- package/dist/types.js +4 -4
- package/dist/types.js.map +1 -1
- package/package.json +17 -8
- package/src/bootstrap-peer-store-file.ts +44 -44
- package/src/bootstrap-peer-store.ts +214 -214
- package/src/cadre-invite-protocol.ts +740 -1052
- package/src/cadre-join-request.ts +24 -3
- package/src/cadre-node.ts +2402 -1079
- package/src/control-authorization.ts +47 -14
- package/src/control-cohort.ts +204 -128
- package/src/control-database.ts +1749 -820
- package/src/control-formation-recorder.ts +359 -353
- package/src/control-protocol-guard.ts +23 -6
- package/src/control-schema-version.ts +301 -0
- package/src/control-schema.ts +273 -78
- package/src/control-write-retry.ts +641 -567
- package/src/delegate-admission.ts +351 -314
- package/src/ed25519-key.ts +174 -171
- package/src/edge-node/config.ts +277 -0
- package/src/edge-node/dissolve.ts +251 -0
- package/src/edge-node/edge-join.ts +319 -0
- package/src/edge-node/edge-node.ts +1045 -0
- package/src/edge-node/index.ts +59 -0
- package/src/edge-node/join-credential.ts +16 -0
- package/src/edge-node/lazy-raw-storage.ts +178 -0
- package/src/edge-node/node-local.ts +84 -0
- package/src/edge-node/options.ts +179 -0
- package/src/edge-node/owner-grant.ts +63 -0
- package/src/edge-node/platform.ts +151 -0
- package/src/edge-node/strand-move.ts +270 -0
- package/src/edge-node/strands.ts +72 -0
- package/src/enrolled-machine-store.ts +3 -3
- package/src/formation-token.ts +63 -0
- package/src/index.ts +899 -789
- package/src/joined-strand-store.ts +73 -28
- package/src/key-store.ts +10 -4
- package/src/link-budget.ts +9 -7
- package/src/machine-removal.ts +26 -111
- package/src/member-redemption.ts +649 -0
- package/src/membership-view.ts +144 -0
- package/src/owner-anchor-sync.ts +19 -18
- package/src/owner-grant-protocol.ts +547 -0
- package/src/owner-key-export.ts +348 -0
- package/src/owner-key-flows.ts +109 -0
- package/src/owner-key-store.ts +85 -0
- package/src/owner-keys.ts +178 -0
- package/src/owner-signer.ts +94 -0
- package/src/peer-addr-book.ts +4 -3
- package/src/peer-authorization.ts +558 -488
- package/src/peer-join-backfill.ts +971 -761
- package/src/pending-join-runner.ts +76 -53
- package/src/provider/rent-node.ts +1 -1
- package/src/seed-bootstrap.ts +343 -349
- package/src/seed-trust-policy.ts +4 -4
- package/src/strand-addr-protocol.ts +547 -538
- package/src/strand-cohort.ts +4 -0
- package/src/strand-database.ts +352 -319
- package/src/strand-first-sync-gate.ts +334 -133
- 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 +33 -25
- package/src/strand-membership-writer.ts +2093 -1974
- package/src/strand-move.ts +50 -38
- package/src/strand-peer-redial.ts +2 -3
- 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 +391 -152
|
@@ -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,22 +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
|
-
* (
|
|
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.
|
|
46
52
|
*
|
|
47
|
-
* Owned by `StrandInstanceManager`: created in `
|
|
53
|
+
* Owned by `StrandInstanceManager`: created in `buildStrandDatabase` for every database build, armed
|
|
48
54
|
* only for a non-founder launch whose first probe fails, stopped and dropped in
|
|
49
55
|
* `releaseRuntime` (so a quiesce → resume rebuild re-probes over the same store), and opened
|
|
50
56
|
* by `publishDatabase` whenever the database is published without its loop — a launch that
|
|
@@ -66,12 +72,25 @@
|
|
|
66
72
|
import debug from 'debug';
|
|
67
73
|
import type { Database } from '@quereus/quereus';
|
|
68
74
|
import type { StrandDatabase } from './strand-database.js';
|
|
75
|
+
import { scheduleRuns, TABLE_CHANGE_COALESCE_MS, type TableChangeWatch } from './table-change-watch.js';
|
|
69
76
|
import { defaultTimeoutScheduler, type TimeoutScheduler } from './timeout-scheduler.js';
|
|
70
77
|
|
|
71
78
|
const log = debug('sereus:cadre:strand-first-sync');
|
|
72
79
|
|
|
73
|
-
/**
|
|
74
|
-
|
|
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;
|
|
75
94
|
|
|
76
95
|
/**
|
|
77
96
|
* How long `CadreNode.addStrand` waits for a machine's first sync before rejecting with
|
|
@@ -103,6 +122,37 @@ export const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
|
|
|
103
122
|
* several of those — which is why both bands are recorded here, and why a change to either
|
|
104
123
|
* number has to be weighed against the other.
|
|
105
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
|
+
*
|
|
106
156
|
* AT THE SUPPORTED LINK ON `@optimystic/*` 1.8.0 (2026-09-29), 1 500 ms one-way, the read
|
|
107
157
|
* deadline derived from the declared link at 7 000 ms (`cohortReadDeadlineMs` in
|
|
108
158
|
* `link-budget.ts`; the plugin's `COHORT_READ_DEADLINE_MS` is the same number), and cadre
|
|
@@ -159,19 +209,19 @@ export const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
|
|
|
159
209
|
* injector are unknown to us) opened the gate at about 150 s, after the previous 120 s budget
|
|
160
210
|
* had already rejected; and a real Galaxy S7 joining fresh through relay.sereus.org took 178 s.
|
|
161
211
|
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* about
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
* it clears the device join by only 1.35x,
|
|
169
|
-
* original 30 s sat INSIDE the 1000 ms fresh
|
|
170
|
-
*
|
|
171
|
-
* 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.
|
|
172
222
|
*
|
|
173
|
-
* NOTE: accepted tradeoff — what
|
|
174
|
-
* 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
|
|
175
225
|
* minutes to report instead of two. The slow report was weighed against refusing attaches that
|
|
176
226
|
* were working, and the refusal is the worse failure. The cost is bounded — the rejection is
|
|
177
227
|
* retryable, the strand stays launched and keeps probing, and `strand:writable` fires the
|
|
@@ -181,7 +231,7 @@ export const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
|
|
|
181
231
|
* sync that is actually in progress — worth more the longer this budget gets.
|
|
182
232
|
*/
|
|
183
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
|
|
184
|
-
export const DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS =
|
|
234
|
+
export const DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS = 360_000;
|
|
185
235
|
|
|
186
236
|
/** Embedder-facing tuning for the gate, threaded from `CadreNodeConfig.strandFirstSync`. */
|
|
187
237
|
export interface StrandFirstSyncConfig {
|
|
@@ -191,8 +241,11 @@ export interface StrandFirstSyncConfig {
|
|
|
191
241
|
* {@link DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS}.
|
|
192
242
|
*/
|
|
193
243
|
timeoutMs?: number;
|
|
194
|
-
/**
|
|
195
|
-
|
|
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;
|
|
196
249
|
}
|
|
197
250
|
|
|
198
251
|
/**
|
|
@@ -222,15 +275,43 @@ export class StrandAwaitingFirstSyncError extends Error {
|
|
|
222
275
|
}
|
|
223
276
|
|
|
224
277
|
/**
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
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.
|
|
229
282
|
*/
|
|
230
|
-
|
|
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> {
|
|
231
312
|
try {
|
|
232
|
-
for await (const
|
|
233
|
-
return
|
|
313
|
+
for await (const _row of db.eval(probeSql(STRAND_HEADER))) {
|
|
314
|
+
return true;
|
|
234
315
|
}
|
|
235
316
|
return false;
|
|
236
317
|
} catch (error) {
|
|
@@ -240,78 +321,156 @@ export async function strandHeaderHeld(db: Database, label: string): Promise<boo
|
|
|
240
321
|
}
|
|
241
322
|
}
|
|
242
323
|
|
|
243
|
-
/** The tables the sApp declared in the `App` schema, by name; views excluded. */
|
|
244
|
-
function appTableNames(db: Database): string[] {
|
|
245
|
-
const app = db.schemaManager.getSchema('App');
|
|
246
|
-
return app ? Array.from(app.getAllTables()).filter((table) => !table.isView).map((table) => table.name) : [];
|
|
247
|
-
}
|
|
248
|
-
|
|
249
324
|
/**
|
|
250
|
-
* Whether a read of
|
|
251
|
-
*
|
|
252
|
-
*
|
|
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.
|
|
253
329
|
*/
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
break;
|
|
259
|
-
}
|
|
260
|
-
} catch (error) {
|
|
261
|
-
log('[%s] App.%s probe failed (treated as not yet synced): %s', label, table,
|
|
262
|
-
error instanceof Error ? error.message : String(error));
|
|
263
|
-
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;
|
|
264
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;
|
|
265
340
|
}
|
|
266
|
-
|
|
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
|
+
];
|
|
267
363
|
}
|
|
268
364
|
|
|
269
365
|
/**
|
|
270
|
-
* The gate's whole probe: the Header is held AND
|
|
271
|
-
*
|
|
272
|
-
*
|
|
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.
|
|
273
384
|
*/
|
|
274
|
-
|
|
275
|
-
|
|
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;
|
|
276
403
|
}
|
|
277
404
|
|
|
278
405
|
export interface StrandFirstSyncGateDeps {
|
|
279
406
|
/** Log tag naming which strand this gate holds (the strand id). */
|
|
280
407
|
label: string;
|
|
281
|
-
/** 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. */
|
|
282
409
|
database: StrandDatabase;
|
|
283
410
|
/**
|
|
284
|
-
* Called exactly once,
|
|
285
|
-
*
|
|
286
|
-
*
|
|
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.
|
|
287
414
|
*/
|
|
288
415
|
onHeaderHeld: () => void;
|
|
289
|
-
/**
|
|
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. */
|
|
290
424
|
scheduler?: TimeoutScheduler;
|
|
291
425
|
}
|
|
292
426
|
|
|
293
427
|
/**
|
|
294
|
-
* Holds one launch's `StrandDatabase` until
|
|
295
|
-
*
|
|
296
|
-
*
|
|
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.
|
|
297
447
|
*
|
|
298
448
|
* NOTE: a probe's reads dial the founder, through the relay when that is the only route, even
|
|
299
449
|
* while the founder is unreachable: one run of `joiner-first-commit-over-slow-relay` logged 17
|
|
300
450
|
* `hop connect denied` at the relay in about 10 s, each a few frames on the joiner's relay
|
|
301
|
-
* connection
|
|
302
|
-
*
|
|
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.
|
|
303
455
|
*/
|
|
304
456
|
export class StrandFirstSyncGate {
|
|
305
457
|
private readonly scheduler: TimeoutScheduler;
|
|
306
|
-
private readonly
|
|
307
|
-
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;
|
|
308
468
|
private stopped = false;
|
|
309
469
|
private opened = false;
|
|
310
|
-
private probing = false;
|
|
311
470
|
|
|
312
471
|
constructor(private readonly deps: StrandFirstSyncGateDeps, config?: StrandFirstSyncConfig) {
|
|
313
472
|
this.scheduler = deps.scheduler ?? defaultTimeoutScheduler;
|
|
314
|
-
this.
|
|
473
|
+
this.backstopMs = config?.backstopMs ?? DEFAULT_STRAND_FIRST_SYNC_BACKSTOP_MS;
|
|
315
474
|
}
|
|
316
475
|
|
|
317
476
|
/** The database this gate is holding back. */
|
|
@@ -319,24 +478,53 @@ export class StrandFirstSyncGate {
|
|
|
319
478
|
return this.deps.database;
|
|
320
479
|
}
|
|
321
480
|
|
|
322
|
-
/** True once the
|
|
481
|
+
/** True once the first sync completed (or the gate was force-opened); probing is then done. */
|
|
323
482
|
get isOpen(): boolean {
|
|
324
483
|
return this.opened;
|
|
325
484
|
}
|
|
326
485
|
|
|
327
|
-
/**
|
|
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
|
+
*/
|
|
328
509
|
start(): void {
|
|
329
|
-
if (this.stopped || this.opened || this.
|
|
330
|
-
this.
|
|
331
|
-
|
|
332
|
-
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);
|
|
333
522
|
}
|
|
334
523
|
|
|
335
524
|
/**
|
|
336
|
-
* Open without
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
* 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.
|
|
340
528
|
*/
|
|
341
529
|
open(): void {
|
|
342
530
|
if (this.opened) return;
|
|
@@ -345,54 +533,67 @@ export class StrandFirstSyncGate {
|
|
|
345
533
|
log('[%s] database published by the caller; first-sync gate not needed or force-opened', this.deps.label);
|
|
346
534
|
}
|
|
347
535
|
|
|
348
|
-
/** Disarm the
|
|
536
|
+
/** Disarm: unsubscribe and drop the schedule; a probe already in flight completes and is then ignored. */
|
|
349
537
|
stop(): void {
|
|
350
538
|
this.stopped = true;
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
private schedule(): void {
|
|
358
|
-
this.timer = this.scheduler.setTimeout(() => {
|
|
359
|
-
this.timer = undefined;
|
|
360
|
-
void this.probe();
|
|
361
|
-
}, this.pollIntervalMs);
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
private async probe(): Promise<void> {
|
|
365
|
-
if (this.stopped || this.opened || this.probing) return;
|
|
366
|
-
this.probing = true;
|
|
367
|
-
const held = await this.probeHeld();
|
|
368
|
-
// Re-check after the await: a release or a force-open may have landed mid-read.
|
|
369
|
-
if (this.stopped || this.opened) return;
|
|
370
|
-
if (!held) {
|
|
371
|
-
this.schedule();
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
this.opened = true;
|
|
375
|
-
this.stop();
|
|
376
|
-
log('[%s] first-sync gate opened: Strand.Header and every App table read from a peer', this.deps.label);
|
|
377
|
-
this.deps.onHeaderHeld();
|
|
539
|
+
this.unsubscribeStorage?.();
|
|
540
|
+
this.unsubscribeStorage = undefined;
|
|
541
|
+
this.probes?.dispose();
|
|
542
|
+
this.probes = undefined;
|
|
378
543
|
}
|
|
379
544
|
|
|
380
545
|
/**
|
|
381
|
-
* One probe's answer,
|
|
382
|
-
* guards —
|
|
383
|
-
* any failing read
|
|
384
|
-
*
|
|
385
|
-
* `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.
|
|
386
550
|
*/
|
|
387
|
-
|
|
551
|
+
async probeOnce(): Promise<boolean> {
|
|
552
|
+
this.landedSinceProbe = false;
|
|
388
553
|
try {
|
|
389
|
-
return await strandFirstSyncComplete(this.deps.database.getDatabase(), this.deps.label);
|
|
554
|
+
return await strandFirstSyncComplete(this.deps.database.getDatabase(), this.deps.label, this.progress);
|
|
390
555
|
} catch (error) {
|
|
391
556
|
log('[%s] first-sync probe threw (treated as not yet synced): %s', this.deps.label,
|
|
392
557
|
error instanceof Error ? error.message : String(error));
|
|
393
558
|
return false;
|
|
394
|
-
} finally {
|
|
395
|
-
this.probing = false;
|
|
396
559
|
}
|
|
397
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
|
+
}
|
|
398
599
|
}
|