Skip to content

feat: support LISTEN/NOTIFY - #512

Open
thuongtamduy wants to merge 1 commit into
denodrivers:mainfrom
thuongtamduy:feat/listen-notify
Open

thuongtamduy wants to merge 1 commit into
denodrivers:mainfrom
thuongtamduy:feat/listen-notify

Conversation

@thuongtamduy

Copy link
Copy Markdown

feat: support LISTEN/NOTIFY

Closes #39

Problem

The driver does not handle NotificationResponse ('A') messages. Once a
session has run LISTEN, any notification sent to it breaks the connection:

const a = new Client(config);
const b = new Client(config);
await a.connect();
await b.connect();

await a.queryArray("LISTEN jobs");
await b.queryArray("SELECT pg_notify('jobs', '')");
await new Promise((r) => setTimeout(r, 100));

await a.queryArray("SELECT 1");
// Error: Unexpected simple query message: A
// ("Unexpected prepared query message: A" for queries with arguments)
await a.queryArray("SELECT 2");
// the leftover response of `SELECT 1` is still in the socket, so the
// connection is out of sync from here on

There is also no way to receive notifications: the socket is only read while a
query is running.

Changes

  • NotificationResponse is parsed (parseNotificationMessage) and handled in
    both the simple and the extended query loops. A notification received in the
    middle of a query is passed to the listeners and does not affect the query
    result. This alone fixes the out-of-sync connection above.
  • While at least one listener is registered and no query is running, the
    connection keeps one message read in progress, so notifications are received
    while the client is idle. A query takes that read over right after acquiring
    the query lock and before writing anything, so there is only ever one reader
    of the socket and the response can't be consumed by the idle reader. Idle
    messages that are not notifications, notices or parameter statuses (e.g. a
    FATAL error when the backend is terminated) close the connection, like any
    other connection loss.
  • Once the last listener is removed, UNLISTEN runs as a normal query (taking
    over the idle read) and no new read is started, so the process can exit
    without calling end().
  • Channels are quoted as identifiers (" doubled), never concatenated as raw
    SQL.
  • After a reconnection, the registered channels are listened to again before
    the next query runs. Client.end() drops all listeners.
  • Listeners run in a microtask, so an exception thrown by a listener can't
    break the message loop.

API

Available on Client only. Pool clients are shared, so a dedicated client
should be used for listening.

import { Client, type Notification } from "jsr:@db/postgres";

const client = new Client();
await client.connect();

const listener = ({ channel, payload, processId }: Notification) => {
  console.log(`${processId} sent "${payload}" to ${channel}`);
};
await client.listen("jobs", listener); // runs LISTEN "jobs" for the first listener

// ...

await client.unlisten("jobs", listener); // or client.unlisten("jobs") to remove all
await client.end();

New exported types: Notification ({ channel, payload, processId }) and
NotificationListener. listen / unlisten throw if the client is locked by a
transaction, the same as queryArray / queryObject. The channel name is used
literally (like a quoted identifier), so it has to match the name passed to
pg_notify exactly. The docs (docs/README.md) have a new "Notifications"
section.

Tests

New tests/notification_test.ts (11 tests):

  • a notification received while idle doesn't break the next simple / prepared
    query, or the one after it. These two tests use plain LISTEN and fail on
    main with the errors above.
  • notifications are received while the client is idle
  • a notification received during a query (pg_sleep) doesn't change its result
    or the next query's result
  • several channels and several listeners per channel
  • unlisten of one listener and of a whole channel (checked with
    pg_listening_channels())
  • unusual channel names (quotes, ;, non-ASCII), Unicode and empty payloads,
    empty channel rejected
  • NOTIFY in a transaction is only delivered after commit
  • listen is rejected while a transaction holds the client
  • channels are listened to again after the backend is terminated
    (pg_terminate_backend) and the client reconnects
  • end() drops the listeners

Results with docker compose run tests / no_check_tests / doc_tests:

  • main: 243 passed, 3 failed
  • this branch: 254 passed, 3 failed. The same 3 tests fail on main: the
    Handles parameter status ... tests use the HongKong time zone, which the
    current postgres:14 image no longer accepts.
  • doc tests: 47 passed
  • deno fmt --check and deno lint pass (Deno 2.2.11, the version in the
    Dockerfile)

The new tests also pass against PostgreSQL 18.

The connection now handles NotificationResponse messages in every state.
Before, a notification received by a session that ran `LISTEN` made the
next query throw "Unexpected simple query message: A" (or "prepared query")
and left the rest of the response in the socket, so the following query
received the result of the previous one.

Notifications received during a query are dispatched to the listeners
without affecting its result. While listeners are registered, the
connection keeps a read in progress when no query is running so
notifications are received while idle; a query that starts takes that read
over before writing, so there is a single reader of the socket at any time.

Adds `Client.listen(channel, listener)` and `Client.unlisten(channel,
listener?)`. Channel names are quoted as identifiers. Channels are listened
to again after a reconnection and listeners are dropped on `end()`.

Closes denodrivers#39

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LISTEN/NOTIFY support

1 participant