Skip to main content

ursula_runtime/
cold_index.rs

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    /// Drops every cached generation/page for one stream. Compaction invokes
595    /// this on every replica when the replicated replacement command applies.
596    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
713/// Loads and de-duplicates the chunk references present in a stream's index
714/// pages. Chunks crossing a 64 MiB page boundary intentionally appear in more
715/// than one page.
716pub 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
742/// Selects the oldest contiguous run of undersized raw chunks whose combined
743/// payload reaches the byte target without exceeding the configured maximum.
744pub 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
780/// Atomically at the page-object level replaces a contiguous set of chunk
781/// references with one equivalent object. Every rewritten page always points
782/// at readable old or new bytes, so a retry after a partial S3 failure remains
783/// safe.
784pub 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        // Touch page 0 so page 1 becomes the eviction candidate.
1174        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}