@gkoos/caracal 0.1.1 → 0.2.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/dist/redis.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/coordination/redis/client.ts","../src/coordination/redis/eval-script.ts","../src/coordination/redis/keys.ts","../src/coordination/redis/scripts.ts","../src/coordination/redis/bulkhead.ts","../src/coordination/redis/circuit-breaker.ts"],"names":["createHash"],"mappings":"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","file":"redis.js","sourcesContent":["import { Cluster, Redis } from \"ioredis\"\n\nexport class CoordinatorUnavailableError extends Error {\n readonly coordination = \"distributed\"\n constructor(cause: unknown) {\n super(\"Redis coordination unavailable; command outcome may be unknown\", {\n cause,\n })\n this.name = \"CoordinatorUnavailableError\"\n }\n}\n\nfunction attachErrorSink(emitter: {\n on(event: \"error\", listener: () => void): void\n}): void {\n emitter.on(\"error\", () => {\n /* Commands surface errors; never crash on an unhandled EventEmitter error. */\n })\n}\n\n/** Standalone Redis client. Caller owns it and must disconnect at shutdown. */\nexport function createCoordinationClient(\n url: string,\n commandTimeout = 1000,\n): Redis {\n if (!Number.isSafeInteger(commandTimeout) || commandTimeout < 1)\n throw new RangeError(\"commandTimeout must be a positive integer\")\n const client = new Redis(url, {\n lazyConnect: true,\n enableOfflineQueue: false,\n maxRetriesPerRequest: 0,\n autoResendUnfulfilledCommands: false,\n commandTimeout,\n connectTimeout: commandTimeout,\n retryStrategy: (attempt) => Math.min(attempt * 50, 1000),\n })\n attachErrorSink(client)\n return client\n}\n\nexport interface ClusterNode {\n readonly host: string\n readonly port: number\n}\n\n/**\n * Redis Cluster client. Caller owns it and must disconnect at shutdown.\n *\n * All coordination keys use a hash-tag so that every key for a given\n * policy+operation+scope lands on the same cluster slot. Multi-key Lua\n * scripts (circuit breaker) are therefore cluster-safe without cross-slot\n * concerns.\n *\n * Call `connect()` before use and `disconnect()` at shutdown, matching the\n * standalone client contract.\n */\nexport function createCoordinationClusterClient(\n nodes: ReadonlyArray<ClusterNode>,\n commandTimeout = 1000,\n): Cluster {\n if (!Number.isSafeInteger(commandTimeout) || commandTimeout < 1)\n throw new RangeError(\"commandTimeout must be a positive integer\")\n if (!Array.isArray(nodes) || nodes.length === 0)\n throw new RangeError(\"nodes must be a non-empty array of { host, port }\")\n const cluster = new Cluster([...nodes], {\n lazyConnect: true,\n enableOfflineQueue: false,\n clusterRetryStrategy: (attempt) => Math.min(attempt * 50, 1000),\n redisOptions: {\n commandTimeout,\n connectTimeout: commandTimeout,\n maxRetriesPerRequest: 0,\n autoResendUnfulfilledCommands: false,\n },\n })\n attachErrorSink(cluster)\n return cluster\n}\n","import { createHash } from \"node:crypto\"\n\n/**\n * Minimal Lua-script capability required by the Redis coordinators.\n *\n * `eval` is always required. `evalsha` is optional so structural test doubles\n * and clients that only expose `eval` keep working; when it is missing the\n * script body is sent with `eval`.\n */\nexport interface ScriptClient {\n eval(\n script: string,\n numberOfKeys: number,\n ...args: (string | number)[]\n ): Promise<unknown>\n evalsha?(\n sha: string,\n numberOfKeys: number,\n ...args: (string | number)[]\n ): Promise<unknown>\n}\n\nconst scriptHashes = new Map<string, string>()\n\n/** SHA1 of a script body. Redis keys its script cache by exactly this value. */\nexport function scriptSha(script: string): string {\n const cached = scriptHashes.get(script)\n if (cached !== undefined) return cached\n const sha = createHash(\"sha1\").update(script).digest(\"hex\")\n scriptHashes.set(script, sha)\n return sha\n}\n\nfunction errorMessage(error: unknown): string {\n if (typeof error === \"string\") return error\n if (error instanceof Error) return error.message\n return \"\"\n}\n\n/**\n * Executes a Lua script, sending the body only when it has to.\n *\n * The script cache is keyed by SHA1, so the steady state is `EVALSHA` - a\n * 40-byte hash instead of kilobytes of Lua on every call. The full body goes\n * out with `EVAL` only when the client cannot send `EVALSHA`, or when the\n * server answers `NOSCRIPT`: the script cache lost the script to a restart, a\n * `SCRIPT FLUSH`, or (Redis 7.4 and later) LRU eviction under memory pressure.\n *\n * Retrying through `EVAL` is safe because Redis rejects an unknown SHA1 before\n * executing anything, so the failed command is known not to have run. This is\n * deliberately narrower than the coordinator's command-timeout rule, where the\n * outcome is unknown and a replay is never allowed. A timeout error is not a\n * `NOSCRIPT` error and propagates unchanged.\n */\nexport async function evalScript(\n client: ScriptClient,\n script: string,\n numberOfKeys: number,\n ...args: (string | number)[]\n): Promise<unknown> {\n const evalsha = client.evalsha\n if (typeof evalsha !== \"function\") {\n return client.eval(script, numberOfKeys, ...args)\n }\n try {\n return await evalsha.call(client, scriptSha(script), numberOfKeys, ...args)\n } catch (error) {\n if (!errorMessage(error).includes(\"NOSCRIPT\")) throw error\n return client.eval(script, numberOfKeys, ...args)\n }\n}\n","import { createHash } from \"node:crypto\"\n\n/**\n * Build a namespaced Redis key for a policy-scoped coordination slot.\n *\n * The SHA-256 hash of `[namespace, policy, operation, scope]` forms the\n * stable identity; `suffix` distinguishes multiple keys that share the\n * same identity (e.g. `:leases`, `:breaker`, `:observations`, `:probes`).\n * Default suffix is `\"leases\"` for backward compatibility with the bulkhead.\n */\nexport function coordinationKey(\n namespace: string,\n policy: string,\n operation: string,\n scope: string,\n suffix = \"leases\",\n): string {\n for (const value of [namespace, policy, operation, scope, suffix]) {\n if (\n typeof value !== \"string\" ||\n !value.trim() ||\n Buffer.byteLength(value) > 1024\n )\n throw new TypeError(\n \"Coordination identities must be nonempty strings of at most 1024 UTF-8 bytes\",\n )\n }\n const identity = createHash(\"sha256\")\n .update(JSON.stringify([namespace, policy, operation, scope]))\n .digest(\"hex\")\n return `caracal:v1:{${identity}}:${suffix}`\n}\n","// ---------------------------------------------------------------------------\n// Versioned atomic operations, internal to the Redis policy capabilities.\n// ---------------------------------------------------------------------------\n\n// ---------------------------------------------------------------------------\n// Bulkhead scripts\n// ---------------------------------------------------------------------------\n\nexport const leaseV1 = `\nlocal action = ARGV[1]\nlocal token = ARGV[2]\nlocal ttl = tonumber(ARGV[3])\nlocal limit = tonumber(ARGV[4])\nlocal time = redis.call('TIME')\nlocal now = time[1] * 1000 + math.floor(time[2] / 1000)\nredis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now)\nlocal existing = redis.call('ZSCORE', KEYS[1], token)\nif action == 'release' then\n return redis.call('ZREM', KEYS[1], token)\nend\nif action == 'renew' and not existing then return 0 end\nif action == 'acquire' and existing then return 1 end\nif action == 'acquire' and redis.call('ZCARD', KEYS[1]) >= limit then return 0 end\nredis.call('ZADD', KEYS[1], now + ttl, token)\nlocal latest = redis.call('ZREVRANGE', KEYS[1], 0, 0, 'WITHSCORES')\nredis.call('PEXPIREAT', KEYS[1], math.ceil(tonumber(latest[2])))\nreturn 1\n`\n\n/** Returns the decision and occupancy from the same atomic transition. */\nexport const bulkheadLeaseV1 = `local function transition()\\n${leaseV1}\\nend\\nlocal allowed = transition()\\nreturn {allowed, redis.call('ZCARD', KEYS[1])}`\n\n// ---------------------------------------------------------------------------\n// Circuit-breaker scripts\n//\n// Return arrays use fixed positions so the TypeScript caller can validate\n// and decode without field names (which Lua/Redis cannot return).\n//\n// stateCode encoding: 0=closed 1=open 2=half-open\n// ---------------------------------------------------------------------------\n\n/**\n * Record one attempt outcome into the distributed sliding window.\n * Atomically opens the breaker when the failure ratio meets the threshold.\n *\n * KEYS[1] = breaker state hash (:breaker)\n * KEYS[2] = observations sorted set (:observations)\n *\n * ARGV[1] = outcome \"success\" | \"failure\"\n * ARGV[2] = expectedGeneration integer\n * ARGV[3] = windowTtlMs observation retention window in ms\n * ARGV[4] = minimumThroughput min observations before opening\n * ARGV[5] = failureThresholdNum failure threshold × 1000 (e.g. 500 = 0.5).\n * The policy rejects thresholds that would round\n * to 0 or to 1000 before they reach this script\n * (see assertResolvableThreshold in core).\n * ARGV[6] = windowSize max observations retained by count\n * ARGV[7] = openMs how long to stay OPEN; closed hash TTL = openMs×2\n * ARGV[8] = uuid unique string for member deduplication\n *\n * Returns: {status, stateCode, generation, windowTotal, windowFailures}\n * status 0 = stale (dropped; generation or state mismatch)\n * status 1 = observed, no transition (remained closed)\n * status 2 = observed, breaker opened (stateCode=1, generation incremented)\n *\n * TTL policy:\n * OPEN state → PERSIST (no expiry). A missing key is treated as closed by\n * all scripts, so expiring an open breaker would silently admit\n * unrestricted traffic and reset the generation counter.\n * CLOSED state → max(openMs×2, windowTtlMs) TTL for eventual cleanup of idle\n * scopes. Expiring a closed key is safe *only* because the TTL\n * outlives the observation window: the window is scoped by an\n * epoch, and an epoch that outlives its members is what keeps\n * cleanup from resurrecting them.\n *\n * Epochs:\n * `generation` increments on every transition, and a brand new value is minted\n * whenever the state hash has to be recreated while observation members from a\n * previous epoch are still present (state lost to eviction or admin cleanup).\n * Window membership is decided by comparing the stored epoch, so members from\n * a superseded epoch can never be counted again, and an attempt holding a\n * pre-loss generation can never pass the staleness check. A scope that has\n * never been observed keeps generation 0.\n */\nexport const breakerObserveV1 = `\nlocal outcome = ARGV[1]\nlocal expectGen = tonumber(ARGV[2])\nlocal windowTtl = tonumber(ARGV[3])\nlocal minTP = tonumber(ARGV[4])\nlocal threshNum = tonumber(ARGV[5])\nlocal windowSize = tonumber(ARGV[6])\nlocal openMs = tonumber(ARGV[7])\nlocal uuid = ARGV[8]\nlocal t = redis.call('TIME')\nlocal now = t[1] * 1000 + math.floor(t[2] / 1000)\nlocal f = redis.call('HMGET', KEYS[1], 'state', 'generation')\nlocal state = f[1] or 'closed'\nlocal gen = tonumber(f[2]) or 0\nif state ~= 'closed' then\n local sc = (state == 'open' and 1) or (state == 'half-open' and 2) or 0\n return {0, sc, gen, 0, 0}\nend\nif redis.call('EXISTS', KEYS[1]) == 0 then\n -- No live state hash: a scope we have never seen, or a hash that was lost\n -- while its window survived.\n if expectGen ~= 0 then\n -- The caller holds a generation this key cannot confirm.\n return {0, 0, 0, 0, 0}\n end\n if redis.call('EXISTS', KEYS[2]) == 1 then\n -- Members from a superseded epoch are still here. Mint an epoch that has\n -- never been used for this key (derived from the observation's own uuid, so\n -- no clock is involved) instead of restarting at a value those members\n -- would match.\n --\n -- Bounded to 11 hex digits (44 bits, <= 14 decimal digits) so the value\n -- prints exactly with stock Lua number formatting: the epoch is read back\n -- from the hash as a decimal string by both this script and the client, and\n -- a larger value could be written in scientific notation and break the\n -- round-trip.\n gen = tonumber(string.sub(redis.sha1hex(uuid), 1, 11), 16)\n if not gen or gen == 0 then gen = 1 end\n else\n gen = 0\n end\n redis.call('HSET', KEYS[1], 'state', 'closed', 'generation', gen,\n 'openedAt', 0, 'probeCount', 0, 'probeSuccesses', 0)\nelseif gen ~= expectGen then\n return {0, 0, gen, 0, 0}\nend\nredis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', now - windowTtl)\nlocal member = tostring(gen) .. ':' .. uuid .. ':' .. outcome\nredis.call('ZADD', KEYS[2], 'NX', now, member)\nlocal cnt = redis.call('ZCARD', KEYS[2])\nif cnt > windowSize then\n redis.call('ZREMRANGEBYRANK', KEYS[2], 0, cnt - windowSize - 1)\nend\nredis.call('PEXPIREAT', KEYS[2], now + windowTtl)\nlocal all = redis.call('ZRANGE', KEYS[2], 0, -1)\nlocal prefix = tostring(gen) .. ':'\nlocal wTotal = 0\nlocal wFail = 0\nfor _, m in ipairs(all) do\n if string.sub(m, 1, #prefix) == prefix then\n wTotal = wTotal + 1\n if string.sub(m, -8) == ':failure' then wFail = wFail + 1 end\n end\nend\nif wTotal >= minTP and wFail * 1000 >= threshNum * wTotal then\n gen = gen + 1\n redis.call('HSET', KEYS[1], 'state', 'open', 'generation', gen,\n 'openedAt', now, 'probeCount', 0, 'probeSuccesses', 0)\n -- OPEN state must never expire: a missing key is treated as closed,\n -- which would silently admit unrestricted traffic and reset the generation.\n redis.call('PERSIST', KEYS[1])\n return {2, 1, gen, wTotal, wFail}\nend\nif redis.call('EXISTS', KEYS[1]) == 1 then\n -- Still CLOSED. The cleanup TTL must not expire the state before the window\n -- it governs, otherwise retained members would survive their epoch.\n redis.call('PEXPIRE', KEYS[1], math.max(openMs * 2, windowTtl))\nend\nreturn {1, 0, gen, wTotal, wFail}\n`\n\n/**\n * Admit a probe attempt. Transitions OPEN→HALF_OPEN when openMs has elapsed,\n * then atomically allocates a probe token slot.\n *\n * KEYS[1] = breaker state hash (:breaker)\n * KEYS[2] = probe tokens sorted set (:probes)\n *\n * ARGV[1] = probeToken unique UUID for this probe attempt\n * ARGV[2] = openMs determines OPEN→HALF_OPEN transition and TTL\n * ARGV[3] = halfOpenProbes maximum concurrent probe tokens\n * ARGV[4] = probeLeaseTtlMs probe token TTL in ms\n *\n * Returns: {status, stateCode, generation, probeCount, transitioned}\n * status 0 = rejected\n * stateCode 0 = breaker already CLOSED (no probe needed)\n * stateCode 1 = still OPEN (openMs not yet elapsed)\n * stateCode 2 = HALF_OPEN but probe limit reached\n * status 1 = admitted (probe token stored)\n * transitioned 1 = OPEN→HALF_OPEN transition happened in this call\n * transitioned 0 = was already HALF_OPEN\n */\nexport const breakerAdmitProbeV1 = `\nlocal probeToken = ARGV[1]\nlocal openMs = tonumber(ARGV[2])\nlocal maxProbes = tonumber(ARGV[3])\nlocal probeLeaseTtl = tonumber(ARGV[4])\nlocal t = redis.call('TIME')\nlocal now = t[1] * 1000 + math.floor(t[2] / 1000)\nlocal f = redis.call('HMGET', KEYS[1], 'state', 'generation', 'openedAt',\n 'probeCount', 'probeSuccesses')\nlocal state = f[1] or 'closed'\nlocal gen = tonumber(f[2]) or 0\nlocal openedAt = tonumber(f[3]) or 0\nlocal transitioned = 0\nif state == 'closed' then\n return {0, 0, gen, 0, 0}\nend\nif state == 'open' then\n if now - openedAt < openMs then\n return {0, 1, gen, 0, 0}\n end\n state = 'half-open'\n transitioned = 1\n redis.call('HSET', KEYS[1], 'state', 'half-open', 'probeSuccesses', 0, 'probeCount', 0)\n -- OPEN/HALF_OPEN must never expire; PERSIST removes any prior cleanup TTL.\n redis.call('PERSIST', KEYS[1])\n -- Tokens from the superseded recovery window must not consume slots in this\n -- one; their settle is dropped by the generation check anyway.\n redis.call('DEL', KEYS[2])\nend\nredis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', now)\nlocal activeProbes = redis.call('ZCARD', KEYS[2])\nif activeProbes >= maxProbes then\n return {0, 2, gen, activeProbes, transitioned}\nend\nredis.call('ZADD', KEYS[2], 'NX', now + probeLeaseTtl, probeToken)\nactiveProbes = activeProbes + 1\nredis.call('HSET', KEYS[1], 'probeCount', activeProbes)\n-- State hash must not expire while recovery probes are in flight.\nredis.call('PERSIST', KEYS[1])\n-- Probes sorted set expires naturally once the last lease elapses.\nredis.call('PEXPIREAT', KEYS[2], now + probeLeaseTtl + 1000)\nreturn {1, 2, gen, activeProbes, transitioned}\n`\n\n/**\n * Record a probe attempt outcome. Removes the probe token and applies the\n * result: failure re-opens; sufficient successes close the breaker.\n *\n * KEYS[1] = breaker state hash (:breaker)\n * KEYS[2] = probe tokens sorted set (:probes)\n *\n * ARGV[1] = probeToken the token issued by admitProbe\n * ARGV[2] = outcome \"success\" | \"failure\" | \"ignored\", where\n * \"ignored\" releases the slot without recording\n * an outcome or advancing recovery\n * ARGV[3] = expectedGeneration generation captured at admission time\n * ARGV[4] = halfOpenSuccesses consecutive probe successes needed to close\n * ARGV[5] = openMs how long the breaker stays OPEN (TTL floor)\n * ARGV[6] = windowTtlMs observation retention (TTL floor): the CLOSED\n * hash must outlive the window it governs\n *\n * Returns: {status, stateCode, generation}\n * status 0 = stale (token missing, lease elapsed, or generation mismatch;\n * the result is dropped and any consumed token is released)\n * status 1 = settled, no state transition (still HALF_OPEN; an \"ignored\"\n * outcome only releases the probe slot)\n * status 2 = settled, state transition occurred\n * stateCode 0 = transitioned to CLOSED\n * stateCode 1 = transitioned back to OPEN (probe failure)\n *\n * A token carries its own deadline as its sorted-set score. When the lease has\n * elapsed the slot is already recoverable - admitProbe prunes expired tokens\n * and re-issues the slot - so the result must not count even while the member is\n * still present. The deadline is therefore checked under the same atomic call.\n */\nexport const breakerSettleProbeV1 = `\nlocal probeToken = ARGV[1]\nlocal outcome = ARGV[2]\nlocal expectGen = tonumber(ARGV[3])\nlocal halfOpenSucc = tonumber(ARGV[4])\nlocal openMs = tonumber(ARGV[5])\nlocal windowTtl = tonumber(ARGV[6])\nlocal t = redis.call('TIME')\nlocal now = t[1] * 1000 + math.floor(t[2] / 1000)\nlocal f = redis.call('HMGET', KEYS[1], 'state', 'generation', 'probeSuccesses', 'probeCount')\nlocal state = f[1] or 'closed'\nlocal gen = tonumber(f[2]) or 0\nlocal probeSucc = tonumber(f[3]) or 0\nlocal probeCnt = tonumber(f[4]) or 0\nlocal sc = (state == 'open' and 1) or (state == 'half-open' and 2) or 0\nlocal deadline = redis.call('ZSCORE', KEYS[2], probeToken)\nif not deadline then\n -- Never admitted, already settled, or dropped with its recovery window.\n return {0, sc, gen}\nend\nredis.call('ZREM', KEYS[2], probeToken)\nif tonumber(deadline) <= now then\n -- The lease elapsed while the probe was still running: its slot was\n -- recoverable, so the result cannot be counted. Release the slot it still\n -- occupied so the probe count keeps matching the token set.\n probeCnt = math.max(0, probeCnt - 1)\n redis.call('HSET', KEYS[1], 'probeCount', probeCnt)\n return {0, sc, gen}\nend\nif state ~= 'half-open' or gen ~= expectGen then\n return {0, sc, gen}\nend\nprobeCnt = math.max(0, probeCnt - 1)\nif outcome == 'ignored' then\n -- The result was not recorded (the classifier ignored it), but the slot the\n -- probe held must still be released: otherwise the recovery window stalls\n -- until the probe lease elapses instead of letting the next probe through.\n -- The local breaker releases its slot immediately, and this matches it: no\n -- success counter advances and the state does not transition.\n redis.call('HSET', KEYS[1], 'probeCount', probeCnt)\n -- Still HALF_OPEN: must not expire while recovery is in progress.\n redis.call('PERSIST', KEYS[1])\n return {1, 2, gen}\nend\nif outcome == 'failure' then\n gen = gen + 1\n redis.call('HSET', KEYS[1], 'state', 'open', 'generation', gen,\n 'openedAt', now, 'probeCount', 0, 'probeSuccesses', 0)\n -- Re-opened: OPEN must not expire (see breakerObserveV1 TTL policy).\n redis.call('PERSIST', KEYS[1])\n -- In-flight probes belong to the dead generation; free their slots so the\n -- next recovery window starts with full capacity.\n redis.call('DEL', KEYS[2])\n return {2, 1, gen}\nend\nprobeSucc = probeSucc + 1\nif probeSucc >= halfOpenSucc then\n gen = gen + 1\n redis.call('HSET', KEYS[1], 'state', 'closed', 'generation', gen,\n 'probeCount', 0, 'probeSuccesses', 0)\n -- Newly CLOSED: expire for cleanup, but never before the window it governs.\n redis.call('PEXPIRE', KEYS[1], math.max(openMs * 2, windowTtl))\n redis.call('DEL', KEYS[2])\n return {2, 0, gen}\nend\nredis.call('HSET', KEYS[1], 'probeSuccesses', probeSucc, 'probeCount', probeCnt)\n-- Still HALF_OPEN: must not expire while recovery is in progress.\nredis.call('PERSIST', KEYS[1])\nreturn {1, 2, gen}\n`\n","import type { BulkheadCoordinator } from \"../../core/bulkhead.js\"\nimport { CoordinatorUnavailableError } from \"./client.js\"\nimport { evalScript } from \"./eval-script.js\"\nimport { coordinationKey } from \"./keys.js\"\nimport type { RedisScriptClient } from \"./leases.js\"\nimport { bulkheadLeaseV1 } from \"./scripts.js\"\nexport function redisCoordinator(\n client: RedisScriptClient,\n options: { namespace: string },\n): BulkheadCoordinator {\n const namespace = options.namespace\n coordinationKey(namespace, \"bulkhead\", \"validate\", \"validate\")\n const coordinator: BulkheadCoordinator = {\n async command(identity, action, token, leaseMs, limit) {\n if (\n ![\"acquire\", \"renew\", \"release\"].includes(action) ||\n typeof token !== \"string\" ||\n !token ||\n token.length > 256\n )\n throw new TypeError(\"Invalid bulkhead lease action or token\")\n if (\n ![leaseMs, limit].every(\n (value) => Number.isSafeInteger(value) && value > 0,\n ) ||\n leaseMs > 86400000\n )\n throw new RangeError(\"Invalid bulkhead lease duration or limit\")\n const key = coordinationKey(\n namespace,\n `bulkhead:${identity.name}`,\n identity.operation,\n identity.scope,\n )\n try {\n const result = await evalScript(\n client,\n bulkheadLeaseV1,\n 1,\n key,\n action,\n token,\n leaseMs,\n limit,\n )\n if (\n !Array.isArray(result) ||\n result.length !== 2 ||\n ![0, 1].includes(result[0]) ||\n !Number.isSafeInteger(result[1]) ||\n result[1] < 0\n )\n throw new Error(\"Invalid bulkhead reply\")\n return { allowed: result[0] === 1, occupancy: result[1] as number }\n } catch (error) {\n throw new CoordinatorUnavailableError(error)\n }\n },\n }\n return Object.freeze(coordinator)\n}\n","import type {\n AdmitProbeResult,\n BreakerCoordinator,\n BreakerIdentity,\n BreakerState,\n ObserveResult,\n SettleProbeResult,\n} from \"../../core/circuit-breaker.js\"\nimport { CoordinatorUnavailableError } from \"./client.js\"\nimport { evalScript } from \"./eval-script.js\"\nimport { coordinationKey } from \"./keys.js\"\nimport type { RedisScriptClient } from \"./leases.js\"\nimport {\n breakerAdmitProbeV1,\n breakerObserveV1,\n breakerSettleProbeV1,\n} from \"./scripts.js\"\n\n// ---------------------------------------------------------------------------\n// Key helpers\n// ---------------------------------------------------------------------------\n\nfunction breakerKeys(\n namespace: string,\n identity: BreakerIdentity,\n): [hashKey: string, obsKey: string, probeKey: string] {\n const policy = `breaker:${identity.name}`\n const hash = coordinationKey(\n namespace,\n policy,\n identity.operation,\n identity.scope,\n \"breaker\",\n )\n const obs = coordinationKey(\n namespace,\n policy,\n identity.operation,\n identity.scope,\n \"observations\",\n )\n const prob = coordinationKey(\n namespace,\n policy,\n identity.operation,\n identity.scope,\n \"probes\",\n )\n return [hash, obs, prob]\n}\n\n// ---------------------------------------------------------------------------\n// Reply validation helpers\n// ---------------------------------------------------------------------------\n\nfunction assertArray(reply: unknown, minLen: number, label: string): unknown[] {\n if (\n !Array.isArray(reply) ||\n reply.length < minLen ||\n reply.some((v) => typeof v !== \"number\" || !Number.isSafeInteger(v))\n ) {\n throw new Error(\n `Invalid ${label} reply from Redis: ${JSON.stringify(reply)}`,\n )\n }\n return reply as unknown[]\n}\n\nconst STATE_CODES: BreakerState[] = [\"closed\", \"open\", \"half-open\"]\n\nfunction decodeState(code: number): BreakerState {\n const s = STATE_CODES[code]\n if (!s) throw new Error(`Unknown breaker state code: ${code}`)\n return s\n}\n\n// ---------------------------------------------------------------------------\n// Factory\n// ---------------------------------------------------------------------------\n\n/**\n * Policy-specific Redis coordinator for `circuitBreaker.distributed()`.\n *\n * The caller owns `client` and must connect/disconnect it independently.\n * `namespace` is prepended to every key; use a per-service, per-environment\n * value to avoid cross-deployment state collisions.\n */\nexport function redisCircuitBreakerCoordinator(\n client: RedisScriptClient,\n options: { readonly namespace: string },\n): BreakerCoordinator {\n const { namespace } = options\n // Validate namespace eagerly (coordinationKey throws on bad input).\n coordinationKey(\n namespace,\n \"breaker:validate\",\n \"validate\",\n \"validate\",\n \"breaker\",\n )\n\n const coordinator: BreakerCoordinator = {\n async readState(identity) {\n const [hashKey] = breakerKeys(namespace, identity)\n try {\n const fields = await client.hmget(hashKey, \"state\", \"generation\")\n const rawState = fields[0]\n if (!rawState) return null\n const state = rawState as BreakerState\n if (!STATE_CODES.includes(state))\n throw new Error(`Unknown breaker state: ${rawState}`)\n const generation = parseInt(fields[1] ?? \"0\", 10)\n return { state, generation }\n } catch (error) {\n throw new CoordinatorUnavailableError(error)\n }\n },\n\n async observe(identity, params) {\n const [hashKey, obsKey] = breakerKeys(namespace, identity)\n const {\n generation,\n outcome,\n uuid,\n windowTtlMs,\n minimumThroughput,\n failureThresholdNumerator,\n windowSize,\n openMs,\n } = params\n try {\n const reply = await evalScript(\n client,\n breakerObserveV1,\n 2,\n hashKey,\n obsKey,\n outcome,\n generation,\n windowTtlMs,\n minimumThroughput,\n failureThresholdNumerator,\n windowSize,\n openMs,\n uuid,\n )\n const r = assertArray(reply, 5, \"breakerObserveV1\") as number[]\n const [status, , newGen, windowTotal, windowFailures] = r\n if (status === 0) {\n return {\n type: \"stale\",\n currentGeneration: newGen,\n } satisfies ObserveResult\n }\n if (status === 2) {\n return {\n type: \"opened\",\n newGeneration: newGen,\n windowTotal,\n windowFailures,\n } satisfies ObserveResult\n }\n return {\n type: \"observed\",\n generation: newGen,\n windowTotal,\n windowFailures,\n } satisfies ObserveResult\n } catch (error) {\n throw new CoordinatorUnavailableError(error)\n }\n },\n\n async admitProbe(identity, params) {\n const [hashKey, , probeKey] = breakerKeys(namespace, identity)\n const { probeToken, openMs, halfOpenProbes, probeLeaseTtlMs } = params\n try {\n const reply = await evalScript(\n client,\n breakerAdmitProbeV1,\n 2,\n hashKey,\n probeKey,\n probeToken,\n openMs,\n halfOpenProbes,\n probeLeaseTtlMs,\n )\n const r = assertArray(reply, 5, \"breakerAdmitProbeV1\") as number[]\n const [status, stateCode, gen, probeCount, transitioned] = r\n if (status === 0) {\n const reason =\n stateCode === 0\n ? \"closed\"\n : stateCode === 1\n ? \"open\"\n : \"probe-limit\"\n return {\n type: \"rejected\",\n reason,\n generation: gen,\n } satisfies AdmitProbeResult\n }\n return {\n type: \"admitted\",\n generation: gen,\n probeCount,\n stateChanged: transitioned === 1,\n } satisfies AdmitProbeResult\n } catch (error) {\n throw new CoordinatorUnavailableError(error)\n }\n },\n\n async settleProbe(identity, params) {\n const [hashKey, , probeKey] = breakerKeys(namespace, identity)\n const {\n probeToken,\n outcome,\n generation,\n halfOpenSuccesses,\n openMs,\n windowTtlMs,\n } = params\n try {\n const reply = await evalScript(\n client,\n breakerSettleProbeV1,\n 2,\n hashKey,\n probeKey,\n probeToken,\n outcome,\n generation,\n halfOpenSuccesses,\n openMs,\n // Floor for the CLOSED cleanup TTL; older callers that do not pass it\n // keep the historical openMs x 2 behaviour.\n windowTtlMs ?? openMs * 2,\n )\n const r = assertArray(reply, 3, \"breakerSettleProbeV1\") as number[]\n const [status, stateCode, newGen] = r\n if (status === 0) {\n return {\n type: \"stale\",\n generation: newGen,\n } satisfies SettleProbeResult\n }\n const state = decodeState(stateCode)\n if (status === 2) {\n return {\n type: \"transitioned\",\n newState: state,\n newGeneration: newGen,\n previousState: \"half-open\",\n } satisfies SettleProbeResult\n }\n return {\n type: \"settled\",\n state,\n generation: newGen,\n } satisfies SettleProbeResult\n } catch (error) {\n throw new CoordinatorUnavailableError(error)\n }\n },\n }\n\n return Object.freeze(coordinator)\n}\n"]}
1
+ {"version":3,"sources":["../src/coordination/redis/client.ts","../src/coordination/redis/eval-script.ts","../src/coordination/redis/keys.ts","../src/coordination/redis/scripts.ts","../src/coordination/redis/bulkhead.ts","../src/coordination/redis/circuit-breaker.ts"],"names":["createHash"],"mappings":";;;;AAEO,IAAM,2BAAA,GAAN,cAA0C,KAAA,CAAM;AAAA,EAC5C,YAAA,GAAe,aAAA;AAAA,EACxB,YAAY,KAAA,EAAgB;AAC1B,IAAA,KAAA,CAAM,gEAAA,EAAkE;AAAA,MACtE;AAAA,KACD,CAAA;AACD,IAAA,IAAA,CAAK,IAAA,GAAO,6BAAA;AAAA,EACd;AACF;AAEA,SAAS,gBAAgB,OAAA,EAEhB;AACP,EAAA,OAAA,CAAQ,EAAA,CAAG,SAAS,MAAM;AAAA,EAE1B,CAAC,CAAA;AACH;AAGO,SAAS,wBAAA,CACd,GAAA,EACA,cAAA,GAAiB,GAAA,EACV;AACP,EAAA,IAAI,CAAC,MAAA,CAAO,aAAA,CAAc,cAAc,KAAK,cAAA,GAAiB,CAAA;AAC5D,IAAA,MAAM,IAAI,WAAW,2CAA2C,CAAA;AAClE,EAAA,MAAM,MAAA,GAAS,IAAI,KAAA,CAAM,GAAA,EAAK;AAAA,IAC5B,WAAA,EAAa,IAAA;AAAA,IACb,kBAAA,EAAoB,KAAA;AAAA,IACpB,oBAAA,EAAsB,CAAA;AAAA,IACtB,6BAAA,EAA+B,KAAA;AAAA,IAC/B,cAAA;AAAA,IACA,cAAA,EAAgB,cAAA;AAAA,IAChB,eAAe,CAAC,OAAA,KAAY,KAAK,GAAA,CAAI,OAAA,GAAU,IAAI,GAAI;AAAA,GACxD,CAAA;AACD,EAAA,eAAA,CAAgB,MAAM,CAAA;AACtB,EAAA,OAAO,MAAA;AACT;AAwBO,SAAS,gCACd,KAAA,EACA,cAAA,GAAiB,GAAA,EACjB,iBAAA,GAAkC,EAAC,EAC1B;AACT,EAAA,IAAI,CAAC,MAAA,CAAO,aAAA,CAAc,cAAc,KAAK,cAAA,GAAiB,CAAA;AAC5D,IAAA,MAAM,IAAI,WAAW,2CAA2C,CAAA;AAClE,EAAA,IAAI,CAAC,KAAA,CAAM,OAAA,CAAQ,KAAK,CAAA,IAAK,MAAM,MAAA,KAAW,CAAA;AAC5C,IAAA,MAAM,IAAI,WAAW,mDAAmD,CAAA;AAC1E,EAAA,IACE,OAAO,iBAAA,KAAsB,QAAA,IAC7B,sBAAsB,IAAA,IACtB,KAAA,CAAM,QAAQ,iBAAiB,CAAA;AAE/B,IAAA,MAAM,IAAI,UAAU,qDAAqD,CAAA;AAC3E,EAAA,MAAM,UAAU,IAAI,OAAA,CAAQ,CAAC,GAAG,KAAK,CAAA,EAAG;AAAA,IACtC,WAAA,EAAa,IAAA;AAAA,IACb,kBAAA,EAAoB,KAAA;AAAA,IACpB,sBAAsB,CAAC,OAAA,KAAY,KAAK,GAAA,CAAI,OAAA,GAAU,IAAI,GAAI,CAAA;AAAA,IAC9D,YAAA,EAAc;AAAA,MACZ,GAAG,iBAAA;AAAA,MACH,cAAA;AAAA,MACA,cAAA,EAAgB,cAAA;AAAA,MAChB,oBAAA,EAAsB,CAAA;AAAA,MACtB,6BAAA,EAA+B;AAAA;AACjC,GACD,CAAA;AACD,EAAA,eAAA,CAAgB,OAAO,CAAA;AACvB,EAAA,OAAO,OAAA;AACT;ACrEA,IAAM,YAAA,uBAAmB,GAAA,EAAoB;AAGtC,SAAS,UAAU,MAAA,EAAwB;AAChD,EAAA,MAAM,MAAA,GAAS,YAAA,CAAa,GAAA,CAAI,MAAM,CAAA;AACtC,EAAA,IAAI,MAAA,KAAW,QAAW,OAAO,MAAA;AACjC,EAAA,MAAM,GAAA,GAAM,WAAW,MAAM,CAAA,CAAE,OAAO,MAAM,CAAA,CAAE,OAAO,KAAK,CAAA;AAC1D,EAAA,YAAA,CAAa,GAAA,CAAI,QAAQ,GAAG,CAAA;AAC5B,EAAA,OAAO,GAAA;AACT;AAEA,SAAS,aAAa,KAAA,EAAwB;AAC5C,EAAA,IAAI,OAAO,KAAA,KAAU,QAAA,EAAU,OAAO,KAAA;AACtC,EAAA,IAAI,KAAA,YAAiB,KAAA,EAAO,OAAO,KAAA,CAAM,OAAA;AACzC,EAAA,OAAO,EAAA;AACT;AAiBA,eAAsB,UAAA,CACpB,MAAA,EACA,MAAA,EACA,YAAA,EAAA,GACG,IAAA,EACe;AAClB,EAAA,MAAM,UAAU,MAAA,CAAO,OAAA;AACvB,EAAA,IAAI,OAAO,YAAY,UAAA,EAAY;AACjC,IAAA,OAAO,MAAA,CAAO,IAAA,CAAK,MAAA,EAAQ,YAAA,EAAc,GAAG,IAAI,CAAA;AAAA,EAClD;AACA,EAAA,IAAI;AACF,IAAA,OAAO,MAAM,QAAQ,IAAA,CAAK,MAAA,EAAQ,UAAU,MAAM,CAAA,EAAG,YAAA,EAAc,GAAG,IAAI,CAAA;AAAA,EAC5E,SAAS,KAAA,EAAO;AACd,IAAA,IAAI,CAAC,YAAA,CAAa,KAAK,EAAE,QAAA,CAAS,UAAU,GAAG,MAAM,KAAA;AACrD,IAAA,OAAO,MAAA,CAAO,IAAA,CAAK,MAAA,EAAQ,YAAA,EAAc,GAAG,IAAI,CAAA;AAAA,EAClD;AACF;AC5DO,SAAS,gBACd,SAAA,EACA,MAAA,EACA,SAAA,EACA,KAAA,EACA,SAAS,QAAA,EACD;AACR,EAAA,KAAA,MAAW,SAAS,CAAC,SAAA,EAAW,QAAQ,SAAA,EAAW,KAAA,EAAO,MAAM,CAAA,EAAG;AACjE,IAAA,IACE,OAAO,KAAA,KAAU,QAAA,IACjB,CAAC,KAAA,CAAM,MAAK,IACZ,MAAA,CAAO,UAAA,CAAW,KAAK,CAAA,GAAI,IAAA;AAE3B,MAAA,MAAM,IAAI,SAAA;AAAA,QACR;AAAA,OACF;AAAA,EACJ;AACA,EAAA,MAAM,WAAWA,UAAAA,CAAW,QAAQ,CAAA,CACjC,MAAA,CAAO,KAAK,SAAA,CAAU,CAAC,SAAA,EAAW,MAAA,EAAQ,WAAW,KAAK,CAAC,CAAC,CAAA,CAC5D,OAAO,KAAK,CAAA;AACf,EAAA,OAAO,CAAA,YAAA,EAAe,QAAQ,CAAA,EAAA,EAAK,MAAM,CAAA,CAAA;AAC3C;;;ACvBO,IAAM,OAAA,GAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,CAAA;AAsBhB,IAAM,eAAA,GAAkB,CAAA;AAAA,EAAgC,OAAO;AAAA;AAAA;AAAA,8CAAA,CAAA;AAsD/D,IAAM,gBAAA,GAAmB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,CAAA;AAsGzB,IAAM,mBAAA,GAAsB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,CAAA;AA2E5B,IAAM,oBAAA,GAAuB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,CAAA;;;AC/P7B,SAAS,gBAAA,CACd,QACA,OAAA,EACqB;AACrB,EAAA,MAAM,YAAY,OAAA,CAAQ,SAAA;AAC1B,EAAA,eAAA,CAAgB,SAAA,EAAW,UAAA,EAAY,UAAA,EAAY,UAAU,CAAA;AAC7D,EAAA,MAAM,WAAA,GAAmC;AAAA,IACvC,MAAM,OAAA,CAAQ,QAAA,EAAU,MAAA,EAAQ,KAAA,EAAO,SAAS,KAAA,EAAO;AACrD,MAAA,IACE,CAAC,CAAC,SAAA,EAAW,OAAA,EAAS,SAAS,CAAA,CAAE,QAAA,CAAS,MAAM,CAAA,IAChD,OAAO,KAAA,KAAU,QAAA,IACjB,CAAC,KAAA,IACD,MAAM,MAAA,GAAS,GAAA;AAEf,QAAA,MAAM,IAAI,UAAU,wCAAwC,CAAA;AAC9D,MAAA,IACE,CAAC,CAAC,OAAA,EAAS,KAAK,CAAA,CAAE,KAAA;AAAA,QAChB,CAAC,KAAA,KAAU,MAAA,CAAO,aAAA,CAAc,KAAK,KAAK,KAAA,GAAQ;AAAA,WAEpD,OAAA,GAAU,KAAA;AAEV,QAAA,MAAM,IAAI,WAAW,0CAA0C,CAAA;AACjE,MAAA,MAAM,GAAA,GAAM,eAAA;AAAA,QACV,SAAA;AAAA,QACA,CAAA,SAAA,EAAY,SAAS,IAAI,CAAA,CAAA;AAAA,QACzB,QAAA,CAAS,SAAA;AAAA,QACT,QAAA,CAAS;AAAA,OACX;AACA,MAAA,IAAI;AACF,QAAA,MAAM,SAAS,MAAM,UAAA;AAAA,UACnB,MAAA;AAAA,UACA,eAAA;AAAA,UACA,CAAA;AAAA,UACA,GAAA;AAAA,UACA,MAAA;AAAA,UACA,KAAA;AAAA,UACA,OAAA;AAAA,UACA;AAAA,SACF;AACA,QAAA,IACE,CAAC,KAAA,CAAM,OAAA,CAAQ,MAAM,CAAA,IACrB,MAAA,CAAO,MAAA,KAAW,CAAA,IAClB,CAAC,CAAC,CAAA,EAAG,CAAC,CAAA,CAAE,QAAA,CAAS,MAAA,CAAO,CAAC,CAAC,CAAA,IAC1B,CAAC,MAAA,CAAO,aAAA,CAAc,MAAA,CAAO,CAAC,CAAC,CAAA,IAC/B,MAAA,CAAO,CAAC,CAAA,GAAI,CAAA;AAEZ,UAAA,MAAM,IAAI,MAAM,wBAAwB,CAAA;AAC1C,QAAA,OAAO,EAAE,SAAS,MAAA,CAAO,CAAC,MAAM,CAAA,EAAG,SAAA,EAAW,MAAA,CAAO,CAAC,CAAA,EAAY;AAAA,MACpE,SAAS,KAAA,EAAO;AACd,QAAA,MAAM,IAAI,4BAA4B,KAAK,CAAA;AAAA,MAC7C;AAAA,IACF;AAAA,GACF;AACA,EAAA,OAAO,MAAA,CAAO,OAAO,WAAW,CAAA;AAClC;;;ACtCA,SAAS,WAAA,CACP,WACA,QAAA,EACqD;AACrD,EAAA,MAAM,MAAA,GAAS,CAAA,QAAA,EAAW,QAAA,CAAS,IAAI,CAAA,CAAA;AACvC,EAAA,MAAM,IAAA,GAAO,eAAA;AAAA,IACX,SAAA;AAAA,IACA,MAAA;AAAA,IACA,QAAA,CAAS,SAAA;AAAA,IACT,QAAA,CAAS,KAAA;AAAA,IACT;AAAA,GACF;AACA,EAAA,MAAM,GAAA,GAAM,eAAA;AAAA,IACV,SAAA;AAAA,IACA,MAAA;AAAA,IACA,QAAA,CAAS,SAAA;AAAA,IACT,QAAA,CAAS,KAAA;AAAA,IACT;AAAA,GACF;AACA,EAAA,MAAM,IAAA,GAAO,eAAA;AAAA,IACX,SAAA;AAAA,IACA,MAAA;AAAA,IACA,QAAA,CAAS,SAAA;AAAA,IACT,QAAA,CAAS,KAAA;AAAA,IACT;AAAA,GACF;AACA,EAAA,OAAO,CAAC,IAAA,EAAM,GAAA,EAAK,IAAI,CAAA;AACzB;AAMA,SAAS,WAAA,CAAY,KAAA,EAAgB,MAAA,EAAgB,KAAA,EAA0B;AAC7E,EAAA,IACE,CAAC,MAAM,OAAA,CAAQ,KAAK,KACpB,KAAA,CAAM,MAAA,GAAS,UACf,KAAA,CAAM,IAAA,CAAK,CAAC,CAAA,KAAM,OAAO,MAAM,QAAA,IAAY,CAAC,OAAO,aAAA,CAAc,CAAC,CAAC,CAAA,EACnE;AACA,IAAA,MAAM,IAAI,KAAA;AAAA,MACR,WAAW,KAAK,CAAA,mBAAA,EAAsB,IAAA,CAAK,SAAA,CAAU,KAAK,CAAC,CAAA;AAAA,KAC7D;AAAA,EACF;AACA,EAAA,OAAO,KAAA;AACT;AAEA,IAAM,WAAA,GAA8B,CAAC,QAAA,EAAU,MAAA,EAAQ,WAAW,CAAA;AAElE,SAAS,YAAY,IAAA,EAA4B;AAC/C,EAAA,MAAM,CAAA,GAAI,YAAY,IAAI,CAAA;AAC1B,EAAA,IAAI,CAAC,CAAA,EAAG,MAAM,IAAI,KAAA,CAAM,CAAA,4BAAA,EAA+B,IAAI,CAAA,CAAE,CAAA;AAC7D,EAAA,OAAO,CAAA;AACT;AAaO,SAAS,8BAAA,CACd,QACA,OAAA,EACoB;AACpB,EAAA,MAAM,EAAE,WAAU,GAAI,OAAA;AAEtB,EAAA,eAAA;AAAA,IACE,SAAA;AAAA,IACA,kBAAA;AAAA,IACA,UAAA;AAAA,IACA,UAAA;AAAA,IACA;AAAA,GACF;AAEA,EAAA,MAAM,WAAA,GAAkC;AAAA,IACtC,MAAM,UAAU,QAAA,EAAU;AACxB,MAAA,MAAM,CAAC,OAAO,CAAA,GAAI,WAAA,CAAY,WAAW,QAAQ,CAAA;AACjD,MAAA,IAAI;AACF,QAAA,MAAM,SAAS,MAAM,MAAA,CAAO,KAAA,CAAM,OAAA,EAAS,SAAS,YAAY,CAAA;AAChE,QAAA,MAAM,QAAA,GAAW,OAAO,CAAC,CAAA;AACzB,QAAA,IAAI,CAAC,UAAU,OAAO,IAAA;AACtB,QAAA,MAAM,KAAA,GAAQ,QAAA;AACd,QAAA,IAAI,CAAC,WAAA,CAAY,QAAA,CAAS,KAAK,CAAA;AAC7B,UAAA,MAAM,IAAI,KAAA,CAAM,CAAA,uBAAA,EAA0B,QAAQ,CAAA,CAAE,CAAA;AACtD,QAAA,MAAM,aAAa,QAAA,CAAS,MAAA,CAAO,CAAC,CAAA,IAAK,KAAK,EAAE,CAAA;AAChD,QAAA,OAAO,EAAE,OAAO,UAAA,EAAW;AAAA,MAC7B,SAAS,KAAA,EAAO;AACd,QAAA,MAAM,IAAI,4BAA4B,KAAK,CAAA;AAAA,MAC7C;AAAA,IACF,CAAA;AAAA,IAEA,MAAM,OAAA,CAAQ,QAAA,EAAU,MAAA,EAAQ;AAC9B,MAAA,MAAM,CAAC,OAAA,EAAS,MAAM,CAAA,GAAI,WAAA,CAAY,WAAW,QAAQ,CAAA;AACzD,MAAA,MAAM;AAAA,QACJ,UAAA;AAAA,QACA,OAAA;AAAA,QACA,IAAA;AAAA,QACA,WAAA;AAAA,QACA,iBAAA;AAAA,QACA,yBAAA;AAAA,QACA,UAAA;AAAA,QACA;AAAA,OACF,GAAI,MAAA;AACJ,MAAA,IAAI;AACF,QAAA,MAAM,QAAQ,MAAM,UAAA;AAAA,UAClB,MAAA;AAAA,UACA,gBAAA;AAAA,UACA,CAAA;AAAA,UACA,OAAA;AAAA,UACA,MAAA;AAAA,UACA,OAAA;AAAA,UACA,UAAA;AAAA,UACA,WAAA;AAAA,UACA,iBAAA;AAAA,UACA,yBAAA;AAAA,UACA,UAAA;AAAA,UACA,MAAA;AAAA,UACA;AAAA,SACF;AACA,QAAA,MAAM,CAAA,GAAI,WAAA,CAAY,KAAA,EAAO,CAAA,EAAG,kBAAkB,CAAA;AAClD,QAAA,MAAM,CAAC,MAAA,IAAU,MAAA,EAAQ,WAAA,EAAa,cAAc,CAAA,GAAI,CAAA;AACxD,QAAA,IAAI,WAAW,CAAA,EAAG;AAChB,UAAA,OAAO;AAAA,YACL,IAAA,EAAM,OAAA;AAAA,YACN,iBAAA,EAAmB;AAAA,WACrB;AAAA,QACF;AACA,QAAA,IAAI,WAAW,CAAA,EAAG;AAChB,UAAA,OAAO;AAAA,YACL,IAAA,EAAM,QAAA;AAAA,YACN,aAAA,EAAe,MAAA;AAAA,YACf,WAAA;AAAA,YACA;AAAA,WACF;AAAA,QACF;AACA,QAAA,OAAO;AAAA,UACL,IAAA,EAAM,UAAA;AAAA,UACN,UAAA,EAAY,MAAA;AAAA,UACZ,WAAA;AAAA,UACA;AAAA,SACF;AAAA,MACF,SAAS,KAAA,EAAO;AACd,QAAA,MAAM,IAAI,4BAA4B,KAAK,CAAA;AAAA,MAC7C;AAAA,IACF,CAAA;AAAA,IAEA,MAAM,UAAA,CAAW,QAAA,EAAU,MAAA,EAAQ;AACjC,MAAA,MAAM,CAAC,OAAA,IAAW,QAAQ,CAAA,GAAI,WAAA,CAAY,WAAW,QAAQ,CAAA;AAC7D,MAAA,MAAM,EAAE,UAAA,EAAY,MAAA,EAAQ,cAAA,EAAgB,iBAAgB,GAAI,MAAA;AAChE,MAAA,IAAI;AACF,QAAA,MAAM,QAAQ,MAAM,UAAA;AAAA,UAClB,MAAA;AAAA,UACA,mBAAA;AAAA,UACA,CAAA;AAAA,UACA,OAAA;AAAA,UACA,QAAA;AAAA,UACA,UAAA;AAAA,UACA,MAAA;AAAA,UACA,cAAA;AAAA,UACA;AAAA,SACF;AACA,QAAA,MAAM,CAAA,GAAI,WAAA,CAAY,KAAA,EAAO,CAAA,EAAG,qBAAqB,CAAA;AACrD,QAAA,MAAM,CAAC,MAAA,EAAQ,SAAA,EAAW,GAAA,EAAK,UAAA,EAAY,YAAY,CAAA,GAAI,CAAA;AAC3D,QAAA,IAAI,WAAW,CAAA,EAAG;AAChB,UAAA,MAAM,SACJ,SAAA,KAAc,CAAA,GACV,QAAA,GACA,SAAA,KAAc,IACZ,MAAA,GACA,aAAA;AACR,UAAA,OAAO;AAAA,YACL,IAAA,EAAM,UAAA;AAAA,YACN,MAAA;AAAA,YACA,UAAA,EAAY;AAAA,WACd;AAAA,QACF;AACA,QAAA,OAAO;AAAA,UACL,IAAA,EAAM,UAAA;AAAA,UACN,UAAA,EAAY,GAAA;AAAA,UACZ,UAAA;AAAA,UACA,cAAc,YAAA,KAAiB;AAAA,SACjC;AAAA,MACF,SAAS,KAAA,EAAO;AACd,QAAA,MAAM,IAAI,4BAA4B,KAAK,CAAA;AAAA,MAC7C;AAAA,IACF,CAAA;AAAA,IAEA,MAAM,WAAA,CAAY,QAAA,EAAU,MAAA,EAAQ;AAClC,MAAA,MAAM,CAAC,OAAA,IAAW,QAAQ,CAAA,GAAI,WAAA,CAAY,WAAW,QAAQ,CAAA;AAC7D,MAAA,MAAM;AAAA,QACJ,UAAA;AAAA,QACA,OAAA;AAAA,QACA,UAAA;AAAA,QACA,iBAAA;AAAA,QACA,MAAA;AAAA,QACA;AAAA,OACF,GAAI,MAAA;AACJ,MAAA,IAAI;AACF,QAAA,MAAM,QAAQ,MAAM,UAAA;AAAA,UAClB,MAAA;AAAA,UACA,oBAAA;AAAA,UACA,CAAA;AAAA,UACA,OAAA;AAAA,UACA,QAAA;AAAA,UACA,UAAA;AAAA,UACA,OAAA;AAAA,UACA,UAAA;AAAA,UACA,iBAAA;AAAA,UACA,MAAA;AAAA;AAAA;AAAA,UAGA,eAAe,MAAA,GAAS;AAAA,SAC1B;AACA,QAAA,MAAM,CAAA,GAAI,WAAA,CAAY,KAAA,EAAO,CAAA,EAAG,sBAAsB,CAAA;AACtD,QAAA,MAAM,CAAC,MAAA,EAAQ,SAAA,EAAW,MAAM,CAAA,GAAI,CAAA;AACpC,QAAA,IAAI,WAAW,CAAA,EAAG;AAChB,UAAA,OAAO;AAAA,YACL,IAAA,EAAM,OAAA;AAAA,YACN,UAAA,EAAY;AAAA,WACd;AAAA,QACF;AACA,QAAA,MAAM,KAAA,GAAQ,YAAY,SAAS,CAAA;AACnC,QAAA,IAAI,WAAW,CAAA,EAAG;AAChB,UAAA,OAAO;AAAA,YACL,IAAA,EAAM,cAAA;AAAA,YACN,QAAA,EAAU,KAAA;AAAA,YACV,aAAA,EAAe,MAAA;AAAA,YACf,aAAA,EAAe;AAAA,WACjB;AAAA,QACF;AACA,QAAA,OAAO;AAAA,UACL,IAAA,EAAM,SAAA;AAAA,UACN,KAAA;AAAA,UACA,UAAA,EAAY;AAAA,SACd;AAAA,MACF,SAAS,KAAA,EAAO;AACd,QAAA,MAAM,IAAI,4BAA4B,KAAK,CAAA;AAAA,MAC7C;AAAA,IACF;AAAA,GACF;AAEA,EAAA,OAAO,MAAA,CAAO,OAAO,WAAW,CAAA;AAClC","file":"redis.js","sourcesContent":["import { Cluster, Redis, type RedisOptions } from \"ioredis\"\n\nexport class CoordinatorUnavailableError extends Error {\n readonly coordination = \"distributed\"\n constructor(cause: unknown) {\n super(\"Redis coordination unavailable; command outcome may be unknown\", {\n cause,\n })\n this.name = \"CoordinatorUnavailableError\"\n }\n}\n\nfunction attachErrorSink(emitter: {\n on(event: \"error\", listener: () => void): void\n}): void {\n emitter.on(\"error\", () => {\n /* Commands surface errors; never crash on an unhandled EventEmitter error. */\n })\n}\n\n/** Standalone Redis client. Caller owns it and must disconnect at shutdown. */\nexport function createCoordinationClient(\n url: string,\n commandTimeout = 1000,\n): Redis {\n if (!Number.isSafeInteger(commandTimeout) || commandTimeout < 1)\n throw new RangeError(\"commandTimeout must be a positive integer\")\n const client = new Redis(url, {\n lazyConnect: true,\n enableOfflineQueue: false,\n maxRetriesPerRequest: 0,\n autoResendUnfulfilledCommands: false,\n commandTimeout,\n connectTimeout: commandTimeout,\n retryStrategy: (attempt) => Math.min(attempt * 50, 1000),\n })\n attachErrorSink(client)\n return client\n}\n\nexport interface ClusterNode {\n readonly host: string\n readonly port: number\n}\n\n/**\n * Redis Cluster client. Caller owns it and must disconnect at shutdown.\n *\n * All coordination keys use a hash-tag so that every key for a given\n * policy+operation+scope lands on the same cluster slot. Multi-key Lua\n * scripts (circuit breaker) are therefore cluster-safe without cross-slot\n * concerns.\n *\n * Call `connect()` before use and `disconnect()` at shutdown, matching the\n * standalone client contract.\n */\n/**\n * Redis Cluster client for a secured cluster: pass credentials and TLS via\n * `connectionOptions`. The coordination safeguards (`lazyConnect`, no offline\n * queue, no command replay, no per-request retries) are applied after them and\n * cannot be overridden, because the coordinators depend on those semantics.\n */\nexport function createCoordinationClusterClient(\n nodes: ReadonlyArray<ClusterNode>,\n commandTimeout = 1000,\n connectionOptions: RedisOptions = {},\n): Cluster {\n if (!Number.isSafeInteger(commandTimeout) || commandTimeout < 1)\n throw new RangeError(\"commandTimeout must be a positive integer\")\n if (!Array.isArray(nodes) || nodes.length === 0)\n throw new RangeError(\"nodes must be a non-empty array of { host, port }\")\n if (\n typeof connectionOptions !== \"object\" ||\n connectionOptions === null ||\n Array.isArray(connectionOptions)\n )\n throw new TypeError(\"connectionOptions must be an ioredis options object\")\n const cluster = new Cluster([...nodes], {\n lazyConnect: true,\n enableOfflineQueue: false,\n clusterRetryStrategy: (attempt) => Math.min(attempt * 50, 1000),\n redisOptions: {\n ...connectionOptions,\n commandTimeout,\n connectTimeout: commandTimeout,\n maxRetriesPerRequest: 0,\n autoResendUnfulfilledCommands: false,\n },\n })\n attachErrorSink(cluster)\n return cluster\n}\n","import { createHash } from \"node:crypto\"\n\n/**\n * Minimal Lua-script capability required by the Redis coordinators.\n *\n * `eval` is always required. `evalsha` is optional so structural test doubles\n * and clients that only expose `eval` keep working; when it is missing the\n * script body is sent with `eval`.\n */\nexport interface ScriptClient {\n eval(\n script: string,\n numberOfKeys: number,\n ...args: (string | number)[]\n ): Promise<unknown>\n evalsha?(\n sha: string,\n numberOfKeys: number,\n ...args: (string | number)[]\n ): Promise<unknown>\n}\n\nconst scriptHashes = new Map<string, string>()\n\n/** SHA1 of a script body. Redis keys its script cache by exactly this value. */\nexport function scriptSha(script: string): string {\n const cached = scriptHashes.get(script)\n if (cached !== undefined) return cached\n const sha = createHash(\"sha1\").update(script).digest(\"hex\")\n scriptHashes.set(script, sha)\n return sha\n}\n\nfunction errorMessage(error: unknown): string {\n if (typeof error === \"string\") return error\n if (error instanceof Error) return error.message\n return \"\"\n}\n\n/**\n * Executes a Lua script, sending the body only when it has to.\n *\n * The script cache is keyed by SHA1, so the steady state is `EVALSHA` - a\n * 40-byte hash instead of kilobytes of Lua on every call. The full body goes\n * out with `EVAL` only when the client cannot send `EVALSHA`, or when the\n * server answers `NOSCRIPT`: the script cache lost the script to a restart, a\n * `SCRIPT FLUSH`, or (Redis 7.4 and later) LRU eviction under memory pressure.\n *\n * Retrying through `EVAL` is safe because Redis rejects an unknown SHA1 before\n * executing anything, so the failed command is known not to have run. This is\n * deliberately narrower than the coordinator's command-timeout rule, where the\n * outcome is unknown and a replay is never allowed. A timeout error is not a\n * `NOSCRIPT` error and propagates unchanged.\n */\nexport async function evalScript(\n client: ScriptClient,\n script: string,\n numberOfKeys: number,\n ...args: (string | number)[]\n): Promise<unknown> {\n const evalsha = client.evalsha\n if (typeof evalsha !== \"function\") {\n return client.eval(script, numberOfKeys, ...args)\n }\n try {\n return await evalsha.call(client, scriptSha(script), numberOfKeys, ...args)\n } catch (error) {\n if (!errorMessage(error).includes(\"NOSCRIPT\")) throw error\n return client.eval(script, numberOfKeys, ...args)\n }\n}\n","import { createHash } from \"node:crypto\"\n\n/**\n * Build a namespaced Redis key for a policy-scoped coordination slot.\n *\n * The SHA-256 hash of `[namespace, policy, operation, scope]` forms the\n * stable identity; `suffix` distinguishes multiple keys that share the\n * same identity (e.g. `:leases`, `:breaker`, `:observations`, `:probes`).\n * Default suffix is `\"leases\"` for backward compatibility with the bulkhead.\n */\nexport function coordinationKey(\n namespace: string,\n policy: string,\n operation: string,\n scope: string,\n suffix = \"leases\",\n): string {\n for (const value of [namespace, policy, operation, scope, suffix]) {\n if (\n typeof value !== \"string\" ||\n !value.trim() ||\n Buffer.byteLength(value) > 1024\n )\n throw new TypeError(\n \"Coordination identities must be nonempty strings of at most 1024 UTF-8 bytes\",\n )\n }\n const identity = createHash(\"sha256\")\n .update(JSON.stringify([namespace, policy, operation, scope]))\n .digest(\"hex\")\n return `caracal:v1:{${identity}}:${suffix}`\n}\n","// ---------------------------------------------------------------------------\n// Versioned atomic operations, internal to the Redis policy capabilities.\n// ---------------------------------------------------------------------------\n\n// ---------------------------------------------------------------------------\n// Bulkhead scripts\n// ---------------------------------------------------------------------------\n\nexport const leaseV1 = `\nlocal action = ARGV[1]\nlocal token = ARGV[2]\nlocal ttl = tonumber(ARGV[3])\nlocal limit = tonumber(ARGV[4])\nlocal time = redis.call('TIME')\nlocal now = time[1] * 1000 + math.floor(time[2] / 1000)\nredis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now)\nlocal existing = redis.call('ZSCORE', KEYS[1], token)\nif action == 'release' then\n return redis.call('ZREM', KEYS[1], token)\nend\nif action == 'renew' and not existing then return 0 end\nif action == 'acquire' and existing then return 1 end\nif action == 'acquire' and redis.call('ZCARD', KEYS[1]) >= limit then return 0 end\nredis.call('ZADD', KEYS[1], now + ttl, token)\nlocal latest = redis.call('ZREVRANGE', KEYS[1], 0, 0, 'WITHSCORES')\nredis.call('PEXPIREAT', KEYS[1], math.ceil(tonumber(latest[2])))\nreturn 1\n`\n\n/** Returns the decision and occupancy from the same atomic transition. */\nexport const bulkheadLeaseV1 = `local function transition()\\n${leaseV1}\\nend\\nlocal allowed = transition()\\nreturn {allowed, redis.call('ZCARD', KEYS[1])}`\n\n// ---------------------------------------------------------------------------\n// Circuit-breaker scripts\n//\n// Return arrays use fixed positions so the TypeScript caller can validate\n// and decode without field names (which Lua/Redis cannot return).\n//\n// stateCode encoding: 0=closed 1=open 2=half-open\n// ---------------------------------------------------------------------------\n\n/**\n * Record one attempt outcome into the distributed sliding window.\n * Atomically opens the breaker when the failure ratio meets the threshold.\n *\n * KEYS[1] = breaker state hash (:breaker)\n * KEYS[2] = observations sorted set (:observations)\n *\n * ARGV[1] = outcome \"success\" | \"failure\"\n * ARGV[2] = expectedGeneration integer\n * ARGV[3] = windowTtlMs observation retention window in ms\n * ARGV[4] = minimumThroughput min observations before opening\n * ARGV[5] = failureThresholdNum failure threshold × 1000 (e.g. 500 = 0.5).\n * The policy rejects thresholds that would round\n * to 0 or to 1000 before they reach this script\n * (see assertResolvableThreshold in core).\n * ARGV[6] = windowSize max observations retained by count\n * ARGV[7] = openMs how long to stay OPEN; closed hash TTL = openMs×2\n * ARGV[8] = uuid unique string for member deduplication\n *\n * Returns: {status, stateCode, generation, windowTotal, windowFailures}\n * status 0 = stale (dropped; generation or state mismatch)\n * status 1 = observed, no transition (remained closed)\n * status 2 = observed, breaker opened (stateCode=1, generation incremented)\n *\n * TTL policy:\n * OPEN state → PERSIST (no expiry). A missing key is treated as closed by\n * all scripts, so expiring an open breaker would silently admit\n * unrestricted traffic and reset the generation counter.\n * CLOSED state → max(openMs×2, windowTtlMs) TTL for eventual cleanup of idle\n * scopes. Expiring a closed key is safe *only* because the TTL\n * outlives the observation window: the window is scoped by an\n * epoch, and an epoch that outlives its members is what keeps\n * cleanup from resurrecting them.\n *\n * Epochs:\n * `generation` increments on every transition, and a brand new value is minted\n * whenever the state hash has to be recreated while observation members from a\n * previous epoch are still present (state lost to eviction or admin cleanup).\n * Window membership is decided by comparing the stored epoch, so members from\n * a superseded epoch can never be counted again, and an attempt holding a\n * pre-loss generation can never pass the staleness check. A scope that has\n * never been observed keeps generation 0.\n */\nexport const breakerObserveV1 = `\nlocal outcome = ARGV[1]\nlocal expectGen = tonumber(ARGV[2])\nlocal windowTtl = tonumber(ARGV[3])\nlocal minTP = tonumber(ARGV[4])\nlocal threshNum = tonumber(ARGV[5])\nlocal windowSize = tonumber(ARGV[6])\nlocal openMs = tonumber(ARGV[7])\nlocal uuid = ARGV[8]\nlocal t = redis.call('TIME')\nlocal now = t[1] * 1000 + math.floor(t[2] / 1000)\nlocal f = redis.call('HMGET', KEYS[1], 'state', 'generation')\nlocal state = f[1] or 'closed'\nlocal gen = tonumber(f[2]) or 0\nif state ~= 'closed' then\n local sc = (state == 'open' and 1) or (state == 'half-open' and 2) or 0\n return {0, sc, gen, 0, 0}\nend\nif redis.call('EXISTS', KEYS[1]) == 0 then\n -- No live state hash: a scope we have never seen, or a hash that was lost\n -- while its window survived.\n if expectGen ~= 0 then\n -- The caller holds a generation this key cannot confirm.\n return {0, 0, 0, 0, 0}\n end\n if redis.call('EXISTS', KEYS[2]) == 1 then\n -- Members from a superseded epoch are still here. Mint an epoch that has\n -- never been used for this key (derived from the observation's own uuid, so\n -- no clock is involved) instead of restarting at a value those members\n -- would match.\n --\n -- Bounded to 11 hex digits (44 bits, <= 14 decimal digits) so the value\n -- prints exactly with stock Lua number formatting: the epoch is read back\n -- from the hash as a decimal string by both this script and the client, and\n -- a larger value could be written in scientific notation and break the\n -- round-trip.\n gen = tonumber(string.sub(redis.sha1hex(uuid), 1, 11), 16)\n if not gen or gen == 0 then gen = 1 end\n else\n gen = 0\n end\n redis.call('HSET', KEYS[1], 'state', 'closed', 'generation', gen,\n 'openedAt', 0, 'probeCount', 0, 'probeSuccesses', 0)\nelseif gen ~= expectGen then\n return {0, 0, gen, 0, 0}\nend\nredis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', now - windowTtl)\nlocal member = tostring(gen) .. ':' .. uuid .. ':' .. outcome\nredis.call('ZADD', KEYS[2], 'NX', now, member)\nlocal cnt = redis.call('ZCARD', KEYS[2])\nif cnt > windowSize then\n redis.call('ZREMRANGEBYRANK', KEYS[2], 0, cnt - windowSize - 1)\nend\nredis.call('PEXPIREAT', KEYS[2], now + windowTtl)\nlocal all = redis.call('ZRANGE', KEYS[2], 0, -1)\nlocal prefix = tostring(gen) .. ':'\nlocal wTotal = 0\nlocal wFail = 0\nfor _, m in ipairs(all) do\n if string.sub(m, 1, #prefix) == prefix then\n wTotal = wTotal + 1\n if string.sub(m, -8) == ':failure' then wFail = wFail + 1 end\n end\nend\nif wTotal >= minTP and wFail * 1000 >= threshNum * wTotal then\n gen = gen + 1\n redis.call('HSET', KEYS[1], 'state', 'open', 'generation', gen,\n 'openedAt', now, 'probeCount', 0, 'probeSuccesses', 0)\n -- OPEN state must never expire: a missing key is treated as closed,\n -- which would silently admit unrestricted traffic and reset the generation.\n redis.call('PERSIST', KEYS[1])\n return {2, 1, gen, wTotal, wFail}\nend\nif redis.call('EXISTS', KEYS[1]) == 1 then\n -- Still CLOSED. The cleanup TTL must not expire the state before the window\n -- it governs, otherwise retained members would survive their epoch.\n redis.call('PEXPIRE', KEYS[1], math.max(openMs * 2, windowTtl))\nend\nreturn {1, 0, gen, wTotal, wFail}\n`\n\n/**\n * Admit a probe attempt. Transitions OPEN→HALF_OPEN when openMs has elapsed,\n * then atomically allocates a probe token slot.\n *\n * KEYS[1] = breaker state hash (:breaker)\n * KEYS[2] = probe tokens sorted set (:probes)\n *\n * ARGV[1] = probeToken unique UUID for this probe attempt\n * ARGV[2] = openMs determines OPEN→HALF_OPEN transition and TTL\n * ARGV[3] = halfOpenProbes maximum concurrent probe tokens\n * ARGV[4] = probeLeaseTtlMs probe token TTL in ms\n *\n * Returns: {status, stateCode, generation, probeCount, transitioned}\n * status 0 = rejected\n * stateCode 0 = breaker already CLOSED (no probe needed)\n * stateCode 1 = still OPEN (openMs not yet elapsed)\n * stateCode 2 = HALF_OPEN but probe limit reached\n * status 1 = admitted (probe token stored)\n * transitioned 1 = OPEN→HALF_OPEN transition happened in this call\n * transitioned 0 = was already HALF_OPEN\n */\nexport const breakerAdmitProbeV1 = `\nlocal probeToken = ARGV[1]\nlocal openMs = tonumber(ARGV[2])\nlocal maxProbes = tonumber(ARGV[3])\nlocal probeLeaseTtl = tonumber(ARGV[4])\nlocal t = redis.call('TIME')\nlocal now = t[1] * 1000 + math.floor(t[2] / 1000)\nlocal f = redis.call('HMGET', KEYS[1], 'state', 'generation', 'openedAt',\n 'probeCount', 'probeSuccesses')\nlocal state = f[1] or 'closed'\nlocal gen = tonumber(f[2]) or 0\nlocal openedAt = tonumber(f[3]) or 0\nlocal transitioned = 0\nif state == 'closed' then\n return {0, 0, gen, 0, 0}\nend\nif state == 'open' then\n if now - openedAt < openMs then\n return {0, 1, gen, 0, 0}\n end\n state = 'half-open'\n transitioned = 1\n redis.call('HSET', KEYS[1], 'state', 'half-open', 'probeSuccesses', 0, 'probeCount', 0)\n -- OPEN/HALF_OPEN must never expire; PERSIST removes any prior cleanup TTL.\n redis.call('PERSIST', KEYS[1])\n -- Tokens from the superseded recovery window must not consume slots in this\n -- one; their settle is dropped by the generation check anyway.\n redis.call('DEL', KEYS[2])\nend\nredis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', now)\nlocal activeProbes = redis.call('ZCARD', KEYS[2])\nif activeProbes >= maxProbes then\n return {0, 2, gen, activeProbes, transitioned}\nend\nredis.call('ZADD', KEYS[2], 'NX', now + probeLeaseTtl, probeToken)\nactiveProbes = activeProbes + 1\nredis.call('HSET', KEYS[1], 'probeCount', activeProbes)\n-- State hash must not expire while recovery probes are in flight.\nredis.call('PERSIST', KEYS[1])\n-- Probes sorted set expires naturally once the last lease elapses.\nredis.call('PEXPIREAT', KEYS[2], now + probeLeaseTtl + 1000)\nreturn {1, 2, gen, activeProbes, transitioned}\n`\n\n/**\n * Record a probe attempt outcome. Removes the probe token and applies the\n * result: failure re-opens; sufficient successes close the breaker.\n *\n * KEYS[1] = breaker state hash (:breaker)\n * KEYS[2] = probe tokens sorted set (:probes)\n *\n * ARGV[1] = probeToken the token issued by admitProbe\n * ARGV[2] = outcome \"success\" | \"failure\" | \"ignored\", where\n * \"ignored\" releases the slot without recording\n * an outcome or advancing recovery\n * ARGV[3] = expectedGeneration generation captured at admission time\n * ARGV[4] = halfOpenSuccesses consecutive probe successes needed to close\n * ARGV[5] = openMs how long the breaker stays OPEN (TTL floor)\n * ARGV[6] = windowTtlMs observation retention (TTL floor): the CLOSED\n * hash must outlive the window it governs\n *\n * Returns: {status, stateCode, generation}\n * status 0 = stale (token missing, lease elapsed, or generation mismatch;\n * the result is dropped and any consumed token is released)\n * status 1 = settled, no state transition (still HALF_OPEN; an \"ignored\"\n * outcome only releases the probe slot)\n * status 2 = settled, state transition occurred\n * stateCode 0 = transitioned to CLOSED\n * stateCode 1 = transitioned back to OPEN (probe failure)\n *\n * A token carries its own deadline as its sorted-set score. When the lease has\n * elapsed the slot is already recoverable - admitProbe prunes expired tokens\n * and re-issues the slot - so the result must not count even while the member is\n * still present. The deadline is therefore checked under the same atomic call.\n */\nexport const breakerSettleProbeV1 = `\nlocal probeToken = ARGV[1]\nlocal outcome = ARGV[2]\nlocal expectGen = tonumber(ARGV[3])\nlocal halfOpenSucc = tonumber(ARGV[4])\nlocal openMs = tonumber(ARGV[5])\nlocal windowTtl = tonumber(ARGV[6])\nlocal t = redis.call('TIME')\nlocal now = t[1] * 1000 + math.floor(t[2] / 1000)\nlocal f = redis.call('HMGET', KEYS[1], 'state', 'generation', 'probeSuccesses', 'probeCount')\nlocal state = f[1] or 'closed'\nlocal gen = tonumber(f[2]) or 0\nlocal probeSucc = tonumber(f[3]) or 0\nlocal probeCnt = tonumber(f[4]) or 0\nlocal sc = (state == 'open' and 1) or (state == 'half-open' and 2) or 0\nlocal deadline = redis.call('ZSCORE', KEYS[2], probeToken)\nif not deadline then\n -- Never admitted, already settled, or dropped with its recovery window.\n return {0, sc, gen}\nend\nredis.call('ZREM', KEYS[2], probeToken)\nif tonumber(deadline) <= now then\n -- The lease elapsed while the probe was still running: its slot was\n -- recoverable, so the result cannot be counted. Release the slot it still\n -- occupied so the probe count keeps matching the token set.\n probeCnt = math.max(0, probeCnt - 1)\n redis.call('HSET', KEYS[1], 'probeCount', probeCnt)\n return {0, sc, gen}\nend\nif state ~= 'half-open' or gen ~= expectGen then\n return {0, sc, gen}\nend\nprobeCnt = math.max(0, probeCnt - 1)\nif outcome == 'ignored' then\n -- The result was not recorded (the classifier ignored it), but the slot the\n -- probe held must still be released: otherwise the recovery window stalls\n -- until the probe lease elapses instead of letting the next probe through.\n -- The local breaker releases its slot immediately, and this matches it: no\n -- success counter advances and the state does not transition.\n redis.call('HSET', KEYS[1], 'probeCount', probeCnt)\n -- Still HALF_OPEN: must not expire while recovery is in progress.\n redis.call('PERSIST', KEYS[1])\n return {1, 2, gen}\nend\nif outcome == 'failure' then\n gen = gen + 1\n redis.call('HSET', KEYS[1], 'state', 'open', 'generation', gen,\n 'openedAt', now, 'probeCount', 0, 'probeSuccesses', 0)\n -- Re-opened: OPEN must not expire (see breakerObserveV1 TTL policy).\n redis.call('PERSIST', KEYS[1])\n -- In-flight probes belong to the dead generation; free their slots so the\n -- next recovery window starts with full capacity.\n redis.call('DEL', KEYS[2])\n return {2, 1, gen}\nend\nprobeSucc = probeSucc + 1\nif probeSucc >= halfOpenSucc then\n gen = gen + 1\n redis.call('HSET', KEYS[1], 'state', 'closed', 'generation', gen,\n 'probeCount', 0, 'probeSuccesses', 0)\n -- Newly CLOSED: expire for cleanup, but never before the window it governs.\n redis.call('PEXPIRE', KEYS[1], math.max(openMs * 2, windowTtl))\n redis.call('DEL', KEYS[2])\n return {2, 0, gen}\nend\nredis.call('HSET', KEYS[1], 'probeSuccesses', probeSucc, 'probeCount', probeCnt)\n-- Still HALF_OPEN: must not expire while recovery is in progress.\nredis.call('PERSIST', KEYS[1])\nreturn {1, 2, gen}\n`\n","import type { BulkheadCoordinator } from \"../../core/bulkhead.js\"\nimport { CoordinatorUnavailableError } from \"./client.js\"\nimport { evalScript } from \"./eval-script.js\"\nimport { coordinationKey } from \"./keys.js\"\nimport type { RedisScriptClient } from \"./leases.js\"\nimport { bulkheadLeaseV1 } from \"./scripts.js\"\nexport function redisCoordinator(\n client: RedisScriptClient,\n options: { namespace: string },\n): BulkheadCoordinator {\n const namespace = options.namespace\n coordinationKey(namespace, \"bulkhead\", \"validate\", \"validate\")\n const coordinator: BulkheadCoordinator = {\n async command(identity, action, token, leaseMs, limit) {\n if (\n ![\"acquire\", \"renew\", \"release\"].includes(action) ||\n typeof token !== \"string\" ||\n !token ||\n token.length > 256\n )\n throw new TypeError(\"Invalid bulkhead lease action or token\")\n if (\n ![leaseMs, limit].every(\n (value) => Number.isSafeInteger(value) && value > 0,\n ) ||\n leaseMs > 86400000\n )\n throw new RangeError(\"Invalid bulkhead lease duration or limit\")\n const key = coordinationKey(\n namespace,\n `bulkhead:${identity.name}`,\n identity.operation,\n identity.scope,\n )\n try {\n const result = await evalScript(\n client,\n bulkheadLeaseV1,\n 1,\n key,\n action,\n token,\n leaseMs,\n limit,\n )\n if (\n !Array.isArray(result) ||\n result.length !== 2 ||\n ![0, 1].includes(result[0]) ||\n !Number.isSafeInteger(result[1]) ||\n result[1] < 0\n )\n throw new Error(\"Invalid bulkhead reply\")\n return { allowed: result[0] === 1, occupancy: result[1] as number }\n } catch (error) {\n throw new CoordinatorUnavailableError(error)\n }\n },\n }\n return Object.freeze(coordinator)\n}\n","import type {\n AdmitProbeResult,\n BreakerCoordinator,\n BreakerIdentity,\n BreakerState,\n ObserveResult,\n SettleProbeResult,\n} from \"../../core/circuit-breaker.js\"\nimport { CoordinatorUnavailableError } from \"./client.js\"\nimport { evalScript } from \"./eval-script.js\"\nimport { coordinationKey } from \"./keys.js\"\nimport type { RedisScriptClient } from \"./leases.js\"\nimport {\n breakerAdmitProbeV1,\n breakerObserveV1,\n breakerSettleProbeV1,\n} from \"./scripts.js\"\n\n// ---------------------------------------------------------------------------\n// Key helpers\n// ---------------------------------------------------------------------------\n\nfunction breakerKeys(\n namespace: string,\n identity: BreakerIdentity,\n): [hashKey: string, obsKey: string, probeKey: string] {\n const policy = `breaker:${identity.name}`\n const hash = coordinationKey(\n namespace,\n policy,\n identity.operation,\n identity.scope,\n \"breaker\",\n )\n const obs = coordinationKey(\n namespace,\n policy,\n identity.operation,\n identity.scope,\n \"observations\",\n )\n const prob = coordinationKey(\n namespace,\n policy,\n identity.operation,\n identity.scope,\n \"probes\",\n )\n return [hash, obs, prob]\n}\n\n// ---------------------------------------------------------------------------\n// Reply validation helpers\n// ---------------------------------------------------------------------------\n\nfunction assertArray(reply: unknown, minLen: number, label: string): unknown[] {\n if (\n !Array.isArray(reply) ||\n reply.length < minLen ||\n reply.some((v) => typeof v !== \"number\" || !Number.isSafeInteger(v))\n ) {\n throw new Error(\n `Invalid ${label} reply from Redis: ${JSON.stringify(reply)}`,\n )\n }\n return reply as unknown[]\n}\n\nconst STATE_CODES: BreakerState[] = [\"closed\", \"open\", \"half-open\"]\n\nfunction decodeState(code: number): BreakerState {\n const s = STATE_CODES[code]\n if (!s) throw new Error(`Unknown breaker state code: ${code}`)\n return s\n}\n\n// ---------------------------------------------------------------------------\n// Factory\n// ---------------------------------------------------------------------------\n\n/**\n * Policy-specific Redis coordinator for `circuitBreaker.distributed()`.\n *\n * The caller owns `client` and must connect/disconnect it independently.\n * `namespace` is prepended to every key; use a per-service, per-environment\n * value to avoid cross-deployment state collisions.\n */\nexport function redisCircuitBreakerCoordinator(\n client: RedisScriptClient,\n options: { readonly namespace: string },\n): BreakerCoordinator {\n const { namespace } = options\n // Validate namespace eagerly (coordinationKey throws on bad input).\n coordinationKey(\n namespace,\n \"breaker:validate\",\n \"validate\",\n \"validate\",\n \"breaker\",\n )\n\n const coordinator: BreakerCoordinator = {\n async readState(identity) {\n const [hashKey] = breakerKeys(namespace, identity)\n try {\n const fields = await client.hmget(hashKey, \"state\", \"generation\")\n const rawState = fields[0]\n if (!rawState) return null\n const state = rawState as BreakerState\n if (!STATE_CODES.includes(state))\n throw new Error(`Unknown breaker state: ${rawState}`)\n const generation = parseInt(fields[1] ?? \"0\", 10)\n return { state, generation }\n } catch (error) {\n throw new CoordinatorUnavailableError(error)\n }\n },\n\n async observe(identity, params) {\n const [hashKey, obsKey] = breakerKeys(namespace, identity)\n const {\n generation,\n outcome,\n uuid,\n windowTtlMs,\n minimumThroughput,\n failureThresholdNumerator,\n windowSize,\n openMs,\n } = params\n try {\n const reply = await evalScript(\n client,\n breakerObserveV1,\n 2,\n hashKey,\n obsKey,\n outcome,\n generation,\n windowTtlMs,\n minimumThroughput,\n failureThresholdNumerator,\n windowSize,\n openMs,\n uuid,\n )\n const r = assertArray(reply, 5, \"breakerObserveV1\") as number[]\n const [status, , newGen, windowTotal, windowFailures] = r\n if (status === 0) {\n return {\n type: \"stale\",\n currentGeneration: newGen,\n } satisfies ObserveResult\n }\n if (status === 2) {\n return {\n type: \"opened\",\n newGeneration: newGen,\n windowTotal,\n windowFailures,\n } satisfies ObserveResult\n }\n return {\n type: \"observed\",\n generation: newGen,\n windowTotal,\n windowFailures,\n } satisfies ObserveResult\n } catch (error) {\n throw new CoordinatorUnavailableError(error)\n }\n },\n\n async admitProbe(identity, params) {\n const [hashKey, , probeKey] = breakerKeys(namespace, identity)\n const { probeToken, openMs, halfOpenProbes, probeLeaseTtlMs } = params\n try {\n const reply = await evalScript(\n client,\n breakerAdmitProbeV1,\n 2,\n hashKey,\n probeKey,\n probeToken,\n openMs,\n halfOpenProbes,\n probeLeaseTtlMs,\n )\n const r = assertArray(reply, 5, \"breakerAdmitProbeV1\") as number[]\n const [status, stateCode, gen, probeCount, transitioned] = r\n if (status === 0) {\n const reason =\n stateCode === 0\n ? \"closed\"\n : stateCode === 1\n ? \"open\"\n : \"probe-limit\"\n return {\n type: \"rejected\",\n reason,\n generation: gen,\n } satisfies AdmitProbeResult\n }\n return {\n type: \"admitted\",\n generation: gen,\n probeCount,\n stateChanged: transitioned === 1,\n } satisfies AdmitProbeResult\n } catch (error) {\n throw new CoordinatorUnavailableError(error)\n }\n },\n\n async settleProbe(identity, params) {\n const [hashKey, , probeKey] = breakerKeys(namespace, identity)\n const {\n probeToken,\n outcome,\n generation,\n halfOpenSuccesses,\n openMs,\n windowTtlMs,\n } = params\n try {\n const reply = await evalScript(\n client,\n breakerSettleProbeV1,\n 2,\n hashKey,\n probeKey,\n probeToken,\n outcome,\n generation,\n halfOpenSuccesses,\n openMs,\n // Floor for the CLOSED cleanup TTL; older callers that do not pass it\n // keep the historical openMs x 2 behaviour.\n windowTtlMs ?? openMs * 2,\n )\n const r = assertArray(reply, 3, \"breakerSettleProbeV1\") as number[]\n const [status, stateCode, newGen] = r\n if (status === 0) {\n return {\n type: \"stale\",\n generation: newGen,\n } satisfies SettleProbeResult\n }\n const state = decodeState(stateCode)\n if (status === 2) {\n return {\n type: \"transitioned\",\n newState: state,\n newGeneration: newGen,\n previousState: \"half-open\",\n } satisfies SettleProbeResult\n }\n return {\n type: \"settled\",\n state,\n generation: newGen,\n } satisfies SettleProbeResult\n } catch (error) {\n throw new CoordinatorUnavailableError(error)\n }\n },\n }\n\n return Object.freeze(coordinator)\n}\n"]}
@@ -1,4 +1,4 @@
1
- import { h as Outcome, O as OperationCapabilities, e as ExecutionMetadata, P as Policy } from './types-Tf9T76C7.js';
1
+ import { h as Outcome, O as OperationCapabilities, e as ExecutionMetadata, P as Policy } from './types-C-Ml-MKp.js';
2
2
 
3
3
  /**
4
4
  * Settled-attempt information passed to a custom `delay` function.
@@ -1,4 +1,4 @@
1
- import { O as OperationCapabilities, h as Outcome, C as Classification, A as Adapter } from '../types-Tf9T76C7.js';
1
+ import { O as OperationCapabilities, h as Outcome, C as Classification, A as Adapter } from '../types-C-Ml-MKp.js';
2
2
 
3
3
  interface AdapterContractSuccess<Args, Result> {
4
4
  readonly args: Args;
@@ -1,4 +1,5 @@
1
- import { operation } from '../chunk-5CXDW7W6.js';
1
+ import { operation } from '../chunk-JV2OLYOF.js';
2
+ import '../chunk-FD7IZJKY.js';
2
3
 
3
4
  // test/harness/adapter-contract.ts
4
5
  function assertEqual(actual, expected, message) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../test/harness/adapter-contract.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import { operation } from \"../../src/core/operation.js\"\nimport type {\n Adapter,\n Classification,\n OperationCapabilities,\n OperationEvent,\n Outcome,\n} from \"../../src/core/types.js\"\n\nexport interface AdapterContractSuccess<Args, Result> {\n readonly args: Args\n readonly assertResult?: (result: Result) => void | Promise<void>\n}\n\nexport interface AdapterContractCapabilityCase<Args> {\n readonly args: Args\n readonly expected: OperationCapabilities\n}\n\nexport interface AdapterContractClassificationCase<Result> {\n readonly outcome: Outcome<Result>\n readonly expected: Classification\n}\n\nexport interface AdapterContractAbortCase<Args> {\n readonly args: Args\n readonly verify: (controls: {\n readonly controller: AbortController\n readonly execute: () => Promise<unknown>\n }) => void | Promise<void>\n}\n\nexport interface AdapterContractOptions<Args, Result> {\n readonly name: string\n readonly adapter: Adapter<Args, Result>\n readonly success: AdapterContractSuccess<Args, Result>\n readonly capabilities: readonly AdapterContractCapabilityCase<Args>[]\n readonly classifications?: readonly AdapterContractClassificationCase<Result>[]\n readonly abort?: AdapterContractAbortCase<Args>\n}\n\nexport interface AdapterContractCheck {\n readonly name: string\n run(): Promise<void>\n}\n\nexport interface AdapterContractSuite {\n readonly name: string\n readonly checks: readonly AdapterContractCheck[]\n}\n\nfunction assertEqual<T>(actual: T, expected: T, message: string): void {\n if (!Object.is(actual, expected)) {\n throw new Error(\n `${message}: expected ${String(expected)}, received ${String(actual)}`,\n )\n }\n}\n\nfunction assertLifecycle(events: readonly OperationEvent[]): void {\n const types = events.map((event) => event.type)\n const expected = [\n \"execution.started\",\n \"attempt.started\",\n \"attempt.settled\",\n \"execution.settled\",\n ]\n if (\n types.length !== expected.length ||\n types.some((type, index) => type !== expected[index])\n ) {\n throw new Error(\n `expected operation lifecycle ${expected.join(\" -> \")}; received ${types.join(\" -> \")}`,\n )\n }\n}\n\n/**\n * Returns runner-agnostic checks for a third-party adapter. Register each\n * check with the application's test runner; this module imports no test runner.\n */\nexport function defineAdapterContractSuite<Args, Result>(\n options: AdapterContractOptions<Args, Result>,\n): AdapterContractSuite {\n const checks: AdapterContractCheck[] = options.capabilities.map(\n (capabilityCase, index) => ({\n name: `${options.name}: capabilities ${index + 1}`,\n async run(): Promise<void> {\n const actual = options.adapter.capabilities(capabilityCase.args)\n assertEqual(\n actual.abort,\n capabilityCase.expected.abort,\n \"abort capability\",\n )\n assertEqual(\n actual.replay,\n capabilityCase.expected.replay,\n \"replay capability\",\n )\n },\n }),\n )\n\n checks.push({\n name: `${options.name}: successful operation lifecycle`,\n async run(): Promise<void> {\n const events: OperationEvent[] = []\n const subject = operation({\n name: `adapter-contract:${options.name}`,\n adapter: options.adapter,\n events: { emit: (event) => events.push(event) },\n })\n const result = await subject.execute(options.success.args, {\n executionId: \"adapter-contract\",\n })\n await options.success.assertResult?.(result)\n assertLifecycle(events)\n },\n })\n\n for (const [index, classificationCase] of (\n options.classifications ?? []\n ).entries()) {\n checks.push({\n name: `${options.name}: classification ${index + 1}`,\n async run(): Promise<void> {\n const actual =\n options.adapter.classify?.(classificationCase.outcome) ??\n (classificationCase.outcome.status === \"success\"\n ? \"success\"\n : \"failure\")\n assertEqual(\n actual,\n classificationCase.expected,\n \"outcome classification\",\n )\n },\n })\n }\n\n if (options.abort !== undefined) {\n checks.push({\n name: `${options.name}: abort behavior`,\n async run(): Promise<void> {\n const controller = new AbortController()\n const subject = operation({\n name: `adapter-contract:${options.name}`,\n adapter: options.adapter,\n })\n await options.abort?.verify({\n controller,\n execute: () =>\n subject.execute(options.abort?.args as Args, {\n signal: controller.signal,\n }),\n })\n },\n })\n }\n\n return Object.freeze({ name: options.name, checks: Object.freeze(checks) })\n}\n\nexport async function runAdapterContractSuite(\n suite: AdapterContractSuite,\n): Promise<void> {\n for (const check of suite.checks) {\n await check.run()\n }\n}\n"]}
1
+ {"version":3,"sources":["../../test/harness/adapter-contract.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import { operation } from \"../../src/core/operation.js\"\nimport type {\n Adapter,\n Classification,\n OperationCapabilities,\n OperationEvent,\n Outcome,\n} from \"../../src/core/types.js\"\n\nexport interface AdapterContractSuccess<Args, Result> {\n readonly args: Args\n readonly assertResult?: (result: Result) => void | Promise<void>\n}\n\nexport interface AdapterContractCapabilityCase<Args> {\n readonly args: Args\n readonly expected: OperationCapabilities\n}\n\nexport interface AdapterContractClassificationCase<Result> {\n readonly outcome: Outcome<Result>\n readonly expected: Classification\n}\n\nexport interface AdapterContractAbortCase<Args> {\n readonly args: Args\n readonly verify: (controls: {\n readonly controller: AbortController\n readonly execute: () => Promise<unknown>\n }) => void | Promise<void>\n}\n\nexport interface AdapterContractOptions<Args, Result> {\n readonly name: string\n readonly adapter: Adapter<Args, Result>\n readonly success: AdapterContractSuccess<Args, Result>\n readonly capabilities: readonly AdapterContractCapabilityCase<Args>[]\n readonly classifications?: readonly AdapterContractClassificationCase<Result>[]\n readonly abort?: AdapterContractAbortCase<Args>\n}\n\nexport interface AdapterContractCheck {\n readonly name: string\n run(): Promise<void>\n}\n\nexport interface AdapterContractSuite {\n readonly name: string\n readonly checks: readonly AdapterContractCheck[]\n}\n\nfunction assertEqual<T>(actual: T, expected: T, message: string): void {\n if (!Object.is(actual, expected)) {\n throw new Error(\n `${message}: expected ${String(expected)}, received ${String(actual)}`,\n )\n }\n}\n\nfunction assertLifecycle(events: readonly OperationEvent[]): void {\n const types = events.map((event) => event.type)\n const expected = [\n \"execution.started\",\n \"attempt.started\",\n \"attempt.settled\",\n \"execution.settled\",\n ]\n if (\n types.length !== expected.length ||\n types.some((type, index) => type !== expected[index])\n ) {\n throw new Error(\n `expected operation lifecycle ${expected.join(\" -> \")}; received ${types.join(\" -> \")}`,\n )\n }\n}\n\n/**\n * Returns runner-agnostic checks for a third-party adapter. Register each\n * check with the application's test runner; this module imports no test runner.\n */\nexport function defineAdapterContractSuite<Args, Result>(\n options: AdapterContractOptions<Args, Result>,\n): AdapterContractSuite {\n const checks: AdapterContractCheck[] = options.capabilities.map(\n (capabilityCase, index) => ({\n name: `${options.name}: capabilities ${index + 1}`,\n async run(): Promise<void> {\n const actual = options.adapter.capabilities(capabilityCase.args)\n assertEqual(\n actual.abort,\n capabilityCase.expected.abort,\n \"abort capability\",\n )\n assertEqual(\n actual.replay,\n capabilityCase.expected.replay,\n \"replay capability\",\n )\n },\n }),\n )\n\n checks.push({\n name: `${options.name}: successful operation lifecycle`,\n async run(): Promise<void> {\n const events: OperationEvent[] = []\n const subject = operation({\n name: `adapter-contract:${options.name}`,\n adapter: options.adapter,\n events: { emit: (event) => events.push(event) },\n })\n const result = await subject.execute(options.success.args, {\n executionId: \"adapter-contract\",\n })\n await options.success.assertResult?.(result)\n assertLifecycle(events)\n },\n })\n\n for (const [index, classificationCase] of (\n options.classifications ?? []\n ).entries()) {\n checks.push({\n name: `${options.name}: classification ${index + 1}`,\n async run(): Promise<void> {\n const actual =\n options.adapter.classify?.(classificationCase.outcome) ??\n (classificationCase.outcome.status === \"success\"\n ? \"success\"\n : \"failure\")\n assertEqual(\n actual,\n classificationCase.expected,\n \"outcome classification\",\n )\n },\n })\n }\n\n if (options.abort !== undefined) {\n checks.push({\n name: `${options.name}: abort behavior`,\n async run(): Promise<void> {\n const controller = new AbortController()\n const subject = operation({\n name: `adapter-contract:${options.name}`,\n adapter: options.adapter,\n })\n await options.abort?.verify({\n controller,\n execute: () =>\n subject.execute(options.abort?.args as Args, {\n signal: controller.signal,\n }),\n })\n },\n })\n }\n\n return Object.freeze({ name: options.name, checks: Object.freeze(checks) })\n}\n\nexport async function runAdapterContractSuite(\n suite: AdapterContractSuite,\n): Promise<void> {\n for (const check of suite.checks) {\n await check.run()\n }\n}\n"]}
@@ -154,6 +154,13 @@ type OperationEvent = BreakerStateChangedEvent | BreakerRejectedEvent | BreakerO
154
154
  context: ExecutionContext;
155
155
  outcome: Outcome<undefined>;
156
156
  classification: Classification;
157
+ }> | Readonly<{
158
+ type: "retry.declined";
159
+ at: number;
160
+ context: ExecutionContext;
161
+ outcome: Outcome<undefined>;
162
+ classification: Classification;
163
+ reason: "replay-unsafe" | "not-retryable";
157
164
  }>;
158
165
  /** Output-only observability contract. Sinks cannot alter policy execution. */
159
166
  interface EventSink {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gkoos/caracal",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Scoped distributed resilience for asynchronous operations.",
5
5
  "keywords": [
6
6
  "resilience",
@@ -1,3 +1,4 @@
1
+ import { MAX_TIMER_MS } from "../../core/runtime.js"
1
2
  import type { RetryContext } from "../../core/retry.js"
2
3
 
3
4
  /**
@@ -34,7 +35,10 @@ function headersOf(value: unknown): Headers | undefined {
34
35
  * response-bearing error.
35
36
  *
36
37
  * Returns `undefined` when no usable header is present. Malformed values
37
- * are ignored rather than thrown.
38
+ * are ignored rather than thrown. Values above `MAX_TIMER_MS` are clamped,
39
+ * because `setTimeout` cannot schedule them and would silently fire almost
40
+ * immediately; `createRetryAfterDelay` applies the same ceiling to the wait it
41
+ * returns, so a delay composed from this parser stays schedulable.
38
42
  */
39
43
  export function retryAfterMs(
40
44
  context: RetryContext,
@@ -48,11 +52,15 @@ export function retryAfterMs(
48
52
 
49
53
  if (deltaSecondsPattern.test(value)) {
50
54
  const seconds = Number(value)
51
- return Number.isSafeInteger(seconds) ? seconds * 1000 : undefined
55
+ return Number.isSafeInteger(seconds)
56
+ ? Math.min(seconds * 1000, MAX_TIMER_MS)
57
+ : undefined
52
58
  }
53
59
 
54
60
  const timestamp = Date.parse(value)
55
- return Number.isNaN(timestamp) ? undefined : Math.max(0, timestamp - now)
61
+ return Number.isNaN(timestamp)
62
+ ? undefined
63
+ : Math.min(Math.max(0, timestamp - now), MAX_TIMER_MS)
56
64
  }
57
65
 
58
66
  /** Options for `createRetryAfterDelay`. */
@@ -1,4 +1,4 @@
1
- import { Cluster, Redis } from "ioredis"
1
+ import { Cluster, Redis, type RedisOptions } from "ioredis"
2
2
 
3
3
  export class CoordinatorUnavailableError extends Error {
4
4
  readonly coordination = "distributed"
@@ -54,19 +54,33 @@ export interface ClusterNode {
54
54
  * Call `connect()` before use and `disconnect()` at shutdown, matching the
55
55
  * standalone client contract.
56
56
  */
57
+ /**
58
+ * Redis Cluster client for a secured cluster: pass credentials and TLS via
59
+ * `connectionOptions`. The coordination safeguards (`lazyConnect`, no offline
60
+ * queue, no command replay, no per-request retries) are applied after them and
61
+ * cannot be overridden, because the coordinators depend on those semantics.
62
+ */
57
63
  export function createCoordinationClusterClient(
58
64
  nodes: ReadonlyArray<ClusterNode>,
59
65
  commandTimeout = 1000,
66
+ connectionOptions: RedisOptions = {},
60
67
  ): Cluster {
61
68
  if (!Number.isSafeInteger(commandTimeout) || commandTimeout < 1)
62
69
  throw new RangeError("commandTimeout must be a positive integer")
63
70
  if (!Array.isArray(nodes) || nodes.length === 0)
64
71
  throw new RangeError("nodes must be a non-empty array of { host, port }")
72
+ if (
73
+ typeof connectionOptions !== "object" ||
74
+ connectionOptions === null ||
75
+ Array.isArray(connectionOptions)
76
+ )
77
+ throw new TypeError("connectionOptions must be an ioredis options object")
65
78
  const cluster = new Cluster([...nodes], {
66
79
  lazyConnect: true,
67
80
  enableOfflineQueue: false,
68
81
  clusterRetryStrategy: (attempt) => Math.min(attempt * 50, 1000),
69
82
  redisOptions: {
83
+ ...connectionOptions,
70
84
  commandTimeout,
71
85
  connectTimeout: commandTimeout,
72
86
  maxRetriesPerRequest: 0,
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto"
2
2
  import {
3
+ MAX_TIMER_MS,
3
4
  admissionSignal,
4
5
  emitRuntimeEvent,
5
6
  withAdmissionSignal,
@@ -81,7 +82,7 @@ function local(options: LocalBulkheadOptions): Policy & {
81
82
  queue.limit < 1 ||
82
83
  !Number.isSafeInteger(queue.timeoutMs) ||
83
84
  queue.timeoutMs < 1 ||
84
- queue.timeoutMs > 2147483647)
85
+ queue.timeoutMs > MAX_TIMER_MS)
85
86
  )
86
87
  throw new RangeError("Invalid bounded queue")
87
88
  let occupancy = 0
@@ -275,13 +276,26 @@ function distributed(
275
276
  }
276
277
  }
277
278
  try {
278
- if (performance.now() >= deadline)
279
+ if (performance.now() >= deadline) {
280
+ // The permit was acquired, but its lease deadline passed before the
281
+ // call could start. Report it like any other rejection, so a sink
282
+ // sees the attempt instead of only the caller seeing the error.
283
+ event(
284
+ context,
285
+ "distributed",
286
+ name,
287
+ scope,
288
+ "rejected",
289
+ admitted.occupancy,
290
+ "admission-expired",
291
+ )
279
292
  throw new BulkheadRejectedError(
280
293
  "distributed",
281
294
  name,
282
295
  scope,
283
296
  "admission-expired",
284
297
  )
298
+ }
285
299
  admissionSignal(context)?.throwIfAborted()
286
300
  event(
287
301
  context,
@@ -4,6 +4,7 @@ import {
4
4
  admissionSignal,
5
5
  createClassifier,
6
6
  createExecutionContext,
7
+ emitToSink,
7
8
  } from "./runtime.js"
8
9
  import type {
9
10
  Adapter,
@@ -51,11 +52,7 @@ function normalizeSinks(events: EventSinks | undefined): readonly EventSink[] {
51
52
 
52
53
  function emit(sinks: readonly EventSink[], event: OperationEvent): void {
53
54
  for (const sink of sinks) {
54
- try {
55
- sink.emit(event)
56
- } catch {
57
- // Observability must not modify resilience execution.
58
- }
55
+ emitToSink(sink, event)
59
56
  }
60
57
  }
61
58
 
package/src/core/retry.ts CHANGED
@@ -1,4 +1,9 @@
1
- import { admissionSignal, emitRuntimeEvent, nextAttempt } from "./runtime.js"
1
+ import {
2
+ MAX_TIMER_MS,
3
+ admissionSignal,
4
+ emitRuntimeEvent,
5
+ nextAttempt,
6
+ } from "./runtime.js"
2
7
  import type {
3
8
  Classification,
4
9
  ExecutionContext,
@@ -71,6 +76,11 @@ function delayFor(
71
76
  if (!Number.isFinite(delay) || delay < 0) {
72
77
  throw new RangeError("retry delay must be a finite non-negative number")
73
78
  }
79
+ if (delay > MAX_TIMER_MS) {
80
+ throw new RangeError(
81
+ `retry delay must not exceed ${MAX_TIMER_MS} ms, the largest delay setTimeout honours`,
82
+ )
83
+ }
74
84
 
75
85
  return delay
76
86
  }
@@ -124,6 +134,16 @@ export function retry(options: RetryOptions): Policy {
124
134
  if (!Number.isInteger(options.maxAttempts) || options.maxAttempts < 1) {
125
135
  throw new RangeError("maxAttempts must be a positive integer")
126
136
  }
137
+ if (
138
+ typeof options.delay === "number" &&
139
+ (!Number.isFinite(options.delay) ||
140
+ options.delay < 0 ||
141
+ options.delay > MAX_TIMER_MS)
142
+ ) {
143
+ throw new RangeError(
144
+ `retry delay must be a finite number within 0..${MAX_TIMER_MS} ms`,
145
+ )
146
+ }
127
147
 
128
148
  return Object.freeze({
129
149
  name: "retry",
@@ -144,6 +164,17 @@ export function retry(options: RetryOptions): Policy {
144
164
  outcomeClassification !== "retryable" ||
145
165
  context.capabilities.replay !== "safe"
146
166
  ) {
167
+ // Declining to retry is observable: without this, a call that was
168
+ // never retried looks identical to one with no retry policy.
169
+ emitRuntimeEvent(context, {
170
+ type: "retry.declined",
171
+ outcome: summarized(outcome),
172
+ classification: outcomeClassification,
173
+ reason:
174
+ context.capabilities.replay !== "safe"
175
+ ? "replay-unsafe"
176
+ : "not-retryable",
177
+ })
147
178
  return value
148
179
  }
149
180
 
@@ -170,11 +201,24 @@ export function retry(options: RetryOptions): Policy {
170
201
  const outcome: Outcome<Result> = { status: "failure", error }
171
202
  const outcomeClassification = classification(context, outcome)
172
203
 
204
+ if (admissionSignal(context)?.aborted) {
205
+ throw error
206
+ }
173
207
  if (
174
- admissionSignal(context)?.aborted ||
175
208
  context.capabilities.replay !== "safe" ||
176
209
  outcomeClassification !== "retryable"
177
210
  ) {
211
+ // Same event as the value path: the caller can tell a declined
212
+ // retry from a missing retry policy.
213
+ emitRuntimeEvent(context, {
214
+ type: "retry.declined",
215
+ outcome: summarized(outcome),
216
+ classification: outcomeClassification,
217
+ reason:
218
+ context.capabilities.replay !== "safe"
219
+ ? "replay-unsafe"
220
+ : "not-retryable",
221
+ })
178
222
  throw error
179
223
  }
180
224
 
@@ -94,6 +94,31 @@ export function withSignal(
94
94
  )
95
95
  }
96
96
 
97
+ /**
98
+ * Largest delay `setTimeout` honours. Anything above it is silently clamped to
99
+ * 1 ms by the platform, so accepting larger values would turn a long wait into
100
+ * an immediate one.
101
+ */
102
+ export const MAX_TIMER_MS = 2_147_483_647
103
+
104
+ /**
105
+ * Delivers one event to one sink, isolating execution from it. Sinks are
106
+ * fire-and-forget: a synchronous throw and a rejected promise are both dropped,
107
+ * so a failing sink can neither modify resilience execution nor surface as an
108
+ * unhandled rejection.
109
+ */
110
+ export function emitToSink(sink: EventSink, event: OperationEvent): void {
111
+ try {
112
+ const pending = sink.emit(event) as unknown
113
+ const thenable = pending as { catch?: unknown } | null | undefined
114
+ if (typeof thenable?.catch === "function") {
115
+ void (pending as Promise<unknown>).catch(() => {})
116
+ }
117
+ } catch {
118
+ // See above: a failing sink must never modify execution.
119
+ }
120
+ }
121
+
97
122
  export function emitRuntimeEvent(
98
123
  context: ExecutionContext,
99
124
  event: EventWithoutRuntimeFields,
@@ -102,11 +127,7 @@ export function emitRuntimeEvent(
102
127
  const fullEvent = { ...event, at: Date.now(), context } as OperationEvent
103
128
 
104
129
  for (const sink of sinks) {
105
- try {
106
- sink.emit(fullEvent)
107
- } catch {
108
- // Observability must not modify resilience execution.
109
- }
130
+ emitToSink(sink, fullEvent)
110
131
  }
111
132
  }
112
133
 
@@ -1,4 +1,9 @@
1
- import { emitRuntimeEvent, withAdmissionSignal, withSignal } from "./runtime.js"
1
+ import {
2
+ MAX_TIMER_MS,
3
+ emitRuntimeEvent,
4
+ withAdmissionSignal,
5
+ withSignal,
6
+ } from "./runtime.js"
2
7
  import type { ExecutionContext, Next, Policy } from "./types.js"
3
8
 
4
9
  export class TimeoutError extends Error {
@@ -31,6 +36,11 @@ export function timeout(options: TimeoutOptions): Policy {
31
36
  if (!Number.isFinite(options.ms) || options.ms <= 0) {
32
37
  throw new RangeError("timeout ms must be a finite positive number")
33
38
  }
39
+ if (options.ms > MAX_TIMER_MS) {
40
+ throw new RangeError(
41
+ `timeout ms must not exceed ${MAX_TIMER_MS} ms, the largest delay setTimeout honours`,
42
+ )
43
+ }
34
44
 
35
45
  return Object.freeze({
36
46
  name: "timeout",
package/src/core/types.ts CHANGED
@@ -190,6 +190,14 @@ export type OperationEvent =
190
190
  outcome: Outcome<undefined>
191
191
  classification: Classification
192
192
  }>
193
+ | Readonly<{
194
+ type: "retry.declined"
195
+ at: number
196
+ context: ExecutionContext
197
+ outcome: Outcome<undefined>
198
+ classification: Classification
199
+ reason: "replay-unsafe" | "not-retryable"
200
+ }>
193
201
 
194
202
  /** Output-only observability contract. Sinks cannot alter policy execution. */
195
203
  export interface EventSink {