1use std::collections::HashMap;
26use std::path::{Path as FsPath, PathBuf};
27use std::sync::{Arc, Mutex, OnceLock, Weak};
28use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
29
30use axum::extract::{FromRequestParts, Path, State};
31use axum::http::StatusCode;
32use axum::http::request::Parts;
33use axum::response::{IntoResponse, Response};
34use axum::routing::{delete, get, post};
35use axum::{Extension, Json, Router};
36use mentedb::consolidation::archival::ArchivalDecision;
37use mentedb::storage::backup::BackupManager;
38use mentedb_core::MemoryNode;
39use mentedb_core::memory::{AttributeValue, MemoryType};
40use mentedb_core::types::{AgentId, MemoryId, SpaceId, UserId};
41use mentedb_embedding::provider::EmbeddingProvider;
42use serde_json::{Value, json};
43use uuid::Uuid;
44
45use crate::auth::AuthenticatedAgent;
46use crate::databases::{DEFAULT_DB, Db, validate_name};
47use crate::error::ApiError;
48use crate::state::AppState;
49
50#[derive(Clone, Debug)]
56pub struct DocumentConfig {
57 pub chunk_chars: usize,
59 pub overlap_chars: usize,
61 pub min_chunk_chars: usize,
63 pub max_chunk_chars: usize,
65 pub max_overlap_ratio: f32,
67 pub max_chunks: usize,
69 pub sentence_terminators: Vec<char>,
71}
72
73impl Default for DocumentConfig {
74 fn default() -> Self {
75 Self {
76 chunk_chars: 1500,
77 overlap_chars: 200,
78 min_chunk_chars: 100,
79 max_chunk_chars: 8000,
80 max_overlap_ratio: 0.5,
81 max_chunks: 10_000,
82 sentence_terminators: vec!['.', '!', '?', '\u{3002}', '\u{ff01}', '\u{ff1f}'],
83 }
84 }
85}
86
87#[derive(Clone, Debug)]
89pub struct ReadinessConfig {
90 pub embed_check_interval: Duration,
93 pub probe_text: String,
95 pub check_timeout: Duration,
97}
98
99impl Default for ReadinessConfig {
100 fn default() -> Self {
101 Self {
102 embed_check_interval: Duration::from_secs(30),
103 probe_text: "readiness probe".to_string(),
104 check_timeout: Duration::from_secs(5),
105 }
106 }
107}
108
109#[derive(Clone, Debug)]
111pub struct OpsConfig {
112 pub backup_dir: Option<PathBuf>,
115 pub consolidation_min_cluster_size: usize,
117 pub consolidation_similarity: f32,
119 pub dedup_batch_size: usize,
122 pub max_forgets_per_archive: usize,
124 pub documents: DocumentConfig,
125 pub readiness: ReadinessConfig,
126}
127
128impl Default for OpsConfig {
129 fn default() -> Self {
130 Self {
131 backup_dir: None,
132 consolidation_min_cluster_size: crate::maintenance::MIN_CLUSTER_SIZE,
133 consolidation_similarity: crate::maintenance::SIMILARITY_THRESHOLD,
134 dedup_batch_size: 1000,
135 max_forgets_per_archive: crate::maintenance::MAX_FORGETS_PER_SWEEP,
136 documents: DocumentConfig::default(),
137 readiness: ReadinessConfig::default(),
138 }
139 }
140}
141
142impl OpsConfig {
143 pub fn from_env() -> Self {
145 Self {
146 backup_dir: std::env::var_os("MENTEDB_BACKUP_DIR")
147 .filter(|v| !v.is_empty())
148 .map(PathBuf::from),
149 ..Self::default()
150 }
151 }
152}
153
154pub fn ops_config() -> &'static OpsConfig {
156 static CONFIG: OnceLock<OpsConfig> = OnceLock::new();
157 CONFIG.get_or_init(OpsConfig::from_env)
158}
159
160pub fn routes() -> Router<Arc<AppState>> {
166 Router::new()
167 .route("/maintenance/decay", post(maintenance_decay))
168 .route("/maintenance/archive", post(maintenance_archive))
169 .route("/maintenance/consolidate", post(maintenance_consolidate))
170 .route("/maintenance/sweep", post(maintenance_sweep))
171 .route("/enrich", post(enrich))
172 .route("/documents", post(store_document))
173 .route("/documents/{document_id}", delete(delete_document))
174 .route("/backup", post(create_backup))
175 .route("/backups", get(list_backups))
176}
177
178pub fn global_routes() -> Router<Arc<AppState>> {
180 Router::new()
181 .route("/v1/databases", get(list_databases).post(create_database))
182 .route("/v1/databases/{name}", delete(delete_database))
183 .route("/v1/ready", get(ready))
184}
185
186pub struct AdminGuard;
194
195impl FromRequestParts<Arc<AppState>> for AdminGuard {
196 type Rejection = ApiError;
197
198 async fn from_request_parts(
199 parts: &mut Parts,
200 state: &Arc<AppState>,
201 ) -> Result<Self, ApiError> {
202 if parts
203 .extensions
204 .get::<AuthenticatedAgent>()
205 .is_some_and(|a| a.admin)
206 {
207 return Ok(Self);
208 }
209 crate::auth::admin_authorized(&parts.headers, &state.admin_key)?;
210 Ok(Self)
211 }
212}
213
214async fn blocking<T, F>(what: &'static str, f: F) -> Result<T, ApiError>
216where
217 T: Send + 'static,
218 F: FnOnce() -> Result<T, ApiError> + Send + 'static,
219{
220 tokio::task::spawn_blocking(f)
221 .await
222 .map_err(|e| ApiError::Internal(format!("{what} task failed: {e}")))?
223}
224
225fn internal(what: &str) -> impl FnOnce(mentedb_core::MenteError) -> ApiError + '_ {
226 move |e| ApiError::Internal(format!("{what} failed: {e}"))
227}
228
229fn now_micros() -> u64 {
230 SystemTime::now()
231 .duration_since(UNIX_EPOCH)
232 .unwrap_or_default()
233 .as_micros() as u64
234}
235
236async fn maintenance_decay(db: Db) -> Result<Json<Value>, ApiError> {
243 let handle = db.handle.clone();
244 let decayed = blocking("decay", move || {
245 handle.apply_decay_global().map_err(internal("decay"))
246 })
247 .await?;
248 Ok(Json(json!({ "database": db.name, "decayed": decayed })))
249}
250
251async fn maintenance_archive(db: Db) -> Result<Json<Value>, ApiError> {
255 let handle = db.handle.clone();
256 let cap = ops_config().max_forgets_per_archive;
257 let report = blocking("archive", move || {
258 let decisions = handle
259 .evaluate_archival_global()
260 .map_err(internal("archival"))?;
261 let evaluated = decisions.len();
262 let (mut keep, mut archive, mut consolidate, mut delete) = (0usize, 0usize, 0, 0);
263 let mut forgotten = 0usize;
264 for (id, decision) in decisions {
265 match decision {
266 ArchivalDecision::Keep => keep += 1,
267 ArchivalDecision::Archive => archive += 1,
268 ArchivalDecision::Consolidate(_) => consolidate += 1,
269 ArchivalDecision::Delete => {
270 delete += 1;
271 if forgotten < cap && handle.forget(id).is_ok() {
272 forgotten += 1;
273 }
274 }
275 }
276 }
277 handle.flush().map_err(internal("flush"))?;
278 Ok(json!({
279 "evaluated": evaluated,
280 "keep": keep,
281 "flagged_archive": archive,
282 "flagged_consolidate": consolidate,
283 "flagged_delete": delete,
284 "forgotten": forgotten,
285 "capped": delete > forgotten,
286 }))
287 })
288 .await?;
289 Ok(Json(with_database(&db.name, report)))
290}
291
292async fn maintenance_consolidate(db: Db) -> Result<Json<Value>, ApiError> {
296 let handle = db.handle.clone();
297 let cfg = ops_config();
298 let (batch, min_cluster, similarity) = (
299 cfg.dedup_batch_size.max(1),
300 cfg.consolidation_min_cluster_size,
301 cfg.consolidation_similarity,
302 );
303 let report = blocking("consolidate", move || {
304 let ids = handle.memory_ids();
305 let mut duplicates_merged = 0usize;
306 for chunk in ids.chunks(batch) {
307 duplicates_merged += handle.dedup_sweep_ids(chunk).map_err(internal("dedup"))?;
308 }
309 let candidates = handle
310 .find_consolidation_candidates(min_cluster, similarity)
311 .map_err(internal("consolidation"))?;
312 let clusters_found = candidates.len();
313 let clusters_consolidated = candidates
314 .iter()
315 .filter(|c| handle.consolidate_cluster(&c.memories).is_ok())
316 .count();
317 handle.flush().map_err(internal("flush"))?;
318 Ok(json!({
319 "duplicates_merged": duplicates_merged,
320 "clusters_found": clusters_found,
321 "clusters_consolidated": clusters_consolidated,
322 }))
323 })
324 .await?;
325 Ok(Json(with_database(&db.name, report)))
326}
327
328async fn maintenance_sweep(db: Db) -> Result<Json<Value>, ApiError> {
330 let handle = db.handle.clone();
331 let report = blocking("sweep", move || Ok(crate::maintenance::run_sweep(&handle))).await?;
332 Ok(Json(json!({
333 "database": db.name,
334 "consolidated": report.consolidated,
335 "decayed": report.decayed,
336 "forgotten": report.forgotten,
337 })))
338}
339
340fn with_database(name: &str, mut report: Value) -> Value {
341 if let Value::Object(map) = &mut report {
342 map.insert("database".into(), Value::String(name.to_string()));
343 }
344 report
345}
346
347async fn enrich(State(state): State<Arc<AppState>>, db: Db) -> Result<Json<Value>, ApiError> {
354 let config = state.extraction_config.clone().ok_or_else(|| {
355 ApiError::ServiceUnavailable(
356 "enrichment needs an LLM: start the server with --llm-provider or \
357 MENTEDB_LLM_PROVIDER"
358 .into(),
359 )
360 })?;
361 let embedder = state.databases.embedder().ok_or_else(|| {
362 ApiError::ServiceUnavailable(
363 "enrichment needs an embedder: start the server with an embedding provider".into(),
364 )
365 })?;
366 let provider = mentedb_extraction::HttpExtractionProvider::new(config.reader_config())
370 .map_err(|e| ApiError::ServiceUnavailable(format!("LLM provider unavailable: {e}")))?;
371 let llm = mentedb::cognitive::CognitiveLlmService::new(
372 mentedb_extraction::ExtractionLlmJudge::new(provider),
373 );
374 let handle = db.handle.clone();
375 let result = crate::hosting::run_on_enrichment_pool(async move {
378 let watermark = now_micros();
381 let result = mentedb::enrichment::run_enrichment(
382 &handle,
383 config,
384 &*embedder,
385 Some(&llm),
386 watermark,
387 false,
388 )
389 .await;
390 handle.flush().map_err(internal("flush"))?;
391 Ok::<_, ApiError>(result)
392 })
393 .await??;
394 Ok(Json(json!({
395 "database": db.name,
396 "memories_stored": result.memories_stored,
397 "edges_created": result.edges_created,
398 "entities_extracted": result.entities_extracted,
399 "duplicates_skipped": result.duplicates_skipped,
400 "contradictions_found": result.contradictions_found,
401 "sync_linked": result.sync_linked,
402 "llm_linked": result.llm_linked,
403 "communities_created": result.communities_created,
404 "user_model_updated": result.user_model_updated,
405 })))
406}
407
408fn document_tag(document_id: &Uuid) -> String {
414 format!("doc:{document_id}")
415}
416
417fn optional_str<'a>(req: &'a Value, field: &str) -> Result<Option<&'a str>, ApiError> {
418 match req.get(field) {
419 None | Some(Value::Null) => Ok(None),
420 Some(Value::String(s)) => Ok(Some(s.as_str())),
421 Some(_) => Err(ApiError::BadRequest(format!("'{field}' must be a string"))),
422 }
423}
424
425fn optional_usize(req: &Value, field: &str) -> Result<Option<usize>, ApiError> {
426 match req.get(field) {
427 None | Some(Value::Null) => Ok(None),
428 Some(v) => v
429 .as_u64()
430 .map(|n| Some(n as usize))
431 .ok_or_else(|| ApiError::BadRequest(format!("'{field}' must be a whole number"))),
432 }
433}
434
435async fn store_document(
441 agent: Option<Extension<AuthenticatedAgent>>,
442 db: Db,
443 Json(req): Json<Value>,
444) -> Result<impl IntoResponse, ApiError> {
445 let cfg = &ops_config().documents;
446 let content = req
447 .get("content")
448 .and_then(|v| v.as_str())
449 .filter(|s| !s.trim().is_empty())
450 .ok_or_else(|| ApiError::BadRequest("missing or empty 'content'".into()))?;
451 let title = optional_str(&req, "title")?.map(String::from);
452 let source = optional_str(&req, "source")?.map(String::from);
453
454 let agent_id = match crate::handlers::parse_optional_uuid(&req, "agent_id")? {
455 Some(id) => {
456 let id = AgentId(id);
457 crate::handlers::check_body_agent(&agent, id)?;
458 id
459 }
460 None => match &agent {
461 Some(Extension(authed)) => authed.agent()?.unwrap_or_else(AgentId::nil),
462 None => AgentId::nil(),
463 },
464 };
465 let user_id = crate::handlers::parse_optional_uuid(&req, "user_id")?
466 .map(UserId)
467 .unwrap_or_else(UserId::nil);
468 let memory_type = if req.get("memory_type").is_some_and(|v| !v.is_null()) {
469 crate::handlers::parse_memory_type(&req)?
470 } else {
471 MemoryType::Semantic
472 };
473 let mut tags: Vec<String> = match req.get("tags") {
474 None | Some(Value::Null) => vec![],
475 Some(Value::Array(arr)) => arr
476 .iter()
477 .map(|v| {
478 v.as_str()
479 .map(String::from)
480 .ok_or_else(|| ApiError::BadRequest("'tags' must be strings".into()))
481 })
482 .collect::<Result<_, _>>()?,
483 Some(_) => return Err(ApiError::BadRequest("'tags' must be an array".into())),
484 };
485
486 let chunk_chars = optional_usize(&req, "chunk_chars")?.unwrap_or(cfg.chunk_chars);
487 let overlap_chars = optional_usize(&req, "overlap_chars")?.unwrap_or(cfg.overlap_chars);
488 if chunk_chars < cfg.min_chunk_chars || chunk_chars > cfg.max_chunk_chars {
489 return Err(ApiError::BadRequest(format!(
490 "'chunk_chars' must be between {} and {}",
491 cfg.min_chunk_chars, cfg.max_chunk_chars
492 )));
493 }
494 let max_overlap = (chunk_chars as f32 * cfg.max_overlap_ratio) as usize;
495 if overlap_chars > max_overlap {
496 return Err(ApiError::BadRequest(format!(
497 "'overlap_chars' must be at most {max_overlap} for chunks of {chunk_chars}"
498 )));
499 }
500
501 let chunks = chunk_text(
502 content,
503 chunk_chars,
504 overlap_chars,
505 &cfg.sentence_terminators,
506 );
507 if chunks.len() > cfg.max_chunks {
508 return Err(ApiError::BadRequest(format!(
509 "document splits into {} chunks, more than the limit of {}",
510 chunks.len(),
511 cfg.max_chunks
512 )));
513 }
514
515 let document_id = Uuid::new_v4();
516 let doc_tag = document_tag(&document_id);
517 if !tags.contains(&doc_tag) {
518 tags.push(doc_tag);
519 }
520 let handle = db.handle.clone();
521 let chunk_count = chunks.len();
522 let ids = blocking("store document", move || {
523 let texts: Vec<&str> = chunks.iter().map(String::as_str).collect();
524 let embeddings = handle
525 .embed_batch(&texts)
526 .map_err(|e| ApiError::ServiceUnavailable(format!("embedding failed: {e}")))?;
527 let now = now_micros();
528 let nodes: Vec<MemoryNode> = chunks
529 .iter()
530 .enumerate()
531 .map(|(i, text)| {
532 let mut attributes = HashMap::new();
533 attributes.insert(
534 "document_id".to_string(),
535 AttributeValue::String(document_id.to_string()),
536 );
537 attributes.insert("chunk_index".to_string(), AttributeValue::Integer(i as i64));
538 attributes.insert(
539 "chunk_count".to_string(),
540 AttributeValue::Integer(chunk_count as i64),
541 );
542 if let Some(t) = &title {
543 attributes.insert("title".to_string(), AttributeValue::String(t.clone()));
544 }
545 if let Some(s) = &source {
546 attributes.insert("source".to_string(), AttributeValue::String(s.clone()));
547 }
548 MemoryNode {
549 id: MemoryId::new(),
550 agent_id,
551 user_id,
552 memory_type,
553 embedding: embeddings
554 .as_ref()
555 .and_then(|e| e.get(i).cloned())
556 .unwrap_or_default(),
557 content: text.clone(),
558 created_at: now,
559 accessed_at: now,
560 access_count: 0,
561 salience: 0.5,
562 confidence: 1.0,
563 space_id: SpaceId::nil(),
564 attributes,
565 tags: tags.clone(),
566 valid_from: None,
567 valid_until: None,
568 context: None,
569 }
570 })
571 .collect();
572 let ids = handle.store_batch(nodes).map_err(internal("store"))?;
573 handle.flush().map_err(internal("flush"))?;
574 Ok(ids)
575 })
576 .await?;
577
578 Ok((
579 StatusCode::CREATED,
580 Json(json!({
581 "database": db.name,
582 "document_id": document_id.to_string(),
583 "chunk_count": ids.len(),
584 "chunk_ids": ids.iter().map(|id| id.to_string()).collect::<Vec<_>>(),
585 })),
586 ))
587}
588
589async fn delete_document(
591 agent: Option<Extension<AuthenticatedAgent>>,
592 db: Db,
593 Path(params): Path<HashMap<String, String>>,
594) -> Result<Json<Value>, ApiError> {
595 let document_id: Uuid = params
596 .get("document_id")
597 .ok_or_else(|| ApiError::BadRequest("missing document id".into()))?
598 .parse()
599 .map_err(|_| ApiError::BadRequest("invalid document id".into()))?;
600 let handle = db.handle.clone();
601 let deleted = blocking("delete document", move || {
602 let ids = handle.memory_ids_with_tag(&document_tag(&document_id));
603 if ids.is_empty() {
604 return Err(ApiError::NotFound(format!(
605 "document {document_id} not found"
606 )));
607 }
608 if agent.is_some() {
611 for id in &ids {
612 if let Ok(node) = handle.get_memory(*id) {
613 crate::handlers::check_owner(&agent, &node)?;
614 }
615 }
616 }
617 for id in &ids {
618 handle.forget(*id).map_err(internal("forget"))?;
619 }
620 handle.flush().map_err(internal("flush"))?;
621 Ok(ids.len())
622 })
623 .await?;
624 Ok(Json(json!({
625 "database": db.name,
626 "document_id": document_id.to_string(),
627 "deleted": deleted,
628 })))
629}
630
631pub fn chunk_text(
637 text: &str,
638 chunk_chars: usize,
639 overlap_chars: usize,
640 terminators: &[char],
641) -> Vec<String> {
642 let chunk_chars = chunk_chars.max(1);
643 let overlap_chars = overlap_chars.min(chunk_chars / 2);
644 let max_unit = chunk_chars.saturating_sub(overlap_chars + 2).max(1);
646
647 let mut units: Vec<(String, bool)> = Vec::new();
649 for paragraph in paragraphs(text) {
650 if char_len(¶graph) <= max_unit {
651 units.push((paragraph, true));
652 continue;
653 }
654 let mut first = true;
655 for sentence in sentences(¶graph, terminators) {
656 for piece in split_long(&sentence, max_unit) {
657 units.push((piece, first));
658 first = false;
659 }
660 }
661 }
662
663 let mut chunks = Vec::new();
664 let mut current = String::new();
665 for (unit, new_paragraph) in units {
666 let sep = if new_paragraph { "\n\n" } else { " " };
667 if current.is_empty() {
668 current = unit;
669 } else if char_len(¤t) + sep.len() + char_len(&unit) <= chunk_chars {
670 current.push_str(sep);
671 current.push_str(&unit);
672 } else {
673 let tail = overlap_tail(¤t, overlap_chars);
674 chunks.push(std::mem::take(&mut current));
675 if !tail.is_empty() {
676 current.push_str(&tail);
677 current.push_str(sep);
678 }
679 current.push_str(&unit);
680 }
681 }
682 if !current.is_empty() {
683 chunks.push(current);
684 }
685 chunks
686}
687
688fn char_len(s: &str) -> usize {
689 s.chars().count()
690}
691
692fn paragraphs(text: &str) -> Vec<String> {
694 let mut out = Vec::new();
695 let mut lines: Vec<&str> = Vec::new();
696 for line in text.lines() {
697 if line.trim().is_empty() {
698 if !lines.is_empty() {
699 out.push(lines.join("\n").trim().to_string());
700 lines.clear();
701 }
702 } else {
703 lines.push(line);
704 }
705 }
706 if !lines.is_empty() {
707 out.push(lines.join("\n").trim().to_string());
708 }
709 out.retain(|p| !p.is_empty());
710 out
711}
712
713fn sentences(paragraph: &str, terminators: &[char]) -> Vec<String> {
715 let mut out = Vec::new();
716 let mut current = String::new();
717 let mut chars = paragraph.chars().peekable();
718 while let Some(c) = chars.next() {
719 current.push(c);
720 if terminators.contains(&c) && chars.peek().is_none_or(|n| n.is_whitespace()) {
721 let s = current.trim();
722 if !s.is_empty() {
723 out.push(s.to_string());
724 }
725 current.clear();
726 }
727 }
728 let s = current.trim();
729 if !s.is_empty() {
730 out.push(s.to_string());
731 }
732 out
733}
734
735fn split_long(text: &str, max: usize) -> Vec<String> {
737 let mut out = Vec::new();
738 let mut current = String::new();
739 for word in text.split_whitespace() {
740 let mut word = word.to_string();
741 while char_len(&word) > max {
743 if !current.is_empty() {
744 out.push(std::mem::take(&mut current));
745 }
746 let head: String = word.chars().take(max).collect();
747 word = word.chars().skip(max).collect();
748 out.push(head);
749 }
750 if word.is_empty() {
751 continue;
752 }
753 if current.is_empty() {
754 current = word;
755 } else if char_len(¤t) + 1 + char_len(&word) <= max {
756 current.push(' ');
757 current.push_str(&word);
758 } else {
759 out.push(std::mem::replace(&mut current, word));
760 }
761 }
762 if !current.is_empty() {
763 out.push(current);
764 }
765 out
766}
767
768fn overlap_tail(chunk: &str, overlap: usize) -> String {
770 if overlap == 0 {
771 return String::new();
772 }
773 let total = char_len(chunk);
774 if total <= overlap {
775 return chunk.trim().to_string();
776 }
777 let start = chunk
778 .char_indices()
779 .nth(total - overlap)
780 .map(|(i, _)| i)
781 .unwrap_or(0);
782 let tail = &chunk[start..];
783 let preceded_by_space = chunk[..start].ends_with(char::is_whitespace);
785 let tail = if preceded_by_space {
786 tail
787 } else {
788 match tail.find(char::is_whitespace) {
789 Some(i) => &tail[i..],
790 None => tail,
791 }
792 };
793 tail.trim().to_string()
794}
795
796const BACKUP_EXTENSION: &str = "mentebackup";
802
803fn backup_root(state: &AppState) -> PathBuf {
804 ops_config()
805 .backup_dir
806 .clone()
807 .unwrap_or_else(|| state.databases.root().join("backups"))
808}
809
810fn utc_stamp(at: SystemTime) -> String {
812 let since = at.duration_since(UNIX_EPOCH).unwrap_or_default();
813 let secs = since.as_secs();
814 let (days, rem) = ((secs / 86_400) as i64, secs % 86_400);
815 let (year, month, day) = civil_from_days(days);
816 format!(
817 "{year:04}{month:02}{day:02}T{:02}{:02}{:02}.{:03}Z",
818 rem / 3600,
819 (rem % 3600) / 60,
820 rem % 60,
821 since.subsec_millis()
822 )
823}
824
825fn civil_from_days(days: i64) -> (i64, u32, u32) {
828 let z = days + 719_468;
829 let era = z.div_euclid(146_097);
830 let doe = z.rem_euclid(146_097);
831 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
832 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
833 let mp = (5 * doy + 2) / 153;
834 let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
835 let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
836 let year = yoe + era * 400 + i64::from(month <= 2);
837 (year, month, day)
838}
839
840fn copy_tree(src: &FsPath, dst: &FsPath, exclude: &[PathBuf]) -> std::io::Result<()> {
842 std::fs::create_dir_all(dst)?;
843 for entry in std::fs::read_dir(src)? {
844 let entry = entry?;
845 let path = entry.path();
846 let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
847 if exclude.iter().any(|x| canonical.starts_with(x)) {
848 continue;
849 }
850 let target = dst.join(entry.file_name());
851 if entry.file_type()?.is_dir() {
852 copy_tree(&path, &target, exclude)?;
853 } else {
854 std::fs::copy(&path, &target)?;
855 }
856 }
857 Ok(())
858}
859
860async fn create_backup(
866 _guard: MaintenanceGuard,
867 State(state): State<Arc<AppState>>,
868 db: Db,
869 body: axum::body::Bytes,
870) -> Result<impl IntoResponse, ApiError> {
871 let requested = if body.iter().all(|b| b.is_ascii_whitespace()) {
872 None
873 } else {
874 let req: Value = serde_json::from_slice(&body)
875 .map_err(|e| ApiError::BadRequest(format!("invalid JSON: {e}")))?;
876 match req.get("name") {
877 None | Some(Value::Null) => None,
878 Some(Value::String(s)) => Some(backup_file_name(s)?),
879 Some(_) => return Err(ApiError::BadRequest("'name' must be a string".into())),
880 }
881 };
882 let db_dir = state.databases.path_of(&db.name)?;
883 let root = backup_root(&state);
884 let data_root = state.databases.root().to_path_buf();
885 let handle = db.handle.clone();
886 let name = db.name.clone();
887 let (file, manifest) = blocking("backup", move || {
888 handle.flush_full().map_err(internal("flush"))?;
889 let io = |what: &'static str| {
890 move |e: std::io::Error| ApiError::Internal(format!("{what}: {e}"))
891 };
892 let dir = root.join(&name);
893 std::fs::create_dir_all(&dir).map_err(io("create backup directory"))?;
894 let root_c = root
895 .canonicalize()
896 .map_err(io("resolve backup directory"))?;
897 let db_c = db_dir
898 .canonicalize()
899 .map_err(io("resolve database directory"))?;
900 if db_c.starts_with(&root_c) {
901 return Err(ApiError::Internal(
902 "the backup directory must not contain the database directory".into(),
903 ));
904 }
905 let file = match &requested {
906 Some(rel) => rel.clone(),
907 None => format!("{name}-{}.{BACKUP_EXTENSION}", utc_stamp(SystemTime::now())),
908 };
909 let path = dir.join(&file);
910 if let Some(parent) = path.parent() {
913 std::fs::create_dir_all(parent).map_err(io("create backup directory"))?;
914 let parent_c = parent
915 .canonicalize()
916 .map_err(io("resolve backup directory"))?;
917 let dir_c = dir.canonicalize().map_err(io("resolve backup directory"))?;
918 if !parent_c.starts_with(&dir_c) {
919 return Err(ApiError::BadRequest(
920 "backup name escapes the backup directory".into(),
921 ));
922 }
923 }
924 if path.exists() {
925 return Err(ApiError::Conflict(format!(
926 "backup {file} already exists; retry"
927 )));
928 }
929
930 let mut exclude = vec![root_c];
933 if name == DEFAULT_DB {
934 let dbs = data_root.join("dbs");
935 exclude.push(dbs.canonicalize().unwrap_or(dbs));
936 }
937 exclude.retain(|x| x.starts_with(&db_c));
938
939 let manifest = if exclude.is_empty() {
940 BackupManager::create_backup(&db_dir, &path).map_err(internal("backup"))?
941 } else {
942 let staging = dir.join(format!(".staging-{}", Uuid::new_v4()));
943 let staged = copy_tree(&db_dir, &staging, &exclude)
944 .map_err(io("stage backup"))
945 .and_then(|_| {
946 BackupManager::create_backup(&staging, &path).map_err(internal("backup"))
947 });
948 let _ = std::fs::remove_dir_all(&staging);
949 staged?
950 };
951 Ok((file, manifest))
952 })
953 .await?;
954 Ok((
955 StatusCode::CREATED,
956 Json(json!({
957 "database": db.name,
958 "file": file,
959 "manifest": manifest,
960 })),
961 ))
962}
963
964const MAX_BACKUP_NAME: usize = 200;
966
967pub fn backup_file_name(name: &str) -> Result<String, ApiError> {
973 let bad = |why: &str| ApiError::BadRequest(format!("invalid backup name '{name}': {why}"));
974 if name.is_empty() || name.len() > MAX_BACKUP_NAME {
975 return Err(bad("use 1 to 200 characters"));
976 }
977 if name.starts_with('/') {
978 return Err(bad("absolute paths are not allowed"));
979 }
980 for segment in name.split('/') {
981 if segment.is_empty() || segment == "." || segment == ".." {
982 return Err(bad("empty, '.' and '..' segments are not allowed"));
983 }
984 if !segment
985 .chars()
986 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
987 {
988 return Err(bad("use letters, digits, '-', '_', '.' and '/'"));
989 }
990 }
991 let suffix = format!(".{BACKUP_EXTENSION}");
992 Ok(if name.ends_with(&suffix) {
993 name.to_string()
994 } else {
995 format!("{name}{suffix}")
996 })
997}
998
999pub struct MaintenanceGuard;
1003
1004impl FromRequestParts<Arc<AppState>> for MaintenanceGuard {
1005 type Rejection = ApiError;
1006
1007 async fn from_request_parts(
1008 parts: &mut Parts,
1009 state: &Arc<AppState>,
1010 ) -> Result<Self, ApiError> {
1011 use axum::extract::RawPathParams;
1012 let params = RawPathParams::from_request_parts(parts, state)
1013 .await
1014 .map_err(|e| ApiError::BadRequest(e.to_string()))?;
1015 let name = params
1016 .iter()
1017 .find(|(k, _)| *k == "db")
1018 .map(|(_, v)| v.to_string())
1019 .unwrap_or_else(|| DEFAULT_DB.to_string());
1020 crate::hosting::authorize_maintenance(
1021 state,
1022 &parts.headers,
1023 parts.extensions.get::<AuthenticatedAgent>(),
1024 &name,
1025 )?;
1026 Ok(Self)
1027 }
1028}
1029
1030async fn list_backups(
1032 _admin: AdminGuard,
1033 State(state): State<Arc<AppState>>,
1034 db: Db,
1035) -> Result<Json<Value>, ApiError> {
1036 let dir = backup_root(&state).join(&db.name);
1037 let backups = blocking("list backups", move || {
1038 let mut found = Vec::new();
1039 let entries = match std::fs::read_dir(&dir) {
1040 Ok(e) => e,
1041 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(found),
1042 Err(e) => return Err(ApiError::Internal(format!("list backups: {e}"))),
1043 };
1044 for entry in entries.flatten() {
1045 let path = entry.path();
1046 if path.extension().and_then(|e| e.to_str()) != Some(BACKUP_EXTENSION) {
1047 continue;
1048 }
1049 if let Ok(manifest) = BackupManager::read_manifest(&path) {
1050 found.push((entry.file_name().to_string_lossy().into_owned(), manifest));
1051 }
1052 }
1053 found.sort_by_key(|(_, m)| std::cmp::Reverse(m.created_at));
1054 Ok(found)
1055 })
1056 .await?;
1057 let backups: Vec<Value> = backups
1058 .into_iter()
1059 .map(|(file, manifest)| json!({ "file": file, "manifest": manifest }))
1060 .collect();
1061 Ok(Json(json!({ "database": db.name, "backups": backups })))
1062}
1063
1064async fn list_databases(
1070 _admin: AdminGuard,
1071 State(state): State<Arc<AppState>>,
1072) -> Result<Json<Value>, ApiError> {
1073 let dbs = state.databases.clone();
1074 let (names, open) = blocking("list databases", move || {
1075 let open: Vec<String> = dbs.open_handles().into_iter().map(|(n, _)| n).collect();
1076 Ok((dbs.list(), open))
1077 })
1078 .await?;
1079 Ok(Json(json!({
1080 "default": DEFAULT_DB,
1081 "databases": names,
1082 "open": open,
1083 })))
1084}
1085
1086async fn create_database(
1088 _admin: AdminGuard,
1089 State(state): State<Arc<AppState>>,
1090 Json(req): Json<Value>,
1091) -> Result<impl IntoResponse, ApiError> {
1092 let name = req
1093 .get("name")
1094 .and_then(|v| v.as_str())
1095 .ok_or_else(|| ApiError::BadRequest("missing 'name'".into()))?
1096 .to_string();
1097 validate_name(&name)?;
1098 let dbs = state.databases.clone();
1099 let created = name.clone();
1100 blocking("create database", move || {
1101 if dbs.exists(&created) {
1102 return Err(ApiError::Conflict(format!(
1103 "database '{created}' already exists"
1104 )));
1105 }
1106 dbs.get(&created, true).map(|_| ())
1107 })
1108 .await?;
1109 Ok((
1110 StatusCode::CREATED,
1111 Json(json!({ "name": name, "status": "created" })),
1112 ))
1113}
1114
1115async fn delete_database(
1117 _admin: AdminGuard,
1118 State(state): State<Arc<AppState>>,
1119 Path(name): Path<String>,
1120) -> Result<Json<Value>, ApiError> {
1121 validate_name(&name)?;
1122 let dbs = state.databases.clone();
1123 let target = name.clone();
1124 blocking("delete database", move || dbs.delete(&target)).await?;
1125 Ok(Json(json!({ "name": name, "status": "deleted" })))
1126}
1127
1128struct EmbedProbe {
1135 embedder: Weak<dyn EmbeddingProvider>,
1136 checked_at: Instant,
1137 result: Result<(), String>,
1138}
1139
1140static EMBED_PROBES: Mutex<Vec<EmbedProbe>> = Mutex::new(Vec::new());
1141
1142async fn check_embedder(
1143 embedder: Arc<dyn EmbeddingProvider>,
1144 cfg: &ReadinessConfig,
1145) -> Result<(), String> {
1146 let weak = Arc::downgrade(&embedder);
1147 {
1148 let mut probes = EMBED_PROBES.lock().unwrap_or_else(|e| e.into_inner());
1149 probes.retain(|p| p.embedder.strong_count() > 0);
1150 if let Some(p) = probes.iter().find(|p| Weak::ptr_eq(&p.embedder, &weak))
1151 && p.checked_at.elapsed() < cfg.embed_check_interval
1152 {
1153 return p.result.clone();
1154 }
1155 }
1156 let text = cfg.probe_text.clone();
1157 let probe = tokio::task::spawn_blocking(move || embedder.embed(&text));
1158 let result = match tokio::time::timeout(cfg.check_timeout, probe).await {
1159 Ok(Ok(Ok(v))) if !v.is_empty() => Ok(()),
1160 Ok(Ok(Ok(_))) => Err("embedder returned an empty vector".to_string()),
1161 Ok(Ok(Err(e))) => Err(format!("embed failed: {e}")),
1162 Ok(Err(e)) => Err(format!("embed task failed: {e}")),
1163 Err(_) => Err(format!("embed timed out after {:?}", cfg.check_timeout)),
1164 };
1165 let mut probes = EMBED_PROBES.lock().unwrap_or_else(|e| e.into_inner());
1166 probes.retain(|p| !Weak::ptr_eq(&p.embedder, &weak));
1167 probes.push(EmbedProbe {
1168 embedder: weak,
1169 checked_at: Instant::now(),
1170 result: result.clone(),
1171 });
1172 result
1173}
1174
1175async fn ready(
1181 State(state): State<Arc<AppState>>,
1182 axum::extract::Query(q): axum::extract::Query<HashMap<String, String>>,
1183) -> Response {
1184 let cfg = &ops_config().readiness;
1185 let skip_embedder = q.get("embedder").is_some_and(|v| v == "skip");
1186 let mut checks = serde_json::Map::new();
1187 let mut failed: Vec<&str> = Vec::new();
1188
1189 let db = state.databases.default_db();
1190 let probe = tokio::task::spawn_blocking(move || db.memory_count());
1191 match tokio::time::timeout(cfg.check_timeout, probe).await {
1192 Ok(Ok(_)) => {
1193 checks.insert("database".into(), json!("ok"));
1194 }
1195 Ok(Err(e)) => {
1196 checks.insert("database".into(), json!(format!("error: {e}")));
1197 failed.push("database");
1198 }
1199 Err(_) => {
1200 checks.insert(
1201 "database".into(),
1202 json!(format!("error: timed out after {:?}", cfg.check_timeout)),
1203 );
1204 failed.push("database");
1205 }
1206 }
1207
1208 let root = state.databases.root().to_path_buf();
1210 let write_probe = tokio::task::spawn_blocking(move || {
1211 let probe = root.join(format!(".ready-{}", Uuid::new_v4()));
1212 std::fs::write(&probe, b"ok").and_then(|_| std::fs::remove_file(&probe))
1213 });
1214 match tokio::time::timeout(cfg.check_timeout, write_probe).await {
1215 Ok(Ok(Ok(()))) => {
1216 checks.insert("data_dir".into(), json!("ok"));
1217 }
1218 Ok(Ok(Err(e))) => {
1219 checks.insert(
1220 "data_dir".into(),
1221 json!(format!("error: not writable: {e}")),
1222 );
1223 failed.push("data_dir");
1224 }
1225 Ok(Err(e)) => {
1226 checks.insert("data_dir".into(), json!(format!("error: {e}")));
1227 failed.push("data_dir");
1228 }
1229 Err(_) => {
1230 checks.insert(
1231 "data_dir".into(),
1232 json!(format!("error: timed out after {:?}", cfg.check_timeout)),
1233 );
1234 failed.push("data_dir");
1235 }
1236 }
1237
1238 if let Some(probe_db) = crate::hosting::hosting_config().ready_probe_db.clone() {
1240 let dbs = state.databases.clone();
1241 let open = tokio::task::spawn_blocking(move || dbs.get(&probe_db, true).map(|_| ()));
1242 match tokio::time::timeout(cfg.check_timeout, open).await {
1243 Ok(Ok(Ok(()))) => {
1244 checks.insert("database_lock".into(), json!("ok"));
1245 }
1246 Ok(Ok(Err(e))) => {
1247 checks.insert("database_lock".into(), json!(format!("error: {e}")));
1248 failed.push("database_lock");
1249 }
1250 Ok(Err(e)) => {
1251 checks.insert("database_lock".into(), json!(format!("error: {e}")));
1252 failed.push("database_lock");
1253 }
1254 Err(_) => {
1255 checks.insert(
1256 "database_lock".into(),
1257 json!(format!("error: timed out after {:?}", cfg.check_timeout)),
1258 );
1259 failed.push("database_lock");
1260 }
1261 }
1262 }
1263
1264 match state.databases.embedder() {
1265 None => {
1266 checks.insert("embedder".into(), json!("not_configured"));
1267 }
1268 Some(_) if skip_embedder => {
1269 checks.insert("embedder".into(), json!("skipped"));
1270 }
1271 Some(embedder) => match check_embedder(embedder, cfg).await {
1272 Ok(()) => {
1273 checks.insert("embedder".into(), json!("ok"));
1274 }
1275 Err(e) => {
1276 checks.insert("embedder".into(), json!(format!("error: {e}")));
1277 failed.push("embedder");
1278 }
1279 },
1280 }
1281
1282 if failed.is_empty() {
1283 (
1284 StatusCode::OK,
1285 Json(json!({ "status": "ready", "checks": checks })),
1286 )
1287 .into_response()
1288 } else {
1289 (
1290 StatusCode::SERVICE_UNAVAILABLE,
1291 Json(json!({ "status": "not_ready", "failed": failed, "checks": checks })),
1292 )
1293 .into_response()
1294 }
1295}
1296
1297#[cfg(test)]
1298mod tests {
1299 use super::*;
1300
1301 const TERMS: &[char] = &['.', '!', '?'];
1302
1303 #[test]
1304 fn short_text_is_one_chunk() {
1305 let chunks = chunk_text("One paragraph. Two sentences.", 100, 20, TERMS);
1306 assert_eq!(chunks, vec!["One paragraph. Two sentences.".to_string()]);
1307 }
1308
1309 #[test]
1310 fn chunks_respect_the_limit_and_overlap() {
1311 let sentence = "The quick brown fox jumps over the lazy dog again.";
1312 let paragraph = [sentence; 6].join(" ");
1313 let text = [paragraph.as_str(); 4].join("\n\n");
1314 let chunks = chunk_text(&text, 200, 40, TERMS);
1315 assert!(chunks.len() > 4);
1316 for c in &chunks {
1317 assert!(char_len(c) <= 200, "{} chars: {c}", char_len(c));
1318 }
1319 for pair in chunks.windows(2) {
1320 let tail_word = pair[0].split_whitespace().last().unwrap();
1321 assert!(pair[1].contains(tail_word));
1322 }
1323 }
1324
1325 #[test]
1326 fn a_word_longer_than_a_chunk_is_cut() {
1327 let text = "x".repeat(450);
1328 let chunks = chunk_text(&text, 200, 0, TERMS);
1329 assert!(chunks.iter().all(|c| char_len(c) <= 200));
1330 assert_eq!(chunks.concat(), text);
1331 }
1332
1333 #[test]
1334 fn utc_stamp_formats_known_instants() {
1335 assert_eq!(utc_stamp(UNIX_EPOCH), "19700101T000000.000Z");
1336 let t = UNIX_EPOCH + Duration::from_millis(1_709_210_096_789);
1337 assert_eq!(utc_stamp(t), "20240229T123456.789Z");
1338 }
1339}