feat(domain): hourly per-service AI vendor discovery rollup - #453
Open
JeremyFunk wants to merge 3 commits into
Open
feat(domain): hourly per-service AI vendor discovery rollup#453JeremyFunk wants to merge 3 commits into
JeremyFunk wants to merge 3 commits into
Conversation
Migration 0016 adds `service_ai_vendors_hourly` and the materialized view that writes it: one row per (org, service, vendor, hour), carrying span and session-key-state counters plus `uniqCombined(12)` states for traces and sessions. It backs service pruning, per-service vendor lookup, session-key health and sampling-exemption suggestions. The view filters `AiVendor != ''`, which is both the cost model and the semantics: the platform's ordinary HTTP and DB spans never enter MV processing, and after enablement "no rows" means "no AI spans". It groups on the stored, clamped `AiRollupHour`, never `toStartOfHour(Timestamp)`, so a skewed client cannot open a partition in 2038. No POPULATE and no backfill: correctness depends on the source rows having been classified, not on the view having existed. Because the view runs synchronously inside the INSERT pipeline, this target's part-count and merge-lag are trace-ingestion health — a `too many parts` here fails the INSERT into `traces`. `requiredForIngest` stays false all the same: the gateway never writes this table, and bumping the ingest gate would un-ready every BYO-ClickHouse org over a read-path change. Reader contract, pinned in the datasource doc and the tests: plain aggregates plus `uniqCombinedMerge(12)`, never `FINAL`, and never an assumption of one row per key. Headline coverage merges across vendor rows — a per-vendor ratio systematically understates co-occurring passthrough vendors. Hours before the recorded enablement hour do not exist for readers, and that boundary is an operator step, not something the migration can enforce; a partly-classified hour is internally consistent and looks healthy. 400-day retention over a 30-day source is deliberate and now the matrix's only 400-day tier: past the raw horizon these rows cannot be rebuilt. The migrated view body and the bootstrapped one come from one exported constant, and a test asserts neither copy drifted: a cluster migrated to 16 and a freshly bootstrapped one must compute the same coverage ratio. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Local schema v6: the rollup table and its view, and nothing else — asserted as a name delta against the frozen v5 manifest so a stray object cannot ride along. The v5 -> v6 module and a frozen v6 DDL snapshot keep an existing local store readable, and the manifest gate checks that snapshot's identity like the ones before it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rollup's output is a coverage percentage shown to a customer, and every way it can be wrong renders fine: a broken counter identity, an HLL state merged at the wrong grouping, a reader assuming merged parts. Only a real server over spans with known-by-construction answers catches those, so this suite runs against one — asserting the counter identities, the trace-level coverage ratio merged across vendors, the hour grouping, and that reading without `FINAL` over deliberately unmerged parts still gives the right answer. Wired into `ch:test` and into the ClickHouse CI job alongside the existing warehouse suites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Aug 13, 2026
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.
Stack position: 3 of 3 (final). Base:
ai2/02-ingest-write-path. Review 01 and02 first — this PR aggregates the columns 02 writes.
Adds
service_ai_vendors_hourly: one AggregatingMergeTree row per(org, service, vendor, hour), populated by a materialized view over theclassified spans landed in 02. It is what makes "which AI frameworks does this
service use, and how well can we resolve its sessions" answerable without scanning
raw spans.
Design rationale is in the write-side plan (§5 and the rollup sections); this
description covers the shape, the reader contract and the verification.
What lands
0017+ the Tinybird datasource and MV, sharing oneexported SELECT constant.
AI_VENDORS_ROLLUP_ENABLEMENT_HOUR_ENVandAI_VENDORS_ROLLUP_TABLE— thereserved enablement-boundary name, the table it governs, and the reader rule.
Nothing consumes them yet; the read path does not exist yet.
ch:test.(Numbering:
maintook migration0015and local schema v5 for the minutelyservice overview while this stack was open, so 02's AI columns are
0016/ localv6 and this PR is
0017/ local v7.)Columns: span and sample-weighted span counts, the four session-key state
counters (absent / invalid / sub-session / session),
uniqCombined(12)states fortraces total, traces with a key and approximate sessions, and rules-version
provenance for both the rows and the aggregate.
The decisions worth reviewing
WHERE AiVendor != ''is the cost model and the semantics. The platform'sHTTP and DB traffic never reaches the aggregate expressions or the HLL states, so
cost scales with AI traffic rather than total traffic. It is also what makes
"no rows for a service-hour" mean genuinely no AI spans post-enablement.
AiRollupHour, nottoStartOfHour(Timestamp). Aclock-skewed client must not be able to open a partition in 2038, and a stored
clamped hour survives a partition rebuild where a
now()-based one would not.POPULATE. Correctness depends on the source rows having been classified,not on the view having existed, so there is nothing safe to backfill. An earlier
draft cited a runbook gate ("classifier at 100% for a full clock hour before MV
creation"); that gate is retracted — the MV ships in the same migration chain as
the columns and is a no-op until the ramp, so it cannot be ordered after it. The
recorded enablement hour covers the boundary instead (plan §7 step 4).
OrgIdfirst in the sorting key. Daily is the granularity aregistry-fix rebuild works at — a per-closed-day, atomic
ALTER TABLE … REPLACE PARTITION … FROM <shadow>, which monthly partitions wouldmake unaffordable — and org deletion, the one sanctioned mutation on this table,
prunes on the sort-key prefix.
400-day tier to say so. The asymmetry is deliberate and comes with its
constraint: past the raw horizon these rows cannot be rebuilt.
and merge-lag alerts are trace-ingestion health, not side-table health — a rollup
target that hits
too many partsfails the INSERT intotraces.requiredForIngest. It adds a read-path rollup and touchesnothing the gateway inserts, so the ingest gate stays at 16 rather than
un-readying every BYO-CH org over a table their ingest path never writes.
Reader contract
Plain
sum()/min()/max()withGROUP BYfor the SimpleAggregateFunctioncolumns,
uniqCombinedMerge(12)for the states. Parts are never guaranteedmerged — no reader may assume one row per key, and no reader may use
FINAL.Headline coverage is trace-level and service-level:
uniqCombinedMerge(12)(TracesWithKey) / uniqCombinedMerge(12)(TracesTotal)groupedby
(OrgId, ServiceName), merging across vendor rows. Per-vendor ratios arediagnostic only — a trace mixing a passthrough vendor with the framework that
carries the key counts in both vendors'
TracesTotalbut only one'sTracesWithKey.WeightedSpanCountissum(SampleRate), notsum(1/SampleRate): Maple'sSampleRateis the adjusted-count convention. The two inverses are pinned in thecolumn doc because picking the wrong one still produces a plausible number.
Hours strictly before the enablement boundary are nonexistent, not zero. The
boundary is the hour
INGEST_AI_CLASSIFICATION_ENABLEDreaches 100% across thefleet — not MV creation, which truncates an empty hour and costs nothing because
WHERE AiVendor != ''matches no unclassified row. That hour is partly classifiedand looks perfectly healthy: every counter is internally consistent and nothing in
the row says it is partial. "No AI spans this hour" and "the rollup was not
recording this hour" are different claims and only the first may be shown to a
customer. Nothing mechanical enforces recording it; it is an operator step, and
every site that mentions the boundary says so rather than implying a mechanism.
Verification
bun typecheck,cargo test(143 lib + 70 binary),@maple/domain(490 passed),@maple/cli(435 passed): green.bun run clickhouse:schema:check— including theappend-only local-schema history gate against
origin/main— green at schema v7.ClickHouse e2e against a real server — 12 tests passed. SQL-text tests cannot
catch any of what this suite is shaped for, because every failure mode still
renders a plausible percentage:
EligibleSpanCount = KeyAbsent + KeyInvalid + KeySubSession + KeySessionidentity;
merged parts reads low and
FINALis not an escape hatch;own row reads 0% on a fully resolvable trace;
WeightedSpanCount's floor — a span withSampleRate 0contributes 1.0, not 0and not an infinity;
AiRollupHour— seeded with a clock-skewed spanwhose raw
Timestampis in 2038 and whose stored hour is inside the fixture, so atoStartOfHour(Timestamp)regression fails rather than reproducing the fixture.The divergence between that span's raw timestamp and its stored hour is asserted
first, so a future seed edit that removes it fails loudly instead of turning the
rest of the test back into a tautology;
Also passed on this branch: the hash-contract suite (4) and the warehouse suite
carrying the 0016 schema probe (4).
Structural tests: migration and deployed MV bodies asserted to contain the
shared SELECT byte-for-byte (a migrated cluster and a bootstrapped one must compute
the same ratio); the SELECT's column order asserted against the target's as a
readability convention — ClickHouse matches an MV's output to its target by
name, and an alias with no matching column raises
THERE_IS_NO_COLUMN, which,because the view runs inside the INSERT pipeline, hard-fails every INSERT into
traces; noPOPULATE; no backfill statements; v7 asserted as a pure name deltaagainst the frozen v6 manifest.
Note
A local store runs no classifier, so its rollup stays empty rather than reporting a
confident zero — which is the honest answer there.
🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.