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}