@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.
- package/.oxlintrc.json +3 -0
- package/AGENTS.md +21 -0
- package/dist/backend.d.ts +126 -103
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +4 -1
- package/dist/backend.js.map +1 -1
- package/dist/client.d.ts +145 -132
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +220 -212
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +15 -8
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +22 -17
- package/dist/config.js.map +1 -1
- package/dist/core/duration.d.ts +5 -4
- package/dist/core/duration.d.ts.map +1 -1
- package/dist/core/duration.js +54 -59
- package/dist/core/duration.js.map +1 -1
- package/dist/core/error.d.ts +10 -7
- package/dist/core/error.d.ts.map +1 -1
- package/dist/core/error.js +21 -21
- package/dist/core/error.js.map +1 -1
- package/dist/core/json.d.ts +8 -3
- package/dist/core/json.d.ts.map +1 -1
- package/dist/core/result.d.ts +10 -14
- package/dist/core/result.d.ts.map +1 -1
- package/dist/core/result.js +21 -16
- package/dist/core/result.js.map +1 -1
- package/dist/core/retry.d.ts +42 -20
- package/dist/core/retry.d.ts.map +1 -1
- package/dist/core/retry.js +49 -20
- package/dist/core/retry.js.map +1 -1
- package/dist/core/schema.d.ts +57 -53
- package/dist/core/schema.d.ts.map +1 -1
- package/dist/core/step.d.ts +28 -78
- package/dist/core/step.d.ts.map +1 -1
- package/dist/core/step.js +53 -63
- package/dist/core/step.js.map +1 -1
- package/dist/core/workflow.d.ts +33 -61
- package/dist/core/workflow.d.ts.map +1 -1
- package/dist/core/workflow.js +31 -41
- package/dist/core/workflow.js.map +1 -1
- package/dist/database/backend.d.ts +53 -46
- package/dist/database/backend.d.ts.map +1 -1
- package/dist/database/backend.js +544 -545
- package/dist/database/backend.js.map +1 -1
- package/dist/database/base.js +48 -25
- package/dist/database/base.js.map +1 -1
- package/dist/database/migrations/20251212000000_0_init.d.ts +10 -0
- package/dist/database/migrations/20251212000000_0_init.d.ts.map +1 -0
- package/dist/database/migrations/20251212000000_0_init.js +8 -4
- package/dist/database/migrations/20251212000000_0_init.js.map +1 -1
- package/dist/database/migrations/20251212000000_1_tables.d.ts +10 -0
- package/dist/database/migrations/20251212000000_1_tables.d.ts.map +1 -0
- package/dist/database/migrations/20251212000000_1_tables.js +81 -83
- package/dist/database/migrations/20251212000000_1_tables.js.map +1 -1
- package/dist/database/migrations/20251212000000_2_fk.d.ts +10 -0
- package/dist/database/migrations/20251212000000_2_fk.d.ts.map +1 -0
- package/dist/database/migrations/20251212000000_2_fk.js +20 -43
- package/dist/database/migrations/20251212000000_2_fk.js.map +1 -1
- package/dist/database/migrations/20251212000000_3_indexes.d.ts +10 -0
- package/dist/database/migrations/20251212000000_3_indexes.d.ts.map +1 -0
- package/dist/database/migrations/20251212000000_3_indexes.js +88 -102
- package/dist/database/migrations/20251212000000_3_indexes.js.map +1 -1
- package/dist/database/pubsub.d.ts +7 -16
- package/dist/database/pubsub.d.ts.map +1 -1
- package/dist/database/pubsub.js +75 -73
- package/dist/database/pubsub.js.map +1 -1
- package/dist/execution.d.ts +20 -57
- package/dist/execution.d.ts.map +1 -1
- package/dist/execution.js +175 -174
- package/dist/execution.js.map +1 -1
- package/dist/index.d.ts +5 -8
- package/dist/index.js +5 -5
- package/dist/internal.d.ts +12 -12
- package/dist/internal.js +4 -4
- package/dist/registry.d.ts +33 -27
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +58 -49
- package/dist/registry.js.map +1 -1
- package/dist/worker.d.ts +57 -50
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +194 -198
- package/dist/worker.js.map +1 -1
- package/dist/workflow.d.ts +26 -27
- package/dist/workflow.d.ts.map +1 -1
- package/dist/workflow.js +20 -15
- package/dist/workflow.js.map +1 -1
- package/nodemon.json +1 -1
- package/package.json +18 -20
- package/src/backend.ts +28 -8
- package/src/chaos.test.ts +3 -1
- package/src/client.test.ts +2 -0
- package/src/client.ts +32 -8
- package/src/config.test.ts +1 -0
- package/src/config.ts +3 -2
- package/src/core/duration.test.ts +2 -1
- package/src/core/duration.ts +1 -1
- package/src/core/error.test.ts +1 -0
- package/src/core/error.ts +1 -1
- package/src/core/result.test.ts +1 -0
- package/src/core/retry.test.ts +181 -11
- package/src/core/retry.ts +95 -19
- package/src/core/schema.ts +2 -2
- package/src/core/step.test.ts +2 -1
- package/src/core/step.ts +4 -3
- package/src/core/workflow.test.ts +2 -1
- package/src/core/workflow.ts +4 -3
- package/src/database/backend.test.ts +1 -0
- package/src/database/backend.testsuite.ts +162 -39
- package/src/database/backend.ts +271 -35
- package/src/database/base.test.ts +41 -0
- package/src/database/base.ts +51 -2
- package/src/database/migrations/20251212000000_0_init.ts +2 -1
- package/src/database/migrations/20251212000000_1_tables.ts +2 -1
- package/src/database/migrations/20251212000000_2_fk.ts +2 -1
- package/src/database/migrations/20251212000000_3_indexes.ts +2 -1
- package/src/database/pubsub.test.ts +6 -3
- package/src/database/pubsub.ts +55 -33
- package/src/execution.test.ts +117 -0
- package/src/execution.ts +65 -10
- package/src/internal.ts +21 -1
- package/src/practices/01-remote-workflow.ts +1 -0
- package/src/registry.test.ts +1 -0
- package/src/registry.ts +1 -1
- package/src/testing/connection.ts +3 -1
- package/src/worker.test.ts +2 -0
- package/src/worker.ts +31 -9
- package/src/workflow.test.ts +1 -0
- package/src/workflow.ts +5 -2
- package/templates/openworkflow.config.ts +2 -1
- package/tsdown.config.ts +31 -0
- package/.swcrc +0 -17
- package/dist/chaos.test.d.ts +0 -2
- package/dist/chaos.test.d.ts.map +0 -1
- package/dist/chaos.test.js +0 -92
- package/dist/chaos.test.js.map +0 -1
- package/dist/client.test.d.ts +0 -2
- package/dist/client.test.d.ts.map +0 -1
- package/dist/client.test.js +0 -340
- package/dist/client.test.js.map +0 -1
- package/dist/config.test.d.ts +0 -2
- package/dist/config.test.d.ts.map +0 -1
- package/dist/config.test.js +0 -24
- package/dist/config.test.js.map +0 -1
- package/dist/core/duration.test.d.ts +0 -2
- package/dist/core/duration.test.d.ts.map +0 -1
- package/dist/core/duration.test.js +0 -265
- package/dist/core/duration.test.js.map +0 -1
- package/dist/core/error.test.d.ts +0 -2
- package/dist/core/error.test.d.ts.map +0 -1
- package/dist/core/error.test.js +0 -63
- package/dist/core/error.test.js.map +0 -1
- package/dist/core/json.js +0 -3
- package/dist/core/json.js.map +0 -1
- package/dist/core/result.test.d.ts +0 -2
- package/dist/core/result.test.d.ts.map +0 -1
- package/dist/core/result.test.js +0 -19
- package/dist/core/result.test.js.map +0 -1
- package/dist/core/retry.test.d.ts +0 -2
- package/dist/core/retry.test.d.ts.map +0 -1
- package/dist/core/retry.test.js +0 -37
- package/dist/core/retry.test.js.map +0 -1
- package/dist/core/schema.js +0 -4
- package/dist/core/schema.js.map +0 -1
- package/dist/core/step.test.d.ts +0 -2
- package/dist/core/step.test.d.ts.map +0 -1
- package/dist/core/step.test.js +0 -356
- package/dist/core/step.test.js.map +0 -1
- package/dist/core/workflow.test.d.ts +0 -2
- package/dist/core/workflow.test.d.ts.map +0 -1
- package/dist/core/workflow.test.js +0 -172
- package/dist/core/workflow.test.js.map +0 -1
- package/dist/database/backend.test.d.ts +0 -2
- package/dist/database/backend.test.d.ts.map +0 -1
- package/dist/database/backend.test.js +0 -19
- package/dist/database/backend.test.js.map +0 -1
- package/dist/database/backend.testsuite.d.ts +0 -20
- package/dist/database/backend.testsuite.d.ts.map +0 -1
- package/dist/database/backend.testsuite.js +0 -1174
- package/dist/database/backend.testsuite.js.map +0 -1
- package/dist/database/base.d.ts +0 -12
- package/dist/database/base.d.ts.map +0 -1
- package/dist/database/pubsub.test.d.ts +0 -2
- package/dist/database/pubsub.test.d.ts.map +0 -1
- package/dist/database/pubsub.test.js +0 -86
- package/dist/database/pubsub.test.js.map +0 -1
- package/dist/execution.test.d.ts +0 -2
- package/dist/execution.test.d.ts.map +0 -1
- package/dist/execution.test.js +0 -558
- package/dist/execution.test.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/internal.d.ts.map +0 -1
- package/dist/internal.js.map +0 -1
- package/dist/practices/01-remote-workflow.d.ts +0 -2
- package/dist/practices/01-remote-workflow.d.ts.map +0 -1
- package/dist/practices/01-remote-workflow.js +0 -70
- package/dist/practices/01-remote-workflow.js.map +0 -1
- package/dist/registry.test.d.ts +0 -2
- package/dist/registry.test.d.ts.map +0 -1
- package/dist/registry.test.js +0 -95
- package/dist/registry.test.js.map +0 -1
- package/dist/testing/connection.d.ts +0 -7
- package/dist/testing/connection.d.ts.map +0 -1
- package/dist/testing/connection.js +0 -39
- package/dist/testing/connection.js.map +0 -1
- package/dist/worker.test.d.ts +0 -2
- package/dist/worker.test.d.ts.map +0 -1
- package/dist/worker.test.js +0 -1164
- package/dist/worker.test.js.map +0 -1
- package/dist/workflow.test.d.ts +0 -2
- package/dist/workflow.test.d.ts.map +0 -1
- package/dist/workflow.test.js +0 -73
- package/dist/workflow.test.js.map +0 -1
package/src/database/pubsub.ts
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import assert from "assert";
|
|
2
|
-
|
|
3
|
-
import {
|
|
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
|
-
//
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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;
|
package/src/execution.test.ts
CHANGED
|
@@ -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
|
|
2
|
-
import type
|
|
1
|
+
import { type Backend } from "./backend";
|
|
2
|
+
import { type DurationString } from "./core/duration";
|
|
3
3
|
import { serializeError } from "./core/error";
|
|
4
|
-
import type
|
|
5
|
-
import
|
|
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
|
|
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 } =
|
|
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
|
-
//
|
|
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 {
|
|
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";
|
package/src/registry.test.ts
CHANGED
package/src/registry.ts
CHANGED
package/src/worker.test.ts
CHANGED
|
@@ -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
|
-
|
|
3
|
-
import type
|
|
2
|
+
|
|
3
|
+
import { type Backend } from "./backend";
|
|
4
|
+
import { type WorkflowRun } from "./core/workflow";
|
|
4
5
|
import { executeWorkflow } from "./execution";
|
|
5
|
-
import type
|
|
6
|
-
import type
|
|
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
|
-
*
|
|
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((
|
|
258
|
-
|
|
277
|
+
.catch((_error: unknown) => {
|
|
278
|
+
// lease 연장 실패에는 heartbeat를 중단하고 abort 신호를 보냅니다.
|
|
279
|
+
this.abortController.abort();
|
|
280
|
+
this.stopHeartbeat();
|
|
259
281
|
});
|
|
260
282
|
}, heartbeatIntervalMs);
|
|
261
283
|
}
|
package/src/workflow.test.ts
CHANGED
package/src/workflow.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import type
|
|
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;
|
package/tsdown.config.ts
ADDED
|
@@ -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
|
+
});
|