use super::deferred_embed::{EmbedLoss, RetryPolicy, with_retry};
use super::embed_repair::EmbedHealth;
use super::embed_store::{chunked_embed_timeout, embed_planned, embed_planned_within, plan_chunks};
use super::handle::PalaceHandle;
use crate::memory_core::embed::Embedder;
use crate::memory_core::palace::Drawer;
use crate::memory_core::store::embed_ledger::{self, EmbedFailure};
use crate::memory_core::timeouts;
use chrono::{DateTime, Utc};
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::{Duration, Instant};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SliceBounds {
pub budget: Duration,
pub max_drawers: usize,
pub max_chunks: usize,
pub embed_timeout: Duration,
pub lock_wait: Duration,
pub min_age: Duration,
pub ledger_backoff: Duration,
pub host_failures: usize,
}
impl Default for SliceBounds {
fn default() -> Self {
Self {
budget: Duration::from_millis(20_000),
max_drawers: 8,
max_chunks: 64,
embed_timeout: Duration::from_secs(120),
lock_wait: Duration::from_secs(5),
min_age: Duration::from_secs(120),
ledger_backoff: Duration::from_secs(86_400),
host_failures: 2,
}
}
}
impl SliceBounds {
pub fn from_env() -> Self {
let d = Self::default();
let ms = |d: Duration| u64::try_from(d.as_millis()).unwrap_or(u64::MAX);
Self {
budget: Duration::from_millis(env_u64("TRUSTY_CHUNK_BACKFILL_SLICE_MS", ms(d.budget))),
max_drawers: env_usize("TRUSTY_CHUNK_BACKFILL_MAX_DRAWERS", d.max_drawers),
max_chunks: env_usize("TRUSTY_CHUNK_BACKFILL_MAX_CHUNKS", d.max_chunks),
embed_timeout: secs("TRUSTY_CHUNK_BACKFILL_EMBED_SECS", d.embed_timeout),
lock_wait: secs("TRUSTY_CHUNK_BACKFILL_LOCK_WAIT_SECS", d.lock_wait),
min_age: secs("TRUSTY_CHUNK_BACKFILL_MIN_AGE_SECS", d.min_age),
ledger_backoff: secs("TRUSTY_CHUNK_BACKFILL_BACKOFF_SECS", d.ledger_backoff),
host_failures: env_usize("TRUSTY_CHUNK_BACKFILL_HOST_FAILURES", d.host_failures),
}
}
}
fn env_u64(name: &str, default: u64) -> u64 {
std::env::var(name)
.ok()
.and_then(|v| v.trim().parse().ok())
.unwrap_or(default)
}
fn env_usize(name: &str, default: usize) -> usize {
usize::try_from(env_u64(name, default as u64)).unwrap_or(default)
}
fn secs(name: &str, default: Duration) -> Duration {
Duration::from_secs(env_u64(name, default.as_secs()))
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum SliceStop {
#[default]
QueueEmpty,
ReadOnly,
ChunkCheckUnavailable(String),
Budget,
DrawerCap,
ChunkCap,
HostFailure,
EmbedTimeout,
LockTimeout(String),
StoreError(String),
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SliceReport {
pub attempted: usize,
pub repaired: usize,
pub failed: usize,
pub stale: usize,
pub chunks_embedded: usize,
pub host_failure: bool,
pub ledger_error: Option<String>,
pub stop: SliceStop,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Commit {
Committed,
Stale,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ChunkCommitError {
#[error("palace '{palace}': write lock not granted within {waited:?}")]
LockWait { palace: String, waited: Duration },
#[error("palace '{palace}': vector upsert failed: {reason}")]
Upsert { palace: String, reason: String },
}
struct PendingRow {
id: Uuid,
loss: EmbedLoss,
drawer_specific: bool,
}
impl PalaceHandle {
pub async fn reembed_slice(
&self,
embedder: &Arc<dyn Embedder + Send + Sync>,
bounds: &SliceBounds,
) -> SliceReport {
let mut report = SliceReport::default();
if self.is_read_only() {
report.stop = SliceStop::ReadOnly;
return report;
}
let health = self.embed_health_with(Some(embedder.as_ref()));
if let Some(reason) = health.chunk_check.unavailable_reason() {
report.stop = SliceStop::ChunkCheckUnavailable(reason.to_string());
return report;
}
let queue = self.backfill_queue(&health, bounds, Utc::now());
let started = Instant::now();
let mut rows: Vec<PendingRow> = Vec::new();
let mut repaired: HashSet<Uuid> = HashSet::new();
let (mut consecutive, mut embed_failures, mut started_drawers) = (0usize, 0usize, 0usize);
for id in queue {
if let Some(stop) = limit_reached(bounds, started, started_drawers, &report) {
report.stop = stop;
break;
}
let Some(text) = self.drawer_text(id) else {
report.stale += 1;
continue;
};
started_drawers += 1;
let chunks: Vec<String> = match plan_chunks(embedder.as_ref(), &text) {
Ok(c) => c.into_iter().map(str::to_string).collect(),
Err(e) => {
report.attempted += 1;
report.failed += 1;
rows.push(PendingRow::new(id, embed_loss(format!("{e:#}")), true));
continue;
}
};
let vectors = match embed_bounded(embedder, &chunks, bounds.embed_timeout).await {
Ok(v) => v,
Err(EmbedFail::TimedOut(reason)) => {
report.attempted += 1;
report.failed += 1;
rows.push(PendingRow::new(id, embed_loss(reason), true));
report.stop = SliceStop::EmbedTimeout;
break;
}
Err(EmbedFail::Error(reason)) => {
report.attempted += 1;
report.failed += 1;
(consecutive, embed_failures) = (consecutive + 1, embed_failures + 1);
rows.push(PendingRow::new(id, embed_loss(reason), false));
if consecutive >= bounds.host_failures.max(1) {
report.stop = SliceStop::HostFailure;
break;
}
continue;
}
};
consecutive = 0;
report.chunks_embedded += chunks.len();
match self
.commit_if_current(id, &text, vectors, bounds.lock_wait)
.await
{
Ok(Commit::Committed) => {
report.attempted += 1;
repaired.insert(id);
}
Ok(Commit::Stale) => report.stale += 1,
Err(e @ ChunkCommitError::LockWait { .. }) => {
report.stop = SliceStop::LockTimeout(e.to_string());
break;
}
Err(e @ ChunkCommitError::Upsert { .. }) => {
report.attempted += 1;
report.failed += 1;
let loss = EmbedLoss::Upsert {
reason: e.to_string(),
attempts: 1,
};
rows.push(PendingRow::new(id, loss, true));
report.stop = SliceStop::StoreError(e.to_string());
break;
}
}
tokio::task::yield_now().await;
}
report.repaired = repaired.len();
report.host_failure = repaired.is_empty() && embed_failures >= bounds.host_failures.max(1);
report.ledger_error = self
.settle_slice_ledger(rows, repaired, report.host_failure)
.await;
report
}
pub(crate) async fn commit_if_current(
&self,
id: Uuid,
text: &str,
vectors: Vec<Vec<f32>>,
lock_wait: Duration,
) -> Result<Commit, ChunkCommitError> {
let palace = || self.id.as_str().to_string();
let _guard = timeouts::lock_with_timeout(&self.write_mutex, lock_wait, self.id.as_str())
.await
.map_err(|_| ChunkCommitError::LockWait {
palace: palace(),
waited: lock_wait,
})?;
if self.drawer_text(id).as_deref() != Some(text) {
return Ok(Commit::Stale);
}
self.vector_store
.upsert_chunks(id, vectors)
.await
.map(|()| Commit::Committed)
.map_err(|e| ChunkCommitError::Upsert {
palace: palace(),
reason: format!("{e:#}"),
})
}
pub(super) fn drawer_text(&self, id: Uuid) -> Option<String> {
self.drawers
.read()
.iter()
.find(|d| d.id == id)
.map(|d| d.content().to_string())
}
pub(crate) fn backfill_queue(
&self,
health: &EmbedHealth,
bounds: &SliceBounds,
now: DateTime<Utc>,
) -> Vec<Uuid> {
let younger = |at: DateTime<Utc>, limit: Duration| {
now.signed_duration_since(at)
.to_std()
.map_or(true, |age| age < limit)
};
let backed_off: HashSet<Uuid> = health
.recorded_failures
.iter()
.filter(|f| younger(f.failed_at, bounds.ledger_backoff))
.map(|f| f.drawer_id)
.collect();
let drawers = self.drawers.read();
let by_id: HashMap<Uuid, &Drawer> = drawers.iter().map(|d| (d.id, d)).collect();
let mut queue: Vec<Uuid> = health
.missing_vector_ids
.iter()
.filter(|id| !backed_off.contains(id))
.filter(|id| {
by_id
.get(id)
.is_some_and(|d| !younger(d.created_at, bounds.min_age))
})
.copied()
.collect();
let mut short: Vec<(f32, Uuid)> = health
.short_chunk_ids
.iter()
.filter(|id| !backed_off.contains(id))
.map(|id| (by_id.get(id).map_or(0.0, |d| d.importance), *id))
.collect();
short.sort_by(|a, b| b.0.total_cmp(&a.0));
queue.extend(short.into_iter().map(|(_, id)| id));
queue
}
async fn settle_slice_ledger(
&self,
rows: Vec<PendingRow>,
repaired: HashSet<Uuid>,
host_failure: bool,
) -> Option<String> {
let dir = self.data_dir.clone()?;
let now = Utc::now();
let entries: Vec<EmbedFailure> = rows
.into_iter()
.filter(|r| r.drawer_specific || !host_failure)
.map(|r| EmbedFailure {
drawer_id: r.id,
failed_at: now,
attempts: r.loss.attempts(),
reason: r.loss.reason(),
})
.collect();
if entries.is_empty() && repaired.is_empty() {
return None;
}
let written = tokio::task::spawn_blocking(move || {
write_ledger(
entries,
&repaired,
|entry| embed_ledger::record(&dir, entry),
|ids| embed_ledger::clear(&dir, ids),
)
})
.await
.unwrap_or_else(|e| Some(format!("slice ledger task failed: {e}")));
if let Some(e) = &written {
tracing::warn!(palace = %self.id, "#9735: slice ledger write failed: {e}");
}
written
}
}
pub(super) fn write_ledger(
entries: Vec<EmbedFailure>,
repaired: &HashSet<Uuid>,
mut record: impl FnMut(EmbedFailure) -> anyhow::Result<()>,
clear: impl FnOnce(&HashSet<Uuid>) -> anyhow::Result<()>,
) -> Option<String> {
let mut errors: Vec<String> = entries
.into_iter()
.filter_map(|entry| record(entry).err().map(|e| format!("{e:#}")))
.collect();
if let Err(e) = clear(repaired) {
errors.push(format!("{e:#}"));
}
(!errors.is_empty()).then(|| errors.join("; "))
}
impl PendingRow {
fn new(id: Uuid, loss: EmbedLoss, drawer_specific: bool) -> Self {
Self {
id,
loss,
drawer_specific,
}
}
}
fn embed_loss(reason: String) -> EmbedLoss {
EmbedLoss::Embed {
reason,
attempts: 1,
}
}
fn limit_reached(
bounds: &SliceBounds,
started: Instant,
started_drawers: usize,
report: &SliceReport,
) -> Option<SliceStop> {
if started.elapsed() >= bounds.budget {
Some(SliceStop::Budget)
} else if started_drawers >= bounds.max_drawers {
Some(SliceStop::DrawerCap)
} else if report.chunks_embedded >= bounds.max_chunks {
Some(SliceStop::ChunkCap)
} else {
None
}
}
enum EmbedFail {
Error(String),
TimedOut(String),
}
async fn embed_bounded(
embedder: &Arc<dyn Embedder + Send + Sync>,
chunks: &[String],
slice_timeout: Duration,
) -> Result<Vec<Vec<f32>>, EmbedFail> {
let scaled = chunked_embed_timeout(timeouts::embed_batch_timeout(), chunks.len());
let bound = slice_timeout.min(scaled);
match tokio::time::timeout(bound, embed_planned(embedder.as_ref(), chunks)).await {
Ok(Ok(v)) => Ok(v),
Ok(Err(e)) => Err(EmbedFail::Error(format!("{e:#}"))),
Err(_) => Err(EmbedFail::TimedOut(format!(
"slice embed of {} chunks timed out after {bound:?}",
chunks.len()
))),
}
}
#[derive(Debug)]
pub(crate) enum Repair {
Repaired(u32),
Stale,
Failed(EmbedLoss),
Locked(ChunkCommitError),
}
impl PalaceHandle {
pub(crate) async fn repair_one(
&self,
embedder: &Arc<dyn Embedder + Send + Sync>,
id: Uuid,
text: &str,
policy: &RetryPolicy,
lock_wait: Duration,
) -> Repair {
let (vectors, embed_attempts) = match embed_outside(embedder, text, policy).await {
Ok(v) => v,
Err(loss) => return Repair::Failed(loss),
};
let total = policy.attempts.max(1);
let mut attempt = 1;
loop {
match self
.commit_if_current(id, text, vectors.clone(), lock_wait)
.await
{
Ok(Commit::Committed) => return Repair::Repaired(embed_attempts.max(attempt)),
Ok(Commit::Stale) => return Repair::Stale,
Err(e @ ChunkCommitError::LockWait { .. }) => return Repair::Locked(e),
Err(ChunkCommitError::Upsert { reason, .. }) if attempt >= total => {
return Repair::Failed(EmbedLoss::Upsert {
reason,
attempts: total,
});
}
Err(ChunkCommitError::Upsert { .. }) => {
tokio::time::sleep(policy.delay_for(attempt)).await;
attempt += 1;
}
}
}
}
}
pub(crate) async fn embed_outside(
embedder: &Arc<dyn Embedder + Send + Sync>,
text: &str,
policy: &RetryPolicy,
) -> Result<(Vec<Vec<f32>>, u32), EmbedLoss> {
let chunks: Vec<String> = plan_chunks(embedder.as_ref(), text)
.map_err(|e| embed_loss(format!("{e:#}")))?
.into_iter()
.map(str::to_string)
.collect();
let per_chunk = timeouts::embed_batch_timeout();
with_retry(policy, |_| {
let chunks = &chunks;
async move {
embed_planned_within(embedder.as_ref(), chunks, per_chunk)
.await
.map_err(|e| format!("{e:#}"))
}
})
.await
.map_err(|(reason, attempts)| EmbedLoss::Embed { reason, attempts })
}