Skip to content

[HDX-5126] Apply HAVING/ORDER BY/LIMIT to the composed metric join, not per-series branches - #2946

Merged
kodiakhq[bot] merged 5 commits into
mainfrom
warren/hdx-5126
Aug 20, 2026
Merged

[HDX-5126] Apply HAVING/ORDER BY/LIMIT to the composed metric join, not per-series branches#2946
kodiakhq[bot] merged 5 commits into
mainfrom
warren/hdx-5126

Conversation

@wrn14897

Copy link
Copy Markdown
Member

Fixes HDX-5126 — follow-up to review feedback on #2909: "HAVING is applied within each CTE instead of to the final join … Same with order by."

Why

The composed multi-series metric query (HDX-5077) builds each per-series branch as { ...chartConfig, select: [...] }, so having, orderBy and limit leaked into every UNION branch:

  • HAVING ran in a scope where the user-facing output names don't exist (the value column is renamed __hdx_value), and filtered each series independently before the join — making it impossible to know what aliases/series/formulas can be referenced.
  • ORDER BY ran per branch and was then discarded by the outer join — the final row order was nondeterministic. This also made table header-click sorting and convertToTableChartConfig's default group ordering silent no-ops on composed metric tables.
  • LIMIT/OFFSET truncated each series to its own arbitrary group window before the join → mismatched group sets across series (spurious NULL cells) and incoherent table pagination (useOffsetPaginatedQuery injects page offsets into limit).

What

All three clauses now render once, on the final joined SELECT, where they reference the output columns: operand aliases (when shown), formula names/aliases, the ratio column, group-by passthroughs and the time bucket. Time charts stay bucket-ordered first, with the user sort as a tiebreaker. Row-level filters (where, filters, per-series aggCondition) stay per-branch, where they belong.

Reference contract (pinned by integration tests on CH 26.5)

Quoted output names or user aliases — e.g. HAVING "err rate" > 0.5, ORDER BY "avg(metric.total)" DESC, or a group-by alias. Raw expressions over source columns (e.g. ResourceAttributes['service.name']) do not resolve in the outer scope — verified UNKNOWN_IDENTIFIER under the new analyzer — so an expression group-by is referenced via its quoted ClickHouse-derived name ("arrayElement(ResourceAttributes, 'service.name')") or, better, a group-by alias. Hidden operand series (showOperandSeries: false) are not referenceable: the contract is "reference what the result outputs".

Tests

  • Unit/snapshot (renderChartConfig.test.ts): clauses render exactly once, on the outer statement, in HAVING → ORDER BY → LIMIT order; bucket ordering stays first with the user sort appended; HAVING can reference a formula output column. Existing composed snapshots are byte-identical (no clause fields → no change).
  • Integration (queryChartConfig.int.test.ts, 8 new): HAVING on an operand output column (including dropping a group whose other series fails the predicate — inexpressible per-branch) and on a formula alias; ORDER BY on a plain group column, an output value column, a quoted derived expression-group-by name, and a group-by alias; bucket-first ordering on time series; ratio-column HAVING/ORDER BY; LIMIT/OFFSET pages are disjoint windows of one consistent joined ordering.

Out of scope (noted in HDX-5126)

  • seriesLimit top-N ranking still runs per-branch.
  • App-side: DBTableChart header-click sorting only quotes explicit user aliases — auto-aliased metric columns and formula columns produce unquoted orderBy expressions that don't resolve (broken before this change too, since branch scopes couldn't resolve them either). Editor autocomplete for output names in the Having/Order By inputs is a separate follow-up.

…-series branches (HDX-5126)

The composed multi-series metric query spread the whole chart config into
each per-series branch, so having/orderBy/limit ran inside every UNION
branch — a scope where the user-facing output names don't exist (the
value column is renamed __hdx_value) — and the outer join then discarded
the per-branch ordering entirely. Each series was also truncated to its
own arbitrary LIMIT window, producing mismatched group sets (spurious
NULL cells) and incoherent table pagination.

All three clauses now render once, on the final joined SELECT, where
they reference the output columns: operand aliases (when shown), formula
names/aliases, the ratio column, group-by passthroughs and the time
bucket. Time charts stay bucket-ordered first, with the user sort as a
tiebreaker. Row-level filters (where/filters/aggCondition) stay
per-branch, where they belong.

Reference contract, pinned by integration tests on CH 26.5: quoted
output names or user aliases. Raw expressions over source columns (e.g.
ResourceAttributes['service.name']) do not resolve in the outer scope —
verified UNKNOWN_IDENTIFIER under the new analyzer — so an expression
group-by is referenced via its quoted ClickHouse-derived name or a
group-by alias.
@changeset-bot

