@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/format.rs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
//! On-disk format: 4 KB header + fixed-size layer-0 slot array + upper-layer region.
|
|
2
|
-
//! See
|
|
2
|
+
//! See ../DESIGN.md §4. Format changes bump VERSION and require reindex.
|
|
3
3
|
|
|
4
4
|
use memmap2::MmapMut;
|
|
5
5
|
use std::fs::OpenOptions;
|
|
6
6
|
use std::io;
|
|
7
|
-
use std::path::Path;
|
|
8
|
-
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
|
7
|
+
use std::path::{Path, PathBuf};
|
|
8
|
+
use std::sync::atomic::{AtomicU32, AtomicU64, AtomicU8, Ordering};
|
|
9
9
|
|
|
10
10
|
pub const MAGIC: u32 = 0x484e_5357; // "HNSW"
|
|
11
|
-
pub const VERSION: u32 =
|
|
11
|
+
pub const VERSION: u32 = 7; // v7: sticky invalidation latch; v6: 4-aligned neighbor + upper id arrays (older files: reindex)
|
|
12
12
|
pub const HEADER_SIZE: usize = 4096;
|
|
13
13
|
|
|
14
14
|
// Header field byte offsets.
|
|
@@ -23,6 +23,12 @@ const H_ID_HIGH_WATER: usize = 32; // u64 atomic
|
|
|
23
23
|
const H_FREELIST_HEAD: usize = 40; // u64 atomic: (tag << 32) | id; id u32::MAX = empty
|
|
24
24
|
const H_TXN_WATERMARK: usize = 48; // u64
|
|
25
25
|
const H_CLEAN_SHUTDOWN: usize = 56; // u8
|
|
26
|
+
// One-way: set by invalidate(), cleared by nothing. While set the watermark reads 0 on every
|
|
27
|
+
// handle whatever a racing flush stamps into it, and open() refuses the file.
|
|
28
|
+
const H_INVALIDATED: usize = 57; // u8
|
|
29
|
+
// Bumped by every node write through any handle: the search-side repair probe's evidence
|
|
30
|
+
// that a fully dead graph may have gained a live node since it last came back empty.
|
|
31
|
+
const H_WRITE_EPOCH: usize = 96; // u64 atomic
|
|
26
32
|
const H_MAX_NODES: usize = 64; // u64
|
|
27
33
|
const H_UPPER_HIGH_WATER: usize = 72; // u64 atomic: upper-entry allocator
|
|
28
34
|
const H_UPPER_FREELIST: usize = 80; // u64 atomic: (tag<<32)|idx; NO_UPPER = empty
|
|
@@ -39,11 +45,15 @@ pub const REGISTRY_SLOTS: usize = 64;
|
|
|
39
45
|
/// 1/8 of max_nodes (2x headroom). P(level >= 9) at mL = 1/ln16 is ~e^-25 — unreachable.
|
|
40
46
|
pub const MAX_UPPER_LEVELS: usize = 8;
|
|
41
47
|
pub const UPPER_CAP: usize = 64; // matches the JS graph's upper cap (M<<2 under optimizeRouting)
|
|
42
|
-
// entry: seq u32 | levels u8 | pad | per-level (degree u16 + ids u32*UPPER_CAP)
|
|
48
|
+
// entry: seq u32 | levels u8 | pad | per-level (degree u16 + pad u16 + ids u32*UPPER_CAP)
|
|
43
49
|
pub const U_SEQ: usize = 0;
|
|
44
50
|
pub const U_LEVELS: usize = 4;
|
|
45
51
|
pub const U_LISTS: usize = 8;
|
|
46
|
-
|
|
52
|
+
/// The pad follows the degree rather than the ids so every id array starts 4-aligned; the
|
|
53
|
+
/// stride (and so the entry size) is unchanged either way.
|
|
54
|
+
pub const UL_DEGREE: usize = 0;
|
|
55
|
+
pub const UL_IDS: usize = 4;
|
|
56
|
+
pub const UPPER_LEVEL_STRIDE: usize = UL_IDS + UPPER_CAP * 4;
|
|
47
57
|
pub const NO_UPPER: u32 = u32::MAX;
|
|
48
58
|
|
|
49
59
|
// Slot layout offsets (within a slot).
|
|
@@ -55,8 +65,15 @@ pub const S_SCALE: usize = 8; // f32
|
|
|
55
65
|
pub const S_INV_MAG: usize = 12; // f32
|
|
56
66
|
pub const S_UPPER_IDX: usize = 16; // u32 index into the upper region; NO_UPPER = none
|
|
57
67
|
pub const S_VECTOR: usize = 20; // dims bytes (int8) or dims*4 (f32)
|
|
58
|
-
// neighbors: u32 * layer0_cap, follows vector
|
|
59
|
-
|
|
68
|
+
// neighbors: u32 * layer0_cap, follows the 4-padded vector
|
|
69
|
+
|
|
70
|
+
/// Byte offset of a slot's neighbor array. The vector is padded to a 4-byte boundary so this
|
|
71
|
+
/// is 4-aligned for every dims: the search hot path then reads each neighbor as one aligned
|
|
72
|
+
/// volatile u32 instead of four byte loads plus shifts.
|
|
73
|
+
#[inline]
|
|
74
|
+
pub const fn neighbor_offset(dims: usize) -> usize {
|
|
75
|
+
S_VECTOR + (dims + 3) / 4 * 4
|
|
76
|
+
}
|
|
60
77
|
|
|
61
78
|
pub const FLAG_VALID: u8 = 1;
|
|
62
79
|
pub const FLAG_DELETED: u8 = 2;
|
|
@@ -65,6 +82,9 @@ pub const NO_ID: u32 = u32::MAX;
|
|
|
65
82
|
pub struct PlaneFile {
|
|
66
83
|
/// Kept open for the lifetime of the mapping: the opener-registry OFD lock lives on it.
|
|
67
84
|
file: std::fs::File,
|
|
85
|
+
/// The path this handle opened or created, as given; the sidecar of `invalidate_file` is
|
|
86
|
+
/// placed next to it.
|
|
87
|
+
pub path: PathBuf,
|
|
68
88
|
/// This handle's registry tag (low bits encode its registry slot). 0 = unregistered
|
|
69
89
|
/// (registry full or platform without OFD locks): this handle's own dead locks cannot be
|
|
70
90
|
/// reclaimed by others, and it never reclaims.
|
|
@@ -77,8 +97,9 @@ pub struct PlaneFile {
|
|
|
77
97
|
upper_offset: usize,
|
|
78
98
|
pub upper_capacity: u64,
|
|
79
99
|
/// Whether the file recorded a clean shutdown when opened (create() reports true).
|
|
80
|
-
///
|
|
81
|
-
///
|
|
100
|
+
/// Advisory only: open() performs no repair — torn seqlocks are taken over lazily at
|
|
101
|
+
/// their slot (seqlock.rs) — and slots may hold unflushed states; hosts rebuild rather
|
|
102
|
+
/// than trust completeness.
|
|
82
103
|
pub opened_clean: bool,
|
|
83
104
|
/// Slots per 4 KB page under page-grouped addressing; 0 = packed (slots may straddle
|
|
84
105
|
/// pages). Grouped is chosen at create when the per-page waste is small (e.g. 1,344 B
|
|
@@ -90,8 +111,32 @@ pub struct PlaneFile {
|
|
|
90
111
|
const PAGE: usize = 4096;
|
|
91
112
|
const H_SLOTS_PER_PAGE: usize = 20; // u16
|
|
92
113
|
|
|
114
|
+
/// MADV_RANDOM: hosts packing many instances live in permanent memory pressure, where
|
|
115
|
+
/// evict-and-refault is steady state; default readahead pulls ~16 unwanted pages per random
|
|
116
|
+
/// re-fault, taxing every tenant's page cache. The plane has no sequential reader to protect
|
|
117
|
+
/// (search is pointer-chasing, the builder writes, backfill scans read the host store).
|
|
118
|
+
fn advise_random(map: &MmapMut) {
|
|
119
|
+
#[cfg(unix)]
|
|
120
|
+
let _ = map.advise(memmap2::Advice::Random);
|
|
121
|
+
#[cfg(not(unix))]
|
|
122
|
+
let _ = map;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
fn stale_sidecar_present(path: &Path) -> bool {
|
|
126
|
+
// any entry counts, a directory or dangling link included, and so does any stat failure
|
|
127
|
+
// other than absence: a marker that fails closed cannot be defeated by a transient EIO
|
|
128
|
+
match std::fs::symlink_metadata(crate::invalidate::stale_path_for(path)) {
|
|
129
|
+
Ok(_) => true,
|
|
130
|
+
Err(e) => e.kind() != io::ErrorKind::NotFound,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
fn invalidated_error(path: &Path) -> io::Error {
|
|
135
|
+
io::Error::new(io::ErrorKind::InvalidData, format!("{} was invalidated: delete it and its .stale sidecar, then rebuild the index", path.display()))
|
|
136
|
+
}
|
|
137
|
+
|
|
93
138
|
fn slot_size_for(dims: usize, layer0_cap: usize) -> usize {
|
|
94
|
-
let raw =
|
|
139
|
+
let raw = neighbor_offset(dims) + layer0_cap * 4;
|
|
95
140
|
raw.next_multiple_of(64) // cache-line align
|
|
96
141
|
}
|
|
97
142
|
|
|
@@ -123,6 +168,11 @@ impl PlaneFile {
|
|
|
123
168
|
if max_nodes >= NO_ID as u64 {
|
|
124
169
|
return Err(io::Error::new(io::ErrorKind::InvalidInput, "maxNodes must be below 2^32-1"));
|
|
125
170
|
}
|
|
171
|
+
if stale_sidecar_present(path) {
|
|
172
|
+
// a leftover sidecar would make the new file unopenable forever; the host must
|
|
173
|
+
// clear it deliberately
|
|
174
|
+
return Err(io::Error::other(format!("{} has a stale sidecar: remove {} before creating", path.display(), crate::invalidate::stale_path_for(path).display())));
|
|
175
|
+
}
|
|
126
176
|
let slot_size = slot_size_for(dims, layer0_cap);
|
|
127
177
|
let slots_per_page = slots_per_page_for(slot_size);
|
|
128
178
|
let data_len = slot_region_len(max_nodes, slot_size, slots_per_page);
|
|
@@ -131,6 +181,7 @@ impl PlaneFile {
|
|
|
131
181
|
let file = OpenOptions::new().read(true).write(true).create(true).truncate(true).open(path)?;
|
|
132
182
|
file.set_len(len)?;
|
|
133
183
|
let mut map = unsafe { MmapMut::map_mut(&file)? };
|
|
184
|
+
advise_random(&map);
|
|
134
185
|
// geometry and allocator state first; MAGIC+VERSION last, so a concurrent opener
|
|
135
186
|
// in the create window sees an invalid header (retryable) rather than adopting a
|
|
136
187
|
// half-initialized plane with max_nodes = 0
|
|
@@ -144,12 +195,16 @@ impl PlaneFile {
|
|
|
144
195
|
.copy_from_slice(&((NO_ID as u64) | 0u64 << 32).to_le_bytes());
|
|
145
196
|
map[H_MAX_NODES..H_MAX_NODES + 8].copy_from_slice(&max_nodes.to_le_bytes());
|
|
146
197
|
map[H_UPPER_FREELIST..H_UPPER_FREELIST + 8].copy_from_slice(&(NO_UPPER as u64).to_le_bytes());
|
|
198
|
+
// zero would read as "node 0 was the previous entry point" and hand every re-election
|
|
199
|
+
// and search-side repair a candidate that was never an entry point
|
|
200
|
+
map[H_ENTRY_PREV..H_ENTRY_PREV + 8].copy_from_slice(&(NO_ID as u64).to_le_bytes());
|
|
147
201
|
map[H_VERSION..H_VERSION + 4].copy_from_slice(&VERSION.to_le_bytes());
|
|
148
202
|
std::sync::atomic::fence(Ordering::Release);
|
|
149
203
|
map[H_MAGIC..H_MAGIC + 4].copy_from_slice(&MAGIC.to_le_bytes());
|
|
150
204
|
let upper_offset = HEADER_SIZE + slot_region_len(max_nodes, slot_size, slots_per_page) as usize;
|
|
151
205
|
let mut plane = PlaneFile {
|
|
152
206
|
file,
|
|
207
|
+
path: path.to_path_buf(),
|
|
153
208
|
self_tag: 0,
|
|
154
209
|
map,
|
|
155
210
|
dims,
|
|
@@ -162,10 +217,36 @@ impl PlaneFile {
|
|
|
162
217
|
opened_clean: true,
|
|
163
218
|
};
|
|
164
219
|
plane.register_opener();
|
|
220
|
+
if stale_sidecar_present(path) {
|
|
221
|
+
// best-effort: an invalidation that raced the create (its in-band leg found no
|
|
222
|
+
// header yet, or was overwritten by ours) left only the sidecar. Latch the finished
|
|
223
|
+
// file so losing that sidecar cannot make this failed create adoptable. A sidecar
|
|
224
|
+
// landing after this check is caught by the next open, not by this handle.
|
|
225
|
+
let _ = plane.invalidate();
|
|
226
|
+
return Err(io::Error::other(format!("{} gained a stale sidecar during create: remove {} and rebuild", path.display(), crate::invalidate::stale_path_for(path).display())));
|
|
227
|
+
}
|
|
165
228
|
Ok(plane)
|
|
166
229
|
}
|
|
167
230
|
|
|
231
|
+
/// Open an existing plane. Refuses one that was invalidated — by its header latch or by a
|
|
232
|
+
/// `<path>.stale` sidecar — so a stale mirror is never adopted by any package consumer;
|
|
233
|
+
/// the host deletes both files and rebuilds.
|
|
168
234
|
pub fn open(path: &Path) -> io::Result<Self> {
|
|
235
|
+
if stale_sidecar_present(path) {
|
|
236
|
+
return Err(invalidated_error(path));
|
|
237
|
+
}
|
|
238
|
+
let plane = Self::open_for_invalidation(path)?;
|
|
239
|
+
// the pre-map check is only half the refusal: a sidecar landed by an invalidation
|
|
240
|
+
// whose in-band leg failed (no latch to see) can appear between the check and the map
|
|
241
|
+
if plane.invalidated() || stale_sidecar_present(path) {
|
|
242
|
+
return Err(invalidated_error(path));
|
|
243
|
+
}
|
|
244
|
+
Ok(plane)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/// `open` without the invalidation refusals: the handle `invalidate_plane` marks through,
|
|
248
|
+
/// which must reach an already-invalidated file so a repeated invalidation is idempotent.
|
|
249
|
+
pub(crate) fn open_for_invalidation(path: &Path) -> io::Result<Self> {
|
|
169
250
|
let file = OpenOptions::new().read(true).write(true).open(path)?;
|
|
170
251
|
let file_len = file.metadata()?.len();
|
|
171
252
|
if file_len < HEADER_SIZE as u64 {
|
|
@@ -173,6 +254,7 @@ impl PlaneFile {
|
|
|
173
254
|
return Err(io::Error::new(io::ErrorKind::InvalidData, "plane file shorter than its header: recreate the index"));
|
|
174
255
|
}
|
|
175
256
|
let map = unsafe { MmapMut::map_mut(&file)? };
|
|
257
|
+
advise_random(&map);
|
|
176
258
|
let magic = u32::from_le_bytes(map[H_MAGIC..H_MAGIC + 4].try_into().unwrap());
|
|
177
259
|
let version = u32::from_le_bytes(map[H_VERSION..H_VERSION + 4].try_into().unwrap());
|
|
178
260
|
if magic != MAGIC || version != VERSION {
|
|
@@ -207,6 +289,7 @@ impl PlaneFile {
|
|
|
207
289
|
let opened_clean = map[H_CLEAN_SHUTDOWN] == 1;
|
|
208
290
|
let mut plane = PlaneFile {
|
|
209
291
|
file,
|
|
292
|
+
path: path.to_path_buf(),
|
|
210
293
|
self_tag: 0,
|
|
211
294
|
map,
|
|
212
295
|
dims,
|
|
@@ -230,8 +313,6 @@ impl PlaneFile {
|
|
|
230
313
|
Ok(plane)
|
|
231
314
|
}
|
|
232
315
|
|
|
233
|
-
/// Force any persisted-odd seqlocks (slot + upper regions) back to even after an unclean
|
|
234
|
-
/// shutdown. Safe because open() runs before any concurrent access exists.
|
|
235
316
|
#[inline]
|
|
236
317
|
pub fn slot_ptr(&self, id: u32) -> *const u8 {
|
|
237
318
|
let off = if self.slots_per_page > 0 {
|
|
@@ -282,8 +363,9 @@ impl PlaneFile {
|
|
|
282
363
|
let _ = head.compare_exchange(cur, NO_ID as u64, Ordering::AcqRel, Ordering::Acquire);
|
|
283
364
|
continue;
|
|
284
365
|
}
|
|
285
|
-
// next-pointer lives in the dead slot's scale field
|
|
286
|
-
//
|
|
366
|
+
// next-pointer lives in the dead slot's scale field rather than its first neighbor
|
|
367
|
+
// word: the neighbor array is a live reader's aligned volatile load target, and a
|
|
368
|
+
// freelist pointer parked there would be decoded as a neighbor id
|
|
287
369
|
let next = unsafe { (*(self.slot_ptr(id).add(S_SCALE) as *const AtomicU32)).load(Ordering::Acquire) };
|
|
288
370
|
let tag = (cur >> 32).wrapping_add(1);
|
|
289
371
|
let new = (next as u64) | (tag << 32);
|
|
@@ -327,6 +409,10 @@ impl PlaneFile {
|
|
|
327
409
|
self.header_atomic_u64(H_ID_HIGH_WATER).load(Ordering::Acquire)
|
|
328
410
|
}
|
|
329
411
|
|
|
412
|
+
pub fn upper_high_water(&self) -> u64 {
|
|
413
|
+
self.header_atomic_u64(H_UPPER_HIGH_WATER).load(Ordering::Acquire)
|
|
414
|
+
}
|
|
415
|
+
|
|
330
416
|
/// Entry point (id, level), read as one atomic word — a torn (new id, old level) pair
|
|
331
417
|
/// would blind a racing search.
|
|
332
418
|
pub fn entry_point(&self) -> (u32, u32) {
|
|
@@ -336,25 +422,112 @@ impl PlaneFile {
|
|
|
336
422
|
|
|
337
423
|
pub fn set_entry_point(&self, id: u32, level: u32) {
|
|
338
424
|
let prev = self.header_atomic_u64(H_ENTRY).swap((id as u64) | ((level as u64) << 32), Ordering::AcqRel);
|
|
339
|
-
|
|
340
|
-
|
|
425
|
+
self.record_previous_entry(prev, id);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/// Remember the entry point a PROMOTION displaced. Only promotions are recorded: the node
|
|
429
|
+
/// they displace was live and high-level, which is what makes it a usable hint. Recording
|
|
430
|
+
/// a re-election's replacement instead would fill the hint with the dead node that forced
|
|
431
|
+
/// the re-election.
|
|
432
|
+
#[inline]
|
|
433
|
+
fn record_previous_entry(&self, prev_packed: u64, new_id: u32) {
|
|
434
|
+
let prev_id = (prev_packed & 0xffff_ffff) as u32;
|
|
435
|
+
if prev_id == NO_ID || prev_id == new_id || (prev_id as u64) >= self.max_nodes {
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
// a hint is only worth keeping while its node is live: the host mirrors a post-delete
|
|
439
|
+
// re-election through this same call, and storing the node that died would evict a
|
|
440
|
+
// usable hint with one the repair path can never follow
|
|
441
|
+
// volatile like every other read of a field a concurrent writer mutates (graph.rs's
|
|
442
|
+
// `vread`): this one is outside the slot seqlock, so the retry cannot even catch a tear
|
|
443
|
+
if unsafe { self.slot_ptr(prev_id).add(S_FLAGS).read_volatile() } != FLAG_VALID {
|
|
444
|
+
return;
|
|
341
445
|
}
|
|
446
|
+
self.header_atomic_u64(H_ENTRY_PREV).store(prev_packed, Ordering::Release);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/// Claim the entry point of an EMPTY graph: a strict compare-exchange from the empty
|
|
450
|
+
/// encoding, so exactly one racer wins. `set_entry_point_if_not_better` cannot serve here —
|
|
451
|
+
/// it is a not-worse install, so a second first-inserter would replace the winner with its
|
|
452
|
+
/// own edgeless node and orphan everything already rooted at the winner. A loser must join
|
|
453
|
+
/// the winner's graph instead of returning an unlinked node.
|
|
454
|
+
pub fn claim_entry_if_empty(&self, id: u32, level: u32) -> bool {
|
|
455
|
+
self.header_atomic_u64(H_ENTRY)
|
|
456
|
+
.compare_exchange(NO_ID as u64, (id as u64) | ((level as u64) << 32), Ordering::AcqRel, Ordering::Acquire)
|
|
457
|
+
.is_ok()
|
|
342
458
|
}
|
|
343
459
|
|
|
344
460
|
/// Entry-point CAS for re-election: install (id, level) only while the current entry is
|
|
345
461
|
/// still `expected_id` or is of a lower level — a concurrent insert that just promoted a
|
|
346
462
|
/// higher-level entry must not be clobbered by a delete's level-0 survivor.
|
|
347
463
|
pub fn set_entry_point_if_not_better(&self, id: u32, level: u32, expected_id: u32) {
|
|
464
|
+
self.cas_entry_if_not_better(id, level, expected_id, false);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/// The same CAS for an insert that PROMOTED itself above the entry it observed: the
|
|
468
|
+
/// displaced entry is live, so it is recorded as the previous-entry hint that re-election
|
|
469
|
+
/// and the search-side repair both consult before any O(high-water) scan.
|
|
470
|
+
pub fn promote_entry_point(&self, id: u32, level: u32, expected_id: u32) {
|
|
471
|
+
self.cas_entry_if_not_better(id, level, expected_id, true);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
fn cas_entry_if_not_better(&self, id: u32, level: u32, expected_id: u32, record_prev: bool) {
|
|
348
475
|
let cell = self.header_atomic_u64(H_ENTRY);
|
|
349
476
|
let new = (id as u64) | ((level as u64) << 32);
|
|
350
477
|
let mut cur = cell.load(Ordering::Acquire);
|
|
351
478
|
loop {
|
|
352
479
|
let cur_id = (cur & 0xffff_ffff) as u32;
|
|
353
480
|
let cur_level = (cur >> 32) as u32;
|
|
354
|
-
|
|
355
|
-
|
|
481
|
+
// `>=`, not `>`: an equal-level entry installed meanwhile may be a fresh
|
|
482
|
+
// `claim_entry_if_empty` winner with no in-edges yet; displacing it orphans that
|
|
483
|
+
// node, and an equal-level swap gains nothing
|
|
484
|
+
if cur_id != expected_id && cur_id != NO_ID && cur_level >= level {
|
|
485
|
+
return; // someone installed a not-worse entry meanwhile
|
|
356
486
|
}
|
|
357
487
|
match cell.compare_exchange(cur, new, Ordering::AcqRel, Ordering::Acquire) {
|
|
488
|
+
Ok(_) => {
|
|
489
|
+
if record_prev {
|
|
490
|
+
self.record_previous_entry(cur, id);
|
|
491
|
+
}
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
Err(now) => cur = now,
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/// Install `(id, level)` ONLY while the entry still names `expected_id`. The read-side
|
|
500
|
+
/// repair publishes through this rather than `set_entry_point_if_not_better`: the entry it
|
|
501
|
+
/// is replacing is dead, so "not worse" is the wrong test — a level-0 root installed while
|
|
502
|
+
/// the repair ran would lose to a higher-level candidate and be orphaned.
|
|
503
|
+
///
|
|
504
|
+
/// It compares the id, not the incarnation, so under the crate's own freelist reuse it can
|
|
505
|
+
/// match a different node that took the same slot. That is a routing-quality window, not a
|
|
506
|
+
/// lost node: the value it could displace is a live edged node, never the edgeless claimer
|
|
507
|
+
/// (`claim_entry_if_empty` fires only from NO_ID, which no reuse can produce). Harper's host
|
|
508
|
+
/// ids are monotonic and never reused, so this cannot arise there at all.
|
|
509
|
+
pub fn replace_entry_if(&self, expected_id: u32, id: u32, level: u32) -> bool {
|
|
510
|
+
let cell = self.header_atomic_u64(H_ENTRY);
|
|
511
|
+
let new = (id as u64) | ((level as u64) << 32);
|
|
512
|
+
let mut cur = cell.load(Ordering::Acquire);
|
|
513
|
+
while (cur & 0xffff_ffff) as u32 == expected_id {
|
|
514
|
+
match cell.compare_exchange(cur, new, Ordering::AcqRel, Ordering::Acquire) {
|
|
515
|
+
Ok(_) => return true,
|
|
516
|
+
Err(now) => cur = now,
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
false
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/// Clear the entry point, but only while it still names `expected_id`. A re-election that
|
|
523
|
+
/// found no candidate must not erase an entry a concurrent insert installed meanwhile —
|
|
524
|
+
/// `set_entry_point_if_not_better(NO_ID, 0, ..)` would, because a level-0 live entry is not
|
|
525
|
+
/// "better" than the level-0 clear.
|
|
526
|
+
pub fn clear_entry_point_if(&self, expected_id: u32) {
|
|
527
|
+
let cell = self.header_atomic_u64(H_ENTRY);
|
|
528
|
+
let mut cur = cell.load(Ordering::Acquire);
|
|
529
|
+
while (cur & 0xffff_ffff) as u32 == expected_id {
|
|
530
|
+
match cell.compare_exchange(cur, NO_ID as u64, Ordering::AcqRel, Ordering::Acquire) {
|
|
358
531
|
Ok(_) => return,
|
|
359
532
|
Err(now) => cur = now,
|
|
360
533
|
}
|
|
@@ -365,10 +538,34 @@ impl PlaneFile {
|
|
|
365
538
|
self.header_atomic_u64(H_TXN_WATERMARK).store(txn, Ordering::Release);
|
|
366
539
|
}
|
|
367
540
|
|
|
541
|
+
/// The completion stamp — 0, "incomplete mirror", once the plane is invalidated, whatever
|
|
542
|
+
/// a flush racing the invalidation wrote into the word afterwards.
|
|
368
543
|
pub fn watermark(&self) -> u64 {
|
|
544
|
+
if self.invalidated() {
|
|
545
|
+
return 0;
|
|
546
|
+
}
|
|
369
547
|
self.header_atomic_u64(H_TXN_WATERMARK).load(Ordering::Acquire)
|
|
370
548
|
}
|
|
371
549
|
|
|
550
|
+
#[inline]
|
|
551
|
+
fn invalidated_cell(&self) -> &AtomicU8 {
|
|
552
|
+
unsafe { &*(self.map.as_ptr().add(H_INVALIDATED) as *const AtomicU8) }
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
pub fn write_epoch(&self) -> u64 {
|
|
556
|
+
self.header_atomic_u64(H_WRITE_EPOCH).load(Ordering::Acquire)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/// Release-ordered so a probe that acquires the new epoch also sees the slot it publishes.
|
|
560
|
+
pub fn bump_write_epoch(&self) {
|
|
561
|
+
self.header_atomic_u64(H_WRITE_EPOCH).fetch_add(1, Ordering::Release);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/// Whether the one-way invalidation latch is set (by this or any other handle).
|
|
565
|
+
pub fn invalidated(&self) -> bool {
|
|
566
|
+
self.invalidated_cell().load(Ordering::Acquire) != 0
|
|
567
|
+
}
|
|
568
|
+
|
|
372
569
|
#[inline]
|
|
373
570
|
pub fn upper_ptr(&self, idx: u32) -> *const u8 {
|
|
374
571
|
debug_assert!((idx as u64) < self.upper_capacity);
|
|
@@ -474,6 +671,12 @@ impl PlaneFile {
|
|
|
474
671
|
}
|
|
475
672
|
}
|
|
476
673
|
|
|
674
|
+
/// Every nonzero registry tag, live or not (liveness is `tag_is_dead`).
|
|
675
|
+
#[cfg(test)]
|
|
676
|
+
pub(crate) fn registered_tags(&self) -> Vec<u32> {
|
|
677
|
+
(0..REGISTRY_SLOTS).map(|slot| self.registry_tag_cell(slot).load(Ordering::Acquire)).filter(|&t| t != 0).collect()
|
|
678
|
+
}
|
|
679
|
+
|
|
477
680
|
/// Try to take the OFD write lock on a registry slot's byte range. `probe` releases it
|
|
478
681
|
/// immediately (liveness check); otherwise it is held for this handle's lifetime.
|
|
479
682
|
#[cfg(target_os = "linux")]
|
|
@@ -553,4 +756,23 @@ impl PlaneFile {
|
|
|
553
756
|
unsafe { *(self.map.as_ptr().add(H_CLEAN_SHUTDOWN) as *mut u8) = 1 };
|
|
554
757
|
self.map.flush_range(0, HEADER_SIZE)
|
|
555
758
|
}
|
|
759
|
+
|
|
760
|
+
/// Mark the plane invalidated, durably, and nothing else: set the one-way latch, zero the
|
|
761
|
+
/// watermark, and msync the header page alone. Every handle then reads watermark 0 and
|
|
762
|
+
/// every later `open` refuses the file. The latch is what makes this stick against a
|
|
763
|
+
/// `flush_with_watermark` already in flight on this or another handle: that flush still
|
|
764
|
+
/// stamps the word, but nothing reads the word past the latch.
|
|
765
|
+
///
|
|
766
|
+
/// Deliberately NOT `flush_with_watermark(Some(0))`: that writes the whole mapping back
|
|
767
|
+
/// first, and the caller invalidating a multi-GB plane cannot pay a full msync inline.
|
|
768
|
+
/// Skipping the data flush is sound because the data is being discarded, and because
|
|
769
|
+
/// lowering the watermark is the safe direction: the ordering hazard
|
|
770
|
+
/// `flush_with_watermark` exists to prevent is a NEW watermark over missing data, never
|
|
771
|
+
/// an old one over durable data. The stores precede the msync, so on an msync failure
|
|
772
|
+
/// the mark may still reach disk through ordinary writeback — also the safe direction.
|
|
773
|
+
pub fn invalidate(&self) -> io::Result<()> {
|
|
774
|
+
self.invalidated_cell().store(1, Ordering::Release);
|
|
775
|
+
self.set_watermark(0);
|
|
776
|
+
self.map.flush_range(0, HEADER_SIZE)
|
|
777
|
+
}
|
|
556
778
|
}
|