Skip to content

perf(frontend): share one cached fetch for /api/auth/settings and /api/tools - #5997

Merged
alteixeira20 merged 3 commits into
odysseus-dev:devfrom
o3LL:perf/memoise-startup-config-fetches
Aug 16, 2026
Merged

perf(frontend): share one cached fetch for /api/auth/settings and /api/tools#5997
alteixeira20 merged 3 commits into
odysseus-dev:devfrom
o3LL:perf/memoise-startup-config-fetches

Conversation

@o3LL

@o3LL o3LL commented Aug 11, 2026

Copy link
Copy Markdown
Member

Summary

Eight modules fetched /api/auth/settings independently and chatRenderer.js fetched /api/tools once per module instance — 4 and 3 requests on a cold load — and nothing made those readers agree with each other or with the 18 code paths that write settings. This adds static/js/appConfig.js, which holds one in-flight-or-resolved promise per endpoint and hands it to every reader, and makes every writer invalidate it. Cold load goes to 1 and 1, and to 0 settings requests on the first load after a login because the cache now consumes the ody-prefetch-settings snapshot login.html already stashes in sessionStorage. The correctness half matters more than the milliseconds: one snapshot per load instead of eight.

Target branch

  • This PR targets dev, not main.

Linked Issue

Fixes #5996

Type of Change

  • Bug fix (non-breaking, fixes a confirmed issue)
  • New feature (non-breaking, adds new behaviour)
  • Breaking change (changes or removes existing behaviour)
  • Refactor / cleanup (behaviour unchanged)
  • Documentation only
  • CI / tooling / configuration

Filed as a refactor rather than a bug fix: no user-visible behaviour changes, the request count and the shared-snapshot guarantee do.

Checklist

How to Test

1. The request counts. Unregister the service worker first, or you are measuring a warm load:

for (const r of await navigator.serviceWorker.getRegistrations()) await r.unregister();
for (const k of await caches.keys()) await caches.delete(k);

Reload, let the app settle, then:

const n = (p) => performance.getEntriesByType('resource').filter(e => e.name.includes(p)).length;
console.log({ settings: n('/api/auth/settings'), tools: n('/api/tools') });

dev gives { settings: 4, tools: 3 }. This branch gives { settings: 1, tools: 1 }. Log out and back in and it gives { settings: 0, tools: 1 }, because the login prefetch is now shared instead of being consumed privately by app.js.

2. The regression this can most plausibly introduce: a stale settings object after a save. Every check below reads the cache through the same module instance the app is using, so it is not a proxy for the behaviour, it is the behaviour:

const cfg = await import('/static/js/appConfig.js');
(await cfg.getSettings()).reminder_channel;

Note settings writes are admin-only (routes/auth_routes.py:645 returns 403 otherwise), so run this as an admin.

Path What to do in the UI Then check
Settings panel (16 save paths) Settings → Reminders → change the channel (await cfg.getSettings()).reminder_channel is the new value
admin.js settings save Settings → Users → toggle "share defaults with users" .share_defaults_with_users flipped
admin.js tools save Settings → Tools → untick bash (await cfg.getTools()).tools.find(t => t.id === 'bash').enabled is false
tasks.js save Tasks → Add → Action on schedule → check_email_urgency → edit "Email triage rules" → Create .urgent_email_prompt is the new text

Each must match GET /api/auth/settings with no reload in between. I ran all four; the before/after/server values agreed every time.

Worth exercising the read side too, since these are the callers that changed: TTS buttons appear or disappear per tts_enabled (Settings → change it, the panel re-calls checkAvailability() on purpose and must see the new value), /shortcuts prints your saved keybinds, and the Email library's reminder bell tracks reminder_channel.

3. Automated:

python -m pytest tests/test_app_config_shared_fetch_js.py

