use crate::errors::AppError;
pub(super) fn classify_enrich_outcome(e: &AppError) -> crate::retry::AttemptOutcome {
use crate::retry::AttemptOutcome;
match e {
AppError::RateLimited { .. } | AppError::Timeout { .. } | AppError::DbBusy(_) => {
AttemptOutcome::Transient
}
AppError::EntityNotYetMaterialized { .. } => AttemptOutcome::Transient,
AppError::ProviderError { .. }
| AppError::NotFound(_)
| AppError::MemoryNotFound { .. }
| AppError::MemoryNotFoundById { .. } => AttemptOutcome::HardFailure,
AppError::Database(_) => {
if crate::storage::utils::is_sqlite_busy(e) {
AttemptOutcome::Transient
} else {
AttemptOutcome::HardFailure
}
}
AppError::Embedding(_) => AttemptOutcome::Transient,
_ => AttemptOutcome::HardFailure,
}
}
pub(super) fn record_item_failure(
queue_conn: &rusqlite::Connection,
queue_id: i64,
attempt: i64,
max_attempts: u32,
err: &AppError,
) -> crate::retry::AttemptOutcome {
let outcome = classify_enrich_outcome(err);
let err_str = format!("{err}");
record_item_failure_typed(
queue_conn,
queue_id,
attempt,
max_attempts,
outcome,
&err_str,
None,
None,
None,
)
}
#[allow(clippy::too_many_arguments)]
pub(super) fn record_item_failure_typed(
queue_conn: &rusqlite::Connection,
queue_id: i64,
attempt: i64,
max_attempts: u32,
outcome: crate::retry::AttemptOutcome,
err_str: &str,
finish_reason: Option<&str>,
input_tokens: Option<i64>,
output_tokens: Option<i64>,
) -> crate::retry::AttemptOutcome {
use crate::retry::AttemptOutcome;
let error_class = match outcome {
AttemptOutcome::Transient => "transient",
AttemptOutcome::HardFailure => "permanent",
AttemptOutcome::Success => "success",
};
let terminal = matches!(outcome, AttemptOutcome::HardFailure) || attempt >= max_attempts as i64;
if terminal {
let _ = queue_conn.execute(
"UPDATE queue SET status='dead', error=?1, error_class=?2, done_at=datetime('now'), \
finish_reason=?3, input_tokens=?4, output_tokens=?5 WHERE id=?6",
rusqlite::params![
err_str,
error_class,
finish_reason,
input_tokens,
output_tokens,
queue_id
],
);
} else {
let delay = crate::retry::compute_delay(
&crate::retry::RetryConfig::llm_rate_limit(),
attempt.max(0) as u32,
);
let secs = delay.as_secs().max(1);
let modifier = format!("+{secs} seconds");
let _ = queue_conn.execute(
"UPDATE queue SET status='pending', error=?1, error_class=?2, next_retry_at=datetime('now', ?3), \
finish_reason=?4, input_tokens=?5, output_tokens=?6 WHERE id=?7",
rusqlite::params![
err_str,
error_class,
modifier,
finish_reason,
input_tokens,
output_tokens,
queue_id
],
);
}
outcome
}
pub(super) enum DequeueOutcome {
Claimed(ClaimedRow),
Empty,
}
#[derive(Debug, Clone)]
pub(super) struct ClaimedRow {
pub id: i64,
pub item_key: String,
pub item_type: String,
pub operation: String,
pub attempt: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum ClaimCheck {
Ok,
RequeueWrongOp,
SkipWrongType { reason: String },
}
pub(super) fn is_non_memory_key_shape(item_key: &str) -> bool {
item_key.starts_with("pair:")
|| item_key.starts_with("entity:")
|| item_key.starts_with("chunk:")
}
pub(super) fn validate_claim(
row: &ClaimedRow,
current_op: &str,
expected_item_type: &str,
) -> ClaimCheck {
if row.operation != current_op {
return ClaimCheck::RequeueWrongOp;
}
if current_op == "ReEmbed" {
return ClaimCheck::Ok;
}
if expected_item_type == "memory" && is_non_memory_key_shape(&row.item_key) {
return ClaimCheck::SkipWrongType {
reason: format!(
"wrong_key_shape_for_operation:{current_op}: key looks like {}",
row.item_key.split(':').next().unwrap_or("prefixed")
),
};
}
if expected_item_type == "memory" && row.item_type != "memory" {
return ClaimCheck::SkipWrongType {
reason: format!(
"wrong_item_type_for_operation:{current_op}: got item_type={}",
row.item_type
),
};
}
if expected_item_type == "entity"
&& row.item_type != "entity"
&& !row.item_key.starts_with("entity:")
{
if row.item_type == "entity_pair" || is_non_memory_key_shape(&row.item_key) {
return ClaimCheck::SkipWrongType {
reason: format!(
"wrong_item_type_for_operation:{current_op}: got item_type={}",
row.item_type
),
};
}
}
if expected_item_type == "entity_pair"
&& row.item_type != "entity_pair"
&& !row.item_key.starts_with("pair:")
{
return ClaimCheck::SkipWrongType {
reason: format!(
"wrong_item_type_for_operation:{current_op}: got item_type={}",
row.item_type
),
};
}
ClaimCheck::Ok
}
pub(super) fn requeue_wrong_op(
queue_conn: &rusqlite::Connection,
queue_id: i64,
) -> Result<(), AppError> {
queue_conn.execute(
"UPDATE queue SET status='pending', \
attempt=CASE WHEN attempt > 0 THEN attempt - 1 ELSE 0 END, \
claimed_at=NULL, error=NULL, error_class=NULL \
WHERE id=?1",
rusqlite::params![queue_id],
)?;
Ok(())
}
pub(super) fn skip_wrong_type(
queue_conn: &rusqlite::Connection,
queue_id: i64,
reason: &str,
) -> Result<(), AppError> {
queue_conn.execute(
"UPDATE queue SET status='skipped', error=?1, done_at=datetime('now'), claimed_at=NULL \
WHERE id=?2",
rusqlite::params![reason, queue_id],
)?;
Ok(())
}
pub(super) fn dequeue_next_pending(
queue_conn: &rusqlite::Connection,
operation: &str,
namespace: &str,
backoff_clause: &str,
) -> Result<DequeueOutcome, AppError> {
let dequeue_sql = format!(
"UPDATE queue SET status='processing', attempt=attempt+1, \
claimed_at=CAST(strftime('%s','now') AS INTEGER) \
WHERE id = (SELECT id FROM queue WHERE status='pending' \
AND operation = ?1 \
AND namespace = ?2 \
{backoff_clause} \
ORDER BY COALESCE(priority, 0) DESC, id ASC LIMIT 1) \
RETURNING id, item_key, item_type, COALESCE(operation,''), attempt"
);
match queue_conn.query_row(
&dequeue_sql,
rusqlite::params![operation, namespace],
|row| {
Ok(ClaimedRow {
id: row.get(0)?,
item_key: row.get(1)?,
item_type: row.get(2)?,
operation: row.get(3)?,
attempt: row.get(4)?,
})
},
) {
Ok(claimed) => Ok(DequeueOutcome::Claimed(claimed)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(DequeueOutcome::Empty),
Err(e) => Err(AppError::Database(e)),
}
}
pub(super) fn count_eligible_pending(
queue_conn: &rusqlite::Connection,
operation: &str,
namespace: &str,
backoff_clause: &str,
) -> i64 {
let sql = format!(
"SELECT COUNT(*) FROM queue WHERE status='pending' \
AND operation = ?1 AND namespace = ?2 {backoff_clause}"
);
queue_conn
.query_row(&sql, rusqlite::params![operation, namespace], |r| r.get(0))
.unwrap_or(0)
}
pub(super) fn reopen_force_redescribe_candidates(
queue_conn: &rusqlite::Connection,
namespace: &str,
keys: &[String],
) -> usize {
if keys.is_empty() {
return 0;
}
let mut total = 0usize;
for key in keys {
match queue_conn.execute(
"UPDATE queue SET status='pending', attempt=0, next_retry_at=NULL, \
error=NULL, error_class=NULL, claimed_at=NULL, done_at=NULL \
WHERE operation = 'EntityDescriptions' \
AND namespace = ?1 \
AND item_key = ?2 \
AND status IN ('skipped', 'done')",
rusqlite::params![namespace, key],
) {
Ok(n) => total = total.saturating_add(n),
Err(e) => {
tracing::warn!(
target: "enrich",
error = %e,
key,
"force-redescribe reopen failed for key"
);
}
}
}
total
}
pub(super) fn reset_processing_for_op(
queue_conn: &rusqlite::Connection,
operation: &str,
namespace: &str,
) -> Result<usize, AppError> {
let n = queue_conn.execute(
"UPDATE queue SET status='pending', claimed_at=NULL \
WHERE status='processing' AND operation = ?1 AND namespace = ?2",
rusqlite::params![operation, namespace],
)?;
Ok(n)
}
pub(super) fn reset_failed_for_op(
queue_conn: &rusqlite::Connection,
operation: &str,
namespace: &str,
) -> Result<usize, AppError> {
let n = queue_conn.execute(
"UPDATE queue SET status='pending', attempt=0, next_retry_at=NULL, \
error=NULL, error_class=NULL, claimed_at=NULL \
WHERE status='failed' AND operation = ?1 AND namespace = ?2",
rusqlite::params![operation, namespace],
)?;
Ok(n)
}
pub(super) fn entity_has_live_embedding(
main_conn: &rusqlite::Connection,
entity_id: i64,
dim: usize,
) -> bool {
let bytes = (dim * 4) as i64;
main_conn
.query_row(
"SELECT 1 FROM entity_embeddings \
WHERE entity_id = ?1 AND LENGTH(embedding) = ?2 LIMIT 1",
rusqlite::params![entity_id, bytes],
|_| Ok(()),
)
.is_ok()
}
pub(super) fn memory_has_live_embedding(
main_conn: &rusqlite::Connection,
memory_id: i64,
dim: usize,
) -> bool {
let bytes = (dim * 4) as i64;
main_conn
.query_row(
"SELECT 1 FROM memory_embeddings \
WHERE memory_id = ?1 AND LENGTH(embedding) = ?2 LIMIT 1",
rusqlite::params![memory_id, bytes],
|_| Ok(()),
)
.is_ok()
}
pub(super) fn chunk_has_live_embedding(
main_conn: &rusqlite::Connection,
chunk_id: i64,
dim: usize,
) -> bool {
let bytes = (dim * 4) as i64;
main_conn
.query_row(
"SELECT 1 FROM chunk_embeddings \
WHERE chunk_id = ?1 AND LENGTH(embedding) = ?2 LIMIT 1",
rusqlite::params![chunk_id, bytes],
|_| Ok(()),
)
.is_ok()
}
pub(super) fn reconcile_satisfied_reembed_pending(
main_conn: &rusqlite::Connection,
queue_conn: &rusqlite::Connection,
namespace: &str,
) -> Result<usize, AppError> {
let dim = crate::constants::embedding_dim();
let mut stmt = queue_conn.prepare(
"SELECT id, item_key FROM queue \
WHERE status='pending' AND operation='ReEmbed' AND namespace=?1",
)?;
let rows: Vec<(i64, String)> = stmt
.query_map(rusqlite::params![namespace], |r| {
Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?))
})?
.collect::<Result<Vec<_>, _>>()?;
let mut reconciled = 0usize;
for (id, key) in rows {
let satisfied = if let Some(name) = key.strip_prefix("entity:") {
match main_conn.query_row(
"SELECT id FROM entities WHERE namespace=?1 AND name=?2",
rusqlite::params![namespace, name],
|r| r.get::<_, i64>(0),
) {
Ok(eid) => entity_has_live_embedding(main_conn, eid, dim),
Err(_) => false,
}
} else if let Some(chunk_key) = key.strip_prefix("chunk:") {
match chunk_key.parse::<i64>() {
Ok(cid) => chunk_has_live_embedding(main_conn, cid, dim),
Err(_) => false,
}
} else {
match 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::<_, i64>(0),
) {
Ok(mid) => memory_has_live_embedding(main_conn, mid, dim),
Err(_) => false,
}
};
if !satisfied {
continue;
}
let n = queue_conn.execute(
"UPDATE queue SET status='done', done_at=datetime('now'), claimed_at=NULL, \
error='reconciled: live embedding already present' \
WHERE id=?1 AND status='pending'",
rusqlite::params![id],
)?;
reconciled = reconciled.saturating_add(n);
}
Ok(reconciled)
}