Skip to content

Commit 24699cd

Browse files
authored
fix: Infer singular quantileXxxMerge() from MV quantilesXxxState() (#2154)
## Summary This PR fixes a bug in the quantile function inference used for MV queries. For MVs defined with `quantilesXxxState()` columns, HyperDX will now infer a `quantileXxxMerge` function instead of a `quantilesXxxMerge` function. `quantilesXxxMerge` returns an array result, which is incompatible with HyperDX's chart implementations. I've tested that quantile, quantileTDigest, and quantileExact all return equivalent results when querying `quantileXxxMerge` and `quantilesXxxMerge` from a `quantilesXxxState` column. ### Screenshots or video Before: <img width="1021" height="554" alt="Screenshot 2026-04-23 at 7 47 59 AM" src="https://github.com/user-attachments/assets/4cb325a9-be69-404b-8975-4d2066aee6c1" /> After: <img width="1023" height="740" alt="Screenshot 2026-04-23 at 7 47 33 AM" src="https://github.com/user-attachments/assets/ca7ca7a0-d0be-4857-9f6c-fba1c25bd2c9" /> ### How to test locally or on Vercel This can be tested locally by creating a MV as follows and registering it in the Traces source configuration: ```sql CREATE TABLE otel_traces_1m_quantiles ( `Timestamp` DateTime, `ServiceName` LowCardinality(String), `StatusCode` LowCardinality(String), `count` SimpleAggregateFunction(sum, UInt64), `avg__Duration` AggregateFunction(avg, UInt64), `max__Duration` SimpleAggregateFunction(max, Int64), `quantiles__Duration` AggregateFunction(quantiles(0.5, 0.95), UInt64) ) ENGINE = AggregatingMergeTree ORDER BY (Timestamp, ServiceName, StatusCode); CREATE MATERIALIZED VIEW otel_traces_1m_quantiles_mv TO otel_traces_1m_quantiles AS SELECT toStartOfMinute(Timestamp) AS Timestamp, ServiceName, StatusCode, count() AS count, avgState(Duration) AS avg__Duration, maxSimpleState(Duration) AS max__Duration, quantilesState(0.5, 0.95)(Duration) AS quantiles__Duration FROM otel_traces GROUP BY Timestamp, ServiceName, StatusCode; ``` ### References - Linear Issue: Closes HDX-4099 Closes #2153 - Related PRs:
1 parent ef571cc commit 24699cd

3 files changed

Lines changed: 118 additions & 2 deletions

File tree

‎.changeset/cool-students-camp.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@hyperdx/common-utils": patch
3+
"@hyperdx/app": patch
4+
---
5+
6+
fix: Infer singular quantileXXX() from MV quantilesXXXState()

‎packages/common-utils/src/clickhouse/__tests__/materializedViews.test.ts‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,112 @@ describe('materializedViews', () => {
287287
expect(result.errors).toBeUndefined();
288288
});
289289

290+
it('should normalize a quantiles AggregateFunction to quantileMerge', async () => {
291+
const quantilesMetadata: Metadata = {
292+
getColumn: jest.fn().mockImplementation(({ column }) => {
293+
if (column === 'quantile__Duration') {
294+
return {
295+
type: 'AggregateFunction(quantiles(0.9, 0.95), UInt64)',
296+
} as unknown as ColumnMeta;
297+
}
298+
return undefined;
299+
}),
300+
} as unknown as Metadata;
301+
302+
const chartConfig: ChartConfigWithOptDateRange = {
303+
from: {
304+
databaseName: 'default',
305+
tableName: 'otel_spans',
306+
},
307+
select: [
308+
{
309+
valueExpression: 'Duration',
310+
aggFn: 'quantile',
311+
level: 0.95,
312+
},
313+
],
314+
where: '',
315+
connection: 'test-connection',
316+
};
317+
318+
const result = await tryConvertConfigToMaterializedViewSelect(
319+
chartConfig,
320+
MV_CONFIG_METRIC_ROLLUP_1M,
321+
quantilesMetadata,
322+
);
323+
324+
expect(result.optimizedConfig).toEqual({
325+
from: {
326+
databaseName: 'default',
327+
tableName: 'metrics_rollup_1m',
328+
},
329+
select: [
330+
{
331+
valueExpression: 'quantile__Duration',
332+
aggFn: 'quantileMerge',
333+
level: 0.95,
334+
},
335+
],
336+
timestampValueExpression: 'Timestamp',
337+
where: '',
338+
connection: 'test-connection',
339+
});
340+
expect(result.errors).toBeUndefined();
341+
});
342+
343+
it('should normalize a quantilesTDigest AggregateFunction to quantileTDigestMerge', async () => {
344+
const quantilesTDigestMetadata: Metadata = {
345+
getColumn: jest.fn().mockImplementation(({ column }) => {
346+
if (column === 'quantile__Duration') {
347+
return {
348+
type: 'AggregateFunction(quantilesTDigest(0.9, 0.95), UInt64)',
349+
} as unknown as ColumnMeta;
350+
}
351+
return undefined;
352+
}),
353+
} as unknown as Metadata;
354+
355+
const chartConfig: ChartConfigWithOptDateRange = {
356+
from: {
357+
databaseName: 'default',
358+
tableName: 'otel_spans',
359+
},
360+
select: [
361+
{
362+
valueExpression: 'Duration',
363+
aggFn: 'quantile',
364+
level: 0.95,
365+
},
366+
],
367+
where: '',
368+
connection: 'test-connection',
369+
};
370+
371+
const result = await tryConvertConfigToMaterializedViewSelect(
372+
chartConfig,
373+
MV_CONFIG_METRIC_ROLLUP_1M,
374+
quantilesTDigestMetadata,
375+
);
376+
377+
expect(result.optimizedConfig).toEqual({
378+
from: {
379+
databaseName: 'default',
380+
tableName: 'metrics_rollup_1m',
381+
},
382+
select: [
383+
{
384+
valueExpression: 'quantile__Duration',
385+
aggFn: 'quantileTDigestMerge',
386+
level: 0.95,
387+
},
388+
],
389+
timestampValueExpression: 'Timestamp',
390+
where: '',
391+
connection: 'test-connection',
392+
});
393+
expect(result.errors).toBeUndefined();
394+
});
395+
290396
it('should convert a histogram AggregateFunction select', async () => {
291397
const chartConfig: ChartConfigWithOptDateRange = {
292398
from: {

‎packages/common-utils/src/core/materializedViews.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,15 @@ async function getQuantileAggregateFunction(
6565
}
6666

6767
// Use regex to extract the quantile function name inside AggregateFunction(...)
68-
// For example, AggregateFunction(quantile(0.95), Int64) --> quantile
68+
// For example, AggregateFunction(quantile(0.95), Int64) --> quantile
6969
// AggregateFunction(quantileTDigest(0.95), Int64) --> quantileTDigest
7070
// AggregateFunction(quantileDD(0.001, 0.95), Int64) --> quantileDD
71+
// The plural `quantiles*` variants return arrays, but a select item carries a
72+
// single `level`, so we normalize to the singular form to pull a scalar value.
73+
// AggregateFunction(quantiles(0.9, 0.95), UInt64) --> quantile
74+
// AggregateFunction(quantilesTDigest(0.9, 0.95), UInt64) --> quantileTDigest
7175
const match = type.match(/^AggregateFunction\(\s*([^(, ]+)\s*\(/);
72-
return match?.[1];
76+
return match?.[1]?.replace(/^quantiles/, 'quantile');
7377
} catch {
7478
return undefined;
7579
}

0 commit comments

Comments
 (0)