pg-transactional-outbox
Version:
A PostgreSQL based transactional outbox and inbox pattern implementation to support exactly once message processing (with at least once message delivery).
129 lines (122 loc) • 4.41 kB
text/typescript
import { ClientConfig, Pool } from 'pg';
import {
ExtendedError,
MessageError,
ensureExtendedError,
} from '../../common/error';
import { OutboxOrInbox } from '../../common/listener-config';
import { TransactionalLogger } from '../../common/logger';
import { FullReplicationListenerConfig } from '../config';
/**
* When some error is caught in the outbox or inbox listener this strategy will
* allow you to log/track the error and potentially remove the underlying issue
* e.g. if the replication slot does not exist after a database failover. It
* returns the wait time until the listener should try to connect again.
*/
export interface ReplicationListenerRestartStrategy {
/**
* Check based on the error how long the listener should wait to restart.
* @param error The caught error object (non Error type errors are wrapped)
* @param logger The logger instance to use for logging the error.
* @param outboxOrInbox The outbox or inbox name
* @returns The time in milliseconds how long the listener should wait before restarting
*/
(
error: ExtendedError,
logger: TransactionalLogger,
outboxOrInbox: OutboxOrInbox,
): Promise<number>;
}
/**
* The default listener restart strategy checks if the error is a PostgreSQL
* error. If the PostgreSQL error is about the replication slot being in use, it
* logs a trace entry and waits for the configured `restartDelaySlotInUseInMs`
* time. Otherwise, it logs an error entry and waits for the configured
* `restartDelayInMs`.
*/
export const defaultReplicationListenerRestartStrategy = (
config: FullReplicationListenerConfig,
): ReplicationListenerRestartStrategy => {
return handleError(config);
};
/**
* The default listener and slot restart strategy uses the same logic as the
* `defaultListenerRestartStrategy`. In addition, it checks if a PostgreSQL error
* is about the replication slot not existing (e.g. after a DB failover). Then
* it tries to create the replication slot with the connection details of the
* replication user slot and waits for the configured `restartDelayInMs`.
*/
export const defaultReplicationListenerAndSlotRestartStrategy = (
config: FullReplicationListenerConfig,
): ReplicationListenerRestartStrategy => {
return handleError(config, createReplicationSlot);
};
const handleError = (
{
settings: {
restartDelayInMs,
restartDelaySlotInUseInMs,
dbReplicationSlot,
},
dbListenerConfig: pgReplicationConfig,
}: FullReplicationListenerConfig,
replicationSlotNotFoundCallback?: typeof createReplicationSlot,
): ReplicationListenerRestartStrategy => {
return async (
error: ExtendedError,
logger: TransactionalLogger,
outboxOrInbox: OutboxOrInbox,
): Promise<number> => {
if ('routine' in error && error.routine === 'ReplicationSlotAcquire') {
if ('code' in error && error.code === '55006') {
logger.trace(
error,
`The replication slot for the ${outboxOrInbox} listener is currently in use.`,
);
return restartDelaySlotInUseInMs;
} else if ('code' in error && error.code === '42704') {
// replication slot not found - best effort to create it again
logger.error(error, error.message);
await replicationSlotNotFoundCallback?.(
pgReplicationConfig,
dbReplicationSlot,
logger,
outboxOrInbox,
);
}
return restartDelayInMs;
}
if (
!(error instanceof MessageError) &&
error.constructor.name !== MessageError.name // needed for jest which has an "instanceof" bug
) {
// Message based errors are already logged
logger.error(error, `Transactional ${outboxOrInbox} listener error`);
}
return restartDelayInMs;
};
};
const createReplicationSlot = async (
pgReplicationConfig: ClientConfig,
dbReplicationSlot: string,
logger: TransactionalLogger,
outboxOrInbox: string,
) => {
const pool = new Pool(pgReplicationConfig);
try {
await pool.query(
/** sql*/ `select pg_create_logical_replication_slot('${dbReplicationSlot}', 'pgoutput');`,
);
} catch (err) {
logger.trace(
ensureExtendedError(err, 'DB_ERROR'),
`Failed to create the replication slot for the ${outboxOrInbox} which does not exist.`,
);
} finally {
try {
await pool.end();
} catch {
// ignore
}
}
};