Skip to main content

sqlite_graphrag/commands/enrich/run/
mod.rs

1//! Enrich command orchestrator: scan → enqueue → drain (serial/parallel).
2//! Extracted from mod.rs (Wave C1).
3//!
4//! One submodule per STAGE of the drain: [`guards`] settles everything that can
5//! end the invocation before a connection is opened, [`provider`] proves the LLM
6//! provider is usable, [`budget`] derives the wall-clock deadlines, [`scan_phase`]
7//! selects candidates, [`dry_run`] reports them without executing, [`queue_prep`]
8//! brings the sidecar queue to a clean state and enqueues, and [`finalize`] closes
9//! the run. The `--until-empty` drain loop itself stays here, because it is the
10//! orchestration.
11
12use std::time::Instant;
13
14use rusqlite::Connection;
15
16use super::args::{EnrichArgs, EnrichMode, EnrichOperation};
17use super::events::ConcurrencyEvent;
18use super::queue::{
19    count_eligible_pending, enqueue_candidate, item_type_for, item_type_for_key, open_queue_db,
20    retain_unskipped,
21};
22use super::scheduler;
23use super::DEFAULT_RATE_LIMIT_WAIT;
24use crate::errors::AppError;
25use crate::output::emit_json_line as emit_json;
26use crate::paths::AppPaths;
27use crate::storage::connection::{ensure_db_ready, open_rw};
28
29mod budget;
30mod dry_run;
31mod finalize;
32mod guards;
33mod provider;
34mod queue_prep;
35mod scan_phase;
36
37/// Run.
38pub fn run(args: &EnrichArgs, backends: crate::cli::BackendChoice) -> Result<(), AppError> {
39    let crate::cli::BackendChoice {
40        llm: llm_backend,
41        embedding: embedding_backend,
42    } = backends;
43    if guards::handle_pre_db_guards(args, backends)? {
44        return Ok(());
45    }
46
47    let started = Instant::now();
48
49    let paths = AppPaths::resolve(args.db.as_deref())?;
50    ensure_db_ready(&paths)?;
51    let conn = open_rw(&paths.db)?;
52    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
53
54    // G28-B (v1.0.68) + G30 (v1.0.69): enforce singleton per
55    // (job_type, namespace, db_hash) so two parallel `enrich` invocations
56    // on the same DB cannot co-exist, but concurrent enrich on different
57    // databases works as expected. The force flag (--force) breaks a
58    // stale lock from a previously crashed invocation.
59    let _singleton = crate::lock::acquire_job_singleton(
60        crate::lock::JobType::Enrich,
61        &namespace,
62        &paths.db,
63        args.wait_job_singleton,
64        args.force_job_singleton,
65    )?;
66
67    let provider_binary = provider::resolve_provider_binary(args)?;
68    provider::check_system_load(args)?;
69    provider::run_preflight(args)?;
70
71    let budget = budget::resolve(args);
72
73    // Dry-run still materialises the full key list (offline preview). Production
74    // uses page→enqueue (GAP-SG-185 v1.2.4) so peak key-buffer RSS tracks page_size.
75    if args.dry_run {
76        let scan = scan_phase::run_scan(&conn, &paths.db, &namespace, args, &budget)?;
77        dry_run::emit_preview(args, &scan.keys, started);
78        return Ok(());
79    }
80
81    // All operations in this enum have an execution path.
82
83    // Queue setup for resume/retry (GAP-SG-64: sidecar alongside --db).
84    // v1.1.2 (Bug 4): `mut` is required because the enqueue batch (D5) opens a
85    // transaction on this connection.
86    let queue_path = crate::paths::sidecar_path(&paths.db, ".enrich-queue.sqlite");
87    let mut queue_conn = open_queue_db(&queue_path)?;
88
89    // GAP-SG-97: never wipe the whole sidecar — scope clear to this operation
90    // (and prefer namespace when the column is present).
91    let op_label = format!("{:?}", args.operation());
92
93    // prepare_queue force-redescribe reopen is applied per page below.
94    queue_prep::prepare_queue(&queue_conn, &conn, args, &namespace, &op_label, &[])?;
95
96    let item_type = item_type_for(&args.operation());
97    let force_redescribe =
98        args.force_redescribe && matches!(args.operation(), EnrichOperation::EntityDescriptions);
99    // Emit scan_start for pair ops before streaming (same as scan_phase).
100    let backlog_degree0_proxy = if budget.pair_scan_ops {
101        scan_phase::emit_scan_start(
102            &conn,
103            &namespace,
104            args,
105            &budget,
106            super::events::enrich_operation_cli_name(&args.operation()),
107        )
108    } else {
109        None
110    };
111    let scan_started = Instant::now();
112    let total = super::scan::scan_operation_for_each(&conn, &namespace, args, |page| {
113        if force_redescribe {
114            let _ = queue_prep::reopen_force_redescribe_page(&queue_conn, &namespace, &page);
115        }
116        queue_prep::enqueue_page(
117            &mut queue_conn,
118            &conn,
119            &namespace,
120            &page,
121            item_type,
122            &op_label,
123        )?;
124        Ok(())
125    })?;
126    // Body-enrich: candidates already vetoed as skipped are not re-enqueued
127    // because INSERT OR IGNORE keeps the skipped row; scan still counted them.
128    // Match legacy scan_phase filter by not special-casing here: skipped rows
129    // remain skipped in the queue (GAP-SG-69).
130    let scan_elapsed_ms = scan_started.elapsed().as_millis() as u64;
131    emit_json(&super::events::PhaseEvent {
132        phase: "scan",
133        binary_path: None,
134        version: None,
135        items_total: Some(total),
136        items_pending: Some(total),
137        llm_parallelism: args.llm_parallelism,
138    });
139    if budget.pair_scan_ops {
140        let op_cli = super::events::enrich_operation_cli_name(&args.operation());
141        emit_json(&serde_json::json!({
142            "phase": "scan_meta",
143            "operation": op_cli,
144            "pair_algorithm": "cooccurrence+hub_island",
145            "items_total": total,
146            "pairs_enqueued_this_scan": total,
147            "backlog_degree0_proxy": backlog_degree0_proxy,
148            "scan_elapsed_ms": scan_elapsed_ms,
149            "scan_aborted_reason": serde_json::Value::Null,
150        }));
151    }
152    queue_prep::log_enqueue_result(&queue_conn, &op_label, &namespace, total);
153
154    let parallelism = super::events::resolve_drain_parallelism(args);
155
156    let mut counters = super::drain_parallel::DrainCounters::default();
157    let backoff_secs = DEFAULT_RATE_LIMIT_WAIT;
158    let rate_limit_deadline = Instant::now() + crate::runtime_config::rate_limit_deadline_secs();
159    let enrich_started = Instant::now();
160
161    let provider_timeout = match args.mode() {
162        EnrichMode::OpenRouter => args.openrouter_chat_timeout_secs(),
163    };
164
165    let provider_model: Option<&str> = match args.mode() {
166        EnrichMode::OpenRouter => args.openrouter_model.as_deref(),
167    };
168
169    // GAP-SG-16: when --ignore-backoff is set, drop the per-item cooldown filter
170    // from candidate selection so items parked on `next_retry_at` are eligible
171    // immediately. Shared by the parallel workers and the serial loop.
172    let backoff_clause: &str = if args.ignore_backoff {
173        ""
174    } else {
175        "AND (next_retry_at IS NULL OR next_retry_at <= datetime('now'))"
176    };
177
178    // GAP-SG-45: announce the scan-vs-drain concurrency split (scan is always
179    // serial; drain uses `parallelism` workers).
180    emit_json(&ConcurrencyEvent {
181        phase: "concurrency",
182        scan_parallelism: 1,
183        drain_parallelism: parallelism as u32,
184    });
185
186    // GAP-ENRICH-BACKLOG-CONVERGE: --until-empty wraps the scan→populate→drain
187    // cycle in an internal loop so the external bash retry loop is unnecessary.
188    // Without --until-empty the loop body runs exactly once (legacy behaviour).
189    //
190    // v1.1.06: `until_deadline` was already computed before the first scan so
191    // --max-runtime covers scan+drain. Skip the identical re-scan on the first
192    // until-empty iteration (candidates were just enqueued above).
193    let mut until_empty_iter: u32 = 0;
194    let yield_every = scheduler::resolve_yield_every_n(args.yield_every_n_items);
195    let mut yield_count: u64 = 0;
196    let mut items_since_yield: usize = 0;
197    // Wave 3: set when EC breaks to let HOT entity-descriptions run.
198    let mut preempted_for_gate = false;
199    // Workload: mixed — SQLite queue I/O is serial; LLM fan-out is bounded
200    // by host semaphore elsewhere. Yield/preempt keep gate ops responsive.
201    loop {
202        if args.until_empty {
203            until_empty_iter = until_empty_iter.saturating_add(1);
204            if until_empty_iter > 1 {
205                // Re-scan and re-enqueue eligible candidates each iteration.
206                // INSERT OR IGNORE never resurrects a dead-letter row (item_key is
207                // UNIQUE), so the backlog converges instead of looping forever.
208                let mut rescan = super::events::scan_operation_with_deadline(
209                    &conn,
210                    &namespace,
211                    args,
212                    Some(budget.until_deadline),
213                )?;
214                // GAP-SG-69: drop memories already vetoed `status='skipped'` so the
215                // re-scan converges instead of re-enqueuing a non-expandable short
216                // body every iteration (body-enrich only; the verdict persists in
217                // the sidecar queue and is cleared by cleanup_queue_entry on edit).
218                // The veto is asked about THESE candidates only: loading the
219                // operation's whole skipped set here re-read and re-allocated it
220                // once per iteration, and the set grows as the drain runs.
221                if matches!(args.operation(), EnrichOperation::BodyEnrich) {
222                    let _ = retain_unskipped(&queue_conn, &op_label, &mut rescan);
223                }
224                // v1.1.2 (Bug 4, D5): batch the re-scan INSERTs in one transaction.
225                {
226                    let tx = queue_conn.transaction()?;
227                    let tx_conn: &Connection = &tx;
228                    for key in &rescan {
229                        let it = item_type_for_key(key, item_type);
230                        enqueue_candidate(tx_conn, &conn, &namespace, key, it, &op_label);
231                    }
232                    tx.commit()?;
233                }
234            }
235        }
236        let completed_before = counters.completed;
237
238        // G19: when parallelism > 1, spawn bounded worker threads.
239        // Each worker opens its own DB connections (WAL supports concurrent readers + serialized writers).
240        // The queue DB claim is atomic via UPDATE...RETURNING — no external lock needed.
241        if parallelism > 1 {
242            super::drain_parallel::drain_parallel(
243                super::drain_parallel::ParallelSession {
244                    args,
245                    paths: &paths,
246                    queue_path: &queue_path,
247                    namespace: &namespace,
248                    parallelism,
249                },
250                super::drain_serial::DrainProvider {
251                    binary: provider_binary.as_deref(),
252                    model: provider_model,
253                    timeout: provider_timeout,
254                    backends: crate::cli::BackendChoice::new(llm_backend, embedding_backend),
255                },
256                super::drain_serial::DrainScope {
257                    op_label: &op_label,
258                    backoff_clause,
259                    item_type,
260                    total,
261                },
262                &mut counters,
263            )?;
264        } else {
265            super::drain_serial::drain_serial(
266                super::drain_serial::DrainSession {
267                    args,
268                    paths: &paths,
269                    conn: &conn,
270                    queue_conn: &queue_conn,
271                    namespace: &namespace,
272                },
273                super::drain_serial::DrainProvider {
274                    binary: provider_binary.as_deref(),
275                    model: provider_model,
276                    timeout: provider_timeout,
277                    backends: crate::cli::BackendChoice::new(llm_backend, embedding_backend),
278                },
279                super::drain_serial::DrainScope {
280                    op_label: &op_label,
281                    backoff_clause,
282                    item_type,
283                    total,
284                },
285                super::drain_serial::DrainClocks {
286                    started: enrich_started,
287                    until_deadline: budget.until_deadline,
288                    rate_limit_deadline,
289                    yield_every,
290                    backoff_secs,
291                },
292                super::drain_serial::DrainProgress {
293                    counters: &mut counters,
294                    items_since_yield: &mut items_since_yield,
295                    yield_count: &mut yield_count,
296                    preempted_for_gate: &mut preempted_for_gate,
297                },
298            )?;
299        }
300
301        if !args.until_empty {
302            break;
303        }
304        // CAPA-A: isolate until-empty convergence to this op+ns (dequeue parity).
305        let eligible_remaining =
306            count_eligible_pending(&queue_conn, &op_label, &namespace, backoff_clause);
307        let progressed = counters.completed > completed_before;
308        if Instant::now() >= budget.until_deadline {
309            tracing::info!(target: "enrich", "until-empty: max-runtime reached, stopping");
310            break;
311        }
312        if !progressed && eligible_remaining == 0 {
313            tracing::info!(target: "enrich", "until-empty: converged (no eligible items remain)");
314            break;
315        }
316        if eligible_remaining == 0 {
317            // Remaining pending items are waiting on backoff; nap and re-check.
318            std::thread::sleep(std::time::Duration::from_secs(
319                crate::constants::ENRICH_UNTIL_EMPTY_IDLE_NAP_SECS,
320            ));
321        }
322    } // end until-empty loop
323
324    finalize::finish(
325        &conn,
326        &queue_conn,
327        &queue_path,
328        args,
329        &op_label,
330        &namespace,
331        finalize::FinalTally {
332            counters: &counters,
333            items_total: total,
334            started,
335            until_deadline: budget.until_deadline,
336            pair_scan_ops: budget.pair_scan_ops,
337            backlog_degree0_proxy,
338            yield_count,
339            preempted_for_gate,
340        },
341    );
342
343    Ok(())
344}