Skip to main content

kvbm_engine/object/
mod.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Object storage module for distributed block management.
5//!
6//! This module provides traits and implementations for storing KV cache blocks
7//! in object storage systems like S3/MinIO.
8//!
9//! # Architecture
10//!
11//! Traits are defined here; implementations are in feature-gated submodules:
12//! - [`ObjectBlockOps`](crate::object::ObjectBlockOps) - High-level block operations (put, get, has)
13//! - [`ObjectLockManager`](crate::object::ObjectLockManager) - Distributed locking for coordinated offloads
14//!
15//! Consumers should use factory functions to obtain trait objects without
16//! depending on specific feature flags.
17
18use std::sync::Arc;
19
20use anyhow::Result;
21use futures::future::BoxFuture;
22
23use crate::{BlockId, SequenceHash};
24use kvbm_common::LogicalLayoutHandle;
25use kvbm_physical::layout::LayoutConfig;
26use kvbm_physical::transfer::PhysicalLayout;
27
28#[cfg(feature = "s3")]
29pub mod s3;
30
31// ============================================================================
32// Key Formatting
33// ============================================================================
34
35/// Trait for converting SequenceHash to object storage keys.
36///
37/// Implementations can embed rank, namespace, or any other prefix/suffix
38/// to ensure key uniqueness across SPMD workers or other contexts.
39pub trait KeyFormatter: Send + Sync {
40    /// Convert a sequence hash to an object storage key string.
41    fn format_key(&self, hash: &SequenceHash) -> String;
42}
43
44/// Default key formatter - uses Display representation of PositionalLineageHash.
45///
46/// Produces keys like: `0:abc123` or `5:abc123:def456` (position:current\[:parent\])
47/// using base58 encoding for hash fragments.
48/// Suitable for single-worker scenarios or testing.
49#[derive(Debug, Clone, Default)]
50pub struct DefaultKeyFormatter;
51
52impl KeyFormatter for DefaultKeyFormatter {
53    fn format_key(&self, hash: &SequenceHash) -> String {
54        hash.to_string()
55    }
56}
57
58/// Rank-prefixed key formatter for SPMD workers.
59///
60/// Formats keys as `{rank}/{display_hash}` to ensure uniqueness across workers
61/// writing the same logical blocks. The hash uses the Display representation
62/// (e.g., `0/5:abc123:def456`).
63#[derive(Debug, Clone)]
64pub struct RankPrefixedKeyFormatter {
65    rank: usize,
66}
67
68impl RankPrefixedKeyFormatter {
69    /// Create a new rank-prefixed formatter.
70    pub fn new(rank: usize) -> Self {
71        Self { rank }
72    }
73
74    /// Get the rank.
75    pub fn rank(&self) -> usize {
76        self.rank
77    }
78}
79
80impl KeyFormatter for RankPrefixedKeyFormatter {
81    fn format_key(&self, hash: &SequenceHash) -> String {
82        format!("{}/{}", self.rank, hash)
83    }
84}
85
86/// Create a key formatter appropriate for the given rank.
87///
88/// Returns a `RankPrefixedKeyFormatter` if rank is provided,
89/// otherwise returns a `DefaultKeyFormatter`.
90pub fn create_key_formatter(rank: Option<usize>) -> Arc<dyn KeyFormatter> {
91    match rank {
92        Some(r) => Arc::new(RankPrefixedKeyFormatter::new(r)),
93        None => Arc::new(DefaultKeyFormatter),
94    }
95}
96
97/// Extension methods for LayoutConfig to support object storage operations.
98pub trait LayoutConfigExt {
99    /// Compute the size of a single block in bytes.
100    fn block_size_bytes(&self) -> usize;
101
102    /// Compute the size of a single memory region in bytes.
103    fn region_size(&self) -> usize;
104}
105
106impl LayoutConfigExt for LayoutConfig {
107    fn block_size_bytes(&self) -> usize {
108        self.num_layers
109            .saturating_mul(self.outer_dim)
110            .saturating_mul(self.page_size)
111            .saturating_mul(self.inner_dim)
112            .saturating_mul(self.dtype_width_bytes)
113    }
114
115    fn region_size(&self) -> usize {
116        self.page_size
117            .saturating_mul(self.inner_dim)
118            .saturating_mul(self.dtype_width_bytes)
119    }
120}
121
122/// Low-level object storage client trait.
123pub trait ObjectClient: Send + Sync {
124    /// Check if an object exists.
125    fn has_object(&self, key: &[u8]) -> anyhow::Result<bool>;
126
127    /// Put an object.
128    fn put_object(&self, key: &[u8], data: &[&[u8]]) -> anyhow::Result<()>;
129
130    /// Get an object.
131    fn get_object(&self, key: &[u8], data: &mut [&mut [u8]]) -> anyhow::Result<()>;
132}
133
134/// Unified object block operations trait.
135///
136/// This trait provides high-level operations for storing and retrieving
137/// KV cache blocks in object storage (e.g., S3, MinIO).
138///
139/// Uses `LogicalLayoutHandle` to identify source/destination layouts. In distributed
140/// mode, workers resolve the logical handle to their own physical layouts. This allows
141/// the leader (which doesn't have physical layouts) to use the same trait.
142///
143/// Uses `'static` BoxFuture for runtime flexibility - implementations clone/Arc
144/// what they need from self. Takes owned Vecs for simplicity; keys are returned
145/// in results so callers can correlate success/failure.
146///
147/// Implemented by:
148/// - `S3ObjectBlockClient` - direct S3 operations (has_blocks only; put/get require physical layout)
149/// - `DirectWorker` - resolves logical handle to physical layout, then delegates
150/// - `CoordinatedWorker` - delegates to inner worker
151/// - `LeaderObjectClient` - coordinates workers for distributed uploads
152pub trait ObjectBlockOps: Send + Sync {
153    /// Check if blocks exist in object storage.
154    ///
155    /// Returns a vector of (hash, size_option) pairs where:
156    /// - Some(size) indicates the block exists with the given size in bytes
157    /// - None indicates the block does not exist or an error occurred
158    fn has_blocks(
159        &self,
160        keys: Vec<SequenceHash>,
161    ) -> BoxFuture<'static, Vec<(SequenceHash, Option<usize>)>>;
162
163    /// Put blocks to object storage.
164    ///
165    /// # Arguments
166    /// * `keys` - Sequence hashes identifying each block
167    /// * `src_layout` - Logical layout handle identifying the source (workers resolve to physical)
168    /// * `block_ids` - Block IDs within the layout to upload
169    ///
170    /// Returns a vector of results for each block:
171    /// - Ok(hash) indicates the block was successfully stored
172    /// - Err(hash) indicates the block failed to store
173    ///
174    /// # Note
175    /// For `S3ObjectBlockClient`, this will error - use `put_blocks_with_layout` instead.
176    /// Workers should resolve the logical handle to their physical layout first.
177    fn put_blocks(
178        &self,
179        keys: Vec<SequenceHash>,
180        src_layout: LogicalLayoutHandle,
181        block_ids: Vec<BlockId>,
182    ) -> BoxFuture<'static, Vec<Result<SequenceHash, SequenceHash>>>;
183
184    /// Get blocks from object storage.
185    ///
186    /// # Arguments
187    /// * `keys` - Sequence hashes identifying each block
188    /// * `dst_layout` - Logical layout handle identifying the destination (workers resolve to physical)
189    /// * `block_ids` - Block IDs within the layout to download into
190    ///
191    /// Returns a vector of results for each block:
192    /// - Ok(hash) indicates the block was successfully retrieved
193    /// - Err(hash) indicates the block failed to retrieve
194    ///
195    /// # Note
196    /// For `S3ObjectBlockClient`, this will error - use `get_blocks_with_layout` instead.
197    /// Workers should resolve the logical handle to their physical layout first.
198    fn get_blocks(
199        &self,
200        keys: Vec<SequenceHash>,
201        dst_layout: LogicalLayoutHandle,
202        block_ids: Vec<BlockId>,
203    ) -> BoxFuture<'static, Vec<Result<SequenceHash, SequenceHash>>>;
204
205    // =========================================================================
206    // Physical Layout Methods (for workers that can resolve handles)
207    // =========================================================================
208
209    /// Put blocks to object storage using a resolved physical layout.
210    ///
211    /// This method is called by workers after resolving a logical handle to
212    /// their physical layout. The default implementation errors; storage backends
213    /// like `S3ObjectBlockClient` override this with actual upload logic.
214    ///
215    /// # Arguments
216    /// * `keys` - Sequence hashes identifying each block
217    /// * `layout` - Physical layout containing the block data
218    /// * `block_ids` - Block IDs within the layout to upload
219    fn put_blocks_with_layout(
220        &self,
221        keys: Vec<SequenceHash>,
222        _layout: PhysicalLayout,
223        _block_ids: Vec<BlockId>,
224    ) -> BoxFuture<'static, Vec<Result<SequenceHash, SequenceHash>>> {
225        Box::pin(async move { keys.into_iter().map(Err).collect() })
226    }
227
228    /// Get blocks from object storage into a resolved physical layout.
229    ///
230    /// This method is called by workers after resolving a logical handle to
231    /// their physical layout. The default implementation errors; storage backends
232    /// like `S3ObjectBlockClient` override this with actual download logic.
233    ///
234    /// # Arguments
235    /// * `keys` - Sequence hashes identifying each block
236    /// * `layout` - Physical layout to write the block data into
237    /// * `block_ids` - Block IDs within the layout to download into
238    fn get_blocks_with_layout(
239        &self,
240        keys: Vec<SequenceHash>,
241        _layout: PhysicalLayout,
242        _block_ids: Vec<BlockId>,
243    ) -> BoxFuture<'static, Vec<Result<SequenceHash, SequenceHash>>> {
244        Box::pin(async move { keys.into_iter().map(Err).collect() })
245    }
246}
247
248// ============================================================================
249// Object Lock Manager Trait
250// ============================================================================
251
252/// Lock file content structure for distributed locking.
253///
254/// The lock file is stored as JSON in object storage at `{sequence_hash}.lock`.
255#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
256pub struct LockFileContent {
257    /// Unique identifier of the instance that holds the lock
258    pub instance_id: String,
259    /// When the lock was acquired (ISO 8601 timestamp)
260    pub acquired_at: String,
261    /// When the lock expires (ISO 8601 timestamp)
262    pub deadline: String,
263}
264
265/// Object lock manager trait for distributed locking in object storage.
266///
267/// This trait provides the locking semantics for the object offload pipeline:
268/// 1. Check `.meta` file to see if block is already offloaded
269/// 2. Try to acquire `.lock` file with conditional PUT
270/// 3. Create `.meta` file after successful offload
271/// 4. Release `.lock` file after completion
272///
273/// # Locking Flow
274///
275/// ```text
276/// has_meta() -> false -> try_acquire_lock() -> true -> execute transfer -> create_meta() -> release_lock()
277///                                           -> false -> skip (another instance owns it)
278///            -> true -> skip (already offloaded)
279/// ```
280pub trait ObjectLockManager: Send + Sync {
281    /// Check if meta file exists (block already offloaded).
282    ///
283    /// Returns `true` if `{hash}.meta` exists, meaning the block has been
284    /// successfully offloaded and should be skipped.
285    fn has_meta(&self, hash: SequenceHash) -> BoxFuture<'static, Result<bool>>;
286
287    /// Try to acquire a lock for the given block.
288    ///
289    /// This method:
290    /// 1. Attempts conditional PUT of `{hash}.lock` with `If-None-Match: *`
291    /// 2. If lock exists, reads it to check deadline
292    /// 3. If deadline is breached (> timeout), overwrites the lock
293    ///
294    /// Returns:
295    /// - `Ok(true)` if lock was acquired or overwritten
296    /// - `Ok(false)` if another instance owns a valid lock
297    /// - `Err(...)` for other errors
298    fn try_acquire_lock(&self, hash: SequenceHash) -> BoxFuture<'static, Result<bool>>;
299
300    /// Create the meta file after successful offload.
301    ///
302    /// This marks the block as successfully offloaded by creating `{hash}.meta`.
303    fn create_meta(&self, hash: SequenceHash) -> BoxFuture<'static, Result<()>>;
304
305    /// Release the lock by deleting the lock file.
306    ///
307    /// Deletes `{hash}.lock` after the transfer is complete.
308    fn release_lock(&self, hash: SequenceHash) -> BoxFuture<'static, Result<()>>;
309}
310
311// ============================================================================
312// Factory Functions
313// ============================================================================
314
315/// Create an object client from configuration.
316///
317/// Returns a trait object so consumers don't need to depend on the `s3` feature.
318/// The implementation is selected based on the configuration type.
319///
320/// # Arguments
321/// * `config` - Object storage configuration
322/// * `rank` - Optional worker rank for key prefixing (None for leader)
323///
324/// # Errors
325/// Returns an error if the object client cannot be initialized or if the
326/// required feature is not enabled.
327#[cfg(feature = "s3")]
328pub async fn create_object_client(
329    config: &kvbm_config::ObjectConfig,
330    rank: Option<usize>,
331) -> Result<Arc<dyn ObjectBlockOps>> {
332    use kvbm_config::ObjectClientConfig;
333    use s3::{S3Config, S3ObjectBlockClient};
334
335    let key_formatter = create_key_formatter(rank);
336
337    match &config.client {
338        ObjectClientConfig::S3(s3_config) => {
339            let config = S3Config::from_object_config(s3_config);
340            let client = S3ObjectBlockClient::with_key_formatter(config, key_formatter).await?;
341            Ok(Arc::new(client))
342        }
343        ObjectClientConfig::Nixl(_nixl_config) => {
344            anyhow::bail!("Nixl object storage backend not yet implemented")
345        }
346    }
347}
348
349/// Fallback when S3 feature is disabled.
350#[cfg(not(feature = "s3"))]
351pub async fn create_object_client(
352    _config: &kvbm_config::ObjectConfig,
353    _rank: Option<usize>,
354) -> Result<Arc<dyn ObjectBlockOps>> {
355    anyhow::bail!("Object storage requires the 's3' feature to be enabled")
356}
357
358/// Create a lock manager from configuration.
359///
360/// Returns a trait object so consumers don't need to depend on the `s3` feature.
361///
362/// # Arguments
363/// * `config` - Object storage configuration
364/// * `instance_id` - Unique identifier for this instance (used in lock files)
365///
366/// # Errors
367/// Returns an error if the lock manager cannot be initialized or if the
368/// required feature is not enabled.
369#[cfg(feature = "s3")]
370pub async fn create_lock_manager(
371    config: &kvbm_config::ObjectConfig,
372    instance_id: String,
373) -> Result<Arc<dyn ObjectLockManager>> {
374    use kvbm_config::ObjectClientConfig;
375    use s3::{S3Config, S3LockManager, S3ObjectBlockClient};
376
377    match &config.client {
378        ObjectClientConfig::S3(s3_config) => {
379            let config = S3Config::from_object_config(s3_config);
380            // Lock manager uses default key formatter (no rank prefix for lock/meta files)
381            let client = Arc::new(S3ObjectBlockClient::new(config).await?);
382            let manager = S3LockManager::new(client, instance_id);
383            Ok(Arc::new(manager))
384        }
385        ObjectClientConfig::Nixl(_nixl_config) => {
386            anyhow::bail!("Nixl object storage backend not yet implemented")
387        }
388    }
389}
390
391/// Fallback when S3 feature is disabled.
392#[cfg(not(feature = "s3"))]
393pub async fn create_lock_manager(
394    _config: &kvbm_config::ObjectConfig,
395    _instance_id: String,
396) -> Result<Arc<dyn ObjectLockManager>> {
397    anyhow::bail!("Object storage requires the 's3' feature to be enabled")
398}