Skip to content

fix(index): replace saved index files atomically - #189

Merged
gouyt13 merged 3 commits into
mainfrom
fix/atomic-index-save
Oct 31, 2026
Merged

gouyt13 merged 3 commits into
mainfrom
fix/atomic-index-save

Conversation

@gouyt13

@gouyt13 gouyt13 commented Oct 31, 2026

Copy link
Copy Markdown
Member

Summary

  • Atomic index saves (fix).
    • HNSW, IVF and SymphonyQG save write to a temporary file beside the destination and rename it into place, so a failed save leaves an existing index unchanged and removes what it wrote. Previously a failure part-way through left a truncated file.
    • IVF renames its HNSW sidecar before the index file.
    • A replaced file keeps its permission bits, but not its owner or other hard links.
    • Symbolic links and other non-regular destinations are still written in place, as before.
    • HNSW serializes one loaded point count instead of the bytes of its atomic counter.
  • IVF and QG builder (perf).
    • IVF::reserve refreshes only the grown cluster's view instead of rebuilding every view, which cost O(num_clusters) per call.
    • The QG builder's reverse-edge pass uses 4096 striped mutexes instead of one per point. Mutual exclusion is unchanged; memory no longer grows with the point count.
  • Defaults and docs.
    • The HNSW construct default is now spelled as every available thread; 0 still auto-selects, so behavior is unchanged.
    • Docs state that one-thread add keeps links deterministic.
    • Rotator dimensions start initialized.

No public API or saved-format changes. The save behavior is documented under the shared persistence conventions in docs/docs/index/usage.md.

Known limitation: if the index-file rename fails after the IVF sidecar rename succeeded, a new sidecar can remain beside the old index. This needs both renames in one directory to succeed and fail in turn (for example, a permission change in between). It is documented; a journaled two-file update is out of scope here.

Behavior change to note: saving over an existing read-only index file now succeeds, because a rename needs write access to the directory, not the file. The replacement keeps the read-only bits.

Testing

Linux x86-64 (GCC 14.3, RABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF):

  • CTest (--no-tests=error): Release and ASan/UBSan, 424 passed each, 1 skipped (the placement test, which requires OpenMP binding). The first commit was also built and tested on its own.
  • New C++ tests (file_replacement_test):
    • replacing the main file and its sidecar without leaving temporaries;
    • creating a missing destination;
    • a failed write keeping the old files and removing temporaries;
    • writing in place through non-regular destinations and symbolic links;
    • keeping the permissions of replaced files. This test failed before the permission copy: 0640 and 0600 became 0664 under umask 0002.
  • Python: 810 passed, 2 skipped (wheel-only tests). This includes a new test that saves over existing index and sidecar files twice and checks that no extra files remain.
  • Static checks: check-format.sh, check-python.sh, full check-tidy.sh build-tidy (clang-tidy 23), mkdocs build --strict and git diff --check pass.

Not run locally: Windows and macOS. On Windows the permission test is skipped, because only a read-only attribute exists there.

HNSW, IVF and SymphonyQG save to a temporary file beside the destination and rename it into place, so a failed save leaves an existing index unchanged and removes what it wrote. IVF renames its HNSW sidecar first. A replaced file keeps its permission bits, but not its owner or other hard links; symbolic links and other non-regular destinations are still written in place. HNSW now serializes one loaded point count instead of the bytes of the atomic counter.
…verse-edge locks

IVF::reserve grows one cluster, so only that cluster's view needs to point at the new storage; rebuilding every view cost O(num_clusters) per call. QGBuilder allocated one mutex per point for each reverse-edge pass; 4096 striped locks keep the same mutual exclusion with memory independent of the point count.
Spell the construct default as every available thread (0 still auto-selects), document that one-thread add keeps links deterministic, and give Rotator dimensions a defined initial value.
@github-actions github-actions Bot added the release:fixed Release category managed from the PR title label Oct 31, 2026
@gouyt13
gouyt13 merged commit 0734ec2 into main Oct 31, 2026
49 checks passed
@gouyt13
gouyt13 deleted the fix/atomic-index-save branch October 31, 2026 01:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:fixed Release category managed from the PR title

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant