add rename-recovery grace window: same-ID delete+create pairing
Root cause of the F.13.13 'Budget & cash-flow tracking -> tracker' false-archive incident (2026-09-24): watchdog's inotify cookie-pairing failed on a PLAIN, unloaded, single-folder Obsidian rename -- no burst, no stress test, just an ordinary rename -- producing a bare on_deleted and a bare on_created ~12s apart instead of one on_moved. This is the same underlying gap as the earlier Untitled-placeholder bug, but proves it is not just a high-load phenomenon: any rename can split this way. Fix: handle_vault_deleted no longer archives an ID-folder deletion immediately. It holds the delete in _pending_id_deletes, keyed by the JD ID code (title-independent, e.g. "F.13.13"), for RENAME_GRACE_SECONDS (15s). handle_vault_created checks this dict on every create; if a create for the SAME ID code lands within the window, _resolve_as_rename moves /data's old-named folder to the new name (and renames its folder-note file to match) instead of archiving -- recovering the rename watchdog failed to pair. If nothing claims the pending delete within the window, _finalize_pending_delete archives it exactly as before (real deletions are unaffected). Tested live both ways: (1) delete+create with same ID within the window -> recovered as a rename, test file content preserved, no archive slot created; (2) delete with no matching create -> correctly archived after the grace window expires, same as pre-fix behavior.
This commit is contained in:
@@ -120,6 +120,18 @@ _lock = threading.Lock()
|
||||
_recent_events = {} # (kind, path) -> timestamp, for debouncing duplicate inotify events
|
||||
_DEBOUNCE_SECONDS = 3.0
|
||||
|
||||
# Watchdog's inotify cookie-pairing (MOVED_FROM + MOVED_TO -> one on_moved)
|
||||
# is best-effort and confirmed to fail even on a single, unloaded, plain
|
||||
# folder rename (see handle_vault_deleted docstring) -- not just under
|
||||
# burst load like the earlier "Untitled" bug. To recover the rename intent
|
||||
# from an unpaired delete+create pair, a vault-side ID deletion is held
|
||||
# here (keyed by JD ID code, e.g. "F.13.13") for RENAME_GRACE_SECONDS
|
||||
# before it's actually archived, giving a same-ID create a chance to
|
||||
# claim it as a rename instead.
|
||||
_pending_id_deletes = {}
|
||||
_pending_deletes_lock = threading.Lock()
|
||||
RENAME_GRACE_SECONDS = 15.0
|
||||
|
||||
|
||||
def _debounced(kind, path):
|
||||
"""
|
||||
@@ -226,6 +238,21 @@ def handle_vault_created(path):
|
||||
# own separate event moments later and mirrors normally, so skipping
|
||||
# this one is enough: no orphan junk in /data, no duplicate ntfy.
|
||||
return
|
||||
base = os.path.basename(path)
|
||||
id_code = vl.id_code(base)
|
||||
if id_code:
|
||||
with _pending_deletes_lock:
|
||||
pending = _pending_id_deletes.pop(id_code, None)
|
||||
if pending is not None:
|
||||
# A vault-side delete of the SAME JD ID landed within the
|
||||
# grace window -- this create is the other half of a rename
|
||||
# that watchdog failed to pair into one on_moved (confirmed
|
||||
# 2026-09-24, see handle_vault_deleted). Recover the rename
|
||||
# instead of doing a normal create-mirror (which would have
|
||||
# produced a fresh empty stub, orphaning whatever was really
|
||||
# in the old-named /data folder).
|
||||
_resolve_as_rename(pending, path, rel)
|
||||
return
|
||||
data_path = os.path.join(DATA, rel)
|
||||
if os.path.isdir(data_path):
|
||||
return # already mirrored (duplicate event or pre-existing)
|
||||
@@ -410,6 +437,26 @@ def _nearest_archive_note(archive_data_path):
|
||||
|
||||
|
||||
def handle_vault_deleted(path):
|
||||
"""
|
||||
An ID-shaped folder disappeared from /vault. Per design this normally
|
||||
means archive the /data copy (see module docstring) -- but a bare
|
||||
on_deleted this is NOT always a real deletion. Confirmed 2026-09-24:
|
||||
a plain, unloaded, single-folder Obsidian rename (no burst, no stress
|
||||
test -- just "Budget & cash-flow tracking" -> "...tracker") produced
|
||||
an unpaired on_deleted + on_created ~12s apart instead of one
|
||||
on_moved. This is the SAME watchdog cookie-pairing gap as the earlier
|
||||
Untitled-placeholder bug, but this time with no load to blame at all
|
||||
-- so it can happen on any rename, not just bursts.
|
||||
|
||||
Fix: don't archive immediately. Hold the delete in
|
||||
`_pending_id_deletes` keyed by the JD ID code (e.g. "F.13.13") for
|
||||
RENAME_GRACE_SECONDS. If a create for a folder with the SAME id code
|
||||
(different title text is fine -- that's exactly what a rename is)
|
||||
shows up within the window, handle_vault_created finds it in the
|
||||
pending dict and treats the pair as the rename it actually was
|
||||
instead of archiving-then-restubbing. Only after the grace window
|
||||
passes with no matching create does this actually archive.
|
||||
"""
|
||||
if _in_skip(path, VAULT):
|
||||
return
|
||||
if not _debounced("vault_deleted", path):
|
||||
@@ -421,6 +468,64 @@ def handle_vault_deleted(path):
|
||||
data_path = os.path.join(DATA, rel)
|
||||
if not os.path.isdir(data_path):
|
||||
return # already handled (duplicate event) or no /data counterpart ever existed
|
||||
id_code = vl.id_code(base)
|
||||
if id_code:
|
||||
with _pending_deletes_lock:
|
||||
_pending_id_deletes[id_code] = {"path": path, "rel": rel, "base": base}
|
||||
log(f"PENDING delete (grace window for possible rename pairing): {rel}")
|
||||
timer = threading.Timer(RENAME_GRACE_SECONDS, _finalize_pending_delete, args=(id_code,))
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
return
|
||||
_archive_deleted_id(path, rel, base)
|
||||
|
||||
|
||||
def _finalize_pending_delete(id_code):
|
||||
"""Timer callback: if nothing claimed this id_code as a rename target
|
||||
within the grace window, it really was just a deletion -- archive now."""
|
||||
with _pending_deletes_lock:
|
||||
entry = _pending_id_deletes.pop(id_code, None)
|
||||
if entry is None:
|
||||
return # a matching create already resolved this as a rename
|
||||
with _lock:
|
||||
_archive_deleted_id(entry["path"], entry["rel"], entry["base"])
|
||||
|
||||
|
||||
def _resolve_as_rename(pending, new_vault_path, new_rel):
|
||||
"""A delete+create pair shared the same JD ID within the grace window
|
||||
-- treat it as the rename it actually is: move /data's old-named
|
||||
folder to the new name (and its folder-note filename to match),
|
||||
instead of archiving. Falls back to a normal create-mirror if /data
|
||||
has no old-named counterpart to move."""
|
||||
old_rel = pending["rel"]
|
||||
data_src = os.path.join(DATA, old_rel)
|
||||
data_dest = os.path.join(DATA, new_rel)
|
||||
if not os.path.isdir(data_src):
|
||||
handle_vault_created(new_vault_path)
|
||||
return
|
||||
if os.path.exists(data_dest):
|
||||
log(f"SKIP rename-recovery mirror (dest exists): {old_rel} -> {new_rel}")
|
||||
return
|
||||
os.makedirs(os.path.dirname(data_dest), exist_ok=True)
|
||||
os.rename(data_src, data_dest)
|
||||
old_base = pending["base"]
|
||||
new_base = os.path.basename(new_vault_path)
|
||||
old_note = os.path.join(data_dest, old_base + ".md")
|
||||
new_note = os.path.join(data_dest, new_base + ".md")
|
||||
if old_base != new_base and os.path.isfile(old_note) and not os.path.exists(new_note):
|
||||
os.rename(old_note, new_note)
|
||||
log(f"RECOVERED rename (delete+create pair, same ID {vl.id_code(new_base)}): {old_rel} -> {new_rel}")
|
||||
ntfy_post(
|
||||
"Vault sync: renamed",
|
||||
f"/data: {old_rel} -> {new_rel}",
|
||||
tags="package",
|
||||
)
|
||||
|
||||
|
||||
def _archive_deleted_id(path, rel, base):
|
||||
data_path = os.path.join(DATA, rel)
|
||||
if not os.path.isdir(data_path):
|
||||
return # nothing to archive -- claimed by a rename, or already gone
|
||||
archive_dir, created_note = _find_nearest_archive_dir(rel)
|
||||
dest = os.path.join(archive_dir, base)
|
||||
if os.path.exists(dest):
|
||||
|
||||
Reference in New Issue
Block a user