mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
Restoring a settings backup ZIP appeared to succeed but the user found settings reverted to defaults, most printers/archive rows missing, and ~1 GB of archive files on disk with only 1 row in the database. Same shape as #668 (closed in March without an actual fix — that user happened to make it work by rolling back to a stable release, which masked the bug). Cause: the live DB runs in WAL mode. Anything the fresh container wrote between startup and the restore call (seed_default_groups, init_db migrations, heartbeat writes) sits in bambuddy.db-wal with valid checksums, and engine.dispose() doesn't checkpoint it. FastAPI's dependency injection keeps the route handler's own `db: Depends(get_db)` session checked out across engine.dispose() (per SQLAlchemy docs, dispose only closes pooled connections, not checked-out ones), so the WAL inode is held open through the whole restore. After shutil.copy2 rewrote the main DB inode in place, SQLite's WAL recovery on the next init_db() re-applied the stale frames on top of the restored content, partially clobbering it with fresh-install state. Initial fix attempt of deleting -wal/-shm/-journal sidecars before the copy was insufficient (verified experimentally) — the still-open request session reads the unlinked sidecars via held fds and bleeds the WAL state back into the new file when it eventually closes. Real fix: replace shutil.copy2 with SQLite's online backup API (src_conn.backup(dst_conn)). The page-by-page protocol opens both DBs as proper SQLite connections, acquires the right locks, and routes new pages through the destination's own WAL. Concurrent open sessions see their own transactional snapshot until they close (transaction isolation) but can't corrupt the restored state.
This commit is contained in:
@@ -751,7 +751,38 @@ async def restore_backup(
|
||||
logger.info("Restoring database from backup...")
|
||||
if is_sqlite():
|
||||
db_path = Path(app_settings.database_url.replace("sqlite+aiosqlite:///", ""))
|
||||
shutil.copy2(backup_db, db_path)
|
||||
# Use SQLite's online backup API instead of shutil.copy2.
|
||||
# The pragma at database.py:19 runs the live DB in WAL mode,
|
||||
# which means a naive file copy is unsafe: anything written
|
||||
# to the live DB before this call that hasn't been
|
||||
# checkpointed yet (seed_default_groups + init_db on first
|
||||
# start, plus whatever background heartbeats wrote during
|
||||
# the request window) sits in bambuddy.db-wal with valid
|
||||
# checksums. The route handler's own `db: Depends(get_db)`
|
||||
# session also keeps a connection checked out across
|
||||
# engine.dispose(), holding fds to the WAL inode. With
|
||||
# `shutil.copy2` SQLite finds the stale WAL on the next
|
||||
# open and silently re-applies those page-level writes on
|
||||
# top of the restored DB, partially clobbering it with
|
||||
# fresh-install state — the user sees a "successful"
|
||||
# restore where most rows and settings have reverted to
|
||||
# defaults (#1211 / #668). The page-by-page backup API
|
||||
# opens both DBs as real SQLite connections, takes the
|
||||
# right locks, and routes new pages through the live DB's
|
||||
# own WAL — so concurrent open sessions see their own
|
||||
# snapshot until they close (transaction isolation) but
|
||||
# can't corrupt the restored state.
|
||||
import sqlite3
|
||||
|
||||
src_conn = sqlite3.connect(str(backup_db))
|
||||
try:
|
||||
dst_conn = sqlite3.connect(str(db_path))
|
||||
try:
|
||||
src_conn.backup(dst_conn)
|
||||
finally:
|
||||
dst_conn.close()
|
||||
finally:
|
||||
src_conn.close()
|
||||
else:
|
||||
# Import SQLite backup into PostgreSQL
|
||||
logger.info("Importing SQLite backup into PostgreSQL...")
|
||||
|
||||
Reference in New Issue
Block a user