mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(migrate): run migrate deploy in a UTC session
Prisma ignores PGTZ, so a database whose default zone is not UTC ran every migration in that zone, and a migration mixing timestamp and timestamptz wrote shifted values. The boot wrapper now appends -c TimeZone=UTC to the options parameter of DATABASE_URL for its Prisma calls, honouring DATABASE_SESSION_OPTIONS_DISABLED like the app does.
This commit is contained in:
1 parent
67cc56f707
commit
b90c82cba3
4 files changed
+264
No files matched your search
@@ -40,6 +40,15 @@ not reachable". The entrypoint also runs the migrations on every boot, so
|
||||
step 2 is optional; it is there for when you want to see the migration
|
||||
result before the new container takes traffic.
|
||||
|
||||
The entrypoint runs every migration in a UTC session, the zone the app's own
|
||||
connections use, whatever the database's default `TimeZone` is: it adds
|
||||
`-c TimeZone=UTC` to the `options` parameter of `DATABASE_URL` for the Prisma
|
||||
calls (an `options` value you set yourself is kept, with the pin after it).
|
||||
Behind a connection pooler that refuses `options`, the
|
||||
`DATABASE_SESSION_OPTIONS_DISABLED` switch turns this off along with the
|
||||
app's own session settings; set the database or role default to UTC there
|
||||
(`ALTER DATABASE healthlog SET timezone = 'UTC'`).
|
||||
|
||||
### A migration that timed out on a lock
|
||||
|
||||
Some migrations bound how long they wait for a table lock, so that a
|
||||
|
||||
@@ -24,7 +24,9 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
decideLockRetry,
|
||||
lockTimeoutInReport,
|
||||
prismaEnv,
|
||||
retryDelays,
|
||||
utcSessionUrl,
|
||||
} from "../migrate-deploy.mjs";
|
||||
|
||||
const P3018_LOCK = `Applying migration \`0412_measurement_hot_updates\`
|
||||
@@ -167,6 +169,53 @@ describe("which failure is retried", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the session time zone Prisma migrates in", () => {
|
||||
const options = (url: string | undefined) =>
|
||||
new URL(url ?? "").searchParams.get("options");
|
||||
|
||||
it("pins TimeZone=UTC through the libpq options parameter", () => {
|
||||
const url = utcSessionUrl(
|
||||
"postgresql://u:p@db:5432/healthlog?schema=public",
|
||||
);
|
||||
expect(options(url)).toBe("-c TimeZone=UTC");
|
||||
expect(new URL(url ?? "").searchParams.get("schema")).toBe("public");
|
||||
});
|
||||
|
||||
it("appends to options the operator set, so a later -c wins", () => {
|
||||
const url = utcSessionUrl(
|
||||
"postgresql://u:p@db/h?options=-c%20TimeZone%3DEurope%2FBerlin",
|
||||
);
|
||||
expect(options(url)).toBe("-c TimeZone=Europe/Berlin -c TimeZone=UTC");
|
||||
// Running it again adds nothing.
|
||||
expect(utcSessionUrl(url)).toBe(url);
|
||||
});
|
||||
|
||||
it("leaves a URL alone when the startup options are switched off", () => {
|
||||
const raw = "postgresql://u:p@db/h?schema=public";
|
||||
expect(utcSessionUrl(raw, { optionsDisabled: true })).toBe(raw);
|
||||
expect(
|
||||
prismaEnv({ DATABASE_URL: raw, DATABASE_SESSION_OPTIONS_DISABLED: "1" })
|
||||
.DATABASE_URL,
|
||||
).toBe(raw);
|
||||
});
|
||||
|
||||
it("passes an unset or unparseable URL through untouched", () => {
|
||||
expect(utcSessionUrl(undefined)).toBeUndefined();
|
||||
expect(utcSessionUrl("")).toBe("");
|
||||
expect(utcSessionUrl("not a url")).toBe("not a url");
|
||||
expect(prismaEnv({}).DATABASE_URL).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hands Prisma the pinned URL and keeps the rest of the environment", () => {
|
||||
const env = prismaEnv({
|
||||
DATABASE_URL: "postgresql://u:p@db/h",
|
||||
HEALTHLOG_X: "1",
|
||||
});
|
||||
expect(options(env.DATABASE_URL)).toBe("-c TimeZone=UTC");
|
||||
expect(env.HEALTHLOG_X).toBe("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the retry loop", () => {
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => {
|
||||
|
||||
@@ -30,6 +30,16 @@
|
||||
* after a failed one (P3009), from the `logs` Prisma stored on the failed
|
||||
* row in `_prisma_migrations`.
|
||||
*
|
||||
* Every Prisma call runs with its session pinned to UTC, the way the app's
|
||||
* own connections are (`src/lib/db-session.ts`). Almost every `DateTime`
|
||||
* column is `timestamp` without a time zone and holds UTC, and Postgres
|
||||
* converts between that and `timestamptz` / `now()` through the session time
|
||||
* zone. A migration that mixes the two (0372 does) therefore wrote hours-off
|
||||
* values on a database whose default zone is not UTC. Prisma ignores `PGTZ`,
|
||||
* so the pin rides the connection URL as the libpq `options` parameter, which
|
||||
* Prisma passes to the server. `DATABASE_SESSION_OPTIONS_DISABLED` (a pooler
|
||||
* that refuses `options`) turns it off, as it does for the app.
|
||||
*
|
||||
* Environment:
|
||||
* HEALTHLOG_PRISMA_CLI Prisma's CLI entry (default: the image's copy)
|
||||
* HEALTHLOG_MIGRATIONS_DIR where the migration files are (default:
|
||||
@@ -49,6 +59,7 @@ const DEFAULT_RETRY_DELAYS_S = [15, 30, 60, 120, 240];
|
||||
export const RETRY_MARKER = /^--\s*@retry-on-lock-timeout\s*$/m;
|
||||
|
||||
const LOCK_TIMEOUT = "55P03";
|
||||
const TIME_ZONE_OPTION = "-c TimeZone=UTC";
|
||||
const MIGRATION_NAME = /^[A-Za-z0-9_]+$/;
|
||||
|
||||
/**
|
||||
@@ -116,10 +127,54 @@ export function retryDelays(raw) {
|
||||
: DEFAULT_RETRY_DELAYS_S;
|
||||
}
|
||||
|
||||
/**
|
||||
* `DATABASE_URL` with the session time zone pinned to UTC, for Prisma.
|
||||
*
|
||||
* Appends to an `options` value the operator set rather than replacing it;
|
||||
* a later `-c` wins, so an operator's own `TimeZone` gives way to UTC. A URL
|
||||
* that does not parse, or a host that disabled the startup options, is
|
||||
* returned unchanged.
|
||||
*
|
||||
* @param {string | undefined} raw
|
||||
* @param {{ optionsDisabled?: boolean }} [opts]
|
||||
* @returns {string | undefined}
|
||||
*/
|
||||
export function utcSessionUrl(raw, { optionsDisabled = false } = {}) {
|
||||
if (!raw || optionsDisabled) return raw;
|
||||
let url;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
const existing = url.searchParams.get("options")?.trim();
|
||||
if (existing && existing.endsWith(TIME_ZONE_OPTION)) return raw;
|
||||
url.searchParams.set(
|
||||
"options",
|
||||
existing ? `${existing} ${TIME_ZONE_OPTION}` : TIME_ZONE_OPTION,
|
||||
);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/** The same switch the app reads in `src/lib/db-session.ts`. */
|
||||
function sessionOptionsDisabled(env) {
|
||||
const raw = env.DATABASE_SESSION_OPTIONS_DISABLED?.trim();
|
||||
return raw !== undefined && /^(1|true|yes)$/i.test(raw);
|
||||
}
|
||||
|
||||
/** The environment every Prisma call runs in. */
|
||||
export function prismaEnv(env = process.env) {
|
||||
const url = utcSessionUrl(env.DATABASE_URL, {
|
||||
optionsDisabled: sessionOptionsDisabled(env),
|
||||
});
|
||||
return url === undefined ? { ...env } : { ...env, DATABASE_URL: url };
|
||||
}
|
||||
|
||||
function runPrisma(cli, args) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [cli, ...args], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: prismaEnv(),
|
||||
});
|
||||
let output = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* The boot's `migrate deploy` (`scripts/migrate-deploy.mjs`) runs every
|
||||
* migration in a UTC session, whatever the database's own default zone.
|
||||
*
|
||||
* A role whose default `TimeZone` is Europe/Berlin stands in for a shared
|
||||
* Postgres configured that way. One migration records the session zone and
|
||||
* the instant a `timestamp` → `timestamptz` cast lands on, which is what a
|
||||
* migration mixing the two types (0372) depends on. Through the wrapper both
|
||||
* read UTC; through the bare Prisma CLI, the counter-check, they read Berlin,
|
||||
* which proves the role default is what the wrapper overrode.
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Client } from "pg";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
const ROLE = "hl_tz_probe";
|
||||
const PASSWORD = "tz-probe";
|
||||
const ROOT = process.cwd();
|
||||
const PRISMA_CLI = join(ROOT, "node_modules", "prisma", "build", "index.js");
|
||||
|
||||
const dirs: string[] = [];
|
||||
|
||||
function adminUrl(): URL {
|
||||
const raw = process.env.DATABASE_URL;
|
||||
if (!raw) throw new Error("DATABASE_URL is not set for the integration run");
|
||||
const url = new URL(raw);
|
||||
url.searchParams.delete("schema");
|
||||
return url;
|
||||
}
|
||||
|
||||
async function admin(): Promise<Client> {
|
||||
const client = new Client({ connectionString: adminUrl().toString() });
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
/** A URL that logs in as the Berlin-default role, into its own schema. */
|
||||
function probeUrl(schema: string): string {
|
||||
const url = new URL(process.env.DATABASE_URL ?? "");
|
||||
url.username = ROLE;
|
||||
url.password = PASSWORD;
|
||||
url.searchParams.set("schema", schema);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const db = await admin();
|
||||
await db.query(`DROP OWNED BY ${ROLE}`).catch(() => {});
|
||||
await db.query(`DROP ROLE IF EXISTS ${ROLE}`);
|
||||
await db.query(`CREATE ROLE ${ROLE} LOGIN SUPERUSER PASSWORD '${PASSWORD}'`);
|
||||
await db.query(`ALTER ROLE ${ROLE} SET timezone = 'Europe/Berlin'`);
|
||||
await db.end();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true });
|
||||
const db = await admin();
|
||||
for (const schema of ["tz_probe_wrapper", "tz_probe_bare"]) {
|
||||
await db.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`);
|
||||
}
|
||||
await db.query(`DROP OWNED BY ${ROLE}`).catch(() => {});
|
||||
await db.query(`DROP ROLE IF EXISTS ${ROLE}`);
|
||||
await db.end();
|
||||
});
|
||||
|
||||
function migrationsDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hl-migrate-utc-"));
|
||||
dirs.push(dir);
|
||||
writeFileSync(
|
||||
join(dir, "schema.prisma"),
|
||||
'datasource db {\n provider = "postgresql"\n}\n',
|
||||
);
|
||||
mkdirSync(join(dir, "migrations", "0001_zone_probe"), { recursive: true });
|
||||
cpSync(
|
||||
join(ROOT, "prisma", "migrations", "migration_lock.toml"),
|
||||
join(dir, "migrations", "migration_lock.toml"),
|
||||
);
|
||||
writeFileSync(
|
||||
join(dir, "migrations", "0001_zone_probe", "migration.sql"),
|
||||
`CREATE TABLE "zone_probe" AS
|
||||
SELECT current_setting('TimeZone') AS zone,
|
||||
('2026-01-15 12:00:00'::timestamp)::timestamptz
|
||||
= '2026-01-15 12:00:00+00'::timestamptz AS cast_is_utc;`,
|
||||
);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function run(
|
||||
args: string[],
|
||||
databaseUrl: string,
|
||||
): Promise<{ code: number; stderr: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, args, {
|
||||
cwd: ROOT,
|
||||
env: {
|
||||
...process.env,
|
||||
DATABASE_URL: databaseUrl,
|
||||
DATABASE_SESSION_OPTIONS_DISABLED: "",
|
||||
HEALTHLOG_PRISMA_CLI: PRISMA_CLI,
|
||||
},
|
||||
});
|
||||
let stderr = "";
|
||||
child.stdout.on("data", () => {});
|
||||
child.stderr.on("data", (chunk) => (stderr += chunk));
|
||||
child.on("close", (code) => resolve({ code: code ?? 1, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
async function probe(schema: string) {
|
||||
const db = await admin();
|
||||
const { rows } = await db.query(
|
||||
`SELECT zone, cast_is_utc FROM ${schema}.zone_probe`,
|
||||
);
|
||||
await db.end();
|
||||
return rows[0] as { zone: string; cast_is_utc: boolean };
|
||||
}
|
||||
|
||||
describe("migrate deploy session time zone", () => {
|
||||
it("runs the migration in UTC through the boot wrapper", async () => {
|
||||
const dir = migrationsDir();
|
||||
const result = await run(
|
||||
[
|
||||
join(ROOT, "scripts", "migrate-deploy.mjs"),
|
||||
"--schema",
|
||||
join(dir, "schema.prisma"),
|
||||
],
|
||||
probeUrl("tz_probe_wrapper"),
|
||||
);
|
||||
expect(result.code, result.stderr).toBe(0);
|
||||
expect(await probe("tz_probe_wrapper")).toEqual({
|
||||
zone: "UTC",
|
||||
cast_is_utc: true,
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
it("counter-check: the bare CLI inherits the database's own zone", async () => {
|
||||
const dir = migrationsDir();
|
||||
const result = await run(
|
||||
[PRISMA_CLI, "migrate", "deploy", "--schema", join(dir, "schema.prisma")],
|
||||
probeUrl("tz_probe_bare"),
|
||||
);
|
||||
expect(result.code, result.stderr).toBe(0);
|
||||
expect(await probe("tz_probe_bare")).toEqual({
|
||||
zone: "Europe/Berlin",
|
||||
cast_is_utc: false,
|
||||
});
|
||||
}, 60_000);
|
||||
});
|
||||
Reference in new issue
Block a user