Skip to content

Commit a57f5ac

Browse files
committed
time bucket thresholds can now be defined per query schema
1 parent 9265453 commit a57f5ac

File tree

7 files changed

+368
-8
lines changed

7 files changed

+368
-8
lines changed

apps/webapp/app/v3/querySchemas.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { column, type TableSchema } from "@internal/tsql";
1+
import { column, type BucketThreshold, type TableSchema } from "@internal/tsql";
22
import { z } from "zod";
33
import { autoFormatSQL } from "~/components/code/TSQLEditor";
44
import { runFriendlyStatus, runStatusTitleFromStatus } from "~/components/runs/v3/TaskRunStatus";
@@ -598,6 +598,18 @@ export const metricsSchema: TableSchema = {
598598
expression: "attributes.trigger.worker_version",
599599
},
600600
},
601+
timeBucketThresholds: [
602+
// Metrics are pre-aggregated into 10-second buckets, so 10s is the most granular interval.
603+
// All thresholds are shifted coarser compared to the runs table defaults.
604+
{ maxRangeSeconds: 3 * 60 * 60, interval: { value: 10, unit: "SECOND" } },
605+
{ maxRangeSeconds: 12 * 60 * 60, interval: { value: 1, unit: "MINUTE" } },
606+
{ maxRangeSeconds: 2 * 24 * 60 * 60, interval: { value: 5, unit: "MINUTE" } },
607+
{ maxRangeSeconds: 7 * 24 * 60 * 60, interval: { value: 15, unit: "MINUTE" } },
608+
{ maxRangeSeconds: 30 * 24 * 60 * 60, interval: { value: 1, unit: "HOUR" } },
609+
{ maxRangeSeconds: 90 * 24 * 60 * 60, interval: { value: 6, unit: "HOUR" } },
610+
{ maxRangeSeconds: 180 * 24 * 60 * 60, interval: { value: 1, unit: "DAY" } },
611+
{ maxRangeSeconds: 365 * 24 * 60 * 60, interval: { value: 1, unit: "WEEK" } },
612+
] satisfies BucketThreshold[],
601613
};
602614

603615
/**

internal-packages/tsql/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,9 @@ export {
133133

134134
// Re-export time bucket utilities
135135
export {
136+
BUCKET_THRESHOLDS,
136137
calculateTimeBucketInterval,
138+
type BucketThreshold,
137139
type TimeBucketInterval,
138140
} from "./query/time_buckets.js";
139141

internal-packages/tsql/src/query/printer.test.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@ import { describe, it, expect, beforeEach } from "vitest";
22
import { parseTSQLSelect, parseTSQLExpr, compileTSQL } from "../index.js";
33
import { ClickHousePrinter, printToClickHouse, type PrintResult } from "./printer.js";
44
import { createPrinterContext, PrinterContext } from "./printer_context.js";
5-
import { createSchemaRegistry, column, type TableSchema, type SchemaRegistry } from "./schema.js";
5+
import {
6+
createSchemaRegistry,
7+
column,
8+
type TableSchema,
9+
type SchemaRegistry,
10+
} from "./schema.js";
11+
import type { BucketThreshold } from "./time_buckets.js";
612
import { QueryError, SyntaxError } from "./errors.js";
713

814
/**
@@ -3570,4 +3576,73 @@ describe("timeBucket()", () => {
35703576
expect(Object.values(params)).toContain("org_test123");
35713577
});
35723578
});
3579+
3580+
describe("per-table timeBucketThresholds", () => {
3581+
const customThresholds: BucketThreshold[] = [
3582+
// 10-second minimum granularity (e.g., for pre-aggregated metrics)
3583+
{ maxRangeSeconds: 10 * 60, interval: { value: 10, unit: "SECOND" } },
3584+
{ maxRangeSeconds: 30 * 60, interval: { value: 30, unit: "SECOND" } },
3585+
{ maxRangeSeconds: 2 * 60 * 60, interval: { value: 1, unit: "MINUTE" } },
3586+
];
3587+
3588+
const schemaWithCustomThresholds: TableSchema = {
3589+
...timeBucketSchema,
3590+
name: "metrics",
3591+
timeBucketThresholds: customThresholds,
3592+
};
3593+
3594+
it("should use custom thresholds when defined on the table schema", () => {
3595+
// 3-minute range: global default would give 5 SECOND, custom gives 10 SECOND
3596+
const threeMinuteRange = {
3597+
from: new Date("2024-01-01T00:00:00Z"),
3598+
to: new Date("2024-01-01T00:03:00Z"),
3599+
};
3600+
3601+
const schema = createSchemaRegistry([schemaWithCustomThresholds]);
3602+
const ctx = createPrinterContext({
3603+
schema,
3604+
enforcedWhereClause: {
3605+
organization_id: { op: "eq", value: "org_test123" },
3606+
project_id: { op: "eq", value: "proj_test456" },
3607+
environment_id: { op: "eq", value: "env_test789" },
3608+
},
3609+
timeRange: threeMinuteRange,
3610+
});
3611+
3612+
const ast = parseTSQLSelect(
3613+
"SELECT timeBucket(), count() FROM metrics GROUP BY timeBucket"
3614+
);
3615+
const { sql } = printToClickHouse(ast, ctx);
3616+
3617+
// Custom thresholds: under 10 min → 10 SECOND (not the global 5 SECOND)
3618+
expect(sql).toContain("toStartOfInterval(created_at, INTERVAL 10 SECOND)");
3619+
});
3620+
3621+
it("should fall back to global defaults when no custom thresholds are defined", () => {
3622+
// 3-minute range with standard schema (no custom thresholds)
3623+
const threeMinuteRange = {
3624+
from: new Date("2024-01-01T00:00:00Z"),
3625+
to: new Date("2024-01-01T00:03:00Z"),
3626+
};
3627+
3628+
const schema = createSchemaRegistry([timeBucketSchema]);
3629+
const ctx = createPrinterContext({
3630+
schema,
3631+
enforcedWhereClause: {
3632+
organization_id: { op: "eq", value: "org_test123" },
3633+
project_id: { op: "eq", value: "proj_test456" },
3634+
environment_id: { op: "eq", value: "env_test789" },
3635+
},
3636+
timeRange: threeMinuteRange,
3637+
});
3638+
3639+
const ast = parseTSQLSelect(
3640+
"SELECT timeBucket(), count() FROM runs GROUP BY timeBucket"
3641+
);
3642+
const { sql } = printToClickHouse(ast, ctx);
3643+
3644+
// Global default: under 5 min → 5 SECOND
3645+
expect(sql).toContain("toStartOfInterval(created_at, INTERVAL 5 SECOND)");
3646+
});
3647+
});
35733648
});

internal-packages/tsql/src/query/printer.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2978,8 +2978,12 @@ export class ClickHousePrinter {
29782978
);
29792979
}
29802980

2981-
// Calculate the appropriate interval
2982-
const interval = calculateTimeBucketInterval(timeRange.from, timeRange.to);
2981+
// Calculate the appropriate interval (use table-specific thresholds if defined)
2982+
const interval = calculateTimeBucketInterval(
2983+
timeRange.from,
2984+
timeRange.to,
2985+
tableSchema.timeBucketThresholds
2986+
);
29832987

29842988
// Emit toStartOfInterval(column, INTERVAL N UNIT)
29852989
return `toStartOfInterval(${escapeClickHouseIdentifier(clickhouseColumnName)}, INTERVAL ${interval.value} ${interval.unit})`;

internal-packages/tsql/src/query/schema.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Defines allowed tables, columns, and tenant isolation configuration
33

44
import { QueryError } from "./errors";
5+
import type { BucketThreshold } from "./time_buckets";
56

67
/**
78
* ClickHouse data types supported by TSQL
@@ -354,6 +355,13 @@ export interface TableSchema {
354355
* ```
355356
*/
356357
timeConstraint?: string;
358+
/**
359+
* Custom time bucket thresholds for this table.
360+
* When set, timeBucket() uses these instead of the global defaults.
361+
* Useful when the table's time granularity differs from the standard (e.g., metrics
362+
* pre-aggregated into 10-second buckets shouldn't go below 10-second intervals).
363+
*/
364+
timeBucketThresholds?: BucketThreshold[];
357365
}
358366

