Skip to main content

ursula_runtime/
cold_store.rs

1use std::collections::HashMap;
2use std::collections::VecDeque;
3use std::fmt;
4use std::future::Future;
5use std::io;
6use std::pin::Pin;
7use std::sync::Arc;
8use std::sync::Mutex;
9use std::sync::atomic::AtomicU64;
10use std::sync::atomic::Ordering;
11use std::time::Duration;
12#[cfg(not(madsim))]
13use std::time::SystemTime;
14#[cfg(not(madsim))]
15use std::time::UNIX_EPOCH;
16
17use bytes::Bytes;
18use crossbeam_utils::CachePadded;
19use opendal::Operator;
20use opendal::Scheme;
21use opendal::layers::RetryLayer;
22use opendal::layers::TimeoutLayer;
23use ursula_shard::BucketStreamId;
24use ursula_stream::ColdChunkRef;
25use ursula_stream::ObjectPayloadRef;
26
27pub(crate) const DEFAULT_CONTENT_TYPE: &str = "application/octet-stream";
28// Keep this global atomic isolated from unrelated statics. This does not remove
29// contention on the counter itself, but avoids accidental false sharing with
30// adjacent data without adding a per-core sequence scheme to this low-frequency
31// object-key path.
32static COLD_CHUNK_SEQUENCE: CachePadded<AtomicU64> = CachePadded::new(AtomicU64::new(0));
33const DEFAULT_COLD_CACHE_BYTES: usize = 256 * 1024 * 1024;
34const DEFAULT_COLD_CACHE_BLOCK_BYTES: usize = 1024 * 1024;
35const DEFAULT_COLD_CACHE_READAHEAD_BLOCKS: usize = 4;
36const DEFAULT_S3_OP_TIMEOUT_MS: u64 = 10_000;
37const DEFAULT_S3_MAX_RETRIES: usize = 3;
38
39/// Wrap an S3 (opendal) operator with the resilience every external
40/// object-store call needs.
41///
42/// 1. **Per-attempt timeout** ([`TimeoutLayer`], inner): a blackholed endpoint
43///    — chaos `s3_unavailable`, or a "busy ESTAB" TCP socket whose future never
44///    gets polled again — otherwise hangs the caller until
45///    `net.ipv4.tcp_retries2` (~15 min). That is the original freeze: the raft
46///    state-machine worker awaits S3 inside `install_snapshot` (`&mut self`),
47///    and openraft type-level-serializes `apply` with it, so an unbounded S3
48///    stall freezes apply. Bounding every attempt keeps the worker progressing.
49/// 2. **Bounded retries** ([`RetryLayer`], outer): S3 answers `503 SlowDown`
50///    while a fresh key prefix warms up (and on transient network blips). These
51///    are `is_temporary()` errors; without retries a single 503 fails a
52///    snapshot upload/download, stalling a restarted node's rejoin/catch-up.
53///    Retries are bounded, so a sustained outage still fails fast enough (each
54///    attempt is timeout-bounded) and the cluster keeps progressing on quorum.
55///
56/// Timeout is applied before retry so each retry attempt is itself bounded
57/// (per opendal's layer-ordering requirement). Tunable via `URSULA_S3_TIMEOUT_MS`
58/// and `URSULA_S3_MAX_RETRIES`.
59pub(crate) fn with_s3_resilience(operator: Operator) -> Operator {
60    let timeout_ms = std::env::var("URSULA_S3_TIMEOUT_MS")
61        .ok()
62        .and_then(|raw| raw.parse::<u64>().ok())
63        .filter(|ms| *ms > 0)
64        .unwrap_or(DEFAULT_S3_OP_TIMEOUT_MS);
65    let timeout = Duration::from_millis(timeout_ms);
66    let max_retries = std::env::var("URSULA_S3_MAX_RETRIES")
67        .ok()
68        .and_then(|raw| raw.parse::<usize>().ok())
69        .unwrap_or(DEFAULT_S3_MAX_RETRIES);
70    operator
71        .layer(
72            TimeoutLayer::new()
73                .with_timeout(timeout)
74                .with_io_timeout(timeout),
75        )
76        .layer(RetryLayer::new().with_max_times(max_retries).with_jitter())
77}
78
79#[derive(Clone)]
80pub struct ColdStore {
81    info: ColdStoreInfo,
82    operator: Operator,
83    read_cache: Option<Arc<ColdReadCache>>,
84    observer: Arc<Mutex<Option<ColdStoreObserver>>>,
85    fault_policy: Arc<Mutex<Option<ColdStoreFaultPolicy>>>,
86    delay_fn: Arc<Mutex<ColdStoreDelayFn>>,
87}
88
89pub type ColdStoreHandle = Arc<ColdStore>;
90
91type ColdStoreObserver = Arc<dyn Fn(ColdStoreEvent) + Send + Sync>;
92type ColdStoreFaultPolicy =
93    Arc<dyn Fn(&ColdStoreFaultContext) -> Option<ColdStoreFaultEffect> + Send + Sync>;
94type ColdStoreDelayFuture = Pin<Box<dyn Future<Output = ()> + Send + 'static>>;
95type ColdStoreDelayFn = Arc<dyn Fn(Duration) -> ColdStoreDelayFuture + Send + Sync>;
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum ColdStoreOperation {
99    WriteChunk,
100    DeleteChunk,
101    RemoveAll,
102    ReadObjectRange,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct ColdStoreFaultContext {
107    pub operation: ColdStoreOperation,
108    pub stream_id: Option<BucketStreamId>,
109    pub path: String,
110    pub payload_len: Option<usize>,
111    pub read_start_offset: Option<u64>,
112    pub len: Option<usize>,
113    pub object_start: Option<u64>,
114    pub object_end: Option<u64>,
115    pub cached: Option<bool>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct ColdStoreFault {
120    pub message: String,
121}
122
123impl ColdStoreFault {
124    pub fn new(message: impl Into<String>) -> Self {
125        Self {
126            message: message.into(),
127        }
128    }
129}
130
131#[derive(Debug, Clone, Default, PartialEq, Eq)]
132pub struct ColdStoreFaultEffect {
133    pub delay: Option<Duration>,
134    pub error: Option<ColdStoreFault>,
135    pub truncate_read_to: Option<usize>,
136}
137
138impl ColdStoreFaultEffect {
139    pub fn delay(duration: Duration) -> Self {
140        Self {
141            delay: Some(duration),
142            error: None,
143            truncate_read_to: None,
144        }
145    }
146
147    pub fn fail(message: impl Into<String>) -> Self {
148        Self {
149            delay: None,
150            error: Some(ColdStoreFault::new(message)),
151            truncate_read_to: None,
152        }
153    }
154
155    pub fn delay_then_fail(duration: Duration, message: impl Into<String>) -> Self {
156        Self {
157            delay: Some(duration),
158            error: Some(ColdStoreFault::new(message)),
159            truncate_read_to: None,
160        }
161    }
162
163    pub fn truncate_read_to(len: usize) -> Self {
164        Self {
165            delay: None,
166            error: None,
167            truncate_read_to: Some(len),
168        }
169    }
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub enum ColdStoreEvent {
174    WriteChunkBegin {
175        path: String,
176        payload_len: usize,
177    },
178    WriteChunkComplete {
179        path: String,
180        object_size: u64,
181    },
182    DeleteChunkBegin {
183        path: String,
184    },
185    DeleteChunkComplete {
186        path: String,
187    },
188    RemoveAllBegin {
189        path: String,
190    },
191    RemoveAllComplete {
192        path: String,
193    },
194    ReadObjectRangeBegin {
195        stream_id: Option<BucketStreamId>,
196        path: String,
197        read_start_offset: u64,
198        len: usize,
199        object_start: u64,
200        object_end: u64,
201        cached: bool,
202    },
203    ReadObjectRangeComplete {
204        stream_id: Option<BucketStreamId>,
205        path: String,
206        read_start_offset: u64,
207        len: usize,
208        returned_len: usize,
209        cached: bool,
210    },
211    FaultInjected {
212        operation: ColdStoreOperation,
213        stream_id: Option<BucketStreamId>,
214        path: String,
215        message: String,
216    },
217    DelayInjected {
218        operation: ColdStoreOperation,
219        stream_id: Option<BucketStreamId>,
220        path: String,
221        delay_ms: u64,
222    },
223    TruncateInjected {
224        stream_id: Option<BucketStreamId>,
225        path: String,
226        requested_len: usize,
227        returned_len: usize,
228    },
229}
230
231impl fmt::Debug for ColdStore {
232    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233        f.debug_struct("ColdStore")
234            .field("info", &self.info)
235            .field("operator", &self.operator)
236            .field("read_cache", &self.read_cache)
237            .finish_non_exhaustive()
238    }
239}
240
241#[derive(Clone, Debug, PartialEq, Eq)]
242pub struct ColdStoreInfo {
243    pub backend: &'static str,
244    pub root: Option<String>,
245    pub bucket: Option<String>,
246    pub region: Option<String>,
247    pub endpoint: Option<String>,
248}
249
250impl ColdStore {
251    pub fn memory() -> io::Result<Self> {
252        let operator = Operator::via_iter(Scheme::Memory, [])
253            .map_err(|err| io::Error::other(err.to_string()))?;
254        Ok(Self::from_operator(operator, ColdStoreInfo {
255            backend: "memory",
256            root: None,
257            bucket: None,
258            region: None,
259            endpoint: None,
260        }))
261    }
262
263    pub fn s3_from_env() -> io::Result<Self> {
264        Self::s3_from_env_with_root(None)
265    }
266
267    pub fn s3_from_env_with_root(root_override: Option<&str>) -> io::Result<Self> {
268        let bucket = std::env::var("URSULA_COLD_S3_BUCKET").map_err(|_| {
269            io::Error::new(
270                io::ErrorKind::InvalidInput,
271                "URSULA_COLD_S3_BUCKET is required when URSULA_COLD_BACKEND=s3",
272            )
273        })?;
274        if bucket.trim().is_empty() {
275            return Err(io::Error::new(
276                io::ErrorKind::InvalidInput,
277                "URSULA_COLD_S3_BUCKET must not be empty",
278            ));
279        }
280
281        let mut builder = opendal::services::S3::default().bucket(&bucket);
282        let mut configured_root = None;
283        if let Some(root) = root_override {
284            if !root.trim().is_empty() {
285                builder = builder.root(root);
286                configured_root = Some(root.to_owned());
287            }
288        } else if let Ok(root) = std::env::var("URSULA_COLD_ROOT")
289            && !root.trim().is_empty()
290        {
291            builder = builder.root(&root);
292            configured_root = Some(root);
293        }
294        let mut configured_region = None;
295        if let Ok(region) = std::env::var("URSULA_COLD_S3_REGION")
296            && !region.trim().is_empty()
297        {
298            builder = builder.region(&region);
299            configured_region = Some(region);
300        }
301        let mut configured_endpoint = None;
302        if let Ok(endpoint) = std::env::var("URSULA_COLD_S3_ENDPOINT")
303            && !endpoint.trim().is_empty()
304        {
305            builder = builder.endpoint(&endpoint);
306            configured_endpoint = Some(endpoint);
307        }
308        if let Ok(access_key_id) = std::env::var("URSULA_COLD_S3_ACCESS_KEY_ID")
309            && !access_key_id.trim().is_empty()
310        {
311            builder = builder.access_key_id(&access_key_id);
312        }
313        if let Ok(secret_access_key) = std::env::var("URSULA_COLD_S3_SECRET_ACCESS_KEY")
314            && !secret_access_key.trim().is_empty()
315        {
316            builder = builder.secret_access_key(&secret_access_key);
317        }
318        if let Ok(session_token) = std::env::var("URSULA_COLD_S3_SESSION_TOKEN")
319            && !session_token.trim().is_empty()
320        {
321            builder = builder.session_token(&session_token);
322        }
323
324        Ok(Self::from_operator(
325            with_s3_resilience(
326                Operator::new(builder)
327                    .map_err(|err| io::Error::other(err.to_string()))?
328                    .finish(),
329            ),
330            ColdStoreInfo {
331                backend: "s3",
332                root: configured_root,
333                bucket: Some(bucket),
334                region: configured_region,
335                endpoint: configured_endpoint,
336            },
337        ))
338    }
339
340    pub fn from_env() -> io::Result<Option<ColdStoreHandle>> {
341        let backend = std::env::var("URSULA_COLD_BACKEND")
342            .unwrap_or_else(|_| "none".to_owned())
343            .to_ascii_lowercase();
344        Self::from_backend(&backend)
345    }
346
347    fn from_backend(backend: &str) -> io::Result<Option<ColdStoreHandle>> {
348        let store = match backend {
349            "none" | "disabled" | "off" => return Ok(None),
350            "memory" | "mem" | "inmem" => Self::memory()?,
351            "s3" => Self::s3_from_env()?,
352            other => {
353                return Err(io::Error::new(
354                    io::ErrorKind::InvalidInput,
355                    format!("unsupported URSULA_COLD_BACKEND '{other}'"),
356                ));
357            }
358        };
359        Ok(Some(Arc::new(store)))
360    }
361
362    pub fn from_operator(operator: Operator, info: ColdStoreInfo) -> Self {
363        Self {
364            info,
365            operator,
366            read_cache: ColdReadCache::from_env().map(Arc::new),
367            observer: Arc::new(Mutex::new(None)),
368            fault_policy: Arc::new(Mutex::new(None)),
369            delay_fn: Arc::new(Mutex::new(default_cold_store_delay_fn())),
370        }
371    }
372
373    pub fn info(&self) -> &ColdStoreInfo {
374        &self.info
375    }
376
377    pub fn with_read_cache(mut self, config: ColdReadCacheConfig) -> Self {
378        self.read_cache = Some(Arc::new(ColdReadCache::new(config)));
379        self
380    }
381
382    pub fn without_read_cache(mut self) -> Self {
383        self.read_cache = None;
384        self
385    }
386
387    pub fn set_observer(&self, observer: impl Fn(ColdStoreEvent) + Send + Sync + 'static) {
388        *self.observer.lock().expect("cold store observer mutex") = Some(Arc::new(observer));
389    }
390
391    pub fn set_fault_policy(
392        &self,
393        policy: impl Fn(&ColdStoreFaultContext) -> Option<ColdStoreFaultEffect> + Send + Sync + 'static,
394    ) {
395        *self
396            .fault_policy
397            .lock()
398            .expect("cold store fault policy mutex") = Some(Arc::new(policy));
399    }
400
401    pub fn clear_fault_policy(&self) {
402        *self
403            .fault_policy
404            .lock()
405            .expect("cold store fault policy mutex") = None;
406    }
407
408    pub fn set_delay_fn<F, Fut>(&self, delay_fn: F)
409    where
410        F: Fn(Duration) -> Fut + Send + Sync + 'static,
411        Fut: Future<Output = ()> + Send + 'static,
412    {
413        *self.delay_fn.lock().expect("cold store delay fn mutex") =
414            Arc::new(move |duration| Box::pin(delay_fn(duration)));
415    }
416
417    #[cfg(test)]
418    pub(crate) fn cached_block_count(&self) -> usize {
419        self.read_cache
420            .as_ref()
421            .map(|cache| cache.block_count())
422            .unwrap_or(0)
423    }
424
425    pub async fn write_chunk(&self, path: &str, payload: &[u8]) -> io::Result<u64> {
426        if path.trim().is_empty() {
427            return Err(io::Error::new(
428                io::ErrorKind::InvalidInput,
429                "cold chunk path must not be empty",
430            ));
431        }
432        self.notify(ColdStoreEvent::WriteChunkBegin {
433            path: path.to_owned(),
434            payload_len: payload.len(),
435        });
436        let _applied_fault = self
437            .maybe_apply_fault_effect(ColdStoreFaultContext {
438                operation: ColdStoreOperation::WriteChunk,
439                stream_id: None,
440                path: path.to_owned(),
441                payload_len: Some(payload.len()),
442                read_start_offset: None,
443                len: None,
444                object_start: None,
445                object_end: None,
446                cached: None,
447            })
448            .await?;
449        self.operator
450            .write(path, payload.to_vec())
451            .await
452            .map_err(|err| cold_store_io_error(path, err))?;
453        let object_size = u64::try_from(payload.len()).expect("payload len fits u64");
454        self.notify(ColdStoreEvent::WriteChunkComplete {
455            path: path.to_owned(),
456            object_size,
457        });
458        Ok(object_size)
459    }
460
461    pub(crate) async fn write_cold_index_page(
462        &self,
463        path: &str,
464        payload: &[u8],
465    ) -> io::Result<u64> {
466        if path.trim().is_empty() {
467            return Err(io::Error::new(
468                io::ErrorKind::InvalidInput,
469                "cold index page path must not be empty",
470            ));
471        }
472        self.operator
473            .write(path, payload.to_vec())
474            .await
475            .map_err(|err| cold_store_io_error(path, err))?;
476        Ok(u64::try_from(payload.len()).expect("payload len fits u64"))
477    }
478
479    #[tracing::instrument(name = "cold.read_index", level = "debug", skip_all)]
480    pub(crate) async fn read_cold_index_page(&self, path: &str) -> io::Result<Option<Vec<u8>>> {
481        if path.trim().is_empty() {
482            return Err(io::Error::new(
483                io::ErrorKind::InvalidInput,
484                "cold index page path must not be empty",
485            ));
486        }
487        match self.operator.read(path).await {
488            Ok(bytes) => Ok(Some(bytes.to_bytes().to_vec())),
489            Err(err) if err.kind() == opendal::ErrorKind::NotFound => Ok(None),
490            Err(err) => Err(cold_store_io_error(path, err)),
491        }
492    }
493
494    pub async fn delete_chunk(&self, path: &str) -> io::Result<()> {
495        if path.trim().is_empty() {
496            return Err(io::Error::new(
497                io::ErrorKind::InvalidInput,
498                "cold chunk path must not be empty",
499            ));
500        }
501        self.notify(ColdStoreEvent::DeleteChunkBegin {
502            path: path.to_owned(),
503        });
504        let _applied_fault = self
505            .maybe_apply_fault_effect(ColdStoreFaultContext {
506                operation: ColdStoreOperation::DeleteChunk,
507                stream_id: None,
508                path: path.to_owned(),
509                payload_len: None,
510                read_start_offset: None,
511                len: None,
512                object_start: None,
513                object_end: None,
514                cached: None,
515            })
516            .await?;
517        self.operator
518            .delete(path)
519            .await
520            .map_err(|err| cold_store_io_error(path, err))?;
521        if let Some(cache) = &self.read_cache {
522            cache.invalidate_path(path);
523        }
524        self.notify(ColdStoreEvent::DeleteChunkComplete {
525            path: path.to_owned(),
526        });
527        Ok(())
528    }
529
530    pub async fn remove_all(&self, path: &str) -> io::Result<()> {
531        self.notify(ColdStoreEvent::RemoveAllBegin {
532            path: path.to_owned(),
533        });
534        let _applied_fault = self
535            .maybe_apply_fault_effect(ColdStoreFaultContext {
536                operation: ColdStoreOperation::RemoveAll,
537                stream_id: None,
538                path: path.to_owned(),
539                payload_len: None,
540                read_start_offset: None,
541                len: None,
542                object_start: None,
543                object_end: None,
544                cached: None,
545            })
546            .await?;
547        self.operator
548            .remove_all(path)
549            .await
550            .map_err(|err| cold_store_io_error(path, err))?;
551        if let Some(cache) = &self.read_cache {
552            cache.invalidate_prefix(path);
553        }
554        self.notify(ColdStoreEvent::RemoveAllComplete {
555            path: path.to_owned(),
556        });
557        Ok(())
558    }
559
560    pub async fn read_chunk_range(
561        &self,
562        chunk: &ColdChunkRef,
563        read_start_offset: u64,
564        len: usize,
565    ) -> io::Result<Vec<u8>> {
566        let object = ObjectPayloadRef {
567            start_offset: chunk.start_offset,
568            end_offset: chunk.end_offset,
569            s3_path: chunk.s3_path.clone(),
570            object_size: chunk.object_size,
571        };
572        self.read_object_range(&object, read_start_offset, len)
573            .await
574    }
575
576    pub async fn read_object_range_for_stream(
577        &self,
578        stream_id: &BucketStreamId,
579        object: &ObjectPayloadRef,
580        read_start_offset: u64,
581        len: usize,
582    ) -> io::Result<Vec<u8>> {
583        self.read_object_range_inner(Some(stream_id), object, read_start_offset, len)
584            .await
585    }
586
587    pub async fn read_object_range(
588        &self,
589        object: &ObjectPayloadRef,
590        read_start_offset: u64,
591        len: usize,
592    ) -> io::Result<Vec<u8>> {
593        self.read_object_range_inner(None, object, read_start_offset, len)
594            .await
595    }
596
597    #[tracing::instrument(
598        name = "cold.read_chunk",
599        level = "debug",
600        skip_all,
601        fields(
602            start_offset = object.start_offset,
603            end_offset = object.end_offset,
604            object_size = object.object_size,
605            len = len,
606        ),
607    )]
608    async fn read_object_range_inner(
609        &self,
610        stream_id: Option<&BucketStreamId>,
611        object: &ObjectPayloadRef,
612        read_start_offset: u64,
613        len: usize,
614    ) -> io::Result<Vec<u8>> {
615        if len == 0 {
616            return Ok(Vec::new());
617        }
618        let len_u64 = u64::try_from(len).map_err(|_| {
619            io::Error::new(io::ErrorKind::InvalidInput, "cold read length exceeds u64")
620        })?;
621        let read_end = read_start_offset.checked_add(len_u64).ok_or_else(|| {
622            io::Error::new(io::ErrorKind::InvalidInput, "cold read range overflow")
623        })?;
624        if read_start_offset < object.start_offset || read_end > object.end_offset {
625            return Err(io::Error::new(
626                io::ErrorKind::InvalidInput,
627                format!(
628                    "cold read range [{read_start_offset}..{read_end}) is outside object segment [{}..{})",
629                    object.start_offset, object.end_offset
630                ),
631            ));
632        }
633        let object_start = read_start_offset - object.start_offset;
634        let object_end = object_start.checked_add(len_u64).ok_or_else(|| {
635            io::Error::new(io::ErrorKind::InvalidInput, "cold read range overflow")
636        })?;
637        if object_end > object.object_size {
638            return Err(io::Error::new(
639                io::ErrorKind::InvalidData,
640                format!(
641                    "cold read range [{object_start}..{object_end}) is outside object '{}' size {}",
642                    object.s3_path, object.object_size
643                ),
644            ));
645        }
646        let cached = self.read_cache.is_some();
647        self.notify(ColdStoreEvent::ReadObjectRangeBegin {
648            stream_id: stream_id.cloned(),
649            path: object.s3_path.clone(),
650            read_start_offset,
651            len,
652            object_start,
653            object_end,
654            cached,
655        });
656        let applied_fault = self
657            .maybe_apply_fault_effect(ColdStoreFaultContext {
658                operation: ColdStoreOperation::ReadObjectRange,
659                stream_id: stream_id.cloned(),
660                path: object.s3_path.clone(),
661                payload_len: None,
662                read_start_offset: Some(read_start_offset),
663                len: Some(len),
664                object_start: Some(object_start),
665                object_end: Some(object_end),
666                cached: Some(cached),
667            })
668            .await?;
669        let mut bytes = if let Some(cache) = &self.read_cache {
670            let bytes = self
671                .read_object_range_cached(cache, object, object_start, object_end, len)
672                .await?;
673            if let Some(stream_id) = stream_id {
674                let readahead_blocks = cache.record_stream_read(stream_id, read_start_offset, len);
675                if readahead_blocks > 0 {
676                    self.spawn_readahead(object.clone(), object_end, readahead_blocks);
677                }
678            }
679            bytes
680        } else {
681            self.read_object_range_uncached(object, object_start, object_end, len)
682                .await?
683        };
684        if let Some(returned_len) = applied_fault.truncate_read_to {
685            let returned_len = returned_len.min(bytes.len());
686            bytes.truncate(returned_len);
687            self.notify(ColdStoreEvent::TruncateInjected {
688                stream_id: stream_id.cloned(),
689                path: object.s3_path.clone(),
690                requested_len: len,
691                returned_len,
692            });
693        }
694        if bytes.len() != len {
695            return Err(io::Error::new(
696                io::ErrorKind::InvalidData,
697                format!(
698                    "cold object '{}' returned {} bytes for requested range [{}..{})",
699                    object.s3_path,
700                    bytes.len(),
701                    object_start,
702                    object_end
703                ),
704            ));
705        }
706        self.notify(ColdStoreEvent::ReadObjectRangeComplete {
707            stream_id: stream_id.cloned(),
708            path: object.s3_path.clone(),
709            read_start_offset,
710            len,
711            returned_len: bytes.len(),
712            cached,
713        });
714        Ok(bytes)
715    }
716
717    async fn read_object_range_uncached(
718        &self,
719        object: &ObjectPayloadRef,
720        object_start: u64,
721        object_end: u64,
722        len: usize,
723    ) -> io::Result<Vec<u8>> {
724        let bytes = self
725            .operator
726            .read_with(&object.s3_path)
727            .range(object_start..object_end)
728            .await
729            .map_err(|err| cold_store_io_error(&object.s3_path, err))?
730            .to_bytes();
731        if bytes.len() != len {
732            return Err(io::Error::new(
733                io::ErrorKind::InvalidData,
734                format!(
735                    "cold object '{}' returned {} bytes for requested range [{}..{})",
736                    object.s3_path,
737                    bytes.len(),
738                    object_start,
739                    object_end
740                ),
741            ));
742        }
743        Ok(bytes.to_vec())
744    }
745
746    async fn read_object_range_cached(
747        &self,
748        cache: &ColdReadCache,
749        object: &ObjectPayloadRef,
750        object_start: u64,
751        object_end: u64,
752        len: usize,
753    ) -> io::Result<Vec<u8>> {
754        let mut payload = Vec::with_capacity(len);
755        let block_size = cache.block_size();
756        let first_block = object_start / block_size;
757        let last_block = (object_end - 1) / block_size;
758        for block_index in first_block..=last_block {
759            let block_start = block_index * block_size;
760            let block_end = block_start
761                .saturating_add(block_size)
762                .min(object.object_size);
763            let block = self
764                .read_cached_block(
765                    cache,
766                    object.s3_path.clone(),
767                    object.object_size,
768                    block_index,
769                    block_start,
770                    block_end,
771                )
772                .await?;
773            let slice_start = usize::try_from(object_start.max(block_start) - block_start)
774                .expect("cache slice start fits usize");
775            let slice_end = usize::try_from(object_end.min(block_end) - block_start)
776                .expect("cache slice end fits usize");
777            payload.extend_from_slice(&block.slice(slice_start..slice_end));
778        }
779        if payload.len() != len {
780            return Err(io::Error::new(
781                io::ErrorKind::InvalidData,
782                format!(
783                    "cold object '{}' returned {} bytes for requested range [{}..{})",
784                    object.s3_path,
785                    payload.len(),
786                    object_start,
787                    object_end
788                ),
789            ));
790        }
791        Ok(payload)
792    }
793
794    async fn read_cached_block(
795        &self,
796        cache: &ColdReadCache,
797        path: String,
798        object_size: u64,
799        block_index: u64,
800        block_start: u64,
801        block_end: u64,
802    ) -> io::Result<Bytes> {
803        if let Some(bytes) = cache.get(&path, block_index) {
804            return Ok(bytes);
805        }
806        let bytes = self
807            .operator
808            .read_with(&path)
809            .range(block_start..block_end)
810            .await
811            .map_err(|err| cold_store_io_error(&path, err))?
812            .to_bytes();
813        let expected_len = usize::try_from(block_end - block_start).map_err(|_| {
814            io::Error::new(
815                io::ErrorKind::InvalidData,
816                "cold cache block length exceeds usize",
817            )
818        })?;
819        if bytes.len() != expected_len {
820            return Err(io::Error::new(
821                io::ErrorKind::InvalidData,
822                format!(
823                    "cold object '{path}' returned {} bytes for cache block [{}..{}) of object size {object_size}",
824                    bytes.len(),
825                    block_start,
826                    block_end
827                ),
828            ));
829        }
830        cache.insert(path, block_index, bytes.clone());
831        Ok(bytes)
832    }
833
834    fn spawn_readahead(&self, object: ObjectPayloadRef, object_end: u64, readahead_blocks: usize) {
835        let Some(cache) = self.read_cache.clone() else {
836            return;
837        };
838        let block_size = cache.block_size();
839        let mut block_index = object_end.div_ceil(block_size);
840        let store = self.clone();
841        crate::rt::spawn(async move {
842            for _ in 0..readahead_blocks {
843                let block_start = block_index * block_size;
844                if block_start >= object.object_size {
845                    break;
846                }
847                let block_end = block_start
848                    .saturating_add(block_size)
849                    .min(object.object_size);
850                if cache.get(&object.s3_path, block_index).is_none() {
851                    let _ = store
852                        .read_cached_block(
853                            &cache,
854                            object.s3_path.clone(),
855                            object.object_size,
856                            block_index,
857                            block_start,
858                            block_end,
859                        )
860                        .await;
861                }
862                block_index += 1;
863            }
864        });
865    }
866
867    fn notify(&self, event: ColdStoreEvent) {
868        let observer = self
869            .observer
870            .lock()
871            .expect("cold store observer mutex")
872            .clone();
873        if let Some(observer) = observer {
874            observer(event);
875        }
876    }
877
878    async fn maybe_apply_fault_effect(
879        &self,
880        context: ColdStoreFaultContext,
881    ) -> io::Result<ColdStoreAppliedFault> {
882        let policy = self
883            .fault_policy
884            .lock()
885            .expect("cold store fault policy mutex")
886            .clone();
887        let Some(policy) = policy else {
888            return Ok(ColdStoreAppliedFault::default());
889        };
890        let Some(effect) = policy(&context) else {
891            return Ok(ColdStoreAppliedFault::default());
892        };
893        if let Some(delay) = effect.delay {
894            self.notify(ColdStoreEvent::DelayInjected {
895                operation: context.operation,
896                stream_id: context.stream_id.clone(),
897                path: context.path.clone(),
898                delay_ms: duration_ms(delay),
899            });
900            let delay_fn = self
901                .delay_fn
902                .lock()
903                .expect("cold store delay fn mutex")
904                .clone();
905            delay_fn(delay).await;
906        }
907        if let Some(fault) = effect.error {
908            self.notify(ColdStoreEvent::FaultInjected {
909                operation: context.operation,
910                stream_id: context.stream_id,
911                path: context.path.clone(),
912                message: fault.message.clone(),
913            });
914            return Err(io::Error::other(format!(
915                "cold store fault injected for {} '{}': {}",
916                context.operation.as_str(),
917                context.path,
918                fault.message
919            )));
920        }
921        Ok(ColdStoreAppliedFault {
922            truncate_read_to: effect.truncate_read_to,
923        })
924    }
925}
926
927#[derive(Debug, Default)]
928struct ColdStoreAppliedFault {
929    truncate_read_to: Option<usize>,
930}
931
932impl ColdStoreOperation {
933    fn as_str(self) -> &'static str {
934        match self {
935            Self::WriteChunk => "write_chunk",
936            Self::DeleteChunk => "delete_chunk",
937            Self::RemoveAll => "remove_all",
938            Self::ReadObjectRange => "read_object_range",
939        }
940    }
941}
942
943fn default_cold_store_delay_fn() -> ColdStoreDelayFn {
944    Arc::new(|duration| Box::pin(crate::rt::time::sleep(duration)))
945}
946
947fn duration_ms(duration: Duration) -> u64 {
948    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
949}
950
951#[derive(Debug, Clone, Copy)]
952pub struct ColdReadCacheConfig {
953    pub max_bytes: usize,
954    pub block_bytes: usize,
955    pub max_readahead_blocks: usize,
956}
957
958#[derive(Debug)]
959struct ColdReadCache {
960    config: ColdReadCacheConfig,
961    inner: Mutex<ColdReadCacheInner>,
962}
963
964#[derive(Debug, Default)]
965struct ColdReadCacheInner {
966    blocks: HashMap<ColdCacheKey, ColdCacheEntry>,
967    lru: VecDeque<(ColdCacheKey, u64)>,
968    current_bytes: usize,
969    generation: u64,
970    readers: HashMap<BucketStreamId, StreamReadState>,
971}
972
973#[derive(Debug, Clone, PartialEq, Eq, Hash)]
974struct ColdCacheKey {
975    path: String,
976    block_index: u64,
977}
978
979#[derive(Debug)]
980struct ColdCacheEntry {
981    bytes: Bytes,
982    generation: u64,
983}
984
985#[derive(Debug, Default)]
986struct StreamReadState {
987    next_offset: u64,
988    sequential_score: usize,
989}
990
991impl ColdReadCache {
992    fn from_env() -> Option<Self> {
993        let max_bytes = env_usize("URSULA_COLD_CACHE_BYTES").unwrap_or(DEFAULT_COLD_CACHE_BYTES);
994        if max_bytes == 0 {
995            return None;
996        }
997        let block_bytes =
998            env_usize("URSULA_COLD_CACHE_BLOCK_BYTES").unwrap_or(DEFAULT_COLD_CACHE_BLOCK_BYTES);
999        let max_readahead_blocks = env_usize("URSULA_COLD_CACHE_READAHEAD_BLOCKS")
1000            .unwrap_or(DEFAULT_COLD_CACHE_READAHEAD_BLOCKS);
1001        Some(Self::new(ColdReadCacheConfig {
1002            max_bytes,
1003            block_bytes,
1004            max_readahead_blocks,
1005        }))
1006    }
1007
1008    fn new(config: ColdReadCacheConfig) -> Self {
1009        let block_bytes = config.block_bytes.max(1);
1010        Self {
1011            config: ColdReadCacheConfig {
1012                max_bytes: config.max_bytes,
1013                block_bytes,
1014                max_readahead_blocks: config.max_readahead_blocks,
1015            },
1016            inner: Mutex::new(ColdReadCacheInner::default()),
1017        }
1018    }
1019
1020    fn block_size(&self) -> u64 {
1021        u64::try_from(self.config.block_bytes.max(1)).expect("cache block size fits u64")
1022    }
1023
1024    fn get(&self, path: &str, block_index: u64) -> Option<Bytes> {
1025        let mut inner = self.inner.lock().expect("cold cache mutex poisoned");
1026        let key = ColdCacheKey {
1027            path: path.to_owned(),
1028            block_index,
1029        };
1030        let bytes = inner.blocks.get(&key)?.bytes.clone();
1031        Self::touch(&mut inner, key);
1032        Some(bytes)
1033    }
1034
1035    fn insert(&self, path: String, block_index: u64, bytes: Bytes) {
1036        if bytes.len() > self.config.max_bytes || self.config.max_bytes == 0 {
1037            return;
1038        }
1039        let mut inner = self.inner.lock().expect("cold cache mutex poisoned");
1040        let key = ColdCacheKey { path, block_index };
1041        if let Some(previous) = inner.blocks.remove(&key) {
1042            inner.current_bytes = inner.current_bytes.saturating_sub(previous.bytes.len());
1043        }
1044        let generation = Self::next_generation(&mut inner);
1045        inner.current_bytes = inner.current_bytes.saturating_add(bytes.len());
1046        inner
1047            .blocks
1048            .insert(key.clone(), ColdCacheEntry { bytes, generation });
1049        inner.lru.push_back((key, generation));
1050        self.evict_locked(&mut inner);
1051        Self::compact_lru_if_needed(&mut inner);
1052    }
1053
1054    fn record_stream_read(
1055        &self,
1056        stream_id: &BucketStreamId,
1057        read_start_offset: u64,
1058        len: usize,
1059    ) -> usize {
1060        let mut inner = self.inner.lock().expect("cold cache mutex poisoned");
1061        let state = inner.readers.entry(stream_id.clone()).or_default();
1062        if read_start_offset == state.next_offset {
1063            state.sequential_score = state
1064                .sequential_score
1065                .saturating_add(1)
1066                .min(self.config.max_readahead_blocks);
1067        } else {
1068            state.sequential_score = 0;
1069        }
1070        state.next_offset =
1071            read_start_offset.saturating_add(u64::try_from(len).unwrap_or(u64::MAX));
1072        state.sequential_score.min(self.config.max_readahead_blocks)
1073    }
1074
1075    fn invalidate_path(&self, path: &str) {
1076        let mut inner = self.inner.lock().expect("cold cache mutex poisoned");
1077        let keys = inner
1078            .blocks
1079            .keys()
1080            .filter(|key| key.path == path)
1081            .cloned()
1082            .collect::<Vec<_>>();
1083        for key in keys {
1084            if let Some(entry) = inner.blocks.remove(&key) {
1085                inner.current_bytes = inner.current_bytes.saturating_sub(entry.bytes.len());
1086            }
1087        }
1088    }
1089
1090    fn invalidate_prefix(&self, prefix: &str) {
1091        let mut inner = self.inner.lock().expect("cold cache mutex poisoned");
1092        let keys = inner
1093            .blocks
1094            .keys()
1095            .filter(|key| key.path.starts_with(prefix))
1096            .cloned()
1097            .collect::<Vec<_>>();
1098        for key in keys {
1099            if let Some(entry) = inner.blocks.remove(&key) {
1100                inner.current_bytes = inner.current_bytes.saturating_sub(entry.bytes.len());
1101            }
1102        }
1103    }
1104
1105    #[cfg(test)]
1106    fn block_count(&self) -> usize {
1107        self.inner
1108            .lock()
1109            .expect("cold cache mutex poisoned")
1110            .blocks
1111            .len()
1112    }
1113
1114    fn touch(inner: &mut ColdReadCacheInner, key: ColdCacheKey) {
1115        let generation = Self::next_generation(inner);
1116        if let Some(entry) = inner.blocks.get_mut(&key) {
1117            entry.generation = generation;
1118        }
1119        inner.lru.push_back((key, generation));
1120        Self::compact_lru_if_needed(inner);
1121    }
1122
1123    fn compact_lru_if_needed(inner: &mut ColdReadCacheInner) {
1124        // `touch` appends a fresh (key, generation) on every hit without removing
1125        // the stale prior entry, and `evict_locked` only reclaims those when the
1126        // cache is over `max_bytes`. With a working set at or below the cap but
1127        // repeated hits, the deque would otherwise grow without bound. Rebuild it
1128        // from the live blocks once it bloats past 2x the live entry count —
1129        // amortized O(1) per touch, since each rebuild shrinks it back to
1130        // `blocks.len()` so the next rebuild is `blocks.len()` touches away.
1131        if inner.lru.len() <= inner.blocks.len() * 2 + 16 {
1132            return;
1133        }
1134        let mut live: Vec<(u64, ColdCacheKey)> = inner
1135            .blocks
1136            .iter()
1137            .map(|(key, entry)| (entry.generation, key.clone()))
1138            .collect();
1139        live.sort_unstable_by_key(|(generation, _)| *generation);
1140        inner.lru = live
1141            .into_iter()
1142            .map(|(generation, key)| (key, generation))
1143            .collect();
1144    }
1145
1146    fn next_generation(inner: &mut ColdReadCacheInner) -> u64 {
1147        inner.generation = inner.generation.wrapping_add(1);
1148        inner.generation
1149    }
1150
1151    fn evict_locked(&self, inner: &mut ColdReadCacheInner) {
1152        while inner.current_bytes > self.config.max_bytes {
1153            let Some((key, generation)) = inner.lru.pop_front() else {
1154                break;
1155            };
1156            let Some(entry) = inner.blocks.get(&key) else {
1157                continue;
1158            };
1159            if entry.generation != generation {
1160                continue;
1161            }
1162            let entry = inner
1163                .blocks
1164                .remove(&key)
1165                .expect("cache entry exists after lookup");
1166            inner.current_bytes = inner.current_bytes.saturating_sub(entry.bytes.len());
1167        }
1168    }
1169}
1170
1171fn env_usize(name: &str) -> Option<usize> {
1172    let value = std::env::var(name).ok()?;
1173    value.parse::<usize>().ok()
1174}
1175
1176fn cold_store_io_error(path: &str, err: opendal::Error) -> io::Error {
1177    io::Error::other(format!("cold object '{path}': {err}"))
1178}
1179
1180#[cfg(not(madsim))]
1181fn cold_object_unix_nanos() -> u128 {
1182    SystemTime::now()
1183        .duration_since(UNIX_EPOCH)
1184        .map(|duration| duration.as_nanos())
1185        .unwrap_or(0)
1186}
1187
1188#[cfg(madsim)]
1189fn cold_object_unix_nanos() -> u128 {
1190    0
1191}
1192
1193pub fn new_cold_chunk_path(
1194    stream_id: &BucketStreamId,
1195    start_offset: u64,
1196    end_offset: u64,
1197) -> String {
1198    let unix_nanos = cold_object_unix_nanos();
1199    let sequence = COLD_CHUNK_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1200    format!(
1201        "{stream_id}/chunks/{start_offset:016x}-{end_offset:016x}-{unix_nanos:032x}-{sequence:016x}.bin"
1202    )
1203}
1204
1205/// The prefix under which all of a stream's cold chunks live. Cold objects are
1206/// stream-exclusive, so removing this prefix reclaims every chunk for a fully
1207/// deleted stream in one sweep. Mirrors the layout of [`new_cold_chunk_path`].
1208pub fn cold_chunk_prefix(stream_id: &BucketStreamId) -> String {
1209    format!("{stream_id}/chunks/")
1210}
1211
1212pub fn new_external_payload_path(stream_id: &BucketStreamId) -> String {
1213    let unix_nanos = cold_object_unix_nanos();
1214    let sequence = COLD_CHUNK_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1215    format!("{stream_id}/external/{unix_nanos:032x}-{sequence:016x}.bin")
1216}
1217
1218/// Reset the global cold-object sequence counter. Only available under
1219/// `cfg(madsim)` so the simulator can clear state between scenarios when
1220/// running multiple seeds in one process (e.g. `Runtime::check_determinism`).
1221#[cfg(madsim)]
1222#[allow(dead_code)]
1223pub fn reset_cold_chunk_sequence_for_sim() {
1224    COLD_CHUNK_SEQUENCE.store(0, Ordering::Relaxed);
1225}
1226
1227#[cfg(test)]
1228mod tests {
1229    use bytes::Bytes;
1230
1231    use super::ColdReadCache;
1232    use super::ColdReadCacheConfig;
1233    use super::ColdStore;
1234
1235    #[test]
1236    fn lru_queue_stays_bounded_under_repeated_hits() {
1237        // Working set fits entirely in the cache (4 blocks == max_bytes), so there
1238        // is no eviction pressure and the recency deque is the only thing that
1239        // could grow. Before compaction it grew by one entry per hit (~40k here);
1240        // it must stay bounded to the live set instead.
1241        let cache = ColdReadCache::new(ColdReadCacheConfig {
1242            max_bytes: 4 * 1024,
1243            block_bytes: 1024,
1244            max_readahead_blocks: 0,
1245        });
1246        for index in 0..4 {
1247            cache.insert("p".to_owned(), index, Bytes::from(vec![0u8; 1024]));
1248        }
1249        for _ in 0..10_000 {
1250            for index in 0..4 {
1251                assert!(cache.get("p", index).is_some());
1252            }
1253        }
1254        let inner = cache.inner.lock().expect("cache mutex");
1255        assert_eq!(inner.blocks.len(), 4, "live blocks unchanged");
1256        assert!(
1257            inner.lru.len() <= inner.blocks.len() * 2 + 16,
1258            "lru deque grew unbounded: {} entries for {} live blocks",
1259            inner.lru.len(),
1260            inner.blocks.len(),
1261        );
1262    }
1263
1264    #[test]
1265    fn fs_backend_is_not_supported() {
1266        let err = ColdStore::from_backend("fs").expect_err("fs backend should be unsupported");
1267        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1268        assert_eq!(err.to_string(), "unsupported URSULA_COLD_BACKEND 'fs'");
1269    }
1270}