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.
210pub const PRIORITY_HOT: i64 = 100;
211
212/// Count pending queue rows at or above `min_priority` for an operation + namespace.
213///
214/// CAPA-G (2026-07-30): filter by `namespace` so HOT preemption for one ns does
215/// not see pending rows belonging to another namespace on a shared sidecar.
216pub(super) fn count_priority_pending(
217 queue_conn: &Connection,
218 operation: &str,
219 namespace: &str,
220 min_priority: i64,
221) -> Result<i64, rusqlite::Error> {
222 // GAP-SG-97: status is a string literal — must be quoted.
223 // Strict operation + namespace (aligned with dequeue_next_pending).
224 queue_conn.query_row(
225 "SELECT COUNT(*) FROM queue \
226 WHERE status='pending' \
227 AND operation = ?1 \
228 AND namespace = ?2 \
229 AND COALESCE(priority, 0) >= ?3",
230 rusqlite::params![operation, namespace, min_priority],
231 |r| r.get(0),
232 )
233}
234
235/// GAP-SG-12: enqueue one scan candidate, linking it to its `memory_id` and
236/// tagging it with the originating `operation`. For memory-keyed operations the
237/// id is resolved from `main_conn` so the cascade cleanup (GAP-SG-13) can target
238/// the queue row by `memory_id` even before the item is processed. Entity/id
239/// keyed operations leave `memory_id` NULL (the `item_key` carries the link).
240/// `INSERT OR IGNORE` preserves the v1.0.96 invariant that a dead-letter row is
241/// never resurrected by re-enqueue (item_key is UNIQUE).
242///
243/// v1.1.2 (Bug 4, D5): batch callers wrap a `queue_conn.transaction()` and pass
244/// `&tx` here (`Transaction` derefs to `Connection`), so hundreds of candidates
245/// commit in a single fsync instead of one-per-statement.
246pub(super) fn enqueue_candidate(
247 queue_conn: &Connection,
248 main_conn: &Connection,
249 namespace: &str,
250 key: &str,
251 item_type: &str,
252 operation: &str,
253) {
254 // G-PR-8 / GAP-SG-102: refuse to enqueue keys that do not exist in the
255 // target namespace (prevents multi-ns residual and CAPA6 dependency).
256 let memory_id: Option<i64> = if item_type == "memory" {
257 match main_conn.query_row(
258 "SELECT id FROM memories WHERE namespace=?1 AND name=?2 AND deleted_at IS NULL",
259 rusqlite::params![namespace, key],
260 |r| r.get(0),
261 ) {
262 Ok(id) => Some(id),
263 Err(_) => {
264 tracing::warn!(
265 target: "enrich",
266 namespace,
267 key,
268 "enqueue rejected: memory not found in namespace"
269 );
270 return;
271 }
272 }
273 } else if item_type == "entity" {
274 // v1.1.1 (P2) re-embed enqueues `entity:{name}` keys so drain can
275 // dispatch (`call_reembed` strips the prefix). Lookup must use the
276 // bare entity name — matching against the prefixed key rejects every
277 // candidate ("entity not found in namespace") and leaves the ~14k
278 // entity_embeddings residual stuck forever.
279 let entity_name = key.strip_prefix("entity:").unwrap_or(key);
280 match main_conn.query_row(
281 "SELECT id FROM entities WHERE namespace=?1 AND name=?2",
282 rusqlite::params![namespace, entity_name],
283 |r| r.get::<_, i64>(0),
284 ) {
285 Ok(_) => None,
286 Err(_) => {
287 tracing::warn!(
288 target: "enrich",
289 namespace,
290 key,
291 "enqueue rejected: entity not found in namespace"
292 );
293 return;
294 }
295 }
296 } else if item_type == "chunk" {
297 // CAPA: never trust the scanner alone for chunk keys — residual
298 // empty-ns / cross-ns queue rows used to land here and then fail at
299 // drain with HardFailure + circuit breaker.
300 let chunk_key = key.strip_prefix("chunk:").unwrap_or(key);
301 let Ok(chunk_id) = chunk_key.parse::<i64>() else {
302 tracing::warn!(
303 target: "enrich",
304 namespace,
305 key,
306 "enqueue rejected: invalid chunk id in re-embed key"
307 );
308 return;
309 };
310 match main_conn.query_row(
311 "SELECT c.id FROM memory_chunks c
312 JOIN memories m ON m.id = c.memory_id
313 WHERE c.id = ?1 AND m.namespace = ?2 AND m.deleted_at IS NULL",
314 rusqlite::params![chunk_id, namespace],
315 |r| r.get::<_, i64>(0),
316 ) {
317 Ok(_) => None,
318 Err(_) => {
319 tracing::warn!(
320 target: "enrich",
321 namespace,
322 key,
323 "enqueue rejected: chunk not found in namespace"
324 );
325 return;
326 }
327 }
328 } else {
329 // entity_pair / other prefixed keys: trust the scanner; still scope by ns.
330 None
331 };
332 if let Err(e) = queue_conn.execute(
333 "INSERT OR IGNORE INTO queue \
334 (namespace, item_key, item_type, status, operation, memory_id, priority) \
335 VALUES (?1, ?2, ?3, 'pending', ?4, ?5, 0)",
336 rusqlite::params![namespace, key, item_type, operation, memory_id],
337 ) {
338 tracing::warn!(target: "enrich", error = %e, "queue insert failed");
339 }
340}
341
342/// Enqueue an entity-keyed candidate with explicit priority (GAP-CLI-PRIO-02/03).
343pub(super) fn enqueue_candidate_with_priority(
344 queue_conn: &Connection,
345 key: &str,
346 item_type: &str,
347 operation: &str,
348 priority: i64,
349) {
350 // Priority hot-path historically lacked namespace; store under '' so the
351 // ternary UNIQUE still applies (callers that know the ns should use
352 // enqueue_candidate after validating the entity).
353 if let Err(e) = queue_conn.execute(
354 "INSERT OR IGNORE INTO queue \
355 (namespace, item_key, item_type, status, operation, memory_id, priority) \
356 VALUES ('', ?1, ?2, 'pending', ?3, NULL, ?4)",
357 rusqlite::params![key, item_type, operation, priority],
358 ) {
359 tracing::warn!(target: "enrich", error = %e, "priority queue insert failed");
360 } else {
361 // If the row already existed as pending with lower priority, bump it.
362 let _ = queue_conn.execute(
363 "UPDATE queue SET priority = MAX(COALESCE(priority, 0), ?2), status = CASE \
364 WHEN status IN ('done','skipped','dead') THEN status ELSE 'pending' END \
365 WHERE item_key = ?1 AND COALESCE(priority, 0) < ?2",
366 rusqlite::params![key, priority],
367 );
368 }
369}
370
371/// v1.1.2 (Bug 4): reset `processing` rows whose `claimed_at` is older than
372/// `max_age_secs`. A row stuck in `processing` after a kill -9 never clears its
373/// claim (no heartbeat, no done_at), so a subsequent run never re-selects it and
374/// the backlog appears permanently drained. Returns the number of rows reset to
375/// `pending`.
376pub fn reset_stale_processing_claims(
377 conn: &Connection,
378 max_age_secs: u64,
379) -> Result<usize, AppError> {
380 let reset = conn.execute(
381 "UPDATE queue SET status='pending', claimed_at=NULL \
382 WHERE status='processing' AND claimed_at IS NOT NULL \
383 AND CAST(strftime('%s','now') AS INTEGER) - claimed_at > ?1",
384 rusqlite::params![max_age_secs as i64],
385 )?;
386 Ok(reset)
387}
388
389/// v1.1.2 (Bug 4): refresh `claimed_at` on the currently-processed row so a slow
390/// LLM call (body-enrich can take 60s+) is not mistaken for a stale claim by a
391/// concurrent sweep. Called by the worker loop right after the dequeue claim.
392pub fn heartbeat(conn: &Connection, queue_id: i64) -> Result<(), AppError> {
393 conn.execute(
394 "UPDATE queue SET claimed_at = CAST(strftime('%s','now') AS INTEGER) WHERE id = ?1",
395 rusqlite::params![queue_id],
396 )?;
397 Ok(())
398}
399
400/// GAP-SG-69: item_keys vetoed `status='skipped'` for an operation. The
401/// body-enrich scan selects candidates purely by `LENGTH(body) <
402/// min_output_chars`, so a short body whose rewrite the preservation guard keeps
403/// rejecting would be re-scanned every pass and `--until-empty` would never
404/// converge. Callers exclude these keys so the scan returns only actionable
405/// items; `cleanup_queue_entry` clears the veto when the body actually changes,
406/// restoring the memory as a candidate.
407pub(super) fn skipped_item_keys(
408 conn: &Connection,
409 operation: &str,
410) -> Result<std::collections::HashSet<String>, AppError> {
411 let mut stmt = conn.prepare(
412 "SELECT item_key FROM queue WHERE status='skipped' AND (operation = ?1 OR operation IS NULL)",
413 )?;
414 let keys = stmt
415 .query_map(rusqlite::params![operation], |r| r.get::<_, String>(0))?
416 .collect::<Result<std::collections::HashSet<String>, _>>()?;
417 Ok(keys)
418}
419
420/// Queue `item_type` for an operation: entity-keyed operations use `"entity"`,
421/// entity-pair operations use `"entity_pair"`, every other (memory/id-keyed)
422/// operation uses `"memory"`.
423pub(super) fn item_type_for(operation: &EnrichOperation) -> &'static str {
424 match operation {
425 EnrichOperation::EntityDescriptions => "entity",
426 // v1.1.06: entity-connect enqueues `pair:{id1}:{id2}` keys — never
427 // treat them as memory names (prune_dead_orphans only reaps memory).
428 EnrichOperation::EntityConnect | EnrichOperation::CrossDomainBridges => "entity_pair",
429 _ => "memory",
430 }
431}
432
433/// v1.1.1 (P2): per-key `item_type` override for the re-embed targets.
434///
435/// Re-embed keys are prefixed with `entity:` / `chunk:` when `--target`
436/// selects a non-memory table; the queue row must carry the real item type
437/// so `prune_dead_orphans` (which only reaps `item_type='memory'` rows)
438/// never mistakes an entity/chunk key for an orphaned memory name.
439/// Unprefixed keys keep the operation-level default.
440///
441/// v1.1.06: `pair:{id1}:{id2}` → `"entity_pair"`.
442pub(super) fn item_type_for_key(key: &str, default: &'static str) -> &'static str {
443 if key.starts_with("pair:") {
444 "entity_pair"
445 } else if key.starts_with("entity:") {
446 "entity"
447 } else if key.starts_with("chunk:") {
448 "chunk"
449 } else {
450 default
451 }
452}
453
454/// GAP-SG-13: remove a memory's enrich-queue entry when the memory is deleted or
455/// force-merged, so the dead-letter / pending sidecar never references a row
456/// that no longer exists. Best-effort and a no-op when the queue file is absent
457/// (the common case after a clean run, which removes it). Targets BOTH
458/// `memory_id` (populated at enqueue for memory ops, GAP-SG-12) and `item_key`
459/// (the memory name) so pending rows enqueued before id resolution are also
460/// cleared. Errors are logged, never propagated — cleanup must not fail the
461/// caller's delete/upsert.
462pub fn cleanup_queue_entry(db_path: &std::path::Path, memory_id: i64, name: &str) {
463 let queue_path = crate::paths::sidecar_path(db_path, ".enrich-queue.sqlite");
464 if !queue_path.exists() {
465 return;
466 }
467 match open_queue_db(&queue_path) {
468 Ok(conn) => {
469 if let Err(e) = conn.execute(
470 "DELETE FROM queue WHERE memory_id = ?1 OR item_key = ?2",
471 rusqlite::params![memory_id, name],
472 ) {
473 tracing::warn!(target: "enrich", error = %e, memory_id, "enrich-queue cleanup failed");
474 }
475 }
476 Err(e) => {
477 tracing::warn!(target: "enrich", error = %e, "enrich-queue cleanup skipped (open failed)");
478 }
479 }
480}
481
482/// GAP-SG-66: prune ORPHAN dead-letter rows — `status='dead'` memory rows whose
483/// `item_key` (the memory name) no longer exists in the main DB for `namespace`.
484///
485/// These are terminal "not found" failures (the memory was renamed/purged after
486/// being enqueued): re-processing them just re-fails with the same not-found
487/// error, so `--requeue-dead` can never recover them and they inflate
488/// `queue_dead` forever. Read-only on the main DB; deletes only the
489/// confirmed-orphan rows from the queue sidecar. Entity-keyed dead rows
490/// (`item_type='entity'`) are left untouched — their key is an entity name, not
491/// a memory name. Returns the number of rows pruned.
492///
493/// The queue SELECT is scoped to `namespace` (strict equality, aligned with
494/// [`count_priority_pending`] and `dequeue_next_pending`). Without that scope a
495/// dead row belonging to namespace A was checked against namespace B's memories,
496/// found absent, and deleted as an orphan — silent cross-namespace data loss.
497pub(super) fn prune_dead_orphans(
498 queue_conn: &Connection,
499 main_conn: &Connection,
500 operation: &str,
501 namespace: &str,
502) -> Result<i64, AppError> {
503 let dead: Vec<(i64, String)> = {
504 let mut stmt = queue_conn.prepare(
505 "SELECT id, item_key FROM queue \
506 WHERE status='dead' AND item_type='memory' \
507 AND (operation = ?1 OR operation IS NULL) \
508 AND namespace = ?2 ORDER BY id",
509 )?;
510 let rows = stmt
511 .query_map(rusqlite::params![operation, namespace], |r| {
512 Ok((r.get(0)?, r.get(1)?))
513 })?
514 .collect::<Result<Vec<_>, _>>()?;
515 rows
516 };
517 let mut pruned = 0_i64;
518 for (id, name) in dead {
519 let exists = main_conn
520 .query_row(
521 "SELECT 1 FROM memories WHERE namespace=?1 AND name=?2 AND deleted_at IS NULL",
522 rusqlite::params![namespace, name],
523 |_| Ok(()),
524 )
525 .is_ok();
526 if !exists {
527 queue_conn.execute("DELETE FROM queue WHERE id=?1", rusqlite::params![id])?;
528 pruned += 1;
529 }
530 }
531 if pruned > 0 {
532 let _ = queue_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
533 }
534 Ok(pruned)
535}
536
537/// v1.1.2: prune dead ENTITY orphan rows — remove every `status='dead'`
538/// `item_type='entity'` row from the queue sidecar. Unlike
539/// [`prune_dead_orphans`], this does NOT consult the main DB: entity dead rows
540/// are terminal artifacts of re-extraction/rename and have no recovery path
541/// (re-running them re-fails the same way). Returns the number of rows pruned.
542///
543/// Scoped to `namespace` for the same reason as [`prune_dead_orphans`]: a prune
544/// asked for one namespace must never delete another namespace's dead rows.
545pub(super) fn prune_dead_entity_orphans(
546 queue_conn: &Connection,
547 operation: &str,
548 namespace: &str,
549) -> Result<i64, AppError> {
550 let pruned = queue_conn.execute(
551 "DELETE FROM queue \
552 WHERE status='dead' AND item_type='entity' \
553 AND (operation = ?1 OR operation IS NULL) \
554 AND namespace = ?2",
555 rusqlite::params![operation, namespace],
556 )? as i64;
557 if pruned > 0 {
558 let _ = queue_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
559 }
560 Ok(pruned)
561}
562
563// ---------------------------------------------------------------------------
564// GAP-ENRICH-BACKLOG-CONVERGE — dead-letter classification + queue failure sink
565// ---------------------------------------------------------------------------
566
567/// Read-only `enrich --status` report (no LLM, no singleton).
568///
569/// GAP-SG-42: all queue counts are scoped to the current `--operation` (rows
570/// migrated before the `operation` column, which are NULL, are still counted so
571/// a legacy queue is not silently reported as empty).
572#[derive(Debug, Serialize, schemars::JsonSchema)]
573pub struct EnrichStatus {
574 pub(super) status_report: bool,
575 pub(super) operation: String,
576 pub(super) namespace: String,
577 pub(super) unbound_backlog: usize,
578 /// GAP-SG-77: DATABASE-semantics backlog for the queried operation, computed
579 /// by `scan::count_operation_backlog` via a `SELECT COUNT(*)` over the real
580 /// store. This is distinct from `queue_pending`/`queue_dead` (FILE/sidecar
581 /// queue semantics) and from the legacy `unbound_backlog` (memory-bindings
582 /// only). It fixes the false `pending=0` that db-backed operations
583 /// (entity-descriptions/body-enrich/re-embed) previously reported.
584 pub(super) scan_backlog: i64,
585 /// GAP-CLI-ED-STATUS-02: empty-description-only backlog (entity-descriptions).
586 #[serde(skip_serializing_if = "Option::is_none")]
587 pub(super) scan_backlog_empty: Option<i64>,
588 /// GAP-CLI-ED-STATUS-02: low-quality description backlog (entity-descriptions).
589 #[serde(skip_serializing_if = "Option::is_none")]
590 pub(super) scan_backlog_low_quality: Option<i64>,
591 /// Whether `--force-redescribe` was active for this status report.
592 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
593 pub(super) force_redescribe: bool,
594 /// Wave 2 / GAP-CLI-OBS-04: fraction of sampled descriptions grounded
595 /// against linked memory bodies (`grounding_coverage` ≥ threshold).
596 #[serde(skip_serializing_if = "Option::is_none")]
597 pub(super) quality_pct: Option<f64>,
598 /// Sample size used for `quality_pct` (entities inspected).
599 #[serde(skip_serializing_if = "Option::is_none")]
600 pub(super) quality_sample_n: Option<u32>,
601 /// Extrapolated count of low-grounding descriptions in the namespace.
602 #[serde(skip_serializing_if = "Option::is_none")]
603 pub(super) scan_backlog_low_grounding_est: Option<i64>,
604 pub(super) queue_pending: i64,
605 pub(super) queue_processing: i64,
606 pub(super) queue_done: i64,
607 pub(super) queue_failed: i64,
608 pub(super) queue_skipped: i64,
609 pub(super) queue_dead: i64,
610 pub(super) eligible_now: i64,
611 pub(super) waiting: i64,
612 /// GAP-SG-15/46: coarse backlog state, disambiguating an empty queue from a
613 /// not-yet-scanned backlog and from a cooldown wait.
614 /// `draining` | `cooldown` | `pending-scan` | `blocked_dead` (scan deficit
615 /// remains but only permanent dead queue rows remain — requeue/prune) |
616 /// `empty`.
617 pub(super) state: &'static str,
618 /// GAP-SG-16: per-item `next_retry_at` for every pending row currently in
619 /// backoff, so an operator can see exactly when each will become eligible.
620 pub(super) waiting_items: Vec<WaitingItem>,
621}
622
623/// GAP-SG-16: one pending queue row waiting on its backoff cooldown.
624#[derive(Debug, Serialize, schemars::JsonSchema)]
625pub struct WaitingItem {
626 pub(super) item_key: String,
627 pub(super) attempt: i64,
628 pub(super) next_retry_at: Option<String>,
629 pub(super) error_class: Option<String>,
630}
631
632/// GAP-SG-23: one dead-letter row reported by `--list-dead`.
633#[derive(Debug, Serialize, schemars::JsonSchema)]
634pub struct DeadItem {
635 pub(super) dead_item: bool,
636 pub(super) item_key: String,
637 pub(super) item_type: String,
638 pub(super) attempt: i64,
639 pub(super) error_class: Option<String>,
640 pub(super) error: Option<String>,
641 /// GAP-SG-72: `choices[0].finish_reason` from the OpenRouter response
642 /// that produced this failure, when one was decoded (e.g. `"length"`
643 /// for a max_tokens truncation). `None` for subprocess-provider modes
644 /// or failures that never reached a decoded response.
645 pub(super) finish_reason: Option<String>,
646 /// GAP-SG-72: `usage.prompt_tokens` from the same response, when known.
647 pub(super) input_tokens: Option<i64>,
648 /// GAP-SG-72: `usage.completion_tokens` from the same response, when known.
649 pub(super) output_tokens: Option<i64>,
650}
651
652/// GAP-SG-23/11: summary footer for `--list-dead` and `--requeue-dead`.
653#[derive(Debug, Serialize, schemars::JsonSchema)]
654pub struct DeadSummary {
655 pub(super) summary: bool,
656 pub(super) operation: String,
657 pub(super) namespace: String,
658 /// `list-dead` | `requeue-dead` | `prune-dead-orphans`
659 pub(super) action: &'static str,
660 pub(super) dead_total: i64,
661 pub(super) requeued: i64,
662 /// GAP-SG-66: `prune-dead-orphans` — dead rows removed because their
663 /// referenced memory no longer exists in the main DB for the namespace.
664 /// Zero for `list-dead` / `requeue-dead`.
665 pub(super) pruned: i64,
666}
667
668// claim/failure helpers in queue_ops.rs
669pub(super) use super::queue_ops::*;
670
671// GAP-SG-146: test modules are named for what they cover. The former
672// `tests_a`/`tests_b` split was by file size and told the reader nothing.
673// `test_fixtures` holds the helpers both halves used to duplicate verbatim.
674#[cfg(test)]
675#[path = "queue_claim_tests.rs"]
676mod claim_tests;
677#[cfg(test)]
678#[path = "queue_failure_tests.rs"]
679mod failure_tests;
680#[cfg(test)]
681#[path = "queue_lifecycle_tests.rs"]
682mod lifecycle_tests;
683#[cfg(test)]
684#[path = "queue_test_fixtures.rs"]
685mod test_fixtures;
686#[cfg(test)]
687#[path = "queue_transition_tests.rs"]
688mod transition_tests;