feat: Add a minimum date to MV configuration (#1549)

Closes HDX-3129

# Summary

This PR adds a "minimum date" setting to Materialized view configurations. The minimum date is optional. When provided, the materialized view will not be used for any query that includes date ranges prior to the minimum date.

## Demo


https://github.com/user-attachments/assets/de0efb45-786e-4b45-b053-f1dc7c322f2e
This commit is contained in:
Drew Davis 2026-01-05 12:59:39 -05:00 committed by GitHub
parent ac1a2f7768
commit ab7645deb6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 204 additions and 8 deletions

View file

@ -0,0 +1,6 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/app": patch
---
feat: Add a minimum date to MV configuration

View file

@ -1,7 +1,9 @@
import { useMemo } from 'react';
import { splitAndTrimWithBracket } from '@hyperdx/common-utils/dist/core/utils';
import { MaterializedViewConfiguration } from '@hyperdx/common-utils/dist/types';
import { Group, Pill, Stack, Table, Text } from '@mantine/core';
import { Grid, Group, Pill, Stack, Table, Text } from '@mantine/core';
import { FormatTime } from '@/useFormatTime';
export default function MVConfigSummary({
config,
@ -29,12 +31,24 @@ export default function MVConfigSummary({
return (
<Stack gap="md">
<div>
<Text size="sm" fw={500} mb="xs">
Minimum Granularity
</Text>
<Pill>{config.minGranularity}</Pill>
</div>
<Grid columns={2}>
<Grid.Col span={1}>
<Text size="sm" fw={500} mb="xs">
Granularity
</Text>
<Pill>{config.minGranularity}</Pill>
</Grid.Col>
{config.minDate && (
<Grid.Col span={1}>
<Text size="sm" fw={500} mb="xs">
Minimum Date
</Text>
<Pill>
<FormatTime value={config.minDate} format="withYear" />
</Pill>
</Grid.Col>
)}
</Grid>
<div>
<Text size="sm" fw={500} mb="xs">

View file

@ -33,6 +33,7 @@ import {
Text,
Tooltip,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { notifications } from '@mantine/notifications';
import {
IconCirclePlus,
@ -402,7 +403,7 @@ function MaterializedViewFormSection({
</Group>
</Grid.Col>
<Grid.Col span={1}>
<Grid.Col span={2}>
<Text size="xs" fw={500} mb={4}>
Timestamp Column
</Text>
@ -445,6 +446,38 @@ function MaterializedViewFormSection({
)}
/>
</Grid.Col>
<Grid.Col span={1}>
<Text size="xs" fw={500} mb={4}>
Minimum Date
<Tooltip
label="(Optional) The earliest date and time (in the local timezone) for which the materialized view contains data. If not provided, then HyperDX will assume that the materialized view contains data for all dates for which the source table contains data."
color="dark"
c="white"
multiline
maw={600}
>
<IconHelpCircle size={14} className="cursor-pointer ms-1" />
</Tooltip>
</Text>
<Controller
control={control}
name={`materializedViews.${mvIndex}.minDate`}
render={({ field }) => (
<DateInput
{...field}
value={field.value ? new Date(field.value) : undefined}
onChange={dateStr =>
field.onChange(dateStr ? dateStr.toISOString() : null)
}
clearable
highlightToday
placeholder="YYYY-MM-DD HH:mm:ss"
valueFormat="YYYY-MM-DD HH:mm:ss"
/>
)}
/>
</Grid.Col>
</Grid>
<Box>

View file

@ -781,6 +781,122 @@ describe('materializedViews', () => {
'Custom count() expressions are not supported with materialized views.',
]);
});
it("should not use the materialized view when the chart config references a date range prior to the MV's date range", async () => {
const chartConfig: ChartConfigWithOptDateRange = {
from: {
databaseName: 'default',
tableName: 'otel_spans',
},
select: [
{
valueExpression: '',
aggFn: 'count',
},
],
where: '',
connection: 'test-connection',
dateRange: [
new Date('2022-12-01T00:00:00Z'),
new Date('2022-12-31T23:59:59Z'),
],
};
const result = await tryConvertConfigToMaterializedViewSelect(
chartConfig,
{
...MV_CONFIG_METRIC_ROLLUP_1M,
minDate: '2023-01-01T00:00:00Z',
},
metadata,
);
expect(result.optimizedConfig).toBeUndefined();
expect(result.errors).toEqual([
'The selected date range includes dates for which this view does not contain data.',
]);
});
it('should not use the materialized view when the chart config has no date range and the MV has a minimum date', async () => {
const chartConfig: ChartConfigWithOptDateRange = {
from: {
databaseName: 'default',
tableName: 'otel_spans',
},
select: [
{
valueExpression: '',
aggFn: 'count',
},
],
where: '',
connection: 'test-connection',
};
const result = await tryConvertConfigToMaterializedViewSelect(
chartConfig,
{
...MV_CONFIG_METRIC_ROLLUP_1M,
minDate: '2023-01-01T00:00:00Z',
},
metadata,
);
expect(result.optimizedConfig).toBeUndefined();
expect(result.errors).toEqual([
'The selected date range includes dates for which this view does not contain data.',
]);
});
it("should use the materialized view when the chart config's date range is after the MV's minimum date", async () => {
const chartConfig: ChartConfigWithOptDateRange = {
from: {
databaseName: 'default',
tableName: 'otel_spans',
},
select: [
{
valueExpression: '',
aggFn: 'count',
},
],
where: '',
connection: 'test-connection',
dateRange: [
new Date('2023-12-01T00:00:00Z'),
new Date('2023-12-31T23:59:59Z'),
],
};
const result = await tryConvertConfigToMaterializedViewSelect(
chartConfig,
{
...MV_CONFIG_METRIC_ROLLUP_1M,
minDate: '2023-01-01T00:00:00Z',
},
metadata,
);
expect(result.optimizedConfig).toEqual({
from: {
databaseName: 'default',
tableName: 'metrics_rollup_1m',
},
dateRange: [
new Date('2023-12-01T00:00:00Z'),
new Date('2023-12-31T23:59:59Z'),
],
select: [
{
valueExpression: 'count',
aggFn: 'sum',
},
],
where: '',
connection: 'test-connection',
});
expect(result.errors).toBeUndefined();
});
});
describe('isUnsupportedCountFunction', () => {

View file

@ -153,6 +153,24 @@ function mvConfigSupportsGranularity(
return chartGranularitySeconds >= mvGranularitySeconds;
}
function mvConfigSupportsDateRange(
mvConfig: MaterializedViewConfiguration,
chartConfig: ChartConfigWithOptDateRange,
) {
if (mvConfig.minDate && !chartConfig.dateRange) {
return false;
}
if (!mvConfig.minDate || !chartConfig.dateRange) {
return true;
}
const [startDate] = chartConfig.dateRange;
const minDate = new Date(mvConfig.minDate);
return startDate >= minDate;
}
const COUNT_FUNCTION_PATTERN = /\bcount(If)?\s*\(/i;
export function isUnsupportedCountFunction(selectItem: SelectItem): boolean {
return COUNT_FUNCTION_PATTERN.test(selectItem.valueExpression);
@ -268,6 +286,14 @@ export async function tryConvertConfigToMaterializedViewSelect<
};
}
if (mvConfig.minDate && !mvConfigSupportsDateRange(mvConfig, chartConfig)) {
return {
errors: [
'The selected date range includes dates for which this view does not contain data.',
],
};
}
if (!mvConfigSupportsGranularity(mvConfig, chartConfig)) {
const error = chartConfig.granularity
? `Granularity must be at least ${mvConfig.minGranularity}.`

View file

@ -646,6 +646,7 @@ export const MaterializedViewConfigurationSchema = z.object({
tableName: z.string().min(1, 'Materialized View Table is required'),
dimensionColumns: z.string(),
minGranularity: SQLIntervalSchema,
minDate: z.string().datetime().nullish(),
timestampColumn: z
.string()
.min(1, 'Materialized View Timestamp column is required'),