Skip to content

feat(insert): option to send the INSERT statement in the query URL parameter - #580

Open
polyglotAI-bot wants to merge 3 commits into
mainfrom
polyglot/insert-query-placement
Open

feat(insert): option to send the INSERT statement in the query URL parameter#580
polyglotAI-bot wants to merge 3 commits into
mainfrom
polyglot/insert-query-placement

Conversation

@polyglotAI-bot

Copy link
Copy Markdown
Collaborator

Description

Adds InsertOptions.QueryPlacement, an opt-in flag that makes a binary insert
(InsertBinaryAsync, both the object[] and the POCO overload) send its
INSERT INTO ... FORMAT ... statement as the query URL parameter instead of writing it as the
first line of the request body.

var options = new InsertOptions { QueryPlacement = InsertQueryPlacement.Url };
await client.InsertBinaryAsync("events", columns, rows, options);

Why: in body mode the statement sits inside a payload that InsertOptions.Compressor compresses
by default (ZSTD), so proxies, load balancers and gateways that route or inspect on the query
parameter 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 answers 400 Bad Request
(HTTP request URI invalid or too long), verified against 26.7.3 — or a lower limit of an
intermediary. The body has no such limit, so Body stays the default and nothing changes for
existing callers.

This also removes an inconsistency between the insert entry points: InsertRawStreamAsync already
sends its statement in the URL, while InsertBinaryAsync could only use the body.

Design

  • An enum, not a bool. InsertQueryPlacement { Body = 0, Url = 1 } matches this client's
    existing option style (RowBinaryFormat, JsonReadMode, JsonWriteMode, MapReadMode), reads
    better at the call site than a bool, and leaves room for a future "URL until it gets too long"
    mode without another breaking rename.
  • Independent of Compressor. Where the statement goes and how the body is encoded are separate
    concerns; every combination is covered by tests.
  • Not surfaced on ClickHouseBulkCopy. That type is deprecated in favour of
    InsertBinaryAsync and builds its own InsertOptions from its own fields; adding a property
    there would widen a deprecated API. It keeps the default Body behavior.
  • Seam: the placement is passed to the batch serializers, which skip the query prologue in URL
    mode. In that mode no newline is written either — a stray leading \n would be read by the
    server as row data and corrupt the first row.

Changes

  • ClickHouse.Driver/InsertQueryPlacement.cs — new public enum.
  • ClickHouse.Driver/InsertOptions.cs — new QueryPlacement property, added to both
    WithQueryId and WithColumnTypes so 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 serializingRows flag that separates a
    prologue-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.csSendBatchAsync and SendPocoBatchAsync pass
    batch.Query as the sql argument of PostStreamAsync in URL mode (the URI builder already
    emits query for 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:

  • Wire framing, on a stubbed endpoint, over both placements × both insert paths × ZSTD and
    uncompressed (8 cases): in URL mode the query parameter equals the statement and the decoded
    body equals the RowBinary rows exactly (no prologue, no leading newline); in body mode there
    is no query parameter and the body is the statement, one \n, then the same rows.
  • Real server round trip in URL mode, both paths, compressed and not.
  • Multiple parallel batches (BatchSize=500, MaxDegreeOfParallelism=4, 2500 rows) — this is
    what would fail if the placement did not survive WithQueryId.
  • RowBinaryWithDefaults in URL mode: the second serializer path frames the body the same way.
  • A row that cannot be serialized in URL mode still throws
    ClickHouseBulkCopySerializationException carrying that row (pins the serializingRows start
    value).
  • InsertOptions.QueryPlacement defaults to Body.

ClickHouseClientQueryOptionsTests.FullyPopulatedInsertOptions sets the new property so the
existing 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: InsertOptions table row + a new "Insert query placement" section under
    "Inserting data".
  • Changelog fragment added under changelog.d/ (--check passes). CHANGELOG.md /
    RELEASENOTES.md untouched, per changelog.d/README.md.
  • PublicAPI.Unshipped.txt updated with the enum, its members and the property.
  • No version bump — additive feature, no breaking change.

Pre-PR validation gate

  • Works via the real entry point (real-server inserts in both modes, both paths)
  • Public API fits sibling conventions (enum option, like RowBinaryFormat / MapReadMode)
  • All applicable entry points + edge cases covered (object[] + POCO, RowBinary +
    RowBinaryWithDefaults, compressed + uncompressed, single + parallel batches, error path)
  • Tests pin intended behavior; no existing tests weakened
  • Public-API surface + changelog + docs updated
  • Convention compliance verified per AGENTS.md (unique table names via CreateTableName,
    parametrized cases, no unverified server-behavior claims)

…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.
Copilot AI lite review requested due to automatic review settings August 20, 2026 11:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 InsertQueryPlacement and InsertOptions.QueryPlacement (defaulting to Body) to control where the INSERT statement is sent.
  • Updates binary insert batch serialization and ClickHouseClient request 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 undefined InsertOptions.QueryPlacement value can make the serializer skip the prologue while urlQuery remains null, producing a request with no INSERT statement. Validate or normalize QueryPlacement before 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.

Comment thread ClickHouse.Driver/ClickHouseClient.cs
@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

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.
@polyglotAI-bot

Copy link
Copy Markdown
Collaborator Author

CI follow-up: the Cloud / Cloud Tests - AWS leg failed on InsertBinaryAsync_WithQueryInUrl_MultipleParallelBatches_InsertsEveryRow (2000 of 2500 rows). Pushed a2291e9 to fix it.

Diagnosis — a read-after-write race in the test, not a defect in the URL placement path:

  • The missing rows were exactly the last batch: the observed checksum 2499000 is the sum of Ids 0..1999, so rows 2000..2499 were simply not visible yet. Nothing was corrupted or misplaced.
  • Both placements send through the same PostStreamAsyncSendAsync(ResponseContentRead)HandleError path, and Parallel.ForEachAsync awaits every batch, so a rejected or dropped batch would have thrown rather than gone missing.
  • Each batch travels on its own connection. Against a service that spreads those over several replicas, the count that follows can be served by a replica that has not picked up the newest part yet. Every single-node leg (7 server versions, macOS, Windows) passed, and the test passed 10/10 locally in a loop.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants