Skip to main content

kvbm_engine/leader/session/
initiator.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::{Mutex, mpsc, watch};
6use tokio::task::JoinHandle;
7
8use std::collections::{HashMap, HashSet};
9use std::sync::Arc;
10
11use crate::{
12    BlockId, G2, G3, InstanceId, SequenceHash, object::ObjectBlockOps,
13    worker::group::ParallelWorkers,
14};
15use kvbm_common::LogicalLayoutHandle;
16use kvbm_logical::{blocks::ImmutableBlock, manager::BlockManager};
17use kvbm_physical::transfer::TransferOptions;
18
19use super::staging;
20
21use super::{
22    super::{OnboardingStatus, SessionControl, StagingMode},
23    BlockHolder, SessionId,
24    messages::OnboardMessage,
25    transport::MessageTransport,
26};
27
28/// Validate that sequence hashes have contiguous positions (X, X+1, X+2, ...).
29///
30/// The positions don't need to start at 0, but they must be monotonically
31/// increasing with no gaps.
32fn validate_contiguous_positions(seq_hashes: &[SequenceHash]) -> Result<()> {
33    if seq_hashes.len() <= 1 {
34        return Ok(());
35    }
36
37    // Collect and sort positions
38    let mut positions: Vec<u64> = seq_hashes.iter().map(|h| h.position()).collect();
39    positions.sort();
40
41    // Check monotonically increasing with no holes: X, X+1, X+2, ...
42    for window in positions.windows(2) {
43        if window[1] != window[0] + 1 {
44            anyhow::bail!(
45                "Position gap detected in remote blocks: {} -> {} (expected {}). \
46                 This indicates a block ordering bug.",
47                window[0],
48                window[1],
49                window[0] + 1
50            );
51        }
52    }
53
54    Ok(())
55}
56
57/// Tracks G4/object storage search state for parallel search.
58///
59/// This state is used when G4 search runs in parallel with G2/G3 search.
60/// The first responder (local, remote, or G4) wins for each hash.
61#[derive(Default)]
62struct G4SearchState {
63    /// Hashes won by G4 in the first-responder-wins race
64    won_hashes: HashSet<SequenceHash>,
65    /// Hashes currently pending load (get_blocks in progress)
66    pending_load: HashSet<SequenceHash>,
67    /// Hashes that failed to load with error messages
68    failed_hashes: HashMap<SequenceHash, String>,
69    /// Block IDs allocated for G4→G2 loading (sequence_hash → block_id)
70    allocated_blocks: HashMap<SequenceHash, BlockId>,
71}
72
73impl G4SearchState {
74    fn new() -> Self {
75        Self::default()
76    }
77
78    /// Clear all state.
79    #[expect(dead_code)]
80    fn clear(&mut self) {
81        self.won_hashes.clear();
82        self.pending_load.clear();
83        self.failed_hashes.clear();
84        self.allocated_blocks.clear();
85    }
86}
87
88/// Initiator-side session for coordinating distributed block search.
89///
90/// Supports three staging modes:
91/// - Hold: Find and hold blocks (G2+G3), no staging
92/// - Prepare: Stage G3→G2 everywhere, keep session alive
93/// - Full: Stage G3→G2 + RDMA pull remote G2→local G2, session completes
94pub struct InitiatorSession {
95    session_id: SessionId,
96    instance_id: InstanceId,
97    mode: StagingMode,
98    g2_manager: Arc<BlockManager<G2>>,
99    g3_manager: Option<Arc<BlockManager<G3>>>,
100    parallel_worker: Option<Arc<dyn ParallelWorkers>>,
101    transport: Arc<MessageTransport>,
102    status_tx: watch::Sender<OnboardingStatus>,
103
104    // Held blocks from local search using BlockHolder for RAII semantics
105    local_g2_blocks: BlockHolder<G2>,
106    local_g3_blocks: BlockHolder<G3>,
107
108    // Track remote blocks by tier
109    remote_g2_blocks: HashMap<InstanceId, Vec<BlockId>>, // G2: track block IDs
110    remote_g2_hashes: HashMap<InstanceId, Vec<SequenceHash>>, // G2: track sequence hashes (parallel to block_ids)
111    remote_g3_blocks: HashMap<InstanceId, Vec<SequenceHash>>, // G3: track sequence hashes
112
113    // Shared with FindMatchesResult for block access
114    all_g2_blocks: Arc<Mutex<Option<Vec<ImmutableBlock<G2>>>>>,
115
116    // Control channel for deferred operations
117    control_rx: mpsc::Receiver<SessionControl>,
118
119    // G4/Object storage fields
120    /// Object storage client for G4 search and load (leader-initiated)
121    object_client: Option<Arc<dyn ObjectBlockOps>>,
122    /// G4 search state tracking won hashes, pending loads, and failures
123    g4_state: G4SearchState,
124    /// Channel for receiving G4 search/load results
125    g4_rx: Option<mpsc::Receiver<OnboardMessage>>,
126    /// Handle for G4 search task (for cancellation on drop)
127    #[allow(dead_code)]
128    g4_task_handle: Option<JoinHandle<()>>,
129}
130
131impl InitiatorSession {
132    /// Create a new initiator session.
133    #[allow(clippy::too_many_arguments)]
134    pub(crate) fn new(
135        session_id: SessionId,
136        instance_id: InstanceId,
137        mode: StagingMode,
138        g2_manager: Arc<BlockManager<G2>>,
139        g3_manager: Option<Arc<BlockManager<G3>>>,
140        parallel_worker: Option<Arc<dyn ParallelWorkers>>,
141        transport: Arc<MessageTransport>,
142        status_tx: watch::Sender<OnboardingStatus>,
143        all_g2_blocks: Arc<Mutex<Option<Vec<ImmutableBlock<G2>>>>>,
144        control_rx: mpsc::Receiver<SessionControl>,
145        object_client: Option<Arc<dyn ObjectBlockOps>>,
146    ) -> Self {
147        Self {
148            session_id,
149            instance_id,
150            mode,
151            g2_manager,
152            g3_manager,
153            parallel_worker,
154            transport,
155            status_tx,
156            local_g2_blocks: BlockHolder::empty(),
157            local_g3_blocks: BlockHolder::empty(),
158            remote_g2_blocks: HashMap::new(),
159            remote_g2_hashes: HashMap::new(),
160            remote_g3_blocks: HashMap::new(),
161            all_g2_blocks,
162            control_rx,
163            object_client,
164            g4_state: G4SearchState::new(),
165            g4_rx: None,
166            g4_task_handle: None,
167        }
168    }
169
170    /// Run the initiator session task.
171    pub async fn run(
172        mut self,
173        mut rx: mpsc::Receiver<OnboardMessage>,
174        remote_leaders: Vec<InstanceId>,
175        sequence_hashes: Vec<SequenceHash>,
176    ) -> Result<()> {
177        tracing::debug!(
178            session_id = %self.session_id,
179            mode = ?self.mode,
180            num_hashes = sequence_hashes.len(),
181            num_remotes = remote_leaders.len(),
182            "Starting initiator session"
183        );
184
185        // Phase 1: Search (local G2 and G3, then remote if needed)
186        self.search_phase(&mut rx, &remote_leaders, &sequence_hashes)
187            .await?;
188
189        // Phase 1.5: Apply find policy (first-hole detection)
190        // Trims results to first contiguous sequence from start
191        self.apply_find_policy(&sequence_hashes).await?;
192
193        tracing::debug!(
194            session_id = %self.session_id,
195            "search_phase complete, entering mode handler"
196        );
197
198        // Phase 2: Staging based on mode
199        match self.mode {
200            StagingMode::Hold => {
201                tracing::debug!(session_id = %self.session_id, "Calling hold_mode()");
202                self.hold_mode().await?;
203                // Wait for control commands or shutdown
204                self.await_commands(rx).await?;
205            }
206            StagingMode::Prepare => {
207                self.prepare_mode(&mut rx).await?;
208                // Wait for pull command or shutdown
209                self.await_commands(rx).await?;
210            }
211            StagingMode::Full => {
212                self.full_mode(&mut rx).await?;
213                // Completes and exits
214            }
215        }
216
217        Ok(())
218    }
219
220    /// Phase 1: Search for blocks locally and remotely.
221    async fn search_phase(
222        &mut self,
223        rx: &mut mpsc::Receiver<OnboardMessage>,
224        remote_leaders: &[InstanceId],
225        sequence_hashes: &[SequenceHash],
226    ) -> Result<()> {
227        // Local G2 search
228        self.local_g2_blocks = BlockHolder::new(self.g2_manager.match_blocks(sequence_hashes));
229
230        let mut matched_hashes: HashSet<SequenceHash> =
231            self.local_g2_blocks.sequence_hashes().into_iter().collect();
232
233        // Local G3 search
234        if let Some(ref g3_manager) = self.g3_manager {
235            let remaining: Vec<_> = sequence_hashes
236                .iter()
237                .filter(|h| !matched_hashes.contains(h))
238                .copied()
239                .collect();
240
241            if !remaining.is_empty() {
242                self.local_g3_blocks = BlockHolder::new(g3_manager.match_blocks(&remaining));
243                for hash in self.local_g3_blocks.sequence_hashes() {
244                    matched_hashes.insert(hash);
245                }
246            }
247        }
248
249        // Check if remote/G4 search needed
250        // Continue if: not all matched locally AND (remote leaders exist OR object_client configured)
251        let has_object_client = self.object_client.is_some();
252        if matched_hashes.len() == sequence_hashes.len()
253            || (remote_leaders.is_empty() && !has_object_client)
254        {
255            return Ok(());
256        }
257
258        // Remote search
259        let remaining_hashes: Vec<_> = sequence_hashes
260            .iter()
261            .filter(|h| !matched_hashes.contains(h))
262            .copied()
263            .collect();
264
265        if remaining_hashes.is_empty() {
266            return Ok(());
267        }
268
269        self.status_tx.send(OnboardingStatus::Searching).ok();
270
271        // Send CreateSession to all remotes FIRST
272        for remote in remote_leaders {
273            let msg = OnboardMessage::CreateSession {
274                requester: self.instance_id,
275                session_id: self.session_id,
276                sequence_hashes: remaining_hashes.clone(),
277            };
278            self.transport.send(*remote, msg).await?;
279        }
280
281        // Then spawn G4 search task if object storage is configured and parallel_worker is available
282        // We use parallel_worker.has_blocks() which fans out to workers with rank-prefixed keys
283        let g4_tx = if self.object_client.is_some() && self.parallel_worker.is_some() {
284            let (tx, rx) = mpsc::channel(16);
285            self.g4_rx = Some(rx);
286            // Spawn G4 search - searches the same remaining hashes as remote search
287            let handle = self.spawn_g4_search(remaining_hashes.clone(), tx.clone());
288            self.g4_task_handle = Some(handle);
289            Some(tx)
290        } else {
291            None
292        };
293
294        // Process search responses (including G4 if configured)
295        self.process_search_responses(rx, remote_leaders, &mut matched_hashes, g4_tx)
296            .await?;
297
298        Ok(())
299    }
300
301    /// Process G2Results, G3Results, and G4 results from responders.
302    ///
303    /// Uses `tokio::select!` to handle both remote messages and G4 results
304    /// in parallel, applying first-responder-wins logic across all tiers.
305    async fn process_search_responses(
306        &mut self,
307        rx: &mut mpsc::Receiver<OnboardMessage>,
308        remote_leaders: &[InstanceId],
309        matched_hashes: &mut HashSet<SequenceHash>,
310        g4_tx: Option<mpsc::Sender<OnboardMessage>>,
311    ) -> Result<()> {
312        let mut pending_g2_responses = remote_leaders.len();
313        let mut pending_g3_responses: HashSet<InstanceId> =
314            remote_leaders.iter().copied().collect();
315        let mut pending_search_complete: HashSet<InstanceId> =
316            remote_leaders.iter().copied().collect();
317        let mut pending_acknowledgments: HashSet<InstanceId> = HashSet::new();
318
319        // G4 state tracking
320        let mut pending_g4_search = self.g4_rx.is_some();
321        let mut pending_g4_load = false;
322
323        // Helper to check if all responses are complete
324        let is_complete = |pending_g2: usize,
325                           pending_g3: &HashSet<InstanceId>,
326                           pending_ack: &HashSet<InstanceId>,
327                           pending_search: &HashSet<InstanceId>,
328                           pending_g4_s: bool,
329                           pending_g4_l: bool| {
330            pending_g2 == 0
331                && pending_g3.is_empty()
332                && pending_ack.is_empty()
333                && pending_search.is_empty()
334                && !pending_g4_s
335                && !pending_g4_l
336        };
337
338        loop {
339            // Check completion before waiting for more messages
340            if is_complete(
341                pending_g2_responses,
342                &pending_g3_responses,
343                &pending_acknowledgments,
344                &pending_search_complete,
345                pending_g4_search,
346                pending_g4_load,
347            ) {
348                tracing::debug!(
349                    session_id = %self.session_id,
350                    "All responses received (including G4), exiting search_phase"
351                );
352                break;
353            }
354
355            tokio::select! {
356                // Handle G4 messages from internal channel
357                g4_msg = async {
358                    if let Some(ref mut g4_rx) = self.g4_rx {
359                        g4_rx.recv().await
360                    } else {
361                        std::future::pending::<Option<OnboardMessage>>().await
362                    }
363                } => {
364                    let Some(msg) = g4_msg else {
365                        // Channel closed unexpectedly
366                        pending_g4_search = false;
367                        pending_g4_load = false;
368                        continue;
369                    };
370
371                    tracing::debug!(
372                        session_id = %self.session_id,
373                        msg = msg.variant_name(),
374                        "process_search_responses received G4"
375                    );
376
377                    match msg {
378                        OnboardMessage::G4Results { found_hashes, .. } => {
379                            pending_g4_search = false;
380
381                            // Process G4 results with first-responder-wins
382                            let won_hashes = self.process_g4_results(found_hashes, matched_hashes);
383
384                            // If G4 won any hashes, start loading them
385                            if !won_hashes.is_empty()
386                                && let Some(ref tx) = g4_tx {
387                                    self.load_g4_blocks(won_hashes, tx.clone()).await?;
388                                    pending_g4_load = true;
389                                }
390                        }
391                        OnboardMessage::G4LoadComplete { success, failures, blocks, .. } => {
392                            self.handle_g4_load_complete(success, failures, blocks);
393                            pending_g4_load = false;
394                        }
395                        _ => {}
396                    }
397                }
398
399                // Handle remote messages
400                remote_msg = rx.recv() => {
401                    let Some(msg) = remote_msg else {
402                        // Channel closed - exit loop
403                        break;
404                    };
405
406                    tracing::debug!(
407                        session_id = %self.session_id,
408                        msg = msg.variant_name(),
409                        "process_search_responses received"
410                    );
411
412                    match msg {
413                        OnboardMessage::G2Results {
414                            responder,
415                            sequence_hashes,
416                            block_ids,
417                            ..
418                        } => {
419                            tracing::debug!(
420                                session_id = %self.session_id,
421                                responder = %responder,
422                                num_hashes = sequence_hashes.len(),
423                                "Processing G2Results"
424                            );
425
426                            // First-responder-wins logic using sequence hashes
427                            let mut hold_hashes = Vec::new();
428                            let mut drop_hashes = Vec::new();
429
430                            for (seq_hash, block_id) in sequence_hashes.iter().zip(block_ids.iter()) {
431                                if matched_hashes.insert(*seq_hash) {
432                                    hold_hashes.push(*seq_hash);
433                                    self.remote_g2_blocks
434                                        .entry(responder)
435                                        .or_default()
436                                        .push(*block_id);
437                                    // Track sequence hash in parallel for block registration after RDMA pull
438                                    self.remote_g2_hashes
439                                        .entry(responder)
440                                        .or_default()
441                                        .push(*seq_hash);
442                                } else {
443                                    drop_hashes.push(*seq_hash);
444                                }
445                            }
446
447                            // Send HoldBlocks decision
448                            self.transport
449                                .send(
450                                    responder,
451                                    OnboardMessage::HoldBlocks {
452                                        requester: self.instance_id,
453                                        session_id: self.session_id,
454                                        hold_hashes,
455                                        drop_hashes,
456                                    },
457                                )
458                                .await?;
459
460                            pending_acknowledgments.insert(responder);
461                            pending_g2_responses -= 1;
462                        }
463                        OnboardMessage::G3Results {
464                            responder,
465                            sequence_hashes,
466                            ..
467                        } => {
468                            // Store G3 sequence hashes for later staging
469                            for seq_hash in sequence_hashes {
470                                if matched_hashes.insert(seq_hash) {
471                                    self.remote_g3_blocks
472                                        .entry(responder)
473                                        .or_default()
474                                        .push(seq_hash);
475                                }
476                            }
477
478                            pending_g3_responses.remove(&responder);
479                        }
480                        OnboardMessage::SearchComplete { responder, .. } => {
481                            pending_search_complete.remove(&responder);
482                            // SearchComplete means responder is done with G2 AND G3 search
483                            pending_g3_responses.remove(&responder);
484
485                            tracing::debug!(
486                                session_id = %self.session_id,
487                                responder = %responder,
488                                g2_pending = pending_g2_responses,
489                                g3_pending = pending_g3_responses.len(),
490                                ack_pending = pending_acknowledgments.len(),
491                                search_pending = pending_search_complete.len(),
492                                g4_search = pending_g4_search,
493                                g4_load = pending_g4_load,
494                                "SearchComplete"
495                            );
496                        }
497                        OnboardMessage::Acknowledged { responder, .. } => {
498                            pending_acknowledgments.remove(&responder);
499                        }
500                        _ => {}
501                    }
502                }
503            }
504        }
505
506        Ok(())
507    }
508
509    /// Apply "first hole" policy: trim results to first contiguous sequence.
510    ///
511    /// This implements the policy where we only return blocks from position 0
512    /// up to (but not including) the first missing block. Any blocks after the
513    /// first hole are released.
514    ///
515    /// # Arguments
516    /// * `sequence_hashes` - The original query hashes in order (position 0 to N)
517    async fn apply_find_policy(&mut self, sequence_hashes: &[SequenceHash]) -> Result<()> {
518        // Build set of all matched hashes (local + remote)
519        let mut matched_hashes: HashSet<SequenceHash> = HashSet::new();
520
521        // Local G2 blocks
522        for hash in self.local_g2_blocks.sequence_hashes() {
523            matched_hashes.insert(hash);
524        }
525
526        // Local G3 blocks
527        for hash in self.local_g3_blocks.sequence_hashes() {
528            matched_hashes.insert(hash);
529        }
530
531        // Remote G2 hashes
532        for hashes in self.remote_g2_hashes.values() {
533            for hash in hashes {
534                matched_hashes.insert(*hash);
535            }
536        }
537
538        // Remote G3 hashes
539        for hashes in self.remote_g3_blocks.values() {
540            for hash in hashes {
541                matched_hashes.insert(*hash);
542            }
543        }
544
545        // G4 won hashes (blocks successfully loaded from object storage)
546        for hash in &self.g4_state.won_hashes {
547            matched_hashes.insert(*hash);
548        }
549
550        // Find the first hole: count contiguous matches from start
551        let mut keep_count = 0;
552        for hash in sequence_hashes {
553            if matched_hashes.contains(hash) {
554                keep_count += 1;
555            } else {
556                // First hole found - stop here
557                break;
558            }
559        }
560
561        // If all hashes matched or first hole is at position 0, nothing to trim
562        if keep_count == sequence_hashes.len() || keep_count == matched_hashes.len() {
563            tracing::debug!(
564                session_id = %self.session_id,
565                matched = keep_count,
566                total = sequence_hashes.len(),
567                "apply_find_policy: no trimming needed"
568            );
569            return Ok(());
570        }
571
572        // Get the hashes to keep
573        let keep_hashes: Vec<SequenceHash> = sequence_hashes[..keep_count].to_vec();
574        let keep_set: HashSet<&SequenceHash> = keep_hashes.iter().collect();
575
576        tracing::debug!(
577            session_id = %self.session_id,
578            from = matched_hashes.len(),
579            to = keep_count,
580            first_hole = keep_count,
581            "apply_find_policy: trimming blocks"
582        );
583
584        // Filter local blocks
585        self.local_g2_blocks.retain(&keep_hashes);
586        self.local_g3_blocks.retain(&keep_hashes);
587
588        // Filter remote G2 block tracking and send ReleaseBlocks messages
589        for (remote_instance, block_ids) in &mut self.remote_g2_blocks {
590            let hashes = self.remote_g2_hashes.get_mut(remote_instance);
591            if let Some(hashes) = hashes {
592                // Find indices of blocks to release
593                let mut release_indices = Vec::new();
594                for (i, hash) in hashes.iter().enumerate() {
595                    if !keep_set.contains(hash) {
596                        release_indices.push(i);
597                    }
598                }
599
600                // Collect hashes to release for ReleaseBlocks message
601                let release_hashes: Vec<SequenceHash> =
602                    release_indices.iter().map(|&i| hashes[i]).collect();
603
604                // Remove from tracking (reverse order to preserve indices)
605                for i in release_indices.into_iter().rev() {
606                    hashes.remove(i);
607                    block_ids.remove(i);
608                }
609
610                // Send ReleaseBlocks message if any blocks need releasing
611                if !release_hashes.is_empty() {
612                    tracing::debug!(
613                        session_id = %self.session_id,
614                        count = release_hashes.len(),
615                        instance = %remote_instance,
616                        "Releasing G2 blocks beyond first hole"
617                    );
618                    self.transport
619                        .send(
620                            *remote_instance,
621                            OnboardMessage::ReleaseBlocks {
622                                requester: self.instance_id,
623                                session_id: self.session_id,
624                                release_hashes,
625                            },
626                        )
627                        .await?;
628                }
629            }
630        }
631
632        // Filter remote G3 block tracking and send ReleaseBlocks messages
633        for (remote_instance, hashes) in &mut self.remote_g3_blocks {
634            // Find hashes to release
635            let release_hashes: Vec<SequenceHash> = hashes
636                .iter()
637                .filter(|h| !keep_set.contains(h))
638                .copied()
639                .collect();
640
641            // Remove from tracking
642            hashes.retain(|h| keep_set.contains(h));
643
644            // Send ReleaseBlocks message if any blocks need releasing
645            if !release_hashes.is_empty() {
646                tracing::debug!(
647                    session_id = %self.session_id,
648                    count = release_hashes.len(),
649                    instance = %remote_instance,
650                    "Releasing G3 blocks beyond first hole"
651                );
652                self.transport
653                    .send(
654                        *remote_instance,
655                        OnboardMessage::ReleaseBlocks {
656                            requester: self.instance_id,
657                            session_id: self.session_id,
658                            release_hashes,
659                        },
660                    )
661                    .await?;
662            }
663        }
664
665        // Filter G4 state - release allocated blocks and remove from tracking for hashes beyond first hole
666        let g4_release_hashes: Vec<SequenceHash> = self
667            .g4_state
668            .won_hashes
669            .iter()
670            .filter(|h| !keep_set.contains(h))
671            .copied()
672            .collect();
673
674        if !g4_release_hashes.is_empty() {
675            tracing::debug!(
676                session_id = %self.session_id,
677                count = g4_release_hashes.len(),
678                "Releasing G4 blocks beyond first hole"
679            );
680
681            for hash in &g4_release_hashes {
682                // Remove from won_hashes
683                self.g4_state.won_hashes.remove(hash);
684                // Remove from pending_load (if still loading)
685                self.g4_state.pending_load.remove(hash);
686                // Remove allocated block (will be deallocated when dropped)
687                self.g4_state.allocated_blocks.remove(hash);
688            }
689        }
690
691        Ok(())
692    }
693
694    /// Hold mode: Just hold blocks without staging.
695    async fn hold_mode(&mut self) -> Result<()> {
696        let local_g2 = self.local_g2_blocks.count();
697        let local_g3 = self.local_g3_blocks.count();
698        let remote_g2: usize = self.remote_g2_blocks.values().map(|v| v.len()).sum();
699        let remote_g3: usize = self.remote_g3_blocks.values().map(|v| v.len()).sum();
700
701        // G4 state
702        let pending_g4 = self.g4_state.pending_load.len();
703        let loaded_g4 = self.g4_state.won_hashes.len();
704        let failed_g4 = self.g4_state.failed_hashes.len();
705
706        tracing::debug!(
707            session_id = %self.session_id,
708            local_g2,
709            local_g3,
710            remote_g2,
711            remote_g3,
712            pending_g4,
713            loaded_g4,
714            failed_g4,
715            "hold_mode"
716        );
717
718        self.status_tx
719            .send(OnboardingStatus::Holding {
720                local_g2,
721                local_g3,
722                remote_g2,
723                remote_g3,
724                pending_g4,
725                loaded_g4,
726                failed_g4,
727            })
728            .ok();
729
730        tracing::debug!(session_id = %self.session_id, "Sent Holding status");
731
732        Ok(())
733    }
734
735    /// Send StageBlocks to all remotes with G3 blocks and wait for BlocksReady responses.
736    ///
737    /// After sending StageBlocks, waits for each remote to respond with BlocksReady,
738    /// which updates `remote_g2_blocks` and `remote_g2_hashes` with the newly staged blocks.
739    async fn send_stage_and_wait_for_ready(
740        &mut self,
741        rx: &mut mpsc::Receiver<OnboardMessage>,
742    ) -> Result<()> {
743        if self.remote_g3_blocks.is_empty() {
744            return Ok(());
745        }
746
747        // Send StageBlocks to remotes for their G3 sequence hashes
748        let remotes_with_g3: Vec<(InstanceId, Vec<SequenceHash>)> = self
749            .remote_g3_blocks
750            .iter()
751            .map(|(k, v)| (*k, v.clone()))
752            .collect();
753
754        for (remote, stage_hashes) in &remotes_with_g3 {
755            self.transport
756                .send(
757                    *remote,
758                    OnboardMessage::StageBlocks {
759                        requester: self.instance_id,
760                        session_id: self.session_id,
761                        stage_hashes: stage_hashes.clone(),
762                    },
763                )
764                .await?;
765        }
766
767        // Wait for BlocksReady from all remotes that had G3 blocks
768        let mut pending: HashSet<InstanceId> = remotes_with_g3.iter().map(|(k, _)| *k).collect();
769
770        while !pending.is_empty() {
771            match rx.recv().await {
772                Some(OnboardMessage::BlocksReady {
773                    responder,
774                    sequence_hashes,
775                    block_ids,
776                    ..
777                }) => {
778                    tracing::debug!(
779                        session_id = %self.session_id,
780                        responder = %responder,
781                        count = block_ids.len(),
782                        "Received BlocksReady"
783                    );
784                    self.remote_g2_blocks
785                        .entry(responder)
786                        .or_default()
787                        .extend(block_ids);
788                    self.remote_g2_hashes
789                        .entry(responder)
790                        .or_default()
791                        .extend(sequence_hashes);
792                    pending.remove(&responder);
793                }
794                Some(other) => {
795                    tracing::warn!(
796                        session_id = %self.session_id,
797                        msg = other.variant_name(),
798                        "Unexpected message while waiting for BlocksReady"
799                    );
800                }
801                None => {
802                    tracing::warn!(
803                        session_id = %self.session_id,
804                        "Channel closed while waiting for BlocksReady"
805                    );
806                    break;
807                }
808            }
809        }
810
811        Ok(())
812    }
813
814    /// Prepare mode: Stage all G3→G2 but keep session alive.
815    async fn prepare_mode(&mut self, rx: &mut mpsc::Receiver<OnboardMessage>) -> Result<()> {
816        // Stage local G3→G2
817        self.stage_local_g3_to_g2().await?;
818
819        // Send StageBlocks to remotes and wait for BlocksReady
820        self.send_stage_and_wait_for_ready(rx).await?;
821
822        let local_g2 = self.local_g2_blocks.count();
823        let remote_g2: usize = self.remote_g2_blocks.values().map(|v| v.len()).sum();
824
825        self.status_tx
826            .send(OnboardingStatus::Prepared {
827                local_g2,
828                remote_g2,
829            })
830            .ok();
831
832        Ok(())
833    }
834
835    /// Full mode: Stage G3→G2 + pull remote G2→local G2.
836    async fn full_mode(&mut self, rx: &mut mpsc::Receiver<OnboardMessage>) -> Result<()> {
837        // Stage local G3→G2
838        self.stage_local_g3_to_g2().await?;
839
840        // Send StageBlocks to remotes and wait for BlocksReady before pulling
841        self.send_stage_and_wait_for_ready(rx).await?;
842
843        // Pull remote G2→local G2 via RDMA (both original G2 and newly staged from G3)
844        self.pull_remote_blocks().await?;
845
846        // Consolidate all blocks
847        self.consolidate_blocks().await;
848
849        // Send CloseSession to all remotes
850        let all_remotes: HashSet<InstanceId> = self
851            .remote_g2_blocks
852            .keys()
853            .chain(self.remote_g3_blocks.keys())
854            .copied()
855            .collect();
856
857        for remote in all_remotes {
858            self.transport
859                .send(
860                    remote,
861                    OnboardMessage::CloseSession {
862                        requester: self.instance_id,
863                        session_id: self.session_id,
864                    },
865                )
866                .await?;
867        }
868
869        Ok(())
870    }
871
872    /// Stage local G3→G2.
873    async fn stage_local_g3_to_g2(&mut self) -> Result<()> {
874        if self.local_g3_blocks.is_empty() {
875            return Ok(());
876        }
877
878        let parallel_worker = self
879            .parallel_worker
880            .as_ref()
881            .ok_or_else(|| anyhow::anyhow!("ParallelWorker required for G3→G2 staging"))?;
882
883        let result =
884            staging::stage_g3_to_g2(&self.local_g3_blocks, &self.g2_manager, &**parallel_worker)
885                .await?;
886
887        let _ = self.local_g3_blocks.take_all();
888        self.local_g2_blocks.extend(result.new_g2_blocks);
889
890        Ok(())
891    }
892
893    /// Pull remote G2→local G2 via RDMA.
894    ///
895    /// This method:
896    /// 1. Imports remote metadata for each instance (if not already imported)
897    /// 2. Allocates local G2 blocks as destinations
898    /// 3. Executes RDMA transfer via worker
899    /// 4. Registers pulled blocks with their sequence hashes
900    async fn pull_remote_blocks(&mut self) -> Result<()> {
901        let parallel_worker = self
902            .parallel_worker
903            .as_ref()
904            .ok_or_else(|| anyhow::anyhow!("ParallelWorker required for RDMA pull"))?;
905
906        // Process each remote instance that has G2 blocks to pull
907        for (remote_instance, block_ids) in self.remote_g2_blocks.clone() {
908            // Skip if no blocks to pull
909            if block_ids.is_empty() {
910                continue;
911            }
912
913            // Get the parallel sequence hashes for registration
914            let seq_hashes = self
915                .remote_g2_hashes
916                .get(&remote_instance)
917                .cloned()
918                .unwrap_or_default();
919            if seq_hashes.len() != block_ids.len() {
920                anyhow::bail!(
921                    "Mismatch between block_ids ({}) and seq_hashes ({}) for instance {}",
922                    block_ids.len(),
923                    seq_hashes.len(),
924                    remote_instance
925                );
926            }
927
928            // Sort (block_id, seq_hash) pairs by position to ensure correct transfer order
929            // This is a safety net in case responder sent blocks in wrong order
930            let mut pairs: Vec<(BlockId, SequenceHash)> =
931                block_ids.into_iter().zip(seq_hashes.into_iter()).collect();
932            pairs.sort_by_key(|(_, hash)| hash.position());
933
934            let block_ids: Vec<BlockId> = pairs.iter().map(|(id, _)| *id).collect();
935            let seq_hashes: Vec<SequenceHash> = pairs.iter().map(|(_, hash)| *hash).collect();
936
937            // Step 1: Import remote metadata if not already done
938            if !parallel_worker.has_remote_metadata(remote_instance) {
939                tracing::debug!(
940                    session_id = %self.session_id,
941                    instance = %remote_instance,
942                    "Requesting metadata from instance"
943                );
944                let metadata = self.transport.request_metadata(remote_instance).await?;
945                parallel_worker
946                    .connect_remote(remote_instance, metadata)?
947                    .await?;
948                tracing::debug!(
949                    session_id = %self.session_id,
950                    instance = %remote_instance,
951                    "Metadata imported for instance"
952                );
953            }
954
955            // Step 2: Allocate local G2 blocks as destinations
956            let dst_blocks = self
957                .g2_manager
958                .allocate_blocks(block_ids.len())
959                .ok_or_else(|| {
960                    anyhow::anyhow!("Failed to allocate {} G2 blocks", block_ids.len())
961                })?;
962            let dst_ids: Vec<BlockId> = dst_blocks.iter().map(|b| b.block_id()).collect();
963
964            tracing::debug!(
965                session_id = %self.session_id,
966                count = block_ids.len(),
967                instance = %remote_instance,
968                "Pulling blocks via RDMA"
969            );
970
971            // Step 3: Execute RDMA transfer
972            // Uses execute_remote_onboard_for_instance which looks up the stored handle mapping
973            let notification = parallel_worker.execute_remote_onboard_for_instance(
974                remote_instance,
975                LogicalLayoutHandle::G2, // source is remote G2
976                block_ids,
977                LogicalLayoutHandle::G2, // destination is local G2
978                Arc::from(dst_ids),
979                TransferOptions::default(),
980            )?;
981            notification.await?;
982
983            tracing::debug!(
984                session_id = %self.session_id,
985                instance = %remote_instance,
986                "RDMA transfer complete"
987            );
988
989            // Step 4: Register pulled blocks with their sequence hashes
990            // We stage each block with the sequence hash from the remote,
991            // then register it to produce an immutable block.
992            let new_g2_blocks: Vec<ImmutableBlock<G2>> = dst_blocks
993                .into_iter()
994                .zip(seq_hashes.iter())
995                .map(|(dst, seq_hash)| {
996                    let complete = dst
997                        .stage(*seq_hash, self.g2_manager.block_size())
998                        .expect("block size mismatch");
999                    self.g2_manager.register_block(complete)
1000                })
1001                .collect();
1002
1003            // Add to local G2 blocks
1004            self.local_g2_blocks.extend(new_g2_blocks);
1005        }
1006
1007        Ok(())
1008    }
1009
1010    /// Consolidate all G2 blocks into shared storage.
1011    ///
1012    /// This method sorts blocks by sequence_hash position to ensure correct
1013    /// positional correspondence for G2→G1 transfer. This is critical because
1014    /// blocks from different sources (local G2, G3→G2, remote G2, G4) may arrive
1015    /// in different orders, but the consumer expects them sorted by position.
1016    async fn consolidate_blocks(&mut self) {
1017        let mut all_blocks = self.local_g2_blocks.take_all();
1018
1019        // Sort blocks by sequence_hash position (lowest to highest)
1020        // This ensures correct positional correspondence for G2→G1 transfer
1021        all_blocks.sort_by_key(|b| b.sequence_hash().position());
1022
1023        // Validate contiguous positions - catches ordering bugs before data corruption.
1024        // If validation fails, we still proceed with sorted blocks because:
1025        // 1. Sorted order is strictly safer than unsorted for G2→G1 transfer
1026        // 2. Non-contiguous positions indicate an upstream aggregation bug, not a
1027        //    sorting bug — failing here would discard valid cached data
1028        // 3. The consumer (G1 transfer) handles sparse blocks correctly
1029        let seq_hashes: Vec<SequenceHash> = all_blocks.iter().map(|b| b.sequence_hash()).collect();
1030        if let Err(e) = validate_contiguous_positions(&seq_hashes) {
1031            tracing::warn!(
1032                session_id = %self.session_id,
1033                error = %e,
1034                "Block positions are not contiguous — proceeding with sorted order"
1035            );
1036        }
1037
1038        let matched_blocks = all_blocks.len();
1039        *self.all_g2_blocks.lock().await = Some(all_blocks);
1040
1041        self.status_tx
1042            .send(OnboardingStatus::Complete { matched_blocks })
1043            .ok();
1044    }
1045
1046    /// Wait for control commands (Hold/Prepare modes).
1047    async fn await_commands(&mut self, mut rx: mpsc::Receiver<OnboardMessage>) -> Result<()> {
1048        loop {
1049            tokio::select! {
1050                Some(cmd) = self.control_rx.recv() => {
1051                    match cmd {
1052                        SessionControl::Prepare => {
1053                            if self.mode == StagingMode::Hold {
1054                                self.prepare_mode(&mut rx).await?;
1055                                self.mode = StagingMode::Prepare;
1056                            }
1057                        }
1058                        SessionControl::Pull => {
1059                            if self.mode == StagingMode::Prepare {
1060                                self.pull_remote_blocks().await?;
1061                                self.consolidate_blocks().await;
1062
1063                                // Send CloseSession to all remotes
1064                                let all_remotes: HashSet<InstanceId> = self
1065                                    .remote_g2_blocks
1066                                    .keys()
1067                                    .chain(self.remote_g3_blocks.keys())
1068                                    .copied()
1069                                    .collect();
1070
1071                                for remote in all_remotes {
1072                                    self.transport.send(remote, OnboardMessage::CloseSession {
1073                                        requester: self.instance_id,
1074                                        session_id: self.session_id,
1075                                    }).await?;
1076                                }
1077
1078                                break;
1079                            }
1080                        }
1081                        SessionControl::Cancel => {
1082                            // Release all blocks and exit
1083                            let all_remotes: HashSet<InstanceId> = self
1084                                .remote_g2_blocks
1085                                .keys()
1086                                .chain(self.remote_g3_blocks.keys())
1087                                .copied()
1088                                .collect();
1089
1090                            for remote in all_remotes {
1091                                self.transport.send(remote, OnboardMessage::CloseSession {
1092                                    requester: self.instance_id,
1093                                    session_id: self.session_id,
1094                                }).await?;
1095                            }
1096                            break;
1097                        }
1098                        SessionControl::Shutdown => {
1099                            break;
1100                        }
1101                    }
1102                }
1103                // Also drain any remaining messages from responders
1104                Some(_msg) = rx.recv() => {
1105                    // Process any late messages if needed
1106                }
1107            }
1108        }
1109
1110        Ok(())
1111    }
1112
1113    // =========================================================================
1114    // G4/Object Storage Methods
1115    // =========================================================================
1116
1117    /// Spawn a G4 search task that runs in parallel with remote G2/G3 search.
1118    ///
1119    /// This task calls `has_blocks` via parallel_worker which fans out to workers.
1120    /// Workers use rank-prefixed keys, so we must query through them (not directly to S3).
1121    fn spawn_g4_search(
1122        &self,
1123        sequence_hashes: Vec<SequenceHash>,
1124        tx: mpsc::Sender<OnboardMessage>,
1125    ) -> JoinHandle<()> {
1126        let session_id = self.session_id;
1127        // Use parallel_worker for has_blocks - it fans out to workers who use rank-prefixed keys
1128        let parallel_worker = self.parallel_worker.clone();
1129
1130        tokio::spawn(async move {
1131            let Some(worker) = parallel_worker else {
1132                // No parallel worker configured, send empty results
1133                let _ = tx
1134                    .send(OnboardMessage::G4Results {
1135                        session_id,
1136                        found_hashes: vec![],
1137                    })
1138                    .await;
1139                return;
1140            };
1141
1142            // Call has_blocks via parallel_worker (fans out to workers with rank-prefixed keys)
1143            let results = worker.has_blocks(sequence_hashes).await;
1144
1145            // Filter to only blocks that exist (Some(size))
1146            let found_hashes: Vec<(SequenceHash, usize)> = results
1147                .into_iter()
1148                .filter_map(|(hash, size_opt)| size_opt.map(|size| (hash, size)))
1149                .collect();
1150
1151            tracing::debug!(
1152                session_id = %session_id,
1153                count = found_hashes.len(),
1154                "G4 search: found blocks in object storage"
1155            );
1156
1157            // Send results back to initiator
1158            let _ = tx
1159                .send(OnboardMessage::G4Results {
1160                    session_id,
1161                    found_hashes,
1162                })
1163                .await;
1164        })
1165    }
1166
1167    /// Process G4 search results with first-responder-wins logic.
1168    ///
1169    /// Returns the hashes that G4 won (not already claimed by G2/G3/remote).
1170    fn process_g4_results(
1171        &mut self,
1172        found_hashes: Vec<(SequenceHash, usize)>,
1173        matched_hashes: &mut HashSet<SequenceHash>,
1174    ) -> Vec<SequenceHash> {
1175        let mut won_hashes = Vec::new();
1176
1177        for (hash, _size) in found_hashes {
1178            // First-responder-wins: only claim if not already matched
1179            if matched_hashes.insert(hash) {
1180                won_hashes.push(hash);
1181                self.g4_state.won_hashes.insert(hash);
1182            }
1183        }
1184
1185        tracing::debug!(
1186            session_id = %self.session_id,
1187            won_count = won_hashes.len(),
1188            "G4 won hashes (first-responder-wins)"
1189        );
1190
1191        won_hashes
1192    }
1193
1194    /// Load G4 blocks into local G2 via workers.
1195    ///
1196    /// Allocates G2 destination blocks and coordinates workers to download
1197    /// from object storage via `get_blocks`. After successful download, blocks
1198    /// are registered with the G2 manager and returned via G4LoadComplete message.
1199    async fn load_g4_blocks(
1200        &mut self,
1201        won_hashes: Vec<SequenceHash>,
1202        g4_tx: mpsc::Sender<OnboardMessage>,
1203    ) -> Result<()> {
1204        if won_hashes.is_empty() {
1205            return Ok(());
1206        }
1207
1208        let parallel_worker = self
1209            .parallel_worker
1210            .as_ref()
1211            .ok_or_else(|| anyhow::anyhow!("ParallelWorkers required for G4 load"))?;
1212
1213        // Mark hashes as pending load
1214        for hash in &won_hashes {
1215            self.g4_state.pending_load.insert(*hash);
1216        }
1217
1218        // Allocate G2 destination blocks
1219        let dst_blocks = self
1220            .g2_manager
1221            .allocate_blocks(won_hashes.len())
1222            .ok_or_else(|| {
1223                anyhow::anyhow!(
1224                    "Failed to allocate {} G2 blocks for G4 load",
1225                    won_hashes.len()
1226                )
1227            })?;
1228
1229        let dst_ids: Vec<BlockId> = dst_blocks.iter().map(|b| b.block_id()).collect();
1230
1231        // Track allocated blocks (for cleanup on failure)
1232        for (hash, block_id) in won_hashes.iter().zip(dst_ids.iter()) {
1233            self.g4_state.allocated_blocks.insert(*hash, *block_id);
1234        }
1235
1236        tracing::debug!(
1237            session_id = %self.session_id,
1238            count = won_hashes.len(),
1239            "Loading G4 blocks via workers"
1240        );
1241
1242        // Clone values for the spawned task
1243        let session_id = self.session_id;
1244        let hashes = won_hashes.clone();
1245        let parallel_worker = parallel_worker.clone();
1246        let g2_manager = self.g2_manager.clone();
1247
1248        // Spawn load task so we can continue processing other messages
1249        // IMPORTANT: dst_blocks is moved into the task to keep them alive during download
1250        tokio::spawn(async move {
1251            // Execute get_blocks via parallel worker
1252            let results = parallel_worker
1253                .get_blocks(hashes.clone(), LogicalLayoutHandle::G2, dst_ids.clone())
1254                .await;
1255
1256            // Separate successes and failures, register successful blocks
1257            let mut success = Vec::new();
1258            let mut failures = Vec::new();
1259            let mut blocks = Vec::new();
1260
1261            // Iterate over results alongside the dst_blocks and hashes
1262            for ((result, dst_block), seq_hash) in results
1263                .into_iter()
1264                .zip(dst_blocks.into_iter())
1265                .zip(hashes.iter())
1266            {
1267                match result {
1268                    Ok(hash) => {
1269                        // Register the block with its sequence hash
1270                        // This adds it to the BlockRegistry for presence filtering
1271                        let complete = dst_block
1272                            .stage(*seq_hash, g2_manager.block_size())
1273                            .expect("block size mismatch");
1274                        let immutable = g2_manager.register_block(complete);
1275                        blocks.push(immutable);
1276                        success.push(hash);
1277                    }
1278                    Err(hash) => {
1279                        // Block will be returned to pool when dst_block is dropped
1280                        failures.push((hash, "Failed to download block".to_string()));
1281                    }
1282                }
1283            }
1284
1285            tracing::debug!(
1286                session_id = %session_id,
1287                success_count = success.len(),
1288                failure_count = failures.len(),
1289                "G4 load complete"
1290            );
1291
1292            // Send completion message with registered blocks
1293            let _ = g4_tx
1294                .send(OnboardMessage::G4LoadComplete {
1295                    session_id,
1296                    success,
1297                    failures,
1298                    blocks: std::sync::Arc::new(blocks),
1299                })
1300                .await;
1301        });
1302
1303        Ok(())
1304    }
1305
1306    /// Handle G4 load completion, updating state and adding blocks to local_g2_blocks.
1307    ///
1308    /// The blocks have already been registered with the G2 manager in the spawned task,
1309    /// so they are now visible in the BlockRegistry for presence filtering.
1310    fn handle_g4_load_complete(
1311        &mut self,
1312        success: Vec<SequenceHash>,
1313        failures: Vec<(SequenceHash, String)>,
1314        blocks: Arc<Vec<ImmutableBlock<G2>>>,
1315    ) {
1316        // Process successful loads - update state tracking
1317        for hash in &success {
1318            self.g4_state.pending_load.remove(hash);
1319            // Remove from allocated_blocks since we now have registered ImmutableBlocks
1320            self.g4_state.allocated_blocks.remove(hash);
1321        }
1322
1323        // Unwrap the Arc to get the Vec (this is the only owner since the message was just received)
1324        let blocks =
1325            Arc::try_unwrap(blocks).expect("G4LoadComplete should be the sole owner of blocks");
1326
1327        // Add the registered G4 blocks to local_g2_blocks
1328        // These blocks are now registered in the BlockRegistry and will be
1329        // detected by the PresenceFilter during G1→G2 offloading
1330        self.local_g2_blocks.extend(blocks);
1331
1332        // Process failures
1333        for (hash, error) in failures {
1334            self.g4_state.pending_load.remove(&hash);
1335            self.g4_state.failed_hashes.insert(hash, error);
1336
1337            // Remove from allocated_blocks on failure (block was already dropped)
1338            self.g4_state.allocated_blocks.remove(&hash);
1339
1340            // Also remove from won_hashes since it failed to load
1341            self.g4_state.won_hashes.remove(&hash);
1342        }
1343
1344        tracing::debug!(
1345            session_id = %self.session_id,
1346            won = self.g4_state.won_hashes.len(),
1347            pending = self.g4_state.pending_load.len(),
1348            failed = self.g4_state.failed_hashes.len(),
1349            local_g2 = self.local_g2_blocks.count(),
1350            "G4 load complete, blocks added to local_g2_blocks"
1351        );
1352    }
1353}