Skip to content

Commit 0610529

Browse files
committed
fix(node): atomic + fsync'd write_meta for crash-recovery sidecar
The recovery meta sidecar is the gate G11 source-of-truth for the "converge to last fully-committed tip after kill -9" claim. Replace std::fs::write with the standard write-tmp-then-rename idiom plus a best-effort directory fsync, so a power loss between the byte-write and the inode-write cannot leave the sidecar partially populated. Op: correct Restores: spec:G11 — crash recovery converges to last fully-committed tip
1 parent bbd6514 commit 0610529

2 files changed

Lines changed: 54 additions & 3 deletions

File tree

crates/node/src/crash_recovery.rs

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,36 @@ pub fn read_meta(state: &NodeState) -> Result<Option<Meta>> {
4747

4848
/// Overwrites the recovery sidecar with `meta`.
4949
pub fn write_meta(state: &NodeState, meta: &Meta) -> Result<()> {
50+
use std::io::Write as _;
51+
5052
let path = meta_path(state);
53+
let dir = path.parent().unwrap_or_else(|| std::path::Path::new("."));
54+
let tmp = path.with_extension("json.tmp");
5155
let json = serde_json::to_vec_pretty(meta)
5256
.with_context(|| format!("encode recovery meta {}", path.display()))?;
53-
std::fs::write(&path, json)
54-
.with_context(|| format!("write recovery meta {}", path.display()))?;
57+
58+
{
59+
let mut file = std::fs::OpenOptions::new()
60+
.create(true)
61+
.truncate(true)
62+
.write(true)
63+
.open(&tmp)
64+
.with_context(|| format!("open tmp recovery meta {}", tmp.display()))?;
65+
file.write_all(&json)
66+
.with_context(|| format!("write tmp recovery meta {}", tmp.display()))?;
67+
file.sync_all()
68+
.with_context(|| format!("fsync tmp recovery meta {}", tmp.display()))?;
69+
}
70+
71+
std::fs::rename(&tmp, &path)
72+
.with_context(|| format!("atomic rename recovery meta {}", path.display()))?;
73+
// Best-effort directory fsync. POSIX allows the rename to be re-ordered until
74+
// the parent directory's inode is synced. Failing the fsync (e.g. on filesystems
75+
// that don't support it) is non-fatal — the rename already happened.
76+
if let Ok(dir_handle) = std::fs::File::open(dir) {
77+
let _ = dir_handle.sync_all();
78+
}
79+
5580
Ok(())
5681
}
5782

crates/node/tests/crash_recovery.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//! Integration tests for the bitcoin-rs node.
22
33
use anyhow::{Context as _, Result};
4-
use bitcoin_rs_node::{Config, Network, crash_recovery, state::NodeState};
4+
use bitcoin_rs_node::{crash_recovery, state::NodeState, Config, Network};
55

66
#[test]
77
fn recovery_replays_from_last_committed_height_to_tip() -> Result<()> {
@@ -28,3 +28,29 @@ fn recovery_replays_from_last_committed_height_to_tip() -> Result<()> {
2828
assert_eq!(restarted.replayed_heights(), vec![8, 9, 10]);
2929
Ok(())
3030
}
31+
32+
#[test]
33+
fn recovery_meta_write_leaves_readable_sidecar_without_tmp() -> Result<()> {
34+
let temp = tempfile::tempdir()?;
35+
let mut config = Config::default_for_network(Network::Regtest);
36+
config.data_dir = temp.path().join("node");
37+
config.storage_backend = "redb".to_owned();
38+
config.p2p_listen.clear();
39+
40+
let meta_path = config.data_dir.join("recovery_meta.json");
41+
let tmp_path = config.data_dir.join("recovery_meta.json.tmp");
42+
{
43+
let state = NodeState::open(config)?;
44+
state.record_synthetic_block_for_recovery(3)?;
45+
}
46+
47+
assert!(meta_path.exists());
48+
let bytes = std::fs::read(&meta_path)
49+
.with_context(|| format!("read recovery metadata {}", meta_path.display()))?;
50+
let meta: crash_recovery::Meta = serde_json::from_slice(&bytes)
51+
.with_context(|| format!("parse recovery metadata {}", meta_path.display()))?;
52+
assert_eq!(meta.height, 3);
53+
assert_eq!(meta.last_committed_height, 3);
54+
assert!(!tmp_path.exists());
55+
Ok(())
56+
}

0 commit comments

Comments
 (0)