hyperdb_mcp/watcher.rs
1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Directory watcher for incremental ingest.
5//!
6//! Producers coordinate with the watcher via a simple sentinel-file protocol:
7//!
8//! 1. Producer atomically writes the data file (e.g. `batch-0001.csv`).
9//! Usually this means writing to `batch-0001.csv.tmp` first and renaming.
10//! 2. Producer creates a zero-byte companion file `batch-0001.csv.ready`.
11//! 3. The watcher detects the `.ready` file, appends the paired data file to
12//! the target table, and deletes **both** files on success.
13//! 4. On failure, the watcher moves both files into a `failed/` subdirectory
14//! and writes a `<name>.error` JSON file with the error details. The
15//! failed files are not retried — manual intervention is expected.
16//!
17//! # Security: TOCTOU and atomic file operations
18//!
19//! There is an inherent TOCTOU (time-of-check-to-time-of-use) race between
20//! detecting the `.ready` sentinel and opening the data file for ingest.
21//! Producers **must** use atomic file operations to avoid this:
22//!
23//! - Write data to a temporary file (e.g. `batch.csv.tmp`), then **rename**
24//! it to the final name (`batch.csv`). Do not write directly to the target.
25//! - Never replace a data file with a symlink between writing and creating
26//! the `.ready` sentinel — the watcher resolves symlinks via
27//! `canonicalize()`, but the window between existence check and open
28//! cannot be fully eliminated without kernel-level file descriptors.
29//! - On shared filesystems, ensure the rename is atomic (same mount point).
30//!
31//! Only one table per watched directory is supported; ingest is always in
32//! append mode. File extensions decide the ingest path: `.csv`/`.json` go
33//! through the CSV ingest (JSON-lines not supported today), `.parquet`/`.pq`
34//! through the Parquet ingest, and `.arrow`/`.ipc`/`.feather` through the
35//! Arrow IPC ingest.
36//!
37//! # Concurrency model
38//!
39//! Each watcher runs as a tokio task and checks out connections from a
40//! per-watcher [`hyperdb_api::pool::Pool`] of [`hyperdb_api::AsyncConnection`]s.
41//! Up to `max_concurrent` ingests run in parallel; every file runs inside
42//! its own `BEGIN / COMMIT` on its own pooled connection, so the engine's
43//! primary sync connection (used by `query`, `execute`, `chart`, etc.) is
44//! never contended or forced to wait on a slow file.
45//!
46//! `notify` delivers events through its own std mpsc channel; we forward
47//! them into a `tokio::sync::mpsc` on a small helper thread so the tokio
48//! consumer can `.recv().await` naturally.
49
50use crate::attach::{AttachRegistry, AttachSource};
51use crate::engine::Engine;
52use crate::error::{ErrorCode, McpError};
53use crate::ingest::{
54 detect_file_format, ingest_csv_file_async, ingest_json_file_async, InferredFileFormat,
55 IngestOptions,
56};
57use crate::ingest_arrow::{ingest_arrow_ipc_file_async, ingest_parquet_file_async};
58use crate::subscriptions::{uris_for_table_change, SubscriptionRegistry};
59use hyperdb_api::pool::{create_pool, Pool, PoolConfig};
60use hyperdb_api::CreateMode;
61use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
62use serde_json::{json, Value};
63use std::collections::{HashMap, HashSet};
64use std::path::{Path, PathBuf};
65use std::sync::atomic::{AtomicU32, Ordering};
66use std::sync::{Arc, Mutex};
67use std::time::SystemTime;
68use tokio::sync::mpsc;
69use tokio::task::JoinHandle;
70
71/// Suffix used for the sentinel ("ready") file. Not a leading dot — append
72/// this to the full data file name (e.g. `orders.csv` → `orders.csv.ready`).
73pub const READY_SUFFIX: &str = ".ready";
74
75/// Resolve the file path the watcher pool should open as its workspace.
76/// `None` → primary (ephemeral). `Some("persistent")` → the engine's
77/// persistent path. `Some(alias)` → user-attached writable file from
78/// `AttachRegistry`. Read-only attachments are rejected because the
79/// watcher needs to INSERT/COPY into the table.
80fn resolve_pool_workspace(
81 eng: &Engine,
82 attachments: Option<&AttachRegistry>,
83 target_db: Option<&str>,
84) -> Result<String, McpError> {
85 let Some(alias) = target_db else {
86 return Ok(eng.ephemeral_path().to_string_lossy().to_string());
87 };
88 if alias.eq_ignore_ascii_case(Engine::PERSISTENT_ALIAS) {
89 let path = eng.persistent_path().ok_or_else(|| {
90 McpError::new(
91 ErrorCode::InvalidArgument,
92 "target 'persistent' but the server is in --ephemeral-only mode",
93 )
94 })?;
95 return Ok(path.to_string_lossy().to_string());
96 }
97 let registry = attachments.ok_or_else(|| {
98 McpError::new(
99 ErrorCode::InternalError,
100 "watcher pool requested for a user-attached alias but no AttachRegistry was supplied",
101 )
102 })?;
103 let entry = registry.get(alias).ok_or_else(|| {
104 McpError::new(
105 ErrorCode::InvalidArgument,
106 format!("database '{alias}' is not attached"),
107 )
108 })?;
109 if !entry.writable {
110 return Err(McpError::new(
111 ErrorCode::InvalidArgument,
112 format!(
113 "database '{alias}' was attached read-only. \
114 Re-attach with writable:true to use it as a watcher target."
115 ),
116 ));
117 }
118 let AttachSource::LocalFile { path } = &entry.source;
119 Ok(path.to_string_lossy().to_string())
120}
121
122/// Build a watcher connection pool from the current engine. Pulled out
123/// of [`start_watching`] so the recovery path (post hyperd restart)
124/// can call it again to swap in a fresh pool.
125///
126/// `target_db` selects the pool's workspace file: `None` → ephemeral
127/// primary, `Some("persistent")` → persistent attachment,
128/// `Some(alias)` → user-attached writable file (from `attachments`).
129fn build_watcher_pool(
130 engine: &Arc<Mutex<Option<Engine>>>,
131 attachments: Option<&AttachRegistry>,
132 target_db: Option<&str>,
133 concurrency: usize,
134) -> Result<Arc<Pool>, McpError> {
135 let guard = engine
136 .lock()
137 .map_err(|_| McpError::new(ErrorCode::InternalError, "Engine lock poisoned"))?;
138 let eng = guard.as_ref().ok_or_else(|| {
139 McpError::new(
140 ErrorCode::InternalError,
141 "Engine not initialized when watcher pool requested",
142 )
143 })?;
144 let endpoint = eng.hyperd_endpoint()?;
145 let workspace = resolve_pool_workspace(eng, attachments, target_db)?;
146 let cfg = PoolConfig::new(endpoint, workspace)
147 .create_mode(CreateMode::DoNotCreate)
148 .max_size(concurrency);
149 Ok(Arc::new(create_pool(cfg).map_err(|e| {
150 McpError::new(
151 ErrorCode::InternalError,
152 format!("Failed to build watcher pool: {e}"),
153 )
154 })?))
155}
156
157/// Replace the watcher's pool atomically with a freshly-built one.
158/// Called from the ingest path when a connection-lost error suggests
159/// the underlying hyperd has been replaced (daemon restart, manual
160/// `hyperd kill`).
161async fn rebuild_watcher_pool(
162 pool_slot: &tokio::sync::RwLock<Arc<Pool>>,
163 engine: &Arc<Mutex<Option<Engine>>>,
164 attachments: Option<&AttachRegistry>,
165 target_db: Option<&str>,
166 concurrency: usize,
167) -> Result<(), McpError> {
168 let new_pool = build_watcher_pool(engine, attachments, target_db, concurrency)?;
169 let mut guard = pool_slot.write().await;
170 *guard = new_pool;
171 Ok(())
172}
173
174/// Default ceiling on parallel ingests per watcher. Chosen conservatively —
175/// each parallel ingest holds one open TCP connection to hyperd plus a
176/// transaction, and most workloads have fewer than 4 incoming streams at a
177/// time.
178pub const DEFAULT_MAX_CONCURRENT: usize = 4;
179
180/// Hard upper bound on `max_concurrent` to prevent a runaway `watch_directory`
181/// call from exhausting hyperd connections.
182pub const MAX_CONCURRENT_LIMIT: usize = 32;
183
184/// Options for [`start_watching`]. Use the builder-free literal form —
185/// every field has a sensible default.
186#[derive(Debug, Clone, Default)]
187pub struct WatchOptions {
188 /// Maximum number of files ingested in parallel. `0` means use
189 /// [`DEFAULT_MAX_CONCURRENT`]. Values above [`MAX_CONCURRENT_LIMIT`]
190 /// are clamped to the limit.
191 pub max_concurrent: usize,
192}
193
194impl WatchOptions {
195 fn resolved_concurrency(&self) -> usize {
196 let n = if self.max_concurrent == 0 {
197 DEFAULT_MAX_CONCURRENT
198 } else {
199 self.max_concurrent
200 };
201 n.clamp(1, MAX_CONCURRENT_LIMIT)
202 }
203}
204
205/// Running counters for a watcher. Updated in place as the background task
206/// processes events.
207#[derive(Debug, Default, Clone)]
208pub struct WatcherStats {
209 pub files_ingested: u64,
210 pub files_failed: u64,
211 pub last_event_at: Option<SystemTime>,
212 pub last_error: Option<String>,
213 /// Configured parallelism ceiling (resolved to an actual number).
214 pub max_concurrent: u32,
215 /// Ingest tasks currently running — gives operators a live-load signal.
216 pub in_flight: u32,
217}
218
219impl WatcherStats {
220 fn snapshot(&self) -> Self {
221 self.clone()
222 }
223}
224
225/// Owns the notify watcher, the async ingest task, and the connection pool
226/// for one watched directory.
227///
228/// Dropping the handle stops the watcher cleanly: the `Option<Watcher>` is
229/// taken first, which drops the sender end of the mpsc channel, which causes
230/// the worker task's `recv()` to return `None`, which ends the loop. We then
231/// abort the task just in case (the `JoinHandle` is dropped non-blockingly —
232/// the task has no cancellation-point awaits after `recv()`'s loop ends so
233/// it completes naturally).
234#[derive(Debug)]
235pub struct WatcherHandle {
236 pub directory: PathBuf,
237 pub table: String,
238 /// Resolved target database alias for this watcher. `None` →
239 /// primary; `Some("persistent")` → persistent attachment;
240 /// `Some(alias)` → user-attached writable. Stored so the
241 /// post-reconnect rebuild path resolves to the same target,
242 /// and so `detach_database` can refuse to detach an alias with
243 /// an active watcher.
244 pub target_db: Option<String>,
245 pub stats: Arc<Mutex<WatcherStats>>,
246 /// Live counter of in-flight ingest tasks. Decremented on task completion
247 /// via an RAII guard.
248 in_flight: Arc<AtomicU32>,
249 watcher: Option<RecommendedWatcher>,
250 /// Handle to the tokio task that consumes notify events. Aborted on drop.
251 task: Option<JoinHandle<()>>,
252 /// Forwarder thread that bridges the std-sync notify sender to the
253 /// tokio mpsc channel. Joined on drop after the notify watcher is
254 /// dropped (which closes the std sender and lets this thread exit).
255 forwarder: Option<std::thread::JoinHandle<()>>,
256 /// Per-watcher connection pool. Wrapped in a tokio `RwLock` so the
257 /// recovery path can swap in a fresh pool after a hyperd restart
258 /// without disturbing in-flight ingests on the old pool. Kept here
259 /// so the pool is torn down (all connections close) when the handle
260 /// is dropped.
261 _pool: Arc<tokio::sync::RwLock<Arc<Pool>>>,
262}
263
264impl Drop for WatcherHandle {
265 fn drop(&mut self) {
266 // Drop the notify watcher first so its std-mpsc sender goes away;
267 // that closes the forwarder's `rx`, which drops the tokio sender,
268 // which ends the async consumer's loop.
269 self.watcher.take();
270 if let Some(t) = self.forwarder.take() {
271 let _ = t.join();
272 }
273 if let Some(task) = self.task.take() {
274 task.abort();
275 }
276 }
277}
278
279/// Registry of all active watchers, keyed by canonicalized directory path.
280#[derive(Debug)]
281pub struct WatcherRegistry {
282 pub(crate) watchers: Mutex<HashMap<PathBuf, WatcherHandle>>,
283}
284
285impl WatcherRegistry {
286 #[must_use]
287 pub fn new() -> Self {
288 Self {
289 watchers: Mutex::new(HashMap::new()),
290 }
291 }
292
293 /// Number of currently registered watchers. Intended for tests and
294 /// diagnostics.
295 pub fn len(&self) -> usize {
296 self.watchers.lock().map_or(0, |g| g.len())
297 }
298
299 /// True when there are no active watchers.
300 pub fn is_empty(&self) -> bool {
301 self.len() == 0
302 }
303
304 /// Render the current set of watchers as a JSON array for the `status` tool.
305 pub fn to_json(&self) -> Value {
306 let Ok(guard) = self.watchers.lock() else {
307 return Value::Array(Vec::new());
308 };
309 let now = SystemTime::now();
310 let items: Vec<Value> = guard
311 .values()
312 .map(|h| {
313 let stats = h.stats.lock().map(|s| s.snapshot()).unwrap_or_default();
314 let in_flight = h.in_flight.load(Ordering::Relaxed);
315 let last_event_ms_ago = stats
316 .last_event_at
317 .and_then(|t| now.duration_since(t).ok())
318 // `Duration::as_millis` is `u128`; saturate to
319 // `u64::MAX` on the absurd-long-duration edge
320 // instead of silently wrapping (AGENTS.md §9).
321 .map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX));
322 json!({
323 "directory": h.directory.to_string_lossy(),
324 "table": h.table,
325 "target_db": h.target_db.clone().unwrap_or_else(|| "local".into()),
326 "files_ingested": stats.files_ingested,
327 "files_failed": stats.files_failed,
328 "last_event_ms_ago": last_event_ms_ago,
329 "last_error": stats.last_error,
330 "max_concurrent": stats.max_concurrent,
331 "in_flight": in_flight,
332 })
333 })
334 .collect();
335 Value::Array(items)
336 }
337}
338
339impl Default for WatcherRegistry {
340 fn default() -> Self {
341 Self::new()
342 }
343}
344
345/// RAII guard that increments `in_flight` on construction and decrements on
346/// drop. Used to keep the live-load counter consistent even if an ingest
347/// task panics or early-returns.
348struct InFlightGuard {
349 counter: Arc<AtomicU32>,
350}
351
352impl InFlightGuard {
353 fn new(counter: Arc<AtomicU32>) -> Self {
354 counter.fetch_add(1, Ordering::Relaxed);
355 Self { counter }
356 }
357}
358
359impl Drop for InFlightGuard {
360 fn drop(&mut self) {
361 self.counter.fetch_sub(1, Ordering::Relaxed);
362 }
363}
364
365#[expect(
366 clippy::needless_pass_by_value,
367 reason = "call-site ergonomics: function consumes logically-owned parameters, refactoring signatures is not worth per-site churn"
368)]
369/// Begin watching `dir`. Builds a dedicated connection pool, runs the
370/// initial sweep (sequentially — there's no benefit to parallelism for
371/// startup since the pool isn't under load yet), then installs a
372/// [`notify`] watcher that streams events to an async tokio task.
373/// Returns a snapshot of the initial-sweep stats.
374///
375/// The engine `Arc` must point to an already-initialized engine (the caller
376/// in `server.rs` eagerly calls `ensure_engine` before invoking this).
377///
378/// # Errors
379///
380/// - Returns [`ErrorCode::FileNotFound`] if `dir` does not exist, is
381/// not a directory, or cannot be canonicalized.
382/// - Returns [`ErrorCode::InternalError`] if the watcher registry
383/// mutex or engine mutex is poisoned, if the engine has not been
384/// initialized, if `start_watching` is not called from a Tokio
385/// runtime, or if the watcher pool / OS file-system watcher cannot
386/// be constructed.
387/// - Returns [`ErrorCode::InternalError`] wrapping the error string
388/// when [`notify::RecommendedWatcher`] setup fails.
389/// - Propagates any error from the initial sweep's per-file ingest
390/// (file read, schema inference, or Hyper `COPY` / `INSERT` errors).
391pub fn start_watching(
392 engine: Arc<Mutex<Option<Engine>>>,
393 attachments: Arc<AttachRegistry>,
394 registry: Arc<WatcherRegistry>,
395 subscriptions: Option<Arc<SubscriptionRegistry>>,
396 dir: PathBuf,
397 table: String,
398 target_db: Option<String>,
399 options: WatchOptions,
400) -> Result<WatcherStats, McpError> {
401 if !dir.exists() {
402 return Err(McpError::new(
403 ErrorCode::FileNotFound,
404 format!("Directory does not exist: {}", dir.display()),
405 ));
406 }
407 if !dir.is_dir() {
408 return Err(McpError::new(
409 ErrorCode::FileNotFound,
410 format!("Not a directory: {}", dir.display()),
411 ));
412 }
413 let canonical = dir.canonicalize().map_err(|e| {
414 McpError::new(
415 ErrorCode::FileNotFound,
416 format!("Cannot canonicalize {}: {e}", dir.display()),
417 )
418 })?;
419
420 {
421 let watchers = registry.watchers.lock().map_err(|_| {
422 McpError::new(ErrorCode::InternalError, "Watcher registry lock poisoned")
423 })?;
424 if watchers.contains_key(&canonical) {
425 return Err(McpError::new(
426 ErrorCode::InternalError,
427 format!("Already watching {}", canonical.display()),
428 )
429 .with_suggestion(
430 "Call unwatch_directory first to re-register with different options",
431 ));
432 }
433 }
434
435 let concurrency = options.resolved_concurrency();
436
437 // Build the per-watcher pool. We pull the endpoint and workspace path
438 // from the engine under a brief lock, then release it — the pool
439 // itself operates independently of the sync connection the engine
440 // still owns.
441 // Build the pool wrapped in an Arc<RwLock<…>> so post-construction
442 // hyperd restarts can swap in a fresh pool without restarting the
443 // watcher.
444 let pool = Arc::new(tokio::sync::RwLock::new(build_watcher_pool(
445 &engine,
446 Some(attachments.as_ref()),
447 target_db.as_deref(),
448 concurrency,
449 )?));
450
451 let stats = Arc::new(Mutex::new(WatcherStats {
452 // Concurrency is configured by the user via a `u32`-sized field
453 // upstream; saturating is a safe diagnostic.
454 max_concurrent: u32::try_from(concurrency).unwrap_or(u32::MAX),
455 ..Default::default()
456 }));
457 let in_flight = Arc::new(AtomicU32::new(0));
458 // Set of `.ready` paths with an in-flight ingest task. Used to dedupe
459 // duplicate filesystem events (macOS FSEvents in particular delivers
460 // both Create and Modify events for a single `write` syscall, and
461 // both would otherwise spawn independent ingest tasks — the per-task
462 // `.exists()` check is a TOCTOU race, not a real idempotence guard).
463 let in_flight_paths: Arc<Mutex<HashSet<PathBuf>>> = Arc::new(Mutex::new(HashSet::new()));
464
465 // Initial sweep: process anything already in the directory before
466 // wiring up events. Done sequentially on a single pooled connection
467 // — fine, because the pool isn't under load yet and this keeps the
468 // return-value shape simple (caller blocks on sweep completion).
469 //
470 // We run the sweep synchronously (the caller — an rmcp tool handler —
471 // is a sync `fn` running on a multi-thread tokio runtime). A plain
472 // `Handle::block_on` would panic with "Cannot start a runtime from
473 // within a runtime"; `block_in_place` tells tokio to move this
474 // worker thread off the task pool for the duration of the blocking
475 // call, then resume. Requires the multi-thread flavor — which the
476 // MCP binary uses via `#[tokio::main]` and which tests must opt
477 // into with `#[tokio::test(flavor = "multi_thread")]`.
478 let initial = {
479 let rt = tokio::runtime::Handle::try_current().map_err(|_| {
480 McpError::new(
481 ErrorCode::InternalError,
482 "start_watching must be called from inside a tokio runtime",
483 )
484 })?;
485 tokio::task::block_in_place(|| {
486 rt.block_on(async {
487 for ready_path in scan_ready_files(&canonical) {
488 process_ready_with_recovery(
489 &pool,
490 &engine,
491 Some(attachments.as_ref()),
492 target_db.as_deref(),
493 concurrency,
494 subscriptions.as_deref(),
495 &canonical,
496 &table,
497 &ready_path,
498 &stats,
499 )
500 .await;
501 }
502 stats.lock().map(|s| s.snapshot()).unwrap_or_default()
503 })
504 })
505 };
506
507 // notify uses its own std channel; bridge it into a tokio mpsc via a
508 // small forwarder thread so the async consumer can `.recv().await`.
509 let (std_tx, std_rx) = std::sync::mpsc::channel::<notify::Result<Event>>();
510 let (async_tx, mut async_rx) = mpsc::unbounded_channel::<notify::Result<Event>>();
511
512 let mut watcher = notify::recommended_watcher(move |res: notify::Result<Event>| {
513 let _ = std_tx.send(res);
514 })
515 .map_err(|e| {
516 McpError::new(
517 ErrorCode::InternalError,
518 format!("Failed to create watcher: {e}"),
519 )
520 })?;
521 watcher
522 .watch(&canonical, RecursiveMode::NonRecursive)
523 .map_err(|e| {
524 McpError::new(
525 ErrorCode::InternalError,
526 format!("Failed to watch directory: {e}"),
527 )
528 })?;
529
530 // Forwarder thread: std sync -> tokio mpsc. Exits when the notify
531 // watcher is dropped (the std sender goes away, `recv()` returns Err).
532 let forwarder = {
533 let async_tx = async_tx.clone();
534 std::thread::Builder::new()
535 .name(format!("hyperdb-mcp-watch-fwd-{}", canonical.display()))
536 .spawn(move || {
537 while let Ok(ev) = std_rx.recv() {
538 if async_tx.send(ev).is_err() {
539 break;
540 }
541 }
542 })
543 .map_err(|e| {
544 McpError::new(
545 ErrorCode::InternalError,
546 format!("Failed to spawn forwarder thread: {e}"),
547 )
548 })?
549 };
550 // Drop our local async sender so the consumer can actually reach EOF
551 // once the forwarder thread exits (the forwarder keeps its own clone).
552 drop(async_tx);
553
554 // Consumer task: one per-ready-file ingest spawned as its own task,
555 // bounded naturally by `pool.get().await` (the pool caps at
556 // `concurrency`). We use `tokio::spawn` rather than `JoinSet` because
557 // we don't need to await every task — they're fire-and-forget from
558 // the consumer's point of view, with stats updated via shared Mutex.
559 let task = {
560 let pool = Arc::clone(&pool);
561 let engine_for_pool = Arc::clone(&engine);
562 let attachments_for_pool = Arc::clone(&attachments);
563 let subs = subscriptions.clone();
564 let stats = Arc::clone(&stats);
565 let in_flight = Arc::clone(&in_flight);
566 let in_flight_paths = Arc::clone(&in_flight_paths);
567 let dir = canonical.clone();
568 let table = table.clone();
569 let task_target_db = target_db.clone();
570 tokio::spawn(async move {
571 while let Some(event_res) = async_rx.recv().await {
572 let Ok(event) = event_res else { continue };
573 if !matches!(event.kind, EventKind::Create(_) | EventKind::Modify(_)) {
574 continue;
575 }
576 for path in event.paths {
577 if !is_ready_file(&path) {
578 continue;
579 }
580 // Dedupe: if a task for this `.ready` path is already
581 // running, drop this event. A lost Modify-after-Create
582 // here is harmless — by the time the in-flight task
583 // reaches its own `.exists()` check the data file is
584 // either still there (gets ingested) or already moved
585 // to `failed/` (correctly skipped).
586 let claimed = in_flight_paths
587 .lock()
588 .is_ok_and(|mut set| set.insert(path.clone()));
589 if !claimed {
590 continue;
591 }
592 let pool_slot = Arc::clone(&pool);
593 let engine_handle = Arc::clone(&engine_for_pool);
594 let attachments_handle = Arc::clone(&attachments_for_pool);
595 let target_db_clone = task_target_db.clone();
596 let subs = subs.clone();
597 let stats = Arc::clone(&stats);
598 let in_flight = Arc::clone(&in_flight);
599 let in_flight_paths = Arc::clone(&in_flight_paths);
600 let dir = dir.clone();
601 let table = table.clone();
602 tokio::spawn(async move {
603 let _guard = InFlightGuard::new(in_flight);
604 process_ready_with_recovery(
605 &pool_slot,
606 &engine_handle,
607 Some(attachments_handle.as_ref()),
608 target_db_clone.as_deref(),
609 concurrency,
610 subs.as_deref(),
611 &dir,
612 &table,
613 &path,
614 &stats,
615 )
616 .await;
617 if let Ok(mut set) = in_flight_paths.lock() {
618 set.remove(&path);
619 }
620 });
621 }
622 }
623 })
624 };
625
626 let handle = WatcherHandle {
627 directory: canonical.clone(),
628 table,
629 target_db,
630 stats,
631 in_flight,
632 watcher: Some(watcher),
633 task: Some(task),
634 forwarder: Some(forwarder),
635 _pool: pool,
636 };
637 {
638 let mut watchers = registry.watchers.lock().map_err(|_| {
639 McpError::new(ErrorCode::InternalError, "Watcher registry lock poisoned")
640 })?;
641 watchers.insert(canonical, handle);
642 }
643 Ok(initial)
644}
645
646/// Stop watching a directory. Returns a JSON summary including final stats.
647///
648/// # Errors
649///
650/// - Returns [`ErrorCode::InternalError`] if the watcher registry
651/// mutex is poisoned.
652/// - Returns [`ErrorCode::FileNotFound`] if no active watcher is
653/// registered for the canonicalized `dir`.
654pub fn stop_watching(registry: &WatcherRegistry, dir: &Path) -> Result<Value, McpError> {
655 let canonical = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
656
657 let handle_opt = {
658 let mut watchers = registry.watchers.lock().map_err(|_| {
659 McpError::new(ErrorCode::InternalError, "Watcher registry lock poisoned")
660 })?;
661 watchers.remove(&canonical)
662 };
663
664 match handle_opt {
665 Some(handle) => {
666 let stats = handle
667 .stats
668 .lock()
669 .map(|s| s.snapshot())
670 .unwrap_or_default();
671 let directory = handle.directory.clone();
672 let table = handle.table.clone();
673 drop(handle); // triggers the Drop impl: stops watcher + joins thread
674 Ok(json!({
675 "directory": directory.to_string_lossy(),
676 "table": table,
677 "status": "stopped",
678 "files_ingested": stats.files_ingested,
679 "files_failed": stats.files_failed,
680 "last_error": stats.last_error,
681 }))
682 }
683 None => Err(McpError::new(
684 ErrorCode::FileNotFound,
685 format!("No active watcher for {}", canonical.display()),
686 )
687 .with_suggestion("Check status tool output for currently watched directories")),
688 }
689}
690
691/// Scan `dir` (non-recursively) for files whose name ends with `.ready`.
692fn scan_ready_files(dir: &Path) -> Vec<PathBuf> {
693 let mut out = Vec::new();
694 let Ok(entries) = std::fs::read_dir(dir) else {
695 return out;
696 };
697 for entry in entries.flatten() {
698 let path = entry.path();
699 if path.is_file() && is_ready_file(&path) {
700 out.push(path);
701 }
702 }
703 out
704}
705
706/// True if the path ends with the `.ready` sentinel suffix.
707fn is_ready_file(path: &Path) -> bool {
708 path.file_name()
709 .and_then(|s| s.to_str())
710 .is_some_and(|s| s.ends_with(READY_SUFFIX))
711}
712
713/// Given a `.ready` sentinel path, return the paired data file path.
714/// Returns `None` if the path doesn't end in `.ready`.
715fn strip_ready_suffix(ready_path: &Path) -> Option<PathBuf> {
716 let name = ready_path.file_name()?.to_str()?;
717 let stripped = name.strip_suffix(READY_SUFFIX)?;
718 Some(ready_path.with_file_name(stripped))
719}
720
721/// Ingest the data file paired with a `.ready` sentinel on a pooled
722/// async connection. On success, both files are deleted. On failure,
723/// both are moved to `<dir>/failed/` and a `<name>.error` JSON file is
724/// written alongside.
725///
726/// The connection is checked out of the pool for the duration of the
727/// ingest (including the `BEGIN / COMMIT`) and released on scope exit
728/// via the `PooledConnection` Drop. Other ingest tasks run in parallel
729/// on their own connections, up to the pool's `max_size`.
730/// Ingest one ready file and return `Ok(rows)` on success, or the
731/// underlying error. Pure ingest path — no file moves, no stats writes.
732/// The wrapper layer ([`process_ready_with_recovery`]) handles those
733/// after deciding whether the error is recoverable.
734async fn ingest_one_ready_file(
735 pool: &Arc<Pool>,
736 table: &str,
737 ready_path: &Path,
738 data_path: &Path,
739) -> Result<u64, McpError> {
740 let mut conn = pool.get().await.map_err(|e| {
741 McpError::new(
742 ErrorCode::InternalError,
743 format!("Failed to check out connection: {e}"),
744 )
745 })?;
746 // The pool was built against the target's `.hyper` file as its
747 // workspace, so from these connections' perspective the target IS
748 // the primary database — qualified SQL with "persistent"."public"
749 // wouldn't resolve here. Keep target_db = None so the unqualified
750 // identifier routes into the pool's primary.
751 let opts = IngestOptions {
752 table: table.to_string(),
753 mode: "append".into(),
754 schema_override: None,
755 merge_key: None,
756 target_db: None,
757 };
758 let data_str = data_path
759 .to_str()
760 .ok_or_else(|| McpError::new(ErrorCode::InternalError, "Non-UTF-8 path"))?;
761 let res = match detect_file_format(data_path) {
762 InferredFileFormat::Parquet => ingest_parquet_file_async(&mut conn, data_str, &opts).await,
763 InferredFileFormat::ArrowIpc => {
764 ingest_arrow_ipc_file_async(&mut conn, data_str, &opts).await
765 }
766 InferredFileFormat::Json => ingest_json_file_async(&mut conn, data_str, &opts).await,
767 InferredFileFormat::Csv => ingest_csv_file_async(&mut conn, data_str, &opts).await,
768 }?;
769 let _ = ready_path; // silence the unused-variable lint; the path is used by the caller
770 Ok(res.rows)
771}
772
773/// Ingest one ready file with one-shot pool-rebuild recovery. If the
774/// first attempt fails with a connection-lost error (hyperd was
775/// restarted by the daemon, or the pool's connections went stale), the
776/// watcher rebuilds its pool from the engine's *current* endpoint and
777/// retries the ingest exactly once. Persistent errors fall through to
778/// the standard `failed/` move.
779async fn process_ready_with_recovery(
780 pool_slot: &tokio::sync::RwLock<Arc<Pool>>,
781 engine: &Arc<Mutex<Option<Engine>>>,
782 attachments: Option<&AttachRegistry>,
783 target_db: Option<&str>,
784 concurrency: usize,
785 subscriptions: Option<&SubscriptionRegistry>,
786 dir: &Path,
787 table: &str,
788 ready_path: &Path,
789 stats: &Arc<Mutex<WatcherStats>>,
790) {
791 let Some(data_path) = strip_ready_suffix(ready_path) else {
792 return;
793 };
794 // Idempotence: either file may already be gone (we processed it on a
795 // previous event); skip silently.
796 if !ready_path.exists() || !data_path.exists() {
797 return;
798 }
799 let is_symlink = |p: &std::path::Path| {
800 p.symlink_metadata()
801 .is_ok_and(|m| m.file_type().is_symlink())
802 };
803 if is_symlink(ready_path) || is_symlink(&data_path) {
804 tracing::warn!(
805 ready = %ready_path.display(),
806 data = %data_path.display(),
807 "Refusing to ingest: sentinel or data file is a symlink"
808 );
809 return;
810 }
811
812 // First attempt — uses whatever pool is currently in the slot.
813 let active_pool = pool_slot.read().await.clone();
814 let mut result = ingest_one_ready_file(&active_pool, table, ready_path, &data_path).await;
815 drop(active_pool);
816
817 // If the first attempt failed with what looks like a connection
818 // loss, try rebuilding the pool once and retrying. Hyperd restarts
819 // (daemon-managed) reuse the same endpoint slot but invalidate
820 // every connection in the pool; without this branch the watcher
821 // would route every subsequent file to `failed/` until the user
822 // notices and re-issues `watch_directory`.
823 if let Err(ref err) = result {
824 if crate::error::is_connection_lost(&err.message) {
825 tracing::warn!(
826 err = %err.message,
827 "watcher: detected connection-lost error, rebuilding pool and retrying"
828 );
829 match rebuild_watcher_pool(pool_slot, engine, attachments, target_db, concurrency).await
830 {
831 Ok(()) => {
832 let active_pool = pool_slot.read().await.clone();
833 result =
834 ingest_one_ready_file(&active_pool, table, ready_path, &data_path).await;
835 }
836 Err(e) => {
837 tracing::warn!(
838 err = %e.message,
839 "watcher: pool rebuild failed; the original ingest error will surface"
840 );
841 }
842 }
843 }
844 }
845
846 match result {
847 Ok(rows) => {
848 let _ = std::fs::remove_file(ready_path);
849 let _ = std::fs::remove_file(&data_path);
850 if let Ok(mut s) = stats.lock() {
851 s.files_ingested += 1;
852 s.last_event_at = Some(SystemTime::now());
853 s.last_error = None;
854 }
855 tracing::info!(
856 "watcher: ingested {rows} rows from {} into {}",
857 data_path.display(),
858 table
859 );
860 // Update _table_catalog on the engine's main connection
861 // (which sees both ephemeral and persistent + any user-
862 // attached aliases). The pool wrote the data into the
863 // target's .hyper file; the engine connection now stamps
864 // a stub row in that DB's per-DB _table_catalog.
865 //
866 // Errors here are logged but never block the success
867 // bookkeeping above — the data is in. Mirrors the sync
868 // load_file/load_data handlers' best-effort contract.
869 let row_count_i64 = i64::try_from(rows).unwrap_or(i64::MAX);
870 let load_params = serde_json::to_string(&json!({
871 "watch_directory": dir.to_string_lossy(),
872 "database": target_db.unwrap_or("local"),
873 }))
874 .ok();
875 if let Ok(guard) = engine.lock() {
876 if let Some(eng) = guard.as_ref() {
877 if let Err(e) = crate::table_catalog::upsert_stub_in(
878 eng,
879 table,
880 "watch_directory",
881 load_params.as_deref(),
882 Some(row_count_i64),
883 true,
884 target_db,
885 None,
886 ) {
887 tracing::warn!(
888 table = %table,
889 target_db = ?target_db,
890 err = %e.message,
891 "watcher: failed to update _table_catalog after ingest"
892 );
893 }
894 }
895 }
896 if let Some(subs) = subscriptions {
897 for uri in uris_for_table_change(table) {
898 subs.notify_updated(&uri);
899 }
900 }
901 }
902 Err(err) => {
903 let fail_dir = dir.join("failed");
904 let _ = std::fs::create_dir_all(&fail_dir);
905 if let Some(name) = data_path.file_name() {
906 let _ = std::fs::rename(&data_path, fail_dir.join(name));
907 let err_file = fail_dir.join(format!("{}.error", name.to_string_lossy()));
908 let err_json = serde_json::to_string_pretty(&json!({
909 "code": format!("{:?}", err.code),
910 "message": err.message,
911 "suggestion": err.suggestion,
912 }))
913 .unwrap_or_default();
914 let _ = std::fs::write(err_file, err_json);
915 }
916 if let Some(name) = ready_path.file_name() {
917 let _ = std::fs::rename(ready_path, fail_dir.join(name));
918 }
919 if let Ok(mut s) = stats.lock() {
920 s.files_failed += 1;
921 s.last_event_at = Some(SystemTime::now());
922 s.last_error = Some(err.to_string());
923 }
924 tracing::warn!(
925 "watcher: ingest failed for {}: {}",
926 data_path.display(),
927 err
928 );
929 }
930 }
931}
932
933#[cfg(test)]
934mod tests {
935 use super::*;
936
937 #[test]
938 fn is_ready_file_checks_suffix() {
939 assert!(is_ready_file(Path::new("/tmp/foo.csv.ready")));
940 assert!(is_ready_file(Path::new("/tmp/bar.ready")));
941 assert!(!is_ready_file(Path::new("/tmp/foo.csv")));
942 assert!(!is_ready_file(Path::new("/tmp/foo.ready.txt")));
943 }
944
945 #[test]
946 fn strip_ready_gives_data_path() {
947 assert_eq!(
948 strip_ready_suffix(Path::new("/tmp/foo.csv.ready")).unwrap(),
949 Path::new("/tmp/foo.csv")
950 );
951 assert!(strip_ready_suffix(Path::new("/tmp/foo.csv")).is_none());
952 }
953
954 #[test]
955 fn resolved_concurrency_clamps() {
956 assert_eq!(
957 WatchOptions { max_concurrent: 0 }.resolved_concurrency(),
958 DEFAULT_MAX_CONCURRENT
959 );
960 assert_eq!(WatchOptions { max_concurrent: 1 }.resolved_concurrency(), 1);
961 assert_eq!(
962 WatchOptions {
963 max_concurrent: 1000
964 }
965 .resolved_concurrency(),
966 MAX_CONCURRENT_LIMIT
967 );
968 }
969}