@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,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Distributed Circuit Breaker implementation.
|
|
3
|
+
*
|
|
4
|
+
* This module provides a circuit breaker that stores state in a distributed
|
|
5
|
+
* store, allowing multiple instances in a cluster to share circuit state.
|
|
6
|
+
*
|
|
7
|
+
* @module flow/distributed-circuit-breaker
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Effect } from "effect";
|
|
11
|
+
import type { UploadistaError } from "../errors";
|
|
12
|
+
import {
|
|
13
|
+
type CircuitBreakerStateData,
|
|
14
|
+
type CircuitBreakerStateValue,
|
|
15
|
+
type CircuitBreakerStore,
|
|
16
|
+
createInitialCircuitBreakerState,
|
|
17
|
+
} from "../types/circuit-breaker-store";
|
|
18
|
+
import {
|
|
19
|
+
type CircuitBreakerConfig,
|
|
20
|
+
type CircuitBreakerEventHandler,
|
|
21
|
+
type CircuitBreakerFallback,
|
|
22
|
+
DEFAULT_CIRCUIT_BREAKER_CONFIG,
|
|
23
|
+
} from "./circuit-breaker";
|
|
24
|
+
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// Distributed Circuit Breaker
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Result of checking if a request is allowed.
|
|
31
|
+
*/
|
|
32
|
+
export interface AllowRequestResult {
|
|
33
|
+
allowed: boolean;
|
|
34
|
+
state: CircuitBreakerStateValue;
|
|
35
|
+
failureCount: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Distributed circuit breaker that uses a store for state persistence.
|
|
40
|
+
*
|
|
41
|
+
* Unlike the in-memory CircuitBreaker, this implementation stores all state
|
|
42
|
+
* in a CircuitBreakerStore, allowing multiple instances to share circuit state.
|
|
43
|
+
*
|
|
44
|
+
* All operations are Effect-based since they may involve I/O.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```typescript
|
|
48
|
+
* const breaker = new DistributedCircuitBreaker(
|
|
49
|
+
* "describe-image",
|
|
50
|
+
* { enabled: true, failureThreshold: 5 },
|
|
51
|
+
* store
|
|
52
|
+
* );
|
|
53
|
+
*
|
|
54
|
+
* // Check if request is allowed
|
|
55
|
+
* const { allowed, state } = yield* breaker.allowRequest();
|
|
56
|
+
* if (!allowed) {
|
|
57
|
+
* // Handle circuit open
|
|
58
|
+
* }
|
|
59
|
+
*
|
|
60
|
+
* // Record result
|
|
61
|
+
* try {
|
|
62
|
+
* const result = yield* executeNode();
|
|
63
|
+
* yield* breaker.recordSuccess();
|
|
64
|
+
* return result;
|
|
65
|
+
* } catch (error) {
|
|
66
|
+
* yield* breaker.recordFailure(error.message);
|
|
67
|
+
* throw error;
|
|
68
|
+
* }
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export class DistributedCircuitBreaker {
|
|
72
|
+
private eventHandler?: CircuitBreakerEventHandler;
|
|
73
|
+
|
|
74
|
+
readonly nodeType: string;
|
|
75
|
+
readonly config: Required<Omit<CircuitBreakerConfig, "fallback">> & {
|
|
76
|
+
fallback: CircuitBreakerFallback;
|
|
77
|
+
};
|
|
78
|
+
readonly store: CircuitBreakerStore;
|
|
79
|
+
|
|
80
|
+
constructor(
|
|
81
|
+
nodeType: string,
|
|
82
|
+
config: CircuitBreakerConfig,
|
|
83
|
+
store: CircuitBreakerStore,
|
|
84
|
+
) {
|
|
85
|
+
this.nodeType = nodeType;
|
|
86
|
+
this.config = {
|
|
87
|
+
enabled: config.enabled ?? DEFAULT_CIRCUIT_BREAKER_CONFIG.enabled,
|
|
88
|
+
failureThreshold:
|
|
89
|
+
config.failureThreshold ??
|
|
90
|
+
DEFAULT_CIRCUIT_BREAKER_CONFIG.failureThreshold,
|
|
91
|
+
resetTimeout:
|
|
92
|
+
config.resetTimeout ?? DEFAULT_CIRCUIT_BREAKER_CONFIG.resetTimeout,
|
|
93
|
+
halfOpenRequests:
|
|
94
|
+
config.halfOpenRequests ??
|
|
95
|
+
DEFAULT_CIRCUIT_BREAKER_CONFIG.halfOpenRequests,
|
|
96
|
+
windowDuration:
|
|
97
|
+
config.windowDuration ?? DEFAULT_CIRCUIT_BREAKER_CONFIG.windowDuration,
|
|
98
|
+
fallback: config.fallback ?? DEFAULT_CIRCUIT_BREAKER_CONFIG.fallback,
|
|
99
|
+
};
|
|
100
|
+
this.store = store;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Sets the event handler for state change notifications.
|
|
105
|
+
*/
|
|
106
|
+
setEventHandler(handler: CircuitBreakerEventHandler): void {
|
|
107
|
+
this.eventHandler = handler;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Checks if a request is allowed through the circuit.
|
|
112
|
+
*
|
|
113
|
+
* This method reads state from the store, checks for time-based transitions,
|
|
114
|
+
* and returns whether the request should proceed.
|
|
115
|
+
*/
|
|
116
|
+
allowRequest(): Effect.Effect<AllowRequestResult, UploadistaError> {
|
|
117
|
+
const self = this;
|
|
118
|
+
return Effect.gen(function* () {
|
|
119
|
+
if (!self.config.enabled) {
|
|
120
|
+
return { allowed: true, state: "closed" as const, failureCount: 0 };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let state = yield* self.store.getState(self.nodeType);
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
|
|
126
|
+
// Initialize state if not exists
|
|
127
|
+
if (state === null) {
|
|
128
|
+
state = createInitialCircuitBreakerState({
|
|
129
|
+
failureThreshold: self.config.failureThreshold,
|
|
130
|
+
resetTimeout: self.config.resetTimeout,
|
|
131
|
+
halfOpenRequests: self.config.halfOpenRequests,
|
|
132
|
+
windowDuration: self.config.windowDuration,
|
|
133
|
+
});
|
|
134
|
+
yield* self.store.setState(self.nodeType, state);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Check for time-based transition: open -> half-open
|
|
138
|
+
if (state.state === "open") {
|
|
139
|
+
const timeSinceOpen = now - state.lastStateChange;
|
|
140
|
+
if (timeSinceOpen >= self.config.resetTimeout) {
|
|
141
|
+
// Transition to half-open
|
|
142
|
+
const previousState = state.state;
|
|
143
|
+
state = {
|
|
144
|
+
...state,
|
|
145
|
+
state: "half-open",
|
|
146
|
+
halfOpenSuccesses: 0,
|
|
147
|
+
lastStateChange: now,
|
|
148
|
+
};
|
|
149
|
+
yield* self.store.setState(self.nodeType, state);
|
|
150
|
+
yield* self.emitEvent(previousState, "half-open", state.failureCount);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Determine if request is allowed
|
|
155
|
+
const allowed = state.state !== "open";
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
allowed,
|
|
159
|
+
state: state.state,
|
|
160
|
+
failureCount: state.failureCount,
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Gets the current circuit state from the store.
|
|
167
|
+
*/
|
|
168
|
+
getState(): Effect.Effect<CircuitBreakerStateValue, UploadistaError> {
|
|
169
|
+
const self = this;
|
|
170
|
+
return Effect.gen(function* () {
|
|
171
|
+
const state = yield* self.store.getState(self.nodeType);
|
|
172
|
+
return state?.state ?? "closed";
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Gets the current failure count from the store.
|
|
178
|
+
*/
|
|
179
|
+
getFailureCount(): Effect.Effect<number, UploadistaError> {
|
|
180
|
+
const self = this;
|
|
181
|
+
return Effect.gen(function* () {
|
|
182
|
+
const state = yield* self.store.getState(self.nodeType);
|
|
183
|
+
return state?.failureCount ?? 0;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Records a successful execution.
|
|
189
|
+
*
|
|
190
|
+
* In half-open state, tracks successes toward closing the circuit.
|
|
191
|
+
* In closed state, resets the failure count.
|
|
192
|
+
*/
|
|
193
|
+
recordSuccess(): Effect.Effect<void, UploadistaError> {
|
|
194
|
+
const self = this;
|
|
195
|
+
return Effect.gen(function* () {
|
|
196
|
+
if (!self.config.enabled) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const state = yield* self.store.getState(self.nodeType);
|
|
201
|
+
if (state === null) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (state.state === "half-open") {
|
|
206
|
+
const newSuccessCount = yield* self.store.incrementHalfOpenSuccesses(
|
|
207
|
+
self.nodeType,
|
|
208
|
+
);
|
|
209
|
+
if (newSuccessCount >= self.config.halfOpenRequests) {
|
|
210
|
+
// Transition to closed
|
|
211
|
+
yield* self.transitionTo("closed", state.failureCount);
|
|
212
|
+
}
|
|
213
|
+
} else if (state.state === "closed") {
|
|
214
|
+
// Reset failure count on success
|
|
215
|
+
yield* self.store.resetFailures(self.nodeType);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Records a failed execution.
|
|
222
|
+
*
|
|
223
|
+
* In closed state, increments failure count and may trip the circuit.
|
|
224
|
+
* In half-open state, immediately reopens the circuit.
|
|
225
|
+
*/
|
|
226
|
+
recordFailure(_errorMessage: string): Effect.Effect<void, UploadistaError> {
|
|
227
|
+
const self = this;
|
|
228
|
+
return Effect.gen(function* () {
|
|
229
|
+
if (!self.config.enabled) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const state = yield* self.store.getState(self.nodeType);
|
|
234
|
+
|
|
235
|
+
if (state === null || state.state === "closed") {
|
|
236
|
+
// Increment failures and check threshold
|
|
237
|
+
const newFailureCount = yield* self.store.incrementFailures(
|
|
238
|
+
self.nodeType,
|
|
239
|
+
self.config.windowDuration,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
if (newFailureCount >= self.config.failureThreshold) {
|
|
243
|
+
// Trip the circuit
|
|
244
|
+
yield* self.transitionTo("open", newFailureCount);
|
|
245
|
+
}
|
|
246
|
+
} else if (state.state === "half-open") {
|
|
247
|
+
// Any failure in half-open reopens the circuit
|
|
248
|
+
yield* self.transitionTo("open", state.failureCount);
|
|
249
|
+
}
|
|
250
|
+
// In open state, failures are ignored (requests shouldn't reach here)
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Gets the fallback configuration.
|
|
256
|
+
*/
|
|
257
|
+
getFallback(): CircuitBreakerFallback {
|
|
258
|
+
return this.config.fallback;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Resets the circuit breaker to closed state.
|
|
263
|
+
*/
|
|
264
|
+
reset(): Effect.Effect<void, UploadistaError> {
|
|
265
|
+
const self = this;
|
|
266
|
+
return Effect.gen(function* () {
|
|
267
|
+
const state = yield* self.store.getState(self.nodeType);
|
|
268
|
+
const previousState = state?.state ?? "closed";
|
|
269
|
+
|
|
270
|
+
yield* self.store.setState(
|
|
271
|
+
self.nodeType,
|
|
272
|
+
createInitialCircuitBreakerState({
|
|
273
|
+
failureThreshold: self.config.failureThreshold,
|
|
274
|
+
resetTimeout: self.config.resetTimeout,
|
|
275
|
+
halfOpenRequests: self.config.halfOpenRequests,
|
|
276
|
+
windowDuration: self.config.windowDuration,
|
|
277
|
+
}),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
if (previousState !== "closed") {
|
|
281
|
+
yield* self.emitEvent(previousState, "closed", 0);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Transitions to a new state.
|
|
288
|
+
*/
|
|
289
|
+
private transitionTo(
|
|
290
|
+
newState: CircuitBreakerStateValue,
|
|
291
|
+
failureCount: number,
|
|
292
|
+
): Effect.Effect<void, UploadistaError> {
|
|
293
|
+
const self = this;
|
|
294
|
+
return Effect.gen(function* () {
|
|
295
|
+
const currentState = yield* self.store.getState(self.nodeType);
|
|
296
|
+
const previousState = currentState?.state ?? "closed";
|
|
297
|
+
|
|
298
|
+
if (previousState === newState) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const now = Date.now();
|
|
303
|
+
const updatedState: CircuitBreakerStateData = {
|
|
304
|
+
state: newState,
|
|
305
|
+
failureCount: newState === "closed" ? 0 : failureCount,
|
|
306
|
+
lastStateChange: now,
|
|
307
|
+
halfOpenSuccesses: 0,
|
|
308
|
+
windowStart:
|
|
309
|
+
newState === "closed" ? now : (currentState?.windowStart ?? now),
|
|
310
|
+
config: {
|
|
311
|
+
failureThreshold: self.config.failureThreshold,
|
|
312
|
+
resetTimeout: self.config.resetTimeout,
|
|
313
|
+
halfOpenRequests: self.config.halfOpenRequests,
|
|
314
|
+
windowDuration: self.config.windowDuration,
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
yield* self.store.setState(self.nodeType, updatedState);
|
|
319
|
+
yield* self.emitEvent(previousState, newState, failureCount);
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Emits a state change event if handler is set.
|
|
325
|
+
*/
|
|
326
|
+
private emitEvent(
|
|
327
|
+
previousState: CircuitBreakerStateValue,
|
|
328
|
+
newState: CircuitBreakerStateValue,
|
|
329
|
+
failureCount: number,
|
|
330
|
+
): Effect.Effect<void, never, never> {
|
|
331
|
+
const self = this;
|
|
332
|
+
return Effect.gen(function* () {
|
|
333
|
+
if (self.eventHandler) {
|
|
334
|
+
yield* self.eventHandler({
|
|
335
|
+
nodeType: self.nodeType,
|
|
336
|
+
previousState,
|
|
337
|
+
newState,
|
|
338
|
+
timestamp: Date.now(),
|
|
339
|
+
failureCount,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ============================================================================
|
|
347
|
+
// Distributed Circuit Breaker Registry
|
|
348
|
+
// ============================================================================
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Registry for managing distributed circuit breakers.
|
|
352
|
+
*
|
|
353
|
+
* Unlike the in-memory CircuitBreakerRegistry, this registry creates
|
|
354
|
+
* DistributedCircuitBreaker instances that share state via a store.
|
|
355
|
+
*
|
|
356
|
+
* @example
|
|
357
|
+
* ```typescript
|
|
358
|
+
* const store = makeKvCircuitBreakerStore(baseKvStore);
|
|
359
|
+
* const registry = new DistributedCircuitBreakerRegistry(store);
|
|
360
|
+
*
|
|
361
|
+
* const breaker = registry.getOrCreate("describe-image", {
|
|
362
|
+
* enabled: true,
|
|
363
|
+
* failureThreshold: 5
|
|
364
|
+
* });
|
|
365
|
+
* ```
|
|
366
|
+
*/
|
|
367
|
+
export class DistributedCircuitBreakerRegistry {
|
|
368
|
+
private breakers: Map<string, DistributedCircuitBreaker> = new Map();
|
|
369
|
+
private eventHandler?: CircuitBreakerEventHandler;
|
|
370
|
+
|
|
371
|
+
constructor(readonly store: CircuitBreakerStore) {}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Sets a global event handler for all circuit breakers.
|
|
375
|
+
*/
|
|
376
|
+
setEventHandler(handler: CircuitBreakerEventHandler): void {
|
|
377
|
+
this.eventHandler = handler;
|
|
378
|
+
for (const breaker of this.breakers.values()) {
|
|
379
|
+
breaker.setEventHandler(handler);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Gets an existing circuit breaker or creates a new one.
|
|
385
|
+
*/
|
|
386
|
+
getOrCreate(
|
|
387
|
+
nodeType: string,
|
|
388
|
+
config: CircuitBreakerConfig,
|
|
389
|
+
): DistributedCircuitBreaker {
|
|
390
|
+
let breaker = this.breakers.get(nodeType);
|
|
391
|
+
if (!breaker) {
|
|
392
|
+
breaker = new DistributedCircuitBreaker(nodeType, config, this.store);
|
|
393
|
+
if (this.eventHandler) {
|
|
394
|
+
breaker.setEventHandler(this.eventHandler);
|
|
395
|
+
}
|
|
396
|
+
this.breakers.set(nodeType, breaker);
|
|
397
|
+
}
|
|
398
|
+
return breaker;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Gets an existing circuit breaker if it exists.
|
|
403
|
+
*/
|
|
404
|
+
get(nodeType: string): DistributedCircuitBreaker | undefined {
|
|
405
|
+
return this.breakers.get(nodeType);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Gets statistics for all circuit breakers from the store.
|
|
410
|
+
*/
|
|
411
|
+
getAllStats(): Effect.Effect<
|
|
412
|
+
Map<string, { state: CircuitBreakerStateValue; failureCount: number }>,
|
|
413
|
+
UploadistaError
|
|
414
|
+
> {
|
|
415
|
+
return this.store.getAllStats();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Resets all circuit breakers.
|
|
420
|
+
*/
|
|
421
|
+
resetAll(): Effect.Effect<void, UploadistaError> {
|
|
422
|
+
const self = this;
|
|
423
|
+
return Effect.gen(function* () {
|
|
424
|
+
for (const breaker of self.breakers.values()) {
|
|
425
|
+
yield* breaker.reset();
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Clears all circuit breakers from the local cache.
|
|
432
|
+
* Note: This does not clear state from the store.
|
|
433
|
+
*/
|
|
434
|
+
clear(): void {
|
|
435
|
+
this.breakers.clear();
|
|
436
|
+
}
|
|
437
|
+
}
|
package/src/flow/event.ts
CHANGED
|
@@ -57,6 +57,18 @@ export enum EventType {
|
|
|
57
57
|
NodeStream = "node-stream",
|
|
58
58
|
/** Emitted for node response data */
|
|
59
59
|
NodeResponse = "node-response",
|
|
60
|
+
/** Emitted when a job is added to the Dead Letter Queue */
|
|
61
|
+
DlqItemAdded = "dlq-item-added",
|
|
62
|
+
/** Emitted when a DLQ retry attempt starts */
|
|
63
|
+
DlqRetryStart = "dlq-retry-start",
|
|
64
|
+
/** Emitted when a DLQ retry succeeds */
|
|
65
|
+
DlqRetrySuccess = "dlq-retry-success",
|
|
66
|
+
/** Emitted when a DLQ retry fails */
|
|
67
|
+
DlqRetryFailed = "dlq-retry-failed",
|
|
68
|
+
/** Emitted when a DLQ item is exhausted (max retries reached) */
|
|
69
|
+
DlqItemExhausted = "dlq-item-exhausted",
|
|
70
|
+
/** Emitted when a DLQ item is resolved */
|
|
71
|
+
DlqItemResolved = "dlq-item-resolved",
|
|
60
72
|
}
|
|
61
73
|
|
|
62
74
|
/**
|
|
@@ -246,6 +258,94 @@ export type FlowEventNodeResponse = {
|
|
|
246
258
|
data: unknown;
|
|
247
259
|
};
|
|
248
260
|
|
|
261
|
+
// ============================================================================
|
|
262
|
+
// Dead Letter Queue Events
|
|
263
|
+
// ============================================================================
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Event emitted when a job is added to the Dead Letter Queue.
|
|
267
|
+
*/
|
|
268
|
+
export type FlowEventDlqItemAdded = {
|
|
269
|
+
eventType: EventType.DlqItemAdded;
|
|
270
|
+
dlqItemId: string;
|
|
271
|
+
jobId: string;
|
|
272
|
+
flowId: string;
|
|
273
|
+
errorCode: string;
|
|
274
|
+
errorMessage: string;
|
|
275
|
+
retryCount: number;
|
|
276
|
+
maxRetries: number;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Event emitted when a DLQ retry attempt starts.
|
|
281
|
+
*/
|
|
282
|
+
export type FlowEventDlqRetryStart = {
|
|
283
|
+
eventType: EventType.DlqRetryStart;
|
|
284
|
+
dlqItemId: string;
|
|
285
|
+
jobId: string;
|
|
286
|
+
flowId: string;
|
|
287
|
+
attemptNumber: number;
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Event emitted when a DLQ retry succeeds.
|
|
292
|
+
*/
|
|
293
|
+
export type FlowEventDlqRetrySuccess = {
|
|
294
|
+
eventType: EventType.DlqRetrySuccess;
|
|
295
|
+
dlqItemId: string;
|
|
296
|
+
jobId: string;
|
|
297
|
+
flowId: string;
|
|
298
|
+
attemptNumber: number;
|
|
299
|
+
durationMs: number;
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Event emitted when a DLQ retry fails.
|
|
304
|
+
*/
|
|
305
|
+
export type FlowEventDlqRetryFailed = {
|
|
306
|
+
eventType: EventType.DlqRetryFailed;
|
|
307
|
+
dlqItemId: string;
|
|
308
|
+
jobId: string;
|
|
309
|
+
flowId: string;
|
|
310
|
+
attemptNumber: number;
|
|
311
|
+
error: string;
|
|
312
|
+
durationMs: number;
|
|
313
|
+
nextRetryAt?: string; // ISO 8601 timestamp
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Event emitted when a DLQ item is exhausted (max retries reached).
|
|
318
|
+
*/
|
|
319
|
+
export type FlowEventDlqItemExhausted = {
|
|
320
|
+
eventType: EventType.DlqItemExhausted;
|
|
321
|
+
dlqItemId: string;
|
|
322
|
+
jobId: string;
|
|
323
|
+
flowId: string;
|
|
324
|
+
totalAttempts: number;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Event emitted when a DLQ item is resolved.
|
|
329
|
+
*/
|
|
330
|
+
export type FlowEventDlqItemResolved = {
|
|
331
|
+
eventType: EventType.DlqItemResolved;
|
|
332
|
+
dlqItemId: string;
|
|
333
|
+
jobId: string;
|
|
334
|
+
flowId: string;
|
|
335
|
+
resolvedBy: "retry" | "manual";
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Union of all DLQ-related events.
|
|
340
|
+
*/
|
|
341
|
+
export type DlqEvent =
|
|
342
|
+
| FlowEventDlqItemAdded
|
|
343
|
+
| FlowEventDlqRetryStart
|
|
344
|
+
| FlowEventDlqRetrySuccess
|
|
345
|
+
| FlowEventDlqRetryFailed
|
|
346
|
+
| FlowEventDlqItemExhausted
|
|
347
|
+
| FlowEventDlqItemResolved;
|
|
348
|
+
|
|
249
349
|
/**
|
|
250
350
|
* Union of all possible flow execution events.
|
|
251
351
|
*
|
|
@@ -267,6 +367,9 @@ export type FlowEventNodeResponse = {
|
|
|
267
367
|
* case EventType.FlowCancel:
|
|
268
368
|
* console.log("Flow cancelled:", event.flowId);
|
|
269
369
|
* break;
|
|
370
|
+
* case EventType.DlqItemAdded:
|
|
371
|
+
* console.log("Job added to DLQ:", event.dlqItemId);
|
|
372
|
+
* break;
|
|
270
373
|
* }
|
|
271
374
|
* }
|
|
272
375
|
* ```
|
|
@@ -283,4 +386,5 @@ export type FlowEvent =
|
|
|
283
386
|
| FlowEventNodeEnd
|
|
284
387
|
| FlowEventNodePause
|
|
285
388
|
| FlowEventNodeResume
|
|
286
|
-
| FlowEventNodeError
|
|
389
|
+
| FlowEventNodeError
|
|
390
|
+
| DlqEvent;
|
package/src/flow/flow-server.ts
CHANGED
|
@@ -53,6 +53,7 @@ export class FlowWaitUntil extends Context.Tag("FlowWaitUntil")<
|
|
|
53
53
|
|
|
54
54
|
import { FlowEventEmitter, FlowJobKVStore } from "../types";
|
|
55
55
|
import { UploadServer } from "../upload";
|
|
56
|
+
import { DeadLetterQueueService } from "./dead-letter-queue";
|
|
56
57
|
import type { FlowEvent } from "./event";
|
|
57
58
|
import type { FlowJob } from "./types/flow-job";
|
|
58
59
|
|
|
@@ -710,6 +711,7 @@ export function createFlowServer() {
|
|
|
710
711
|
const eventEmitter = yield* FlowEventEmitter;
|
|
711
712
|
const kvStore = yield* FlowJobKVStore;
|
|
712
713
|
const uploadServer = yield* UploadServer;
|
|
714
|
+
const dlqOption = yield* DeadLetterQueueService.optional;
|
|
713
715
|
|
|
714
716
|
const updateJob = (jobId: string, updates: Partial<FlowJob>) =>
|
|
715
717
|
Effect.gen(function* () {
|
|
@@ -766,6 +768,50 @@ export function createFlowServer() {
|
|
|
766
768
|
});
|
|
767
769
|
});
|
|
768
770
|
|
|
771
|
+
// Helper function to add failed job to Dead Letter Queue
|
|
772
|
+
const addToDeadLetterQueue = (
|
|
773
|
+
jobId: string,
|
|
774
|
+
error: UploadistaError,
|
|
775
|
+
) =>
|
|
776
|
+
Effect.gen(function* () {
|
|
777
|
+
if (Option.isNone(dlqOption)) {
|
|
778
|
+
// DLQ not configured, skip
|
|
779
|
+
yield* Effect.logDebug(
|
|
780
|
+
`[FlowServer] DLQ not configured, skipping for job: ${jobId}`,
|
|
781
|
+
);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const dlq = dlqOption.value;
|
|
786
|
+
|
|
787
|
+
// Get the job to add to DLQ
|
|
788
|
+
const job = yield* Effect.catchAll(kvStore.get(jobId), () =>
|
|
789
|
+
Effect.succeed(null as FlowJob | null),
|
|
790
|
+
);
|
|
791
|
+
|
|
792
|
+
if (!job) {
|
|
793
|
+
yield* Effect.logWarning(
|
|
794
|
+
`[FlowServer] Job ${jobId} not found when adding to DLQ`,
|
|
795
|
+
);
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Add to DLQ
|
|
800
|
+
yield* Effect.catchAll(dlq.add(job, error), (dlqError) =>
|
|
801
|
+
Effect.gen(function* () {
|
|
802
|
+
yield* Effect.logError(
|
|
803
|
+
`[FlowServer] Failed to add job ${jobId} to DLQ`,
|
|
804
|
+
dlqError,
|
|
805
|
+
);
|
|
806
|
+
return Effect.succeed(undefined);
|
|
807
|
+
}),
|
|
808
|
+
);
|
|
809
|
+
|
|
810
|
+
yield* Effect.logInfo(
|
|
811
|
+
`[FlowServer] Added job ${jobId} to Dead Letter Queue`,
|
|
812
|
+
);
|
|
813
|
+
});
|
|
814
|
+
|
|
769
815
|
// Helper function to execute flow in background
|
|
770
816
|
const executeFlowInBackground = ({
|
|
771
817
|
jobId,
|
|
@@ -895,6 +941,18 @@ export function createFlowServer() {
|
|
|
895
941
|
),
|
|
896
942
|
);
|
|
897
943
|
|
|
944
|
+
// Add failed job to Dead Letter Queue for retry/debugging
|
|
945
|
+
const uploadistaError =
|
|
946
|
+
error instanceof UploadistaError
|
|
947
|
+
? error
|
|
948
|
+
: new UploadistaError({
|
|
949
|
+
code: "UNKNOWN_ERROR",
|
|
950
|
+
status: 500,
|
|
951
|
+
body: String(error),
|
|
952
|
+
cause: error,
|
|
953
|
+
});
|
|
954
|
+
yield* addToDeadLetterQueue(jobId, uploadistaError);
|
|
955
|
+
|
|
898
956
|
throw error;
|
|
899
957
|
}),
|
|
900
958
|
),
|
|
@@ -1216,6 +1274,18 @@ export function createFlowServer() {
|
|
|
1216
1274
|
),
|
|
1217
1275
|
);
|
|
1218
1276
|
|
|
1277
|
+
// Add failed job to Dead Letter Queue for retry/debugging
|
|
1278
|
+
const uploadistaError =
|
|
1279
|
+
error instanceof UploadistaError
|
|
1280
|
+
? error
|
|
1281
|
+
: new UploadistaError({
|
|
1282
|
+
code: "UNKNOWN_ERROR",
|
|
1283
|
+
status: 500,
|
|
1284
|
+
body: String(error),
|
|
1285
|
+
cause: error,
|
|
1286
|
+
});
|
|
1287
|
+
yield* addToDeadLetterQueue(jobId, uploadistaError);
|
|
1288
|
+
|
|
1219
1289
|
throw error;
|
|
1220
1290
|
}),
|
|
1221
1291
|
),
|