Skip to main content

av_state/
redis_store.rs

1//! Redis-backed `StateStore` — the distributed budget/state layer for
2//! multi-replica deployments.
3//!
4//! Check-and-spend runs as a server-side Lua script so it is atomic across
5//! distributed clients — same contract as `InMemoryStore::try_spend`.
6//! Contract tests live in `tests/redis_contract.rs`, gated on `AV_REDIS_URL`
7//! (skipped loudly when unset — a skipped gate prints, never silently passes).
8
9use crate::store::{Spend, StateError, StateStore};
10use redis::Commands;
11
12/// TTL (seconds) applied to every counter key this store touches (the
13/// spend/add INCRBY paths and the refund clamp). 24 h: counters are
14/// session-scoped and the TTL is the backstop that keeps abandoned
15/// sessions from leaking Redis memory forever. NOTE the prod/dev
16/// divergence this creates: `InMemoryStore` never expires, so a session
17/// idle longer than this window has its budget counters silently reset
18/// against Redis only. Callers must keep session lifetimes within this
19/// window (or persist budgets elsewhere) — see the `StateStore` trait
20/// docs on counter lifetime.
21const BUDGET_COUNTER_TTL_SECS: u64 = 86_400;
22/// One-shot refund replay markers (see `REFUND_LUA`) only need to
23/// outlive the cluster driver's transparent retry window (seconds);
24/// 5 minutes is generous without accumulating a day of marker keys.
25const REFUND_MARKER_TTL_SECS: u64 = 300;
26
27/// Atomic check-and-spend using subtraction so `current + amount` never rounds.
28static TRY_SPEND_LUA: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
29    format!(
30        r"
31for i, key in ipairs(KEYS) do
32    local current = tonumber(redis.call('GET', key) or '0')
33    local amount = tonumber(ARGV[(i - 1) * 2 + 1])
34    local limit = tonumber(ARGV[(i - 1) * 2 + 2])
35    if current > limit or amount > limit - current then
36        -- Refresh TTL on every EXISTING key
37        -- in this invocation before returning the refusal. Without
38        -- this, a session that hit its cap and keeps retrying (every
39        -- attempt refused, commit loop never reached) has its counter
40        -- expire {BUDGET_COUNTER_TTL_SECS}s after the last SUCCESSFUL
41        -- spend — the next attempt then sees current=0 and the cap
42        -- silently RESETS mid-session, doubling the budget. The TTL's
43        -- purpose is reclaiming ABANDONED sessions; an actively
44        -- retrying session is not abandoned.
45        for j, k in ipairs(KEYS) do
46            if redis.call('EXISTS', k) == 1 then
47                redis.call('EXPIRE', k, {BUDGET_COUNTER_TTL_SECS})
48            end
49        end
50        -- Refusal shape: {{-1, refusal_index_1_based}}.
51        -- The leading -1 discriminates from the commit shape
52        -- ({{post_commit_min_remaining, 0}}). See TrySpendOutcome
53        -- in store.rs for rationale.
54        return {{-1, i}}
55    end
56end
57-- R66 F3: compute post-commit min-headroom inside the SAME atomic
58-- EVAL that commits the spends, so `BudgetDecision::Allowed
59-- {{ remaining }}` cannot race with a concurrent
60-- remove_prefix/spend/refund on the same keys. Every key shares
61-- the caller's session hash tag, so the whole script runs on ONE
62-- server-side slot.
63local min_remaining = -1
64for i, key in ipairs(KEYS) do
65    local amount = tonumber(ARGV[(i - 1) * 2 + 1])
66    local limit = tonumber(ARGV[(i - 1) * 2 + 2])
67    local new = redis.call('INCRBY', key, amount)
68    redis.call('EXPIRE', key, {BUDGET_COUNTER_TTL_SECS})
69    local remaining = limit - new
70    if remaining < 0 then
71        remaining = 0
72    end
73    if min_remaining == -1 or remaining < min_remaining then
74        min_remaining = remaining
75    end
76end
77if min_remaining == -1 then
78    -- Empty KEYS: sentinel meaning `u64::MAX` on the client side.
79    min_remaining = -1
80end
81-- Commit shape: {{min_remaining, 0}}. See TRY_SPEND_LUA_REFUSED_SENTINEL
82-- in redis_store.rs.
83return {{min_remaining, 0}}
84"
85    )
86});
87
88static ADD_LUA: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
89    format!(
90        r"
91local current = tonumber(redis.call('GET', KEYS[1]) or '0')
92local amount = tonumber(ARGV[1])
93local limit = tonumber(ARGV[2])
94if current > limit or amount > limit - current then
95    -- Keep an actively-refused counter
96    -- alive — see the twin comment in TRY_SPEND_LUA.
97    if redis.call('EXISTS', KEYS[1]) == 1 then
98        redis.call('EXPIRE', KEYS[1], {BUDGET_COUNTER_TTL_SECS})
99    end
100    return -1
101end
102local result = redis.call('INCRBY', KEYS[1], ARGV[1])
103-- Match TRY_SPEND_LUA's TTL. Without this, any counter touched
104-- only through `add()` (bookkeeping, telemetry, non-budget spending)
105-- persists forever in Redis; over a long-running deployment that
106-- silently leaks memory until Redis OOMs. The two APIs must be
107-- interchangeable from the persistence perspective.
108redis.call('EXPIRE', KEYS[1], {BUDGET_COUNTER_TTL_SECS})
109return result
110"
111    )
112});
113
114static FETCH_MAX_LUA: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
115    format!(
116        r"
117local current = tonumber(redis.call('GET', KEYS[1]) or '0')
118local value = tonumber(ARGV[1])
119local limit = tonumber(ARGV[2])
120if not current or current < 0 or current > limit then return -1 end
121if value > current or redis.call('EXISTS', KEYS[1]) == 0 then
122    redis.call('SET', KEYS[1], ARGV[1])
123    current = value
124end
125redis.call('EXPIRE', KEYS[1], {BUDGET_COUNTER_TTL_SECS})
126return current
127"
128    )
129});
130
131/// Saturating single-key refund. `EXISTS`-gated to never resurrect
132/// a `remove_prefix`-cleared cell; `SET key 0 EX {BUDGET_COUNTER_TTL_SECS}`
133/// on underflow keeps the counter TTL-aligned; `EXPIRE` on the
134/// non-underflow path matches the same discipline. Extracted to a
135/// static (R69) so `every_counter_script_applies_the_shared_ttl`
136/// enrolls it in the TTL-drift regression test (R67 review L3).
137///
138/// KEYS[2] is a one-shot idempotency marker (same hash tag as the
139/// counter — a suffix on KEYS[1] — so cluster mode keeps the script
140/// single-slot). The cluster driver transparently RETRIES commands on
141/// connection loss (redis-rs `DEFAULT_RETRIES`), and a refund replayed
142/// after its first execution committed-but-lost-the-reply would credit
143/// the budget twice — refunding tokens that were only debited once,
144/// i.e. spendable budget minted out of a network flap. `SET NX` on the
145/// marker makes the replay a no-op. Spend-side scripts deliberately
146/// have no marker: their replay double-DEBITS, which fails
147/// conservative (the documented EVAL-uncertainty posture), never
148/// budget-inflating.
149static REFUND_LUA: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
150    format!(
151        r"
152if redis.call('SET', KEYS[2], '1', 'NX', 'EX', {REFUND_MARKER_TTL_SECS}) == false then
153    return -1
154end
155if redis.call('EXISTS', KEYS[1]) == 0 then
156    return 0
157end
158local new = redis.call('DECRBY', KEYS[1], ARGV[1])
159if new < 0 then
160    redis.call('SET', KEYS[1], 0, 'EX', {BUDGET_COUNTER_TTL_SECS})
161    return 0
162end
163redis.call('EXPIRE', KEYS[1], {BUDGET_COUNTER_TTL_SECS})
164return new
165"
166    )
167});
168
169/// Atomic multi-key refund. Per-key semantics match `REFUND_LUA`
170/// exactly (EXISTS gate + DECRBY + clamp-at-zero via SET with
171/// fresh TTL / EXPIRE refresh on non-underflow). Extracted to a
172/// static (R69) so the same TTL-drift test enrolls it.
173///
174/// The trailing key (`KEYS[#KEYS]`) is the replay marker — see
175/// `REFUND_LUA`. The decrement passes `ARGV[i]` to DECRBY as the raw
176/// integer STRING: the previous `tonumber(ARGV[i])` converted through
177/// a Lua double, so an `i64::MAX`-clamped amount rounded to 2^63,
178/// DECRBY rejected it mid-loop, and earlier keys in the same batch
179/// stayed decremented while later ones were skipped — a partial
180/// commit the in-memory backend (which clamps every entry) never
181/// produces. Redis parses the string as an exact i64.
182static REFUND_MANY_LUA: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
183    format!(
184        r"
185if redis.call('SET', KEYS[#KEYS], '1', 'NX', 'EX', {REFUND_MARKER_TTL_SECS}) == false then
186    return -1
187end
188for i = 1, #KEYS - 1 do
189    local key = KEYS[i]
190    if redis.call('EXISTS', key) == 1 then
191        local new = redis.call('DECRBY', key, ARGV[i])
192        if new < 0 then
193            redis.call('SET', key, 0, 'EX', {BUDGET_COUNTER_TTL_SECS})
194        else
195            redis.call('EXPIRE', key, {BUDGET_COUNTER_TTL_SECS})
196        end
197    end
198end
199return 0
200"
201    )
202});
203
204/// Redis-backed store. Connections are pooled internally (r2d2 for both
205/// single-node and cluster; the redis crate implements
206/// `r2d2::ManageConnection` for `ClusterClient` directly).
207///
208/// **EVAL uncertainty on network drop.** `try_spend_many` / `add` invoke
209/// atomic Lua scripts server-side; if the connection breaks between the
210/// server's INCRBY commit and the client's response read, the caller
211/// sees `StateError::Backend` and cannot distinguish "commit succeeded,
212/// response lost" from "commit never ran". Sandbox path treats
213/// backend-error as blocked (fails closed for the request) — but a
214/// subsequent client retry that lands with the same intent then hits a
215/// pre-debited counter and cumulates the two spends into one intended
216/// tool call, overcharging the budget by the retry's amount. The
217/// 24 h counter TTL bounds unbounded growth, and refund-on-loss-race
218/// (`refund_tool_call`) recovers the common cases, but the strictly
219/// idempotent shape would require a client-supplied request nonce
220/// stored briefly in Redis — deferred as a design change.
221pub struct RedisStore {
222    backend: RedisBackend,
223}
224
225enum RedisBackend {
226    Single(r2d2::Pool<RedisConnectionManager>),
227    Cluster(r2d2::Pool<redis::cluster::ClusterClient>),
228}
229
230struct RedisConnectionManager {
231    client: redis::Client,
232    timeout: std::time::Duration,
233}
234
235impl r2d2::ManageConnection for RedisConnectionManager {
236    type Connection = redis::Connection;
237    type Error = redis::RedisError;
238
239    fn connect(&self) -> Result<Self::Connection, Self::Error> {
240        let connection = self.client.get_connection_with_timeout(self.timeout)?;
241        connection.set_read_timeout(Some(self.timeout))?;
242        connection.set_write_timeout(Some(self.timeout))?;
243        Ok(connection)
244    }
245
246    fn is_valid(&self, connection: &mut Self::Connection) -> Result<(), Self::Error> {
247        redis::cmd("PING").query(connection)
248    }
249
250    fn has_broken(&self, _connection: &mut Self::Connection) -> bool {
251        false
252    }
253}
254
255/// Verify one connection during startup, then grow on demand. r2d2's
256/// default minimum equals the maximum; requiring 32 complete Cluster
257/// connections can exhaust the startup deadline during concurrent TLS setup.
258fn redis_pool_builder<M: r2d2::ManageConnection>() -> r2d2::Builder<M> {
259    r2d2::Pool::builder()
260        .max_size(32)
261        .min_idle(Some(1))
262        .connection_timeout(std::time::Duration::from_secs(2))
263}
264
265impl RedisStore {
266    /// Connect to `url` (e.g. `rediss://redis.example.com:6379`).
267    /// TLS verifies the server name and the native certificate trust store;
268    /// `SSL_CERT_FILE` can supply a PEM trust bundle for a private CA.
269    /// Comma-separated URLs select Redis Cluster mode. All seeds must use
270    /// the same transport, and disabling TLS verification is never allowed.
271    pub fn connect(url: &str) -> Result<Self, StateError> {
272        // Other connectors can enable a second rustls crypto backend through
273        // Cargo feature unification. Select one explicitly before redis-rs
274        // creates its ClientConfig; preserve a provider installed by the host.
275        let _ = rustls_tls::crypto::ring::default_provider().install_default();
276        let nodes: Vec<String> = url
277            .split(',')
278            .map(str::trim)
279            .filter(|node| !node.is_empty())
280            .map(str::to_owned)
281            .collect();
282        validate_transports(&nodes)?;
283        if nodes.len() > 1 {
284            // Previously one `ClusterConnection` behind a mutex —
285            // every quota/budget operation across all sessions serialized on
286            // a single socket, which under 10k-connection load stalled
287            // admission long enough to blow upstream timeouts (observed as
288            // 502 "upstream timed out" in the 10k SLA gate). Pool cluster
289            // connections exactly like the single-node path.
290            let client = redis::cluster::ClusterClientBuilder::new(nodes)
291                .connection_timeout(std::time::Duration::from_secs(2))
292                .response_timeout(std::time::Duration::from_secs(2))
293                .build()
294                .map_err(|e| StateError::Backend(e.to_string()))?;
295            let pool = redis_pool_builder()
296                .build(client)
297                .map_err(|e| StateError::Backend(e.to_string()))?;
298            return Ok(Self {
299                backend: RedisBackend::Cluster(pool),
300            });
301        }
302        let client = redis::Client::open(url).map_err(|e| StateError::Backend(e.to_string()))?;
303        let manager = RedisConnectionManager {
304            client,
305            timeout: std::time::Duration::from_secs(2),
306        };
307        let pool = redis_pool_builder()
308            .build(manager)
309            .map_err(|e| StateError::Backend(e.to_string()))?;
310        Ok(Self {
311            backend: RedisBackend::Single(pool),
312        })
313    }
314}
315
316fn validate_transports(nodes: &[String]) -> Result<(), StateError> {
317    use redis::IntoConnectionInfo as _;
318    let mut previous_tls = None;
319    for node in nodes {
320        let info = node
321            .as_str()
322            .into_connection_info()
323            .map_err(|error| StateError::Backend(error.to_string()))?;
324        let tls = match info.addr {
325            redis::ConnectionAddr::TcpTls { insecure: true, .. } => {
326                return Err(StateError::Backend(
327                    "Redis TLS certificate verification cannot be disabled".into(),
328                ));
329            }
330            redis::ConnectionAddr::TcpTls { .. } => true,
331            _ => false,
332        };
333        if previous_tls.is_some_and(|previous| previous != tls) {
334            return Err(StateError::Backend(
335                "Redis Cluster seeds must all use the same transport".into(),
336            ));
337        }
338        previous_tls = Some(tls);
339    }
340    Ok(())
341}
342
343fn add_on<C: redis::ConnectionLike>(conn: &mut C, key: &str, delta: u64) -> Result<u64, StateError> {
344    if delta > av_core::error::JCS_SAFE_MAX {
345        return Err(StateError::Overflow(key.to_owned()));
346    }
347    let value: i64 = redis::Script::new(&ADD_LUA)
348        .key(key)
349        .arg(delta)
350        .arg(av_core::error::JCS_SAFE_MAX)
351        .invoke(conn)
352        .map_err(|e| StateError::Backend(e.to_string()))?;
353    if value < 0 {
354        return Err(StateError::Overflow(key.to_owned()));
355    }
356    u64::try_from(value).map_err(|_| StateError::Overflow(key.to_owned()))
357}
358
359fn get_on<C: redis::ConnectionLike>(conn: &mut C, key: &str) -> Result<u64, StateError> {
360    let value: Option<i64> = conn.get(key).map_err(|e| StateError::Backend(e.to_string()))?;
361    let value = match value {
362        None => 0,
363        Some(v) if v < 0 => return Err(StateError::Overflow(key.to_owned())),
364        Some(v) => u64::try_from(v).map_err(|_| StateError::Overflow(key.to_owned()))?,
365    };
366    if value > av_core::error::JCS_SAFE_MAX {
367        return Err(StateError::Overflow(key.to_owned()));
368    }
369    Ok(value)
370}
371
372fn fetch_max_on<C: redis::ConnectionLike>(conn: &mut C, key: &str, value: u64) -> Result<u64, StateError> {
373    if value > av_core::error::JCS_SAFE_MAX {
374        return Err(StateError::Overflow(key.to_owned()));
375    }
376    let result: i64 = redis::Script::new(&FETCH_MAX_LUA)
377        .key(key)
378        .arg(value)
379        .arg(av_core::error::JCS_SAFE_MAX)
380        .invoke(conn)
381        .map_err(|e| StateError::Backend(e.to_string()))?;
382    u64::try_from(result).map_err(|_| StateError::Overflow(key.to_owned()))
383}
384
385fn spend_many_on<C: redis::ConnectionLike>(
386    conn: &mut C,
387    spends: &[Spend],
388) -> Result<crate::TrySpendOutcome, StateError> {
389    // Shared duplicate-key guard: the Lua script reads
390    // GET(key) once per iteration in the check phase, so two spends on
391    // the same key each see the pre-commit value — same hazard as the
392    // in-memory backend, one implementation.
393    crate::store::refuse_duplicate_spend_keys(spends)?;
394    for spend in spends {
395        if spend.amount > av_core::error::JCS_SAFE_MAX || spend.limit > av_core::error::JCS_SAFE_MAX {
396            return Err(StateError::Overflow(spend.key.clone()));
397        }
398    }
399    if spends.is_empty() {
400        // Match the sentinel InMemoryStore returns for the empty-
401        // slice case. No EVAL round-trip; nothing to spend.
402        return Ok(crate::TrySpendOutcome::Committed {
403            post_commit_min_remaining: u64::MAX,
404        });
405    }
406    let script = redis::Script::new(&TRY_SPEND_LUA);
407    let mut invocation = script.prepare_invoke();
408    for spend in spends {
409        invocation.key(&spend.key).arg(spend.amount).arg(spend.limit);
410    }
411    // Return shape: [i64, i64]. Two disjoint carriers:
412    //   Commit:  [post_commit_min_remaining, 0]  (min_remaining
413    //            may be -1 to sentinel u64::MAX for empty KEYS;
414    //            spends.is_empty() short-circuits above so we
415    //            should not see -1 here).
416    //   Refusal: [-1, refusal_index_1_based]
417    let outcome: (i64, i64) = invocation
418        .invoke(conn)
419        .map_err(|e| StateError::Backend(e.to_string()))?;
420    match outcome {
421        (-1, refused) => {
422            let refused = usize::try_from(refused - 1)
423                .map_err(|_| StateError::Backend(format!("invalid Lua failure index {refused}")))?;
424            Ok(crate::TrySpendOutcome::Refused { index: refused })
425        }
426        (min_remaining, 0) => {
427            // -1 is the empty-KEYS sentinel (see the Lua); in practice
428            // unreachable here (guarded above) but honour it.
429            let remaining = if min_remaining < 0 {
430                u64::MAX
431            } else {
432                u64::try_from(min_remaining).unwrap_or(0)
433            };
434            Ok(crate::TrySpendOutcome::Committed {
435                post_commit_min_remaining: remaining,
436            })
437        }
438        (a, b) => Err(StateError::Backend(format!(
439            "TRY_SPEND_LUA returned unrecognised shape [{a}, {b}]"
440        ))),
441    }
442}
443
444impl StateStore for RedisStore {
445    fn fetch_max(&self, key: &str, value: u64) -> Result<u64, StateError> {
446        match &self.backend {
447            RedisBackend::Single(pool) => fetch_max_on(
448                &mut pool.get().map_err(|e| StateError::Backend(e.to_string()))?,
449                key,
450                value,
451            ),
452            RedisBackend::Cluster(pool) => fetch_max_on(
453                &mut *pool.get().map_err(|e| StateError::Backend(e.to_string()))?,
454                key,
455                value,
456            ),
457        }
458    }
459    fn counter_ttl_secs(&self) -> Option<u64> {
460        Some(BUDGET_COUNTER_TTL_SECS)
461    }
462
463    fn add(&self, key: &str, delta: u64) -> Result<u64, StateError> {
464        match &self.backend {
465            RedisBackend::Single(pool) => add_on(
466                &mut pool.get().map_err(|e| StateError::Backend(e.to_string()))?,
467                key,
468                delta,
469            ),
470            RedisBackend::Cluster(pool) => add_on(
471                &mut *pool.get().map_err(|e| StateError::Backend(e.to_string()))?,
472                key,
473                delta,
474            ),
475        }
476    }
477
478    fn get(&self, key: &str) -> Result<u64, StateError> {
479        match &self.backend {
480            RedisBackend::Single(pool) => get_on(
481                &mut pool.get().map_err(|e| StateError::Backend(e.to_string()))?,
482                key,
483            ),
484            RedisBackend::Cluster(pool) => get_on(
485                &mut *pool.get().map_err(|e| StateError::Backend(e.to_string()))?,
486                key,
487            ),
488        }
489    }
490
491    // `try_spend` uses the default trait impl (routes through
492    // `try_spend_many` with a 1-element slice). Removed the standalone
493    // wrapper in R68 — the default is exactly the same shape.
494
495    fn try_spend_many(&self, spends: &[Spend]) -> Result<crate::TrySpendOutcome, StateError> {
496        match &self.backend {
497            RedisBackend::Single(pool) => spend_many_on(
498                &mut pool.get().map_err(|e| StateError::Backend(e.to_string()))?,
499                spends,
500            ),
501            RedisBackend::Cluster(pool) => spend_many_on(
502                &mut *pool.get().map_err(|e| StateError::Backend(e.to_string()))?,
503                spends,
504            ),
505        }
506    }
507
508    fn remove(&self, key: &str) {
509        // Mirror the `refund` treatment
510        // — best-effort by contract, but a completely-silent Redis
511        // failure on the cleanup path leaves an operator with no
512        // signal that a session's stale key wasn't removed. The next
513        // recycled-session-id can then inherit the counter. Warn on
514        // both pool-get and DEL failure with structured fields.
515        let outcome: Result<(), redis::RedisError> = match &self.backend {
516            RedisBackend::Single(pool) => match pool.get() {
517                Ok(mut connection) => connection.del(key),
518                Err(error) => Err(redis::RedisError::from((
519                    redis::ErrorKind::IoError,
520                    "connection pool exhausted",
521                    error.to_string(),
522                ))),
523            },
524            RedisBackend::Cluster(pool) => match pool.get() {
525                Ok(mut connection) => connection.del(key),
526                Err(error) => Err(redis::RedisError::from((
527                    redis::ErrorKind::IoError,
528                    "connection pool exhausted",
529                    error.to_string(),
530                ))),
531            },
532        };
533        if let Err(error) = outcome {
534            let error_kind = error.kind();
535            tracing::warn!(
536                target: "av_state::redis",
537                kind = ?error_kind,
538                detail = %error,
539                "Redis remove failed silently; per-key cleanup was not applied — \
540                 a subsequent session recycling this key may inherit its counter \
541                 until the 24 h TTL expires"
542            );
543        }
544    }
545
546    /// Saturating refund via `DECRBY` + a MAX(0) clamp.
547    /// Best-effort — errors are silently swallowed so a Redis blip on
548    /// the compensation path can never turn a lost-race response into
549    /// a 5xx.
550    ///
551    /// NEVER resurrect a key that was already `DEL`'d
552    /// by a concurrent `remove_prefix`. The prior implementation
553    /// called `DECRBY` on the raw key: Redis initialises a missing
554    /// key to 0 first, so `DECRBY` returned `-amount` and the
555    /// `MAX(0)` clamp branch did `SET key 0` (no `EX`), producing
556    /// a permanent TTL-less key. Under the lost-claim-
557    /// plus-idle-close ordering (mcp_call sandbox-gate debit
558    /// races with the reconciler's clear_budget_state), the
559    /// refund path leaked one-to-three TTL-less keys per session
560    /// — attacker-choosable memory growth against the exact class
561    /// the `clear_budget_state` doc in reconciler.rs documents as
562    /// impossible. Fix: gate
563    /// the whole DECRBY on `EXISTS`. If the session was cleared,
564    /// the budget is already gone and there is nothing to
565    /// compensate; the refund is a silent no-op. If the session
566    /// is alive, we DECRBY-clamp AND refresh the counter TTL to
567    /// match `TRY_SPEND_LUA` (a plain DECRBY does not refresh).
568    fn refund(&self, key: &str, amount: u64) {
569        // Redis DECRBY takes i64. Cap at i64::MAX so a caller passing
570        // u64::MAX cannot silently wrap into a negative value.
571        let amount = i64::try_from(amount).unwrap_or(i64::MAX);
572        // Fresh replay marker per logical refund: the same-slot suffix
573        // keeps cluster mode single-slot; a driver-level retry reuses
574        // the SAME marker and no-ops (see REFUND_LUA).
575        let marker = format!("{key}:r:{}", av_core::new_event_uid());
576        // Refund is best-effort by the trait contract, but
577        // its silent-swallow used to be COMPLETELY invisible — no log,
578        // no metric — so an operator seeing budget depletion during a
579        // Redis outage had no signal that compensation had been
580        // failing. Log the failed refund with structured fields so
581        // downstream aggregators (Vector → OTLP → SIEM) can alert on
582        // it. The response path still stays 200 OK so the caller
583        // never learns a compensation failure as a 5xx.
584        let outcome: Result<i64, redis::RedisError> = match &self.backend {
585            RedisBackend::Single(pool) => match pool.get() {
586                Ok(mut connection) => redis::Script::new(&REFUND_LUA)
587                    .key(key)
588                    .key(&marker)
589                    .arg(amount)
590                    .invoke(&mut *connection),
591                Err(error) => Err(redis::RedisError::from((
592                    redis::ErrorKind::IoError,
593                    "connection pool exhausted",
594                    error.to_string(),
595                ))),
596            },
597            RedisBackend::Cluster(pool) => match pool.get() {
598                Ok(mut connection) => redis::Script::new(&REFUND_LUA)
599                    .key(key)
600                    .key(&marker)
601                    .arg(amount)
602                    .invoke(&mut *connection),
603                Err(error) => Err(redis::RedisError::from((
604                    redis::ErrorKind::IoError,
605                    "connection pool exhausted",
606                    error.to_string(),
607                ))),
608            },
609        };
610        if let Err(error) = outcome {
611            let error_kind = error.kind();
612            tracing::warn!(
613                target: "av_state::redis",
614                kind = ?error_kind,
615                detail = %error,
616                "Redis refund failed silently; per-key compensation was not applied — \
617                 budget counter may remain over-charged until the 24 h TTL expires or a \
618                 successful spend/refund lands"
619            );
620        }
621    }
622
623    /// R66 F2: atomic multi-key refund via a single Lua script.
624    /// All keys share the caller's Redis Cluster hash tag (guaranteed
625    /// by `ActionBudget::session_prefix` which wraps its digest in
626    /// `{...}`), so cross-slot dispatch is impossible and the script
627    /// runs on ONE server-side EVAL. Per-key semantics match
628    /// `refund` exactly: EXISTS gate (never resurrect a
629    /// remove_prefix-cleared cell), DECRBY, clamp-at-zero via SET
630    /// with fresh TTL on underflow, EXPIRE refresh on non-underflow.
631    /// Best-effort like `refund` — Redis errors log-warn but never
632    /// propagate.
633    fn refund_many(&self, refunds: &[crate::Refund]) {
634        let Some(first) = refunds.first() else {
635            return;
636        };
637        // Replay marker key (see REFUND_LUA): derived from the first
638        // refund key so it shares the batch's hash tag (all keys carry
639        // the same `{...}` tag per the invariant above) — single-slot
640        // in cluster mode.
641        let marker = format!("{}:r:{}", first.key, av_core::new_event_uid());
642        let outcome: Result<i64, redis::RedisError> = match &self.backend {
643            RedisBackend::Single(pool) => match pool.get() {
644                Ok(mut connection) => {
645                    let script = redis::Script::new(&REFUND_MANY_LUA);
646                    let mut invocation = script.prepare_invoke();
647                    for r in refunds {
648                        invocation.key(&r.key);
649                        // Cap at i64::MAX so a caller passing u64::MAX
650                        // cannot silently wrap into a negative value.
651                        invocation.arg(i64::try_from(r.amount).unwrap_or(i64::MAX));
652                    }
653                    invocation.key(&marker);
654                    invocation.invoke(&mut *connection)
655                }
656                Err(error) => Err(redis::RedisError::from((
657                    redis::ErrorKind::IoError,
658                    "connection pool exhausted",
659                    error.to_string(),
660                ))),
661            },
662            RedisBackend::Cluster(pool) => match pool.get() {
663                Ok(mut connection) => {
664                    let script = redis::Script::new(&REFUND_MANY_LUA);
665                    let mut invocation = script.prepare_invoke();
666                    for r in refunds {
667                        invocation.key(&r.key);
668                        invocation.arg(i64::try_from(r.amount).unwrap_or(i64::MAX));
669                    }
670                    invocation.key(&marker);
671                    invocation.invoke(&mut *connection)
672                }
673                Err(error) => Err(redis::RedisError::from((
674                    redis::ErrorKind::IoError,
675                    "connection pool exhausted",
676                    error.to_string(),
677                ))),
678            },
679        };
680        if let Err(error) = outcome {
681            let error_kind = error.kind();
682            tracing::warn!(
683                target: "av_state::redis",
684                kind = ?error_kind,
685                detail = %error,
686                refunds = refunds.len(),
687                "Redis refund_many failed silently; per-key compensation was not applied — \
688                 budget counters may remain over-charged until the 24 h TTL expires or a \
689                 successful spend/refund lands"
690            );
691        }
692    }
693
694    /// Whole-session counter cleanup. Previously left as the
695    /// trait's default no-op on the assumption that the 24 h TTL made it
696    /// pure hygiene — but `SessionRegistry::get_or_open` recycles a
697    /// finalized session id into a fresh open session, which then spends
698    /// against the SAME hash-tagged keys. Against `InMemoryStore` (dev,
699    /// CI, and every budget test) the recycled incarnation starts from
700    /// zero; against Redis it silently inherited up to 24 h of the prior
701    /// incarnation's `tokens`/`total_calls`/`tool:*`/`payout` counters —
702    /// the exact cross-backend divergence class the
703    /// `add`/`try_spend_many` alignment closed.
704    ///
705    /// All of a session's keys share one cluster slot by construction
706    /// (`ActionBudget::session_prefix` wraps the digest in a `{hash-tag}`),
707    /// so a SCAN cursor + bounded DEL batches — routed by the prefix
708    /// pattern's hash-tag to the owning master in cluster mode — reach
709    /// every key on both single-node and cluster backends.
710    ///
711    /// SCAN, not KEYS. The first take used
712    /// `redis.call('KEYS', pattern)` inside a Lua script, which is
713    /// O(entire keyspace per node) AND blocks the Redis event loop
714    /// atomically for the whole scan+delete. On a shared Redis with high
715    /// session churn or attacker-chosen ids, every close stalled all
716    /// other Redis clients (including hot-path `try_spend_many` gates)
717    /// for the duration. SCAN is O(matches) amortized and yields between
718    /// batches, so per-session cleanup no longer blocks concurrent
719    /// traffic.
720    ///
721    /// `route_command` with an explicit slot for cluster
722    /// mode. An intermediate revision used
723    /// `Commands::scan_match` on the cluster connection, but bare
724    /// `SCAN` has no key argument — `RoutingInfo::for_routable` returns
725    /// `None`, and `ClusterConnection::request` treats that as an
726    /// immediate `UNROUTABLE_ERROR`. The iterator failed on
727    /// construction, the `Err(_) => return` arm swallowed it, and
728    /// cleanup silently did nothing on every cluster deployment.
729    /// Best-effort like `remove`/`refund`: any Redis blip on the
730    /// cleanup path must not fail the close.
731    fn remove_prefix(&self, prefix: &str) {
732        // `MATCH` glob treats `* ? [ ] \` as metacharacters. The prefix
733        // is `budget:{<32 hex>}:` today (no metacharacters), but escape
734        // defensively so a future prefix shape cannot over-match.
735        let mut pattern = String::with_capacity(prefix.len() + 1);
736        for c in prefix.chars() {
737            if matches!(c, '*' | '?' | '[' | ']' | '\\') {
738                pattern.push('\\');
739            }
740            pattern.push(c);
741        }
742        pattern.push('*');
743        match &self.backend {
744            RedisBackend::Single(pool) => {
745                if let Ok(mut connection) = pool.get() {
746                    scan_and_delete_single(&mut connection, &pattern);
747                }
748            }
749            RedisBackend::Cluster(pool) => {
750                if let Ok(mut connection) = pool.get() {
751                    // Route by the ORIGINAL (unescaped) prefix so the
752                    // hash-tag slot matches the one Redis derives from
753                    // the on-cluster keys. If the future adds a prefix
754                    // shape that contains any glob metachar, computing
755                    // the slot from the ESCAPED pattern would embed
756                    // `\` characters that never appear in the on-cluster
757                    // keys — SCAN would route to a slot no key lives on
758                    // and `remove_prefix` would silently no-op, letting
759                    // budget counters survive up to the 24 h TTL. Today's
760                    // prefix is metachar-free so escaping is a no-op and
761                    // this bug is latent; the fix protects against the
762                    // future shape change.
763                    scan_and_delete_cluster(&mut connection, &pattern, prefix);
764                }
765            }
766        }
767    }
768}
769
770/// Bounded SCAN+DEL loop against a single-node connection. Each SCAN
771/// call is O(COUNT) at the server; the loop terminates when the cursor
772/// returns to 0. DEL is batched so one round-trip retires up to
773/// `SCAN_COUNT` keys.
774fn scan_and_delete_single(conn: &mut redis::Connection, pattern: &str) {
775    const SCAN_COUNT: usize = 500;
776    let mut cursor: u64 = 0;
777    loop {
778        let scan: Result<(u64, Vec<String>), _> = redis::cmd("SCAN")
779            .arg(cursor)
780            .arg("MATCH")
781            .arg(pattern)
782            .arg("COUNT")
783            .arg(SCAN_COUNT)
784            .query(conn);
785        let (next, batch) = match scan {
786            Ok(pair) => pair,
787            Err(error) => {
788                // SCAN failing means we bail
789                // out mid-cleanup — the remaining keys survive with
790                // their TTLs, and a future session recycling this id
791                // (within 24 h) would inherit the leftover counters
792                // silently. Log with structured fields so an operator
793                // aggregating warns can spot chronic backend outages.
794                let error_kind = error.kind();
795                tracing::warn!(
796                    target: "av_state::redis",
797                    kind = ?error_kind,
798                    detail = %error,
799                    "Redis SCAN failed during remove_prefix; partial cleanup — surviving \
800                     keys will expire at the 24 h TTL or on the next successful \
801                     remove_prefix for the same prefix"
802                );
803                return;
804            }
805        };
806        if !batch.is_empty() {
807            let mut del = redis::cmd("DEL");
808            for key in &batch {
809                del.arg(key);
810            }
811            let outcome: Result<i64, _> = del.query(conn);
812            if let Err(error) = outcome {
813                let error_kind = error.kind();
814                tracing::warn!(
815                    target: "av_state::redis",
816                    kind = ?error_kind,
817                    detail = %error,
818                    batch_len = batch.len(),
819                    "Redis DEL batch failed during remove_prefix; these keys survive \
820                     until the 24 h TTL expires"
821                );
822            }
823        }
824        if next == 0 {
825            return;
826        }
827        cursor = next;
828    }
829}
830
831/// Cluster variant: bare `SCAN` has no key argument so
832/// `redis::cluster_routing::RoutingInfo::for_routable` returns `None`
833/// on it (the sync ClusterConnection then fails with
834/// `UNROUTABLE_ERROR`, silently deleting nothing — the
835/// regression this replaces). All matches share one hash-slot by
836/// construction (`ActionBudget::session_prefix` wraps the digest in
837/// `{hash-tag}`), so compute the slot from the prefix and route both
838/// SCAN and DEL through `route_command` to that specific master.
839///
840/// `pattern` carries the ESCAPED glob (metachars prefixed with `\`)
841/// for the SCAN MATCH argument. `route_prefix` carries the ORIGINAL
842/// unescaped prefix used SOLELY for slot computation — the hash-tag
843/// content Redis extracts from an on-cluster key is the RAW bytes,
844/// so a slot computed from an escaped pattern would miss on any
845/// prefix shape containing a glob metachar. Today's prefix is
846/// metachar-free so escape is a no-op; the separation protects
847/// against a future prefix shape change.
848fn scan_and_delete_cluster(conn: &mut redis::cluster::ClusterConnection, pattern: &str, route_prefix: &str) {
849    use redis::cluster_routing::{Route, RoutingInfo, SingleNodeRoutingInfo, SlotAddr};
850    const SCAN_COUNT: usize = 500;
851    let route_key = route_prefix.trim_end_matches('*');
852    let slot = redis::cluster_routing::get_slot(route_key.as_bytes());
853    let routing = RoutingInfo::SingleNode(SingleNodeRoutingInfo::SpecificNode(Route::new(
854        slot,
855        SlotAddr::Master,
856    )));
857    let mut cursor: u64 = 0;
858    loop {
859        let scan_cmd = {
860            let mut cmd = redis::cmd("SCAN");
861            cmd.arg(cursor)
862                .arg("MATCH")
863                .arg(pattern)
864                .arg("COUNT")
865                .arg(SCAN_COUNT);
866            cmd
867        };
868        let value = match conn.route_command(&scan_cmd, routing.clone()) {
869            Ok(value) => value,
870            Err(error) => {
871                // Parity with
872                // `scan_and_delete_single`. Cluster-mode SCAN failure
873                // was completely silent, so an operator watching
874                // `av_state::redis::warn` for cleanup problems saw
875                // nothing on cluster deployments even under Redis
876                // slowdown.
877                let error_kind = error.kind();
878                tracing::warn!(
879                    target: "av_state::redis",
880                    kind = ?error_kind,
881                    detail = %error,
882                    slot,
883                    "Redis cluster SCAN failed during remove_prefix; partial cleanup — \
884                     surviving keys will expire at the 24 h TTL or on the next successful \
885                     remove_prefix for the same prefix"
886                );
887                return;
888            }
889        };
890        let parsed: Result<(u64, Vec<String>), _> = redis::FromRedisValue::from_redis_value(&value);
891        let (next, batch) = match parsed {
892            Ok(pair) => pair,
893            Err(error) => {
894                let error_kind = error.kind();
895                tracing::warn!(
896                    target: "av_state::redis",
897                    kind = ?error_kind,
898                    detail = %error,
899                    "Redis cluster SCAN returned an unparsable response during remove_prefix; \
900                     aborting further cleanup for this prefix"
901                );
902                return;
903            }
904        };
905        if !batch.is_empty() {
906            let mut del = redis::cmd("DEL");
907            for key in &batch {
908                del.arg(key);
909            }
910            // Multi-key DEL is safe here because every match shares the
911            // slot we already computed above — route it explicitly so
912            // the driver does not need to inspect the keys again.
913            if let Err(error) = conn.route_command(&del, routing.clone()) {
914                let error_kind = error.kind();
915                tracing::warn!(
916                    target: "av_state::redis",
917                    kind = ?error_kind,
918                    detail = %error,
919                    slot,
920                    batch_len = batch.len(),
921                    "Redis cluster DEL batch failed during remove_prefix; these keys survive \
922                     until the 24 h TTL expires"
923                );
924            }
925        }
926        if next == 0 {
927            return;
928        }
929        cursor = next;
930    }
931}
932
933#[cfg(test)]
934mod tests {
935    #![allow(clippy::unwrap_used)]
936
937    use super::*;
938
939    struct StartupManager {
940        attempts: std::sync::Arc<std::sync::atomic::AtomicUsize>,
941        first_available: bool,
942        additional_available: std::sync::Arc<std::sync::atomic::AtomicBool>,
943    }
944
945    impl r2d2::ManageConnection for StartupManager {
946        type Connection = usize;
947        type Error = std::io::Error;
948
949        fn connect(&self) -> Result<Self::Connection, Self::Error> {
950            use std::sync::atomic::Ordering;
951            let attempt = self.attempts.fetch_add(1, Ordering::SeqCst);
952            if (attempt == 0 && self.first_available) || self.additional_available.load(Ordering::SeqCst) {
953                Ok(attempt)
954            } else {
955                Err(std::io::Error::other("additional connection is not ready"))
956            }
957        }
958
959        fn is_valid(&self, _connection: &mut Self::Connection) -> Result<(), Self::Error> {
960            Ok(())
961        }
962
963        fn has_broken(&self, _connection: &mut Self::Connection) -> bool {
964            false
965        }
966    }
967
968    #[test]
969    fn pool_verifies_one_connection_then_grows_on_demand_with_the_same_limits() {
970        use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
971        use std::sync::Arc;
972        use std::time::Duration;
973
974        let attempts = Arc::new(AtomicUsize::new(0));
975        let additional_available = Arc::new(AtomicBool::new(false));
976        // Only one connection is initially available. Requiring the default
977        // 32 connections would time out, despite a usable backend connection.
978        let pool = redis_pool_builder()
979            .build(StartupManager {
980                attempts: Arc::clone(&attempts),
981                first_available: true,
982                additional_available: Arc::clone(&additional_available),
983            })
984            .unwrap();
985        assert_eq!(attempts.load(Ordering::SeqCst), 1);
986        assert_eq!(pool.state().connections, 1);
987        assert_eq!(pool.connection_timeout(), Duration::from_secs(2));
988
989        additional_available.store(true, Ordering::SeqCst);
990        let held: Vec<_> = (0..32).map(|_| pool.get().unwrap()).collect();
991        assert_eq!(held.len(), 32);
992        assert_eq!(pool.state().connections, 32);
993        assert!(pool.get_timeout(Duration::from_millis(20)).is_err());
994        drop(held);
995        assert!(pool.get().is_ok());
996    }
997
998    #[test]
999    fn pool_startup_still_refuses_an_unavailable_backend() {
1000        use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1001        use std::sync::Arc;
1002
1003        let attempts = Arc::new(AtomicUsize::new(0));
1004        let result = redis_pool_builder().build(StartupManager {
1005            attempts: Arc::clone(&attempts),
1006            first_available: false,
1007            additional_available: Arc::new(AtomicBool::new(false)),
1008        });
1009        assert!(result.is_err());
1010        assert!(attempts.load(Ordering::SeqCst) > 0);
1011    }
1012
1013    /// Every Lua script that mutates a counter must apply the single
1014    /// shared TTL constant — a hardcoded-literal drift between the
1015    /// spend/add/refund paths would make some counters outlive others
1016    /// and silently split the session-expiry policy.
1017    ///
1018    /// R69: extended to enrol REFUND_LUA and REFUND_MANY_LUA. Both
1019    /// were inline `format!` strings pre-R69 (a per-call allocation);
1020    /// extraction to statics closed R67 review L3's coverage gap so
1021    /// a future edit that drops `EXPIRE` from either would be caught
1022    /// here.
1023    #[test]
1024    fn every_counter_script_applies_the_shared_ttl() {
1025        let ttl = BUDGET_COUNTER_TTL_SECS.to_string();
1026        for (name, script) in [
1027            ("TRY_SPEND_LUA", TRY_SPEND_LUA.as_str()),
1028            ("ADD_LUA", ADD_LUA.as_str()),
1029            ("FETCH_MAX_LUA", FETCH_MAX_LUA.as_str()),
1030            ("REFUND_LUA", REFUND_LUA.as_str()),
1031            ("REFUND_MANY_LUA", REFUND_MANY_LUA.as_str()),
1032        ] {
1033            assert!(
1034                script.contains(&format!("EXPIRE', KEYS[1], {ttl}"))
1035                    || script.contains(&format!("EXPIRE', key, {ttl}")),
1036                "{name} must EXPIRE with BUDGET_COUNTER_TTL_SECS: {script}"
1037            );
1038            // R111 F1: the R69 review-L3 guard was `!script.contains('{')`
1039            // — designed to catch unexpanded `{BUDGET_COUNTER_TTL_SECS}`
1040            // format placeholders left over from the format!() template.
1041            // But TRY_SPEND_LUA legitimately contains literal `{`
1042            // characters: the raw-string template writes `{{-1, i}}`
1043            // and `{{min_remaining, 0}}` for Lua-return-table syntax
1044            // (double-braces escape in format!()), producing literal
1045            // `{...}` in the compiled script. The blanket contains-'{'
1046            // scan panicked on every run — the guard has been silently
1047            // broken since it landed. Fix: check for the ONLY
1048            // placeholder name we actually use (BUDGET_COUNTER_TTL_SECS)
1049            // — any surviving occurrence of the identifier means the
1050            // format!() expansion didn't happen.
1051            assert!(
1052                !script.contains("BUDGET_COUNTER_TTL_SECS"),
1053                "{name} has an unexpanded BUDGET_COUNTER_TTL_SECS placeholder"
1054            );
1055        }
1056        // Refund replay/precision pins (pass-7 findings):
1057        // 1. Both refund scripts open with the SET-NX replay marker —
1058        //    the cluster driver transparently retries scripts on
1059        //    connection loss, and an unmarked refund replay CREDITS the
1060        //    budget twice (spendable budget minted from a network flap).
1061        // 2. REFUND_MANY passes ARGV[i] to DECRBY as the raw integer
1062        //    string — `tonumber` converted through a Lua double, so an
1063        //    i64::MAX amount rounded to 2^63, DECRBY rejected mid-loop,
1064        //    and the batch PARTIALLY committed (in-memory clamps every
1065        //    entry; the backends must agree).
1066        for (name, script) in [
1067            ("REFUND_LUA", REFUND_LUA.as_str()),
1068            ("REFUND_MANY_LUA", REFUND_MANY_LUA.as_str()),
1069        ] {
1070            assert!(
1071                script.contains("'NX', 'EX'"),
1072                "{name} must guard against driver-level replay with a SET-NX marker: {script}"
1073            );
1074        }
1075        assert!(
1076            !REFUND_MANY_LUA.contains("tonumber(ARGV"),
1077            "REFUND_MANY_LUA must pass ARGV to DECRBY as an exact integer string, \
1078             not through a Lua double: {}",
1079            REFUND_MANY_LUA.as_str()
1080        );
1081    }
1082}
1083
1084#[cfg(test)]
1085mod transport_tests {
1086    use super::validate_transports;
1087
1088    #[test]
1089    fn tls_is_enabled_and_verification_cannot_be_disabled() {
1090        assert!(validate_transports(&["rediss://localhost:6379".into()]).is_ok());
1091        let error = validate_transports(&["rediss://localhost:6379/#insecure".into()]);
1092        assert!(matches!(error, Err(super::StateError::Backend(message))
1093            if message.contains("verification cannot be disabled")));
1094    }
1095
1096    #[test]
1097    fn cluster_seeds_cannot_mix_tls_and_plaintext() {
1098        for nodes in [
1099            ["rediss://one:6379", "redis://two:6379"],
1100            ["redis://one:6379", "rediss://two:6379"],
1101        ] {
1102            assert!(validate_transports(&nodes.map(str::to_owned)).is_err());
1103        }
1104        assert!(validate_transports(&["rediss://one:6379".into(), "rediss://two:6379".into()]).is_ok());
1105    }
1106}