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
|
_recent_events = {} # (kind, path) -> timestamp, for debouncing duplicate inotify events
|
||||||
_DEBOUNCE_SECONDS = 3.0
|
_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):
|
def _debounced(kind, path):
|
||||||
"""
|
"""
|
||||||
@@ -226,6 +238,21 @@ def handle_vault_created(path):
|
|||||||
# own separate event moments later and mirrors normally, so skipping
|
# own separate event moments later and mirrors normally, so skipping
|
||||||
# this one is enough: no orphan junk in /data, no duplicate ntfy.
|
# this one is enough: no orphan junk in /data, no duplicate ntfy.
|
||||||
return
|
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)
|
data_path = os.path.join(DATA, rel)
|
||||||
if os.path.isdir(data_path):
|
if os.path.isdir(data_path):
|
||||||
return # already mirrored (duplicate event or pre-existing)
|
return # already mirrored (duplicate event or pre-existing)
|
||||||
@@ -410,6 +437,26 @@ def _nearest_archive_note(archive_data_path):
|
|||||||
|
|
||||||
|
|
||||||
def handle_vault_deleted(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):
|
if _in_skip(path, VAULT):
|
||||||
return
|
return
|
||||||
if not _debounced("vault_deleted", path):
|
if not _debounced("vault_deleted", path):
|
||||||
@@ -421,6 +468,64 @@ def handle_vault_deleted(path):
|
|||||||
data_path = os.path.join(DATA, rel)
|
data_path = os.path.join(DATA, rel)
|
||||||
if not os.path.isdir(data_path):
|
if not os.path.isdir(data_path):
|
||||||
return # already handled (duplicate event) or no /data counterpart ever existed
|
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)
|
archive_dir, created_note = _find_nearest_archive_dir(rel)
|
||||||
dest = os.path.join(archive_dir, base)
|
dest = os.path.join(archive_dir, base)
|
||||||
if os.path.exists(dest):
|
if os.path.exists(dest):
|
||||||
|
|||||||
Reference in New Issue
Block a user