Skip to main content

kvbm_engine/worker/
physical.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Base worker implementation for single-worker transfer execution.
5//!
6//! This module provides the [`PhysicalWorker`] type which executes transfer operations
7//! using a local [`TransferManager`]. It serves as the foundation for both standalone
8//! worker scenarios and as a building block for parallel worker implementations.
9
10#[cfg(feature = "collectives")]
11mod replicated;
12#[cfg(feature = "collectives")]
13#[allow(unused_imports)]
14pub use replicated::ReplicatedDataWorker;
15
16use std::collections::HashMap;
17use std::sync::{Arc, RwLock};
18
19#[cfg(feature = "nccl")]
20use cudarc::driver::CudaEvent;
21use derive_builder::Builder;
22use futures::future::BoxFuture;
23
24use crate::object::ObjectBlockOps;
25use kvbm_physical::layout::PhysicalLayout;
26use kvbm_physical::{
27    manager::{SerializedLayout, TransferManager},
28    transfer::{BounceBuffer, TransferOptions, context::TransferCompleteNotification},
29};
30
31use super::*;
32
33/// PhysicalWorker executes transfer operations using a local TransferManager.
34///
35/// This is the fundamental worker type that directly owns a `TransferManager` and
36/// layout handles for executing data transfers. It implements the [`Worker`] and
37/// [`WorkerTransfers`] traits for single-worker scenarios.
38///
39/// # Builder fields
40///
41/// | Field | Required | Description |
42/// |-------|----------|-------------|
43/// | `manager` | **yes** | `TransferManager` that executes actual data movement |
44/// | `g1_handle` | no | GPU KV cache layout handle (for GPU transfers) |
45/// | `g2_handle` | no | Host/pinned cache layout handle (for host transfers) |
46/// | `g3_handle` | no | Disk cache layout handle (for disk-tier transfers) |
47/// | `rank` | no | Worker rank for object-key prefixing in SPMD setups |
48/// | `object_client` | no | Object storage client for G4 tier (S3, etc.) |
49///
50/// # Execution State vs Coordination State
51///
52/// PhysicalWorker maintains **execution state** -- the handles and manager needed
53/// to actually perform RDMA/local transfers. This is distinct from
54/// **coordination state** which the leader tracks in [`CoordinatedWorker`].
55///
56/// When a leader wraps a PhysicalWorker in a CoordinatedWorker:
57/// - PhysicalWorker: owns handles for TransferManager execution
58/// - CoordinatedWorker: tracks the same handles for leader coordination
59///
60/// This duplication is intentional -- PhysicalWorker needs handles to execute,
61/// and CoordinatedWorker provides a uniform API regardless of whether the
62/// inner worker is local (PhysicalWorker) or remote (VeloWorkerClient).
63///
64/// # Typical lifecycle
65///
66/// 1. Created via `PhysicalWorker::builder()` during deferred initialization
67/// 2. Wrapped by [`VeloWorkerService`] to expose RPC handlers
68/// 3. Wrapped by [`CoordinatedWorker`] for leader coordination
69/// 4. Used as a building block in parallel workers (e.g., `SpmdParallelWorkers`)
70///
71/// [`CoordinatedWorker`]: super::CoordinatedWorker
72/// [`VeloWorkerService`]: super::VeloWorkerService
73#[derive(Builder)]
74#[builder(pattern = "owned")]
75pub struct PhysicalWorker {
76    // =========================================================================
77    // Execution State - needed by TransferManager to perform operations
78    // =========================================================================
79    /// The transfer manager that executes actual data movement.
80    manager: TransferManager,
81
82    /// G1 (GPU KV cache) layout handle - set during initialization.
83    /// Required for GPU-to-GPU and GPU-to-Host transfers.
84    #[builder(default, setter(strip_option))]
85    g1_handle: Option<LayoutHandle>,
86
87    /// G2 (Host/pinned cache) layout handle - set during initialization.
88    /// Required for Host-to-GPU and Host-to-Disk transfers.
89    #[builder(default, setter(strip_option))]
90    g2_handle: Option<LayoutHandle>,
91
92    /// G3 (Disk cache) layout handle - set during initialization if disk tier enabled.
93    /// Required for Disk-to-Host transfers.
94    #[builder(default, setter(strip_option))]
95    g3_handle: Option<LayoutHandle>,
96
97    /// Remote handle mappings for peer-to-peer transfers.
98    /// Key: (InstanceId, LogicalLayoutHandle) → remote LayoutHandle
99    ///
100    /// Populated by `connect_remote` when this worker imports metadata from
101    /// a peer instance. Used by `execute_remote_onboard_for_instance` to
102    /// resolve logical handles to physical handles for RDMA transfers.
103    ///
104    /// Note: This is per-instance mapping (no rank), suitable for single-worker
105    /// scenarios. For multi-worker asymmetric TP, use CoordinatedWorker's
106    /// rank-aware remote_handles instead.
107    #[builder(default = "RwLock::new(HashMap::new())")]
108    remote_handles: RwLock<HashMap<(InstanceId, LogicalLayoutHandle), LayoutHandle>>,
109
110    // =========================================================================
111    // Object Storage State
112    // =========================================================================
113    /// Worker rank (set during initialization from LeaderLayoutConfig).
114    /// Used to augment object keys for unique storage across SPMD workers.
115    #[builder(default, setter(strip_option))]
116    rank: Option<usize>,
117
118    /// Optional object storage client for G4 tier operations.
119    /// Set during initialization if object storage is enabled.
120    #[builder(default, setter(strip_option))]
121    object_client: Option<Arc<dyn ObjectBlockOps>>,
122}
123
124impl PhysicalWorker {
125    /// Create a new builder for PhysicalWorker.
126    ///
127    /// # Example
128    /// ```rust,ignore
129    /// let worker = PhysicalWorker::builder()
130    ///     .manager(manager)
131    ///     .g1_handle(g1_handle)
132    ///     .g2_handle(g2_handle)
133    ///     .g3_handle(g3_handle)
134    ///     .build();
135    /// ```
136    pub fn builder() -> PhysicalWorkerBuilder {
137        PhysicalWorkerBuilder::default()
138    }
139
140    /// Get the worker rank (if set).
141    pub fn rank(&self) -> Option<usize> {
142        self.rank
143    }
144
145    /// Get the object storage client (if set).
146    pub fn object_client(&self) -> Option<&Arc<dyn ObjectBlockOps>> {
147        self.object_client.as_ref()
148    }
149
150    /// Get the G1 layout handle (if set).
151    pub fn g1_handle(&self) -> Option<LayoutHandle> {
152        self.g1_handle
153    }
154
155    /// Get the G2 layout handle (if set).
156    pub fn g2_handle(&self) -> Option<LayoutHandle> {
157        self.g2_handle
158    }
159
160    /// Get the G3 layout handle (if set).
161    pub fn g3_handle(&self) -> Option<LayoutHandle> {
162        self.g3_handle
163    }
164
165    /// Get a reference to the TransferManager.
166    pub fn transfer_manager(&self) -> &TransferManager {
167        &self.manager
168    }
169
170    /// Resolve a logical layout handle to a physical layout.
171    ///
172    /// # Arguments
173    /// * `logical` - The logical layout handle (G1, G2, G3)
174    ///
175    /// # Returns
176    /// The physical layout for the given logical handle, or an error if not found.
177    pub fn resolve_layout(&self, logical: LogicalLayoutHandle) -> Result<PhysicalLayout> {
178        use LogicalLayoutHandle::*;
179
180        let physical_handle = match logical {
181            G1 => self.g1_handle(),
182            G2 => self.g2_handle(),
183            G3 => self.g3_handle(),
184            _ => None,
185        }
186        .ok_or_else(|| anyhow::anyhow!("No layout registered for {:?}", logical))?;
187
188        self.manager
189            .get_physical_layout(physical_handle)
190            .ok_or_else(|| {
191                anyhow::anyhow!(
192                    "Layout handle {:?} not found in TransferManager",
193                    physical_handle
194                )
195            })
196    }
197
198    /// Create a bounce buffer specification from a layout handle and block IDs.
199    pub fn create_bounce_buffer(
200        &self,
201        handle: LayoutHandle,
202        block_ids: Vec<BlockId>,
203    ) -> Result<BounceBuffer> {
204        Ok(BounceBuffer::from_handle(handle, block_ids))
205    }
206
207    /// Export serialized layout metadata with proper logical type mappings.
208    ///
209    /// This exports layouts with their logical types (G1, G2, G3) so that
210    /// remote instances can correctly identify which handle corresponds to
211    /// which tier during RDMA transfers.
212    pub fn export_metadata(&self) -> Result<SerializedLayout> {
213        self.export_metadata_with_logical_types()
214    }
215
216    /// Export metadata with logical type annotations for each registered handle.
217    fn export_metadata_with_logical_types(&self) -> Result<SerializedLayout> {
218        let mut descriptors = Vec::new();
219
220        // Build descriptors for each registered logical handle
221        if let Some(handle) = self.g1_handle() {
222            descriptors.push(
223                self.manager
224                    .build_logical_descriptor(handle, LogicalLayoutHandle::G1)?,
225            );
226        }
227        if let Some(handle) = self.g2_handle() {
228            descriptors.push(
229                self.manager
230                    .build_logical_descriptor(handle, LogicalLayoutHandle::G2)?,
231            );
232        }
233        if let Some(handle) = self.g3_handle() {
234            descriptors.push(
235                self.manager
236                    .build_logical_descriptor(handle, LogicalLayoutHandle::G3)?,
237            );
238        }
239
240        // Pack with worker address and NIXL metadata
241        let worker_address = self.manager.worker_address();
242        let nixl_metadata = self.manager.get_nixl_metadata()?;
243
244        SerializedLayout::pack(worker_address, nixl_metadata, descriptors)
245    }
246
247    /// Import serialized layout metadata into the transfer manager.
248    pub fn import_metadata(&self, metadata: SerializedLayout) -> Result<Vec<LayoutHandle>> {
249        self.manager.import_metadata(metadata)
250    }
251
252    /// Execute layer-wise local transfer from G2 to G1.
253    ///
254    /// This method transfers blocks from the host cache (G2) to the GPU cache (G1)
255    /// one layer at a time, recording an event after each layer's transfer completes.
256    /// All transfers execute on the same CUDA stream to ensure proper ordering.
257    ///
258    /// The caller provides pre-allocated events that are reused across iterations.
259    /// After calling this method, the caller can use `cudaStreamWaitEvent` on the
260    /// torch stream to synchronize each layer's load before attention computation.
261    ///
262    /// # Arguments
263    /// * `src_block_ids` - Source block IDs in G2 (host cache)
264    /// * `dst_block_ids` - Destination block IDs in G1 (GPU cache)
265    /// * `layer_events` - Pre-allocated CUDA events, one per layer. Must have length == num_layers.
266    ///
267    /// # Returns
268    /// `Ok(())` on success. The caller owns synchronization via the recorded events.
269    ///
270    /// # Errors
271    /// Returns an error if:
272    /// - src_block_ids and dst_block_ids have different lengths
273    /// - layer_events length doesn't match num_layers
274    /// - G1 or G2 handles are not registered
275    /// - Any layer transfer fails
276    #[cfg(feature = "nccl")]
277    pub fn execute_local_layerwise_onboard(
278        &self,
279        src_block_ids: &[BlockId],
280        dst_block_ids: &[BlockId],
281        layer_events: &[Arc<CudaEvent>],
282    ) -> Result<()> {
283        // Validate block ID lengths match
284        if src_block_ids.len() != dst_block_ids.len() {
285            return Err(anyhow::anyhow!(
286                "Block ID length mismatch: src={}, dst={}",
287                src_block_ids.len(),
288                dst_block_ids.len()
289            ));
290        }
291
292        // Get layout handles
293        let g2_handle = self
294            .g2_handle()
295            .ok_or_else(|| anyhow::anyhow!("G2 layout not registered"))?;
296        let g1_handle = self
297            .g1_handle()
298            .ok_or_else(|| anyhow::anyhow!("G1 layout not registered"))?;
299
300        // Get num_layers from layout config
301        let g2_config = self.manager.get_layout_config(g2_handle)?;
302        let num_layers = g2_config.num_layers;
303
304        // Validate layer_events length
305        if layer_events.len() != num_layers {
306            return Err(anyhow::anyhow!(
307                "layer_events length ({}) doesn't match num_layers ({})",
308                layer_events.len(),
309                num_layers
310            ));
311        }
312
313        // Acquire a dedicated stream for all layer transfers
314        let stream = self.manager.context().acquire_h2d_stream();
315
316        tracing::debug!(
317            num_layers,
318            num_blocks = src_block_ids.len(),
319            "Starting layer-wise onboard from G2 to G1"
320        );
321
322        // Execute transfer for each layer and record event
323        for layer in 0..num_layers {
324            // Execute single-layer transfer on our dedicated stream
325            let options = TransferOptions::builder()
326                .layer_range(layer..layer + 1)
327                .cuda_stream(stream.clone())
328                .build()?;
329
330            self.manager.execute_transfer(
331                g2_handle,
332                src_block_ids,
333                g1_handle,
334                dst_block_ids,
335                options,
336            )?;
337
338            // Record event on the stream for this layer
339            layer_events[layer].record(stream.as_ref())?;
340        }
341
342        tracing::debug!(num_layers, "Layer-wise onboard complete - events recorded");
343
344        Ok(())
345    }
346}
347
348impl WorkerTransfers for PhysicalWorker {
349    fn execute_local_transfer(
350        &self,
351        src: LogicalLayoutHandle,
352        dst: LogicalLayoutHandle,
353        src_block_ids: Arc<[BlockId]>,
354        dst_block_ids: Arc<[BlockId]>,
355        options: TransferOptions,
356    ) -> Result<TransferCompleteNotification> {
357        use LogicalLayoutHandle::*;
358
359        let src_layout = match &src {
360            G1 => self.g1_handle(),
361            G2 => self.g2_handle(),
362            G3 => self.g3_handle(),
363            G4 => return Err(anyhow::anyhow!("G4 is not supported for local transfers")),
364        }
365        .ok_or_else(|| anyhow::anyhow!("Source layout not registered: {:?}", src))?;
366
367        let dst_layout = match &dst {
368            G1 => self.g1_handle(),
369            G2 => self.g2_handle(),
370            G3 => self.g3_handle(),
371            G4 => return Err(anyhow::anyhow!("G4 is not supported for local transfers")),
372        }
373        .ok_or_else(|| anyhow::anyhow!("Destination layout not registered: {:?}", dst))?;
374
375        self.manager.execute_transfer(
376            src_layout,
377            &src_block_ids,
378            dst_layout,
379            &dst_block_ids,
380            options,
381        )
382    }
383
384    fn execute_remote_onboard(
385        &self,
386        src: RemoteDescriptor,
387        dst: LogicalLayoutHandle,
388        dst_block_ids: Arc<[BlockId]>,
389        options: TransferOptions,
390    ) -> Result<TransferCompleteNotification> {
391        use LogicalLayoutHandle::*;
392
393        let dst_layout = match &dst {
394            G1 => self.g1_handle(),
395            G2 => self.g2_handle(),
396            G3 => self.g3_handle(),
397            G4 => return Err(anyhow::anyhow!("G4 is not supported for remote transfers")),
398        }
399        .ok_or_else(|| anyhow::anyhow!("Destination layout not registered: {:?}", dst))?;
400
401        match src {
402            RemoteDescriptor::Layout { handle, block_ids } => {
403                // RDMA onboard from remote layout
404                let block_ids_arc: Arc<[BlockId]> = block_ids.into();
405                self.manager.execute_transfer(
406                    handle,
407                    &block_ids_arc,
408                    dst_layout,
409                    &dst_block_ids,
410                    options,
411                )
412            }
413            RemoteDescriptor::Object { keys } => {
414                // Object storage onboard (e.g., S3 → G2)
415                let object_client = self
416                    .object_client
417                    .as_ref()
418                    .ok_or_else(|| anyhow::anyhow!("Object client not configured"))?
419                    .clone();
420
421                // Resolve destination physical layout
422                let dst_physical = self.resolve_layout(dst)?;
423                let block_ids_vec: Vec<BlockId> = dst_block_ids.to_vec();
424
425                // Create event for completion notification
426                let ctx = self.manager.context();
427                let event = ctx.event_system().new_event()?;
428                let handle = event.handle();
429                let awaiter = ctx.event_system().awaiter(handle)?;
430
431                // Spawn task to execute object storage read
432                ctx.tokio().spawn(async move {
433                    let results = object_client
434                        .get_blocks_with_layout(keys.clone(), dst_physical, block_ids_vec)
435                        .await;
436
437                    // Check if any failed
438                    let failed: Vec<_> = results.iter().filter(|r| r.is_err()).collect();
439                    if failed.is_empty() {
440                        let _ = event.trigger();
441                    } else {
442                        let error_msg = format!(
443                            "{} of {} blocks failed to download",
444                            failed.len(),
445                            results.len()
446                        );
447                        let _ = event.poison(error_msg);
448                    }
449                });
450
451                Ok(TransferCompleteNotification::from_awaiter(awaiter))
452            }
453        }
454    }
455
456    fn execute_remote_offload(
457        &self,
458        src: LogicalLayoutHandle,
459        src_block_ids: Arc<[BlockId]>,
460        dst: RemoteDescriptor,
461        _options: TransferOptions,
462    ) -> Result<TransferCompleteNotification> {
463        match dst {
464            RemoteDescriptor::Layout { handle, block_ids } => {
465                // RDMA offload to remote layout
466                let src_layout = match &src {
467                    LogicalLayoutHandle::G1 => self.g1_handle(),
468                    LogicalLayoutHandle::G2 => self.g2_handle(),
469                    LogicalLayoutHandle::G3 => self.g3_handle(),
470                    LogicalLayoutHandle::G4 => {
471                        return Err(anyhow::anyhow!("G4 cannot be used as source for offload"));
472                    }
473                }
474                .ok_or_else(|| anyhow::anyhow!("Source layout not registered: {:?}", src))?;
475
476                let block_ids_arc: Arc<[BlockId]> = block_ids.into();
477                self.manager.execute_transfer(
478                    src_layout,
479                    &src_block_ids,
480                    handle,
481                    &block_ids_arc,
482                    _options,
483                )
484            }
485            RemoteDescriptor::Object { keys } => {
486                // Object storage offload (e.g., G2 → S3)
487                let object_client = self
488                    .object_client
489                    .as_ref()
490                    .ok_or_else(|| anyhow::anyhow!("Object client not configured"))?
491                    .clone();
492
493                // Resolve source physical layout
494                let src_physical = self.resolve_layout(src)?;
495                let block_ids_vec: Vec<BlockId> = src_block_ids.to_vec();
496
497                // Create event for completion notification
498                let ctx = self.manager.context();
499                let event = ctx.event_system().new_event()?;
500                let handle = event.handle();
501                let awaiter = ctx.event_system().awaiter(handle)?;
502
503                // Spawn task to execute object storage write
504                ctx.tokio().spawn(async move {
505                    let results = object_client
506                        .put_blocks_with_layout(keys.clone(), src_physical, block_ids_vec)
507                        .await;
508
509                    // Check if any failed
510                    let failed: Vec<_> = results.iter().filter(|r| r.is_err()).collect();
511                    if failed.is_empty() {
512                        let _ = event.trigger();
513                    } else {
514                        let error_msg = format!(
515                            "{} of {} blocks failed to upload",
516                            failed.len(),
517                            results.len()
518                        );
519                        let _ = event.poison(error_msg);
520                    }
521                });
522
523                Ok(TransferCompleteNotification::from_awaiter(awaiter))
524            }
525        }
526    }
527
528    fn connect_remote(
529        &self,
530        instance_id: InstanceId,
531        metadata: Vec<SerializedLayout>,
532    ) -> Result<ConnectRemoteResponse> {
533        // PhysicalWorker expects exactly 1 metadata item
534        if metadata.len() != 1 {
535            anyhow::bail!(
536                "PhysicalWorker expects exactly 1 metadata item, got {}",
537                metadata.len()
538            );
539        }
540        let meta = metadata.into_iter().next().unwrap();
541
542        // Unpack to extract logical type info
543        let unpacked = meta.unpack()?;
544
545        // Store mappings
546        {
547            let mut handles = self.remote_handles.write().unwrap();
548            for descriptor in &unpacked.layouts {
549                handles.insert((instance_id, descriptor.logical_type), descriptor.handle);
550            }
551        }
552
553        // Import so NIXL knows about the remote (repack to pass ownership)
554        let repacked = SerializedLayout::pack(
555            unpacked.worker_address,
556            unpacked.nixl_metadata,
557            unpacked.layouts,
558        )?;
559        self.manager.import_metadata(repacked)?;
560
561        Ok(ConnectRemoteResponse::ready())
562    }
563
564    fn has_remote_metadata(&self, instance_id: InstanceId) -> bool {
565        let handles = self.remote_handles.read().unwrap();
566        handles.keys().any(|(id, _)| *id == instance_id)
567    }
568
569    fn execute_remote_onboard_for_instance(
570        &self,
571        instance_id: InstanceId,
572        remote_logical_type: LogicalLayoutHandle,
573        src_block_ids: Vec<BlockId>,
574        dst: LogicalLayoutHandle,
575        dst_block_ids: Arc<[BlockId]>,
576        options: TransferOptions,
577    ) -> Result<TransferCompleteNotification> {
578        let handles = self.remote_handles.read().unwrap();
579        let remote_handle = handles
580            .get(&(instance_id, remote_logical_type))
581            .ok_or_else(|| {
582                anyhow::anyhow!(
583                    "No remote {:?} handle for instance {}",
584                    remote_logical_type,
585                    instance_id
586                )
587            })?;
588
589        let descriptor = RemoteDescriptor::Layout {
590            handle: *remote_handle,
591            block_ids: src_block_ids,
592        };
593
594        self.execute_remote_onboard(descriptor, dst, dst_block_ids, options)
595    }
596}
597
598impl Worker for PhysicalWorker {
599    fn g1_handle(&self) -> Option<LayoutHandle> {
600        self.g1_handle
601    }
602
603    fn g2_handle(&self) -> Option<LayoutHandle> {
604        self.g2_handle
605    }
606
607    fn g3_handle(&self) -> Option<LayoutHandle> {
608        self.g3_handle
609    }
610
611    fn export_metadata(&self) -> Result<SerializedLayoutResponse> {
612        // Use the logical-type-aware export
613        self.export_metadata_with_logical_types()
614            .map(SerializedLayoutResponse::ready)
615    }
616
617    fn import_metadata(&self, metadata: SerializedLayout) -> Result<ImportMetadataResponse> {
618        self.manager
619            .import_metadata(metadata)
620            .map(ImportMetadataResponse::ready)
621    }
622}
623
624impl ObjectBlockOps for PhysicalWorker {
625    fn has_blocks(
626        &self,
627        keys: Vec<SequenceHash>,
628    ) -> BoxFuture<'static, Vec<(SequenceHash, Option<usize>)>> {
629        // Object client handles rank-based key prefixing internally
630        if let Some(client) = self.object_client.as_ref() {
631            client.has_blocks(keys)
632        } else {
633            // No object client configured - return all keys as not found
634            Box::pin(async move { keys.into_iter().map(|k| (k, None)).collect() })
635        }
636    }
637
638    fn put_blocks(
639        &self,
640        keys: Vec<SequenceHash>,
641        src_layout: LogicalLayoutHandle,
642        block_ids: Vec<BlockId>,
643    ) -> BoxFuture<'static, Vec<Result<SequenceHash, SequenceHash>>> {
644        // Resolve logical handle to physical layout
645        let physical_layout = match self.resolve_layout(src_layout) {
646            Ok(layout) => layout,
647            Err(e) => {
648                tracing::error!(?src_layout, error = %e, "Failed to resolve layout for put_blocks");
649                return Box::pin(async move { keys.into_iter().map(Err).collect() });
650            }
651        };
652
653        // Object client handles rank-based key prefixing internally
654        if let Some(client) = self.object_client.as_ref() {
655            client.put_blocks_with_layout(keys, physical_layout, block_ids)
656        } else {
657            // No object client configured - return all keys as failed
658            tracing::warn!("put_blocks called but no object client configured");
659            Box::pin(async move { keys.into_iter().map(Err).collect() })
660        }
661    }
662
663    fn get_blocks(
664        &self,
665        keys: Vec<SequenceHash>,
666        dst_layout: LogicalLayoutHandle,
667        block_ids: Vec<BlockId>,
668    ) -> BoxFuture<'static, Vec<Result<SequenceHash, SequenceHash>>> {
669        // Resolve logical handle to physical layout
670        let physical_layout = match self.resolve_layout(dst_layout) {
671            Ok(layout) => layout,
672            Err(e) => {
673                tracing::error!(?dst_layout, error = %e, "Failed to resolve layout for get_blocks");
674                return Box::pin(async move { keys.into_iter().map(Err).collect() });
675            }
676        };
677
678        // Object client handles rank-based key prefixing internally
679        if let Some(client) = self.object_client.as_ref() {
680            client.get_blocks_with_layout(keys, physical_layout, block_ids)
681        } else {
682            // No object client configured - return all keys as failed
683            tracing::warn!("get_blocks called but no object client configured");
684            Box::pin(async move { keys.into_iter().map(Err).collect() })
685        }
686    }
687}