Skip to content
Draft
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
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -344,9 +344,38 @@ If the partitioned parent owns a primary key (including a composite key), Postgr

By default, pgslice re-issues the parent table's grants on each new partition, because Postgres does not cascade a parent's grants to its partitions. This keeps a replication/CDC role's access intact as new partitions appear. Disable it with `--no-inherit-grants`.

## Dropping Expired Partitions

Give a table a retention by adding `retention:<N>` to its settings comment. The current period plus the `N` periods before it are kept; older partitions are expired.

```sql
COMMENT ON TABLE <table> IS 'column:created_at,period:month,cast:date,version:3,retention:3';
```

Preview, then drop, the expired partitions:

```sh
pgslice drop_partitions <table> --dry-run
pgslice drop_partitions <table>
```

Or prune every managed table that has a retention as part of `maintain` (off by default):

```sh
pgslice maintain --drop-expired
```

Partitions are selected **by bounds**, not by name: a partition is expired when its range ends at or before the start of the period `N` periods ago. `DEFAULT` and `MINVALUE`/`MAXVALUE` partitions, and sub-partitioned children, are never dropped. Tables without a `retention` are never pruned, and an invalid value (anything but a positive integer) is ignored.

Dropping a partition takes an `ACCESS EXCLUSIVE` lock on the parent, so queries against the table queue behind a drop that is waiting for its lock. To keep that short, each partition is dropped oldest-first in its own transaction under `--lock-timeout` (default: `5s`). If the lock isn't acquired in time, the run stops without dropping anything further — leaving the retained partitions contiguous — and the rest is retried on the next run. Nothing is dropped with `CASCADE`, so a partition that a view or other object depends on fails loudly instead.

### Logical replication

`DROP TABLE` is not decoded by logical replication: no `DELETE`s are emitted, replication slots are unaffected, and changes made to a partition before it was dropped are still delivered. Downstream copies built from logical replication therefore keep the dropped rows. If a downstream system is your archive, confirm it has the data before a partition expires, and note that re-syncing it from the source later won't bring dropped data back.

## Archiving Partitions

Back up and drop older partitions each day, month, or year.
To keep a copy outside the database, back up older partitions before they expire.

```sh
pg_dump -c -Fc -t <table>_202409 $PGSLICE_URL > <table>_202409.dump
Expand Down
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AddPartitionsCommand } from "./commands/add-partitions.js";
import { AnalyzeCommand } from "./commands/analyze.js";
import { DisableMirroringCommand } from "./commands/disable-mirroring.js";
import { DisableRetiredMirroringCommand } from "./commands/disable-retired-mirroring.js";
import { DropPartitionsCommand } from "./commands/drop-partitions.js";
import { EnableMirroringCommand } from "./commands/enable-mirroring.js";
import { EnableRetiredMirroringCommand } from "./commands/enable-retired-mirroring.js";
import { FillCommand } from "./commands/fill.js";
Expand All @@ -28,6 +29,7 @@ export function createCli(): Cli {
cli.register(AnalyzeCommand);
cli.register(DisableMirroringCommand);
cli.register(DisableRetiredMirroringCommand);
cli.register(DropPartitionsCommand);
cli.register(EnableMirroringCommand);
cli.register(EnableRetiredMirroringCommand);
cli.register(FillCommand);
Expand Down
96 changes: 96 additions & 0 deletions src/commands/drop-partitions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, expect } from "vitest";
import { sql, type DatabaseTransactionConnection } from "slonik";

import { commandTest as test } from "../testing/index.js";
import { DropPartitionsCommand } from "./drop-partitions.js";

describe("DropPartitionsCommand", () => {
test.scoped({ commandClass: ({}, use) => use(DropPartitionsCommand) });

async function createPosts(
transaction: DatabaseTransactionConnection,
comment: string,
) {
await transaction.query(sql.unsafe`
CREATE TABLE posts (
id bigint NOT NULL,
created_at timestamp without time zone NOT NULL,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at)
`);
await transaction.query(
sql.unsafe`COMMENT ON TABLE posts IS ${sql.literalValue(comment)}`,
);
await transaction.query(sql.unsafe`
CREATE TABLE posts_202001 PARTITION OF posts
FOR VALUES FROM ('2020-01-01') TO ('2020-02-01')
`);
}

test("--dry-run lists expired partitions without dropping them", async ({
cli,
commandContext,
transaction,
}) => {
await createPosts(
transaction,
"column:created_at,period:month,cast:date,version:3,retention:3",
);

const exitCode = await cli.run(
["drop_partitions", "posts", "--dry-run"],
commandContext,
);

expect(exitCode).toBe(0);
expect(commandContext.stdout.read()?.toString()).toBe(
"posts: would drop 1 partition(s): posts_202001\n",
);
const exists = await transaction.oneFirst(
sql.unsafe`SELECT to_regclass('posts_202001') IS NOT NULL`,
);
expect(exists).toBe(true);
});

test("drops expired partitions", async ({
cli,
commandContext,
transaction,
}) => {
await createPosts(
transaction,
"column:created_at,period:month,cast:date,version:3,retention:3",
);

const exitCode = await cli.run(
["drop_partitions", "posts"],
commandContext,
);

expect(exitCode).toBe(0);
expect(commandContext.stdout.read()?.toString()).toBe(
"posts: dropped 1 partition(s): posts_202001\n",
);
});

test("fails without a retention setting", async ({
cli,
commandContext,
transaction,
}) => {
await createPosts(
transaction,
"column:created_at,period:month,cast:date,version:3",
);

const exitCode = await cli.run(
["drop_partitions", "posts"],
commandContext,
);

expect(exitCode).toBe(1);
expect(commandContext.stderr.read()?.toString()).toContain(
"No retention configured: public.posts",
);
});
});
56 changes: 56 additions & 0 deletions src/commands/drop-partitions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Command, Option } from "clipanion";

import { BaseCommand } from "./base.js";
import { Pgslice } from "../pgslice.js";

export class DropPartitionsCommand extends BaseCommand {
static override paths = [["drop_partitions"]];

static override usage = Command.Usage({
description: "Drop a partitioned table's expired partitions",
details: `
Drops the partitions that fall entirely outside the table's retention
window. Retention is read from the table's pgslice settings comment as
\`retention:<N>\`: the current period plus the N periods before it are
kept. Tables without a retention setting are never pruned.

DEFAULT and MINVALUE/MAXVALUE partitions are never dropped, and nothing is
dropped with CASCADE. Each partition is dropped oldest-first in its own
short transaction; if a drop cannot acquire the parent's lock within
--lock-timeout, the command stops and the rest is retried on the next run.
`,
examples: [
[
"Preview which partitions would be dropped",
"$0 drop_partitions posts --dry-run",
],
["Drop expired partitions", "$0 drop_partitions posts"],
],
});

table = Option.String({ required: true, name: "table" });
dryRun = Option.Boolean("--dry-run", false, {
description: "List the expired partitions without dropping them",
});
lockTimeout = Option.String("--lock-timeout", "5s", {
description:
"How long each DROP waits on the parent's lock before backing off (default: 5s)",
});

