@gkoos/caracal 0.1.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/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +311 -0
- package/dist/chunk-5CXDW7W6.js +202 -0
- package/dist/chunk-5CXDW7W6.js.map +1 -0
- package/dist/circuit-breaker-BSkcV0W_.d.ts +296 -0
- package/dist/fetch.d.ts +58 -0
- package/dist/fetch.js +117 -0
- package/dist/fetch.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +1065 -0
- package/dist/index.js.map +1 -0
- package/dist/postgres.d.ts +28 -0
- package/dist/postgres.js +56 -0
- package/dist/postgres.js.map +1 -0
- package/dist/redis.d.ts +59 -0
- package/dist/redis.js +549 -0
- package/dist/redis.js.map +1 -0
- package/dist/retry-BFP_k3Hg.d.ts +26 -0
- package/dist/testing/index.d.ts +45 -0
- package/dist/testing/index.js +101 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/types-Tf9T76C7.d.ts +187 -0
- package/package.json +127 -0
- package/src/adapters/fetch/adapter.ts +122 -0
- package/src/adapters/fetch/index.ts +15 -0
- package/src/adapters/fetch/retry-after.ts +111 -0
- package/src/adapters/postgres/adapter.ts +102 -0
- package/src/adapters/postgres/index.ts +7 -0
- package/src/coordination/redis/bulkhead.ts +61 -0
- package/src/coordination/redis/circuit-breaker.ts +270 -0
- package/src/coordination/redis/client.ts +78 -0
- package/src/coordination/redis/eval-script.ts +71 -0
- package/src/coordination/redis/keys.ts +32 -0
- package/src/coordination/redis/leases.ts +44 -0
- package/src/coordination/redis/scripts.ts +314 -0
- package/src/core/bulkhead.ts +336 -0
- package/src/core/circuit-breaker.ts +1066 -0
- package/src/core/index.ts +36 -0
- package/src/core/operation.ts +174 -0
- package/src/core/retry.ts +204 -0
- package/src/core/runtime.ts +123 -0
- package/src/core/scope-state-cache.ts +50 -0
- package/src/core/timeout.ts +73 -0
- package/src/core/types.ts +230 -0
- package/src/fetch.ts +17 -0
- package/src/index.ts +49 -0
- package/src/postgres.ts +9 -0
- package/src/redis.ts +8 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import {
|
|
3
|
+
admissionSignal,
|
|
4
|
+
emitRuntimeEvent,
|
|
5
|
+
withAdmissionSignal,
|
|
6
|
+
withSignal,
|
|
7
|
+
} from "./runtime.js"
|
|
8
|
+
import type { ExecutionContext, Next, Policy } from "./types.js"
|
|
9
|
+
|
|
10
|
+
export class BulkheadRejectedError extends Error {
|
|
11
|
+
constructor(
|
|
12
|
+
readonly coordination: "local" | "distributed",
|
|
13
|
+
readonly policyName: string,
|
|
14
|
+
readonly scope: string,
|
|
15
|
+
readonly reason: string,
|
|
16
|
+
) {
|
|
17
|
+
super(`Bulkhead ${policyName} rejected: ${reason}`)
|
|
18
|
+
this.name = "BulkheadRejectedError"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export interface LocalBulkheadOptions {
|
|
22
|
+
readonly name: string
|
|
23
|
+
readonly limit: number
|
|
24
|
+
readonly queue?: { readonly limit: number; readonly timeoutMs: number }
|
|
25
|
+
}
|
|
26
|
+
/** Policy-specific capability supplied by caracal/redis. */
|
|
27
|
+
export interface BulkheadCoordinator {
|
|
28
|
+
command(
|
|
29
|
+
identity: { name: string; operation: string; scope: string },
|
|
30
|
+
action: "acquire" | "renew" | "release",
|
|
31
|
+
token: string,
|
|
32
|
+
leaseMs: number,
|
|
33
|
+
limit: number,
|
|
34
|
+
): Promise<{ allowed: boolean; occupancy: number }>
|
|
35
|
+
}
|
|
36
|
+
export interface DistributedBulkheadOptions {
|
|
37
|
+
readonly name: string
|
|
38
|
+
readonly limit: number
|
|
39
|
+
readonly coordinator: BulkheadCoordinator
|
|
40
|
+
readonly scope: (context: ExecutionContext) => string
|
|
41
|
+
readonly leaseMs?: number
|
|
42
|
+
}
|
|
43
|
+
function validate(name: string, limit: number) {
|
|
44
|
+
if (!name.trim() || !Number.isSafeInteger(limit) || limit < 1)
|
|
45
|
+
throw new RangeError("Bulkhead needs a name and positive integer limit")
|
|
46
|
+
}
|
|
47
|
+
function event(
|
|
48
|
+
context: ExecutionContext,
|
|
49
|
+
coordination: "local" | "distributed",
|
|
50
|
+
policyName: string,
|
|
51
|
+
scope: string,
|
|
52
|
+
type:
|
|
53
|
+
| "admitted"
|
|
54
|
+
| "rejected"
|
|
55
|
+
| "waited"
|
|
56
|
+
| "released"
|
|
57
|
+
| "lease-lost"
|
|
58
|
+
| "degraded",
|
|
59
|
+
occupancy?: number,
|
|
60
|
+
reason?: string,
|
|
61
|
+
) {
|
|
62
|
+
emitRuntimeEvent(context, {
|
|
63
|
+
type: `bulkhead.${type}`,
|
|
64
|
+
coordination,
|
|
65
|
+
policyName,
|
|
66
|
+
scope,
|
|
67
|
+
...(occupancy === undefined ? {} : { occupancy }),
|
|
68
|
+
...(reason === undefined ? {} : { reason }),
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
function local(options: LocalBulkheadOptions): Policy & {
|
|
72
|
+
readonly coordination: "local"
|
|
73
|
+
snapshot(): { coordination: "local"; occupancy: number; waiting: number }
|
|
74
|
+
} {
|
|
75
|
+
const { name, limit } = options
|
|
76
|
+
const queue = options.queue && { ...options.queue }
|
|
77
|
+
validate(name, limit)
|
|
78
|
+
if (
|
|
79
|
+
queue &&
|
|
80
|
+
(!Number.isSafeInteger(queue.limit) ||
|
|
81
|
+
queue.limit < 1 ||
|
|
82
|
+
!Number.isSafeInteger(queue.timeoutMs) ||
|
|
83
|
+
queue.timeoutMs < 1 ||
|
|
84
|
+
queue.timeoutMs > 2147483647)
|
|
85
|
+
)
|
|
86
|
+
throw new RangeError("Invalid bounded queue")
|
|
87
|
+
let occupancy = 0
|
|
88
|
+
const waiting: (() => void)[] = []
|
|
89
|
+
return Object.freeze({
|
|
90
|
+
name,
|
|
91
|
+
phase: "attempt" as const,
|
|
92
|
+
coordination: "local" as const,
|
|
93
|
+
snapshot: () => ({
|
|
94
|
+
coordination: "local" as const,
|
|
95
|
+
occupancy,
|
|
96
|
+
waiting: waiting.length,
|
|
97
|
+
}),
|
|
98
|
+
async execute<Result>(
|
|
99
|
+
context: ExecutionContext,
|
|
100
|
+
next: Next<Result>,
|
|
101
|
+
): Promise<Result> {
|
|
102
|
+
const signal = admissionSignal(context)
|
|
103
|
+
signal?.throwIfAborted()
|
|
104
|
+
const reject = (reason: string) => {
|
|
105
|
+
event(context, "local", name, "process", "rejected", occupancy, reason)
|
|
106
|
+
return new BulkheadRejectedError("local", name, "process", reason)
|
|
107
|
+
}
|
|
108
|
+
if (occupancy >= limit) {
|
|
109
|
+
if (!queue || waiting.length >= queue.limit) throw reject("capacity")
|
|
110
|
+
event(context, "local", name, "process", "waited", occupancy)
|
|
111
|
+
await new Promise<void>((resolve, fail) => {
|
|
112
|
+
const cleanup = () => {
|
|
113
|
+
clearTimeout(timer)
|
|
114
|
+
signal?.removeEventListener("abort", abort)
|
|
115
|
+
const i = waiting.indexOf(grant)
|
|
116
|
+
if (i >= 0) waiting.splice(i, 1)
|
|
117
|
+
}
|
|
118
|
+
const grant = () => {
|
|
119
|
+
cleanup()
|
|
120
|
+
occupancy++
|
|
121
|
+
resolve()
|
|
122
|
+
}
|
|
123
|
+
const abort = () => {
|
|
124
|
+
cleanup()
|
|
125
|
+
event(
|
|
126
|
+
context,
|
|
127
|
+
"local",
|
|
128
|
+
name,
|
|
129
|
+
"process",
|
|
130
|
+
"rejected",
|
|
131
|
+
occupancy,
|
|
132
|
+
"cancelled",
|
|
133
|
+
)
|
|
134
|
+
fail(signal?.reason)
|
|
135
|
+
}
|
|
136
|
+
const timer = setTimeout(() => {
|
|
137
|
+
cleanup()
|
|
138
|
+
fail(reject("wait-timeout"))
|
|
139
|
+
}, queue.timeoutMs)
|
|
140
|
+
waiting.push(grant)
|
|
141
|
+
signal?.addEventListener("abort", abort, { once: true })
|
|
142
|
+
if (signal?.aborted) abort()
|
|
143
|
+
})
|
|
144
|
+
} else occupancy++
|
|
145
|
+
event(context, "local", name, "process", "admitted", occupancy)
|
|
146
|
+
try {
|
|
147
|
+
signal?.throwIfAborted()
|
|
148
|
+
return await next(context)
|
|
149
|
+
} finally {
|
|
150
|
+
occupancy--
|
|
151
|
+
waiting[0]?.()
|
|
152
|
+
event(context, "local", name, "process", "released", occupancy)
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
function distributed(
|
|
158
|
+
options: DistributedBulkheadOptions,
|
|
159
|
+
): Policy & { readonly coordination: "distributed" } {
|
|
160
|
+
const {
|
|
161
|
+
name,
|
|
162
|
+
limit,
|
|
163
|
+
coordinator,
|
|
164
|
+
scope: resolveScope,
|
|
165
|
+
leaseMs = 30000,
|
|
166
|
+
} = options
|
|
167
|
+
validate(name, limit)
|
|
168
|
+
if (!Number.isSafeInteger(leaseMs) || leaseMs < 100 || leaseMs > 86400000)
|
|
169
|
+
throw new RangeError("leaseMs must be 100..86400000")
|
|
170
|
+
return Object.freeze({
|
|
171
|
+
name,
|
|
172
|
+
phase: "attempt" as const,
|
|
173
|
+
coordination: "distributed" as const,
|
|
174
|
+
async execute<Result>(
|
|
175
|
+
context: ExecutionContext,
|
|
176
|
+
next: Next<Result>,
|
|
177
|
+
): Promise<Result> {
|
|
178
|
+
admissionSignal(context)?.throwIfAborted()
|
|
179
|
+
const scope = resolveScope(context)
|
|
180
|
+
if (typeof scope !== "string" || !scope.trim())
|
|
181
|
+
throw new TypeError("Invalid bulkhead scope")
|
|
182
|
+
const identity = { name, operation: context.operationName, scope }
|
|
183
|
+
const token = randomUUID()
|
|
184
|
+
const command = (action: "acquire" | "renew" | "release") =>
|
|
185
|
+
coordinator.command(identity, action, token, leaseMs, limit)
|
|
186
|
+
let admitted: { allowed: boolean; occupancy: number }
|
|
187
|
+
const started = performance.now()
|
|
188
|
+
try {
|
|
189
|
+
admitted = await command("acquire")
|
|
190
|
+
} catch (error) {
|
|
191
|
+
event(
|
|
192
|
+
context,
|
|
193
|
+
"distributed",
|
|
194
|
+
name,
|
|
195
|
+
scope,
|
|
196
|
+
"degraded",
|
|
197
|
+
undefined,
|
|
198
|
+
"admission-unknown",
|
|
199
|
+
)
|
|
200
|
+
event(
|
|
201
|
+
context,
|
|
202
|
+
"distributed",
|
|
203
|
+
name,
|
|
204
|
+
scope,
|
|
205
|
+
"rejected",
|
|
206
|
+
undefined,
|
|
207
|
+
"coordinator-unavailable",
|
|
208
|
+
)
|
|
209
|
+
throw error
|
|
210
|
+
}
|
|
211
|
+
if (!admitted.allowed) {
|
|
212
|
+
event(
|
|
213
|
+
context,
|
|
214
|
+
"distributed",
|
|
215
|
+
name,
|
|
216
|
+
scope,
|
|
217
|
+
"rejected",
|
|
218
|
+
admitted.occupancy,
|
|
219
|
+
"capacity",
|
|
220
|
+
)
|
|
221
|
+
throw new BulkheadRejectedError("distributed", name, scope, "capacity")
|
|
222
|
+
}
|
|
223
|
+
let stopped = false
|
|
224
|
+
let lost = false
|
|
225
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
226
|
+
let deadlineTimer: ReturnType<typeof setTimeout> | undefined
|
|
227
|
+
let deadline = started + leaseMs
|
|
228
|
+
const controller = new AbortController()
|
|
229
|
+
const lose = () => {
|
|
230
|
+
if (lost || stopped) return
|
|
231
|
+
lost = true
|
|
232
|
+
clearTimeout(timer)
|
|
233
|
+
event(context, "distributed", name, scope, "lease-lost")
|
|
234
|
+
event(
|
|
235
|
+
context,
|
|
236
|
+
"distributed",
|
|
237
|
+
name,
|
|
238
|
+
scope,
|
|
239
|
+
"degraded",
|
|
240
|
+
undefined,
|
|
241
|
+
"lease-uncertain",
|
|
242
|
+
)
|
|
243
|
+
controller.abort(
|
|
244
|
+
new BulkheadRejectedError("distributed", name, scope, "lease-lost"),
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
const watch = () => {
|
|
248
|
+
clearTimeout(deadlineTimer)
|
|
249
|
+
deadlineTimer = setTimeout(
|
|
250
|
+
lose,
|
|
251
|
+
Math.max(0, deadline - performance.now()),
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
const renew = async () => {
|
|
255
|
+
const sent = performance.now()
|
|
256
|
+
try {
|
|
257
|
+
if (
|
|
258
|
+
!(await command("renew")).allowed ||
|
|
259
|
+
performance.now() >= deadline
|
|
260
|
+
) {
|
|
261
|
+
lose()
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
deadline = sent + leaseMs
|
|
265
|
+
} catch {
|
|
266
|
+
lose()
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
if (!stopped && !lost) {
|
|
270
|
+
watch()
|
|
271
|
+
timer = setTimeout(() => void renew(), leaseMs / 3)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
if (performance.now() >= deadline)
|
|
276
|
+
throw new BulkheadRejectedError(
|
|
277
|
+
"distributed",
|
|
278
|
+
name,
|
|
279
|
+
scope,
|
|
280
|
+
"admission-expired",
|
|
281
|
+
)
|
|
282
|
+
admissionSignal(context)?.throwIfAborted()
|
|
283
|
+
event(
|
|
284
|
+
context,
|
|
285
|
+
"distributed",
|
|
286
|
+
name,
|
|
287
|
+
scope,
|
|
288
|
+
"admitted",
|
|
289
|
+
admitted.occupancy,
|
|
290
|
+
)
|
|
291
|
+
watch()
|
|
292
|
+
timer = setTimeout(() => void renew(), leaseMs / 3)
|
|
293
|
+
return await next(
|
|
294
|
+
withAdmissionSignal(
|
|
295
|
+
context.capabilities.abort === "supported"
|
|
296
|
+
? withSignal(
|
|
297
|
+
context,
|
|
298
|
+
context.signal
|
|
299
|
+
? AbortSignal.any([context.signal, controller.signal])
|
|
300
|
+
: controller.signal,
|
|
301
|
+
)
|
|
302
|
+
: context,
|
|
303
|
+
controller.signal,
|
|
304
|
+
),
|
|
305
|
+
)
|
|
306
|
+
} finally {
|
|
307
|
+
stopped = true
|
|
308
|
+
clearTimeout(timer)
|
|
309
|
+
clearTimeout(deadlineTimer)
|
|
310
|
+
try {
|
|
311
|
+
const result = await command("release")
|
|
312
|
+
event(
|
|
313
|
+
context,
|
|
314
|
+
"distributed",
|
|
315
|
+
name,
|
|
316
|
+
scope,
|
|
317
|
+
"released",
|
|
318
|
+
result.occupancy,
|
|
319
|
+
result.allowed ? undefined : "already-expired-or-released",
|
|
320
|
+
)
|
|
321
|
+
} catch {
|
|
322
|
+
event(
|
|
323
|
+
context,
|
|
324
|
+
"distributed",
|
|
325
|
+
name,
|
|
326
|
+
scope,
|
|
327
|
+
"degraded",
|
|
328
|
+
undefined,
|
|
329
|
+
"release-unknown",
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
export const bulkhead = Object.freeze({ local, distributed })
|