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,
#[serde(default)]
pub last_self_test_at: Option<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(3);
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.remove_btree_index(&["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)),
("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,
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 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),
),
("last_self_test_at".to_string(), Fv::U64(now_ms)),
("updated_at".to_string(), Fv::U64(now_ms)),
]),
)
.await?;
}
None => {
self.collection
.add_from(&MemoryUsage {
entity: entity.clone(),
self_test_count: 1,
last_self_test_at: Some(now_ms),
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)
}
}
#[cfg(test)]
pub(crate) mod v2 {
use super::*;
#[derive(Debug, Clone, Default, Serialize, Deserialize, AndaDBSchema)]
pub(crate) struct MemoryUsageV2 {
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(crate) async fn create(db: &AndaDB, rows: &[MemoryUsageV2]) -> Result<(), DBError> {
let mut schema = MemoryUsageV2::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,
)
.await?;
for row in rows {
collection.add_from(row).await?;
}
collection.flush(anda_engine::unix_ms()).await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use anda_db::database::DBConfig;
#[tokio::test]
async fn a_v2_ledger_upgrades_in_place() {
let store = Arc::new(object_store::memory::InMemory::new());
let config = DBConfig {
name: "ledger_upgrade".into(),
..Default::default()
};
let db = Arc::new(AndaDB::create(store.clone(), config.clone()).await.unwrap());
v2::create(
&db,
&[v2::MemoryUsageV2 {
entity: "C:7".into(),
self_test_count: 1,
updated_at: 1,
..Default::default()
}],
)
.await
.unwrap();
db.close().await.unwrap();
let db = Arc::new(AndaDB::open(store, config).await.unwrap());
let ledger = UsageLedger::connect(&db).await.unwrap();
let row = ledger.get("C:7").await.unwrap().unwrap();
assert_eq!(row.self_test_count, 1);
assert_eq!(row.last_self_test_at, None);
ledger
.record_self_test(&BTreeSet::from(["C:7".to_string()]), 42)
.await
.unwrap();
let row = ledger.get("C:7").await.unwrap().unwrap();
assert_eq!((row.self_test_count, row.last_self_test_at), (2, Some(42)));
db.close().await.unwrap();
}
}