@open-mercato/cli 0.6.1-develop.3291.1.6fad645fd0 → 0.6.1
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-scheduler.js +37 -0
- package/dist/lib/auto-spawn-scheduler.js.map +7 -0
- package/dist/lib/scheduler-supervisor.js +216 -0
- package/dist/lib/scheduler-supervisor.js.map +7 -0
- package/dist/lib/testing/integration.js +9 -1
- package/dist/lib/testing/integration.js.map +2 -2
- package/dist/mercato.js +54 -8
- package/dist/mercato.js.map +2 -2
- package/package.json +6 -7
- package/src/__tests__/mercato.test.ts +88 -0
- package/src/lib/__tests__/auto-spawn-scheduler.test.ts +63 -0
- package/src/lib/__tests__/scheduler-supervisor.test.ts +199 -0
- package/src/lib/auto-spawn-scheduler.ts +39 -0
- package/src/lib/scheduler-supervisor.ts +286 -0
- package/src/lib/testing/__tests__/integration.test.ts +12 -0
- package/src/lib/testing/integration.ts +9 -1
- package/src/mercato.ts +56 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/cli",
|
|
3
|
-
"version": "0.6.1
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
"@mikro-orm/decorators": "^7.0.15",
|
|
60
60
|
"@mikro-orm/migrations": "^7.0.15",
|
|
61
61
|
"@mikro-orm/postgresql": "^7.0.15",
|
|
62
|
-
"@open-mercato/queue": "0.6.1
|
|
63
|
-
"@open-mercato/shared": "0.6.1
|
|
62
|
+
"@open-mercato/queue": "0.6.1",
|
|
63
|
+
"@open-mercato/shared": "0.6.1",
|
|
64
64
|
"cross-spawn": "^7.0.6",
|
|
65
65
|
"pg": "8.20.0",
|
|
66
66
|
"semver": "^7.8.0",
|
|
@@ -70,10 +70,10 @@
|
|
|
70
70
|
"typescript": "^5.9.3"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
|
-
"@open-mercato/shared": "0.6.1
|
|
73
|
+
"@open-mercato/shared": "0.6.1"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
-
"@open-mercato/shared": "0.6.1
|
|
76
|
+
"@open-mercato/shared": "0.6.1",
|
|
77
77
|
"@types/jest": "^30.0.0",
|
|
78
78
|
"jest": "^30.4.2",
|
|
79
79
|
"ts-jest": "^29.4.9"
|
|
@@ -85,6 +85,5 @@
|
|
|
85
85
|
"type": "git",
|
|
86
86
|
"url": "https://github.com/open-mercato/open-mercato",
|
|
87
87
|
"directory": "packages/cli"
|
|
88
|
-
}
|
|
89
|
-
"stableVersion": "0.6.0"
|
|
88
|
+
}
|
|
90
89
|
}
|
|
@@ -62,6 +62,16 @@ const eventsWorkerFixture: Pick<Module, 'id' | 'workers'> = {
|
|
|
62
62
|
],
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
const schedulerCliFixture: Pick<Module, 'id' | 'cli'> = {
|
|
66
|
+
id: 'scheduler',
|
|
67
|
+
cli: [
|
|
68
|
+
{
|
|
69
|
+
command: 'start',
|
|
70
|
+
run: jest.fn(),
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
}
|
|
74
|
+
|
|
65
75
|
describe('mercato CLI module registration', () => {
|
|
66
76
|
beforeEach(() => {
|
|
67
77
|
// Reset module state by re-importing
|
|
@@ -620,6 +630,7 @@ describe('server dev managed process exits', () => {
|
|
|
620
630
|
const originalAutoSpawnScheduler = process.env.AUTO_SPAWN_SCHEDULER
|
|
621
631
|
const originalAutoSpawnWorkers = process.env.AUTO_SPAWN_WORKERS
|
|
622
632
|
const originalLazy = process.env.OM_AUTO_SPAWN_WORKERS_LAZY
|
|
633
|
+
const originalLazyScheduler = process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY
|
|
623
634
|
|
|
624
635
|
beforeEach(() => {
|
|
625
636
|
jest.restoreAllMocks()
|
|
@@ -627,6 +638,7 @@ describe('server dev managed process exits', () => {
|
|
|
627
638
|
process.env.AUTO_SPAWN_SCHEDULER = 'false'
|
|
628
639
|
process.env.AUTO_SPAWN_WORKERS = 'true'
|
|
629
640
|
delete process.env.OM_AUTO_SPAWN_WORKERS_LAZY
|
|
641
|
+
delete process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY
|
|
630
642
|
})
|
|
631
643
|
|
|
632
644
|
afterEach(() => {
|
|
@@ -636,8 +648,10 @@ describe('server dev managed process exits', () => {
|
|
|
636
648
|
jest.dontMock('../lib/generators')
|
|
637
649
|
jest.dontMock('../lib/resolver')
|
|
638
650
|
jest.dontMock('../lib/queue-worker-supervisor')
|
|
651
|
+
jest.dontMock('../lib/scheduler-supervisor')
|
|
639
652
|
jest.resetModules()
|
|
640
653
|
delete process.env.OM_AUTO_SPAWN_WORKERS_LAZY
|
|
654
|
+
delete process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY
|
|
641
655
|
})
|
|
642
656
|
|
|
643
657
|
afterAll(() => {
|
|
@@ -645,6 +659,8 @@ describe('server dev managed process exits', () => {
|
|
|
645
659
|
process.env.AUTO_SPAWN_WORKERS = originalAutoSpawnWorkers
|
|
646
660
|
if (originalLazy === undefined) delete process.env.OM_AUTO_SPAWN_WORKERS_LAZY
|
|
647
661
|
else process.env.OM_AUTO_SPAWN_WORKERS_LAZY = originalLazy
|
|
662
|
+
if (originalLazyScheduler === undefined) delete process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY
|
|
663
|
+
else process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY = originalLazyScheduler
|
|
648
664
|
})
|
|
649
665
|
|
|
650
666
|
it('skips scheduler auto-start when the module is not enabled', async () => {
|
|
@@ -801,12 +817,79 @@ describe('server dev managed process exits', () => {
|
|
|
801
817
|
consoleErrorSpy.mockRestore()
|
|
802
818
|
consoleLogSpy.mockRestore()
|
|
803
819
|
})
|
|
820
|
+
|
|
821
|
+
it('starts the lazy scheduler supervisor instead of the scheduler process when OM_AUTO_SPAWN_SCHEDULER_LAZY=true', async () => {
|
|
822
|
+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()
|
|
823
|
+
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation()
|
|
824
|
+
process.env.AUTO_SPAWN_SCHEDULER = 'true'
|
|
825
|
+
process.env.AUTO_SPAWN_WORKERS = 'false'
|
|
826
|
+
process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY = 'true'
|
|
827
|
+
|
|
828
|
+
jest.doMock('node:fs', () => {
|
|
829
|
+
const actual = jest.requireActual('node:fs')
|
|
830
|
+
return {
|
|
831
|
+
...actual,
|
|
832
|
+
existsSync: jest.fn((candidate: string) =>
|
|
833
|
+
candidate.includes('next/dist/bin/next') || candidate.includes('@open-mercato/cli/bin/mercato'),
|
|
834
|
+
),
|
|
835
|
+
unlinkSync: jest.fn(),
|
|
836
|
+
}
|
|
837
|
+
})
|
|
838
|
+
jest.doMock('../lib/generators', () => ({
|
|
839
|
+
generateModulePackageSources: jest.fn().mockResolvedValue(undefined),
|
|
840
|
+
}))
|
|
841
|
+
jest.doMock('../lib/resolver', () => ({
|
|
842
|
+
resolveEnvironment: () => ({
|
|
843
|
+
appDir: '/tmp/test-app',
|
|
844
|
+
rootDir: '/tmp/test-root',
|
|
845
|
+
}),
|
|
846
|
+
createResolver: () => ({}),
|
|
847
|
+
}))
|
|
848
|
+
|
|
849
|
+
const schedulerClose = jest.fn().mockResolvedValue(undefined)
|
|
850
|
+
const startLazySchedulerSupervisor = jest.fn(() => ({
|
|
851
|
+
started: false,
|
|
852
|
+
getActiveChild: () => undefined,
|
|
853
|
+
close: schedulerClose,
|
|
854
|
+
done: Promise.resolve(),
|
|
855
|
+
}))
|
|
856
|
+
jest.doMock('../lib/scheduler-supervisor', () => ({
|
|
857
|
+
startLazySchedulerSupervisor,
|
|
858
|
+
}))
|
|
859
|
+
|
|
860
|
+
jest.doMock('child_process', () =>
|
|
861
|
+
buildMockChildProcessModule((args) =>
|
|
862
|
+
args[0]?.includes('next/dist/bin/next') ? { code: null, signal: 'SIGTERM' } : undefined,
|
|
863
|
+
),
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
const mercado = await import('../mercato')
|
|
867
|
+
mercado.registerCliModules([schedulerCliFixture as Module])
|
|
868
|
+
|
|
869
|
+
const exitCode = await mercado.run(['node', 'mercato', 'server', 'dev'])
|
|
870
|
+
|
|
871
|
+
expect(exitCode).toBe(0)
|
|
872
|
+
expect(startLazySchedulerSupervisor).toHaveBeenCalledTimes(1)
|
|
873
|
+
const supervisorCall = startLazySchedulerSupervisor.mock.calls[0][0]
|
|
874
|
+
expect(supervisorCall.pollMs).toBe(1000)
|
|
875
|
+
expect(supervisorCall.restartOnUnexpectedExit).toBe(true)
|
|
876
|
+
expect(schedulerClose).toHaveBeenCalled()
|
|
877
|
+
|
|
878
|
+
const { spawn } = await import('child_process')
|
|
879
|
+
const allSpawnCalls = (spawn as jest.Mock).mock.calls.map((call) => call[1] as string[])
|
|
880
|
+
const schedulerSpawn = allSpawnCalls.find((args) => args.slice(1).join(' ') === 'scheduler start')
|
|
881
|
+
expect(schedulerSpawn).toBeUndefined()
|
|
882
|
+
|
|
883
|
+
consoleErrorSpy.mockRestore()
|
|
884
|
+
consoleLogSpy.mockRestore()
|
|
885
|
+
})
|
|
804
886
|
})
|
|
805
887
|
|
|
806
888
|
describe('server start managed process exits', () => {
|
|
807
889
|
const originalAutoSpawnScheduler = process.env.AUTO_SPAWN_SCHEDULER
|
|
808
890
|
const originalAutoSpawnWorkers = process.env.AUTO_SPAWN_WORKERS
|
|
809
891
|
const originalLazy = process.env.OM_AUTO_SPAWN_WORKERS_LAZY
|
|
892
|
+
const originalLazyScheduler = process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY
|
|
810
893
|
|
|
811
894
|
beforeEach(() => {
|
|
812
895
|
jest.restoreAllMocks()
|
|
@@ -814,6 +897,7 @@ describe('server start managed process exits', () => {
|
|
|
814
897
|
process.env.AUTO_SPAWN_SCHEDULER = 'false'
|
|
815
898
|
process.env.AUTO_SPAWN_WORKERS = 'true'
|
|
816
899
|
delete process.env.OM_AUTO_SPAWN_WORKERS_LAZY
|
|
900
|
+
delete process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY
|
|
817
901
|
})
|
|
818
902
|
|
|
819
903
|
afterEach(() => {
|
|
@@ -822,8 +906,10 @@ describe('server start managed process exits', () => {
|
|
|
822
906
|
jest.dontMock('../lib/resolver')
|
|
823
907
|
jest.dontMock('../lib/server-start-lock')
|
|
824
908
|
jest.dontMock('../lib/queue-worker-supervisor')
|
|
909
|
+
jest.dontMock('../lib/scheduler-supervisor')
|
|
825
910
|
jest.resetModules()
|
|
826
911
|
delete process.env.OM_AUTO_SPAWN_WORKERS_LAZY
|
|
912
|
+
delete process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY
|
|
827
913
|
})
|
|
828
914
|
|
|
829
915
|
afterAll(() => {
|
|
@@ -831,6 +917,8 @@ describe('server start managed process exits', () => {
|
|
|
831
917
|
process.env.AUTO_SPAWN_WORKERS = originalAutoSpawnWorkers
|
|
832
918
|
if (originalLazy === undefined) delete process.env.OM_AUTO_SPAWN_WORKERS_LAZY
|
|
833
919
|
else process.env.OM_AUTO_SPAWN_WORKERS_LAZY = originalLazy
|
|
920
|
+
if (originalLazyScheduler === undefined) delete process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY
|
|
921
|
+
else process.env.OM_AUTO_SPAWN_SCHEDULER_LAZY = originalLazyScheduler
|
|
834
922
|
})
|
|
835
923
|
|
|
836
924
|
it('skips scheduler auto-start when the module is not enabled', async () => {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveAutoSpawnSchedulerEnabled,
|
|
3
|
+
resolveAutoSpawnSchedulerLazy,
|
|
4
|
+
resolveAutoSpawnSchedulerMode,
|
|
5
|
+
resolveLazySchedulerPollMs,
|
|
6
|
+
resolveLazySchedulerRestart,
|
|
7
|
+
} from '../auto-spawn-scheduler'
|
|
8
|
+
|
|
9
|
+
describe('auto scheduler spawn env resolution', () => {
|
|
10
|
+
it('defaults to eager scheduler auto-spawn', () => {
|
|
11
|
+
expect(resolveAutoSpawnSchedulerMode({})).toBe('eager')
|
|
12
|
+
expect(resolveAutoSpawnSchedulerEnabled({})).toBe(true)
|
|
13
|
+
expect(resolveAutoSpawnSchedulerLazy({})).toBe(false)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('returns off when AUTO_SPAWN_SCHEDULER=false', () => {
|
|
17
|
+
expect(resolveAutoSpawnSchedulerMode({ AUTO_SPAWN_SCHEDULER: 'false' })).toBe('off')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('returns off when OM_AUTO_SPAWN_SCHEDULER=false and legacy unset', () => {
|
|
21
|
+
expect(resolveAutoSpawnSchedulerMode({ OM_AUTO_SPAWN_SCHEDULER: 'false' })).toBe('off')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('legacy AUTO_SPAWN_SCHEDULER=true wins over OM_AUTO_SPAWN_SCHEDULER=false', () => {
|
|
25
|
+
expect(resolveAutoSpawnSchedulerMode({
|
|
26
|
+
AUTO_SPAWN_SCHEDULER: 'true',
|
|
27
|
+
OM_AUTO_SPAWN_SCHEDULER: 'false',
|
|
28
|
+
})).toBe('eager')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('returns lazy when scheduler is enabled and OM_AUTO_SPAWN_SCHEDULER_LAZY=true', () => {
|
|
32
|
+
expect(resolveAutoSpawnSchedulerMode({
|
|
33
|
+
AUTO_SPAWN_SCHEDULER: 'true',
|
|
34
|
+
OM_AUTO_SPAWN_SCHEDULER_LAZY: 'true',
|
|
35
|
+
})).toBe('lazy')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('lazy is ignored when AUTO_SPAWN_SCHEDULER=false', () => {
|
|
39
|
+
expect(resolveAutoSpawnSchedulerMode({
|
|
40
|
+
AUTO_SPAWN_SCHEDULER: 'false',
|
|
41
|
+
OM_AUTO_SPAWN_SCHEDULER_LAZY: 'true',
|
|
42
|
+
})).toBe('off')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('treats invalid AUTO_SPAWN_SCHEDULER values as default', () => {
|
|
46
|
+
expect(resolveAutoSpawnSchedulerMode({ AUTO_SPAWN_SCHEDULER: 'maybe' })).toBe('eager')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('resolves and clamps the lazy scheduler poll interval', () => {
|
|
50
|
+
expect(resolveLazySchedulerPollMs({})).toBe(1000)
|
|
51
|
+
expect(resolveLazySchedulerPollMs({ OM_AUTO_SPAWN_SCHEDULER_LAZY_POLL_MS: '500' })).toBe(500)
|
|
52
|
+
expect(resolveLazySchedulerPollMs({ OM_AUTO_SPAWN_SCHEDULER_LAZY_POLL_MS: '50' })).toBe(250)
|
|
53
|
+
expect(resolveLazySchedulerPollMs({ OM_AUTO_SPAWN_SCHEDULER_LAZY_POLL_MS: 'abc' })).toBe(1000)
|
|
54
|
+
expect(resolveLazySchedulerPollMs({ OM_AUTO_SPAWN_SCHEDULER_LAZY_POLL_MS: '0' })).toBe(1000)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('defaults lazy scheduler restart to enabled', () => {
|
|
58
|
+
expect(resolveLazySchedulerRestart({})).toBe(true)
|
|
59
|
+
expect(resolveLazySchedulerRestart({ OM_AUTO_SPAWN_SCHEDULER_LAZY_RESTART: 'false' })).toBe(false)
|
|
60
|
+
expect(resolveLazySchedulerRestart({ OM_AUTO_SPAWN_SCHEDULER_LAZY_RESTART: 'true' })).toBe(true)
|
|
61
|
+
expect(resolveLazySchedulerRestart({ OM_AUTO_SPAWN_SCHEDULER_LAZY_RESTART: 'maybe' })).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events'
|
|
2
|
+
import type { ChildProcess } from 'node:child_process'
|
|
3
|
+
import {
|
|
4
|
+
startLazySchedulerSupervisor,
|
|
5
|
+
type LazySchedulerSupervisorProbeFn,
|
|
6
|
+
type LazySchedulerSupervisorSpawnFn,
|
|
7
|
+
} from '../scheduler-supervisor'
|
|
8
|
+
|
|
9
|
+
type FakeChild = ChildProcess & {
|
|
10
|
+
triggerExit: (code: number | null, signal?: NodeJS.Signals | null) => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function createFakeChild(): FakeChild {
|
|
14
|
+
const child = new EventEmitter() as unknown as FakeChild
|
|
15
|
+
;(child as any).stdout = null
|
|
16
|
+
;(child as any).stderr = null
|
|
17
|
+
;(child as any).pid = Math.floor(Math.random() * 100000) + 1
|
|
18
|
+
;(child as any).killed = false
|
|
19
|
+
;(child as any).exitCode = null
|
|
20
|
+
;(child as any).signalCode = null
|
|
21
|
+
;(child as any).kill = jest.fn((signal: NodeJS.Signals = 'SIGTERM') => {
|
|
22
|
+
if ((child as any).killed) return true
|
|
23
|
+
;(child as any).killed = true
|
|
24
|
+
;(child as any).signalCode = signal
|
|
25
|
+
process.nextTick(() => child.emit('exit', null, signal))
|
|
26
|
+
return true
|
|
27
|
+
})
|
|
28
|
+
child.triggerExit = (code, signal = null) => {
|
|
29
|
+
;(child as any).exitCode = code
|
|
30
|
+
;(child as any).signalCode = signal
|
|
31
|
+
child.emit('exit', code, signal)
|
|
32
|
+
}
|
|
33
|
+
return child
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const silentLogger = { log: jest.fn(), warn: jest.fn(), error: jest.fn() }
|
|
37
|
+
|
|
38
|
+
async function flushAsync(times = 5): Promise<void> {
|
|
39
|
+
for (let i = 0; i < times; i++) {
|
|
40
|
+
// eslint-disable-next-line no-await-in-loop
|
|
41
|
+
await Promise.resolve()
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('startLazySchedulerSupervisor', () => {
|
|
46
|
+
it('does not spawn the scheduler while no enabled schedules exist', async () => {
|
|
47
|
+
const spawnFn = jest.fn() as unknown as jest.MockedFunction<LazySchedulerSupervisorSpawnFn>
|
|
48
|
+
const probeFn = jest.fn(async () => ({
|
|
49
|
+
enabledSchedules: 0,
|
|
50
|
+
dueSchedules: 0,
|
|
51
|
+
error: false,
|
|
52
|
+
})) as jest.MockedFunction<LazySchedulerSupervisorProbeFn>
|
|
53
|
+
|
|
54
|
+
const handle = startLazySchedulerSupervisor({
|
|
55
|
+
mercatoBin: '/tmp/mercato',
|
|
56
|
+
appDir: '/tmp/app',
|
|
57
|
+
runtimeEnv: { ...process.env },
|
|
58
|
+
pollMs: 250,
|
|
59
|
+
restartOnUnexpectedExit: true,
|
|
60
|
+
spawnFn,
|
|
61
|
+
probeFn,
|
|
62
|
+
logger: silentLogger,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
await flushAsync(20)
|
|
66
|
+
expect(probeFn).toHaveBeenCalled()
|
|
67
|
+
expect(spawnFn).not.toHaveBeenCalled()
|
|
68
|
+
expect(handle.started).toBe(false)
|
|
69
|
+
|
|
70
|
+
await handle.close()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('spawns the existing scheduler command after an enabled schedule appears', async () => {
|
|
74
|
+
const child = createFakeChild()
|
|
75
|
+
const spawnFn = jest.fn(() => child) as unknown as jest.MockedFunction<LazySchedulerSupervisorSpawnFn>
|
|
76
|
+
const probeFn = jest.fn(async () => ({
|
|
77
|
+
enabledSchedules: 1,
|
|
78
|
+
dueSchedules: 0,
|
|
79
|
+
error: false,
|
|
80
|
+
})) as jest.MockedFunction<LazySchedulerSupervisorProbeFn>
|
|
81
|
+
|
|
82
|
+
const handle = startLazySchedulerSupervisor({
|
|
83
|
+
mercatoBin: '/tmp/mercato',
|
|
84
|
+
appDir: '/tmp/app',
|
|
85
|
+
runtimeEnv: { ...process.env },
|
|
86
|
+
pollMs: 250,
|
|
87
|
+
restartOnUnexpectedExit: false,
|
|
88
|
+
spawnFn,
|
|
89
|
+
probeFn,
|
|
90
|
+
logger: silentLogger,
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
await flushAsync(20)
|
|
94
|
+
|
|
95
|
+
expect(spawnFn).toHaveBeenCalledTimes(1)
|
|
96
|
+
const [command, args] = spawnFn.mock.calls[0]
|
|
97
|
+
expect(command).toBe('node')
|
|
98
|
+
expect(args).toEqual(['/tmp/mercato', 'scheduler', 'start'])
|
|
99
|
+
expect(handle.started).toBe(true)
|
|
100
|
+
|
|
101
|
+
await flushAsync(20)
|
|
102
|
+
expect(spawnFn).toHaveBeenCalledTimes(1)
|
|
103
|
+
|
|
104
|
+
await handle.close()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('does not spawn when the probe reports an error', async () => {
|
|
108
|
+
const spawnFn = jest.fn() as unknown as jest.MockedFunction<LazySchedulerSupervisorSpawnFn>
|
|
109
|
+
const probeFn = jest.fn(async () => ({
|
|
110
|
+
enabledSchedules: 0,
|
|
111
|
+
dueSchedules: 0,
|
|
112
|
+
error: true,
|
|
113
|
+
errorMessage: 'database unavailable',
|
|
114
|
+
})) as jest.MockedFunction<LazySchedulerSupervisorProbeFn>
|
|
115
|
+
|
|
116
|
+
const handle = startLazySchedulerSupervisor({
|
|
117
|
+
mercatoBin: '/tmp/mercato',
|
|
118
|
+
appDir: '/tmp/app',
|
|
119
|
+
runtimeEnv: { ...process.env },
|
|
120
|
+
pollMs: 250,
|
|
121
|
+
restartOnUnexpectedExit: true,
|
|
122
|
+
spawnFn,
|
|
123
|
+
probeFn,
|
|
124
|
+
logger: silentLogger,
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
await flushAsync(20)
|
|
128
|
+
expect(spawnFn).not.toHaveBeenCalled()
|
|
129
|
+
|
|
130
|
+
await handle.close()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('terminates the scheduler child on close()', async () => {
|
|
134
|
+
const child = createFakeChild()
|
|
135
|
+
const spawnFn = jest.fn(() => child) as unknown as jest.MockedFunction<LazySchedulerSupervisorSpawnFn>
|
|
136
|
+
const probeFn = jest.fn(async () => ({
|
|
137
|
+
enabledSchedules: 1,
|
|
138
|
+
dueSchedules: 1,
|
|
139
|
+
error: false,
|
|
140
|
+
})) as jest.MockedFunction<LazySchedulerSupervisorProbeFn>
|
|
141
|
+
|
|
142
|
+
const handle = startLazySchedulerSupervisor({
|
|
143
|
+
mercatoBin: '/tmp/mercato',
|
|
144
|
+
appDir: '/tmp/app',
|
|
145
|
+
runtimeEnv: { ...process.env },
|
|
146
|
+
pollMs: 250,
|
|
147
|
+
restartOnUnexpectedExit: true,
|
|
148
|
+
spawnFn,
|
|
149
|
+
probeFn,
|
|
150
|
+
logger: silentLogger,
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
await flushAsync(20)
|
|
154
|
+
await handle.close()
|
|
155
|
+
|
|
156
|
+
expect((child as any).kill).toHaveBeenCalledWith('SIGTERM')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('restarts an unexpectedly exited scheduler only while schedules still exist', async () => {
|
|
160
|
+
const firstChild = createFakeChild()
|
|
161
|
+
const secondChild = createFakeChild()
|
|
162
|
+
const spawnFn = jest
|
|
163
|
+
.fn()
|
|
164
|
+
.mockImplementationOnce(() => firstChild)
|
|
165
|
+
.mockImplementationOnce(() => secondChild) as unknown as jest.MockedFunction<LazySchedulerSupervisorSpawnFn>
|
|
166
|
+
|
|
167
|
+
let enabledSchedules = 1
|
|
168
|
+
const probeFn = jest.fn(async () => ({
|
|
169
|
+
enabledSchedules,
|
|
170
|
+
dueSchedules: enabledSchedules,
|
|
171
|
+
error: false,
|
|
172
|
+
})) as jest.MockedFunction<LazySchedulerSupervisorProbeFn>
|
|
173
|
+
|
|
174
|
+
const handle = startLazySchedulerSupervisor({
|
|
175
|
+
mercatoBin: '/tmp/mercato',
|
|
176
|
+
appDir: '/tmp/app',
|
|
177
|
+
runtimeEnv: { ...process.env },
|
|
178
|
+
pollMs: 250,
|
|
179
|
+
restartOnUnexpectedExit: true,
|
|
180
|
+
spawnFn,
|
|
181
|
+
probeFn,
|
|
182
|
+
logger: silentLogger,
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
await flushAsync(20)
|
|
186
|
+
expect(spawnFn).toHaveBeenCalledTimes(1)
|
|
187
|
+
|
|
188
|
+
firstChild.triggerExit(1, null)
|
|
189
|
+
await flushAsync(40)
|
|
190
|
+
expect(spawnFn).toHaveBeenCalledTimes(2)
|
|
191
|
+
|
|
192
|
+
enabledSchedules = 0
|
|
193
|
+
secondChild.triggerExit(1, null)
|
|
194
|
+
await flushAsync(40)
|
|
195
|
+
expect(spawnFn).toHaveBeenCalledTimes(2)
|
|
196
|
+
|
|
197
|
+
await handle.close()
|
|
198
|
+
})
|
|
199
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
|
|
2
|
+
|
|
3
|
+
export type AutoSpawnSchedulerMode = 'off' | 'eager' | 'lazy'
|
|
4
|
+
|
|
5
|
+
export type AutoSpawnSchedulerEnvSource = Pick<NodeJS.ProcessEnv, string> | Record<string, string | undefined>
|
|
6
|
+
|
|
7
|
+
const DEFAULT_LAZY_SCHEDULER_POLL_MS = 1000
|
|
8
|
+
const MIN_LAZY_SCHEDULER_POLL_MS = 250
|
|
9
|
+
|
|
10
|
+
export function resolveAutoSpawnSchedulerEnabled(env: AutoSpawnSchedulerEnvSource = process.env): boolean {
|
|
11
|
+
const legacy = parseBooleanToken(env.AUTO_SPAWN_SCHEDULER)
|
|
12
|
+
if (legacy !== null) return legacy
|
|
13
|
+
const aliased = parseBooleanToken(env.OM_AUTO_SPAWN_SCHEDULER)
|
|
14
|
+
if (aliased !== null) return aliased
|
|
15
|
+
return true
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function resolveAutoSpawnSchedulerLazy(env: AutoSpawnSchedulerEnvSource = process.env): boolean {
|
|
19
|
+
return parseBooleanToken(env.OM_AUTO_SPAWN_SCHEDULER_LAZY) === true
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveAutoSpawnSchedulerMode(env: AutoSpawnSchedulerEnvSource = process.env): AutoSpawnSchedulerMode {
|
|
23
|
+
if (!resolveAutoSpawnSchedulerEnabled(env)) return 'off'
|
|
24
|
+
if (resolveAutoSpawnSchedulerLazy(env)) return 'lazy'
|
|
25
|
+
return 'eager'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveLazySchedulerPollMs(env: AutoSpawnSchedulerEnvSource = process.env): number {
|
|
29
|
+
const raw = env.OM_AUTO_SPAWN_SCHEDULER_LAZY_POLL_MS
|
|
30
|
+
if (typeof raw !== 'string' || raw.trim() === '') return DEFAULT_LAZY_SCHEDULER_POLL_MS
|
|
31
|
+
const parsed = Number(raw)
|
|
32
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_LAZY_SCHEDULER_POLL_MS
|
|
33
|
+
return Math.max(MIN_LAZY_SCHEDULER_POLL_MS, Math.floor(parsed))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function resolveLazySchedulerRestart(env: AutoSpawnSchedulerEnvSource = process.env): boolean {
|
|
37
|
+
const parsed = parseBooleanToken(env.OM_AUTO_SPAWN_SCHEDULER_LAZY_RESTART)
|
|
38
|
+
return parsed === null ? true : parsed
|
|
39
|
+
}
|