Skip to content

TCP I2: Add Array(T) support - #435

Open
alex-clickhouse wants to merge 10 commits into
tcp/epic-i1-nullablefrom
tcp/epic-i2-arrays
Open

TCP I2: Add Array(T) support#435
alex-clickhouse wants to merge 10 commits into
tcp/epic-i1-nullablefrom
tcp/epic-i2-arrays

Conversation

@alex-clickhouse

@alex-clickhouse alex-clickhouse commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Stacked on tcp/epic-i1-nullable. Implements Array(T) for the TCP/Native client and — because the ergonomic write path needed it — finalizes the codec write interface.

Write interface: IColumnWriteState

Array is the first codec whose state prefix and body must agree on work derived from the values, so IColumnCodec grows a per-slice scratch phase:

  • BeginWrite(column, start, length)IColumnWriteState (default null) — computed once per slice, handed to new state-aware WriteStatePrefix/WriteColumn overloads, disposed by the caller after the body. A codec needing no scratch returns null and those overloads fall back to the self-contained ones, so every existing codec is unchanged.
  • WriteStatePrefix now takes the column slice. Each block's prefix reflects only that block's rows, and a type whose prefix is data-dependent (a future Dynamic's runtime type list) must see the same rows the body will write. Fixed-prefix codecs ignore the arguments; NullableColumnCodec forwards them to its inner unchanged.
  • BlockWriter drives BeginWrite → prefix → body → dispose per column. ColumnCodecExtensions.WriteFull does the same for callers outside the block layer.

Array(T)

  • ArrayColumnCodec<TElement> — delegates the state prefix to the inner codec, then reads/writes a per-row num_rows × UInt64 LE offsets stream (cumulative element ends) followed by the inner type's encoding for every element concatenated end-to-end. An empty column is zero bytes.
  • ArrayValueColumn<TElement> — a flat inner column plus per-row offsets, each row materialized lazily as TElement[]. This is the wire's own layout, so it doubles as a zero-copy write source when a read-back column is re-inserted.
  • Ergonomic (jagged) write builds no flat buffer. Per-row lengths are summed once into slice-relative offsets, then the inner is driven by its shape:
    • a leaf inner — the fixed-width types and String, i.e. those implementing the new internal ISpanWritableCodec<T> — takes each row as a contiguous run (a bulk blit for fixed-width), straight from the source arrays;
    • a sectioned inner (Nullable's null-map, LowCardinality's dictionary, a nested Array's offsets) has to see every element as one column so each section is emitted once spanning the whole run — driving it row by row would interleave those sections and corrupt the stream. It gets ConcatColumn<T>, a lazy flattening view (row cursor, binary-search fallback), not a copy.
  • The registry pipeline is non-generic (codecs come back as IColumnCodec and the inner element type arrives as a runtime Type), so a small non-generic ArrayColumnCodec factory closes the generic codec over the inner's element type — one reflective instantiation per element type, cached as a constructor delegate. The single (IColumn<TElement>) cast at the read boundary is the only cast this leaves. Composites recurse: Array(Array(T)), Array(Nullable(T)).

New public API: IArrayColumn<TElement>

The columnar read surface of a decoded array column. Values/the indexer allocate a TElement[] per row; this exposes the flat wire layout instead — InnerValues + Offsets, where row i is InnerValues.Slice(Offsets[i], Offsets[i + 1] - Offsets[i]) — so a read-only caller iterates without allocating a row array. Inner hands back the flat inner column so a nested composite (Array(Array(T)), later Array(Tuple(...))) can be walked all the way down without materializing intermediate levels. All borrowed views, valid only while the owning block is alive; Offsets is bounded to RowCount + 1 rather than exposing the rented buffer's length.

Insert memory bound

Blocks split by row count alone, so peak client memory during a large insert is held by the between-column flush backstop — which is now caller-tunable via a new maxSendBufferBytes parameter on the internal InsertAsync (default BlockWriter.DefaultFlushThresholdBytes, validated positive). A single column larger than the cap still buffers in full. Inserted before handlers, so it is source-breaking for positional callers of that internal API.

Safety / correctness

  • Read: offsets validated monotonic and in-int-range as they are decoded; malformed or oversized offsets throw ClickHouseProtocolException. The offsets stream's own byte length is checked against Array.MaxLength before renting.
  • Write: the flattened element count is guarded against Array.MaxLength rather than truncating longint. A null row throws an ArgumentException naming the row and pointing at Array(Nullable(T)).
  • The read path's pooled buffers (offsets, wire scratch) are returned exactly once, and ownership transfers to the column only on success — the read-boundary cast sits inside the try, so an element-type mismatch leaks neither the buffer nor the inner column. The write path pools nothing.

Tests

  • Unit (ArrayColumnCodecTests) — trimmed to what a server round-trip cannot reach: corruption rejection (non-monotonic offsets, an offset past int.MaxValue), an empty column consuming no bytes, row access past RowCount, the short-offsets constructor guard, CanWrite/ElementType/NullPlaceholder, and the type-resolution errors.
  • Integration — an Array(T) round-trip case for every supported inner type (all integer widths, enums, floats, bool, string, dates/datetimes/DateTime64, UUID, IPs, all decimal widths, intervals, BFloat16, Time/Time64), plus Array(Array(T)), Array(Nullable(T)) and Array(Array(Nullable(T))); jagged and dense columns split across blocks; a read-back dense column re-inserted; a server-generated array via range(); and the IArrayColumn zero-copy surface including nested recursion.
  • Nullable(Array(T)) is server-rejected, so Array is the one type without a Nullable-wrapped case; Array(Nullable(T)) composes nullability the other way instead.
  • One pre-existing test that used Array as its "unsupported type" example is repointed at Tuple (still unsupported).

Full TCP suite green against a live server: 741 tests (454 unit + 287 integration).

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown

Triage

Category: featureRisk: high

Summary
This PR adds Array(T) read/write support to the TCP/Native client (epic I2). It introduces ArrayColumnCodec<TElement> (binary offsets + element stream codec), ArrayValueColumn<TElement> (the dense wire-shaped column), ConcatColumn<T> (a lazy jagged-to-flat view for sectioned inner codecs), and IArrayColumn<TElement> (zero-copy public read interface). It also extends IColumnCodec with four new members — BeginWrite, two new WriteStatePrefix overloads, and a new WriteColumn overload — replacing the old zero-arg WriteStatePrefix(writer) signature. BlockWriter is updated to call the new BeginWrite/state pattern, ClickHouseTcpConnection.InsertAsync gets a new maxSendBufferBytes parameter, and NullableColumnCodec.WriteStatePrefix is updated to the new signature. Tests cover 40+ inner element types, nested/nullable composition, block-splitting, offset validation, and server-generated array reads via live integration.

What this impacts

  • ClickHouse.Driver.Tcp/Types/ — new ArrayColumnCodec, ArrayValueColumn, ConcatColumn, IArrayColumn, IColumnWriteState; IColumnCodec interface revised; NullableColumnCodec updated
  • ClickHouse.Driver.Tcp/Format/BlockWriter.cs — now drives BeginWrite/state-aware write pattern
  • ClickHouse.Driver.Tcp/Protocol/ClickHouseTcpConnection.cs — new maxSendBufferBytes param on InsertAsync
  • Public API: new IArrayColumn<TElement> interface exposed; IColumnCodec interface extended

Concerns

  • ReflectionArrayColumnCodec.BuildFactory uses BindingFlags.NonPublic + MakeGenericMethod to close the generic codec; cached per element type, but this is a new use of reflection in the codec-resolution path. High-risk rule: "any use of reflection."
  • IColumnCodec interface breaking change — the old WriteStatePrefix(ClickHouseBinaryWriter writer) member is removed and replaced with WriteStatePrefix(writer, column, start, length). Any external implementor that overrode the old signature will compile (it's now unrelated) but silently stop being called. The new signature has a default impl, so compilation doesn't break, but call semantics change for overriders.
  • Binary protocol / TCP write pathArrayColumnCodec owns the offsets stream encoding and the per-element serialization ordering; a bug here would produce a silently corrupt stream accepted by some server versions.
  • Stacked PR — based on tcp/epic-i1-nullable (I1); the diff should be reviewed with that base branch loaded, as changes from I1 may appear in this diff.
  • ConcatColumn binary search mutable statehintRow is a mutable field on an instance handed to the inner codec's BeginWrite; concurrent read would race, but the write path is single-threaded, so this is acceptable — worth confirming reviewers agree.

Required reviewer action

  • PR body must include an architectural description before review. (Body is present and detailed; confirm it covers the IColumnCodec signature migration rationale and the reflection caching strategy explicitly.)

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

Adds ClickHouse Array(T) support to the TCP/Native client type system, enabling native-format read/write of array columns (including nested arrays and arrays of nullable elements) and extending the TCP test suite to cover these composites.

Changes:

  • Register Array(T) in the TCP ColumnCodecRegistry and implement ArrayColumnCodec (offsets + flattened values stream).
  • Introduce ArrayValueColumn<TElement> as the dense (wire-layout) decoded column shape for zero-copy writes.
  • Add dedicated unit tests for ArrayColumnCodec and expand TCP insert round-trip coverage to include Array(T) across supported inner types.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
ClickHouse.Driver.Tcp/Types/ColumnCodecRegistry.cs Registers the Array codec factory for recursive type resolution.
ClickHouse.Driver.Tcp/Types/Codecs/ArrayColumnCodec.cs Implements Array(T) read/write, measuring, and type bridging via cached shapes.
ClickHouse.Driver.Tcp/Types/ArrayValueColumn.cs Adds a dense array column representation (inner flat column + offsets).
ClickHouse.Driver.Tcp.Tests/Utilities/InsertRoundTripCase.cs Adds integration round-trip cases for Array(T) across supported element types and compositions.
ClickHouse.Driver.Tcp.Tests/Types/NullableColumnCodecTests.cs Updates an “unsupported inner” test to use Tuple instead of Array.
ClickHouse.Driver.Tcp.Tests/Types/ColumnCodecRegistryTests.cs Updates an “unsupported type” test to use Tuple instead of Array.
ClickHouse.Driver.Tcp.Tests/Types/ArrayColumnCodecTests.cs Adds unit tests covering array read/write, slicing, measuring, element typing, and corruption rejection.

Comment thread ClickHouse.Driver.Tcp/Types/Codecs/ArrayColumnCodec.cs

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

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Comment thread ClickHouse.Driver.Tcp/Types/Codecs/ArrayColumnCodec.cs Outdated
Comment thread ClickHouse.Driver.Tcp/Types/Codecs/ArrayColumnCodec.cs Outdated
@alex-clickhouse
alex-clickhouse force-pushed the tcp/epic-i2-arrays branch 2 times, most recently from f98211f to 3516b90 Compare July 16, 2026 16:17
@alex-clickhouse alex-clickhouse changed the title Add Array(T) support for the TCP client TCP I2: Add Array(T) support Jul 20, 2026
@alex-clickhouse
alex-clickhouse force-pushed the tcp/epic-i2-arrays branch 2 times, most recently from a20008b to 5ef30f4 Compare July 22, 2026 15:26
@alex-clickhouse
alex-clickhouse requested a review from Copilot July 22, 2026 15:27

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

Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.

@alex-clickhouse
alex-clickhouse force-pushed the tcp/epic-i2-arrays branch 2 times, most recently from 80377df to 429eae4 Compare July 28, 2026 15:26
@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

alex-clickhouse and others added 10 commits July 29, 2026 15:36
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements IColumnCodec.Densify for the Array codec: converts the ergonomic
jagged (T[]-per-row) column into the dense wire shape — a per-row offsets array
plus a single flat inner column holding every element end-to-end — in one pass,
so a later measure or write indexes/slices it with no re-projection. The flat
inner run is itself densified through the inner codec, so a nested composite
inner (e.g. Array(Nullable(T))) becomes dense all the way down. An already-dense
column whose inner is already dense is returned unchanged (by reference).

Not yet wired into the write/measure paths; that arrives with the pipeline-level
densify and the removal of the ergonomic write/measure branches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ecedes them

With the insert pipeline densifying every column first, an Array column reaching
WriteColumn or MeasureRowBytes is always the dense wire shape (offsets + a flat
inner column). Drops the jagged branches from both: WriteColumn writes the
offsets and the flat inner directly, and MeasureRowBytes prices a row from its
offset range. The jagged flatten, null-row rejection, and size guard now live
only in Densify (which SumElementCount still backs). The direct-write slice tests
use the harness's WriteDenseAsync, and the null-row tests assert on Densify, which
is where that validation now runs.

Also adds InsertAsync_DenseReadbackReinserted_RoundTripsThroughSelect: for every
InsertRoundTripCase it re-inserts the dense read-back column (here the array and
primitive/nullable dense columns) into a second table and reads it back, covering
each supported type's dense insert form end-to-end alongside the ergonomic one.
Later branches inherit it, so their added types (Tuple, Map, Nested, …) gain the
same dense coverage as they are introduced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Establishes the final ergonomic write path: the block layer builds a
per-slice BeginWrite state shared by the state prefix and body phases;
IColumnWriteState carries it. Array writes its offsets then drives each
row's run straight from the jagged source — a bulk blit for a fixed-width
inner, a flattening ConcatColumn view for a sectioned inner — with no
densify pre-pass. Drops the now-redundant maxSendBufferBytes insert knob.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PR 435 feedback:
- Dispose the inner column if the element-type cast fails on the
  zero-row read path, matching the exception safety of the non-empty
  path.
- The null-element error message referenced Array.Empty<T>() with no T
  in scope; reword to plain guidance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… the gaps

Five codec round-trips asserted values that the Array(T) cases in
InsertRoundTripCase already prove through a real INSERT/SELECT:

  ReadColumn_WriteThenRead_VariableInnerRoundTrips  -> Arrays("String", ...)
  ReadColumn_WriteThenRead_NestedArrayRoundTrips    -> Arrays<byte[]>("Array(UInt8)", ...)
  ReadColumn_WriteThenRead_NullableInnerRoundTrips  -> Arrays<uint?>("Nullable(UInt32)", ...)
  WriteColumn_DenseArrayValueColumn_...             -> InsertAsync_DenseArrayValueColumnSplitAcrossBlocks
  WriteColumn_SlicedRange_WritesOffsetsRelative...  -> InsertAsync_JaggedArrayColumnSplitAcrossBlocks

The last two are worth spelling out. The dense column is not unreachable
from integration the way an internal shape usually is: this epic's own
InsertAsync_DenseArrayValueColumnSplitAcrossBlocks builds the same
ArrayValueColumn, and InsertAsync_DenseReadbackReinserted re-inserts the
dense read-back of every case. And the slice test wrote a slice and read it
back, so a paired offset bug cancels out -- the jagged split-across-blocks
integration test drives the same per-block slicing against a server and is
strictly stronger.

ReadColumn_WriteThenRead_FixedWidthInnerRoundTripsWithEmptyRows became
Values_AfterMaterializing_AgreesWithTheIndexer, narrowed to the two things
integration cannot see: TypeName, and the warm-cache branch of the indexer.
ArrayValueColumn.Values materializes a cache while the indexer reads
`cache is not null ? cache[row] : Materialize(row)`, and AssertColumnsEqual
only calls GetValue on a cold column, so the cached branch ran nowhere.

Three new integration cases, and ReadColumn_EveryRowEmpty deleted because
the first of them now covers it:

- every row empty (all-zero offsets, no values stream)
- an empty *inner* array, the only shape putting an equal consecutive pair
  in the inner codec's own offsets stream
- Array(Array(Nullable(T))), composing the array recursion with the nullable
  state prefix that the half-cases each cover separately

Not verified locally: the integration suite needs Docker for Testcontainers,
which is unavailable in this environment, so the new cases are compile-only.

Co-Authored-By: Claude <noreply@anthropic.com>
Materialize read offsets[row] and offsets[row + 1] straight out of the offsets buffer,
which is normally a pooled array longer than the column. A row past RowCount therefore read
the buffer's tail — and because a previous, larger read leaves monotonic offsets behind
there, the pair read back is usually still increasing, so the column handed out a
real-looking slice of the inner column rather than throwing. Indexing through the
RowCount-sliced Offsets span makes that a bounds failure.

The row count itself has to stay a parameter here: the inner column is flat (one entry per
element across every row, so its height is the element total) and the offsets are pooled, so
neither argument encodes it. The constructor validates the one input that can disagree
instead — an offsets array too short for the row count.

Co-Authored-By: Claude <noreply@anthropic.com>
IArrayColumn<TElement> already carried the flat elements as a span, but a span
of a composite element type hands back materialized values — for
Array(Array(T)) InnerValues is a ReadOnlySpan<T[]>, one freshly allocated array
per inner row, which defeats the purpose of the zero-copy view.

Promote the inner column itself onto the interface. When the element type is a
composite it pattern-matches to that type's own columnar view, so a nested
composite can be walked to the leaf run without materializing an intermediate
level.

Also add the first integration coverage of IArrayColumn: the interface shipped
untested, with no test anywhere naming it.
The interface promised iteration "without allocating", unqualified. That holds
only for a fixed-width element type. For a string-like or composite element the
span's type is the materialized form, so producing it allocates per element — a
string each for Array(String), an inner array each for Array(Array(T)). The
previous commit added Inner precisely so those cases have a way down that does
not materialize, but documented the remedy without documenting the problem.
The leading comments on WriteBody and BuildState had grown into paragraphs
that re-derived the whole leaf-vs-sectioned inner story, and BeginWrite,
WriteStatePrefix and ArrayWriteState each restated a slice of it again. Each
header is now a line or two on what the method does, with the reasoning moved
to the branch it justifies. The leaf-vs-sectioned rationale itself lives once,
on ISpanWritableCodec, which the leaf branch points at.

Also corrects the type doc: it claimed the write path is always the dense
column and that the ergonomic form is projected into it beforehand. That was
true under the densify step, which has since been removed -- both shapes are
now written as they come.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants