@sonamu-kit/tasks 0.1.3 → 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.
Files changed (215) hide show
  1. package/.oxlintrc.json +3 -0
  2. package/AGENTS.md +21 -0
  3. package/dist/backend.d.ts +126 -103
  4. package/dist/backend.d.ts.map +1 -1
  5. package/dist/backend.js +4 -1
  6. package/dist/backend.js.map +1 -1
  7. package/dist/client.d.ts +145 -132
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +220 -212
  10. package/dist/client.js.map +1 -1
  11. package/dist/config.d.ts +15 -8
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +22 -17
  14. package/dist/config.js.map +1 -1
  15. package/dist/core/duration.d.ts +5 -4
  16. package/dist/core/duration.d.ts.map +1 -1
  17. package/dist/core/duration.js +54 -59
  18. package/dist/core/duration.js.map +1 -1
  19. package/dist/core/error.d.ts +10 -7
  20. package/dist/core/error.d.ts.map +1 -1
  21. package/dist/core/error.js +21 -21
  22. package/dist/core/error.js.map +1 -1
  23. package/dist/core/json.d.ts +8 -3
  24. package/dist/core/json.d.ts.map +1 -1
  25. package/dist/core/result.d.ts +10 -14
  26. package/dist/core/result.d.ts.map +1 -1
  27. package/dist/core/result.js +21 -16
  28. package/dist/core/result.js.map +1 -1
  29. package/dist/core/retry.d.ts +42 -20
  30. package/dist/core/retry.d.ts.map +1 -1
  31. package/dist/core/retry.js +49 -20
  32. package/dist/core/retry.js.map +1 -1
  33. package/dist/core/schema.d.ts +57 -53
  34. package/dist/core/schema.d.ts.map +1 -1
  35. package/dist/core/step.d.ts +28 -78
  36. package/dist/core/step.d.ts.map +1 -1
  37. package/dist/core/step.js +53 -63
  38. package/dist/core/step.js.map +1 -1
  39. package/dist/core/workflow.d.ts +33 -61
  40. package/dist/core/workflow.d.ts.map +1 -1
  41. package/dist/core/workflow.js +31 -41
  42. package/dist/core/workflow.js.map +1 -1
  43. package/dist/database/backend.d.ts +53 -46
  44. package/dist/database/backend.d.ts.map +1 -1
  45. package/dist/database/backend.js +544 -545
  46. package/dist/database/backend.js.map +1 -1
  47. package/dist/database/base.js +48 -25
  48. package/dist/database/base.js.map +1 -1
  49. package/dist/database/migrations/20251212000000_0_init.d.ts +10 -0
  50. package/dist/database/migrations/20251212000000_0_init.d.ts.map +1 -0
  51. package/dist/database/migrations/20251212000000_0_init.js +8 -4
  52. package/dist/database/migrations/20251212000000_0_init.js.map +1 -1
  53. package/dist/database/migrations/20251212000000_1_tables.d.ts +10 -0
  54. package/dist/database/migrations/20251212000000_1_tables.d.ts.map +1 -0
  55. package/dist/database/migrations/20251212000000_1_tables.js +81 -83
  56. package/dist/database/migrations/20251212000000_1_tables.js.map +1 -1
  57. package/dist/database/migrations/20251212000000_2_fk.d.ts +10 -0
  58. package/dist/database/migrations/20251212000000_2_fk.d.ts.map +1 -0
  59. package/dist/database/migrations/20251212000000_2_fk.js +20 -43
  60. package/dist/database/migrations/20251212000000_2_fk.js.map +1 -1
  61. package/dist/database/migrations/20251212000000_3_indexes.d.ts +10 -0
  62. package/dist/database/migrations/20251212000000_3_indexes.d.ts.map +1 -0
  63. package/dist/database/migrations/20251212000000_3_indexes.js +88 -102
  64. package/dist/database/migrations/20251212000000_3_indexes.js.map +1 -1
  65. package/dist/database/pubsub.d.ts +7 -16
  66. package/dist/database/pubsub.d.ts.map +1 -1
  67. package/dist/database/pubsub.js +75 -73
  68. package/dist/database/pubsub.js.map +1 -1
  69. package/dist/execution.d.ts +20 -57
  70. package/dist/execution.d.ts.map +1 -1
  71. package/dist/execution.js +175 -174
  72. package/dist/execution.js.map +1 -1
  73. package/dist/index.d.ts +5 -8
  74. package/dist/index.js +5 -5
  75. package/dist/internal.d.ts +12 -12
  76. package/dist/internal.js +4 -4
  77. package/dist/registry.d.ts +33 -27
  78. package/dist/registry.d.ts.map +1 -1
  79. package/dist/registry.js +58 -49
  80. package/dist/registry.js.map +1 -1
  81. package/dist/worker.d.ts +57 -50
  82. package/dist/worker.d.ts.map +1 -1
  83. package/dist/worker.js +194 -198
  84. package/dist/worker.js.map +1 -1
  85. package/dist/workflow.d.ts +26 -27
  86. package/dist/workflow.d.ts.map +1 -1
  87. package/dist/workflow.js +20 -15
  88. package/dist/workflow.js.map +1 -1
  89. package/nodemon.json +1 -1
  90. package/package.json +18 -20
  91. package/src/backend.ts +28 -8
  92. package/src/chaos.test.ts +3 -1
  93. package/src/client.test.ts +2 -0
  94. package/src/client.ts +32 -8
  95. package/src/config.test.ts +1 -0
  96. package/src/config.ts +3 -2
  97. package/src/core/duration.test.ts +2 -1
  98. package/src/core/duration.ts +1 -1
  99. package/src/core/error.test.ts +1 -0
  100. package/src/core/error.ts +1 -1
  101. package/src/core/result.test.ts +1 -0
  102. package/src/core/retry.test.ts +181 -11
  103. package/src/core/retry.ts +95 -19
  104. package/src/core/schema.ts +2 -2
  105. package/src/core/step.test.ts +2 -1
  106. package/src/core/step.ts +4 -3
  107. package/src/core/workflow.test.ts +2 -1
  108. package/src/core/workflow.ts +4 -3
  109. package/src/database/backend.test.ts +1 -0
  110. package/src/database/backend.testsuite.ts +162 -39
  111. package/src/database/backend.ts +271 -35
  112. package/src/database/base.test.ts +41 -0
  113. package/src/database/base.ts +51 -2
  114. package/src/database/migrations/20251212000000_0_init.ts +2 -1
  115. package/src/database/migrations/20251212000000_1_tables.ts +2 -1
  116. package/src/database/migrations/20251212000000_2_fk.ts +2 -1
  117. package/src/database/migrations/20251212000000_3_indexes.ts +2 -1
  118. package/src/database/pubsub.test.ts +6 -3
  119. package/src/database/pubsub.ts +55 -33
  120. package/src/execution.test.ts +117 -0
  121. package/src/execution.ts +65 -10
  122. package/src/internal.ts +21 -1
  123. package/src/practices/01-remote-workflow.ts +1 -0
  124. package/src/registry.test.ts +1 -0
  125. package/src/registry.ts +1 -1
  126. package/src/testing/connection.ts +3 -1
  127. package/src/worker.test.ts +2 -0
  128. package/src/worker.ts +31 -9
  129. package/src/workflow.test.ts +1 -0
  130. package/src/workflow.ts +5 -2
  131. package/templates/openworkflow.config.ts +2 -1
  132. package/tsdown.config.ts +31 -0
  133. package/.swcrc +0 -17
  134. package/dist/chaos.test.d.ts +0 -2
  135. package/dist/chaos.test.d.ts.map +0 -1
  136. package/dist/chaos.test.js +0 -92
  137. package/dist/chaos.test.js.map +0 -1
  138. package/dist/client.test.d.ts +0 -2
  139. package/dist/client.test.d.ts.map +0 -1
  140. package/dist/client.test.js +0 -340
  141. package/dist/client.test.js.map +0 -1
  142. package/dist/config.test.d.ts +0 -2
  143. package/dist/config.test.d.ts.map +0 -1
  144. package/dist/config.test.js +0 -24
  145. package/dist/config.test.js.map +0 -1
  146. package/dist/core/duration.test.d.ts +0 -2
  147. package/dist/core/duration.test.d.ts.map +0 -1
  148. package/dist/core/duration.test.js +0 -265
  149. package/dist/core/duration.test.js.map +0 -1
  150. package/dist/core/error.test.d.ts +0 -2
  151. package/dist/core/error.test.d.ts.map +0 -1
  152. package/dist/core/error.test.js +0 -63
  153. package/dist/core/error.test.js.map +0 -1
  154. package/dist/core/json.js +0 -3
  155. package/dist/core/json.js.map +0 -1
  156. package/dist/core/result.test.d.ts +0 -2
  157. package/dist/core/result.test.d.ts.map +0 -1
  158. package/dist/core/result.test.js +0 -19
  159. package/dist/core/result.test.js.map +0 -1
  160. package/dist/core/retry.test.d.ts +0 -2
  161. package/dist/core/retry.test.d.ts.map +0 -1
  162. package/dist/core/retry.test.js +0 -37
  163. package/dist/core/retry.test.js.map +0 -1
  164. package/dist/core/schema.js +0 -4
  165. package/dist/core/schema.js.map +0 -1
  166. package/dist/core/step.test.d.ts +0 -2
  167. package/dist/core/step.test.d.ts.map +0 -1
  168. package/dist/core/step.test.js +0 -356
  169. package/dist/core/step.test.js.map +0 -1
  170. package/dist/core/workflow.test.d.ts +0 -2
  171. package/dist/core/workflow.test.d.ts.map +0 -1
  172. package/dist/core/workflow.test.js +0 -172
  173. package/dist/core/workflow.test.js.map +0 -1
  174. package/dist/database/backend.test.d.ts +0 -2
  175. package/dist/database/backend.test.d.ts.map +0 -1
  176. package/dist/database/backend.test.js +0 -19
  177. package/dist/database/backend.test.js.map +0 -1
  178. package/dist/database/backend.testsuite.d.ts +0 -20
  179. package/dist/database/backend.testsuite.d.ts.map +0 -1
  180. package/dist/database/backend.testsuite.js +0 -1174
  181. package/dist/database/backend.testsuite.js.map +0 -1
  182. package/dist/database/base.d.ts +0 -12
  183. package/dist/database/base.d.ts.map +0 -1
  184. package/dist/database/pubsub.test.d.ts +0 -2
  185. package/dist/database/pubsub.test.d.ts.map +0 -1
  186. package/dist/database/pubsub.test.js +0 -86
  187. package/dist/database/pubsub.test.js.map +0 -1
  188. package/dist/execution.test.d.ts +0 -2
  189. package/dist/execution.test.d.ts.map +0 -1
  190. package/dist/execution.test.js +0 -558
  191. package/dist/execution.test.js.map +0 -1
  192. package/dist/index.d.ts.map +0 -1
  193. package/dist/index.js.map +0 -1
  194. package/dist/internal.d.ts.map +0 -1
  195. package/dist/internal.js.map +0 -1
  196. package/dist/practices/01-remote-workflow.d.ts +0 -2
  197. package/dist/practices/01-remote-workflow.d.ts.map +0 -1
  198. package/dist/practices/01-remote-workflow.js +0 -70
  199. package/dist/practices/01-remote-workflow.js.map +0 -1
  200. package/dist/registry.test.d.ts +0 -2
  201. package/dist/registry.test.d.ts.map +0 -1
  202. package/dist/registry.test.js +0 -95
  203. package/dist/registry.test.js.map +0 -1
  204. package/dist/testing/connection.d.ts +0 -7
  205. package/dist/testing/connection.d.ts.map +0 -1
  206. package/dist/testing/connection.js +0 -39
  207. package/dist/testing/connection.js.map +0 -1
  208. package/dist/worker.test.d.ts +0 -2
  209. package/dist/worker.test.d.ts.map +0 -1
  210. package/dist/worker.test.js +0 -1164
  211. package/dist/worker.test.js.map +0 -1
  212. package/dist/workflow.test.d.ts +0 -2
  213. package/dist/workflow.test.d.ts.map +0 -1
  214. package/dist/workflow.test.js +0 -73
  215. package/dist/workflow.test.js.map +0 -1
@@ -1,15 +1,21 @@
1
1
  import assert from "assert";
2
- import type { Knex } from "knex";
3
- import { err, ok, type Result } from "../core/result";
2
+
3
+ import { type Knex } from "knex";
4
+
5
+ import { err, ok } from "../core/result";
6
+ import { type Result } from "../core/result";
4
7
 
5
8
  export type OnSubscribed = (result: Result<string | null>) => void | Promise<void>;
6
9
 
7
10
  export class PostgresPubSub {
8
11
  private _destroyed = false;
12
+ private _connecting = false;
9
13
  private _onClosed: () => Promise<void>;
14
+ private _onNotification: (msg: { channel: string; payload: unknown }) => Promise<void>;
15
+ private _onError: (error: Error) => Promise<void>;
10
16
  private _listeners = new Map<string, Set<OnSubscribed>>();
11
17
 
12
- // biome-ignore lint/suspicious/noExplicitAny: Knex exposes a connection as any
18
+ // oxlint-disable-next-line @typescript-eslint/no-explicit-any -- Knex exposes a connection as any
13
19
  private _connection: any | null = null;
14
20
 
15
21
  private constructor(private readonly knex: Knex) {
@@ -21,46 +27,60 @@ export class PostgresPubSub {
21
27
 
22
28
  await this.connect();
23
29
  }).bind(this);
24
- }
25
30
 
26
- get destroyed() {
27
- return this._destroyed;
28
- }
31
+ this._onNotification = (async ({
32
+ channel,
33
+ payload: rawPayload,
34
+ }: {
35
+ channel: string;
36
+ payload: unknown;
37
+ }) => {
38
+ const payload = typeof rawPayload === "string" && rawPayload.length !== 0 ? rawPayload : null;
39
+ const listeners = this._listeners.get(channel);
40
+ if (!listeners) {
41
+ return;
42
+ }
29
43
 
30
- // acquire new raw connection and set up listeners
31
- async connect() {
32
- const connection = await this.knex.client.acquireRawConnection();
33
- connection.on("close", this._onClosed);
34
- connection.on(
35
- "notification",
36
- async ({ channel, payload: rawPayload }: { channel: string; payload: unknown }) => {
37
- const payload =
38
- typeof rawPayload === "string" && rawPayload.length !== 0 ? rawPayload : null;
39
- const listeners = this._listeners.get(channel);
40
- if (!listeners) {
41
- return;
42
- }
43
-
44
- const result = ok(payload);
45
- await Promise.allSettled(
46
- Array.from(listeners.values()).map((listener) => Promise.resolve(listener(result))),
47
- );
48
- },
49
- );
50
- connection.on("error", async (error: Error) => {
44
+ const result = ok(payload);
45
+ await Promise.allSettled(
46
+ Array.from(listeners.values()).map((listener) => Promise.resolve(listener(result))),
47
+ );
48
+ }).bind(this);
49
+
50
+ this._onError = (async (error: Error) => {
51
51
  const result = err(error);
52
52
  await Promise.allSettled(
53
53
  Array.from(this._listeners.values())
54
54
  .flatMap((listeners) => Array.from(listeners))
55
55
  .map((listener) => Promise.resolve(listener(result))),
56
56
  );
57
- });
57
+ }).bind(this);
58
+ }
58
59
 
