use std::sync::Arc;
use notedthat_core::KbSlug;
use notedthat_indexer::{IndexEvent, VectorStore};
use notedthat_storage_fs::{
FsChange, FsConfig, FsSignal, FsStorage, FsWatchConfig, FsWatcher, IndexedEtag, reconcile,
};
use tokio::sync::mpsc;
use tracing::{error, info, warn};
const RECONCILE_BUFFER: usize = 256;
pub(super) struct FsWatch {
watcher: FsWatcher,
bridge: tokio::task::JoinHandle<()>,
}
impl FsWatch {
pub(super) async fn stop(self) {
self.watcher.stop().await;
let _ = self.bridge.await;
}
}
pub(super) fn start(
config: &FsConfig,
tenant: notedthat_core::TenantSlug,
kbs: Vec<KbSlug>,
store: Arc<dyn VectorStore>,
indexer_tx: mpsc::Sender<IndexEvent>,
) -> anyhow::Result<Option<FsWatch>> {
if !config.watch {
info!(
"filesystem watching is off; only writes through NotedThat will update the \
search index"
);
return Ok(None);
}
let storage = FsStorage::new(config, config.root.clone(), tenant);
let (signals_tx, signals_rx) = mpsc::channel::<FsSignal>(1024);
let watcher =
notedthat_storage_fs::watch_kbs(&storage, &kbs, FsWatchConfig::from(config), signals_tx)?;
info!(
knowledgebases = kbs.len(),
debounce_ms = u64::try_from(config.watch_debounce.as_millis()).unwrap_or(u64::MAX),
"watching the storage tree for changes made outside NotedThat"
);
let bridge = tokio::spawn(run_bridge(storage, store, indexer_tx, signals_rx, kbs));
Ok(Some(FsWatch { watcher, bridge }))
}
async fn run_bridge(
storage: FsStorage,
store: Arc<dyn VectorStore>,
indexer_tx: mpsc::Sender<IndexEvent>,
mut signals: mpsc::Receiver<FsSignal>,
kbs: Vec<KbSlug>,
) {
for kb in &kbs {
reconcile_into(&storage, store.as_ref(), &indexer_tx, kb, None, "startup").await;
}
while let Some(signal) = signals.recv().await {
match signal {
FsSignal::Changed { kb, key } => {
if indexer_tx
.send(IndexEvent::Refresh {
kb,
object_key: key,
})
.await
.is_err()
{
return;
}
}
FsSignal::Prefix { kb, prefix } => {
reconcile_into(
&storage,
store.as_ref(),
&indexer_tx,
&kb,
Some(prefix.as_str()),
"subtree changed",
)
.await;
}
FsSignal::Kb { kb } => {
reconcile_into(&storage, store.as_ref(), &indexer_tx, &kb, None, "rescan").await;
}
}
}
}
async fn reconcile_into(
storage: &FsStorage,
store: &dyn VectorStore,
indexer_tx: &mpsc::Sender<IndexEvent>,
kb: &KbSlug,
prefix: Option<&str>,
cause: &str,
) {
let indexed = match store.indexed_objects(kb, prefix).await {
Ok(indexed) => indexed,
Err(notedthat_indexer::VectorStoreError::CollectionNotFound { .. }) => {
error!(
target: "notedthat::watch",
kb = %kb.as_str(),
prefix = prefix.unwrap_or(""),
"FS_WATCH_RESCAN: this knowledge base has no search collection, so every \
pass is skipped and nothing under it will be indexed. Check the \
provisioning warnings from startup and restart once Qdrant is reachable."
);
return;
}
Err(error) => {
error!(
target: "notedthat::watch",
kb = %kb.as_str(),
prefix = prefix.unwrap_or(""),
%error,
"FS_WATCH_RESCAN: could not read the index, so this pass was skipped"
);
return;
}
};
let indexed: Vec<IndexedEtag> = indexed
.into_iter()
.map(|object| IndexedEtag {
key: object.object_key,
etag: object.etag,
})
.collect();
let (changes_tx, mut changes_rx) = mpsc::channel::<FsChange>(RECONCILE_BUFFER);
let forwarder = {
let indexer_tx = indexer_tx.clone();
tokio::spawn(async move {
while let Some(FsChange { kb, key }) = changes_rx.recv().await {
if indexer_tx
.send(IndexEvent::Refresh {
kb,
object_key: key,
})
.await
.is_err()
{
return;
}
}
})
};
let report = reconcile(storage, kb, prefix, &indexed, &changes_tx).await;
drop(changes_tx);
let _ = forwarder.await;
match report {
Ok(report) if report.is_clean() => info!(
target: "notedthat::watch",
kb = %kb.as_str(),
prefix = prefix.unwrap_or(""),
cause,
objects = report.objects_on_disk,
"already in step with the index"
),
Ok(report) => info!(
target: "notedthat::watch",
kb = %kb.as_str(),
prefix = prefix.unwrap_or(""),
cause,
objects = report.objects_on_disk,
changed = report.changed,
orphaned = report.orphaned,
"enqueued objects whose index entries are out of date"
),
Err(error) => warn!(
target: "notedthat::watch",
kb = %kb.as_str(),
prefix = prefix.unwrap_or(""),
cause,
%error,
"FS_WATCH_RESCAN: could not read the tree, so this pass was incomplete"
),
}
}