Repository navigation
feat(typelayer)!: validate, coerce and filter through chtypes - #712
Draft
EricAndrechek wants to merge 145 commits into
Draft
EricAndrechek wants to merge 145 commits into
EricAndrechek wants to merge 145 commits into
Conversation
Pin the revision-6 26.6 artifact in chtypes.lock and fetch it with scripts/fetch-chtypes.sh. Every build is cgo: a glibc builder and a distroless/cc runtime that bakes the artifact, native-runner release builds for linux/amd64, linux/arm64 and darwin/arm64, and the setup-env action caches the artifact for the unit, integration and e2e jobs. golangci-lint moves to v2.13.2 (the next go directive needs it), which retires four nolint directives it no longer needs. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
Ports the reference migration's documentation onto main's current multi-tenant, multi-role text. Ingest validation and row-level security run through one process-wide chtypes engine with a per-tenant table set, loaded only by api-role processes; a tenant with no artifact for its ClickHouse line, or a server time zone that differs from the zone that line was opened with, is refused on its own. Error bodies keep the string code class and add exception_code. /v1/query and pipes are rendered by ClickHouse. Platforms narrow to linux/amd64, linux/arm64 and darwin/arm64 on glibc. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
Export the resolved row-filter predicate (Predicate) and hand it out
through ResolvedPermissions.Predicates, the same resolution the query
path renders, so the stream can evaluate it with ClickHouse's own engine.
ResolvedSelect.WhereSQL(colType) renders the clause with an integer
column's claims bound through the strict round-trip cast, so a claim that
does not fit the column matches nothing instead of wrapping. WhereClause,
WhereParams and RowVisible stay until their callers move.
chsql gains the shared {p:String} encoding (EscapeStringParam), the
strict cast (IntegerType, StrictInt, IntParam), and QuoteIdent now
escapes NUL and the control characters exactly as ClickHouse's
backQuote does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
A SchemaRegistry now runs OnRefresh hooks after each successful Refresh publishes its schemas and before it marks the registry loaded, so a reader that sees Loaded() also sees what the hooks built from the first refresh. Overlapping refreshes run their publish and hooks as one step, in publish order. The server's default zone name is stored with the version and exposed as ServerTimezone. A refresh that finds tables without a CREATE statement (the two system scans are not one snapshot) warns once, naming them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
Add internal/typelayer, the one package that calls the chtypes SDK (go/v0.4.0, which needs go 1.27). One process-wide Engine holds one lazy registry and every tenant's compiled tables: Bind(tenant, version, zone, tables) compiles a tenant's set, Table and RoleTable hand out read-locked handles, and Forget drops a tenant with its handles closed in the background so a caller holding a reload lock never waits on a request. A missing artifact for a tenant's server line, a server zone that differs from the zone its line was opened with in this process, or a table that does not compile makes that tenant (or that table) Unavailable; every other tenant keeps answering. A table compiles one handle at Bind and grows its pool only when every handle is busy, up to min(GOMAXPROCS, 8); role shapes keep one. A rebind closes the old role projections after releasing the base table, so a request holding a projection can still look the base table up. SkipWithoutArtifact lets any package's tests skip without the artifact, or fail under WAVEHOUSE_TEST_REQUIRE_CHTYPES=1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
…face
The structured query and named pipes now read through ClickHouse's HTTP
interface and serve ClickHouse's own FORMAT JSONEachRow rendering, framed
as a JSON array, instead of scanning rows through the native driver and
re-marshalling them in Go.
- Per-tenant Target and the TLS-aware client cache the ops proxy uses; a
refusal is a chconn.HTTPError and a failure on the way wraps with %w, so
the error classes keep working. An oversized response is typed and
answers clickhouse.response_too_large.
- Every read pins the rendering settings and sends wait_end_of_query,
http_write_exception_in_output_format=0, max_execution_time (the tighter
of the role's cap and query_timeout), cancellation on client close and
readonly=2. A READONLY refusal of a read is clickhouse.misconfigured.
- Write pipes run without readonly=2 and keep BYPASS, no-store and the
never-retryable error answer. IsMutation stays, in sql_classify.go.
- Reads share one connection cap per tenant (MaxConns, default 100).
- The builder binds named {pN:String} parameters, an in list as one
Array(String), refuses a null filter value and a value too large for
the HTTP interface with a 400, and rewrites an RFC 3339 filter value
only on a DateTime or DateTime64 column.
- The cache key opens with a rendering marker, so builds that render
differently never share a Redis entry.
BREAKING CHANGE: /v1/query and pipe responses carry ClickHouse's
rendering: keys in SELECT order, DateTime as `YYYY-MM-DD hh:mm:ss[.fff]`
in the column's zone, decimals as numbers, NaN and Inf as null. A null
filter value is a 400.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
…ished with ParseRow accepted only the generation's full wire column list, so a row published by a role that may write some columns could never be read. It now accepts any duplicate-free subset of the wire columns, in any order, and names that list on the parse (chtypes.WithColumns), so every unlisted column holds the DEFAULT the server would store for it, computed with the listed values in scope. A list naming an unknown or repeated column is still ErrColumnsDrift. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
The structured query's row filter now comes from WhereSQL with the
discovered column types: a policy claim on an integer column (any width,
Nullable or LowCardinality) binds as one {pN:String} parameter compared
through the strict round-trip cast, so a value that is not the canonical
spelling of an in-range integer matches nothing instead of wrapping.
Every other value keeps the plain {pN:String} binding, encoded by
chsql.EscapeStringParam, the same encoding the type layer's row filters
use.
The e2e suite pins the rendering of a Decimal and a DateTime64, RFC 3339
filter values on DateTime and DateTime64 columns, a timestamp read back
from /v1/query filtering as it is, and the 400 for a null filter value.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
clickhouse.chtypes_registry (WH_CHTYPES_REGISTRY) names the chtypes artifact directory the API role's type layer searches first; empty keeps the SDK's own search path. It is a bound key, so boot's refusal of unbound WH_* variables lets it through. The ingest worker takes its parsing settings from typelayer.InsertSettings, the map the API judges rows under, and pins async_insert=0 so a message is acked only once its row is stored. Nothing else in the worker changes; its test fixture builds rows by hand instead of through EncodeCompactRow. The dev and quickstart ClickHouse move to 26.8.15.10. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
The hub's row filter is now decided by ClickHouse's own parser and expression engine through the tenant's compiled schema, instead of a Go re-implementation of ClickHouse's comparisons over a name-keyed decode. - RowEvaluator.Prepare(tenant, table, columns, row) parses each event once; the returned view answers per subscriber. NewRowEvaluator(engine) is the production evaluator; an unwired hub or a nil engine withholds every row of a row-filtered role instead of delivering it. - Rows published with the inserting role's narrower column list are parsed under that list and evaluated, in any column order. A filter on a column the event does not carry withholds the row for that subscriber, since the stored row's DEFAULT is computed again at insert. - wavehouse_sse_rows_withheld_total gains a reason label: filter, error, decline, unavailable or drift. - The policy read stays per event during replay; the schema frame, Prune and tenant topics are unchanged. - The SSE end-to-end test expects ClickHouse's DateTime64 rendering. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
Ingest hands the request body to the tenant's chtypes type layer in one parse (Table.IngestWith through the role's projection) instead of decoding, validating, checking, injecting and re-encoding records in Go. The verdicts feed the existing windowed dedupe (reserve, publish, commit, release) and the envelope carries ClickHouse's exported row with the role's wire columns. - content_type.go (was record_reader.go): text/csv and text/tab-separated-values, with RFC 4180's header parameter three ways (present, absent, auto-detect); any other header value is a 415. - ingest_framing.go: depth-1 comma reframing of a compact JSON array so one bad record does not cost its siblings, and the dedupe id read by position from the exported row. A null id cell is missing, like an absent one. - Error bodies keep the string code class. ClickHouse's numeric code travels as exception_code: per record, and on a whole-request header refusal alongside code clickhouse.rejected. - A tenant or table the type layer cannot judge is a 503 with Retry-After: 5, decided before the body is read, with a generic body and the cause in the log. - Column policy is the role's compiled schema, so a denied column is ClickHouse's 117 (400) instead of a gateway 403; parse errors now precede check errors. - The RecordValidator/InsertChecker seams are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
The SDK's InsertRecordResult gains exception_code, ClickHouse's numeric error code on a per-record parser refusal; the string code class is unchanged. The ingest, NDJSON, DLQ and batching suites now assert the type layer's behaviour: CSV/TSV with the header parameter, a compact JSON array that keeps its good records, a denied column as 117, a header refusal as clickhouse.rejected with exception_code 117, an _in check judged on the table default, and an unparseable row refused at ingest so the DLQ never sees it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
An API-role process opens one typelayer.Engine at boot (the clickhouse.chtypes_registry directory, else the SDK's search path) and refuses to start without an artifact; an ingest-only or sweeper-only process never opens it and boots with none installed. Each tenant's schema registry binds the tenant from every successful refresh, attached before its first one so a loaded registry is a bound one, and a registry a reload retires forgets it without waiting on anything. Only the tenant's current registry binds it: a refresh still in flight when its registry was retired is dropped, and one already binding forgets again afterwards, so a tenant a reload moved never keeps the previous database's tables and a removed tenant's do not come back. The engine is released after schema discovery, after the HTTP drain. The ingest handler judges with the engine, the stream hub evaluates row filters with stream.NewRowEvaluator over it, and pipes and structured queries cap their HTTP connections at the tenant's max_open_conns. Tests that boot the api role skip without the artifact, or fail under WAVEHOUSE_TEST_REQUIRE_CHTYPES=1; the test settings close the HTTP port too, so a ClickHouse on the developer's :8123 cannot answer them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
The 26.6 line gets no further chtypes builds, so the lock moves to the 26.8 LTS line: 26.8.15.10-lts, build b1790845279, on darwin-arm64, linux-amd64 and linux-arm64, written by the v0.5.1 CLI as lock schema 2 (every consumer of the lock is on v0.5.1). scripts/fetch-chtypes.sh fetches line 26.8 with the same SDK version as go.mod, and CI and the e2e orchestrator pin clickhouse/clickhouse-server:26.8.15.10, the patch the artifact is built from. ABI revision 6 and the abi6 cache path are unchanged. The 26.8 build fills a skipped row's VerdictCode/VerdictErr, reports RowsPassed 0 for a rejected batch and sets ExportDeclined on a zero-row refusal, where every 26.6 build does not. The type layer already gates on Outcome and reads ErrCode/ErrMsg, which are right on both; the test that pinned the old RowsPassed now pins only what Ingest must do, and the comments say which builds behave which way. TestNewEngine_OpensNoLibraryAtConstruction now shadows the test line itself and releases a table it did not expect to get, so a passing lookup fails the test instead of deadlocking Close. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
chtypes.Timezone is superseded in SDK v0.5.1, and a direct write races the SDK's own read when another library opens. openLine now sets the zone with SetDefaultTimezone under the lock every open takes, so the zone an open reads is the one set for it. WithTimezone does not fit one registry built at boot, before any server has reported its zone, that then opens each line in the zone of its first tenant. The zone record is keyed on the opened library's path, the unit the SDK initialises once per process. A second registry that asks for a line already open in another zone gets the SDK's own, untyped refusal; it is recognised from the record and reported as the same per-tenant cause, with the SDK's words kept. The registry is asked for the server's line, as v0.4.0 resolved it, so every tenant on a line shares one library whichever patch its server runs. Which artifact answers a tenant is logged once each time the tenant's library changes, as a warning when the server runs another patch. Anything the SDK would print on stderr goes to the process log through its Progress writer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
A table with a LowCardinality(UInt64) column exists on a server only because its CREATE passed allow_suspicious_low_cardinality_types, but the type layer compiled it without that gate, so chtypes refused every record with code 455 and every filter over the table declined. A FixedString wider than 256 and a Variant of similar types failed the same way with code 44. The compile profile now carries the ten type gates that the 25.8, 26.6 and 26.8 artifacts all accept. Every table compiled here already exists on the server, so admitting its types changes no verdict a server would give. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
Policy-driven tests now run against the wired app the way an operator drives it: withPolicy writes roles.json and policies.json into the settings directory and reloads it through the ops route, and bearer mints a token for the role under the app's JWT secret. default_role stays the admin role, so the rest of the suite is unchanged. Ported onto it: the stream-vs-query row-filter differential, every query now through the production /v1/query as its own role and every stream verdict from the hub's evaluator over the app's own type layer, with a check that the query side admits something; CSV, TSV and their WithNames forms landing in ClickHouse; a compact array with one bad record; an unknown header refused as 400 with exception_code 117; the published row being the stored row; filter values round-tripping both bindings; the type-rendering pin; a denied column refused per record with 117; an injected check column; an _in check judged on the table default; an integer claim that does not fit refused. The resource-cap test runs through the same harness, and a role's time cap is pinned through /v1/query against a slow view instead of through the native driver, which no read path uses now. Dropped with their subject: the Go row-filter narrowing and timestamp canonicalization differentials. The identifier fixtures quote names the way ClickHouse's backQuote does, control characters included. The suite's ClickHouse moves to 26.8.15.10. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
Deployment's chtypes section now explains what a fetch does and that a cached line is never updated at runtime, that integrity comes from chtypes' signature verification rather than a lock, mirrors and pull-through proxies, cache mounts for docker run, compose and Kubernetes (and --read-only), the bundled CLI, air-gapped deployments, and two chtypes 1.0.2 limitations until 1.0.4: a cache holding a higher line answers a lower one (Wave-RF/chtypes#481; fill caches in ascending order) and concurrent installs into one cache are unsafe (Wave-RF/chtypes#482; prefetch a shared cache once). Wave-RF/chtypes#456 is no longer described as open. The development guide, README, CONTRIBUTING, SECURITY, AGENTS, the architecture page, the workflows README and the changelog follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…487 A cached line is never refreshed by a bind, so the deployment guide now says how to move to a newer build (the bundled CLI, then a restart), that a stale mirror keeps clients on an older build, what happens with no cache volume, why a filled cache never belongs in an image layer, and how to make a host-fetched cache readable. checkLayout's refusal cites Wave-RF/chtypes#486, the upstream mode that would retire it, and `chtypes verify` is no longer offered as a readability check. The workflows README records why CI warms up with a plain fetch, not --frozen (Wave-RF/chtypes#487). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ger holds the zone Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ouse does Ingest parsed with ClickHouse's error recovery on (input_format_allow_errors_ratio=1) to give every record its own verdict. The reader recovers by resuming at the next line, which can be inside the same record or past the next one (a short UUID reads 36 bytes ahead), so records were lost behind an ok, fragments were published as rows nobody sent, and errors landed on the wrong record: 25 of 76 measured bodies. Recovery is now off, as on a default INSERT. The first record ClickHouse cannot read refuses the whole request: 400 clickhouse.rejected with its exception_code and "record N: " leading the message, nothing published. Per-record answers remain for policy check clauses (403) and dedupe ids once the body parses. A body chtypes cannot answer for at all is one 422. Per-record type errors may return through Wave-RF/chtypes#497. Removed with nothing left to police: the record-count guard (records.go, Batch.Miscount) and the JSON array re-framing; ClickHouse's reader frames and refuses malformed arrays itself. The 76 bodies are permanent tests at the IngestWith and HTTP levels (typelayertest.RecoveryBodies). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
chtypes 1.0.4 (Wave-RF/chtypes#481, #482) answers a request only with a build within the requested line, refuses a library outside it, and makes concurrent installs into one cache safe. Remove the lib.Minor guard and its tests, add a test that a wrong-line cache never serves a tenant, and drop the matching known limitations; prefetch is now optional. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # AGENTS.md # CHANGELOG.md # docs/src/content/docs/architecture.md # docs/src/content/docs/deployment.md
…d of forcing synchronous inserts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#480 figures, fetch stalls) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The batch INSERT keeps the server's async_insert with wait_for_async_insert=1; each row of row-by-row isolation now sets async_insert=0. An async flush merges concurrent INSERTs of the same statement into one block, and a CHECK constraint one row violates fails every INSERT in it. Measured on 26.8.15.10 with two workers writing one table, one batch all good and one with a violating row: both bulk INSERTs failed, and with async isolation the good row isolated in step with the bad one was parked on the DLQ too. Isolated synchronously, every good row is stored and only the bad one is parked. Async isolation also paid the flush wait per row: a 500-row batch with one bad row took 29 s, against 0.9 s now. Adds integration tests for one batch with a violating row and for two concurrent writers to one table, under the server's async_insert default; the second fails without this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, changelog framing) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…able cause - The pre-seeded entry test used a non-hex name chtypes 1.1 ignores; use a 64-hex name. - Pin the ErrCacheUnusable branch of fetchCause in a test, and cover unusable caches in its and openLine's comments. - The explicit-dir check now refuses only on fs.ErrNotExist; other stat errors fall through to strict mode's CHTYPES_CACHE_UNUSABLE (new test under a mode-000 parent). - Docs: give the run-as-owner advice a reason that is still true, and split the missing-directory case out of the CHTYPES_CACHE_UNUSABLE sentence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Library build 20261006.170903 routes overlapping calls on one schema to separate internal compiles, so a shared handle now scales like a handle per goroutine (Wave-RF/chtypes#480, closed). Replace the "not yet in a production build" wording with upstream's published figures, and note that a cache holding an earlier build keeps it until a newer one is fetched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ording) Quote upstream's published 4x-6.5x for shared-handle scaling before library build 20261006.170903 instead of a Go SDK 1.0.2 figure, and say a refetched build takes a restart. Give the same-user advice for a shared cache its current reason, say WaveHouse sets StrictCache in code rather than the environment variable, and add the unusable-cache cause to the tenant-unavailable list and the failed-fetch causes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 6, 2026
…fetch failures offline chtypes go v1.1.0 exposes no cache-root API, so the layout knowledge is isolated in typelayer.cachePaths. Fetch-failure tests use Config.Offline (FetchOptions.Offline) with an empty or default cache and assert CHTYPES_ARTIFACT_MISSING. fetch-chtypes.sh gains --where and the setup-env cache step uses it instead of a hard-coded path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in the offline tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every case now points HOME at a temp dir, so a cachePaths that falls through to the default root plants its mode-000 entry in a temp dir, not in the developer's real chtypes cache (where strict mode would then refuse boot). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…utside the temp dirs A cachePaths regression that ignored $HOME would otherwise plant the case's unreadable entry in the real cache; require the resolved root to sit under os.TempDir() before anything is written. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cache path was introduced by this unreleased change. On chtypes go v1.1.0 CHTYPES_CACHE names the layout directory itself, and a later SDK appends its own subroot under any cache root, so the version-neutral /var/cache/chtypes replaces /var/cache/chtypes/v1 in the images, compose file and docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 7, 2026
Open
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
WaveHouse stops modelling ClickHouse itself. Validation, coercion, DEFAULT substitution, row-level security and insert checks now run through chtypes (
github.com/wave-rf/chtypes/gov1.1.0, ABI v1). chtypes is a cgodlopenof a per-ClickHouse-line library that runs the server's own parser and analyzer in-process. Ingest hands the request body to ClickHouse's own reader and refuses it the way anINSERTwould./v1/queryand pipes ask ClickHouse to render their results. The Go that approximated ClickHouse is deleted.This redoes #589 on current
main, which is now multi-tenant. #589, on the oldmain, is closed as superseded by this PR.Updated since the last revision
For reviewers who read the previous revision (head 30b9a07, chtypes Go v1.0.2):
CHTYPES_CACHE_UNUSABLE, so WaveHouse's own cache-layout check is deleted. Caches written by one user are readable by another. WaveHouse's copy of chtypes' cache-path resolution lives in one function, pinned to the SDK by a test, until chtypes exposes it (Go API for the resolved cache root and system search dirs (what chtypes where prints) chtypes#530); tests force a failed fetch with the SDK's offline mode rather than a dead registry.chtypesCLI. Autofetch is on by default and fetches a ClickHouse line's artifact the first time a tenant on that line binds.chtypes.lockis gone; integrity comes from chtypes' signature verification, and CI uses the latest build of the line pinned ininternal/chversion. New keys:WH_CHTYPES_AUTOFETCH,WH_CHTYPES_CACHE,WH_CHTYPES_ARTIFACTS_URL. The docs cover cache mounts, mirrors and the CLI.400 clickhouse.rejected,record N: …). Why: ClickHouse's error-tolerant mode was measured to lose records silently and publish rows no submitted record holds (25 of 76 probe bodies went wrong), so the PR no longer tries to keep a per-record verdict through it. The record-count guard and the UUID limitation are removed with it. Per-record type errors may return through Isolated per-record parsing: frame records the way ClickHouse does, then one verdict per record (deferred to ABI v3+) chtypes#497."nan"/"inf"/"-inf"on/v1/queryand pipes too, matching ingest and the stream. This closes bug(api): NaN/Inf render as null on /v1/query but as "nan"/"inf" strings on the stream #722. The cache marker moves toJSONEachRow/4, and TS codegen types Float columns asnumber | "nan" | "inf" | "-inf".wait_for_async_insert=1. Row-by-row isolation retries stay synchronous, because two writers on one table can share an async flush.mainon AWS Graviton4 (below).mainmerged in (feat(sdk): add codegen --tenant and --operator-key flags #729, codegen--tenantand--operator-key).Fixes #387 (timestamp filters and
time_rangeshifted on non-UTC columns), fixes #516 (the TS SDK's client-side stream filters compared timestamps as text), fixes #547 (the dedupe walkthrough posted a columnclickslacks), fixes #558 (discovery now warns on tables without DDL; the second half is moot because the type layer builds its table declarations fromsystem.columnsand never readscreate_table_query), fixes #722 (NaN/Inf rendered asnullon/v1/querybut as strings on the stream).Breaking, by design:
registry.wavehouse.dev(or your mirror), so a deployment now depends on that registry or a mirror until each line is cached, and the first tenant binds a few seconds late. Air-gapped deployments setWH_CHTYPES_AUTOFETCH=falseand install the artifact into the cache beforehand. Mount a volume at/var/cache/chtypesor every new container fetches again.apirole refuses to start when autofetch is off and no artifact for its platform is installed, when an explicitly setWH_CHTYPES_CACHEdoes not exist, or when the cache exists but cannot be read.chtypes.lockis removed.400 clickhouse.rejected,record N: …, nothing published, earlier records included), wheremainreported it per record. Policy checks stay per-record (403)."nan"/"inf"/"-inf"on/v1/queryand pipes, wheremainfailed the request. Cached/v1/queryand pipe entries from before the upgrade are not reused (new rendering marker).number | "nan" | "inf" | "-inf", where it emittednumber. Regenerate and fix call sites that do arithmetic on them./v1/queryand pipe results come out in SELECT order with Decimal as a number, and responses over 64 MiB fail (502); SSE/wire timestamps are rendered by ClickHouse at the column's scale (table below).wait_for_async_insert=1and leavesasync_insertat the server's setting; only isolation retries are synchronous.What changed
Type layer (
internal/typelayer). One process-wide engine holds one lazily opened chtypes registry, set up once with the image zone before the first open; the zone is recorded only after that open succeeds. Each tenant gets its own table set, bound from that tenant's schema refresh before the registry reports loaded and forgotten when the tenant is removed or moves.DateTimeand aDEFAULT,MATERIALIZED,ALIASorEPHEMERALexpression is unavailable for such a tenant (Per-schema server profile: the server's zone and input-affecting settings, honoured by functions, DEFAULTs, literals and parsing (1.0.x, high priority) chtypes#419); a role's_eqcheck on a zone-lessDateTimeloses its auto-inject.internal/typelayerasks for a tenant's line on its first bind. The fetch is signature-verified by chtypes on every download: a signed statement whose subject is the layer digest, checked against the SDK's embedded release key. A mirror can withhold or delay a build but cannot substitute bytes chtypes did not sign. Sharing a cache between processes is safe (rename-first installs); they must run as the same user.api-role processes load chtypes. Ingest-worker and sweeper processes need no artifact.LowCardinality(<integer>)andVariantcolumns ingest and filter. Tables compile from a generatedCREATE TABLE … ENGINE = MergeTree ORDER BY tuple(), because chtypes models only some engines.Ingest. The request body goes to chtypes as-is: one
Rowscall per body, exportingJSONCompactEachRow, with the role'scheckclauses as a row filter. ClickHouse's error-tolerant mode is off.400,code: "clickhouse.rejected", ClickHouse'sexception_codeand an errorrecord N: <message>(1-based, a header line not counted). Nothing is published. This is what anINSERTdoes.okat its index (a quoted multi-line CSV field, a nested object on its own NDJSON line, a raw line feed inside a JSON string), 19 published a row that no submitted record holds (a fragment of a record parsed as a row), and the rest misattributed an error to a valid record, so that a retry duplicates it. Every published row was exactly what a ClickHouse server stores from the same body with error tolerance on, so these are ClickHouse's behaviour, not a WaveHouse bug (measured, 76 of 76). Removing the recovery removes the class.checkclause, or lacking a required dedupe id, is reported inresults(403/400per record) and does not block the rest, once the body has parsed.generateSnowflakeID(),rowNumberInAllBlocks()ortimezone()column, or a tenant whose zone chtypes cannot load after another tenant set the image zone, gets one422for the request and nothing is published.output_format_json_quote_denormals=1pinned.Content-Typedeclares the format: the JSON family,text/csvandtext/tab-separated-values, with RFC 4180'sheaderparameter mapped three ways:present→CSVWithNames/TSVWithNames,absent→ positional with header detection off, no parameter → ClickHouse's default detection.nullor empty id cell counts as missing.Ingest worker. The worker's bulk INSERT leaves
async_insertat the server's setting and setswait_for_async_insert=1, so a message is acked only after its rows are stored even if a profile sets it to 0, and the server can merge many small worker batches into fewer parts. Isolating a rejected batch row by row stays synchronous (async_insert=0): an async flush merges concurrent INSERTs of the same statement, so aCHECKviolation by one row fails every INSERT in the flush. Measured on 26.8 with two workers on one table, async isolation also parked the good row isolated in step with the bad one; synchronous isolation stored every good row and parked only the bad one, and a 500-row batch with one bad row took 29 s to isolate asynchronously against under 1 s synchronously.Errors. Bodies keep main's string
codeclass. ClickHouse's numeric code travels asexception_code, on a whole-request parser refusal (code: "clickhouse.rejected").Query path.
/v1/queryand pipes go over HTTP to the tenant's target withFORMAT JSONEachRow. Failures are typedchconn.HTTPErrors, so the per-class error mapping is unchanged.wait_end_of_query=1http_write_exception_in_output_format=0max_execution_timecancel_http_readonly_queries_on_client_close=1readonly=2date_time_output_format=isoso DateTime is RFC 3339 UTC on every surface, andoutput_format_json_quote_denormals=1so Float NaN and ±Inf are the strings"nan","inf"and"-inf"as on ingest and the streamIsMutationrouting; they are never cached.JSONEachRow/4, so old and new builds never share an entry.max_open_conns.Timestamp filters. Before this change, an RFC 3339 filter value and the
time_rangebounds were rewritten in Go to zone-less UTC text, and ClickHouse read that text in the column's zone. On any non-UTC column, results were off by the offset. Measured with the server in Europe/Berlin: aDateTime('Asia/Tokyo')column matched nothing, and aDateTime64(3,'America/New_York')column matched the row 4 hours late.col OP parseDateTime64BestEffort({p:String}, 8[, '<column zone>']).inlists. They travel as ClickHouse external-data tables in a multipart body instead of as URL parameters, which ClickHouse caps at about 128 KiB per value. A 1.26 MiB, 60,000-element list goes through, and the primary key is still used throughIN (SELECT …).Integer claims. A claim bound as
{p:String}and compared with an integer column wraps modulo 2^64 (at their own width for 128/256-bit columns), on the server and in chtypes alike. Row filters, insert checks and the/v1/querypolicy predicate now compare integer columns against a round-trip strict cast:if(toString(accurateCastOrNull({p:String}, 'T')) = {p:String}, accurateCastOrNull({p:String}, 'T'), NULL)007,+5,1.0or ≥ 2^64 matches no row and fails an insert check with403.Stream. Row filters run through chtypes over the published bytes.
/v1/queryexcludes.filter,error,decline,unavailable,drift).TypeScript SDK and codegen. Generated types for
Float32andFloat64columns arenumber | "nan" | "inf" | "-inf", matching what the wire carries.main's--tenantand--operator-keycodegen flags (#729) are merged.Build, CI and release.
CGO_ENABLED=1everywhere,go 1.27, golangci-lint v2.13.2.distroless/ccruntime. The images carry thechtypesCLI at/app/chtypesand an empty cache at/var/cache/chtypes, and no artifact; they declare noVOLUMEfor the cache.internal/chversion). CI fetches the latest build of that line (scripts/fetch-chtypes.sh) and caches it; bumping the pin moves the artifact line with it.chtypes.lockis deleted.Deleted:
internal/discovery/{validation,timestamp}.go).internal/policy/{rowfilter,numeric}.go,LiteralValue,CanonicalNumericLiteral,RowVisible).internal/ingest/compact.go).chtypes.lockand the baked-in artifact layer.CGO_ENABLED=0path.Size, measured. Production Go (non-test
.gooutsidetests/) goes from 35,889 to 39,318. The migration trades hand-written ClickHouse modelling for a wrapper around the real thing, and adds per-tenant lifecycle, artifact fetching, the HTTP reader and external-datainlists; it does not shrink the codebase lines.Ingest benchmark,
mainvs this PR (measured)AWS Graviton4 (arm64), ClickHouse 26.8.15.10, concurrency 1 to 32, every point 3 × 60 s after a 10 s warm-up with the build order rotated, 988,113 requests, 0 non-200 and 0 partial batches (this PR measured at head 30b9a07; the later commits change the worker's insert settings and the error path, not the per-request parse). amd64 and stream fan-out were not run.
SyncAlways): both builds are fsync-bound at about 8.5k rows/s from concurrency 8. At concurrency 1 this PR does 48 req/s againstmain's 62. The extra WaveHouse CPU per row (1.27 to 1.40×) shows up as throughput or latency only at concurrency ≤ 4.main's throughput (0.62× at concurrency 1, 0.75× at 8, 0.86× at 32) at 1.36 to 1.63× the CPU per row.main. Per call that is about 0.9 ms fixed plus about 52 µs per row (Per-row cost of a batch preview: ~52 µs/row inside the library on a realistic ingest shape (measured) chtypes#504).20261006.170903it scales about like a handle per goroutine (upstream measured 7.6× against 7.6× on arm64; One shared handle should scale across threads like N separate handles chtypes#480, closed).main's throughput at concurrency 1 / 8 / 32, up from 0.62× / 0.75× / 0.86×. Only part of that gain is the library build (about −5 to −6 % CPU per row at concurrency ≥ 8, isolated on one binary); the rest is the worker's async-insert change.main's 15 (inferred: concurrent cgo calls each hold a thread).main's at every concurrency. On the default durable setup the forced-synchronous insert cost nothing, so only the tmpfs figures moved.Documented contract changes (please review these first)
403 column "x" not allowed for insert400,exception_code117 (does not reveal whether the column exists)400,exception_code117*WithNames) and a DEFAULT reads it; otherwise400/117 (positional CSV/TSV carry wire columns only)_eqcheck value supplied for a column the role may not write403500retryable:false, noRetry-After; cause logged400 clickhouse.rejected,record N: <ClickHouse message>, ClickHouse'sexception_code, nothing published (per-record answers may return through Wave-RF/chtypes#497)]400(code 27, norecord N:prefix)[a,,b], leading or trailing comma)400, code 27, named as the record it stands in forcheckclause fails, or that lacks a required dedupe idresults, the rest of the body publishedgenerateSnowflakeID(),rowNumberInAllBlocks()ortimezone()column; tenant zone chtypes cannot load)422 validation engine declined: …for the request, nothing published403on an insert checktext/csv/text/tab-separated-valuesingest415headermapping400with ClickHouse's code at the edgeDateTime64column503,Retry-After: 5, generic body; cause in logs only/v1/queryand pipe renderingjson.Marshal: alphabetical keys, Decimal as string, NaN/Inf fail the requestJSONEachRow: SELECT order, Decimal as number, NaN/Inf as the strings"nan"/"inf"/"-inf"(as on ingest and the stream); DateTime stays RFC 3339 UTC (date_time_output_format=iso), now at the column's scale (.000Z, not trimmed)/v1/queryand pipe resultsJSONEachRow/4; entries from other builds are never readFloat32/Float64numbernumber | "nan" | "inf" | "-inf"/v1/queryand pipe response over 64 MiB502 clickhouse.response_too_large/v1/querynull filter value400/v1/querytimestamp filter /time_rangeon a non-UTC column/v1/queryinelement that isn't a value of the column's type400decline)DateTimeand aDEFAULT/MATERIALIZED/ALIAS/EPHEMERALexpression, on a tenant whose zone differs from the process's image zone503, generic body); other tables and tenants are unaffectedVerification
make cipassed on this exact tree (d06d30c): Go unit 3,753, integration 536 + 101, e2e 96, TS SDK 270, Go total coverage 94.8%, and every coverage gate passed. No threshold was lowered. The tree before the image cache-path rename (43be200) also passed the fullmake cion linux-arm64 against chtypes library build 20261007.044112.--read-only), fetch the artifact into the cache on the first tenant bind, answer/readyz200, accept a typed record and refuse a malformed one with400(code 27). (Verified at the previous revision's head; the artifact model changed after it, and the autofetch path is covered by the suites below.)/v1/queryanswer, covering every column shape × operator × claim, including hostile spellings and integer claims at and beyond every width. 0 disagreements (run at the previous revision's head).time_range, and an external-datainlist. It fails if the zone or the cast is removed.clickhouse-local26.8.15.10, confirming every published row is exactly what the server stores from the same body.Forgetnever blocks a reload hook; binds racing tenant removal never resurrect a removed tenant.apiprocess with autofetch off refuses to boot without one, and chtypes' strict mode refuses an unusable cache (CHTYPES_CACHE_UNUSABLE); an explicit cache directory that does not exist is refused by WaveHouse.Known limitations
Several of these close when chtypes v2 ships. chtypes locks its library interface per major version, so the server profile (Wave-RF/chtypes#419), verbatim
CREATE TABLE(#477) and defer-to-server defaults (#479) arrive in v2: first as opt-in2.0.0-devSDKs, then as stable 2.0.0. This PR stays a draft until then. The v2 adoption is prepared separately and tested against the v2 dev SDK: per-tenant server handles replace the process image zone, the per-call zone and the per-table zone refusal, andtimezone()defaults are answered (#726 and #717 close with it). It moves into this PR in one step when chtypes 2.0.0 ships, so this PR's docs describe one stable download contract. Bug fixes ship on 1.x: shared-handle scaling (#480) is in the production library build, and strict cache (#486) is in SDK 1.1.0.Each is tied to an issue; none is a regression against
mainunless it says so.DateTimeplus a zone-dependent expression unavailable, and a role's_eqcheck on a zone-lessDateTimeloses its auto-inject there (the record must supply the column, else403).CHECKconstraints are not evaluated at ingest (Compile a server's verbatim create_table_query for every engine: enforce CHECK, accept ASSUME and storage-only clauses chtypes#477 for a verbatimCREATE TABLE; WaveHouse typelayer: table CHECK constraints are not evaluated at ingest #727). The server enforces them at insert: a violating record is accepted, may reach the stream, and is then parked by the worker, as onmain. Rolecheckclauses are unaffected.generateSnowflakeID(),rowNumberInAllBlocks()andtimezone()defaults are declined: a body in which any record omits such a column gets one422and nothing is published.generateUUIDv4(),generateUUIDv7(),rand()andnow()work. Deferring them to the server is the open upstream item.mainin the benchmark above.resolveandprune(CLI: chtypes resolve <line> prints the build and manifest digest a line resolves to, without installing chtypes#493, deps: bump eventsource-parser from 3.1.0 to 3.1.1 in the npm-deps group #494) would let CI and deployments name and trim builds without a full fetch.startupProbefor the worst case, or turn autofetch off where the registry cannot be reached.First-run risks on GitHub
registry.wavehouse.devis a CI dependency on a cold artifact cache. Each fetch verifies a signature, so the registry cannot change what runs, but an outage fails a cold CI run (unverified).ubuntu-24.04-armandmacos-latest(advisory release-validation job).Follow-ups (not in this PR)
CHECKconstraints to chtypes so ingest evaluates them (typelayer: table CHECK constraints are not evaluated at ingest #727; Compile a server's verbatim create_table_query for every engine: enforce CHECK, accept ASSUME and storage-only clauses chtypes#477).resolveandprune(CLI: chtypes resolve <line> prints the build and manifest digest a line resolves to, without installing chtypes#493, deps: bump eventsource-parser from 3.1.0 to 3.1.1 in the npm-deps group #494) and a fetch-only call (deps: eventsource-parser held at major 3 — v4 drops CJS and needs Node >= 22.12 #492) in CI and deployments.DateTimecolumn is refused (code 53); 26.5 and later accept it becausecast_string_to_date_time_modedefaults tobest_effort(measured on 26.8.15.10). Parsing claims explicitly would need the/v1/queryand stream renderers changed together./v1/ops/querydoes not pinwait_end_of_query=1/http_write_exception_in_output_format=0like the other read paths, so a statement failing mid-stream can return a 200 with truncated JSON (unchanged frommain).select_allfor a column-restricted role expands to every schema column, EPHEMERAL included, which ClickHouse refuses to SELECT (inferred, unchanged frommain).READONLY(code 164) refusal on/v1/ops/queryand write pipes is still classed as unavailable (503, retryable), unchanged frommain; the read paths map it to502 clickhouse.misconfigured./v1/queryinelements on a 128/256-bit integer column go through plainaccurateCastOrNull, which wraps at the column's width (the server's own behaviour). Caller filters only narrow what policy admits, but the strict cast could cover them too.🤖 Generated with Claude Code