Skip to content

Commit 1faf89b

Browse files
AP-5046 outbox-core package for transactional outbox pattern (#204)
1 parent ab8ce42 commit 1faf89b

File tree

12 files changed

+793
-0
lines changed

12 files changed

+793
-0
lines changed

packages/outbox-core/README.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# outbox-core
2+
3+
Main package that contains the core functionality of the Outbox pattern to provide "at least once" delivery semantics for messages.
4+
5+
## Installation
6+
7+
```bash
8+
npm i -S @message-queue-toolkit/outbox-core
9+
```
10+
11+
## Usage
12+
13+
To process outbox entries and emit them to the message queue, you need to create an instance of the `OutboxPeriodicJob` class:
14+
15+
```typescript
16+
import { OutboxPeriodicJob } from '@message-queue-toolkit/outbox-core';
17+
18+
const job = new OutboxPeriodicJob(
19+
//Implementation of OutboxStorage interface, TODO: Point to other packages in message-queue-toolkit
20+
outboxStorage,
21+
//Default available accumulator for gathering outbox entries as the process job is progressing.
22+
new InMemoryOutboxAccumulator(),
23+
//DomainEventEmitter, it will be used to publish events, see @message-queue-toolkit/core
24+
eventEmitter,
25+
//See PeriodicJobDependencies from @lokalise/background-jobs-common
26+
dependencies,
27+
//Retry count, how many times outbox entries should be retried to be processed
28+
3,
29+
//emitBatchSize - how many outbox entries should be emitted at once
30+
10,
31+
//internalInMs - how often the job should be executed, e.g. below it runs every 1sec
32+
1000
33+
)
34+
```
35+
36+
Job will take care of processing outbox entries emitted by:
37+
```typescript
38+
import {
39+
type CommonEventDefinition,
40+
enrichMessageSchemaWithBase,
41+
} from '@message-queue-toolkit/schemas'
42+
43+
const MyEvents = {
44+
created: {
45+
...enrichMessageSchemaWithBase(
46+
'entity.created',
47+
z.object({
48+
message: z.string(),
49+
}),
50+
),
51+
},
52+
} as const satisfies Record<string, CommonEventDefinition>
53+
54+
type MySupportedEvents = (typeof TestEvents)[keyof typeof TestEvents][]
55+
56+
const emitter = new OutboxEventEmitter<MySupportedEvents>(
57+
//Same instance of outbox storage that is used by OutboxPeriodicJob
58+
outboxStorage
59+
)
60+
61+
//It pushes the entry to the storage, later will be picked up by the OutboxPeriodicJob
62+
await emitter.emit(/* args */)
63+
```

packages/outbox-core/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './lib/outbox'
2+
export * from './lib/objects'
3+
export * from './lib/accumulators'
4+
export * from './lib/storage'
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { CommonEventDefinition } from '@message-queue-toolkit/schemas'
2+
import type { OutboxEntry } from './objects.ts'
3+
4+
/**
5+
* Accumulator is responsible for storing outbox entries in two cases:
6+
* - successfully dispatched event
7+
* - failed events
8+
*
9+
* Thanks to this, we can use aggregated result and persist in the storage in batches.
10+
*/
11+
export interface OutboxAccumulator<SupportedEvents extends CommonEventDefinition[]> {
12+
/**
13+
* Accumulates successfully dispatched event.
14+
* @param outboxEntry
15+
*/
16+
add(outboxEntry: OutboxEntry<SupportedEvents[number]>): Promise<void>
17+
18+
/**
19+
* Accumulates failed event.
20+
* @param outboxEntry
21+
*/
22+
addFailure(outboxEntry: OutboxEntry<SupportedEvents[number]>): Promise<void>
23+
24+
/**
25+
* Returns all entries that should be persisted as successful ones.
26+
*/
27+
getEntries(): Promise<OutboxEntry<SupportedEvents[number]>[]>
28+
29+
/**
30+
* Returns all entries that should be persisted as failed ones. Such entries will be retried + their retryCount will be incremented.
31+
*/
32+
getFailedEntries(): Promise<OutboxEntry<SupportedEvents[number]>[]>
33+
34+
/**
35+
* After running clear(), no entries should be returned by getEntries() and getFailedEntries().
36+
*
37+
* clear() is always called after flush() in OutboxStorage.
38+
*/
39+
clear(): Promise<void>
40+
}
41+
42+
export class InMemoryOutboxAccumulator<SupportedEvents extends CommonEventDefinition[]>
43+
implements OutboxAccumulator<SupportedEvents>
44+
{
45+
private entries: OutboxEntry<SupportedEvents[number]>[] = []
46+
private failedEntries: OutboxEntry<SupportedEvents[number]>[] = []
47+
48+
public add(outboxEntry: OutboxEntry<SupportedEvents[number]>) {
49+
this.entries.push(outboxEntry)
50+
51+
return Promise.resolve()
52+
}
53+
54+
public addFailure(outboxEntry: OutboxEntry<SupportedEvents[number]>) {
55+
this.failedEntries.push(outboxEntry)
56+
57+
return Promise.resolve()
58+
}
59+
60+
getEntries(): Promise<OutboxEntry<SupportedEvents[number]>[]> {
61+
return Promise.resolve(this.entries)
62+
}
63+
64+
getFailedEntries(): Promise<OutboxEntry<SupportedEvents[number]>[]> {
65+
return Promise.resolve(this.failedEntries)
66+
}
67+
68+
public clear(): Promise<void> {
69+
this.entries = []
70+
this.failedEntries = []
71+
return Promise.resolve()
72+
}
73+
}

packages/outbox-core/lib/objects.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type {
2+
CommonEventDefinition,
3+
CommonEventDefinitionPublisherSchemaType,
4+
ConsumerMessageMetadataType,
5+
} from '@message-queue-toolkit/schemas'
6+
7+
/**
8+
* Status of the outbox entry.
9+
* - CREATED - entry was created and is waiting to be processed to publish actual event
10+
* - ACKED - entry was picked up by outbox job and is being processed
11+
* - SUCCESS - entry was successfully processed, event was published
12+
* - FAILED - entry processing failed, it will be retried
13+
*/
14+
export type OutboxEntryStatus = 'CREATED' | 'ACKED' | 'SUCCESS' | 'FAILED'
15+
16+
export type OutboxEntry<SupportedEvent extends CommonEventDefinition> = {
17+
id: string
18+
event: SupportedEvent
19+
data: Omit<CommonEventDefinitionPublisherSchemaType<SupportedEvent>, 'type'>
20+
precedingMessageMetadata?: Partial<ConsumerMessageMetadataType>
21+
status: OutboxEntryStatus
22+
created: Date
23+
updated?: Date
24+
retryCount: number
25+
}

packages/outbox-core/lib/outbox.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import type { PeriodicJobDependencies } from '@lokalise/background-jobs-common'
2+
import { AbstractPeriodicJob, type JobExecutionContext } from '@lokalise/background-jobs-common'
3+
import type {
4+
CommonEventDefinition,
5+
CommonEventDefinitionPublisherSchemaType,
6+
ConsumerMessageMetadataType,
7+
DomainEventEmitter,
8+
} from '@message-queue-toolkit/core'
9+
import { PromisePool } from '@supercharge/promise-pool'
10+
import { uuidv7 } from 'uuidv7'
11+
import type { OutboxAccumulator } from './accumulators'
12+
import type { OutboxEntry } from './objects'
13+
import type { OutboxStorage } from './storage'
14+
15+
export type OutboxDependencies<SupportedEvents extends CommonEventDefinition[]> = {
16+
outboxStorage: OutboxStorage<SupportedEvents>
17+
outboxAccumulator: OutboxAccumulator<SupportedEvents>
18+
eventEmitter: DomainEventEmitter<SupportedEvents>
19+
}
20+
21+
export type OutboxProcessorConfiguration = {
22+
maxRetryCount: number
23+
emitBatchSize: number
24+
}
25+
26+
export type OutboxConfiguration = {
27+
jobIntervalInMs: number
28+
} & OutboxProcessorConfiguration
29+
30+
/**
31+
* Main logic for handling outbox entries.
32+
*
33+
* If entry is rejected, it is NOT going to be handled during the same execution. Next execution will pick it up.
34+
*/
35+
export class OutboxProcessor<SupportedEvents extends CommonEventDefinition[]> {
36+
constructor(
37+
private readonly outboxDependencies: OutboxDependencies<SupportedEvents>,
38+
private readonly outboxProcessorConfiguration: OutboxProcessorConfiguration,
39+
) {}
40+
41+
public async processOutboxEntries(context: JobExecutionContext) {
42+
const { outboxStorage, eventEmitter, outboxAccumulator } = this.outboxDependencies
43+
44+
const entries = await outboxStorage.getEntries(this.outboxProcessorConfiguration.maxRetryCount)
45+
46+
const filteredEntries =
47+
entries.length === 0 ? entries : await this.getFilteredEntries(entries, outboxAccumulator)
48+
49+
await PromisePool.for(filteredEntries)
50+
.withConcurrency(this.outboxProcessorConfiguration.emitBatchSize)
51+
.process(async (entry) => {
52+
try {
53+
await eventEmitter.emit(entry.event, entry.data, entry.precedingMessageMetadata)
54+
await outboxAccumulator.add(entry)
55+
} catch (e) {
56+
context.logger.error({ error: e }, 'Failed to process outbox entry.')
57+
58+
await outboxAccumulator.addFailure(entry)
59+
}
60+
})
61+
62+
await outboxStorage.flush(outboxAccumulator)
63+
await outboxAccumulator.clear()
64+
}
65+
66+
private async getFilteredEntries(
67+
entries: OutboxEntry<SupportedEvents[number]>[],
68+
outboxAccumulator: OutboxAccumulator<SupportedEvents>,
69+
) {
70+
const currentEntriesInAccumulator = new Set(
71+
(await outboxAccumulator.getEntries()).map((entry) => entry.id),
72+
)
73+
return entries.filter((entry) => !currentEntriesInAccumulator.has(entry.id))
74+
}
75+
}
76+
77+
/**
78+
* Periodic job that processes outbox entries every "intervalInMs". If processing takes longer than defined interval, another subsequent job WILL NOT be started.
79+
*
80+
* When event is published, and then entry is accumulated into SUCCESS group. If processing fails, entry is accumulated as FAILED and will be retried.
81+
*
82+
* Max retry count is defined by the user.
83+
*/
84+
/* c8 ignore start */
85+
export class OutboxPeriodicJob<
86+
SupportedEvents extends CommonEventDefinition[],
87+
> extends AbstractPeriodicJob {
88+
private readonly outboxProcessor: OutboxProcessor<SupportedEvents>
89+
90+
constructor(
91+
outboxDependencies: OutboxDependencies<SupportedEvents>,
92+
outboxConfiguration: OutboxConfiguration,
93+
dependencies: PeriodicJobDependencies,
94+
) {
95+
super(
96+
{
97+
jobId: 'OutboxJob',
98+
schedule: {
99+
intervalInMs: outboxConfiguration.jobIntervalInMs,
100+
},
101+
singleConsumerMode: {
102+
enabled: true,
103+
},
104+
},
105+
{
106+
redis: dependencies.redis,
107+
logger: dependencies.logger,
108+
transactionObservabilityManager: dependencies.transactionObservabilityManager,
109+
errorReporter: dependencies.errorReporter,
110+
scheduler: dependencies.scheduler,
111+
},
112+
)
113+
114+
this.outboxProcessor = new OutboxProcessor<SupportedEvents>(
115+
outboxDependencies,
116+
outboxConfiguration,
117+
)
118+
}
119+
120+
protected async processInternal(context: JobExecutionContext): Promise<void> {
121+
await this.outboxProcessor.processOutboxEntries(context)
122+
}
123+
}
124+
/* c8 ignore stop */
125+
126+
export class OutboxEventEmitter<SupportedEvents extends CommonEventDefinition[]> {
127+
constructor(private storage: OutboxStorage<SupportedEvents>) {}
128+
129+
/**
130+
* Persists outbox entry in persistence layer, later it will be picked up by outbox job.
131+
* @param supportedEvent
132+
* @param data
133+
* @param precedingMessageMetadata
134+
*/
135+
public async emit<SupportedEvent extends SupportedEvents[number]>(
136+
supportedEvent: SupportedEvent,
137+
data: Omit<CommonEventDefinitionPublisherSchemaType<SupportedEvent>, 'type'>,
138+
precedingMessageMetadata?: Partial<ConsumerMessageMetadataType>,
139+
) {
140+
await this.storage.createEntry({
141+
id: uuidv7(),
142+
event: supportedEvent,
143+
data,
144+
precedingMessageMetadata,
145+
status: 'CREATED',
146+
created: new Date(),
147+
retryCount: 0,
148+
})
149+
}
150+
}

packages/outbox-core/lib/storage.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { CommonEventDefinition } from '@message-queue-toolkit/schemas'
2+
import type { OutboxAccumulator } from './accumulators'
3+
import type { OutboxEntry } from './objects'
4+
5+
/**
6+
* Takes care of persisting and retrieving outbox entries.
7+
*
8+
* Implementation is required:
9+
* - in order to fulfill at least once delivery guarantee, persisting entries should be performed inside isolated transaction
10+
* - to return entries in the order they were created (UUID7 is used to create entries in OutboxEventEmitter)
11+
* - returned entries should not include the ones with 'SUCCESS' status
12+
*/
13+
export interface OutboxStorage<SupportedEvents extends CommonEventDefinition[]> {
14+
createEntry(
15+
outboxEntry: OutboxEntry<SupportedEvents[number]>,
16+
): Promise<OutboxEntry<SupportedEvents[number]>>
17+
18+
/**
19+
* Responsible for taking all entries from the accumulator and persisting them in the storage.
20+
*
21+
* - Items that are in OutboxAccumulator::getEntries MUST be changed to SUCCESS status and `updatedAt` field needs to be set.
22+
* - Items that are in OutboxAccumulator::getFailedEntries MUST be changed to FAILED status, `updatedAt` field needs to be set and retryCount needs to be incremented.
23+
*/
24+
flush(outboxAccumulator: OutboxAccumulator<SupportedEvents>): Promise<void>
25+
26+
/**
27+
* Returns entries in the order they were created. It doesn't return entries with 'SUCCESS' status. It doesn't return entries that have been retried more than maxRetryCount times.
28+
*
29+
* For example if entry retryCount is 1 and maxRetryCount is 1, entry MUST be returned. If it fails again then retry count is 2, in that case entry MUST NOT be returned.
30+
*/
31+
getEntries(maxRetryCount: number): Promise<OutboxEntry<SupportedEvents[number]>[]>
32+
}

0 commit comments

Comments
 (0)