changeset-bot Bot commented Aug 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d5f8306

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@hyperdx/common-utils Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Aug 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hyperdx-oss Ready Ready Preview Aug 20, 2026 6:51am
hyperdx-storybook Ready Ready Preview Aug 20, 2026 6:51am

Request Review

@github-actions github-actions Bot added the review/tier-2 Low risk — AI review + quick human skim label Aug 20, 2026
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

🔵 Tier 2 — Low Risk

Small, isolated change with no API route or data model modifications.

Why this tier:

  • Standard feature/fix — introduces new logic or modifies core functionality

Additional context: touches the query rendering engine lightly (53 lines, under the 150-line bar for Tier 4)

Review process: AI review + quick human skim (target: 5–15 min). Reviewer validates AI assessment and checks for domain-specific concerns.
SLA: Resolve within 4 business hours.

Stats
  • Production files changed: 1
  • Production lines changed: 53 (+ 638 in test files, excluded from tier calculation)
  • Branch: warren/hdx-5126
  • Author: wrn14897

To override this classification, remove the review/tier-2 label and apply a different review/tier-* label. Manual overrides are preserved on subsequent pushes.

@greptile-apps

greptile-apps Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR moves composed metric HAVING, ORDER BY, and LIMIT clauses from individual series branches to the final joined result, with a post-window wrapper for share-of-total filtering.

  • Removes final-result clauses from each per-series branch.
  • Applies filtering, sorting, and pagination once to the composed projection.
  • Preserves bucket-first ordering for time charts.
  • Adds unit, snapshot, and ClickHouse integration coverage for output aliases, formulas, ratios, grouping, and pagination.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the previously reported share-of-total HAVING issue is addressed by filtering the completed window projection through an outer query, with integration coverage confirming pre-filter denominator semantics.

Important Files Changed

Filename Overview
packages/common-utils/src/core/renderChartConfig.ts Moves composed-query clauses to the final projection and safely filters share-of-total window results through an outer wrapper.
packages/common-utils/src/tests/renderChartConfig.test.ts Verifies clause placement, wrapper shape, bucket-first sorting, string-form ordering, number charts, and formula aliases.
packages/common-utils/src/tests/queryChartConfig.int.test.ts Adds ClickHouse integration coverage for joined filtering, ordering, pagination, group aliases, ratios, and post-window share-of-total filtering.
packages/common-utils/src/tests/snapshots/renderChartConfig.test.ts.snap Pins the generated SQL for standard outer clauses and the share-of-total wrapper.
.changeset/composed-outer-clauses.md Documents the corrected composed metric filtering, ordering, and pagination behavior.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Chart configuration] --> B[Render per-series branches]
  B --> C[UNION ALL]
  C --> D[Compose joined projection]
  D --> E{Share-of-total with HAVING?}
  E -- No --> F[Apply HAVING]
  E -- Yes --> G[Wrap projection and apply WHERE]
  F --> H[Apply final ORDER BY and LIMIT]
  G --> H
  H --> I[Final ClickHouse query]
Loading

Reviews (5): Last reviewed commit: "Merge branch 'main' into warren/hdx-5126" | Re-trigger Greptile

Comment thread packages/common-utils/src/core/renderChartConfig.ts Outdated
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

E2E Test Results

All tests passed • 305 passed • 1 skipped • 1023s

Status Count
✅ Passed 305
❌ Failed 0
⚠️ Flaky 1
⏭️ Skipped 1

Tests ran across 4 shards in parallel.

View full report →

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Deep Review

Composed multi-series metric charts now render HAVING / ORDER BY / LIMIT once on the final joined SELECT instead of leaking into each per-series UNION ALL branch, with a SELECT * FROM (core) WHERE … wrapper for share_of_total ratios (ClickHouse forbids window functions in HAVING). The change is tightly scoped to renderMultiSeriesMetricChartConfig and is backed by a large unit/snapshot + integration matrix.

Independent verification of the highest-risk paths: concatChSql skips empty fragments and only inserts separators between non-empty parts, so the guarded orderBy.sql ? …, having?.sql, and limit?.sql renders cannot emit a dangling ORDER BY/HAVING/LIMIT keyword; the usesWindowProjection gate is exhaustive (only share_of_total ratios produce a window projection, and formulas — which supersede the ratio toggle — never do, so a window function cannot leak into a real HAVING); and renderOrderBy remains live on the single-series path (no dead code).

✅ No critical issues found.

🟡 P2 -- recommended

  • packages/common-utils/src/core/renderChartConfig.ts:2392 -- Stripping per-branch LIMIT means each UNION ALL branch now emits every group (bounded by group-by cardinality) into the pivot and the outer sort instead of ~limit rows, so a high-cardinality group-by with a small LIMIT materializes and sorts a much larger intermediate set; this is a necessary consequence of the correctness fix (per-branch limits selected inconsistent group sets), not a defect, but it is an unmeasured resource tradeoff.
    • Fix: Add an integration assertion (or monitoring note) covering a high-cardinality group-by with a small LIMIT to confirm outer-query memory/sort cost stays acceptable at expected scale.
    • performance
🔵 P3 nitpicks (3)
  • packages/common-utils/src/core/renderChartConfig.ts:2563 -- The outer ORDER BY is built inline with the same shape as renderOrderBy (bucket expression + renderSortSpecificationList), differing only in the bucket rendering, so a future change to ordering precedence in renderOrderBy would silently diverge from this site.
    • Fix: Give renderOrderBy an optional bucket-expression override so both sites share the ordering-precedence logic.
    • maintainability
  • packages/common-utils/src/core/renderChartConfig.ts:2586 -- The three-way nested ternary for filtered couples two orthogonal decisions (whether a filter exists, and how it must be applied) into one dense expression.
    • Fix: Extract a small applyOuterHaving(core, having, usesWindowProjection) helper or use an early guard plus if/else so each branch's intent is scannable.
    • maintainability
  • packages/common-utils/src/core/renderChartConfig.ts:2594 -- The share_of_total integration/snapshot tests all pair the window ratio with a HAVING (wrapper path); the distinct path where a share_of_total ratio has an ORDER BY but no HAVING (filtered = core, then ORDER BY referencing the window-function alias directly on the core statement) is not exercised.
    • Fix: Add a test for a share_of_total ratio with orderBy and no having to pin the no-wrapper ordering path.

Reviewers (5): maintainability, project-standards, performance, agent-native, learnings-researcher.

Coverage note: The correctness, adversarial, testing, and TypeScript reviewers were dispatched but had not returned when synthesis was forced; the correctness/adversarial-class concerns (dangling clauses, window-function leakage into HAVING, wrapper gating exhaustiveness, dead code) were instead verified directly against the source and found sound. Changeset (.changeset/composed-outer-clauses.md) format and patch bump were confirmed compliant. No prior docs/solutions/ learnings apply to this subsystem.

Testing gaps:

  • No assertion that a high-cardinality group-by with a small LIMIT stays within acceptable outer-query memory/sort cost.
  • share_of_total ratio with ORDER BY and no HAVING (window alias ordered on the core statement) is untested.

The share_of_total ratio is the one composed projection built on a
window function (sum(...) OVER (...)), and ClickHouse prohibits window
functions in HAVING — alias substitution of the ratio output name would
pull the OVER() expression straight into the clause and fail the query.

When having is set on a share_of_total ratio, the filter now runs as
WHERE on a wrapper around the joined result, which evaluates after the
window with identical filter-the-output-rows semantics. ORDER BY and
LIMIT follow on the outermost statement either way (filter, then order,
then limit). Every other projection keeps plain HAVING, rendered
byte-identically to before.

Flagged by Greptile on the PR; verified with an integration test that
the share divides by the pre-filter group total, i.e. the window
evaluates over the full joined result before the filter.
Fills the gaps in the HDX-5126 coverage:

- number shape + HAVING: the outer query has no GROUP BY ALL (implicit
  global aggregation) — pinned both as rendered SQL and end-to-end
  (filters the single row / drops it)
- time-series HAVING: filters (bucket, group) joined rows; NULL fails
  the predicate like any SQL comparison
- share_of_total on a time series: the window partitions per bucket
  inside the wrapper, per-bucket totals divide each share, and the
  bucket-first ORDER BY sits outside the wrapper
- heterogeneous branch classes: HAVING on a gauge column drops
  histogram rows, whose gauge value is NULL (plain vs Array group
  columns never share a merge key)
- ORDER BY a formula output column (with LIMIT)
- hidden operands are not referenceable: showOperandSeries false +
  HAVING on an operand name rejects instead of silently filtering on a
  column the chart doesn't show
- string-form orderBy (SortSpecificationList string variant) renders
  once, on the outer statement

Deliberately not pinned: seriesLimit still ranks per branch (tracked as
an HDX-5126 follow-up — a test would freeze the imperfect behavior) and
lucene-language HAVING (the editor and MCP only emit SQL having).
Drop ticket-number references, tighten the outer-clause comment blocks,
and correct the fixture-rate recaps to the last-value gauge semantics
the table shape actually produces.

@jordan-simonovski jordan-simonovski left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@kodiakhq
kodiakhq Bot merged commit c349a5d into main Aug 20, 2026
27 checks passed
@kodiakhq
kodiakhq Bot deleted the warren/hdx-5126 branch August 20, 2026 06:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automerge review/tier-2 Low risk — AI review + quick human skim

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants