@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/insert.rs CHANGED
@@ -7,7 +7,7 @@
7
7
  use crate::distance::{quantize_int8, Query};
8
8
  use crate::format::{NO_ID, NO_UPPER};
9
9
  use crate::graph::Graph;
10
- use crate::search::{greedy_descend, search_layer, SearchScratch, SearchStats};
10
+ use crate::search::{beam_descend, search_layer, SearchScratch, SearchStats, DESCENT_EF};
11
11
 
12
12
  pub struct InsertParams {
13
13
  pub m: usize, // base connection count (JS M, default 16)
@@ -34,7 +34,7 @@ fn level_for(id: u32, ml: f64) -> u8 {
34
34
  /// Remove `to` from `from`'s adjacency at `level` (edge-replacement maintenance).
35
35
  fn remove_edge(graph: &Graph, from: u32, to: u32, level: u8) {
36
36
  if level == 0 {
37
- graph.update_neighbors(from, |list| {
37
+ let _ = graph.update_neighbors(from, |list| {
38
38
  if let Some(pos) = list.iter().position(|&x| x == to) {
39
39
  list.remove(pos);
40
40
  }
@@ -93,6 +93,25 @@ fn prune_with_coverage(graph: &Graph, base: u32, list: &mut Vec<u32>, cap: usize
93
93
  *list = scored.into_iter().map(|(cand, _)| cand).collect();
94
94
  }
95
95
 
96
+ /// The contended fallback's merge: add `new_id` under the slot lock, displacing the tail once the
97
+ /// list is at `cap`. Which neighbor that is is arbitrary — appends push at the tail, so a list is
98
+ /// distance-ordered only immediately after a prune — but it must not be `new_id` itself, which is
99
+ /// what a push followed by `truncate(cap)` drops. That loss is the systematic one: the edge being
100
+ /// added is the in-edge keeping a freshly inserted node reachable from `nid`, and it disappears
101
+ /// every time the list is full and the CAS path is contended. Picking a better victim needs
102
+ /// distances, which this path deliberately keeps outside the lock.
103
+ fn merge_neighbor_capped(graph: &Graph, nid: u32, new_id: u32, cap: usize) {
104
+ let _ = graph.update_neighbors(nid, |list| {
105
+ if list.contains(&new_id) {
106
+ return;
107
+ }
108
+ if list.len() >= cap {
109
+ list.truncate(cap.saturating_sub(1));
110
+ }
111
+ list.push(new_id);
112
+ });
113
+ }
114
+
96
115
  /// Add `new_id` to `nid`'s adjacency at `level`, coverage-pruning to `cap` when over. The
97
116
  /// prune's distance computations (which can major-fault on a cold mapping) run OUTSIDE the
98
117
  /// slot lock: the list is snapshotted, pruned, and applied with a compare-and-set; after a
@@ -117,12 +136,7 @@ fn add_reverse_edge(graph: &Graph, nid: u32, new_id: u32, level: u8, cap: usize)
117
136
  }
118
137
  }
119
138
  // contended twice: merge cheaply under the lock (bounded critical section)
120
- let _ = graph.update_neighbors(nid, |list| {
121
- if !list.contains(&new_id) {
122
- list.push(new_id);
123
- list.truncate(cap);
124
- }
125
- });
139
+ merge_neighbor_capped(graph, nid, new_id, cap);
126
140
  } else {
127
141
  let _ = graph.update_upper_level(nid, level, |list| {
128
142
  if list.contains(&new_id) {
@@ -161,48 +175,92 @@ pub fn insert(
161
175
  let layer0_cap = graph.file.layer0_cap;
162
176
  let m = params.m;
163
177
 
164
- let (entry_id, entry_level) = graph.file.entry_point();
165
- if entry_id == NO_ID {
166
- let upper_idx = if level > 0 { graph.write_upper(&vec![Vec::new(); level as usize]).unwrap_or(NO_UPPER) } else { NO_UPPER };
167
- graph.write_node(id, level, &bytes, scale, inv_mag, &[], upper_idx).map_err(|_| InsertError::Wedged)?;
168
- // CAS: a concurrent first insert may have installed an entry already — never clobber
169
- graph.file.set_entry_point_if_not_better(id, level as u32, NO_ID);
170
- return Ok(id);
171
- }
172
-
173
178
  let mut stats = SearchStats { visits: 0 };
174
- let (entry_id, entry_level, entry_dist) = match graph.distance_to(entry_id, &query) {
175
- Some(d) => (entry_id, entry_level, d),
176
- None => {
177
- // The stored entry point is gone (e.g. a mirroring host cleared it without
178
- // re-electing). Self-promoting an edgeless new node here would orphan the whole
179
- // existing graph behind an unreachable root re-elect from the live graph and
180
- // continue; only a truly empty graph makes this node the first entry.
181
- graph.reelect_entry_point(&[]);
182
- let (re_id, re_level) = graph.file.entry_point();
183
- match (re_id != NO_ID).then(|| graph.distance_to(re_id, &query)).flatten() {
184
- Some(d) => (re_id, re_level, d),
185
- None => {
186
- let upper_idx = if level > 0 { graph.write_upper(&vec![Vec::new(); level as usize]).unwrap_or(NO_UPPER) } else { NO_UPPER };
187
- graph.write_node(id, level, &bytes, scale, inv_mag, &[], upper_idx).map_err(|_| InsertError::Wedged)?;
188
- graph.file.set_entry_point_if_not_better(id, level as u32, NO_ID);
189
- return Ok(id);
190
- }
179
+ // Upper entry a first-entry claim attempt already published for `id`. Its slot names the
180
+ // index, so the join path must rewrite it in place — freeing an index a live slot names
181
+ // would let another node adopt it mid-traversal.
182
+ let mut published_upper = NO_UPPER;
183
+ let mut published = false;
184
+ let publish_edgeless = |published: &mut bool, published_upper: &mut u32| -> Result<(), InsertError> {
185
+ if *published {
186
+ return Ok(());
187
+ }
188
+ *published_upper =
189
+ if level > 0 { graph.write_upper(&vec![Vec::new(); level as usize]).unwrap_or(NO_UPPER) } else { NO_UPPER };
190
+ graph.write_node(id, level, &bytes, scale, inv_mag, &[], *published_upper).map_err(|_| InsertError::Wedged)?;
191
+ *published = true;
192
+ Ok(())
193
+ };
194
+
195
+ // Resolve an entry point to grow from. Every turn makes progress — it claims an empty
196
+ // graph, joins a live entry, or replaces one that is provably gone — so the cap only
197
+ // guards an insert/delete interleaving that keeps clearing the entry under us.
198
+ let mut joined = None;
199
+ for _ in 0..16 {
200
+ let (entry_id, entry_level) = graph.file.entry_point();
201
+ if entry_id == NO_ID {
202
+ publish_edgeless(&mut published, &mut published_upper)?;
203
+ // Claim only from EMPTY, and only the winner returns: a not-worse install would put
204
+ // this edgeless node over a live equal-or-lower-level entry and orphan the graph
205
+ // behind it, and it cannot report losing, which a loser must know to join instead.
206
+ if graph.file.claim_entry_if_empty(id, level as u32) {
207
+ return Ok(id);
191
208
  }
209
+ continue; // a racer rooted the graph — join it rather than stand alone
192
210
  }
211
+ if let Some(d) = graph.distance_to(entry_id, &query) {
212
+ joined = Some((entry_id, entry_level, d));
213
+ break;
214
+ }
215
+ // The stored entry point is gone (e.g. a mirroring host cleared it without
216
+ // re-electing). Self-promoting an edgeless new node here would orphan the whole
217
+ // existing graph behind an unreachable root — re-elect from the live graph and
218
+ // continue; only a truly empty graph makes this node the first entry.
219
+ graph.reelect_entry_point_replacing(&[], entry_id);
220
+ }
221
+ // An unresolvable entry point is an error the host retries: Ok here would report success
222
+ // for a node no search can reach.
223
+ let Some((entry_id, entry_level, entry_dist)) = joined else {
224
+ if published {
225
+ // the edgeless node a failed claim left behind is a live-reading slot with no
226
+ // in-edges: a later re-election or repair probe could root the graph at it
227
+ let _ = graph.delete_node(id);
228
+ }
229
+ return Err(InsertError::Wedged);
193
230
  };
194
231
  let top = level.min(entry_level as u8);
195
- let (mut ep, mut ep_dist) =
196
- greedy_descend(graph, &query, entry_id, entry_dist, entry_level, top as u32, &mut stats);
232
+ let (mut ep, mut ep_dist) = beam_descend(
233
+ graph,
234
+ &query,
235
+ entry_id,
236
+ entry_dist,
237
+ entry_level,
238
+ top as u32,
239
+ DESCENT_EF,
240
+ scratch,
241
+ &mut stats,
242
+ );
197
243
 
198
244
  // Per-level connection lists for the new node, selection-ordered.
199
245
  let mut connections: Vec<Vec<(u32, f32)>> = vec![Vec::new(); level as usize + 1];
200
246
  let mut nbuf: Vec<u32> = Vec::new();
247
+ let mut neighbors: Vec<(u32, f32)> = Vec::new();
201
248
 
202
249
  for l in (0..=top).rev() {
203
250
  scratch_begin(graph, scratch);
204
- let mut neighbors =
205
- search_layer(graph, &query, ep, ep_dist, params.ef_construction, l, scratch, &mut stats, None, u64::MAX);
251
+ search_layer(
252
+ graph,
253
+ &query,
254
+ ep,
255
+ ep_dist,
256
+ params.ef_construction,
257
+ l,
258
+ scratch,
259
+ &mut stats,
260
+ None,
261
+ u64::MAX,
262
+ &mut neighbors,
263
+ );
206
264
  neighbors.truncate(m << 1);
207
265
  if let Some(&(best, best_d)) = neighbors.first() {
208
266
  ep = best;
@@ -261,7 +319,12 @@ pub fn insert(
261
319
  .unwrap_or_default()
262
320
  })
263
321
  .collect();
264
- graph.write_upper(&levels).unwrap_or(NO_UPPER)
322
+ if published_upper != NO_UPPER {
323
+ graph.rewrite_upper(published_upper, &levels).map_err(|_| InsertError::Wedged)?;
324
+ published_upper
325
+ } else {
326
+ graph.write_upper(&levels).unwrap_or(NO_UPPER)
327
+ }
265
328
  } else {
266
329
  NO_UPPER
267
330
  };
@@ -279,7 +342,7 @@ pub fn insert(
279
342
 
280
343
  if (level as u32) > entry_level {
281
344
  // CAS against the observed entry: a concurrent higher-level promotion wins
282
- graph.file.set_entry_point_if_not_better(id, level as u32, entry_id);
345
+ graph.file.promote_entry_point(id, level as u32, entry_id);
283
346
  }
284
347
  Ok(id)
285
348
  }
@@ -290,3 +353,41 @@ fn scratch_begin(graph: &Graph, scratch: &mut SearchScratch) {
290
353
  // via this helper to keep the public surface small.
291
354
  scratch.begin_public(graph.file.id_high_water());
292
355
  }
356
+
357
+ #[cfg(test)]
358
+ mod reverse_edge_tests {
359
+ use super::*;
360
+ use crate::PlaneFile;
361
+
362
+ /// The contended fallback must still add the edge when the neighbor list is already full —
363
+ /// the one case where a push-then-`truncate(cap)` discards `new_id` rather than a neighbor,
364
+ /// losing the in-edge exactly in the contended-and-full case the fallback exists to serve.
365
+ #[test]
366
+ fn a_contended_merge_into_a_full_neighbor_list_keeps_the_edge_it_adds() {
367
+ let dims = 8;
368
+ let cap = 8usize;
369
+ let path = std::env::temp_dir().join(format!("hnsw-revedge-{}.hnsw", std::process::id()));
370
+ let _ = std::fs::remove_file(&path);
371
+ let graph = Graph::new(PlaneFile::create(&path, dims, cap, 4_096).expect("create"));
372
+
373
+ let vector = vec![0i8; dims];
374
+ let full: Vec<u32> = (1..=cap as u32).collect();
375
+ graph.write_node_raw(0, 0, &vector, 1.0, 1.0, &full, &[]).expect("seed the full list");
376
+ for &nid in &full {
377
+ graph.write_node_raw(nid, 0, &vector, 1.0, 1.0, &[], &[]).expect("seed a neighbor");
378
+ }
379
+ let newcomer = cap as u32 + 1;
380
+ graph.write_node_raw(newcomer, 0, &vector, 1.0, 1.0, &[], &[]).expect("seed the newcomer");
381
+
382
+ merge_neighbor_capped(&graph, 0, newcomer, cap);
383
+
384
+ let mut neighbors: Vec<u32> = Vec::new();
385
+ graph.neighbors_into(0, &mut neighbors).expect("node 0 is live");
386
+ assert!(
387
+ neighbors.contains(&newcomer),
388
+ "the contended merge dropped the edge it was adding: {neighbors:?}"
389
+ );
390
+ assert_eq!(neighbors.len(), cap, "the merge must stay within the layer-0 cap");
391
+ let _ = std::fs::remove_file(&path);
392
+ }
393
+ }
@@ -0,0 +1,336 @@
1
+ //! Path-level invalidation: make a plane file that could not be deleted unadoptable, durably.
2
+ //!
3
+ //! Two markers, both attempted every call: the in-band latch (`PlaneFile::invalidate` — the
4
+ //! sticky header byte plus a zeroed watermark, msync'd) and a `<path>.stale` sidecar, fsync'd
5
+ //! along with its directory entry. `PlaneFile::open` refuses a file carrying either, so the
6
+ //! markers are enforced by the package, not by each host's attach path. In band first: the
7
+ //! sidecar is what a process that cannot map the file checks, the latch is what covers a
8
+ //! plane whose sidecar a crash lost. A temporary handle opened here is dropped before the
9
+ //! sidecar is written and before returning — its mapping is the kind of thing that keeps a
10
+ //! file undeletable in the first place, and its registry slot must not wait on a finalizer.
11
+
12
+ use crate::format::PlaneFile;
13
+ use std::fs::{File, OpenOptions};
14
+ use std::io;
15
+ use std::path::{Path, PathBuf};
16
+
17
+ /// Which markers landed. `Ok` for a marker means it is durable, not merely written.
18
+ #[derive(Debug)]
19
+ pub struct Invalidation {
20
+ pub in_band: io::Result<()>,
21
+ pub sidecar: io::Result<()>,
22
+ }
23
+
24
+ /// The sidecar convention: `<plane path>.stale`. Its presence means the plane file is stale
25
+ /// and must never be opened; hosts delete both and rebuild.
26
+ pub fn stale_path_for(path: &Path) -> PathBuf {
27
+ let mut name = path.as_os_str().to_owned();
28
+ name.push(".stale");
29
+ PathBuf::from(name)
30
+ }
31
+
32
+ /// Invalidate the plane at `path` through a temporary handle. Returns `Err` only when NEITHER
33
+ /// marker became durable. Nothing here deletes or renames, so the caller keeps whatever
34
+ /// recovery state it had; an in-band mark whose msync failed may still have landed in the
35
+ /// shared mapping, which is the safe direction (every handle reads it as incomplete).
36
+ /// Idempotent: an already invalidated plane reports both markers again.
37
+ pub fn invalidate_plane(path: &Path) -> io::Result<Invalidation> {
38
+ invalidate_at(path, None)
39
+ }
40
+
41
+ /// Invalidate the file `plane` maps, in band through the handle itself and with the sidecar
42
+ /// next to the path it was opened at. The caller's own handle is the right one when it
43
+ /// exists: on Windows that mapping is why the unlink failed, and a second open would claim a
44
+ /// second registry slot for nothing. The path must not have been replaced underneath the
45
+ /// handle since it opened; a host that unlinked and recreated it has nothing to invalidate.
46
+ pub fn invalidate_file(plane: &PlaneFile) -> io::Result<Invalidation> {
47
+ invalidate_at(&plane.path, Some(plane))
48
+ }
49
+
50
+ fn invalidate_at(path: &Path, attached: Option<&PlaneFile>) -> io::Result<Invalidation> {
51
+ invalidate_with(path, attached, write_sidecar)
52
+ }
53
+
54
+ /// The order is the contract: the in-band mark is durable before the sidecar exists, so a
55
+ /// crash between the two cannot leave a sidecar-less file with its old watermark.
56
+ fn invalidate_with(
57
+ path: &Path,
58
+ attached: Option<&PlaneFile>,
59
+ sidecar: impl FnOnce(&Path) -> io::Result<()>,
60
+ ) -> io::Result<Invalidation> {
61
+ let in_band = match attached {
62
+ Some(plane) => plane.invalidate(),
63
+ None => PlaneFile::open_for_invalidation(path).and_then(|plane| plane.invalidate()),
64
+ };
65
+ let sidecar = sidecar(&stale_path_for(path));
66
+ if let (Err(in_band), Err(sidecar)) = (&in_band, &sidecar) {
67
+ return Err(io::Error::other(format!(
68
+ "neither invalidation marker is durable for {}: in-band: {in_band}; sidecar: {sidecar}",
69
+ path.display()
70
+ )));
71
+ }
72
+ Ok(Invalidation { in_band, sidecar })
73
+ }
74
+
75
+ /// Create-new rather than create: the plane directory may be writable by another principal,
76
+ /// and a planted symlink at the sidecar path would otherwise be followed and its target
77
+ /// truncated. An existing marker is re-synced through a no-follow open checked on the open
78
+ /// handle, so a swap between the two calls cannot redirect the sync either.
79
+ fn write_sidecar(stale: &Path) -> io::Result<()> {
80
+ let marker = match OpenOptions::new().write(true).create_new(true).open(stale) {
81
+ Ok(file) => file,
82
+ Err(e) if e.kind() == io::ErrorKind::AlreadyExists => open_existing_marker(stale)?,
83
+ Err(e) => return Err(e),
84
+ };
85
+ marker.sync_all()?;
86
+ sync_dir(parent_dir(stale))
87
+ }
88
+
89
+ fn open_existing_marker(stale: &Path) -> io::Result<File> {
90
+ let mut options = OpenOptions::new();
91
+ options.write(true);
92
+ #[cfg(unix)]
93
+ {
94
+ use std::os::unix::fs::OpenOptionsExt;
95
+ // O_NONBLOCK: a FIFO planted here must fail (ENXIO) rather than block the open
96
+ options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
97
+ }
98
+ #[cfg(windows)]
99
+ {
100
+ use std::os::windows::fs::OpenOptionsExt;
101
+ const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
102
+ options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
103
+ }
104
+ let file = options.open(stale)?;
105
+ if !file.metadata()?.file_type().is_file() {
106
+ return Err(io::Error::other(format!("{} exists and is not a regular file", stale.display())));
107
+ }
108
+ Ok(file)
109
+ }
110
+
111
+ fn parent_dir(stale: &Path) -> &Path {
112
+ stale.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."))
113
+ }
114
+
115
+ /// Make the directory entry durable. Windows has no directory fsync through `std` (a
116
+ /// directory handle needs backup semantics); there `FlushFileBuffers` on the marker itself
117
+ /// is documented to flush the metadata of its creation, so the marker's `sync_all` is the
118
+ /// durability point and this is a no-op.
119
+ #[cfg(unix)]
120
+ fn sync_dir(dir: &Path) -> io::Result<()> {
121
+ File::open(dir)?.sync_all()
122
+ }
123
+
124
+ #[cfg(not(unix))]
125
+ fn sync_dir(_dir: &Path) -> io::Result<()> {
126
+ Ok(())
127
+ }
128
+
129
+ #[cfg(test)]
130
+ mod tests {
131
+ use super::*;
132
+
133
+ fn tmp(tag: &str) -> PathBuf {
134
+ let dir = std::env::temp_dir().join(format!("hnsw-invalidate-{}-{tag}", std::process::id()));
135
+ let _ = std::fs::remove_dir_all(&dir);
136
+ std::fs::create_dir_all(&dir).unwrap();
137
+ dir.join("plane.hnsw")
138
+ }
139
+
140
+ fn complete_looking_plane(path: &Path) {
141
+ let plane = PlaneFile::create(path, 8, 8, 256).expect("create");
142
+ plane.flush_with_watermark(Some(4_096)).expect("barrier");
143
+ assert_eq!(plane.watermark(), 4_096);
144
+ }
145
+
146
+ fn open_is_refused(path: &Path) {
147
+ let err = PlaneFile::open(path).err().expect("an invalidated plane must not open");
148
+ assert!(err.to_string().contains("invalidated"), "{err}");
149
+ }
150
+
151
+ #[test]
152
+ fn both_markers_land_and_every_later_open_is_refused() {
153
+ let path = tmp("both");
154
+ complete_looking_plane(&path);
155
+ let outcome = invalidate_plane(&path).expect("at least one marker");
156
+ assert!(outcome.in_band.is_ok(), "{:?}", outcome.in_band);
157
+ assert!(outcome.sidecar.is_ok(), "{:?}", outcome.sidecar);
158
+ assert!(stale_path_for(&path).is_file());
159
+ open_is_refused(&path);
160
+ }
161
+
162
+ /// The in-band mark must be on the file before the sidecar is even attempted, through a
163
+ /// temporary handle and through the caller's own: the sidecar writer here observes the
164
+ /// latch (and no sidecar yet) at the moment it is called.
165
+ #[test]
166
+ fn the_in_band_mark_lands_before_the_sidecar_is_written() {
167
+ let path = tmp("order");
168
+ complete_looking_plane(&path);
169
+ let observed = std::cell::Cell::new(false);
170
+ let outcome = invalidate_with(&path, None, |stale| {
171
+ assert!(!stale.exists(), "the sidecar must not exist before the in-band mark");
172
+ observed.set(PlaneFile::open_for_invalidation(&path).expect("temp reopen").invalidated());
173
+ write_sidecar(stale)
174
+ })
175
+ .expect("invalidate");
176
+ assert!(observed.get(), "the latch must be set before the sidecar step runs");
177
+ assert!(outcome.in_band.is_ok() && outcome.sidecar.is_ok(), "{outcome:?}");
178
+
179
+ let path = tmp("orderattached");
180
+ complete_looking_plane(&path);
181
+ let attached = PlaneFile::open(&path).expect("open");
182
+ let observed = std::cell::Cell::new(false);
183
+ invalidate_with(&path, Some(&attached), |stale| {
184
+ observed.set(attached.invalidated() && !stale.exists());
185
+ write_sidecar(stale)
186
+ })
187
+ .expect("invalidate");
188
+ assert!(observed.get(), "the attached handle must carry the latch before the sidecar step");
189
+ }
190
+
191
+ #[test]
192
+ fn invalidation_is_idempotent() {
193
+ let path = tmp("twice");
194
+ complete_looking_plane(&path);
195
+ invalidate_plane(&path).expect("first");
196
+ let again = invalidate_plane(&path).expect("second");
197
+ assert!(again.in_band.is_ok() && again.sidecar.is_ok(), "{again:?}");
198
+ }
199
+
200
+ #[test]
201
+ fn the_sidecar_is_named_next_to_the_plane() {
202
+ assert_eq!(stale_path_for(Path::new("/data/t/a%2Fb.hnsw")), PathBuf::from("/data/t/a%2Fb.hnsw.stale"));
203
+ assert_eq!(parent_dir(Path::new("plane.hnsw.stale")), Path::new("."));
204
+ assert_eq!(parent_dir(Path::new("/data/t/plane.hnsw.stale")), Path::new("/data/t"));
205
+ }
206
+
207
+ /// A directory squatting the sidecar path makes its creation fail on every platform.
208
+ #[test]
209
+ fn in_band_alone_still_succeeds_when_the_sidecar_cannot_be_written() {
210
+ let path = tmp("inbandonly");
211
+ complete_looking_plane(&path);
212
+ std::fs::create_dir(stale_path_for(&path)).unwrap();
213
+ let outcome = invalidate_plane(&path).expect("the in-band mark is enough");
214
+ assert!(outcome.in_band.is_ok());
215
+ assert!(outcome.sidecar.is_err(), "a directory at the sidecar path must be reported");
216
+ std::fs::remove_dir(stale_path_for(&path)).unwrap();
217
+ open_is_refused(&path);
218
+ }
219
+
220
+ /// A file that is not a plane cannot carry the in-band mark; the sidecar must still land,
221
+ /// and the temporary open that failed must not leave anything holding the file.
222
+ #[test]
223
+ fn the_sidecar_alone_still_succeeds_when_the_plane_cannot_be_opened() {
224
+ let path = tmp("sidecaronly");
225
+ std::fs::write(&path, b"not a plane").unwrap();
226
+ let outcome = invalidate_plane(&path).expect("the sidecar is enough");
227
+ assert!(outcome.in_band.is_err());
228
+ assert!(outcome.sidecar.is_ok(), "{:?}", outcome.sidecar);
229
+ assert!(stale_path_for(&path).is_file());
230
+ std::fs::remove_file(&path).expect("nothing of ours may hold the file");
231
+ }
232
+
233
+ /// Neither marker: an error that names both causes, and nothing deleted or replaced — the
234
+ /// caller's recovery state (retry later, stay disabled in-process) is preserved.
235
+ #[test]
236
+ fn a_double_failure_is_an_error_and_deletes_nothing() {
237
+ let path = tmp("double");
238
+ std::fs::write(&path, b"not a plane").unwrap();
239
+ std::fs::create_dir(stale_path_for(&path)).unwrap();
240
+ let err = invalidate_plane(&path).expect_err("no marker landed");
241
+ let message = err.to_string();
242
+ assert!(message.contains("in-band:") && message.contains("sidecar:"), "{message}");
243
+ assert_eq!(std::fs::read(&path).unwrap(), b"not a plane");
244
+ assert!(stale_path_for(&path).is_dir(), "nothing may be deleted or replaced");
245
+ std::fs::remove_file(&path).expect("nothing of ours may hold the file");
246
+ }
247
+
248
+ /// The in-band mark must survive the writers that can still reach the header: a flush
249
+ /// already in flight on this handle, and another handle's own watermark stamps. Without
250
+ /// the sticky latch a `flushAsync(900)` racing the invalidation restored the old
251
+ /// completion stamp and the plane was adoptable again.
252
+ #[test]
253
+ fn a_later_flush_or_stamp_cannot_revive_an_invalidated_plane() {
254
+ let path = tmp("revive");
255
+ complete_looking_plane(&path);
256
+ let ours = PlaneFile::open(&path).expect("our handle");
257
+ let theirs = PlaneFile::open(&path).expect("another worker's handle");
258
+ let outcome = invalidate_file(&ours).expect("invalidate");
259
+ assert!(outcome.in_band.is_ok() && outcome.sidecar.is_ok(), "{outcome:?}");
260
+ ours.flush_with_watermark(Some(900)).expect("the pending flush lands after the invalidation");
261
+ theirs.set_watermark(4_096);
262
+ theirs.flush_with_watermark(None).expect("their cadence barrier");
263
+ assert_eq!(ours.watermark(), 0, "an invalidated plane must read incomplete on every handle");
264
+ assert_eq!(theirs.watermark(), 0);
265
+ std::fs::remove_file(stale_path_for(&path)).unwrap();
266
+ open_is_refused(&path);
267
+ }
268
+
269
+ /// A planted symlink at the sidecar path must not be followed, on the first invalidation
270
+ /// (create-new) and on a repeat that finds the marker swapped for a link: the victim keeps
271
+ /// its bytes and the sidecar is reported as not durable.
272
+ #[cfg(unix)]
273
+ #[test]
274
+ fn a_symlink_at_the_sidecar_path_is_never_followed() {
275
+ let path = tmp("symlink");
276
+ complete_looking_plane(&path);
277
+ let victim = path.with_file_name("victim.txt");
278
+ std::fs::write(&victim, b"precious").unwrap();
279
+ std::os::unix::fs::symlink(&victim, stale_path_for(&path)).unwrap();
280
+ let outcome = invalidate_plane(&path).expect("in band still lands");
281
+ assert!(outcome.sidecar.is_err(), "a symlink at the sidecar path must be refused");
282
+ assert_eq!(std::fs::read(&victim).unwrap(), b"precious");
283
+ let err = open_existing_marker(&stale_path_for(&path)).err().expect("the no-follow reopen must refuse a link");
284
+ assert!(err.raw_os_error().is_some() || err.to_string().contains("regular file"), "{err}");
285
+ }
286
+
287
+ /// The package enforces the markers at open: a sidecar alone (the plane's own header was
288
+ /// never reached) refuses the open, and a create over a leftover sidecar refuses too
289
+ /// rather than minting a plane that can never be opened again.
290
+ #[test]
291
+ fn open_and_create_refuse_a_path_with_a_sidecar() {
292
+ let path = tmp("sidecaropen");
293
+ complete_looking_plane(&path);
294
+ std::fs::write(stale_path_for(&path), b"").unwrap();
295
+ open_is_refused(&path);
296
+ let err = PlaneFile::create(&path, 8, 8, 256).err().expect("create must refuse");
297
+ assert!(err.to_string().contains("stale"), "{err}");
298
+ std::fs::remove_file(stale_path_for(&path)).unwrap();
299
+ assert_eq!(PlaneFile::open(&path).expect("clean again").watermark(), 4_096);
300
+ }
301
+
302
+ /// The temporary handle must be gone before the call returns: the file is deletable
303
+ /// (which its mapping would block on Windows) and its registry slot reads dead to a
304
+ /// concurrent opener (Linux, where the registry exists).
305
+ #[test]
306
+ fn the_temporary_handle_is_released_before_returning() {
307
+ let path = tmp("release");
308
+ complete_looking_plane(&path);
309
+ let observer = PlaneFile::open(&path).expect("a concurrent opener");
310
+ invalidate_plane(&path).expect("invalidate");
311
+ #[cfg(target_os = "linux")]
312
+ {
313
+ let registered: Vec<u32> = observer.registered_tags().into_iter().filter(|&t| t != observer.self_tag).collect();
314
+ assert!(!registered.is_empty(), "the temporary open must have registered itself");
315
+ for tag in registered {
316
+ assert!(observer.tag_is_dead(tag), "registry tag {tag:#x} still reads alive after the call returned");
317
+ }
318
+ }
319
+ drop(observer);
320
+ std::fs::remove_file(&path).expect("no mapping of ours may hold the file");
321
+ }
322
+
323
+ /// Through the caller's own handle nothing is opened here, so no registry slot is claimed.
324
+ #[test]
325
+ fn an_attached_handle_means_no_temporary_open() {
326
+ let path = tmp("attached");
327
+ complete_looking_plane(&path);
328
+ let attached = PlaneFile::open(&path).expect("open");
329
+ let before = attached.registered_tags();
330
+ let outcome = invalidate_file(&attached).expect("invalidate");
331
+ assert!(outcome.in_band.is_ok() && outcome.sidecar.is_ok(), "{outcome:?}");
332
+ assert_eq!(attached.registered_tags(), before, "no second opener may appear");
333
+ assert_eq!(attached.watermark(), 0);
334
+ assert!(stale_path_for(&path).is_file());
335
+ }
336
+ }
package/src/lib.rs CHANGED
@@ -1,11 +1,12 @@
1
1
  //! hnsw-plane: native HNSW traversal plane over a memory-mapped fixed-slot file.
2
- //! Design: ../../hnsw-native-plane.md. NAPI bindings land behind the `napi` feature in
2
+ //! Design: ../DESIGN.md. NAPI bindings land behind the `napi` feature in
3
3
  //! phase-1 integration; the core is buildable and benchmarkable standalone.
4
4
 
5
5
  pub mod distance;
6
6
  pub mod format;
7
7
  pub mod graph;
8
8
  pub mod insert;
9
+ pub mod invalidate;
9
10
  #[cfg(feature = "napi")]
10
11
  mod napi;
11
12
  pub mod search;
@@ -13,3 +14,4 @@ pub mod seqlock;
13
14
 
14
15
  pub use format::PlaneFile;
15
16
  pub use graph::Graph;
17
+ pub use invalidate::{invalidate_file, invalidate_plane, stale_path_for, Invalidation};