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:
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>,