@axinom/mosaic-transactional-inbox-outbox
Version:
This library encapsulates the Mosaic based transactional inbox and outbox pattern
197 lines (187 loc) • 6.67 kB
text/typescript
import { MessageHandler, MessageInfo } from '@axinom/mosaic-message-bus';
import { MessagingSettings } from '@axinom/mosaic-message-bus-abstractions';
import { Mutex } from 'async-mutex';
import { Pool } from 'pg';
import {
DEFAULT_INBOX_MESSAGE_TYPE,
Dict,
InboxOutboxLogger,
UNKNOWN_AGGREGATE_ID,
UNKNOWN_AGGREGATE_TYPE,
} from '../common';
import { OptionalInboxData, StoreInboxMessage } from './setup-inbox-storage';
export interface InboxMessageInput extends OptionalInboxData {
messageId: string;
aggregateId: string;
messagingSettings: Pick<MessagingSettings, 'messageType' | 'aggregateType'>;
payload: unknown;
}
export interface CustomMessageMapper {
(message: MessageInfo): InboxMessageInput | undefined;
}
export interface CustomMessagePreProcessor {
(message: InboxMessageInput): void;
}
export interface InboxWriterCustomizations {
/**
* A custom message mapper for messages that do not follow the Mosaic messaging
* pattern. Returns either an TransactionalMessage or undefined - in this case
* the default mapping is used.
*/
customMessageMapper?: CustomMessageMapper;
/**
* A custom message processor to change message properties before storing the
* inbox message. This can be useful to define the segment or the concurrency
* processing.
*/
customMessagePreProcessor?: CustomMessagePreProcessor;
/**
* An optional list of all messages that are expected for backward compatibility.
* Old clients may not send in the aggregate type yet so this will provide a
* mapping to find the aggregate type by the event type only.
*/
acceptedMessageSettings?: MessagingSettings[];
}
export class RabbitMqInboxWriter extends MessageHandler<unknown> {
private customMessageMapper?: CustomMessageMapper;
private customMessagePreProcessor?: CustomMessagePreProcessor;
private backwardCompatibilityMappings: Dict<string>;
private mutex = new Mutex();
/**
* Creates a new RabbitMQ-based handler that receives all events and commands
* and stores them in the transactional inbox.
* @param storeInboxMessage Function to store the incoming RabbitMQ message in the transactional inbox
* @param customizations Provide custom logic on how the inbox writer should process messages
*/
constructor(
protected storeInboxMessage: StoreInboxMessage,
protected ownerPool: Pool,
protected logger: InboxOutboxLogger,
customizations?: InboxWriterCustomizations,
) {
super(DEFAULT_INBOX_MESSAGE_TYPE);
this.customMessageMapper = customizations?.customMessageMapper;
this.customMessagePreProcessor = customizations?.customMessagePreProcessor;
this.backwardCompatibilityMappings = this.backwardCompatibilityMapper(
customizations?.acceptedMessageSettings,
);
}
//TODO: [feature/image-transactional-in-out-box] remove backwards compatibility after 2024-02-01
backwardCompatibilityMapper = (
acceptedMessageSettings?: MessagingSettings[],
): Dict<string> => {
const backwardCompatibilityMappings: Dict<string> = {};
for (const messageSettings of acceptedMessageSettings ?? []) {
backwardCompatibilityMappings[messageSettings.messageType] =
messageSettings.aggregateType;
}
return backwardCompatibilityMappings;
};
/**
* Store the incoming message in the transactional inbox.
*/
async onMessage(
payload: unknown,
message: MessageInfo<unknown>,
): Promise<void> {
// Using a mutex to ensure that each message is completely inserted
// in the original sort order into the inbox.
const release = await this.mutex.acquire();
let msgInput: InboxMessageInput | undefined;
try {
if (this.customMessageMapper) {
msgInput = this.customMessageMapper(message);
}
if (!msgInput) {
const envelope = message.envelope;
let aggregateType = envelope.aggregate_type;
if (!aggregateType) {
//TODO: [feature/image-transactional-in-out-box] remove backwards compatibility after 2024-02-01
aggregateType =
this.backwardCompatibilityMappings[envelope.message_type] ??
UNKNOWN_AGGREGATE_TYPE;
}
let aggregateId = envelope.aggregate_id;
if (!aggregateId) {
//TODO: [feature/image-transactional-in-out-box] remove backwards compatibility after 2024-02-01
aggregateId = String(
(payload as { id: string | number | undefined })?.id ??
UNKNOWN_AGGREGATE_ID,
);
}
msgInput = {
messageId: envelope.message_id, // Primary key - ensures the message is stored only once
aggregateId,
messagingSettings: {
aggregateType,
messageType: envelope.message_type,
},
payload,
metadata: {
envelopeTimestamp: envelope.timestamp,
messageVersion: envelope.message_version,
messageContext: envelope.message_context,
authToken: envelope.auth_token,
fields: message.fields,
properties: message.properties,
},
concurrency: 'parallel',
segment: unsafeGetEnvironment(envelope.auth_token),
};
}
this.customMessagePreProcessor?.(msgInput);
const {
payload: p,
aggregateId,
messagingSettings,
...optionalData
} = msgInput;
// No transaction for single insert needed - directly using the pool
await this.storeInboxMessage(
aggregateId,
messagingSettings,
p,
this.ownerPool,
optionalData,
);
this.logger.debug({
message: 'Message added to the inbox table.',
details: { ...msgInput },
});
} catch (error) {
const err =
error instanceof Error
? error
: new Error(String(error ?? 'unknown error'));
this.logger.error(err, {
message: 'Could not write the message to the inbox table.',
details: {
...(msgInput ?? message),
},
});
throw err;
} finally {
release();
}
}
}
/**
* Get the environment ID by parsing the JWT without checking the signature.
* This is only used for "fair message processing" - the actual JWT check is
* done in the message handler.
*/
const unsafeGetEnvironment = (
token: string | undefined,
): string | undefined => {
if (!token) {
return undefined;
}
try {
const parsed = JSON.parse(
Buffer.from(token.split('.')[1], 'base64').toString(),
);
return parsed?.environmentId ?? undefined;
} catch {
return undefined;
}
};