@uploadista/core 0.0.17 → 0.0.18-beta.10
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/README.md +102 -0
- package/dist/{checksum-DaCqP8Qa.mjs → checksum-COoD-F1l.mjs} +2 -2
- package/dist/{checksum-DaCqP8Qa.mjs.map → checksum-COoD-F1l.mjs.map} +1 -1
- package/dist/{checksum-BIlVW8bD.cjs → checksum-YLW4hVY7.cjs} +1 -1
- package/dist/errors/index.cjs +1 -1
- package/dist/errors/index.d.cts +1 -1
- package/dist/errors/index.d.mts +1 -1
- package/dist/errors/index.mjs +1 -1
- package/dist/flow/index.cjs +1 -1
- package/dist/flow/index.d.cts +5 -5
- package/dist/flow/index.d.mts +5 -5
- package/dist/flow/index.mjs +1 -1
- package/dist/flow-BLGpxdEm.mjs +2 -0
- package/dist/flow-BLGpxdEm.mjs.map +1 -0
- package/dist/flow-DaBzRGmY.cjs +1 -0
- package/dist/{index-BGi1r_fi.d.mts → index-9gyMMEIB.d.cts} +2 -2
- package/dist/{index-BGi1r_fi.d.mts.map → index-9gyMMEIB.d.cts.map} +1 -1
- package/dist/{index-B_SvQ0MU.d.cts → index-B9V5SSxl.d.mts} +2 -2
- package/dist/{index-B_SvQ0MU.d.cts.map → index-B9V5SSxl.d.mts.map} +1 -1
- package/dist/{index-DIWuZlxd.d.mts → index-BFSHumky.d.mts} +2 -2
- package/dist/{index-DIWuZlxd.d.mts.map → index-BFSHumky.d.mts.map} +1 -1
- package/dist/{index-BQ5luyME.d.cts → index-D7i4bgl3.d.mts} +2747 -828
- package/dist/index-D7i4bgl3.d.mts.map +1 -0
- package/dist/{index-qIN6ULCb.d.cts → index-DFbu_-zn.d.cts} +2 -2
- package/dist/{index-qIN6ULCb.d.cts.map → index-DFbu_-zn.d.cts.map} +1 -1
- package/dist/{index-BtnCNLsH.d.mts → index-fF-j_WhY.d.cts} +2747 -828
- package/dist/index-fF-j_WhY.d.cts.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.cts +5 -5
- package/dist/index.d.mts +5 -5
- package/dist/index.mjs +1 -1
- package/dist/{stream-limiter-D2Y8Z_Kv.mjs → stream-limiter-B9nsn2gb.mjs} +2 -2
- package/dist/{stream-limiter-D2Y8Z_Kv.mjs.map → stream-limiter-B9nsn2gb.mjs.map} +1 -1
- package/dist/{stream-limiter-By0fxkAh.cjs → stream-limiter-DyWOdil4.cjs} +1 -1
- package/dist/streams/index.cjs +1 -1
- package/dist/streams/index.d.cts +2 -2
- package/dist/streams/index.d.mts +2 -2
- package/dist/streams/index.mjs +1 -1
- package/dist/testing/index.cjs +1 -1
- package/dist/testing/index.d.cts +4 -4
- package/dist/testing/index.d.mts +4 -4
- package/dist/testing/index.mjs +1 -1
- package/dist/types/index.cjs +1 -1
- package/dist/types/index.d.cts +5 -5
- package/dist/types/index.d.mts +5 -5
- package/dist/types/index.mjs +1 -1
- package/dist/types-CH0BgiJN.mjs +2 -0
- package/dist/types-CH0BgiJN.mjs.map +1 -0
- package/dist/types-DUYVoR13.cjs +1 -0
- package/dist/upload/index.cjs +1 -1
- package/dist/upload/index.d.cts +4 -4
- package/dist/upload/index.d.mts +4 -4
- package/dist/upload/index.mjs +1 -1
- package/dist/{upload-bBgM3QFI.cjs → upload-CFT-dWPB.cjs} +1 -1
- package/dist/{upload-Bq9h95w6.mjs → upload-ggK-0ZBM.mjs} +2 -2
- package/dist/{upload-Bq9h95w6.mjs.map → upload-ggK-0ZBM.mjs.map} +1 -1
- package/dist/{uploadista-error-DCRIscEv.cjs → uploadista-error-BxBLmQtX.cjs} +4 -1
- package/dist/{uploadista-error-Bb-qIIKM.d.cts → uploadista-error-CYCmAtkZ.d.cts} +2 -2
- package/dist/uploadista-error-CYCmAtkZ.d.cts.map +1 -0
- package/dist/{uploadista-error-djFxVTLh.mjs → uploadista-error-CkSxSyNo.mjs} +4 -1
- package/dist/uploadista-error-CkSxSyNo.mjs.map +1 -0
- package/dist/{uploadista-error-D7Gubrr1.d.mts → uploadista-error-DR0XimpE.d.mts} +2 -2
- package/dist/uploadista-error-DR0XimpE.d.mts.map +1 -0
- package/dist/utils/index.cjs +1 -1
- package/dist/utils/index.d.cts +2 -2
- package/dist/utils/index.d.mts +2 -2
- package/dist/utils/index.mjs +1 -1
- package/dist/{utils-MQUZyB9S.mjs → utils-B-ZhQ6b0.mjs} +2 -2
- package/dist/{utils-MQUZyB9S.mjs.map → utils-B-ZhQ6b0.mjs.map} +1 -1
- package/dist/{utils-DxLVhlLd.cjs → utils-Dhq3vPqp.cjs} +1 -1
- package/docs/CIRCUIT_BREAKER.md +381 -0
- package/docs/DEAD-LETTER-QUEUE.md +374 -0
- package/package.json +11 -6
- package/src/errors/uploadista-error.ts +16 -1
- package/src/flow/README.md +102 -0
- package/src/flow/circuit-breaker-store.ts +382 -0
- package/src/flow/circuit-breaker.ts +99 -0
- package/src/flow/dead-letter-queue.ts +573 -0
- package/src/flow/distributed-circuit-breaker.ts +437 -0
- package/src/flow/event.ts +105 -1
- package/src/flow/flow-server.ts +70 -0
- package/src/flow/flow.ts +141 -3
- package/src/flow/index.ts +14 -2
- package/src/flow/input-type-registry.ts +229 -0
- package/src/flow/node-types/index.ts +26 -20
- package/src/flow/node.ts +48 -26
- package/src/flow/nodes/input-node.ts +4 -2
- package/src/flow/nodes/transform-node.ts +64 -6
- package/src/flow/output-type-registry.ts +231 -0
- package/src/flow/type-guards.ts +38 -22
- package/src/flow/typed-flow.ts +26 -0
- package/src/flow/types/dead-letter-item.ts +258 -0
- package/src/flow/types/flow-types.ts +320 -2
- package/src/flow/types/retry-policy.ts +260 -0
- package/src/flow/utils/file-naming.ts +308 -0
- package/src/types/circuit-breaker-store.ts +222 -0
- package/src/types/health-check.ts +204 -0
- package/src/types/index.ts +2 -0
- package/src/types/kv-store.ts +82 -2
- package/tests/flow/dead-letter-item.test.ts +283 -0
- package/tests/flow/dead-letter-queue.test.ts +613 -0
- package/tests/flow/file-naming.test.ts +390 -0
- package/tests/flow/retry-policy.test.ts +284 -0
- package/tests/flow/type-registry.test.ts +1 -1
- package/tests/flow/type-system.test.ts +17 -14
- package/dist/flow-BiUCrFTv.cjs +0 -1
- package/dist/flow-vXXjtBBv.mjs +0 -2
- package/dist/flow-vXXjtBBv.mjs.map +0 -1
- package/dist/index-BQ5luyME.d.cts.map +0 -1
- package/dist/index-BtnCNLsH.d.mts.map +0 -1
- package/dist/types-B5I4BioZ.cjs +0 -1
- package/dist/types-f6w5J3UD.mjs +0 -2
- package/dist/types-f6w5J3UD.mjs.map +0 -1
- package/dist/uploadista-error-Bb-qIIKM.d.cts.map +0 -1
- package/dist/uploadista-error-D7Gubrr1.d.mts.map +0 -1
- package/dist/uploadista-error-djFxVTLh.mjs.map +0 -1
- package/src/flow/type-registry.ts +0 -379
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for the Dead Letter Queue service
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - Adding failed jobs to DLQ
|
|
6
|
+
* - Retrieving DLQ items
|
|
7
|
+
* - Listing with filters
|
|
8
|
+
* - Retry status transitions
|
|
9
|
+
* - Cleanup operations
|
|
10
|
+
* - Statistics calculation
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { it } from "@effect/vitest";
|
|
14
|
+
import { Context, Effect, Layer, Option } from "effect";
|
|
15
|
+
import { describe, expect } from "vitest";
|
|
16
|
+
import { UploadistaError } from "../../src/errors";
|
|
17
|
+
import {
|
|
18
|
+
createDeadLetterQueueService,
|
|
19
|
+
DeadLetterQueueService,
|
|
20
|
+
} from "../../src/flow/dead-letter-queue";
|
|
21
|
+
import type { DeadLetterItem } from "../../src/flow/types/dead-letter-item";
|
|
22
|
+
import type { FlowJob } from "../../src/flow/types/flow-job";
|
|
23
|
+
import {
|
|
24
|
+
BaseKvStoreService,
|
|
25
|
+
DeadLetterQueueKVStore,
|
|
26
|
+
deadLetterQueueKvStore,
|
|
27
|
+
type BaseKvStore,
|
|
28
|
+
} from "../../src/types/kv-store";
|
|
29
|
+
|
|
30
|
+
// In-memory KV store for testing
|
|
31
|
+
const createInMemoryKvStore = (): BaseKvStore => {
|
|
32
|
+
const store = new Map<string, string>();
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
get: (key: string) =>
|
|
36
|
+
Effect.gen(function* () {
|
|
37
|
+
const value = store.get(key);
|
|
38
|
+
if (value === undefined) {
|
|
39
|
+
return yield* Effect.fail(
|
|
40
|
+
UploadistaError.fromCode("FILE_NOT_FOUND", {
|
|
41
|
+
cause: `Key ${key} not found`,
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}),
|
|
47
|
+
set: (key: string, value: string) =>
|
|
48
|
+
Effect.gen(function* () {
|
|
49
|
+
store.set(key, value);
|
|
50
|
+
}),
|
|
51
|
+
delete: (key: string) =>
|
|
52
|
+
Effect.gen(function* () {
|
|
53
|
+
store.delete(key);
|
|
54
|
+
}),
|
|
55
|
+
list: (prefix: string) =>
|
|
56
|
+
Effect.succeed([...store.keys()].filter((key) => key.startsWith(prefix))),
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Create test layers
|
|
61
|
+
const createTestLayers = () => {
|
|
62
|
+
const inMemoryStore = createInMemoryKvStore();
|
|
63
|
+
const baseKvStoreLayer = Layer.succeed(BaseKvStoreService, inMemoryStore);
|
|
64
|
+
|
|
65
|
+
return Layer.provide(deadLetterQueueKvStore, baseKvStoreLayer);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Create a mock FlowJob for testing
|
|
69
|
+
const createMockFlowJob = (overrides?: Partial<FlowJob>): FlowJob => ({
|
|
70
|
+
id: `job_${Date.now()}`,
|
|
71
|
+
flowId: "test-flow",
|
|
72
|
+
storageId: "test-storage",
|
|
73
|
+
clientId: "test-client",
|
|
74
|
+
status: "failed",
|
|
75
|
+
tasks: [
|
|
76
|
+
{
|
|
77
|
+
nodeId: "input-node",
|
|
78
|
+
nodeName: "Input Node",
|
|
79
|
+
status: "completed",
|
|
80
|
+
result: { file: { id: "file_123" } },
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
nodeId: "process-node",
|
|
84
|
+
nodeName: "Process Node",
|
|
85
|
+
status: "failed",
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
createdAt: new Date(),
|
|
89
|
+
updatedAt: new Date(),
|
|
90
|
+
executionState: {
|
|
91
|
+
executionOrder: ["input-node", "process-node"],
|
|
92
|
+
currentIndex: 1,
|
|
93
|
+
inputs: { input: { uploadId: "upload_123" } },
|
|
94
|
+
},
|
|
95
|
+
...overrides,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("DeadLetterQueueService", () => {
|
|
99
|
+
describe("add", () => {
|
|
100
|
+
it.effect("should add a failed job to the DLQ", () =>
|
|
101
|
+
Effect.gen(function* () {
|
|
102
|
+
const dlqStore = yield* DeadLetterQueueKVStore;
|
|
103
|
+
const service = yield* createDeadLetterQueueService();
|
|
104
|
+
|
|
105
|
+
const job = createMockFlowJob();
|
|
106
|
+
const error = UploadistaError.fromCode("FLOW_NODE_ERROR", {
|
|
107
|
+
body: "Processing failed",
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const item = yield* service.add(job, error);
|
|
111
|
+
|
|
112
|
+
expect(item.id).toMatch(/^dlq_/);
|
|
113
|
+
expect(item.jobId).toBe(job.id);
|
|
114
|
+
expect(item.flowId).toBe("test-flow");
|
|
115
|
+
expect(item.storageId).toBe("test-storage");
|
|
116
|
+
expect(item.clientId).toBe("test-client");
|
|
117
|
+
expect(item.error.code).toBe("FLOW_NODE_ERROR");
|
|
118
|
+
expect(item.error.message).toBe("Processing failed");
|
|
119
|
+
expect(item.retryCount).toBe(0);
|
|
120
|
+
expect(item.maxRetries).toBe(3); // Default
|
|
121
|
+
expect(item.status).toBe("pending");
|
|
122
|
+
expect(item.retryHistory).toHaveLength(0);
|
|
123
|
+
expect(item.createdAt).toBeInstanceOf(Date);
|
|
124
|
+
expect(item.updatedAt).toBeInstanceOf(Date);
|
|
125
|
+
}).pipe(Effect.provide(createTestLayers())),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
it.effect("should capture node results from completed tasks", () =>
|
|
129
|
+
Effect.gen(function* () {
|
|
130
|
+
const service = yield* createDeadLetterQueueService();
|
|
131
|
+
|
|
132
|
+
const job = createMockFlowJob({
|
|
133
|
+
tasks: [
|
|
134
|
+
{
|
|
135
|
+
nodeId: "input-node",
|
|
136
|
+
nodeName: "Input",
|
|
137
|
+
status: "completed",
|
|
138
|
+
result: { file: { id: "file_123", size: 1024 } },
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
nodeId: "resize-node",
|
|
142
|
+
nodeName: "Resize",
|
|
143
|
+
status: "completed",
|
|
144
|
+
result: { thumbnail: { width: 200, height: 150 } },
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
nodeId: "output-node",
|
|
148
|
+
nodeName: "Output",
|
|
149
|
+
status: "failed",
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
});
|
|
153
|
+
const error = new UploadistaError({
|
|
154
|
+
code: "STORAGE_ERROR",
|
|
155
|
+
status: 500,
|
|
156
|
+
body: "Upload failed",
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const item = yield* service.add(job, error);
|
|
160
|
+
|
|
161
|
+
expect(item.nodeResults["input-node"]).toEqual({
|
|
162
|
+
file: { id: "file_123", size: 1024 },
|
|
163
|
+
});
|
|
164
|
+
expect(item.nodeResults["resize-node"]).toEqual({
|
|
165
|
+
thumbnail: { width: 200, height: 150 },
|
|
166
|
+
});
|
|
167
|
+
expect(item.failedAtNodeId).toBe("output-node");
|
|
168
|
+
}).pipe(Effect.provide(createTestLayers())),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
it.effect("should use custom retry policy when provided", () =>
|
|
172
|
+
Effect.gen(function* () {
|
|
173
|
+
const service = yield* createDeadLetterQueueService();
|
|
174
|
+
|
|
175
|
+
const job = createMockFlowJob();
|
|
176
|
+
const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
|
|
177
|
+
|
|
178
|
+
const item = yield* service.add(job, error, {
|
|
179
|
+
enabled: true,
|
|
180
|
+
maxRetries: 10,
|
|
181
|
+
backoff: { type: "immediate" },
|
|
182
|
+
ttlMs: 86400000, // 1 day
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(item.maxRetries).toBe(10);
|
|
186
|
+
expect(item.expiresAt).toBeDefined();
|
|
187
|
+
}).pipe(Effect.provide(createTestLayers())),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
it.effect("should mark as exhausted for non-retryable errors", () =>
|
|
191
|
+
Effect.gen(function* () {
|
|
192
|
+
const service = yield* createDeadLetterQueueService();
|
|
193
|
+
|
|
194
|
+
const job = createMockFlowJob();
|
|
195
|
+
const error = UploadistaError.fromCode("VALIDATION_ERROR");
|
|
196
|
+
|
|
197
|
+
const item = yield* service.add(job, error, {
|
|
198
|
+
enabled: true,
|
|
199
|
+
maxRetries: 3,
|
|
200
|
+
backoff: { type: "immediate" },
|
|
201
|
+
nonRetryableErrors: ["VALIDATION_ERROR"],
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
expect(item.status).toBe("exhausted");
|
|
205
|
+
expect(item.nextRetryAt).toBeUndefined();
|
|
206
|
+
}).pipe(Effect.provide(createTestLayers())),
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("get and getOption", () => {
|
|
211
|
+
it.effect("should retrieve an existing item", () =>
|
|
212
|
+
Effect.gen(function* () {
|
|
213
|
+
const service = yield* createDeadLetterQueueService();
|
|
214
|
+
|
|
215
|
+
const job = createMockFlowJob();
|
|
216
|
+
const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
|
|
217
|
+
const added = yield* service.add(job, error);
|
|
218
|
+
|
|
219
|
+
const retrieved = yield* service.get(added.id);
|
|
220
|
+
|
|
221
|
+
expect(retrieved.id).toBe(added.id);
|
|
222
|
+
expect(retrieved.jobId).toBe(job.id);
|
|
223
|
+
}).pipe(Effect.provide(createTestLayers())),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
it.effect("should fail when item not found", () =>
|
|
227
|
+
Effect.gen(function* () {
|
|
228
|
+
const service = yield* createDeadLetterQueueService();
|
|
229
|
+
|
|
230
|
+
const result = yield* Effect.either(service.get("non-existent-id"));
|
|
231
|
+
|
|
232
|
+
expect(result._tag).toBe("Left");
|
|
233
|
+
}).pipe(Effect.provide(createTestLayers())),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
it.effect("should return Option.none for non-existent item", () =>
|
|
237
|
+
Effect.gen(function* () {
|
|
238
|
+
const service = yield* createDeadLetterQueueService();
|
|
239
|
+
|
|
240
|
+
const result = yield* service.getOption("non-existent-id");
|
|
241
|
+
|
|
242
|
+
expect(Option.isNone(result)).toBe(true);
|
|
243
|
+
}).pipe(Effect.provide(createTestLayers())),
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
it.effect("should return Option.some for existing item", () =>
|
|
247
|
+
Effect.gen(function* () {
|
|
248
|
+
const service = yield* createDeadLetterQueueService();
|
|
249
|
+
|
|
250
|
+
const job = createMockFlowJob();
|
|
251
|
+
const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
|
|
252
|
+
const added = yield* service.add(job, error);
|
|
253
|
+
|
|
254
|
+
const result = yield* service.getOption(added.id);
|
|
255
|
+
|
|
256
|
+
expect(Option.isSome(result)).toBe(true);
|
|
257
|
+
if (Option.isSome(result)) {
|
|
258
|
+
expect(result.value.id).toBe(added.id);
|
|
259
|
+
}
|
|
260
|
+
}).pipe(Effect.provide(createTestLayers())),
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("list", () => {
|
|
265
|
+
it.effect("should list all items", () =>
|
|
266
|
+
Effect.gen(function* () {
|
|
267
|
+
const service = yield* createDeadLetterQueueService();
|
|
268
|
+
|
|
269
|
+
// Add multiple items
|
|
270
|
+
const job1 = createMockFlowJob({ id: "job_1" });
|
|
271
|
+
const job2 = createMockFlowJob({ id: "job_2" });
|
|
272
|
+
const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
|
|
273
|
+
|
|
274
|
+
yield* service.add(job1, error);
|
|
275
|
+
yield* service.add(job2, error);
|
|
276
|
+
|
|
277
|
+
const { items, total } = yield* service.list();
|
|
278
|
+
|
|
279
|
+
expect(items).toHaveLength(2);
|
|
280
|
+
expect(total).toBe(2);
|
|
281
|
+
}).pipe(Effect.provide(createTestLayers())),
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
it.effect("should filter by status", () =>
|
|
285
|
+
Effect.gen(function* () {
|
|
286
|
+
const service = yield* createDeadLetterQueueService();
|
|
287
|
+
|
|
288
|
+
const job1 = createMockFlowJob({ id: "job_1" });
|
|
289
|
+
const job2 = createMockFlowJob({ id: "job_2" });
|
|
290
|
+
const retryableError = UploadistaError.fromCode("FLOW_NODE_ERROR");
|
|
291
|
+
const nonRetryableError = UploadistaError.fromCode("VALIDATION_ERROR");
|
|
292
|
+
|
|
293
|
+
yield* service.add(job1, retryableError);
|
|
294
|
+
yield* service.add(job2, nonRetryableError, {
|
|
295
|
+
enabled: true,
|
|
296
|
+
maxRetries: 0,
|
|
297
|
+
backoff: { type: "immediate" },
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// Update second item to exhausted
|
|
301
|
+
const { items: allItems } = yield* service.list();
|
|
302
|
+
const job2Item = allItems.find((i) => i.jobId === "job_2");
|
|
303
|
+
if (job2Item) {
|
|
304
|
+
yield* service.update(job2Item.id, { status: "exhausted" });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const { items: pendingItems } = yield* service.list({
|
|
308
|
+
status: "pending",
|
|
309
|
+
});
|
|
310
|
+
const { items: exhaustedItems } = yield* service.list({
|
|
311
|
+
status: "exhausted",
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
expect(pendingItems.every((i) => i.status === "pending")).toBe(true);
|
|
315
|
+
expect(exhaustedItems.every((i) => i.status === "exhausted")).toBe(true);
|
|
316
|
+
}).pipe(Effect.provide(createTestLayers())),
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
it.effect("should filter by flowId", () =>
|
|
320
|
+
Effect.gen(function* () {
|
|
321
|
+
const service = yield* createDeadLetterQueueService();
|
|
322
|
+
|
|
323
|
+
const job1 = createMockFlowJob({ id: "job_1", flowId: "flow-a" });
|
|
324
|
+
const job2 = createMockFlowJob({ id: "job_2", flowId: "flow-b" });
|
|
325
|
+
const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
|
|
326
|
+
|
|
327
|
+
yield* service.add(job1, error);
|
|
328
|
+
yield* service.add(job2, error);
|
|
329
|
+
|
|
330
|
+
const { items } = yield* service.list({ flowId: "flow-a" });
|
|
331
|
+
|
|
332
|
+
expect(items).toHaveLength(1);
|
|
333
|
+
expect(items[0]?.flowId).toBe("flow-a");
|
|
334
|
+
}).pipe(Effect.provide(createTestLayers())),
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
it.effect("should paginate results", () =>
|
|
338
|
+
Effect.gen(function* () {
|
|
339
|
+
const service = yield* createDeadLetterQueueService();
|
|
340
|
+
|
|
341
|
+
// Add 5 items
|
|
342
|
+
const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
|
|
343
|
+
for (let i = 0; i < 5; i++) {
|
|
344
|
+
const job = createMockFlowJob({ id: `job_${i}` });
|
|
345
|
+
yield* service.add(job, error);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const { items: page1, total } = yield* service.list({
|
|
349
|
+
limit: 2,
|
|
350
|
+
offset: 0,
|
|
351
|
+
});
|
|
352
|
+
const { items: page2 } = yield* service.list({ limit: 2, offset: 2 });
|
|
353
|
+
|
|
354
|
+
expect(total).toBe(5);
|
|
355
|
+
expect(page1).toHaveLength(2);
|
|
356
|
+
expect(page2).toHaveLength(2);
|
|
357
|
+
}).pipe(Effect.provide(createTestLayers())),
|
|
358
|
+
);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
describe("markRetrying and recordRetryFailure", () => {
|
|
362
|
+
it.effect("should transition to retrying status", () =>
|
|
363
|
+
Effect.gen(function* () {
|
|
364
|
+
const service = yield* createDeadLetterQueueService();
|
|
365
|
+
|
|
366
|
+
const job = createMockFlowJob();
|
|
367
|
+
const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
|
|
368
|
+
const added = yield* service.add(job, error);
|
|
369
|
+
|
|
370
|
+
const updated = yield* service.markRetrying(added.id);
|
|
371
|
+
|
|
372
|
+
expect(updated.status).toBe("retrying");
|
|
373
|
+
// updatedAt should be at least the same or later (timing can be same millisecond)
|
|
374
|
+
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(
|
|
375
|
+
added.updatedAt.getTime(),
|
|
376
|
+
);
|
|
377
|
+
}).pipe(Effect.provide(createTestLayers())),
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
it.effect("should record retry failure and increment count", () =>
|
|
381
|
+
Effect.gen(function* () {
|
|
382
|
+
const service = yield* createDeadLetterQueueService();
|
|
383
|
+
|
|
384
|
+
const job = createMockFlowJob();
|
|
385
|
+
const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
|
|
386
|
+
const added = yield* service.add(job, error);
|
|
387
|
+
|
|
388
|
+
yield* service.markRetrying(added.id);
|
|
389
|
+
const afterFailure = yield* service.recordRetryFailure(
|
|
390
|
+
added.id,
|
|
391
|
+
"Timeout error",
|
|
392
|
+
5000,
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
expect(afterFailure.retryCount).toBe(1);
|
|
396
|
+
expect(afterFailure.status).toBe("pending");
|
|
397
|
+
expect(afterFailure.retryHistory).toHaveLength(1);
|
|
398
|
+
expect(afterFailure.retryHistory[0]?.error).toBe("Timeout error");
|
|
399
|
+
expect(afterFailure.retryHistory[0]?.durationMs).toBe(5000);
|
|
400
|
+
expect(afterFailure.nextRetryAt).toBeDefined();
|
|
401
|
+
}).pipe(Effect.provide(createTestLayers())),
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
it.effect("should mark as exhausted when max retries reached", () =>
|
|
405
|
+
Effect.gen(function* () {
|
|
406
|
+
const service = yield* createDeadLetterQueueService();
|
|
407
|
+
|
|
408
|
+
const job = createMockFlowJob();
|
|
409
|
+
const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
|
|
410
|
+
const added = yield* service.add(job, error, {
|
|
411
|
+
enabled: true,
|
|
412
|
+
maxRetries: 2,
|
|
413
|
+
backoff: { type: "immediate" },
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// First retry
|
|
417
|
+
yield* service.markRetrying(added.id);
|
|
418
|
+
yield* service.recordRetryFailure(added.id, "Error 1", 1000);
|
|
419
|
+
|
|
420
|
+
// Second retry (exhausts)
|
|
421
|
+
yield* service.markRetrying(added.id);
|
|
422
|
+
const afterSecondFailure = yield* service.recordRetryFailure(
|
|
423
|
+
added.id,
|
|
424
|
+
"Error 2",
|
|
425
|
+
1000,
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
expect(afterSecondFailure.retryCount).toBe(2);
|
|
429
|
+
expect(afterSecondFailure.status).toBe("exhausted");
|
|
430
|
+
expect(afterSecondFailure.nextRetryAt).toBeUndefined();
|
|
431
|
+
}).pipe(Effect.provide(createTestLayers())),
|
|
432
|
+
);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
describe("markResolved", () => {
|
|
436
|
+
it.effect("should transition to resolved status", () =>
|
|
437
|
+
Effect.gen(function* () {
|
|
438
|
+
const service = yield* createDeadLetterQueueService();
|
|
439
|
+
|
|
440
|
+
const job = createMockFlowJob();
|
|
441
|
+
const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
|
|
442
|
+
const added = yield* service.add(job, error);
|
|
443
|
+
|
|
444
|
+
const resolved = yield* service.markResolved(added.id);
|
|
445
|
+
|
|
446
|
+
expect(resolved.status).toBe("resolved");
|
|
447
|
+
expect(resolved.nextRetryAt).toBeUndefined();
|
|
448
|
+
}).pipe(Effect.provide(createTestLayers())),
|
|
449
|
+
);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
describe("delete", () => {
|
|
453
|
+
it.effect("should delete an item", () =>
|
|
454
|
+
Effect.gen(function* () {
|
|
455
|
+
const service = yield* createDeadLetterQueueService();
|
|
456
|
+
|
|
457
|
+
const job = createMockFlowJob();
|
|
458
|
+
const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
|
|
459
|
+
const added = yield* service.add(job, error);
|
|
460
|
+
|
|
461
|
+
yield* service.delete(added.id);
|
|
462
|
+
|
|
463
|
+
const result = yield* service.getOption(added.id);
|
|
464
|
+
expect(Option.isNone(result)).toBe(true);
|
|
465
|
+
}).pipe(Effect.provide(createTestLayers())),
|
|
466
|
+
);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
describe("getScheduledRetries", () => {
|
|
470
|
+
it.effect("should return items ready for retry", () =>
|
|
471
|
+
Effect.gen(function* () {
|
|
472
|
+
const service = yield* createDeadLetterQueueService();
|
|
473
|
+
|
|
474
|
+
const job = createMockFlowJob();
|
|
475
|
+
const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
|
|
476
|
+
const added = yield* service.add(job, error, {
|
|
477
|
+
enabled: true,
|
|
478
|
+
maxRetries: 3,
|
|
479
|
+
backoff: { type: "immediate" }, // Immediate retry
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// Item should be immediately ready for retry
|
|
483
|
+
const readyItems = yield* service.getScheduledRetries();
|
|
484
|
+
|
|
485
|
+
expect(readyItems.length).toBeGreaterThanOrEqual(1);
|
|
486
|
+
expect(readyItems.some((i) => i.id === added.id)).toBe(true);
|
|
487
|
+
}).pipe(Effect.provide(createTestLayers())),
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
it.effect("should respect limit parameter", () =>
|
|
491
|
+
Effect.gen(function* () {
|
|
492
|
+
const service = yield* createDeadLetterQueueService();
|
|
493
|
+
|
|
494
|
+
// Add multiple items
|
|
495
|
+
const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
|
|
496
|
+
for (let i = 0; i < 5; i++) {
|
|
497
|
+
const job = createMockFlowJob({ id: `job_${i}` });
|
|
498
|
+
yield* service.add(job, error, {
|
|
499
|
+
enabled: true,
|
|
500
|
+
maxRetries: 3,
|
|
501
|
+
backoff: { type: "immediate" },
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const readyItems = yield* service.getScheduledRetries(2);
|
|
506
|
+
|
|
507
|
+
expect(readyItems).toHaveLength(2);
|
|
508
|
+
}).pipe(Effect.provide(createTestLayers())),
|
|
509
|
+
);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
describe("cleanup", () => {
|
|
513
|
+
it.effect("should cleanup items based on olderThan", () =>
|
|
514
|
+
Effect.gen(function* () {
|
|
515
|
+
const service = yield* createDeadLetterQueueService();
|
|
516
|
+
|
|
517
|
+
const job = createMockFlowJob();
|
|
518
|
+
const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
|
|
519
|
+
|
|
520
|
+
// Add item
|
|
521
|
+
const added = yield* service.add(job, error);
|
|
522
|
+
|
|
523
|
+
// Mark as resolved (so it can be cleaned up)
|
|
524
|
+
yield* service.markResolved(added.id);
|
|
525
|
+
|
|
526
|
+
// Cleanup with a future date (should catch all)
|
|
527
|
+
const futureDate = new Date(Date.now() + 1000000);
|
|
528
|
+
const result = yield* service.cleanup({
|
|
529
|
+
olderThan: futureDate,
|
|
530
|
+
status: "resolved",
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
expect(result.deleted).toBeGreaterThanOrEqual(1);
|
|
534
|
+
|
|
535
|
+
const check = yield* service.getOption(added.id);
|
|
536
|
+
expect(Option.isNone(check)).toBe(true);
|
|
537
|
+
}).pipe(Effect.provide(createTestLayers())),
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
it.effect("should cleanup resolved items older than threshold", () =>
|
|
541
|
+
Effect.gen(function* () {
|
|
542
|
+
const service = yield* createDeadLetterQueueService();
|
|
543
|
+
|
|
544
|
+
const job = createMockFlowJob();
|
|
545
|
+
const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
|
|
546
|
+
const added = yield* service.add(job, error);
|
|
547
|
+
|
|
548
|
+
// Mark as resolved
|
|
549
|
+
yield* service.markResolved(added.id);
|
|
550
|
+
|
|
551
|
+
// Cleanup items older than future date (should catch all)
|
|
552
|
+
const futureDate = new Date(Date.now() + 1000000);
|
|
553
|
+
const result = yield* service.cleanup({
|
|
554
|
+
olderThan: futureDate,
|
|
555
|
+
status: "resolved",
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
expect(result.deleted).toBeGreaterThanOrEqual(1);
|
|
559
|
+
}).pipe(Effect.provide(createTestLayers())),
|
|
560
|
+
);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
describe("getStats", () => {
|
|
564
|
+
it.effect("should return correct statistics", () =>
|
|
565
|
+
Effect.gen(function* () {
|
|
566
|
+
const service = yield* createDeadLetterQueueService();
|
|
567
|
+
|
|
568
|
+
const error = UploadistaError.fromCode("FLOW_NODE_ERROR");
|
|
569
|
+
|
|
570
|
+
// Add items with different statuses
|
|
571
|
+
const job1 = createMockFlowJob({ id: "job_1", flowId: "flow-a" });
|
|
572
|
+
const job2 = createMockFlowJob({ id: "job_2", flowId: "flow-a" });
|
|
573
|
+
const job3 = createMockFlowJob({ id: "job_3", flowId: "flow-b" });
|
|
574
|
+
|
|
575
|
+
const item1 = yield* service.add(job1, error);
|
|
576
|
+
const item2 = yield* service.add(job2, error);
|
|
577
|
+
yield* service.add(job3, error);
|
|
578
|
+
|
|
579
|
+
// Mark one as resolved
|
|
580
|
+
yield* service.markResolved(item1.id);
|
|
581
|
+
|
|
582
|
+
// Do a retry on another
|
|
583
|
+
yield* service.markRetrying(item2.id);
|
|
584
|
+
yield* service.recordRetryFailure(item2.id, "Error", 1000);
|
|
585
|
+
|
|
586
|
+
const stats = yield* service.getStats();
|
|
587
|
+
|
|
588
|
+
expect(stats.totalItems).toBe(3);
|
|
589
|
+
expect(stats.byStatus.resolved).toBe(1);
|
|
590
|
+
expect(stats.byStatus.pending).toBe(2);
|
|
591
|
+
expect(stats.byFlow["flow-a"]).toBe(2);
|
|
592
|
+
expect(stats.byFlow["flow-b"]).toBe(1);
|
|
593
|
+
expect(stats.averageRetryCount).toBeGreaterThan(0);
|
|
594
|
+
}).pipe(Effect.provide(createTestLayers())),
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
it.effect("should handle empty queue", () =>
|
|
598
|
+
Effect.gen(function* () {
|
|
599
|
+
const service = yield* createDeadLetterQueueService();
|
|
600
|
+
|
|
601
|
+
const stats = yield* service.getStats();
|
|
602
|
+
|
|
603
|
+
expect(stats.totalItems).toBe(0);
|
|
604
|
+
expect(stats.byStatus.pending).toBe(0);
|
|
605
|
+
expect(stats.byStatus.retrying).toBe(0);
|
|
606
|
+
expect(stats.byStatus.exhausted).toBe(0);
|
|
607
|
+
expect(stats.byStatus.resolved).toBe(0);
|
|
608
|
+
expect(stats.averageRetryCount).toBe(0);
|
|
609
|
+
expect(stats.oldestItem).toBeUndefined();
|
|
610
|
+
}).pipe(Effect.provide(createTestLayers())),
|
|
611
|
+
);
|
|
612
|
+
});
|
|
613
|
+
});
|