refactor: retire the images tarball backup
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 25s
CI and images / backend-lint-and-test (push) Successful in 50s
CI and images / integration (push) Successful in 2m38s
CI and images / build-agent (push) Successful in 5s
CI and images / sign-extension (push) Successful in 8s
CI and images / build-web (push) Successful in 4m10s
CI and images / smoke-web (push) Successful in 1m3s
CI and images / promote (push) Successful in 1s
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 2s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 25s
CI and images / backend-lint-and-test (push) Successful in 50s
CI and images / integration (push) Successful in 2m38s
CI and images / build-agent (push) Successful in 5s
CI and images / sign-extension (push) Successful in 8s
CI and images / build-web (push) Successful in 4m10s
CI and images / smoke-web (push) Successful in 1m3s
CI and images / promote (push) Successful in 1s
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
Images and attachments are backed up off-site, one object per file, so the tar+zstd of /images goes: its service and restore, both tasks, the trigger and restore routes, the Keep last setting, the screen's Images section, and the tests. Migration 0122 drops backup_run.tar_path and import_settings.backup_images_keep_last_n and deletes any images runs. The local database dump stays for an install with no off-site destination, and the Backups card now says plainly that images and attachments are backed up only off-site. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+3
-103
@@ -125,55 +125,6 @@ def backup_db_task(self, *, tag: str | None = None,
|
||||
return {"backup_run_id": run_id}
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.backup.backup_images_task",
|
||||
bind=True,
|
||||
autoretry_for=(OperationalError, DBAPIError),
|
||||
retry_backoff=30, retry_backoff_max=300, max_retries=1,
|
||||
soft_time_limit=21600, time_limit=23400,
|
||||
)
|
||||
def backup_images_task(self, *, tag: str | None = None,
|
||||
triggered_by: str = "manual") -> dict:
|
||||
"""Create one images backup. Same shape as backup_db_task; uses
|
||||
tar_path instead of sql_path."""
|
||||
SessionLocal = _sync_session_factory()
|
||||
now = datetime.now(UTC)
|
||||
with SessionLocal() as session:
|
||||
row = BackupRun(
|
||||
kind="images", status="running", tag=tag,
|
||||
triggered_by=triggered_by, started_at=now, manifest={},
|
||||
)
|
||||
session.add(row)
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
run_id = row.id
|
||||
|
||||
try:
|
||||
result = backup_service.backup_images(
|
||||
images_root=IMAGES_ROOT,
|
||||
tag=tag, triggered_by=triggered_by,
|
||||
)
|
||||
except (SoftTimeLimitExceeded, Exception) as exc:
|
||||
with SessionLocal() as session:
|
||||
row = session.get(BackupRun, run_id)
|
||||
if row is not None:
|
||||
_mark_failed(session, row, exc)
|
||||
raise
|
||||
|
||||
with SessionLocal() as session:
|
||||
row = session.get(BackupRun, run_id)
|
||||
row.status = "ok"
|
||||
row.finished_at = datetime.now(UTC)
|
||||
row.tar_path = result["tar_path"]
|
||||
row.size_bytes = result["size_bytes"]
|
||||
row.manifest = {
|
||||
"manifest_path": result["manifest_path"],
|
||||
"ts": result["ts"],
|
||||
}
|
||||
session.commit()
|
||||
return {"backup_run_id": run_id}
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.backup.restore_db_task",
|
||||
bind=True,
|
||||
@@ -225,52 +176,6 @@ def restore_db_task(self, *, source_backup_run_id: int) -> dict:
|
||||
return {"backup_run_id": marker_id}
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.backup.restore_images_task",
|
||||
bind=True, max_retries=0,
|
||||
soft_time_limit=21600, time_limit=23400,
|
||||
)
|
||||
def restore_images_task(self, *, source_backup_run_id: int) -> dict:
|
||||
"""Mirrors restore_db_task; uses backup_service.restore_images."""
|
||||
SessionLocal = _sync_session_factory()
|
||||
now = datetime.now(UTC)
|
||||
with SessionLocal() as session:
|
||||
src = session.get(BackupRun, source_backup_run_id)
|
||||
if src is None or src.kind != "images" or not src.tar_path:
|
||||
raise ValueError(
|
||||
f"BackupRun id={source_backup_run_id} is not a valid images backup"
|
||||
)
|
||||
marker = BackupRun(
|
||||
kind="images", status="restoring",
|
||||
triggered_by="restore", started_at=now,
|
||||
restored_from_id=src.id,
|
||||
manifest={"source_tar_path": src.tar_path},
|
||||
)
|
||||
session.add(marker)
|
||||
session.commit()
|
||||
session.refresh(marker)
|
||||
marker_id = marker.id
|
||||
tar_path = src.tar_path
|
||||
|
||||
try:
|
||||
backup_service.restore_images(
|
||||
images_root=IMAGES_ROOT, tar_path=Path(tar_path),
|
||||
)
|
||||
except (SoftTimeLimitExceeded, Exception) as exc:
|
||||
with SessionLocal() as session:
|
||||
row = session.get(BackupRun, marker_id)
|
||||
if row is not None:
|
||||
_mark_failed(session, row, exc)
|
||||
raise
|
||||
|
||||
with SessionLocal() as session:
|
||||
row = session.get(BackupRun, marker_id)
|
||||
row.status = "restored"
|
||||
row.finished_at = datetime.now(UTC)
|
||||
session.commit()
|
||||
return {"backup_run_id": marker_id}
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.backup.prune_backups",
|
||||
soft_time_limit=300, time_limit=600,
|
||||
@@ -278,27 +183,23 @@ def restore_images_task(self, *, source_backup_run_id: int) -> dict:
|
||||
def prune_backups() -> dict:
|
||||
"""Daily Beat. Per-kind retention from ImportSettings.
|
||||
|
||||
Returns {"db_deleted": N, "images_deleted": M, "media_deleted": L,
|
||||
"restore_deleted": R, "reconcile_deleted": C, "test_deleted": T,
|
||||
Returns {"db_deleted": N, "media_deleted": L, "restore_deleted": R, "reconcile_deleted": C, "test_deleted": T,
|
||||
"files_unlinked": K}. Media, restore, reconcile and test runs leave no
|
||||
files here; only their history is trimmed, to the latest MEDIA_RUNS_KEPT,
|
||||
RESTORE_RUNS_KEPT, RECONCILE_RUNS_KEPT and TEST_RUNS_KEPT.
|
||||
Tagged rows (tag IS NOT NULL) are never pruned.
|
||||
Status='running' / 'restoring' rows are never pruned — the
|
||||
recover_stalled_backup_runs sweep flips truly-stuck ones to
|
||||
'error' first. (Earlier docstring claimed the FC-3i TaskRun sweep
|
||||
handled those, but TaskRun cleanup never touched BackupRun rows.
|
||||
Audit 2026-06-02 added the dedicated sweep.)
|
||||
'error' first.
|
||||
"""
|
||||
SessionLocal = _sync_session_factory()
|
||||
counts = {"db_deleted": 0, "images_deleted": 0, "media_deleted": 0,
|
||||
counts = {"db_deleted": 0, "media_deleted": 0,
|
||||
"restore_deleted": 0, "reconcile_deleted": 0, "test_deleted": 0,
|
||||
"files_unlinked": 0}
|
||||
with SessionLocal() as session:
|
||||
s = ImportSettings.load_sync(session)
|
||||
for kind, keep in (
|
||||
("db", s.backup_db_keep_last_n),
|
||||
("images", s.backup_images_keep_last_n),
|
||||
("media", MEDIA_RUNS_KEPT),
|
||||
("restore", RESTORE_RUNS_KEPT),
|
||||
("reconcile", RECONCILE_RUNS_KEPT),
|
||||
@@ -315,7 +216,6 @@ def prune_backups() -> dict:
|
||||
for row in candidates:
|
||||
result = backup_service.unlink_artifact_files(
|
||||
sql_path=row.sql_path,
|
||||
tar_path=row.tar_path,
|
||||
manifest_path=(row.manifest or {}).get("manifest_path"),
|
||||
)
|
||||
counts["files_unlinked"] += sum(
|
||||
|
||||
@@ -168,11 +168,6 @@ QUEUE_STUCK_THRESHOLD_MINUTES: dict[str, int] = {
|
||||
}
|
||||
TASK_STUCK_THRESHOLD_MINUTES: dict[str, int] = {
|
||||
"backend.app.tasks.import_file.import_archive_file": 40,
|
||||
# Backup images runs hours, not minutes (6.5h hard limit). The
|
||||
# task-name override beats the queue's 75-min default so a
|
||||
# legitimately-running backup isn't flagged.
|
||||
"backend.app.tasks.backup.backup_images_task": 420,
|
||||
"backend.app.tasks.backup.restore_images_task": 420,
|
||||
# One media-backup chunk stops starting files after 45 min but may finish
|
||||
# a large file in flight, up to its 6.5 h hard limit.
|
||||
"backend.app.tasks.backup.backup_media_task": 420,
|
||||
@@ -849,7 +844,8 @@ def recover_stalled_backup_runs() -> int:
|
||||
result = session.execute(
|
||||
update(BackupRun)
|
||||
.where(BackupRun.status.in_(["running", "restoring"]))
|
||||
# db backups/restores are fast (40-min window); images run hours (7h).
|
||||
# db backups/restores are fast (40-min window); a test restore and the
|
||||
# weekly bucket check get 7 h.
|
||||
# A media run or an off-site restore spans many chunks and hours on
|
||||
# purpose, so its window runs from its last progress, not its start.
|
||||
.where(
|
||||
|
||||
Reference in New Issue
Block a user