@gkoos/caracal 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +311 -0
- package/dist/chunk-5CXDW7W6.js +202 -0
- package/dist/chunk-5CXDW7W6.js.map +1 -0
- package/dist/circuit-breaker-BSkcV0W_.d.ts +296 -0
- package/dist/fetch.d.ts +58 -0
- package/dist/fetch.js +117 -0
- package/dist/fetch.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +1065 -0
- package/dist/index.js.map +1 -0
- package/dist/postgres.d.ts +28 -0
- package/dist/postgres.js +56 -0
- package/dist/postgres.js.map +1 -0
- package/dist/redis.d.ts +59 -0
- package/dist/redis.js +549 -0
- package/dist/redis.js.map +1 -0
- package/dist/retry-BFP_k3Hg.d.ts +26 -0
- package/dist/testing/index.d.ts +45 -0
- package/dist/testing/index.js +101 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/types-Tf9T76C7.d.ts +187 -0
- package/package.json +127 -0
- package/src/adapters/fetch/adapter.ts +122 -0
- package/src/adapters/fetch/index.ts +15 -0
- package/src/adapters/fetch/retry-after.ts +111 -0
- package/src/adapters/postgres/adapter.ts +102 -0
- package/src/adapters/postgres/index.ts +7 -0
- package/src/coordination/redis/bulkhead.ts +61 -0
- package/src/coordination/redis/circuit-breaker.ts +270 -0
- package/src/coordination/redis/client.ts +78 -0
- package/src/coordination/redis/eval-script.ts +71 -0
- package/src/coordination/redis/keys.ts +32 -0
- package/src/coordination/redis/leases.ts +44 -0
- package/src/coordination/redis/scripts.ts +314 -0
- package/src/core/bulkhead.ts +336 -0
- package/src/core/circuit-breaker.ts +1066 -0
- package/src/core/index.ts +36 -0
- package/src/core/operation.ts +174 -0
- package/src/core/retry.ts +204 -0
- package/src/core/runtime.ts +123 -0
- package/src/core/scope-state-cache.ts +50 -0
- package/src/core/timeout.ts +73 -0
- package/src/core/types.ts +230 -0
- package/src/fetch.ts +17 -0
- package/src/index.ts +49 -0
- package/src/postgres.ts +9 -0
- package/src/redis.ts +8 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build a namespaced Redis key for a policy-scoped coordination slot.
|
|
5
|
+
*
|
|
6
|
+
* The SHA-256 hash of `[namespace, policy, operation, scope]` forms the
|
|
7
|
+
* stable identity; `suffix` distinguishes multiple keys that share the
|
|
8
|
+
* same identity (e.g. `:leases`, `:breaker`, `:observations`, `:probes`).
|
|
9
|
+
* Default suffix is `"leases"` for backward compatibility with the bulkhead.
|
|
10
|
+
*/
|
|
11
|
+
export function coordinationKey(
|
|
12
|
+
namespace: string,
|
|
13
|
+
policy: string,
|
|
14
|
+
operation: string,
|
|
15
|
+
scope: string,
|
|
16
|
+
suffix = "leases",
|
|
17
|
+
): string {
|
|
18
|
+
for (const value of [namespace, policy, operation, scope, suffix]) {
|
|
19
|
+
if (
|
|
20
|
+
typeof value !== "string" ||
|
|
21
|
+
!value.trim() ||
|
|
22
|
+
Buffer.byteLength(value) > 1024
|
|
23
|
+
)
|
|
24
|
+
throw new TypeError(
|
|
25
|
+
"Coordination identities must be nonempty strings of at most 1024 UTF-8 bytes",
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
const identity = createHash("sha256")
|
|
29
|
+
.update(JSON.stringify([namespace, policy, operation, scope]))
|
|
30
|
+
.digest("hex")
|
|
31
|
+
return `caracal:v1:{${identity}}:${suffix}`
|
|
32
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { CoordinatorUnavailableError } from "./client.js"
|
|
2
|
+
import { evalScript, type ScriptClient } from "./eval-script.js"
|
|
3
|
+
import { leaseV1 } from "./scripts.js"
|
|
4
|
+
export interface RedisScriptClient extends ScriptClient {
|
|
5
|
+
hmget(key: string, ...fields: string[]): Promise<(string | null)[]>
|
|
6
|
+
}
|
|
7
|
+
/** Internal policy-specific capability, not a public distributed lock API. */
|
|
8
|
+
export async function leaseCommand(
|
|
9
|
+
client: RedisScriptClient,
|
|
10
|
+
key: string,
|
|
11
|
+
action: "acquire" | "renew" | "release",
|
|
12
|
+
token: string,
|
|
13
|
+
ttl: number,
|
|
14
|
+
limit: number,
|
|
15
|
+
): Promise<boolean> {
|
|
16
|
+
if (
|
|
17
|
+
!["acquire", "renew", "release"].includes(action) ||
|
|
18
|
+
!token ||
|
|
19
|
+
token.length > 256
|
|
20
|
+
)
|
|
21
|
+
throw new TypeError("Invalid lease action or token")
|
|
22
|
+
if (
|
|
23
|
+
![ttl, limit].every((value) => Number.isSafeInteger(value) && value > 0) ||
|
|
24
|
+
ttl > 86400000
|
|
25
|
+
)
|
|
26
|
+
throw new RangeError("Invalid lease TTL or limit")
|
|
27
|
+
try {
|
|
28
|
+
const result = await evalScript(
|
|
29
|
+
client,
|
|
30
|
+
leaseV1,
|
|
31
|
+
1,
|
|
32
|
+
key,
|
|
33
|
+
action,
|
|
34
|
+
token,
|
|
35
|
+
ttl,
|
|
36
|
+
limit,
|
|
37
|
+
)
|
|
38
|
+
if (result !== 0 && result !== 1)
|
|
39
|
+
throw new Error("Invalid Redis script reply")
|
|
40
|
+
return result === 1
|
|
41
|
+
} catch (error) {
|
|
42
|
+
throw new CoordinatorUnavailableError(error)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Versioned atomic operations, internal to the Redis policy capabilities.
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Bulkhead scripts
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
export const leaseV1 = `
|
|
10
|
+
local action = ARGV[1]
|
|
11
|
+
local token = ARGV[2]
|
|
12
|
+
local ttl = tonumber(ARGV[3])
|
|
13
|
+
local limit = tonumber(ARGV[4])
|
|
14
|
+
local time = redis.call('TIME')
|
|
15
|
+
local now = time[1] * 1000 + math.floor(time[2] / 1000)
|
|
16
|
+
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now)
|
|
17
|
+
local existing = redis.call('ZSCORE', KEYS[1], token)
|
|
18
|
+
if action == 'release' then
|
|
19
|
+
return redis.call('ZREM', KEYS[1], token)
|
|
20
|
+
end
|
|
21
|
+
if action == 'renew' and not existing then return 0 end
|
|
22
|
+
if action == 'acquire' and existing then return 1 end
|
|
23
|
+
if action == 'acquire' and redis.call('ZCARD', KEYS[1]) >= limit then return 0 end
|
|
24
|
+
redis.call('ZADD', KEYS[1], now + ttl, token)
|
|
25
|
+
local latest = redis.call('ZREVRANGE', KEYS[1], 0, 0, 'WITHSCORES')
|
|
26
|
+
redis.call('PEXPIREAT', KEYS[1], math.ceil(tonumber(latest[2])))
|
|
27
|
+
return 1
|
|
28
|
+
`
|
|
29
|
+
|
|
30
|
+
/** Returns the decision and occupancy from the same atomic transition. */
|
|
31
|
+
export const bulkheadLeaseV1 = `local function transition()\n${leaseV1}\nend\nlocal allowed = transition()\nreturn {allowed, redis.call('ZCARD', KEYS[1])}`
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Circuit-breaker scripts
|
|
35
|
+
//
|
|
36
|
+
// Return arrays use fixed positions so the TypeScript caller can validate
|
|
37
|
+
// and decode without field names (which Lua/Redis cannot return).
|
|
38
|
+
//
|
|
39
|
+
// stateCode encoding: 0=closed 1=open 2=half-open
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Record one attempt outcome into the distributed sliding window.
|
|
44
|
+
* Atomically opens the breaker when the failure ratio meets the threshold.
|
|
45
|
+
*
|
|
46
|
+
* KEYS[1] = breaker state hash (:breaker)
|
|
47
|
+
* KEYS[2] = observations sorted set (:observations)
|
|
48
|
+
*
|
|
49
|
+
* ARGV[1] = outcome "success" | "failure"
|
|
50
|
+
* ARGV[2] = expectedGeneration integer
|
|
51
|
+
* ARGV[3] = windowTtlMs observation retention window in ms
|
|
52
|
+
* ARGV[4] = minimumThroughput min observations before opening
|
|
53
|
+
* ARGV[5] = failureThresholdNum failure threshold × 1000 (e.g. 500 = 0.5)
|
|
54
|
+
* ARGV[6] = windowSize max observations retained by count
|
|
55
|
+
* ARGV[7] = openMs how long to stay OPEN; closed hash TTL = openMs×2
|
|
56
|
+
* ARGV[8] = uuid unique string for member deduplication
|
|
57
|
+
*
|
|
58
|
+
* Returns: {status, stateCode, generation, windowTotal, windowFailures}
|
|
59
|
+
* status 0 = stale (dropped; generation or state mismatch)
|
|
60
|
+
* status 1 = observed, no transition (remained closed)
|
|
61
|
+
* status 2 = observed, breaker opened (stateCode=1, generation incremented)
|
|
62
|
+
*
|
|
63
|
+
* TTL policy:
|
|
64
|
+
* OPEN state → PERSIST (no expiry). A missing key is treated as closed by
|
|
65
|
+
* all scripts, so expiring an open breaker would silently admit
|
|
66
|
+
* unrestricted traffic and reset the generation counter.
|
|
67
|
+
* CLOSED state → max(openMs×2, windowTtlMs) TTL for eventual cleanup of idle
|
|
68
|
+
* scopes. Expiring a closed key is safe *only* because the TTL
|
|
69
|
+
* outlives the observation window: the window is scoped by an
|
|
70
|
+
* epoch, and an epoch that outlives its members is what keeps
|
|
71
|
+
* cleanup from resurrecting them.
|
|
72
|
+
*
|
|
73
|
+
* Epochs:
|
|
74
|
+
* `generation` increments on every transition, and a brand new value is minted
|
|
75
|
+
* whenever the state hash has to be recreated while observation members from a
|
|
76
|
+
* previous epoch are still present (state lost to eviction or admin cleanup).
|
|
77
|
+
* Window membership is decided by comparing the stored epoch, so members from
|
|
78
|
+
* a superseded epoch can never be counted again, and an attempt holding a
|
|
79
|
+
* pre-loss generation can never pass the staleness check. A scope that has
|
|
80
|
+
* never been observed keeps generation 0.
|
|
81
|
+
*/
|
|
82
|
+
export const breakerObserveV1 = `
|
|
83
|
+
local outcome = ARGV[1]
|
|
84
|
+
local expectGen = tonumber(ARGV[2])
|
|
85
|
+
local windowTtl = tonumber(ARGV[3])
|
|
86
|
+
local minTP = tonumber(ARGV[4])
|
|
87
|
+
local threshNum = tonumber(ARGV[5])
|
|
88
|
+
local windowSize = tonumber(ARGV[6])
|
|
89
|
+
local openMs = tonumber(ARGV[7])
|
|
90
|
+
local uuid = ARGV[8]
|
|
91
|
+
local t = redis.call('TIME')
|
|
92
|
+
local now = t[1] * 1000 + math.floor(t[2] / 1000)
|
|
93
|
+
local f = redis.call('HMGET', KEYS[1], 'state', 'generation')
|
|
94
|
+
local state = f[1] or 'closed'
|
|
95
|
+
local gen = tonumber(f[2]) or 0
|
|
96
|
+
if state ~= 'closed' then
|
|
97
|
+
local sc = (state == 'open' and 1) or (state == 'half-open' and 2) or 0
|
|
98
|
+
return {0, sc, gen, 0, 0}
|
|
99
|
+
end
|
|
100
|
+
if redis.call('EXISTS', KEYS[1]) == 0 then
|
|
101
|
+
-- No live state hash: a scope we have never seen, or a hash that was lost
|
|
102
|
+
-- while its window survived.
|
|
103
|
+
if expectGen ~= 0 then
|
|
104
|
+
-- The caller holds a generation this key cannot confirm.
|
|
105
|
+
return {0, 0, 0, 0, 0}
|
|
106
|
+
end
|
|
107
|
+
if redis.call('EXISTS', KEYS[2]) == 1 then
|
|
108
|
+
-- Members from a superseded epoch are still here. Mint an epoch that has
|
|
109
|
+
-- never been used for this key (derived from the observation's own uuid, so
|
|
110
|
+
-- no clock is involved) instead of restarting at a value those members
|
|
111
|
+
-- would match.
|
|
112
|
+
--
|
|
113
|
+
-- Bounded to 11 hex digits (44 bits, <= 14 decimal digits) so the value
|
|
114
|
+
-- prints exactly with stock Lua number formatting: the epoch is read back
|
|
115
|
+
-- from the hash as a decimal string by both this script and the client, and
|
|
116
|
+
-- a larger value could be written in scientific notation and break the
|
|
117
|
+
-- round-trip.
|
|
118
|
+
gen = tonumber(string.sub(redis.sha1hex(uuid), 1, 11), 16)
|
|
119
|
+
if not gen or gen == 0 then gen = 1 end
|
|
120
|
+
else
|
|
121
|
+
gen = 0
|
|
122
|
+
end
|
|
123
|
+
redis.call('HSET', KEYS[1], 'state', 'closed', 'generation', gen,
|
|
124
|
+
'openedAt', 0, 'probeCount', 0, 'probeSuccesses', 0)
|
|
125
|
+
elseif gen ~= expectGen then
|
|
126
|
+
return {0, 0, gen, 0, 0}
|
|
127
|
+
end
|
|
128
|
+
redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', now - windowTtl)
|
|
129
|
+
local member = tostring(gen) .. ':' .. uuid .. ':' .. outcome
|
|
130
|
+
redis.call('ZADD', KEYS[2], 'NX', now, member)
|
|
131
|
+
local cnt = redis.call('ZCARD', KEYS[2])
|
|
132
|
+
if cnt > windowSize then
|
|
133
|
+
redis.call('ZREMRANGEBYRANK', KEYS[2], 0, cnt - windowSize - 1)
|
|
134
|
+
end
|
|
135
|
+
redis.call('PEXPIREAT', KEYS[2], now + windowTtl)
|
|
136
|
+
local all = redis.call('ZRANGE', KEYS[2], 0, -1)
|
|
137
|
+
local prefix = tostring(gen) .. ':'
|
|
138
|
+
local wTotal = 0
|
|
139
|
+
local wFail = 0
|
|
140
|
+
for _, m in ipairs(all) do
|
|
141
|
+
if string.sub(m, 1, #prefix) == prefix then
|
|
142
|
+
wTotal = wTotal + 1
|
|
143
|
+
if string.sub(m, -8) == ':failure' then wFail = wFail + 1 end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
if wTotal >= minTP and wFail * 1000 >= threshNum * wTotal then
|
|
147
|
+
gen = gen + 1
|
|
148
|
+
redis.call('HSET', KEYS[1], 'state', 'open', 'generation', gen,
|
|
149
|
+
'openedAt', now, 'probeCount', 0, 'probeSuccesses', 0)
|
|
150
|
+
-- OPEN state must never expire: a missing key is treated as closed,
|
|
151
|
+
-- which would silently admit unrestricted traffic and reset the generation.
|
|
152
|
+
redis.call('PERSIST', KEYS[1])
|
|
153
|
+
return {2, 1, gen, wTotal, wFail}
|
|
154
|
+
end
|
|
155
|
+
if redis.call('EXISTS', KEYS[1]) == 1 then
|
|
156
|
+
-- Still CLOSED. The cleanup TTL must not expire the state before the window
|
|
157
|
+
-- it governs, otherwise retained members would survive their epoch.
|
|
158
|
+
redis.call('PEXPIRE', KEYS[1], math.max(openMs * 2, windowTtl))
|
|
159
|
+
end
|
|
160
|
+
return {1, 0, gen, wTotal, wFail}
|
|
161
|
+
`
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Admit a probe attempt. Transitions OPEN→HALF_OPEN when openMs has elapsed,
|
|
165
|
+
* then atomically allocates a probe token slot.
|
|
166
|
+
*
|
|
167
|
+
* KEYS[1] = breaker state hash (:breaker)
|
|
168
|
+
* KEYS[2] = probe tokens sorted set (:probes)
|
|
169
|
+
*
|
|
170
|
+
* ARGV[1] = probeToken unique UUID for this probe attempt
|
|
171
|
+
* ARGV[2] = openMs determines OPEN→HALF_OPEN transition and TTL
|
|
172
|
+
* ARGV[3] = halfOpenProbes maximum concurrent probe tokens
|
|
173
|
+
* ARGV[4] = probeLeaseTtlMs probe token TTL in ms
|
|
174
|
+
*
|
|
175
|
+
* Returns: {status, stateCode, generation, probeCount, transitioned}
|
|
176
|
+
* status 0 = rejected
|
|
177
|
+
* stateCode 0 = breaker already CLOSED (no probe needed)
|
|
178
|
+
* stateCode 1 = still OPEN (openMs not yet elapsed)
|
|
179
|
+
* stateCode 2 = HALF_OPEN but probe limit reached
|
|
180
|
+
* status 1 = admitted (probe token stored)
|
|
181
|
+
* transitioned 1 = OPEN→HALF_OPEN transition happened in this call
|
|
182
|
+
* transitioned 0 = was already HALF_OPEN
|
|
183
|
+
*/
|
|
184
|
+
export const breakerAdmitProbeV1 = `
|
|
185
|
+
local probeToken = ARGV[1]
|
|
186
|
+
local openMs = tonumber(ARGV[2])
|
|
187
|
+
local maxProbes = tonumber(ARGV[3])
|
|
188
|
+
local probeLeaseTtl = tonumber(ARGV[4])
|
|
189
|
+
local t = redis.call('TIME')
|
|
190
|
+
local now = t[1] * 1000 + math.floor(t[2] / 1000)
|
|
191
|
+
local f = redis.call('HMGET', KEYS[1], 'state', 'generation', 'openedAt',
|
|
192
|
+
'probeCount', 'probeSuccesses')
|
|
193
|
+
local state = f[1] or 'closed'
|
|
194
|
+
local gen = tonumber(f[2]) or 0
|
|
195
|
+
local openedAt = tonumber(f[3]) or 0
|
|
196
|
+
local transitioned = 0
|
|
197
|
+
if state == 'closed' then
|
|
198
|
+
return {0, 0, gen, 0, 0}
|
|
199
|
+
end
|
|
200
|
+
if state == 'open' then
|
|
201
|
+
if now - openedAt < openMs then
|
|
202
|
+
return {0, 1, gen, 0, 0}
|
|
203
|
+
end
|
|
204
|
+
state = 'half-open'
|
|
205
|
+
transitioned = 1
|
|
206
|
+
redis.call('HSET', KEYS[1], 'state', 'half-open', 'probeSuccesses', 0, 'probeCount', 0)
|
|
207
|
+
-- OPEN/HALF_OPEN must never expire; PERSIST removes any prior cleanup TTL.
|
|
208
|
+
redis.call('PERSIST', KEYS[1])
|
|
209
|
+
-- Tokens from the superseded recovery window must not consume slots in this
|
|
210
|
+
-- one; their settle is dropped by the generation check anyway.
|
|
211
|
+
redis.call('DEL', KEYS[2])
|
|
212
|
+
end
|
|
213
|
+
redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', now)
|
|
214
|
+
local activeProbes = redis.call('ZCARD', KEYS[2])
|
|
215
|
+
if activeProbes >= maxProbes then
|
|
216
|
+
return {0, 2, gen, activeProbes, transitioned}
|
|
217
|
+
end
|
|
218
|
+
redis.call('ZADD', KEYS[2], 'NX', now + probeLeaseTtl, probeToken)
|
|
219
|
+
activeProbes = activeProbes + 1
|
|
220
|
+
redis.call('HSET', KEYS[1], 'probeCount', activeProbes)
|
|
221
|
+
-- State hash must not expire while recovery probes are in flight.
|
|
222
|
+
redis.call('PERSIST', KEYS[1])
|
|
223
|
+
-- Probes sorted set expires naturally once the last lease elapses.
|
|
224
|
+
redis.call('PEXPIREAT', KEYS[2], now + probeLeaseTtl + 1000)
|
|
225
|
+
return {1, 2, gen, activeProbes, transitioned}
|
|
226
|
+
`
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Record a probe attempt outcome. Removes the probe token and applies the
|
|
230
|
+
* result: failure re-opens; sufficient successes close the breaker.
|
|
231
|
+
*
|
|
232
|
+
* KEYS[1] = breaker state hash (:breaker)
|
|
233
|
+
* KEYS[2] = probe tokens sorted set (:probes)
|
|
234
|
+
*
|
|
235
|
+
* ARGV[1] = probeToken the token issued by admitProbe
|
|
236
|
+
* ARGV[2] = outcome "success" | "failure"
|
|
237
|
+
* ARGV[3] = expectedGeneration generation captured at admission time
|
|
238
|
+
* ARGV[4] = halfOpenSuccesses consecutive probe successes needed to close
|
|
239
|
+
* ARGV[5] = openMs how long the breaker stays OPEN (TTL floor)
|
|
240
|
+
* ARGV[6] = windowTtlMs observation retention (TTL floor): the CLOSED
|
|
241
|
+
* hash must outlive the window it governs
|
|
242
|
+
*
|
|
243
|
+
* Returns: {status, stateCode, generation}
|
|
244
|
+
* status 0 = stale (token missing, lease elapsed, or generation mismatch;
|
|
245
|
+
* the result is dropped and any consumed token is released)
|
|
246
|
+
* status 1 = settled, no state transition (still HALF_OPEN)
|
|
247
|
+
* status 2 = settled, state transition occurred
|
|
248
|
+
* stateCode 0 = transitioned to CLOSED
|
|
249
|
+
* stateCode 1 = transitioned back to OPEN (probe failure)
|
|
250
|
+
*
|
|
251
|
+
* A token carries its own deadline as its sorted-set score. When the lease has
|
|
252
|
+
* elapsed the slot is already recoverable - admitProbe prunes expired tokens
|
|
253
|
+
* and re-issues the slot - so the result must not count even while the member is
|
|
254
|
+
* still present. The deadline is therefore checked under the same atomic call.
|
|
255
|
+
*/
|
|
256
|
+
export const breakerSettleProbeV1 = `
|
|
257
|
+
local probeToken = ARGV[1]
|
|
258
|
+
local outcome = ARGV[2]
|
|
259
|
+
local expectGen = tonumber(ARGV[3])
|
|
260
|
+
local halfOpenSucc = tonumber(ARGV[4])
|
|
261
|
+
local openMs = tonumber(ARGV[5])
|
|
262
|
+
local windowTtl = tonumber(ARGV[6])
|
|
263
|
+
local t = redis.call('TIME')
|
|
264
|
+
local now = t[1] * 1000 + math.floor(t[2] / 1000)
|
|
265
|
+
local f = redis.call('HMGET', KEYS[1], 'state', 'generation', 'probeSuccesses', 'probeCount')
|
|
266
|
+
local state = f[1] or 'closed'
|
|
267
|
+
local gen = tonumber(f[2]) or 0
|
|
268
|
+
local probeSucc = tonumber(f[3]) or 0
|
|
269
|
+
local probeCnt = tonumber(f[4]) or 0
|
|
270
|
+
local sc = (state == 'open' and 1) or (state == 'half-open' and 2) or 0
|
|
271
|
+
local deadline = redis.call('ZSCORE', KEYS[2], probeToken)
|
|
272
|
+
if not deadline then
|
|
273
|
+
-- Never admitted, already settled, or dropped with its recovery window.
|
|
274
|
+
return {0, sc, gen}
|
|
275
|
+
end
|
|
276
|
+
redis.call('ZREM', KEYS[2], probeToken)
|
|
277
|
+
if tonumber(deadline) <= now then
|
|
278
|
+
-- The lease elapsed while the probe was still running: its slot was
|
|
279
|
+
-- recoverable, so the result cannot be counted. Release the slot it still
|
|
280
|
+
-- occupied so the probe count keeps matching the token set.
|
|
281
|
+
probeCnt = math.max(0, probeCnt - 1)
|
|
282
|
+
redis.call('HSET', KEYS[1], 'probeCount', probeCnt)
|
|
283
|
+
return {0, sc, gen}
|
|
284
|
+
end
|
|
285
|
+
if state ~= 'half-open' or gen ~= expectGen then
|
|
286
|
+
return {0, sc, gen}
|
|
287
|
+
end
|
|
288
|
+
probeCnt = math.max(0, probeCnt - 1)
|
|
289
|
+
if outcome == 'failure' then
|
|
290
|
+
gen = gen + 1
|
|
291
|
+
redis.call('HSET', KEYS[1], 'state', 'open', 'generation', gen,
|
|
292
|
+
'openedAt', now, 'probeCount', 0, 'probeSuccesses', 0)
|
|
293
|
+
-- Re-opened: OPEN must not expire (see breakerObserveV1 TTL policy).
|
|
294
|
+
redis.call('PERSIST', KEYS[1])
|
|
295
|
+
-- In-flight probes belong to the dead generation; free their slots so the
|
|
296
|
+
-- next recovery window starts with full capacity.
|
|
297
|
+
redis.call('DEL', KEYS[2])
|
|
298
|
+
return {2, 1, gen}
|
|
299
|
+
end
|
|
300
|
+
probeSucc = probeSucc + 1
|
|
301
|
+
if probeSucc >= halfOpenSucc then
|
|
302
|
+
gen = gen + 1
|
|
303
|
+
redis.call('HSET', KEYS[1], 'state', 'closed', 'generation', gen,
|
|
304
|
+
'probeCount', 0, 'probeSuccesses', 0)
|
|
305
|
+
-- Newly CLOSED: expire for cleanup, but never before the window it governs.
|
|
306
|
+
redis.call('PEXPIRE', KEYS[1], math.max(openMs * 2, windowTtl))
|
|
307
|
+
redis.call('DEL', KEYS[2])
|
|
308
|
+
return {2, 0, gen}
|
|
309
|
+
end
|
|
310
|
+
redis.call('HSET', KEYS[1], 'probeSuccesses', probeSucc, 'probeCount', probeCnt)
|
|
311
|
+
-- Still HALF_OPEN: must not expire while recovery is in progress.
|
|
312
|
+
redis.call('PERSIST', KEYS[1])
|
|
313
|
+
return {1, 2, gen}
|
|
314
|
+
`
|