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:
@@ -166,6 +166,7 @@ def _says(status: int, reason: str, content: bytes) -> str:
|
||||
"InvalidAccessKeyId": "the access key isn't known there",
|
||||
"NoSuchBucket": "there is no such bucket",
|
||||
"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
|
||||
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, *,
|
||||
content_type: str = "application/octet-stream",
|
||||
payload_sha256: str | None = None,
|
||||
pacer: Pacer | None = None,
|
||||
now: datetime | None = None) -> None:
|
||||
"""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
|
||||
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)
|
||||
size = path.stat().st_size
|
||||
url = target.url(key)
|
||||
@@ -203,7 +207,8 @@ def put_file(target: S3Target, key: str, path: Path, *,
|
||||
conn = _connect(target, url, TRANSFER_TIMEOUT_SECONDS)
|
||||
try:
|
||||
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()
|
||||
content = response.read()
|
||||
except (OSError, http.client.HTTPException) as exc:
|
||||
@@ -307,15 +312,23 @@ class Pacer:
|
||||
self.sent = 0
|
||||
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]:
|
||||
view = memoryview(data)
|
||||
for at in range(0, len(view), CHUNK):
|
||||
piece = view[at:at + CHUNK]
|
||||
if self.rate:
|
||||
self.sent += len(piece)
|
||||
ahead = self.sent / self.rate - (time.monotonic() - self.started)
|
||||
if ahead > 0:
|
||||
time.sleep(ahead)
|
||||
self._wait(len(piece))
|
||||
yield piece
|
||||
|
||||
def file_chunks(self, f) -> Iterator[bytes]:
|
||||
while piece := f.read(CHUNK):
|
||||
self._wait(len(piece))
|
||||
yield piece
|
||||
|
||||
|
||||
@@ -455,3 +468,38 @@ class Upload:
|
||||
_send(self.target, "DELETE", self._url())
|
||||
except Exception: # noqa: BLE001 — best effort, see above
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user