Description
Reading a result in ClickHouseFormat.Native that spans more than one Native block silently
corrupts the data around every block boundary: the last row of a block and the first row of
the next block are dropped, and two rows whose columns are all null are returned in
their place. The row count is unchanged, no exception is raised, and the columns are not
Nullable — so a caller gets null for a non-nullable column with no signal that anything
went wrong.
This is independent of #3137 (Nullable null map): it reproduces with plain non-nullable
columns.
Steps to reproduce
- Run a query with
ClickHouseFormat.Native whose result spans several Native blocks
(either enough rows for the default 65409-row block, or SETTINGS max_block_size = 10).
- Iterate the result with
Client.newBinaryFormatReader(response) / reader.next().
- Look at the values around each block boundary.
Error Log or Exception StackTrace
(no exception — the data is silently wrong)
Expected Behaviour
All rows are returned, with the values the server sent. FORMAT TSV and
RowBinaryWithNamesAndTypes both return the correct values for the same query, so the wire
data is correct and this is a reader defect:
$ curl -s 'http://.../?query=SELECT toInt32(number) AS n FROM numbers(25) SETTINGS max_block_size=10 FORMAT TSV' | tr '\n' ' '
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
Actual, reading the same query with the Native reader (10-row blocks):
rows=25 values: 0 1 2 3 4 5 6 7 8 null null 11 12 13 14 15 16 17 18 null null 21 22 23 24
Rows 9/10 and 19/20 — the last row of a block and the first row of the next — are lost
and replaced by all-null rows. The same query read as RowBinaryWithNamesAndTypes returns
0 … 24 correctly.
With the server default block size the corruption lands on rows 65408/65409, 130817/130818,
196226/196227 of a 200000-row result (6 bad rows out of 200000, again with no error).
Code Example
Client client = new Client.Builder()
.addEndpoint("http://localhost:8123")
.setUsername("default").setPassword("...")
.setDefaultDatabase("default")
.compressServerResponse(false)
.build();
String sql = "SELECT toInt32(number) AS n FROM numbers(25) SETTINGS max_block_size = 10";
QueryResponse resp = client.query(sql, new QuerySettings().setFormat(ClickHouseFormat.Native)).get();
ClickHouseBinaryFormatReader reader = client.newBinaryFormatReader(resp);
while (reader.hasNext()) {
Map<String, Object> rec = reader.next();
System.out.print(rec.get("n") + " "); // prints: 0 1 ... 8 null null 11 ...
}
Root cause
The reader reads one row ahead, while NativeFormatReader re-publishes the schema for every
block — and publishing a schema reallocates the two row buffers:
NativeFormatReader.readBlock() calls setSchema(schema) once per block
(client-v2/src/main/java/com/clickhouse/client/api/data_formats/NativeFormatReader.java:135).
AbstractBinaryFormatReader.setSchema() unconditionally replaces both row buffers with fresh
arrays (.../internal/AbstractBinaryFormatReader.java:308-309):
this.currentRecord = new Object[columns.length];
this.nextRecord = new Object[columns.length];
AbstractBinaryFormatReader.next() swaps the buffers, calls readNextRecord() — the
read-ahead — and only then reads the currentRecord field to build the returned record
(.../internal/AbstractBinaryFormatReader.java:273-284).
So at a block boundary the read-ahead enters readBlock(), which allocates two new empty
arrays. Two things break at once:
readRecord(Object[] record) had already captured the old nextRecord reference as its
argument, so the first row of the new block is written into an array that is no longer
reachable — that row is lost.
next() then returns new RecordWrapper(currentRecord, …) reading the field after the
reallocation, so it wraps the freshly allocated, all-null currentRecord instead of the
buffer that held the last row of the previous block — that row is lost too, and the caller
gets a row of nulls.
That accounts for exactly two lost rows and two all-null rows per boundary.
Suggested fix
Do not discard the row buffers when the schema is re-published with the same shape:
in setSchema(), allocate currentRecord / nextRecord only when they are null or their
length differs from columns.length. With the buffers preserved, the read-ahead writes the new
block's first row into the array that is still this.nextRecord, and next() returns the
buffer that really holds the previous row — both symptoms disappear.
Additionally, next() reading the currentRecord field after readNextRecord() is a latent
hazard even once the buffers are stable; capturing the reference into a local before the
read-ahead makes the method independent of anything the read-ahead does to the fields.
Contrast case that must keep its current behavior: the first setSchema() call (from the
constructor, and from RowBinaryWithNamesAndTypes reading its header) must still allocate the
buffers.
A regression test should read a multi-block Native result (e.g. max_block_size = 10 over 25
rows) and assert every value, not just the row count — the row count is correct today.
Configuration
Environment
- Client version: 0.11.0-rc1 (
main, bc886ed)
- Language version: JDK 17
- OS: Linux
ClickHouse Server
- ClickHouse Server version: 26.9.1.1629
- Non-default settings:
max_block_size = 10 in the minimal reproduction only
- No table needed —
numbers() is enough
Found by automated analysis of the client while working on #3137, and verified against a live
ClickHouse server (the server's own FORMAT TSV output is the reference above), not by
inspection alone.
Description
Reading a result in
ClickHouseFormat.Nativethat spans more than one Native block silentlycorrupts the data around every block boundary: the last row of a block and the first row of
the next block are dropped, and two rows whose columns are all
nullare returned intheir place. The row count is unchanged, no exception is raised, and the columns are not
Nullable— so a caller getsnullfor a non-nullable column with no signal that anythingwent wrong.
This is independent of #3137 (Nullable null map): it reproduces with plain non-nullable
columns.
Steps to reproduce
ClickHouseFormat.Nativewhose result spans several Native blocks(either enough rows for the default 65409-row block, or
SETTINGS max_block_size = 10).Client.newBinaryFormatReader(response)/reader.next().Error Log or Exception StackTrace
Expected Behaviour
All rows are returned, with the values the server sent.
FORMAT TSVandRowBinaryWithNamesAndTypesboth return the correct values for the same query, so the wiredata is correct and this is a reader defect:
Actual, reading the same query with the Native reader (10-row blocks):
Rows
9/10and19/20— the last row of a block and the first row of the next — are lostand replaced by all-null rows. The same query read as
RowBinaryWithNamesAndTypesreturns0 … 24correctly.With the server default block size the corruption lands on rows 65408/65409, 130817/130818,
196226/196227 of a 200000-row result (6 bad rows out of 200000, again with no error).
Code Example
Root cause
The reader reads one row ahead, while
NativeFormatReaderre-publishes the schema for everyblock — and publishing a schema reallocates the two row buffers:
NativeFormatReader.readBlock()callssetSchema(schema)once per block(
client-v2/src/main/java/com/clickhouse/client/api/data_formats/NativeFormatReader.java:135).AbstractBinaryFormatReader.setSchema()unconditionally replaces both row buffers with fresharrays (
.../internal/AbstractBinaryFormatReader.java:308-309):AbstractBinaryFormatReader.next()swaps the buffers, callsreadNextRecord()— theread-ahead — and only then reads the
currentRecordfield to build the returned record(
.../internal/AbstractBinaryFormatReader.java:273-284).So at a block boundary the read-ahead enters
readBlock(), which allocates two new emptyarrays. Two things break at once:
readRecord(Object[] record)had already captured the oldnextRecordreference as itsargument, so the first row of the new block is written into an array that is no longer
reachable — that row is lost.
next()then returnsnew RecordWrapper(currentRecord, …)reading the field after thereallocation, so it wraps the freshly allocated, all-null
currentRecordinstead of thebuffer that held the last row of the previous block — that row is lost too, and the caller
gets a row of nulls.
That accounts for exactly two lost rows and two all-null rows per boundary.
Suggested fix
Do not discard the row buffers when the schema is re-published with the same shape:
in
setSchema(), allocatecurrentRecord/nextRecordonly when they arenullor theirlength differs from
columns.length. With the buffers preserved, the read-ahead writes the newblock's first row into the array that is still
this.nextRecord, andnext()returns thebuffer that really holds the previous row — both symptoms disappear.
Additionally,
next()reading thecurrentRecordfield afterreadNextRecord()is a latenthazard even once the buffers are stable; capturing the reference into a local before the
read-ahead makes the method independent of anything the read-ahead does to the fields.
Contrast case that must keep its current behavior: the first
setSchema()call (from theconstructor, and from
RowBinaryWithNamesAndTypesreading its header) must still allocate thebuffers.
A regression test should read a multi-block Native result (e.g.
max_block_size = 10over 25rows) and assert every value, not just the row count — the row count is correct today.
Configuration
Environment
main, bc886ed)ClickHouse Server
max_block_size = 10in the minimal reproduction onlynumbers()is enoughFound by automated analysis of the client while working on #3137, and verified against a live
ClickHouse server (the server's own
FORMAT TSVoutput is the reference above), not byinspection alone.