@openfn/ws-worker 0.1.4 → 0.1.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # ws-worker
2
2
 
3
+ ## 0.1.6
4
+
5
+ ### Patch Changes
6
+
7
+ - 5037c68: Support maxWorkers flag
8
+ Enforce minBackoff before claiming the next job
9
+ - Updated dependencies [ac7b0ca]
10
+ - @openfn/engine-multi@0.1.4
11
+
12
+ ## 0.1.5
13
+
14
+ ### Patch Changes
15
+
16
+ - Test release
17
+
3
18
  ## 0.1.4
4
19
 
5
20
  ### Patch Changes
package/dist/index.d.ts CHANGED
@@ -1,6 +1,50 @@
1
1
  import Koa from 'koa';
2
- import { Logger } from '@openfn/logger';
2
+ import { SanitizePolicies, Logger } from '@openfn/logger';
3
3
  import { RuntimeEngine } from '@openfn/engine-multi';
4
+ import { Channel as Channel$1 } from 'phoenix';
5
+ import { ExecutionPlan } from '@openfn/runtime';
6
+
7
+ type AttemptOptions = {
8
+ timeout?: number;
9
+ sanitize?: SanitizePolicies;
10
+ };
11
+
12
+ type ReceiveHook = {
13
+ receive: (
14
+ status: 'ok' | 'timeout' | 'error',
15
+ callback: (payload?: any) => void
16
+ ) => ReceiveHook;
17
+ };
18
+
19
+ // export declare class Socket extends PhxSocket {
20
+ // constructor(endpoint: string, options: { params: any });
21
+ // onOpen(callback: () => void): void;
22
+ // connect(): void;
23
+ // channel(channelName: string, params: any): Channel;
24
+ // }
25
+
26
+ interface Channel extends Channel$1 {
27
+ // on: (event: string, fn: (evt: any) => void) => void;
28
+
29
+ // TODO it would be super nice to infer the event from the payload
30
+ push: <P = any>(event: string, payload?: P) => ReceiveHook;
31
+ // join: () => ReceiveHook;
32
+ }
33
+
34
+ declare type AttemptState = {
35
+ activeRun?: string;
36
+ activeJob?: string;
37
+ plan: ExecutionPlan;
38
+ options: AttemptOptions;
39
+ dataclips: Record<string, any>;
40
+ lastDataclipId?: string;
41
+ };
42
+ declare type Context = {
43
+ channel: Channel;
44
+ state: AttemptState;
45
+ logger: Logger;
46
+ onComplete: (result: any) => void;
47
+ };
4
48
 
