Ingest blocks the event loop and buffers whole uploads in RAM #86
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: HIGH
The bug
ingest_endpoint(backend/app/api/routes/ingest.py:44-80) is the onlyasync defdatahandler in the codebase — and it is exactly the one that must not be. Inside it, all of the
following run directly on the event loop:
sha256_file(full-file read)extract_exif(Pillow)shutil.copy2, twice (temp → storage)Path.write_bytesEvery other route is a sync
def, which FastAPI correctly runs in a threadpool.Additionally
await front.read()loads the entire upload into memory before the size check,so the data path is network → RAM → temp file → re-read for hashing → copy to storage: three disk
passes plus a full RAM copy, with spikes up to 2×100 MB per in-flight request.
Impact
During bulk ingest of tens of thousands of scans, every concurrent request — media serving,
review actions, everything — stalls behind each photo's hash and copy, because the event loop is
blocked. This directly violates #48's "no background job blocks interactive review."
Fix
def. FastAPI threadpools it, andfront.fileis aSpooledTemporaryFilereadable synchronously. This one keyword removes the event-loop hazardand is worth doing on its own.
pass and the RAM buffer.
MAX_FILE_SIZEduring streaming, not after. Today an oversized upload consumes memorybefore the 413.
Done when
References
backend/app/api/routes/ingest.py:44-80backend/app/services/ingest.py:64-115backend/app/services/storage.py:48-52,79-84Coordinate with the filename sanitization fix in v0.1.1 — same handler.
Done in
d689da8.backend/tests/test_ingest_streaming.py, 15 tests.The one keyword
ingest_endpointis now a plaindef. That alone removes the event-loop hazard, exactly as the issue says — FastAPI threadpools sync handlers, andupload.fileis aSpooledTemporaryFilethat reads synchronously.Streaming, with the hash and the limit folded in
One pass now does three things that used to be three passes:
bytesobjectingest_photono longer re-reads the entire file to compute a digest that was available for freeMAX_FILE_SIZEas the bytes arrive, abandoning the request at the first chunk that crosses the lineThat last one is the substantive part of the fix. The limit was applied to
len(data)after the read — the 413 arrived having already spent exactly the memory it exists to protect. The partial file is also unlinked at the point of refusal rather than left for the temp-directory teardown, so a refused upload leaves no attacker-controlled bytes on disk even briefly.ingest_phototakes the digest as an optional argument rather than requiring it, because the other callers (the CLI, a future watch-directory worker) have a path and nothing else — and a caller passing the wrong digest would silently deduplicate against the wrong photograph, so it is only ever supplied by code that hashed the same bytes it wrote.Three more handlers were doing the same thing
logout,/api/auth/me, and both dev-login routes wereasync defwith noawaitin them at all — the worst of both: no concurrency gained, and their SQLAlchemy calls running on the loop. Now sync. Onlyloginandcallbackremain async, and they genuinely await an HTTP round trip to the identity provider, which is the work an event loop is for.The rule is asserted, not remembered
test_no_data_route_is_asyncwalks the whole route table and fails on any async handler outside those two, named individually rather than by an/api/auth/prefix — a prefix exemption would have gone on saying the three above were fine.The failure here was never that somebody chose wrongly; it was that nothing said which way is right.
Verification
Verified load-bearing: restoring
async deffails both event-loop tests.Done when
1059 passed, 8 skipped; 7 e2e; ruff clean.