feat(insert): option to send the INSERT statement in the query URL parameter - #580
feat(insert): option to send the INSERT statement in the query URL parameter#580polyglotAI-bot wants to merge 3 commits into
Conversation
…rameter A binary insert writes its INSERT INTO ... FORMAT ... statement as the first line of the request body, which is ZSTD-compressed by default, so proxies, gateways and access logs cannot read it. InsertOptions.QueryPlacement = InsertQueryPlacement.Url sends the statement as the query URL parameter instead and leaves the body to the rows alone. The body remains the default, because the statement then counts towards the URL length limit.
There was a problem hiding this comment.
Pull request overview
This PR adds an opt-in insert feature to ClickHouseClient.InsertBinaryAsync that allows sending the INSERT INTO ... FORMAT ... statement via the query URL parameter (instead of as the first line of the request body), improving compatibility with intermediaries/observability that rely on inspecting the URL.
Changes:
- Introduces
InsertQueryPlacementandInsertOptions.QueryPlacement(defaulting toBody) to control where the INSERT statement is sent. - Updates binary insert batch serialization and
ClickHouseClientrequest construction to support URL-mode framing (rows-only body, no leading newline). - Adds tests (stubbed wire framing + real-server coverage) and updates docs/changelog/PublicAPI tracking.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| docs/overview.mdx | Documents the new InsertOptions.QueryPlacement option and its tradeoffs/usage. |
| ClickHouse.Driver/PublicAPI/PublicAPI.Unshipped.txt | Records the new public enum and InsertOptions property for API surface tracking. |
| ClickHouse.Driver/InsertQueryPlacement.cs | Adds the public InsertQueryPlacement { Body, Url } enum with XML documentation. |
| ClickHouse.Driver/InsertOptions.cs | Adds QueryPlacement with default Body, and ensures it survives internal option cloning. |
| ClickHouse.Driver/Copy/Serializer/IBatchSerializer.cs | Updates the serializer contract to accept InsertQueryPlacement. |
| ClickHouse.Driver/Copy/Serializer/BatchSerializer.cs | Skips writing the INSERT prologue in URL mode to keep the body rows-only. |
| ClickHouse.Driver/Copy/Serializer/PocoBatchSerializer.cs | Mirrors the rows-only body behavior for the POCO insert path. |
| ClickHouse.Driver/ClickHouseClient.cs | Sends the INSERT statement as the query URL parameter in URL mode and passes placement to serializers. |
| ClickHouse.Driver.Tests/InsertBinaryQueryPlacementTests.cs | Adds coverage for wire framing, real-server round trips, parallel batching, and error-path behavior in URL mode. |
| ClickHouse.Driver.Tests/ClickHouseClientQueryOptionsTests.cs | Extends existing reflection-based copy tests to include QueryPlacement. |
| changelog.d/polyglot-insert-query-placement.features.md | Adds a changelog fragment for the new feature. |
Suppressed comments (1)
ClickHouse.Driver/ClickHouseClient.cs:768
- Same issue as in
SendBatchAsync: an undefinedInsertOptions.QueryPlacementvalue can make the serializer skip the prologue whileurlQueryremains null, producing a request with no INSERT statement. Validate or normalizeQueryPlacementbefore using it.
// Stream the (optionally compressed) batch straight into the request stream (see
// SendBatchAsync for the serialization-error capture and query-placement rationale).
var queryPlacement = insertOptions.QueryPlacement;
var urlQuery = queryPlacement == InsertQueryPlacement.Url ? batch.Query : null;
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Both send paths derive the query URL parameter and the body framing from QueryPlacement separately, so a value outside the enum (a cast or a configuration binding) left the statement out of the URL and out of the body, producing a request the server cannot read. PrepareInsertAsync validates it, next to the existing BatchSize and MaxDegreeOfParallelism checks and on the path both inserts share.
The parallel-batch test read the row count and the checksum straight after the insert returned. Each batch travels on a connection of its own, so a service that spreads them over several replicas can serve that read from a replica that has not picked up the newest part yet — the Cloud leg saw 2000 of 2500 rows, missing exactly the last batch, while every single-node leg passed. Read both totals from one query and retry while the count is short. The assertion is unchanged: the exact count and the checksum over every Id are still required, so a batch that is genuinely lost still fails the test.
|
CI follow-up: the Diagnosis — a read-after-write race in the test, not a defect in the URL placement path:
Fix: read the count and the checksum from one query and retry while the count is short (20 × 250 ms). The assertion itself is unchanged — the exact row count and the checksum over every Id are still required. Verified non-vacuous: with the insert cut to 2000 rows the test still fails after the wait. Full suite green on net10.0: 10908 passed, 0 failed. |
Description
Adds
InsertOptions.QueryPlacement, an opt-in flag that makes a binary insert(
InsertBinaryAsync, both theobject[]and the POCO overload) send itsINSERT INTO ... FORMAT ...statement as thequeryURL parameter instead of writing it as thefirst line of the request body.
Why: in body mode the statement sits inside a payload that
InsertOptions.Compressorcompressesby default (ZSTD), so proxies, load balancers and gateways that route or inspect on the
queryparameter cannot see it, and it does not reach access logs or observability tooling.
Why opt-in: the statement then counts towards the URL length. A long column list can exceed the
server's
max_uri_size(1 MiB by default) — the server answers400 Bad Request(
HTTP request URI invalid or too long), verified against 26.7.3 — or a lower limit of anintermediary. The body has no such limit, so
Bodystays the default and nothing changes forexisting callers.
This also removes an inconsistency between the insert entry points:
InsertRawStreamAsyncalreadysends its statement in the URL, while
InsertBinaryAsynccould only use the body.Design
InsertQueryPlacement { Body = 0, Url = 1 }matches this client'sexisting option style (
RowBinaryFormat,JsonReadMode,JsonWriteMode,MapReadMode), readsbetter at the call site than a
bool, and leaves room for a future "URL until it gets too long"mode without another breaking rename.
Compressor. Where the statement goes and how the body is encoded are separateconcerns; every combination is covered by tests.
ClickHouseBulkCopy. That type is deprecated in favour ofInsertBinaryAsyncand builds its ownInsertOptionsfrom its own fields; adding a propertythere would widen a deprecated API. It keeps the default
Bodybehavior.mode. In that mode no newline is written either — a stray leading
\nwould be read by theserver as row data and corrupt the first row.
Changes
ClickHouse.Driver/InsertQueryPlacement.cs— new public enum.ClickHouse.Driver/InsertOptions.cs— newQueryPlacementproperty, added to bothWithQueryIdandWithColumnTypesso it survives per-batch option copying.ClickHouse.Driver/Copy/Serializer/{IBatchSerializer,BatchSerializer,PocoBatchSerializer}.cs—take the placement and skip the prologue in URL mode. The
serializingRowsflag that separates aprologue-write failure from a row-serialization failure starts out set when there is no prologue,
so a row fault is still wrapped in
ClickHouseBulkCopySerializationException.ClickHouse.Driver/ClickHouseClient.cs—SendBatchAsyncandSendPocoBatchAsyncpassbatch.Queryas thesqlargument ofPostStreamAsyncin URL mode (the URI builder alreadyemits
queryfor a non-empty SQL, so it needed no change).ClickHouse.Driver/PublicAPI/PublicAPI.Unshipped.txt,docs/overview.mdx,changelog.d/polyglot-insert-query-placement.features.md.Test
New
ClickHouse.Driver.Tests/InsertBinaryQueryPlacementTests.cs:uncompressed (8 cases): in URL mode the
queryparameter equals the statement and the decodedbody equals the RowBinary rows exactly (no prologue, no leading newline); in body mode there
is no
queryparameter and the body is the statement, one\n, then the same rows.BatchSize=500,MaxDegreeOfParallelism=4, 2500 rows) — this iswhat would fail if the placement did not survive
WithQueryId.RowBinaryWithDefaultsin URL mode: the second serializer path frames the body the same way.ClickHouseBulkCopySerializationExceptioncarrying that row (pins theserializingRowsstartvalue).
InsertOptions.QueryPlacementdefaults toBody.ClickHouseClientQueryOptionsTests.FullyPopulatedInsertOptionssets the new property so theexisting reflection copy-all tests cover it non-vacuously. No existing test was weakened.
Full suite green on net10.0: 10906 passed, 0 failed.
Docs / surface
docs/overview.mdx:InsertOptionstable row + a new "Insert query placement" section under"Inserting data".
changelog.d/(--checkpasses).CHANGELOG.md/RELEASENOTES.mduntouched, perchangelog.d/README.md.PublicAPI.Unshipped.txtupdated with the enum, its members and the property.Pre-PR validation gate
RowBinaryFormat/MapReadMode)object[]+ POCO,RowBinary+RowBinaryWithDefaults, compressed + uncompressed, single + parallel batches, error path)AGENTS.md(unique table names viaCreateTableName,parametrized cases, no unverified server-behavior claims)