Skip to content

Commit 54f3fc9

Browse files
committed
feat: add sqlite persistence backend
1 parent 518c70a commit 54f3fc9

3 files changed

Lines changed: 196 additions & 155 deletions

File tree

main.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
import argparse
2020
import asyncio
2121
import logging
22-
import os
2322
import re
2423
import sys
2524

@@ -294,11 +293,12 @@ async def run_node(port: int, host: str, connect_to: str | None, fund: int, data
294293

295294
# Load existing chain from disk, or start fresh
296295
chain = None
297-
if datadir and os.path.exists(os.path.join(datadir, "data.json")):
296+
if datadir:
298297
try:
299-
from minichain.persistence import load
300-
chain = load(datadir)
301-
logger.info("Restored chain from '%s'", datadir)
298+
from minichain.persistence import load, persistence_exists
299+
if persistence_exists(datadir):
300+
chain = load(datadir)
301+
logger.info("Restored chain from '%s'", datadir)
302302
except FileNotFoundError as e:
303303
logger.warning("Could not load saved chain: %s — starting fresh", e)
304304
except ValueError as e:

minichain/persistence.py

Lines changed: 139 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,56 @@
11
"""
2-
Chain persistence: save and load the blockchain and state to/from JSON.
2+
Chain persistence: save and load the blockchain and state to/from SQLite.
33
44
Design:
5-
- blockchain.json holds the full list of serialised blocks
6-
- state.json holds the accounts dict (includes off-chain credits)
5+
- data.db holds the full chain snapshot, account state, and small metadata.
6+
- legacy data.json snapshots can still be loaded for backward compatibility.
77
8-
Both files are written atomically (temp → rename) to prevent corruption
9-
on crash. On load, chain integrity is verified before the data is trusted.
10-
11-
Usage:
8+
The public API intentionally stays the same:
129
from minichain.persistence import save, load
1310
1411
save(blockchain, path="data/")
1512
blockchain = load(path="data/")
1613
"""
1714

15+
from __future__ import annotations
16+
1817
import json
19-
import os
20-
import tempfile
2118
import logging
22-
import copy
19+
import os
20+
import sqlite3
21+
from typing import Any
2322

2423
from .block import Block
2524
from .chain import Blockchain, validate_block_link_and_hash
2625

2726
logger = logging.getLogger(__name__)
2827

29-
_DATA_FILE = "data.json"
28+
_DB_FILE = "data.db"
29+
_LEGACY_DATA_FILE = "data.json"
30+
31+
32+
def persistence_exists(path: str = ".") -> bool:
33+
"""Return True if a SQLite or legacy JSON snapshot exists inside *path*."""
34+
return os.path.exists(os.path.join(path, _DB_FILE)) or os.path.exists(
35+
os.path.join(path, _LEGACY_DATA_FILE)
36+
)
3037

3138

3239
# ---------------------------------------------------------------------------
3340
# Public API
3441
# ---------------------------------------------------------------------------
3542

36-
def save(blockchain: Blockchain, path: str = ".") -> None:
37-
"""
38-
Persist the blockchain and account state to a JSON file inside *path*.
3943

40-
Uses atomic write (write-to-temp → rename) with fsync so a crash mid-save
41-
never corrupts the existing file. Chain and state are saved together to
42-
prevent torn snapshots.
43-
"""
44+
def save(blockchain: Blockchain, path: str = ".") -> None:
45+
"""Persist the blockchain and account state to SQLite inside *path*."""
4446
os.makedirs(path, exist_ok=True)
47+
db_path = os.path.join(path, _DB_FILE)
4548

46-
with blockchain._lock: # Thread-safe: hold lock while serialising
49+
with blockchain._lock:
4750
chain_data = [block.to_dict() for block in blockchain.chain]
48-
state_data = copy.deepcopy(blockchain.state.accounts)
49-
50-
snapshot = {
51-
"chain": chain_data,
52-
"state": state_data
53-
}
51+
state_data = json.loads(json.dumps(blockchain.state.accounts))
5452

55-
_atomic_write_json(os.path.join(path, _DATA_FILE), snapshot)
53+
_save_snapshot_to_sqlite(db_path, {"chain": chain_data, "state": state_data})
5654

5755
logger.info(
5856
"Saved %d blocks and %d accounts to '%s'",
@@ -63,42 +61,33 @@ def save(blockchain: Blockchain, path: str = ".") -> None:
6361

6462

6563
def load(path: str = ".") -> Blockchain:
66-
"""
67-
Restore a Blockchain from the JSON file inside *path*.
64+
"""Restore a Blockchain from SQLite inside *path* (with legacy JSON fallback)."""
65+
db_path = os.path.join(path, _DB_FILE)
66+
legacy_path = os.path.join(path, _LEGACY_DATA_FILE)
6867

69-
Steps:
70-
1. Load and deserialise blocks from data.json
71-
2. Verify chain integrity (genesis, linkage, hashes)
72-
3. Load account state
73-
74-
Raises:
75-
FileNotFoundError: if data.json is missing.
76-
ValueError: if data is invalid or integrity checks fail.
77-
"""
78-
data_path = os.path.join(path, _DATA_FILE)
79-
snapshot = _read_json(data_path)
68+
if os.path.exists(db_path):
69+
snapshot = _load_snapshot_from_sqlite(db_path)
70+
elif os.path.exists(legacy_path):
71+
snapshot = _read_legacy_json(legacy_path)
72+
else:
73+
raise FileNotFoundError(f"Persistence file not found in '{path}'")
8074

8175
if not isinstance(snapshot, dict):
82-
raise ValueError(f"Invalid snapshot data in '{data_path}'")
76+
raise ValueError(f"Invalid snapshot data in '{path}'")
8377

8478
raw_blocks = snapshot.get("chain")
8579
raw_accounts = snapshot.get("state")
8680

8781
if not isinstance(raw_blocks, list) or not raw_blocks:
88-
raise ValueError(f"Invalid or empty chain data in '{data_path}'")
82+
raise ValueError(f"Invalid or empty chain data in '{path}'")
8983
if not isinstance(raw_accounts, dict):
90-
raise ValueError(f"Invalid accounts data in '{data_path}'")
84+
raise ValueError(f"Invalid accounts data in '{path}'")
9185

9286
blocks = [_deserialize_block(b) for b in raw_blocks]
93-
94-
# --- Integrity verification ---
9587
_verify_chain_integrity(blocks)
9688

97-
# --- Rebuild blockchain properly (no __new__ hack) ---
98-
blockchain = Blockchain() # creates genesis + fresh state
99-
blockchain.chain = blocks # replace with loaded chain
100-
101-
# Restore state
89+
blockchain = Blockchain()
90+
blockchain.chain = blocks
10291
blockchain.state.accounts = raw_accounts
10392

10493
logger.info(
@@ -114,14 +103,13 @@ def load(path: str = ".") -> Blockchain:
114103
# Integrity verification
115104
# ---------------------------------------------------------------------------
116105

117-
def _verify_chain_integrity(blocks: list) -> None:
106+
107+
def _verify_chain_integrity(blocks: list[Block]) -> None:
118108
"""Verify genesis, hash linkage, and block hashes."""
119-
# Check genesis
120109
genesis = blocks[0]
121110
if genesis.index != 0 or genesis.hash != "0" * 64:
122111
raise ValueError("Invalid genesis block")
123112

124-
# Check linkage and hashes for every subsequent block
125113
for i in range(1, len(blocks)):
126114
block = blocks[i]
127115
prev = blocks[i - 1]
@@ -132,46 +120,112 @@ def _verify_chain_integrity(blocks: list) -> None:
132120

133121

134122
# ---------------------------------------------------------------------------
135-
# Helpers
123+
# SQLite helpers
136124
# ---------------------------------------------------------------------------
137125

138-
def _atomic_write_json(filepath: str, data) -> None:
139-
"""Write JSON atomically with fsync for durability."""
140-
dir_name = os.path.dirname(filepath) or "."
141-
fd, tmp_path = tempfile.mkstemp(dir=dir_name, suffix=".tmp")
126+
127+
def _connect(db_path: str) -> sqlite3.Connection:
128+
conn = sqlite3.connect(db_path)
129+
conn.row_factory = sqlite3.Row
130+
conn.execute("PRAGMA foreign_keys = ON")
131+
return conn
132+
133+
134+
def _initialize_schema(conn: sqlite3.Connection) -> None:
135+
conn.executescript(
136+
"""
137+
CREATE TABLE IF NOT EXISTS blocks (
138+
height INTEGER PRIMARY KEY,
139+
block_json TEXT NOT NULL
140+
);
141+
142+
CREATE TABLE IF NOT EXISTS accounts (
143+
address TEXT PRIMARY KEY,
144+
account_json TEXT NOT NULL
145+
);
146+
147+
CREATE TABLE IF NOT EXISTS metadata (
148+
key TEXT PRIMARY KEY,
149+
value TEXT NOT NULL
150+
);
151+
"""
152+
)
153+
154+
155+
def _save_snapshot_to_sqlite(db_path: str, snapshot: dict[str, Any]) -> None:
156+
conn = _connect(db_path)
142157
try:
143-
with os.fdopen(fd, "w", encoding="utf-8") as f:
144-
json.dump(data, f, indent=2)
145-
f.flush()
146-
os.fsync(f.fileno()) # Ensure data is on disk
147-
os.replace(tmp_path, filepath) # Atomic rename
148-
149-
# Attempt to fsync the directory so the rename is durable
150-
if hasattr(os, "O_DIRECTORY"):
151-
try:
152-
dir_fd = os.open(dir_name, os.O_RDONLY | os.O_DIRECTORY)
153-
try:
154-
os.fsync(dir_fd)
155-
finally:
156-
os.close(dir_fd)
157-
except OSError:
158-
pass # Directory fsync not supported on all platforms
159-
160-
except BaseException:
161-
try:
162-
os.unlink(tmp_path)
163-
except OSError:
164-
pass
165-
raise
158+
_initialize_schema(conn)
159+
with conn:
160+
conn.execute("DELETE FROM blocks")
161+
conn.execute("DELETE FROM accounts")
162+
conn.execute("DELETE FROM metadata")
163+
164+
for block in snapshot["chain"]:
165+
conn.execute(
166+
"INSERT INTO blocks (height, block_json) VALUES (?, ?)",
167+
(int(block["index"]), json.dumps(block, sort_keys=True)),
168+
)
169+
170+
for address, account in sorted(snapshot["state"].items()):
171+
conn.execute(
172+
"INSERT INTO accounts (address, account_json) VALUES (?, ?)",
173+
(address, json.dumps(account, sort_keys=True)),
174+
)
175+
176+
conn.execute(
177+
"INSERT INTO metadata (key, value) VALUES (?, ?)",
178+
("chain_length", str(len(snapshot["chain"]))),
179+
)
180+
finally:
181+
conn.close()
182+
183+
184+
def _load_snapshot_from_sqlite(db_path: str) -> dict[str, Any]:
185+
try:
186+
conn = _connect(db_path)
187+
except sqlite3.DatabaseError as exc:
188+
raise ValueError(f"Invalid SQLite persistence data in '{db_path}'") from exc
189+
190+
try:
191+
_initialize_schema(conn)
192+
block_rows = conn.execute(
193+
"SELECT block_json FROM blocks ORDER BY height ASC"
194+
).fetchall()
195+
account_rows = conn.execute(
196+
"SELECT address, account_json FROM accounts ORDER BY address ASC"
197+
).fetchall()
198+
except sqlite3.DatabaseError as exc:
199+
raise ValueError(f"Invalid SQLite persistence data in '{db_path}'") from exc
200+
finally:
201+
conn.close()
202+
203+
try:
204+
chain = [json.loads(row["block_json"]) for row in block_rows]
205+
state = {
206+
row["address"]: json.loads(row["account_json"])
207+
for row in account_rows
208+
}
209+
except json.JSONDecodeError as exc:
210+
raise ValueError(f"Invalid persisted JSON payload in '{db_path}'") from exc
166211

212+
return {"chain": chain, "state": state}
167213

168-
def _read_json(filepath: str):
169-
if not os.path.exists(filepath):
170-
raise FileNotFoundError(f"Persistence file not found: '{filepath}'")
214+
215+
# ---------------------------------------------------------------------------
216+
# Legacy JSON helpers
217+
# ---------------------------------------------------------------------------
218+
219+
220+
def _read_legacy_json(filepath: str) -> dict[str, Any]:
171221
with open(filepath, "r", encoding="utf-8") as f:
172222
return json.load(f)
173223

174224

175-
def _deserialize_block(data: dict) -> Block:
176-
"""Reconstruct a Block (including its transactions) from a plain dict."""
225+
# ---------------------------------------------------------------------------
226+
# Block deserialisation
227+
# ---------------------------------------------------------------------------
228+
229+
230+
def _deserialize_block(data: dict[str, Any]) -> Block:
177231
return Block.from_dict(data)

0 commit comments

Comments
 (0)