Skip to content

feat(diskann): delete-time in-neighbor repair with in-degree tracking and garden refresh - #104

Open
huanglune wants to merge 3 commits into
AlayaDB-AI:mainfrom
huanglune:feat/diskann-delete-repair-fixes
Open

feat(diskann): delete-time in-neighbor repair with in-degree tracking and garden refresh#104
huanglune wants to merge 3 commits into
AlayaDB-AI:mainfrom
huanglune:feat/diskann-delete-repair-fixes

Conversation

@huanglune

Copy link
Copy Markdown
Contributor

Description

Adds an opt-in delete-time in-neighbor repair mode to the updatable DiskANN index, plus live in-degree tracking and a budgeted garden_refresh maintenance pass, with follow-up scheduling/robustness fixes from review.

Today's lazy delete tombstones a slot and puts it straight on the free list; in-neighbors keep dangling edges until an insert's reconnect or the safety net touches them, and once the slot is reused those edges silently point at an unrelated new vector. With update_repair on, a delete batch is tombstoned first, each deleted node's approximate in-neighbors (out-neighbor symmetry, optionally merged with an insert-style candidate search via repair_search_l) are rebuilt while the slots are still dark — dead edges dropped, two-hop spliced — and only then do the slots become allocatable, so a reused slot can no longer inherit stale in-edges from its previous owner.

Related Issue

N/A

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Performance improvement
  • Code refactoring
  • Documentation update
  • CI/CD changes

Changes Made

  • SlotAllocator: split free() into mark_removed() (tombstone, not reusable) + release() (enter the free list after the repair window).
  • DiskANNLoadParams.update_repair / repair_search_l: delete-time discovery + repair wave shared by remove / batch_remove / batch_remove_with_pool; the lazy path is unchanged and remains the default. Repair supersedes the lazy path's safety-net reconnect (its affected set is a subset of the discovery set), so the net is skipped on the repair branch.
  • DiskANNLoadParams.track_in_degree: live in-degree counters maintained symmetrically across all neighbor-list write paths, recounted from disk at load; garden_refresh(budget) re-links the lowest-in-degree live nodes (search → prune → forward + reverse edges) and reports before/after percentiles.
  • Wave scheduling: repair discovery, the repair wave, and garden refresh fan out in kUpdateWaveChunk = 1024 chunks instead of worker-count chunks (which re-created the intra-batch barrier convoy batch_insert had removed); read_delete_nodes chunks by half the page cache via the new DiskPageIO::page_cache_capacity(); symmetry-only discovery (repair_search_l = 0) short-circuits to a sync loop; the repair-mode local pool uses the same worker count with or without the io_uring reactor.
  • Robustness: slots are released (degrading to lazy-delete semantics) if repair throws, instead of leaking tombstoned off the free list; a one-time warning fires if fresh slots outrun the in-degree tracking headroom.
  • Bench: --update_repair, --repair_search_l, --track_indegree, --garden_budget/--garden_l, an --oracle_ids batch-rebuild reference mode, and a --garden_pass coverage probe.

Testing

  • Unit tests added/updated
  • Integration tests added/updated
  • Manual testing performed

Seven new e2e tests in test_diskann_update_e2e: the repair remove contract (hidden → repaired → slots reused at same capacity), churn recall floor vs lazy mode, search-assisted discovery, the in-degree counter == full-recount invariant across insert/delete/garden, garden lifting the starved in-degree tail, garden API contracts, and RepairSkipsSafetyNetUnderDefaultArming (fails without the safety-net skip).

cmake -B build/Release -G Ninja -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON
cmake --build build/Release --target test_diskann_update_e2e bench_diskann_sift_update -j
./build/Release/tests/diskann/test_diskann_update_e2e   # 27/27 passing
make lint                                                # all pre-commit hooks pass

Also run under ThreadSanitizer (-fsanitize=thread -O1): the repair/in-degree/garden tests pass, and every reported race classifies to the pre-existing OpenMP VamanaBuilder build phase (vamana_builder.hpp, robust_prune.hpp) — none in the update paths this PR touches. CI is green on the fork branch.

Checklist

  • My code follows the project's coding style
  • I have run make lint and fixed any issues
  • I have added tests that prove my fix/feature works
  • All new and existing tests pass (make test)
  • I have updated documentation if needed
  • My commits follow the conventional commits format

Additional Notes

  • The new load params are C++-level only; Python bindings are intentionally not extended in this PR.
  • In-neighbor discovery is approximate by design (out-neighbor symmetry + optional top-L search); undiscovered in-neighbors still degrade lazily as before. The bench's --oracle_ids mode exists to quantify that residual against a batch rebuild.
  • The pre-existing VamanaBuilder (OpenMP build phase) data races observed under TSan are present on main and may deserve a separate issue.

…rden refresh

Delete-time repair (update_repair): tombstone the batch, discover each
deleted node's approximate in-neighbors (out-neighbor symmetry plus an
optional repair_search_l candidate search), rebuild their lists while the
slots are still dark, then release the slots for reuse. SlotAllocator
grows mark_removed()/release() to split tombstoning from free-list entry.

