@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/.turbo/turbo-build.log +1 -1
- package/dist/lib/auto-spawn-workers.js +37 -0
- package/dist/lib/auto-spawn-workers.js.map +7 -0
- package/dist/lib/queue-worker-supervisor.js +222 -0
- package/dist/lib/queue-worker-supervisor.js.map +7 -0
- package/dist/mercato.js +56 -6
- package/dist/mercato.js.map +2 -2
- package/jest.config.cjs +3 -0
- package/package.json +5 -5
- package/src/__tests__/mercato.test.ts +136 -0
- package/src/lib/__tests__/auto-spawn-workers.test.ts +101 -0
- package/src/lib/__tests__/queue-worker-supervisor.test.ts +285 -0
- package/src/lib/auto-spawn-workers.ts +39 -0
- package/src/lib/queue-worker-supervisor.ts +327 -0
- package/src/mercato.ts +58 -6
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.
|
|
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.
|
|
63
|
-
"@open-mercato/shared": "0.6.1-develop.
|
|
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.
|
|
73
|
+
"@open-mercato/shared": "0.6.1-develop.3174.1.c3822bcdf0"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
-
"@open-mercato/shared": "0.6.1-develop.
|
|
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
|
+
}
|