1use std::{fmt, net::IpAddr, sync::LazyLock};
4
5use chrono::{DateTime, TimeDelta, Utc};
6use redis::{
7 RedisFuture, Script, Value,
8 aio::{ConnectionLike, ConnectionManager},
9 cluster_async::ClusterConnection,
10};
11use serde::Serialize;
12use sha2::{Digest, Sha256};
13use uuid::Uuid;
14
15use super::{
16 ClaimScope, SessionRecord, SessionStore, SessionUpdate, Supersede, TokenLookup, TokenRecord, session_bucket,
17 token_bucket,
18};
19use crate::{
20 checks::Check,
21 error::StoreError,
22 event::{ClientSummary, EventKind, SessionEvent, SessionOrigin},
23 fingerprint::Baseline,
24 token::TokenHash,
25};
26
27const PURGE_BATCH: usize = 500;
29
30const SREM_BATCH: usize = 1000;
32
33macro_rules! lua_helpers {
36 () => {
37 r"
38local function read_session(P, sid)
39 local v = redis.call('HMGET', P .. 's:' .. sid,
40 'subject', 'created', 'auth', 'seen', 'expires', 'step_up', 'baseline', 'origin')
41 if not v[1] then return nil end
42 return {sid, v[1], v[2], v[3], v[4], v[5], v[6], v[7], v[8] or ''}
43end
44
45-- Deletes a session and its tokens; returns its subject, or false if it did not exist.
46local function del_session(P, sid)
47 local s = P .. 's:' .. sid
48 local k = P .. 'k:' .. sid
49 local subject = redis.call('HGET', s, 'subject')
50 for _, hex in ipairs(redis.call('ZRANGE', k, 0, -1)) do
51 redis.call('DEL', P .. 't:' .. hex)
52 end
53 redis.call('DEL', k)
54 redis.call('ZREM', P .. 'x', sid)
55 redis.call('ZREM', P .. 'i', sid)
56 if subject then
57 redis.call('DEL', s)
58 return subject
59 end
60 return false
61end
62"
63 };
64}
65
66static INSERT: LazyLock<Script> = LazyLock::new(|| {
69 Script::new(
70 r"
71local P = ARGV[1]
72if redis.call('EXISTS', KEYS[1]) == 1 or redis.call('EXISTS', KEYS[2]) == 1 then return 0 end
73redis.call('HSET', KEYS[1], 'subject', ARGV[4], 'created', ARGV[5], 'auth', ARGV[6],
74 'seen', ARGV[7], 'expires', ARGV[8], 'step_up', ARGV[9], 'baseline', ARGV[10],
75 'exp_ms', ARGV[11], 'cur', ARGV[3], 'seq', 1)
76if ARGV[12] ~= '' then redis.call('HSET', KEYS[1], 'origin', ARGV[12]) end
77redis.call('HSET', KEYS[2], 'sid', ARGV[2], 'seq', 1, 'issued', ARGV[5])
78redis.call('ZADD', KEYS[3], 1, ARGV[3])
79redis.call('ZADD', KEYS[4], ARGV[8], ARGV[2])
80redis.call('ZADD', P .. 'i', ARGV[7], ARGV[2])
81redis.call('PEXPIREAT', KEYS[1], ARGV[11])
82redis.call('PEXPIREAT', KEYS[2], ARGV[11])
83redis.call('PEXPIREAT', KEYS[3], ARGV[11])
84return 1
85",
86 )
87});
88
89static INDEX_ADD: LazyLock<Script> = LazyLock::new(|| {
92 Script::new(
93 r"
94redis.call('SADD', KEYS[1], ARGV[1])
95local pt = redis.call('PEXPIRETIME', KEYS[1])
96if pt == -1 or pt < tonumber(ARGV[2]) then redis.call('PEXPIREAT', KEYS[1], ARGV[2]) end
97return 1
98",
99 )
100});
101
102static INDEX_MOVE: LazyLock<Script> = LazyLock::new(|| {
106 Script::new(
107 r"
108local pt = redis.call('PEXPIRETIME', KEYS[1])
109if pt ~= -2 then
110 local cur = redis.call('PEXPIRETIME', KEYS[2])
111 redis.call('SUNIONSTORE', KEYS[2], KEYS[2], KEYS[1])
112 local at = math.max(pt, cur)
113 if at > 0 then redis.call('PEXPIREAT', KEYS[2], at) end
114 redis.call('DEL', KEYS[1])
115end
116return redis.call('SMEMBERS', KEYS[2])
117",
118 )
119});
120
121static FIND: LazyLock<Script> = LazyLock::new(|| {
123 Script::new(concat!(
124 lua_helpers!(),
125 r"
126local P = ARGV[1]
127local t = redis.call('HMGET', KEYS[1], 'sid', 'seq', 'issued', 'used', 'sup')
128if not t[1] then return false end
129local rec = read_session(P, t[1])
130if not rec then return false end
131local newer = 0
132for _, hex in ipairs(redis.call('ZRANGEBYSCORE', P .. 'k:' .. t[1], '(' .. t[2], '+inf')) do
133 if redis.call('HEXISTS', P .. 't:' .. hex, 'used') == 1 then
134 newer = 1
135 break
136 end
137end
138rec[10] = t[3]
139rec[11] = t[4] or ''
140rec[12] = t[5] or ''
141rec[13] = tostring(newer)
142return rec
143",
144 ))
145});
146
147static READ: LazyLock<Script> = LazyLock::new(|| {
149 Script::new(concat!(
150 lua_helpers!(),
151 "return read_session(ARGV[1], ARGV[2]) or false\n"
152 ))
153});
154
155static APPLY: LazyLock<Script> = LazyLock::new(|| {
158 Script::new(
159 r"
160local s = KEYS[1]
161if redis.call('EXISTS', s) == 0 then return 0 end
162local P, sid, now, mode = ARGV[1], ARGV[2], ARGV[3], ARGV[4]
163local rotated = 0
164if mode ~= '' then
165 local cur = redis.call('HGET', s, 'cur')
166 if cur then
167 local ck = P .. 't:' .. cur
168 local holds = false
169 if mode == 'exact' then
170 holds = cur == ARGV[5]
171 elseif mode == 'before' then
172 local issued = tonumber(redis.call('HGET', ck, 'issued'))
173 holds = issued ~= nil and issued < tonumber(ARGV[5])
174 elseif mode == 'current' then
175 holds = redis.call('EXISTS', ck) == 1
176 end
177 local nk = P .. 't:' .. ARGV[6]
178 if holds and redis.call('EXISTS', nk) == 0 then
179 local k = P .. 'k:' .. sid
180 redis.call('HSET', ck, 'sup', now)
181 local seq = redis.call('HINCRBY', s, 'seq', 1)
182 redis.call('HSET', nk, 'sid', sid, 'seq', seq, 'issued', now)
183 local exp = redis.call('HGET', s, 'exp_ms')
184 if exp then redis.call('PEXPIREAT', nk, exp) end
185 redis.call('ZADD', k, seq, ARGV[6])
186 redis.call('HSET', s, 'cur', ARGV[6])
187 local cutoff = tonumber(ARGV[7])
188 for _, hex in ipairs(redis.call('ZRANGE', k, 0, -1)) do
189 local sup = tonumber(redis.call('HGET', P .. 't:' .. hex, 'sup'))
190 if sup ~= nil and sup < cutoff then
191 redis.call('DEL', P .. 't:' .. hex)
192 redis.call('ZREM', k, hex)
193 end
194 end
195 rotated = 1
196 end
197 end
198end
199if ARGV[8] ~= '' then
200 local mk = P .. 't:' .. ARGV[8]
201 if redis.call('HGET', mk, 'sid') == sid and redis.call('HEXISTS', mk, 'used') == 0 then
202 redis.call('HSET', mk, 'used', now)
203 end
204end
205redis.call('HSET', s, 'seen', now)
206redis.call('ZADD', P .. 'i', now, sid)
207if ARGV[9] ~= '' then redis.call('HSET', s, 'baseline', ARGV[9]) end
208if ARGV[10] ~= '' then redis.call('HSET', s, 'step_up', ARGV[10]) end
209if ARGV[11] ~= '' then redis.call('HSET', s, 'auth', ARGV[11]) end
210return rotated
211",
212 )
213});
214
215static DELETE: LazyLock<Script> = LazyLock::new(|| {
218 Script::new(concat!(
219 lua_helpers!(),
220 r"
221if ARGV[3] and redis.call('HGET', KEYS[1], 'subject') ~= ARGV[3] then return false end
222return del_session(ARGV[1], ARGV[2])
223",
224 ))
225});
226
227static DELETE_EXPIRED: LazyLock<Script> = LazyLock::new(|| {
229 Script::new(concat!(
230 lua_helpers!(),
231 r"
232local P = ARGV[1]
233local found, n = 0, 0
234for _, idx in ipairs({{'x', ARGV[2]}, {'i', ARGV[3]}}) do
235 local ids = redis.call('ZRANGEBYSCORE', P .. idx[1], '-inf', idx[2], 'LIMIT', 0, ARGV[4])
236 found = found + #ids
237 for _, sid in ipairs(ids) do
238 if del_session(P, sid) then n = n + 1 end
239 end
240end
241return {n, found}
242",
243 ))
244});
245
246static DELETE_EVENTS: LazyLock<Script> = LazyLock::new(|| {
248 Script::new(
249 r"
250local bound = '(' .. ARGV[2]
251local n = redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', bound)
252for _, m in ipairs(redis.call('ZRANGEBYSCORE', KEYS[2], '-inf', bound)) do
253 redis.call('DEL', ARGV[1] .. 'c:' .. m)
254end
255redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', bound)
256return n
257",
258 )
259});
260
261static CLAIM: LazyLock<Script> = LazyLock::new(|| {
263 Script::new(
264 r"
265local at = tonumber(redis.call('GET', KEYS[1]))
266if at ~= nil and tonumber(ARGV[1]) - at < tonumber(ARGV[2]) then return 0 end
267redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[3])
268redis.call('ZADD', KEYS[2], ARGV[1], ARGV[4])
269return 1
270",
271 )
272});
273
274#[derive(Clone)]
276enum Conn {
277 Standalone(ConnectionManager),
278 Cluster(ClusterConnection),
279}
280
281impl ConnectionLike for Conn {
282 fn req_packed_command<'a>(&'a mut self, cmd: &'a redis::Cmd) -> RedisFuture<'a, Value> {
283 match self {
284 Self::Standalone(conn) => conn.req_packed_command(cmd),
285 Self::Cluster(conn) => conn.req_packed_command(cmd),
286 }
287 }
288
289 fn req_packed_commands<'a>(
290 &'a mut self,
291 pipeline: &'a redis::Pipeline,
292 offset: usize,
293 count: usize,
294 ) -> RedisFuture<'a, Vec<Value>> {
295 match self {
296 Self::Standalone(conn) => conn.req_packed_commands(pipeline, offset, count),
297 Self::Cluster(conn) => conn.req_packed_commands(pipeline, offset, count),
298 }
299 }
300
301 fn get_db(&self) -> i64 {
302 match self {
303 Self::Standalone(conn) => conn.get_db(),
304 Self::Cluster(conn) => conn.get_db(),
305 }
306 }
307}
308
309#[derive(Clone)]
366pub struct ValkeyStore {
367 conn: Conn,
368 prefix: String,
369}
370
371impl fmt::Debug for ValkeyStore {
372 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
373 f.debug_struct("ValkeyStore")
374 .field("key_prefix", &self.prefix)
375 .finish_non_exhaustive()
376 }
377}
378
379impl ValkeyStore {
380 pub fn new(conn: ConnectionManager) -> Self {
382 Self {
383 conn: Conn::Standalone(conn),
384 prefix: "sg:".to_owned(),
385 }
386 }
387
388 pub fn from_cluster(conn: ClusterConnection) -> Self {
390 Self {
391 conn: Conn::Cluster(conn),
392 prefix: "sg:".to_owned(),
393 }
394 }
395
396 pub async fn connect_cluster<T: redis::IntoConnectionInfo>(
403 nodes: impl IntoIterator<Item = T>,
404 ) -> Result<Self, redis::RedisError> {
405 let client = redis::cluster::ClusterClient::new(nodes)?;
406 Ok(Self::from_cluster(client.get_async_connection().await?))
407 }
408
409 pub async fn connect(url: &str) -> Result<Self, redis::RedisError> {
416 let client = redis::Client::open(url)?;
417 Ok(Self::new(ConnectionManager::new(client).await?))
418 }
419
420 #[must_use]
428 pub fn key_prefix(mut self, prefix: impl Into<String>) -> Self {
429 let prefix = prefix.into();
430 assert!(
431 !prefix.contains(['{', '}']),
432 "ValkeyStore key prefix must not contain '{{' or '}}'"
433 );
434 self.prefix = prefix;
435 self
436 }
437
438 fn bucket_prefix(&self, bucket: u8) -> String {
440 format!("{}{{{bucket:02x}}}:", self.prefix)
441 }
442
443 fn key(&self, bucket: u8, kind: &str, id: &str) -> String {
445 format!("{}{kind}:{id}", self.bucket_prefix(bucket))
446 }
447
448 fn plain_key(&self, bucket: u8, kind: &str) -> String {
450 format!("{}{kind}", self.bucket_prefix(bucket))
451 }
452
453 fn conn(&self) -> Conn {
454 self.conn.clone()
455 }
456
457 async fn unindex(&self, subject: &str, session_id: Uuid) {
460 let bucket = subject_bucket(subject);
461 for kind in ["u", "ur"] {
462 let _removed: Result<i64, _> = redis::cmd("SREM")
463 .arg(self.key(bucket, kind, subject))
464 .arg(session_id.to_string())
465 .query_async(&mut self.conn())
466 .await;
467 }
468 }
469}
470
471fn subject_bucket(subject: &str) -> u8 {
473 Sha256::digest(subject.as_bytes())[0]
474}
475
476fn ensure_aligned(session_id: Uuid, token: &TokenHash) -> Result<(), StoreError> {
477 if session_bucket(session_id) == token_bucket(token) {
478 Ok(())
479 } else {
480 Err("valkey store: token and session id are in different buckets".into())
481 }
482}
483
484fn micros(at: DateTime<Utc>) -> String {
485 at.timestamp_micros().to_string()
486}
487
488fn parse_time(value: &str) -> Result<DateTime<Utc>, StoreError> {
489 value
490 .parse::<i64>()
491 .ok()
492 .and_then(DateTime::from_timestamp_micros)
493 .ok_or_else(|| "valkey store: malformed timestamp".into())
494}
495
496fn parse_optional_time(value: &str) -> Result<Option<DateTime<Utc>>, StoreError> {
497 if value.is_empty() {
498 Ok(None)
499 } else {
500 parse_time(value).map(Some)
501 }
502}
503
504fn parse_session(fields: &[String]) -> Result<SessionRecord, StoreError> {
506 let [id, subject, created, auth, seen, expires, step_up, baseline, origin] = fields else {
507 return Err("valkey store: malformed session".into());
508 };
509 let baseline: Baseline = serde_json::from_str(baseline)?;
510 let origin: Option<SessionOrigin> = if origin.is_empty() {
511 None
512 } else {
513 Some(serde_json::from_str(origin)?)
514 };
515 Ok(SessionRecord {
516 id: Uuid::parse_str(id)?,
517 subject: subject.clone(),
518 created_at: parse_time(created)?,
519 authenticated_at: parse_time(auth)?,
520 last_seen_at: parse_time(seen)?,
521 expires_at: parse_time(expires)?,
522 step_up_required: step_up == "1",
523 baseline,
524 origin,
525 })
526}
527
528#[derive(Serialize)]
529struct EventRow<'a> {
530 id: Uuid,
532 session_id: Uuid,
533 subject: &'a str,
534 kind: EventKind,
535 enforced: bool,
536 score: Option<u32>,
537 signals: &'a [Check],
538 ip: IpAddr,
539 user_agent: Option<&'a str>,
540 client: &'a ClientSummary,
541 at: DateTime<Utc>,
542}
543
544impl SessionStore for ValkeyStore {
545 fn sharded(&self) -> bool {
546 true
547 }
548
549 async fn insert_session(&self, session: &SessionRecord, token: &TokenHash) -> Result<(), StoreError> {
550 ensure_aligned(session.id, token)?;
551 let bucket = session_bucket(session.id);
552 let sid = session.id.to_string();
553 let hex = token.to_hex();
554 let exp_ms = session
555 .expires_at
556 .timestamp_micros()
557 .saturating_add(999)
558 .div_euclid(1000);
559 let sbucket = subject_bucket(&session.subject);
561 let _: i64 = INDEX_ADD
562 .key(self.key(sbucket, "u", &session.subject))
563 .arg(&sid)
564 .arg(exp_ms)
565 .invoke_async(&mut self.conn())
566 .await?;
567 let inserted: i64 = INSERT
568 .key(self.key(bucket, "s", &sid))
569 .key(self.key(bucket, "t", &hex))
570 .key(self.key(bucket, "k", &sid))
571 .key(self.plain_key(bucket, "x"))
572 .arg(self.bucket_prefix(bucket))
573 .arg(&sid)
574 .arg(&hex)
575 .arg(&session.subject)
576 .arg(micros(session.created_at))
577 .arg(micros(session.authenticated_at))
578 .arg(micros(session.last_seen_at))
579 .arg(micros(session.expires_at))
580 .arg(if session.step_up_required { "1" } else { "0" })
581 .arg(serde_json::to_string(&session.baseline)?)
582 .arg(exp_ms)
583 .arg(match &session.origin {
584 Some(origin) => serde_json::to_string(origin)?,
585 None => String::new(),
586 })
587 .invoke_async(&mut self.conn())
588 .await?;
589 if inserted != 1 {
590 return Err("duplicate session id or token".into());
591 }
592 let indexed: bool = redis::cmd("SISMEMBER")
593 .arg(self.key(sbucket, "u", &session.subject))
594 .arg(&sid)
595 .query_async(&mut self.conn())
596 .await?;
597 if !indexed {
598 let exists: bool = redis::cmd("EXISTS")
599 .arg(self.key(bucket, "s", &sid))
600 .query_async(&mut self.conn())
601 .await?;
602 if exists {
603 self.delete_session(session.id).await?;
606 return Err("valkey store: subject was revoked while the session was created".into());
607 }
608 }
609 Ok(())
610 }
611
612 async fn find_by_token(&self, token: &TokenHash) -> Result<Option<TokenLookup>, StoreError> {
613 let bucket = token_bucket(token);
614 let fields: Option<Vec<String>> = FIND
615 .key(self.key(bucket, "t", &token.to_hex()))
616 .arg(self.bucket_prefix(bucket))
617 .invoke_async(&mut self.conn())
618 .await?;
619 let Some(fields) = fields else {
620 return Ok(None);
621 };
622 let [session @ .., issued, used, superseded, newer] = fields.as_slice() else {
623 return Err("valkey store: malformed token lookup".into());
624 };
625 Ok(Some(TokenLookup {
626 session: parse_session(session)?,
627 token: TokenRecord {
628 hash: *token,
629 issued_at: parse_time(issued)?,
630 first_used_at: parse_optional_time(used)?,
631 superseded_at: parse_optional_time(superseded)?,
632 },
633 newer_token_used: newer == "1",
634 }))
635 }
636
637 async fn apply(&self, update: &SessionUpdate) -> Result<bool, StoreError> {
638 if let Some(rotation) = &update.rotation {
639 ensure_aligned(update.session_id, &rotation.new_token)?;
640 }
641 if let Some(used) = &update.mark_used {
642 ensure_aligned(update.session_id, used)?;
643 }
644 let bucket = session_bucket(update.session_id);
645 let sid = update.session_id.to_string();
646 let (mode, mode_arg, new_token, prune) = match &update.rotation {
647 None => ("", String::new(), String::new(), String::new()),
648 Some(rotation) => {
649 let (mode, arg) = match rotation.supersede {
650 Supersede::Exact(hash) => ("exact", hash.to_hex()),
651 Supersede::CurrentIssuedBefore(at) => ("before", micros(at)),
652 Supersede::Current => ("current", String::new()),
653 };
654 (
655 mode,
656 arg,
657 rotation.new_token.to_hex(),
658 micros(rotation.prune_superseded_before),
659 )
660 }
661 };
662 let baseline = match &update.baseline {
663 Some(baseline) => serde_json::to_string(baseline)?,
664 None => String::new(),
665 };
666 let step_up = match update.step_up_required {
667 Some(true) => "1",
668 Some(false) => "0",
669 None => "",
670 };
671 let rotated: i64 = APPLY
672 .key(self.key(bucket, "s", &sid))
673 .arg(self.bucket_prefix(bucket))
674 .arg(&sid)
675 .arg(micros(update.now))
676 .arg(mode)
677 .arg(mode_arg)
678 .arg(new_token)
679 .arg(prune)
680 .arg(update.mark_used.map(|h| h.to_hex()).unwrap_or_default())
681 .arg(baseline)
682 .arg(step_up)
683 .arg(update.authenticated_at.map(micros).unwrap_or_default())
684 .invoke_async(&mut self.conn())
685 .await?;
686 Ok(rotated == 1)
687 }
688
689 async fn delete_session(&self, session_id: Uuid) -> Result<bool, StoreError> {
690 let bucket = session_bucket(session_id);
691 let sid = session_id.to_string();
692 let subject: Option<String> = DELETE
693 .key(self.key(bucket, "s", &sid))
694 .arg(self.bucket_prefix(bucket))
695 .arg(&sid)
696 .invoke_async(&mut self.conn())
697 .await?;
698 let Some(subject) = subject else {
699 return Ok(false);
700 };
701 self.unindex(&subject, session_id).await;
702 Ok(true)
703 }
704
705 async fn delete_subject_sessions(&self, subject: &str) -> Result<Vec<Uuid>, StoreError> {
706 let sbucket = subject_bucket(subject);
707 let revoking = self.key(sbucket, "ur", subject);
708 let members: Vec<String> = INDEX_MOVE
709 .key(self.key(sbucket, "u", subject))
710 .key(&revoking)
711 .invoke_async(&mut self.conn())
712 .await?;
713 let mut deleted = Vec::new();
714 for member in &members {
715 let Ok(id) = Uuid::parse_str(member) else { continue };
717 let bucket = session_bucket(id);
718 let removed: Option<String> = DELETE
719 .key(self.key(bucket, "s", member))
720 .arg(self.bucket_prefix(bucket))
721 .arg(member)
722 .arg(subject)
723 .invoke_async(&mut self.conn())
724 .await?;
725 if removed.is_some() {
726 deleted.push(id);
727 }
728 }
729 for chunk in members.chunks(SREM_BATCH) {
731 let _removed: i64 = redis::cmd("SREM")
732 .arg(&revoking)
733 .arg(chunk)
734 .query_async(&mut self.conn())
735 .await?;
736 }
737 Ok(deleted)
738 }
739
740 async fn list_subject_sessions(&self, subject: &str) -> Result<Vec<SessionRecord>, StoreError> {
741 let sbucket = subject_bucket(subject);
742 let members: Vec<String> = redis::cmd("SUNION")
743 .arg(self.key(sbucket, "u", subject))
744 .arg(self.key(sbucket, "ur", subject))
745 .query_async(&mut self.conn())
746 .await?;
747 let mut sessions = Vec::with_capacity(members.len());
748 let mut stale = Vec::new();
749 for member in &members {
750 let Ok(id) = Uuid::parse_str(member) else {
751 stale.push(member);
752 continue;
753 };
754 let bucket = session_bucket(id);
755 let fields: Option<Vec<String>> = READ
756 .key(self.key(bucket, "s", member))
757 .arg(self.bucket_prefix(bucket))
758 .arg(member)
759 .invoke_async(&mut self.conn())
760 .await?;
761 match fields.map(|fields| parse_session(&fields)).transpose()? {
762 Some(record) if record.subject == subject => sessions.push(record),
763 _ => stale.push(member),
764 }
765 }
766 for chunk in stale.chunks(SREM_BATCH) {
769 for kind in ["u", "ur"] {
770 let _removed: Result<i64, _> = redis::cmd("SREM")
771 .arg(self.key(sbucket, kind, subject))
772 .arg(chunk)
773 .query_async(&mut self.conn())
774 .await;
775 }
776 }
777 Ok(sessions)
778 }
779
780 async fn delete_expired(&self, now: DateTime<Utc>, idle_cutoff: DateTime<Utc>) -> Result<u64, StoreError> {
781 let mut total = 0;
782 for bucket in 0..=u8::MAX {
783 loop {
784 let (deleted, found): (u64, u64) = DELETE_EXPIRED
785 .key(self.plain_key(bucket, "x"))
786 .arg(self.bucket_prefix(bucket))
787 .arg(micros(now))
788 .arg(micros(idle_cutoff))
789 .arg(PURGE_BATCH)
790 .invoke_async(&mut self.conn())
791 .await?;
792 total += deleted;
793 if found == 0 {
794 break;
795 }
796 }
797 }
798 Ok(total)
799 }
800
801 async fn record_event(&self, event: &SessionEvent) -> Result<(), StoreError> {
802 let row = EventRow {
803 id: Uuid::new_v4(),
804 session_id: event.session_id,
805 subject: &event.subject,
806 kind: event.kind,
807 enforced: event.enforced,
808 score: event.score,
809 signals: &event.signals,
810 ip: event.ip,
811 user_agent: event.user_agent.as_deref(),
812 client: &event.client,
813 at: event.at,
814 };
815 let _added: i64 = redis::cmd("ZADD")
816 .arg(self.plain_key(session_bucket(event.session_id), "e"))
817 .arg(event.at.timestamp_micros())
818 .arg(serde_json::to_string(&row)?)
819 .query_async(&mut self.conn())
820 .await?;
821 Ok(())
822 }
823
824 async fn delete_events_before(&self, cutoff: DateTime<Utc>) -> Result<u64, StoreError> {
825 let mut total = 0;
826 for bucket in 0..=u8::MAX {
827 let deleted: u64 = DELETE_EVENTS
828 .key(self.plain_key(bucket, "e"))
829 .key(self.plain_key(bucket, "cz"))
830 .arg(self.bucket_prefix(bucket))
831 .arg(micros(cutoff))
832 .invoke_async(&mut self.conn())
833 .await?;
834 total += deleted;
835 }
836 Ok(total)
837 }
838
839 async fn claim_event(
840 &self,
841 scope: ClaimScope<'_>,
842 kind: EventKind,
843 now: DateTime<Utc>,
844 window: TimeDelta,
845 ) -> Result<bool, StoreError> {
846 let (bucket, member) = match scope {
848 ClaimScope::Session(session_id) => (session_bucket(session_id), format!("{session_id}:{kind}")),
849 ClaimScope::Subject(subject) => (subject_bucket(subject), format!("s:{subject}:{kind}")),
850 };
851 let micros_window = window.num_microseconds().unwrap_or(i64::MAX);
852 let millis_window = window.num_milliseconds().max(1);
853 let claimed: i64 = CLAIM
854 .key(self.key(bucket, "c", &member))
855 .key(self.plain_key(bucket, "cz"))
856 .arg(micros(now))
857 .arg(micros_window)
858 .arg(millis_window)
859 .arg(&member)
860 .invoke_async(&mut self.conn())
861 .await?;
862 Ok(claimed == 1)
863 }
864}