use std::path::Path;
use tokio::fs::File;
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use super::{parse_big_index_entry, PartitionIndexEntry};
use crate::error::Result;
const REFILL_CHUNK: usize = 64 * 1024;
pub(crate) struct IndexEntryStream {
file: File,
buf: Vec<u8>,
cursor: usize,
eof: bool,
entries_touched: usize,
truncated_tail: bool,
}
impl IndexEntryStream {
pub(crate) async fn open(index_path: &Path, start_position: u64) -> Result<Self> {
let mut file = File::open(index_path).await?;
if start_position > 0 {
file.seek(std::io::SeekFrom::Start(start_position)).await?;
}
Ok(Self {
file,
buf: Vec::new(),
cursor: 0,
eof: false,
entries_touched: 0,
truncated_tail: false,
})
}
#[cfg(test)]
pub(crate) fn entries_touched(&self) -> usize {
self.entries_touched
}
pub(crate) fn truncated_tail(&self) -> bool {
self.truncated_tail
}
async fn refill(&mut self) -> Result<usize> {
if self.cursor > 0 {
self.buf.drain(0..self.cursor);
self.cursor = 0;
}
let prev_len = self.buf.len();
self.buf.resize(prev_len + REFILL_CHUNK, 0);
let n = self.file.read(&mut self.buf[prev_len..]).await?;
self.buf.truncate(prev_len + n);
if n == 0 {
self.eof = true;
}
Ok(n)
}
pub(crate) async fn next(&mut self) -> Result<Option<PartitionIndexEntry>> {
loop {
let parsed = {
let slice = &self.buf[self.cursor..];
if slice.is_empty() {
None
} else {
match parse_big_index_entry(slice) {
Ok((rest, entry)) => Some((slice.len() - rest.len(), entry)),
Err(_) => None,
}
}
};
if let Some((consumed, entry)) = parsed {
if consumed == 0 {
self.truncated_tail = self.cursor < self.buf.len();
return Ok(None);
}
self.cursor += consumed;
self.entries_touched += 1;
return Ok(Some(entry));
}
if self.eof {
self.truncated_tail = self.cursor < self.buf.len();
return Ok(None);
}
if self.refill().await? == 0 {
continue;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn encode_entry(key: &[u8], data_offset: u64) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&(key.len() as u16).to_be_bytes());
out.extend_from_slice(key);
out.extend_from_slice(&crate::parser::vint::encode_vuint(data_offset));
out.extend_from_slice(&crate::parser::vint::encode_vuint(0));
out
}
fn write_temp(name: &str, bytes: &[u8]) -> (std::path::PathBuf, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!(
"cqlite-2412-stream-{name}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("nb-1-big-Index.db");
std::fs::write(&path, bytes).unwrap();
(dir, path)
}
async fn drain(path: &Path, start: u64) -> (Vec<PartitionIndexEntry>, bool, usize) {
let mut s = IndexEntryStream::open(path, start).await.unwrap();
let mut out = Vec::new();
while let Some(e) = s.next().await.unwrap() {
out.push(e);
}
(out, s.truncated_tail(), s.entries_touched())
}
#[tokio::test]
async fn streams_every_entry_in_order_from_offset_zero() {
let mut bytes = Vec::new();
for (k, off) in [
(b"aaaa".as_slice(), 0u64),
(b"bbbb", 100),
(b"cccc", 250),
(b"dddd", 400),
] {
bytes.extend_from_slice(&encode_entry(k, off));
}
let (dir, path) = write_temp("order", &bytes);
let (entries, truncated, touched) = drain(&path, 0).await;
assert_eq!(entries.len(), 4);
assert_eq!(entries[0].key_digest.as_ref(), b"aaaa");
assert_eq!(entries[0].data_offset, 0);
assert_eq!(entries[3].key_digest.as_ref(), b"dddd");
assert_eq!(entries[3].data_offset, 400);
assert!(
!truncated,
"a clean stream must not report a truncated tail"
);
assert_eq!(touched, 4);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn seeking_to_a_sample_offset_starts_mid_file() {
let first = encode_entry(b"before", 0);
let rest = {
let mut v = Vec::new();
v.extend_from_slice(&encode_entry(b"target", 900));
v.extend_from_slice(&encode_entry(b"after", 950));
v
};
let mut bytes = first.clone();
bytes.extend_from_slice(&rest);
let (dir, path) = write_temp("seek", &bytes);
let (entries, _truncated, _touched) = drain(&path, first.len() as u64).await;
assert_eq!(entries.len(), 2, "seek skips the first entry");
assert_eq!(entries[0].key_digest.as_ref(), b"target");
assert_eq!(entries[1].key_digest.as_ref(), b"after");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn truncated_tail_is_reported() {
let mut bytes = encode_entry(b"whole", 10);
bytes.extend_from_slice(&[0x00, 0x40]);
let (dir, path) = write_temp("trunc", &bytes);
let (entries, truncated, touched) = drain(&path, 0).await;
assert_eq!(entries.len(), 1, "only the one whole entry is yielded");
assert!(truncated, "the dangling tail must be flagged truncated");
assert_eq!(touched, 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn streams_across_refill_boundaries() {
let n = 4000usize;
let mut bytes = Vec::new();
for i in 0..n {
let key = format!("key{i:08}");
bytes.extend_from_slice(&encode_entry(key.as_bytes(), (i * 10) as u64));
}
assert!(bytes.len() > REFILL_CHUNK, "fixture must span >1 refill");
let (dir, path) = write_temp("refill", &bytes);
let (entries, truncated, touched) = drain(&path, 0).await;
assert_eq!(entries.len(), n);
assert_eq!(entries[n - 1].data_offset, ((n - 1) * 10) as u64);
assert!(!truncated);
assert_eq!(touched, n);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn empty_file_is_a_clean_empty_stream() {
let (dir, path) = write_temp("empty", &[]);
let (entries, truncated, touched) = drain(&path, 0).await;
assert!(entries.is_empty());
assert!(!truncated, "a zero-length file is not a truncated tail");
assert_eq!(touched, 0);
let _ = std::fs::remove_dir_all(&dir);
}
}