fix(export): stop rewriting unchanged wiki/Obsidian pages on every run (#3060) - #3760
abhay-codes07 wants to merge 1 commit into
Conversation
Graphify-Labs#3060) `to_wiki()` and `to_obsidian()` regenerate the full page set on every call and write every page unconditionally — `to_wiki` even unlinks every `*.md` up front, guaranteeing a full rewrite. An export re-runs on any graph.json change, so on an always-on `watch`/export setup this rewrites tens of thousands of identical pages per run (measured: 31,417 pages / 137 MB per export, ~7 GB/day), and each rewrite is also a modification event that re-fires Obsidian re-indexing, sync clients, and downstream inotify pipelines for content that did not change. Compare before writing: - New `paths.write_text_atomic_if_changed(path, text) -> bool` writes (and reports True) only when the content differs from what is on disk, comparing DECODED text — text-mode writes translate `\n`↔`\r\n` on Windows, so a byte compare would report every file changed. - `export._owned_write` uses it and still records the note in `_written` (the ownership + prune-exclusion set, Graphify-Labs#1896), so an unchanged note stays owned and is neither pruned nor dropped from the manifest. - `to_wiki` drops the up-front unlink-all — which forced the rewrite and opened a window where a reader saw an empty `wiki/` mid-export — for an end-of-run sweep that removes only the `*.md` this run did not produce, keeping the same orphan guarantee for relabelled/removed communities. Tests: the helper skips an identical write (mtime untouched) and writes on a diff or a missing file; an Obsidian re-run leaves an unchanged note's mtime untouched yet still owned (not pruned) and still rewrites a note whose body changed; a wiki re-run leaves unchanged articles untouched and still sweeps a relabelled community's stale page. The wiki/export/obsidian suites stay green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJbLfztSxm2tH5cJwBbe9q
There was a problem hiding this comment.
Graphify reviewed this change.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Formal verification. 1 change(s) tested, no difference found (not proven).
Graphify review — findings
Adds write_text_atomic_if_changed, which compares decoded UTF-8 text against what's on disk and skips the atomic replace (preserving mtime/inode) when identical, treating a missing or non-UTF-8 target as changed — the byte-level rename churn that used to fire inotify/re-index/sync on every re-export is gone (#3060). Routes Obsidian's _owned_write and every wiki article/index write through it, so unchanged pages are left alone while still being recorded as owned so they aren't pruned. Replaces to_wiki's up-front unlink of all *.md with an end-of-run sweep that removes only files this run didn't produce, killing both the full rewrite and the mid-export window where readers saw an empty wiki/, while still cleaning up pages orphaned by drifting community labels.
No blocking issues surfaced. 10 lower-confidence candidates did not survive cross-model review.
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 2344 functions depend on the 105 functions this change touches.
Health — this change adds coupling hotspots:
- new:
extract()— 655 callers, 45 callees - new:
_rebuild_code()— 142 callers, 54 callees - new:
build_from_json()— 218 callers, 20 callees - new:
build_merge()— 76 callers, 14 callees - new:
to_obsidian()— 41 callers, 14 callees - new:
save_semantic_cache()— 63 callers, 9 callees - new:
save_manifest()— 40 callers, 11 callees - new:
to_json()— 58 callers, 7 callees - …and 58 more — each is listed as a finding
Verification — 2344 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 1437 function(s) in the blast radius were not formally verified this run
Test selection
Test selection
115 of 291 test file(s) selected (40%) via static blast radius.
tests/test_affected_cli.py— impacttests/test_agents_platform.py— impacttests/test_analyze.py— impacttests/test_atomic_canvas_export.py— impacttests/test_atomic_version_stamp.py— impacttests/test_atomic_writes.py— impacttests/test_benchmark.py— impacttests/test_benchmark_raw_graph.py— impacttests/test_build.py— impacttests/test_build_merge_dedup_scope.py— impacttests/test_build_merge_hyperedges_and_prune.py— impacttests/test_build_merge_shrink_guard.py— impacttests/test_cache.py— impacttests/test_callflow_html.py— impacttests/test_carried_hyperedge_remap.py— impacttests/test_charmap_encoding.py— impacttests/test_chunking.py— impacttests/test_cli_export.py— impacttests/test_cluster.py— impacttests/test_codebuddy.py— impacttests/test_community_labels_skill.py— impacttests/test_confidence.py— impacttests/test_corrupt_graph_json.py— impacttests/test_cpp_objc_cross_file_calls.py— impacttests/test_cross_extension_reexport_self_cycle.py— impacttests/test_dedup.py— impacttests/test_dedup_remaps_hyperedges.py— impacttests/test_definition_file_portability.py— impacttests/test_detect.py— impacttests/test_devin.py— impacttests/test_duplicate_annotation_edges.py— impacttests/test_evidence_binding.py— impacttests/test_explain_cli.py— impacttests/test_export.py— impacttests/test_export_control_characters.py— impacttests/test_export_idempotent_writes.py— impact, changed-testtests/test_export_path_length.py— impacttests/test_external_stub_endpoints.py— impacttests/test_extract.py— impacttests/test_extract_cache_location.py— impacttests/test_extract_cli.py— impacttests/test_falkordb_integration.py— impacttests/test_file_label_disambiguation.py— impacttests/test_global_add_tag_inference.py— impacttests/test_global_graph.py— impacttests/test_go_qualified_resolution.py— impacttests/test_god_nodes_cli.py— impacttests/test_god_nodes_exclude_hubs.py— impacttests/test_hollow_chunks_arm_shrink_guard.py— impacttests/test_hook_out_of_project_paths.py— impact- … and 65 more
Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.
Formal verification
No difference found (not proven): No behavior difference found in to\_obsidian (not a proof).
The verifier ran both versions of to\_obsidian on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
Could not verify: Could not verify to\_wiki.
The verifier did not have enough to check to\_wiki, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: no capturable inputs from the test suite; property tier: parameter `output_dir` is annotated `str | Path` — outside the synthesizable primitive/collection set
· 66 more finding(s) on lines outside this diff (see the check run).
|
Shipped in v0.9.67 (on PyPI). Cherry-picked with authorship preserved. Thanks @abhay-codes07! The decoded-text compare (not byte compare) to avoid the CRLF false-diff, and keeping skipped-but-owned pages out of the prune set, were both the right calls. |
Fixes #3060.
What
to_wiki()andto_obsidian()regenerate the full page set on every call and write every page unconditionally:wiki.pydeletes every*.mdup front (for old in out.glob("*.md"): old.unlink()), guaranteeing all of them are then rewritten from scratch even when identical;export.py::_owned_writecallswrite_text_atomic(...)without comparing against what is already on disk.An export is triggered by any
graph.jsonchange, so on an always-onwatch --semantic+ export setup it runs constantly. Measured by the reporter on one vault (26.7k nodes): 31,417 pages / 137 MB rewritten per export, 51 exports/day → ~7 GB/day, while the graph moved by ~20 nodes between runs. The disk cost is the smaller half — every rewrite is a modification event, so Obsidian re-indexes the whole vault, sync clients re-upload it, and inotify pipelines re-fire, dozens of times a day, for content that did not change.Fix
Compare before writing:
paths.write_text_atomic_if_changed(path, text) -> bool— writes (and reportsTrue) only when the content differs. It compares decoded text, not bytes:write_text_atomicwrites in text mode and translates\n↔\r\non Windows, so a byte compare would report every file as changed.export._owned_writeuses it and still records the note in_written— that list is both the ownership manifest and the prune-exclusion set (export obsidian: never prunes notes for removed nodes, so re-export merges old and new graphs #1896), so an unchanged note stays owned and is neither pruned as stale nor dropped from the manifest.to_wikidrops the up-front unlink-all (which forced the rewrite and opened a window where a reader saw an emptywiki/mid-export) for an end-of-run sweep that removes only the*.mdthis run did not produce — same orphan guarantee for relabelled/removed communities.Verification
New tests:
The wiki / export / Obsidian / canvas / CLI-export suites stay green (100 in the focused sweep; 184 including the wider export set). The ownership and #1896 prune tests are unaffected.
🤖 Generated with Claude Code
https://claude.ai/code/session_01JJbLfztSxm2tH5cJwBbe9q