use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, OnceLock, mpsc};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ChunkCutovers {
pub markdown_bytes: u64,
pub plain_text_bytes: u64,
}
fn is_text_like(mime: &str) -> bool {
let mime = mime.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
mime.starts_with("text/")
|| ["json", "yaml", "xml", "toml", "csv"]
.iter()
.any(|marker| mime.contains(marker))
}
pub fn prefers_linear_chunking(mime: &str, file_len: u64, cutovers: ChunkCutovers) -> bool {
if !is_text_like(mime) {
return false;
}
let plain = mime
.split(';')
.next()
.unwrap_or("")
.trim()
.eq_ignore_ascii_case("text/plain");
let limit = if plain {
cutovers.plain_text_bytes
} else {
cutovers.markdown_bytes
};
file_len > limit
}
pub fn linear_chunk_spans(text: &str, max_chars: usize, overlap: usize) -> Vec<(usize, usize)> {
let max_chars = max_chars.max(1);
let overlap = overlap.min(max_chars.saturating_sub(1));
let mut spans = Vec::new();
let mut start = 0;
while start < text.len() {
let rest = &text[start..];
let window_end = rest
.char_indices()
.nth(max_chars)
.map_or(text.len(), |(i, _)| start + i);
let mut end = window_end;
if window_end < text.len() {
let window = &text[start..window_end];
let half = window.len() / 2;
let break_at = window.rfind('\n').map(|i| i + 1).filter(|&i| i > half).or_else(|| {
window
.char_indices()
.rev()
.find(|(i, c)| c.is_whitespace() && i + c.len_utf8() > half)
.map(|(i, c)| i + c.len_utf8())
});
if let Some(offset) = break_at {
end = start + offset;
}
}
if !text[start..end].trim().is_empty() {
spans.push((start, end));
}
if end >= text.len() {
break;
}
let next = text[start..end]
.char_indices()
.rev()
.nth(overlap.saturating_sub(1))
.map_or(end, |(i, _)| start + i);
start = if overlap == 0 || next <= start { end } else { next };
}
spans
}
#[derive(Debug)]
pub enum DeadlineError {
Elapsed,
AtCapacity { live: usize, cap: usize },
Failed(String),
}
#[derive(Debug, Clone, Default)]
pub struct AbandonedThreads {
live: Arc<AtomicUsize>,
}
impl AbandonedThreads {
pub fn global() -> &'static Self {
static GLOBAL: OnceLock<AbandonedThreads> = OnceLock::new();
GLOBAL.get_or_init(Self::default)
}
pub fn live(&self) -> usize {
self.live.load(Ordering::Relaxed)
}
}
pub fn abandoned_extractions() -> usize {
AbandonedThreads::global().live()
}
const RUNNING: u8 = 0;
const ABANDONED: u8 = 1;
const FINISHED: u8 = 2;
struct ThreadSlot {
state: AtomicU8,
live: Arc<AtomicUsize>,
}
impl ThreadSlot {
fn abandon(&self) -> bool {
self.live.fetch_add(1, Ordering::AcqRel);
let won = self
.state
.compare_exchange(RUNNING, ABANDONED, Ordering::AcqRel, Ordering::Acquire)
.is_ok();
if !won {
self.live.fetch_sub(1, Ordering::AcqRel);
}
won
}
fn finish(&self) {
if self.state.swap(FINISHED, Ordering::AcqRel) == ABANDONED {
self.live.fetch_sub(1, Ordering::AcqRel);
}
}
}
struct FinishGuard(Arc<ThreadSlot>);
impl Drop for FinishGuard {
fn drop(&mut self) {
self.0.finish();
}
}
pub fn run_with_deadline<T: Send + 'static>(
ledger: &AbandonedThreads,
cap: usize,
budget: Duration,
work: impl FnOnce() -> T + Send + 'static,
) -> Result<T, DeadlineError> {
let live = ledger.live();
if live >= cap {
return Err(DeadlineError::AtCapacity { live, cap });
}
let (tx, rx) = mpsc::sync_channel(1);
let slot = Arc::new(ThreadSlot {
state: AtomicU8::new(RUNNING),
live: Arc::clone(&ledger.live),
});
let guard = FinishGuard(Arc::clone(&slot));
std::thread::Builder::new()
.name("basemind-doc-extract".to_string())
.stack_size(8 * 1024 * 1024)
.spawn(move || {
let _guard = guard;
let _ = tx.send(work());
})
.map_err(|error| DeadlineError::Failed(format!("spawn extraction thread: {error}")))?;
match rx.recv_timeout(budget) {
Ok(value) => Ok(value),
Err(mpsc::RecvTimeoutError::Timeout) => {
if slot.abandon() {
Err(DeadlineError::Elapsed)
} else {
rx.try_recv()
.map_err(|_| DeadlineError::Failed("extraction thread panicked".to_string()))
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
Err(DeadlineError::Failed("extraction thread panicked".to_string()))
}
}
}
#[derive(Debug, Default)]
pub struct DocPhase {
state: Mutex<PhaseState>,
}
#[derive(Debug, Default)]
struct PhaseState {
budget: Option<Duration>,
started: Option<Instant>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PhaseSpent {
pub elapsed: Duration,
pub budget: Duration,
}
impl DocPhase {
pub fn with_budget(budget: Duration) -> Self {
Self {
state: Mutex::new(PhaseState {
budget: Some(budget),
started: None,
}),
}
}
pub fn begin(&self, budget: Duration) {
*self.lock() = PhaseState {
budget: Some(budget),
started: None,
};
}
pub fn admit(&self) -> Result<(), PhaseSpent> {
self.admit_at(Instant::now())
}
fn admit_at(&self, now: Instant) -> Result<(), PhaseSpent> {
let mut state = self.lock();
let Some(budget) = state.budget else {
return Ok(());
};
let started = *state.started.get_or_insert(now);
let elapsed = now.saturating_duration_since(started);
if elapsed >= budget {
Err(PhaseSpent { elapsed, budget })
} else {
Ok(())
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, PhaseState> {
self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
#[cfg(test)]
mod tests {
use super::*;
const CUTOVERS: ChunkCutovers = ChunkCutovers {
markdown_bytes: 1000,
plain_text_bytes: 100,
};
#[test]
fn routes_by_mime_family_and_size() {
assert!(prefers_linear_chunking("text/plain", 101, CUTOVERS));
assert!(!prefers_linear_chunking("text/plain", 100, CUTOVERS));
assert!(!prefers_linear_chunking("text/markdown", 1000, CUTOVERS));
assert!(prefers_linear_chunking("text/csv", 1001, CUTOVERS));
assert!(prefers_linear_chunking(
"application/json; charset=utf-8",
1001,
CUTOVERS
));
assert!(prefers_linear_chunking("application/x-yaml", 1001, CUTOVERS));
assert!(!prefers_linear_chunking("application/pdf", u64::MAX, CUTOVERS));
assert!(!prefers_linear_chunking("", u64::MAX, CUTOVERS));
}
fn assert_valid(text: &str, spans: &[(usize, usize)], max_chars: usize) {
let mut prev_end = 0;
for &(s, e) in spans {
assert!(text.is_char_boundary(s) && text.is_char_boundary(e));
assert!(s < e && e > prev_end, "spans advance");
assert!(text[s..e].chars().count() <= max_chars);
assert!(s <= prev_end, "no gap between spans");
prev_end = e;
}
}
#[test]
fn linear_chunks_cover_text_without_gaps_and_prefer_line_breaks() {
let text: String = (0..500).map(|i| format!("line number {i}\n")).collect();
let spans = linear_chunk_spans(&text, 100, 20);
assert_valid(&text, &spans, 100);
assert_eq!(spans.last().unwrap().1, text.len());
assert!(spans.iter().all(|&(_, e)| text.as_bytes()[e - 1] == b'\n'));
}
#[test]
fn linear_chunks_hard_split_long_lines_on_char_boundaries() {
let text = "é".repeat(1000);
let spans = linear_chunk_spans(&text, 64, 8);
assert_valid(&text, &spans, 64);
assert_eq!(spans.last().unwrap().1, text.len());
}
#[test]
fn linear_chunks_handle_degenerate_inputs() {
assert!(linear_chunk_spans("", 64, 8).is_empty());
assert!(linear_chunk_spans(" \n\n ", 64, 8).is_empty());
assert_eq!(linear_chunk_spans("short", 64, 8), vec![(0, 5)]);
let spans = linear_chunk_spans(&"x".repeat(300), 64, 0);
assert_eq!(spans.len(), 5);
}
const ROOMY: usize = 8;
#[test]
fn deadline_returns_fast_work_and_abandons_slow_work() {
let ledger = AbandonedThreads::default();
let run = |budget, work: fn() -> i32| run_with_deadline(&ledger, ROOMY, budget, work);
assert_eq!(run(Duration::from_secs(5), || 7).unwrap(), 7);
let started = Instant::now();
let slow = run(Duration::from_millis(50), || {
std::thread::sleep(Duration::from_millis(600));
0
});
assert!(matches!(slow, Err(DeadlineError::Elapsed)));
assert!(started.elapsed() < Duration::from_millis(500));
let panicked = run(Duration::from_secs(5), || panic!("boom"));
assert!(matches!(panicked, Err(DeadlineError::Failed(_))));
}
fn wait_until(what: &str, mut done: impl FnMut() -> bool) {
let started = Instant::now();
while !done() {
assert!(
started.elapsed() < Duration::from_secs(10),
"timed out waiting for {what}"
);
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn abandoned_threads_are_counted_until_they_finish() {
let ledger = AbandonedThreads::default();
let (release, gate) = mpsc::channel::<()>();
let slow = run_with_deadline(&ledger, ROOMY, Duration::from_millis(30), move || {
let _ = gate.recv();
});
assert!(matches!(slow, Err(DeadlineError::Elapsed)));
assert_eq!(ledger.live(), 1, "the overrunning thread is still running");
release.send(()).unwrap();
wait_until("the abandoned thread to finish", || ledger.live() == 0);
}
#[test]
fn a_panicking_abandoned_thread_still_leaves_the_count() {
let ledger = AbandonedThreads::default();
let (release, gate) = mpsc::channel::<()>();
let slow = run_with_deadline(&ledger, ROOMY, Duration::from_millis(30), move || {
let _ = gate.recv();
panic!("late boom");
});
assert!(matches!(slow, Err(DeadlineError::Elapsed)));
assert_eq!(ledger.live(), 1);
release.send(()).unwrap();
wait_until("the panicked thread to leave the count", || ledger.live() == 0);
}
#[test]
fn at_the_cap_no_further_thread_is_spawned() {
let ledger = AbandonedThreads::default();
let (release, gate) = mpsc::channel::<()>();
let gate = Arc::new(Mutex::new(gate));
for _ in 0..2 {
let gate = Arc::clone(&gate);
let slow = run_with_deadline(&ledger, 2, Duration::from_millis(30), move || {
let _ = gate.lock().unwrap().recv();
});
assert!(matches!(slow, Err(DeadlineError::Elapsed)));
}
assert_eq!(ledger.live(), 2);
let ran = Arc::new(std::sync::atomic::AtomicBool::new(false));
let flag = Arc::clone(&ran);
let refused = run_with_deadline(&ledger, 2, Duration::from_secs(5), move || {
flag.store(true, Ordering::SeqCst);
});
assert!(matches!(refused, Err(DeadlineError::AtCapacity { live: 2, cap: 2 })));
std::thread::sleep(Duration::from_millis(50));
assert!(!ran.load(Ordering::SeqCst), "a refused extraction must not run");
drop(release);
wait_until("abandoned threads to drain", || ledger.live() == 0);
assert_eq!(
run_with_deadline(&ledger, 2, Duration::from_secs(5), || 1).unwrap(),
1,
"capacity returns once the stuck threads finish"
);
}
#[test]
fn phase_budget_starts_on_first_document_and_refuses_after_it_is_spent() {
let phase = DocPhase::with_budget(Duration::from_secs(10));
let t0 = Instant::now();
assert!(phase.admit_at(t0).is_ok(), "first document starts the clock");
assert!(phase.admit_at(t0 + Duration::from_secs(9)).is_ok());
let spent = phase.admit_at(t0 + Duration::from_secs(10)).unwrap_err();
assert_eq!(spent.budget, Duration::from_secs(10));
assert_eq!(spent.elapsed, Duration::from_secs(10));
assert!(phase.admit_at(t0 + Duration::from_secs(500)).is_err(), "stays spent");
phase.begin(Duration::from_secs(10));
assert!(
phase.admit_at(t0 + Duration::from_secs(500)).is_ok(),
"begin re-arms the phase"
);
}
#[test]
fn an_unarmed_phase_admits_everything() {
let phase = DocPhase::default();
assert!(phase.admit_at(Instant::now()).is_ok());
}
}