Skip to main content

RedisStore

Struct RedisStore 

Source
pub struct RedisStore { /* private fields */ }
Expand description

Redis-backed store. Connections are pooled internally (r2d2 for both single-node and cluster; the redis crate implements r2d2::ManageConnection for ClusterClient directly).

EVAL uncertainty on network drop. try_spend_many / add invoke atomic Lua scripts server-side; if the connection breaks between the server’s INCRBY commit and the client’s response read, the caller sees StateError::Backend and cannot distinguish “commit succeeded, response lost” from “commit never ran”. Sandbox path treats backend-error as blocked (fails closed for the request) — but a subsequent client retry that lands with the same intent then hits a pre-debited counter and cumulates the two spends into one intended tool call, overcharging the budget by the retry’s amount. The 24 h counter TTL bounds unbounded growth, and refund-on-loss-race (refund_tool_call) recovers the common cases, but the strictly idempotent shape would require a client-supplied request nonce stored briefly in Redis — deferred as a design change.

Implementations§

Source§

impl RedisStore

Source

pub fn connect(url: &str) -> Result<Self, StateError>

Connect to url (e.g. rediss://redis.example.com:6379). TLS verifies the server name and the native certificate trust store; SSL_CERT_FILE can supply a PEM trust bundle for a private CA. Comma-separated URLs select Redis Cluster mode. All seeds must use the same transport, and disabling TLS verification is never allowed.

Trait Implementations§

Source§

impl StateStore for RedisStore

Source§

fn refund(&self, key: &str, amount: u64)

Saturating refund via DECRBY + a MAX(0) clamp. Best-effort — errors are silently swallowed so a Redis blip on the compensation path can never turn a lost-race response into a 5xx.

NEVER resurrect a key that was already DEL’d by a concurrent remove_prefix. The prior implementation called DECRBY on the raw key: Redis initialises a missing key to 0 first, so DECRBY returned -amount and the MAX(0) clamp branch did SET key 0 (no EX), producing a permanent TTL-less key. Under the lost-claim- plus-idle-close ordering (mcp_call sandbox-gate debit races with the reconciler’s clear_budget_state), the refund path leaked one-to-three TTL-less keys per session — attacker-choosable memory growth against the exact class the clear_budget_state doc in reconciler.rs documents as impossible. Fix: gate the whole DECRBY on EXISTS. If the session was cleared, the budget is already gone and there is nothing to compensate; the refund is a silent no-op. If the session is alive, we DECRBY-clamp AND refresh the counter TTL to match TRY_SPEND_LUA (a plain DECRBY does not refresh).

Source§

fn refund_many(&self, refunds: &[Refund])

R66 F2: atomic multi-key refund via a single Lua script. All keys share the caller’s Redis Cluster hash tag (guaranteed by ActionBudget::session_prefix which wraps its digest in {...}), so cross-slot dispatch is impossible and the script runs on ONE server-side EVAL. Per-key semantics match refund exactly: EXISTS gate (never resurrect a remove_prefix-cleared cell), DECRBY, clamp-at-zero via SET with fresh TTL on underflow, EXPIRE refresh on non-underflow. Best-effort like refund — Redis errors log-warn but never propagate.

Source§

fn remove_prefix(&self, prefix: &str)

Whole-session counter cleanup. Previously left as the trait’s default no-op on the assumption that the 24 h TTL made it pure hygiene — but SessionRegistry::get_or_open recycles a finalized session id into a fresh open session, which then spends against the SAME hash-tagged keys. Against InMemoryStore (dev, CI, and every budget test) the recycled incarnation starts from zero; against Redis it silently inherited up to 24 h of the prior incarnation’s tokens/total_calls/tool:*/payout counters — the exact cross-backend divergence class the add/try_spend_many alignment closed.

All of a session’s keys share one cluster slot by construction (ActionBudget::session_prefix wraps the digest in a {hash-tag}), so a SCAN cursor + bounded DEL batches — routed by the prefix pattern’s hash-tag to the owning master in cluster mode — reach every key on both single-node and cluster backends.

SCAN, not KEYS. The first take used redis.call('KEYS', pattern) inside a Lua script, which is O(entire keyspace per node) AND blocks the Redis event loop atomically for the whole scan+delete. On a shared Redis with high session churn or attacker-chosen ids, every close stalled all other Redis clients (including hot-path try_spend_many gates) for the duration. SCAN is O(matches) amortized and yields between batches, so per-session cleanup no longer blocks concurrent traffic.

route_command with an explicit slot for cluster mode. An intermediate revision used Commands::scan_match on the cluster connection, but bare SCAN has no key argument — RoutingInfo::for_routable returns None, and ClusterConnection::request treats that as an immediate UNROUTABLE_ERROR. The iterator failed on construction, the Err(_) => return arm swallowed it, and cleanup silently did nothing on every cluster deployment. Best-effort like remove/refund: any Redis blip on the cleanup path must not fail the close.

Source§

fn fetch_max(&self, key: &str, value: u64) -> Result<u64, StateError>

Atomically retain the greater of the stored value and value, returning the resulting maximum. Used for monotonic revocation cutoffs.
Source§

fn counter_ttl_secs(&self) -> Option<u64>

Backend counter TTL in seconds, if the backend expires counters natively. None means counters live for the process lifetime (in-memory). Exposing it makes the prod/dev divergence a VALUE callers can inspect (startup logs it; ops docs cite it) instead of a doc comment — a session active longer than this window has its budget counters silently reset against the expiring backend only.
Source§

fn add(&self, key: &str, delta: u64) -> Result<u64, StateError>

Add delta to key, returning the new value.
Source§

fn get(&self, key: &str) -> Result<u64, StateError>

Read the current value of key (0 if absent).
Source§

fn try_spend_many( &self, spends: &[Spend], ) -> Result<TrySpendOutcome, StateError>

Atomically validate and commit every spend, or commit none. Read more
Source§

fn remove(&self, key: &str)

Remove a key (session cleanup).
Source§

fn try_spend( &self, key: &str, amount: u64, limit: u64, ) -> Result<bool, StateError>

Atomically spend amount from the remaining budget limit - spent(key). Returns Ok(true) and records the spend if the full amount fits, Ok(false) (recording nothing) otherwise. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> IntoEither for T

Source§

fn into_either(self, into_left: bool) -> Either<Self, Self>

Converts self into a Left variant of Either<Self, Self> if into_left is true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
Source§

fn into_either_with<F>(self, into_left: F) -> Either<Self, Self>
where F: FnOnce(&Self) -> bool,

Converts self into a Left variant of Either<Self, Self> if into_left(&self) returns true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a [WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a [WithDispatch] wrapper. Read more
§

impl<ST, DT> CastableFrom<ST, Initialized, Initialized> for DT
where ST: ?Sized, DT: ?Sized,

§

impl<ST, DT> CastableFrom<ST, Uninit, Uninit> for DT
where ST: ?Sized, DT: ?Sized,

§

impl<T> Read<Exclusive, BecauseExclusive> for T
where T: ?Sized,