feat(cache): shared cache backend on Redis, Valkey and Dragonfly - #626
Merged
Merged
Conversation
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
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Advanced Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
…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
This was referenced Sep 25, 2026
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
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
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #613. Stacked on #614 (
feat/cache-snapshot). #614 no longer stacks on #655:refactor/keyencis merged into main, which #614 includes. The key schema escapes names withinternal/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.RedisCacheis acache.Cacheshared by every process pointed at one Redis-compatible server: Redis, Valkey, Dragonfly, ElastiCache or MemoryDB. It sends onlyGET,SETandMGET. 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.backendaccepts onlylocaluntil #630 addsredis, thecache.redis.*boot config and the wiring. Every deployment still runsLocalCache.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. ANamespacecarries 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 withkeyenc.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 asVersionManager's:B:;S:<scope>andS:(the whole-table view).MGETof the tokens is pipelined with aGETof 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 (tablea\x00bagainst tableawith scopeb\x00).SET NXand read back; it is never read as a value. Eviction, expiry,FLUSHALLor a restart without persistence therefore orphan entries and never revive them, soallkeys-lruis safe. A token or value key that holds something else (a string of another length, a list, a hash) is replaced.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 undernoeviction),NOREPLICAS,MISCONF,LOADING,BUSYorCLUSTERDOWN. It also opens at once on a reply refusing the credentials,WRONGPASSorNOAUTH, which a new connection's handshake meets after a password rotation; that is logged atERROR. Any other reply counts as up, including one about a single key or command (WRONGTYPE,NOPERM,TRYAGAIN) and a malformed one.SET <prefix>:probe) decides whether the breaker closes, and only that probe closes it, when a write is answered withinTimeout. A server that answers but refuses writes therefore stays bypassed, and so does one that answers slower thanTimeout.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.DialTimeoutand then the handshake byDialTimeoutagain, so a reconnect slower thanTimeoutfails that operation. The probe's first write gets twiceDialTimeoutfor a reconnect on top ofTimeout, so such a reconnect still closes an open breaker. The allowance is for a reconnect only: a first write slower thanTimeoutis repeated underTimeout, and the repeat decides. But rueidis spreads commands over several connections (up to four to one server, byGOMAXPROCS, and one per cluster node), and the probe reconnects only the one it lands on.Timeoutshould therefore still exceed a reconnect, or operations that land on the other connections keep failing.NewRedisnever fails on an unreachable server. The cache starts bypassed and keeps dialing, with backoff up to 30 s. Each dial is bounded byDialTimeout, and a cluster client's topology read by the larger of that andTimeout(rueidis leaves it at 10 s otherwise).pending.go): repeats coalesce per key.Invalidateand the drain send 1,000 bumps per round trip.Closemakes one last attempt past the breaker.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.Close. The cost is one reconnect per connection a minute. The lifetime is not configurable: an unexported field lets tests shorten it.MaxValueBytesis refused before compression. Otherwise the cache could store a value it could never decode.VersionTTLunder 2 s is refused:EXhas 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 withEX 0; Redis rejects that, so the bump is deferred and retried. The 2 s floor leaves a margin.WithLowerEncoderMemwas 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.internal/cache/redis_integration_test.go,//go:build integration), andmake test-integrationnow also runs./internal/cache/....MOVED); nodes announce127.0.0.1on a same-numbered host port. A 3-master cluster in containers cannot gossip across a typical desktop container network's port mapping.DialTimeoutfor a reconnect, thenTimeoutfor the write. rueidis (v1.0.78) bounds a reconnect's dial, TLS included, byDialTimeout, then gives theHELLO/AUTHhandshake a freshDialTimeout, so a reconnect can take about twice that. The allowance is for a reconnect only: a first probe write slower thanTimeoutis repeated underTimeoutalone, and the repeat decides. Without that, a server answering slower thanTimeoutbut within the allowance passed every probe: the breaker closed, the next operations each waited outTimeoutand failed, and it reopened, every cycle. A repeat that lands on another connection still needing a reconnect fails, which costs one moreBreakerOpenForcycle.NOPERMdoes not.NOPERMnames one key or command, which the rest of the work may not touch. Counting it as a refusal madeTestRedis_OwedBumpHoldsItsLookupsfail: an ACL denying one key's write bypassed the cache for every tenant.pool_sizeand TLS file loading. TLS is a*tls.Configthat feat(config): cache.backend=redis selects the shared cache #630 builds from files.Tests
cachetest.Run(from feat(cache): shared redis cache, snapshot keys, uncached write pipes #614's suite) against pinnedredis:8.10.2-alpine,valkey/valkey:8.1.10-alpine,dragonflydb/dragonfly:v2.0.0and the Redis Cluster node:NewPairdrives the cross-instance cases.Entriescounts the values under the case's key prefix plus the empty key, so the zero-snapshot case runs on all four. Mutation-checked: droppingSet's zero-snapshot guard fails it.TestRedis_LostTokensAreMisses: deletes the T, B, S or all token keys (the values survive), thenFLUSHALL. 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:TestRedis_OwedBumpHoldsItsLookups: an ACL lets the process read tokens but not replace them, so a bump stays owed with the breaker closed.TestRedis_RefusedWrites, for a primary demoted withREPLICAOFto an unreachable host (READONLY) and formaxmemory 1undernoeviction(OOM):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 notCLUSTER SHARDSheldrueidis.NewClientfor 10.0 s. It now fails within the bound. Mutation-checked.TestRedis_CloseDeliversPastAnOpenBreaker: mutation-checked.TestRedis_ForeignTokenIsReplaced: a short string underB:, a list underT, 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, withTimeout100 ms andDialTimeout1 s: the reconnect, about 1.4 s, outlastsDialTimeoutplusTimeout. The lookup that must reconnect fails and opens the breaker, and the probe reconnects and closes it. Mutation-checked: a probe bounded byTimeout, or byDialTimeoutplusTimeout, never closes it.TestRedis_ProbeKeepsASlowServerBypassed: the proxy delays every reply by 300 ms (Timeout100 ms,DialTimeout1 s, threshold 1,BreakerOpenFor200 ms). The breaker stays open through 4 s of probes, and closes once replies are prompt again. Mutation-checked: without the repeat underTimeout, 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 withWRONGPASS, 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.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 failednames never run togetheron all four.:collision in token keys;:, NUL and cross-dependency collisions in value keys;wavehouse_cache_oversize_total(mutation-checked), the first deferral waking the drain, config validation (includingConnWriteTimeout), bypass on an unreachable server, and the metrics.make ci(static checks, unit, integration — includingTestRedis_Conformanceon Redis, Valkey, Dragonfly and a one-node Redis Cluster — e2e and coverage) is green locally, andgo test -race -tags integration ./internal/cache/...passes.Metrics (new series)
Meter
wavehouse-cache, every series carryingbackend="redis", no tenant label. None is emitted until #630 wires the backend.wavehouse_cache_lookups_total{result}hit/miss/stale(filed under since-bumped tokens) /bypass(server skipped, or held by a bump this process owes) /errorwavehouse_cache_op_duration_seconds{op}lookup/set/invalidatewavehouse_cache_breaker_openwavehouse_cache_invalidations_total{result}okcounts every bump that lands, retried ones included;deferredcounts 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 owedwavehouse_cache_invalidations_pendingwavehouse_cache_value_byteswavehouse_cache_oversize_totalwavehouse_cache_set_failures_total{reason}oom/timeout/otherDeliberately left to later PRs
cache.backend/cache.redis.*config, the wiring and itsClosebudget, the configuration and deployment docs (includingmaxmemory-policy, the consistency statement, and no replica reads), and the two-Append-to-end test.keyenckeeps, needs one of two things. Either the new build reads and bumps both layouts (folding the old tokens into what it files) until no old build is left, and a later build drops the old; or the upgrade never runs two builds against the server at once.valueKeychange only orphans values and is safe to roll.VersionManager. This PR does not touch it.PipelineMultiplex) or a probe that reaches every connection. Until then,Timeoutshould exceed a reconnect.MOVEDreply counts as up, so keys in other slots always miss and their bumps stay owed. A check at connect (cluster_enabled:1) could also refuse single-endpoint cluster services that work in standalone mode today, so it waits for feat(config): cache.backend=redis selects the shared cache #630's configuration.🤖 Generated with Claude Code
https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd