Repository navigation
TCP converters 3/6: POCO reads on the fused converter loop - #670
alex-clickhouse wants to merge 14 commits into
Conversation
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
🟡 Changes recommended
The test project remains a library, so its new child process cannot invoke Program.Main.
1 open finding
What changed in this PR
Moves native TCP POCO reads onto the shared converter layer while preserving existing behavior.
Changes:
- Adds compiled
Emitand dynamic-code-freeFillscatter tiers. - Caches LowCardinality dictionary conversions and adds canonical indexer reads.
- Expands differential, concurrency, integration, and fallback-path coverage.
| File | Description |
|---|---|
ClickHouse.Driver.Tcp/Types/Converters/IndexedReader.cs |
Adds canonical indexer reader. |
ClickHouse.Driver.Tcp/Types/Converters/DictionaryReaders.cs |
Caches converted dictionaries. |
ClickHouse.Driver.Tcp/Types/Converters/ConverterDerivation.Read.cs |
Selects indexer readers. |
ClickHouse.Driver.Tcp/Types/Converters/ColumnReader.cs |
Documents dictionary caching. |
ClickHouse.Driver.Tcp/Poco/PocoValueProjection.cs |
Retains legacy projection path. |
ClickHouse.Driver.Tcp/Poco/PocoTypeRegistry.cs |
Supports forced scatter tiers. |
ClickHouse.Driver.Tcp/Poco/PocoScatterTier.cs |
Defines Emit and Fill tiers. |
ClickHouse.Driver.Tcp/Poco/PocoReadPlan.cs |
Builds converter-based plans. |
ClickHouse.Driver.Tcp/Poco/PocoReadErrors.cs |
Centralizes read diagnostics. |
ClickHouse.Driver.Tcp/Poco/PocoColumnScatterFactory.cs |
Implements converter scatters. |
ClickHouse.Driver.Tcp/Poco/LegacyPocoScatterTier.cs |
Preserves legacy tier names. |
ClickHouse.Driver.Tcp/Poco/LegacyPocoColumnScatterFactory.cs |
Preserves differential reference path. |
ClickHouse.Driver.Tcp.Tests/Types/Converters/ReadRulesTests.cs |
Exposes shared test matrices. |
ClickHouse.Driver.Tcp.Tests/Types/Converters/ReadCombinatorTests.cs |
Compares POCO failure behavior. |
ClickHouse.Driver.Tcp.Tests/Types/Converters/IndexedReaderTests.cs |
Tests indexer-only reads. |
ClickHouse.Driver.Tcp.Tests/Types/Converters/DictionaryEntryCacheTests.cs |
Tests caching and concurrency. |
ClickHouse.Driver.Tcp.Tests/Program.cs |
Adds child-process entry point. |
ClickHouse.Driver.Tcp.Tests/Poco/PocoReadRulesTests.cs |
Tests conversion-rule parity. |
ClickHouse.Driver.Tcp.Tests/Poco/PocoReadRegistration.cs |
Registers differential POCO arms. |
ClickHouse.Driver.Tcp.Tests/Poco/PocoReadPlanTests.cs |
Updates plan coverage. |
ClickHouse.Driver.Tcp.Tests/Poco/PocoReadPlanFillTests.cs |
Repeats plan tests through Fill. |
ClickHouse.Driver.Tcp.Tests/Poco/PocoReadNoDynamicCodeTests.cs |
Tests no-dynamic-code execution. |
ClickHouse.Driver.Tcp.Tests/Poco/PocoColumnScatterFactoryTests.cs |
Tests tier selection and failures. |
ClickHouse.Driver.Tcp.Tests/Poco/LegacyPocoColumnScatterFactoryTests.cs |
Covers legacy tier behavior. |
ClickHouse.Driver.Tcp.Tests/Integration/PocoReadTierIntegrationTests.cs |
Compares tiers against a server. |
ClickHouse.Driver.Tcp.Tests/Integration/PocoReadIntegrationTests.cs |
Uses shared integration fixture. |
ClickHouse.Driver.Tcp.Tests/Integration/PocoReadIntegrationTestBase.cs |
Hosts shared POCO integration tests. |
ClickHouse.Driver.Tcp.Tests/Integration/PocoReadFillIntegrationTests.cs |
Runs integration tests through Fill. |
ClickHouse.Driver.Tcp.Tests/Differential/LegacyPocoRead.cs |
Adds the legacy reference arm. |
ClickHouse.Driver.Tcp.Tests/Differential/ClientArms.cs |
Registers converter-based POCO arms. |
ClickHouse.Driver.Tcp.Tests/ClickHouse.Driver.Tcp.Tests.csproj |
Configures the explicit test entry point. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| <!-- codecov.yml ignores the vendored source in ClickHouse.Driver.Common/Vendor, so do not instrument it. --> | ||
| <ExcludeByFile>**/Vendor/**/*.cs</ExcludeByFile> | ||
| <!-- The assembly has its own entry point (Program.cs), which a test starts in a child process. --> | ||
| <GenerateProgramFile>false</GenerateProgramFile> |
There was a problem hiding this comment.
This is not a defect. Microsoft.NET.Test.Sdk sets OutputType to Exe for each .NET target framework, and GenerateProgramFile=false keeps Program.Main as the entry point. dotnet msbuild -getProperty:OutputType gives Exe for net8.0, net9.0 and net10.0, the built dll has an entry point, dotnet exec ClickHouse.Driver.Tcp.Tests.dll --read-poco-rows runs, and PocoReadNoDynamicCodeTests passes on the three frameworks in CI.
|
Summary This PR moves native TCP POCO reads onto the converter layer, with a compiled loop, a bulk-read fallback, and weakly cached LowCardinality dictionaries. Static review found no actionable issues in the changed behavior or regression coverage. Tests and benchmarks could not be run in this sandbox. ✅ Approve. Final verdict✅ Approve Automated review by Polyglot AI (gpt-6-astra) of |
The dictionary readers convert the entries of a column once and keep them in a cache that keys on the column object (a ConditionalWeakTable, safe for concurrent use, which does not keep the column alive). The setup of Emit takes the entries from that cache, and so does the bulk read of a reader that is bound again. So the windows of a POCO read, which run the setup of the compiled loop for each window, and the element reader of an Array or a Map, which that setup binds again, convert the dictionary of a column once. Part of ClickHouse/integrations#801. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e indexer Variant, Dynamic, Nested, QBit, the geo types and Tuple() read only as their canonical type. Their columns make each value when it is read, and their Values makes the values of all the rows into a cache that the column keeps. The reader of these types (IndexedReader) reads the indexer, in Fill and in Emit, so a POCO read of a window of rows makes only the values of that window, as the old POCO scatter did with its indexer tier. The values do not change. Part of ClickHouse/integrations#801. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PocoColumnScatterFactory derives the reader of each POCO column from the column type and the property type (ConverterDerivation, with the read rules of POCO mapping at the root) and runs it in one of two tiers: - Emit: the reader's expression for one row is spliced into one compiled loop for each column, which sets the property of each row. The runtime chooses it where expression trees compile. - Fill: the reader's bulk read into a pooled buffer, then a setter delegate for each row. It compiles no code, so a runtime without dynamic code (RuntimeFeature.IsDynamicCodeCompiled false) uses it. A NULL that a read rule meets throws NullValueException with the row of the column; the scatter throws the POCO message for it, with the row of the result (rowOffset + row - start). The refusal and NULL texts are the ones of the old scatter (PocoReadErrors). Rows are still built with the compiled constructor of the descriptor. The old scatter moves unchanged to LegacyPocoColumnScatterFactory (with LegacyPocoScatterTier), which only the tests call through PocoReadPlan.BuildLegacy. It is the reference of the differential tests (ReferenceArms.Poco, LegacyPocoRead) and of the read-rule matrix test. The client's plan is registered as a candidate for every POCO facet, once with the tier that the runtime chooses and once with Fill forced; no outcome changes. PocoTypeRegistry.ForcedTier lets a test force the tier of a client. Tests changed on purpose: the POCO plan tests read decoded columns where a reader needs the decoded shape of the column type, and name the new tiers; the tests of the old tier selection move to LegacyPocoColumnScatterFactoryTests. Part of ClickHouse/integrations#801. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PocoReadPlanFillTests and PocoReadFillIntegrationTests run the POCO plan tests and the server-backed POCO read tests with PocoScatterTier.Fill forced, the tier of a runtime without dynamic code, which the test host does not choose by itself. PocoReadRulesTests compares each tier of the POCO plan with the old plan over the read-rule matrix of column types and CLR targets (enums, casts, nullable targets, tuples, maps, the array casts), for all the rows and for a window that starts past the first row of the block and of the result. Part of ClickHouse/integrations#801. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… type A converter tree reads the decoded shape of its column type, for example the null map and the inner column of a Nullable column. A column that a test builds can have the type name without that shape (a block from the server cannot, and a caller cannot build a Block). The plan then fails when it reads, with a message that names the column and the shape, in both tiers. Part of ClickHouse/integrations#801. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The old POCO scatter throws the NULL message with no inner exception. The new scatter kept the reader's NullValueException as the inner exception, whose text names the row of the column, while the message names the row of the result, so the two rows differ after the first window. It now throws the old exception exactly. PocoReadRulesTests compares the inner exception of each failure with the old plan's. Part of ClickHouse/integrations#801. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…omments The comments of ColumnReader and of the dictionary readers say that a tree keeps the converted dictionary of each LowCardinality column, in a table that does not keep the column alive. The comment of IndexedReader says that a Tuple() column stores its values. The tier comments say that the compiled loop, not Emit, sets the property, and that a property setter that throws can meet a failure of a later row first in the Fill tier. The comments of the reference path say what it is. Part of ClickHouse/integrations#801. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AssertFailsAsPocoMapping compared the readers with ClientArms.Poco, which is the client's plan on the converter derivation since the POCO tier moved, so the two LowCardinality failure-order tests compared the new path with itself. They compare with ReferenceArms.Poco (the old plan) again, and check that the client's plan, in each tier, gives the failure of the old plan. Part of ClickHouse/integrations#801. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The POCO plan tests and the POCO read integration tests run in two fixtures, one for each tier, so a test that names its tiers ran twice. The window test reads in the tier of its fixture. The tests that compare the tiers (Materialize_EveryTier_ProducesTheSameRows, ReadPlanFor_DifferentForcedTiers_CompileTheirOwnPlans and the integration test Materialize_EveryScatterTier_ReadsTheSameRows) move, unchanged, to fixtures that no other fixture derives from. A new test runs the scatter of each tier over the windows of one LowCardinality column and checks that its dictionary is converted once. The two base fixtures carry a justified suppression of NUnit1034. Part of ClickHouse/integrations#801. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 1 of the POCO reads (ClickHouse/integrations#801), F3: the comment of the span tier in LegacyPocoColumnScatterFactory and the comment of the interpreter test in LegacyPocoColumnScatterFactoryTests keep their text, with a colon and a full stop in place of the dashes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 1 of the POCO reads (ClickHouse/integrations#801), F2. A test starts the test assembly in a child process with the runtime switch System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported set to false in its runtime configuration (what the DynamicCodeSupport MSBuild property sets). There RuntimeFeature.IsDynamicCodeCompiled is false, so the POCO read plan chooses the Fill tier by itself, Expression.Compile interprets the compiled constructor of the rows, and Reflection.Emit is not available. The child reads a block of twelve column types (LowCardinality, Nullable, Array, Map, Tuple, Variant, a calendar conversion, an enum ordinal and a cast to object) in windows of two rows, and a NULL into a value type, and its lines must equal those of the compiled loop in the test process. The test project gets its own entry point (GenerateProgramFile is false) for the child; the test runner does not call it. The test takes about 0.3 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the Cloud job The tests of PocoReadIntegrationTests move, unchanged, to the abstract PocoReadIntegrationTestBase. PocoReadIntegrationTests (the tier that the runtime chooses) carries the Integration and Cloud categories, as before; PocoReadFillIntegrationTests (Fill forced) carries only Integration, because NUnit gives a fixture the categories of its base classes and the Cloud category is for behavior that a Cloud service changes, not for coverage (ClickHouse.Driver.Tcp/AGENTS.md). The names of the tests do not change. Part of ClickHouse/integrations#801. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 2 of the POCO reads (ClickHouse/integrations#801), F1. The Fill tier read every row of a window before it called a setter, so a NULL or a failed conversion of a later row came before the exception of a setter at an earlier row, where the compiled loop and the old plan give the setter's exception. The bulk read stays the path of a window that reads without a failure. When it fails, the scatter reads and sets the rows again one at a time and throws the first failure in row order (the failure of the bulk read when no row fails then). A test reads a Nullable(Int32) column of [1, NULL] and of [NULL, 1] into a property whose setter refuses 1, through the old plan, Emit and Fill: the same exception in each. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e it runs Review round 2 of the POCO reads (ClickHouse/integrations#801), F2. The test read the standard output of the child process to its end before it waited with a timeout, so a child that kept the stream open would hold the test past the timeout. It reads both streams while the process runs, waits for the process with a 2-minute cancellation, kills the process tree when the time passes, and then takes the two streams. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
484f6c2 to
38c5aa9
Compare
…e#451) * perf(read): pool the response read buffer instead of allocating per query (ClickHouse#670) The reader wrapped the HTTP response in a BufferedStream, which allocates a fresh ReadBufferSize buffer (8 KiB default) per query. Add a small read-only PooledReadBufferStream (ClickHouse.Driver/Formats) that buffers reads over a buffer rented from ArrayPool, and use it on the response path. The BinaryReader -> PeekableStreamWrapper chain does not propagate Dispose to inner streams (PeekableStreamWrapper.Dispose is `new`, not an override), so the reader holds the pooled stream and returns its buffer explicitly on Dispose (idempotent), and on the error path in FromHttpResponseAsync. leaveOpen: true because the HttpResponseMessage owns and disposes the raw content stream. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Raise default ReadBufferSize to 64 KiB now that it's pooled The 8 KiB default (v1.3.0) was chosen because the response read buffer was a fresh per-query allocation and 512 KiB hit the large object heap. Now that the buffer is pooled (rented from ArrayPool, not allocated per query), the small default is no longer necessary. Raise it to 64 KiB to cut read syscalls/refills on large responses; 64 KiB stays under the ~85 KiB LOH threshold, so a pool miss under high concurrency still allocates on the normal heap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Address review: fix stale doc default + validate Read arguments - PooledReadBufferStream doc comment said "8 KiB by default"; the default is now 64 KiB. - Read(byte[], offset, count) now validates its arguments per the Stream contract (null array, negative offset/count, offset+count past the buffer) instead of returning 0 or failing later with a less-clear exception. Added a test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: clarify short-read semantics in pooled read buffer comments Drop the LOH/64 KiB rationale from the ReadBufferSize and PooledReadBufferStream doc comments, and document that PooledReadBufferStream.Read may return fewer bytes than requested (callers loop via ExtendedBinaryReader for exact-count reads). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Address review: update stale 8 KiB doc + assert against ClickHouseDefaults - ClickHouseClientSettings.ReadBufferSize XML doc said "Default: 8192 (8 KiB)"; update to 64 KiB and note the buffer is now pooled. - ConnectionStringBuilderTests asserted the default against the 8*1024 magic number, which now mismatches; assert against ClickHouseDefaults.ReadBufferSize. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

