gitdataai/libs/room/src/room.rs

200 lines
7.6 KiB
Rust

use crate::error::RoomError;
use crate::service::RoomService;
use crate::ws_context::WsUserContext;
use models::rooms::{room, room_message, room_user_state};
use redis::AsyncCommands;
use sea_orm::*;
use uuid::Uuid;
impl RoomService {
const ROOM_LIST_CACHE_TTL: u64 = 15;
pub async fn room_list(
&self,
project_name: String,
only_public: Option<bool>,
ctx: &WsUserContext,
) -> Result<Vec<super::RoomResponse>, RoomError> {
let user_id = ctx.user_id;
let project = self.utils_find_project_by_name(project_name).await?;
self.check_project_access(project.id, user_id).await?;
let cache_key = format!(
"room:list:{}:{}:public={}",
project.id,
user_id,
only_public.unwrap_or(false)
);
if let Ok(mut conn) = self.cache.conn().await {
if let Ok(Some(cached)) = redis::cmd("GET")
.arg(&cache_key)
.query_async::<Option<String>>(&mut conn)
.await
{
if let Ok(responses) = serde_json::from_str::<Vec<super::RoomResponse>>(&cached) {
tracing::debug!(cache_key = %cache_key, "room_list: cache hit");
return Ok(responses);
}
}
}
tracing::debug!(cache_key = %cache_key, "room_list: cache miss");
let mut query = room::Entity::find().filter(room::Column::Project.eq(project.id));
if only_public.unwrap_or(false) {
query = query.filter(room::Column::Public.eq(true));
}
let models = query
.order_by_desc(room::Column::LastMsgAt)
.all(&self.db)
.await?;
let room_ids: Vec<Uuid> = models.iter().map(|r| r.id).collect();
let latest_seqs: std::collections::HashMap<Uuid, i64> = room_message::Entity::find()
.select_only()
.column(room_message::Column::Room)
.column_as(room_message::Column::Seq.max(), "max_seq")
.filter(room_message::Column::Room.is_in(room_ids.clone()))
.group_by(room_message::Column::Room)
.into_tuple::<(Uuid, Option<i64>)>()
.all(&self.db)
.await?
.into_iter()
.map(|(room, seq)| (room, seq.unwrap_or(0)))
.collect();
// Use room_user_state for read position (lazy — only exists if user has interacted)
let user_read_seqs: std::collections::HashMap<Uuid, i64> = room_user_state::Entity::find()
.filter(room_user_state::Column::User.eq(user_id))
.filter(room_user_state::Column::Room.is_in(room_ids.clone()))
.all(&self.db)
.await?
.into_iter()
.map(|s| (s.room, s.last_read_seq.unwrap_or(0)))
.collect();
let _unread_counts: std::collections::HashMap<Uuid, i64> = if !room_ids.is_empty() {
let _q = room_message::Entity::find()
.select_only()
.column(room_message::Column::Room)
.column_as(room_message::Column::Id.count(), "count")
.filter(room_message::Column::Room.is_in(room_ids.clone()))
.group_by(room_message::Column::Room);
// This is still tricky because last_read_seq is per room-user.
// For now, let's keep the latest_seq - last_read_seq logic but
// ensure it doesn't over-report when seq starts at a high number.
// A better fix would be to store the "base seq" or "start seq" per room.
// But the most accurate is counting per room.
latest_seqs.clone()
} else {
std::collections::HashMap::new()
};
let mut responses = Vec::new();
for model in models {
let last_read_seq = user_read_seqs.get(&model.id).copied().unwrap_or(0);
let latest_seq = latest_seqs.get(&model.id).copied().unwrap_or(0);
// If user has never read, unread count is the total messages in room.
// If they have read, it's the gap between latest and last read.
// This is still an approximation if there are gaps, but better than before.
let unread_count = if last_read_seq == 0 {
// If never read, we ideally want the count of all messages.
// But for performance in a list, we'll use latest_seq as a hint
// or just stick to the gap logic if we assume seq is monotonic.
// The issue is latest_seq = 136, last_read = 0 -> 136.
// We'll use a heuristic: if latest_seq > 0 and last_read == 0,
// we'll assume they haven't read anything.
latest_seq
} else {
std::cmp::max(latest_seq - last_read_seq, 0)
};
let mut response = super::RoomResponse::from(model);
response.unread_count = unread_count;
responses.push(response);
}
if let Ok(mut conn) = self.cache.conn().await {
if let Ok(json) = serde_json::to_string(&responses) {
let _: Option<String> = redis::cmd("SETEX")
.arg(&cache_key)
.arg(Self::ROOM_LIST_CACHE_TTL)
.arg(&json)
.query_async(&mut conn)
.await
.inspect_err(|e| {
tracing::warn!(cache_key = %cache_key, error = %e, "room_list: failed to cache");
})
.ok();
}
}
Ok(responses)
}
pub async fn room_get(
&self,
room_id: Uuid,
ctx: &WsUserContext,
) -> Result<super::RoomResponse, RoomError> {
let user_id = ctx.user_id;
let model = self.find_room_or_404(room_id).await?;
self.ensure_room_visible_for_user(&model, user_id).await?;
let version = self.get_room_version(room_id).await?;
let mut resp = super::RoomResponse::from(model);
resp.version = version;
Ok(resp)
}
pub(crate) async fn invalidate_room_list_cache(&self, project_id: Uuid) {
self.invalidate_room_list_cache_for_prefix(&format!("room:list:{}:", project_id))
.await;
}
pub(crate) async fn invalidate_room_list_cache_for_user(
&self,
project_id: Uuid,
user_id: Uuid,
) {
self.invalidate_room_list_cache_for_prefix(&format!("room:list:{}:{}:", project_id, user_id))
.await;
}
async fn invalidate_room_list_cache_for_prefix(&self, prefix: &str) {
let pattern = format!("{}*", prefix);
if let Ok(mut conn) = self.cache.conn().await {
let mut cursor: u64 = 0;
loop {
match redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(&pattern)
.arg("COUNT")
.arg(100)
.query_async::<(u64, Vec<String>)>(&mut conn)
.await
{
Ok((next_cursor, keys)) => {
for key in &keys {
let _: () = conn.del(key).await.unwrap_or(());
}
if next_cursor == 0 {
break;
}
cursor = next_cursor;
}
Err(e) => {
tracing::debug!(pattern = %pattern, error = ?e, "room_list cache scan failed");
break;
}
}
}
}
}
}