8 tests: concurrent callers share one request; a later caller reuses the resolved snapshot; invalidateSettings() forces a refetch; a failed fetch does not poison the cache; settings and tools are independent slots; the login prefetch is used once and then consumed; every POST site in static/app.js + static/js/** has a matching invalidation; and appConfig.js is in the service worker's PRECACHE. The JS ones run the real module through node --input-type=module, same idiom as the other 54 tests/test_*_js.py files.

Two of them fail against deliberately broken versions, which is the point of having them: swap the cache for plain ??= memoisation and test_a_failed_fetch_does_not_poison_the_cache fails; delete the invalidateSettings() from tasks.js and the source scan fails with ['static/js/tasks.js:228 POST /api/auth/settings'].

Full suite on this branch: 4910 passed, 2 failed, 4 skipped in 142 s. The two failures are tests/test_workspace_confine.py::test_glob_confined_e2e (/tmp resolves to /private/tmp on macOS) and tests/test_integration_api_call_ssrf.py::test_real_socket_falls_back_from_dead_first_to_live_second (real sockets, connect-refused timing). Both reproduce identically on an unmodified checkout. node --check passes on all 12 touched JS files; python -m compileall passes on the new test.

Visual / UI changes

  • Screenshot or short clip of the change in the running app, attached below.
  • Style match: no styling was touched. No CSS, no markup, no colour, font or spacing value. This change moves where a fetch lives and nothing else.
  • No new component patterns. No component. One new module with four exported functions.
  • I am not an LLM agent submitting a bulk PR.

Screenshots / clips

This change is request plumbing — no CSS, no markup, no drawing code — so there is no visual delta to show. What these do show is the two panels the diff touches, rendering unchanged on the branch head: Settings → Reminders, whose save path now goes through the invalidating helper, and Settings → Agent Tools, the panel from the review finding, which refetches authoritative state on every open.

pr5997-settings-reminders pr5997-admin-agent-tools

No mobile shot: nothing in the diff affects layout at any width.

Design notes worth a reviewer's attention

A rejected fetch clears its slot instead of being memoised. ??= keeps the rejected promise, and every caller here has a .catch that quietly degrades: default keybinds, TTS off, search provider back to searxng. One transient failure at boot would leave the session in that state with no retry and no explanation. The clear is guarded so a late failure cannot wipe a newer promise, and the error is rethrown so every existing .catch behaves as before. Cost: a boot-time failure retries once per call site instead of once. There is a test for it.

API_BASE was dropped deliberately, not by accident. API_BASE is window.location.origin (static/app.js:55), so `${API_BASE}/api/auth/settings` and /api/auth/settings resolve to the same URL. appConfig.js uses bare paths. One visible consequence: search.js's copy of API_BASE had no other reader left, so init(apiBase) became init(). Happy to keep the parameter for symmetry with the other init(API_BASE) calls if you would rather.

All 16 settings-panel saves route through one _postSettings() helper. This is the part I would review first. Invalidating only the two obvious writers (tasks.js, admin.js) would have shipped the exact bug this is meant to prevent: turn TTS off in Settings, settings.js:955 deliberately re-calls aiTTSManager.checkAvailability() to pick the change up, and it reads a stale snapshot. One helper that invalidates in a finally means a 17th save path cannot forget, and it comes out 20 lines shorter than the 16 inline fetches it replaces.

The settings panel keeps reading directly. Its 17 GETs are untouched: that panel is the writer and edits what it reads, so it must see authoritative state rather than a cached snapshot.

Known limit, stated rather than hidden. The cache is session-long and only the browser invalidates it. If settings change server-side — manage_settings / ui_control, or a second tab — a reader can serve a stale value until something invalidates. Before this, emailLibrary, slashCommands and the admin toggle re-read on every panel open, so that is a small regression for those three; tasks.js already had exactly this bug locally (_urgentEmailSettings was cached forever and never invalidated), so it improves there. The proper fix is a settings-changed broadcast from the server, which is a separate change.

chatRenderer.js is still three module instances. It is imported under three different specifiers (?v=20260722emailfastindex1, ?v=20260722ctxheader1, bare), which is why /api/tools was 3 rather than 2. The shared cache collapses the requests because all three copies import appConfig.js by the same specifier, but there are still three EXEC_FENCE_RE variables and three of everything else in that module. Unifying the ?v= strings is a separate change with its own risk; I have not touched it here.

Overlap with an open PR

#5994 also edits static/sw.js both PRs bump CACHE_NAME on the same line (odysseus-v377-shared-config-cache here, odysseus-v378-lazy-katex-mermaid there) and add a PRECACHE entry. Whichever merges second will need a one-line rebase; if that is this one, say the word and I will push it.

Not verified

  • Chrome 151 only. No Firefox, no Safari.
  • Python 3.11.15, not the 3.14 in the Docker image. No Docker run, no Windows.
  • I did not test two tabs open simultaneously, or settings changed by the agent's own tools mid-session. That is the known limit above, reasoned rather than measured.
  • The other duplicated endpoints from the same measurement (/api/default-chat ×5, /api/models ×3, /api/email/unread-state ×3, and six more at ×2) are out of scope here. Listed in /api/auth/settings is fetched 4 times and /api/tools 3 times on every page load, by modules unaware of each other #5996 so they are written down.

@github-actions github-actions Bot added the ready for review Description complete — ready for maintainer review label Aug 11, 2026

@RaresKeY RaresKeY left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found one lost-update path in the Admin Tools editor.

Findings

P2 Badge issue (correctness): Refresh tool state before editing the authoritative list

  • Problem: getTools() retains one page-lifetime snapshot, and Admin > Tools now reuses it whenever the panel opens. The same tool state can change through manage_settings or another tab without invalidating this page's cache.
  • Impact: The panel can render stale checkboxes after an out-of-band change. Toggling any unrelated checkbox then posts the entire stale disabled-tool list, silently undoing the newer state and potentially re-enabling a globally disabled tool.
  • Ask: Keep the shared startup read, but make the repeat-open editor fetch authoritative current state before rendering/saving, or add equivalent conflict handling. Please cover an out-of-band tool change followed by an unrelated panel save.
  • Location: static/js/admin.js:1895-1990

Validation

  • The current substantive GitHub checks pass.
  • I traced the canonical promise cache, rejection and invalidation races, every changed reader and frontend writer, the backend agent-tool writer, the Admin full-state POST, and the service-worker wiring.
  • I did not complete an independent browser or two-tab reproduction, so live cross-context timing remains the residual validation gap.

@o3LL

o3LL commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

Good catch, and it is worse than stale rendering: I reproduced the lost update.

settings.js:58 sends every admin sidebar click to adminModule.open(tab), so refreshAll() and therefore loadBuiltinTools() run on every open, not just the first. On dev that meant a fresh /api/tools each time. My change turned it into a read of the boot snapshot, which is the regression.

Against the running app, with the out-of-band write coming from a separate HTTP client so the page could not know about it:

before the fix
1. panel open              api_call checked, app_api checked
2. curl disables api_call  server disabled = ['api_call']
3. panel reopened          api_call checked        <- stale
4. toggle app_api off      server disabled = ['app_api']   <- api_call re-enabled

after the fix
3. panel reopened          api_call UNCHECKED
4. toggle app_api off      server disabled = ['api_call', 'app_api']

f41c11e makes loadBuiltinTools() drop the shared /api/tools entry before reading it, so the editor renders authoritative state and refills the cache for everyone else. The startup read that chatRenderer.js shares is untouched: cold load is still 1 request each for /api/auth/settings and /api/tools, measured from the access log. Panel opens cost 2 requests each, which is what dev costs today. I checked that by swapping dev's admin.js into the running instance and counting both.

Two tests in tests/test_app_config_shared_fetch_js.py: the scenario you asked for, an out-of-band change followed by an unrelated save, asserting the posted list keeps the newer state and recording what the stale snapshot would have posted instead; and a source scan pinning the invalidate-before-read, since the failure mode is ordering in a call site rather than behaviour of the cache. Reverting admin.js makes the second one fail.

Full suite locally: 4912 passed, 2 failed, 4 skipped. The 2 are the known macOS-environmental pair, test_glob_confined_e2e and test_real_socket_falls_back_from_dead_first_to_live_second.

One thing I did not do. You offered "or add equivalent conflict handling", and I only fixed the render. The window between rendering the panel and toggling a checkbox minutes later is still there, but it is there on dev too, and closing it means choosing between merging the save, last-write-wins, and refusing on conflict. That is a design decision for the full-state POST rather than something to fold into this PR. Happy to open a separate issue for it if you want it tracked.

No two-tab browser reproduction, so live cross-context timing is still unverified on my side as well.

@o3LL
o3LL force-pushed the perf/memoise-startup-config-fetches branch from f41c11e to 28f3abd Compare August 14, 2026 01:41
@o3LL
o3LL requested a review from RaresKeY August 14, 2026 01:47
@RaresKeY

Copy link
Copy Markdown
Member

The last P2 looks addressed but this needs a conflict resolution and rebase before full re-review pass

@o3LL
o3LL force-pushed the perf/memoise-startup-config-fetches branch from 28f3abd to 6334cda Compare August 16, 2026 12:48
@github-actions github-actions Bot added ready for review Description complete — ready for maintainer review and removed ready for review Description complete — ready for maintainer review labels Aug 16, 2026
@github-actions github-actions Bot added merge conflict Conflicts with the base branch; needs a rebase before review. and removed ready for review Description complete — ready for maintainer review labels Aug 16, 2026
o3LL and others added 3 commits August 16, 2026 20:52
/api/auth/settings was fetched independently by eight modules and /api/tools by
three on a single load — 4 and 3 requests measured — and any two of those
callers could observe a different snapshot of the same object. chatRenderer.js
is imported under three different ?v= query strings, so it is three separate
module instances each issuing its own /api/tools request.

appConfig.js holds one promise per endpoint, so concurrent and later callers
share it. Every writer invalidates: the settings panel routes its 16 saves
through a single helper, and the admin tools save drops both snapshots because
that route persists disabled_tools into the same settings store. A rejected
fetch clears its slot rather than being memoised, so one blip at boot cannot
leave keybinds, TTS and the search provider on defaults for the session.

The settings panel keeps reading directly: it is the writer and edits what it
reads, so it must see authoritative state.

Cold load, Resource Timing: /api/auth/settings 4 -> 1, /api/tools 3 -> 1, and
0 settings requests on the first load after a login, because the cache now
consumes the sessionStorage prefetch that login.html writes.

Fixes odysseus-dev#5996
The shared cache made Admin > Tools render the boot snapshot on every
reopen. Its save posts the whole disabled list rebuilt from the checkboxes,
so a tool disabled out of band (the manage_settings tool, another tab) came
back enabled on the next unrelated toggle. Reproduced against the running
app: with api_call disabled by a separate client, toggling app_api off
posted ['app_api'] and silently re-enabled api_call.

The panel now drops the shared entry before reading it, which restores what
dev does today and keeps the startup read that chatRenderer.js shares. Cold
load is still 1 request each for /api/auth/settings and /api/tools, and the
panel costs the same 2 requests per open as dev.
@alteixeira20
alteixeira20 force-pushed the perf/memoise-startup-config-fetches branch from 47cc9a3 to 953081a Compare August 16, 2026 19:52
@github-actions github-actions Bot added ready for review Description complete — ready for maintainer review and removed merge conflict Conflicts with the base branch; needs a rebase before review. labels Aug 16, 2026
@alteixeira20
alteixeira20 dismissed RaresKeY’s stale review August 16, 2026 20:00

Fixed the lost-update issue Rares flagged. The Admin Tools editor now refreshes authoritative tool state before saving and merges only the user’s intended changes, so out-of-band updates from another tab or manage_settings are preserved. Added regression coverage, rebased onto current dev, and the focused suite passes.

@alteixeira20 alteixeira20 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! The stale Admin Tools write path is fixed, out-of-band tool changes are now preserved, regression coverage is in place, and the branch has been rebased onto current dev. Approved, thanks!

@alteixeira20
alteixeira20 merged commit 04b8829 into odysseus-dev:dev Aug 16, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready for review Description complete — ready for maintainer review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

/api/auth/settings is fetched 4 times and /api/tools 3 times on every page load, by modules unaware of each other

3 participants