Skip to content

fix(pipes): run write pipes every call, uncached and uncoalesced - #634

Merged
EricAndrechek merged 16 commits into
feat/cache-snapshotfrom
fix/mutation-pipes-uncached
Sep 26, 2026
Merged

EricAndrechek merged 16 commits into
feat/cache-snapshotfrom
fix/mutation-pipes-uncached

Conversation

@EricAndrechek

@EricAndrechek EricAndrechek commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Fixes #386. Part of #613.

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

What changes

A pipe whose SQL is a write went to ClickHouse through Exec. Its [] was still cached, and identical calls in flight were coalesced. So a repeat within the TTL (10 s to 1 h) answered 200 without writing, and N concurrent identical calls became one write. With cache.backend: redis (#630), that cached [] would answer every instance.

PipesHandler.Execute now runs IsMutation on the bound SQL. A write branches off to executeWrite before the cache lookup, so it skips the lookup, the fill and singleflight. It takes the tenant's pool itself (no pool is still a 503 with Retry-After: 30, before anything is sent), executes on every call and answers X-Cache: BYPASS with Cache-Control: no-store, so an HTTP cache in front of a GET write pipe can't answer a repeat either. Reads are unchanged and keep #614's order: cache lookup, then the pool, whose absence answers 503 before a hit is served. The execute-and-marshal body moved into PipesHandler.run, which both paths share.

A failed write answers with #627's classification, status and code, but always retryable: false and with no Retry-After, 503 clickhouse.unavailable included (writeCHWriteError). Once the statement is sent it may have run, and the TS SDK retries both a 5xx with no retryable: false and any 503 that carries Retry-After.

How a pipe is identified as a write

The handler uses IsMutation, the classifier executeCHQuery already uses to send a statement to Exec instead of Query. It reads the leading keyword and skips whitespace, comments, string literals, quoted identifiers (curly-quoted ones included) and heredocs the way ClickHouse's lexer does. After a WITH list ClickHouse accepts only SELECT, a FROM-first SELECT or INSERT INTO (measured on 26.6.3.62: every other statement verb there is a SYNTAX_ERROR), so a WITH-led statement is a write exactly when it holds INSERT INTO outside parentheses. The classifier never tries to tell which other word starts the statement, because a WITH list's names can be spelled like any keyword. A number led by . ends at its digits and exponent, as ClickHouse's lexer ends it, so .5INSERT is .5 then INSERT. An EXECUTE AS <user> prefix is looked through, and the statement after it is classified by the same rules; a bare EXECUTE AS <user> returns no result set and goes through Exec.

  • Why not a declared pipe property ("mutation": true): an operator who forgets it reintroduces bug(pipes): mutation pipe results are cached and coalesced — the write silently drops on repeat calls #386 without any error. Classifying automatically means nothing has to be declared.
  • Why not ClickHouse's own classification (EXPLAIN, query_kind): that costs a round trip on every call, because the SQL has {{param}} placeholders until it is bound. query_kind in system.query_log is only known after the statement has run. Instead, the integration suite checks the classifier against ClickHouse's parser (EXPLAIN AST), so a disagreement fails CI rather than a call.
  • Why this classifier and not a new parser: the statements that skip the cache are exactly the statements that run as writes, so the two cannot disagree. A write IsMutation misses is worse than uncached: it goes through conn.Query, which runs it and then fails the call (measured: clickhouse-go v2.48.0 native returns EOF after the row is inserted), and a client that retries the 5xx writes again.

Classification runs on the bound SQL, the same string Exec receives. A value bound to a bare placeholder is an escaped literal and cannot change the statement; a quoted placeholder is unsafe and is now documented as such (guard: #662).

Invalidating what a write pipe writes: left to #394

A write pipe still does not invalidate cached reads of the table it writes. This PR leaves that to #394 and does not fold #394 in:

Write-pipe rows also don't reach /v1/stream subscribers, because only /v1/ingest publishes to the queue. That is the same root cause as #362. The docs now say both.

Tests

  • TestPipesHandler_Execute_MutationRunsEveryCall: INSERT, WITH … INSERT and ALTER … DELETE pipes, each called 3 times over a real LocalCache. Result: 3 Exec calls, 0 Query calls, X-Cache: BYPASS, Cache-Control: no-store and [] every time.
  • TestPipesHandler_Execute_ConcurrentMutationsNotCoalesced: under synctest, 3 identical calls held inside Exec together. Result: 3 writes in flight.
  • TestPipesHandler_Execute_ReadPipeStaysCached: SELECT * FROM insert_log …, whose table name starts with a write verb, gives MISS, HIT, HIT with 1 query.
  • TestPipes_WriteClickHouseErrors: every row of fix(api): map ClickHouse query failures by class, not HTTP status #627's error table on a write pipe gives the read's status and code, retryable: false, no Retry-After, and exactly 1 Exec. TestClickHouseRoutes_NoPoolIs503/write_pipe_execute: no pool is a 503 with Retry-After: 30.
  • TestIsMutation runs the 152 cases in internal/testutil/mutationtest, 88 of them new in this PR: writes and the reads they must not catch (backslash escapes in '…', "…" and backticks; curly quotes; nested block comments; // comments; \v, \f, NBSP and BOM; heredocs; $ in a bareword; _-led words; a leading bareword such as insert_log read whole; a WITH list's names spelled like keywords, as a CTE, an alias, a function, a lambda parameter, an operand, a qualified name's part, an array element or a bare element; a .-led number glued to INSERT; EXECUTE AS ahead of writes and reads, with a bare, quoted, backticked, curly-quoted or heredoc user and an @host). TestExecuteCHQuery_* sends EXECUTE AS … INSERT to Exec and EXECUTE AS … SELECT to Query. TestIsMutation_ClickHouseWhitespace covers every whitespace character ClickHouse accepts in four positions.
  • TestIsMutation_AgreesWithClickHouseParser (integration): each case goes through EXPLAIN AST on the pinned ClickHouse, and its root node must map to the case's answer; an ExecuteAsQuery root is mapped by the statement it runs, and a bare one counts as a write. The 15 cases ClickHouse rejects (among them WITH … DELETE, WITH … ALTER, WITH … TRUNCATE and REPLACE INTO) are marked Unparsed and must still fail with SYNTAX_ERROR, so a ClickHouse that starts accepting one fails the test.
  • TestIsMutation_KeywordNamesInWithList (integration): every word in system.keywords (498) in 12 WITH shapes, ahead of SELECT, FROM-first SELECT and INSERT INTO. That is 17,928 statements, of which 17,860 parse, in about 2.5 s under -race, and IsMutation must agree with the parser on each.
  • Mutation checks:
    • disabling the bypass (if false && IsMutation(sql)) fails both write tests, while the read test still passes;
    • against an early version of the classifier, 18 of the first 19 new TestIsMutation rows (the 19th, a read, passes either way) and every non-ASCII-space case fail; against the classifier before the heredoc fix, all 6 heredoc/$ rows fail; against an earlier version of the classifier, 7 of its 8 rows fail (the 8th, a read, passes either way), and both leading-bareword rows fail against an earlier commit;
    • answering a write failure with the read path's writeCHError fails 7 subtests of TestPipes_WriteClickHouseErrors (the retryable ones), and a bare 500 fails all 15;
    • with the .-number case disabled, all 5 .-led rows fail (allowing no _ fails the separator row); with the EXECUTE AS look-through disabled, the 12 EXECUTE AS write rows and the Exec dispatch test fail, and classifying every EXECUTE AS as a write fails the 8 read rows and the Query dispatch test;
    • against an early version of the classifier, 26 TestIsMutation rows fail and TestIsMutation_KeywordNamesInWithList reports 6,066 disagreements (76 writes read as reads, 5,990 reads as writes); skipping only ASCII spaces between INSERT and INTO fails the whitespace test.
  • make ci passed at every commit in this PR's history through the shared queue.

Docs

pipes.mdx has a "Pipes that write" section after the error table. It lists the write verbs the classifier recognizes, says a WITH list may lead only INSERT INTO as a write and that EXECUTE AS is looked through, and says what a write led by any other verb costs (#666): BACKUP and RESTORE return rows, so they are cached and coalesced; UNDROP and MOVE return none, so the call fails after the statement has run, and the SDK may retry it. It says that allowed_roles is a write pipe's only gate, default_role included, that a failed write is not retried automatically, since it may have run (with the SDK's remaining retries: a dropped connection, or a proxy's 502/503/504), and that no write invalidates a read pipe's cached result (#343). Its cached-path wording mentions the bypass. "How a value becomes SQL" now says to write each placeholder bare, never inside quotes. The pipe response in api.md covers X-Cache: BYPASS, and its ClickHouse-errors section and pipe error table cover the write answer, as do sdk/pipes.md, the SDK error reference and configuration.mdx. Four places said /v1/ops/query was the only way to run a non-insert write: the Insert-only note and the /v1/ops/query section in api.md, the two request-flow notes in architecture.md, and the ingest/ bullet in AGENTS.md. Each now also names an operator-authored write pipe. ingest-pipeline.md names write pipes as a mutation path too. settings-directory.mdx says clickhouse.query_timeout bounds write pipes and /v1/ops/query too. The pipes.go and ch_errors.go entries in architecture.md, the AGENTS.md api/ bullet, invariant 13 and Testing Conventions, and the CHANGELOG (Fixed, and the placeholder correction under Security) are updated too. make build-docs passes, links included.

Deliberately left to later PRs

🤖 Generated with Claude Code

https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd

EricAndrechek and others added 3 commits September 25, 2026 04:17
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
…api.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: aa8ac3c2-0697-4d47-af25-136f7c75f485

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.

EricAndrechek and others added 7 commits September 26, 2026 04:58
isMutation missed a write hidden behind a backslash-escaped quote (in
'...', "..." or backticks), a nested block comment, or leading
whitespace outside space, tab, CR and LF (\v, \f, a no-break space, a
byte-order mark and the other Unicode spaces ClickHouse skips). The
missed write went through Query, which ran it and then failed the call,
so a client retrying the 500 wrote again. The same gaps could make a
read look like a write.

The scanners now share one whitespace, comment and quote skipper that
follows ClickHouse's lexer: a backslash or doubled quote escapes the
next byte, block comments nest, and the whitespace set is the one
ClickHouse 26.6 accepts, each character checked against a live server.
The CTE-name lookahead uses the same skipper.

Docs: pipes.mdx says to write each placeholder bare, since a quoted one
lets a string value's own quotes close the template's; the write-pipe
section lists the verbs the classifier recognizes; "only write path"
now reads "only way to define or change a pipe".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd
Brings in the reworked cache snapshot and main at 5004cd2, including
the ClickHouse error classification.

Conflicts:
- internal/api/pipes.go: a read now looks up the cache before taking
  the tenant's pool, and a missing pool answers 503 before a hit is
  served. A write does no lookup, so it branches off before the
  lookup and takes the pool itself (executeWrite). Its error answer is
  unchanged here.
- AGENTS.md, CHANGELOG.md: both sides kept.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd
ClickHouse reads $$...$$ and $tag$...$tag$ as a string, but isMutation
scanned into it: a paren or quote inside hid the INSERT after a WITH
list, so the write went through Query and could run again on a retry,
and a verb inside made a read look like a write. The scanner now skips
a heredoc as ClickHouse's lexer does (tag of letters, digits and _,
matched exactly; unclosed is not a heredoc), and reads $ as part of a
bareword, as ClickHouse does (a$b, set$).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd
A write pipe's failure answered a bare 500, while a read's is classed
by the ClickHouse error. It is now classed the same way (status and
code), but always as retryable:false with no Retry-After, 503 included:
once the statement is sent it may have run, and the SDK retries both a
retryable 5xx and any 503 carrying Retry-After, which would run the
write again. A write refused before it is sent (the tenant on no pool)
keeps its 503 with Retry-After: 30.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd
Review findings, each checked on ClickHouse 26.6.3.62:

- `//` starts a line comment, so a `(` after it hid the INSERT behind a
  WITH list, and a leading one hid the verb.
- ClickHouse reads a string literal in curly single quotes and a quoted
  identifier in curly double quotes, with no escapes inside; a paren in
  one hid a WITH's INSERT, and a verb in one looked like a write.
- A bareword led by `_` (`_delete`, `_set`) was read from its second
  byte, so its tail matched a verb and a read ran through Exec. Words
  now start at any word byte.

A heredoc tag stays letters, digits and `_`: ClickHouse rejects
`$a b$ ... $a b$`.

Docs: the SDK pipes page and error reference say a failed write pipe
is returned on the first attempt, and that a dropped connection is
still retried; configuration.mdx and ingest-pipeline.md name write
pipes; "only write path" becomes "only way to define or change a pipe"
on the SDK pipes page and in the PipesNamespace doc comment.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd
A 502/503/504 from a proxy in front of WaveHouse carries no retryable
field, so the SDK retries it like a dropped connection; name both. The
SDK pipes page keeps .fetch()'s options next to its opening sentence and
moves the retry caveat into its own paragraph.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
isMutation read the leading verb as a run of letters, so `insert_log`
or `insert2` matched INSERT. ClickHouse reads a bareword whole (letters,
digits, `_`, `$`), as the WITH scanner already does; the leading scan
now uses the same skipWord. No valid statement starts with such a word,
so this only aligns the classifier with ClickHouse's lexer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
EricAndrechek and others added 4 commits September 26, 2026 08:52
After a WITH list ClickHouse parses only SELECT, a FROM-first SELECT or
INSERT INTO, yet the scanner took the first word spelled like a statement
keyword for the statement. A name in the list could be spelled like any
keyword, so `WITH 'd' AS desc INSERT ...` ran as a read (and a retried
error wrote again), while `WITH 1 AS set SELECT set` and
`WITH 1 AS x FROM system.one SELECT x` ran as writes and answered `[]`.
A WITH-led statement is now a write exactly when it holds INSERT INTO
outside parentheses.

The cases move to internal/testutil/mutationtest so the integration
suite can check each one, and every keyword as a WITH list's name, against
the pinned ClickHouse's parser (EXPLAIN AST). The classifier is exported as
IsMutation for it. Cases that ClickHouse rejects as syntax errors
(WITH ... DELETE/ALTER/TRUNCATE among them) are marked so, and the check
fails if one starts to parse.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd
- architecture.md: an operator-authored write pipe is the other write
  path besides the admin raw-SQL proxy.
- pipes.mdx: no write invalidates a read pipe's cached result (#343), and
  a write led by a verb the classifier does not know runs as a cached read
  (#666).
- settings-directory.mdx: query_timeout bounds every call on the query
  paths, write pipes and /v1/ops/query included; the HTTP proxy sends no
  max_execution_time. Same for the settings and wire comments.
- api.md: only a read pipe is cached and coalesced.
- ch_errors.go: a write pipe's unavailable or unknown failure is never
  retryable.
- CHANGELOG: the quoted-placeholder correction moves under Security, with
  what to check in existing templates (#662).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd
Brings in the base's docs correction of the cache key terms. No
conflicts.

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
@github-actions github-actions Bot added the area/app Process wiring (internal/app): component build, run, release label Sep 26, 2026
EricAndrechek and others added 2 commits September 26, 2026 12:59
ClickHouse ends a number led by `.` at its digits and exponent, so in
`WITH 1 AS a, .5INSERT INTO t SELECT a` it reads `.5` then INSERT. The
classifier stepped over the `.` and read `5INSERT` as one word, so the
write ran through Query, which ran it and then failed the call.
hasTopLevelInsertInto now reads a `.`-led number as ClickHouse's lexer
does: digits with `_` between two of them, then an optional exponent.

`EXECUTE AS <user> <statement>` runs the statement as that user, and
IsMutation classified it by EXECUTE, so `EXECUTE AS u INSERT ...` also
ran through Query. The prefix is now looked through, for a bare,
quoted or heredoc user and an optional `@host`, and the statement after
it is classified by the same rules. A bare `EXECUTE AS u` switches the
session's user and returns no result set, so it goes through Exec.

The EXPLAIN AST oracle maps an ExecuteAsQuery root by the statement it
runs. The comment on a read's `... AS insert INTO OUTFILE` now says what
happens to it: it is classified as a write and answers [] uncached.

Docs: pipes.mdx states the WITH rule as it is, looks through EXECUTE AS,
and gives UNDROP and MOVE their real consequence (they fail after
running, which the SDK may retry); architecture.md and AGENTS.md no
longer say non-insert mutations must use /v1/ops/query and then name a
second path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd
The Insert-only note said every non-insert mutation must go through
POST /v1/ops/query and then named write pipes as "the one other
route". It now names both paths in the same sentence, as
architecture.md and AGENTS.md do.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd
@EricAndrechek
EricAndrechek merged commit 088a4b1 into feat/cache-snapshot Sep 26, 2026
2 of 3 checks passed
@EricAndrechek
EricAndrechek deleted the fix/mutation-pipes-uncached 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/api HTTP handlers, routing, middleware area/app Process wiring (internal/app): component build, run, release area/docs Documentation, site/, README area/pipes Named query pipes area/sdk TypeScript SDK (clients/ts/) documentation Improvements or additions to documentation go Pull requests that update go code

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant