Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Most full nodes center a **UTXO set + block files** (Bitcoin Core). Most Electru
backends are **external indexers** of another node. rbitcoin does neither:
**no UTXO set** (relational archive), **Electrum + txindex in-process**.

- **~200 GiB** hot pin/annotate set (schema 17); **~700 GiB** with cold `seqsigwit` —
- **~200 GiB** hot pin/annotate set (schema 17); **~730 GiB** with the cold append-only files (`seqsigwit` ~486, `txstat` ~11, `input` ~29) —
census in [`SCHEMA.md`](./SCHEMA.md), `--sh-index` costs in [`OPERATOR.md`](./OPERATOR.md)
- **Under ~30 h** IBD on a laptop-class host with **`--milestone 0`**
- **Modest RAM** during sync — no multi‑GiB `dbcache` pause
Expand Down
18 changes: 13 additions & 5 deletions SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,11 +225,12 @@ itself changed.
seqsigwit.body / seqsigwit.loc / seqsigwit.off / seqsigwit.loc.ovf # Class A inputs+witness (cold loc)
seqsigwit.prune # optional: u32 LE pruneheight sidecar (`--prune-seqsigwit`; missing = off)
seqsigwit.window/ # optional prune window: one {height}.bin per kept height
seqsigwit.reloc # optional: seqsigwit lives under --datadir-cold/store
seqsigwit.reloc # optional: append-only files live under --datadir-cold/store
spent.body # sole-spender 8 B × n_out; leftover spent.off unlinked
tx.body / tx.idx.* # schema ≤14 packed (refused if non-empty)
txid.body # dense create_fk-ordered txids (schema 13+)
txstat.body / txstat.ovf / txstat.blk # 8 B/create ULEB econ + per-header tails (schema 25)
input.loc / input.off / input.body # spender → parent edges (schema 22–23)
tx.head/ # meta + open OA NNNNNN; sealed NNNNNN.mphf|.fuse8
spent.ovf # multi-spender overflow (was spenders.body)
confirmed.body # Class C: height → header_fk
Expand Down Expand Up @@ -257,12 +258,19 @@ itself changed.
<datadir-cold>/ # only when --datadir-cold is set
store/
seqsigwit.body / seqsigwit.loc / seqsigwit.off / seqsigwit.loc.ovf
txstat.body / txstat.ovf / txstat.blk
input.loc / input.off / input.body
blockfilter.idx/ blockfilter.body/ # when --block-filter-index has created them
sp_tweaks.idx/ sp_tweaks.body/ # when --sp-tweaks has created them
```

`--datadir` holds both stems by default. `--datadir-cold PATH` places only
`seqsigwit.body` + `seqsigwit.loc` (and `seqsigwit.off` / `seqsigwit.loc.ovf`) under `PATH/store/`
(and writes `seqsigwit.reloc` in the hot store). Pin / SH / spend-annotate stay on
the hot volume.
`--datadir` holds every file by default. `--datadir-cold PATH` places the
files the IBD pin path does not read under `PATH/store/` and writes
`seqsigwit.reloc` in the hot store: `seqsigwit.*`, `txstat.*`, `input.*`,
and, once those indexes exist, `blockfilter.*` and `sp_tweaks.*`. Pin / SH /
spend-annotate stay on the hot volume. `--prune-seqsigwit` is separate: it
drops historical `seqsigwit` below tip−288 and keeps that window on the hot
store. `txstat`, `input`, block filters, and tweaks are not pruned.

**Height → txs:** `confirmed[h]` → `header_fk` → contiguous Class A range
`[header_txs_first[h−1], header_txs_first[h−1] + header_txs_count[h−1])`.
Expand Down
4 changes: 3 additions & 1 deletion SCHEMA_HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ unstamped). `input.body` is 8 B per input: parent `create_fk` u40 LE
per-header blob (`txstat.ovf` + `txstat.blk`). `seqsigwit.*` is the renamed
`inwit` stem (sequence, scriptSig, witness); open renames the files in place.
`txstat.*` and `input.*` sit next to `seqsigwit` (cold when `--datadir-cold`
is set). Open with no `input.loc` and a matching `seqsigwit` count backfills
is set). `blockfilter.*` and `sp_tweaks.*` use that same directory once
enabled. That placement is not a schema bump, and neither index is pruned.
Open with no `input.loc` and a matching `seqsigwit` count backfills
parent edges from `seqsigwit` prevouts. A four-ULEB cell that started with
`n_in` is not detected; resync that experimental datadir. Occupied 24
rewrites `meta` and extends zeros to `create.loc` count. `txout.body` is not
Expand Down
2 changes: 1 addition & 1 deletion TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ A cell that names a crate-local test describes the witness that exists today. It
| ID | Layer | Description |
|----|-------|-------------|
| `overlay_config` | Node CLI + net | In-process onion / I2P / cjdns matrix: `onlynet`, proxy, SAM, reachable, accept-incoming, one parse or learn check per network, `--only-net` dial candidates, no cjdns dial until reachable, and one `peers` file round trip for all three. Live Tor, i2pd, and cjdns stay in overlay-functional. |
| `node_cli_and_surface_smoke` | Lifecycle/CLI | Networks, `run_node`, config errors, CLI flags (incl. `--conf`, `--peer-timeout=0` refuse / `=1` smoke, unknown conf key ignored, `min_relay_tx_fee=-1` and `network=nope` conf fail), dropped Core `--rpcuser`/`--rpcpassword`/`--rpcport`/`--rpcconnect`/`-rpcport` refuse, help/version (`rbitcoin-cli` help and version do not dial). Usage errors exit 2 before a datadir is touched: unknown flags, missing or bad values, and every concatenated, one-dash, or Core spelling of a native kebab flag. A missing conf, a bad line, a bad `log_level`, conf `rpcuser`/`rpcpassword`, and `max_outbound=0` exit 2 and open no store. Non-hex `--min-chain-work`, a signet challenge or block time off a custom signet, and `--sp-tweaks` with `--prune-seqsigwit` exit 1. One smoke takes the native flag set together; bare network conf lines parse and CLI `--datadir` wins over conf `datadir`; `--datadir-cold` puts seqsigwit on the cold store; a custom signet smokes. Signet: genesis header plus height-1 BIP325 connect. The assembled `NodeConfig` values and help text no surface reports are the node-crate `operator_conf_and_argv` (`--bytes-per-sigop`, `--block-reserved-sigops`, conf `sp_tweaks` together with `prune_seqsigwit`, conf `sp_tweaks=1` at the default dust and `sp_tweaks_dust` of 546, 0, or a non-numeric value, and Electrum or Esplora with and without `--sh-index` plus `--sh-index` alone) |
| `node_cli_and_surface_smoke` | Lifecycle/CLI | Networks, `run_node`, config errors, CLI flags (incl. `--conf`, `--peer-timeout=0` refuse / `=1` smoke, unknown conf key ignored, `min_relay_tx_fee=-1` and `network=nope` conf fail), dropped Core `--rpcuser`/`--rpcpassword`/`--rpcport`/`--rpcconnect`/`-rpcport` refuse, help/version (`rbitcoin-cli` help and version do not dial). Usage errors exit 2 before a datadir is touched: unknown flags, missing or bad values, and every concatenated, one-dash, or Core spelling of a native kebab flag. A missing conf, a bad line, a bad `log_level`, conf `rpcuser`/`rpcpassword`, and `max_outbound=0` exit 2 and open no store. Non-hex `--min-chain-work`, a signet challenge or block time off a custom signet, and `--sp-tweaks` with `--prune-seqsigwit` exit 1. One smoke takes the native flag set together; bare network conf lines parse and CLI `--datadir` wins over conf `datadir`; `--datadir-cold` puts seqsigwit, txstat, and input on the cold store; a custom signet smokes. Signet: genesis header plus height-1 BIP325 connect. The assembled `NodeConfig` values and help text no surface reports are the node-crate `operator_conf_and_argv` (`--bytes-per-sigop`, `--block-reserved-sigops`, conf `sp_tweaks` together with `prune_seqsigwit`, conf `sp_tweaks=1` at the default dust and `sp_tweaks_dust` of 546, 0, or a non-numeric value, and Electrum or Esplora with and without `--sh-index` plus `--sh-index` alone) |
| `three_stage_confirm_and_parent_pin_surface` | Consensus+query | Split load→scripts→write of pad+spend from genesis (header-plan BIP68 MTP); parent pin; load ready timeout/cancel; instance-owned `last_write` / `last_pin` / `take_window` meters (a second engine's window stays empty); txstat fee / size / weight from the load assemble, restamp and its refuses; same-run create then spend; 546-shaped 2-vout merge + same-block chain + cross-batch head resolve; an already-at-height retry finishes a spend annotate |
| `mempool_under_pressure` | Mempool + RPC (crate) | One entry in `orphanage`, `accept`, `tx_relay`, and `methods_tests`: orphan reserve and expiry, sigops before script, rolling fee floor, cluster cap, parked min-relay orphan, and the package RPC rejects (unsorted, missing inputs, conflict, min-relay parent with maxfeerate child). |
| `sigop_adjusted_size_budget_and_reopen` | Mempool (crate) | One empty `ActiveMempool`: sigop-adjusted vsize (boundary, min relay, full-pool floor, RBF, package and 1p1c), the raw-weight cluster limit, the shared block sigop budget, and sigop cost plus bytes-per-sigop and reserve overlays across reopen and compact. Each beat restores an empty pool. |
Expand Down
8 changes: 8 additions & 0 deletions changelog.d/cold-index-files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Changed

- `--datadir-cold` is the append-only volume: `seqsigwit`, `txstat`,
`input`, and, once enabled, `blockfilter` and `sp_tweaks`.
`--prune-seqsigwit` still only drops historical seqsigwit and keeps
its 288-height window on the hot store. A split datadir that still
has `blockfilter.*` or `sp_tweaks.*` on the hot store refuses to open
until those directories are moved next to seqsigwit.
6 changes: 4 additions & 2 deletions crates/rbitcoin-node/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -375,8 +375,10 @@ SV2: --sv2-tp-listen ADDR serves the Stratum v2 Template Distribution Protocol (
--sv2-tp-fee-delta SATS pushes a same-tip template once its fees gain SATS over the last one sent (default 1000).\n\
--sv2-tp-template-interval SECS checks for that gain every SECS, 1 to 86400 (default 5).\n\
RPC: --rpc unix socket {{datadir}}/rpc.sock; --rpc-listen [ADDR] adds TCP (default 127.0.0.1 and Core-matching port). Token {{datadir}}/rpc.token (Bearer); --rpc-cookie-file opts TCP into Core cookie HTTP Basic. No --rpcuser. --rest turns on unauthenticated /rest/ on those listeners (off unless set).\n\
Cold files: --datadir-cold PATH puts Class A seqsigwit.body/idx under PATH/store (HDD).\n\
Default (flag omitted): hot and cold files both live under --datadir.\n\
Cold files: --datadir-cold PATH puts append-only IBD files under PATH/store\n\
(seqsigwit, txstat, input, and blockfilter / sp_tweaks once enabled).\n\
Default (flag omitted): those files live under --datadir. --prune-seqsigwit\n\
is separate: it drops old seqsigwit and keeps a 288-height window on the hot store.\n\
Conf: --conf FILE (snake_case key=value; CLI kebab overrides conf). See OPERATOR.md and docs/rpc.md.\n\
Advanced debug/IO knobs remain RBITCOIN_* env (not required for normal sync; preserved if CLI omits).\n\
IBD densify: up to 1024 concurrent getdata, max 16 in transit per peer.\n\
Expand Down
2 changes: 1 addition & 1 deletion crates/rbitcoin-query/src/block_filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ impl Query {
/// disconnect and its filter truncate, or a torn slot).
pub fn set_block_filter_index(&self, enabled: bool) -> Result<(), QueryError> {
if enabled && self.block_filters.get().is_none() {
let table = BlockFilterTable::open_or_create(self.store.path())?;
let table = BlockFilterTable::open_or_create(self.store.cold_files_dir())?;
self.trim_block_filters_to_best_chain(&table)?;
let _ = self.block_filters.set(table);
}
Expand Down
5 changes: 3 additions & 2 deletions crates/rbitcoin-query/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -440,8 +440,9 @@ impl Query {
let _ = store.strong_tx.flush();
}
let store_path = store.path().to_path_buf();
let (sp_tweaks, sptweaks_origin) = if SpTweaksTable::files_present(&store_path) {
match SpTweaksTable::open(&store_path) {
let index_dir = store.cold_files_dir().to_path_buf();
let (sp_tweaks, sptweaks_origin) = if SpTweaksTable::files_present(&index_dir) {
match SpTweaksTable::open(&index_dir) {
Ok(t) => {
let origin = t.origin_height().0;
(Some(t), origin)
Expand Down
27 changes: 26 additions & 1 deletion crates/rbitcoin-query/src/sp_tweaks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ impl Query {
fn ensure_sp_tweaks(&self, origin: Height) -> Result<(), QueryError> {
let mut g = self.sp_tweaks.lock().unwrap_or_else(|e| e.into_inner());
if g.is_none() {
let t = SpTweaksTable::open_or_create(self.store.path(), origin)?;
let t = SpTweaksTable::open_or_create(self.store.cold_files_dir(), origin)?;
self.repair_sp_tweaks(&t)?;
*g = Some(t);
self.sptweaks_origin
Expand Down Expand Up @@ -539,6 +539,31 @@ mod tests {
crate::testutil::tiny_query_labeled("sptweaks")
}

#[test]
fn split_store_puts_blockfilter_and_sp_tweaks_on_cold() {
let root = crate::testutil::TempDir::labeled("cold-indexes").unwrap();
let hot = root.path().join("hot");
let cold = root.path().join("cold");
let layout = rbitcoin_store::StoreLayout::tiny(&hot).with_cold_dir(&cold);
let q = Query::open_or_create_layout(layout.clone()).unwrap();
q.set_block_filter_index(true).unwrap();
q.set_sptweaks_enabled(true, Height(0)).unwrap();
assert!(cold.join("blockfilter.idx").is_dir());
assert!(cold.join("blockfilter.body").is_dir());
assert!(cold.join("sp_tweaks.idx").is_dir());
assert!(cold.join("sp_tweaks.body").is_dir());
assert!(!hot.join("blockfilter.idx").exists());
assert!(!hot.join("blockfilter.body").exists());
assert!(!hot.join("sp_tweaks.idx").exists());
assert!(!hot.join("sp_tweaks.body").exists());
drop(q);
let q = Query::open_or_create_layout(layout).unwrap();
assert_eq!(q.sptweaks_next_height(), Some(Height(0)));
q.set_block_filter_index(true).unwrap();
assert!(!hot.join("blockfilter.idx").exists());
assert!(cold.join("blockfilter.idx").join("meta").is_file());
}

#[test]
fn put_and_load_noop_when_disabled() {
let (dir, q) = tmp_q();
Expand Down
2 changes: 1 addition & 1 deletion crates/rbitcoin-rpc/src/methods/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -695,7 +695,7 @@ const NAMED_HELP: &[(&str, &str)] = &[
initialblockdownload is relay-inhibited after densify (min-chain-work +\n\
max-tip-age), not still catching up. chainwork is summed header work\n\
(regtest 2/block). size_on_disk is a walk of store file lengths (plus\n\
cold seqsigwit when split). verificationprogress is blocks/headers (1.0 when\n\
the cold store when split). verificationprogress is blocks/headers (1.0 when\n\
headers is 0).",
),
(
Expand Down
94 changes: 87 additions & 7 deletions crates/rbitcoin-store/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,23 @@ use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};

/// Sidecar in the hot `{datadir}/store`: `seqsigwit.body` / `seqsigwit.loc` live under
/// `{datadir-cold}/store`. Presence-only (path always comes from the operator).
/// Sidecar in the hot `{datadir}/store`. Present when the append-only files live
/// under `{datadir-cold}/store`. Presence-only (the path comes from the operator).
pub const SEQSIGWIT_RELOC_NAME: &str = "seqsigwit.reloc";

/// Index directories that live next to seqsigwit when the store is split.
const COLD_INDEX_DIRS: &[&str] = &[
"blockfilter.idx",
"blockfilter.body",
"sp_tweaks.idx",
"sp_tweaks.body",
];

/// Where a store’s files live, plus open-time head geometry.
///
/// `dir` is `{datadir}/store`. When `cold_dir` is set and distinct, Class A
/// `seqsigwit.body` + `seqsigwit.loc` live there (bulk / HDD). Everything else stays
/// `dir` is `{datadir}/store`. When `cold_dir` is set and distinct, the
/// append-only IBD files live there: `seqsigwit.*`, `txstat.*`, `input.*`,
/// and (once enabled) `blockfilter.*` and `sp_tweaks.*`. The pin set stays
/// in `dir`.
///
/// [`Self::single`] / [`Self::with_cold`] are **Mainnet** scale (production).
Expand Down Expand Up @@ -179,6 +188,29 @@ pub(crate) fn rename_legacy_inwit_files(dir: &Path) -> Result<(), StoreError> {
Ok(())
}

fn refuse_cold_index_dirs(hot: &Path, cold: &Path) -> Result<(), StoreError> {
for name in COLD_INDEX_DIRS {
let on_hot = hot.join(name);
let on_cold = cold.join(name);
if !on_hot.is_dir() {
continue;
}
if on_cold.exists() {
return Err(StoreError::Layout(format!(
"{name} exists in both {} and {}; keep it only under the cold store",
hot.display(),
cold.display()
)));
}
return Err(StoreError::Layout(format!(
"{name} is still in {}; move it next to seqsigwit under {}",
hot.display(),
cold.display()
)));
}
Ok(())
}

fn seqsigwit_files_present(dir: &Path) -> bool {
dir.join("seqsigwit.body").exists()
|| dir.join("seqsigwit.loc").exists()
Expand Down Expand Up @@ -262,7 +294,7 @@ fn write_seqsigwit_reloc(hot: &Path) -> Result<(), StoreError> {
/// Top-level store handle for a datadir `store/` directory.
pub struct Store {
path: PathBuf,
/// `{datadir-cold}/store` when seqsigwit is split; `None` = seqsigwit in [`Self::path`].
/// `{datadir-cold}/store` when append-only files are split; `None` = they live in [`Self::path`].
cold_path: Option<PathBuf>,
head_scale: HeadScale,
pub headers: HeaderTable,
Expand Down Expand Up @@ -441,6 +473,9 @@ impl Store {
crate::scripthash::unlink_scripthash_run_leftovers(&path)?;
open_layout_rewrite_current(&path, meta_ver)?;
let seqsigwit_dir = resolve_seqsigwit_dir(&layout)?;
if layout.is_split() {
refuse_cold_index_dirs(&path, &seqsigwit_dir)?;
}
let txs = TxTable::open_seqsigwit(&path, &seqsigwit_dir, layout.open_opts())?;
if layout.is_split() {
write_seqsigwit_reloc(&path)?;
Expand Down Expand Up @@ -499,13 +534,20 @@ impl Store {
&self.path
}

/// Cold store directory when seqsigwit is split (`{datadir-cold}/store`).
/// Cold store directory when the append-only files are split
/// (`{datadir-cold}/store`).
pub fn cold_path(&self) -> Option<&Path> {
self.cold_path.as_deref()
}

/// Where IBD append-only files live. The cold store when split, otherwise
/// [`Self::path`].
pub fn cold_files_dir(&self) -> &Path {
self.cold_path.as_deref().unwrap_or(&self.path)
}

/// Sum of regular file lengths under the hot store and, when split, the
/// cold seqsigwit directory. Used by `getblockchaininfo.size_on_disk`.
/// cold store. Used by `getblockchaininfo.size_on_disk`.
pub fn datadir_bytes(&self) -> u64 {
let mut n = dir_file_bytes(&self.path);
if let Some(cold) = &self.cold_path {
Expand Down Expand Up @@ -4221,6 +4263,44 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}

#[test]
fn split_refuses_blockfilter_left_in_hot() {
let root = tmp();
let hot = root.join("hot");
let cold = root.join("cold");
Store::create_layout(StoreLayout::tiny(&hot).with_cold_dir(&cold)).unwrap();
std::fs::create_dir_all(hot.join("blockfilter.idx")).unwrap();
match Store::open_layout(StoreLayout::tiny(&hot).with_cold_dir(&cold)) {
Ok(_) => panic!("must refuse blockfilter left on the hot store"),
Err(err) => {
let msg = err.to_string();
assert!(msg.contains("blockfilter.idx"), "{msg}");
assert!(msg.contains("move"), "{msg}");
assert!(msg.contains(cold.to_str().unwrap()), "{msg}");
}
}
let _ = std::fs::remove_dir_all(&root);
}

#[test]
fn split_refuses_sp_tweaks_in_both_dirs() {
let root = tmp();
let hot = root.join("hot");
let cold = root.join("cold");
Store::create_layout(StoreLayout::tiny(&hot).with_cold_dir(&cold)).unwrap();
std::fs::create_dir_all(hot.join("sp_tweaks.body")).unwrap();
std::fs::create_dir_all(cold.join("sp_tweaks.body")).unwrap();
match Store::open_layout(StoreLayout::tiny(&hot).with_cold_dir(&cold)) {
Ok(_) => panic!("must refuse sp_tweaks in both stores"),
Err(err) => {
let msg = err.to_string();
assert!(msg.contains("sp_tweaks.body"), "{msg}");
assert!(msg.contains("both"), "{msg}");
}
}
let _ = std::fs::remove_dir_all(&root);
}

#[test]
fn split_roundtrip_create_fk_and_seqsigwit_range() {
let root = tmp();
Expand Down
Loading
Loading