Repository navigation
perf(dash): serve a stale cached read for up to ten minutes and show its real age - #565
OsherElhadad wants to merge 3 commits into
Conversation
OsherElhadad
left a comment
There was a problem hiding this comment.
Review: REQUEST CHANGES at ade961c. (Posted as a comment: GitHub does not let the author account request changes.)
Verified:
- Single-flight still holds. Only the first stale reader calls
beginRefresh, andendRefreshis deferred. A failed refresh keeps the stale body inside its cap. Past the cap nothing stale is served (miss,Age: 0). - Cache keys are unchanged (tenant + manager + rounded query, so manager
?tenant=is in the key). No cross-tenant body, and background refreshes compute only the requesting key's scope. - Memory is unchanged. Entries were already kept until the 256-entry wipe; only the serve window grew.
- Tests catch regressions. With
Ageforced to 0 on stale answers, or the cap back at 30 s,TestServeJSONServesAStaleBodyWithItsAgefails. - Checks.
gofmtandgo vetare clean.go test ./dashpasses in full, and the cache subset passes under-race. CI is green, and the commit is signed off. - Adapt routes are untouched. They don't use
serveJSON, so their per-account report limits are unaffected.
Blocking:
- The 10-minute cap also applies to state-bearing routes, so a user's own change can be shown undone.
/api/toolfilter(toolFilterCache) caches a body that includes the account's current removal list (toolFilterStateForScopeis baked into the compute). The POST answer is fresh, but a reload or tab switch afterwards can serve the pre-change switches for up to 10.5 minutes, where before it was 60 s./api/tools(toolsCache) is the same. Neither route shows an age (only the Overview readsAge), so the page presents the old state as current. Fix one of these ways:- drop the account's
toolFilterCache/toolsCacheentries on every removal-list write (POST /api/toolfilter and the adapt apply/revert writers); - or keep these two caches on the old short cap;
- or compute the state outside the cached body and merge it per request.
- drop the account's
Non-blocking:
2. The age can understate. serveJSON reads the body with c.load(key) and then the age with a separate c.age(key). If a refresh's set lands between the two locks, a stale body is labelled with the new entry's age, close to 0, which contradicts "never shown newer than it is". Returning the entry's at from load/get under the same lock fixes it.
3. The benefit is mostly the all-time view. cacheKey rounds since/until to 30 s buckets, so a relative range ("last 24h") moves to a new key every 30 s and the longer stale window rarely applies there. That is fine; the PR text could say the gain is mainly the all-time view.
4. The retry can repeat for up to 10 minutes. loadOverview's stale retry re-arms every 10 s while the data stays over 45 s old. If refreshes keep failing, that is about 60 cheap cache reads over the cap window. Acceptable, but consider stopping after one or two retries.
OsherElhadad
left a comment
There was a problem hiding this comment.
Re-review: REQUEST CHANGES at b033366, for one small gap. (Posted as a comment: GitHub does not let the author account request changes.)
The hosted path is fixed:
Registry.Updatecallschanged(ChangeAccount)aftertx.Commit()and only on a real change. Strategy and campaign create, update, delete and archive callchanged(ChangeStrategy)after a successful commit.proxy.gowires the hook toInvalidateStateCaches.- With the wiring removed,
TestToolFilterWriteShowsOnTheNextReadfails. - The hook runs after the transaction, and
InvalidateStateCachesonly takes jsonCache locks, so there is no lock-order inversion with the registry. - Clearing all tenants' entries on each write is acceptable, since writes are rare.
Blocking:
- Single-tenant writes don't invalidate. On a local proxy there is no registry, so the hook is never set.
reloadLocal(proxy/adaptapply.go), whichadaptWriteLocalandadaptRevertLocaluse, swaps the pipeline without clearing anything. A/context-guru:remove-mcp-and-skills apply,save-more config applyor a revert on the plugin's own proxy can therefore show the old removal list in the local dashboard's Inventory and Tool filter tabs for up to the 10-minute stale window. That is the original bug, on the plugin's main path. One line fixes it: callh.api.InvalidateStateCaches(tenant.ChangeAccount)(nil-guarded) at the end of a successfulreloadLocal. Add a test like the hosted one.
Non-blocking:
2. setIfCurrent checks the generation under the lock, unlocks, and then set locks again. A clear() landing between the two lets a pre-write body be stored after the clear. Doing the compare and the store under one lock closes it.
3. Only the method is pinned, not the call sites. TestInvalidateStateCachesDropsReadsAndIgnoresALateOldCompute calls setIfCurrent directly. With both serveJSON call sites switched back to set, every test still passes.
4. The earlier age note still stands. The body and c.age(key) are read under separate locks. After a clear() between the two, age reads 0 for an old body.
Checks: gofmt and go vet are clean. ./tenant passes, the dash cache/stale/invalidate tests and the proxy toolfilter/adapt tests pass under -race, and CI is green. The commit is signed off with no AI attribution.
|
Pushed b699b9c: single-tenant apply/revert (reloadLocal) now invalidates (test fails without it); check+store and body+age each under one lock; a clear during a slow compute is not undone (foreground flight and background refresh, test added). The single-flight key fix moved to its own hotfix, #574. |
10a6b3e to
b699b9c
Compare
OsherElhadad
left a comment
There was a problem hiding this comment.
Re-review at b699b9c: the code is APPROVED. Please re-run CI before merging: build-test is red, on what looks like an unrelated timing flake (details at the end). (Posted as a comment: GitHub does not let the author account approve.)
Last blocker fixed:
reloadLocal(proxy/adaptapply.go) now callsh.api.InvalidateStateCaches(tenant.ChangeAccount), nil-guarded, after a successful pipeline swap. Without it,TestAdaptApplyLocalDropsTheDashboardsCachedReadsfails.
Earlier non-blocking notes, all fixed and pinned:
setIfCurrentcompares the generation and stores under one lock hold (setLocked). With the check turned intoif true,TestInvalidateStateCachesDropsReadsAndIgnoresALateOldComputefails.- The live routes use it. I switched each
serveJSONcall site back toset, the foreground flight (api.go:400) and the background refresh (api.go:506), one at a time. Each makesTestAClearDuringASlowComputeIsNotUndoneByItfail. - Body and age come from one lock hold (
loadAged). Writing a stale answer with age 0 makesTestServeJSONServesAStaleBodyWithItsAgefail.
Other checks:
- The single-flight key fix is not in this PR. It merges cleanly both with main and with #574's head, so there is no dependency or conflict.
gofmtandgo vetare clean on dash, proxy and tenant.go test -race ./dash -run 'Cache|Stale|Invalidate|Age'andgo test -race ./proxy -run 'ToolFilter|Adapt'pass, and./tenantpasses.- The commit is signed off with no AI attribution.
CI: build-test fails only TestDashboardAddsNoRequestLatencyWithContentCapture. The dashboard added 57.69 ms against a 57.10 ms budget, on a 571 ms median baseline. That latency-budget test is known to be timing-sensitive under load, and this PR touches nothing on the request goroutine (caches and write hooks only). Please re-run it.
This approval covers b699b9c, once CI is green.
…hes, and show its real age The cap on a stale answer was 30 s past a 30 s TTL, shorter than the all-time stats read it protected (about 6 s warm, 35 s on a cold process), so a reader who came back after a minute paid the whole query. The cap is now ten minutes. Every cached body carries an Age header and the Overview subtracts it from 'Updated N ago', so the page shows the age of the data, and looks once more shortly after a stale answer so it settles on current figures. Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
…y write to it The ten-minute stale window also covered /api/tools, /api/toolfilter and /api/prompt, whose bodies include the account's removal list, and the keep-alive routes that include strategies. A toggle then showed the old switch on reload for up to ten minutes. The tenant registry now calls a listener after every committed account write (configuration, removal list, upstreams, label) and every strategy or campaign write; the dashboard clears the affected caches. A compute that began before the write cannot put its old body back. Read-only aggregates (stats, series, sessions, facets, components) keep the stale window. Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
…re guard checks and stores under one lock - reloadLocal (the single-tenant apply/revert path) now calls the same invalidation the registry hook does, so a local remove-mcp-and-skills or save-more apply shows on the next load. - setIfCurrent compares the generation and stores under one lock hold, and the body and its age are read under one hold, so a clear can no longer slip between them. - The single-flight key fix is split out into its own hotfix PR. Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
b699b9c to
ca1477d
Compare
|
Rebased onto #564's branch (875d960) and the PR base set to feat/dashboard-ux-overhaul so the two merge in either order: the only change is the Age handling in dash/ui/app.js, now on #564's lean-then-full Overview read (the lean read sets 'Updated N ago' from the Age header; the full read sets it again and schedules the one catch-up refresh when the answer was stale by more than 45 s). Tests on this head: dash UI, savings, nav, freshness and cache tests, the browser smoke test, proxy tool-filter and local-apply tests, tenant on-change test: ok. When #564 merges I retarget this to main and rebase; the diff should be identical (range-diff). |
OsherElhadad
left a comment
There was a problem hiding this comment.
Re-review: APPROVE at ca1477d (restacked on #564's 875d960). git range-diff against the approved b699b9c: 21 patches are identical, and the two marked "!" differ only in context. One is the #574 single-flight key line already on the base. The other is the dash/ui/app.js Age hunk moved into #564's lean-then-full loadOverview. In that hunk, state.loadedAt is set from Date.now() - ageMs.stats after both the lean and the full read. Both calls use the path stats, so each takes its own response's Age. The single 10 s catch-up refresh is scheduled only after the full read and only when the data is more than 45 s old. Invalidation, reloadLocal, the one-lock guard and the server Age header are unchanged. CI is green. This approval covers ca1477d.
The dashboard's stale-while-revalidate cache could serve an old body for only 30 s past a 30 s TTL. The all-time stats read it protects takes about 6 s warm and 35 s on a cold process on a 3.6 GB database, so anyone who came back after a minute found nothing to serve and paid the whole query again: the "TTL shorter than the work" pattern.
Change
dashCacheStale30 s to 10 minutes. The single-flight refresh behind a stale answer is unchanged (one compute per key, however many readers).Ageheader (seconds since it was computed;0for a body computed for this request).Updated N agois now the age of the data, not of the request, so the page never claims fresher figures than it shows. After a stale answer older than 45 s it looks once more after 10 s, so it settles on current figures without a click.Measured (3.6 GB snapshot copy,
stats?lean=1, all time; both servers on their own copy, same moment)X-Cache: missX-Cache: stale,Age: 99Age: 7The numbers are the same numbers; they can now be up to 10 minutes old, and the response says how old.
Tests
Stale body served at once with its age and exactly one refresh behind it; past the cap nothing stale is served (
Age: 0,miss); the Overview's freshness uses the Age header. The existing single-flight and cap tests pass unchanged.Cached reads that include user-editable state (review follow-up)
Audit of every cached route (
statsCache,facetsCache,componentsCache,toolsCache,toolFilterCache,promptCache,spendCache,routeCache): only the tool, tool-filter and prompt reads include state a user edits (the account's removal list lives in its stored configuration), and the keep-alive routes inrouteCachecan include strategy and campaign state. The stats, series, sessions, facets, components, spend and kvcache reads are aggregates of traffic.SetOnChange); it fires after every committed account write (anyUpdatethat changes a field, including the toolfilter switch,PUT /api/me, adapt apply, keep-alive control) and every strategy or campaign write, never for a refused or no-op write.API.InvalidateStateCachesclearstoolsCache,toolFilterCacheandpromptCacheon an account write, and alsorouteCacheon a strategy or campaign write. A compute that began before the write cannot store its (old) body afterwards (generation check).TestToolFilterWriteShowsOnTheNextRead, fails without the wiring), the registry listener fires only for committed writes, and a late old compute does not repopulate the cache.