From 032ac1d9d8860392532636b2c7fcdbaef9dbead8 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 7 Nov 2026 01:44:30 +0100 Subject: [PATCH] fix(query-engine): charts span the service pages' longest presets The services, service detail, service map and releases pages offer presets up to 12 months, but every timeseries through /execute was refused past 31 days. The services table waits on its sparklines, so 3mo/6mo/12mo (and 1mo across a DST change) showed "Couldn't load services / Time range too large". Timeseries and the services facets read the 365-day rollups, so they now get a 367-day ceiling. Lists, breakdowns and other kinds keep theirs. --- .../warehouse/QueryEngineService.test.ts | 65 +++++++++++++++++++ packages/query-engine/src/limits.ts | 10 ++- .../query-engine/src/runtime/query-engine.ts | 25 +++++-- 3 files changed, 91 insertions(+), 9 deletions(-) diff --git a/packages/backend/src/services/warehouse/QueryEngineService.test.ts b/packages/backend/src/services/warehouse/QueryEngineService.test.ts index 78571471be..b5ea2c19a0 100644 --- a/packages/backend/src/services/warehouse/QueryEngineService.test.ts +++ b/packages/backend/src/services/warehouse/QueryEngineService.test.ts @@ -593,6 +593,71 @@ describe("makeQueryEngineExecute", () => { }), ) + it.effect("serves a timeseries over the service pages' longest presets", () => + Effect.gen(function* () { + let queried = false + const execute = makeQueryEngineExecute( + makeTinybirdStub({ + sqlQuery: () => + Effect.sync(() => { + queried = true + return [] + }), + }), + ) + const exit = yield* Effect.exit( + execute( + tenant, + new QueryEngineExecuteRequest({ + startTime: "2025-11-06 00:00:00", + endTime: "2026-11-07 01:00:00", // "12mo" plus the DST hour + query: { + kind: "timeseries", + source: "traces", + metric: "count", + allMetrics: true, + groupBy: ["service"], + filters: { rootSpansOnly: true }, + bucketSeconds: 86400, + }, + }), + ), + ) + + assert.isTrue(Exit.isSuccess(exit)) + assert.isTrue(queried) + }), + ) + + it.effect("still refuses a breakdown wider than its own ceiling", () => + Effect.gen(function* () { + const execute = makeQueryEngineExecute( + makeTinybirdStub({ sqlQuery: () => Effect.die(new Error("should not be called")) }), + ) + const exit = yield* Effect.exit( + execute( + tenant, + new QueryEngineExecuteRequest({ + startTime: "2026-08-01 00:00:00", + endTime: "2026-11-01 00:00:00", + query: { + kind: "breakdown", + source: "traces", + metric: "count", + groupBy: "service", + filters: { serviceName: asServiceName("checkout") }, + }, + }), + ), + ) + + assert.include( + (getFailure(exit) as { message?: string } | undefined)?.message ?? "", + "Time range too large", + ) + }), + ) + it.effect("rejects timeseries requests that exceed the point budget", () => Effect.gen(function* () { const execute = makeQueryEngineExecute(makeTinybirdStub()) diff --git a/packages/query-engine/src/limits.ts b/packages/query-engine/src/limits.ts index a8937c5aad..b1bdc28f06 100644 --- a/packages/query-engine/src/limits.ts +++ b/packages/query-engine/src/limits.ts @@ -14,9 +14,15 @@ import { relativeRangeSeconds } from "./datetime" -/** Widest window any query kind may span. */ +/** Widest window any query kind may span, timeseries aside. */ export const MAX_QUERY_RANGE_SECONDS = 60 * 60 * 24 * 31 +/** + * Timeseries read the 365-day hourly rollups, and the service pages offer presets up to 12 months + * (a calendar year, a leap day and a DST hour included). Raw tables keep 30 days, which bounds the rest. + */ +export const MAX_TIMESERIES_RANGE_SECONDS = 60 * 60 * 24 * 367 + /** * List queries (recent traces/logs tiles) scan raw rows rather than a rollup, * so they carry a much tighter ceiling than charts. @@ -78,7 +84,7 @@ export function maxRangeSecondsForKind(kind: QueryRangeKind): number { case "breakdown": return MAX_BREAKDOWN_RANGE_SECONDS case "timeseries": - return MAX_QUERY_RANGE_SECONDS + return MAX_TIMESERIES_RANGE_SECONDS } } diff --git a/packages/query-engine/src/runtime/query-engine.ts b/packages/query-engine/src/runtime/query-engine.ts index 539662827d..e910b6633e 100644 --- a/packages/query-engine/src/runtime/query-engine.ts +++ b/packages/query-engine/src/runtime/query-engine.ts @@ -58,6 +58,7 @@ import { MAX_LIST_RANGE_SECONDS, MAX_QUERY_RANGE_SECONDS, MAX_TIMESERIES_POINTS, + MAX_TIMESERIES_RANGE_SECONDS, MAX_UNFILTERED_BREAKDOWN_RANGE_SECONDS, formatRangeSeconds, } from "../limits" @@ -108,6 +109,7 @@ export { MAX_LIST_RANGE_SECONDS, MAX_QUERY_RANGE_SECONDS, MAX_TIMESERIES_POINTS, + MAX_TIMESERIES_RANGE_SECONDS, MAX_UNFILTERED_BREAKDOWN_RANGE_SECONDS, formatRangeSeconds, maxRangeSecondsForKind, @@ -489,10 +491,13 @@ const normalizeBucket = (bucket: string | Date | DateTime.Utc): string => { return raw } -const validateTimeRange = Effect.fn("QueryEngineService.validateTimeRange")(function* (request: { - readonly startTime: string - readonly endTime: string -}): Effect.fn.Return { +const validateTimeRange = Effect.fn("QueryEngineService.validateTimeRange")(function* ( + request: { + readonly startTime: string + readonly endTime: string + }, + maxRangeSeconds: number = MAX_QUERY_RANGE_SECONDS, +): Effect.fn.Return { const startMs = toEpochMs(request.startTime) const endMs = toEpochMs(request.endTime) @@ -511,10 +516,10 @@ const validateTimeRange = Effect.fn("QueryEngineService.validateTimeRange")(func } const rangeSeconds = (endMs - startMs) / 1000 - if (rangeSeconds > MAX_QUERY_RANGE_SECONDS) { + if (rangeSeconds > maxRangeSeconds) { return yield* new QueryEngineValidationError({ message: "Time range too large", - details: [`Maximum supported range is ${formatRangeSeconds(MAX_QUERY_RANGE_SECONDS)}`], + details: [`Maximum supported range is ${formatRangeSeconds(maxRangeSeconds)}`], }) } @@ -840,10 +845,16 @@ function groupAllMetricsTimeSeriesRows< })) } +/** Timeseries and the services facets read the 365-day rollups; everything else gets the general ceiling. */ +const executeRangeCeiling = (query: QuerySpec): number => + query.kind === "timeseries" || (query.kind === "facets" && query.source === "services") + ? MAX_TIMESERIES_RANGE_SECONDS + : MAX_QUERY_RANGE_SECONDS + const validateExecute = Effect.fn("QueryEngineService.validateExecute")(function* ( request: QueryEngineExecuteRequest, ): Effect.fn.Return { - const range = yield* validateTimeRange(request) + const range = yield* validateTimeRange(request, executeRangeCeiling(request.query)) yield* validateTraceAttributeFilters(request.query) yield* validateMetricsAttributeFilters(request.query) yield* validateProductEventsAttributeGrouping(request.query)