Skip to content

Commit 7c6cbec

Browse files
committed
fix(webapp): hold queue metric windows inside the plan query period
The queue-metric queries that go straight to ClickHouse (the queues list table and the concurrency-keys endpoint) never applied the org's query-period limit, so a hand-typed `?period=` could read further back than the plan allows. Every query through executeQuery is already clipped this way; both of these now clip with the same limit, capped at the 30 day retention. A remembered period longer than the plan allows is clamped to the plan maximum, and the picker now renders the resolved window rather than the raw search param, so the label can no longer disagree with the data on screen. The plan cap is resolved once per load and handed to the page, replacing the copy each route derived from the client-side subscription.
1 parent bd74dd3 commit 7c6cbec

5 files changed

Lines changed: 87 additions & 27 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { getLimit } from "~/services/platform.v3.server";
2+
import { QUEUE_METRICS_RETENTION_DAYS } from "./queueMetricsPeriod";
3+
4+
/**
5+
* The furthest back this org can query queue metrics: their plan's query period, capped at the
6+
* 30 day retention. Same limit `executeQuery` enforces, so the queue-metric queries that bypass it
7+
* and go straight to ClickHouse stay in step with the ones that don't.
8+
*/
9+
export async function queueMetricsMaxPeriodDays(organizationId: string): Promise<number> {
10+
const planPeriodDays = await getLimit(
11+
organizationId,
12+
"queryPeriodDays",
13+
QUEUE_METRICS_RETENTION_DAYS
14+
);
15+
return Math.min(planPeriodDays, QUEUE_METRICS_RETENTION_DAYS);
16+
}

apps/webapp/app/components/queues/queueMetricsPeriod.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
1919
const PERIOD_PATTERN = /^\d{1,4}[mhd]$/;
2020

2121
/** Queue metrics are retained for 30 days, so a longer window can only ever render empty. */
22-
const MAX_PERIOD_MS = 30 * 24 * 60 * 60 * 1000;
22+
export const QUEUE_METRICS_RETENTION_DAYS = 30;
23+
24+
const DAY_MS = 24 * 60 * 60 * 1000;
25+
const MAX_PERIOD_MS = QUEUE_METRICS_RETENTION_DAYS * DAY_MS;
2326