override async perform(pgslice: Pgslice): Promise<void> {
const dropped = await pgslice.start(async (connection) =>
pgslice.dropPartitions(connection, {
table: this.table,
dryRun: this.dryRun,
lockTimeout: this.lockTimeout,
}),
);

const verb = this.dryRun ? "would drop" : "dropped";
this.context.stdout.write(
dropped.length > 0
? `${this.table}: ${verb} ${dropped.length} partition(s): ${dropped.join(", ")}\n`
: `${this.table}: no expired partitions\n`,
);
}
}
60 changes: 58 additions & 2 deletions src/commands/maintain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ interface LogEntry {
level: string;
target: { db: string; host: string; schema?: string; table?: string };
future?: { daily: number; weekly: number; monthly: number; yearly: number };
partitions?: { new: number; total: number };
dropExpired?: boolean;
partitions?: { new: number; dropped: number; total: number };
success?: number;
succeeded?: { count: number; tables: string[] };
failed?: { count: number; tables: string[] };
Expand Down Expand Up @@ -90,6 +91,7 @@ describe("MaintainCommand", () => {
expect(table?.target.schema).toBe("public");
expect(table?.partitions).toEqual({
new: expect.any(Number),
dropped: 0,
total: expect.any(Number),
});

Expand Down Expand Up @@ -180,10 +182,64 @@ describe("MaintainCommand", () => {
expect(table?.msg).toBe("Table already up to date; no extension needed");
expect(table?.level).toBe("info");
expect(table?.success).toBe(1);
expect(table?.partitions).toEqual({ new: 0, total: 1 });
expect(table?.partitions).toEqual({ new: 0, dropped: 0, total: 1 });
expect(table?.target.host).toBe("localhost");
});

async function createExpiredPosts(
transaction: DatabaseTransactionConnection,
) {
await createPosts(transaction);
await transaction.query(sql.unsafe`
COMMENT ON TABLE posts IS 'column:created_at,period:month,cast:date,version:3,retention:1'
`);
await transaction.query(sql.unsafe`
CREATE TABLE posts_y2020m01 PARTITION OF posts
FOR VALUES FROM ('2020-01-01') TO ('2020-02-01')
`);
}

test("does not drop expired partitions by default", async ({
cli,
commandContext,
transaction,
}) => {
await createExpiredPosts(transaction);

expect(await cli.run(["maintain"], commandContext)).toBe(0);

const logs = jsonLines(commandContext.stdout.read()?.toString());
expect(logs[0].dropExpired).toBe(false);
expect(
logs.find((entry) => entry.target.table === "posts")?.partitions?.dropped,
).toBe(0);
});

test("drops expired partitions with --drop-expired", async ({
cli,
commandContext,
transaction,
}) => {
await createExpiredPosts(transaction);

expect(await cli.run(["maintain", "--drop-expired"], commandContext)).toBe(
0,
);

const logs = jsonLines(commandContext.stdout.read()?.toString());
expect(logs[0].dropExpired).toBe(true);
// This suite runs on the real clock, so how many months fall outside the
// one-month window varies; the 2020 partition is always among them.
expect(
logs.find((entry) => entry.target.table === "posts")?.partitions?.dropped,
).toBeGreaterThanOrEqual(1);
expect(
await transaction.oneFirst(
sql.unsafe`SELECT to_regclass('posts_y2020m01') IS NULL`,
),
).toBe(true);
});

test("reads per-period horizons from PGSLICE_FUTURE_* env vars", async ({
cli,
commandContext,
Expand Down
16 changes: 15 additions & 1 deletion src/commands/maintain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ export class MaintainCommand extends BaseCommand {
extending each table this checks that every leaf partition has a replica
identity usable for logical replication, and exits non-zero if any does
not.

With --drop-expired, each table whose settings comment carries a
\`retention:<N>\` also has its expired partitions dropped (see
drop_partitions) after it is extended. Tables without a retention setting
are never pruned.
`,
examples: [
[
Expand All @@ -36,6 +41,10 @@ export class MaintainCommand extends BaseCommand {
"PGSLICE_FUTURE_MONTHLY=12 $0 maintain",
],
["Restrict to a single schema", "$0 maintain --schema analytics"],
[
"Also drop partitions outside each table's retention window",
"$0 maintain --drop-expired",
],
],
});

Expand Down Expand Up @@ -75,7 +84,11 @@ export class MaintainCommand extends BaseCommand {
});
lockTimeout = Option.String("--lock-timeout", "5s", {
description:
"How long each partition-creation statement waits on a table's lock before backing off (default: 5s)",
"How long each partition create or drop waits on a table's lock before backing off (default: 5s)",
});
dropExpired = Option.Boolean("--drop-expired", false, {
description:
"Also drop partitions outside each table's configured retention (default: false)",
});

override async perform(pgslice: Pgslice): Promise<number | void> {
Expand Down Expand Up @@ -103,6 +116,7 @@ export class MaintainCommand extends BaseCommand {
tablespace: this.tablespace,
inheritGrants: this.inheritGrants,
lockTimeout: this.lockTimeout,
dropExpired: this.dropExpired,
},
log,
),
Expand Down
Loading
Loading