Skip to content

feat(cache): shared redis cache, snapshot keys, uncached write pipes - #614

Merged
EricAndrechek merged 108 commits into
mainfrom
feat/cache-snapshot
Sep 26, 2026
Merged

EricAndrechek merged 108 commits into
mainfrom
feat/cache-snapshot

Conversation

@EricAndrechek

@EricAndrechek EricAndrechek commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

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 Cache: result can be keyed to a newer version than the data it reflects (write-during-query staleness) #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 perf(cache): cache version-manager map grows unbounded with tables × scopes #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 bug(pipes): mutation pipe results are cached and coalesced — the write silently drops on repeat calls #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

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 (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. LocalCache runs it, and so does RedisCache against pinned Redis, Valkey, Dragonfly and a Redis Cluster node.
  • Cache: result can be keyed to a newer version than the data it reflects (write-during-query staleness) #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.ai/code/session_01FyrXjhR7iDg33paioLQHFq

taitelee and others added 5 commits September 24, 2026 17:49
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
@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: e9d137d1-29f6-4933-bd93-f93d5b264f96

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

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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

❤️ Share

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation go Pull requests that update go code area/api HTTP handlers, routing, middleware area/ingest Ingest pipeline (Bento, batching, DLQ) area/cache Local / shared / tiered caching area/docs Documentation, site/, README area/app Process wiring (internal/app): component build, run, release labels Sep 25, 2026
EricAndrechek and others added 4 commits September 24, 2026 23:56
The local version index nested each table under its tenant's version and
each scope under its table's, and was never pruned: every InvalidateTenant
left the tenant's whole index behind. It now holds one version per tenant,
(tenant, table) and (tenant, table, scope), bumped in place. A tenant bump
drops the tenant's index and its next key gets a process-unique
generation; a table bump drops the table's scopes. After each reload the
wiring prunes the index to the tenants served.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr5tY4WQUy2sc4MbW67vL
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
EricAndrechek and others added 2 commits September 25, 2026 00:25
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
EricAndrechek and others added 6 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
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
EricAndrechek and others added 14 commits September 26, 2026 13:10
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
@github-actions github-actions Bot added dependencies Pull requests that update a dependency file github_actions Pull requests that update GitHub Actions code area/observability Metrics, logs, traces, health, profiling area/pipes Named query pipes area/sdk TypeScript SDK (clients/ts/) area/infra CI, build, deploy, Docker, release labels Sep 26, 2026
@EricAndrechek EricAndrechek changed the title fix(cache): snapshot versions at lookup; tenant token on every key feat(cache): shared redis cache, snapshot keys, uncached write pipes Sep 26, 2026
@EricAndrechek
EricAndrechek marked this pull request as ready for review September 26, 2026 20:38
@EricAndrechek
EricAndrechek requested review from a team and taitelee September 26, 2026 20:38
@EricAndrechek
EricAndrechek merged commit 9db8185 into main Sep 26, 2026
33 of 34 checks passed
@EricAndrechek
EricAndrechek deleted the feat/cache-snapshot branch September 26, 2026 21:19
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/api HTTP handlers, routing, middleware 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 area/pipes Named query pipes area/query Structured query AST, SQL builder area/sdk TypeScript SDK (clients/ts/) 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

2 participants