Skip to content

Commit 572f394

Browse files
committed
fix(webapp,clickhouse): treat SQL the caller wrote as their mistake, not ours
The quota list was built on the wrong axis. It classified by resource limit, but production shows the volume is in plain SQL mistakes: one error group alone, a missing GROUP BY on the public query API, accounts for over a million events across hundreds of users, and it stayed at error level because its code is not a limit. ClickHouse rejections that mean the SQL itself is wrong now log at warn, but only when the caller wrote that SQL. The same rejection on TRQL we generated is our bug and keeps alerting, so the decision is gated on a userAuthoredQuery flag set by the public query API and the query editor. Generated dashboard tiles deliberately do not set it. Drops QUERY_WAS_CANCELLED from the limit list. It was there on the reasoning that client disconnects would be noisy; it has not fired once in the last fourteen days, so it was speculation rather than evidence.
1 parent 24a62f4 commit 572f394

7 files changed

Lines changed: 146 additions & 20 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
183183
const queryResult = await executeQuery({
184184
name: "query-page",
185185
query,
186+
userAuthoredQuery: true,
186187
scope,
187188
organizationId: project.organizationId,
188189
projectId: project.id,

apps/webapp/app/routes/api.v1.query.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const { action, loader } = createActionApiRoute(
5454
const queryResult = await executeQuery({
5555
name: "api-query",
5656
query,
57+
userAuthoredQuery: true,
5758
scope: scope as QueryScope,
5859
organizationId: env.organization.id,
5960
projectId: env.project.id,

apps/webapp/app/services/queryService.server.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,13 @@ export type ExecuteQueryOptions<TOut extends z.ZodSchema> = Omit<
100100
};
101101
/** Custom per-org concurrency limit (overrides default) */
102102
customOrgConcurrencyLimit?: number;
103+
/**
104+
* Set when the caller wrote `query` themselves, as on the public query API and
105+
* the query editor. ClickHouse rejecting their SQL is then their mistake, so it
106+
* is logged as a warning instead of raising an alert. Leave unset for TRQL we
107+
* generate, where the same rejection is a bug worth alerting on.
108+
*/
109+
userAuthoredQuery?: boolean;
103110
};
104111

105112
/**

internal-packages/clickhouse/src/client/client.ts

Lines changed: 76 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -179,10 +179,15 @@ export class ClickhouseClient implements ClickhouseReader, ClickhouseWriter {
179179
queryId,
180180
};
181181

182-
if (isClickhouseQuotaError(clickhouseError)) {
183-
this.logger.warn("Query exceeded a ClickHouse limit", errorLogFields);
184-
} else {
185-
this.logger.error("Error querying clickhouse", errorLogFields);
182+
switch (classifyClickhouseError(clickhouseError, false)) {
183+
case "quota":
184+
this.logger.warn("Query exceeded a ClickHouse limit", errorLogFields);
185+
break;
186+
case "invalid-sql":
187+
this.logger.warn("ClickHouse rejected an invalid query", errorLogFields);
188+
break;
189+
default:
190+
this.logger.error("Error querying clickhouse", errorLogFields);
186191
}
187192

188193
recordClickhouseError(span, clickhouseError);
@@ -271,6 +276,11 @@ export class ClickhouseClient implements ClickhouseReader, ClickhouseWriter {
271276
* record what produced the SQL, e.g. the TSQL a caller actually wrote.
272277
*/
273278
logFields?: Record<string, unknown>;
279+
/**
280+
* Set when the SQL originates from whoever made the request rather than
281+
* from us. Invalid-SQL rejections are then their mistake, not a bug.
282+
*/
283+
userAuthoredQuery?: boolean;
274284
}): ClickhouseQueryWithStatsFunction<z.input<TIn>, z.output<TOut>> {
275285
return async (params, options) => {
276286
const queryId = randomUUID();
@@ -340,10 +350,15 @@ export class ClickhouseClient implements ClickhouseReader, ClickhouseWriter {
340350
queryId,
341351
};
342352

343-
if (isClickhouseQuotaError(clickhouseError)) {
344-
this.logger.warn("Query exceeded a ClickHouse limit", errorLogFields);
345-
} else {
346-
this.logger.error("Error querying clickhouse", errorLogFields);
353+
switch (classifyClickhouseError(clickhouseError, req.userAuthoredQuery)) {
354+
case "quota":
355+
this.logger.warn("Query exceeded a ClickHouse limit", errorLogFields);
356+
break;
357+
case "invalid-sql":
358+
this.logger.warn("ClickHouse rejected an invalid query", errorLogFields);
359+
break;
360+
default:
361+
this.logger.error("Error querying clickhouse", errorLogFields);
347362
}
348363

349364
recordClickhouseError(span, clickhouseError);
@@ -479,10 +494,15 @@ export class ClickhouseClient implements ClickhouseReader, ClickhouseWriter {
479494
queryId,
480495
};
481496

482-
if (isClickhouseQuotaError(clickhouseError)) {
483-
this.logger.warn("Query exceeded a ClickHouse limit", errorLogFields);
484-
} else {
485-
this.logger.error("Error querying clickhouse", errorLogFields);
497+
switch (classifyClickhouseError(clickhouseError, false)) {
498+
case "quota":
499+
this.logger.warn("Query exceeded a ClickHouse limit", errorLogFields);
500+
break;
501+
case "invalid-sql":
502+
this.logger.warn("ClickHouse rejected an invalid query", errorLogFields);
503+
break;
504+
default:
505+
this.logger.error("Error querying clickhouse", errorLogFields);
486506
}
487507

488508
recordClickhouseError(span, clickhouseError);
@@ -631,7 +651,7 @@ export class ClickhouseClient implements ClickhouseReader, ClickhouseWriter {
631651
queryId,
632652
};
633653

634-
if (error instanceof Error && isClickhouseQuotaError(error)) {
654+
if (error instanceof Error && classifyClickhouseError(error, false) === "quota") {
635655
self.logger.warn("Streamed query exceeded a ClickHouse limit", errorLogFields);
636656
} else {
637657
self.logger.error("Error streaming clickhouse", errorLogFields);
@@ -1043,15 +1063,51 @@ const CLICKHOUSE_QUOTA_ERROR_TYPES = new Set([
10431063
"TOO_MANY_ROWS",
10441064
"TOO_MANY_BYTES",
10451065
"TOO_MANY_ROWS_OR_BYTES",
1046-
"QUERY_WAS_CANCELLED",
10471066
]);
10481067

1049-
function isClickhouseQuotaError(error: Error): boolean {
1050-
return (
1051-
error instanceof ClickHouseError &&
1052-
error.type !== undefined &&
1053-
CLICKHOUSE_QUOTA_ERROR_TYPES.has(error.type)
1054-
);
1068+
/**
1069+
* ClickHouse error types that mean the SQL itself is wrong. Only treated as the
1070+
* caller's fault when the query was written by the caller — the same error on a
1071+
* query we generated is our bug and has to keep alerting.
1072+
*/
1073+
const CLICKHOUSE_INVALID_SQL_ERROR_TYPES = new Set([
1074+
"NOT_AN_AGGREGATE",
1075+
"ILLEGAL_AGGREGATION",
1076+
"UNKNOWN_IDENTIFIER",
1077+
"UNKNOWN_FUNCTION",
1078+
"UNKNOWN_TABLE",
1079+
"AMBIGUOUS_COLUMN_NAME",
1080+
"MULTIPLE_EXPRESSIONS_FOR_ALIAS",
1081+
"SYNTAX_ERROR",
1082+
"BAD_ARGUMENTS",
1083+
"TYPE_MISMATCH",
1084+
"NO_COMMON_TYPE",
1085+
"ILLEGAL_TYPE_OF_ARGUMENT",
1086+
"ILLEGAL_COLUMN",
1087+
"CANNOT_CONVERT_TYPE",
1088+
"CANNOT_PARSE_TEXT",
1089+
"CANNOT_PARSE_NUMBER",
1090+
"CANNOT_PARSE_DATE",
1091+
"CANNOT_PARSE_DATETIME",
1092+
"CANNOT_PARSE_INPUT_ASSERTION_FAILED",
1093+
]);
1094+
1095+
type ClickhouseErrorCategory = "quota" | "invalid-sql" | "fault";
1096+
1097+
function classifyClickhouseError(
1098+
error: Error,
1099+
userAuthoredQuery: boolean | undefined
1100+
): ClickhouseErrorCategory {
1101+
if (!(error instanceof ClickHouseError) || error.type === undefined) {
1102+
return "fault";
1103+
}
1104+
if (CLICKHOUSE_QUOTA_ERROR_TYPES.has(error.type)) {
1105+
return "quota";
1106+
}
1107+
if (userAuthoredQuery && CLICKHOUSE_INVALID_SQL_ERROR_TYPES.has(error.type)) {
1108+
return "invalid-sql";
1109+
}
1110+
return "fault";
10551111
}
10561112

10571113
function recordClickhouseError(span: Span, error: Error): void {

internal-packages/clickhouse/src/client/tsql.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,11 @@ export interface ExecuteTSQLOptions<TOut extends z.ZodSchema> {
114114
* (counters zero-fill, gauges carry forward). Off by default.
115115
*/
116116
fillGaps?: boolean;
117+
/**
118+
* Set when `query` was written by whoever made the request rather than by us.
119+
* A rejection of their SQL is then their mistake, not a bug on our side.
120+
*/
121+
userAuthoredQuery?: boolean;
117122
}
118123

119124
/**
@@ -215,6 +220,7 @@ export async function executeTSQL<TOut extends z.ZodSchema>(
215220
schema: isExplain ? z.object({ explain: z.string() }) : options.schema,
216221
settings: options.clickhouseSettings,
217222
logFields: { tsql: options.query },
223+
userAuthoredQuery: options.userAuthoredQuery,
218224
});
219225

220226
const [error, result] = await queryFn(params);
@@ -249,6 +255,7 @@ export async function executeTSQL<TOut extends z.ZodSchema>(
249255
schema: z.object({ explain: z.string() }),
250256
settings: options.clickhouseSettings,
251257
logFields: { tsql: options.query },
258+
userAuthoredQuery: options.userAuthoredQuery,
252259
});
253260

254261
const [additionalError, additionalResult] = await additionalQueryFn(params);

internal-packages/clickhouse/src/client/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,11 @@ export interface ClickhouseReader {
140140
* record what produced the SQL, e.g. the TSQL a caller actually wrote.
141141
*/
142142
logFields?: Record<string, unknown>;
143+
/**
144+
* Set when the SQL originates from whoever made the request rather than
145+
* from us. Invalid-SQL rejections are then their mistake, not a bug.
146+
*/
147+
userAuthoredQuery?: boolean;
143148
}): ClickhouseQueryWithStatsFunction<z.input<TIn>, z.output<TOut>>;
144149

145150
queryFast<TOut extends Record<string, any>, TParams extends Record<string, any>>(req: {

internal-packages/clickhouse/src/tsql.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1694,6 +1694,55 @@ describe("TSQL Error Log Levels", () => {
16941694
}
16951695
);
16961696

1697+
clickhouseTest(
1698+
"logs invalid caller-written SQL as a warning",
1699+
async ({ clickhouseContainer }) => {
1700+
const client = new ClickhouseClient({
1701+
name: "test",
1702+
url: clickhouseContainer.getConnectionUrl(),
1703+
});
1704+
1705+
const [error] = await executeTSQL(client, {
1706+
name: "test-user-authored-invalid",
1707+
query: "SELECT status, sum(is_test) AS n FROM task_runs",
1708+
schema: z.object({ status: z.string(), n: z.number() }),
1709+
enforcedWhereClause: {
1710+
organization_id: { op: "eq", value: "org_tenant1" },
1711+
},
1712+
tableSchema: [taskRunsSchema],
1713+
userAuthoredQuery: true,
1714+
});
1715+
1716+
expect(error).not.toBeNull();
1717+
expect(logged(warnSpy)).toContain("ClickHouse rejected an invalid query");
1718+
expect(logged(errorSpy)).not.toContain("Error querying clickhouse");
1719+
}
1720+
);
1721+
1722+
clickhouseTest(
1723+
"keeps invalid SQL we generated at error level",
1724+
async ({ clickhouseContainer }) => {
1725+
const client = new ClickhouseClient({
1726+
name: "test",
1727+
url: clickhouseContainer.getConnectionUrl(),
1728+
});
1729+
1730+
const [error] = await executeTSQL(client, {
1731+
name: "test-internal-invalid",
1732+
query: "SELECT status, sum(is_test) AS n FROM task_runs",
1733+
schema: z.object({ status: z.string(), n: z.number() }),
1734+
enforcedWhereClause: {
1735+
organization_id: { op: "eq", value: "org_tenant1" },
1736+
},
1737+
tableSchema: [taskRunsSchema],
1738+
});
1739+
1740+
expect(error).not.toBeNull();
1741+
expect(logged(errorSpy)).toContain("Error querying clickhouse");
1742+
expect(logged(warnSpy)).not.toContain("ClickHouse rejected an invalid query");
1743+
}
1744+
);
1745+
16971746
clickhouseTest("logs a ClickHouse limit breach as a warning", async ({ clickhouseContainer }) => {
16981747
const client = new ClickhouseClient({
16991748
name: "test",

0 commit comments

Comments
 (0)