@axinom/mosaic-transactional-inbox-outbox
Version:
This library encapsulates the Mosaic based transactional inbox and outbox pattern
181 lines (171 loc) • 6.79 kB
text/typescript
import { MessagingSettings } from '@axinom/mosaic-message-bus-abstractions';
import {
DatabaseClient,
MessageError,
StoredTransactionalMessage,
TransactionalMessageHandler,
} from 'pg-transactional-outbox';
import { Dict, InboxOutboxLogger, TypedTransactionalMessage } from '../common';
interface HandlePreprocessor<
TMessage,
TContext extends Dict<unknown> = Dict<unknown>,
> {
(
message: TypedTransactionalMessage<TMessage>,
envOwnerClient: DatabaseClient,
error?: Error,
): Promise<TContext | undefined>;
}
export abstract class TransactionalInboxMessageHandler<
TMessage,
TConfig,
TContext extends Dict<unknown> = Dict<unknown>,
> implements TransactionalMessageHandler
{
public readonly aggregateType: string;
public readonly messageType: string;
/**
* Create a new Message handler that provides both the functionality to store
* an incoming RabbitMQ message in the inbox and provides the actual logic to
* execute the business logic that is based on that message.
* @param messagingSettings The definitions of the message that is handled.
* @param config The service configuration object
* @param handlePreprocessor A preprocessor that is called before the `handleMessage` and `handleErrorMessage`methods. It can be used for validations and to provide an optional context.
*/
constructor(
protected messagingSettings: MessagingSettings,
protected logger: InboxOutboxLogger,
protected config: TConfig,
protected handlePreprocessor?: HandlePreprocessor<TMessage, TContext>,
) {
this.aggregateType = messagingSettings.aggregateType;
this.messageType = messagingSettings.messageType;
}
/**
* Implement to execute your custom business logic to handle a message that was stored in the inbox.
* @param message The inbox message with the payload to handle.
* @param envOwnerClient The database client that is part of a DB env owner transaction to safely handle the inbox message.
* @throws If something fails and the inbox message should NOT be acknowledged - throw an error.
*/
abstract handleMessage(
message: TypedTransactionalMessage<TMessage>,
envOwnerClient: DatabaseClient,
context?: TContext,
): Promise<void>;
/**
* Optionally override this method with your custom business logic to handle
* an error that was caused by the `handleMessage` method. The default
* implementation logs the error if there are no further retries.
* @param error The error that was thrown in the `handleMessage` method.
* @param message The inbox message with the payload that was attempted to be handled.
* @param envOwnerClient The database client that is part of a (new) transaction to safely handle the error.
* @param retry True if the message will be retried again.
* @param context An optional context that can be provided
*/
public async handleErrorMessage(
error: Error,
message: TypedTransactionalMessage<TMessage>,
_envOwnerClient: DatabaseClient,
retry: boolean,
_context?: TContext,
): Promise<void> {
if (retry) {
return;
}
this.logger.error(error, {
message: `The final message handling attempt failed: ${error.message}`,
details: { ...message },
});
}
/**
* Is called from the transactional inbox and calls the abstract
* `handleMessage` function with the correct message payload typing. Do not
* override this message but implement the `handleMessage` message.
* @param message The inbox message with the payload to handle.
* @param envOwnerClient The database client that is part of a DB env owner transaction to safely handle the inbox message.
*/
public async handle(
message: StoredTransactionalMessage,
envOwnerClient: DatabaseClient,
): Promise<void> {
const msg = <TypedTransactionalMessage<TMessage>>message;
const context = await this.handlePreprocessor?.(msg, envOwnerClient);
return this.handleMessage(msg, envOwnerClient, context);
}
/**
* Is called from the transactional inbox and calls the `handleErrorMessage`
* function with the correct message payload typing. Do not override this
* message but override the `handleErrorMessage` message.
* @param error The error that was thrown in the handle method.
* @param message The inbox message with the payload that was attempted to be handled.
* @param envOwnerClient The database client that is part of a (new) transaction to safely handle the error.
* @param retry True if the message will be retried again.
* @returns A flag that defines if the message should be retried ('transient_error') or not ('permanent_error')
*/
public async handleError(
error: Error,
message: StoredTransactionalMessage,
envOwnerClient: DatabaseClient,
retry: boolean,
): Promise<void> {
const msg = <TypedTransactionalMessage<TMessage>>message;
const e = this.mapError(
error instanceof MessageError && !!error.innerError
? error.innerError
: error,
);
let context: TContext | undefined;
try {
context = await this.handlePreprocessor?.(msg, envOwnerClient, e);
this.updateErrorDetails(e, message, context);
} catch (err) {
this.logger.warn(err instanceof Error ? err : new Error(String(err)), {
message: 'The preprocessor in the inbox message error handler failed.',
details: { ...msg },
});
}
await this.handleErrorMessage(
e,
<TypedTransactionalMessage<TMessage>>message,
envOwnerClient,
retry,
context,
);
}
/**
* Function to map a thrown error of an unspecified type to a potentially more
* human-readable error or remove unsafe properties.
*/
public mapError(error: Error): Error {
return error;
}
/**
* Modifies the original error by extending its details subobject, if exists.
* Default behavior adds tenantId and environmentId to the details object by
* parsing the routing key. If still not available - returns original error.
* Some routing keys will not have tenantId and environmentId, e.g. when a
* command is sent. A separate handling is needed to extact these values, e.g.
* from the jwt.
*/
protected updateErrorDetails(
error: Error & { details?: Record<string, unknown> },
message: StoredTransactionalMessage,
_context?: TContext,
): void {
const [_serviceId, tenantId, environmentId] = (
(message.metadata?.fields as Record<string, string>)?.routingKey ?? ''
).split('.');
if (
tenantId &&
tenantId !== '*' &&
environmentId &&
environmentId !== '*'
) {
error.details = {
tenantId,
environmentId,
...(error.details ?? {}),
};
}
}
}