1use std::collections::{HashMap, HashSet};
33use std::path::{Path, PathBuf};
34use std::sync::Arc;
35use std::time::{Duration, Instant};
36
37use async_trait::async_trait;
38
39use crate::config::ExtractionSection;
40use crate::graph::error::GraphError;
41use crate::graph::llm::LlmProvider;
42use crate::graph::{GraphMemory, IngestContext};
43use crate::serve::{BackgroundGuard, DaemonLog, ExtractionState, IdleTracker, ShutdownSignal};
44
45const MAX_POLL: Duration = Duration::from_secs(30);
47const MIN_POLL: Duration = Duration::from_millis(100);
50const MAX_UNIT_ATTEMPTS: u32 = 2;
53const MAX_CONSECUTIVE_FAILURES: u32 = 3;
56
57pub trait Clock: Send + Sync {
62 fn now(&self) -> Instant;
63}
64
65#[derive(Debug, Default, Clone, Copy)]
67pub struct SystemClock;
68
69impl Clock for SystemClock {
70 fn now(&self) -> Instant {
71 Instant::now()
72 }
73}
74
75#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
79pub struct UnitReport {
80 pub entities: u32,
81 pub relationships: u32,
82}
83
84#[async_trait]
89pub trait ExtractionUnit: Send + Sync {
90 async fn pending(&self, limit: usize) -> Result<Vec<u32>, GraphError>;
92
93 async fn extract(&self, log_number: u32) -> Result<UnitReport, GraphError>;
96
97 async fn quarantine(&self, log_number: u32, reason: &str);
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub struct Schedule {
106 pub idle_after: Duration,
108 pub batch_size: usize,
110 pub poll_interval: Duration,
112}
113
114impl Schedule {
115 #[must_use]
117 pub fn from_config(config: &ExtractionSection) -> Self {
118 let idle_after = config.idle_after();
119 Self {
120 idle_after,
121 batch_size: config.effective_batch_size(),
122 poll_interval: (idle_after / 4).clamp(MIN_POLL, MAX_POLL),
123 }
124 }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
129pub enum Plan {
130 Run(Schedule),
132 Off(String),
134}
135
136#[must_use]
147pub fn plan(config: &ExtractionSection, graph_mode: &str) -> Plan {
148 if !config.background_enabled {
149 return Plan::Off("[extraction] background_enabled = false".into());
150 }
151 if graph_mode == "server" {
152 return Plan::Off("[graph] mode = \"server\" — use `graph extract`".into());
153 }
154 Plan::Run(Schedule::from_config(config))
155}
156
157pub struct WorkerContext {
161 pub idle: Arc<IdleTracker>,
162 pub shutdown: Arc<ShutdownSignal>,
163 pub state: Arc<ExtractionState>,
164 pub log: Arc<DaemonLog>,
165 pub clock: Arc<dyn Clock>,
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170enum BatchOutcome {
171 NoWork,
173 Worked,
175 Stopped,
177 Wedged,
179}
180
181pub struct ExtractionWorker {
183 schedule: Schedule,
184 context: WorkerContext,
185 attempts: HashMap<u32, u32>,
187 skipped: HashSet<u32>,
189 consecutive_failures: u32,
190}
191
192impl ExtractionWorker {
193 #[must_use]
194 pub fn new(schedule: Schedule, context: WorkerContext) -> Self {
195 Self {
196 schedule,
197 context,
198 attempts: HashMap::new(),
199 skipped: HashSet::new(),
200 consecutive_failures: 0,
201 }
202 }
203
204 pub async fn run(mut self, unit: Arc<dyn ExtractionUnit>) {
206 self.context.state.enable();
207 loop {
208 if self
209 .context
210 .shutdown
211 .sleep_until_stopped(self.schedule.poll_interval)
212 .await
213 {
214 break;
215 }
216 if !self.is_quiet() {
217 continue;
218 }
219 match self.run_batch(unit.as_ref()).await {
220 BatchOutcome::NoWork | BatchOutcome::Worked => {}
221 BatchOutcome::Stopped => break,
222 BatchOutcome::Wedged => {
223 let reason = format!(
224 "extraction failed {MAX_CONSECUTIVE_FAILURES} times in a row — \
225 not retrying until the daemon restarts"
226 );
227 self.context
228 .log
229 .log(&format!("background extraction off: {reason}"));
230 self.context.state.disable(reason);
231 return;
232 }
233 }
234 }
235 self.context.state.disable("daemon stopping");
236 }
237
238 fn is_quiet(&self) -> bool {
239 self.context
240 .idle
241 .is_quiet_at(self.context.clock.now(), self.schedule.idle_after)
242 }
243
244 async fn run_batch(&mut self, unit: &dyn ExtractionUnit) -> BatchOutcome {
246 let pending = match self.take_pending(unit).await {
247 Some(pending) if !pending.is_empty() => pending,
248 Some(_) => return BatchOutcome::NoWork,
249 None => return BatchOutcome::Stopped,
250 };
251
252 let _busy = BackgroundGuard::new(Arc::clone(&self.context.idle));
254 let started = self.context.clock.now();
255 let mut extracted = 0u64;
256 let mut outcome = BatchOutcome::Worked;
257
258 for log_number in pending {
259 if self.context.shutdown.is_triggered() {
260 outcome = BatchOutcome::Stopped;
261 break;
262 }
263 if self.context.idle.has_connections() {
266 break;
267 }
268
269 match self.context.shutdown.guard(unit.extract(log_number)).await {
270 None => {
271 outcome = BatchOutcome::Stopped;
272 break;
273 }
274 Some(Ok(report)) => {
275 extracted += 1;
276 self.consecutive_failures = 0;
277 self.attempts.remove(&log_number);
278 self.context.log.log(&format!(
279 "extracted log {log_number:03} in the background: \
280 +{} entities, {} relationships",
281 report.entities, report.relationships
282 ));
283 }
284 Some(Err(err)) => {
285 if self.record_failure(unit, log_number, &err).await {
286 outcome = BatchOutcome::Wedged;
287 break;
288 }
289 }
290 }
291
292 tokio::task::yield_now().await;
295 }
296
297 self.finish_batch(extracted, started);
298 outcome
299 }
300
301 async fn take_pending(&mut self, unit: &dyn ExtractionUnit) -> Option<Vec<u32>> {
303 let limit = self.schedule.batch_size + self.skipped.len();
306 match self.context.shutdown.guard(unit.pending(limit)).await? {
307 Ok(pending) => Some(
308 pending
309 .into_iter()
310 .filter(|log_number| !self.skipped.contains(log_number))
311 .take(self.schedule.batch_size)
312 .collect(),
313 ),
314 Err(err) => {
315 self.context
316 .log
317 .log(&format!("background extraction: cannot list work: {err}"));
318 self.context.state.record_error(err.to_string());
319 Some(Vec::new())
320 }
321 }
322 }
323
324 async fn record_failure(
326 &mut self,
327 unit: &dyn ExtractionUnit,
328 log_number: u32,
329 err: &GraphError,
330 ) -> bool {
331 self.consecutive_failures += 1;
332 let attempts = self.attempts.entry(log_number).or_insert(0);
333 *attempts += 1;
334
335 self.context.state.record_error(err.to_string());
336 if *attempts >= MAX_UNIT_ATTEMPTS {
337 self.skipped.insert(log_number);
338 unit.quarantine(log_number, &err.to_string()).await;
339 self.context.log.log(&format!(
340 "background extraction quarantined log {log_number:03} after \
341 {MAX_UNIT_ATTEMPTS} attempts: {err}"
342 ));
343 } else {
344 self.context.log.log(&format!(
345 "background extraction failed on log {log_number:03}: {err}"
346 ));
347 }
348
349 self.consecutive_failures >= MAX_CONSECUTIVE_FAILURES
350 }
351
352 fn finish_batch(&self, extracted: u64, started: Instant) {
355 if extracted == 0 {
356 return;
357 }
358 let finished = self.context.clock.now();
359 let elapsed = finished.saturating_duration_since(started);
360 self.context
361 .state
362 .record_batch(extracted, elapsed, finished);
363 self.context.idle.touch_at(Instant::now());
364 self.context.log.log(&format!(
365 "background extraction: {extracted} archives in {}ms",
366 elapsed.as_millis()
367 ));
368 }
369}
370
371pub struct GraphExtractionUnit {
376 graph: Arc<GraphMemory>,
377 llm: Box<dyn LlmProvider>,
378 conversations_dir: PathBuf,
379 quarantine_path: PathBuf,
380}
381
382impl GraphExtractionUnit {
383 #[must_use]
384 pub fn new(
385 graph: Arc<GraphMemory>,
386 llm: Box<dyn LlmProvider>,
387 conversations_dir: PathBuf,
388 quarantine_path: PathBuf,
389 ) -> Self {
390 Self {
391 graph,
392 llm,
393 conversations_dir,
394 quarantine_path,
395 }
396 }
397}
398
399#[async_trait]
400impl ExtractionUnit for GraphExtractionUnit {
401 async fn pending(&self, limit: usize) -> Result<Vec<u32>, GraphError> {
402 let quarantined = read_quarantine(&self.quarantine_path);
403 Ok(self
404 .graph
405 .unextracted_log_numbers()
406 .await?
407 .into_iter()
408 .filter_map(|log_number| u32::try_from(log_number).ok())
409 .filter(|log_number| !quarantined.contains(log_number))
410 .take(limit)
411 .collect())
412 }
413
414 async fn extract(&self, log_number: u32) -> Result<UnitReport, GraphError> {
415 let path = crate::graph_cli::find_archive_file(&self.conversations_dir, log_number)
416 .map_err(|err| GraphError::NotFound(err.to_string()))?;
417 let content = std::fs::read_to_string(&path)?;
418 let (session_id, _) = crate::graph_cli::extract_archive_metadata(&content, &path);
419 let context = IngestContext::new(session_id, Some(log_number));
420
421 let report = self
422 .graph
423 .extract_from_archive(&content, &context, self.llm.as_ref())
424 .await?;
425 self.graph.mark_extracted(log_number).await?;
426
427 Ok(UnitReport {
428 entities: report.entities_created + report.entities_merged,
429 relationships: report.relationships_created,
430 })
431 }
432
433 async fn quarantine(&self, log_number: u32, _reason: &str) {
434 use std::io::Write as _;
435
436 let _ = std::fs::OpenOptions::new()
439 .create(true)
440 .append(true)
441 .open(&self.quarantine_path)
442 .and_then(|mut file| writeln!(file, "{log_number:03}"));
443 }
444}
445
446fn read_quarantine(path: &Path) -> HashSet<u32> {
448 std::fs::read_to_string(path)
449 .unwrap_or_default()
450 .lines()
451 .filter_map(|line| line.trim().parse().ok())
452 .collect()
453}
454
455pub struct Setup {
459 pub memory_dir: PathBuf,
460 pub graph: Arc<GraphMemory>,
461 pub idle: Arc<IdleTracker>,
462 pub shutdown: Arc<ShutdownSignal>,
463 pub state: Arc<ExtractionState>,
464 pub log: Arc<DaemonLog>,
465}
466
467pub fn spawn(setup: Setup) -> Option<tokio::task::JoinHandle<()>> {
474 let config = crate::config::load_from_dir(&setup.memory_dir);
475 let mode = crate::serve_client::graph_mode(&setup.memory_dir);
476
477 let schedule = match plan(&config.extraction, &mode) {
478 Plan::Run(schedule) => schedule,
479 Plan::Off(reason) => return refuse(&setup, &reason),
480 };
481
482 let conversations_dir = match crate::graph_cli::find_conversations_dir(&setup.memory_dir) {
483 Ok(dir) => dir,
484 Err(err) => return refuse(&setup, &format!("no archives to extract ({err})")),
485 };
486
487 let (llm, model) = match crate::llm_provider::create_provider(&setup.memory_dir, None, None) {
493 Ok(provider) => provider,
494 Err(err) => return refuse(&setup, &format!("no usable LLM provider ({err})")),
495 };
496
497 if let Some(timeout) = setup.idle.timeout() {
498 if schedule.idle_after >= timeout {
499 setup.log.log(&format!(
500 "warning: [extraction] idle_after_secs ({}s) is not shorter than \
501 [serve] idle_timeout_secs ({}s) — the daemon exits before it extracts",
502 schedule.idle_after.as_secs(),
503 timeout.as_secs()
504 ));
505 }
506 }
507
508 setup.log.log(&format!(
509 "background extraction on: {} provider, model {}, every {}s of quiet, {} archives per batch",
510 config.llm.provider,
511 if model.is_empty() { "default" } else { &model },
512 schedule.idle_after.as_secs(),
513 schedule.batch_size,
514 ));
515
516 let unit: Arc<dyn ExtractionUnit> = Arc::new(GraphExtractionUnit::new(
517 Arc::clone(&setup.graph),
518 llm,
519 conversations_dir,
520 setup
521 .memory_dir
522 .join("graph")
523 .join("extraction-quarantine.txt"),
524 ));
525 let worker = ExtractionWorker::new(
526 schedule,
527 WorkerContext {
528 idle: setup.idle,
529 shutdown: setup.shutdown,
530 state: setup.state,
531 log: setup.log,
532 clock: Arc::new(SystemClock),
533 },
534 );
535 Some(tokio::spawn(worker.run(unit)))
536}
537
538fn refuse(setup: &Setup, reason: &str) -> Option<tokio::task::JoinHandle<()>> {
540 setup
541 .log
542 .log(&format!("background extraction off: {reason}"));
543 setup.state.disable(reason);
544 None
545}
546
547#[cfg(test)]
548mod tests {
549 use super::*;
550
551 fn config(background_enabled: bool) -> ExtractionSection {
552 ExtractionSection {
553 background_enabled,
554 ..ExtractionSection::default()
555 }
556 }
557
558 #[test]
559 fn the_default_plan_runs_on_the_configured_schedule() {
560 let Plan::Run(schedule) = plan(&config(true), "embedded") else {
561 panic!("the default config must run");
562 };
563 assert_eq!(schedule.idle_after, Duration::from_secs(120));
564 assert_eq!(schedule.batch_size, 3);
565 assert_eq!(schedule.poll_interval, Duration::from_secs(30));
566 }
567
568 #[test]
569 fn opting_out_turns_the_worker_off() {
570 let Plan::Off(reason) = plan(&config(false), "embedded") else {
571 panic!("background_enabled = false must be honored");
572 };
573 assert!(reason.contains("background_enabled"), "{reason}");
574 }
575
576 #[test]
577 fn server_mode_never_extracts_in_the_background() {
578 let Plan::Off(reason) = plan(&config(true), "server") else {
579 panic!("server mode has no daemon to schedule against");
580 };
581 assert!(reason.contains("server"), "{reason}");
582 }
583
584 #[test]
585 fn poll_interval_is_bounded_at_both_ends() {
586 let fast = Schedule::from_config(&ExtractionSection {
587 idle_after_secs: 0,
588 ..ExtractionSection::default()
589 });
590 assert_eq!(fast.poll_interval, MIN_POLL);
591
592 let slow = Schedule::from_config(&ExtractionSection {
593 idle_after_secs: 86_400,
594 ..ExtractionSection::default()
595 });
596 assert_eq!(slow.poll_interval, MAX_POLL);
597 }
598
599 #[test]
600 fn a_batch_of_zero_would_never_extract_so_it_is_one() {
601 let schedule = Schedule::from_config(&ExtractionSection {
602 batch_size: 0,
603 ..ExtractionSection::default()
604 });
605 assert_eq!(schedule.batch_size, 1);
606 }
607
608 #[test]
609 fn quarantined_log_numbers_survive_a_restart() {
610 let dir = tempfile::tempdir().unwrap();
611 let path = dir.path().join("extraction-quarantine.txt");
612 assert!(read_quarantine(&path).is_empty());
613
614 std::fs::write(&path, "007\n12\nnot-a-number\n").unwrap();
615 let quarantined = read_quarantine(&path);
616 assert!(quarantined.contains(&7));
617 assert!(quarantined.contains(&12));
618 assert_eq!(quarantined.len(), 2);
619 }
620}