@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.
@@ -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 autoSpawnWorkers = process.env.AUTO_SPAWN_WORKERS !== 'false'
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 (autoSpawnWorkers) {
1786
- const discoveredWorkerQueues = [...new Set(getRegisteredCliWorkers().map((worker) => worker.queue))]
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 autoSpawnWorkers = process.env.AUTO_SPAWN_WORKERS !== 'false'
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 (autoSpawnWorkers) {
1900
- const discoveredWorkerQueues = [...new Set(getRegisteredCliWorkers().map((worker) => worker.queue))]
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'], {