fix(server): stream the backup as an async iterator so it doesn't time out#3074
Conversation
…e out StreamingHttpResponse only streams an asynchronous iterator under ASGI. Handed the sync stream_backup() generator, Django's __aiter__ falls back to `await sync_to_async(list)(...)`, which drains the whole generator — building the entire archive (buffered in RAM) before the first response byte. That silently reintroduced the 0-bytes-then-timeout failure the streaming path was meant to fix and risks OOM on a 1 GB Pi (issue #3073). - add astream_backup(): async wrapper pulling each chunk via sync_to_async(thread_sensitive=False) so bytes flow as the tar builds - close the underlying sync generator on disconnect to stop the producer - point the download view at astream_backup() (is_async path) - regression test drives aiter(StreamingHttpResponse(...)) — the real ASGI consumption path the unit-level test never exercised Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes backup downloads timing out under ASGI by ensuring the backup stream is an async iterator, so Django’s ASGI streaming path sends bytes as the archive is produced (instead of buffering the entire generator in memory first).
Changes:
- Add
astream_backup()as an async wrapper around the existingstream_backup()generator. - Update the settings backup download view to use
astream_backup()so ASGI truly streams. - Add a regression test that iterates
StreamingHttpResponsevia the ASGIaiter(...)consumption path and validates recovery.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
tests/test_backup_helper.py |
Adds an ASGI-path regression test validating async streaming + recover() round-trip. |
src/anthias_server/lib/backup_helper.py |
Introduces astream_backup() async generator wrapper using sync_to_async to pull chunks. |
src/anthias_server/app/views.py |
Switches the backup download endpoint from stream_backup() to astream_backup() and updates docstring. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
On-device verification (Raspberry Pi 4 Model B Rev 1.5, 4 GB)Built the Before (current master image The body sat at 0 bytes for 69 seconds (TTFB only reflects the headers — which is why the browser shows a download dialog that then stalls). The full archive was also held in RAM. On a multi-GB library this dead air exceeds the browser's request timeout → exactly issue #3073. After (this branch, Bytes flow from the first second and grow steadily (~11 MB/s) for the whole build — the browser sees continuous progress and never stalls, and server memory stays flat. The streamed archive passes Testbed restored to its original image and asset library afterwards.
|
- wrap next() in a typed helper with a None sentinel; sync_to_async(next) resolved next's single-arg overload, tripping mypy on the 2-arg call - use a distinct file handle in the regression test (text- then binary-mode reuse of one variable is a mypy type conflict) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot review: with next() and close() on sync_to_async's shared pool, a client disconnect mid-next() could run gen.close() on another thread concurrently — raising "generator already executing" and leaking the producer thread. - drive next() and the cleanup close() through one dedicated single-worker executor so they can never overlap; the queued close() runs only after any in-flight next() returns - add a regression test that aclose()s the async generator mid-stream and asserts the producer thread exits cleanly Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|



Issues Fixed
Fixes issue #3073 — "Unable to Get backup since recent updates" (download dialog appears, body sits at 0 B, then times out on Pi 3B+/Pi 4B running v2026.06.3).
Description
The streaming backup added in #3005 never actually streamed under ASGI.
StreamingHttpResponseonly streams an asynchronous iterator; handed the synchronousstream_backup()generator, Django 5.2's__aiter__falls into its sync-iterator branch and doesawait sync_to_async(list)(...)— draining the whole generator (building the entiretar.gz, buffered chunk-by-chunk in a RAM list) before the first response byte goes out. The headers (and so the browser download prompt) ship immediately, then the body stays at 0 B for the multi-minute build and the browser aborts. This is the exact failure #3005 set out to fix, plus an OOM risk on a 1 GB Pi with a multi-GB library.The unit tests only called the helper directly, so they never traversed Django's ASGI
__aiter__and didn't catch it.Fix:
astream_backup(), an async wrapper that pulls each chunk from the existing producer/pipe generator viasync_to_async(next, thread_sensitive=False)so bytes flow as the archive is built and the memory footprint stays flat.thread_sensitive=Falsekeeps the blocking pipe read off Django's single shared sync executor.aclose()s the async generator; we close the underlying sync generator so the producer thread stops taring instead of leaking.astream_backup()(soresponse.is_asyncisTrueand Django takes its real streaming path).aiter(StreamingHttpResponse(...))— the real ASGI consumption path — and round-trips throughrecover().Checklist
🤖 Generated with Claude Code