WP-D and WP-E complete the planned v1.1 implementation packages. WP-D — recovery honesty: - backups verify the completed copy with PRAGMA integrity_check - corruption missed by quick_check is detected by the full check - existing good backups remain protected - oversized exports are still delivered but declare whether this version can import them, while the 20 MB import limit remains unchanged - backup was exercised through the real browser UI on both the normal campaign database and a campaign-shaped database over 100 MB WP-E — control-boundary contrast: - interactive control boundaries meet the WCAG 1.4.11 3:1 target - the contrast audit is now a failing gate rather than an advisory - rendered browser measurements pass for the composer, controls, tabs and nav - text contrast and focus visibility remain intact - owner reviewed and approved the before/after screenshots Reports: - planning/reports/v1.1/V1.1-WP-D-REPORT.md - planning/reports/v1.1/V1.1-WP-E-REPORT.md All planned v1.1 work packages A-E are now complete. Release validation has not yet begun.
290 lines
12 KiB
Python
290 lines
12 KiB
Python
"""M9: a consistent copy of the whole database, taken while the app is running.
|
|
|
|
This is **not** the campaign bundle, and the two are not alternatives. They are
|
|
different recovery tools and M9 keeps them apart deliberately:
|
|
|
|
campaign bundle one campaign, logical, portable between installations,
|
|
importable into a clean data directory on another
|
|
machine, readable by a human and by a later build
|
|
database backup every campaign, every setting, physical, this machine,
|
|
restored by putting the file back
|
|
|
|
The bundle is the primary cross-install recovery path and is what the acceptance
|
|
tests measure. This exists for the other question: the reader has one database
|
|
holding everything they have ever played, and wants a copy of it before they
|
|
upgrade, move a disk, or try something they might regret.
|
|
|
|
## Why not `cp data.db backup.db`
|
|
|
|
Because a copy taken with the application running is a copy of a moving target.
|
|
SQLite writes a database in pages, and a plain file copy can read page 5 before
|
|
a transaction and page 900 after it — the result is a file that opens, reports a
|
|
schema, and is silently missing or duplicating rows. In WAL mode it is worse: the
|
|
committed data may be in a `-wal` file the copy never touched. Nothing warns
|
|
anyone. The corruption is found later, by which time the original may be gone.
|
|
|
|
So this uses SQLite's own **online backup API** (`sqlite3.Connection.backup`),
|
|
which is the supported mechanism for exactly this: it copies page by page while
|
|
holding the right locks, restarts if a write moves the source underneath it, and
|
|
produces a file that is a transactionally consistent snapshot of some committed
|
|
point. The application keeps running throughout; no session is closed and no
|
|
turn is blocked.
|
|
|
|
## What the procedure guarantees
|
|
|
|
1. The source database is opened **read-only** and is never written to. A backup
|
|
that could damage what it is backing up would be worse than no backup.
|
|
2. The copy is written to a temporary file beside the destination and renamed
|
|
into place only after it has been verified, so an interrupted or failed run
|
|
never leaves a half-written file wearing a backup's name. `os.replace` is
|
|
atomic on the same filesystem, which is why the temporary sits in the
|
|
destination's own directory rather than in `/tmp`.
|
|
3. `PRAGMA integrity_check` runs against the finished copy, opened as its own
|
|
database, before it is renamed. A backup nobody verified is a belief. v1.1
|
|
WP-D made this the full check rather than `quick_check`; see `_verify`.
|
|
4. An existing file is never overwritten. Each run writes a new name stamped
|
|
with the time, so yesterday's backup survives today's mistake — which is most
|
|
of what a backup is for.
|
|
5. Failure is reported and leaves nothing behind but the log line.
|
|
|
|
## What it does not do
|
|
|
|
There is no restore endpoint. Restoring a whole database means replacing the
|
|
file the running application has open, and doing that from inside that
|
|
application is a way to lose both copies. The procedure is in `DEVELOPMENT.md`:
|
|
stop the app, move the file into place, start it. Campaign-level recovery — the
|
|
common case, and the one that crosses machines — is the bundle.
|
|
|
|
No path comes from a caller. The destination directory is derived from the
|
|
database the application is already using and the filename is generated here, so
|
|
there is no request that can direct a write anywhere else (H08).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import sqlite3
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from .database import DB_PATH
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
#: Where backups go: a directory beside the database itself. Beside, rather than
|
|
#: inside a configurable location, because the one thing this must not do is
|
|
#: write somewhere a request can name.
|
|
DIRECTORY_NAME = "backups"
|
|
|
|
#: The stem every backup file carries, so a directory listing sorts by date and
|
|
#: says what these files are without being opened.
|
|
PREFIX = "adventure-storyteller"
|
|
|
|
|
|
class BackupError(RuntimeError):
|
|
"""A backup did not complete. The source database is untouched."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Backup:
|
|
"""One finished, verified backup file."""
|
|
|
|
path: Path
|
|
bytes: int
|
|
pages: int
|
|
seconds: float
|
|
integrity: str
|
|
|
|
def as_dict(self) -> dict:
|
|
return {
|
|
# The name alone, not the path. The full path is a fact about this
|
|
# machine's filesystem, and the reader is told the directory once by
|
|
# the endpoint that lists them.
|
|
"filename": self.path.name,
|
|
"bytes": self.bytes,
|
|
"pages": self.pages,
|
|
"seconds": round(self.seconds, 3),
|
|
"integrity": self.integrity,
|
|
}
|
|
|
|
|
|
def directory(db_path: Path | None = None) -> Path:
|
|
"""The backup directory for a database, created if it does not exist."""
|
|
root = (db_path or DB_PATH).parent / DIRECTORY_NAME
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
return root
|
|
|
|
|
|
def create(db_path: Path | None = None, *, now: datetime | None = None) -> Backup:
|
|
"""Takes one verified backup of the live database, and returns it.
|
|
|
|
Raises `BackupError` on any failure, having removed whatever it had written.
|
|
The source database is opened read-only and is never modified, so a failure
|
|
here costs the backup and nothing else.
|
|
"""
|
|
source_path = db_path or DB_PATH
|
|
if not source_path.exists():
|
|
raise BackupError(f"There is no database at {source_path}.")
|
|
stamp = (now or datetime.now()).strftime("%Y%m%d-%H%M%S")
|
|
target = _unused_name(directory(source_path), stamp)
|
|
# The temporary sits in the destination directory so the rename below is a
|
|
# rename rather than a copy across filesystems, which would not be atomic.
|
|
working = target.with_name(target.name + ".partial")
|
|
started = datetime.now()
|
|
try:
|
|
pages = _copy(source_path, working)
|
|
integrity = _verify(working)
|
|
except BackupError:
|
|
_discard(working)
|
|
raise
|
|
except Exception as exc: # noqa: BLE001 - reported, never raised raw
|
|
_discard(working)
|
|
log.exception("Backup of %s failed", source_path)
|
|
raise BackupError(f"{type(exc).__name__}: {exc}") from exc
|
|
size = working.stat().st_size
|
|
# Only now does the file get the name a reader would trust.
|
|
os.replace(working, target)
|
|
return Backup(
|
|
path=target,
|
|
bytes=size,
|
|
pages=pages,
|
|
seconds=(datetime.now() - started).total_seconds(),
|
|
integrity=integrity,
|
|
)
|
|
|
|
|
|
def _copy(source_path: Path, working: Path) -> int:
|
|
"""Runs SQLite's online backup from `source_path` into a new file.
|
|
|
|
The source is opened through a URI with `mode=ro`, so this connection cannot
|
|
write to it even by accident. The destination is a fresh database that this
|
|
function creates; `backup()` overwrites whatever is in it, and the caller has
|
|
guaranteed the name is unused.
|
|
|
|
Returns the number of pages copied, which is the one honest measure of how
|
|
much was actually written — the file size counts pages the source had
|
|
already allocated.
|
|
"""
|
|
source = sqlite3.connect(f"file:{source_path}?mode=ro", uri=True)
|
|
try:
|
|
destination = sqlite3.connect(working)
|
|
try:
|
|
copied = 0
|
|
|
|
def progress(_status, remaining, total):
|
|
nonlocal copied
|
|
copied = total - remaining
|
|
|
|
# `pages=-1` copies the whole database in one step while holding the
|
|
# source's read lock, which is the right trade for a local
|
|
# single-user database: it is the fastest option, it cannot restart
|
|
# partway, and the lock it holds does not block readers.
|
|
source.backup(destination, pages=-1, progress=progress)
|
|
return copied
|
|
finally:
|
|
destination.close()
|
|
finally:
|
|
source.close()
|
|
|
|
|
|
def _verify(working: Path) -> str:
|
|
"""Runs `PRAGMA integrity_check` against the finished copy.
|
|
|
|
Opened as its own connection, so what is checked is the file on disk rather
|
|
than any page cache the copy left behind.
|
|
|
|
**v1.1 WP-D: the full check, not `quick_check`.** M9 chose `quick_check` for
|
|
its speed, on the argument that a backup verified slowly enough that nobody
|
|
takes one is worse than a fast one. The measurements say the trade was not
|
|
needed here: `quick_check` omits the cross-check between a table and its
|
|
indexes, and that is a real class of damage it reports as `ok`. A copy whose
|
|
index disagrees with its table restores into a database that answers queries
|
|
with rows that are not there — the failure a backup exists to prevent.
|
|
|
|
The cost is small at the sizes this application produces: on the 100-turn
|
|
evidence campaign both checks are a few milliseconds, and on a synthetic
|
|
database two orders of magnitude larger the difference is still short of a
|
|
second (WP-D report §E). A backup nobody verified is a belief; this is the
|
|
check that makes it a fact.
|
|
"""
|
|
connection = sqlite3.connect(f"file:{working}?mode=ro", uri=True)
|
|
try:
|
|
rows = connection.execute("PRAGMA integrity_check").fetchall()
|
|
finally:
|
|
connection.close()
|
|
result = ", ".join(str(row[0]) for row in rows) if rows else "no result"
|
|
if result != "ok":
|
|
raise BackupError(
|
|
f"The backup was written but did not verify: {result}. It has been "
|
|
f"discarded; the original database is untouched."
|
|
)
|
|
return result
|
|
|
|
|
|
def _unused_name(root: Path, stamp: str) -> Path:
|
|
"""A name in `root` that nothing is using.
|
|
|
|
An existing backup is never overwritten. Two backups taken inside one second
|
|
are the only way to collide, and the counter settles that rather than one of
|
|
them silently replacing the other.
|
|
"""
|
|
candidate = root / f"{PREFIX}-{stamp}.db"
|
|
counter = 2
|
|
while candidate.exists() or candidate.with_name(candidate.name + ".partial").exists():
|
|
candidate = root / f"{PREFIX}-{stamp}-{counter}.db"
|
|
counter += 1
|
|
return candidate
|
|
|
|
|
|
def _discard(working: Path) -> None:
|
|
"""Removes a partial file, ignoring a file that is already gone."""
|
|
try:
|
|
working.unlink()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def existing(db_path: Path | None = None) -> list[dict]:
|
|
"""Every backup in the directory, newest first.
|
|
|
|
Names and sizes only. Reading one to report what is inside it would mean
|
|
opening a database on every page load for a screen that is a list.
|
|
|
|
`taken_at` is read out of the **filename**, which is the stamp `create`
|
|
wrote when it took the backup, and falls back to the file's modification
|
|
time only for a name that does not parse. The two usually agree, and where
|
|
they disagree the name is the one telling the truth: copying a backup to
|
|
another disk, restoring it from an archive, or touching it all move the
|
|
mtime, and a list that then reordered itself would report when the file was
|
|
last handled rather than when the backup was taken.
|
|
"""
|
|
root = directory(db_path)
|
|
rows = []
|
|
for path in root.glob(f"{PREFIX}-*.db"):
|
|
try:
|
|
stat = path.stat()
|
|
except OSError:
|
|
continue
|
|
rows.append({
|
|
"filename": path.name,
|
|
"bytes": stat.st_size,
|
|
"taken_at": (
|
|
_stamp_in(path.name) or datetime.fromtimestamp(stat.st_mtime)
|
|
).isoformat(timespec="seconds"),
|
|
})
|
|
rows.sort(key=lambda row: (row["taken_at"], row["filename"]), reverse=True)
|
|
return rows
|
|
|
|
|
|
def _stamp_in(filename: str) -> datetime | None:
|
|
"""The time in a backup's name, or `None` if it does not carry one."""
|
|
rest = filename[len(PREFIX) + 1:].removesuffix(".db")
|
|
# A collision within one second gets a `-2` suffix, which is not the stamp.
|
|
stamp = "-".join(rest.split("-")[:2])
|
|
try:
|
|
return datetime.strptime(stamp, "%Y%m%d-%H%M%S")
|
|
except ValueError:
|
|
return None
|