1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
//! Sharded ID mappings for HNSW index using `DashMap`.
//!
//! This module provides lock-free concurrent bidirectional mapping between
//! external IDs (u64) and internal HNSW indices (usize).
//!
//! # Performance characteristics
//!
//! - **Lock-free reads**: O(1) lookups without blocking
//! - **Sharded writes**: Minimal contention on parallel insertions
//! - **No allocation**: the graph's arena hands out every slot; the mappings
//! record where each id landed ([`ShardedMappings::assign`], #2246)
//!
//! # EPIC-A.1: Integrated into `HnswIndex`
use std::sync::atomic::{AtomicUsize, Ordering};
use dashmap::DashMap;
use super::native_inner::Placed;
/// Lock-free sharded ID mappings for HNSW index.
///
/// Uses `DashMap` internally for concurrent access without global locks.
/// This enables linear scaling on multi-core systems.
///
/// # Example
///
/// ```rust,ignore
/// // Crate-internal: `ShardedMappings` is not exported.
/// let mappings = ShardedMappings::new();
/// // `placed` is the token a graph placement returns for slot 0: see `Placed`.
/// mappings.assign(42, placed);
/// assert_eq!(mappings.get_idx(42), Some(0));
/// ```
#[derive(Debug)]
pub struct ShardedMappings {
/// Mapping from external IDs to internal indices (lock-free).
id_to_idx: DashMap<u64, usize>,
/// Mapping from internal indices to external IDs (lock-free).
idx_to_id: DashMap<usize, u64>,
/// One past the highest slot ever assigned, or the slot count `clear_for`
/// was given if higher; never decreases until the next `clear_for`.
next_idx: AtomicUsize,
/// Whether any external id above `u32::MAX` was ever registered.
///
/// Gates the O(N) overflow-id sweep in the bitmap brute-force path: with
/// no overflow ids (virtually every deployment) that sweep is a pure
/// full-DashMap scan per filtered query. Sticky by design — removing the
/// last overflow id keeps the flag set until the index is reloaded, which
/// costs the sweep again but never correctness.
has_overflow_ids: std::sync::atomic::AtomicBool,
}
impl Default for ShardedMappings {
fn default() -> Self {
Self::new()
}
}
impl ShardedMappings {
/// Creates new empty sharded mappings.
#[must_use]
pub fn new() -> Self {
Self {
id_to_idx: DashMap::new(),
idx_to_id: DashMap::new(),
next_idx: AtomicUsize::new(0),
has_overflow_ids: std::sync::atomic::AtomicBool::new(false),
}
}
/// Creates mappings with pre-allocated capacity.
///
/// Use this when the expected number of vectors is known upfront.
#[must_use]
pub fn with_capacity(capacity: usize) -> Self {
Self {
id_to_idx: DashMap::with_capacity(capacity),
idx_to_id: DashMap::with_capacity(capacity),
next_idx: AtomicUsize::new(0),
has_overflow_ids: std::sync::atomic::AtomicBool::new(false),
}
}
/// Records `id` into the overflow flag (see `has_overflow_ids`).
#[inline]
fn note_id(&self, id: u64) {
if u32::try_from(id).is_err() {
self.has_overflow_ids
.store(true, std::sync::atomic::Ordering::Release);
}
}
/// Returns `true` if any id above `u32::MAX` was ever registered.
#[inline]
#[must_use]
pub fn has_overflow_ids(&self) -> bool {
self.has_overflow_ids
.load(std::sync::atomic::Ordering::Acquire)
}
/// Removes a stale reverse mapping (`idx` -> `id`) without touching the forward mapping.
///
/// Removal is conditional: the entry goes only while it still names
/// `expected_id`, so retiring a slot can never erase another id's mapping.
pub(super) fn remove_reverse(&self, idx: usize, expected_id: u64) {
use dashmap::mapref::entry::Entry;
let Entry::Occupied(entry) = self.idx_to_id.entry(idx) else {
return;
};
if *entry.get() == expected_id {
entry.remove();
}
}
/// Maps `id` to the slot `placed` names — the arena slot its vector
/// already occupies — and returns the slot it held before, when that was
/// another.
///
/// The one way an insert registers an id: the vector is placed first, by
/// whoever owns the arena's allocation, and the mapping follows the slot it
/// got. Nothing predicts a slot, so nothing can hand one slot to two ids
/// (#2246). The previous slot's reverse entry is retired, which is what
/// turns the old graph node into a tombstone search filters out.
///
/// `placed` borrows the graph guard its placement ran under, so the slot
/// cannot be renumbered before it is mapped (see `Placed`). `next_idx` is
/// raised to at least `slot + 1`, so it stays an upper bound on every slot
/// in use for its readers (vacuum, persistence).
pub(crate) fn assign(&self, id: u64, placed: Placed<'_>) -> Option<usize> {
use dashmap::mapref::entry::Entry;
let slot = placed.into_slot();
// Checked before either map changes. The writes then run under the
// shard lock over the id's entry, in order (forward entry, retire the
// old reverse entry, claim the new one), so assigns of one id
// serialize. `remove` drops its two entries one at a time and may
// interleave, but never with a renumber: its callers hold the graph's
// read guard. Once writers settle, each id is named by its own slot
// alone.
self.refuse_foreign_slot(slot, id);
self.note_id(id);
self.next_idx
.fetch_max(slot.saturating_add(1), Ordering::Relaxed);
match self.id_to_idx.entry(id) {
Entry::Occupied(mut entry) => {
let old = entry.insert(slot);
if old != slot {
self.remove_reverse(old, id);
}
self.idx_to_id.insert(slot, id);
(old != slot).then_some(old)
}
Entry::Vacant(entry) => {
// `insert` hands the shard lock to the reference it returns.
// The reverse write reads the slot through it, so the lock is
// held until that write is done, as in the occupied arm.
let forward = entry.insert(slot);
self.idx_to_id.insert(*forward, id);
drop(forward);
None
}
}
}
/// Refuses, in a debug build, a slot whose reverse entry names another id.
///
/// The arena hands every slot out once per arena (a vacuum rebuilds the
/// arena and clears the mappings under the same lock), so such a slot
/// means a broken caller, not a move. The check only reads, and runs
/// before either map changes; release builds skip it.
fn refuse_foreign_slot(&self, slot: usize, id: u64) {
if cfg!(debug_assertions) {
if let Some(owner) = self.idx_to_id.get(&slot).map(|owner| *owner) {
assert_eq!(owner, id, "slot {slot} already belongs to id {owner}");
}
}
}
/// Removes an ID and returns its internal index if it existed.
///
/// The two writes are not atomic: callers hold the graph's read guard
/// (`soft_delete` borrows the graph), so no renumber runs between them.
pub fn remove(&self, id: u64) -> Option<usize> {
if let Some((_, idx)) = self.id_to_idx.remove(&id) {
self.idx_to_id.remove(&idx);
Some(idx)
} else {
None
}
}
/// Gets the internal index for an external ID.
///
/// This is a lock-free read operation.
#[must_use]
pub fn get_idx(&self, id: u64) -> Option<usize> {
self.id_to_idx.get(&id).map(|r| *r)
}
/// Gets the external ID for an internal index.
///
/// This is a lock-free read operation.
#[must_use]
pub fn get_id(&self, idx: usize) -> Option<u64> {
self.idx_to_id.get(&idx).map(|r| *r)
}
/// Returns the number of registered IDs.
#[must_use]
pub fn len(&self) -> usize {
self.id_to_idx.len()
}
/// Returns true if no IDs are registered.
#[must_use]
pub fn is_empty(&self) -> bool {
self.id_to_idx.is_empty()
}
/// Checks if an ID is registered.
#[must_use]
pub fn contains(&self, id: u64) -> bool {
self.id_to_idx.contains_key(&id)
}
/// Returns an iterator over all (id, idx) pairs.
///
/// Note: This acquires read locks on shards during iteration.
pub fn iter(&self) -> impl Iterator<Item = (u64, usize)> + '_ {
self.id_to_idx.iter().map(|r| (*r.key(), *r.value()))
}
/// One past the highest slot ever assigned, or the slot count `clear_for`
/// was given if higher.
///
/// Never decreases, even after removals, so `next_idx() - len()` counts
/// the slots below it that no id names any more.
#[must_use]
pub fn next_idx(&self) -> usize {
self.next_idx.load(std::sync::atomic::Ordering::Relaxed)
}
/// Clears all mappings and sets `next_idx` to `slots`, the slot count of
/// the arena they are about to name.
///
/// `vacuum` installs a new graph and maps into it only the ids still live,
/// so a slot of that graph no id comes to name is dead, wherever it falls.
/// `next_idx` has to cover it for `next_idx() - len()` to count it (#2262):
/// reset to 0 and raised by each `assign`, it would stop at the highest
/// slot mapped and leave out the dead slots above it.
pub(crate) fn clear_for(&self, slots: usize) {
self.id_to_idx.clear();
self.idx_to_id.clear();
self.next_idx.store(slots, Ordering::Relaxed);
}
/// Renumbers every internal index through `old_to_new`.
///
/// The graph owns the node numbering, and a BFS locality reorder changes
/// it. Both directions of this map are keyed by that numbering, so they
/// have to move with it — `vacuum` renumbers too and rebuilds them from
/// scratch for the same reason. Skipping it does not fail: every lookup
/// still resolves, to a different vector than the one asked for, so a
/// query returns confident, wrong answers (#2112).
///
/// `old_to_new[i]` is the new index of the node that was `i`; a reorder
/// that succeeds covers every slot of the arena.
///
/// Tombstoned nodes need no special case — deletion removes the mapping
/// and leaves the node, so they simply have no entry to move.
///
/// Not atomic against concurrent readers. The caller holds the index
/// write lock across the graph permutation and this call, which is what
/// keeps a search from observing the half-renumbered state.
pub fn remap_indices(&self, old_to_new: &[usize]) {
let renumber = |idx: usize| old_to_new.get(idx).copied().unwrap_or(idx);
let moved: Vec<(u64, usize)> = self
.id_to_idx
.iter()
.map(|entry| (*entry.key(), renumber(*entry.value())))
.collect();
self.id_to_idx.clear();
self.idx_to_id.clear();
for (id, idx) in moved {
self.id_to_idx.insert(id, idx);
self.idx_to_id.insert(idx, id);
}
}
/// Creates mappings from existing data (for deserialization).
///
/// # Arguments
///
/// * `id_to_idx` - Map from external IDs to internal indices
/// * `idx_to_id` - Map from internal indices to external IDs
/// * `next_idx` - One past the highest slot ever assigned
#[must_use]
pub fn from_parts(
id_to_idx: std::collections::HashMap<u64, usize>,
idx_to_id: std::collections::HashMap<usize, u64>,
next_idx: usize,
) -> Self {
let sharded_id_to_idx = DashMap::with_capacity(id_to_idx.len());
let sharded_idx_to_id = DashMap::with_capacity(idx_to_id.len());
let mut has_overflow = false;
for (id, idx) in id_to_idx {
has_overflow |= u32::try_from(id).is_err();
sharded_id_to_idx.insert(id, idx);
}
for (idx, id) in idx_to_id {
sharded_idx_to_id.insert(idx, id);
}
Self {
id_to_idx: sharded_id_to_idx,
idx_to_id: sharded_idx_to_id,
next_idx: AtomicUsize::new(next_idx),
has_overflow_ids: std::sync::atomic::AtomicBool::new(has_overflow),
}
}
/// Both maps and `next_idx`, cloned, each map in its own pass.
///
/// For tests, which read the live reverse map through it. A save does not
/// use it: with writers running, a write between the two passes leaves maps
/// that disagree, so a save copies the forward map alone and derives the
/// reverse (see `persistence::dump_graph`).
///
/// # Returns
///
/// Tuple of (`id_to_idx`, `idx_to_id`, `next_idx`).
#[cfg(test)]
#[must_use]
pub fn as_parts(
&self,
) -> (
std::collections::HashMap<u64, usize>,
std::collections::HashMap<usize, u64>,
usize,
) {
let id_to_idx: std::collections::HashMap<u64, usize> = self
.id_to_idx
.iter()
.map(|r| (*r.key(), *r.value()))
.collect();
let idx_to_id: std::collections::HashMap<usize, u64> = self
.idx_to_id
.iter()
.map(|r| (*r.key(), *r.value()))
.collect();
let next_idx = self.next_idx.load(Ordering::SeqCst);
(id_to_idx, idx_to_id, next_idx)
}
}