use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::RwLock;
use tracing::error;
use crate::storage::AtomicFile;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CatalogEvent {
pub id: String,
pub timestamp: String,
pub object_type: String,
pub object_id: String,
pub change_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResourceUpdateEvent {
pub uri: String,
pub timestamp: String,
pub server: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedCatalogState {
pub counter: u64,
pub events: Vec<CatalogEvent>,
}
pub const MAX_CATALOG_EVENTS: usize = 5_000;
pub struct CatalogEventStore {
events: RwLock<Vec<CatalogEvent>>,
counter: std::sync::atomic::AtomicU64,
storage: Option<AtomicFile<PersistedCatalogState>>,
}
impl Default for CatalogEventStore {
fn default() -> Self {
Self::new()
}
}
impl CatalogEventStore {
pub fn new() -> Self {
Self {
events: RwLock::new(Vec::new()),
counter: std::sync::atomic::AtomicU64::new(1),
storage: None,
}
}
pub fn open_or_create(path: impl AsRef<Path>) -> Result<Self> {
let storage = AtomicFile::new(path);
let loaded: Option<PersistedCatalogState> = storage.load_opt()?;
let (counter_val, events_vec) = match loaded {
Some(state) => (state.counter.max(1), state.events),
None => (1, Vec::new()),
};
Ok(Self {
events: RwLock::new(events_vec),
counter: std::sync::atomic::AtomicU64::new(counter_val),
storage: Some(storage),
})
}
fn sync_to_disk(&self) {
if let Some(ref store) = self.storage {
let guard = self.events.read().unwrap_or_else(|e| e.into_inner());
let current_counter = self.counter.load(std::sync::atomic::Ordering::Relaxed);
let state = PersistedCatalogState {
counter: current_counter,
events: guard.clone(),
};
drop(guard);
if let Err(e) = store.save(&state) {
error!(error = %e, path = %store.path().display(), "failed to persist catalog events to disk");
}
}
}
pub fn record_with_detail(
&self,
object_type: impl AsRef<str>,
object_id: impl AsRef<str>,
change_type: impl AsRef<str>,
detail: Option<impl Into<String>>,
) {
let seq = self
.counter
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let event_id = format!("evt_{}", seq);
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs().to_string())
.unwrap_or_else(|_| "0".to_string());
let mut guard = self.events.write().unwrap_or_else(|e| e.into_inner());
if guard.len() >= MAX_CATALOG_EVENTS {
let excess = guard.len() - (MAX_CATALOG_EVENTS - 1);
guard.drain(0..excess);
}
guard.push(CatalogEvent {
id: event_id,
timestamp,
object_type: object_type.as_ref().to_string(),
object_id: object_id.as_ref().to_string(),
change_type: change_type.as_ref().to_string(),
detail: detail.map(|d| d.into()),
});
drop(guard);
self.sync_to_disk();
}
pub fn record(
&self,
object_type: impl AsRef<str>,
object_id: impl AsRef<str>,
change_type: impl AsRef<str>,
) {
self.record_with_detail(object_type, object_id, change_type, None::<String>);
}
pub fn get_events_after(&self, after_cursor: Option<&str>) -> (Vec<CatalogEvent>, String) {
let guard = self.events.read().unwrap_or_else(|e| e.into_inner());
let start_index = match after_cursor {
Some(cursor) if !cursor.is_empty() => guard
.iter()
.position(|e| e.id == cursor)
.map(|idx| idx + 1)
.unwrap_or(0),
_ => 0,
};
let result_events = guard[start_index..].to_vec();
let next_cursor = guard.last().map(|e| e.id.clone()).unwrap_or_default();
(result_events, next_cursor)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn event_store_records_and_filters_by_cursor() {
let store = CatalogEventStore::new();
store.record("capability", "github.issues.search", "added");
store.record("resource", "fs.readme", "added");
let (all, cursor) = store.get_events_after(None);
assert_eq!(all.len(), 2);
assert_eq!(cursor, "evt_2");
let (filtered, _) = store.get_events_after(Some("evt_1"));
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].id, "evt_2");
assert_eq!(filtered[0].object_id, "fs.readme");
}
#[test]
fn test_catalog_event_store_persistence_across_restarts() {
let temp_dir = tempfile::tempdir().unwrap();
let state_file = temp_dir.path().join("catalog_events.json");
let store1 = CatalogEventStore::open_or_create(&state_file).unwrap();
store1.record("capability", "docker.start", "added");
store1.record("capability", "docker.stop", "added");
let (events1, cursor1) = store1.get_events_after(None);
assert_eq!(events1.len(), 2);
assert_eq!(cursor1, "evt_2");
drop(store1);
let store2 = CatalogEventStore::open_or_create(&state_file).unwrap();
store2.record("resource", "docker.status", "updated");
let (all_events, cursor2) = store2.get_events_after(None);
assert_eq!(all_events.len(), 3);
assert_eq!(cursor2, "evt_3");
let (paged, _) = store2.get_events_after(Some("evt_2"));
assert_eq!(paged.len(), 1);
assert_eq!(paged[0].id, "evt_3");
assert_eq!(paged[0].object_id, "docker.status");
}
}