Skip to content

[MOD-18494] Stop a relabel mid-query from reporting one vector twice - #1047

Draft
nonirosenfeldredis wants to merge 3 commits into
mainfrom
sharon-tiered-relabel-query-race
Draft

[MOD-18494] Stop a relabel mid-query from reporting one vector twice#1047
nonirosenfeldredis wants to merge 3 commits into
mainfrom
sharon-tiered-relabel-query-race

Conversation

@nonirosenfeldredis

@nonirosenfeldredis nonirosenfeldredis commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Describe the changes in the pull request

A tiered query reads the flat buffer, releases flatIndexGuard, then reads the main index, and merge_result_lists collapses a vector that both tiers report by matching labels. A relabel landing in that window defeats the match: the flat half of the pair still says old_label while the main half now says new_label, so the merge keeps both and the caller gets one vector twice, under a pair of labels the index never held at the same time.

The label-keyed merge is load-bearing for the migration race it was written for — TopKQuery_SameVectorDifferentScores_DueToQuantization pins that — and it works there only because migration preserves the label. relabelVector (#1017) moves the very key the merge rests on.

Replay, not just detection. recordRelabel appends {old, new, epoch} under the guards that made the move. A query samples the epoch while the flat guard still covers its snapshot — so the sample names exactly the state its results came from — and after the main read replays every recorded move newer than that sample onto both lists. A flat result under a label that has since moved is rewritten to the label it moved to, and the label-keyed merge collapses it as it always did.

Details that matter:

  • Chains are followed. A label that moved twice in one window (A→B→C) must land on C; stopping at B matches nothing in the main results and duplicates just the same. The walk is bounded by the number of moves, which also stops a cycle (A→B then B→A) spinning.
  • Re-sorted before merging. Rewriting labels can break the by-id tie-break merge_result_lists assumes.
  • Fixed ring, so it never allocates and never grows. Past capacity the oldest moves are dropped, and a query whose snapshot is no longer covered falls back to a pinned re-read (flat guard held across both reads, which excludes relabel outright). The epoch is the version stamp; the pin is the backstop.

Why record rather than only detect. A reader that cannot restart can still consult the log. That is the route to covering TieredHNSW_BatchIterator, which merges the tiers incrementally across many calls and which redo-on-detect cannot help. Not wired up here — the mechanism now exists for it.

Rejected: keying the log off the flat index and reading it with the flat snapshot. Both halves fail. At flat-read time the relabel has not happened yet, so the list comes back empty — the query needs to know about a move in its future. And a relabel touching only the main index (reachable when the snapshot predates migration) would never be recorded by a flat-scoped log at all.

Rejected alternative. Validating flat-sourced results against current label state after the main read also fixes this, but it additionally drops stale results for concurrently deleted labels — a behaviour change well outside relabel's scope.

Not covered. TieredHNSW_BatchIterator merges the tiers incrementally across many calls, so redo-on-detect does not map onto it; a relabel concurrent with a live batch iterator can still duplicate. Flagging rather than papering over it.

Tests. Two tests drive a relabel into the window via a new BUILD_TESTS hook, one for topK and one for range. Each asserts the vector is reported once and that the retry actually fired, so they fail loudly rather than passing on the fast path if the window moves. The hook deliberately does not fire on the pinned retry — there is no window there, and injecting one would self-deadlock against the guard the querying thread holds.

Verified by mutation, twice. With the replay disabled, all three duplicate tests report Which is: 2 — the bug. With the chain walk cut to a single step, the chain test alone fails while the other two pass, so each test discriminates rather than merely passing.

Full suites green: test_hnsw 191/191, test_hnsw_parallel 4/4, test_hnsw_sq8 55/55, test_svs 376 passed / 101 skipped (pre-existing SVS gating) / 0 failed. make check-format clean.

Which issues this PR fixes

  1. MOD-18494
  2. Found while reviewing the disk relabel implementation in RediSearchEnterprise#967 — the same defect, since vecsim_disk reuses this merge. Fixing the RAM path first; the disk index inherits the query shape.

Main objects this PR modified

  1. VecSimTieredIndexrelabelEpoch + bumpRelabelEpoch(); topKQuery/rangeQuery sample the epoch and redo once; topKQueryImp/rangeQueryImp take pin_flat; FlatGuardRelease releases the pinned guard on the early-return paths.
  2. TieredHNSWIndex::relabelVector — bumps the epoch under both guards.
  3. tests/unit/test_hnsw_tiered.cpprelabelDuringQueryDoesNotDuplicate, relabelDuringRangeQueryDoesNotDuplicate.

Mark if applicable

  • This PR introduces API changes
  • This PR introduces serialization changes

🤖 Generated with Claude Code

A tiered query reads the flat buffer, releases its guard, then reads the main
index, and `merge_result_lists` collapses a vector that both tiers report by
matching labels. A relabel landing in that window defeats the match: the flat
half of the pair still says `old_label` while the main half now says
`new_label`, so the merge keeps both and the caller gets one vector twice, under
a pair of labels the index never held at the same time.

The label-keyed merge is load-bearing for the migration race it was written for
-- `TopKQuery_SameVectorDifferentScores_DueToQuantization` pins that -- and it
holds there only because migration preserves the label. Relabel moves the very
key the merge rests on.

Detection rather than exclusion. `relabelEpoch` is bumped by every relabel that
moves a label, while it still holds both guards; a query samples it around its
pair of reads and redoes the read if it changed. The redo passes `pin_flat`,
holding the flat guard across both reads, which is what makes the window absent
the second time: relabel needs that guard exclusively. So the retry is bounded
at one -- no loop, no livelock.

The fast path costs two atomic loads. Pinning is only paid when a relabel
actually interleaved, and it cannot deadlock: no path takes the flat guard while
holding the main guard, so a query holding flat-then-main cannot cycle against
relabel's flat-then-main.

Rejected: validating flat-sourced results against current label state after the
main read. It fixes this, but it also drops stale results for concurrently
*deleted* labels -- a behaviour change well outside relabel's scope.

Not covered: `TieredHNSW_BatchIterator` merges the tiers incrementally across
many calls, so redo-on-detect does not map onto it. A relabel concurrent with a
live batch iterator can still duplicate.

Tests drive a relabel into the window through a new BUILD_TESTS hook, for topK
and range. Both assert the vector is reported once and that the retry actually
fired, so they fail loudly rather than passing on the fast path if the window
moves. Verified by mutation: with the epoch bump removed both report
`Which is: 2`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@nonirosenfeldredis nonirosenfeldredis changed the title Stop a relabel mid-query from reporting one vector twice [MOD-18494] Stop a relabel mid-query from reporting one vector twice Sep 10, 2026
nonirosenfeldredis and others added 2 commits September 10, 2026 15:50
Detection alone forces a re-read, which only works for a reader that can start
over. Record what each relabel did and the merge can repair the pair it was
handed: a flat result under a label that has since moved is rewritten to the
label it moved to, so the label-keyed merge collapses it against the main
result as it always did.

`recordRelabel` appends {old, new, epoch} under the guards that made the move.
A query samples the epoch while the flat guard still covers its snapshot, so
the sample names exactly the state its results came from, and after the main
read it replays every recorded move newer than that sample onto both lists.

Chains are followed: a label that moved twice inside one window (A->B->C) must
land on C, since stopping at B matches nothing in the main results and
duplicates just the same. The walk is bounded by the number of moves, which
also stops a cycle (A->B then B->A) from spinning. Rewriting labels can break
the by-id tie-break the merge assumes, so both lists are re-sorted first.

The log is a fixed ring, so it never allocates and never grows. Past its
capacity the oldest moves are dropped, and a query whose snapshot is no longer
covered falls back to the pinned re-read from the previous commit, which is
always correct. That keeps the previous mechanism as the fallback rather than
replacing it: the epoch is the version stamp, the pin is the backstop.

Why record rather than only detect: a reader that cannot restart can still
consult the log. That is the route to covering `TieredHNSW_BatchIterator`,
which merges the tiers incrementally across many calls and which redo-on-detect
cannot help. Not wired up here.

Rejected: keying the log off the flat buffer and reading it with the flat
snapshot. Both fail. At flat-read time the relabel has not happened yet, so the
list is empty; and a relabel that touches only the main index -- reachable when
the snapshot predates migration -- would never be recorded at all.

Tests add the chain case and the overflow fallback, and the two duplicate tests
now assert the log served them without a re-read. Verified by mutation: with
the replay disabled all three report `Which is: 2`; with the chain walk cut to
one step the chain test alone fails, so each test discriminates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The batch iterator holds `mainIndexGuard` shared from its first call until the
HNSW iterator depletes, and a relabel needs that guard exclusively. So for all
but the first call a relabel simply cannot run, and the iterator has exactly one
window: between releasing the flat guard and taking the main one on that first
call. A relabel there moves a label out from under the flat snapshot already
taken, and the iterator then reports the same vector once per label across its
batches.

Take the main guard before releasing the flat one and the window is gone: a
relabel needs both and can hold neither. The flat->main order is the one every
other acquisition already uses, so overlapping them cannot deadlock.

This is why the iterator needs no part of the relabel log. It cannot start over,
which is what the log was meant to buy it -- but it does not have to, because it
already excludes relabel for its whole life and only the seam between its two
acquisitions was open. The log stays for the queries, which genuinely release
both guards mid-read.

The test drives the relabel from another thread, since the hook now runs while
the flat guard is held and relabeling inline would deadlock against it. It
blocks on the flat guard, then on the main guard for the rest of the iteration,
and completes only once the iterator is freed -- so the iteration stays
consistent with the snapshot it took. Verified by mutation: restoring the
original release order makes it report `Which is: 2`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nonirosenfeldredis added a commit that referenced this pull request Sep 11, 2026
`SVSIndex::relabelVector` moved a label, but wrapping that index in a tier did
not inherit the ability: neither `TieredSVSIndex` nor `VecSimTieredIndex`
overrode `relabelVector`, so a tiered SVS index fell through to the interface
default and answered `Unsupported`. That is correct for a caller which honours
the code, but it meant a tiered SVS index never took the relabel path however
capable its backend was -- and tiered is how it is used.

Both tiers are asked, because a multi-value label routinely has copies in each
and a target taken in either would collide once the buffer drains. The backend
moves first, since it is the tier that can refuse -- `Unsupported` when built
against an SVS without `replace_external_id` -- and refusing after the buffer
had moved would leave the label half applied.

`updateJobMutex` is taken first, in the order `updateSVSIndex` takes its own
locks. That is not defensive: an update job snapshots the buffer's labels *by
value* and afterwards reconciles only id swaps and deletions, so a rename
landing between the snapshot and the backend insert is invisible to it and the
vector reaches the backend under the old label, leaving it under both.

All three guards are held across the checks and the mutations. Checking under
shared locks and reacquiring exclusively would be cheaper for rejections, but
`std::shared_mutex` cannot upgrade, and async `addVector` and `deleteVector`
need neither `updateJobMutex` nor a held guard -- so either could land in the
gap and leave the move applied to one tier only.

Tests mirror what insertion has for this mode. `MovesTheLabelInBothWriteStates`
covers buffered-with-a-pending-job and moved-to-the-backend, the states
`addVector` and `insertJob` cover; `RejectsOnATier` covers each code, with the
taken target tried in both tiers; `DuringUpdateJob` is the `insertJobAsync`
analogue, relabelling 200 labels against live workers. The fixture's type set
spans single, multi and Quant_8, so this also answers whether a compressed
backend can move a label: it can, since `replace_external_id` renames an id and
never touches the stored vector.

`CannotLandInsideAnUpdateJobsWindow` is what pins the mutex, via the
`UpdateJob::before_add_to_svs` tracing hook that sits exactly in the window.
Worth stating why it exists: `DuringUpdateJob` still passes with the mutex
removed, so concurrency alone does not demonstrate the need. The hook test
fails, reporting a label count of 2 for one vector. It relabels from another
thread, since the job holds the mutex throughout and relabelling inline would
block on a lock the thread already holds.

Not added: relabel concurrent with a query. The tiers share
`VecSimTieredIndex::topKQueryImp`, so SVS inherits the cross-tier duplicate that
#1047 fixes, and such a test belongs with that fix rather than failing here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nonirosenfeldredis added a commit that referenced this pull request Sep 11, 2026
The relabel counterpart to `test_parallel_insert_search`: the operation on one
thread, queries on another, with the relabels aimed at labels still being
ingested so a label is briefly held by both tiers.

The two tests differ in what they can assert, and that is the point. Inserting
concurrently makes a query legitimately miss vectors, so the insert test can
only check that recall did not regress. A relabel adds and removes nothing, so
the invariant is exact: a reply must never list one label twice. That is the
defect -- `merge_result_lists` collapses a vector both tiers report by matching
labels, and a relabel inside a query's window moves that key, so the two copies
survive as one vector under two labels.

Skipped on this branch. Not because the assertion is unsound -- a duplicate
label in a reply is always wrong -- but because the fix is on another branch
(#1047, MOD-18494) and the failure is intermittent, so leaving it live would
redden this PR's CI for a defect it did not introduce. Remove the marker once
that lands; nothing else about the test changes.

Being a canary is the honest description: the window is a few instructions, so
it can pass on a broken build. It cannot report a failure that is not real,
which is the acceptable direction. The deterministic coverage is the hook-based
C++ tests on #1047.

`import pytest` is added because `common.py` does not import it and the skip
marker needs it -- the same omission that bit `test_svs.py` earlier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nonirosenfeldredis added a commit that referenced this pull request Sep 12, 2026
A test that never runs earns nothing. It was added skipped because the fix it
depends on is on another branch, which makes it dead weight here: it cannot
catch a regression, and the marker is one more thing to notice and remove later.
The deterministic coverage for this is the hook-based C++ tests on #1047, which
is also where a flow-level version belongs once that lands.

`import pytest` goes with it; nothing else in the file used it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant