add periodic full-tree reconcile sweep as inotify backstop
Root cause of the R.12.22 mkdir going unnoticed was never pinned down -- kernel-level inotifywait on the exact same directory caught the same mkdir instantly, ruling out mount/fs issues, but watchdog's own internal bookkeeping missed it with zero log line. Rather than keep chasing a specific inotify failure mode, make the daemon resilient regardless of cause: a reconcile() sweep walks the whole /data and /vault tree every 3 minutes (plus once at startup) and calls the same idempotent handle_data_created/handle_vault_created used for live events. Both are check-target-state-first by construction, so this is a no-op on an already-synced tree and only acts on genuine misses. Does not attempt rename reconstruction -- a missed rename looks identical to a delete+create after the fact, which is genuinely ambiguous. Live on_moved handling remains the only rename path; reconcile's job is strictly to guarantee creates are never lost.
This commit is contained in:
@@ -493,6 +493,59 @@ def handle_data_created(path):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_data_moved(src_path, dest_path):
|
||||||
|
"""
|
||||||
|
A folder in /data was renamed/moved (e.g. via an SMB client, GUI file
|
||||||
|
manager, or any tool that stages-then-renames rather than mkdir'ing
|
||||||
|
directly in place). DataHandler previously had NO on_moved at all, so
|
||||||
|
this fired zero log line and zero ntfy -- confirmed root cause of
|
||||||
|
Ryan's 2026-09-22 report ("created a folder in /data, nothing happened
|
||||||
|
on ntfy or in /vault": the folder actually arrived via a rename, not a
|
||||||
|
bare create, and there was no handler to catch it).
|
||||||
|
|
||||||
|
Mirror rule: if /vault already has a note at the OLD relative path,
|
||||||
|
rename it to match (same as the forward vault->data rename mirror).
|
||||||
|
If there's no /vault counterpart yet at all, treat this exactly like
|
||||||
|
a fresh data_created at the NEW path (covers the same "staged then
|
||||||
|
renamed into place" arrival pattern as a plain create).
|
||||||
|
"""
|
||||||
|
if _in_skip(src_path, DATA) or _in_skip(dest_path, DATA):
|
||||||
|
return
|
||||||
|
rel_src = os.path.relpath(src_path, DATA)
|
||||||
|
rel_dest = os.path.relpath(dest_path, DATA)
|
||||||
|
if _under_data_exempt(rel_src) or _under_data_exempt(rel_dest):
|
||||||
|
return
|
||||||
|
vault_src = os.path.join(VAULT, rel_src)
|
||||||
|
vault_dest = os.path.join(VAULT, rel_dest)
|
||||||
|
if not os.path.isdir(vault_src):
|
||||||
|
# No /vault counterpart to rename -- treat the arrival at the new
|
||||||
|
# name like a normal reverse-sync create.
|
||||||
|
handle_data_created(dest_path)
|
||||||
|
return
|
||||||
|
if os.path.exists(vault_dest):
|
||||||
|
log(f"SKIP data-move mirror (vault dest exists): {rel_src} -> {rel_dest}")
|
||||||
|
return
|
||||||
|
os.makedirs(os.path.dirname(vault_dest), exist_ok=True)
|
||||||
|
os.rename(vault_src, vault_dest)
|
||||||
|
# keep the folder-note's own filename in sync with its containing
|
||||||
|
# folder's new name (matches the "<ID name>/<ID name>.md" convention
|
||||||
|
# used throughout the vault)
|
||||||
|
old_base = os.path.basename(vault_src)
|
||||||
|
new_base = os.path.basename(vault_dest)
|
||||||
|
old_note = os.path.join(vault_dest, old_base + ".md")
|
||||||
|
new_note = os.path.join(vault_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"MIRRORED rename /vault: {rel_src} -> {rel_dest}")
|
||||||
|
commit_paths = [vault_dest] if os.path.isdir(vault_dest) else []
|
||||||
|
_git_commit(f"vault_sync_daemon: renamed {old_base} -> {new_base}", commit_paths)
|
||||||
|
ntfy_post(
|
||||||
|
"Vault sync: renamed",
|
||||||
|
f"/vault: {rel_src} -> {rel_dest}",
|
||||||
|
tags="package",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# watchdog wiring
|
# watchdog wiring
|
||||||
#
|
#
|
||||||
@@ -546,6 +599,11 @@ class DataHandler(FileSystemEventHandler):
|
|||||||
return
|
return
|
||||||
_event_queue.put(("data_created", event.src_path))
|
_event_queue.put(("data_created", event.src_path))
|
||||||
|
|
||||||
|
def on_moved(self, event):
|
||||||
|
if not event.is_directory:
|
||||||
|
return
|
||||||
|
_event_queue.put(("data_moved", event.src_path, event.dest_path))
|
||||||
|
|
||||||
|
|
||||||
def _worker_loop():
|
def _worker_loop():
|
||||||
"""Single consumer thread: drains _event_queue and does the actual
|
"""Single consumer thread: drains _event_queue and does the actual
|
||||||
@@ -576,6 +634,9 @@ def _worker_loop():
|
|||||||
_, path = item
|
_, path = item
|
||||||
time.sleep(0.5) # small settle delay so a batch mkdir/copy isn't caught half-written
|
time.sleep(0.5) # small settle delay so a batch mkdir/copy isn't caught half-written
|
||||||
handle_data_created(path)
|
handle_data_created(path)
|
||||||
|
elif kind == "data_moved":
|
||||||
|
_, src_path, dest_path = item
|
||||||
|
handle_data_moved(src_path, dest_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f"ERROR handling {item}: {e}")
|
log(f"ERROR handling {item}: {e}")
|
||||||
ntfy_post(
|
ntfy_post(
|
||||||
@@ -588,6 +649,78 @@ def _worker_loop():
|
|||||||
_event_queue.task_done()
|
_event_queue.task_done()
|
||||||
|
|
||||||
|
|
||||||
|
RECONCILE_INTERVAL_SECONDS = 180
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile():
|
||||||
|
"""
|
||||||
|
Full-tree self-healing sweep, independent of inotify.
|
||||||
|
|
||||||
|
inotify is best-effort: watch descriptors can be dropped, events can be
|
||||||
|
coalesced or lost under load, and — per Ryan 2026-09-24 — a plain
|
||||||
|
`mkdir` under R.12.22 produced zero log line and zero ntfy with no
|
||||||
|
reproducible cause found (kernel-level inotifywait on the same exact
|
||||||
|
directory caught the same mkdir instantly, so it wasn't a mount/fs
|
||||||
|
issue — watchdog's own bookkeeping simply missed it). Rather than chase
|
||||||
|
every possible way an event can go missing, the daemon must be
|
||||||
|
resilient no matter what: periodically diff the whole tree in both
|
||||||
|
directions and self-heal anything inotify failed to deliver.
|
||||||
|
|
||||||
|
This walks every directory in /data and /vault and calls the exact
|
||||||
|
same idempotent handlers used for live events (handle_data_created /
|
||||||
|
handle_vault_created). Both are "check target state first, act only if
|
||||||
|
something's actually missing" by construction (see module docstring),
|
||||||
|
so calling them on every already-mirrored directory is a cheap no-op —
|
||||||
|
only genuinely missed folders get created, stubbed, and ntfy'd here.
|
||||||
|
|
||||||
|
Deliberately does NOT attempt rename detection: a rename that happened
|
||||||
|
while the daemon was blind looks identical to a delete+create pair,
|
||||||
|
which is genuinely ambiguous to reconstruct after the fact. Live
|
||||||
|
on_moved handling remains the only path for renames; reconcile's job
|
||||||
|
is just to guarantee creates are never permanently lost.
|
||||||
|
"""
|
||||||
|
t0 = time.time()
|
||||||
|
data_count = 0
|
||||||
|
for dirpath, dirnames, _ in os.walk(DATA):
|
||||||
|
dirnames.sort()
|
||||||
|
if dirpath == DATA:
|
||||||
|
continue
|
||||||
|
if _in_skip(dirpath, DATA):
|
||||||
|
dirnames[:] = []
|
||||||
|
continue
|
||||||
|
with _lock:
|
||||||
|
handle_data_created(dirpath)
|
||||||
|
data_count += 1
|
||||||
|
vault_count = 0
|
||||||
|
for dirpath, dirnames, _ in os.walk(VAULT):
|
||||||
|
dirnames.sort()
|
||||||
|
if dirpath == VAULT:
|
||||||
|
continue
|
||||||
|
if _in_skip(dirpath, VAULT):
|
||||||
|
dirnames[:] = []
|
||||||
|
continue
|
||||||
|
with _lock:
|
||||||
|
handle_vault_created(dirpath)
|
||||||
|
vault_count += 1
|
||||||
|
log(f"reconcile: swept {data_count} /data dirs, {vault_count} /vault dirs "
|
||||||
|
f"in {time.time() - t0:.1f}s")
|
||||||
|
|
||||||
|
|
||||||
|
def _reconcile_loop():
|
||||||
|
while True:
|
||||||
|
time.sleep(RECONCILE_INTERVAL_SECONDS)
|
||||||
|
try:
|
||||||
|
reconcile()
|
||||||
|
except Exception as e:
|
||||||
|
log(f"ERROR in reconcile sweep: {e}")
|
||||||
|
ntfy_post(
|
||||||
|
"Vault sync ERROR",
|
||||||
|
f"Periodic reconcile sweep failed:\n{e}",
|
||||||
|
tags="rotating_light",
|
||||||
|
priority="urgent",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
log("vault_sync_daemon starting")
|
log("vault_sync_daemon starting")
|
||||||
worker = threading.Thread(target=_worker_loop, daemon=True)
|
worker = threading.Thread(target=_worker_loop, daemon=True)
|
||||||
@@ -598,6 +731,14 @@ def main():
|
|||||||
observer.start()
|
observer.start()
|
||||||
log(f"watching {VAULT} and {DATA}")
|
log(f"watching {VAULT} and {DATA}")
|
||||||
ntfy_post("Vault sync daemon started", f"Watching {VAULT} and {DATA}", tags="rocket", priority="low")
|
ntfy_post("Vault sync daemon started", f"Watching {VAULT} and {DATA}", tags="rocket", priority="low")
|
||||||
|
# Run one reconcile sweep at startup (catches anything that happened
|
||||||
|
# while the daemon was down, e.g. a reboot) then keep sweeping on a
|
||||||
|
# timer as a standing backstop under live inotify events.
|
||||||
|
try:
|
||||||
|
reconcile()
|
||||||
|
except Exception as e:
|
||||||
|
log(f"ERROR in startup reconcile sweep: {e}")
|
||||||
|
threading.Thread(target=_reconcile_loop, daemon=True).start()
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
time.sleep(5)
|
time.sleep(5)
|
||||||
|
|||||||
Reference in New Issue
Block a user