@open-mercato/cli 0.6.6-develop.5509.1.006f4d4f24 → 0.6.6-develop.5531.1.ab1959dfae

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/cli",
3
- "version": "0.6.6-develop.5509.1.006f4d4f24",
3
+ "version": "0.6.6-develop.5531.1.ab1959dfae",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -59,8 +59,8 @@
59
59
  "@mikro-orm/decorators": "^7.1.4",
60
60
  "@mikro-orm/migrations": "^7.1.4",
61
61
  "@mikro-orm/postgresql": "^7.1.4",
62
- "@open-mercato/queue": "0.6.6-develop.5509.1.006f4d4f24",
63
- "@open-mercato/shared": "0.6.6-develop.5509.1.006f4d4f24",
62
+ "@open-mercato/queue": "0.6.6-develop.5531.1.ab1959dfae",
63
+ "@open-mercato/shared": "0.6.6-develop.5531.1.ab1959dfae",
64
64
  "cross-spawn": "^7.0.6",
65
65
  "pg": "8.21.0",
66
66
  "semver": "^7.8.3",
@@ -70,10 +70,10 @@
70
70
  "typescript": "^6.0.3"
71
71
  },
72
72
  "peerDependencies": {
73
- "@open-mercato/shared": "0.6.6-develop.5509.1.006f4d4f24"
73
+ "@open-mercato/shared": "0.6.6-develop.5531.1.ab1959dfae"
74
74
  },
