@open-mercato/cli 0.6.6-develop.6389.1.135a466c47 → 0.6.6-develop.6390.1.210bbbc703

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.6389.1.135a466c47",
3
+ "version": "0.6.6-develop.6390.1.210bbbc703",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -60,8 +60,8 @@
60
60
  "@mikro-orm/decorators": "^7.1.4",
61
61
  "@mikro-orm/migrations": "^7.1.4",
62
62
  "@mikro-orm/postgresql": "^7.1.4",
63
- "@open-mercato/queue": "0.6.6-develop.6389.1.135a466c47",
64
- "@open-mercato/shared": "0.6.6-develop.6389.1.135a466c47",
63
+ "@open-mercato/queue": "0.6.6-develop.6390.1.210bbbc703",
64
+ "@open-mercato/shared": "0.6.6-develop.6390.1.210bbbc703",
65
65
  "cross-spawn": "^7.0.6",
66
66
  "pg": "8.22.0",
67
67
  "semver": "^7.8.5",
@@ -71,10 +71,10 @@
71
71
  "typescript": "^6.0.3"
72
72
  },
73
73
  "peerDependencies": {
74
- "@open-mercato/shared": "0.6.6-develop.6389.1.135a466c47"
74
+ "@open-mercato/shared": "0.6.6-develop.6390.1.210bbbc703"
75
75
  },
76
76
  "devDependencies": {
77
- "@open-mercato/shared": "0.6.6-develop.6389.1.135a466c47",
77
+ "@open-mercato/shared": "0.6.6-develop.6390.1.210bbbc703",
78
78
  "@types/jest": "^30.0.0",
79
79
  "jest": "^30.4.2",
80
80
  "ts-jest": "^29.4.11"
@@ -645,6 +645,7 @@ describe('server dev managed process exits', () => {
645
645
  const originalAutoSpawnScheduler = process.env.AUTO_SPAWN_SCHEDULER
646
646
  const originalAutoSpawnWorkers = process.env.AUTO_SPAWN_WORKERS
647
647
  const originalLazy = process.env.OM_AUTO_SPAWN_WORKERS_LAZY
648
+ const originalLazyMode = process.env.OM_AUTO_SPAWN_WORKERS_LAZY_MODE
648
649
  const originalLazyScheduler = process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY
649
650
  const originalGenerateWatchMode = process.env.OM_DEV_GENERATE_WATCH_MODE
650
651
  const originalSingleDelivery = process.env.OM_EVENTS_SINGLE_DELIVERY
@@ -656,6 +657,7 @@ describe('server dev managed process exits', () => {
656
657
  process.env.AUTO_SPAWN_SCHEDULER = 'false'
657
658
  process.env.AUTO_SPAWN_WORKERS = 'true'
658
659
  delete process.env.OM_AUTO_SPAWN_WORKERS_LAZY
660
+ delete process.env.OM_AUTO_SPAWN_WORKERS_LAZY_MODE
659
661
  delete process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY
660
662
  // These tests toggle AUTO_SPAWN_WORKERS to exercise scheduler/Next exits, not
661
663
  // the events single-delivery guard. Acknowledge an external events worker so
@@ -683,6 +685,7 @@ describe('server dev managed process exits', () => {
683
685
  if (originalGenerateWatchMode === undefined) delete process.env.OM_DEV_GENERATE_WATCH_MODE
684
686
  else process.env.OM_DEV_GENERATE_WATCH_MODE = originalGenerateWatchMode
685
687
  delete process.env.OM_AUTO_SPAWN_WORKERS_LAZY
688
+ delete process.env.OM_AUTO_SPAWN_WORKERS_LAZY_MODE
686
689
  delete process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY
687
690
  })
688
691
 
@@ -691,6 +694,8 @@ describe('server dev managed process exits', () => {
691
694
  process.env.AUTO_SPAWN_WORKERS = originalAutoSpawnWorkers
692
695
  if (originalLazy === undefined) delete process.env.OM_AUTO_SPAWN_WORKERS_LAZY
693
696
  else process.env.OM_AUTO_SPAWN_WORKERS_LAZY = originalLazy
697
+ if (originalLazyMode === undefined) delete process.env.OM_AUTO_SPAWN_WORKERS_LAZY_MODE
698
+ else process.env.OM_AUTO_SPAWN_WORKERS_LAZY_MODE = originalLazyMode
694
699
  if (originalLazyScheduler === undefined) delete process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY
695
700
  else process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY = originalLazyScheduler
696
701
  if (originalSingleDelivery === undefined) delete process.env.OM_EVENTS_SINGLE_DELIVERY
@@ -793,6 +798,7 @@ describe('server dev managed process exits', () => {
793
798
  const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
794
799
  const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation()
795
800
  process.env.OM_AUTO_SPAWN_WORKERS_LAZY = 'true'
801
+ process.env.OM_AUTO_SPAWN_WORKERS_LAZY_MODE = 'shared'
796
802
 
797
803
  jest.doMock('node:fs', () => {
798
804
  const actual = jest.requireActual('node:fs')
@@ -843,8 +849,15 @@ describe('server dev managed process exits', () => {
843
849
  expect(supervisorCall.workers.map((worker) => worker.queue)).toEqual(['events'])
844
850
  expect(supervisorCall.pollMs).toBe(1000)
845
851
  expect(supervisorCall.restartOnUnexpectedExit).toBe(true)
852
+ expect(supervisorCall.spawnMode).toBe('shared')
846
853
  expect(supervisorClose).toHaveBeenCalled()
847
854
 
855
+ const lazyLogLine = consoleLogSpy.mock.calls
856
+ .map((call) => call[0])
857
+ .find((line) => typeof line === 'string' && line.startsWith('[server] Lazy worker auto-spawn enabled'))
858
+ expect(lazyLogLine).toContain('shared worker mode')
859
+ expect(lazyLogLine).toContain('OM_AUTO_SPAWN_WORKERS_LAZY_MODE=per-queue')
860
+
848
861
  const { spawn } = await import('child_process')
849
862
  const allSpawnCalls = (spawn as jest.Mock).mock.calls.map((call) => call[1] as string[])
850
863
  const queueWorkerSpawn = allSpawnCalls.find((args) => args.slice(1).join(' ') === 'queue worker --all')
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  resolveAutoSpawnWorkersMode,
3
+ resolveLazySpawnMode,
3
4
  resolveLazyPollMs,
4
5
  resolveLazyRestart,
5
6
  } from '../auto-spawn-workers'
@@ -82,6 +83,24 @@ describe('resolveLazyPollMs', () => {
82
83
  })
83
84
  })
84
85
 
86
+ describe('resolveLazySpawnMode', () => {
87
+ it('defaults to per-queue workers when unset', () => {
88
+ expect(resolveLazySpawnMode({})).toBe('per-queue')
89
+ })
90
+
91
+ it('uses a shared worker when explicitly requested', () => {
92
+ expect(resolveLazySpawnMode({ OM_AUTO_SPAWN_WORKERS_LAZY_MODE: 'shared' })).toBe('shared')
93
+ })
94
+
95
+ it('accepts the explicit per-queue mode', () => {
96
+ expect(resolveLazySpawnMode({ OM_AUTO_SPAWN_WORKERS_LAZY_MODE: 'per-queue' })).toBe('per-queue')
97
+ })
98
+
99
+ it('falls back to per-queue for invalid values', () => {
100
+ expect(resolveLazySpawnMode({ OM_AUTO_SPAWN_WORKERS_LAZY_MODE: 'maybe' })).toBe('per-queue')
101
+ })
102
+ })
103
+
85
104
  describe('resolveLazyRestart', () => {
86
105
  it('defaults to true when unset', () => {
87
106
  expect(resolveLazyRestart({})).toBe(true)
@@ -128,6 +128,48 @@ describe('startLazyWorkerSupervisor', () => {
128
128
  await handle.close()
129
129
  })
130
130
 
131
+ it('can start one shared worker for all queues when any queue has ready jobs', async () => {
132
+ const child = createFakeChild()
133
+ const spawnFn = jest.fn(() => child) as unknown as jest.MockedFunction<LazySupervisorSpawnFn>
134
+ const probeFn = jest.fn(async (queueName, strategy) => {
135
+ if (queueName === 'events' || queueName === 'emails') return readyProbe(queueName, strategy)
136
+ return emptyProbe(queueName, strategy)
137
+ }) as jest.MockedFunction<LazySupervisorProbeFn>
138
+
139
+ const handle = startLazyWorkerSupervisor({
140
+ mercatoBin: '/tmp/mercato',
141
+ appDir: '/tmp/app',
142
+ runtimeEnv: { ...process.env },
143
+ workers: [makeWorker('events'), makeWorker('emails'), makeWorker('search')],
144
+ pollMs: 250,
145
+ restartOnUnexpectedExit: false,
146
+ strategy: 'local',
147
+ spawnMode: 'shared',
148
+ spawnFn,
149
+ probeFn,
150
+ logger: silentLogger,
151
+ })
152
+
153
+ await flushAsync(20)
154
+
155
+ expect(spawnFn).toHaveBeenCalledTimes(1)
156
+ const [command, args] = spawnFn.mock.calls[0]
157
+ expect(command).toBe('node')
158
+ expect(args).toEqual(['/tmp/mercato', 'queue', 'worker', '--all'])
159
+ expect(handle.startedQueues.has('events')).toBe(true)
160
+ expect(handle.startedQueues.has('emails')).toBe(true)
161
+ expect(handle.startedQueues.has('search')).toBe(true)
162
+ expect(handle.getActiveChild('events')).toBe(child)
163
+ expect(handle.getActiveChild('emails')).toBe(child)
164
+ expect(handle.getActiveChild('search')).toBe(child)
165
+
166
+ await flushAsync(20)
167
+ expect(spawnFn).toHaveBeenCalledTimes(1)
168
+
169
+ await handle.close()
170
+ expect((child as any).kill).toHaveBeenCalledWith('SIGTERM')
171
+ })
172
+
131
173
  it('does not spawn when probe reports an error', async () => {
132
174
  const spawnFn = jest.fn() as unknown as jest.MockedFunction<LazySupervisorSpawnFn>
133
175
  const probeFn = jest.fn(async (queueName, strategy) => ({
@@ -1,6 +1,7 @@
1
1
  import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
2
2
 
3
3
  export type AutoSpawnWorkersMode = 'off' | 'eager' | 'lazy'
4
+ export type LazyWorkerSpawnMode = 'per-queue' | 'shared'
4
5
 
5
6
  export type AutoSpawnEnvSource = Pick<NodeJS.ProcessEnv, string> | Record<string, string | undefined>
6
7
 
@@ -33,6 +34,13 @@ export function resolveLazyPollMs(env: AutoSpawnEnvSource = process.env): number
33
34
  return Math.max(MIN_LAZY_POLL_MS, Math.floor(parsed))
34
35
  }
35
36
 
37
+ export function resolveLazySpawnMode(env: AutoSpawnEnvSource = process.env): LazyWorkerSpawnMode {
38
+ const raw = env.OM_AUTO_SPAWN_WORKERS_LAZY_MODE?.trim().toLowerCase()
39
+ if (raw === 'shared') return 'shared'
40
+ if (raw === 'per-queue') return 'per-queue'
41
+ return 'per-queue'
42
+ }
43
+
36
44
  export function resolveLazyRestart(env: AutoSpawnEnvSource = process.env): boolean {
37
45
  const parsed = parseBooleanToken(env.OM_AUTO_SPAWN_WORKERS_LAZY_RESTART)
38
46
  return parsed === null ? true : parsed
@@ -30,6 +30,7 @@ import {
30
30
  type QueueStrategyType,
31
31
  } from '@open-mercato/queue'
32
32
  import type { ModuleWorker } from '@open-mercato/shared/modules/registry'
33
+ import type { LazyWorkerSpawnMode } from './auto-spawn-workers'
33
34
 
34
35
  export type LazySupervisorSpawnFn = (
35
36
  command: string,
@@ -49,6 +50,7 @@ export type LazyWorkerSupervisorOptions = {
49
50
  workers: ModuleWorker[]
50
51
  pollMs: number
51
52
  restartOnUnexpectedExit: boolean
53
+ spawnMode?: LazyWorkerSpawnMode
52
54
  strategy?: QueueStrategyType
53
55
  /** Override for tests. Defaults to `child_process.spawn`. */
54
56
  spawnFn?: LazySupervisorSpawnFn
@@ -112,6 +114,7 @@ export function startLazyWorkerSupervisor(
112
114
  ): LazyWorkerSupervisorHandle {
113
115
  const logger = options.logger ?? console
114
116
  const strategy: QueueStrategyType = options.strategy ?? resolveStrategyFromEnv(options.runtimeEnv)
117
+ const spawnMode: LazyWorkerSpawnMode = options.spawnMode ?? 'per-queue'
115
118
  const probe: LazySupervisorProbeFn = options.probeFn
116
119
  ?? ((queueName) => getQueuePendingProbe(queueName, strategy))
117
120
  const groups = groupWorkersByQueue(options.workers)
@@ -119,6 +122,8 @@ export function startLazyWorkerSupervisor(
119
122
  const startedQueues = new Set<string>()
120
123
  const activeChildren = new Map<string, ChildProcess>()
121
124
  const startingQueues = new Set<string>()
125
+ let activeSharedChild: ChildProcess | undefined
126
+ let startingSharedWorker = false
122
127
  const probeErrorLastLoggedAt = new Map<string, number>()
123
128
 
124
129
  let stopping = false
@@ -143,6 +148,22 @@ export function startLazyWorkerSupervisor(
143
148
  const spawnFn: LazySupervisorSpawnFn =
144
149
  options.spawnFn ?? ((command, args, opts) => nodeSpawn(command, args as string[], opts))
145
150
 
151
+ const markSharedWorkerStarted = () => {
152
+ for (const group of groups) {
153
+ startedQueues.add(group.queueName)
154
+ activeChildren.set(group.queueName, activeSharedChild as ChildProcess)
155
+ }
156
+ }
157
+
158
+ const clearSharedWorker = () => {
159
+ for (const group of groups) {
160
+ if (activeChildren.get(group.queueName) === activeSharedChild) {
161
+ activeChildren.delete(group.queueName)
162
+ }
163
+ }
164
+ activeSharedChild = undefined
165
+ }
166
+
146
167
  function logProbeError(queueName: string, message: string): void {
147
168
  const now = Date.now()
148
169
  const lastAt = probeErrorLastLoggedAt.get(queueName) ?? 0
@@ -215,6 +236,81 @@ export function startLazyWorkerSupervisor(
215
236
  })
216
237
  }
217
238
 
239
+ function spawnSharedWorker(triggerQueueNames: string[]): void {
240
+ if (stopping) return
241
+ if (activeSharedChild) return
242
+ if (startingSharedWorker) return
243
+ startingSharedWorker = true
244
+
245
+ const triggerSummary = triggerQueueNames.length > 0
246
+ ? ` after pending job(s) on ${triggerQueueNames.join(', ')}`
247
+ : ''
248
+ logger.log(`[lazy-supervisor] Pending job detected${triggerSummary} — starting shared worker for all queues`)
249
+ let child: ChildProcess
250
+ try {
251
+ child = spawnFn(
252
+ 'node',
253
+ [options.mercatoBin, 'queue', 'worker', '--all'],
254
+ {
255
+ stdio: 'inherit',
256
+ env: options.runtimeEnv,
257
+ cwd: options.appDir,
258
+ },
259
+ )
260
+ } catch (err) {
261
+ startingSharedWorker = false
262
+ const message = err instanceof Error ? err.message : String(err)
263
+ logger.error(`[lazy-supervisor] Failed to spawn shared worker: ${message}`)
264
+ return
265
+ }
266
+
267
+ activeSharedChild = child
268
+ markSharedWorkerStarted()
269
+ startingSharedWorker = false
270
+ for (const group of groups) {
271
+ if (options.onSpawn) {
272
+ try {
273
+ options.onSpawn(group.queueName, child)
274
+ } catch (err) {
275
+ const message = err instanceof Error ? err.message : String(err)
276
+ logger.warn(`[lazy-supervisor] onSpawn callback threw for "${group.queueName}": ${message}`)
277
+ }
278
+ }
279
+ }
280
+
281
+ child.on('exit', (code, signal) => {
282
+ clearSharedWorker()
283
+ for (const group of groups) {
284
+ if (options.onChildExit) {
285
+ try {
286
+ options.onChildExit(group.queueName, code, signal)
287
+ } catch (err) {
288
+ const message = err instanceof Error ? err.message : String(err)
289
+ logger.warn(`[lazy-supervisor] onChildExit callback threw for "${group.queueName}": ${message}`)
290
+ }
291
+ }
292
+ }
293
+ if (stopping) return
294
+ const expected = signal === 'SIGTERM' || signal === 'SIGINT'
295
+ if (expected) return
296
+ const reason = code !== null ? `code ${code}` : `signal ${signal ?? 'unknown'}`
297
+ logger.warn(`[lazy-supervisor] Shared worker exited (${reason}).`)
298
+ if (!options.restartOnUnexpectedExit) return
299
+ void (async () => {
300
+ const readyQueues: string[] = []
301
+ for (const group of groups) {
302
+ const result = await safeProbe(group.queueName)
303
+ if (!result || result.error) continue
304
+ if (result.ready > 0) readyQueues.push(group.queueName)
305
+ }
306
+ if (readyQueues.length > 0) {
307
+ logger.warn('[lazy-supervisor] Restarting shared worker because jobs remain pending.')
308
+ spawnSharedWorker(readyQueues)
309
+ }
310
+ })()
311
+ })
312
+ }
313
+
218
314
  async function safeProbe(queueName: string): Promise<QueuePendingProbeResult | null> {
219
315
  try {
220
316
  return await probe(queueName, strategy)
@@ -228,6 +324,7 @@ export function startLazyWorkerSupervisor(
228
324
  async function tickOnce(): Promise<void> {
229
325
  if (stopping) return
230
326
  if (groups.length === 0) return
327
+ if (spawnMode === 'shared' && (activeSharedChild || startingSharedWorker)) return
231
328
 
232
329
  const toCheck = groups.filter((g) => !activeChildren.has(g.queueName) && !startingQueues.has(g.queueName))
233
330
  if (toCheck.length === 0) return
@@ -241,6 +338,7 @@ export function startLazyWorkerSupervisor(
241
338
 
242
339
  if (stopping) return
243
340
 
341
+ const readySharedQueues: string[] = []
244
342
  for (const { group, result } of probes) {
245
343
  if (!result) continue
246
344
  if (result.error) {
@@ -250,9 +348,16 @@ export function startLazyWorkerSupervisor(
250
348
  continue
251
349
  }
252
350
  if (result.ready > 0) {
351
+ if (spawnMode === 'shared') {
352
+ readySharedQueues.push(group.queueName)
353
+ continue
354
+ }
253
355
  spawnQueueWorker(group.queueName)
254
356
  }
255
357
  }
358
+ if (spawnMode === 'shared' && readySharedQueues.length > 0) {
359
+ spawnSharedWorker(readySharedQueues)
360
+ }
256
361
  }
257
362
 
258
363
  function scheduleNext(): void {
package/src/mercato.ts CHANGED
@@ -11,6 +11,7 @@ import { getRedisUrl, getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/
11
11
  import { resolveInitDerivedSecrets } from './lib/init-secrets'
12
12
  import {
13
13
  resolveAutoSpawnWorkersMode,
14
+ resolveLazySpawnMode,
14
15
  resolveLazyPollMs,
15
16
  resolveLazyRestart,
16
17
  } from './lib/auto-spawn-workers'
@@ -2119,7 +2120,11 @@ export async function run(argv = process.argv) {
2119
2120
  if (discoveredWorkerQueues.length === 0) {
2120
2121
  console.error('[server] AUTO_SPAWN_WORKERS is enabled, but no queues were discovered from CLI modules. Run `yarn generate` and verify `.mercato/generated/modules.cli.generated.ts` contains worker entries. Continuing without auto-spawned workers.')
2121
2122
  } else if (autoSpawnWorkersMode === 'lazy') {
2122
- console.log(`[server] Lazy worker auto-spawn enabled — workers will start on first job (${discoveredWorkerQueues.length} queue(s) watched).`)
2123
+ const lazySpawnMode = resolveLazySpawnMode(process.env)
2124
+ const lazyModeHint = lazySpawnMode === 'shared'
2125
+ ? 'shared worker mode — one `queue worker --all` process; set OM_AUTO_SPAWN_WORKERS_LAZY_MODE=per-queue to change'
2126
+ : 'per-queue worker mode — one process per queue; set OM_AUTO_SPAWN_WORKERS_LAZY_MODE=shared to change'
2127
+ console.log(`[server] Lazy worker auto-spawn enabled — workers will start on first job (${discoveredWorkerQueues.length} queue(s) watched, ${lazyModeHint}).`)
2123
2128
  activeLazySupervisor = startLazyWorkerSupervisor({
2124
2129
  mercatoBin,
2125
2130
  appDir,
@@ -2127,6 +2132,7 @@ export async function run(argv = process.argv) {
2127
2132
  workers: discoveredWorkers,
2128
2133
  pollMs: resolveLazyPollMs(process.env),
2129
2134
  restartOnUnexpectedExit: resolveLazyRestart(process.env),
2135
+ spawnMode: lazySpawnMode,
2130
2136
  })
2131
2137
  } else {
2132
2138
  console.log('[server] Eager worker auto-spawn enabled - starting workers for all queues...')
@@ -2324,7 +2330,11 @@ export async function run(argv = process.argv) {
2324
2330
  if (discoveredWorkerQueues.length === 0) {
2325
2331
  console.error('[server] AUTO_SPAWN_WORKERS is enabled, but no queues were discovered from CLI modules. Run `yarn generate` and verify `.mercato/generated/modules.cli.generated.ts` contains worker entries. Continuing without auto-spawned workers.')
2326
2332
  } else if (autoSpawnWorkersMode === 'lazy') {
2327
- console.log(`[server] Lazy worker auto-spawn enabled — workers will start on first job (${discoveredWorkerQueues.length} queue(s) watched).`)
2333
+ const lazySpawnMode = resolveLazySpawnMode(process.env)
2334
+ const lazyModeHint = lazySpawnMode === 'shared'
2335
+ ? 'shared worker mode — one `queue worker --all` process; set OM_AUTO_SPAWN_WORKERS_LAZY_MODE=per-queue to change'
2336
+ : 'per-queue worker mode — one process per queue; set OM_AUTO_SPAWN_WORKERS_LAZY_MODE=shared to change'
2337
+ console.log(`[server] Lazy worker auto-spawn enabled — workers will start on first job (${discoveredWorkerQueues.length} queue(s) watched, ${lazyModeHint}).`)
2328
2338
  activeLazySupervisor = startLazyWorkerSupervisor({
2329
2339
  mercatoBin,
2330
2340
  appDir,
@@ -2332,6 +2342,7 @@ export async function run(argv = process.argv) {
2332
2342
  workers: discoveredWorkers,
2333
2343
  pollMs: resolveLazyPollMs(process.env),
2334
2344
  restartOnUnexpectedExit: resolveLazyRestart(process.env),
2345
+ spawnMode: lazySpawnMode,
2335
2346
  })
2336
2347
  } else {
2337
2348
  console.log('[server] Eager worker auto-spawn enabled - starting workers for all queues...')