1use std::collections::HashMap;
28use std::future::Future;
29use std::sync::{Arc, OnceLock};
30use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
31
32use axum::body::Body;
33use axum::extract::{Path, Query, State};
34use axum::http::{HeaderMap, Request, StatusCode};
35use axum::middleware::Next;
36use axum::response::{IntoResponse, Response};
37use axum::routing::{get, post};
38use axum::{Extension, Json, Router};
39use mentedb::injection::InjectionQuery;
40use mentedb::{HybridOptions, MemoryPatch};
41use mentedb_core::MemoryNode;
42use mentedb_core::error::MenteError;
43use mentedb_core::types::{AgentId, MemoryId, UserId};
44use serde_json::{Value, json};
45use uuid::Uuid;
46
47use crate::auth::AuthenticatedAgent;
48use crate::databases::{DEFAULT_DB, Db, validate_name};
49use crate::error::ApiError;
50use crate::handlers::{
51 check_body_agent, check_owner, embed_query, memory_id_param, memory_node_to_json,
52 memory_node_to_json_with, parse_attributes, parse_embedding, parse_memory_type,
53 parse_optional_uuid, wants_embedding,
54};
55use crate::state::AppState;
56
57type Caller = Option<Extension<AuthenticatedAgent>>;
58
59#[derive(Clone, Debug)]
65pub struct HostingConfig {
66 pub list_default_limit: usize,
68 pub list_max_limit: usize,
70 pub batch_max_databases: usize,
72 pub batch_default_k: usize,
74 pub batch_max_k: usize,
76 pub action_default_k: usize,
78 pub action_max_k: usize,
80 pub request_timeout: Option<Duration>,
83 pub enrich_workers: usize,
86 pub ready_probe_db: Option<String>,
89}
90
91impl Default for HostingConfig {
92 fn default() -> Self {
93 Self {
94 list_default_limit: 50,
95 list_max_limit: 1000,
96 batch_max_databases: 32,
97 batch_default_k: 10,
98 batch_max_k: 200,
99 action_default_k: 10,
100 action_max_k: 100,
101 request_timeout: None,
102 enrich_workers: 1,
103 ready_probe_db: Some("readiness-probe".to_string()),
104 }
105 }
106}
107
108fn env_usize(name: &str) -> Option<usize> {
109 std::env::var(name).ok().and_then(|v| v.trim().parse().ok())
110}
111
112impl HostingConfig {
113 pub fn from_env() -> Self {
115 let mut c = Self::default();
116 if let Some(n) = env_usize("MENTEDB_LIST_MAX_LIMIT").filter(|n| *n > 0) {
117 c.list_max_limit = n;
118 }
119 if let Some(n) = env_usize("MENTEDB_BATCH_MAX_DATABASES").filter(|n| *n > 0) {
120 c.batch_max_databases = n;
121 }
122 if let Some(n) = env_usize("MENTEDB_REQUEST_TIMEOUT_MS") {
123 c.request_timeout = (n > 0).then(|| Duration::from_millis(n as u64));
124 }
125 if let Some(n) = env_usize("MENTEDB_ENRICH_WORKERS").filter(|n| *n > 0) {
126 c.enrich_workers = n;
127 }
128 if let Ok(v) = std::env::var("MENTEDB_READY_PROBE_DB") {
129 let v = v.trim().to_string();
130 c.ready_probe_db = (!v.is_empty()).then_some(v);
131 }
132 c
133 }
134}
135
136pub fn hosting_config() -> &'static HostingConfig {
138 static CONFIG: OnceLock<HostingConfig> = OnceLock::new();
139 CONFIG.get_or_init(HostingConfig::from_env)
140}
141
142pub fn routes() -> Router<Arc<AppState>> {
149 Router::new()
150 .route("/memories/{id}/supersede", post(supersede_memory))
151 .route("/recall_for_action", post(recall_for_action))
152 .route("/consolidation_candidates", get(consolidation_candidates))
153 .route("/consolidate", post(consolidate))
154}
155
156pub fn global_routes() -> Router<Arc<AppState>> {
158 Router::new()
159 .route("/v1/db/{db}", axum::routing::delete(drop_database))
160 .route("/v1/batch/search", post(batch_search))
161 .route("/v1/live", get(live))
162}
163
164pub const DEADLINE_HEADER: &str = "x-mentedb-deadline-ms";
170
171#[derive(Clone, Copy, Debug)]
174pub struct Deadline(pub Instant);
175
176impl Deadline {
177 pub fn check(&self) -> Result<(), ApiError> {
179 if Instant::now() >= self.0 {
180 Err(ApiError::GatewayTimeout(
181 "the request deadline passed before the work finished".into(),
182 ))
183 } else {
184 Ok(())
185 }
186 }
187}
188
189fn check_deadline(deadline: &Option<Extension<Deadline>>) -> Result<(), ApiError> {
190 match deadline {
191 Some(Extension(d)) => d.check(),
192 None => Ok(()),
193 }
194}
195
196pub async fn deadline_middleware(
201 State(server_timeout): State<Option<Duration>>,
202 mut request: Request<Body>,
203 next: Next,
204) -> Response {
205 let requested = match request.headers().get(DEADLINE_HEADER) {
206 None => None,
207 Some(v) => match v.to_str().ok().and_then(|s| s.trim().parse::<u64>().ok()) {
208 Some(ms) => Some(Duration::from_millis(ms)),
209 None => {
210 return ApiError::BadRequest(format!(
211 "{DEADLINE_HEADER} must be a whole number of milliseconds"
212 ))
213 .into_response();
214 }
215 },
216 };
217 let budget = match (requested, server_timeout) {
218 (Some(a), Some(b)) => Some(a.min(b)),
219 (a, b) => a.or(b),
220 };
221 let Some(budget) = budget else {
222 return next.run(request).await;
223 };
224 request
225 .extensions_mut()
226 .insert(Deadline(Instant::now() + budget));
227 match tokio::time::timeout(budget, next.run(request)).await {
228 Ok(response) => response,
229 Err(_) => ApiError::GatewayTimeout(format!("no answer within {} ms", budget.as_millis()))
230 .into_response(),
231 }
232}
233
234fn now_micros() -> u64 {
239 SystemTime::now()
240 .duration_since(UNIX_EPOCH)
241 .unwrap_or_default()
242 .as_micros() as u64
243}
244
245async fn blocking<T, F>(what: &'static str, f: F) -> Result<T, ApiError>
246where
247 T: Send + 'static,
248 F: FnOnce() -> Result<T, ApiError> + Send + 'static,
249{
250 tokio::task::spawn_blocking(f)
251 .await
252 .map_err(|e| ApiError::Internal(format!("{what} task failed: {e}")))?
253}
254
255fn engine(what: &'static str) -> impl FnOnce(MenteError) -> ApiError {
256 move |e| match e {
257 MenteError::MemoryNotFound(id) => ApiError::NotFound(format!("memory {id} not found")),
258 other => ApiError::Internal(format!("{what} failed: {other}")),
259 }
260}
261
262fn token_agent(agent: &Caller) -> Result<Option<AgentId>, ApiError> {
264 match agent {
265 Some(Extension(a)) if !a.admin => a.agent(),
266 _ => Ok(None),
267 }
268}
269
270fn scope_agent(agent: &Caller, requested: Option<Uuid>) -> Result<Option<AgentId>, ApiError> {
273 match requested {
274 Some(id) => {
275 check_body_agent(agent, AgentId(id))?;
276 Ok(Some(AgentId(id)))
277 }
278 None => token_agent(agent),
279 }
280}
281
282fn visible(owner: Option<AgentId>, node: &MemoryNode) -> bool {
283 owner.is_none_or(|a| node.agent_id == a || node.agent_id.is_nil())
284}
285
286fn bounded(value: Option<usize>, default: usize, max: usize) -> usize {
287 value.unwrap_or(default).clamp(1, max.max(1))
288}
289
290fn body_usize(req: &Value, field: &str) -> Result<Option<usize>, ApiError> {
291 req.get(field)
292 .filter(|v| !v.is_null())
293 .map(|v| {
294 v.as_u64()
295 .map(|n| n as usize)
296 .ok_or_else(|| ApiError::BadRequest(format!("'{field}' must be a whole number")))
297 })
298 .transpose()
299}
300
301fn query_usize(q: &HashMap<String, String>, field: &str) -> Result<Option<usize>, ApiError> {
302 q.get(field)
303 .map(|s| {
304 s.trim()
305 .parse()
306 .map_err(|_| ApiError::BadRequest(format!("'{field}' must be a whole number")))
307 })
308 .transpose()
309}
310
311fn query_bool(q: &HashMap<String, String>, field: &str) -> Result<bool, ApiError> {
312 match q.get(field).map(|s| s.trim().to_lowercase()) {
313 None => Ok(false),
314 Some(v) if v == "1" || v == "true" => Ok(true),
315 Some(v) if v == "0" || v == "false" || v.is_empty() => Ok(false),
316 Some(_) => Err(ApiError::BadRequest(format!("'{field}' must be 0 or 1"))),
317 }
318}
319
320fn string_array(req: &Value, field: &str) -> Result<Vec<String>, ApiError> {
321 match req.get(field) {
322 None | Some(Value::Null) => Ok(Vec::new()),
323 Some(Value::Array(items)) => items
324 .iter()
325 .map(|v| {
326 v.as_str()
327 .map(String::from)
328 .ok_or_else(|| ApiError::BadRequest(format!("'{field}' must hold strings")))
329 })
330 .collect(),
331 Some(_) => Err(ApiError::BadRequest(format!("'{field}' must be an array"))),
332 }
333}
334
335fn load_owned(agent: &Caller, db: &Db, id: MemoryId) -> Result<MemoryNode, ApiError> {
336 let node = db
337 .get_memory(id)
338 .map_err(|_| ApiError::NotFound(format!("memory {id} not found")))?;
339 check_owner(agent, &node)?;
340 Ok(node)
341}
342
343pub async fn list_memories(
351 agent: Caller,
352 deadline: Option<Extension<Deadline>>,
353 db: Db,
354 Query(q): Query<HashMap<String, String>>,
355) -> Result<Json<Value>, ApiError> {
356 let cfg = hosting_config();
357 let limit = bounded(
358 query_usize(&q, "limit")?,
359 cfg.list_default_limit,
360 cfg.list_max_limit,
361 );
362 let offset = query_usize(&q, "offset")?.unwrap_or(0);
363 let include_inactive = query_bool(&q, "include_inactive")?;
364 let tag = q.get("tag").filter(|t| !t.is_empty()).cloned();
365 let owner = token_agent(&agent)?;
366 let embedding = wants_embedding(&q);
367 check_deadline(&deadline)?;
368 let handle = db.handle.clone();
369 let page = blocking("list memories", move || {
370 Ok(handle.list_memories_page(
371 tag.as_deref(),
372 owner,
373 include_inactive,
374 now_micros(),
375 offset,
376 limit,
377 ))
378 })
379 .await?;
380 check_deadline(&deadline)?;
381 let memories: Vec<Value> = page
382 .memories
383 .iter()
384 .map(|n| memory_node_to_json_with(n, embedding))
385 .collect();
386 let next_offset = page.has_more.then_some(offset + memories.len());
387 Ok(Json(json!({
388 "database": db.name,
389 "limit": limit,
390 "offset": offset,
391 "next_offset": next_offset,
392 "memories": memories,
393 })))
394}
395
396pub async fn patch_memory(
403 agent: Caller,
404 db: Db,
405 Path(params): Path<HashMap<String, String>>,
406 Json(req): Json<Value>,
407) -> Result<Json<Value>, ApiError> {
408 let id = memory_id_param(¶ms)?;
409 load_owned(&agent, &db, id)?;
410 let mut patch = MemoryPatch::default();
411 match req.get("attributes") {
412 None | Some(Value::Null) => {}
413 Some(v @ Value::Object(_)) => patch.attributes = parse_attributes(Some(v)),
414 Some(_) => {
415 return Err(ApiError::BadRequest(
416 "'attributes' must be an object".into(),
417 ));
418 }
419 }
420 if req.get("tags").is_some_and(|v| !v.is_null()) {
421 patch.tags = Some(string_array(&req, "tags")?);
422 }
423 if let Some(v) = req.get("valid_until") {
424 patch.valid_until = Some(match v {
425 Value::Null => None,
426 v => Some(v.as_u64().ok_or_else(|| {
427 ApiError::BadRequest("'valid_until' must be microseconds or null".into())
428 })?),
429 });
430 }
431 if patch.attributes.is_empty() && patch.tags.is_none() && patch.valid_until.is_none() {
432 return Err(ApiError::BadRequest(
433 "nothing to update: send attributes, tags or valid_until".into(),
434 ));
435 }
436 let handle = db.handle.clone();
437 let node = blocking("update memory", move || {
438 handle
439 .update_memory_fields(id, &patch)
440 .map_err(engine("update memory"))
441 })
442 .await?;
443 Ok(Json(memory_node_to_json(&node)))
444}
445
446pub async fn supersede_memory(
455 agent: Caller,
456 db: Db,
457 Path(params): Path<HashMap<String, String>>,
458 Json(req): Json<Value>,
459) -> Result<impl IntoResponse, ApiError> {
460 let old_id = memory_id_param(¶ms)?;
461 let old = load_owned(&agent, &db, old_id)?;
462 let content = req
463 .get("content")
464 .and_then(|v| v.as_str())
465 .filter(|s| !s.is_empty())
466 .ok_or_else(|| ApiError::BadRequest("missing 'content'".into()))?
467 .to_string();
468 let memory_type = if req.get("memory_type").is_some() {
469 parse_memory_type(&req)?
470 } else {
471 old.memory_type
472 };
473 let mut node = MemoryNode::new(
474 old.agent_id,
475 memory_type,
476 content,
477 parse_embedding(&req, false)?,
478 )
479 .with_user_id(old.user_id);
480 node.space_id = old.space_id;
481 node.attributes = parse_attributes(req.get("attributes"));
482 node.tags = string_array(&req, "tags")?;
483 let new_id = node.id;
484 let handle = db.handle.clone();
485 let at = now_micros();
486 let stored = blocking("supersede memory", move || {
487 handle
488 .supersede_memory(old_id, node, at)
489 .map_err(|e| match e {
490 MenteError::MemoryNotFound(id) => {
491 ApiError::NotFound(format!("memory {id} not found"))
492 }
493 MenteError::Storage(msg) => ApiError::Conflict(msg),
494 other => ApiError::Internal(format!("supersede failed: {other}")),
495 })
496 })
497 .await?;
498 debug_assert_eq!(stored, new_id);
499 Ok((
500 StatusCode::CREATED,
501 Json(json!({
502 "id": stored.to_string(),
503 "supersedes": old_id.to_string(),
504 "superseded_at": at,
505 })),
506 ))
507}
508
509pub async fn recall_for_action(
517 agent: Caller,
518 db: Db,
519 Json(req): Json<Value>,
520) -> Result<Json<Value>, ApiError> {
521 let cfg = hosting_config();
522 let action = req
523 .get("action")
524 .and_then(|v| v.as_str())
525 .filter(|s| !s.is_empty())
526 .map(String::from);
527 let embedding = match (req.get("embedding"), &action) {
528 (Some(_), _) => parse_embedding(&req, true)?,
529 (None, Some(text)) => embed_query(&db, text).await?,
530 (None, None) => {
531 return Err(ApiError::BadRequest(
532 "send 'action' text or an 'embedding' vector".into(),
533 ));
534 }
535 };
536 let k = bounded(
537 body_usize(&req, "k")?,
538 cfg.action_default_k,
539 cfg.action_max_k,
540 );
541 let agent_scope = scope_agent(&agent, parse_optional_uuid(&req, "agent_id")?)?;
542 let user_scope = parse_optional_uuid(&req, "user_id")?.map(UserId);
543 let handle = db.handle.clone();
544 let nodes = blocking("recall for action", move || {
545 handle
546 .recall_for_action(&embedding, action.as_deref(), agent_scope, user_scope, k)
547 .map_err(engine("recall for action"))
548 })
549 .await?;
550 let memories: Vec<Value> = nodes.iter().map(memory_node_to_json).collect();
551 Ok(Json(json!({ "database": db.name, "memories": memories })))
552}
553
554pub async fn consolidation_candidates(
563 agent: Caller,
564 deadline: Option<Extension<Deadline>>,
565 db: Db,
566 Query(q): Query<HashMap<String, String>>,
567) -> Result<Json<Value>, ApiError> {
568 let ops = crate::handlers_ops::ops_config();
569 let min_cluster = query_usize(&q, "min_cluster")?
570 .unwrap_or(ops.consolidation_min_cluster_size)
571 .max(2);
572 let similarity = match q.get("similarity") {
573 None => ops.consolidation_similarity,
574 Some(s) => s
575 .trim()
576 .parse::<f32>()
577 .ok()
578 .filter(|v| (0.0..=1.0).contains(v))
579 .ok_or_else(|| ApiError::BadRequest("'similarity' must be between 0 and 1".into()))?,
580 };
581 let owner = token_agent(&agent)?;
582 check_deadline(&deadline)?;
583 let handle = db.handle.clone();
584 let clusters = blocking("consolidation candidates", move || {
585 let found = handle
586 .find_consolidation_candidates(min_cluster, similarity)
587 .map_err(engine("consolidation candidates"))?;
588 Ok(found
589 .into_iter()
590 .filter_map(|c| {
591 let nodes: Vec<MemoryNode> = c
592 .memories
593 .iter()
594 .filter_map(|id| handle.get_memory(*id).ok())
595 .collect();
596 (nodes.len() == c.memories.len() && nodes.iter().all(|n| visible(owner, n))).then(
597 || {
598 json!({
599 "topic": c.topic,
600 "avg_similarity": c.avg_similarity,
601 "memories": nodes.iter().map(memory_node_to_json).collect::<Vec<_>>(),
602 })
603 },
604 )
605 })
606 .collect::<Vec<Value>>())
607 })
608 .await?;
609 check_deadline(&deadline)?;
610 Ok(Json(json!({
611 "database": db.name,
612 "min_cluster": min_cluster,
613 "similarity": similarity,
614 "clusters": clusters,
615 })))
616}
617
618pub async fn consolidate(
621 agent: Caller,
622 db: Db,
623 Json(req): Json<Value>,
624) -> Result<impl IntoResponse, ApiError> {
625 let ids: Vec<MemoryId> = string_array(&req, "ids")?
626 .iter()
627 .map(|s| {
628 s.parse()
629 .map_err(|_| ApiError::BadRequest(format!("invalid id in 'ids': {s}")))
630 })
631 .collect::<Result<_, _>>()?;
632 let mut unique = ids.clone();
633 unique.sort();
634 unique.dedup();
635 if unique.len() < 2 {
636 return Err(ApiError::BadRequest(
637 "'ids' must name at least two different memories".into(),
638 ));
639 }
640 for id in &unique {
641 load_owned(&agent, &db, *id)?;
642 }
643 let handle = db.handle.clone();
644 let merged = blocking("consolidate", move || {
645 handle
646 .consolidate_cluster(&unique)
647 .map_err(engine("consolidate"))
648 })
649 .await?;
650 Ok((
651 StatusCode::CREATED,
652 Json(json!({
653 "database": db.name,
654 "id": merged.to_string(),
655 "consolidated": ids.iter().map(|i| i.to_string()).collect::<Vec<_>>(),
656 })),
657 ))
658}
659
660pub fn authorize_maintenance(
669 state: &AppState,
670 headers: &HeaderMap,
671 agent: Option<&AuthenticatedAgent>,
672 name: &str,
673) -> Result<(), ApiError> {
674 let key_check = crate::auth::admin_authorized(headers, &state.admin_key);
675 if key_check.is_ok() {
676 return Ok(());
677 }
678 match agent {
679 Some(a) => a.authorize_maintenance(name),
680 None => key_check,
681 }
682}
683
684pub async fn drop_database(
686 State(state): State<Arc<AppState>>,
687 agent: Caller,
688 headers: HeaderMap,
689 Path(name): Path<String>,
690) -> Result<Json<Value>, ApiError> {
691 if name == DEFAULT_DB {
692 return Err(ApiError::BadRequest(
693 "the default database cannot be deleted".into(),
694 ));
695 }
696 validate_name(&name)?;
697 authorize_maintenance(&state, &headers, agent.as_ref().map(|e| &e.0), &name)?;
698 let dbs = state.databases.clone();
699 let target = name.clone();
700 blocking("drop database", move || dbs.delete(&target)).await?;
701 Ok(Json(json!({ "name": name, "status": "deleted" })))
702}
703
704pub async fn batch_search(
715 State(state): State<Arc<AppState>>,
716 agent: Caller,
717 deadline: Option<Extension<Deadline>>,
718 Json(req): Json<Value>,
719) -> Result<Json<Value>, ApiError> {
720 let cfg = hosting_config();
721 let names = string_array(&req, "databases")?;
722 if names.is_empty() {
723 return Err(ApiError::BadRequest(
724 "'databases' must name at least one".into(),
725 ));
726 }
727 if names.len() > cfg.batch_max_databases {
728 return Err(ApiError::BadRequest(format!(
729 "at most {} databases per batch",
730 cfg.batch_max_databases
731 )));
732 }
733 for name in &names {
734 if name != DEFAULT_DB {
735 validate_name(name)?;
736 }
737 if let Some(Extension(a)) = &agent {
738 a.authorize_db(name, false)?;
739 }
740 }
741 let k = bounded(body_usize(&req, "k")?, cfg.batch_default_k, cfg.batch_max_k);
742 let with_context = req
743 .get("context")
744 .and_then(|v| v.as_bool())
745 .unwrap_or(false);
746 let max_items = bounded(body_usize(&req, "max_items")?, 10, 100);
747 let query_text = req
748 .get("query")
749 .and_then(|v| v.as_str())
750 .filter(|s| !s.is_empty())
751 .map(String::from);
752 let agent_scope = scope_agent(&agent, parse_optional_uuid(&req, "agent_id")?)?;
753 let user_scope = parse_optional_uuid(&req, "user_id")?.map(UserId);
754
755 let embedding = match (req.get("embedding"), &query_text) {
757 (Some(_), _) => parse_embedding(&req, true)?,
758 (None, Some(text)) => {
759 let embedder = state.databases.embedder().ok_or_else(|| {
760 ApiError::BadRequest(
761 "this server has no embedder; send an 'embedding' vector instead".into(),
762 )
763 })?;
764 let text = text.clone();
765 blocking("embed query", move || {
766 embedder
767 .embed(&text)
768 .map_err(|e| ApiError::ServiceUnavailable(format!("embedding failed: {e}")))
769 })
770 .await?
771 }
772 (None, None) => {
773 return Err(ApiError::BadRequest(
774 "send 'query' text or an 'embedding' vector".into(),
775 ));
776 }
777 };
778 let embedding = Arc::new(embedding);
779
780 let mut results = Vec::new();
781 let mut errors = serde_json::Map::new();
782 for name in names {
783 check_deadline(&deadline)?;
784 let dbs = state.databases.clone();
785 let emb = embedding.clone();
786 let text = query_text.clone();
787 let lookup = name.clone();
788 let outcome = blocking("batch search", move || {
789 if !dbs.exists(&lookup) {
790 return Ok(Err(format!("database '{lookup}' not found")));
791 }
792 let handle = dbs.get(&lookup, false)?;
793 let hits = handle
794 .recall_hybrid_scoped_with(
795 &emb,
796 text.as_deref(),
797 k,
798 now_micros(),
799 None,
800 false,
801 None,
802 agent_scope,
803 user_scope,
804 None,
805 &HybridOptions {
806 record_access: true,
807 ..HybridOptions::default()
808 },
809 )
810 .map_err(engine("hybrid search"))?;
811 let found: Vec<Value> = hits
812 .into_iter()
813 .filter_map(|(id, score)| {
814 let node = handle.get_memory(id).ok()?;
815 let mut j = memory_node_to_json(&node);
816 if let Value::Object(m) = &mut j {
817 m.insert("score".into(), json!(score));
818 }
819 Some(j)
820 })
821 .collect();
822 let context = if with_context {
823 let selected = handle
824 .recall_for_injection(&InjectionQuery {
825 embedding: &emb,
826 query_text: text.as_deref(),
827 session_id: None,
828 exclude_ids: &[],
829 max_items,
830 max_episodic: max_items.min(3),
831 agent_id: agent_scope,
832 user_id: user_scope,
833 current_project: None,
834 })
835 .map_err(engine("context recall"))?;
836 Some(
837 selected
838 .iter()
839 .map(|c| {
840 let mut j = memory_node_to_json(&c.node);
841 if let Value::Object(m) = &mut j {
842 m.insert("score".into(), json!(c.score));
843 }
844 j
845 })
846 .collect::<Vec<Value>>(),
847 )
848 } else {
849 None
850 };
851 Ok(Ok((found, context)))
852 })
853 .await?;
854 match outcome {
855 Ok((found, context)) => {
856 let mut entry = json!({ "database": name, "results": found });
857 if let (Some(ctx), Value::Object(m)) = (context, &mut entry) {
858 m.insert("context".into(), json!(ctx));
859 }
860 results.push(entry);
861 }
862 Err(msg) => {
863 errors.insert(name, json!(msg));
864 }
865 }
866 }
867 Ok(Json(json!({ "results": results, "errors": errors })))
868}
869
870pub async fn live() -> Json<Value> {
878 Json(json!({ "status": "alive" }))
879}
880
881struct EnrichmentPool {
888 runtime: tokio::runtime::Runtime,
889 permits: Arc<tokio::sync::Semaphore>,
890}
891
892static ENRICHMENT_POOL: OnceLock<Result<EnrichmentPool, String>> = OnceLock::new();
893
894fn enrichment_pool() -> Result<&'static EnrichmentPool, ApiError> {
895 ENRICHMENT_POOL
896 .get_or_init(|| {
897 let workers = hosting_config().enrich_workers.max(1);
898 tokio::runtime::Builder::new_multi_thread()
899 .worker_threads(workers)
900 .thread_name("mentedb-enrich")
901 .enable_all()
902 .build()
903 .map(|runtime| EnrichmentPool {
904 runtime,
905 permits: Arc::new(tokio::sync::Semaphore::new(workers)),
906 })
907 .map_err(|e| e.to_string())
908 })
909 .as_ref()
910 .map_err(|e| ApiError::Internal(format!("enrichment pool unavailable: {e}")))
911}
912
913pub async fn run_on_enrichment_pool<F, T>(job: F) -> Result<T, ApiError>
916where
917 F: Future<Output = T> + Send + 'static,
918 T: Send + 'static,
919{
920 let pool = enrichment_pool()?;
921 let permits = pool.permits.clone();
922 pool.runtime
923 .spawn(async move {
924 let _slot = permits.acquire_owned().await.ok();
927 job.await
928 })
929 .await
930 .map_err(|e| ApiError::Internal(format!("enrichment task failed: {e}")))
931}
932
933#[cfg(test)]
934mod tests {
935 use super::*;
936 use std::sync::atomic::{AtomicUsize, Ordering};
937
938 #[test]
939 fn query_bool_accepts_zero_and_one() {
940 let q = |v: &str| HashMap::from([("x".to_string(), v.to_string())]);
941 assert!(query_bool(&q("1"), "x").unwrap());
942 assert!(!query_bool(&q("0"), "x").unwrap());
943 assert!(!query_bool(&HashMap::new(), "x").unwrap());
944 assert!(query_bool(&q("yes please"), "x").is_err());
945 }
946
947 #[tokio::test]
948 async fn enrichment_pool_bounds_concurrency_and_runs_off_the_caller() {
949 let running = Arc::new(AtomicUsize::new(0));
950 let peak = Arc::new(AtomicUsize::new(0));
951 let mut jobs = Vec::new();
952 for _ in 0..4 {
953 let (running, peak) = (running.clone(), peak.clone());
954 jobs.push(tokio::spawn(run_on_enrichment_pool(async move {
955 let now = running.fetch_add(1, Ordering::SeqCst) + 1;
956 peak.fetch_max(now, Ordering::SeqCst);
957 tokio::time::sleep(Duration::from_millis(30)).await;
958 running.fetch_sub(1, Ordering::SeqCst);
959 std::thread::current().name().map(String::from)
960 })));
961 }
962 for j in jobs {
963 let name = j.await.unwrap().unwrap();
964 assert_eq!(name.as_deref(), Some("mentedb-enrich"));
965 }
966 assert_eq!(
967 peak.load(Ordering::SeqCst),
968 hosting_config().enrich_workers.max(1)
969 );
970 }
971}