kvbm_engine/offload/pending.rs
1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Pending transfer tracking for duplicate prevention.
5//!
6//! This module provides `PendingTracker` and `PendingGuard` types that work together
7//! to track blocks that are currently in-flight through the transfer pipeline.
8//!
9//! # Problem
10//!
11//! When overlapping sequences are enqueued for transfer at roughly the same time,
12//! the presence policy may allow duplicate transfers because:
13//! - The first sequence's blocks haven't completed registration yet
14//! - The second sequence sees the same blocks as "not present"
15//!
16//! # Solution
17//!
18//! The `PendingTracker` maintains a set of sequence hashes currently in the pipeline.
19//! When blocks pass policy evaluation, a `PendingGuard` is created that:
20//! - Adds the sequence hash to the pending set on creation
21//! - Automatically removes it on drop (RAII pattern)
22//!
23//! The `PresenceFilter` can then check both the registry (completed transfers)
24//! AND the pending set (in-flight transfers) to avoid duplicates.
25//!
26//! # Example
27//!
28//! ```ignore
29//! let tracker = Arc::new(PendingTracker::new());
30//!
31//! // Create guard when block passes policy
32//! let guard = tracker.guard(sequence_hash);
33//!
34//! // Guard travels with block through pipeline stages
35//! queued_block.pending_guard = Some(guard);
36//!
37//! // When block completes or is cancelled, guard is dropped
38//! // and hash is automatically removed from pending set
39//! ```
40
41use std::sync::Arc;
42
43use dashmap::DashSet;
44
45use crate::SequenceHash;
46
47/// Tracks sequence hashes that are currently pending transfer.
48///
49/// This is shared between the pipeline and the presence policy via `Arc`.
50/// Thread-safe for concurrent access from multiple pipeline stages.
51#[derive(Debug, Default)]
52pub struct PendingTracker {
53 pending: DashSet<SequenceHash>,
54}
55
56impl PendingTracker {
57 /// Create a new empty pending tracker.
58 pub fn new() -> Self {
59 Self {
60 pending: DashSet::new(),
61 }
62 }
63
64 /// Check if a sequence hash is currently pending transfer.
65 ///
66 /// Used by `PresenceFilter` to skip blocks that are already in-flight.
67 pub fn is_pending(&self, hash: &SequenceHash) -> bool {
68 self.pending.contains(hash)
69 }
70
71 /// Get the number of pending transfers.
72 ///
73 /// Useful for metrics and debugging.
74 pub fn len(&self) -> usize {
75 self.pending.len()
76 }
77
78 /// Check if there are no pending transfers.
79 pub fn is_empty(&self) -> bool {
80 self.pending.is_empty()
81 }
82
83 /// Create a guard that marks a sequence hash as pending until dropped.
84 ///
85 /// The guard uses RAII to ensure the hash is removed when:
86 /// - Transfer completes successfully
87 /// - Transfer is cancelled
88 /// - Block is evicted from pipeline
89 /// - Any error causes the block to be dropped
90 pub fn guard(self: &Arc<Self>, hash: SequenceHash) -> PendingGuard {
91 self.pending.insert(hash);
92 PendingGuard {
93 hash,
94 tracker: Arc::clone(self),
95 }
96 }
97}
98
99/// Extension trait for `Option<Arc<PendingTracker>>` to simplify pending checks.
100///
101/// Reduces the common pattern `self.pending_tracker.as_ref().is_some_and(|t| t.is_pending(&hash))`
102/// to a single method call.
103pub(crate) trait PendingCheck {
104 fn is_hash_pending(&self, hash: &SequenceHash) -> bool;
105}
106
107impl PendingCheck for Option<Arc<PendingTracker>> {
108 fn is_hash_pending(&self, hash: &SequenceHash) -> bool {
109 self.as_ref().is_some_and(|t| t.is_pending(hash))
110 }
111}
112
113/// RAII guard that removes a sequence hash from the pending set on drop.
114///
115/// This guard travels with the block through all pipeline stages and ensures
116/// cleanup happens automatically regardless of how the transfer completes.
117///
118/// # Clone Behavior
119///
120/// Cloning a `PendingGuard` is cheap (Arc clone) but does NOT create a new
121/// pending entry. The hash is only inserted once when the first guard is
122/// created, and removed when ALL clones are dropped.
123///
124/// However, the current implementation removes on first drop, so cloning
125/// should be avoided unless you understand the implications.
126pub struct PendingGuard {
127 hash: SequenceHash,
128 tracker: Arc<PendingTracker>,
129}
130
131impl PendingGuard {
132 /// Get the sequence hash this guard is tracking.
133 pub fn sequence_hash(&self) -> SequenceHash {
134 self.hash
135 }
136}
137
138impl Drop for PendingGuard {
139 fn drop(&mut self) {
140 self.tracker.pending.remove(&self.hash);
141 }
142}
143
144impl std::fmt::Debug for PendingGuard {
145 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146 f.debug_struct("PendingGuard")
147 .field("sequence_hash", &self.hash)
148 .finish()
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 /// Helper to create a test SequenceHash with unique values.
157 fn test_hash(id: u64) -> SequenceHash {
158 SequenceHash::new(id, Some(0), id)
159 }
160
161 #[test]
162 fn test_pending_tracker_new() {
163 let tracker = PendingTracker::new();
164 assert!(tracker.is_empty());
165 assert_eq!(tracker.len(), 0);
166 }
167
168 #[test]
169 fn test_pending_guard_inserts_and_removes() {
170 let tracker = Arc::new(PendingTracker::new());
171 let hash = test_hash(12345);
172
173 assert!(!tracker.is_pending(&hash));
174
175 {
176 let _guard = tracker.guard(hash);
177 assert!(tracker.is_pending(&hash));
178 assert_eq!(tracker.len(), 1);
179 }
180
181 // Guard dropped, hash should be removed
182 assert!(!tracker.is_pending(&hash));
183 assert!(tracker.is_empty());
184 }
185
186 #[test]
187 fn test_multiple_guards_different_hashes() {
188 let tracker = Arc::new(PendingTracker::new());
189 let hash1 = test_hash(111);
190 let hash2 = test_hash(222);
191 let hash3 = test_hash(333);
192
193 let guard1 = tracker.guard(hash1);
194 let guard2 = tracker.guard(hash2);
195
196 assert!(tracker.is_pending(&hash1));
197 assert!(tracker.is_pending(&hash2));
198 assert!(!tracker.is_pending(&hash3));
199 assert_eq!(tracker.len(), 2);
200
201 drop(guard1);
202 assert!(!tracker.is_pending(&hash1));
203 assert!(tracker.is_pending(&hash2));
204 assert_eq!(tracker.len(), 1);
205
206 drop(guard2);
207 assert!(tracker.is_empty());
208 }
209
210 #[test]
211 fn test_guard_sequence_hash_accessor() {
212 let tracker = Arc::new(PendingTracker::new());
213 let hash = test_hash(42);
214
215 let guard = tracker.guard(hash);
216 assert_eq!(guard.sequence_hash(), hash);
217 }
218
219 #[test]
220 fn test_tracker_debug() {
221 let tracker = PendingTracker::new();
222 let debug_str = format!("{:?}", tracker);
223 assert!(debug_str.contains("PendingTracker"));
224 }
225
226 #[test]
227 fn test_guard_debug() {
228 let tracker = Arc::new(PendingTracker::new());
229 let hash = test_hash(999);
230 let guard = tracker.guard(hash);
231
232 let debug_str = format!("{:?}", guard);
233 assert!(debug_str.contains("PendingGuard"));
234 assert!(debug_str.contains("sequence_hash"));
235 }
236
237 #[test]
238 fn test_concurrent_access_to_same_hash() {
239 // Test that the same hash being added twice is handled correctly
240 let tracker = Arc::new(PendingTracker::new());
241 let hash = test_hash(555);
242
243 // First guard marks it as pending
244 let guard1 = tracker.guard(hash);
245 assert!(tracker.is_pending(&hash));
246 assert_eq!(tracker.len(), 1);
247
248 // Second guard for same hash - DashSet.insert returns false if already present
249 // but our guard() always inserts (doesn't check first)
250 let guard2 = tracker.guard(hash);
251 assert!(tracker.is_pending(&hash));
252 // DashSet deduplicates, so len is still 1
253 assert_eq!(tracker.len(), 1);
254
255 // Drop first guard - hash removed from set
256 drop(guard1);
257 // DashSet now doesn't have the hash
258 assert!(!tracker.is_pending(&hash));
259
260 // Second guard still exists but hash was already removed
261 // This is expected behavior - the RAII ensures cleanup on any drop
262 drop(guard2);
263 assert!(tracker.is_empty());
264 }
265}