@open-mercato/scheduler 0.6.7-develop.6630.1.c8a615e536 → 0.6.7-develop.6645.1.52f0b37813
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/scheduler/lib/queueTargetPayload.js +17 -0
- package/dist/modules/scheduler/lib/queueTargetPayload.js.map +7 -0
- package/dist/modules/scheduler/services/localSchedulerService.js +5 -7
- package/dist/modules/scheduler/services/localSchedulerService.js.map +2 -2
- package/dist/modules/scheduler/workers/execute-schedule.worker.js +6 -7
- package/dist/modules/scheduler/workers/execute-schedule.worker.js.map +2 -2
- package/package.json +5 -5
- package/src/modules/scheduler/lib/__tests__/queueTargetPayload.test.ts +85 -0
- package/src/modules/scheduler/lib/queueTargetPayload.ts +37 -0
- package/src/modules/scheduler/services/__tests__/localSchedulerService.test.ts +14 -6
- package/src/modules/scheduler/services/localSchedulerService.ts +10 -8
- package/src/modules/scheduler/workers/__tests__/execute-schedule.worker.test.ts +78 -0
- package/src/modules/scheduler/workers/execute-schedule.worker.ts +10 -12
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:scheduler] found
|
|
1
|
+
[build:scheduler] found 53 entry points
|
|
2
2
|
[build:scheduler] built successfully
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
function buildQueueTargetPayload(input) {
|
|
2
|
+
const base = input.targetPayload && typeof input.targetPayload === "object" && !Array.isArray(input.targetPayload) ? { ...input.targetPayload } : {};
|
|
3
|
+
return {
|
|
4
|
+
...base,
|
|
5
|
+
tenantId: input.tenantId ?? null,
|
|
6
|
+
organizationId: input.organizationId ?? null,
|
|
7
|
+
_idempotencyKey: input.idempotencyKey
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
function buildSchedulerIdempotencyKey(scheduleId, executionKey) {
|
|
11
|
+
return `scheduler-${scheduleId}-${executionKey}`;
|
|
12
|
+
}
|
|
13
|
+
export {
|
|
14
|
+
buildQueueTargetPayload,
|
|
15
|
+
buildSchedulerIdempotencyKey
|
|
16
|
+
};
|
|
17
|
+
//# sourceMappingURL=queueTargetPayload.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/scheduler/lib/queueTargetPayload.ts"],
|
|
4
|
+
"sourcesContent": ["export type QueueTargetPayloadInput = {\n targetPayload: unknown\n tenantId: string | null | undefined\n organizationId: string | null | undefined\n idempotencyKey: string\n}\n\n/**\n * Single source of truth for the payload a scheduled queue target receives.\n * Both the local scheduler and the asynchronous execute-schedule worker MUST\n * build their enqueue payload here so workers see one flat contract:\n * the configured targetPayload fields on the root, with scheduler-owned\n * tenantId/organizationId/_idempotencyKey applied last so they always win\n * over conflicting targetPayload fields.\n */\nexport function buildQueueTargetPayload(input: QueueTargetPayloadInput): Record<string, unknown> {\n const base =\n input.targetPayload && typeof input.targetPayload === 'object' && !Array.isArray(input.targetPayload)\n ? { ...(input.targetPayload as Record<string, unknown>) }\n : {}\n return {\n ...base,\n tenantId: input.tenantId ?? null,\n organizationId: input.organizationId ?? null,\n _idempotencyKey: input.idempotencyKey,\n }\n}\n\n/**\n * One logical scheduled firing must keep one idempotency key across worker\n * retries, so downstream consumers can deduplicate. Pass a retry-stable\n * execution key: the execute-schedule job id in async mode, or the firing\n * timestamp in local mode (which runs each firing exactly once).\n */\nexport function buildSchedulerIdempotencyKey(scheduleId: string, executionKey: string | number): string {\n return `scheduler-${scheduleId}-${executionKey}`\n}\n"],
|
|
5
|
+
"mappings": "AAeO,SAAS,wBAAwB,OAAyD;AAC/F,QAAM,OACJ,MAAM,iBAAiB,OAAO,MAAM,kBAAkB,YAAY,CAAC,MAAM,QAAQ,MAAM,aAAa,IAChG,EAAE,GAAI,MAAM,cAA0C,IACtD,CAAC;AACP,SAAO;AAAA,IACL,GAAG;AAAA,IACH,UAAU,MAAM,YAAY;AAAA,IAC5B,gBAAgB,MAAM,kBAAkB;AAAA,IACxC,iBAAiB,MAAM;AAAA,EACzB;AACF;AAQO,SAAS,6BAA6B,YAAoB,cAAuC;AACtG,SAAO,aAAa,UAAU,IAAI,YAAY;AAChD;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -5,6 +5,7 @@ import { recalculateNextRun } from "../lib/nextRunCalculator.js";
|
|
|
5
5
|
import { emitSchedulerEvent } from "../events.js";
|
|
6
6
|
import { getGlobalEventBus } from "@open-mercato/shared/modules/events";
|
|
7
7
|
import { buildScheduledCommandContext } from "../lib/commandContext.js";
|
|
8
|
+
import { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from "../lib/queueTargetPayload.js";
|
|
8
9
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
9
10
|
const logger = createLogger("scheduler").child({ component: "local" });
|
|
10
11
|
class LocalSchedulerService {
|
|
@@ -167,15 +168,12 @@ class LocalSchedulerService {
|
|
|
167
168
|
throw new Error("Target queue is required for queue target type");
|
|
168
169
|
}
|
|
169
170
|
const queue = this.queueFactory(schedule.targetQueue);
|
|
170
|
-
await queue.enqueue({
|
|
171
|
-
|
|
172
|
-
scheduleName: schedule.name,
|
|
173
|
-
scopeType: schedule.scopeType,
|
|
171
|
+
await queue.enqueue(buildQueueTargetPayload({
|
|
172
|
+
targetPayload: schedule.targetPayload,
|
|
174
173
|
tenantId: schedule.tenantId,
|
|
175
174
|
organizationId: schedule.organizationId,
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
});
|
|
175
|
+
idempotencyKey: buildSchedulerIdempotencyKey(schedule.id, Date.now())
|
|
176
|
+
}));
|
|
179
177
|
logger.info("Enqueued job to target queue", { scheduleId: schedule.id, targetQueue: schedule.targetQueue });
|
|
180
178
|
}
|
|
181
179
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/scheduler/services/localSchedulerService.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/core'\nimport type { Queue } from '@open-mercato/queue'\nimport { CommandBus } from '@open-mercato/shared/lib/commands'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport { ScheduledJob } from '../data/entities.js'\nimport { LocalLockStrategy } from '../lib/localLockStrategy'\nimport { recalculateNextRun } from '../lib/nextRunCalculator'\nimport { emitSchedulerEvent } from '../events.js'\nimport { getGlobalEventBus } from '@open-mercato/shared/modules/events'\nimport { buildScheduledCommandContext } from '../lib/commandContext.js'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('scheduler').child({ component: 'local' })\n\nexport interface RbacServiceLike {\n tenantHasFeature(tenantId: string | null | undefined, feature: string, opts?: { organizationId?: string | null }): Promise<boolean>\n}\n\nexport interface LocalSchedulerConfig {\n pollIntervalMs: number\n}\n\n/**\n * Local scheduler service for development\n * \n * This service polls the database for due schedules and executes them locally\n * without requiring Redis. Perfect for local development.\n * \n * Features:\n * - PostgreSQL polling (configurable interval, default 30s)\n * - PostgreSQL advisory locks for duplicate prevention\n * - Supports both queue and command targets\n * - Emits the same events as async strategy\n * - Updates nextRunAt after execution\n * \n * Limitations:\n * - Polling delay (up to configured interval)\n * - Single instance only (no distributed locking across instances)\n * - Higher database load than async strategy\n * \n * Set QUEUE_STRATEGY=local to use this service.\n */\nexport class LocalSchedulerService {\n private isRunning = false\n private pollTimer?: NodeJS.Timeout\n private lockStrategy: LocalLockStrategy\n\n constructor(\n private em: () => EntityManager,\n private queueFactory: (name: string) => Queue,\n private rbacService: RbacServiceLike,\n private config: LocalSchedulerConfig = { pollIntervalMs: 30000 },\n ) {\n this.lockStrategy = new LocalLockStrategy(em)\n }\n\n /**\n * Start the local scheduler polling engine\n */\n async start(): Promise<void> {\n if (this.isRunning) {\n logger.warn('Polling engine already running')\n return\n }\n\n this.isRunning = true\n logger.info('Starting polling engine', { pollIntervalMs: this.config.pollIntervalMs })\n\n // Run initial poll immediately\n await this.poll()\n\n // Schedule recurring polls\n this.pollTimer = setInterval(() => {\n this.poll().catch((error) => {\n logger.error('Poll error', { err: error })\n })\n }, this.config.pollIntervalMs)\n\n logger.info('Polling engine started')\n }\n\n /**\n * Stop the local scheduler\n */\n async stop(): Promise<void> {\n logger.info('Stopping polling engine')\n this.isRunning = false\n \n if (this.pollTimer) {\n clearInterval(this.pollTimer)\n this.pollTimer = undefined\n }\n\n logger.info('Polling engine stopped')\n }\n\n /**\n * Poll for due schedules and execute them\n */\n private async poll(): Promise<void> {\n if (!this.isRunning) {\n return\n }\n\n const em = this.em().fork()\n\n try {\n // Find enabled schedules that are due (limited to avoid spikes after outages)\n const dueSchedules = await em.find(ScheduledJob, {\n isEnabled: true,\n deletedAt: null,\n nextRunAt: { $lte: new Date() },\n }, {\n limit: 100,\n orderBy: { nextRunAt: 'ASC' },\n })\n\n if (dueSchedules.length === 0) {\n logger.debug('No due schedules')\n return\n }\n\n logger.info('Found due schedules', { count: dueSchedules.length })\n\n // Execute each schedule\n for (const schedule of dueSchedules) {\n await this.executeSchedule(schedule)\n }\n } catch (error: unknown) {\n logger.error('Poll failed', { err: error })\n }\n }\n\n /**\n * Execute a single schedule\n */\n private async executeSchedule(schedule: ScheduledJob): Promise<void> {\n const lockKey = `schedule:${schedule.id}`\n const scheduleLogger = logger.child({ scheduleId: schedule.id, scheduleName: schedule.name })\n\n const { acquired } = await this.lockStrategy.runWithLock(lockKey, async () => {\n scheduleLogger.info('Executing schedule')\n\n // Emit started event\n await emitSchedulerEvent('scheduler.job.started', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n triggerType: 'scheduled',\n startedAt: new Date(),\n })\n\n try {\n // Check feature flag if required\n if (schedule.requireFeature) {\n const hasFeature = await this.checkFeature(schedule)\n \n if (!hasFeature) {\n scheduleLogger.info('Schedule skipped: missing required feature', { requireFeature: schedule.requireFeature })\n \n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n reason: `Missing required feature: ${schedule.requireFeature}`,\n skippedAt: new Date(),\n })\n\n // Still update next run time\n await this.updateNextRun(schedule)\n return\n }\n }\n\n // Execute based on target type\n if (schedule.targetType === 'queue') {\n await this.executeQueueTarget(schedule)\n } else if (schedule.targetType === 'command') {\n await this.executeCommandTarget(schedule)\n } else {\n throw new Error(`Unknown target type: ${schedule.targetType}`)\n }\n\n // Update last run and next run times\n const em = this.em().fork()\n const freshSchedule = await em.findOne(ScheduledJob, { id: schedule.id })\n \n if (freshSchedule) {\n freshSchedule.lastRunAt = new Date()\n \n // Calculate next run time\n const nextRun = recalculateNextRun(\n freshSchedule.scheduleType,\n freshSchedule.scheduleValue,\n freshSchedule.timezone\n )\n \n if (nextRun) {\n freshSchedule.nextRunAt = nextRun\n }\n \n await em.flush()\n }\n\n scheduleLogger.info('Schedule completed')\n\n // Emit completed event\n await emitSchedulerEvent('scheduler.job.completed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n completedAt: new Date(),\n })\n } catch (error: unknown) {\n scheduleLogger.error('Schedule failed', { err: error })\n\n // Emit failed event\n await emitSchedulerEvent('scheduler.job.failed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n error: error instanceof Error ? error.message : String(error),\n failedAt: new Date(),\n })\n\n // Still update next run time even on failure\n await this.updateNextRun(schedule)\n }\n })\n\n if (!acquired) {\n scheduleLogger.debug('Schedule already locked, skipping')\n return\n }\n }\n\n /**\n * Execute a queue-based target\n */\n private async executeQueueTarget(schedule: ScheduledJob): Promise<void> {\n if (!schedule.targetQueue) {\n throw new Error('Target queue is required for queue target type')\n }\n\n const queue = this.queueFactory(schedule.targetQueue)\n \n await queue.enqueue({\n scheduleId: schedule.id,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n payload: schedule.targetPayload || {},\n triggeredAt: new Date(),\n })\n\n logger.info('Enqueued job to target queue', { scheduleId: schedule.id, targetQueue: schedule.targetQueue })\n }\n\n /**\n * Execute a command-based target\n */\n private async executeCommandTarget(schedule: ScheduledJob): Promise<void> {\n if (!schedule.targetCommand) {\n throw new Error('Target command is required for command target type')\n }\n\n const commandBus = new CommandBus()\n \n const commandInput = {\n ...((schedule.targetPayload as Record<string, unknown>) || {}),\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n }\n \n const commandCtx = buildScheduledCommandContext(\n schedule,\n {\n resolve: (name: string) => {\n // Simple resolver that forwards to our dependencies\n if (name === 'em') return this.em()\n if (name === 'eventBus') return getGlobalEventBus()\n if (name === 'rbacService') return this.rbacService\n throw new Error(`Service not available in scheduler context: ${name}`)\n },\n } as AppContainer,\n )\n\n await commandBus.execute(schedule.targetCommand, {\n input: commandInput,\n ctx: commandCtx,\n })\n\n logger.info('Executed command', { scheduleId: schedule.id, targetCommand: schedule.targetCommand })\n }\n\n /**\n * Check if user/tenant has required feature\n */\n private async checkFeature(schedule: ScheduledJob): Promise<boolean> {\n if (!schedule.requireFeature) {\n return true\n }\n\n try {\n // For system-scoped schedules, no RBAC check needed\n if (schedule.scopeType === 'system') {\n return true\n }\n\n // For tenant/org scoped schedules, check if ANY admin user has the feature\n // This is a simplified check - in reality, scheduled jobs run without a user context\n // so we're just checking if the feature is enabled for the tenant\n const hasFeature = await this.rbacService.tenantHasFeature(\n schedule.tenantId,\n schedule.requireFeature,\n {\n organizationId: schedule.organizationId,\n }\n )\n\n return hasFeature\n } catch (error: unknown) {\n logger.error('Feature check failed', { scheduleId: schedule.id, err: error })\n return false\n }\n }\n\n /**\n * Update next run time for a schedule\n */\n private async updateNextRun(schedule: ScheduledJob): Promise<void> {\n const em = this.em().fork()\n const freshSchedule = await em.findOne(ScheduledJob, { id: schedule.id })\n \n if (freshSchedule) {\n const nextRun = recalculateNextRun(\n freshSchedule.scheduleType,\n freshSchedule.scheduleValue,\n freshSchedule.timezone\n )\n \n if (nextRun) {\n freshSchedule.nextRunAt = nextRun\n await em.flush()\n }\n }\n }\n}\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/core'\nimport type { Queue } from '@open-mercato/queue'\nimport { CommandBus } from '@open-mercato/shared/lib/commands'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport { ScheduledJob } from '../data/entities.js'\nimport { LocalLockStrategy } from '../lib/localLockStrategy'\nimport { recalculateNextRun } from '../lib/nextRunCalculator'\nimport { emitSchedulerEvent } from '../events.js'\nimport { getGlobalEventBus } from '@open-mercato/shared/modules/events'\nimport { buildScheduledCommandContext } from '../lib/commandContext.js'\nimport { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from '../lib/queueTargetPayload.js'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('scheduler').child({ component: 'local' })\n\nexport interface RbacServiceLike {\n tenantHasFeature(tenantId: string | null | undefined, feature: string, opts?: { organizationId?: string | null }): Promise<boolean>\n}\n\nexport interface LocalSchedulerConfig {\n pollIntervalMs: number\n}\n\n/**\n * Local scheduler service for development\n * \n * This service polls the database for due schedules and executes them locally\n * without requiring Redis. Perfect for local development.\n * \n * Features:\n * - PostgreSQL polling (configurable interval, default 30s)\n * - PostgreSQL advisory locks for duplicate prevention\n * - Supports both queue and command targets\n * - Emits the same events as async strategy\n * - Updates nextRunAt after execution\n * \n * Limitations:\n * - Polling delay (up to configured interval)\n * - Single instance only (no distributed locking across instances)\n * - Higher database load than async strategy\n * \n * Set QUEUE_STRATEGY=local to use this service.\n */\nexport class LocalSchedulerService {\n private isRunning = false\n private pollTimer?: NodeJS.Timeout\n private lockStrategy: LocalLockStrategy\n\n constructor(\n private em: () => EntityManager,\n private queueFactory: (name: string) => Queue,\n private rbacService: RbacServiceLike,\n private config: LocalSchedulerConfig = { pollIntervalMs: 30000 },\n ) {\n this.lockStrategy = new LocalLockStrategy(em)\n }\n\n /**\n * Start the local scheduler polling engine\n */\n async start(): Promise<void> {\n if (this.isRunning) {\n logger.warn('Polling engine already running')\n return\n }\n\n this.isRunning = true\n logger.info('Starting polling engine', { pollIntervalMs: this.config.pollIntervalMs })\n\n // Run initial poll immediately\n await this.poll()\n\n // Schedule recurring polls\n this.pollTimer = setInterval(() => {\n this.poll().catch((error) => {\n logger.error('Poll error', { err: error })\n })\n }, this.config.pollIntervalMs)\n\n logger.info('Polling engine started')\n }\n\n /**\n * Stop the local scheduler\n */\n async stop(): Promise<void> {\n logger.info('Stopping polling engine')\n this.isRunning = false\n \n if (this.pollTimer) {\n clearInterval(this.pollTimer)\n this.pollTimer = undefined\n }\n\n logger.info('Polling engine stopped')\n }\n\n /**\n * Poll for due schedules and execute them\n */\n private async poll(): Promise<void> {\n if (!this.isRunning) {\n return\n }\n\n const em = this.em().fork()\n\n try {\n // Find enabled schedules that are due (limited to avoid spikes after outages)\n const dueSchedules = await em.find(ScheduledJob, {\n isEnabled: true,\n deletedAt: null,\n nextRunAt: { $lte: new Date() },\n }, {\n limit: 100,\n orderBy: { nextRunAt: 'ASC' },\n })\n\n if (dueSchedules.length === 0) {\n logger.debug('No due schedules')\n return\n }\n\n logger.info('Found due schedules', { count: dueSchedules.length })\n\n // Execute each schedule\n for (const schedule of dueSchedules) {\n await this.executeSchedule(schedule)\n }\n } catch (error: unknown) {\n logger.error('Poll failed', { err: error })\n }\n }\n\n /**\n * Execute a single schedule\n */\n private async executeSchedule(schedule: ScheduledJob): Promise<void> {\n const lockKey = `schedule:${schedule.id}`\n const scheduleLogger = logger.child({ scheduleId: schedule.id, scheduleName: schedule.name })\n\n const { acquired } = await this.lockStrategy.runWithLock(lockKey, async () => {\n scheduleLogger.info('Executing schedule')\n\n // Emit started event\n await emitSchedulerEvent('scheduler.job.started', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n triggerType: 'scheduled',\n startedAt: new Date(),\n })\n\n try {\n // Check feature flag if required\n if (schedule.requireFeature) {\n const hasFeature = await this.checkFeature(schedule)\n \n if (!hasFeature) {\n scheduleLogger.info('Schedule skipped: missing required feature', { requireFeature: schedule.requireFeature })\n \n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n reason: `Missing required feature: ${schedule.requireFeature}`,\n skippedAt: new Date(),\n })\n\n // Still update next run time\n await this.updateNextRun(schedule)\n return\n }\n }\n\n // Execute based on target type\n if (schedule.targetType === 'queue') {\n await this.executeQueueTarget(schedule)\n } else if (schedule.targetType === 'command') {\n await this.executeCommandTarget(schedule)\n } else {\n throw new Error(`Unknown target type: ${schedule.targetType}`)\n }\n\n // Update last run and next run times\n const em = this.em().fork()\n const freshSchedule = await em.findOne(ScheduledJob, { id: schedule.id })\n \n if (freshSchedule) {\n freshSchedule.lastRunAt = new Date()\n \n // Calculate next run time\n const nextRun = recalculateNextRun(\n freshSchedule.scheduleType,\n freshSchedule.scheduleValue,\n freshSchedule.timezone\n )\n \n if (nextRun) {\n freshSchedule.nextRunAt = nextRun\n }\n \n await em.flush()\n }\n\n scheduleLogger.info('Schedule completed')\n\n // Emit completed event\n await emitSchedulerEvent('scheduler.job.completed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n completedAt: new Date(),\n })\n } catch (error: unknown) {\n scheduleLogger.error('Schedule failed', { err: error })\n\n // Emit failed event\n await emitSchedulerEvent('scheduler.job.failed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n scopeType: schedule.scopeType,\n error: error instanceof Error ? error.message : String(error),\n failedAt: new Date(),\n })\n\n // Still update next run time even on failure\n await this.updateNextRun(schedule)\n }\n })\n\n if (!acquired) {\n scheduleLogger.debug('Schedule already locked, skipping')\n return\n }\n }\n\n /**\n * Execute a queue-based target\n */\n private async executeQueueTarget(schedule: ScheduledJob): Promise<void> {\n if (!schedule.targetQueue) {\n throw new Error('Target queue is required for queue target type')\n }\n\n const queue = this.queueFactory(schedule.targetQueue)\n\n // Deliver the same flat contract as the asynchronous execute-schedule\n // worker: targetPayload fields on the root, scheduler-owned scope and\n // idempotency fields applied last. Local mode runs each firing exactly\n // once, so the firing timestamp is a valid logical execution key.\n await queue.enqueue(buildQueueTargetPayload({\n targetPayload: schedule.targetPayload,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n idempotencyKey: buildSchedulerIdempotencyKey(schedule.id, Date.now()),\n }))\n\n logger.info('Enqueued job to target queue', { scheduleId: schedule.id, targetQueue: schedule.targetQueue })\n }\n\n /**\n * Execute a command-based target\n */\n private async executeCommandTarget(schedule: ScheduledJob): Promise<void> {\n if (!schedule.targetCommand) {\n throw new Error('Target command is required for command target type')\n }\n\n const commandBus = new CommandBus()\n \n const commandInput = {\n ...((schedule.targetPayload as Record<string, unknown>) || {}),\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n }\n \n const commandCtx = buildScheduledCommandContext(\n schedule,\n {\n resolve: (name: string) => {\n // Simple resolver that forwards to our dependencies\n if (name === 'em') return this.em()\n if (name === 'eventBus') return getGlobalEventBus()\n if (name === 'rbacService') return this.rbacService\n throw new Error(`Service not available in scheduler context: ${name}`)\n },\n } as AppContainer,\n )\n\n await commandBus.execute(schedule.targetCommand, {\n input: commandInput,\n ctx: commandCtx,\n })\n\n logger.info('Executed command', { scheduleId: schedule.id, targetCommand: schedule.targetCommand })\n }\n\n /**\n * Check if user/tenant has required feature\n */\n private async checkFeature(schedule: ScheduledJob): Promise<boolean> {\n if (!schedule.requireFeature) {\n return true\n }\n\n try {\n // For system-scoped schedules, no RBAC check needed\n if (schedule.scopeType === 'system') {\n return true\n }\n\n // For tenant/org scoped schedules, check if ANY admin user has the feature\n // This is a simplified check - in reality, scheduled jobs run without a user context\n // so we're just checking if the feature is enabled for the tenant\n const hasFeature = await this.rbacService.tenantHasFeature(\n schedule.tenantId,\n schedule.requireFeature,\n {\n organizationId: schedule.organizationId,\n }\n )\n\n return hasFeature\n } catch (error: unknown) {\n logger.error('Feature check failed', { scheduleId: schedule.id, err: error })\n return false\n }\n }\n\n /**\n * Update next run time for a schedule\n */\n private async updateNextRun(schedule: ScheduledJob): Promise<void> {\n const em = this.em().fork()\n const freshSchedule = await em.findOne(ScheduledJob, { id: schedule.id })\n \n if (freshSchedule) {\n const nextRun = recalculateNextRun(\n freshSchedule.scheduleType,\n freshSchedule.scheduleValue,\n freshSchedule.timezone\n )\n \n if (nextRun) {\n freshSchedule.nextRunAt = nextRun\n await em.flush()\n }\n }\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -4,6 +4,7 @@ import { ScheduledJob } from "../data/entities.js";
|
|
|
4
4
|
import { CommandBus } from "@open-mercato/shared/lib/commands";
|
|
5
5
|
import { emitSchedulerEvent } from "../events.js";
|
|
6
6
|
import { buildScheduledCommandContext } from "../lib/commandContext.js";
|
|
7
|
+
import { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from "../lib/queueTargetPayload.js";
|
|
7
8
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
8
9
|
const logger = createLogger("scheduler").child({ component: "worker" });
|
|
9
10
|
const metadata = {
|
|
@@ -97,15 +98,13 @@ async function executeScheduleWorker(job, ctx) {
|
|
|
97
98
|
});
|
|
98
99
|
let targetJobId;
|
|
99
100
|
try {
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
...schedule.targetPayload || {},
|
|
101
|
+
const idempotencyKey = buildSchedulerIdempotencyKey(schedule.id, ctx.jobId ?? Date.now());
|
|
102
|
+
targetJobId = await targetQueue.enqueue(buildQueueTargetPayload({
|
|
103
|
+
targetPayload: schedule.targetPayload,
|
|
104
104
|
tenantId: schedule.tenantId,
|
|
105
105
|
organizationId: schedule.organizationId,
|
|
106
|
-
|
|
107
|
-
};
|
|
108
|
-
targetJobId = await targetQueue.enqueue(queuePayload);
|
|
106
|
+
idempotencyKey
|
|
107
|
+
}));
|
|
109
108
|
} finally {
|
|
110
109
|
await targetQueue.close();
|
|
111
110
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/scheduler/workers/execute-schedule.worker.ts"],
|
|
4
|
-
"sourcesContent": ["import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport { createQueue } from '@open-mercato/queue'\nimport { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'\nimport type { EntityManager } from '@mikro-orm/core'\nimport { ScheduledJob } from '../data/entities.js'\nimport { CommandBus } from '@open-mercato/shared/lib/commands'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport { emitSchedulerEvent } from '../events.js'\nimport { buildScheduledCommandContext } from '../lib/commandContext.js'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('scheduler').child({ component: 'worker' })\n\n// Worker metadata for auto-discovery\nexport const metadata: WorkerMeta = {\n queue: 'scheduler-execution',\n concurrency: 5, // Process up to 5 schedules concurrently\n}\n\nexport type ExecuteSchedulePayload = {\n scheduleId: string\n tenantId?: string | null\n organizationId?: string | null\n scopeType: 'system' | 'organization' | 'tenant'\n triggerType?: 'scheduled' | 'manual'\n triggeredByUserId?: string | null\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\ntype RbacServiceLike = {\n tenantHasFeature(\n tenantId: string | null | undefined,\n feature: string,\n opts?: { organizationId?: string | null },\n ): Promise<boolean>\n}\n\n/**\n * Worker that executes scheduled jobs.\n * \n * This worker is triggered by BullMQ repeatable jobs at the scheduled times.\n * It loads the fresh schedule configuration from the database, validates\n * conditions, and enqueues the target job or executes the command.\n * \n * BullMQ handles:\n * - Timing (exact cron/interval execution)\n * - Distributed locking (prevents duplicate execution)\n * - Retries (if worker fails)\n * - Execution history (via job state, logs, timestamps)\n * \n * This worker handles:\n * - Loading fresh schedule config\n * - Checking feature flags and conditions\n * - Enqueuing target job or executing command\n * - Updating last run time\n */\nexport default async function executeScheduleWorker(\n job: QueuedJob<ExecuteSchedulePayload>,\n ctx: JobContext & HandlerContext,\n): Promise<void> {\n logger.debug('Processing job', {\n jobId: ctx.jobId,\n attemptNumber: ctx.attemptNumber,\n })\n \n // Defensive: handle both data and payload for BullMQ compatibility\n const payload = (job.payload || (job as unknown as { data?: ExecuteSchedulePayload }).data) as ExecuteSchedulePayload | undefined\n \n if (!payload || !payload.scheduleId) {\n logger.error('Invalid job payload: scheduleId missing', { jobId: ctx.jobId })\n throw new Error('scheduleId is required in job payload')\n }\n\n const { scheduleId } = payload\n\n const em = ctx.resolve<EntityManager>('em')\n const rbacService = ctx.resolve<RbacServiceLike>('rbacService')\n\n // Load fresh schedule from database\n const schedule = await em.findOne(ScheduledJob, { \n id: scheduleId,\n deletedAt: null,\n })\n\n if (!schedule) {\n logger.info('Schedule not found or deleted', { scheduleId })\n return\n }\n\n // CRITICAL: Verify scope integrity - ensure payload scope matches database\n // This prevents scope tampering and ensures proper multi-tenant isolation\n if (payload.scopeType !== schedule.scopeType) {\n logger.error('Scope type mismatch for schedule', {\n scheduleId,\n payloadScope: payload.scopeType,\n dbScope: schedule.scopeType,\n })\n throw new Error('Schedule scope type mismatch - potential security issue')\n }\n\n if (payload.tenantId !== schedule.tenantId) {\n logger.error('Tenant ID mismatch for schedule', {\n scheduleId,\n payloadTenant: payload.tenantId,\n dbTenant: schedule.tenantId,\n })\n throw new Error('Schedule tenant ID mismatch - potential security issue')\n }\n\n if (payload.organizationId !== schedule.organizationId) {\n logger.error('Organization ID mismatch for schedule', {\n scheduleId,\n payloadOrg: payload.organizationId,\n dbOrg: schedule.organizationId,\n })\n throw new Error('Schedule organization ID mismatch - potential security issue')\n }\n\n // Check if schedule is still enabled\n if (!schedule.isEnabled) {\n logger.debug('Schedule is disabled', { scheduleId })\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: 'Schedule is disabled',\n })\n return\n }\n\n // Emit started event\n await emitSchedulerEvent('scheduler.job.started', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n attemptNumber: ctx.attemptNumber || 1,\n })\n\n // Check feature flag if required\n if (schedule.requireFeature) {\n const hasFeature = await rbacService.tenantHasFeature(\n schedule.tenantId,\n schedule.requireFeature,\n { organizationId: schedule.organizationId },\n )\n \n if (!hasFeature) {\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: `Feature not enabled: ${schedule.requireFeature}`,\n })\n\n logger.debug('Schedule skipped: feature not enabled', { scheduleId, requireFeature: schedule.requireFeature })\n return\n }\n }\n\n // Enqueue target job or execute command\n if (schedule.targetType === 'queue' && schedule.targetQueue) {\n // Determine queue strategy from environment\n const queueStrategy = (process.env.QUEUE_STRATEGY || 'local') as 'local' | 'async'\n const targetQueue = createQueue(schedule.targetQueue, queueStrategy, {\n connection: { url: getRedisUrlOrThrow('QUEUE') },\n })\n \n let targetJobId: string | undefined\n try {\n //
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["import type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport { createQueue } from '@open-mercato/queue'\nimport { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'\nimport type { EntityManager } from '@mikro-orm/core'\nimport { ScheduledJob } from '../data/entities.js'\nimport { CommandBus } from '@open-mercato/shared/lib/commands'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport { emitSchedulerEvent } from '../events.js'\nimport { buildScheduledCommandContext } from '../lib/commandContext.js'\nimport { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from '../lib/queueTargetPayload.js'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('scheduler').child({ component: 'worker' })\n\n// Worker metadata for auto-discovery\nexport const metadata: WorkerMeta = {\n queue: 'scheduler-execution',\n concurrency: 5, // Process up to 5 schedules concurrently\n}\n\nexport type ExecuteSchedulePayload = {\n scheduleId: string\n tenantId?: string | null\n organizationId?: string | null\n scopeType: 'system' | 'organization' | 'tenant'\n triggerType?: 'scheduled' | 'manual'\n triggeredByUserId?: string | null\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\ntype RbacServiceLike = {\n tenantHasFeature(\n tenantId: string | null | undefined,\n feature: string,\n opts?: { organizationId?: string | null },\n ): Promise<boolean>\n}\n\n/**\n * Worker that executes scheduled jobs.\n * \n * This worker is triggered by BullMQ repeatable jobs at the scheduled times.\n * It loads the fresh schedule configuration from the database, validates\n * conditions, and enqueues the target job or executes the command.\n * \n * BullMQ handles:\n * - Timing (exact cron/interval execution)\n * - Distributed locking (prevents duplicate execution)\n * - Retries (if worker fails)\n * - Execution history (via job state, logs, timestamps)\n * \n * This worker handles:\n * - Loading fresh schedule config\n * - Checking feature flags and conditions\n * - Enqueuing target job or executing command\n * - Updating last run time\n */\nexport default async function executeScheduleWorker(\n job: QueuedJob<ExecuteSchedulePayload>,\n ctx: JobContext & HandlerContext,\n): Promise<void> {\n logger.debug('Processing job', {\n jobId: ctx.jobId,\n attemptNumber: ctx.attemptNumber,\n })\n \n // Defensive: handle both data and payload for BullMQ compatibility\n const payload = (job.payload || (job as unknown as { data?: ExecuteSchedulePayload }).data) as ExecuteSchedulePayload | undefined\n \n if (!payload || !payload.scheduleId) {\n logger.error('Invalid job payload: scheduleId missing', { jobId: ctx.jobId })\n throw new Error('scheduleId is required in job payload')\n }\n\n const { scheduleId } = payload\n\n const em = ctx.resolve<EntityManager>('em')\n const rbacService = ctx.resolve<RbacServiceLike>('rbacService')\n\n // Load fresh schedule from database\n const schedule = await em.findOne(ScheduledJob, { \n id: scheduleId,\n deletedAt: null,\n })\n\n if (!schedule) {\n logger.info('Schedule not found or deleted', { scheduleId })\n return\n }\n\n // CRITICAL: Verify scope integrity - ensure payload scope matches database\n // This prevents scope tampering and ensures proper multi-tenant isolation\n if (payload.scopeType !== schedule.scopeType) {\n logger.error('Scope type mismatch for schedule', {\n scheduleId,\n payloadScope: payload.scopeType,\n dbScope: schedule.scopeType,\n })\n throw new Error('Schedule scope type mismatch - potential security issue')\n }\n\n if (payload.tenantId !== schedule.tenantId) {\n logger.error('Tenant ID mismatch for schedule', {\n scheduleId,\n payloadTenant: payload.tenantId,\n dbTenant: schedule.tenantId,\n })\n throw new Error('Schedule tenant ID mismatch - potential security issue')\n }\n\n if (payload.organizationId !== schedule.organizationId) {\n logger.error('Organization ID mismatch for schedule', {\n scheduleId,\n payloadOrg: payload.organizationId,\n dbOrg: schedule.organizationId,\n })\n throw new Error('Schedule organization ID mismatch - potential security issue')\n }\n\n // Check if schedule is still enabled\n if (!schedule.isEnabled) {\n logger.debug('Schedule is disabled', { scheduleId })\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: 'Schedule is disabled',\n })\n return\n }\n\n // Emit started event\n await emitSchedulerEvent('scheduler.job.started', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n scheduleName: schedule.name,\n attemptNumber: ctx.attemptNumber || 1,\n })\n\n // Check feature flag if required\n if (schedule.requireFeature) {\n const hasFeature = await rbacService.tenantHasFeature(\n schedule.tenantId,\n schedule.requireFeature,\n { organizationId: schedule.organizationId },\n )\n \n if (!hasFeature) {\n await emitSchedulerEvent('scheduler.job.skipped', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n reason: `Feature not enabled: ${schedule.requireFeature}`,\n })\n\n logger.debug('Schedule skipped: feature not enabled', { scheduleId, requireFeature: schedule.requireFeature })\n return\n }\n }\n\n // Enqueue target job or execute command\n if (schedule.targetType === 'queue' && schedule.targetQueue) {\n // Determine queue strategy from environment\n const queueStrategy = (process.env.QUEUE_STRATEGY || 'local') as 'local' | 'async'\n const targetQueue = createQueue(schedule.targetQueue, queueStrategy, {\n connection: { url: getRedisUrlOrThrow('QUEUE') },\n })\n \n let targetJobId: string | undefined\n try {\n // The execute-schedule job id is stable across BullMQ retries, so if\n // this worker crashes between enqueue and DB flush the retried attempt\n // reuses the same idempotency key and downstream workers can dedupe.\n const idempotencyKey = buildSchedulerIdempotencyKey(schedule.id, ctx.jobId ?? Date.now())\n\n targetJobId = await targetQueue.enqueue(buildQueueTargetPayload({\n targetPayload: schedule.targetPayload,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n idempotencyKey,\n }))\n } finally {\n // Always close the queue instance to free Redis connections\n await targetQueue.close()\n }\n \n // Update schedule's last run time\n schedule.lastRunAt = new Date()\n await em.flush()\n\n await emitSchedulerEvent('scheduler.job.completed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n queueJobId: targetJobId,\n queueName: schedule.targetQueue,\n })\n\n logger.debug('Successfully enqueued job', {\n scheduleId: schedule.id,\n targetQueue: schedule.targetQueue,\n queueJobId: targetJobId,\n })\n\n } else if (schedule.targetType === 'command' && schedule.targetCommand) {\n const commandBus = new CommandBus()\n \n const commandInput = {\n ...((schedule.targetPayload as Record<string, unknown>) || {}),\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n }\n \n const commandCtx = buildScheduledCommandContext(schedule, ctx as unknown as AppContainer)\n \n const commandResult = await commandBus.execute(schedule.targetCommand, {\n input: commandInput,\n ctx: commandCtx,\n })\n \n // Update schedule's last run time\n schedule.lastRunAt = new Date()\n await em.flush()\n \n await emitSchedulerEvent('scheduler.job.completed', {\n id: schedule.id,\n tenantId: schedule.tenantId,\n organizationId: schedule.organizationId,\n commandId: schedule.targetCommand,\n commandResult: commandResult.result,\n })\n \n logger.debug('Successfully executed command', {\n scheduleId: schedule.id,\n commandId: schedule.targetCommand,\n })\n\n } else {\n throw new Error('Invalid target configuration')\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/scheduler",
|
|
3
|
-
"version": "0.6.7-develop.
|
|
3
|
+
"version": "0.6.7-develop.6645.1.52f0b37813",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Database-managed scheduled jobs with admin UI",
|
|
6
6
|
"type": "module",
|
|
@@ -59,13 +59,13 @@
|
|
|
59
59
|
"./*/*/*/*/*/*.json": "./src/*/*/*/*/*/*.json"
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@open-mercato/events": "0.6.7-develop.
|
|
63
|
-
"@open-mercato/queue": "0.6.7-develop.
|
|
62
|
+
"@open-mercato/events": "0.6.7-develop.6645.1.52f0b37813",
|
|
63
|
+
"@open-mercato/queue": "0.6.7-develop.6645.1.52f0b37813",
|
|
64
64
|
"cron-parser": "^5.6.2"
|
|
65
65
|
},
|
|
66
66
|
"peerDependencies": {
|
|
67
67
|
"@mikro-orm/core": "^7.0.14",
|
|
68
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
68
|
+
"@open-mercato/shared": "0.6.7-develop.6645.1.52f0b37813",
|
|
69
69
|
"bullmq": "^5.0.0",
|
|
70
70
|
"ioredis": "^5.0.0",
|
|
71
71
|
"zod": ">=3.23.0"
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
}
|
|
80
80
|
},
|
|
81
81
|
"devDependencies": {
|
|
82
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
82
|
+
"@open-mercato/shared": "0.6.7-develop.6645.1.52f0b37813",
|
|
83
83
|
"@types/jest": "^30.0.0",
|
|
84
84
|
"@types/node": "^26.0.1",
|
|
85
85
|
"jest": "^30.4.2",
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from '../queueTargetPayload'
|
|
2
|
+
|
|
3
|
+
describe('buildQueueTargetPayload', () => {
|
|
4
|
+
it('spreads targetPayload fields onto the payload root', () => {
|
|
5
|
+
const payload = buildQueueTargetPayload({
|
|
6
|
+
targetPayload: { connectionId: 'connection-id', scope: 'organization' },
|
|
7
|
+
tenantId: 'tenant-1',
|
|
8
|
+
organizationId: 'org-1',
|
|
9
|
+
idempotencyKey: 'scheduler-s1-key',
|
|
10
|
+
})
|
|
11
|
+
expect(payload).toEqual({
|
|
12
|
+
connectionId: 'connection-id',
|
|
13
|
+
scope: 'organization',
|
|
14
|
+
tenantId: 'tenant-1',
|
|
15
|
+
organizationId: 'org-1',
|
|
16
|
+
_idempotencyKey: 'scheduler-s1-key',
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('lets scheduler-owned scope and idempotency fields win over colliding targetPayload fields', () => {
|
|
21
|
+
const payload = buildQueueTargetPayload({
|
|
22
|
+
targetPayload: {
|
|
23
|
+
tenantId: 'spoofed-tenant',
|
|
24
|
+
organizationId: 'spoofed-org',
|
|
25
|
+
_idempotencyKey: 'spoofed-key',
|
|
26
|
+
},
|
|
27
|
+
tenantId: 'tenant-1',
|
|
28
|
+
organizationId: 'org-1',
|
|
29
|
+
idempotencyKey: 'scheduler-s1-key',
|
|
30
|
+
})
|
|
31
|
+
expect(payload.tenantId).toBe('tenant-1')
|
|
32
|
+
expect(payload.organizationId).toBe('org-1')
|
|
33
|
+
expect(payload._idempotencyKey).toBe('scheduler-s1-key')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('handles empty and non-object targetPayload values', () => {
|
|
37
|
+
for (const targetPayload of [null, undefined, {}, 'text', 42, ['array']]) {
|
|
38
|
+
const payload = buildQueueTargetPayload({
|
|
39
|
+
targetPayload,
|
|
40
|
+
tenantId: null,
|
|
41
|
+
organizationId: null,
|
|
42
|
+
idempotencyKey: 'scheduler-s1-key',
|
|
43
|
+
})
|
|
44
|
+
expect(payload).toEqual({
|
|
45
|
+
tenantId: null,
|
|
46
|
+
organizationId: null,
|
|
47
|
+
_idempotencyKey: 'scheduler-s1-key',
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('preserves a literal application field named payload on the root', () => {
|
|
53
|
+
const payload = buildQueueTargetPayload({
|
|
54
|
+
targetPayload: { payload: { nested: true }, other: 1 },
|
|
55
|
+
tenantId: 'tenant-1',
|
|
56
|
+
organizationId: null,
|
|
57
|
+
idempotencyKey: 'scheduler-s1-key',
|
|
58
|
+
})
|
|
59
|
+
expect(payload.payload).toEqual({ nested: true })
|
|
60
|
+
expect(payload.other).toBe(1)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('does not mutate the configured targetPayload', () => {
|
|
64
|
+
const configured = { connectionId: 'connection-id' }
|
|
65
|
+
buildQueueTargetPayload({
|
|
66
|
+
targetPayload: configured,
|
|
67
|
+
tenantId: 'tenant-1',
|
|
68
|
+
organizationId: 'org-1',
|
|
69
|
+
idempotencyKey: 'scheduler-s1-key',
|
|
70
|
+
})
|
|
71
|
+
expect(configured).toEqual({ connectionId: 'connection-id' })
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('buildSchedulerIdempotencyKey', () => {
|
|
76
|
+
it('is deterministic for one logical execution key', () => {
|
|
77
|
+
expect(buildSchedulerIdempotencyKey('s1', 'job-1')).toBe('scheduler-s1-job-1')
|
|
78
|
+
expect(buildSchedulerIdempotencyKey('s1', 'job-1')).toBe(buildSchedulerIdempotencyKey('s1', 'job-1'))
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('differs across schedules and executions', () => {
|
|
82
|
+
expect(buildSchedulerIdempotencyKey('s1', 'job-1')).not.toBe(buildSchedulerIdempotencyKey('s2', 'job-1'))
|
|
83
|
+
expect(buildSchedulerIdempotencyKey('s1', 'job-1')).not.toBe(buildSchedulerIdempotencyKey('s1', 'job-2'))
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type QueueTargetPayloadInput = {
|
|
2
|
+
targetPayload: unknown
|
|
3
|
+
tenantId: string | null | undefined
|
|
4
|
+
organizationId: string | null | undefined
|
|
5
|
+
idempotencyKey: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Single source of truth for the payload a scheduled queue target receives.
|
|
10
|
+
* Both the local scheduler and the asynchronous execute-schedule worker MUST
|
|
11
|
+
* build their enqueue payload here so workers see one flat contract:
|
|
12
|
+
* the configured targetPayload fields on the root, with scheduler-owned
|
|
13
|
+
* tenantId/organizationId/_idempotencyKey applied last so they always win
|
|
14
|
+
* over conflicting targetPayload fields.
|
|
15
|
+
*/
|
|
16
|
+
export function buildQueueTargetPayload(input: QueueTargetPayloadInput): Record<string, unknown> {
|
|
17
|
+
const base =
|
|
18
|
+
input.targetPayload && typeof input.targetPayload === 'object' && !Array.isArray(input.targetPayload)
|
|
19
|
+
? { ...(input.targetPayload as Record<string, unknown>) }
|
|
20
|
+
: {}
|
|
21
|
+
return {
|
|
22
|
+
...base,
|
|
23
|
+
tenantId: input.tenantId ?? null,
|
|
24
|
+
organizationId: input.organizationId ?? null,
|
|
25
|
+
_idempotencyKey: input.idempotencyKey,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One logical scheduled firing must keep one idempotency key across worker
|
|
31
|
+
* retries, so downstream consumers can deduplicate. Pass a retry-stable
|
|
32
|
+
* execution key: the execute-schedule job id in async mode, or the firing
|
|
33
|
+
* timestamp in local mode (which runs each firing exactly once).
|
|
34
|
+
*/
|
|
35
|
+
export function buildSchedulerIdempotencyKey(scheduleId: string, executionKey: string | number): string {
|
|
36
|
+
return `scheduler-${scheduleId}-${executionKey}`
|
|
37
|
+
}
|
|
@@ -271,9 +271,9 @@ describe('LocalSchedulerService', () => {
|
|
|
271
271
|
|
|
272
272
|
expect(mockQueue.enqueue).toHaveBeenCalledWith(
|
|
273
273
|
expect.objectContaining({
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
274
|
+
tenantId: null,
|
|
275
|
+
organizationId: null,
|
|
276
|
+
_idempotencyKey: expect.stringMatching(/^scheduler-test-1-/),
|
|
277
277
|
})
|
|
278
278
|
)
|
|
279
279
|
})
|
|
@@ -548,9 +548,9 @@ describe('LocalSchedulerService', () => {
|
|
|
548
548
|
)
|
|
549
549
|
})
|
|
550
550
|
|
|
551
|
-
it('should
|
|
551
|
+
it('should deliver the flat targetPayload contract to the queue job', async () => {
|
|
552
552
|
const schedule = createSchedule({
|
|
553
|
-
targetPayload: { foo: 'bar', baz: 123 },
|
|
553
|
+
targetPayload: { foo: 'bar', baz: 123, tenantId: 'spoofed-tenant' },
|
|
554
554
|
})
|
|
555
555
|
|
|
556
556
|
mockForkedEm.find.mockResolvedValue([schedule])
|
|
@@ -565,9 +565,17 @@ describe('LocalSchedulerService', () => {
|
|
|
565
565
|
|
|
566
566
|
expect(mockQueue.enqueue).toHaveBeenCalledWith(
|
|
567
567
|
expect.objectContaining({
|
|
568
|
-
|
|
568
|
+
foo: 'bar',
|
|
569
|
+
baz: 123,
|
|
570
|
+
tenantId: schedule.tenantId,
|
|
571
|
+
organizationId: schedule.organizationId,
|
|
572
|
+
_idempotencyKey: expect.stringMatching(new RegExp(`^scheduler-${schedule.id}-`)),
|
|
569
573
|
})
|
|
570
574
|
)
|
|
575
|
+
const enqueued = mockQueue.enqueue.mock.calls[0][0] as Record<string, unknown>
|
|
576
|
+
expect(enqueued).not.toHaveProperty('payload')
|
|
577
|
+
expect(enqueued).not.toHaveProperty('scheduleId')
|
|
578
|
+
expect(enqueued).not.toHaveProperty('scheduleName')
|
|
571
579
|
})
|
|
572
580
|
|
|
573
581
|
it('should pass scope to command input', async () => {
|
|
@@ -8,6 +8,7 @@ import { recalculateNextRun } from '../lib/nextRunCalculator'
|
|
|
8
8
|
import { emitSchedulerEvent } from '../events.js'
|
|
9
9
|
import { getGlobalEventBus } from '@open-mercato/shared/modules/events'
|
|
10
10
|
import { buildScheduledCommandContext } from '../lib/commandContext.js'
|
|
11
|
+
import { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from '../lib/queueTargetPayload.js'
|
|
11
12
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
12
13
|
|
|
13
14
|
const logger = createLogger('scheduler').child({ component: 'local' })
|
|
@@ -251,16 +252,17 @@ export class LocalSchedulerService {
|
|
|
251
252
|
}
|
|
252
253
|
|
|
253
254
|
const queue = this.queueFactory(schedule.targetQueue)
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
255
|
+
|
|
256
|
+
// Deliver the same flat contract as the asynchronous execute-schedule
|
|
257
|
+
// worker: targetPayload fields on the root, scheduler-owned scope and
|
|
258
|
+
// idempotency fields applied last. Local mode runs each firing exactly
|
|
259
|
+
// once, so the firing timestamp is a valid logical execution key.
|
|
260
|
+
await queue.enqueue(buildQueueTargetPayload({
|
|
261
|
+
targetPayload: schedule.targetPayload,
|
|
259
262
|
tenantId: schedule.tenantId,
|
|
260
263
|
organizationId: schedule.organizationId,
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
})
|
|
264
|
+
idempotencyKey: buildSchedulerIdempotencyKey(schedule.id, Date.now()),
|
|
265
|
+
}))
|
|
264
266
|
|
|
265
267
|
logger.info('Enqueued job to target queue', { scheduleId: schedule.id, targetQueue: schedule.targetQueue })
|
|
266
268
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
2
2
|
import { ensureTenantScope } from '@open-mercato/shared/lib/commands/scope'
|
|
3
|
+
import { createQueue } from '@open-mercato/queue'
|
|
3
4
|
import { ScheduledJob } from '../../data/entities'
|
|
4
5
|
import executeScheduleWorker from '../execute-schedule.worker'
|
|
5
6
|
|
|
@@ -15,6 +16,10 @@ jest.mock('@open-mercato/queue', () => ({
|
|
|
15
16
|
createQueue: jest.fn(),
|
|
16
17
|
}), { virtual: true })
|
|
17
18
|
|
|
19
|
+
jest.mock('@open-mercato/shared/lib/redis/connection', () => ({
|
|
20
|
+
getRedisUrlOrThrow: jest.fn(() => 'redis://localhost:6379'),
|
|
21
|
+
}))
|
|
22
|
+
|
|
18
23
|
jest.mock('../../events', () => ({
|
|
19
24
|
emitSchedulerEvent: jest.fn(async () => undefined),
|
|
20
25
|
}))
|
|
@@ -100,3 +105,76 @@ describe('executeScheduleWorker command scope', () => {
|
|
|
100
105
|
expect(em.flush).not.toHaveBeenCalled()
|
|
101
106
|
})
|
|
102
107
|
})
|
|
108
|
+
|
|
109
|
+
describe('executeScheduleWorker queue target payload contract', () => {
|
|
110
|
+
const enqueue = jest.fn(async () => 'target-job-1')
|
|
111
|
+
const close = jest.fn(async () => undefined)
|
|
112
|
+
|
|
113
|
+
function buildQueueSchedule(overrides: Partial<ScheduledJob> = {}): ScheduledJob {
|
|
114
|
+
return buildCommandSchedule({
|
|
115
|
+
targetType: 'queue',
|
|
116
|
+
targetQueue: 'example',
|
|
117
|
+
targetCommand: null,
|
|
118
|
+
targetPayload: { connectionId: 'connection-id', scope: 'organization' },
|
|
119
|
+
...overrides,
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function runWorker(schedule: ScheduledJob, jobId = 'worker-job-1', attemptNumber = 1) {
|
|
124
|
+
const { context } = buildWorkerContext(schedule)
|
|
125
|
+
return executeScheduleWorker(
|
|
126
|
+
{
|
|
127
|
+
id: 'queued-job-1',
|
|
128
|
+
queue: 'scheduler-execution',
|
|
129
|
+
payload: {
|
|
130
|
+
scheduleId,
|
|
131
|
+
tenantId: schedule.tenantId,
|
|
132
|
+
organizationId: schedule.organizationId,
|
|
133
|
+
scopeType: schedule.scopeType,
|
|
134
|
+
},
|
|
135
|
+
attempts: 0,
|
|
136
|
+
createdAt: Date.now(),
|
|
137
|
+
},
|
|
138
|
+
{ ...context, jobId, attemptNumber },
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
beforeEach(() => {
|
|
143
|
+
enqueue.mockClear()
|
|
144
|
+
close.mockClear()
|
|
145
|
+
;(createQueue as jest.Mock).mockReturnValue({ enqueue, close })
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('delivers the flat targetPayload contract with scheduler-owned fields applied last', async () => {
|
|
149
|
+
const schedule = buildQueueSchedule({
|
|
150
|
+
targetPayload: {
|
|
151
|
+
connectionId: 'connection-id',
|
|
152
|
+
scope: 'organization',
|
|
153
|
+
tenantId: 'spoofed-tenant',
|
|
154
|
+
payload: { nested: true },
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
await runWorker(schedule)
|
|
159
|
+
|
|
160
|
+
expect(enqueue).toHaveBeenCalledWith({
|
|
161
|
+
connectionId: 'connection-id',
|
|
162
|
+
scope: 'organization',
|
|
163
|
+
payload: { nested: true },
|
|
164
|
+
tenantId: 'tenant-a',
|
|
165
|
+
organizationId: 'org-a',
|
|
166
|
+
_idempotencyKey: `scheduler-${scheduleId}-worker-job-1`,
|
|
167
|
+
})
|
|
168
|
+
expect(close).toHaveBeenCalledTimes(1)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('keeps one idempotency key across retries of the same logical firing', async () => {
|
|
172
|
+
await runWorker(buildQueueSchedule(), 'worker-job-1', 1)
|
|
173
|
+
await runWorker(buildQueueSchedule(), 'worker-job-1', 2)
|
|
174
|
+
|
|
175
|
+
const firstKey = (enqueue.mock.calls[0][0] as Record<string, unknown>)._idempotencyKey
|
|
176
|
+
const secondKey = (enqueue.mock.calls[1][0] as Record<string, unknown>)._idempotencyKey
|
|
177
|
+
expect(firstKey).toBe(`scheduler-${scheduleId}-worker-job-1`)
|
|
178
|
+
expect(secondKey).toBe(firstKey)
|
|
179
|
+
})
|
|
180
|
+
})
|
|
@@ -7,6 +7,7 @@ import { CommandBus } from '@open-mercato/shared/lib/commands'
|
|
|
7
7
|
import type { AppContainer } from '@open-mercato/shared/lib/di/container'
|
|
8
8
|
import { emitSchedulerEvent } from '../events.js'
|
|
9
9
|
import { buildScheduledCommandContext } from '../lib/commandContext.js'
|
|
10
|
+
import { buildQueueTargetPayload, buildSchedulerIdempotencyKey } from '../lib/queueTargetPayload.js'
|
|
10
11
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
11
12
|
|
|
12
13
|
const logger = createLogger('scheduler').child({ component: 'worker' })
|
|
@@ -168,20 +169,17 @@ export default async function executeScheduleWorker(
|
|
|
168
169
|
|
|
169
170
|
let targetJobId: string | undefined
|
|
170
171
|
try {
|
|
171
|
-
//
|
|
172
|
-
// this worker
|
|
173
|
-
//
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
...((schedule.targetPayload as Record<string, unknown>) || {}),
|
|
172
|
+
// The execute-schedule job id is stable across BullMQ retries, so if
|
|
173
|
+
// this worker crashes between enqueue and DB flush the retried attempt
|
|
174
|
+
// reuses the same idempotency key and downstream workers can dedupe.
|
|
175
|
+
const idempotencyKey = buildSchedulerIdempotencyKey(schedule.id, ctx.jobId ?? Date.now())
|
|
176
|
+
|
|
177
|
+
targetJobId = await targetQueue.enqueue(buildQueueTargetPayload({
|
|
178
|
+
targetPayload: schedule.targetPayload,
|
|
179
179
|
tenantId: schedule.tenantId,
|
|
180
180
|
organizationId: schedule.organizationId,
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
targetJobId = await targetQueue.enqueue(queuePayload)
|
|
181
|
+
idempotencyKey,
|
|
182
|
+
}))
|
|
185
183
|
} finally {
|
|
186
184
|
// Always close the queue instance to free Redis connections
|
|
187
185
|
await targetQueue.close()
|