Skip to content

Commit 6edb4a2

Browse files
committed
fix(crawler): persist and drain peer discovery rounds
1 parent 66d4749 commit 6edb4a2

34 files changed

Lines changed: 3593 additions & 1007 deletions

CLAUDE.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,11 @@ For any non-trivial task, use this structure in the final summary or PR descript
6262
- **Append-only store responsibility**: append-only store (`[store].append_only_data_path`, 1 CF: `CF_CELLS`) holds only immutable cell payloads, content-addressed by outpoint. Write-once, never updated or deleted.
6363
- **Append-only correction policy**: if cell payload data in the append-only store is wrong, fix indexer logic and rebuild from genesis; do not patch cell data with in-place update/delete.
6464
- **Cross-store cell reads**: live/consumed markers live in domain store; cell payloads live in append-only store. Reading a full cell requires both the domain and append-only stores.
65-
- **Network store responsibility** (third store class): the network store (`[store].network_data_path`, default `data/network`, 2 CFs: `CF_NET_NODES`, `CF_NET_STATS`) holds whole-network p2p-crawler observations — non-chain, non-deterministic, TTL-retained network-topology data. Written exclusively by the opt-in `ckbadger-crawler` service (never the indexer); the API opens it secondary (read-only). It is the ONLY store EXEMPT from the rebuild-from-genesis invariant, because its contents derive from live p2p observation rather than chain replay.
65+
- **Network store responsibility** (third store class): the network store (`[store].network_data_path`, default `data/network`, 3 CFs: `CF_NET_NODES`, `CF_NET_STATS`, `CF_NET_CRAWL`) holds whole-network p2p-crawler observations and durable in-progress crawl state — non-chain, non-deterministic, TTL-retained network-topology data. Written exclusively by the opt-in `ckbadger-crawler` service (never the indexer); the API opens it secondary (read-only). It is the ONLY store EXEMPT from the rebuild-from-genesis invariant, because its contents derive from live p2p observation rather than chain replay.
6666

6767
## Store Boundary Check Rules (MANDATORY)
6868

69-
- `CF_CELLS` is the only append-only CF. The 59 domain CFs are the canonical mutable chain view; the 2 network CFs (`CF_NET_NODES`, `CF_NET_STATS`) belong to the separate network store (mutable, TTL-retained, non-chain).
69+
- `CF_CELLS` is the only append-only CF. The 59 domain CFs are the canonical mutable chain view; the 3 network CFs (`CF_NET_NODES`, `CF_NET_STATS`, `CF_NET_CRAWL`) belong to the separate network store (mutable, TTL-retained, non-chain).
7070
- Every storage PR must explicitly state which logical store each new/changed write path targets (`domain`, `append-only`, or `network`).
7171
- Any write path to the append-only store (`CF_CELLS`) must be reviewed as append-only semantics: new-key append only, no update, no delete, no overwrite.
7272
- Do not add helper APIs that allow generic mutation on append-only store (for example update-by-key or delete-by-key operations).
@@ -152,7 +152,7 @@ crates/
152152
indexer/ # Blockchain sync daemon library (three-stage pipeline)
153153
src/sync/bulk_build/ # Bulk-build engine (in-memory reducers, FactsArena, LiveCellOwner)
154154
src/verify/ # Data integrity verification suite (57 checks)
155-
ckbadger-store/ # Embedded RocksDB storage engine (three store classes: 59 domain + 1 append-only + 2 network CFs)
155+
ckbadger-store/ # Embedded RocksDB storage engine (three store classes: 59 domain + 1 append-only + 3 network CFs)
156156
dob-decoder/ # CKB-VM DOB decoder (DNA extraction from Spore NFTs)
157157
common/ # Shared types (block, cell, tx, script, error)
158158
ckb-store-reader/ # Read-only CKB RocksDB reader (optional direct read mode)
@@ -163,7 +163,7 @@ frontend/ # Vite + React SPA
163163
docs/ARCHITECTURE_MAP.md # Module ownership and entry points
164164
docs/POSTMORTEM.md # Historical bugs - READ BEFORE CKB/DAO WORK
165165
docs/INDEXER_PIPELINE.md # Pipeline architecture and progress tracking
166-
docs/STORE_SCHEMA.md # Column families reference (59 domain + 1 append-only + 2 network)
166+
docs/STORE_SCHEMA.md # Column families reference (59 domain + 1 append-only + 3 network)
167167
docs/TESTING.md # Data integrity verification details
168168
```
169169

@@ -191,7 +191,7 @@ Sync progress and memory stats are stored in RocksDB (`get_sync_tip()`/`get_sync
191191

192192
## ckbadger-store (Embedded Storage Engine)
193193

