@open-mercato/cli 0.6.1-develop.3167.1.7aedd7b2ba → 0.6.1-develop.3174.1.c3822bcdf0

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/jest.config.cjs CHANGED
@@ -16,6 +16,9 @@ module.exports = {
16
16
  },
17
17
  moduleNameMapper: {
18
18
  '^@open-mercato/shared/(.*)$': '<rootDir>/../shared/src/$1',
19
+ '^@open-mercato/queue$': '<rootDir>/../queue/src/index.ts',
20
+ '^@open-mercato/queue/worker$': '<rootDir>/../queue/src/worker/runner.ts',
21
+ '^@open-mercato/queue/(.*)$': '<rootDir>/../queue/src/$1',
19
22
  },
20
23
  transformIgnorePatterns: [
21
24
  'node_modules/(?!(@mikro-orm)/)',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/cli",
3
- "version": "0.6.1-develop.3167.1.7aedd7b2ba",
3
+ "version": "0.6.1-develop.3174.1.c3822bcdf0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -59,8 +59,8 @@
59
59
  "@mikro-orm/decorators": "^7.0.14",
60
60
  "@mikro-orm/migrations": "^7.0.14",
61
61
  "@mikro-orm/postgresql": "^7.0.14",
62
- "@open-mercato/queue": "0.6.1-develop.3167.1.7aedd7b2ba",
63
- "@open-mercato/shared": "0.6.1-develop.3167.1.7aedd7b2ba",
62
+ "@open-mercato/queue": "0.6.1-develop.3174.1.c3822bcdf0",
63
+ "@open-mercato/shared": "0.6.1-develop.3174.1.c3822bcdf0",
64
64
  "cross-spawn": "^7.0.6",
65
65
  "pg": "8.20.0",
66
66
  "semver": "^7.7.4",
@@ -70,10 +70,10 @@
70
70
  "typescript": "^5.9.3"
71
71
  },
72
72
  "peerDependencies": {
73
- "@open-mercato/shared": "0.6.1-develop.3167.1.7aedd7b2ba"
73
+ "@open-mercato/shared": "0.6.1-develop.3174.1.c3822bcdf0"
74
74
  },
75
75
  "devDependencies": {
76
- "@open-mercato/shared": "0.6.1-develop.3167.1.7aedd7b2ba",
76
+ "@open-mercato/shared": "0.6.1-develop.3174.1.c3822bcdf0",
77
77
  "@types/jest": "^30.0.0",
78
78
  "jest": "^30.3.0",
79
79
  "ts-jest": "^29.4.9"
@@ -619,12 +619,14 @@ describe('generate post-step structural cache purge', () => {
619
619
  describe('server dev managed process exits', () => {
620
620
  const originalAutoSpawnScheduler = process.env.AUTO_SPAWN_SCHEDULER
621
621
  const originalAutoSpawnWorkers = process.env.AUTO_SPAWN_WORKERS
622
+ const originalLazy = process.env.OM_AUTO_SPAWN_WORKERS_LAZY
622
623
 
623
624
  beforeEach(() => {
624
625
  jest.restoreAllMocks()
625
626
  jest.resetModules()
626
627
  process.env.AUTO_SPAWN_SCHEDULER = 'false'
627
628
  process.env.AUTO_SPAWN_WORKERS = 'true'
629
+ delete process.env.OM_AUTO_SPAWN_WORKERS_LAZY
628
630
  })
629
631
 
630
632
  afterEach(() => {
@@ -633,12 +635,16 @@ describe('server dev managed process exits', () => {
633
635
  jest.dontMock('../lib/dev-env-reload')
634
636
  jest.dontMock('../lib/generators')
635
637
  jest.dontMock('../lib/resolver')
638
+ jest.dontMock('../lib/queue-worker-supervisor')
636
639
  jest.resetModules()
640
+ delete process.env.OM_AUTO_SPAWN_WORKERS_LAZY
637
641
  })
638
642
 
639
643
  afterAll(() => {
640
644
  process.env.AUTO_SPAWN_SCHEDULER = originalAutoSpawnScheduler
641
645
  process.env.AUTO_SPAWN_WORKERS = originalAutoSpawnWorkers
646
+ if (originalLazy === undefined) delete process.env.OM_AUTO_SPAWN_WORKERS_LAZY
647
+ else process.env.OM_AUTO_SPAWN_WORKERS_LAZY = originalLazy
642
648
  })
643
649
 
644
650
  it('skips scheduler auto-start when the module is not enabled', async () => {
@@ -730,17 +736,84 @@ describe('server dev managed process exits', () => {
730
736
  consoleErrorSpy.mockRestore()
731
737
  consoleLogSpy.mockRestore()
732
738
  })
739
+
740
+ it('starts the lazy worker supervisor instead of `queue worker --all` when OM_AUTO_SPAWN_WORKERS_LAZY=true', async () => {
741
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
742
+ const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation()
743
+ process.env.OM_AUTO_SPAWN_WORKERS_LAZY = 'true'
744
+
745
+ jest.doMock('node:fs', () => {
746
+ const actual = jest.requireActual('node:fs')
747
+ return {
748
+ ...actual,
749
+ existsSync: jest.fn((candidate: string) =>
750
+ candidate.includes('next/dist/bin/next') || candidate.includes('@open-mercato/cli/bin/mercato'),
751
+ ),
752
+ unlinkSync: jest.fn(),
753
+ }
754
+ })
755
+ jest.doMock('../lib/generators', () => ({
756
+ generateModulePackageSources: jest.fn().mockResolvedValue(undefined),
757
+ }))
758
+ jest.doMock('../lib/resolver', () => ({
759
+ resolveEnvironment: () => ({
760
+ appDir: '/tmp/test-app',
761
+ rootDir: '/tmp/test-root',
762
+ }),
763
+ createResolver: () => ({}),
764
+ }))
765
+
766
+ const supervisorClose = jest.fn().mockResolvedValue(undefined)
767
+ const startLazyWorkerSupervisor = jest.fn(() => ({
768
+ startedQueues: new Set<string>(),
769
+ getActiveChild: () => undefined,
770
+ close: supervisorClose,
771
+ done: Promise.resolve(),
772
+ }))
773
+ jest.doMock('../lib/queue-worker-supervisor', () => ({
774
+ startLazyWorkerSupervisor,
775
+ }))
776
+
777
+ jest.doMock('child_process', () =>
778
+ buildMockChildProcessModule((args) =>
779
+ args[0]?.includes('next/dist/bin/next') ? { code: null, signal: 'SIGTERM' } : undefined,
780
+ ),
781
+ )
782
+
783
+ const mercato = await import('../mercato')
784
+ mercato.registerCliModules([eventsWorkerFixture as Module])
785
+
786
+ const exitCode = await mercato.run(['node', 'mercato', 'server', 'dev'])
787
+
788
+ expect(exitCode).toBe(0)
789
+ expect(startLazyWorkerSupervisor).toHaveBeenCalledTimes(1)
790
+ const supervisorCall = startLazyWorkerSupervisor.mock.calls[0][0]
791
+ expect(supervisorCall.workers.map((worker) => worker.queue)).toEqual(['events'])
792
+ expect(supervisorCall.pollMs).toBe(1000)
793
+ expect(supervisorCall.restartOnUnexpectedExit).toBe(true)
794
+ expect(supervisorClose).toHaveBeenCalled()
795
+
796
+ const { spawn } = await import('child_process')
797
+ const allSpawnCalls = (spawn as jest.Mock).mock.calls.map((call) => call[1] as string[])
798
+ const queueWorkerSpawn = allSpawnCalls.find((args) => args.slice(1).join(' ') === 'queue worker --all')
799
+ expect(queueWorkerSpawn).toBeUndefined()
800
+
801
+ consoleErrorSpy.mockRestore()
802
+ consoleLogSpy.mockRestore()
803
+ })
733
804
  })
734
805
 
735
806
  describe('server start managed process exits', () => {
736
807
  const originalAutoSpawnScheduler = process.env.AUTO_SPAWN_SCHEDULER
737
808
  const originalAutoSpawnWorkers = process.env.AUTO_SPAWN_WORKERS
809
+ const originalLazy = process.env.OM_AUTO_SPAWN_WORKERS_LAZY
738
810
 
739
811
  beforeEach(() => {
740
812
  jest.restoreAllMocks()
741
813
  jest.resetModules()
742
814
  process.env.AUTO_SPAWN_SCHEDULER = 'false'
743
815
  process.env.AUTO_SPAWN_WORKERS = 'true'
816
+ delete process.env.OM_AUTO_SPAWN_WORKERS_LAZY
744
817
  })
745
818
 
746
819
  afterEach(() => {
@@ -748,12 +821,16 @@ describe('server start managed process exits', () => {
748
821
  jest.dontMock('node:fs')
749
822
  jest.dontMock('../lib/resolver')
750
823
  jest.dontMock('../lib/server-start-lock')
824
+ jest.dontMock('../lib/queue-worker-supervisor')
751
825
  jest.resetModules()
826
+ delete process.env.OM_AUTO_SPAWN_WORKERS_LAZY
752
827
  })
753
828
 
754
829
  afterAll(() => {
755
830
  process.env.AUTO_SPAWN_SCHEDULER = originalAutoSpawnScheduler
756
831
  process.env.AUTO_SPAWN_WORKERS = originalAutoSpawnWorkers
832
+ if (originalLazy === undefined) delete process.env.OM_AUTO_SPAWN_WORKERS_LAZY
833
+ else process.env.OM_AUTO_SPAWN_WORKERS_LAZY = originalLazy
757
834
  })
758
835
 
759
836
  it('skips scheduler auto-start when the module is not enabled', async () => {
@@ -956,4 +1033,63 @@ describe('server start managed process exits', () => {
956
1033
  consoleErrorSpy.mockRestore()
957
1034
  consoleLogSpy.mockRestore()
958
1035
  })
1036
+
1037
+ it('starts the lazy worker supervisor in production server when OM_AUTO_SPAWN_WORKERS_LAZY=true', async () => {
1038
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
1039
+ const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation()
1040
+ process.env.OM_AUTO_SPAWN_WORKERS_LAZY = 'true'
1041
+
1042
+ jest.doMock('node:fs', () => {
1043
+ const actual = jest.requireActual('node:fs')
1044
+ return {
1045
+ ...actual,
1046
+ existsSync: jest.fn((candidate: string) =>
1047
+ candidate.includes('next/dist/bin/next') || candidate.includes('@open-mercato/cli/bin/mercato'),
1048
+ ),
1049
+ }
1050
+ })
1051
+ jest.doMock('../lib/resolver', () => ({
1052
+ resolveEnvironment: () => ({
1053
+ appDir: '/tmp/test-app',
1054
+ rootDir: '/tmp/test-root',
1055
+ }),
1056
+ }))
1057
+ jest.doMock('../lib/server-start-lock', () => ({
1058
+ acquireServerStartLock: jest.fn(() => ({ release: jest.fn() })),
1059
+ }))
1060
+
1061
+ const supervisorClose = jest.fn().mockResolvedValue(undefined)
1062
+ const startLazyWorkerSupervisor = jest.fn(() => ({
1063
+ startedQueues: new Set<string>(),
1064
+ getActiveChild: () => undefined,
1065
+ close: supervisorClose,
1066
+ done: Promise.resolve(),
1067
+ }))
1068
+ jest.doMock('../lib/queue-worker-supervisor', () => ({
1069
+ startLazyWorkerSupervisor,
1070
+ }))
1071
+
1072
+ jest.doMock('child_process', () =>
1073
+ buildMockChildProcessModule((args) =>
1074
+ args[0]?.includes('next/dist/bin/next') ? { code: null, signal: 'SIGTERM' } : undefined,
1075
+ ),
1076
+ )
1077
+
1078
+ const mercato = await import('../mercato')
1079
+ mercato.registerCliModules([eventsWorkerFixture as Module])
1080
+
1081
+ const exitCode = await mercato.run(['node', 'mercato', 'server', 'start'])
1082
+
1083
+ expect(exitCode).toBe(0)
1084
+ expect(startLazyWorkerSupervisor).toHaveBeenCalledTimes(1)
1085
+ expect(supervisorClose).toHaveBeenCalled()
1086
+
1087
+ const { spawn } = await import('child_process')
1088
+ const allSpawnCalls = (spawn as jest.Mock).mock.calls.map((call) => call[1] as string[])
1089
+ const queueWorkerSpawn = allSpawnCalls.find((args) => args.slice(1).join(' ') === 'queue worker --all')
1090
+ expect(queueWorkerSpawn).toBeUndefined()
1091
+
1092
+ consoleErrorSpy.mockRestore()
1093
+ consoleLogSpy.mockRestore()
1094
+ })
959
1095
  })
@@ -0,0 +1,101 @@
1
+ import {
2
+ resolveAutoSpawnWorkersMode,
3
+ resolveLazyPollMs,
4
+ resolveLazyRestart,
5
+ } from '../auto-spawn-workers'
6
+
7
+ describe('resolveAutoSpawnWorkersMode', () => {
8
+ it('defaults to eager when no env is set', () => {
9
+ expect(resolveAutoSpawnWorkersMode({})).toBe('eager')
10
+ })
11
+
12
+ it('returns off when AUTO_SPAWN_WORKERS=false', () => {
13
+ expect(resolveAutoSpawnWorkersMode({ AUTO_SPAWN_WORKERS: 'false' })).toBe('off')
14
+ })
15
+
16
+ it('returns off when OM_AUTO_SPAWN_WORKERS=false and legacy unset', () => {
17
+ expect(resolveAutoSpawnWorkersMode({ OM_AUTO_SPAWN_WORKERS: 'false' })).toBe('off')
18
+ })
19
+
20
+ it('legacy AUTO_SPAWN_WORKERS=true wins over OM_AUTO_SPAWN_WORKERS=false', () => {
21
+ expect(
22
+ resolveAutoSpawnWorkersMode({
23
+ AUTO_SPAWN_WORKERS: 'true',
24
+ OM_AUTO_SPAWN_WORKERS: 'false',
25
+ }),
26
+ ).toBe('eager')
27
+ })
28
+
29
+ it('returns lazy when workers are enabled and OM_AUTO_SPAWN_WORKERS_LAZY=true', () => {
30
+ expect(
31
+ resolveAutoSpawnWorkersMode({
32
+ AUTO_SPAWN_WORKERS: 'true',
33
+ OM_AUTO_SPAWN_WORKERS_LAZY: 'true',
34
+ }),
35
+ ).toBe('lazy')
36
+ })
37
+
38
+ it('lazy is ignored when AUTO_SPAWN_WORKERS=false', () => {
39
+ expect(
40
+ resolveAutoSpawnWorkersMode({
41
+ AUTO_SPAWN_WORKERS: 'false',
42
+ OM_AUTO_SPAWN_WORKERS_LAZY: 'true',
43
+ }),
44
+ ).toBe('off')
45
+ })
46
+
47
+ it('treats invalid AUTO_SPAWN_WORKERS values as default', () => {
48
+ expect(resolveAutoSpawnWorkersMode({ AUTO_SPAWN_WORKERS: 'maybe' })).toBe('eager')
49
+ })
50
+
51
+ it('falls through OM alias only when legacy is unset', () => {
52
+ expect(resolveAutoSpawnWorkersMode({ OM_AUTO_SPAWN_WORKERS: 'true' })).toBe('eager')
53
+ expect(
54
+ resolveAutoSpawnWorkersMode({
55
+ OM_AUTO_SPAWN_WORKERS: 'true',
56
+ OM_AUTO_SPAWN_WORKERS_LAZY: 'true',
57
+ }),
58
+ ).toBe('lazy')
59
+ })
60
+ })
61
+
62
+ describe('resolveLazyPollMs', () => {
63
+ it('returns the default when unset', () => {
64
+ expect(resolveLazyPollMs({})).toBe(1000)
65
+ })
66
+
67
+ it('parses numeric values', () => {
68
+ expect(resolveLazyPollMs({ OM_AUTO_SPAWN_WORKERS_LAZY_POLL_MS: '500' })).toBe(500)
69
+ })
70
+
71
+ it('clamps to the minimum', () => {
72
+ expect(resolveLazyPollMs({ OM_AUTO_SPAWN_WORKERS_LAZY_POLL_MS: '50' })).toBe(250)
73
+ })
74
+
75
+ it('falls back to default for non-numeric', () => {
76
+ expect(resolveLazyPollMs({ OM_AUTO_SPAWN_WORKERS_LAZY_POLL_MS: 'abc' })).toBe(1000)
77
+ })
78
+
79
+ it('falls back to default for non-positive', () => {
80
+ expect(resolveLazyPollMs({ OM_AUTO_SPAWN_WORKERS_LAZY_POLL_MS: '0' })).toBe(1000)
81
+ expect(resolveLazyPollMs({ OM_AUTO_SPAWN_WORKERS_LAZY_POLL_MS: '-100' })).toBe(1000)
82
+ })
83
+ })
84
+
85
+ describe('resolveLazyRestart', () => {
86
+ it('defaults to true when unset', () => {
87
+ expect(resolveLazyRestart({})).toBe(true)
88
+ })
89
+
90
+ it('returns false when explicitly disabled', () => {
91
+ expect(resolveLazyRestart({ OM_AUTO_SPAWN_WORKERS_LAZY_RESTART: 'false' })).toBe(false)
92
+ })
93
+
94
+ it('returns true when explicitly enabled', () => {
95
+ expect(resolveLazyRestart({ OM_AUTO_SPAWN_WORKERS_LAZY_RESTART: 'true' })).toBe(true)
96
+ })
97
+
98
+ it('falls back to default for invalid values', () => {
99
+ expect(resolveLazyRestart({ OM_AUTO_SPAWN_WORKERS_LAZY_RESTART: 'maybe' })).toBe(true)
100
+ })
101
+ })
@@ -0,0 +1,285 @@
1
+ import { EventEmitter } from 'node:events'
2
+ import type { ChildProcess } from 'node:child_process'
3
+ import type { ModuleWorker } from '@open-mercato/shared/modules/registry'
4
+ import {
5
+ startLazyWorkerSupervisor,
6
+ type LazySupervisorProbeFn,
7
+ type LazySupervisorSpawnFn,
8
+ } from '../queue-worker-supervisor'
9
+ import type {
10
+ QueuePendingProbeResult,
11
+ QueueStrategyType,
12
+ } from '@open-mercato/queue'
13
+
14
+ type FakeChild = ChildProcess & {
15
+ triggerExit: (code: number | null, signal?: NodeJS.Signals | null) => void
16
+ }
17
+
18
+ function createFakeChild(): FakeChild {
19
+ const child = new EventEmitter() as unknown as FakeChild
20
+ ;(child as any).stdout = null
21
+ ;(child as any).stderr = null
22
+ ;(child as any).pid = Math.floor(Math.random() * 100000) + 1
23
+ ;(child as any).killed = false
24
+ ;(child as any).exitCode = null
25
+ ;(child as any).signalCode = null
26
+ ;(child as any).kill = jest.fn((signal: NodeJS.Signals = 'SIGTERM') => {
27
+ if ((child as any).killed) return true
28
+ ;(child as any).killed = true
29
+ ;(child as any).signalCode = signal
30
+ process.nextTick(() => child.emit('exit', null, signal))
31
+ return true
32
+ })
33
+ child.triggerExit = (code, signal = null) => {
34
+ ;(child as any).exitCode = code
35
+ ;(child as any).signalCode = signal
36
+ child.emit('exit', code, signal)
37
+ }
38
+ return child
39
+ }
40
+
41
+ const silentLogger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() }
42
+
43
+ function makeWorker(queue: string, id?: string, concurrency = 1): ModuleWorker {
44
+ return {
45
+ id: id ?? `${queue}-worker`,
46
+ queue,
47
+ concurrency,
48
+ handler: jest.fn() as unknown as ModuleWorker['handler'],
49
+ }
50
+ }
51
+
52
+ function emptyProbe(queueName: string, strategy: QueueStrategyType): QueuePendingProbeResult {
53
+ return { queueName, strategy, ready: 0, delayedFuture: 0, active: 0, error: false }
54
+ }
55
+
56
+ function readyProbe(queueName: string, strategy: QueueStrategyType, ready = 1): QueuePendingProbeResult {
57
+ return { queueName, strategy, ready, delayedFuture: 0, active: 0, error: false }
58
+ }
59
+
60
+ async function flushAsync(times = 5): Promise<void> {
61
+ for (let i = 0; i < times; i++) {
62
+ // eslint-disable-next-line no-await-in-loop
63
+ await Promise.resolve()
64
+ }
65
+ }
66
+
67
+ describe('startLazyWorkerSupervisor', () => {
68
+ it('does not spawn any worker while every queue is idle', async () => {
69
+ const spawnFn = jest.fn() as unknown as jest.MockedFunction<LazySupervisorSpawnFn>
70
+ const probeFn = jest.fn(async (queueName, strategy) =>
71
+ emptyProbe(queueName, strategy),
72
+ ) as jest.MockedFunction<LazySupervisorProbeFn>
73
+
74
+ const handle = startLazyWorkerSupervisor({
75
+ mercatoBin: '/tmp/mercato',
76
+ appDir: '/tmp/app',
77
+ runtimeEnv: { ...process.env },
78
+ workers: [makeWorker('events'), makeWorker('emails')],
79
+ pollMs: 250,
80
+ restartOnUnexpectedExit: true,
81
+ strategy: 'local',
82
+ spawnFn,
83
+ probeFn,
84
+ logger: silentLogger,
85
+ })
86
+
87
+ await flushAsync(20)
88
+ expect(probeFn).toHaveBeenCalled()
89
+ expect(spawnFn).not.toHaveBeenCalled()
90
+ expect(handle.startedQueues.size).toBe(0)
91
+
92
+ await handle.close()
93
+ })
94
+
95
+ it('spawns only the queue with ready jobs and never duplicates the spawn', async () => {
96
+ const child = createFakeChild()
97
+ const spawnFn = jest.fn(() => child) as unknown as jest.MockedFunction<LazySupervisorSpawnFn>
98
+ const probeFn = jest.fn(async (queueName, strategy) => {
99
+ if (queueName === 'events') return readyProbe(queueName, strategy)
100
+ return emptyProbe(queueName, strategy)
101
+ }) as jest.MockedFunction<LazySupervisorProbeFn>
102
+
103
+ const handle = startLazyWorkerSupervisor({
104
+ mercatoBin: '/tmp/mercato',
105
+ appDir: '/tmp/app',
106
+ runtimeEnv: { ...process.env },
107
+ workers: [makeWorker('events'), makeWorker('emails')],
108
+ pollMs: 250,
109
+ restartOnUnexpectedExit: false,
110
+ strategy: 'local',
111
+ spawnFn,
112
+ probeFn,
113
+ logger: silentLogger,
114
+ })
115
+
116
+ await flushAsync(20)
117
+
118
+ expect(spawnFn).toHaveBeenCalledTimes(1)
119
+ const [command, args] = spawnFn.mock.calls[0]
120
+ expect(command).toBe('node')
121
+ expect(args).toEqual(['/tmp/mercato', 'queue', 'worker', 'events'])
122
+ expect(handle.startedQueues.has('events')).toBe(true)
123
+ expect(handle.startedQueues.has('emails')).toBe(false)
124
+
125
+ await flushAsync(20)
126
+ expect(spawnFn).toHaveBeenCalledTimes(1)
127
+
128
+ await handle.close()
129
+ })
130
+
131
+ it('does not spawn when probe reports an error', async () => {
132
+ const spawnFn = jest.fn() as unknown as jest.MockedFunction<LazySupervisorSpawnFn>
133
+ const probeFn = jest.fn(async (queueName, strategy) => ({
134
+ queueName,
135
+ strategy,
136
+ ready: 0,
137
+ delayedFuture: 0,
138
+ active: 0,
139
+ error: true,
140
+ errorMessage: 'redis-unreachable',
141
+ })) as jest.MockedFunction<LazySupervisorProbeFn>
142
+
143
+ const handle = startLazyWorkerSupervisor({
144
+ mercatoBin: '/tmp/mercato',
145
+ appDir: '/tmp/app',
146
+ runtimeEnv: { ...process.env },
147
+ workers: [makeWorker('events')],
148
+ pollMs: 250,
149
+ restartOnUnexpectedExit: false,
150
+ strategy: 'async',
151
+ spawnFn,
152
+ probeFn,
153
+ logger: silentLogger,
154
+ })
155
+
156
+ await flushAsync(20)
157
+ expect(spawnFn).not.toHaveBeenCalled()
158
+
159
+ await handle.close()
160
+ })
161
+
162
+ it('terminates active children on close()', async () => {
163
+ const child = createFakeChild()
164
+ const spawnFn = jest.fn(() => child) as unknown as jest.MockedFunction<LazySupervisorSpawnFn>
165
+ const probeFn = jest.fn(async (queueName, strategy) =>
166
+ readyProbe(queueName, strategy),
167
+ ) as jest.MockedFunction<LazySupervisorProbeFn>
168
+
169
+ const handle = startLazyWorkerSupervisor({
170
+ mercatoBin: '/tmp/mercato',
171
+ appDir: '/tmp/app',
172
+ runtimeEnv: { ...process.env },
173
+ workers: [makeWorker('events')],
174
+ pollMs: 250,
175
+ restartOnUnexpectedExit: false,
176
+ strategy: 'local',
177
+ spawnFn,
178
+ probeFn,
179
+ logger: silentLogger,
180
+ })
181
+
182
+ await flushAsync(20)
183
+ expect(spawnFn).toHaveBeenCalledTimes(1)
184
+
185
+ await handle.close()
186
+ expect((child as any).kill).toHaveBeenCalledWith('SIGTERM')
187
+ })
188
+
189
+ it('restarts an unexpectedly exited worker only when jobs remain pending', async () => {
190
+ const firstChild = createFakeChild()
191
+ const secondChild = createFakeChild()
192
+ const spawnFn = jest
193
+ .fn()
194
+ .mockImplementationOnce(() => firstChild)
195
+ .mockImplementationOnce(() => secondChild) as unknown as jest.MockedFunction<LazySupervisorSpawnFn>
196
+
197
+ let pendingReady = 1
198
+ const probeFn = jest.fn(async (queueName, strategy) => {
199
+ if (queueName !== 'events') return emptyProbe(queueName, strategy)
200
+ return readyProbe(queueName, strategy, pendingReady)
201
+ }) as jest.MockedFunction<LazySupervisorProbeFn>
202
+
203
+ const handle = startLazyWorkerSupervisor({
204
+ mercatoBin: '/tmp/mercato',
205
+ appDir: '/tmp/app',
206
+ runtimeEnv: { ...process.env },
207
+ workers: [makeWorker('events')],
208
+ pollMs: 250,
209
+ restartOnUnexpectedExit: true,
210
+ strategy: 'local',
211
+ spawnFn,
212
+ probeFn,
213
+ logger: silentLogger,
214
+ })
215
+
216
+ await flushAsync(20)
217
+ expect(spawnFn).toHaveBeenCalledTimes(1)
218
+
219
+ // Worker dies unexpectedly with jobs still pending — supervisor must restart.
220
+ firstChild.triggerExit(1, null)
221
+ await flushAsync(40)
222
+ expect(spawnFn).toHaveBeenCalledTimes(2)
223
+
224
+ // Worker dies again, but this time no jobs remain — supervisor must NOT restart.
225
+ pendingReady = 0
226
+ secondChild.triggerExit(1, null)
227
+ await flushAsync(40)
228
+ expect(spawnFn).toHaveBeenCalledTimes(2)
229
+
230
+ await handle.close()
231
+ })
232
+
233
+ it('does not restart on an expected SIGTERM exit', async () => {
234
+ const child = createFakeChild()
235
+ const spawnFn = jest.fn(() => child) as unknown as jest.MockedFunction<LazySupervisorSpawnFn>
236
+ const probeFn = jest.fn(async (queueName, strategy) =>
237
+ readyProbe(queueName, strategy),
238
+ ) as jest.MockedFunction<LazySupervisorProbeFn>
239
+
240
+ const handle = startLazyWorkerSupervisor({
241
+ mercatoBin: '/tmp/mercato',
242
+ appDir: '/tmp/app',
243
+ runtimeEnv: { ...process.env },
244
+ workers: [makeWorker('events')],
245
+ pollMs: 250,
246
+ restartOnUnexpectedExit: true,
247
+ strategy: 'local',
248
+ spawnFn,
249
+ probeFn,
250
+ logger: silentLogger,
251
+ })
252
+
253
+ await flushAsync(20)
254
+ expect(spawnFn).toHaveBeenCalledTimes(1)
255
+
256
+ child.triggerExit(null, 'SIGTERM')
257
+ await flushAsync(40)
258
+
259
+ expect(spawnFn).toHaveBeenCalledTimes(1)
260
+
261
+ await handle.close()
262
+ })
263
+
264
+ it('handles an empty worker list without crashing', async () => {
265
+ const spawnFn = jest.fn() as unknown as jest.MockedFunction<LazySupervisorSpawnFn>
266
+ const probeFn = jest.fn() as unknown as jest.MockedFunction<LazySupervisorProbeFn>
267
+ const handle = startLazyWorkerSupervisor({
268
+ mercatoBin: '/tmp/mercato',
269
+ appDir: '/tmp/app',
270
+ runtimeEnv: { ...process.env },
271
+ workers: [],
272
+ pollMs: 250,
273
+ restartOnUnexpectedExit: true,
274
+ strategy: 'local',
275
+ spawnFn,
276
+ probeFn,
277
+ logger: silentLogger,
278
+ })
279
+
280
+ await flushAsync(20)
281
+ expect(probeFn).not.toHaveBeenCalled()
282
+ expect(spawnFn).not.toHaveBeenCalled()
283
+ await handle.close()
284
+ })
285
+ })
@@ -0,0 +1,39 @@
1
+ import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
2
+
3
+ export type AutoSpawnWorkersMode = 'off' | 'eager' | 'lazy'
4
+
5
+ export type AutoSpawnEnvSource = Pick<NodeJS.ProcessEnv, string> | Record<string, string | undefined>
6
+
7
+ const DEFAULT_LAZY_POLL_MS = 1000
8
+ const MIN_LAZY_POLL_MS = 250
9
+
10
+ export function resolveAutoSpawnWorkersEnabled(env: AutoSpawnEnvSource = process.env): boolean {
11
+ const legacy = parseBooleanToken(env.AUTO_SPAWN_WORKERS)
12
+ if (legacy !== null) return legacy
13
+ const aliased = parseBooleanToken(env.OM_AUTO_SPAWN_WORKERS)
14
+ if (aliased !== null) return aliased
15
+ return true
16
+ }
17
+
18
+ export function resolveAutoSpawnWorkersLazy(env: AutoSpawnEnvSource = process.env): boolean {
19
+ return parseBooleanToken(env.OM_AUTO_SPAWN_WORKERS_LAZY) === true
20
+ }
21
+
22
+ export function resolveAutoSpawnWorkersMode(env: AutoSpawnEnvSource = process.env): AutoSpawnWorkersMode {
23
+ if (!resolveAutoSpawnWorkersEnabled(env)) return 'off'
24
+ if (resolveAutoSpawnWorkersLazy(env)) return 'lazy'
25
+ return 'eager'
26
+ }
27
+
28
+ export function resolveLazyPollMs(env: AutoSpawnEnvSource = process.env): number {
29
+ const raw = env.OM_AUTO_SPAWN_WORKERS_LAZY_POLL_MS
30
+ if (typeof raw !== 'string' || raw.trim() === '') return DEFAULT_LAZY_POLL_MS
31
+ const parsed = Number(raw)
32
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_LAZY_POLL_MS
33
+ return Math.max(MIN_LAZY_POLL_MS, Math.floor(parsed))
34
+ }
35
+
36
+ export function resolveLazyRestart(env: AutoSpawnEnvSource = process.env): boolean {
37
+ const parsed = parseBooleanToken(env.OM_AUTO_SPAWN_WORKERS_LAZY_RESTART)
38
+ return parsed === null ? true : parsed
39
+ }