@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,111 @@
1
+ import type { RetryContext } from "../../core/retry.js"
2
+
3
+ /**
4
+ * Opt-in retry pacing for `@gkoos/caracal/fetch` that honours the HTTP
5
+ * `Retry-After` header. It is protocol-specific, so it lives with the
6
+ * adapter rather than in the protocol-agnostic core.
7
+ */
8
+
9
+ const DEFAULTS = {
10
+ baseMs: 100,
11
+ factor: 2,
12
+ maxDelayMs: 30_000,
13
+ jitterRatio: 0.1,
14
+ } as const
15
+
16
+ const deltaSecondsPattern = /^\d+$/
17
+
18
+ /**
19
+ * Structurally extracts a `Headers`-like object. `instanceof Response` is
20
+ * unreliable across realms and custom fetch implementations.
21
+ */
22
+ function headersOf(value: unknown): Headers | undefined {
23
+ if (typeof value !== "object" || value === null) return undefined
24
+ const headers = (value as { headers?: unknown }).headers
25
+ if (typeof headers !== "object" || headers === null) return undefined
26
+ const get = (headers as { get?: unknown }).get
27
+ return typeof get === "function" ? (headers as Headers) : undefined
28
+ }
29
+
30
+ /**
31
+ * Parses `Retry-After` (delta-seconds or HTTP-date) from a settled fetch
32
+ * outcome. Reads the response from `context.result`, or from
33
+ * `context.error` when a custom fetch implementation throws a
34
+ * response-bearing error.
35
+ *
36
+ * Returns `undefined` when no usable header is present. Malformed values
37
+ * are ignored rather than thrown.
38
+ */
39
+ export function retryAfterMs(
40
+ context: RetryContext,
41
+ now: number = Date.now(),
42
+ ): number | undefined {
43
+ const headers = headersOf(context.result) ?? headersOf(context.error)
44
+ if (headers === undefined) return undefined
45
+
46
+ const value = headers.get("retry-after")?.trim()
47
+ if (!value) return undefined
48
+
49
+ if (deltaSecondsPattern.test(value)) {
50
+ const seconds = Number(value)
51
+ return Number.isSafeInteger(seconds) ? seconds * 1000 : undefined
52
+ }
53
+
54
+ const timestamp = Date.parse(value)
55
+ return Number.isNaN(timestamp) ? undefined : Math.max(0, timestamp - now)
56
+ }
57
+
58
+ /** Options for `createRetryAfterDelay`. */
59
+ export interface RetryAfterDelayOptions {
60
+ /** Base of the exponential backoff in ms. Default: 100. */
61
+ readonly baseMs?: number
62
+ /** Exponential growth factor (>= 1). Default: 2. */
63
+ readonly factor?: number
64
+ /** Ceiling applied to the deterministic wait in ms. Default: 30 000. */
65
+ readonly maxDelayMs?: number
66
+ /** Additive jitter as a fraction of the wait, within [0, 1]. Default: 0.1. */
67
+ readonly jitterRatio?: number
68
+ }
69
+
70
+ /** A `RetryDelay` that reads `Retry-After` from the settled outcome. */
71
+ export type RetryAfterDelay = (attempt: number, context: RetryContext) => number
72
+
73
+ /**
74
+ * Builds a `RetryDelay` that waits for the longer of exponential backoff
75
+ * and the `Retry-After` the server sent, then adds additive jitter.
76
+ *
77
+ * Jitter only ever lengthens the wait, so a server-provided minimum is
78
+ * never retried early.
79
+ */
80
+ export function createRetryAfterDelay(
81
+ options: RetryAfterDelayOptions = {},
82
+ ): RetryAfterDelay {
83
+ const baseMs = options.baseMs ?? DEFAULTS.baseMs
84
+ const factor = options.factor ?? DEFAULTS.factor
85
+ const maxDelayMs = options.maxDelayMs ?? DEFAULTS.maxDelayMs
86
+ const jitterRatio = options.jitterRatio ?? DEFAULTS.jitterRatio
87
+
88
+ if (!Number.isFinite(baseMs) || baseMs < 0)
89
+ throw new RangeError("retryAfterDelay baseMs must be finite and >= 0")
90
+ if (!Number.isFinite(factor) || factor < 1)
91
+ throw new RangeError("retryAfterDelay factor must be finite and >= 1")
92
+ if (!Number.isFinite(maxDelayMs) || maxDelayMs < 0)
93
+ throw new RangeError("retryAfterDelay maxDelayMs must be finite and >= 0")
94
+ if (!Number.isFinite(jitterRatio) || jitterRatio < 0 || jitterRatio > 1)
95
+ throw new RangeError("retryAfterDelay jitterRatio must be within [0, 1]")
96
+
97
+ return (attempt, context) => {
98
+ const backoff = Math.min(baseMs * factor ** (attempt - 1), maxDelayMs)
99
+ const base = Math.min(
100
+ Math.max(backoff, retryAfterMs(context) ?? 0),
101
+ maxDelayMs,
102
+ )
103
+ return base + Math.random() * base * jitterRatio
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Ready-to-use default: `retry({ maxAttempts: 3, delay: retryAfterDelay })`.
109
+ * Use `createRetryAfterDelay()` to change the pacing.
110
+ */
111
+ export const retryAfterDelay: RetryAfterDelay = createRetryAfterDelay()
@@ -0,0 +1,102 @@
1
+ import type { QueryResult } from "pg"
2
+
3
+ import type {
4
+ Adapter,
5
+ Classification,
6
+ OperationCapabilities,
7
+ Outcome,
8
+ } from "../../core/types.js"
9
+
10
+ export type PostgresReplay =
11
+ | OperationCapabilities["replay"]
12
+ | ((args: PostgresQueryArgs) => OperationCapabilities["replay"])
13
+
14
+ export type PostgresQueryArgs = Readonly<{
15
+ sql: string
16
+ values?: readonly unknown[]
17
+ /** Application-declared per-query replay trait; never inferred from SQL. */
18
+ replay?: OperationCapabilities["replay"]
19
+ }>
20
+
21
+ export interface PostgresQueryable {
22
+ query(query: {
23
+ text: string
24
+ values?: readonly unknown[]
25
+ }): Promise<QueryResult>
26
+ }
27
+
28
+ export interface PostgresAdapterOptions {
29
+ readonly replay?: PostgresReplay
30
+ readonly classifyError?: (error: unknown) => Classification
31
+ }
32
+
33
+ const retryableSqlStates = new Set([
34
+ "40001", // serialization_failure
35
+ "40P01", // deadlock_detected
36
+ "55P03", // lock_not_available
37
+ "53300", // too_many_connections
38
+ "57P01", // admin_shutdown
39
+ "57P02", // crash_shutdown
40
+ "57P03", // cannot_connect_now
41
+ ])
42
+
43
+ function sqlState(error: unknown): string | undefined {
44
+ if (typeof error !== "object" || error === null || !("code" in error)) {
45
+ return undefined
46
+ }
47
+
48
+ return typeof error.code === "string" ? error.code : undefined
49
+ }
50
+
51
+ function defaultErrorClassification(error: unknown): Classification {
52
+ const state = sqlState(error)
53
+ return state?.startsWith("08") === true ||
54
+ (state !== undefined && retryableSqlStates.has(state))
55
+ ? "retryable"
56
+ : "failure"
57
+ }
58
+
59
+ function replayFor(
60
+ args: PostgresQueryArgs,
61
+ configured: PostgresReplay | undefined,
62
+ ): OperationCapabilities["replay"] {
63
+ if (args.replay !== undefined) {
64
+ return args.replay
65
+ }
66
+
67
+ return typeof configured === "function"
68
+ ? configured(args)
69
+ : (configured ?? "unknown")
70
+ }
71
+
72
+ /**
73
+ * Adapter for node-postgres (`pg`) 8.x query clients/pools. The standard
74
+ * `query()` contract does not provide a portable AbortSignal cancellation path,
75
+ * so this adapter accurately declares abort as unsupported.
76
+ */
77
+ export function postgresAdapter(
78
+ client: PostgresQueryable,
79
+ options: PostgresAdapterOptions = {},
80
+ ): Adapter<PostgresQueryArgs, QueryResult> {
81
+ return Object.freeze({
82
+ capabilities(args: PostgresQueryArgs): OperationCapabilities {
83
+ return { abort: "unsupported", replay: replayFor(args, options.replay) }
84
+ },
85
+ execute(args: PostgresQueryArgs): Promise<QueryResult> {
86
+ return client.query({
87
+ text: args.sql,
88
+ values: args.values === undefined ? undefined : [...args.values],
89
+ })
90
+ },
91
+ classify(outcome: Outcome<QueryResult>): Classification {
92
+ if (outcome.status === "success") {
93
+ return "success"
94
+ }
95
+
96
+ return (
97
+ options.classifyError?.(outcome.error) ??
98
+ defaultErrorClassification(outcome.error)
99
+ )
100
+ },
101
+ })
102
+ }
@@ -0,0 +1,7 @@
1
+ export type {
2
+ PostgresAdapterOptions,
3
+ PostgresQueryArgs,
4
+ PostgresQueryable,
5
+ PostgresReplay,
6
+ } from "./adapter.js"
7
+ export { postgresAdapter } from "./adapter.js"
@@ -0,0 +1,61 @@
1
+ import type { BulkheadCoordinator } from "../../core/bulkhead.js"
2
+ import { CoordinatorUnavailableError } from "./client.js"
3
+ import { evalScript } from "./eval-script.js"
4
+ import { coordinationKey } from "./keys.js"
5
+ import type { RedisScriptClient } from "./leases.js"
6
+ import { bulkheadLeaseV1 } from "./scripts.js"
7
+ export function redisCoordinator(
8
+ client: RedisScriptClient,
9
+ options: { namespace: string },
10
+ ): BulkheadCoordinator {
11
+ const namespace = options.namespace
12
+ coordinationKey(namespace, "bulkhead", "validate", "validate")
13
+ const coordinator: BulkheadCoordinator = {
14
+ async command(identity, action, token, leaseMs, limit) {
15
+ if (
16
+ !["acquire", "renew", "release"].includes(action) ||
17
+ typeof token !== "string" ||
18
+ !token ||
19
+ token.length > 256
20
+ )
21
+ throw new TypeError("Invalid bulkhead lease action or token")
22
+ if (
23
+ ![leaseMs, limit].every(
24
+ (value) => Number.isSafeInteger(value) && value > 0,
25
+ ) ||
26
+ leaseMs > 86400000
27
+ )
28
+ throw new RangeError("Invalid bulkhead lease duration or limit")
29
+ const key = coordinationKey(
30
+ namespace,
31
+ `bulkhead:${identity.name}`,
32
+ identity.operation,
33
+ identity.scope,
34
+ )
35
+ try {
36
+ const result = await evalScript(
37
+ client,
38
+ bulkheadLeaseV1,
39
+ 1,
40
+ key,
41
+ action,
42
+ token,
43
+ leaseMs,
44
+ limit,
45
+ )
46
+ if (
47
+ !Array.isArray(result) ||
48
+ result.length !== 2 ||
49
+ ![0, 1].includes(result[0]) ||
50
+ !Number.isSafeInteger(result[1]) ||
51
+ result[1] < 0
52
+ )
53
+ throw new Error("Invalid bulkhead reply")
54
+ return { allowed: result[0] === 1, occupancy: result[1] as number }
55
+ } catch (error) {
56
+ throw new CoordinatorUnavailableError(error)
57
+ }
58
+ },
59
+ }
60
+ return Object.freeze(coordinator)
61
+ }
@@ -0,0 +1,270 @@
1
+ import type {
2
+ AdmitProbeResult,
3
+ BreakerCoordinator,
4
+ BreakerIdentity,
5
+ BreakerState,
6
+ ObserveResult,
7
+ SettleProbeResult,
8
+ } from "../../core/circuit-breaker.js"
9
+ import { CoordinatorUnavailableError } from "./client.js"
10
+ import { evalScript } from "./eval-script.js"
11
+ import { coordinationKey } from "./keys.js"
12
+ import type { RedisScriptClient } from "./leases.js"
13
+ import {
14
+ breakerAdmitProbeV1,
15
+ breakerObserveV1,
16
+ breakerSettleProbeV1,
17
+ } from "./scripts.js"
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Key helpers
21
+ // ---------------------------------------------------------------------------
22
+
23
+ function breakerKeys(
24
+ namespace: string,
25
+ identity: BreakerIdentity,
26
+ ): [hashKey: string, obsKey: string, probeKey: string] {
27
+ const policy = `breaker:${identity.name}`
28
+ const hash = coordinationKey(
29
+ namespace,
30
+ policy,
31
+ identity.operation,
32
+ identity.scope,
33
+ "breaker",
34
+ )
35
+ const obs = coordinationKey(
36
+ namespace,
37
+ policy,
38
+ identity.operation,
39
+ identity.scope,
40
+ "observations",
41
+ )
42
+ const prob = coordinationKey(
43
+ namespace,
44
+ policy,
45
+ identity.operation,
46
+ identity.scope,
47
+ "probes",
48
+ )
49
+ return [hash, obs, prob]
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Reply validation helpers
54
+ // ---------------------------------------------------------------------------
55
+
56
+ function assertArray(reply: unknown, minLen: number, label: string): unknown[] {
57
+ if (
58
+ !Array.isArray(reply) ||
59
+ reply.length < minLen ||
60
+ reply.some((v) => typeof v !== "number" || !Number.isSafeInteger(v))
61
+ ) {
62
+ throw new Error(
63
+ `Invalid ${label} reply from Redis: ${JSON.stringify(reply)}`,
64
+ )
65
+ }
66
+ return reply as unknown[]
67
+ }
68
+
69
+ const STATE_CODES: BreakerState[] = ["closed", "open", "half-open"]
70
+
71
+ function decodeState(code: number): BreakerState {
72
+ const s = STATE_CODES[code]
73
+ if (!s) throw new Error(`Unknown breaker state code: ${code}`)
74
+ return s
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Factory
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /**
82
+ * Policy-specific Redis coordinator for `circuitBreaker.distributed()`.
83
+ *
84
+ * The caller owns `client` and must connect/disconnect it independently.
85
+ * `namespace` is prepended to every key; use a per-service, per-environment
86
+ * value to avoid cross-deployment state collisions.
87
+ */
88
+ export function redisCircuitBreakerCoordinator(
89
+ client: RedisScriptClient,
90
+ options: { readonly namespace: string },
91
+ ): BreakerCoordinator {
92
+ const { namespace } = options
93
+ // Validate namespace eagerly (coordinationKey throws on bad input).
94
+ coordinationKey(
95
+ namespace,
96
+ "breaker:validate",
97
+ "validate",
98
+ "validate",
99
+ "breaker",
100
+ )
101
+
102
+ const coordinator: BreakerCoordinator = {
103
+ async readState(identity) {
104
+ const [hashKey] = breakerKeys(namespace, identity)
105
+ try {
106
+ const fields = await client.hmget(hashKey, "state", "generation")
107
+ const rawState = fields[0]
108
+ if (!rawState) return null
109
+ const state = rawState as BreakerState
110
+ if (!STATE_CODES.includes(state))
111
+ throw new Error(`Unknown breaker state: ${rawState}`)
112
+ const generation = parseInt(fields[1] ?? "0", 10)
113
+ return { state, generation }
114
+ } catch (error) {
115
+ throw new CoordinatorUnavailableError(error)
116
+ }
117
+ },
118
+
119
+ async observe(identity, params) {
120
+ const [hashKey, obsKey] = breakerKeys(namespace, identity)
121
+ const {
122
+ generation,
123
+ outcome,
124
+ uuid,
125
+ windowTtlMs,
126
+ minimumThroughput,
127
+ failureThresholdNumerator,
128
+ windowSize,
129
+ openMs,
130
+ } = params
131
+ try {
132
+ const reply = await evalScript(
133
+ client,
134
+ breakerObserveV1,
135
+ 2,
136
+ hashKey,
137
+ obsKey,
138
+ outcome,
139
+ generation,
140
+ windowTtlMs,
141
+ minimumThroughput,
142
+ failureThresholdNumerator,
143
+ windowSize,
144
+ openMs,
145
+ uuid,
146
+ )
147
+ const r = assertArray(reply, 5, "breakerObserveV1") as number[]
148
+ const [status, , newGen, windowTotal, windowFailures] = r
149
+ if (status === 0) {
150
+ return {
151
+ type: "stale",
152
+ currentGeneration: newGen,
153
+ } satisfies ObserveResult
154
+ }
155
+ if (status === 2) {
156
+ return {
157
+ type: "opened",
158
+ newGeneration: newGen,
159
+ windowTotal,
160
+ windowFailures,
161
+ } satisfies ObserveResult
162
+ }
163
+ return {
164
+ type: "observed",
165
+ generation: newGen,
166
+ windowTotal,
167
+ windowFailures,
168
+ } satisfies ObserveResult
169
+ } catch (error) {
170
+ throw new CoordinatorUnavailableError(error)
171
+ }
172
+ },
173
+
174
+ async admitProbe(identity, params) {
175
+ const [hashKey, , probeKey] = breakerKeys(namespace, identity)
176
+ const { probeToken, openMs, halfOpenProbes, probeLeaseTtlMs } = params
177
+ try {
178
+ const reply = await evalScript(
179
+ client,
180
+ breakerAdmitProbeV1,
181
+ 2,
182
+ hashKey,
183
+ probeKey,
184
+ probeToken,
185
+ openMs,
186
+ halfOpenProbes,
187
+ probeLeaseTtlMs,
188
+ )
189
+ const r = assertArray(reply, 5, "breakerAdmitProbeV1") as number[]
190
+ const [status, stateCode, gen, probeCount, transitioned] = r
191
+ if (status === 0) {
192
+ const reason =
193
+ stateCode === 0
194
+ ? "closed"
195
+ : stateCode === 1
196
+ ? "open"
197
+ : "probe-limit"
198
+ return {
199
+ type: "rejected",
200
+ reason,
201
+ generation: gen,
202
+ } satisfies AdmitProbeResult
203
+ }
204
+ return {
205
+ type: "admitted",
206
+ generation: gen,
207
+ probeCount,
208
+ stateChanged: transitioned === 1,
209
+ } satisfies AdmitProbeResult
210
+ } catch (error) {
211
+ throw new CoordinatorUnavailableError(error)
212
+ }
213
+ },
214
+
215
+ async settleProbe(identity, params) {
216
+ const [hashKey, , probeKey] = breakerKeys(namespace, identity)
217
+ const {
218
+ probeToken,
219
+ outcome,
220
+ generation,
221
+ halfOpenSuccesses,
222
+ openMs,
223
+ windowTtlMs,
224
+ } = params
225
+ try {
226
+ const reply = await evalScript(
227
+ client,
228
+ breakerSettleProbeV1,
229
+ 2,
230
+ hashKey,
231
+ probeKey,
232
+ probeToken,
233
+ outcome,
234
+ generation,
235
+ halfOpenSuccesses,
236
+ openMs,
237
+ // Floor for the CLOSED cleanup TTL; older callers that do not pass it
238
+ // keep the historical openMs x 2 behaviour.
239
+ windowTtlMs ?? openMs * 2,
240
+ )
241
+ const r = assertArray(reply, 3, "breakerSettleProbeV1") as number[]
242
+ const [status, stateCode, newGen] = r
243
+ if (status === 0) {
244
+ return {
245
+ type: "stale",
246
+ generation: newGen,
247
+ } satisfies SettleProbeResult
248
+ }
249
+ const state = decodeState(stateCode)
250
+ if (status === 2) {
251
+ return {
252
+ type: "transitioned",
253
+ newState: state,
254
+ newGeneration: newGen,
255
+ previousState: "half-open",
256
+ } satisfies SettleProbeResult
257
+ }
258
+ return {
259
+ type: "settled",
260
+ state,
261
+ generation: newGen,
262
+ } satisfies SettleProbeResult
263
+ } catch (error) {
264
+ throw new CoordinatorUnavailableError(error)
265
+ }
266
+ },
267
+ }
268
+
269
+ return Object.freeze(coordinator)
270
+ }
@@ -0,0 +1,78 @@
1
+ import { Cluster, Redis } from "ioredis"
2
+
3
+ export class CoordinatorUnavailableError extends Error {
4
+ readonly coordination = "distributed"
5
+ constructor(cause: unknown) {
6
+ super("Redis coordination unavailable; command outcome may be unknown", {
7
+ cause,
8
+ })
9
+ this.name = "CoordinatorUnavailableError"
10
+ }
11
+ }
12
+
13
+ function attachErrorSink(emitter: {
14
+ on(event: "error", listener: () => void): void
15
+ }): void {
16
+ emitter.on("error", () => {
17
+ /* Commands surface errors; never crash on an unhandled EventEmitter error. */
18
+ })
19
+ }
20
+
21
+ /** Standalone Redis client. Caller owns it and must disconnect at shutdown. */
22
+ export function createCoordinationClient(
23
+ url: string,
24
+ commandTimeout = 1000,
25
+ ): Redis {
26
+ if (!Number.isSafeInteger(commandTimeout) || commandTimeout < 1)
27
+ throw new RangeError("commandTimeout must be a positive integer")
28
+ const client = new Redis(url, {
29
+ lazyConnect: true,
30
+ enableOfflineQueue: false,
31
+ maxRetriesPerRequest: 0,
32
+ autoResendUnfulfilledCommands: false,
33
+ commandTimeout,
34
+ connectTimeout: commandTimeout,
35
+ retryStrategy: (attempt) => Math.min(attempt * 50, 1000),
36
+ })
37
+ attachErrorSink(client)
38
+ return client
39
+ }
40
+
41
+ export interface ClusterNode {
42
+ readonly host: string
43
+ readonly port: number
44
+ }
45
+
46
+ /**
47
+ * Redis Cluster client. Caller owns it and must disconnect at shutdown.
48
+ *
49
+ * All coordination keys use a hash-tag so that every key for a given
50
+ * policy+operation+scope lands on the same cluster slot. Multi-key Lua
51
+ * scripts (circuit breaker) are therefore cluster-safe without cross-slot
52
+ * concerns.
53
+ *
54
+ * Call `connect()` before use and `disconnect()` at shutdown, matching the
55
+ * standalone client contract.
56
+ */
57
+ export function createCoordinationClusterClient(
58
+ nodes: ReadonlyArray<ClusterNode>,
59
+ commandTimeout = 1000,
60
+ ): Cluster {
61
+ if (!Number.isSafeInteger(commandTimeout) || commandTimeout < 1)
62
+ throw new RangeError("commandTimeout must be a positive integer")
63
+ if (!Array.isArray(nodes) || nodes.length === 0)
64
+ throw new RangeError("nodes must be a non-empty array of { host, port }")
65
+ const cluster = new Cluster([...nodes], {
66
+ lazyConnect: true,
67
+ enableOfflineQueue: false,
68
+ clusterRetryStrategy: (attempt) => Math.min(attempt * 50, 1000),
69
+ redisOptions: {
70
+ commandTimeout,
71
+ connectTimeout: commandTimeout,
72
+ maxRetriesPerRequest: 0,
73
+ autoResendUnfulfilledCommands: false,
74
+ },
75
+ })
76
+ attachErrorSink(cluster)
77
+ return cluster
78
+ }
@@ -0,0 +1,71 @@
1
+ import { createHash } from "node:crypto"
2
+
3
+ /**
4
+ * Minimal Lua-script capability required by the Redis coordinators.
5
+ *
6
+ * `eval` is always required. `evalsha` is optional so structural test doubles
7
+ * and clients that only expose `eval` keep working; when it is missing the
8
+ * script body is sent with `eval`.
9
+ */
10
+ export interface ScriptClient {
11
+ eval(
12
+ script: string,
13
+ numberOfKeys: number,
14
+ ...args: (string | number)[]
15
+ ): Promise<unknown>
16
+ evalsha?(
17
+ sha: string,
18
+ numberOfKeys: number,
19
+ ...args: (string | number)[]
20
+ ): Promise<unknown>
21
+ }
22
+
23
+ const scriptHashes = new Map<string, string>()
24
+
25
+ /** SHA1 of a script body. Redis keys its script cache by exactly this value. */
26
+ export function scriptSha(script: string): string {
27
+ const cached = scriptHashes.get(script)
28
+ if (cached !== undefined) return cached
29
+ const sha = createHash("sha1").update(script).digest("hex")
30
+ scriptHashes.set(script, sha)
31
+ return sha
32
+ }
33
+
34
+ function errorMessage(error: unknown): string {
35
+ if (typeof error === "string") return error
36
+ if (error instanceof Error) return error.message
37
+ return ""
38
+ }
39
+
40
+ /**
41
+ * Executes a Lua script, sending the body only when it has to.
42
+ *
43
+ * The script cache is keyed by SHA1, so the steady state is `EVALSHA` - a
44
+ * 40-byte hash instead of kilobytes of Lua on every call. The full body goes
45
+ * out with `EVAL` only when the client cannot send `EVALSHA`, or when the
46
+ * server answers `NOSCRIPT`: the script cache lost the script to a restart, a
47
+ * `SCRIPT FLUSH`, or (Redis 7.4 and later) LRU eviction under memory pressure.
48
+ *
49
+ * Retrying through `EVAL` is safe because Redis rejects an unknown SHA1 before
50
+ * executing anything, so the failed command is known not to have run. This is
51
+ * deliberately narrower than the coordinator's command-timeout rule, where the
52
+ * outcome is unknown and a replay is never allowed. A timeout error is not a
53
+ * `NOSCRIPT` error and propagates unchanged.
54
+ */
55
+ export async function evalScript(
56
+ client: ScriptClient,
57
+ script: string,
58
+ numberOfKeys: number,
59
+ ...args: (string | number)[]
60
+ ): Promise<unknown> {
61
+ const evalsha = client.evalsha
62
+ if (typeof evalsha !== "function") {
63
+ return client.eval(script, numberOfKeys, ...args)
64
+ }
65
+ try {
66
+ return await evalsha.call(client, scriptSha(script), numberOfKeys, ...args)
67
+ } catch (error) {
68
+ if (!errorMessage(error).includes("NOSCRIPT")) throw error
69
+ return client.eval(script, numberOfKeys, ...args)
70
+ }
71
+ }