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";
34static COLD_CHUNK_SEQUENCE: CachePadded<AtomicU64> = CachePadded::new(AtomicU64::new(0));
39
40pub(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
80pub(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 pub encryption: Option<&'static str>,
280}
281
282impl ColdStore {
283 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 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 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#[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 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
1334pub fn cold_bucket_prefix(bucket_id: &str) -> String {
1337 format!("{bucket_id}/")
1338}
1339
1340pub 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#[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 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}