@open-mercato/cli 0.6.6-develop.5586.1.c9ed1d68a8 → 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/.turbo/turbo-build.log +1 -1
- package/dist/lib/worker-connection-budget.js +63 -0
- package/dist/lib/worker-connection-budget.js.map +7 -0
- package/dist/mercato.js +40 -2
- package/dist/mercato.js.map +2 -2
- package/package.json +5 -5
- package/src/lib/__tests__/worker-connection-budget.test.ts +115 -0
- package/src/lib/worker-connection-budget.ts +131 -0
- package/src/mercato.ts +70 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/cli",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
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.
|
|
63
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
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.
|
|
73
|
+
"@open-mercato/shared": "0.6.6-develop.5588.1.a8f6c51d1f"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
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 =
|
|
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
|
|
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
|
|