fix(pipes): run write pipes every call, uncached and uncoalesced - #634
Merged
Merged
Conversation
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
|
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 |
This was referenced Sep 25, 2026
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>
This was referenced Sep 26, 2026
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
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
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.
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) answered200without writing, and N concurrent identical calls became one write. Withcache.backend: redis(#630), that cached[]would answer every instance.PipesHandler.Executenow runsIsMutationon the bound SQL. A write branches off toexecuteWritebefore the cache lookup, so it skips the lookup, the fill and singleflight. It takes the tenant's pool itself (no pool is still a503withRetry-After: 30, before anything is sent), executes on every call and answersX-Cache: BYPASSwithCache-Control: no-store, so an HTTP cache in front of aGETwrite pipe can't answer a repeat either. Reads are unchanged and keep #614's order: cache lookup, then the pool, whose absence answers503before a hit is served. The execute-and-marshal body moved intoPipesHandler.run, which both paths share.A failed write answers with #627's classification, status and
code, but alwaysretryable: falseand with noRetry-After,503 clickhouse.unavailableincluded (writeCHWriteError). Once the statement is sent it may have run, and the TS SDK retries both a5xxwith noretryable: falseand any503that carriesRetry-After.How a pipe is identified as a write
The handler uses
IsMutation, the classifierexecuteCHQueryalready uses to send a statement toExecinstead ofQuery. 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 aWITHlist ClickHouse accepts onlySELECT, a FROM-firstSELECTorINSERT INTO(measured on 26.6.3.62: every other statement verb there is aSYNTAX_ERROR), so aWITH-led statement is a write exactly when it holdsINSERT INTOoutside parentheses. The classifier never tries to tell which other word starts the statement, because aWITHlist'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.5INSERTis.5thenINSERT. AnEXECUTE AS <user>prefix is looked through, and the statement after it is classified by the same rules; a bareEXECUTE AS <user>returns no result set and goes throughExec."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.EXPLAIN,query_kind): that costs a round trip on every call, because the SQL has{{param}}placeholders until it is bound.query_kindinsystem.query_logis 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.IsMutationmisses is worse than uncached: it goes throughconn.Query, which runs it and then fails the call (measured: clickhouse-go v2.48.0 native returnsEOFafter the row is inserted), and a client that retries the5xxwrites again.Classification runs on the bound SQL, the same string
Execreceives. 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:
/v1/ops/query, the HTTP proxy that does not classify statements. A write pipe needs the same extraction. One fix should cover both, so I widened #394 to include the pipe surface.InvalidateTenanton every write pipe would work, but it would empty the tenant's whole cache on every audit-log insert. That is a policy decision, not part of this bug fix.Write-pipe rows also don't reach
/v1/streamsubscribers, because only/v1/ingestpublishes to the queue. That is the same root cause as #362. The docs now say both.Tests
TestPipesHandler_Execute_MutationRunsEveryCall:INSERT,WITH … INSERTandALTER … DELETEpipes, each called 3 times over a realLocalCache. Result: 3Execcalls, 0Querycalls,X-Cache: BYPASS,Cache-Control: no-storeand[]every time.TestPipesHandler_Execute_ConcurrentMutationsNotCoalesced: undersynctest, 3 identical calls held insideExectogether. 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 andcode,retryable: false, noRetry-After, and exactly 1Exec.TestClickHouseRoutes_NoPoolIs503/write_pipe_execute: no pool is a503withRetry-After: 30.TestIsMutationruns the 152 cases ininternal/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 asinsert_logread whole; aWITHlist'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 toINSERT;EXECUTE ASahead of writes and reads, with a bare, quoted, backticked, curly-quoted or heredoc user and an@host).TestExecuteCHQuery_*sendsEXECUTE AS … INSERTtoExecandEXECUTE AS … SELECTtoQuery.TestIsMutation_ClickHouseWhitespacecovers every whitespace character ClickHouse accepts in four positions.TestIsMutation_AgreesWithClickHouseParser(integration): each case goes throughEXPLAIN ASTon the pinned ClickHouse, and its root node must map to the case's answer; anExecuteAsQueryroot is mapped by the statement it runs, and a bare one counts as a write. The 15 cases ClickHouse rejects (among themWITH … DELETE,WITH … ALTER,WITH … TRUNCATEandREPLACE INTO) are markedUnparsedand must still fail withSYNTAX_ERROR, so a ClickHouse that starts accepting one fails the test.TestIsMutation_KeywordNamesInWithList(integration): every word insystem.keywords(498) in 12WITHshapes, ahead ofSELECT, FROM-firstSELECTandINSERT INTO. That is 17,928 statements, of which 17,860 parse, in about 2.5 s under-race, andIsMutationmust agree with the parser on each.if false && IsMutation(sql)) fails both write tests, while the read test still passes;TestIsMutationrows (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;writeCHErrorfails 7 subtests ofTestPipes_WriteClickHouseErrors(the retryable ones), and a bare500fails all 15;.-number case disabled, all 5.-led rows fail (allowing no_fails the separator row); with theEXECUTE ASlook-through disabled, the 12EXECUTE ASwrite rows and theExecdispatch test fail, and classifying everyEXECUTE ASas a write fails the 8 read rows and theQuerydispatch test;TestIsMutationrows fail andTestIsMutation_KeywordNamesInWithListreports 6,066 disagreements (76 writes read as reads, 5,990 reads as writes); skipping only ASCII spaces betweenINSERTandINTOfails the whitespace test.make cipassed at every commit in this PR's history through the shared queue.Docs
pipes.mdxhas a "Pipes that write" section after the error table. It lists the write verbs the classifier recognizes, says aWITHlist may lead onlyINSERT INTOas a write and thatEXECUTE ASis looked through, and says what a write led by any other verb costs (#666):BACKUPandRESTOREreturn rows, so they are cached and coalesced;UNDROPandMOVEreturn none, so the call fails after the statement has run, and the SDK may retry it. It says thatallowed_rolesis a write pipe's only gate,default_roleincluded, 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's502/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 inapi.mdcoversX-Cache: BYPASS, and its ClickHouse-errors section and pipe error table cover the write answer, as dosdk/pipes.md, the SDK error reference andconfiguration.mdx. Four places said/v1/ops/querywas the only way to run a non-insert write: the Insert-only note and the/v1/ops/querysection inapi.md, the two request-flow notes inarchitecture.md, and theingest/bullet in AGENTS.md. Each now also names an operator-authored write pipe.ingest-pipeline.mdnames write pipes as a mutation path too.settings-directory.mdxsaysclickhouse.query_timeoutbounds write pipes and/v1/ops/querytoo. Thepipes.goandch_errors.goentries inarchitecture.md, the AGENTS.mdapi/bullet, invariant 13 and Testing Conventions, and the CHANGELOG (Fixed, and the placeholder correction under Security) are updated too.make build-docspasses, links included.Deliberately left to later PRs
/v1/stream(SSE/live-query yields no events for tables populated by a ClickHouse MATERIALIZED VIEW (only ingest-API writes republish to NATS) #362).BindParams(security(pipes): a quoted placeholder lets a value break out of its literal #662).405to aGETon a write pipe (bug(pipes): a write pipe answers GET, which proxies and clients replay #663).BACKUP,RESTORE,UNDROPandMOVEpipes as writes (bug(pipes): a BACKUP/RESTORE/UNDROP pipe runs as a read and is cached #666).🤖 Generated with Claude Code
https://claude.ai/code/session_017aS7rLrH1RKkUMem7X4ckd