59
- for (const channel of this._listeners.keys()) {
60
- connection.query(`LISTEN ${channel}`);
61
- }
60
+ get destroyed() {
61
+ return this._destroyed;
62
+ }
63
+
64
+ // acquire new raw connection and set up listeners
65
+ async connect() {
66
+ // 동시 재연결 시도로 인한 연결 누수를 방지합니다.
67
+ if (this._connecting) return;
68
+ this._connecting = true;
69
+
70
+ try {
71
+ const connection = await this.knex.client.acquireRawConnection();
72
+ connection.on("close", this._onClosed);
73
+ connection.on("notification", this._onNotification);
74
+ connection.on("error", this._onError);
62
75
 
63
- this._connection = connection;
76
+ for (const channel of this._listeners.keys()) {
77
+ connection.query(`LISTEN ${channel}`);
78
+ }
79
+
80
+ this._connection = connection;
81
+ } finally {
82
+ this._connecting = false;
83
+ }
64
84
  }
65
85
 
66
86
  // destroy the listener and close the connection, do not destroy the knex connection
@@ -70,6 +90,8 @@ export class PostgresPubSub {
70
90
  }
71
91
  try {
72
92
  this._connection.off("close", this._onClosed);
93
+ this._connection.off("notification", this._onNotification);
94
+ this._connection.off("error", this._onError);
73
95
  await this.knex.client.destroyRawConnection(this._connection);
74
96
  } finally {
75
97
  this._destroyed = true;
@@ -1,5 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
+
2
3
  import { afterAll, beforeAll, describe, expect, test } from "vitest";
4
+
3
5
  import { BackendPostgres } from ".";
4
6
  import { OpenWorkflow } from "./client";
5
7
  import { KNEX_GLOBAL_CONFIG } from "./testing/connection";
@@ -505,6 +507,121 @@ describe("executeWorkflow", () => {
505
507
  });
506
508
  });
507
509
 
