perf(file_storage): one scan, cached, for the browse screen's bucket totals - #307
Merged
Conversation
…totals Rendering /file-storage/ issued five queries against `file_storage_stored_file`, three of which deliberately ignored the active filters and scanned the whole table: `content_type_facets` grouped by content type, `uploader_facets` grouped by uploader, and `used_bytes` summed every row. None was cached and none was bounded by the page size, so the cost of the screen grew with the bucket rather than with the page — on every render, including the ones that only changed `?page=`. A page requested past the end made it seven, because the clamp re-ran the whole listing after throwing away a page of rows nobody would ever see. The reasoning for ignoring the filters was right and is kept: a facet list that hides its own alternatives is a dead end, and a usage figure that shrinks when you type in the search box describes nothing. What changes is what that costs. `aggregates.compute` answers all three from a single `GROUP BY content_type, created_by` carrying a count and a byte sum, and folds the grid into the three shapes in Python. Not GROUPING SETS: SQLite has none, and the grid is already bounded by cardinality the filter dropdowns must be able to render anyway. `AggregateCache` then memoises that result per app with a 30s TTL, dropped by any commit that wrote a `StoredFile`. Invalidation hangs off the DB write rather than off `FileUploaded`/`FileDeleted` for two reasons: it also catches writes that publish nothing — a seed script, a back-fill, a fix-up in the shell — and it fires *after* the commit, so a concurrent reader cannot re-cache the pre-commit numbers for a whole TTL. The cache lives on `FileStorageServices`, not at module scope, so a process running two apps never serves one app's totals out of the other's database. A service constructed directly gets no cache and reads through, which is what a caller checking "did my write land?" wants. The view now counts before paging instead of paging and re-paging, so a clamped `?page=` costs one page fetch rather than two. Net: a cold render goes 5 file-table queries -> 3, a warm one -> 2, and a past-the-end page 7 -> 3. The read half of `FileStorageService` moves to `reads.py` as a mixin to stay under the 300-line cap — a real split, since nothing in it touches a storage backend or mutates a row. Tests assert the *shape* of the work — how many statements name the table, and that exactly one of them is grouped — rather than wall-clock time, which would be flaky in CI and would still pass on the day someone adds a fourth scan. Verified failing before the change: 5 vs 3 cold, 5 vs 2 warm, 7 vs 3 clamped. Closes #299
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Deploying simple-module-python with
|
| Latest commit: |
fb0c295
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://e7ecb6a5.simple-module-python.pages.dev |
| Branch Preview URL: | https://fix-file-storage-aggregates.simple-module-python.pages.dev |
This was referenced Sep 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #299
The problem
Rendering
/file-storage/issued five queries againstfile_storage_stored_file, three of which deliberately ignored the active filters and scanned the whole table:list_filescountCOUNT(*)list_filespagecontent_type_facetsGROUP BY content_typeover every rowuploader_facetsGROUP BY created_byover every rowused_bytesSUM(size_bytes)over every rowNone was cached and none was bounded by the page size, so the cost of the screen grew with the bucket rather than with the page — on every render, including the ones that only changed
?page=. A page requested past the end made it seven, because the clamp re-ran the whole listing after fetching and discarding a page of rows nobody would ever see.The reasoning for ignoring the filters was right and is kept: a facet list that hides its own alternatives is a dead end, and a usage figure that shrinks when you type in the search box describes nothing. What changes is what that costs.
The fix
One scan instead of three.
aggregates.computeanswers all three from a singleGROUP BY content_type, created_bycarrying a count and a byte sum, then folds the grid into the three shapes in Python. NotGROUPING SETS: SQLite has none, and the grid is already bounded by cardinality the filter dropdowns have to be able to render anyway.A short TTL cache with write-driven invalidation.
AggregateCachememoises the result per app with a 30s TTL, dropped by any commit that wrote aStoredFile.Invalidation hangs off the DB write rather than off
FileUploaded/FileDeleted— the issue's suggestion — for two reasons, both improvements on it:CommitBeforeResponseMiddlewarecommits; invalidating there would have left that window open.The cache lives on
FileStorageServices(per app), not at module scope, so a process running two apps never serves one app's totals out of the other's database. AFileStorageServiceconstructed directly — a test, a script — gets no cache and reads through, which is what a caller checking "did my write land?" wants.Count before paging. The view now counts, clamps, then fetches one page, instead of fetching a page and re-fetching after the clamp.
Net effect
?page=reads.pyis a mechanical extraction: the read half ofFileStorageServicemoves to a mixin to stay under the 300-line cap. It is a real responsibility split — nothing in it touches a storage backend or mutates a row.queries.uploader_facets/queries.used_bytesare removed (subsumed byaggregates.compute);queries.content_type_facetsstays for thecreated_by-filtered case, which is not what the browse dropdown renders.Tests
Assertions are on the shape of the work — how many statements name the table, and that exactly one of them is grouped — not on wall-clock time, which would be flaky in CI and would still pass on the day someone adds a fourth scan. A
record_statementsfixture (tests/conftest.py) hooksbefore_cursor_execute.Verified failing on
mainbefore the change:Also covered: the folded totals match per-type / per-uploader / byte counts, soft-deleted rows stop counting, uploaderless rows count their bytes but are not offered as a filter option, facet ordering is preserved, and upload / delete / bulk-delete / out-of-band-commit are all reflected on the next render.
One bug found and fixed while writing these: the first version guarded re-registration with
event.contains, whose key isid(target). A torn-down app's session class can be collected and its address reused, so a later app read back as "already registered" and was left with a cache nothing invalidated — order-dependent, and it did fail in the full-suite run. The guard is now a flag on the cache._drop_on_commitalso reads rather than pops the session flag, since a session can commit more than once.Verification
uv run pytest modules/file_storage -quv run pytest -q(full suite)uv run ruff format --check modules/file_storage/uv run ruff check modules/file_storage/uv run ty check modules/file_storageuv run python scripts/check_file_size.pymake doctorNo
.tsxwas touched — the browse props keep their exact wire shape — sovitest/ci-check-untranslatedwere not applicable.