use super::args::{EnrichArgs, EnrichOperation};
use super::queue::{
open_queue_db, prune_dead_entity_orphans, prune_dead_orphans, reset_stale_processing_claims,
DeadItem, DeadSummary, EnrichStatus, WaitingItem,
};
use super::scan::{
count_operation_backlog_with_force, sample_entity_description_quality, scan_unbound_memories,
DEFAULT_QUALITY_SAMPLE_N,
};
use crate::errors::AppError;
use crate::output::emit_json_line as emit_json;
use crate::paths::AppPaths;
use crate::storage::connection::{ensure_db_ready, open_rw};
pub(crate) fn try_handle_maintenance(args: &EnrichArgs) -> Result<bool, AppError> {
if args.list_dead
|| args.requeue_dead
|| args.prune_dead_orphans
|| args.prune_dead_entity_orphans
{
let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
let op_label = format!("{:?}", args.operation());
let paths = AppPaths::resolve(args.db.as_deref())?;
let queue_path = crate::paths::sidecar_path(&paths.db, ".enrich-queue.sqlite");
let queue_conn = open_queue_db(&queue_path)?;
if args.prune_dead_entity_orphans {
let pruned = prune_dead_entity_orphans(&queue_conn, &op_label)?;
let dead_total: i64 = queue_conn
.query_row(
"SELECT COUNT(*) FROM queue WHERE status='dead' \
AND item_type='entity' \
AND (operation = ?1 OR operation IS NULL)",
rusqlite::params![op_label],
|r| r.get(0),
)
.unwrap_or(0);
emit_json(&DeadSummary {
summary: true,
operation: op_label,
namespace,
action: "prune-dead-entity-orphans",
dead_total,
requeued: 0,
pruned,
});
return Ok(true);
}
if args.prune_dead_orphans {
ensure_db_ready(&paths)?;
let main_conn = open_rw(&paths.db)?;
let pruned = prune_dead_orphans(&queue_conn, &main_conn, &op_label, &namespace)?;
let dead_total: i64 = queue_conn
.query_row(
"SELECT COUNT(*) FROM queue WHERE status='dead' \
AND (operation = ?1 OR operation IS NULL)",
rusqlite::params![op_label],
|r| r.get(0),
)
.unwrap_or(0);
emit_json(&DeadSummary {
summary: true,
operation: op_label,
namespace,
action: "prune-dead-orphans",
dead_total,
requeued: 0,
pruned,
});
return Ok(true);
}
if args.list_dead {
let mut stmt = queue_conn.prepare(
"SELECT item_key, item_type, attempt, error_class, error, \
finish_reason, input_tokens, output_tokens FROM queue \
WHERE status='dead' AND (operation = ?1 OR operation IS NULL) ORDER BY id",
)?;
let rows = stmt
.query_map(rusqlite::params![op_label], |r| {
Ok(DeadItem {
dead_item: true,
item_key: r.get(0)?,
item_type: r.get(1)?,
attempt: r.get(2)?,
error_class: r.get(3)?,
error: r.get(4)?,
finish_reason: r.get(5)?,
input_tokens: r.get(6)?,
output_tokens: r.get(7)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
let dead_total = rows.len() as i64;
for item in &rows {
emit_json(item);
}
emit_json(&DeadSummary {
summary: true,
operation: op_label,
namespace,
action: "list-dead",
dead_total,
requeued: 0,
pruned: 0,
});
return Ok(true);
}
let dead_total: i64 = queue_conn
.query_row(
"SELECT COUNT(*) FROM queue WHERE status='dead' \
AND (operation = ?1 OR operation IS NULL)",
rusqlite::params![op_label],
|r| r.get(0),
)
.unwrap_or(0);
let requeued = queue_conn
.execute(
"UPDATE queue SET status='pending', attempt=0, next_retry_at=NULL, \
error=NULL, error_class=NULL \
WHERE status='dead' AND (operation = ?1 OR operation IS NULL)",
rusqlite::params![op_label],
)
.map_err(|e| AppError::Validation(format!("requeue-dead failed: {e}")))?
as i64;
let _ = queue_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
emit_json(&DeadSummary {
summary: true,
operation: op_label,
namespace,
action: "requeue-dead",
dead_total,
requeued,
pruned: 0,
});
return Ok(true);
}
if args.status {
let paths = AppPaths::resolve(args.db.as_deref())?;
ensure_db_ready(&paths)?;
let conn = open_rw(&paths.db)?;
let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
let unbound_backlog = scan_unbound_memories(&conn, &namespace, None, &[])?.len();
let scan_backlog = count_operation_backlog_with_force(
&conn,
&args.operation(),
&namespace,
args.target,
args.force_redescribe,
)?;
let scan_backlog_empty = if matches!(args.operation(), EnrichOperation::EntityDescriptions)
{
Some(count_operation_backlog_with_force(
&conn,
&args.operation(),
&namespace,
args.target,
false,
)?)
} else {
None
};
let scan_backlog_low_quality = if matches!(
args.operation(),
EnrichOperation::EntityDescriptions
) && args.force_redescribe
{
Some(
scan_backlog
- scan_backlog_empty.unwrap_or(0),
)
} else if matches!(args.operation(), EnrichOperation::EntityDescriptions) {
let with_force = count_operation_backlog_with_force(
&conn,
&args.operation(),
&namespace,
args.target,
true,
)?;
Some(with_force - scan_backlog)
} else {
None
};
let (quality_pct, quality_sample_n, scan_backlog_low_grounding_est) =
if matches!(args.operation(), EnrichOperation::EntityDescriptions) {
let sample_n = crate::runtime_config::resolve_usize(
args.quality_sample,
"enrich.entity_description.quality_sample",
DEFAULT_QUALITY_SAMPLE_N,
);
if sample_n == 0 {
(None, None, None)
} else {
let sample = sample_entity_description_quality(
&conn,
&namespace,
sample_n,
args.entity_description_grounding_threshold,
)?;
(
Some(sample.quality_pct),
Some(sample.sampled),
Some(sample.low_grounding_est),
)
}
} else {
(None, None, None)
};
let queue_path = crate::paths::sidecar_path(&paths.db, ".enrich-queue.sqlite");
let queue_conn = open_queue_db(&queue_path)?;
let op_label = format!("{:?}", args.operation());
let count_status = |st: &str, op: &str| -> i64 {
queue_conn
.query_row(
"SELECT COUNT(*) FROM queue WHERE status=?1 \
AND (operation = ?2 OR operation IS NULL)",
rusqlite::params![st, op],
|r| r.get(0),
)
.unwrap_or(0)
};
let eligible_now: i64 = queue_conn
.query_row(
"SELECT COUNT(*) FROM queue WHERE status='pending' \
AND (operation = ?1 OR operation IS NULL) \
AND (next_retry_at IS NULL OR next_retry_at <= datetime('now'))",
rusqlite::params![op_label],
|r| r.get(0),
)
.unwrap_or(0);
let waiting: i64 = queue_conn
.query_row(
"SELECT COUNT(*) FROM queue WHERE status='pending' \
AND (operation = ?1 OR operation IS NULL) \
AND next_retry_at IS NOT NULL AND next_retry_at > datetime('now')",
rusqlite::params![op_label],
|r| r.get(0),
)
.unwrap_or(0);
let waiting_items = {
let mut stmt = queue_conn.prepare(
"SELECT item_key, attempt, next_retry_at, error_class FROM queue \
WHERE status='pending' AND (operation = ?1 OR operation IS NULL) \
AND next_retry_at IS NOT NULL AND next_retry_at > datetime('now') \
ORDER BY next_retry_at",
)?;
let items: Vec<WaitingItem> = stmt
.query_map(rusqlite::params![op_label], |r| {
Ok(WaitingItem {
item_key: r.get(0)?,
attempt: r.get(1)?,
next_retry_at: r.get(2)?,
error_class: r.get(3)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
items
};
let queue_pending = count_status("pending", &op_label);
let queue_processing = count_status("processing", &op_label);
let queue_done = count_status("done", &op_label);
let queue_failed = count_status("failed", &op_label);
let queue_skipped = count_status("skipped", &op_label);
let queue_dead = count_status("dead", &op_label);
let state = if eligible_now > 0 {
"draining"
} else if waiting > 0 {
"cooldown"
} else if queue_pending == 0 && scan_backlog > 0 && queue_dead > 0 {
"blocked_dead"
} else if queue_pending == 0 && scan_backlog > 0 {
"pending-scan"
} else {
"empty"
};
emit_json(&EnrichStatus {
status_report: true,
operation: op_label,
namespace,
unbound_backlog,
scan_backlog,
scan_backlog_empty,
scan_backlog_low_quality,
force_redescribe: args.force_redescribe,
quality_pct,
quality_sample_n,
scan_backlog_low_grounding_est,
queue_pending,
queue_processing,
queue_done,
queue_failed,
queue_skipped,
queue_dead,
eligible_now,
waiting,
state,
waiting_items,
});
return Ok(true);
}
if args.reset_stale_claims {
let paths = AppPaths::resolve(args.db.as_deref())?;
ensure_db_ready(&paths)?;
let queue_path = crate::paths::sidecar_path(&paths.db, ".enrich-queue.sqlite");
let queue_conn = open_queue_db(&queue_path)?;
let reset = reset_stale_processing_claims(&queue_conn, args.stale_claim_secs)?;
let _ = queue_conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
tracing::info!(
target: "enrich",
reset,
max_age_secs = args.stale_claim_secs,
"reset stale processing claims"
);
emit_json(&serde_json::json!({
"reset_stale_claims": true,
"reset": reset,
"max_age_secs": args.stale_claim_secs,
}));
return Ok(true);
}
Ok(false)
}