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:
@@ -0,0 +1,46 @@
|
|||||||
|
"""backup_blob: which library files are already in the off-site bucket.
|
||||||
|
|
||||||
|
One row per (destination, sha256) sent. The media backup's work list is every
|
||||||
|
image and attachment sha with no row for the current destination, so a run is
|
||||||
|
incremental and resumable by construction, and pointing backups at a new
|
||||||
|
bucket starts that bucket's copy from the beginning.
|
||||||
|
|
||||||
|
backup_run.last_progress_at is the heartbeat of a run that spans hours; the
|
||||||
|
stall sweep measures a media run's silence from it, not from its start.
|
||||||
|
|
||||||
|
Revision ID: 0121
|
||||||
|
Revises: 0120
|
||||||
|
Create Date: 2026-11-07
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0121"
|
||||||
|
down_revision = "0120"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"backup_blob",
|
||||||
|
sa.Column("target", sa.Text(), nullable=False),
|
||||||
|
sa.Column("sha256", sa.String(64), nullable=False),
|
||||||
|
sa.Column("key", sa.Text(), nullable=False),
|
||||||
|
sa.Column("size_bytes", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"uploaded_at", sa.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("target", "sha256", name=op.f("pk_backup_blob")),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"backup_run",
|
||||||
|
sa.Column("last_progress_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("backup_run", "last_progress_at")
|
||||||
|
op.drop_table("backup_blob")
|
||||||
@@ -20,7 +20,7 @@ system_backup_bp = Blueprint(
|
|||||||
"system_backup", __name__, url_prefix="/api/system/backup",
|
"system_backup", __name__, url_prefix="/api/system/backup",
|
||||||
)
|
)
|
||||||
|
|
||||||
_KINDS = frozenset({"db", "images"})
|
_KINDS = frozenset({"db", "images", "media"})
|
||||||
_TAG_MAX_LEN = 64
|
_TAG_MAX_LEN = 64
|
||||||
_BACKUP_SETTINGS_FIELDS = (
|
_BACKUP_SETTINGS_FIELDS = (
|
||||||
"backup_db_nightly_enabled",
|
"backup_db_nightly_enabled",
|
||||||
@@ -110,6 +110,38 @@ async def trigger_images_backup():
|
|||||||
return jsonify({"status": "dispatched"}), 202
|
return jsonify({"status": "dispatched"}), 202
|
||||||
|
|
||||||
|
|
||||||
|
@system_backup_bp.route("/media", methods=["POST"])
|
||||||
|
async def trigger_media_backup():
|
||||||
|
"""Send the images and attachments not yet in the off-site bucket. Refused
|
||||||
|
when no destination is set up; a run already going is left to finish."""
|
||||||
|
from ..services import backup_destination as dest
|
||||||
|
|
||||||
|
async with get_session() as session:
|
||||||
|
row = await ImportSettings.load(session)
|
||||||
|
if not dest.describe(row)["configured"]:
|
||||||
|
return _bad("no_destination",
|
||||||
|
detail="set up an off-site destination before sending files")
|
||||||
|
from ..tasks.backup import backup_media_task
|
||||||
|
backup_media_task.delay(triggered_by="manual")
|
||||||
|
return jsonify({"status": "dispatched"}), 202
|
||||||
|
|
||||||
|
|
||||||
|
@system_backup_bp.route("/media", methods=["GET"])
|
||||||
|
async def media_coverage():
|
||||||
|
"""How much of the library is in the off-site bucket: files and bytes,
|
||||||
|
sent and total, each file counted once however many posts hold it."""
|
||||||
|
from ..services import backup_destination as dest
|
||||||
|
from ..services import backup_media
|
||||||
|
|
||||||
|
async with get_session() as session:
|
||||||
|
row = await ImportSettings.load(session)
|
||||||
|
f = dest.fields_of(row)
|
||||||
|
configured = dest.describe(row)["configured"]
|
||||||
|
tid = backup_media.destination_id(f.endpoint, f.bucket) if configured else None
|
||||||
|
counts = await session.run_sync(lambda s: backup_media.coverage(s, tid))
|
||||||
|
return jsonify({"configured": configured, **counts})
|
||||||
|
|
||||||
|
|
||||||
@system_backup_bp.route("/runs", methods=["GET"])
|
@system_backup_bp.route("/runs", methods=["GET"])
|
||||||
async def list_runs():
|
async def list_runs():
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -302,6 +302,10 @@ def make_celery() -> Celery:
|
|||||||
"task": "backend.app.tasks.backup.prune_backups",
|
"task": "backend.app.tasks.backup.prune_backups",
|
||||||
"schedule": 86400.0, # daily
|
"schedule": 86400.0, # daily
|
||||||
},
|
},
|
||||||
|
"reconcile-media-backup": {
|
||||||
|
"task": "backend.app.tasks.backup.reconcile_media_backup",
|
||||||
|
"schedule": 604800.0, # weekly: list the bucket, re-send what's missing
|
||||||
|
},
|
||||||
# Audit 2026-06-02 — three new per-entity recovery sweeps.
|
# Audit 2026-06-02 — three new per-entity recovery sweeps.
|
||||||
# Each runs every 5 min like the other recover_stalled_*
|
# Each runs every 5 min like the other recover_stalled_*
|
||||||
# sweeps; each is a no-op when nothing is stuck.
|
# sweeps; each is a no-op when nothing is stuck.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from .app_setting import AppSetting
|
|||||||
from .artist import Artist
|
from .artist import Artist
|
||||||
from .artist_membership_suggestion import ArtistMembershipSuggestion
|
from .artist_membership_suggestion import ArtistMembershipSuggestion
|
||||||
from .artist_visit import ArtistVisit
|
from .artist_visit import ArtistVisit
|
||||||
|
from .backup_blob import BackupBlob
|
||||||
from .backup_run import BackupRun
|
from .backup_run import BackupRun
|
||||||
from .base import Base
|
from .base import Base
|
||||||
from .character_prototype import CcipPrototypeState, CharacterPrototype
|
from .character_prototype import CcipPrototypeState, CharacterPrototype
|
||||||
@@ -55,6 +56,7 @@ __all__ = [
|
|||||||
"Artist",
|
"Artist",
|
||||||
"ArtistMembershipSuggestion",
|
"ArtistMembershipSuggestion",
|
||||||
"ArtistVisit",
|
"ArtistVisit",
|
||||||
|
"BackupBlob",
|
||||||
"BackupRun",
|
"BackupRun",
|
||||||
"Source",
|
"Source",
|
||||||
"Credential",
|
"Credential",
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""backup_blob: a library file already sent to the off-site bucket.
|
||||||
|
|
||||||
|
One row per (destination, sha256). `target` names the destination as
|
||||||
|
"<endpoint>/<bucket>", so pointing backups at another bucket leaves these rows
|
||||||
|
inert and that bucket's copy starts from the beginning. `key` is where the
|
||||||
|
file sits in the bucket (`media/ab/cd/<sha256><ext>`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, DateTime, String, Text, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class BackupBlob(Base):
|
||||||
|
__tablename__ = "backup_blob"
|
||||||
|
|
||||||
|
target: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||||
|
sha256: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
key: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
|
uploaded_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(),
|
||||||
|
)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""FC-3h: backup_run — operator-facing artifact record for a backup run.
|
"""FC-3h: backup_run — operator-facing artifact record for a backup run.
|
||||||
|
|
||||||
One row per backup attempt (kind='db' or 'images'). Lifecycle
|
One row per backup attempt (kind='db', 'images', or 'media' for the off-site
|
||||||
|
upload of images and attachments, which spans many task chunks). Lifecycle
|
||||||
tracking (started_at/finished_at/duration_ms/exception text) lives
|
tracking (started_at/finished_at/duration_ms/exception text) lives
|
||||||
in task_run from FC-3i — this row records artifact metadata: file
|
in task_run from FC-3i — this row records artifact metadata: file
|
||||||
paths, sizes, tag (retention protection), and restore lineage via
|
paths, sizes, tag (retention protection), and restore lineage via
|
||||||
@@ -62,6 +63,11 @@ class BackupRun(Base):
|
|||||||
)
|
)
|
||||||
# Self-referential FK, unindexed until 0089 (#3300): SET NULL has to find
|
# Self-referential FK, unindexed until 0089 (#3300): SET NULL has to find
|
||||||
# the rows pointing at a deleted run before it can null them.
|
# the rows pointing at a deleted run before it can null them.
|
||||||
|
# Heartbeat of a run that spans hours (the media backup): set as it makes
|
||||||
|
# progress, and what the stall sweep measures its silence from.
|
||||||
|
last_progress_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True,
|
||||||
|
)
|
||||||
restored_from_id: Mapped[int | None] = mapped_column(
|
restored_from_id: Mapped[int | None] = mapped_column(
|
||||||
ForeignKey("backup_run.id", ondelete="SET NULL"),
|
ForeignKey("backup_run.id", ondelete="SET NULL"),
|
||||||
nullable=True, index=True,
|
nullable=True, index=True,
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""The off-site copy of the library's files: every image and attachment, sent
|
||||||
|
once each to the backup bucket as a content-addressed object.
|
||||||
|
|
||||||
|
Layout in the bucket, beside the database dumps under `database/`:
|
||||||
|
|
||||||
|
media/ab/cd/<sha256><ext>
|
||||||
|
|
||||||
|
`ab` and `cd` are the first four hex digits of the file's sha256, which keeps
|
||||||
|
any one prefix small, and the extension keeps the files readable in the bucket
|
||||||
|
without FabledCurator. The sha256 is the one recorded at import, so a file
|
||||||
|
uploads exactly once however many rows or posts point at it, and a run is
|
||||||
|
incremental: its work list is every sha with no `backup_blob` row for the
|
||||||
|
current destination. A file removed from the library stays in the bucket until
|
||||||
|
the bucket's own retention removes it; FabledCurator never deletes there.
|
||||||
|
|
||||||
|
The bucket checks every object against that recorded sha256, so a file whose
|
||||||
|
bytes changed on disk since import is refused and reported, never stored under
|
||||||
|
the wrong name.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select, text
|
||||||
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
|
|
||||||
|
from ..models import BackupBlob
|
||||||
|
from .backup_s3 import (
|
||||||
|
Pacer,
|
||||||
|
S3Error,
|
||||||
|
S3Target,
|
||||||
|
list_objects,
|
||||||
|
object_present,
|
||||||
|
put_file,
|
||||||
|
put_file_in_parts,
|
||||||
|
)
|
||||||
|
|
||||||
|
PREFIX = "media/"
|
||||||
|
# Files above this size go in parts (S3 refuses a single PUT over 5 GiB, and a
|
||||||
|
# part that fails is resent alone instead of the whole file).
|
||||||
|
MULTIPART_OVER = 1 << 30
|
||||||
|
# The bucket is listed this many keys at a time during a reconcile, with a
|
||||||
|
# short rest between pages.
|
||||||
|
LIST_PAGE = 1000
|
||||||
|
LIST_REST_SECONDS = 0.2
|
||||||
|
_EXT = re.compile(r"^\.[a-z0-9]{1,10}$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Blob:
|
||||||
|
sha256: str
|
||||||
|
path: str
|
||||||
|
size_bytes: int
|
||||||
|
mime: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def destination_id(endpoint: str, bucket: str) -> str:
|
||||||
|
"""How backup_blob names a destination: one copy per endpoint and bucket."""
|
||||||
|
return f"{endpoint.rstrip('/')}/{bucket}"
|
||||||
|
|
||||||
|
|
||||||
|
def target_id(target: S3Target) -> str:
|
||||||
|
return destination_id(target.endpoint, target.bucket)
|
||||||
|
|
||||||
|
|
||||||
|
def blob_key(sha256: str, path: str) -> str:
|
||||||
|
"""The bucket key for a file: `media/ab/cd/<sha256><ext>`. An extension
|
||||||
|
that isn't short and plain is left off, so a key is always safe."""
|
||||||
|
ext = Path(path).suffix.lower()
|
||||||
|
return f"{PREFIX}{sha256[:2]}/{sha256[2:4]}/{sha256}{ext if _EXT.match(ext) else ''}"
|
||||||
|
|
||||||
|
|
||||||
|
# Every library file once per sha256: images and attachments together. An
|
||||||
|
# attachment's sha can repeat across posts, and an image extracted from an
|
||||||
|
# archive is its own file with its own sha.
|
||||||
|
_LIBRARY = """
|
||||||
|
SELECT sha256, path, size_bytes, mime FROM image_record
|
||||||
|
UNION ALL
|
||||||
|
SELECT sha256, path, size_bytes, mime FROM post_attachment
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def pending(session, tid: str, *, after: str = "", limit: int = 500) -> list[Blob]:
|
||||||
|
"""The next `limit` files not yet in the bucket, in sha order after
|
||||||
|
`after`, so a run walks the library once even when some files fail."""
|
||||||
|
rows = session.execute(text(f"""
|
||||||
|
SELECT DISTINCT ON (lib.sha256) lib.sha256, lib.path, lib.size_bytes, lib.mime
|
||||||
|
FROM ({_LIBRARY}) AS lib
|
||||||
|
WHERE lib.sha256 > :after
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM backup_blob b WHERE b.target = :tid AND b.sha256 = lib.sha256
|
||||||
|
)
|
||||||
|
ORDER BY lib.sha256, lib.path
|
||||||
|
LIMIT :limit
|
||||||
|
"""), {"after": after, "tid": tid, "limit": limit}).all()
|
||||||
|
return [Blob(r.sha256, r.path, r.size_bytes, r.mime) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def coverage(session, tid: str | None) -> dict:
|
||||||
|
"""How much of the library is in the bucket: files and bytes, sent and
|
||||||
|
total, each sha counted once."""
|
||||||
|
row = session.execute(text(f"""
|
||||||
|
WITH lib AS (
|
||||||
|
SELECT DISTINCT ON (sha256) sha256, size_bytes FROM ({_LIBRARY}) AS u
|
||||||
|
ORDER BY sha256
|
||||||
|
)
|
||||||
|
SELECT count(*) AS files_total,
|
||||||
|
coalesce(sum(lib.size_bytes), 0) AS bytes_total,
|
||||||
|
count(b.sha256) AS files_sent,
|
||||||
|
coalesce(sum(lib.size_bytes) FILTER (WHERE b.sha256 IS NOT NULL), 0)
|
||||||
|
AS bytes_sent
|
||||||
|
FROM lib
|
||||||
|
LEFT JOIN backup_blob b ON b.sha256 = lib.sha256 AND b.target = :tid
|
||||||
|
"""), {"tid": tid or ""}).one()
|
||||||
|
return {
|
||||||
|
"files_total": row.files_total, "bytes_total": int(row.bytes_total),
|
||||||
|
"files_sent": row.files_sent, "bytes_sent": int(row.bytes_sent),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def send(target: S3Target, blob: Blob, images_root: Path, pacer: Pacer | None) -> str:
|
||||||
|
"""Upload one file to its key and return the key. Raises S3Error (or
|
||||||
|
OSError) with the reason when it can't be sent."""
|
||||||
|
path = Path(blob.path)
|
||||||
|
if not path.is_absolute():
|
||||||
|
path = images_root / path
|
||||||
|
if not path.is_file():
|
||||||
|
raise S3Error(f"{path} is not on disk.")
|
||||||
|
size = path.stat().st_size
|
||||||
|
if size != blob.size_bytes:
|
||||||
|
raise S3Error(f"{path} is {size} bytes on disk but {blob.size_bytes} when imported.")
|
||||||
|
key = blob_key(blob.sha256, blob.path)
|
||||||
|
content_type = blob.mime or "application/octet-stream"
|
||||||
|
try:
|
||||||
|
if size > MULTIPART_OVER:
|
||||||
|
put_file_in_parts(target, key, path, sha256=blob.sha256,
|
||||||
|
content_type=content_type, pacer=pacer)
|
||||||
|
else:
|
||||||
|
put_file(target, key, path, content_type=content_type,
|
||||||
|
payload_sha256=blob.sha256, pacer=pacer)
|
||||||
|
except S3Error:
|
||||||
|
if object_present(target, key, size):
|
||||||
|
return key # already there from an earlier run
|
||||||
|
raise
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def record(session, tid: str, blobs: list[tuple[Blob, str]]) -> None:
|
||||||
|
"""Note files as sent: (blob, key) pairs. A row already there is kept."""
|
||||||
|
if blobs:
|
||||||
|
session.execute(insert(BackupBlob).values([
|
||||||
|
{"target": tid, "sha256": b.sha256, "key": key, "size_bytes": b.size_bytes}
|
||||||
|
for b, key in blobs
|
||||||
|
]).on_conflict_do_nothing())
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile(session, target: S3Target) -> dict:
|
||||||
|
"""Check backup_blob against what the bucket actually holds under
|
||||||
|
`media/`. A row whose object is missing or the wrong size is dropped, so
|
||||||
|
the next run sends that file again; an object the bucket holds for a
|
||||||
|
library file with no row (a restored database, a lost commit) is recorded,
|
||||||
|
so it isn't sent twice. Returns the counts."""
|
||||||
|
tid = target_id(target)
|
||||||
|
held: dict[str, int] = {}
|
||||||
|
token = None
|
||||||
|
while True:
|
||||||
|
page = list_objects(target, PREFIX, LIST_PAGE, continuation=token)
|
||||||
|
held.update((o.key, o.size) for o in page.objects)
|
||||||
|
token = page.next_token
|
||||||
|
if not token:
|
||||||
|
break
|
||||||
|
time.sleep(LIST_REST_SECONDS)
|
||||||
|
|
||||||
|
rows = session.execute(
|
||||||
|
select(BackupBlob.sha256, BackupBlob.key, BackupBlob.size_bytes)
|
||||||
|
.where(BackupBlob.target == tid)
|
||||||
|
).all()
|
||||||
|
gone = [r.sha256 for r in rows if held.get(r.key) != r.size_bytes]
|
||||||
|
for at in range(0, len(gone), 1000):
|
||||||
|
session.execute(delete(BackupBlob).where(
|
||||||
|
BackupBlob.target == tid, BackupBlob.sha256.in_(gone[at:at + 1000]),
|
||||||
|
))
|
||||||
|
kept = {r.sha256 for r in rows} - set(gone)
|
||||||
|
|
||||||
|
found = []
|
||||||
|
for r in session.execute(text(f"""
|
||||||
|
SELECT DISTINCT ON (sha256) sha256, path, size_bytes, mime
|
||||||
|
FROM ({_LIBRARY}) AS lib ORDER BY sha256, path
|
||||||
|
""")).all():
|
||||||
|
if r.sha256 in kept:
|
||||||
|
continue
|
||||||
|
blob = Blob(r.sha256, r.path, r.size_bytes, r.mime)
|
||||||
|
key = blob_key(blob.sha256, blob.path)
|
||||||
|
if held.get(key) == blob.size_bytes:
|
||||||
|
found.append((blob, key))
|
||||||
|
for at in range(0, len(found), 1000):
|
||||||
|
record(session, tid, found[at:at + 1000])
|
||||||
|
session.commit()
|
||||||
|
return {"listed": len(held), "dropped": len(gone), "adopted": len(found)}
|
||||||
@@ -166,6 +166,7 @@ def _says(status: int, reason: str, content: bytes) -> str:
|
|||||||
"InvalidAccessKeyId": "the access key isn't known there",
|
"InvalidAccessKeyId": "the access key isn't known there",
|
||||||
"NoSuchBucket": "there is no such bucket",
|
"NoSuchBucket": "there is no such bucket",
|
||||||
"AccessDenied": "the key isn't allowed to do that",
|
"AccessDenied": "the key isn't allowed to do that",
|
||||||
|
"XAmzContentSHA256Mismatch": "the file's bytes don't match its recorded checksum",
|
||||||
}
|
}
|
||||||
said = hints.get(code) or message or reason
|
said = hints.get(code) or message or reason
|
||||||
if not code and 400 <= status < 500:
|
if not code and 400 <= status < 500:
|
||||||
@@ -190,10 +191,13 @@ def file_sha256(path: Path) -> str:
|
|||||||
def put_file(target: S3Target, key: str, path: Path, *,
|
def put_file(target: S3Target, key: str, path: Path, *,
|
||||||
content_type: str = "application/octet-stream",
|
content_type: str = "application/octet-stream",
|
||||||
payload_sha256: str | None = None,
|
payload_sha256: str | None = None,
|
||||||
|
pacer: Pacer | None = None,
|
||||||
now: datetime | None = None) -> None:
|
now: datetime | None = None) -> None:
|
||||||
"""Upload one file as one object, streamed from disk. `payload_sha256` is
|
"""Upload one file as one object, streamed from disk. `payload_sha256` is
|
||||||
the file's sha256 when the caller already knows it (the library stores
|
the file's sha256 when the caller already knows it (the library stores
|
||||||
every file's), which saves reading the file twice."""
|
every file's), which saves reading the file twice; the bucket checks the
|
||||||
|
bytes against it, so a file that changed on disk is refused rather than
|
||||||
|
stored under the wrong name."""
|
||||||
digest = payload_sha256 or file_sha256(path)
|
digest = payload_sha256 or file_sha256(path)
|
||||||
size = path.stat().st_size
|
size = path.stat().st_size
|
||||||
url = target.url(key)
|
url = target.url(key)
|
||||||
@@ -203,7 +207,8 @@ def put_file(target: S3Target, key: str, path: Path, *,
|
|||||||
conn = _connect(target, url, TRANSFER_TIMEOUT_SECONDS)
|
conn = _connect(target, url, TRANSFER_TIMEOUT_SECONDS)
|
||||||
try:
|
try:
|
||||||
with open(path, "rb") as f:
|
with open(path, "rb") as f:
|
||||||
conn.request("PUT", _request_target(url), body=f, headers=headers)
|
conn.request("PUT", _request_target(url),
|
||||||
|
body=pacer.file_chunks(f) if pacer else f, headers=headers)
|
||||||
response = conn.getresponse()
|
response = conn.getresponse()
|
||||||
content = response.read()
|
content = response.read()
|
||||||
except (OSError, http.client.HTTPException) as exc:
|
except (OSError, http.client.HTTPException) as exc:
|
||||||
@@ -307,15 +312,23 @@ class Pacer:
|
|||||||
self.sent = 0
|
self.sent = 0
|
||||||
self.started = time.monotonic()
|
self.started = time.monotonic()
|
||||||
|
|
||||||
|
def _wait(self, n: int) -> None:
|
||||||
|
if self.rate:
|
||||||
|
self.sent += n
|
||||||
|
ahead = self.sent / self.rate - (time.monotonic() - self.started)
|
||||||
|
if ahead > 0:
|
||||||
|
time.sleep(ahead)
|
||||||
|
|
||||||
def chunks(self, data: bytes) -> Iterator[memoryview]:
|
def chunks(self, data: bytes) -> Iterator[memoryview]:
|
||||||
view = memoryview(data)
|
view = memoryview(data)
|
||||||
for at in range(0, len(view), CHUNK):
|
for at in range(0, len(view), CHUNK):
|
||||||
piece = view[at:at + CHUNK]
|
piece = view[at:at + CHUNK]
|
||||||
if self.rate:
|
self._wait(len(piece))
|
||||||
self.sent += len(piece)
|
yield piece
|
||||||
ahead = self.sent / self.rate - (time.monotonic() - self.started)
|
|
||||||
if ahead > 0:
|
def file_chunks(self, f) -> Iterator[bytes]:
|
||||||
time.sleep(ahead)
|
while piece := f.read(CHUNK):
|
||||||
|
self._wait(len(piece))
|
||||||
yield piece
|
yield piece
|
||||||
|
|
||||||
|
|
||||||
@@ -455,3 +468,38 @@ class Upload:
|
|||||||
_send(self.target, "DELETE", self._url())
|
_send(self.target, "DELETE", self._url())
|
||||||
except Exception: # noqa: BLE001 — best effort, see above
|
except Exception: # noqa: BLE001 — best effort, see above
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def put_file_in_parts(target: S3Target, key: str, path: Path, *, sha256: str,
|
||||||
|
content_type: str = "application/octet-stream",
|
||||||
|
pacer: Pacer | None = None) -> int:
|
||||||
|
"""Upload a large file as a multipart object, read from disk a part at a
|
||||||
|
time. The whole file's sha256 is computed on the way and must equal
|
||||||
|
`sha256` before the upload is completed; otherwise it is aborted, so a file
|
||||||
|
that changed on disk is never stored under its old name. Returns the
|
||||||
|
number of bytes sent."""
|
||||||
|
upload = Upload(target, key, content_type=content_type, pacer=pacer)
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
while part := f.read(PART_SIZE):
|
||||||
|
digest.update(part)
|
||||||
|
upload.add(part)
|
||||||
|
if digest.hexdigest() != sha256:
|
||||||
|
raise S3Error(f"{path} no longer matches its recorded checksum; not stored.")
|
||||||
|
upload.complete()
|
||||||
|
except BaseException:
|
||||||
|
upload.abort()
|
||||||
|
raise
|
||||||
|
return upload.size
|
||||||
|
|
||||||
|
|
||||||
|
def object_present(target: S3Target, key: str, size: int) -> bool:
|
||||||
|
"""Whether `key` is already in the bucket at `size` bytes. Used when a put
|
||||||
|
is refused: an append-only bucket refuses to replace an object that is
|
||||||
|
already there, which for a content-addressed key means the work is done."""
|
||||||
|
try:
|
||||||
|
page = list_objects(target, prefix=key, limit=1)
|
||||||
|
except S3Error:
|
||||||
|
return False
|
||||||
|
return any(o.key == key and o.size == size for o in page.objects)
|
||||||
|
|||||||
+172
-4
@@ -8,6 +8,7 @@ BackupRun.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ from sqlalchemy.exc import DBAPIError, OperationalError
|
|||||||
from ..celery_app import celery
|
from ..celery_app import celery
|
||||||
from ..config import get_config
|
from ..config import get_config
|
||||||
from ..models import BackupRun, ImportSettings
|
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
|
from ._sync_engine import sync_session_factory as _sync_session_factory
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -267,7 +268,9 @@ def restore_images_task(self, *, source_backup_run_id: int) -> dict:
|
|||||||
def prune_backups() -> dict:
|
def prune_backups() -> dict:
|
||||||
"""Daily Beat. Per-kind retention from ImportSettings.
|
"""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.
|
Tagged rows (tag IS NOT NULL) are never pruned.
|
||||||
Status='running' / 'restoring' rows are never pruned — the
|
Status='running' / 'restoring' rows are never pruned — the
|
||||||
recover_stalled_backup_runs sweep flips truly-stuck ones to
|
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.)
|
Audit 2026-06-02 added the dedicated sweep.)
|
||||||
"""
|
"""
|
||||||
SessionLocal = _sync_session_factory()
|
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:
|
with SessionLocal() as session:
|
||||||
s = ImportSettings.load_sync(session)
|
s = ImportSettings.load_sync(session)
|
||||||
for kind, keep in (
|
for kind, keep in (
|
||||||
("db", s.backup_db_keep_last_n),
|
("db", s.backup_db_keep_last_n),
|
||||||
("images", s.backup_images_keep_last_n),
|
("images", s.backup_images_keep_last_n),
|
||||||
|
("media", MEDIA_RUNS_KEPT),
|
||||||
):
|
):
|
||||||
candidates = session.execute(
|
candidates = session.execute(
|
||||||
select(BackupRun)
|
select(BackupRun)
|
||||||
@@ -325,4 +329,168 @@ def backup_db_nightly() -> dict:
|
|||||||
if now_hour != configured_hour:
|
if now_hour != configured_hour:
|
||||||
return {"skipped": f"hour={now_hour} != configured={configured_hour}"}
|
return {"skipped": f"hour={now_hour} != configured={configured_hour}"}
|
||||||
res = backup_db_task.delay(triggered_by="nightly")
|
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)
|
update(BackupRun)
|
||||||
.where(BackupRun.status.in_(["running", "restoring"]))
|
.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); 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(
|
.where(
|
||||||
or_(
|
or_(
|
||||||
and_(BackupRun.kind == "db", BackupRun.started_at < db_cutoff),
|
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)
|
.values(status="error", finished_at=now, error=msg)
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
<p v-if="offsite" class="fc-muted text-body-2 mb-4">
|
<p v-if="offsite" class="fc-muted text-body-2 mb-4">
|
||||||
Database dumps stream to the <strong>{{ offsite.bucket }}</strong> bucket
|
Database dumps stream to the <strong>{{ offsite.bucket }}</strong> bucket
|
||||||
at {{ offsite.endpoint }}, with no copy kept on this server; the bucket's
|
at {{ offsite.endpoint }}, with no copy kept on this server; the bucket's
|
||||||
own retention thins old dumps. The images backup below still writes a
|
own retention thins old dumps. Images and attachments go to the same
|
||||||
tar+zstd to <code>/images/_backups/</code>, by hand only.
|
bucket's <code>media/</code> folder, below.
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="fc-muted text-body-2 mb-4">
|
<p v-else class="fc-muted text-body-2 mb-4">
|
||||||
pg_dump for the database (fast, nightly-schedulable);
|
pg_dump for the database (fast, nightly-schedulable);
|
||||||
@@ -48,7 +48,8 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="offsite" class="fc-muted text-caption mb-3">
|
<p v-if="offsite" class="fc-muted text-caption mb-3">
|
||||||
Keep last applies to this list and to any dumps still on this server.
|
Nightly sends the database, then any new images and attachments. Keep
|
||||||
|
last applies to this list and to any dumps still on this server.
|
||||||
</p>
|
</p>
|
||||||
<v-btn
|
<v-btn
|
||||||
color="accent" variant="flat" rounded="pill"
|
color="accent" variant="flat" rounded="pill"
|
||||||
@@ -66,6 +67,38 @@
|
|||||||
|
|
||||||
<v-divider class="my-4" />
|
<v-divider class="my-4" />
|
||||||
|
|
||||||
|
<!-- Off-site: images and attachments as one file per fingerprint -->
|
||||||
|
<template v-if="offsite">
|
||||||
|
<h3 class="fc-section-title">Images and attachments</h3>
|
||||||
|
<p class="fc-muted text-body-2 mb-2">
|
||||||
|
Each file goes to the bucket's <code>media/</code> folder once, named by
|
||||||
|
its fingerprint, and the bucket checks it against the fingerprint taken
|
||||||
|
at import. Only files not sent yet go in each run. With Nightly on, they
|
||||||
|
follow the database dump every night.
|
||||||
|
</p>
|
||||||
|
<div v-if="coverage" class="mb-3">
|
||||||
|
<div class="text-body-2 mb-1" style="font-variant-numeric: tabular-nums;">
|
||||||
|
{{ coverage.files_sent.toLocaleString() }} of
|
||||||
|
{{ coverage.files_total.toLocaleString() }} files in the bucket
|
||||||
|
({{ humanBytes(coverage.bytes_sent) }} of {{ humanBytes(coverage.bytes_total) }})
|
||||||
|
</div>
|
||||||
|
<v-progress-linear
|
||||||
|
:model-value="coveragePct" color="accent" height="6" rounded
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<v-btn
|
||||||
|
color="accent" variant="flat" rounded="pill"
|
||||||
|
prepend-icon="mdi-cloud-upload-outline" class="mb-3"
|
||||||
|
:loading="mediaTriggering" :disabled="mediaRunning"
|
||||||
|
@click="onTrigger('media')"
|
||||||
|
>Send new files now</v-btn>
|
||||||
|
<span v-if="mediaRunning" class="text-caption fc-muted ml-2">
|
||||||
|
A run is going: {{ runningSent.toLocaleString() }} files sent so far.
|
||||||
|
</span>
|
||||||
|
<BackupRunsTable :runs="store.mediaRuns" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
<!-- Images section -->
|
<!-- Images section -->
|
||||||
<h3 class="fc-section-title">Images</h3>
|
<h3 class="fc-section-title">Images</h3>
|
||||||
<div class="fc-settings-row mb-3">
|
<div class="fc-settings-row mb-3">
|
||||||
@@ -92,6 +125,7 @@
|
|||||||
@delete="onDelete"
|
@delete="onDelete"
|
||||||
@tag="onTag"
|
@tag="onTag"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
<BackupConfirmModal
|
<BackupConfirmModal
|
||||||
v-model="confirmOpen"
|
v-model="confirmOpen"
|
||||||
:action="confirmAction"
|
:action="confirmAction"
|
||||||
@@ -109,6 +143,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
|||||||
import { useBackupStore } from '../../stores/backup.js'
|
import { useBackupStore } from '../../stores/backup.js'
|
||||||
import BackupConfirmModal from '../modal/DestructiveConfirmModal.vue'
|
import BackupConfirmModal from '../modal/DestructiveConfirmModal.vue'
|
||||||
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
import MaintenanceTile from '../common/MaintenanceTile.vue'
|
||||||
|
import { humanBytes } from '../../utils/bytes.js'
|
||||||
import BackupRunsTable from './BackupRunsTable.vue'
|
import BackupRunsTable from './BackupRunsTable.vue'
|
||||||
|
|
||||||
const store = useBackupStore()
|
const store = useBackupStore()
|
||||||
@@ -125,6 +160,16 @@ const offsite = computed(() => (
|
|||||||
store.destination?.configured ? store.destination : null
|
store.destination?.configured ? store.destination : null
|
||||||
))
|
))
|
||||||
|
|
||||||
|
const coverage = computed(() => store.mediaCoverage)
|
||||||
|
const coveragePct = computed(() => {
|
||||||
|
const c = coverage.value
|
||||||
|
return c && c.files_total ? (100 * c.files_sent) / c.files_total : 0
|
||||||
|
})
|
||||||
|
const runningMedia = computed(() => store.mediaRuns.find((r) => r.status === 'running'))
|
||||||
|
const mediaRunning = computed(() => Boolean(runningMedia.value))
|
||||||
|
const runningSent = computed(() => runningMedia.value?.manifest?.sent || 0)
|
||||||
|
|
||||||
|
const mediaTriggering = ref(false)
|
||||||
const dbTriggering = ref(false)
|
const dbTriggering = ref(false)
|
||||||
const imagesTriggering = ref(false)
|
const imagesTriggering = ref(false)
|
||||||
const confirmOpen = ref(false)
|
const confirmOpen = ref(false)
|
||||||
@@ -134,11 +179,20 @@ const confirmRunId = ref(0)
|
|||||||
const confirmDescription = ref('')
|
const confirmDescription = ref('')
|
||||||
|
|
||||||
let pollId = null
|
let pollId = null
|
||||||
|
let polls = 0
|
||||||
|
// Coverage counts every library file, so it refreshes every sixth poll
|
||||||
|
// (30 s) rather than with the run lists.
|
||||||
|
const COVERAGE_EVERY = 6
|
||||||
function pollOnce() {
|
function pollOnce() {
|
||||||
if (document.hidden) return
|
if (document.hidden) return
|
||||||
store.loadRuns('db')
|
store.loadRuns('db')
|
||||||
|
if (offsite.value) {
|
||||||
|
store.loadRuns('media')
|
||||||
|
if (polls++ % COVERAGE_EVERY === 0) store.loadMediaCoverage()
|
||||||
|
} else {
|
||||||
store.loadRuns('images')
|
store.loadRuns('images')
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.loadSettings()
|
store.loadSettings()
|
||||||
@@ -151,7 +205,7 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function onTrigger(kind) {
|
async function onTrigger(kind) {
|
||||||
const triggeringRef = kind === 'db' ? dbTriggering : imagesTriggering
|
const triggeringRef = { db: dbTriggering, images: imagesTriggering, media: mediaTriggering }[kind]
|
||||||
triggeringRef.value = true
|
triggeringRef.value = true
|
||||||
try {
|
try {
|
||||||
await store.triggerBackup(kind)
|
await store.triggerBackup(kind)
|
||||||
|
|||||||
@@ -84,7 +84,8 @@
|
|||||||
No destination is set up, so nothing is sent off this server yet.
|
No destination is set up, so nothing is sent off this server yet.
|
||||||
</p>
|
</p>
|
||||||
<p v-else-if="configured && !testResult" class="fc-muted text-caption mb-0">
|
<p v-else-if="configured && !testResult" class="fc-muted text-caption mb-0">
|
||||||
Database backups go to this bucket. Set when they run under Backups below.
|
Database backups and the library's images and attachments go to this bucket.
|
||||||
|
Set when they run under Backups below.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<v-dialog v-model="confirmRemove" max-width="440">
|
<v-dialog v-model="confirmRemove" max-width="440">
|
||||||
|
|||||||
@@ -8,13 +8,15 @@ export const useBackupStore = defineStore('backup', () => {
|
|||||||
|
|
||||||
const dbRuns = ref([])
|
const dbRuns = ref([])
|
||||||
const imagesRuns = ref([])
|
const imagesRuns = ref([])
|
||||||
|
const mediaRuns = ref([])
|
||||||
const settings = ref(null)
|
const settings = ref(null)
|
||||||
const loading = ref({ dbRuns: false, imagesRuns: false, settings: false })
|
const loading = ref({ dbRuns: false, imagesRuns: false, mediaRuns: false, settings: false })
|
||||||
const lastError = ref(null)
|
const lastError = ref(null)
|
||||||
|
const RUNS = { db: dbRuns, images: imagesRuns, media: mediaRuns }
|
||||||
|
|
||||||
async function loadRuns(kind) {
|
async function loadRuns(kind) {
|
||||||
const targetRef = kind === 'db' ? dbRuns : imagesRuns
|
const targetRef = RUNS[kind]
|
||||||
const loadingKey = kind === 'db' ? 'dbRuns' : 'imagesRuns'
|
const loadingKey = `${kind}Runs`
|
||||||
loading.value[loadingKey] = true
|
loading.value[loadingKey] = true
|
||||||
lastError.value = null
|
lastError.value = null
|
||||||
try {
|
try {
|
||||||
@@ -125,8 +127,21 @@ export const useBackupStore = defineStore('backup', () => {
|
|||||||
return api.post('/api/system/backup/destination/test', { body: fields })
|
return api.post('/api/system/backup/destination/test', { body: fields })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// How much of the library is in the off-site bucket:
|
||||||
|
// {configured, files_total, files_sent, bytes_total, bytes_sent}.
|
||||||
|
const mediaCoverage = ref(null)
|
||||||
|
|
||||||
|
async function loadMediaCoverage() {
|
||||||
|
try {
|
||||||
|
mediaCoverage.value = await api.get('/api/system/backup/media')
|
||||||
|
} catch (e) {
|
||||||
|
lastError.value = e.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dbRuns, imagesRuns, settings, loading, lastError,
|
dbRuns, imagesRuns, mediaRuns, mediaCoverage, loadMediaCoverage,
|
||||||
|
settings, loading, lastError,
|
||||||
loadRuns, triggerBackup, restore, deleteRun, setTag,
|
loadRuns, triggerBackup, restore, deleteRun, setTag,
|
||||||
loadSettings, patchSettings,
|
loadSettings, patchSettings,
|
||||||
destination, loadDestination, saveDestination, removeDestination, testDestination,
|
destination, loadDestination, saveDestination, removeDestination, testDestination,
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"""The off-site copy of the library's files (services/backup_media.py and the
|
||||||
|
backup_media_task chunk loop): each image and attachment goes to the bucket
|
||||||
|
once, by sha256, the work list is what isn't there yet, and a file that can't
|
||||||
|
be sent is reported and tried again rather than stopping the run."""
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from backend.app.models import BackupBlob, BackupRun, ImageRecord, PostAttachment
|
||||||
|
from backend.app.services import backup_media
|
||||||
|
from backend.app.services.backup_s3 import ListPage, S3Error, S3Target, StoredObject
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
TARGET = S3Target("http://archives.example:8081", "curator", "AK", "SK")
|
||||||
|
TID = "http://archives.example:8081/curator"
|
||||||
|
|
||||||
|
|
||||||
|
def _sha(c: str) -> str:
|
||||||
|
return c * 64
|
||||||
|
|
||||||
|
|
||||||
|
def _image(db_sync, tmp_path, c, body=b"img", ext=".jpg"):
|
||||||
|
path = tmp_path / f"{c}{ext}"
|
||||||
|
path.write_bytes(body)
|
||||||
|
db_sync.add(ImageRecord(path=str(path), sha256=_sha(c), size_bytes=len(body),
|
||||||
|
mime="image/jpeg", origin="imported_filesystem"))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _attachment(db_sync, tmp_path, c, body=b"zip", name="set.zip"):
|
||||||
|
path = tmp_path / f"{c}-{name}"
|
||||||
|
path.write_bytes(body)
|
||||||
|
db_sync.add(PostAttachment(sha256=_sha(c), path=str(path), original_filename=name,
|
||||||
|
ext=".zip", size_bytes=len(body)))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
# ── keys and the work list ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_keys_are_content_addressed_under_media():
|
||||||
|
sha = "abcd" + "0" * 60
|
||||||
|
assert backup_media.blob_key(sha, "/images/x/Pic.JPG") == f"media/ab/cd/{sha}.jpg"
|
||||||
|
assert backup_media.blob_key(sha, "/images/x/noext") == f"media/ab/cd/{sha}"
|
||||||
|
assert backup_media.blob_key(sha, "/images/x/a.we ird") == f"media/ab/cd/{sha}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_work_list_is_every_file_once_that_the_bucket_lacks(db_sync, tmp_path):
|
||||||
|
_image(db_sync, tmp_path, "a")
|
||||||
|
_image(db_sync, tmp_path, "b")
|
||||||
|
_attachment(db_sync, tmp_path, "c")
|
||||||
|
# The same archive held by a second post is one file to send.
|
||||||
|
db_sync.add(PostAttachment(sha256=_sha("c"), path=str(tmp_path / "c-copy.zip"),
|
||||||
|
original_filename="set.zip", ext=".zip", size_bytes=3))
|
||||||
|
db_sync.add(BackupBlob(target=TID, sha256=_sha("b"), key="media/bb", size_bytes=3))
|
||||||
|
# A row for another bucket doesn't count for this one.
|
||||||
|
db_sync.add(BackupBlob(target="http://other/bucket", sha256=_sha("a"), key="k",
|
||||||
|
size_bytes=3))
|
||||||
|
db_sync.commit()
|
||||||
|
|
||||||
|
shas = [b.sha256 for b in backup_media.pending(db_sync, TID)]
|
||||||
|
assert shas == [_sha("a"), _sha("c")]
|
||||||
|
assert [b.sha256 for b in backup_media.pending(db_sync, TID, after=_sha("a"))] == [_sha("c")]
|
||||||
|
|
||||||
|
cov = backup_media.coverage(db_sync, TID)
|
||||||
|
assert cov == {"files_total": 3, "bytes_total": 9, "files_sent": 1, "bytes_sent": 3}
|
||||||
|
|
||||||
|
|
||||||
|
# ── sending one file ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def puts(monkeypatch):
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr(backup_media, "put_file",
|
||||||
|
lambda t, key, path, **kw: sent.append(("whole", key, kw)))
|
||||||
|
monkeypatch.setattr(backup_media, "put_file_in_parts",
|
||||||
|
lambda t, key, path, **kw: sent.append(("parts", key, kw)))
|
||||||
|
return sent
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_file_is_signed_with_its_recorded_sha(db_sync, tmp_path, puts):
|
||||||
|
path = _image(db_sync, tmp_path, "a")
|
||||||
|
blob = backup_media.Blob(_sha("a"), str(path), 3, "image/jpeg")
|
||||||
|
key = backup_media.send(TARGET, blob, tmp_path, None)
|
||||||
|
assert key == f"media/aa/aa/{_sha('a')}.jpg"
|
||||||
|
(how, sent_key, kw) = puts[0]
|
||||||
|
assert (how, sent_key) == ("whole", key)
|
||||||
|
assert kw["payload_sha256"] == _sha("a") and kw["content_type"] == "image/jpeg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_large_file_goes_in_parts(db_sync, tmp_path, puts, monkeypatch):
|
||||||
|
monkeypatch.setattr(backup_media, "MULTIPART_OVER", 2)
|
||||||
|
path = _attachment(db_sync, tmp_path, "c")
|
||||||
|
backup_media.send(TARGET, backup_media.Blob(_sha("c"), str(path), 3, None), tmp_path, None)
|
||||||
|
assert puts[0][0] == "parts"
|
||||||
|
assert puts[0][2]["sha256"] == _sha("c")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_or_changed_file_is_refused_before_sending(db_sync, tmp_path, puts):
|
||||||
|
gone = backup_media.Blob(_sha("a"), str(tmp_path / "gone.jpg"), 3, None)
|
||||||
|
with pytest.raises(S3Error, match="not on disk"):
|
||||||
|
backup_media.send(TARGET, gone, tmp_path, None)
|
||||||
|
path = _image(db_sync, tmp_path, "b", body=b"longer now")
|
||||||
|
changed = backup_media.Blob(_sha("b"), str(path), 3, None)
|
||||||
|
with pytest.raises(S3Error, match="when imported"):
|
||||||
|
backup_media.send(TARGET, changed, tmp_path, None)
|
||||||
|
assert puts == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_refusal_for_an_object_already_there_counts_as_sent(db_sync, tmp_path, monkeypatch):
|
||||||
|
def refused(*a, **kw):
|
||||||
|
raise S3Error("HTTP 403 AccessDenied: append-only.")
|
||||||
|
monkeypatch.setattr(backup_media, "put_file", refused)
|
||||||
|
monkeypatch.setattr(backup_media, "object_present", lambda t, key, size: True)
|
||||||
|
path = _image(db_sync, tmp_path, "a")
|
||||||
|
key = backup_media.send(TARGET, backup_media.Blob(_sha("a"), str(path), 3, None),
|
||||||
|
tmp_path, None)
|
||||||
|
assert key.startswith("media/aa/aa/")
|
||||||
|
|
||||||
|
|
||||||
|
# ── the run ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def run_env(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr("backend.app.tasks.backup.IMAGES_ROOT", tmp_path)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"backend.app.services.backup_destination.saved_target", lambda settings: TARGET,
|
||||||
|
)
|
||||||
|
queued = []
|
||||||
|
from backend.app.tasks import backup as tasks
|
||||||
|
monkeypatch.setattr(tasks.backup_media_task, "delay", lambda **kw: queued.append(kw))
|
||||||
|
return queued
|
||||||
|
|
||||||
|
|
||||||
|
def _send_all_but(bad_sha):
|
||||||
|
def send(target, blob, images_root, pacer):
|
||||||
|
if blob.sha256 == bad_sha:
|
||||||
|
raise S3Error("HTTP 503: busy.")
|
||||||
|
return backup_media.blob_key(blob.sha256, blob.path)
|
||||||
|
return send
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_run_sends_what_is_missing_and_reports_what_failed(
|
||||||
|
db_sync, tmp_path, run_env, monkeypatch,
|
||||||
|
):
|
||||||
|
from backend.app.tasks.backup import backup_media_task
|
||||||
|
for c in "abc":
|
||||||
|
_image(db_sync, tmp_path, c)
|
||||||
|
db_sync.commit()
|
||||||
|
monkeypatch.setattr(backup_media, "send", _send_all_but(_sha("b")))
|
||||||
|
|
||||||
|
result = backup_media_task(triggered_by="manual")
|
||||||
|
assert result["continues"] is False
|
||||||
|
row = db_sync.get(BackupRun, result["backup_run_id"])
|
||||||
|
db_sync.refresh(row)
|
||||||
|
assert (row.kind, row.status, row.size_bytes) == ("media", "ok", 6)
|
||||||
|
assert row.manifest["sent"] == 2 and row.manifest["failed"] == 1
|
||||||
|
assert row.manifest["offsite"]["bucket"] == "curator"
|
||||||
|
assert "1 file(s) weren't sent" in row.error
|
||||||
|
assert row.manifest["failures"][0]["error"] == "HTTP 503: busy."
|
||||||
|
sent = set(db_sync.execute(select(BackupBlob.sha256)).scalars())
|
||||||
|
assert sent == {_sha("a"), _sha("c")}
|
||||||
|
|
||||||
|
# The next run tries only the file that failed.
|
||||||
|
monkeypatch.setattr(backup_media, "send", _send_all_but(None))
|
||||||
|
again = backup_media_task(triggered_by="manual")
|
||||||
|
row = db_sync.get(BackupRun, again["backup_run_id"])
|
||||||
|
db_sync.refresh(row)
|
||||||
|
assert row.manifest["sent"] == 1 and row.error is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_run_out_of_time_queues_its_next_chunk(db_sync, tmp_path, run_env, monkeypatch):
|
||||||
|
from backend.app.tasks import backup as tasks
|
||||||
|
for c in "ab":
|
||||||
|
_image(db_sync, tmp_path, c)
|
||||||
|
db_sync.commit()
|
||||||
|
monkeypatch.setattr(backup_media, "send", _send_all_but(None))
|
||||||
|
monkeypatch.setattr(tasks, "MEDIA_CHUNK_SECONDS", -1)
|
||||||
|
|
||||||
|
result = tasks.backup_media_task(triggered_by="manual")
|
||||||
|
assert result["continues"] is True
|
||||||
|
assert run_env == [{"run_id": result["backup_run_id"]}]
|
||||||
|
row = db_sync.get(BackupRun, result["backup_run_id"])
|
||||||
|
db_sync.refresh(row)
|
||||||
|
assert row.status == "running" and row.last_progress_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_second_run_waits_for_the_one_going(db_sync, run_env):
|
||||||
|
from backend.app.tasks.backup import backup_media_task
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
db_sync.add(BackupRun(kind="media", status="running", triggered_by="nightly",
|
||||||
|
started_at=now, manifest={}))
|
||||||
|
db_sync.commit()
|
||||||
|
assert "already running" in backup_media_task(triggered_by="manual")["skipped"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_without_a_destination_the_run_fails_with_why(db_sync, monkeypatch):
|
||||||
|
from backend.app.tasks.backup import backup_media_task
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"backend.app.services.backup_destination.saved_target", lambda settings: None,
|
||||||
|
)
|
||||||
|
with pytest.raises(S3Error):
|
||||||
|
backup_media_task(triggered_by="manual")
|
||||||
|
row = db_sync.execute(select(BackupRun).where(BackupRun.kind == "media")).scalar_one()
|
||||||
|
assert row.status == "error" and "No off-site destination" in row.error
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_long_media_run_is_judged_stalled_by_its_heartbeat(db_sync):
|
||||||
|
from backend.app.tasks.maintenance import recover_stalled_backup_runs
|
||||||
|
long_ago = datetime.now(UTC) - timedelta(hours=20)
|
||||||
|
alive = BackupRun(kind="media", status="running", triggered_by="nightly",
|
||||||
|
started_at=long_ago, last_progress_at=datetime.now(UTC), manifest={})
|
||||||
|
silent = BackupRun(kind="media", status="running", triggered_by="nightly",
|
||||||
|
started_at=long_ago, last_progress_at=long_ago, manifest={})
|
||||||
|
db_sync.add_all([alive, silent])
|
||||||
|
db_sync.commit()
|
||||||
|
assert recover_stalled_backup_runs() == 1
|
||||||
|
db_sync.refresh(alive)
|
||||||
|
db_sync.refresh(silent)
|
||||||
|
assert (alive.status, silent.status) == ("running", "error")
|
||||||
|
|
||||||
|
|
||||||
|
# ── reconcile ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconcile_resends_what_the_bucket_lost_and_adopts_what_it_has(
|
||||||
|
db_sync, tmp_path, monkeypatch,
|
||||||
|
):
|
||||||
|
_image(db_sync, tmp_path, "a") # row, but the bucket lost it
|
||||||
|
_image(db_sync, tmp_path, "b") # in the bucket, no row
|
||||||
|
_image(db_sync, tmp_path, "c") # neither
|
||||||
|
key_a = backup_media.blob_key(_sha("a"), "x.jpg")
|
||||||
|
key_b = backup_media.blob_key(_sha("b"), "x.jpg")
|
||||||
|
db_sync.add(BackupBlob(target=TID, sha256=_sha("a"), key=key_a, size_bytes=3))
|
||||||
|
db_sync.commit()
|
||||||
|
pages = iter([
|
||||||
|
ListPage([StoredObject("media/zz/other", 1, "")], "next"),
|
||||||
|
ListPage([StoredObject(key_b, 3, "")], None),
|
||||||
|
])
|
||||||
|
monkeypatch.setattr(backup_media, "list_objects", lambda *a, **kw: next(pages))
|
||||||
|
monkeypatch.setattr(backup_media.time, "sleep", lambda s: None)
|
||||||
|
|
||||||
|
assert backup_media.reconcile(db_sync, TARGET) == {
|
||||||
|
"listed": 2, "dropped": 1, "adopted": 1,
|
||||||
|
}
|
||||||
|
assert set(db_sync.execute(select(BackupBlob.sha256)).scalars()) == {_sha("b")}
|
||||||
@@ -322,3 +322,42 @@ def test_the_pacer_holds_bytes_to_the_limit(monkeypatch):
|
|||||||
def test_no_limit_never_waits(monkeypatch):
|
def test_no_limit_never_waits(monkeypatch):
|
||||||
monkeypatch.setattr(s3.time, "sleep", lambda secs: pytest.fail("waited without a limit"))
|
monkeypatch.setattr(s3.time, "sleep", lambda secs: pytest.fail("waited without a limit"))
|
||||||
assert b"".join(s3.Pacer(0).chunks(b"y" * (2 * s3.CHUNK + 1))) == b"y" * (2 * s3.CHUNK + 1)
|
assert b"".join(s3.Pacer(0).chunks(b"y" * (2 * s3.CHUNK + 1))) == b"y" * (2 * s3.CHUNK + 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_large_file_is_sent_in_parts_and_checked_against_its_sha(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(s3, "PART_SIZE", 4)
|
||||||
|
f = tmp_path / "big.zip"
|
||||||
|
f.write_bytes(b"0123456789")
|
||||||
|
with _Server(_bucket()) as srv:
|
||||||
|
sent = s3.put_file_in_parts(srv.target(), "media/x.zip", f,
|
||||||
|
sha256=hashlib.sha256(b"0123456789").hexdigest())
|
||||||
|
assert sent == 10
|
||||||
|
assert [r["body"] for r in srv.seen if r["method"] == "PUT"] == [b"0123", b"4567", b"89"]
|
||||||
|
assert srv.seen[-1]["method"] == "POST" # completed
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_file_that_changed_since_import_is_not_completed(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(s3, "PART_SIZE", 4)
|
||||||
|
f = tmp_path / "big.zip"
|
||||||
|
f.write_bytes(b"0123456789")
|
||||||
|
with _Server(_bucket()) as srv:
|
||||||
|
with pytest.raises(s3.S3Error, match="recorded checksum"):
|
||||||
|
s3.put_file_in_parts(srv.target(), "media/x.zip", f, sha256="0" * 64)
|
||||||
|
assert srv.seen[-1]["method"] == "DELETE" # aborted, nothing stored
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_paced_put_sends_the_same_bytes(tmp_path):
|
||||||
|
f = tmp_path / "a.bin"
|
||||||
|
f.write_bytes(b"z" * (s3.CHUNK + 5))
|
||||||
|
with _Server(lambda h: (200, b"")) as srv:
|
||||||
|
s3.put_file(srv.target(), "a.bin", f, pacer=s3.Pacer(0))
|
||||||
|
assert srv.seen[0]["body"] == b"z" * (s3.CHUNK + 5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_presence_is_the_exact_key_at_the_exact_size():
|
||||||
|
listing = (b"<ListBucketResult><Contents><Key>media/a.jpg</Key><Size>3</Size>"
|
||||||
|
b"</Contents></ListBucketResult>")
|
||||||
|
with _Server(lambda h: (200, listing)) as srv:
|
||||||
|
assert s3.object_present(srv.target(), "media/a.jpg", 3)
|
||||||
|
assert not s3.object_present(srv.target(), "media/a.jpg", 4)
|
||||||
|
assert not s3.object_present(srv.target(), "media/a.jp", 3)
|
||||||
|
|||||||
Reference in New Issue
Block a user