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
impl RedisStore
Sourcepub fn connect(url: &str) -> Result<Self, StateError>
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
impl StateStore for RedisStore
Source§fn refund(&self, key: &str, amount: u64)
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])
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)
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>
fn fetch_max(&self, key: &str, value: u64) -> Result<u64, StateError>
value, returning
the resulting maximum. Used for monotonic revocation cutoffs.Source§fn counter_ttl_secs(&self) -> Option<u64>
fn counter_ttl_secs(&self) -> Option<u64>
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>
fn add(&self, key: &str, delta: u64) -> Result<u64, StateError>
delta to key, returning the new value.Source§fn get(&self, key: &str) -> Result<u64, StateError>
fn get(&self, key: &str) -> Result<u64, StateError>
key (0 if absent).Source§fn try_spend_many(
&self,
spends: &[Spend],
) -> Result<TrySpendOutcome, StateError>
fn try_spend_many( &self, spends: &[Spend], ) -> Result<TrySpendOutcome, StateError>
Auto Trait Implementations§
impl Freeze for RedisStore
impl !RefUnwindSafe for RedisStore
impl Send for RedisStore
impl Sync for RedisStore
impl Unpin for RedisStore
impl UnsafeUnpin for RedisStore
impl !UnwindSafe for RedisStore
Blanket Implementations§
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
§impl<T> Instrument for T
impl<T> Instrument for T
§fn instrument(self, span: Span) -> Instrumented<Self>
fn instrument(self, span: Span) -> Instrumented<Self>
§fn in_current_span(self) -> Instrumented<Self>
fn in_current_span(self) -> Instrumented<Self>
Source§impl<T> IntoEither for T
impl<T> IntoEither for T
Source§fn into_either(self, into_left: bool) -> Either<Self, Self>
fn into_either(self, into_left: bool) -> Either<Self, Self>
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 moreSource§fn into_either_with<F>(self, into_left: F) -> Either<Self, Self>
fn into_either_with<F>(self, into_left: F) -> Either<Self, Self>
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