Skip to content

feat(cache): shared cache backend on Redis, Valkey and Dragonfly - #626

Merged
EricAndrechek merged 63 commits into
feat/cache-snapshotfrom
feat/cache-redis
Sep 26, 2026
Merged

EricAndrechek merged 63 commits into
feat/cache-snapshotfrom
feat/cache-redis

Conversation

@EricAndrechek

@EricAndrechek EricAndrechek commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Part of #613. Stacked on #614 (feat/cache-snapshot). #614 no longer stacks on #655: refactor/keyenc is merged into main, which #614 includes. The key schema escapes names with internal/keyenc.

Addresses #664 in part: the breaker's probe now fits a reconnect slower than the per-operation timeout, but the client's other connections must still reconnect within it (see "Failure means bypass" and "Deliberately left").

What this adds

cache.RedisCache is a cache.Cache shared by every process pointed at one Redis-compatible server: Redis, Valkey, Dragonfly, ElastiCache or MemoryDB. It sends only GET, SET and MGET. There are no scripts and no client tracking, so ElastiCache Serverless and cluster mode work unchanged.

It is built and tested, but nothing selects it. cache.backend accepts only local until #630 adds redis, the cache.redis.* boot config and the wiring. Every deployment still runs LocalCache.

  • Version tokens (redis_codec.go): each tenant, each table and each scope has a random 8-byte token under the tenant's hash tag (<prefix>:{t}:T, :B:<table>, :S:<table>:<scope>). A bump sets a fresh token. A Namespace carries raw names (feat(cache): shared redis cache, snapshot keys, uncached write pipes #614), and the codec escapes the table and scope after the fixed <prefix>:{t}:B: / :S: pieces with keyenc.AppendJoin, so a : in a name never reads as the separator. The {t} hash tag goes in verbatim, as a tenant id is its own escaped form. The subsumption lattice is the same as VersionManager's:
    • a dependency folds its tenant, table and scope tokens;
    • a scopeless write bumps B:;
    • a scoped write bumps S:<scope> and S: (the whole-table view).
  • One round trip per lookup: an MGET of the tokens is pipelined with a GET of the value. A value carries the tokens it was filed under, and it is a hit only while all of them are still current. Value keys (<prefix>:q:<t>:<hash of sha and deps>) have no hash tag, so a tenant's values spread across shards. The hash is over the escaped sha and each dependency's escaped, joined table and scope, each ended by a NUL, which escaping never writes. Hashing the raw names let a name holding a NUL share a value key with another set of names (table a\x00b against table a with scope b\x00).
  • Lost tokens can only cause misses. A missing token is created with SET NX and read back; it is never read as a value. Eviction, expiry, FLUSHALL or a restart without persistence therefore orphan entries and never revive them, so allkeys-lru is safe. A token or value key that holds something else (a string of another length, a list, a hash) is replaced.
    • Restoring an RDB or AOF snapshot, or a backup, is not a loss but a rollback, and so is a restart after a crash that reloads the server's last save, which stock Redis and Valkey make by default: the old tokens come back with the values filed under them, so what was invalidated since is served again until its TTL, as after a failover to a replica that missed the bumps.
  • Values: payloads of 1 KiB or more are zstd-compressed when that makes them smaller, with a 1 MiB window. The stored-size cap is 1 MiB, and decoding is capped at 8× that to refuse a zip bomb. An unknown format is a miss, so a rolling upgrade is safe.
  • Failure means bypass. Each operation has a 100 ms timeout. A failure or timeout makes the lookup a miss, the fill a no-op and the invalidation deferred. Queries never fail on the cache.
    • The breaker (breaker.go) opens after 5 consecutive transport failures or timeouts. It opens at once on an error reply saying the server takes no writes: READONLY (a demoted primary), MASTERDOWN, OOM (full under noeviction), NOREPLICAS, MISCONF, LOADING, BUSY or CLUSTERDOWN. It also opens at once on a reply refusing the credentials, WRONGPASS or NOAUTH, which a new connection's handshake meets after a password rotation; that is logged at ERROR. Any other reply counts as up, including one about a single key or command (WRONGTYPE, NOPERM, TRYAGAIN) and a malformed one.
    • After 5 s, one background write (SET <prefix>:probe) decides whether the breaker closes, and only that probe closes it, when a write is answered within Timeout. A server that answers but refuses writes therefore stays bypassed, and so does one that answers slower than Timeout.
    • Every connection is replaced after a minute; rueidis retries what was in flight. After a failover behind a stable address (a DNS name, a proxy), the connections a process holds stay on the demoted node, which answers but refuses writes, and rueidis does not redial on READONLY. The replacement re-resolves the address, so a bypassed process reaches the new primary, and delivers the bumps it owes, within about a minute.
    • rueidis dials a replacement lazily, under the context of the operation that lands on it, bounding the dial (TLS included) by DialTimeout and then the handshake by DialTimeout again, so a reconnect slower than Timeout fails that operation. The probe's first write gets twice DialTimeout for a reconnect on top of Timeout, so such a reconnect still closes an open breaker. The allowance is for a reconnect only: a first write slower than Timeout is repeated under Timeout, and the repeat decides. But rueidis spreads commands over several connections (up to four to one server, by GOMAXPROCS, and one per cluster node), and the probe reconnects only the one it lands on. Timeout should therefore still exceed a reconnect, or operations that land on the other connections keep failing.
    • NewRedis never fails on an unreachable server. The cache starts bypassed and keeps dialing, with backoff up to 30 s. Each dial is bounded by DialTimeout, and a cluster client's topology read by the larger of that and Timeout (rueidis leaves it at 10 s otherwise).
  • Deferred invalidations (pending.go): repeats coalesce per key.
    • A background drain retries them: the first one owed at once, then with backoff from 100 ms to 10 s. While the breaker is open, it retries when the probe is due, so a process with no lookups recovers as promptly as one that has them.
    • Invalidate and the drain send 1,000 bumps per round trip.
    • Past 100,000 keys the set collapses to one tenant bump per affected tenant.
    • Close makes one last attempt past the breaker.
  • An owed bump holds its lookups. While this process owes a bump on any of a lookup's token keys, that lookup is a bypass with a zero snapshot: no hit, no fill. The tenant token that a collapsed set bumps is one of every lookup's keys, so a collapse holds the whole tenant. Other processes cannot know what this one owes, and serve those entries until the bump lands (the pending gauge shows the window).
  • Metrics (metrics.go): the eight series listed below.

