file-analyzer-server 1.1.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.
- file_analyzer/server/__init__.py +64 -0
- file_analyzer/server/__main__.py +216 -0
- file_analyzer/server/backup.py +351 -0
- file_analyzer/server/backup_pool.py +296 -0
- file_analyzer/server/backup_worker.py +102 -0
- file_analyzer/server/catalog.py +307 -0
- file_analyzer/server/daemon.py +205 -0
- file_analyzer/server/dbhost.py +344 -0
- file_analyzer/server/go/backup.go +236 -0
- file_analyzer/server/go/go.mod +3 -0
- file_analyzer/server/locking.py +164 -0
- file_analyzer/server/pglog.py +137 -0
- file_analyzer/server/readonly.py +74 -0
- file_analyzer/server/sdk.py +181 -0
- file_analyzer/server/server.py +596 -0
- file_analyzer_server-1.1.0.dist-info/METADATA +70 -0
- file_analyzer_server-1.1.0.dist-info/RECORD +21 -0
- file_analyzer_server-1.1.0.dist-info/WHEEL +5 -0
- file_analyzer_server-1.1.0.dist-info/entry_points.txt +2 -0
- file_analyzer_server-1.1.0.dist-info/licenses/LICENSE +201 -0
- file_analyzer_server-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""
|
|
2
|
+
``file_analyzer.server`` -- the database-hosting server and its client.
|
|
3
|
+
|
|
4
|
+
This subpackage is the *hosting* half of the server split (the MCP/agent
|
|
5
|
+
transport half stays in ``mcp_server`` / :class:`FileAnalyzerMCPServer`). It
|
|
6
|
+
stands up a real, detachable, multi-instance server that hosts a repo-session's
|
|
7
|
+
databases under the ``PROJECT_ROOT-{session-token}-{db_name}`` convention in
|
|
8
|
+
SQLite or Postgres/MySQL, coordinates parallel instances through a shared
|
|
9
|
+
session catalog (with token reuse), runs periodic chunked rotating backups, can
|
|
10
|
+
surface the backend Postgres logs, and serves a token-authenticated HTTP control
|
|
11
|
+
plane that a :class:`ServerClient` connects to.
|
|
12
|
+
|
|
13
|
+
Everything here is import-safe on a bare interpreter: only the standard library
|
|
14
|
+
is imported at module load; the optional Postgres/MySQL drivers are imported
|
|
15
|
+
lazily by :class:`~file_analyzer.store.SqlStore` when a remote backend is
|
|
16
|
+
actually used.
|
|
17
|
+
|
|
18
|
+
References consulted for the implementation (best-practice grounding):
|
|
19
|
+
|
|
20
|
+
* SQLite online backup API (``Connection.backup(pages=...)``) --
|
|
21
|
+
https://docs.python.org/3/library/sqlite3.html#sqlite3.Connection.backup
|
|
22
|
+
* ``secrets`` for session tokens --
|
|
23
|
+
https://docs.python.org/3/library/secrets.html
|
|
24
|
+
* Cross-platform file locking (fcntl/msvcrt) --
|
|
25
|
+
https://dev.to/susumun/cross-platform-file-locking-in-python-fcntl-vs-msvcrt-from-scratch-19c5
|
|
26
|
+
* PostgreSQL logging / ``logging_collector`` / ``log_directory`` --
|
|
27
|
+
https://www.postgresql.org/docs/current/runtime-config-logging.html
|
|
28
|
+
* ``pg_dump`` custom-format backups --
|
|
29
|
+
https://www.postgresql.org/docs/current/app-pgdump.html
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from ..client import ServerClient, ServerError
|
|
35
|
+
from ..naming import sqlite_db_path, storage_key
|
|
36
|
+
from ..tokens import new_session_token, project_fingerprint, project_slug
|
|
37
|
+
from .backup import BackupManager
|
|
38
|
+
from .catalog import SessionCatalog, default_catalog_dir
|
|
39
|
+
from .daemon import pid_alive, spawn_detached, stop_pid
|
|
40
|
+
from .dbhost import DatabaseHost, HostedDatabase
|
|
41
|
+
from .pglog import PostgresLogTailer
|
|
42
|
+
from .sdk import FileAnalyzerServer
|
|
43
|
+
from .server import DatabaseServer
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"DatabaseServer",
|
|
47
|
+
"FileAnalyzerServer",
|
|
48
|
+
"ServerClient",
|
|
49
|
+
"ServerError",
|
|
50
|
+
"SessionCatalog",
|
|
51
|
+
"DatabaseHost",
|
|
52
|
+
"HostedDatabase",
|
|
53
|
+
"BackupManager",
|
|
54
|
+
"PostgresLogTailer",
|
|
55
|
+
"default_catalog_dir",
|
|
56
|
+
"storage_key",
|
|
57
|
+
"sqlite_db_path",
|
|
58
|
+
"new_session_token",
|
|
59
|
+
"project_fingerprint",
|
|
60
|
+
"project_slug",
|
|
61
|
+
"pid_alive",
|
|
62
|
+
"spawn_detached",
|
|
63
|
+
"stop_pid",
|
|
64
|
+
]
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""
|
|
2
|
+
``python -m file_analyzer.server`` -- run and control the database-hosting server.
|
|
3
|
+
|
|
4
|
+
Subcommands::
|
|
5
|
+
|
|
6
|
+
start [root] [--detach] [options] # start a server (detached persists by PID)
|
|
7
|
+
run [root] [options] # foreground runner (what --detach launches)
|
|
8
|
+
stop [root] [--all] [--server-id] # stop this repo's server(s) by PID
|
|
9
|
+
status [root] # running servers + hosted databases
|
|
10
|
+
host [root] --db-name N --source P # host a database file under the session
|
|
11
|
+
databases [root] # list hosted databases (JSON)
|
|
12
|
+
backup [root] [--storage-key K] # run a backup now
|
|
13
|
+
logs [root] [--lines N] # tail the backend Postgres logs
|
|
14
|
+
|
|
15
|
+
``start --detach`` launches a background process that outlives this shell and
|
|
16
|
+
writes a PID file; ``stop`` terminates it by that PID. Multiple ``start`` calls on
|
|
17
|
+
the same repository share one session token (no duplicate database copies).
|
|
18
|
+
|
|
19
|
+
The subcommand is optional: ``python -m file_analyzer.server /repo`` is treated as
|
|
20
|
+
``python -m file_analyzer.server start /repo``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import sys
|
|
28
|
+
from typing import List, Optional
|
|
29
|
+
|
|
30
|
+
from .._version import __version__
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
34
|
+
ap = argparse.ArgumentParser(
|
|
35
|
+
prog="python -m file_analyzer.server",
|
|
36
|
+
description="Host a repo-session's databases; serve them to clients.",
|
|
37
|
+
)
|
|
38
|
+
ap.add_argument(
|
|
39
|
+
"--version",
|
|
40
|
+
action="version",
|
|
41
|
+
version=f"file-analyzer {__version__}",
|
|
42
|
+
help="print the file-analyzer version and exit.",
|
|
43
|
+
)
|
|
44
|
+
sub = ap.add_subparsers(dest="command")
|
|
45
|
+
|
|
46
|
+
def _common(p: argparse.ArgumentParser, *, serving: bool) -> None:
|
|
47
|
+
p.add_argument("root", nargs="?", default=".", help="repository to host")
|
|
48
|
+
p.add_argument(
|
|
49
|
+
"--backend",
|
|
50
|
+
default="sqlite",
|
|
51
|
+
help="'sqlite' (live files, default) or a SQL URL "
|
|
52
|
+
"(postgresql://... / mysql://...) for the chunked content store.",
|
|
53
|
+
)
|
|
54
|
+
p.add_argument("--catalog-dir", default=None, help="shared catalog directory")
|
|
55
|
+
p.add_argument("--catalog-target", default=None, help="catalog SQL URL/path")
|
|
56
|
+
if serving:
|
|
57
|
+
p.add_argument("--host", default="127.0.0.1", help="bind host")
|
|
58
|
+
p.add_argument("--port", type=int, default=0, help="bind port (0=auto)")
|
|
59
|
+
p.add_argument("--token", default=None, help="explicit session token")
|
|
60
|
+
p.add_argument("--server-id", default=None, help="explicit server id")
|
|
61
|
+
p.add_argument(
|
|
62
|
+
"--chunk-bytes",
|
|
63
|
+
type=int,
|
|
64
|
+
default=8 * 1024 * 1024,
|
|
65
|
+
help="per-chunk size for the remote content store (default 8 MiB)",
|
|
66
|
+
)
|
|
67
|
+
p.add_argument(
|
|
68
|
+
"--backup-interval",
|
|
69
|
+
type=float,
|
|
70
|
+
default=900.0,
|
|
71
|
+
help="seconds between periodic backups (0 disables; default 900)",
|
|
72
|
+
)
|
|
73
|
+
p.add_argument(
|
|
74
|
+
"--backup-keep",
|
|
75
|
+
type=int,
|
|
76
|
+
default=5,
|
|
77
|
+
help="backup sets to keep per database before rotating (default 5)",
|
|
78
|
+
)
|
|
79
|
+
p.add_argument(
|
|
80
|
+
"--no-contain",
|
|
81
|
+
action="store_true",
|
|
82
|
+
help="skip the bare-metal containment guard (caller owns isolation)",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
start = sub.add_parser("start", help="start a server (optionally detached)")
|
|
86
|
+
_common(start, serving=True)
|
|
87
|
+
start.add_argument(
|
|
88
|
+
"--detach",
|
|
89
|
+
action="store_true",
|
|
90
|
+
help="run in the background, detached with a PID, and return its endpoint",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
run = sub.add_parser("run", help="foreground runner (used by --detach)")
|
|
94
|
+
_common(run, serving=True)
|
|
95
|
+
|
|
96
|
+
stop = sub.add_parser("stop", help="stop server(s) hosting a repository")
|
|
97
|
+
_common(stop, serving=False)
|
|
98
|
+
stop.add_argument("--all", action="store_true", help="stop all servers for repo")
|
|
99
|
+
stop.add_argument("--server-id", default=None, help="stop one server by id")
|
|
100
|
+
|
|
101
|
+
status = sub.add_parser("status", help="show running servers + hosted databases")
|
|
102
|
+
_common(status, serving=False)
|
|
103
|
+
|
|
104
|
+
host = sub.add_parser("host", help="host a database file under the session")
|
|
105
|
+
_common(host, serving=False)
|
|
106
|
+
host.add_argument("--db-name", required=True, help="logical database name")
|
|
107
|
+
host.add_argument("--source", required=True, help="path to the SQLite database")
|
|
108
|
+
|
|
109
|
+
dbs = sub.add_parser("databases", help="list hosted databases")
|
|
110
|
+
_common(dbs, serving=False)
|
|
111
|
+
|
|
112
|
+
backup = sub.add_parser("backup", help="run a backup now")
|
|
113
|
+
_common(backup, serving=False)
|
|
114
|
+
backup.add_argument("--storage-key", default=None, help="one database, else all")
|
|
115
|
+
|
|
116
|
+
logs = sub.add_parser("logs", help="tail the backend Postgres logs")
|
|
117
|
+
_common(logs, serving=False)
|
|
118
|
+
logs.add_argument("--lines", type=int, default=100, help="lines to show")
|
|
119
|
+
|
|
120
|
+
return ap
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _build_server(args: argparse.Namespace):
|
|
124
|
+
from .server import DatabaseServer
|
|
125
|
+
|
|
126
|
+
kwargs = dict(
|
|
127
|
+
root=args.root,
|
|
128
|
+
backend=args.backend,
|
|
129
|
+
catalog_dir=args.catalog_dir,
|
|
130
|
+
catalog_target=args.catalog_target,
|
|
131
|
+
)
|
|
132
|
+
for attr in (
|
|
133
|
+
"host",
|
|
134
|
+
"port",
|
|
135
|
+
"token",
|
|
136
|
+
"server_id",
|
|
137
|
+
"chunk_bytes",
|
|
138
|
+
"backup_interval",
|
|
139
|
+
"backup_keep",
|
|
140
|
+
):
|
|
141
|
+
if hasattr(args, attr) and getattr(args, attr) is not None:
|
|
142
|
+
kwargs[attr] = getattr(args, attr)
|
|
143
|
+
return DatabaseServer(**kwargs)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
147
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
148
|
+
known = {
|
|
149
|
+
"start",
|
|
150
|
+
"run",
|
|
151
|
+
"stop",
|
|
152
|
+
"status",
|
|
153
|
+
"host",
|
|
154
|
+
"databases",
|
|
155
|
+
"backup",
|
|
156
|
+
"logs",
|
|
157
|
+
}
|
|
158
|
+
if argv and argv[0] not in known and argv[0] not in ("-h", "--help", "--version"):
|
|
159
|
+
argv = ["start", *argv]
|
|
160
|
+
|
|
161
|
+
args = build_parser().parse_args(argv)
|
|
162
|
+
if not args.command:
|
|
163
|
+
build_parser().print_help()
|
|
164
|
+
return 2
|
|
165
|
+
|
|
166
|
+
if args.command in ("start", "run"):
|
|
167
|
+
server = _build_server(args)
|
|
168
|
+
if args.command == "start" and getattr(args, "detach", False):
|
|
169
|
+
info = server.start_detached()
|
|
170
|
+
print(json.dumps(info, indent=2, default=str))
|
|
171
|
+
return 0
|
|
172
|
+
# Foreground run.
|
|
173
|
+
return server.serve(contained=not getattr(args, "no_contain", False))
|
|
174
|
+
|
|
175
|
+
if args.command == "stop":
|
|
176
|
+
server = _build_server(args)
|
|
177
|
+
result = server.stop(all_for_repo=args.all)
|
|
178
|
+
print(json.dumps(result, indent=2, default=str))
|
|
179
|
+
return 0
|
|
180
|
+
|
|
181
|
+
if args.command == "status":
|
|
182
|
+
server = _build_server(args)
|
|
183
|
+
print(json.dumps(server.status(), indent=2, default=str))
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
if args.command == "host":
|
|
187
|
+
server = _build_server(args)
|
|
188
|
+
rec = server.host_database(args.source, args.db_name)
|
|
189
|
+
print(json.dumps(rec, indent=2, default=str))
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
if args.command == "databases":
|
|
193
|
+
server = _build_server(args)
|
|
194
|
+
print(json.dumps(server.databases(), indent=2, default=str))
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
if args.command == "backup":
|
|
198
|
+
server = _build_server(args)
|
|
199
|
+
if args.storage_key:
|
|
200
|
+
out = server.backups.backup_database(args.storage_key)
|
|
201
|
+
else:
|
|
202
|
+
out = server.backups.backup_all()
|
|
203
|
+
print(json.dumps(out, indent=2, default=str))
|
|
204
|
+
return 0
|
|
205
|
+
|
|
206
|
+
if args.command == "logs":
|
|
207
|
+
server = _build_server(args)
|
|
208
|
+
print(json.dumps(server.postgres_logs(args.lines), indent=2, default=str))
|
|
209
|
+
return 0
|
|
210
|
+
|
|
211
|
+
build_parser().print_help()
|
|
212
|
+
return 2
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Periodic, chunked, rotating backups of hosted databases.
|
|
3
|
+
|
|
4
|
+
The server backs up what it hosts on a schedule, and does it in a way that keeps
|
|
5
|
+
individual backup artifacts bounded in size ("chunking so that db size will be
|
|
6
|
+
modulated") and old backups from piling up (rotation):
|
|
7
|
+
|
|
8
|
+
* **SQLite hosted databases** are copied with SQLite's *online backup API*
|
|
9
|
+
(``sqlite3.Connection.backup(dest, pages=...)``), which produces a consistent
|
|
10
|
+
snapshot without blocking writers and, by copying a bounded number of pages per
|
|
11
|
+
step, never holds the source locked for long. The snapshot is gzip-compressed
|
|
12
|
+
and then split into fixed-size ``.partNNN`` chunk files, with a JSON manifest
|
|
13
|
+
describing the set.
|
|
14
|
+
* **Remote (Postgres/MySQL) hosted databases** are reassembled from their content
|
|
15
|
+
chunks and backed up the same way; additionally, when the backend is Postgres
|
|
16
|
+
and ``pg_dump`` is on ``PATH``, a native ``pg_dump -Fc`` custom-format dump of
|
|
17
|
+
the whole cluster database is taken and chunked alongside.
|
|
18
|
+
|
|
19
|
+
:meth:`BackupManager.start_periodic` runs the whole thing on a background daemon
|
|
20
|
+
thread until stopped; the server owns one and stops it on shutdown.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import gzip
|
|
26
|
+
import hashlib
|
|
27
|
+
import json
|
|
28
|
+
import shutil
|
|
29
|
+
import sqlite3
|
|
30
|
+
import subprocess
|
|
31
|
+
import tempfile
|
|
32
|
+
import threading
|
|
33
|
+
import time
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any, Dict, List, Optional
|
|
36
|
+
|
|
37
|
+
from ..tokens import PathLike
|
|
38
|
+
from .catalog import SessionCatalog
|
|
39
|
+
from .dbhost import DEFAULT_CHUNK_BYTES, DatabaseHost
|
|
40
|
+
from .locking import FileLock, atomic_write_text
|
|
41
|
+
|
|
42
|
+
#: SQLite pages copied per online-backup step (32 MiB at the 4 KiB default).
|
|
43
|
+
DEFAULT_BACKUP_PAGES = 8192
|
|
44
|
+
#: Default size of each backup part file: 16 MiB.
|
|
45
|
+
DEFAULT_PART_BYTES = 16 * 1024 * 1024
|
|
46
|
+
#: Default number of backup sets to keep per database before rotating out.
|
|
47
|
+
DEFAULT_KEEP = 5
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _timestamp() -> str:
|
|
51
|
+
return time.strftime("%Y%m%d-%H%M%S", time.gmtime())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class BackupManager:
|
|
55
|
+
"""Creates, chunks, rotates and restores backups for a :class:`DatabaseHost`."""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
host: DatabaseHost,
|
|
60
|
+
*,
|
|
61
|
+
backup_dir: PathLike,
|
|
62
|
+
part_bytes: int = DEFAULT_PART_BYTES,
|
|
63
|
+
pages_per_step: int = DEFAULT_BACKUP_PAGES,
|
|
64
|
+
keep: int = DEFAULT_KEEP,
|
|
65
|
+
) -> None:
|
|
66
|
+
self.host = host
|
|
67
|
+
self.backup_dir = Path(backup_dir)
|
|
68
|
+
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
self.part_bytes = max(64 * 1024, int(part_bytes))
|
|
70
|
+
self.pages_per_step = max(64, int(pages_per_step))
|
|
71
|
+
self.keep = max(1, int(keep))
|
|
72
|
+
# Legacy shared lock path (kept for compatibility); per-database backups
|
|
73
|
+
# use a per-key lock so distinct databases back up genuinely concurrently.
|
|
74
|
+
self._lock_path = self.backup_dir / "backup.lock"
|
|
75
|
+
self._stop = threading.Event()
|
|
76
|
+
self._thread: Optional[threading.Thread] = None
|
|
77
|
+
|
|
78
|
+
def _key_lock_path(self, key: str) -> Path:
|
|
79
|
+
"""Per-key lock: distinct storage keys never share a lock, so concurrent
|
|
80
|
+
backups of different databases do not serialize on one global lock. Each
|
|
81
|
+
key owns its own ``<backup_dir>/<key>/`` subtree (snapshot set + rotation),
|
|
82
|
+
so the lock is both sufficient and non-conflicting."""
|
|
83
|
+
key_dir = self.backup_dir / key
|
|
84
|
+
key_dir.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
return key_dir / "backup.lock"
|
|
86
|
+
|
|
87
|
+
# -- snapshot -------------------------------------------------------
|
|
88
|
+
def _online_backup_sqlite(self, source_path: PathLike, dest_path: PathLike) -> None:
|
|
89
|
+
"""Consistent hot copy of a SQLite file via the online backup API."""
|
|
90
|
+
src = sqlite3.connect(f"file:{Path(source_path).as_posix()}?mode=ro", uri=True)
|
|
91
|
+
try:
|
|
92
|
+
dst = sqlite3.connect(str(dest_path))
|
|
93
|
+
try:
|
|
94
|
+
src.backup(dst, pages=self.pages_per_step)
|
|
95
|
+
finally:
|
|
96
|
+
dst.close()
|
|
97
|
+
finally:
|
|
98
|
+
src.close()
|
|
99
|
+
|
|
100
|
+
def _chunk_file(self, plain_path: Path, key: str, ts: str) -> Dict[str, Any]:
|
|
101
|
+
"""gzip ``plain_path`` and split it into bounded part files + manifest."""
|
|
102
|
+
set_dir = self.backup_dir / key / ts
|
|
103
|
+
set_dir.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
parts: List[Dict[str, Any]] = []
|
|
105
|
+
sha = hashlib.sha256()
|
|
106
|
+
raw_size = 0
|
|
107
|
+
seq = 0
|
|
108
|
+
with open(plain_path, "rb") as fin:
|
|
109
|
+
# Stream through gzip in memory-bounded windows, emitting part files.
|
|
110
|
+
buf = bytearray()
|
|
111
|
+
compressor_path = set_dir / "_tmp.gz"
|
|
112
|
+
with gzip.open(compressor_path, "wb") as gz:
|
|
113
|
+
for block in iter(lambda: fin.read(1024 * 1024), b""):
|
|
114
|
+
raw_size += len(block)
|
|
115
|
+
sha.update(block)
|
|
116
|
+
gz.write(block)
|
|
117
|
+
# Now split the compressed file into parts.
|
|
118
|
+
with open(compressor_path, "rb") as gzf:
|
|
119
|
+
while True:
|
|
120
|
+
block = gzf.read(self.part_bytes)
|
|
121
|
+
if not block:
|
|
122
|
+
break
|
|
123
|
+
part_name = f"{key}.{ts}.gz.part{seq:04d}"
|
|
124
|
+
part_path = set_dir / part_name
|
|
125
|
+
part_path.write_bytes(block)
|
|
126
|
+
parts.append({"seq": seq, "name": part_name, "bytes": len(block)})
|
|
127
|
+
seq += 1
|
|
128
|
+
compressor_path.unlink()
|
|
129
|
+
manifest = {
|
|
130
|
+
"storage_key": key,
|
|
131
|
+
"timestamp": ts,
|
|
132
|
+
"raw_size_bytes": raw_size,
|
|
133
|
+
"raw_sha256": sha.hexdigest(),
|
|
134
|
+
"compression": "gzip",
|
|
135
|
+
"part_bytes": self.part_bytes,
|
|
136
|
+
"n_parts": len(parts),
|
|
137
|
+
"parts": parts,
|
|
138
|
+
"created_at": time.time(),
|
|
139
|
+
}
|
|
140
|
+
atomic_write_text(set_dir / "manifest.json", json.dumps(manifest, indent=2))
|
|
141
|
+
return manifest
|
|
142
|
+
|
|
143
|
+
def backup_database(self, storage_key_: str) -> Dict[str, Any]:
|
|
144
|
+
"""Back up a single hosted database; return its manifest."""
|
|
145
|
+
rec = self.host.catalog.get_database(storage_key_)
|
|
146
|
+
if rec is None:
|
|
147
|
+
raise KeyError(f"no hosted database with key {storage_key_!r}")
|
|
148
|
+
ts = _timestamp()
|
|
149
|
+
with FileLock(self._key_lock_path(storage_key_), timeout=60.0):
|
|
150
|
+
with tempfile.TemporaryDirectory(prefix="fa-backup-") as tmpd:
|
|
151
|
+
snap = Path(tmpd) / "snapshot.db"
|
|
152
|
+
if rec.get("dialect") == "sqlite":
|
|
153
|
+
self._online_backup_sqlite(rec["location"], snap)
|
|
154
|
+
else:
|
|
155
|
+
# Reassemble remote chunks, then take a consistent copy.
|
|
156
|
+
materialized = self.host.materialize(storage_key_, snap)
|
|
157
|
+
snap = Path(materialized)
|
|
158
|
+
manifest = self._chunk_file(snap, storage_key_, ts)
|
|
159
|
+
self._rotate(storage_key_)
|
|
160
|
+
return manifest
|
|
161
|
+
|
|
162
|
+
def job_spec(self) -> Dict[str, Any]:
|
|
163
|
+
"""A JSON-serializable description of this manager + its host + catalog.
|
|
164
|
+
|
|
165
|
+
A subprocess backup worker (driven by the Go backup pool) rebuilds an
|
|
166
|
+
identical :class:`SessionCatalog`, :class:`DatabaseHost` and
|
|
167
|
+
:class:`BackupManager` from this spec, so it can back up a subset of the
|
|
168
|
+
storage keys with exactly the same chunk/manifest/rotation logic -- which
|
|
169
|
+
is what keeps every produced backup restore-compatible regardless of
|
|
170
|
+
which process created it.
|
|
171
|
+
"""
|
|
172
|
+
return {
|
|
173
|
+
"catalog_dir": str(self.host.catalog.dir),
|
|
174
|
+
"catalog_target": self.host.catalog.target,
|
|
175
|
+
"catalog_lock_timeout": self.host.catalog.lock_timeout,
|
|
176
|
+
"root": self.host.root,
|
|
177
|
+
"token": self.host.token,
|
|
178
|
+
"data_dir": str(self.host.data_dir),
|
|
179
|
+
"backend": self.host.backend,
|
|
180
|
+
"chunk_bytes": self.host.chunk_bytes,
|
|
181
|
+
"backup_dir": str(self.backup_dir),
|
|
182
|
+
"part_bytes": self.part_bytes,
|
|
183
|
+
"pages_per_step": self.pages_per_step,
|
|
184
|
+
"keep": self.keep,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
@classmethod
|
|
188
|
+
def from_job_spec(cls, spec: Dict[str, Any]) -> "BackupManager":
|
|
189
|
+
"""Rebuild a :class:`BackupManager` (with host + catalog) from a spec."""
|
|
190
|
+
catalog = SessionCatalog(
|
|
191
|
+
spec["catalog_dir"],
|
|
192
|
+
target=spec.get("catalog_target"),
|
|
193
|
+
lock_timeout=float(spec.get("catalog_lock_timeout", 30.0)),
|
|
194
|
+
)
|
|
195
|
+
host = DatabaseHost(
|
|
196
|
+
root=spec["root"],
|
|
197
|
+
token=spec["token"],
|
|
198
|
+
data_dir=spec["data_dir"],
|
|
199
|
+
catalog=catalog,
|
|
200
|
+
backend=spec.get("backend", "sqlite"),
|
|
201
|
+
chunk_bytes=int(spec.get("chunk_bytes", DEFAULT_CHUNK_BYTES)),
|
|
202
|
+
)
|
|
203
|
+
return cls(
|
|
204
|
+
host,
|
|
205
|
+
backup_dir=spec["backup_dir"],
|
|
206
|
+
part_bytes=int(spec.get("part_bytes", DEFAULT_PART_BYTES)),
|
|
207
|
+
pages_per_step=int(spec.get("pages_per_step", DEFAULT_BACKUP_PAGES)),
|
|
208
|
+
keep=int(spec.get("keep", DEFAULT_KEEP)),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def backup_all(
|
|
212
|
+
self, *, workers: Optional[int] = None, use_go: bool = True
|
|
213
|
+
) -> List[Dict[str, Any]]:
|
|
214
|
+
"""Back up every hosted database for this session, concurrently.
|
|
215
|
+
|
|
216
|
+
Distinct storage keys own disjoint on-disk subtrees and per-key locks, so
|
|
217
|
+
they back up in parallel with no contention. The work is fanned out by a
|
|
218
|
+
Go pool (goroutine pool spawning backup-worker subprocesses) when the Go
|
|
219
|
+
toolchain is present, and otherwise by a pure-Python thread pool -- both
|
|
220
|
+
call the same :meth:`backup_database`, so the produced backups are
|
|
221
|
+
identical either way. Returns one manifest (or ``{"error": ...}``) per key.
|
|
222
|
+
"""
|
|
223
|
+
keys = [hosted.storage_key for hosted in self.host.list()]
|
|
224
|
+
if not keys:
|
|
225
|
+
return []
|
|
226
|
+
from .backup_pool import backup_keys
|
|
227
|
+
|
|
228
|
+
report = backup_keys(self, keys, workers=workers, use_go=use_go)
|
|
229
|
+
return report["results"]
|
|
230
|
+
|
|
231
|
+
# -- native postgres dump ------------------------------------------
|
|
232
|
+
def pg_dump(self, database_url: str) -> Optional[Dict[str, Any]]:
|
|
233
|
+
"""Take a native ``pg_dump -Fc`` custom-format backup, then chunk it.
|
|
234
|
+
|
|
235
|
+
Returns the manifest, or ``None`` if ``pg_dump`` is not available. The
|
|
236
|
+
custom format (``-Fc``) is the production-recommended, restore-flexible
|
|
237
|
+
format; the dump streams to a file (never through memory).
|
|
238
|
+
"""
|
|
239
|
+
if shutil.which("pg_dump") is None:
|
|
240
|
+
return None
|
|
241
|
+
ts = _timestamp()
|
|
242
|
+
with FileLock(self._key_lock_path("pg_cluster_dump"), timeout=120.0):
|
|
243
|
+
with tempfile.TemporaryDirectory(prefix="fa-pgdump-") as tmpd:
|
|
244
|
+
dump = Path(tmpd) / "cluster.dump"
|
|
245
|
+
with open(dump, "wb") as out:
|
|
246
|
+
proc = subprocess.run(
|
|
247
|
+
["pg_dump", "--format=custom", "--no-owner", database_url],
|
|
248
|
+
stdout=out,
|
|
249
|
+
stderr=subprocess.PIPE,
|
|
250
|
+
)
|
|
251
|
+
if proc.returncode != 0:
|
|
252
|
+
raise RuntimeError(
|
|
253
|
+
"pg_dump failed: "
|
|
254
|
+
+ proc.stderr.decode("utf-8", "replace").strip()
|
|
255
|
+
)
|
|
256
|
+
manifest = self._chunk_file(dump, "pg_cluster_dump", ts)
|
|
257
|
+
self._rotate("pg_cluster_dump")
|
|
258
|
+
return manifest
|
|
259
|
+
|
|
260
|
+
# -- rotation / listing / restore ----------------------------------
|
|
261
|
+
def _rotate(self, key: str) -> None:
|
|
262
|
+
key_dir = self.backup_dir / key
|
|
263
|
+
if not key_dir.is_dir():
|
|
264
|
+
return
|
|
265
|
+
sets = sorted(
|
|
266
|
+
(d for d in key_dir.iterdir() if d.is_dir()), key=lambda d: d.name
|
|
267
|
+
)
|
|
268
|
+
excess = len(sets) - self.keep
|
|
269
|
+
for old in sets[: max(0, excess)]:
|
|
270
|
+
shutil.rmtree(old, ignore_errors=True)
|
|
271
|
+
|
|
272
|
+
def list_backups(self, storage_key_: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
273
|
+
out: List[Dict[str, Any]] = []
|
|
274
|
+
keys = (
|
|
275
|
+
[storage_key_]
|
|
276
|
+
if storage_key_
|
|
277
|
+
else [d.name for d in self.backup_dir.iterdir() if d.is_dir()]
|
|
278
|
+
)
|
|
279
|
+
for key in keys:
|
|
280
|
+
key_dir = self.backup_dir / key
|
|
281
|
+
if not key_dir.is_dir():
|
|
282
|
+
continue
|
|
283
|
+
for set_dir in sorted(key_dir.iterdir()):
|
|
284
|
+
man = set_dir / "manifest.json"
|
|
285
|
+
if man.is_file():
|
|
286
|
+
try:
|
|
287
|
+
out.append(json.loads(man.read_text(encoding="utf-8")))
|
|
288
|
+
except (OSError, ValueError):
|
|
289
|
+
continue
|
|
290
|
+
return out
|
|
291
|
+
|
|
292
|
+
def restore(self, storage_key_: str, timestamp: str, dest: PathLike) -> Path:
|
|
293
|
+
"""Reassemble + decompress a backup set into ``dest`` (a SQLite file)."""
|
|
294
|
+
set_dir = self.backup_dir / storage_key_ / timestamp
|
|
295
|
+
man_path = set_dir / "manifest.json"
|
|
296
|
+
if not man_path.is_file():
|
|
297
|
+
raise FileNotFoundError(f"no backup manifest at {man_path}")
|
|
298
|
+
manifest = json.loads(man_path.read_text(encoding="utf-8"))
|
|
299
|
+
parts = sorted(manifest["parts"], key=lambda p: p["seq"])
|
|
300
|
+
dest = Path(dest)
|
|
301
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".gz") as tmp:
|
|
302
|
+
gz_path = Path(tmp.name)
|
|
303
|
+
for part in parts:
|
|
304
|
+
tmp.write((set_dir / part["name"]).read_bytes())
|
|
305
|
+
try:
|
|
306
|
+
with gzip.open(gz_path, "rb") as gz, open(dest, "wb") as fout:
|
|
307
|
+
shutil.copyfileobj(gz, fout)
|
|
308
|
+
finally:
|
|
309
|
+
gz_path.unlink()
|
|
310
|
+
expected = manifest.get("raw_sha256")
|
|
311
|
+
if expected:
|
|
312
|
+
h = hashlib.sha256()
|
|
313
|
+
with open(dest, "rb") as fh:
|
|
314
|
+
for block in iter(lambda: fh.read(1024 * 1024), b""):
|
|
315
|
+
h.update(block)
|
|
316
|
+
if h.hexdigest() != expected:
|
|
317
|
+
raise ValueError("restored file checksum mismatch")
|
|
318
|
+
return dest
|
|
319
|
+
|
|
320
|
+
# -- periodic loop --------------------------------------------------
|
|
321
|
+
def start_periodic(
|
|
322
|
+
self, interval: float, *, database_url: Optional[str] = None
|
|
323
|
+
) -> "BackupManager":
|
|
324
|
+
"""Run :meth:`backup_all` (and pg_dump, if applicable) every ``interval`` s."""
|
|
325
|
+
if self._thread is not None:
|
|
326
|
+
return self
|
|
327
|
+
self._stop.clear()
|
|
328
|
+
|
|
329
|
+
def _loop() -> None:
|
|
330
|
+
# Wait first, so startup is not immediately followed by a backup.
|
|
331
|
+
while not self._stop.wait(interval):
|
|
332
|
+
try:
|
|
333
|
+
self.backup_all()
|
|
334
|
+
if database_url and self.host.dialect == "postgresql":
|
|
335
|
+
try:
|
|
336
|
+
self.pg_dump(database_url)
|
|
337
|
+
except Exception:
|
|
338
|
+
pass
|
|
339
|
+
except Exception:
|
|
340
|
+
# Never let a backup failure kill the loop.
|
|
341
|
+
pass
|
|
342
|
+
|
|
343
|
+
self._thread = threading.Thread(target=_loop, name="fa-backup", daemon=True)
|
|
344
|
+
self._thread.start()
|
|
345
|
+
return self
|
|
346
|
+
|
|
347
|
+
def stop(self, timeout: float = 10.0) -> None:
|
|
348
|
+
self._stop.set()
|
|
349
|
+
if self._thread is not None:
|
|
350
|
+
self._thread.join(timeout=timeout)
|
|
351
|
+
self._thread = None
|