Skip to content

feat(typelayer)!: validate, coerce and filter through chtypes - #589

Closed
EricAndrechek wants to merge 13 commits into
mainfrom
chtypes
Closed

EricAndrechek wants to merge 13 commits into
mainfrom
chtypes

Conversation

@EricAndrechek

@EricAndrechek EricAndrechek commented Sep 17, 2026 •

Copy link
Copy Markdown
Member

Reference PR — not for merge. This branch is based on main at e462135 (2026-09-17), before the multi-tenant work landed. It is kept open as a draft so the full migration and its test evidence stay readable in one place. The migration has been redone on current main in #712; review that one.

Summary

WaveHouse stops modelling ClickHouse itself. Validation, coercion, DEFAULT substitution, timestamp parsing, row-level security and insert checks run through chtypes (github.com/wave-rf/chtypes/go v0.4.0, ABI revision 6): a cgo dlopen of a per-ClickHouse-version shared library that runs the server's own parser and analyzer in-process. The hand-written Go that used to approximate ClickHouse is deleted.

Breaking, by design: cgo is unconditional (no pure-Go build), supported platforms narrow to linux/amd64, linux/arm64 and darwin/arm64, the Linux binaries are dynamically linked (glibc ≥ 2.34), and several documented behaviours move to "whatever ClickHouse does" (listed below).

What changed

Ingest — the request body goes to chtypes as-is. Content-Type declares the format: the JSON family (object, array, NDJSON are one format), text/csv and text/tab-separated-values. The RFC 4180 header parameter maps three ways: header=present reads CSVWithNames/TSVWithNames, header=absent reads positionally with ClickHouse's header detection off, and no parameter keeps ClickHouse's own default (it detects a header line); any other value is 415. One parse per body: RowsExportWith with a row filter returns each record's parse outcome and its check verdict together, so there is no Go-side decode, re-encode or second parse. Column policy and _eq auto-inject are per-role compiled schemas; check clauses and row filters are chtypes filters over the parsed rows with every claim bound as a String parameter. Per-record rejections carry ClickHouse's code. The published row is the exact bytes ClickHouse's own writer produced, so SSE, the table and /v1/query agree by construction (#372).

Integer claims — a claim bound as a plain {p:String} and compared against an integer column wraps modulo 2^64 (and at their own width on [U]Int128/[U]Int256), on the server and in chtypes alike, so a claim of 18446744073709551621 read and wrote tenant 5. Row filters, insert checks and the /v1/query policy predicate now compare integer columns (Nullable/LowCardinality included) against if(toString(accurateCastOrNull({p:String}, 'T')) = {p:String}, accurateCastOrNull({p:String}, 'T'), NULL): an in-range canonical claim answers exactly as before and keeps the primary key in use; an out-of-range or non-canonical claim (007, +5, 1.0) matches no row and fails an insert check with 403. Ingested data is unaffected ("007" still stores 7).

Query path — /v1/query and pipes ask ClickHouse for FORMAT JSONEachRow over HTTP and pass the bytes through; the 424-line result transformer is gone.

Identifiers and literals — chsql.QuoteIdent is byte-identical to ClickHouse's backQuote (a test compares it with the SDK's QuoteIdentifier over every byte); DDL literals use the SDK's QuoteLiteral.

Build/CI/release — CGO_ENABLED=1 everywhere, go 1.27, glibc builder + distroless/cc runtime that bakes the pinned artifact (chtypes.lock, scripts/fetch-chtypes.sh --frozen; the lock pins the revision-6 26.6.8.7 build), golangci-lint v2.13.2, audit-cgo removed. Releases build each target natively on ubuntu-latest, ubuntu-24.04-arm and macos-latest (cgo darwin cannot be cross-built from Linux: the Prometheus client's darwin collector needs a Mach header); one job assembles archives, checksums, provenance, the GHCR image and the Release.

Deleted — internal/discovery/{validation,timestamp}.go, internal/ingest/compact.go, internal/policy/{rowfilter,numeric}.go, internal/api/{record_reader,ingest_seams,clickhouse_exec}.go, policy.LiteralValue/CanonicalNumericLiteral, the two differential-oracle integration tests, every CGO_ENABLED=0 path.

Size, measured — production Go is 17,114 lines at the base commit and 18,007 here (non-test .go outside tests/): the migration adds about 890 lines (695 at this PR's first push). An earlier version of this description said "about 800 lines fewer than main"; that counted only some files and was wrong. The migration trades hand-written ClickHouse modelling for a wrapper around the real thing; it does not shrink the codebase.

Documented contract changes (please review these first)

surface before after
denied insert column 403 column "x" not allowed for insert 400 {"code":117,"error":"Unknown field …"} (does not reveal whether the column exists)
unknown field / EPHEMERAL / ALIAS / MATERIALIZED supplied WaveHouse wording ClickHouse code 117
_in check on an absent column 403 the table default is tested
parse error + check failure on one record 403 the 400 with ClickHouse's code
out-of-range or non-canonical integer claim (007, +5, 1.0, ≥ 2^64) on an integer column wrapped, or accepted matches no row on a read; 403 on an insert check
explicit null on a checked column 403 same as omitting it (takes the injected claim)
text/csv / text/tab-separated-values ingest not accepted positional; header=present / header=absent / no parameter (ClickHouse detects a header)
wire / SSE / query timestamps RFC 3339 Z ClickHouse's rendering, e.g. 2026-06-21 04:00:00.123
/v1/query Decimal JSON string JSON number (the SDK docs already said so)
/v1/query key order alphabetical SELECT order
/v1/query null filter value silent empty result 400
/v1/query in list unbounded one Array(String) param, ~6,000-element ceiling
unparseable timestamp on ingest passed through, failed at the worker 400 with ClickHouse's code at the edge
"missing required column" 400 ClickHouse accepts and stores the type zero
SSE withheld-reason label for an unreadable numeric filter constant decline filter (the strict cast makes it a non-match)
compact JSON array with one bad record whole batch lost (ClickHouse's answer) the other records still ingest

Verification

  • make ci green on this exact tree: Go unit 1,774 (-race), integration 95, e2e 87 (1 skipped), TS unit 229, Go total coverage 92.6%, no threshold lowered.
  • Differential tests against ClickHouse 26.6: the SSE-replayed row equals SELECT … FORMAT JSONCompactEachRow of the stored row; stream row-filter verdicts equal the production /v1/query handler's answer on every column shape × operator × claim, including hostile spellings, escaped values and integer claims at and beyond every width (8,838 cells, zero disagreements); a /v1/query type-rendering pin test.
  • CSV/TSV header handling matches a live ClickHouse server on every combination tried (header present, absent, bare; with and without a header-like first line).
  • Pre-push reviewers were skipped for this reference push (logged in tmp/review-skips-<sha>.log); they run on the redo PR.

First-run risks on GitHub

  • e2e on a cold cache is the long pole (timeout raised to 35 min); if CI is red, read that job's log first and check for a timeout rather than a failure.
  • CodeQL default setup will try to build go1.27 with cgo (not a required check).
  • First use of ubuntu-24.04-arm and macos-latest (advisory validation job).
  • artifacts.wavehouse.dev is a CI dependency on a cache miss.

Known limits (carried into the redo)

  • chtypes.Timezone is process-global and fixed when a ClickHouse version's library is first opened; a per-call session_timezone is accepted and ignored. One process serves one server time zone per ClickHouse version.
  • Filters over a LowCardinality(<integer>) column are declined by chtypes (fail closed).
  • Base-table handle pool is min(GOMAXPROCS, 8); re-measure on deployment hardware.

🤖 Generated with Claude Code

https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB

WaveHouse stops modelling ClickHouse itself. Ingest validation, coercion,
DEFAULT substitution, timestamp parsing, insert checks and row-level
security run through chtypes (github.com/wave-rf/chtypes/go v0.2.1, ABI
rev 4): the request body goes to one RowsExport as-is with the
Content-Type as the declared format (JSON family, CSV, TSV), column policy
and auto-inject are per-role compiled schemas, checks and row filters are
chtypes filters with every claim bound as a String parameter, and the
published row is the bytes ClickHouse's own writer produced. /v1/query and
pipes pass ClickHouse's JSONEachRow through.

BREAKING: cgo is unconditional; platforms narrow to linux/amd64,
linux/arm64, darwin/arm64; Linux binaries need glibc >= 2.34; per-record
errors carry ClickHouse's code (a denied column is 117, not 403); wire and
query timestamps use ClickHouse's rendering; Decimal is a JSON number.

Deleted: discovery validation/timestamp, compact encoder, policy row
filter/numeric comparator, record readers, ingest seams, clickhouse_exec,
every CGO_ENABLED=0 path and the audit-cgo target. Releases build each
target natively on free GitHub runners.

make ci green on this tree (unit 1676 -race, integration 89, e2e 84,
Go total coverage 92.2%).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Y3XxjTbLMPaNw7kg1KMDi
@coderabbitai

coderabbitai Bot commented Sep 17, 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: 7d4e59af-a790-470e-9c9d-0ebd5065f8d7

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
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


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

❤️ Share

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file github_actions Pull requests that update GitHub Actions code go Pull requests that update go code area/observability Metrics, logs, traces, health, profiling area/api HTTP handlers, routing, middleware area/ingest Ingest pipeline (Bento, batching, DLQ) area/query Structured query AST, SQL builder area/policy Access control policies (Hasura-style) area/pipes Named query pipes area/sdk TypeScript SDK (clients/ts/) area/docs Documentation, site/, README area/infra CI, build, deploy, Docker, release labels Sep 17, 2026
EricAndrechek and others added 11 commits September 30, 2026 13:49
- ReconstructDDL is a Library method; identifiers are quoted by the
  library's own QuoteIdentifier, once per compile.
- Insert check clauses are answered by the same parse as the verdicts
  (RowsExportWith + WithRowFilter); CheckVerdicts and the accepted-row
  cursor are gone. The parse outcome still decides first.
- Library.QuoteLiteral replaces the hand-written DEFAULT literal escaper
  and the defensive column re-read.
- The registry is lazy: artifact errors surface at the first Bind.
- CSVWithNames/TSVWithNames via `header=present`; positional CSV/TSV
  keep header detection off, preserving the documented contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
Integration and e2e cases for text/csv and text/tab-separated-values
with header=present (reordered header, omitted column takes its DEFAULT,
unknown header column is a whole-request 400 with code 117), and the
ingest section of api.md for the new content types, header detection
being off for the positional pair, and checks answered in one parse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
…docs for one-parse checks and header=present

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
…tables keep one

Linux arm64 measurements show own handles scale 4-6x at N=8 while a
serialization gate never beats one thread. Role shapes (LRU 256) keep a
single handle to bound memory.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
header=present is CSVWithNames, header=absent is strictly positional
(detect_header=0), and a bare type keeps ClickHouse's default header
auto-detection. typelayer gains IngestWith/IngestOptions.StrictPositional.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
…olumn

Measured against ClickHouse 26.6.3.62 and 25.8.33.6 (and chtypes, cell for
cell): a String-bound value >= 2^64 wraps modulo 2^64 before comparison on
UInt8 and UInt32 columns too, not only 64-bit ones; 128/256-bit columns also
wrap at their own width. The earlier caveat said narrow columns were safe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
…arning about wrap

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
A policy claim bound as a plain {p:String} wraps modulo 2^64 on every
integer column (and at their own width on [U]Int128/[U]Int256), on
/v1/query, the stream row filter and the insert check alike, so a claim
of 18446744073709551621 read and wrote tenant 5.

Every claim compared against an integer column (Nullable/LowCardinality
included) now binds through
  if(toString(accurateCastOrNull({p:String}, 'T')) = {p:String},
     accurateCastOrNull({p:String}, 'T'), NULL)
with T the bare integer type. In-range canonical claims answer exactly
as before and keep the primary key in use; out-of-range and
non-canonical claims match nothing on every operator, and an insert
check refuses them with 403 (a non-canonical claim used to be code 53:
no rows on read, 422 on insert). Other column types and caller filters
keep the plain form.

- chsql: IntegerType (picks the form from the column's type string) and
  StrictInt (renders it), shared by both paths; IntParam carries a
  claim the builder's NamedParams expands to StrictInt over one param.
- policy: WhereClause/WhereParams (rendered at Evaluate, with no column
  types) become ResolvedSelect.WhereSQL(colType), called by the builder
  with the discovered schema.
- typelayer: render() writes StrictInt for integer columns, for the
  stream filter and the ingest check; _in casts each element.
- tests: the stream/query differential now drives the production
  /v1/query handler and checks integer shapes (UInt8 through Int256,
  Nullable(UInt64)) against the exact answer; new ingest integration
  test; unit tables for the type function, the rendered SQL and the
  boundary claims on chtypes.
- docs: access-control caution, api.md, architecture.md, CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
@EricAndrechek

Copy link
Copy Markdown
Member Author

Superseded by #712, which redoes this migration on current main (multi-tenant) with chtypes SDK go/v0.5.2 and ClickHouse 26.8.

@github-project-automation github-project-automation Bot moved this from Backlog to Done in WaveHouse Task Board Oct 1, 2026
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/docs Documentation, site/, README area/infra CI, build, deploy, Docker, release area/ingest Ingest pipeline (Bento, batching, DLQ) area/observability Metrics, logs, traces, health, profiling area/pipes Named query pipes area/policy Access control policies (Hasura-style) area/query Structured query AST, SQL builder area/sdk TypeScript SDK (clients/ts/) dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation github_actions Pull requests that update GitHub Actions code go Pull requests that update go code

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant