infino 0.5.2

A fast retrieval engine that stores data on object storage and runs SQL, full-text search, and vector search over it from a single system — search-on-Parquet.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The Infino Authors

//! [`BlockCachedSource`] — block-granular NVMe retention for lazy
//! (range-GET-backed) superfile reads.
//!
//! The disk cache historically had exactly two states per superfile: a lazy
//! reader whose every `range()` was a fresh object-store GET (nothing
//! retained), or a fully-promoted mmap of the whole object. Between "first
//! touch" and "full promotion" the same byte ranges were re-fetched on every
//! query — and full promotion of everything cannot work once the table
//! outgrows local disk (a 1B-row index is TBs).
//!
//! This source is the missing middle state: reads through it land in a
//! sparse local file at fixed block granularity. A miss fetches one
//! block-aligned GET per contiguous missing run, writes it into the sparse
//! file, and marks the blocks filled; every later read of those bytes — from
//! any query on the shared cached reader — is a local `pread`, zero GETs.
//! Disk (and budget) cost is proportional to the *touched* working set, not
//! the object size, which is what lets the cache serve tables far larger
//! than local disk.
//!
//! Budget integration: each newly filled run reserves its bytes against the
//! owning [`DiskCacheStore`]'s budget (with LRU eviction pressure) *before*
//! fetching, and the entry's shared `size_bytes` counter grows as blocks
//! land — so eviction sees a lazy entry's true footprint. On budget
//! exhaustion, or once this source's cache entry has been replaced (eviction
//! / mmap promotion), reads degrade to plain uncached passthrough instead of
//! failing. The source releases its accounted bytes and unlinks its file on
//! `Drop` (i.e. when the last in-flight reader over it goes away).

use std::{
    fs,
    os::unix::fs::FileExt,
    path::PathBuf,
    sync::{
        Arc, Mutex, OnceLock, Weak,
        atomic::{AtomicU64, Ordering},
    },
};

use async_trait::async_trait;
use bytes::Bytes;
use memmap2::Mmap;
use roaring::RoaringBitmap;

use super::disk::{ArcMmapOwner, DiskCacheStore};
use crate::{
    superfile::{LazyByteSource, LazyByteSourceError},
    supertable::manifest::SuperfileUri,
};

/// Cache block size. Misses fetch block-aligned runs, so this bounds both
/// the read amplification of a small scan (a request pays at most one
/// leading + one trailing partial block of overhead) and the bitmap size
/// (a 32 MiB cell superfile is 64 blocks; a 27 GiB one at 1B scale is
/// ~55K). Post-drain vector queries read ~0.25–2 MiB scan ranges, so
/// 512 KiB keeps first-touch overshoot well under 2× while still
/// coalescing a multi-MiB scan into a handful of blocks.
const CACHE_BLOCK_BYTES: u64 = 512 * 1024;

/// Lazily-initialized sparse backing file. Created on the first cached read
/// (the object size may only be known after the open-time `tail()` on
/// unknown-size sources). `None` means creation failed once — the source
/// then serves plain passthrough reads forever (cache disabled, not broken).
struct BlockFile {
    file: fs::File,
    size: u64,
    /// Lazily-created read-only mapping for zero-copy hit service.
    /// Inner `None` = mapping failed once; keep serving via pread.
    mmap: OnceLock<Option<Arc<Mmap>>>,
}

/// Block-caching wrapper around a network-backed [`LazyByteSource`].
/// See the module docs for semantics.
pub(crate) struct BlockCachedSource {
    inner: Arc<dyn LazyByteSource>,
    store: Weak<DiskCacheStore>,
    uri: SuperfileUri,
    path: PathBuf,
    /// Distinguishes this source from a replacement entry for the same URI.
    entry_token: Arc<()>,
    /// Whether this source reserves and releases touched-block bytes itself.
    owns_accounting: bool,
    /// Virtual hole: `(offset, len)` of a subsection whose reads bypass the
    /// block cache and fetch exact ranges from the inner source. Set to the
    /// FTS subsection: posting reads are ~KiB-sized and scattered, so
    /// rounding each to a 512 KiB block over-fetches ~200× per read on a
    /// wide OR (the block size is tuned for vector's 0.25–2 MiB scans).
    /// Their warm locality comes from the background-fill mmap promotion,
    /// not from this cache. Reads only partially overlapping the hole keep
    /// block semantics.
    passthrough: Option<(u64, u64)>,
    state: OnceLock<Option<BlockFile>>,
    /// Filled-block set. Guarded by a sync mutex; never held across await.
    filled: Mutex<RoaringBitmap>,
    /// Bytes of filled blocks — shared with the owning `CachedEntry`'s
    /// `size_bytes`, so eviction candidates report a lazy entry's real
    /// footprint as it grows.
    filled_bytes: Arc<AtomicU64>,
}

impl BlockCachedSource {
    #[cfg(test)]
    pub(crate) fn new(
        inner: Arc<dyn LazyByteSource>,
        store: Weak<DiskCacheStore>,
        uri: SuperfileUri,
        path: PathBuf,
    ) -> Arc<Self> {
        Self::new_with_accounting(inner, store, uri, path, true, None)
    }

    /// Construct a sparse source whose owning entry has already reserved the
    /// complete object size. `passthrough` is the optional exact-read hole
    /// (see the field docs) — the FTS subsection on the cold-open path.
    pub(crate) fn new_pre_reserved(
        inner: Arc<dyn LazyByteSource>,
        store: Weak<DiskCacheStore>,
        uri: SuperfileUri,
        path: PathBuf,
        passthrough: Option<(u64, u64)>,
    ) -> Arc<Self> {
        Self::new_with_accounting(inner, store, uri, path, false, passthrough)
    }

    fn new_with_accounting(
        inner: Arc<dyn LazyByteSource>,
        store: Weak<DiskCacheStore>,
        uri: SuperfileUri,
        path: PathBuf,
        owns_accounting: bool,
        passthrough: Option<(u64, u64)>,
    ) -> Arc<Self> {
        Arc::new(Self {
            inner,
            store,
            uri,
            path,
            entry_token: Arc::new(()),
            owns_accounting,
            passthrough,
            state: OnceLock::new(),
            filled: Mutex::new(RoaringBitmap::new()),
            filled_bytes: Arc::new(AtomicU64::new(0)),
        })
    }

    /// Whether `[start, start + len)` lies fully inside the passthrough hole.
    fn in_passthrough(&self, start: u64, len: u64) -> bool {
        match self.passthrough {
            Some((off, hole_len)) => start >= off && start + len <= off + hole_len,
            None => false,
        }
    }

    /// Identity token installed on the cache entry that owns this source.
    pub(crate) fn entry_token(&self) -> Arc<()> {
        Arc::clone(&self.entry_token)
    }

    /// Shared filled-bytes counter, installed as the cache entry's
    /// `size_bytes` so accounting and eviction see live growth.
    #[cfg(test)]
    pub(crate) fn filled_bytes_handle(&self) -> Arc<AtomicU64> {
        Arc::clone(&self.filled_bytes)
    }

    /// The sparse file, created on first use once the object size is known.
    fn block_file(&self) -> Option<&BlockFile> {
        let size = self.inner.size();
        if size == 0 {
            // Size not discovered yet (pre-`tail()` on an unknown-size
            // source) — don't latch the OnceLock; try again next read.
            return None;
        }
        self.state
            .get_or_init(|| {
                // Publish a FRESH inode rather than truncating in place. Remove
                // the old name first: a reader still holding the previous
                // `.blocks` inode keeps its bytes (POSIX unlink-while-open),
                // while this source gets its own inode to fill. `create_new`
                // then refuses to reopen an existing inode, so a concurrent
                // same-path source fails cleanly (degrading to a passthrough
                // read) instead of truncating a live reader's blocks.
                let _ = fs::remove_file(&self.path);
                let file = fs::OpenOptions::new()
                    .read(true)
                    .write(true)
                    .create_new(true)
                    .open(&self.path)
                    .ok()?;
                file.set_len(size).ok()?;
                Some(BlockFile {
                    file,
                    size,
                    mmap: OnceLock::new(),
                })
            })
            .as_ref()
    }

    /// Byte length of block `b` (the trailing block may be partial).
    fn block_len(size: u64, b: u32) -> u64 {
        let start = u64::from(b) * CACHE_BLOCK_BYTES;
        (size - start).min(CACHE_BLOCK_BYTES)
    }

    /// Inclusive block index range covering `[start, start + len)`.
    fn block_span(start: u64, len: u64) -> (u32, u32) {
        let b0 = start / CACHE_BLOCK_BYTES;
        let b1 = (start + len - 1) / CACHE_BLOCK_BYTES;
        (b0 as u32, b1 as u32)
    }

    fn all_filled(&self, b0: u32, b1: u32) -> bool {
        let filled = self.filled.lock().expect("filled bitmap mutex poisoned");
        (b0..=b1).all(|b| filled.contains(b))
    }

    /// Contiguous runs of not-yet-filled blocks within `[b0, b1]`.
    fn missing_runs(&self, b0: u32, b1: u32) -> Vec<(u32, u32)> {
        let filled = self.filled.lock().expect("filled bitmap mutex poisoned");
        let mut runs = Vec::new();
        let mut run_start: Option<u32> = None;
        for b in b0..=b1 {
            if filled.contains(b) {
                if let Some(s) = run_start.take() {
                    runs.push((s, b - 1));
                }
            } else if run_start.is_none() {
                run_start = Some(b);
            }
        }
        if let Some(s) = run_start {
            runs.push((s, b1));
        }
        runs
    }

    /// Mark `[b0, b1]` filled; returns the byte count of blocks that were
    /// NEWLY marked (a concurrent filler may have raced us on some).
    fn mark_filled(&self, size: u64, b0: u32, b1: u32) -> u64 {
        let mut filled = self.filled.lock().expect("filled bitmap mutex poisoned");
        let mut newly = 0u64;
        for b in b0..=b1 {
            if filled.insert(b) {
                newly += Self::block_len(size, b);
            }
        }
        newly
    }

    /// Serve `[start, start+len)` from the sparse file. `None` on a read
    /// error (caller degrades to passthrough).
    fn read_local(&self, bf: &BlockFile, start: u64, len: u64) -> Option<Bytes> {
        // Zero-copy hit service: filled ranges are handed out as slices of
        // one shared read-only mapping instead of alloc+pread per range. At
        // law width a warm vector query reads ~20 MB through here; the
        // per-range alloc+zero+pread copies were the measured ~6-7 ms
        // prefix-service floor of phase A (and the residual copy cost in
        // the deferred rerank's exact-range gather). Slices keep the
        // mapping alive after eviction, exactly like the promoted
        // parquet/FTS mmaps.
        let mapped = bf.mmap.get_or_init(|| {
            // SAFETY: the blocks file is pre-sized at creation
            // (`file.set_len(size)`) and only ever written through
            // `write_all_at` within that size, so a mapping of the full
            // file never outruns it (no SIGBUS); the read-only shared
            // mapping stays page-cache-coherent with those writes, and
            // reads of not-yet-filled blocks are gated by `all_filled`
            // before this method runs.
            unsafe { Mmap::map(&bf.file) }.ok().map(Arc::new)
        });
        if let Some(m) = mapped.as_ref() {
            let s = usize::try_from(start).ok()?;
            let e = s.checked_add(usize::try_from(len).ok()?)?;
            if e <= m.len() {
                return Some(Bytes::from_owner(ArcMmapOwner(Arc::clone(m))).slice(s..e));
            }
        }
        let mut out = vec![0u8; len as usize];
        bf.file.read_exact_at(&mut out, start).ok()?;
        Some(Bytes::from(out))
    }

    /// Fill every missing block covering the request, reserving budget per
    /// run and settling duplicate-fill accounting. Returns `false` if the
    /// read should degrade to passthrough (budget exhausted, entry replaced,
    /// store gone, or local file I/O failed).
    async fn fill_missing(
        &self,
        bf: &BlockFile,
        b0: u32,
        b1: u32,
    ) -> Result<bool, LazyByteSourceError> {
        let Some(store) = self.store.upgrade() else {
            return Ok(false);
        };
        // Only the source installed in the live cache entry accounts bytes:
        // after eviction or mmap promotion replaced the entry, keep serving
        // already-filled blocks but stop growing the footprint.
        if !store.lazy_block_entry_is_current(&self.uri, &self.entry_token) {
            return Ok(false);
        }
        for (rb0, rb1) in self.missing_runs(b0, b1) {
            let run_start = u64::from(rb0) * CACHE_BLOCK_BYTES;
            let run_end = (u64::from(rb1) + 1) * CACHE_BLOCK_BYTES;
            let run_len = run_end.min(bf.size) - run_start;
            if self.owns_accounting && store.reserve_block_bytes(run_len).await.is_err() {
                // Budget pressure with no evictable victims: serve uncached.
                return Ok(false);
            }
            let bytes = match self.inner.range(run_start, run_len).await {
                Ok(b) => b,
                Err(e) => {
                    if self.owns_accounting {
                        store.release_block_bytes(run_len);
                    }
                    return Err(e);
                }
            };
            if bf.file.write_all_at(&bytes, run_start).is_err() {
                if self.owns_accounting {
                    store.release_block_bytes(run_len);
                }
                return Ok(false);
            }
            let newly = self.mark_filled(bf.size, rb0, rb1);
            self.filled_bytes.fetch_add(newly, Ordering::AcqRel);
            if self.owns_accounting && newly < run_len {
                // A concurrent filler beat us to some blocks; its accounting
                // stands, ours is released.
                store.release_block_bytes(run_len - newly);
            }
        }
        Ok(true)
    }
}

impl Drop for BlockCachedSource {
    fn drop(&mut self) {
        // Last reader over this source is gone (entry evicted/replaced and
        // no in-flight queries): release the accounted bytes and remove the
        // sparse file. The store may already be gone at process teardown.
        if self.owns_accounting
            && let Some(store) = self.store.upgrade()
        {
            let filled = self.filled_bytes.load(Ordering::Acquire);
            if filled > 0 {
                store.release_block_bytes(filled);
            }
        }
        if self.state.get().is_some_and(|s| s.is_some()) {
            let _ = fs::remove_file(&self.path);
        }
    }
}

#[async_trait]
impl LazyByteSource for BlockCachedSource {
    fn size(&self) -> u64 {
        self.inner.size()
    }

    async fn range(&self, start: u64, len: u64) -> Result<Bytes, LazyByteSourceError> {
        if len == 0 {
            return Ok(Bytes::new());
        }
        if self.in_passthrough(start, len) {
            return self.inner.range(start, len).await;
        }
        let Some(bf) = self.block_file() else {
            return self.inner.range(start, len).await;
        };
        if start.saturating_add(len) > bf.size {
            // Out-of-bounds: let the inner source surface its typed error.
            return self.inner.range(start, len).await;
        }
        let (b0, b1) = Self::block_span(start, len);
        if !self.all_filled(b0, b1) && !self.fill_missing(bf, b0, b1).await? {
            return self.inner.range(start, len).await;
        }
        match self.read_local(bf, start, len) {
            Some(bytes) => Ok(bytes),
            None => self.inner.range(start, len).await,
        }
    }

    fn try_get_range_sync(&self, start: u64, len: u64) -> Option<Bytes> {
        if len == 0 {
            return Some(Bytes::new());
        }
        if self.in_passthrough(start, len) {
            return self.inner.try_get_range_sync(start, len);
        }
        let bf = self.block_file()?;
        if start.saturating_add(len) > bf.size {
            return None;
        }
        let (b0, b1) = Self::block_span(start, len);
        if !self.all_filled(b0, b1) {
            return self.inner.try_get_range_sync(start, len);
        }
        self.read_local(bf, start, len)
    }

    async fn tail(&self, len: u64) -> Result<(Bytes, u64), LazyByteSourceError> {
        // Pass through: `tail` both fetches and (on unknown-size sources)
        // discovers the object size, which the inner source caches. Tail
        // bytes are open-time metadata already retained by the open-blob /
        // prefetch overlay above this source, so caching them here as
        // (mostly partial) blocks buys nothing.
        self.inner.tail(len).await
    }
}

#[cfg(test)]
mod tests {
    use std::sync::atomic::AtomicUsize;

