fix: pin retry_after_s to the next certain retry moment

retry_after_s never had a written definition, so each backend improvised
and they drifted apart. It now answers exactly one question: how long
until a retry is *certainly* worth attempting. OPEN has such a moment
(the cooldown deadline); HALF_OPEN does not, because the probe can come
back at any time -- so it reports 0.0, which already means "retry now"
elsewhere in the library.

Six exits are brought in line. The half-open rejection is the one issue
14 reported: it returned the probe lease remainder, a deadlock-guard
value derived from 2x the slowest timeout, so a 60s cooldown told
callers to wait 600s. Worse, retry.py fed that number into the source
cooldown memo, whose set_until only moves forward -- a source stayed
skipped in-process for the whole lease even after its probe succeeded
and the gate closed. That now writes an already-expired deadline, so
the memo goes back to recording only real OPEN cooldowns.

The other five were pre-existing memory/redis divergences hidden by a
contract-test blind spot (the suite pinned that a second caller gets
rejected, never what number it got): redis reported the probe TTL on
grant and the lease remainder on fenced-out writes, where memory has
always reported 0. Contract cases now pin all four half-open exits on
both backends, with 1:1 real-wait variants for redis since the
fake-clock ones skip there.
This commit is contained in:
2026-08-20 00:09:20 -04:00
parent 942af99856
commit 8edd3fb2cd
4 changed files with 114 additions and 27 deletions
+3 -11
View File
@@ -42,7 +42,8 @@ if state == 'open' and now < open_until then
return {0, state, epoch, 0, '', open_until - now}
end
if state == 'half_open' and now < probe_until then
return {0, state, epoch, 0, '', probe_until - now}
-- 探针在途: 无确定的可再试时刻 → 0(issue #14,与 memory `_remaining` 同口径)
return {0, state, epoch, 0, '', 0}
end
local next_probe_until = now + tonumber(ARGV[2])
@@ -50,7 +51,7 @@ redis.call('HSET', KEYS[1],
'state', 'half_open',
'probe_owner', ARGV[1],
'probe_until', next_probe_until)
return {1, 'half_open', epoch, 1, ARGV[1], tonumber(ARGV[2])}
return {1, 'half_open', epoch, 1, ARGV[1], 0}
"""
# M2.5 窗口/退避公共片段(拼接进 success/failure 脚本;Lua 脚本间无法共享函数)
@@ -124,8 +125,6 @@ end
local deadline = 0
if state == 'open' then
deadline = tonumber(redis.call('HGET', KEYS[1], 'open_until') or '0')
elseif state == 'half_open' then
deadline = tonumber(redis.call('HGET', KEYS[1], 'probe_until') or '0')
end
return {0, state, epoch, failures, math.max(deadline - now, 0)}
"""
@@ -156,8 +155,6 @@ if not matches then
local deadline = 0
if state == 'open' then
deadline = tonumber(redis.call('HGET', KEYS[1], 'open_until') or '0')
elseif state == 'half_open' then
deadline = tonumber(redis.call('HGET', KEYS[1], 'probe_until') or '0')
end
return {0, state, epoch, failures, math.max(deadline - now, 0)}
end
@@ -255,8 +252,6 @@ end
local deadline = 0
if state == 'open' then
deadline = tonumber(redis.call('HGET', KEYS[1], 'open_until') or '0')
elseif state == 'half_open' then
deadline = tonumber(redis.call('HGET', KEYS[1], 'probe_until') or '0')
end
return {0, state, epoch, failures, math.max(deadline - now, 0)}
"""
@@ -272,9 +267,6 @@ for _, key in ipairs(KEYS) do
if state == 'open' then
local deadline = tonumber(redis.call('HGET', key, 'open_until') or '0')
remaining = math.max(deadline - now, 0)
elseif state == 'half_open' then
local deadline = tonumber(redis.call('HGET', key, 'probe_until') or '0')
remaining = math.max(deadline - now, 0)
end
if minimum == nil or remaining < minimum then minimum = remaining end
end