@gkoos/caracal 0.1.0 → 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/CHANGELOG.md +40 -5
- package/README.md +13 -3
- package/dist/chunk-FD7IZJKY.js +84 -0
- package/dist/chunk-FD7IZJKY.js.map +1 -0
- package/dist/{chunk-5CXDW7W6.js → chunk-JV2OLYOF.js} +5 -82
- package/dist/chunk-JV2OLYOF.js.map +1 -0
- package/dist/{circuit-breaker-BSkcV0W_.d.ts → circuit-breaker-xZ8uenT8.d.ts} +7 -2
- package/dist/fetch.d.ts +6 -3
- package/dist/fetch.js +4 -2
- package/dist/fetch.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +96 -41
- package/dist/index.js.map +1 -1
- package/dist/postgres.d.ts +1 -1
- package/dist/redis.d.ts +10 -4
- package/dist/redis.js +15 -1
- package/dist/redis.js.map +1 -1
- package/dist/{retry-BFP_k3Hg.d.ts → retry-DD85oXL9.d.ts} +1 -1
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +2 -1
- package/dist/testing/index.js.map +1 -1
- package/dist/{types-Tf9T76C7.d.ts → types-C-Ml-MKp.d.ts} +7 -0
- package/package.json +9 -5
- package/src/adapters/fetch/retry-after.ts +11 -3
- package/src/coordination/redis/client.ts +15 -1
- package/src/coordination/redis/scripts.ts +20 -3
- package/src/core/bulkhead.ts +20 -3
- package/src/core/circuit-breaker.ts +81 -36
- package/src/core/operation.ts +2 -5
- package/src/core/retry.ts +46 -2
- package/src/core/runtime.ts +26 -5
- package/src/core/timeout.ts +11 -1
- package/src/core/types.ts +8 -0
- package/test/harness/adapter-contract.ts +170 -0
- package/test/harness/index.ts +15 -0
- package/dist/chunk-5CXDW7W6.js.map +0 -1
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, 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-
|
|
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.
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { O as OperationCapabilities, h as Outcome, C as Classification, A as Adapter } from '../types-
|
|
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;
|
package/dist/testing/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../test/harness/adapter-contract.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"sources":["../../test/harness/adapter-contract.ts"],"names":[],"mappings":";;;;AAmDA,SAAS,WAAA,CAAe,MAAA,EAAW,QAAA,EAAa,OAAA,EAAuB;AACrE,EAAA,IAAI,CAAC,MAAA,CAAO,EAAA,CAAG,MAAA,EAAQ,QAAQ,CAAA,EAAG;AAChC,IAAA,MAAM,IAAI,KAAA;AAAA,MACR,CAAA,EAAG,OAAO,CAAA,WAAA,EAAc,MAAA,CAAO,QAAQ,CAAC,CAAA,WAAA,EAAc,MAAA,CAAO,MAAM,CAAC,CAAA;AAAA,KACtE;AAAA,EACF;AACF;AAEA,SAAS,gBAAgB,MAAA,EAAyC;AAChE,EAAA,MAAM,QAAQ,MAAA,CAAO,GAAA,CAAI,CAAC,KAAA,KAAU,MAAM,IAAI,CAAA;AAC9C,EAAA,MAAM,QAAA,GAAW;AAAA,IACf,mBAAA;AAAA,IACA,iBAAA;AAAA,IACA,iBAAA;AAAA,IACA;AAAA,GACF;AACA,EAAA,IACE,KAAA,CAAM,MAAA,KAAW,QAAA,CAAS,MAAA,IAC1B,KAAA,CAAM,IAAA,CAAK,CAAC,IAAA,EAAM,KAAA,KAAU,IAAA,KAAS,QAAA,CAAS,KAAK,CAAC,CAAA,EACpD;AACA,IAAA,MAAM,IAAI,KAAA;AAAA,MACR,CAAA,6BAAA,EAAgC,SAAS,IAAA,CAAK,MAAM,CAAC,CAAA,WAAA,EAAc,KAAA,CAAM,IAAA,CAAK,MAAM,CAAC,CAAA;AAAA,KACvF;AAAA,EACF;AACF;AAMO,SAAS,2BACd,OAAA,EACsB;AACtB,EAAA,MAAM,MAAA,GAAiC,QAAQ,YAAA,CAAa,GAAA;AAAA,IAC1D,CAAC,gBAAgB,KAAA,MAAW;AAAA,MAC1B,MAAM,CAAA,EAAG,OAAA,CAAQ,IAAI,CAAA,eAAA,EAAkB,QAAQ,CAAC,CAAA,CAAA;AAAA,MAChD,MAAM,GAAA,GAAqB;AACzB,QAAA,MAAM,MAAA,GAAS,OAAA,CAAQ,OAAA,CAAQ,YAAA,CAAa,eAAe,IAAI,CAAA;AAC/D,QAAA,WAAA;AAAA,UACE,MAAA,CAAO,KAAA;AAAA,UACP,eAAe,QAAA,CAAS,KAAA;AAAA,UACxB;AAAA,SACF;AACA,QAAA,WAAA;AAAA,UACE,MAAA,CAAO,MAAA;AAAA,UACP,eAAe,QAAA,CAAS,MAAA;AAAA,UACxB;AAAA,SACF;AAAA,MACF;AAAA,KACF;AAAA,GACF;AAEA,EAAA,MAAA,CAAO,IAAA,CAAK;AAAA,IACV,IAAA,EAAM,CAAA,EAAG,OAAA,CAAQ,IAAI,CAAA,gCAAA,CAAA;AAAA,IACrB,MAAM,GAAA,GAAqB;AACzB,MAAA,MAAM,SAA2B,EAAC;AAClC,MAAA,MAAM,UAAU,SAAA,CAAU;AAAA,QACxB,IAAA,EAAM,CAAA,iBAAA,EAAoB,OAAA,CAAQ,IAAI,CAAA,CAAA;AAAA,QACtC,SAAS,OAAA,CAAQ,OAAA;AAAA,QACjB,MAAA,EAAQ,EAAE,IAAA,EAAM,CAAC,UAAU,MAAA,CAAO,IAAA,CAAK,KAAK,CAAA;AAAE,OAC/C,CAAA;AACD,MAAA,MAAM,SAAS,MAAM,OAAA,CAAQ,OAAA,CAAQ,OAAA,CAAQ,QAAQ,IAAA,EAAM;AAAA,QACzD,WAAA,EAAa;AAAA,OACd,CAAA;AACD,MAAA,MAAM,OAAA,CAAQ,OAAA,CAAQ,YAAA,GAAe,MAAM,CAAA;AAC3C,MAAA,eAAA,CAAgB,MAAM,CAAA;AAAA,IACxB;AAAA,GACD,CAAA;AAED,EAAA,KAAA,MAAW,CAAC,OAAO,kBAAkB,CAAA,IAAA,CACnC,QAAQ,eAAA,IAAmB,EAAC,EAC5B,OAAA,EAAQ,EAAG;AACX,IAAA,MAAA,CAAO,IAAA,CAAK;AAAA,MACV,MAAM,CAAA,EAAG,OAAA,CAAQ,IAAI,CAAA,iBAAA,EAAoB,QAAQ,CAAC,CAAA,CAAA;AAAA,MAClD,MAAM,GAAA,GAAqB;AACzB,QAAA,MAAM,MAAA,GACJ,OAAA,CAAQ,OAAA,CAAQ,QAAA,GAAW,kBAAA,CAAmB,OAAO,CAAA,KACpD,kBAAA,CAAmB,OAAA,CAAQ,MAAA,KAAW,SAAA,GACnC,SAAA,GACA,SAAA,CAAA;AACN,QAAA,WAAA;AAAA,UACE,MAAA;AAAA,UACA,kBAAA,CAAmB,QAAA;AAAA,UACnB;AAAA,SACF;AAAA,MACF;AAAA,KACD,CAAA;AAAA,EACH;AAEA,EAAA,IAAI,OAAA,CAAQ,UAAU,MAAA,EAAW;AAC/B,IAAA,MAAA,CAAO,IAAA,CAAK;AAAA,MACV,IAAA,EAAM,CAAA,EAAG,OAAA,CAAQ,IAAI,CAAA,gBAAA,CAAA;AAAA,MACrB,MAAM,GAAA,GAAqB;AACzB,QAAA,MAAM,UAAA,GAAa,IAAI,eAAA,EAAgB;AACvC,QAAA,MAAM,UAAU,SAAA,CAAU;AAAA,UACxB,IAAA,EAAM,CAAA,iBAAA,EAAoB,OAAA,CAAQ,IAAI,CAAA,CAAA;AAAA,UACtC,SAAS,OAAA,CAAQ;AAAA,SAClB,CAAA;AACD,QAAA,MAAM,OAAA,CAAQ,OAAO,MAAA,CAAO;AAAA,UAC1B,UAAA;AAAA,UACA,SAAS,MACP,OAAA,CAAQ,OAAA,CAAQ,OAAA,CAAQ,OAAO,IAAA,EAAc;AAAA,YAC3C,QAAQ,UAAA,CAAW;AAAA,WACpB;AAAA,SACJ,CAAA;AAAA,MACH;AAAA,KACD,CAAA;AAAA,EACH;AAEA,EAAA,OAAO,MAAA,CAAO,MAAA,CAAO,EAAE,IAAA,EAAM,OAAA,CAAQ,IAAA,EAAM,MAAA,EAAQ,MAAA,CAAO,MAAA,CAAO,MAAM,CAAA,EAAG,CAAA;AAC5E;AAEA,eAAsB,wBACpB,KAAA,EACe;AACf,EAAA,KAAA,MAAW,KAAA,IAAS,MAAM,MAAA,EAAQ;AAChC,IAAA,MAAM,MAAM,GAAA,EAAI;AAAA,EAClB;AACF","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.
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
}
|
|
@@ -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)
|
|
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)
|
|
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,
|
|
@@ -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
|
@@ -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 >
|
|
85
|
+
queue.timeoutMs > MAX_TIMER_MS)
|
|
85
86
|
)
|
|
86
87
|
throw new RangeError("Invalid bounded queue")
|
|
87
88
|
let occupancy = 0
|
|
@@ -148,8 +149,11 @@ function local(options: LocalBulkheadOptions): Policy & {
|
|
|
148
149
|
return await next(context)
|
|
149
150
|
} finally {
|
|
150
151
|
occupancy--
|
|
151
|
-
|
|
152
|
+
// Report the released permit before handing it to the queued successor:
|
|
153
|
+
// granting first would make this event's occupancy already include the
|
|
154
|
+
// next admission, so the released/admitted pair would look inverted.
|
|
152
155
|
event(context, "local", name, "process", "released", occupancy)
|
|
156
|
+
waiting[0]?.()
|
|
153
157
|
}
|
|
154
158
|
},
|
|
155
159
|
})
|
|
@@ -272,13 +276,26 @@ function distributed(
|
|
|
272
276
|
}
|
|
273
277
|
}
|
|
274
278
|
try {
|
|
275
|
-
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
|
+
)
|
|
276
292
|
throw new BulkheadRejectedError(
|
|
277
293
|
"distributed",
|
|
278
294
|
name,
|
|
279
295
|
scope,
|
|
280
296
|
"admission-expired",
|
|
281
297
|
)
|
|
298
|
+
}
|
|
282
299
|
admissionSignal(context)?.throwIfAborted()
|
|
283
300
|
event(
|
|
284
301
|
context,
|