Unify app last-access on the DB column (fixes #133)#175
Open
ClaydeCode wants to merge 4 commits into
Open
Conversation
update_last_access previously debounced against the mutable app object and recorded access into an in-memory dict read only by idle-stop, so the installed_apps.last_access column was written but never consulted for lifecycle decisions (issue #133). Route the debounce through a cached get_last_access that reads the DB column, and add a short read cache (read_cache_ttl, 5s) so the forwardAuth hot path for loading app assets stays off the DB (issue #89). The write debounce is unchanged; cache entries are refreshed on every write, so the cached value always equals the last persisted last_access. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
control_apps already loads every app (with its last_access) from the DB each cycle, so idle and LRU decisions can read that column directly instead of the in-memory last_access_dict. Remove the dict and the per-request write in ensure_app_is_running; access is now recorded solely by update_last_access. A never-accessed app (last_access None) is treated as maximally idle / oldest, matching the old dict.get(name, 0.0) default. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
The unit tests drove idle via last_access_dict; set InstalledApp.last_access instead. Add three tests in test_app_lifecycle.py pinning that _control_app_time consults the DB column: a stale last_access stops the app, a recent one leaves it running, and a never-accessed running app is stopped. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
- Invalidate _last_access_cache on on_apps_update, mirroring the auth _app_cache pattern, so an uninstall/reinstall of the same app name never serves a stale time (and tests regain isolation the removed dict fixture provided). - Represent cache entries as a NamedTuple (_LastAccessCacheEntry) so the TTL logic reads by field, not tuple index. - Add test_app_last_access_cache.py pinning that get_last_access serves repeat reads from cache within the TTL and re-reads once it expires. - Add an LRU-demotion test pinning that a never-accessed app sorts as oldest. - Note in app_lifecycle that idle decisions read the column directly, not via the request-path cache; rename the overclaiming unit test. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
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 #133.
What
Make
installed_apps.last_access(the DB column) the single source of truth for app last-access, and delete the parallel in-memorylast_access_dictthat idle-stop used to read. Per the maintainer decision on the issue: keep the write debounce, and add a small time-bound cache in front of the DB read so the forwardAuth hot path (loading app assets) stays off the DB.How
update_last_access(app_meta.py) now debounces against a cached DB read (get_last_access) instead of the mutable request object, so the DB column is authoritative. The cache is TTL-bounded (apps.last_access.read_cache_ttl, default 5s) and refreshed on every write, so the cached value always equals the last persistedlast_access. It is cleared onon_apps_update(mirroring the existing auth_app_cache), so an uninstall/reinstall never serves a stale time.app_lifecycle.pyidle-stop and LRU demotion readInstalledApp.last_accessdirectly off the objectscontrol_appsalready loads from the DB each cycle — no hot-path and no cache in that path (its TTL must never delay a stop). Thelast_access_dictand the per-request write inensure_app_is_runningare gone. A never-accessed app (last_access = None) is treated as maximally idle / oldest, matching the olddict.get(name, 0.0)default.Behavior note (intentional tradeoff)
Because idle-detection now reads the debounced column, an app's observed idle can lag real access by up to
max_update_frequency. In the shipped config this is immaterial:pause_enabled = false, so the only active tier is stop atdefault_idle_for_stop = 10800s(3h) — a ≤60s lag is 0.5%. When the pause tier is rolled out, keepmax_update_frequencycomfortably below the smallest activeidle_for_pause(both currently default to 60), or an actively-used app could pause up tomax_update_frequencyseconds early (it unpauses on the next request, ms–2s). This lag is the direct consequence of the three constraints in the issue decision (column = source of truth, remove the dict, keep the debounce); the only way to remove it is to reintroduce a per-request in-memory map, which the issue explicitly asked to delete.Tests
tests/test_app_lifecycle.py: three unit tests pinning column-driven idle-stop (stale → stop, recent → keep running, never-accessed → stop).tests/test_app_last_access_cache.py: DB-mocked tests pinning thatget_last_accessserves repeat reads from cache within the TTL and re-reads after expiry.tests/test_app_lifecycle_pause.py: adapted to setInstalledApp.last_access; added an LRU test pinning never-accessed = oldest.tests/test_app_last_access.py(debounce end-to-end) still green.Review panel
Three adversarial reviewers ran (adversarial correctness, test adversary, DevEx/readability). Specialists not run: security (no auth-decision/token/secret logic changed), DB/migration (no schema/SQL/migration), API-contract (no route change), UX (not user-visible).
Blocking finding — idle-detection inherits the write-debounce lag (correctness reviewer). An actively-used app could be paused up to
max_update_frequencyearly once the pause tier is enabled. Resolution: dismissed as a merge blocker, with reasoning — it is the logically necessary consequence of the three constraints the issue decision fixed; the proposed fix reintroduces the per-request in-memory map #133 asked to remove; and the shipped default (pause_enabled = false, stop at 3h) makes it immaterial. Surfaced explicitly in the "Behavior note" above with rollout guidance rather than silently absorbed.Blocking-ish finding —
_last_access_cachehad no invalidation / no test-scoped reset (both correctness and test reviewers). Uninstall/reinstall within the TTL could serve a stale time; removing the oldclean_last_accessfixture dropped test isolation fortest_app_last_access.py. Resolution: fixed in41a5788— added anon_apps_updatereceiver that clears the cache (mirrors the auth_app_cachepattern), which also restores per-test isolation because app install fireson_apps_update.Advisory — the cache mechanism itself was untested (test reviewer). Fixed in
41a5788(test_app_last_access_cache.py; verified it fails if the TTL check is removed).Advisory — never-accessed app not pinned for LRU victim selection (test reviewer). Fixed in
41a5788(test_demote_lru_treats_never_accessed_app_as_oldest).Advisory — cache-entry tuple read positionally; misleading test name; cache-vs-column relationship unstated (DevEx reviewer). Fixed in
41a5788—NamedTuplecache entry, test renamed, clarifying comment added.Advisory — tz-normalize duplicated in a few spots. Noted, not changed: each copy is correct and dedup would span two modules; left surgical.
Recommended reading order
shard_core/settings.py— newread_cache_ttloptionshard_core/data_model/app_meta.py— cache + debounce (source of truth)shard_core/service/app_lifecycle.py— idle/LRU read the columntests/test_app_last_access_cache.py,tests/test_app_lifecycle.py,tests/test_app_lifecycle_pause.py🤖 Generated with Claude Code