use std::path::{Path, PathBuf};
use crate::scheduler::budget::{BoundedJob, Step, WorkBudget};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum IndexPhaseId {
Scan,
Parse,
Pdg,
Lexical,
PublishCore,
Neural,
PublishEnhanced,
Complete,
}
impl IndexPhaseId {
pub fn next(self) -> Self {
match self {
IndexPhaseId::Scan => IndexPhaseId::Parse,
IndexPhaseId::Parse => IndexPhaseId::Pdg,
IndexPhaseId::Pdg => IndexPhaseId::Lexical,
IndexPhaseId::Lexical => IndexPhaseId::PublishCore,
IndexPhaseId::PublishCore => IndexPhaseId::Neural,
IndexPhaseId::Neural => IndexPhaseId::PublishEnhanced,
IndexPhaseId::PublishEnhanced | IndexPhaseId::Complete => IndexPhaseId::Complete,
}
}
pub fn marker_name(self) -> String {
format!("{}.done", self.as_str())
}
pub fn as_str(self) -> &'static str {
match self {
IndexPhaseId::Scan => "scan",
IndexPhaseId::Parse => "parse",
IndexPhaseId::Pdg => "pdg",
IndexPhaseId::Lexical => "lexical",
IndexPhaseId::PublishCore => "publish-core",
IndexPhaseId::Neural => "neural",
IndexPhaseId::PublishEnhanced => "publish-enhanced",
IndexPhaseId::Complete => "complete",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexJobProgress {
pub completed_phase: IndexPhaseId,
pub next_phase: IndexPhaseId,
pub items_done: usize,
pub checkpoint: PathBuf,
}
pub trait PhaseExecutor {
type Error: std::error::Error + Send + Sync + 'static;
fn run_phase(&mut self, phase: IndexPhaseId) -> Result<usize, Self::Error>;
fn estimated_next_bytes(&self, phase: IndexPhaseId) -> usize;
}
pub struct IndexJob<E: PhaseExecutor> {
executor: E,
phase: IndexPhaseId,
checkpoint_dir: PathBuf,
items_done: usize,
done: bool,
}
impl<E: PhaseExecutor> IndexJob<E> {
pub fn new(executor: E, checkpoint_dir: impl Into<PathBuf>, force: bool) -> Self {
let checkpoint_dir = checkpoint_dir.into();
let phase = if force {
IndexPhaseId::Scan
} else {
Self::first_incomplete(&checkpoint_dir)
};
Self {
executor,
phase,
checkpoint_dir,
items_done: 0,
done: false,
}
}
pub fn current_phase(&self) -> IndexPhaseId {
self.phase
}
pub fn next_checkpoint(&self) -> PathBuf {
self.checkpoint_dir.join(self.phase.marker_name())
}
fn write_checkpoint(&mut self, completed: IndexPhaseId) -> std::io::Result<()> {
std::fs::create_dir_all(&self.checkpoint_dir)?;
let marker = self.checkpoint_dir.join(completed.marker_name());
std::fs::write(&marker, b"")?;
self.phase = completed.next();
Ok(())
}
pub fn rewind_to(&mut self, phase: IndexPhaseId) {
self.phase = phase;
self.done = false;
}
}
impl<E: PhaseExecutor> IndexJob<E> {
fn first_incomplete(dir: &Path) -> IndexPhaseId {
let order = [
IndexPhaseId::Scan,
IndexPhaseId::Parse,
IndexPhaseId::Pdg,
IndexPhaseId::Lexical,
IndexPhaseId::PublishCore,
IndexPhaseId::Neural,
IndexPhaseId::PublishEnhanced,
];
for phase in order {
if !dir.join(phase.marker_name()).exists() {
return phase;
}
}
IndexPhaseId::Complete
}
}
impl<E: PhaseExecutor + Send> BoundedJob for IndexJob<E> {
type Progress = IndexJobProgress;
fn step(&mut self, _budget: WorkBudget) -> anyhow::Result<Step<Self::Progress>> {
if self.done || self.phase == IndexPhaseId::Complete {
self.done = true;
return Ok(Step::Complete);
}
let phase = self.phase;
let items = self
.executor
.run_phase(phase)
.map_err(|e| anyhow::anyhow!("index phase {} failed: {e}", phase.as_str()))?;
self.items_done += items;
self.write_checkpoint(phase)?;
let next = phase.next();
if next == IndexPhaseId::Complete {
self.done = true;
Ok(Step::Complete)
} else {
Ok(Step::Yield(IndexJobProgress {
completed_phase: phase,
next_phase: next,
items_done: self.items_done,
checkpoint: self.checkpoint_dir.join(phase.marker_name()),
}))
}
}
fn estimated_next_bytes(&self) -> usize {
self.executor.estimated_next_bytes(self.phase)
}
}
#[cfg(test)]
mod test {
use super::*;
use std::collections::{BTreeSet, HashMap};
use std::sync::{Arc, Mutex};
use std::time::Duration;
fn max_budget() -> WorkBudget {
WorkBudget::new(
usize::MAX,
usize::MAX,
usize::MAX,
Duration::from_secs(3600),
)
}
#[derive(Default)]
struct Recorder {
ran: Mutex<Vec<IndexPhaseId>>,
}
struct FakeExecutor {
recorder: Arc<Recorder>,
}
impl PhaseExecutor for FakeExecutor {
type Error = std::io::Error;
fn run_phase(&mut self, phase: IndexPhaseId) -> Result<usize, Self::Error> {
self.recorder.ran.lock().unwrap().push(phase);
Ok(7) }
fn estimated_next_bytes(&self, _phase: IndexPhaseId) -> usize {
4096
}
}
fn tempdir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"leindex-indexjob-test-{}-{tag}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn test_step_progresses_through_phases_one_chunk_at_a_time() {
let dir = tempdir("step-progress");
let recorder = Arc::new(Recorder::default());
let mut job = IndexJob::new(
FakeExecutor {
recorder: recorder.clone(),
},
dir.clone(),
true,
);
let mut seen: Vec<IndexPhaseId> = Vec::new();
let mut total_items = 0;
while let Step::Yield(progress) = job.step(max_budget()).unwrap() {
seen.push(progress.completed_phase);
total_items = progress.items_done;
assert!(
progress.checkpoint.exists(),
"checkpoint must exist at yield"
);
}
assert_eq!(
seen,
vec![
IndexPhaseId::Scan,
IndexPhaseId::Parse,
IndexPhaseId::Pdg,
IndexPhaseId::Lexical,
IndexPhaseId::PublishCore,
IndexPhaseId::Neural,
]
);
assert_eq!(seen.len(), 6, "one yield per non-final chunk");
assert_eq!(total_items, 42, "6 yielded phases x 7 units");
assert_eq!(
recorder.ran.lock().unwrap().len(),
7,
"final phase runs in the Complete step"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_killed_mid_step_resumes_at_last_checkpoint() {
let dir = tempdir("resume");
let recorder = Arc::new(Recorder::default());
{
let mut job = IndexJob::new(
FakeExecutor {
recorder: recorder.clone(),
},
dir.clone(),
false,
);
assert_eq!(
job.step(max_budget()).unwrap(),
Step::Yield(IndexJobProgress {
completed_phase: IndexPhaseId::Scan,
next_phase: IndexPhaseId::Parse,
items_done: 7,
checkpoint: dir.join("scan.done"),
})
);
assert!(matches!(job.step(max_budget()).unwrap(), Step::Yield(..)));
}
let mut resume = IndexJob::new(
FakeExecutor {
recorder: recorder.clone(),
},
dir.clone(),
false,
);
assert_eq!(
resume.current_phase(),
IndexPhaseId::Pdg,
"resume starts at the phase after the last checkpoint"
);
let mut resumed_phases = Vec::new();
while let Step::Yield(p) = resume.step(max_budget()).unwrap() {
resumed_phases.push(p.completed_phase);
}
assert_eq!(
resumed_phases,
vec![
IndexPhaseId::Pdg,
IndexPhaseId::Lexical,
IndexPhaseId::PublishCore,
IndexPhaseId::Neural,
],
"only the un-checkpointed phases run on resume"
);
{
let r = recorder.ran.lock().unwrap();
let unique: BTreeSet<_> = r.iter().copied().collect();
assert_eq!(r.len(), 7, "no phase re-executed after resume");
assert_eq!(unique.len(), 7, "every phase ran exactly once");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_force_ignores_checkpoints() {
let dir = tempdir("force");
let recorder = Arc::new(Recorder::default());
{
let mut job = IndexJob::new(
FakeExecutor {
recorder: recorder.clone(),
},
dir.clone(),
false,
);
while let Step::Yield(_) = job.step(max_budget()).unwrap() {}
}
let fresh: Vec<IndexPhaseId> = recorder.ran.lock().unwrap().iter().copied().collect();
assert_eq!(fresh.len(), 7);
recorder.ran.lock().unwrap().clear();
let mut force = IndexJob::new(
FakeExecutor {
recorder: recorder.clone(),
},
dir.clone(),
true,
);
assert_eq!(force.current_phase(), IndexPhaseId::Scan);
while let Step::Yield(_) = force.step(max_budget()).unwrap() {}
assert_eq!(
recorder.ran.lock().unwrap().len(),
7,
"force re-runs every phase"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_estimated_next_bytes_delegates() {
let dir = tempdir("estimate");
let job = IndexJob::new(
FakeExecutor {
recorder: Arc::default(),
},
dir.clone(),
false,
);
assert_eq!(job.estimated_next_bytes(), 4096);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_phase_ids_unique_markers() {
let mut names = HashMap::new();
for p in [
IndexPhaseId::Scan,
IndexPhaseId::Parse,
IndexPhaseId::Pdg,
IndexPhaseId::Lexical,
IndexPhaseId::PublishCore,
IndexPhaseId::Neural,
IndexPhaseId::PublishEnhanced,
] {
names.insert(p.marker_name(), p);
}
assert_eq!(names.len(), 7, "marker names must be unique");
}
}