kvbm_engine/leader/session/responder.rs
1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use anyhow::Result;
5use tokio::sync::mpsc;
6
7use std::collections::HashSet;
8use std::sync::Arc;
9
10use crate::{BlockId, G2, G3, InstanceId, SequenceHash, worker::group::ParallelWorkers};
11use kvbm_logical::manager::BlockManager;
12
13use super::{BlockHolder, SessionId, messages::OnboardMessage, transport::MessageTransport};
14
15/// Responder-side session for handling block onboarding requests.
16///
17/// Lifecycle:
18/// 1. Spawned when receiving CreateSession
19/// 2. Searches local G2 for matches
20/// 3. Holds `ImmutableBlock<G2>` references (RAII)
21/// 4. Sends G2Results immediately
22/// 5. Searches local G3 for remaining matches (if G3 available)
23/// 6. Sends G3Results
24/// 7. Receives HoldBlocks and filters held G2 blocks
25/// 8. Receives StageBlocks and executes G3->G2 transfers
26/// 9. Sends BlocksReady when staging completes
27/// 10. Sends Acknowledged
28/// 11. Completes and drops (releases blocks)
29pub struct ResponderSession {
30 session_id: SessionId,
31 instance_id: InstanceId,
32 requester: InstanceId,
33 g2_manager: Arc<BlockManager<G2>>,
34 g3_manager: Option<Arc<BlockManager<G3>>>,
35 parallel_worker: Option<Arc<dyn ParallelWorkers>>,
36 transport: Arc<MessageTransport>,
37 // Held blocks using BlockHolder for RAII semantics
38 // Blocks are automatically released when the session drops
39 held_g2_blocks: BlockHolder<G2>,
40 held_g3_blocks: BlockHolder<G3>,
41}
42
43impl ResponderSession {
44 /// Create a new responder session.
45 pub fn new(
46 session_id: SessionId,
47 instance_id: InstanceId,
48 requester: InstanceId,
49 g2_manager: Arc<BlockManager<G2>>,
50 g3_manager: Option<Arc<BlockManager<G3>>>,
51 parallel_worker: Option<Arc<dyn ParallelWorkers>>,
52 transport: Arc<MessageTransport>,
53 ) -> Self {
54 Self {
55 session_id,
56 instance_id,
57 requester,
58 g2_manager,
59 g3_manager,
60 parallel_worker,
61 transport,
62 held_g2_blocks: BlockHolder::empty(),
63 held_g3_blocks: BlockHolder::empty(),
64 }
65 }
66
67 /// Run the responder session task.
68 ///
69 /// This is the main session loop that processes messages from the channel.
70 pub async fn run(
71 mut self,
72 mut rx: mpsc::Receiver<OnboardMessage>,
73 sequence_hashes: Vec<SequenceHash>,
74 ) -> Result<()> {
75 // Phase 1: Immediate G2 search
76 // Use scan_matches instead of match_blocks to find all matching blocks
77 // without stopping on first miss (supports partial sequence matching)
78 let g2_matches_map = self.g2_manager.scan_matches(&sequence_hashes, true);
79 let mut g2_matches: Vec<_> = g2_matches_map.into_values().collect();
80
81 // Sort by position to ensure G2Results are in position order
82 // HashMap iteration order is arbitrary, so we must sort explicitly
83 g2_matches.sort_by_key(|block| block.sequence_hash().position());
84
85 // Hold the G2 blocks using BlockHolder (RAII semantics)
86 self.held_g2_blocks = BlockHolder::new(g2_matches);
87
88 // Send G2 results immediately (fire-and-forget) with parallel arrays
89 let g2_sequence_hashes: Vec<SequenceHash> = self.held_g2_blocks.sequence_hashes();
90 let g2_block_ids: Vec<BlockId> = self
91 .held_g2_blocks
92 .blocks()
93 .iter()
94 .map(|b| b.block_id())
95 .collect();
96
97 let g2_msg = OnboardMessage::G2Results {
98 responder: self.instance_id,
99 session_id: self.session_id,
100 sequence_hashes: g2_sequence_hashes,
101 block_ids: g2_block_ids,
102 };
103 self.transport.send(self.requester, g2_msg).await?;
104
105 // Phase 2: Search G3 for remaining hashes (if G3 available)
106 let g2_matched_hashes: HashSet<SequenceHash> =
107 self.held_g2_blocks.sequence_hashes().into_iter().collect();
108
109 let remaining_hashes: Vec<SequenceHash> = sequence_hashes
110 .iter()
111 .filter(|h| !g2_matched_hashes.contains(h))
112 .copied()
113 .collect();
114
115 if !remaining_hashes.is_empty()
116 && let Some(ref g3_manager) = self.g3_manager
117 {
118 // Use scan_matches instead of match_blocks to find all matching blocks
119 // without stopping on first miss (supports partial sequence matching)
120 let g3_matches_map = g3_manager.scan_matches(&remaining_hashes, true);
121 let mut g3_matches: Vec<_> = g3_matches_map.into_values().collect();
122
123 // Sort by position to ensure G3Results are in position order
124 g3_matches.sort_by_key(|block| block.sequence_hash().position());
125
126 if !g3_matches.is_empty() {
127 // Hold the G3 blocks using BlockHolder
128 self.held_g3_blocks = BlockHolder::new(g3_matches);
129
130 // Send G3 results (sequence hashes only, keep order)
131 let g3_sequence_hashes: Vec<SequenceHash> = self.held_g3_blocks.sequence_hashes();
132
133 let g3_msg = OnboardMessage::G3Results {
134 responder: self.instance_id,
135 session_id: self.session_id,
136 sequence_hashes: g3_sequence_hashes,
137 };
138 self.transport.send(self.requester, g3_msg).await?;
139 }
140 }
141
142 // Send SearchComplete to signal we're done searching
143 let complete_msg = OnboardMessage::SearchComplete {
144 responder: self.instance_id,
145 session_id: self.session_id,
146 };
147 self.transport.send(self.requester, complete_msg).await?;
148
149 // Phase 3: Process incoming messages
150 while let Some(msg) = rx.recv().await {
151 match msg {
152 OnboardMessage::HoldBlocks {
153 hold_hashes,
154 drop_hashes: _,
155 ..
156 } => {
157 // Filter by sequence hash - BlockHolder's retain keeps only matching hashes
158 self.held_g2_blocks.retain(&hold_hashes);
159 self.held_g3_blocks.retain(&hold_hashes);
160
161 // Send acknowledgment
162 let ack = OnboardMessage::Acknowledged {
163 responder: self.instance_id,
164 session_id: self.session_id,
165 };
166 self.transport.send(self.requester, ack).await?;
167
168 // Always wait for CloseSession, even if no G3 blocks
169 // This ensures proper session lifecycle and avoids race conditions
170 // where initiator sends CloseSession after we've already exited
171 }
172
173 OnboardMessage::StageBlocks { stage_hashes, .. } => {
174 // Filter G3 blocks to only keep blocks to be staged
175 // BlockHolder's retain keeps only matching hashes
176 self.held_g3_blocks.retain(&stage_hashes);
177
178 if !self.held_g3_blocks.is_empty() {
179 if self.parallel_worker.is_some() {
180 // Execute G3->G2 transfer
181 self.stage_g3_to_g2().await?;
182 } else {
183 tracing::warn!(
184 session_id = %self.session_id,
185 g3_blocks = self.held_g3_blocks.count(),
186 "G3 blocks cannot be staged: no parallel worker configured"
187 );
188 }
189 }
190
191 // Don't exit - wait for CloseSession in Hold/Prepare modes
192 }
193
194 OnboardMessage::ReleaseBlocks { release_hashes, .. } => {
195 // Release specific blocks by sequence hash
196 // BlockHolder's release removes blocks with given hashes
197 self.held_g2_blocks.release(&release_hashes);
198 self.held_g3_blocks.release(&release_hashes);
199 }
200
201 // todo: how does close session drop the session from the dashmap?
202 // todo: do we need to handle this in the handler rather than the session responder loop?
203 OnboardMessage::CloseSession { .. } => {
204 // Session complete - release all blocks and exit
205 // take_all() explicitly releases the blocks
206 let _ = self.held_g2_blocks.take_all();
207 let _ = self.held_g3_blocks.take_all();
208 break;
209 }
210
211 OnboardMessage::CreateSession { .. } => {
212 // Duplicate CreateSession - ignore
213 }
214
215 // todo: be explicit about what messages are expected and what messages are unexpected
216 // on the responder session - avoid using the wildcard match
217 _ => {
218 // Unexpected message - log and ignore
219 tracing::warn!(
220 session_id = %self.session_id,
221 msg = ?msg,
222 "ResponderSession: unexpected message"
223 );
224 }
225 }
226
227 // TODO: Add heartbeat/TTL timeout handling
228 // If no message received within TTL duration:
229 // - Release all held blocks
230 // - Exit session
231 // Implementation:
232 // tokio::select! {
233 // msg = rx.recv() => { /* process message */ }
234 // _ = tokio::time::sleep_until(ttl_deadline) => {
235 // eprintln!("Session {} TTL expired, releasing blocks", self.session_id);
236 // break;
237 // }
238 // }
239 }
240
241 Ok(())
242 }
243
244 /// Stage G3 blocks to G2.
245 async fn stage_g3_to_g2(&mut self) -> Result<()> {
246 let parallel_worker = self
247 .parallel_worker
248 .as_ref()
249 .ok_or_else(|| anyhow::anyhow!("ParallelWorker required for G3->G2 staging"))?;
250
251 let result = super::staging::stage_g3_to_g2(
252 &self.held_g3_blocks,
253 &self.g2_manager,
254 &**parallel_worker,
255 )
256 .await?;
257
258 // Extract sequence hashes and block IDs for newly staged blocks
259 let new_sequence_hashes: Vec<SequenceHash> = result
260 .new_g2_blocks
261 .iter()
262 .map(|b| b.sequence_hash())
263 .collect();
264 let new_block_ids: Vec<BlockId> =
265 result.new_g2_blocks.iter().map(|b| b.block_id()).collect();
266
267 // Release G3 blocks (take_all releases them) and hold new G2 blocks
268 let _ = self.held_g3_blocks.take_all();
269 self.held_g2_blocks.extend(result.new_g2_blocks);
270
271 // Send BlocksReady with only newly staged blocks
272 let ready_msg = OnboardMessage::BlocksReady {
273 responder: self.instance_id,
274 session_id: self.session_id,
275 sequence_hashes: new_sequence_hashes,
276 block_ids: new_block_ids,
277 };
278 self.transport.send(self.requester, ready_msg).await?;
279
280 Ok(())
281 }
282}