use super::embed::call_reembed_batch;
use crate::commands::enrich::events::ItemEvent;
use crate::commands::enrich::extraction::EnrichItemResult;
use crate::commands::enrich::queue::{
dequeue_batch_pending, heartbeat, mark_done, mark_skipped, record_item_failure, writeback,
};
use crate::errors::AppError;
use crate::output::emit_json_line as emit_json;
use rusqlite::Connection;
use std::time::Instant;
pub(in crate::commands::enrich) struct ReembedCycleCtx<'a> {
pub main_conn: &'a Connection,
pub queue_conn: &'a Connection,
pub namespace: &'a str,
pub op_label: &'a str,
pub backoff_clause: &'a str,
pub paths: &'a crate::paths::AppPaths,
pub backends: crate::cli::BackendChoice,
pub max_attempts: u32,
pub total: usize,
pub stdout_mu: Option<&'a parking_lot::Mutex<()>>,
}
#[derive(Default)]
pub(in crate::commands::enrich) struct ReembedTally {
pub completed: usize,
pub failed: usize,
pub skipped: usize,
}
pub(in crate::commands::enrich) enum ReembedCycle {
Empty,
Progressed,
DbBusy,
BreakerOpen,
}
pub(in crate::commands::enrich) fn run_reembed_cycle(
ctx: &ReembedCycleCtx<'_>,
tally: &mut ReembedTally,
mut breaker: Option<&mut crate::retry::CircuitBreaker>,
) -> ReembedCycle {
let limit = crate::runtime_config::reembed_claim_batch();
let claimed = match crate::storage::utils::with_busy_retry(|| {
dequeue_batch_pending(
ctx.queue_conn,
ctx.op_label,
ctx.namespace,
ctx.backoff_clause,
limit,
)
}) {
Ok(rows) => rows,
Err(AppError::DbBusy(msg)) => {
tracing::error!(target: "enrich", error = %msg, "SQLITE_BUSY exhausted bounded retries, aborting re-embed batch claim");
return ReembedCycle::DbBusy;
}
Err(e) => {
tracing::error!(target: "enrich", error = %e, "re-embed batch claim failed");
return ReembedCycle::Empty;
}
};
if claimed.is_empty() {
return ReembedCycle::Empty;
}
for row in &claimed {
let _ = writeback("heartbeat", 0, &row.item_key, || {
heartbeat(ctx.queue_conn, row.id)
});
}
let keys: Vec<String> = claimed.iter().map(|r| r.item_key.clone()).collect();
let started = Instant::now();
let batch = call_reembed_batch(ctx.main_conn, ctx.namespace, &keys, ctx.paths, ctx.backends);
let elapsed_ms = started.elapsed().as_millis() as i64;
let outcomes = match batch {
Ok(v) => v,
Err(e) => {
let err_str = format!("{e}");
let mut outcome = crate::retry::AttemptOutcome::HardFailure;
for row in &claimed {
outcome =
record_item_failure(ctx.queue_conn, row.id, row.attempt, ctx.max_attempts, &e);
let index = tally.completed + tally.failed + tally.skipped;
tally.failed += 1;
emit_item_event(
ctx,
&row.item_key,
"failed",
None,
Some(err_str.clone()),
elapsed_ms,
index,
);
}
if let Some(b) = breaker.as_deref_mut() {
if b.record(outcome) {
tracing::error!(target: "enrich",
consecutive_failures = b.consecutive_failures(),
"circuit breaker opened — aborting worker"
);
return ReembedCycle::BreakerOpen;
}
}
return ReembedCycle::Progressed;
}
};
let done_count = outcomes
.iter()
.filter(|o| matches!(o.result, EnrichItemResult::Done { .. }))
.count();
let batch_cost: f64 = outcomes
.iter()
.map(|o| match o.result {
EnrichItemResult::Done { cost, .. } => cost,
_ => 0.0,
})
.sum();
let cost_per_item = if done_count == 0 {
0.0
} else {
batch_cost / done_count as f64
};
let mut writeback_lost = false;
for (row, outcome) in claimed.iter().zip(outcomes) {
let index = tally.completed + tally.failed + tally.skipped;
match outcome.result {
EnrichItemResult::Done {
memory_id,
entity_id,
entities,
rels,
..
} => {
if !writeback("mark_done", 0, &outcome.item_key, || {
mark_done(
ctx.queue_conn,
row.id,
memory_id,
entity_id,
entities,
rels,
cost_per_item,
elapsed_ms,
)
}) {
writeback_lost = true;
}
tally.completed += 1;
emit_item_event(
ctx,
&outcome.item_key,
"done",
Some((memory_id, entity_id, entities, rels)),
None,
elapsed_ms,
index,
);
}
EnrichItemResult::Skipped { reason, cost: _ } => {
if !writeback("mark_skipped", 0, &outcome.item_key, || {
mark_skipped(ctx.queue_conn, row.id, &reason)
}) {
writeback_lost = true;
}
tally.skipped += 1;
emit_item_event(
ctx,
&outcome.item_key,
"skipped",
None,
None,
elapsed_ms,
index,
);
}
EnrichItemResult::Retyped { .. } | EnrichItemResult::PreservationFailed { .. } => {
if !writeback("mark_skipped", 0, &outcome.item_key, || {
mark_skipped(ctx.queue_conn, row.id, "preservation_failed")
}) {
writeback_lost = true;
}
tally.skipped += 1;
emit_item_event(
ctx,
&outcome.item_key,
"skipped",
None,
None,
elapsed_ms,
index,
);
}
}
}
if let Some(b) = breaker {
let _ = b.record(crate::retry::AttemptOutcome::Success);
}
if writeback_lost {
return ReembedCycle::DbBusy;
}
ReembedCycle::Progressed
}
fn emit_item_event(
ctx: &ReembedCycleCtx<'_>,
item: &str,
status: &str,
done: Option<(Option<i64>, Option<i64>, usize, usize)>,
error: Option<String>,
elapsed_ms: i64,
index: usize,
) {
let (memory_id, entity_id, entities, rels) = match done {
Some((m, e, ent, rel)) => (m, e, Some(ent), Some(rel)),
None => (None, None, None, None),
};
let event = ItemEvent {
item,
status,
memory_id,
entity_id,
entities,
rels,
elapsed_ms: Some(elapsed_ms.max(0) as u64),
error,
index,
total: ctx.total,
..Default::default()
};
match ctx.stdout_mu {
Some(mu) => {
let _guard = mu.lock();
emit_json(&event);
}
None => emit_json(&event),
}
}