From f1ad56b472f0a09858acbc9bd57a7d61adc1d419 Mon Sep 17 00:00:00 2001 From: Jean-Philippe Sirois Date: Thu, 13 Aug 2026 19:55:04 -0300 Subject: [PATCH] fix(remote): earn a statistics re-dump at a fifth, not a half MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Size Drift decided a snapshot was still current until a table's reltuples moved by half. That is coarser than the signal it reads: pg_class.reltuples is rewritten when autovacuum analyzes the table, which by default happens once it has changed by 10%, so four fifths of the available resolution went unused. It also let a table sit at 1.5x its snapshot size while every query against it was costed a third light. Move the ratio to 0.2 — two ticks of the underlying signal, and small enough that the growth/shrink asymmetry in |now - before| / before stops mattering. Co-Authored-By: Claude --- src/remote/stats-drift.test.ts | 23 +++++++++++++++++++---- src/remote/stats-drift.ts | 18 ++++++++++++++---- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/remote/stats-drift.test.ts b/src/remote/stats-drift.test.ts index 15e227c..9c608b9 100644 --- a/src/remote/stats-drift.test.ts +++ b/src/remote/stats-drift.test.ts @@ -105,11 +105,26 @@ describe("detectDrift — Size Drift", () => { expect(verdict.drifted).toBe(true); }); + it("fires on a quarter-sized move", () => { + // A table a quarter bigger than the snapshot has every query against it + // costed a quarter light, which is well past what a planner shrugs off. + const baseline = baselineFromDump([table("users", BIG)]); + + const verdict = detectDrift(baseline, { + reltuples: reltuples({ users: Math.round(BIG * 1.25) }), + }); + + expect(verdict.drifted).toBe(true); + }); + it("ignores a move below the ratio", () => { + // One autoanalyze tick. `pg_class.reltuples` only moves when autovacuum + // analyzes the table, so a move this size is as likely to be the sampling + // catching up as the data actually growing. const baseline = baselineFromDump([table("users", BIG)]); const verdict = detectDrift(baseline, { - reltuples: reltuples({ users: Math.round(BIG * 1.2) }), + reltuples: reltuples({ users: Math.round(BIG * 1.1) }), }); expect(verdict.drifted).toBe(false); @@ -190,14 +205,14 @@ describe("detectDrift — the closest table that did not drift", () => { const baseline = baselineFromDump([table("users", BIG), table("teams", BIG)]); const verdict = detectDrift(baseline, { - reltuples: reltuples({ users: BIG * 0.6, teams: BIG * 0.95 }), + reltuples: reltuples({ users: BIG * 0.85, teams: BIG * 0.95 }), }); expect(verdict.drifted).toBe(false); - // users moved 40%, teams 5%. The threshold is 50%, so neither fires. + // users moved 15%, teams 5%. The threshold is 20%, so neither fires. expect(verdict.drifted === false && verdict.closest).toEqual({ table: "public.users", - ratio: expect.closeTo(0.4, 5), + ratio: expect.closeTo(0.15, 5), }); }); diff --git a/src/remote/stats-drift.ts b/src/remote/stats-drift.ts index f7194db..b6ae032 100644 --- a/src/remote/stats-drift.ts +++ b/src/remote/stats-drift.ts @@ -44,11 +44,21 @@ export type DriftVerdict = | { drifted: true; kind: "shape" | "size"; reason: string }; /** - * Ratio a table's `reltuples` must move by before Size Drift fires. 0.5 means - * the table has to halve or grow by 50%; below that the planner's choices are - * unlikely to change enough to be worth a full dump. + * Ratio a table's `reltuples` must move by before Size Drift fires. 0.2 means + * the table has to grow or shrink by a fifth. + * + * The floor under this number is the resolution of the signal it reads. + * `pg_class.reltuples` is only rewritten when autovacuum analyzes the table, + * which by default happens once it has changed by `autovacuum_analyze_scale_ + * factor` — 10%. A threshold below that would be reading sampling noise rather + * than the data moving. + * + * The ceiling is how wrong a cost may be before it stops being worth + * reporting. At the old 0.5 a table could sit half again its snapshot size and + * still be called current, so every query against it was costed a third light + * — a bigger error than most of what we flag. */ -export const DEFAULT_SIZE_DRIFT_RATIO = 0.5; +export const DEFAULT_SIZE_DRIFT_RATIO = 0.2; /** * Tables smaller than this are exempt from Size Drift. A table going from 2 to