Skip to content

fix(warehouse): route alerts to the minutely rollup and sweep the MV set - #559

Merged
Makisuo merged 2 commits into
mainfrom
fix/warehouse-mv-routing-and-sweep
Aug 21, 2026
Merged

fix(warehouse): route alerts to the minutely rollup and sweep the MV set#559
Makisuo merged 2 commits into
mainfrom
fix/warehouse-mv-routing-and-sweep

Conversation

@Makisuo

@Makisuo Makisuo commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Started as "which of our 40 materialized views are actually useful?" Two of the findings turned out to be bugs rather than bloat, so this is mostly a fix.

Everything below is measured — production storage from datasources_storage, and a week of read traffic from our own query.context spans. Read time is ~97 CPU-hours/week, of which ~91 is background automation; interactive UI traffic is under 5%. So MV value is decided by what the alerting and error-tick loops do.

Alert evaluation could never reach the tier built for it

canUseAnnualServiceOverview required allMetrics === true. computeAlertBuckets never sets it, so all 372k alert evaluations/week fell through to a flat scan of the 325M-row per-span service_overview_spans — 165k scans per 3 days — while the 2.2M-row service_overview_minutely, built for exactly that shape, sat unreachable.

allMetrics is not a capability limit. It only picks which aggregates the raw paths bother computing (metricSelectExprs); the rollup tiers read pre-aggregated columns and produce all five MetricNeeds unconditionally, so every TracesMetric is serveable. The flag was encoding "this is the dashboard caller."

Hour-multiple buckets are deliberately yielded back to traces_aggregates_hourly: it stores sample-weighted quantile states where these tiers store unweighted ones, so claiming them would have silently downgraded accuracy under heterogeneous sampling. Only the sub-hour case is claimed, where the alternative is the per-span scan.

span_metrics_calls_hourly held 0 rows for its entire existence

I was about to delete this as dead. A code comment citing a measured ~7s p95 didn't fit that story, so I checked: the metric is live as traces.span.metrics.calls — ~880k rows / 2 days across 30 services, monotonic. The MV filtered MetricName IN ('span.metrics.calls', 'calls'), because spanmetricsconnector output is namespaced by its pipeline.

The write filter and the read fast-path missed it identically, so they agreed with each other and disagreed with reality. Fixed on both sides rather than deleted.

Sweep (migration 0019 / local schema v9)

  • Drop error_spans + its MV. No query ever called from(ErrorSpans), yet warehouse-catalog.ts still described it to LLM agents as "use this instead of traces WHERE StatusCode = 'Error'" — so run_sql agents were actively steered onto a table no product code reads.
  • Bound attribute_values_hourly cardinality. With AttributeValue != '' as its only filter it had reached 1.59B rows / 12.3 GB to serve an autocomplete dropdown read ~217 times a week. The rules come from measuring the table: a length cap and an id-key denylist — the two intuitive mechanisms — would both have missed idle_ns and busy_ns, short numeric measurements that were ~70% of the rows. Verified against production: 93.6% of rows dropped, zero values lost for http.response.status_code, http.method, db.system, service.name, deployment.environment, http.route, server.address and every other canonical OTel key. attribute_keys_hourly is untouched, so all keys stay discoverable and filterable.
  • Drop the three unread Events* columns from trace_detail_spans (1.19 GB). Confirmed unused by all nine readers, including the two AI-session queries in query-engine-integrations.
  • Left alone deliberately: metrics_exponential_histogram is empty but is written by ingest — it's an unused capability, not dead code. Recorded in the doc so it isn't re-flagged.

Alert evaluation cache

buildEvaluateCacheKey snapped to 15s while endMs = now and the tick runs every 60s, and the TTL was 30s — shorter than the tick that created the entry. Windows now snap to the tick boundary, which also excludes the still-filling current minute, and the TTL clears one tick.

Reviewer note: this adds up to one tick of lag to alert evaluation, bounded by the tick period the scheduler already imposes. It's the one deliberate behaviour trade-off here. It does not collapse the per-service fan-out (each service compiles a distinct plan); it lets rules over one identical signal — e.g. warn-at-100 and page-at-500 — share an entry.

Verification

  • Migration replay against real ClickHouse 26.2 — proves the DROP VIEWDROP TABLE ordering (the inverse wedges ingest with Code: 60).
  • SQL catalog analyzer sweep, 157 shapes, and the raw-vs-rollup parity suite.
  • Production parity check of the old vs new alert route: 73 buckets, 0 mismatches on count and spanCount, 1,686,684 spans matched exactly.
  • 2,272 unit tests across domain / query-engine / cli, plus the affected API suites; schema drift gates, manifest gate, lint, oxfmt.

