@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
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy queue worker supervisor.
|
|
3
|
+
*
|
|
4
|
+
* Replaces `mercato queue worker --all` with a lightweight watcher that
|
|
5
|
+
* starts a per-queue worker process only after the first ready job appears
|
|
6
|
+
* on that queue. Discovered queues with no jobs stay completely idle.
|
|
7
|
+
*
|
|
8
|
+
* Design:
|
|
9
|
+
* - Probes every queue on a configurable interval using
|
|
10
|
+
* `getQueuePendingProbe()` from `@open-mercato/queue`.
|
|
11
|
+
* - The first non-error probe with `ready > 0` triggers `spawn('node', [
|
|
12
|
+
* mercatoBin, 'queue', 'worker', queueName ])`.
|
|
13
|
+
* - Each spawned child inherits stdio so its logs flow through the
|
|
14
|
+
* surrounding dev runtime.
|
|
15
|
+
* - On unexpected exit while jobs are still pending, the supervisor
|
|
16
|
+
* restarts the worker if `restartOnUnexpectedExit` is enabled.
|
|
17
|
+
* - On `close()`, all active children receive SIGTERM and the supervisor
|
|
18
|
+
* waits for them to exit before resolving.
|
|
19
|
+
*
|
|
20
|
+
* Probes never invoke handlers, never create BullMQ Workers, and never
|
|
21
|
+
* import generated worker handler modules. The contract is preserved by
|
|
22
|
+
* `getQueuePendingProbe()`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { spawn as nodeSpawn } from 'node:child_process'
|
|
26
|
+
import type { ChildProcess, SpawnOptions } from 'node:child_process'
|
|
27
|
+
import {
|
|
28
|
+
getQueuePendingProbe,
|
|
29
|
+
type QueuePendingProbeResult,
|
|
30
|
+
type QueueStrategyType,
|
|
31
|
+
} from '@open-mercato/queue'
|
|
32
|
+
import type { ModuleWorker } from '@open-mercato/shared/modules/registry'
|
|
33
|
+
|
|
34
|
+
export type LazySupervisorSpawnFn = (
|
|
35
|
+
command: string,
|
|
36
|
+
args: readonly string[],
|
|
37
|
+
options: SpawnOptions,
|
|
38
|
+
) => ChildProcess
|
|
39
|
+
|
|
40
|
+
export type LazySupervisorProbeFn = (
|
|
41
|
+
queueName: string,
|
|
42
|
+
strategy: QueueStrategyType,
|
|
43
|
+
) => Promise<QueuePendingProbeResult>
|
|
44
|
+
|
|
45
|
+
export type LazyWorkerSupervisorOptions = {
|
|
46
|
+
mercatoBin: string
|
|
47
|
+
appDir: string
|
|
48
|
+
runtimeEnv: NodeJS.ProcessEnv
|
|
49
|
+
workers: ModuleWorker[]
|
|
50
|
+
pollMs: number
|
|
51
|
+
restartOnUnexpectedExit: boolean
|
|
52
|
+
strategy?: QueueStrategyType
|
|
53
|
+
/** Override for tests. Defaults to `child_process.spawn`. */
|
|
54
|
+
spawnFn?: LazySupervisorSpawnFn
|
|
55
|
+
/** Override for tests. Defaults to `getQueuePendingProbe`. */
|
|
56
|
+
probeFn?: LazySupervisorProbeFn
|
|
57
|
+
/** Logger override. Defaults to `console`. */
|
|
58
|
+
logger?: Pick<Console, 'log' | 'warn' | 'error'>
|
|
59
|
+
onSpawn?: (queueName: string, child: ChildProcess) => void
|
|
60
|
+
onChildExit?: (
|
|
61
|
+
queueName: string,
|
|
62
|
+
code: number | null,
|
|
63
|
+
signal: NodeJS.Signals | null,
|
|
64
|
+
) => void
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type LazyWorkerSupervisorHandle = {
|
|
68
|
+
/** Set of queue names whose worker has started at least once. */
|
|
69
|
+
readonly startedQueues: ReadonlySet<string>
|
|
70
|
+
/** Currently running child process per queue (if any). */
|
|
71
|
+
getActiveChild(queueName: string): ChildProcess | undefined
|
|
72
|
+
/** Stop polling, kill children, wait for exit. Idempotent. */
|
|
73
|
+
close(): Promise<void>
|
|
74
|
+
/** Resolves when the watcher loop has stopped (after `close()`). */
|
|
75
|
+
done: Promise<void>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type QueueGroup = {
|
|
79
|
+
queueName: string
|
|
80
|
+
concurrency: number
|
|
81
|
+
workerCount: number
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function groupWorkersByQueue(workers: ModuleWorker[]): QueueGroup[] {
|
|
85
|
+
const groups = new Map<string, QueueGroup>()
|
|
86
|
+
for (const worker of workers) {
|
|
87
|
+
const existing = groups.get(worker.queue)
|
|
88
|
+
if (existing) {
|
|
89
|
+
existing.workerCount += 1
|
|
90
|
+
if (worker.concurrency > existing.concurrency) {
|
|
91
|
+
existing.concurrency = worker.concurrency
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
groups.set(worker.queue, {
|
|
95
|
+
queueName: worker.queue,
|
|
96
|
+
concurrency: Math.max(worker.concurrency, 1),
|
|
97
|
+
workerCount: 1,
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return [...groups.values()].sort((a, b) => a.queueName.localeCompare(b.queueName))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function resolveStrategyFromEnv(env: NodeJS.ProcessEnv): QueueStrategyType {
|
|
105
|
+
return env.QUEUE_STRATEGY === 'async' ? 'async' : 'local'
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const PROBE_ERROR_LOG_THROTTLE_MS = 60_000
|
|
109
|
+
|
|
110
|
+
export function startLazyWorkerSupervisor(
|
|
111
|
+
options: LazyWorkerSupervisorOptions,
|
|
112
|
+
): LazyWorkerSupervisorHandle {
|
|
113
|
+
const logger = options.logger ?? console
|
|
114
|
+
const strategy: QueueStrategyType = options.strategy ?? resolveStrategyFromEnv(options.runtimeEnv)
|
|
115
|
+
const probe: LazySupervisorProbeFn = options.probeFn
|
|
116
|
+
?? ((queueName) => getQueuePendingProbe(queueName, strategy))
|
|
117
|
+
const groups = groupWorkersByQueue(options.workers)
|
|
118
|
+
|
|
119
|
+
const startedQueues = new Set<string>()
|
|
120
|
+
const activeChildren = new Map<string, ChildProcess>()
|
|
121
|
+
const startingQueues = new Set<string>()
|
|
122
|
+
const probeErrorLastLoggedAt = new Map<string, number>()
|
|
123
|
+
|
|
124
|
+
let stopping = false
|
|
125
|
+
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
|
126
|
+
let watchPromiseResolve: (() => void) | null = null
|
|
127
|
+
const done = new Promise<void>((resolve) => {
|
|
128
|
+
watchPromiseResolve = resolve
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
if (groups.length === 0) {
|
|
132
|
+
logger.warn(
|
|
133
|
+
'[lazy-supervisor] No queues discovered. Idle until queues are registered or run `yarn generate`.',
|
|
134
|
+
)
|
|
135
|
+
} else {
|
|
136
|
+
logger.log(
|
|
137
|
+
`[lazy-supervisor] Watching ${groups.length} queue(s): ${groups
|
|
138
|
+
.map((g) => g.queueName)
|
|
139
|
+
.join(', ')}`,
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const spawnFn: LazySupervisorSpawnFn =
|
|
144
|
+
options.spawnFn ?? ((command, args, opts) => nodeSpawn(command, args as string[], opts))
|
|
145
|
+
|
|
146
|
+
function logProbeError(queueName: string, message: string): void {
|
|
147
|
+
const now = Date.now()
|
|
148
|
+
const lastAt = probeErrorLastLoggedAt.get(queueName) ?? 0
|
|
149
|
+
if (now - lastAt < PROBE_ERROR_LOG_THROTTLE_MS) return
|
|
150
|
+
probeErrorLastLoggedAt.set(queueName, now)
|
|
151
|
+
logger.warn(`[lazy-supervisor] Probe failed for queue "${queueName}": ${message}`)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function spawnQueueWorker(queueName: string): void {
|
|
155
|
+
if (stopping) return
|
|
156
|
+
if (activeChildren.has(queueName)) return
|
|
157
|
+
if (startingQueues.has(queueName)) return
|
|
158
|
+
startingQueues.add(queueName)
|
|
159
|
+
|
|
160
|
+
logger.log(`[lazy-supervisor] Pending job detected — starting worker for queue "${queueName}"`)
|
|
161
|
+
let child: ChildProcess
|
|
162
|
+
try {
|
|
163
|
+
child = spawnFn(
|
|
164
|
+
'node',
|
|
165
|
+
[options.mercatoBin, 'queue', 'worker', queueName],
|
|
166
|
+
{
|
|
167
|
+
stdio: 'inherit',
|
|
168
|
+
env: options.runtimeEnv,
|
|
169
|
+
cwd: options.appDir,
|
|
170
|
+
},
|
|
171
|
+
)
|
|
172
|
+
} catch (err) {
|
|
173
|
+
startingQueues.delete(queueName)
|
|
174
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
175
|
+
logger.error(`[lazy-supervisor] Failed to spawn worker for "${queueName}": ${message}`)
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
activeChildren.set(queueName, child)
|
|
180
|
+
startedQueues.add(queueName)
|
|
181
|
+
startingQueues.delete(queueName)
|
|
182
|
+
if (options.onSpawn) {
|
|
183
|
+
try {
|
|
184
|
+
options.onSpawn(queueName, child)
|
|
185
|
+
} catch (err) {
|
|
186
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
187
|
+
logger.warn(`[lazy-supervisor] onSpawn callback threw for "${queueName}": ${message}`)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
child.on('exit', (code, signal) => {
|
|
192
|
+
activeChildren.delete(queueName)
|
|
193
|
+
if (options.onChildExit) {
|
|
194
|
+
try {
|
|
195
|
+
options.onChildExit(queueName, code, signal)
|
|
196
|
+
} catch (err) {
|
|
197
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
198
|
+
logger.warn(`[lazy-supervisor] onChildExit callback threw for "${queueName}": ${message}`)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (stopping) return
|
|
202
|
+
const expected = signal === 'SIGTERM' || signal === 'SIGINT'
|
|
203
|
+
if (expected) return
|
|
204
|
+
const reason = code !== null ? `code ${code}` : `signal ${signal ?? 'unknown'}`
|
|
205
|
+
logger.warn(`[lazy-supervisor] Worker for "${queueName}" exited (${reason}).`)
|
|
206
|
+
if (!options.restartOnUnexpectedExit) return
|
|
207
|
+
void (async () => {
|
|
208
|
+
const result = await safeProbe(queueName)
|
|
209
|
+
if (!result || result.error) return
|
|
210
|
+
if (result.ready > 0) {
|
|
211
|
+
logger.warn(`[lazy-supervisor] Restarting worker for "${queueName}" because jobs remain pending.`)
|
|
212
|
+
spawnQueueWorker(queueName)
|
|
213
|
+
}
|
|
214
|
+
})()
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function safeProbe(queueName: string): Promise<QueuePendingProbeResult | null> {
|
|
219
|
+
try {
|
|
220
|
+
return await probe(queueName, strategy)
|
|
221
|
+
} catch (err) {
|
|
222
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
223
|
+
logProbeError(queueName, message)
|
|
224
|
+
return null
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function tickOnce(): Promise<void> {
|
|
229
|
+
if (stopping) return
|
|
230
|
+
if (groups.length === 0) return
|
|
231
|
+
|
|
232
|
+
const toCheck = groups.filter((g) => !activeChildren.has(g.queueName) && !startingQueues.has(g.queueName))
|
|
233
|
+
if (toCheck.length === 0) return
|
|
234
|
+
|
|
235
|
+
const probes = await Promise.all(
|
|
236
|
+
toCheck.map(async (group) => ({
|
|
237
|
+
group,
|
|
238
|
+
result: await safeProbe(group.queueName),
|
|
239
|
+
})),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if (stopping) return
|
|
243
|
+
|
|
244
|
+
for (const { group, result } of probes) {
|
|
245
|
+
if (!result) continue
|
|
246
|
+
if (result.error) {
|
|
247
|
+
if (result.errorMessage) {
|
|
248
|
+
logProbeError(group.queueName, result.errorMessage)
|
|
249
|
+
}
|
|
250
|
+
continue
|
|
251
|
+
}
|
|
252
|
+
if (result.ready > 0) {
|
|
253
|
+
spawnQueueWorker(group.queueName)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function scheduleNext(): void {
|
|
259
|
+
if (stopping) return
|
|
260
|
+
pollTimer = setTimeout(() => {
|
|
261
|
+
void (async () => {
|
|
262
|
+
try {
|
|
263
|
+
await tickOnce()
|
|
264
|
+
} catch (err) {
|
|
265
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
266
|
+
logger.warn(`[lazy-supervisor] Poll cycle failed: ${message}`)
|
|
267
|
+
} finally {
|
|
268
|
+
if (!stopping) scheduleNext()
|
|
269
|
+
}
|
|
270
|
+
})()
|
|
271
|
+
}, options.pollMs)
|
|
272
|
+
pollTimer.unref?.()
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Kick off immediately so the first probe doesn't wait one full poll interval.
|
|
276
|
+
void (async () => {
|
|
277
|
+
try {
|
|
278
|
+
await tickOnce()
|
|
279
|
+
} catch (err) {
|
|
280
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
281
|
+
logger.warn(`[lazy-supervisor] Initial poll failed: ${message}`)
|
|
282
|
+
} finally {
|
|
283
|
+
scheduleNext()
|
|
284
|
+
}
|
|
285
|
+
})()
|
|
286
|
+
|
|
287
|
+
async function close(): Promise<void> {
|
|
288
|
+
if (stopping) {
|
|
289
|
+
await done
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
stopping = true
|
|
293
|
+
if (pollTimer) {
|
|
294
|
+
clearTimeout(pollTimer)
|
|
295
|
+
pollTimer = null
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const exitWaiters: Promise<void>[] = []
|
|
299
|
+
for (const [queueName, child] of activeChildren) {
|
|
300
|
+
exitWaiters.push(
|
|
301
|
+
new Promise<void>((resolve) => {
|
|
302
|
+
if (child.exitCode !== null || child.signalCode !== null) return resolve()
|
|
303
|
+
child.once('exit', () => resolve())
|
|
304
|
+
}),
|
|
305
|
+
)
|
|
306
|
+
try {
|
|
307
|
+
if (!child.killed) child.kill('SIGTERM')
|
|
308
|
+
} catch (err) {
|
|
309
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
310
|
+
logger.warn(`[lazy-supervisor] Failed to send SIGTERM to "${queueName}": ${message}`)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
await Promise.all(exitWaiters)
|
|
314
|
+
activeChildren.clear()
|
|
315
|
+
if (watchPromiseResolve) {
|
|
316
|
+
watchPromiseResolve()
|
|
317
|
+
watchPromiseResolve = null
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
startedQueues,
|
|
323
|
+
getActiveChild: (queueName) => activeChildren.get(queueName),
|
|
324
|
+
close,
|
|
325
|
+
done,
|
|
326
|
+
}
|
|
327
|
+
}
|
package/src/mercato.ts
CHANGED
|
@@ -9,6 +9,12 @@ import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
|
|
|
9
9
|
import { getSslConfig } from '@open-mercato/shared/lib/db/ssl'
|
|
10
10
|
import { getRedisUrl, getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'
|
|
11
11
|
import { resolveInitDerivedSecrets } from './lib/init-secrets'
|
|
12
|
+
import {
|
|
13
|
+
resolveAutoSpawnWorkersMode,
|
|
14
|
+
resolveLazyPollMs,
|
|
15
|
+
resolveLazyRestart,
|
|
16
|
+
} from './lib/auto-spawn-workers'
|
|
17
|
+
import { startLazyWorkerSupervisor } from './lib/queue-worker-supervisor'
|
|
12
18
|
import { parseModuleInstallArgs } from './lib/module-install-args'
|
|
13
19
|
import { resolveNextBuildIdCandidate } from './lib/next-build-id'
|
|
14
20
|
import { acquireServerStartLock } from './lib/server-start-lock'
|
|
@@ -1660,6 +1666,7 @@ export async function run(argv = process.argv) {
|
|
|
1660
1666
|
let didRetryCorruptedTurbopackCache = false
|
|
1661
1667
|
let stopping = false
|
|
1662
1668
|
let envChangePromiseResolve: ((result: DevServerRestartResult) => void) | null = null
|
|
1669
|
+
let activeLazySupervisor: ReturnType<typeof startLazyWorkerSupervisor> | null = null
|
|
1663
1670
|
const envReloader = createDevEnvReloader(appDir, process.env, initialProcessEnvironmentEntries)
|
|
1664
1671
|
|
|
1665
1672
|
function cleanup() {
|
|
@@ -1669,6 +1676,9 @@ export async function run(argv = process.argv) {
|
|
|
1669
1676
|
proc.kill('SIGTERM')
|
|
1670
1677
|
}
|
|
1671
1678
|
}
|
|
1679
|
+
if (activeLazySupervisor) {
|
|
1680
|
+
void activeLazySupervisor.close().catch(() => undefined)
|
|
1681
|
+
}
|
|
1672
1682
|
}
|
|
1673
1683
|
|
|
1674
1684
|
async function cleanupAndWait() {
|
|
@@ -1683,6 +1693,14 @@ export async function run(argv = process.argv) {
|
|
|
1683
1693
|
})
|
|
1684
1694
|
)
|
|
1685
1695
|
)
|
|
1696
|
+
if (activeLazySupervisor) {
|
|
1697
|
+
try {
|
|
1698
|
+
await activeLazySupervisor.close()
|
|
1699
|
+
} catch {
|
|
1700
|
+
// Supervisor close errors should not block dev runtime cleanup.
|
|
1701
|
+
}
|
|
1702
|
+
activeLazySupervisor = null
|
|
1703
|
+
}
|
|
1686
1704
|
// Safety net: remove Next.js dev lock file in case the child didn't clean up
|
|
1687
1705
|
const lockFile = path.join(appDir, '.mercato', 'next', 'dev', 'lock')
|
|
1688
1706
|
try {
|
|
@@ -1772,7 +1790,7 @@ export async function run(argv = process.argv) {
|
|
|
1772
1790
|
while (!stopping) {
|
|
1773
1791
|
envReloader.reload()
|
|
1774
1792
|
const runtimeEnv = buildServerProcessEnvironment(process.env)
|
|
1775
|
-
const
|
|
1793
|
+
const autoSpawnWorkersMode = resolveAutoSpawnWorkersMode(process.env)
|
|
1776
1794
|
const autoSpawnScheduler = process.env.AUTO_SPAWN_SCHEDULER !== 'false'
|
|
1777
1795
|
const queueStrategy = process.env.QUEUE_STRATEGY || 'local'
|
|
1778
1796
|
const schedulerCommand = lookupModuleCommand(getCliModules(), 'scheduler', 'start')
|
|
@@ -1782,10 +1800,21 @@ export async function run(argv = process.argv) {
|
|
|
1782
1800
|
]
|
|
1783
1801
|
|
|
1784
1802
|
// Start workers if enabled
|
|
1785
|
-
if (
|
|
1786
|
-
const
|
|
1803
|
+
if (autoSpawnWorkersMode !== 'off') {
|
|
1804
|
+
const discoveredWorkers = getRegisteredCliWorkers()
|
|
1805
|
+
const discoveredWorkerQueues = [...new Set(discoveredWorkers.map((worker) => worker.queue))]
|
|
1787
1806
|
if (discoveredWorkerQueues.length === 0) {
|
|
1788
1807
|
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.')
|
|
1808
|
+
} else if (autoSpawnWorkersMode === 'lazy') {
|
|
1809
|
+
console.log(`[server] Lazy worker auto-spawn enabled — workers will start on first job (${discoveredWorkerQueues.length} queue(s) watched).`)
|
|
1810
|
+
activeLazySupervisor = startLazyWorkerSupervisor({
|
|
1811
|
+
mercatoBin,
|
|
1812
|
+
appDir,
|
|
1813
|
+
runtimeEnv,
|
|
1814
|
+
workers: discoveredWorkers,
|
|
1815
|
+
pollMs: resolveLazyPollMs(process.env),
|
|
1816
|
+
restartOnUnexpectedExit: resolveLazyRestart(process.env),
|
|
1817
|
+
})
|
|
1789
1818
|
} else {
|
|
1790
1819
|
console.log('[server] Starting workers for all queues...')
|
|
1791
1820
|
const workerProcess = spawn('node', [mercatoBin, 'queue', 'worker', '--all'], {
|
|
@@ -1843,7 +1872,7 @@ export async function run(argv = process.argv) {
|
|
|
1843
1872
|
const nodeModulesBases = Array.from(new Set([env.rootDir, appDir]))
|
|
1844
1873
|
|
|
1845
1874
|
const processes: ChildProcess[] = []
|
|
1846
|
-
const
|
|
1875
|
+
const autoSpawnWorkersMode = resolveAutoSpawnWorkersMode(process.env)
|
|
1847
1876
|
const autoSpawnScheduler = process.env.AUTO_SPAWN_SCHEDULER !== 'false'
|
|
1848
1877
|
const queueStrategy = process.env.QUEUE_STRATEGY || 'local'
|
|
1849
1878
|
const runtimeEnv = buildServerProcessEnvironment(process.env)
|
|
@@ -1851,6 +1880,7 @@ export async function run(argv = process.argv) {
|
|
|
1851
1880
|
const serverStartLock = acquireServerStartLock(appDir, {
|
|
1852
1881
|
port: runtimeEnv.PORT ?? process.env.PORT ?? null,
|
|
1853
1882
|
})
|
|
1883
|
+
let activeLazySupervisor: ReturnType<typeof startLazyWorkerSupervisor> | null = null
|
|
1854
1884
|
|
|
1855
1885
|
function cleanup() {
|
|
1856
1886
|
console.log('[server] Shutting down...')
|
|
@@ -1859,6 +1889,9 @@ export async function run(argv = process.argv) {
|
|
|
1859
1889
|
proc.kill('SIGTERM')
|
|
1860
1890
|
}
|
|
1861
1891
|
}
|
|
1892
|
+
if (activeLazySupervisor) {
|
|
1893
|
+
void activeLazySupervisor.close().catch(() => undefined)
|
|
1894
|
+
}
|
|
1862
1895
|
}
|
|
1863
1896
|
|
|
1864
1897
|
async function cleanupAndWait() {
|
|
@@ -1872,6 +1905,14 @@ export async function run(argv = process.argv) {
|
|
|
1872
1905
|
})
|
|
1873
1906
|
)
|
|
1874
1907
|
)
|
|
1908
|
+
if (activeLazySupervisor) {
|
|
1909
|
+
try {
|
|
1910
|
+
await activeLazySupervisor.close()
|
|
1911
|
+
} catch {
|
|
1912
|
+
// Supervisor close errors should not block server shutdown.
|
|
1913
|
+
}
|
|
1914
|
+
activeLazySupervisor = null
|
|
1915
|
+
}
|
|
1875
1916
|
}
|
|
1876
1917
|
|
|
1877
1918
|
process.on('SIGTERM', cleanup)
|
|
@@ -1896,10 +1937,21 @@ export async function run(argv = process.argv) {
|
|
|
1896
1937
|
]
|
|
1897
1938
|
|
|
1898
1939
|
// Start workers if enabled
|
|
1899
|
-
if (
|
|
1900
|
-
const
|
|
1940
|
+
if (autoSpawnWorkersMode !== 'off') {
|
|
1941
|
+
const discoveredWorkers = getRegisteredCliWorkers()
|
|
1942
|
+
const discoveredWorkerQueues = [...new Set(discoveredWorkers.map((worker) => worker.queue))]
|
|
1901
1943
|
if (discoveredWorkerQueues.length === 0) {
|
|
1902
1944
|
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.')
|
|
1945
|
+
} else if (autoSpawnWorkersMode === 'lazy') {
|
|
1946
|
+
console.log(`[server] Lazy worker auto-spawn enabled — workers will start on first job (${discoveredWorkerQueues.length} queue(s) watched).`)
|
|
1947
|
+
activeLazySupervisor = startLazyWorkerSupervisor({
|
|
1948
|
+
mercatoBin,
|
|
1949
|
+
appDir,
|
|
1950
|
+
runtimeEnv,
|
|
1951
|
+
workers: discoveredWorkers,
|
|
1952
|
+
pollMs: resolveLazyPollMs(process.env),
|
|
1953
|
+
restartOnUnexpectedExit: resolveLazyRestart(process.env),
|
|
1954
|
+
})
|
|
1903
1955
|
} else {
|
|
1904
1956
|
console.log('[server] Starting workers for all queues...')
|
|
1905
1957
|
const workerProcess = spawn('node', [mercatoBin, 'queue', 'worker', '--all'], {
|