This is part 3 of the stack of pull requests for ClickHouse/integrations#801, a converter layer for the native TCP
client. It is stacked on #668 (part 2b,
Block.ReadAs<T>andCanReadon the converter layer), which is on #667, #666and #665. It moves POCO mapping (
QueryAsync<T>) onto the converter derivation. WhatQueryAsync<T>accepts, the valuesthat it gives and its error messages do not change.
What
its property, with the read rules of POCO mapping (a CLR enum from its ordinal, a cast that the CLR allows,
a nullable target, and a nullable column read as a value type that throws at the first
NULL).sets the property of each row (
PocoScatterTier.Emit).RuntimeFeature.IsDynamicCodeCompiledis false), the plan reads therows with the tree's bulk read into a pooled buffer and sets each property through a setter delegate
(
PocoScatterTier.Fill). That tier builds no expression from the tree, because the expression interpreter cannot runthe span locals of the trees.
so the 256-row windows of one block convert the dictionary once, also for a dictionary inside an
Arrayor aMap.Variant,Dynamic,Nested,QBitand the geo types read through the indexer of their columns, so a window of rowsmakes only the values of that window, as before. Their
Valueswould make the values of the whole block into a cache.How
PocoColumnScatterFactorykeeps the old checks and texts: a column that does not surface its codec's element type, anda property type that the column cannot be read as (with the types that it reads as). The texts are in
PocoReadErrors.NULLthat a read rule meets throws an internal exception that holds the row of the column. The scatter throws theold exception for it, with the row of the result (
rowOffset + row - start).LegacyPocoColumnScatterFactory, which only the tests call, throughPocoReadPlan.BuildLegacy. It is the reference of the differential tests. The last part of the stack deletes it withPocoValueProjection.Why
The POCO tier had its own copy of the conversion rules (
PocoValueProjectionand the codecs' projections). With theconverter derivation,
QueryAsync<T>,Block.ReadAs<T>andCanReaduse one set of rules. The compiled loop also makesthe composite and LowCardinality POCO reads faster (see Benchmarks).
What a user sees
No change in behavior: the same accepted readings, values, error messages and order of failures, in both tiers. A
property setter that throws gives the same first failure in row order as before: when the bulk read of a window fails,
the
Filltier reads and sets the rows of that window again one at a time. There is no changelog fragment and no docschange, because nothing visible changes.
Tests
the tier that the runtime chooses and once with the
Filltier forced, against the old plan, for all rows and for atail that starts above zero. No change is declared. The reference outcome counts do not change.
PocoReadRulesTestscompares both tiers with the old plan for 36 column types and 53 targets (enums, casts toobjectand to interfaces, nullable targets, tuples, maps, the array casts), for all rows and for a window that startspast the first row of the block and of the result: the same refusal, values, or failure with the same inner exception.
PocoReadPlanFillTestsandPocoReadFillIntegrationTestsrun the POCO plan tests and the server-backed POCO readtests again with the
Filltier forced. The second does not run in the Cloud job: it is coverage, not Cloud behavior.PocoReadNoDynamicCodeTestsstarts the test assembly in a child process whose runtime configuration turns dynamic codeoff (
System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported: false, which theDynamicCodeSupportMSBuild property sets). There the plan chooses the
Filltier by itself andExpression.Compileinterprets theconstructor of the rows. The child reads twelve column types (LowCardinality, Nullable, Array, Map, Tuple, Variant and
the read rules) and a
NULLinto a value type, and gives the rows and the failure text of the compiled loop. For thistest, the test project has its own entry point, which the test runner does not call. The test kills the child process
if it does not end in 2 minutes.
Array, the liftingdictionary, two columns, eight threads at once, through the scatter of each tier, and a column that nothing else holds
is not kept alive); the indexer reads of the canonical-only types; the tier choice; the
Filltier with a tree that hasno expression; a property setter that throws and a
NULL, in both orders, through the old plan and both tiers.PocoReadPlanTests: ten tests read columns that a codec decoded where a converter needs the decoded shape of thecolumn type (the
Nullable,ArrayandStringcolumns); the assertions do not change. A new test builds aNullable(Int32)column without that shape and pins the failure, which names the column and the missing shape. Onlytests can build such a block:
Blockhas only an internal constructor. The tests read in the tier of their fixture,and the window test is one test in each fixture instead of two cases.
Materialize_EveryTier_ProducesTheSameRowsandReadPlanFor_DifferentForcedTiers_CompileTheirOwnPlanstoPocoColumnScatterFactoryTests, and the integration testMaterialize_EveryScatterTier_ReadsTheSameRowstoPocoReadTierIntegrationTests; they name the new tiers. The testof the old indexer tier under the expression interpreter and the three tests of the old tier choice move to
LegacyPocoColumnScatterFactoryTests, unchanged except for the names of the legacy types.PocoReadIntegrationTestsmove unchanged to an abstract base class,PocoReadIntegrationTestBase, whichcreates its client through a virtual method, so that the
Fillfixture can force the tier. The names of the tests donot change.
Benchmarks
The performance gate of the stack: the converter benchmarks of part 0 on part 0 (base) and on this part (head), run
alternately in 4 rounds in one quiet window on the same machine. A shape fails when its ratio head/base is above 1.05 in
every pair. Measured on
015f7c2d; the later commits change only tests, comments and the failure path of theFilltier.The POCO read shapes are 0.45x to 0.91x for the composites and the LowCardinality columns, and 0.95x to 1.03x for the two
leaf shapes, which do the same work for each row as before. The columnar shapes that convert are 0.33x to 0.79x, and the
write shapes do not change (0.97x to 1.01x).
The one
FAIL,Columnar(LowCardinalityNullableStringAsString)at 1.16x, is not a cost of this part. That shape reads aLowCardinality(Nullable(String))column asstring, whichReadAsreturns as the column itself, and this part does notchange that code. The benchmarks run with tiered compilation off, so the JIT compiles each method once, at its first
call, at the next free place in its code memory. The benchmark's setup builds the POCO read plan for every shape, also
for the columnar ones, and with this part that compiles more methods before the measurement. So the method that reads the
column (
NullableLowCardinalityReferenceColumn<T>.Values) starts at another 64-byte offset, and its 10,000-row loop,which the JIT does not align because it has a call, covers three 64-byte lines of code where it covered two:
StringColumn.Values, is the same with and without the setup that slows theread. Only the embedded addresses differ.
the read from 37.4 µs to 33.1 µs a block, with no code of this part; two extra methods move it back, and it is slow
again. In the benchmark's own processes, the method starts at offset 32 (mod 64) on part 0 and at 0 on this part.
block (1.02x), within the run-to-run spread.
Decision: the shape is a code-placement artifact of the benchmark setup, not a cost of this part, and the benchmark does
not change.
🤖 Generated with Claude Code