use std::collections::HashMap;
use std::fs::OpenOptions;
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use ulid::Ulid;
use crate::crypto::{self, KdfParams};
use crate::error::{MnemoError, Result};
use crate::format::{Header, FLAG_ENCRYPTED, PAGE_SIZE, PAYLOAD, VERSION, WRAPPED_DEK_LEN};
use crate::index::{IndexConfig, IndexInfo, IvfPqIndex};
use crate::memory::{self, Memory, MemoryType, Metric, Scope, ScoreWeights};
use crate::pager::Pager;
use crate::wal;
const DEFAULT_WAL_PAGES: u64 = 8;
const MIN_WAL_PAGES: u64 = 2;
pub const DEFAULT_MAX_SNAPSHOTS: usize = 256;
const TORN_HEADER_SCAN_PAGES: u64 = 64;
#[derive(Clone, Copy, Debug)]
pub struct MnemoConfig {
pub dimensions: usize,
pub kdf: KdfParams,
pub wal_pages_initial: u64,
pub max_snapshots: usize,
}
impl Default for MnemoConfig {
fn default() -> Self {
Self {
dimensions: 768,
kdf: KdfParams::secure(),
wal_pages_initial: DEFAULT_WAL_PAGES,
max_snapshots: DEFAULT_MAX_SNAPSHOTS,
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
struct CatalogEntry {
id: u128,
start_page: u64,
page_count: u32,
len: u32,
deleted: bool,
accessed_at: i64,
access_count: u32,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
struct CatalogEntryV4 {
id: u128,
start_page: u64,
page_count: u32,
len: u32,
deleted: bool,
}
struct FlushPrelude {
cat_bytes: Option<Vec<u8>>,
idx_bytes: Option<Vec<u8>>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
struct Snapshot {
txn_id: u64,
created_at: i64,
catalog_start: u64,
catalog_pages: u64,
catalog_len: u64,
index_start: u64,
index_pages: u64,
index_len: u64,
memory_count: u64,
}
#[derive(Clone, Copy, Debug)]
pub struct SnapshotInfo {
pub txn_id: u64,
pub created_at: i64,
pub memory_count: u64,
}
#[derive(Clone, Debug)]
pub struct RecallRequest {
pub query: Vec<f32>,
pub top_k: usize,
pub memory_types: Option<Vec<MemoryType>>,
pub agent_id: Option<String>,
pub metric: Metric,
pub weights: ScoreWeights,
pub n_probe: Option<usize>,
pub n_rerank: Option<usize>,
pub track_access: bool,
}
impl RecallRequest {
pub fn new(query: Vec<f32>) -> Self {
Self {
query,
top_k: 10,
memory_types: None,
agent_id: None,
metric: Metric::Cosine,
weights: ScoreWeights::default(),
n_probe: None,
n_rerank: None,
track_access: true,
}
}
pub fn top_k(mut self, k: usize) -> Self {
self.top_k = k;
self
}
pub fn types(mut self, t: Vec<MemoryType>) -> Self {
self.memory_types = Some(t);
self
}
pub fn agent(mut self, agent_id: impl Into<String>) -> Self {
self.agent_id = Some(agent_id.into());
self
}
pub fn metric(mut self, metric: Metric) -> Self {
self.metric = metric;
self
}
pub fn weights(mut self, weights: ScoreWeights) -> Self {
self.weights = weights;
self
}
pub fn n_probe(mut self, n: usize) -> Self {
self.n_probe = Some(n);
self
}
pub fn n_rerank(mut self, n: usize) -> Self {
self.n_rerank = Some(n);
self
}
pub fn track_access(mut self, track: bool) -> Self {
self.track_access = track;
self
}
}
#[derive(Clone, Debug)]
pub struct RecallResult {
pub memory: Memory,
pub score: f32,
pub similarity: f32,
}
#[derive(Clone, Debug)]
pub struct Stats {
pub memories: usize,
pub deleted: usize,
pub dimensions: usize,
pub file_bytes: u64,
pub agents: Vec<String>,
pub encrypted: bool,
pub created_at: i64,
pub index: Option<IndexInfo>,
pub wal_pages: u64,
}
#[derive(Clone, Copy, Debug)]
pub struct CompactReport {
pub before: usize,
pub after: usize,
}
pub struct Mnemo {
pager: Pager,
header: Header,
catalog: Vec<CatalogEntry>,
index: HashMap<u128, usize>,
#[allow(dead_code)]
path: PathBuf,
dimensions: usize,
kdf: KdfParams,
dirty_catalog: bool,
ann: Option<IvfPqIndex>,
dirty_index: bool,
manifest: Vec<Snapshot>,
max_snapshots: usize,
}
fn read_run_bytes(pager: &mut Pager, start: u64, pages: u64, len: u64) -> Result<Vec<u8>> {
let mut buf = Vec::with_capacity(len as usize);
for i in 0..pages {
buf.extend_from_slice(&pager.read_page(start + i)?);
}
buf.truncate(len as usize);
Ok(buf)
}
fn load_catalog(pager: &mut Pager, start: u64, pages: u64, len: u64) -> Result<Vec<CatalogEntry>> {
if pages == 0 {
return Ok(Vec::new());
}
let buf = read_run_bytes(pager, start, pages, len)?;
rmp_serde::from_slice(&buf).map_err(|e| MnemoError::Serialize(e.to_string()))
}
fn load_catalog_v4(
pager: &mut Pager,
start: u64,
pages: u64,
len: u64,
) -> Result<Vec<CatalogEntryV4>> {
if pages == 0 {
return Ok(Vec::new());
}
let buf = read_run_bytes(pager, start, pages, len)?;
rmp_serde::from_slice(&buf).map_err(|e| MnemoError::Serialize(e.to_string()))
}
fn migrate_v4_catalog(
pager: &mut Pager,
v4: Vec<CatalogEntryV4>,
) -> Result<Vec<CatalogEntry>> {
let mut out = Vec::with_capacity(v4.len());
for e in &v4 {
let (accessed_at, access_count) = if e.deleted {
(0i64, 0u32)
} else {
let mut buf = Vec::with_capacity(e.len as usize);
for i in 0..e.page_count as u64 {
buf.extend_from_slice(&pager.read_page(e.start_page + i)?);
}
buf.truncate(e.len as usize);
let mem: Memory = rmp_serde::from_slice(&buf)
.map_err(|err| MnemoError::Serialize(err.to_string()))?;
(mem.accessed_at, mem.access_count)
};
out.push(CatalogEntry {
id: e.id,
start_page: e.start_page,
page_count: e.page_count,
len: e.len,
deleted: e.deleted,
accessed_at,
access_count,
});
}
Ok(out)
}
fn load_manifest(pager: &mut Pager, start: u64, pages: u64, len: u64) -> Result<Vec<Snapshot>> {
if pages == 0 {
return Ok(Vec::new());
}
let buf = read_run_bytes(pager, start, pages, len)?;
rmp_serde::from_slice(&buf).map_err(|e| MnemoError::Serialize(e.to_string()))
}
fn load_index(
pager: &mut Pager,
start: u64,
pages: u64,
len: u64,
dims: usize,
) -> Result<Option<IvfPqIndex>> {
if pages == 0 {
return Ok(None);
}
let buf = read_run_bytes(pager, start, pages, len)?;
let mut idx: IvfPqIndex =
rmp_serde::from_slice(&buf).map_err(|e| MnemoError::Serialize(e.to_string()))?;
if idx.dims() != dims {
return Err(MnemoError::Invalid(
"ANN index dimensionality does not match the database".into(),
));
}
idx.rebuild_assignment();
Ok(Some(idx))
}
fn is_manifest(m: &Memory) -> bool {
m.metadata
.get("topic")
.and_then(|v| v.as_str())
.map(|s| s.eq_ignore_ascii_case("manifest"))
.unwrap_or(false)
}
fn build_id_index(catalog: &[CatalogEntry]) -> HashMap<u128, usize> {
let mut m = HashMap::with_capacity(catalog.len());
for (i, e) in catalog.iter().enumerate() {
m.insert(e.id, i);
}
m
}
impl Mnemo {
pub fn create(path: impl Into<PathBuf>, passphrase: &str, config: MnemoConfig) -> Result<Mnemo> {
let path: PathBuf = path.into();
if config.dimensions == 0 {
return Err(MnemoError::Invalid("dimensions must be > 0".into()));
}
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&path)?;
let salt = crypto::random_salt();
let dek = crypto::random_dek();
let kek = crypto::derive_kek(passphrase.as_bytes(), &salt, config.kdf)?;
let dek_nonce = crypto::random_nonce();
let wrapped = crypto::wrap_dek(&kek, &dek_nonce, &dek)?;
if wrapped.len() != WRAPPED_DEK_LEN {
return Err(MnemoError::Crypto("unexpected wrapped DEK length".into()));
}
let mut wrapped_dek = [0u8; WRAPPED_DEK_LEN];
wrapped_dek.copy_from_slice(&wrapped);
let mut header = Header {
version: VERSION,
page_size: PAGE_SIZE as u32,
flags: FLAG_ENCRYPTED,
dimensions: config.dimensions as u32,
created_at: memory::now_secs(),
write_counter: 0,
next_page: 1 + config.wal_pages_initial.max(MIN_WAL_PAGES),
catalog_start: 0,
catalog_pages: 0,
catalog_len: 0,
vector_count: 0,
m_cost: config.kdf.m_cost,
t_cost: config.kdf.t_cost,
p_cost: config.kdf.p_cost,
salt,
dek_nonce,
wrapped_dek,
index_start: 0,
index_pages: 0,
index_len: 0,
wal_start: 1,
wal_pages: config.wal_pages_initial.max(MIN_WAL_PAGES),
wal_seq: 0,
manifest_start: 0,
manifest_pages: 0,
manifest_len: 0,
seal_nonce: None,
seal_tag: None,
};
let mut pager = Pager::new(file, dek, 0, VERSION);
let mut page = header.to_page();
header.apply_seal(&mut page, pager.dek())?;
pager.write_raw(0, &page)?;
pager.sync()?;
Ok(Mnemo {
pager,
header,
catalog: Vec::new(),
index: HashMap::new(),
path,
dimensions: config.dimensions,
kdf: config.kdf,
dirty_catalog: false,
ann: None,
dirty_index: false,
manifest: Vec::new(),
max_snapshots: config.max_snapshots,
})
}
pub fn open(path: impl Into<PathBuf>, passphrase: &str) -> Result<Mnemo> {
let path: PathBuf = path.into();
let mut file = OpenOptions::new().read(true).write(true).open(&path)?;
let mut hbuf = [0u8; PAGE_SIZE];
file.seek(SeekFrom::Start(0))?;
file.read_exact(&mut hbuf)?;
let mut header = match Header::from_page(&hbuf) {
Ok(h) => h,
Err(MnemoError::HeaderChecksum) => {
let healed = wal::recover(&mut file, 1, TORN_HEADER_SCAN_PAGES, 0)?;
let frames = healed.ok_or(MnemoError::HeaderChecksum)?;
for (page_no, bytes) in &frames {
if bytes.len() != PAGE_SIZE {
return Err(MnemoError::Invalid("WAL frame is not page-sized".into()));
}
file.seek(SeekFrom::Start(page_no * PAGE_SIZE as u64))?;
file.write_all(bytes)?;
}
file.sync_all()?;
file.seek(SeekFrom::Start(0))?;
file.read_exact(&mut hbuf)?;
Header::from_page(&hbuf)?
}
Err(e) => return Err(e),
};
if let Some(frames) =
wal::recover(&mut file, header.wal_start, header.wal_pages, header.wal_seq)?
{
for (page_no, bytes) in &frames {
if bytes.len() != PAGE_SIZE {
return Err(MnemoError::Invalid("WAL frame is not page-sized".into()));
}
file.seek(SeekFrom::Start(page_no * PAGE_SIZE as u64))?;
file.write_all(bytes)?;
}
file.sync_all()?;
file.seek(SeekFrom::Start(0))?;
file.read_exact(&mut hbuf)?;
header = Header::from_page(&hbuf)?;
}
let kdf = KdfParams {
m_cost: header.m_cost,
t_cost: header.t_cost,
p_cost: header.p_cost,
};
let kek = crypto::derive_kek(passphrase.as_bytes(), &header.salt, kdf)?;
let dek = crypto::unwrap_dek(&kek, &header.dek_nonce, &header.wrapped_dek)?;
header.validate_seal(&dek)?;
let on_disk_version = header.version;
let mut pager = Pager::new(file, dek, header.write_counter, on_disk_version);
let dimensions = header.dimensions as usize;
let (catalog, migrated_from_v4) = if on_disk_version >= 5 {
(
load_catalog(
&mut pager,
header.catalog_start,
header.catalog_pages,
header.catalog_len,
)?,
false,
)
} else if on_disk_version == 4 {
let v4 = load_catalog_v4(
&mut pager,
header.catalog_start,
header.catalog_pages,
header.catalog_len,
)?;
(migrate_v4_catalog(&mut pager, v4)?, true)
} else {
return Err(MnemoError::UnsupportedVersion(on_disk_version));
};
let index = build_id_index(&catalog);
let ann = load_index(
&mut pager,
header.index_start,
header.index_pages,
header.index_len,
dimensions,
)?;
let mut manifest = load_manifest(
&mut pager,
header.manifest_start,
header.manifest_pages,
header.manifest_len,
)?;
let migrated_pages_to_v6 = on_disk_version < 6;
if migrated_pages_to_v6 {
for e in &catalog {
if e.deleted {
continue;
}
for i in 0..e.page_count as u64 {
let payload = pager.read_page(e.start_page + i)?;
pager.write_page(e.start_page + i, &payload)?;
}
}
pager.set_version(6);
}
let migrated_header_to_v7 = on_disk_version < 7;
if migrated_header_to_v7 {
pager.set_version(VERSION);
}
let migrated = migrated_from_v4 || migrated_pages_to_v6 || migrated_header_to_v7;
if migrated {
header.version = VERSION;
manifest.clear();
}
let dirty_index = migrated_pages_to_v6 && ann.is_some();
Ok(Mnemo {
pager,
header,
catalog,
index,
path,
dimensions,
kdf,
dirty_catalog: migrated,
ann,
dirty_index,
manifest,
max_snapshots: DEFAULT_MAX_SNAPSHOTS,
})
}
pub fn set_max_snapshots(&mut self, max: usize) {
self.max_snapshots = max;
}
pub fn dimensions(&self) -> usize {
self.dimensions
}
pub fn len(&self) -> usize {
self.catalog.iter().filter(|e| !e.deleted).count()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
fn write_record(&mut self, bytes: &[u8]) -> Result<(u64, u32)> {
let pc = bytes.len().div_ceil(PAYLOAD).max(1);
let start = self.header.next_page;
self.header.next_page += pc as u64;
for i in 0..pc {
let lo = i * PAYLOAD;
let hi = ((i + 1) * PAYLOAD).min(bytes.len());
self.pager.write_page(start + i as u64, &bytes[lo..hi])?;
}
Ok((start, pc as u32))
}
fn read_record(&mut self, e: &CatalogEntry) -> Result<Vec<u8>> {
let mut buf = Vec::with_capacity(e.len as usize);
for i in 0..e.page_count as u64 {
buf.extend_from_slice(&self.pager.read_page(e.start_page + i)?);
}
buf.truncate(e.len as usize);
Ok(buf)
}
fn read_memory(&mut self, e: &CatalogEntry) -> Result<Memory> {
let bytes = self.read_record(e)?;
let mut m: Memory = rmp_serde::from_slice(&bytes)
.map_err(|err| MnemoError::Serialize(err.to_string()))?;
m.accessed_at = e.accessed_at;
m.access_count = e.access_count;
Ok(m)
}
fn put(&mut self, mut m: Memory) -> Result<Ulid> {
if m.vector.len() != self.dimensions {
return Err(MnemoError::DimensionMismatch {
expected: self.dimensions,
got: m.vector.len(),
});
}
if m.id == Ulid::nil() {
m.id = Ulid::new();
}
let id_u: u128 = m.id.0;
let bytes = rmp_serde::to_vec(&m).map_err(|e| MnemoError::Serialize(e.to_string()))?;
let (start, pc) = self.write_record(&bytes)?;
let (prev_accessed, prev_count) = match self.index.get(&id_u).copied() {
Some(idx) => (self.catalog[idx].accessed_at, self.catalog[idx].access_count),
None => (m.accessed_at, m.access_count),
};
let entry = CatalogEntry {
id: id_u,
start_page: start,
page_count: pc,
len: bytes.len() as u32,
deleted: false,
accessed_at: prev_accessed,
access_count: prev_count,
};
match self.index.get(&id_u).copied() {
Some(idx) => self.catalog[idx] = entry,
None => {
self.index.insert(id_u, self.catalog.len());
self.catalog.push(entry);
}
}
self.dirty_catalog = true;
if let Some(ann) = &mut self.ann {
ann.add(id_u, &m.vector);
self.dirty_index = true;
}
Ok(m.id)
}
pub fn remember(&mut self, memory: Memory) -> Result<Ulid> {
self.put(memory)
}
pub fn get(&mut self, id: &Ulid) -> Result<Memory> {
let idx = *self
.index
.get(&id.0)
.ok_or_else(|| MnemoError::NotFound(id.to_string()))?;
let entry = self.catalog[idx].clone();
if entry.deleted {
return Err(MnemoError::NotFound(id.to_string()));
}
self.read_memory(&entry)
}
pub fn delete(&mut self, id: &Ulid) -> Result<()> {
let idx = *self
.index
.get(&id.0)
.ok_or_else(|| MnemoError::NotFound(id.to_string()))?;
if !self.catalog[idx].deleted {
self.catalog[idx].deleted = true;
self.dirty_catalog = true;
}
if let Some(ann) = &mut self.ann {
ann.remove(id.0);
self.dirty_index = true;
}
Ok(())
}
pub fn memories(&mut self) -> Result<Vec<Memory>> {
let entries: Vec<CatalogEntry> = self
.catalog
.iter()
.filter(|e| !e.deleted)
.cloned()
.collect();
let mut out = Vec::with_capacity(entries.len());
for e in &entries {
out.push(self.read_memory(e)?);
}
Ok(out)
}
pub fn about(&mut self) -> Result<Vec<Memory>> {
let mut out: Vec<Memory> = self
.memories()?
.into_iter()
.filter(|m| {
m.metadata
.get("area")
.and_then(|v| v.as_str())
.map(|s| s.eq_ignore_ascii_case("onboarding"))
.unwrap_or(false)
})
.collect();
out.sort_by(|a, b| {
let a_manifest = is_manifest(a);
let b_manifest = is_manifest(b);
b_manifest
.cmp(&a_manifest)
.then_with(|| b.importance.total_cmp(&a.importance))
.then_with(|| a.created_at.cmp(&b.created_at))
});
Ok(out)
}
pub fn search(
&mut self,
query: &[f32],
top_k: usize,
metric: Metric,
) -> Result<Vec<(Memory, f32)>> {
if query.len() != self.dimensions {
return Err(MnemoError::DimensionMismatch {
expected: self.dimensions,
got: query.len(),
});
}
let now = memory::now_secs();
let entries: Vec<CatalogEntry> = self
.catalog
.iter()
.filter(|e| !e.deleted)
.cloned()
.collect();
let mut scored: Vec<(Memory, f32)> = Vec::new();
for e in &entries {
let m = self.read_memory(e)?;
if m.is_expired(now) {
continue;
}
let sim = memory::similarity(metric, query, &m.vector);
scored.push((m, sim));
}
scored.sort_by(|a, b| b.1.total_cmp(&a.1));
scored.truncate(top_k);
Ok(scored)
}
pub fn recall(&mut self, req: &RecallRequest) -> Result<Vec<RecallResult>> {
if req.query.len() != self.dimensions {
return Err(MnemoError::DimensionMismatch {
expected: self.dimensions,
got: req.query.len(),
});
}
let now = memory::now_secs();
let entries: Vec<CatalogEntry> = if let Some(ann) = &self.ann {
let ids = ann.query(&req.query, req.n_probe, req.n_rerank);
ids.iter()
.filter_map(|id| self.index.get(id).copied())
.map(|i| self.catalog[i].clone())
.filter(|e| !e.deleted)
.collect()
} else {
self.catalog
.iter()
.filter(|e| !e.deleted)
.cloned()
.collect()
};
let mut scored: Vec<RecallResult> = Vec::new();
for e in &entries {
let m = self.read_memory(e)?;
if m.is_expired(now) {
continue;
}
if let Some(types) = &req.memory_types {
if !types.contains(&m.memory_type) {
continue;
}
}
if let Some(agent) = &req.agent_id {
let visible = m.agent_id == *agent || m.scope == Scope::Shared;
if !visible {
continue;
}
}
let sim = memory::similarity(req.metric, &req.query, &m.vector);
let age = (now - m.accessed_at) as f32;
let score = req.weights.score(sim, age, m.importance, m.access_count);
scored.push(RecallResult { memory: m, score, similarity: sim });
}
scored.sort_by(|a, b| b.score.total_cmp(&a.score));
scored.truncate(req.top_k);
if req.track_access {
for r in &mut scored {
if let Some(&idx) = self.index.get(&r.memory.id.0) {
let entry = &mut self.catalog[idx];
entry.accessed_at = now;
entry.access_count = entry.access_count.saturating_add(1);
r.memory.accessed_at = entry.accessed_at;
r.memory.access_count = entry.access_count;
}
}
if !scored.is_empty() {
self.dirty_catalog = true;
}
}
Ok(scored)
}
pub fn build_index(&mut self) -> Result<IndexInfo> {
self.build_index_with(IndexConfig::default())
}
pub fn build_index_with(&mut self, cfg: IndexConfig) -> Result<IndexInfo> {
let mems = self.memories()?;
if mems.is_empty() {
return Err(MnemoError::Invalid(
"cannot build an index over an empty database".into(),
));
}
let items: Vec<(u128, &[f32])> =
mems.iter().map(|m| (m.id.0, m.vector.as_slice())).collect();
let idx = IvfPqIndex::build(self.dimensions, &items, cfg)?;
let info = idx.info();
self.ann = Some(idx);
self.dirty_index = true;
Ok(info)
}
pub fn rebuild_index(&mut self) -> Result<IndexInfo> {
let cfg = match &self.ann {
Some(a) => IndexConfig {
n_probe: a.n_probe(),
n_rerank: a.n_rerank(),
..Default::default()
},
None => IndexConfig::default(),
};
self.build_index_with(cfg)
}
pub fn drop_index(&mut self) {
if self.ann.is_some() {
self.ann = None;
self.dirty_index = true;
}
}
pub fn has_index(&self) -> bool {
self.ann.is_some()
}
fn seal_run(&mut self, bytes: &[u8], frames: &mut Vec<wal::Frame>) -> Result<(u64, u32)> {
let pc = bytes.len().div_ceil(PAYLOAD).max(1);
let start = self.header.next_page;
self.header.next_page += pc as u64;
for i in 0..pc {
let lo = i * PAYLOAD;
let hi = ((i + 1) * PAYLOAD).min(bytes.len());
let page_no = start + i as u64;
let sealed = self.pager.seal_page(page_no, &bytes[lo..hi])?;
frames.push((page_no, sealed.to_vec()));
}
Ok((start, pc as u32))
}
fn ensure_wal_capacity(&mut self, frame_pages: usize) -> Result<()> {
let need = wal::txn_byte_len(frame_pages, PAGE_SIZE);
if need <= self.header.wal_pages * PAGE_SIZE as u64 {
return Ok(());
}
let req = need.div_ceil(PAGE_SIZE as u64);
let new_pages = (req + req / 2 + 4).max(DEFAULT_WAL_PAGES);
let new_start = self.header.next_page;
self.header.next_page += new_pages;
self.header.wal_start = new_start;
self.header.wal_pages = new_pages;
self.header.write_counter = self.pager.write_counter;
let mut page = self.header.to_page();
self.header.apply_seal(&mut page, self.pager.dek())?;
self.pager.write_raw(0, &page)?;
self.pager.sync()?;
Ok(())
}
fn checkpoint(&mut self, frames: &[wal::Frame]) -> Result<()> {
for (page_no, bytes) in frames {
let mut img = [0u8; PAGE_SIZE];
img.copy_from_slice(bytes);
self.pager.write_sealed(*page_no, &img)?;
}
self.pager.sync()?;
Ok(())
}
pub fn flush(&mut self) -> Result<()> {
if !self.dirty_catalog && !self.dirty_index {
return Ok(());
}
let ctx = self.prepare_for_flush()?;
self.pager.flush()?;
let mut frames: Vec<wal::Frame> = Vec::new();
if let Some(bytes) = &ctx.cat_bytes {
let (start, pages) = self.seal_run(bytes, &mut frames)?;
self.header.catalog_start = start;
self.header.catalog_pages = pages as u64;
self.header.catalog_len = bytes.len() as u64;
}
if self.dirty_index {
match &ctx.idx_bytes {
Some(bytes) => {
let (start, pages) = self.seal_run(bytes, &mut frames)?;
self.header.index_start = start;
self.header.index_pages = pages as u64;
self.header.index_len = bytes.len() as u64;
}
None => {
self.header.index_start = 0;
self.header.index_pages = 0;
self.header.index_len = 0;
}
}
}
let live = self.len() as u64;
let txn_id = self.header.wal_seq + 1;
let mut manifest = self.manifest.clone();
if self.max_snapshots > 0 {
while manifest.len() >= self.max_snapshots {
manifest.remove(0);
}
}
manifest.push(Snapshot {
txn_id,
created_at: memory::now_secs(),
catalog_start: self.header.catalog_start,
catalog_pages: self.header.catalog_pages,
catalog_len: self.header.catalog_len,
index_start: self.header.index_start,
index_pages: self.header.index_pages,
index_len: self.header.index_len,
memory_count: live,
});
let man_bytes =
rmp_serde::to_vec(&manifest).map_err(|e| MnemoError::Serialize(e.to_string()))?;
let (m_start, m_pages) = self.seal_run(&man_bytes, &mut frames)?;
self.header.manifest_start = m_start;
self.header.manifest_pages = m_pages as u64;
self.header.manifest_len = man_bytes.len() as u64;
self.header.vector_count = live;
self.header.write_counter = self.pager.write_counter;
self.header.wal_seq = txn_id;
let mut hpage = self.header.to_page();
self.header.apply_seal(&mut hpage, self.pager.dek())?;
frames.push((0, hpage.to_vec()));
let (wal_start, wal_pages) = (self.header.wal_start, self.header.wal_pages);
wal::commit(self.pager.file_mut(), wal_start, wal_pages, txn_id, &frames)?;
self.checkpoint(&frames)?;
self.manifest = manifest;
self.dirty_catalog = false;
self.dirty_index = false;
Ok(())
}
fn prepare_for_flush(&mut self) -> Result<FlushPrelude> {
let cat_bytes = if self.dirty_catalog {
Some(
rmp_serde::to_vec(&self.catalog)
.map_err(|e| MnemoError::Serialize(e.to_string()))?,
)
} else {
None
};
let idx_bytes: Option<Vec<u8>> = if self.dirty_index {
match &self.ann {
Some(ann) => Some(
rmp_serde::to_vec(ann).map_err(|e| MnemoError::Serialize(e.to_string()))?,
),
None => None,
}
} else {
None
};
let cat_pc = cat_bytes.as_ref().map_or(0, |b| b.len().div_ceil(PAYLOAD).max(1));
let idx_pc = idx_bytes.as_ref().map_or(0, |b| b.len().div_ceil(PAYLOAD).max(1));
let man_upper = 9 + (self.manifest.len() + 1) * 82;
let man_pc_est = man_upper.div_ceil(PAYLOAD).max(1);
self.ensure_wal_capacity(cat_pc + idx_pc + man_pc_est + 1)?;
let data_dirty = self.pager.dirty_page_count() as u64;
let counter_lease = data_dirty + (cat_pc + idx_pc + man_pc_est) as u64;
let page_lease = (cat_pc + idx_pc + man_pc_est) as u64;
let mut leased = self.header.clone();
leased.write_counter = self.pager.write_counter + counter_lease;
leased.next_page += page_lease;
let mut page = leased.to_page();
leased.apply_seal(&mut page, self.pager.dek())?;
self.pager.write_raw(0, &page)?;
self.pager.sync()?;
Ok(FlushPrelude { cat_bytes, idx_bytes })
}
pub fn close(&mut self) -> Result<()> {
self.flush()
}
#[doc(hidden)]
pub fn __crash_partial_flush_for_testing(&mut self) -> Result<()> {
if !self.dirty_catalog && !self.dirty_index {
return Ok(());
}
let _prelude = self.prepare_for_flush()?;
self.pager.flush()
}
pub fn set_cache_capacity(&mut self, pages: usize) {
self.pager.set_cache_capacity(pages);
}
pub fn cache_stats(&self) -> (usize, usize) {
self.pager.cache_stats()
}
pub fn session(&mut self, agent_id: impl Into<String>) -> crate::session::Session<'_> {
crate::session::Session::new(self, agent_id.into())
}
pub fn snapshots(&self) -> Vec<SnapshotInfo> {
let mut v: Vec<SnapshotInfo> = self
.manifest
.iter()
.map(|s| SnapshotInfo {
txn_id: s.txn_id,
created_at: s.created_at,
memory_count: s.memory_count,
})
.collect();
v.sort_by_key(|s| s.txn_id);
v
}
fn apply_snapshot(&mut self, snap: &Snapshot) -> Result<()> {
let catalog = load_catalog(
&mut self.pager,
snap.catalog_start,
snap.catalog_pages,
snap.catalog_len,
)?;
let ann = load_index(
&mut self.pager,
snap.index_start,
snap.index_pages,
snap.index_len,
self.dimensions,
)?;
self.index = build_id_index(&catalog);
self.catalog = catalog;
self.ann = ann;
self.dirty_catalog = true;
self.dirty_index = true;
self.flush()
}
pub fn restore_to(&mut self, txn_id: u64) -> Result<SnapshotInfo> {
let snap = self
.manifest
.iter()
.find(|s| s.txn_id == txn_id)
.cloned()
.ok_or_else(|| MnemoError::NotFound(format!("snapshot for transaction {txn_id}")))?;
self.apply_snapshot(&snap)?;
Ok(SnapshotInfo {
txn_id: snap.txn_id,
created_at: snap.created_at,
memory_count: snap.memory_count,
})
}
pub fn restore_to_time(&mut self, unix_secs: i64) -> Result<SnapshotInfo> {
let snap = self
.manifest
.iter()
.filter(|s| s.created_at <= unix_secs)
.max_by_key(|s| s.txn_id)
.cloned()
.ok_or_else(|| {
MnemoError::NotFound(format!("no snapshot at or before time {unix_secs}"))
})?;
self.apply_snapshot(&snap)?;
Ok(SnapshotInfo {
txn_id: snap.txn_id,
created_at: snap.created_at,
memory_count: snap.memory_count,
})
}
pub fn rekey(&mut self, new_passphrase: &str, kdf: KdfParams) -> Result<()> {
self.flush()?;
let new_salt = crypto::random_salt();
let new_kek = crypto::derive_kek(new_passphrase.as_bytes(), &new_salt, kdf)?;
let new_nonce = crypto::random_nonce();
let wrapped = crypto::wrap_dek(&new_kek, &new_nonce, self.pager.dek())?;
if wrapped.len() != WRAPPED_DEK_LEN {
return Err(MnemoError::Crypto("unexpected wrapped DEK length".into()));
}
let mut wrapped_dek = [0u8; WRAPPED_DEK_LEN];
wrapped_dek.copy_from_slice(&wrapped);
self.header.salt = new_salt;
self.header.dek_nonce = new_nonce;
self.header.wrapped_dek = wrapped_dek;
self.header.m_cost = kdf.m_cost;
self.header.t_cost = kdf.t_cost;
self.header.p_cost = kdf.p_cost;
self.header.write_counter = self.pager.write_counter;
self.kdf = kdf;
let mut page = self.header.to_page();
self.header.apply_seal(&mut page, self.pager.dek())?;
self.pager.write_raw(0, &page)?;
self.pager.sync()?;
Ok(())
}
pub fn verify(&mut self) -> Result<usize> {
let entries: Vec<CatalogEntry> = self
.catalog
.iter()
.filter(|e| !e.deleted)
.cloned()
.collect();
let n = entries.len();
for e in &entries {
let bytes = self.read_record(e)?;
let _m: Memory = rmp_serde::from_slice(&bytes)
.map_err(|err| MnemoError::Serialize(err.to_string()))?;
}
Ok(n)
}
pub fn stats(&mut self) -> Result<Stats> {
let deleted = self.catalog.iter().filter(|e| e.deleted).count();
let mut agents: Vec<String> = self
.memories()?
.into_iter()
.map(|m| m.agent_id)
.collect();
agents.sort();
agents.dedup();
let file_bytes = self.header.next_page * PAGE_SIZE as u64;
Ok(Stats {
memories: self.len(),
deleted,
dimensions: self.dimensions,
file_bytes,
agents,
encrypted: self.header.flags & FLAG_ENCRYPTED != 0,
created_at: self.header.created_at,
index: self.ann.as_ref().map(|a| a.info()),
wal_pages: self.header.wal_pages,
})
}
pub fn compact_file(path: &str, passphrase: &str) -> Result<CompactReport> {
let mut old = Mnemo::open(path, passphrase)?;
let dims = old.dimensions;
let kdf = old.kdf;
let tmp = format!("{path}.compact-tmp");
let mut new = Mnemo::create(
&tmp,
passphrase,
MnemoConfig { dimensions: dims, kdf, ..Default::default() },
)?;
let want_index = old.ann.as_ref().map(|a| (a.n_probe(), a.n_rerank()));
let now = memory::now_secs();
let all = old.memories()?;
let before = all.len();
let mut after = 0;
for m in all {
if m.is_expired(now) {
continue;
}
new.remember(m)?;
after += 1;
}
if let Some((n_probe, n_rerank)) = want_index {
if after > 0 {
new.build_index_with(IndexConfig {
n_probe,
n_rerank,
..Default::default()
})?;
}
}
new.flush()?;
drop(new);
drop(old);
std::fs::rename(&tmp, path)?;
Ok(CompactReport { before, after })
}
}