359367
/**

internal-packages/tsql/src/query/time_buckets.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,23 @@ export interface TimeBucketInterval {
1717
}
1818

1919
/**
20-
* Time bucket thresholds: each entry defines a maximum time range duration (in seconds)
20+
* A threshold mapping a maximum time range duration to a bucket interval.
21+
*/
22+
export interface BucketThreshold {
23+
/** Maximum range duration in seconds for this threshold to apply */
24+
maxRangeSeconds: number;
25+
/** The bucket interval to use when the range is under maxRangeSeconds */
26+
interval: TimeBucketInterval;
27+
}
28+
29+
/**
30+
* Default time bucket thresholds: each entry defines a maximum time range duration (in seconds)
2131
* and the corresponding bucket interval to use.
2232
*
2333
* The intervals are chosen to produce roughly 50-100 data points for the given range.
2434
* Entries are ordered from smallest to largest range.
2535
*/
26-
const BUCKET_THRESHOLDS: Array<{ maxRangeSeconds: number; interval: TimeBucketInterval }> = [
36+
export const BUCKET_THRESHOLDS: BucketThreshold[] = [
2737
// Under 5 minutes → 5 second buckets (max 60 buckets)
2838
{ maxRangeSeconds: 5 * 60, interval: { value: 5, unit: "SECOND" } },
2939
// Under 30 minutes → 30 second buckets (max 60 buckets)
@@ -73,10 +83,14 @@ const DEFAULT_LARGE_INTERVAL: TimeBucketInterval = { value: 1, unit: "MONTH" };
7383
* ); // { value: 6, unit: "HOUR" }
7484
* ```
7585
*/
76-
export function calculateTimeBucketInterval(from: Date, to: Date): TimeBucketInterval {
86+
export function calculateTimeBucketInterval(
87+
from: Date,
88+
to: Date,
89+
thresholds?: BucketThreshold[]
90+
): TimeBucketInterval {
7791
const rangeSeconds = Math.abs(to.getTime() - from.getTime()) / 1000;
7892

79-
for (const threshold of BUCKET_THRESHOLDS) {
93+
for (const threshold of thresholds ?? BUCKET_THRESHOLDS) {
8094
if (rangeSeconds < threshold.maxRangeSeconds) {
8195
return threshold.interval;
8296
}

0 commit comments

Comments
 (0)