Skip to content

Commit 26baa67

Browse files
feat: add a ReplicationSlot custom resource
1 parent 45ded21 commit 26baa67

8 files changed

Lines changed: 653 additions & 2 deletions

File tree

‎cdk-postgresql/lib/handler.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { VError } from "verror";
44
import { handler as dbHandler } from "./database.handler";
55
import { handler as roleHandler } from "./role.handler";
66
import { handler as roleMembershipHandler } from "./role-membership.handler";
7+
import { handler as replicationSlotHandler } from "./replication-slot.handler";
78

89
export const handler = async (event: CloudFormationCustomResourceEvent) => {
910
switch (event.ResourceType) {
@@ -13,6 +14,8 @@ export const handler = async (event: CloudFormationCustomResourceEvent) => {
1314
return dbHandler(event);
1415
case "Custom::Postgresql-RoleMembership":
1516
return roleMembershipHandler(event);
17+
case "Custom::Postgresql-ReplicationSlot":
18+
return replicationSlotHandler(event);
1619
default:
1720
throw new VError(`unexpected ResourceType: ${event.ResourceType}`);
1821
}

‎cdk-postgresql/lib/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from "./database";
22
export * from "./role";
33
export * from "./role-membership";
4+
export * from "./replication-slot";
45
export * from "./provider";

‎cdk-postgresql/lib/postgres.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,34 @@ export const revokeRoleMembership = async (props: {
105105
console.warn(thrown.message);
106106
}
107107
};
108+
109+
export const createReplicationSlot = async (props: {
110+
client: Client;
111+
name: string;
112+
plugin: string;
113+
}) => {
114+
const { client, name, plugin } = props;
115+
116+
await client.query("SELECT pg_create_logical_replication_slot($1, $2)", [
117+
name,
118+
plugin,
119+
]);
120+
};
121+
122+
/**
123+
* Dropping is scoped to the connected database. A slot name is unique across
124+
* the whole cluster, so an unscoped drop would destroy a slot decoding another
125+
* database, and a dropped slot loses its replication position permanently.
126+
* A slot that is already gone is left alone.
127+
*/
128+
export const dropReplicationSlot = async (props: {
129+
client: Client;
130+
name: string;
131+
}) => {
132+
const { client, name } = props;
133+
134+
await client.query(
135+
"SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots WHERE slot_name = $1 AND database = current_database()",
136+
[name]
137+
);
138+
};
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import {
2+
CloudFormationCustomResourceEvent,
3+
CloudFormationCustomResourceCreateEvent,
4+
CloudFormationCustomResourceUpdateEvent,
5+
CloudFormationCustomResourceDeleteEvent,
6+
} from "aws-lambda/trigger/cloudformation-custom-resource";
7+
8+
import { validateConnection, hashCode, getConnectedClient } from "./util";
9+
import { Connection } from "./lambda.types";
10+
import * as postgres from "./postgres";
11+
12+
interface Props {
13+
ServiceToken: string;
14+
Connection: Connection;
15+
Name: string;
16+
Plugin: string;
17+
RequiredPublication?: string;
18+
}
19+
20+
const SLOT_PROVENANCES = ["created", "adopted"] as const;
21+
22+
/**
23+
* Whether this resource created the slot or adopted one that already existed.
24+
* It is recorded in the physical id, which CloudFormation hands back verbatim
25+
* on delete, so only a slot this resource created is ever dropped.
26+
*/
27+
type SlotProvenance = (typeof SLOT_PROVENANCES)[number];
28+
29+
export const handler = async (event: CloudFormationCustomResourceEvent) => {
30+
switch (event.RequestType) {
31+
case "Create":
32+
return handleCreate(event);
33+
case "Update":
34+
return handleUpdate(event);
35+
case "Delete":
36+
return handleDelete(event);
37+
}
38+
};
39+
40+
const handleCreate = async (event: CloudFormationCustomResourceCreateEvent) => {
41+
const props = event.ResourceProperties as Props;
42+
validateProps(props);
43+
const provenance = await createSlotIfAbsent(props);
44+
return {
45+
PhysicalResourceId: buildPhysicalId({
46+
identity: deriveSlotIdentity(props),
47+
provenance,
48+
}),
49+
};
50+
};
51+
52+
/**
53+
* A slot cannot be altered in place: the database it decodes and its name are
54+
* its identity, and its plugin is fixed at creation. A change to either half of
55+
* that identity reports a new physical id, so CloudFormation creates the new
56+
* slot and then deletes the old resource. A plugin change under an unchanged
57+
* identity is refused, because the existing slot would silently keep its old
58+
* plugin. An unchanged identity keeps the physical id it was given, so a slot
59+
* adopted at creation is never recorded as created.
60+
*/
61+
const handleUpdate = async (event: CloudFormationCustomResourceUpdateEvent) => {
62+
const props = event.ResourceProperties as Props;
63+
validateProps(props);
64+
65+
const oldProps = event.OldResourceProperties as Props;
66+
const identity = deriveSlotIdentity(props);
67+
const { identity: currentIdentity } = parsePhysicalId(
68+
event.PhysicalResourceId
69+
);
70+
71+
if (identity != currentIdentity) {
72+
const provenance = await createSlotIfAbsent(props);
73+
return { PhysicalResourceId: buildPhysicalId({ identity, provenance }) };
74+
}
75+
76+
if (props.Plugin != oldProps.Plugin) {
77+
throw new Error(
78+
`The plugin of replication slot "${props.Name}" cannot change in place; drop and recreate the slot deliberately instead`
79+
);
80+
}
81+
82+
return { PhysicalResourceId: event.PhysicalResourceId };
83+
};
84+
85+
/**
86+
* Only a slot the physical id records this resource as having created is
87+
* dropped. A slot that was adopted, or one whose physical id carries no
88+
* provenance, may belong to someone else, and a dropped slot loses its
89+
* replication position permanently.
90+
*/
91+
const handleDelete = async (event: CloudFormationCustomResourceDeleteEvent) => {
92+
const props = event.ResourceProperties as Props;
93+
validateProps(props);
94+
95+
const { provenance } = parsePhysicalId(event.PhysicalResourceId);
96+
if (provenance != "created") {
97+
console.log(
98+
"Not dropping replication slot, this resource did not create it",
99+
props.Name,
100+
provenance
101+
);
102+
return {};
103+
}
104+
105+
console.log("Dropping replication slot", props.Name);
106+
const client = await getConnectedClient(props.Connection);
107+
108+
try {
109+
await postgres.dropReplicationSlot({ client, name: props.Name });
110+
} finally {
111+
await client.end();
112+
}
113+
114+
return {};
115+
};
116+
117+
const validateProps = (props: Props) => {
118+
if (!("Connection" in props)) {
119+
throw "Connection property is required";
120+
}
121+
validateConnection(props.Connection);
122+
123+
if (!("Name" in props)) {
124+
throw "Name property is required";
125+
}
126+
if (!("Plugin" in props)) {
127+
throw "Plugin property is required";
128+
}
129+
};
130+
131+
const deriveSlotIdentity = (props: Props): string => {
132+
const { Host, Port, Database } = props.Connection;
133+
const suffix = Math.abs(
134+
hashCode(`${Host}-${Port}-${Database}-${props.Name}`)
135+
);
136+
return `replication-slot-${suffix}`;
137+
};
138+
139+
const buildPhysicalId = (params: {
140+
identity: string;
141+
provenance: SlotProvenance;
142+
}): string => `${params.identity}-${params.provenance}`;
143+
144+
/**
145+
* The one place that knows the shape of a physical id. An id carrying no
146+
* provenance suffix comes from the rollback of a failed create, and the guards
147+
* in the create path throw exactly when a slot this resource never created
148+
* already exists, so such an id reports "unknown" rather than "created".
149+
*/
150+
const parsePhysicalId = (
151+
physicalResourceId: string
152+
): { identity: string; provenance: SlotProvenance | "unknown" } => {
153+
for (const provenance of SLOT_PROVENANCES) {
154+
const suffix = `-${provenance}`;
155+
if (physicalResourceId.endsWith(suffix)) {
156+
return {
157+
identity: physicalResourceId.slice(0, -suffix.length),
158+
provenance,
159+
};
160+
}
161+
}
162+
163+
return { identity: physicalResourceId, provenance: "unknown" };
164+
};
165+
166+
const createSlotIfAbsent = async (props: Props): Promise<SlotProvenance> => {
167+
const client = await getConnectedClient(props.Connection);
168+
169+
try {
170+
if (props.RequiredPublication) {
171+
const { rows } = await client.query(
172+
"SELECT 1 FROM pg_publication WHERE pubname = $1",
173+
[props.RequiredPublication]
174+
);
175+
if (rows.length === 0) {
176+
// A slot created before its publication decodes from a catalog snapshot
177+
// in which the publication does not exist, and replication then fails
178+
// continuously, so refusing here turns a subtle runtime failure into a
179+
// loud deploy failure:
180+
throw new Error(
181+
`publication "${props.RequiredPublication}" does not exist in database "${props.Connection.Database}"; deploy the migration that creates it before this slot`
182+
);
183+
}
184+
}
185+
186+
// `pg_replication_slots` lists the whole cluster, so a slot of the same
187+
// name decoding another database must not be adopted as this one; letting
188+
// Postgres raise "replication slot already exists" is the loud failure:
189+
const { rows: existingSlots } = await client.query(
190+
"SELECT plugin FROM pg_replication_slots WHERE slot_name = $1 AND database = current_database()",
191+
[props.Name]
192+
);
193+
const [existingSlot] = existingSlots;
194+
195+
if (!existingSlot) {
196+
console.log("Creating replication slot", props.Name);
197+
await postgres.createReplicationSlot({
198+
client,
199+
name: props.Name,
200+
plugin: props.Plugin,
201+
});
202+
return "created";
203+
}
204+
205+
if (existingSlot.plugin !== props.Plugin) {
206+
throw new Error(
207+
`replication slot "${props.Name}" already exists in database "${props.Connection.Database}" with plugin "${existingSlot.plugin}" instead of "${props.Plugin}"; a slot's plugin is fixed at creation, so drop and recreate the slot deliberately instead`
208+
);
209+
}
210+
211+
console.log("Replication slot already exists", props.Name);
212+
return "adopted";
213+
} finally {
214+
await client.end();
215+
}
216+
};
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { Construct } from "constructs";
2+
import * as cdk from "aws-cdk-lib";
3+
import { RemovalPolicy } from "aws-cdk-lib";
4+
import { Provider } from "./provider";
5+
6+
export interface ReplicationSlotProps {
7+
/**
8+
* Provider required to connect to the Postgresql server. Logical slots decode
9+
* one database, the one the provider's `database` prop names.
10+
*/
11+
provider: Provider;
12+
13+
/**
14+
* The name of the slot. Must be unique on the PostgreSQL server instance
15+
* where it is configured.
16+
*/
17+
name: string;
18+
19+
/**
20+
* The logical decoding plugin the slot is created with. It cannot change
21+
* after creation.
22+
*
23+
* @default - "pgoutput"
24+
*/
25+
plugin?: string;
26+
27+
/**
28+
* A publication that has to exist before the slot is created. A slot created
29+
* earlier decodes from a catalog snapshot in which the publication does not
30+
* exist, and replication fails continuously, so creation is refused until
31+
* the publication is there.
32+
*/
33+
requiredPublication?: string;
34+
35+
/**
36+
* Policy to apply when the slot is removed from this stack. A dropped slot
37+
* loses its position permanently, so stacks that replicate production data
38+
* should retain it.
39+
*
40+
* @default - The slot will be dropped.
41+
*/
42+
removalPolicy?: RemovalPolicy;
43+
}
44+
45+
/**
46+
* A PostgreSQL logical replication slot. The database retains write-ahead-log
47+
* data until the slot's consumer confirms having read it, which is what makes
48+
* replication resumable after the consumer restarts.
49+
*
50+
* A slot that already exists on the same database with the same plugin is
51+
* adopted and managed in place, and removing the resource only ever drops a
52+
* slot this resource itself created.
53+
*/
54+
export class ReplicationSlot extends Construct {
55+
constructor(scope: Construct, id: string, props: ReplicationSlotProps) {
56+
super(scope, id);
57+
58+
const { provider, name, plugin, requiredPublication, removalPolicy } = props;
59+
60+
const cr = new cdk.CustomResource(this, "CustomResource", {
61+
serviceToken: provider.serviceToken,
62+
resourceType: "Custom::Postgresql-ReplicationSlot",
63+
properties: {
64+
connection: provider.buildConnectionProperty(),
65+
name,
66+
plugin: plugin ?? "pgoutput",
67+
requiredPublication,
68+
},
69+
pascalCaseProperties: true,
70+
});
71+
72+
cr.applyRemovalPolicy(removalPolicy || cdk.RemovalPolicy.DESTROY);
73+
cr.node.addDependency(provider);
74+
}
75+
}

‎cdk-postgresql/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@botpress/cdk-postgresql",
3-
"version": "3.0.0",
3+
"version": "3.1.0",
44
"description": "Postgresql constructs for AWS CDK",
55
"main": "./dist/index.cjs",
66
"types": "./dist/index.d.cts",

‎cdk-postgresql/test/helpers.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,14 @@ export const isMemberOf = async (props: {
8181
);
8282
return rows[0].is_member;
8383
};
84+
85+
export const replicationSlotExists = async (
86+
client: Client,
87+
name: string
88+
): Promise<boolean> => {
89+
const { rows } = await client.query(
90+
"SELECT 1 FROM pg_replication_slots WHERE slot_name = $1 AND database = current_database()",
91+
[name]
92+
);
93+
return rows.length > 0;
94+
};

0 commit comments

Comments
 (0)