1 parent
cfc74d9db6
commit
dacdf9c27d
76 files changed
+5209
-741
No files matched your search
@@ -4,7 +4,7 @@ use async_trait::async_trait;
|
||||
use rusqlite::{Connection, params};
|
||||
use sqlx::{FromRow, MySqlPool, PgPool};
|
||||
|
||||
use super::{PersistedSession, SessionPersistence};
|
||||
use super::{PersistedSession, SessionPersistence, SessionPersistenceDiagnostics};
|
||||
use crate::config::AppConfig;
|
||||
use crate::queries;
|
||||
|
||||
@@ -148,11 +148,6 @@ pub struct DatabaseSessionStore {
|
||||
|
||||
impl DatabaseSessionStore {
|
||||
pub async fn connect(config: &AppConfig) -> Result<Self, String> {
|
||||
// Schema ownership is centralized in db::pool::configure(). Calling it here
|
||||
// keeps direct/test construction safe, while the normal application startup
|
||||
// hits the OnceLock fast path. Do not run schema.sql again from this store:
|
||||
// that used to duplicate DDL for SQLite/MySQL/PostgreSQL and produced repeated
|
||||
// PostgreSQL "relation already exists, skipping" NOTICE messages.
|
||||
crate::db::pool::configure(config).await?;
|
||||
|
||||
let pool = match config.database_backend.as_str() {
|
||||
@@ -425,6 +420,61 @@ impl SessionPersistence for DatabaseSessionStore {
|
||||
}
|
||||
}
|
||||
|
||||
async fn diagnostics(&self, now_unix: u64) -> Result<SessionPersistenceDiagnostics, String> {
|
||||
let (backend, active_sessions, unique_users, unique_browsers, latest_accessed_unix) =
|
||||
match &self.pool {
|
||||
Pool::Sqlite(path) => {
|
||||
let path = path.clone();
|
||||
let row = sqlite_task(path, move |conn| {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*), COUNT(DISTINCT user_hash), COUNT(DISTINCT browser_id), MAX(last_accessed_unix) FROM sessions WHERE (last_accessed_unix + timeout_secs) >= ?1",
|
||||
params![now_unix as i64],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, i64>(2)?, row.get::<_, Option<i64>>(3)?)),
|
||||
)
|
||||
.map_err(|e| format!("failed to query SQLite session diagnostics: {e}"))
|
||||
})
|
||||
.await?;
|
||||
("database/sqlite", row.0, row.1, row.2, row.3)
|
||||
}
|
||||
Pool::Mysql(pool) => {
|
||||
let row: (i64, i64, i64, Option<i64>) = sqlx::query_as(
|
||||
"SELECT COUNT(*), COUNT(DISTINCT user_hash), COUNT(DISTINCT browser_id), MAX(last_accessed_unix) FROM sessions WHERE (last_accessed_unix + timeout_secs) >= ?"
|
||||
)
|
||||
.bind(now_unix as i64)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| format!("failed to query MySQL session diagnostics: {e}"))?;
|
||||
("database/mysql", row.0, row.1, row.2, row.3)
|
||||
}
|
||||
Pool::Pgsql(pool) => {
|
||||
let row: (i64, i64, i64, Option<i64>) = sqlx::query_as(
|
||||
"SELECT COUNT(*), COUNT(DISTINCT user_hash), COUNT(DISTINCT browser_id), MAX(last_accessed_unix) FROM sessions WHERE (last_accessed_unix + timeout_secs) >= $1"
|
||||
)
|
||||
.bind(now_unix as i64)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| format!("failed to query PostgreSQL session diagnostics: {e}"))?;
|
||||
("database/pgsql", row.0, row.1, row.2, row.3)
|
||||
}
|
||||
};
|
||||
Ok(SessionPersistenceDiagnostics {
|
||||
backend: backend.to_string(),
|
||||
status: "ok".to_string(),
|
||||
active_sessions: active_sessions.max(0) as usize,
|
||||
unique_users: unique_users.max(0) as usize,
|
||||
unique_browsers: unique_browsers.max(0) as usize,
|
||||
latest_accessed_unix: latest_accessed_unix.and_then(|value| u64::try_from(value).ok()),
|
||||
truncated: false,
|
||||
engine_version: None,
|
||||
connected_clients: None,
|
||||
blocked_clients: None,
|
||||
used_memory_bytes: None,
|
||||
server_uptime_secs: None,
|
||||
role: None,
|
||||
master_link_status: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn purge_expired(&self, now_unix: u64) -> Result<u64, String> {
|
||||
match &self.pool {
|
||||
Pool::Sqlite(path) => {
|
||||
|
||||
@@ -7,6 +7,24 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SessionPersistenceDiagnostics {
|
||||
pub backend: String,
|
||||
pub status: String,
|
||||
pub active_sessions: usize,
|
||||
pub unique_users: usize,
|
||||
pub unique_browsers: usize,
|
||||
pub latest_accessed_unix: Option<u64>,
|
||||
pub truncated: bool,
|
||||
pub engine_version: Option<String>,
|
||||
pub connected_clients: Option<u64>,
|
||||
pub blocked_clients: Option<u64>,
|
||||
pub used_memory_bytes: Option<u64>,
|
||||
pub server_uptime_secs: Option<u64>,
|
||||
pub role: Option<String>,
|
||||
pub master_link_status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PersistedSession {
|
||||
pub token: String,
|
||||
@@ -45,6 +63,7 @@ pub trait SessionPersistence: Send + Sync {
|
||||
async fn delete_account(&self, account_id: &str) -> Result<bool, String>;
|
||||
async fn delete_browser(&self, browser_id: &str) -> Result<(), String>;
|
||||
async fn purge_expired(&self, now_unix: u64) -> Result<u64, String>;
|
||||
async fn diagnostics(&self, now_unix: u64) -> Result<SessionPersistenceDiagnostics, String>;
|
||||
}
|
||||
|
||||
pub async fn from_config(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use async_trait::async_trait;
|
||||
use redis::AsyncCommands;
|
||||
|
||||
use super::{PersistedSession, SessionPersistence};
|
||||
use super::{PersistedSession, SessionPersistence, SessionPersistenceDiagnostics};
|
||||
use crate::config::AppConfig;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -23,6 +23,14 @@ pub struct RedisSessionStore {
|
||||
mode: RedisMode,
|
||||
}
|
||||
|
||||
fn redis_info_value<'a>(info: &'a str, key: &str) -> Option<&'a str> {
|
||||
info.lines().find_map(|line| {
|
||||
let line = line.trim_end_matches('\r');
|
||||
let (name, value) = line.split_once(':')?;
|
||||
(name == key).then_some(value.trim())
|
||||
})
|
||||
}
|
||||
|
||||
impl RedisSessionStore {
|
||||
pub async fn connect(config: &AppConfig) -> Result<Self, String> {
|
||||
let mode = if let Some(url) = config
|
||||
@@ -274,6 +282,107 @@ impl SessionPersistence for RedisSessionStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn diagnostics(&self, now_unix: u64) -> Result<SessionPersistenceDiagnostics, String> {
|
||||
use std::collections::HashSet;
|
||||
|
||||
const MAX_SCAN_SESSIONS: usize = 5000;
|
||||
let mut con = self.connection().await?;
|
||||
let pong: String = redis::cmd("PING")
|
||||
.query_async(&mut con)
|
||||
.await
|
||||
.map_err(|e| format!("Redis PING failed: {e}"))?;
|
||||
if !pong.eq_ignore_ascii_case("PONG") {
|
||||
return Err(format!("unexpected Redis PING response: {pong}"));
|
||||
}
|
||||
let info: String = redis::cmd("INFO")
|
||||
.query_async(&mut con)
|
||||
.await
|
||||
.map_err(|e| format!("Redis INFO failed: {e}"))?;
|
||||
|
||||
let mut cursor = 0u64;
|
||||
let mut seen = 0usize;
|
||||
let mut active = 0usize;
|
||||
let mut users = HashSet::new();
|
||||
let mut browsers = HashSet::new();
|
||||
let mut latest = None;
|
||||
let mut truncated = false;
|
||||
|
||||
loop {
|
||||
let (next_cursor, keys): (u64, Vec<String>) = redis::cmd("SCAN")
|
||||
.arg(cursor)
|
||||
.arg("MATCH")
|
||||
.arg("rustbox:session:*")
|
||||
.arg("COUNT")
|
||||
.arg(250)
|
||||
.query_async(&mut con)
|
||||
.await
|
||||
.map_err(|e| format!("Redis SCAN failed: {e}"))?;
|
||||
|
||||
if !keys.is_empty() {
|
||||
let remaining = MAX_SCAN_SESSIONS.saturating_sub(seen);
|
||||
if remaining == 0 {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
let keys = if keys.len() > remaining {
|
||||
truncated = true;
|
||||
&keys[..remaining]
|
||||
} else {
|
||||
&keys[..]
|
||||
};
|
||||
let mut command = redis::cmd("MGET");
|
||||
for key in keys {
|
||||
command.arg(key);
|
||||
}
|
||||
let values: Vec<Option<String>> = command
|
||||
.query_async(&mut con)
|
||||
.await
|
||||
.map_err(|e| format!("Redis MGET diagnostics failed: {e}"))?;
|
||||
seen += keys.len();
|
||||
for raw in values.into_iter().flatten() {
|
||||
let Ok(session) = serde_json::from_str::<PersistedSession>(&raw) else {
|
||||
continue;
|
||||
};
|
||||
if session.expired_at(now_unix) {
|
||||
continue;
|
||||
}
|
||||
active += 1;
|
||||
users.insert(session.user_hash);
|
||||
browsers.insert(session.browser_id);
|
||||
latest = Some(latest.map_or(session.last_accessed_unix, |value: u64| {
|
||||
value.max(session.last_accessed_unix)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if truncated || next_cursor == 0 {
|
||||
break;
|
||||
}
|
||||
cursor = next_cursor;
|
||||
}
|
||||
|
||||
Ok(SessionPersistenceDiagnostics {
|
||||
backend: "redis".to_string(),
|
||||
status: "ok".to_string(),
|
||||
active_sessions: active,
|
||||
unique_users: users.len(),
|
||||
unique_browsers: browsers.len(),
|
||||
latest_accessed_unix: latest,
|
||||
truncated,
|
||||
engine_version: redis_info_value(&info, "redis_version").map(str::to_string),
|
||||
connected_clients: redis_info_value(&info, "connected_clients")
|
||||
.and_then(|value| value.parse().ok()),
|
||||
blocked_clients: redis_info_value(&info, "blocked_clients")
|
||||
.and_then(|value| value.parse().ok()),
|
||||
used_memory_bytes: redis_info_value(&info, "used_memory")
|
||||
.and_then(|value| value.parse().ok()),
|
||||
server_uptime_secs: redis_info_value(&info, "uptime_in_seconds")
|
||||
.and_then(|value| value.parse().ok()),
|
||||
role: redis_info_value(&info, "role").map(str::to_string),
|
||||
master_link_status: redis_info_value(&info, "master_link_status").map(str::to_string),
|
||||
})
|
||||
}
|
||||
|
||||
async fn purge_expired(&self, _now_unix: u64) -> Result<u64, String> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user