use crate::index::IndexDb;
use crate::index::writer::IndexWriter;
use crate::scanner_index_batch::{SCAN_STAGED_BYTES, StagedByteLedger};
const STAGE_BUDGET_BYTES: u64 = 8 * 1024 * 1024;
pub(crate) struct BudgetedWriter<'a> {
index_db: &'a IndexDb,
writer: Option<IndexWriter>,
staged: usize,
reported_bytes: u64,
max_items: usize,
byte_budget: u64,
ledger: &'static StagedByteLedger,
phase: &'static str,
}
impl<'a> BudgetedWriter<'a> {
pub(crate) fn new(index_db: &'a IndexDb, max_items: usize, phase: &'static str) -> Self {
Self::with_budget(index_db, max_items, STAGE_BUDGET_BYTES, &SCAN_STAGED_BYTES, phase)
}
fn with_budget(
index_db: &'a IndexDb,
max_items: usize,
byte_budget: u64,
ledger: &'static StagedByteLedger,
phase: &'static str,
) -> Self {
Self {
index_db,
writer: None,
staged: 0,
reported_bytes: 0,
max_items,
byte_budget,
ledger,
phase,
}
}
pub(crate) fn writer(&mut self) -> &mut IndexWriter {
let index_db = self.index_db;
self.writer.get_or_insert_with(|| index_db.writer())
}
pub(crate) fn item_staged(&mut self) {
self.staged += 1;
let staged_bytes = self.writer.as_ref().map_or(0, IndexWriter::staged_bytes);
self.ledger.charge(staged_bytes.saturating_sub(self.reported_bytes));
self.reported_bytes = staged_bytes;
if self.staged >= self.max_items || staged_bytes >= self.byte_budget || self.ledger.over_ceiling() {
self.commit();
}
}
fn commit(&mut self) -> bool {
let mut ok = true;
if let Some(writer) = self.writer.take()
&& let Err(error) = writer.commit()
{
tracing::warn!(
%error,
phase = self.phase,
"index commit failed — resolved navigation may be stale"
);
ok = false;
}
self.release_reported();
self.staged = 0;
ok
}
fn release_reported(&mut self) {
self.ledger.release(self.reported_bytes);
self.reported_bytes = 0;
}
pub(crate) fn finish(mut self) -> bool {
self.commit()
}
}
impl Drop for BudgetedWriter<'_> {
fn drop(&mut self) {
self.release_reported();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::intel::model::{FileResolvedRefs, ResolvedEdge};
use crate::path::RelPath;
fn fresh_index() -> (tempfile::TempDir, IndexDb) {
let dir = tempfile::tempdir().expect("tempdir");
let db = IndexDb::open(dir.path()).expect("open index");
(dir, db)
}
fn edge_heavy(edges: u32) -> FileResolvedRefs {
let mut refs = FileResolvedRefs::new("typescript");
refs.intra = (0..edges)
.map(|i| ResolvedEdge {
use_start: i * 32,
use_end: i * 32 + 8,
def_start: 4,
def_end: 12,
})
.collect();
refs
}
#[test]
fn the_byte_budget_commits_long_before_the_item_count_bound() {
static LEDGER: StagedByteLedger = StagedByteLedger::new(u64::MAX);
let (_dir, db) = fresh_index();
let refs = edge_heavy(64);
let mut batch = BudgetedWriter::with_budget(&db, 256, 8 * 1024, &LEDGER, "test");
for i in 0..8u32 {
batch
.writer()
.upsert_resolved_file(&RelPath::from(format!("src/f{i}.ts")), &refs)
.expect("stage resolved file");
batch.item_staged();
}
assert!(
batch.staged < 256,
"the item-count bound must not be what committed here"
);
assert!(
db.refs_by_def.iter().next().is_some(),
"the byte budget must have forced a commit already"
);
assert!(batch.finish());
assert_eq!(LEDGER.in_flight(), 0);
}
#[test]
fn the_ledger_does_not_drift_across_commits_or_a_panic() {
static LEDGER: StagedByteLedger = StagedByteLedger::new(64 * 1024);
let (_dir, db) = fresh_index();
let refs = edge_heavy(8);
let mut batch = BudgetedWriter::with_budget(&db, 256, 4 * 1024, &LEDGER, "test");
for i in 0..64u32 {
batch
.writer()
.upsert_resolved_file(&RelPath::from(format!("src/f{i}.ts")), &refs)
.expect("stage resolved file");
batch.item_staged();
assert_eq!(
LEDGER.in_flight(),
batch.reported_bytes,
"the sole batch's contribution must be exactly what the ledger holds"
);
}
assert!(batch.finish());
assert_eq!(
LEDGER.in_flight(),
0,
"the ledger must be empty once every batch committed"
);
let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let mut batch = BudgetedWriter::with_budget(&db, 256, u64::MAX, &LEDGER, "test");
batch
.writer()
.upsert_resolved_file(&RelPath::from("src/pathological.ts"), &refs)
.expect("stage resolved file");
batch.item_staged();
assert!(LEDGER.in_flight() > 0, "staging must charge the ledger");
panic!("pathological file");
}));
assert!(caught.is_err());
assert_eq!(LEDGER.in_flight(), 0, "a panicking batch must not leak budget");
}
#[test]
fn commit_granularity_does_not_change_index_contents() {
static LEDGER: StagedByteLedger = StagedByteLedger::new(u64::MAX);
let (_eager_dir, eager) = fresh_index();
let (_lazy_dir, lazy) = fresh_index();
let refs = edge_heavy(6);
for (db, budget) in [(&eager, 1u64), (&lazy, u64::MAX)] {
let mut batch = BudgetedWriter::with_budget(db, 256, budget, &LEDGER, "test");
for i in 0..12u32 {
batch
.writer()
.upsert_resolved_file(&RelPath::from(format!("src/f{i}.ts")), &refs)
.expect("stage resolved file");
batch.item_staged();
}
assert!(batch.finish());
}
let dump = |db: &IndexDb| -> Vec<Vec<u8>> {
db.refs_by_def
.iter()
.map(|guard| (*guard.into_inner().expect("read entry").0).to_vec())
.collect()
};
assert_eq!(dump(&eager), dump(&lazy));
assert_eq!(LEDGER.in_flight(), 0);
}
}