-
Notifications
You must be signed in to change notification settings - Fork 9
Remove Redis keyspace SCANs in preparation for cluster mode #2139
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,12 @@ | ||
| //! Index janitor (PRD 5.5.8, DESIGN §4.3). | ||
| //! | ||
| //! Per-key Redis TTLs remove session records and token mappings, but ZSET | ||
| //! index members (`asm:user_sessions:*`) and refresh-schedule orphans linger | ||
| //! until trimmed. One leader (Redis lock, DD-BFF-09 — same election as the | ||
| //! refresher) runs a pass every `janitor_interval_seconds` (default 30 s) and | ||
| //! emits removed/backlog metrics; a rising backlog means no pod is running | ||
| //! passes. | ||
| //! Per-key Redis TTLs remove session records and token mappings; the | ||
| //! login-state live index and refresh-schedule orphans linger until trimmed. | ||
| //! Per-user session indexes are trimmed inline by writers and TTL-bounded, | ||
| //! so the pass issues no keyspace SCAN. One leader (Redis lock, DD-BFF-09 — | ||
| //! same election as the refresher) runs a pass every | ||
| //! `janitor_interval_seconds` (default 30 s) and emits removed/backlog | ||
| //! metrics; a rising backlog means no pod is running passes. | ||
|
Comment on lines
+3
to
+9
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Separate backlog from pass liveness.
🤖 Prompt for AI Agents |
||
|
|
||
| use std::sync::Arc; | ||
| use std::sync::atomic::{AtomicU64, Ordering}; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -214,10 +214,31 @@ impl SessionManager { | |
| pub async fn connect(redis_url: &str) -> anyhow::Result<Self> { | ||
| anyhow::ensure!(!redis_url.is_empty(), "redis_url is required (fail closed)"); | ||
| let client = redis::Client::open(redis_url).context("open Redis client")?; | ||
| let conn = client | ||
| let mut conn = client | ||
| .get_connection_manager() | ||
| .await | ||
| .context("establish Redis connection manager")?; | ||
|
|
||
| // EXPIREAT NX|GT needs Redis >= 7.0; fail at boot, not per login. | ||
| let info: String = redis::cmd("INFO") | ||
| .arg("server") | ||
| .query_async(&mut conn) | ||
| .await | ||
| .context("read Redis server info")?; | ||
| let version = info | ||
| .lines() | ||
| .find_map(|l| l.strip_prefix("redis_version:")) | ||
| .map_or("", str::trim); | ||
| let major: u64 = version | ||
| .split('.') | ||
| .next() | ||
| .and_then(|v| v.parse().ok()) | ||
| .unwrap_or(0); | ||
| anyhow::ensure!( | ||
| major >= 7, | ||
| "Redis >= 7.0 required (EXPIREAT NX/GT), server reports {version:?}" | ||
| ); | ||
|
|
||
| Ok(Self { conn }) | ||
| } | ||
|
|
||
|
|
@@ -377,12 +398,27 @@ impl SessionManager { | |
| .with_expiration(redis::SetExpiry::EX(s.jwt_reissue_after_seconds)), | ||
| ) | ||
| .ignore(); | ||
| // User-session index (score = expiry). | ||
| pipe.zadd(user_sessions_key(&r.person_id), &s.session_id, expires_at) | ||
| // User-session index (score = expiry): inline trim + guarded TTL | ||
| // stand in for the removed janitor SCAN. | ||
| let ukey = user_sessions_key(&r.person_id); | ||
| let created = i64::try_from(r.created_at).unwrap_or(0); | ||
| let absolute = i64::try_from(r.absolute_expires_at).unwrap_or(i64::MAX); | ||
| pipe.zrembyscore(&ukey, 0, created).ignore(); | ||
| pipe.zadd(&ukey, &s.session_id, expires_at).ignore(); | ||
| // INVARIANT: index TTL >= every member's absolute expiry. NX seeds | ||
| // a TTL (GT treats no-TTL as infinite); GT only ever extends. | ||
| pipe.cmd("EXPIREAT") | ||
| .arg(&ukey) | ||
| .arg(absolute) | ||
| .arg("NX") | ||
| .ignore(); | ||
| pipe.cmd("EXPIREAT") | ||
| .arg(&ukey) | ||
| .arg(absolute) | ||
| .arg("GT") | ||
| .ignore(); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| // Back-channel logout indexes: by OIDC `sid` (when the IdP supplies | ||
| // one) and by `(iss, sub)` — the sub-only fallback path. | ||
| let absolute = i64::try_from(r.absolute_expires_at).unwrap_or(i64::MAX); | ||
| // A view-as session is ALSO indexed under the real principal, so | ||
| // revoke-by-person against the impersonator (admin deprovisioning, | ||
| // self "log out everywhere") reaches it. Scored at the absolute cap: | ||
|
|
@@ -391,22 +427,49 @@ impl SessionManager { | |
| // Reads stay correct — a dead session's record is gone, so index | ||
| // readers skip it. | ||
| if !r.impersonator_person_id.is_empty() { | ||
| pipe.zadd( | ||
| user_sessions_key(&r.impersonator_person_id), | ||
| &s.session_id, | ||
| absolute, | ||
| ) | ||
| .ignore(); | ||
| let ikey = user_sessions_key(&r.impersonator_person_id); | ||
| pipe.zrembyscore(&ikey, 0, created).ignore(); | ||
| pipe.zadd(&ikey, &s.session_id, absolute).ignore(); | ||
| pipe.cmd("EXPIREAT") | ||
| .arg(&ikey) | ||
| .arg(absolute) | ||
| .arg("NX") | ||
| .ignore(); | ||
| pipe.cmd("EXPIREAT") | ||
| .arg(&ikey) | ||
| .arg(absolute) | ||
| .arg("GT") | ||
| .ignore(); | ||
| } | ||
| if let Some(sid) = &r.idp_sid { | ||
| let idx = sid_index_key(&r.idp_iss, sid); | ||
| pipe.sadd(&idx, &s.session_id).ignore(); | ||
| pipe.expire_at(&idx, absolute).ignore(); | ||
| // INVARIANT: NX/GT, never plain EXPIREAT — the set is shared and | ||
| // a shorter-lived session must not cut the TTL under a live one. | ||
| pipe.cmd("EXPIREAT") | ||
| .arg(&idx) | ||
| .arg(absolute) | ||
| .arg("NX") | ||
| .ignore(); | ||
| pipe.cmd("EXPIREAT") | ||
| .arg(&idx) | ||
| .arg(absolute) | ||
| .arg("GT") | ||
| .ignore(); | ||
| } | ||
| if !r.idp_sub.is_empty() { | ||
| let idx = sub_index_key(&r.idp_iss, &r.idp_sub); | ||
| pipe.sadd(&idx, &s.session_id).ignore(); | ||
| pipe.expire_at(&idx, absolute).ignore(); | ||
| pipe.cmd("EXPIREAT") | ||
| .arg(&idx) | ||
| .arg(absolute) | ||
| .arg("NX") | ||
| .ignore(); | ||
| pipe.cmd("EXPIREAT") | ||
| .arg(&idx) | ||
| .arg(absolute) | ||
| .arg("GT") | ||
| .ignore(); | ||
| } | ||
| // IdP refresh schedule (consumer lands in step 10). | ||
| if let Some(due) = s.refresh_due_at { | ||
|
|
@@ -759,11 +822,11 @@ impl SessionManager { | |
| Ok(()) | ||
| } | ||
|
|
||
| /// One janitor pass (DESIGN §4.3): trim expired members from every | ||
| /// `asm:user_sessions:*` ZSET (`ZREMRANGEBYSCORE 0 now` — per-key TTLs | ||
| /// removed the records, the index members linger) and drop long-overdue | ||
| /// orphans from the refresh schedule (live sessions are re-scheduled by | ||
| /// the refresher; an entry still due after `orphan_grace` has no owner). | ||
| /// One janitor pass (DESIGN §4.3): trim expired members from the | ||
| /// login-state index and drop long-overdue orphans from the refresh | ||
| /// schedule (live sessions are re-scheduled by the refresher; an entry | ||
| /// still due after `orphan_grace` has no owner). Per-user session | ||
| /// indexes are trimmed inline by writers and TTL-bounded — no SCAN. | ||
|
Comment on lines
+825
to
+829
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift Split
As per coding guidelines: keep one noun per Rust file and split modules when a module exceeds approximately 400 lines. 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| /// Returns (removed members, overdue-backlog size before trimming). | ||
| /// | ||
| /// # Errors | ||
|
|
@@ -774,38 +837,6 @@ impl SessionManager { | |
| let mut removed = 0u64; | ||
| let mut backlog = 0u64; | ||
|
|
||
| // SCAN, never KEYS — bounded batches on a shared Redis. | ||
| let mut cursor: u64 = 0; | ||
| loop { | ||
| let (next, keys): (u64, Vec<String>) = redis::cmd("SCAN") | ||
| .arg(cursor) | ||
| .arg("MATCH") | ||
| .arg("asm:user_sessions:*") | ||
| .arg("COUNT") | ||
| .arg(100) | ||
| .query_async(&mut conn) | ||
| .await | ||
| .context("scan user-session indexes")?; | ||
| for key in keys { | ||
| let expired: u64 = conn | ||
| .zcount(&key, 0, now_i) | ||
| .await | ||
| .context("count expired index members")?; | ||
| if expired > 0 { | ||
| backlog += expired; | ||
| let n: u64 = conn | ||
| .zrembyscore(&key, 0, now_i) | ||
| .await | ||
| .context("trim expired index members")?; | ||
| removed += n; | ||
| } | ||
| } | ||
| cursor = next; | ||
| if cursor == 0 { | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| // Expired login-state index members (the HASH keys expired via TTL). | ||
| let stale_states: u64 = conn | ||
| .zrembyscore(LOGIN_STATE_LIVE_KEY, 0, now_i) | ||
|
|
@@ -927,7 +958,7 @@ impl SessionManager { | |
|
|
||
| /// List a person's live sessions from the per-user index (score > `now`), | ||
| /// loading each record. Index members whose record has already expired are | ||
| /// skipped (the janitor trims them). | ||
| /// skipped (the next session-create on this index trims them). | ||
| /// | ||
| /// # Errors | ||
| /// Fails on a Redis error. | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.