@falcondev-oss/workflow 0.9.0 → 0.10.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
@@ -13,6 +13,7 @@ type WorkflowLogger = {
13
13
  success?: (...data: any[]) => void;
14
14
  error?: (...data: any[]) => void;
15
15
  debug?: (...data: any[]) => void;
16
+ warn?: (...data: any[]) => void;
16
17
  };
17
18
  declare const Settings: {
18
19
  defaultConnection: (() => Promise<IORedis> | IORedis) | undefined;
@@ -45,11 +46,16 @@ declare class WorkflowStep {
45
46
  private workflowJobId;
46
47
  private stepNamePrefix;
47
48
  private updateStepDataMutex;
49
+ private meta;
48
50
  constructor(opts: {
49
51
  queue: WorkflowQueueInternal<unknown>;
50
52
  workflowJobId: string;
51
53
  workflowId: string;
52
54
  stepNamePrefix?: string;
55
+ meta: {
56
+ stepPromises: Set<Promise<any>>;
57
+ isCanceled: boolean;
58
+ };
53
59
  });
54
60
  private addNamePrefix;
55
61
  do<R>(stepName: string, run: (ctx: {
package/dist/index.mjs CHANGED
@@ -7296,7 +7296,7 @@ var require_parser = /* @__PURE__ */ __commonJSMin(((exports, module) => {
7296
7296
  }));
7297
7297
 
7298
7298
  //#endregion
7299
- //#region node_modules/.pnpm/groupmq@1.1.1-next.2_patch_hash=0f055dcc6e397c02b849be5416debe7d948309b119c1f5282e0cfc6f2a063543_ioredis@5.9.2/node_modules/groupmq/dist/index.js
7299
+ //#region node_modules/.pnpm/groupmq@1.1.1-next.2_patch_hash=1f311e9b6cc731809ddc67335576540abb3c2e90f98c33be84e8bb3c8113391c_ioredis@5.9.2/node_modules/groupmq/dist/index.js
7300
7300
  var import_parser = /* @__PURE__ */ __toESM$1(require_parser(), 1);
7301
7301
  const __INLINED_LUA_SCRIPTS__ = {
7302
7302
  "change-delay": "-- argv: ns, jobId, newDelayUntil, now\nlocal ns = KEYS[1]\nlocal jobId = ARGV[1]\nlocal newDelayUntil = tonumber(ARGV[2])\nlocal now = tonumber(ARGV[3])\n\nlocal jobKey = ns .. \":job:\" .. jobId\nlocal delayedKey = ns .. \":delayed\"\nlocal readyKey = ns .. \":ready\"\n\n-- Check if job exists\nlocal exists = redis.call(\"EXISTS\", jobKey)\nif exists == 0 then\n return 0\nend\n\nlocal groupId = redis.call(\"HGET\", jobKey, \"groupId\")\nif not groupId then\n return 0\nend\n\nlocal gZ = ns .. \":g:\" .. groupId\n\n-- Update job's delayUntil field\nredis.call(\"HSET\", jobKey, \"delayUntil\", tostring(newDelayUntil))\n\n-- Check if job is currently in delayed set\nlocal inDelayed = redis.call(\"ZSCORE\", delayedKey, jobId)\n\nif newDelayUntil > 0 and newDelayUntil > now then\n -- Job should be delayed\n redis.call(\"HSET\", jobKey, \"status\", \"delayed\")\n if inDelayed then\n -- Update existing delay\n redis.call(\"ZADD\", delayedKey, newDelayUntil, jobId)\n else\n -- Move to delayed\n redis.call(\"ZADD\", delayedKey, newDelayUntil, jobId)\n -- If this is the head job, remove group from ready\n local head = redis.call(\"ZRANGE\", gZ, 0, 0)\n if head and #head > 0 and head[1] == jobId then\n redis.call(\"ZREM\", readyKey, groupId)\n end\n end\nelse\n -- Job should be ready immediately\n redis.call(\"HSET\", jobKey, \"status\", \"waiting\")\n if inDelayed then\n -- Remove from delayed\n redis.call(\"ZREM\", delayedKey, jobId)\n -- If this is the head job, ensure group is in ready\n local head = redis.call(\"ZRANGE\", gZ, 0, 0, \"WITHSCORES\")\n if head and #head >= 2 and head[1] == jobId then\n local headScore = tonumber(head[2])\n redis.call(\"ZADD\", readyKey, headScore, groupId)\n end\n end\nend\n\nreturn 1\n\n\n",
@@ -8782,7 +8782,7 @@ var Queue = class {
8782
8782
  async addRepeatingJob(opts) {
8783
8783
  if (!opts.repeat) throw new Error("Repeat options are required for repeating jobs");
8784
8784
  const now = Date.now();
8785
- const repeatKey = `${opts.groupId}:${JSON.stringify(opts.repeat)}:${now}:${Math.random().toString(36).slice(2)}`;
8785
+ const repeatKey = `${opts.groupId}:${now}:${Math.random().toString(36).slice(2)}`;
8786
8786
  let nextRunTime;
8787
8787
  if ("every" in opts.repeat) nextRunTime = now + opts.repeat.every;
8788
8788
  else nextRunTime = this.getNextCronTime(opts.repeat.pattern, now);
@@ -8799,7 +8799,7 @@ var Queue = class {
8799
8799
  const repeatJobKey = `${this.ns}:repeat:${repeatKey}`;
8800
8800
  await this.r.set(repeatJobKey, JSON.stringify(repeatJobData));
8801
8801
  await this.r.zadd(`${this.ns}:repeat:schedule`, nextRunTime, repeatKey);
8802
- const lookupKey = `${this.ns}:repeat:lookup:${opts.groupId}:${JSON.stringify(opts.repeat)}`;
8802
+ const lookupKey = `${this.ns}:repeat:lookup:${opts.groupId}`;
8803
8803
  await this.r.set(lookupKey, repeatKey);
8804
8804
  const repeatId = `repeat:${repeatKey}`;
8805
8805
  const jobHashKey = `${this.ns}:job:${repeatId}`;
@@ -8821,9 +8821,9 @@ var Queue = class {
8821
8821
  /**
8822
8822
  * Remove a repeating job
8823
8823
  */
8824
- async removeRepeatingJob(groupId, repeat) {
8824
+ async removeRepeatingJob(groupId) {
8825
8825
  try {
8826
- const lookupKey = `${this.ns}:repeat:lookup:${groupId}:${JSON.stringify(repeat)}`;
8826
+ const lookupKey = `${this.ns}:repeat:lookup:${groupId}`;
8827
8827
  const repeatKey = await this.r.get(lookupKey);
8828
8828
  if (!repeatKey) return false;
8829
8829
  const repeatJobKey = `${this.ns}:repeat:${repeatKey}`;
@@ -9628,11 +9628,13 @@ var WorkflowStep = class WorkflowStep {
9628
9628
  workflowJobId;
9629
9629
  stepNamePrefix;
9630
9630
  updateStepDataMutex = new Mutex();
9631
+ meta;
9631
9632
  constructor(opts) {
9632
9633
  this.queue = opts.queue;
9633
9634
  this.workflowJobId = opts.workflowJobId;
9634
9635
  this.workflowId = opts.workflowId;
9635
9636
  this.stepNamePrefix = opts.stepNamePrefix ? `${opts.stepNamePrefix}|` : "";
9637
+ this.meta = opts.meta;
9636
9638
  }
9637
9639
  addNamePrefix(name) {
9638
9640
  return `${this.stepNamePrefix}${name}`;
@@ -9650,7 +9652,7 @@ var WorkflowStep = class WorkflowStep {
9650
9652
  attempt: initialAttempt
9651
9653
  });
9652
9654
  Settings.logger?.debug?.(`[${this.workflowId}/${this.workflowJobId}] Running step '${name}' (attempt ${initialAttempt + 1})`);
9653
- return pRetry(async (attempt) => {
9655
+ const promise = pRetry(async (attempt) => {
9654
9656
  const result = await runWithTracing(`workflow-worker/${this.workflowId}/step/${name}`, { attributes: {
9655
9657
  "workflow.id": this.workflowId,
9656
9658
  "workflow.job_id": this.workflowJobId,
@@ -9661,7 +9663,8 @@ var WorkflowStep = class WorkflowStep {
9661
9663
  queue: this.queue,
9662
9664
  workflowId: this.workflowId,
9663
9665
  workflowJobId: this.workflowJobId,
9664
- stepNamePrefix: name
9666
+ stepNamePrefix: name,
9667
+ meta: this.meta
9665
9668
  }),
9666
9669
  span
9667
9670
  }));
@@ -9682,8 +9685,14 @@ var WorkflowStep = class WorkflowStep {
9682
9685
  attempt: initialAttempt + ctx.attemptNumber
9683
9686
  });
9684
9687
  return options?.retry?.onFailedAttempt?.(ctx);
9688
+ },
9689
+ shouldRetry: async (context$1) => {
9690
+ if (this.meta.isCanceled) return false;
9691
+ return options?.retry?.shouldRetry?.(context$1) ?? true;
9685
9692
  }
9686
9693
  });
9694
+ this.meta.stepPromises.add(promise);
9695
+ return promise.finally(() => this.meta.stepPromises.delete(promise));
9687
9696
  }
9688
9697
  async wait(stepName, durationMs) {
9689
9698
  const name = this.addNamePrefix(stepName);
@@ -9758,19 +9767,33 @@ var Workflow = class {
9758
9767
  },
9759
9768
  kind: SpanKind.CONSUMER
9760
9769
  }, async (span) => {
9770
+ const stepMeta = {
9771
+ stepPromises: /* @__PURE__ */ new Set(),
9772
+ isCanceled: false
9773
+ };
9761
9774
  const start = performance.now();
9762
- const result = await this.opts.run({
9763
- input: parsedData?.value,
9764
- step: new WorkflowStep({
9765
- queue,
9766
- workflowJobId: jobId,
9767
- workflowId: this.opts.id
9768
- }),
9769
- span
9770
- });
9771
- const end = performance.now();
9772
- Settings.logger?.success?.(`[${this.opts.id}] Completed job ${job.id} in ${(end - start).toFixed(2)} ms`);
9773
- return serialize$1(result);
9775
+ try {
9776
+ const result = await this.opts.run({
9777
+ input: parsedData?.value,
9778
+ step: new WorkflowStep({
9779
+ queue,
9780
+ workflowJobId: jobId,
9781
+ workflowId: this.opts.id,
9782
+ meta: stepMeta
9783
+ }),
9784
+ span
9785
+ });
9786
+ const end = performance.now();
9787
+ Settings.logger?.success?.(`[${this.opts.id}] Completed job ${job.id} in ${(end - start).toFixed(2)} ms`);
9788
+ return serialize$1(result);
9789
+ } catch (err) {
9790
+ stepMeta.isCanceled = true;
9791
+ if (stepMeta.stepPromises.size > 0) {
9792
+ Settings.logger?.warn?.(`[${this.opts.id}] Job failed but there are still ${stepMeta.stepPromises.size} running step(s), waiting for them to finish. Be careful when using 'Promise.all([step0, step1, ...])', as running steps are not canceled when one of them fails.`);
9793
+ await Promise.allSettled(stepMeta.stepPromises);
9794
+ }
9795
+ throw err;
9796
+ }
9774
9797
  }, propagation.extract(ROOT_CONTEXT, deserializedData.tracingHeaders));
9775
9798
  },
9776
9799
  queue,
@@ -9796,6 +9819,10 @@ var Workflow = class {
9796
9819
  const parsedInput = this.opts.schema && await this.opts.schema["~standard"].validate(input);
9797
9820
  if (parsedInput?.issues) throw new Error("Invalid workflow input");
9798
9821
  const queue = await this.getOrCreateQueue();
9822
+ const groupId = opts?.groupId ?? await this.opts.getGroupId?.(parsedInput?.value) ?? randomUUID();
9823
+ if (opts?.repeat) {
9824
+ if (await queue.removeRepeatingJob(groupId)) Settings.logger?.debug?.(`[${this.opts.id}] Removed existing repeating job with groupId '${groupId}' before adding new one with schedule ${JSON.stringify(opts.repeat)}`);
9825
+ }
9799
9826
  return runWithTracing(`workflow-producer/${this.opts.id}`, {
9800
9827
  attributes: { "workflow.id": this.opts.id },
9801
9828
  kind: SpanKind.PRODUCER
@@ -9804,7 +9831,6 @@ var Workflow = class {
9804
9831
  propagation.inject(context.active(), tracingHeaders);
9805
9832
  const orderMs = opts?.orderMs ?? (opts?.priority === "high" ? 0 : void 0) ?? this.opts.jobOptions?.orderMs ?? (this.opts.jobOptions?.priority === "high" ? 0 : void 0);
9806
9833
  return new WorkflowJob({ job: await queue.add({
9807
- groupId: await this.opts.getGroupId?.(parsedInput?.value) ?? randomUUID(),
9808
9834
  data: serialize$1({
9809
9835
  input: parsedInput?.value,
9810
9836
  stepData: {},
@@ -9812,6 +9838,7 @@ var Workflow = class {
9812
9838
  }),
9813
9839
  ...this.opts.jobOptions,
9814
9840
  ...opts,
9841
+ groupId,
9815
9842
  orderMs
9816
9843
  }) });
9817
9844
  });
@@ -9831,6 +9858,7 @@ var Workflow = class {
9831
9858
  async "runCron"(scheduleId, cron, input, opts) {
9832
9859
  return this.run(input, {
9833
9860
  groupId: scheduleId,
9861
+ jobId: scheduleId,
9834
9862
  repeat: { pattern: cron },
9835
9863
  ...opts
9836
9864
  });
@@ -9838,6 +9866,7 @@ var Workflow = class {
9838
9866
  async "runEvery"(scheduleId, everyMs, input, opts) {
9839
9867
  return this.run(input, {
9840
9868
  groupId: scheduleId,
9869
+ jobId: scheduleId,
9841
9870
  repeat: { every: everyMs },
9842
9871
  ...opts
9843
9872
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@falcondev-oss/workflow",
3
3
  "type": "module",
4
- "version": "0.9.0",
4
+ "version": "0.10.0",
5
5
  "description": "Simple type-safe queue worker with durable execution using Redis.",
6
6
  "license": "MIT",
7
7
  "repository": "github:falcondev-oss/workflow",