@gkoos/caracal 0.1.0 → 0.1.1
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 +16 -5
- package/README.md +10 -0
- package/dist/{circuit-breaker-BSkcV0W_.d.ts → circuit-breaker-DHEz0YLV.d.ts} +6 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +51 -36
- package/dist/index.js.map +1 -1
- package/dist/redis.d.ts +1 -1
- package/dist/redis.js +11 -0
- package/dist/redis.js.map +1 -1
- package/package.json +9 -5
- package/src/coordination/redis/scripts.ts +20 -3
- package/src/core/bulkhead.ts +4 -1
- package/src/core/circuit-breaker.ts +81 -36
- package/test/harness/adapter-contract.ts +170 -0
- package/test/harness/index.ts +15 -0
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 * 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\"\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)\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 == '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":"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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"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gkoos/caracal",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Scoped distributed resilience for asynchronous operations.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"resilience",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"type": "module",
|
|
32
32
|
"sideEffects": false,
|
|
33
33
|
"engines": {
|
|
34
|
-
"node": ">=20"
|
|
34
|
+
"node": ">=20.3.0"
|
|
35
35
|
},
|
|
36
36
|
"exports": {
|
|
37
37
|
".": {
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"files": [
|
|
60
60
|
"dist",
|
|
61
61
|
"src",
|
|
62
|
+
"test/harness",
|
|
62
63
|
"README.md",
|
|
63
64
|
"CHANGELOG.md",
|
|
64
65
|
"LICENSE"
|
|
@@ -104,16 +105,16 @@
|
|
|
104
105
|
"@types/node": "^22.0.0",
|
|
105
106
|
"@types/pg": "^8.23.1",
|
|
106
107
|
"fast-check": "^4.0.0",
|
|
107
|
-
"ioredis": "^
|
|
108
|
+
"ioredis": "^6.0.0",
|
|
108
109
|
"pg": "^8.23.0",
|
|
109
110
|
"rimraf": "^6.0.0",
|
|
110
111
|
"tsup": "^8.0.0",
|
|
111
112
|
"tsx": "^4.23.13",
|
|
112
113
|
"typescript": "^5.0.0",
|
|
113
|
-
"vitest": "^
|
|
114
|
+
"vitest": "^4.1.11"
|
|
114
115
|
},
|
|
115
116
|
"peerDependencies": {
|
|
116
|
-
"ioredis": "^5.0.0",
|
|
117
|
+
"ioredis": "^5.0.0 || ^6.0.0",
|
|
117
118
|
"pg": ">=8.11 <9"
|
|
118
119
|
},
|
|
119
120
|
"peerDependenciesMeta": {
|
|
@@ -123,5 +124,8 @@
|
|
|
123
124
|
"ioredis": {
|
|
124
125
|
"optional": true
|
|
125
126
|
}
|
|
127
|
+
},
|
|
128
|
+
"overrides": {
|
|
129
|
+
"esbuild": "^0.28.1"
|
|
126
130
|
}
|
|
127
131
|
}
|
|
@@ -50,7 +50,10 @@ export const bulkheadLeaseV1 = `local function transition()\n${leaseV1}\nend\nlo
|
|
|
50
50
|
* ARGV[2] = expectedGeneration integer
|
|
51
51
|
* ARGV[3] = windowTtlMs observation retention window in ms
|
|
52
52
|
* ARGV[4] = minimumThroughput min observations before opening
|
|
53
|
-
* ARGV[5] = failureThresholdNum failure threshold × 1000 (e.g. 500 = 0.5)
|
|
53
|
+
* ARGV[5] = failureThresholdNum failure threshold × 1000 (e.g. 500 = 0.5).
|
|
54
|
+
* The policy rejects thresholds that would round
|
|
55
|
+
* to 0 or to 1000 before they reach this script
|
|
56
|
+
* (see assertResolvableThreshold in core).
|
|
54
57
|
* ARGV[6] = windowSize max observations retained by count
|
|
55
58
|
* ARGV[7] = openMs how long to stay OPEN; closed hash TTL = openMs×2
|
|
56
59
|
* ARGV[8] = uuid unique string for member deduplication
|
|
@@ -233,7 +236,9 @@ return {1, 2, gen, activeProbes, transitioned}
|
|
|
233
236
|
* KEYS[2] = probe tokens sorted set (:probes)
|
|
234
237
|
*
|
|
235
238
|
* ARGV[1] = probeToken the token issued by admitProbe
|
|
236
|
-
* ARGV[2] = outcome "success" | "failure"
|
|
239
|
+
* ARGV[2] = outcome "success" | "failure" | "ignored", where
|
|
240
|
+
* "ignored" releases the slot without recording
|
|
241
|
+
* an outcome or advancing recovery
|
|
237
242
|
* ARGV[3] = expectedGeneration generation captured at admission time
|
|
238
243
|
* ARGV[4] = halfOpenSuccesses consecutive probe successes needed to close
|
|
239
244
|
* ARGV[5] = openMs how long the breaker stays OPEN (TTL floor)
|
|
@@ -243,7 +248,8 @@ return {1, 2, gen, activeProbes, transitioned}
|
|
|
243
248
|
* Returns: {status, stateCode, generation}
|
|
244
249
|
* status 0 = stale (token missing, lease elapsed, or generation mismatch;
|
|
245
250
|
* the result is dropped and any consumed token is released)
|
|
246
|
-
* status 1 = settled, no state transition (still HALF_OPEN
|
|
251
|
+
* status 1 = settled, no state transition (still HALF_OPEN; an "ignored"
|
|
252
|
+
* outcome only releases the probe slot)
|
|
247
253
|
* status 2 = settled, state transition occurred
|
|
248
254
|
* stateCode 0 = transitioned to CLOSED
|
|
249
255
|
* stateCode 1 = transitioned back to OPEN (probe failure)
|
|
@@ -286,6 +292,17 @@ if state ~= 'half-open' or gen ~= expectGen then
|
|
|
286
292
|
return {0, sc, gen}
|
|
287
293
|
end
|
|
288
294
|
probeCnt = math.max(0, probeCnt - 1)
|
|
295
|
+
if outcome == 'ignored' then
|
|
296
|
+
-- The result was not recorded (the classifier ignored it), but the slot the
|
|
297
|
+
-- probe held must still be released: otherwise the recovery window stalls
|
|
298
|
+
-- until the probe lease elapses instead of letting the next probe through.
|
|
299
|
+
-- The local breaker releases its slot immediately, and this matches it: no
|
|
300
|
+
-- success counter advances and the state does not transition.
|
|
301
|
+
redis.call('HSET', KEYS[1], 'probeCount', probeCnt)
|
|
302
|
+
-- Still HALF_OPEN: must not expire while recovery is in progress.
|
|
303
|
+
redis.call('PERSIST', KEYS[1])
|
|
304
|
+
return {1, 2, gen}
|
|
305
|
+
end
|
|
289
306
|
if outcome == 'failure' then
|
|
290
307
|
gen = gen + 1
|
|
291
308
|
redis.call('HSET', KEYS[1], 'state', 'open', 'generation', gen,
|
package/src/core/bulkhead.ts
CHANGED
|
@@ -148,8 +148,11 @@ function local(options: LocalBulkheadOptions): Policy & {
|
|
|
148
148
|
return await next(context)
|
|
149
149
|
} finally {
|
|
150
150
|
occupancy--
|
|
151
|
-
|
|
151
|
+
// Report the released permit before handing it to the queued successor:
|
|
152
|
+
// granting first would make this event's occupancy already include the
|
|
153
|
+
// next admission, so the released/admitted pair would look inverted.
|
|
152
154
|
event(context, "local", name, "process", "released", occupancy)
|
|
155
|
+
waiting[0]?.()
|
|
153
156
|
}
|
|
154
157
|
},
|
|
155
158
|
})
|
|
@@ -108,10 +108,35 @@ const DEFAULT_HALF_OPEN_SUCCESSES = 1
|
|
|
108
108
|
const DEFAULT_HALF_OPEN_PROBES = 1
|
|
109
109
|
const DEFAULT_WINDOW_SIZE = 100
|
|
110
110
|
|
|
111
|
+
// The distributed coordinator compares the failure threshold as an integer
|
|
112
|
+
// numerator of thousandths (`wFail * 1000 >= numerator * wTotal`). A threshold
|
|
113
|
+
// that rounds to 0 makes that comparison unconditionally true, so the breaker
|
|
114
|
+
// opens on a success-only window and re-opens after every recovery; a threshold
|
|
115
|
+
// that rounds to the full scale requires every observation to fail, so the
|
|
116
|
+
// breaker effectively never opens. Both are rejected instead of silently
|
|
117
|
+
// reinterpreted, and the local breaker enforces the same bounds so one policy
|
|
118
|
+
// config works with either coordination.
|
|
119
|
+
const FAILURE_THRESHOLD_SCALE = 1000
|
|
120
|
+
|
|
111
121
|
// ---------------------------------------------------------------------------
|
|
112
122
|
// Validation
|
|
113
123
|
// ---------------------------------------------------------------------------
|
|
114
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Rejects a failureThreshold the thousandths comparison cannot represent.
|
|
127
|
+
*
|
|
128
|
+
* Accepts `0.0005 <= failureThreshold < 0.9995`. Anything else resolves to a
|
|
129
|
+
* numerator of 0 or of the full scale, which changes what the threshold means
|
|
130
|
+
* rather than how precisely it is expressed.
|
|
131
|
+
*/
|
|
132
|
+
function assertResolvableThreshold(failureThreshold: number): void {
|
|
133
|
+
const numerator = Math.round(failureThreshold * FAILURE_THRESHOLD_SCALE)
|
|
134
|
+
if (numerator < 1 || numerator >= FAILURE_THRESHOLD_SCALE)
|
|
135
|
+
throw new RangeError(
|
|
136
|
+
`failureThreshold must be at least 0.0005 and below 0.9995 (thresholds are resolved to thousandths); got ${failureThreshold}`,
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
115
140
|
function validate(opts: LocalBreakerOptions): void {
|
|
116
141
|
if (!opts.name.trim())
|
|
117
142
|
throw new RangeError("Circuit breaker name must not be empty")
|
|
@@ -127,6 +152,7 @@ function validate(opts: LocalBreakerOptions): void {
|
|
|
127
152
|
failureThreshold >= 1
|
|
128
153
|
)
|
|
129
154
|
throw new RangeError("failureThreshold must be a number in (0, 1)")
|
|
155
|
+
assertResolvableThreshold(failureThreshold)
|
|
130
156
|
|
|
131
157
|
const { openMs = DEFAULT_OPEN_MS } = opts
|
|
132
158
|
if (!Number.isInteger(openMs) || openMs < 1)
|
|
@@ -519,7 +545,12 @@ export interface BreakerCoordinator {
|
|
|
519
545
|
identity: BreakerIdentity,
|
|
520
546
|
params: {
|
|
521
547
|
readonly probeToken: string
|
|
522
|
-
|
|
548
|
+
/**
|
|
549
|
+
* `"success"` and `"failure"` record an outcome; `"ignored"` releases the
|
|
550
|
+
* probe's slot without recording anything, which is what the policy sends
|
|
551
|
+
* when its classifier ignores the result.
|
|
552
|
+
*/
|
|
553
|
+
readonly outcome: "success" | "failure" | "ignored"
|
|
523
554
|
readonly generation: number
|
|
524
555
|
readonly halfOpenSuccesses: number
|
|
525
556
|
readonly openMs: number
|
|
@@ -648,6 +679,7 @@ function validateDistributed(opts: DistributedBreakerOptions): void {
|
|
|
648
679
|
failureThreshold >= 1
|
|
649
680
|
)
|
|
650
681
|
throw new RangeError("failureThreshold must be a number in (0, 1)")
|
|
682
|
+
assertResolvableThreshold(failureThreshold)
|
|
651
683
|
|
|
652
684
|
const { openMs = DEFAULT_DIST_OPEN_MS } = opts
|
|
653
685
|
if (!Number.isInteger(openMs) || openMs < 1)
|
|
@@ -701,7 +733,9 @@ function distributed(
|
|
|
701
733
|
options.minimumThroughput ?? DEFAULT_DIST_MINIMUM_THROUGHPUT
|
|
702
734
|
const failureThreshold =
|
|
703
735
|
options.failureThreshold ?? DEFAULT_DIST_FAILURE_THRESHOLD
|
|
704
|
-
const failureThresholdNumerator = Math.round(
|
|
736
|
+
const failureThresholdNumerator = Math.round(
|
|
737
|
+
failureThreshold * FAILURE_THRESHOLD_SCALE,
|
|
738
|
+
)
|
|
705
739
|
const windowSize = options.windowSize ?? DEFAULT_DIST_WINDOW_SIZE
|
|
706
740
|
const openMs = options.openMs ?? DEFAULT_DIST_OPEN_MS
|
|
707
741
|
const windowTtlMs = options.windowTtlMs ?? Math.max(openMs * 3, 60_000)
|
|
@@ -930,11 +964,18 @@ function distributed(
|
|
|
930
964
|
? { status: "success", value }
|
|
931
965
|
: { status: "failure", error: thrownError },
|
|
932
966
|
)
|
|
933
|
-
|
|
967
|
+
// An ignored result is never recorded. A probe still has to settle
|
|
968
|
+
// though: releasing the slot it holds is what lets the next probe
|
|
969
|
+
// through, instead of stalling the recovery window until the probe lease
|
|
970
|
+
// elapses. The local breaker frees its slot immediately, and a release
|
|
971
|
+
// records nothing and does not advance recovery.
|
|
972
|
+
if (breakerOutcome !== "ignored" || admission.kind === "probe") {
|
|
934
973
|
const outcomeStr =
|
|
935
974
|
breakerOutcome === "success"
|
|
936
975
|
? ("success" as const)
|
|
937
976
|
: ("failure" as const)
|
|
977
|
+
const settleOutcome =
|
|
978
|
+
breakerOutcome === "ignored" ? ("ignored" as const) : outcomeStr
|
|
938
979
|
|
|
939
980
|
if (admission.kind === "closed") {
|
|
940
981
|
try {
|
|
@@ -998,49 +1039,53 @@ function distributed(
|
|
|
998
1039
|
try {
|
|
999
1040
|
const result = await coordinator.settleProbe(identity, {
|
|
1000
1041
|
probeToken: admission.probeToken,
|
|
1001
|
-
outcome:
|
|
1042
|
+
outcome: settleOutcome,
|
|
1002
1043
|
generation: admission.generation,
|
|
1003
1044
|
halfOpenSuccesses,
|
|
1004
1045
|
openMs,
|
|
1005
1046
|
windowTtlMs,
|
|
1006
1047
|
})
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
policyName: name,
|
|
1012
|
-
scope,
|
|
1013
|
-
attemptGeneration: admission.generation,
|
|
1014
|
-
currentGeneration: result.generation,
|
|
1015
|
-
})
|
|
1016
|
-
} else {
|
|
1017
|
-
emitRuntimeEvent(context, {
|
|
1018
|
-
type: "breaker.observation",
|
|
1019
|
-
coordination: "distributed",
|
|
1020
|
-
policyName: name,
|
|
1021
|
-
scope,
|
|
1022
|
-
outcome: outcomeStr,
|
|
1023
|
-
generation: admission.generation,
|
|
1024
|
-
})
|
|
1025
|
-
if (result.type === "transitioned") {
|
|
1026
|
-
if (result.newState === "closed") {
|
|
1027
|
-
lastKnownState.forget(identity.operation, scope)
|
|
1028
|
-
} else {
|
|
1029
|
-
lastKnownState.remember(
|
|
1030
|
-
identity.operation,
|
|
1031
|
-
scope,
|
|
1032
|
-
result.newState,
|
|
1033
|
-
)
|
|
1034
|
-
}
|
|
1048
|
+
// A release reports nothing: it is not an observation, so there is
|
|
1049
|
+
// no breaker.observation event and no transition to announce.
|
|
1050
|
+
if (breakerOutcome !== "ignored") {
|
|
1051
|
+
if (result.type === "stale") {
|
|
1035
1052
|
emitRuntimeEvent(context, {
|
|
1036
|
-
type: "breaker.
|
|
1053
|
+
type: "breaker.observation-stale",
|
|
1037
1054
|
coordination: "distributed",
|
|
1038
1055
|
policyName: name,
|
|
1039
1056
|
scope,
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1057
|
+
attemptGeneration: admission.generation,
|
|
1058
|
+
currentGeneration: result.generation,
|
|
1059
|
+
})
|
|
1060
|
+
} else {
|
|
1061
|
+
emitRuntimeEvent(context, {
|
|
1062
|
+
type: "breaker.observation",
|
|
1063
|
+
coordination: "distributed",
|
|
1064
|
+
policyName: name,
|
|
1065
|
+
scope,
|
|
1066
|
+
outcome: outcomeStr,
|
|
1067
|
+
generation: admission.generation,
|
|
1043
1068
|
})
|
|
1069
|
+
if (result.type === "transitioned") {
|
|
1070
|
+
if (result.newState === "closed") {
|
|
1071
|
+
lastKnownState.forget(identity.operation, scope)
|
|
1072
|
+
} else {
|
|
1073
|
+
lastKnownState.remember(
|
|
1074
|
+
identity.operation,
|
|
1075
|
+
scope,
|
|
1076
|
+
result.newState,
|
|
1077
|
+
)
|
|
1078
|
+
}
|
|
1079
|
+
emitRuntimeEvent(context, {
|
|
1080
|
+
type: "breaker.state-changed",
|
|
1081
|
+
coordination: "distributed",
|
|
1082
|
+
policyName: name,
|
|
1083
|
+
scope,
|
|
1084
|
+
state: result.newState,
|
|
1085
|
+
previousState: "half-open",
|
|
1086
|
+
generation: result.newGeneration,
|
|
1087
|
+
})
|
|
1088
|
+
}
|
|
1044
1089
|
}
|
|
1045
1090
|
}
|
|
1046
1091
|
} catch (settleError) {
|