lunaris_retrieve/service.rs
1//! `tower::Service<Query, Response = Vec<Hit>>` adapter.
2//!
3//! The retriever IS a tower service per blueprint §8 — composing with
4//! `ServiceBuilder::new().rate_limit(..).timeout(..).retry(..).service(retriever)`
5//! gives the entire ecosystem of tower middleware for free.
6//!
7//! ## Construction
8//!
9//! ```no_run
10//! use std::sync::Arc;
11//! use std::time::Duration;
12//!
13//! use lunaris_core::{Embedder, KeywordPort, Scope, StoragePort};
14//! use lunaris_retrieve::{Query, RetrievalService, Retriever};
15//! use tower::{Service, ServiceBuilder, ServiceExt};
16//!
17//! # async fn demo(
18//! # root: Arc<dyn Retriever>,
19//! # embedder: Arc<dyn Embedder>,
20//! # storage: Arc<dyn StoragePort>,
21//! # keyword: Arc<dyn KeywordPort>,
22//! # scope: Scope,
23//! # ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
24//! let svc = RetrievalService::new(root, embedder, storage, keyword, scope);
25//! let mut wrapped = ServiceBuilder::new()
26//! .rate_limit(10, Duration::from_secs(1))
27//! .timeout(Duration::from_secs(5))
28//! .service(svc);
29//! let hits = wrapped.ready().await?.call(Query::text("foo")).await?;
30//! # Ok(()) }
31//! ```
32
33use std::future::Future;
34use std::pin::Pin;
35use std::sync::Arc;
36use std::task::{Context, Poll};
37
38use lunaris_core::{Embedder, KeywordPort, LunarisError, Scope, StoragePort};
39use tower::Service;
40
41use crate::hydrate::hydrate;
42use crate::operators::{QueryContext, Retriever};
43use crate::types::{Hit, Query};
44
45/// Boxed future for the tower::Service impl. We use a `BoxFuture` rather than
46/// a named future type because Service futures must be `'static + Send` and
47/// the inner retriever's future is opaque.
48type BoxFuture = Pin<Box<dyn Future<Output = Result<Vec<Hit>, LunarisError>> + Send + 'static>>;
49
50/// `tower::Service` adapter wrapping a built operator tree.
51///
52/// Cloneable — every clone shares the same `Arc<dyn Retriever>` so wrapping
53/// in a `Buffer` / `Limit` layer is cheap.
54///
55/// RFC 0001 Wave 2: every constructed service carries a [`Scope`] that
56/// scopes the per-call retrieval. `RetrievalService::new` now requires
57/// the scope explicitly; the v0.2 `Scope::dev()` default is gone.
58#[derive(Clone)]
59pub struct RetrievalService {
60 pub(crate) root: Arc<dyn Retriever>,
61 pub(crate) embedder: Arc<dyn Embedder>,
62 pub(crate) storage: Arc<dyn StoragePort>,
63 pub(crate) keyword: Arc<dyn KeywordPort>,
64 pub(crate) scope: Scope,
65}
66
67impl RetrievalService {
68 pub fn new(
69 root: Arc<dyn Retriever>,
70 embedder: Arc<dyn Embedder>,
71 storage: Arc<dyn StoragePort>,
72 keyword: Arc<dyn KeywordPort>,
73 scope: Scope,
74 ) -> Self {
75 Self { root, embedder, storage, keyword, scope }
76 }
77}
78
79impl Service<Query> for RetrievalService {
80 type Response = Vec<Hit>;
81 type Error = LunarisError;
82 type Future = BoxFuture;
83
84 fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
85 // Always ready — backpressure happens inside the operator tree (vector
86 // search timeout / keyword search timeout / etc.). Tower's middleware
87 // (rate_limit / concurrency_limit) wraps THIS service for shared
88 // backpressure across callers.
89 Poll::Ready(Ok(()))
90 }
91
92 fn call(&mut self, q: Query) -> Self::Future {
93 let storage = self.storage.clone();
94 let embedder = self.embedder.clone();
95 let keyword = self.keyword.clone();
96 let root = self.root.clone();
97 let scope = self.scope.clone();
98 let as_of = q.as_of;
99
100 Box::pin(async move {
101 // P0 #1 Wave 2: scope is carried on the service itself — every
102 // call inherits the constructor-supplied partition key.
103 let ctx = QueryContext::new(q, scope, embedder, storage.clone(), keyword);
104 let raw = root.retrieve(&ctx).await?;
105 // Plan 04-04 B-9: RetrievalService callers don't have a verifier
106 // queue-depth check (only `Lunaris::recall_with_degraded_check`
107 // does), so initial_degraded is unconditionally false here.
108 hydrate(storage.as_ref(), &ctx.scope, raw, as_of, false).await
109 })
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116 use std::any::TypeId;
117
118 #[test]
119 fn retrieval_service_is_send_sync() {
120 fn assert_send_sync<T: Send + Sync>() {}
121 assert_send_sync::<RetrievalService>();
122 }
123
124 #[test]
125 fn future_type_is_static_send() {
126 // Sanity: future type is `'static + Send` per Service contract.
127 // TypeId comparison is a compile-time-ish proxy.
128 let _ = TypeId::of::<BoxFuture>();
129 }
130}