1use crate::store::{Spend, StateError, StateStore};
10use redis::Commands;
11
12const BUDGET_COUNTER_TTL_SECS: u64 = 86_400;
22const REFUND_MARKER_TTL_SECS: u64 = 300;
26
27static 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
131static 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
169static 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
204pub 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
255fn 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 pub fn connect(url: &str) -> Result<Self, StateError> {
272 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 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 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 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 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 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 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 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 fn refund(&self, key: &str, amount: u64) {
569 let amount = i64::try_from(amount).unwrap_or(i64::MAX);
572 let marker = format!("{key}:r:{}", av_core::new_event_uid());
576 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 fn refund_many(&self, refunds: &[crate::Refund]) {
634 let Some(first) = refunds.first() else {
635 return;
636 };
637 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 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 fn remove_prefix(&self, prefix: &str) {
732 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 scan_and_delete_cluster(&mut connection, &pattern, prefix);
764 }
765 }
766 }
767 }
768}
769
770fn 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 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
831fn 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 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 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 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 #[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 assert!(
1052 !script.contains("BUDGET_COUNTER_TTL_SECS"),
1053 "{name} has an unexpanded BUDGET_COUNTER_TTL_SECS placeholder"
1054 );
1055 }
1056 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}