Files
HealthLog/tests/integration/measurement-hot-update.test.ts
T
Marc-André Bombeck 2c33c2a0a0 test(integration): stop cases reading what another case left behind
- the AI-state cases take their baseline in a world of their own
  instead of from the S0 case;
- the live-window case drops a rollup tier the rollup case built;
- the job readers start each case on an empty queue, and the
  no-schema case starts pg-boss again after it;
- the server move case reseeds the server it emptied;
- the extremes cases rebuild the tier one of them breaks on purpose;
- the first Google Health backfill seeds its own history;
- the batch budget warms the process caches as the reads do;
- the HOT-update count is read as a difference: since Postgres 15 the
  transaction view also shows an earlier transaction's unflushed counts;
- the MANAGE matrix checks its effect set after all cases rather than
  in a case of its own that a shuffle could run first.
2026-11-03 21:35:57 +01:00

132 lines
4.6 KiB
TypeScript

/**
* Migration 0412: an update of `measurements` that changes nothing keeps its
* stamps and stays on its page.
*
* `updated_at` sits in the sync-feed index, so an update that moved it could
* never be heap-only, and every provider re-sync that rewrote unchanged rows
* grew the table by a full row version and an entry in every index. Proven
* here against Postgres: the no-op update keeps `updated_at` and
* `sync_version` and is counted as HOT in the transaction's own statistics,
* a real change still moves both, the nightly check reads the table's
* statistics and leaves the vacuuming to autovacuum. The migration's lock
* wait is proven through the real `migrate deploy` in
* `migrate-deploy-lock-retry.test.ts`.
*/
import { Client } from "pg";
import { beforeEach, describe, expect, it } from "vitest";
import {
resolveSteps,
runMeasurementMaintenance,
tableHealth,
} from "@/lib/jobs/measurement-maintenance";
import { getPrismaClient, truncateAllTables } from "./setup";
const prisma = getPrismaClient();
async function seedReading() {
const user = await prisma.user.create({
data: {
username: "hot-update",
email: "hot-update@example.test",
role: "USER",
timezone: "UTC",
},
});
return prisma.measurement.create({
data: {
userId: user.id,
type: "WEIGHT",
value: 72.3,
unit: "kg",
measuredAt: new Date("2026-10-01T07:00:00.000Z"),
source: "WITHINGS",
},
});
}
beforeEach(async () => {
await truncateAllTables(prisma);
});
describe("an update that changes nothing", () => {
it("keeps updated_at and sync_version and stays on its page", async () => {
const row = await seedReading();
await new Promise((r) => setTimeout(r, 20));
// Read as a difference within the transaction. Since Postgres 15 the
// `pg_stat_xact_*` views also carry counts an earlier transaction on the
// same connection has not flushed yet (stats flush at most once a
// second), so an update from the case before this one could show up.
const hot = await prisma.$transaction(async (tx) => {
const read = async () => {
const [stats] = await tx.$queryRaw<Array<{ hot: bigint; upd: bigint }>>`
SELECT n_tup_hot_upd AS hot, n_tup_upd AS upd
FROM pg_stat_xact_user_tables WHERE relname = 'measurements'`;
return { hot: Number(stats?.hot ?? 0), upd: Number(stats?.upd ?? 0) };
};
const before = await read();
await tx.measurement.update({
where: { id: row.id },
data: { value: 72.3, deletedAt: null, syncVersion: { increment: 1 } },
});
const after = await read();
return { hot: after.hot - before.hot, upd: after.upd - before.upd };
});
expect(hot.upd).toBe(1);
expect(hot.hot).toBe(1);
const after = await prisma.measurement.findUniqueOrThrow({
where: { id: row.id },
});
expect(after.updatedAt.getTime()).toBe(row.updatedAt.getTime());
expect(after.syncVersion).toBe(row.syncVersion);
});
it("still moves both stamps when a value changes", async () => {
const row = await seedReading();
await new Promise((r) => setTimeout(r, 20));
await prisma.measurement.update({
where: { id: row.id },
data: { value: 72.4, syncVersion: { increment: 1 } },
});
const after = await prisma.measurement.findUniqueOrThrow({
where: { id: row.id },
});
expect(after.updatedAt.getTime()).toBeGreaterThan(row.updatedAt.getTime());
expect(after.syncVersion).toBe(row.syncVersion + 1);
});
it("gives the table room on each page and an earlier autovacuum", async () => {
const [rel] = await prisma.$queryRaw<Array<{ options: string[] | null }>>`
SELECT reloptions AS options FROM pg_class WHERE relname = 'measurements'`;
expect(rel.options).toEqual(
expect.arrayContaining([
"fillfactor=85",
"autovacuum_vacuum_scale_factor=0.05",
]),
);
});
});
describe("the nightly check", () => {
it("reads the statistics and leaves the vacuuming to autovacuum", async () => {
await seedReading();
const db = new Client({ connectionString: process.env.DATABASE_URL });
await db.connect();
try {
await db.query("ANALYZE measurements");
const health = await tableHealth(db);
expect(health.heapBytes).toBeGreaterThan(0);
const steps = resolveSteps({});
expect(steps).toEqual({ vacuum: false, reindex: false });
const summary = await runMeasurementMaintenance(db, steps);
expect(summary.outcome).toBe("nothing_due");
expect(summary.steps).toEqual([]);
} finally {
await db.end();
}
});
});