In-degree tracking (track_in_degree): live in-degree counters maintained
across all three neighbor-list write paths, recounted at load. Garden
refresh (garden_refresh): re-links the lowest in-degree live nodes with an
insert-style search under a caller budget.

Bench grows --update_repair / --track_indegree / --garden_* knobs, an
--oracle_ids batch-rebuild reference mode, and a --garden_pass coverage
probe. E2E tests cover the repair contract, churn recall floor, the
in-degree counter invariant, and garden behavior.
…repair robustness

Review fixes on the delete-repair feature:

- Skip the lazy path's safety-net reconnect on the repair branch. Repair's
  discovery set (out-neighbors + optional search) is a superset of the set
  the net scans, so under default arming (5% tombstones / 16 deletes) every
  armed batch paid a full-history scan that could never find an edge to
  fix. Pinned by RepairSkipsSafetyNetUnderDefaultArming, which fails on the
  previous behavior.
- Replace worker-count wave chunks with kUpdateWaveChunk=1024 across repair
  discovery, the repair wave, and garden_refresh: chunking at the worker
  count re-created the intra-batch barrier convoy batch_insert removed and
  never filled the 4x AsyncGate; unbounded when_all would churn the page
  LRU. Bound read_delete_nodes chunks by half the page cache (new
  DiskPageIO::page_cache_capacity()) instead of the worker count.
- Give the repair-mode local pool the same worker count with or without the
  reactor (was 1 with it, serializing discovery searches and RMWs).
- Short-circuit symmetry-only discovery (repair_search_l=0) to a sync loop
  over the in-memory out-neighbor lists.
- Garden refresh now shares the insert path's selection-coords wave
  (extracted wave_selection_coords) and warms the node + pruned pages in
  one wave before its RMW loop, mirroring insert_one.
- Release the batch's slots when repair throws (degrades to lazy-delete
  semantics) instead of leaking them tombstoned off the free list.
- Warn once when fresh slots outrun the in-degree tracking headroom.
- Sort once for in-degree percentiles instead of three nth_element copies.
Copilot AI review requested due to automatic review settings July 10, 2026 08:46
@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

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.

Pull request overview

Adds an opt-in delete-time in-neighbor repair mode to the updatable DiskANN index, along with live in-degree tracking and a budgeted garden_refresh maintenance pass, and extends benchmarks/e2e coverage to exercise the new behaviors.

Changes:

  • Introduces delete-time repair flow (tombstone-first, repair in-neighbors, then release slots) and new load params to control it.
  • Adds optional in-degree tracking plus garden_refresh(budget) and related stats plumbing.
  • Expands benchmark CLI flags and adds multiple e2e tests covering repair, churn, in-degree invariants, and garden behavior.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/diskann/test_diskann_update_e2e.cpp Adds e2e coverage for repair delete semantics, churn recall floors, in-degree invariants, and garden refresh contracts.
tests/diskann/bench_diskann_sift_update.cpp Extends benchmark options/CLI behavior for update repair, in-degree tracking, garden passes, and oracle rebuild evaluation.
include/index/graph/diskann/slot_allocator.hpp Splits delete semantics into tombstone-only (mark_removed) and delayed freelist release (release).
include/index/graph/diskann/diskann_index.hpp Implements repair-mode delete batching, in-degree tracking/recounting, and garden_refresh, plus new instrumentation fields.
include/index/graph/diskann/disk_page_io.hpp Exposes effective page-cache capacity for wave sizing and chunking decisions.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

void mark_removed(uint32_t id) { tombstone_.set(id); }

/// Make a mark_removed() slot reusable after its in-neighbor repair window.
void release(uint32_t id) { free_list_.push_back(id); }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Adopted in d9a6261: release() now re-asserts the tombstone before enqueuing (a no-op in the intended mark_removed() -> release() sequence, which runs entirely under the update serial mutex with no publish possible in between — but the hardening makes the invariant local instead of structural).

