use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use kanade_shared::manifest::GroupDef;
use serde_json::Value as JsonValue;
use sqlx::SqlitePool;
use tracing::warn;
use super::AppState;
use super::query::{ReadOnlyError, run_read_only};
const GROUP_ROW_LIMIT: usize = super::query::MAX_LIMIT;
pub type GroupCache = Arc<Mutex<HashMap<String, CacheEntry>>>;
pub struct CacheEntry {
hash: u64,
computed_at: Instant,
result: Result<Vec<String>, String>,
}
pub fn new_cache() -> GroupCache {
Arc::new(Mutex::new(HashMap::new()))
}
fn group_hash(g: &GroupDef) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
g.query.hash(&mut h);
g.members.hash(&mut h);
g.refresh.hash(&mut h);
h.finish()
}
pub async fn resolve_group_members(
state: &AppState,
group: &GroupDef,
) -> Result<Vec<String>, String> {
let Some(query) = group.dynamic_query() else {
return Ok(group.members.clone());
};
let hash = group_hash(group);
let ttl = group.refresh_interval();
{
let cache = state.group_cache.lock().expect("group_cache mutex");
if let Some(e) = cache.get(&group.id)
&& e.hash == hash
&& e.computed_at.elapsed() < ttl
{
return e.result.clone();
}
}
let result = run_group_query(&state.query_pool, &group.id, query).await;
let mut cache = state.group_cache.lock().expect("group_cache mutex");
cache.insert(
group.id.clone(),
CacheEntry {
hash,
computed_at: Instant::now(),
result: result.clone(),
},
);
result
}
async fn run_group_query(pool: &SqlitePool, id: &str, query: &str) -> Result<Vec<String>, String> {
let res = run_read_only(pool, query, GROUP_ROW_LIMIT, None)
.await
.map_err(|e: ReadOnlyError| e.to_string())?;
let idx = res
.columns
.iter()
.position(|c| c.eq_ignore_ascii_case("pc_id"))
.ok_or_else(|| {
format!(
"group query must return a `pc_id` column (returned: {})",
res.columns.join(", ")
)
})?;
if res.truncated {
warn!(
group = %id,
cap = GROUP_ROW_LIMIT,
"group query hit the row cap — membership truncated; tighten the query",
);
}
let mut out = Vec::with_capacity(res.rows.len());
for row in &res.rows {
match row.get(idx) {
Some(JsonValue::String(s)) => out.push(s.clone()),
Some(JsonValue::Null) | None => {}
Some(other) => out.push(other.to_string()),
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::sqlite::SqlitePoolOptions;
async fn pool_with_agents() -> SqlitePool {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlx::query("CREATE TABLE agents (pc_id TEXT, hostname TEXT)")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO agents VALUES ('SRV-1','SRV-1'), ('PC-1','PC-1'), ('SRV-2','SRV-2')",
)
.execute(&pool)
.await
.unwrap();
pool
}
#[tokio::test]
async fn dynamic_query_extracts_pc_id_column() {
let pool = pool_with_agents().await;
let ids = run_group_query(
&pool,
"servers",
"SELECT pc_id FROM agents WHERE hostname LIKE 'SRV-%' ORDER BY pc_id",
)
.await
.unwrap();
assert_eq!(ids, vec!["SRV-1", "SRV-2"]);
}
#[tokio::test]
async fn extra_columns_are_ignored_pc_id_is_found_by_name() {
let pool = pool_with_agents().await;
let ids = run_group_query(
&pool,
"g",
"SELECT hostname, pc_id FROM agents WHERE pc_id = 'PC-1'",
)
.await
.unwrap();
assert_eq!(ids, vec!["PC-1"]);
}
#[tokio::test]
async fn pc_id_column_match_is_case_insensitive() {
let pool = pool_with_agents().await;
let ids = run_group_query(
&pool,
"g",
"SELECT pc_id AS PC_ID FROM agents WHERE hostname = 'PC-1'",
)
.await
.unwrap();
assert_eq!(ids, vec!["PC-1"]);
}
#[tokio::test]
async fn missing_pc_id_column_is_an_error() {
let pool = pool_with_agents().await;
let err = run_group_query(&pool, "g", "SELECT hostname FROM agents")
.await
.unwrap_err();
assert!(err.contains("pc_id"), "err: {err}");
}
#[tokio::test]
async fn write_query_is_rejected_read_only() {
let pool = pool_with_agents().await;
let err = run_group_query(&pool, "g", "DELETE FROM agents")
.await
.unwrap_err();
assert!(err.to_lowercase().contains("read-only"), "err: {err}");
}
#[tokio::test]
async fn null_pc_id_rows_are_dropped() {
let pool = pool_with_agents().await;
sqlx::query("INSERT INTO agents VALUES (NULL, 'ghost')")
.execute(&pool)
.await
.unwrap();
let ids = run_group_query(
&pool,
"g",
"SELECT pc_id FROM agents WHERE hostname = 'ghost'",
)
.await
.unwrap();
assert!(ids.is_empty(), "null pc_id dropped: {ids:?}");
}
}