75
75
  "devDependencies": {
76
- "@open-mercato/shared": "0.6.6-develop.5509.1.006f4d4f24",
76
+ "@open-mercato/shared": "0.6.6-develop.5531.1.ab1959dfae",
77
77
  "@types/jest": "^30.0.0",
78
78
  "jest": "^30.4.2",
79
79
  "ts-jest": "^29.4.11"
@@ -0,0 +1,138 @@
1
+ import {
2
+ assertSingleInstanceStrategies,
3
+ evaluateSingleInstanceGuard,
4
+ SingleInstanceStrategyError,
5
+ type InfraStrategySnapshot,
6
+ } from '../single-instance-strategy-guard'
7
+
8
+ const singleInstanceSnapshot: InfraStrategySnapshot = {
9
+ cacheStrategy: 'memory',
10
+ queueStrategy: 'local',
11
+ rateLimitStrategy: 'memory',
12
+ }
13
+
14
+ const multiInstanceSafeSnapshot: InfraStrategySnapshot = {
15
+ cacheStrategy: 'redis',
16
+ queueStrategy: 'async',
17
+ rateLimitStrategy: 'redis',
18
+ }
19
+
20
+ describe('evaluateSingleInstanceGuard', () => {
21
+ it('is a no-op outside production even with single-instance strategies', () => {
22
+ const result = evaluateSingleInstanceGuard(singleInstanceSnapshot, {
23
+ NODE_ENV: 'development',
24
+ OM_MULTI_INSTANCE: '1',
25
+ })
26
+ expect(result.action).toBe('ok')
27
+ expect(result.offenders).toHaveLength(3)
28
+ })
29
+
30
+ it('is a no-op in production when all strategies are multi-instance safe', () => {
31
+ const result = evaluateSingleInstanceGuard(multiInstanceSafeSnapshot, {
32
+ NODE_ENV: 'production',
33
+ OM_MULTI_INSTANCE: '1',
34
+ })
35
+ expect(result.action).toBe('ok')
36
+ expect(result.offenders).toHaveLength(0)
37
+ })
38
+
39
+ it('fails in production when a multi-instance topology is declared via OM_MULTI_INSTANCE', () => {
40
+ const result = evaluateSingleInstanceGuard(singleInstanceSnapshot, {
41
+ NODE_ENV: 'production',
42
+ OM_MULTI_INSTANCE: '1',
43
+ })
44
+ expect(result.action).toBe('fail')
45
+ expect(result.offenders.map((offender) => offender.envVar)).toEqual([
46
+ 'CACHE_STRATEGY',
47
+ 'QUEUE_STRATEGY',
48
+ 'RATE_LIMIT_STRATEGY',
49
+ ])
50
+ })
51
+
52
+ it('treats OM_INSTANCE_COUNT > 1 as a multi-instance topology', () => {
53
+ const result = evaluateSingleInstanceGuard(singleInstanceSnapshot, {
54
+ NODE_ENV: 'production',
55
+ OM_INSTANCE_COUNT: '3',
56
+ })
57
+ expect(result.action).toBe('fail')
58
+ })
59
+
60
+ it('does not treat OM_INSTANCE_COUNT=1 as multi-instance', () => {
61
+ const result = evaluateSingleInstanceGuard(singleInstanceSnapshot, {
62
+ NODE_ENV: 'production',
63
+ OM_INSTANCE_COUNT: '1',
64
+ })
65
+ expect(result.action).toBe('warn')
66
+ })
67
+
68
+ it('only warns in production when no multi-instance topology is declared', () => {
69
+ const result = evaluateSingleInstanceGuard(singleInstanceSnapshot, {
70
+ NODE_ENV: 'production',
71
+ })
72
+ expect(result.action).toBe('warn')
73
+ expect(result.multiInstance).toBe(false)
74
+ })
75
+
76
+ it('downgrades a hard failure to a warning when the override is set', () => {
77
+ const result = evaluateSingleInstanceGuard(singleInstanceSnapshot, {
78
+ NODE_ENV: 'production',
79
+ OM_MULTI_INSTANCE: '1',
80
+ OM_ALLOW_SINGLE_INSTANCE_STRATEGIES: '1',
81
+ })
82
+ expect(result.action).toBe('warn')
83
+ expect(result.overridden).toBe(true)
84
+ })
85
+
86
+ it('flags only the offending strategy when others are safe', () => {
87
+ const result = evaluateSingleInstanceGuard(
88
+ { cacheStrategy: 'redis', queueStrategy: 'local', rateLimitStrategy: 'redis' },
89
+ { NODE_ENV: 'production', OM_MULTI_INSTANCE: '1' },
90
+ )
91
+ expect(result.action).toBe('fail')
92
+ expect(result.offenders).toHaveLength(1)
93
+ expect(result.offenders[0].component).toBe('queue')
94
+ })
95
+ })
96
+
97
+ describe('assertSingleInstanceStrategies', () => {
98
+ function createLogger() {
99
+ return {
100
+ warn: jest.fn(),
101
+ error: jest.fn(),
102
+ }
103
+ }
104
+
105
+ it('throws and logs when the guard fails', () => {
106
+ const logger = createLogger()
107
+ expect(() =>
108
+ assertSingleInstanceStrategies(
109
+ { NODE_ENV: 'production', OM_MULTI_INSTANCE: '1' },
110
+ { snapshot: singleInstanceSnapshot, logger },
111
+ ),
112
+ ).toThrow(SingleInstanceStrategyError)
113
+ expect(logger.error).toHaveBeenCalled()
114
+ expect(logger.warn).not.toHaveBeenCalled()
115
+ })
116
+
117
+ it('logs a warning but does not throw when only warning', () => {
118
+ const logger = createLogger()
119
+ const result = assertSingleInstanceStrategies(
120
+ { NODE_ENV: 'production' },
121
+ { snapshot: singleInstanceSnapshot, logger },
122
+ )
123
+ expect(result.action).toBe('warn')
124
+ expect(logger.warn).toHaveBeenCalled()
125
+ expect(logger.error).not.toHaveBeenCalled()
126
+ })
127
+
128
+ it('stays silent and does not throw when everything is safe', () => {
129
+ const logger = createLogger()
130
+ const result = assertSingleInstanceStrategies(
131
+ { NODE_ENV: 'production', OM_MULTI_INSTANCE: '1' },
132
+ { snapshot: multiInstanceSafeSnapshot, logger },
133
+ )
134
+ expect(result.action).toBe('ok')
135
+ expect(logger.warn).not.toHaveBeenCalled()
136
+ expect(logger.error).not.toHaveBeenCalled()
137
+ })
138
+ })
@@ -0,0 +1,135 @@
1
+ import type { ModuleWorker } from '@open-mercato/shared/modules/registry'
2
+ import { createPerJobWorkerHandler, type WorkerJobContainer } from '../worker-job-handler'
3
+
4
+ type FakeContainer = WorkerJobContainer & {
5
+ id: number
6
+ em: { clear: jest.Mock }
7
+ }
8
+
9
+ function makeContainerFactory() {
10
+ const containers: FakeContainer[] = []
11
+ let nextId = 0
12
+ const factory = jest.fn(async (): Promise<WorkerJobContainer> => {
13
+ const em = { clear: jest.fn() }
14
+ const container: FakeContainer = {
15
+ id: nextId++,
16
+ em,
17
+ resolve: <T = unknown>(name: string): T => {
18
+ if (name === 'em') return em as unknown as T
19
+ return undefined as unknown as T
20
+ },
21
+ }
22
+ containers.push(container)
23
+ return container
24
+ })
25
+ return { factory, containers }
26
+ }
27
+
28
+ function makeWorker(id: string, handler: ModuleWorker['handler']): ModuleWorker {
29
+ return { id, queue: 'test', concurrency: 1, handler }
30
+ }
31
+
32
+ const baseCtx = { jobId: 'job-1', attemptNumber: 1, queueName: 'test' }
33
+
34
+ describe('createPerJobWorkerHandler', () => {
35
+ it('creates a fresh container for every job invocation', async () => {
36
+ const { factory } = makeContainerFactory()
37
+ const worker = makeWorker('w', jest.fn())
38
+ const handler = createPerJobWorkerHandler([worker], factory)
39
+
40
+ await handler({ id: 'a' }, { ...baseCtx, jobId: 'a' })
41
+ await handler({ id: 'b' }, { ...baseCtx, jobId: 'b' })
42
+
43
+ expect(factory).toHaveBeenCalledTimes(2)
44
+ })
45
+
46
+ it('passes each worker a resolve bound to that job container', async () => {
47
+ const { factory, containers } = makeContainerFactory()
48
+ const seenEms: unknown[] = []
49
+ const worker = makeWorker('w', (_job, ctx) => {
50
+ const resolve = (ctx as { resolve: (name: string) => unknown }).resolve
51
+ seenEms.push(resolve('em'))
52
+ })
53
+ const handler = createPerJobWorkerHandler([worker], factory)
54
+
55
+ await handler({ id: 'a' }, { ...baseCtx, jobId: 'a' })
56
+ await handler({ id: 'b' }, { ...baseCtx, jobId: 'b' })
57
+
58
+ expect(seenEms).toHaveLength(2)
59
+ expect(seenEms[0]).toBe(containers[0].em)
60
+ expect(seenEms[1]).toBe(containers[1].em)
61
+ expect(seenEms[0]).not.toBe(seenEms[1])
62
+ })
63
+
64
+ it('isolates concurrent jobs in distinct containers (no shared EntityManager)', async () => {
65
+ const { factory, containers } = makeContainerFactory()
66
+ let release: (() => void) | null = null
67
+ const gate = new Promise<void>((resolve) => {
68
+ release = resolve
69
+ })
70
+ const resolvedEms: unknown[] = []
71
+ let firstEntered = false
72
+ const worker = makeWorker('w', async (_job, ctx) => {
73
+ const resolve = (ctx as { resolve: (name: string) => unknown }).resolve
74
+ resolvedEms.push(resolve('em'))
75
+ if (!firstEntered) {
76
+ firstEntered = true
77
+ // Hold the first job open so the second job starts concurrently.
78
+ await gate
79
+ }
80
+ })
81
+ const handler = createPerJobWorkerHandler([worker], factory)
82
+
83
+ const first = handler({ id: 'a' }, { ...baseCtx, jobId: 'a' })
84
+ const second = handler({ id: 'b' }, { ...baseCtx, jobId: 'b' })
85
+ release?.()
86
+ await Promise.all([first, second])
87
+
88
+ expect(factory).toHaveBeenCalledTimes(2)
89
+ expect(resolvedEms[0]).toBe(containers[0].em)
90
+ expect(resolvedEms[1]).toBe(containers[1].em)
91
+ expect(resolvedEms[0]).not.toBe(resolvedEms[1])
92
+ })
93
+
94
+ it('runs every worker for the queue against the same per-job container', async () => {
95
+ const { factory, containers } = makeContainerFactory()
96
+ const seen: unknown[] = []
97
+ const record = (_job: unknown, ctx: unknown) => {
98
+ seen.push((ctx as { resolve: (name: string) => unknown }).resolve('em'))
99
+ }
100
+ const handler = createPerJobWorkerHandler(
101
+ [makeWorker('w1', record), makeWorker('w2', record)],
102
+ factory,
103
+ )
104
+
105
+ await handler({ id: 'a' }, baseCtx)
106
+
107
+ expect(factory).toHaveBeenCalledTimes(1)
108
+ expect(seen).toHaveLength(2)
109
+ expect(seen[0]).toBe(containers[0].em)
110
+ expect(seen[1]).toBe(containers[0].em)
111
+ })
112
+
113
+ it('clears the job container EntityManager after the job completes', async () => {
114
+ const { factory, containers } = makeContainerFactory()
115
+ const handler = createPerJobWorkerHandler([makeWorker('w', jest.fn())], factory)
116
+
117
+ await handler({ id: 'a' }, baseCtx)
118
+
119
+ expect(containers[0].em.clear).toHaveBeenCalledTimes(1)
120
+ })
121
+
122
+ it('clears the EntityManager and rethrows when a worker fails', async () => {
123
+ const { factory, containers } = makeContainerFactory()
124
+ const boom = new Error('worker failed')
125
+ const handler = createPerJobWorkerHandler(
126
+ [makeWorker('w', () => {
127
+ throw boom
128
+ })],
129
+ factory,
130
+ )
131
+
132
+ await expect(handler({ id: 'a' }, baseCtx)).rejects.toBe(boom)
133
+ expect(containers[0].em.clear).toHaveBeenCalledTimes(1)
134
+ })
135
+ })
@@ -0,0 +1,172 @@
1
+ import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
2
+ import { resolveQueueStrategy } from '@open-mercato/queue'
3
+
4
+ /**
5
+ * Boot-time guard that refuses to start (or warns) when an infrastructure
6
+ * strategy that is only safe for a single process/instance is configured under
7
+ * a declared multi-instance topology.
8
+ *
9
+ * Background: `CACHE_STRATEGY`, `RATE_LIMIT_STRATEGY` and `QUEUE_STRATEGY` all
10
+ * default to single-instance modes (`memory`/`memory`/`local`). Running more
11
+ * than one app instance against those defaults silently breaks three
12
+ * correctness invariants at once — stale RBAC caches after a privilege
13
+ * revocation, rate limits multiplied by the instance count, and duplicate job
14
+ * processing from the leaderless local file queue. None of them warn.
15
+ *
16
+ * Only strategies that coordinate across processes via shared external state
17
+ * are considered multi-instance safe. Everything else (the in-process memory
18
+ * strategies, the local file queue, and disk-backed caches that are not shared
19
+ * across hosts) is treated as single-instance.
20
+ */
21
+ const MULTI_INSTANCE_SAFE_STRATEGIES = {
22
+ cache: ['redis'],
23
+ queue: ['async'],
24
+ rateLimit: ['redis'],
25
+ } as const
26
+
27
+ export type SingleInstanceGuardComponent = 'cache' | 'queue' | 'rateLimit'
28
+
29
+ export type SingleInstanceGuardOffender = {
30
+ component: SingleInstanceGuardComponent
31
+ envVar: string
32
+ configured: string
33
+ safeValues: readonly string[]
34
+ }
35
+
36
+ export type SingleInstanceGuardAction = 'ok' | 'warn' | 'fail'
37
+
38
+ export type SingleInstanceGuardResult = {
39
+ action: SingleInstanceGuardAction
40
+ offenders: SingleInstanceGuardOffender[]
41
+ production: boolean
42
+ multiInstance: boolean
43
+ overridden: boolean
44
+ }
45
+
46
+ export type InfraStrategySnapshot = {
47
+ cacheStrategy: string
48
+ queueStrategy: string
49
+ rateLimitStrategy: string
50
+ }
51
+
52
+ const COMPONENT_ENV_VARS: Record<SingleInstanceGuardComponent, string> = {
53
+ cache: 'CACHE_STRATEGY',
54
+ queue: 'QUEUE_STRATEGY',
55
+ rateLimit: 'RATE_LIMIT_STRATEGY',
56
+ }
57
+
58
+ export class SingleInstanceStrategyError extends Error {
59
+ readonly offenders: SingleInstanceGuardOffender[]
60
+
61
+ constructor(result: SingleInstanceGuardResult) {
62
+ super(
63
+ `Refusing to start: single-instance infrastructure strategies (${result.offenders
64
+ .map((offender) => `${offender.envVar}=${offender.configured}`)
65
+ .join(', ')}) are configured under a declared multi-instance topology. ` +
66
+ 'Switch to a multi-instance-safe strategy or set OM_ALLOW_SINGLE_INSTANCE_STRATEGIES=1 to override.',
67
+ )
68
+ this.name = 'SingleInstanceStrategyError'
69
+ this.offenders = result.offenders
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Read the configured infrastructure strategies. Queue resolution reuses the
75
+ * canonical `resolveQueueStrategy()` so the default stays single-sourced; cache
76
+ * and rate-limit values are read directly with their documented defaults
77
+ * (`packages/cache/src/service.ts`, `packages/shared/src/lib/ratelimit/config.ts`)
78
+ * because the guard's safe-set policy — not the resolver default — decides what
79
+ * counts as single-instance.
80
+ */
81
+ export function readInfraStrategySnapshot(env: NodeJS.ProcessEnv = process.env): InfraStrategySnapshot {
82
+ return {
83
+ cacheStrategy: env.CACHE_STRATEGY?.trim() || 'memory',
84
+ queueStrategy: resolveQueueStrategy(),
85
+ rateLimitStrategy: env.RATE_LIMIT_STRATEGY?.trim() || 'memory',
86
+ }
87
+ }
88
+
89
+ function resolveMultiInstanceHint(env: NodeJS.ProcessEnv): boolean {
90
+ if (parseBooleanWithDefault(env.OM_MULTI_INSTANCE, false)) return true
91
+ const instanceCount = Number.parseInt(env.OM_INSTANCE_COUNT ?? '', 10)
92
+ return Number.isFinite(instanceCount) && instanceCount > 1
93
+ }
94
+
95
+ export function evaluateSingleInstanceGuard(
96
+ snapshot: InfraStrategySnapshot,
97
+ env: NodeJS.ProcessEnv = process.env,
98
+ ): SingleInstanceGuardResult {
99
+ const configured: Record<SingleInstanceGuardComponent, string> = {
100
+ cache: snapshot.cacheStrategy,
101
+ queue: snapshot.queueStrategy,
102
+ rateLimit: snapshot.rateLimitStrategy,
103
+ }
104
+
105
+ const offenders: SingleInstanceGuardOffender[] = []
106
+ for (const component of Object.keys(configured) as SingleInstanceGuardComponent[]) {
107
+ const safeValues: readonly string[] = MULTI_INSTANCE_SAFE_STRATEGIES[component]
108
+ if (!safeValues.includes(configured[component])) {
109
+ offenders.push({
110
+ component,
111
+ envVar: COMPONENT_ENV_VARS[component],
112
+ configured: configured[component],
113
+ safeValues,
114
+ })
115
+ }
116
+ }
117
+
118
+ const production = (env.NODE_ENV ?? '').trim() === 'production'
119
+ const multiInstance = resolveMultiInstanceHint(env)
120
+ const overridden = parseBooleanWithDefault(env.OM_ALLOW_SINGLE_INSTANCE_STRATEGIES, false)
121
+
122
+ let action: SingleInstanceGuardAction = 'ok'
123
+ if (offenders.length > 0 && production) {
124
+ action = multiInstance && !overridden ? 'fail' : 'warn'
125
+ }
126
+
127
+ return { action, offenders, production, multiInstance, overridden }
128
+ }
129
+
130
+ export function formatSingleInstanceGuardMessage(result: SingleInstanceGuardResult): string[] {
131
+ const offenderLines = result.offenders.map(
132
+ (offender) =>
133
+ ` - ${offender.envVar}=${offender.configured} (multi-instance-safe: ${offender.safeValues.join(', ')})`,
134
+ )
135
+ const header =
136
+ result.action === 'fail'
137
+ ? '[server] Refusing to start: single-instance infrastructure strategies under a multi-instance topology.'
138
+ : '[server] WARNING: single-instance infrastructure strategies detected in production.'
139
+ const guidance =
140
+ result.action === 'fail'
141
+ ? '[server] Switch each strategy above to a multi-instance-safe value, or set OM_ALLOW_SINGLE_INSTANCE_STRATEGIES=1 to override (accepting duplicate jobs, stale ACLs, and weakened rate limits).'
142
+ : result.multiInstance
143
+ ? '[server] OM_ALLOW_SINGLE_INSTANCE_STRATEGIES=1 is set — proceeding despite the risks above (duplicate jobs, stale ACLs, weakened rate limits).'
144
+ : '[server] Running multiple instances against these strategies will cause duplicate jobs, stale ACLs, and weakened rate limits. Set OM_MULTI_INSTANCE=1 to make this a hard failure once you scale out.'
145
+ return [header, ...offenderLines, guidance]
146
+ }
147
+
148
+ export type SingleInstanceGuardLogger = Pick<Console, 'warn' | 'error'>
149
+
150
+ /**
151
+ * Evaluate the guard and enforce it: throw on `fail`, log prominently on
152
+ * `warn`, and stay silent on `ok`. Safe to call on every `start`; it never
153
+ * fires for dev boots or single-instance production deployments.
154
+ */
155
+ export function assertSingleInstanceStrategies(
156
+ env: NodeJS.ProcessEnv = process.env,
157
+ options?: { snapshot?: InfraStrategySnapshot; logger?: SingleInstanceGuardLogger },
158
+ ): SingleInstanceGuardResult {
159
+ const snapshot = options?.snapshot ?? readInfraStrategySnapshot(env)
160
+ const result = evaluateSingleInstanceGuard(snapshot, env)
161
+ const logger = options?.logger ?? console
162
+
163
+ if (result.action === 'fail') {
164
+ for (const line of formatSingleInstanceGuardMessage(result)) logger.error(line)
165
+ throw new SingleInstanceStrategyError(result)
166
+ }
167
+ if (result.action === 'warn') {
168
+ for (const line of formatSingleInstanceGuardMessage(result)) logger.warn(line)
169
+ }
170
+
171
+ return result
172
+ }
@@ -0,0 +1,45 @@
1
+ import type { JobContext, JobHandler } from '@open-mercato/queue'
2
+ import type { ModuleWorker } from '@open-mercato/shared/modules/registry'
3
+
4
+ export type WorkerJobContainer = {
5
+ resolve: <T = unknown>(name: string) => T
6
+ }
7
+
8
+ export type WorkerJobContainerFactory = () => Promise<WorkerJobContainer>
9
+
10
+ type ClearableEntityManager = {
11
+ clear?: () => void
12
+ }
13
+
14
+ /**
15
+ * Builds a queue job handler that isolates every job in its own request
16
+ * container, instead of sharing a single process-wide `EntityManager` fork
17
+ * across all concurrent jobs.
18
+ *
19
+ * Under the async (BullMQ) strategy jobs run with real concurrency, so a
20
+ * shared EM would interleave unit-of-work flushes between unrelated jobs and
21
+ * never release its identity map. Creating one container per job removes both
22
+ * the cross-job flush race and the unbounded identity-map growth, while
23
+ * keeping the `ctx.resolve` contract and DI keys unchanged. See issue #2970.
24
+ */
25
+ export function createPerJobWorkerHandler(
26
+ workers: ModuleWorker[],
27
+ createContainer: WorkerJobContainerFactory,
28
+ ): JobHandler {
29
+ return async (job, ctx: JobContext) => {
30
+ const container = await createContainer()
31
+ try {
32
+ for (const worker of workers) {
33
+ await worker.handler(job, { ...ctx, resolve: container.resolve.bind(container) })
34
+ }
35
+ } finally {
36
+ try {
37
+ const em = container.resolve('em') as ClearableEntityManager | null
38
+ em?.clear?.()
39
+ } catch {
40
+ // Best-effort: clearing the identity map is a memory optimization and
41
+ // must never mask a job's own outcome.
42
+ }
43
+ }
44
+ }
45
+ }
package/src/mercato.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  resolveLazyRestart,
16
16
  } from './lib/auto-spawn-workers'
17
17
  import { startLazyWorkerSupervisor } from './lib/queue-worker-supervisor'
18
+ import { createPerJobWorkerHandler } from './lib/worker-job-handler'
18
19
  import {
19
20
  resolveAutoSpawnSchedulerMode,
20
21
  resolveLazySchedulerPollMs,
@@ -32,6 +33,7 @@ import {
32
33
  import { parseModuleInstallArgs } from './lib/module-install-args'
33
34
  import { resolveNextBuildIdCandidate } from './lib/next-build-id'
34
35
  import { acquireServerStartLock } from './lib/server-start-lock'
36
+ import { assertSingleInstanceStrategies } from './lib/single-instance-strategy-guard'
35
37
  import { createDevEnvReloader, watchDevEnvFiles } from './lib/dev-env-reload'
36
38
  // Lazy-imported to avoid pulling in `testcontainers` (devDependency) at startup
37
39
  const lazyIntegration = () => import('./lib/testing/integration')
@@ -1518,7 +1520,6 @@ export async function run(argv = process.argv) {
1518
1520
  }
1519
1521
 
1520
1522
  const { createRequestContainer } = await import('@open-mercato/shared/lib/di/container')
1521
- const container = await createRequestContainer()
1522
1523
  console.log(`[worker] Starting workers for all queues: ${discoveredQueues.join(', ')}`)
1523
1524
 
1524
1525
  // Start all queue workers in background mode
@@ -1534,11 +1535,7 @@ export async function run(argv = process.argv) {
1534
1535
  connection: queueRedisUrl ? { url: queueRedisUrl } : undefined,
1535
1536
  concurrency,
1536
1537
  background: true,
1537
- handler: async (job, ctx) => {
1538
- for (const worker of queueWorkers) {
1539
- await worker.handler(job, { ...ctx, resolve: container.resolve.bind(container) })
1540
- }
1541
- },
1538
+ handler: createPerJobWorkerHandler(queueWorkers, createRequestContainer),
1542
1539
  })
1543
1540
  })
1544
1541
 
@@ -1555,7 +1552,6 @@ export async function run(argv = process.argv) {
1555
1552
  if (queueWorkers.length > 0) {
1556
1553
  // Use discovered workers
1557
1554
  const { createRequestContainer } = await import('@open-mercato/shared/lib/di/container')
1558
- const container = await createRequestContainer()
1559
1555
  const concurrency = concurrencyOverride ?? Math.max(...queueWorkers.map((w) => w.concurrency), 1)
1560
1556
 
1561
1557
  console.log(`[worker] Found ${queueWorkers.length} worker(s) for queue "${queueName}"`)
@@ -1565,11 +1561,7 @@ export async function run(argv = process.argv) {
1565
1561
  queueName: queueName!,
1566
1562
  connection: queueRedisUrl ? { url: queueRedisUrl } : undefined,
1567
1563
  concurrency,
1568
- handler: async (job, ctx) => {
1569
- for (const worker of queueWorkers) {
1570
- await worker.handler(job, { ...ctx, resolve: container.resolve.bind(container) })
1571
- }
1572
- },
1564
+ handler: createPerJobWorkerHandler(queueWorkers, createRequestContainer),
1573
1565
  })
1574
1566
  } else {
1575
1567
  console.error(`No workers found for queue "${queueName}"`)
@@ -2154,6 +2146,9 @@ export async function run(argv = process.argv) {
2154
2146
  const autoSpawnSchedulerMode = resolveAutoSpawnSchedulerMode(process.env)
2155
2147
  const queueStrategy = process.env.QUEUE_STRATEGY || 'local'
2156
2148
  const runtimeEnv = buildServerProcessEnvironment(process.env)
2149
+ // Throws on single-instance strategies under a multi-instance topology,
2150
+ // aborting before the start lock is acquired or any process is spawned.
2151
+ assertSingleInstanceStrategies(runtimeEnv)
2157
2152
  const schedulerCommand = lookupModuleCommand(getCliModules(), 'scheduler', 'start')
2158
2153
  const serverStartLock = acquireServerStartLock(appDir, {
2159
2154
  port: runtimeEnv.PORT ?? process.env.PORT ?? null,