@falcondev-oss/workflow 0.1.2 → 0.3.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.d.mts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { ConnectionOptions, Job, JobsOptions, Queue, QueueEvents, QueueEventsOptions, QueueOptions, UnrecoverableError, WorkerOptions } from "bullmq";
2
2
  import "@antfu/utils";
3
3
  import IORedis from "ioredis";
4
+ import { Span } from "@opentelemetry/api";
4
5
  import { SuperJSONResult } from "superjson";
5
6
  import { Options } from "p-retry";
6
7
  import { StandardSchemaV1 } from "@standard-schema/spec";
7
- import { SetOptional, Tagged } from "type-fest";
8
+ import { Except, IsUnknown, SetOptional, Tagged } from "type-fest";
8
9
 
9
10
  //#region src/errors.d.ts
10
11
  declare class WorkflowInputError extends UnrecoverableError {
@@ -16,6 +17,7 @@ declare class WorkflowInputError extends UnrecoverableError {
16
17
  declare const Settings: {
17
18
  defaultPrefix: string;
18
19
  defaultConnection: IORedis | undefined;
20
+ defaultCronTimezone: string | undefined;
19
21
  };
20
22
  //#endregion
21
23
  //#region src/serializer.d.ts
@@ -35,14 +37,20 @@ declare class WorkflowStep {
35
37
  private workflowId;
36
38
  private queue;
37
39
  private workflowJobId;
40
+ private stepNamePrefix;
38
41
  constructor(opts: {
39
42
  queue: WorkflowQueueInternal<any, any>;
40
43
  workflowJobId: string;
41
44
  workflowId: string;
45
+ stepNamePrefix?: string;
42
46
  });
43
- do<R>(name: string, run: () => R, options?: WorkflowStepOptions): Promise<R>;
44
- wait(name: string, durationMs: number): Promise<void>;
45
- waitUntil(name: string, date: Date): Promise<void>;
47
+ private addNamePrefix;
48
+ do<R>(stepName: string, run: (ctx: {
49
+ step: WorkflowStep;
50
+ span: Span;
51
+ }) => R, options?: WorkflowStepOptions): Promise<R>;
52
+ wait(stepName: string, durationMs: number): Promise<void>;
53
+ waitUntil(stepName: string, date: Date): Promise<void>;
46
54
  private getStepData;
47
55
  private updateStepData;
48
56
  private getWorkflowJob;
@@ -55,6 +63,7 @@ interface WorkflowStepOptions {
55
63
  type WorkflowJobInternal<Input, Output> = Job<Serialized<{
56
64
  input: Input;
57
65
  stepData: Record<string, WorkflowStepData>;
66
+ tracingHeaders: unknown;
58
67
  }>, Serialized<Output>, string>;
59
68
  type WorkflowQueueInternal<Input, Output> = Queue<WorkflowJobInternal<Input, Output>>;
60
69
  //#endregion
@@ -72,8 +81,8 @@ declare class WorkflowJob<Output> {
72
81
  //#region src/workflow.d.ts
73
82
  interface WorkflowOptions<RunInput, Input, Output> {
74
83
  id: string;
75
- input: StandardSchemaV1<RunInput, Input>;
76
- run: (context: WorkflowRunContext<Input>) => Promise<Output>;
84
+ schema?: StandardSchemaV1<RunInput, Input>;
85
+ run: (ctx: WorkflowRunContext<Input>) => Promise<Output>;
77
86
  queueOptions?: SetOptional<QueueOptions, 'connection'>;
78
87
  queueEventsOptions?: SetOptional<QueueEventsOptions, 'connection'>;
79
88
  connection?: ConnectionOptions;
@@ -85,12 +94,16 @@ declare class Workflow<RunInput, Input, Output> {
85
94
  constructor(opts: WorkflowOptions<RunInput, Input, Output>);
86
95
  work(opts?: Omit<SetOptional<WorkerOptions, 'connection'>, 'autorun'>): Promise<this>;
87
96
  run(input: RunInput, opts?: JobsOptions): Promise<WorkflowJob<Output>>;
97
+ runIn(input: RunInput, delayMs: number, opts?: Except<JobsOptions, 'delay'>): Promise<WorkflowJob<Output>>;
98
+ runAt(input: RunInput, date: Date, opts?: Except<JobsOptions, 'delay'>): Promise<WorkflowJob<Output>>;
99
+ repeat(input: RunInput, cronOrInterval: string | number, opts?: Except<JobsOptions, 'repeat'>): Promise<WorkflowJob<Output>>;
88
100
  private getOrCreateQueue;
89
101
  private getOrCreateQueueEvents;
90
102
  }
91
103
  interface WorkflowRunContext<Input> {
92
- input: Input;
104
+ input: IsUnknown<Input> extends true ? undefined : Input;
93
105
  step: WorkflowStep;
106
+ span: Span;
94
107
  }
95
108
  //#endregion
96
109
  export { Settings, Workflow, WorkflowInputError, WorkflowOptions, WorkflowRunContext };
package/dist/index.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  import { Queue, QueueEvents, UnrecoverableError, Worker } from "bullmq";
2
2
  import { createSingletonPromise } from "@antfu/utils";
3
3
  import IORedis from "ioredis";
4
+ import { ROOT_CONTEXT, SpanKind, SpanStatusCode, context, propagation, trace } from "@opentelemetry/api";
4
5
  import { deserialize, serialize } from "superjson";
5
6
  import { setTimeout } from "node:timers/promises";
6
7
  import pRetry from "p-retry";
7
- import { SpanStatusCode, trace } from "@opentelemetry/api";
8
8
 
9
9
  //#region src/errors.ts
10
10
  var WorkflowInputError = class extends UnrecoverableError {
@@ -19,7 +19,8 @@ var WorkflowInputError = class extends UnrecoverableError {
19
19
  //#region src/settings.ts
20
20
  const Settings = {
21
21
  defaultPrefix: "falcondev-oss-workflow",
22
- defaultConnection: void 0
22
+ defaultConnection: void 0,
23
+ defaultCronTimezone: void 0
23
24
  };
24
25
  const defaultRedisConnection = createSingletonPromise(async () => {
25
26
  if (Settings.defaultConnection) return Settings.defaultConnection;
@@ -59,53 +60,65 @@ var WorkflowJob = class {
59
60
  function getTracer() {
60
61
  return trace.getTracer("falcondev-oss-workflow");
61
62
  }
62
- async function runWithTracing(spanName, attributes, fn) {
63
- return getTracer().startActiveSpan(spanName, async (span) => {
64
- try {
65
- span.setAttributes(attributes);
66
- const result = await fn();
67
- span.setStatus({ code: SpanStatusCode.OK });
68
- return result;
69
- } catch (err_) {
70
- const err = err_;
71
- span.recordException(err);
72
- span.setStatus({
73
- code: SpanStatusCode.ERROR,
74
- message: err.message
75
- });
76
- throw err_;
77
- } finally {
78
- span.end();
79
- }
80
- });
63
+ async function runWithTracing(spanName, options, fn, context$1) {
64
+ const span = getTracer().startSpan(spanName, options, context$1);
65
+ try {
66
+ const result = await fn(span);
67
+ span.setStatus({ code: SpanStatusCode.OK });
68
+ return result;
69
+ } catch (err_) {
70
+ const err = err_;
71
+ span.recordException(err);
72
+ span.setStatus({
73
+ code: SpanStatusCode.ERROR,
74
+ message: err.message
75
+ });
76
+ throw err_;
77
+ } finally {
78
+ span.end();
79
+ }
81
80
  }
82
81
 
83
82
  //#endregion
84
83
  //#region src/step.ts
85
- var WorkflowStep = class {
84
+ var WorkflowStep = class WorkflowStep {
86
85
  workflowId;
87
86
  queue;
88
87
  workflowJobId;
88
+ stepNamePrefix;
89
89
  constructor(opts) {
90
90
  this.queue = opts.queue;
91
91
  this.workflowJobId = opts.workflowJobId;
92
92
  this.workflowId = opts.workflowId;
93
+ this.stepNamePrefix = opts.stepNamePrefix ? `${opts.stepNamePrefix}|` : "";
93
94
  }
94
- async do(name, run, options) {
95
- const stepData = await this.getStepData("do", "name");
96
- if (stepData && "result" in stepData) return stepData.result;
95
+ addNamePrefix(name) {
96
+ return `${this.stepNamePrefix}${name}`;
97
+ }
98
+ async do(stepName, run, options) {
99
+ const name = this.addNamePrefix(stepName);
100
+ const stepData = await this.getStepData("do", name);
101
+ if (stepData?.result) return stepData.result;
97
102
  const initialAttempt = stepData?.attempt ?? 0;
98
103
  await this.updateStepData(name, {
99
104
  type: "do",
100
105
  attempt: initialAttempt
101
106
  });
102
107
  return pRetry(async (attempt) => {
103
- const result = await runWithTracing(`step:${name}`, {
108
+ const result = await runWithTracing(`step:${name}`, { attributes: {
104
109
  "workflow.id": this.workflowId,
105
110
  "workflow.job_id": this.workflowJobId,
106
111
  "workflow.step_name": name,
107
112
  "workflow.step.attempt": attempt
108
- }, run);
113
+ } }, async (span) => run({
114
+ step: new WorkflowStep({
115
+ queue: this.queue,
116
+ workflowId: this.workflowId,
117
+ workflowJobId: this.workflowJobId,
118
+ stepNamePrefix: name
119
+ }),
120
+ span
121
+ }));
109
122
  await this.updateStepData(name, {
110
123
  type: "do",
111
124
  result,
@@ -115,16 +128,17 @@ var WorkflowStep = class {
115
128
  }, {
116
129
  ...options?.retry,
117
130
  retries: (options?.retry?.retries ?? 0) - initialAttempt,
118
- onFailedAttempt: async (context) => {
131
+ onFailedAttempt: async (ctx) => {
119
132
  await this.updateStepData(name, {
120
133
  type: "do",
121
- attempt: initialAttempt + context.attemptNumber
134
+ attempt: initialAttempt + ctx.attemptNumber
122
135
  });
123
- return options?.retry?.onFailedAttempt?.(context);
136
+ return options?.retry?.onFailedAttempt?.(ctx);
124
137
  }
125
138
  });
126
139
  }
127
- async wait(name, durationMs) {
140
+ async wait(stepName, durationMs) {
141
+ const name = this.addNamePrefix(stepName);
128
142
  const job = await this.getWorkflowJob();
129
143
  const existingStepData = await this.getStepData("wait", name);
130
144
  const now = Date.now();
@@ -134,11 +148,11 @@ var WorkflowStep = class {
134
148
  startedAt: now
135
149
  };
136
150
  await this.updateStepData(name, stepData);
137
- await runWithTracing(`step:${name}`, {
151
+ await runWithTracing(`step:${name}`, { attributes: {
138
152
  "workflow.id": this.workflowId,
139
153
  "workflow.job_id": this.workflowJobId,
140
154
  "workflow.step_name": name
141
- }, async () => {
155
+ } }, async () => {
142
156
  const remainingMs = Math.max(0, stepData.startedAt + stepData.durationMs - now);
143
157
  const interval = setInterval(() => {
144
158
  job.updateProgress(name);
@@ -147,11 +161,11 @@ var WorkflowStep = class {
147
161
  clearInterval(interval);
148
162
  });
149
163
  }
150
- async waitUntil(name, date) {
164
+ async waitUntil(stepName, date) {
151
165
  const now = Date.now();
152
166
  const targetTime = date.getTime();
153
167
  const durationMs = Math.max(0, targetTime - now);
154
- return this.wait(name, durationMs);
168
+ return this.wait(stepName, durationMs);
155
169
  }
156
170
  async getStepData(type, stepName) {
157
171
  const stepData = deserialize$1((await this.getWorkflowJob()).data).stepData[stepName];
@@ -186,21 +200,25 @@ var Workflow = class {
186
200
  const jobId = job.id;
187
201
  if (!jobId) throw new Error("Job ID is missing");
188
202
  const deserializedData = deserialize$1(job.data);
189
- const parsedData = await this.opts.input["~standard"].validate(deserializedData.input);
190
- if (parsedData.issues) throw new WorkflowInputError("Invalid workflow input", parsedData.issues);
191
- return runWithTracing(`workflow:${this.opts.id}`, {
192
- "workflow.id": this.opts.id,
193
- "workflow.job_id": jobId
194
- }, async () => {
203
+ const parsedData = this.opts.schema && await this.opts.schema["~standard"].validate(deserializedData.input);
204
+ if (parsedData?.issues) throw new WorkflowInputError("Invalid workflow input", parsedData.issues);
205
+ return runWithTracing(`workflow:work:${this.opts.id}`, {
206
+ attributes: {
207
+ "workflow.id": this.opts.id,
208
+ "workflow.job_id": jobId
209
+ },
210
+ kind: SpanKind.CONSUMER
211
+ }, async (span) => {
195
212
  return serialize$1(await this.opts.run({
196
- input: parsedData.value,
213
+ input: parsedData?.value,
197
214
  step: new WorkflowStep({
198
215
  queue,
199
216
  workflowJobId: jobId,
200
217
  workflowId: this.opts.id
201
- })
218
+ }),
219
+ span
202
220
  }));
203
- });
221
+ }, propagation.extract(ROOT_CONTEXT, deserializedData.tracingHeaders));
204
222
  }, {
205
223
  connection: this.opts.connection ?? await defaultRedisConnection(),
206
224
  prefix: Settings.defaultPrefix,
@@ -209,14 +227,42 @@ var Workflow = class {
209
227
  return this;
210
228
  }
211
229
  async run(input, opts) {
212
- const parsedInput = await this.opts.input["~standard"].validate(input);
213
- if (parsedInput.issues) throw new WorkflowInputError("Invalid workflow input", parsedInput.issues);
214
- return new WorkflowJob({
215
- job: await (await this.getOrCreateQueue()).add("workflow-job", serialize$1({
216
- input: parsedInput.value,
217
- stepData: {}
218
- }), opts),
219
- queueEvents: await this.getOrCreateQueueEvents()
230
+ const parsedInput = this.opts.schema && await this.opts.schema["~standard"].validate(input);
231
+ if (parsedInput?.issues) throw new WorkflowInputError("Invalid workflow input", parsedInput.issues);
232
+ const queue = await this.getOrCreateQueue();
233
+ return runWithTracing(`workflow:run:${this.opts.id}`, {
234
+ attributes: { "workflow.id": this.opts.id },
235
+ kind: SpanKind.PRODUCER
236
+ }, async () => {
237
+ const tracingHeaders = {};
238
+ propagation.inject(context.active(), tracingHeaders);
239
+ return new WorkflowJob({
240
+ job: await queue.add("workflow-job", serialize$1({
241
+ input: parsedInput?.value,
242
+ stepData: {},
243
+ tracingHeaders
244
+ }), opts),
245
+ queueEvents: await this.getOrCreateQueueEvents()
246
+ });
247
+ });
248
+ }
249
+ async runIn(input, delayMs, opts) {
250
+ return this.run(input, {
251
+ delay: delayMs,
252
+ ...opts
253
+ });
254
+ }
255
+ async runAt(input, date, opts) {
256
+ const now = Date.now();
257
+ return date.getTime() < now ? this.run(input, opts) : this.runIn(input, date.getTime() - Date.now(), opts);
258
+ }
259
+ async repeat(input, cronOrInterval, opts) {
260
+ return this.run(input, {
261
+ repeat: {
262
+ tz: Settings.defaultCronTimezone,
263
+ ...typeof cronOrInterval === "string" ? { pattern: cronOrInterval } : { every: cronOrInterval }
264
+ },
265
+ ...opts
220
266
  });
221
267
  }
222
268
  async getOrCreateQueue() {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@falcondev-oss/workflow",
3
3
  "type": "module",
4
- "version": "0.1.2",
4
+ "version": "0.3.0",
5
5
  "description": "Simple type-safe queue worker with durable execution based on BullMQ.",
6
6
  "license": "MIT",
7
7
  "repository": "github:falcondev-oss/workflow",