@harperfast/hnsw 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/graph.rs CHANGED
@@ -6,14 +6,43 @@
6
6
 
7
7
  use crate::distance::{cosine_i8_i8_raw, cosine_int8_raw, Query};
8
8
  use crate::format::{
9
- PlaneFile, FLAG_DELETED, FLAG_VALID, MAX_UPPER_LEVELS, NO_UPPER, S_DEGREE, S_FLAGS, S_INV_MAG, S_LEVEL, S_SCALE,
10
- S_UPPER_IDX, S_VECTOR, UPPER_CAP, UPPER_LEVEL_STRIDE, U_LEVELS, U_LISTS,
9
+ neighbor_offset, PlaneFile, FLAG_DELETED, FLAG_VALID, MAX_UPPER_LEVELS, NO_UPPER, S_DEGREE, S_FLAGS, S_INV_MAG,
10
+ S_LEVEL, S_SCALE, S_UPPER_IDX, S_VECTOR, UPPER_CAP, UPPER_LEVEL_STRIDE, UL_DEGREE, UL_IDS, U_LEVELS, U_LISTS,
11
11
  };
12
12
  use crate::seqlock;
13
13
  use crate::seqlock::Wedged;
14
14
 
15
+ /// Aligned volatile load of a slot/upper-entry field another process may be mutating.
16
+ ///
17
+ /// This forbids the optimizer from duplicating, splitting, or sinking the load across the
18
+ /// seqlock's validating fence, which would let a reader act on bytes the generation check
19
+ /// never covered. It does NOT make the access race-free under Rust's memory model — only
20
+ /// atomics would, and that is the format change DESIGN.md §10 records as
21
+ /// follow-up. The vector is deliberately not read this way: `cosine_int8_raw` must stay
22
+ /// autovectorized, and a torn vector only perturbs a distance the generation check discards.
23
+ /// Every field read here is naturally aligned (slots are 64-aligned; the neighbor and upper
24
+ /// id arrays are 4-padded by format.rs), so these compile to single loads.
25
+ #[inline(always)]
26
+ unsafe fn vread<T: Copy>(p: *const T) -> T {
27
+ p.read_volatile()
28
+ }
29
+
15
30
  pub struct Graph {
16
31
  pub file: PlaneFile,
32
+ /// Rotates `probe_for_entry`'s starting offset so this plane's consecutive repairs sample
33
+ /// different ids. Per handle, not per process: a shared counter is advanced by every other
34
+ /// plane's repairs too, so one plane's calls can land on a single residue indefinitely —
35
+ /// which is the coverage the rotation exists to provide.
36
+ probe_rotation: std::sync::atomic::AtomicU32,
37
+ /// (high-water, write epoch) at which this handle's last `stride` consecutive probes all
38
+ /// came back empty, so a fully dead graph stops paying the probe; any handle's node write
39
+ /// bumps the header epoch and re-arms it.
40
+ probe_futile_hw: std::sync::atomic::AtomicU64,
41
+ probe_futile_epoch: std::sync::atomic::AtomicU64,
42
+ probe_futile_runs: std::sync::atomic::AtomicU32,
43
+ /// One repair probe at a time per handle: concurrent searches on the pool would each pay
44
+ /// the full walk before one of them publishes.
45
+ probe_in_flight: std::sync::atomic::AtomicBool,
17
46
  }
18
47
 
19
48
  /// A consistent full copy of one node (construction paths only; search uses zero-copy).
