@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +21 -0
  3. package/README.md +311 -0
  4. package/dist/chunk-5CXDW7W6.js +202 -0
  5. package/dist/chunk-5CXDW7W6.js.map +1 -0
  6. package/dist/circuit-breaker-BSkcV0W_.d.ts +296 -0
  7. package/dist/fetch.d.ts +58 -0
  8. package/dist/fetch.js +117 -0
  9. package/dist/fetch.js.map +1 -0
  10. package/dist/index.d.ts +19 -0
  11. package/dist/index.js +1065 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/postgres.d.ts +28 -0
  14. package/dist/postgres.js +56 -0
  15. package/dist/postgres.js.map +1 -0
  16. package/dist/redis.d.ts +59 -0
  17. package/dist/redis.js +549 -0
  18. package/dist/redis.js.map +1 -0
  19. package/dist/retry-BFP_k3Hg.d.ts +26 -0
  20. package/dist/testing/index.d.ts +45 -0
  21. package/dist/testing/index.js +101 -0
  22. package/dist/testing/index.js.map +1 -0
  23. package/dist/types-Tf9T76C7.d.ts +187 -0
  24. package/package.json +127 -0
  25. package/src/adapters/fetch/adapter.ts +122 -0
  26. package/src/adapters/fetch/index.ts +15 -0
  27. package/src/adapters/fetch/retry-after.ts +111 -0
  28. package/src/adapters/postgres/adapter.ts +102 -0
  29. package/src/adapters/postgres/index.ts +7 -0
  30. package/src/coordination/redis/bulkhead.ts +61 -0
  31. package/src/coordination/redis/circuit-breaker.ts +270 -0
  32. package/src/coordination/redis/client.ts +78 -0
  33. package/src/coordination/redis/eval-script.ts +71 -0
  34. package/src/coordination/redis/keys.ts +32 -0
  35. package/src/coordination/redis/leases.ts +44 -0
  36. package/src/coordination/redis/scripts.ts +314 -0
  37. package/src/core/bulkhead.ts +336 -0
  38. package/src/core/circuit-breaker.ts +1066 -0
  39. package/src/core/index.ts +36 -0
  40. package/src/core/operation.ts +174 -0
  41. package/src/core/retry.ts +204 -0
  42. package/src/core/runtime.ts +123 -0
  43. package/src/core/scope-state-cache.ts +50 -0
  44. package/src/core/timeout.ts +73 -0
  45. package/src/core/types.ts +230 -0
  46. package/src/fetch.ts +17 -0
  47. package/src/index.ts +49 -0
  48. package/src/postgres.ts +9 -0
  49. 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 })