510
+ describe("executeWorkflow with dynamic retryPolicy", () => {
511
+ let backend: BackendPostgres;
512
+
513
+ beforeAll(async () => {
514
+ backend = new BackendPostgres(KNEX_GLOBAL_CONFIG, {
515
+ namespaceId: randomUUID(),
516
+ runMigrations: false,
517
+ });
518
+ await backend.initialize();
519
+ });
520
+
521
+ afterAll(async () => {
522
+ await backend.stop();
523
+ });
524
+
525
+ test("calls failWorkflowRun with forceComplete=true when shouldRetry returns false", async () => {
526
+ const client = new OpenWorkflow({ backend });
527
+
528
+ // shouldRetry가 false를 반환하는 retryPolicy
529
+ const workflow = client.defineWorkflow(
530
+ {
531
+ name: "dynamic-retry-false",
532
+ retryPolicy: {
533
+ maxAttempts: 10, // 정적으로는 10번까지 허용하지만
534
+ shouldRetry: () => ({ shouldRetry: false, delayMs: 0 }), // 동적으로 즉시 거부
535
+ },
536
+ },
537
+ () => {
538
+ throw new Error("Intentional failure");
539
+ },
540
+ );
541
+
542
+ const worker = client.newWorker();
543
+ const handle = await workflow.run();
544
+ await worker.tick();
545
+ await sleep(100);
546
+
547
+ // shouldRetry가 false를 반환했으므로 즉시 failed 상태가 되어야 합니다
548
+ const workflowRun = await backend.getWorkflowRun({
549
+ workflowRunId: handle.workflowRun.id,
550
+ });
551
+ expect(workflowRun?.status).toBe("failed");
552
+ expect(workflowRun?.attempts).toBe(1); // 한 번만 시도하고 종료
553
+ });
554
+
555
+ test("calls failWorkflowRun with forceComplete=false when shouldRetry returns true", async () => {
556
+ const client = new OpenWorkflow({ backend });
557
+
558
+ // shouldRetry가 true를 반환하는 retryPolicy
559
+ const workflow = client.defineWorkflow(
560
+ {
561
+ name: "dynamic-retry-true",
562
+ retryPolicy: {
563
+ maxAttempts: 2,
564
+ shouldRetry: () => ({ shouldRetry: true, delayMs: 1000 }),
565
+ },
566
+ },
567
+ () => {
568
+ throw new Error("Intentional failure");
569
+ },
570
+ );
571
+
572
+ const worker = client.newWorker();
573
+ const handle = await workflow.run();
574
+ await worker.tick();
575
+ await sleep(100);
576
+
577
+ // shouldRetry가 true를 반환했으므로 pending 상태로 재시도 대기
578
+ const workflowRun = await backend.getWorkflowRun({
579
+ workflowRunId: handle.workflowRun.id,
580
+ });
581
+ expect(workflowRun?.status).toBe("pending");
582
+ expect(workflowRun?.attempts).toBe(1);
583
+ });
584
+
585
+ test("receives correct error and attempt number in shouldRetry function", async () => {
586
+ const client = new OpenWorkflow({ backend });
587
+
588
+ let receivedError: unknown = null;
589
+ let receivedAttempt: number | null = null;
590
+
591
+ const workflow = client.defineWorkflow(
592
+ {
593
+ name: "dynamic-retry-params",
594
+ retryPolicy: {
595
+ maxAttempts: 10,
596
+ shouldRetry: (error, attempt) => {
597
+ receivedError = error;
598
+ receivedAttempt = attempt;
599
+ return { shouldRetry: false, delayMs: 0 };
600
+ },
601
+ },
602
+ },
603
+ () => {
604
+ throw new Error("Test error message");
605
+ },
606
+ );
607
+
608
+ const worker = client.newWorker();
609
+ const handle = await workflow.run();
610
+ await worker.tick();
611
+ await sleep(100);
612
+
613
+ // shouldRetry 함수가 올바른 파라미터를 받았는지 확인
614
+ expect(receivedError).not.toBeNull();
615
+ expect((receivedError as { message?: string }).message).toBe("Test error message");
616
+ expect(receivedAttempt).toBe(1); // 첫 번째 시도 후이므로 1
617
+
618
+ const workflowRun = await backend.getWorkflowRun({
619
+ workflowRunId: handle.workflowRun.id,
620
+ });
621
+ expect(workflowRun?.status).toBe("failed");
622
+ });
623
+ });
624
+
508
625
  function sleep(ms: number): Promise<void> {
509
626
  return new Promise((resolve) => setTimeout(resolve, ms));
510
627
  }
package/src/execution.ts CHANGED
@@ -1,8 +1,10 @@
1
- import type { Backend } from "./backend";
2
- import type { DurationString } from "./core/duration";
1
+ import { type Backend } from "./backend";
2
+ import { type DurationString } from "./core/duration";
3
3
  import { serializeError } from "./core/error";
