Repository navigation
Binary datapoints: read, stream, dataframe ingest and compression 2 - #177
Merged
Merged
Conversation
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>
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
changed the base branch from
feat/binary-datapoint-read-stream
to
main
October 14, 2026 10:42
This was referenced Oct 14, 2026
olavgg
approved these changes
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
edc73920b974182bebeaf0d621962592a60242ca165c7dfa5cfd029aMerging only the first commits would put a read on
mainthat understands only compression 1, which an api on the arrow branch never sends.Binary read
TimeSeriesService::retrieve_datapoints_binarysends the ordinary retrieve body toPOST /timeseries/data/list/binaryand returns one columnarBinaryDatapointsper series.stream_datapoints_binaryreturns aStreamwith one item per frame. Frames are decoded onspawn_blocking, at mostmin(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.retrieve_datapoints_binaryreturns aBinaryReadResult, a sequence of per-seriesBinaryDatapoints.retrieve_datapoints_binary_streamreturns a single-useBinaryDatapointReader.external_ids, a long-table Arrow stream andto_polars(wide=False|True), all through one helper (_tables.py) so the two cannot drift.BinaryDatapointsimplements the Arrow PyCapsule stream protocol, so polars, pyarrow and pandas read it without copying. The package adds no runtime dependency.DHDEend marker was cut short and is a 502, never fewer points.Errbefore there is a stream.cursor(it is a 400); read on by movingstartpast the last timestamp.avg/min/max/sum. Decimals stay exact, as an unscaledi128plus a scale.Dataframe ingest
insert_table_binary(&batches, timestamp, &options)takes wide ArrowRecordBatches: a timestamp column plus one column per series, named by external id.insert_dataframe_binary(df, timestamp=None, zstd_level=None, compression=None)takes a polars, pandas or pyarrow table through the Arrow C stream.timestamp=, it uses the pandas index if that is a timestamp, then atimestampcolumn, then the only timestamp-typed column.to_polars(wide=True)returns goes back in unchanged.Compression 2
DatapointFrameWriter.relayStream, and the old compression 1 fixtures are kept asread_*_zstd.bin.BinaryIngestOptions::compressiontakesFrameCompression::Buffers(compression 2, the default) orFrameCompression::Payload(compression 1).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.compression="buffers" | "payload"on the three binary ingest methods,"buffers"by default.in_band_raw_len).357794cceor later.BinaryIngestOptionshas 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:
retrieve_datapoints_binary_stream(...).to_polars()retrieve_datapoints_binary(...).to_polars()insert_dataframe_binaryfrom polarsinsert_from_lists_binaryTests
test_datapoints_binary_read, the wide polars round trip at both compressions, and a 120k-row pandas round trip.357794cce; an older api refuses the frames with a 400.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, thecompressionoption and the zstd default;query-and-aggregate.mdx: the binary read;🤖 Generated with Claude Code