@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/Cargo.lock +1 -0
- package/DESIGN.md +95 -9
- package/README.md +16 -6
- package/build.mjs +7 -6
- package/index.d.ts +42 -1
- package/index.js +39 -18
- package/package.json +8 -3
- package/src/bin/bench.rs +1 -1
- package/src/format.rs +241 -19
- package/src/graph.rs +263 -69
- package/src/insert.rs +141 -40
- package/src/invalidate.rs +336 -0
- package/src/lib.rs +3 -1
- package/src/napi.rs +92 -4
- package/src/search.rs +413 -87
- package/prebuilds/darwin-arm64/hnsw-plane.node +0 -0
- package/prebuilds/linux-arm64/hnsw-plane.node +0 -0
- package/prebuilds/linux-x64/hnsw-plane.node +0 -0
- package/prebuilds/win32-x64/hnsw-plane.node +0 -0
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::{
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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};
|