4
- import type { JsonValue } from "./core/json";
5
- import type { StepAttempt, StepAttemptCache } from "./core/step";
4
+ import { type JsonValue } from "./core/json";
5
+ import { isDynamicRetryPolicy } from "./core/retry";
6
+ import { type RetryPolicy } from "./core/retry";
7
+ import { type StepAttempt, type StepAttemptCache } from "./core/step";
6
8
  import {
7
9
  addToStepAttemptCache,
8
10
  calculateSleepResumeAt,
@@ -11,7 +13,7 @@ import {
11
13
  getCachedStepAttempt,
12
14
  normalizeStepOutput,
13
15
  } from "./core/step";
14
- import type { WorkflowRun } from "./core/workflow";
16
+ import { type WorkflowRun } from "./core/workflow";
15
17
 
16
18
  /**
17
19
  * Config for an individual step defined with `step.run()`.
@@ -69,6 +71,16 @@ class SleepSignal extends Error {
69
71
  }
70
72
  }
71
73
 
74
+ /**
75
+ * 외부에서 workflow 상태가 변경되었을 때 실행을 안전하게 중단하기 위한 에러입니다.
76
+ */
77
+ class WorkflowAbortedError extends Error {
78
+ constructor() {
79
+ super("Workflow execution aborted");
80
+ this.name = "WorkflowAbortedError";
81
+ }
82
+ }
83
+
72
84
  /**
73
85
  * Configures the options for a StepExecutor.
74
86
  */
@@ -77,6 +89,7 @@ export interface StepExecutorOptions {
77
89
  workflowRunId: string;
78
90
  workerId: string;
79
91
  attempts: StepAttempt[];
92
+ signal?: AbortSignal;
80
93
  }
81
94
 
82
95
  /**
@@ -87,12 +100,14 @@ export class StepExecutor implements StepApi {
87
100
  private readonly backend: Backend;
88
101
  private readonly workflowRunId: string;
89
102
  private readonly workerId: string;
103
+ private readonly signal?: AbortSignal;
90
104
  private cache: StepAttemptCache;
91
105
 
92
106
  constructor(options: Readonly<StepExecutorOptions>) {
93
107
  this.backend = options.backend;
94
108
  this.workflowRunId = options.workflowRunId;
95
109
  this.workerId = options.workerId;
110
+ this.signal = options.signal;
96
111
 
97
112
  this.cache = createStepAttemptCacheFromAttempts(options.attempts);
98
113
  }
@@ -102,6 +117,9 @@ export class StepExecutor implements StepApi {
102
117
  fn: StepFunction<Output>,
103
118
  ): Promise<Output> {
104
119
  const { name } = config;
120
+ if (this.signal?.aborted) {
121
+ throw new WorkflowAbortedError();
122
+ }
105
123
 
106
124
  // return cached result if available
107
125
  const existingAttempt = getCachedStepAttempt(this.cache, name);
@@ -124,31 +142,42 @@ export class StepExecutor implements StepApi {
124
142
  const result = await fn();
125
143
  const output = normalizeStepOutput(result);
126
144
 
127
- // mark success
145
+ // mark success — null이면 외부에서 워크플로우 상태가 변경된 것입니다(pause/cancel).
128
146
  const savedAttempt = await this.backend.completeStepAttempt({
129
147
  workflowRunId: this.workflowRunId,
130
148
  stepAttemptId: attempt.id,
131
149
  workerId: this.workerId,
132
150
  output,
133
151
  });
152
+ if (!savedAttempt) {
153
+ throw new WorkflowAbortedError();
154
+ }
134
155
 
135
156
  // cache result
136
157
  this.cache = addToStepAttemptCache(this.cache, savedAttempt);
137
158
 
138
159
  return savedAttempt.output as Output;
139
160
  } catch (error) {
140
- // mark failure
141
- await this.backend.failStepAttempt({
161
+ // mark failure — null이면 외부에서 워크플로우 상태가 변경된 것입니다(pause/cancel).
162
+ const failed = await this.backend.failStepAttempt({
142
163
  workflowRunId: this.workflowRunId,
143
164
  stepAttemptId: attempt.id,
144
165
  workerId: this.workerId,
145
166
  error: serializeError(error),
146
167
  });
168
+ if (!failed) {
169
+ throw new WorkflowAbortedError();
170
+ }
171
+
147
172
  throw error;
148
173
  }
149
174
  }
150
175
 
151
176
  async sleep(name: string, duration: DurationString): Promise<void> {
177
+ if (this.signal?.aborted) {
178
+ throw new WorkflowAbortedError();
179
+ }
180
+
152
181
  // return cached result if this sleep already completed
153
182
  const existingAttempt = getCachedStepAttempt(this.cache, name);
154
183
  if (existingAttempt) return;
@@ -186,6 +215,8 @@ export interface ExecuteWorkflowParams {
186
215
  workflowFn: WorkflowFunction<unknown, unknown>;
187
216
  workflowVersion: string | null;
188
217
  workerId: string;
218
+ retryPolicy?: RetryPolicy;
219
+ signal?: AbortSignal;
189
220
  }
190
221
 
191
222
  /**
@@ -198,7 +229,8 @@ export interface ExecuteWorkflowParams {
198
229
  * @param params - The execution parameters
199
230
  */
200
231
  export async function executeWorkflow(params: Readonly<ExecuteWorkflowParams>): Promise<void> {
201
- const { backend, workflowRun, workflowFn, workflowVersion, workerId } = params;
232
+ const { backend, workflowRun, workflowFn, workflowVersion, workerId, retryPolicy, signal } =
233
+ params;
202
234
 
203
235
  try {
204
236
  // load all pages of step history
@@ -242,6 +274,9 @@ export async function executeWorkflow(params: Readonly<ExecuteWorkflowParams>):
242
274
  workerId,
243
275
  output: null,
244
276
  });
277
+ if (!completed) {
278
+ throw new WorkflowAbortedError();
279
+ }
245
280
 
246
281
  // update cache w/ completed attempt
247
282
  attempts[i] = completed;
@@ -254,6 +289,7 @@ export async function executeWorkflow(params: Readonly<ExecuteWorkflowParams>):
254
289
  workflowRunId: workflowRun.id,
255
290
  workerId,
256
291
  attempts,
292
+ signal,
257
293
  });
258
294
 
259
295
  // execute workflow
@@ -281,11 +317,30 @@ export async function executeWorkflow(params: Readonly<ExecuteWorkflowParams>):
281
317
  return;
282
318
  }
283
319
 
284
- // mark failure
320
+ // heartbeat 실패로 abort된 경우, failWorkflowRun을 호출하지 않고 조용히 종료합니다.
321
+ if (error instanceof WorkflowAbortedError || signal?.aborted) {
322
+ return;
323
+ }
324
+
325
+ // claimWorkflowRun에서 이미 attempts가 증가된 상태입니다.
326
+ let forceComplete = false;
327
+ let customDelayMs: number | undefined;
328
+ if (retryPolicy && isDynamicRetryPolicy(retryPolicy)) {
329
+ const serializedError = serializeError(error);
330
+ const decision = retryPolicy.shouldRetry(serializedError, workflowRun.attempts ?? 1);
331
+ if (!decision.shouldRetry) {
332
+ forceComplete = true;
333
+ } else {
334
+ customDelayMs = decision.delayMs;
335
+ }
336
+ }
337
+
285
338
  await backend.failWorkflowRun({
286
339
  workflowRunId: workflowRun.id,
287
340
  workerId,
288
341
  error: serializeError(error),
342
+ forceComplete,
343
+ customDelayMs,
289
344
  });
