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