feat(cache): shared redis cache, snapshot keys, uncached write pipes - #614
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
Cache.Get/Set becomes Lookup(ctx, tenant, sha, deps) -> (Entry, Snapshot, error) and Set(ctx, Snapshot, value, ttl): a fill is filed under the versions read before its query ran, so a bump landing mid-query orphans it instead of re-homing pre-write rows (#382). Every query key folds the tenant version, so InvalidateTenant now orphans pipe results too. A Lookup naming another tenant's namespace is ErrForeignDependency. Set errors only on backend failure. Adds internal/testutil/cachetest, the backend-agnostic conformance suite LocalCache runs and the Redis backend will. Fixes #382. Part of #613. 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
|
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 |
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
…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
A pipe whose bound SQL is a write went to ClickHouse through Exec but still had its [] cached and identical in-flight calls coalesced, so a repeat within the TTL answered 200 without writing (#386). With a shared cache that holds on every instance. The handler now classifies the bound SQL with isMutation, the classifier executeCHQuery routes Exec by, and a write skips the cache lookup, fill and singleflight and answers X-Cache: BYPASS. Reads are unchanged. Fixes #386. Part of #613. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
A write pipe answers Cache-Control: no-store so an HTTP cache in front of a GET cannot drop the write. api.md, architecture.md and AGENTS.md no longer call /v1/ops/query the only non-insert write path, pipes.mdx says allowed_roles is a write pipe's only gate, and its section moves below the execution error table. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
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
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
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
A closed breaker opening still logs one WARN, or ERROR for rejected credentials. A failed probe reopening an already-open breaker now logs at DEBUG, so a server that stays down is one line for the outage rather than one every BreakerOpenFor. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
A failed probe reopening the breaker logged at DEBUG whatever its cause, so an outage that turned into rejected credentials after a restart left only the first opening's WARN. A reopening for another cause than the one last logged is now logged at its own level. The configuration page still said a down server logs every 5 s; it now says one line. 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
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
marked this pull request as ready for review
September 26, 2026 20:38
EricAndrechek
added a commit
that referenced
this pull request
Sep 26, 2026
Keeps both sides: the redis cache backend and the dynamodb dedupe backend sit side by side in defaults(), the backend validation test, the coverage excludes, the changelog and the docs. The app test helper for a redis cache config now carries the dedupe lease and concurrency defaults, since Validate refuses them at zero. 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
The multiple-instances section from #614 said dedupe is always per instance, and the boot-config list named only cache.redis. Both now name dedupe.dynamodb. The integration setup also starts dynamodb-local. 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 29, 2026
Brings in #614 (shared redis cache), #625 (dedupe reserve/commit, dynamodb, dedupe.lease, WithIdempotencyKey), #680 (a failed queue join is the tenant's alone) and #623's squash, whose content the stack already had from its PR head. Resolutions: - config: Warnings keeps the nats warnings for every role and main's api-only cache/redis/dedupe ones; the unknown-backend test lists nats beside redis and dynamodb. - mq: main's WithIdempotencyKey, ErrUnavailable and Consume contracts; the conformance suite is main's, idempotency case included. - testutil: main's storedir replaces this stack's StoreDir. - ingest: main's publishFailed already answers ErrUnavailable with 503. - integration setup keeps startNATS beside startDynamoDBLocal; the Makefile runs internal/cache with the tagged suites as main does. - docs and CHANGELOG keep both sides; the backend lists name nats, redis and dynamodb together. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FyrXjhR7iDg33paioLQHFq
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.
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.
cache.Cacheinterface is nowLookup(ctx, tenant, sha, deps) (Entry, Snapshot, error)andSet(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. PreviouslyPOST /v1/queryand 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.InvalidateTenantnow drops a returning or moved tenant's cached pipe results too. ALookupwhose deps name another tenant is refused (ErrForeignDependency).cache.Namespacecarries raw table and scope names and the cache escapes them itself withinternal/keyenc, soquery.SafeEncodeTokenis gone and names that would run together under an unescaped join can no longer share a key.VersionManagerholds 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:InvalidateTenantdrops the tenant's index and the next key gets a fresh generation. After every settings reload,LocalCache.Prunedrops the index of each tenant no longer served.IsMutationclassifies as a write skips the cache lookup, the fill and singleflight, and runs on every call. Before, a repeat within the TTL answered200without 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 aWITH-led statement byINSERT INTOalone, and looks throughEXECUTE AS. An integration test checks every case, and every keyword insystem.keywordsin 12WITHshapes, against ClickHouse's own parser.cache.RedisCacheruns against Redis, Valkey, Dragonfly, ElastiCache and MemoryDB, standalone or cluster, using onlyGET,SETandMGET. 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. Eightwavehouse_cache_*metrics, all labeledbackend="redis", report hits, round-trip time, breaker state, owed invalidations, value size and failed fills.cache.backend: redis. A newcache.redisboot-config block (WH_CACHE_REDIS_*):addrs,mode(standaloneorcluster), credentials,db, TLS files,key_prefix,timeoutanddial_timeout(each capped at1s),max_value_bytes,compress_min_bytesandversion_ttl.wireCachebuilds the backend from it. It is the shared cache that splitting theapiandingestroles 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
X-Cache: BYPASSwithCache-Control: no-storeand are never coalesced. Each call executes, so identical concurrent calls are that many writes. A failed write keeps the read path's status andcodebut always answersretryable: falsewith noRetry-After, since the statement may have run. Read pipes are unchanged./v1/streamsubscribers (SSE/live-query yields no events for tables populated by a ClickHouse MATERIALIZED VIEW (only ingest-API writes republish to NATS) #362).InvalidateTenantdrops more than before: a tenant's cached pipe results as well as its query results. Inserts still do not reach pipe results (feat(pipes): resolve table dependencies for cache invalidation #343).WARN, or oneERRORfor rejected credentials. A failed probe reopening it logs atDEBUG, 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: sentinelrefuses boot until bug(cache): a demoted Redis primary reads as healthy; Sentinel mode is unconfigured #656. A URL-style address is refused without echoing it, and a standalone server takes exactly one address.WARN, notERROR: the shared backend defers and retries it.wavehouse_cache_invalidations_pendingis the signal to alert on.maxmemory-policyand without persistence. Undernoevictiona 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.GET, which proxies and clients may replay.BACKUP,RESTORE,UNDROPandMOVEpipes are not classified as writes.SET,USEandEXECUTE ASin a pipe leak into the pooled session.scope), the rest of bug(cache): Redis recovery probe must fit a fresh dial in the op timeout #664 (the probe reconnects one connection of several), Sentinel (bug(cache): a demoted Redis primary reads as healthy; Sentinel mode is unconfigured #656), and the near-cache.Tests
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 (Cache: result can be keyed to a newer version than the data it reflects (write-during-query staleness) #382), oversize values, zero snapshots, and concurrent use under-race. Shared backends also get two-instances-over-one-store cases.LocalCacheruns it, and so doesRedisCacheagainst pinned Redis, Valkey, Dragonfly and a Redis Cluster node.INSERT,WITH … INSERTandALTER … DELETEpipes 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 answersretryable: false. Over the Redis-backed e2e stack, a write pipe called twice leaves both rows, and a failed one answers400 clickhouse.rejectedwithretryable: false. There are 152 table-driven classifier cases, each also checked againstEXPLAIN ASTon the pinned ClickHouse, and 17,928 keyword-namedWITHstatements where the classifier must agree with the parser.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.app.Newinstances over one Redis: an ingest on one invalidates the other, and with Redis paused, queries bypass and still succeed.make cipasses: 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.ai/code/session_01FyrXjhR7iDg33paioLQHFq