290
345
  }
291
346
  }
package/src/internal.ts CHANGED
@@ -3,7 +3,27 @@ export type * from "./client";
3
3
  export { loadConfig } from "./config";
4
4
  export type { DurationString } from "./core/duration";
5
5
  export type { JsonValue } from "./core/json";
6
- export { DEFAULT_RETRY_POLICY } from "./core/retry";
6
+ export type {
7
+ DynamicRetryPolicy,
8
+ MergedDynamicRetryPolicy,
9
+ MergedRetryPolicy,
10
+ MergedStaticRetryPolicy,
11
+ RetryDecision,
12
+ RetryDecisionFn,
13
+ RetryPolicy,
14
+ SerializableRetryPolicy,
15
+ StaticRetryPolicy,
16
+ } from "./core/retry";
17
+ export {
18
+ calculateRetryDelayMs,
19
+ DEFAULT_RETRY_POLICY,
20
+ isDynamicRetryPolicy,
21
+ isStaticRetryPolicy,
22
+ mergeRetryPolicy,
23
+ serializeRetryPolicy,
24
+ shouldRetry,
25
+ shouldRetryByPolicy,
26
+ } from "./core/retry";
7
27
  export type { StandardSchemaV1 } from "./core/schema";
8
28
  export type { StepAttempt } from "./core/step";
9
29
  export type { SchemaInput, SchemaOutput, WorkflowRun } from "./core/workflow";
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert";
2
2
  import { randomUUID } from "node:crypto";
3
+
3
4
  import { BackendPostgres, OpenWorkflow } from "../";
4
5
  import { KNEX_GLOBAL_CONFIG } from "../testing/connection";
5
6
 
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "vitest";
2
+
2
3
  import { WorkflowRegistry } from "./registry";
3
4
  import { defineWorkflow } from "./workflow";
4
5
 
package/src/registry.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Workflow } from "./workflow";
1
+ import { type Workflow } from "./workflow";
2
2
 
3
3
  /**
4
4
  * A registry for storing and retrieving workflows by name and version.
@@ -1,5 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import type { Knex } from "knex";
2
+
3
+ import { type Knex } from "knex";
4
+
3
5
  import { BackendPostgres } from "../database/backend";
4
6
  import { migrate as baseMigrate, DEFAULT_SCHEMA } from "../database/base";
5
7
 
@@ -1,5 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
+
2
3
  import { afterEach, beforeEach, describe, expect, test } from "vitest";
4
+
3
5
  import { declareWorkflow, OpenWorkflow } from "./client";
4
6
  import { BackendPostgres } from "./database/backend";
5
7
  import { KNEX_GLOBAL_CONFIG } from "./testing/connection";
package/src/worker.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import type { Backend } from "./backend";
3
- import type { WorkflowRun } from "./core/workflow";
2
+
3
+ import { type Backend } from "./backend";
4
+ import { type WorkflowRun } from "./core/workflow";
4
5
  import { executeWorkflow } from "./execution";
5
- import type { WorkflowRegistry } from "./registry";
6
- import type { Workflow } from "./workflow";
6
+ import { type WorkflowRegistry } from "./registry";
7
+ import { type Workflow } from "./workflow";
7
8
 
8
9
  const DEFAULT_LEASE_DURATION_MS = 30 * 1000; // 30s
9
10
  const DEFAULT_POLL_INTERVAL_MS = 100; // 100ms
@@ -32,6 +33,7 @@ export class Worker {
32
33
  private readonly activeExecutions = new Set<WorkflowExecution>();
33
34
  private running = false;
34
35
  private loopPromise: Promise<void> | null = null;
36
+ private subscribed = false;
35
37
 
36
38
  private usePubSub: boolean;
37
39
  private listenDelay: number;
@@ -72,6 +74,8 @@ export class Worker {
72
74
 
73
75
  // wait for all active executions to finish
74
76
  while (this.activeExecutions.size > 0) await sleep(100);
77
+
78
+ this.subscribed = false;
75
79
  }
76
80
 
77
81
  /**
@@ -108,9 +112,10 @@ export class Worker {
108
112
  * Only sleeps when no work was claimed to avoid busy-waiting.
109
113
  */