194-
Three logical RocksDB store classes: domain (`[store].domain_data_path`, default `data/domain`, 59 CFs), append-only (`[store].append_only_data_path`, default `data/append-only`, 1 CF: `CF_CELLS`), and network (`[store].network_data_path`, default `data/network`, 2 CFs: `CF_NET_NODES`, `CF_NET_STATS`). For the two chain stores the indexer opens read-write and the API opens secondary (read-only); the append-only store holds only immutable cell payloads keyed by outpoint, while all other chain state (activities, indexes, stats, etc.) lives in the domain store. A secondary has no snapshots and its view advances only on `try_catch_up_with_primary()`, so the API pins one read view per HTTP request (`crates/ckbadger-store/src/read_view.rs`) and catch-up is exclusive with pinned scopes — any read that resolves an index row and then loads the row it points at is coherent by default. Handlers that wait for the indexer to write new data (the cycles long-poll) must release the pin first; see `docs/STORE_SCHEMA.md` → Read Consistency. The network store is written solely by the opt-in `ckbadger-crawler` service (configured via the `[crawler]` section, default `enabled = false`; API opens it secondary) and holds non-chain p2p-crawler observations — it is the only store EXEMPT from rebuild-from-genesis. See `docs/STORE_SCHEMA.md` for full column family reference.
194+
Three logical RocksDB store classes: domain (`[store].domain_data_path`, default `data/domain`, 59 CFs), append-only (`[store].append_only_data_path`, default `data/append-only`, 1 CF: `CF_CELLS`), and network (`[store].network_data_path`, default `data/network`, 3 CFs: `CF_NET_NODES`, `CF_NET_STATS`, `CF_NET_CRAWL`). For the two chain stores the indexer opens read-write and the API opens secondary (read-only); the append-only store holds only immutable cell payloads keyed by outpoint, while all other chain state (activities, indexes, stats, etc.) lives in the domain store. A secondary has no snapshots and its view advances only on `try_catch_up_with_primary()`, so the API pins one read view per HTTP request (`crates/ckbadger-store/src/read_view.rs`) and catch-up is exclusive with pinned scopes — any read that resolves an index row and then loads the row it points at is coherent by default. Handlers that wait for the indexer to write new data (the cycles long-poll) must release the pin first; see `docs/STORE_SCHEMA.md` → Read Consistency. The network store is written solely by the opt-in `ckbadger-crawler` service (configured via the `[crawler]` section, default `enabled = false`; API opens it secondary) and holds non-chain p2p-crawler observations plus resumable crawl state — it is the only store EXEMPT from rebuild-from-genesis. See `docs/STORE_SCHEMA.md` for full column family reference.
195195

196196
Memory is budgeted per network. `[store].memory_budget_gb` is an explicit per-network override;
197197
otherwise detected host RAM is divided by the number of co-resident orchestrator networks. The

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ ckbadger run (orchestrator supervisor)
161161
| **Visualization** | react-force-graph-2d, D3.js | Cell relationship graphs |
162162
| **API** | Rust (Axum) | High-performance REST/WebSocket |
163163
| **Indexer** | Rust (3-stage pipeline) | Block parsing, cell tracking |
164-
| **Storage** | RocksDB (59 domain + 1 append-only + 2 network CFs, ckbadger-store) | Embedded three-store data engine |
164+
| **Storage** | RocksDB (59 domain + 1 append-only + 3 network CFs, ckbadger-store) | Embedded three-store data engine |
165165
| **Cache** | In-memory LRU | API response cache |
166166
| **IPC** | Unix domain sockets | Inter-process communication |
167167

crates/api/src/entry.rs

Lines changed: 150 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
use anyhow::{bail, Result};
2+
use arc_swap::ArcSwapOption;
23
use axum::extract::State;
34
use axum::http::{header, HeaderMap, StatusCode, Uri};
45
use axum::response::{IntoResponse, Response};
56
use axum::{routing::get, Router};
67
use std::net::SocketAddr;
78
use std::path::{Path, PathBuf};
89
use std::sync::Arc;
10+
use std::time::Duration;
911
use tracing::info;
1012

1113
use ckbadger_store::{
@@ -33,13 +35,83 @@ pub struct ApiServiceConfig {
3335
pub dob_decode_dir: PathBuf,
3436
/// Directory where API writes cycles calculation request files for the indexer worker.
3537
pub cycles_request_dir: Option<std::path::PathBuf>,
36-
/// Path to the network-crawler store primary. The API opens a read-only
37-
/// secondary only when this primary already exists (opt-in crawler).
38+
/// Path to the network-crawler store primary. The API attaches a read-only
39+
/// secondary immediately or when this opt-in primary later appears.
3840
pub network_data_path: String,
3941
/// Whether the network crawler is enabled in config (surfaced to the UI).
4042
pub crawler_enabled: bool,
4143
}
4244

45+
const NETWORK_STORE_RETRY_INTERVAL: Duration = Duration::from_secs(1);
46+
47+
fn open_network_secondary_if_present(
48+
primary_path: &Path,
49+
runtime_config: StoreRuntimeConfig,
50+
) -> Result<Option<Arc<CkbadgerStore>>> {
51+
if !primary_path.join("CURRENT").exists() {
52+
return Ok(None);
53+
}
54+
55+
let secondary_path = secondary_store_path(primary_path, SecondaryStoreOwner::Api);
56+
CkbadgerStore::open_network_secondary_with_runtime(
57+
primary_path,
58+
secondary_path.as_path(),
59+
runtime_config,
60+
)
61+
.map(Arc::new)
62+
.map(Some)
63+
}
64+
65+
async fn wait_for_network_store_secondary(
66+
slot: Arc<ArcSwapOption<CkbadgerStore>>,
67+
primary_path: PathBuf,
68+
runtime_config: StoreRuntimeConfig,
69+
retry_interval: Duration,
70+
) {
71+
let mut last_error = None;
72+
loop {
73+
if slot.load().is_some() {
74+
return;
75+
}
76+
77+
let open_primary_path = primary_path.clone();
78+
let open_result = tokio::task::spawn_blocking(move || {
79+
open_network_secondary_if_present(&open_primary_path, runtime_config)
80+
})
81+
.await;
82+
83+
match open_result {
84+
Ok(Ok(Some(store))) => {
85+
slot.store(Some(store));
86+
info!(
87+
"Network store secondary attached after crawler startup: {}",
88+
primary_path.display()
89+
);
90+
return;
91+
}
92+
Ok(Ok(None)) => {}
93+
Ok(Err(error)) => {
94+
let message = error.to_string();
95+
if last_error.as_deref() != Some(message.as_str()) {
96+
tracing::warn!(
97+
"network store present but failed to open secondary; will retry: {message}"
98+
);
99+
last_error = Some(message);
100+
}
101+
}
102+
Err(error) => {
103+
let message = format!("network store secondary open task failed: {error}");
104+
if last_error.as_deref() != Some(message.as_str()) {
105+
tracing::warn!("{message}; will retry");
106+
last_error = Some(message);
107+
}
108+
}
109+
}
110+
111+
tokio::time::sleep(retry_interval).await;
112+
}
113+
}
114+
43115
/// One backend network the frontend proxy can route to.
44116
#[derive(Clone, Debug)]
45117
pub struct FrontendNetwork {
@@ -116,34 +188,38 @@ pub async fn run_api(config: ApiServiceConfig) -> Result<()> {
116188
config.store_runtime_config,
117189
)?);
118190

119-
// The network-crawler store is opt-in: open a read-only secondary only when
120-
// the crawler has already produced a primary (CURRENT marker present). A
121-
// missing primary or an open failure is a normal `None`, never a startup
122-
// error — the API stays read-only and never writes this store.
123-
let network_store = {
124-
let primary = Path::new(&config.network_data_path);
125-
if primary.join("CURRENT").exists() {
126-
let sec = secondary_store_path(&config.network_data_path, SecondaryStoreOwner::Api);
127-
info!(
128-
"Opening ckbadger network store (secondary) at: {} -> {}",
129-
config.network_data_path,
130-
sec.display()
131-
);
132-
match CkbadgerStore::open_network_secondary_with_runtime(
133-
primary,
134-
sec.as_path(),
135-
config.store_runtime_config,
136-
) {
137-
Ok(s) => Some(Arc::new(s)),
138-
Err(e) => {
139-
tracing::warn!("network store present but failed to open secondary: {e}");
140-
None
141-
}
191+
// The API remains read-only, but unlike the chain stores the opt-in network
192+
// primary may appear after the API has already started. Try once before
193+
// constructing the router, then keep retrying in the background until the
194+
// crawler creates/upgrades the primary and the secondary can be attached.
195+
let network_primary_path = PathBuf::from(&config.network_data_path);
196+
let initial_network_store =
197+
match open_network_secondary_if_present(&network_primary_path, config.store_runtime_config)
198+
{
199+
Ok(store) => store,
200+
Err(error) => {
201+
tracing::warn!(
202+
"network store present but failed to open secondary; will retry: {error}"
203+
);
204+
None
142205
}
143-
} else {
144-
None
145-
}
146-
};
206+
};
207+
if initial_network_store.is_some() {
208+
info!(
209+
"Opened ckbadger network store (secondary) at: {}",
210+
config.network_data_path
211+
);
212+
}
213+
let should_wait_for_network_store = initial_network_store.is_none();
214+
let network_store = Arc::new(ArcSwapOption::from(initial_network_store));
215+
if should_wait_for_network_store {
216+
tokio::spawn(wait_for_network_store_secondary(
217+
network_store.clone(),
218+
network_primary_path,
219+
config.store_runtime_config,
220+
NETWORK_STORE_RETRY_INTERVAL,
221+
));
222+
}
147223

148224
let app_config = AppConfig {
149225
store,
@@ -668,6 +744,51 @@ mod tests {
668744
assert!(!config.crawler_enabled);
669745
}
670746

747+
#[tokio::test]
748+
async fn network_secondary_attaches_when_primary_appears_after_api_start() {
749+
use ckbadger_store::LatestStatus;
750+
751+
let dir = tempfile::tempdir().expect("network store tempdir");
752+
let primary_path = dir.path().join("network");
753+
let slot = Arc::new(ArcSwapOption::from(None));
754+
let waiter = tokio::spawn(wait_for_network_store_secondary(
755+
slot.clone(),
756+
primary_path.clone(),
757+
StoreRuntimeConfig::default(),
758+
Duration::from_millis(10),
759+
));
760+
761+
tokio::time::sleep(Duration::from_millis(25)).await;
762+
assert!(slot.load_full().is_none());
763+
764+
let primary =
765+
CkbadgerStore::open_network_with_runtime(&primary_path, StoreRuntimeConfig::default())
766+
.expect("open network primary");
767+
primary
768+
.put_network_status(&LatestStatus {
769+
round_id: 9,
770+
started: 100,
771+
finished: 200,
772+
..Default::default()
773+
})
774+
.expect("seed network status");
775+
776+
tokio::time::timeout(Duration::from_secs(5), waiter)
777+
.await
778+
.expect("secondary attachment timed out")
779+
.expect("secondary attachment task panicked");
780+
781+
let secondary = slot.load_full().expect("network secondary attached");
782+
assert_eq!(
783+
secondary
784+
.get_network_status()
785+
.expect("read network status")
786+
.expect("network status exists")
787+
.round_id,
788+
9
789+
);
790+
}
791+
671792
#[test]
672793
fn test_frontend_service_config_fields() {
673794
let config = FrontendServiceConfig {

crates/api/src/lib.rs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ pub mod utils;
1010
pub mod warmup;
1111
pub mod ws;
1212

13-
use arc_swap::ArcSwap;
13+
use arc_swap::{ArcSwap, ArcSwapOption};
1414
use axum::{routing::get, Router};
1515
use ckbadger_common::{
1616
BackgroundTaskEntry, BackgroundTaskKind, BackgroundTaskState, BackgroundTasksData,
@@ -61,9 +61,10 @@ impl Drop for CleanupPathGuard {
6161
pub struct AppState {
6262
pub store: Arc<CkbadgerStore>,
6363
pub append_only_store: Arc<CkbadgerStore>,
64-
/// Optional read-only secondary of the network-crawler store. `None` when the
65-
/// opt-in crawler has never produced a primary store (the common case).
66-
pub network_store: Option<Arc<CkbadgerStore>>,
64+
/// Hot-swappable read-only secondary of the network-crawler store. The slot
65+
/// starts empty when the crawler has not created its primary yet and is
66+
/// populated without restarting the API once that primary becomes available.
67+
pub network_store: Arc<ArcSwapOption<CkbadgerStore>>,
6768
/// Whether the network crawler is enabled in config (drives UI availability
6869
/// hints independently of whether a store snapshot exists yet).
6970
pub crawler_enabled: bool,
@@ -191,8 +192,8 @@ impl AppState {
191192
pub struct AppConfig {
192193
pub store: Arc<CkbadgerStore>,
193194
pub append_only_store: Arc<CkbadgerStore>,
194-
/// Optional read-only secondary of the network-crawler store (opt-in).
195-
pub network_store: Option<Arc<CkbadgerStore>>,
195+
/// Hot-swappable read-only secondary of the network-crawler store (opt-in).
196+
pub network_store: Arc<ArcSwapOption<CkbadgerStore>>,
196197
/// Whether the network crawler is enabled in config.
197198
pub crawler_enabled: bool,
198199
pub ckb_rpc_url: String,
@@ -368,7 +369,7 @@ pub async fn create_router(config: AppConfig) -> Router {
368369
let store = refresh_store.clone();
369370
let append_only = refresh_append_only_store.clone();
370371
let ckb = refresh_ckb_store.clone();
371-
let network = refresh_network_store.clone();
372+
let network = refresh_network_store.load_full();
372373
let result = tokio::task::spawn_blocking(move || {
373374
// One exclusive window for every secondary this process reads.
374375
// It waits for in-flight requests (each pins the view for its

0 commit comments

Comments
 (0)