Repository navigation
Conversation
A stream's rows often have a natural composite key, and every consumer that dedupes, joins or looks a row up by it paid for several columns and string comparisons at query time. Stream.new(identity=...) now makes the writer fill _streamcast_id on every row it appends, by send or ingest: XXH3-128 over the identity columns' values in declaration order, no domain tag, fixed-width values little-endian, strings and binaries length-prefixed, stored as the canonical 16 bytes (#159). The declaration lives on the log's schema, as field metadata on the column, and in each log's metadata entry as `identity`, since an Iceberg schema keeps no field metadata. It is fixed for the stream's life: new and migrate refuse another identity, and migrate refuses removing or retyping an identity column. Readers hide it from `*` (* EXCLUDE) in scan, sql and live and return it when named; sql decides per query, since a view has no hidden columns. filters= that fix every identity column with == also filter on the key, beside the natural terms, which keep the statistics that prune logs, files and row groups. Broker tail rows are keyed by the reader as the writer keyed them. It is not a primary key, and it is opt-in. _streamcast_id becomes a reserved column name. SPEC states the rule: library columns with meaning are visible and unprefixed, derived plumbing is underscore-prefixed, hidden from * and selectable by name. Measured on a loaded 2-vCPU box, min over alternating rounds: send_many 12.74 -> 15.66 us a row, ingest 632 -> 1707 ns a row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Did we actually measure the impact on query performance here? Like, is this even worth doing? The entire point of this change was not having to select the natural columns to filter the result (saves having to read those columns back fromobject storage). Is DuckDB's optimizer smart enough to not read the natural columns up front? |
The review of the identity columns asked whether `_streamcast_id` saves a
reader anything, and whether DuckDB avoids fetching the natural columns
when the key term sits beside them. Neither is answered by wall time on a
local disk, so this measures what a reader fetches from object storage.
`benchmarks/s3proxy.py` is a forwarding HTTP proxy in front of the store
that records each request's method, path, Range and bytes returned;
`benchmarks/identity.py build` writes one sessions-like table twice
through `Stream.ingest` and publishes both to S3, with and without
`identity=("session_id", "line_number")`, and `measure` runs lookups,
whole-session reads, distinct counts and semi-joins on both, through
`Stream.snapshot` and as raw `iceberg_scan` SQL, attributing every
fetched byte to the Parquet column chunk it fell in from the footers.
numpy is brought by `uv run --with numpy` for the generator rather than
added to the dev group: one benchmark's generator is not a reason for
every checkout to install it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first harness counted one way of asking each question. The review needs the plan beside the bytes, a join of two streams, warm as well as cold, and the one design in which the hash could save reads: files picked from the Iceberg manifests on the natural columns' bounds, then filtered on `_streamcast_id` alone. `measure` now runs every arm in `ARMS` that way, records each run's bytes by column and the row groups each column was read from, and saves one cold EXPLAIN ANALYZE per arm. Three things the first full runs got wrong are fixed here. The proxy dropped requests the client abandoned mid-body (5 in one run), so it undercounted exactly the reads a reader gives up on; they are recorded as `aborted`. DuckDB's default memory limit is 80% of the host per database, and a dedupe of 20M keys under a 2.3 GB scope was OOM-killed; `--memory-limit` (1.5 GB) and a spill directory apply to every database. And in one long process the later arms' warm runs slowed to 14-57 s with nothing read from the store, while the same query alone ran warm in about 1 s; each arm now runs in a process of its own, and its records are appended as it finishes, so `--resume` carries on after a failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The raw records, the tables and one EXPLAIN ANALYZE per arm from `just bench-identity` on 20,000,000 rows, with a summary that states where the hash loses and where it wins. On this layout `_streamcast_id` is 16.37 B/row, 5.3x the natural key columns together, and +36% on the table. With the hash beside the natural predicates DuckDB fetches all three filter columns for nearly every row group (379.9 MB for one lookup against 60.7-78.1 MB for the natural predicate alone). Manifest pruning on the natural bounds rules out no file, since sessions interleave. The hash loses every lookup and the join, in bytes and in cold latency, and wins one thing: the dedupe on a bare DuckDB connection, 22-28% faster cold at 5.3x the bytes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`from s3proxy import ...` resolved in CI's type check and not in a local one, so its `ty: ignore[unresolved-import]` was an unused ignore there, which fails the check. `benchmarks.s3proxy`, with the repository root put on the path, resolves the same way in both and needs no ignore. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closes #159.
A stream can declare its identity columns,
Stream.new(..., identity=("session_id", "line_number")). The writer then fills a derived_streamcast_idon every row, so a dedupe, a join or a lookup by the whole key reads one 16-byte column. This follows the design settled on the issue (2026-10-18 and 2026-10-20).What it does
src/streamcast/_identity.py, the one implementation that the writer, ingest and readers all call):xxh3_128over the identity columns' values only, in declaration order, with no domain tag.bytesNbinary is its N bytes;fixed_size_binary(16).send,send_manyand publisher frames (Stream._stored, inside_commit, which has noawait, so I1 is unchanged) and onStream.ingest(_ingest.Source, a column at a time). A row or a source that carries_streamcast_idis refused.schema, not in catch-up rows (_log.columnsand_log.declaredleave it out, through_log.owned)._streamcast_id(litelink keeps field metadata throughnewandopen);identity, because an Iceberg schema keeps no field metadata, so readers andrestorelearn it there.*, selectable by name:Snapshot.scan,Stream.scanandLive.scanproject* EXCLUDE ("_streamcast_id").sqlbuilds its viewlogwith the column only when the query names it. A view has no hidden columns, so this is decided per query, with DuckDB's tokenizer: it counts the name quoted or not, in any case, and not inside a string or a comment. A query that names it also sees it in its own*.iceberg_scanoutside streamcast shows it. SPEC says so._snapshot._keyed): whenfilters=(onscan,sql, andlive'swhere=) fix every identity column with==, the reader adds"_streamcast_id" = '<key>'::BLOB, computed from the literals and naming the column explicitly.where=SQL string is not rewritten, for the same reason SQL is not mined for pruning terms (Prune retired logs from where= and the SQL WHERE clause, soundly #57).Live,as_of_offset=LATEST) are keyed by the reader exactly as the writer keyed them (_tail_table).Stream.newon an existing log refuses anidentity=other than the log's, in either direction.Stream.migrate(..., identity=)refuses a changed identity (added, dropped or reordered), a schema that removes an identity column, and one that retypes one.restoreand revive carry the identity over from the metadata.migratemay add an identity to an existing stream is left open (SPEC says so).identitykey and the snapshot behaviour. API.md and the CHANGELOG are updated.xxhash>=3.6.0becomes a direct dependency. It was already installed through litelink, at the same floor.Breaking? Version?
New API, so a minor release, 0.24.0 (not cut, as asked). It is breaking in one narrow way, stated in the CHANGELOG:
_streamcast_idis now a reserved name. A schema declaring it is refused, and so is reopening a log that an earlier release created with an application column of that name (rename it withmigratefirst). The critical reviewer found this; I reproduced it and chose to document it rather than make column ownership vary per log.Mixed versions, measured against 0.23.0 from PyPI in its own virtualenv, on a stream this branch keyed:
Stream.newrefuses to open the log ("has columns [..., '_streamcast_id'], and this stream declares ..."), so an old server fails closed._streamcast_idinSELECT *, and its catch-up rows carry the key, which live frames don't.tests/test_interop.py; the matrix there covers streams without an identity, which this doesn't change.No consumer pin needs to move until someone wants the feature. Per the shared rules, this should not be released until a consumer pull request uses it.
Cost, measured
Same session, same 2-vCPU box. The box was under load (load average 1.3–2.6), so I report the minimum over alternating rounds; the medians were noise (plain was slower than keyed by median).
send_many, 1000-row batches, 40 alternating roundsingest, 200k rows, 5 roundsIdentity.key()aloneThe ingest-side
keys()was first written with a dict per row, at 5085 ns/row; the column-wise version measures 1150 ns/row. A stream that declares no identity takes the old path.Tests:
tests/test_identity.py, 34 tests, ~11 sThe ones the step asked for:
('ab','c')→ef2fcd6c…, against('a','bc')→6e496efc…;99aa06d3…)._streamcast_id(test_two_streams_join_on_the_key).test_a_key_filter_returns_the_rows_the_natural_predicate_does). Two more prove the key term is really applied:SELECT *leaves it out,SELECT _streamcast_idreturns it, throughsqlandscan, on published rows, the broker tail, and aLiveview.migraterefuses an identity change: removing an identity column, retyping one, and changing or dropping the declaration. A migration that keeps the identity keys the next log alike.Plus: refused declarations (float, nullable, nested, unknown column, the reserved name); a row or an ingest source supplying the key; reopening with another identity or gaining one; ingest keying as
senddoes (including a float64 source cast to int64); frames and greeting not carrying the key.Falsified: 25 mutations, each run against the tests that should catch it, then restored. The first pass missed two, and I fixed the tests for both:
_selectnot adding the key term): the answer is identical by design, so I added the forged-key test.All 25 are caught now. The mutations include: no length prefix, a reversed digest, big-endian ints, ints coerced from bool/float/str,
*not hidden, the name never detected, the rewrite on the wrong column order, the tail unkeyed, the tail key left in catch-up rows, the writer storing a constant key, a row allowed to supply the key, the open ignoring identity, eachmigraterefusal disabled,migratedropping the identity, metadata orshape()forgetting it, ingest unkeyed, a source allowed to supply the key, and the wire carrying the key.Critical review
It ran once, as a general-purpose subagent with the repository's rubric. Its bars: a reader silently given a short or wrong answer by the key filter; one row getting two keys depending on how it entered; a stream without an identity behaving differently or failing to open after the upgrade;
sqlbinder errors from the hiding.==terms, and send vs ingest vs tail keys._streamcast_idno longer opens (above)."_STREAMCAST_ID"was not recognised (DuckDB resolves quoted identifiers without case), which caused a binder error. It now is, with a test._term_sqlonmainalready mis-rendersbytesvalues and NUL-containing strings infilters=. Keying makes neither worse. I have not filed it.Gates
The full suite, run with every tier required (
STREAMCAST_REQUIRE_S3=1 STREAMCAST_REQUIRE_NODE=1 STREAMCAST_REQUIRE_INTEROP=1 STREAMCAST_REQUIRE_POSTGRES=1 uv run pytest, ascheck-allsets them; rustfs up). The log was read, not re-run:Failed: node is not installed, and STREAMCAST_REQUIRE_NODE is set. This box has no Node, so the browser-client tier did not run here; CI runs it.AssertionError: 223 ticks in 4.98s(it needs 249). The load average was 4.7 on 2 vCPUs. Run alone afterwards it passed 3 of 3 (1.7–4.7 s). I'm not counting that as a pass of the full run; CI's run is the one that says.tests/test_store.py:129: a SQLite file has no connection to lose.CI on this PR passed in full (run 39506588699): Tests (3.11) and (3.14), the type check, lint and format, the wheel job, and
CI success.ruff check,ruff format --checkand the blank-line check pass.ty checkreports 2possibly-missing-submodulewarnings intests/test_limits.py, a file this branch doesn't touch. They are onmaintoo and failjust typecheck(and sojust check-all) locally. CI's type check passed on the last PRs, so its ty version presumably differs.Note on other open PRs
This branches from
main(26e903d). #177 (startup adopts or refuses a published stream) also edits_open_or_createinsrc/streamcast/_stream.py, so whichever merges second needs a small rebase.🤖 Generated with Claude Code