use anda_db::{
collection::{Collection, CollectionConfig},
database::AndaDB,
error::DBError,
query::{Filter, Fv, Query, RangeQuery},
schema::AndaDBSchema,
};
use serde::{Deserialize, Serialize};
use std::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
};
#[derive(Debug, Clone, Default, Serialize, Deserialize, AndaDBSchema)]
pub struct MemoryUsage {
pub _id: u64,
pub entity: String,
pub recall_count: u64,
pub self_test_count: u64,
pub last_recalled_at: u64,
pub correction_count: u64,
pub last_corrected_at: u64,
pub flushed_recall_count: u64,
pub dirty: u64,
pub updated_at: u64,
}
pub struct UsageLedger {
collection: Arc<Collection>,
write_lock: tokio::sync::Mutex<()>,
}
impl UsageLedger {
pub async fn connect(db: &Arc<AndaDB>) -> Result<Self, DBError> {
let mut schema = MemoryUsage::schema()?;
schema.with_version(2);
let collection = db
.open_or_create_collection(
schema,
CollectionConfig {
name: "memory_usage".to_string(),
description: "Memory usage ledger (recall/correction counters)".to_string(),
},
async |collection| {
collection.create_btree_index_nx(&["entity"]).await?;
collection.create_btree_index_nx(&["dirty"]).await?;
collection
.create_btree_index_nx(&["last_recalled_at"])
.await?;
collection
.create_btree_index_nx(&["last_corrected_at"])
.await?;
Ok(())
},
)
.await?;
Ok(Self {
collection,
write_lock: tokio::sync::Mutex::new(()),
})
}
pub async fn get(&self, entity: &str) -> Result<Option<MemoryUsage>, DBError> {
let rows: Vec<MemoryUsage> = self
.collection
.search_as(Query {
search: None,
filter: Some(Filter::Field((
"entity".to_string(),
RangeQuery::Eq(Fv::Text(entity.to_string())),
))),
limit: Some(1),
})
.await?;
Ok(rows.into_iter().next())
}
pub async fn record_recall(
&self,
entities: &BTreeSet<String>,
now_ms: u64,
) -> Result<u64, DBError> {
let _guard = self.write_lock.lock().await;
let mut touched = 0u64;
for entity in entities {
match self.get(entity).await? {
Some(row) => {
self.collection
.update(
row._id,
BTreeMap::from([
("recall_count".to_string(), Fv::U64(row.recall_count + 1)),
("last_recalled_at".to_string(), Fv::U64(now_ms)),
("dirty".to_string(), Fv::U64(1)),
("updated_at".to_string(), Fv::U64(now_ms)),
]),
)
.await?;
}
None => {
self.collection
.add_from(&MemoryUsage {
entity: entity.clone(),
recall_count: 1,
last_recalled_at: now_ms,
dirty: 1,
updated_at: now_ms,
..Default::default()
})
.await?;
}
}
touched += 1;
}
Ok(touched)
}
pub async fn record_correction(&self, entity: &str, now_ms: u64) -> Result<bool, DBError> {
let _guard = self.write_lock.lock().await;
match self.get(entity).await? {
Some(row) => {
if row.correction_count > 0 {
return Ok(false);
}
self.collection
.update(
row._id,
BTreeMap::from([
("correction_count".to_string(), Fv::U64(1)),
("last_corrected_at".to_string(), Fv::U64(now_ms)),
("updated_at".to_string(), Fv::U64(now_ms)),
]),
)
.await?;
Ok(true)
}
None => {
self.collection
.add_from(&MemoryUsage {
entity: entity.to_string(),
correction_count: 1,
last_corrected_at: now_ms,
updated_at: now_ms,
..Default::default()
})
.await?;
Ok(true)
}
}
}
pub async fn unflushed_recalls(
&self,
after_id: u64,
limit: usize,
) -> Result<(Vec<MemoryUsage>, Option<u64>), DBError> {
let mut ids = self
.collection
.query_all_ids(Filter::And(vec![
Box::new(Filter::Field((
"dirty".to_string(),
RangeQuery::Eq(Fv::U64(1)),
))),
Box::new(Filter::Field((
"_id".to_string(),
RangeQuery::Gt(Fv::U64(after_id)),
))),
]))
.await?;
ids.sort_unstable();
let next_cursor = if ids.len() > limit {
ids.get(limit.saturating_sub(1)).copied()
} else {
None
};
ids.truncate(limit);
let mut rows = Vec::with_capacity(ids.len());
for id in ids {
match self.collection.get_as::<MemoryUsage>(id).await {
Ok(row) if row.recall_count > row.flushed_recall_count => rows.push(row),
Ok(_) | Err(_) => {}
}
}
Ok((rows, next_cursor))
}
pub async fn mark_flushed(
&self,
id: u64,
recall_count: u64,
now_ms: u64,
) -> Result<(), DBError> {
let _guard = self.write_lock.lock().await;
let current = match self.collection.get_as::<MemoryUsage>(id).await {
Ok(row) => row.recall_count,
Err(_) => return Ok(()),
};
let dirty = if current > recall_count { 1 } else { 0 };
self.collection
.update(
id,
BTreeMap::from([
("flushed_recall_count".to_string(), Fv::U64(recall_count)),
("dirty".to_string(), Fv::U64(dirty)),
("updated_at".to_string(), Fv::U64(now_ms)),
]),
)
.await?;
Ok(())
}
pub async fn record_self_test(
&self,
entities: &BTreeSet<String>,
now_ms: u64,
) -> Result<(), DBError> {
let _guard = self.write_lock.lock().await;
for entity in entities {
match self.get(entity).await? {
Some(row) => {
self.collection
.update(
row._id,
BTreeMap::from([
(
"self_test_count".to_string(),
Fv::U64(row.self_test_count + 1),
),
("updated_at".to_string(), Fv::U64(now_ms)),
]),
)
.await?;
}
None => {
self.collection
.add_from(&MemoryUsage {
entity: entity.clone(),
self_test_count: 1,
updated_at: now_ms,
..Default::default()
})
.await?;
}
}
}
Ok(())
}
pub async fn corrected_since(
&self,
since_ms: u64,
limit: usize,
) -> Result<Vec<MemoryUsage>, DBError> {
let rows: Vec<MemoryUsage> = self
.collection
.search_as(Query {
search: None,
filter: Some(Filter::Field((
"last_corrected_at".to_string(),
RangeQuery::Gt(Fv::U64(since_ms)),
))),
limit: Some(limit),
})
.await?;
Ok(rows
.into_iter()
.filter(|row| row.correction_count > 0)
.collect())
}
pub async fn forget_entity(&self, entity: &str) -> Result<bool, DBError> {
let _guard = self.write_lock.lock().await;
match self.get(entity).await? {
Some(row) => {
self.collection.remove(row._id).await?;
Ok(true)
}
None => Ok(false),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, AndaDBSchema)]
pub struct RecallMiss {
pub _id: u64,
pub query: String,
pub created_at: u64,
}
pub struct MissCache {
collection: Arc<Collection>,
write_lock: tokio::sync::Mutex<()>,
}
pub const RECALL_MISS_TTL_MS: u64 = 3_600_000;
const MISS_CACHE_MAX_ROWS: usize = 1024;
const MISS_QUERY_MAX_CHARS: usize = 512;
impl MissCache {
pub async fn connect(db: &Arc<AndaDB>) -> Result<Self, DBError> {
let collection = db
.open_or_create_collection(
RecallMiss::schema()?,
CollectionConfig {
name: "recall_misses".to_string(),
description: "Negative-knowledge cache (queries with no memory)".to_string(),
},
async |collection| {
collection.create_btree_index_nx(&["query"]).await?;
Ok(())
},
)
.await?;
Ok(Self {
collection,
write_lock: tokio::sync::Mutex::new(()),
})
}
fn cache_key(query: &str) -> String {
query
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}
async fn get(&self, key: &str) -> Result<Option<RecallMiss>, DBError> {
let rows: Vec<RecallMiss> = self
.collection
.search_as(Query {
search: None,
filter: Some(Filter::Field((
"query".to_string(),
RangeQuery::Eq(Fv::Text(key.to_string())),
))),
limit: Some(1),
})
.await?;
Ok(rows.into_iter().next())
}
pub async fn is_fresh_miss(&self, query: &str, now_ms: u64) -> Result<bool, DBError> {
match self.get(&Self::cache_key(query)).await? {
Some(row) if now_ms.saturating_sub(row.created_at) <= RECALL_MISS_TTL_MS => Ok(true),
Some(row) => {
let _guard = self.write_lock.lock().await;
let _ = self.collection.remove(row._id).await;
Ok(false)
}
None => Ok(false),
}
}
pub async fn record_miss(&self, query: &str, now_ms: u64) -> Result<(), DBError> {
let key = Self::cache_key(query);
if key.is_empty() || key.chars().count() > MISS_QUERY_MAX_CHARS {
return Ok(());
}
let _guard = self.write_lock.lock().await;
match self.get(&key).await? {
Some(row) => {
self.collection
.update(
row._id,
BTreeMap::from([("created_at".to_string(), Fv::U64(now_ms))]),
)
.await?;
}
None => {
if self.collection.len() >= MISS_CACHE_MAX_ROWS {
self.purge_expired_locked(now_ms).await?;
if self.collection.len() >= MISS_CACHE_MAX_ROWS {
return Ok(());
}
}
self.collection
.add_from(&RecallMiss {
query: key,
created_at: now_ms,
..Default::default()
})
.await?;
}
}
Ok(())
}
async fn purge_expired_locked(&self, now_ms: u64) -> Result<(), DBError> {
let mut cursor = 0u64;
loop {
let rows: Vec<RecallMiss> = self
.collection
.search_as(Query {
search: None,
filter: Some(Filter::Field((
"_id".to_string(),
RangeQuery::Gt(Fv::U64(cursor)),
))),
limit: Some(100),
})
.await?;
let Some(max_id) = rows.iter().map(|row| row._id).max() else {
break;
};
cursor = cursor.max(max_id);
for row in rows {
if now_ms.saturating_sub(row.created_at) > RECALL_MISS_TTL_MS {
let _ = self.collection.remove(row._id).await;
}
}
}
Ok(())
}
pub async fn clear(&self) -> Result<u64, DBError> {
let _guard = self.write_lock.lock().await;
let mut cleared = 0u64;
let mut cursor = 0u64;
loop {
let rows: Vec<RecallMiss> = self
.collection
.search_as(Query {
search: None,
filter: Some(Filter::Field((
"_id".to_string(),
RangeQuery::Gt(Fv::U64(cursor)),
))),
limit: Some(100),
})
.await?;
let Some(max_id) = rows.iter().map(|row| row._id).max() else {
break;
};
cursor = cursor.max(max_id);
for row in rows {
if self.collection.remove(row._id).await.is_ok() {
cleared += 1;
}
}
}
Ok(cleared)
}
}