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