Comment on lines +257 to +280
void print_usage(const char *argv0) {
std::cerr << "Usage:\n"
<< " " << argv0 << " [data_dir] [trace_dir] [index_dir] [out_csv] [flags]\n\n"
<< "Flags:\n"
<< " --rebuild force a fresh index build\n"
<< " --deterministic deterministic search barriers\n"
<< " --flush_rounds flush dirty pages after each round\n"
<< " --no_flush_rounds skip per-round dirty-page flush\n"
<< " --no_update_rerank disable update rerank\n"
<< " --update_insert_prune alpha-prune insert candidates\n"
<< " --update_repair enable delete-time in-neighbor repair\n"
<< " --no_update_repair disable delete-time in-neighbor repair\n"
<< " --repair_search_l N repair search list (0 => deleted-node out-neighbors)\n"
<< " --track_indegree track live-slot in-degree percentiles\n"
<< " --garden_budget N refresh up to N low-in-degree live nodes per round\n"
<< " --garden_l N garden search list (0 => use --update_L)\n"
<< " --build_only build the initial index and exit\n"
<< " --eval_only replay live mask and run eval only\n"
<< " --single_updates issue single remove/insert calls\n"
<< " --mixed run mixed update/search workload\n"
<< " --mixed_mode MODE background or shared_queue\n"
<< " --rounds N cap update rounds\n"
<< " --nq N cap eval queries (0 => all)\n";
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Adopted in d9a6261: --garden_pass, --garden_pass_iters and --oracle_ids are now listed in print_usage().

Comment on lines +473 to +490
std::vector<uint64_t> read_ids_file(const std::string &path) {
std::ifstream in(path, std::ios::binary);
if (!in) {
throw std::runtime_error("cannot open oracle ids file: " + path);
}
uint64_t count = 0;
in.read(reinterpret_cast<char *>(&count), sizeof(count));
if (!in) {
throw std::runtime_error("oracle ids file truncated header: " + path);
}
std::vector<uint64_t> labels(count);
in.read(reinterpret_cast<char *>(labels.data()),
static_cast<std::streamsize>(count * sizeof(uint64_t)));
if (!in) {
throw std::runtime_error("oracle ids file truncated body: " + path);
}
return labels;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Adopted in d9a6261: the header count is validated against std::filesystem::file_size before the vector is allocated, so a corrupt ids.bin fails with a clear error instead of a giant allocation.

Comment on lines +1249 to +1251
spdlog::info("DiskANNIndex::load: in-degree recount scanned {} slots in {} us",
counts.size(),
scan_us);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Keeping this at info deliberately: the recount is opt-in (track_in_degree) and is a full neighbor-list scan that can take seconds to minutes on a large index — without the line, load() looks mysteriously slow with no explanation. It fires once per load, and test/embedding contexts already control verbosity via the spdlog level (the e2e fixture runs at warn). Happy to revisit if the project prefers the LOG_* macros from utils/log.hpp for consistency.

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request introduces delete-time in-neighbor graph repair and live in-degree tracking/refresh (gardening) for the DiskANN index. It adds configuration parameters, tracking arrays, and methods to split slot deletion into tombstoning and releasing phases, allowing in-neighbors to be rebuilt before slots are reused. Additionally, it updates benchmarking tools and adds extensive E2E tests to validate these new features. Feedback is provided regarding the kInDegreeRecountChunk constant, which is set to a very high value (65536) and could trigger massive concurrent I/O waves, potentially exhausting kernel resources or causing memory allocation failures; reducing this chunk size to a safer limit is recommended.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

// untracked by the guarded in-degree helpers; brand-new slots are not garden
// targets.
static constexpr uint64_t kInDegreeHeadroomSlots = 1ULL << 20;
static constexpr uint32_t kInDegreeRecountChunk = 65536;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The kInDegreeRecountChunk constant is set to 65536. When recount_in_degree is called during index load, this large chunk size is passed to read_delete_neighbors, which in turn calls read_neighbors_batch_async (when the reactor is enabled). This triggers a massive concurrent I/O wave via wave_load_pages for up to 65,536 pages, allocating a huge contiguous buffer (up to 256 MB) and submitting an excessive number of concurrent requests to io_uring. This can easily exhaust kernel resources, exceed the io_uring submission queue depth, or cause severe memory allocation failures.

To prevent LRU cache thrashing and I/O queue exhaustion, consider reducing kInDegreeRecountChunk to a much safer size (e.g., 1024 to match kUpdateWaveChunk or 4096).

Suggested change
static constexpr uint32_t kInDegreeRecountChunk = 65536;
static constexpr uint32_t kInDegreeRecountChunk = 1024;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Partially adopted in d9a6261 — the constant is now 8192, though not for the SQ-depth reason: UringReactor::read_batch submits in ring-depth chunks by contract (see the reactor header notes), so a large wave cannot exceed the submission queue, and the recount reads land in a private wave buffer (reconciled afterwards), not the prefetch LRU. The real bound is the ONE contiguous aligned buffer backing each wave's unique pages plus the in-flight count: at 65536 consecutive ids that is ~13k pages (~51 MiB) for SIFT-128 and up to 256 MiB when a node fills a page (high-dim). 8192 caps the worst case at 32 MiB while keeping waves far above the ring depth, which is what actually saturates the one-time load scan — 1024 would give up scan throughput without buying extra safety.

…(), bench arg validation

- kInDegreeRecountChunk 65536 -> 8192: each recount wave backs its unique
  pages with one contiguous aligned buffer and keeps them all in flight (the
  reactor already submits in ring-depth chunks), so the real bound is wave
  memory — 8192 ids cap the worst case (one node per page) at 32 MiB while
  staying far above the ring depth that saturates the one-time scan.
- SlotAllocator::release() re-asserts the tombstone (no-op in the intended
  sequence) so misuse on a live slot cannot leave it searchable + allocatable.
- bench: validate the ids.bin header count against the file size before
  allocating; document --garden_pass/--garden_pass_iters/--oracle_ids in
  print_usage.
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.

2 participants