@openfn/ws-worker 1.21.4 → 1.22.0

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