@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/index.js CHANGED
@@ -33,29 +33,30 @@ var destroy = async (app, logger) => {
33
33
  await Promise.all([
34
34
  new Promise((resolve) => {
35
35
  app.destroyed = true;
36
- app.workloop?.stop("server closed");
36
+ for (const w of app.workloops) {
37
+ w.stop("server closed");
38
+ }
37
39
  app.server.close(async () => {
38
40
  resolve();
39
41
  });
40
42
  }),
41
- new Promise(async (resolve) => {
43
+ (async () => {
42
44
  await waitForRunsAndClaims(app, logger);
43
45
  app.queueChannel?.leave();
44
46
  await app.engine.destroy();
45
47
  app.socket?.disconnect();
46
- resolve();
47
- })
48
+ })()
48
49
  ]);
49
50
  logger.success("Server closed");
50
51
  };
51
52
  var waitForRunsAndClaims = (app, logger) => new Promise((resolve) => {
52
53
  const log = () => {
53
54
  logger.debug(
54
- `Waiting for ${Object.keys(app.workflows).length} runs and ${Object.keys(app.openClaims).length} claims to complete...`
55
+ `Waiting for ${Object.keys(app.workflows).length} runs and ${app.pendingClaims()} claims to complete...`
55
56
  );
56
57
  };
57
58
  const checkAllClear = () => {
58
- if (Object.keys(app.workflows).length + Object.keys(app.openClaims).length === 0) {
59
+ if (Object.keys(app.workflows).length + app.pendingClaims() === 0) {
59
60
  logger.debug("All runs completed!");
60
61
  app.events.off(INTERNAL_RUN_COMPLETE, checkAllClear);
61
62
  app.events.off(INTERNAL_CLAIM_COMPLETE, checkAllClear);
@@ -64,7 +65,7 @@ var waitForRunsAndClaims = (app, logger) => new Promise((resolve) => {
64
65
  log();
65
66
  }
66
67
  };
67
- if (Object.keys(app.workflows).length || Object.keys(app.openClaims).length) {
68
+ if (Object.keys(app.workflows).length || app.pendingClaims()) {
68
69
  log();
69
70
  app.events.on(INTERNAL_RUN_COMPLETE, checkAllClear);
70
71
  app.events.on(INTERNAL_CLAIM_COMPLETE, checkAllClear);
@@ -75,51 +76,6 @@ var waitForRunsAndClaims = (app, logger) => new Promise((resolve) => {
75
76
  });
76
77
  var destroy_default = destroy;
77
78
 
78
- // src/util/try-with-backoff.ts
79
- var BACKOFF_MULTIPLIER = 1.15;
80
- var tryWithBackoff = (fn, opts = {}) => {
81
- const { min = 1e3, max = 1e4, maxRuns, runs = 1 } = opts;
82
- let cancelled = false;
83
- if (!opts.isCancelled) {
84
- opts.isCancelled = () => cancelled;
85
- }
86
- const promise = new Promise(async (resolve, reject) => {
87
- try {
88
- await fn();
89
- resolve();
90
- } catch (e) {
91
- if (e?.abort) {
92
- cancelled = true;
93
- return reject();
94
- }
95
- if (opts.isCancelled()) {
96
- return resolve();
97
- }
98
- if (!isNaN(maxRuns) && runs >= maxRuns) {
99
- return reject(new Error("max runs exceeded"));
100
- }
101
- setTimeout(() => {
102
- if (opts.isCancelled()) {
103
- return resolve();
104
- }
105
- const nextOpts = {
106
- maxRuns,
107
- runs: runs + 1,
108
- min: Math.min(max, min * BACKOFF_MULTIPLIER),
109
- max,
110
- isCancelled: opts.isCancelled
111
- };
112
- tryWithBackoff(fn, nextOpts).then(resolve).catch(reject);
113
- }, min);
114
- }
115
- });
116
- promise.cancel = () => {
117
- cancelled = true;
118
- };
119
- return promise;
120
- };
121
- var try_with_backoff_default = tryWithBackoff;
122
-
123
79
  // src/api/claim.ts
124
80
  import v8 from "node:v8";
125
81
  import * as Sentry from "@sentry/node";
@@ -148,24 +104,26 @@ var ClaimError = class extends Error {
148
104
  }
149
105
  };
150
106
  var claimIdGen = 0;
151
- var claim = (app, logger = mockLogger, options = {}) => {
107
+ var claim = (app, workloop, logger = mockLogger, options = {}) => {
152
108
  return new Promise((resolve, reject) => {
153
- app.openClaims ??= {};
154
- const { maxWorkers = 5, demand = 1 } = options;
109
+ const { demand = 1 } = options;
155
110
  const podName = NAME ? `[${NAME}] ` : "";
156
- const activeWorkers = Object.keys(app.workflows).length;
157
- const pendingClaims = Object.values(app.openClaims).reduce(
111
+ const activeInWorkloop = workloop.activeRuns.size;
112
+ const capacity = workloop.capacity;
113
+ const pendingWorkloopClaims = Object.values(workloop.openClaims).reduce(
158
114
  (a, b) => a + b,
159
115
  0
160
116
  );
161
- if (activeWorkers >= maxWorkers) {
162
- app.workloop?.stop(`server at capacity (${activeWorkers}/${maxWorkers})`);
163
- return reject(new ClaimError("Server at capacity"));
164
- } else if (activeWorkers + pendingClaims >= maxWorkers) {
165
- app.workloop?.stop(
166
- `server at capacity (${activeWorkers}/${maxWorkers}, ${pendingClaims} pending)`
117
+ if (activeInWorkloop >= capacity) {
118
+ workloop.stop(
119
+ `workloop ${workloop.id} at capacity (${activeInWorkloop}/${capacity})`
167
120
  );
168
- return reject(new ClaimError("Server at capacity"));
121
+ return reject(new ClaimError("Workloop at capacity"));
122
+ } else if (activeInWorkloop + pendingWorkloopClaims >= capacity) {
123
+ workloop.stop(
124
+ `workloop ${workloop.id} at capacity (${activeInWorkloop}/${capacity}, ${pendingWorkloopClaims} pending)`
125
+ );
126
+ return reject(new ClaimError("Workloop at capacity"));
169
127
  }
170
128
  if (!app.queueChannel) {
171
129
  logger.warn("skipping claim attempt: websocket unavailable");
@@ -178,21 +136,22 @@ var claim = (app, logger = mockLogger, options = {}) => {
178
136
  return reject(e);
179
137
  }
180
138
  const claimId = ++claimIdGen;
181
- app.openClaims[claimId] = demand;
139
+ workloop.openClaims[claimId] = demand;
182
140
  const { used_heap_size, heap_size_limit } = v8.getHeapStatistics();
183
141
  const usedHeapMb = Math.round(used_heap_size / 1024 / 1024);
184
142
  const totalHeapMb = Math.round(heap_size_limit / 1024 / 1024);
185
143
  const memPercent = Math.round(usedHeapMb / totalHeapMb * 100);
186
144
  logger.debug(
187
- `Claiming runs :: demand ${demand} | capacity ${activeWorkers}/${maxWorkers} | memory ${memPercent}% (${usedHeapMb}/${totalHeapMb}mb)`
145
+ `Claiming runs [${workloop.id}] :: demand ${demand} | capacity ${activeInWorkloop}/${capacity} | memory ${memPercent}% (${usedHeapMb}/${totalHeapMb}mb)`
188
146
  );
189
147
  app.events.emit(INTERNAL_CLAIM_START);
190
148
  const start = Date.now();
191
149
  app.queueChannel.push(CLAIM, {
192
150
  demand,
193
- worker_name: NAME || null
151
+ worker_name: NAME || null,
152
+ queues: workloop.queues
194
153
  }).receive("ok", async ({ runs }) => {
195
- delete app.openClaims[claimId];
154
+ delete workloop.openClaims[claimId];
196
155
  const duration = Date.now() - start;
197
156
  logger.debug(
198
157
  `${podName}claimed ${runs.length} runs in ${duration}ms (${runs.length ? runs.map((r) => r.id).join(",") : "-"})`
@@ -216,17 +175,19 @@ var claim = (app, logger = mockLogger, options = {}) => {
216
175
  } else {
217
176
  logger.debug("skipping run token validation for", run.id);
218
177
  }
178
+ workloop.activeRuns.add(run.id);
179
+ app.runWorkloopMap[run.id] = workloop;
219
180
  logger.debug(`${podName} starting run ${run.id}`);
220
181
  app.execute(run);
221
182
  }
222
183
  resolve();
223
184
  app.events.emit(INTERNAL_CLAIM_COMPLETE, { runs });
224
185
  }).receive("error", (e) => {
225
- delete app.openClaims[claimId];
186
+ delete workloop.openClaims[claimId];
226
187
  logger.error("Error on claim", e);
227
188
  reject(new Error("claim error"));
228
189
  }).receive("timeout", () => {
229
- delete app.openClaims[claimId];
190
+ delete workloop.openClaims[claimId];
230
191
  logger.error("TIMEOUT on claim. Runs may be lost.");
231
192
  reject(new Error("timeout"));
232
193
  });
@@ -234,43 +195,6 @@ var claim = (app, logger = mockLogger, options = {}) => {
234
195
  };
235
196
  var claim_default = claim;
236
197
 
237
- // src/api/workloop.ts
238
- var startWorkloop = (app, logger, minBackoff, maxBackoff, maxWorkers) => {
239
- let promise;
240
- let cancelled = false;
241
- const workLoop = () => {
242
- if (!cancelled) {
243
- promise = try_with_backoff_default(
244
- () => claim_default(app, logger, {
245
- maxWorkers
246
- }),
247
- {
248
- min: minBackoff,
249
- max: maxBackoff
250
- }
251
- );
252
- promise.then(() => {
253
- if (!cancelled) {
254
- setTimeout(workLoop, minBackoff);
255
- }
256
- }).catch(() => {
257
- });
258
- }
259
- };
260
- workLoop();
261
- return {
262
- stop: (reason = "reason unknown") => {
263
- if (!cancelled) {
264
- logger.info(`cancelling workloop: ${reason}`);
265
- cancelled = true;
266
- promise.cancel();
267
- }
268
- },
269
- isStopped: () => cancelled
270
- };
271
- };
272
- var workloop_default = startWorkloop;
273
-
274
198
  // src/api/execute.ts
275
199
  import * as Sentry4 from "@sentry/node";
276
200
  import {
@@ -575,6 +499,50 @@ var stringify_default = (obj) => stringify(obj, (_key, value) => {
575
499
  return value;
576
500
  });
577
501
 
502
+ // src/util/try-with-backoff.ts
503
+ var BACKOFF_MULTIPLIER = 1.15;
504
+ var tryWithBackoff = (fn, opts = {}) => {
505
+ const { min = 1e3, max = 1e4, maxRuns, runs = 1 } = opts;
506
+ let cancelled = false;
507
+ if (!opts.isCancelled) {
508
+ opts.isCancelled = () => cancelled;
509
+ }
510
+ const run = async () => {
511
+ try {
512
+ await fn();
513
+ } catch (e) {
514
+ if (e?.abort) {
515
+ cancelled = true;
516
+ throw e;
517
+ }
518
+ if (opts.isCancelled()) {
519
+ return;
520
+ }
521
+ if (!isNaN(maxRuns) && runs >= maxRuns) {
522
+ throw new Error("max runs exceeded");
523
+ }
524
+ await new Promise((resolve) => setTimeout(resolve, min));
525
+ if (opts.isCancelled()) {
526
+ return;
527
+ }
528
+ const nextOpts = {
529
+ maxRuns,
530
+ runs: runs + 1,
531
+ min: Math.min(max, min * BACKOFF_MULTIPLIER),
532
+ max,
533
+ isCancelled: opts.isCancelled
534
+ };
535
+ return tryWithBackoff(fn, nextOpts);
536
+ }
537
+ };
538
+ const promise = run();
539
+ promise.cancel = () => {
540
+ cancelled = true;
541
+ };
542
+ return promise;
543
+ };
544
+ var try_with_backoff_default = tryWithBackoff;
545
+
578
546
  // src/util/timestamp.ts
579
547
  var timeInMicroseconds = (time) => time && (BigInt(time) / BigInt(1e3)).toString();
580
548
 
@@ -615,16 +583,13 @@ async function onRunLog(context, events) {
615
583
  };
616
584
  return sendEvent(context, RUN_LOG_BATCH, payload);
617
585
  } else {
618
- return new Promise(async (resolve) => {
619
- for (const log of logs) {
620
- const payload = {
621
- run_id: `${state.plan.id}`,
622
- ...log
623
- };
624
- await sendEvent(context, RUN_LOG, payload);
625
- }
626
- resolve();
627
- });
586
+ for (const log of logs) {
587
+ const payload = {
588
+ run_id: `${state.plan.id}`,
589
+ ...log
590
+ };
591
+ await sendEvent(context, RUN_LOG, payload);
592
+ }
628
593
  }
629
594
  }
630
595
 
@@ -1235,6 +1200,7 @@ var connectToWorkerQueue = (endpoint, serverId, secret, logger, options) => {
1235
1200
  messageTimeout = DEFAULT_MESSAGE_TIMEOUT_SECONDS,
1236
1201
  claimTimeout = DEFAULT_CLAIM_TIMEOUT_SECONDS,
1237
1202
  capacity,
1203
+ queues,
1238
1204
  SocketConstructor = PhxSocket
1239
1205
  } = options;
1240
1206
  const events = new EventEmitter();
@@ -1271,6 +1237,9 @@ var connectToWorkerQueue = (endpoint, serverId, secret, logger, options) => {
1271
1237
  didOpen = true;
1272
1238
  shouldReportConnectionError = true;
1273
1239
  const joinPayload = { capacity };
1240
+ if (queues) {
1241
+ joinPayload.queues = queues;
1242
+ }
1274
1243
  const channel = socket.channel("worker:queue", joinPayload);
1275
1244
  channel.onMessage = (ev, load) => {
1276
1245
  events.emit("message", ev, load);
@@ -1311,6 +1280,140 @@ var connectToWorkerQueue = (endpoint, serverId, secret, logger, options) => {
1311
1280
  };
1312
1281
  var worker_queue_default = connectToWorkerQueue;
1313
1282
 
1283
+ // src/api/workloop.ts
1284
+ var Workloop = class {
1285
+ constructor({
1286
+ id,
1287
+ queues,
1288
+ capacity
1289
+ }) {
1290
+ this.activeRuns = /* @__PURE__ */ new Set();
1291
+ this.openClaims = {};
1292
+ this.cancelled = true;
1293
+ this.id = id;
1294
+ this.queues = queues;
1295
+ this.capacity = capacity;
1296
+ }
1297
+ hasCapacity() {
1298
+ const pendingClaims = Object.values(this.openClaims).reduce(
1299
+ (a, b) => a + b,
1300
+ 0
1301
+ );
1302
+ return this.activeRuns.size + pendingClaims < this.capacity;
1303
+ }
1304
+ start(app, logger, minBackoff, maxBackoff) {
1305
+ this.logger = logger;
1306
+ this.cancelled = false;
1307
+ const loop = () => {
1308
+ if (!this.cancelled) {
1309
+ this.promise = try_with_backoff_default(() => claim_default(app, this, logger), {
1310
+ min: minBackoff,
1311
+ max: maxBackoff
1312
+ });
1313
+ this.promise.then(() => {
1314
+ if (!this.cancelled) {
1315
+ setTimeout(loop, minBackoff);
1316
+ }
1317
+ }).catch(() => {
1318
+ });
1319
+ }
1320
+ };
1321
+ loop();
1322
+ }
1323
+ stop(reason = "reason unknown") {
1324
+ if (!this.cancelled) {
1325
+ this.logger?.info(`cancelling workloop: ${reason}`);
1326
+ this.cancelled = true;
1327
+ this.promise?.cancel();
1328
+ }
1329
+ }
1330
+ isStopped() {
1331
+ return this.cancelled;
1332
+ }
1333
+ };
1334
+
1335
+ // src/util/parse-workloops.ts
1336
+ var WorkloopValidationError = class extends Error {
1337
+ constructor(message) {
1338
+ super(message);
1339
+ this.name = "WorkloopValidationError";
1340
+ }
1341
+ };
1342
+ var VALID_NAME = /^[a-zA-Z0-9_]+$/;
1343
+ function parseWorkloops(input) {
1344
+ const trimmed = input.trim();
1345
+ if (!trimmed) {
1346
+ throw new WorkloopValidationError("Workloop configuration cannot be empty");
1347
+ }
1348
+ const tokens = trimmed.split(/\s+/);
1349
+ const configs = tokens.map(parseToken);
1350
+ const seenConfigs = /* @__PURE__ */ new Map();
1351
+ for (let i = 0; i < configs.length; i++) {
1352
+ const key = JSON.stringify(configs[i].queues);
1353
+ if (seenConfigs.has(key)) {
1354
+ const prevIndex = seenConfigs.get(key);
1355
+ console.warn(
1356
+ `Warning: workloops at positions ${prevIndex} and ${i} have identical queue configurations: ${tokens[prevIndex]} and ${tokens[i]}`
1357
+ );
1358
+ } else {
1359
+ seenConfigs.set(key, i);
1360
+ }
1361
+ }
1362
+ return configs;
1363
+ }
1364
+ function parseToken(token) {
1365
+ const lastColon = token.lastIndexOf(":");
1366
+ if (lastColon === -1) {
1367
+ throw new WorkloopValidationError(
1368
+ `Invalid token "${token}": missing :<count> suffix`
1369
+ );
1370
+ }
1371
+ const prefStr = token.slice(0, lastColon);
1372
+ const countStr = token.slice(lastColon + 1);
1373
+ const count = Number(countStr);
1374
+ if (!Number.isInteger(count) || countStr !== String(Math.floor(count))) {
1375
+ throw new WorkloopValidationError(
1376
+ `Invalid count "${countStr}" in token "${token}": must be a positive integer`
1377
+ );
1378
+ }
1379
+ if (count < 1) {
1380
+ throw new WorkloopValidationError(
1381
+ `Invalid count "${countStr}" in token "${token}": must be >= 1`
1382
+ );
1383
+ }
1384
+ const names = prefStr.split(">");
1385
+ for (const name of names) {
1386
+ if (name === "") {
1387
+ throw new WorkloopValidationError(`Empty queue name in token "${token}"`);
1388
+ }
1389
+ if (name !== "*" && !VALID_NAME.test(name)) {
1390
+ throw new WorkloopValidationError(
1391
+ `Invalid queue name "${name}" in token "${token}": must match /^[a-zA-Z0-9_]+$/ or be "*"`
1392
+ );
1393
+ }
1394
+ }
1395
+ const nonWildcardNames = names.filter((n) => n !== "*");
1396
+ const seen = /* @__PURE__ */ new Set();
1397
+ for (const name of nonWildcardNames) {
1398
+ if (seen.has(name)) {
1399
+ console.warn(
1400
+ `Warning: duplicate queue name "${name}" in token "${token}"`
1401
+ );
1402
+ }
1403
+ seen.add(name);
1404
+ }
1405
+ const wildcardIndex = names.indexOf("*");
1406
+ if (wildcardIndex !== -1 && wildcardIndex !== names.length - 1) {
1407
+ throw new WorkloopValidationError(
1408
+ `Wildcard "*" must be the last element in token "${token}"`
1409
+ );
1410
+ }
1411
+ return new Workloop({ id: token, queues: names, capacity: count });
1412
+ }
1413
+
1414
+ // src/util/get-default-workloop-config.ts
1415
+ var get_default_workloop_config_default = (capacity = 5) => `manual>*:${capacity}`;
1416
+
1314
1417
  // src/server.ts
1315
1418
  var exec = promisify(_exec);
1316
1419
  var DEFAULT_PORT = 2222;
@@ -1338,8 +1441,10 @@ function connect(app, logger, options = {}) {
1338
1441
  app.resumeWorkloop();
1339
1442
  };
1340
1443
  const onDisconnect = () => {
1341
- if (!app.workloop?.isStopped()) {
1342
- app.workloop?.stop("Socket disconnected unexpectedly");
1444
+ for (const w of app.workloops) {
1445
+ if (!w.isStopped()) {
1446
+ w.stop("Socket disconnected unexpectedly");
1447
+ }
1343
1448
  }
1344
1449
  if (!app.destroyed) {
1345
1450
  logger.info("Connection to lightning lost");
@@ -1361,17 +1466,26 @@ function connect(app, logger, options = {}) {
1361
1466
  const onMessage = (event) => {
1362
1467
  if (event === WORK_AVAILABLE) {
1363
1468
  if (!app.destroyed) {
1364
- claim_default(app, logger, { maxWorkers: options.maxWorkflows }).catch(() => {
1365
- });
1469
+ for (const w of app.workloops) {
1470
+ if (w.hasCapacity()) {
1471
+ claim_default(app, w, logger).catch(() => {
1472
+ });
1473
+ }
1474
+ }
1366
1475
  }
1367
1476
  }
1368
1477
  };
1478
+ const queuesMap = {};
1479
+ for (const w of app.workloops) {
1480
+ queuesMap[w.queues.join(">")] = w.capacity;
1481
+ }
1369
1482
  worker_queue_default(options.lightning, app.id, options.secret, logger, {
1370
1483
  // TODO: options.socketTimeoutSeconds wins because this is what USED to be used
1371
1484
  // But it's deprecated and should be removed soon
1372
1485
  messageTimeout: options.socketTimeoutSeconds ?? options.messageTimeoutSeconds,
1373
1486
  claimTimeout: options.claimTimeoutSeconds,
1374
- capacity: options.maxWorkflows
1487
+ capacity: options.maxWorkflows,
1488
+ queues: queuesMap
1375
1489
  }).on("connect", onConnect).on("disconnect", onDisconnect).on("error", onError).on("message", onMessage);
1376
1490
  }
1377
1491
  async function setupCollections(options, logger) {
@@ -1420,27 +1534,36 @@ function createServer(engine, options = {}) {
1420
1534
  logger.debug(str);
1421
1535
  })
1422
1536
  );
1423
- app.openClaims = {};
1424
1537
  app.workflows = {};
1425
1538
  app.destroyed = false;
1539
+ app.workloops = parseWorkloops(
1540
+ options.workloopConfigs ?? get_default_workloop_config_default(options.maxWorkflows)
1541
+ );
1542
+ app.runWorkloopMap = {};
1426
1543
  app.server = app.listen(port);
1427
1544
  logger.success(`Worker ${app.id} listening on ${port}`);
1428
1545
  process.send?.("READY");
1429
1546
  router.get("/livez", healthcheck_default);
1430
1547
  router.get("/", healthcheck_default);
1431
1548
  app.options = options;
1432
- app.resumeWorkloop = () => {
1549
+ app.resumeWorkloop = (workloop) => {
1433
1550
  if (options.noLoop || app.destroyed) {
1434
1551
  return;
1435
1552
  }
1436
- if (!app.workloop || app.workloop?.isStopped()) {
1437
- logger.info("Starting workloop");
1438
- app.workloop = workloop_default(
1553
+ const targets = workloop ? [workloop] : app.workloops;
1554
+ for (const w of targets) {
1555
+ if (!w.hasCapacity()) {
1556
+ continue;
1557
+ }
1558
+ if (!w.isStopped()) {
1559
+ w.stop("restarting");
1560
+ }
1561
+ logger.info(`Starting workloop for ${w.id}`);
1562
+ w.start(
1439
1563
  app,
1440
1564
  logger,
1441
1565
  options.backoff?.min || MIN_BACKOFF,
1442
- options.backoff?.max || MAX_BACKOFF,
1443
- options.maxWorkflows
1566
+ options.backoff?.max || MAX_BACKOFF
1444
1567
  );
1445
1568
  }
1446
1569
  };
@@ -1485,8 +1608,16 @@ function createServer(engine, options = {}) {
1485
1608
  );
1486
1609
  delete app.workflows[id];
1487
1610
  runChannel.leave();
1488
- app.events.emit(INTERNAL_RUN_COMPLETE);
1489
- app.resumeWorkloop();
1611
+ const owningWorkloop = app.runWorkloopMap[id];
1612
+ if (owningWorkloop) {
1613
+ owningWorkloop.activeRuns.delete(id);
1614
+ delete app.runWorkloopMap[id];
1615
+ app.events.emit(INTERNAL_RUN_COMPLETE);
1616
+ app.resumeWorkloop(owningWorkloop);
1617
+ } else {
1618
+ app.events.emit(INTERNAL_RUN_COMPLETE);
1619
+ app.resumeWorkloop();
1620
+ }
1490
1621
  };
1491
1622
  const context = execute(
1492
1623
  runChannel,
@@ -1500,7 +1631,14 @@ function createServer(engine, options = {}) {
1500
1631
  app.workflows[id] = context;
1501
1632
  } catch (e) {
1502
1633
  delete app.workflows[id];
1503
- app.resumeWorkloop();
1634
+ const owningWorkloop = app.runWorkloopMap[id];
1635
+ if (owningWorkloop) {
1636
+ owningWorkloop.activeRuns.delete(id);
1637
+ delete app.runWorkloopMap[id];
1638
+ app.resumeWorkloop(owningWorkloop);
1639
+ } else {
1640
+ app.resumeWorkloop();
1641
+ }
1504
1642
  logger.error(`Unexpected error executing ${id}`);
1505
1643
  logger.error(e);
1506
1644
  }
@@ -1510,9 +1648,13 @@ function createServer(engine, options = {}) {
1510
1648
  };
1511
1649
  router.post("/claim", async (ctx) => {
1512
1650
  logger.info("triggering claim from POST request");
1513
- return claim_default(app, logger, {
1514
- maxWorkers: options.maxWorkflows
1515
- }).then(() => {
1651
+ const promises = app.workloops.map((w) => {
1652
+ if (w.hasCapacity()) {
1653
+ return claim_default(app, w, logger);
1654
+ }
1655
+ return Promise.reject(new Error("Workloop at capacity"));
1656
+ });
1657
+ return Promise.any(promises).then(() => {
1516
1658
  logger.info("claim complete: 1 run claimed");
1517
1659
  ctx.body = "complete";
1518
1660
  ctx.status = 200;
@@ -1523,10 +1665,15 @@ function createServer(engine, options = {}) {
1523
1665
  });
1524
1666
  });
1525
1667
  app.claim = () => {
1526
- return claim_default(app, logger, {
1527
- maxWorkers: options.maxWorkflows
1668
+ const promises = app.workloops.map((w) => {
1669
+ if (w.hasCapacity()) {
1670
+ return claim_default(app, w, logger);
1671
+ }
1672
+ return Promise.reject(new Error("Workloop at capacity"));
1528
1673
  });
1674
+ return Promise.any(promises);
1529
1675
  };
1676
+ app.pendingClaims = () => app.workloops.reduce((sum, w) => sum + Object.keys(w.openClaims).length, 0);
1530
1677
  app.destroy = () => destroy_default(app, logger);
1531
1678
  app.use(router.routes());
1532
1679
  if (options.lightning) {