From dae051269837712f0deb5c6cc630cb1a561bb119 Mon Sep 17 00:00:00 2001 From: Cody Swaney Date: Mon, 6 Jul 2026 15:45:40 -0400 Subject: [PATCH] fix: Add a configurable lock_timeout (default 5s) to add_partitions Bounds how long the CREATE ... PARTITION OF statements wait on the partitioned parent's lock, so a scheduled maintain run backs off instead of blocking writers queued behind it. On timeout the statement errors, the transaction rolls back, and the table is reported as a failed extension to be retried on the next run. Defaults to 5s (matching the swapper) and is tunable via --lock-timeout on the add_partitions and maintain commands (lockTimeout in the library options / MaintainOptions). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/add-partitions-retrofit.test.ts | 36 ++++++++++++++++++++++++++ src/commands/add-partitions.ts | 5 ++++ src/commands/maintain.ts | 5 ++++ src/maintain.test.ts | 39 +++++++++++++++++++++++++++++ src/pgslice.ts | 12 +++++++++ src/types.ts | 12 +++++++++ 6 files changed, 109 insertions(+) diff --git a/src/add-partitions-retrofit.test.ts b/src/add-partitions-retrofit.test.ts index a432f9a..c6fa3c0 100644 --- a/src/add-partitions-retrofit.test.ts +++ b/src/add-partitions-retrofit.test.ts @@ -75,6 +75,42 @@ describe("Pgslice.addPartitions (retrofit)", () => { ]); }); + test("defaults to a 5s lock_timeout for the add_partitions transaction", async ({ + pgslice, + transaction, + }) => { + await pgslice.addPartitions(transaction, { table: "posts", future: 1 }); + + // addPartitions opens its own (sub)transaction; passing this test's + // transaction makes that a SAVEPOINT, and a released savepoint's SET LOCAL + // propagates to the parent — so reading it here proves it was applied + // inside the add_partitions transaction that ran the CREATE ... PARTITION OF. + const result = await transaction.one( + sql.type(z.object({ lock_timeout: z.string() }))` + SHOW lock_timeout + `, + ); + expect(result.lock_timeout).toBe("5s"); + }); + + test("honors a custom lockTimeout override", async ({ + pgslice, + transaction, + }) => { + await pgslice.addPartitions(transaction, { + table: "posts", + future: 1, + lockTimeout: "10s", + }); + + const result = await transaction.one( + sql.type(z.object({ lock_timeout: z.string() }))` + SHOW lock_timeout + `, + ); + expect(result.lock_timeout).toBe("10s"); + }); + test("new partitions inherit the parent composite key and gain no duplicate PK", async ({ pgslice, transaction, diff --git a/src/commands/add-partitions.ts b/src/commands/add-partitions.ts index aa9e430..2560f6d 100644 --- a/src/commands/add-partitions.ts +++ b/src/commands/add-partitions.ts @@ -50,6 +50,10 @@ export class AddPartitionsCommand extends BaseCommand { description: "Copy the parent table's privileges onto each new partition (default: true; disable with --no-inherit-grants)", }); + lockTimeout = Option.String("--lock-timeout", "5s", { + description: + "How long each partition-creation statement waits on the parent's lock before backing off (default: 5s)", + }); override async perform(pgslice: Pgslice): Promise { const created = await pgslice.start(async (tx) => @@ -60,6 +64,7 @@ export class AddPartitionsCommand extends BaseCommand { future: this.future, tablespace: this.tablespace, inheritGrants: this.inheritGrants, + lockTimeout: this.lockTimeout, }), ); diff --git a/src/commands/maintain.ts b/src/commands/maintain.ts index d36f7e0..5f5b5dc 100644 --- a/src/commands/maintain.ts +++ b/src/commands/maintain.ts @@ -73,6 +73,10 @@ export class MaintainCommand extends BaseCommand { description: "Copy each parent table's privileges onto its new partitions (default: true; disable with --no-inherit-grants)", }); + lockTimeout = Option.String("--lock-timeout", "5s", { + description: + "How long each partition-creation statement waits on a table's lock before backing off (default: 5s)", + }); override async perform(pgslice: Pgslice): Promise { // maintain emits structured records (start, per-table, final); write each as @@ -98,6 +102,7 @@ export class MaintainCommand extends BaseCommand { schema: this.schema, tablespace: this.tablespace, inheritGrants: this.inheritGrants, + lockTimeout: this.lockTimeout, }, log, ), diff --git a/src/maintain.test.ts b/src/maintain.test.ts index 5c25f92..7bfbf1b 100644 --- a/src/maintain.test.ts +++ b/src/maintain.test.ts @@ -401,6 +401,45 @@ describe("Pgslice.maintain (fleet)", () => { ); expect(grants.map((g) => g.privilege)).toContain("SELECT"); }); + + test("forwards a custom lockTimeout to the add_partitions transaction", async ({ + pgslice, + transaction, + }) => { + await nativeParent( + transaction, + "posts", + "created_at", + TS, + "month", + "date", + ); + await addChild( + transaction, + "posts", + "posts_y2026m01", + "2026-01-01", + "2026-02-01", + ); + + await pgslice.maintain(transaction, { + futureDaily: 1, + futureWeekly: 1, + futureMonthly: 1, + futureYearly: 1, + lockTimeout: "10s", + }); + + // addPartitions runs in a savepoint whose released SET LOCAL propagates to + // this transaction, so a "10s" reading here proves maintain forwarded the + // override all the way through to the partition-creation statements. + const result = await transaction.one( + sql.type(z.object({ lock_timeout: z.string() }))` + SHOW lock_timeout + `, + ); + expect(result.lock_timeout).toBe("10s"); + }); }); describe("classic pgslice tables", () => { diff --git a/src/pgslice.ts b/src/pgslice.ts index c363aab..2139d4e 100644 --- a/src/pgslice.ts +++ b/src/pgslice.ts @@ -320,6 +320,17 @@ export class Pgslice { // partitions against the existing ones under a non-UTC session. await tx.query(sql.typeAlias("void")`SET LOCAL TIME ZONE 'UTC'`); + // Bound how long the CREATE ... PARTITION OF statements may wait on the + // parent's lock, so a scheduled run backs off instead of blocking + // writers queued behind it. On timeout the statement errors, the + // transaction rolls back, and the table is reported as a failed + // extension to be retried on the next run. + await tx.query( + sql.typeAlias( + "void", + )`SET LOCAL lock_timeout = ${sql.literalValue(options.lockTimeout ?? "5s")}`, + ); + const targetTable = options.intermediate ? originalTable.intermediate : originalTable; @@ -588,6 +599,7 @@ export class Pgslice { future: futureByPeriod[period], tablespace: options.tablespace, inheritGrants: options.inheritGrants, + lockTimeout: options.lockTimeout, now, }); diff --git a/src/types.ts b/src/types.ts index 8872461..4f2bf0d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -78,6 +78,12 @@ export interface AddPartitionsOptions { * period boundary uses a consistent horizon for every table. */ now?: Date; + /** + * How long each partition-creation statement may wait on the parent's lock + * before backing off, rather than blocking writers queued behind it. Any + * Postgres interval literal (e.g. "5s"). Defaults to "5s". + */ + lockTimeout?: string; } import type { MirroringTargetType } from "./mirroring.js"; @@ -256,6 +262,12 @@ export interface MaintainOptions { schema?: string; tablespace?: string; inheritGrants?: boolean; + /** + * How long each partition-creation statement may wait on a table's lock + * before backing off, rather than blocking writers queued behind it. Any + * Postgres interval literal (e.g. "5s"). Defaults to "5s". + */ + lockTimeout?: string; /** * The reference "now" for choosing each table's partition horizon. Defaults * to a single instant captured when the run starts, so a fleet run that