use crate::memory_core::palace::{Drawer, 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,
ForgetOfMergedSurvivor,
}
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",
Self::ForgetOfMergedSurvivor => "forget_of_merged_survivor",
}
}
}
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,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub drawer: Option<RemovedDrawer>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RemovedDrawer {
pub content: String,
pub room_id: Uuid,
pub tags: Vec<String>,
pub importance: f32,
pub created_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fact_key: Option<String>,
}
impl From<&Drawer> for RemovedDrawer {
fn from(d: &Drawer) -> Self {
Self {
content: d.content().to_string(),
room_id: d.room_id,
tags: d.tags.clone(),
importance: d.importance,
created_at: d.created_at,
fact_key: d.fact_key.clone(),
}
}
}
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(),
drawer: None,
}
}
pub fn with_drawer(mut self, drawer: &Drawer) -> Self {
self.drawer = Some(RemovedDrawer::from(drawer));
self
}
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 as_json =
|| serde_json::to_string(rec).unwrap_or_else(|e| format!("<record not serializable: {e}>"));
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, record = %as_json(),
"#8732: maintenance deletion (palace has no data dir; this line is the record)"
);
return RecordOutcome::LoggedOnly;
};
match append(dir, rec, ROTATE_AT_BYTES) {
Ok(()) => {
tracing::warn!(
palace = %rec.palace, drawer_id = %rec.drawer_id, reason = %rec.reason,
survivor_id = ?rec.survivor_id, score = ?rec.score,
"#8729: maintenance removed drawer {} ({})", rec.drawer_id, rec.reason
);
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, record = %as_json(),
"#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 (removed, l1_saved) = self.forget_removing(id).await?;
let Some(removed) = removed else {
l1_saved?;
return Ok(ForgetOutcome::NotFound);
};
let mut rec = MaintenanceDeletion::new(&self.id, id, reason).with_drawer(&removed);
if let Some((survivor_id, score)) = survivor {
rec = rec.with_survivor(survivor_id, score);
}
record(self.data_dir.as_deref(), &rec);
l1_saved?;
Ok(ForgetOutcome::Deleted)
}
}
pub(crate) fn record_survivor_forget(handle: &PalaceHandle, drawer: &Drawer) {
let Some(dir) = handle.data_dir.as_deref() else {
return;
};
if names_survivor(dir, drawer) {
let rec = MaintenanceDeletion::new(
&handle.id,
drawer.id,
DeletionReason::ForgetOfMergedSurvivor,
)
.with_drawer(drawer);
record(Some(dir), &rec);
}
}
fn names_survivor(dir: &Path, drawer: &Drawer) -> bool {
let created = std::time::SystemTime::from(drawer.created_at);
let written_since = [MAINTENANCE_LOG_ROTATED_FILENAME, MAINTENANCE_LOG_FILENAME]
.iter()
.filter_map(|name| std::fs::metadata(dir.join(name)).ok()?.modified().ok())
.any(|modified| modified >= created);
if !written_since {
return false;
}
match read_journal(dir) {
Ok(journal) => journal
.records
.iter()
.any(|r| r.survivor_id == Some(drawer.id)),
Err(e) => {
tracing::warn!(drawer_id = %drawer.id, "#9172: journal unreadable; recording the forget: {e:#}");
true
}
}
}