use crate::memory_core::palace::PalaceId;
use crate::memory_core::retrieval::{ForgetOutcome, PalaceHandle};
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::io::{BufRead, Write};
use std::path::{Path, PathBuf};
use uuid::Uuid;
pub const MAINTENANCE_LOG_FILENAME: &str = "maintenance_deletions.jsonl";
pub const MAINTENANCE_LOG_ROTATED_FILENAME: &str = "maintenance_deletions.1.jsonl";
pub(crate) const ROTATE_AT_BYTES: u64 = 4 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DeletionReason {
DreamDedup,
DreamContentPrune,
DreamPrune,
SemanticConsolidation,
ExpiredPurge,
ExpiredPurgeAtOpen,
}
impl DeletionReason {
pub fn as_str(self) -> &'static str {
match self {
Self::DreamDedup => "dream_dedup",
Self::DreamContentPrune => "dream_content_prune",
Self::DreamPrune => "dream_prune",
Self::SemanticConsolidation => "semantic_consolidation",
Self::ExpiredPurge => "expired_purge",
Self::ExpiredPurgeAtOpen => "expired_purge_at_open",
}
}
}
impl std::fmt::Display for DeletionReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MaintenanceDeletion {
pub at: DateTime<Utc>,
pub palace: String,
pub drawer_id: Uuid,
pub reason: DeletionReason,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub survivor_id: Option<Uuid>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub score: Option<f32>,
pub pid: u32,
}
impl MaintenanceDeletion {
pub fn new(palace: &PalaceId, drawer_id: Uuid, reason: DeletionReason) -> Self {
Self {
at: Utc::now(),
palace: palace.as_str().to_string(),
drawer_id,
reason,
survivor_id: None,
score: None,
pid: std::process::id(),
}
}
pub fn with_survivor(mut self, survivor_id: Uuid, score: Option<f32>) -> Self {
self.survivor_id = Some(survivor_id);
self.score = score;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecordOutcome {
Journaled,
LoggedOnly,
}
pub fn journal_path(data_dir: &Path) -> PathBuf {
data_dir.join(MAINTENANCE_LOG_FILENAME)
}
const JOURNAL_LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
pub(crate) fn append(data_dir: &Path, rec: &MaintenanceDeletion, rotate_at: u64) -> Result<()> {
let path = journal_path(data_dir);
let mut line = serde_json::to_string(rec).context("serialize maintenance deletion")?;
line.push('\n');
let locked = crate::file_lock::with_exclusive_lock_timeout(&path, JOURNAL_LOCK_TIMEOUT, || {
if let Err(e) = rotate_if_due(data_dir, &path, rotate_at) {
tracing::warn!(palace = %rec.palace, "#8732: journal rotation failed; appending to the live file: {e:#}");
}
append_line(&path, &line)
});
match locked {
Ok(appended) => appended,
Err(e) => {
tracing::warn!(palace = %rec.palace, "#8732: journal lock unavailable; appending without rotation: {e}");
append_line(&path, &line)
}
}
}
fn rotate_if_due(data_dir: &Path, path: &Path, rotate_at: u64) -> Result<()> {
if let Ok(meta) = std::fs::metadata(path)
&& meta.is_file()
&& meta.len() >= rotate_at
{
std::fs::rename(path, data_dir.join(MAINTENANCE_LOG_ROTATED_FILENAME))
.with_context(|| format!("rotate {}", path.display()))?;
}
Ok(())
}
fn append_line(path: &Path, line: &str) -> Result<()> {
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("open {}", path.display()))?;
file.write_all(line.as_bytes())
.with_context(|| format!("append {}", path.display()))
}
pub fn record(data_dir: Option<&Path>, rec: &MaintenanceDeletion) -> RecordOutcome {
let Some(dir) = data_dir else {
tracing::warn!(
palace = %rec.palace, drawer_id = %rec.drawer_id, reason = %rec.reason,
survivor_id = ?rec.survivor_id, score = ?rec.score,
"#8732: maintenance deletion (palace has no data dir; this line is the record)"
);
return RecordOutcome::LoggedOnly;
};
match append(dir, rec, ROTATE_AT_BYTES) {
Ok(()) => RecordOutcome::Journaled,
Err(e) => {
tracing::error!(
palace = %rec.palace, drawer_id = %rec.drawer_id, reason = %rec.reason,
survivor_id = ?rec.survivor_id, score = ?rec.score,
"#8732: maintenance deletion journal write failed; the drawer is \
deleted and this line is the record: {e:#}"
);
RecordOutcome::LoggedOnly
}
}
}
pub fn warn_removed(palace: &PalaceId, pass: &str, count: usize) {
if count == 0 {
return;
}
tracing::warn!(
palace = %palace, pass, count,
"#8732: {pass} removed {count} drawer(s); per-drawer record: \
`trusty-memory palace deletions {palace}`"
);
}
#[derive(Debug, Default)]
pub struct JournalContents {
pub records: Vec<MaintenanceDeletion>,
pub malformed: usize,
}
pub fn read_journal(data_dir: &Path) -> Result<JournalContents> {
let mut out = JournalContents::default();
for name in [MAINTENANCE_LOG_ROTATED_FILENAME, MAINTENANCE_LOG_FILENAME] {
let path = data_dir.join(name);
let file = match std::fs::File::open(&path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(e).with_context(|| format!("open {}", path.display())),
};
for line in std::io::BufReader::new(file).lines() {
let line = line.with_context(|| format!("read {}", path.display()))?;
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<MaintenanceDeletion>(&line) {
Ok(rec) => out.records.push(rec),
Err(_) => out.malformed += 1,
}
}
}
Ok(out)
}
impl PalaceHandle {
pub async fn forget_for_maintenance(
&self,
id: Uuid,
reason: DeletionReason,
survivor: Option<(Uuid, Option<f32>)>,
) -> Result<ForgetOutcome> {
let outcome = self.forget(id).await?;
if outcome.is_deleted() {
let mut rec = MaintenanceDeletion::new(&self.id, id, reason);
if let Some((survivor_id, score)) = survivor {
rec = rec.with_survivor(survivor_id, score);
}
record(self.data_dir.as_deref(), &rec);
}
Ok(outcome)
}
}