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 futures_util::TryStreamExt;
20use opendal::EntryMode;
21use opendal::Operator;
22use opendal::Scheme;
23use opendal::layers::RetryLayer;
24use opendal::layers::TimeoutLayer;
25use ursula_config::config::ColdBackend;
26use ursula_shard::BucketStreamId;
27use ursula_stream::ColdChunkRef;
28use ursula_stream::ObjectPayloadRef;
29
30use crate::ColdConfig;
31use crate::ColdIndexPageKey;
32
33pub(crate) const DEFAULT_CONTENT_TYPE: &str = "application/octet-stream";
34// Keep this global atomic isolated from unrelated statics. This does not remove
35// contention on the counter itself, but avoids accidental false sharing with
36// adjacent data without adding a per-core sequence scheme to this low-frequency
37// object-key path.
38static COLD_CHUNK_SEQUENCE: CachePadded<AtomicU64> = CachePadded::new(AtomicU64::new(0));
39
40/// Applies the configured server-side encryption to an S3 builder and returns
41/// the label recorded in [`ColdStoreInfo`]. Shared by the cold store and the
42/// S3 snapshot store so backup objects inherit the same posture (#149).
43pub(crate) fn apply_s3_encryption(
44    builder: opendal::services::S3,
45    s3: &ursula_config::S3Config,
46) -> io::Result<(opendal::services::S3, &'static str)> {
47    use ursula_config::S3ServerSideEncryption;
48    match s3.server_side_encryption {
49        S3ServerSideEncryption::Aes256 => {
50            if s3.kms_key_id.is_some() {
51                return Err(io::Error::new(
52                    io::ErrorKind::InvalidInput,
53                    "s3.kms_key_id requires server_side_encryption = \"aws-kms\"",
54                ));
55            }
56            Ok((builder.server_side_encryption_with_s3_key(), "aes256"))
57        }
58        S3ServerSideEncryption::AwsKms => match s3.kms_key_id.as_deref() {
59            Some(key_id) if !key_id.trim().is_empty() => Ok((
60                builder.server_side_encryption_with_customer_managed_kms_key(key_id),
61                "aws-kms",
62            )),
63            _ => Ok((
64                builder.server_side_encryption_with_aws_managed_kms_key(),
65                "aws-kms",
66            )),
67        },
68        S3ServerSideEncryption::None => {
69            if s3.kms_key_id.is_some() {
70                return Err(io::Error::new(
71                    io::ErrorKind::InvalidInput,
72                    "s3.kms_key_id requires server_side_encryption = \"aws-kms\"",
73                ));
74            }
75            Ok((builder, "none"))
76        }
77    }
78}
79
80/// Wrap an S3 (opendal) operator with timeout and bounded-retry layers.
81///
82/// 1. **Per-attempt timeout** ([`TimeoutLayer`], inner): a blackholed endpoint
83///    — chaos `s3_unavailable`, or a "busy ESTAB" TCP socket whose future never
84///    gets polled again — otherwise hangs the caller until
85///    `net.ipv4.tcp_retries2` (~15 min). That is the original freeze: the raft
86///    state-machine worker awaits S3 inside `install_snapshot` (`&mut self`),
87///    and openraft type-level-serializes `apply` with it, so an unbounded S3
88///    stall freezes apply. Bounding every attempt keeps the worker progressing.
89/// 2. **Bounded retries** ([`RetryLayer`], outer): S3 answers `503 SlowDown`
90///    while a fresh key prefix warms up (and on transient network blips). These
91///    are `is_temporary()` errors; without retries a single 503 fails a
92///    snapshot upload/download, stalling a restarted node's rejoin/catch-up.
93///    Retries are bounded, so a sustained outage still fails fast enough (each
94///    attempt is timeout-bounded) and the cluster keeps progressing on quorum.
95pub(crate) fn with_s3_resilience(
96    operator: Operator,
97    timeout: Duration,
98    max_retries: usize,
99) -> Operator {
100    operator
101        .layer(
102            TimeoutLayer::new()
103                .with_timeout(timeout)
104                .with_io_timeout(timeout),
105        )
106        .layer(RetryLayer::new().with_max_times(max_retries).with_jitter())
107}
108
109#[derive(Clone)]
110pub struct ColdStore {
111    info: ColdStoreInfo,
112    operator: Operator,
113    read_cache: Option<Arc<ColdReadCache>>,
114    observer: Arc<Mutex<Option<ColdStoreObserver>>>,
115    fault_policy: Arc<Mutex<Option<ColdStoreFaultPolicy>>>,
116    delay_fn: Arc<Mutex<ColdStoreDelayFn>>,
117}
118
119pub type ColdStoreHandle = Arc<ColdStore>;
120
121type ColdStoreObserver = Arc<dyn Fn(ColdStoreEvent) + Send + Sync>;
122type ColdStoreFaultPolicy =
123    Arc<dyn Fn(&ColdStoreFaultContext) -> Option<ColdStoreFaultEffect> + Send + Sync>;
124type ColdStoreDelayFuture = Pin<Box<dyn Future<Output = ()> + Send + 'static>>;
125type ColdStoreDelayFn = Arc<dyn Fn(Duration) -> ColdStoreDelayFuture + Send + Sync>;
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum ColdStoreOperation {
129    WriteChunk,
130    DeleteChunk,
131    RemoveAll,
132    ReadObjectRange,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct ColdStoreFaultContext {
137    pub operation: ColdStoreOperation,
138    pub stream_id: Option<BucketStreamId>,
139    pub path: String,
140    pub payload_len: Option<usize>,
141    pub read_start_offset: Option<u64>,
142    pub len: Option<usize>,
143    pub object_start: Option<u64>,
144    pub object_end: Option<u64>,
145    pub cached: Option<bool>,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct ColdStoreFault {
150    pub message: String,
151}
152
153impl ColdStoreFault {
154    pub fn new(message: impl Into<String>) -> Self {
155        Self {
156            message: message.into(),
157        }
158    }
159}
160
161#[derive(Debug, Clone, Default, PartialEq, Eq)]
162pub struct ColdStoreFaultEffect {
163    pub delay: Option<Duration>,
164    pub error: Option<ColdStoreFault>,
165    pub truncate_read_to: Option<usize>,
166}
167
168impl ColdStoreFaultEffect {
169    pub fn delay(duration: Duration) -> Self {
170        Self {
171            delay: Some(duration),
172            error: None,
173            truncate_read_to: None,
174        }
175    }
176
177    pub fn fail(message: impl Into<String>) -> Self {
178        Self {
179            delay: None,
180            error: Some(ColdStoreFault::new(message)),
181            truncate_read_to: None,
182        }
183    }
184
185    pub fn delay_then_fail(duration: Duration, message: impl Into<String>) -> Self {
186        Self {
187            delay: Some(duration),
188            error: Some(ColdStoreFault::new(message)),
189            truncate_read_to: None,
190        }
191    }
192
193    pub fn truncate_read_to(len: usize) -> Self {
194        Self {
195            delay: None,
196            error: None,
197            truncate_read_to: Some(len),
198        }
199    }
200}
201
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub enum ColdStoreEvent {
204    WriteChunkBegin {
205        path: String,
206        payload_len: usize,
207    },
208    WriteChunkComplete {
209        path: String,
210        object_size: u64,
211    },
212    DeleteChunkBegin {
213        path: String,
214    },
215    DeleteChunkComplete {
216        path: String,
217    },
218    RemoveAllBegin {
219        path: String,
220    },
221    RemoveAllComplete {
222        path: String,
223    },
224    ReadObjectRangeBegin {
225        stream_id: Option<BucketStreamId>,
226        path: String,
227        read_start_offset: u64,
228        len: usize,
229        object_start: u64,
230        object_end: u64,
231        cached: bool,
232    },
233    ReadObjectRangeComplete {
234        stream_id: Option<BucketStreamId>,
235        path: String,
236        read_start_offset: u64,
237        len: usize,
238        returned_len: usize,
239        cached: bool,
240    },
241    FaultInjected {
242        operation: ColdStoreOperation,
243        stream_id: Option<BucketStreamId>,
244        path: String,
245        message: String,
246    },
247    DelayInjected {
248        operation: ColdStoreOperation,
249        stream_id: Option<BucketStreamId>,
250        path: String,
251        delay_ms: u64,
252    },
253    TruncateInjected {
254        stream_id: Option<BucketStreamId>,
255        path: String,
256        requested_len: usize,
257        returned_len: usize,
258    },
259}
260
261impl fmt::Debug for ColdStore {
262    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263        f.debug_struct("ColdStore")
264            .field("info", &self.info)
265            .field("operator", &self.operator)
266            .field("read_cache", &self.read_cache)
267            .finish_non_exhaustive()
268    }
269}
270
271#[derive(Clone, Debug, PartialEq, Eq)]
272pub struct ColdStoreInfo {
273    pub backend: &'static str,
274    pub root: Option<String>,
275    pub bucket: Option<String>,
276    pub region: Option<String>,
277    pub endpoint: Option<String>,
278    /// Server-side encryption mode applied to object writes (S3 backend only).
279    pub encryption: Option<&'static str>,
280}
281
282impl ColdStore {
283    /// Lists the authoritative cold-index pages rather than every chunk. One
284    /// page covers 64 MiB of one stream, so this is the bounded discovery
285    /// surface used by the background chunk compactor.
286    pub async fn list_cold_index_pages(&self) -> io::Result<Vec<ColdIndexPageKey>> {
287        let mut lister = self
288            .operator
289            .lister_with("")
290            .recursive(true)
291            .await
292            .map_err(|err| cold_store_io_error("", err))?;
293        let mut pages = Vec::new();
294        while let Some(entry) = lister
295            .try_next()
296            .await
297            .map_err(|err| cold_store_io_error("", err))?
298        {
299            if entry.metadata().mode() != EntryMode::FILE {
300                continue;
301            }
302            if let Some(key) = parse_cold_index_page_path(entry.path()) {
303                pages.push(key);
304            }
305        }
306        pages.sort_by(|left, right| {
307            left.stream_id
308                .bucket_id
309                .cmp(&right.stream_id.bucket_id)
310                .then_with(|| {
311                    left.stream_id
312                        .affinity_key
313                        .cmp(&right.stream_id.affinity_key)
314                })
315                .then_with(|| left.stream_id.stream_id.cmp(&right.stream_id.stream_id))
316                .then_with(|| left.generation.cmp(&right.generation))
317                .then_with(|| left.page_id.cmp(&right.page_id))
318        });
319        pages.dedup();
320        Ok(pages)
321    }
322
323    pub fn memory() -> io::Result<Self> {
324        let operator = Operator::via_iter(Scheme::Memory, [])
325            .map_err(|err| io::Error::other(err.to_string()))?;
326        Ok(Self::from_operator(operator, ColdStoreInfo {
327            backend: "memory",
328            root: None,
329            bucket: None,
330            region: None,
331            endpoint: None,
332            encryption: None,
333        }))
334    }
335
336    /// Build a [`ColdStore`] from an explicit [`ColdConfig`].
337    ///
338    /// The bootstrap layer assembles the typed config before calling this method;
339    /// this method is purely functional — it does not touch `std::env`.
340    ///
341    /// Returns `Err` when the backend is [`ColdBackend::None`];
342    /// the caller should skip construction when cold storage is disabled.
343    pub fn try_new(config: &ColdConfig) -> io::Result<Self> {
344        let mut store = match config.backend {
345            ColdBackend::None => {
346                return Err(io::Error::new(
347                    io::ErrorKind::InvalidInput,
348                    "ColdStore::try_new called with backend=none; \
349                     the caller should skip construction when cold storage is disabled",
350                ));
351            }
352            ColdBackend::Memory => Self::memory()?,
353            ColdBackend::S3 => Self::s3_from_config(config)?,
354        };
355        let cache = config.cache.clone().unwrap_or_default();
356        if cache.max_size.as_bytes() > 0 {
357            let cache_params = ColdReadCacheParams {
358                max_bytes: cache.max_size.as_bytes() as usize,
359                block_bytes: cache.block_size.as_bytes() as usize,
360                max_readahead_blocks: cache.readahead_blocks,
361            };
362            store = store.with_read_cache(cache_params);
363        }
364        Ok(store)
365    }
366
367    fn s3_from_config(config: &ColdConfig) -> io::Result<Self> {
368        let s3 = config.s3.as_ref().ok_or_else(|| {
369            io::Error::new(
370                io::ErrorKind::InvalidInput,
371                "s3 configuration is required when cold backend is s3",
372            )
373        })?;
374        let bucket = s3.bucket.as_deref().ok_or_else(|| {
375            io::Error::new(
376                io::ErrorKind::InvalidInput,
377                "s3 bucket is required when cold backend is s3",
378            )
379        })?;
380        if bucket.trim().is_empty() {
381            return Err(io::Error::new(
382                io::ErrorKind::InvalidInput,
383                "s3 bucket must not be empty",
384            ));
385        }
386
387        let mut builder = opendal::services::S3::default().bucket(bucket);
388        let mut configured_root = None;
389        if let Some(root) = config.root.as_deref()
390            && !root.trim().is_empty()
391        {
392            builder = builder.root(root);
393            configured_root = Some(root.to_owned());
394        }
395        let mut configured_region = None;
396        if let Some(region) = s3.region.as_deref()
397            && !region.trim().is_empty()
398        {
399            builder = builder.region(region);
400            configured_region = Some(region.to_owned());
401        }
402        let mut configured_endpoint = None;
403        if let Some(endpoint) = s3.endpoint.as_deref()
404            && !endpoint.trim().is_empty()
405        {
406            builder = builder.endpoint(endpoint);
407            configured_endpoint = Some(endpoint.to_owned());
408        }
409        if let Some(access_key_id) = s3.access_key_id.as_deref()
410            && !access_key_id.trim().is_empty()
411        {
412            builder = builder.access_key_id(access_key_id);
413        }
414        if let Some(secret_access_key) = s3.secret_access_key.as_deref()
415            && !secret_access_key.trim().is_empty()
416        {
417            builder = builder.secret_access_key(secret_access_key);
418        }
419        if let Some(session_token) = s3.session_token.as_deref()
420            && !session_token.trim().is_empty()
421        {
422            builder = builder.session_token(session_token);
423        }
424        let (builder, encryption) = apply_s3_encryption(builder, s3)?;
425
426        Ok(Self::from_operator(
427            with_s3_resilience(
428                Operator::new(builder)
429                    .map_err(|err| io::Error::other(err.to_string()))?
430                    .finish(),
431                s3.timeout.as_duration(),
432                s3.max_retries,
433            ),
434            ColdStoreInfo {
435                backend: "s3",
436                root: configured_root,
437                bucket: Some(bucket.to_owned()),
438                region: configured_region,
439                endpoint: configured_endpoint,
440                encryption: Some(encryption),
441            },
442        ))
443    }
444
445    fn from_operator(operator: Operator, info: ColdStoreInfo) -> Self {
446        Self {
447            info,
448            operator,
449            read_cache: None,
450            observer: Arc::new(Mutex::new(None)),
451            fault_policy: Arc::new(Mutex::new(None)),
452            delay_fn: Arc::new(Mutex::new(default_cold_store_delay_fn())),
453        }
454    }
455
456    pub fn info(&self) -> &ColdStoreInfo {
457        &self.info
458    }
459
460    pub fn with_read_cache(mut self, config: ColdReadCacheParams) -> Self {
461        self.read_cache = Some(Arc::new(ColdReadCache::new(config)));
462        self
463    }
464
465    pub fn without_read_cache(mut self) -> Self {
466        self.read_cache = None;
467        self
468    }
469
470    pub fn set_observer(&self, observer: impl Fn(ColdStoreEvent) + Send + Sync + 'static) {
471        *self.observer.lock().expect("cold store observer mutex") = Some(Arc::new(observer));
472    }
473
474    pub fn set_fault_policy(
475        &self,
476        policy: impl Fn(&ColdStoreFaultContext) -> Option<ColdStoreFaultEffect> + Send + Sync + 'static,
477    ) {
478        *self
479            .fault_policy
480            .lock()
481            .expect("cold store fault policy mutex") = Some(Arc::new(policy));
482    }
483
484    pub fn clear_fault_policy(&self) {
485        *self
486            .fault_policy
487            .lock()
488            .expect("cold store fault policy mutex") = None;
489    }
490
491    pub fn set_delay_fn<F, Fut>(&self, delay_fn: F)
492    where
493        F: Fn(Duration) -> Fut + Send + Sync + 'static,
494        Fut: Future<Output = ()> + Send + 'static,
495    {
496        *self.delay_fn.lock().expect("cold store delay fn mutex") =
497            Arc::new(move |duration| Box::pin(delay_fn(duration)));
498    }
499
500    #[cfg(test)]
501    pub(crate) fn cached_block_count(&self) -> usize {
502        self.read_cache
503            .as_ref()
504            .map(|cache| cache.block_count())
505            .unwrap_or(0)
506    }
507
508    pub async fn write_chunk(&self, path: &str, payload: &[u8]) -> io::Result<u64> {
509        if path.trim().is_empty() {
510            return Err(io::Error::new(
511                io::ErrorKind::InvalidInput,
512                "cold chunk path must not be empty",
513            ));
514        }
515        self.notify(ColdStoreEvent::WriteChunkBegin {
516            path: path.to_owned(),
517            payload_len: payload.len(),
518        });
519        let _applied_fault = self
520            .maybe_apply_fault_effect(ColdStoreFaultContext {
521                operation: ColdStoreOperation::WriteChunk,
522                stream_id: None,
523                path: path.to_owned(),
524                payload_len: Some(payload.len()),
525                read_start_offset: None,
526                len: None,
527                object_start: None,
528                object_end: None,
529                cached: None,
530            })
531            .await?;
532        self.operator
533            .write(path, payload.to_vec())
534            .await
535            .map_err(|err| cold_store_io_error(path, err))?;
536        let object_size = u64::try_from(payload.len()).expect("payload len fits u64");
537        self.notify(ColdStoreEvent::WriteChunkComplete {
538            path: path.to_owned(),
539            object_size,
540        });
541        Ok(object_size)
542    }
543
544    pub(crate) async fn write_cold_index_page(
545        &self,
546        path: &str,
547        payload: &[u8],
548    ) -> io::Result<u64> {
549        if path.trim().is_empty() {
550            return Err(io::Error::new(
551                io::ErrorKind::InvalidInput,
552                "cold index page path must not be empty",
553            ));
554        }
555        self.operator
556            .write(path, payload.to_vec())
557            .await
558            .map_err(|err| cold_store_io_error(path, err))?;
559        Ok(u64::try_from(payload.len()).expect("payload len fits u64"))
560    }
561
562    #[tracing::instrument(name = "cold.read_index", level = "debug", skip_all)]
563    pub(crate) async fn read_cold_index_page(&self, path: &str) -> io::Result<Option<Vec<u8>>> {
564        if path.trim().is_empty() {
565            return Err(io::Error::new(
566                io::ErrorKind::InvalidInput,
567                "cold index page path must not be empty",
568            ));
569        }
570        match self.operator.read(path).await {
571            Ok(bytes) => Ok(Some(bytes.to_bytes().to_vec())),
572            Err(err) if err.kind() == opendal::ErrorKind::NotFound => Ok(None),
573            Err(err) => Err(cold_store_io_error(path, err)),
574        }
575    }
576
577    pub async fn delete_chunk(&self, path: &str) -> io::Result<()> {
578        if path.trim().is_empty() {
579            return Err(io::Error::new(
580                io::ErrorKind::InvalidInput,
581                "cold chunk path must not be empty",
582            ));
583        }
584        self.notify(ColdStoreEvent::DeleteChunkBegin {
585            path: path.to_owned(),
586        });
587        let _applied_fault = self
588            .maybe_apply_fault_effect(ColdStoreFaultContext {
589                operation: ColdStoreOperation::DeleteChunk,
590                stream_id: None,
591                path: path.to_owned(),
592                payload_len: None,
593                read_start_offset: None,
594                len: None,
595                object_start: None,
596                object_end: None,
597                cached: None,
598            })
599            .await?;
600        self.operator
601            .delete(path)
602            .await
603            .map_err(|err| cold_store_io_error(path, err))?;
604        if let Some(cache) = &self.read_cache {
605            cache.invalidate_path(path);
606        }
607        self.notify(ColdStoreEvent::DeleteChunkComplete {
608            path: path.to_owned(),
609        });
610        Ok(())
611    }
612
613    pub async fn remove_all(&self, path: &str) -> io::Result<()> {
614        self.notify(ColdStoreEvent::RemoveAllBegin {
615            path: path.to_owned(),
616        });
617        let _applied_fault = self
618            .maybe_apply_fault_effect(ColdStoreFaultContext {
619                operation: ColdStoreOperation::RemoveAll,
620                stream_id: None,
621                path: path.to_owned(),
622                payload_len: None,
623                read_start_offset: None,
624                len: None,
625                object_start: None,
626                object_end: None,
627                cached: None,
628            })
629            .await?;
630        self.operator
631            .remove_all(path)
632            .await
633            .map_err(|err| cold_store_io_error(path, err))?;
634        if let Some(cache) = &self.read_cache {
635            cache.invalidate_prefix(path);
636        }
637        self.notify(ColdStoreEvent::RemoveAllComplete {
638            path: path.to_owned(),
639        });
640        Ok(())
641    }
642
643    /// Proves that the object store has no file below `path`. Tenant purge
644    /// uses this after recursive deletion; a successful delete request alone
645    /// is not physical-absence evidence.
646    pub async fn prefix_is_empty(&self, path: &str) -> io::Result<bool> {
647        let mut lister = self
648            .operator
649            .lister_with(path)
650            .recursive(true)
651            .await
652            .map_err(|err| cold_store_io_error(path, err))?;
653        while let Some(entry) = lister
654            .try_next()
655            .await
656            .map_err(|err| cold_store_io_error(path, err))?
657        {
658            if entry.metadata().mode() == EntryMode::FILE {
659                return Ok(false);
660            }
661        }
662        Ok(true)
663    }
664
665    pub async fn read_chunk_range(
666        &self,
667        chunk: &ColdChunkRef,
668        read_start_offset: u64,
669        len: usize,
670    ) -> io::Result<Vec<u8>> {
671        let object = ObjectPayloadRef {
672            start_offset: chunk.start_offset,
673            end_offset: chunk.end_offset,
674            s3_path: chunk.s3_path.clone(),
675            object_size: chunk.object_size,
676            object_offset: chunk.object_offset,
677        };
678        self.read_object_range(&object, read_start_offset, len)
679            .await
680    }
681
682    pub async fn read_object_range_for_stream(
683        &self,
684        stream_id: &BucketStreamId,
685        object: &ObjectPayloadRef,
686        read_start_offset: u64,
687        len: usize,
688    ) -> io::Result<Vec<u8>> {
689        self.read_object_range_inner(Some(stream_id), object, read_start_offset, len)
690            .await
691    }
692
693    pub async fn read_object_range(
694        &self,
695        object: &ObjectPayloadRef,
696        read_start_offset: u64,
697        len: usize,
698    ) -> io::Result<Vec<u8>> {
699        self.read_object_range_inner(None, object, read_start_offset, len)
700            .await
701    }
702
703    #[tracing::instrument(
704        name = "cold.read_chunk",
705        level = "debug",
706        skip_all,
707        fields(
708            start_offset = object.start_offset,
709            end_offset = object.end_offset,
710            object_size = object.object_size,
711            len = len,
712        ),
713    )]
714    async fn read_object_range_inner(
715        &self,
716        stream_id: Option<&BucketStreamId>,
717        object: &ObjectPayloadRef,
718        read_start_offset: u64,
719        len: usize,
720    ) -> io::Result<Vec<u8>> {
721        if len == 0 {
722            return Ok(Vec::new());
723        }
724        let len_u64 = u64::try_from(len).map_err(|_| {
725            io::Error::new(io::ErrorKind::InvalidInput, "cold read length exceeds u64")
726        })?;
727        let read_end = read_start_offset.checked_add(len_u64).ok_or_else(|| {
728            io::Error::new(io::ErrorKind::InvalidInput, "cold read range overflow")
729        })?;
730        if read_start_offset < object.start_offset || read_end > object.end_offset {
731            return Err(io::Error::new(
732                io::ErrorKind::InvalidInput,
733                format!(
734                    "cold read range [{read_start_offset}..{read_end}) is outside object segment [{}..{})",
735                    object.start_offset, object.end_offset
736                ),
737            ));
738        }
739        let object_start = object
740            .object_offset
741            .checked_add(read_start_offset - object.start_offset)
742            .ok_or_else(|| {
743                io::Error::new(io::ErrorKind::InvalidInput, "cold read range overflow")
744            })?;
745        let object_end = object_start.checked_add(len_u64).ok_or_else(|| {
746            io::Error::new(io::ErrorKind::InvalidInput, "cold read range overflow")
747        })?;
748        if object_end > object.object_size {
749            return Err(io::Error::new(
750                io::ErrorKind::InvalidData,
751                format!(
752                    "cold read range [{object_start}..{object_end}) is outside object '{}' size {}",
753                    object.s3_path, object.object_size
754                ),
755            ));
756        }
757        let cached = self.read_cache.is_some();
758        self.notify(ColdStoreEvent::ReadObjectRangeBegin {
759            stream_id: stream_id.cloned(),
760            path: object.s3_path.clone(),
761            read_start_offset,
762            len,
763            object_start,
764            object_end,
765            cached,
766        });
767        let applied_fault = self
768            .maybe_apply_fault_effect(ColdStoreFaultContext {
769                operation: ColdStoreOperation::ReadObjectRange,
770                stream_id: stream_id.cloned(),
771                path: object.s3_path.clone(),
772                payload_len: None,
773                read_start_offset: Some(read_start_offset),
774                len: Some(len),
775                object_start: Some(object_start),
776                object_end: Some(object_end),
777                cached: Some(cached),
778            })
779            .await?;
780        let mut bytes = if let Some(cache) = &self.read_cache {
781            let bytes = self
782                .read_object_range_cached(cache, object, object_start, object_end, len)
783                .await?;
784            if let Some(stream_id) = stream_id {
785                let readahead_blocks = cache.record_stream_read(stream_id, read_start_offset, len);
786                if readahead_blocks > 0 {
787                    self.spawn_readahead(object.clone(), object_end, readahead_blocks);
788                }
789            }
790            bytes
791        } else {
792            self.read_object_range_uncached(object, object_start, object_end, len)
793                .await?
794        };
795        if let Some(returned_len) = applied_fault.truncate_read_to {
796            let returned_len = returned_len.min(bytes.len());
797            bytes.truncate(returned_len);
798            self.notify(ColdStoreEvent::TruncateInjected {
799                stream_id: stream_id.cloned(),
800                path: object.s3_path.clone(),
801                requested_len: len,
802                returned_len,
803            });
804        }
805        if bytes.len() != len {
806            return Err(io::Error::new(
807                io::ErrorKind::InvalidData,
808                format!(
809                    "cold object '{}' returned {} bytes for requested range [{}..{})",
810                    object.s3_path,
811                    bytes.len(),
812                    object_start,
813                    object_end
814                ),
815            ));
816        }
817        self.notify(ColdStoreEvent::ReadObjectRangeComplete {
818            stream_id: stream_id.cloned(),
819            path: object.s3_path.clone(),
820            read_start_offset,
821            len,
822            returned_len: bytes.len(),
823            cached,
824        });
825        Ok(bytes)
826    }
827
828    async fn read_object_range_uncached(
829        &self,
830        object: &ObjectPayloadRef,
831        object_start: u64,
832        object_end: u64,
833        len: usize,
834    ) -> io::Result<Vec<u8>> {
835        let bytes = self
836            .operator
837            .read_with(&object.s3_path)
838            .range(object_start..object_end)
839            .await
840            .map_err(|err| cold_store_io_error(&object.s3_path, err))?
841            .to_bytes();
842        if bytes.len() != len {
843            return Err(io::Error::new(
844                io::ErrorKind::InvalidData,
845                format!(
846                    "cold object '{}' returned {} bytes for requested range [{}..{})",
847                    object.s3_path,
848                    bytes.len(),
849                    object_start,
850                    object_end
851                ),
852            ));
853        }
854        Ok(bytes.to_vec())
855    }
856
857    async fn read_object_range_cached(
858        &self,
859        cache: &ColdReadCache,
860        object: &ObjectPayloadRef,
861        object_start: u64,
862        object_end: u64,
863        len: usize,
864    ) -> io::Result<Vec<u8>> {
865        let mut payload = Vec::with_capacity(len);
866        let block_size = cache.block_size();
867        let first_block = object_start / block_size;
868        let last_block = (object_end - 1) / block_size;
869        for block_index in first_block..=last_block {
870            let block_start = block_index * block_size;
871            let block_end = block_start
872                .saturating_add(block_size)
873                .min(object.object_size);
874            let block = self
875                .read_cached_block(
876                    cache,
877                    object.s3_path.clone(),
878                    object.object_size,
879                    block_index,
880                    block_start,
881                    block_end,
882                )
883                .await?;
884            let slice_start = usize::try_from(object_start.max(block_start) - block_start)
885                .expect("cache slice start fits usize");
886            let slice_end = usize::try_from(object_end.min(block_end) - block_start)
887                .expect("cache slice end fits usize");
888            payload.extend_from_slice(&block.slice(slice_start..slice_end));
889        }
890        if payload.len() != len {
891            return Err(io::Error::new(
892                io::ErrorKind::InvalidData,
893                format!(
894                    "cold object '{}' returned {} bytes for requested range [{}..{})",
895                    object.s3_path,
896                    payload.len(),
897                    object_start,
898                    object_end
899                ),
900            ));
901        }
902        Ok(payload)
903    }
904
905    async fn read_cached_block(
906        &self,
907        cache: &ColdReadCache,
908        path: String,
909        object_size: u64,
910        block_index: u64,
911        block_start: u64,
912        block_end: u64,
913    ) -> io::Result<Bytes> {
914        if let Some(bytes) = cache.get(&path, block_index) {
915            return Ok(bytes);
916        }
917        let bytes = self
918            .operator
919            .read_with(&path)
920            .range(block_start..block_end)
921            .await
922            .map_err(|err| cold_store_io_error(&path, err))?
923            .to_bytes();
924        let expected_len = usize::try_from(block_end - block_start).map_err(|_| {
925            io::Error::new(
926                io::ErrorKind::InvalidData,
927                "cold cache block length exceeds usize",
928            )
929        })?;
930        if bytes.len() != expected_len {
931            return Err(io::Error::new(
932                io::ErrorKind::InvalidData,
933                format!(
934                    "cold object '{path}' returned {} bytes for cache block [{}..{}) of object size {object_size}",
935                    bytes.len(),
936                    block_start,
937                    block_end
938                ),
939            ));
940        }
941        cache.insert(path, block_index, bytes.clone());
942        Ok(bytes)
943    }
944
945    fn spawn_readahead(&self, object: ObjectPayloadRef, object_end: u64, readahead_blocks: usize) {
946        let Some(cache) = self.read_cache.clone() else {
947            return;
948        };
949        let block_size = cache.block_size();
950        let mut block_index = object_end.div_ceil(block_size);
951        let store = self.clone();
952        crate::rt::spawn(async move {
953            for _ in 0..readahead_blocks {
954                let block_start = block_index * block_size;
955                if block_start >= object.object_size {
956                    break;
957                }
958                let block_end = block_start
959                    .saturating_add(block_size)
960                    .min(object.object_size);
961                if cache.get(&object.s3_path, block_index).is_none() {
962                    let _ = store
963                        .read_cached_block(
964                            &cache,
965                            object.s3_path.clone(),
966                            object.object_size,
967                            block_index,
968                            block_start,
969                            block_end,
970                        )
971                        .await;
972                }
973                block_index += 1;
974            }
975        });
976    }
977
978    fn notify(&self, event: ColdStoreEvent) {
979        let observer = self
980            .observer
981            .lock()
982            .expect("cold store observer mutex")
983            .clone();
984        if let Some(observer) = observer {
985            observer(event);
986        }
987    }
988
989    async fn maybe_apply_fault_effect(
990        &self,
991        context: ColdStoreFaultContext,
992    ) -> io::Result<ColdStoreAppliedFault> {
993        let policy = self
994            .fault_policy
995            .lock()
996            .expect("cold store fault policy mutex")
997            .clone();
998        let Some(policy) = policy else {
999            return Ok(ColdStoreAppliedFault::default());
1000        };
1001        let Some(effect) = policy(&context) else {
1002            return Ok(ColdStoreAppliedFault::default());
1003        };
1004        if let Some(delay) = effect.delay {
1005            self.notify(ColdStoreEvent::DelayInjected {
1006                operation: context.operation,
1007                stream_id: context.stream_id.clone(),
1008                path: context.path.clone(),
1009                delay_ms: duration_ms(delay),
1010            });
1011            let delay_fn = self
1012                .delay_fn
1013                .lock()
1014                .expect("cold store delay fn mutex")
1015                .clone();
1016            delay_fn(delay).await;
1017        }
1018        if let Some(fault) = effect.error {
1019            self.notify(ColdStoreEvent::FaultInjected {
1020                operation: context.operation,
1021                stream_id: context.stream_id,
1022                path: context.path.clone(),
1023                message: fault.message.clone(),
1024            });
1025            return Err(io::Error::other(format!(
1026                "cold store fault injected for {} '{}': {}",
1027                context.operation.as_str(),
1028                context.path,
1029                fault.message
1030            )));
1031        }
1032        Ok(ColdStoreAppliedFault {
1033            truncate_read_to: effect.truncate_read_to,
1034        })
1035    }
1036}
1037
1038fn parse_cold_index_page_path(path: &str) -> Option<ColdIndexPageKey> {
1039    let parts = path.split('/').collect::<Vec<_>>();
1040    let (stream_id, generation, page_id) = match parts.as_slice() {
1041        [bucket, stream, "cold-index", generation, page] => {
1042            (BucketStreamId::new(*bucket, *stream), *generation, *page)
1043        }
1044        [bucket, affinity, stream, "cold-index", generation, page] => (
1045            BucketStreamId::with_affinity(*bucket, *affinity, *stream),
1046            *generation,
1047            *page,
1048        ),
1049        _ => return None,
1050    };
1051    if stream_id.bucket_id.is_empty()
1052        || stream_id
1053            .affinity_key
1054            .as_ref()
1055            .is_some_and(String::is_empty)
1056        || stream_id.stream_id.is_empty()
1057    {
1058        return None;
1059    }
1060    Some(ColdIndexPageKey {
1061        stream_id,
1062        generation: generation.parse().ok()?,
1063        page_id: page_id.strip_suffix(".idx")?.parse().ok()?,
1064    })
1065}
1066
1067#[derive(Debug, Default)]
1068struct ColdStoreAppliedFault {
1069    truncate_read_to: Option<usize>,
1070}
1071
1072impl ColdStoreOperation {
1073    fn as_str(self) -> &'static str {
1074        match self {
1075            Self::WriteChunk => "write_chunk",
1076            Self::DeleteChunk => "delete_chunk",
1077            Self::RemoveAll => "remove_all",
1078            Self::ReadObjectRange => "read_object_range",
1079        }
1080    }
1081}
1082
1083fn default_cold_store_delay_fn() -> ColdStoreDelayFn {
1084    Arc::new(|duration| Box::pin(crate::rt::time::sleep(duration)))
1085}
1086
1087fn duration_ms(duration: Duration) -> u64 {
1088    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
1089}
1090
1091/// Runtime parameters for the optional cold-read cache.
1092///
1093/// Kept separate from the serde [`ursula_config::ColdCacheConfig`] because
1094/// `ColdReadCache` accesses these fields on the hot path.
1095#[derive(Debug, Clone, Copy)]
1096pub struct ColdReadCacheParams {
1097    pub max_bytes: usize,
1098    pub block_bytes: usize,
1099    pub max_readahead_blocks: usize,
1100}
1101
1102#[derive(Debug)]
1103struct ColdReadCache {
1104    config: ColdReadCacheParams,
1105    inner: Mutex<ColdReadCacheInner>,
1106}
1107
1108#[derive(Debug, Default)]
1109struct ColdReadCacheInner {
1110    blocks: HashMap<ColdCacheKey, ColdCacheEntry>,
1111    lru: VecDeque<(ColdCacheKey, u64)>,
1112    current_bytes: usize,
1113    generation: u64,
1114    readers: HashMap<BucketStreamId, StreamReadState>,
1115}
1116
1117#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1118struct ColdCacheKey {
1119    path: String,
1120    block_index: u64,
1121}
1122
1123#[derive(Debug)]
1124struct ColdCacheEntry {
1125    bytes: Bytes,
1126    generation: u64,
1127}
1128
1129#[derive(Debug, Default)]
1130struct StreamReadState {
1131    next_offset: u64,
1132    sequential_score: usize,
1133}
1134
1135impl ColdReadCache {
1136    fn new(config: ColdReadCacheParams) -> Self {
1137        let block_bytes = config.block_bytes.max(1);
1138        Self {
1139            config: ColdReadCacheParams {
1140                max_bytes: config.max_bytes,
1141                block_bytes,
1142                max_readahead_blocks: config.max_readahead_blocks,
1143            },
1144            inner: Mutex::new(ColdReadCacheInner::default()),
1145        }
1146    }
1147
1148    fn block_size(&self) -> u64 {
1149        u64::try_from(self.config.block_bytes.max(1)).expect("cache block size fits u64")
1150    }
1151
1152    fn get(&self, path: &str, block_index: u64) -> Option<Bytes> {
1153        let mut inner = self.inner.lock().expect("cold cache mutex poisoned");
1154        let key = ColdCacheKey {
1155            path: path.to_owned(),
1156            block_index,
1157        };
1158        let bytes = inner.blocks.get(&key)?.bytes.clone();
1159        Self::touch(&mut inner, key);
1160        Some(bytes)
1161    }
1162
1163    fn insert(&self, path: String, block_index: u64, bytes: Bytes) {
1164        if bytes.len() > self.config.max_bytes || self.config.max_bytes == 0 {
1165            return;
1166        }
1167        let mut inner = self.inner.lock().expect("cold cache mutex poisoned");
1168        let key = ColdCacheKey { path, block_index };
1169        if let Some(previous) = inner.blocks.remove(&key) {
1170            inner.current_bytes = inner.current_bytes.saturating_sub(previous.bytes.len());
1171        }
1172        let generation = Self::next_generation(&mut inner);
1173        inner.current_bytes = inner.current_bytes.saturating_add(bytes.len());
1174        inner
1175            .blocks
1176            .insert(key.clone(), ColdCacheEntry { bytes, generation });
1177        inner.lru.push_back((key, generation));
1178        self.evict_locked(&mut inner);
1179        Self::compact_lru_if_needed(&mut inner);
1180    }
1181
1182    fn record_stream_read(
1183        &self,
1184        stream_id: &BucketStreamId,
1185        read_start_offset: u64,
1186        len: usize,
1187    ) -> usize {
1188        let mut inner = self.inner.lock().expect("cold cache mutex poisoned");
1189        let state = inner.readers.entry(stream_id.clone()).or_default();
1190        if read_start_offset == state.next_offset {
1191            state.sequential_score = state
1192                .sequential_score
1193                .saturating_add(1)
1194                .min(self.config.max_readahead_blocks);
1195        } else {
1196            state.sequential_score = 0;
1197        }
1198        state.next_offset =
1199            read_start_offset.saturating_add(u64::try_from(len).unwrap_or(u64::MAX));
1200        state.sequential_score.min(self.config.max_readahead_blocks)
1201    }
1202
1203    fn invalidate_path(&self, path: &str) {
1204        let mut inner = self.inner.lock().expect("cold cache mutex poisoned");
1205        let keys = inner
1206            .blocks
1207            .keys()
1208            .filter(|key| key.path == path)
1209            .cloned()
1210            .collect::<Vec<_>>();
1211        for key in keys {
1212            if let Some(entry) = inner.blocks.remove(&key) {
1213                inner.current_bytes = inner.current_bytes.saturating_sub(entry.bytes.len());
1214            }
1215        }
1216    }
1217
1218    fn invalidate_prefix(&self, prefix: &str) {
1219        let mut inner = self.inner.lock().expect("cold cache mutex poisoned");
1220        let keys = inner
1221            .blocks
1222            .keys()
1223            .filter(|key| key.path.starts_with(prefix))
1224            .cloned()
1225            .collect::<Vec<_>>();
1226        for key in keys {
1227            if let Some(entry) = inner.blocks.remove(&key) {
1228                inner.current_bytes = inner.current_bytes.saturating_sub(entry.bytes.len());
1229            }
1230        }
1231    }
1232
1233    #[cfg(test)]
1234    fn block_count(&self) -> usize {
1235        self.inner
1236            .lock()
1237            .expect("cold cache mutex poisoned")
1238            .blocks
1239            .len()
1240    }
1241
1242    fn touch(inner: &mut ColdReadCacheInner, key: ColdCacheKey) {
1243        let generation = Self::next_generation(inner);
1244        if let Some(entry) = inner.blocks.get_mut(&key) {
1245            entry.generation = generation;
1246        }
1247        inner.lru.push_back((key, generation));
1248        Self::compact_lru_if_needed(inner);
1249    }
1250
1251    fn compact_lru_if_needed(inner: &mut ColdReadCacheInner) {
1252        // `touch` appends a fresh (key, generation) on every hit without removing
1253        // the stale prior entry, and `evict_locked` only reclaims those when the
1254        // cache is over `max_bytes`. With a working set at or below the cap but
1255        // repeated hits, the deque would otherwise grow without bound. Rebuild it
1256        // from the live blocks once it bloats past 2x the live entry count —
1257        // amortized O(1) per touch, since each rebuild shrinks it back to
1258        // `blocks.len()` so the next rebuild is `blocks.len()` touches away.
1259        if inner.lru.len() <= inner.blocks.len() * 2 + 16 {
1260            return;
1261        }
1262        let mut live: Vec<(u64, ColdCacheKey)> = inner
1263            .blocks
1264            .iter()
1265            .map(|(key, entry)| (entry.generation, key.clone()))
1266            .collect();
1267        live.sort_unstable_by_key(|(generation, _)| *generation);
1268        inner.lru = live
1269            .into_iter()
1270            .map(|(generation, key)| (key, generation))
1271            .collect();
1272    }
1273
1274    fn next_generation(inner: &mut ColdReadCacheInner) -> u64 {
1275        inner.generation = inner.generation.wrapping_add(1);
1276        inner.generation
1277    }
1278
1279    fn evict_locked(&self, inner: &mut ColdReadCacheInner) {
1280        while inner.current_bytes > self.config.max_bytes {
1281            let Some((key, generation)) = inner.lru.pop_front() else {
1282                break;
1283            };
1284            let Some(entry) = inner.blocks.get(&key) else {
1285                continue;
1286            };
1287            if entry.generation != generation {
1288                continue;
1289            }
1290            let entry = inner
1291                .blocks
1292                .remove(&key)
1293                .expect("cache entry exists after lookup");
1294            inner.current_bytes = inner.current_bytes.saturating_sub(entry.bytes.len());
1295        }
1296    }
1297}
1298
1299fn cold_store_io_error(path: &str, err: opendal::Error) -> io::Error {
1300    io::Error::other(format!("cold object '{path}': {err}"))
1301}
1302
1303#[cfg(not(madsim))]
1304fn cold_object_unix_nanos() -> u128 {
1305    SystemTime::now()
1306        .duration_since(UNIX_EPOCH)
1307        .map(|duration| duration.as_nanos())
1308        .unwrap_or(0)
1309}
1310
1311#[cfg(madsim)]
1312fn cold_object_unix_nanos() -> u128 {
1313    0
1314}
1315
1316pub fn new_cold_chunk_path(
1317    stream_id: &BucketStreamId,
1318    start_offset: u64,
1319    end_offset: u64,
1320) -> String {
1321    let unix_nanos = cold_object_unix_nanos();
1322    let sequence = COLD_CHUNK_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1323    format!(
1324        "{stream_id}/chunks/{start_offset:016x}-{end_offset:016x}-{unix_nanos:032x}-{sequence:016x}.bin"
1325    )
1326}
1327
1328pub fn new_cold_pack_path(bucket_id: &str, raft_group_id: u32) -> String {
1329    let unix_nanos = cold_object_unix_nanos();
1330    let sequence = COLD_CHUNK_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1331    format!("{bucket_id}/_packs/{raft_group_id:08x}/{unix_nanos:032x}-{sequence:016x}.bin")
1332}
1333
1334/// The physical erasure domain for one tenant bucket. Every current chunk,
1335/// index, pack, and external payload path is nested below this prefix.
1336pub fn cold_bucket_prefix(bucket_id: &str) -> String {
1337    format!("{bucket_id}/")
1338}
1339
1340/// The prefix under which all of a stream's cold chunks live. Cold objects are
1341/// stream-exclusive, so removing this prefix reclaims every chunk for a fully
1342/// deleted stream in one sweep. Mirrors the layout of [`new_cold_chunk_path`].
1343pub fn cold_chunk_prefix(stream_id: &BucketStreamId) -> String {
1344    format!("{stream_id}/chunks/")
1345}
1346
1347pub fn new_external_payload_path(stream_id: &BucketStreamId) -> String {
1348    let unix_nanos = cold_object_unix_nanos();
1349    let sequence = COLD_CHUNK_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1350    format!("{stream_id}/external/{unix_nanos:032x}-{sequence:016x}.bin")
1351}
1352
1353/// Reset the global cold-object sequence counter. Only available under
1354/// `cfg(madsim)` so the simulator can clear state between scenarios when
1355/// running multiple seeds in one process (e.g. `Runtime::check_determinism`).
1356#[cfg(madsim)]
1357#[allow(dead_code)]
1358pub fn reset_cold_chunk_sequence_for_sim() {
1359    COLD_CHUNK_SEQUENCE.store(0, Ordering::Relaxed);
1360}
1361
1362#[cfg(test)]
1363mod tests {
1364    use bytes::Bytes;
1365    use ursula_config::config::ColdBackend;
1366    use ursula_shard::BucketStreamId;
1367
1368    use super::ColdReadCache;
1369    use super::ColdStore;
1370    use super::parse_cold_index_page_path;
1371    use crate::ColdConfig;
1372    use crate::ColdReadCacheParams;
1373
1374    fn read_cache_params(store: &ColdStore) -> ColdReadCacheParams {
1375        store
1376            .read_cache
1377            .as_ref()
1378            .map(|cache| cache.config)
1379            .expect("read cache")
1380    }
1381
1382    #[test]
1383    fn cold_index_paths_preserve_optional_affinity() {
1384        let plain = parse_cold_index_page_path(
1385            "benchcmp/journal/cold-index/00000000000000000007/00000000000000000042.idx",
1386        )
1387        .expect("plain path");
1388        assert_eq!(plain.stream_id, BucketStreamId::new("benchcmp", "journal"));
1389
1390        let grouped = parse_cold_index_page_path(
1391            "benchcmp/run-42/journal/cold-index/00000000000000000007/00000000000000000042.idx",
1392        )
1393        .expect("grouped path");
1394        assert_eq!(
1395            grouped.stream_id,
1396            BucketStreamId::with_affinity("benchcmp", "run-42", "journal")
1397        );
1398    }
1399
1400    #[test]
1401    fn try_new_omitted_cache_installs_default_cache() {
1402        let config = ColdConfig {
1403            backend: ColdBackend::Memory,
1404            cache: None,
1405            ..Default::default()
1406        };
1407
1408        let store = ColdStore::try_new(&config).expect("memory cold store");
1409        let cache = read_cache_params(&store);
1410
1411        assert_eq!(cache.max_bytes, 256 * 1024 * 1024);
1412        assert_eq!(cache.block_bytes, 1024 * 1024);
1413        assert_eq!(cache.max_readahead_blocks, 4);
1414    }
1415
1416    #[test]
1417    fn try_new_zero_cache_disables_cache() {
1418        let config = ColdConfig {
1419            backend: ColdBackend::Memory,
1420            cache: Some(ursula_config::ColdCacheConfig {
1421                max_size: ursula_config::HumanSize::bytes(0),
1422                ..Default::default()
1423            }),
1424            ..Default::default()
1425        };
1426
1427        let store = ColdStore::try_new(&config).expect("memory cold store");
1428
1429        assert!(store.read_cache.is_none());
1430    }
1431
1432    #[test]
1433    fn try_new_custom_cache_installs_cache() {
1434        let config = ColdConfig {
1435            backend: ColdBackend::Memory,
1436            cache: Some(ursula_config::ColdCacheConfig {
1437                max_size: ursula_config::HumanSize::mib(7),
1438                block_size: ursula_config::HumanSize::kib(512),
1439                readahead_blocks: 3,
1440            }),
1441            ..Default::default()
1442        };
1443
1444        let store = ColdStore::try_new(&config).expect("memory cold store");
1445        let cache = read_cache_params(&store);
1446
1447        assert_eq!(cache.max_bytes, 7 * 1024 * 1024);
1448        assert_eq!(cache.block_bytes, 512 * 1024);
1449        assert_eq!(cache.max_readahead_blocks, 3);
1450    }
1451
1452    fn s3_test_config(
1453        encryption: ursula_config::S3ServerSideEncryption,
1454        kms_key_id: Option<&str>,
1455    ) -> ColdConfig {
1456        ColdConfig {
1457            backend: ColdBackend::S3,
1458            s3: Some(ursula_config::S3Config {
1459                bucket: Some("test-bucket".to_owned()),
1460                region: Some("us-east-1".to_owned()),
1461                server_side_encryption: encryption,
1462                kms_key_id: kms_key_id.map(str::to_owned),
1463                ..Default::default()
1464            }),
1465            ..Default::default()
1466        }
1467    }
1468
1469    #[test]
1470    fn s3_store_defaults_to_sse_s3_and_reports_it() {
1471        let store = ColdStore::try_new(&s3_test_config(
1472            ursula_config::S3ServerSideEncryption::Aes256,
1473            None,
1474        ))
1475        .expect("s3 cold store");
1476        assert_eq!(store.info().encryption, Some("aes256"));
1477    }
1478
1479    #[test]
1480    fn s3_store_reports_kms_and_disabled_modes() {
1481        let kms = ColdStore::try_new(&s3_test_config(
1482            ursula_config::S3ServerSideEncryption::AwsKms,
1483            Some("arn:aws:kms:us-east-1:111122223333:key/test"),
1484        ))
1485        .expect("s3 cold store with kms");
1486        assert_eq!(kms.info().encryption, Some("aws-kms"));
1487
1488        let disabled = ColdStore::try_new(&s3_test_config(
1489            ursula_config::S3ServerSideEncryption::None,
1490            None,
1491        ))
1492        .expect("s3 cold store without sse");
1493        assert_eq!(disabled.info().encryption, Some("none"));
1494    }
1495
1496    #[test]
1497    fn kms_key_without_kms_mode_is_rejected() {
1498        let err = ColdStore::try_new(&s3_test_config(
1499            ursula_config::S3ServerSideEncryption::Aes256,
1500            Some("arn:aws:kms:us-east-1:111122223333:key/test"),
1501        ))
1502        .expect_err("kms key without aws-kms mode");
1503        assert!(err.to_string().contains("aws-kms"), "got: {err}");
1504    }
1505
1506    #[test]
1507    fn lru_queue_stays_bounded_under_repeated_hits() {
1508        // Working set fits entirely in the cache (4 blocks == max_bytes), so there
1509        // is no eviction pressure and the recency deque is the only thing that
1510        // could grow. Before compaction it grew by one entry per hit (~40k here);
1511        // it must stay bounded to the live set instead.
1512        let cache = ColdReadCache::new(ColdReadCacheParams {
1513            max_bytes: 4 * 1024,
1514            block_bytes: 1024,
1515            max_readahead_blocks: 0,
1516        });
1517        for index in 0..4 {
1518            cache.insert("p".to_owned(), index, Bytes::from(vec![0u8; 1024]));
1519        }
1520        for _ in 0..10_000 {
1521            for index in 0..4 {
1522                assert!(cache.get("p", index).is_some());
1523            }
1524        }
1525        let inner = cache.inner.lock().expect("cache mutex");
1526        assert_eq!(inner.blocks.len(), 4, "live blocks unchanged");
1527        assert!(
1528            inner.lru.len() <= inner.blocks.len() * 2 + 16,
1529            "lru deque grew unbounded: {} entries for {} live blocks",
1530            inner.lru.len(),
1531            inner.blocks.len(),
1532        );
1533    }
1534
1535    #[test]
1536    fn s3_without_bucket_fails() {
1537        let config = ColdConfig {
1538            backend: ColdBackend::S3,
1539            ..Default::default()
1540        };
1541        let err = ColdStore::try_new(&config).expect_err("s3 without bucket should fail");
1542        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1543    }
1544}