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_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;