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(|| left.stream_id.stream_id.cmp(&right.stream_id.stream_id))
311                .then_with(|| left.generation.cmp(&right.generation))
312                .then_with(|| left.page_id.cmp(&right.page_id))
313        });
314        pages.dedup();
315        Ok(pages)
316    }
317
318    pub fn memory() -> io::Result<Self> {
319        let operator = Operator::via_iter(Scheme::Memory, [])
320            .map_err(|err| io::Error::other(err.to_string()))?;
321        Ok(Self::from_operator(operator, ColdStoreInfo {
322            backend: "memory",
323            root: None,
324            bucket: None,
325            region: None,
326            endpoint: None,
327            encryption: None,
328        }))
329    }
330
331    /// Build a [`ColdStore`] from an explicit [`ColdConfig`].
332    ///
333    /// The bootstrap layer assembles the typed config before calling this method;
334    /// this method is purely functional — it does not touch `std::env`.
335    ///
336    /// Returns `Err` when the backend is [`ColdBackend::None`];
337    /// the caller should skip construction when cold storage is disabled.
338    pub fn try_new(config: &ColdConfig) -> io::Result<Self> {
339        let mut store = match config.backend {
340            ColdBackend::None => {
341                return Err(io::Error::new(
342                    io::ErrorKind::InvalidInput,
343                    "ColdStore::try_new called with backend=none; \
344                     the caller should skip construction when cold storage is disabled",
345                ));
346            }
347            ColdBackend::Memory => Self::memory()?,
348            ColdBackend::S3 => Self::s3_from_config(config)?,
349        };
350        let cache = config.cache.clone().unwrap_or_default();
351        if cache.max_size.as_bytes() > 0 {
352            let cache_params = ColdReadCacheParams {
353                max_bytes: cache.max_size.as_bytes() as usize,
354                block_bytes: cache.block_size.as_bytes() as usize,
355                max_readahead_blocks: cache.readahead_blocks,
356            };
357            store = store.with_read_cache(cache_params);
358        }
359        Ok(store)
360    }
361
362    fn s3_from_config(config: &ColdConfig) -> io::Result<Self> {
363        let s3 = config.s3.as_ref().ok_or_else(|| {
364            io::Error::new(
365                io::ErrorKind::InvalidInput,
366                "s3 configuration is required when cold backend is s3",
367            )
368        })?;
369        let bucket = s3.bucket.as_deref().ok_or_else(|| {
370            io::Error::new(
371                io::ErrorKind::InvalidInput,
372                "s3 bucket is required when cold backend is s3",
373            )
374        })?;
375        if bucket.trim().is_empty() {
376            return Err(io::Error::new(
377                io::ErrorKind::InvalidInput,
378                "s3 bucket must not be empty",
379            ));
380        }
381
382        let mut builder = opendal::services::S3::default().bucket(bucket);
383        let mut configured_root = None;
384        if let Some(root) = config.root.as_deref()
385            && !root.trim().is_empty()
386        {
387            builder = builder.root(root);
388            configured_root = Some(root.to_owned());
389        }
390        let mut configured_region = None;
391        if let Some(region) = s3.region.as_deref()
392            && !region.trim().is_empty()
393        {
394            builder = builder.region(region);
395            configured_region = Some(region.to_owned());
396        }
397        let mut configured_endpoint = None;
398        if let Some(endpoint) = s3.endpoint.as_deref()
399            && !endpoint.trim().is_empty()
400        {
401            builder = builder.endpoint(endpoint);
402            configured_endpoint = Some(endpoint.to_owned());
403        }
404        if let Some(access_key_id) = s3.access_key_id.as_deref()
405            && !access_key_id.trim().is_empty()
406        {
407            builder = builder.access_key_id(access_key_id);
408        }
409        if let Some(secret_access_key) = s3.secret_access_key.as_deref()
410            && !secret_access_key.trim().is_empty()
411        {
412            builder = builder.secret_access_key(secret_access_key);
413        }
414        if let Some(session_token) = s3.session_token.as_deref()
415            && !session_token.trim().is_empty()
416        {
417            builder = builder.session_token(session_token);
418        }
419        let (builder, encryption) = apply_s3_encryption(builder, s3)?;
420
421        Ok(Self::from_operator(
422            with_s3_resilience(
423                Operator::new(builder)
424                    .map_err(|err| io::Error::other(err.to_string()))?
425                    .finish(),
426                s3.timeout.as_duration(),
427                s3.max_retries,
428            ),
429            ColdStoreInfo {
430                backend: "s3",
431                root: configured_root,
432                bucket: Some(bucket.to_owned()),
433                region: configured_region,
434                endpoint: configured_endpoint,
435                encryption: Some(encryption),
436            },
437        ))
438    }
439
440    fn from_operator(operator: Operator, info: ColdStoreInfo) -> Self {
441        Self {
442            info,
443            operator,
444            read_cache: None,
445            observer: Arc::new(Mutex::new(None)),
446            fault_policy: Arc::new(Mutex::new(None)),
447            delay_fn: Arc::new(Mutex::new(default_cold_store_delay_fn())),
448        }
449    }
450
451    pub fn info(&self) -> &ColdStoreInfo {
452        &self.info
453    }
454
455    pub fn with_read_cache(mut self, config: ColdReadCacheParams) -> Self {
456        self.read_cache = Some(Arc::new(ColdReadCache::new(config)));
457        self
458    }
459
460    pub fn without_read_cache(mut self) -> Self {
461        self.read_cache = None;
462        self
463    }
464
465    pub fn set_observer(&self, observer: impl Fn(ColdStoreEvent) + Send + Sync + 'static) {
466        *self.observer.lock().expect("cold store observer mutex") = Some(Arc::new(observer));
467    }
468
469    pub fn set_fault_policy(
470        &self,
471        policy: impl Fn(&ColdStoreFaultContext) -> Option<ColdStoreFaultEffect> + Send + Sync + 'static,
472    ) {
473        *self
474            .fault_policy
475            .lock()
476            .expect("cold store fault policy mutex") = Some(Arc::new(policy));
477    }
478
479    pub fn clear_fault_policy(&self) {
480        *self
481            .fault_policy
482            .lock()
483            .expect("cold store fault policy mutex") = None;
484    }
485
486    pub fn set_delay_fn<F, Fut>(&self, delay_fn: F)
487    where
488        F: Fn(Duration) -> Fut + Send + Sync + 'static,
489        Fut: Future<Output = ()> + Send + 'static,
490    {
491        *self.delay_fn.lock().expect("cold store delay fn mutex") =
492            Arc::new(move |duration| Box::pin(delay_fn(duration)));
493    }
494
495    #[cfg(test)]
496    pub(crate) fn cached_block_count(&self) -> usize {
497        self.read_cache
498            .as_ref()
499            .map(|cache| cache.block_count())
500            .unwrap_or(0)
501    }
502
503    pub async fn write_chunk(&self, path: &str, payload: &[u8]) -> io::Result<u64> {
504        if path.trim().is_empty() {
505            return Err(io::Error::new(
506                io::ErrorKind::InvalidInput,
507                "cold chunk path must not be empty",
508            ));
509        }
510        self.notify(ColdStoreEvent::WriteChunkBegin {
511            path: path.to_owned(),
512            payload_len: payload.len(),
513        });
514        let _applied_fault = self
515            .maybe_apply_fault_effect(ColdStoreFaultContext {
516                operation: ColdStoreOperation::WriteChunk,
517                stream_id: None,
518                path: path.to_owned(),
519                payload_len: Some(payload.len()),
520                read_start_offset: None,
521                len: None,
522                object_start: None,
523                object_end: None,
524                cached: None,
525            })
526            .await?;
527        self.operator
528            .write(path, payload.to_vec())
529            .await
530            .map_err(|err| cold_store_io_error(path, err))?;
531        let object_size = u64::try_from(payload.len()).expect("payload len fits u64");
532        self.notify(ColdStoreEvent::WriteChunkComplete {
533            path: path.to_owned(),
534            object_size,
535        });
536        Ok(object_size)
537    }
538
539    pub(crate) async fn write_cold_index_page(
540        &self,
541        path: &str,
542        payload: &[u8],
543    ) -> io::Result<u64> {
544        if path.trim().is_empty() {
545            return Err(io::Error::new(
546                io::ErrorKind::InvalidInput,
547                "cold index page path must not be empty",
548            ));
549        }
550        self.operator
551            .write(path, payload.to_vec())
552            .await
553            .map_err(|err| cold_store_io_error(path, err))?;
554        Ok(u64::try_from(payload.len()).expect("payload len fits u64"))
555    }
556
557    #[tracing::instrument(name = "cold.read_index", level = "debug", skip_all)]
558    pub(crate) async fn read_cold_index_page(&self, path: &str) -> io::Result<Option<Vec<u8>>> {
559        if path.trim().is_empty() {
560            return Err(io::Error::new(
561                io::ErrorKind::InvalidInput,
562                "cold index page path must not be empty",
563            ));
564        }
565        match self.operator.read(path).await {
566            Ok(bytes) => Ok(Some(bytes.to_bytes().to_vec())),
567            Err(err) if err.kind() == opendal::ErrorKind::NotFound => Ok(None),
568            Err(err) => Err(cold_store_io_error(path, err)),
569        }
570    }
571
572    pub async fn delete_chunk(&self, path: &str) -> io::Result<()> {
573        if path.trim().is_empty() {
574            return Err(io::Error::new(
575                io::ErrorKind::InvalidInput,
576                "cold chunk path must not be empty",
577            ));
578        }
579        self.notify(ColdStoreEvent::DeleteChunkBegin {
580            path: path.to_owned(),
581        });
582        let _applied_fault = self
583            .maybe_apply_fault_effect(ColdStoreFaultContext {
584                operation: ColdStoreOperation::DeleteChunk,
585                stream_id: None,
586                path: path.to_owned(),
587                payload_len: None,
588                read_start_offset: None,
589                len: None,
590                object_start: None,
591                object_end: None,
592                cached: None,
593            })
594            .await?;
595        self.operator
596            .delete(path)
597            .await
598            .map_err(|err| cold_store_io_error(path, err))?;
599        if let Some(cache) = &self.read_cache {
600            cache.invalidate_path(path);
601        }
602        self.notify(ColdStoreEvent::DeleteChunkComplete {
603            path: path.to_owned(),
604        });
605        Ok(())
606    }
607
608    pub async fn remove_all(&self, path: &str) -> io::Result<()> {
609        self.notify(ColdStoreEvent::RemoveAllBegin {
610            path: path.to_owned(),
611        });
612        let _applied_fault = self
613            .maybe_apply_fault_effect(ColdStoreFaultContext {
614                operation: ColdStoreOperation::RemoveAll,
615                stream_id: None,
616                path: path.to_owned(),
617                payload_len: None,
618                read_start_offset: None,
619                len: None,
620                object_start: None,
621                object_end: None,
622                cached: None,
623            })
624            .await?;
625        self.operator
626            .remove_all(path)
627            .await
628            .map_err(|err| cold_store_io_error(path, err))?;
629        if let Some(cache) = &self.read_cache {
630            cache.invalidate_prefix(path);
631        }
632        self.notify(ColdStoreEvent::RemoveAllComplete {
633            path: path.to_owned(),
634        });
635        Ok(())
636    }
637
638    pub async fn read_chunk_range(
639        &self,
640        chunk: &ColdChunkRef,
641        read_start_offset: u64,
642        len: usize,
643    ) -> io::Result<Vec<u8>> {
644        let object = ObjectPayloadRef {
645            start_offset: chunk.start_offset,
646            end_offset: chunk.end_offset,
647            s3_path: chunk.s3_path.clone(),
648            object_size: chunk.object_size,
649            object_offset: chunk.object_offset,
650        };
651        self.read_object_range(&object, read_start_offset, len)
652            .await
653    }
654
655    pub async fn read_object_range_for_stream(
656        &self,
657        stream_id: &BucketStreamId,
658        object: &ObjectPayloadRef,
659        read_start_offset: u64,
660        len: usize,
661    ) -> io::Result<Vec<u8>> {
662        self.read_object_range_inner(Some(stream_id), object, read_start_offset, len)
663            .await
664    }
665
666    pub async fn read_object_range(
667        &self,
668        object: &ObjectPayloadRef,
669        read_start_offset: u64,
670        len: usize,
671    ) -> io::Result<Vec<u8>> {
672        self.read_object_range_inner(None, object, read_start_offset, len)
673            .await
674    }
675
676    #[tracing::instrument(
677        name = "cold.read_chunk",
678        level = "debug",
679        skip_all,
680        fields(
681            start_offset = object.start_offset,
682            end_offset = object.end_offset,
683            object_size = object.object_size,
684            len = len,
685        ),
686    )]
687    async fn read_object_range_inner(
688        &self,
689        stream_id: Option<&BucketStreamId>,
690        object: &ObjectPayloadRef,
691        read_start_offset: u64,
692        len: usize,
693    ) -> io::Result<Vec<u8>> {
694        if len == 0 {
695            return Ok(Vec::new());
696        }
697        let len_u64 = u64::try_from(len).map_err(|_| {
698            io::Error::new(io::ErrorKind::InvalidInput, "cold read length exceeds u64")
699        })?;
700        let read_end = read_start_offset.checked_add(len_u64).ok_or_else(|| {
701            io::Error::new(io::ErrorKind::InvalidInput, "cold read range overflow")
702        })?;
703        if read_start_offset < object.start_offset || read_end > object.end_offset {
704            return Err(io::Error::new(
705                io::ErrorKind::InvalidInput,
706                format!(
707                    "cold read range [{read_start_offset}..{read_end}) is outside object segment [{}..{})",
708                    object.start_offset, object.end_offset
709                ),
710            ));
711        }
712        let object_start = object
713            .object_offset
714            .checked_add(read_start_offset - object.start_offset)
715            .ok_or_else(|| {
716                io::Error::new(io::ErrorKind::InvalidInput, "cold read range overflow")
717            })?;
718        let object_end = object_start.checked_add(len_u64).ok_or_else(|| {
719            io::Error::new(io::ErrorKind::InvalidInput, "cold read range overflow")
720        })?;
721        if object_end > object.object_size {
722            return Err(io::Error::new(
723                io::ErrorKind::InvalidData,
724                format!(
725                    "cold read range [{object_start}..{object_end}) is outside object '{}' size {}",
726                    object.s3_path, object.object_size
727                ),
728            ));
729        }
730        let cached = self.read_cache.is_some();
731        self.notify(ColdStoreEvent::ReadObjectRangeBegin {
732            stream_id: stream_id.cloned(),
733            path: object.s3_path.clone(),
734            read_start_offset,
735            len,
736            object_start,
737            object_end,
738            cached,
739        });
740        let applied_fault = self
741            .maybe_apply_fault_effect(ColdStoreFaultContext {
742                operation: ColdStoreOperation::ReadObjectRange,
743                stream_id: stream_id.cloned(),
744                path: object.s3_path.clone(),
745                payload_len: None,
746                read_start_offset: Some(read_start_offset),
747                len: Some(len),
748                object_start: Some(object_start),
749                object_end: Some(object_end),
750                cached: Some(cached),
751            })
752            .await?;
753        let mut bytes = if let Some(cache) = &self.read_cache {
754            let bytes = self
755                .read_object_range_cached(cache, object, object_start, object_end, len)
756                .await?;
757            if let Some(stream_id) = stream_id {
758                let readahead_blocks = cache.record_stream_read(stream_id, read_start_offset, len);
759                if readahead_blocks > 0 {
760                    self.spawn_readahead(object.clone(), object_end, readahead_blocks);
761                }
762            }
763            bytes
764        } else {
765            self.read_object_range_uncached(object, object_start, object_end, len)
766                .await?
767        };
768        if let Some(returned_len) = applied_fault.truncate_read_to {
769            let returned_len = returned_len.min(bytes.len());
770            bytes.truncate(returned_len);
771            self.notify(ColdStoreEvent::TruncateInjected {
772                stream_id: stream_id.cloned(),
773                path: object.s3_path.clone(),
774                requested_len: len,
775                returned_len,
776            });
777        }
778        if bytes.len() != len {
779            return Err(io::Error::new(
780                io::ErrorKind::InvalidData,
781                format!(
782                    "cold object '{}' returned {} bytes for requested range [{}..{})",
783                    object.s3_path,
784                    bytes.len(),
785                    object_start,
786                    object_end
787                ),
788            ));
789        }
790        self.notify(ColdStoreEvent::ReadObjectRangeComplete {
791            stream_id: stream_id.cloned(),
792            path: object.s3_path.clone(),
793            read_start_offset,
794            len,
795            returned_len: bytes.len(),
796            cached,
797        });
798        Ok(bytes)
799    }
800
801    async fn read_object_range_uncached(
802        &self,
803        object: &ObjectPayloadRef,
804        object_start: u64,
805        object_end: u64,
806        len: usize,
807    ) -> io::Result<Vec<u8>> {
808        let bytes = self
809            .operator
810            .read_with(&object.s3_path)
811            .range(object_start..object_end)
812            .await
813            .map_err(|err| cold_store_io_error(&object.s3_path, err))?
814            .to_bytes();
815        if bytes.len() != len {
816            return Err(io::Error::new(
817                io::ErrorKind::InvalidData,
818                format!(
819                    "cold object '{}' returned {} bytes for requested range [{}..{})",
820                    object.s3_path,
821                    bytes.len(),
822                    object_start,
823                    object_end
824                ),
825            ));
826        }
827        Ok(bytes.to_vec())
828    }
829
830    async fn read_object_range_cached(
831        &self,
832        cache: &ColdReadCache,
833        object: &ObjectPayloadRef,
834        object_start: u64,
835        object_end: u64,
836        len: usize,
837    ) -> io::Result<Vec<u8>> {
838        let mut payload = Vec::with_capacity(len);
839        let block_size = cache.block_size();
840        let first_block = object_start / block_size;
841        let last_block = (object_end - 1) / block_size;
842        for block_index in first_block..=last_block {
843            let block_start = block_index * block_size;
844            let block_end = block_start
845                .saturating_add(block_size)
846                .min(object.object_size);
847            let block = self
848                .read_cached_block(
849                    cache,
850                    object.s3_path.clone(),
851                    object.object_size,
852                    block_index,
853                    block_start,
854                    block_end,
855                )
856                .await?;
857            let slice_start = usize::try_from(object_start.max(block_start) - block_start)
858                .expect("cache slice start fits usize");
859            let slice_end = usize::try_from(object_end.min(block_end) - block_start)
860                .expect("cache slice end fits usize");
861            payload.extend_from_slice(&block.slice(slice_start..slice_end));
862        }
863        if payload.len() != len {
864            return Err(io::Error::new(
865                io::ErrorKind::InvalidData,
866                format!(
867                    "cold object '{}' returned {} bytes for requested range [{}..{})",
868                    object.s3_path,
869                    payload.len(),
870                    object_start,
871                    object_end
872                ),
873            ));
874        }
875        Ok(payload)
876    }
877
878    async fn read_cached_block(
879        &self,
880        cache: &ColdReadCache,
881        path: String,
882        object_size: u64,
883        block_index: u64,
884        block_start: u64,
885        block_end: u64,
886    ) -> io::Result<Bytes> {
887        if let Some(bytes) = cache.get(&path, block_index) {
888            return Ok(bytes);
889        }
890        let bytes = self
891            .operator
892            .read_with(&path)
893            .range(block_start..block_end)
894            .await
895            .map_err(|err| cold_store_io_error(&path, err))?
896            .to_bytes();
897        let expected_len = usize::try_from(block_end - block_start).map_err(|_| {
898            io::Error::new(
899                io::ErrorKind::InvalidData,
900                "cold cache block length exceeds usize",
901            )
902        })?;
903        if bytes.len() != expected_len {
904            return Err(io::Error::new(
905                io::ErrorKind::InvalidData,
906                format!(
907                    "cold object '{path}' returned {} bytes for cache block [{}..{}) of object size {object_size}",
908                    bytes.len(),
909                    block_start,
910                    block_end
911                ),
912            ));
913        }
914        cache.insert(path, block_index, bytes.clone());
915        Ok(bytes)
916    }
917
918    fn spawn_readahead(&self, object: ObjectPayloadRef, object_end: u64, readahead_blocks: usize) {
919        let Some(cache) = self.read_cache.clone() else {
920            return;
921        };
922        let block_size = cache.block_size();
923        let mut block_index = object_end.div_ceil(block_size);
924        let store = self.clone();
925        crate::rt::spawn(async move {
926            for _ in 0..readahead_blocks {
927                let block_start = block_index * block_size;
928                if block_start >= object.object_size {
929                    break;
930                }
931                let block_end = block_start
932                    .saturating_add(block_size)
933                    .min(object.object_size);
934                if cache.get(&object.s3_path, block_index).is_none() {
935                    let _ = store
936                        .read_cached_block(
937                            &cache,
938                            object.s3_path.clone(),
939                            object.object_size,
940                            block_index,
941                            block_start,
942                            block_end,
943                        )
944                        .await;
945                }
946                block_index += 1;
947            }
948        });
949    }
950
951    fn notify(&self, event: ColdStoreEvent) {
952        let observer = self
953            .observer
954            .lock()
955            .expect("cold store observer mutex")
956            .clone();
957        if let Some(observer) = observer {
958            observer(event);
959        }
960    }
961
962    async fn maybe_apply_fault_effect(
963        &self,
964        context: ColdStoreFaultContext,
965    ) -> io::Result<ColdStoreAppliedFault> {
966        let policy = self
967            .fault_policy
968            .lock()
969            .expect("cold store fault policy mutex")
970            .clone();
971        let Some(policy) = policy else {
972            return Ok(ColdStoreAppliedFault::default());
973        };
974        let Some(effect) = policy(&context) else {
975            return Ok(ColdStoreAppliedFault::default());
976        };
977        if let Some(delay) = effect.delay {
978            self.notify(ColdStoreEvent::DelayInjected {
979                operation: context.operation,
980                stream_id: context.stream_id.clone(),
981                path: context.path.clone(),
982                delay_ms: duration_ms(delay),
983            });
984            let delay_fn = self
985                .delay_fn
986                .lock()
987                .expect("cold store delay fn mutex")
988                .clone();
989            delay_fn(delay).await;
990        }
991        if let Some(fault) = effect.error {
992            self.notify(ColdStoreEvent::FaultInjected {
993                operation: context.operation,
994                stream_id: context.stream_id,
995                path: context.path.clone(),
996                message: fault.message.clone(),
997            });
998            return Err(io::Error::other(format!(
999                "cold store fault injected for {} '{}': {}",
1000                context.operation.as_str(),
1001                context.path,
1002                fault.message
1003            )));
1004        }
1005        Ok(ColdStoreAppliedFault {
1006            truncate_read_to: effect.truncate_read_to,
1007        })
1008    }
1009}
1010
1011fn parse_cold_index_page_path(path: &str) -> Option<ColdIndexPageKey> {
1012    let mut parts = path.split('/');
1013    let bucket_id = parts.next()?;
1014    let stream_id = parts.next()?;
1015    if parts.next()? != "cold-index" {
1016        return None;
1017    }
1018    let generation = parts.next()?.parse().ok()?;
1019    let page_id = parts.next()?.strip_suffix(".idx")?.parse().ok()?;
1020    if parts.next().is_some() || bucket_id.is_empty() || stream_id.is_empty() {
1021        return None;
1022    }
1023    Some(ColdIndexPageKey {
1024        stream_id: BucketStreamId::new(bucket_id, stream_id),
1025        generation,
1026        page_id,
1027    })
1028}
1029
1030#[derive(Debug, Default)]
1031struct ColdStoreAppliedFault {
1032    truncate_read_to: Option<usize>,
1033}
1034
1035impl ColdStoreOperation {
1036    fn as_str(self) -> &'static str {
1037        match self {
1038            Self::WriteChunk => "write_chunk",
1039            Self::DeleteChunk => "delete_chunk",
1040            Self::RemoveAll => "remove_all",
1041            Self::ReadObjectRange => "read_object_range",
1042        }
1043    }
1044}
1045
1046fn default_cold_store_delay_fn() -> ColdStoreDelayFn {
1047    Arc::new(|duration| Box::pin(crate::rt::time::sleep(duration)))
1048}
1049
1050fn duration_ms(duration: Duration) -> u64 {
1051    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
1052}
1053
1054/// Runtime parameters for the optional cold-read cache.
1055///
1056/// Kept separate from the serde [`ursula_config::ColdCacheConfig`] because
1057/// `ColdReadCache` accesses these fields on the hot path.
1058#[derive(Debug, Clone, Copy)]
1059pub struct ColdReadCacheParams {
1060    pub max_bytes: usize,
1061    pub block_bytes: usize,
1062    pub max_readahead_blocks: usize,
1063}
1064
1065#[derive(Debug)]
1066struct ColdReadCache {
1067    config: ColdReadCacheParams,
1068    inner: Mutex<ColdReadCacheInner>,
1069}
1070
1071#[derive(Debug, Default)]
1072struct ColdReadCacheInner {
1073    blocks: HashMap<ColdCacheKey, ColdCacheEntry>,
1074    lru: VecDeque<(ColdCacheKey, u64)>,
1075    current_bytes: usize,
1076    generation: u64,
1077    readers: HashMap<BucketStreamId, StreamReadState>,
1078}
1079
1080#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1081struct ColdCacheKey {
1082    path: String,
1083    block_index: u64,
1084}
1085
1086#[derive(Debug)]
1087struct ColdCacheEntry {
1088    bytes: Bytes,
1089    generation: u64,
1090}
1091
1092#[derive(Debug, Default)]
1093struct StreamReadState {
1094    next_offset: u64,
1095    sequential_score: usize,
1096}
1097
1098impl ColdReadCache {
1099    fn new(config: ColdReadCacheParams) -> Self {
1100        let block_bytes = config.block_bytes.max(1);
1101        Self {
1102            config: ColdReadCacheParams {
1103                max_bytes: config.max_bytes,
1104                block_bytes,
1105                max_readahead_blocks: config.max_readahead_blocks,
1106            },
1107            inner: Mutex::new(ColdReadCacheInner::default()),
1108        }
1109    }
1110
1111    fn block_size(&self) -> u64 {
1112        u64::try_from(self.config.block_bytes.max(1)).expect("cache block size fits u64")
1113    }
1114
1115    fn get(&self, path: &str, block_index: u64) -> Option<Bytes> {
1116        let mut inner = self.inner.lock().expect("cold cache mutex poisoned");
1117        let key = ColdCacheKey {
1118            path: path.to_owned(),
1119            block_index,
1120        };
1121        let bytes = inner.blocks.get(&key)?.bytes.clone();
1122        Self::touch(&mut inner, key);
1123        Some(bytes)
1124    }
1125
1126    fn insert(&self, path: String, block_index: u64, bytes: Bytes) {
1127        if bytes.len() > self.config.max_bytes || self.config.max_bytes == 0 {
1128            return;
1129        }
1130        let mut inner = self.inner.lock().expect("cold cache mutex poisoned");
1131        let key = ColdCacheKey { path, block_index };
1132        if let Some(previous) = inner.blocks.remove(&key) {
1133            inner.current_bytes = inner.current_bytes.saturating_sub(previous.bytes.len());
1134        }
1135        let generation = Self::next_generation(&mut inner);
1136        inner.current_bytes = inner.current_bytes.saturating_add(bytes.len());
1137        inner
1138            .blocks
1139            .insert(key.clone(), ColdCacheEntry { bytes, generation });
1140        inner.lru.push_back((key, generation));
1141        self.evict_locked(&mut inner);
1142        Self::compact_lru_if_needed(&mut inner);
1143    }
1144
1145    fn record_stream_read(
1146        &self,
1147        stream_id: &BucketStreamId,
1148        read_start_offset: u64,
1149        len: usize,
1150    ) -> usize {
1151        let mut inner = self.inner.lock().expect("cold cache mutex poisoned");
1152        let state = inner.readers.entry(stream_id.clone()).or_default();
1153        if read_start_offset == state.next_offset {
1154            state.sequential_score = state
1155                .sequential_score
1156                .saturating_add(1)
1157                .min(self.config.max_readahead_blocks);
1158        } else {
1159            state.sequential_score = 0;
1160        }
1161        state.next_offset =
1162            read_start_offset.saturating_add(u64::try_from(len).unwrap_or(u64::MAX));
1163        state.sequential_score.min(self.config.max_readahead_blocks)
1164    }
1165
1166    fn invalidate_path(&self, path: &str) {
1167        let mut inner = self.inner.lock().expect("cold cache mutex poisoned");
1168        let keys = inner
1169            .blocks
1170            .keys()
1171            .filter(|key| key.path == path)
1172            .cloned()
1173            .collect::<Vec<_>>();
1174        for key in keys {
1175            if let Some(entry) = inner.blocks.remove(&key) {
1176                inner.current_bytes = inner.current_bytes.saturating_sub(entry.bytes.len());
1177            }
1178        }
1179    }
1180
1181    fn invalidate_prefix(&self, prefix: &str) {
1182        let mut inner = self.inner.lock().expect("cold cache mutex poisoned");
1183        let keys = inner
1184            .blocks
1185            .keys()
1186            .filter(|key| key.path.starts_with(prefix))
1187            .cloned()
1188            .collect::<Vec<_>>();
1189        for key in keys {
1190            if let Some(entry) = inner.blocks.remove(&key) {
1191                inner.current_bytes = inner.current_bytes.saturating_sub(entry.bytes.len());
1192            }
1193        }
1194    }
1195
1196    #[cfg(test)]
1197    fn block_count(&self) -> usize {
1198        self.inner
1199            .lock()
1200            .expect("cold cache mutex poisoned")
1201            .blocks
1202            .len()
1203    }
1204
1205    fn touch(inner: &mut ColdReadCacheInner, key: ColdCacheKey) {
1206        let generation = Self::next_generation(inner);
1207        if let Some(entry) = inner.blocks.get_mut(&key) {
1208            entry.generation = generation;
1209        }
1210        inner.lru.push_back((key, generation));
1211        Self::compact_lru_if_needed(inner);
1212    }
1213
1214    fn compact_lru_if_needed(inner: &mut ColdReadCacheInner) {
1215        // `touch` appends a fresh (key, generation) on every hit without removing
1216        // the stale prior entry, and `evict_locked` only reclaims those when the
1217        // cache is over `max_bytes`. With a working set at or below the cap but
1218        // repeated hits, the deque would otherwise grow without bound. Rebuild it
1219        // from the live blocks once it bloats past 2x the live entry count —
1220        // amortized O(1) per touch, since each rebuild shrinks it back to
1221        // `blocks.len()` so the next rebuild is `blocks.len()` touches away.
1222        if inner.lru.len() <= inner.blocks.len() * 2 + 16 {
1223            return;
1224        }
1225        let mut live: Vec<(u64, ColdCacheKey)> = inner
1226            .blocks
1227            .iter()
1228            .map(|(key, entry)| (entry.generation, key.clone()))
1229            .collect();
1230        live.sort_unstable_by_key(|(generation, _)| *generation);
1231        inner.lru = live
1232            .into_iter()
1233            .map(|(generation, key)| (key, generation))
1234            .collect();
1235    }
1236
1237    fn next_generation(inner: &mut ColdReadCacheInner) -> u64 {
1238        inner.generation = inner.generation.wrapping_add(1);
1239        inner.generation
1240    }
1241
1242    fn evict_locked(&self, inner: &mut ColdReadCacheInner) {
1243        while inner.current_bytes > self.config.max_bytes {
1244            let Some((key, generation)) = inner.lru.pop_front() else {
1245                break;
1246            };
1247            let Some(entry) = inner.blocks.get(&key) else {
1248                continue;
1249            };
1250            if entry.generation != generation {
1251                continue;
1252            }
1253            let entry = inner
1254                .blocks
1255                .remove(&key)
1256                .expect("cache entry exists after lookup");
1257            inner.current_bytes = inner.current_bytes.saturating_sub(entry.bytes.len());
1258        }
1259    }
1260}
1261
1262fn cold_store_io_error(path: &str, err: opendal::Error) -> io::Error {
1263    io::Error::other(format!("cold object '{path}': {err}"))
1264}
1265
1266#[cfg(not(madsim))]
1267fn cold_object_unix_nanos() -> u128 {
1268    SystemTime::now()
1269        .duration_since(UNIX_EPOCH)
1270        .map(|duration| duration.as_nanos())
1271        .unwrap_or(0)
1272}
1273
1274#[cfg(madsim)]
1275fn cold_object_unix_nanos() -> u128 {
1276    0
1277}
1278
1279pub fn new_cold_chunk_path(
1280    stream_id: &BucketStreamId,
1281    start_offset: u64,
1282    end_offset: u64,
1283) -> String {
1284    let unix_nanos = cold_object_unix_nanos();
1285    let sequence = COLD_CHUNK_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1286    format!(
1287        "{stream_id}/chunks/{start_offset:016x}-{end_offset:016x}-{unix_nanos:032x}-{sequence:016x}.bin"
1288    )
1289}
1290
1291pub fn new_cold_pack_path(raft_group_id: u32) -> String {
1292    let unix_nanos = cold_object_unix_nanos();
1293    let sequence = COLD_CHUNK_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1294    format!("_packs/{raft_group_id:08x}/{unix_nanos:032x}-{sequence:016x}.bin")
1295}
1296
1297/// The prefix under which all of a stream's cold chunks live. Cold objects are
1298/// stream-exclusive, so removing this prefix reclaims every chunk for a fully
1299/// deleted stream in one sweep. Mirrors the layout of [`new_cold_chunk_path`].
1300pub fn cold_chunk_prefix(stream_id: &BucketStreamId) -> String {
1301    format!("{stream_id}/chunks/")
1302}
1303
1304pub fn new_external_payload_path(stream_id: &BucketStreamId) -> String {
1305    let unix_nanos = cold_object_unix_nanos();
1306    let sequence = COLD_CHUNK_SEQUENCE.fetch_add(1, Ordering::Relaxed);
1307    format!("{stream_id}/external/{unix_nanos:032x}-{sequence:016x}.bin")
1308}
1309
1310/// Reset the global cold-object sequence counter. Only available under
1311/// `cfg(madsim)` so the simulator can clear state between scenarios when
1312/// running multiple seeds in one process (e.g. `Runtime::check_determinism`).
1313#[cfg(madsim)]
1314#[allow(dead_code)]
1315pub fn reset_cold_chunk_sequence_for_sim() {
1316    COLD_CHUNK_SEQUENCE.store(0, Ordering::Relaxed);
1317}
1318
1319#[cfg(test)]
1320mod tests {
1321    use bytes::Bytes;
1322    use ursula_config::config::ColdBackend;
1323
1324    use super::ColdReadCache;
1325    use super::ColdStore;
1326    use crate::ColdConfig;
1327    use crate::ColdReadCacheParams;
1328
1329    fn read_cache_params(store: &ColdStore) -> ColdReadCacheParams {
1330        store
1331            .read_cache
1332            .as_ref()
1333            .map(|cache| cache.config)
1334            .expect("read cache")
1335    }
1336
1337    #[test]
1338    fn try_new_omitted_cache_installs_default_cache() {
1339        let config = ColdConfig {
1340            backend: ColdBackend::Memory,
1341            cache: None,
1342            ..Default::default()
1343        };
1344
1345        let store = ColdStore::try_new(&config).expect("memory cold store");
1346        let cache = read_cache_params(&store);
1347
1348        assert_eq!(cache.max_bytes, 256 * 1024 * 1024);
1349        assert_eq!(cache.block_bytes, 1024 * 1024);
1350        assert_eq!(cache.max_readahead_blocks, 4);
1351    }
1352
1353    #[test]
1354    fn try_new_zero_cache_disables_cache() {
1355        let config = ColdConfig {
1356            backend: ColdBackend::Memory,
1357            cache: Some(ursula_config::ColdCacheConfig {
1358                max_size: ursula_config::HumanSize::bytes(0),
1359                ..Default::default()
1360            }),
1361            ..Default::default()
1362        };
1363
1364        let store = ColdStore::try_new(&config).expect("memory cold store");
1365
1366        assert!(store.read_cache.is_none());
1367    }
1368
1369    #[test]
1370    fn try_new_custom_cache_installs_cache() {
1371        let config = ColdConfig {
1372            backend: ColdBackend::Memory,
1373            cache: Some(ursula_config::ColdCacheConfig {
1374                max_size: ursula_config::HumanSize::mib(7),
1375                block_size: ursula_config::HumanSize::kib(512),
1376                readahead_blocks: 3,
1377            }),
1378            ..Default::default()
1379        };
1380
1381        let store = ColdStore::try_new(&config).expect("memory cold store");
1382        let cache = read_cache_params(&store);
1383
1384        assert_eq!(cache.max_bytes, 7 * 1024 * 1024);
1385        assert_eq!(cache.block_bytes, 512 * 1024);
1386        assert_eq!(cache.max_readahead_blocks, 3);
1387    }
1388
1389    fn s3_test_config(
1390        encryption: ursula_config::S3ServerSideEncryption,
1391        kms_key_id: Option<&str>,
1392    ) -> ColdConfig {
1393        ColdConfig {
1394            backend: ColdBackend::S3,
1395            s3: Some(ursula_config::S3Config {
1396                bucket: Some("test-bucket".to_owned()),
1397                region: Some("us-east-1".to_owned()),
1398                server_side_encryption: encryption,
1399                kms_key_id: kms_key_id.map(str::to_owned),
1400                ..Default::default()
1401            }),
1402            ..Default::default()
1403        }
1404    }
1405
1406    #[test]
1407    fn s3_store_defaults_to_sse_s3_and_reports_it() {
1408        let store = ColdStore::try_new(&s3_test_config(
1409            ursula_config::S3ServerSideEncryption::Aes256,
1410            None,
1411        ))
1412        .expect("s3 cold store");
1413        assert_eq!(store.info().encryption, Some("aes256"));
1414    }
1415
1416    #[test]
1417    fn s3_store_reports_kms_and_disabled_modes() {
1418        let kms = ColdStore::try_new(&s3_test_config(
1419            ursula_config::S3ServerSideEncryption::AwsKms,
1420            Some("arn:aws:kms:us-east-1:111122223333:key/test"),
1421        ))
1422        .expect("s3 cold store with kms");
1423        assert_eq!(kms.info().encryption, Some("aws-kms"));
1424
1425        let disabled = ColdStore::try_new(&s3_test_config(
1426            ursula_config::S3ServerSideEncryption::None,
1427            None,
1428        ))
1429        .expect("s3 cold store without sse");
1430        assert_eq!(disabled.info().encryption, Some("none"));
1431    }
1432
1433    #[test]
1434    fn kms_key_without_kms_mode_is_rejected() {
1435        let err = ColdStore::try_new(&s3_test_config(
1436            ursula_config::S3ServerSideEncryption::Aes256,
1437            Some("arn:aws:kms:us-east-1:111122223333:key/test"),
1438        ))
1439        .expect_err("kms key without aws-kms mode");
1440        assert!(err.to_string().contains("aws-kms"), "got: {err}");
1441    }
1442
1443    #[test]
1444    fn lru_queue_stays_bounded_under_repeated_hits() {
1445        // Working set fits entirely in the cache (4 blocks == max_bytes), so there
1446        // is no eviction pressure and the recency deque is the only thing that
1447        // could grow. Before compaction it grew by one entry per hit (~40k here);
1448        // it must stay bounded to the live set instead.
1449        let cache = ColdReadCache::new(ColdReadCacheParams {
1450            max_bytes: 4 * 1024,
1451            block_bytes: 1024,
1452            max_readahead_blocks: 0,
1453        });
1454        for index in 0..4 {
1455            cache.insert("p".to_owned(), index, Bytes::from(vec![0u8; 1024]));
1456        }
1457        for _ in 0..10_000 {
1458            for index in 0..4 {
1459                assert!(cache.get("p", index).is_some());
1460            }
1461        }
1462        let inner = cache.inner.lock().expect("cache mutex");
1463        assert_eq!(inner.blocks.len(), 4, "live blocks unchanged");
1464        assert!(
1465            inner.lru.len() <= inner.blocks.len() * 2 + 16,
1466            "lru deque grew unbounded: {} entries for {} live blocks",
1467            inner.lru.len(),
1468            inner.blocks.len(),
1469        );
1470    }
1471
1472    #[test]
1473    fn s3_without_bucket_fails() {
1474        let config = ColdConfig {
1475            backend: ColdBackend::S3,
1476            ..Default::default()
1477        };
1478        let err = ColdStore::try_new(&config).expect_err("s3 without bucket should fail");
1479        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1480    }
1481}