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
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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user