chore(event-bus, workflow-engine): Enable more granualar queues configuration (#14201)
Summary
This PR adds BullMQ queue and worker configuration options to the workflow-engine-redis module, bringing feature parity with the event-bus-redis module. It also introduces per-queue
configuration options for fine-grained control over the three internal queues (main, job, and cleaner).
Key changes:
- Added per-queue BullMQ configuration options (mainQueueOptions, jobQueueOptions, cleanerQueueOptions and their worker counterparts) with shared defaults
- Unified Redis option naming across modules: deprecated url → redisUrl, options → redisOptions (with backward compatibility)
- Moved configuration resolution to the loader and registered options in the DI container
- Added comprehensive JSDoc documentation for all configuration options
- Added unit tests for option merging and queue/worker configuration
Configuration Example
```ts
// Simple configuration - same options for all queues
{
redisUrl: "redis://localhost:6379",
queueOptions: { defaultJobOptions: { removeOnComplete: 1000 } },
workerOptions: { concurrency: 10 }
}
```
```ts
// Advanced configuration - per-queue overrides
{
redisUrl: "redis://localhost:6379",
workerOptions: { concurrency: 10 }, // shared default
jobWorkerOptions: { concurrency: 5 }, // override for scheduled workflows
cleanerWorkerOptions: { concurrency: 1 } // override for cleanup (low priority)
}
```
This commit is contained in:
committed by
GitHub
parent
3e3e6c37bd
commit
144f0f4e2e
@@ -0,0 +1,323 @@
|
||||
import { Logger } from "@medusajs/framework/types"
|
||||
import redisLoader from "../redis"
|
||||
|
||||
jest.mock("ioredis", () => {
|
||||
return jest.fn().mockImplementation(() => ({
|
||||
connect: jest.fn((callback) => {
|
||||
if (callback) callback()
|
||||
return Promise.resolve()
|
||||
}),
|
||||
disconnect: jest.fn(),
|
||||
}))
|
||||
})
|
||||
|
||||
const loggerMock = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
} as unknown as Logger
|
||||
|
||||
describe("Redis Loader", () => {
|
||||
let containerMock: { register: jest.Mock }
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
containerMock = {
|
||||
register: jest.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
describe("Option merging", () => {
|
||||
it("should use shared queueOptions as default for all queues", async () => {
|
||||
const sharedQueueOptions = {
|
||||
defaultJobOptions: { removeOnComplete: 1000 },
|
||||
}
|
||||
|
||||
await redisLoader(
|
||||
{
|
||||
container: containerMock as any,
|
||||
logger: loggerMock,
|
||||
options: {
|
||||
redis: {
|
||||
redisUrl: "redis://localhost:6379",
|
||||
queueOptions: sharedQueueOptions,
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
{} as any
|
||||
)
|
||||
|
||||
const registerCall = containerMock.register.mock.calls[0][0]
|
||||
|
||||
expect(registerCall.redisMainQueueOptions.resolve()).toEqual(
|
||||
sharedQueueOptions
|
||||
)
|
||||
expect(registerCall.redisJobQueueOptions.resolve()).toEqual(
|
||||
sharedQueueOptions
|
||||
)
|
||||
expect(registerCall.redisCleanerQueueOptions.resolve()).toEqual(
|
||||
sharedQueueOptions
|
||||
)
|
||||
})
|
||||
|
||||
it("should use shared workerOptions as default for all workers", async () => {
|
||||
const sharedWorkerOptions = { concurrency: 10 }
|
||||
|
||||
await redisLoader(
|
||||
{
|
||||
container: containerMock as any,
|
||||
logger: loggerMock,
|
||||
options: {
|
||||
redis: {
|
||||
redisUrl: "redis://localhost:6379",
|
||||
workerOptions: sharedWorkerOptions,
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
{} as any
|
||||
)
|
||||
|
||||
const registerCall = containerMock.register.mock.calls[0][0]
|
||||
|
||||
expect(registerCall.redisMainWorkerOptions.resolve()).toEqual(
|
||||
sharedWorkerOptions
|
||||
)
|
||||
expect(registerCall.redisJobWorkerOptions.resolve()).toEqual(
|
||||
sharedWorkerOptions
|
||||
)
|
||||
expect(registerCall.redisCleanerWorkerOptions.resolve()).toEqual(
|
||||
sharedWorkerOptions
|
||||
)
|
||||
})
|
||||
|
||||
it("should override shared options with per-queue options", async () => {
|
||||
const sharedQueueOptions = {
|
||||
defaultJobOptions: { removeOnComplete: 1000 },
|
||||
}
|
||||
const mainQueueOptions = {
|
||||
defaultJobOptions: { removeOnComplete: 500, attempts: 3 },
|
||||
}
|
||||
|
||||
await redisLoader(
|
||||
{
|
||||
container: containerMock as any,
|
||||
logger: loggerMock,
|
||||
options: {
|
||||
redis: {
|
||||
redisUrl: "redis://localhost:6379",
|
||||
queueOptions: sharedQueueOptions,
|
||||
mainQueueOptions: mainQueueOptions,
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
{} as any
|
||||
)
|
||||
|
||||
const registerCall = containerMock.register.mock.calls[0][0]
|
||||
|
||||
expect(registerCall.redisMainQueueOptions.resolve()).toEqual({
|
||||
defaultJobOptions: { removeOnComplete: 500, attempts: 3 },
|
||||
})
|
||||
|
||||
expect(registerCall.redisJobQueueOptions.resolve()).toEqual(
|
||||
sharedQueueOptions
|
||||
)
|
||||
expect(registerCall.redisCleanerQueueOptions.resolve()).toEqual(
|
||||
sharedQueueOptions
|
||||
)
|
||||
})
|
||||
|
||||
it("should override shared worker options with per-worker options", async () => {
|
||||
const sharedWorkerOptions = { concurrency: 10 }
|
||||
const jobWorkerOptions = { concurrency: 5 }
|
||||
const cleanerWorkerOptions = { concurrency: 1 }
|
||||
|
||||
await redisLoader(
|
||||
{
|
||||
container: containerMock as any,
|
||||
logger: loggerMock,
|
||||
options: {
|
||||
redis: {
|
||||
redisUrl: "redis://localhost:6379",
|
||||
workerOptions: sharedWorkerOptions,
|
||||
jobWorkerOptions: jobWorkerOptions,
|
||||
cleanerWorkerOptions: cleanerWorkerOptions,
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
{} as any
|
||||
)
|
||||
|
||||
const registerCall = containerMock.register.mock.calls[0][0]
|
||||
|
||||
expect(registerCall.redisMainWorkerOptions.resolve()).toEqual(
|
||||
sharedWorkerOptions
|
||||
)
|
||||
|
||||
expect(registerCall.redisJobWorkerOptions.resolve()).toEqual(
|
||||
jobWorkerOptions
|
||||
)
|
||||
|
||||
expect(registerCall.redisCleanerWorkerOptions.resolve()).toEqual(
|
||||
cleanerWorkerOptions
|
||||
)
|
||||
})
|
||||
|
||||
it("should merge nested options correctly", async () => {
|
||||
const sharedWorkerOptions = {
|
||||
concurrency: 10,
|
||||
limiter: { max: 100, duration: 1000 },
|
||||
}
|
||||
const mainWorkerOptions = {
|
||||
concurrency: 20,
|
||||
}
|
||||
|
||||
await redisLoader(
|
||||
{
|
||||
container: containerMock as any,
|
||||
logger: loggerMock,
|
||||
options: {
|
||||
redis: {
|
||||
redisUrl: "redis://localhost:6379",
|
||||
workerOptions: sharedWorkerOptions,
|
||||
mainWorkerOptions: mainWorkerOptions,
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
{} as any
|
||||
)
|
||||
|
||||
const registerCall = containerMock.register.mock.calls[0][0]
|
||||
|
||||
expect(registerCall.redisMainWorkerOptions.resolve()).toEqual({
|
||||
concurrency: 20,
|
||||
limiter: { max: 100, duration: 1000 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Deprecation warnings", () => {
|
||||
it("should log warning when using deprecated 'url' option", async () => {
|
||||
await redisLoader(
|
||||
{
|
||||
container: containerMock as any,
|
||||
logger: loggerMock,
|
||||
options: {
|
||||
redis: {
|
||||
url: "redis://localhost:6379",
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
{} as any
|
||||
)
|
||||
|
||||
expect(loggerMock.warn).toHaveBeenCalledWith(
|
||||
"[Workflow-engine-redis] The `url` option is deprecated. Please use `redisUrl` instead for consistency with other modules."
|
||||
)
|
||||
})
|
||||
|
||||
it("should log warning when using deprecated 'options' option", async () => {
|
||||
await redisLoader(
|
||||
{
|
||||
container: containerMock as any,
|
||||
logger: loggerMock,
|
||||
options: {
|
||||
redis: {
|
||||
redisUrl: "redis://localhost:6379",
|
||||
options: { maxRetriesPerRequest: 3 },
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
{} as any
|
||||
)
|
||||
|
||||
expect(loggerMock.warn).toHaveBeenCalledWith(
|
||||
"[Workflow-engine-redis] The `options` option is deprecated. Please use `redisOptions` instead for consistency with other modules."
|
||||
)
|
||||
})
|
||||
|
||||
it("should not log warning when using new option names", async () => {
|
||||
await redisLoader(
|
||||
{
|
||||
container: containerMock as any,
|
||||
logger: loggerMock,
|
||||
options: {
|
||||
redis: {
|
||||
redisUrl: "redis://localhost:6379",
|
||||
redisOptions: { maxRetriesPerRequest: 3 },
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
{} as any
|
||||
)
|
||||
|
||||
expect(loggerMock.warn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Queue names", () => {
|
||||
it("should use default queue names when not provided", async () => {
|
||||
await redisLoader(
|
||||
{
|
||||
container: containerMock as any,
|
||||
logger: loggerMock,
|
||||
options: {
|
||||
redis: {
|
||||
redisUrl: "redis://localhost:6379",
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
{} as any
|
||||
)
|
||||
|
||||
const registerCall = containerMock.register.mock.calls[0][0]
|
||||
|
||||
expect(registerCall.redisQueueName.resolve()).toEqual("medusa-workflows")
|
||||
expect(registerCall.redisJobQueueName.resolve()).toEqual(
|
||||
"medusa-workflows-jobs"
|
||||
)
|
||||
})
|
||||
|
||||
it("should use custom queue names when provided", async () => {
|
||||
await redisLoader(
|
||||
{
|
||||
container: containerMock as any,
|
||||
logger: loggerMock,
|
||||
options: {
|
||||
redis: {
|
||||
redisUrl: "redis://localhost:6379",
|
||||
queueName: "custom-workflows",
|
||||
jobQueueName: "custom-jobs",
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
{} as any
|
||||
)
|
||||
|
||||
const registerCall = containerMock.register.mock.calls[0][0]
|
||||
|
||||
expect(registerCall.redisQueueName.resolve()).toEqual("custom-workflows")
|
||||
expect(registerCall.redisJobQueueName.resolve()).toEqual("custom-jobs")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Error handling", () => {
|
||||
it("should throw error when redisUrl is not provided", async () => {
|
||||
await expect(
|
||||
redisLoader(
|
||||
{
|
||||
container: containerMock as any,
|
||||
logger: loggerMock,
|
||||
options: {
|
||||
redis: {},
|
||||
},
|
||||
} as any,
|
||||
{} as any
|
||||
)
|
||||
).rejects.toThrow(
|
||||
"No `redis.redisUrl` (or deprecated `redis.url`) provided"
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -12,32 +12,86 @@ export default async (
|
||||
): Promise<void> => {
|
||||
const {
|
||||
url,
|
||||
options: redisOptions,
|
||||
redisUrl,
|
||||
options: deprecatedRedisOptions,
|
||||
redisOptions: newRedisOptions,
|
||||
jobQueueName,
|
||||
queueName,
|
||||
// Shared options
|
||||
queueOptions,
|
||||
workerOptions,
|
||||
// Per-queue options
|
||||
mainQueueOptions,
|
||||
mainWorkerOptions,
|
||||
jobQueueOptions,
|
||||
jobWorkerOptions,
|
||||
cleanerQueueOptions,
|
||||
cleanerWorkerOptions,
|
||||
pubsub,
|
||||
} = options?.redis as RedisWorkflowsOptions
|
||||
|
||||
// TODO: get default from ENV VAR
|
||||
if (!url) {
|
||||
throw Error(
|
||||
"No `redis.url` provided in `workflowOrchestrator` module options. It is required for the Workflow Orchestrator Redis."
|
||||
// Handle backward compatibility for deprecated options
|
||||
const resolvedUrl = redisUrl ?? url
|
||||
const redisOptions = newRedisOptions ?? deprecatedRedisOptions
|
||||
|
||||
// Log deprecation warnings
|
||||
if (url && !redisUrl) {
|
||||
logger?.warn(
|
||||
"[Workflow-engine-redis] The `url` option is deprecated. Please use `redisUrl` instead for consistency with other modules."
|
||||
)
|
||||
}
|
||||
if (deprecatedRedisOptions && !newRedisOptions) {
|
||||
logger?.warn(
|
||||
"[Workflow-engine-redis] The `options` option is deprecated. Please use `redisOptions` instead for consistency with other modules."
|
||||
)
|
||||
}
|
||||
|
||||
const cnnPubSub = pubsub ?? { url, options: redisOptions }
|
||||
// TODO: get default from ENV VAR
|
||||
if (!resolvedUrl) {
|
||||
throw Error(
|
||||
"No `redis.redisUrl` (or deprecated `redis.url`) provided in `workflowOrchestrator` module options. It is required for the Workflow Orchestrator Redis."
|
||||
)
|
||||
}
|
||||
|
||||
const cnnPubSub = pubsub ?? { url: resolvedUrl, options: redisOptions }
|
||||
|
||||
const queueName_ = queueName ?? "medusa-workflows"
|
||||
const jobQueueName_ = jobQueueName ?? "medusa-workflows-jobs"
|
||||
|
||||
// Resolve per-queue options by merging shared defaults with per-queue overrides
|
||||
const resolvedMainQueueOptions = {
|
||||
...(queueOptions ?? {}),
|
||||
...(mainQueueOptions ?? {}),
|
||||
}
|
||||
const resolvedMainWorkerOptions = {
|
||||
...(workerOptions ?? {}),
|
||||
...(mainWorkerOptions ?? {}),
|
||||
}
|
||||
const resolvedJobQueueOptions = {
|
||||
...(queueOptions ?? {}),
|
||||
...(jobQueueOptions ?? {}),
|
||||
}
|
||||
const resolvedJobWorkerOptions = {
|
||||
...(workerOptions ?? {}),
|
||||
...(jobWorkerOptions ?? {}),
|
||||
}
|
||||
const resolvedCleanerQueueOptions = {
|
||||
...(queueOptions ?? {}),
|
||||
...(cleanerQueueOptions ?? {}),
|
||||
}
|
||||
const resolvedCleanerWorkerOptions = {
|
||||
...(workerOptions ?? {}),
|
||||
...(cleanerWorkerOptions ?? {}),
|
||||
}
|
||||
|
||||
let connection
|
||||
let redisPublisher
|
||||
let redisSubscriber
|
||||
let workerConnection
|
||||
|
||||
try {
|
||||
connection = await getConnection(url, redisOptions)
|
||||
workerConnection = await getConnection(url, {
|
||||
connection = await getConnection(resolvedUrl, redisOptions)
|
||||
workerConnection = await getConnection(resolvedUrl, {
|
||||
...(redisOptions ?? {}),
|
||||
maxRetriesPerRequest: null,
|
||||
})
|
||||
@@ -71,6 +125,13 @@ export default async (
|
||||
redisSubscriber: asValue(redisSubscriber),
|
||||
redisQueueName: asValue(queueName_),
|
||||
redisJobQueueName: asValue(jobQueueName_),
|
||||
// Per-queue resolved options
|
||||
redisMainQueueOptions: asValue(resolvedMainQueueOptions),
|
||||
redisMainWorkerOptions: asValue(resolvedMainWorkerOptions),
|
||||
redisJobQueueOptions: asValue(resolvedJobQueueOptions),
|
||||
redisJobWorkerOptions: asValue(resolvedJobWorkerOptions),
|
||||
redisCleanerQueueOptions: asValue(resolvedCleanerQueueOptions),
|
||||
redisCleanerWorkerOptions: asValue(resolvedCleanerWorkerOptions),
|
||||
redisDisconnectHandler: asValue(async () => {
|
||||
connection.disconnect()
|
||||
workerConnection.disconnect()
|
||||
|
||||
Reference in New Issue
Block a user