Skip to main content

sqlite_graphrag/commands/enrich/
queue.rs

1//! Enrichment queue — SQLite-backed scan/retry/dead-letter DB.
2
3use super::*;
4/* wave-c1-imports */
5use rusqlite::Connection;
6use serde::Serialize;
7use crate::errors::AppError;
8
9
10// ---------------------------------------------------------------------------
11// Queue DB
12// ---------------------------------------------------------------------------
13
14/// Opens or creates the enrichment queue database.
15///
16/// The queue schema mirrors `ingest_claude` for resume/retry parity.
17/// Uses a different filename (`.enrich-queue.sqlite`) to avoid collision.
18///
19/// # DRY note
20///
21/// This is a near-verbatim copy of `open_queue_db` in `ingest_claude.rs`.
22/// Both should be unified in a shared `llm_runner.rs` module by the
23/// Integration stream.
24pub(super) fn open_queue_db<P: AsRef<std::path::Path>>(path: P) -> Result<Connection, AppError> {
25    let conn = Connection::open(path)?;
26    conn.pragma_update(None, "journal_mode", "wal")?;
27    // GAP-SG-76: without an explicit busy_timeout, a lock contention window
28    // between the dequeue claim and a concurrent worker/main-DB writer
29    // surfaces as SQLITE_BUSY immediately instead of retrying briefly.
30    // Reuses the project-wide canonical value (see rules_rust_sqlite.md —
31    // "DEFINIR busy_timeout em milissegundos explícitos por conexão").
32    conn.pragma_update(None, "busy_timeout", crate::constants::BUSY_TIMEOUT_MILLIS)?;
33    conn.execute_batch(
34        "CREATE TABLE IF NOT EXISTS queue (
35            id          INTEGER PRIMARY KEY AUTOINCREMENT,
36            item_key    TEXT NOT NULL UNIQUE,
37            item_type   TEXT NOT NULL DEFAULT 'memory',
38            status      TEXT NOT NULL DEFAULT 'pending',
39            memory_id   INTEGER,
40            entity_id   INTEGER,
41            entities    INTEGER DEFAULT 0,
42            rels        INTEGER DEFAULT 0,
43            error       TEXT,
44            cost_usd    REAL DEFAULT 0.0,
45            attempt     INTEGER DEFAULT 0,
46            elapsed_ms  INTEGER,
47            created_at  TEXT DEFAULT (datetime('now')),
48            done_at     TEXT
49        );
50        CREATE INDEX IF NOT EXISTS idx_enrich_queue_status ON queue(status);",
51    )?;
52    // GAP-ENRICH-BACKLOG-CONVERGE (v1.0.96): dead-letter columns. The legacy
53    // `.enrich-queue.sqlite` predates these columns and `CREATE TABLE IF NOT
54    // EXISTS` never alters an existing table, so add them idempotently here.
55    let mut has_error_class = false;
56    let mut has_next_retry_at = false;
57    // GAP-SG-12/42: the `operation` column scopes queue rows to the enrich
58    // operation that enqueued them, so `--status` can segment counts per
59    // operation instead of conflating a shared `item_key` space. Migrated
60    // idempotently here for the same reason as the v1.0.96 columns.
61    let mut has_operation = false;
62    // GAP-SG-72: dead-letter diagnostics carried from a typed OpenRouter
63    // `ChatError` (finish_reason + token counts) so `--list-dead` can show
64    // WHY an item died (e.g. truncated by max_tokens) instead of only the
65    // formatted error string. Migrated idempotently for the same reason as
66    // the columns above.
67    let mut has_finish_reason = false;
68    let mut has_input_tokens = false;
69    let mut has_output_tokens = false;
70    // v1.1.2 (Bug 4): `claimed_at` carries the unixepoch timestamp of the last
71    // dequeue claim. `--reset-stale-claims` and the run-startup sweep use it to
72    // reset rows stuck in `processing` after a kill -9 (the schema predates this
73    // column, so migrate idempotently like the other dead-letter columns).
74    let mut has_claimed_at = false;
75    // GAP-CLI-PRIO-03: priority column (hot > normal). Higher values claim first.
76    let mut has_priority = false;
77    {
78        let mut stmt = conn.prepare("PRAGMA table_info(queue)")?;
79        let names = stmt.query_map([], |r| r.get::<_, String>(1))?;
80        for name in names {
81            match name?.as_str() {
82                "error_class" => has_error_class = true,
83                "next_retry_at" => has_next_retry_at = true,
84                "operation" => has_operation = true,
85                "finish_reason" => has_finish_reason = true,
86                "input_tokens" => has_input_tokens = true,
87                "output_tokens" => has_output_tokens = true,
88                "claimed_at" => has_claimed_at = true,
89                "priority" => has_priority = true,
90                _ => {}
91            }
92        }
93    }
94    if !has_error_class {
95        conn.execute_batch("ALTER TABLE queue ADD COLUMN error_class TEXT")?;
96    }
97    if !has_next_retry_at {
98        conn.execute_batch("ALTER TABLE queue ADD COLUMN next_retry_at TEXT")?;
99    }
100    if !has_operation {
101        conn.execute_batch("ALTER TABLE queue ADD COLUMN operation TEXT")?;
102    }
103    if !has_finish_reason {
104        conn.execute_batch("ALTER TABLE queue ADD COLUMN finish_reason TEXT")?;
105    }
106    if !has_input_tokens {
107        conn.execute_batch("ALTER TABLE queue ADD COLUMN input_tokens INTEGER")?;
108    }
109    if !has_output_tokens {
110        conn.execute_batch("ALTER TABLE queue ADD COLUMN output_tokens INTEGER")?;
111    }
112    if !has_claimed_at {
113        conn.execute_batch("ALTER TABLE queue ADD COLUMN claimed_at INTEGER")?;
114    }
115    if !has_priority {
116        conn.execute_batch(
117            "ALTER TABLE queue ADD COLUMN priority INTEGER NOT NULL DEFAULT 0",
118        )?;
119    }
120    // GAP-CLI-QISO-01: rows with NULL operation predate per-op claim. Tag them
121    // LegacyUnscoped so they are never claimable by a named operation drain
122    // (fail-safe: operator re-scans to repopulate with the correct op label).
123    conn.execute(
124        "UPDATE queue SET operation = 'LegacyUnscoped' WHERE operation IS NULL OR operation = ''",
125        [],
126    )?;
127    conn.execute_batch(
128        "CREATE INDEX IF NOT EXISTS idx_enrich_queue_eligible ON queue(status, next_retry_at);
129         CREATE INDEX IF NOT EXISTS idx_enrich_queue_operation ON queue(operation, status);
130         CREATE INDEX IF NOT EXISTS idx_enrich_queue_memory ON queue(memory_id);
131         CREATE INDEX IF NOT EXISTS idx_enrich_queue_priority ON queue(status, priority DESC, id)",
132    )?;
133    Ok(conn)
134}
135
136/// Priority level for hot-set entity-descriptions after `remember`
137/// (GAP-CLI-PRIO-03). Higher values are claimed first.
138pub const PRIORITY_HOT: i64 = 100;
139
140/// Count pending queue rows at or above `min_priority` for an operation label.
141pub(super) fn count_priority_pending(
142    queue_conn: &Connection,
143    operation: &str,
144    min_priority: i64,
145) -> Result<i64, rusqlite::Error> {
146    queue_conn.query_row(
147        "SELECT COUNT(*) FROM queue \
148         WHERE status=pending \
149           AND (operation = ?1 OR operation IS NULL) \
150           AND COALESCE(priority, 0) >= ?2",
151        rusqlite::params![operation, min_priority],
152        |r| r.get(0),
153    )
154}
155
156/// GAP-SG-12: enqueue one scan candidate, linking it to its `memory_id` and
157/// tagging it with the originating `operation`. For memory-keyed operations the
158/// id is resolved from `main_conn` so the cascade cleanup (GAP-SG-13) can target
159/// the queue row by `memory_id` even before the item is processed. Entity/id
160/// keyed operations leave `memory_id` NULL (the `item_key` carries the link).
161/// `INSERT OR IGNORE` preserves the v1.0.96 invariant that a dead-letter row is
162/// never resurrected by re-enqueue (item_key is UNIQUE).
163///
164/// v1.1.2 (Bug 4, D5): batch callers wrap a `queue_conn.transaction()` and pass
165/// `&tx` here (`Transaction` derefs to `Connection`), so hundreds of candidates
166/// commit in a single fsync instead of one-per-statement.
167pub(super) fn enqueue_candidate(
168    queue_conn: &Connection,
169    main_conn: &Connection,
170    namespace: &str,
171    key: &str,
172    item_type: &str,
173    operation: &str,
174) {
175    let memory_id: Option<i64> = if item_type == "memory" {
176        main_conn
177            .query_row(
178                "SELECT id FROM memories WHERE namespace=?1 AND name=?2 AND deleted_at IS NULL",
179                rusqlite::params![namespace, key],
180                |r| r.get(0),
181            )
182            .ok()
183    } else {
184        None
185    };
186    if let Err(e) = queue_conn.execute(
187        "INSERT OR IGNORE INTO queue (item_key, item_type, status, operation, memory_id, priority) \
188         VALUES (?1, ?2, 'pending', ?3, ?4, 0)",
189        rusqlite::params![key, item_type, operation, memory_id],
190    ) {
191        tracing::warn!(target: "enrich", error = %e, "queue insert failed");
192    }
193}
194
195/// Enqueue an entity-keyed candidate with explicit priority (GAP-CLI-PRIO-02/03).
196pub(super) fn enqueue_candidate_with_priority(
197    queue_conn: &Connection,
198    key: &str,
199    item_type: &str,
200    operation: &str,
201    priority: i64,
202) {
203    if let Err(e) = queue_conn.execute(
204        "INSERT OR IGNORE INTO queue (item_key, item_type, status, operation, memory_id, priority) \
205         VALUES (?1, ?2, 'pending', ?3, NULL, ?4)",
206        rusqlite::params![key, item_type, operation, priority],
207    ) {
208        tracing::warn!(target: "enrich", error = %e, "priority queue insert failed");
209    } else {
210        // If the row already existed as pending with lower priority, bump it.
211        let _ = queue_conn.execute(
212            "UPDATE queue SET priority = MAX(COALESCE(priority, 0), ?2), status = CASE \
213                WHEN status IN ('done','skipped','dead') THEN status ELSE 'pending' END \
214             WHERE item_key = ?1 AND COALESCE(priority, 0) < ?2",
215            rusqlite::params![key, priority],
216        );
217    }
218}
219
220/// v1.1.2 (Bug 4): reset `processing` rows whose `claimed_at` is older than
221/// `max_age_secs`. A row stuck in `processing` after a kill -9 never clears its
222/// claim (no heartbeat, no done_at), so a subsequent run never re-selects it and
223/// the backlog appears permanently drained. Returns the number of rows reset to
224/// `pending`.
225pub fn reset_stale_processing_claims(
226    conn: &Connection,
227    max_age_secs: u64,
228) -> Result<usize, AppError> {
229    let reset = conn.execute(
230        "UPDATE queue SET status='pending', claimed_at=NULL \
231         WHERE status='processing' AND claimed_at IS NOT NULL \
232         AND CAST(strftime('%s','now') AS INTEGER) - claimed_at > ?1",
233        rusqlite::params![max_age_secs as i64],
234    )?;
235    Ok(reset)
236}
237
238/// v1.1.2 (Bug 4): refresh `claimed_at` on the currently-processed row so a slow
239/// LLM call (body-enrich can take 60s+) is not mistaken for a stale claim by a
240/// concurrent sweep. Called by the worker loop right after the dequeue claim.
241pub fn heartbeat(conn: &Connection, queue_id: i64) -> Result<(), AppError> {
242    conn.execute(
243        "UPDATE queue SET claimed_at = CAST(strftime('%s','now') AS INTEGER) WHERE id = ?1",
244        rusqlite::params![queue_id],
245    )?;
246    Ok(())
247}
248
249/// GAP-SG-69: item_keys vetoed `status='skipped'` for an operation. The
250/// body-enrich scan selects candidates purely by `LENGTH(body) <
251/// min_output_chars`, so a short body whose rewrite the preservation guard keeps
252/// rejecting would be re-scanned every pass and `--until-empty` would never
253/// converge. Callers exclude these keys so the scan returns only actionable
254/// items; `cleanup_queue_entry` clears the veto when the body actually changes,
255/// restoring the memory as a candidate.
256pub(super) fn skipped_item_keys(
257    conn: &Connection,
258    operation: &str,
259) -> Result<std::collections::HashSet<String>, AppError> {
260    let mut stmt = conn.prepare(
261        "SELECT item_key FROM queue WHERE status='skipped' AND (operation = ?1 OR operation IS NULL)",
262    )?;
263    let keys = stmt
264        .query_map(rusqlite::params![operation], |r| r.get::<_, String>(0))?
265        .collect::<Result<std::collections::HashSet<String>, _>>()?;
266    Ok(keys)
267}
268
269/// Queue `item_type` for an operation: entity-keyed operations use `"entity"`,
270/// entity-pair operations use `"entity_pair"`, every other (memory/id-keyed)
271/// operation uses `"memory"`.
272pub(super) fn item_type_for(operation: &EnrichOperation) -> &'static str {
273    match operation {
274        EnrichOperation::EntityDescriptions => "entity",
275        // v1.1.06: entity-connect enqueues `pair:{id1}:{id2}` keys — never
276        // treat them as memory names (prune_dead_orphans only reaps memory).
277        EnrichOperation::EntityConnect | EnrichOperation::CrossDomainBridges => "entity_pair",
278        _ => "memory",
279    }
280}
281
282/// v1.1.1 (P2): per-key `item_type` override for the re-embed targets.
283///
284/// Re-embed keys are prefixed with `entity:` / `chunk:` when `--target`
285/// selects a non-memory table; the queue row must carry the real item type
286/// so `prune_dead_orphans` (which only reaps `item_type='memory'` rows)
287/// never mistakes an entity/chunk key for an orphaned memory name.
288/// Unprefixed keys keep the operation-level default.
289///
290/// v1.1.06: `pair:{id1}:{id2}` → `"entity_pair"`.
291pub(super) fn item_type_for_key(key: &str, default: &'static str) -> &'static str {
292    if key.starts_with("pair:") {
293        "entity_pair"
294    } else if key.starts_with("entity:") {
295        "entity"
296    } else if key.starts_with("chunk:") {
297        "chunk"
298    } else {
299        default
300    }
301}
302
303/// GAP-SG-13: remove a memory's enrich-queue entry when the memory is deleted or
304/// force-merged, so the dead-letter / pending sidecar never references a row
305/// that no longer exists. Best-effort and a no-op when the queue file is absent
306/// (the common case after a clean run, which removes it). Targets BOTH
307/// `memory_id` (populated at enqueue for memory ops, GAP-SG-12) and `item_key`
308/// (the memory name) so pending rows enqueued before id resolution are also
309/// cleared. Errors are logged, never propagated — cleanup must not fail the
310/// caller's delete/upsert.
311pub fn cleanup_queue_entry(db_path: &std::path::Path, memory_id: i64, name: &str) {
312    let queue_path = crate::paths::sidecar_path(db_path, ".enrich-queue.sqlite");
313    if !queue_path.exists() {
314        return;
315    }
316    match open_queue_db(&queue_path) {
317        Ok(conn) => {
318            if let Err(e) = conn.execute(
319                "DELETE FROM queue WHERE memory_id = ?1 OR item_key = ?2",
320                rusqlite::params![memory_id, name],
321            ) {
322                tracing::warn!(target: "enrich", error = %e, memory_id, "enrich-queue cleanup failed");
323            }
324        }
325        Err(e) => {
326            tracing::warn!(target: "enrich", error = %e, "enrich-queue cleanup skipped (open failed)");
327        }
328    }
329}
330
331/// GAP-SG-66: prune ORPHAN dead-letter rows — `status='dead'` memory rows whose
332/// `item_key` (the memory name) no longer exists in the main DB for `namespace`.
333///
334/// These are terminal "not found" failures (the memory was renamed/purged after
335/// being enqueued): re-processing them just re-fails with the same not-found
336/// error, so `--requeue-dead` can never recover them and they inflate
337/// `queue_dead` forever. Read-only on the main DB; deletes only the
338/// confirmed-orphan rows from the queue sidecar. Entity-keyed dead rows
339/// (`item_type='entity'`) are left untouched — their key is an entity name, not
340/// a memory name. Returns the number of rows pruned.
341pub(super) fn prune_dead_orphans(
342    queue_conn: &Connection,
343    main_conn: &Connection,
344    operation: &str,
345    namespace: &str,
346) -> Result<i64, AppError> {
347    let dead: Vec<(i64, String)> = {
348        let mut stmt = queue_conn.prepare(
349            "SELECT id, item_key FROM queue \
350             WHERE status='dead' AND item_type='memory' \
351             AND (operation = ?1 OR operation IS NULL) ORDER BY id",
352        )?;
353        let rows = stmt
354            .query_map(rusqlite::params![operation], |r| Ok((r.get(0)?, r.get(1)?)))?
355            .collect::<Result<Vec<_>, _>>()?;
356        rows
357    };
358    let mut pruned = 0_i64;
359    for (id, name) in dead {
360        let exists = main_conn
361            .query_row(
362                "SELECT 1 FROM memories WHERE namespace=?1 AND name=?2 AND deleted_at IS NULL",
363                rusqlite::params![namespace, name],
364                |_| Ok(()),
365            )
366            .is_ok();
367        if !exists {
368            queue_conn.execute("DELETE FROM queue WHERE id=?1", rusqlite::params![id])?;
369            pruned += 1;
370        }
371    }
372    if pruned > 0 {
373        let _ = queue_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
374    }
375    Ok(pruned)
376}
377
378/// v1.1.2: prune dead ENTITY orphan rows — remove every `status='dead'`
379/// `item_type='entity'` row from the queue sidecar. Unlike
380/// [`prune_dead_orphans`], this does NOT consult the main DB: entity dead rows
381/// are terminal artifacts of re-extraction/rename and have no recovery path
382/// (re-running them re-fails the same way). Returns the number of rows pruned.
383pub(super) fn prune_dead_entity_orphans(
384    queue_conn: &Connection,
385    operation: &str,
386) -> Result<i64, AppError> {
387    let pruned = queue_conn.execute(
388        "DELETE FROM queue \
389         WHERE status='dead' AND item_type='entity' \
390         AND (operation = ?1 OR operation IS NULL)",
391        rusqlite::params![operation],
392    )? as i64;
393    if pruned > 0 {
394        let _ = queue_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
395    }
396    Ok(pruned)
397}
398
399// ---------------------------------------------------------------------------
400// GAP-ENRICH-BACKLOG-CONVERGE — dead-letter classification + queue failure sink
401// ---------------------------------------------------------------------------
402
403/// Read-only `enrich --status` report (no LLM, no singleton).
404///
405/// GAP-SG-42: all queue counts are scoped to the current `--operation` (rows
406/// migrated before the `operation` column, which are NULL, are still counted so
407/// a legacy queue is not silently reported as empty).
408#[derive(Debug, Serialize, schemars::JsonSchema)]
409pub struct EnrichStatus {
410    pub(super) status_report: bool,
411    pub(super) operation: String,
412    pub(super) namespace: String,
413    pub(super) unbound_backlog: usize,
414    /// GAP-SG-77: DATABASE-semantics backlog for the queried operation, computed
415    /// by `scan::count_operation_backlog` via a `SELECT COUNT(*)` over the real
416    /// store. This is distinct from `queue_pending`/`queue_dead` (FILE/sidecar
417    /// queue semantics) and from the legacy `unbound_backlog` (memory-bindings
418    /// only). It fixes the false `pending=0` that db-backed operations
419    /// (entity-descriptions/body-enrich/re-embed) previously reported.
420    pub(super) scan_backlog: i64,
421    /// GAP-CLI-ED-STATUS-02: empty-description-only backlog (entity-descriptions).
422    #[serde(skip_serializing_if = "Option::is_none")]
423    pub(super) scan_backlog_empty: Option<i64>,
424    /// GAP-CLI-ED-STATUS-02: low-quality description backlog (entity-descriptions).
425    #[serde(skip_serializing_if = "Option::is_none")]
426    pub(super) scan_backlog_low_quality: Option<i64>,
427    /// Whether `--force-redescribe` was active for this status report.
428    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
429    pub(super) force_redescribe: bool,
430    /// Wave 2 / GAP-CLI-OBS-04: fraction of sampled descriptions grounded
431    /// against linked memory bodies (`grounding_coverage` ≥ threshold).
432    #[serde(skip_serializing_if = "Option::is_none")]
433    pub(super) quality_pct: Option<f64>,
434    /// Sample size used for `quality_pct` (entities inspected).
435    #[serde(skip_serializing_if = "Option::is_none")]
436    pub(super) quality_sample_n: Option<u32>,
437    /// Extrapolated count of low-grounding descriptions in the namespace.
438    #[serde(skip_serializing_if = "Option::is_none")]
439    pub(super) scan_backlog_low_grounding_est: Option<i64>,
440    pub(super) queue_pending: i64,
441    pub(super) queue_processing: i64,
442    pub(super) queue_done: i64,
443    pub(super) queue_failed: i64,
444    pub(super) queue_skipped: i64,
445    pub(super) queue_dead: i64,
446    pub(super) eligible_now: i64,
447    pub(super) waiting: i64,
448    /// GAP-SG-15/46: coarse backlog state, disambiguating an empty queue from a
449    /// not-yet-scanned backlog and from a cooldown wait.
450    /// `draining` | `cooldown` | `pending-scan` | `blocked_dead` (scan deficit
451    /// remains but only permanent dead queue rows remain — requeue/prune) |
452    /// `empty`.
453    pub(super) state: &'static str,
454    /// GAP-SG-16: per-item `next_retry_at` for every pending row currently in
455    /// backoff, so an operator can see exactly when each will become eligible.
456    pub(super) waiting_items: Vec<WaitingItem>,
457}
458
459/// GAP-SG-16: one pending queue row waiting on its backoff cooldown.
460#[derive(Debug, Serialize, schemars::JsonSchema)]
461pub struct WaitingItem {
462    pub(super) item_key: String,
463    pub(super) attempt: i64,
464    pub(super) next_retry_at: Option<String>,
465    pub(super) error_class: Option<String>,
466}
467
468/// GAP-SG-23: one dead-letter row reported by `--list-dead`.
469#[derive(Debug, Serialize, schemars::JsonSchema)]
470pub struct DeadItem {
471    pub(super) dead_item: bool,
472    pub(super) item_key: String,
473    pub(super) item_type: String,
474    pub(super) attempt: i64,
475    pub(super) error_class: Option<String>,
476    pub(super) error: Option<String>,
477    /// GAP-SG-72: `choices[0].finish_reason` from the OpenRouter response
478    /// that produced this failure, when one was decoded (e.g. `"length"`
479    /// for a max_tokens truncation). `None` for subprocess-provider modes
480    /// or failures that never reached a decoded response.
481    pub(super) finish_reason: Option<String>,
482    /// GAP-SG-72: `usage.prompt_tokens` from the same response, when known.
483    pub(super) input_tokens: Option<i64>,
484    /// GAP-SG-72: `usage.completion_tokens` from the same response, when known.
485    pub(super) output_tokens: Option<i64>,
486}
487
488/// GAP-SG-23/11: summary footer for `--list-dead` and `--requeue-dead`.
489#[derive(Debug, Serialize, schemars::JsonSchema)]
490pub struct DeadSummary {
491    pub(super) summary: bool,
492    pub(super) operation: String,
493    pub(super) namespace: String,
494    /// `list-dead` | `requeue-dead` | `prune-dead-orphans`
495    pub(super) action: &'static str,
496    pub(super) dead_total: i64,
497    pub(super) requeued: i64,
498    /// GAP-SG-66: `prune-dead-orphans` — dead rows removed because their
499    /// referenced memory no longer exists in the main DB for the namespace.
500    /// Zero for `list-dead` / `requeue-dead`.
501    pub(super) pruned: i64,
502}
503
504// claim/failure helpers in queue_ops.rs
505pub(super) use super::queue_ops::*;
506
507#[cfg(test)]
508#[path = "queue_tests_a.rs"]
509mod tests_a;
510#[cfg(test)]
511#[path = "queue_tests_b.rs"]
512mod tests_b;