use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
fn gen_path(dir: &Path, shard: usize) -> PathBuf {
dir.join(format!("feed-{shard}.gen"))
}
fn meta_path(dir: &Path, shard: usize) -> PathBuf {
dir.join(format!("feed-{shard}.meta"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FeedBoot {
pub generation: u64,
pub next_offset: u64,
}
pub fn write_feed_gen(dir: &Path, shard: usize, generation: u64) -> io::Result<()> {
let tmp = dir.join(format!("feed-{shard}.gen.tmp"));
{
let mut f = fs::File::create(&tmp)?;
f.write_all(generation.to_string().as_bytes())?;
f.sync_all()?;
}
fs::rename(&tmp, gen_path(dir, shard))?;
Ok(())
}
pub fn write_feed_meta(dir: &Path, shard: usize, generation: u64, next_offset: u64) -> io::Result<()> {
let tmp = dir.join(format!("feed-{shard}.meta.tmp"));
{
let mut f = fs::File::create(&tmp)?;
f.write_all(format!("{generation} {next_offset}").as_bytes())?;
f.sync_all()?;
}
fs::rename(&tmp, meta_path(dir, shard))?;
Ok(())
}
fn read_meta(dir: &Path, shard: usize) -> Option<(u64, u64)> {
let s = fs::read_to_string(meta_path(dir, shard)).ok()?;
let mut it = s.split_whitespace();
let g = it.next()?.parse().ok()?;
let o = it.next()?.parse().ok()?;
Some((g, o))
}
pub fn load_feed_boot(dir: &Path, shard: usize) -> io::Result<FeedBoot> {
let highwater: Option<u64> = fs::read_to_string(gen_path(dir, shard))
.ok()
.and_then(|s| s.trim().parse().ok());
let marker = read_meta(dir, shard);
let _ = fs::remove_file(meta_path(dir, shard));
let boot = match (highwater, marker) {
(None, _) => FeedBoot { generation: 1, next_offset: 0 },
(Some(g), Some((mg, off))) if mg == g => FeedBoot { generation: g, next_offset: off },
(Some(g), _) => FeedBoot { generation: g + 1, next_offset: 0 },
};
if Some(boot.generation) != highwater {
write_feed_gen(dir, shard, boot.generation)?;
}
Ok(boot)
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp() -> PathBuf {
let d = std::env::temp_dir().join(format!(
"kevy-feedmeta-{}-{:?}",
std::process::id(),
std::time::Instant::now()
));
fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn fresh_dir_starts_at_gen_one() {
let d = tmp();
assert_eq!(load_feed_boot(&d, 0).unwrap(), FeedBoot { generation: 1, next_offset: 0 });
assert_eq!(fs::read_to_string(d.join("feed-0.gen")).unwrap(), "1");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn clean_shutdown_resumes_cursor() {
let d = tmp();
let b = load_feed_boot(&d, 0).unwrap();
write_feed_meta(&d, 0, b.generation, 42).unwrap();
let b2 = load_feed_boot(&d, 0).unwrap();
assert_eq!(b2, FeedBoot { generation: 1, next_offset: 42 });
let b3 = load_feed_boot(&d, 0).unwrap();
assert_eq!(b3, FeedBoot { generation: 2, next_offset: 0 });
let _ = fs::remove_dir_all(&d);
}
#[test]
fn unclean_boot_bumps_and_persists_highwater() {
let d = tmp();
let _ = load_feed_boot(&d, 0).unwrap(); let b = load_feed_boot(&d, 0).unwrap();
assert_eq!(b.generation, 2);
assert_eq!(fs::read_to_string(d.join("feed-0.gen")).unwrap(), "2");
let _ = fs::remove_dir_all(&d);
}
#[test]
fn mismatched_marker_bumps() {
let d = tmp();
let _ = load_feed_boot(&d, 0).unwrap(); write_feed_meta(&d, 0, 99, 7).unwrap(); let b = load_feed_boot(&d, 0).unwrap();
assert_eq!(b, FeedBoot { generation: 2, next_offset: 0 });
let _ = fs::remove_dir_all(&d);
}
#[test]
fn shards_are_independent() {
let d = tmp();
let _ = load_feed_boot(&d, 0).unwrap();
write_feed_meta(&d, 0, 1, 10).unwrap();
let _ = load_feed_boot(&d, 1).unwrap(); let b0 = load_feed_boot(&d, 0).unwrap();
assert_eq!(b0.next_offset, 10);
let _ = fs::remove_dir_all(&d);
}
}