feat(weave): add intent cluster assignment tables - #7691
Draft
gtarpenning wants to merge 3 commits into
Draft
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
gtarpenning
force-pushed
the
griffin/intent-records-normalized
branch
from
August 5, 2026 18:40
6d32bd8 to
fcd19ff
Compare
gtarpenning
force-pushed
the
griffin/intent-records-normalized
branch
2 times, most recently
from
August 6, 2026 01:48
ff97443 to
28176a9
Compare
Assignments carry the run's UMAP 2D projection so the cluster scatter plot reads from the same row as cluster_id. Run-scoped, since a rerun reprojects into different axes. Scope the daily rollup to its run's project, lens, pipeline version, and source-time window, and deduplicate occurrences before counting. A rollup row is persisted, so a duplicate counted once is never corrected by a later merge. Negative-control rows in the test cover each excluded case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A run's children are now plain MergeTree with no version column. A retry is a new run id rather than a rewrite, which removes the orphan-day problem and takes FINAL off every hot read. Only the runs table keeps a lifecycle. Immutability is what allows the reverse-lookup projection: ClickHouse rejects projections on ReplacingMergeTree outright. Measured at 100M occurrences over 80.6M signatures, "which signatures are in this cluster" goes from 80.7M rows scanned to 188K, 295 ms to 7 ms, and the same table still answers signature lookups from its base order. A run now covers exactly one lens under an opaque id, so lens leaves every child key. intent cluster 0 and failure cluster 0 previously collided under one run id. signature_count per day was wrong on any multi-day read, counting a signature once per day it appeared. It becomes a mergeable state alongside users and conversations. Those states move from uniq to uniqHLL12: a fixed small state instead of one that grows with cardinality, worth 18 KB to 4 KB per row and 551 MiB to 53 MiB on a reach query. Every run-scoped table shares one TTL, so clusters can no longer outlive their own membership, and runs carry an explicit promotion pointer so an unpromoted experimental run cannot go live by being newest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dropping ReplacingMergeTree took away the only thing that made a retried insert harmless. "A retry is a new run id" covers a retried run, not a retried insert: a duplicated rollup row silently doubles occurrences, and a duplicated assignment doubles it again through the fold's join. The uniq states are replay-safe, the plain integer is not. non_replicated_deduplication_window makes insert_deduplication_token effective on the non-replicated path, and the writer contract now spells out that every child insert must carry one. Verified: same token is a no-op, a different token duplicates. Block-checksum dedup alone does not cover this, since now64() defaults make two logically identical inserts different blocks. Also drops lens from the runs sorting key, since the children key on cluster_run_id alone and keeping lens in this table's identity would let two lenses share one run id while their children merged indistinguishably. Removes DEFAULT 'intent' from lens for the same reason as in 040. Records the promotion read that readers must use: this table is ReplacingMergeTree read without FINAL, so max(promoted_at) over raw rows reports a demoted run as live. Also states the reclaim owner for abandoned attempts, the expire_at ordering invariant, that reuse of an assignment across pipeline versions is deliberate, and that the projection was a choice rather than a platform constraint, since 'rebuild' mode was available. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
gtarpenning
force-pushed
the
griffin/intent-records-normalized
branch
from
August 6, 2026 02:24
28176a9 to
0433999
Compare
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.
Summary
cluster_*columns on the occurrence row leave no writer able to produce a complete row. Assignment gets its own tables instead. Stacked on feat(weave): add intent records storage schema #7598, which no longer creates those columns, so 041 is purely additive.signature_id, so an occurrence arriving after a run resolves to its cluster with no backfill.intent_clusters,intent_cluster_assignments, andintent_cluster_dailyare plain MergeTree with no version column. A retry is a new run id rather than a rewrite, so nothing needs replacing, no read needsFINAL, and a partially written run is abandoned and expired rather than repaired. Onlyintent_cluster_runskeeps a lifecycle, and it holds one row per attempt.insert_deduplication_token, and the three child tables setnon_replicated_deduplication_window = 1000so that token is honored on the non-replicated path. Verified: with a shared token a replayed fold stays at one row, without one it lands twice and doublesoccurrences.intent_cluster_runskeys on(project_id, cluster_run_id)withlensout of the key, so two lenses cannot share a run id here while their children merge into one indistinguishable pile. Readers ofpromoted_atmust collapse versions withargMaxbefore ordering, since amax()over raw RMT rows reports a demoted run as live.ADD PROJECTIONon ReplacingMergeTree outright (deduplicate_merge_projection_mode = throw), so "which signatures are in this cluster" could not be indexed at all while the table stayed RMT.signature_idalready uses.signature_countper day was wrong on any multi-day read, counting a signature once per day it appeared whileusersbeside it merged correctly. It is now a mergeable state alongside users and conversations. Those states move fromuniqtouniqHLL12.promoted_atpointer so an unpromoted experimental run cannot go live by being newest.Query patterns
Every read resolves the promoted run first, then hits one of three shapes. No read uses
FINAL, and onlyintent_cluster_runsneedsargMax.Writing the rollup
The fold is the run's last step and stays sourced from occurrences. It is chunked
over disjoint
cluster_idranges, which is what keeps its memory bounded:Chunk on
cluster_idspecifically. Chunking on a signature prefix benchmarksbetter and is silently wrong: each chunk emits partial aggregates for the same
(day, cluster_id)key, and with nothing to sum them the rollup ends up holdingone arbitrary chunk. Measured, that read back 12.5M of 100M occurrences.
Each chunk insert carries
insert_deduplication_tokenkeyed on the run id,table, and chunk sequence. Block-checksum dedup alone does not cover a replay:
now64()defaults make two logically identical inserts different blocks. Thefull writer contract, including who reclaims an abandoned attempt and why
expire_atordering is an invariant, is stated at the top of the migration.Measured profile
Local ClickHouse 26.6, one project holding 100M occurrences over 80.6M signatures
(the measured 1.24:1 dedup) across 90 days, 441 clusters, so ~161k signatures per
cluster. Four other projects hold 1M more occurrences so
project_idpruning isreal.
read_rowsand peak memory fromsystem.query_log, granules fromEXPLAIN indexes = 1, warm execution.read_rowssums every table a query touches.argMaxCosts, stated plainly: the projection adds 1.25 GiB to a 3.06 GiB assignments
table and 26 s to build at 80.6M rows, and
uniqHLL12trades 2.1% error on a63k-user count for the tenfold memory cut.
Index confirmation
EXPLAIN indexes = 1pushes the full key prefix down on every read above.intent_cluster_dailykey.toYYYYMM(day)prunes parts first, thenproject_id, cluster_run_id, day, cluster_idprunes granules. Reads stay in the tens of thousands of rows and do not move with occurrence count, which is the reason the table exists.intent_cluster_assignmentskey: 1 granule of 9,845. Droppingcluster_run_idstill prunes to one granule per run, becauseproject_idpins the prefix andsignature_idstays sorted inside each run.ReadFromMergeTree (proj_by_cluster), 22 granules of 9,845. The same table still answers signature lookups from its base order, so both directions are served without a second table.idx_signature_idkeeps 123 of 123 granules at that fan-out, while pruning 246 to 5 for a single signature, which is what it is for. What made this read expensive was theGROUP BY iddedup, not the scan: dropping it is 7.62 GiB to 51.9 MiB and 4,601 ms to 551 ms.What this does not fix
At 1.24:1 dedup, signatures track occurrences at ~0.8x, so assignments are ~80M rows per attempt and the noise bucket alone holds 9.68M signatures. Every signature-keyed structure grows with ingest. More pressing at this scale, 441 clusters over 80.6M signatures means HDBSCAN over 80.6M vectors, which is the real ceiling. If clustering ends up running on a sample, most occurrences have no assignment row and "what cluster is this trace in" returns nothing for them. That is a product decision this schema does not settle.
Testing
uv run --extra trace_server --group test pytest tests/trace_server_migrator/test_migrator_functional.py tests/trace_server/test_clickhouse_trace_server_migrator.pyon local ClickHouse 26.6: 137 passed, 1 pre-existing failure (test_distributed_legacy_replicated_management_db, an exactengine_fullassertion differing only byORDER BY (db_name)parentheses on a newer local server than CI's pinned 26.4).FINAL.insert_deduplication_tokenand asserts the counts do not move, which is the only guard left once immutability removed replacement.test_split_migration_sql_equivalent_on_all_shipped_migrationspins the comment-aware splitter against the old naive;split, and a semicolon mid-comment makes the two disagree.Breaking changes
None. This adds four new tables plus their migrator sharding entries. 041 is unreleased, so these changes edit it in place rather than adding a follow-up migration.