Skip to content

fix(backup): stream the off-host copy so it fits a small container - #927

Merged
MBombeck merged 2 commits into
mainfrom
fix/offhost-backup-streaming
Sep 6, 2026
Merged

fix(backup): stream the off-host copy so it fits a small container#927
MBombeck merged 2 commits into
mainfrom
fix/offhost-backup-streaming

Conversation

@MBombeck

@MBombeck MBombeck commented Sep 5, 2026

Copy link
Copy Markdown
Owner

The first ever configured run of the nightly off-host backup took production down: FATAL ERROR: Reached heap limit seventeen seconds in, container restarted. The destination was blanked again the same minute. This is what lets it be switched back on.

The job had shared only the payload builder with the v1.38.6 streaming work; everything after it still materialised: the whole JSON string, gzipSync over it, a whole-buffer cipher pass, one PutObject. Four copies of the record. The commit message says so plainly rather than repeating the earlier claim that the job "benefited".

Reproduced under the real ceiling with a comparable record (445 000 measurements, 30 000 mood entries, 60 000 intake events) against MinIO, from a process already holding part of its heap, since a cold script survives and a warm server does not. At 120 MB retained the old path dies inside the JSON builder; the new one completes at a peak of 319 MB against a 183 MB baseline. At 300 MB retained, closest to production, it still completes, 79 MB above baseline. Streaming holds 14 MB live where materialising held 128.

The stored envelope gains version 3 with the authentication tag at the end, which is the only place a stream can put it. Versions 1 and 2 still read: a unit test writes one of each and restores both, and a version 2 and a version 3 object of the seeded account were restored end to end through the restore script with identical counts, tombstones included. The cipher stays one implementation under two framings.

Bad credentials, a missing bucket and a refused connection each upload nothing, leave no orphaned multipart parts, keep the process up, and turn zero uploads for a non-empty cohort into a job failure that names the cause. A refused upload that rejected mid-write used to leave the producer waiting on a drain nobody delivered; that arm tears the pipeline down too, and the test for it hangs to timeout if the line is reverted.

Five mutations, each red on its own. @aws-sdk/lib-storage is pinned exactly to the installed client's line. Multipart needs AbortMultipartUpload in the bucket grant; the runbook says so.

The off-host job was said to benefit from the streaming work the weekly
in-database backup got, and it did not. Only the payload builder was
shared: `runOffhostBackup` called `buildFullBackupJson` for the whole
document, `gzipSync` over that whole string, a whole-buffer
`createCipheriv` pass over the result, and handed the finished buffer to
a single `PutObject`. Four full copies of the record alive at once, on
the same arithmetic the weekly pass had already been fixed for.

Configuring an S3 target on a long-lived record therefore restarted the
container. On an account of 445 000 measurements the JSON alone is
242 MB, and against a 1 GB container's 524 MB heap the run died of heap
exhaustion seventeen seconds in — taking every signed-in session with
it, because the job shares the app process.

The write path is a stream end to end now: `streamFullBackupJson`
produces the document a page at a time, gzip and an incremental
AES-256-GCM writer consume it as it arrives, and `@aws-sdk/lib-storage`
puts it up in two 8 MB parts. What the process holds is fixed by the
pipeline's shape rather than by the record. Measured on that same
445 000-measurement account under `--max-old-space-size=450`, with the
heap pre-loaded to what a warm server holds: the old path dies with
`Ineffective mark-compacts near heap limit`, the new one finishes with a
peak 79 MB above its baseline and writes a 9.1 MB object.

The stored envelope gains version 3, which moves the GCM tag from in
front of the ciphertext to the end. That is the only change the stream
required — the tag exists only once the last block is in, so a leading
one means the whole object must exist before its first byte can be sent.
Nothing about the authentication moves: the tag still covers every
ciphertext byte and is verified before a byte of plaintext comes back.
Versions 1 and 2 still read, so every object already in a bucket
restores through the same reader with no flag to tell them apart.

The incremental writer is the crypto module's, split into its byte-level
half so the base64 form the in-database blob uses and the raw form an
object needs are one implementation under two framings. The off-host key
handling is unchanged: a separate `BACKUP_ENCRYPTION_KEY`, 64 hex or
32-byte base64, passed in rather than looked up.

A run that uploaded nothing for anybody is a failed job now, not
`ok: true` with an empty bucket behind it. Wrong credentials, a missing
bucket and an unreachable endpoint all fail every account rather than
one, and the target's own sentence rides out as the cause. A run where
some account got a copy still succeeds; retrying the whole cohort over
one object would re-upload everybody's.

The one ceiling left is structural rather than a memory bound, and it is
counted rather than discovered: a multipart upload carries 10 000 parts,
so an object past 80 GB is refused for that account with a clear message
instead of failing halfway through with an SDK error about part numbers.

`tests/integration/offhost-backup-streaming-memory.test.ts` pins the
budget from both sides — what the streaming uploader holds, and that the
materialising path does not fit the same budget on the same fixture in
the same process, so the number cannot pass by measuring nothing.
Nothing calls it any more: the weekly pass and the off-host job both
walk the payload through the streaming writer. Keeping an exported
function that builds the entire record as one string invites the next
caller to reintroduce the memory profile this branch removes.
@MBombeck
MBombeck merged commit 48fa96d into main Sep 6, 2026
24 checks passed
@MBombeck
MBombeck deleted the fix/offhost-backup-streaming branch September 6, 2026 12:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant