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::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}