feat: with an off-site destination set up, database backups stream to it
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 1s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 23s
CI and images / backend-lint-and-test (push) Successful in 41s
CI and images / integration (push) Successful in 2m25s
CI and images / sign-extension (push) Successful in 2s
CI and images / build-agent (push) Successful in 5s
CI and images / build-web (push) Successful in 1m50s
CI and images / smoke-web (push) Successful in 49s
CI and images / promote (push) Successful in 2s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped

The nightly database backup (and "Run DB backup now") now sends pg_dump's
output straight to the off-site bucket as a multipart upload, with no copy
staged on this server. The object is named database/fabledcurator-<UTC
time>.dump and appears only once pg_dump has exited cleanly and every part
has landed; a failed dump aborts the upload and records pg_dump's last line.
Without a destination, dumps are written to /images/_backups as before.

- backup_s3: Upload (start / add part / complete / abort), with a busy or
  silent bucket rested and asked again (15 s, 60 s, Retry-After obeyed), and
  a Pacer that holds uploads to the new bandwidth limit.
- Settings: "Upload limit (Mbit/s)" on the destination card, 200 by default,
  0 for none (migration 0120).
- pg_dump runs under nice (and ionice where installed), locally too.
- Backups card says where dumps go; the runs table gains "Saved to" and
  shows a failed run's error. Off-site rows have no restore/tag/delete here:
  restoring from the bucket is a later step (#6880).

Task #6878, milestone 586.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 21:11:12 -05:00
co-authored by Claude Opus 5.5
parent bb10d30b2d
commit 772482d794
14 changed files with 749 additions and 29 deletions
+79 -3
View File
@@ -4,7 +4,9 @@ Two independent backup kinds:
- 'db' — pg_dump only; fast; nightly via Beat (settings-gated)
- 'images' — tar+zstd of /images; slow; manual trigger only
Files live under <images_root>/_backups/. Each backup writes:
With an off-site destination set up, a database dump streams straight to that
bucket instead (backup_db_offsite). Otherwise files live under
<images_root>/_backups/. Each local backup writes:
fc_<kind>_<ts>.{sql|tar.zst} — the artifact
fc_<kind>_<ts>.json — manifest (kind/tag/triggered_by)
@@ -19,6 +21,7 @@ import os
import shutil
import subprocess
import tempfile
import time
from datetime import UTC, datetime
from pathlib import Path
@@ -153,10 +156,10 @@ def backup_db(
tmp_path = Path(tmp_name)
try:
_run_bounded(
[
_gently([
"pg_dump", "--no-owner", "--no-acl", "-Fc",
"-f", str(tmp_path), _libpq_url(db_url),
],
]),
_DB_SUBPROCESS_TIMEOUT_S,
)
shutil.move(str(tmp_path), str(sql_path))
@@ -177,6 +180,79 @@ def backup_db(
}
class DumpFailed(Exception):
"""pg_dump exited with an error; the message is its last line of output."""
def offsite_dump_key(ts: str) -> str:
"""Where a database dump lands in the off-site bucket. Dated names sit
side by side under one prefix, which is what lets the bucket's retention
thin them by age."""
return f"database/fabledcurator-{ts}.dump"
def _gently(cmd: list[str]) -> list[str]:
"""`cmd` at low CPU priority, and low disk priority where `ionice` is
installed, so a long dump leaves the server responsive."""
io = ["ionice", "-c", "2", "-n", "7"] if shutil.which("ionice") else []
return ["nice", "-n", "10", *io, *cmd]
def _last_line(f) -> str | None:
f.seek(0)
lines = f.read().decode(errors="replace").strip().splitlines()
return lines[-1].strip() if lines else None
def backup_db_offsite(*, db_url: str, target, limit_mbps: int | None = None) -> dict:
"""Stream `pg_dump` straight into the off-site bucket, in parts, with no
copy staged on this server. The object appears only once pg_dump has
exited cleanly and every part has arrived; a dump that fails partway
leaves nothing in the bucket, and raises DumpFailed with pg_dump's own
last line. Returns a dict for the caller to record in BackupRun."""
from .backup_s3 import PART_SIZE, Pacer, Upload
ts = _now_ts()
key = offsite_dump_key(ts)
deadline = time.monotonic() + _DB_SUBPROCESS_TIMEOUT_S
with tempfile.TemporaryFile() as err:
proc = subprocess.Popen(
_gently(["pg_dump", "--no-owner", "--no-acl", "-Fc", _libpq_url(db_url)]),
stdout=subprocess.PIPE, stderr=err,
)
upload = None
try:
upload = Upload(target, key, pacer=Pacer(limit_mbps))
while part := proc.stdout.read(PART_SIZE):
if time.monotonic() > deadline:
raise subprocess.TimeoutExpired(proc.args, _DB_SUBPROCESS_TIMEOUT_S)
upload.add(part)
proc.stdout.close()
proc.wait(timeout=_KILL_REAP_GRACE_S * 6)
if proc.returncode != 0:
raise DumpFailed(_last_line(err) or f"pg_dump exited with {proc.returncode}")
upload.complete()
except BaseException:
if proc.poll() is None:
proc.kill()
try:
proc.wait(timeout=_KILL_REAP_GRACE_S)
except subprocess.TimeoutExpired:
pass # unkillable (D-state): abandon the reap, fail fast
if upload is not None:
upload.abort()
raise
return {
"kind": "db",
"ts": ts,
"key": key,
"bucket": target.bucket,
"endpoint": target.endpoint,
"parts": upload.parts,
"size_bytes": upload.size,
}
def backup_images(
*, images_root: Path,
tag: str | None = None, triggered_by: str = "manual",