Fix missing import, add transaction safety, clean orphaned snapshots

- Add load_json to tab_timeline_ng imports (NameError on disk fallback)
- Wrap save_history_tree in BEGIN/COMMIT transaction (was autocommitting
  each statement, risking partial writes on failure)
- Clean up orphaned history_snapshots in sync_to_db when nodes are
  removed from the tree

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 09:56:10 +01:00
parent a1a85ecc4d
commit e575a78893
3 changed files with 43 additions and 21 deletions
+27 -18
View File
@@ -326,30 +326,39 @@ class ProjectDB:
def save_history_tree(self, data_file_id: int, tree_data: dict) -> None: def save_history_tree(self, data_file_id: int, tree_data: dict) -> None:
"""Save history tree, extracting node snapshots into separate table.""" """Save history tree, extracting node snapshots into separate table."""
now = time.time() now = time.time()
# Extract snapshot data from nodes into history_snapshots table
nodes = tree_data.get("nodes", {}) nodes = tree_data.get("nodes", {})
slim_tree = dict(tree_data) slim_tree = dict(tree_data)
slim_nodes = {} slim_nodes = {}
for nid, node in nodes.items(): for nid, node in nodes.items():
snap = node.get("data")
if snap:
self.conn.execute(
"INSERT INTO history_snapshots (data_file_id, node_id, snapshot_data, updated_at) "
"VALUES (?, ?, ?, ?) "
"ON CONFLICT(data_file_id, node_id) DO UPDATE SET "
"snapshot_data=excluded.snapshot_data, updated_at=excluded.updated_at",
(data_file_id, nid, json.dumps(snap), now),
)
# Store node without data in tree
slim_nodes[nid] = {k: v for k, v in node.items() if k != "data"} slim_nodes[nid] = {k: v for k, v in node.items() if k != "data"}
slim_tree["nodes"] = slim_nodes slim_tree["nodes"] = slim_nodes
self.conn.execute(
"INSERT INTO history_trees (data_file_id, tree_data, updated_at) " self.conn.execute("BEGIN IMMEDIATE")
"VALUES (?, ?, ?) " try:
"ON CONFLICT(data_file_id) DO UPDATE SET tree_data=excluded.tree_data, updated_at=excluded.updated_at", # Extract snapshot data from nodes into history_snapshots table
(data_file_id, json.dumps(slim_tree), now), for nid, node in nodes.items():
) snap = node.get("data")
self.conn.commit() if snap:
self.conn.execute(
"INSERT INTO history_snapshots (data_file_id, node_id, snapshot_data, updated_at) "
"VALUES (?, ?, ?, ?) "
"ON CONFLICT(data_file_id, node_id) DO UPDATE SET "
"snapshot_data=excluded.snapshot_data, updated_at=excluded.updated_at",
(data_file_id, nid, json.dumps(snap), now),
)
self.conn.execute(
"INSERT INTO history_trees (data_file_id, tree_data, updated_at) "
"VALUES (?, ?, ?) "
"ON CONFLICT(data_file_id) DO UPDATE SET tree_data=excluded.tree_data, updated_at=excluded.updated_at",
(data_file_id, json.dumps(slim_tree), now),
)
self.conn.execute("COMMIT")
except Exception:
try:
self.conn.execute("ROLLBACK")
except Exception:
pass
raise
def get_history_tree(self, data_file_id: int) -> dict | None: def get_history_tree(self, data_file_id: int) -> dict | None:
"""Load history tree metadata (without snapshot data).""" """Load history tree metadata (without snapshot data)."""
+2 -3
View File
@@ -8,7 +8,7 @@ from nicegui import ui
from state import AppState from state import AppState
from history_tree import HistoryTree from history_tree import HistoryTree
from utils import save_json, sync_to_db, KEY_BATCH_DATA, KEY_HISTORY_TREE from utils import save_json, load_json, sync_to_db, KEY_BATCH_DATA, KEY_HISTORY_TREE
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -580,8 +580,7 @@ async def _restore_node(data, node, htree, file_path, state: AppState):
state.db.get_node_snapshot, df['id'], node['id']) state.db.get_node_snapshot, df['id'], node['id'])
if not raw_snap: if not raw_snap:
# Last resort: read from JSON file on disk # Last resort: read from JSON file on disk
from utils import load_json as _load_json raw_file, _ = await asyncio.to_thread(load_json, file_path)
raw_file, _ = await asyncio.to_thread(_load_json, file_path)
tree_on_disk = raw_file.get(KEY_HISTORY_TREE, {}) tree_on_disk = raw_file.get(KEY_HISTORY_TREE, {})
raw_snap = tree_on_disk.get('nodes', {}).get(node['id'], {}).get('data', {}) raw_snap = tree_on_disk.get('nodes', {}).get(node['id'], {}).get('data', {})
node_data = json.loads(json.dumps(raw_snap)) if raw_snap else {} node_data = json.loads(json.dumps(raw_snap)) if raw_snap else {}
+14
View File
@@ -312,6 +312,20 @@ def sync_to_db(db, project_name: str, file_path: Path, data: dict) -> None:
"ON CONFLICT(data_file_id) DO UPDATE SET tree_data=excluded.tree_data, updated_at=excluded.updated_at", "ON CONFLICT(data_file_id) DO UPDATE SET tree_data=excluded.tree_data, updated_at=excluded.updated_at",
(df_id, json.dumps(slim_tree), now), (df_id, json.dumps(slim_tree), now),
) )
# Clean up orphaned snapshots for nodes no longer in tree
current_node_ids = set(nodes.keys())
if current_node_ids:
placeholders = ",".join("?" for _ in current_node_ids)
db.conn.execute(
f"DELETE FROM history_snapshots WHERE data_file_id = ? "
f"AND node_id NOT IN ({placeholders})",
(df_id, *current_node_ids),
)
else:
db.conn.execute(
"DELETE FROM history_snapshots WHERE data_file_id = ?",
(df_id,),
)
db.conn.execute("COMMIT") db.conn.execute("COMMIT")
except Exception: except Exception: