refactor(ingest): buffer the body, add type-layer seams and encoder - #553
Conversation
Both decisions in classify-paths.sh were `if printf … | grep -qE …; then A; else B; fi`. grep exits 0 on match, 1 on no match, and 2 on error — can't fork/exec, read error, bad pattern — and the else branch collapsed 1 and 2 into the same answer. `set -euo pipefail` does not help: `set -e` is suppressed for a command used as an `if` condition. Observed twice while gating this stack, in runs whose static checks run at -j 14. A different single case failed each time — `mixed-docs-go` answering docs=false, then `dep-bump-go` answering code=false — while every other case passed. That is the signature of a transient grep failure under load, not a pattern bug; the script and its test are unchanged from main and both pass standalone. The test caught it because it asserts expected values. The production path has no such check: CI's `changes` job gates the docs pipeline on this answer, so a docs=false produced by an errored grep skips the docs build and still reports success. Both greps now go through a `matches` helper that aborts with a diagnostic on any exit above 1. The test suite stubs grep onto PATH to prove the abort fires — that case fails against the previous script, which answers confidently instead. Closes #545. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Epn88jTEw4ZkXrvTKzZXQ
Additive metadata the upcoming native type layer needs, captured on the same refresh as the columns so they can never describe different servers: - Column gains DefaultExpression and Position, both scanned from the widened system.columns select. TableSchema.Columns was already ordered by position, so declaration order needed no new structure. - TableSchema gains DDL from system.tables.create_table_query. It is json:"-": the schema endpoint marshals TableSchema straight to the client and an external-engine table (S3, MySQL, Kafka) carries its credentials in that statement. A table listed in system.tables with no system.columns rows is skipped, never published column-less. - SchemaRegistry gains ServerVersion(), from a SELECT version() probe next to the existing SELECT timezone(). Both new queries fail the refresh on error, matching timezone() and system.columns: callers keep the prior cache and retry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Epn88jTEw4ZkXrvTKzZXQ
`tables.<table>.select.<role>` becomes `tables.<table>.<role>.select`. Field
names and semantics are unchanged; only the nesting moves. `select` and
`insert` are now distinct types (`SelectPermissions`/`InsertPermissions`), so a
field on the wrong side is a validation error instead of being accepted and
ignored.
There is no automatic conversion — convert the file by hand and run
`wavehouse validate` before restarting. A pre-v2 document is reported as one
clear finding pointing at the migration note rather than a confusing
strict-decode error.
Three fail-closed fixes the new shape made visible or possible:
- `ResolvedPermissions` now marks the side `Evaluate` did not resolve. An
unresolved side is zero, and a zero side reads as an empty allow list plus an
empty deny list — which every accessor would answer as "unrestricted". Each
one now denies instead, `HasRowFilter` included: it is the reachable gate in
front of `RowVisible`, so without it the guard behind it was dead code and
both call sites took the whole-bucket fast path.
- `evaluateInsert` resolved a check using an operator it does not honor
(`_neq`/`_gt`/`_lt`, or the ambiguous `_eq`+`_in`) to no clause at all,
authorizing the insert with the rule silently gone — where `evaluateSelect`
denies outright in the mirror situation.
- A `filter` or `check` entry naming no operator (`"tenant_id": {}`) survived a
strict decode, passed `Validate`, and matched no case in either resolver.
Reproduced before fixing: `Allowed=true`, `HasRowFilter()=false`, and
`RowVisible` true for another tenant's row — a declared row-level restriction
applying on neither the query path nor the live stream.
Note on scope: an earlier revision of this work also made
`POST /v1/ops/policy/validate` agree with file adoption. #541 deleted the
policy HTTP surface entirely, so that fix is gone with it — the legacy-layout
detector it relied on is still reached through file adoption's own pipeline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Epn88jTEw4ZkXrvTKzZXQ
`POST /v1/ingest` used to sniff the body and treat the header as a hint: the first non-whitespace byte chose between a single object and an array, and an NDJSON body whose first line happened to start with `[` was re-framed as a JSON array — silently, as a whole-request reinterpretation rather than a per-record error. The header is now required and authoritative. A request declaring nothing, or something ingest does not read, is `415` before the body is parsed, with the supported types named in the body. The declared type chooses the format *family* — `application/json` versus the four NDJSON spellings — and within the JSON family the first non-whitespace byte still picks array versus single object. The bytes never choose the family, so an NDJSON body is read as NDJSON whatever its first byte and a bad line fails as a per-record error. Parameters are ignored (`application/json; charset=utf-8` is `application/json`), matching `mime.ParseMediaType`. The TS SDK already sent `application/json` on every ingest call, so `.insert()` is unaffected. A hand-rolled client that relied on sniffing must now declare the type. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Epn88jTEw4ZkXrvTKzZXQ
…encoder Observable behavior is unchanged. This is the seam work the native type layer lands against, plus the memory trade that comes with it. The handler now reads the whole (already `MaxBytesReader`-capped) body into a pooled `*bytes.Buffer` and runs the record readers over those bytes rather than the live connection, so the `413` surfaces at that read instead of mid-iteration and the `415` is decided from the header before a byte is read. The memory profile is NOT unchanged, and that is the deliberate part. Streaming meant peak resident bytes on the order of one record — NDJSON scanned line by line, the array path let `json.Decoder` compact after each element. Peak is now O(body) per in-flight request, and `bytes.Buffer` doubles, so peak allocation can exceed the cap before `MaxBytesReader` errors. `maxPooledBufferBytes` (1 MiB) caps what a request hands back to the pool, not its peak, and nothing bounds total in-flight bytes — the ceiling is concurrency × the 16 MiB data-plane cap, which has no operator knob, so the outer limit is the proxy's. Kept because the type layer needs the body addressable rather than consumed; a server-side bound is tracked in #544, and the reverse-proxy guide now says to size the container for concurrency rather than for one request. Three decision points became interfaces with default implementations that delegate to today's code unchanged: `RecordValidator` (schema validation + timestamp canonicalization — the two calls stay where they are, with the check-clause block between them, since merging them would move checks onto canonicalized values), `InsertChecker` (the `_eq` and `_in` comparisons), and `stream.RowEvaluator` (row visibility, reached by both the live fan-out and replay through the one shared admission step). `ingest.EncodeCompactRow` lands here unused — the positional encoder the wire change ahead of it will call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Epn88jTEw4ZkXrvTKzZXQ
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Team Run ID: 📒 Files selected for processing (14)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. 📜 Recent review details⏰ Context from checks skipped due to timeout. (8)
🧰 Additional context used📓 Path-based instructions (4)Create `*_test.go` files in the same package as the code under test.📄 CodeRabbit inference engine (AGENTS.md) Files:
Every code change updates its docs + `CHANGELOG.md` in the same PR Never hard-wrap prose.📄 CodeRabbit inference engine (AGENTS.md) Files:
Create the package under `internal/`.📄 CodeRabbit inference engine (AGENTS.md) Files:
Never hard-wrap prose.📄 CodeRabbit inference engine (AGENTS.md) Files:
🧠 Learnings (5)📓 Common learnings📚 Learning: 2026-08-13T12:17:52.620ZApplied to files:
📚 Learning: 2026-06-26T12:23:22.696ZApplied to files:
📚 Learning: 2026-05-23T01:23:59.268ZApplied to files:
📚 Learning: 2026-08-19T15:44:27.183ZApplied to files:
🪛 LanguageTooldocs/src/content/docs/reverse-proxy.mdx[typographical] ~101-~101: Insert a space between the numerical value and the unit symbol. (UNIT_SPACE) [style] ~107-~107: This word has been used in one of the immediately preceding sentences. Using a synonym could make your text more interesting to read, unless the repetition is intentional. (EN_REPEATEDWORDS_WHOLE) [style] ~107-~107: ‘On top of that’ might be wordy. Consider a shorter alternative. (EN_WORDINESS_PREMIUM_ON_TOP_OF_THAT) docs/src/content/docs/sdk/queries.md[style] ~43-~43: As an alternative to the over-used intensifier ‘very’, consider replacing this phrase. (EN_WEAK_ADJECTIVE) docs/src/content/docs/architecture.md[style] ~120-~120: This sentence is over 40 words long. Consider splitting it up, as shorter sentences make the text easier to read. (TOO_LONG_SENTENCE) [style] ~148-~148: Consider using “who” when you are referring to a person instead of an object. (THAT_WHO) CHANGELOG.md[style] ~23-~23: Since ownership is already implied, this phrasing may be redundant. (PRP_OWN) [typographical] ~25-~25: Consider using an em dash in dialogues and enumerations. (DASH_RULE) docs/src/content/docs/api.md[style] ~322-~322: This word has been used in one of the immediately preceding sentences. Using a synonym could make your text more interesting to read, unless the repetition is intentional. (EN_REPEATEDWORDS_WHOLE) [style] ~374-~374: Consider using the typographical ellipsis character here instead. (ELLIPSIS) 🔇 Additional comments (1)
📝 SummarySummary by CodeRabbit
WalkthroughThe ingest handler now buffers request bodies before parsing and resolves content types before reading. Validator, insert-check, and stream row-visibility decisions use nil-safe interfaces with default implementations. Compact row encoding and related tests were added. ChangesIngest and stream seams
Estimated code review effort: 4 (Complex) | ~45 minutes Merge Risk: 🟡 Moderate · up to Parallel near-limit uploads can exhaust server memory and disrupt ingestion, so this should be addressed or explicitly accepted before merge. Sequence Diagram(s)sequenceDiagram
participant Client
participant IngestHandler
participant BodyBuffer
participant RecordReader
Client->>IngestHandler: Send request with Content-Type and body
IngestHandler->>BodyBuffer: Read capped request body
IngestHandler->>RecordReader: Create reader from resolved format and buffered bytes
RecordReader-->>IngestHandler: Return records
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
✨ Simplify code
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 |
|
@coderabbitai review Auto-review is off for PRs whose base isn't the default branch, and this one sits in a stack (base Note for context: this PR's diff against its base is one layer of a seven-PR stack replacing #540. Reviewing it in isolation is the intent — the layers below it are already in its history. |
|
|
`database` is a live getter so a ClickHouse reconfigure is honored on the next refresh. Reading it once per query rather than once per refresh let a reconfigure land between the system.columns and system.tables scans, attaching DDL from the new database to same-named schemas discovered from the old one — silently, and it would survive into GET /v1/ops/schema. Refresh now snapshots the name before the first query and threads it to both. Both refresh-failure tests asserted emptiness after a single failed refresh, which is satisfied by the regression they exist to prevent — a failure that wipes an already-published registry. Both now seed a successful refresh first. TestRefresh_DatabaseSnapshottedForWholeRefresh flips the getter between the two scans and fails without the snapshot. CORRECTS TWO CLAIMS IN THIS BRANCH'S EARLIER COMMIT (e10eb04), whose message is already pushed and cannot be amended under the no-force-push rule: 1. "an external-engine table carries its credentials in that statement" is FALSE on the ClickHouse this repo ships. Verified on 26.7.3.19: MySQL, PostgreSQL, S3, Kafka SASL, MongoDB URIs and S3 presigned signatures all render the secret as `[HIDDEN]`. What create_table_query does leak, unconditionally, is topology — endpoint, bucket or host, database, username, S3 access key id. `json:"-"` is still right, for that stronger reason. The password is exposed only on a pre-masking server or one with the server-level display_secrets_in_show_and_select enabled. 2. "captured on the same refresh so they can never describe different servers" overstated the guarantee. chconn.Manager resolves the connection per call, so a reload changing clickhouse.addr mid-refresh can pair a version from one server with schemas from another. It is a publication guarantee, not a same-server one. The read side straddles too: ServerVersion() and Get() take separate RLocks. The rationale is corrected at every site it was replicated — discovery.go, AGENTS.md, architecture.md, api.md, the CHANGELOG — and the test fixture, which had been asserting a literal secret no real server emits, now uses masked DDL and asserts on the bucket and access key id instead. Adds tests/integration/discovery_metadata_test.go: position is 1-based and contiguous against live ClickHouse, DefaultExpression and HasDefault behave as documented for a MATERIALIZED column, DDL is captured, ServerVersion is set. Raised by CodeRabbit on #550 and by the pre-push reviewer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Epn88jTEw4ZkXrvTKzZXQ
The unsupported-media-type test checked the status and two of the five accepted types. It now asserts the JSON error envelope and its security headers via testutil.AssertJSONErrorResponse — which this branch was missing, though the same assertion exists further up the stack, so the split dropped it onto the wrong layer — and iterates supportedContentTypes. On what that loop pins: it cannot catch an alias being dropped from supportedContentTypes, because the message is built from the same slice and the loop would simply check one fewer. Verified. What it does catch is the message diverging from the list, so a client is never told to use a type the server accepts but never names; verified by truncating the Join and watching it fail. Also rewords the documented 415 cause in three places: "or one ingest doesn't read" was not a condition a reader could act on. Now "or an unsupported one". Raised by CodeRabbit on #552. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Epn88jTEw4ZkXrvTKzZXQ
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Actionable comments posted: 4
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Team
Run ID: be1c6d13-6c39-4e08-995c-2914c1e3d43a
📒 Files selected for processing (12)
CHANGELOG.mddocs/src/content/docs/reverse-proxy.mdxinternal/api/bufpool.gointernal/api/bufpool_test.gointernal/api/ingest.gointernal/api/ingest_seams.gointernal/api/ingest_seams_test.gointernal/api/record_reader.gointernal/ingest/compact.gointernal/ingest/compact_test.gointernal/stream/hub.gointernal/stream/roweval_test.go
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.
📜 Review details
🧰 Additional context used
📓 Path-based instructions (2)
Never hard-wrap prose.
📄 CodeRabbit inference engine (AGENTS.md)
Files:
docs/src/content/docs/reverse-proxy.mdxCHANGELOG.md
In MDX, leave a blank line between a JSX tag and a code fence.
📄 CodeRabbit inference engine (AGENTS.md)
Files:
docs/src/content/docs/reverse-proxy.mdx
🧠 Learnings (4)
📚 Learning: 2026-08-11T21:55:53.726Z
Learnt from: jfwoods
Repo: Wave-RF/WaveHouse PR: 434
File: clients/go/stream_test.go:159-176
Timestamp: 2026-08-11T21:55:53.726Z
Learning: For Go files in this repository, do not report direct type assertions solely because the `forcetypeassert` rule is commented out in `.golangci.yml`. Only flag a type assertion when there is an independent correctness, safety, or maintainability issue.
Applied to files:
internal/api/bufpool.go
📚 Learning: 2026-06-26T12:23:22.696Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 346
File: internal/stream/subscriber_test.go:9-28
Timestamp: 2026-06-26T12:23:22.696Z
Learning: In this Go repository, prefer table-driven tests (e.g., `[]struct{...}` with `t.Run(...)`) only for tests that cover multiple scenarios/inputs and can be cleanly enumerated. Do not artificially rewrite a clear single-scenario sequential behavioral-flow test into a table-driven form just to fit the pattern; if there’s only one meaningful scenario, keep the test as a straightforward linear flow (as in `TestSubscriber_SendDeliversThenDropsWhenFull`).
Applied to files:
internal/api/ingest_seams_test.gointernal/stream/roweval_test.go
📚 Learning: 2026-05-23T01:23:59.268Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 174
File: internal/api/ingest_test.go:111-111
Timestamp: 2026-05-23T01:23:59.268Z
Learning: In WaveHouse Go tests in internal/api/**/*_test.go, use internal/testutil.AssertJSONErrorResponse(t, w) for HTTP error-path JSON assertions. Do not use (or reintroduce) package-local assertJSONErrorResponse helpers. AssertJSONErrorResponse verifies the response Content-Type is application/json, includes the X-Content-Type-Options: nosniff header, and that the JSON body contains an "error" field.
Applied to files:
internal/api/ingest_seams_test.go
📚 Learning: 2026-08-13T12:17:52.620Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 470
File: docs/src/content/docs/reverse-proxy.mdx:137-144
Timestamp: 2026-08-13T12:17:52.620Z
Learning: For Wave-RF/WaveHouse documentation, verify claims about implementation control flow against the authoritative implementation source (for example, internal/auth/auth.go) rather than relying solely on docs/** content. Documentation may lag behind or paraphrase behavior, so control-flow claims should be confirmed in source code.
Applied to files:
docs/src/content/docs/reverse-proxy.mdx
🪛 LanguageTool
docs/src/content/docs/reverse-proxy.mdx
[grammar] ~104-~104: Ensure spelling is correct
Context: ...ler of the proxy's and WaveHouse's. For ingest, WaveHouse's 16 MiB is the ceiling — ra...
(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)
[style] ~107-~107: This word has been used in one of the immediately preceding sentences. Using a synonym could make your text more interesting to read, unless the repetition is intentional.
Context: ... just for one request] Ingest reads the whole body into memory before parsing it, so ...
(EN_REPEATEDWORDS_WHOLE)
🔇 Additional comments (2)
internal/ingest/compact.go (1)
30-49: LGTM!internal/ingest/compact_test.go (1)
14-130: LGTM!
The pre-v2 layout detector treated `tables.<table>.select` as an operation map whenever its value's keys were not permission fields, so a valid v2 grant for a role literally named `select` was rejected before the strict decode. Raised by CodeRabbit on #551. The first fix bailed out whenever every key was an operation name. That was wrong, and wrong in the dangerous direction — it made this document adopt: {"tables":{"clicks":{"select":{"select":{"allow_columns":["a"]}, "insert":{"allow_columns":["b"]}}}}} Pre-v2 that grants two read-only roles. Read as v2 it grants ONE role read and write: role `select` gained an INSERT the file never contained, and role `insert` silently lost its SELECT. No error, no warning. Caught by the pre-push reviewer; reproduced before this fix. The discriminator is narrower. When the inner key set is exactly the outer operation (`select.select`), the v1 and v2 readings describe identical access, so accepting the v2 decode is free. When it names the OTHER operation (`select.insert`) the readings diverge, and that is precisely the escalating case — so it is refused, with its own message asking for a rename rather than the migration message, which would tell an operator to convert a file that may already be v2. TestReportLegacyPolicyLayout_RoleNamedAfterAnOperation pins all three outcomes and asserts the divergent case does not get the migration message. The previous version of that test asserted the escalating shape as safe. Also corrects `settings-directory.mdx`, which said a leftover `"select": {}` block always fails the undeclared-role check first — that holds only when `roles.json` does not declare a role named `select` — and qualifies the "reported as one clear finding" claims in the CHANGELOG and access-control.mdx, which are not true of the ambiguous shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Epn88jTEw4ZkXrvTKzZXQ
Four items from the chtypes-side review of #551: - `looksLikeRoleMap`'s doc comment still described the withdrawn first attempt, where that function itself special-cased an operation-named key and the ambiguous case "lost" to the v2 reading. Neither is true: the body only rejects permission-field keys, and the ambiguous case is refused by `operationNamedRoles`, not resolved. Rewritten to match. - Re-add the two operator-less rows dropped in the role-first rewrite of `validate_test.go`. `policy_test.go` still pinned the rule, but the settings layer is where an operator actually hits it. - Drop `ResolvedInsert.CheckPredicates`. It had no reader and a rule that deliberately diverges from the one ingest enforces (fail-closed vs auto-inject ""), which is a trap to leave lying around unread. It can come back in the PR that reads it, reviewed against a real consumer. - Note at `ingest.go`'s bare `CheckClauses` read why the `unresolved` marker cannot defend that line, at the call site rather than only on the type. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Epn88jTEw4ZkXrvTKzZXQ
Pre-push review findings on the previous commit: - The `CheckClauses` call-site comment I just added claimed "Evaluate only returns Allowed with both sides resolved". That is false, and false in the direction that matters: `evaluateSelect` returns `Allowed: true` with `Insert` marked `unresolved`, which is the whole point of the marker. A maintainer trusting that sentence would conclude a cross-side bare read is safe anywhere. The real reason this line is safe is narrower — the handler resolved this grant for `insert` — so say that. - `ResolvedPredicate`/`ResolvePredicates` were exported by this PR only because `ResolvedInsert.CheckPredicates` needed a public type. That field is gone and nothing outside `internal/policy` names either identifier, so they go back to main's unexported spelling, along with the two prose mentions that moved with them. - `architecture.md`'s rowfilter bullet lost its third list item to a spliced-in clause: `ColumnSpec` read as a coordinate of the `HasRowFilter` gate explanation with no verb attaching it. The gate explanation moves to the end of the bullet. - The CHANGELOG's "without it" attached the `RowVisible`-true consequence to the insert-side bind-unsafe deny; that consequence belongs to the select-side operator-less deny. Check clauses never reach `RowVisible`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Epn88jTEw4ZkXrvTKzZXQ
Three CodeRabbit threads on #551: - `access-control.mdx` claimed an old-layout document is always reported as one clear error. An *empty* operation block (`"select": {}`) carries no role names to give it away, so it reads as a grant for a role named `select` and either trips the undeclared-role error or — if `select` is declared — warns and adopts. State the exception. - `settings-directory.mdx` described only the migration rejection. The ambiguity refusal (a role named after the *other* operation) is a second, distinct one with its own message, and the operator who hits it was being told their file uses the pre-v2 layout. Both spellings are now stated, including that the same-operation form is accepted. - Pin the mixed-key classification in `validate_test.go`: a pre-v2 block carrying one operation-named role beside a real one. The real name settles the reading, so it is pre-v2 and gets the migration message, not the ambiguity refusal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Epn88jTEw4ZkXrvTKzZXQ
The reordering this branch exists to establish had no test defending it. Every 415 case passes a small, readable body, so moving resolveContentType back below body.ReadFrom left the entire suite green while making an unsupported request pay for a full 16 MiB buffer — and AGENTS.md Key Design Decision #3 ("a 415 decided *before* the body is read") would have become false with nothing to say so. Verified by mutation: hoisting the buffer read above resolution fails both new subtests (415 -> 400, 415 -> 413) and no pre-existing test at all. The body-cap consequence reached table.ts's insertNDJSON docstring, api.md and reverse-proxy.mdx but not the SDK doc page for that same method, whose example uploads a whole .ndjson file. That page is where a reader forms the "NDJSON is the big-upload path" belief this branch retires. The adjacent array-insert paragraph had the same gap. architecture.md's ingest flow skipped from the Content-Type step straight to schema validation, so the step both neighbours point at ("before the body is parsed", "before the body is read") was invisible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FZqHaWmHxxNLRUEQy5ZYRw
#552 landed on main as a single squashed commit (e203f3a), which carries no ancestry link to stack/3-content-type. Git therefore re-derives the whole stack diff against this branch and conflicts in four files, even though 6b095d5 already merged that exact content here. Resolved with -s ours because it discards nothing: main gained exactly one commit over stack/3-content-type, and `git diff stack/3-content-type main` is empty, so main's tree is identical to content this branch already carries. The tree is unchanged by this merge; only the ancestry is recorded, which is what makes every future merge from main clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FZqHaWmHxxNLRUEQy5ZYRw
…racy Three gaps found by mutation-testing the seams rather than reading them. Replacing h.checker().InSet with the canonical valueInSet left the entire package green: the existing _in tests reach that arm only through the default checker, so the wiring was undefended. A swapped-in type-aware checker would have taken effect for _eq and silently not for _in, inside insert-check authorization. The new subtests fail under that mutation and nothing else does. TestIngest_BodyCap_413 pinned only half the cap change. All four existing cases put the over-cap bytes inside the first value, so every one answers 413 on main too. The case that distinguishes the trees is a COMPLETE first object followed by an oversized tail — 200 with one record published on main, 413 with none here. api.md states that contract explicitly; nothing defended it. Docs accuracy, all in prose this branch introduced: - api.md called per-record error reporting an NDJSON advantage. Arrays report per-record too (arrayReader turns an UnmarshalTypeError into a recordSyntaxError and keeps going). The real differentiator is syntax tolerance: a bad line is skipped where a syntax error kills the array. - The at-least-once caution exempted 413 but not the 400 of an upload cut off in transit, which is the same case — decided before any record is processed. It also omitted the oversized NDJSON line, which IS an abort-partway: bufio.ErrTooLong is fatal and the earlier lines are already published. - The reverse-proxy sizing note said "the amplification above" with no clear referent. The reading that makes it meaningful is the order-of-magnitude figure three paragraphs up, which is attributed to an array of many small values — precisely what a flat single-object row cannot be, its key count being bounded by the table's columns. Named the referent and scoped it rather than telling operators to size for a blow-up that does not apply. - insert()'s TSDoc was the fourth copy of the body-cap claim, unswept. - architecture.md's ingest/ section is a complete per-file map; compact.go made it four files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FZqHaWmHxxNLRUEQy5ZYRw
The parenthetical I added last commit was wrong, and wrong in the unsafe direction. It claimed a flat ingest row cannot show the order-of-magnitude decode blow-up because its key count is bounded by the table's columns. That bound is real for an ACCEPTED row and irrelevant to peak memory: objectReader decodes into a map[string]any carrying whatever keys arrived (record_reader.go:158), and h.validator().Validate — the only thing that knows the table's columns — is not reached until ingest.go:448, with the whole map already materialized. Measured with the handler's own decoder settings (UseNumber, map[string]any): a 16 MiB flat object of ~1.4M one-byte values decodes to 183 MiB of heap, 11.5x the body. So peak for one request is the ~32 MiB buffer plus that map, and the "size for 3x" rule undercounts by roughly a factor of three in the one paragraph an operator sizes a container from. Under the shipped compose policy (default_role public, insert allow_columns ["*"]) that request needs no credential. Replaced the exemption with the ordering that causes it and raised the sizing guidance to ~10x, noting a single oversized array element or NDJSON line costs the same. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FZqHaWmHxxNLRUEQy5ZYRw
…ionable
Three follow-ons to the previous commit, all in the same paragraph.
The 10x rule was itself ~2x optimistic for a shape it did not cover: an
array-VALUED key, not a top-level array element. {"c":[1,1,1,...]} at 16 MiB
decodes to 285.9 MiB live heap — 17.9x, and 61x in total allocation churn —
because objectReader builds the map and materializes the []any value before
discovery.Validate ever looks at the column type. An operator sizing at a
flat 10x is OOM'd by one uncredentialled request. The tail is unbounded
(nesting goes higher), so the fix is a directional hedge rather than a bigger
number.
"Cap object bodies at the proxy" named a distinction no proxy can make: a
single object and an array both arrive as application/json, arity being
chosen by the first non-whitespace byte, and nginx cannot set
client_max_body_size from a variable. Replaced with lowering the body cap for
/v1/ingest, which is what the surrounding examples actually support.
Line 96's parenthetical still named only arrays, so a reader following the
new cross-reference up from line 107 found an example that did not match the
shape they had just been shown to be reachable. Objects measure 7.1x, so they
belong there on the merits.
Measurement note: the 11.5x in the previous commit message was taken without
a post-decode GC and so counted garbage. Settled figures, live heap after GC
with the handler's decoder settings: flat object of tiny keys 7.1x, one
array-valued key 17.9x, one 16 MiB string value 1.0x.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FZqHaWmHxxNLRUEQy5ZYRw
|
📚 Docs preview is live → https://cc6af349-wavehouse-docs.wave-rf.workers.dev
|
Code Coverage OverviewLanguages: Go GoThe overall line coverage in commit c807b46 in the Show a line coverage summary of the most impacted files.
Updated |
This branch was 87 commits behind its own base and 5 behind main, which also meant it still carried golang.org/x/crypto v0.55.0 and failed vulncheck (GO-2026-6354/6355); main already has v0.56.0. Two conflicts: AGENTS.md — two independent renames of the same sentence. #554 renamed stream.filterColumns to stream.projectIndices; #553 renamed ResolvePredicates to resolvePredicates. Kept both, verified against the merged tree rather than the diff. internal/stream/filter_test.go — #554 rewrote these cases from filterColumns (map in, map out) to projectIndices (indices + names), and filterColumns no longer exists, so #554's structure had to win. Applied #553's change of Select to a pointer, and carried its unresolved-select-side case across in the new shape. That surfaced a real semantic interaction rather than a textual one: #553 makes a nil Select mean "never resolved for reads" and deny, but #554's "no lists keeps every column" case built ResolvedPermissions{Allowed: true} with no Select at all, which was unrestricted when Select was a value type and is now denied. Corrected to the explicit-empty form, matching how IsAdmin builds an unrestricted grant, and the nil case is now covered separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#553 landed on main as a single squashed commit (f72c8c6) carrying no ancestry link to stack/4-seams, so git re-derives the whole stack diff against this branch and conflicts — even though 6b095d5 already merged that exact content here. Same pattern as when #552 squashed. Resolved with -s ours because the interlock says it discards nothing: main gained exactly one commit over stack/4-seams, and `git diff stack/4-seams main` is empty, so main's tree is content this branch already carries. The tree is unchanged by this merge; only the ancestry is recorded, which is what makes every later merge from main clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#554 gained five commits since this branch last took it: the -s ours merge of main after #553 squashed, the deterministic policy-check guard, the unpairable counter on both delivery paths, and the gap-fill qualifications. One conflict, in api.md's schema-frame paragraph, and it is the good kind — both branches had independently added the same "not guaranteed after a gap-fill" qualification, at different points in the same paragraph. Kept this branch's, which places it earlier and also carries the received_timestamp name-collision note; #554's copy would have been a second statement of the same fact three sentences later. Verified rather than assumed, because the last merge from #554 left eight paraphrased duplicates that -X theirs could not see: the qualification appears once, the collision note once, #554's other two sites (api.md's envelope section and sdk/streaming.md) survived intact, and the context-aware sweep reports zero unqualified schema-frame claims across AGENTS.md and the whole docs tree. The eight claims that were duplicated last time are all still single-copy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ave-RF#553) ## Summary This is the seam work the native type layer lands against, plus the memory trade that comes with it. Responses are unchanged **except at the body cap**, where buffering makes the `413` atomic — and that is a real behavior change, not a refactor artifact: - An over-cap **batch** used to publish the records it had already decoded before the cap surfaced mid-iteration. A client retrying that `413` re-sent them. Now nothing is published, so a `413` is safe to retry once split. - An over-cap **single object** followed by an oversized tail used to answer `200`, having ingested that first object and silently discarded the rest. Now it is a `413`. - One new response shape: `400 {"error":"invalid request body"}` when the body cannot be read at all (malformed transfer encoding, truncated upload), which previously surfaced through the decoder as `invalid json`. Both cap changes are improvements, and the CHANGELOG and `api.md` error tables carry them. The handler now reads the whole (already `MaxBytesReader`-capped) body into a pooled `*bytes.Buffer` and runs the record readers over those bytes rather than the live connection, so the `413` surfaces at that read instead of mid-iteration and the `415` is decided from the header before a byte is read. The memory profile is NOT unchanged, and that is the deliberate part. Streaming meant peak resident bytes on the order of one record — NDJSON scanned line by line, the array path let `json.Decoder` compact after each element. Peak is now O(body) per in-flight request, and `bytes.Buffer` doubles, so peak allocation can exceed the cap before `MaxBytesReader` errors. `maxPooledBufferBytes` (1 MiB) caps what a request hands back to the pool, not its peak, and nothing bounds total in-flight bytes — the ceiling is concurrency × the 16 MiB data-plane cap, which has no operator knob, so the outer limit is the proxy's. Kept because the type layer needs the body addressable rather than consumed; a server-side bound is tracked in Wave-RF#544, and the reverse-proxy guide now says to size the container for concurrency rather than for one request. Three decision points became interfaces with default implementations that delegate to today's code unchanged: `RecordValidator` (schema validation + timestamp canonicalization — the two calls stay where they are, with the check-clause block between them, since merging them would move checks onto canonicalized values), `InsertChecker` (the `_eq` and `_in` comparisons), and `stream.RowEvaluator` (row visibility, reached by both the live fan-out and replay through the one shared admission step). `ingest.EncodeCompactRow` lands here unused — the positional encoder the wire change ahead of it will call. ## Stacked PR This is **part 4 of 7** in a stack that replaces Wave-RF#540. Each PR is based on the one above it, so review this PR's own diff against its base — GitHub shows only this layer's changes. | # | Branch | Base | | | - | ------ | ---- | - | | 0 | `stack/0-classify-paths` | `main` | | | 1 | `stack/1-discovery` | `stack/0-classify-paths` | | | 2 | `stack/2-policy` | `stack/1-discovery` | | | 3 | `stack/3-content-type` | `stack/2-policy` | | | 4 | `stack/4-seams` | `stack/3-content-type` | **→ this PR** | | 5 | `stack/5-positional-wire` | `stack/4-seams` | | | 6 | `stack/6-computed-columns` | `stack/5-positional-wire` | | Merge in order, top to bottom. Rebasing or squashing out of order will make the later PRs' diffs unreadable. ## Test plan - [x] `make ci` green on this branch's exact tree (verify, unit, integration against live ClickHouse, e2e, all coverage gates) - [x] The branch descends from its base and carries only this layer's change (plus any follow-up commits answering review) - [x] `go.mod` / `go.sum` untouched; no new dependencies ## Review Both pre-push reviewers gate the tip of the stack (Wave-RF#555), whose delta against `main` is the union of all seven branches. This branch carries a logged skip (`scripts/skip-pre-push-review.sh`) on that basis, recorded in `tmp/review-skips-*.log`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_018Epn88jTEw4ZkXrvTKzZXQ --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Upstream-Commit: f72c8c6
Summary
This is the seam work the native type layer lands against, plus the memory
trade that comes with it.
Responses are unchanged except at the body cap, where buffering makes the
413atomic — and that is a real behavior change, not a refactor artifact:before the cap surfaced mid-iteration. A client retrying that
413re-sentthem. Now nothing is published, so a
413is safe to retry once split.200, having ingested that first object and silently discarded the rest. Nowit is a
413.400 {"error":"invalid request body"}when the bodycannot be read at all (malformed transfer encoding, truncated upload), which
previously surfaced through the decoder as
invalid json.Both cap changes are improvements, and the CHANGELOG and
api.mderror tablescarry them.
The handler now reads the whole (already
MaxBytesReader-capped) body into apooled
*bytes.Bufferand runs the record readers over those bytes rather thanthe live connection, so the
413surfaces at that read instead ofmid-iteration and the
415is decided from the header before a byte is read.The memory profile is NOT unchanged, and that is the deliberate part. Streaming
meant peak resident bytes on the order of one record — NDJSON scanned line by
line, the array path let
json.Decodercompact after each element. Peak is nowO(body) per in-flight request, and
bytes.Bufferdoubles, so peak allocationcan exceed the cap before
MaxBytesReadererrors.maxPooledBufferBytes(1MiB) caps what a request hands back to the pool, not its peak, and nothing
bounds total in-flight bytes — the ceiling is concurrency × the 16 MiB
data-plane cap, which has no operator knob, so the outer limit is the proxy's.
Kept because the type layer needs the body addressable rather than consumed; a
server-side bound is tracked in #544, and the reverse-proxy guide now says to
size the container for concurrency rather than for one request.
Three decision points became interfaces with default implementations that
delegate to today's code unchanged:
RecordValidator(schema validation +timestamp canonicalization — the two calls stay where they are, with the
check-clause block between them, since merging them would move checks onto
canonicalized values),
InsertChecker(the_eqand_incomparisons), andstream.RowEvaluator(row visibility, reached by both the live fan-out andreplay through the one shared admission step).
ingest.EncodeCompactRowlands here unused — the positional encoder the wirechange ahead of it will call.
Stacked PR
This is part 4 of 7 in a stack that replaces #540. Each PR is based on the one above it, so review this PR's own diff against its base — GitHub shows only this layer's changes.
stack/0-classify-pathsmainstack/1-discoverystack/0-classify-pathsstack/2-policystack/1-discoverystack/3-content-typestack/2-policystack/4-seamsstack/3-content-typestack/5-positional-wirestack/4-seamsstack/6-computed-columnsstack/5-positional-wireMerge in order, top to bottom. Rebasing or squashing out of order will make the later PRs' diffs unreadable.
Test plan
make cigreen on this branch's exact tree (verify, unit, integration against live ClickHouse, e2e, all coverage gates)go.mod/go.sumuntouched; no new dependenciesReview
Both pre-push reviewers gate the tip of the stack (#555), whose delta against
mainis the union of all seven branches. This branch carries a logged skip (scripts/skip-pre-push-review.sh) on that basis, recorded intmp/review-skips-*.log.🤖 Generated with Claude Code
https://claude.ai/code/session_018Epn88jTEw4ZkXrvTKzZXQ