use super::*;
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,
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 {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], |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)),
}
}