5
49
  declare type CLAIM_ATTEMPT = {
6
50
  id: string;
@@ -22,7 +66,8 @@ declare type ServerOptions = {
22
66
  interface ServerApp extends Koa {
23
67
  id: string;
24
68
  socket: any;
25
- channel: any;
69
+ channel: Channel;
70
+ workflows: Record<string, true | Context>;
26
71
  execute: ({ id, token }: CLAIM_ATTEMPT) => Promise<void>;
27
72
  destroy: () => void;
28
73
  killWorkloop: () => void;
package/dist/index.js CHANGED
@@ -63,17 +63,21 @@ var RUN_COMPLETE = "run:complete";
63
63
 
64
64
  // src/api/claim.ts
65
65
  var mockLogger = createMockLogger();
66
- var claim = (channel, execute2, logger = mockLogger) => {
66
+ var claim = (app, logger = mockLogger, maxWorkers = 5) => {
67
67
  return new Promise((resolve, reject) => {
68
+ const activeWorkers = Object.keys(app.workflows).length;
69
+ if (activeWorkers >= maxWorkers) {
70
+ return reject(new Error("Server at capacity"));
71
+ }
68
72
  logger.debug("requesting attempt...");
69
- channel.push(CLAIM, { demand: 1 }).receive("ok", ({ attempts }) => {
73
+ app.channel.push(CLAIM, { demand: 1 }).receive("ok", ({ attempts }) => {
70
74
  logger.debug(`pulled ${attempts.length} attempts`);
71
75
  if (!attempts?.length) {
72
- return reject(new Error("claim failed"));
76
+ return reject(new Error("No attempts returned"));
73
77
  }
74
78
  attempts.forEach((attempt) => {
75
79
  logger.debug("starting attempt", attempt.id);
76
- execute2(attempt);
80
+ app.execute(attempt);
77
81
  resolve();
78
82
  });
79
83
  });
@@ -82,18 +86,18 @@ var claim = (channel, execute2, logger = mockLogger) => {
82
86
  var claim_default = claim;
83
87
 
84
88
  // src/api/workloop.ts
85
- var startWorkloop = (channel, execute2, logger, minBackoff, maxBackoff) => {
89
+ var startWorkloop = (app, logger, minBackoff, maxBackoff, maxWorkers) => {
86
90
  let promise;
87
91
  let cancelled = false;
88
92
  const workLoop = () => {
89
93
  if (!cancelled) {
90
- promise = try_with_backoff_default(() => claim_default(channel, execute2, logger), {
94
+ promise = try_with_backoff_default(() => claim_default(app, logger, maxWorkers), {
91
95
  min: minBackoff,
92
96
  max: maxBackoff
93
97
  });
94
98
  promise.then(() => {
95
99
  if (!cancelled) {
96
- workLoop();
100
+ setTimeout(workLoop, minBackoff);
97
101
  }
98
102
  });
99
103
  }
@@ -215,59 +219,62 @@ var eventMap = {
215
219
  log: ATTEMPT_LOG,
216
220
  "workflow-complete": ATTEMPT_COMPLETE
217
221
  };
218
- function execute(channel, engine, logger, plan, options = {}) {
219
- return new Promise(async (resolve, reject) => {
220
- logger.info("execute...");
221
- const state = {
222
- plan,
223
- lastDataclipId: plan.initialState,
224
- dataclips: {},
225
- options
222
+ function execute(channel, engine, logger, plan, options = {}, onComplete = (_result) => {
223
+ }) {
224
+ logger.info("execute...");
225
+ const state = {
226
+ plan,
227
+ lastDataclipId: plan.initialState,
228
+ dataclips: {},
229
+ options
230
+ };
231
+ const context = { channel, state, logger, onComplete };
232
+ const addEvent = (eventName, handler) => {
233
+ const wrappedFn = async (event) => {
234
+ const lightningEvent = eventMap[eventName];
235
+ try {
236
+ await handler(context, event);
237
+ logger.info(`${plan.id} :: ${lightningEvent} :: OK`);
238
+ } catch (e) {
239
+ logger.error(
240
+ `${plan.id} :: ${lightningEvent} :: ERR: ${e.message || e.toString()}`
241
+ );
242
+ logger.error(e);
243
+ }
226
244
  };
227
- const context = { channel, state, logger, onComplete: resolve };
228
- const addEvent = (eventName, handler) => {
229
- const wrappedFn = async (event) => {
230
- const lightningEvent = eventMap[eventName];
231
- try {
232
- await handler(context, event);
233
- logger.info(`${plan.id} :: ${lightningEvent} :: OK`);
234
- } catch (e) {
235
- logger.error(
236
- `${plan.id} :: ${lightningEvent} :: ERR: ${e.message || e.toString()}`
237
- );
238
- logger.error(e);
239
- }
240
- };
241
- return {
242
- [eventName]: wrappedFn
243
- };
244
- };
245
- const listeners = Object.assign(
246
- {},
247
- addEvent("workflow-start", onWorkflowStart),
248
- addEvent("job-start", onJobStart),
249
- addEvent("job-complete", onJobComplete),
250
- addEvent("workflow-log", onJobLog),
251
- addEvent("workflow-complete", onWorkflowComplete),
252
- addEvent("workflow-error", onWorkflowError)
253
- );
254
- engine.listen(plan.id, listeners);
255
- const resolvers = {
256
- credential: (id) => loadCredential(channel, id)
245
+ return {
246
+ [eventName]: wrappedFn
257
247
  };
248
+ };
249
+ const listeners = Object.assign(
250
+ {},
251
+ addEvent("workflow-start", onWorkflowStart),
252
+ addEvent("job-start", onJobStart),
253
+ addEvent("job-complete", onJobComplete),
254
+ addEvent("workflow-log", onJobLog),
255
+ addEvent("workflow-complete", onWorkflowComplete),
256
+ addEvent("workflow-error", onWorkflowError)
257
+ );
258
+ engine.listen(plan.id, listeners);
259
+ const resolvers = {
260
+ credential: (id) => loadCredential(channel, id)
261
+ };
262
+ Promise.resolve().then(async () => {
258
263
  if (typeof plan.initialState === "string") {
259
264
  logger.debug("loading dataclip", plan.initialState);
260
265
  plan.initialState = await loadDataclip(channel, plan.initialState);
261
266
  logger.success("dataclip loaded");
262
267
  logger.debug(plan.initialState);
263
268
  }
269
+ return plan;
270
+ }).then(() => {
264
271
  try {
265
272
  engine.execute(plan, { resolvers, ...options });
266
273
  } catch (e) {
267
274
  onWorkflowError(context, { workflowId: plan.id, message: e.message });
268
- reject(e);
269
275
  }
270
276
  });
277
+ return context;
271
278
  }
272
279
  var sendEvent = (channel, event, payload) => new Promise((resolve, reject) => {
273
280
  channel.push(event, payload).receive("error", reject).receive("timeout", () => reject(new Error("timeout"))).receive("ok", resolve);
@@ -443,11 +450,11 @@ function connect(app, engine, logger, options = {}) {
443
450
  }
444
451
  logger.info("Starting workloop");
445
452
  app.killWorkloop = workloop_default(
446
- channel,
447
- app.execute,
453
+ app,
448
454
  logger,
449
455
  options.backoff?.min || MIN_BACKOFF,
450
- options.backoff?.max || MAX_BACKOFF
456
+ options.backoff?.max || MAX_BACKOFF,
457
+ options.maxWorkflows
451
458
  );
452
459
  } else {
453
460
  logger.break();
@@ -484,23 +491,36 @@ function createServer(engine, options = {}) {
484
491
  logger.debug(str);
485
492
  })
486
493
  );
494
+ app.workflows = {};
487
495
  const server = app.listen(port);
488
496
  logger.success(`ws-worker ${app.id} listening on ${port}`);
489
497
  app.execute = async ({ id, token }) => {
490
498
  if (app.socket) {
499
+ app.workflows[id] = true;
491
500
  const {
492
501
  channel: attemptChannel,
493
502
  plan,
494
503
  options: options2
495
504
  } = await attempt_default(app.socket, token, id, logger);
496
- execute(attemptChannel, engine, logger, plan, options2);
505
+ const onComplete = () => {
506
+ delete app.workflows[id];
507
+ };
508
+ const context = execute(
509
+ attemptChannel,
510
+ engine,
511
+ logger,
512
+ plan,
513
+ options2,
514
+ onComplete
515
+ );
516
+ app.workflows[id] = context;
497
517
  } else {
498
518
  logger.error("No lightning socket established");
499
519
  }
500
520
  };
501
521
  router.post("/claim", async (ctx) => {
502
522
  logger.info("triggering claim from POST request");
503
- return claim_default(app.channel, app.execute, logger).then(() => {
523
+ return claim_default(app, logger, options.maxWorkflows).then(() => {
504
524
  logger.info("claim complete: 1 attempt claimed");
505
525
  ctx.body = "complete";
506
526
  ctx.status = 200;
package/dist/start.js CHANGED
@@ -4924,12 +4924,20 @@ async function createMock() {
4924
4924
  dispatch("job-start", { workflowId, jobId, runId });
4925
4925
  info("Running job " + jobId);
4926
4926
  let nextState = initialState;
4927
- try {
4928
- nextState = JSON.parse(expression);
4929
- info("Parsing expression as JSON state");
4930
- info(nextState);
4931
- } catch (e) {
4927
+ if (expression?.startsWith?.("wait@")) {
4928
+ const [_, delay] = expression.split("@");
4932
4929
  nextState = initialState;
4930
+ await new Promise((resolve5) => {
4931
+ setTimeout(() => resolve5(), parseInt(delay));
4932
+ });
4933
+ } else {
4934
+ try {
4935
+ nextState = JSON.parse(expression);
4936
+ info("Parsing expression as JSON state");
4937
+ info(nextState);
4938
+ } catch (e) {
4939
+ nextState = initialState;
4940
+ }
4933
4941
  }
4934
4942
  dispatch("job-complete", { workflowId, jobId, state: nextState, runId });
4935
4943
  return nextState;
@@ -5037,17 +5045,21 @@ var RUN_COMPLETE = "run:complete";
5037
5045
 
5038
5046
  // src/api/claim.ts
5039
5047
  var mockLogger = createMockLogger();
5040
- var claim = (channel, execute2, logger2 = mockLogger) => {
5048
+ var claim = (app, logger2 = mockLogger, maxWorkers = 5) => {
5041
5049
  return new Promise((resolve5, reject) => {
5050
+ const activeWorkers = Object.keys(app.workflows).length;
5051
+ if (activeWorkers >= maxWorkers) {
5052
+ return reject(new Error("Server at capacity"));
5053
+ }
5042
5054
  logger2.debug("requesting attempt...");
5043
- channel.push(CLAIM, { demand: 1 }).receive("ok", ({ attempts }) => {
5055
+ app.channel.push(CLAIM, { demand: 1 }).receive("ok", ({ attempts }) => {
5044
5056
  logger2.debug(`pulled ${attempts.length} attempts`);
5045
5057
  if (!attempts?.length) {
5046
- return reject(new Error("claim failed"));
5058
+ return reject(new Error("No attempts returned"));
5047
5059
  }
5048
5060
  attempts.forEach((attempt) => {
5049
5061
  logger2.debug("starting attempt", attempt.id);
5050
- execute2(attempt);
5062
+ app.execute(attempt);
5051
5063
  resolve5();
5052
5064
  });
5053
5065
  });
@@ -5056,18 +5068,18 @@ var claim = (channel, execute2, logger2 = mockLogger) => {
5056
5068
  var claim_default = claim;
5057
5069
 
5058
5070
  // src/api/workloop.ts
5059
- var startWorkloop = (channel, execute2, logger2, minBackoff2, maxBackoff2) => {
5071
+ var startWorkloop = (app, logger2, minBackoff2, maxBackoff2, maxWorkers) => {
5060
5072
  let promise;
5061
5073
  let cancelled = false;
5062
5074
  const workLoop = () => {
5063
5075
  if (!cancelled) {
5064
- promise = try_with_backoff_default(() => claim_default(channel, execute2, logger2), {
5076
+ promise = try_with_backoff_default(() => claim_default(app, logger2, maxWorkers), {
5065
5077
  min: minBackoff2,
5066
5078
  max: maxBackoff2
5067
5079
  });
5068
5080
  promise.then(() => {
5069
5081
  if (!cancelled) {
5070
- workLoop();
5082
+ setTimeout(workLoop, minBackoff2);
5071
5083
  }
5072
5084
  });
5073
5085
  }
@@ -5189,59 +5201,62 @@ var eventMap = {
5189
5201
  log: ATTEMPT_LOG,
5190
5202
  "workflow-complete": ATTEMPT_COMPLETE
5191
5203
  };
5192
- function execute(channel, engine, logger2, plan, options = {}) {
5193
- return new Promise(async (resolve5, reject) => {
5194
- logger2.info("execute...");
5195
- const state = {
5196
- plan,
5197
- lastDataclipId: plan.initialState,
5198
- dataclips: {},
5199
- options
5200
- };
5201
- const context = { channel, state, logger: logger2, onComplete: resolve5 };
5202
- const addEvent = (eventName, handler) => {
5203
- const wrappedFn = async (event) => {
5204
- const lightningEvent = eventMap[eventName];
5205
- try {
5206
- await handler(context, event);
5207
- logger2.info(`${plan.id} :: ${lightningEvent} :: OK`);
5208
- } catch (e) {
5209
- logger2.error(
5210
- `${plan.id} :: ${lightningEvent} :: ERR: ${e.message || e.toString()}`
5211
- );
5212
- logger2.error(e);
5213
- }
5214
- };
5215
- return {
5216
- [eventName]: wrappedFn
5217
- };
5204
+ function execute(channel, engine, logger2, plan, options = {}, onComplete = (_result) => {
5205
+ }) {
5206
+ logger2.info("execute...");
5207
+ const state = {
5208
+ plan,
5209
+ lastDataclipId: plan.initialState,
5210
+ dataclips: {},
5211
+ options
5212
+ };
5213
+ const context = { channel, state, logger: logger2, onComplete };
5214
+ const addEvent = (eventName, handler) => {
5215
+ const wrappedFn = async (event) => {
5216
+ const lightningEvent = eventMap[eventName];
5217
+ try {
5218
+ await handler(context, event);
5219
+ logger2.info(`${plan.id} :: ${lightningEvent} :: OK`);
5220
+ } catch (e) {
5221
+ logger2.error(
5222
+ `${plan.id} :: ${lightningEvent} :: ERR: ${e.message || e.toString()}`
5223
+ );
5224
+ logger2.error(e);
5225
+ }
5218
5226
  };
5219
- const listeners = Object.assign(
5220
- {},
5221
- addEvent("workflow-start", onWorkflowStart),
5222
- addEvent("job-start", onJobStart),
5223
- addEvent("job-complete", onJobComplete),
5224
- addEvent("workflow-log", onJobLog),
5225
- addEvent("workflow-complete", onWorkflowComplete),
5226
- addEvent("workflow-error", onWorkflowError)
5227
- );
5228
- engine.listen(plan.id, listeners);
5229
- const resolvers = {
5230
- credential: (id) => loadCredential(channel, id)
5227
+ return {
5228
+ [eventName]: wrappedFn
5231
5229
  };
5230
+ };
5231
+ const listeners = Object.assign(
5232
+ {},
5233
+ addEvent("workflow-start", onWorkflowStart),
5234
+ addEvent("job-start", onJobStart),
5235
+ addEvent("job-complete", onJobComplete),
5236
+ addEvent("workflow-log", onJobLog),
5237
+ addEvent("workflow-complete", onWorkflowComplete),
5238
+ addEvent("workflow-error", onWorkflowError)
5239
+ );
5240
+ engine.listen(plan.id, listeners);
5241
+ const resolvers = {
5242
+ credential: (id) => loadCredential(channel, id)
5243
+ };
5244
+ Promise.resolve().then(async () => {
5232
5245
  if (typeof plan.initialState === "string") {
5233
5246
  logger2.debug("loading dataclip", plan.initialState);
5234
5247
  plan.initialState = await loadDataclip(channel, plan.initialState);
5235
5248
  logger2.success("dataclip loaded");
5236
5249
  logger2.debug(plan.initialState);
5237
5250
  }
5251
+ return plan;
5252
+ }).then(() => {
5238
5253
  try {
5239
5254
  engine.execute(plan, { resolvers, ...options });
5240
5255
  } catch (e) {
5241
5256
  onWorkflowError(context, { workflowId: plan.id, message: e.message });
5242
- reject(e);
5243
5257
  }
5244
5258
  });
5259
+ return context;
5245
5260
  }
5246
5261
  var sendEvent = (channel, event, payload) => new Promise((resolve5, reject) => {
5247
5262
  channel.push(event, payload).receive("error", reject).receive("timeout", () => reject(new Error("timeout"))).receive("ok", resolve5);
@@ -5417,11 +5432,11 @@ function connect(app, engine, logger2, options = {}) {
5417
5432
  }
5418
5433
  logger2.info("Starting workloop");
5419
5434
  app.killWorkloop = workloop_default(
5420
- channel,
5421
- app.execute,
5435
+ app,
5422
5436
  logger2,
5423
5437
  options.backoff?.min || MIN_BACKOFF,
5424
- options.backoff?.max || MAX_BACKOFF
5438
+ options.backoff?.max || MAX_BACKOFF,
5439
+ options.maxWorkflows
5425
5440
  );
5426
5441
  } else {
5427
5442
  logger2.break();
@@ -5458,23 +5473,36 @@ function createServer(engine, options = {}) {
5458
5473
  logger2.debug(str);
5459
5474
  })
5460
5475
  );
5476
+ app.workflows = {};
5461
5477
  const server = app.listen(port);
5462
5478
  logger2.success(`ws-worker ${app.id} listening on ${port}`);
5463
5479
  app.execute = async ({ id, token }) => {
5464
5480
  if (app.socket) {
5481
+ app.workflows[id] = true;
5465
5482
  const {
5466
5483
  channel: attemptChannel,
5467
5484
  plan,
5468
5485
  options: options2
5469
5486
  } = await attempt_default(app.socket, token, id, logger2);
5470
- execute(attemptChannel, engine, logger2, plan, options2);
5487
+ const onComplete = () => {
5488
+ delete app.workflows[id];
5489
+ };
5490
+ const context = execute(
5491
+ attemptChannel,
5492
+ engine,
5493
+ logger2,
5494
+ plan,
5495
+ options2,
5496
+ onComplete
5497
+ );
5498
+ app.workflows[id] = context;
5471
5499
  } else {
5472
5500
  logger2.error("No lightning socket established");
5473
5501
  }
5474
5502
  };
5475
5503
  router.post("/claim", async (ctx) => {
5476
5504
  logger2.info("triggering claim from POST request");
5477
- return claim_default(app.channel, app.execute, logger2).then(() => {
5505
+ return claim_default(app, logger2, options.maxWorkflows).then(() => {
5478
5506
  logger2.info("claim complete: 1 attempt claimed");
5479
5507
  ctx.body = "complete";
5480
5508
  ctx.status = 200;
@@ -5535,6 +5563,10 @@ var args = yargs_default(hideBin(process.argv)).command("server", "Start a ws-wo
5535
5563
  }).option("backoff", {
5536
5564
  description: "Claim backoff rules: min/max (s)",
5537
5565
  default: "1/10"
5566
+ }).option("capacity", {
5567
+ description: "max concurrent workers",
5568
+ default: 5,
5569
+ type: "number"
5538
5570
  }).parse();
5539
5571
  var logger = createLogger("SRV", { level: args.log });
5540
5572
  if (args.lightning === "mock") {
@@ -5562,7 +5594,8 @@ function engineReady(engine) {
5562
5594
  backoff: {
5563
5595
  min: minBackoff,
5564
5596
  max: maxBackoff
5565
- }
5597
+ },
5598
+ maxWorkflows: args.capacity
5566
5599
  });
5567
5600
  }
5568
5601
  if (args.mock) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openfn/ws-worker",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "A Websocket Worker to connect Lightning to a Runtime Engine",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -21,7 +21,7 @@
21
21
  "koa-logger": "^3.2.1",
22
22
  "phoenix": "^1.7.7",
23
23
  "ws": "^8.14.1",
24
- "@openfn/engine-multi": "0.1.3",
24
+ "@openfn/engine-multi": "0.1.4",
25
25
  "@openfn/logger": "0.0.18",
26
26
  "@openfn/runtime": "0.0.32"
27
27
  },
@@ -40,7 +40,7 @@
40
40
  "tsup": "^6.2.3",
41
41
  "typescript": "^4.6.4",
42
42
  "yargs": "^17.6.2",
43
- "@openfn/lightning-mock": "1.0.4"
43
+ "@openfn/lightning-mock": "1.0.5"
44
44
  },
45
45
  "files": [
46
46
  "dist",