1use std::collections::HashMap;
2use std::collections::HashSet;
3use std::collections::VecDeque;
4use std::future::Future;
5use std::io;
6use std::pin::Pin;
7use std::sync::Arc;
8use std::sync::Mutex;
9
10use ursula_shard::BucketStreamId;
11use ursula_stream::ColdChunkRef;
12use ursula_stream::ExternalPayloadRef;
13use ursula_stream::ObjectPayloadRef;
14use ursula_stream::StreamReadColdIndexSegment;
15
16use crate::cold_store::ColdStoreHandle;
17
18pub type ColdIndexPageStoreFuture<'a, T> = Pin<Box<dyn Future<Output = io::Result<T>> + Send + 'a>>;
19
20const COLD_INDEX_PAGE_MAGIC: &[u8; 8] = b"UCIDX001";
21const COLD_INDEX_PAGE_VERSION: u16 = 1;
22const COLD_INDEX_ENTRY_COLD_CHUNK: u8 = 1;
23const COLD_INDEX_ENTRY_EXTERNAL_SEGMENT: u8 = 2;
24const FNV64_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
25const FNV64_PRIME: u64 = 0x0000_0100_0000_01b3;
26
27#[derive(Debug, Clone, PartialEq, Eq, Hash)]
28pub struct ColdIndexPageKey {
29 pub stream_id: BucketStreamId,
30 pub generation: u64,
31 pub page_id: u64,
32}
33
34impl ColdIndexPageKey {
35 pub fn path(&self) -> String {
36 format!(
37 "{}/{}/cold-index/{:020}/{:020}.idx",
38 self.stream_id.bucket_id, self.stream_id.stream_id, self.generation, self.page_id
39 )
40 }
41}
42
43pub fn cold_index_prefix(stream_id: &BucketStreamId) -> String {
44 format!(
45 "{}/{}/cold-index/",
46 stream_id.bucket_id, stream_id.stream_id
47 )
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct ColdIndexPage {
52 pub start_offset: u64,
53 pub end_offset: u64,
54 pub cold_chunks: Vec<ColdChunkRef>,
55 pub external_segments: Vec<ObjectPayloadRef>,
56}
57
58impl ColdIndexPage {
59 pub fn covers(&self, offset: u64) -> bool {
60 self.start_offset <= offset && offset < self.end_offset
61 }
62}
63
64#[derive(Debug, Clone)]
65pub struct ColdIndexPageRollback {
66 key: ColdIndexPageKey,
67 previous: Option<ColdIndexPage>,
68 written_chunk: ColdChunkRef,
69}
70
71fn encode_page(key: &ColdIndexPageKey, page: &ColdIndexPage) -> Vec<u8> {
72 let mut body = Vec::new();
73 put_string(&mut body, &key.stream_id.bucket_id);
74 put_string(&mut body, &key.stream_id.stream_id);
75 put_u64(&mut body, key.generation);
76 put_u64(&mut body, key.page_id);
77 put_u64(&mut body, page.start_offset);
78 put_u64(&mut body, page.end_offset);
79 put_u32(
80 &mut body,
81 u32::try_from(page.cold_chunks.len()).expect("cold index cold chunk count fits u32"),
82 );
83 for chunk in &page.cold_chunks {
84 put_u8(&mut body, COLD_INDEX_ENTRY_COLD_CHUNK);
85 put_u64(&mut body, chunk.start_offset);
86 put_u64(&mut body, chunk.end_offset);
87 put_u64(&mut body, chunk.object_size);
88 put_string(&mut body, &chunk.s3_path);
89 }
90 put_u32(
91 &mut body,
92 u32::try_from(page.external_segments.len())
93 .expect("cold index external segment count fits u32"),
94 );
95 for object in &page.external_segments {
96 put_u8(&mut body, COLD_INDEX_ENTRY_EXTERNAL_SEGMENT);
97 put_u64(&mut body, object.start_offset);
98 put_u64(&mut body, object.end_offset);
99 put_u64(&mut body, object.object_size);
100 put_string(&mut body, &object.s3_path);
101 }
102
103 let mut bytes = Vec::with_capacity(COLD_INDEX_PAGE_MAGIC.len() + 2 + 4 + body.len() + 8);
104 bytes.extend_from_slice(COLD_INDEX_PAGE_MAGIC);
105 put_u16(&mut bytes, COLD_INDEX_PAGE_VERSION);
106 put_u32(
107 &mut bytes,
108 u32::try_from(body.len()).expect("cold index page body len fits u32"),
109 );
110 bytes.extend_from_slice(&body);
111 put_u64(&mut bytes, checksum64(&body));
112 bytes
113}
114
115fn decode_page(key: &ColdIndexPageKey, bytes: &[u8]) -> io::Result<ColdIndexPage> {
116 let mut cursor = Cursor::new(bytes);
117 let magic = cursor.read_exact(COLD_INDEX_PAGE_MAGIC.len())?;
118 if magic != COLD_INDEX_PAGE_MAGIC {
119 return Err(io::Error::new(
120 io::ErrorKind::InvalidData,
121 "cold index page has invalid magic",
122 ));
123 }
124 let version = cursor.read_u16()?;
125 if version != COLD_INDEX_PAGE_VERSION {
126 return Err(io::Error::new(
127 io::ErrorKind::InvalidData,
128 format!("unsupported cold index page version {version}"),
129 ));
130 }
131 let body_len = usize::try_from(cursor.read_u32()?).expect("u32 fits usize");
132 let body = cursor.read_exact(body_len)?;
133 let expected_checksum = cursor.read_u64()?;
134 if cursor.remaining() != 0 {
135 return Err(io::Error::new(
136 io::ErrorKind::InvalidData,
137 "cold index page has trailing bytes",
138 ));
139 }
140 let actual_checksum = checksum64(body);
141 if actual_checksum != expected_checksum {
142 return Err(io::Error::new(
143 io::ErrorKind::InvalidData,
144 "cold index page checksum mismatch",
145 ));
146 }
147
148 let mut body = Cursor::new(body);
149 let bucket_id = body.read_string()?;
150 let stream_id = body.read_string()?;
151 let generation = body.read_u64()?;
152 let page_id = body.read_u64()?;
153 if bucket_id != key.stream_id.bucket_id
154 || stream_id != key.stream_id.stream_id
155 || generation != key.generation
156 || page_id != key.page_id
157 {
158 return Err(io::Error::new(
159 io::ErrorKind::InvalidData,
160 "cold index page key metadata mismatch",
161 ));
162 }
163 let start_offset = body.read_u64()?;
164 let end_offset = body.read_u64()?;
165 let cold_chunk_count = body.read_u32()?;
166 let mut cold_chunks =
167 Vec::with_capacity(usize::try_from(cold_chunk_count).expect("u32 fits usize"));
168 for _ in 0..cold_chunk_count {
169 let tag = body.read_u8()?;
170 if tag != COLD_INDEX_ENTRY_COLD_CHUNK {
171 return Err(io::Error::new(
172 io::ErrorKind::InvalidData,
173 "cold index page expected cold chunk entry",
174 ));
175 }
176 cold_chunks.push(ColdChunkRef {
177 start_offset: body.read_u64()?,
178 end_offset: body.read_u64()?,
179 object_size: body.read_u64()?,
180 s3_path: body.read_string()?,
181 object_offset: 0,
182 shared_object: false,
183 payload_digest: String::new(),
184 });
185 }
186 let external_segment_count = body.read_u32()?;
187 let mut external_segments =
188 Vec::with_capacity(usize::try_from(external_segment_count).expect("u32 fits usize"));
189 for _ in 0..external_segment_count {
190 let tag = body.read_u8()?;
191 if tag != COLD_INDEX_ENTRY_EXTERNAL_SEGMENT {
192 return Err(io::Error::new(
193 io::ErrorKind::InvalidData,
194 "cold index page expected external segment entry",
195 ));
196 }
197 external_segments.push(ObjectPayloadRef {
198 start_offset: body.read_u64()?,
199 end_offset: body.read_u64()?,
200 object_size: body.read_u64()?,
201 s3_path: body.read_string()?,
202 object_offset: 0,
203 });
204 }
205 if body.remaining() != 0 {
206 return Err(io::Error::new(
207 io::ErrorKind::InvalidData,
208 "cold index page body has trailing bytes",
209 ));
210 }
211 Ok(ColdIndexPage {
212 start_offset,
213 end_offset,
214 cold_chunks,
215 external_segments,
216 })
217}
218
219fn put_u8(out: &mut Vec<u8>, value: u8) {
220 out.push(value);
221}
222
223fn put_u16(out: &mut Vec<u8>, value: u16) {
224 out.extend_from_slice(&value.to_le_bytes());
225}
226
227fn put_u32(out: &mut Vec<u8>, value: u32) {
228 out.extend_from_slice(&value.to_le_bytes());
229}
230
231fn put_u64(out: &mut Vec<u8>, value: u64) {
232 out.extend_from_slice(&value.to_le_bytes());
233}
234
235fn put_string(out: &mut Vec<u8>, value: &str) {
236 put_u32(
237 out,
238 u32::try_from(value.len()).expect("cold index string len fits u32"),
239 );
240 out.extend_from_slice(value.as_bytes());
241}
242
243fn checksum64(bytes: &[u8]) -> u64 {
244 let mut hash = FNV64_OFFSET_BASIS;
245 for byte in bytes {
246 hash ^= u64::from(*byte);
247 hash = hash.wrapping_mul(FNV64_PRIME);
248 }
249 hash
250}
251
252struct Cursor<'a> {
253 bytes: &'a [u8],
254 offset: usize,
255}
256
257impl<'a> Cursor<'a> {
258 fn new(bytes: &'a [u8]) -> Self {
259 Self { bytes, offset: 0 }
260 }
261
262 fn remaining(&self) -> usize {
263 self.bytes.len().saturating_sub(self.offset)
264 }
265
266 fn read_exact(&mut self, len: usize) -> io::Result<&'a [u8]> {
267 let end = self.offset.checked_add(len).ok_or_else(|| {
268 io::Error::new(
269 io::ErrorKind::InvalidData,
270 "cold index page offset overflow",
271 )
272 })?;
273 if end > self.bytes.len() {
274 return Err(io::Error::new(
275 io::ErrorKind::UnexpectedEof,
276 "cold index page ended early",
277 ));
278 }
279 let slice = &self.bytes[self.offset..end];
280 self.offset = end;
281 Ok(slice)
282 }
283
284 fn read_u8(&mut self) -> io::Result<u8> {
285 Ok(self.read_exact(1)?[0])
286 }
287
288 fn read_u16(&mut self) -> io::Result<u16> {
289 let mut bytes = [0; 2];
290 bytes.copy_from_slice(self.read_exact(2)?);
291 Ok(u16::from_le_bytes(bytes))
292 }
293
294 fn read_u32(&mut self) -> io::Result<u32> {
295 let mut bytes = [0; 4];
296 bytes.copy_from_slice(self.read_exact(4)?);
297 Ok(u32::from_le_bytes(bytes))
298 }
299
300 fn read_u64(&mut self) -> io::Result<u64> {
301 let mut bytes = [0; 8];
302 bytes.copy_from_slice(self.read_exact(8)?);
303 Ok(u64::from_le_bytes(bytes))
304 }
305
306 fn read_string(&mut self) -> io::Result<String> {
307 let len = usize::try_from(self.read_u32()?).expect("u32 fits usize");
308 let bytes = self.read_exact(len)?;
309 String::from_utf8(bytes.to_vec()).map_err(|err| {
310 io::Error::new(
311 io::ErrorKind::InvalidData,
312 format!("cold index page contains invalid UTF-8: {err}"),
313 )
314 })
315 }
316}
317
318pub trait ColdIndexPageStore: Send + Sync {
319 fn put_page<'a>(
320 &'a self,
321 key: &'a ColdIndexPageKey,
322 page: &'a ColdIndexPage,
323 ) -> ColdIndexPageStoreFuture<'a, ()>;
324
325 fn get_page<'a>(
326 &'a self,
327 key: &'a ColdIndexPageKey,
328 ) -> ColdIndexPageStoreFuture<'a, Option<ColdIndexPage>>;
329}
330
331pub async fn write_cold_chunk_index_pages<S: ColdIndexPageStore + ?Sized>(
332 store: &S,
333 stream_id: &BucketStreamId,
334 chunk: &ColdChunkRef,
335) -> io::Result<()> {
336 write_cold_chunk_index_pages_with_rollback(store, stream_id, chunk)
337 .await
338 .map(|_| ())
339}
340
341pub async fn write_cold_chunk_index_pages_with_rollback<S: ColdIndexPageStore + ?Sized>(
342 store: &S,
343 stream_id: &BucketStreamId,
344 chunk: &ColdChunkRef,
345) -> io::Result<Vec<ColdIndexPageRollback>> {
346 if chunk.end_offset <= chunk.start_offset {
347 return Ok(Vec::new());
348 }
349 let first_page_id = chunk.start_offset / ursula_stream::COLD_INDEX_PAGE_SPAN_BYTES;
350 let last_page_id = (chunk.end_offset - 1) / ursula_stream::COLD_INDEX_PAGE_SPAN_BYTES;
351 let mut rollback = Vec::new();
352 for page_id in first_page_id..=last_page_id {
353 let key = ColdIndexPageKey {
354 stream_id: stream_id.clone(),
355 generation: 0,
356 page_id,
357 };
358 let page_start = page_id.saturating_mul(ursula_stream::COLD_INDEX_PAGE_SPAN_BYTES);
359 let page_end = page_start.saturating_add(ursula_stream::COLD_INDEX_PAGE_SPAN_BYTES);
360 let previous = store.get_page(&key).await?;
361 let mut page = previous.clone().unwrap_or_else(|| ColdIndexPage {
362 start_offset: page_start,
363 end_offset: page_end,
364 cold_chunks: Vec::new(),
365 external_segments: Vec::new(),
366 });
367 rollback.push(ColdIndexPageRollback {
368 key: key.clone(),
369 previous,
370 written_chunk: chunk.clone(),
371 });
372 page.cold_chunks.retain(|existing| {
373 existing.start_offset != chunk.start_offset || existing.end_offset != chunk.end_offset
374 });
375 page.cold_chunks.push(chunk.clone());
376 page.cold_chunks.sort_by_key(|chunk| chunk.start_offset);
377 store.put_page(&key, &page).await?;
378 }
379 Ok(rollback)
380}
381
382pub async fn rollback_cold_index_pages<S: ColdIndexPageStore + ?Sized>(
383 store: &S,
384 rollback: Vec<ColdIndexPageRollback>,
385) -> io::Result<()> {
386 for entry in rollback.into_iter().rev() {
387 let Some(current) = store.get_page(&entry.key).await? else {
388 continue;
389 };
390 let current_still_has_written_chunk = current.cold_chunks.iter().any(|chunk| {
391 chunk.start_offset == entry.written_chunk.start_offset
392 && chunk.end_offset == entry.written_chunk.end_offset
393 && chunk.s3_path == entry.written_chunk.s3_path
394 });
395 if !current_still_has_written_chunk {
396 continue;
397 }
398 let page = entry.previous.unwrap_or_else(|| {
399 let page_start = entry
400 .key
401 .page_id
402 .saturating_mul(ursula_stream::COLD_INDEX_PAGE_SPAN_BYTES);
403 let page_end = page_start.saturating_add(ursula_stream::COLD_INDEX_PAGE_SPAN_BYTES);
404 ColdIndexPage {
405 start_offset: page_start,
406 end_offset: page_end,
407 cold_chunks: Vec::new(),
408 external_segments: Vec::new(),
409 }
410 });
411 store.put_page(&entry.key, &page).await?;
412 }
413 Ok(())
414}
415
416pub async fn write_external_segment_index_pages<S: ColdIndexPageStore + ?Sized>(
417 store: &S,
418 stream_id: &BucketStreamId,
419 start_offset: u64,
420 payload: &ExternalPayloadRef,
421) -> io::Result<()> {
422 let object = ObjectPayloadRef {
423 start_offset,
424 end_offset: start_offset.saturating_add(payload.payload_len),
425 s3_path: payload.s3_path.clone(),
426 object_size: payload.object_size,
427 object_offset: 0,
428 };
429 write_object_index_pages(store, stream_id, object).await
430}
431
432async fn write_object_index_pages<S: ColdIndexPageStore + ?Sized>(
433 store: &S,
434 stream_id: &BucketStreamId,
435 object: ObjectPayloadRef,
436) -> io::Result<()> {
437 if object.end_offset <= object.start_offset {
438 return Ok(());
439 }
440 let first_page_id = object.start_offset / ursula_stream::COLD_INDEX_PAGE_SPAN_BYTES;
441 let last_page_id = (object.end_offset - 1) / ursula_stream::COLD_INDEX_PAGE_SPAN_BYTES;
442 for page_id in first_page_id..=last_page_id {
443 let key = ColdIndexPageKey {
444 stream_id: stream_id.clone(),
445 generation: 0,
446 page_id,
447 };
448 let page_start = page_id.saturating_mul(ursula_stream::COLD_INDEX_PAGE_SPAN_BYTES);
449 let page_end = page_start.saturating_add(ursula_stream::COLD_INDEX_PAGE_SPAN_BYTES);
450 let mut page = store
451 .get_page(&key)
452 .await?
453 .unwrap_or_else(|| ColdIndexPage {
454 start_offset: page_start,
455 end_offset: page_end,
456 cold_chunks: Vec::new(),
457 external_segments: Vec::new(),
458 });
459 page.external_segments.retain(|existing| {
460 existing.start_offset != object.start_offset || existing.end_offset != object.end_offset
461 });
462 page.external_segments.push(object.clone());
463 page.external_segments
464 .sort_by_key(|object| object.start_offset);
465 store.put_page(&key, &page).await?;
466 }
467 Ok(())
468}
469
470#[derive(Debug, Default)]
471pub struct InMemoryColdIndexPageStore {
472 pages: Mutex<HashMap<ColdIndexPageKey, Vec<u8>>>,
473}
474
475#[derive(Debug, Clone)]
476pub struct ColdStoreColdIndexPageStore {
477 cold_store: ColdStoreHandle,
478}
479
480impl ColdStoreColdIndexPageStore {
481 pub fn new(cold_store: ColdStoreHandle) -> Self {
482 Self { cold_store }
483 }
484}
485
486impl ColdIndexPageStore for ColdStoreColdIndexPageStore {
487 fn put_page<'a>(
488 &'a self,
489 key: &'a ColdIndexPageKey,
490 page: &'a ColdIndexPage,
491 ) -> ColdIndexPageStoreFuture<'a, ()> {
492 Box::pin(async move {
493 let bytes = encode_page(key, page);
494 self.cold_store
495 .write_cold_index_page(&key.path(), &bytes)
496 .await?;
497 Ok(())
498 })
499 }
500
501 fn get_page<'a>(
502 &'a self,
503 key: &'a ColdIndexPageKey,
504 ) -> ColdIndexPageStoreFuture<'a, Option<ColdIndexPage>> {
505 Box::pin(async move {
506 self.cold_store
507 .read_cold_index_page(&key.path())
508 .await?
509 .map(|bytes| decode_page(key, &bytes))
510 .transpose()
511 })
512 }
513}
514
515impl InMemoryColdIndexPageStore {
516 pub fn new() -> Self {
517 Self::default()
518 }
519}
520
521impl ColdIndexPageStore for InMemoryColdIndexPageStore {
522 fn put_page<'a>(
523 &'a self,
524 key: &'a ColdIndexPageKey,
525 page: &'a ColdIndexPage,
526 ) -> ColdIndexPageStoreFuture<'a, ()> {
527 Box::pin(async move {
528 let bytes = encode_page(key, page);
529 self.pages
530 .lock()
531 .expect("cold index page store mutex poisoned")
532 .insert(key.clone(), bytes);
533 Ok(())
534 })
535 }
536
537 fn get_page<'a>(
538 &'a self,
539 key: &'a ColdIndexPageKey,
540 ) -> ColdIndexPageStoreFuture<'a, Option<ColdIndexPage>> {
541 Box::pin(async move {
542 self.pages
543 .lock()
544 .expect("cold index page store mutex poisoned")
545 .get(key)
546 .map(|bytes| decode_page(key, bytes))
547 .transpose()
548 })
549 }
550}
551
552#[derive(Debug)]
553pub struct ColdIndexPageCache<S: ColdIndexPageStore + ?Sized> {
554 store: Arc<S>,
555 capacity_pages: usize,
556 inner: Mutex<ColdIndexPageCacheInner>,
557}
558
559#[derive(Debug, Default)]
560struct ColdIndexPageCacheInner {
561 next_generation: u64,
562 pages: HashMap<ColdIndexPageKey, ColdIndexPageCacheEntry>,
563 lru: VecDeque<(ColdIndexPageKey, u64)>,
564}
565
566#[derive(Debug)]
567struct ColdIndexPageCacheEntry {
568 page: Arc<ColdIndexPage>,
569 generation: u64,
570}
571
572impl<S: ColdIndexPageStore + ?Sized> ColdIndexPageCache<S> {
573 pub fn new(store: Arc<S>, capacity_pages: usize) -> Self {
574 Self {
575 store,
576 capacity_pages,
577 inner: Mutex::new(ColdIndexPageCacheInner::default()),
578 }
579 }
580
581 pub async fn put_page(&self, key: &ColdIndexPageKey, page: &ColdIndexPage) -> io::Result<()> {
582 self.store.put_page(key, page).await?;
583 self.insert(key.clone(), Arc::new(page.clone()));
584 Ok(())
585 }
586
587 pub async fn get_page(&self, key: &ColdIndexPageKey) -> io::Result<Option<Arc<ColdIndexPage>>> {
588 if let Some(page) = self.get_cached(key) {
589 return Ok(Some(page));
590 }
591 self.reload_page(key).await
592 }
593
594 pub fn invalidate_stream(&self, stream_id: &BucketStreamId) {
597 let mut inner = self.inner.lock().expect("cold index cache mutex poisoned");
598 inner.pages.retain(|key, _| &key.stream_id != stream_id);
599 inner.lru.retain(|(key, _)| &key.stream_id != stream_id);
600 }
601
602 async fn reload_page(&self, key: &ColdIndexPageKey) -> io::Result<Option<Arc<ColdIndexPage>>> {
603 let Some(page) = self.store.get_page(key).await? else {
604 return Ok(None);
605 };
606 let page = Arc::new(page);
607 self.insert(key.clone(), page.clone());
608 Ok(Some(page))
609 }
610
611 pub async fn object_segments_for_read(
612 &self,
613 stream_id: &BucketStreamId,
614 segment: &StreamReadColdIndexSegment,
615 ) -> io::Result<Vec<ObjectPayloadRef>> {
616 let key = ColdIndexPageKey {
617 stream_id: stream_id.clone(),
618 generation: segment.generation,
619 page_id: segment.page_id,
620 };
621 let Some(page) = self.get_page(&key).await? else {
622 return Err(io::Error::new(
623 io::ErrorKind::NotFound,
624 format!("cold index page '{}' does not exist", key.path()),
625 ));
626 };
627 let read_end = segment
628 .read_start_offset
629 .checked_add(u64::try_from(segment.len).expect("cold index read len fits u64"))
630 .ok_or_else(|| {
631 io::Error::new(
632 io::ErrorKind::InvalidInput,
633 "cold index read range overflows",
634 )
635 })?;
636 let mut objects = objects_for_read(&page, segment.read_start_offset, read_end);
637 if !objects_cover_range(&objects, segment.read_start_offset, read_end)
638 && let Some(reloaded) = self.reload_page(&key).await?
639 {
640 objects = objects_for_read(&reloaded, segment.read_start_offset, read_end);
641 }
642 if !objects_cover_range(&objects, segment.read_start_offset, read_end) {
643 return Err(io::Error::new(
644 io::ErrorKind::InvalidData,
645 "cold index page does not cover requested read range",
646 ));
647 }
648 Ok(objects)
649 }
650
651 pub fn cached_page_count(&self) -> usize {
652 self.inner
653 .lock()
654 .expect("cold index page cache mutex poisoned")
655 .pages
656 .len()
657 }
658
659 fn get_cached(&self, key: &ColdIndexPageKey) -> Option<Arc<ColdIndexPage>> {
660 let mut inner = self
661 .inner
662 .lock()
663 .expect("cold index page cache mutex poisoned");
664 let page = inner.pages.get(key)?.page.clone();
665 Self::touch(&mut inner, key.clone());
666 Some(page)
667 }
668
669 fn insert(&self, key: ColdIndexPageKey, page: Arc<ColdIndexPage>) {
670 let mut inner = self
671 .inner
672 .lock()
673 .expect("cold index page cache mutex poisoned");
674 let generation = Self::touch(&mut inner, key.clone());
675 inner
676 .pages
677 .insert(key, ColdIndexPageCacheEntry { page, generation });
678 Self::evict_over_capacity(&mut inner, self.capacity_pages);
679 }
680
681 fn touch(inner: &mut ColdIndexPageCacheInner, key: ColdIndexPageKey) -> u64 {
682 let generation = inner.next_generation;
683 inner.next_generation = inner.next_generation.saturating_add(1);
684 if let Some(entry) = inner.pages.get_mut(&key) {
685 entry.generation = generation;
686 }
687 inner.lru.push_back((key, generation));
688 generation
689 }
690
691 fn evict_over_capacity(inner: &mut ColdIndexPageCacheInner, capacity_pages: usize) {
692 if capacity_pages == 0 {
693 inner.pages.clear();
694 inner.lru.clear();
695 return;
696 }
697 while inner.pages.len() > capacity_pages {
698 let Some((key, generation)) = inner.lru.pop_front() else {
699 break;
700 };
701 let stale = inner
702 .pages
703 .get(&key)
704 .is_none_or(|entry| entry.generation != generation);
705 if stale {
706 continue;
707 }
708 inner.pages.remove(&key);
709 }
710 }
711}
712
713pub async fn load_cold_chunks_from_pages<S: ColdIndexPageStore + ?Sized>(
717 store: &S,
718 keys: &[ColdIndexPageKey],
719) -> io::Result<Vec<ColdChunkRef>> {
720 let mut chunks = Vec::new();
721 let mut seen = HashSet::new();
722 for key in keys {
723 let Some(page) = store.get_page(key).await? else {
724 continue;
725 };
726 for chunk in page.cold_chunks {
727 let identity = (chunk.start_offset, chunk.end_offset, chunk.s3_path.clone());
728 if seen.insert(identity) {
729 chunks.push(chunk);
730 }
731 }
732 }
733 chunks.sort_by(|left, right| {
734 left.start_offset
735 .cmp(&right.start_offset)
736 .then_with(|| left.end_offset.cmp(&right.end_offset))
737 .then_with(|| left.s3_path.cmp(&right.s3_path))
738 });
739 Ok(chunks)
740}
741
742pub fn select_cold_chunk_compaction(
745 chunks: &[ColdChunkRef],
746 target_bytes: u64,
747 max_bytes: u64,
748) -> Option<Vec<ColdChunkRef>> {
749 if target_bytes == 0 || max_bytes < target_bytes {
750 return None;
751 }
752 let mut candidate = Vec::new();
753 let mut bytes = 0_u64;
754 let mut next_offset = None;
755 for chunk in chunks {
756 let logical_bytes = chunk.end_offset.checked_sub(chunk.start_offset)?;
757 let usable = logical_bytes > 0
758 && chunk.object_size == logical_bytes
759 && chunk.object_size < target_bytes;
760 let contiguous = next_offset.is_none_or(|offset| offset == chunk.start_offset);
761 let next_bytes = bytes.checked_add(chunk.object_size);
762 if !usable || !contiguous || next_bytes.is_none_or(|total| total > max_bytes) {
763 candidate.clear();
764 bytes = 0;
765 next_offset = None;
766 if !usable {
767 continue;
768 }
769 }
770 bytes = bytes.checked_add(chunk.object_size)?;
771 next_offset = Some(chunk.end_offset);
772 candidate.push(chunk.clone());
773 if candidate.len() >= 2 && bytes >= target_bytes {
774 return Some(candidate);
775 }
776 }
777 None
778}
779
780pub async fn replace_cold_chunk_index_pages<S: ColdIndexPageStore + ?Sized>(
785 store: &S,
786 stream_id: &BucketStreamId,
787 old_chunks: &[ColdChunkRef],
788 replacement: &ColdChunkRef,
789) -> io::Result<bool> {
790 replace_cold_chunk_index_pages_with_rollback(store, stream_id, old_chunks, replacement)
791 .await
792 .map(|rollback| rollback.is_some())
793}
794
795pub async fn replace_cold_chunk_index_pages_with_rollback<S: ColdIndexPageStore + ?Sized>(
796 store: &S,
797 stream_id: &BucketStreamId,
798 old_chunks: &[ColdChunkRef],
799 replacement: &ColdChunkRef,
800) -> io::Result<Option<Vec<ColdIndexPageRollback>>> {
801 if old_chunks.len() < 2 || replacement.end_offset <= replacement.start_offset {
802 return Ok(None);
803 }
804 let first_page_id = replacement.start_offset / ursula_stream::COLD_INDEX_PAGE_SPAN_BYTES;
805 let last_page_id = (replacement.end_offset - 1) / ursula_stream::COLD_INDEX_PAGE_SPAN_BYTES;
806 let old_identities = old_chunks
807 .iter()
808 .map(|chunk| (chunk.start_offset, chunk.end_offset, chunk.s3_path.as_str()))
809 .collect::<HashSet<_>>();
810 let mut pages = Vec::new();
811 let mut found = HashSet::new();
812 for page_id in first_page_id..=last_page_id {
813 let key = ColdIndexPageKey {
814 stream_id: stream_id.clone(),
815 generation: 0,
816 page_id,
817 };
818 let Some(mut page) = store.get_page(&key).await? else {
819 return Ok(None);
820 };
821 let previous = page.clone();
822 for chunk in &page.cold_chunks {
823 let identity = (chunk.start_offset, chunk.end_offset, chunk.s3_path.as_str());
824 if old_identities.contains(&identity) {
825 found.insert((chunk.start_offset, chunk.end_offset, chunk.s3_path.clone()));
826 }
827 }
828 page.cold_chunks.retain(|chunk| {
829 !old_identities.contains(&(
830 chunk.start_offset,
831 chunk.end_offset,
832 chunk.s3_path.as_str(),
833 ))
834 });
835 page.cold_chunks.retain(|chunk| {
836 chunk.start_offset != replacement.start_offset
837 || chunk.end_offset != replacement.end_offset
838 });
839 page.cold_chunks.push(replacement.clone());
840 page.cold_chunks.sort_by_key(|chunk| chunk.start_offset);
841 pages.push((key, previous, page));
842 }
843 if found.len() != old_identities.len() {
844 return Ok(None);
845 }
846 let mut rollback = Vec::with_capacity(pages.len());
847 for (key, previous, page) in pages {
848 if let Err(err) = store.put_page(&key, &page).await {
849 rollback_cold_index_pages(store, rollback).await?;
850 return Err(err);
851 }
852 rollback.push(ColdIndexPageRollback {
853 key,
854 previous: Some(previous),
855 written_chunk: replacement.clone(),
856 });
857 }
858 Ok(Some(rollback))
859}
860
861fn objects_for_read(page: &ColdIndexPage, read_start: u64, read_end: u64) -> Vec<ObjectPayloadRef> {
862 let mut objects = Vec::new();
863 for chunk in &page.cold_chunks {
864 if let Some(object) = intersect_object(&ObjectPayloadRef::from(chunk), read_start, read_end)
865 {
866 objects.push(object);
867 }
868 }
869 for object in &page.external_segments {
870 if let Some(object) = intersect_object(object, read_start, read_end) {
871 objects.push(object);
872 }
873 }
874 objects.sort_by_key(|object| object.start_offset);
875 objects
876}
877
878fn intersect_object(
879 object: &ObjectPayloadRef,
880 read_start: u64,
881 read_end: u64,
882) -> Option<ObjectPayloadRef> {
883 let start = object.start_offset.max(read_start);
884 let end = object.end_offset.min(read_end);
885 (start < end).then(|| object.clone())
886}
887
888fn objects_cover_range(objects: &[ObjectPayloadRef], start: u64, end: u64) -> bool {
889 let mut expected = start;
890 for object in objects {
891 if object.end_offset <= expected {
892 continue;
893 }
894 if object.start_offset > expected {
895 return false;
896 }
897 expected = object.end_offset;
898 if expected >= end {
899 return true;
900 }
901 }
902 expected == end
903}
904
905#[cfg(test)]
906mod tests {
907 use super::*;
908
909 fn key(page_id: u64) -> ColdIndexPageKey {
910 ColdIndexPageKey {
911 stream_id: BucketStreamId::new("benchcmp", "cold-index"),
912 generation: 7,
913 page_id,
914 }
915 }
916
917 fn page(start_offset: u64, end_offset: u64) -> ColdIndexPage {
918 ColdIndexPage {
919 start_offset,
920 end_offset,
921 cold_chunks: vec![ColdChunkRef {
922 start_offset,
923 end_offset,
924 s3_path: format!("benchcmp/cold-index/chunks/{start_offset:020}.bin"),
925 object_size: end_offset - start_offset,
926 ..Default::default()
927 }],
928 external_segments: Vec::new(),
929 }
930 }
931
932 #[tokio::test]
933 async fn memory_store_round_trips_pages() {
934 let store = InMemoryColdIndexPageStore::new();
935 let key = key(1);
936 let page = page(0, 128);
937
938 assert_eq!(
939 key.path(),
940 "benchcmp/cold-index/cold-index/00000000000000000007/00000000000000000001.idx"
941 );
942 assert_eq!(store.get_page(&key).await.expect("get missing"), None);
943 store.put_page(&key, &page).await.expect("put page");
944 assert_eq!(
945 store.get_page(&key).await.expect("get page"),
946 Some(page.clone())
947 );
948 assert!(page.covers(127));
949 assert!(!page.covers(128));
950 }
951
952 #[tokio::test]
953 async fn rollback_skips_page_updated_by_newer_writer() {
954 let store = InMemoryColdIndexPageStore::new();
955 let stream_id = BucketStreamId::new("benchcmp", "cold-index");
956 let first = ColdChunkRef {
957 start_offset: 0,
958 end_offset: 128,
959 s3_path: "benchcmp/cold-index/chunks/first.bin".to_owned(),
960 object_size: 128,
961 ..Default::default()
962 };
963 let stale = ColdChunkRef {
964 start_offset: 0,
965 end_offset: 128,
966 s3_path: "benchcmp/cold-index/chunks/stale.bin".to_owned(),
967 object_size: 128,
968 ..Default::default()
969 };
970 let newer = ColdChunkRef {
971 start_offset: 0,
972 end_offset: 128,
973 s3_path: "benchcmp/cold-index/chunks/newer.bin".to_owned(),
974 object_size: 128,
975 ..Default::default()
976 };
977 write_cold_chunk_index_pages(&store, &stream_id, &first)
978 .await
979 .expect("write first chunk");
980 let rollback = write_cold_chunk_index_pages_with_rollback(&store, &stream_id, &stale)
981 .await
982 .expect("write stale chunk");
983 write_cold_chunk_index_pages(&store, &stream_id, &newer)
984 .await
985 .expect("write newer chunk");
986
987 rollback_cold_index_pages(&store, rollback)
988 .await
989 .expect("rollback stale chunk");
990
991 let page = store
992 .get_page(&ColdIndexPageKey {
993 stream_id,
994 generation: 0,
995 page_id: 0,
996 })
997 .await
998 .expect("get page")
999 .expect("page exists");
1000 assert_eq!(page.cold_chunks, vec![newer]);
1001 }
1002
1003 #[tokio::test]
1004 async fn compact_replacement_rollback_restores_input_chunks() {
1005 let store = InMemoryColdIndexPageStore::new();
1006 let stream_id = BucketStreamId::new("benchcmp", "cold-index");
1007 let first = ColdChunkRef {
1008 start_offset: 0,
1009 end_offset: 64,
1010 s3_path: "benchcmp/cold-index/chunks/first.bin".to_owned(),
1011 object_size: 64,
1012 ..Default::default()
1013 };
1014 let second = ColdChunkRef {
1015 start_offset: 64,
1016 end_offset: 128,
1017 s3_path: "benchcmp/cold-index/chunks/second.bin".to_owned(),
1018 object_size: 64,
1019 ..Default::default()
1020 };
1021 let replacement = ColdChunkRef {
1022 start_offset: 0,
1023 end_offset: 128,
1024 s3_path: "benchcmp/cold-index/chunks/compacted.bin".to_owned(),
1025 object_size: 128,
1026 ..Default::default()
1027 };
1028 for chunk in [&first, &second] {
1029 write_cold_chunk_index_pages(&store, &stream_id, chunk)
1030 .await
1031 .expect("write input chunk");
1032 }
1033
1034 let rollback = replace_cold_chunk_index_pages_with_rollback(
1035 &store,
1036 &stream_id,
1037 &[first.clone(), second.clone()],
1038 &replacement,
1039 )
1040 .await
1041 .expect("replace chunks")
1042 .expect("inputs still match");
1043 rollback_cold_index_pages(&store, rollback)
1044 .await
1045 .expect("rollback replacement");
1046
1047 let page = store
1048 .get_page(&ColdIndexPageKey {
1049 stream_id,
1050 generation: 0,
1051 page_id: 0,
1052 })
1053 .await
1054 .expect("get page")
1055 .expect("page exists");
1056 assert_eq!(page.cold_chunks, vec![first, second]);
1057 }
1058
1059 #[tokio::test]
1060 async fn read_reload_repairs_stale_cached_page() {
1061 let store = Arc::new(InMemoryColdIndexPageStore::new());
1062 let stream_id = BucketStreamId::new("benchcmp", "cold-index");
1063 let cache = ColdIndexPageCache::new(store.clone(), 8);
1064 let first = ColdChunkRef {
1065 start_offset: 0,
1066 end_offset: 128,
1067 s3_path: "benchcmp/cold-index/chunks/first.bin".to_owned(),
1068 object_size: 128,
1069 ..Default::default()
1070 };
1071 write_cold_chunk_index_pages(store.as_ref(), &stream_id, &first)
1072 .await
1073 .expect("write first chunk");
1074 assert_eq!(
1075 cache
1076 .object_segments_for_read(&stream_id, &StreamReadColdIndexSegment {
1077 generation: 0,
1078 page_id: 0,
1079 read_start_offset: 0,
1080 len: 1,
1081 },)
1082 .await
1083 .expect("read first byte")
1084 .len(),
1085 1
1086 );
1087
1088 let second = ColdChunkRef {
1089 start_offset: 128,
1090 end_offset: 256,
1091 s3_path: "benchcmp/cold-index/chunks/second.bin".to_owned(),
1092 object_size: 128,
1093 ..Default::default()
1094 };
1095 write_cold_chunk_index_pages(store.as_ref(), &stream_id, &second)
1096 .await
1097 .expect("write second chunk behind cache");
1098
1099 let objects = cache
1100 .object_segments_for_read(&stream_id, &StreamReadColdIndexSegment {
1101 generation: 0,
1102 page_id: 0,
1103 read_start_offset: 128,
1104 len: 1,
1105 })
1106 .await
1107 .expect("reload stale page");
1108 assert_eq!(objects[0].s3_path, second.s3_path);
1109 }
1110
1111 #[test]
1112 fn binary_page_format_round_trips_and_validates() {
1113 let key = key(42);
1114 let mut page = page(128, 256);
1115 page.external_segments.push(ObjectPayloadRef {
1116 start_offset: 256,
1117 end_offset: 300,
1118 s3_path: "benchcmp/cold-index/external/00000000000000000256.bin".to_owned(),
1119 object_size: 44,
1120 ..Default::default()
1121 });
1122 let bytes = encode_page(&key, &page);
1123 assert!(bytes.starts_with(COLD_INDEX_PAGE_MAGIC));
1124
1125 assert_eq!(decode_page(&key, &bytes).expect("decode page"), page);
1126
1127 let mut corrupted = bytes.clone();
1128 let last = corrupted.last_mut().expect("checksum byte");
1129 *last ^= 0xff;
1130 let err = decode_page(&key, &corrupted).expect_err("corrupt checksum");
1131 assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1132
1133 let wrong_key = ColdIndexPageKey {
1134 stream_id: key.stream_id.clone(),
1135 generation: key.generation + 1,
1136 page_id: key.page_id,
1137 };
1138 let err = decode_page(&wrong_key, &bytes).expect_err("key mismatch");
1139 assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1140 }
1141
1142 #[tokio::test]
1143 async fn page_cache_loads_on_miss_and_evicts_lru() {
1144 let store = Arc::new(InMemoryColdIndexPageStore::new());
1145 for page_id in 0..3 {
1146 store
1147 .put_page(&key(page_id), &page(page_id * 100, page_id * 100 + 100))
1148 .await
1149 .expect("put page");
1150 }
1151 let cache = ColdIndexPageCache::new(store, 2);
1152
1153 assert_eq!(
1154 cache
1155 .get_page(&key(0))
1156 .await
1157 .expect("load page")
1158 .expect("page")
1159 .start_offset,
1160 0
1161 );
1162 assert_eq!(
1163 cache
1164 .get_page(&key(1))
1165 .await
1166 .expect("load page")
1167 .expect("page")
1168 .start_offset,
1169 100
1170 );
1171 assert_eq!(cache.cached_page_count(), 2);
1172
1173 assert!(
1175 cache
1176 .get_page(&key(0))
1177 .await
1178 .expect("cached page")
1179 .is_some()
1180 );
1181 assert_eq!(
1182 cache
1183 .get_page(&key(2))
1184 .await
1185 .expect("load page")
1186 .expect("page")
1187 .start_offset,
1188 200
1189 );
1190 assert_eq!(cache.cached_page_count(), 2);
1191 }
1192
1193 #[tokio::test]
1194 async fn zero_capacity_cache_does_not_retain_pages() {
1195 let store = Arc::new(InMemoryColdIndexPageStore::new());
1196 store
1197 .put_page(&key(0), &page(0, 64))
1198 .await
1199 .expect("put page");
1200 let cache = ColdIndexPageCache::new(store, 0);
1201
1202 assert!(cache.get_page(&key(0)).await.expect("load page").is_some());
1203 assert_eq!(cache.cached_page_count(), 0);
1204 }
1205
1206 #[tokio::test]
1207 async fn selects_and_replaces_target_sized_contiguous_chunks() {
1208 let store = InMemoryColdIndexPageStore::new();
1209 let stream_id = BucketStreamId::new("benchcmp", "compact");
1210 let chunks = (0..4)
1211 .map(|index| ColdChunkRef {
1212 start_offset: index * 2,
1213 end_offset: index * 2 + 2,
1214 object_size: 2,
1215 s3_path: format!("old-{index}"),
1216 ..Default::default()
1217 })
1218 .collect::<Vec<_>>();
1219 for chunk in &chunks {
1220 write_cold_chunk_index_pages(&store, &stream_id, chunk)
1221 .await
1222 .expect("write chunk index");
1223 }
1224 let selected =
1225 select_cold_chunk_compaction(&chunks, 8, 16).expect("select compaction candidate");
1226 assert_eq!(selected, chunks);
1227 let replacement = ColdChunkRef {
1228 start_offset: 0,
1229 end_offset: 8,
1230 object_size: 8,
1231 s3_path: "replacement".to_owned(),
1232 ..Default::default()
1233 };
1234 assert!(
1235 replace_cold_chunk_index_pages(&store, &stream_id, &selected, &replacement)
1236 .await
1237 .expect("replace chunks")
1238 );
1239 let loaded = load_cold_chunks_from_pages(&store, &[ColdIndexPageKey {
1240 stream_id,
1241 generation: 0,
1242 page_id: 0,
1243 }])
1244 .await
1245 .expect("load replacement");
1246 assert_eq!(loaded, vec![replacement]);
1247 }
1248}