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}