relay_knowledge/watcher/engine/
mod.rs1use 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;