Skip to content

perf(cache): flat version index, pruned per tenant - #621

Merged
EricAndrechek merged 11 commits into
feat/cache-snapshotfrom
feat/cache-flat-versions
Sep 26, 2026
Merged

EricAndrechek merged 11 commits into
feat/cache-snapshotfrom
feat/cache-flat-versions

Conversation

@EricAndrechek

@EricAndrechek EricAndrechek commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Part of #613. Stacked on #614 (feat/cache-snapshot).

Part of #262 (fixes the two unbounded-growth paths — growth across bumps, and a departed tenant's whole index — for the local backend). The issue stays open for a residual: per-table scope cardinality is not capped, since scope is empty until #235 populates it; see the comment on #262 for the trigger. The Redis-compatible backend (#626) bounds its versions its own way: random tokens with a TTL (version_ttl).

What changes

The local version index nested each table under its tenant's version and each scope under its table's version. Nothing was ever pruned, so every InvalidateTenant left the tenant's whole old index behind, and the index kept every tenant ever served.

  • Flat index (internal/cache/version_manager.go): one version per tenant, per (tenant, table) and per (tenant, table, scope). Each is keyed by name alone and bumped in place, so the index holds one entry per live tenant, table and scope however often each is bumped. The lattice is unchanged: an entry's key (QueryKey) folds the tenant's, table's and scope's versions of each dependency, and the cachetest conformance suite still passes. Key rendering goes through internal/keyenc.Join/Escape (from feat(cache): shared redis cache, snapshot keys, uncached write pipes #614) rather than fmt.Sprintf.
  • A tenant's version is a process-unique generation. The first QueryKey built for a tenant hands it out. BumpTenant (behind InvalidateTenant) drops the tenant's index, and the next key gets a fresh generation that no cached entry folds. Dropping is therefore a bump, and the tenant's memory is released. A per-tenant counter that restarted at 0 would revive entries; TestVersionManager_GenerationsNeverRepeat pins this.
  • A table bump drops the table's scope versions. This is safe because every key that folds a scope version also folds the table version the bump moved (TestVersionManager_BumpTableDropsScopes). So scopes are bounded by those written since the table's last whole-table write.
  • Any bump against a tenant with no index is a no-op that records nothing — table, scope or tenant alike. No key folds its next generation yet, so neither the ingest worker's sharedTables fan-out nor an insert still in flight for a just-pruned tenant recreates it (TestVersionManager_BumpWithoutIndex).
  • Pruning (LocalCache.Prune, wired in internal/app/wire.go:wireCache): after every settings reload, the index of each tenant no longer served (removed or rejected) is dropped. The hook sits beside Hub.Prune and the verifier pruning, and asserts as a run-time test that the wired cache actually satisfies the small pruner interface it type-asserts on a.cache — not just a hand-substituted test double — so a backend without an in-process index (Redis) simply doesn't implement it. When such a tenant comes back, its cached results are orphaned. That already happened before this PR, through the AfterAdopt InvalidateTenant of a tenant back on a pool.
  • Every version is now read under one lock, so the snapshot is fully consistent.

No change to what is cached or served.

Tests

  • TestVersionManager_SizeDoesNotGrowWithBumps: 10 000 rounds of table, scope and tenant bumps interleaved with key reads. The index size stays at its settled value (the perf(cache): cache version-manager map grows unbounded with tables × scopes #262 regression test).
  • TestVersionManager_{GenerationsNeverRepeat,BumpTableDropsScopes,BumpWithoutIndex,Prune}, and TestLocalCache_Prune (a pruned tenant misses and a kept tenant still hits).
  • TestReload_PrunesCacheIndexToServedTenants (app): a rejected tenant, then a removed one, is reported unserved to Prune, and now also asserts the wired cache is a pruner before the test substitutes its own recorder.
  • Mutation-checked: a fixed generation fails 5 tests (the conformance suite included), a BumpTable that doesn't advance the version fails 4, and wiring a cache that doesn't satisfy pruner fails the new assertion. TestVersionManager_BumpWithoutIndex was also mutation-checked against a tableLocked that wrongly creates a missing tenant's index instead of returning nil.
  • make ci passes locally at HEAD.

Deliberately left to later PRs

🤖 Generated with Claude Code

https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd

EricAndrechek and others added 2 commits September 24, 2026 23:56
The local version index nested each table under its tenant's version and
each scope under its table's, and was never pruned: every InvalidateTenant
left the tenant's whole index behind. It now holds one version per tenant,
(tenant, table) and (tenant, table, scope), bumped in place. A tenant bump
drops the tenant's index and its next key gets a process-unique
generation; a table bump drops the table's scopes. After each reload the
wiring prunes the index to the tenants served.

Fixes #262 for the local backend. Part of #613.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
@coderabbitai

coderabbitai Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 3123c0e3-05c0-4c40-bd87-972a9fa1f79b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added documentation Improvements or additions to documentation go Pull requests that update go code area/cache Local / shared / tiered caching area/docs Documentation, site/, README area/app Process wiring (internal/app): component build, run, release labels Sep 25, 2026
EricAndrechek and others added 3 commits September 25, 2026 17:45
Sync #621 with its parent, which now includes main's #612 squash and
subsequent main history. Resolved conflicts in AGENTS.md and
docs/architecture.md (app/wiring sections): kept cache-snapshot's
rewritten prose and combined it with cache-flat-versions' own
additions (the wireCache/LocalCache.Prune clauses naming cache in the
per-tenant prune set).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
TestReload_PrunesCacheIndexToServedTenants replaced a.cache with a
recorder by hand, so the run-time a.cache.(pruner) check against the
cache wireCache actually builds was never exercised: a change that
stopped wiring a pruning cache left the test green while pruning
silently went dead. Assert the wired cache satisfies pruner before the
swap.

Also reword a stale assertion message ("a rejection releases; it
orphans nothing yet") that no longer describes real wiring now that a
rejection drops the tenant's cache index via Prune, and correct the
CHANGELOG's claim that the Redis-compatible backend (#613) already
bounds its versions with a TTL — that backend does not exist yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd
…ache-flat-versions

# Conflicts:
#	AGENTS.md
#	CHANGELOG.md
#	docs/src/content/docs/architecture.md
#	internal/app/wire.go
#	internal/cache/version_manager.go
#	internal/cache/version_manager_test.go
EricAndrechek and others added 2 commits September 26, 2026 05:46
The version_manager.go bullet read "a bump of a tenant with no index
is a no-op" right after introducing BumpTenant, so it looked scoped to
that one call. It actually covers any bump — table, scope or tenant —
against a tenant with no index, which is what lets Prune and a departed
tenant's index stay gone (neither the sharedTables fan-out nor an
insert still in flight for a just-pruned tenant can revive it).

Found by pre-push review of #621 (origin/feat/cache-snapshot...HEAD).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd
The #262 entry said "fixes #262 for the in-process cache", which
would close the issue on merge (delete_branch_on_merge +
squash_merge_commit_message: PR_BODY carry the PR body's own "Fixes
#262" into main's history the same way). #262 has a residual left
open on purpose: per-table scope cardinality isn't capped, since scope
stays empty until #235 populates it. Reworded to "part of #262" and
named the residual, matching the PR body and the issue comment that
records the trigger.

Found by pre-push review of #621 (origin/feat/cache-snapshot...HEAD).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
EricAndrechek added a commit that referenced this pull request Sep 26, 2026
main now carries #612 and #618 as squashes, plus #632, #622, #627,
#615, #619, #647, #616, #655 and #623. The merge was resolved against the
pre-squash #618 head (f129d57) as its base, so main's version wins for
everything this stack does not own and only the cache stack's changes
(#614, #621, #626 as merged here, and this PR) are re-applied on top.

Warnings keeps main's api-role gate for the cache.redis warnings too: a
split's Deployments differ only in roles, so the API's cover the others'.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd
EricAndrechek added a commit that referenced this pull request Sep 26, 2026
Both parents of #630, at 95575f2 and 1f24da5; only AGENTS.md and
architecture.md conflicted, each keeping both sides.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
EricAndrechek added a commit that referenced this pull request Sep 26, 2026
feat/cache-redis at 1f24da5 and feat/cache-flat-versions at 95575f2,
merged together first (e0d7018); both already carry #614 and main at
5004cd2. Every conflict keeps the new parents' text and re-applies only
this PR's edits: the cache.backend: redis wording in AGENTS.md,
architecture.md and the CHANGELOG, and the redis case in wireCache.
configuration.mdx drops PING from the data commands, which the write
probe replaced.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
EricAndrechek and others added 4 commits September 26, 2026 08:30
TestVersionManager_BumpWithoutIndex asserted only on vm.size() after its
final BumpTenant call, which unconditionally deletes the tenant's index
regardless of what the earlier no-index bumps did — so it could not fail
against a tableLocked that wrongly creates an index instead of returning
nil. Assert after each bump instead, and add the pruned-tenant case a write
racing Prune must not revive.

Also reword two docs passages that overloaded or misstated a term:
architecture.md used "query key" for both the caller's input and the
rendered entry key in the same paragraph; AGENTS.md's cache bullet read as
if the index maps were keyed by escaped names; CHANGELOG.md's #382 bullet
said the version index builds its keys with internal/keyenc, contradicting
its own closing sentence that the index's entries are unaffected by the
escaping.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd
…-key mixup too

Same overload as architecture.md's version_manager.go bullet: the type
doc's "A query key folds all three versions of each dependency" means the
rendered entry key QueryKey returns, not the caller's input. Reword to
match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd
…ache-flat-versions

#614's docs-only rewording (query key vs entry key terminology, a new
cachetest bullet, a deployment.md tenant-move clarification, a CHANGELOG
"pool"->"cache holds" fix) conflicted with this branch's own flat-index
rewrite of the same passages. Kept this branch's flat-model content
throughout, folding in #614's terminology fixes and its purely additive
changes (the cachetest bullet, the deployment.md and development.md
wording).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd
…s missed

version_manager.go's tenants field comment still said "the first query key
built for it" — the same overload the type doc three lines above and the
other doc fixes in this round eliminated (query key = the caller's input,
entry key = what QueryKey renders). Both confirmation reviewers caught this
independently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd
EricAndrechek added a commit that referenced this pull request Sep 26, 2026
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd

# Conflicts:
#	AGENTS.md
#	docs/src/content/docs/architecture.md
EricAndrechek added a commit that referenced this pull request Sep 26, 2026
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd

# Conflicts:
#	AGENTS.md
#	CHANGELOG.md
#	docs/src/content/docs/architecture.md
@EricAndrechek
EricAndrechek merged commit 13418e9 into feat/cache-snapshot Sep 26, 2026
3 checks passed
@EricAndrechek
EricAndrechek deleted the feat/cache-flat-versions branch September 26, 2026 19:18
EricAndrechek added a commit that referenced this pull request Sep 26, 2026
…614)

## Summary

This PR makes the query cache correct under concurrent writes and
shareable across instances. It folds in #621, #634, #626 and #630, which
were reviewed separately against this branch.

- **Version snapshot at lookup (fixes #382).** The `cache.Cache`
interface is now `Lookup(ctx, tenant, sha, deps) (Entry, Snapshot,
error)` and `Set(ctx, Snapshot, value, ttl)`. A result's versions are
read once, before its query runs and before the handler takes the
tenant's ClickHouse pool, and the fill is filed under what was read.
Previously `POST /v1/query` and pipe execution rebuilt the
version-folded key after the query, so an insert that landed mid-query
filed pre-insert rows under post-insert versions and they were served as
fresh until their TTL. A reload that moves a tenant to another address
or database now orphans a fill taken from the old pool the same way. The
singleflight key and coalescing are unchanged.
- **A tenant token on every key.** Every entry key folds the tenant's
version, a pipe's dependency-free key included, so `InvalidateTenant`
now drops a returning or moved tenant's cached pipe results too. A
`Lookup` whose deps name another tenant is refused
(`ErrForeignDependency`). `cache.Namespace` carries raw table and scope
names and the cache escapes them itself with `internal/keyenc`, so
`query.SafeEncodeToken` is gone and names that would run together under
an unescaped join can no longer share a key.
- **Flat local version index (part of #262).** `VersionManager` holds
one version per tenant, per (tenant, table) and per (tenant, table,
scope), bumped in place, so the index no longer grows with every bump. A
tenant's version is a process-unique generation: `InvalidateTenant`
drops the tenant's index and the next key gets a fresh generation. After
every settings reload, `LocalCache.Prune` drops the index of each tenant
no longer served.
- **Write pipes run uncached (fixes #386).** A pipe whose bound SQL
`IsMutation` classifies as a write skips the cache lookup, the fill and
singleflight, and runs on every call. Before, a repeat within the TTL
answered `200` without writing, and N concurrent identical calls became
one write. The classifier now reads the leading keyword the way
ClickHouse's lexer does (comments, quoted text, heredocs, the whitespace
ClickHouse accepts), classifies a `WITH`-led statement by `INSERT INTO`
alone, and looks through `EXECUTE AS`. An integration test checks every
case, and every keyword in `system.keywords` in 12 `WITH` shapes,
against ClickHouse's own parser.
- **A Redis-compatible shared cache backend.** `cache.RedisCache` runs
against Redis, Valkey, Dragonfly, ElastiCache and MemoryDB, standalone
or cluster, using only `GET`, `SET` and `MGET`. Versions are random
8-byte tokens under the tenant's hash tag, and a lookup is one round
trip. A lost token can only cause a miss. Values of 1 KiB or more are
zstd-compressed, and stored values are capped at 1 MiB. Every operation
has a 100 ms timeout, and a failure is a miss, a skipped fill or a
deferred invalidation, never a failed query. A circuit breaker opens
after 5 consecutive failures, or at once on a reply that refuses writes
(`READONLY`, `OOM`, …) or the credentials, and only a successful probe
write closes it. Deferred invalidations are retried until they land, and
while a process owes one it bypasses the lookups that invalidation would
orphan. Eight `wavehouse_cache_*` metrics, all labeled
`backend="redis"`, report hits, round-trip time, breaker state, owed
invalidations, value size and failed fills.
- **`cache.backend: redis`.** A new `cache.redis` boot-config block
(`WH_CACHE_REDIS_*`): `addrs`, `mode` (`standalone` or `cluster`),
credentials, `db`, TLS files, `key_prefix`, `timeout` and `dial_timeout`
(each capped at `1s`), `max_value_bytes`, `compress_min_bytes` and
`version_ttl`. `wireCache` builds the backend from it. It is the shared
cache that splitting the `api` and `ingest` roles into separate
processes needs, and the boot error for such a split now names it; every
split is still refused while the queue is embedded. The e2e suite now
runs on Redis.

## Behaviour and compatibility notes

- **Write pipes answer `X-Cache: BYPASS` with `Cache-Control: no-store`
and are never coalesced.** Each call executes, so identical concurrent
calls are that many writes. A failed write keeps the read path's status
and `code` but always answers `retryable: false` with no `Retry-After`,
since the statement may have run. Read pipes are unchanged.
- **A write pipe does not invalidate cached reads of the table it
writes** (#394, and #343 for read pipes), and its rows do not reach
`/v1/stream` subscribers (#362).
- **`InvalidateTenant` drops more than before**: a tenant's cached pipe
results as well as its query results. Inserts still do not reach pipe
results (#343).
- **In-process cache keys changed** (the caller's query key is now
escaped inside the entry key). They are in-process only, so a restart is
the whole migration.
- **Shared-cache token keys are a protocol between builds.** Every
process sharing a server reads and bumps them for itself, so a later
change to that layout needs a rolling-upgrade plan. A change to value
keys only orphans entries and is safe to roll.
- **Boot with Redis down or refusing the password succeeds, degraded.**
The cache starts bypassed and keeps reconnecting. When a closed breaker
opens, it logs one `WARN`, or one `ERROR` for rejected credentials. A
failed probe reopening it logs at `DEBUG`, unless it failed for another
cause than the one last logged (rejected credentials after a restart,
say), which is logged at its own level. A long outage is one line.
- **`mode: sentinel` refuses boot** until #656. A URL-style address is
refused without echoing it, and a standalone server takes exactly one
address.
- **The ingest worker logs an invalidation that did not land at
`WARN`**, not `ERROR`: the shared backend defers and retries it.
`wavehouse_cache_invalidations_pending` is the signal to alert on.
- **Run the server with an evicting `maxmemory-policy` and without
persistence.** Under `noeviction` a full server refuses the token
writes. Restoring a snapshot, or a crash-restart that reloads the last
save, is a rollback that serves previously invalidated entries until
their TTL. The deployment guide covers both.
- **Known follow-ups:**
- #662: a quoted placeholder lets a bound value break out of its
literal.
- #663: a write pipe answers `GET`, which proxies and clients may
replay.
- #666: `BACKUP`, `RESTORE`, `UNDROP` and `MOVE` pipes are not
classified as writes.
- #671: `SET`, `USE` and `EXECUTE AS` in a pipe leak into the pooled
session.
- Also still open: per-table scope cardinality (#262, until #235
populates `scope`), the rest of #664 (the probe reconnects one
connection of several), Sentinel (#656), and the near-cache.

## Tests

- **Conformance suite** `internal/testutil/cachetest.Run`: miss, hit and
TTL, dependency order, tenant isolation, foreign deps, the scope
lattice, raw names that would run together,
`Invalidate`/`InvalidateTenant`, a bump during the query (#382),
oversize values, zero snapshots, and concurrent use under `-race`.
Shared backends also get two-instances-over-one-store cases.
`LocalCache` runs it, and so does `RedisCache` against pinned Redis,
Valkey, Dragonfly and a Redis Cluster node.
- **#382**: on both cached routes, a bump from inside the ClickHouse
call, and one from inside the pool lookup, each give MISS, MISS, HIT.
The read's namespace and the ingest worker's bump are pinned to meet for
raw table names.
- **Flat index**: 10,000 rounds of interleaved bumps and key reads leave
the index at its settled size. Generations never repeat, a table bump
drops its scopes, and a bump against a tenant with no index records
nothing. A reload prunes the index to the tenants still served.
- **Write pipes**: `INSERT`, `WITH … INSERT` and `ALTER … DELETE` pipes
each run on every call. Three identical concurrent calls are three
writes in flight. A read pipe over a table named like a write verb stays
cached. Every row of the ClickHouse error table on a write pipe answers
`retryable: false`. Over the Redis-backed e2e stack, a write pipe called
twice leaves both rows, and a failed one answers `400
clickhouse.rejected` with `retryable: false`. There are 152 table-driven
classifier cases, each also checked against `EXPLAIN AST` on the pinned
ClickHouse, and 17,928 keyword-named `WITH` statements where the
classifier must agree with the parser.
- **Redis backend (integration)**: lost tokens miss, and so does a
flushed server. On a paused server, lookups fail within the bound, the
breaker opens, invalidations are deferred, and all of it recovers. An
owed bump holds its lookups. Refused writes (`READONLY`, `OOM`) open the
breaker at once. A failover behind a stable address delivers the owed
bump. A cluster topology read is bounded. A slow reconnect still closes
the breaker, and a slow server stays bypassed. Rotated credentials open
the breaker. A restored snapshot behaves as a rollback. Unit tests cover
the key schema, the codec and its zip-bomb refusal, the breaker state
machine, pending coalescing and collapse, and the breaker logging each
opening once and a changed cause at its own level.
- **Config and wiring**: defaults, env, YAML, the validation table,
URL-style addresses (neither the address nor the secret is echoed), boot
against a closed port (bypassed, not failed), and an unreadable TLS file
refusing boot. Two `app.New` instances over one Redis: an ingest on one
invalidates the other, and with Redis paused, queries bypass and still
succeed.
- Most behavioural tests are mutation-checked: each fails with the fix
removed.
- `make ci` passes: static checks, unit, integration, e2e on Redis, and
coverage.

Fixes #382. Fixes #386. Part of #262. Part of #613.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq

---------

Co-authored-by: taitelee <taitelee@umich.edu>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/app Process wiring (internal/app): component build, run, release area/cache Local / shared / tiered caching area/docs Documentation, site/, README documentation Improvements or additions to documentation go Pull requests that update go code

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant