v0.2.8
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-01 20:40:40 +01:00
1 parent cfc74d9db6
commit dacdf9c27d
76 files changed
+5209 -741

No files matched your search

+56 -6
View File
@@ -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) => {
+19
View File
@@ -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(
+110 -1
View File
@@ -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)
}