Core Concepts
Notifications
Pyranid provides a small, database-neutral API for named notifications. PostgreSQL LISTEN/NOTIFY is the only currently supported implementation. The public API deliberately contains no PostgreSQL or JDBC-driver types; Pyranid might support other database notification systems in the future.
Notifications are just nice-to-haves
Database notifications are transient, lossy hints. Use them to wake code that reconciles authoritative database state. Do not treat them as a durable event log, exact event counter, change-data-capture stream, or work queue.
For a complete long-running listener with implemented reconciliation, reconnects, and shutdown, see Supervising A Listener.
Example Setup
The sending, manual-listener, and supervisor examples below all use this small feature-flag cache. Create and seed its table in PostgreSQL first:
CREATE TABLE feature_flag (
name TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL
);
INSERT INTO feature_flag (name, enabled)
VALUES ('new_checkout', FALSE);
CREATE TABLE feature_flag (
name TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL
);
INSERT INTO feature_flag (name, enabled)
VALUES ('new_checkout', FALSE);
Use a session-affine source with capacity for at least two simultaneous connections: the listener holds one, and cache refreshes borrow another. See Choosing The Listener Database if ordinary traffic uses a transaction-pooled source.
The example defines a public record for those rows:
public record FeatureFlag(String name, Boolean enabled) {}
public record FeatureFlag(String name, Boolean enabled) {}
The downloadable examples declare this record inside their public classes so the default InstanceProvider can construct it. Each example's run(...) method accepts your configured data source and creates the database, cache, and reconciliation operation:
Database database = Database.with(dataSource, DatabaseType.POSTGRESQL)
.build();
AtomicReference<Map<String, Boolean>> flags =
new AtomicReference<>(Map.of());
Runnable reloadFlags = () -> {
Map<String, Boolean> snapshot = database.query("""
SELECT name, enabled
FROM feature_flag
""")
.queryTimeout(Duration.ofSeconds(5))
.fetchList(FeatureFlag.class)
.stream()
.collect(Collectors.toUnmodifiableMap(
FeatureFlag::name, FeatureFlag::enabled));
flags.set(snapshot);
System.out.println("Refreshed feature flags: " + snapshot);
};
Database database = Database.with(dataSource, DatabaseType.POSTGRESQL)
.build();
AtomicReference<Map<String, Boolean>> flags =
new AtomicReference<>(Map.of());
Runnable reloadFlags = () -> {
Map<String, Boolean> snapshot = database.query("""
SELECT name, enabled
FROM feature_flag
""")
.queryTimeout(Duration.ofSeconds(5))
.fetchList(FeatureFlag.class)
.stream()
.collect(Collectors.toUnmodifiableMap(
FeatureFlag::name, FeatureFlag::enabled));
flags.set(snapshot);
System.out.println("Refreshed feature flags: " + snapshot);
};
AtomicReference publishes a complete immutable Map, so readers never see a partially rebuilt cache. The cache changes only after the query and conversion succeed; a failed refresh leaves the previous snapshot available. This example assumes the whole reference table fits comfortably in memory. The queryTimeout(...) method asks the driver to limit each refresh query to five seconds.
Sending Notifications
Use Database::sendNotification(...) to send a named notification with an optional string payload:
// Send a hint without a payload.
database.sendNotification("feature_flag_changed");
// Include the changed record's identifier when it helps reconciliation.
database.sendNotification("feature_flag_changed", "new_checkout");
// Send a hint without a payload.
database.sendNotification("feature_flag_changed");
// Include the changed record's identifier when it helps reconciliation.
database.sendNotification("feature_flag_changed", "new_checkout");
The one-argument overload is equivalent to passing null to the two-argument overload, which also accepts "" or a nonempty string. Pyranid does not normalize payloads generically: Notification::getPayload() is nullable, and each database determines how null and empty payloads are represented. PostgreSQL converts a null payload to "", so PostgreSQL receivers observe an empty string for null, omitted, and explicitly empty payloads. Portable code should not assume that null and empty payloads remain distinct.
When a durable change and its notification should commit together, perform both through the same Database::transaction(...) call:
// Commit the durable change and its notification atomically.
database.transaction(() -> {
database.query("""
UPDATE feature_flag
SET enabled = :enabled
WHERE name = :name
""")
.bind("enabled", true)
.bind("name", "new_checkout")
.execute();
// PostgreSQL delivers this only if the transaction commits.
database.sendNotification("feature_flag_changed", "new_checkout");
});
// Commit the durable change and its notification atomically.
database.transaction(() -> {
database.query("""
UPDATE feature_flag
SET enabled = :enabled
WHERE name = :name
""")
.bind("enabled", true)
.bind("name", "new_checkout")
.execute();
// PostgreSQL delivers this only if the transaction commits.
database.sendNotification("feature_flag_changed", "new_checkout");
});
The backend determines the precise delivery point. PostgreSQL publishes the notification after commit and discards it on rollback.
Notification payloads should contain only enough information to locate or version durable state. Treat them as untrusted input and avoid secrets. Sends use normal Pyranid statement logging, parameter redaction, timeout, and metrics behavior.
Receiving Notifications
Database::withNotificationSession(...) opens one callback-scoped listener session for either one channel or a fixed set. This loop uses the implemented reloadFlags operation from Example Setup. Download the complete manual-listener example, including imports, setup, and the receive loop. Call its FeatureFlagSession.run(dataSource) method on a thread your application owns; interrupt that thread to request a stop:
try {
database.withNotificationSession(
Set.of("feature_flag_changed", "feature_flag_removed"),
session -> {
// Registration has finished. Load the current durable state.
reloadFlags.run();
while (true) {
List<Notification> hints =
session.awaitNotifications(Duration.ofSeconds(30));
boolean stopRequested = Thread.interrupted();
try {
if (!hints.isEmpty())
reloadFlags.run();
} finally {
if (stopRequested)
Thread.currentThread().interrupt();
}
}
}
);
} catch (InterruptedException interruptedException) {
// Preserve the cancelation signal for the caller.
Thread.currentThread().interrupt();
}
try {
database.withNotificationSession(
Set.of("feature_flag_changed", "feature_flag_removed"),
session -> {
// Registration has finished. Load the current durable state.
reloadFlags.run();
while (true) {
List<Notification> hints =
session.awaitNotifications(Duration.ofSeconds(30));
boolean stopRequested = Thread.interrupted();
try {
if (!hints.isEmpty())
reloadFlags.run();
} finally {
if (stopRequested)
Thread.currentThread().interrupt();
}
}
}
);
} catch (InterruptedException interruptedException) {
// Preserve the cancelation signal for the caller.
Thread.currentThread().interrupt();
}
The callback begins only after every requested channel has been registered. Each nonempty batch reloads the table once, including when several notifications have been coalesced. Performing the initial reconciliation inside the callback closes the setup gap: notifications committed while that reconciliation runs remain available to a later receive.
The Thread.interrupted() call reads and clears the interrupt flag, allowing a batch returned alongside an interrupt to trigger one final cache reload. The finally block restores the flag afterward. The next awaitNotifications(...) call then throws InterruptedException, ending the loop; the outer catch restores the flag before returning. See Interruption And Supervision for later interrupt races and driver I/O limits.
One withNotificationSession(...) invocation:
- runs synchronously on the calling thread;
- acquires at most one listener connection from that
Databaseinstance's configuredDataSource; - invokes the callback at most once;
- never replaces or reconnects the listener connection;
- expires the supplied
NotificationSessionwhen the callback exits; and - completes listener cleanup before returning or throwing.
The withNotificationSession(...) method does not create a listener thread, executor, timer, scheduler, or reconnect task. Your application owns its calling thread and retry policy. The NotificationSupervisor manages workers, repeated sessions, and independent polling, as shown below.
Durable Progress Without A Notification
Callback-entry reconciliation closes the listener-registration gap, but it cannot recover every publication gap while one listener remains active. For example, your application can crash between committing durable work and sending a best-effort notification in a separate step. The notification is also inherently lossy after publication.
When notifications provide lower latency, keep an authoritative poll independent of the listener thread. Do not run the only periodic poll after awaitNotifications(...) returns: a receive that is stuck inside driver protocol work cannot reach it. NotificationSupervisor.Builder.pollEvery(...) provides this independent path. It requests an initial poll immediately and shares a serialized reconciliation worker with notification hints. The complete Supervising A Listener example below implements the cache reload, reconnect policy, and shutdown sequence. A terminal listener failure stops the entire supervisor, including polling. Applications using callback-scoped sessions directly still need to manage their own independent polling and coordination.
An outbox with persistent relay retries can close the commit-to-publication crash gap, but consumers must still reconcile durable state because notification delivery itself is not durable.
Waiting And Draining
NotificationSession::awaitNotifications(...) waits for a nonempty batch until its best-effort elapsed-time budget expires:
// Wait up to 30 seconds for at least one notification.
List<Notification> notifications =
session.awaitNotifications(Duration.ofSeconds(30));
// Wait up to 30 seconds for at least one notification.
List<Notification> notifications =
session.awaitNotifications(Duration.ofSeconds(30));
An empty result means only that no notification was observed before that call's final check. It does not prove that a quiet connection is still healthy.
NotificationSession::drainNotifications() performs one driver-specific non-waiting poll:
// Poll once without waiting for a notification.
List<Notification> pending = session.drainNotifications();
boolean stopRequested = Thread.interrupted();
try {
if (!pending.isEmpty())
reloadFlags.run();
} finally {
if (stopRequested)
Thread.currentThread().interrupt();
}
// Poll once without waiting for a notification.
List<Notification> pending = session.drainNotifications();
boolean stopRequested = Thread.interrupted();
try {
if (!pending.isEmpty())
reloadFlags.run();
} finally {
if (stopRequested)
Thread.currentThread().interrupt();
}
Calling awaitNotifications(Duration.ZERO) has the same behavior as drainNotifications(). Returned batches are immutable and preserve the encounter order reported by the adapter. A conceptual burst can span multiple batches, so you should normally reconcile once per nonempty batch rather than interpreting each Notification as one durable event.
Positive waits are best-effort rather than hard JDBC deadlines. Interruption supports cooperative cancelation, but cannot universally preempt a JDBC call already in progress.
PostgreSQL notification receives have no hard completion bound while driver I/O is in progress. This also applies to drainNotifications() and awaitNotifications(Duration.ZERO): their non-waiting poll can still block in driver I/O. Give the listener a bounded shutdown deadline and escalate to process-level termination if it does not join.
When a nonempty batch and a stop race, the batch wins and the interrupt flag remains set. Process the returned batch - or intentionally discard it according to your application's policy - before acting on that later stop.
Session Scope And Transaction Boundaries
NotificationSession is confined to the callback thread and may not be retained. Calls from another thread, calls after callback exit, concurrent/reentrant receives, and receives after a terminal transport failure throw IllegalStateException.
Opening a notification session or consuming notifications while any Pyranid transaction is active on the calling thread is also illegal. Notification consumption is not transactional: rolling back or retrying a transaction cannot put an already-consumed notification back.
This guard observes Pyranid's thread-local transaction stack only. It cannot detect a raw JDBC transaction created by your application or transaction-control SQL such as BEGIN issued through Database::useRawConnection(...). Ordinary lifecycle calls such as Connection::setAutoCommit(...), Connection::commit(), and Connection::rollback() remain blocked on Pyranid's guarded raw connection, but SQL text can still establish transaction state that the notification guard cannot see. Do not consume notifications while any such unmanaged transaction is active.
Complete each bounded reconciliation transaction before waiting again. For example, inside a notification-session callback, these are separate operations; the wait starts after the transaction has ended:
// reloadFlags is the implemented cache refresh from Example Setup.
database.transaction(() -> reloadFlags.run());
// Keep notification consumption outside the transaction closure.
List<Notification> hints =
session.awaitNotifications(Duration.ofSeconds(30));
// reloadFlags is the implemented cache refresh from Example Setup.
database.transaction(() -> reloadFlags.run());
// Keep notification consumption outside the transaction closure.
List<Notification> hints =
session.awaitNotifications(Duration.ofSeconds(30));
Apply the same boundary to each later refresh. If a returned batch carries a late interrupt, clear and restore it around the transaction as shown below.
Nested notification sessions are permitted, but strongly discouraged. The outer listener is not serviced while its callback runs the inner session, and the inner call needs another connection. Against a capacity-one source, that checkout can block indefinitely and deadlock your application. Prefer separately supervised listener threads and provision at least one checkout for every simultaneously live listener. Concurrent top-level calls are thread-safe, but Pyranid promises no acquisition fairness or progress beyond the configured DataSource.
Interruption And Supervision
The methods withNotificationSession(...), awaitNotifications(...), and drainNotifications() declare InterruptedException. An interrupt observed before driver work or after an empty receive is normal cooperative cancelation, not a database failure.
The manual receive loop catches the exception outside the session callback, restores the thread's interrupt flag, and returns. Exiting the callback lets Pyranid finish listener cleanup before the caller continues. It does not attempt to reconnect after cancelation.
A successfully decoded nonempty batch wins over an interrupt that races after the driver returns. Pyranid returns the batch and leaves the interrupt flag set so your code can perform its final bounded reconciliation before stopping.
On Java 21+, interrupting a virtual thread blocked in an applicable JDK system-default socket can close the socket and surface as a terminal DatabaseException rather than clean InterruptedException. If a returned batch carries a late interrupt and final database work is required, clear and remember the flag, perform only bounded work, and restore the flag in finally. This is the same implemented reloadFlags operation and flag-handling sequence used by the manual receive loop:
// Capture an interrupt that raced with a returned batch.
List<Notification> hints =
session.awaitNotifications(Duration.ofSeconds(30));
boolean stopRequested = Thread.interrupted();
try {
if (!hints.isEmpty())
reloadFlags.run();
} finally {
// Restore the signal after bounded reconciliation.
if (stopRequested)
Thread.currentThread().interrupt();
}
// Capture an interrupt that raced with a returned batch.
List<Notification> hints =
session.awaitNotifications(Duration.ofSeconds(30));
boolean stopRequested = Thread.interrupted();
try {
if (!hints.isEmpty())
reloadFlags.run();
} finally {
// Restore the signal after bounded reconciliation.
if (stopRequested)
Thread.currentThread().interrupt();
}
A later interrupt can still race with that bounded work; this pattern preserves a stop already observed but does not make socket I/O immune to interruption.
Connection loss is terminal for the current invocation and surfaces as DatabaseException. Pyranid does not reinvoke the callback. To recover, your application should observe the failure, apply its own backoff and retry policy, and call withNotificationSession(...) again. The new callback should reconcile authoritative state again after registration.
Catching a terminal receive failure inside the callback does not make the session healthy: later receives throw IllegalStateException. When the callback exits, withNotificationSession(...) completes cleanup and reports the retained failure.
There are intentionally no listener-health accessors. A Boolean snapshot cannot certify that a quiet physical connection remains healthy and registered.
Supervising A Listener
NotificationSupervisor, available since 4.8.0, runs a listener and a callback that rereads durable database state. This example keeps a small feature-flag cache current by reloading its PostgreSQL table after notifications and periodic polls.
This walkthrough uses the table, database, and implemented reloadFlags operation from Example Setup, then builds, starts, and stops the supervisor. Download the complete Java example for the imports and every callback implementation; its FeatureFlagListener.run(dataSource) method accepts your configured DataSource.
Build The Supervisor
Pass that implemented reloadFlags operation to reconcile(...):
NotificationSupervisor supervisor = NotificationSupervisor
.withDatabase(database)
.channels(Set.of("feature_flag_changed"))
.reconcile(reloadFlags)
.pollEvery(Duration.ofSeconds(30))
.onReconciliationFailure(Throwable::printStackTrace)
.retry(
failure -> (failure.getPhase() == NotificationSupervisor.FailurePhase.OPEN
|| failure.getPhase() == NotificationSupervisor.FailurePhase.RECEIVE)
&& failure.getException().getSqlState()
.map(state -> state.startsWith("08")
|| Set.of("57P01", "57P02", "57P03").contains(state))
.orElse(false),
RetryPolicy.Backoff.fullJitter(
RetryPolicy.Backoff.exponential(
Duration.ofSeconds(1), Duration.ofMinutes(1))),
Duration.ofMinutes(1)
)
.build();
NotificationSupervisor supervisor = NotificationSupervisor
.withDatabase(database)
.channels(Set.of("feature_flag_changed"))
.reconcile(reloadFlags)
.pollEvery(Duration.ofSeconds(30))
.onReconciliationFailure(Throwable::printStackTrace)
.retry(
failure -> (failure.getPhase() == NotificationSupervisor.FailurePhase.OPEN
|| failure.getPhase() == NotificationSupervisor.FailurePhase.RECEIVE)
&& failure.getException().getSqlState()
.map(state -> state.startsWith("08")
|| Set.of("57P01", "57P02", "57P03").contains(state))
.orElse(false),
RetryPolicy.Backoff.fullJitter(
RetryPolicy.Backoff.exponential(
Duration.ofSeconds(1), Duration.ofMinutes(1))),
Duration.ofMinutes(1)
)
.build();
The settings serve different purposes:
| Setting | What it does here |
|---|---|
channels(...) | Registers feature_flag_changed on the listener connection. |
reconcile(...) | Defines the cache reload requested by registration, notification batches, and polls. Requests can coalesce. |
pollEvery(...) | Requests an immediate refresh and another refresh 30 seconds after the previous pass finishes. |
onReconciliationFailure(...) | Prints recoverable refresh failures in this console demo. A later poll retries the refresh. |
retry(...) | Reconnects after selected listener failures; it is separate from retrying a cache refresh. |
The reconnect condition is deliberately concrete and conservative: it accepts OPEN or RECEIVE failures only when DatabaseException.getSqlState() reports a connection-class SQLSTATE beginning with 08, or PostgreSQL 57P01, 57P02, or 57P03 (administrative shutdown, crash shutdown, or temporarily unable to connect). These PostgreSQL SQLSTATE codes cover connection loss and selected server restarts. A missing SQLSTATE or another failure stops this example's listener. Adapt that condition to the disconnect errors your driver and deployment actually report; isTransient() alone is too broad for a disconnect policy.
The fullJitter(...) method randomizes the exponential reconnect delay, whose cap grows from one second to one minute. The final one-minute argument is the stability window, not a retry deadline: a session must stay established that long before the consecutive-failure count resets. Omitting retry(...) disables listener reconnects.
Start And Stop It
Building the supervisor starts no work. The start() method creates named daemon workers; the supervisor never closes your data source. The demo waits for its first ready cache, then waits for Enter. Its enclosing run(...) method declares throws Exception, allowing checked waiting and console-input failures to propagate:
try {
supervisor.start();
supervisor.firstReady().toCompletableFuture().get(10, TimeUnit.SECONDS);
System.out.println("new_checkout enabled: "
+ flags.get().getOrDefault("new_checkout", false));
System.out.println("Ready. Press Enter to stop.");
System.in.read();
} finally {
supervisor.requestShutdown();
NotificationSupervisor.ShutdownResult result = supervisor.awaitShutdown();
result.getFailure().ifPresent(Throwable::printStackTrace);
}
try {
supervisor.start();
supervisor.firstReady().toCompletableFuture().get(10, TimeUnit.SECONDS);
System.out.println("new_checkout enabled: "
+ flags.get().getOrDefault("new_checkout", false));
System.out.println("Ready. Press Enter to stop.");
System.in.read();
} finally {
supervisor.requestShutdown();
NotificationSupervisor.ShutdownResult result = supervisor.awaitShutdown();
result.getFailure().ifPresent(Throwable::printStackTrace);
}
The stage returned by firstReady() succeeds only after channel registration and a successful refresh that began after registration. It stays pending while retryable startup failures are being recovered. The ten-second limit bounds this demo's readiness wait; it does not force a JDBC call to stop. The finally block requests shutdown even when startup fails or the wait times out.
The requestShutdown() method starts cooperative shutdown, and awaitShutdown() waits for listener cleanup and accepted refresh work. Keep the data source open until that completes. A server would use the same sequence at application startup and shutdown, and read the current cache with flags.get() while serving requests. For a deadline on the shutdown wait, see Shutdown.
Publish A Change
While the demo is listening, run this from another PostgreSQL session connected to the same database:
BEGIN;
UPDATE feature_flag SET enabled = TRUE WHERE name = 'new_checkout';
NOTIFY feature_flag_changed;
COMMIT;
BEGIN;
UPDATE feature_flag SET enabled = TRUE WHERE name = 'new_checkout';
NOTIFY feature_flag_changed;
COMMIT;
The callback reloads the table and prints the updated snapshot. The notification carries no value to apply: the database row supplies the value. If the notification is missed, polling still reloads the table. Sending Notifications shows how to perform the update and send through one Pyranid transaction.
Readiness And Recovery
A successful first-readiness signal remains completed during later outages. The snapshot() method reports observed lifecycle state, attempt number, previous readiness, and the last observed failure; ACTIVE does not prove that a quiet connection is healthy or the cache is current. A poll already running when registration completes cannot satisfy readiness, and an interrupted refresh does not count as success.
Retry decisions receive a RetryFailure with its phase, consecutive failure count, and whether first readiness was reached. The count resets only after the configured stability window, measured from successful initial reconciliation to the first observed failure; acquisition and cleanup time do not count toward stability. Fatal errors, unsupported listening capabilities, unreadable failures, unexpected application exceptions, and failures in retry-policy callbacks terminate the supervisor. Inspect the shutdown result for the terminal failure.
Independent Polling And Reconciliation
Registration, nonempty notification batches, and polls request the same serialized callback. One supervisor never runs its reconciliation callback concurrently. Requests arriving during a pass coalesce into a follow-up pass. Other supervisors and application workers can still run concurrently, so reconciliation must tolerate repeated and concurrent work. A decoded nonempty batch racing with shutdown is still admitted for a final bounded reconciliation; interrupting the listener's wait does not cancel that accepted work.
Polling is disabled by default. Calling pollEvery(Duration) requests an initial poll immediately. The interval is a fixed delay measured after each completed reconciliation pass, including passes requested by notifications or registration. Successful notification-driven passes therefore defer redundant polls; this is not a fixed wall-clock schedule. Polling remains independent of notification receive, connection opening, and reconnect backoff. It can discover durable changes while the listener is disconnected and retrying, or blocked waiting for notifications. A terminal listener failure stops the whole supervisor, including polling. A blocked reconciliation callback still blocks later passes; use bounded queries and bounded application work.
With polling enabled, a recoverable DatabaseException from reconciliation is recorded and reported to onReconciliationFailure(...), if configured. A later poll retries reconciliation without reconnecting a healthy listener. The listener waits for that successful pass before receiving another batch, so a registered listener can remain active while reconciliation is failing. A failed initial reconciliation leaves readiness pending until a successful pass covers the registration. Without polling, reconciliation failures retain the listener's configured retry(...) behavior. Unexpected application exceptions, fatal errors, and failures thrown by the reconciliation-failure observer terminate the supervisor; keep that observer short and nonthrowing.
Reconciliation status is separate from listener status. The snapshot().getLastSuccessfulReconciliation() call returns an Optional<Instant>, and getLastReconciliationFailure() returns an Optional<Throwable> that clears after a successful pass. The isReconciliationRunning() method reports an executing pass. The isReconciliationPending() method reports queued or unsatisfied listener reconciliation requests; it does not indicate that a polling timer is armed or that every failed periodic pass has a scheduled retry. A failed periodic pass can leave pending false while the next poll is still scheduled. Monitor reconciliation freshness as well as listener failures; a registered listener alone does not prove that your application has caught up.
Customizing Worker Threads
The default named daemon threads need no executor configuration. For custom naming, daemon policy, or instrumentation, supply a mapping from nested NotificationSupervisor.ThreadRole values to standard ThreadFactory instances:
AtomicInteger threadIds = new AtomicInteger();
Function<NotificationSupervisor.ThreadRole, ThreadFactory> threadFactories =
role -> task -> {
Thread thread = new Thread(task,
"feature-flags-" + role + "-" + threadIds.incrementAndGet());
thread.setDaemon(true);
return thread;
};
AtomicInteger threadIds = new AtomicInteger();
Function<NotificationSupervisor.ThreadRole, ThreadFactory> threadFactories =
role -> task -> {
Thread thread = new Thread(task,
"feature-flags-" + role + "-" + threadIds.incrementAndGet());
thread.setDaemon(true);
return thread;
};
Pass threadFactories to threadFactory(...) before building the supervisor. The roles are LISTENER and RECONCILIATION; they describe worker responsibilities rather than individual notifications or a guaranteed thread count. Each role must provide a non-null factory that creates new, unstarted threads. Building remains lazy, and the supervisor owns shutdown of workers created through custom factories.
Shutdown
On application shutdown, request shutdown and wait for cleanup:
supervisor.requestShutdown();
NotificationSupervisor.ShutdownResult result = supervisor.awaitShutdown();
result.getFailure().ifPresent(Throwable::printStackTrace);
supervisor.requestShutdown();
NotificationSupervisor.ShutdownResult result = supervisor.awaitShutdown();
result.getFailure().ifPresent(Throwable::printStackTrace);
Keep the listener and reconciliation data sources open until supervisor shutdown completes. Accepted reconciliation can still acquire an ordinary database connection while shutdown drains outstanding work. Close application-owned connection pools only after awaitShutdown() or awaitShutdownAsync() completes.
The awaitShutdown() method blocks and declares InterruptedException. Interrupting the waiting thread ends only that wait; it does not request supervisor shutdown. To enforce a waiting deadline, use the asynchronous view:
supervisor.requestShutdown();
NotificationSupervisor.ShutdownResult result = supervisor.awaitShutdownAsync()
.toCompletableFuture().get(5, TimeUnit.SECONDS);
result.getFailure().ifPresent(Throwable::printStackTrace);
supervisor.requestShutdown();
NotificationSupervisor.ShutdownResult result = supervisor.awaitShutdownAsync()
.toCompletableFuture().get(5, TimeUnit.SECONDS);
result.getFailure().ifPresent(Throwable::printStackTrace);
The requestShutdown() method is idempotent and initiates cooperative shutdown without waiting for driver I/O. The awaitShutdownAsync() method returns a stage that completes normally with a result that retains terminal failures, including failures racing with shutdown. ShutdownResult.isShutdownRequested() records correlation and does not claim the shutdown request caused a failure. A timeout while waiting does not force-cancel blocked JDBC or application work. Report a supervisor that misses its shutdown deadline and escalate at the process boundary when necessary.
The shutdown result retains unrecovered reconciliation failures together with terminal listener and cleanup failures, including failures that occur during shutdown. A later successful reconciliation clears earlier recoverable reconciliation failures. Shutdown still waits for listener I/O and accepted reconciliation work to finish.
Shutdown completion means listener cleanup and outstanding reconciliation are complete and no more supervisor JDBC or reconciliation work will occur. The application must still call requestShutdown() and awaitShutdown() for orderly shutdown: daemon threads merely allow process exit and do not replace cleanup. CompletionStage views cannot cancel or complete the supervisor, and synchronous continuations may run on the completing worker; keep them short or use explicit asynchronous continuations. Do not wait for supervisor shutdown from its own listener, reconciliation, failure-observer, or thread-factory callback. The awaitShutdown() method rejects self-waits with IllegalStateException; directly blocking on the asynchronous view can still deadlock.
Legacy Executor Mode
The start(Executor) overload runs the supervisor using an application-owned asynchronous executor. Reconciliation runs synchronously on the listener worker, and the application owns executor shutdown. This mode rejects pollEvery(...), custom threadFactory(...), and onReconciliationFailure(...) configuration; use start() for managed polling and workers. Inline execution, including a triggered CallerRunsPolicy, is rejected before listener work begins. Applications using this mode need a separately coordinated independent poller for fallback reconciliation.
A supervisor cannot make notification delivery durable or guarantee that arbitrary JDBC I/O returns before a deadline. Applications needing individual payload processing or a different receive loop can continue using withNotificationSession(...) directly.
Choosing The Listener Database
Listening requires a DataSource that preserves one backend session for the complete callback. The Database your application uses for ordinary work is suitable when its source provides that affinity and has enough pool capacity.
The manual receive loop and supervisor example both use that arrangement: one source with enough capacity for the listener and a concurrent cache refresh.
If ordinary traffic uses a source that cannot preserve listener state, construct a second Database over a session-affine listener source:
// Dedicate this Database to a session-affine listener source.
Database listenerDatabase = Database.with(listenerDataSource, DatabaseType.POSTGRESQL)
.build();
// Dedicate this Database to a session-affine listener source.
Database listenerDatabase = Database.with(listenerDataSource, DatabaseType.POSTGRESQL)
.build();
Pass listenerDatabase to NotificationSupervisor.withDatabase(...) or use it to open the manual listener session. Keep reloadFlags bound to the ordinary database instance from Example Setup.
This is ordinary Database composition; Pyranid has no notification-specific DataSource setting. Reconciliation and transactional publication should continue through the Database your application uses for ordinary work. Each Database retains its one caller-owned DataSource; Pyranid neither compares separately configured sources nor closes them.
Each live listener holds one connection for its full callback lifetime. A capacity-one listener source cannot simultaneously serve callback queries or sends, even if another Database object wraps that same source.
The listener instance's DatabaseType is required when creating its builder. Reading that configured type does not acquire a connection from the listener source.
Call Database::isNotificationListeningSupported() on the listener Database when it differs from the instance used for ordinary work. It reports whether the configured dialect and runtime driver adapter support attempting to listen. It does not verify server configuration, proxy mode, backend-session affinity, connection health, or whether publisher, listener, and reconciliation instances reach the same database.
Pyranid performs no runtime topology validation or active liveness query. In particular, using PgBouncer transaction or statement pooling as the listener source is unsupported and can appear to register successfully before delivery silently stops. Use a direct or session-pooled listener source, and route every participating instance to the same active logical database and primary.
Portable Contract
Pyranid standardizes the callback scope and common value model while leaving database semantics with the backend.
| Pyranid guarantees | Backend-specific behavior |
|---|---|
| Nonblank, NUL-free channel names | Channel case matching and additional limits |
| Nullable string payloads with no generic normalization | Whether null and empty remain distinct, whether payloads are supported, and their size limit |
| Immutable notification batches | Delivery timing, ordering, fan-out, and coalescing |
| One physical listener session per invocation | Proxy and pooling compatibility |
| No backend sender identifier or occurrence count | Any metadata that Pyranid deliberately omits |
Send and listen support are independent. A backend may support one without the other. Each backend defines whether it accepts, preserves, transforms, or rejects null and empty payloads; a backend without payload support may reject any supplied payload.
PostgreSQL
PostgreSQL notification receiving requires pgjdbc 42.7.13 or newer, or YugabyteDB's supported com.yugabyte smart driver. These drivers are not runtime dependencies of Pyranid; applications must provide the appropriate driver. Pyranid does not promise compatibility with arbitrary repackaged pgjdbc forks. Sending uses bound pg_notify(?, ?) SQL and remains available without the receive adapter.
YugabyteDB exposes LISTEN/NOTIFY as an Early Access feature in v2025.2.3 and later. It is disabled by default and requires ysql_yb_enable_listen_notify=true on both Masters and TServers. Database::isNotificationListeningSupported() checks only the configured dialect and loadable driver adapter; it cannot inspect this server flag. If notifications are disabled or unavailable in the current server version, setup or sending fails with UnsupportedOperationException. See the YugabyteDB documentation.
CockroachDB does not implement LISTEN/NOTIFY, although applications configure it as DatabaseType.POSTGRESQL. Pyranid translates the capability errors from its generated notification SQL to UnsupportedOperationException. A true result from Database::isNotificationListeningSupported() therefore means the dialect and runtime adapter are available, not that every wire-compatible server exposes the feature in its current version or configuration.
PostgreSQL-specific behavior - including commit/rollback delivery, duplicate coalescing, channel and payload limits, PgBouncer pooling modes, TCP/socket considerations, and notification-queue operations - is covered under Database-Specific Recipes.
Metrics
Notification sessions emit low-cardinality lifecycle callbacks through the MetricsCollector configured on the listener Database. Sends and ordinary statements emit through the Database instance that executes them.
See Metrics Collection for lifecycle outcomes, in-memory counters, and current OpenTelemetry adapter coverage.