    use tempfile::tempdir;

    use super::*;
    use crate::supertable::reader_cache::{ColdFetchMode, DiskCacheConfig, LruPolicy};

    /// In-memory fake source that counts `range` calls.
    struct CountingSource {
        blob: Bytes,
        calls: AtomicUsize,
    }

    impl CountingSource {
        fn new(n: usize) -> Self {
            let blob: Vec<u8> = (0..n).map(|i| (i % 251) as u8).collect();
            Self {
                blob: Bytes::from(blob),
                calls: AtomicUsize::new(0),
            }
        }

        fn calls(&self) -> usize {
            self.calls.load(Ordering::Acquire)
        }
    }

    #[async_trait]
    impl LazyByteSource for CountingSource {
        fn size(&self) -> u64 {
            self.blob.len() as u64
        }

        async fn range(&self, start: u64, len: u64) -> Result<Bytes, LazyByteSourceError> {
            self.calls.fetch_add(1, Ordering::AcqRel);
            let (s, e) = (start as usize, (start + len) as usize);
            if e > self.blob.len() {
                return Err(LazyByteSourceError::OutOfBounds {
                    start,
                    len,
                    size: self.blob.len() as u64,
                });
            }
            Ok(self.blob.slice(s..e))
        }
    }

    /// A store whose budget admits everything; `noop_storage` is never hit
    /// because the block source's inner fake serves all reads.
    fn test_store(dir: &std::path::Path, budget: u64) -> Arc<DiskCacheStore> {
        use std::{ops::Range, time::SystemTime};

        use object_store::MultipartUpload;

        use crate::storage::{ObjectMeta, StorageError, StorageProvider};

        #[derive(Debug)]
        struct NoopStorage;

        fn unimplemented_err(uri: &str) -> StorageError {
            StorageError::Permanent {
                uri: uri.into(),
                source: "noop storage".into(),
            }
        }

        #[async_trait]
        impl StorageProvider for NoopStorage {
            async fn head(&self, uri: &str) -> Result<ObjectMeta, StorageError> {
                let _ = uri;
                Ok(ObjectMeta {
                    size: 0,
                    etag: None,
                    last_modified: SystemTime::UNIX_EPOCH,
                })
            }
            async fn get(&self, uri: &str) -> Result<(Bytes, ObjectMeta), StorageError> {
                Err(unimplemented_err(uri))
            }
            async fn get_range(&self, uri: &str, _r: Range<u64>) -> Result<Bytes, StorageError> {
                Err(unimplemented_err(uri))
            }
            async fn put_atomic(
                &self,
                uri: &str,
                _b: Bytes,
            ) -> Result<Option<String>, StorageError> {
                Err(unimplemented_err(uri))
            }
            async fn put_if_match(
                &self,
                uri: &str,
                _b: Bytes,
                _etag: Option<&str>,
            ) -> Result<Option<String>, StorageError> {
                Err(unimplemented_err(uri))
            }
            async fn put_multipart(
                &self,
                uri: &str,
            ) -> Result<Box<dyn MultipartUpload>, StorageError> {
                Err(unimplemented_err(uri))
            }
            async fn delete(&self, _uri: &str) -> Result<(), StorageError> {
                Ok(())
            }
        }

        let cfg = DiskCacheConfig {
            cache_root: dir.to_path_buf(),
            disk_budget_bytes: budget,
            cold_fetch_mode: ColdFetchMode::LazyForegroundWithBackgroundFill,
            eviction: Box::new(LruPolicy::new()),
            ..DiskCacheConfig::default()
        };
        DiskCacheStore::new_unpinned(Arc::new(NoopStorage), cfg).expect("test store")
    }

    /// Second read of the same bytes never touches the inner source, the
    /// returned bytes are identical, and accounting matches the touched
    /// block footprint (not the object size).
    #[tokio::test]
    async fn repeat_reads_are_served_locally_and_accounted_by_blocks() {
        const OBJ: usize = 3 * CACHE_BLOCK_BYTES as usize + 1000;
        let dir = tempdir().expect("tempdir");
        let store = test_store(dir.path(), u64::MAX);
        let uri = SuperfileUri::new_v4();
        let inner = Arc::new(CountingSource::new(OBJ));
        let src = BlockCachedSource::new(
            Arc::clone(&inner) as Arc<dyn LazyByteSource>,
            Arc::downgrade(&store),
            uri,
            dir.path().join("t.blocks"),
        );
        // Install the source as current for its (synthetic) entry.
        store.install_block_entry_for_test(uri, src.filled_bytes_handle(), src.entry_token());

        // Read spanning blocks 0..=2 (one contiguous missing run → 1 GET).
        let start = 100u64;
        let len = 2 * CACHE_BLOCK_BYTES + 500;
        let first = src.range(start, len).await.expect("first read");
        assert_eq!(first, inner.blob.slice(100..(start + len) as usize));
        assert_eq!(inner.calls(), 1, "one block-run GET for the miss");

        // Identical read → zero inner calls.
        let second = src.range(start, len).await.expect("second read");
        assert_eq!(second, first);
        assert_eq!(inner.calls(), 1, "repeat read must not touch the source");

        // Sub-range and sync reads also come from local blocks.
        let sub = src.range(start + 10, 100).await.expect("sub read");
        assert_eq!(sub, inner.blob.slice(110..210));
        let sync = src
            .try_get_range_sync(start + 10, 100)
            .expect("sync read of filled blocks");
        assert_eq!(sync, sub);
        assert_eq!(inner.calls(), 1);

        // Accounting = 3 whole blocks (0..=2), not the object size.
        let expected = 3 * CACHE_BLOCK_BYTES;
        assert_eq!(src.filled_bytes_handle().load(Ordering::Acquire), expected);
        assert_eq!(store.stats().current_bytes, expected);

        // Touch the trailing partial block: its length is size - 3*B.
        let tail_start = 3 * CACHE_BLOCK_BYTES + 10;
        let t = src.range(tail_start, 50).await.expect("tail block read");
        assert_eq!(
            t,
            inner
                .blob
                .slice(tail_start as usize..tail_start as usize + 50)
        );
        assert_eq!(inner.calls(), 2);
        assert_eq!(
            src.filled_bytes_handle().load(Ordering::Acquire),
            expected + 1000,
            "trailing partial block accounts its real length"
        );

        // Drop the source: accounting released, blocks file unlinked.
        let path = dir.path().join("t.blocks");
        assert!(path.exists());
        store.remove_block_entry_for_test(&uri);
        drop(src);
        assert_eq!(store.stats().current_bytes, 0);
        assert!(!path.exists());
    }

    /// Regression for the transient `Required field type_ is missing` decode
    /// crash: two sources for the same superfile share a deterministic
    /// `.blocks` path. The second source's `block_file()` opens with
    /// `truncate(true)`, zeroing the sparse file the first still holds open —
    /// so the first, still trusting its "filled" bitmap, reads zeros where it
    /// had cached real bytes. (In production the second source is a
    /// post-eviction cold refetch of a superfile a long scan still holds; the
    /// zeros land where a Parquet Thrift tag belongs and the decode panics.)
    ///
    /// The second source reads a DISJOINT range so its truncate does not
    /// coincidentally refill the first source's blocks. Fails today (the first
    /// reader reads zeros); passes once eviction unlinks the `.blocks` file so
    /// the refetch gets a fresh inode and the live reader keeps its own.
    #[tokio::test]
    async fn stale_shared_blocks_file_must_not_corrupt_live_reader() {
        const OBJ: usize = 4 * CACHE_BLOCK_BYTES as usize + 1000;
        let dir = tempdir().expect("tempdir");
        let store = test_store(dir.path(), u64::MAX);
        let uri = SuperfileUri::new_v4();
        // Both sources map to the same per-URI scratch path (the bug).
        let path = dir.path().join("shared.blocks");

        // Reader A becomes the current entry, fills blocks 0..=2 onto the
        // shared file, and stays alive (a long scan holding it).
        let inner_a = Arc::new(CountingSource::new(OBJ));
        let a = BlockCachedSource::new(
            Arc::clone(&inner_a) as Arc<dyn LazyByteSource>,
            Arc::downgrade(&store),
            uri,
            path.clone(),
        );
        store.install_block_entry_for_test(uri, a.filled_bytes_handle(), a.entry_token());
        let start = 100u64;
        let len = 2 * CACHE_BLOCK_BYTES + 500;
        let want = inner_a.blob.slice(start as usize..(start + len) as usize);
        let a_first = a.range(start, len).await.expect("A first read");
        assert_eq!(a_first, want, "A reads correct bytes before the collision");
        assert_eq!(inner_a.calls(), 1, "A's blocks are on the shared file");

        // Eviction removes the catalog entry but leaves the `.blocks` file on
        // disk (today's bug); reader A is still held by its long scan.
        store.remove_block_entry_for_test(&uri);

        // Reader B is the post-eviction refetch: same uri, same path, becomes
        // current, and fills only the trailing block — but its `block_file()`
        // truncates the shared inode A still holds open first.
        let inner_b = Arc::new(CountingSource::new(OBJ));
        let b = BlockCachedSource::new(
            Arc::clone(&inner_b) as Arc<dyn LazyByteSource>,
            Arc::downgrade(&store),
            uri,
            path.clone(),
        );
        store.install_block_entry_for_test(uri, b.filled_bytes_handle(), b.entry_token());
        let tail = 3 * CACHE_BLOCK_BYTES + 10;
        let _ = b
            .range(tail, 50)
            .await
            .expect("B read (truncates shared file)");

        // A re-reads the range it already cached. Its bitmap still says filled,
        // so it serves from the now-truncated shared file.
        let a_again = a.range(start, len).await.expect("A re-read");
        assert_eq!(
            a_again, want,
            "live reader A must still serve its cached bytes, not zeros"
        );
    }

