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
+132 -2
View File
@@ -5,6 +5,7 @@ without external binaries. The real subprocess behavior is exercised
implicitly via the Celery task tests in test_tasks_backup.py.
"""
import json
import re
import subprocess
from pathlib import Path
@@ -28,7 +29,7 @@ def fake_subprocess(monkeypatch):
stderr = b""
def _write_sentinel(cmd):
if cmd[0] == "pg_dump":
if "pg_dump" in cmd:
i = cmd.index("-f")
Path(cmd[i + 1]).write_bytes(b"-- fake pg_dump\n")
elif cmd[0] == "tar" and "-cf" in cmd:
@@ -87,7 +88,7 @@ def test_backup_db_strips_sqlalchemy_psycopg_driver(tmp_path, fake_subprocess):
db_url="postgresql+psycopg://u:p@h/d", images_root=tmp_path,
)
cmd = fake_subprocess[0]
assert cmd[0] == "pg_dump"
assert "pg_dump" in cmd
assert cmd[-1].startswith("postgresql://")
assert "+psycopg" not in cmd[-1]
@@ -284,3 +285,132 @@ def test_run_bounded_fails_fast_when_unkillable(monkeypatch):
with pytest.raises(subprocess.TimeoutExpired):
backup_service._run_bounded(["pg_dump"], 1)
assert killed["n"] == 1
# --- backup_db_offsite: pg_dump streamed to the bucket ---------------
class _Dump:
"""A pg_dump stand-in: `output` on stdout, `said` on stderr, exiting with
`code` once its output has been read."""
def __init__(self, output=b"", said=b"", code=0):
self.output, self.said, self.code = output, said, code
self.cmd = None
self.killed = False
def __call__(self, cmd, stdout=None, stderr=None, **kwargs):
import io
self.cmd = list(cmd)
self.args = cmd
self.stdout = io.BytesIO(self.output)
stderr.write(self.said)
self.returncode = None
return self
def poll(self):
return self.returncode
def wait(self, timeout=None):
if self.returncode is None:
self.returncode = -9 if self.killed else self.code
return self.returncode
def kill(self):
self.killed = True
class _Bucket:
"""Records what an Upload was asked to do instead of sending it."""
def __init__(self, fail_on_part=None):
self.fail_on_part = fail_on_part
self.uploads = []
def __call__(self, target, key, **kwargs):
from backend.app.services.backup_s3 import S3Error
bucket = self
class _Upload:
def __init__(self):
self.key, self.received = key, []
self.completed = self.aborted = False
bucket.uploads.append(self)
@property
def parts(self):
return len(self.received)
@property
def size(self):
return sum(len(p) for p in self.received)
def add(self, data):
if bucket.fail_on_part == len(self.received) + 1:
raise S3Error("HTTP 503: busy.")
self.received.append(data)
def complete(self):
self.completed = True
def abort(self):
self.aborted = True
return _Upload()
@pytest.fixture
def offsite(monkeypatch):
from backend.app.services import backup_s3
monkeypatch.setattr(backup_s3, "PART_SIZE", 4)
bucket = _Bucket()
monkeypatch.setattr(backup_s3, "Upload", bucket)
target = backup_s3.S3Target("http://archives.example:8081", "curator", "AK", "SK")
return bucket, target
def test_a_dump_streams_to_the_bucket_in_parts_with_nothing_kept_here(offsite, monkeypatch):
bucket, target = offsite
dump = _Dump(output=b"0123456789")
monkeypatch.setattr("subprocess.Popen", dump)
result = backup_service.backup_db_offsite(
db_url="postgresql+psycopg://u:p@h/d", target=target, limit_mbps=50,
)
(upload,) = bucket.uploads
assert upload.received == [b"0123", b"4567", b"89"]
assert upload.completed and not upload.aborted
assert re.fullmatch(r"database/fabledcurator-\d{8}T\d{6}Z\.dump", result["key"])
assert result["key"] == upload.key
assert (result["bucket"], result["size_bytes"], result["parts"]) == ("curator", 10, 3)
assert dump.cmd[0] == "nice" and "pg_dump" in dump.cmd and "-Fc" in dump.cmd
assert "-f" not in dump.cmd # stdout, not a file
assert dump.cmd[-1] == "postgresql://u:p@h/d"
def test_a_failed_dump_leaves_nothing_in_the_bucket_and_says_why(offsite, monkeypatch):
bucket, target = offsite
said = b"pg_dump: dumping contents of table x\npg_dump: error: connection lost\n"
monkeypatch.setattr("subprocess.Popen", _Dump(output=b"012345", said=said, code=1))
with pytest.raises(backup_service.DumpFailed, match="^pg_dump: error: connection lost$"):
backup_service.backup_db_offsite(db_url="postgresql://u@h/d", target=target)
(upload,) = bucket.uploads
assert upload.aborted and not upload.completed
def test_a_bucket_that_stops_taking_parts_stops_the_dump(offsite, monkeypatch):
from backend.app.services.backup_s3 import S3Error
bucket, target = offsite
bucket.fail_on_part = 2
dump = _Dump(output=b"0123456789")
monkeypatch.setattr("subprocess.Popen", dump)
with pytest.raises(S3Error):
backup_service.backup_db_offsite(db_url="postgresql://u@h/d", target=target)
assert dump.killed
(upload,) = bucket.uploads
assert upload.aborted and not upload.completed
def test_dump_names_sort_by_time():
assert backup_service.offsite_dump_key("20261107T030000Z") == (
"database/fabledcurator-20261107T030000Z.dump"
)