From 1ea958fe62bd8525626a85123d09e2a2fb25dfd7 Mon Sep 17 00:00:00 2001 From: Xetera Date: Mon, 27 Apr 2026 16:34:56 +0300 Subject: [PATCH 1/3] feat: return computed stats to the client --- src/main.ts | 2 +- src/remote/query-optimizer.ts | 5 +++++ src/remote/remote-controller.ts | 16 +++++++++++++--- src/remote/remote.ts | 8 ++++++-- src/reporters/reporter.ts | 3 ++- src/reporters/site-api.ts | 8 +++++++- src/runner.ts | 1 + src/sync/pg-connector.ts | 1 - 8 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/main.ts b/src/main.ts index 6cb51af0..f6c089f9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -54,7 +54,7 @@ async function runInCI( // POST to Site API first so we get the run ID for the PR comment link let runId: string | null = null; if (siteApiEndpoint) { - runId = await postToSiteApi(siteApiEndpoint, queries); + runId = await postToSiteApi(siteApiEndpoint, queries, reportContext.statisticsMode, reportContext.computedStats); } // Build the run URL and query base URL for the PR comment diff --git a/src/remote/query-optimizer.ts b/src/remote/query-optimizer.ts index 37f20ad3..077367bc 100644 --- a/src/remote/query-optimizer.ts +++ b/src/remote/query-optimizer.ts @@ -12,6 +12,7 @@ import { OptimizeResult, PgIdentifier, PostgresExplainStage, + ComputedStats, PostgresQueryBuilder, PostgresTransaction, PostgresVersion, @@ -105,6 +106,10 @@ export class QueryOptimizer extends EventEmitter { return this.target?.statistics.mode ?? QueryOptimizer.defaultStatistics; } + get computedStats(): ComputedStats | undefined { + return this.target?.statistics.computedStats; + } + getExistingIndexes(): IndexedTable[] { return this.existingIndexes; } diff --git a/src/remote/remote-controller.ts b/src/remote/remote-controller.ts index 108beaaa..907156c0 100644 --- a/src/remote/remote-controller.ts +++ b/src/remote/remote-controller.ts @@ -10,7 +10,7 @@ import { ToggleIndexDto, } from "./remote-controller.dto.ts"; import { ZodError } from "zod"; -import { ExportedStats, Statistics } from "@query-doctor/core"; +import { CombinedExport, ExportedStats, Statistics } from "@query-doctor/core"; import { type Connectable } from "../sync/connectable.ts"; import { connectToSource } from "../sql/postgresjs.ts"; @@ -141,6 +141,8 @@ export class RemoteController { : { type: "ok", value: queries }, disabledIndexes: { type: "ok", value: disabledIndexes }, deltas, + statisticsMode: this.remote.optimizer.statisticsMode, + computedStats: this.remote.optimizer.computedStats, }; } @@ -192,6 +194,8 @@ export class RemoteController { queries: pgStatStatementsNotInstalled ? this.pgStatStatementsNotInstalledError() : { type: "ok", value: queries }, + statisticsMode: this.remote.optimizer.statisticsMode, + computedStats: this.remote.optimizer.computedStats, }, }; } catch (error) { @@ -222,7 +226,10 @@ export class RemoteController { async onImportStats(body: unknown): Promise { let stats: ExportedStats[]; try { - stats = ExportedStats.array().parse(body); + const combined = CombinedExport.safeParse(body); + stats = combined.success + ? combined.data.stats + : ExportedStats.array().parse(body); } catch (error) { if (error instanceof ZodError) { return { @@ -232,7 +239,7 @@ export class RemoteController { } return { status: 400, - body: { type: "error", error: "invalid_body", message: "body must be an array of ExportedStats" }, + body: { type: "error", error: "invalid_body", message: "body must be an array of ExportedStats or a CombinedExport object" }, }; } @@ -240,6 +247,9 @@ export class RemoteController { await this.remote.applyStatistics( Statistics.statsModeFromExport(stats), ); + if (this.syncResponse) { + this.syncResponse.meta.inferredStatsStrategy = "imported"; + } return { status: 200, body: { success: true } }; } catch (error) { console.error(error); diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 2bceb568..c6b9ea57 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -250,7 +250,11 @@ export class Remote extends EventEmitter { const nextDbName = this.generationDbName(nextGeneration); log.info(`Creating new generation database: ${nextDbName}`, "remote"); const baseDb = this.manager.getOrCreateConnection(this.baseDbURL); - await baseDb.exec(`create database ${nextDbName};`); + try { + await baseDb.exec(`create database ${nextDbName};`); + } catch (err) { + // it's ok if the db already exists (previously crashed) + } const prevDbName = this.generationDbName(prevGeneration); this.generation = nextGeneration; this.optimizingDbUDRL = this.optimizingDbUDRL.withDatabaseName(nextDbName); @@ -434,7 +438,7 @@ export type StatisticsStrategy = { stats: StatisticsMode; }; -export type InferredStatsStrategy = "10k" | "fromSource"; +export type InferredStatsStrategy = "10k" | "fromSource" | "imported"; type StatsResult = { mode: StatisticsMode; diff --git a/src/reporters/reporter.ts b/src/reporters/reporter.ts index bebb16d6..d95e73b1 100644 --- a/src/reporters/reporter.ts +++ b/src/reporters/reporter.ts @@ -1,4 +1,4 @@ -import type { IndexIdentifier, StatisticsMode } from "@query-doctor/core"; +import type { ComputedStats, IndexIdentifier, StatisticsMode } from "@query-doctor/core"; import type { RunComparison } from "./site-api.ts"; export interface Reporter { @@ -74,6 +74,7 @@ export interface ReportStatistics { export interface ReportContext { statisticsMode: StatisticsMode; + computedStats?: ComputedStats; recommendations: ReportIndexRecommendation[]; queriesPastThreshold: ReportQueryCostWarning[]; queryStats: Readonly; diff --git a/src/reporters/site-api.ts b/src/reporters/site-api.ts index 5ddfcaa6..0e10445d 100644 --- a/src/reporters/site-api.ts +++ b/src/reporters/site-api.ts @@ -1,5 +1,5 @@ import * as github from "@actions/github"; -import type { IndexRecommendation, Nudge, SQLCommenterTag, TableReference } from "@query-doctor/core"; +import type { ComputedStats, IndexRecommendation, Nudge, SQLCommenterTag, StatisticsMode, TableReference } from "@query-doctor/core"; import { DEFAULT_CONFIG, type AnalyzerConfig } from "../config.ts"; import type { OptimizedQuery } from "../sql/recent-query.ts"; @@ -11,6 +11,8 @@ interface CiRunPayload { prNumber?: number; runId: string; queries: CiQueryPayload[]; + statisticsMode?: StatisticsMode; + computedStats?: ComputedStats; } export interface CiQueryPayload { @@ -243,6 +245,8 @@ export function compareRuns( export async function postToSiteApi( endpoint: string, queries: CiQueryPayload[], + statisticsMode?: StatisticsMode, + computedStats?: ComputedStats, ): Promise { const payload: CiRunPayload = { repo: process.env.GITHUB_REPOSITORY ?? "", @@ -251,6 +255,8 @@ export async function postToSiteApi( prNumber: github.context.payload.pull_request?.number, runId: process.env.GITHUB_RUN_ID ?? "", queries, + statisticsMode, + computedStats, }; const url = `${endpoint.replace(/\/$/, "")}/ci/runs`; diff --git a/src/runner.ts b/src/runner.ts index 5afbb231..44a07fee 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -254,6 +254,7 @@ export class Runner { const timeElapsed = Date.now() - startDate.getTime(); const reportContext: ReportContext = { statisticsMode: this.remote.optimizer.statisticsMode, + computedStats: this.remote.optimizer.computedStats, recommendations: filteredRecommendations, queriesPastThreshold: filteredThresholdWarnings, queryStats: Object.freeze({ diff --git a/src/sync/pg-connector.ts b/src/sync/pg-connector.ts index 531b8d8d..e44255dc 100644 --- a/src/sync/pg-connector.ts +++ b/src/sync/pg-connector.ts @@ -23,7 +23,6 @@ import { SegmentedQueryCache } from "./seen-cache.ts"; import { FullSchema, FullSchemaColumn } from "./schema_differ.ts"; import { ExtensionNotInstalledError, PostgresError } from "./errors.ts"; import { RawRecentQuery, RecentQuery } from "../sql/recent-query.ts"; -import { ConnectionManager } from "./connection-manager.ts"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); From 8ef263d028cea9410dcc28b82c19aa68cbc416a7 Mon Sep 17 00:00:00 2001 From: Xetera Date: Mon, 27 Apr 2026 17:14:09 +0300 Subject: [PATCH 2/3] fix: use the default stats from Statistics --- src/remote/query-optimizer.ts | 5 +---- src/remote/remote.test.ts | 2 +- src/remote/remote.ts | 6 +++--- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/remote/query-optimizer.ts b/src/remote/query-optimizer.ts index 077367bc..c67ad68e 100644 --- a/src/remote/query-optimizer.ts +++ b/src/remote/query-optimizer.ts @@ -42,10 +42,7 @@ type Target = { export class QueryOptimizer extends EventEmitter { private static readonly MAX_CONCURRENCY = 1; - private static readonly defaultStatistics: StatisticsMode = { - kind: "fromAssumption", - reltuples: 10_000, - }; + private static readonly defaultStatistics: StatisticsMode = Statistics.defaultStatsMode; private readonly queries = new Map(); private readonly disabledIndexes = new DisabledIndexes(); diff --git a/src/remote/remote.test.ts b/src/remote/remote.test.ts index 66d35f1d..17f9fb01 100644 --- a/src/remote/remote.test.ts +++ b/src/remote/remote.test.ts @@ -250,7 +250,7 @@ test("infers '10k' stats strategy when row count is below threshold", async () = const result = await remote.syncFrom(source); await remote.optimizer.finish; - expect(result.meta.inferredStatsStrategy).toEqual("10k"); + expect(result.meta.inferredStatsStrategy).toEqual("default"); await remote.cleanup(); } finally { await Promise.all([sourceDb.stop(), targetDb.stop()]); diff --git a/src/remote/remote.ts b/src/remote/remote.ts index c6b9ea57..9599f13b 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -316,10 +316,10 @@ export class Remote extends EventEmitter { if (totalRows < Remote.STATS_ROWS_THRESHOLD) { log.info( - `Total rows (${totalRows}) below threshold, using default 10k stats`, + `Total rows (${totalRows}) below threshold, using default stats`, "remote", ); - return { mode: Statistics.defaultStatsMode, strategy: "10k" }; + return { mode: Statistics.defaultStatsMode, strategy: "default" }; } log.info( @@ -438,7 +438,7 @@ export type StatisticsStrategy = { stats: StatisticsMode; }; -export type InferredStatsStrategy = "10k" | "fromSource" | "imported"; +export type InferredStatsStrategy = "default" | "fromSource" | "imported"; type StatsResult = { mode: StatisticsMode; From c71833e3e05ea8817c48dcb977a4feeac66126ee Mon Sep 17 00:00:00 2001 From: Xetera Date: Wed, 29 Apr 2026 11:09:25 +0400 Subject: [PATCH 3/3] chore: add docker-compose dev setup Co-Authored-By: Claude Opus 4.7 (1M context) --- docker-compose.dev.yml | 9 +++++++++ package.json | 1 + 2 files changed, 10 insertions(+) create mode 100644 docker-compose.dev.yml diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..c13c7c14 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,9 @@ +services: + analyzer: + build: . + ports: + - 2345:2345 + env_file: + - .env + volumes: + - ../query-doctor/packages/core/dist:/app/node_modules/@query-doctor/core/dist diff --git a/package.json b/package.json index 48e0af91..f20e2f9a 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "start": "node --import tsx src/main.ts", "start:dev": "node --import tsx --watch src/main.ts", "dev": "node --env-file=.env --import tsx --watch src/main.ts", + "dev:docker": "docker compose -f docker-compose.dev.yml up --build", "test": "vitest", "typecheck": "tsc --noEmit", "build": "esbuild src/main.ts --bundle --platform=node --format=esm --outfile=dist/main.mjs --packages=external && cp src/reporters/github/success.md.j2 src/sync/schema_dump.sql dist/"