@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/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 ../../../hnsw-native-plane.md §4. Format changes bump VERSION and require reindex.
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 = 5; // v5: opener registry + aligned freelist pointer (older files: reindex)
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
- pub const UPPER_LEVEL_STRIDE: usize = 2 + UPPER_CAP * 4 + 2; // degree + ids + pad -> 132
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
- // deleted slots reuse the first neighbor word as freelist next-pointer
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
- /// An unclean open has had its torn seqlocks scrubbed, but individual slots may hold
81
- /// unflushed/partial stateshosts should rebuild rather than trust completeness.
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 = S_VECTOR + dims + layer0_cap * 4;
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: offset 8, aligned for any
286
- // dims (the first neighbor word at S_VECTOR+dims is 4-aligned only when dims%4==0)
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
- if (prev & 0xffff_ffff) as u32 != NO_ID && (prev & 0xffff_ffff) as u32 != id {
340
- self.header_atomic_u64(H_ENTRY_PREV).store(prev, Ordering::Release);
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
- if cur_id != expected_id && cur_id != NO_ID && cur_level > level {
355
- return; // someone installed a better entry meanwhile
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
  }