use crate::attach::{AttachRegistry, AttachSource};
use crate::engine::Engine;
use crate::error::{ErrorCode, McpError};
use crate::ingest::{
detect_file_format, ingest_csv_file_async, ingest_json_file_async, InferredFileFormat,
IngestOptions,
};
use crate::ingest_arrow::{ingest_arrow_ipc_file_async, ingest_parquet_file_async};
use crate::subscriptions::{uris_for_table_change, SubscriptionRegistry};
use hyperdb_api::pool::{create_pool, Pool, PoolConfig};
use hyperdb_api::CreateMode;
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use serde_json::{json, Value};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
pub const READY_SUFFIX: &str = ".ready";
fn resolve_pool_workspace(
eng: &Engine,
attachments: Option<&AttachRegistry>,
target_db: Option<&str>,
) -> Result<String, McpError> {
let Some(alias) = target_db else {
return Ok(eng.ephemeral_path().to_string_lossy().to_string());
};
if alias.eq_ignore_ascii_case(Engine::PERSISTENT_ALIAS) {
let path = eng.persistent_path().ok_or_else(|| {
McpError::new(
ErrorCode::InvalidArgument,
"target 'persistent' but the server is in --ephemeral-only mode",
)
})?;
return Ok(path.to_string_lossy().to_string());
}
let registry = attachments.ok_or_else(|| {
McpError::new(
ErrorCode::InternalError,
"watcher pool requested for a user-attached alias but no AttachRegistry was supplied",
)
})?;
let entry = registry.get(alias).ok_or_else(|| {
McpError::new(
ErrorCode::InvalidArgument,
format!("database '{alias}' is not attached"),
)
})?;
if !entry.writable {
return Err(McpError::new(
ErrorCode::InvalidArgument,
format!(
"database '{alias}' was attached read-only. \
Re-attach with writable:true to use it as a watcher target."
),
));
}
let AttachSource::LocalFile { path } = &entry.source;
Ok(path.to_string_lossy().to_string())
}
fn build_watcher_pool(
engine: &Arc<Mutex<Option<Engine>>>,
attachments: Option<&AttachRegistry>,
target_db: Option<&str>,
concurrency: usize,
) -> Result<Arc<Pool>, McpError> {
let guard = engine
.lock()
.map_err(|_| McpError::new(ErrorCode::InternalError, "Engine lock poisoned"))?;
let eng = guard.as_ref().ok_or_else(|| {
McpError::new(
ErrorCode::InternalError,
"Engine not initialized when watcher pool requested",
)
})?;
let endpoint = eng.hyperd_endpoint()?;
let workspace = resolve_pool_workspace(eng, attachments, target_db)?;
let cfg = PoolConfig::new(endpoint, workspace)
.create_mode(CreateMode::DoNotCreate)
.max_size(concurrency);
Ok(Arc::new(create_pool(cfg).map_err(|e| {
McpError::new(
ErrorCode::InternalError,
format!("Failed to build watcher pool: {e}"),
)
})?))
}
async fn rebuild_watcher_pool(
pool_slot: &tokio::sync::RwLock<Arc<Pool>>,
engine: &Arc<Mutex<Option<Engine>>>,
attachments: Option<&AttachRegistry>,
target_db: Option<&str>,
concurrency: usize,
) -> Result<(), McpError> {
let new_pool = build_watcher_pool(engine, attachments, target_db, concurrency)?;
let mut guard = pool_slot.write().await;
*guard = new_pool;
Ok(())
}
pub const DEFAULT_MAX_CONCURRENT: usize = 4;
pub const MAX_CONCURRENT_LIMIT: usize = 32;
#[derive(Debug, Clone, Default)]
pub struct WatchOptions {
pub max_concurrent: usize,
}
impl WatchOptions {
fn resolved_concurrency(&self) -> usize {
let n = if self.max_concurrent == 0 {
DEFAULT_MAX_CONCURRENT
} else {
self.max_concurrent
};
n.clamp(1, MAX_CONCURRENT_LIMIT)
}
}
#[derive(Debug, Default, Clone)]
pub struct WatcherStats {
pub files_ingested: u64,
pub files_failed: u64,
pub last_event_at: Option<SystemTime>,
pub last_error: Option<String>,
pub max_concurrent: u32,
pub in_flight: u32,
}
impl WatcherStats {
fn snapshot(&self) -> Self {
self.clone()
}
}
#[derive(Debug)]
pub struct WatcherHandle {
pub directory: PathBuf,
pub table: String,
pub target_db: Option<String>,
pub stats: Arc<Mutex<WatcherStats>>,
in_flight: Arc<AtomicU32>,
watcher: Option<RecommendedWatcher>,
task: Option<JoinHandle<()>>,
forwarder: Option<std::thread::JoinHandle<()>>,
_pool: Arc<tokio::sync::RwLock<Arc<Pool>>>,
}
impl Drop for WatcherHandle {
fn drop(&mut self) {
self.watcher.take();
if let Some(t) = self.forwarder.take() {
let _ = t.join();
}
if let Some(task) = self.task.take() {
task.abort();
}
}
}
#[derive(Debug)]
pub struct WatcherRegistry {
pub(crate) watchers: Mutex<HashMap<PathBuf, WatcherHandle>>,
}
impl WatcherRegistry {
#[must_use]
pub fn new() -> Self {
Self {
watchers: Mutex::new(HashMap::new()),
}
}
pub fn len(&self) -> usize {
self.watchers.lock().map_or(0, |g| g.len())
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn to_json(&self) -> Value {
let Ok(guard) = self.watchers.lock() else {
return Value::Array(Vec::new());
};
let now = SystemTime::now();
let items: Vec<Value> = guard
.values()
.map(|h| {
let stats = h.stats.lock().map(|s| s.snapshot()).unwrap_or_default();
let in_flight = h.in_flight.load(Ordering::Relaxed);
let last_event_ms_ago = stats
.last_event_at
.and_then(|t| now.duration_since(t).ok())
.map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX));
json!({
"directory": h.directory.to_string_lossy(),
"table": h.table,
"target_db": h.target_db.clone().unwrap_or_else(|| "local".into()),
"files_ingested": stats.files_ingested,
"files_failed": stats.files_failed,
"last_event_ms_ago": last_event_ms_ago,
"last_error": stats.last_error,
"max_concurrent": stats.max_concurrent,
"in_flight": in_flight,
})
})
.collect();
Value::Array(items)
}
}
impl Default for WatcherRegistry {
fn default() -> Self {
Self::new()
}
}
struct InFlightGuard {
counter: Arc<AtomicU32>,
}
impl InFlightGuard {
fn new(counter: Arc<AtomicU32>) -> Self {
counter.fetch_add(1, Ordering::Relaxed);
Self { counter }
}
}
impl Drop for InFlightGuard {
fn drop(&mut self) {
self.counter.fetch_sub(1, Ordering::Relaxed);
}
}
#[expect(
clippy::needless_pass_by_value,
reason = "call-site ergonomics: function consumes logically-owned parameters, refactoring signatures is not worth per-site churn"
)]
pub fn start_watching(
engine: Arc<Mutex<Option<Engine>>>,
attachments: Arc<AttachRegistry>,
registry: Arc<WatcherRegistry>,
subscriptions: Option<Arc<SubscriptionRegistry>>,
dir: PathBuf,
table: String,
target_db: Option<String>,
options: WatchOptions,
) -> Result<WatcherStats, McpError> {
if !dir.exists() {
return Err(McpError::new(
ErrorCode::FileNotFound,
format!("Directory does not exist: {}", dir.display()),
));
}
if !dir.is_dir() {
return Err(McpError::new(
ErrorCode::FileNotFound,
format!("Not a directory: {}", dir.display()),
));
}
let canonical = dir.canonicalize().map_err(|e| {
McpError::new(
ErrorCode::FileNotFound,
format!("Cannot canonicalize {}: {e}", dir.display()),
)
})?;
{
let watchers = registry.watchers.lock().map_err(|_| {
McpError::new(ErrorCode::InternalError, "Watcher registry lock poisoned")
})?;
if watchers.contains_key(&canonical) {
return Err(McpError::new(
ErrorCode::InternalError,
format!("Already watching {}", canonical.display()),
)
.with_suggestion(
"Call unwatch_directory first to re-register with different options",
));
}
}
let concurrency = options.resolved_concurrency();
let pool = Arc::new(tokio::sync::RwLock::new(build_watcher_pool(
&engine,
Some(attachments.as_ref()),
target_db.as_deref(),
concurrency,
)?));
let stats = Arc::new(Mutex::new(WatcherStats {
max_concurrent: u32::try_from(concurrency).unwrap_or(u32::MAX),
..Default::default()
}));
let in_flight = Arc::new(AtomicU32::new(0));
let in_flight_paths: Arc<Mutex<HashSet<PathBuf>>> = Arc::new(Mutex::new(HashSet::new()));
let initial = {
let rt = tokio::runtime::Handle::try_current().map_err(|_| {
McpError::new(
ErrorCode::InternalError,
"start_watching must be called from inside a tokio runtime",
)
})?;
tokio::task::block_in_place(|| {
rt.block_on(async {
for ready_path in scan_ready_files(&canonical) {
process_ready_with_recovery(
&pool,
&engine,
Some(attachments.as_ref()),
target_db.as_deref(),
concurrency,
subscriptions.as_deref(),
&canonical,
&table,
&ready_path,
&stats,
)
.await;
}
stats.lock().map(|s| s.snapshot()).unwrap_or_default()
})
})
};
let (std_tx, std_rx) = std::sync::mpsc::channel::<notify::Result<Event>>();
let (async_tx, mut async_rx) = mpsc::unbounded_channel::<notify::Result<Event>>();
let mut watcher = notify::recommended_watcher(move |res: notify::Result<Event>| {
let _ = std_tx.send(res);
})
.map_err(|e| {
McpError::new(
ErrorCode::InternalError,
format!("Failed to create watcher: {e}"),
)
})?;
watcher
.watch(&canonical, RecursiveMode::NonRecursive)
.map_err(|e| {
McpError::new(
ErrorCode::InternalError,
format!("Failed to watch directory: {e}"),
)
})?;
let forwarder = {
let async_tx = async_tx.clone();
std::thread::Builder::new()
.name(format!("hyperdb-mcp-watch-fwd-{}", canonical.display()))
.spawn(move || {
while let Ok(ev) = std_rx.recv() {
if async_tx.send(ev).is_err() {
break;
}
}
})
.map_err(|e| {
McpError::new(
ErrorCode::InternalError,
format!("Failed to spawn forwarder thread: {e}"),
)
})?
};
drop(async_tx);
let task = {
let pool = Arc::clone(&pool);
let engine_for_pool = Arc::clone(&engine);
let attachments_for_pool = Arc::clone(&attachments);
let subs = subscriptions.clone();
let stats = Arc::clone(&stats);
let in_flight = Arc::clone(&in_flight);
let in_flight_paths = Arc::clone(&in_flight_paths);
let dir = canonical.clone();
let table = table.clone();
let task_target_db = target_db.clone();
tokio::spawn(async move {
while let Some(event_res) = async_rx.recv().await {
let Ok(event) = event_res else { continue };
if !matches!(event.kind, EventKind::Create(_) | EventKind::Modify(_)) {
continue;
}
for path in event.paths {
if !is_ready_file(&path) {
continue;
}
let claimed = in_flight_paths
.lock()
.is_ok_and(|mut set| set.insert(path.clone()));
if !claimed {
continue;
}
let pool_slot = Arc::clone(&pool);
let engine_handle = Arc::clone(&engine_for_pool);
let attachments_handle = Arc::clone(&attachments_for_pool);
let target_db_clone = task_target_db.clone();
let subs = subs.clone();
let stats = Arc::clone(&stats);
let in_flight = Arc::clone(&in_flight);
let in_flight_paths = Arc::clone(&in_flight_paths);
let dir = dir.clone();
let table = table.clone();
tokio::spawn(async move {
let _guard = InFlightGuard::new(in_flight);
process_ready_with_recovery(
&pool_slot,
&engine_handle,
Some(attachments_handle.as_ref()),
target_db_clone.as_deref(),
concurrency,
subs.as_deref(),
&dir,
&table,
&path,
&stats,
)
.await;
if let Ok(mut set) = in_flight_paths.lock() {
set.remove(&path);
}
});
}
}
})
};
let handle = WatcherHandle {
directory: canonical.clone(),
table,
target_db,
stats,
in_flight,
watcher: Some(watcher),
task: Some(task),
forwarder: Some(forwarder),
_pool: pool,
};
{
let mut watchers = registry.watchers.lock().map_err(|_| {
McpError::new(ErrorCode::InternalError, "Watcher registry lock poisoned")
})?;
watchers.insert(canonical, handle);
}
Ok(initial)
}
pub fn stop_watching(registry: &WatcherRegistry, dir: &Path) -> Result<Value, McpError> {
let canonical = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
let handle_opt = {
let mut watchers = registry.watchers.lock().map_err(|_| {
McpError::new(ErrorCode::InternalError, "Watcher registry lock poisoned")
})?;
watchers.remove(&canonical)
};
match handle_opt {
Some(handle) => {
let stats = handle
.stats
.lock()
.map(|s| s.snapshot())
.unwrap_or_default();
let directory = handle.directory.clone();
let table = handle.table.clone();
drop(handle); Ok(json!({
"directory": directory.to_string_lossy(),
"table": table,
"status": "stopped",
"files_ingested": stats.files_ingested,
"files_failed": stats.files_failed,
"last_error": stats.last_error,
}))
}
None => Err(McpError::new(
ErrorCode::FileNotFound,
format!("No active watcher for {}", canonical.display()),
)
.with_suggestion("Check status tool output for currently watched directories")),
}
}
fn scan_ready_files(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return out;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && is_ready_file(&path) {
out.push(path);
}
}
out
}
fn is_ready_file(path: &Path) -> bool {
path.file_name()
.and_then(|s| s.to_str())
.is_some_and(|s| s.ends_with(READY_SUFFIX))
}
fn strip_ready_suffix(ready_path: &Path) -> Option<PathBuf> {
let name = ready_path.file_name()?.to_str()?;
let stripped = name.strip_suffix(READY_SUFFIX)?;
Some(ready_path.with_file_name(stripped))
}
async fn ingest_one_ready_file(
pool: &Arc<Pool>,
table: &str,
ready_path: &Path,
data_path: &Path,
) -> Result<u64, McpError> {
let conn = pool.get().await.map_err(|e| {
McpError::new(
ErrorCode::InternalError,
format!("Failed to check out connection: {e}"),
)
})?;
let opts = IngestOptions {
table: table.to_string(),
mode: "append".into(),
schema_override: None,
merge_key: None,
target_db: None,
};
let data_str = data_path
.to_str()
.ok_or_else(|| McpError::new(ErrorCode::InternalError, "Non-UTF-8 path"))?;
let res = match detect_file_format(data_path) {
InferredFileFormat::Parquet => ingest_parquet_file_async(&conn, data_str, &opts).await,
InferredFileFormat::ArrowIpc => ingest_arrow_ipc_file_async(&conn, data_str, &opts).await,
InferredFileFormat::Json => ingest_json_file_async(&conn, data_str, &opts).await,
InferredFileFormat::Csv => ingest_csv_file_async(&conn, data_str, &opts).await,
}?;
let _ = ready_path; Ok(res.rows)
}
async fn process_ready_with_recovery(
pool_slot: &tokio::sync::RwLock<Arc<Pool>>,
engine: &Arc<Mutex<Option<Engine>>>,
attachments: Option<&AttachRegistry>,
target_db: Option<&str>,
concurrency: usize,
subscriptions: Option<&SubscriptionRegistry>,
dir: &Path,
table: &str,
ready_path: &Path,
stats: &Arc<Mutex<WatcherStats>>,
) {
let Some(data_path) = strip_ready_suffix(ready_path) else {
return;
};
if !ready_path.exists() || !data_path.exists() {
return;
}
let is_symlink = |p: &std::path::Path| {
p.symlink_metadata()
.is_ok_and(|m| m.file_type().is_symlink())
};
if is_symlink(ready_path) || is_symlink(&data_path) {
tracing::warn!(
ready = %ready_path.display(),
data = %data_path.display(),
"Refusing to ingest: sentinel or data file is a symlink"
);
return;
}
let active_pool = pool_slot.read().await.clone();
let mut result = ingest_one_ready_file(&active_pool, table, ready_path, &data_path).await;
drop(active_pool);
if let Err(ref err) = result {
if crate::error::is_connection_lost(&err.message) {
tracing::warn!(
err = %err.message,
"watcher: detected connection-lost error, rebuilding pool and retrying"
);
match rebuild_watcher_pool(pool_slot, engine, attachments, target_db, concurrency).await
{
Ok(()) => {
let active_pool = pool_slot.read().await.clone();
result =
ingest_one_ready_file(&active_pool, table, ready_path, &data_path).await;
}
Err(e) => {
tracing::warn!(
err = %e.message,
"watcher: pool rebuild failed; the original ingest error will surface"
);
}
}
}
}
match result {
Ok(rows) => {
let _ = std::fs::remove_file(ready_path);
let _ = std::fs::remove_file(&data_path);
if let Ok(mut s) = stats.lock() {
s.files_ingested += 1;
s.last_event_at = Some(SystemTime::now());
s.last_error = None;
}
tracing::info!(
"watcher: ingested {rows} rows from {} into {}",
data_path.display(),
table
);
let row_count_i64 = i64::try_from(rows).unwrap_or(i64::MAX);
let load_params = serde_json::to_string(&json!({
"watch_directory": dir.to_string_lossy(),
"database": target_db.unwrap_or("local"),
}))
.ok();
if let Ok(guard) = engine.lock() {
if let Some(eng) = guard.as_ref() {
if let Err(e) = crate::table_catalog::upsert_stub_in(
eng,
table,
"watch_directory",
load_params.as_deref(),
Some(row_count_i64),
true,
target_db,
None,
) {
tracing::warn!(
table = %table,
target_db = ?target_db,
err = %e.message,
"watcher: failed to update _table_catalog after ingest"
);
}
}
}
if let Some(subs) = subscriptions {
for uri in uris_for_table_change(table) {
subs.notify_updated(&uri);
}
}
}
Err(err) => {
let fail_dir = dir.join("failed");
let _ = std::fs::create_dir_all(&fail_dir);
if let Some(name) = data_path.file_name() {
let _ = std::fs::rename(&data_path, fail_dir.join(name));
let err_file = fail_dir.join(format!("{}.error", name.to_string_lossy()));
let err_json = serde_json::to_string_pretty(&json!({
"code": format!("{:?}", err.code),
"message": err.message,
"suggestion": err.suggestion,
}))
.unwrap_or_default();
let _ = std::fs::write(err_file, err_json);
}
if let Some(name) = ready_path.file_name() {
let _ = std::fs::rename(ready_path, fail_dir.join(name));
}
if let Ok(mut s) = stats.lock() {
s.files_failed += 1;
s.last_event_at = Some(SystemTime::now());
s.last_error = Some(err.to_string());
}
tracing::warn!(
"watcher: ingest failed for {}: {}",
data_path.display(),
err
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_ready_file_checks_suffix() {
assert!(is_ready_file(Path::new("/tmp/foo.csv.ready")));
assert!(is_ready_file(Path::new("/tmp/bar.ready")));
assert!(!is_ready_file(Path::new("/tmp/foo.csv")));
assert!(!is_ready_file(Path::new("/tmp/foo.ready.txt")));
}
#[test]
fn strip_ready_gives_data_path() {
assert_eq!(
strip_ready_suffix(Path::new("/tmp/foo.csv.ready")).unwrap(),
Path::new("/tmp/foo.csv")
);
assert!(strip_ready_suffix(Path::new("/tmp/foo.csv")).is_none());
}
#[test]
fn resolved_concurrency_clamps() {
assert_eq!(
WatchOptions { max_concurrent: 0 }.resolved_concurrency(),
DEFAULT_MAX_CONCURRENT
);
assert_eq!(WatchOptions { max_concurrent: 1 }.resolved_concurrency(), 1);
assert_eq!(
WatchOptions {
max_concurrent: 1000
}
.resolved_concurrency(),
MAX_CONCURRENT_LIMIT
);
}
}