110
114
  private async runLoop(): Promise<void> {
111
- if (this.usePubSub) {
115
+ if (this.usePubSub && !this.subscribed) {
116
+ this.subscribed = true;
112
117
  this.backend.subscribe(async (result) => {
113
- if (!result.ok) {
118
+ if (!result.ok || !this.running) {
114
119
  return;
115
120
  }
116
121
 
@@ -134,7 +139,7 @@ export class Worker {
134
139
  }
135
140
 
136
141
  /*
137
- * Cclaim and process a workflow run for the given worker ID. Do not await the
142
+ * Claim and process a workflow run for the given worker ID. Do not await the
138
143
  * processing here to avoid blocking the caller.
139
144
  * Returns the claimed workflow run, or null if none was available.
140
145
  */
@@ -202,8 +207,14 @@ export class Worker {
202
207
  workflowFn: workflow.fn,
203
208
  workflowVersion: execution.workflowRun.version,
204
209
  workerId: execution.workerId,
210
+ retryPolicy: workflow.spec.retryPolicy,
211
+ signal: execution.signal,
205
212
  });
206
213
  } catch (error) {
214
+ if (execution.signal.aborted) {
215
+ return;
216
+ }
217
+
207
218
  // specifically for unexpected errors in the execution wrapper itself, not
208
219
  // for business logic errors (those are handled inside executeWorkflow)
209
220
  console.error(
@@ -232,6 +243,7 @@ class WorkflowExecution {
232
243
  workflowRun: WorkflowRun;
233
244
  workerId: string;
234
245
  private heartbeatTimer: NodeJS.Timeout | null = null;
246
+ private abortController = new AbortController();
235
247
 
236
248
  constructor(options: WorkflowExecutionOptions) {
237
249
  this.backend = options.backend;
@@ -239,6 +251,14 @@ class WorkflowExecution {
239
251
  this.workerId = options.workerId;
240
252
  }
241
253
 
254
+ /**
255
+ * 외부에서 abort 여부를 확인할 수 있는 signal입니다.
256
+ * heartbeat 실패 시 abort됩니다.
257
+ */
258
+ get signal(): AbortSignal {
259
+ return this.abortController.signal;
260
+ }
261
+
242
262
  /**
243
263
  * Start the heartbeat loop for this execution, heartbeating at half the lease
244
264
  * duration.
@@ -254,8 +274,10 @@ class WorkflowExecution {
254
274
  workerId: this.workerId,
255
275
  leaseDurationMs,
256
276
  })
257
- .catch((error: unknown) => {
258
- console.error("Heartbeat failed:", error);
277
+ .catch((_error: unknown) => {
278
+ // lease 연장 실패에는 heartbeat를 중단하고 abort 신호를 보냅니다.
279
+ this.abortController.abort();
280
+ this.stopHeartbeat();
259
281
  });
260
282
  }, heartbeatIntervalMs);
261
283
  }
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "vitest";
2
+
2
3
  import { defineWorkflow, defineWorkflowSpec } from "./workflow";
3
4
 
4
5
  describe("defineWorkflowSpec", () => {
package/src/workflow.ts CHANGED
@@ -1,5 +1,6 @@
1
- import type { StandardSchemaV1 } from "./core/schema";
2
- import type { WorkflowFunction } from "./execution";
1
+ import { type RetryPolicy } from "./core/retry";
2
+ import { type StandardSchemaV1 } from "./core/schema";
3
+ import { type WorkflowFunction } from "./execution";
3
4
 
4
5
  export interface WorkflowSpec<Input, Output, RawInput> {
5
6
  /** The name of the workflow. */
@@ -8,6 +9,8 @@ export interface WorkflowSpec<Input, Output, RawInput> {
8
9
  readonly version?: string;
9
10
  /** The schema used to validate inputs. */
10
11
  readonly schema?: StandardSchemaV1<RawInput, Input>;
12
+ /** The retry policy for the workflow. */
13
+ readonly retryPolicy?: RetryPolicy;
11
14
  /** Phantom type carrier - won't exist at runtime. */
12
15
  readonly __types?: {
13
16
  output: Output;
@@ -1,4 +1,5 @@
1
- import type { Knex } from "knex";
1
+ import { type Knex } from "knex";
2
+
2
3
  import { BackendPostgres, defineConfig } from "../src/index";
3
4
 
4
5
  const config: Knex.Config = {
@@ -0,0 +1,31 @@
1
+ import { readdirSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { defineConfig } from "tsdown";
5
+
6
+ const migrationsDir = path.join(import.meta.dirname, "src/database/migrations");
7
+ const migrationEntries = Object.fromEntries(
8
+ readdirSync(migrationsDir)
9
+ .filter((filename) => filename.endsWith(".ts"))
10
+ .map((filename) => [
11
+ `database/migrations/${filename.replace(/\.ts$/, "")}`,
12
+ `src/database/migrations/${filename}`,
13
+ ]),
14
+ );
15
+
16
+ export default defineConfig({
17
+ clean: true,
18
+ dts: {
19
+ sourcemap: true,
20
+ },
21
+ entry: {
22
+ index: "src/index.ts",
23
+ internal: "src/internal.ts",
24
+ ...migrationEntries,
25
+ },
26
+ format: "esm",
27
+ platform: "node",
28
+ sourcemap: true,
29
+ target: "esnext",
30
+ unbundle: true,
31
+ });