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).
64 lines (62 loc) • 2.98 kB
text/typescript
import { DatabaseClient } from '../common/database';
import { ListenerConfig } from '../common/listener-config';
import { StoredTransactionalMessage } from './transactional-message';
/**
* This function increases the "started_attempts" for the outbox or inbox
* message by one in the table. This number can then be compared to the
* "finished_attempts" number which is only increased when a message processing
* exception was correctly handled or an error was caught. A difference between
* the two can only happen if the service crashes after increasing the
* "started_attempts" but before successfully marking the message as done
* (success case) or catching an error (error case). If the "started_attempts"
* and the "finished_attempts" field differ by more than one, the chances are
* high that this message is causing a service crash.
* It sets the started_attempts, finished_attempts, locked_until. abandoned_at,
* and processed_at values on the message.
* For additional safety it makes sure, that the message was not and is not
* currently being worked on.
* @param message The message for which to acquire a lock and increment the started_attempts
* @param client The database client. Must be part of a transaction that runs before the message handling transaction.
* @param config The configuration settings that defines the database schema.
* @returns 'MESSAGE_NOT_FOUND' if the message was not found, 'ALREADY_PROCESSED' if it was processed, and otherwise assigns the properties to the message and returns true.
*/
export const startedAttemptsIncrement = async (
message: StoredTransactionalMessage,
client: DatabaseClient,
{ settings }: Pick<ListenerConfig, 'settings'>,
): Promise<
true | 'MESSAGE_NOT_FOUND' | 'ALREADY_PROCESSED' | 'ABANDONED_MESSAGE'
> => {
// Use a NOWAIT select to fully lock and immediately fail if another process is locking that message row
const updateResult = await client.query(
/* sql */ `
UPDATE ${settings.dbSchema}.${settings.dbTable} SET started_attempts = started_attempts + 1 WHERE id IN
(SELECT id FROM ${settings.dbSchema}.${settings.dbTable} WHERE id = $1 FOR UPDATE NOWAIT)
RETURNING started_attempts, finished_attempts, locked_until, processed_at, abandoned_at;`,
[message.id],
);
if (updateResult.rowCount === 0) {
return 'MESSAGE_NOT_FOUND';
}
const {
started_attempts,
finished_attempts,
locked_until,
processed_at,
abandoned_at,
} = updateResult.rows[0];
if (processed_at) {
return 'ALREADY_PROCESSED';
}
if (abandoned_at) {
return 'ABANDONED_MESSAGE';
}
// set the values for the poisonous message strategy
message.startedAttempts = started_attempts;
message.finishedAttempts = finished_attempts;
message.lockedUntil = locked_until?.toISOString() ?? null;
// If we get here the message is neither processed nor abandoned
message.processedAt = null;
message.abandonedAt = null;
return true;
};