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(|| 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 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#[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 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
1297pub 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#[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 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}