use super::*;
use rusqlite::Connection;
use serde::Serialize;
use crate::errors::AppError;
pub(super) fn open_queue_db<P: AsRef<std::path::Path>>(path: P) -> Result<Connection, AppError> {
let conn = Connection::open(path)?;
conn.pragma_update(None, "journal_mode", "wal")?;
conn.pragma_update(None, "busy_timeout", crate::constants::BUSY_TIMEOUT_MILLIS)?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_key TEXT NOT NULL UNIQUE,
item_type TEXT NOT NULL DEFAULT 'memory',
status TEXT NOT NULL DEFAULT 'pending',
memory_id INTEGER,
entity_id INTEGER,
entities INTEGER DEFAULT 0,
rels INTEGER DEFAULT 0,
error TEXT,
cost_usd REAL DEFAULT 0.0,
attempt INTEGER DEFAULT 0,
elapsed_ms INTEGER,
created_at TEXT DEFAULT (datetime('now')),
done_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_enrich_queue_status ON queue(status);",
)?;
let mut has_error_class = false;
let mut has_next_retry_at = false;
let mut has_operation = false;
let mut has_finish_reason = false;
let mut has_input_tokens = false;
let mut has_output_tokens = false;
let mut has_claimed_at = false;
let mut has_priority = false;
{
let mut stmt = conn.prepare("PRAGMA table_info(queue)")?;
let names = stmt.query_map([], |r| r.get::<_, String>(1))?;
for name in names {
match name?.as_str() {
"error_class" => has_error_class = true,
"next_retry_at" => has_next_retry_at = true,
"operation" => has_operation = true,
"finish_reason" => has_finish_reason = true,
"input_tokens" => has_input_tokens = true,
"output_tokens" => has_output_tokens = true,
"claimed_at" => has_claimed_at = true,
"priority" => has_priority = true,
_ => {}
}
}
}
if !has_error_class {
conn.execute_batch("ALTER TABLE queue ADD COLUMN error_class TEXT")?;
}
if !has_next_retry_at {
conn.execute_batch("ALTER TABLE queue ADD COLUMN next_retry_at TEXT")?;
}
if !has_operation {
conn.execute_batch("ALTER TABLE queue ADD COLUMN operation TEXT")?;
}
if !has_finish_reason {
conn.execute_batch("ALTER TABLE queue ADD COLUMN finish_reason TEXT")?;
}
if !has_input_tokens {
conn.execute_batch("ALTER TABLE queue ADD COLUMN input_tokens INTEGER")?;
}
if !has_output_tokens {
conn.execute_batch("ALTER TABLE queue ADD COLUMN output_tokens INTEGER")?;
}
if !has_claimed_at {
conn.execute_batch("ALTER TABLE queue ADD COLUMN claimed_at INTEGER")?;
}
if !has_priority {
conn.execute_batch(
"ALTER TABLE queue ADD COLUMN priority INTEGER NOT NULL DEFAULT 0",
)?;
}
conn.execute(
"UPDATE queue SET operation = 'LegacyUnscoped' WHERE operation IS NULL OR operation = ''",
[],
)?;
conn.execute_batch(
"CREATE INDEX IF NOT EXISTS idx_enrich_queue_eligible ON queue(status, next_retry_at);
CREATE INDEX IF NOT EXISTS idx_enrich_queue_operation ON queue(operation, status);
CREATE INDEX IF NOT EXISTS idx_enrich_queue_memory ON queue(memory_id);
CREATE INDEX IF NOT EXISTS idx_enrich_queue_priority ON queue(status, priority DESC, id)",
)?;
Ok(conn)
}
pub const PRIORITY_HOT: i64 = 100;
pub(super) fn count_priority_pending(
queue_conn: &Connection,
operation: &str,
min_priority: i64,
) -> Result<i64, rusqlite::Error> {
queue_conn.query_row(
"SELECT COUNT(*) FROM queue \
WHERE status=pending \
AND (operation = ?1 OR operation IS NULL) \
AND COALESCE(priority, 0) >= ?2",
rusqlite::params![operation, min_priority],
|r| r.get(0),
)
}
pub(super) fn enqueue_candidate(
queue_conn: &Connection,
main_conn: &Connection,
namespace: &str,
key: &str,
item_type: &str,
operation: &str,
) {
let memory_id: Option<i64> = if item_type == "memory" {
main_conn
.query_row(
"SELECT id FROM memories WHERE namespace=?1 AND name=?2 AND deleted_at IS NULL",
rusqlite::params![namespace, key],
|r| r.get(0),
)
.ok()
} else {
None
};
if let Err(e) = queue_conn.execute(
"INSERT OR IGNORE INTO queue (item_key, item_type, status, operation, memory_id, priority) \
VALUES (?1, ?2, 'pending', ?3, ?4, 0)",
rusqlite::params![key, item_type, operation, memory_id],
) {
tracing::warn!(target: "enrich", error = %e, "queue insert failed");
}
}
pub(super) fn enqueue_candidate_with_priority(
queue_conn: &Connection,
key: &str,
item_type: &str,
operation: &str,
priority: i64,
) {
if let Err(e) = queue_conn.execute(
"INSERT OR IGNORE INTO queue (item_key, item_type, status, operation, memory_id, priority) \
VALUES (?1, ?2, 'pending', ?3, NULL, ?4)",
rusqlite::params![key, item_type, operation, priority],
) {
tracing::warn!(target: "enrich", error = %e, "priority queue insert failed");
} else {
let _ = queue_conn.execute(
"UPDATE queue SET priority = MAX(COALESCE(priority, 0), ?2), status = CASE \
WHEN status IN ('done','skipped','dead') THEN status ELSE 'pending' END \
WHERE item_key = ?1 AND COALESCE(priority, 0) < ?2",
rusqlite::params![key, priority],
);
}
}
pub fn reset_stale_processing_claims(
conn: &Connection,
max_age_secs: u64,
) -> Result<usize, AppError> {
let reset = conn.execute(
"UPDATE queue SET status='pending', claimed_at=NULL \
WHERE status='processing' AND claimed_at IS NOT NULL \
AND CAST(strftime('%s','now') AS INTEGER) - claimed_at > ?1",
rusqlite::params![max_age_secs as i64],
)?;
Ok(reset)
}
pub fn heartbeat(conn: &Connection, queue_id: i64) -> Result<(), AppError> {
conn.execute(
"UPDATE queue SET claimed_at = CAST(strftime('%s','now') AS INTEGER) WHERE id = ?1",
rusqlite::params![queue_id],
)?;
Ok(())
}
pub(super) fn skipped_item_keys(
conn: &Connection,
operation: &str,
) -> Result<std::collections::HashSet<String>, AppError> {
let mut stmt = conn.prepare(
"SELECT item_key FROM queue WHERE status='skipped' AND (operation = ?1 OR operation IS NULL)",
)?;
let keys = stmt
.query_map(rusqlite::params![operation], |r| r.get::<_, String>(0))?
.collect::<Result<std::collections::HashSet<String>, _>>()?;
Ok(keys)
}
pub(super) fn item_type_for(operation: &EnrichOperation) -> &'static str {
match operation {
EnrichOperation::EntityDescriptions => "entity",
EnrichOperation::EntityConnect | EnrichOperation::CrossDomainBridges => "entity_pair",
_ => "memory",
}
}
pub(super) fn item_type_for_key(key: &str, default: &'static str) -> &'static str {
if key.starts_with("pair:") {
"entity_pair"
} else if key.starts_with("entity:") {
"entity"
} else if key.starts_with("chunk:") {
"chunk"
} else {
default
}
}
pub fn cleanup_queue_entry(db_path: &std::path::Path, memory_id: i64, name: &str) {
let queue_path = crate::paths::sidecar_path(db_path, ".enrich-queue.sqlite");
if !queue_path.exists() {
return;
}
match open_queue_db(&queue_path) {
Ok(conn) => {
if let Err(e) = conn.execute(
"DELETE FROM queue WHERE memory_id = ?1 OR item_key = ?2",
rusqlite::params![memory_id, name],
) {
tracing::warn!(target: "enrich", error = %e, memory_id, "enrich-queue cleanup failed");
}
}
Err(e) => {
tracing::warn!(target: "enrich", error = %e, "enrich-queue cleanup skipped (open failed)");
}
}
}
pub(super) fn prune_dead_orphans(
queue_conn: &Connection,
main_conn: &Connection,
operation: &str,
namespace: &str,
) -> Result<i64, AppError> {
let dead: Vec<(i64, String)> = {
let mut stmt = queue_conn.prepare(
"SELECT id, item_key FROM queue \
WHERE status='dead' AND item_type='memory' \
AND (operation = ?1 OR operation IS NULL) ORDER BY id",
)?;
let rows = stmt
.query_map(rusqlite::params![operation], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect::<Result<Vec<_>, _>>()?;
rows
};
let mut pruned = 0_i64;
for (id, name) in dead {
let exists = main_conn
.query_row(
"SELECT 1 FROM memories WHERE namespace=?1 AND name=?2 AND deleted_at IS NULL",
rusqlite::params![namespace, name],
|_| Ok(()),
)
.is_ok();
if !exists {
queue_conn.execute("DELETE FROM queue WHERE id=?1", rusqlite::params![id])?;
pruned += 1;
}
}
if pruned > 0 {
let _ = queue_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
}
Ok(pruned)
}
pub(super) fn prune_dead_entity_orphans(
queue_conn: &Connection,
operation: &str,
) -> Result<i64, AppError> {
let pruned = queue_conn.execute(
"DELETE FROM queue \
WHERE status='dead' AND item_type='entity' \
AND (operation = ?1 OR operation IS NULL)",
rusqlite::params![operation],
)? as i64;
if pruned > 0 {
let _ = queue_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
}
Ok(pruned)
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct EnrichStatus {
pub(super) status_report: bool,
pub(super) operation: String,
pub(super) namespace: String,
pub(super) unbound_backlog: usize,
pub(super) scan_backlog: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) scan_backlog_empty: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) scan_backlog_low_quality: Option<i64>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub(super) force_redescribe: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) quality_pct: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) quality_sample_n: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) scan_backlog_low_grounding_est: Option<i64>,
pub(super) queue_pending: i64,
pub(super) queue_processing: i64,
pub(super) queue_done: i64,
pub(super) queue_failed: i64,
pub(super) queue_skipped: i64,
pub(super) queue_dead: i64,
pub(super) eligible_now: i64,
pub(super) waiting: i64,
pub(super) state: &'static str,
pub(super) waiting_items: Vec<WaitingItem>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct WaitingItem {
pub(super) item_key: String,
pub(super) attempt: i64,
pub(super) next_retry_at: Option<String>,
pub(super) error_class: Option<String>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct DeadItem {
pub(super) dead_item: bool,
pub(super) item_key: String,
pub(super) item_type: String,
pub(super) attempt: i64,
pub(super) error_class: Option<String>,
pub(super) error: Option<String>,
pub(super) finish_reason: Option<String>,
pub(super) input_tokens: Option<i64>,
pub(super) output_tokens: Option<i64>,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct DeadSummary {
pub(super) summary: bool,
pub(super) operation: String,
pub(super) namespace: String,
pub(super) action: &'static str,
pub(super) dead_total: i64,
pub(super) requeued: i64,
pub(super) pruned: i64,
}
pub(super) use super::queue_ops::*;
#[cfg(test)]
#[path = "queue_tests_a.rs"]
mod tests_a;
#[cfg(test)]
#[path = "queue_tests_b.rs"]
mod tests_b;