@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,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,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
|
+
}
|