use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use thiserror::Error;
use super::{BootstrapState, NetworkKey};
const MAGIC: [u8; 4] = *b"TPBS";
const VERSION: u8 = 1;
pub const BYPASS_ENV: &str = "TAPE_NO_BOOTSTRAP_CACHE";
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Error)]
pub enum StoreError {
#[error("bootstrap cache io: {0}")]
Io(#[from] std::io::Error),
#[error("bootstrap cache encode: {0}")]
Encode(String),
#[error("bootstrap cache path has no parent directory")]
NoParent,
}
pub struct BootstrapStore {
path: Option<PathBuf>,
}
impl BootstrapStore {
pub fn new(path: impl Into<PathBuf>) -> Self {
match std::env::var_os(BYPASS_ENV) {
Some(_) => Self::disabled(),
None => Self {
path: Some(path.into()),
},
}
}
pub fn disabled() -> Self {
Self { path: None }
}
pub fn path(&self) -> Option<&Path> {
self.path.as_deref()
}
pub fn is_enabled(&self) -> bool {
self.path.is_some()
}
pub fn load(&self, expected: &NetworkKey) -> Option<BootstrapState> {
let state = self.load_any()?;
match &state.network == expected {
true => Some(state),
false => None,
}
}
pub fn load_any(&self) -> Option<BootstrapState> {
let bytes = fs::read(self.path.as_ref()?).ok()?;
let body = bytes.strip_prefix(&MAGIC)?;
let (version, body) = body.split_first()?;
if *version != VERSION {
return None;
}
wincode::deserialize(body).ok()
}
pub fn save(&self, state: &BootstrapState) -> Result<(), StoreError> {
let Some(path) = self.path.as_deref() else {
return Ok(());
};
let parent = path.parent().ok_or(StoreError::NoParent)?;
fs::create_dir_all(parent)?;
let encoded =
wincode::serialize(state).map_err(|error| StoreError::Encode(format!("{error:?}")))?;
let mut bytes = Vec::with_capacity(MAGIC.len() + 1 + encoded.len());
bytes.extend_from_slice(&MAGIC);
bytes.push(VERSION);
bytes.extend_from_slice(&encoded);
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let temp = path.with_extension(format!("tmp.{}.{}", std::process::id(), sequence));
fs::write(&temp, &bytes)?;
match fs::rename(&temp, path) {
Ok(()) => Ok(()),
Err(error) => {
let _ = fs::remove_file(&temp);
Err(error.into())
}
}
}
pub fn clear(&self) -> Result<(), StoreError> {
let Some(path) = self.path.as_deref() else {
return Ok(());
};
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
}
}
}
#[cfg(test)]
mod tests {
use std::io::Write;
use tape_crypto::Address;
use tape_crypto::hash::Hash;
use super::*;
use crate::bootstrap::{PeerHealth, Prediction};
use tape_core::types::{EpochDuration, EpochNumber};
fn key() -> NetworkKey {
NetworkKey {
program_id: Address::new_unique(),
genesis: Hash([3u8; 32]),
}
}
fn populated(network: NetworkKey) -> BootstrapState {
let mut state = BootstrapState::new(network);
state.fetched_at = 1_700_000_000;
state.prediction = Some(Prediction {
epoch: EpochNumber(9),
total_groups: 50,
epoch_start: 1_699_999_000,
epoch_duration: EpochDuration(3_600),
});
state.peer_mut(Address::new_unique()).record_success(5, 21.0);
let flaky = Address::new_unique();
for tick in 0..4 {
state.peer_mut(flaky).record_failure(tick);
}
state
}
fn temp_path(name: &str) -> PathBuf {
let mut path = std::env::temp_dir();
path.push(format!("tape-bootstrap-{}-{}", std::process::id(), name));
path
}
#[test]
fn round_trip() {
let path = temp_path("round-trip");
let store = BootstrapStore::new(&path);
let network = key();
let state = populated(network);
store.save(&state).expect("save");
let loaded = store.load(&network).expect("load");
assert_eq!(loaded, state);
let flaky = loaded
.peers
.iter()
.find(|record| record.consecutive_failures == 4)
.expect("flaky peer");
assert!(matches!(flaky.health, PeerHealth::Quarantined { .. }));
store.clear().expect("clear");
assert!(store.load(&network).is_none());
}
#[test]
fn wrong_network_is_ignored() {
let path = temp_path("wrong-network");
let store = BootstrapStore::new(&path);
let network = key();
store.save(&populated(network)).expect("save");
assert!(store.load(&key()).is_none());
assert!(store.load_any().is_some());
store.clear().expect("clear");
}
#[test]
fn foreign_file_is_ignored() {
let path = temp_path("foreign");
let mut file = fs::File::create(&path).expect("create");
file.write_all(b"not a tape cache at all").expect("write");
drop(file);
let store = BootstrapStore::new(&path);
assert!(store.load_any().is_none());
store.clear().expect("clear");
}
#[test]
fn wrong_version_is_ignored() {
let path = temp_path("version");
let mut bytes = Vec::new();
bytes.extend_from_slice(&MAGIC);
bytes.push(VERSION + 1);
bytes.extend_from_slice(b"whatever follows");
fs::write(&path, &bytes).expect("write");
let store = BootstrapStore::new(&path);
assert!(store.load_any().is_none());
store.clear().expect("clear");
}
#[test]
fn truncated_file_is_ignored() {
let path = temp_path("truncated");
let store = BootstrapStore::new(&path);
let network = key();
store.save(&populated(network)).expect("save");
let bytes = fs::read(&path).expect("read");
fs::write(&path, &bytes[..bytes.len() / 2]).expect("truncate");
assert!(store.load(&network).is_none());
store.clear().expect("clear");
}
#[test]
fn disabled_store_is_a_noop() {
let store = BootstrapStore::disabled();
let network = key();
assert!(!store.is_enabled());
assert!(store.path().is_none());
store.save(&populated(network)).expect("save");
assert!(store.load(&network).is_none());
store.clear().expect("clear");
}
#[test]
fn concurrent_writes_leave_a_readable_file() {
let path = temp_path("concurrent");
let network = key();
let first = populated(network);
let mut second = populated(network);
second.fetched_at = 1_800_000_000;
std::thread::scope(|scope| {
for state in [&first, &second] {
scope.spawn(|| {
let store = BootstrapStore::new(&path);
for _ in 0..20 {
store.save(state).expect("save");
}
});
}
});
let store = BootstrapStore::new(&path);
let loaded = store.load(&network).expect("load");
assert!(loaded.fetched_at == first.fetched_at || loaded.fetched_at == second.fetched_at);
store.clear().expect("clear");
}
}