Phase 17: run the migration, and fix the guard that locked out its own backup

Claire's writing — 8 documents, 33 snapshots, 103 suggestions, 3 vocabulary
words and an image — now belongs to her account rather than to the pre-auth
'local' user, and the VPS is canonical. millenia was left running and
untouched as a frozen fallback; it diverges the moment either side is
written to, so it wants retiring rather than syncing.

The plan's stated prerequisite, that she log in once so her subject exists,
turned out to be false. Authentik's hashed_user_id sub is the user's uid,
derived from her id and the instance secret, so it can be read in advance —
which means the data moves first and she signs in to find her writing
already there, instead of to an empty Petal that fills in later.

The fix here is to the liveness guard, and it is the second attempt at it.
PRAGMA locking_mode = EXCLUSIVE goes on holding its lock after being set
back to NORMAL — SQLite only lets go on that connection's next database
access — so against a real WAL database the script locked itself out of its
own VACUUM INTO backup. It passed locally because the test database had
come out of VACUUM INTO and so was never in WAL mode: the fixture didn't
look like production, the same way the stub identity provider's slashless
issuer didn't. The probe now runs on its own connection and closes it.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
prosolis
2026-07-27 07:52:16 -07:00
parent 151df4565b
commit 84ee6bfb9c
3 changed files with 61 additions and 14 deletions
+13 -8
View File
@@ -83,7 +83,7 @@ def counts(conn: sqlite3.Connection, user_id: str) -> dict[str, int]:
return out
def require_app_stopped(conn: sqlite3.Connection, path: str) -> None:
def require_app_stopped(path: str) -> None:
"""Fail unless nothing else has the database open.
`BEGIN EXCLUSIVE` is not enough, and quietly so: in WAL mode it only
@@ -94,11 +94,18 @@ def require_app_stopped(conn: sqlite3.Connection, path: str) -> None:
Checking for a `-wal` file would be no use either: WAL mode leaves one
behind whether or not anything is running.
This runs on its own connection, which is then closed. Setting locking_mode
back to NORMAL does not release the lock straight away — SQLite drops it on
the connection's next database access, which on a WAL database can leave the
file locked against everything else this script is about to do. Closing is
the only unambiguous way to let go of it.
"""
probe = sqlite3.connect(path, isolation_level=None)
try:
conn.execute("PRAGMA locking_mode = EXCLUSIVE")
conn.execute("BEGIN IMMEDIATE")
conn.execute("COMMIT")
probe.execute("PRAGMA locking_mode = EXCLUSIVE")
probe.execute("BEGIN IMMEDIATE")
probe.execute("COMMIT")
except sqlite3.OperationalError as err:
die(
f"{path} is in use ({err}). Stop Petal first:\n"
@@ -106,9 +113,7 @@ def require_app_stopped(conn: sqlite3.Connection, path: str) -> None:
" systemctl --user stop petal # millenia"
)
finally:
# Back to normal locking so the rest of the run behaves like any other
# client, and so a dry run leaves the file exactly as it found it.
conn.execute("PRAGMA locking_mode = NORMAL")
probe.close()
def backup(path: str) -> str:
@@ -140,8 +145,8 @@ def main() -> None:
if args.to == args.source:
die("source and destination are the same account")
require_app_stopped(args.database)
conn = open_db(args.database)
require_app_stopped(conn, args.database)
src_counts = counts(conn, args.source)
if not any(src_counts.values()):