Skip to content

Commit 57e82e0

Browse files
committed
Coordinate browser bootstrap across networks before rebuilding indexes (#2898)
Browser startup imports the missing networks together on a fresh database, then rebuilds shared secondary indexes once. This layer above #2897 addresses the cross-network startup cost in [RAI-2651](https://linear.app/makeitrain/issue/RAI-2651/integrate-bulk-sql-bootstrap-and-deferred-indexes-in-raindex-browser). **Live effect:** coordinated initial browser imports · **Risk:** medium (stored data, startup scheduling) · **Ships:** next raindex/webapp release after #2897 ## Decisions - On a fresh DB with at least two owned networks, download eligible seeds in parallel and import successful downloads atomically in one bounded sqlite-web session. Rebuild the 41 non-unique secondary indexes once; PRIMARY KEY/UNIQUE constraints remain enforced. Populated DBs start independent runners and retain their indexes. Smaller cohorts hand their guards to individual runners and preserve fresh-DB deferral without cohort downloads/timeouts. - Hold shared bootstrap and per-network ownership through provisioning; transfer each network guard directly into its runner. A shared import Web Lock serializes watermark/index preflight through commit or cancellation, including fallback imports. Recheck DB state after waiting for ownership; unsupported ownership retains indexes. - Failed downloads retry per target; import/manifest failure returns to normal provisioning. Catch-up analyzes applied data without an extra cohort ANALYZE. Producer artifacts, schema, native import and incremental writes retain their behavior. ## Risks - Small cold networks wait for the largest participating seed. Decoded cohort SQL stays in memory, and reads wait behind the atomic import. Coordinated manifest and seed requests are bounded to two minutes each; per-network runner requests keep their existing behavior, and fallback may lose shared deferral. - A seed already at finalized head receives planner statistics after the next applied window. Worker loss during commit can leave its result unknown; subsequent provisioning checks persisted watermarks. ## Proof - Frozen Base + Robinhood comparison on `3a85402`: parent **60.2 s**, cohort **33.9 s** to both ACTIVE; one 30.9 s import, 897 chunks, one rebuild and no extra ANALYZE. All table counts/index definitions/watermarks match; integrity is `ok`. Warm reload: **0.73 s**, zero seed imports. Fixtures: 42.4 MB gzip / 234 MB SQL, about 470 MB stored origin usage. - These are local observations, not a guaranteed speedup. Earlier runs ranged from 31.7–37.0 s, and one took 147.8 s during heavy observed Spotlight activity; parent timing also varied. No production latency or peak-memory claim. - Nix checks and staged independent local reviews cover atomic rollback, uniqueness, cancellation, partial downloads, leadership handoff and cross-tab import preflight. Latest `e240135` [deployed preview](https://rain-orderbook-v6-gaj9om8b9-rain-x-h20.vercel.app/orders) passed a cold import, Orders/Vaults lists, Base order detail with a trade and Robinhood vault detail with balance history; both networks reached ACTIVE and are healthy/ready. The Base seed download took 23.2 s in this functional run, so it is not an import-speed comparison. Warm reload downloads no SQL seed and keeps list data/readiness. Selection was restricted to Base/Robinhood because the existing Ethereum subgraph request remained pending. - Runtime head `1f71d21` also skips coordination on any populated DB, including after waiting for the bootstrap lock, and names synthetic stop/error tests accurately. Fresh Nix checks on `e240135` pass: 1,926 native, 153 Node/Wasm, 151 actual browser/Wasm and 1,168 JS tests; builds, lint, types and Rust static checks pass. Renewed staged local review is clean (two independent Codex reviewers; no OpenCode). - The final single-network bypass was exercised with a frozen Base fixture on `e240135`: one manifest fetch, one seed download, one import, one 41-index rebuild, matching watermark, integrity `ok`, and Base healthy/ready/ACTIVE. No live indexing; this is functional evidence, not a paired speed comparison. - Final `e240135` addresses Marvin’s last minor follow-up: fewer than two owned networks bypass cohort downloads while retaining direct guard handoff and the existing fresh-DB deferred import. All 12 review threads are resolved. Both final-commit bot gates pass: CodeRabbit has no actionable comments and Marvin approved with no open findings. Application CI and the deployed preview pass. Human reviews requested from Jakub, Juan, Alex and Rouz. The known Solidity static failure is accepted separately; human review remains pending. ## Rollout 1. Merge after #2897, release raindex and deploy the webapp. Existing manifests/dumps and sqlite-web 0.0.4 remain supported; this layer needs no producer change. 2. Verify both networks reach ACTIVE, a single rebuild spans the cold cohort, and warm startup downloads no seed. 3. Roll back the raindex/webapp release if startup regresses; schema is unchanged.
1 parent c70fa66 commit 57e82e0

9 files changed

Lines changed: 1232 additions & 41 deletions

File tree

‎crates/common/ARCHITECTURE.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,28 @@ Target gating is used extensively:
144144
validation. When the YAML declares `local-db-sync` sections and DB callbacks
145145
are provided, the constructor automatically sets up the local DB and starts
146146
the sync scheduler.
147+
- Browser startup starts all networks independently when any persisted target
148+
exists, retaining indexes and letting each missing network import its own
149+
seed. A fresh DB coordinates missing targets when it owns at least two
150+
networks, under a shared Web Lock and their per-network leadership locks.
151+
Ownership transfers directly to cold runners, including on failure; networks
152+
led in another tab are left to their existing runners. Smaller cohorts use
153+
their individual runners, retaining ownership and fresh-DB index deferral.
154+
Seeds download in parallel; failed downloads leave only those targets for
155+
retry, while successful downloads are rechecked against persisted watermarks
156+
and imported together in one bounded sqlite-web session. A globally fresh DB
157+
drops non-unique secondary indexes and rebuilds them once after all seeds;
158+
additions to a populated DB retain existing indexes. A shared import Web Lock
159+
covers watermark/index preflight through commit or cancellation for both
160+
cohort and per-network imports; without that ownership, or after SDK lease
161+
contention, imports retain indexes. PRIMARY KEY/UNIQUE indexes remain
162+
enforced. Cold network loops catch up, analyze their applied data and
163+
establish readiness; the cohort does not run an additional `ANALYZE`.
164+
Import/manifest failures and unsupported coordination fall back to per-network
165+
provisioning; stale-target refresh retains its existing behavior. Coordinated
166+
manifest and seed requests are bounded to two minutes each; per-network runner
167+
requests keep their existing behavior. Scheduler stop cancels
168+
coordination/import cleanup.
147169
- `LocalDbState` encapsulates the local DB handle, scheduler, `SyncReadiness`
148170
(tracks which chains have completed a sync cycle), and the set of configured
149171
chain IDs. Query routing uses `QuerySource::LocalDb` vs

‎crates/common/src/local_db/query/executor.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ pub trait LocalDbQueryExecutor {
2323
.await
2424
}
2525

26+
/// Atomically import a browser bootstrap cohort with a single index rebuild.
27+
/// Implementations must not commit individual dumps separately.
28+
#[cfg(target_family = "wasm")]
29+
async fn execute_sql_dumps(&self, _dumps: Vec<Arc<String>>) -> Result<(), LocalDbQueryError> {
30+
Err(LocalDbQueryError::not_implemented("execute_sql_dumps"))
31+
}
32+
2633
async fn query_json<T>(&self, stmt: &SqlStatement) -> Result<T, LocalDbQueryError>
2734
where
2835
T: FromDbJson;

‎crates/common/src/raindex_client/local_db/executor.rs‎

Lines changed: 239 additions & 31 deletions
Large diffs are not rendered by default.

‎crates/common/src/raindex_client/local_db/mod.rs‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ type ExecuteBatchFn = dyn Fn(
4747
type ExecuteDumpFn =
4848
dyn Fn(Arc<String>) -> Pin<Box<dyn Future<Output = Result<(), LocalDbQueryError>> + 'static>>;
4949

50+
#[cfg(target_family = "wasm")]
51+
type ExecuteDumpsFn =
52+
dyn Fn(
53+
Vec<Arc<String>>,
54+
) -> Pin<Box<dyn Future<Output = Result<(), LocalDbQueryError>> + 'static>>;
55+
5056
#[cfg(target_family = "wasm")]
5157
type QueryTextFn =
5258
dyn Fn(
@@ -89,6 +95,8 @@ pub struct LocalDb {
8995
execute_batch_fn: FnPtr<ExecuteBatchFn>,
9096
#[cfg(target_family = "wasm")]
9197
execute_dump_fn: FnPtr<ExecuteDumpFn>,
98+
#[cfg(target_family = "wasm")]
99+
execute_dumps_fn: FnPtr<ExecuteDumpsFn>,
92100
query_text_fn: FnPtr<QueryTextFn>,
93101
query_json_fn: FnPtr<QueryJsonFn>,
94102
#[cfg(target_family = "wasm")]
@@ -121,6 +129,14 @@ impl LocalDb {
121129
})
122130
};
123131

132+
let execute_dumps_fn: FnPtr<ExecuteDumpsFn> = {
133+
let exec = Rc::clone(&exec);
134+
Rc::new(move |dumps: Vec<Arc<String>>| {
135+
let exec = Rc::clone(&exec);
136+
Box::pin(async move { exec.execute_sql_dumps(dumps).await })
137+
})
138+
};
139+
124140
let query_text_fn: FnPtr<QueryTextFn> = {
125141
let exec = Rc::clone(&exec);
126142
Rc::new(move |stmt: &SqlStatement| {
@@ -159,6 +175,7 @@ impl LocalDb {
159175
Self {
160176
execute_batch_fn,
161177
execute_dump_fn,
178+
execute_dumps_fn,
162179
query_text_fn,
163180
query_json_fn,
164181
query_json_retryable_fn,
@@ -241,6 +258,11 @@ impl LocalDbQueryExecutor for LocalDb {
241258
(self.execute_dump_fn)(dump).await
242259
}
243260

261+
#[cfg(target_family = "wasm")]
262+
async fn execute_sql_dumps(&self, dumps: Vec<Arc<String>>) -> Result<(), LocalDbQueryError> {
263+
(self.execute_dumps_fn)(dumps).await
264+
}
265+
244266
async fn query_json<T>(&self, stmt: &SqlStatement) -> Result<T, LocalDbQueryError>
245267
where
246268
T: FromDbJson,
@@ -394,6 +416,62 @@ mod wasm_tests {
394416

395417
wasm_bindgen_test_configure!(run_in_browser);
396418

419+
#[wasm_bindgen_test]
420+
async fn import_preflight_waits_for_cross_tab_ownership() {
421+
use super::pipeline::runner::leadership::acquire_import;
422+
use js_sys::{Array, Function, Reflect};
423+
use std::sync::Arc;
424+
let mut holder = None;
425+
for _ in 0..100 {
426+
holder = acquire_import().await.unwrap();
427+
if holder.is_some() {
428+
break;
429+
}
430+
TimeoutFuture::new(5).await;
431+
}
432+
let holder = holder.expect("previous test must release import ownership");
433+
let local_db = Function::new_no_args(r#"return {
434+
reads:0, populated:false, chunks:[],
435+
query(sql) {
436+
this.reads++;
437+
if (!sql.includes('target_watermarks')) throw new Error('must retain populated indexes');
438+
return {value:this.populated ? '[{"present":1}]' : '[]'};
439+
},
440+
transaction() {}, wipeAndRecreate() {},
441+
beginSqlDumpImport() { return {value:'session'}; },
442+
appendSqlDumpChunk(id, sql) { this.chunks.push(sql); return {value:''}; },
443+
finishSqlDumpImport() { return {value:''}; }, cancelSqlDumpImport() {}
444+
};"#).call0(&JsValue::UNDEFINED).unwrap();
445+
let exec = JsCallbackExecutor::new(local_db.clone()).unwrap();
446+
let mut import = exec.execute_sql_dumps(vec![Arc::new(
447+
"BEGIN; INSERT INTO t VALUES (2); COMMIT;".into(),
448+
)]);
449+
for _ in 0..10 {
450+
assert!(futures::poll!(import.as_mut()).is_pending());
451+
gloo_timers::future::TimeoutFuture::new(1).await;
452+
}
453+
assert_eq!(
454+
Reflect::get(&local_db, &"reads".into()).unwrap().as_f64(),
455+
Some(0.0)
456+
);
457+
Reflect::set(&local_db, &"populated".into(), &JsValue::TRUE).unwrap();
458+
drop(holder);
459+
import.await.unwrap();
460+
assert_eq!(
461+
Reflect::get(&local_db, &"reads".into()).unwrap().as_f64(),
462+
Some(1.0)
463+
);
464+
let chunks = Reflect::get(&local_db, &"chunks".into())
465+
.unwrap()
466+
.dyn_into::<Array>()
467+
.unwrap();
468+
let sql = chunks
469+
.iter()
470+
.map(|v| v.as_string().unwrap())
471+
.collect::<String>();
472+
assert!(!sql.contains("DROP INDEX"));
473+
}
474+
397475
fn single_raindex_settings_yaml() -> String {
398476
format!(
399477
r#"

‎crates/common/src/raindex_client/local_db/pipeline/bootstrap.rs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ use std::sync::Arc;
1515

1616
const BOOTSTRAP_CACHE_SIZE_SQL: &str = "PRAGMA cache_size = -25000";
1717
#[cfg(target_family = "wasm")]
18-
const BOOTSTRAP_CACHE_SIZE_QUERY_SQL: &str = "PRAGMA cache_size = -25000; SELECT 1 WHERE 0;";
18+
pub(super) const BOOTSTRAP_CACHE_SIZE_QUERY_SQL: &str =
19+
"PRAGMA cache_size = -25000; SELECT 1 WHERE 0;";
1920

2021
#[derive(Debug, Default, Clone, Copy)]
2122
pub struct ClientBootstrapAdapter;
@@ -272,7 +273,7 @@ mod tests {
272273
for sql in [
273274
BOOTSTRAP_CACHE_SIZE_QUERY_SQL,
274275
"SELECT 1 AS present FROM target_watermarks LIMIT 1",
275-
"SELECT name, sql FROM sqlite_master WHERE type = 'index' AND sql IS NOT NULL ORDER BY name",
276+
"SELECT m.name, m.sql FROM sqlite_master AS m JOIN pragma_index_list(m.tbl_name) AS i ON i.name = m.name WHERE m.type = 'index' AND m.sql IS NOT NULL AND i.\"unique\" = 0 ORDER BY m.name",
276277
] {
277278
assert_eq!(
278279
Reflect::get(&calls, &JsValue::from_str(sql))

0 commit comments

Comments
 (0)