Skip to content

feat(dash): the KV-cache TTL analysis page and its strategy simulator - #105

Merged
OsherElhadad merged 2 commits into
mainfrom
feat/kv-cache-dashboard-on-main
Aug 24, 2026
Merged

feat(dash): the KV-cache TTL analysis page and its strategy simulator#105
OsherElhadad merged 2 commits into
mainfrom
feat/kv-cache-dashboard-on-main

Conversation

@OsherElhadad

Copy link
Copy Markdown
Collaborator

Re-targets #104 at main. #104 was merged into the wrong place and its content never reached main — this PR is the same commit, rebased onto main, and it is the one to merge.

What went wrong with #104

#99 (the calculation half) was squash-merged into main at 19:21. #104 was based on feat/kv-cache-ttl-cost-model and merged into that branch at 19:34 — thirteen minutes after the branch had stopped being on any path to main. So the dashboard commit sits on the feature branch and main never got it, which is why the tab does not appear.

I based #104 on the feature branch because kvcache/ did not exist on main at the time, so a PR against main would 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 target main, 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 of main (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.

Section Answers
Coverage statement what the analysis could not answer — no recorded TTL, no cost, single-request conversations, whether the read was capped
Summary tiles requests, conversations, median/mean idle, 5-minute and 1-hour reuse probability, cache-hit rate as the provider reported it, cost as billed, requests with no successor, median cached prompt
Distribution the idle histogram (past five minutes in the de-emphasis gray) and the survival curve
Grouped views the same measurement by observed TTL, user, model, time-of-day band (UTC)
Prices every rate the simulation uses, editable, with what each comes to on the window's own median prefix
Strategies the arm picker built from the server's registry, and the comparison table
Detail table the derived dataset, sortable on 13 columns, paged on the server, each row linking back to the request drawer and the session diff
Formulas printed from the payload, not retyped in the page

Four GET routes

/api/kvcache, /api/kvcache/rows, /api/kvcache/simulate, /api/kvcache/pricing — all scopeTenant, returning numbers, enum labels and ids only. No prompt text, no transcript.

GET rather than POST for two reasons worth more than tidiness: both scoping tests probe the mounted route 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.

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, TestAPIIgnoresCraftedTenantParam and TestAPIFailsClosedWithoutAPrincipal all 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 = 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 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 exactly kvcache.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. TestTheSQLPartitionIsExactlyTheConversationKey asserts 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.

  1. An absence is never rendered as a zero. A request with no successor has idle_ms: null, not 0, 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.
  2. Hit rate is not presented as the objective, with a banner saying so: fixed-1h has 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.
  3. A comparison is never clamped and says which way it points in words$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 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 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

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.

Docs: docs/dashboard-kvcache-page.md, in the mkdocs nav, with the four routes and the ttl filter added to docs/reference/routes.md.

Verification, on top of main

gofmt -l, go vet ./... and go test ./... clean across every package; CGO_ENABLED=1 production binary builds. 51 KV-cache tests in package dash.

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.

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>
@OsherElhadad
OsherElhadad merged commit b1982e7 into main Aug 24, 2026
5 checks passed
@github-project-automation github-project-automation Bot moved this from New/ToDo to Done in Rossoctl Issue Prioritization Aug 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants