Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .changeset/heavy-pandas-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@electric-sql/pglite': patch
---

Support logical replication slots: start Postgres with `wal_level=logical` by default, export the symbols the `pgoutput` output plugin needs from the WASM main module, and surface unexpected WASM crashes as errors instead of silently corrupting the session.
2 changes: 1 addition & 1 deletion docs/docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ Path to the directory for storing the Postgres database. You can provide a URI s
```

- `startParams?: string[]` <br />
An array of strings that will be passed to the Postgres process as start parameters. This is the set of parameters one would pass to a native PostgreSQL instance.
An array of strings that will be passed to the Postgres process as start parameters. This is the set of parameters one would pass to a native PostgreSQL instance. When not provided, `PGlite.defaultStartParams` is used, which starts Postgres with `wal_level=logical` so that logical replication slots (e.g. `pg_create_logical_replication_slot` with the `pgoutput` plugin) work out of the box.

```ts
import { PGlite } from '@electric-sql/pglite'
Expand Down
8 changes: 8 additions & 0 deletions packages/pglite/src/pglite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ export class PGlite
'io_method=sync',
'-c',
'max_parallel_maintenance_workers=0',
'-c',
'wal_level=logical', // enable logical decoding / replication slots

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bump postgres-pglite for pgoutput exports

This enables wal_level=logical and documents logical slot support, but the commit does not include the required wasm-side symbol export fix: git ls-tree shows both this commit and its parent still pin postgres-pglite to 7b4ee5086055dc5e54ae1e13e487888249438e68, and the root wasm:build script builds the release wasm from that submodule. In a clean build of this tree, pgoutput still lacks the exported core symbols described in the commit message, so calls like pg_logical_slot_get_binary_changes(..., 'pgoutput') will still hit the missing-symbol crash instead of delivering changes; please include the submodule bump before advertising/enabling this feature.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — addressed in 6d55f6c: the submodule now points at the head of electric-sql/postgres-pglite#79 (04f1bea), which contains the pgoutput symbol-export fix. That commit is served by the upstream repo via refs/pull/79/head, so git submodule update can fetch it by SHA (verified with a git fetch --depth 1 <upstream> 04f1bea from a fresh clone). A clean wasm:build of this tree now produces a main module that exports all 46 symbols pgoutput.so imports, and the new replication-slot tests pass against it. Once #79 merges, this pointer should be bumped to the merge commit.

]

