use std::cell::Cell;
use std::path::Path;
use crate::backpressure::{AdmitOutcome, FootprintGate};
use crate::chunk_drive::{ChunkCut, drive_chunks_governed};
use crate::config::{Config, MaxFootprint};
use crate::scanner::{FileResult, FileStatus, ScanCancel, ScanSource, ScanStats};
use crate::scanner_file::{run_candidates, scanner_pool};
use crate::scanner_filter::Filters;
use crate::store::Store;
#[cfg(feature = "documents")]
pub(crate) type PendingDocBatchOpt = crate::scanner_docs::PendingDocBatch;
#[cfg(not(feature = "documents"))]
pub(crate) type PendingDocBatchOpt = ();
#[cfg(feature = "code-search")]
pub(crate) type PendingCodeBatchOpt = crate::scanner_code::PendingCodeBatch;
#[cfg(not(feature = "code-search"))]
pub(crate) type PendingCodeBatchOpt = ();
pub trait ScanObserver {
fn on_file(&mut self, result: FileResult);
fn on_batch(&mut self, live_results: usize) {
let _ = live_results;
}
}
pub struct NullObserver;
impl ScanObserver for NullObserver {
fn on_file(&mut self, _result: FileResult) {}
}
#[derive(Debug, Default)]
pub struct CollectObserver {
results: Vec<FileResult>,
}
impl CollectObserver {
pub fn new() -> Self {
Self::default()
}
pub fn results(&self) -> &[FileResult] {
&self.results
}
pub fn take(&mut self) -> Vec<FileResult> {
std::mem::take(&mut self.results)
}
}
impl ScanObserver for CollectObserver {
fn on_file(&mut self, result: FileResult) {
self.results.push(result);
}
}
const RECOVERY_CLEAR_ADMITS: usize = 4;
const EMBED_PASS_MAX_WORKERS: usize = 2;
const MIN_BASE_CHUNK_ITEMS: usize = 1024;
const CHUNK_ITEMS_PER_WORKER: usize = 64;
fn base_chunk_items(workers: usize) -> usize {
MIN_BASE_CHUNK_ITEMS.max(workers.saturating_mul(CHUNK_ITEMS_PER_WORKER))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct DrivePlan {
pub(crate) items: usize,
pub(crate) workers: usize,
}
pub(crate) struct DriveGovernor<S = fn() -> Option<u64>>
where
S: Fn() -> Option<u64>,
{
gate: FootprintGate<S>,
base_items: usize,
base_workers: usize,
items: usize,
workers: usize,
clear_streak: usize,
}
impl DriveGovernor {
pub(crate) fn new(setting: MaxFootprint, base_items: usize, base_workers: usize) -> Self {
Self::with_gate(FootprintGate::new(setting), base_items, base_workers)
}
}
impl<S> DriveGovernor<S>
where
S: Fn() -> Option<u64>,
{
pub(crate) fn with_gate(gate: FootprintGate<S>, base_items: usize, base_workers: usize) -> Self {
let base_items = base_items.max(1);
let base_workers = base_workers.max(1);
Self {
gate,
base_items,
base_workers,
items: base_items,
workers: base_workers,
clear_streak: 0,
}
}
pub(crate) fn admit(&mut self) -> DrivePlan {
match self.gate.admit() {
AdmitOutcome::WaitedOut | AdmitOutcome::Serialized => self.narrow(),
AdmitOutcome::Throttled => self.clear_streak = 0,
AdmitOutcome::Clear | AdmitOutcome::Disabled | AdmitOutcome::Unavailable => self.widen(),
}
DrivePlan {
items: self.items,
workers: self.workers,
}
}
fn narrow(&mut self) {
self.items = (self.items / 2).max(1);
self.workers = (self.workers / 2).max(1);
self.clear_streak = 0;
tracing::warn!(
chunk_items = self.items,
workers = self.workers,
"scan over its memory ceiling for the full admit wait; narrowing the drive chunk"
);
}
fn widen(&mut self) {
if self.items >= self.base_items && self.workers >= self.base_workers {
return;
}
self.clear_streak += 1;
if self.clear_streak < RECOVERY_CLEAR_ADMITS {
return;
}
self.clear_streak = 0;
self.items = self.items.saturating_mul(2).min(self.base_items);
self.workers = self.workers.saturating_mul(2).min(self.base_workers);
}
}
pub(crate) struct ScanDrive<'a> {
pub(crate) root: &'a Path,
pub(crate) filters: &'a Filters,
pub(crate) source: &'a ScanSource<'a>,
pub(crate) config: &'a Config,
pub(crate) scope: &'a str,
pub(crate) embed: crate::scanner::EmbedMode,
pub(crate) cancel: &'a ScanCancel,
}
#[derive(Default)]
pub(crate) struct DriveOutcome {
pub(crate) doc_batches: Vec<PendingDocBatchOpt>,
pub(crate) code_batches: Vec<PendingCodeBatchOpt>,
pub(crate) seen: ahash::AHashSet<String>,
#[cfg(feature = "documents")]
pub(crate) doc_seen: ahash::AHashSet<String>,
}
struct DriveCtx<'a> {
store: &'a mut Store,
stats: &'a mut ScanStats,
observer: &'a mut dyn ScanObserver,
out: DriveOutcome,
}
pub(crate) fn drive_scan(
drive: &ScanDrive<'_>,
candidates: &[String],
store: &mut Store,
stats: &mut ScanStats,
observer: &mut dyn ScanObserver,
) -> DriveOutcome {
let pool_threads = scanner_pool(drive.config.resources.scan_threads).current_num_threads();
let mut governor = DriveGovernor::new(
drive.config.resources.max_footprint_mb,
base_chunk_items(pool_threads),
pool_threads,
);
let workers = Cell::new(pool_threads);
let mut ctx = DriveCtx {
store,
stats,
observer,
out: DriveOutcome::default(),
};
drive_chunks_governed(
&mut ctx,
candidates,
|| {
if drive.cancel.is_cancelled() {
return None;
}
let plan = governor.admit();
workers.set(plan.workers);
Some(ChunkCut::new(plan.items, u64::MAX))
},
|_, _| 0,
|ctx: &DriveCtx<'_>, chunk| {
let mut max_workers = if workers.get() >= pool_threads {
0
} else {
workers.get()
};
if matches!(drive.embed, crate::scanner::EmbedMode::Inline) {
max_workers = match max_workers {
0 => EMBED_PASS_MAX_WORKERS,
n => n.min(EMBED_PASS_MAX_WORKERS),
};
}
run_candidates(
chunk,
drive.root,
drive.filters,
ctx.store,
drive.source,
drive.config,
drive.scope,
drive.embed,
drive.cancel,
max_workers,
)
},
|ctx: &mut DriveCtx<'_>, produced| apply_outcomes(ctx, produced),
);
ctx.out
}
fn apply_outcomes(ctx: &mut DriveCtx<'_>, outcomes: Vec<FileResult>) {
ctx.observer.on_batch(outcomes.len());
for mut o in outcomes {
ctx.stats.scanned += 1;
match &o.status {
FileStatus::Updated {
had_errors,
error_count: _,
reused,
} => {
ctx.stats.updated += 1;
if ctx
.store
.lookup(&o.path)
.is_some_and(|prior| prior.extract_epoch < crate::extract::EXTRACT_EPOCH)
{
ctx.stats.refreshed_extraction += 1;
}
if *had_errors {
ctx.stats.updated_with_warnings += 1;
}
if *reused {
ctx.stats.reused_extraction += 1;
}
}
FileStatus::Unchanged => ctx.stats.skipped_unchanged += 1,
#[cfg(feature = "documents")]
FileStatus::DocUnchanged => ctx.stats.skipped_unchanged += 1,
FileStatus::SkippedTooLarge { .. } => ctx.stats.skipped_too_large += 1,
FileStatus::SkippedNonUtf8 => ctx.stats.skipped_non_utf8 += 1,
FileStatus::SkippedNoLang => ctx.stats.skipped_no_lang += 1,
FileStatus::SkippedBinary => ctx.stats.skipped_binary += 1,
FileStatus::Removed => ctx.stats.removed += 1,
FileStatus::ReadFailed { .. } => ctx.stats.read_failed += 1,
FileStatus::ExtractFailed { .. } => ctx.stats.extract_failed += 1,
FileStatus::ParseTimedOut => {
ctx.stats.extract_failed += 1;
ctx.stats.parse_timeouts += 1;
}
#[cfg(feature = "documents")]
FileStatus::DocIndexed { reused, degraded, .. } => {
ctx.stats.docs_indexed += 1;
if *reused {
ctx.stats.reused_doc_extraction += 1;
}
if *degraded {
ctx.stats.docs_degraded += 1;
}
}
#[cfg(feature = "documents")]
FileStatus::DocTimedOut => {
ctx.stats.extract_failed += 1;
ctx.stats.doc_timeouts += 1;
}
}
if matches!(o.status, FileStatus::Updated { .. } | FileStatus::Unchanged) {
ctx.out.seen.insert(o.path.clone());
}
#[cfg(feature = "documents")]
if matches!(o.status, FileStatus::DocIndexed { .. } | FileStatus::DocUnchanged) {
ctx.out.doc_seen.insert(o.path.clone());
}
if let Some(entry) = o.upsert.take() {
ctx.store.upsert(&o.path, entry);
}
#[cfg(feature = "documents")]
if let Some(entry) = o.doc_upsert.take() {
ctx.store.upsert_doc(&o.path, entry);
}
#[cfg(feature = "documents")]
if let Some(batch) = o.doc_batch.take() {
ctx.out.doc_batches.push(batch);
}
#[cfg(feature = "code-search")]
if let Some(batch) = o.code_batch.take() {
ctx.out.code_batches.push(batch);
}
ctx.observer.on_file(FileResult::bare(o.path, o.status));
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
const MB: u64 = 1024 * 1024;
fn always_over(base_items: usize, base_workers: usize) -> DriveGovernor<fn() -> Option<u64>> {
let sampler: fn() -> Option<u64> = || Some(u64::MAX);
DriveGovernor::with_gate(
FootprintGate::with_sampler(1, sampler).with_timing(Duration::from_millis(1), Duration::from_millis(2)),
base_items,
base_workers,
)
}
fn always_clear(base_items: usize, base_workers: usize) -> DriveGovernor<fn() -> Option<u64>> {
let sampler: fn() -> Option<u64> = || Some(MB);
DriveGovernor::with_gate(FootprintGate::with_sampler(4096, sampler), base_items, base_workers)
}
#[test]
fn the_baseline_chunk_is_a_thousand_items_or_sixty_four_per_worker() {
assert_eq!(base_chunk_items(1), 1024);
assert_eq!(base_chunk_items(8), 1024);
assert_eq!(base_chunk_items(16), 1024);
assert_eq!(base_chunk_items(32), 2048);
}
#[test]
fn waiting_out_the_gate_halves_the_chunk_and_the_workers() {
let mut governor = always_over(1024, 16);
let plans: Vec<DrivePlan> = (0..6).map(|_| governor.admit()).collect();
let items: Vec<usize> = plans.iter().map(|p| p.items).collect();
let workers: Vec<usize> = plans.iter().map(|p| p.workers).collect();
assert_eq!(items, vec![512, 256, 128, 64, 32, 16]);
assert_eq!(workers, vec![8, 4, 2, 1, 1, 1]);
}
#[test]
fn a_clear_gate_never_narrows_below_the_baseline() {
let mut governor = always_clear(1024, 16);
for _ in 0..8 {
assert_eq!(
governor.admit(),
DrivePlan {
items: 1024,
workers: 16
}
);
}
}
#[test]
fn the_governor_ratchets_back_up_after_a_run_of_clear_admits() {
let mut governor = always_over(1024, 16);
governor.admit();
governor.admit();
assert_eq!(governor.items, 256);
assert_eq!(governor.workers, 4);
let clear: fn() -> Option<u64> = || Some(MB);
governor.gate = FootprintGate::with_sampler(4096, clear);
for _ in 0..RECOVERY_CLEAR_ADMITS - 1 {
assert_eq!(governor.admit().items, 256, "a partial streak must not widen");
}
assert_eq!(governor.admit().items, 512);
for _ in 0..RECOVERY_CLEAR_ADMITS {
governor.admit();
}
assert_eq!(
governor.admit(),
DrivePlan {
items: 1024,
workers: 16
}
);
}
#[test]
fn a_governed_drive_narrows_its_chunks_under_sustained_pressure() {
let items: Vec<u32> = (0..65).collect();
let mut governor = always_over(64, 8);
let mut lengths: Vec<usize> = Vec::new();
drive_chunks_governed(
&mut lengths,
&items,
|| Some(ChunkCut::new(governor.admit().items, u64::MAX)),
|_, _| 0,
|_, chunk| vec![chunk.len()],
|lengths: &mut Vec<usize>, produced| lengths.extend(produced),
);
assert_eq!(lengths, vec![32, 16, 8, 4, 2, 1, 1, 1]);
assert_eq!(lengths.iter().sum::<usize>(), 65, "the drive stops when items run out");
}
}