Skip to main content

sqlite_graphrag/commands/enrich/
queue.rs

1//! Enrichment queue — SQLite-backed scan/retry/dead-letter DB.
2
3use super::args::EnrichOperation;
4use crate::errors::AppError;
5use rusqlite::Connection;
6use serde::Serialize;
7
8// ---------------------------------------------------------------------------
9// Queue DB
10// ---------------------------------------------------------------------------
11
12/// Opens or creates the enrichment queue database (`.enrich-queue.sqlite`).
13///
14/// # Schema note (GAP-SG-121)
15///
16/// This schema is **not** the same product as the ingest sidecar
17/// (`.ingest-queue.sqlite`), which keys on `file_path` for file-progress
18/// tracking. The distinction dates from the `ingest_claude` / `ingest_codex`
19/// frontends v1.2.0 removed, and outlived them: `ingest` still keeps its own
20/// sidecar. Shared connection setup lives in
21/// [`crate::pragmas::apply_sidecar_queue_pragmas`]; table DDL stays separate.
22///
23/// GAP-SG-95: namespace-scoped queue with ternary UNIQUE
24/// `(namespace, operation, item_key)`. Fresh DBs get the final schema;
25/// legacy sidecars are rebuilt below when `namespace` is missing.
26pub(crate) fn open_queue_db<P: AsRef<std::path::Path>>(path: P) -> Result<Connection, AppError> {
27    let conn = Connection::open(path)?;
28    crate::pragmas::apply_sidecar_queue_pragmas(&conn)?;
29    conn.execute_batch(
30        "CREATE TABLE IF NOT EXISTS queue (
31            id          INTEGER PRIMARY KEY AUTOINCREMENT,
32            namespace   TEXT NOT NULL DEFAULT '',
33            item_key    TEXT NOT NULL,
34            item_type   TEXT NOT NULL DEFAULT 'memory',
35            status      TEXT NOT NULL DEFAULT 'pending',
36            memory_id   INTEGER,
37            entity_id   INTEGER,
38            entities    INTEGER DEFAULT 0,
39            rels        INTEGER DEFAULT 0,
40            error       TEXT,
41            cost_usd    REAL DEFAULT 0.0,
42            attempt     INTEGER DEFAULT 0,
43            elapsed_ms  INTEGER,
44            created_at  TEXT DEFAULT (datetime('now')),
45            done_at     TEXT,
46            error_class TEXT,
47            next_retry_at TEXT,
48            operation   TEXT,
49            finish_reason TEXT,
50            input_tokens INTEGER,
51            output_tokens INTEGER,
52            claimed_at  INTEGER,
53            priority    INTEGER NOT NULL DEFAULT 0,
54            UNIQUE (namespace, operation, item_key)
55        );
56        CREATE INDEX IF NOT EXISTS idx_enrich_queue_status ON queue(status);",
57    )?;
58    // GAP-ENRICH-BACKLOG-CONVERGE (v1.0.96): dead-letter columns. The legacy
59    // `.enrich-queue.sqlite` predates these columns and `CREATE TABLE IF NOT
60    // EXISTS` never alters an existing table, so add them idempotently here.
61    let mut has_error_class = false;
62    let mut has_next_retry_at = false;
63    // GAP-SG-12/42: the `operation` column scopes queue rows to the enrich
64    // operation that enqueued them, so `--status` can segment counts per
65    // operation instead of conflating a shared `item_key` space. Migrated
66    // idempotently here for the same reason as the v1.0.96 columns.
67    let mut has_operation = false;
68    // GAP-SG-72: dead-letter diagnostics carried from a typed OpenRouter
69    // `ChatError` (finish_reason + token counts) so `--list-dead` can show
70    // WHY an item died (e.g. truncated by max_tokens) instead of only the
71    // formatted error string. Migrated idempotently for the same reason as
72    // the columns above.
73    let mut has_finish_reason = false;
74    let mut has_input_tokens = false;
75    let mut has_output_tokens = false;
76    // v1.1.2 (Bug 4): `claimed_at` carries the unixepoch timestamp of the last
77    // dequeue claim. `--reset-stale-claims` and the run-startup sweep use it to
78    // reset rows stuck in `processing` after a kill -9 (the schema predates this
79    // column, so migrate idempotently like the other dead-letter columns).
80    let mut has_claimed_at = false;
81    // GAP-CLI-PRIO-03: priority column (hot > normal). Higher values claim first.
82    let mut has_priority = false;
83    // GAP-SG-95: namespace column for multi-namespace isolation.
84    let mut has_namespace = false;
85    {
86        let mut stmt = conn.prepare("PRAGMA table_info(queue)")?;
87        let names = stmt.query_map([], |r| r.get::<_, String>(1))?;
88        for name in names {
89            match name?.as_str() {
90                "error_class" => has_error_class = true,
91                "next_retry_at" => has_next_retry_at = true,
92                "operation" => has_operation = true,
93                "finish_reason" => has_finish_reason = true,
94                "input_tokens" => has_input_tokens = true,
95                "output_tokens" => has_output_tokens = true,
96                "claimed_at" => has_claimed_at = true,
97                "priority" => has_priority = true,
98                "namespace" => has_namespace = true,
99                _ => {}
100            }
101        }
102    }
103    if !has_error_class {
104        conn.execute_batch("ALTER TABLE queue ADD COLUMN error_class TEXT")?;
105    }
106    if !has_next_retry_at {
107        conn.execute_batch("ALTER TABLE queue ADD COLUMN next_retry_at TEXT")?;
108    }
109    if !has_operation {
110        conn.execute_batch("ALTER TABLE queue ADD COLUMN operation TEXT")?;
111    }
112    if !has_finish_reason {
113        conn.execute_batch("ALTER TABLE queue ADD COLUMN finish_reason TEXT")?;
114    }
115    if !has_input_tokens {
116        conn.execute_batch("ALTER TABLE queue ADD COLUMN input_tokens INTEGER")?;
117    }
118    if !has_output_tokens {
119        conn.execute_batch("ALTER TABLE queue ADD COLUMN output_tokens INTEGER")?;
120    }
121    if !has_claimed_at {
122        conn.execute_batch("ALTER TABLE queue ADD COLUMN claimed_at INTEGER")?;
123    }
124    if !has_priority {
125        conn.execute_batch("ALTER TABLE queue ADD COLUMN priority INTEGER NOT NULL DEFAULT 0")?;
126    }
127    // GAP-CLI-QISO-01: rows with NULL operation predate per-op claim. Tag them
128    // LegacyUnscoped so they are never claimable by a named operation drain
129    // (fail-safe: operator re-scans to repopulate with the correct op label).
130    conn.execute(
131        "UPDATE queue SET operation = 'LegacyUnscoped' WHERE operation IS NULL OR operation = ''",
132        [],
133    )?;
134    // GAP-SG-95: SQLite cannot ADD a composite UNIQUE via ALTER. When the
135    // legacy global UNIQUE(item_key) schema is still present, rebuild the
136    // table with UNIQUE(namespace, operation, item_key).
137    if !has_namespace {
138        migrate_queue_add_namespace(&conn)?;
139    }
140    conn.execute_batch(
141        "CREATE INDEX IF NOT EXISTS idx_enrich_queue_eligible ON queue(status, next_retry_at);
142         CREATE INDEX IF NOT EXISTS idx_enrich_queue_operation ON queue(operation, status);
143         CREATE INDEX IF NOT EXISTS idx_enrich_queue_memory ON queue(memory_id);
144         CREATE INDEX IF NOT EXISTS idx_enrich_queue_priority ON queue(status, priority DESC, id);
145         CREATE INDEX IF NOT EXISTS idx_enrich_queue_ns ON queue(namespace, operation, status)",
146    )?;
147    Ok(conn)
148}
149
150/// Rebuild `queue` with a `namespace` column and ternary UNIQUE
151/// `(namespace, operation, item_key)`. Legacy rows get `namespace = ''`.
152fn migrate_queue_add_namespace(conn: &Connection) -> Result<(), AppError> {
153    tracing::info!(target: "enrich", "migrating enrich queue to namespace-scoped UNIQUE");
154    conn.execute_batch(
155        "BEGIN;
156         CREATE TABLE queue_v120 (
157            id          INTEGER PRIMARY KEY AUTOINCREMENT,
158            namespace   TEXT NOT NULL DEFAULT '',
159            item_key    TEXT NOT NULL,
160            item_type   TEXT NOT NULL DEFAULT 'memory',
161            status      TEXT NOT NULL DEFAULT 'pending',
162            memory_id   INTEGER,
163            entity_id   INTEGER,
164            entities    INTEGER DEFAULT 0,
165            rels        INTEGER DEFAULT 0,
166            error       TEXT,
167            cost_usd    REAL DEFAULT 0.0,
168            attempt     INTEGER DEFAULT 0,
169            elapsed_ms  INTEGER,
170            created_at  TEXT DEFAULT (datetime('now')),
171            done_at     TEXT,
172            error_class TEXT,
173            next_retry_at TEXT,
174            operation   TEXT,
175            finish_reason TEXT,
176            input_tokens INTEGER,
177            output_tokens INTEGER,
178            claimed_at  INTEGER,
179            priority    INTEGER NOT NULL DEFAULT 0,
180            UNIQUE (namespace, operation, item_key)
181         );
182         INSERT OR IGNORE INTO queue_v120 (
183            id, namespace, item_key, item_type, status, memory_id, entity_id,
184            entities, rels, error, cost_usd, attempt, elapsed_ms, created_at,
185            done_at, error_class, next_retry_at, operation, finish_reason,
186            input_tokens, output_tokens, claimed_at, priority
187         )
188         SELECT
189            id, '', item_key, item_type, status, memory_id, entity_id,
190            entities, rels, error, cost_usd, attempt, elapsed_ms, created_at,
191            done_at, error_class, next_retry_at,
192            COALESCE(NULLIF(operation, ''), 'LegacyUnscoped'),
193            finish_reason, input_tokens, output_tokens, claimed_at,
194            COALESCE(priority, 0)
195         FROM queue;
196         DROP TABLE queue;
197         ALTER TABLE queue_v120 RENAME TO queue;
198         COMMIT;",
199    )
200    .map_err(|e| {
201        AppError::Validation(crate::i18n::validation::queue_namespace_migration_failed(
202            &e,
203        ))
204    })?;
205    Ok(())
206}
207
208/// Priority level for hot-set entity-descriptions after `remember`
209/// (GAP-CLI-PRIO-03). Higher values are claimed first.
210///
211/// DEBT (v1.2.8): belongs in `src/constants/`. It is not an implementation
212/// detail of this module — it is the scheduling policy that decides which work
213/// a drain claims first, and the only other priority in the system (`0`, the
214/// enqueue default) is spelled in SQL text elsewhere. Kept here because
215/// `src/constants/` was outside the scope this change was allowed to touch.
216pub const PRIORITY_HOT: i64 = 100;
217
218/// Count pending queue rows at or above `min_priority` for an operation + namespace.
219///
220/// CAPA-G (2026-07-30): filter by `namespace` so HOT preemption for one ns does
221/// not see pending rows belonging to another namespace on a shared sidecar.
222pub(super) fn count_priority_pending(
223    queue_conn: &Connection,
224    operation: &str,
225    namespace: &str,
226    min_priority: i64,
227) -> Result<i64, rusqlite::Error> {
228    // GAP-SG-97: status is a string literal — must be quoted.
229    // Strict operation + namespace (aligned with dequeue_next_pending).
230    queue_conn.query_row(
231        "SELECT COUNT(*) FROM queue \
232         WHERE status='pending' \
233           AND operation = ?1 \
234           AND namespace = ?2 \
235           AND COALESCE(priority, 0) >= ?3",
236        rusqlite::params![operation, namespace, min_priority],
237        |r| r.get(0),
238    )
239}
240
241/// GAP-SG-12: enqueue one scan candidate, linking it to its `memory_id` and
242/// tagging it with the originating `operation`. For memory-keyed operations the
243/// id is resolved from `main_conn` so the cascade cleanup (GAP-SG-13) can target
244/// the queue row by `memory_id` even before the item is processed. Entity/id
245/// keyed operations leave `memory_id` NULL (the `item_key` carries the link).
246/// `INSERT OR IGNORE` preserves the v1.0.96 invariant that a dead-letter row is
247/// never resurrected by re-enqueue (item_key is UNIQUE).
248///
249/// v1.1.2 (Bug 4, D5): batch callers wrap a `queue_conn.transaction()` and pass
250/// `&tx` here (`Transaction` derefs to `Connection`), so hundreds of candidates
251/// commit in a single fsync instead of one-per-statement.
252pub(super) fn enqueue_candidate(
253    queue_conn: &Connection,
254    main_conn: &Connection,
255    namespace: &str,
256    key: &str,
257    item_type: &str,
258    operation: &str,
259) {
260    // G-PR-8 / GAP-SG-102: refuse to enqueue keys that do not exist in the
261    // target namespace (prevents multi-ns residual and CAPA6 dependency).
262    let memory_id: Option<i64> = if item_type == "memory" {
263        match main_conn.query_row(
264            "SELECT id FROM memories WHERE namespace=?1 AND name=?2 AND deleted_at IS NULL",
265            rusqlite::params![namespace, key],
266            |r| r.get(0),
267        ) {
268            Ok(id) => Some(id),
269            Err(_) => {
270                tracing::warn!(
271                    target: "enrich",
272                    namespace,
273                    key,
274                    "enqueue rejected: memory not found in namespace"
275                );
276                return;
277            }
278        }
279    } else if item_type == "entity" {
280        // v1.1.1 (P2) re-embed enqueues `entity:{name}` keys so drain can
281        // dispatch (`call_reembed` strips the prefix). Lookup must use the
282        // bare entity name — matching against the prefixed key rejects every
283        // candidate ("entity not found in namespace") and leaves the ~14k
284        // entity_embeddings residual stuck forever.
285        let entity_name = key.strip_prefix("entity:").unwrap_or(key);
286        match main_conn.query_row(
287            "SELECT id FROM entities WHERE namespace=?1 AND name=?2",
288            rusqlite::params![namespace, entity_name],
289            |r| r.get::<_, i64>(0),
290        ) {
291            Ok(_) => None,
292            Err(_) => {
293                tracing::warn!(
294                    target: "enrich",
295                    namespace,
296                    key,
297                    "enqueue rejected: entity not found in namespace"
298                );
299                return;
300            }
301        }
302    } else if item_type == "chunk" {
303        // CAPA: never trust the scanner alone for chunk keys — residual
304        // empty-ns / cross-ns queue rows used to land here and then fail at
305        // drain with HardFailure + circuit breaker.
306        let chunk_key = key.strip_prefix("chunk:").unwrap_or(key);
307        let Ok(chunk_id) = chunk_key.parse::<i64>() else {
308            tracing::warn!(
309                target: "enrich",
310                namespace,
311                key,
312                "enqueue rejected: invalid chunk id in re-embed key"
313            );
314            return;
315        };
316        match main_conn.query_row(
317            "SELECT c.id FROM memory_chunks c
318             JOIN memories m ON m.id = c.memory_id
319             WHERE c.id = ?1 AND m.namespace = ?2 AND m.deleted_at IS NULL",
320            rusqlite::params![chunk_id, namespace],
321            |r| r.get::<_, i64>(0),
322        ) {
323            Ok(_) => None,
324            Err(_) => {
325                tracing::warn!(
326                    target: "enrich",
327                    namespace,
328                    key,
329                    "enqueue rejected: chunk not found in namespace"
330                );
331                return;
332            }
333        }
334    } else {
335        // entity_pair / other prefixed keys: trust the scanner; still scope by ns.
336        None
337    };
338    if let Err(e) = queue_conn.execute(
339        "INSERT OR IGNORE INTO queue \
340         (namespace, item_key, item_type, status, operation, memory_id, priority) \
341         VALUES (?1, ?2, ?3, 'pending', ?4, ?5, 0)",
342        rusqlite::params![namespace, key, item_type, operation, memory_id],
343    ) {
344        tracing::warn!(target: "enrich", error = %e, "queue insert failed");
345    }
346}
347
348/// Enqueue an entity-keyed candidate with explicit priority (GAP-CLI-PRIO-02/03).
349pub(super) fn enqueue_candidate_with_priority(
350    queue_conn: &Connection,
351    key: &str,
352    item_type: &str,
353    operation: &str,
354    priority: i64,
355) {
356    // Priority hot-path historically lacked namespace; store under '' so the
357    // ternary UNIQUE still applies (callers that know the ns should use
358    // enqueue_candidate after validating the entity).
359    if let Err(e) = queue_conn.execute(
360        "INSERT OR IGNORE INTO queue \
361         (namespace, item_key, item_type, status, operation, memory_id, priority) \
362         VALUES ('', ?1, ?2, 'pending', ?3, NULL, ?4)",
363        rusqlite::params![key, item_type, operation, priority],
364    ) {
365        tracing::warn!(target: "enrich", error = %e, "priority queue insert failed");
366    } else {
367        // If the row already existed as pending with lower priority, bump it.
368        let _ = queue_conn.execute(
369            "UPDATE queue SET priority = MAX(COALESCE(priority, 0), ?2), status = CASE \
370                WHEN status IN ('done','skipped','dead') THEN status ELSE 'pending' END \
371             WHERE item_key = ?1 AND COALESCE(priority, 0) < ?2",
372            rusqlite::params![key, priority],
373        );
374    }
375}
376
377/// v1.1.2 (Bug 4): reset `processing` rows whose `claimed_at` is older than
378/// `max_age_secs`. A row stuck in `processing` after a kill -9 never clears its
379/// claim (no heartbeat, no done_at), so a subsequent run never re-selects it and
380/// the backlog appears permanently drained. Returns the number of rows reset to
381/// `pending`.
382pub fn reset_stale_processing_claims(
383    conn: &Connection,
384    max_age_secs: u64,
385) -> Result<usize, AppError> {
386    let reset = conn.execute(
387        "UPDATE queue SET status='pending', claimed_at=NULL \
388         WHERE status='processing' AND claimed_at IS NOT NULL \
389         AND CAST(strftime('%s','now') AS INTEGER) - claimed_at > ?1",
390        rusqlite::params![max_age_secs as i64],
391    )?;
392    Ok(reset)
393}
394
395/// v1.1.2 (Bug 4): refresh `claimed_at` on the currently-processed row so a slow
396/// LLM call (body-enrich can take 60s+) is not mistaken for a stale claim by a
397/// concurrent sweep. Called by the worker loop right after the dequeue claim.
398pub fn heartbeat(conn: &Connection, queue_id: i64) -> Result<(), AppError> {
399    conn.execute(
400        "UPDATE queue SET claimed_at = CAST(strftime('%s','now') AS INTEGER) WHERE id = ?1",
401        rusqlite::params![queue_id],
402    )?;
403    Ok(())
404}
405
406/// GAP-SG-69: item_keys vetoed `status='skipped'` for an operation. The
407/// body-enrich scan selects candidates purely by `LENGTH(body) <
408/// min_output_chars`, so a short body whose rewrite the preservation guard keeps
409/// rejecting would be re-scanned every pass and `--until-empty` would never
410/// converge. Callers exclude these keys so the scan returns only actionable
411/// items; `cleanup_queue_entry` clears the veto when the body actually changes,
412/// restoring the memory as a candidate.
413///
414/// Loads the WHOLE veto set, so its cost grows with the number of skipped rows
415/// and not with the work at hand. Callers that run once per invocation can pay
416/// it; callers inside the `--until-empty` loop must use [`retain_unskipped`],
417/// which asks the same question against the candidates it actually holds.
418pub(super) fn skipped_item_keys(
419    conn: &Connection,
420    operation: &str,
421) -> Result<std::collections::HashSet<String>, AppError> {
422    let mut stmt = conn.prepare(
423        "SELECT item_key FROM queue WHERE status='skipped' AND (operation = ?1 OR operation IS NULL)",
424    )?;
425    let keys = stmt
426        .query_map(rusqlite::params![operation], |r| r.get::<_, String>(0))?
427        .collect::<Result<std::collections::HashSet<String>, _>>()?;
428    Ok(keys)
429}
430
431/// Drop the candidates this operation has already vetoed `status='skipped'`.
432///
433/// Same veto as [`skipped_item_keys`], asked the other way round (v1.2.8). The
434/// set-based form loads every skipped row of the operation into a `HashSet`, and
435/// the `--until-empty` loop rebuilt that set on EVERY iteration: a long drain
436/// against a corpus with tens of thousands of non-expandable bodies re-read and
437/// re-allocated the whole veto set once per pass, growing as the drain itself
438/// pushed more rows into `skipped`. Here the working set is bounded by `keys`,
439/// which the scan already caps, and the query is chunked at
440/// [`crate::constants::DEFAULT_ENRICH_SCAN_PAGE_SIZE`] placeholders so a large
441/// candidate list cannot exceed SQLite's bound-variable limit.
442///
443/// The keys are BOUND, one placeholder each, never pasted into the SQL text
444/// (GAP-SG-167) — they are memory names an operator chose.
445pub(super) fn retain_unskipped(
446    conn: &Connection,
447    operation: &str,
448    keys: &mut Vec<String>,
449) -> Result<(), AppError> {
450    if keys.is_empty() {
451        return Ok(());
452    }
453    let mut vetoed = std::collections::HashSet::new();
454    for chunk in keys.chunks(crate::constants::DEFAULT_ENRICH_SCAN_PAGE_SIZE) {
455        // One `?` per key after the operation placeholder. Only two distinct SQL
456        // texts occur (a full chunk and the final partial one), so
457        // `prepare_cached` still amortises across iterations.
458        let placeholders = (0..chunk.len())
459            .map(|i| format!("?{}", i + 2))
460            .collect::<Vec<_>>()
461            .join(",");
462        let sql = format!(
463            "SELECT item_key FROM queue WHERE status='skipped' \
464             AND (operation = ?1 OR operation IS NULL) \
465             AND item_key IN ({placeholders})"
466        );
467        let mut stmt = conn.prepare_cached(&sql)?;
468        let mut params: Vec<&dyn rusqlite::ToSql> = Vec::with_capacity(chunk.len() + 1);
469        params.push(&operation);
470        for key in chunk {
471            params.push(key);
472        }
473        let rows = stmt.query_map(params.as_slice(), |r| r.get::<_, String>(0))?;
474        for row in rows {
475            vetoed.insert(row?);
476        }
477    }
478    keys.retain(|k| !vetoed.contains(k));
479    Ok(())
480}
481
482/// Queue `item_type` for an operation: entity-keyed operations use `"entity"`,
483/// entity-pair operations use `"entity_pair"`, every other (memory/id-keyed)
484/// operation uses `"memory"`.
485pub(super) fn item_type_for(operation: &EnrichOperation) -> &'static str {
486    match operation {
487        // v1.2.8: entity-type-validate joins entity-descriptions here. Its keys
488        // are ENTITY NAMES (see `scan_entities_for_type_validation`), but the
489        // catch-all below typed them `"memory"`, with two consequences.
490        // `enqueue_candidate` resolves `item_type == "memory"` keys against
491        // `memories` by name and drops what it cannot find, so entity names were
492        // rejected at enqueue; and any row that did land became visible to
493        // `--prune-dead-orphans`, which reaps `item_type='memory'` rows whose
494        // memory is gone — every entity name qualifies as gone.
495        EnrichOperation::EntityDescriptions | EnrichOperation::EntityTypeValidate => "entity",
496        // v1.1.06: entity-connect enqueues `pair:{id1}:{id2}` keys — never
497        // treat them as memory names (prune_dead_orphans only reaps memory).
498        EnrichOperation::EntityConnect | EnrichOperation::CrossDomainBridges => "entity_pair",
499        _ => "memory",
500    }
501}
502
503/// v1.1.1 (P2): per-key `item_type` override for the re-embed targets.
504///
505/// Re-embed keys are prefixed with `entity:` / `chunk:` when `--target`
506/// selects a non-memory table; the queue row must carry the real item type
507/// so `prune_dead_orphans` (which only reaps `item_type='memory'` rows)
508/// never mistakes an entity/chunk key for an orphaned memory name.
509/// Unprefixed keys keep the operation-level default.
510///
511/// v1.1.06: `pair:{id1}:{id2}` → `"entity_pair"`.
512pub(super) fn item_type_for_key(key: &str, default: &'static str) -> &'static str {
513    if key.starts_with("pair:") {
514        "entity_pair"
515    } else if key.starts_with("entity:") {
516        "entity"
517    } else if key.starts_with("chunk:") {
518        "chunk"
519    } else {
520        default
521    }
522}
523
524/// GAP-SG-13: remove a memory's enrich-queue entry when the memory is deleted or
525/// force-merged, so the dead-letter / pending sidecar never references a row
526/// that no longer exists. Best-effort and a no-op when the queue file is absent
527/// (the common case after a clean run, which removes it). Targets BOTH
528/// `memory_id` (populated at enqueue for memory ops, GAP-SG-12) and `item_key`
529/// (the memory name) so pending rows enqueued before id resolution are also
530/// cleared. Errors are logged, never propagated — cleanup must not fail the
531/// caller's delete/upsert.
532pub fn cleanup_queue_entry(db_path: &std::path::Path, memory_id: i64, name: &str) {
533    let queue_path = crate::paths::sidecar_path(db_path, ".enrich-queue.sqlite");
534    if !queue_path.exists() {
535        return;
536    }
537    match open_queue_db(&queue_path) {
538        Ok(conn) => {
539            if let Err(e) = conn.execute(
540                "DELETE FROM queue WHERE memory_id = ?1 OR item_key = ?2",
541                rusqlite::params![memory_id, name],
542            ) {
543                tracing::warn!(target: "enrich", error = %e, memory_id, "enrich-queue cleanup failed");
544            }
545        }
546        Err(e) => {
547            tracing::warn!(target: "enrich", error = %e, "enrich-queue cleanup skipped (open failed)");
548        }
549    }
550}
551
552/// GAP-SG-66: prune ORPHAN dead-letter rows — `status='dead'` memory rows whose
553/// `item_key` (the memory name) no longer exists in the main DB for `namespace`.
554///
555/// These are terminal "not found" failures (the memory was renamed/purged after
556/// being enqueued): re-processing them just re-fails with the same not-found
557/// error, so `--requeue-dead` can never recover them and they inflate
558/// `queue_dead` forever. Read-only on the main DB; deletes only the
559/// confirmed-orphan rows from the queue sidecar. Entity-keyed dead rows
560/// (`item_type='entity'`) are left untouched — their key is an entity name, not
561/// a memory name. Returns the number of rows pruned.
562///
563/// The queue SELECT is scoped to `namespace` (strict equality, aligned with
564/// [`count_priority_pending`] and `dequeue_next_pending`). Without that scope a
565/// dead row belonging to namespace A was checked against namespace B's memories,
566/// found absent, and deleted as an orphan — silent cross-namespace data loss.
567pub(super) fn prune_dead_orphans(
568    queue_conn: &Connection,
569    main_conn: &Connection,
570    operation: &str,
571    namespace: &str,
572) -> Result<i64, AppError> {
573    let dead: Vec<(i64, String)> = {
574        let mut stmt = queue_conn.prepare(
575            "SELECT id, item_key FROM queue \
576             WHERE status='dead' AND item_type='memory' \
577             AND (operation = ?1 OR operation IS NULL) \
578             AND namespace = ?2 ORDER BY id",
579        )?;
580        let rows = stmt
581            .query_map(rusqlite::params![operation, namespace], |r| {
582                Ok((r.get(0)?, r.get(1)?))
583            })?
584            .collect::<Result<Vec<_>, _>>()?;
585        rows
586    };
587    let mut pruned = 0_i64;
588    for (id, name) in dead {
589        let exists = main_conn
590            .query_row(
591                "SELECT 1 FROM memories WHERE namespace=?1 AND name=?2 AND deleted_at IS NULL",
592                rusqlite::params![namespace, name],
593                |_| Ok(()),
594            )
595            .is_ok();
596        if !exists {
597            queue_conn.execute("DELETE FROM queue WHERE id=?1", rusqlite::params![id])?;
598            pruned += 1;
599        }
600    }
601    if pruned > 0 {
602        let _ = queue_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
603    }
604    Ok(pruned)
605}
606
607/// v1.1.2: prune dead ENTITY orphan rows — remove every `status='dead'`
608/// `item_type='entity'` row from the queue sidecar. Unlike
609/// [`prune_dead_orphans`], this does NOT consult the main DB: entity dead rows
610/// are terminal artifacts of re-extraction/rename and have no recovery path
611/// (re-running them re-fails the same way). Returns the number of rows pruned.
612///
613/// Scoped to `namespace` for the same reason as [`prune_dead_orphans`]: a prune
614/// asked for one namespace must never delete another namespace's dead rows.
615pub(super) fn prune_dead_entity_orphans(
616    queue_conn: &Connection,
617    operation: &str,
618    namespace: &str,
619) -> Result<i64, AppError> {
620    let pruned = queue_conn.execute(
621        "DELETE FROM queue \
622         WHERE status='dead' AND item_type='entity' \
623         AND (operation = ?1 OR operation IS NULL) \
624         AND namespace = ?2",
625        rusqlite::params![operation, namespace],
626    )? as i64;
627    if pruned > 0 {
628        let _ = queue_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
629    }
630    Ok(pruned)
631}
632
633// ---------------------------------------------------------------------------
634// GAP-ENRICH-BACKLOG-CONVERGE — dead-letter classification + queue failure sink
635// ---------------------------------------------------------------------------
636
637/// Read-only `enrich --status` report (no LLM, no singleton).
638///
639/// GAP-SG-42: all queue counts are scoped to the current `--operation` (rows
640/// migrated before the `operation` column, which are NULL, are still counted so
641/// a legacy queue is not silently reported as empty).
642#[derive(Debug, Serialize, schemars::JsonSchema)]
643pub struct EnrichStatus {
644    pub(super) status_report: bool,
645    pub(super) operation: String,
646    pub(super) namespace: String,
647    pub(super) unbound_backlog: usize,
648    /// GAP-SG-77: DATABASE-semantics backlog for the queried operation, computed
649    /// by `scan::count_operation_backlog` via a `SELECT COUNT(*)` over the real
650    /// store. This is distinct from `queue_pending`/`queue_dead` (FILE/sidecar
651    /// queue semantics) and from the legacy `unbound_backlog` (memory-bindings
652    /// only). It fixes the false `pending=0` that db-backed operations
653    /// (entity-descriptions/body-enrich/re-embed) previously reported.
654    pub(super) scan_backlog: i64,
655    /// GAP-CLI-ED-STATUS-02: empty-description-only backlog (entity-descriptions).
656    #[serde(skip_serializing_if = "Option::is_none")]
657    pub(super) scan_backlog_empty: Option<i64>,
658    /// GAP-CLI-ED-STATUS-02: low-quality description backlog (entity-descriptions).
659    #[serde(skip_serializing_if = "Option::is_none")]
660    pub(super) scan_backlog_low_quality: Option<i64>,
661    /// Whether `--force-redescribe` was active for this status report.
662    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
663    pub(super) force_redescribe: bool,
664    /// Wave 2 / GAP-CLI-OBS-04: fraction of sampled descriptions grounded
665    /// against linked memory bodies (`grounding_coverage` ≥ threshold).
666    #[serde(skip_serializing_if = "Option::is_none")]
667    pub(super) quality_pct: Option<f64>,
668    /// Sample size used for `quality_pct` (entities inspected).
669    #[serde(skip_serializing_if = "Option::is_none")]
670    pub(super) quality_sample_n: Option<u32>,
671    /// Extrapolated count of low-grounding descriptions in the namespace.
672    #[serde(skip_serializing_if = "Option::is_none")]
673    pub(super) scan_backlog_low_grounding_est: Option<i64>,
674    /// G-PR-7: sampled entities that carry a description while having NO
675    /// linked corpus to justify it. These used to be scored as PERFECT
676    /// quality, because the sampler asked the same inverted grounding gate
677    /// that wrote them. A non-zero value here means `quality_pct` is being
678    /// dragged down by descriptions that should never have existed — that is
679    /// the honest reading, not a regression.
680    #[serde(skip_serializing_if = "Option::is_none")]
681    pub(super) sampled_without_corpus: Option<u32>,
682    /// G-PR-7: nearest-rank quantiles of the sampled grounding coverage.
683    ///
684    /// This is the surface that makes `grounding_threshold` calibratable with
685    /// data. Compare the configured threshold against `p10`/`p25`: a threshold
686    /// above `p25` rejects at least a quarter of what the corpus can support,
687    /// and one below `p10` is not filtering anything.
688    #[serde(skip_serializing_if = "Option::is_none")]
689    pub(super) grounding_percentiles: Option<super::quality_sample::GroundingPercentiles>,
690    /// The threshold that PRODUCED `quality_pct` and the percentiles above,
691    /// after the full `flag > XDG > constant` resolution.
692    ///
693    /// Emitted because the comparison the percentile doc asks for is impossible
694    /// without it: once the XDG channel went live, setting
695    /// `enrich.entity_description.grounding_threshold` swung `quality_pct` from
696    /// 0.719 to 0.262 on the same database and the same binary, and nothing in
697    /// this envelope named the cause. A reported rate whose governing value is
698    /// invisible is the same two-values-nobody-reconciles defect the threshold
699    /// itself had.
700    #[serde(skip_serializing_if = "Option::is_none")]
701    pub(super) grounding_threshold: Option<f64>,
702    pub(super) queue_pending: i64,
703    pub(super) queue_processing: i64,
704    pub(super) queue_done: i64,
705    pub(super) queue_failed: i64,
706    pub(super) queue_skipped: i64,
707    pub(super) queue_dead: i64,
708    pub(super) eligible_now: i64,
709    pub(super) waiting: i64,
710    /// GAP-SG-15/46: coarse backlog state, disambiguating an empty queue from a
711    /// not-yet-scanned backlog and from a cooldown wait.
712    /// `draining` | `cooldown` | `pending-scan` | `blocked_dead` (scan deficit
713    /// remains but only permanent dead queue rows remain — requeue/prune) |
714    /// `empty`.
715    pub(super) state: &'static str,
716    /// GAP-SG-16: per-item `next_retry_at` for every pending row currently in
717    /// backoff, so an operator can see exactly when each will become eligible.
718    pub(super) waiting_items: Vec<WaitingItem>,
719}
720
721/// GAP-SG-16: one pending queue row waiting on its backoff cooldown.
722#[derive(Debug, Serialize, schemars::JsonSchema)]
723pub struct WaitingItem {
724    pub(super) item_key: String,
725    pub(super) attempt: i64,
726    pub(super) next_retry_at: Option<String>,
727    pub(super) error_class: Option<String>,
728}
729
730/// GAP-SG-23: one dead-letter row reported by `--list-dead`.
731#[derive(Debug, Serialize, schemars::JsonSchema)]
732pub struct DeadItem {
733    pub(super) dead_item: bool,
734    pub(super) item_key: String,
735    pub(super) item_type: String,
736    pub(super) attempt: i64,
737    pub(super) error_class: Option<String>,
738    pub(super) error: Option<String>,
739    /// GAP-SG-72: `choices[0].finish_reason` from the OpenRouter response
740    /// that produced this failure, when one was decoded (e.g. `"length"`
741    /// for a max_tokens truncation). `None` for subprocess-provider modes
742    /// or failures that never reached a decoded response.
743    pub(super) finish_reason: Option<String>,
744    /// GAP-SG-72: `usage.prompt_tokens` from the same response, when known.
745    pub(super) input_tokens: Option<i64>,
746    /// GAP-SG-72: `usage.completion_tokens` from the same response, when known.
747    pub(super) output_tokens: Option<i64>,
748}
749
750/// GAP-SG-23/11: summary footer for `--list-dead` and `--requeue-dead`.
751#[derive(Debug, Serialize, schemars::JsonSchema)]
752pub struct DeadSummary {
753    pub(super) summary: bool,
754    pub(super) operation: String,
755    pub(super) namespace: String,
756    /// `list-dead` | `requeue-dead` | `prune-dead-orphans`
757    pub(super) action: &'static str,
758    pub(super) dead_total: i64,
759    pub(super) requeued: i64,
760    /// GAP-SG-66: `prune-dead-orphans` — dead rows removed because their
761    /// referenced memory no longer exists in the main DB for the namespace.
762    /// Zero for `list-dead` / `requeue-dead`.
763    pub(super) pruned: i64,
764}
765
766// claim/failure helpers in queue_ops.rs
767pub(super) use super::queue_ops::*;
768
769// GAP-SG-146: test modules are named for what they cover. The former
770// `tests_a`/`tests_b` split was by file size and told the reader nothing.
771// `test_fixtures` holds the helpers both halves used to duplicate verbatim.
772#[cfg(test)]
773#[path = "queue_claim_tests.rs"]
774mod claim_tests;
775#[cfg(test)]
776#[path = "queue_failure_tests.rs"]
777mod failure_tests;
778#[cfg(test)]
779#[path = "queue_lifecycle_tests.rs"]
780mod lifecycle_tests;
781#[cfg(test)]
782#[path = "queue_test_fixtures.rs"]
783mod test_fixtures;
784#[cfg(test)]
785#[path = "queue_transition_tests.rs"]
786mod transition_tests;