ANW-36 Watcher: drop index entries when note files are deleted

This commit is contained in:
Andreas Brenner 2026-07-24 20:58:31 +03:00
parent 23ce90e103
commit 77a504ddcb
4 changed files with 350 additions and 49 deletions

View file

@ -442,6 +442,11 @@ pub enum IndexWriterMessage {
pub struct IndexBatch { pub struct IndexBatch {
pub upserts: Vec<Note>, pub upserts: Vec<Note>,
pub deletes: Vec<String>, pub deletes: Vec<String>,
/// Directories that are gone: every indexed note under them drops. A
/// removed or renamed directory produces no per-file event, so the
/// prefix is the only handle on those notes [ANW-36].
#[serde(default)]
pub delete_prefixes: Vec<String>,
} }
#[derive(Clone)] #[derive(Clone)]
@ -498,8 +503,15 @@ impl GenServer for IndexWriterState {
} }
IndexWriterMessage::Batch(batch) => { IndexWriterMessage::Batch(batch) => {
let (u, d) = (batch.upserts.len(), batch.deletes.len()); let (u, d) = (batch.upserts.len(), batch.deletes.len());
self.store.apply_batch(batch.upserts, &batch.deletes); let dropped =
tracing::info!(upserts = u, deletes = d, "index_writer: batch applied"); self.store
.apply_batch(batch.upserts, &batch.deletes, &batch.delete_prefixes);
tracing::info!(
upserts = u,
deletes = d,
dropped,
"index_writer: batch applied"
);
} }
IndexWriterMessage::Upsert(note) => { IndexWriterMessage::Upsert(note) => {
let path = note.path.clone(); let path = note.path.clone();

View file

@ -40,19 +40,45 @@ impl NoteStore {
} }
} }
/// Apply one debounce-window's worth of changes. Mirrors the order in /// Apply one debounce-window's worth of changes: deletes first (paths,
/// [`crate::index::NoteIndex::apply_batch`]: deletes first, then upserts. /// then whole directories), then upserts. A "delete-then-upsert"
/// sequence on one path is therefore unambiguous, and a directory that
/// was removed and recreated inside one window keeps only what the walk
/// found.
///
/// Each entry of `delete_prefixes` is a vault-relative directory; every
/// note under it drops [ANW-36]. Returns the number of notes dropped
/// that way.
/// ///
/// # Panics /// # Panics
/// Panics if the inner `RwLock` has been poisoned. /// Panics if the inner `RwLock` has been poisoned.
pub fn apply_batch(&self, upserts: Vec<Note>, deletes: &[String]) { pub fn apply_batch(
&self,
upserts: Vec<Note>,
deletes: &[String],
delete_prefixes: &[String],
) -> usize {
let mut guard = self.inner.write().expect("note_store: write lock poisoned"); let mut guard = self.inner.write().expect("note_store: write lock poisoned");
for path in deletes { for path in deletes {
guard.remove(path); guard.remove(path);
} }
let mut dropped = 0;
for dir in delete_prefixes {
let prefix = format!("{}/", dir.trim_end_matches('/'));
let doomed: Vec<String> = guard
.range(prefix.clone()..)
.take_while(|(p, _)| p.starts_with(&prefix))
.map(|(p, _)| p.clone())
.collect();
dropped += doomed.len();
for path in doomed {
guard.remove(&path);
}
}
for note in upserts { for note in upserts {
guard.insert(note.path.clone(), note); guard.insert(note.path.clone(), note);
} }
dropped
} }
/// # Panics /// # Panics
@ -143,13 +169,42 @@ mod tests {
fn apply_batch_deletes_then_upserts() { fn apply_batch_deletes_then_upserts() {
let s = NoteStore::new(); let s = NoteStore::new();
s.replace(vec![note("a.md"), note("b.md")]); s.replace(vec![note("a.md"), note("b.md")]);
s.apply_batch(vec![note("c.md")], &["a.md".to_string()]); s.apply_batch(vec![note("c.md")], &["a.md".to_string()], &[]);
assert_eq!(s.len(), 2); assert_eq!(s.len(), 2);
assert!(s.get("a.md").is_none()); assert!(s.get("a.md").is_none());
assert!(s.get("b.md").is_some()); assert!(s.get("b.md").is_some());
assert!(s.get("c.md").is_some()); assert!(s.get("c.md").is_some());
} }
#[test]
fn apply_batch_prefix_drops_the_whole_subtree() {
let s = NoteStore::new();
s.replace(vec![
note("Notes/a.md"),
note("Notes/deep/b.md"),
note("Notesy/c.md"),
note("Other/d.md"),
]);
let dropped = s.apply_batch(vec![], &[], &["Notes".to_string()]);
assert_eq!(dropped, 2);
assert!(s.get("Notes/a.md").is_none());
assert!(s.get("Notes/deep/b.md").is_none());
// A sibling sharing the prefix as a string, but not as a directory.
assert!(s.get("Notesy/c.md").is_some());
assert!(s.get("Other/d.md").is_some());
}
#[test]
fn apply_batch_prefix_delete_precedes_upserts() {
let s = NoteStore::new();
s.replace(vec![note("Notes/gone.md")]);
// A directory removed and recreated inside one window: only what the
// walk found survives.
s.apply_batch(vec![note("Notes/fresh.md")], &[], &["Notes".to_string()]);
assert!(s.get("Notes/gone.md").is_none());
assert!(s.get("Notes/fresh.md").is_some());
}
#[test] #[test]
fn upsert_replaces_by_path() { fn upsert_replaces_by_path() {
let s = NoteStore::new(); let s = NoteStore::new();

View file

@ -154,12 +154,23 @@ pub enum ScanWarningKind {
/// Walk the vault and return every readable Markdown note alongside any /// Walk the vault and return every readable Markdown note alongside any
/// per-file issues. The walk never panics on a single broken file. /// per-file issues. The walk never panics on a single broken file.
#[must_use]
pub fn scan(vault_root: &Path) -> ScanResult { pub fn scan(vault_root: &Path) -> ScanResult {
scan_from(vault_root, vault_root)
}
/// Walk `start` (a directory inside `vault_root`) and return every readable
/// Markdown note under it. Note paths stay relative to `vault_root`, so a
/// subtree result is directly comparable with a full [`scan`]. Used by the
/// watcher when a directory appears or is renamed into the vault [ANW-36]:
/// the native event names the directory, never its files.
#[must_use]
pub fn scan_from(vault_root: &Path, start: &Path) -> ScanResult {
let mut notes = Vec::new(); let mut notes = Vec::new();
let mut issues = Vec::new(); let mut issues = Vec::new();
let mut warnings = Vec::new(); let mut warnings = Vec::new();
let walker = WalkDir::new(vault_root) let walker = WalkDir::new(start)
.follow_links(false) .follow_links(false)
.into_iter() .into_iter()
// The root entry itself may have a dot-prefixed name (e.g., a // The root entry itself may have a dot-prefixed name (e.g., a

View file

@ -28,10 +28,16 @@ use crate::vault;
/// One path-scoped action derived from a native filesystem event. Always /// One path-scoped action derived from a native filesystem event. Always
/// carries a vault-relative, forward-slash-normalized path string -- the /// carries a vault-relative, forward-slash-normalized path string -- the
/// same form [`vault::Note.path`] uses. /// same form [`vault::Note.path`] uses.
///
/// The `*Tree` variants carry a directory instead of a note. Native events
/// name only the directory when one is created, removed, or renamed; the
/// files under it produce no events of their own [ANW-36].
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum WatchAction { pub enum WatchAction {
Upsert(String), Upsert(String),
Delete(String), Delete(String),
UpsertTree(String),
DeleteTree(String),
} }
/// One debouncer window's worth of coalesced changes. /// One debouncer window's worth of coalesced changes.
@ -39,6 +45,10 @@ pub enum WatchAction {
pub struct WatchBatch { pub struct WatchBatch {
pub upserts: Vec<String>, pub upserts: Vec<String>,
pub deletes: Vec<String>, pub deletes: Vec<String>,
/// Directories to walk for notes to upsert.
pub upsert_trees: Vec<String>,
/// Directories whose indexed notes are all gone.
pub delete_trees: Vec<String>,
} }
/// Classify one [`Event`] into zero or more [`WatchAction`]s. Dot-segments /// Classify one [`Event`] into zero or more [`WatchAction`]s. Dot-segments
@ -54,60 +64,104 @@ pub fn map_event(event: &Event, vault_root: &Path) -> Vec<WatchAction> {
let mut actions = Vec::new(); let mut actions = Vec::new();
let action_kind = classify(event.kind); let action_kind = classify(event.kind);
match action_kind { match action_kind {
Some(EventAction::Upsert) => { Some(EventAction::Modify) => {
// Content and metadata events only ever name a file.
for p in &event.paths { for p in &event.paths {
if let Some(rel) = vault_relative(vault_root, p) { if let Some(rel) = note_relative(vault_root, p) {
actions.push(WatchAction::Upsert(rel)); actions.push(WatchAction::Upsert(rel));
} }
} }
} }
Some(EventAction::Delete) => { Some(EventAction::Appear) => {
for p in &event.paths { for p in &event.paths {
if let Some(rel) = vault_relative(vault_root, p) { actions.extend(appear(vault_root, p));
actions.push(WatchAction::Delete(rel));
} }
} }
Some(EventAction::Vanish) => {
for p in &event.paths {
actions.extend(vanish(vault_root, p));
}
} }
Some(EventAction::Rename) => { Some(EventAction::Rename) => {
// Notify packs (from, to) in event.paths in that order. // Notify packs (from, to) in event.paths in that order.
if let Some(from) = event.paths.first() if let Some(from) = event.paths.first() {
&& let Some(rel) = vault_relative(vault_root, from) actions.extend(vanish(vault_root, from));
{
actions.push(WatchAction::Delete(rel));
} }
if let Some(to) = event.paths.get(1) if let Some(to) = event.paths.get(1) {
&& let Some(rel) = vault_relative(vault_root, to) actions.extend(appear(vault_root, to));
{
actions.push(WatchAction::Upsert(rel));
} }
} }
Some(EventAction::RenameUnpaired) => {
// FSEvents (macOS) cannot pair the two sides of a rename and
// reports `Modify(Name(Any))` for each side separately, so the
// direction has to come off the filesystem [ANW-36].
for p in &event.paths {
if p.exists() {
actions.extend(appear(vault_root, p));
} else {
actions.extend(vanish(vault_root, p));
}
}
// `p.exists()` can only report the moment it is asked. A path
// that vanishes right after the probe is caught downstream:
// `build_index_batch` turns a not-found upsert into a delete.
}
None => {} None => {}
} }
actions actions
} }
/// A path that now exists: a note to read, or a directory to walk. The
/// filesystem answers which, so a non-note file (an attachment, an editor
/// temp file) costs nothing beyond the probe.
fn appear(vault_root: &Path, abs: &Path) -> Option<WatchAction> {
if abs.is_dir() {
tree_relative(vault_root, abs).map(WatchAction::UpsertTree)
} else {
note_relative(vault_root, abs).map(WatchAction::Upsert)
}
}
/// A path that is gone: a note to drop, or a directory whose notes are all
/// gone with it. The filesystem cannot be asked -- it no longer holds the
/// entry -- so the decision rests on the path's own shape.
fn vanish(vault_root: &Path, abs: &Path) -> Option<WatchAction> {
if is_markdown(abs) {
note_relative(vault_root, abs).map(WatchAction::Delete)
} else {
tree_relative(vault_root, abs).map(WatchAction::DeleteTree)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EventAction { enum EventAction {
Upsert, /// Existing file, new content.
Delete, Modify,
/// Path came into the vault.
Appear,
/// Path left the vault.
Vanish,
/// Both sides in one event, `(from, to)`.
Rename, Rename,
/// One side of a rename, direction unknown.
RenameUnpaired,
} }
fn classify(kind: EventKind) -> Option<EventAction> { fn classify(kind: EventKind) -> Option<EventAction> {
match kind { match kind {
EventKind::Create(_) EventKind::Modify(ModifyKind::Data(_) | ModifyKind::Metadata(_) | ModifyKind::Any)
| EventKind::Modify( | EventKind::Access(AccessKind::Close(AccessMode::Write)) => Some(EventAction::Modify),
ModifyKind::Data(_) EventKind::Create(_) | EventKind::Modify(ModifyKind::Name(RenameMode::To)) => {
| ModifyKind::Metadata(_) Some(EventAction::Appear)
| ModifyKind::Any }
| ModifyKind::Name(RenameMode::To),
)
| EventKind::Access(AccessKind::Close(AccessMode::Write)) => Some(EventAction::Upsert),
EventKind::Modify(ModifyKind::Name(RenameMode::From)) | EventKind::Remove(_) => { EventKind::Modify(ModifyKind::Name(RenameMode::From)) | EventKind::Remove(_) => {
Some(EventAction::Delete) Some(EventAction::Vanish)
} }
EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => Some(EventAction::Rename), EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => Some(EventAction::Rename),
// Access(non-close), Modify(Name(Any|Other)), Other, Any -> drop. EventKind::Modify(ModifyKind::Name(RenameMode::Any | RenameMode::Other)) => {
Some(EventAction::RenameUnpaired)
}
// Access(non-close), Other, Any -> drop.
_ => None, _ => None,
} }
} }
@ -120,8 +174,10 @@ pub fn is_overflow(event: &Event) -> bool {
} }
/// Collapse a sequence of [`WatchAction`]s into a single batch. The last /// Collapse a sequence of [`WatchAction`]s into a single batch. The last
/// action per path wins (delete-then-upsert ends up as upsert, and so on). /// action per path wins (delete-then-upsert ends up as upsert, and so on);
/// The batch keeps deterministic order by sorting paths inside each list. /// notes and directories are tracked separately, since a directory action
/// covers paths a note action cannot name. The batch keeps deterministic
/// order by sorting paths inside each list.
#[must_use] #[must_use]
pub fn coalesce(actions: impl IntoIterator<Item = WatchAction>) -> WatchBatch { pub fn coalesce(actions: impl IntoIterator<Item = WatchAction>) -> WatchBatch {
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@ -129,36 +185,64 @@ pub fn coalesce(actions: impl IntoIterator<Item = WatchAction>) -> WatchBatch {
Upsert, Upsert,
Delete, Delete,
} }
let mut state: BTreeMap<String, Last> = BTreeMap::new(); let mut notes: BTreeMap<String, Last> = BTreeMap::new();
let mut trees: BTreeMap<String, Last> = BTreeMap::new();
for a in actions { for a in actions {
match a { match a {
WatchAction::Upsert(p) => { WatchAction::Upsert(p) => {
state.insert(p, Last::Upsert); notes.insert(p, Last::Upsert);
} }
WatchAction::Delete(p) => { WatchAction::Delete(p) => {
state.insert(p, Last::Delete); notes.insert(p, Last::Delete);
}
WatchAction::UpsertTree(p) => {
trees.insert(p, Last::Upsert);
}
WatchAction::DeleteTree(p) => {
trees.insert(p, Last::Delete);
} }
} }
} }
let mut upserts = Vec::new(); let mut batch = WatchBatch::default();
let mut deletes = Vec::new(); for (path, last) in notes {
for (path, last) in state {
match last { match last {
Last::Upsert => upserts.push(path), Last::Upsert => batch.upserts.push(path),
Last::Delete => deletes.push(path), Last::Delete => batch.deletes.push(path),
} }
} }
WatchBatch { upserts, deletes } for (path, last) in trees {
match last {
Last::Upsert => batch.upsert_trees.push(path),
Last::Delete => batch.delete_trees.push(path),
}
}
batch
}
fn is_markdown(path: &Path) -> bool {
path.extension().is_some_and(|e| e == "md")
} }
/// Filter and normalize an absolute notify path. Returns the vault-relative /// Filter and normalize an absolute notify path. Returns the vault-relative
/// forward-slash path if the entry is a `.md` file outside any dot-directory; /// forward-slash path if the entry is a `.md` file outside any dot-directory;
/// returns `None` otherwise (so the caller drops the event). /// returns `None` otherwise (so the caller drops the event).
fn vault_relative(vault_root: &Path, abs: &Path) -> Option<String> { fn note_relative(vault_root: &Path, abs: &Path) -> Option<String> {
let rel = abs.strip_prefix(vault_root).ok()?; if !is_markdown(abs) {
if rel.extension().is_none_or(|e| e != "md") {
return None; return None;
} }
relative(vault_root, abs)
}
/// Same, for a directory: any path that is not a note. The vault root itself
/// relativizes to the empty string and is dropped -- a root-level event is a
/// vault-wide signal the rescan path handles, not a prefix to delete.
fn tree_relative(vault_root: &Path, abs: &Path) -> Option<String> {
let rel = relative(vault_root, abs)?;
if rel.is_empty() { None } else { Some(rel) }
}
fn relative(vault_root: &Path, abs: &Path) -> Option<String> {
let rel = abs.strip_prefix(vault_root).ok()?;
for component in rel.components() { for component in rel.components() {
let s = component.as_os_str().to_str()?; let s = component.as_os_str().to_str()?;
if s.starts_with('.') { if s.starts_with('.') {
@ -226,7 +310,11 @@ pub async fn run_debouncer(
); );
} }
let batch = coalesce(actions); let batch = coalesce(actions);
if batch.upserts.is_empty() && batch.deletes.is_empty() { if batch.upserts.is_empty()
&& batch.deletes.is_empty()
&& batch.upsert_trees.is_empty()
&& batch.delete_trees.is_empty()
{
continue; continue;
} }
let index_batch = build_index_batch(&vault_root, batch); let index_batch = build_index_batch(&vault_root, batch);
@ -239,10 +327,17 @@ pub async fn run_debouncer(
fn build_index_batch(vault_root: &Path, batch: WatchBatch) -> IndexBatch { fn build_index_batch(vault_root: &Path, batch: WatchBatch) -> IndexBatch {
let mut upserts = Vec::with_capacity(batch.upserts.len()); let mut upserts = Vec::with_capacity(batch.upserts.len());
let mut deletes = batch.deletes;
for rel in batch.upserts { for rel in batch.upserts {
let abs = absolute_path(vault_root, &rel); let abs = absolute_path(vault_root, &rel);
match vault::scan_one(vault_root, &abs) { match vault::scan_one(vault_root, &abs) {
Ok(note) => upserts.push(note), Ok(note) => upserts.push(note),
// The file is gone by the time we read it. An event backend that
// coalesces create-and-remove into one upsert-shaped event would
// otherwise leave the entry in the index forever [ANW-36].
Err(vault::ScanIssueKind::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {
deletes.push(rel);
}
Err(kind) => { Err(kind) => {
tracing::warn!( tracing::warn!(
path = %abs.display(), path = %abs.display(),
@ -252,9 +347,22 @@ fn build_index_batch(vault_root: &Path, batch: WatchBatch) -> IndexBatch {
} }
} }
} }
for dir in &batch.upsert_trees {
let abs = absolute_path(vault_root, dir);
let result = vault::scan_from(vault_root, &abs);
for issue in &result.issues {
tracing::warn!(
path = %issue.path.display(),
error = %issue.kind,
"filesystem_watcher: subtree walk issue; skipping note"
);
}
upserts.extend(result.notes);
}
IndexBatch { IndexBatch {
upserts, upserts,
deletes: batch.deletes, deletes,
delete_prefixes: batch.delete_trees,
} }
} }
@ -393,6 +501,121 @@ mod tests {
assert_eq!(batch.deletes, vec!["c.md".to_string()]); assert_eq!(batch.deletes, vec!["c.md".to_string()]);
} }
#[test]
fn coalesce_keeps_notes_and_trees_apart() {
let actions = vec![
WatchAction::Upsert("Notes/a.md".into()),
WatchAction::DeleteTree("Notes".into()),
WatchAction::UpsertTree("Fresh".into()),
];
let batch = coalesce(actions);
assert_eq!(batch.upserts, vec!["Notes/a.md".to_string()]);
assert!(batch.deletes.is_empty());
assert_eq!(batch.upsert_trees, vec!["Fresh".to_string()]);
assert_eq!(batch.delete_trees, vec!["Notes".to_string()]);
}
#[test]
fn removed_directory_is_a_tree_delete() {
let e = ev(EventKind::Remove(RemoveKind::Folder), &["/v/Notes"]);
assert_eq!(
map_event(&e, &vault()),
vec![WatchAction::DeleteTree("Notes".into())]
);
}
#[test]
fn directory_renamed_away_is_a_tree_delete() {
let e = ev(
EventKind::Modify(ModifyKind::Name(RenameMode::From)),
&["/v/Notes"],
);
assert_eq!(
map_event(&e, &vault()),
vec![WatchAction::DeleteTree("Notes".into())]
);
}
#[test]
fn vault_root_itself_is_not_a_tree_action() {
let e = ev(EventKind::Remove(RemoveKind::Folder), &["/v"]);
assert!(map_event(&e, &vault()).is_empty());
}
#[test]
fn removed_non_note_file_touches_no_note() {
// A vanished path with no `.md` suffix is treated as a directory;
// the prefix simply matches nothing in the store.
let e = ev(EventKind::Remove(RemoveKind::File), &["/v/image.png"]);
assert_eq!(
map_event(&e, &vault()),
vec![WatchAction::DeleteTree("image.png".into())]
);
}
#[test]
fn created_directory_is_a_tree_upsert() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir(root.path().join("Notes")).unwrap();
let e = ev(EventKind::Create(CreateKind::Folder), &[]).add_path(root.path().join("Notes"));
assert_eq!(
map_event(&e, root.path()),
vec![WatchAction::UpsertTree("Notes".into())]
);
}
#[test]
fn unpaired_rename_resolves_by_existence() {
// FSEvents (macOS) reports each side of a rename as
// `Modify(Name(Any))` with no way to pair them.
let root = tempfile::tempdir().unwrap();
std::fs::write(root.path().join("here.md"), "x").unwrap();
let kind = EventKind::Modify(ModifyKind::Name(RenameMode::Any));
let present = ev(kind, &[]).add_path(root.path().join("here.md"));
assert_eq!(
map_event(&present, root.path()),
vec![WatchAction::Upsert("here.md".into())]
);
let absent = ev(kind, &[]).add_path(root.path().join("gone.md"));
assert_eq!(
map_event(&absent, root.path()),
vec![WatchAction::Delete("gone.md".into())]
);
}
#[test]
fn upsert_of_a_vanished_file_becomes_a_delete() {
let root = tempfile::tempdir().unwrap();
let batch = WatchBatch {
upserts: vec!["gone.md".to_string()],
..WatchBatch::default()
};
let index_batch = build_index_batch(root.path(), batch);
assert!(index_batch.upserts.is_empty());
assert_eq!(index_batch.deletes, vec!["gone.md".to_string()]);
}
#[test]
fn tree_upsert_walks_the_subtree() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir_all(root.path().join("Notes/deep")).unwrap();
std::fs::write(root.path().join("Notes/a.md"), "---\nk: 1\n---\nbody\n").unwrap();
std::fs::write(root.path().join("Notes/deep/b.md"), "body\n").unwrap();
std::fs::write(root.path().join("Notes/skip.txt"), "no\n").unwrap();
std::fs::write(root.path().join("outside.md"), "no\n").unwrap();
let batch = WatchBatch {
upsert_trees: vec!["Notes".to_string()],
..WatchBatch::default()
};
let index_batch = build_index_batch(root.path(), batch);
let mut paths: Vec<String> = index_batch.upserts.into_iter().map(|n| n.path).collect();
paths.sort();
assert_eq!(paths, vec!["Notes/a.md", "Notes/deep/b.md"]);
}
#[test] #[test]
fn overflow_event_returns_empty_actions() { fn overflow_event_returns_empty_actions() {
// Caller dispatches rescan_now; map_event itself produces no per-path actions. // Caller dispatches rescan_now; map_event itself produces no per-path actions.