@harperfast/hnsw 0.1.0

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/search.rs ADDED
@@ -0,0 +1,477 @@
1
+ //! Beam search over the plane, zero-copy: per-visit cost is one seqlock-guarded distance
2
+ //! against mmap bytes plus primitive heap/visited ops. Visited tracking is an epoch-stamped
3
+ //! array; neighbor ids stream through a reusable scratch buffer.
4
+
5
+ use crate::distance::Query;
6
+ use crate::format::NO_ID;
7
+ use crate::graph::Graph;
8
+ use std::cmp::Ordering as CmpOrdering;
9
+ use std::collections::BinaryHeap;
10
+
11
+ #[derive(PartialEq)]
12
+ struct Candidate {
13
+ distance: f32,
14
+ id: u32,
15
+ }
16
+ impl Eq for Candidate {}
17
+ impl Ord for Candidate {
18
+ fn cmp(&self, other: &Self) -> CmpOrdering {
19
+ // min-heap by distance via reverse
20
+ other.distance.partial_cmp(&self.distance).unwrap_or(CmpOrdering::Equal)
21
+ }
22
+ }
23
+ impl PartialOrd for Candidate {
24
+ fn partial_cmp(&self, other: &Self) -> Option<CmpOrdering> {
25
+ Some(self.cmp(other))
26
+ }
27
+ }
28
+
29
+ #[derive(PartialEq)]
30
+ struct Result_ {
31
+ distance: f32,
32
+ id: u32,
33
+ }
34
+ impl Eq for Result_ {}
35
+ impl Ord for Result_ {
36
+ fn cmp(&self, other: &Self) -> CmpOrdering {
37
+ // max-heap by distance (worst result on top for eviction)
38
+ self.distance.partial_cmp(&other.distance).unwrap_or(CmpOrdering::Equal)
39
+ }
40
+ }
41
+ impl PartialOrd for Result_ {
42
+ fn partial_cmp(&self, other: &Self) -> Option<CmpOrdering> {
43
+ Some(self.cmp(other))
44
+ }
45
+ }
46
+
47
+ /// Reusable per-thread search scratch.
48
+ pub struct SearchScratch {
49
+ visited: Vec<u32>,
50
+ epoch: u32,
51
+ neighbors: Vec<u32>,
52
+ }
53
+
54
+ impl SearchScratch {
55
+ pub fn new() -> Self {
56
+ SearchScratch { visited: Vec::new(), epoch: 0, neighbors: Vec::new() }
57
+ }
58
+
59
+ pub fn begin_public(&mut self, capacity: u64) {
60
+ self.begin(capacity)
61
+ }
62
+
63
+ fn begin(&mut self, capacity: u64) {
64
+ if self.visited.len() < capacity as usize {
65
+ self.visited.resize(capacity as usize, 0);
66
+ }
67
+ self.epoch = self.epoch.wrapping_add(1);
68
+ if self.epoch == 0 {
69
+ self.visited.fill(0);
70
+ self.epoch = 1;
71
+ }
72
+ }
73
+
74
+ #[inline]
75
+ fn visit(&mut self, id: u32) -> bool {
76
+ // ids minted by concurrent inserts after begin() can exceed the sizing snapshot;
77
+ // growth is bounded by the id itself, which write paths bound by max_nodes
78
+ if id as usize >= self.visited.len() {
79
+ self.visited.resize(id as usize + 1024, 0);
80
+ }
81
+ let slot = &mut self.visited[id as usize];
82
+ if *slot == self.epoch {
83
+ false
84
+ } else {
85
+ *slot = self.epoch;
86
+ true
87
+ }
88
+ }
89
+ }
90
+
91
+ impl Default for SearchScratch {
92
+ fn default() -> Self {
93
+ Self::new()
94
+ }
95
+ }
96
+
97
+ pub struct SearchStats {
98
+ pub visits: u64,
99
+ }
100
+
101
+ #[inline]
102
+ fn bit_allowed(filter: Option<&[u8]>, id: u32) -> bool {
103
+ match filter {
104
+ None => true,
105
+ Some(bits) => {
106
+ let byte = (id >> 3) as usize;
107
+ byte < bits.len() && bits[byte] & (1 << (id & 7)) != 0
108
+ }
109
+ }
110
+ }
111
+
112
+ /// Beam search within one layer, starting from `entry`. Level 0 reads slot adjacency;
113
+ /// upper levels read the resident upper map. Returns (id, distance) ascending by distance.
114
+ /// Assumes scratch.begin() was called for this query; entry is marked visited here.
115
+ ///
116
+ /// `filter`: optional allow-bitset over node ids (bit i of byte i>>3). Filtered-out nodes
117
+ /// are traversed (their edges route) but excluded from results — ACORN-style — with
118
+ /// `visit_budget` bounding total visits so a selective filter terminates.
119
+ pub fn search_layer(
120
+ graph: &Graph,
121
+ query: &Query,
122
+ entry: u32,
123
+ entry_dist: f32,
124
+ ef: usize,
125
+ level: u8,
126
+ scratch: &mut SearchScratch,
127
+ stats: &mut SearchStats,
128
+ filter: Option<&[u8]>,
129
+ visit_budget: u64,
130
+ ) -> Vec<(u32, f32)> {
131
+ let mut candidates = BinaryHeap::new();
132
+ let mut results: BinaryHeap<Result_> = BinaryHeap::new();
133
+ scratch.visit(entry);
134
+ candidates.push(Candidate { distance: entry_dist, id: entry });
135
+ if bit_allowed(filter, entry) {
136
+ results.push(Result_ { distance: entry_dist, id: entry });
137
+ }
138
+
139
+ // take() the scratch neighbor buffer to sidestep the double-borrow of scratch
140
+ let mut nbuf = std::mem::take(&mut scratch.neighbors);
141
+
142
+ while let Some(c) = candidates.pop() {
143
+ let worst = results.peek().map(|r| r.distance).unwrap_or(f32::INFINITY);
144
+ if results.len() >= ef && c.distance > worst {
145
+ break;
146
+ }
147
+ if stats.visits >= visit_budget {
148
+ break;
149
+ }
150
+ if level == 0 {
151
+ if graph.neighbors_into(c.id, &mut nbuf).is_none() {
152
+ continue;
153
+ }
154
+ } else {
155
+ graph.upper_neighbors_into(c.id, level, &mut nbuf);
156
+ }
157
+ for i in 0..nbuf.len() {
158
+ let nid = nbuf[i];
159
+ if (nid as u64) >= graph.file.max_nodes {
160
+ continue; // corrupt/torn neighbor id: skip rather than size allocations by it
161
+ }
162
+ if !scratch.visit(nid) {
163
+ continue;
164
+ }
165
+ if let Some(d) = graph.distance_to(nid, query) {
166
+ stats.visits += 1;
167
+ let worst = results.peek().map(|r| r.distance).unwrap_or(f32::INFINITY);
168
+ if results.len() < ef || d < worst {
169
+ candidates.push(Candidate { distance: d, id: nid });
170
+ if bit_allowed(filter, nid) {
171
+ results.push(Result_ { distance: d, id: nid });
172
+ if results.len() > ef {
173
+ results.pop();
174
+ }
175
+ }
176
+ }
177
+ }
178
+ }
179
+ }
180
+ scratch.neighbors = nbuf;
181
+
182
+ let mut out: Vec<(u32, f32)> = results.into_iter().map(|r| (r.id, r.distance)).collect();
183
+ out.sort_by(|a, b| a.1.total_cmp(&b.1));
184
+ out
185
+ }
186
+
187
+ /// Greedy single-candidate descent through upper layers from `from_level` down to
188
+ /// `to_level` (exclusive lower bound handled by caller loops). Returns improved entry.
189
+ pub fn greedy_descend(
190
+ graph: &Graph,
191
+ query: &Query,
192
+ mut current: u32,
193
+ mut current_dist: f32,
194
+ from_level: u32,
195
+ to_level: u32,
196
+ stats: &mut SearchStats,
197
+ ) -> (u32, f32) {
198
+ let mut nbuf: Vec<u32> = Vec::new();
199
+ let mut level = from_level;
200
+ while level > to_level {
201
+ let mut improved = true;
202
+ while improved {
203
+ improved = false;
204
+ graph.upper_neighbors_into(current, level.min(255) as u8, &mut nbuf);
205
+ for i in 0..nbuf.len() {
206
+ let nid = nbuf[i];
207
+ if let Some(d) = graph.distance_to(nid, query) {
208
+ stats.visits += 1;
209
+ if d < current_dist {
210
+ current = nid;
211
+ current_dist = d;
212
+ improved = true;
213
+ }
214
+ }
215
+ }
216
+ }
217
+ level -= 1;
218
+ }
219
+ (current, current_dist)
220
+ }
221
+
222
+ /// Full search: greedy descent through upper layers, then beam at layer 0.
223
+ pub fn search(
224
+ graph: &Graph,
225
+ query: &Query,
226
+ k: usize,
227
+ ef: usize,
228
+ scratch: &mut SearchScratch,
229
+ ) -> (Vec<(u32, f32)>, SearchStats) {
230
+ let mut stats = SearchStats { visits: 0 };
231
+ let (entry_id, entry_level) = graph.file.entry_point();
232
+ if entry_id == NO_ID {
233
+ return (Vec::new(), stats);
234
+ }
235
+ scratch.begin(graph.file.id_high_water());
236
+
237
+ let entry_dist = match graph.distance_to(entry_id, query) {
238
+ Some(d) => {
239
+ stats.visits += 1;
240
+ d
241
+ }
242
+ None => return (Vec::new(), stats),
243
+ };
244
+ let (ep, ep_dist) = greedy_descend(graph, query, entry_id, entry_dist, entry_level, 0, &mut stats);
245
+ let mut out = search_layer(graph, query, ep, ep_dist, ef, 0, scratch, &mut stats, None, u64::MAX);
246
+ out.truncate(k);
247
+ (out, stats)
248
+ }
249
+
250
+ /// Full search with an optional allow-bitset filter. `filter_expansion` multiplies ef into
251
+ /// the visit budget when a filter is present (matching the JS filterExpansion semantics).
252
+ pub fn search_filtered(
253
+ graph: &Graph,
254
+ query: &Query,
255
+ k: usize,
256
+ ef: usize,
257
+ filter: Option<&[u8]>,
258
+ filter_expansion: usize,
259
+ scratch: &mut SearchScratch,
260
+ ) -> (Vec<(u32, f32)>, SearchStats) {
261
+ let mut stats = SearchStats { visits: 0 };
262
+ let (entry_id, entry_level) = graph.file.entry_point();
263
+ if entry_id == NO_ID {
264
+ return (Vec::new(), stats);
265
+ }
266
+ scratch.begin_public(graph.file.id_high_water());
267
+ let entry_dist = match graph.distance_to(entry_id, query) {
268
+ Some(d) => {
269
+ stats.visits += 1;
270
+ d
271
+ }
272
+ None => return (Vec::new(), stats),
273
+ };
274
+ let (ep, ep_dist) = greedy_descend(graph, query, entry_id, entry_dist, entry_level, 0, &mut stats);
275
+ let budget = if filter.is_some() { (ef * filter_expansion) as u64 } else { u64::MAX };
276
+ let mut out = search_layer(graph, query, ep, ep_dist, ef, 0, scratch, &mut stats, filter, budget);
277
+ out.truncate(k);
278
+ (out, stats)
279
+ }
280
+
281
+ /// Pipelined predicate filtering: candidate ids are batched to an external evaluator (the
282
+ /// NAPI layer wires this to a JS ThreadsafeFunction) while traversal continues expanding —
283
+ /// the search thread never blocks on the evaluator until the beam itself is done. Verdicts
284
+ /// steer result admission only; routing uses pure distance order, bounded by the visit
285
+ /// budget, so a slow or saturated JS loop degrades speculative overshoot, not correctness.
286
+ pub struct PredicatePipe {
287
+ /// Sends one batch of ids for evaluation. Must not block.
288
+ pub dispatch: Box<dyn FnMut(Vec<u32>) + Send>,
289
+ /// Receives (ids, verdicts) pairs; verdicts[i] != 0 admits ids[i].
290
+ pub rx: std::sync::mpsc::Receiver<(Vec<u32>, Vec<u8>)>,
291
+ }
292
+
293
+ const PREDICATE_BATCH: usize = 64;
294
+ const DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
295
+
296
+ /// Full search with a pipelined predicate filter (upper-layer descent is unfiltered, as in
297
+ /// the JS implementation — predicates gate results, not routing). `visit_budget` is the
298
+ /// absolute layer-0 visit cap: hosts pass their own resolved budget directly, since a
299
+ /// multiplier-of-ef encoding cannot express a budget below ef.
300
+ pub fn search_predicated(
301
+ graph: &Graph,
302
+ query: &Query,
303
+ k: usize,
304
+ ef: usize,
305
+ pipe: &mut PredicatePipe,
306
+ visit_budget: u64,
307
+ scratch: &mut SearchScratch,
308
+ ) -> (Vec<(u32, f32)>, SearchStats) {
309
+ let mut stats = SearchStats { visits: 0 };
310
+ let (entry_id, entry_level) = graph.file.entry_point();
311
+ if entry_id == NO_ID {
312
+ return (Vec::new(), stats);
313
+ }
314
+ scratch.begin_public(graph.file.id_high_water());
315
+ let entry_dist = match graph.distance_to(entry_id, query) {
316
+ Some(d) => {
317
+ stats.visits += 1;
318
+ d
319
+ }
320
+ None => return (Vec::new(), stats),
321
+ };
322
+ let (ep, ep_dist) = greedy_descend(graph, query, entry_id, entry_dist, entry_level, 0, &mut stats);
323
+
324
+ use std::collections::HashMap;
325
+ let mut verdicts: HashMap<u32, bool> = HashMap::new();
326
+ let mut speculative: Vec<(u32, f32)> = Vec::new(); // awaiting verdicts
327
+ let mut batch: Vec<u32> = Vec::new();
328
+ let mut outstanding = 0usize;
329
+
330
+ let mut candidates = BinaryHeap::new();
331
+ let mut results: BinaryHeap<Result_> = BinaryHeap::new();
332
+ scratch.visit(ep);
333
+ candidates.push(Candidate { distance: ep_dist, id: ep });
334
+ speculative.push((ep, ep_dist));
335
+ batch.push(ep);
336
+
337
+ let mut nbuf = std::mem::take(&mut scratch.neighbors);
338
+
339
+ macro_rules! drain {
340
+ ($recv:expr) => {
341
+ while let Ok((ids, flags)) = $recv {
342
+ outstanding -= 1;
343
+ for (i, id) in ids.iter().enumerate() {
344
+ verdicts.insert(*id, flags.get(i).copied().unwrap_or(0) != 0);
345
+ }
346
+ }
347
+ };
348
+ }
349
+
350
+ loop {
351
+ // non-blocking verdict intake each iteration
352
+ drain!(pipe.rx.try_recv());
353
+ if !verdicts.is_empty() && !speculative.is_empty() {
354
+ speculative.retain(|&(id, d)| match verdicts.get(&id) {
355
+ Some(true) => {
356
+ results.push(Result_ { distance: d, id });
357
+ if results.len() > ef {
358
+ results.pop();
359
+ }
360
+ false
361
+ }
362
+ Some(false) => false,
363
+ None => true,
364
+ });
365
+ }
366
+
367
+ let Some(c) = candidates.pop() else { break };
368
+ let worst = results.peek().map(|r| r.distance).unwrap_or(f32::INFINITY);
369
+ if results.len() >= ef && c.distance > worst {
370
+ break;
371
+ }
372
+ if stats.visits >= visit_budget {
373
+ break;
374
+ }
375
+ if graph.neighbors_into(c.id, &mut nbuf).is_none() {
376
+ continue;
377
+ }
378
+ for i in 0..nbuf.len() {
379
+ let nid = nbuf[i];
380
+ if (nid as u64) >= graph.file.max_nodes {
381
+ continue; // corrupt/torn neighbor id: skip rather than size allocations by it
382
+ }
383
+ if !scratch.visit(nid) {
384
+ continue;
385
+ }
386
+ if let Some(d) = graph.distance_to(nid, query) {
387
+ stats.visits += 1;
388
+ let worst = results.peek().map(|r| r.distance).unwrap_or(f32::INFINITY);
389
+ if results.len() < ef || d < worst {
390
+ candidates.push(Candidate { distance: d, id: nid });
391
+ speculative.push((nid, d));
392
+ batch.push(nid);
393
+ if batch.len() >= PREDICATE_BATCH {
394
+ (pipe.dispatch)(std::mem::take(&mut batch));
395
+ outstanding += 1;
396
+ }
397
+ }
398
+ }
399
+ }
400
+ }
401
+ scratch.neighbors = nbuf;
402
+
403
+ // flush the tail batch and block-drain what's still in flight
404
+ if !batch.is_empty() {
405
+ (pipe.dispatch)(std::mem::take(&mut batch));
406
+ outstanding += 1;
407
+ }
408
+ let deadline = std::time::Instant::now() + DRAIN_TIMEOUT;
409
+ while outstanding > 0 && std::time::Instant::now() < deadline {
410
+ drain!(pipe.rx.recv_timeout(std::time::Duration::from_millis(50)));
411
+ }
412
+ speculative.retain(|&(id, d)| {
413
+ if verdicts.get(&id).copied().unwrap_or(false) {
414
+ results.push(Result_ { distance: d, id });
415
+ if results.len() > ef {
416
+ results.pop();
417
+ }
418
+ }
419
+ false
420
+ });
421
+
422
+ let mut out: Vec<(u32, f32)> = results.into_iter().map(|r| (r.id, r.distance)).collect();
423
+ out.sort_by(|a, b| a.1.total_cmp(&b.1));
424
+ out.truncate(k);
425
+ (out, stats)
426
+ }
427
+
428
+ #[cfg(test)]
429
+ mod predicate_tests {
430
+ use super::*;
431
+ use crate::insert::{insert, InsertParams};
432
+ use crate::PlaneFile;
433
+
434
+ #[test]
435
+ fn pipelined_predicate_filters_results() {
436
+ let dims = 32;
437
+ let path = std::env::temp_dir().join(format!("hnsw-pred-{}.hnsw", std::process::id()));
438
+ let _ = std::fs::remove_file(&path);
439
+ let file = PlaneFile::create(&path, dims, 16, 4_096).expect("create");
440
+ let graph = Graph::new(file);
441
+ let params = InsertParams::default();
442
+ let mut scratch = SearchScratch::new();
443
+ for i in 0..1_000u32 {
444
+ let v: Vec<f32> = (0..dims).map(|d| ((i as f32 * 0.31 + d as f32) * 0.7).sin()).collect();
445
+ insert(&graph, &v, &params, &mut scratch).unwrap();
446
+ }
447
+
448
+ // evaluator thread: admit even ids only, answering over a channel like the TSFN does
449
+ let (req_tx, req_rx) = std::sync::mpsc::channel::<Vec<u32>>();
450
+ let (res_tx, res_rx) = std::sync::mpsc::channel::<(Vec<u32>, Vec<u8>)>();
451
+ let worker = std::thread::spawn(move || {
452
+ while let Ok(ids) = req_rx.recv() {
453
+ let verdicts: Vec<u8> = ids.iter().map(|id| (id % 2 == 0) as u8).collect();
454
+ if res_tx.send((ids, verdicts)).is_err() {
455
+ break;
456
+ }
457
+ }
458
+ });
459
+
460
+ let mut pipe = PredicatePipe {
461
+ dispatch: Box::new(move |ids| {
462
+ let _ = req_tx.send(ids);
463
+ }),
464
+ rx: res_rx,
465
+ };
466
+ let q: Vec<f32> = (0..dims).map(|d| ((41.0f32 * 0.31 + d as f32) * 0.7).sin()).collect();
467
+ let (hits, _) =
468
+ search_predicated(&graph, &Query::new(q), 10, 64, &mut pipe, 64 * 24, &mut scratch);
469
+ assert!(!hits.is_empty());
470
+ for (id, _) in &hits {
471
+ assert_eq!(id % 2, 0, "odd id {id} leaked through the predicate");
472
+ }
473
+ drop(pipe);
474
+ worker.join().unwrap();
475
+ let _ = std::fs::remove_file(&path);
476
+ }
477
+ }
package/src/seqlock.rs ADDED
@@ -0,0 +1,217 @@
1
+ //! Per-slot lock with owner identity. The lock word is a u32: bit 31 set = locked, low 31
2
+ //! bits = the owner handle's registry tag (see format.rs); unlocked values are generations
3
+ //! (bit 31 clear) that change on every release, so readers validate a consistent snapshot
4
+ //! seqlock-style.
5
+ //!
6
+ //! Crash recovery happens at the contended slot: a waiter that has watched the SAME locked
7
+ //! value for a full window asks `owner_dead(tag)` — implemented over kernel-owned file
8
+ //! locks that die with the owner's open handle, so it is immune to pid reuse, container
9
+ //! pid-1 restarts, and pid namespaces. Only a provably dead owner is taken over, and the
10
+ //! taker first runs `sanitize` (marking the payload deleted): a dead writer's payload is
11
+ //! half-written and must read as absent until rewritten. When liveness is unknowable
12
+ //! (non-Linux platforms, an unregistered handle), readers return `fallback()` after the
13
+ //! window instead of waiting forever, and writers keep waiting.
14
+
15
+ use std::sync::atomic::{AtomicU32, Ordering};
16
+ use std::time::{Duration, Instant};
17
+
18
+ pub const LOCKED: u32 = 1 << 31;
19
+ pub const GEN_MASK: u32 = LOCKED - 1;
20
+ /// A lock value decomposes as: bit 31 LOCKED | salt(13 bits) | handle identity(19 bits).
21
+ /// The identity — registry slot (6 bits) + the handle's per-open epoch (12 bits) — is what
22
+ /// liveness is keyed on; the salt changes every acquisition so back-to-back writers from ONE
23
+ /// handle still change the observed value (a waiter that never sees an unlocked window must
24
+ /// still see progress, or healthy same-handle churn would trip the wedge bound).
25
+ pub const TAG_MASK: u32 = (1 << 19) - 1;
26
+
27
+ #[inline]
28
+ fn acquisition_value(self_tag: u32) -> u32 {
29
+ use std::cell::Cell;
30
+ use std::sync::atomic::AtomicU32 as GlobalCounter;
31
+ // each thread's salt stream starts at a globally unique offset — identical thread-local
32
+ // streams across threads could publish identical lock values, making back-to-back
33
+ // acquisitions indistinguishable from one long hold
34
+ static NEXT_STREAM: GlobalCounter = GlobalCounter::new(1);
35
+ thread_local! {
36
+ static SALT: Cell<u32> = Cell::new(NEXT_STREAM.fetch_add(0x2545_f491, Ordering::Relaxed));
37
+ }
38
+ let salt = SALT.with(|c| {
39
+ let v = c.get().wrapping_add(1);
40
+ c.set(v);
41
+ v
42
+ });
43
+ LOCKED | (((salt << 19) | (self_tag & TAG_MASK)) & GEN_MASK)
44
+ }
45
+
46
+ /// How long a locked value must stay unchanged before the owner's liveness is checked.
47
+ const TAKEOVER_AFTER: Duration = Duration::from_millis(20);
48
+ /// Hard bound on waiting for a lock this thread cannot reclaim (owner alive-or-unknowable:
49
+ /// an unregistered handle's abandoned lock, a deadlocked live thread). A live writer's
50
+ /// critical section is microseconds, so five seconds of one unchanged locked value means
51
+ /// the slot is wedged — surfacing an error beats hanging a caller forever.
52
+ const WRITE_WEDGE_AFTER: Duration = Duration::from_secs(5);
53
+
54
+ /// The slot's lock could not be acquired or reclaimed within the wedge bound.
55
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
56
+ pub struct Wedged;
57
+ const SPINS_BEFORE_CLOCK: u32 = 1 << 10;
58
+
59
+ /// A fresh generation for a takeover release: the previous generation is unknowable, so it
60
+ /// must be a value no in-flight reader plausibly holds as its first snapshot.
61
+ #[inline]
62
+ fn fresh_generation() -> u32 {
63
+ let nanos = std::time::SystemTime::now()
64
+ .duration_since(std::time::UNIX_EPOCH)
65
+ .map(|d| d.subsec_nanos())
66
+ .unwrap_or(0);
67
+ (nanos ^ (std::process::id() << 10)) & GEN_MASK
68
+ }
69
+
70
+ pub struct SeqWriteGuard<'a> {
71
+ seq: &'a AtomicU32,
72
+ release_gen: u32,
73
+ }
74
+
75
+ impl Drop for SeqWriteGuard<'_> {
76
+ fn drop(&mut self) {
77
+ self.seq.store(self.release_gen, Ordering::Release);
78
+ }
79
+ }
80
+
81
+ enum Stale {
82
+ No,
83
+ DeadOwner(u32),
84
+ UnknownPastWindow,
85
+ }
86
+
87
+ /// Track how long one locked value has been observed; decide staleness.
88
+ struct StaleWatch {
89
+ seen: u32,
90
+ since: Option<Instant>,
91
+ }
92
+
93
+ impl StaleWatch {
94
+ fn new() -> Self {
95
+ StaleWatch { seen: 0, since: None }
96
+ }
97
+
98
+ fn observe(&mut self, locked_value: u32, owner_dead: &impl Fn(u32) -> bool) -> Stale {
99
+ if self.seen != locked_value || self.since.is_none() {
100
+ self.seen = locked_value;
101
+ self.since = Some(Instant::now());
102
+ return Stale::No;
103
+ }
104
+ if self.since.map(|at| at.elapsed() < TAKEOVER_AFTER).unwrap_or(true) {
105
+ return Stale::No;
106
+ }
107
+ if owner_dead(locked_value & GEN_MASK) {
108
+ Stale::DeadOwner(locked_value)
109
+ } else {
110
+ Stale::UnknownPastWindow
111
+ }
112
+ }
113
+ }
114
+
115
+ /// Acquire write ownership of a slot. `self_tag` identifies this handle in the lock word;
116
+ /// `sanitize` runs (holding the lock) only after a takeover from a dead owner; `owner_dead`
117
+ /// decides takeover eligibility. A lock held past the window by an owner that is alive or
118
+ /// unknowable is simply waited on.
119
+ pub fn write_lock<'a>(
120
+ seq: &'a AtomicU32,
121
+ self_tag: u32,
122
+ sanitize: impl Fn(),
123
+ owner_dead: impl Fn(u32) -> bool,
124
+ ) -> Result<SeqWriteGuard<'a>, Wedged> {
125
+ let mut spins = 0u32;
126
+ let mut watch = StaleWatch::new();
127
+ let mut wedged_since: Option<Instant> = None;
128
+ loop {
129
+ let cur = seq.load(Ordering::Acquire);
130
+ if cur & LOCKED == 0 {
131
+ if seq
132
+ .compare_exchange_weak(cur, acquisition_value(self_tag), Ordering::AcqRel, Ordering::Acquire)
133
+ .is_ok()
134
+ {
135
+ return Ok(SeqWriteGuard { seq, release_gen: cur.wrapping_add(1) & GEN_MASK });
136
+ }
137
+ wedged_since = None;
138
+ } else {
139
+ spins += 1;
140
+ if spins > SPINS_BEFORE_CLOCK {
141
+ match watch.observe(cur, &owner_dead) {
142
+ Stale::DeadOwner(observed) => {
143
+ if seq
144
+ .compare_exchange(observed, acquisition_value(self_tag), Ordering::AcqRel, Ordering::Acquire)
145
+ .is_ok()
146
+ {
147
+ sanitize();
148
+ return Ok(SeqWriteGuard { seq, release_gen: fresh_generation() });
149
+ }
150
+ wedged_since = None;
151
+ }
152
+ Stale::UnknownPastWindow => {
153
+ // unreclaimable (unregistered owner tag, or an alive-but-stuck
154
+ // holder): bounded wait, then surface the wedge instead of hanging
155
+ let since = *wedged_since.get_or_insert_with(Instant::now);
156
+ if since.elapsed() > WRITE_WEDGE_AFTER {
157
+ return Err(Wedged);
158
+ }
159
+ }
160
+ // the lock VALUE moved: owners are cycling, i.e. real progress — a busy
161
+ // slot must never trip the wedge bound
162
+ Stale::No => wedged_since = None,
163
+ }
164
+ std::thread::yield_now();
165
+ continue;
166
+ }
167
+ }
168
+ std::hint::spin_loop();
169
+ }
170
+ }
171
+
172
+ /// Run `read` until it observes a stable (unlocked, unchanged) generation. `read` must be
173
+ /// side-effect-free on retry. A dead owner's lock is taken over (sanitizing the payload)
174
+ /// and the read retried; an alive-or-unknowable owner past the window makes this return
175
+ /// `fallback()` rather than stall a search indefinitely.
176
+ #[inline]
177
+ pub fn read_consistent<T>(
178
+ seq: &AtomicU32,
179
+ self_tag: u32,
180
+ mut read: impl FnMut() -> T,
181
+ sanitize: impl Fn(),
182
+ fallback: impl FnOnce() -> T,
183
+ owner_dead: impl Fn(u32) -> bool,
184
+ ) -> T {
185
+ let mut spins = 0u32;
186
+ let mut watch = StaleWatch::new();
187
+ loop {
188
+ let before = seq.load(Ordering::Acquire);
189
+ if before & LOCKED == 0 {
190
+ let value = read();
191
+ std::sync::atomic::fence(Ordering::Acquire);
192
+ if seq.load(Ordering::Relaxed) == before {
193
+ return value;
194
+ }
195
+ } else {
196
+ spins += 1;
197
+ if spins > SPINS_BEFORE_CLOCK {
198
+ match watch.observe(before, &owner_dead) {
199
+ Stale::DeadOwner(observed) => {
200
+ if seq
201
+ .compare_exchange(observed, acquisition_value(self_tag), Ordering::AcqRel, Ordering::Acquire)
202
+ .is_ok()
203
+ {
204
+ sanitize();
205
+ seq.store(fresh_generation(), Ordering::Release);
206
+ }
207
+ }
208
+ Stale::UnknownPastWindow => return fallback(),
209
+ Stale::No => {}
210
+ }
211
+ std::thread::yield_now();
212
+ continue;
213
+ }
214
+ }
215
+ std::hint::spin_loop();
216
+ }
217
+ }