Skip to content

Binary datapoints: read, stream, dataframe ingest and compression 2 - #177

Merged
JosteinGj merged 8 commits into
mainfrom
feat/binary-dataframe-ingest
Oct 16, 2026
Merged

JosteinGj merged 8 commits into
mainfrom
feat/binary-dataframe-ingest

Conversation

@JosteinGj

@JosteinGj JosteinGj commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

The whole binary datapoint stack in one PR: binary read (buffered and streamed), dataframe ingest, and compression 2 in both directions. #175 and #176 were closed into it with their commits unchanged, so each commit still reads on its own:

commit what
edc7392 Read datapoints through the binary endpoint, decoded in parallel (was #175)
0b97418 Stream the binary read frame by frame as it arrives (was #176)
2bebeaf One table for buffered and streamed reads, long or wide (was #176)
0d62196 Insert a dataframe through the binary endpoint, straight from Arrow
2592a60 Read compression 2 frames, the api's relayed ClickHouse batches
242ca16 Send ingest frames compressed in-band, as compression 2
5c7dfa5 Default binary ingest to zstd level 3
cfd029a Default binary ingest to compression 2, in-band buffers

Merging only the first commits would put a read on main that understands only compression 1, which an api on the arrow branch never sends.

Binary read

  • Rust: TimeSeriesService::retrieve_datapoints_binary sends the ordinary retrieve body to POST /timeseries/data/list/binary and returns one columnar BinaryDatapoints per series. stream_datapoints_binary returns a Stream with one item per frame. Frames are decoded on spawn_blocking, at most min(cores, 8) in flight, kept in order, while the rest of the response downloads. The buffered call collects that stream and builds each series once, at its final size.
  • Python (sync client):
    • retrieve_datapoints_binary returns a BinaryReadResult, a sequence of per-series BinaryDatapoints.
    • retrieve_datapoints_binary_stream returns a single-use BinaryDatapointReader.
    • Both offer external_ids, a long-table Arrow stream and to_polars(wide=False|True), all through one helper (_tables.py) so the two cannot drift.
    • Each BinaryDatapoints implements the Arrow PyCapsule stream protocol, so polars, pyarrow and pandas read it without copying. The package adds no runtime dependency.
  • Errors:
    • A body without the DHDE end marker was cut short and is a 502, never fewer points.
    • A stream that breaks off ends with an error item after the items that already arrived.
    • A refusal is the Err before there is a stream.
  • Differences from the JSON read:
    • There is no cursor (it is a 400); read on by moving start past the last timestamp.
    • A series with no points is absent rather than empty.
    • An unknown series is a 400 for the whole request.
  • Aggregates: codec 2 frames come back as avg/min/max/sum. Decimals stay exact, as an unscaled i128 plus a scale.

Dataframe ingest

  • Rust: insert_table_binary(&batches, timestamp, &options) takes wide Arrow RecordBatches: a timestamp column plus one column per series, named by external id.
  • Python: insert_dataframe_binary(df, timestamp=None, zstd_level=None, compression=None) takes a polars, pandas or pyarrow table through the Arrow C stream.
  • Timestamp column: with no timestamp=, it uses the pandas index if that is a timestamp, then a timestamp column, then the only timestamp-typed column.
  • Round trip: what to_polars(wide=True) returns goes back in unchanged.
  • Values:
    • A null means no point at that timestamp, and pandas exports NaN as null.
    • Unsorted rows are sorted, and a repeated timestamp keeps its last value.
    • Values are stored the way the string path stores them; the offline tests decode both paths' frames and compare.
  • Refused before anything is sent:
    • a column that does not name a readable series (404);
    • a timestamp with no time zone, or finer than a millisecond (422);
    • a column its series' value type cannot hold (422).

Compression 2

  • Read: the api now relays ClickHouse's compressed batches as compression 2: an Arrow IPC stream whose buffers are zstd-compressed in-band. Both compressions are read. The fixtures are written by the platform's own DatapointFrameWriter.relayStream, and the old compression 1 fixtures are kept as read_*_zstd.bin.
  • Ingest:
    • BinaryIngestOptions::compression takes FrameCompression::Buffers (compression 2, the default) or FrameCompression::Payload (compression 1).
    • Buffers is the default because the consumer relays those batches into ClickHouse without decompressing them (platform c9e9351dd). On 100M float points from polars, time until the last point was readable fell from 15.5 s to 13.2 s, at the same cost to the client.
    • In Python it is compression="buffers" | "payload" on the three binary ingest methods, "buffers" by default.
    • At compression 2 the raw length the server recounts is read back from the stream that was written (in_band_raw_len).
    • The ingest side of compression 2 needs platform 357794cce or later.
  • Default zstd level: now 3 instead of 9. On noisy floats, level 9 compressed no smaller than level 1 and took several times as long.
  • Breaking for Rust callers: BinaryIngestOptions has a new public field, so code that builds it as a struct literal no longer compiles.

Measured

Release build, 100M float rows across 10 series:

time
read, server alone (compression 2) 2.20 s, down from 6.5 s at compression 1
retrieve_datapoints_binary_stream(...).to_polars() 2.33 s
retrieve_datapoints_binary(...).to_polars() 2.67 s
insert_dataframe_binary from polars 3.1 s, against about 75 s through insert_from_lists_binary

Tests

  • Offline (Rust): the Java-written fixtures at both compressions, including several batches per frame and a series cut across frames. Also: frames split at any chunk size, cut-short and bad-frame streams, Buffers frames read back the same as Payload frames, and dataframe-vs-string parity.
  • Offline (Python): stand-in servers for the read, the stream and the dataframe round trip, including compression 2 frames.
  • Live: test_datapoints_binary_read, the wide polars round trip at both compressions, and a 120k-row pandas round trip.
    • Binary ingest now defaults to compression 2, so every live binary ingest test needs an api that includes 357794cce; an older api refuses the frames with a 400.
    • The Java decoder accepted every compression 2 frame the SDK wrote.

Docs

Tracked in IntelliStream-DataHub/datahub-sdk-docs#115 and written in IntelliStream-DataHub/datahub-sdk-docs#116, stacked on the platform side's #114:

  • ingest-timeseries.mdx: the dataframe ingest, the compression option and the zstd default;
  • query-and-aggregate.mdx: the binary read;
  • the reference pages.

🤖 Generated with Claude Code

JosteinGj and others added 5 commits October 12, 2026 14:14
retrieve_datapoints_binary posts the JSON retrieve body to
POST /timeseries/data/list/binary and parses the frames that come back
into one columnar BinaryDatapoints per series. A body without the DHDE
end marker was cut short and is a 502, never fewer points. Aggregate
frames (codec 2) carry avg, min, max and sum; decimals stay exact.

The envelopes are walked first and the frames then decompressed and
decoded on scoped threads: zstd was 90% of a single-threaded parse.
For 100M float rows the parse went from 1.46s to 0.60s and the SDK's
share of an end-to-end read from 1.57s to 0.90s.

In Python, the sync client's retrieve_datapoints_binary returns objects
implementing the Arrow PyCapsule stream protocol, so polars, pyarrow
and pandas 3 take them without copying. The package adds no runtime
dependency. The offline tests read fixtures written by the platform's
own frame writer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jgjesdal <jostein@intellistream.ai>
stream_datapoints_binary returns a Stream of BinaryDatapoints, one per
frame. A FrameAssembler cuts the body into frames however the chunks
fall, and frames are decoded on spawn_blocking, at most min(cores, 8)
at once and kept in order, while the rest downloads. A refusal is the
Err before there is a stream; a response that breaks off is an error
item after the items that arrived. The blocking client has it as an
iterator.

retrieve_datapoints_binary now collects that stream and merges each
series once at its final size, so it too decodes during the download.

In Python, retrieve_datapoints_binary_stream returns a single-use
BinaryDatapointReader: iterate it per frame, or read it as an Arrow
stream of one long table (timeseries_id, timestamp, values), with
external_ids mapping the ids. An external_id dictionary column cost
polars 0.8s per 100M rows, so the table leaves it out.

100M float rows over 10 series: the server takes ~6.5s; the buffered
call adds 0.24s (was 0.90s) and the stream into polars 0.02s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jgjesdal <jostein@intellistream.ai>
retrieve_datapoints_binary returns a BinaryReadResult: a sequence of
the per-series BinaryDatapoints it returned before, plus the
external_ids, long-table Arrow stream and to_polars the streamed reader
has. Both call one pure-Python helper (_tables.py), so the tables
cannot drift between them.

to_polars(wide=True) gives one row per timestamp and one column per
series, named by external id ({external_id}.{column} for mixed and
aggregate series). Series on identical timestamps are put side by side
for free; one value type is pivoted on a numeric key (1.7s per 100M
rows, against 3.9s pivoting on names and 5.1s aligning); otherwise
the frames are aligned, so different value types sit side by side.

100M float rows, 10 series: buffered and streamed, long or wide, all
land within 0.07-0.50s of the server's 6.5s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jgjesdal <jostein@intellistream.ai>
TimeSeriesService::insert_table_binary takes wide Arrow RecordBatches:
a timestamp column and one column per series, named by external id.
Columns already in their series' type are sliced rather than copied,
nulls are left out as "no point", unsorted rows are sorted with the
last of a repeated timestamp kept, and frames are compressed on scoped
threads. Values are stored as the string path stores them; the offline
tests decode both paths' frames and compare. A mixed series takes a
column as typed rather than classifying each value.

Series resolution, request packing and the stale-series retry are now
shared with the string path rather than duplicated.

In Python, insert_dataframe_binary takes a polars or pandas DataFrame
or a pyarrow table through the Arrow C stream, finding the timestamp
in the pandas index, a timestamp column or the only timestamp-typed
column. What to_polars(wide=True) returns from a read goes back in
unchanged.

100M float points from a polars frame: 3.1s, against about 75s
through Python lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jgjesdal <jostein@intellistream.ai>
A binary read now answers with compression 2: the payload is an Arrow IPC
stream whose record batches zstd-compress each buffer in-band, relayed from
ClickHouse without decoding. arrow-ipc's zstd feature decodes it; compression
1 is still read. At compression 2 the envelope's raw length is the buffers
uncompressed, which arrow-rs does not report, so only its cap is checked.

The read fixtures are regenerated with the platform's relayStream, the old
compression 1 bodies kept beside them, plus read_batches.bin: several
compressed batches to a frame and a series the relay cut across two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jgjesdal <jostein@intellistream.ai>
@JosteinGj
JosteinGj requested a review from olavgg October 14, 2026 07:45
JosteinGj and others added 2 commits October 14, 2026 10:30
BinaryIngestOptions::compression picks where the zstd goes: Payload, one
stream around the Arrow payload (compression 1, still the default), or
Buffers, arrow-rs compressing each buffer in-band (compression 2, the form a
read answers with). Both the string and the dataframe path take it; in
Python it is compression="payload" or "buffers".

The server recounts a compression 2 frame's raw length from its buffers and
refuses a mismatch, so in_band_raw_len reads it back from the written stream
instead of predicting which buffers arrow-rs writes. Frames of every value
type from both paths pass the platform's own DatapointFrame decoder.

Adds a live pandas round trip: a timestamp index, NaN and pd.NA as gaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jgjesdal <jostein@intellistream.ai>
Level 9 compressed noisy floats no smaller than level 1 and took several
times as long, and the client is what pays for it. 1 and 9 stay available.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jgjesdal <jostein@intellistream.ai>
@JosteinGj
JosteinGj changed the base branch from feat/binary-datapoint-read-stream to main October 14, 2026 10:42
@JosteinGj JosteinGj changed the title Insert a dataframe through the binary endpoint Binary datapoints: read, stream, dataframe ingest and compression 2 Oct 14, 2026
The consumer relays compression 2 batches into ClickHouse without
decompressing them (platform c9e9351dd), while a compression 1 frame is
decoded first. On 100M float points from polars, time until the last point
was readable fell from 15.5 s to 13.2 s, at the same cost to the client.
Payload stays available as compression="payload".

bench_json_vs_binary takes DATAHUB_BENCH_ZSTD_LEVEL and
DATAHUB_BENCH_COMPRESSION to compare the two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jgjesdal <jostein@intellistream.ai>
@JosteinGj
JosteinGj merged commit 365c8ba into main Oct 16, 2026
34 checks passed
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