    /// Two disjoint missing runs in one request → one GET per run.
    #[tokio::test]
    async fn disjoint_missing_runs_fetch_separately() {
        const OBJ: usize = 6 * CACHE_BLOCK_BYTES as usize;
        let dir = tempdir().expect("tempdir");
        let store = test_store(dir.path(), u64::MAX);
        let uri = SuperfileUri::new_v4();
        let inner = Arc::new(CountingSource::new(OBJ));
        let src = BlockCachedSource::new(
            Arc::clone(&inner) as Arc<dyn LazyByteSource>,
            Arc::downgrade(&store),
            uri,
            dir.path().join("runs.blocks"),
        );
        store.install_block_entry_for_test(uri, src.filled_bytes_handle(), src.entry_token());

        // Fill block 2 first.
        let b = CACHE_BLOCK_BYTES;
        let _ = src.range(2 * b, 10).await.expect("fill middle block");
        assert_eq!(inner.calls(), 1);

        // Read blocks 1..=3: blocks 1 and 3 are missing → two run GETs.
        let got = src.range(b, 3 * b).await.expect("spanning read");
        assert_eq!(got, inner.blob.slice(b as usize..(4 * b) as usize));
        assert_eq!(inner.calls(), 3, "two missing runs around the filled block");

        store.remove_block_entry_for_test(&uri);
    }

    /// When the entry is no longer current (evicted/promoted), reads still
    /// succeed as passthrough and accounting stops growing.
    #[tokio::test]
    async fn stale_entry_degrades_to_passthrough() {
        const OBJ: usize = 2 * CACHE_BLOCK_BYTES as usize;
        let dir = tempdir().expect("tempdir");
        let store = test_store(dir.path(), u64::MAX);
        let uri = SuperfileUri::new_v4();
        let inner = Arc::new(CountingSource::new(OBJ));
        let src = BlockCachedSource::new(
            Arc::clone(&inner) as Arc<dyn LazyByteSource>,
            Arc::downgrade(&store),
            uri,
            dir.path().join("stale.blocks"),
        );
        // Never installed as current → every miss is passthrough.
        let a = src.range(0, 64).await.expect("passthrough read");
        let bb = src.range(0, 64).await.expect("passthrough read again");
        assert_eq!(a, bb);
        assert_eq!(inner.calls(), 2, "uncached passthrough on both reads");
        assert_eq!(src.filled_bytes_handle().load(Ordering::Acquire), 0);
        assert_eq!(store.stats().current_bytes, 0);
    }
}