feat: images and attachments go to the off-site bucket once each, by fingerprint
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / frontend-build (push) Successful in 25s
CI and images / extension-test (push) Successful in 18s
CI and images / backend-lint-and-test (push) Failing after 44s
CI and images / integration (push) Failing after 2m28s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / build-agent (push) Skipped
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / frontend-build (push) Successful in 25s
CI and images / extension-test (push) Successful in 18s
CI and images / backend-lint-and-test (push) Failing after 44s
CI and images / integration (push) Failing after 2m28s
CI and images / sign-extension (push) Skipped
CI and images / build-web (push) Skipped
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
CI and images / refresh-dev (push) Skipped
CI and images / unattended-failure (push) Skipped
CI and images / build-agent (push) Skipped
With an off-site destination set up, the library's files are sent to the bucket's media/ folder as media/ab/cd/<sha256><ext>, beside the database dumps under database/. Each file goes once: a new backup_blob table records what each destination holds (migration 0121), and a run's work list is every image and attachment sha with no row yet, so runs are incremental and a new bucket starts its copy from the beginning. - The bucket checks each file against the sha recorded at import; a file over 1 GiB goes in parts and is checked before the upload is completed. A file missing or changed on disk is reported, not stored. - A run is chunked: each 45-minute chunk commits after every file and queues the next, so other long-maintenance jobs get turns. One run at a time. The stall sweep judges a media run by its progress heartbeat (backup_run.last_progress_at), not its start. - A file that can't be sent is listed on the run (status ok, with the error line) and tried again by the next run. A refused put for an object already in an append-only bucket counts as sent. - Nightly, with a destination set, runs the dump and then the media. - Weekly reconcile lists media/ and re-sends what the bucket lost, and adopts what it already holds (a restored database sends nothing twice). - Uploads use the bandwidth limit; read transactions end before transfers. - Backups card: with a destination, an "Images and attachments" section shows coverage (files and bytes in the bucket), "Send new files now", and the media runs. Without one, the local tarball section is unchanged. - Media run history keeps the latest 60 rows. Task #6879, milestone 586. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+172
-4
@@ -8,6 +8,7 @@ BackupRun.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -18,7 +19,7 @@ from sqlalchemy.exc import DBAPIError, OperationalError
|
||||
from ..celery_app import celery
|
||||
from ..config import get_config
|
||||
from ..models import BackupRun, ImportSettings
|
||||
from ..services import backup_destination, backup_service
|
||||
from ..services import backup_destination, backup_media, backup_s3, backup_service
|
||||
from ._sync_engine import sync_session_factory as _sync_session_factory
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -267,7 +268,9 @@ 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, "files_unlinked": K}.
|
||||
Returns {"db_deleted": N, "images_deleted": M, "media_deleted": L,
|
||||
"files_unlinked": K}. Media runs leave no files here; only their history
|
||||
is trimmed, to the latest MEDIA_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
|
||||
@@ -276,12 +279,13 @@ def prune_backups() -> dict:
|
||||
Audit 2026-06-02 added the dedicated sweep.)
|
||||
"""
|
||||
SessionLocal = _sync_session_factory()
|
||||
counts = {"db_deleted": 0, "images_deleted": 0, "files_unlinked": 0}
|
||||
counts = {"db_deleted": 0, "images_deleted": 0, "media_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),
|
||||
):
|
||||
candidates = session.execute(
|
||||
select(BackupRun)
|
||||
@@ -325,4 +329,168 @@ def backup_db_nightly() -> dict:
|
||||
if now_hour != configured_hour:
|
||||
return {"skipped": f"hour={now_hour} != configured={configured_hour}"}
|
||||
res = backup_db_task.delay(triggered_by="nightly")
|
||||
return {"dispatched": res.id}
|
||||
out = {"dispatched": res.id}
|
||||
# With an off-site destination, the night's backup is the whole library:
|
||||
# the database dump, then any images and attachments not yet in the bucket.
|
||||
# Both run on the long-maintenance lane, one after the other.
|
||||
with SessionLocal() as session:
|
||||
try:
|
||||
offsite = backup_destination.saved_target(ImportSettings.load_sync(session))
|
||||
except Exception: # noqa: BLE001 — the media run reports it itself
|
||||
offsite = True
|
||||
if offsite is not None:
|
||||
out["media_dispatched"] = backup_media_task.delay(triggered_by="nightly").id
|
||||
return out
|
||||
|
||||
|
||||
# ── Off-site copy of the library's files ─────────────────────────────────────
|
||||
|
||||
# Each task invocation sends files for this long, then queues the next chunk
|
||||
# of the same run, so other long-maintenance jobs get their turn in between.
|
||||
MEDIA_CHUNK_SECONDS = 45 * 60
|
||||
MEDIA_RUNS_KEPT = 60
|
||||
# A failed file is listed on the run, up to this many; the count covers all.
|
||||
MEDIA_FAILURES_LISTED = 20
|
||||
|
||||
|
||||
def _offsite_manifest(target) -> dict:
|
||||
return {"endpoint": target.endpoint, "bucket": target.bucket,
|
||||
"key": backup_media.PREFIX}
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.backup.backup_media_task",
|
||||
bind=True,
|
||||
autoretry_for=(OperationalError, DBAPIError),
|
||||
retry_backoff=30, retry_backoff_max=300, max_retries=2,
|
||||
# A chunk stops starting files after MEDIA_CHUNK_SECONDS; the limits leave
|
||||
# room for the file in flight, a multi-gigabyte archive at a low upload
|
||||
# limit included.
|
||||
soft_time_limit=21600, time_limit=23400,
|
||||
)
|
||||
def backup_media_task(self, *, run_id: int | None = None,
|
||||
triggered_by: str = "manual") -> dict:
|
||||
"""Send the library's images and attachments that aren't in the off-site
|
||||
bucket yet, one chunk of a run. Called without `run_id` it starts a run
|
||||
(unless one is already going); each chunk queues the next until nothing is
|
||||
left. A file that can't be sent is listed on the run and tried again by
|
||||
the next run. Returns {'backup_run_id': N, ...}."""
|
||||
SessionLocal = _sync_session_factory()
|
||||
now = datetime.now(UTC)
|
||||
with SessionLocal() as session:
|
||||
if run_id is None:
|
||||
going = session.execute(
|
||||
select(BackupRun.id).where(BackupRun.kind == "media")
|
||||
.where(BackupRun.status == "running").limit(1)
|
||||
).scalar_one_or_none()
|
||||
if going is not None:
|
||||
return {"backup_run_id": going, "skipped": "a media backup is already running"}
|
||||
row = BackupRun(kind="media", status="running", triggered_by=triggered_by,
|
||||
started_at=now, last_progress_at=now, size_bytes=0,
|
||||
manifest={"sent": 0, "failed": 0, "failures": [], "cursor": ""})
|
||||
session.add(row)
|
||||
session.commit()
|
||||
run_id = row.id
|
||||
else:
|
||||
row = session.get(BackupRun, run_id)
|
||||
if row is None or row.status != "running":
|
||||
return {"backup_run_id": run_id, "skipped": "run is no longer running"}
|
||||
|
||||
try:
|
||||
with SessionLocal() as session:
|
||||
settings = ImportSettings.load_sync(session)
|
||||
target = backup_destination.saved_target(settings)
|
||||
limit_mbps = settings.backup_upload_limit_mbps
|
||||
if target is None:
|
||||
raise backup_s3.S3Error("No off-site destination is set up.")
|
||||
more = _send_media_chunk(SessionLocal, run_id, target, backup_s3.Pacer(limit_mbps))
|
||||
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
|
||||
|
||||
if more:
|
||||
backup_media_task.delay(run_id=run_id)
|
||||
return {"backup_run_id": run_id, "continues": True}
|
||||
with SessionLocal() as session:
|
||||
row = session.get(BackupRun, run_id)
|
||||
m = dict(row.manifest or {})
|
||||
row.status = "ok"
|
||||
row.finished_at = datetime.now(UTC)
|
||||
if m.get("failed"):
|
||||
first = (m.get("failures") or [{}])[0]
|
||||
row.error = (f"{m['failed']} file(s) weren't sent; the next run tries them again. "
|
||||
f"First: {first.get('path')}: {first.get('error')}")[:2000]
|
||||
session.commit()
|
||||
return {"backup_run_id": run_id, "continues": False}
|
||||
|
||||
|
||||
def _send_media_chunk(SessionLocal, run_id: int, target, pacer) -> bool:
|
||||
"""Send files until the chunk's time is up or none are left. Progress is
|
||||
committed after every file, so a chunk that dies loses at most the file
|
||||
in flight. Returns True when files remain for another chunk."""
|
||||
tid = backup_media.target_id(target)
|
||||
deadline = time.monotonic() + MEDIA_CHUNK_SECONDS
|
||||
with SessionLocal() as session:
|
||||
row = session.get(BackupRun, run_id)
|
||||
m = dict(row.manifest or {})
|
||||
m["offsite"] = _offsite_manifest(target)
|
||||
cursor = m.get("cursor") or ""
|
||||
while True:
|
||||
batch = backup_media.pending(session, tid, after=cursor)
|
||||
# End the read before any upload starts: a transaction left open
|
||||
# across a long transfer holds back vacuum for its whole length.
|
||||
session.commit()
|
||||
if not batch:
|
||||
m["cursor"] = cursor
|
||||
row.manifest = dict(m)
|
||||
row.last_progress_at = datetime.now(UTC)
|
||||
session.commit()
|
||||
return False
|
||||
for blob in batch:
|
||||
if time.monotonic() > deadline:
|
||||
m["cursor"] = cursor
|
||||
row.manifest = dict(m)
|
||||
session.commit()
|
||||
return True
|
||||
try:
|
||||
key = backup_media.send(target, blob, IMAGES_ROOT, pacer)
|
||||
except (backup_s3.S3Error, OSError) as exc:
|
||||
m["failed"] = m.get("failed", 0) + 1
|
||||
if len(m.get("failures") or []) < MEDIA_FAILURES_LISTED:
|
||||
m["failures"] = [*(m.get("failures") or []),
|
||||
{"path": blob.path, "error": str(exc)[:300]}]
|
||||
else:
|
||||
backup_media.record(session, tid, [(blob, key)])
|
||||
m["sent"] = m.get("sent", 0) + 1
|
||||
row.size_bytes = (row.size_bytes or 0) + blob.size_bytes
|
||||
cursor = blob.sha256
|
||||
m["cursor"] = cursor
|
||||
row.manifest = dict(m)
|
||||
row.last_progress_at = datetime.now(UTC)
|
||||
session.commit()
|
||||
|
||||
|
||||
@celery.task(
|
||||
name="backend.app.tasks.backup.reconcile_media_backup",
|
||||
soft_time_limit=1800, time_limit=2100,
|
||||
)
|
||||
def reconcile_media_backup() -> dict:
|
||||
"""Weekly Beat. Compare what the off-site bucket holds under `media/` with
|
||||
what FabledCurator believes it sent: a missing object is sent again by
|
||||
the next run, and an object already there is not sent twice. Skipped
|
||||
while a media run is going, and when no destination is set up."""
|
||||
SessionLocal = _sync_session_factory()
|
||||
with SessionLocal() as session:
|
||||
target = backup_destination.saved_target(ImportSettings.load_sync(session))
|
||||
if target is None:
|
||||
return {"skipped": "no destination"}
|
||||
going = session.execute(
|
||||
select(BackupRun.id).where(BackupRun.kind == "media")
|
||||
.where(BackupRun.status == "running").limit(1)
|
||||
).scalar_one_or_none()
|
||||
if going is not None:
|
||||
return {"skipped": "a media backup is running"}
|
||||
return backup_media.reconcile(session, target)
|
||||
|
||||
@@ -842,10 +842,18 @@ def recover_stalled_backup_runs() -> int:
|
||||
update(BackupRun)
|
||||
.where(BackupRun.status.in_(["running", "restoring"]))
|
||||
# db backups/restores are fast (40-min window); images run hours (7h).
|
||||
# A media run spans many chunks and hours on purpose, so its window
|
||||
# runs from its last progress, not from its start.
|
||||
.where(
|
||||
or_(
|
||||
and_(BackupRun.kind == "db", BackupRun.started_at < db_cutoff),
|
||||
and_(BackupRun.kind != "db", BackupRun.started_at < slow_cutoff),
|
||||
and_(
|
||||
BackupRun.kind == "media",
|
||||
func.coalesce(BackupRun.last_progress_at, BackupRun.started_at)
|
||||
< slow_cutoff,
|
||||
),
|
||||
and_(BackupRun.kind.not_in(["db", "media"]),
|
||||
BackupRun.started_at < slow_cutoff),
|
||||
)
|
||||
)
|
||||
.values(status="error", finished_at=now, error=msg)
|
||||
|
||||
Reference in New Issue
Block a user