One test worth flagging: ch.test.ts asserted toContain("FROM service_overview_spans") for this route. The tiered union reads that table too, as its raw edge — so the assertion passed on both routes and could never have caught this. It now asserts the tier that distinguishes them.

Scope

alertRawQuery173,000 s/week, now the largest remaining consumer by far — is untouched. It's user-authored SQL against raw tables, so no MV can see it and no routing guard applies. It and the other open items (trace_detail_spans TTL, the service_map_spans amplification) are recorded in docs/warehouse-rollups.md.

That doc is the durable half of this: the tier ladder, the three shapes that justify an MV, and the four rules whose absence produced each finding above.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Two of these are bugs, not cleanup.

Alert evaluation could never reach the tier built for it.
`canUseAnnualServiceOverview` required `allMetrics === true`, which
`computeAlertBuckets` never sets, so every alert fell through to a flat
scan of the 325M-row per-span `service_overview_spans` while the 2.2M-row
`service_overview_minutely` sat unreachable. That flag is not a capability
limit: it only picks which aggregates the raw paths bother computing, and
the rollup tiers produce all five MetricNeeds unconditionally. Hour-multiple
buckets still yield to `traces_aggregates_hourly`, which stores
sample-WEIGHTED quantile states where these tiers store unweighted ones.

`span_metrics_calls_hourly` held 0 rows for its entire existence. The MV
filtered `MetricName IN ('span.metrics.calls', 'calls')`, but the collector
emits the counter namespaced by its pipeline as `traces.span.metrics.calls`
(~880k rows / 2 days, 30 services). Write filter and read fast-path missed
it identically, so they agreed with each other and disagreed with reality,
and every read fell back to the raw scan measured at ~7s p95.

Also in migration 0019 / local schema v9:

- Drop `error_spans` and its MV. No query ever called `from(ErrorSpans)`,
  yet `warehouse-catalog.ts` still told LLM agents to prefer it over
  `traces WHERE StatusCode = 'Error'`.
- Bound `attribute_values_hourly` cardinality. With `AttributeValue != ''`
  as its only filter it had reached 1.59B rows / 12.3 GB to serve an
  autocomplete dropdown read a couple hundred times a week. The rules come
  from measuring the table, not from intuition: a length cap and an id-key
  denylist would both have missed `idle_ns` and `busy_ns`, short numeric
  measurements that were ~70% of the rows. Drops 93.6% of rows and zero
  values for every canonical OTel key.
- Drop the three unread `Events*` columns from `trace_detail_spans`.

Alert windows now snap to the tick boundary, which excludes the
still-filling current minute and lets rules over one signal share a
`qe-evaluate` entry; its TTL was 30s, shorter than the 60s tick, so no
entry could outlive the tick that created it.

Adds docs/warehouse-rollups.md with the tiering contract and the four rules
whose absence produced each finding above.
… v9 edge

The migration installed the narrowed v9 schema and assumed that removed the
three `Events*` columns. It does not: the bundled DDL is
`CREATE TABLE IF NOT EXISTS`, so on a store where `trace_detail_spans` already
exists it is a no-op and the wide v8 table survives. `assertPhysicalSchema`
then fails the edge with `unexpected column EventsTimestamp`.

This is the same `IF NOT EXISTS` trap the edge already handles for the views —
which is why they are dropped explicitly — applied to a table rather than a
view. Issue `ALTER TABLE ... DROP COLUMN IF EXISTS` alongside them, mirroring
what ClickHouse migration 0019 does and the shape v6 -> v7 uses for its ADDs.

Also bumps the head identity pinned in the native probe to v9, which its own
comment asks for in lockstep with LOCAL_SCHEMA_VERSION.

Both native steps now pass locally: the migration reaches step 9/9 and reports
39 views, and the checkpoint smoke round-trips.
@Makisuo
Makisuo merged commit dc0cd22 into main Aug 21, 2026
41 checks passed
@Makisuo
Makisuo deleted the fix/warehouse-mv-routing-and-sweep branch August 21, 2026 12:17
@Makisuo
Makisuo deployed to pr-preview August 21, 2026 12:17 — with GitHub Actions Active
@github-actions

Copy link
Copy Markdown

🍁 Maple PR preview

Note

Preview resources were removed when this pull request closed.

Final commit 8a09917 · View workflow run

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.

1 participant