Storage read interface returns a Path and cannot support S3 #90
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: MEDIUM (cheap now, breaking later)
The problem
backend/app/services/storage.py:27-28defines the read side of the storage abstraction as:The contract is "return a filesystem path the caller may read directly," consumed by
FileResponseatphotos.py:177,190. An S3 backend (#41) cannot honour that withoutdownloading every object to a temp file first.
So the Phase 4 exit criterion — "swapping storage backends requires no core application logic
change" — fails at the interface, not the implementation.
By contrast the auth abstraction is already effectively generic (OIDC discovery URL), and the AI
backend does not exist yet, so #3's protocol plan is unconstrained.
Fix
Change the read contract now, while there is one implementation and two call sites:
Serve via
StreamingResponse, falling back to a redirect whenpublic_urlreturns a value.Cheap today. Expensive once #41 is in flight and every media path assumes a local file.
Done when
LocalStorageis the only implementation but #41 has nothing to renegotiateReferences
backend/app/services/storage.py:27-28backend/app/api/routes/photos.py:177,190Blocks: #41 (S3 storage backend).
Done in
d5b796b.backend/tests/test_storage_interface.py, 19 tests.Four methods, not one
The issue proposes
open()+public_url(). That is most of it, but two consumers make it four — and the reason is the same reasonget_pathwent wrong in the first place: the callers want different things and collapsing them hides a cost.open(key) -> BinaryIOsize(key) -> intContent-Length, without reading anything — a metadata call on a remote backend, not a downloadlocal_path(key)public_url(key)Nonelocal_pathis the one the issue does not mention, and it is unavoidable. The image sandbox (#65) and tesseract (#4) are subprocesses. A subprocess cannot be handed a Python file object, and no interface can wish that away. What the context manager buys is that the cost is visible: a remote backend downloads and deletes inside the block, a local one yields the stored file for free, and a call site that only needs bytes cannot silently acquire a download by reaching for the wrong method.public_urlis deliberately not abstract. "Cannot" is the right answer for a local disk — and it must be, because handing out a filesystem path is not something this may ever do. Forcing every backend to writereturn Noneteaches nothing.Range requests
FileResponseanswered these;StreamingResponsedoes not. Dropping them in exchange for an abstraction would be a silent regression, and a client assembling a file from ranges would get the whole body back for each one and quietly produce nonsense. So the route does it:bytes=5-9), open-ended (bytes=30-), and suffix (bytes=-8= the last eight bytes, not the first — getting that backwards produces a plausible response that is simply wrong)Rangeignored, per RFC 9110Accept-Ranges: bytesadvertisedMulti-range is answered with the whole body, which is permitted and much simpler than assembling a multipart response for a case nothing here produces.
Tested against a backend with no filesystem
InMemoryStorageimplements the contract and holds its objects in a dict. That is the only way to prove the route makes no filesystem assumption —LocalStoragewould satisfy a route that still calledget_path. Media serving, caching headers, 304s, ranges, 404s and the presigned redirect are all exercised through it.The redirect path is asserted with authorization first: a presigned URL is a credential, and handing one to a caller who may not read the photograph would be worse than serving the bytes, because the URL outlives the request and can be passed on.
The OCR handler is exercised through the worker against the same in-memory backend, so "the worker stops assuming a local disk" is checked rather than asserted from reading the source.
Done when
LocalStorageis the only implementation but #41 has nothing to renegotiate —test_get_path_is_gonesays so explicitly, because aget_pathleft in place "for compatibility" is a contract a future backend still has to honour1098 passed, 8 skipped; 7 e2e; ruff clean; no OpenAPI change.