Skip to main content

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::engine::Engine;
51use crate::error::{ErrorCode, McpError};
52use crate::ingest::{
53    detect_file_format, ingest_csv_file_async, ingest_json_file_async, InferredFileFormat,
54    IngestOptions,
55};
56use crate::ingest_arrow::{ingest_arrow_ipc_file_async, ingest_parquet_file_async};
57use crate::subscriptions::{uris_for_table_change, SubscriptionRegistry};
58use hyperdb_api::pool::{create_pool, Pool, PoolConfig};
59use hyperdb_api::CreateMode;
60use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
61use serde_json::{json, Value};
62use std::collections::{HashMap, HashSet};
63use std::path::{Path, PathBuf};
64use std::sync::atomic::{AtomicU32, Ordering};
65use std::sync::{Arc, Mutex};
66use std::time::SystemTime;
67use tokio::sync::mpsc;
68use tokio::task::JoinHandle;
69
70/// Suffix used for the sentinel ("ready") file. Not a leading dot — append
71/// this to the full data file name (e.g. `orders.csv` → `orders.csv.ready`).
72pub const READY_SUFFIX: &str = ".ready";
73
74/// Default ceiling on parallel ingests per watcher. Chosen conservatively —
75/// each parallel ingest holds one open TCP connection to hyperd plus a
76/// transaction, and most workloads have fewer than 4 incoming streams at a
77/// time.
78pub const DEFAULT_MAX_CONCURRENT: usize = 4;
79
80/// Hard upper bound on `max_concurrent` to prevent a runaway `watch_directory`
81/// call from exhausting hyperd connections.
82pub const MAX_CONCURRENT_LIMIT: usize = 32;
83
84/// Options for [`start_watching`]. Use the builder-free literal form —
85/// every field has a sensible default.
86#[derive(Debug, Clone, Default)]
87pub struct WatchOptions {
88    /// Maximum number of files ingested in parallel. `0` means use
89    /// [`DEFAULT_MAX_CONCURRENT`]. Values above [`MAX_CONCURRENT_LIMIT`]
90    /// are clamped to the limit.
91    pub max_concurrent: usize,
92}
93
94impl WatchOptions {
95    fn resolved_concurrency(&self) -> usize {
96        let n = if self.max_concurrent == 0 {
97            DEFAULT_MAX_CONCURRENT
98        } else {
99            self.max_concurrent
100        };
101        n.clamp(1, MAX_CONCURRENT_LIMIT)
102    }
103}
104
105/// Running counters for a watcher. Updated in place as the background task
106/// processes events.
107#[derive(Debug, Default, Clone)]
108pub struct WatcherStats {
109    pub files_ingested: u64,
110    pub files_failed: u64,
111    pub last_event_at: Option<SystemTime>,
112    pub last_error: Option<String>,
113    /// Configured parallelism ceiling (resolved to an actual number).
114    pub max_concurrent: u32,
115    /// Ingest tasks currently running — gives operators a live-load signal.
116    pub in_flight: u32,
117}
118
119impl WatcherStats {
120    fn snapshot(&self) -> Self {
121        self.clone()
122    }
123}
124
125/// Owns the notify watcher, the async ingest task, and the connection pool
126/// for one watched directory.
127///
128/// Dropping the handle stops the watcher cleanly: the `Option<Watcher>` is
129/// taken first, which drops the sender end of the mpsc channel, which causes
130/// the worker task's `recv()` to return `None`, which ends the loop. We then
131/// abort the task just in case (the `JoinHandle` is dropped non-blockingly —
132/// the task has no cancellation-point awaits after `recv()`'s loop ends so
133/// it completes naturally).
134#[derive(Debug)]
135pub struct WatcherHandle {
136    pub directory: PathBuf,
137    pub table: String,
138    pub stats: Arc<Mutex<WatcherStats>>,
139    /// Live counter of in-flight ingest tasks. Decremented on task completion
140    /// via an RAII guard.
141    in_flight: Arc<AtomicU32>,
142    watcher: Option<RecommendedWatcher>,
143    /// Handle to the tokio task that consumes notify events. Aborted on drop.
144    task: Option<JoinHandle<()>>,
145    /// Forwarder thread that bridges the std-sync notify sender to the
146    /// tokio mpsc channel. Joined on drop after the notify watcher is
147    /// dropped (which closes the std sender and lets this thread exit).
148    forwarder: Option<std::thread::JoinHandle<()>>,
149    /// Per-watcher connection pool. Kept here so it's torn down when the
150    /// handle is dropped — all outstanding connections close.
151    _pool: Arc<Pool>,
152}
153
154impl Drop for WatcherHandle {
155    fn drop(&mut self) {
156        // Drop the notify watcher first so its std-mpsc sender goes away;
157        // that closes the forwarder's `rx`, which drops the tokio sender,
158        // which ends the async consumer's loop.
159        self.watcher.take();
160        if let Some(t) = self.forwarder.take() {
161            let _ = t.join();
162        }
163        if let Some(task) = self.task.take() {
164            task.abort();
165        }
166    }
167}
168
169/// Registry of all active watchers, keyed by canonicalized directory path.
170#[derive(Debug)]
171pub struct WatcherRegistry {
172    pub(crate) watchers: Mutex<HashMap<PathBuf, WatcherHandle>>,
173}
174
175impl WatcherRegistry {
176    #[must_use]
177    pub fn new() -> Self {
178        Self {
179            watchers: Mutex::new(HashMap::new()),
180        }
181    }
182
183    /// Number of currently registered watchers. Intended for tests and
184    /// diagnostics.
185    pub fn len(&self) -> usize {
186        self.watchers.lock().map_or(0, |g| g.len())
187    }
188
189    /// True when there are no active watchers.
190    pub fn is_empty(&self) -> bool {
191        self.len() == 0
192    }
193
194    /// Render the current set of watchers as a JSON array for the `status` tool.
195    pub fn to_json(&self) -> Value {
196        let Ok(guard) = self.watchers.lock() else {
197            return Value::Array(Vec::new());
198        };
199        let now = SystemTime::now();
200        let items: Vec<Value> = guard
201            .values()
202            .map(|h| {
203                let stats = h.stats.lock().map(|s| s.snapshot()).unwrap_or_default();
204                let in_flight = h.in_flight.load(Ordering::Relaxed);
205                let last_event_ms_ago = stats
206                    .last_event_at
207                    .and_then(|t| now.duration_since(t).ok())
208                    // `Duration::as_millis` is `u128`; saturate to
209                    // `u64::MAX` on the absurd-long-duration edge
210                    // instead of silently wrapping (AGENTS.md §9).
211                    .map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX));
212                json!({
213                    "directory": h.directory.to_string_lossy(),
214                    "table": h.table,
215                    "files_ingested": stats.files_ingested,
216                    "files_failed": stats.files_failed,
217                    "last_event_ms_ago": last_event_ms_ago,
218                    "last_error": stats.last_error,
219                    "max_concurrent": stats.max_concurrent,
220                    "in_flight": in_flight,
221                })
222            })
223            .collect();
224        Value::Array(items)
225    }
226}
227
228impl Default for WatcherRegistry {
229    fn default() -> Self {
230        Self::new()
231    }
232}
233
234/// RAII guard that increments `in_flight` on construction and decrements on
235/// drop. Used to keep the live-load counter consistent even if an ingest
236/// task panics or early-returns.
237struct InFlightGuard {
238    counter: Arc<AtomicU32>,
239}
240
241impl InFlightGuard {
242    fn new(counter: Arc<AtomicU32>) -> Self {
243        counter.fetch_add(1, Ordering::Relaxed);
244        Self { counter }
245    }
246}
247
248impl Drop for InFlightGuard {
249    fn drop(&mut self) {
250        self.counter.fetch_sub(1, Ordering::Relaxed);
251    }
252}
253
254#[expect(
255    clippy::needless_pass_by_value,
256    reason = "call-site ergonomics: function consumes logically-owned parameters, refactoring signatures is not worth per-site churn"
257)]
258/// Begin watching `dir`. Builds a dedicated connection pool, runs the
259/// initial sweep (sequentially — there's no benefit to parallelism for
260/// startup since the pool isn't under load yet), then installs a
261/// [`notify`] watcher that streams events to an async tokio task.
262/// Returns a snapshot of the initial-sweep stats.
263///
264/// The engine `Arc` must point to an already-initialized engine (the caller
265/// in `server.rs` eagerly calls `ensure_engine` before invoking this).
266///
267/// # Errors
268///
269/// - Returns [`ErrorCode::FileNotFound`] if `dir` does not exist, is
270///   not a directory, or cannot be canonicalized.
271/// - Returns [`ErrorCode::InternalError`] if the watcher registry
272///   mutex or engine mutex is poisoned, if the engine has not been
273///   initialized, if `start_watching` is not called from a Tokio
274///   runtime, or if the watcher pool / OS file-system watcher cannot
275///   be constructed.
276/// - Returns [`ErrorCode::InternalError`] wrapping the error string
277///   when [`notify::RecommendedWatcher`] setup fails.
278/// - Propagates any error from the initial sweep's per-file ingest
279///   (file read, schema inference, or Hyper `COPY` / `INSERT` errors).
280pub fn start_watching(
281    engine: Arc<Mutex<Option<Engine>>>,
282    registry: Arc<WatcherRegistry>,
283    subscriptions: Option<Arc<SubscriptionRegistry>>,
284    dir: PathBuf,
285    table: String,
286    options: WatchOptions,
287) -> Result<WatcherStats, McpError> {
288    if !dir.exists() {
289        return Err(McpError::new(
290            ErrorCode::FileNotFound,
291            format!("Directory does not exist: {}", dir.display()),
292        ));
293    }
294    if !dir.is_dir() {
295        return Err(McpError::new(
296            ErrorCode::FileNotFound,
297            format!("Not a directory: {}", dir.display()),
298        ));
299    }
300    let canonical = dir.canonicalize().map_err(|e| {
301        McpError::new(
302            ErrorCode::FileNotFound,
303            format!("Cannot canonicalize {}: {e}", dir.display()),
304        )
305    })?;
306
307    {
308        let watchers = registry.watchers.lock().map_err(|_| {
309            McpError::new(ErrorCode::InternalError, "Watcher registry lock poisoned")
310        })?;
311        if watchers.contains_key(&canonical) {
312            return Err(McpError::new(
313                ErrorCode::InternalError,
314                format!("Already watching {}", canonical.display()),
315            )
316            .with_suggestion(
317                "Call unwatch_directory first to re-register with different options",
318            ));
319        }
320    }
321
322    let concurrency = options.resolved_concurrency();
323
324    // Build the per-watcher pool. We pull the endpoint and workspace path
325    // from the engine under a brief lock, then release it — the pool
326    // itself operates independently of the sync connection the engine
327    // still owns.
328    let pool = {
329        let guard = engine
330            .lock()
331            .map_err(|_| McpError::new(ErrorCode::InternalError, "Engine lock poisoned"))?;
332        let eng = guard.as_ref().ok_or_else(|| {
333            McpError::new(
334                ErrorCode::InternalError,
335                "Engine not initialized when watcher started",
336            )
337        })?;
338        let endpoint = eng.hyperd_endpoint()?;
339        let workspace = eng.workspace_path().to_string_lossy().to_string();
340        let cfg = PoolConfig::new(endpoint, workspace)
341            .create_mode(CreateMode::DoNotCreate)
342            .max_size(concurrency);
343        Arc::new(create_pool(cfg).map_err(|e| {
344            McpError::new(
345                ErrorCode::InternalError,
346                format!("Failed to build watcher pool: {e}"),
347            )
348        })?)
349    };
350
351    let stats = Arc::new(Mutex::new(WatcherStats {
352        // Concurrency is configured by the user via a `u32`-sized field
353        // upstream; saturating is a safe diagnostic.
354        max_concurrent: u32::try_from(concurrency).unwrap_or(u32::MAX),
355        ..Default::default()
356    }));
357    let in_flight = Arc::new(AtomicU32::new(0));
358    // Set of `.ready` paths with an in-flight ingest task. Used to dedupe
359    // duplicate filesystem events (macOS FSEvents in particular delivers
360    // both Create and Modify events for a single `write` syscall, and
361    // both would otherwise spawn independent ingest tasks — the per-task
362    // `.exists()` check is a TOCTOU race, not a real idempotence guard).
363    let in_flight_paths: Arc<Mutex<HashSet<PathBuf>>> = Arc::new(Mutex::new(HashSet::new()));
364
365    // Initial sweep: process anything already in the directory before
366    // wiring up events. Done sequentially on a single pooled connection
367    // — fine, because the pool isn't under load yet and this keeps the
368    // return-value shape simple (caller blocks on sweep completion).
369    //
370    // We run the sweep synchronously (the caller — an rmcp tool handler —
371    // is a sync `fn` running on a multi-thread tokio runtime). A plain
372    // `Handle::block_on` would panic with "Cannot start a runtime from
373    // within a runtime"; `block_in_place` tells tokio to move this
374    // worker thread off the task pool for the duration of the blocking
375    // call, then resume. Requires the multi-thread flavor — which the
376    // MCP binary uses via `#[tokio::main]` and which tests must opt
377    // into with `#[tokio::test(flavor = "multi_thread")]`.
378    let initial = {
379        let rt = tokio::runtime::Handle::try_current().map_err(|_| {
380            McpError::new(
381                ErrorCode::InternalError,
382                "start_watching must be called from inside a tokio runtime",
383            )
384        })?;
385        tokio::task::block_in_place(|| {
386            rt.block_on(async {
387                for ready_path in scan_ready_files(&canonical) {
388                    process_ready_async(
389                        &pool,
390                        subscriptions.as_deref(),
391                        &canonical,
392                        &table,
393                        &ready_path,
394                        &stats,
395                    )
396                    .await;
397                }
398                stats.lock().map(|s| s.snapshot()).unwrap_or_default()
399            })
400        })
401    };
402
403    // notify uses its own std channel; bridge it into a tokio mpsc via a
404    // small forwarder thread so the async consumer can `.recv().await`.
405    let (std_tx, std_rx) = std::sync::mpsc::channel::<notify::Result<Event>>();
406    let (async_tx, mut async_rx) = mpsc::unbounded_channel::<notify::Result<Event>>();
407
408    let mut watcher = notify::recommended_watcher(move |res: notify::Result<Event>| {
409        let _ = std_tx.send(res);
410    })
411    .map_err(|e| {
412        McpError::new(
413            ErrorCode::InternalError,
414            format!("Failed to create watcher: {e}"),
415        )
416    })?;
417    watcher
418        .watch(&canonical, RecursiveMode::NonRecursive)
419        .map_err(|e| {
420            McpError::new(
421                ErrorCode::InternalError,
422                format!("Failed to watch directory: {e}"),
423            )
424        })?;
425
426    // Forwarder thread: std sync -> tokio mpsc. Exits when the notify
427    // watcher is dropped (the std sender goes away, `recv()` returns Err).
428    let forwarder = {
429        let async_tx = async_tx.clone();
430        std::thread::Builder::new()
431            .name(format!("hyperdb-mcp-watch-fwd-{}", canonical.display()))
432            .spawn(move || {
433                while let Ok(ev) = std_rx.recv() {
434                    if async_tx.send(ev).is_err() {
435                        break;
436                    }
437                }
438            })
439            .map_err(|e| {
440                McpError::new(
441                    ErrorCode::InternalError,
442                    format!("Failed to spawn forwarder thread: {e}"),
443                )
444            })?
445    };
446    // Drop our local async sender so the consumer can actually reach EOF
447    // once the forwarder thread exits (the forwarder keeps its own clone).
448    drop(async_tx);
449
450    // Consumer task: one per-ready-file ingest spawned as its own task,
451    // bounded naturally by `pool.get().await` (the pool caps at
452    // `concurrency`). We use `tokio::spawn` rather than `JoinSet` because
453    // we don't need to await every task — they're fire-and-forget from
454    // the consumer's point of view, with stats updated via shared Mutex.
455    let task = {
456        let pool = Arc::clone(&pool);
457        let subs = subscriptions.clone();
458        let stats = Arc::clone(&stats);
459        let in_flight = Arc::clone(&in_flight);
460        let in_flight_paths = Arc::clone(&in_flight_paths);
461        let dir = canonical.clone();
462        let table = table.clone();
463        tokio::spawn(async move {
464            while let Some(event_res) = async_rx.recv().await {
465                let Ok(event) = event_res else { continue };
466                if !matches!(event.kind, EventKind::Create(_) | EventKind::Modify(_)) {
467                    continue;
468                }
469                for path in event.paths {
470                    if !is_ready_file(&path) {
471                        continue;
472                    }
473                    // Dedupe: if a task for this `.ready` path is already
474                    // running, drop this event. A lost Modify-after-Create
475                    // here is harmless — by the time the in-flight task
476                    // reaches its own `.exists()` check the data file is
477                    // either still there (gets ingested) or already moved
478                    // to `failed/` (correctly skipped).
479                    let claimed = in_flight_paths
480                        .lock()
481                        .is_ok_and(|mut set| set.insert(path.clone()));
482                    if !claimed {
483                        continue;
484                    }
485                    let pool = Arc::clone(&pool);
486                    let subs = subs.clone();
487                    let stats = Arc::clone(&stats);
488                    let in_flight = Arc::clone(&in_flight);
489                    let in_flight_paths = Arc::clone(&in_flight_paths);
490                    let dir = dir.clone();
491                    let table = table.clone();
492                    tokio::spawn(async move {
493                        let _guard = InFlightGuard::new(in_flight);
494                        process_ready_async(&pool, subs.as_deref(), &dir, &table, &path, &stats)
495                            .await;
496                        if let Ok(mut set) = in_flight_paths.lock() {
497                            set.remove(&path);
498                        }
499                    });
500                }
501            }
502        })
503    };
504
505    let handle = WatcherHandle {
506        directory: canonical.clone(),
507        table,
508        stats,
509        in_flight,
510        watcher: Some(watcher),
511        task: Some(task),
512        forwarder: Some(forwarder),
513        _pool: pool,
514    };
515    {
516        let mut watchers = registry.watchers.lock().map_err(|_| {
517            McpError::new(ErrorCode::InternalError, "Watcher registry lock poisoned")
518        })?;
519        watchers.insert(canonical, handle);
520    }
521    Ok(initial)
522}
523
524/// Stop watching a directory. Returns a JSON summary including final stats.
525///
526/// # Errors
527///
528/// - Returns [`ErrorCode::InternalError`] if the watcher registry
529///   mutex is poisoned.
530/// - Returns [`ErrorCode::FileNotFound`] if no active watcher is
531///   registered for the canonicalized `dir`.
532pub fn stop_watching(registry: &WatcherRegistry, dir: &Path) -> Result<Value, McpError> {
533    let canonical = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
534
535    let handle_opt = {
536        let mut watchers = registry.watchers.lock().map_err(|_| {
537            McpError::new(ErrorCode::InternalError, "Watcher registry lock poisoned")
538        })?;
539        watchers.remove(&canonical)
540    };
541
542    match handle_opt {
543        Some(handle) => {
544            let stats = handle
545                .stats
546                .lock()
547                .map(|s| s.snapshot())
548                .unwrap_or_default();
549            let directory = handle.directory.clone();
550            let table = handle.table.clone();
551            drop(handle); // triggers the Drop impl: stops watcher + joins thread
552            Ok(json!({
553                "directory": directory.to_string_lossy(),
554                "table": table,
555                "status": "stopped",
556                "files_ingested": stats.files_ingested,
557                "files_failed": stats.files_failed,
558                "last_error": stats.last_error,
559            }))
560        }
561        None => Err(McpError::new(
562            ErrorCode::FileNotFound,
563            format!("No active watcher for {}", canonical.display()),
564        )
565        .with_suggestion("Check status tool output for currently watched directories")),
566    }
567}
568
569/// Scan `dir` (non-recursively) for files whose name ends with `.ready`.
570fn scan_ready_files(dir: &Path) -> Vec<PathBuf> {
571    let mut out = Vec::new();
572    let Ok(entries) = std::fs::read_dir(dir) else {
573        return out;
574    };
575    for entry in entries.flatten() {
576        let path = entry.path();
577        if path.is_file() && is_ready_file(&path) {
578            out.push(path);
579        }
580    }
581    out
582}
583
584/// True if the path ends with the `.ready` sentinel suffix.
585fn is_ready_file(path: &Path) -> bool {
586    path.file_name()
587        .and_then(|s| s.to_str())
588        .is_some_and(|s| s.ends_with(READY_SUFFIX))
589}
590
591/// Given a `.ready` sentinel path, return the paired data file path.
592/// Returns `None` if the path doesn't end in `.ready`.
593fn strip_ready_suffix(ready_path: &Path) -> Option<PathBuf> {
594    let name = ready_path.file_name()?.to_str()?;
595    let stripped = name.strip_suffix(READY_SUFFIX)?;
596    Some(ready_path.with_file_name(stripped))
597}
598
599/// Ingest the data file paired with a `.ready` sentinel on a pooled
600/// async connection. On success, both files are deleted. On failure,
601/// both are moved to `<dir>/failed/` and a `<name>.error` JSON file is
602/// written alongside.
603///
604/// The connection is checked out of the pool for the duration of the
605/// ingest (including the `BEGIN / COMMIT`) and released on scope exit
606/// via the `PooledConnection` Drop. Other ingest tasks run in parallel
607/// on their own connections, up to the pool's `max_size`.
608async fn process_ready_async(
609    pool: &Arc<Pool>,
610    subscriptions: Option<&SubscriptionRegistry>,
611    dir: &Path,
612    table: &str,
613    ready_path: &Path,
614    stats: &Arc<Mutex<WatcherStats>>,
615) {
616    let Some(data_path) = strip_ready_suffix(ready_path) else {
617        return;
618    };
619    // Idempotence: either file may already be gone (we processed it on a
620    // previous event); skip silently.
621    if !ready_path.exists() || !data_path.exists() {
622        return;
623    }
624    // Reject symlinks for both the sentinel and the data file. A symlink swap
625    // between this check and the open could redirect ingest to read sensitive
626    // files (e.g. /etc/passwd) into the table. Producers writing the sentinel
627    // and data file as regular files (the documented protocol) are unaffected.
628    let is_symlink = |p: &std::path::Path| {
629        p.symlink_metadata()
630            .is_ok_and(|m| m.file_type().is_symlink())
631    };
632    if is_symlink(ready_path) || is_symlink(&data_path) {
633        tracing::warn!(
634            ready = %ready_path.display(),
635            data = %data_path.display(),
636            "Refusing to ingest: sentinel or data file is a symlink"
637        );
638        return;
639    }
640
641    let result = async {
642        let conn = pool.get().await.map_err(|e| {
643            McpError::new(
644                ErrorCode::InternalError,
645                format!("Failed to check out connection: {e}"),
646            )
647        })?;
648
649        let opts = IngestOptions {
650            table: table.to_string(),
651            mode: "append".into(),
652            schema_override: None,
653            merge_key: None,
654        };
655        let data_str = data_path
656            .to_str()
657            .ok_or_else(|| McpError::new(ErrorCode::InternalError, "Non-UTF-8 path"))?;
658        let res = match detect_file_format(&data_path) {
659            InferredFileFormat::Parquet => ingest_parquet_file_async(&conn, data_str, &opts).await,
660            InferredFileFormat::ArrowIpc => {
661                ingest_arrow_ipc_file_async(&conn, data_str, &opts).await
662            }
663            InferredFileFormat::Json => ingest_json_file_async(&conn, data_str, &opts).await,
664            InferredFileFormat::Csv => ingest_csv_file_async(&conn, data_str, &opts).await,
665        }?;
666        Ok::<u64, McpError>(res.rows)
667    }
668    .await;
669
670    match result {
671        Ok(rows) => {
672            let _ = std::fs::remove_file(ready_path);
673            let _ = std::fs::remove_file(&data_path);
674            if let Ok(mut s) = stats.lock() {
675                s.files_ingested += 1;
676                s.last_event_at = Some(SystemTime::now());
677                s.last_error = None;
678            }
679            tracing::info!(
680                "watcher: ingested {rows} rows from {} into {}",
681                data_path.display(),
682                table
683            );
684            if let Some(subs) = subscriptions {
685                for uri in uris_for_table_change(table) {
686                    subs.notify_updated(&uri);
687                }
688            }
689        }
690        Err(err) => {
691            let fail_dir = dir.join("failed");
692            let _ = std::fs::create_dir_all(&fail_dir);
693            if let Some(name) = data_path.file_name() {
694                let _ = std::fs::rename(&data_path, fail_dir.join(name));
695                let err_file = fail_dir.join(format!("{}.error", name.to_string_lossy()));
696                let err_json = serde_json::to_string_pretty(&json!({
697                    "code": format!("{:?}", err.code),
698                    "message": err.message,
699                    "suggestion": err.suggestion,
700                }))
701                .unwrap_or_default();
702                let _ = std::fs::write(err_file, err_json);
703            }
704            if let Some(name) = ready_path.file_name() {
705                let _ = std::fs::rename(ready_path, fail_dir.join(name));
706            }
707            if let Ok(mut s) = stats.lock() {
708                s.files_failed += 1;
709                s.last_event_at = Some(SystemTime::now());
710                s.last_error = Some(err.to_string());
711            }
712            tracing::warn!(
713                "watcher: ingest failed for {}: {}",
714                data_path.display(),
715                err
716            );
717        }
718    }
719}
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724
725    #[test]
726    fn is_ready_file_checks_suffix() {
727        assert!(is_ready_file(Path::new("/tmp/foo.csv.ready")));
728        assert!(is_ready_file(Path::new("/tmp/bar.ready")));
729        assert!(!is_ready_file(Path::new("/tmp/foo.csv")));
730        assert!(!is_ready_file(Path::new("/tmp/foo.ready.txt")));
731    }
732
733    #[test]
734    fn strip_ready_gives_data_path() {
735        assert_eq!(
736            strip_ready_suffix(Path::new("/tmp/foo.csv.ready")).unwrap(),
737            Path::new("/tmp/foo.csv")
738        );
739        assert!(strip_ready_suffix(Path::new("/tmp/foo.csv")).is_none());
740    }
741
742    #[test]
743    fn resolved_concurrency_clamps() {
744        assert_eq!(
745            WatchOptions { max_concurrent: 0 }.resolved_concurrency(),
746            DEFAULT_MAX_CONCURRENT
747        );
748        assert_eq!(WatchOptions { max_concurrent: 1 }.resolved_concurrency(), 1);
749        assert_eq!(
750            WatchOptions {
751                max_concurrent: 1000
752            }
753            .resolved_concurrency(),
754            MAX_CONCURRENT_LIMIT
755        );
756    }
757}