Decisions beyond the design doc

  • NewRedis(cfg) takes no ctx. It dials once, bounded by the dial timeout, and then retries in the background; nothing at boot needs a ctx.
  • Breaker accounting: transport failures and timeouts count against the server, and so does a reply that refuses writes, which opens the breaker at once: no bump can land, and a threshold would let successful reads keep resetting the count. A reply about one key, or one the backend cannot use, counts as up, so a foreign value under a token key does not open the breaker for every tenant. The probe writes for the same reason.
  • Connection lifetime, not a re-dial on refusal: replacing connections is rueidis's own mechanism, with in-flight retries. Swapping a whole client from the probe would race Close. The cost is one reconnect per connection a minute. The lifetime is not configurable: an unexported field lets tests shorten it.
  • Foreign keys are replaced: a token or value key holding a non-token string, a list or a hash is overwritten.
  • Raw-size cap: a raw value over 8× MaxValueBytes is refused before compression. Otherwise the cache could store a value it could never decode.
  • VersionTTL under 2 s is refused: EX has one-second resolution (rueidis truncates to whole seconds) and the jitter spans [0.9, 1.1) of the TTL. Below about 1.1 s, a token could be set with EX 0; Redis rejects that, so the bump is deferred and retried. The 2 s floor leaves a margin.
  • zstd window 1 MiB: the encoder keeps one window-sized history per concurrent caller for the life of the process. With 240 KB values at 14 procs it kept about 121 MiB at SpeedFastest's default 4 MiB window, and about 36 MiB at 1 MiB, with the same speed and ratio on row payloads. WithLowerEncoderMem was rejected: it was 6× slower on 4 MiB values. The decoder accepts windows up to the decoded-size cap, so values written before this change still decode.
  • Where the tests live: the integration tests sit beside the package (internal/cache/redis_integration_test.go, //go:build integration), and make test-integration now also runs ./internal/cache/....
  • Coverage: the new files are excluded from the e2e coverage suite only. The e2e stack cannot reach an unwired backend, and they had pulled that suite to 56.4% against its 60% floor. The merged total still counts them.
  • Redis Cluster: tested as a one-node cluster owning every slot. That exercises CROSSSLOT enforcement and the cluster client's topology read, not routing across nodes (a node owning every slot never answers MOVED); nodes announce 127.0.0.1 on a same-numbered host port. A 3-master cluster in containers cannot gossip across a typical desktop container network's port mapping.
  • Probe budget: twice DialTimeout for a reconnect, then Timeout for the write. rueidis (v1.0.78) bounds a reconnect's dial, TLS included, by DialTimeout, then gives the HELLO/AUTH handshake a fresh DialTimeout, so a reconnect can take about twice that. The allowance is for a reconnect only: a first probe write slower than Timeout is repeated under Timeout alone, and the repeat decides. Without that, a server answering slower than Timeout but within the allowance passed every probe: the breaker closed, the next operations each waited out Timeout and failed, and it reopened, every cycle. A repeat that lands on another connection still needing a reconnect fails, which costs one more BreakerOpenFor cycle.
  • Credential replies open the breaker; NOPERM does not. NOPERM names one key or command, which the rest of the work may not touch. Counting it as a refusal made TestRedis_OwedBumpHoldsItsLookups fail: an ACL denying one key's write bypassed the cache for every tenant.
  • Not implemented: pool_size and TLS file loading. TLS is a *tls.Config that feat(config): cache.backend=redis selects the shared cache #630 builds from files.

Tests

  • Conformance: cachetest.Run (from feat(cache): shared redis cache, snapshot keys, uncached write pipes #614's suite) against pinned redis:8.10.2-alpine, valkey/valkey:8.1.10-alpine, dragonflydb/dragonfly:v2.0.0 and the Redis Cluster node:
    • NewPair drives the cross-instance cases.
    • Entries counts the values under the case's key prefix plus the empty key, so the zero-snapshot case runs on all four. Mutation-checked: dropping Set's zero-snapshot guard fails it.
    • Each server also runs a cross-tenant invalidation spanning 20 slots, a compression round trip, and an incompressible value between the stored-size and raw-size limits, which must not be stored. Mutation-checked: without the stored-size guard it is stored on all four.
  • TestRedis_LostTokensAreMisses: deletes the T, B, S or all token keys (the values survive), then FLUSHALL. Every case must miss, twice. Mutation-checked: recreating tokens as a constant, as a counter restarted at 0 would, fails all four.
  • TestRedis_ServerStopsAnswering: the server is paused, then:
    • each failed lookup returns in under 10× the 100 ms timeout (the bound the test asserts), and three failures open the breaker;
    • once open, lookups skip the server and cost no round trip;
    • an invalidation is deferred;
    • a process that boots meanwhile starts bypassed;
    • after the pause lifts, the deferred bump lands, the late process connects, and a second instance misses the pre-pause fill.
  • TestRedis_OwedBumpHoldsItsLookups: an ACL lets the process read tokens but not replace them, so a bump stays owed with the breaker closed.
    • The lookup that bump would orphan is a bypass with a zero snapshot, while other tables' and pipe lookups are served.
    • After the grant, no lookup returns the pre-write value before the bump lands.
    • Mutation-checked: without the hold, it fails 3/3.
  • TestRedis_RefusedWrites, for a primary demoted with REPLICAOF to an unreachable host (READONLY) and for maxmemory 1 under noeviction (OOM):
    • the first refusal opens the breaker despite a threshold of 1000, and the bump stays owed;
    • a process tripped by a refused fill, which owes nothing, stays bypassed across 7 s of refused probes;
    • after recovery, the owed bump lands before anything it would orphan is served;
    • an ingest-only process with 1,501 owed bumps (two batches) recovers within the probe period plus 3 s, where a drain left at its backoff would take about 5.7 s;
    • a fresh process misses.
    • Mutation-checked: a PING probe, refusals counted as up, and a drain that ignores the probe's due time each fail it.
  • TestRedis_FailoverBehindAStableAddress: a primary and its replica sit behind an in-process forwarder that moves new connections only. The test promotes the replica, demotes the primary, and invalidates (READONLY, deferred), then switches the forwarder. The owed bump lands on the new primary with no pre-write hit in between, and a fresh process misses. Mutation-checked: without the connection lifetime, the bump never lands.
  • TestRedis_ClusterTopologyReadIsBounded: a node that answers the handshake but not CLUSTER SHARDS held rueidis.NewClient for 10.0 s. It now fails within the bound. Mutation-checked.
  • TestRedis_CloseDeliversPastAnOpenBreaker: mutation-checked.
  • TestRedis_ForeignTokenIsReplaced: a short string under B:, a list under T, a hash under the value key. Mutation-checked.
  • TestRedis_RestoredSnapshotIsARollback: fill, SAVE, invalidate (a miss confirmed), DEBUG RELOAD NOSAVE: a fresh process gets the pre-write rows back. It pins the documented exception to "lost tokens can only cause misses".
  • TestRedis_ProbeFitsASlowReconnect: an in-process proxy terminates TLS, drops the connections, and delays both the TLS handshake and the forward of each new one by 700 ms, with Timeout 100 ms and DialTimeout 1 s: the reconnect, about 1.4 s, outlasts DialTimeout plus Timeout. The lookup that must reconnect fails and opens the breaker, and the probe reconnects and closes it. Mutation-checked: a probe bounded by Timeout, or by DialTimeout plus Timeout, never closes it.
  • TestRedis_ProbeKeepsASlowServerBypassed: the proxy delays every reply by 300 ms (Timeout 100 ms, DialTimeout 1 s, threshold 1, BreakerOpenFor 200 ms). The breaker stays open through 4 s of probes, and closes once replies are prompt again. Mutation-checked: without the repeat under Timeout, a probe closes it within the 4 s.
  • TestRedis_RejectedCredentialsOpenTheBreaker: the ACL user's password is rotated and its connections killed. The reconnect is refused with WRONGPASS, the breaker opens despite a threshold of 1000 and stays open across refused probes, and it closes once the old password works again. Mutation-checked: with credential replies counted as up, the breaker never opens.
  • Names that would run together: feat(cache): shared redis cache, snapshot keys, uncached write pipes #614's conformance cases for raw names (raw names read and bump alike, names never run together: pairs that collide under an unescaped join at a ., :, | or NUL) pass on all four servers. Mutation-checked: the codec before escaping failed names never run together on all four.
  • Unit tests (no server):
    • the key schema goldens: escaped table and scope; a : collision in token keys; :, NUL and cross-dependency collisions in value keys;
    • the codec round trip, its corruption and zip-bomb refusals, and the encoder window, including a wider-window value still decoding;
    • the breaker state machine on a fake clock, including the trip and the probe schedule;
    • pending coalescing, collapse, and which lookups a pending set holds;
    • the refusal and credential classification, both size limits counted in wavehouse_cache_oversize_total (mutation-checked), the first deferral waking the drain, config validation (including ConnWriteTimeout), bypass on an unreachable server, and the metrics.
  • make ci (static checks, unit, integration — including TestRedis_Conformance on Redis, Valkey, Dragonfly and a one-node Redis Cluster — e2e and coverage) is green locally, and go test -race -tags integration ./internal/cache/... passes.

Metrics (new series)

Meter wavehouse-cache, every series carrying backend="redis", no tenant label. None is emitted until #630 wires the backend.

series meaning
wavehouse_cache_lookups_total{result} lookups by hit / miss / stale (filed under since-bumped tokens) / bypass (server skipped, or held by a bump this process owes) / error
wavehouse_cache_op_duration_seconds{op} round-trip time of lookup / set / invalidate
wavehouse_cache_breaker_open 1 while the cache is bypassed (breaker open, or never connected)
wavehouse_cache_invalidations_total{result} ok counts every bump that lands, retried ones included; deferred counts each bump an invalidation could not deliver when made, a repeat of one already owed included; a failed retry is not counted again. They overlap; the pending gauge is what is still owed
wavehouse_cache_invalidations_pending bumps still owed to the server; other processes may serve the entries they would orphan meanwhile
wavehouse_cache_value_bytes stored value size (after compression)
wavehouse_cache_oversize_total results not cached for size
wavehouse_cache_set_failures_total{reason} failed fills by oom / timeout / other

Deliberately left to later PRs

🤖 Generated with Claude Code

https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd

EricAndrechek and others added 9 commits September 24, 2026 23:27
mq.backend, cache.backend, dedupe.backend and coord.backend select each
layer's implementation; only today's in-process one exists per layer and
it is the default. Validate refuses an unknown value, internal/app picks
the implementation in one switch per layer, data_dir is probed only when
a selected backend keeps state there, and boot logs Config.Warnings.

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
…ENTS.md

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
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
RedisCache keeps query results and their versions in one Redis, Valkey,
Dragonfly, ElastiCache or MemoryDB server shared by every process. Versions
are random tokens under the tenant's hash tag; a value carries the tokens it
was filed under, so a lookup is one pipelined MGET+GET and a lost token can
only cause misses. Failures bypass the cache behind a circuit breaker, and
undelivered invalidations are retried until they land. Built and tested
against Redis, Valkey, Dragonfly and a Redis Cluster node; not yet
selectable by config (E4).

Part of #613.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
Review round: a reply the backend cannot use (a foreign value under a
token key, an unreadable MGET) no longer opens the breaker, and such a
token is replaced; the probe follows the same rule. Close drains the
pending bumps past an open breaker, and starts no probe once closing.
VersionTTL under 2s is refused (it would round to EX 0). Docs: PING in
the command list, the breaker rule, compression wording.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
MGET reads a non-string token key as nil and SET NX will not overwrite
it, so such a key disabled caching behind it; it is now replaced on a
second round trip. A value key of the wrong type is a miss the fill's
SET replaces, not a lookup failure. Docs: VersionTTL is a lifetime from
the last bump, and each metric's labels are listed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
The e2e stack runs LocalCache and no config selects the Redis backend
yet, so its files pulled the e2e suite to 56.4% (floor 60). The
integration suite covers them against real servers, and per-suite
excludes leave the merged total unchanged.

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: ca8af015-cbbe-4f7b-98ae-aa41376e0908

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 dependencies Pull requests that update a dependency file go Pull requests that update go code area/observability Metrics, logs, traces, health, profiling area/cache Local / shared / tiered caching area/docs Documentation, site/, README area/infra CI, build, deploy, Docker, release labels Sep 25, 2026
EricAndrechek and others added 4 commits September 25, 2026 01:01
…che-redis-wiring

# Conflicts:
#	CHANGELOG.md
#	docs/src/content/docs/architecture.md
…eat/cache-redis-wiring

# Conflicts:
#	AGENTS.md
#	docs/src/content/docs/architecture.md
#	internal/app/wire.go
The boot config's cache.backend now takes redis, configured by a new
cache.redis block (WH_CACHE_REDIS_*), and wireCache builds a RedisCache
from it, reading the TLS files. A malformed block refuses boot; an
unreachable server or a rejected password boots bypassed and keeps
reconnecting.

An integration test boots two instances over one Redis and one
ClickHouse: an ingest through one is served fresh by the other inside
the stale entry's TTL, and a paused Redis leaves queries succeeding.
The e2e suite now runs against Redis, so its coverage exclude is gone.

Part of #613 (E4).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
Exclude LocalCache from the e2e gate now that e2e runs on Redis;
document the shared server as a trust boundary, the noeviction and
mutation-pipe (#386) staleness cases, and the connection commands an
ACL user needs; refuse addresses with surrounding spaces.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
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>
# Conflicts:
#	AGENTS.md
#	docs/src/content/docs/architecture.md
A Namespace now carries raw table and scope names, so the Redis codec
builds its token keys with keyenc.AppendJoin after the fixed
"<prefix>:{tenant}:B:" and ":S:" pieces; the {tenant} hash tag is placed
as it was, since a tenant id is its own escaped form. A ':' in a table or
scope no longer reads as the separator: table "a:b" with scope "c" and
table "a" with scope "b:c" had one scope token.

valueKey hashed each dep's raw table and scope ended by NULs, so a name
holding a NUL could hash the same input as another set of names: table
"a\x00b" and table "a" with scope "b\x00" shared a value key. It now
hashes the escaped sha and each dep's escaped, joined form, each ended by
a NUL, which escaping never writes. The key layout is otherwise unchanged.

The conformance cases added with the raw-name change run against Redis,
Valkey, Dragonfly and a Redis Cluster node, and the old codec fails
"names never run together" on all four.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1tJWUp6oaDoH1usLwGtLF
EricAndrechek and others added 10 commits September 26, 2026 06:23
Since #626 bounds a cluster client's topology read by the larger of the
two, a dial held by boot or Close is a connect and a handshake, each up
to dial_timeout, plus that read. At a 2s dial cap it could reach 6s,
past the 5s release budget. Both caps are now 1s, so at most 3s.

The shared-cache docs now describe #626's behavior: a refused write
opens the breaker, the owing instance bypasses the lookups it would
orphan, and connections are replaced every minute after a failover
behind a stable address.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e2e gate

- Standalone mode dials only the first address, so a second one was
  silently ignored: it now refuses boot. A port must be 1-65535.
- The manual e2e boot command needs a Redis now that the fixture sets
  cache.backend: redis; the e2e stack is described as ClickHouse and
  Redis wherever it was ClickHouse only.
- The e2e gate (59.9%) excludes cache_redis.go's rejection paths and
  pending.go's outage-only retries, as it excludes internal/settings;
  unit and integration cover both, and the merged total still counts them.
- The integration tests read the TTL floor from cache.QueryTimeToTTL.
- A failed InvalidateTenant on reconcile logs at WARN, like the worker.
- The overview pages mention the shared cache.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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
- configuration.mdx: isAuthError also matches NOPERM (an ACL user
  missing a connection command); the boot-error-logging line only
  named WRONGPASS/NOAUTH.
- CHANGELOG.md: the E4 entry claimed the e2e coverage exclude for the
  cache files was gone; .testcoverage.yml still excludes
  internal/cache/pending.go, internal/config/cache_redis.go and
  internal/cache/(local|version_manager).go from e2e, for reasons the
  entry now states. Also added the docs/.github files this PR touched
  that the entry's file list was missing.
- pipes.mdx, api.md: "L1" claims that are wrong once cache.backend is
  redis (there is no L1) — reworded to "the query cache"/"cache".
- deployment.md: "Persistence is not needed" was incomplete — a
  restart *with* persistence reloads a stale snapshot and can serve
  invalidated results as hits until their TTL. Now says to run without
  persistence, and that restoring a snapshot is a rollback.
- development.md: note that shared_cache_test.go starts its own Redis
  per test and boots extra cache.backend=redis instances over the
  integration suite's ClickHouse.
- app_test.go: TestRedisConfig_FromLoadedDefaults left Username, DB
  and TLS at their zero value on both sides of its assert.Equal, so
  deleting any of their three mapping lines in redisConfig wouldn't
  fail it. Added TestRedisConfig_UsernameDBTLSMapped, which drives all
  three to a non-zero value through config.Load. Mutation-checked:
  deleting each of the three lines in redisConfig fails this test (the
  TLS line fails to compile instead, since dropping it leaves the
  local `t` unused — still a build failure).

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
- A password rotated under a running process refused every new
  connection's handshake with WRONGPASS, which counted as the server
  being up: the breaker stayed closed and nothing was logged. WRONGPASS
  and NOAUTH now open it at once, logged at ERROR. NOPERM still names
  one key or command and does not.
- rueidis redials under the calling operation's context, so a probe
  bounded by Timeout could never complete a reconnect slower than it.
  The probe now runs under DialTimeout plus Timeout. It reconnects only
  the connection it lands on; the client's others still reconnect under
  the op timeout, and the docs say so (refs #664).
- Restoring an RDB/AOF snapshot is a rollback, not a lost token: the
  docs no longer say a restart can only cause misses, and a test pins
  the rollback.
- The stored-size cap is now tested: a unit count of both size limits,
  and an incompressible value between them on every server.
- Docs: invalidations_total's ok and deferred overlap; the breaker.go
  and pending.go bullets describe their own files and name the redis.go
  functions around them; the cluster test covers CROSSSLOT and the
  topology read, not routing; the timing assertion is stated as written.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd
Co-Authored-By: Claude Opus 5.5 (1M context) <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 and others added 11 commits September 26, 2026 09:24
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
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
Verified against the merged internal/cache/{redis,breaker}.go:

- rejectsCredentials (WRONGPASS, NOAUTH) now trips the breaker at once
  at runtime too, logged at ERROR once per opening and once per
  refused probe (breaker.trip()); NOPERM deliberately does not (it
  names one key/command, not every operation, so an ACL denial on one
  tenant's traffic shouldn't bypass the cache for all of them).
  configuration.mdx's circuit-breaker paragraph didn't mention this at
  all; added it, distinct from the dial-time isAuthError logging (a
  separate function, used only in dial()/dialLoop(), where WRONGPASS,
  NOAUTH and NOPERM are all ERROR since any of them blocks the
  connection outright before a client exists to send a scoped command
  on).
- deployment.md's failover bullet gets the probe's DialTimeout+Timeout
  budget and the caveat that every other connection still redials
  under Timeout alone, matching architecture.md's redis.go bullet.
- deployment.md's metrics list presented invalidations_total's `ok`/
  `deferred` as if they partition the count; they overlap (a retried
  landing counts `ok` again), per metrics.go's own doc comment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd
The probe's reconnect allowance also covered its write, so a server
answering slower than Timeout, but within the allowance, passed every
probe: the breaker closed, the next operations timed out on the server,
and it reopened, every cycle. A probe write slower than Timeout is now
repeated under Timeout alone, and the repeat decides.

rueidis bounds a reconnect's dial, TLS included, by DialTimeout and then
its handshake by DialTimeout again, so the allowance is now twice
DialTimeout. The slow-reconnect test now reconnects over TLS, with the
dial and the handshake each taking most of DialTimeout, and a new test
keeps a server that answers slower than Timeout bypassed.

Docs: a restart after a crash that reloads the last save is a rollback,
like restoring a snapshot; what the deferred invalidation count counts;
the client's other connections must reconnect within Timeout.

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

# Conflicts:
#	CHANGELOG.md
#	docs/src/content/docs/architecture.md
Verified against the merged internal/cache/redis.go:

- The failover bullet said the probe gets DialTimeout+Timeout; it's
  now 2×DialTimeout+Timeout for the first write (rueidis bounds the
  dial and the handshake by DialTimeout in turn), and a write slower
  than Timeout is repeated under Timeout alone — only the repeat
  decides whether the breaker closes, so a server that merely answers
  slowly stays bypassed instead of flapping open and shut every cycle.
- Confirmed the probe is unaffected by, and doesn't affect, the boot/
  Close 1s-cap arithmetic in configuration.mdx's dial_timeout row and
  cache_redis.go's maxRedisTimeout comment: probe() runs in an
  untracked goroutine (no r.wg.Add before `go r.probe(...)`), so
  Close's r.wg.Wait() never waits on it. No doc change needed there.
- "Run it without persistence" didn't say why: stock Redis and Valkey
  persist by default (periodic RDB save points), so an ordinary crash-
  restart reloads the last save on its own — the same rollback as
  restoring a snapshot by hand. Reworded to match #626's CHANGELOG/
  architecture.md wording (grepped for other copies; found none).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd
rueidis keeps up to four connections to a standalone server by
GOMAXPROCS and dials each on first use, so an operation landing on one
first used after the failover reached the new primary with no
connection replaced: without ConnLifetime the test still passed unless
GOMAXPROCS was 1. A test-only option gives the client one connection,
dialed before the failover, so the test fails without the replacement
at any GOMAXPROCS.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
A reply refusing writes and a run of unanswered operations opened the
breaker silently. Each opening, a failed probe's included, now logs one
WARN with the reply or the error; operations failing while it is open
log nothing. Also rewords a comment that named an internal milestone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
Close can wait up to 4s, not 3s: a dial in flight, then the 1s final
drain. deferred counts each deferral once, not failed retries. The probe
write closes the breaker only within timeout. The breaker's WARN on
opening is documented alongside the credentials ERROR.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
@github-actions github-actions Bot added github_actions Pull requests that update GitHub Actions code area/ingest Ingest pipeline (Bento, batching, DLQ) area/app Process wiring (internal/app): component build, run, release labels Sep 26, 2026
@EricAndrechek
EricAndrechek merged commit 2db5027 into feat/cache-snapshot Sep 26, 2026
2 checks passed
@EricAndrechek
EricAndrechek deleted the feat/cache-redis 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 area/infra CI, build, deploy, Docker, release area/ingest Ingest pipeline (Bento, batching, DLQ) area/observability Metrics, logs, traces, health, profiling dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation github_actions Pull requests that update GitHub Actions code go Pull requests that update go code

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant