@open-mercato/cli 0.6.6-develop.5558.1.748adcd5fc → 0.6.6-develop.5588.1.a8f6c51d1f

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/cli",
3
- "version": "0.6.6-develop.5558.1.748adcd5fc",
3
+ "version": "0.6.6-develop.5588.1.a8f6c51d1f",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "exports": {
@@ -59,8 +59,8 @@
59
59
  "@mikro-orm/decorators": "^7.1.4",
60
60
  "@mikro-orm/migrations": "^7.1.4",
61
61
  "@mikro-orm/postgresql": "^7.1.4",
62
- "@open-mercato/queue": "0.6.6-develop.5558.1.748adcd5fc",
63
- "@open-mercato/shared": "0.6.6-develop.5558.1.748adcd5fc",
62
+ "@open-mercato/queue": "0.6.6-develop.5588.1.a8f6c51d1f",
63
+ "@open-mercato/shared": "0.6.6-develop.5588.1.a8f6c51d1f",
64
64
  "cross-spawn": "^7.0.6",
65
65
  "pg": "8.21.0",
66
66
  "semver": "^7.8.4",
@@ -70,10 +70,10 @@
70
70
  "typescript": "^6.0.3"
71
71
  },
72
72
  "peerDependencies": {
73
- "@open-mercato/shared": "0.6.6-develop.5558.1.748adcd5fc"
73
+ "@open-mercato/shared": "0.6.6-develop.5588.1.a8f6c51d1f"
74
74
  },
75
75
  "devDependencies": {
76
- "@open-mercato/shared": "0.6.6-develop.5558.1.748adcd5fc",
76
+ "@open-mercato/shared": "0.6.6-develop.5588.1.a8f6c51d1f",
77
77
  "@types/jest": "^30.0.0",
78
78
  "jest": "^30.4.2",
79
79
  "ts-jest": "^29.4.11"
@@ -0,0 +1,115 @@
1
+ import {
2
+ planWorkerConcurrency,
3
+ resolveWorkerConnectionBudget,
4
+ } from '../worker-connection-budget'
5
+
6
+ describe('resolveWorkerConnectionBudget', () => {
7
+ it('defaults to the pool max when no override is set', () => {
8
+ expect(resolveWorkerConnectionBudget({} as NodeJS.ProcessEnv, 20)).toBe(20)
9
+ })
10
+
11
+ it('honors a positive OM_WORKERS_DB_CONNECTION_BUDGET override', () => {
12
+ expect(
13
+ resolveWorkerConnectionBudget(
14
+ { OM_WORKERS_DB_CONNECTION_BUDGET: '8' } as unknown as NodeJS.ProcessEnv,
15
+ 20,
16
+ ),
17
+ ).toBe(8)
18
+ })
19
+
20
+ it('falls back to the pool max for non-numeric or non-positive overrides', () => {
21
+ for (const value of ['0', '-3', 'abc', '']) {
22
+ expect(
23
+ resolveWorkerConnectionBudget(
24
+ { OM_WORKERS_DB_CONNECTION_BUDGET: value } as unknown as NodeJS.ProcessEnv,
25
+ 16,
26
+ ),
27
+ ).toBe(16)
28
+ }
29
+ })
30
+
31
+ it('clamps an invalid pool max to at least 1', () => {
32
+ expect(resolveWorkerConnectionBudget({} as NodeJS.ProcessEnv, 0)).toBe(1)
33
+ expect(resolveWorkerConnectionBudget({} as NodeJS.ProcessEnv, Number.NaN)).toBe(1)
34
+ })
35
+ })
36
+
37
+ describe('planWorkerConcurrency', () => {
38
+ it('passes concurrency through untouched when it fits the budget', () => {
39
+ const plan = planWorkerConcurrency(
40
+ [
41
+ { queue: 'events', concurrency: 5 },
42
+ { queue: 'vector-indexing', concurrency: 2 },
43
+ { queue: 'fulltext-indexing', concurrency: 2 },
44
+ ],
45
+ 20,
46
+ )
47
+ expect(plan.clamped).toBe(false)
48
+ expect(plan.totalEffective).toBe(9)
49
+ expect(plan.entries.map((entry) => entry.effective)).toEqual([5, 2, 2])
50
+ })
51
+
52
+ it('scales down to exactly the budget when over-subscribed', () => {
53
+ const plan = planWorkerConcurrency(
54
+ [
55
+ { queue: 'events', concurrency: 10 },
56
+ { queue: 'vector-indexing', concurrency: 10 },
57
+ { queue: 'fulltext-indexing', concurrency: 10 },
58
+ ],
59
+ 12,
60
+ )
61
+ expect(plan.clamped).toBe(true)
62
+ expect(plan.belowQueueFloor).toBe(false)
63
+ expect(plan.totalEffective).toBe(12)
64
+ for (const entry of plan.entries) {
65
+ expect(entry.effective).toBeGreaterThanOrEqual(1)
66
+ expect(entry.effective).toBeLessThanOrEqual(entry.requested)
67
+ }
68
+ })
69
+
70
+ it('keeps a floor of 1 per queue and never exceeds the request', () => {
71
+ const plan = planWorkerConcurrency(
72
+ [
73
+ { queue: 'events', concurrency: 16 },
74
+ { queue: 'vector-indexing', concurrency: 2 },
75
+ { queue: 'fulltext-indexing', concurrency: 2 },
76
+ ],
77
+ 6,
78
+ )
79
+ const byQueue = Object.fromEntries(plan.entries.map((entry) => [entry.queue, entry.effective]))
80
+ expect(byQueue['vector-indexing']).toBeGreaterThanOrEqual(1)
81
+ expect(byQueue['fulltext-indexing']).toBeGreaterThanOrEqual(1)
82
+ // The largest requester absorbs most of the remaining budget.
83
+ expect(byQueue['events']).toBeGreaterThan(byQueue['vector-indexing'])
84
+ expect(plan.totalEffective).toBe(6)
85
+ })
86
+
87
+ it('flags belowQueueFloor when the budget is smaller than the queue count', () => {
88
+ const plan = planWorkerConcurrency(
89
+ [
90
+ { queue: 'a', concurrency: 4 },
91
+ { queue: 'b', concurrency: 4 },
92
+ { queue: 'c', concurrency: 4 },
93
+ ],
94
+ 2,
95
+ )
96
+ expect(plan.clamped).toBe(true)
97
+ expect(plan.belowQueueFloor).toBe(true)
98
+ // Floor of 1 wins even though it exceeds the budget.
99
+ expect(plan.entries.every((entry) => entry.effective === 1)).toBe(true)
100
+ expect(plan.totalEffective).toBe(3)
101
+ })
102
+
103
+ it('treats zero/negative requested concurrency as a floor of 1', () => {
104
+ const plan = planWorkerConcurrency(
105
+ [
106
+ { queue: 'events', concurrency: 0 },
107
+ { queue: 'vector-indexing', concurrency: -5 },
108
+ ],
109
+ 20,
110
+ )
111
+ expect(plan.entries.map((entry) => entry.requested)).toEqual([1, 1])
112
+ expect(plan.totalEffective).toBe(2)
113
+ expect(plan.clamped).toBe(false)
114
+ })
115
+ })
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Bound the DB-connection demand of background queue workers.
3
+ *
4
+ * Since #2970/#3011 each worker job builds its own request container, so its own
5
+ * `EntityManager` checks out a dedicated pooled DB connection for the job's
6
+ * duration. The peak connection demand of `worker --all` is therefore the sum of
7
+ * every queue's concurrency. Nothing previously bounded that sum against the DB
8
+ * pool (`DB_POOL_MAX`) or the database's global `max_connections`, so a storm of
9
+ * slow/failing jobs could over-subscribe connections — thrashing on the worker's
10
+ * own acquire timeout and, across the worker + web processes, starving the
11
+ * request/onboarding path that shares the same database.
12
+ *
13
+ * These helpers derive a per-queue effective-concurrency plan whose total never
14
+ * exceeds a connection budget (defaulting to the resolved pool max), while
15
+ * guaranteeing every queue keeps at least one worker.
16
+ */
17
+
18
+ export type WorkerQueueConcurrency = {
19
+ queue: string
20
+ concurrency: number
21
+ }
22
+
23
+ export type WorkerConcurrencyPlanEntry = {
24
+ queue: string
25
+ requested: number
26
+ effective: number
27
+ }
28
+
29
+ export type WorkerConcurrencyPlan = {
30
+ /** Connection budget the plan was fitted to. */
31
+ budget: number
32
+ /** Sum of requested concurrency across all queues. */
33
+ totalRequested: number
34
+ /** Sum of effective concurrency the plan grants. */
35
+ totalEffective: number
36
+ /** True when any queue's concurrency was reduced to fit the budget. */
37
+ clamped: boolean
38
+ /**
39
+ * True when the budget is smaller than the number of queues, so the per-queue
40
+ * floor of 1 forces the total above the budget. The caller should surface this
41
+ * as a misconfiguration (raise `DB_POOL_MAX` or the budget).
42
+ */
43
+ belowQueueFloor: boolean
44
+ entries: WorkerConcurrencyPlanEntry[]
45
+ }
46
+
47
+ function toPositiveInt(value: number | undefined, fallback: number): number {
48
+ if (value === undefined || !Number.isFinite(value)) return fallback
49
+ const floored = Math.floor(value)
50
+ return floored > 0 ? floored : fallback
51
+ }
52
+
53
+ /**
54
+ * Resolve the connection budget for background workers. Defaults to the resolved
55
+ * DB pool max so the worker never tries to use more connections than its own pool
56
+ * can hand out; override with `OM_WORKERS_DB_CONNECTION_BUDGET` (positive int).
57
+ */
58
+ export function resolveWorkerConnectionBudget(
59
+ env: NodeJS.ProcessEnv,
60
+ poolMax: number,
61
+ ): number {
62
+ const safePoolMax = toPositiveInt(poolMax, 1)
63
+ const raw = env.OM_WORKERS_DB_CONNECTION_BUDGET
64
+ if (!raw) return safePoolMax
65
+ const parsed = Number.parseInt(raw, 10)
66
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : safePoolMax
67
+ }
68
+
69
+ /**
70
+ * Fit per-queue concurrency to a connection budget.
71
+ *
72
+ * - Every queue keeps a floor of 1 worker (no queue is starved).
73
+ * - No queue exceeds its requested concurrency.
74
+ * - When the sum exceeds the budget, leftover capacity is distributed greedily to
75
+ * the queues furthest below their request, so the total matches the budget
76
+ * exactly (when `budget >= queueCount`) and the allocation is deterministic.
77
+ */
78
+ export function planWorkerConcurrency(
79
+ queues: WorkerQueueConcurrency[],
80
+ budget: number,
81
+ ): WorkerConcurrencyPlan {
82
+ const safeBudget = toPositiveInt(budget, 1)
83
+ const requested = queues.map((entry) => ({
84
+ queue: entry.queue,
85
+ requested: toPositiveInt(entry.concurrency, 1),
86
+ }))
87
+ const totalRequested = requested.reduce((sum, entry) => sum + entry.requested, 0)
88
+
89
+ if (totalRequested <= safeBudget) {
90
+ return {
91
+ budget: safeBudget,
92
+ totalRequested,
93
+ totalEffective: totalRequested,
94
+ clamped: false,
95
+ belowQueueFloor: false,
96
+ entries: requested.map((entry) => ({ ...entry, effective: entry.requested })),
97
+ }
98
+ }
99
+
100
+ // Floor every queue at 1, then water-fill the remaining budget to the queues
101
+ // with the largest unmet request first.
102
+ const effective = requested.map(() => 1)
103
+ let used = effective.length
104
+ while (used < safeBudget) {
105
+ let bestIndex = -1
106
+ let bestDeficit = 0
107
+ for (let index = 0; index < requested.length; index += 1) {
108
+ const deficit = requested[index].requested - effective[index]
109
+ if (deficit > bestDeficit) {
110
+ bestDeficit = deficit
111
+ bestIndex = index
112
+ }
113
+ }
114
+ if (bestIndex === -1) break
115
+ effective[bestIndex] += 1
116
+ used += 1
117
+ }
118
+
119
+ const totalEffective = effective.reduce((sum, value) => sum + value, 0)
120
+ return {
121
+ budget: safeBudget,
122
+ totalRequested,
123
+ totalEffective,
124
+ clamped: true,
125
+ belowQueueFloor: requested.length > safeBudget,
126
+ entries: requested.map((entry, index) => ({
127
+ ...entry,
128
+ effective: effective[index],
129
+ })),
130
+ }
131
+ }
package/src/mercato.ts CHANGED
@@ -16,6 +16,11 @@ import {
16
16
  } from './lib/auto-spawn-workers'
17
17
  import { startLazyWorkerSupervisor } from './lib/queue-worker-supervisor'
18
18
  import { createPerJobWorkerHandler } from './lib/worker-job-handler'
19
+ import {
20
+ planWorkerConcurrency,
21
+ resolveWorkerConnectionBudget,
22
+ type WorkerConcurrencyPlan,
23
+ } from './lib/worker-connection-budget'
19
24
  import {
20
25
  resolveAutoSpawnSchedulerMode,
21
26
  resolveLazySchedulerPollMs,
@@ -525,6 +530,46 @@ function formatQueueWorkerLabel(queueNames: string[]): string {
525
530
  return `Queue worker (${preview})`
526
531
  }
527
532
 
533
+ /**
534
+ * Fit the requested per-queue worker concurrency to the worker process's DB
535
+ * connection budget and log the resolved plan. Since each job runs in its own
536
+ * request container (one pooled connection per in-flight job), the sum of worker
537
+ * concurrency is the worker's peak connection demand — it MUST stay within the
538
+ * pool so background jobs cannot starve the request/onboarding path that shares
539
+ * the same database.
540
+ */
541
+ async function resolveWorkerBudgetPlan(
542
+ requestedByQueue: { queue: string; concurrency: number }[],
543
+ ): Promise<WorkerConcurrencyPlan> {
544
+ const { resolvePoolConfig } = await import('@open-mercato/shared/lib/db/mikro')
545
+ const poolMax = resolvePoolConfig(process.env).poolMax
546
+ const budget = resolveWorkerConnectionBudget(process.env, poolMax)
547
+ const plan = planWorkerConcurrency(requestedByQueue, budget)
548
+
549
+ console.log(
550
+ `[worker] DB connection budget: ${plan.budget} (pool max ${poolMax}); ` +
551
+ `requested Σconcurrency ${plan.totalRequested}, effective ${plan.totalEffective}`,
552
+ )
553
+ if (plan.clamped) {
554
+ const perQueue = plan.entries
555
+ .map((entry) => `${entry.queue}=${entry.effective}/${entry.requested}`)
556
+ .join(', ')
557
+ console.warn(
558
+ `[worker] Worker concurrency clamped to fit the DB connection budget (${plan.budget}): ${perQueue}. ` +
559
+ `Raise DB_POOL_MAX or set OM_WORKERS_DB_CONNECTION_BUDGET to change this. ` +
560
+ `Keep web_pool_max + worker_pool_max + overhead <= Postgres max_connections.`,
561
+ )
562
+ }
563
+ if (plan.belowQueueFloor) {
564
+ console.warn(
565
+ `[worker] DB connection budget (${plan.budget}) is smaller than the number of queues ` +
566
+ `(${requestedByQueue.length}); every queue runs at concurrency 1 and total demand ` +
567
+ `(${plan.totalEffective}) still exceeds the budget. Raise DB_POOL_MAX.`,
568
+ )
569
+ }
570
+ return plan
571
+ }
572
+
528
573
  function lookupModuleCommand(
529
574
  allModules: Module[],
530
575
  moduleName: string,
@@ -1520,12 +1565,31 @@ export async function run(argv = process.argv) {
1520
1565
  }
1521
1566
 
1522
1567
  const { createRequestContainer } = await import('@open-mercato/shared/lib/di/container')
1568
+
1569
+ // Fit Σconcurrency to the worker's DB connection budget before
1570
+ // starting any worker, so the per-job containers (one connection each)
1571
+ // can never over-subscribe the pool the request path shares.
1572
+ const requestedByQueue = discoveredQueues.map((queue) => {
1573
+ const queueWorkers = allWorkers.filter((w) => w.queue === queue)
1574
+ return {
1575
+ queue,
1576
+ concurrency: concurrencyOverride ?? Math.max(...queueWorkers.map((w) => w.concurrency), 1),
1577
+ }
1578
+ })
1579
+ const budgetPlan = await resolveWorkerBudgetPlan(requestedByQueue)
1580
+ const effectiveByQueue = new Map(
1581
+ budgetPlan.entries.map((entry) => [entry.queue, entry.effective]),
1582
+ )
1583
+
1523
1584
  console.log(`[worker] Starting workers for all queues: ${discoveredQueues.join(', ')}`)
1524
1585
 
1525
1586
  // Start all queue workers in background mode
1526
1587
  const workerPromises = discoveredQueues.map(async (queue) => {
1527
1588
  const queueWorkers = allWorkers.filter((w) => w.queue === queue)
1528
- const concurrency = concurrencyOverride ?? Math.max(...queueWorkers.map((w) => w.concurrency), 1)
1589
+ const concurrency =
1590
+ effectiveByQueue.get(queue) ??
1591
+ concurrencyOverride ??
1592
+ Math.max(...queueWorkers.map((w) => w.concurrency), 1)
1529
1593
 
1530
1594
  console.log(`[worker] Starting "${queue}" with ${queueWorkers.length} handler(s), concurrency: ${concurrency}`)
1531
1595
 
@@ -1552,7 +1616,11 @@ export async function run(argv = process.argv) {
1552
1616
  if (queueWorkers.length > 0) {
1553
1617
  // Use discovered workers
1554
1618
  const { createRequestContainer } = await import('@open-mercato/shared/lib/di/container')
1555
- const concurrency = concurrencyOverride ?? Math.max(...queueWorkers.map((w) => w.concurrency), 1)
1619
+ const requested = concurrencyOverride ?? Math.max(...queueWorkers.map((w) => w.concurrency), 1)
1620
+ // Bound a single-queue run to the connection budget too, so it can
1621
+ // never check out more pooled connections than the worker pool holds.
1622
+ const budgetPlan = await resolveWorkerBudgetPlan([{ queue: queueName!, concurrency: requested }])
1623
+ const concurrency = budgetPlan.entries[0]?.effective ?? requested
1556
1624
 
1557
1625
  console.log(`[worker] Found ${queueWorkers.length} worker(s) for queue "${queueName}"`)
1558
1626