@sonamu-kit/tasks 0.1.2 → 0.2.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/backend.d.ts +4 -0
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js.map +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +3 -1
- package/dist/client.js.map +1 -1
- package/dist/core/retry.d.ts +35 -19
- package/dist/core/retry.d.ts.map +1 -1
- package/dist/core/retry.js +50 -14
- package/dist/core/retry.js.map +1 -1
- package/dist/core/retry.test.js +172 -11
- package/dist/core/retry.test.js.map +1 -1
- package/dist/database/backend.d.ts.map +1 -1
- package/dist/database/backend.js +114 -5
- package/dist/database/backend.js.map +1 -1
- package/dist/database/backend.testsuite.d.ts.map +1 -1
- package/dist/database/backend.testsuite.js +106 -0
- package/dist/database/backend.testsuite.js.map +1 -1
- package/dist/execution.d.ts +2 -0
- package/dist/execution.d.ts.map +1 -1
- package/dist/execution.js +17 -3
- package/dist/execution.js.map +1 -1
- package/dist/execution.test.js +104 -0
- package/dist/execution.test.js.map +1 -1
- package/dist/internal.d.ts +2 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -1
- package/dist/internal.js.map +1 -1
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +2 -1
- package/dist/worker.js.map +1 -1
- package/dist/workflow.d.ts +3 -0
- package/dist/workflow.d.ts.map +1 -1
- package/dist/workflow.js.map +1 -1
- package/package.json +3 -3
- package/src/backend.ts +4 -0
- package/src/client.ts +2 -0
- package/src/core/retry.test.ts +180 -11
- package/src/core/retry.ts +95 -19
- package/src/database/backend.testsuite.ts +119 -0
- package/src/database/backend.ts +133 -5
- package/src/execution.test.ts +115 -0
- package/src/execution.ts +18 -2
- package/src/internal.ts +21 -1
- package/src/worker.ts +1 -0
- package/src/workflow.ts +3 -0
package/src/core/retry.test.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import type { SerializedError } from "./error";
|
|
3
|
+
import type { DynamicRetryPolicy, StaticRetryPolicy } from "./retry";
|
|
4
|
+
import {
|
|
5
|
+
calculateRetryDelayMs,
|
|
6
|
+
DEFAULT_RETRY_POLICY,
|
|
7
|
+
isDynamicRetryPolicy,
|
|
8
|
+
isStaticRetryPolicy,
|
|
9
|
+
mergeRetryPolicy,
|
|
10
|
+
serializeRetryPolicy,
|
|
11
|
+
shouldRetry,
|
|
12
|
+
shouldRetryByPolicy,
|
|
13
|
+
} from "./retry";
|
|
3
14
|
|
|
4
15
|
describe("calculateRetryDelayMs", () => {
|
|
5
16
|
test("calculates exponential backoff correctly", () => {
|
|
@@ -9,14 +20,15 @@ describe("calculateRetryDelayMs", () => {
|
|
|
9
20
|
expect(calculateRetryDelayMs(4)).toBe(8000);
|
|
10
21
|
expect(calculateRetryDelayMs(5)).toBe(16_000);
|
|
11
22
|
expect(calculateRetryDelayMs(6)).toBe(32_000);
|
|
12
|
-
|
|
23
|
+
// attempt 7: 1s * 2^6 = 64s = 64000ms, capped at 60000ms (max)
|
|
24
|
+
expect(calculateRetryDelayMs(7)).toBe(60_000);
|
|
13
25
|
});
|
|
14
26
|
|
|
15
27
|
test("caps delay at maximum interval", () => {
|
|
16
28
|
const { maximumIntervalMs } = DEFAULT_RETRY_POLICY;
|
|
17
29
|
|
|
18
|
-
// attempt
|
|
19
|
-
expect(calculateRetryDelayMs(
|
|
30
|
+
// attempt 7: 1s * 2^6 = 64s = 64000ms, capped at 60000ms (max)
|
|
31
|
+
expect(calculateRetryDelayMs(7)).toBe(maximumIntervalMs);
|
|
20
32
|
|
|
21
33
|
// attempts 10 & 100: should still be capped
|
|
22
34
|
expect(calculateRetryDelayMs(10)).toBe(maximumIntervalMs);
|
|
@@ -26,16 +38,173 @@ describe("calculateRetryDelayMs", () => {
|
|
|
26
38
|
test("handles edge cases", () => {
|
|
27
39
|
// attempt 0: 1s * 2^-1 = 0.5s = 500ms
|
|
28
40
|
expect(calculateRetryDelayMs(0)).toBe(500);
|
|
29
|
-
expect(calculateRetryDelayMs(Infinity)).toBe(
|
|
41
|
+
expect(calculateRetryDelayMs(Infinity)).toBe(60_000);
|
|
30
42
|
});
|
|
31
43
|
});
|
|
32
44
|
|
|
33
45
|
describe("shouldRetry", () => {
|
|
34
|
-
test("
|
|
35
|
-
|
|
36
|
-
expect(shouldRetry(
|
|
37
|
-
expect(shouldRetry(
|
|
38
|
-
expect(shouldRetry(
|
|
39
|
-
expect(shouldRetry(
|
|
46
|
+
test("returns false when attempt reaches maxAttempts", () => {
|
|
47
|
+
// 기본 정책: maxAttempts = 5
|
|
48
|
+
expect(shouldRetry(DEFAULT_RETRY_POLICY, 1)).toBe(true);
|
|
49
|
+
expect(shouldRetry(DEFAULT_RETRY_POLICY, 4)).toBe(true);
|
|
50
|
+
expect(shouldRetry(DEFAULT_RETRY_POLICY, 5)).toBe(false);
|
|
51
|
+
expect(shouldRetry(DEFAULT_RETRY_POLICY, 10)).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("shouldRetryByPolicy", () => {
|
|
56
|
+
test("respects maxAttempts from policy", () => {
|
|
57
|
+
expect(shouldRetryByPolicy({ maxAttempts: 3 }, 1)).toBe(true);
|
|
58
|
+
expect(shouldRetryByPolicy({ maxAttempts: 3 }, 2)).toBe(true);
|
|
59
|
+
expect(shouldRetryByPolicy({ maxAttempts: 3 }, 3)).toBe(false);
|
|
60
|
+
expect(shouldRetryByPolicy({ maxAttempts: 3 }, 4)).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("uses default maxAttempts when not specified", () => {
|
|
64
|
+
expect(shouldRetryByPolicy({}, 1)).toBe(true);
|
|
65
|
+
expect(shouldRetryByPolicy({}, 4)).toBe(true);
|
|
66
|
+
expect(shouldRetryByPolicy({}, 5)).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("isDynamicRetryPolicy", () => {
|
|
71
|
+
test("returns true for policy with shouldRetry function", () => {
|
|
72
|
+
const dynamicPolicy: DynamicRetryPolicy = {
|
|
73
|
+
maxAttempts: 3,
|
|
74
|
+
shouldRetry: () => ({ shouldRetry: true, delayMs: 1000 }),
|
|
75
|
+
};
|
|
76
|
+
expect(isDynamicRetryPolicy(dynamicPolicy)).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("returns false for static policy without shouldRetry", () => {
|
|
80
|
+
const staticPolicy: StaticRetryPolicy = {
|
|
81
|
+
maxAttempts: 5,
|
|
82
|
+
initialIntervalMs: 1000,
|
|
83
|
+
};
|
|
84
|
+
expect(isDynamicRetryPolicy(staticPolicy)).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("returns false for empty policy", () => {
|
|
88
|
+
expect(isDynamicRetryPolicy({})).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("isStaticRetryPolicy", () => {
|
|
93
|
+
test("returns true for static policy without shouldRetry", () => {
|
|
94
|
+
const staticPolicy: StaticRetryPolicy = {
|
|
95
|
+
maxAttempts: 5,
|
|
96
|
+
initialIntervalMs: 1000,
|
|
97
|
+
};
|
|
98
|
+
expect(isStaticRetryPolicy(staticPolicy)).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("returns true for empty policy", () => {
|
|
102
|
+
expect(isStaticRetryPolicy({})).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("returns false for dynamic policy", () => {
|
|
106
|
+
const dynamicPolicy: DynamicRetryPolicy = {
|
|
107
|
+
maxAttempts: 3,
|
|
108
|
+
shouldRetry: () => ({ shouldRetry: true, delayMs: 1000 }),
|
|
109
|
+
};
|
|
110
|
+
expect(isStaticRetryPolicy(dynamicPolicy)).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("mergeRetryPolicy", () => {
|
|
115
|
+
test("returns default values when policy is undefined", () => {
|
|
116
|
+
const merged = mergeRetryPolicy(undefined);
|
|
117
|
+
expect(merged.maxAttempts).toBe(5);
|
|
118
|
+
expect(merged.initialIntervalMs).toBe(1000);
|
|
119
|
+
expect(merged.backoffCoefficient).toBe(2);
|
|
120
|
+
expect(merged.maximumIntervalMs).toBe(60_000);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("uses provided values and fills missing with defaults for static policy", () => {
|
|
124
|
+
const merged = mergeRetryPolicy({ maxAttempts: 10, initialIntervalMs: 500 });
|
|
125
|
+
expect(merged.maxAttempts).toBe(10);
|
|
126
|
+
expect(merged.initialIntervalMs).toBe(500);
|
|
127
|
+
expect(merged.backoffCoefficient).toBe(2);
|
|
128
|
+
expect(merged.maximumIntervalMs).toBe(60_000);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("returns only maxAttempts and shouldRetry for dynamic policy", () => {
|
|
132
|
+
const customFn = (_error: SerializedError, _attempt: number) => ({
|
|
133
|
+
shouldRetry: false,
|
|
134
|
+
delayMs: 5000,
|
|
135
|
+
});
|
|
136
|
+
const dynamicPolicy: DynamicRetryPolicy = {
|
|
137
|
+
maxAttempts: 3,
|
|
138
|
+
shouldRetry: customFn,
|
|
139
|
+
};
|
|
140
|
+
const merged = mergeRetryPolicy(dynamicPolicy);
|
|
141
|
+
|
|
142
|
+
expect(merged.maxAttempts).toBe(3);
|
|
143
|
+
expect(merged.shouldRetry).toBe(customFn);
|
|
144
|
+
// 동적 정책에서는 backoff 필드들이 없어야 합니다.
|
|
145
|
+
expect("initialIntervalMs" in merged).toBe(false);
|
|
146
|
+
expect("backoffCoefficient" in merged).toBe(false);
|
|
147
|
+
expect("maximumIntervalMs" in merged).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("uses default maxAttempts for dynamic policy when not specified", () => {
|
|
151
|
+
const customFn = () => ({ shouldRetry: true, delayMs: 1000 });
|
|
152
|
+
const dynamicPolicy: DynamicRetryPolicy = {
|
|
153
|
+
shouldRetry: customFn,
|
|
154
|
+
};
|
|
155
|
+
const merged = mergeRetryPolicy(dynamicPolicy);
|
|
156
|
+
|
|
157
|
+
expect(merged.maxAttempts).toBe(5); // 기본값
|
|
158
|
+
expect(merged.shouldRetry).toBe(customFn);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("serializeRetryPolicy", () => {
|
|
163
|
+
test("returns empty object with hasDynamicPolicy=false for undefined", () => {
|
|
164
|
+
const serialized = serializeRetryPolicy(undefined);
|
|
165
|
+
expect(serialized.hasDynamicPolicy).toBe(false);
|
|
166
|
+
expect(serialized.maxAttempts).toBeUndefined();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("serializes static fields for static policy", () => {
|
|
170
|
+
const serialized = serializeRetryPolicy({
|
|
171
|
+
maxAttempts: 10,
|
|
172
|
+
initialIntervalMs: 2000,
|
|
173
|
+
});
|
|
174
|
+
expect(serialized.maxAttempts).toBe(10);
|
|
175
|
+
expect(serialized.initialIntervalMs).toBe(2000);
|
|
176
|
+
expect(serialized.hasDynamicPolicy).toBe(false);
|
|
177
|
+
expect("shouldRetry" in serialized).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("excludes backoff fields for dynamic policy", () => {
|
|
181
|
+
const dynamicPolicy: DynamicRetryPolicy = {
|
|
182
|
+
maxAttempts: 3,
|
|
183
|
+
shouldRetry: () => ({ shouldRetry: true, delayMs: 1000 }),
|
|
184
|
+
};
|
|
185
|
+
const serialized = serializeRetryPolicy(dynamicPolicy);
|
|
186
|
+
|
|
187
|
+
expect(serialized.maxAttempts).toBe(3);
|
|
188
|
+
expect(serialized.hasDynamicPolicy).toBe(true);
|
|
189
|
+
// 동적 정책에서는 backoff 필드들이 없어야 합니다.
|
|
190
|
+
expect(serialized.initialIntervalMs).toBeUndefined();
|
|
191
|
+
expect(serialized.backoffCoefficient).toBeUndefined();
|
|
192
|
+
expect(serialized.maximumIntervalMs).toBeUndefined();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("includes backoff fields for static policy", () => {
|
|
196
|
+
const staticPolicy: StaticRetryPolicy = {
|
|
197
|
+
maxAttempts: 5,
|
|
198
|
+
initialIntervalMs: 2000,
|
|
199
|
+
backoffCoefficient: 3,
|
|
200
|
+
maximumIntervalMs: 30000,
|
|
201
|
+
};
|
|
202
|
+
const serialized = serializeRetryPolicy(staticPolicy);
|
|
203
|
+
|
|
204
|
+
expect(serialized.maxAttempts).toBe(5);
|
|
205
|
+
expect(serialized.initialIntervalMs).toBe(2000);
|
|
206
|
+
expect(serialized.backoffCoefficient).toBe(3);
|
|
207
|
+
expect(serialized.maximumIntervalMs).toBe(30000);
|
|
208
|
+
expect(serialized.hasDynamicPolicy).toBe(false);
|
|
40
209
|
});
|
|
41
210
|
});
|
package/src/core/retry.ts
CHANGED
|
@@ -1,29 +1,105 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import type { SerializedError } from "./error";
|
|
2
|
+
|
|
3
|
+
export interface RetryDecision {
|
|
4
|
+
shouldRetry: boolean;
|
|
5
|
+
delayMs: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type RetryDecisionFn = (error: SerializedError, attempt: number) => RetryDecision;
|
|
9
|
+
|
|
10
|
+
export interface StaticRetryPolicy {
|
|
11
|
+
maxAttempts?: number;
|
|
12
|
+
initialIntervalMs?: number;
|
|
13
|
+
backoffCoefficient?: number;
|
|
14
|
+
maximumIntervalMs?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DynamicRetryPolicy {
|
|
18
|
+
maxAttempts?: number;
|
|
19
|
+
shouldRetry: RetryDecisionFn;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type RetryPolicy = StaticRetryPolicy | DynamicRetryPolicy;
|
|
23
|
+
|
|
24
|
+
export interface SerializableRetryPolicy extends StaticRetryPolicy {
|
|
25
|
+
hasDynamicPolicy?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type MergedStaticRetryPolicy = Required<StaticRetryPolicy>;
|
|
29
|
+
|
|
30
|
+
export interface MergedDynamicRetryPolicy {
|
|
31
|
+
maxAttempts: number;
|
|
32
|
+
shouldRetry: RetryDecisionFn;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type MergedRetryPolicy = MergedStaticRetryPolicy | MergedDynamicRetryPolicy;
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_RETRY_POLICY: Required<StaticRetryPolicy> = {
|
|
38
|
+
maxAttempts: 5,
|
|
39
|
+
initialIntervalMs: 1000,
|
|
3
40
|
backoffCoefficient: 2,
|
|
4
|
-
maximumIntervalMs:
|
|
5
|
-
|
|
6
|
-
} as const;
|
|
41
|
+
maximumIntervalMs: 60_000,
|
|
42
|
+
};
|
|
7
43
|
|
|
8
|
-
export
|
|
44
|
+
export function isDynamicRetryPolicy(policy: RetryPolicy): policy is DynamicRetryPolicy {
|
|
45
|
+
return "shouldRetry" in policy && typeof policy.shouldRetry === "function";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isStaticRetryPolicy(policy: RetryPolicy): policy is StaticRetryPolicy {
|
|
49
|
+
return !isDynamicRetryPolicy(policy);
|
|
50
|
+
}
|
|
9
51
|
|
|
10
|
-
/**
|
|
11
|
-
* Calculate the next retry delay using exponential backoff.
|
|
12
|
-
* @param attemptNumber - Attempt number (1-based)
|
|
13
|
-
* @returns Delay in milliseconds
|
|
14
|
-
*/
|
|
15
52
|
export function calculateRetryDelayMs(attemptNumber: number): number {
|
|
16
53
|
const { initialIntervalMs, backoffCoefficient, maximumIntervalMs } = DEFAULT_RETRY_POLICY;
|
|
17
54
|
const backoffMs = initialIntervalMs * backoffCoefficient ** (attemptNumber - 1);
|
|
18
55
|
return Math.min(backoffMs, maximumIntervalMs);
|
|
19
56
|
}
|
|
20
57
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return attemptNumber <
|
|
58
|
+
export function shouldRetry(retryPolicy: StaticRetryPolicy, attemptNumber: number): boolean {
|
|
59
|
+
const maxAttempts = retryPolicy.maxAttempts ?? DEFAULT_RETRY_POLICY.maxAttempts;
|
|
60
|
+
return attemptNumber < maxAttempts;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function shouldRetryByPolicy(policy: StaticRetryPolicy, attemptNumber: number): boolean {
|
|
64
|
+
const maxAttempts = policy.maxAttempts ?? DEFAULT_RETRY_POLICY.maxAttempts;
|
|
65
|
+
return attemptNumber < maxAttempts;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function mergeRetryPolicy(policy: StaticRetryPolicy | undefined): MergedStaticRetryPolicy;
|
|
69
|
+
export function mergeRetryPolicy(policy: DynamicRetryPolicy): MergedDynamicRetryPolicy;
|
|
70
|
+
export function mergeRetryPolicy(policy?: RetryPolicy): MergedRetryPolicy;
|
|
71
|
+
export function mergeRetryPolicy(policy?: RetryPolicy): MergedRetryPolicy {
|
|
72
|
+
if (policy && isDynamicRetryPolicy(policy)) {
|
|
73
|
+
return {
|
|
74
|
+
maxAttempts: policy.maxAttempts ?? DEFAULT_RETRY_POLICY.maxAttempts,
|
|
75
|
+
shouldRetry: policy.shouldRetry,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
maxAttempts: policy?.maxAttempts ?? DEFAULT_RETRY_POLICY.maxAttempts,
|
|
80
|
+
initialIntervalMs: policy?.initialIntervalMs ?? DEFAULT_RETRY_POLICY.initialIntervalMs,
|
|
81
|
+
backoffCoefficient: policy?.backoffCoefficient ?? DEFAULT_RETRY_POLICY.backoffCoefficient,
|
|
82
|
+
maximumIntervalMs: policy?.maximumIntervalMs ?? DEFAULT_RETRY_POLICY.maximumIntervalMs,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function serializeRetryPolicy(policy?: RetryPolicy): SerializableRetryPolicy {
|
|
87
|
+
if (!policy) {
|
|
88
|
+
return { hasDynamicPolicy: false };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (isDynamicRetryPolicy(policy)) {
|
|
92
|
+
return {
|
|
93
|
+
maxAttempts: policy.maxAttempts,
|
|
94
|
+
hasDynamicPolicy: true,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
maxAttempts: policy.maxAttempts,
|
|
100
|
+
initialIntervalMs: policy.initialIntervalMs,
|
|
101
|
+
backoffCoefficient: policy.backoffCoefficient,
|
|
102
|
+
maximumIntervalMs: policy.maximumIntervalMs,
|
|
103
|
+
hasDynamicPolicy: false,
|
|
104
|
+
};
|
|
29
105
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
3
3
|
import type { Backend } from "..//backend";
|
|
4
|
+
import type { SerializableRetryPolicy } from "../core/retry";
|
|
4
5
|
import type { StepAttempt } from "../core/step";
|
|
5
6
|
import type { WorkflowRun } from "../core/workflow";
|
|
6
7
|
|
|
@@ -532,6 +533,124 @@ export function testBackend(options: TestBackendOptions): void {
|
|
|
532
533
|
|
|
533
534
|
await teardown(backend);
|
|
534
535
|
});
|
|
536
|
+
|
|
537
|
+
test("marks workflow run as failed when maxAttempts is reached", async () => {
|
|
538
|
+
const backend = await setup();
|
|
539
|
+
|
|
540
|
+
// retryPolicy에 maxAttempts: 2를 지정하여 생성
|
|
541
|
+
const retryPolicy: SerializableRetryPolicy = {
|
|
542
|
+
maxAttempts: 2,
|
|
543
|
+
initialIntervalMs: 100,
|
|
544
|
+
};
|
|
545
|
+
await backend.createWorkflowRun({
|
|
546
|
+
workflowName: randomUUID(),
|
|
547
|
+
version: null,
|
|
548
|
+
idempotencyKey: null,
|
|
549
|
+
input: null,
|
|
550
|
+
config: {},
|
|
551
|
+
context: null,
|
|
552
|
+
availableAt: null,
|
|
553
|
+
deadlineAt: null,
|
|
554
|
+
retryPolicy,
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// 첫 번째 시도 - 실패하면 pending으로 스케줄링
|
|
558
|
+
let workerId = randomUUID();
|
|
559
|
+
let claimed = await backend.claimWorkflowRun({
|
|
560
|
+
workerId,
|
|
561
|
+
leaseDurationMs: 100,
|
|
562
|
+
});
|
|
563
|
+
if (!claimed) throw new Error("Expected workflow run to be claimed");
|
|
564
|
+
expect(claimed.attempts).toBe(1);
|
|
565
|
+
|
|
566
|
+
const firstFailed = await backend.failWorkflowRun({
|
|
567
|
+
workflowRunId: claimed.id,
|
|
568
|
+
workerId,
|
|
569
|
+
error: { message: "first failure" },
|
|
570
|
+
});
|
|
571
|
+
expect(firstFailed.status).toBe("pending"); // 아직 maxAttempts(2) 미달
|
|
572
|
+
|
|
573
|
+
await sleep(150); // 100ms backoff 대기
|
|
574
|
+
|
|
575
|
+
// 두 번째 시도 - maxAttempts에 도달하면 failed로 종료
|
|
576
|
+
workerId = randomUUID();
|
|
577
|
+
claimed = await backend.claimWorkflowRun({
|
|
578
|
+
workerId,
|
|
579
|
+
leaseDurationMs: 100,
|
|
580
|
+
});
|
|
581
|
+
if (!claimed) throw new Error("Expected workflow run to be claimed");
|
|
582
|
+
expect(claimed.attempts).toBe(2);
|
|
583
|
+
|
|
584
|
+
const secondFailed = await backend.failWorkflowRun({
|
|
585
|
+
workflowRunId: claimed.id,
|
|
586
|
+
workerId,
|
|
587
|
+
error: { message: "second failure" },
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
// maxAttempts에 도달했으므로 failed로 종료
|
|
591
|
+
expect(secondFailed.status).toBe("failed");
|
|
592
|
+
expect(secondFailed.availableAt).toBeNull();
|
|
593
|
+
expect(secondFailed.finishedAt).not.toBeNull();
|
|
594
|
+
|
|
595
|
+
await teardown(backend);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("marks workflow run as failed immediately when forceComplete is true", async () => {
|
|
599
|
+
const backend = await setup();
|
|
600
|
+
|
|
601
|
+
await createPendingWorkflowRun(backend);
|
|
602
|
+
|
|
603
|
+
const workerId = randomUUID();
|
|
604
|
+
const claimed = await backend.claimWorkflowRun({
|
|
605
|
+
workerId,
|
|
606
|
+
leaseDurationMs: 100,
|
|
607
|
+
});
|
|
608
|
+
if (!claimed) throw new Error("Expected workflow run to be claimed");
|
|
609
|
+
|
|
610
|
+
// forceComplete: true로 호출하면 재시도 없이 즉시 failed
|
|
611
|
+
const failed = await backend.failWorkflowRun({
|
|
612
|
+
workflowRunId: claimed.id,
|
|
613
|
+
workerId,
|
|
614
|
+
error: { message: "forced failure" },
|
|
615
|
+
forceComplete: true,
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
expect(failed.status).toBe("failed");
|
|
619
|
+
expect(failed.availableAt).toBeNull();
|
|
620
|
+
expect(failed.finishedAt).not.toBeNull();
|
|
621
|
+
|
|
622
|
+
await teardown(backend);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test("stores retryPolicy in config when creating workflow run", async () => {
|
|
626
|
+
const backend = await setup();
|
|
627
|
+
|
|
628
|
+
const retryPolicy: SerializableRetryPolicy = {
|
|
629
|
+
maxAttempts: 10,
|
|
630
|
+
initialIntervalMs: 500,
|
|
631
|
+
backoffCoefficient: 1.5,
|
|
632
|
+
maximumIntervalMs: 30000,
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
const created = await backend.createWorkflowRun({
|
|
636
|
+
workflowName: randomUUID(),
|
|
637
|
+
version: null,
|
|
638
|
+
idempotencyKey: null,
|
|
639
|
+
input: null,
|
|
640
|
+
config: { existingKey: "existingValue" },
|
|
641
|
+
context: null,
|
|
642
|
+
availableAt: null,
|
|
643
|
+
deadlineAt: null,
|
|
644
|
+
retryPolicy,
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
// config에 retryPolicy가 저장되어 있는지 확인
|
|
648
|
+
const config = created.config as Record<string, unknown>;
|
|
649
|
+
expect(config.existingKey).toBe("existingValue");
|
|
650
|
+
expect(config.retryPolicy).toEqual(retryPolicy);
|
|
651
|
+
|
|
652
|
+
await teardown(backend);
|
|
653
|
+
});
|
|
535
654
|
});
|
|
536
655
|
|
|
537
656
|
describe("createStepAttempt()", () => {
|