Skip to content

fix(export): stop rewriting unchanged wiki/Obsidian pages on every run (#3060) - #3760

Closed
abhay-codes07 wants to merge 1 commit into
Graphify-Labs:v8from
abhay-codes07:fix/exporter-write-if-changed
Closed

abhay-codes07 wants to merge 1 commit into
Graphify-Labs:v8from
abhay-codes07:fix/exporter-write-if-changed

Conversation

@abhay-codes07

Copy link
Copy Markdown
Contributor

Fixes #3060.

What

to_wiki() and to_obsidian() regenerate the full page set on every call and write every page unconditionally:

  • wiki.py deletes every *.md up front (for old in out.glob("*.md"): old.unlink()), guaranteeing all of them are then rewritten from scratch even when identical;
  • export.py::_owned_write calls write_text_atomic(...) without comparing against what is already on disk.

An export is triggered by any graph.json change, so on an always-on watch --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:

  1. paths.write_text_atomic_if_changed(path, text) -> bool — writes (and reports True) only when the content differs. It compares decoded text, not bytes: write_text_atomic writes in text mode and translates \n↔\r\n on Windows, so a byte compare would report every file as changed.
  2. export._owned_write uses 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.
  3. 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 — same orphan guarantee for relabelled/removed communities.

Verification

New 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 (new neighbor).
  • A wiki re-run leaves unchanged articles untouched and still sweeps a relabelled community's stale page.

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

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
Copilot AI lite review requested due to automatic review settings September 22, 2026 18:35

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@graphify-labs graphify-labs Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 — impact
  • tests/test_agents_platform.py — impact
  • tests/test_analyze.py — impact
  • tests/test_atomic_canvas_export.py — impact
  • tests/test_atomic_version_stamp.py — impact
  • tests/test_atomic_writes.py — impact
  • tests/test_benchmark.py — impact
  • tests/test_benchmark_raw_graph.py — impact
  • tests/test_build.py — impact
  • tests/test_build_merge_dedup_scope.py — impact
  • tests/test_build_merge_hyperedges_and_prune.py — impact
  • tests/test_build_merge_shrink_guard.py — impact
  • tests/test_cache.py — impact
  • tests/test_callflow_html.py — impact
  • tests/test_carried_hyperedge_remap.py — impact
  • tests/test_charmap_encoding.py — impact
  • tests/test_chunking.py — impact
  • tests/test_cli_export.py — impact
  • tests/test_cluster.py — impact
  • tests/test_codebuddy.py — impact
  • tests/test_community_labels_skill.py — impact
  • tests/test_confidence.py — impact
  • tests/test_corrupt_graph_json.py — impact
  • tests/test_cpp_objc_cross_file_calls.py — impact
  • tests/test_cross_extension_reexport_self_cycle.py — impact
  • tests/test_dedup.py — impact
  • tests/test_dedup_remaps_hyperedges.py — impact
  • tests/test_definition_file_portability.py — impact
  • tests/test_detect.py — impact
  • tests/test_devin.py — impact
  • tests/test_duplicate_annotation_edges.py — impact
  • tests/test_evidence_binding.py — impact
  • tests/test_explain_cli.py — impact
  • tests/test_export.py — impact
  • tests/test_export_control_characters.py — impact
  • tests/test_export_idempotent_writes.py — impact, changed-test
  • tests/test_export_path_length.py — impact
  • tests/test_external_stub_endpoints.py — impact
  • tests/test_extract.py — impact
  • tests/test_extract_cache_location.py — impact
  • tests/test_extract_cli.py — impact
  • tests/test_falkordb_integration.py — impact
  • tests/test_file_label_disambiguation.py — impact
  • tests/test_global_add_tag_inference.py — impact
  • tests/test_global_graph.py — impact
  • tests/test_go_qualified_resolution.py — impact
  • tests/test_god_nodes_cli.py — impact
  • tests/test_god_nodes_exclude_hubs.py — impact
  • tests/test_hollow_chunks_arm_shrink_guard.py — impact
  • tests/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).

@safishamsi

Copy link
Copy Markdown
Collaborator

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Wiki and Obsidian exporters rewrite every page on every run, even when nothing changed (31,417 files / 137 MB per export, ~7 GB/day here)

3 participants