Upgrading relaybus
Upgrading from 0.4.1 to 0.5.0
applies to: '@relaybus/* 0.4.1 -> 0.5.0'
downtime: 'none'
rollout: 'mixed 0.4.1 and 0.5.0 instances are safe'
database: 'none'
rollback: 'safe; 0.5.0 stores and sends nothing 0.4.1 cannot read'Nothing is required. 0.5.0 adds API and changes no behaviour of existing code: routes, jsonEnvelope wireTypes, handler ids, stored deliveries and the wire format are as in 0.4.1.
Optional steps:
Replace per-event routes with a catalog route. Routes of one destination written event by event can become one
route; the handler idrelaybus.transport:<transport>:<destination>and the routed event types stay the same, so deliveries stored under the old routes are still sent. Do it in one deploy; there is nothing to migrate.ts// before routes: [ { event: OrderPlaced, transport: 'kafka', destination: 'orders', retry }, { event: OrderCancelled, transport: 'kafka', destination: 'orders', retry }, ], // after routes: [ route(produce(OrderEvents, { events: ['OrderPlaced', 'OrderCancelled'] }), { transport: 'kafka', destination: 'orders', retry, }), ],Move wire names to the catalog. Names from the
wireTypesof ajsonEnvelopecan be declared bydefineCatalog(..., { wireTypes })orroute(..., { wireTypes })instead. The bytes are identical; naming the same pair in both places is allowed, naming another name for the same event type fails the boot withRELAYBUS_WIRE_TYPE_COLLISION.Replace a lock with SERIALIZABLE. A
pg_advisory_xact_lockorSELECT ... FOR UPDATEthat only guards a count-check-insert can become@RelayBusTransaction({ isolationLevel: 'serializable' })(orbus.transaction(work, { isolationLevel: 'serializable' })), which does not block and reruns the transaction after a serialization failure. The method may then run more than once, so keep calls to other services out of it. Test it under concurrency first; see Serializable transactions. Nothing changes for code that does not passisolationLevel.
Things to know:
- A custom
PostgresExecutorkeeps working: without the new optionaltransactionWithIsolationthe store sendsSET TRANSACTION ISOLATION LEVEL ...as the first statement of itstransaction, and it recognizes SQLSTATE40001incode,causeanddriverErroron its own (the optionalisSerializationFailurecovers other shapes). A customEventStorethat does not declareisolationLevelsrejects everyisolationLevel, so a service that never passes one is not affected. - A custom
Transportwritten outside relaybus keeps working. It only needsdeclareWireTypes(wireTypes)if you declare wire names for it; without the method, declaring them fails the boot withRELAYBUS_WIRE_TYPES_UNSUPPORTED. Without wire names, it is never called. RelayBusOptions.routesis nowreadonly RouteDefinition[](RouteOptions | CatalogRoute). Code that only passes routes compiles unchanged; code that readsoptions.routesand expectsRouteOptionsnow has to narrow.- A service that only consumes a catalog with wire names has no route to declare them: give its codec
wireTypes: catalogWireTypes(OrderEvents).
Upgrading from 0.4.0 to 0.4.1
applies to: '@relaybus/* 0.4.0 -> 0.4.1'
downtime: 'none; publishes and claims wait while setup() rewrites the events table once'
rollout: 'mixed 0.4.0 and 0.4.1 instances are safe with the defaults'
database: 'PostgreSQL: payload and headers of the events table become json (one table rewrite)'
rollback: 'safe with the defaults; 0.4.0 reads and writes the json columns, no DDL'Why. The PostgreSQL store kept payload and headers as jsonb, which re-orders object keys (shorter keys first, then alphabetically). An event read back from the outbox therefore serialized differently from what the producer published: JSON consumers did not notice, byte or string comparisons and consumers that depend on field order did. 0.4.1 stores both as json, which keeps the text the producer serialized, and hands the transport and the handlers exactly that. MongoDB and the in-memory store already kept the order.
Rows already stored keep the order jsonb gave them. Only events published after the upgrade keep their producer's key order. The values of old rows are unchanged.
With setup() (the default, setupStore: true)
Nothing to do. The first 0.4.1 instance that starts converts the two columns in place. It is idempotent and guarded: it runs only while a column is still jsonb.
The conversion rewrites the events table once under an exclusive lock, so publishes and claims wait for it. It takes about as long as copying the table; on a large table run it at a quiet time, or run the DDL below yourself first. setup() runs it in a transaction of its own, so it cannot deadlock with running completions, and on a schema 0.4.0 created (recognized by the marker 0.4.0 left on the lane table) that is all it does: the other schema statements, whose table locks could queue behind running claims, do not run again. 0.4.1 keeps writing the marker 0.4.0 writes and reads whether the conversion ran from the column types, so once both columns are json its setup() runs nothing and takes no lock.
Without setup() (setupStore: false, your own migrations)
Run this once, with your schema and table prefix (the defaults are public and relaybus_):
ALTER TABLE "public"."relaybus_events"
ALTER COLUMN headers DROP DEFAULT,
ALTER COLUMN payload TYPE json USING payload::json,
ALTER COLUMN headers TYPE json USING headers::json,
ALTER COLUMN headers SET DEFAULT '{}'::json;createPostgresSchemaSql() contains the same conversion guarded by a column type check, so re-applying its output is safe as well.
During the rollout
0.4.0 and 0.4.1 instances can run side by side. Before the conversion, 0.4.1 publishes into the jsonb columns (PostgreSQL casts on assignment; key order is lost as before); after it, 0.4.0 publishes into the json columns the same way. Mixed rollouts and rollbacks are safe with the default settings: a 0.4.0 instance that starts or restarts with setupStore: true after the conversion finds its own schema marker, which 0.4.1 keeps, and runs no schema statement. Rolling back needs no DDL; the columns stay json, and a later 0.4.1 start has nothing to do.
Also in 0.4.1
- Prisma 5.
@relaybus/postgresdeclares its optional@prisma/clientpeer as^5.0.0 || ^6.0.0 || ^7.0.0. With 0.4.0 (^6 || ^7), npm refused to install it next to Prisma 5 (ERESOLVE) or installed a second Prisma Client. Nothing changes in your code; Prisma 5 needspreviewFeatures = ["driverAdapters"]for@prisma/adapter-pg. - jsonEnvelope.
bodyOrdernow also places the event headers spread byheaders: 'top-level-fields'; before, they always came last, even whenbodyOrdernamed them. Only a mapping whosebodyOrdernames a spread header writes different bytes, and those are the bytes it asked for; headers it does not name still come last. New options:otherFieldskeeps unknown top-level fields of every JSON type,wireTypesnames event types differently on the wire. See Interoperating with other services. - Codec contract. Compared byte for byte, the decoded payload must now also keep the expected key order, so a case whose expected payload lists keys in another order than the wire fails with a
decoded payloadline; write the expected payload in wire order or usecompare: 'json'. Jest projects use@relaybus/testing/jest;vitestis now an optional peer.
Upgrading from 0.3 to 0.4
applies to: '@relaybus/* 0.3.x -> 0.4.0'
downtime: 'none; a rolling deploy is supported'
database: 'no migration; setup() has nothing new to apply'
rollback: 'safe; 0.4.0 writes the same rows, SQL and wire format as 0.3.x'There is nothing to do. The public API only grew, defaults are unchanged and the stored and wire formats are the same. Update the packages (keep every @relaybus/* on one version) and deploy:
pnpm add @relaybus/core@0.4.0 @relaybus/postgres@0.4.0Everything below is optional.
Optional: give an ORM store the pool
With Prisma, Kysely or Drizzle, pass the pg pool your ORM's driver uses next to executor. Claims run as prepared statements, and an event published in your ORM transaction is dispatched right after the commit instead of at the next poll:
const pool = new Pool({ connectionString });
const prisma = new PrismaClient({ adapter: new PrismaPg(pool) });
const store = new PostgresEventStore({ executor: createPrismaExecutor(prisma), pool });Optional: wake the dispatcher after your own commit
For a store without commit notifications (MongoDB, wakeUp: 'poll', an executor without pool):
await myTransaction.commit();
bus.wake();Without it the dispatcher is woken 25, 100 and 250 ms after the latest publish and otherwise at the next poll.
Optional: the Confluent Kafka client
import { KafkaJS } from '@confluentinc/kafka-javascript';
const kafka = new KafkaJS.Kafka({ kafkaJS: { clientId: 'orders', brokers: ['kafka:9092'] } });
new KafkaTransport({ kafka, client: 'confluent', groupId: 'billing' });kafkajs stays the default and keeps working. The two clients differ in a few behaviours; read Choosing a client first and roll it out per service.
Optional: control the Kafka record key
jsonEnvelope({ /* mapping */ recordKey: 'none' }); // or (event) => event.payload.accountIdThe default 'event-key' is what 0.3 wrote. A keyless record is placed by the producer, so per-key order then holds only up to the broker.
Optional: publish through your own producer
const producer = kafka.producer(); // already connected, owned by your application
await producer.connect();
new KafkaTransport({ producer }); // publish-only; add kafka and groupId to consume as wellThe transport only calls send; it never connects or disconnects the producer. Do not combine producer with producerConfig or maxInFlightRequests (a ConfigurationError): configure the producer itself.
Optional: in-house formats and their tests
jsonEnvelope({
type: { header: 'x-event-type' },
payload: { body: 'payload' },
occurredAt: { body: 'sentAt', format: 'epoch-millis' },
extra: { body: { schema: 'orders/v1' }, headers: { 'x-schema-version': '1' } },
});import { kafkaCodecContract } from '@relaybus/testing';
kafkaCodecContract('orders format', { encode: codec.encode, decode, cases });See Interoperating with non-relaybus services for legacy flat envelopes and the contract helpers.
Upgrading from 0.2 to 0.3
applies to: '@relaybus/* 0.2.x -> 0.3.0'
downtime: 'none; a rolling deploy is supported'
database: 'no migration; setup() has nothing new to apply'
rollback: 'roll forward; 0.2.x does not know the new route ids of deliveries written by 0.3.0'Your code keeps compiling and running: every renamed API still works under its old name. Five things behave differently and are worth checking, in this order. Coming from 0.1, upgrade to 0.2 first (it deletes completed deliveries and upgrades the schema in place), then follow this page.
- Update the packages
- Route ids changed
- Transactions have a time budget
- stop() returns a StopResult
- Renames: decorators and codec
- Error codes and the Mongo error field
- Optional new packages
Step 1: update the packages
Keep every @relaybus/* package on the same version:
pnpm add @relaybus/core@0.3.0 @relaybus/nestjs@0.3.0 @relaybus/postgres@0.3.0Do the same for mongodb, kafka, rabbitmq and testing if you use them. Node.js 22.12 or newer is still required.
Step 2: route ids changed
A route sends deliveries of an event type to a transport destination. Routes to one destination used to be one handler per event type; they are now one handler per destination, so events of one key but different types reach the broker in commit order.
| 0.2.0 | 0.3.0 | |
|---|---|---|
| Route handler id | relaybus.transport:kafka:orders:order.placed.v1 | relaybus.transport:kafka:orders |
Nothing has to be migrated. Deliveries stored under a 0.2.0 id, including those written by 0.2.0 processes during a rolling deploy, are still claimed and sent by the new route. The stored rows keep their old id.
Order during the upgrade
A delivery under a 0.2.0 id and one under the new id are two queues, so one key's backlog from 0.2.0 is not ordered against events published after the upgrade. Where cross-type order matters, drain the old backlog first:
- Stop publishing with 0.2.0 (stop the 0.2.0 publishers before the 0.3.0 ones start).
- Wait until no delivery is left under the old ids:
const old = await bus.listDeliveries({
handler: 'relaybus.transport:kafka:orders:order.placed.v1', // one query per routed event type
status: 'pending',
});
// continue when old is empty- Start the 0.3.0 publishers.
If order across types does not matter for your consumers, skip this and deploy normally.
Two routes that no longer construct
Routes to the same destination now have to resolve to the same retry and timeout, and an event type may be routed to a destination once; otherwise new RelayBus throws a ConfigurationError.
// Before (0.2.0): accepted
routes: [
{ event: OrderPlaced, transport: 'kafka', destination: 'orders', retry: { attempts: 5 } },
{ event: OrderCancelled, transport: 'kafka', destination: 'orders' },
],
// After (0.3.0): the same settings on both routes
routes: [
{ event: OrderPlaced, transport: 'kafka', destination: 'orders', retry: { attempts: 5 } },
{ event: OrderCancelled, transport: 'kafka', destination: 'orders', retry: { attempts: 5 } },
],Dashboards and code that match ids
handlers(), listDeliveries({ handler }), HandlerContext.handler and observer events show the new id for new deliveries. Update alerts that filter on the old id; query both ids while the 0.2.0 backlog drains.
Step 3: transactions have a time budget
transactionTimeout is new and defaults to '5s'. A bus.transaction(...) or @RelayBusTransaction() method that runs longer is rolled back before its commit, and the caller gets a TransactionTimeoutError (RELAYBUS_TRANSACTION_TIMEOUT). The database enforces the budget where it can: on PostgreSQL statement_timeout and, with the pg executor, idle_in_transaction_session_timeout (and transaction_timeout on PostgreSQL 17 or newer); on MongoDB maxCommitTimeMS.
First look for transactions that call another service. The usual fix is to move the call out:
// Before: an HTTP call holds the transaction (and its row locks) open
await bus.transaction(async (tx) => {
const rate = await fetch(ratesUrl).then((r) => r.json());
await orders.insert(tx, { ...order, rate });
await bus.publish(new OrderPlaced({ orderId }), { transaction: tx });
});
// After: call first, then a short transaction
const rate = await fetch(ratesUrl).then((r) => r.json());
await bus.transaction(async (tx) => {
await orders.insert(tx, { ...order, rate });
await bus.publish(new OrderPlaced({ orderId }), { transaction: tx });
});If a transaction legitimately needs longer (a large publishAll, a migration job), raise the budget for the bus:
new RelayBus({ store, source: 'orders', transactionTimeout: '30s' });
// NestJS
RelayBusModule.forRoot({ store, source: 'orders', transactionTimeout: '30s' });@relaybus/eslint-plugin (step 7) finds external calls inside transactions at lint time.
Who owns the pool matters. On a pool the store creates from connectionString or poolConfig, each connection keeps the budget as a session setting, so a publish costs no extra statement; the price is that statements outside a transaction on that connection (claims, completions, renewals) run under the budget the connection last received, '5s' by default. On a pool or executor you pass in, the budget lasts only for the transaction and your pool's settings are never changed. Details and when to choose which: Tuning.
Step 4: stop() returns a StopResult
bus.stop() is now bounded: the whole shutdown takes at most timeout (default dispatcher.shutdownTimeout, 30 s) and resolves with what happened.
// Before (0.2.0): Promise<void>, no deadline
await bus.stop();
// After (0.3.0): Promise<StopResult>
const { graceful, abandonedDeliveries, durationMs } = await bus.stop({ timeout: '25s' });
if (!graceful)
logger.warn(`shutdown forced, ${abandonedDeliveries} deliveries left to be taken over`);- Past the deadline, relaybus force-closes what it opened itself (a store's own pool, its
LISTENconnection, a RabbitMQ connection) and never closes a pool or client you passed in. It never rejects because of the deadline. - Abandoned deliveries keep their lease until it expires, then another process runs them again. This is the at-least-once guarantee; keep handlers idempotent.
- Align the timeout with Kubernetes:
terminationGracePeriodSecondsshould be larger thanshutdownTimeout. With NestJS callapp.enableShutdownHooks();RelayBusModulestops the bus inbeforeApplicationShutdown, now bounded. - Existing
await bus.stop()calls compile and behave as before; only code that needs to know whether the shutdown was graceful has to read the result.
Step 5: renames: decorators and codec
Both old names keep working, are marked @deprecated and are removed in 1.0.0. Moving now means the editor shows no strikethrough and the upgrade to 1.0.0 is a no-op.
Decorators
// Before (0.2.0)
import { Subscribe, EventConsumer } from '@relaybus/nestjs';
@Subscribe(OrderPlaced)
async onPlaced(event: EventEnvelope<OrderPlacedPayload>) {}
@EventConsumer(OrderEvents)
export class BillingHandlers implements HandlersOf<typeof OrderEvents> {}
// After (0.3.0)
import { RelayBusConsumer, RelayBusCatalogConsumer } from '@relaybus/nestjs';
@RelayBusConsumer(OrderPlaced)
async onPlaced(event: EventEnvelope<OrderPlacedPayload>) {}
@RelayBusCatalogConsumer(OrderEvents)
export class BillingHandlers implements HandlersOf<typeof OrderEvents> {}| Old | New |
|---|---|
@Subscribe (method) | @RelayBusConsumer (method, the default style) |
@EventConsumer (class) | @RelayBusCatalogConsumer (class, optional) |
| Their option and metadata types | RelayBusConsumerOptions, RelayBusConsumerMetadata, RelayBusCatalogConsumerOptions, RelayBusCatalogConsumerMetadata |
@RelayBusConsumer only compiles on a method and takes an event; @RelayBusCatalogConsumer only on a class and takes a catalog. Handler ids are unchanged (ClassName.methodName by default), so no stored delivery is orphaned.
decode becomes codec
KafkaTransport and RabbitMqTransport take one symmetric codec for the wire format, both for publishing and for consuming. The decode option reads with your function and writes the default format; it is the same as codec: { encode: encodeEnvelope, decode }.
// Before (0.2.0): inbound only
new KafkaTransport({
kafka,
groupId: 'billing',
decode: plainJsonDecoder({ type: fromHeader('event-type') }),
});
// After (0.3.0)
new KafkaTransport({
kafka,
groupId: 'billing',
codec: { encode: encodeEnvelope, decode: plainJsonDecoder({ type: fromHeader('event-type') }) },
});
// or one of the built-in codecs: cloudEventsBinary (default), cloudEventsStructured, jsonEnvelope({ ... })Giving both decode and codec is a configuration error. The default codec, cloudEventsBinary, writes exactly the bytes 0.2.0 wrote (pinned by golden tests), so services you did not upgrade keep reading your messages. See the interoperability recipe.
Step 6: error codes
- Every relaybus error has a
code.ErrorInfo(thelastErrorof a delivery, theerrorof observer events) gains an optionalcodefor relaybus errors; other errors are unchanged. Records written before 0.3.0 have nocode, so read it as optional. The list is on Error codes. - Five error classes report their own name.
InvalidKafkaMessageError,RabbitMqUnavailableError,RabbitMqPublishRejectedError,UnroutableMessageErrorandMalformedMessageErrorused to reportname: 'RelayBusError'. Alerts that matched that name should match the class name or, better, thecode. - MongoDB keeps the code. The MongoDB store now stores
codein thelastErrorof a delivery, as PostgreSQL always did (it storesErrorInfoas JSON). Documents withoutcodestay valid. - Broker dead letters carry more headers. Kafka adds
relaybus-error-nameandrelaybus-error-codeto records copied to<topic>.dead; RabbitMQ addsx-relaybus-dead-letter-error-nameandx-relaybus-dead-letter-error-code. The reason headers are unchanged. onFailurehas a new stage,'backlog'. Add a case if youswitchexhaustively over the stage.
Step 7: optional new packages
Neither is required, and neither changes behaviour until you add it.
pnpm add -D @relaybus/eslint-plugin # lint: no external call inside a transaction, no publish outside one
pnpm add @relaybus/otel @opentelemetry/api # metrics and traces// eslint.config.js
import tseslint from 'typescript-eslint';
import relaybus from '@relaybus/eslint-plugin';
export default tseslint.config(...tseslint.configs.recommended, relaybus.configs.recommended);import { relayBusOpenTelemetry } from '@relaybus/otel';
const otel = relayBusOpenTelemetry(); // global meter and tracer providers
new RelayBus({ store, observers: [otel.observer], tracer: otel.tracer, backlogInterval: '30s' });See Transactions in NestJS and Observability.
Checklist
- [ ] All
@relaybus/*packages on 0.3.0 - [ ] Routes to one destination share
retryandtimeout - [ ] Old route backlog drained, if cross-type order matters
- [ ] No transaction waits on a slow external call (or
transactionTimeoutraised on purpose) - [ ] On a pool the store owns, you accept that statements outside transactions share the transaction budget (or pass your own pool)
- [ ]
stop()callers handleStopResult; grace period larger thanshutdownTimeout - [ ] Dashboards read both route ids during the drain
Upgrading from 0.1 to 0.2
applies to: '@relaybus/* 0.1.0 -> 0.2.0'
downtime: 'a short stop of every 0.1.0 worker (the upgraded schema is not compatible with 0.1.0 code)'
rollback: 'restore from a backup; there is no down migration'0.2.0 changes how deliveries are stored (why). Your code keeps working: publish, @Subscribe, handlers, retries and dead letters have the same API. What changes is the database layout, which setup() upgrades in place, and one visible behaviour: completed deliveries are deleted.
What changes for you
| 0.1.0 | 0.2.0 | Action |
|---|---|---|
A completed delivery stays as a completed row until retention purges it. | It is deleted when it completes. listDeliveries({ status: 'completed' }) is empty and statistics().completed is 0. | If you read completed deliveries, set completionHistory: true. |
Each delivery has lease columns and a processing status. | Workers lease 256 lanes; the lease columns are dropped and processing deliveries become pending. | Stop 0.1.0 workers first; setup() converts the data. |
| Deduplication relies on completed rows. | Every event remembers the handlers it ever had a delivery for, so a redelivered event runs no handler twice. | None. Keep retention.completed longer than any broker redelivery window. |
| No notifications. | PostgreSQL LISTEN/NOTIFY wakes other processes. | None, or wakeUp: 'poll' behind a pooler. |
| Statements are parsed on every call. | Repeated statements are prepared per connection. | None, or preparedStatements: false behind PgBouncer before 1.21. |
Before you start
- Back up the database. There is no down migration.
- Rehearse on a copy of production data and time it. The PostgreSQL upgrade rewrites every
deliveriesrow once. - Purge old completed deliveries to shorten that rewrite: set a short
retention.completedand runbus.purgeExpired()while still on 0.1.0. - Update every package to the same version, for example
pnpm add @relaybus/core@0.2.0 @relaybus/nestjs@0.2.0 @relaybus/postgres@0.2.0(andmongodb,kafka,rabbitmq,testingif you use them).
Step 1: stop every 0.1.0 worker
Scale the processes that run a dispatcher (API processes with the dispatcher enabled, worker fleets) to zero. Running 0.1.0 and 0.2.0 together is not supported: 0.1.0 code fails on the dropped columns.
Step 2: upgrade the schema
Choose one.
Option A: let setup() do it (default)
Start one 0.2.0 process with setupStore: true (the default). setup() runs in one transaction under an advisory lock and is safe to run again. In order, on PostgreSQL it:
- Detects a 0.1.0 table by its
deliveries.lease_tokencolumn. - Adds
events.handlers, filled from the existing deliveries (completed ones included, so nothing that completed under 0.1.0 runs again). - Adds
deliveries.lanewith the same hash new deliveries use, turnsprocessingdeliveries intopending(the attempt stays counted, as a lost lease would count it) and dropslease_owner,lease_tokenandlease_expires_at. - Drops the indexes that no longer exist, creates the new ones and seeds the 256
lanesrows.
Deliveries that were completed stay as completed rows and are purged by retention.completed, or kept forever with retention: { completed: false }. New completions are deleted.
Option B: run the SQL with your own migration tool
When your application role has no DDL rights, or you apply migrations from CI, render the script and run it as one transaction with the workers stopped:
import { createPostgresSchemaSql } from '@relaybus/postgres';
// schema and tablePrefix must match your store options
console.log(createPostgresSchemaSql({ schema: 'app', tablePrefix: 'bus_' }));Then start 0.2.0 with setupStore: false, so the processes only need DML rights.
MongoDB
setup() upgrades the database in place and repeatably: processing deliveries become pending and due at once (the attempt stays counted), per-delivery lease fields are removed, every delivery gets its laneId and every event its handlers and createdAt (in batches of 1,000), the old indexes are dropped and the 256 lane documents are created. The lane documents mark the upgrade as done, so later runs skip it. completed documents left by 0.1.0 stay until purge({ completedBefore }) removes them. There is no SQL to run: if your application role cannot create collections, call setup() once from a deployment script with a role that can.
Step 3: keep completion history (optional)
If an audit, a dashboard or a test reads completed deliveries, opt in:
new PostgresEventStore({ pool, completionHistory: true });
new MongoEventStore({ client, database: 'orders', completionHistory: true });
new InMemoryEventStore({ retainCompleted: true }); // testsWith it, each completion also inserts one append-only row into <prefix>completed_deliveries (a collection on MongoDB) in the same transaction that deletes the delivery. listDeliveries({ status: 'completed' }) and statistics().completed read it, and retention.completed purges it. The cost is one more heap and index write per delivery, so leave it off on hot paths. Use the same value in every process.
Completed rows that 0.1.0 left in deliveries keep being listed until retention purges them, whatever this option says.
Step 4: start 0.2.0 and verify
Start the workers, one at a time if you want to watch, then check:
console.log(await bus.statistics()); // pending falls, dead is unchanged, nothing stays in processingSELECT count(*) FROM relaybus_lanes; -- 256
SELECT count(*) FROM relaybus_deliveries WHERE lane IS NULL; -- 0Dead letters from 0.1.0 are still listed and can be requeued as before.
PgBouncer and other poolers
A pooler in transaction mode gives each transaction a different server session, and 0.2.0 uses two things that need a session: LISTEN and prepared statements.
| Setup | Settings |
|---|---|
| Direct to PostgreSQL, or PgBouncer in session mode | Defaults. |
PgBouncer 1.21 or newer in transaction mode with max_prepared_statements > 0 | wakeUp: 'poll' (no LISTEN through a transaction pooler); prepared statements work. |
PgBouncer before 1.21 in transaction mode, or max_prepared_statements = 0 | preparedStatements: false and wakeUp: 'poll'. |
new PostgresEventStore({
pool, // connects through PgBouncer
wakeUp: 'poll',
preparedStatements: false,
});If you forget preparedStatements: false, the first statement that lands on another session fails once, the store stops preparing and logs [PgExecutor](query) The server does not keep prepared statements per connection. When you poll, shorten dispatcher.pollInterval to the latency you need. See Tuning.
New optional APIs
None of these is required to upgrade.
- Event catalogs:
defineCatalog,HandlersOf,defineHandlers,bus.forCatalog,bus.subscribeCatalog,consume(...)andproduce(...)roles, and@EventConsumerin@relaybus/nestjs. Guide. - Foreign messages:
decode: plainJsonDecoder(...)anddecode: decodeCloudEventon the Kafka and RabbitMQ transports. Guide. - Store options:
wakeUp,preparedStatements,completionHistory. Tuning.
Rollback
Going back to 0.1.0 needs a restore of the backup you took before step 2. Keep it until you are happy with 0.2.0 in production.