@@ -27,7 +56,19 @@ pub struct NodeRead {
27
56
 
28
57
  impl Graph {
29
58
  pub fn new(file: PlaneFile) -> Self {
30
- Graph { file }
59
+ Graph {
60
+ file,
61
+ probe_rotation: std::sync::atomic::AtomicU32::new(0),
62
+ probe_futile_hw: std::sync::atomic::AtomicU64::new(u64::MAX),
63
+ probe_futile_epoch: std::sync::atomic::AtomicU64::new(u64::MAX),
64
+ probe_futile_runs: std::sync::atomic::AtomicU32::new(0),
65
+ probe_in_flight: std::sync::atomic::AtomicBool::new(false),
66
+ }
67
+ }
68
+
69
+ #[inline]
70
+ fn node_written(&self) {
71
+ self.file.bump_write_epoch();
31
72
  }
32
73
 
33
74
  #[inline]
@@ -67,12 +108,12 @@ impl Graph {
67
108
  seqlock::read_consistent(seq, self.file.self_tag, || {
68
109
  let p = self.file.slot_ptr(id);
69
110
  unsafe {
70
- let flags = *p.add(S_FLAGS);
111
+ let flags = vread(p.add(S_FLAGS));
71
112
  if flags & FLAG_VALID == 0 || flags & FLAG_DELETED != 0 {
72
113
  return None;
73
114
  }
74
- let scale = (p.add(S_SCALE) as *const f32).read_unaligned();
75
- let inv_mag = (p.add(S_INV_MAG) as *const f32).read_unaligned();
115
+ let scale = vread(p.add(S_SCALE) as *const f32);
116
+ let inv_mag = vread(p.add(S_INV_MAG) as *const f32);
76
117
  Some(cosine_int8_raw(query, p.add(S_VECTOR) as *const i8, scale, inv_mag))
77
118
  }
78
119
  }, self.slot_sanitizer(id), || None, self.owner_dead())
@@ -119,20 +160,20 @@ impl Graph {
119
160
  }
120
161
  let seq = self.file.seq_atomic(id);
121
162
  let cap = self.file.layer0_cap;
122
- let dims = self.file.dims;
163
+ let nbase = neighbor_offset(self.file.dims);
123
164
  seqlock::read_consistent(seq, self.file.self_tag, || {
124
165
  out.clear();
125
166
  let p = self.file.slot_ptr(id);
126
167
  unsafe {
127
- let flags = *p.add(S_FLAGS);
168
+ let flags = vread(p.add(S_FLAGS));
128
169
  if flags & FLAG_VALID == 0 || flags & FLAG_DELETED != 0 {
129
170
  return None;
130
171
  }
131
- let level = *p.add(S_LEVEL);
132
- let degree = u16::from_le((p.add(S_DEGREE) as *const u16).read_unaligned()) as usize;
133
- let base = p.add(S_VECTOR + dims) as *const u32;
172
+ let level = vread(p.add(S_LEVEL));
173
+ let degree = u16::from_le(vread(p.add(S_DEGREE) as *const u16)) as usize;
174
+ let base = p.add(nbase) as *const u32;
134
175
  for i in 0..degree.min(cap) {
135
- out.push(u32::from_le(base.add(i).read_unaligned()));
176
+ out.push(u32::from_le(vread(base.add(i))));
136
177
  }
137
178
  Some(level)
138
179
  }
@@ -149,11 +190,11 @@ impl Graph {
149
190
  seqlock::read_consistent(seq, self.file.self_tag, || {
150
191
  let p = self.file.slot_ptr(id);
151
192
  unsafe {
152
- let flags = *p.add(S_FLAGS);
193
+ let flags = vread(p.add(S_FLAGS));
153
194
  if flags & FLAG_VALID == 0 || flags & FLAG_DELETED != 0 {
154
195
  return NO_UPPER;
155
196
  }
156
- (p.add(S_UPPER_IDX) as *const u32).read_unaligned()
197
+ vread(p.add(S_UPPER_IDX) as *const u32)
157
198
  }
158
199
  }, self.slot_sanitizer(id), || NO_UPPER, self.owner_dead())
159
200
  }
@@ -172,15 +213,15 @@ impl Graph {
172
213
  out.clear();
173
214
  let p = self.file.upper_ptr(idx);
174
215
  unsafe {
175
- let levels = *p.add(U_LEVELS);
216
+ let levels = vread(p.add(U_LEVELS));
176
217
  if level > levels {
177
218
  return false;
178
219
  }
179
220
  let lp = p.add(U_LISTS + (level as usize - 1) * UPPER_LEVEL_STRIDE);
180
- let degree = u16::from_le((lp as *const u16).read_unaligned()) as usize;
181
- let base = lp.add(2) as *const u32;
221
+ let degree = u16::from_le(vread(lp.add(UL_DEGREE) as *const u16)) as usize;
222
+ let base = lp.add(UL_IDS) as *const u32;
182
223
  for i in 0..degree.min(UPPER_CAP) {
183
- out.push(u32::from_le(base.add(i).read_unaligned()));
224
+ out.push(u32::from_le(vread(base.add(i))));
184
225
  }
185
226
  true
186
227
  }
@@ -206,8 +247,8 @@ impl Graph {
206
247
  for (l, list) in levels.iter().take(n).enumerate() {
207
248
  let lp = p.add(U_LISTS + l * UPPER_LEVEL_STRIDE);
208
249
  let deg = list.len().min(UPPER_CAP);
209
- (lp as *mut u16).write_unaligned((deg as u16).to_le());
210
- let base = lp.add(2) as *mut u32;
250
+ (lp.add(UL_DEGREE) as *mut u16).write_unaligned((deg as u16).to_le());
251
+ let base = lp.add(UL_IDS) as *mut u32;
211
252
  for (i, id) in list.iter().take(deg).enumerate() {
212
253
  base.add(i).write_unaligned(id.to_le());
213
254
  }
@@ -229,8 +270,8 @@ impl Graph {
229
270
  for (l, list) in levels.iter().take(n).enumerate() {
230
271
  let lp = p.add(U_LISTS + l * UPPER_LEVEL_STRIDE);
231
272
  let deg = list.len().min(UPPER_CAP);
232
- (lp as *mut u16).write_unaligned((deg as u16).to_le());
233
- let base = lp.add(2) as *mut u32;
273
+ (lp.add(UL_DEGREE) as *mut u16).write_unaligned((deg as u16).to_le());
274
+ let base = lp.add(UL_IDS) as *mut u32;
234
275
  for (i, id) in list.iter().take(deg).enumerate() {
235
276
  base.add(i).write_unaligned(id.to_le());
236
277
  }
@@ -246,25 +287,27 @@ impl Graph {
246
287
  return false;
247
288
  }
248
289
  let seq = self.file.seq_atomic(id);
249
- seqlock::read_consistent(seq, self.file.self_tag, || unsafe { *self.file.slot_ptr(id).add(S_FLAGS) != 0 }, self.slot_sanitizer(id), || true, self.owner_dead())
290
+ seqlock::read_consistent(seq, self.file.self_tag, || unsafe { vread(self.file.slot_ptr(id).add(S_FLAGS)) != 0 }, self.slot_sanitizer(id), || true, self.owner_dead())
250
291
  }
251
292
 
252
- /// The slot's stored upper idx regardless of valid/deleted flags the raw mirroring
253
- /// path reuses a cleared node's entry when the host rewrites the same id.
254
- fn upper_idx_raw(&self, id: u32) -> u32 {
293
+ /// The slot's stored upper idx regardless of valid/deleted flags. Taken under the slot
294
+ /// write lock rather than `read_consistent`, whose NO_UPPER fallback cannot be told apart
295
+ /// from an unbound slot reusing it as one mints a second entry for an id that already
296
+ /// owns one.
297
+ fn upper_idx_locked(&self, id: u32) -> Result<u32, Wedged> {
255
298
  if !self.in_range(id) {
256
- return NO_UPPER;
299
+ return Ok(NO_UPPER);
257
300
  }
258
301
  let seq = self.file.seq_atomic(id);
259
- seqlock::read_consistent(seq, self.file.self_tag, || {
260
- let p = self.file.slot_ptr(id);
261
- unsafe {
262
- if *p.add(S_FLAGS) == 0 {
263
- return NO_UPPER; // never written
264
- }
302
+ let _guard = seqlock::write_lock(seq, self.file.self_tag, self.slot_sanitizer(id), self.owner_dead())?;
303
+ let p = self.file.slot_ptr(id);
304
+ Ok(unsafe {
305
+ if *p.add(S_FLAGS) == 0 {
306
+ NO_UPPER // never written
307
+ } else {
265
308
  (p.add(S_UPPER_IDX) as *const u32).read_unaligned()
266
309
  }
267
- }, self.slot_sanitizer(id), || NO_UPPER, self.owner_dead())
310
+ })
268
311
  }
269
312
 
270
313
  /// Mirror a host-maintained node into the plane: full state per call, host-allocated id
@@ -281,21 +324,35 @@ impl Graph {
281
324
  upper_levels: &[Vec<u32>],
282
325
  ) -> Result<(), Wedged> {
283
326
  self.file.ensure_high_water(id);
284
- let existing = match self.upper_idx_raw(id) {
327
+ let existing = match self.upper_idx_locked(id)? {
285
328
  idx if idx != NO_UPPER && (idx as u64) >= self.file.upper_capacity => NO_UPPER, // corrupt stored index
286
329
  idx => idx,
287
330
  };
331
+ let mut fresh = NO_UPPER;
288
332
  let upper_idx = if upper_levels.is_empty() {
289
- existing // keep an existing entry bound (level never shrinks in practice)
333
+ // the host reseeds its id counter to largestNodeId + 1 on restart, so an id can be
334
+ // re-minted at level 0 over a slot that had a hierarchy; that entry must stop being
335
+ // readable. Emptied in place rather than freed: the freelist hand-off is not atomic
336
+ // with publishing the slot below, so a mirror that read this index first could
337
+ // republish a slot pointing at an entry already given to another node. One idle
338
+ // entry per id is the bounded retention DESIGN.md §10 accepts.
339
+ if existing != NO_UPPER {
340
+ self.rewrite_upper(existing, &[])?;
341
+ }
342
+ existing
290
343
  } else if existing != NO_UPPER {
291
344
  self.rewrite_upper(existing, upper_levels)?;
292
345
  existing
293
346
  } else {
294
- self.write_upper(upper_levels)?
347
+ fresh = self.write_upper(upper_levels)?;
348
+ fresh
295
349
  };
296
350
  let mut l0 = neighbors.to_vec();
297
351
  l0.truncate(self.file.layer0_cap);
298
- self.write_node(id, level, vector, scale, inv_mag, &l0, upper_idx)?;
352
+ if let Err(wedged) = self.write_node(id, level, vector, scale, inv_mag, &l0, upper_idx) {
353
+ self.file.free_upper(fresh); // unreachable from any slot until publication succeeds
354
+ return Err(wedged);
355
+ }
299
356
  Ok(())
300
357
  }
301
358
 
@@ -340,12 +397,12 @@ impl Graph {
340
397
  return Ok(false);
341
398
  }
342
399
  let lp = p.add(U_LISTS + (level as usize - 1) * UPPER_LEVEL_STRIDE);
343
- let degree = u16::from_le((lp as *const u16).read_unaligned()) as usize;
344
- let base = lp.add(2) as *mut u32;
400
+ let degree = u16::from_le((lp.add(UL_DEGREE) as *const u16).read_unaligned()) as usize;
401
+ let base = lp.add(UL_IDS) as *mut u32;
345
402
  let mut list: Vec<u32> = (0..degree.min(UPPER_CAP)).map(|i| u32::from_le(base.add(i).read_unaligned())).collect();
346
403
  f(&mut list);
347
404
  list.truncate(UPPER_CAP);
348
- (lp as *mut u16).write_unaligned((list.len() as u16).to_le());
405
+ (lp.add(UL_DEGREE) as *mut u16).write_unaligned((list.len() as u16).to_le());
349
406
  for (i, id) in list.iter().enumerate() {
350
407
  base.add(i).write_unaligned(id.to_le());
351
408
  }
@@ -361,20 +418,21 @@ impl Graph {
361
418
  let seq = self.file.seq_atomic(id);
362
419
  let dims = self.file.dims;
363
420
  let cap = self.file.layer0_cap;
421
+ let nbase_off = neighbor_offset(dims);
364
422
  seqlock::read_consistent(seq, self.file.self_tag, || {
365
423
  let p = self.file.slot_ptr(id);
366
424
  unsafe {
367
- let flags = *p.add(S_FLAGS);
425
+ let flags = vread(p.add(S_FLAGS));
368
426
  if flags & FLAG_VALID == 0 || flags & FLAG_DELETED != 0 {
369
427
  return None;
370
428
  }
371
- let level = *p.add(S_LEVEL);
372
- let degree = u16::from_le((p.add(S_DEGREE) as *const u16).read_unaligned()) as usize;
373
- let scale = (p.add(S_SCALE) as *const f32).read_unaligned();
374
- let inv_mag = (p.add(S_INV_MAG) as *const f32).read_unaligned();
429
+ let level = vread(p.add(S_LEVEL));
430
+ let degree = u16::from_le(vread(p.add(S_DEGREE) as *const u16)) as usize;
431
+ let scale = vread(p.add(S_SCALE) as *const f32);
432
+ let inv_mag = vread(p.add(S_INV_MAG) as *const f32);
375
433
  let vector = std::slice::from_raw_parts(p.add(S_VECTOR) as *const i8, dims).to_vec();
376
- let nbase = p.add(S_VECTOR + dims) as *const u32;
377
- let neighbors = (0..degree.min(cap)).map(|i| u32::from_le(nbase.add(i).read_unaligned())).collect();
434
+ let nbase = p.add(nbase_off) as *const u32;
435
+ let neighbors = (0..degree.min(cap)).map(|i| u32::from_le(vread(nbase.add(i)))).collect();
378
436
  Some(NodeRead { level, scale, inv_mag, vector, neighbors })
379
437
  }
380
438
  }, self.slot_sanitizer(id), || None, self.owner_dead())
@@ -397,11 +455,15 @@ impl Graph {
397
455
  (p.add(S_UPPER_IDX) as *mut u32).write_unaligned(upper_idx);
398
456
  std::ptr::copy_nonoverlapping(vector.as_ptr() as *const u8, p.add(S_VECTOR), dims);
399
457
  for (i, n) in neighbors.iter().enumerate() {
400
- (p.add(S_VECTOR + dims + i * 4) as *mut u32).write_unaligned(n.to_le());
458
+ (p.add(neighbor_offset(dims) + i * 4) as *mut u32).write_unaligned(n.to_le());
401
459
  }
402
460
  // valid last within the locked section; the seqlock release publishes it
403
461
  *p.add(S_FLAGS) = FLAG_VALID;
404
462
  }
463
+ drop(_guard);
464
+ // after the release: a probe that consumed a bump while the slot was still invalid
465
+ // would otherwise latch on a graph that holds a live node
466
+ self.node_written();
405
467
  Ok(())
406
468
  }
407
469
 
@@ -424,7 +486,7 @@ impl Graph {
424
486
  return Ok(false);
425
487
  }
426
488
  let degree = u16::from_le((p.add(S_DEGREE) as *const u16).read_unaligned()) as usize;
427
- let base = p.add(S_VECTOR + dims) as *mut u32;
489
+ let base = p.add(neighbor_offset(dims)) as *mut u32;
428
490
  let mut list: Vec<u32> = (0..degree.min(cap)).map(|i| u32::from_le(base.add(i).read_unaligned())).collect();
429
491
  f(&mut list);
430
492
  list.truncate(cap);
@@ -457,7 +519,7 @@ impl Graph {
457
519
  if degree != expected.len() {
458
520
  return Ok(false);
459
521
  }
460
- let base = p.add(S_VECTOR + dims) as *mut u32;
522
+ let base = p.add(neighbor_offset(dims)) as *mut u32;
461
523
  for (i, want) in expected.iter().enumerate() {
462
524
  if u32::from_le(base.add(i).read_unaligned()) != *want {
463
525
  return Ok(false);
@@ -481,7 +543,7 @@ impl Graph {
481
543
  unsafe {
482
544
  (p.add(S_DEGREE) as *mut u16).write_unaligned((neighbors.len() as u16).to_le());
483
545
  for (i, n) in neighbors.iter().enumerate() {
484
- (p.add(S_VECTOR + dims + i * 4) as *mut u32).write_unaligned(n.to_le());
546
+ (p.add(neighbor_offset(dims) + i * 4) as *mut u32).write_unaligned(n.to_le());
485
547
  }
486
548
  }
487
549
  Ok(())
@@ -501,6 +563,13 @@ impl Graph {
501
563
  if entry_id == id {
502
564
  self.neighbors_into(id, &mut candidates);
503
565
  }
566
+ // Re-elect before the tombstone, not after: between marking the slot deleted and
567
+ // installing a replacement, every concurrent search routes through a node that reads
568
+ // as absent and returns nothing. The node is still live here, so a crash inside the
569
+ // window leaves the header naming a live entry either way.
570
+ if entry_id == id {
571
+ self.reelect_entry_point_replacing(&candidates, id);
572
+ }
504
573
  let upper_idx;
505
574
  {
506
575
  let seq = self.file.seq_atomic(id);
@@ -525,9 +594,6 @@ impl Graph {
525
594
  self.rewrite_upper(upper_idx, &[])?;
526
595
  }
527
596
  self.file.free_upper(upper_idx);
528
- if entry_id == id {
529
- self.reelect_entry_point_replacing(&candidates, id);
530
- }
531
597
  self.file.free_id(id);
532
598
  Ok(())
533
599
  }
@@ -536,7 +602,7 @@ impl Graph {
536
602
  /// first live node found scanning the id range (rare path: only when the entry's whole
537
603
  /// neighborhood is gone). An empty graph clears the entry.
538
604
  /// A node's level without copying its vector or edges (cheap re-election scans).
539
- fn node_level(&self, id: u32) -> Option<u8> {
605
+ pub(crate) fn node_level(&self, id: u32) -> Option<u8> {
540
606
  if !self.in_range(id) {
541
607
  return None;
542
608
  }
@@ -544,25 +610,82 @@ impl Graph {
544
610
  seqlock::read_consistent(seq, self.file.self_tag, || {
545
611
  let p = self.file.slot_ptr(id);
546
612
  unsafe {
547
- if *p.add(S_FLAGS) != FLAG_VALID {
613
+ if vread(p.add(S_FLAGS)) != FLAG_VALID {
548
614
  return None;
549
615
  }
550
- Some(*p.add(S_LEVEL))
616
+ Some(vread(p.add(S_LEVEL)))
551
617
  }
552
618
  }, self.slot_sanitizer(id), || None, self.owner_dead())
553
619
  }
554
620
 
621
+ /// Highest-level live node among at most `limit` probes, skipping `skip`. The read-side
622
+ /// repair's last resort, bounded because `reelect_entry_point_replacing`'s scan runs to the
623
+ /// high-water mark and a search on the shared pool thread cannot afford it.
624
+ ///
625
+ /// Walks down from the newest id with a stride spanning the whole range, so it assumes
626
+ /// nothing about where the live nodes sit: Harper allocates ids monotonically and never
627
+ /// reuses them, so a churned table's low prefix is all tombstones, while the crate's own
628
+ /// freelist reuses ids and keeps live nodes low.
629
+ ///
630
+ /// The start rotates per handle, so `stride` consecutive repairs of this plane cover every id
631
+ /// while each stays capped at `limit`; a fixed start would probe one residue class forever
632
+ /// and leave a graph lying between its samples invisible permanently, not for one search.
633
+ /// That coverage rests on `stride * limit >= hw`, which is why the stride is a ceiling
634
+ /// division: below it a walk stops short of id 0 and no offset ever reaches the tail.
635
+ ///
636
+ /// Best-level rather than first-live: a level-0 entry degrades every later search to a
637
+ /// layer-0-only beam.
638
+ pub(crate) fn probe_for_entry(&self, limit: u32, skip: u32) -> Option<(u32, u8)> {
639
+ let hw = self.file.id_high_water().min(self.file.max_nodes) as u32;
640
+ if hw == 0 || limit == 0 {
641
+ return None;
642
+ }
643
+ let stride = hw.div_ceil(limit);
644
+ use std::sync::atomic::Ordering::Relaxed;
645
+ let epoch = self.file.write_epoch();
646
+ let unchanged = self.probe_futile_hw.load(Relaxed) == hw as u64 && self.probe_futile_epoch.load(Relaxed) == epoch;
647
+ if unchanged && self.probe_futile_runs.load(Relaxed) >= stride {
648
+ return None; // every residue probed since the last write anywhere: nothing to find
649
+ }
650
+ if self.probe_in_flight.swap(true, std::sync::atomic::Ordering::AcqRel) {
651
+ return None; // another search on this handle is repairing; it publishes for both
652
+ }
653
+ let offset = self.probe_rotation.fetch_add(1, Relaxed) % stride;
654
+ let mut best: Option<(u32, u8)> = None;
655
+ let mut cand = hw - 1 - offset;
656
+ for _ in 0..limit {
657
+ if cand != skip {
658
+ if let Some(level) = self.node_level(cand) {
659
+ if best.map(|(_, l)| level > l).unwrap_or(true) {
660
+ best = Some((cand, level));
661
+ }
662
+ }
663
+ }
664
+ if cand < stride {
665
+ break;
666
+ }
667
+ cand -= stride;
668
+ }
669
+ if best.is_some() {
670
+ self.probe_futile_runs.store(0, Relaxed);
671
+ } else if unchanged {
672
+ self.probe_futile_runs.fetch_add(1, Relaxed);
673
+ } else {
674
+ self.probe_futile_hw.store(hw as u64, Relaxed);
675
+ self.probe_futile_epoch.store(epoch, Relaxed);
676
+ self.probe_futile_runs.store(1, Relaxed);
677
+ }
678
+ self.probe_in_flight.store(false, std::sync::atomic::Ordering::Release);
679
+ best
680
+ }
681
+
555
682
  /// Pick a new entry point: the highest-level live node among `preferred`, else the
556
683
  /// highest-level live node found scanning the id range (level reads only — no per-node
557
684
  /// vector copies; still O(high-water), which only runs when an entry point vanished
558
685
  /// with no live neighborhood). Preferring level keeps the hierarchy navigable — a
559
686
  /// level-0 entry degrades every search to a layer-0-only beam. An empty graph clears
560
687
  /// the entry.
561
- pub(crate) fn reelect_entry_point(&self, preferred: &[u32]) {
562
- self.reelect_entry_point_replacing(preferred, crate::format::NO_ID)
563
- }
564
-
565
- fn reelect_entry_point_replacing(&self, preferred: &[u32], replacing: u32) {
688
+ pub(crate) fn reelect_entry_point_replacing(&self, preferred: &[u32], replacing: u32) {
566
689
  let mut best: Option<(u32, u8)> = None;
567
690
  // the most recently replaced entry point is the best cheap candidate: usually alive,
568
691
  // usually high-level — and it makes the full fallback scan a last resort
@@ -573,6 +696,9 @@ impl Graph {
573
696
  }
574
697
  }
575
698
  for &cand in preferred {
699
+ if cand == replacing {
700
+ continue; // the node on its way out is never its own replacement
701
+ }
576
702
  if let Some(level) = self.node_level(cand) {
577
703
  if best.map(|(_, l)| level > l).unwrap_or(true) {
578
704
  best = Some((cand, level));
@@ -582,6 +708,9 @@ impl Graph {
582
708
  if best.is_none() {
583
709
  let hw = self.file.id_high_water().min(self.file.max_nodes) as u32;
584
710
  for cand in 0..hw {
711
+ if cand == replacing {
712
+ continue;
713
+ }
585
714
  if let Some(level) = self.node_level(cand) {
586
715
  if best.map(|(_, l)| level > l).unwrap_or(true) {
587
716
  best = Some((cand, level));
@@ -594,7 +723,7 @@ impl Graph {
594
723
  }
595
724
  match best {
596
725
  Some((cand, level)) => self.file.set_entry_point_if_not_better(cand, level as u32, replacing),
597
- None => self.file.set_entry_point_if_not_better(crate::format::NO_ID, 0, replacing),
726
+ None => self.file.clear_entry_point_if(replacing),
598
727
  }
599
728
  }
600
729
 
@@ -616,12 +745,20 @@ impl Graph {
616
745
  debug_assert!(neighbors.len() <= self.file.layer0_cap);
617
746
  debug_assert_eq!(vector.len(), self.file.dims);
618
747
  self.file.ensure_high_water(id);
619
- // the upper entry is allocated before taking the slot lock (allocation is cheap and
620
- // an unused entry is freed below on the untouched-check failing)
748
+ // the upper entry is allocated before taking the slot lock (allocation is cheap); it is
749
+ // unreachable from any slot until the write below lands, so every path that does not
750
+ // publish it — a wedged lock, a slot that turns out to be touched — has to free it
621
751
  let upper_idx = if upper_levels.is_empty() { NO_UPPER } else { self.write_upper(upper_levels)? };
622
752
  let seq = self.file.seq_atomic(id);
623
753
  let written = {
624
- let _guard = seqlock::write_lock(seq, self.file.self_tag, self.slot_sanitizer(id), self.owner_dead())?;
754
+ let _guard = match seqlock::write_lock(seq, self.file.self_tag, self.slot_sanitizer(id), self.owner_dead())
755
+ {
756
+ Ok(guard) => guard,
757
+ Err(wedged) => {
758
+ self.file.free_upper(upper_idx);
759
+ return Err(wedged);
760
+ }
761
+ };
625
762
  let p = self.file.slot_ptr_mut(id);
626
763
  let dims = self.file.dims;
627
764
  unsafe {
@@ -635,16 +772,73 @@ impl Graph {
635
772
  (p.add(S_UPPER_IDX) as *mut u32).write_unaligned(upper_idx);
636
773
  std::ptr::copy_nonoverlapping(vector.as_ptr() as *const u8, p.add(S_VECTOR), dims);
637
774
  for (i, n) in neighbors.iter().enumerate() {
638
- (p.add(S_VECTOR + dims + i * 4) as *mut u32).write_unaligned(n.to_le());
775
+ (p.add(neighbor_offset(dims) + i * 4) as *mut u32).write_unaligned(n.to_le());
639
776
  }
640
777
  *p.add(S_FLAGS) = FLAG_VALID;
641
778
  true
642
779
  }
643
780
  }
644
781
  };
645
- if !written {
782
+ if written {
783
+ self.node_written();
784
+ } else {
646
785
  self.file.free_upper(upper_idx);
647
786
  }
648
787
  Ok(written)
649
788
  }
650
789
  }
790
+
791
+ #[cfg(test)]
792
+ mod probe_tests {
793
+ use super::*;
794
+ use crate::distance::Query;
795
+ use crate::insert::{insert, InsertParams};
796
+ use crate::search::{search, SearchScratch};
797
+ use std::sync::atomic::Ordering::Relaxed;
798
+
799
+ fn vector_for(i: u32, dims: usize) -> Vec<f32> {
800
+ (0..dims).map(|d| ((i as f32 * 0.31 + d as f32) * 0.7).sin()).collect()
801
+ }
802
+
803
+ /// A graph whose every node is dead must stop paying the repair probe once a full rotation
804
+ /// has come back empty, and must resume it after ANY handle writes a node — the revival
805
+ /// here comes through a second handle on the same file, as another process's would.
806
+ #[test]
807
+ fn a_fully_dead_graph_stops_probing_until_a_node_is_written() {
808
+ let dims = 32;
809
+ let path = std::env::temp_dir().join(format!("hnsw-probefutile-{}.hnsw", std::process::id()));
810
+ let _ = std::fs::remove_file(&path);
811
+ let graph = Graph::new(PlaneFile::create(&path, dims, 16, 4_096).expect("create"));
812
+ let params = InsertParams::default();
813
+ let mut scratch = SearchScratch::new();
814
+ for i in 0..2_100 {
815
+ insert(&graph, &vector_for(i, dims), &params, &mut scratch).unwrap();
816
+ }
817
+ let hw = graph.file.id_high_water() as u32;
818
+ let stride = hw.div_ceil(1_024); // REPAIR_PROBE_LIMIT
819
+ assert!(stride > 1, "precondition: a rotation the guard has to wait out");
820
+ let (entry, _) = graph.file.entry_point();
821
+ for id in 0..hw {
822
+ let _ = graph.clear_node(id);
823
+ }
824
+ graph.file.clear_entry_point_if(entry);
825
+
826
+ let query = Query::new(vector_for(7, dims));
827
+ for _ in 0..stride {
828
+ assert!(search(&graph, &query, 5, 64, &mut scratch).0.is_empty());
829
+ }
830
+ let rotation = graph.probe_rotation.load(Relaxed);
831
+ assert!(search(&graph, &query, 5, 64, &mut scratch).0.is_empty());
832
+ assert_eq!(graph.probe_rotation.load(Relaxed), rotation, "a probed-out plane must not probe again");
833
+
834
+ // a node written through another handle, with no entry-point update (mirroring hosts do
835
+ // not always re-elect), must be findable again within one rotation
836
+ let revived = 3u32;
837
+ let q = crate::distance::quantize_int8(&vector_for(revived, dims));
838
+ let other = Graph::new(PlaneFile::open(&path).expect("a second handle"));
839
+ other.write_node_raw(revived, 0, &q.0, q.1, q.2, &[], &[]).expect("revive");
840
+ let found = (0..stride).any(|_| !search(&graph, &Query::new(vector_for(revived, dims)), 5, 64, &mut scratch).0.is_empty());
841
+ assert!(found, "a write must re-arm the probe");
842
+ let _ = std::fs::remove_file(&path);
843
+ }
844
+ }