Skip to main content

ursula_runtime/
cold_index.rs

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