Skip to content

Commit e527e6c

Browse files
committed
feat(analytics): query timeout limits based on plan
We update the schema to enforce a max of the growth plan limit.
1 parent 6a48fde commit e527e6c

2 files changed

Lines changed: 94 additions & 7 deletions

File tree

apps/backend/src/app/api/latest/internal/analytics/query/route.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import { getClickhouseExternalClient } from "@/lib/clickhouse";
2+
import { getBillingTeamId } from "@/lib/plan-entitlements";
23
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
4+
import { getStackServerApp } from "@/stack";
35
import { KnownErrors } from "@stackframe/stack-shared";
6+
import { ITEM_IDS, PLAN_LIMITS } from "@stackframe/stack-shared/dist/plans";
47
import { adaptSchema, adminAuthTypeSchema, jsonSchema, yupBoolean, yupMixed, yupNumber, yupObject, yupRecord, yupString } from "@stackframe/stack-shared/dist/schema-fields";
58
import { getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
69
import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
710
import { Result } from "@stackframe/stack-shared/dist/utils/results";
811
import { randomUUID } from "crypto";
912

10-
const MAX_QUERY_TIMEOUT_MS = 120_000;
13+
const MAX_QUERY_TIMEOUT_MS = Math.max(...Object.values(PLAN_LIMITS).map(p => p.analyticsTimeoutSeconds)) * 1000;
1114
const DEFAULT_QUERY_TIMEOUT_MS = 10_000;
1215

1316
export const POST = createSmartRouteHandler({
@@ -36,6 +39,16 @@ export const POST = createSmartRouteHandler({
3639
if (body.include_all_branches) {
3740
throw new StackAssertionError("include_all_branches is not supported yet");
3841
}
42+
43+
let effectiveTimeoutMs = body.timeout_ms;
44+
const billingTeamId = getBillingTeamId(auth.tenancy.project);
45+
if (billingTeamId != null) {
46+
const app = getStackServerApp();
47+
const timeoutItem = await app.getItem({ itemId: ITEM_IDS.analyticsTimeoutSeconds, teamId: billingTeamId });
48+
const maxAllowedMs = timeoutItem.quantity * 1000;
49+
effectiveTimeoutMs = Math.min(body.timeout_ms, maxAllowedMs);
50+
}
51+
3952
const client = getClickhouseExternalClient();
4053
const queryId = `${auth.tenancy.project.id}:${auth.tenancy.branchId}:${randomUUID()}`;
4154
const resultSet = await Result.fromPromise(client.query({
@@ -45,7 +58,7 @@ export const POST = createSmartRouteHandler({
4558
clickhouse_settings: {
4659
SQL_project_id: auth.tenancy.project.id,
4760
SQL_branch_id: auth.tenancy.branchId,
48-
max_execution_time: body.timeout_ms / 1000,
61+
max_execution_time: effectiveTimeoutMs / 1000,
4962
readonly: "1",
5063
allow_ddl: 0,
5164
max_result_rows: MAX_RESULT_ROWS.toString(),

apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import { PLAN_LIMITS, PlanId } from "@stackframe/stack-shared/dist/plans";
12
import { wait } from "@stackframe/stack-shared/dist/utils/promises";
23
import { deindent } from "@stackframe/stack-shared/dist/utils/strings";
34
import { it } from "../../../../helpers";
4-
import { Project, User, niceBackendFetch } from "../../../backend-helpers";
5+
import { backendContext, InternalProjectKeys, Project, User, niceBackendFetch } from "../../../backend-helpers";
56

67
async function runQuery(body: { query: string, params?: Record<string, string>, timeout_ms?: number }) {
78
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
@@ -15,6 +16,33 @@ async function runQuery(body: { query: string, params?: Record<string, string>,
1516
return response;
1617
}
1718

19+
async function runQueryWithPlan(planId: PlanId, body: { query: string, params?: Record<string, string>, timeout_ms?: number }) {
20+
const { createProjectResponse, adminAccessToken } = await Project.createAndSwitch({ config: { magic_link_enabled: true } });
21+
const ownerTeamId = createProjectResponse.body.owner_team_id;
22+
23+
if (planId !== "free") {
24+
const savedKeys = backendContext.value.projectKeys;
25+
backendContext.set({ projectKeys: InternalProjectKeys });
26+
const grantResponse = await niceBackendFetch(`/api/v1/payments/products/team/${ownerTeamId}`, {
27+
method: "POST",
28+
accessType: "server",
29+
body: { product_id: planId },
30+
});
31+
if (grantResponse.status !== 200) {
32+
throw new Error(`Failed to grant plan '${planId}' to team '${ownerTeamId}': ${JSON.stringify(grantResponse.body)}`);
33+
}
34+
backendContext.set({ projectKeys: savedKeys });
35+
}
36+
37+
const response = await niceBackendFetch("/api/v1/internal/analytics/query", {
38+
method: "POST",
39+
accessType: "admin",
40+
body,
41+
});
42+
43+
return response;
44+
}
45+
1846
type ExpectLike = ((value: unknown) => { toEqual: (value: unknown) => void }) & {
1947
any: (constructor: unknown) => unknown,
2048
};
@@ -154,10 +182,11 @@ it("can execute a query with custom timeout", async ({ expect }) => {
154182
`);
155183
});
156184

157-
it("rejects timeouts longer than 2 minutes", async ({ expect }) => {
185+
it("rejects timeouts longer than max plan limit", async ({ expect }) => {
186+
const maxSchemaMs = Math.max(...Object.values(PLAN_LIMITS).map(p => p.analyticsTimeoutSeconds)) * 1000;
158187
const response = await runQuery({
159188
query: "SELECT 1 as value",
160-
timeout_ms: 120_001,
189+
timeout_ms: maxSchemaMs + 1,
161190
});
162191

163192
expect(stripQueryId(response, expect)).toMatchInlineSnapshot(`
@@ -168,12 +197,12 @@ it("rejects timeouts longer than 2 minutes", async ({ expect }) => {
168197
"details": {
169198
"message": deindent\`
170199
Request validation failed on POST /api/v1/internal/analytics/query:
171-
- body.timeout_ms must be less than or equal to 120000
200+
- body.timeout_ms must be less than or equal to ${maxSchemaMs}
172201
\`,
173202
},
174203
"error": deindent\`
175204
Request validation failed on POST /api/v1/internal/analytics/query:
176-
- body.timeout_ms must be less than or equal to 120000
205+
- body.timeout_ms must be less than or equal to ${maxSchemaMs}
177206
\`,
178207
},
179208
"headers": Headers {
@@ -1524,6 +1553,51 @@ it("does not allow input() function", async ({ expect }) => {
15241553
`);
15251554
});
15261555

1556+
it("clamps timeout to free plan limit", async ({ expect }) => {
1557+
const response = await runQueryWithPlan("free", {
1558+
query: "SELECT getSetting('max_execution_time') as max_execution_time",
1559+
timeout_ms: 120000,
1560+
});
1561+
1562+
expect(response.status).toBe(200);
1563+
const maxExecutionTime = Number((response.body?.result as any)?.[0]?.max_execution_time);
1564+
expect(maxExecutionTime).toBe(PLAN_LIMITS.free.analyticsTimeoutSeconds);
1565+
});
1566+
1567+
it("clamps timeout to team plan limit", async ({ expect }) => {
1568+
const response = await runQueryWithPlan("team", {
1569+
query: "SELECT getSetting('max_execution_time') as max_execution_time",
1570+
timeout_ms: 120000,
1571+
});
1572+
1573+
expect(response.status).toBe(200);
1574+
const maxExecutionTime = Number((response.body?.result as any)?.[0]?.max_execution_time);
1575+
expect(maxExecutionTime).toBe(PLAN_LIMITS.team.analyticsTimeoutSeconds);
1576+
});
1577+
1578+
it("clamps timeout to growth plan limit", async ({ expect }) => {
1579+
const maxSchemaMs = Math.max(...Object.values(PLAN_LIMITS).map(p => p.analyticsTimeoutSeconds)) * 1000;
1580+
const response = await runQueryWithPlan("growth", {
1581+
query: "SELECT getSetting('max_execution_time') as max_execution_time",
1582+
timeout_ms: maxSchemaMs,
1583+
});
1584+
1585+
expect(response.status).toBe(200);
1586+
const maxExecutionTime = Number((response.body?.result as any)?.[0]?.max_execution_time);
1587+
expect(maxExecutionTime).toBe(PLAN_LIMITS.growth.analyticsTimeoutSeconds);
1588+
});
1589+
1590+
it("does not clamp timeout below the plan limit", async ({ expect }) => {
1591+
const response = await runQueryWithPlan("team", {
1592+
query: "SELECT getSetting('max_execution_time') as max_execution_time",
1593+
timeout_ms: 5000,
1594+
});
1595+
1596+
expect(response.status).toBe(200);
1597+
const maxExecutionTime = Number((response.body?.result as any)?.[0]?.max_execution_time);
1598+
expect(maxExecutionTime).toBe(5);
1599+
});
1600+
15271601
it("does not allow numbers table function with large values", async ({ expect }) => {
15281602
const response = await runQuery({
15291603
query: "SELECT * FROM numbers(1000000000)",

0 commit comments

Comments
 (0)