iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 2acd5ddbdd6349b081886280cd7d0b8280e03de7
parent 2bf3c97e786d97129c396a12a1e3e2f28978d97d
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Tue, 25 Aug 2026 15:17:59 +0200

Archive legacy chain databases on startup

Diffstat:
MREADME.md | 9+++++----
Mdocs/operator-playbooks.md | 15++++++++-------
Mdocs/protocol.md | 2+-
Msrc/adapters/chain_store.rs | 230+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/adapters/chain_store/compact.rs | 9+++++++++
5 files changed, 250 insertions(+), 15 deletions(-)

diff --git a/README.md b/README.md @@ -181,10 +181,11 @@ changed in place. The five-slot committee is also a consensus reset: volumes created by the earlier three-slot protocol must likewise be recreated. The next release also introduces compact snapshot v6 and deliberately provides -no old-chain migration. All nodes must participate in the coordinated network -reset: preserve wallet/configuration files, remove or archive the old chain and -UI databases, and create or join the new genesis. See the operator playbooks for -non-Compose reset commands. +no old-chain migration. On startup, legacy chain databases are archived with a +`.pre-v6` suffix and replaced by a fresh database; wallet and configuration +files are retained. All nodes must still participate in the coordinated network +reset and create or join the agreed new genesis. See the operator playbooks for +details and manual archive commands. Stop the network while keeping chain data: diff --git a/docs/operator-playbooks.md b/docs/operator-playbooks.md @@ -119,17 +119,18 @@ Avoid: ## Coordinated Snapshot V6 Reset -The next release accepts compact local snapshot format v6 only and does not migrate earlier chain history. This is a planned consensus/network reset, not a corrupted-database incident. All operators must agree on the release, genesis, network identity, bootnodes, and start time before bringing public nodes back online. +The next release accepts compact local snapshot format v6 only and does not migrate earlier chain history. This is a planned consensus/network reset, not a corrupted-database incident. At startup, the node detects the legacy JSON schema and compact snapshot versions older than v6, checkpoints the database, archives it as `chain.sqlite3.pre-v6` (or the next available numbered suffix), and creates a fresh v6 database. The UI cache is then cleared normally. Wallet and configuration files are left untouched. + +All operators must still agree on the release, genesis, network identity, bootnodes, and start time before bringing public nodes back online. Automatic local archiving does not coordinate genesis. Before upgrading: -1. Stop the node and preserve `chain.sqlite3` if it is needed as historical evidence. -2. Keep `wallet.json` and `config.json`; verify that their backups are readable. -3. Archive or remove only `chain.sqlite3` and `ui_data.sqlite3` from the node's configured data directory. -4. Start exactly one designated node with `--genesis`, record its genesis hash, and publish that hash with the release commit and checksums. -5. Start every other node without `--genesis` and join a trusted published bootnode. +1. Stop the node and back up `wallet.json` and `config.json`; verify that the backups are readable. +2. Start exactly one designated node with a fresh chain database and `--genesis`, record its genesis hash, and publish that hash with the release commit and checksums. +3. Start every other node without `--genesis` and join a trusted published bootnode. Its incompatible chain database is archived automatically. +4. Preserve the generated `.pre-v6` archive if the old chain is needed as historical evidence. -Example for a default data directory, retaining the old databases as evidence: +Manual equivalent for operators who want to choose the archive names before starting: ```sh mv ~/.iuna/chain.sqlite3 ~/.iuna/chain.sqlite3.pre-v6 2>/dev/null || true diff --git a/docs/protocol.md b/docs/protocol.md @@ -330,7 +330,7 @@ The genesis flow bootstraps the mainnet-candidate network with an initial burn t ## Local Chain Persistence And Reset Boundary -The local `chain.sqlite3` database stores one atomically replaced compact snapshot blob plus independently checked tip height and tip hash metadata. Snapshot format v6 is the only accepted local format in the next release; older compact snapshot versions are deliberately not decoded or migrated. +The local `chain.sqlite3` database stores one atomically replaced compact snapshot blob plus independently checked tip height and tip hash metadata. Snapshot format v6 is the only accepted local format in the next release; older compact snapshot versions are deliberately not decoded or migrated. Legacy JSON databases and compact versions older than v6 are checkpointed, renamed with a unique `.pre-v6` suffix, and replaced by a fresh database during startup. This persistence change is paired with a coordinated network reset. Every node must start the next release without its previous chain and UI databases, then either create the agreed new genesis or join a trusted peer on that new chain. Wallet and configuration files are not chain state and should be retained. Detailed recovery and reset commands are in [Operator Failure Playbooks](operator-playbooks.md). diff --git a/src/adapters/chain_store.rs b/src/adapters/chain_store.rs @@ -8,7 +8,7 @@ use anyhow::{Context, Result}; use rusqlite::{Connection, OptionalExtension, params}; use crate::{ - compact::{decode_compact_snapshot, encode_compact_snapshot}, + compact::{decode_compact_snapshot, encode_compact_snapshot, legacy_compact_snapshot_version}, domain::ChainSnapshot, }; @@ -44,6 +44,15 @@ impl SqliteChainStore { })?; } + if let Some(reason) = legacy_chain_reason(&path)? { + let archive_path = archive_legacy_chain(&path)?; + println!( + "archived incompatible chain database ({reason}): {} -> {}", + path.display(), + archive_path.display() + ); + } + let store = Self { path }; store.with_connection_mut(|connection| { connection @@ -171,6 +180,119 @@ PRAGMA synchronous = NORMAL; } } +fn legacy_chain_reason(path: &Path) -> Result<Option<String>> { + if !path.exists() { + return Ok(None); + } + + let connection = Connection::open(path) + .with_context(|| format!("failed to inspect chain database {}", path.display()))?; + connection + .execute_batch("PRAGMA busy_timeout = 5000;") + .context("failed to configure chain database inspection")?; + + let table_exists = connection + .query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'chain_snapshots'", + [], + |_| Ok(()), + ) + .optional() + .context("failed to inspect chain database schema")? + .is_some(); + if !table_exists { + return Ok(None); + } + + let mut columns = connection + .prepare("SELECT name FROM pragma_table_info('chain_snapshots')") + .context("failed to inspect chain snapshot columns")?; + let columns = columns + .query_map([], |row| row.get::<_, String>(0)) + .context("failed to read chain snapshot columns")? + .collect::<rusqlite::Result<Vec<_>>>() + .context("failed to read chain snapshot columns")?; + let has_snapshot_blob = columns.iter().any(|column| column == "snapshot_blob"); + if !has_snapshot_blob && columns.iter().any(|column| column == "snapshot_json") { + return Ok(Some("legacy JSON snapshot schema".to_string())); + } + if !has_snapshot_blob { + return Ok(None); + } + + let snapshot_blob = connection + .query_row( + "SELECT snapshot_blob FROM chain_snapshots WHERE id = 1", + [], + |row| row.get::<_, Vec<u8>>(0), + ) + .optional() + .context("failed to inspect compact chain snapshot version")?; + Ok(snapshot_blob + .as_deref() + .and_then(legacy_compact_snapshot_version) + .map(|version| format!("compact snapshot version {version}"))) +} + +fn archive_legacy_chain(path: &Path) -> Result<PathBuf> { + // Fold committed WAL contents into the database before moving the archive. + // Renaming any remaining sidecars first prevents them from being attached to + // the fresh database if startup is interrupted between the moves. + let connection = Connection::open(path) + .with_context(|| format!("failed to prepare legacy chain database {}", path.display()))?; + connection + .execute_batch("PRAGMA busy_timeout = 5000; PRAGMA wal_checkpoint(TRUNCATE);") + .context("failed to checkpoint legacy chain database before archiving")?; + drop(connection); + + let archive_path = available_archive_path(path); + for suffix in ["-wal", "-shm"] { + let source = path_with_suffix(path, suffix); + if source.exists() { + let destination = path_with_suffix(&archive_path, suffix); + fs::rename(&source, &destination).with_context(|| { + format!( + "failed to archive legacy chain sidecar {} as {}", + source.display(), + destination.display() + ) + })?; + } + } + fs::rename(path, &archive_path).with_context(|| { + format!( + "failed to archive legacy chain database {} as {}", + path.display(), + archive_path.display() + ) + })?; + Ok(archive_path) +} + +fn available_archive_path(path: &Path) -> PathBuf { + for index in 0_u64.. { + let suffix = if index == 0 { + ".pre-v6".to_string() + } else { + format!(".pre-v6.{index}") + }; + let candidate = path_with_suffix(path, &suffix); + if !candidate.exists() + && !path_with_suffix(&candidate, "-wal").exists() + && !path_with_suffix(&candidate, "-shm").exists() + { + return candidate; + } + } + unreachable!("archive suffix counter exhausted") +} + +fn path_with_suffix(path: &Path, suffix: &str) -> PathBuf { + let mut value = path.as_os_str().to_os_string(); + value.push(suffix); + PathBuf::from(value) +} + fn snapshot_tip(snapshot: &ChainSnapshot) -> Option<(u64, String)> { snapshot .blocks @@ -187,13 +309,26 @@ fn unix_ms() -> u64 { #[cfg(test)] mod tests { - use std::collections::BTreeMap; + use std::{collections::BTreeMap, fs}; + use rusqlite::Connection; use tempfile::tempdir; use crate::domain::{ChainSnapshot, GenesisBurn, Ledger, Wallet}; - use super::SqliteChainStore; + use super::{SCHEMA, SqliteChainStore}; + + const LEGACY_JSON_SCHEMA: &str = r#" +CREATE TABLE chain_snapshots ( + id INTEGER PRIMARY KEY CHECK (id = 1), + height INTEGER NOT NULL, + tip_hash TEXT NOT NULL, + snapshot_json TEXT NOT NULL, + updated_at_ms INTEGER NOT NULL +); +INSERT INTO chain_snapshots (id, height, tip_hash, snapshot_json, updated_at_ms) +VALUES (1, 0, 'legacy-tip', '{}', 0); +"#; fn test_snapshot(seed: &str) -> ChainSnapshot { let wallet = Wallet::from_seed(seed); @@ -205,6 +340,95 @@ mod tests { } #[test] + fn open_archives_legacy_json_schema_and_creates_fresh_database() { + let dir = tempdir().unwrap(); + let path = dir.path().join("chain.sqlite3"); + Connection::open(&path) + .unwrap() + .execute_batch(LEGACY_JSON_SCHEMA) + .unwrap(); + + let store = SqliteChainStore::open(&path).unwrap(); + + assert!(store.load().unwrap().is_none()); + let archive = dir.path().join("chain.sqlite3.pre-v6"); + assert!(archive.exists()); + let legacy_json: String = Connection::open(archive) + .unwrap() + .query_row( + "SELECT snapshot_json FROM chain_snapshots WHERE id = 1", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(legacy_json, "{}"); + } + + #[test] + fn open_archives_legacy_compact_snapshot_and_uses_unique_name() { + let dir = tempdir().unwrap(); + let path = dir.path().join("chain.sqlite3"); + let existing_archive = dir.path().join("chain.sqlite3.pre-v6"); + fs::write(&existing_archive, b"existing archive").unwrap(); + let connection = Connection::open(&path).unwrap(); + connection.execute_batch(SCHEMA).unwrap(); + let mut legacy_blob = b"IUNA-SNAPSHOT".to_vec(); + legacy_blob.push(5); + connection + .execute( + r#" +INSERT INTO chain_snapshots (id, height, tip_hash, snapshot_blob, updated_at_ms) +VALUES (1, 0, 'legacy-tip', ?1, 0) +"#, + [&legacy_blob], + ) + .unwrap(); + drop(connection); + + let store = SqliteChainStore::open(&path).unwrap(); + + assert!(store.load().unwrap().is_none()); + assert_eq!(fs::read(existing_archive).unwrap(), b"existing archive"); + let archived_blob: Vec<u8> = Connection::open(dir.path().join("chain.sqlite3.pre-v6.1")) + .unwrap() + .query_row( + "SELECT snapshot_blob FROM chain_snapshots WHERE id = 1", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(archived_blob, legacy_blob); + } + + #[test] + fn open_does_not_archive_corrupt_or_future_compact_snapshots() { + for (name, blob) in [ + ("corrupt", vec![0, 1, 2, 3]), + ("future", [b"IUNA-SNAPSHOT".as_slice(), &[7]].concat()), + ] { + let dir = tempdir().unwrap(); + let path = dir.path().join(format!("{name}.sqlite3")); + let connection = Connection::open(&path).unwrap(); + connection.execute_batch(SCHEMA).unwrap(); + connection + .execute( + r#" +INSERT INTO chain_snapshots (id, height, tip_hash, snapshot_blob, updated_at_ms) +VALUES (1, 0, 'bad-tip', ?1, 0) +"#, + [&blob], + ) + .unwrap(); + drop(connection); + + let store = SqliteChainStore::open(&path).unwrap(); + + assert!(store.load().is_err()); + assert!(!dir.path().join(format!("{name}.sqlite3.pre-v6")).exists()); + } + } + + #[test] fn failed_snapshot_save_keeps_last_committed_chain() { let dir = tempdir().unwrap(); let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); diff --git a/src/adapters/chain_store/compact.rs b/src/adapters/chain_store/compact.rs @@ -15,6 +15,15 @@ const MAX_COMPACT_SNAPSHOT_BLOCKS: usize = 10_000; const MAX_COMPACT_VEC_ITEMS: usize = 10_000; const MAX_COMPACT_BYTE_FIELD: usize = 8 * 1024 * 1024; +pub(super) fn legacy_compact_snapshot_version(bytes: &[u8]) -> Option<u8> { + let version_offset = COMPACT_SNAPSHOT_MAGIC.len(); + if !bytes.starts_with(COMPACT_SNAPSHOT_MAGIC) || bytes.len() <= version_offset { + return None; + } + let version = bytes[version_offset]; + (version < COMPACT_SNAPSHOT_VERSION).then_some(version) +} + #[derive(Clone, Debug, Default)] struct EncodeTables { addresses: BTreeMap<String, u64>,