Skip to content

Cache the file share path list instead of re-reading it per request - #419

Open
krokicki wants to merge 1 commit into
mainfrom
cache-file-share-paths
Open

Cache the file share path list instead of re-reading it per request#419
krokicki wants to merge 1 commit into
mainfrom
cache-file-share-paths

Conversation

@krokicki

@krokicki krokicki commented Jul 31, 2026

Copy link
Copy Markdown
Member

Follow-up to #403, which noted that the file share path list is still fetched and serialized on every file action. It turns out the worker dispatch path isn't even the hottest caller.

Where the cost actually was

caller frequency
server.py:428 _resolve_proxy_info -> get_file_share_path every proxied data request — once per chunk a viewer pulls through a sharing link
worker_pool.py:81 _fetch_fsp_rows every file action dispatched to a worker
server.py:830 GET /api/file-share-paths frontend polling
apps/command.py:393, database.py:724 per app launch / per proxied-path create

Each of those was doing a full SELECT plus a rebuild of every FileSharePath model, and the worker path then dumped all of them to JSON. So the fix belongs in database.get_file_share_paths, not at the call site #403 happened to touch — caching there would have left the per-chunk path untouched.

Also worth knowing: get_fsp_names_to_mount_paths and find_fsp_by_path are both dead (zero callers), so they're not in the list above. The first is already flagged in notes/ponytail-audit.md.

Why a TTL and not invalidation

file_share_paths is populated out-of-band by a separate process, not by anything running in the server — the only reference to FileSharePathDB in this repo is the read query at database.py:415. So no request handler is ever in a position to know the list changed, and there is no in-process event to invalidate on.

That leaves polling something cheaper than the full list (the last_refresh row exists for roughly this purpose) versus plain bounded staleness. The arithmetic says don't bother: the cache converts a per-request cost into a per-TTL cost, so at 60s the rebuild is a 0.008% duty cycle — one 5ms rebuild per minute per process, against thousands of requests a second on the proxy path during a viewer session. Precise invalidation would add code to eliminate a query that no longer happens in any meaningful quantity.

The TTL is also not the binding constraint on how fast a new share appears: the out-of-band updater has its own schedule, and it is the larger term. Tightening the TTL is a one-constant change if that ever stops being true.

Two caches, both keyed by database URL:

  • database._fsp_cache — the FileSharePath model list, so every caller above benefits
  • worker_pool._fsp_row_cache — the JSON-serialized rows, because once the query is cached the model_dump per row is what dominates the worker path, and it lets _fetch_fsp_rows skip creating a session at all on a hit

The keying matters: a process can talk to more than one database (the test suite builds a fresh one per module), and an unkeyed cache serves one database's shares for another's. There's a regression test for exactly that.

Benchmark

Method: N shares in a sqlite DB, median per-call time over 5 batches of 500 calls, warmed first. db+1ms injects 1ms per query via a before_cursor_execute listener to model a database that isn't on localhost. Every scenario asserts the returned list is the right length and contents first, so a cache that returned the wrong thing couldn't look fast. Script is not committed; it's a throwaway.

100 shares:

before after
full list 1050us 2.6us 400x
by name (the proxy path) 184us 7.2us 26x
worker rows 1487us 1.7us 870x
by name, db+1ms 1253us 7.2us 174x

500 shares:

before after
full list 5291us 3.8us 1390x
by name 187us 26.1us 7x
worker rows 6783us 1.7us 4000x

Two honest caveats on those numbers. sqlite on the same filesystem is the best case for the old code, so the local figures understate what a non-local database sees — that's what the db+1ms row is for. And the by-name lookup now scans the cached list in Python where it used to push a WHERE into SQL, so its win shrinks as the share count grows: 26x at 100 shares, 7x at 500, and it would only lose to the query somewhere north of ~3500 shares. A name-keyed dict would fix that if we ever get there; not worth it now.

Behaviour change

An operator adding a share waits up to 60s for it to appear, and GET /api/file-share-paths can be up to 60s stale. tests/conftest.py clears both caches between tests so nothing races the TTL.

Validation

  • pixi run -e test test-backend — 770 passed (768 on main, plus the two new tests)
  • both new tests were mutation-checked: making the cache unkeyed fails only test_file_share_path_cache_is_keyed_by_database, and removing the cache fails only test_file_share_paths_are_served_from_cache
  • backend only, no frontend changes

@StephanPreibisch @JaneliaSciComp/fileglancer

Every proxied data request resolves its share through get_file_share_paths
(once per chunk a viewer pulls), and every file action dispatched to a worker
serializes the whole list into the request. Both were paying a full query plus
a rebuild of every FileSharePath model, on the hottest paths in the app.

Nothing in Fileglancer writes the file_share_paths table -- it is populated
externally, and last_refresh has no writer -- so there is no event to
invalidate on. A 60s TTL is the honest option: bounded staleness, no
invalidation protocol.

Two caches, both keyed by database URL because a process can talk to more than
one database and an unkeyed cache would serve one database's shares for
another's: the model list in database.py, and the JSON-serialized rows in
worker_pool, since dumping a few hundred models costs more than the query does
once the query is cached.

Measured per call (sqlite, median of 5 batches), 100 shares:
  full list        1050us -> 2.6us
  by name (proxy)   184us -> 7.2us
  worker rows      1487us -> 1.7us
At 500 shares the full list goes 5291us -> 3.8us. With 1ms of injected
per-query latency, modelling a non-local database, by-name goes 1253us -> 7.2us.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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