feat(dash): the KV-cache TTL analysis page and its strategy simulator - #105
Merged
Conversation
The dashboard half of the KV-cache work: a tab that answers how long a
conversation actually stays idle, what the prompt cache costs at that idle
profile, and what a different TTL policy would have cost on the same history.
The policies, the cost model and the replay are package kvcache; nothing here
decides or prices anything.
Four GET routes, all scopeTenant, all returning numbers, enum labels and ids
only. GET rather than POST for two reasons worth more than tidiness: both
scoping tests probe the mounted table with a GET, so a POST route would be one
neither could check, and a simulation is a view, so its whole input belongs in a
URL that can be bookmarked and pasted into an issue.
GET /api/kvcache the analysis: cards, idle histogram, survival
curve, four grouped views, price list, coverage
GET /api/kvcache/rows the derived dataset, sortable on 13 columns and
paged on the server
GET /api/kvcache/simulate every requested arm replayed and scored against
one baseline
GET /api/kvcache/pricing the editable rate table and what each rate comes
to on the window's own median prefix
The derivation applies a filter in TWO places, deliberately. The predicates
that select which conversations are in scope — tenant, time window, session,
the exclusion of ping rows — run inside the window function. Everything that
selects which requests to SHOW runs outside it. Running `model = X` inside the
partition would make a request's successor the next request on that model,
which is not the next request in the conversation, and on this corpus that is
not a corner case: it would distort the 12,035 requests sitting in a session
that uses more than one model.
The window partitions by (tenant_id, session_id, model), which is exactly
kvcache.Conversation. The model is in the key because a cache entry does not
transfer between models, and a guard now asserts the two groupings agree in
both directions so the SQL cannot drift from the Go type again.
Three things the page refuses to do, each enforced by a test rather than by
review:
- It never renders an absence as a zero. A request with no successor has
idle_ms null, not 0; a tier that was never recorded reads "not recorded" in
both the grouped table and the row pill, never the tier it is replayed as;
a row with incomplete accounting has an unknown cost, never $0.
- It does not present the hit rate as the objective, with a banner saying so:
holding every prefix for an hour raises the hit rate and costs more, so a
reader scanning for the best-looking column would pick the worst arm.
- It never clamps a comparison and says which way it points in words. The
column reads "$X cheaper" or "$X MORE", because a column of signed dollars
headed "saving" was read as a saving when it was the opposite.
Evidence. Rendered in Chromium against a seeded 3,391-request window shaped
like the live corpus (94.9% of gaps inside five minutes against the measured
95.3%, median cached prompt 133k tokens against 124.8k) and inspected panel by
panel. That found four defects no substring test could see: the exact ceiling
reporting the same total as the no-cache arm because the price list was dropped
when threading the config; the by-TTL table folding 295 not-recorded rows into
the five-minute group while the coverage banner directly above reported them as
not recorded; the row pill printing "5m" for those same rows; and the formulas
panel rendering eleven headings above eleven empty boxes because the payload
emitted {name, expression, prose} while the page read {name, formula, note}.
Each is now guarded.
The wire contract between the payloads and the page is checked in both
directions from the struct tags, and the comment states what that check does
NOT catch — a field moving between shapes — with the reproduction, rather than
letting a green line imply coverage it does not have. Cost identities and the
percentage-versus-fraction case are asserted as invariants, because every
nominal check is blind to a key that keeps its name and changes its meaning.
Filter gains a TTL dimension with "none" as a sentinel, for the same reason
Reason has "compacted": an uncached request's stored value is the empty string,
which is also what "no filter" looks like, so an empty field cannot mean it.
gofmt, go vet and go test ./... clean; production binary builds. 51 KV-cache
tests in package dash.
Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
A review of #105 found three that are not cosmetic. All are in code this PR added; each is fixed with the guard that would have caught it. 1. A DOCUMENTED FILTER RETURNED NOTHING. `ttl` was read into two filters with incompatible vocabularies — Filter.TTL matched the raw cache_ttl column, KVCacheOptions.TTL matched the reconstructed tier — and kvCacheQuery ANDed them. They disagree on exactly the rows observedTTL exists to rescue: ttl=unrecorded became `cache_ttl = 'unrecorded'`, a value the column can never hold, so the by-TTL table's own drill-down returned an empty table for a group showing 295 requests. ttl=1h lost every row whose tier was deduced from the provider's 1h write counter rather than recorded — 37% of the group on a corpus that has them. On a deployment upgraded from a pre-cache_ttl schema every row is blank, so both groups were entirely unreachable. Filter.TTL was consumed by nothing but the predicate it broke and the shared filter bar never offered it, so it is gone: one reader of `ttl`, one vocabulary, the reconstructed tier. The existing test asserted the tier predicate at the DB layer with Filter.TTL empty — the one place the defect could not appear. The new one drives the handler and asserts every by-TTL group's key round-trips to its own row count. 2. AN ABANDONED REQUEST KEPT COSTING THE PROCESS, AND NOTHING BOUNDED HOW MANY RAN AT ONCE. kvCacheMaxRows bounds ONE analysis: ~135 MB and several seconds at the ceiling. It never bounded the number of them, the reads took no context, and the store's pool has no SetMaxOpenConns, so under WAL they all ran in parallel. Eight concurrent analyses measured 1.65 GB resident and 24 s each, which OOMs a 2 GB container; a request killed 1.5 s in still burned 7.1 s of CPU and allocated to completion. On a single-tenant deployment no credential is needed to hold the refresh key. Reads are now cancellable — DB.WithContext carries the request's context on a shallow copy rather than adding a parameter to fifty call sites, most of them tests — and the two routes that read the whole dataset take one of kvCacheMaxConcurrent slots. A caller who has already gone leaves the queue instead of entering it, so capacity goes to somebody still listening. The redundant COUNT pass is gone with it. Reading one row past the cap tells the read whether it truncated, so the second complete window-function pass now runs only when it did — it was costing 0.76 s of a 7 s request to learn something the read already knew. kvCacheMaxRows is a var so the truncation branch, which decides what `total` means, is reachable in a test. 3. THE NUMBER EVERY COST IS MULTIPLIED BY WAS COMPUTED OVER THE WRONG ROWS, TWICE. It medianed over every request including the ones that cached nothing. That is not noise but bias: 2,120 of the production corpus's 14,407 rows have a zero prefix, pulling the median from 147,550 to 124,845, so every derived cost ran 18% low. A tenant whose traffic is mostly uncached — the tenant most likely to be reading this page — got a median of zero and then a whole table of $0.00 rates rendered as though they were known. And the pricing route computed it from Filter alone, so the page's own narrowings never reached it: under has_next=no the panel printed 133,245 where the card above it printed 53,457, both captioned "this window's own median". It also had no LIMIT, making it the one read the stated 200,000-row ceiling did not apply to. So it now medians over rows that cached something, is built through kvCacheQuery like every other read on this page, and is bounded by the same ceiling. prefix_known says when there is no prefix to price at all, and the panel omits the cost columns rather than showing $0.00 — the same rule kvcache.Result.Valued keeps one layer over. ALSO: /api/kvcache/simulate returned 400 for store failures, so no 5xx alert could fire for one; only an unknown arm or baseline is the caller's mistake now. The tier select gained its fourth option, without which the only route to that filter was a click that left the control reading "All". Two guards land with these, both mutation-verified. The 5m/1h boundary is computed twice and only the copy that never runs was tested: kvcache.Derive has no production caller, and the assertion lives on it — changing dash's live `<=` to `<` passed both packages, while the same change to Derive fails instantly. And no arm may mutate the dataset the other arms replay, asserted by running every selectable arm in both directions over one shared slice. Verified: gofmt, go vet, go test ./... clean; production binary builds. End to end on a 3,391-request database, every tier group round-trips to its own count (2811/285/295), the card and the pricing panel agree under every page-local filter, and four concurrent analyses serialise in pairs. Signed-off-by: Osher-Elhadad <Osher.Elhadad@ibm.com>
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.
Re-targets #104 at
main. #104 was merged into the wrong place and its content never reachedmain— this PR is the same commit, rebased ontomain, and it is the one to merge.What went wrong with #104
#99 (the calculation half) was squash-merged into
mainat 19:21. #104 was based onfeat/kv-cache-ttl-cost-modeland merged into that branch at 19:34 — thirteen minutes after the branch had stopped being on any path tomain. So the dashboard commit sits on the feature branch andmainnever got it, which is why the tab does not appear.I based #104 on the feature branch because
kvcache/did not exist onmainat the time, so a PR againstmainwould not have compiled. That reasoning was right when I opened it and stale by the time it merged. The correct move would have been to wait for #99 and targetmain, or to retarget #104 the moment #99 landed.Now that #99's content is on
main,kvcache/is there and this compiles against it. The cherry-pick applied with no conflicts, and everything below was verified on top ofmain(7b20027), not on the old base.What it is
The dashboard half of the KV-cache work: a tab answering how long a conversation actually stays idle, what the prompt cache costs at that idle profile, and what a different TTL policy would have cost on the same requests. The policies, the cost model and the replay are package
kvcache— nothing here decides or prices anything.Four
GETroutes/api/kvcache,/api/kvcache/rows,/api/kvcache/simulate,/api/kvcache/pricing— allscopeTenant, returning numbers, enum labels and ids only. No prompt text, no transcript.GETrather thanPOSTfor two reasons worth more than tidiness: both scoping tests probe the mounted route table with aGET, so aPOSTroute would be one neither could check; and a simulation is a view, so its whole input belongs in a URL that can be bookmarked and pasted into an issue.Not manager-only. The tab carries no
data-manager, so every signed-in account sees it scoped to its own traffic. A manager additionally gets the service-wide view — the User filter, the drill-down on By user, and the User column.TestAPIScopesEveryRouteToTheCaller,TestAPIIgnoresCraftedTenantParamandTestAPIFailsClosedWithoutAPrincipalall walk these four routes.The derivation, and the thing it would be easy to get wrong
A filter is applied in two places, deliberately. The predicates selecting which conversations are in scope — tenant, time window, session, the exclusion of ping rows — run inside the window function. Everything selecting which requests to show runs outside it. Running
model = Xinside the partition would make a request's successor the next request on that model, which is not the next request in the conversation, and that would distort the 12,035 requests sitting in a session that uses more than one model.The window partitions by
(tenant_id, session_id, model), which is exactlykvcache.Conversation. The model is in the key because a cache entry does not transfer between models — an opus request cannot read a sonnet request's entry, and linking them grants the second a hit at 0.1× on an entry it could never have matched, one-directionally making every arm look cheaper and hit more often than it can.TestTheSQLPartitionIsExactlyTheConversationKeyasserts the SQL grouping and the Go type agree structurally and behaviourally, so they cannot drift again — this bug was live until the pre-commit test run caught it.Three things the page refuses to do
Each enforced by a test rather than by review, because all three are invisible on screen when wrong.
idle_ms: null, not0, and is excluded from every average. A tier never recorded reads not recorded in both the grouped table and the row pill, never the tier it is replayed as. A row with incomplete accounting has an unknown cost.fixed-1hhas a better hit rate than the baseline and costs more, so a reader scanning for the best-looking column would pick the worst arm. Nothing sorts or colours by it.$X cheaper/$X MORE.Evidence
Rendered in Chromium against a seeded 3,391-request window shaped like the live corpus (94.9% of gaps inside five minutes against the measured 95.3%; median cached prompt 133k tokens against 124.8k) and inspected panel by panel. That found four defects no substring test could see: the exact ceiling reporting the same total as the no-cache arm because the price list was dropped when threading the config; the by-TTL table folding 295 not-recorded rows into the five-minute group while the coverage banner directly above reported them as not recorded; the row pill printing
5mfor those same rows; and the formulas panel rendering eleven headings above eleven empty boxes because the payload emitted{name, expression, prose}while the page read{name, formula, note}. Each is now guarded.The wire contract is checked in both directions from the struct tags, and its comment states what that check does not catch — a field moving between shapes — with the reproduction, rather than letting a green line imply coverage it lacks. Cost identities and the percentage-versus-fraction case are asserted as invariants, because every nominal check is blind to a key that keeps its name and changes its meaning.
Also
Filtergains aTTLdimension with"none"as a sentinel, for the same reasonReasonhas"compacted": an uncached request's stored value is the empty string, which is also what "no filter" looks like.Docs:
docs/dashboard-kvcache-page.md, in the mkdocs nav, with the four routes and thettlfilter added todocs/reference/routes.md.Verification, on top of
maingofmt -l,go vet ./...andgo test ./...clean across every package;CGO_ENABLED=1production binary builds. 51 KV-cache tests in packagedash.After merging
The UI is
go:embed-ed into the binary, so a merge alone changes nothing on a running service: it needs a rebuild and a restart, and the dashboard needs-dashboard/DASHBOARD=1. Open at/dashboard/#kvcache.