chore: ability to group events on redis event bus (#7655)

* chore: ability to group events on redis event bus

* chore: fix tests

* Update packages/modules/event-bus-redis/src/services/event-bus-redis.ts

Co-authored-by: Adrien de Peretti <adrien.deperetti@gmail.com>

* chore: change shape of input and body data

* chore: fix builds

* chore: address comments

* chore: fix unit test

---------

Co-authored-by: Adrien de Peretti <adrien.deperetti@gmail.com>
This commit is contained in:
Riqwan Thamir
2024-06-10 22:15:43 +02:00
committed by GitHub
parent 3b8160b564
commit 39ddba2491
24 changed files with 924 additions and 732 deletions

View File

@@ -165,7 +165,8 @@ medusaIntegrationTestRunner({
) )
const logSpy = jest.spyOn(logger, "info") const logSpy = jest.spyOn(logger, "info")
await eventBus.emit("order.created", { await eventBus.emit({
eventName: "order.created",
data: { data: {
order: { order: {
id: "1234", id: "1234",

View File

@@ -35,7 +35,7 @@ export const emitEventStep = createStep(
context, context,
}) })
await eventBus.emit([message]) await eventBus.emit(message)
}, },
async (data: void) => {} async (data: void) => {}
) )

View File

@@ -1,5 +1,4 @@
import { import {
EmitData,
EventBusTypes, EventBusTypes,
IEventBusModuleService, IEventBusModuleService,
Message, Message,
@@ -7,24 +6,9 @@ import {
} from "@medusajs/types" } from "@medusajs/types"
export default class EventBusService implements IEventBusModuleService { export default class EventBusService implements IEventBusModuleService {
emit<T>( async emit<T>(
eventName: string, data: Message<T> | Message<T>[],
data: T, options: Record<string, unknown>
options?: Record<string, unknown>
): Promise<void>
emit<T>(data: EmitData<T>[]): Promise<void>
emit<T>(data: Message<T>[]): Promise<void>
async emit<
T,
TInput extends
| string
| EventBusTypes.EmitData<T>[]
| EventBusTypes.Message<T>[] = string
>(
eventOrData: TInput,
data?: T,
options: Record<string, unknown> = {}
): Promise<void> {} ): Promise<void> {}
subscribe(event: string | symbol, subscriber: Subscriber): this { subscribe(event: string | symbol, subscriber: Subscriber): this {

View File

@@ -1,8 +1,8 @@
import { initModules, InitModulesOptions } from "./init-modules"
import { getDatabaseURL, getMikroOrmWrapper, TestDatabase } from "./database" import { getDatabaseURL, getMikroOrmWrapper, TestDatabase } from "./database"
import { initModules, InitModulesOptions } from "./init-modules"
import { MockEventBusService } from "."
import { ContainerRegistrationKeys, ModulesSdkUtils } from "@medusajs/utils" import { ContainerRegistrationKeys, ModulesSdkUtils } from "@medusajs/utils"
import { MockEventBusService } from "."
export interface SuiteOptions<TService = unknown> { export interface SuiteOptions<TService = unknown> {
MikroOrmWrapper: TestDatabase MikroOrmWrapper: TestDatabase
@@ -75,7 +75,7 @@ export function moduleIntegrationTestRunner<TService = any>({
const moduleOptions_: InitModulesOptions = { const moduleOptions_: InitModulesOptions = {
injectedDependencies: { injectedDependencies: {
[ContainerRegistrationKeys.PG_CONNECTION]: connection, [ContainerRegistrationKeys.PG_CONNECTION]: connection,
["eventBusModuleService"]: new MockEventBusService(), eventBusModuleService: new MockEventBusService(),
[ContainerRegistrationKeys.LOGGER]: console, [ContainerRegistrationKeys.LOGGER]: console,
...injectedDependencies, ...injectedDependencies,
}, },

View File

@@ -1,7 +1,7 @@
import { Context } from "../shared-context" import { Context } from "../shared-context"
export type Subscriber<T = unknown> = ( export type Subscriber<TData = unknown> = (
data: T, data: TData,
eventName: string eventName: string
) => Promise<void> ) => Promise<void>
@@ -14,36 +14,28 @@ export type SubscriberDescriptor = {
subscriber: Subscriber subscriber: Subscriber
} }
export type EventHandler<T = unknown> = ( export type EventHandler<TData = unknown> = (
data: T, data: TData,
eventName: string eventName: string
) => Promise<void> ) => Promise<void>
export type EmitData<T = unknown> = { export type EventMetadata = Record<string, unknown> & {
eventGroupId?: string
}
export type MessageBody<TData = unknown> = {
eventName: string eventName: string
data: T metadata?: EventMetadata
data: TData
}
export type Message<TData = unknown> = MessageBody<TData> & {
options?: Record<string, unknown> options?: Record<string, unknown>
} }
export type MessageBody<T = unknown> = { export type RawMessageFormat<TData = any> = {
metadata: {
source: string
action: string
object: string
eventGroupId?: string
}
data: T
}
export type Message<T = unknown> = {
eventName: string eventName: string
body: MessageBody<T> data: TData
options?: Record<string, unknown>
}
export type RawMessageFormat<T = any> = {
eventName: string
data: T
source: string source: string
object: string object: string
action?: string action?: string

View File

@@ -1,13 +1,10 @@
import { EmitData, Message, Subscriber, SubscriberContext } from "./common" import { Message, Subscriber, SubscriberContext } from "./common"
export interface IEventBusModuleService { export interface IEventBusModuleService {
emit<T>( emit<T>(
eventName: string, data: Message<T> | Message<T>[],
data: T,
options?: Record<string, unknown> options?: Record<string, unknown>
): Promise<void> ): Promise<void>
emit<T>(data: EmitData<T>[]): Promise<void>
emit<T>(data: Message<T>[]): Promise<void>
subscribe( subscribe(
eventName: string | symbol, eventName: string | symbol,

View File

@@ -1,5 +1,5 @@
import { ITransactionBaseService } from "../transaction-base" import { ITransactionBaseService } from "../transaction-base"
import { EmitData, Message, Subscriber, SubscriberContext } from "./common" import { Message, Subscriber, SubscriberContext } from "./common"
export interface IEventBusService extends ITransactionBaseService { export interface IEventBusService extends ITransactionBaseService {
subscribe( subscribe(
@@ -14,7 +14,5 @@ export interface IEventBusService extends ITransactionBaseService {
context?: SubscriberContext context?: SubscriberContext
): this ): this
emit<T>(event: string, data: T, options?: unknown): Promise<unknown | void> emit<T>(data: Message<T> | Message<T>[]): Promise<unknown | void>
emit<T>(data: EmitData<T>[]): Promise<unknown | void>
emit<T>(data: Message<T>[]): Promise<unknown | void>
} }

View File

@@ -9,70 +9,60 @@ describe("MessageAggregator", function () {
const aggregator = new MessageAggregator() const aggregator = new MessageAggregator()
aggregator.save({ aggregator.save({
eventName: "ProductVariant.created", eventName: "ProductVariant.created",
body: { metadata: {
metadata: { source: "ProductService",
source: "ProductService", action: "created",
action: "created", object: "ProductVariant",
object: "ProductVariant", eventGroupId: "1",
eventGroupId: "1",
},
data: { id: 999 },
}, },
data: { id: 999 },
}) })
aggregator.save({ aggregator.save({
eventName: "Product.created", eventName: "Product.created",
body: { metadata: {
metadata: { source: "ProductService",
source: "ProductService", action: "created",
action: "created", object: "Product",
object: "Product", eventGroupId: "1",
eventGroupId: "1",
},
data: { id: 1 },
}, },
data: { id: 1 },
}) })
aggregator.save({ aggregator.save({
eventName: "ProductVariant.created", eventName: "ProductVariant.created",
body: { metadata: {
metadata: { source: "ProductService",
source: "ProductService", action: "created",
action: "created", object: "ProductVariant",
object: "ProductVariant", eventGroupId: "1",
eventGroupId: "1",
},
data: { id: 222 },
}, },
data: { id: 222 },
}) })
aggregator.save({ aggregator.save({
eventName: "ProductType.detached", eventName: "ProductType.detached",
body: { metadata: {
metadata: { source: "ProductService",
source: "ProductService", action: "detached",
action: "detached", object: "ProductType",
object: "ProductType", eventGroupId: "1",
eventGroupId: "1",
},
data: { id: 333 },
}, },
data: { id: 333 },
}) })
aggregator.save({ aggregator.save({
eventName: "ProductVariant.updated", eventName: "ProductVariant.updated",
body: { metadata: {
metadata: { source: "ProductService",
source: "ProductService", action: "updated",
action: "updated", object: "ProductVariant",
object: "ProductVariant", eventGroupId: "1",
eventGroupId: "1",
},
data: { id: 123 },
}, },
data: { id: 123 },
}) })
const format = { const format = {
groupBy: ["eventName", "body.metadata.object", "body.metadata.action"], groupBy: ["eventName", "metadata.object", "metadata.action"],
sortBy: { sortBy: {
"body.metadata.object": ["ProductType", "ProductVariant", "Product"], "metadata.object": ["ProductType", "ProductVariant", "Product"],
"body.data.id": "asc", "data.id": "asc",
}, },
} }
@@ -85,72 +75,62 @@ describe("MessageAggregator", function () {
expect(allGroups[0]).toEqual([ expect(allGroups[0]).toEqual([
{ {
eventName: "ProductType.detached", eventName: "ProductType.detached",
body: { metadata: {
metadata: { source: "ProductService",
source: "ProductService", action: "detached",
action: "detached", object: "ProductType",
object: "ProductType", eventGroupId: "1",
eventGroupId: "1",
},
data: { id: 333 },
}, },
data: { id: 333 },
}, },
]) ])
expect(allGroups[1]).toEqual([ expect(allGroups[1]).toEqual([
{ {
eventName: "ProductVariant.updated", eventName: "ProductVariant.updated",
body: { metadata: {
metadata: { source: "ProductService",
source: "ProductService", action: "updated",
action: "updated", object: "ProductVariant",
object: "ProductVariant", eventGroupId: "1",
eventGroupId: "1",
},
data: { id: 123 },
}, },
data: { id: 123 },
}, },
]) ])
expect(allGroups[2]).toEqual([ expect(allGroups[2]).toEqual([
{ {
eventName: "ProductVariant.created", eventName: "ProductVariant.created",
body: { metadata: {
metadata: { source: "ProductService",
source: "ProductService", action: "created",
action: "created", object: "ProductVariant",
object: "ProductVariant", eventGroupId: "1",
eventGroupId: "1",
},
data: { id: 222 },
}, },
data: { id: 222 },
}, },
{ {
eventName: "ProductVariant.created", eventName: "ProductVariant.created",
body: { metadata: {
metadata: { source: "ProductService",
source: "ProductService", action: "created",
action: "created", object: "ProductVariant",
object: "ProductVariant", eventGroupId: "1",
eventGroupId: "1",
},
data: { id: 999 },
}, },
data: { id: 999 },
}, },
]) ])
expect(allGroups[3]).toEqual([ expect(allGroups[3]).toEqual([
{ {
eventName: "Product.created", eventName: "Product.created",
body: { metadata: {
metadata: { source: "ProductService",
source: "ProductService", action: "created",
action: "created", object: "Product",
object: "Product", eventGroupId: "1",
eventGroupId: "1",
},
data: { id: 1 },
}, },
data: { id: 1 },
}, },
]) ])
}) })

View File

@@ -45,10 +45,8 @@ export function composeMessage(
return { return {
eventName, eventName,
body: { metadata,
metadata, data,
data,
},
options, options,
} }
} }

View File

@@ -17,12 +17,9 @@ export abstract class AbstractEventBusModuleService
} }
abstract emit<T>( abstract emit<T>(
eventName: string, data: EventBusTypes.Message<T> | EventBusTypes.Message<T>[],
data: T,
options: Record<string, unknown> options: Record<string, unknown>
): Promise<void> ): Promise<void>
abstract emit<T>(data: EventBusTypes.EmitData<T>[]): Promise<void>
abstract emit<T>(data: EventBusTypes.Message<T>[]): Promise<void>
/* /*
Grouped events are useful when you have distributed transactions Grouped events are useful when you have distributed transactions

View File

@@ -501,6 +501,7 @@ export function abstractModuleServiceFactory<
await this.eventBusModuleService_?.emit( await this.eventBusModuleService_?.emit(
softDeletedEntities.map(({ id }) => ({ softDeletedEntities.map(({ id }) => ({
eventName: `${kebabCase(model.name)}.deleted`, eventName: `${kebabCase(model.name)}.deleted`,
metadata: { source: "", action: "", object: "" },
data: { id }, data: { id },
})) }))
) )

View File

@@ -1,6 +1,6 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk" import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { PaymentWebhookEvents } from "@medusajs/utils"
import { PaymentModuleOptions } from "@medusajs/types" import { PaymentModuleOptions } from "@medusajs/types"
import { PaymentWebhookEvents } from "@medusajs/utils"
import { MedusaRequest, MedusaResponse } from "../../../../types/routing" import { MedusaRequest, MedusaResponse } from "../../../../types/routing"
@@ -20,10 +20,16 @@ export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const eventBus = req.scope.resolve(ModuleRegistrationName.EVENT_BUS) const eventBus = req.scope.resolve(ModuleRegistrationName.EVENT_BUS)
// we delay the processing of the event to avoid a conflict caused by a race condition // we delay the processing of the event to avoid a conflict caused by a race condition
await eventBus.emit(PaymentWebhookEvents.WebhookReceived, event, { await eventBus.emit(
delay: options.webhook_delay || 5000, {
attempts: options.webhook_retries || 3, eventName: PaymentWebhookEvents.WebhookReceived,
}) data: event,
},
{
delay: options.webhook_delay || 5000,
attempts: options.webhook_retries || 3,
}
)
} catch (err) { } catch (err) {
res.status(400).send(`Webhook Error: ${err.message}`) res.status(400).send(`Webhook Error: ${err.message}`)
return return

View File

@@ -14,7 +14,8 @@ const moduleDeps = {
} }
describe("LocalEventBusService", () => { describe("LocalEventBusService", () => {
let eventBus let eventBus: LocalEventBusService
let eventEmitter
describe("emit", () => { describe("emit", () => {
describe("Successfully emits events", () => { describe("Successfully emits events", () => {
@@ -22,148 +23,184 @@ describe("LocalEventBusService", () => {
jest.clearAllMocks() jest.clearAllMocks()
eventBus = new LocalEventBusService(moduleDeps as any) eventBus = new LocalEventBusService(moduleDeps as any)
eventEmitter = (eventBus as any).eventEmitter_
}) })
it("should emit an event", async () => { it("should emit an event", async () => {
eventBus.eventEmitter_.emit.mockImplementationOnce((data) => data) eventEmitter.emit = jest.fn((data) => data)
await eventBus.emit("eventName", { hi: "1234" }) await eventBus.emit({
eventName: "eventName",
data: { hi: "1234" },
})
expect(eventBus.eventEmitter_.emit).toHaveBeenCalledTimes(1) expect(eventEmitter.emit).toHaveBeenCalledTimes(1)
expect(eventBus.eventEmitter_.emit).toHaveBeenCalledWith("eventName", { expect(eventEmitter.emit).toHaveBeenCalledWith("eventName", {
hi: "1234", data: { hi: "1234" },
}) })
}) })
it("should emit multiple events", async () => { it("should emit multiple events", async () => {
eventBus.eventEmitter_.emit.mockImplementationOnce((data) => data) eventEmitter.emit = jest.fn((data) => data)
await eventBus.emit([ await eventBus.emit([
{ eventName: "event-1", data: { hi: "1234" } }, { eventName: "event-1", data: { hi: "1234" } },
{ eventName: "event-2", data: { hi: "5678" } }, { eventName: "event-2", data: { hi: "5678" } },
]) ])
expect(eventBus.eventEmitter_.emit).toHaveBeenCalledTimes(2) expect(eventEmitter.emit).toHaveBeenCalledTimes(2)
expect(eventBus.eventEmitter_.emit).toHaveBeenCalledWith("event-1", { expect(eventEmitter.emit).toHaveBeenCalledWith("event-1", {
hi: "1234", data: { hi: "1234" },
}) })
expect(eventBus.eventEmitter_.emit).toHaveBeenCalledWith("event-2", { expect(eventEmitter.emit).toHaveBeenCalledWith("event-2", {
hi: "5678", data: { hi: "5678" },
}) })
}) })
it("should group an event if data consists of eventGroupId", async () => { it("should group an event if data consists of eventGroupId", async () => {
const groupEventFn = jest.spyOn(eventBus, "groupEvent") let groupEventFn = jest.spyOn(eventBus, "groupEvent" as any)
eventEmitter.emit = jest.fn((data) => data)
eventBus.eventEmitter_.emit.mockImplementationOnce((data) => data) await eventBus.emit({
eventName: "test-event",
await eventBus.emit("test-event", { data: {
test: "1234", test: "1234",
eventGroupId: "test", },
metadata: {
eventGroupId: "test",
},
}) })
expect(eventBus.eventEmitter_.emit).not.toHaveBeenCalled() expect(eventEmitter.emit).not.toHaveBeenCalled()
expect(groupEventFn).toHaveBeenCalledTimes(1) expect(groupEventFn).toHaveBeenCalledTimes(1)
expect(groupEventFn).toHaveBeenCalledWith("test", "test-event", { expect(groupEventFn).toHaveBeenCalledWith("test", {
test: "1234", data: { test: "1234" },
metadata: { eventGroupId: "test" },
eventName: "test-event",
}) })
jest.clearAllMocks() jest.clearAllMocks()
eventBus.eventEmitter_.emit.mockImplementationOnce((data) => data) groupEventFn = jest.spyOn(eventBus, "groupEvent" as any)
eventBus.emit("test-event", { test: "1234", eventGroupId: "test" }) eventEmitter.emit = jest.fn((data) => data)
eventBus.emit("test-event", { test: "test-1" })
eventBus.emit([
{
eventName: "test-event",
data: { test: "1234" },
metadata: { eventGroupId: "test" },
},
{
eventName: "test-event",
data: { test: "test-1" },
},
])
expect(eventBus.eventEmitter_.emit).toHaveBeenCalledTimes(1)
expect(groupEventFn).toHaveBeenCalledTimes(1) expect(groupEventFn).toHaveBeenCalledTimes(1)
expect(eventBus.groupedEventsMap_.get("test")).toEqual([ expect((eventBus as any).groupedEventsMap_.get("test")).toEqual([
expect.objectContaining({ eventName: "test-event" }), expect.objectContaining({ eventName: "test-event" }),
expect.objectContaining({ eventName: "test-event" }), expect.objectContaining({ eventName: "test-event" }),
]) ])
await eventBus.emit("test-event", { await eventBus.emit({
test: "1234", eventName: "test-event",
eventGroupId: "test-2", data: { test: "1234" },
metadata: { eventGroupId: "test-2" },
}) })
expect(eventBus.groupedEventsMap_.get("test-2")).toEqual([ expect((eventBus as any).groupedEventsMap_.get("test-2")).toEqual([
expect.objectContaining({ eventName: "test-event" }), expect.objectContaining({ eventName: "test-event" }),
]) ])
}) })
it("should release events when requested with eventGroupId", async () => { it("should release events when requested with eventGroupId", async () => {
eventBus.eventEmitter_.emit.mockImplementationOnce((data) => data) eventEmitter.emit = jest.fn((data) => data)
await eventBus.emit([ await eventBus.emit([
{ {
eventName: "event-1", eventName: "event-1",
data: { test: "1", eventGroupId: "group-1" }, data: { test: "1" },
metadata: { eventGroupId: "group-1" },
}, },
{ {
eventName: "event-2", eventName: "event-2",
data: { test: "2", eventGroupId: "group-1" }, data: { test: "2" },
metadata: { eventGroupId: "group-1" },
}, },
{ {
eventName: "event-1", eventName: "event-1",
data: { test: "1", eventGroupId: "group-2" }, data: { test: "1" },
metadata: { eventGroupId: "group-2" },
}, },
{ {
eventName: "event-2", eventName: "event-2",
data: { test: "2", eventGroupId: "group-2" }, data: { test: "2" },
metadata: { eventGroupId: "group-2" },
}, },
{ eventName: "event-1", data: { test: "1" } }, { eventName: "event-1", data: { test: "1" } },
]) ])
expect(eventBus.eventEmitter_.emit).toHaveBeenCalledTimes(1) expect(eventEmitter.emit).toHaveBeenCalledTimes(1)
expect(eventBus.eventEmitter_.emit).toHaveBeenCalledWith("event-1", { expect(eventEmitter.emit).toHaveBeenCalledWith("event-1", {
test: "1", data: { test: "1" },
}) })
expect(eventBus.groupedEventsMap_.get("group-1")).toHaveLength(2) expect((eventBus as any).groupedEventsMap_.get("group-1")).toHaveLength(
expect(eventBus.groupedEventsMap_.get("group-2")).toHaveLength(2) 2
)
expect((eventBus as any).groupedEventsMap_.get("group-2")).toHaveLength(
2
)
jest.clearAllMocks() jest.clearAllMocks()
eventBus.eventEmitter_.emit.mockImplementationOnce((data) => data) eventEmitter.emit = jest.fn((data) => data)
eventBus.releaseGroupedEvents("group-1") eventBus.releaseGroupedEvents("group-1")
expect(eventBus.groupedEventsMap_.get("group-1")).not.toBeDefined() expect(
expect(eventBus.groupedEventsMap_.get("group-2")).toHaveLength(2) (eventBus as any).groupedEventsMap_.get("group-1")
).not.toBeDefined()
expect((eventBus as any).groupedEventsMap_.get("group-2")).toHaveLength(
2
)
expect(eventBus.eventEmitter_.emit).toHaveBeenCalledTimes(2) expect(eventEmitter.emit).toHaveBeenCalledTimes(2)
expect(eventBus.eventEmitter_.emit).toHaveBeenCalledWith("event-1", { expect(eventEmitter.emit).toHaveBeenCalledWith("event-1", {
test: "1", data: { test: "1" },
}) })
expect(eventBus.eventEmitter_.emit).toHaveBeenCalledWith("event-2", { expect(eventEmitter.emit).toHaveBeenCalledWith("event-2", {
test: "2", data: { test: "2" },
}) })
}) })
it("should clear events from grouped events when requested with eventGroupId", async () => { it("should clear events from grouped events when requested with eventGroupId", async () => {
eventBus.eventEmitter_.emit.mockImplementationOnce((data) => data) eventEmitter.emit = jest.fn((data) => data)
const getMap = () => (eventBus as any).groupedEventsMap_
await eventBus.emit([ await eventBus.emit([
{ {
eventName: "event-1", eventName: "event-1",
data: { test: "1", eventGroupId: "group-1" }, data: { test: "1" },
metadata: { eventGroupId: "group-1" },
}, },
{ {
eventName: "event-1", eventName: "event-1",
data: { test: "1", eventGroupId: "group-2" }, data: { test: "1" },
metadata: { eventGroupId: "group-2" },
}, },
]) ])
expect(eventBus.groupedEventsMap_.get("group-1")).toHaveLength(1) expect(getMap().get("group-1")).toHaveLength(1)
expect(eventBus.groupedEventsMap_.get("group-2")).toHaveLength(1) expect(getMap().get("group-2")).toHaveLength(1)
eventBus.clearGroupedEvents("group-1") eventBus.clearGroupedEvents("group-1")
expect(eventBus.groupedEventsMap_.get("group-1")).not.toBeDefined() expect(getMap().get("group-1")).not.toBeDefined()
expect(eventBus.groupedEventsMap_.get("group-2")).toHaveLength(1) expect(getMap().get("group-2")).toHaveLength(1)
eventBus.clearGroupedEvents("group-2") eventBus.clearGroupedEvents("group-2")
expect(eventBus.groupedEventsMap_.get("group-2")).not.toBeDefined() expect(getMap().get("group-2")).not.toBeDefined()
}) })
}) })
}) })

View File

@@ -1,9 +1,9 @@
import { MedusaContainer } from "@medusajs/modules-sdk" import { MedusaContainer } from "@medusajs/modules-sdk"
import { import {
EmitData,
EventBusTypes, EventBusTypes,
Logger, Logger,
Message, Message,
MessageBody,
Subscriber, Subscriber,
} from "@medusajs/types" } from "@medusajs/types"
import { AbstractEventBusModuleService } from "@medusajs/utils" import { AbstractEventBusModuleService } from "@medusajs/utils"
@@ -35,47 +35,28 @@ export default class LocalEventBusService extends AbstractEventBusModuleService
this.groupedEventsMap_ = new Map() this.groupedEventsMap_ = new Map()
} }
async emit<T>( async emit<T = unknown>(
eventName: string, eventsData: Message<T> | Message<T>[],
data: T,
options: Record<string, unknown>
): Promise<void>
/**
* Emit a number of events
* @param {EmitData} data - the data to send to the subscriber.
*/
async emit<T>(data: EmitData<T>[]): Promise<void>
async emit<T>(data: Message<T>[]): Promise<void>
async emit<T, TInput extends string | EmitData<T>[] | Message<T>[] = string>(
eventOrData: TInput,
data?: T,
options: Record<string, unknown> = {} options: Record<string, unknown> = {}
): Promise<void> { ): Promise<void> {
const isBulkEmit = Array.isArray(eventOrData) const normalizedEventsData = Array.isArray(eventsData)
? eventsData
: [eventsData]
const events: EmitData[] | Message<T>[] = isBulkEmit for (const eventData of normalizedEventsData) {
? eventOrData
: [{ eventName: eventOrData, data }]
for (const event of events) {
const eventListenersCount = this.eventEmitter_.listenerCount( const eventListenersCount = this.eventEmitter_.listenerCount(
event.eventName eventData.eventName
) )
this.logger_?.info( this.logger_?.info(
`Processing ${event.eventName} which has ${eventListenersCount} subscribers` `Processing ${eventData.eventName} which has ${eventListenersCount} subscribers`
) )
if (eventListenersCount === 0) { if (eventListenersCount === 0) {
continue continue
} }
const data = (event as EmitData).data ?? (event as Message<T>).body await this.groupOrEmitEvent(eventData)
await this.groupOrEmitEvent(event.eventName, data)
} }
} }
@@ -84,28 +65,27 @@ export default class LocalEventBusService extends AbstractEventBusModuleService
// explicitly requested. // explicitly requested.
// This is useful in the event of a distributed transaction where you'd want to emit // This is useful in the event of a distributed transaction where you'd want to emit
// events only once the transaction ends. // events only once the transaction ends.
private async groupOrEmitEvent( private async groupOrEmitEvent<T = unknown>(eventData: Message<T>) {
eventName: string, const { options, ...eventBody } = eventData
data: unknown & { eventGroupId?: string } const eventGroupId = eventBody.metadata?.eventGroupId
) {
const { eventGroupId, ...eventData } = data
if (eventGroupId) { if (eventGroupId) {
await this.groupEvent(eventGroupId, eventName, eventData) await this.groupEvent(eventGroupId, eventData)
} else { } else {
this.eventEmitter_.emit(eventName, data) this.eventEmitter_.emit(eventData.eventName, {
data: eventData.data,
})
} }
} }
// Groups an event to a queue to be emitted upon explicit release // Groups an event to a queue to be emitted upon explicit release
private async groupEvent( private async groupEvent<T = unknown>(
eventGroupId: string, eventGroupId: string,
eventName: string, eventData: MessageBody<T>
data: unknown
) { ) {
const groupedEvents = this.groupedEventsMap_.get(eventGroupId) || [] const groupedEvents = this.groupedEventsMap_.get(eventGroupId) || []
groupedEvents.push({ eventName, data }) groupedEvents.push(eventData)
this.groupedEventsMap_.set(eventGroupId, groupedEvents) this.groupedEventsMap_.set(eventGroupId, groupedEvents)
} }
@@ -116,7 +96,7 @@ export default class LocalEventBusService extends AbstractEventBusModuleService
for (const event of groupedEvents) { for (const event of groupedEvents) {
const { eventName, data } = event const { eventName, data } = event
this.eventEmitter_.emit(eventName, data) this.eventEmitter_.emit(eventName, { data })
} }
this.clearGroupedEvents(eventGroupId) this.clearGroupedEvents(eventGroupId)

View File

@@ -1,318 +0,0 @@
import { Queue, Worker } from "bullmq"
import RedisEventBusService from "../event-bus-redis"
jest.genMockFromModule("bullmq")
jest.genMockFromModule("ioredis")
jest.mock("bullmq")
jest.mock("ioredis")
const loggerMock = {
info: jest.fn().mockReturnValue(console.log),
warn: jest.fn().mockReturnValue(console.log),
error: jest.fn().mockReturnValue(console.log),
}
const simpleModuleOptions = { redisUrl: "test-url" }
const moduleDeps = {
manager: {},
logger: loggerMock,
eventBusRedisConnection: {},
}
describe("RedisEventBusService", () => {
let eventBus
describe("constructor", () => {
beforeAll(() => {
jest.clearAllMocks()
})
it("Creates a queue + worker", () => {
eventBus = new RedisEventBusService(moduleDeps, simpleModuleOptions, {
resources: "shared",
})
expect(Queue).toHaveBeenCalledTimes(1)
expect(Queue).toHaveBeenCalledWith("events-queue", {
connection: expect.any(Object),
prefix: "RedisEventBusService",
})
expect(Worker).toHaveBeenCalledTimes(1)
expect(Worker).toHaveBeenCalledWith(
"events-queue",
expect.any(Function),
{
connection: expect.any(Object),
prefix: "RedisEventBusService",
}
)
})
it("Throws on isolated module declaration", () => {
try {
eventBus = new RedisEventBusService(moduleDeps, simpleModuleOptions, {
resources: "isolated",
})
} catch (error) {
expect(error.message).toEqual(
"At the moment this module can only be used with shared resources"
)
}
})
})
describe("emit", () => {
describe("Successfully emits events", () => {
beforeEach(() => {
jest.clearAllMocks()
})
it("Adds job to queue with default options", () => {
eventBus = new RedisEventBusService(moduleDeps, simpleModuleOptions, {
resources: "shared",
})
eventBus.queue_.addBulk.mockImplementationOnce(() => "hi")
eventBus.emit("eventName", { hi: "1234" })
expect(eventBus.queue_.addBulk).toHaveBeenCalledTimes(1)
expect(eventBus.queue_.addBulk).toHaveBeenCalledWith([
{
name: "eventName",
data: { eventName: "eventName", data: { hi: "1234" } },
opts: {
attempts: 1,
removeOnComplete: true,
},
},
])
})
it("Adds job to queue with custom options passed directly upon emitting", () => {
eventBus = new RedisEventBusService(moduleDeps, simpleModuleOptions, {
resources: "shared",
})
eventBus.queue_.addBulk.mockImplementationOnce(() => "hi")
eventBus.emit(
"eventName",
{ hi: "1234" },
{ attempts: 3, backoff: 5000, delay: 1000 }
)
expect(eventBus.queue_.addBulk).toHaveBeenCalledTimes(1)
expect(eventBus.queue_.addBulk).toHaveBeenCalledWith([
{
name: "eventName",
data: { eventName: "eventName", data: { hi: "1234" } },
opts: {
attempts: 3,
backoff: 5000,
delay: 1000,
removeOnComplete: true,
},
},
])
})
it("Adds job to queue with module job options", () => {
eventBus = new RedisEventBusService(
moduleDeps,
{
...simpleModuleOptions,
jobOptions: {
removeOnComplete: {
age: 5,
},
attempts: 7,
},
},
{
resources: "shared",
}
)
eventBus.queue_.addBulk.mockImplementationOnce(() => "hi")
eventBus.emit("eventName", { hi: "1234" })
expect(eventBus.queue_.addBulk).toHaveBeenCalledTimes(1)
expect(eventBus.queue_.addBulk).toHaveBeenCalledWith([
{
name: "eventName",
data: { eventName: "eventName", data: { hi: "1234" } },
opts: {
attempts: 7,
removeOnComplete: {
age: 5,
},
},
},
])
})
it("Adds job to queue with default, local, and global options merged", () => {
eventBus = new RedisEventBusService(
moduleDeps,
{
...simpleModuleOptions,
jobOptions: {
removeOnComplete: 5,
},
},
{
resources: "shared",
}
)
eventBus.queue_.addBulk.mockImplementationOnce(() => "hi")
eventBus.emit("eventName", { hi: "1234" }, { delay: 1000 })
expect(eventBus.queue_.addBulk).toHaveBeenCalledTimes(1)
expect(eventBus.queue_.addBulk).toHaveBeenCalledWith([
{
name: "eventName",
data: { eventName: "eventName", data: { hi: "1234" } },
opts: {
attempts: 1,
removeOnComplete: 5,
delay: 1000,
},
},
])
})
})
})
describe("worker_", () => {
let result
describe("Successfully processes the jobs", () => {
beforeEach(async () => {
jest.clearAllMocks()
eventBus = new RedisEventBusService(moduleDeps, simpleModuleOptions, {
resources: "shared",
})
})
it("Processes a simple event with no options", async () => {
eventBus.subscribe("eventName", () => Promise.resolve("hi"))
result = await eventBus.worker_({
data: { eventName: "eventName", data: {} },
opts: { attempts: 1 },
})
expect(loggerMock.info).toHaveBeenCalledTimes(1)
expect(loggerMock.info).toHaveBeenCalledWith(
"Processing eventName which has 1 subscribers"
)
expect(result).toEqual(["hi"])
})
it("Processes event with failing subscribers", async () => {
eventBus.subscribe("eventName", () => Promise.resolve("hi"))
eventBus.subscribe("eventName", () => Promise.reject("fail1"))
eventBus.subscribe("eventName", () => Promise.resolve("hi2"))
eventBus.subscribe("eventName", () => Promise.reject("fail2"))
result = await eventBus.worker_({
data: { eventName: "eventName", data: {} },
update: (data) => data,
opts: { attempts: 1 },
})
expect(loggerMock.info).toHaveBeenCalledTimes(1)
expect(loggerMock.info).toHaveBeenCalledWith(
"Processing eventName which has 4 subscribers"
)
expect(loggerMock.warn).toHaveBeenCalledTimes(3)
expect(loggerMock.warn).toHaveBeenCalledWith(
"An error occurred while processing eventName: fail1"
)
expect(loggerMock.warn).toHaveBeenCalledWith(
"An error occurred while processing eventName: fail2"
)
expect(loggerMock.warn).toHaveBeenCalledWith(
"One or more subscribers of eventName failed. Retrying is not configured. Use 'attempts' option when emitting events."
)
expect(result).toEqual(["hi", "fail1", "hi2", "fail2"])
})
it("Retries processing when subcribers fail, if configured - final attempt", async () => {
eventBus.subscribe("eventName", async () => Promise.resolve("hi"), {
subscriberId: "1",
})
eventBus.subscribe("eventName", async () => Promise.reject("fail1"), {
subscriberId: "2",
})
result = await eventBus
.worker_({
data: {
eventName: "eventName",
data: {},
completedSubscriberIds: ["1"],
},
attemptsMade: 2,
update: (data) => data,
opts: { attempts: 2 },
})
.catch((error) => void 0)
expect(loggerMock.warn).toHaveBeenCalledTimes(1)
expect(loggerMock.warn).toHaveBeenCalledWith(
"An error occurred while processing eventName: fail1"
)
expect(loggerMock.info).toHaveBeenCalledTimes(2)
expect(loggerMock.info).toHaveBeenCalledWith(
"Final retry attempt for eventName"
)
expect(loggerMock.info).toHaveBeenCalledWith(
"Retrying eventName which has 2 subscribers (1 of them failed)"
)
})
it("Retries processing when subcribers fail, if configured", async () => {
eventBus.subscribe("eventName", async () => Promise.resolve("hi"), {
subscriberId: "1",
})
eventBus.subscribe("eventName", async () => Promise.reject("fail1"), {
subscriberId: "2",
})
result = await eventBus
.worker_({
data: {
eventName: "eventName",
data: {},
completedSubscriberIds: ["1"],
},
attemptsMade: 2,
updateData: (data) => data,
opts: { attempts: 3 },
})
.catch((err) => void 0)
expect(loggerMock.warn).toHaveBeenCalledTimes(2)
expect(loggerMock.warn).toHaveBeenCalledWith(
"An error occurred while processing eventName: fail1"
)
expect(loggerMock.warn).toHaveBeenCalledWith(
"One or more subscribers of eventName failed. Retrying..."
)
expect(loggerMock.info).toHaveBeenCalledTimes(1)
expect(loggerMock.info).toHaveBeenCalledWith(
"Retrying eventName which has 2 subscribers (1 of them failed)"
)
})
})
})
})

View File

@@ -0,0 +1,491 @@
import { Logger } from "@medusajs/types"
import { Queue, Worker } from "bullmq"
import { Redis } from "ioredis"
import RedisEventBusService from "../event-bus-redis"
// const redisURL = "redis://localhost:6379"
// const client = new Redis(6379, redisURL, {
// // Lazy connect to properly handle connection errors
// lazyConnect: true,
// maxRetriesPerRequest: 0,
// })
jest.genMockFromModule("bullmq")
jest.genMockFromModule("ioredis")
jest.mock("bullmq")
jest.mock("ioredis")
const loggerMock = {
info: jest.fn().mockReturnValue(console.log),
warn: jest.fn().mockReturnValue(console.log),
error: jest.fn().mockReturnValue(console.log),
} as unknown as Logger
const redisMock = {
del: () => jest.fn(),
rpush: () => jest.fn(),
lrange: () => jest.fn(),
disconnect: () => jest.fn(),
expire: () => jest.fn(),
} as unknown as Redis
const simpleModuleOptions = { redisUrl: "test-url" }
const moduleDeps = {
logger: loggerMock,
eventBusRedisConnection: redisMock,
}
describe("RedisEventBusService", () => {
let eventBus: RedisEventBusService
let queue
let redis
describe("constructor", () => {
beforeEach(async () => {
jest.clearAllMocks()
eventBus = new RedisEventBusService(moduleDeps, simpleModuleOptions, {
scope: "internal",
resources: "shared",
})
})
it("Creates a queue + worker", () => {
expect(Queue).toHaveBeenCalledTimes(1)
expect(Queue).toHaveBeenCalledWith("events-queue", {
connection: expect.any(Object),
prefix: "RedisEventBusService",
})
expect(Worker).toHaveBeenCalledTimes(1)
expect(Worker).toHaveBeenCalledWith(
"events-queue",
expect.any(Function),
{
connection: expect.any(Object),
prefix: "RedisEventBusService",
}
)
})
it("Throws on isolated module declaration", () => {
try {
eventBus = new RedisEventBusService(moduleDeps, simpleModuleOptions, {
resources: "isolated",
scope: "internal",
})
} catch (error) {
expect(error.message).toEqual(
"At the moment this module can only be used with shared resources"
)
}
})
})
describe("emit", () => {
describe("Successfully emits events", () => {
beforeEach(async () => {
jest.clearAllMocks()
eventBus = new RedisEventBusService(moduleDeps, simpleModuleOptions, {
scope: "internal",
resources: "shared",
})
queue = (eventBus as any).queue_
queue.addBulk = jest.fn()
redis = (eventBus as any).eventBusRedisConnection_
redis.rpush = jest.fn()
})
it("should add job to queue with default options", async () => {
await eventBus.emit([
{
eventName: "eventName",
data: {
hi: "1234",
},
},
])
expect(queue.addBulk).toHaveBeenCalledTimes(1)
expect(queue.addBulk).toHaveBeenCalledWith([
{
name: "eventName",
data: { eventName: "eventName", data: { hi: "1234" } },
opts: {
attempts: 1,
removeOnComplete: true,
},
},
])
})
it("should add job to queue with custom options passed directly upon emitting", async () => {
await eventBus.emit(
[{ eventName: "eventName", data: { hi: "1234" } }],
{ attempts: 3, backoff: 5000, delay: 1000 }
)
expect(queue.addBulk).toHaveBeenCalledTimes(1)
expect(queue.addBulk).toHaveBeenCalledWith([
{
name: "eventName",
data: { eventName: "eventName", data: { hi: "1234" } },
opts: {
attempts: 3,
backoff: 5000,
delay: 1000,
removeOnComplete: true,
},
},
])
})
it("should add job to queue with module job options", async () => {
eventBus = new RedisEventBusService(
moduleDeps,
{
...simpleModuleOptions,
jobOptions: {
removeOnComplete: { age: 5 },
attempts: 7,
},
},
{
resources: "shared",
scope: "internal",
}
)
queue = (eventBus as any).queue_
queue.addBulk = jest.fn()
await eventBus.emit(
[
{
eventName: "eventName",
data: { hi: "1234" },
},
],
{ attempts: 3, backoff: 5000, delay: 1000 }
)
expect(queue.addBulk).toHaveBeenCalledTimes(1)
expect(queue.addBulk).toHaveBeenCalledWith([
{
name: "eventName",
data: { eventName: "eventName", data: { hi: "1234" } },
opts: {
attempts: 3,
backoff: 5000,
delay: 1000,
removeOnComplete: {
age: 5,
},
},
},
])
})
it("should add job to queue with default, local, and global options merged", async () => {
eventBus = new RedisEventBusService(
moduleDeps,
{
...simpleModuleOptions,
jobOptions: {
removeOnComplete: 5,
},
},
{
resources: "shared",
scope: "internal",
}
)
queue = (eventBus as any).queue_
queue.addBulk = jest.fn()
await eventBus.emit(
{
eventName: "eventName",
data: { hi: "1234" },
},
{ delay: 1000 }
)
expect(queue.addBulk).toHaveBeenCalledTimes(1)
expect(queue.addBulk).toHaveBeenCalledWith([
{
name: "eventName",
data: { eventName: "eventName", data: { hi: "1234" } },
opts: {
attempts: 1,
removeOnComplete: 5,
delay: 1000,
},
},
])
})
it("should successfully group events", async () => {
const options = { delay: 1000 }
const event = {
eventName: "eventName",
data: { hi: "1234" },
metadata: { eventGroupId: "test-group-1" },
}
const [builtEvent] = (eventBus as any).buildEvents([event], options)
await eventBus.emit(event, options)
expect(queue.addBulk).toHaveBeenCalledTimes(0)
expect(redis.rpush).toHaveBeenCalledTimes(1)
expect(redis.rpush).toHaveBeenCalledWith(
"staging:test-group-1",
JSON.stringify(builtEvent)
)
})
it("should successfully group, release and clear events", async () => {
const options = { delay: 1000 }
const events = [
{
eventName: "grouped-event-1",
data: { hi: "1234" },
metadata: { eventGroupId: "test-group-1" },
},
{
eventName: "ungrouped-event-2",
data: { hi: "1234" },
},
{
eventName: "grouped-event-2",
data: { hi: "1234" },
metadata: { eventGroupId: "test-group-2" },
},
{
eventName: "grouped-event-3",
data: { hi: "1235" },
metadata: { eventGroupId: "test-group-2" },
},
]
redis.del = jest.fn()
await eventBus.emit(events, options)
// Expect 1 event to have been send
// Expect 2 pushes to redis as there are 2 groups of events to push
expect(queue.addBulk).toHaveBeenCalledTimes(1)
expect(redis.rpush).toHaveBeenCalledTimes(2)
expect(redis.del).not.toHaveBeenCalled()
const [testGroup1Event] = (eventBus as any).buildEvents(
[events[0]],
options
)
const [testGroup2Event] = (eventBus as any).buildEvents(
[events[2]],
options
)
const [testGroup2Event2] = (eventBus as any).buildEvents(
[events[3]],
options
)
redis.lrange = jest.fn((key) => {
if (key === "staging:test-group-1") {
return Promise.resolve([JSON.stringify(testGroup1Event)])
}
if (key === "staging:test-group-2") {
return Promise.resolve([
JSON.stringify(testGroup2Event),
JSON.stringify(testGroup2Event2),
])
}
})
queue = (eventBus as any).queue_
queue.addBulk = jest.fn()
await eventBus.releaseGroupedEvents("test-group-1")
expect(queue.addBulk).toHaveBeenCalledTimes(1)
expect(queue.addBulk).toHaveBeenCalledWith([testGroup1Event])
expect(redis.del).toHaveBeenCalledTimes(1)
expect(redis.del).toHaveBeenCalledWith("staging:test-group-1")
queue = (eventBus as any).queue_
queue.addBulk = jest.fn()
redis.del = jest.fn()
await eventBus.releaseGroupedEvents("test-group-2")
expect(queue.addBulk).toHaveBeenCalledTimes(1)
expect(queue.addBulk).toHaveBeenCalledWith([
testGroup2Event,
testGroup2Event2,
])
expect(redis.del).toHaveBeenCalledTimes(1)
expect(redis.del).toHaveBeenCalledWith("staging:test-group-2")
})
})
})
describe("worker_", () => {
let result
describe("Successfully processes the jobs", () => {
beforeEach(async () => {
jest.clearAllMocks()
eventBus = new RedisEventBusService(moduleDeps, simpleModuleOptions, {
resources: "shared",
scope: "internal",
})
})
it("should process a simple event with no options", async () => {
const test: string[] = []
eventBus.subscribe("eventName", () => {
test.push("success")
return Promise.resolve()
})
// TODO: The typing for this is all over the place
await eventBus.worker_({
data: { eventName: "eventName", data: { test: 1 } },
opts: { attempts: 1 },
} as any)
expect(loggerMock.info).toHaveBeenCalledTimes(1)
expect(loggerMock.info).toHaveBeenCalledWith(
"Processing eventName which has 1 subscribers"
)
expect(test).toEqual(["success"])
})
it("should process event with failing subscribers", async () => {
const test: string[] = []
eventBus.subscribe("eventName", () => {
test.push("hi")
return Promise.resolve()
})
eventBus.subscribe("eventName", () => {
test.push("fail1")
return Promise.reject("fail1")
})
eventBus.subscribe("eventName", () => {
test.push("hi2")
return Promise.resolve()
})
eventBus.subscribe("eventName", () => {
test.push("fail2")
return Promise.reject("fail2")
})
result = await eventBus.worker_({
data: { eventName: "eventName", data: { test: 1 } },
opts: { attempts: 1 },
update: (data) => data,
} as any)
expect(loggerMock.info).toHaveBeenCalledTimes(1)
expect(loggerMock.info).toHaveBeenCalledWith(
"Processing eventName which has 4 subscribers"
)
expect(loggerMock.warn).toHaveBeenCalledTimes(3)
expect(loggerMock.warn).toHaveBeenCalledWith(
"An error occurred while processing eventName: fail1"
)
expect(loggerMock.warn).toHaveBeenCalledWith(
"An error occurred while processing eventName: fail2"
)
expect(loggerMock.warn).toHaveBeenCalledWith(
"One or more subscribers of eventName failed. Retrying is not configured. Use 'attempts' option when emitting events."
)
expect(test.sort()).toEqual(["hi", "fail1", "hi2", "fail2"].sort())
})
it("should retry processing when subcribers fail, if configured - final attempt", async () => {
eventBus.subscribe("eventName", async () => Promise.resolve(), {
subscriberId: "1",
})
eventBus.subscribe("eventName", async () => Promise.reject("fail1"), {
subscriberId: "2",
})
result = await eventBus
.worker_({
data: {
eventName: "eventName",
data: {},
completedSubscriberIds: ["1"],
},
attemptsMade: 2,
update: (data) => data,
opts: { attempts: 2 },
} as any)
.catch((error) => void 0)
expect(loggerMock.warn).toHaveBeenCalledTimes(1)
expect(loggerMock.warn).toHaveBeenCalledWith(
"An error occurred while processing eventName: fail1"
)
expect(loggerMock.info).toHaveBeenCalledTimes(2)
expect(loggerMock.info).toHaveBeenCalledWith(
"Final retry attempt for eventName"
)
expect(loggerMock.info).toHaveBeenCalledWith(
"Retrying eventName which has 2 subscribers (1 of them failed)"
)
})
it("should retry processing when subcribers fail, if configured", async () => {
eventBus.subscribe("eventName", async () => Promise.resolve(), {
subscriberId: "1",
})
eventBus.subscribe("eventName", async () => Promise.reject("fail1"), {
subscriberId: "2",
})
result = await eventBus
.worker_({
data: {
eventName: "eventName",
data: {},
completedSubscriberIds: ["1"],
},
attemptsMade: 2,
updateData: (data) => data,
opts: { attempts: 3 },
} as any)
.catch((err) => void 0)
expect(loggerMock.warn).toHaveBeenCalledTimes(2)
expect(loggerMock.warn).toHaveBeenCalledWith(
"An error occurred while processing eventName: fail1"
)
expect(loggerMock.warn).toHaveBeenCalledWith(
"One or more subscribers of eventName failed. Retrying..."
)
expect(loggerMock.info).toHaveBeenCalledTimes(1)
expect(loggerMock.info).toHaveBeenCalledWith(
"Retrying eventName which has 2 subscribers (1 of them failed)"
)
})
})
})
})

View File

@@ -1,15 +1,25 @@
import { InternalModuleDeclaration } from "@medusajs/modules-sdk" import { InternalModuleDeclaration } from "@medusajs/modules-sdk"
import { EmitData, Logger, Message } from "@medusajs/types" import { Logger, Message, MessageBody } from "@medusajs/types"
import { AbstractEventBusModuleService, isString } from "@medusajs/utils" import {
import { BulkJobOptions, JobsOptions, Queue, Worker } from "bullmq" AbstractEventBusModuleService,
isPresent,
promiseAll,
} from "@medusajs/utils"
import { BulkJobOptions, Queue, Worker } from "bullmq"
import { Redis } from "ioredis" import { Redis } from "ioredis"
import { BullJob, EmitOptions, EventBusRedisModuleOptions } from "../types" import { BullJob, EventBusRedisModuleOptions } from "../types"
type InjectedDependencies = { type InjectedDependencies = {
logger: Logger logger: Logger
eventBusRedisConnection: Redis eventBusRedisConnection: Redis
} }
type IORedisEventType<T = unknown> = {
name: string
data: MessageBody<T>
opts: BulkJobOptions
}
/** /**
* Can keep track of multiple subscribers to different events and run the * Can keep track of multiple subscribers to different events and run the
* subscribers when events happen. Events will run asynchronously. * subscribers when events happen. Events will run asynchronously.
@@ -71,78 +81,137 @@ export default class RedisEventBusService extends AbstractEventBusModuleService
}, },
} }
/** private buildEvents<T>(
* Emit a single event eventsData: Message<T>[],
* @param {string} eventName - the name of the event to be process. options: BulkJobOptions = {}
* @param data - the data to send to the subscriber. ): IORedisEventType<T>[] {
* @param options - options to add the job with
*/
async emit<T>(
eventName: string,
data: T,
options: Record<string, unknown>
): Promise<void>
/**
* Emit a number of events
* @param {EmitData} data - the data to send to the subscriber.
*/
async emit<T>(data: EmitData<T>[]): Promise<void>
async emit<T>(data: Message<T>[]): Promise<void>
async emit<T, TInput extends string | EmitData<T>[] | Message<T>[] = string>(
eventNameOrData: TInput,
data?: T,
options: BulkJobOptions | JobsOptions = {}
): Promise<void> {
const globalJobOptions = this.moduleOptions_.jobOptions ?? {}
const isBulkEmit = Array.isArray(eventNameOrData)
const opts = { const opts = {
// default options // default options
removeOnComplete: true, removeOnComplete: true,
attempts: 1, attempts: 1,
// global options // global options
...globalJobOptions, ...(this.moduleOptions_.jobOptions ?? {}),
} as EmitOptions ...options,
}
const dataBody = isString(eventNameOrData) return eventsData.map((eventData) => {
? data ?? (data as Message<T>).body const { options, ...eventBody } = eventData
: undefined
const events = isBulkEmit return {
? eventNameOrData.map((event) => ({ name: eventData.eventName,
name: event.eventName, data: eventBody,
data: { opts: {
eventName: event.eventName, // options for event group
data: (event as EmitData).data ?? (event as Message<T>).body, ...opts,
}, // options for a particular event
opts: { ...options,
...opts, },
// local options }
...event.options, })
},
}))
: [
{
name: eventNameOrData as string,
data: { eventName: eventNameOrData, data: dataBody },
opts: {
...opts,
// local options
...options,
},
},
]
await this.queue_.addBulk(events)
} }
// TODO: Implement redis based staging + release /**
async releaseGroupedEvents(eventGroupId: string) {} * Emit a single or number of events
async clearGroupedEvents(eventGroupId: string) {} * @param {Message} data - the data to send to the subscriber.
* @param {BulkJobOptions} data - the options to add to bull mq
*/
async emit<T = unknown>(
eventsData: Message<T> | Message<T>[],
options: BulkJobOptions & { groupedEventsTTL?: number } = {}
): Promise<void> {
let eventsDataArray = Array.isArray(eventsData) ? eventsData : [eventsData]
const { groupedEventsTTL = 600 } = options
delete options.groupedEventsTTL
const eventsToEmit = eventsDataArray.filter(
(eventData) => !isPresent(eventData.metadata?.eventGroupId)
)
const eventsToGroup = eventsDataArray.filter((eventData) =>
isPresent(eventData.metadata?.eventGroupId)
)
const groupEventsMap = new Map<string, Message<T>[]>()
for (const event of eventsToGroup) {
const groupId = event.metadata?.eventGroupId!
const array = groupEventsMap.get(groupId) ?? []
array.push(event)
groupEventsMap.set(groupId, array)
}
const promises: Promise<unknown>[] = []
if (eventsToEmit.length) {
const emitData = this.buildEvents(eventsToEmit, options)
promises.push(this.queue_.addBulk(emitData))
}
for (const [groupId, events] of groupEventsMap.entries()) {
if (!events?.length) {
continue
}
// Set a TTL for the key of the list that is scoped to a group
// This will be helpful in preventing stale data from staying in redis for too long
// in the event the module fails to cleanup events. For long running workflows, setting a much higher
// TTL or even skipping the TTL would be required
this.setExpire(groupId, groupedEventsTTL)
const eventsData = this.buildEvents(events, options)
promises.push(this.groupEvents(groupId, eventsData))
}
await promiseAll(promises)
}
private async setExpire(eventGroupId: string, ttl: number) {
if (!eventGroupId) {
return
}
await this.eventBusRedisConnection_.expire(`staging:${eventGroupId}`, ttl)
}
private async groupEvents<T = unknown>(
eventGroupId: string,
events: IORedisEventType<T>[]
) {
await this.eventBusRedisConnection_.rpush(
`staging:${eventGroupId}`,
...events.map((event) => JSON.stringify(event))
)
}
private async getGroupedEvents(
eventGroupId: string
): Promise<IORedisEventType[]> {
return await this.eventBusRedisConnection_
.lrange(`staging:${eventGroupId}`, 0, -1)
.then((result) => {
return result.map((jsonString) => JSON.parse(jsonString))
})
}
async releaseGroupedEvents(eventGroupId: string) {
const groupedEvents = await this.getGroupedEvents(eventGroupId)
await this.queue_.addBulk(groupedEvents)
await this.clearGroupedEvents(eventGroupId)
}
async clearGroupedEvents(eventGroupId: string) {
if (!eventGroupId) {
return
}
await this.eventBusRedisConnection_.del(`staging:${eventGroupId}`)
}
/** /**
* Handles incoming jobs. * Handles incoming jobs.

View File

@@ -10,15 +10,13 @@ export function buildExpectedEventMessageShape(options: {
}): EventBusTypes.Message { }): EventBusTypes.Message {
return { return {
eventName: options.eventName, eventName: options.eventName,
body: { metadata: {
metadata: { action: options.action,
action: options.action, eventGroupId: options.eventGroupId,
eventGroupId: options.eventGroupId, source: "fulfillment",
source: "fulfillment", object: options.object,
object: options.object,
},
data: options.data,
}, },
data: options.data,
options: options.options, options: options.options,
} }
} }

View File

@@ -208,15 +208,13 @@ export default class LinkModuleService<TLink> implements ILinkModule {
await this.eventBusModuleService_?.emit<Record<string, unknown>>( await this.eventBusModuleService_?.emit<Record<string, unknown>>(
(data as { id: unknown }[]).map(({ id }) => ({ (data as { id: unknown }[]).map(({ id }) => ({
eventName: this.entityName_ + "." + CommonEvents.ATTACHED, eventName: this.entityName_ + "." + CommonEvents.ATTACHED,
body: { metadata: {
metadata: { source: this.serviceName_,
source: this.serviceName_, action: CommonEvents.ATTACHED,
action: CommonEvents.ATTACHED, object: this.entityName_,
object: this.entityName_, eventGroupId: sharedContext.eventGroupId,
eventGroupId: sharedContext.eventGroupId,
},
data: { id },
}, },
data: { id },
})) }))
) )
@@ -261,15 +259,13 @@ export default class LinkModuleService<TLink> implements ILinkModule {
await this.eventBusModuleService_?.emit<Record<string, unknown>>( await this.eventBusModuleService_?.emit<Record<string, unknown>>(
allData.map(({ id }) => ({ allData.map(({ id }) => ({
eventName: this.entityName_ + "." + CommonEvents.DETACHED, eventName: this.entityName_ + "." + CommonEvents.DETACHED,
body: { metadata: {
metadata: { source: this.serviceName_,
source: this.serviceName_, action: CommonEvents.DETACHED,
action: CommonEvents.DETACHED, object: this.entityName_,
object: this.entityName_, eventGroupId: sharedContext.eventGroupId,
eventGroupId: sharedContext.eventGroupId,
},
data: { id },
}, },
data: { id },
})) }))
) )
} }
@@ -312,15 +308,13 @@ export default class LinkModuleService<TLink> implements ILinkModule {
await this.eventBusModuleService_?.emit<Record<string, unknown>>( await this.eventBusModuleService_?.emit<Record<string, unknown>>(
(deletedEntities as { id: string }[]).map(({ id }) => ({ (deletedEntities as { id: string }[]).map(({ id }) => ({
eventName: this.entityName_ + "." + CommonEvents.DETACHED, eventName: this.entityName_ + "." + CommonEvents.DETACHED,
body: { metadata: {
metadata: { source: this.serviceName_,
source: this.serviceName_, action: CommonEvents.DETACHED,
action: CommonEvents.DETACHED, object: this.entityName_,
object: this.entityName_, eventGroupId: sharedContext.eventGroupId,
eventGroupId: sharedContext.eventGroupId,
},
data: { id },
}, },
data: { id },
})) }))
) )
@@ -372,15 +366,13 @@ export default class LinkModuleService<TLink> implements ILinkModule {
await this.eventBusModuleService_?.emit<Record<string, unknown>>( await this.eventBusModuleService_?.emit<Record<string, unknown>>(
(restoredEntities as { id: string }[]).map(({ id }) => ({ (restoredEntities as { id: string }[]).map(({ id }) => ({
eventName: this.entityName_ + "." + CommonEvents.ATTACHED, eventName: this.entityName_ + "." + CommonEvents.ATTACHED,
body: { metadata: {
metadata: { source: this.serviceName_,
source: this.serviceName_, action: CommonEvents.ATTACHED,
action: CommonEvents.ATTACHED, object: this.entityName_,
object: this.entityName_, eventGroupId: sharedContext.eventGroupId,
eventGroupId: sharedContext.eventGroupId,
},
data: { id },
}, },
data: { id },
})) }))
) )

View File

@@ -291,8 +291,9 @@ moduleIntegrationTestRunner<IProductModuleService>({
}) })
expect(eventBusSpy).toHaveBeenCalledTimes(1) expect(eventBusSpy).toHaveBeenCalledTimes(1)
expect(eventBusSpy).toHaveBeenCalledWith("product-category.created", { expect(eventBusSpy).toHaveBeenCalledWith({
id: category.id, data: { id: category.id },
eventName: "product-category.created",
}) })
}) })
@@ -380,8 +381,9 @@ moduleIntegrationTestRunner<IProductModuleService>({
}) })
expect(eventBusSpy).toHaveBeenCalledTimes(1) expect(eventBusSpy).toHaveBeenCalledTimes(1)
expect(eventBusSpy).toHaveBeenCalledWith("product-category.updated", { expect(eventBusSpy).toHaveBeenCalledWith({
id: productCategoryZero.id, data: { id: productCategoryZero.id },
eventName: "product-category.updated",
}) })
}) })
@@ -547,8 +549,9 @@ moduleIntegrationTestRunner<IProductModuleService>({
await service.deleteCategory(productCategoryOne.id) await service.deleteCategory(productCategoryOne.id)
expect(eventBusSpy).toHaveBeenCalledTimes(1) expect(eventBusSpy).toHaveBeenCalledTimes(1)
expect(eventBusSpy).toHaveBeenCalledWith("product-category.deleted", { expect(eventBusSpy).toHaveBeenCalledWith({
id: productCategoryOne.id, data: { id: productCategoryOne.id },
eventName: "product-category.deleted",
}) })
}) })

View File

@@ -298,8 +298,8 @@ moduleIntegrationTestRunner<IProductModuleService>({
expect(eventBusSpy).toHaveBeenCalledTimes(1) expect(eventBusSpy).toHaveBeenCalledTimes(1)
expect(eventBusSpy).toHaveBeenCalledWith([ expect(eventBusSpy).toHaveBeenCalledWith([
{ {
eventName: "product-collection.updated",
data: { id: collectionId }, data: { id: collectionId },
eventName: "product-collection.updated",
}, },
]) ])
}) })
@@ -488,16 +488,14 @@ moduleIntegrationTestRunner<IProductModuleService>({
const eventBusSpy = jest.spyOn(MockEventBusService.prototype, "emit") const eventBusSpy = jest.spyOn(MockEventBusService.prototype, "emit")
const collections = await service.createCollections([ const collections = await service.createCollections([
{ { title: "New Collection" },
title: "New Collection",
},
]) ])
expect(eventBusSpy).toHaveBeenCalledTimes(1) expect(eventBusSpy).toHaveBeenCalledTimes(1)
expect(eventBusSpy).toHaveBeenCalledWith([ expect(eventBusSpy).toHaveBeenCalledWith([
{ {
eventName: "product-collection.created",
data: { id: collections[0].id }, data: { id: collections[0].id },
eventName: "product-collection.created",
}, },
]) ])
}) })

View File

@@ -51,8 +51,8 @@ import {
UpdateTagInput, UpdateTagInput,
UpdateTypeInput, UpdateTypeInput,
} from "../types" } from "../types"
import { entityNameToLinkableKeysMap, joinerConfig } from "./../joiner-config"
import { eventBuilders } from "../utils" import { eventBuilders } from "../utils"
import { entityNameToLinkableKeysMap, joinerConfig } from "./../joiner-config"
type InjectedDependencies = { type InjectedDependencies = {
baseRepository: DAL.RepositoryService baseRepository: DAL.RepositoryService
@@ -1134,10 +1134,10 @@ export default class ProductModuleService<
sharedContext sharedContext
) )
await this.eventBusModuleService_?.emit<ProductCategoryEventData>( await this.eventBusModuleService_?.emit<ProductCategoryEventData>({
ProductCategoryEvents.CATEGORY_CREATED, eventName: ProductCategoryEvents.CATEGORY_CREATED,
{ id: productCategory.id } data: { id: productCategory.id },
) })
return productCategory return productCategory
} }
@@ -1154,10 +1154,10 @@ export default class ProductModuleService<
sharedContext sharedContext
) )
await this.eventBusModuleService_?.emit<ProductCategoryEventData>( await this.eventBusModuleService_?.emit<ProductCategoryEventData>({
ProductCategoryEvents.CATEGORY_UPDATED, eventName: ProductCategoryEvents.CATEGORY_UPDATED,
{ id: productCategory.id } data: { id: productCategory.id },
) })
return await this.baseRepository_.serialize(productCategory, { return await this.baseRepository_.serialize(productCategory, {
populate: true, populate: true,
@@ -1171,10 +1171,10 @@ export default class ProductModuleService<
): Promise<void> { ): Promise<void> {
await this.productCategoryService_.delete(categoryId, sharedContext) await this.productCategoryService_.delete(categoryId, sharedContext)
await this.eventBusModuleService_?.emit<ProductCategoryEventData>( await this.eventBusModuleService_?.emit<ProductCategoryEventData>({
ProductCategoryEvents.CATEGORY_DELETED, eventName: ProductCategoryEvents.CATEGORY_DELETED,
{ id: categoryId } data: { id: categoryId },
) })
} }
create( create(

View File

@@ -1,9 +1,12 @@
import { IUserModuleService } from "@medusajs/types/dist/user"
import { MockEventBusService } from "medusa-test-utils"
import { Modules } from "@medusajs/modules-sdk" import { Modules } from "@medusajs/modules-sdk"
import { IUserModuleService } from "@medusajs/types/dist/user"
import { UserEvents } from "@medusajs/utils" import { UserEvents } from "@medusajs/utils"
import {
MockEventBusService,
moduleIntegrationTestRunner,
SuiteOptions,
} from "medusa-test-utils"
import { createInvites } from "../../../__fixtures__/invite" import { createInvites } from "../../../__fixtures__/invite"
import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils"
jest.setTimeout(30000) jest.setTimeout(30000)
@@ -178,9 +181,7 @@ moduleIntegrationTestRunner({
expect(eventBusSpy).toHaveBeenCalledTimes(1) expect(eventBusSpy).toHaveBeenCalledTimes(1)
expect(eventBusSpy).toHaveBeenCalledWith([ expect(eventBusSpy).toHaveBeenCalledWith([
expect.objectContaining({ expect.objectContaining({
body: expect.objectContaining({ data: { id: "1" },
data: { id: "1" },
}),
eventName: UserEvents.invite_updated, eventName: UserEvents.invite_updated,
}), }),
]) ])
@@ -197,9 +198,7 @@ moduleIntegrationTestRunner({
expect(eventBusSpy).toHaveBeenCalledTimes(1) expect(eventBusSpy).toHaveBeenCalledTimes(1)
expect(eventBusSpy).toHaveBeenCalledWith([ expect(eventBusSpy).toHaveBeenCalledWith([
expect.objectContaining({ expect.objectContaining({
body: expect.objectContaining({ data: { id: "1" },
data: { id: "1" },
}),
eventName: UserEvents.invite_token_generated, eventName: UserEvents.invite_token_generated,
}), }),
]) ])
@@ -228,27 +227,19 @@ moduleIntegrationTestRunner({
expect(eventBusSpy).toHaveBeenCalledTimes(1) expect(eventBusSpy).toHaveBeenCalledTimes(1)
expect(eventBusSpy).toHaveBeenCalledWith([ expect(eventBusSpy).toHaveBeenCalledWith([
expect.objectContaining({ expect.objectContaining({
body: expect.objectContaining({ data: { id: "1" },
data: { id: "1" },
}),
eventName: UserEvents.invite_created, eventName: UserEvents.invite_created,
}), }),
expect.objectContaining({ expect.objectContaining({
body: expect.objectContaining({ data: { id: "2" },
data: { id: "2" },
}),
eventName: UserEvents.invite_created, eventName: UserEvents.invite_created,
}), }),
expect.objectContaining({ expect.objectContaining({
body: expect.objectContaining({ data: { id: "1" },
data: { id: "1" },
}),
eventName: UserEvents.invite_token_generated, eventName: UserEvents.invite_token_generated,
}), }),
expect.objectContaining({ expect.objectContaining({
body: expect.objectContaining({ data: { id: "2" },
data: { id: "2" },
}),
eventName: UserEvents.invite_token_generated, eventName: UserEvents.invite_token_generated,
}), }),
]) ])

View File

@@ -1,9 +1,12 @@
import { IUserModuleService } from "@medusajs/types/dist/user"
import { MockEventBusService } from "medusa-test-utils"
import { Modules } from "@medusajs/modules-sdk" import { Modules } from "@medusajs/modules-sdk"
import { IUserModuleService } from "@medusajs/types/dist/user"
import { UserEvents } from "@medusajs/utils" import { UserEvents } from "@medusajs/utils"
import {
MockEventBusService,
moduleIntegrationTestRunner,
SuiteOptions,
} from "medusa-test-utils"
import { createUsers } from "../../../__fixtures__/user" import { createUsers } from "../../../__fixtures__/user"
import { moduleIntegrationTestRunner, SuiteOptions } from "medusa-test-utils"
jest.setTimeout(30000) jest.setTimeout(30000)
@@ -190,9 +193,7 @@ moduleIntegrationTestRunner({
expect(eventBusSpy).toHaveBeenCalledTimes(1) expect(eventBusSpy).toHaveBeenCalledTimes(1)
expect(eventBusSpy).toHaveBeenCalledWith([ expect(eventBusSpy).toHaveBeenCalledWith([
expect.objectContaining({ expect.objectContaining({
body: expect.objectContaining({ data: { id: "1" },
data: { id: "1" },
}),
eventName: UserEvents.updated, eventName: UserEvents.updated,
}), }),
]) ])
@@ -222,15 +223,11 @@ moduleIntegrationTestRunner({
expect(eventBusSpy).toHaveBeenCalledTimes(1) expect(eventBusSpy).toHaveBeenCalledTimes(1)
expect(eventBusSpy).toHaveBeenCalledWith([ expect(eventBusSpy).toHaveBeenCalledWith([
expect.objectContaining({ expect.objectContaining({
body: expect.objectContaining({ data: { id: "1" },
data: { id: "1" },
}),
eventName: UserEvents.created, eventName: UserEvents.created,
}), }),
expect.objectContaining({ expect.objectContaining({
body: expect.objectContaining({ data: { id: "2" },
data: { id: "2" },
}),
eventName: UserEvents.created, eventName: UserEvents.created,
}), }),
]) ])