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