/**
Expand Down Expand Up @@ -940,6 +942,12 @@ export class PGlite
// that we call whenever the exception longjmp is executed
// like this we also just need to setjmp only once, in a similar fashion to the original code.
mod._PostgresMainLongJmp()
} else if (typeof e?.status !== 'number') {
// not an emscripten ExitStatus: the wasm instance crashed
// (e.g. a trap or a call to a missing symbol) and its internal
// state can no longer be trusted. Swallowing it here would leave
// the session silently corrupted, so surface it to the caller.
throw e
}
// even if there is an exception caused by one of the batched queries,
// we need to continue processing the rest without throwing.
Expand Down
134 changes: 134 additions & 0 deletions packages/pglite/tests/replication-slots.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { PGlite } from '../dist/index.js'

describe('replication slots', () => {
let db: PGlite

beforeEach(async () => {
db = await PGlite.create()
})

afterEach(async () => {
if (!db.closed) {
await db.close()
}
})

it('wal_level defaults to logical', async () => {
const res = await db.query<{ wal_level: string }>(`SHOW wal_level`)
expect(res.rows[0].wal_level).toBe('logical')
})

it('can create, list and drop a logical replication slot', async () => {
const created = await db.query<{ slot_name: string; lsn: string }>(
`SELECT * FROM pg_create_logical_replication_slot('test_slot', 'pgoutput')`,
)
expect(created.rows[0].slot_name).toBe('test_slot')
expect(created.rows[0].lsn).toBeTruthy()

const slots = await db.query<{ slot_name: string; plugin: string }>(
`SELECT slot_name, plugin FROM pg_replication_slots`,
)
expect(slots.rows).toEqual([{ slot_name: 'test_slot', plugin: 'pgoutput' }])

await db.query(`SELECT pg_drop_replication_slot('test_slot')`)
const after = await db.query(`SELECT slot_name FROM pg_replication_slots`)
expect(after.rows).toEqual([])
})

it('decodes changes via pgoutput', async () => {
await db.exec(`
CREATE TABLE rep_test (id int PRIMARY KEY, value text);
CREATE PUBLICATION rep_pub FOR ALL TABLES;
`)
// slot creation is not allowed in a transaction that has performed
// writes, so it can't be part of the exec batch above
await db.query(
`SELECT pg_create_logical_replication_slot('rep_slot', 'pgoutput')`,
)

await db.exec(`
INSERT INTO rep_test VALUES (1, 'one'), (2, 'two');
UPDATE rep_test SET value = 'uno' WHERE id = 1;
DELETE FROM rep_test WHERE id = 2;
`)

// peek first - changes must remain available afterwards
const peeked = await db.query<{ data: Uint8Array }>(
`SELECT data FROM pg_logical_slot_peek_binary_changes(
'rep_slot', NULL, NULL,
'proto_version', '1', 'publication_names', 'rep_pub')`,
)
expect(peeked.rows.length).toBeGreaterThan(0)

// messages start with a tag byte; expect insert (I), update (U) and delete (D)
const tags = peeked.rows.map((r) => String.fromCharCode(r.data[0]))
expect(tags).toContain('I')
expect(tags).toContain('U')
expect(tags).toContain('D')

// consuming advances the slot
const consumed = await db.query(
`SELECT lsn FROM pg_logical_slot_get_binary_changes(
'rep_slot', NULL, NULL,
'proto_version', '1', 'publication_names', 'rep_pub')`,
)
expect(consumed.rows.length).toBe(peeked.rows.length)

const empty = await db.query(
`SELECT lsn FROM pg_logical_slot_get_binary_changes(
'rep_slot', NULL, NULL,
'proto_version', '1', 'publication_names', 'rep_pub')`,
)
expect(empty.rows).toEqual([])
})

it('recovers cleanly from a decoding error', async () => {
await db.query(
`SELECT pg_create_logical_replication_slot('err_slot', 'pgoutput')`,
)

// pgoutput requires publication_names; this must fail with a proper error
await expect(
db.query(
`SELECT * FROM pg_logical_slot_get_binary_changes('err_slot', NULL, NULL, 'proto_version', '1')`,
),
).rejects.toThrow(/publication_names/)

// and the session must still be usable afterwards
const res = await db.query<{ one: number }>(`SELECT 1 AS one`)
expect(res.rows[0].one).toBe(1)

const slots = await db.query<{ slot_name: string; active: boolean }>(
`SELECT slot_name, active FROM pg_replication_slots`,
)
expect(slots.rows).toEqual([{ slot_name: 'err_slot', active: false }])
})

it('slots survive a dump/load cycle', async () => {
await db.exec(`
CREATE TABLE rep_persist (id int PRIMARY KEY);
CREATE PUBLICATION persist_pub FOR ALL TABLES;
`)
await db.query(
`SELECT pg_create_logical_replication_slot('persist_slot', 'pgoutput')`,
)
const dump = await db.dumpDataDir('none')
await db.close()

db = await PGlite.create({ loadDataDir: dump })
const slots = await db.query<{ slot_name: string }>(
`SELECT slot_name FROM pg_replication_slots`,
)
expect(slots.rows).toEqual([{ slot_name: 'persist_slot' }])

// the restored slot must still be usable
await db.exec(`INSERT INTO rep_persist VALUES (1)`)
const changes = await db.query(
`SELECT lsn FROM pg_logical_slot_get_binary_changes(
'persist_slot', NULL, NULL,
'proto_version', '1', 'publication_names', 'persist_pub')`,
)
expect(changes.rows.length).toBeGreaterThan(0)
})
})