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_commit_reconciliations: u64,
63    pub total_commit_tasks_queued: u64,
64    pub total_commit_reconcile_failures: u64,
65    pub total_events_dropped: u64,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub last_error: Option<String>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub degraded_reason: Option<String>,
70}
71
72impl Default for WatcherDiagnostics {
73    fn default() -> Self {
74        Self {
75            state: WatcherState::Disabled,
76            watched_repository_count: 0,
77            total_events_received: 0,
78            total_events_filtered: 0,
79            total_index_tasks_queued: 0,
80            total_commit_reconciliations: 0,
81            total_commit_tasks_queued: 0,
82            total_commit_reconcile_failures: 0,
83            total_events_dropped: 0,
84            last_error: None,
85            degraded_reason: None,
86        }
87    }
88}
89
90#[derive(Debug, Clone)]
91pub struct WatcherHandle {
92    diagnostics: watch::Receiver<WatcherDiagnostics>,
93    shutdown: watch::Sender<bool>,
94    state: Arc<RwLock<WatcherInternalState>>,
95    command_tx: Option<mpsc::Sender<WatcherCommand>>,
96}
97
98impl WatcherHandle {
99    pub fn diagnostics(&self) -> WatcherDiagnostics {
100        self.diagnostics.borrow().clone()
101    }
102
103    pub async fn updated_diagnostics(&self) -> WatcherDiagnostics {
104        let mut diagnostics = self.diagnostics.clone();
105        let _ = diagnostics.changed().await;
106        diagnostics.borrow().clone()
107    }
108
109    pub fn request_shutdown(&self) {
110        let _ = self.shutdown.send(true);
111    }
112
113    pub async fn add_repository(&self, repository: WatchedRepository) -> bool {
114        let Some(command_tx) = &self.command_tx else {
115            return false;
116        };
117        let (response_tx, response_rx) = oneshot::channel();
118        let command = WatcherCommand::Add {
119            repository,
120            response: response_tx,
121        };
122        if command_tx.send(command).await.is_err() {
123            return false;
124        }
125        matches!(
126            tokio::time::timeout(COMMAND_RESPONSE_TIMEOUT, response_rx).await,
127            Ok(Ok(true))
128        )
129    }
130
131    pub async fn remove_repository(&self, alias: &str) -> bool {
132        let Some(command_tx) = &self.command_tx else {
133            return false;
134        };
135        let (response_tx, response_rx) = oneshot::channel();
136        let command = WatcherCommand::Remove {
137            alias_or_id: alias.to_owned(),
138            response: response_tx,
139        };
140        if command_tx.send(command).await.is_err() {
141            return false;
142        }
143        matches!(
144            tokio::time::timeout(COMMAND_RESPONSE_TIMEOUT, response_rx).await,
145            Ok(Ok(true))
146        )
147    }
148
149    pub async fn repository_count(&self) -> usize {
150        self.state.read().await.repositories.len()
151    }
152}
153
154pub struct FileWatcher {
155    config: WatcherConfig,
156}
157
158impl FileWatcher {
159    pub fn new(config: WatcherConfig) -> Self {
160        Self { config }
161    }
162
163    pub fn start(self, repositories: Vec<WatchedRepository>) -> Result<WatcherHandle, String> {
164        self.start_with_sink(repositories, |_| async { Ok(()) })
165    }
166
167    pub fn start_with_sink<F, Fut>(
168        self,
169        repositories: Vec<WatchedRepository>,
170        task_sink: F,
171    ) -> Result<WatcherHandle, String>
172    where
173        F: Fn(crate::storage::CodeIndexTaskSeed) -> Fut + Send + Sync + 'static,
174        Fut: Future<Output = Result<(), String>> + Send + 'static,
175    {
176        if !self.config.enabled {
177            return Ok(disabled_handle(self.config.hash_cache_capacity));
178        }
179
180        let (diagnostics_tx, diagnostics_rx) = watch::channel(WatcherDiagnostics {
181            state: WatcherState::Active,
182            ..WatcherDiagnostics::default()
183        });
184        let (shutdown_tx, shutdown_rx) = watch::channel(false);
185        let (command_tx, command_rx) = mpsc::channel(COMMAND_CHANNEL_CAPACITY);
186        let state = Arc::new(RwLock::new(WatcherInternalState::new(
187            self.config.hash_cache_capacity,
188        )));
189        let handle = WatcherHandle {
190            diagnostics: diagnostics_rx,
191            shutdown: shutdown_tx,
192            state: Arc::clone(&state),
193            command_tx: Some(command_tx),
194        };
195        let context = WatcherLoopContext {
196            state,
197            diagnostics_tx,
198            dropped_events: Arc::new(AtomicU64::new(0)),
199            debounce: self.config.debounce,
200            commit_reconcile_interval: self.config.commit_reconcile_interval,
201            max_watch_dirs: self.config.max_watch_dirs,
202            task_sink: boxed_task_sink(task_sink),
203        };
204        tokio::spawn(event_loop::run(
205            context,
206            shutdown_rx,
207            repositories,
208            command_rx,
209        ));
210
211        Ok(handle)
212    }
213}
214
215#[derive(Debug)]
216struct WatcherInternalState {
217    repositories: Vec<WatchedRepository>,
218    hash_cache: ContentHashCache,
219    deferred_changes: ContentHashCache,
220    events_received: u64,
221    events_filtered: u64,
222    index_tasks_queued: u64,
223    commit_reconciliations: u64,
224    commit_tasks_queued: u64,
225    commit_reconcile_failures: u64,
226}
227
228impl WatcherInternalState {
229    fn new(hash_cache_capacity: usize) -> Self {
230        Self {
231            repositories: Vec::new(),
232            hash_cache: ContentHashCache::new(hash_cache_capacity),
233            deferred_changes: ContentHashCache::new(hash_cache_capacity),
234            events_received: 0,
235            events_filtered: 0,
236            index_tasks_queued: 0,
237            commit_reconciliations: 0,
238            commit_tasks_queued: 0,
239            commit_reconcile_failures: 0,
240        }
241    }
242}
243
244enum WatcherCommand {
245    Add {
246        repository: WatchedRepository,
247        response: oneshot::Sender<bool>,
248    },
249    Remove {
250        alias_or_id: String,
251        response: oneshot::Sender<bool>,
252    },
253}
254
255struct WatcherLoopContext {
256    state: Arc<RwLock<WatcherInternalState>>,
257    diagnostics_tx: watch::Sender<WatcherDiagnostics>,
258    dropped_events: Arc<AtomicU64>,
259    debounce: Duration,
260    commit_reconcile_interval: Duration,
261    max_watch_dirs: usize,
262    task_sink: TaskQueueSink,
263}
264
265fn disabled_handle(hash_cache_capacity: usize) -> WatcherHandle {
266    let (_diagnostics_tx, diagnostics_rx) = watch::channel(WatcherDiagnostics {
267        state: WatcherState::Disabled,
268        ..WatcherDiagnostics::default()
269    });
270    let (shutdown_tx, _) = watch::channel(false);
271    WatcherHandle {
272        diagnostics: diagnostics_rx,
273        shutdown: shutdown_tx,
274        state: Arc::new(RwLock::new(WatcherInternalState::new(hash_cache_capacity))),
275        command_tx: None,
276    }
277}
278
279fn boxed_task_sink<F, Fut>(task_sink: F) -> TaskQueueSink
280where
281    F: Fn(crate::storage::CodeIndexTaskSeed) -> Fut + Send + Sync + 'static,
282    Fut: Future<Output = Result<(), String>> + Send + 'static,
283{
284    Arc::new(move |task| Box::pin(task_sink(task)))
285}
286
287#[cfg(test)]
288use self::{
289    diagnostics::emit as emit_diagnostics,
290    index_queue::{process_debounced_paths, reconcile_all_commit_heads, should_process_path},
291};
292
293#[cfg(test)]
294#[path = "integration_tests.rs"]
295mod tests;