weightsdb 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- weightsdb/__about__.py +1 -0
- weightsdb/__init__.py +71 -0
- weightsdb/backup.py +610 -0
- weightsdb/engine.py +229 -0
- weightsdb/errors.py +83 -0
- weightsdb/health.py +318 -0
- weightsdb/migrations.py +328 -0
- weightsdb/py.typed +0 -0
- weightsdb/redaction.py +26 -0
- weightsdb/session.py +115 -0
- weightsdb/testing.py +131 -0
- weightsdb/types.py +187 -0
- weightsdb-0.2.0.dist-info/METADATA +96 -0
- weightsdb-0.2.0.dist-info/RECORD +16 -0
- weightsdb-0.2.0.dist-info/WHEEL +4 -0
- weightsdb-0.2.0.dist-info/licenses/LICENSE +201 -0
weightsdb/__about__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
weightsdb/__init__.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""weightsdb — shared SQLAlchemy + Alembic plumbing: engines, sessions, pragmas, migrations, backup.
|
|
2
|
+
|
|
3
|
+
No application table, no shared schema (database standards §1). See
|
|
4
|
+
``docs/packages/weightsdb/spec.md`` for the full contract.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from weightsdb.__about__ import __version__
|
|
10
|
+
from weightsdb.backup import (
|
|
11
|
+
BackupResult,
|
|
12
|
+
IntegrityResult,
|
|
13
|
+
RestoreResult,
|
|
14
|
+
backup,
|
|
15
|
+
checkpoint,
|
|
16
|
+
database_size_bytes,
|
|
17
|
+
integrity_check,
|
|
18
|
+
pg_restore_command,
|
|
19
|
+
restore,
|
|
20
|
+
)
|
|
21
|
+
from weightsdb.engine import create_engine_for
|
|
22
|
+
from weightsdb.errors import (
|
|
23
|
+
DatabaseError,
|
|
24
|
+
DatabaseUnavailable,
|
|
25
|
+
MigrationFailed,
|
|
26
|
+
MigrationRequired,
|
|
27
|
+
SchemaAhead,
|
|
28
|
+
StorageBusy,
|
|
29
|
+
StorageFull,
|
|
30
|
+
)
|
|
31
|
+
from weightsdb.health import DatabaseHealth, database_health, is_network_filesystem
|
|
32
|
+
from weightsdb.migrations import MigrationOutcome, MigrationRunner, ParityResult
|
|
33
|
+
from weightsdb.redaction import redact_url
|
|
34
|
+
from weightsdb.session import session_factory, session_scope, transaction
|
|
35
|
+
from weightsdb.types import PortableJSON, UtcDateTime, measurement_columns, ulid_primary_key, upsert
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"BackupResult",
|
|
39
|
+
"DatabaseError",
|
|
40
|
+
"DatabaseHealth",
|
|
41
|
+
"DatabaseUnavailable",
|
|
42
|
+
"IntegrityResult",
|
|
43
|
+
"MigrationFailed",
|
|
44
|
+
"MigrationOutcome",
|
|
45
|
+
"MigrationRequired",
|
|
46
|
+
"MigrationRunner",
|
|
47
|
+
"ParityResult",
|
|
48
|
+
"PortableJSON",
|
|
49
|
+
"RestoreResult",
|
|
50
|
+
"SchemaAhead",
|
|
51
|
+
"StorageBusy",
|
|
52
|
+
"StorageFull",
|
|
53
|
+
"UtcDateTime",
|
|
54
|
+
"__version__",
|
|
55
|
+
"backup",
|
|
56
|
+
"checkpoint",
|
|
57
|
+
"create_engine_for",
|
|
58
|
+
"database_health",
|
|
59
|
+
"database_size_bytes",
|
|
60
|
+
"integrity_check",
|
|
61
|
+
"is_network_filesystem",
|
|
62
|
+
"measurement_columns",
|
|
63
|
+
"pg_restore_command",
|
|
64
|
+
"redact_url",
|
|
65
|
+
"restore",
|
|
66
|
+
"session_factory",
|
|
67
|
+
"session_scope",
|
|
68
|
+
"transaction",
|
|
69
|
+
"ulid_primary_key",
|
|
70
|
+
"upsert",
|
|
71
|
+
]
|
weightsdb/backup.py
ADDED
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
"""weightsdb.backup — backup, restore, rotation, integrity and size.
|
|
2
|
+
|
|
3
|
+
Spec §11.4: the automatic restore-on-failure guarantee is **SQLite-only**. On SQLite, a backup is a
|
|
4
|
+
byte-identical copy taken through the SQLite backup API (safe against a live writer), and a failed
|
|
5
|
+
migration restores it. On PostgreSQL there is no equivalent — ``pg_dump``/``pg_restore`` is not
|
|
6
|
+
byte-identical, a restore generally needs privileges a least-privileged role deliberately does not
|
|
7
|
+
hold, and a restore cannot run safely underneath a live database — so PostgreSQL's :func:`backup`
|
|
8
|
+
exists for an explicit caller only, :func:`restore` refuses and names the ``pg_restore`` invocation
|
|
9
|
+
instead, and :mod:`weightsdb.migrations` never restores automatically for this dialect.
|
|
10
|
+
|
|
11
|
+
**Restoring a SQLite database is a file-level operation, and WAL makes that subtle.** The engine
|
|
12
|
+
runs ``journal_mode=WAL`` (database standards §2), so committed data lives in a ``-wal`` sidecar
|
|
13
|
+
until a checkpoint folds it into the main file. Copying a backup over the main file alone leaves
|
|
14
|
+
that sidecar in place, and the next reader replays it straight back on top — the restore silently
|
|
15
|
+
does nothing to the very writes it was meant to undo. :func:`restore` therefore checkpoints,
|
|
16
|
+
disposes the pool and removes the sidecars before the swap, and verifies the result opens before
|
|
17
|
+
discarding the file it replaced.
|
|
18
|
+
|
|
19
|
+
Moved from FreeWeight's ``infrastructure.db.backup`` (ADR-0011); behaviour is unchanged.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import gzip
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
import shutil
|
|
28
|
+
import sqlite3
|
|
29
|
+
import subprocess
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from datetime import UTC, datetime
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from sqlalchemy import Engine, text
|
|
35
|
+
from sqlalchemy.exc import DBAPIError, SQLAlchemyError
|
|
36
|
+
|
|
37
|
+
from weightsdb.errors import DatabaseError, StorageFull
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"BackupResult",
|
|
41
|
+
"IntegrityResult",
|
|
42
|
+
"RestoreResult",
|
|
43
|
+
"backup",
|
|
44
|
+
"backup_revision",
|
|
45
|
+
"checkpoint",
|
|
46
|
+
"database_size_bytes",
|
|
47
|
+
"integrity_check",
|
|
48
|
+
"pg_restore_command",
|
|
49
|
+
"prune_backups",
|
|
50
|
+
"reclaimable_bytes",
|
|
51
|
+
"restore",
|
|
52
|
+
"sqlite_path",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
_LOG = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
# Long enough that an ordinary concurrent writer finishes, short enough that a genuinely stuck
|
|
58
|
+
# database reports rather than hangs. Matches engine.create_engine_for's own busy_timeout default.
|
|
59
|
+
_SQLITE_BUSY_TIMEOUT_SECONDS = 5.0
|
|
60
|
+
|
|
61
|
+
_SIDECAR_SUFFIXES = ("-wal", "-shm")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class BackupResult:
|
|
66
|
+
"""The outcome of a successful :func:`backup`.
|
|
67
|
+
|
|
68
|
+
Attributes:
|
|
69
|
+
path: Where the backup was written.
|
|
70
|
+
size_bytes: The backup file's size, for a "space this will use" report.
|
|
71
|
+
created_at: When the backup was taken.
|
|
72
|
+
dialect: ``"sqlite"`` or ``"postgresql"`` — which mechanism produced it, since the two are
|
|
73
|
+
not interchangeable (a ``pg_dump`` archive is never restored onto a SQLite file).
|
|
74
|
+
pruned: Automatic backups deleted by rotation as part of this call, oldest first. Empty
|
|
75
|
+
when rotation was not requested or nothing aged out.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
path: Path
|
|
79
|
+
size_bytes: int
|
|
80
|
+
created_at: datetime
|
|
81
|
+
dialect: str
|
|
82
|
+
pruned: tuple[Path, ...] = ()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True, slots=True)
|
|
86
|
+
class RestoreResult:
|
|
87
|
+
"""The outcome of a successful :func:`restore`.
|
|
88
|
+
|
|
89
|
+
Attributes:
|
|
90
|
+
path: The database file that was restored into.
|
|
91
|
+
source: The backup that was restored from.
|
|
92
|
+
restored_at: When the restore completed.
|
|
93
|
+
revision: The Alembic revision the restored database is at, or ``None`` if the backup
|
|
94
|
+
predates any migration.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
path: Path
|
|
98
|
+
source: Path
|
|
99
|
+
restored_at: datetime
|
|
100
|
+
revision: str | None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True, slots=True)
|
|
104
|
+
class IntegrityResult:
|
|
105
|
+
"""The outcome of :func:`integrity_check`.
|
|
106
|
+
|
|
107
|
+
Attributes:
|
|
108
|
+
ok: Whether the database passed its integrity check.
|
|
109
|
+
detail: The backend's own report — the raw ``PRAGMA integrity_check`` output on SQLite, or
|
|
110
|
+
a summary sentence on PostgreSQL.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
ok: bool
|
|
114
|
+
detail: str
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def sqlite_path(engine: Engine) -> Path:
|
|
118
|
+
"""Return the on-disk path of a SQLite engine's database file.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
DatabaseError: The engine is not SQLite, or is the special in-memory database, which has
|
|
122
|
+
no file to back up.
|
|
123
|
+
"""
|
|
124
|
+
if engine.dialect.name != "sqlite":
|
|
125
|
+
raise DatabaseError(
|
|
126
|
+
f"Expected a SQLite engine; got dialect {engine.dialect.name!r}.",
|
|
127
|
+
details={"dialect": engine.dialect.name},
|
|
128
|
+
)
|
|
129
|
+
# `URL.database` already applies the sqlite dialect's own rule for how many leading slashes
|
|
130
|
+
# separate "sqlite://" from an absolute path; parsing the URL string by hand here is how a
|
|
131
|
+
# "sqlite:////tmp/x" quietly becomes the relative "tmp/x".
|
|
132
|
+
database = engine.url.database
|
|
133
|
+
if not database or database == ":memory:":
|
|
134
|
+
raise DatabaseError(
|
|
135
|
+
"Cannot back up an in-memory SQLite database — it has no file to copy.",
|
|
136
|
+
details={"database_url": "sqlite:///:memory:"},
|
|
137
|
+
)
|
|
138
|
+
return Path(database)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _sidecars(database: Path) -> tuple[Path, ...]:
|
|
142
|
+
"""Return the ``-wal``/``-shm`` companion paths SQLite keeps beside ``database``."""
|
|
143
|
+
return tuple(Path(f"{database}{suffix}") for suffix in _SIDECAR_SUFFIXES)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def checkpoint(engine: Engine) -> None:
|
|
147
|
+
"""Fold the WAL into the main database file and truncate the sidecar. A no-op off SQLite.
|
|
148
|
+
|
|
149
|
+
Needed wherever the *file* is the thing being measured or moved. Under ``journal_mode=WAL`` the
|
|
150
|
+
main file lags whatever is still in ``<db>-wal``, so a size read or a byte-for-byte copy taken
|
|
151
|
+
without checkpointing first describes neither the old state nor the new one.
|
|
152
|
+
|
|
153
|
+
Best-effort: a database too damaged to checkpoint is exactly the one a restore is about to
|
|
154
|
+
replace, so a failure here is logged and does not abort the caller.
|
|
155
|
+
"""
|
|
156
|
+
if engine.dialect.name != "sqlite":
|
|
157
|
+
return
|
|
158
|
+
try:
|
|
159
|
+
# AUTOCOMMIT is load-bearing. Every ordinary connection from this engine opens its
|
|
160
|
+
# transaction with BEGIN IMMEDIATE, and `wal_checkpoint` cannot run inside a transaction —
|
|
161
|
+
# it reports busy in its result row rather than raising, so a checkpoint issued the
|
|
162
|
+
# ordinary way silently does nothing and every caller here quietly gets the
|
|
163
|
+
# un-checkpointed file it was trying to avoid.
|
|
164
|
+
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as connection:
|
|
165
|
+
connection.exec_driver_sql("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
166
|
+
except Exception: # noqa: BLE001 — a database too broken to checkpoint is one we still replace
|
|
167
|
+
_LOG.debug("WAL checkpoint failed; continuing", exc_info=True)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _checkpoint_and_release(engine: Engine) -> None:
|
|
171
|
+
"""Checkpoint, then drop every pooled connection to the database.
|
|
172
|
+
|
|
173
|
+
Both halves matter before a file-level swap: the checkpoint makes the main file complete (so
|
|
174
|
+
the copy taken of it is worth keeping as a rollback), and the dispose releases the handles that
|
|
175
|
+
would otherwise keep the old ``-wal`` alive underneath the new database.
|
|
176
|
+
"""
|
|
177
|
+
checkpoint(engine)
|
|
178
|
+
engine.dispose()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def backup_revision(source: Path, *, version_table: str = "alembic_version") -> str | None:
|
|
182
|
+
"""Return the Alembic revision recorded inside a SQLite backup file.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
source: The backup file to inspect. Opened read-only; never modified.
|
|
186
|
+
version_table: The table Alembic records the revision in. Must be a plain identifier.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
The revision string, or ``None`` when the file carries no version table at all — a
|
|
190
|
+
legitimate state for a backup taken before the first migration ran.
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
DatabaseError: ``version_table`` is not a plain identifier, or ``source`` cannot be opened.
|
|
194
|
+
"""
|
|
195
|
+
if not version_table.isidentifier():
|
|
196
|
+
raise DatabaseError(f"Invalid version table name {version_table!r}.")
|
|
197
|
+
try:
|
|
198
|
+
connection = sqlite3.connect(f"file:{source}?mode=ro", uri=True)
|
|
199
|
+
except sqlite3.Error as exc:
|
|
200
|
+
raise DatabaseError(
|
|
201
|
+
f"Backup {source} could not be opened: {exc}", details={"source": str(source)}
|
|
202
|
+
) from exc
|
|
203
|
+
try:
|
|
204
|
+
# `version_table` is validated as an identifier immediately above and is never
|
|
205
|
+
# caller-supplied in practice; SQLite has no parameter form for a table name.
|
|
206
|
+
row = connection.execute(f"SELECT version_num FROM {version_table}").fetchone() # noqa: S608
|
|
207
|
+
except sqlite3.OperationalError:
|
|
208
|
+
return None
|
|
209
|
+
finally:
|
|
210
|
+
connection.close()
|
|
211
|
+
return str(row[0]) if row else None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def backup(
|
|
215
|
+
engine: Engine,
|
|
216
|
+
destination: Path,
|
|
217
|
+
*,
|
|
218
|
+
compress: bool = False,
|
|
219
|
+
keep: int | None = None,
|
|
220
|
+
prefix: str | None = None,
|
|
221
|
+
) -> BackupResult:
|
|
222
|
+
"""Take a consistent backup of the database ``engine`` is connected to.
|
|
223
|
+
|
|
224
|
+
On SQLite this uses the SQLite backup API (:meth:`sqlite3.Connection.backup`), which is safe to
|
|
225
|
+
run against a database with an active writer — it never holds a lock for longer than a single
|
|
226
|
+
page copy. On PostgreSQL this shells out to ``pg_dump`` in the custom archive format.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
engine: The engine to back up.
|
|
230
|
+
destination: Where to write the backup. Parent directories are created if missing; the
|
|
231
|
+
file is created with mode ``0600`` before a byte is written to it (security
|
|
232
|
+
standards), never widened first and narrowed afterwards.
|
|
233
|
+
compress: Gzip the SQLite backup after writing it. Ignored for PostgreSQL, whose custom
|
|
234
|
+
archive format is already compressed.
|
|
235
|
+
keep: Retain only this many backups matching ``prefix`` in ``destination``'s directory,
|
|
236
|
+
newest first, deleting the rest (database standards §7, default 5 for automatic
|
|
237
|
+
backups). ``None`` disables rotation, which is what an operator-chosen ``--output``
|
|
238
|
+
path gets — this function never deletes a file the operator named.
|
|
239
|
+
prefix: Filename prefix identifying the family of automatic backups ``keep`` applies to.
|
|
240
|
+
Required when ``keep`` is given.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
The :class:`BackupResult`.
|
|
244
|
+
|
|
245
|
+
Raises:
|
|
246
|
+
DatabaseError: The backup could not be taken, including
|
|
247
|
+
:class:`~weightsdb.errors.StorageFull` when the destination device is out of space
|
|
248
|
+
(the partial file is removed first). Also raised when the SQLite database file does
|
|
249
|
+
not exist — an empty backup of a database that was never created is worse than a
|
|
250
|
+
refusal, because it looks like a successful one.
|
|
251
|
+
"""
|
|
252
|
+
if keep is not None and prefix is None:
|
|
253
|
+
raise DatabaseError("backup(keep=...) requires a prefix identifying which files to rotate.")
|
|
254
|
+
|
|
255
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
256
|
+
dialect = engine.dialect.name
|
|
257
|
+
|
|
258
|
+
if dialect == "sqlite":
|
|
259
|
+
source_path = sqlite_path(engine)
|
|
260
|
+
if not source_path.is_file():
|
|
261
|
+
raise DatabaseError(
|
|
262
|
+
f"There is no database at {source_path} to back up.",
|
|
263
|
+
details={"source": str(source_path)},
|
|
264
|
+
)
|
|
265
|
+
_touch_private(destination)
|
|
266
|
+
source = sqlite3.connect(source_path, timeout=_SQLITE_BUSY_TIMEOUT_SECONDS)
|
|
267
|
+
try:
|
|
268
|
+
target = sqlite3.connect(destination)
|
|
269
|
+
try:
|
|
270
|
+
source.backup(target)
|
|
271
|
+
finally:
|
|
272
|
+
target.close()
|
|
273
|
+
except sqlite3.OperationalError as exc:
|
|
274
|
+
destination.unlink(missing_ok=True)
|
|
275
|
+
if "disk" in str(exc).lower() or "space" in str(exc).lower():
|
|
276
|
+
raise StorageFull(
|
|
277
|
+
f"No space left to write backup {destination}.",
|
|
278
|
+
details={"destination": str(destination)},
|
|
279
|
+
) from exc
|
|
280
|
+
raise DatabaseError(
|
|
281
|
+
f"SQLite backup of {source_path} to {destination} failed: {exc}",
|
|
282
|
+
details={"source": str(source_path), "destination": str(destination)},
|
|
283
|
+
) from exc
|
|
284
|
+
finally:
|
|
285
|
+
source.close()
|
|
286
|
+
if compress:
|
|
287
|
+
compressed = Path(f"{destination}.gz")
|
|
288
|
+
_touch_private(compressed)
|
|
289
|
+
try:
|
|
290
|
+
with destination.open("rb") as raw, gzip.open(compressed, "wb") as archive:
|
|
291
|
+
shutil.copyfileobj(raw, archive)
|
|
292
|
+
except OSError as exc:
|
|
293
|
+
# The uncompressed file is the one that is known-good; leaving a half-written
|
|
294
|
+
# ".gz" beside it would look like a backup and restore as nothing.
|
|
295
|
+
compressed.unlink(missing_ok=True)
|
|
296
|
+
raise DatabaseError(
|
|
297
|
+
f"Compressing backup {destination} failed: {exc}",
|
|
298
|
+
details={"destination": str(destination)},
|
|
299
|
+
) from exc
|
|
300
|
+
destination.unlink()
|
|
301
|
+
destination = compressed
|
|
302
|
+
elif dialect == "postgresql":
|
|
303
|
+
_touch_private(destination)
|
|
304
|
+
url = engine.url
|
|
305
|
+
command = [
|
|
306
|
+
"pg_dump",
|
|
307
|
+
"--format=custom",
|
|
308
|
+
f"--file={destination}",
|
|
309
|
+
f"--host={url.host or 'localhost'}",
|
|
310
|
+
f"--port={url.port or 5432}",
|
|
311
|
+
f"--username={url.username}",
|
|
312
|
+
url.database or "",
|
|
313
|
+
]
|
|
314
|
+
env = {**os.environ, "PGPASSWORD": url.password} if url.password else None
|
|
315
|
+
try:
|
|
316
|
+
subprocess.run(command, check=True, capture_output=True, env=env) # noqa: S603, S607
|
|
317
|
+
except FileNotFoundError as exc:
|
|
318
|
+
destination.unlink(missing_ok=True)
|
|
319
|
+
raise DatabaseError(
|
|
320
|
+
"pg_dump is not installed or not on PATH; PostgreSQL backups require the "
|
|
321
|
+
"PostgreSQL client tools.",
|
|
322
|
+
) from exc
|
|
323
|
+
except subprocess.CalledProcessError as exc:
|
|
324
|
+
destination.unlink(missing_ok=True)
|
|
325
|
+
raise DatabaseError(
|
|
326
|
+
f"pg_dump failed with exit code {exc.returncode}: "
|
|
327
|
+
f"{exc.stderr.decode(errors='replace')}",
|
|
328
|
+
) from exc
|
|
329
|
+
else:
|
|
330
|
+
raise DatabaseError(
|
|
331
|
+
f"Unsupported dialect {dialect!r}; only sqlite and postgresql are supported.",
|
|
332
|
+
details={"dialect": dialect},
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
pruned: tuple[Path, ...] = ()
|
|
336
|
+
if keep is not None and prefix is not None:
|
|
337
|
+
pruned = prune_backups(destination.parent, prefix=prefix, keep=keep)
|
|
338
|
+
|
|
339
|
+
return BackupResult(
|
|
340
|
+
path=destination,
|
|
341
|
+
size_bytes=destination.stat().st_size,
|
|
342
|
+
created_at=datetime.now(UTC),
|
|
343
|
+
dialect=dialect,
|
|
344
|
+
pruned=pruned,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _touch_private(path: Path) -> None:
|
|
349
|
+
"""Create ``path`` empty with mode ``0600``, before anything writes content into it.
|
|
350
|
+
|
|
351
|
+
``chmod`` after the write leaves a window in which the file exists, holds the whole database,
|
|
352
|
+
and is world-readable. Creating it private first closes that window; the ``O_EXCL``-free open
|
|
353
|
+
is deliberate, since a backup destination may legitimately be overwritten.
|
|
354
|
+
"""
|
|
355
|
+
path.unlink(missing_ok=True)
|
|
356
|
+
path.touch(mode=0o600)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def prune_backups(directory: Path, *, prefix: str, keep: int) -> tuple[Path, ...]:
|
|
360
|
+
"""Delete all but the ``keep`` newest backups named ``prefix*`` in ``directory``.
|
|
361
|
+
|
|
362
|
+
Rotation is logged (database standards §7) so that a backup disappearing is always traceable to
|
|
363
|
+
a policy rather than looking like data loss.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
directory: The backups directory. A missing directory prunes nothing.
|
|
367
|
+
prefix: Only files whose name starts with this are candidates — an operator's own
|
|
368
|
+
``--output`` backup, named anything else, is never a candidate.
|
|
369
|
+
keep: How many to retain. ``0`` deletes every matching file; negative is refused.
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
The deleted paths, oldest first.
|
|
373
|
+
|
|
374
|
+
Raises:
|
|
375
|
+
DatabaseError: ``keep`` is negative.
|
|
376
|
+
"""
|
|
377
|
+
if keep < 0:
|
|
378
|
+
raise DatabaseError(f"backup retention must not be negative; got {keep}.")
|
|
379
|
+
if not directory.is_dir():
|
|
380
|
+
return ()
|
|
381
|
+
candidates = sorted(
|
|
382
|
+
(path for path in directory.iterdir() if path.is_file() and path.name.startswith(prefix)),
|
|
383
|
+
key=lambda path: (path.stat().st_mtime_ns, path.name),
|
|
384
|
+
)
|
|
385
|
+
doomed = candidates[: max(0, len(candidates) - keep)]
|
|
386
|
+
for path in doomed:
|
|
387
|
+
path.unlink(missing_ok=True)
|
|
388
|
+
_LOG.info("rotated out backup %s (retention: keep %d)", path, keep)
|
|
389
|
+
return tuple(doomed)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def pg_restore_command(engine: Engine, source: Path) -> str:
|
|
393
|
+
"""Return the ``pg_restore`` invocation an operator runs to restore ``source`` by hand."""
|
|
394
|
+
url = engine.url
|
|
395
|
+
return (
|
|
396
|
+
f"pg_restore --clean --if-exists --host={url.host or 'localhost'} "
|
|
397
|
+
f"--port={url.port or 5432} --username={url.username} "
|
|
398
|
+
f"--dbname={url.database or ''} {source}"
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def restore(
|
|
403
|
+
engine: Engine,
|
|
404
|
+
source: Path,
|
|
405
|
+
*,
|
|
406
|
+
confirm: bool,
|
|
407
|
+
known_revisions: frozenset[str] | None = None,
|
|
408
|
+
) -> RestoreResult:
|
|
409
|
+
"""Restore the database ``engine`` is connected to from a backup.
|
|
410
|
+
|
|
411
|
+
The backup is verified before anything is touched — it must open, pass its own integrity
|
|
412
|
+
check, and (when ``known_revisions`` is given) sit at a revision this build knows how to read.
|
|
413
|
+
The database being replaced is then checkpointed, released and copied aside as a
|
|
414
|
+
``.pre-restore`` sibling, which is deleted only once the restored file has been opened and has
|
|
415
|
+
passed an integrity check of its own; if it does not, the ``.pre-restore`` copy is put back and
|
|
416
|
+
the failure is raised (database standards §7).
|
|
417
|
+
|
|
418
|
+
``engine``'s connection pool is disposed as part of this — a file-level swap cannot happen
|
|
419
|
+
safely underneath live handles, and the stale ``-wal`` those handles keep alive would be
|
|
420
|
+
replayed on top of the restored file. The engine remains usable afterwards; SQLAlchemy opens a
|
|
421
|
+
fresh pool on next use.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
engine: The engine whose database will be replaced. Must be SQLite — restoring a
|
|
425
|
+
PostgreSQL database from a ``pg_dump`` archive is a deliberate, privileged operation
|
|
426
|
+
this function does not perform, and refuses while naming the ``pg_restore`` command.
|
|
427
|
+
source: The backup file to restore from.
|
|
428
|
+
confirm: Must be ``True`` or the restore is refused. There is no implicit destructive path
|
|
429
|
+
(spec §14).
|
|
430
|
+
known_revisions: Every revision this build's migration history contains. When given, a
|
|
431
|
+
backup at a revision outside it is refused rather than restored into a build that
|
|
432
|
+
cannot read it. ``None`` skips the check, for callers that have no history to check
|
|
433
|
+
against.
|
|
434
|
+
|
|
435
|
+
Returns:
|
|
436
|
+
The :class:`RestoreResult`.
|
|
437
|
+
|
|
438
|
+
Raises:
|
|
439
|
+
DatabaseError: ``confirm`` is ``False``, ``engine`` is not SQLite, ``source`` does not
|
|
440
|
+
exist, ``source`` fails to open or fails its integrity check, ``source`` is at an
|
|
441
|
+
unknown revision, or the restored file itself fails to open — in which case the
|
|
442
|
+
original database has already been put back.
|
|
443
|
+
"""
|
|
444
|
+
if not confirm:
|
|
445
|
+
raise DatabaseError(
|
|
446
|
+
"restore() requires confirm=True; there is no implicit destructive path.",
|
|
447
|
+
)
|
|
448
|
+
if engine.dialect.name == "postgresql":
|
|
449
|
+
raise DatabaseError(
|
|
450
|
+
"Restoring a PostgreSQL database is not performed in-process (spec §11.4). "
|
|
451
|
+
f"Run: {pg_restore_command(engine, source)}",
|
|
452
|
+
details={"command": pg_restore_command(engine, source), "source": str(source)},
|
|
453
|
+
)
|
|
454
|
+
if not source.is_file():
|
|
455
|
+
raise DatabaseError(
|
|
456
|
+
f"Backup file {source} does not exist.", details={"source": str(source)}
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
target_path = sqlite_path(engine)
|
|
460
|
+
_verify_backup_file(source)
|
|
461
|
+
revision = backup_revision(source)
|
|
462
|
+
if known_revisions is not None and revision is not None and revision not in known_revisions:
|
|
463
|
+
raise DatabaseError(
|
|
464
|
+
f"Backup {source} is at revision {revision!r}, which this build's migration history "
|
|
465
|
+
"does not contain; it was written by a newer version and restoring it would leave a "
|
|
466
|
+
"database this build cannot read.",
|
|
467
|
+
details={"source": str(source), "revision": revision},
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
_checkpoint_and_release(engine)
|
|
471
|
+
|
|
472
|
+
pre_restore = Path(f"{target_path}.pre-restore")
|
|
473
|
+
had_original = target_path.is_file()
|
|
474
|
+
if had_original:
|
|
475
|
+
shutil.copy2(target_path, pre_restore)
|
|
476
|
+
pre_restore.chmod(0o600)
|
|
477
|
+
for sidecar in _sidecars(target_path):
|
|
478
|
+
sidecar.unlink(missing_ok=True)
|
|
479
|
+
|
|
480
|
+
_touch_private(target_path)
|
|
481
|
+
shutil.copyfile(source, target_path)
|
|
482
|
+
|
|
483
|
+
verification = integrity_check(engine)
|
|
484
|
+
if not verification.ok:
|
|
485
|
+
engine.dispose()
|
|
486
|
+
for sidecar in _sidecars(target_path):
|
|
487
|
+
sidecar.unlink(missing_ok=True)
|
|
488
|
+
if had_original:
|
|
489
|
+
shutil.copyfile(pre_restore, target_path)
|
|
490
|
+
pre_restore.unlink(missing_ok=True)
|
|
491
|
+
else:
|
|
492
|
+
target_path.unlink(missing_ok=True)
|
|
493
|
+
raise DatabaseError(
|
|
494
|
+
f"Restored database from {source} failed its integrity check "
|
|
495
|
+
f"({verification.detail}); the original database has been put back.",
|
|
496
|
+
details={"source": str(source), "detail": verification.detail},
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
# Release the handle the verification above opened, so the restored database is left
|
|
500
|
+
# checkpointed and sidecar-free exactly as the backup was.
|
|
501
|
+
engine.dispose()
|
|
502
|
+
if had_original:
|
|
503
|
+
pre_restore.unlink(missing_ok=True)
|
|
504
|
+
|
|
505
|
+
return RestoreResult(
|
|
506
|
+
path=target_path, source=source, restored_at=datetime.now(UTC), revision=revision
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _verify_backup_file(source: Path) -> None:
|
|
511
|
+
"""Raise unless ``source`` opens as SQLite and passes ``PRAGMA integrity_check``."""
|
|
512
|
+
try:
|
|
513
|
+
probe = sqlite3.connect(f"file:{source}?mode=ro", uri=True)
|
|
514
|
+
except sqlite3.Error as exc:
|
|
515
|
+
raise DatabaseError(
|
|
516
|
+
f"Backup {source} could not be opened: {exc}", details={"source": str(source)}
|
|
517
|
+
) from exc
|
|
518
|
+
try:
|
|
519
|
+
row = probe.execute("PRAGMA integrity_check").fetchone()
|
|
520
|
+
except sqlite3.DatabaseError as exc:
|
|
521
|
+
# Damage bad enough that the pragma cannot run at all — a corrupt page 1, a file that was
|
|
522
|
+
# never SQLite — is reported by raising rather than by returning a row naming the bad
|
|
523
|
+
# pages. Which of the two shapes a given corruption produces is not stable across SQLite
|
|
524
|
+
# versions, so both say "failed its integrity check": the refusal is the same refusal.
|
|
525
|
+
raise DatabaseError(
|
|
526
|
+
f"Backup {source} failed its integrity check: it is not a readable SQLite "
|
|
527
|
+
f"database ({exc}).",
|
|
528
|
+
details={"source": str(source)},
|
|
529
|
+
) from exc
|
|
530
|
+
finally:
|
|
531
|
+
probe.close()
|
|
532
|
+
if row is None or row[0] != "ok":
|
|
533
|
+
raise DatabaseError(
|
|
534
|
+
f"Backup {source} failed its integrity check: {row[0] if row else 'unreadable'}.",
|
|
535
|
+
details={"source": str(source)},
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def integrity_check(engine: Engine) -> IntegrityResult:
|
|
540
|
+
"""Run the database's own integrity check.
|
|
541
|
+
|
|
542
|
+
Args:
|
|
543
|
+
engine: The engine to check.
|
|
544
|
+
|
|
545
|
+
Returns:
|
|
546
|
+
The :class:`IntegrityResult`. On SQLite this runs ``PRAGMA integrity_check``. On
|
|
547
|
+
PostgreSQL, which has no equivalent single command, this reports ``ok`` from a successful
|
|
548
|
+
connection and a trivial query — a real corruption check there is an operator running
|
|
549
|
+
``pg_amcheck`` or restoring onto a scratch instance, out of scope for an in-process call.
|
|
550
|
+
|
|
551
|
+
A SQLite database the pragma cannot run against at all — damaged beyond opening, or
|
|
552
|
+
otherwise refusing the statement — is reported as ``ok=False`` carrying the driver's own
|
|
553
|
+
message, never by raising: SQLite answers the same corruption either by listing the bad
|
|
554
|
+
pages in a row or by failing the statement with "database disk image is malformed", and
|
|
555
|
+
which one it picks varies with the damage and the SQLite version. Every caller here acts on
|
|
556
|
+
a failed check rather than propagating it, and neither can do that if half of all
|
|
557
|
+
corruptions arrive as an exception instead.
|
|
558
|
+
"""
|
|
559
|
+
if engine.dialect.name == "sqlite":
|
|
560
|
+
try:
|
|
561
|
+
with engine.connect() as connection:
|
|
562
|
+
row = connection.execute(text("PRAGMA integrity_check")).fetchone()
|
|
563
|
+
except SQLAlchemyError as exc:
|
|
564
|
+
cause = exc.orig if isinstance(exc, DBAPIError) and exc.orig is not None else exc
|
|
565
|
+
return IntegrityResult(ok=False, detail=str(cause))
|
|
566
|
+
detail = row[0] if row else "unreadable"
|
|
567
|
+
return IntegrityResult(ok=detail == "ok", detail=str(detail))
|
|
568
|
+
|
|
569
|
+
with engine.connect() as connection:
|
|
570
|
+
connection.execute(text("SELECT 1"))
|
|
571
|
+
return IntegrityResult(ok=True, detail="connection and a trivial query succeeded")
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def database_size_bytes(engine: Engine) -> int:
|
|
575
|
+
"""Return how much space the database occupies.
|
|
576
|
+
|
|
577
|
+
On SQLite this is the main file plus its ``-wal``/``-shm`` sidecars, because those are real
|
|
578
|
+
bytes on the user's disk and a report that omitted them would understate a busy database. On
|
|
579
|
+
PostgreSQL it is ``pg_database_size(current_database())``.
|
|
580
|
+
|
|
581
|
+
Returns:
|
|
582
|
+
The size in bytes; ``0`` when the SQLite file does not exist yet.
|
|
583
|
+
"""
|
|
584
|
+
if engine.dialect.name == "sqlite":
|
|
585
|
+
if not engine.url.database or engine.url.database == ":memory:":
|
|
586
|
+
# An in-memory database occupies no disk. Reporting that is right; raising, the way
|
|
587
|
+
# sqlite_path() does for callers that need a file to copy, would make a size report
|
|
588
|
+
# the thing that breaks a status command.
|
|
589
|
+
return 0
|
|
590
|
+
database = sqlite_path(engine)
|
|
591
|
+
paths = (database, *_sidecars(database))
|
|
592
|
+
return sum(path.stat().st_size for path in paths if path.is_file())
|
|
593
|
+
with engine.connect() as connection:
|
|
594
|
+
size = connection.execute(text("SELECT pg_database_size(current_database())")).scalar_one()
|
|
595
|
+
return int(size)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def reclaimable_bytes(engine: Engine) -> int:
|
|
599
|
+
"""Estimate what a ``VACUUM`` would reclaim, so a caller can preview it before running.
|
|
600
|
+
|
|
601
|
+
On SQLite this is exact and cheap: the free page count times the page size. On PostgreSQL there
|
|
602
|
+
is no comparable cheap estimate — a real one needs ``pgstattuple`` — so this reports ``0``, and
|
|
603
|
+
the caller reports the actual before/after difference instead of a prediction.
|
|
604
|
+
"""
|
|
605
|
+
if engine.dialect.name != "sqlite":
|
|
606
|
+
return 0
|
|
607
|
+
with engine.connect() as connection:
|
|
608
|
+
free_pages = connection.execute(text("PRAGMA freelist_count")).scalar_one()
|
|
609
|
+
page_size = connection.execute(text("PRAGMA page_size")).scalar_one()
|
|
610
|
+
return int(free_pages) * int(page_size)
|