@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/.turbo/turbo-build.log
CHANGED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
function toPositiveInt(value, fallback) {
|
|
2
|
+
if (value === void 0 || !Number.isFinite(value)) return fallback;
|
|
3
|
+
const floored = Math.floor(value);
|
|
4
|
+
return floored > 0 ? floored : fallback;
|
|
5
|
+
}
|
|
6
|
+
function resolveWorkerConnectionBudget(env, poolMax) {
|
|
7
|
+
const safePoolMax = toPositiveInt(poolMax, 1);
|
|
8
|
+
const raw = env.OM_WORKERS_DB_CONNECTION_BUDGET;
|
|
9
|
+
if (!raw) return safePoolMax;
|
|
10
|
+
const parsed = Number.parseInt(raw, 10);
|
|
11
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : safePoolMax;
|
|
12
|
+
}
|
|
13
|
+
function planWorkerConcurrency(queues, budget) {
|
|
14
|
+
const safeBudget = toPositiveInt(budget, 1);
|
|
15
|
+
const requested = queues.map((entry) => ({
|
|
16
|
+
queue: entry.queue,
|
|
17
|
+
requested: toPositiveInt(entry.concurrency, 1)
|
|
18
|
+
}));
|
|
19
|
+
const totalRequested = requested.reduce((sum, entry) => sum + entry.requested, 0);
|
|
20
|
+
if (totalRequested <= safeBudget) {
|
|
21
|
+
return {
|
|
22
|
+
budget: safeBudget,
|
|
23
|
+
totalRequested,
|
|
24
|
+
totalEffective: totalRequested,
|
|
25
|
+
clamped: false,
|
|
26
|
+
belowQueueFloor: false,
|
|
27
|
+
entries: requested.map((entry) => ({ ...entry, effective: entry.requested }))
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const effective = requested.map(() => 1);
|
|
31
|
+
let used = effective.length;
|
|
32
|
+
while (used < safeBudget) {
|
|
33
|
+
let bestIndex = -1;
|
|
34
|
+
let bestDeficit = 0;
|
|
35
|
+
for (let index = 0; index < requested.length; index += 1) {
|
|
36
|
+
const deficit = requested[index].requested - effective[index];
|
|
37
|
+
if (deficit > bestDeficit) {
|
|
38
|
+
bestDeficit = deficit;
|
|
39
|
+
bestIndex = index;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (bestIndex === -1) break;
|
|
43
|
+
effective[bestIndex] += 1;
|
|
44
|
+
used += 1;
|
|
45
|
+
}
|
|
46
|
+
const totalEffective = effective.reduce((sum, value) => sum + value, 0);
|
|
47
|
+
return {
|
|
48
|
+
budget: safeBudget,
|
|
49
|
+
totalRequested,
|
|
50
|
+
totalEffective,
|
|
51
|
+
clamped: true,
|
|
52
|
+
belowQueueFloor: requested.length > safeBudget,
|
|
53
|
+
entries: requested.map((entry, index) => ({
|
|
54
|
+
...entry,
|
|
55
|
+
effective: effective[index]
|
|
56
|
+
}))
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export {
|
|
60
|
+
planWorkerConcurrency,
|
|
61
|
+
resolveWorkerConnectionBudget
|
|
62
|
+
};
|
|
63
|
+
//# sourceMappingURL=worker-connection-budget.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/lib/worker-connection-budget.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Bound the DB-connection demand of background queue workers.\n *\n * Since #2970/#3011 each worker job builds its own request container, so its own\n * `EntityManager` checks out a dedicated pooled DB connection for the job's\n * duration. The peak connection demand of `worker --all` is therefore the sum of\n * every queue's concurrency. Nothing previously bounded that sum against the DB\n * pool (`DB_POOL_MAX`) or the database's global `max_connections`, so a storm of\n * slow/failing jobs could over-subscribe connections \u2014 thrashing on the worker's\n * own acquire timeout and, across the worker + web processes, starving the\n * request/onboarding path that shares the same database.\n *\n * These helpers derive a per-queue effective-concurrency plan whose total never\n * exceeds a connection budget (defaulting to the resolved pool max), while\n * guaranteeing every queue keeps at least one worker.\n */\n\nexport type WorkerQueueConcurrency = {\n queue: string\n concurrency: number\n}\n\nexport type WorkerConcurrencyPlanEntry = {\n queue: string\n requested: number\n effective: number\n}\n\nexport type WorkerConcurrencyPlan = {\n /** Connection budget the plan was fitted to. */\n budget: number\n /** Sum of requested concurrency across all queues. */\n totalRequested: number\n /** Sum of effective concurrency the plan grants. */\n totalEffective: number\n /** True when any queue's concurrency was reduced to fit the budget. */\n clamped: boolean\n /**\n * True when the budget is smaller than the number of queues, so the per-queue\n * floor of 1 forces the total above the budget. The caller should surface this\n * as a misconfiguration (raise `DB_POOL_MAX` or the budget).\n */\n belowQueueFloor: boolean\n entries: WorkerConcurrencyPlanEntry[]\n}\n\nfunction toPositiveInt(value: number | undefined, fallback: number): number {\n if (value === undefined || !Number.isFinite(value)) return fallback\n const floored = Math.floor(value)\n return floored > 0 ? floored : fallback\n}\n\n/**\n * Resolve the connection budget for background workers. Defaults to the resolved\n * DB pool max so the worker never tries to use more connections than its own pool\n * can hand out; override with `OM_WORKERS_DB_CONNECTION_BUDGET` (positive int).\n */\nexport function resolveWorkerConnectionBudget(\n env: NodeJS.ProcessEnv,\n poolMax: number,\n): number {\n const safePoolMax = toPositiveInt(poolMax, 1)\n const raw = env.OM_WORKERS_DB_CONNECTION_BUDGET\n if (!raw) return safePoolMax\n const parsed = Number.parseInt(raw, 10)\n return Number.isFinite(parsed) && parsed > 0 ? parsed : safePoolMax\n}\n\n/**\n * Fit per-queue concurrency to a connection budget.\n *\n * - Every queue keeps a floor of 1 worker (no queue is starved).\n * - No queue exceeds its requested concurrency.\n * - When the sum exceeds the budget, leftover capacity is distributed greedily to\n * the queues furthest below their request, so the total matches the budget\n * exactly (when `budget >= queueCount`) and the allocation is deterministic.\n */\nexport function planWorkerConcurrency(\n queues: WorkerQueueConcurrency[],\n budget: number,\n): WorkerConcurrencyPlan {\n const safeBudget = toPositiveInt(budget, 1)\n const requested = queues.map((entry) => ({\n queue: entry.queue,\n requested: toPositiveInt(entry.concurrency, 1),\n }))\n const totalRequested = requested.reduce((sum, entry) => sum + entry.requested, 0)\n\n if (totalRequested <= safeBudget) {\n return {\n budget: safeBudget,\n totalRequested,\n totalEffective: totalRequested,\n clamped: false,\n belowQueueFloor: false,\n entries: requested.map((entry) => ({ ...entry, effective: entry.requested })),\n }\n }\n\n // Floor every queue at 1, then water-fill the remaining budget to the queues\n // with the largest unmet request first.\n const effective = requested.map(() => 1)\n let used = effective.length\n while (used < safeBudget) {\n let bestIndex = -1\n let bestDeficit = 0\n for (let index = 0; index < requested.length; index += 1) {\n const deficit = requested[index].requested - effective[index]\n if (deficit > bestDeficit) {\n bestDeficit = deficit\n bestIndex = index\n }\n }\n if (bestIndex === -1) break\n effective[bestIndex] += 1\n used += 1\n }\n\n const totalEffective = effective.reduce((sum, value) => sum + value, 0)\n return {\n budget: safeBudget,\n totalRequested,\n totalEffective,\n clamped: true,\n belowQueueFloor: requested.length > safeBudget,\n entries: requested.map((entry, index) => ({\n ...entry,\n effective: effective[index],\n })),\n }\n}\n"],
|
|
5
|
+
"mappings": "AA8CA,SAAS,cAAc,OAA2B,UAA0B;AAC1E,MAAI,UAAU,UAAa,CAAC,OAAO,SAAS,KAAK,EAAG,QAAO;AAC3D,QAAM,UAAU,KAAK,MAAM,KAAK;AAChC,SAAO,UAAU,IAAI,UAAU;AACjC;AAOO,SAAS,8BACd,KACA,SACQ;AACR,QAAM,cAAc,cAAc,SAAS,CAAC;AAC5C,QAAM,MAAM,IAAI;AAChB,MAAI,CAAC,IAAK,QAAO;AACjB,QAAM,SAAS,OAAO,SAAS,KAAK,EAAE;AACtC,SAAO,OAAO,SAAS,MAAM,KAAK,SAAS,IAAI,SAAS;AAC1D;AAWO,SAAS,sBACd,QACA,QACuB;AACvB,QAAM,aAAa,cAAc,QAAQ,CAAC;AAC1C,QAAM,YAAY,OAAO,IAAI,CAAC,WAAW;AAAA,IACvC,OAAO,MAAM;AAAA,IACb,WAAW,cAAc,MAAM,aAAa,CAAC;AAAA,EAC/C,EAAE;AACF,QAAM,iBAAiB,UAAU,OAAO,CAAC,KAAK,UAAU,MAAM,MAAM,WAAW,CAAC;AAEhF,MAAI,kBAAkB,YAAY;AAChC,WAAO;AAAA,MACL,QAAQ;AAAA,MACR;AAAA,MACA,gBAAgB;AAAA,MAChB,SAAS;AAAA,MACT,iBAAiB;AAAA,MACjB,SAAS,UAAU,IAAI,CAAC,WAAW,EAAE,GAAG,OAAO,WAAW,MAAM,UAAU,EAAE;AAAA,IAC9E;AAAA,EACF;AAIA,QAAM,YAAY,UAAU,IAAI,MAAM,CAAC;AACvC,MAAI,OAAO,UAAU;AACrB,SAAO,OAAO,YAAY;AACxB,QAAI,YAAY;AAChB,QAAI,cAAc;AAClB,aAAS,QAAQ,GAAG,QAAQ,UAAU,QAAQ,SAAS,GAAG;AACxD,YAAM,UAAU,UAAU,KAAK,EAAE,YAAY,UAAU,KAAK;AAC5D,UAAI,UAAU,aAAa;AACzB,sBAAc;AACd,oBAAY;AAAA,MACd;AAAA,IACF;AACA,QAAI,cAAc,GAAI;AACtB,cAAU,SAAS,KAAK;AACxB,YAAQ;AAAA,EACV;AAEA,QAAM,iBAAiB,UAAU,OAAO,CAAC,KAAK,UAAU,MAAM,OAAO,CAAC;AACtE,SAAO;AAAA,IACL,QAAQ;AAAA,IACR;AAAA,IACA;AAAA,IACA,SAAS;AAAA,IACT,iBAAiB,UAAU,SAAS;AAAA,IACpC,SAAS,UAAU,IAAI,CAAC,OAAO,WAAW;AAAA,MACxC,GAAG;AAAA,MACH,WAAW,UAAU,KAAK;AAAA,IAC5B,EAAE;AAAA,EACJ;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/dist/mercato.js
CHANGED
|
@@ -11,6 +11,10 @@ import {
|
|
|
11
11
|
} from "./lib/auto-spawn-workers.js";
|
|
12
12
|
import { startLazyWorkerSupervisor } from "./lib/queue-worker-supervisor.js";
|
|
13
13
|
import { createPerJobWorkerHandler } from "./lib/worker-job-handler.js";
|
|
14
|
+
import {
|
|
15
|
+
planWorkerConcurrency,
|
|
16
|
+
resolveWorkerConnectionBudget
|
|
17
|
+
} from "./lib/worker-connection-budget.js";
|
|
14
18
|
import {
|
|
15
19
|
resolveAutoSpawnSchedulerMode,
|
|
16
20
|
resolveLazySchedulerPollMs,
|
|
@@ -379,6 +383,27 @@ function formatQueueWorkerLabel(queueNames) {
|
|
|
379
383
|
const preview = sorted.length > 4 ? `${sorted.slice(0, 4).join(", ")}, +${sorted.length - 4} more` : sorted.join(", ");
|
|
380
384
|
return `Queue worker (${preview})`;
|
|
381
385
|
}
|
|
386
|
+
async function resolveWorkerBudgetPlan(requestedByQueue) {
|
|
387
|
+
const { resolvePoolConfig } = await import("@open-mercato/shared/lib/db/mikro");
|
|
388
|
+
const poolMax = resolvePoolConfig(process.env).poolMax;
|
|
389
|
+
const budget = resolveWorkerConnectionBudget(process.env, poolMax);
|
|
390
|
+
const plan = planWorkerConcurrency(requestedByQueue, budget);
|
|
391
|
+
console.log(
|
|
392
|
+
`[worker] DB connection budget: ${plan.budget} (pool max ${poolMax}); requested \u03A3concurrency ${plan.totalRequested}, effective ${plan.totalEffective}`
|
|
393
|
+
);
|
|
394
|
+
if (plan.clamped) {
|
|
395
|
+
const perQueue = plan.entries.map((entry) => `${entry.queue}=${entry.effective}/${entry.requested}`).join(", ");
|
|
396
|
+
console.warn(
|
|
397
|
+
`[worker] Worker concurrency clamped to fit the DB connection budget (${plan.budget}): ${perQueue}. Raise DB_POOL_MAX or set OM_WORKERS_DB_CONNECTION_BUDGET to change this. Keep web_pool_max + worker_pool_max + overhead <= Postgres max_connections.`
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
if (plan.belowQueueFloor) {
|
|
401
|
+
console.warn(
|
|
402
|
+
`[worker] DB connection budget (${plan.budget}) is smaller than the number of queues (${requestedByQueue.length}); every queue runs at concurrency 1 and total demand (${plan.totalEffective}) still exceeds the budget. Raise DB_POOL_MAX.`
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
return plan;
|
|
406
|
+
}
|
|
382
407
|
function lookupModuleCommand(allModules, moduleName, commandName) {
|
|
383
408
|
const mod = allModules.find((entry) => entry.id === moduleName);
|
|
384
409
|
if (!mod) {
|
|
@@ -1226,10 +1251,21 @@ async function run(argv = process.argv) {
|
|
|
1226
1251
|
return;
|
|
1227
1252
|
}
|
|
1228
1253
|
const { createRequestContainer } = await import("@open-mercato/shared/lib/di/container");
|
|
1254
|
+
const requestedByQueue = discoveredQueues.map((queue) => {
|
|
1255
|
+
const queueWorkers = allWorkers.filter((w) => w.queue === queue);
|
|
1256
|
+
return {
|
|
1257
|
+
queue,
|
|
1258
|
+
concurrency: concurrencyOverride ?? Math.max(...queueWorkers.map((w) => w.concurrency), 1)
|
|
1259
|
+
};
|
|
1260
|
+
});
|
|
1261
|
+
const budgetPlan = await resolveWorkerBudgetPlan(requestedByQueue);
|
|
1262
|
+
const effectiveByQueue = new Map(
|
|
1263
|
+
budgetPlan.entries.map((entry) => [entry.queue, entry.effective])
|
|
1264
|
+
);
|
|
1229
1265
|
console.log(`[worker] Starting workers for all queues: ${discoveredQueues.join(", ")}`);
|
|
1230
1266
|
const workerPromises = discoveredQueues.map(async (queue) => {
|
|
1231
1267
|
const queueWorkers = allWorkers.filter((w) => w.queue === queue);
|
|
1232
|
-
const concurrency = concurrencyOverride ?? Math.max(...queueWorkers.map((w) => w.concurrency), 1);
|
|
1268
|
+
const concurrency = effectiveByQueue.get(queue) ?? concurrencyOverride ?? Math.max(...queueWorkers.map((w) => w.concurrency), 1);
|
|
1233
1269
|
console.log(`[worker] Starting "${queue}" with ${queueWorkers.length} handler(s), concurrency: ${concurrency}`);
|
|
1234
1270
|
const queueRedisUrl = getRedisUrl("QUEUE");
|
|
1235
1271
|
await runWorker({
|
|
@@ -1248,7 +1284,9 @@ async function run(argv = process.argv) {
|
|
|
1248
1284
|
const queueWorkers = allWorkers.filter((w) => w.queue === queueName);
|
|
1249
1285
|
if (queueWorkers.length > 0) {
|
|
1250
1286
|
const { createRequestContainer } = await import("@open-mercato/shared/lib/di/container");
|
|
1251
|
-
const
|
|
1287
|
+
const requested = concurrencyOverride ?? Math.max(...queueWorkers.map((w) => w.concurrency), 1);
|
|
1288
|
+
const budgetPlan = await resolveWorkerBudgetPlan([{ queue: queueName, concurrency: requested }]);
|
|
1289
|
+
const concurrency = budgetPlan.entries[0]?.effective ?? requested;
|
|
1252
1290
|
console.log(`[worker] Found ${queueWorkers.length} worker(s) for queue "${queueName}"`);
|
|
1253
1291
|
const queueRedisUrl = getRedisUrl("QUEUE");
|
|
1254
1292
|
await runWorker({
|