2427
function isPeriod(value: string | undefined | null): value is string {
2528
if (typeof value !== "string" || !PERIOD_PATTERN.test(value)) return false;
@@ -78,3 +81,27 @@ export function resolveQueueMetricsPeriod({
7881
if (from || to) return null;
7982
return defaultPeriod;
8083
}
84+
85+
/**
86+
* Hold a period inside a day budget (the org's plan query period). A remembered period longer than
87+
* the plan allows becomes the plan's maximum, so the picker shows the window the data covers.
88+
*/
89+
export function clampQueueMetricsPeriod(period: string, maxPeriodDays: number): string {
90+
const ms = parse(period);
91+
if (typeof ms === "number" && ms > 0 && ms <= maxPeriodDays * DAY_MS) return period;
92+
return `${maxPeriodDays}d`;
93+
}
94+
95+
/**
96+
* Pull a window forward to the earliest time the org's plan can query, the same clip `executeQuery`
97+
* applies to every metric query. Queue-metric queries that go straight to ClickHouse (the queues
98+
* list table, the concurrency-keys endpoint) have to apply it themselves, otherwise a hand-typed
99+
* `?period=` reaches further back than the plan allows.
100+
*/
101+
export function clipQueueMetricsWindow(
102+
window: { from: Date; to: Date },
103+
maxPeriodDays: number
104+
): { from: Date; to: Date } {
105+
const earliest = new Date(Date.now() - maxPeriodDays * DAY_MS);
106+
return { from: window.from < earliest ? earliest : window.from, to: window.to };
107+
}

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,13 @@ import { canAccessQueueMetricsUi } from "~/v3/canAccessQueueMetricsUi.server";
104104
import { QueueAllocationPresenter } from "~/presenters/v3/QueueAllocationPresenter.server";
105105
import {
106106
QUEUE_METRICS_DEFAULT_PERIOD,
107+
clampQueueMetricsPeriod,
108+
clipQueueMetricsWindow,
107109
queueMetricsPeriodFromRequest,
108110
resolveQueueMetricsPeriod,
109111
useRememberQueueMetricsPeriod,
110112
} from "~/components/queues/queueMetricsPeriod";
113+
import { queueMetricsMaxPeriodDays } from "~/components/queues/queueMetricsPeriod.server";
111114

112115
const SearchParamsSchema = z.object({
113116
query: z.string().optional(),
@@ -147,8 +150,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
147150
Object.fromEntries(url.searchParams)
148151
);
149152

150-
const defaultPeriod = queueMetricsPeriodFromRequest(request);
151-
152153
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
153154
if (!project) {
154155
throw new Response(undefined, {
@@ -169,6 +170,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
169170
// no metrics query fires.
170171
const queueMetricsUiEnabled = await canAccessQueueMetricsUi({ userId, organizationSlug });
171172

173+
const maxPeriodDays = await queueMetricsMaxPeriodDays(environment.organizationId);
174+
const defaultPeriod = clampQueueMetricsPeriod(
175+
queueMetricsPeriodFromRequest(request),
176+
maxPeriodDays
177+
);
178+
172179
try {
173180
const queueListPresenter = new QueueListPresenter();
174181
const queues = await queueListPresenter.call({
@@ -200,12 +207,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
200207
const queueNames = queues.queues.map((q) =>
201208
q.type === "task" ? `task/${q.name}` : q.name
202209
);
203-
const timeRange = timeFilterFromTo({
204-
period: resolveQueueMetricsPeriod({ period, from, to, defaultPeriod }) ?? undefined,
205-
from: parseFiniteInt(from),
206-
to: parseFiniteInt(to),
207-
defaultPeriod,
208-
});
210+
const timeRange = clipQueueMetricsWindow(
211+
timeFilterFromTo({
212+
period: resolveQueueMetricsPeriod({ period, from, to, defaultPeriod }) ?? undefined,
213+
from: parseFiniteInt(from),
214+
to: parseFiniteInt(to),
215+
defaultPeriod,
216+
}),
217+
maxPeriodDays
218+
);
209219
const queueMetrics =
210220
queueNames.length > 0
211221
? await presenter.getQueueListMetrics({
@@ -246,6 +256,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
246256
allocation,
247257
queueMetricsUiEnabled,
248258
defaultPeriod,
259+
maxPeriodDays,
249260
});
250261
} catch (error) {
251262
console.error(error);
@@ -370,6 +381,7 @@ function QueuesWithMetricsView() {
370381
metrics,
371382
allocation,
372383
defaultPeriod,
384+
maxPeriodDays,
373385
} = useTypedLoaderData<typeof loader>();
374386

375387
const metricsByQueue = metrics?.byQueue ?? {};
@@ -385,10 +397,6 @@ function QueuesWithMetricsView() {
385397
const project = useProject();
386398
const env = useEnvironment();
387399
const plan = useCurrentPlan();
388-
// Queue metrics are retained for 30 days in ClickHouse, so cap the picker there even for
389-
// plans whose query-period limit was raised above it — a longer window would render empty.
390-
const planPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number;
391-
const maxPeriodDays = Math.min(planPeriodDays ?? 30, 30);
392400

393401
// The header tiles fetch client-side with the same period/from/to the TimeFilter writes.
394402
const { value } = useSearchParams();
@@ -487,6 +495,7 @@ function QueuesWithMetricsView() {
487495
</div>
488496
<div className="flex items-center gap-1.5">
489497
<TimeFilter
498+
period={timeRange.period ?? undefined}
490499
defaultPeriod={defaultPeriod}
491500
labelName="Period"
492501
maxPeriodDays={maxPeriodDays}

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@ import type {
5454
ConcurrencyKeyRow,
5555
ConcurrencyKeysResponse,
5656
} from "~/routes/resources.queues.concurrency-keys";
57-
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
5857
import { canAccessQueueMetricsUi } from "~/v3/canAccessQueueMetricsUi.server";
5958
import { requireUserId } from "~/services/session.server";
6059
import { docsPath, EnvironmentParamSchema, v3RunsPath } from "~/utils/pathBuilder";
@@ -67,10 +66,12 @@ import {
6766
QueuePauseResumeButton,
6867
} from "~/components/queues/QueueControls";
6968
import {
69+
clampQueueMetricsPeriod,
7070
queueMetricsPeriodFromRequest,
7171
resolveQueueMetricsPeriod,
7272
useRememberQueueMetricsPeriod,
7373
} from "~/components/queues/queueMetricsPeriod";
74+
import { queueMetricsMaxPeriodDays } from "~/components/queues/queueMetricsPeriod.server";
7475
import { LinkButton } from "~/components/primitives/Buttons";
7576
import { RunsIcon } from "~/assets/icons/RunsIcon";
7677
import { InfoPanel } from "~/components/primitives/InfoPanel";
@@ -110,6 +111,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
110111
const queue = retrieve.queue;
111112
const fullName = queue.type === "task" ? `task/${queue.name}` : queue.name;
112113

114+
const maxPeriodDays = await queueMetricsMaxPeriodDays(environment.organizationId);
115+
113116
const [ckBreakdown, oldestQueuedAt] = await Promise.all([
114117
engine.concurrencyKeyBreakdown(environment, fullName, { limit: CK_LIVE_LIMIT }),
115118
// Enqueue time of the oldest run still waiting in the queue right now (any queue, keyed or
@@ -138,7 +141,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
138141
oldestQueuedAt: oldestQueuedAt ?? null,
139142
loadedAt: Date.now(),
140143
backPath: url.pathname.replace(/\/[^/]+$/, ""),
141-
defaultPeriod: queueMetricsPeriodFromRequest(request),
144+
defaultPeriod: clampQueueMetricsPeriod(queueMetricsPeriodFromRequest(request), maxPeriodDays),
145+
maxPeriodDays,
142146
ids: {
143147
organizationId: environment.organizationId,
144148
projectId: environment.projectId,
@@ -216,12 +220,8 @@ export default function Page() {
216220
backPath,
217221
ids,
218222
defaultPeriod,
223+
maxPeriodDays,
219224
} = useTypedLoaderData<typeof loader>();
220-
const plan = useCurrentPlan();
221-
// Queue metrics are retained for 30 days in ClickHouse, so cap the picker there even for
222-
// plans whose query-period limit was raised above it — a longer window would render empty.
223-
const planPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number;
224-
const maxPeriodDays = Math.min(planPeriodDays ?? 30, 30);
225225

226226
const { value, replace } = useSearchParams();
227227
const timeRange: TimeRangeParams = {
@@ -295,6 +295,7 @@ export default function Page() {
295295
/>
296296
) : null}
297297
<TimeFilter
298+
period={timeRange.period ?? undefined}
298299
defaultPeriod={defaultPeriod}
299300
labelName="Period"
300301
maxPeriodDays={maxPeriodDays}

apps/webapp/app/routes/resources.queues.concurrency-keys.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
22
import { z } from "zod";
33
import { timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
4-
import { QUEUE_METRICS_DEFAULT_PERIOD } from "~/components/queues/queueMetricsPeriod";
4+
import {
5+
QUEUE_METRICS_DEFAULT_PERIOD,
6+
clipQueueMetricsWindow,
7+
} from "~/components/queues/queueMetricsPeriod";
8+
import { queueMetricsMaxPeriodDays } from "~/components/queues/queueMetricsPeriod.server";
59
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
610
import { findEnvironmentById, hasAccessToEnvironment } from "~/models/runtimeEnvironment.server";
711
import { requireUserId } from "~/services/session.server";
@@ -110,12 +114,15 @@ export const action = async ({ request }: ActionFunctionArgs) => {
110114
return json<ConcurrencyKeysResponse>({ success: false, error: "Not found" }, { status: 404 });
111115
}
112116

113-
const range = timeFilterFromTo({
114-
period: period ?? undefined,
115-
from: from ?? undefined,
116-
to: to ?? undefined,
117-
defaultPeriod: DEFAULT_PERIOD,
118-
});
117+
const range = clipQueueMetricsWindow(
118+
timeFilterFromTo({
119+
period: period ?? undefined,
120+
from: from ?? undefined,
121+
to: to ?? undefined,
122+
defaultPeriod: DEFAULT_PERIOD,
123+
}),
124+
await queueMetricsMaxPeriodDays(organizationId)
125+
);
119126
const startTime = formatClickhouseDateTime(new Date(floorToMinute(range.from.getTime())));
120127
const endTime = formatClickhouseDateTime(new Date(ceilToMinute(range.to.getTime())));
121128

0 commit comments

Comments
 (0)