Skip to main content

relay_knowledge/watcher/engine/
mod.rs

1use std::{
2    future::Future,
3    pin::Pin,
4    sync::{Arc, atomic::AtomicU64},
5    time::Duration,
6};
7
8use serde::{Deserialize, Serialize};
9use tokio::sync::{RwLock, mpsc, oneshot, watch};
10
11use super::{ContentHashCache, WatchedRepository, config::WatcherConfig};
12
13mod diagnostics;
14mod event_loop;
15mod index_queue;
16mod repository_registry;
17
18const COMMAND_CHANNEL_CAPACITY: usize = 128;
19const COMMAND_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5);
20
21type TaskQueueFuture = Pin<Box<dyn Future<Output = Result<(), String>> + Send>>;
22type TaskQueueSink =
23    Arc<dyn Fn(crate::storage::CodeIndexTaskSeed) -> TaskQueueFuture + Send + Sync>;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum WatcherState {
28    Disabled,
29    Active,
30    Degraded,
31    Failed,
32}
33
34impl WatcherState {
35    pub const fn as_str(self) -> &'static str {
36        match self {
37            Self::Disabled => "disabled",
38            Self::Active => "active",
39            Self::Degraded => "degraded",
40            Self::Failed => "failed",
41        }
42    }
43
44    pub fn parse(value: &str) -> Option<Self> {
45        match value {
46            "disabled" => Some(Self::Disabled),
47            "active" => Some(Self::Active),
48            "degraded" => Some(Self::Degraded),
49            "failed" => Some(Self::Failed),
50            _ => None,
51        }
52    }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct WatcherDiagnostics {
57    pub state: WatcherState,
58    pub watched_repository_count: usize,
59    pub total_events_received: u64,
60    pub total_events_filtered: u64,
61    pub total_index_tasks_queued: u64,
62    pub total_events_dropped: u64,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub last_error: Option<String>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub degraded_reason: Option<String>,
67}
68
69impl Default for WatcherDiagnostics {
70    fn default() -> Self {
71        Self {
72            state: WatcherState::Disabled,
73            watched_repository_count: 0,
74            total_events_received: 0,
75            total_events_filtered: 0,
76            total_index_tasks_queued: 0,
77            total_events_dropped: 0,
78            last_error: None,
79            degraded_reason: None,
80        }
81    }
82}
83
84#[derive(Debug, Clone)]
85pub struct WatcherHandle {
86    diagnostics: watch::Receiver<WatcherDiagnostics>,
87    shutdown: watch::Sender<bool>,
88    state: Arc<RwLock<WatcherInternalState>>,
89    command_tx: Option<mpsc::Sender<WatcherCommand>>,
90}
91
92impl WatcherHandle {
93    pub fn diagnostics(&self) -> WatcherDiagnostics {
94        self.diagnostics.borrow().clone()
95    }
96
97    pub async fn updated_diagnostics(&self) -> WatcherDiagnostics {
98        let mut diagnostics = self.diagnostics.clone();
99        let _ = diagnostics.changed().await;
100        diagnostics.borrow().clone()
101    }
102
103    pub fn request_shutdown(&self) {
104        let _ = self.shutdown.send(true);
105    }
106
107    pub async fn add_repository(&self, repository: WatchedRepository) -> bool {
108        let Some(command_tx) = &self.command_tx else {
109            return false;
110        };
111        let (response_tx, response_rx) = oneshot::channel();
112        let command = WatcherCommand::Add {
113            repository,
114            response: response_tx,
115        };
116        if command_tx.send(command).await.is_err() {
117            return false;
118        }
119        matches!(
120            tokio::time::timeout(COMMAND_RESPONSE_TIMEOUT, response_rx).await,
121            Ok(Ok(true))
122        )
123    }
124
125    pub async fn remove_repository(&self, alias: &str) -> bool {
126        let Some(command_tx) = &self.command_tx else {
127            return false;
128        };
129        let (response_tx, response_rx) = oneshot::channel();
130        let command = WatcherCommand::Remove {
131            alias_or_id: alias.to_owned(),
132            response: response_tx,
133        };
134        if command_tx.send(command).await.is_err() {
135            return false;
136        }
137        matches!(
138            tokio::time::timeout(COMMAND_RESPONSE_TIMEOUT, response_rx).await,
139            Ok(Ok(true))
140        )
141    }
142
143    pub async fn repository_count(&self) -> usize {
144        self.state.read().await.repositories.len()
145    }
146}
147
148pub struct FileWatcher {
149    config: WatcherConfig,
150}
151
152impl FileWatcher {
153    pub fn new(config: WatcherConfig) -> Self {
154        Self { config }
155    }
156
157    pub fn start(self, repositories: Vec<WatchedRepository>) -> Result<WatcherHandle, String> {
158        self.start_with_sink(repositories, |_| async { Ok(()) })
159    }
160
161    pub fn start_with_sink<F, Fut>(
162        self,
163        repositories: Vec<WatchedRepository>,
164        task_sink: F,
165    ) -> Result<WatcherHandle, String>
166    where
167        F: Fn(crate::storage::CodeIndexTaskSeed) -> Fut + Send + Sync + 'static,
168        Fut: Future<Output = Result<(), String>> + Send + 'static,
169    {
170        if !self.config.enabled {
171            return Ok(disabled_handle(self.config.hash_cache_capacity));
172        }
173
174        let (diagnostics_tx, diagnostics_rx) = watch::channel(WatcherDiagnostics {
175            state: WatcherState::Active,
176            ..WatcherDiagnostics::default()
177        });
178        let (shutdown_tx, shutdown_rx) = watch::channel(false);
179        let (command_tx, command_rx) = mpsc::channel(COMMAND_CHANNEL_CAPACITY);
180        let state = Arc::new(RwLock::new(WatcherInternalState::new(
181            self.config.hash_cache_capacity,
182        )));
183        let handle = WatcherHandle {
184            diagnostics: diagnostics_rx,
185            shutdown: shutdown_tx,
186            state: Arc::clone(&state),
187            command_tx: Some(command_tx),
188        };
189        let context = WatcherLoopContext {
190            state,
191            diagnostics_tx,
192            dropped_events: Arc::new(AtomicU64::new(0)),
193            debounce: self.config.debounce,
194            max_watch_dirs: self.config.max_watch_dirs,
195            task_sink: boxed_task_sink(task_sink),
196        };
197        tokio::spawn(event_loop::run(
198            context,
199            shutdown_rx,
200            repositories,
201            command_rx,
202        ));
203
204        Ok(handle)
205    }
206}
207
208#[derive(Debug)]
209struct WatcherInternalState {
210    repositories: Vec<WatchedRepository>,
211    hash_cache: ContentHashCache,
212    events_received: u64,
213    events_filtered: u64,
214    index_tasks_queued: u64,
215}
216
217impl WatcherInternalState {
218    fn new(hash_cache_capacity: usize) -> Self {
219        Self {
220            repositories: Vec::new(),
221            hash_cache: ContentHashCache::new(hash_cache_capacity),
222            events_received: 0,
223            events_filtered: 0,
224            index_tasks_queued: 0,
225        }
226    }
227}
228
229enum WatcherCommand {
230    Add {
231        repository: WatchedRepository,
232        response: oneshot::Sender<bool>,
233    },
234    Remove {
235        alias_or_id: String,
236        response: oneshot::Sender<bool>,
237    },
238}
239
240struct WatcherLoopContext {
241    state: Arc<RwLock<WatcherInternalState>>,
242    diagnostics_tx: watch::Sender<WatcherDiagnostics>,
243    dropped_events: Arc<AtomicU64>,
244    debounce: Duration,
245    max_watch_dirs: usize,
246    task_sink: TaskQueueSink,
247}
248
249fn disabled_handle(hash_cache_capacity: usize) -> WatcherHandle {
250    let (_diagnostics_tx, diagnostics_rx) = watch::channel(WatcherDiagnostics {
251        state: WatcherState::Disabled,
252        ..WatcherDiagnostics::default()
253    });
254    let (shutdown_tx, _) = watch::channel(false);
255    WatcherHandle {
256        diagnostics: diagnostics_rx,
257        shutdown: shutdown_tx,
258        state: Arc::new(RwLock::new(WatcherInternalState::new(hash_cache_capacity))),
259        command_tx: None,
260    }
261}
262
263fn boxed_task_sink<F, Fut>(task_sink: F) -> TaskQueueSink
264where
265    F: Fn(crate::storage::CodeIndexTaskSeed) -> Fut + Send + Sync + 'static,
266    Fut: Future<Output = Result<(), String>> + Send + 'static,
267{
268    Arc::new(move |task| Box::pin(task_sink(task)))
269}
270
271#[cfg(test)]
272use self::{
273    diagnostics::emit as emit_diagnostics,
274    index_queue::{process_debounced_paths, should_process_path},
275};
276
277#[cfg(test)]
278#[path = "integration_tests.rs"]
279mod tests;