Skip to main content

recall_echo/
serve_extract.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Background entity extraction inside the graph daemon.
6//!
7//! recall-echo's claim is that the memory lifecycle is mechanical rather than
8//! on the honor system. That was true of episodes — `SessionEnd` ingests them
9//! through a hook — and false of everything built on top of them: entities,
10//! relationships, confidence and provenance only appeared when a human
11//! remembered to run `recall-echo graph extract`. This module closes that gap.
12//!
13//! The daemon is the natural home for the pass: it is already long-lived, it
14//! already owns the embedded store, and it already knows when nobody is using
15//! it. Once the machine has been quiet for `[extraction] idle_after_secs`, the
16//! worker takes a small batch of un-extracted archives and runs the *same*
17//! extraction the CLI runs — this module decides *when* extraction happens,
18//! never *how*.
19//!
20//! Discipline:
21//!
22//! - **No lock is held across a batch.** The worker shares the daemon's
23//!   `Arc<GraphMemory>` like any connection task; a request that arrives is
24//!   served concurrently, and the batch stops at the next unit boundary.
25//! - **Crash-only, per unit.** `extracted` flips one archive at a time, after
26//!   that archive's extraction succeeded, so an interrupted batch leaves a
27//!   store that simply has work left to do.
28//! - **Shutdown wins.** Every wait and every unit runs under
29//!   [`ShutdownSignal`], so an admin operation taking the store never waits for
30//!   a batch to finish.
31
32use 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
45/// Longest the worker sleeps between quiet checks.
46const MAX_POLL: Duration = Duration::from_secs(30);
47/// Shortest it sleeps, so a short `idle_after_secs` stays responsive without
48/// spinning.
49const MIN_POLL: Duration = Duration::from_millis(100);
50/// Attempts one archive gets before it is quarantined. The CLI uses the same
51/// rule: one retry, then set it aside rather than retry it forever.
52const MAX_UNIT_ATTEMPTS: u32 = 2;
53/// Consecutive failed units before the worker concludes the provider is wedged
54/// and stops for the daemon's remaining life.
55const MAX_CONSECUTIVE_FAILURES: u32 = 3;
56
57// ── Clock ────────────────────────────────────────────────────────────────
58
59/// Source of the current instant. Injectable so scheduling can be tested
60/// without waiting for real time to pass.
61pub trait Clock: Send + Sync {
62    fn now(&self) -> Instant;
63}
64
65/// The real clock.
66#[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// ── The unit of work ─────────────────────────────────────────────────────
76
77/// What one background batch did to one archive.
78#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
79pub struct UnitReport {
80    pub entities: u32,
81    pub relationships: u32,
82}
83
84/// One archive's worth of extraction, as the scheduler sees it.
85///
86/// Kept behind a trait so the worker's scheduling — when it waits, when it
87/// yields, when it stops — can be exercised without a store or a model.
88#[async_trait]
89pub trait ExtractionUnit: Send + Sync {
90    /// Log numbers still awaiting extraction, at most `limit` of them.
91    async fn pending(&self, limit: usize) -> Result<Vec<u32>, GraphError>;
92
93    /// Extract one archive and mark it extracted. Marking is the last thing it
94    /// does, so a failure or an interruption leaves the archive pending.
95    async fn extract(&self, log_number: u32) -> Result<UnitReport, GraphError>;
96
97    /// Stop offering this archive: it has failed [`MAX_UNIT_ATTEMPTS`] times.
98    async fn quarantine(&self, log_number: u32, reason: &str);
99}
100
101// ── Schedule ─────────────────────────────────────────────────────────────
102
103/// When and how much the worker extracts.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub struct Schedule {
106    /// Quiet period before a batch may start.
107    pub idle_after: Duration,
108    /// Archives per batch.
109    pub batch_size: usize,
110    /// How often the worker re-checks whether the daemon is quiet.
111    pub poll_interval: Duration,
112}
113
114impl Schedule {
115    /// The schedule described by an `[extraction]` section.
116    #[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/// Whether background extraction should run at all in this daemon.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub enum Plan {
130    /// Run, on this schedule.
131    Run(Schedule),
132    /// Do not run, for this reason.
133    Off(String),
134}
135
136/// Decide whether this daemon extracts in the background.
137///
138/// `graph_mode` is the `[graph] mode` of the memory directory. Server mode is
139/// refused deliberately: there, clients talk to SurrealDB directly and never
140/// start or consult a daemon, so a daemon's idea of "quiet" is not a fact
141/// about the user's activity at all — it is a fact about a socket nobody is
142/// connected to. A worker there would extract continuously, against a store
143/// other processes are writing to, on behalf of a user who has no reason to
144/// know the daemon exists. `graph extract` remains the way to extract in
145/// server mode.
146#[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
157// ── Worker ───────────────────────────────────────────────────────────────
158
159/// Everything the worker shares with the rest of the daemon.
160pub 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/// How a batch ended.
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170enum BatchOutcome {
171    /// Nothing to extract.
172    NoWork,
173    /// At least one archive was extracted.
174    Worked,
175    /// Shutdown arrived.
176    Stopped,
177    /// The provider has failed too many times in a row.
178    Wedged,
179}
180
181/// The background extraction loop.
182pub struct ExtractionWorker {
183    schedule: Schedule,
184    context: WorkerContext,
185    /// Failed attempts per log number, within this daemon's life.
186    attempts: HashMap<u32, u32>,
187    /// Log numbers this daemon has given up on.
188    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    /// Extract in the background until the daemon shuts down.
205    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    /// Extract up to `batch_size` archives, yielding between each.
245    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        // From here on the daemon may not idle-exit: the batch is in flight.
253        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            // A hot request beats background work, always. The rest of the
264            // batch waits for the next quiet period.
265            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            // Hand the runtime back between units so a request that arrived
293            // mid-batch is picked up before the next archive starts.
294            tokio::task::yield_now().await;
295        }
296
297        self.finish_batch(extracted, started);
298        outcome
299    }
300
301    /// Log numbers worth attempting, or `None` when shutdown arrived.
302    async fn take_pending(&mut self, unit: &dyn ExtractionUnit) -> Option<Vec<u32>> {
303        // Over-fetch, then drop what this daemon has given up on, so a
304        // quarantined archive cannot occupy a batch slot forever.
305        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    /// Account for a failed archive. `true` means the provider looks wedged.
325    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    /// Record what the batch did — and only then let the idle clock restart,
353    /// so a daemon with a backlog stays alive and one without it does not.
354    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
371// ── The real unit: an archive on disk, a store, a provider ───────────────
372
373/// Extraction against the daemon's own store, with a provider built from
374/// config. Runs exactly what `recall-echo graph extract` runs.
375pub 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        // Best effort. The worker's in-memory skip list already holds for this
437        // daemon's life, so a failed write costs one retry after a restart.
438        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
446/// Log numbers a previous run set aside. Absent or unreadable means none.
447fn 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
455// ── Wiring ───────────────────────────────────────────────────────────────
456
457/// Everything [`spawn`] needs from the daemon.
458pub 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
467/// Start the background worker for a daemon, if it should have one.
468///
469/// Every refusal is quiet, final and stated once: a missing provider, a
470/// missing key, a missing conversations directory and a config opt-out all
471/// end here, with a line in the daemon log and a reason on the wire — never a
472/// retry loop, and never surprise API spend.
473pub 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    // The daemon is started with an allowlisted environment that deliberately
488    // excludes API keys, so an API-key provider fails here — which is the
489    // point: an auto-started daemon can only spend what a subscription already
490    // covers. Running `serve --foreground` with a key exported is the explicit
491    // way to opt an API provider in.
492    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
538/// Say once why no background extraction runs, and run none.
539fn 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}