-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.ts
More file actions
486 lines (456 loc) · 19.8 KB
/
Copy pathmain.ts
File metadata and controls
486 lines (456 loc) · 19.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
import * as core from "@actions/core";
import { QueryProcessResult, Runner } from "./runner.ts";
import { env } from "./env.ts";
import { log } from "./log.ts";
import { createServer } from "./server/http.ts";
import { Connectable } from "./sync/connectable.ts";
import { shutdownController } from "./shutdown.ts";
import {
buildQueries,
type CiRunResult,
classifyIngestFailure,
compareRuns,
fetchPreviousRun,
gateEligibleNewQueries,
postToSiteApi,
} from "./reporters/site-api.ts";
import { formatCost, queryPreview } from "./reporters/github/github.ts";
import { fetchPrChangedFiles } from "./gate/changed-files.ts";
import { evaluateTestPresence } from "./gate/test-presence.ts";
import { gateRegression } from "./gate/regression.ts";
import { gateNewQuery } from "./gate/new-query.ts";
import { evaluateGates } from "./gate/evaluate.ts";
import { tableGrowth } from "./gate/table-growth.ts";
import { gateSchemaChange } from "./gate/schema-change.ts";
import { resolveVerdict } from "./gate/policy.ts";
import { DEFAULT_CONFIG } from "./config.ts";
import { ApiClient } from "./remote/api-client.ts";
import { Remote } from "./remote/remote.ts";
import { watchEventLoopLag } from "./remote/event-loop-lag.ts";
import { ConnectionManager } from "./sync/connection-manager.ts";
import { PgbadgerSource } from "./sql/pgbadger.ts";
import { baselineNotFoundMessage } from "./reporters/baseline-notice.ts";
import type { RecentQuerySource } from "./sql/recent-query.ts";
import type { FullSchema, RepoPolicyConfig } from "@query-doctor/core";
async function runInCI(
targetPostgresUrl: Connectable,
sourcePostgresUrl: Connectable,
logPath: string | undefined,
maxCost?: number,
) {
const siteApiEndpoint = env.SITE_API_ENDPOINT;
const repo = env.GITHUB_REPOSITORY;
const branch =
process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || "";
const remoteDbManager = ConnectionManager.forRemoteDatabase()
const remote = new Remote(
targetPostgresUrl,
ConnectionManager.forLocalDatabase(),
remoteDbManager,
{ disableQueryLoader: true },
);
if (!env.TOKEN) {
throw new Error("CI mode cannot be run without a TOKEN variable provided")
}
const { api, dispose: disposeApi } = await ApiClient.connect(siteApiEndpoint, env.TOKEN, { kind: "ci", branch, sha: "" }, remote, (err) => {
log.warn(`API connection broken during CI run: ${err}`, "main");
});
// `Runner.build` triggers `remote.syncFrom`, which emits `schemaSynced` with
// the schema dumped from the source DB. CI doesn't wire up `hookUpApiReporter`
// (that's the persistent-server path), so capture the schema here and push it
// explicitly. We await the push before `disposeApi` so the short-lived CI
// process doesn't exit before the WS write flushes.
let syncedSchema: FullSchema | undefined;
const onSchemaSynced = (schema: FullSchema) => {
syncedSchema = schema;
};
remote.on("schemaSynced", onSchemaSynced);
try {
const config = repo
? await api.getRepoConfig(repo, branch).catch(
(err) => {
log.warn(`Failed to fetch repo config via RPC: ${err}. Using defaults`, "main");
return DEFAULT_CONFIG;
},
)
: DEFAULT_CONFIG;
// Cost against the project's stored production statistics when available, so
// CI numbers reflect real prod cardinality instead of synthetic assumptions.
// Scoped server-side to this connection's project; null when none is stored
// or the pull fails, in which case the runner falls back to synthetic stats.
const productionStats = await api.getProductionStats().catch((err) => {
log.warn(
`Failed to fetch production stats via RPC: ${err}. Falling back to synthetic stats`,
"main",
);
return null;
});
if (productionStats && productionStats.length > 0) {
log.info(
`Costing against ${productionStats.length} table(s) of stored production statistics`,
"main",
);
}
const source: RecentQuerySource = logPath
? new PgbadgerSource(logPath)
: remoteDbManager.getConnectorFor(sourcePostgresUrl);
const runner = await Runner.build({
targetPostgresUrl,
sourcePostgresUrl,
source,
maxCost,
ignoredQueryHashes: config.ignoredQueryHashes,
remote,
productionStats: productionStats ?? undefined,
});
let allResults: QueryProcessResult[];
let reportContext;
try {
log.info("main", "Running in CI mode. Skipping server creation");
const results = await runner.run(config);
allResults = results.allResults;
reportContext = results.reportContext;
} finally {
await runner.close();
}
const queries = buildQueries(allResults, config);
// POST to Site API first so we get the run ID (for baseline exclusion) and
// the unified CI-signal metadata for the PR comment.
let runResult: CiRunResult | null = null;
if (siteApiEndpoint) {
const outcome = await postToSiteApi(siteApiEndpoint, queries, reportContext.statisticsMode, reportContext.computedStats, syncedSchema);
if (outcome.ok) {
runResult = outcome.result;
} else {
// Ingestion failed: the run was computed but not saved. Without this it
// silently vanishes — no dashboard link, a degraded PR comment that
// looks like a normal empty result, and a green check. Surface it in the
// comment, and pick the Actions severity by who can act and whether it
// recovers (see classifyIngestFailure).
const kind = classifyIngestFailure(outcome.failure.status);
reportContext.ingestError = { kind, ...outcome.failure };
const statusText = outcome.failure.status
? ` (HTTP ${outcome.failure.status})`
: "";
const base = `Query Doctor could not record this run${statusText}; it was not saved to the dashboard.`;
if (kind === "auth") {
// The user's to fix and means CI isn't actually running — fail loudly.
core.setFailed(`${base} The project TOKEN is missing or invalid.`);
} else if (kind === "too_large") {
// The payload exceeded the API's size limit — our side to fix, not
// theirs, and re-running the same payload won't help. Loud but
// non-blocking unless opted in, same as a rejected run.
const msg = `${base} The submission was too large for the API to accept; re-running won't help.`;
if (env.FAIL_ON_INGEST_ERROR) core.setFailed(msg);
else core.error(msg);
} else if (kind === "rejected") {
// The API refused a computed run (likely analyzer/API skew) — our bug,
// not theirs. Red and loud, but don't block the PR unless opted in.
const msg = `${base} The API rejected the submission; re-running won't help.`;
if (env.FAIL_ON_INGEST_ERROR) core.setFailed(msg);
else core.error(msg);
} else {
// Transient (network/timeout/5xx) — recoverable, so warn and move on.
core.warning(`${base} Query Doctor was unreachable — re-run to retry.`);
}
}
}
const runId: string | null = runResult?.id ?? null;
// Run link and per-query links come straight from the API response. Both
// degrade gracefully: `url` is null and `queries` is empty for an unlinked repo.
if (runResult) {
reportContext.runUrl = runResult.url ?? undefined;
reportContext.runMetadata = runResult.metadata ?? undefined;
}
// Fetch previous run for comparison
let previousRun = null;
if (siteApiEndpoint && repo) {
const comparisonBranch =
config.comparisonBranch ?? process.env.GITHUB_BASE_REF ?? branch;
const result = await fetchPreviousRun(
siteApiEndpoint,
repo,
comparisonBranch,
runId ?? undefined,
);
reportContext.comparisonBranch = comparisonBranch;
if (result.kind === "found") {
previousRun = result.run;
} else if (result.kind === "not-found") {
log.info("main", baselineNotFoundMessage(comparisonBranch));
} else {
// Transient fetch failure after retries — flag it so the comment says
// "temporarily unavailable, re-run" rather than claiming there is no
// baseline (which would tell the user to add an already-present trigger).
reportContext.comparisonUnavailable = true;
log.warn(
"main",
`Failed to fetch baseline for branch "${comparisonBranch}" (${result.reason}). ` +
`Comparison will be skipped this run — re-run the check to retry.`,
);
}
}
if (previousRun) {
reportContext.comparison = await compareRuns(
queries,
previousRun,
config.regressionThreshold,
config.minimumCost,
config.acknowledgedQueryHashes,
);
}
// Test-presence gate (#3496): flag data-access changes that ship with no
// data-layer test. A diff heuristic, but capture overrides it (#3502): when
// the run captured new query surface, the change demonstrably ran against
// Postgres, so the "no related test" guess is dropped. Runs even on a
// brand-new PR with no baseline (then capture is empty and it's diff-only).
// Best-effort: a GitHub API hiccup must never sink the whole run.
if (env.GITHUB_TOKEN) {
try {
const changedFiles = await fetchPrChangedFiles(env.GITHUB_TOKEN);
if (changedFiles) {
const capture = reportContext.comparison
? { newQueryHashes: reportContext.comparison.newQueries.map((q) => q.hash) }
: undefined;
reportContext.testPresenceVerdict =
evaluateTestPresence(changedFiles, capture) ?? undefined;
}
} catch (err) {
log.warn(`Test-presence gate skipped: ${err}`, "main");
}
}
// Resolve the gate verdict's CI conclusion via the shared taxonomy (#3498)
// and policy engine (#3500) instead of a local severity. By default an
// unverified data-access change concludes `failure`, so it blocks the check —
// a "Warning" beside a green check is invisible to downstream reviewers
// (Site#3541). A repo can soften it to a non-blocking neutral (`warn`) or
// suppress it (`off`). Resolved before the report so `off` also drops the
// comment block, and so the comment can render the right framing.
if (reportContext.testPresenceVerdict) {
const policyConfig: RepoPolicyConfig =
("conditionPolicies" in config ? config.conditionPolicies : undefined) ??
{};
const resolved = resolveVerdict(
reportContext.testPresenceVerdict,
policyConfig,
);
if (resolved.surfaced) {
reportContext.testPresenceConclusion = resolved.conclusion;
} else {
reportContext.testPresenceVerdict = undefined;
}
}
// Resolve every gate condition *before* the report renders, so the comment can
// state its own check result. The check annotations below read the same roster,
// which is what stops the comment and the check disagreeing (ADR-0009).
{
const policyConfig: RepoPolicyConfig =
("conditionPolicies" in config ? config.conditionPolicies : undefined) ??
{};
const eligible = reportContext.comparison
? gateEligibleNewQueries(
reportContext.comparison.newQueries,
config.regressionThreshold,
config.acknowledgedQueryHashes,
config.minimumCost,
)
: [];
// computedStats lists every relation; statisticsMode carries the table
// names, so growth is reported per table rather than once per index.
const knownTables =
reportContext.statisticsMode.kind === "fromStatisticsExport"
? new Set(
reportContext.statisticsMode.stats.map((s) => s.tableName),
)
: undefined;
reportContext.tableGrowth = tableGrowth(
previousRun?.computedStats,
reportContext.computedStats,
knownTables,
);
reportContext.gates = evaluateGates(
{
regressedCount: reportContext.comparison?.regressed.length ?? 0,
newQueryCount: reportContext.comparison?.newQueries.length ?? 0,
indexedNewQueryCount: eligible.length,
// An approved migration is not drift the roll-up should count: a
// person has already validated it, so the condition is satisfied
// rather than softened.
schemaChanged:
reportContext.runMetadata?.schemaChange?.changed === true &&
reportContext.runMetadata.schemaChange.approved !== true,
untestedDataAccessFileCount:
reportContext.testPresenceVerdict?.dataAccessFiles.length ?? 0,
regressionThreshold: config.regressionThreshold,
minimumCost: config.minimumCost,
},
policyConfig,
);
}
console.log("Creating report...")
// Generate PR comment with comparison data
await runner.report(reportContext);
// Carry the verdict into the check itself: a blocking conclusion fails the
// run (red X), a softened one surfaces as a non-blocking annotation. Framed
// as "could not verify", never "your query is broken".
if (reportContext.testPresenceVerdict) {
const verdict = reportContext.testPresenceVerdict;
const files = verdict.dataAccessFiles
.map((f) =>
f.evidence
? ` - ${f.path} — matched ${f.evidence.rule} on line ${f.evidence.line}: ${f.evidence.matched}`
: ` - ${f.path}`,
)
.join("\n");
const message =
`${verdict.reason}\n\nNext step: ${verdict.nextStep}\n\n` +
`Changed data-access files with no related data-layer test:\n${files}`;
if (reportContext.testPresenceConclusion === "failure") {
core.setFailed(message);
} else {
core.warning(message);
}
}
// Schema-change gate (Site#3289). The API already computed the schema diff
// (`runMetadata.schemaChange`) and the comment renders it; this only decides
// the check. Block by default so a person validates the migration; a repo
// softens it to `warn`/`off` via the schema-drift policy.
const schemaGate = gateSchemaChange(
reportContext.runMetadata?.schemaChange,
"conditionPolicies" in config ? config.conditionPolicies : undefined,
);
if (schemaGate) {
if (schemaGate.conclusion === "failure") {
core.setFailed(schemaGate.message);
} else {
core.warning(schemaGate.message);
}
}
// Block PR if regressions exceed thresholds, or if a brand-new query ships
// with a high-confidence index recommendation (#3281). New queries have no
// baseline so they can never regress; the new-query gate catches the missing
// index at introduction, while it's still a one-line fix.
if (reportContext.comparison) {
const queryLinks = new Map(
(reportContext.runMetadata?.queries ?? []).map((q) => [q.hash, q.link]),
);
const linkFor = (hash: string) => {
const queryLink = queryLinks.get(hash);
return queryLink ? `\n ${queryLink}` : "";
};
const policyConfig: RepoPolicyConfig =
("conditionPolicies" in config ? config.conditionPolicies : undefined) ??
{};
const { regressed, newQueries } = reportContext.comparison;
// Regression arm routed through the shared policy engine (#3500), so a repo
// can soften it to warn/off like untested-data-access and schema-drift.
// `regressed` is already the untriaged, beyond-threshold set; the policy is
// a second, repo-wide layer on top of per-query triage. Default `fail`, so
// a repo that sets nothing blocks exactly as the old inline path did.
const regressionGate = gateRegression(regressed.length, policyConfig);
if (regressionGate) {
const messages = regressed.map((q) => {
const preview = queryPreview(q.formattedQuery);
const cost = `cost ${formatCost(q.previousCost)} → ${formatCost(q.currentCost)} (+${q.regressionPercentage.toFixed(1)}%)`;
return ` - ${preview}: ${cost}${linkFor(q.hash)}`;
});
const message = `${regressed.length} untriaged regression(s) beyond threshold:\n${messages.join("\n")}`;
if (regressionGate.conclusion === "failure") core.setFailed(message);
else core.warning(message);
}
// New-query arm routed through the shared policy engine (#3500). A plain
// new query is informational (`new-query` defaults to `warn`), but one that
// ships with a beyond-threshold index recommendation is actionable, so
// `gateEligibleNewQueries` scopes to that set and the gate routes under the
// `new-query-index` key, which defaults `fail` — blocking exactly as the old
// inline path did, while letting a repo soften it to warn/off.
const gateNewQueries = gateEligibleNewQueries(
newQueries,
config.regressionThreshold,
config.acknowledgedQueryHashes,
config.minimumCost,
);
const newQueryGate = gateNewQuery(gateNewQueries.length, policyConfig);
if (newQueryGate) {
const messages = gateNewQueries.map((q) => {
const preview = queryPreview(q.formattedQuery);
// gateEligibleNewQueries only returns improvements_available entries.
const opt = q.optimization as Extract<
typeof q.optimization,
{ state: "improvements_available" }
>;
const detail = `cost ${formatCost(opt.cost)}, index recommendation cuts it ${opt.costReductionPercentage.toFixed(1)}%`;
return ` - ${preview}: ${detail}${linkFor(q.hash)}`;
});
const message = `${gateNewQueries.length} new quer${gateNewQueries.length === 1 ? "y" : "ies"} ship${gateNewQueries.length === 1 ? "s" : ""} with a high-impact index recommendation (acknowledge on the dashboard to allow):\n${messages.join("\n")}`;
if (newQueryGate.conclusion === "failure") core.setFailed(message);
else core.warning(message);
}
}
} finally {
remote.off("schemaSynced", onSchemaSynced);
disposeApi();
}
}
async function runOutsideCI() {
const os = process.platform;
const arch = process.arch;
log.info(
`Starting server (${os}-${arch}) on ${env.HOST}:${env.PORT}`,
"main",
);
if (!env.POSTGRES_URL) {
throw new Error("POSTGRES_URL environment variable is not set. If you're seeing this inside Docker something has gone wrong");
}
if (!env.TOKEN) {
throw new Error("TOKEN environment variable is not set\nYou probably forgot to pass a `-e TOKEN=...` parameter to the docker container");
}
const sourceDb = Connectable.fromString(env.SOURCE_DATABASE_URL)
const remote = new Remote(
Connectable.fromString(env.POSTGRES_URL),
ConnectionManager.forLocalDatabase(),
ConnectionManager.forRemoteDatabase(),
{ disableQueryLoader: false },
sourceDb,
);
// Only the persistent analyzer holds a connection long enough for a stall to
// cost it one; a CI run's session is over in seconds.
watchEventLoopLag();
ApiClient.connectWithReconnect(env.SITE_API_ENDPOINT, env.TOKEN, { kind: "persistent" }, remote);
const server = await createServer(
env.HOST,
env.PORT,
remote,
sourceDb
);
const shutdown = async () => {
shutdownController.abort();
await server.close();
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
}
async function main() {
if (env.CI) {
if (!env.POSTGRES_URL) {
core.setFailed("POSTGRES_URL environment variable is not set");
process.exit(1);
}
await runInCI(
Connectable.fromString(env.POSTGRES_URL),
Connectable.fromString(env.SOURCE_DATABASE_URL),
env.LOG_PATH,
typeof env.MAX_COST === "number" ? env.MAX_COST : undefined,
);
process.exit(process.exitCode ?? 0);
} else {
await runOutsideCI();
}
}
try {
await main();
} catch (error) {
console.error(error);
process.exit(1);
}