@openfn/ws-worker 1.21.5 → 1.22.1

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/dist/start.js CHANGED
@@ -182,29 +182,30 @@ var destroy = async (app, logger2) => {
182
182
  await Promise.all([
183
183
  new Promise((resolve5) => {
184
184
  app.destroyed = true;
185
- app.workloop?.stop("server closed");
185
+ for (const w of app.workloops) {
186
+ w.stop("server closed");
187
+ }
186
188
  app.server.close(async () => {
187
189
  resolve5();
188
190
  });
189
191
  }),
190
- new Promise(async (resolve5) => {
192
+ (async () => {
191
193
  await waitForRunsAndClaims(app, logger2);
192
194
  app.queueChannel?.leave();
193
195
  await app.engine.destroy();
194
196
  app.socket?.disconnect();
195
- resolve5();
196
- })
197
+ })()
197
198
  ]);
198
199
  logger2.success("Server closed");
199
200
  };
200
201
  var waitForRunsAndClaims = (app, logger2) => new Promise((resolve5) => {
201
202
  const log = () => {
202
203
  logger2.debug(
203
- `Waiting for ${Object.keys(app.workflows).length} runs and ${Object.keys(app.openClaims).length} claims to complete...`
204
+ `Waiting for ${Object.keys(app.workflows).length} runs and ${app.pendingClaims()} claims to complete...`
204
205
  );
205
206
  };
206
207
  const checkAllClear = () => {
207
- if (Object.keys(app.workflows).length + Object.keys(app.openClaims).length === 0) {
208
+ if (Object.keys(app.workflows).length + app.pendingClaims() === 0) {
208
209
  logger2.debug("All runs completed!");
209
210
  app.events.off(INTERNAL_RUN_COMPLETE, checkAllClear);
210
211
  app.events.off(INTERNAL_CLAIM_COMPLETE, checkAllClear);
@@ -213,7 +214,7 @@ var waitForRunsAndClaims = (app, logger2) => new Promise((resolve5) => {
213
214
  log();
214
215
  }
215
216
  };
216
- if (Object.keys(app.workflows).length || Object.keys(app.openClaims).length) {
217
+ if (Object.keys(app.workflows).length || app.pendingClaims()) {
217
218
  log();
218
219
  app.events.on(INTERNAL_RUN_COMPLETE, checkAllClear);
219
220
  app.events.on(INTERNAL_CLAIM_COMPLETE, checkAllClear);
@@ -224,51 +225,6 @@ var waitForRunsAndClaims = (app, logger2) => new Promise((resolve5) => {
224
225
  });
225
226
  var destroy_default = destroy;
226
227
 
227
- // src/util/try-with-backoff.ts
228
- var BACKOFF_MULTIPLIER = 1.15;
229
- var tryWithBackoff = (fn, opts = {}) => {
230
- const { min = 1e3, max = 1e4, maxRuns, runs = 1 } = opts;
231
- let cancelled = false;
232
- if (!opts.isCancelled) {
233
- opts.isCancelled = () => cancelled;
234
- }
235
- const promise = new Promise(async (resolve5, reject) => {
236
- try {
237
- await fn();
238
- resolve5();
239
- } catch (e) {
240
- if (e?.abort) {
241
- cancelled = true;
242
- return reject();
243
- }
244
- if (opts.isCancelled()) {
245
- return resolve5();
246
- }
247
- if (!isNaN(maxRuns) && runs >= maxRuns) {
248
- return reject(new Error("max runs exceeded"));
249
- }
250
- setTimeout(() => {
251
- if (opts.isCancelled()) {
252
- return resolve5();
253
- }
254
- const nextOpts = {
255
- maxRuns,
256
- runs: runs + 1,
257
- min: Math.min(max, min * BACKOFF_MULTIPLIER),
258
- max,
259
- isCancelled: opts.isCancelled
260
- };
261
- tryWithBackoff(fn, nextOpts).then(resolve5).catch(reject);
262
- }, min);
263
- }
264
- });
265
- promise.cancel = () => {
266
- cancelled = true;
267
- };
268
- return promise;
269
- };
270
- var try_with_backoff_default = tryWithBackoff;
271
-
272
228
  // src/api/claim.ts
273
229
  import v8 from "node:v8";
274
230
  import * as Sentry from "@sentry/node";
@@ -297,24 +253,26 @@ var ClaimError = class extends Error {
297
253
  }
298
254
  };
299
255
  var claimIdGen = 0;
300
- var claim = (app, logger2 = mockLogger, options = {}) => {
256
+ var claim = (app, workloop, logger2 = mockLogger, options = {}) => {
301
257
  return new Promise((resolve5, reject) => {
302
- app.openClaims ??= {};
303
- const { maxWorkers = 5, demand = 1 } = options;
258
+ const { demand = 1 } = options;
304
259
  const podName = NAME ? `[${NAME}] ` : "";
305
- const activeWorkers = Object.keys(app.workflows).length;
306
- const pendingClaims = Object.values(app.openClaims).reduce(
260
+ const activeInWorkloop = workloop.activeRuns.size;
261
+ const capacity = workloop.capacity;
262
+ const pendingWorkloopClaims = Object.values(workloop.openClaims).reduce(
307
263
  (a, b) => a + b,
308
264
  0
309
265
  );
310
- if (activeWorkers >= maxWorkers) {
311
- app.workloop?.stop(`server at capacity (${activeWorkers}/${maxWorkers})`);
312
- return reject(new ClaimError("Server at capacity"));
313
- } else if (activeWorkers + pendingClaims >= maxWorkers) {
314
- app.workloop?.stop(
315
- `server at capacity (${activeWorkers}/${maxWorkers}, ${pendingClaims} pending)`
266
+ if (activeInWorkloop >= capacity) {
267
+ workloop.stop(
268
+ `workloop ${workloop.id} at capacity (${activeInWorkloop}/${capacity})`
316
269
  );
317
- return reject(new ClaimError("Server at capacity"));
270
+ return reject(new ClaimError("Workloop at capacity"));
271
+ } else if (activeInWorkloop + pendingWorkloopClaims >= capacity) {
272
+ workloop.stop(
273
+ `workloop ${workloop.id} at capacity (${activeInWorkloop}/${capacity}, ${pendingWorkloopClaims} pending)`
274
+ );
275
+ return reject(new ClaimError("Workloop at capacity"));
318
276
  }
319
277
  if (!app.queueChannel) {
320
278
  logger2.warn("skipping claim attempt: websocket unavailable");
@@ -327,21 +285,22 @@ var claim = (app, logger2 = mockLogger, options = {}) => {
327
285
  return reject(e);
328
286
  }
329
287
  const claimId = ++claimIdGen;
330
- app.openClaims[claimId] = demand;
288
+ workloop.openClaims[claimId] = demand;
331
289
  const { used_heap_size, heap_size_limit } = v8.getHeapStatistics();
332
290
  const usedHeapMb = Math.round(used_heap_size / 1024 / 1024);
333
291
  const totalHeapMb = Math.round(heap_size_limit / 1024 / 1024);
334
292
  const memPercent = Math.round(usedHeapMb / totalHeapMb * 100);
335
293
  logger2.debug(
336
- `Claiming runs :: demand ${demand} | capacity ${activeWorkers}/${maxWorkers} | memory ${memPercent}% (${usedHeapMb}/${totalHeapMb}mb)`
294
+ `Claiming runs [${workloop.id}] :: demand ${demand} | capacity ${activeInWorkloop}/${capacity} | memory ${memPercent}% (${usedHeapMb}/${totalHeapMb}mb)`
337
295
  );
338
296
  app.events.emit(INTERNAL_CLAIM_START);
339
297
  const start = Date.now();
340
298
  app.queueChannel.push(CLAIM, {
341
299
  demand,
342
- worker_name: NAME || null
300
+ worker_name: NAME || null,
301
+ queues: workloop.queues
343
302
  }).receive("ok", async ({ runs }) => {
344
- delete app.openClaims[claimId];
303
+ delete workloop.openClaims[claimId];
345
304
  const duration = Date.now() - start;
346
305
  logger2.debug(
347
306
  `${podName}claimed ${runs.length} runs in ${duration}ms (${runs.length ? runs.map((r) => r.id).join(",") : "-"})`
@@ -365,17 +324,19 @@ var claim = (app, logger2 = mockLogger, options = {}) => {
365
324
  } else {
366
325
  logger2.debug("skipping run token validation for", run2.id);
367
326
  }
327
+ workloop.activeRuns.add(run2.id);
328
+ app.runWorkloopMap[run2.id] = workloop;
368
329
  logger2.debug(`${podName} starting run ${run2.id}`);
369
330
  app.execute(run2);
370
331
  }
371
332
  resolve5();
372
333
  app.events.emit(INTERNAL_CLAIM_COMPLETE, { runs });
373
334
  }).receive("error", (e) => {
374
- delete app.openClaims[claimId];
335
+ delete workloop.openClaims[claimId];
375
336
  logger2.error("Error on claim", e);
376
337
  reject(new Error("claim error"));
377
338
  }).receive("timeout", () => {
378
- delete app.openClaims[claimId];
339
+ delete workloop.openClaims[claimId];
379
340
  logger2.error("TIMEOUT on claim. Runs may be lost.");
380
341
  reject(new Error("timeout"));
381
342
  });
@@ -383,43 +344,6 @@ var claim = (app, logger2 = mockLogger, options = {}) => {
383
344
  };
384
345
  var claim_default = claim;
385
346
 
386
- // src/api/workloop.ts
387
- var startWorkloop = (app, logger2, minBackoff2, maxBackoff2, maxWorkers) => {
388
- let promise;
389
- let cancelled = false;
390
- const workLoop = () => {
391
- if (!cancelled) {
392
- promise = try_with_backoff_default(
393
- () => claim_default(app, logger2, {
394
- maxWorkers
395
- }),
396
- {
397
- min: minBackoff2,
398
- max: maxBackoff2
399
- }
400
- );
401
- promise.then(() => {
402
- if (!cancelled) {
403
- setTimeout(workLoop, minBackoff2);
404
- }
405
- }).catch(() => {
406
- });
407
- }
408
- };
409
- workLoop();
410
- return {
411
- stop: (reason = "reason unknown") => {
412
- if (!cancelled) {
413
- logger2.info(`cancelling workloop: ${reason}`);
414
- cancelled = true;
415
- promise.cancel();
416
- }
417
- },
418
- isStopped: () => cancelled
419
- };
420
- };
421
- var workloop_default = startWorkloop;
422
-
423
347
  // src/api/execute.ts
424
348
  import * as Sentry4 from "@sentry/node";
425
349
  import {
@@ -724,6 +648,50 @@ var stringify_default = (obj) => stringify(obj, (_key, value) => {
724
648
  return value;
725
649
  });
726
650
 
651
+ // src/util/try-with-backoff.ts
652
+ var BACKOFF_MULTIPLIER = 1.15;
653
+ var tryWithBackoff = (fn, opts = {}) => {
654
+ const { min = 1e3, max = 1e4, maxRuns, runs = 1 } = opts;
655
+ let cancelled = false;
656
+ if (!opts.isCancelled) {
657
+ opts.isCancelled = () => cancelled;
658
+ }
659
+ const run2 = async () => {
660
+ try {
661
+ await fn();
662
+ } catch (e) {
663
+ if (e?.abort) {
664
+ cancelled = true;
665
+ throw e;
666
+ }
667
+ if (opts.isCancelled()) {
668
+ return;
669
+ }
670
+ if (!isNaN(maxRuns) && runs >= maxRuns) {
671
+ throw new Error("max runs exceeded");
672
+ }
673
+ await new Promise((resolve5) => setTimeout(resolve5, min));
674
+ if (opts.isCancelled()) {
675
+ return;
676
+ }
677
+ const nextOpts = {
678
+ maxRuns,
679
+ runs: runs + 1,
680
+ min: Math.min(max, min * BACKOFF_MULTIPLIER),
681
+ max,
682
+ isCancelled: opts.isCancelled
683
+ };
684
+ return tryWithBackoff(fn, nextOpts);
685
+ }
686
+ };
687
+ const promise = run2();
688
+ promise.cancel = () => {
689
+ cancelled = true;
690
+ };
691
+ return promise;
692
+ };
693
+ var try_with_backoff_default = tryWithBackoff;
694
+
727
695
  // src/util/timestamp.ts
728
696
  var timeInMicroseconds = (time) => time && (BigInt(time) / BigInt(1e3)).toString();
729
697
 
@@ -764,16 +732,13 @@ async function onRunLog(context, events) {
764
732
  };
765
733
  return sendEvent(context, RUN_LOG_BATCH, payload);
766
734
  } else {
767
- return new Promise(async (resolve5) => {
768
- for (const log of logs) {
769
- const payload = {
770
- run_id: `${state.plan.id}`,
771
- ...log
772
- };
773
- await sendEvent(context, RUN_LOG, payload);
774
- }
775
- resolve5();
776
- });
735
+ for (const log of logs) {
736
+ const payload = {
737
+ run_id: `${state.plan.id}`,
738
+ ...log
739
+ };
740
+ await sendEvent(context, RUN_LOG, payload);
741
+ }
777
742
  }
778
743
  }
779
744
 
@@ -1384,6 +1349,7 @@ var connectToWorkerQueue = (endpoint, serverId, secret, logger2, options) => {
1384
1349
  messageTimeout = DEFAULT_MESSAGE_TIMEOUT_SECONDS,
1385
1350
  claimTimeout = DEFAULT_CLAIM_TIMEOUT_SECONDS,
1386
1351
  capacity,
1352
+ queues,
1387
1353
  SocketConstructor = PhxSocket
1388
1354
  } = options;
1389
1355
  const events = new EventEmitter2();
@@ -1420,6 +1386,9 @@ var connectToWorkerQueue = (endpoint, serverId, secret, logger2, options) => {
1420
1386
  didOpen = true;
1421
1387
  shouldReportConnectionError = true;
1422
1388
  const joinPayload = { capacity };
1389
+ if (queues) {
1390
+ joinPayload.queues = queues;
1391
+ }
1423
1392
  const channel = socket.channel("worker:queue", joinPayload);
1424
1393
  channel.onMessage = (ev, load) => {
1425
1394
  events.emit("message", ev, load);
@@ -1460,6 +1429,140 @@ var connectToWorkerQueue = (endpoint, serverId, secret, logger2, options) => {
1460
1429
  };
1461
1430
  var worker_queue_default = connectToWorkerQueue;
1462
1431
 
1432
+ // src/api/workloop.ts
1433
+ var Workloop = class {
1434
+ constructor({
1435
+ id,
1436
+ queues,
1437
+ capacity
1438
+ }) {
1439
+ this.activeRuns = /* @__PURE__ */ new Set();
1440
+ this.openClaims = {};
1441
+ this.cancelled = true;
1442
+ this.id = id;
1443
+ this.queues = queues;
1444
+ this.capacity = capacity;
1445
+ }
1446
+ hasCapacity() {
1447
+ const pendingClaims = Object.values(this.openClaims).reduce(
1448
+ (a, b) => a + b,
1449
+ 0
1450
+ );
1451
+ return this.activeRuns.size + pendingClaims < this.capacity;
1452
+ }
1453
+ start(app, logger2, minBackoff2, maxBackoff2) {
1454
+ this.logger = logger2;
1455
+ this.cancelled = false;
1456
+ const loop = () => {
1457
+ if (!this.cancelled) {
1458
+ this.promise = try_with_backoff_default(() => claim_default(app, this, logger2), {
1459
+ min: minBackoff2,
1460
+ max: maxBackoff2
1461
+ });
1462
+ this.promise.then(() => {
1463
+ if (!this.cancelled) {
1464
+ setTimeout(loop, minBackoff2);
1465
+ }
1466
+ }).catch(() => {
1467
+ });
1468
+ }
1469
+ };
1470
+ loop();
1471
+ }
1472
+ stop(reason = "reason unknown") {
1473
+ if (!this.cancelled) {
1474
+ this.logger?.info(`cancelling workloop: ${reason}`);
1475
+ this.cancelled = true;
1476
+ this.promise?.cancel();
1477
+ }
1478
+ }
1479
+ isStopped() {
1480
+ return this.cancelled;
1481
+ }
1482
+ };
1483
+
1484
+ // src/util/parse-workloops.ts
1485
+ var WorkloopValidationError = class extends Error {
1486
+ constructor(message) {
1487
+ super(message);
1488
+ this.name = "WorkloopValidationError";
1489
+ }
1490
+ };
1491
+ var VALID_NAME = /^[a-zA-Z0-9_]+$/;
1492
+ function parseWorkloops(input) {
1493
+ const trimmed = input.trim();
1494
+ if (!trimmed) {
1495
+ throw new WorkloopValidationError("Workloop configuration cannot be empty");
1496
+ }
1497
+ const tokens = trimmed.split(/\s+/);
1498
+ const configs = tokens.map(parseToken);
1499
+ const seenConfigs = /* @__PURE__ */ new Map();
1500
+ for (let i = 0; i < configs.length; i++) {
1501
+ const key = JSON.stringify(configs[i].queues);
1502
+ if (seenConfigs.has(key)) {
1503
+ const prevIndex = seenConfigs.get(key);
1504
+ console.warn(
1505
+ `Warning: workloops at positions ${prevIndex} and ${i} have identical queue configurations: ${tokens[prevIndex]} and ${tokens[i]}`
1506
+ );
1507
+ } else {
1508
+ seenConfigs.set(key, i);
1509
+ }
1510
+ }
1511
+ return configs;
1512
+ }
1513
+ function parseToken(token) {
1514
+ const lastColon = token.lastIndexOf(":");
1515
+ if (lastColon === -1) {
1516
+ throw new WorkloopValidationError(
1517
+ `Invalid token "${token}": missing :<count> suffix`
1518
+ );
1519
+ }
1520
+ const prefStr = token.slice(0, lastColon);
1521
+ const countStr = token.slice(lastColon + 1);
1522
+ const count = Number(countStr);
1523
+ if (!Number.isInteger(count) || countStr !== String(Math.floor(count))) {
1524
+ throw new WorkloopValidationError(
1525
+ `Invalid count "${countStr}" in token "${token}": must be a positive integer`
1526
+ );
1527
+ }
1528
+ if (count < 1) {
1529
+ throw new WorkloopValidationError(
1530
+ `Invalid count "${countStr}" in token "${token}": must be >= 1`
1531
+ );
1532
+ }
1533
+ const names = prefStr.split(">");
1534
+ for (const name of names) {
1535
+ if (name === "") {
1536
+ throw new WorkloopValidationError(`Empty queue name in token "${token}"`);
1537
+ }
1538
+ if (name !== "*" && !VALID_NAME.test(name)) {
1539
+ throw new WorkloopValidationError(
1540
+ `Invalid queue name "${name}" in token "${token}": must match /^[a-zA-Z0-9_]+$/ or be "*"`
1541
+ );
1542
+ }
1543
+ }
1544
+ const nonWildcardNames = names.filter((n) => n !== "*");
1545
+ const seen = /* @__PURE__ */ new Set();
1546
+ for (const name of nonWildcardNames) {
1547
+ if (seen.has(name)) {
1548
+ console.warn(
1549
+ `Warning: duplicate queue name "${name}" in token "${token}"`
1550
+ );
1551
+ }
1552
+ seen.add(name);
1553
+ }
1554
+ const wildcardIndex = names.indexOf("*");
1555
+ if (wildcardIndex !== -1 && wildcardIndex !== names.length - 1) {
1556
+ throw new WorkloopValidationError(
1557
+ `Wildcard "*" must be the last element in token "${token}"`
1558
+ );
1559
+ }
1560
+ return new Workloop({ id: token, queues: names, capacity: count });
1561
+ }
1562
+
1563
+ // src/util/get-default-workloop-config.ts
1564
+ var get_default_workloop_config_default = (capacity = 5) => `manual>*:${capacity}`;
1565
+
1463
1566
  // src/server.ts
1464
1567
  var exec = promisify(_exec);
1465
1568
  var DEFAULT_PORT = 2222;
@@ -1487,8 +1590,10 @@ function connect(app, logger2, options = {}) {
1487
1590
  app.resumeWorkloop();
1488
1591
  };
1489
1592
  const onDisconnect = () => {
1490
- if (!app.workloop?.isStopped()) {
1491
- app.workloop?.stop("Socket disconnected unexpectedly");
1593
+ for (const w of app.workloops) {
1594
+ if (!w.isStopped()) {
1595
+ w.stop("Socket disconnected unexpectedly");
1596
+ }
1492
1597
  }
1493
1598
  if (!app.destroyed) {
1494
1599
  logger2.info("Connection to lightning lost");
@@ -1510,17 +1615,26 @@ function connect(app, logger2, options = {}) {
1510
1615
  const onMessage = (event) => {
1511
1616
  if (event === WORK_AVAILABLE) {
1512
1617
  if (!app.destroyed) {
1513
- claim_default(app, logger2, { maxWorkers: options.maxWorkflows }).catch(() => {
1514
- });
1618
+ for (const w of app.workloops) {
1619
+ if (w.hasCapacity()) {
1620
+ claim_default(app, w, logger2).catch(() => {
1621
+ });
1622
+ }
1623
+ }
1515
1624
  }
1516
1625
  }
1517
1626
  };
1627
+ const queuesMap = {};
1628
+ for (const w of app.workloops) {
1629
+ queuesMap[w.queues.join(">")] = w.capacity;
1630
+ }
1518
1631
  worker_queue_default(options.lightning, app.id, options.secret, logger2, {
1519
1632
  // TODO: options.socketTimeoutSeconds wins because this is what USED to be used
1520
1633
  // But it's deprecated and should be removed soon
1521
1634
  messageTimeout: options.socketTimeoutSeconds ?? options.messageTimeoutSeconds,
1522
1635
  claimTimeout: options.claimTimeoutSeconds,
1523
- capacity: options.maxWorkflows
1636
+ capacity: options.maxWorkflows,
1637
+ queues: queuesMap
1524
1638
  }).on("connect", onConnect).on("disconnect", onDisconnect).on("error", onError).on("message", onMessage);
1525
1639
  }
1526
1640
  async function setupCollections(options, logger2) {
@@ -1569,27 +1683,36 @@ function createServer(engine, options = {}) {
1569
1683
  logger2.debug(str);
1570
1684
  })
1571
1685
  );
1572
- app.openClaims = {};
1573
1686
  app.workflows = {};
1574
1687
  app.destroyed = false;
1688
+ app.workloops = parseWorkloops(
1689
+ options.workloopConfigs ?? get_default_workloop_config_default(options.maxWorkflows)
1690
+ );
1691
+ app.runWorkloopMap = {};
1575
1692
  app.server = app.listen(port);
1576
1693
  logger2.success(`Worker ${app.id} listening on ${port}`);
1577
1694
  process.send?.("READY");
1578
1695
  router.get("/livez", healthcheck_default);
1579
1696
  router.get("/", healthcheck_default);
1580
1697
  app.options = options;
1581
- app.resumeWorkloop = () => {
1698
+ app.resumeWorkloop = (workloop) => {
1582
1699
  if (options.noLoop || app.destroyed) {
1583
1700
  return;
1584
1701
  }
1585
- if (!app.workloop || app.workloop?.isStopped()) {
1586
- logger2.info("Starting workloop");
1587
- app.workloop = workloop_default(
1702
+ const targets = workloop ? [workloop] : app.workloops;
1703
+ for (const w of targets) {
1704
+ if (!w.hasCapacity()) {
1705
+ continue;
1706
+ }
1707
+ if (!w.isStopped()) {
1708
+ w.stop("restarting");
1709
+ }
1710
+ logger2.info(`Starting workloop for ${w.id}`);
1711
+ w.start(
1588
1712
  app,
1589
1713
  logger2,
1590
1714
  options.backoff?.min || MIN_BACKOFF,
1591
- options.backoff?.max || MAX_BACKOFF,
1592
- options.maxWorkflows
1715
+ options.backoff?.max || MAX_BACKOFF
1593
1716
  );
1594
1717
  }
1595
1718
  };
@@ -1634,8 +1757,16 @@ function createServer(engine, options = {}) {
1634
1757
  );
1635
1758
  delete app.workflows[id];
1636
1759
  runChannel.leave();
1637
- app.events.emit(INTERNAL_RUN_COMPLETE);
1638
- app.resumeWorkloop();
1760
+ const owningWorkloop = app.runWorkloopMap[id];
1761
+ if (owningWorkloop) {
1762
+ owningWorkloop.activeRuns.delete(id);
1763
+ delete app.runWorkloopMap[id];
1764
+ app.events.emit(INTERNAL_RUN_COMPLETE);
1765
+ app.resumeWorkloop(owningWorkloop);
1766
+ } else {
1767
+ app.events.emit(INTERNAL_RUN_COMPLETE);
1768
+ app.resumeWorkloop();
1769
+ }
1639
1770
  };
1640
1771
  const context = execute(
1641
1772
  runChannel,
@@ -1649,7 +1780,14 @@ function createServer(engine, options = {}) {
1649
1780
  app.workflows[id] = context;
1650
1781
  } catch (e) {
1651
1782
  delete app.workflows[id];
1652
- app.resumeWorkloop();
1783
+ const owningWorkloop = app.runWorkloopMap[id];
1784
+ if (owningWorkloop) {
1785
+ owningWorkloop.activeRuns.delete(id);
1786
+ delete app.runWorkloopMap[id];
1787
+ app.resumeWorkloop(owningWorkloop);
1788
+ } else {
1789
+ app.resumeWorkloop();
1790
+ }
1653
1791
  logger2.error(`Unexpected error executing ${id}`);
1654
1792
  logger2.error(e);
1655
1793
  }
@@ -1659,9 +1797,13 @@ function createServer(engine, options = {}) {
1659
1797
  };
1660
1798
  router.post("/claim", async (ctx) => {
1661
1799
  logger2.info("triggering claim from POST request");
1662
- return claim_default(app, logger2, {
1663
- maxWorkers: options.maxWorkflows
1664
- }).then(() => {
1800
+ const promises = app.workloops.map((w) => {
1801
+ if (w.hasCapacity()) {
1802
+ return claim_default(app, w, logger2);
1803
+ }
1804
+ return Promise.reject(new Error("Workloop at capacity"));
1805
+ });
1806
+ return Promise.any(promises).then(() => {
1665
1807
  logger2.info("claim complete: 1 run claimed");
1666
1808
  ctx.body = "complete";
1667
1809
  ctx.status = 200;
@@ -1672,10 +1814,15 @@ function createServer(engine, options = {}) {
1672
1814
  });
1673
1815
  });
1674
1816
  app.claim = () => {
1675
- return claim_default(app, logger2, {
1676
- maxWorkers: options.maxWorkflows
1817
+ const promises = app.workloops.map((w) => {
1818
+ if (w.hasCapacity()) {
1819
+ return claim_default(app, w, logger2);
1820
+ }
1821
+ return Promise.reject(new Error("Workloop at capacity"));
1677
1822
  });
1823
+ return Promise.any(promises);
1678
1824
  };
1825
+ app.pendingClaims = () => app.workloops.reduce((sum, w) => sum + Object.keys(w.openClaims).length, 0);
1679
1826
  app.destroy = () => destroy_default(app, logger2);
1680
1827
  app.use(router.routes());
1681
1828
  if (options.lightning) {
@@ -6605,6 +6752,7 @@ function parseArgs(argv) {
6605
6752
  WORKER_STATE_PROPS_TO_REMOVE,
6606
6753
  WORKER_TIMEOUT_RETRY_COUNT,
6607
6754
  WORKER_TIMEOUT_RETRY_DELAY_MS,
6755
+ WORKER_WORKLOOPS,
6608
6756
  WORKER_VALIDATION_RETRIES,
6609
6757
  WORKER_VALIDATION_TIMEOUT_MS
6610
6758
  } = process.env;
@@ -6666,8 +6814,12 @@ function parseArgs(argv) {
6666
6814
  }).option("backoff", {
6667
6815
  description: "Claim backoff rules: min/max (in seconds). Env: WORKER_BACKOFF"
6668
6816
  }).option("capacity", {
6669
- description: `max concurrent workers. Default ${DEFAULT_WORKER_CAPACITY}. Env: WORKER_CAPACITY`,
6817
+ description: `Sets the maximum concurrent workers - but only if workloops is not set. Default ${DEFAULT_WORKER_CAPACITY}. Env: WORKER_CAPACITY`,
6670
6818
  type: "number"
6819
+ }).option("workloops", {
6820
+ description: 'Configure workloops with a priorised queue list and a max capacity. Syntax: "<queues>:<capacity> ...". Mutually exclusive with --capacity. Env: WORKER_WORKLOOPS',
6821
+ type: "string",
6822
+ example: "fast_lane:1 manual>*:4"
6671
6823
  }).option("state-props-to-remove", {
6672
6824
  description: "A list of properties to remove from the final state returned by a job. Env: WORKER_STATE_PROPS_TO_REMOVE",
6673
6825
  type: "array"
@@ -6705,10 +6857,25 @@ function parseArgs(argv) {
6705
6857
  }).option("timeout-retry-delay", {
6706
6858
  description: "When a websocket event receives a timeout, this option sets how log to wait before retrying Default 30000. Env: WORKER_TIMEOUT_RETRY_DELAY_MS",
6707
6859
  type: "number"
6708
- });
6860
+ }).example(
6861
+ "start --queues *:5",
6862
+ "Default start configuration: a single workloop with capacity 5, claiming from all queues"
6863
+ ).example(
6864
+ "start --queues manual>*:5",
6865
+ "A single workloop, capacity 5, which claims across two queues. Runs in the manual queue will be picked first, else any other queue will be picked."
6866
+ ).example(
6867
+ "start --queues fast_lane:1 manual>*:4",
6868
+ "production start configuration with 1 fast lane workloop (capacity 1) and a second workloop with capacity 4"
6869
+ );
6709
6870
  const args2 = parser2.parse();
6871
+ const resolvedWorkloops = setArg(args2.workloops, WORKER_WORKLOOPS);
6872
+ const capacityExplicit = args2.capacity !== void 0 || WORKER_CAPACITY !== void 0;
6873
+ if (resolvedWorkloops !== void 0 && capacityExplicit) {
6874
+ throw new Error("--workloops and --capacity are mutually exclusive");
6875
+ }
6710
6876
  return {
6711
6877
  ...args2,
6878
+ workloops: resolvedWorkloops,
6712
6879
  port: setArg(args2.port, WORKER_PORT, DEFAULT_PORT2),
6713
6880
  lightning: setArg(
6714
6881
  args2.lightning,
@@ -6797,8 +6964,16 @@ function parseArgs(argv) {
6797
6964
 
6798
6965
  // src/start.ts
6799
6966
  var args = parseArgs(process.argv);
6967
+ var workloopConfigs = args.workloops ?? get_default_workloop_config_default(args.capacity);
6968
+ var effectiveCapacity = workloopConfigs.trim().split(/\s+/).reduce((sum, token) => sum + (parseInt(token.split(":").pop()) || 0), 0);
6800
6969
  var logger = createLogger("SRV", { level: args.log });
6801
- logger.info("Starting worker server...");
6970
+ logger.info("Starting worker...");
6971
+ logger.info(
6972
+ "Workloops:",
6973
+ workloopConfigs,
6974
+ "effective capacity:",
6975
+ effectiveCapacity
6976
+ );
6802
6977
  if (args.lightning === "mock") {
6803
6978
  args.lightning = "ws://localhost:8888/worker";
6804
6979
  if (!args.secret) {
@@ -6823,7 +6998,8 @@ function engineReady(engine) {
6823
6998
  min: minBackoff,
6824
6999
  max: maxBackoff
6825
7000
  },
6826
- maxWorkflows: args.capacity,
7001
+ maxWorkflows: effectiveCapacity,
7002
+ workloopConfigs,
6827
7003
  payloadLimitMb: args.payloadMemory,
6828
7004
  logPayloadLimitMb: args.logPayloadMemory ?? 1,
6829
7005
  // Default to 1MB
@@ -6873,7 +7049,7 @@ if (args.mock) {
6873
7049
  const engineOptions = {
6874
7050
  repoDir: args.repoDir,
6875
7051
  memoryLimitMb: args.runMemory,
6876
- maxWorkers: args.capacity,
7052
+ maxWorkers: effectiveCapacity,
6877
7053
  statePropsToRemove: args.statePropsToRemove,
6878
7054
  runTimeoutMs: args.maxRunDurationSeconds * 1e3,
6879
7055
  workerValidationTimeout: args.engineValidationTimeoutMs,