Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/remote/apply-statistics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from "vitest";
import { Statistics, type ExportedStats } from "@query-doctor/core";
import { Connectable } from "../sync/connectable.ts";
import { ConnectionManager } from "../sync/connection-manager.ts";
import { Remote } from "./remote.ts";

function makeRemote(): Remote {
return new Remote(
Connectable.fromString("postgresql://postgres@localhost:5432/postgres"),
ConnectionManager.forRemoteDatabase(),
);
}

function table(name: string, reltuples: number): ExportedStats {
return {
schemaName: "public",
tableName: name,
reltuples,
relpages: 1,
relallvisible: 0,
columns: [],
indexes: [],
} as unknown as ExportedStats;
}

/**
* `applyStatistics` decides what reaches the server's stored snapshot, so what
* it emits matters more than what it applies locally. The optimizer runs
* against a copy restored without table data, so its own metadata reports
* `reltuples = -1` everywhere — pushing that would replace a project's real
* production statistics with an empty snapshot.
*/
describe("Remote.applyStatistics", () => {
it("pushes the source dump, not the optimizer's own metadata", async () => {
const remote = makeRemote();
const sourceDump = [table("users", 5_000_000)];
// The optimizing database is empty; its dump is what must NOT be pushed.
vi.spyOn(remote.optimizer, "setStatistics").mockResolvedValue(undefined);
vi.spyOn(remote.optimizer, "restart").mockResolvedValue(undefined);
vi.spyOn(remote.optimizer, "ownMetadata", "get").mockReturnValue([
table("users", -1),
]);
const pushed: ExportedStats[][] = [];
remote.on("statsApplied", (stats) => pushed.push(stats));

await remote.applyStatistics(Statistics.statsModeFromExport(sourceDump));

expect(pushed).toEqual([sourceDump]);
expect(pushed[0][0].reltuples).toBe(5_000_000);
});

it("pushes nothing for synthetic statistics", async () => {
// resetStats applies fromAssumption. Those numbers are invented, so
// publishing them as production statistics would be a lie.
const remote = makeRemote();
vi.spyOn(remote.optimizer, "setStatistics").mockResolvedValue(undefined);
vi.spyOn(remote.optimizer, "restart").mockResolvedValue(undefined);
vi.spyOn(remote.optimizer, "ownMetadata", "get").mockReturnValue([
table("users", -1),
]);
let pushed = false;
remote.on("statsApplied", () => {
pushed = true;
});

await remote.applyStatistics(Statistics.defaultStatsMode);

expect(pushed).toBe(false);
});
});
22 changes: 12 additions & 10 deletions src/remote/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,16 +490,18 @@ export class Remote extends EventEmitter<RemoteEvents> {

async applyStatistics(statsMode: StatisticsMode): Promise<void> {
await this.optimizer.setStatistics(statsMode);
const stats = this.optimizer.ownMetadata;
if (stats) {
// Record what we're about to push as the drift baseline. Only meaningful
// for source-derived stats; an imported snapshot describes someone else's
// database, so drifting against it would be nonsense.
if (statsMode.kind === "fromStatisticsExport") {
this.statsBaseline = baselineFromDump(stats);
this.lastStatsPushAt = Date.now();
}
this.emit("statsApplied", stats);
// Push the statistics we were handed, not `optimizer.ownMetadata`. That is
// a dump of the *optimizing* database, which is restored with
// `--exclude-table-data-and-children` and never durably analyzed, so every
// table in it reports `reltuples = -1` and every column `stats: null`.
// Sending it would overwrite the project's real snapshot with an empty one.
//
// `fromAssumption` carries no real numbers at all, so it pushes nothing —
// synthetic defaults are not this project's production statistics.
if (statsMode.kind === "fromStatisticsExport" && statsMode.stats.length > 0) {
this.statsBaseline = baselineFromDump(statsMode.stats);
this.lastStatsPushAt = Date.now();
this.emit("statsApplied", statsMode.stats);
}
// don't block the reply by awaiting all optimizations
this.optimizer.restart();
Expand Down