@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,32 @@
1
+ import { createHash } from "node:crypto"
2
+
3
+ /**
4
+ * Build a namespaced Redis key for a policy-scoped coordination slot.
5
+ *
6
+ * The SHA-256 hash of `[namespace, policy, operation, scope]` forms the
7
+ * stable identity; `suffix` distinguishes multiple keys that share the
8
+ * same identity (e.g. `:leases`, `:breaker`, `:observations`, `:probes`).
9
+ * Default suffix is `"leases"` for backward compatibility with the bulkhead.
10
+ */
11
+ export function coordinationKey(
12
+ namespace: string,
13
+ policy: string,
14
+ operation: string,
15
+ scope: string,
16
+ suffix = "leases",
17
+ ): string {
18
+ for (const value of [namespace, policy, operation, scope, suffix]) {
19
+ if (
20
+ typeof value !== "string" ||
21
+ !value.trim() ||
22
+ Buffer.byteLength(value) > 1024
23
+ )
24
+ throw new TypeError(
25
+ "Coordination identities must be nonempty strings of at most 1024 UTF-8 bytes",
26
+ )
27
+ }
28
+ const identity = createHash("sha256")
29
+ .update(JSON.stringify([namespace, policy, operation, scope]))
30
+ .digest("hex")
31
+ return `caracal:v1:{${identity}}:${suffix}`
32
+ }
@@ -0,0 +1,44 @@
1
+ import { CoordinatorUnavailableError } from "./client.js"
2
+ import { evalScript, type ScriptClient } from "./eval-script.js"
3
+ import { leaseV1 } from "./scripts.js"
4
+ export interface RedisScriptClient extends ScriptClient {
5
+ hmget(key: string, ...fields: string[]): Promise<(string | null)[]>
6
+ }
7
+ /** Internal policy-specific capability, not a public distributed lock API. */
8
+ export async function leaseCommand(
9
+ client: RedisScriptClient,
10
+ key: string,
11
+ action: "acquire" | "renew" | "release",
12
+ token: string,
13
+ ttl: number,
14
+ limit: number,
15
+ ): Promise<boolean> {
16
+ if (
17
+ !["acquire", "renew", "release"].includes(action) ||
18
+ !token ||
19
+ token.length > 256
20
+ )
21
+ throw new TypeError("Invalid lease action or token")
22
+ if (
23
+ ![ttl, limit].every((value) => Number.isSafeInteger(value) && value > 0) ||
24
+ ttl > 86400000
25
+ )
26
+ throw new RangeError("Invalid lease TTL or limit")
27
+ try {
28
+ const result = await evalScript(
29
+ client,
30
+ leaseV1,
31
+ 1,
32
+ key,
33
+ action,
34
+ token,
35
+ ttl,
36
+ limit,
37
+ )
38
+ if (result !== 0 && result !== 1)
39
+ throw new Error("Invalid Redis script reply")
40
+ return result === 1
41
+ } catch (error) {
42
+ throw new CoordinatorUnavailableError(error)
43
+ }
44
+ }
@@ -0,0 +1,314 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Versioned atomic operations, internal to the Redis policy capabilities.
3
+ // ---------------------------------------------------------------------------
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Bulkhead scripts
7
+ // ---------------------------------------------------------------------------
8
+
9
+ export const leaseV1 = `
10
+ local action = ARGV[1]
11
+ local token = ARGV[2]
12
+ local ttl = tonumber(ARGV[3])
13
+ local limit = tonumber(ARGV[4])
14
+ local time = redis.call('TIME')
15
+ local now = time[1] * 1000 + math.floor(time[2] / 1000)
16
+ redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now)
17
+ local existing = redis.call('ZSCORE', KEYS[1], token)
18
+ if action == 'release' then
19
+ return redis.call('ZREM', KEYS[1], token)
20
+ end
21
+ if action == 'renew' and not existing then return 0 end
22
+ if action == 'acquire' and existing then return 1 end
23
+ if action == 'acquire' and redis.call('ZCARD', KEYS[1]) >= limit then return 0 end
24
+ redis.call('ZADD', KEYS[1], now + ttl, token)
25
+ local latest = redis.call('ZREVRANGE', KEYS[1], 0, 0, 'WITHSCORES')
26
+ redis.call('PEXPIREAT', KEYS[1], math.ceil(tonumber(latest[2])))
27
+ return 1
28
+ `
29
+
30
+ /** Returns the decision and occupancy from the same atomic transition. */
31
+ export const bulkheadLeaseV1 = `local function transition()\n${leaseV1}\nend\nlocal allowed = transition()\nreturn {allowed, redis.call('ZCARD', KEYS[1])}`
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Circuit-breaker scripts
35
+ //
36
+ // Return arrays use fixed positions so the TypeScript caller can validate
37
+ // and decode without field names (which Lua/Redis cannot return).
38
+ //
39
+ // stateCode encoding: 0=closed 1=open 2=half-open
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * Record one attempt outcome into the distributed sliding window.
44
+ * Atomically opens the breaker when the failure ratio meets the threshold.
45
+ *
46
+ * KEYS[1] = breaker state hash (:breaker)
47
+ * KEYS[2] = observations sorted set (:observations)
48
+ *
49
+ * ARGV[1] = outcome "success" | "failure"
50
+ * ARGV[2] = expectedGeneration integer
51
+ * ARGV[3] = windowTtlMs observation retention window in ms
52
+ * ARGV[4] = minimumThroughput min observations before opening
53
+ * ARGV[5] = failureThresholdNum failure threshold × 1000 (e.g. 500 = 0.5)
54
+ * ARGV[6] = windowSize max observations retained by count
55
+ * ARGV[7] = openMs how long to stay OPEN; closed hash TTL = openMs×2
56
+ * ARGV[8] = uuid unique string for member deduplication
57
+ *
58
+ * Returns: {status, stateCode, generation, windowTotal, windowFailures}
59
+ * status 0 = stale (dropped; generation or state mismatch)
60
+ * status 1 = observed, no transition (remained closed)
61
+ * status 2 = observed, breaker opened (stateCode=1, generation incremented)
62
+ *
63
+ * TTL policy:
64
+ * OPEN state → PERSIST (no expiry). A missing key is treated as closed by
65
+ * all scripts, so expiring an open breaker would silently admit
66
+ * unrestricted traffic and reset the generation counter.
67
+ * CLOSED state → max(openMs×2, windowTtlMs) TTL for eventual cleanup of idle
68
+ * scopes. Expiring a closed key is safe *only* because the TTL
69
+ * outlives the observation window: the window is scoped by an
70
+ * epoch, and an epoch that outlives its members is what keeps
71
+ * cleanup from resurrecting them.
72
+ *
73
+ * Epochs:
74
+ * `generation` increments on every transition, and a brand new value is minted
75
+ * whenever the state hash has to be recreated while observation members from a
76
+ * previous epoch are still present (state lost to eviction or admin cleanup).
77
+ * Window membership is decided by comparing the stored epoch, so members from
78
+ * a superseded epoch can never be counted again, and an attempt holding a
79
+ * pre-loss generation can never pass the staleness check. A scope that has
80
+ * never been observed keeps generation 0.
81
+ */
82
+ export const breakerObserveV1 = `
83
+ local outcome = ARGV[1]
84
+ local expectGen = tonumber(ARGV[2])
85
+ local windowTtl = tonumber(ARGV[3])
86
+ local minTP = tonumber(ARGV[4])
87
+ local threshNum = tonumber(ARGV[5])
88
+ local windowSize = tonumber(ARGV[6])
89
+ local openMs = tonumber(ARGV[7])
90
+ local uuid = ARGV[8]
91
+ local t = redis.call('TIME')
92
+ local now = t[1] * 1000 + math.floor(t[2] / 1000)
93
+ local f = redis.call('HMGET', KEYS[1], 'state', 'generation')
94
+ local state = f[1] or 'closed'
95
+ local gen = tonumber(f[2]) or 0
96
+ if state ~= 'closed' then
97
+ local sc = (state == 'open' and 1) or (state == 'half-open' and 2) or 0
98
+ return {0, sc, gen, 0, 0}
99
+ end
100
+ if redis.call('EXISTS', KEYS[1]) == 0 then
101
+ -- No live state hash: a scope we have never seen, or a hash that was lost
102
+ -- while its window survived.
103
+ if expectGen ~= 0 then
104
+ -- The caller holds a generation this key cannot confirm.
105
+ return {0, 0, 0, 0, 0}
106
+ end
107
+ if redis.call('EXISTS', KEYS[2]) == 1 then
108
+ -- Members from a superseded epoch are still here. Mint an epoch that has
109
+ -- never been used for this key (derived from the observation's own uuid, so
110
+ -- no clock is involved) instead of restarting at a value those members
111
+ -- would match.
112
+ --
113
+ -- Bounded to 11 hex digits (44 bits, <= 14 decimal digits) so the value
114
+ -- prints exactly with stock Lua number formatting: the epoch is read back
115
+ -- from the hash as a decimal string by both this script and the client, and
116
+ -- a larger value could be written in scientific notation and break the
117
+ -- round-trip.
118
+ gen = tonumber(string.sub(redis.sha1hex(uuid), 1, 11), 16)
119
+ if not gen or gen == 0 then gen = 1 end
120
+ else
121
+ gen = 0
122
+ end
123
+ redis.call('HSET', KEYS[1], 'state', 'closed', 'generation', gen,
124
+ 'openedAt', 0, 'probeCount', 0, 'probeSuccesses', 0)
125
+ elseif gen ~= expectGen then
126
+ return {0, 0, gen, 0, 0}
127
+ end
128
+ redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', now - windowTtl)
129
+ local member = tostring(gen) .. ':' .. uuid .. ':' .. outcome
130
+ redis.call('ZADD', KEYS[2], 'NX', now, member)
131
+ local cnt = redis.call('ZCARD', KEYS[2])
132
+ if cnt > windowSize then
133
+ redis.call('ZREMRANGEBYRANK', KEYS[2], 0, cnt - windowSize - 1)
134
+ end
135
+ redis.call('PEXPIREAT', KEYS[2], now + windowTtl)
136
+ local all = redis.call('ZRANGE', KEYS[2], 0, -1)
137
+ local prefix = tostring(gen) .. ':'
138
+ local wTotal = 0
139
+ local wFail = 0
140
+ for _, m in ipairs(all) do
141
+ if string.sub(m, 1, #prefix) == prefix then
142
+ wTotal = wTotal + 1
143
+ if string.sub(m, -8) == ':failure' then wFail = wFail + 1 end
144
+ end
145
+ end
146
+ if wTotal >= minTP and wFail * 1000 >= threshNum * wTotal then
147
+ gen = gen + 1
148
+ redis.call('HSET', KEYS[1], 'state', 'open', 'generation', gen,
149
+ 'openedAt', now, 'probeCount', 0, 'probeSuccesses', 0)
150
+ -- OPEN state must never expire: a missing key is treated as closed,
151
+ -- which would silently admit unrestricted traffic and reset the generation.
152
+ redis.call('PERSIST', KEYS[1])
153
+ return {2, 1, gen, wTotal, wFail}
154
+ end
155
+ if redis.call('EXISTS', KEYS[1]) == 1 then
156
+ -- Still CLOSED. The cleanup TTL must not expire the state before the window
157
+ -- it governs, otherwise retained members would survive their epoch.
158
+ redis.call('PEXPIRE', KEYS[1], math.max(openMs * 2, windowTtl))
159
+ end
160
+ return {1, 0, gen, wTotal, wFail}
161
+ `
162
+
163
+ /**
164
+ * Admit a probe attempt. Transitions OPEN→HALF_OPEN when openMs has elapsed,
165
+ * then atomically allocates a probe token slot.
166
+ *
167
+ * KEYS[1] = breaker state hash (:breaker)
168
+ * KEYS[2] = probe tokens sorted set (:probes)
169
+ *
170
+ * ARGV[1] = probeToken unique UUID for this probe attempt
171
+ * ARGV[2] = openMs determines OPEN→HALF_OPEN transition and TTL
172
+ * ARGV[3] = halfOpenProbes maximum concurrent probe tokens
173
+ * ARGV[4] = probeLeaseTtlMs probe token TTL in ms
174
+ *
175
+ * Returns: {status, stateCode, generation, probeCount, transitioned}
176
+ * status 0 = rejected
177
+ * stateCode 0 = breaker already CLOSED (no probe needed)
178
+ * stateCode 1 = still OPEN (openMs not yet elapsed)
179
+ * stateCode 2 = HALF_OPEN but probe limit reached
180
+ * status 1 = admitted (probe token stored)
181
+ * transitioned 1 = OPEN→HALF_OPEN transition happened in this call
182
+ * transitioned 0 = was already HALF_OPEN
183
+ */
184
+ export const breakerAdmitProbeV1 = `
185
+ local probeToken = ARGV[1]
186
+ local openMs = tonumber(ARGV[2])
187
+ local maxProbes = tonumber(ARGV[3])
188
+ local probeLeaseTtl = tonumber(ARGV[4])
189
+ local t = redis.call('TIME')
190
+ local now = t[1] * 1000 + math.floor(t[2] / 1000)
191
+ local f = redis.call('HMGET', KEYS[1], 'state', 'generation', 'openedAt',
192
+ 'probeCount', 'probeSuccesses')
193
+ local state = f[1] or 'closed'
194
+ local gen = tonumber(f[2]) or 0
195
+ local openedAt = tonumber(f[3]) or 0
196
+ local transitioned = 0
197
+ if state == 'closed' then
198
+ return {0, 0, gen, 0, 0}
199
+ end
200
+ if state == 'open' then
201
+ if now - openedAt < openMs then
202
+ return {0, 1, gen, 0, 0}
203
+ end
204
+ state = 'half-open'
205
+ transitioned = 1
206
+ redis.call('HSET', KEYS[1], 'state', 'half-open', 'probeSuccesses', 0, 'probeCount', 0)
207
+ -- OPEN/HALF_OPEN must never expire; PERSIST removes any prior cleanup TTL.
208
+ redis.call('PERSIST', KEYS[1])
209
+ -- Tokens from the superseded recovery window must not consume slots in this
210
+ -- one; their settle is dropped by the generation check anyway.
211
+ redis.call('DEL', KEYS[2])
212
+ end
213
+ redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', now)
214
+ local activeProbes = redis.call('ZCARD', KEYS[2])
215
+ if activeProbes >= maxProbes then
216
+ return {0, 2, gen, activeProbes, transitioned}
217
+ end
218
+ redis.call('ZADD', KEYS[2], 'NX', now + probeLeaseTtl, probeToken)
219
+ activeProbes = activeProbes + 1
220
+ redis.call('HSET', KEYS[1], 'probeCount', activeProbes)
221
+ -- State hash must not expire while recovery probes are in flight.
222
+ redis.call('PERSIST', KEYS[1])
223
+ -- Probes sorted set expires naturally once the last lease elapses.
224
+ redis.call('PEXPIREAT', KEYS[2], now + probeLeaseTtl + 1000)
225
+ return {1, 2, gen, activeProbes, transitioned}
226
+ `
227
+
228
+ /**
229
+ * Record a probe attempt outcome. Removes the probe token and applies the
230
+ * result: failure re-opens; sufficient successes close the breaker.
231
+ *
232
+ * KEYS[1] = breaker state hash (:breaker)
233
+ * KEYS[2] = probe tokens sorted set (:probes)
234
+ *
235
+ * ARGV[1] = probeToken the token issued by admitProbe
236
+ * ARGV[2] = outcome "success" | "failure"
237
+ * ARGV[3] = expectedGeneration generation captured at admission time
238
+ * ARGV[4] = halfOpenSuccesses consecutive probe successes needed to close
239
+ * ARGV[5] = openMs how long the breaker stays OPEN (TTL floor)
240
+ * ARGV[6] = windowTtlMs observation retention (TTL floor): the CLOSED
241
+ * hash must outlive the window it governs
242
+ *
243
+ * Returns: {status, stateCode, generation}
244
+ * status 0 = stale (token missing, lease elapsed, or generation mismatch;
245
+ * the result is dropped and any consumed token is released)
246
+ * status 1 = settled, no state transition (still HALF_OPEN)
247
+ * status 2 = settled, state transition occurred
248
+ * stateCode 0 = transitioned to CLOSED
249
+ * stateCode 1 = transitioned back to OPEN (probe failure)
250
+ *
251
+ * A token carries its own deadline as its sorted-set score. When the lease has
252
+ * elapsed the slot is already recoverable - admitProbe prunes expired tokens
253
+ * and re-issues the slot - so the result must not count even while the member is
254
+ * still present. The deadline is therefore checked under the same atomic call.
255
+ */
256
+ export const breakerSettleProbeV1 = `
257
+ local probeToken = ARGV[1]
258
+ local outcome = ARGV[2]
259
+ local expectGen = tonumber(ARGV[3])
260
+ local halfOpenSucc = tonumber(ARGV[4])
261
+ local openMs = tonumber(ARGV[5])
262
+ local windowTtl = tonumber(ARGV[6])
263
+ local t = redis.call('TIME')
264
+ local now = t[1] * 1000 + math.floor(t[2] / 1000)
265
+ local f = redis.call('HMGET', KEYS[1], 'state', 'generation', 'probeSuccesses', 'probeCount')
266
+ local state = f[1] or 'closed'
267
+ local gen = tonumber(f[2]) or 0
268
+ local probeSucc = tonumber(f[3]) or 0
269
+ local probeCnt = tonumber(f[4]) or 0
270
+ local sc = (state == 'open' and 1) or (state == 'half-open' and 2) or 0
271
+ local deadline = redis.call('ZSCORE', KEYS[2], probeToken)
272
+ if not deadline then
273
+ -- Never admitted, already settled, or dropped with its recovery window.
274
+ return {0, sc, gen}
275
+ end
276
+ redis.call('ZREM', KEYS[2], probeToken)
277
+ if tonumber(deadline) <= now then
278
+ -- The lease elapsed while the probe was still running: its slot was
279
+ -- recoverable, so the result cannot be counted. Release the slot it still
280
+ -- occupied so the probe count keeps matching the token set.
281
+ probeCnt = math.max(0, probeCnt - 1)
282
+ redis.call('HSET', KEYS[1], 'probeCount', probeCnt)
283
+ return {0, sc, gen}
284
+ end
285
+ if state ~= 'half-open' or gen ~= expectGen then
286
+ return {0, sc, gen}
287
+ end
288
+ probeCnt = math.max(0, probeCnt - 1)
289
+ if outcome == 'failure' then
290
+ gen = gen + 1
291
+ redis.call('HSET', KEYS[1], 'state', 'open', 'generation', gen,
292
+ 'openedAt', now, 'probeCount', 0, 'probeSuccesses', 0)
293
+ -- Re-opened: OPEN must not expire (see breakerObserveV1 TTL policy).
294
+ redis.call('PERSIST', KEYS[1])
295
+ -- In-flight probes belong to the dead generation; free their slots so the
296
+ -- next recovery window starts with full capacity.
297
+ redis.call('DEL', KEYS[2])
298
+ return {2, 1, gen}
299
+ end
300
+ probeSucc = probeSucc + 1
301
+ if probeSucc >= halfOpenSucc then
302
+ gen = gen + 1
303
+ redis.call('HSET', KEYS[1], 'state', 'closed', 'generation', gen,
304
+ 'probeCount', 0, 'probeSuccesses', 0)
305
+ -- Newly CLOSED: expire for cleanup, but never before the window it governs.
306
+ redis.call('PEXPIRE', KEYS[1], math.max(openMs * 2, windowTtl))
307
+ redis.call('DEL', KEYS[2])
308
+ return {2, 0, gen}
309
+ end
310
+ redis.call('HSET', KEYS[1], 'probeSuccesses', probeSucc, 'probeCount', probeCnt)
311
+ -- Still HALF_OPEN: must not expire while recovery is in progress.
312
+ redis.call('PERSIST', KEYS[1])
313
+ return {1, 2, gen}
314
+ `