Skip to main content

corium_peer/
server.rs

1//! Peer server: hosts a peer for thin clients over the public
2//! `PeerServerService` gRPC surface (see `docs/design/protocol.md`).
3//!
4//! Queries execute server-side against the hosted peer's local database
5//! values with per-request fuel and chunked result streams.
6
7use std::pin::Pin;
8use std::sync::Arc;
9use std::time::Instant;
10
11use corium_core::{EntityId, IndexOrder};
12use corium_db::{Db, key_prefix};
13use corium_protocol::authz::{self, Access, Action, Guard, IdentityInterceptor, Principal};
14use corium_protocol::codec;
15use corium_protocol::pb;
16use corium_protocol::pb::peer_server_server::{PeerServer, PeerServerServer};
17use corium_query::edn::Edn;
18use corium_query::{ExecOptions, QInput, QueryCache, ast, boundary, exec};
19use tokio_stream::Stream;
20use tonic::{Request, Response, Status};
21
22use crate::Connection;
23use crate::metrics::Metrics;
24
25/// Peer-server execution limits.
26#[derive(Clone, Copy, Debug)]
27pub struct PeerServerConfig {
28    /// Fuel ceiling per query (datoms touched).
29    pub max_fuel: u64,
30    /// Rows or datoms per streamed chunk.
31    pub chunk_size: usize,
32    /// Maximum datoms returned by one `Datoms` request.
33    pub max_datoms: usize,
34}
35
36impl Default for PeerServerConfig {
37    fn default() -> Self {
38        Self {
39            max_fuel: 10_000_000,
40            chunk_size: 1024,
41            max_datoms: 1_000_000,
42        }
43    }
44}
45
46/// The hosted-peer gRPC service.
47pub struct PeerServerSvc {
48    connection: Arc<Connection>,
49    cache: QueryCache,
50    config: PeerServerConfig,
51    metrics: Arc<Metrics>,
52    guard: Guard,
53}
54
55impl PeerServerSvc {
56    /// Hosts `connection` with the given limits and no auth
57    /// ([`Guard::disabled`]); add a policy with [`PeerServerSvc::with_guard`].
58    #[must_use]
59    pub fn new(connection: Arc<Connection>, config: PeerServerConfig) -> Self {
60        let metrics = Arc::new(Metrics::new(connection.segment_cache_metrics()));
61        Self {
62            connection,
63            cache: QueryCache::new(),
64            config,
65            metrics,
66            guard: Guard::disabled(),
67        }
68    }
69
70    /// Enforces `guard` on every request (builder style).
71    #[must_use]
72    pub fn with_guard(mut self, guard: Guard) -> Self {
73        self.guard = guard;
74        self
75    }
76
77    /// Process query metrics suitable for a Prometheus endpoint.
78    #[must_use]
79    pub fn metrics(&self) -> Arc<Metrics> {
80        Arc::clone(&self.metrics)
81    }
82
83    /// Authorizes `access` for the request's [`Principal`], mapping the guard's
84    /// decision onto a gRPC status. A returned
85    /// [`ViewFilter`](authz::ViewFilter) is rejected for now: the query and
86    /// datom paths do not yet apply one, so honoring a filtered decision by
87    /// returning full data would be an authorization bypass.
88    async fn authorize<T>(&self, request: &Request<T>, access: Access) -> Result<(), Status> {
89        let principal: Principal = authz::principal(request);
90        match self.guard.authorize(&principal, &access).await {
91            Ok(None) => Ok(()),
92            Ok(Some(_filter)) => Err(Status::unimplemented(
93                "view filtering is not enforced on this surface yet",
94            )),
95            Err(error) => Err(error.to_status()),
96        }
97    }
98
99    /// The database this peer server hosts, for building an [`Access`].
100    fn db_target(&self) -> String {
101        self.connection.db_name().to_owned()
102    }
103
104    fn view_db(&self, spec: Option<&pb::DbViewSpec>) -> Result<Db, Status> {
105        let spec = spec.ok_or_else(|| Status::invalid_argument("request names no database"))?;
106        if spec.db != self.connection.db_name() {
107            return Err(Status::not_found(format!(
108                "this peer server hosts {:?}, not {:?}",
109                self.connection.db_name(),
110                spec.db
111            )));
112        }
113        let base = self.connection.db();
114        Ok(match &spec.view {
115            Some(pb::db_view_spec::View::AsOf(t)) => base.as_of(*t),
116            Some(pb::db_view_spec::View::Since(t)) => base.since(*t),
117            Some(pb::db_view_spec::View::History(true)) => base.history(),
118            Some(pb::db_view_spec::View::AsOfInstant(instant)) => base.as_of_instant(*instant),
119            Some(pb::db_view_spec::View::SinceInstant(instant)) => base.since_instant(*instant),
120            None | Some(pb::db_view_spec::View::History(false)) => base,
121        })
122    }
123}
124
125fn decode_edn(bytes: &[u8], what: &str) -> Result<Edn, Status> {
126    codec::decode_edn(bytes)
127        .map_err(|error| Status::invalid_argument(format!("bad {what}: {error}")))
128}
129
130fn resolve_eid(db: &Db, form: &Edn) -> Result<EntityId, Status> {
131    match form {
132        Edn::Long(n) => u64::try_from(*n)
133            .map(EntityId::from_raw)
134            .map_err(|_| Status::invalid_argument("negative entity id")),
135        Edn::Tagged(tag, value) if tag == "eid" => match value.as_ref() {
136            Edn::Long(n) => u64::try_from(*n)
137                .map(EntityId::from_raw)
138                .map_err(|_| Status::invalid_argument("negative entity id")),
139            _ => Err(Status::invalid_argument("#eid requires a long")),
140        },
141        Edn::Keyword(keyword) => db
142            .idents()
143            .entid(keyword)
144            .ok_or_else(|| Status::invalid_argument(format!("unknown ident {keyword}"))),
145        Edn::Vector(items) => {
146            let [attr_form, value_form] = items.as_slice() else {
147                return Err(Status::invalid_argument("lookup ref requires [attr value]"));
148            };
149            let attr = attr_form
150                .as_keyword()
151                .and_then(|keyword| db.idents().entid(keyword))
152                .ok_or_else(|| Status::invalid_argument("unknown lookup attribute"))?;
153            let value = boundary::edn_to_value(Some(db), value_form)
154                .ok_or_else(|| Status::invalid_argument("bad lookup value"))?;
155            let value = db.schema().get(attr).map_or(value.clone(), |meta| {
156                exec::coerce_for_type(value, meta.value_type)
157            });
158            db.lookup(attr, &value)
159                .ok_or_else(|| Status::not_found(format!("lookup ref {form} did not resolve")))
160        }
161        other => Err(Status::invalid_argument(format!(
162            "bad entity position {other}"
163        ))),
164    }
165}
166
167type ChunkStream<T> = Pin<Box<dyn Stream<Item = Result<T, Status>> + Send>>;
168
169enum Slot {
170    E,
171    A,
172    V,
173}
174
175fn chunk_stream<T: Send + 'static>(chunks: Vec<T>) -> ChunkStream<T> {
176    Box::pin(tokio_stream::iter(chunks.into_iter().map(Ok)))
177}
178
179#[tonic::async_trait]
180impl PeerServer for PeerServerSvc {
181    type QueryStream = ChunkStream<pb::QueryResultChunk>;
182
183    async fn query(
184        &self,
185        request: Request<pb::QueryRequest>,
186    ) -> Result<Response<Self::QueryStream>, Status> {
187        self.authorize(&request, Access::on(Action::Query, self.db_target()))
188            .await?;
189        let request = request.into_inner();
190        let query_form = decode_edn(&request.query, "query")?;
191        let parsed = self
192            .cache
193            .parse(&query_form)
194            .map_err(|error| Status::invalid_argument(error.to_string()))?;
195        let args = match decode_edn(&request.args, "args")? {
196            Edn::Nil => Vec::new(),
197            Edn::Vector(items) | Edn::List(items) => items,
198            other => {
199                return Err(Status::invalid_argument(format!(
200                    "args must be a vector, got {other}"
201                )));
202            }
203        };
204        // Bind database views and arguments positionally per the :in spec.
205        let mut dbs = Vec::new();
206        for spec in &request.dbs {
207            dbs.push(self.view_db(Some(spec))?);
208        }
209        let mut next_db = 0;
210        let mut next_arg = 0;
211        let mut inputs: Vec<QInput<'_>> = Vec::with_capacity(parsed.inputs.len());
212        for spec in &parsed.inputs {
213            if matches!(spec, ast::InSpec::Db(_)) {
214                let db = dbs
215                    .get(next_db)
216                    .ok_or_else(|| Status::invalid_argument("query needs more database views"))?;
217                inputs.push(QInput::Db(db));
218                next_db += 1;
219            } else {
220                let arg = args
221                    .get(next_arg)
222                    .cloned()
223                    .ok_or_else(|| Status::invalid_argument("query needs more arguments"))?;
224                inputs.push(QInput::Edn(arg));
225                next_arg += 1;
226            }
227        }
228        let fuel = if request.fuel == 0 {
229            self.config.max_fuel
230        } else {
231            request.fuel.min(self.config.max_fuel)
232        };
233        let started = Instant::now();
234        let (result, report) = corium_query::run(
235            &parsed,
236            &inputs,
237            ExecOptions {
238                fuel: Some(fuel),
239                ..ExecOptions::default()
240            },
241        )
242        .map_err(|error| Status::invalid_argument(error.to_string()))?;
243        self.metrics
244            .record_query(started.elapsed(), report.datoms_scanned);
245        tracing::debug!(
246            db = %self.connection.db_name(),
247            datoms_scanned = report.datoms_scanned,
248            elapsed_micros = started.elapsed().as_micros(),
249            "query completed"
250        );
251        let (shape, rows) = match (&parsed.find, result) {
252            (ast::FindSpec::Rel(_), Edn::Vector(rows)) => (pb::ResultShape::Relation, rows),
253            (ast::FindSpec::Coll(_), Edn::Vector(rows)) => (pb::ResultShape::Collection, rows),
254            (ast::FindSpec::Tuple(_), value) => (pb::ResultShape::Tuple, vec![value]),
255            (ast::FindSpec::Scalar(_), value) => (pb::ResultShape::Scalar, vec![value]),
256            (_, value) => (pb::ResultShape::Relation, vec![value]),
257        };
258        let mut chunks = Vec::new();
259        match shape {
260            pb::ResultShape::Tuple | pb::ResultShape::Scalar => {
261                chunks.push(pb::QueryResultChunk {
262                    shape: shape.into(),
263                    rows: codec::encode_edn(&rows.into_iter().next().unwrap_or(Edn::Nil)),
264                    last: true,
265                });
266            }
267            _ => {
268                let groups: Vec<&[Edn]> = if rows.is_empty() {
269                    vec![&[]]
270                } else {
271                    rows.chunks(self.config.chunk_size.max(1)).collect()
272                };
273                let total = groups.len();
274                for (index, group) in groups.into_iter().enumerate() {
275                    chunks.push(pb::QueryResultChunk {
276                        shape: shape.into(),
277                        rows: codec::encode_edn(&Edn::Vector(group.to_vec())),
278                        last: index + 1 == total,
279                    });
280                }
281            }
282        }
283        Ok(Response::new(chunk_stream(chunks)))
284    }
285
286    async fn pull(
287        &self,
288        request: Request<pb::PullRequest>,
289    ) -> Result<Response<pb::PullResponse>, Status> {
290        self.authorize(&request, Access::on(Action::Pull, self.db_target()))
291            .await?;
292        let request = request.into_inner();
293        let db = self.view_db(request.db.as_ref())?;
294        let pattern = decode_edn(&request.pattern, "pull pattern")?;
295        let eid = resolve_eid(&db, &decode_edn(&request.eid, "entity")?)?;
296        let result = corium_query::pull(&db, &pattern, eid)
297            .map_err(|error| Status::invalid_argument(error.to_string()))?;
298        Ok(Response::new(pb::PullResponse {
299            result: codec::encode_edn(&result),
300        }))
301    }
302
303    async fn transact(
304        &self,
305        request: Request<pb::TransactRequest>,
306    ) -> Result<Response<pb::TransactResponse>, Status> {
307        self.authorize(&request, Access::on(Action::Transact, self.db_target()))
308            .await?;
309        let request = request.into_inner();
310        if request.db != self.connection.db_name() {
311            return Err(Status::not_found(format!(
312                "this peer server hosts {:?}, not {:?}",
313                self.connection.db_name(),
314                request.db
315            )));
316        }
317        let response = self
318            .connection
319            .transact_raw_at(request.tx_data, request.expected_basis_t)
320            .await
321            .map_err(|error| match error {
322                crate::PeerError::Rpc(status) => status,
323                other => Status::internal(other.to_string()),
324            })?;
325        // Read-your-writes for thin clients: later queries on this server
326        // observe the transaction.
327        self.connection
328            .sync_to(response.basis_t)
329            .await
330            .map_err(|error| Status::internal(error.to_string()))?;
331        Ok(Response::new(response))
332    }
333
334    type DatomsStream = ChunkStream<pb::DatomChunk>;
335
336    #[allow(clippy::too_many_lines)]
337    async fn datoms(
338        &self,
339        request: Request<pb::DatomsRequest>,
340    ) -> Result<Response<Self::DatomsStream>, Status> {
341        self.authorize(&request, Access::on(Action::Datoms, self.db_target()))
342            .await?;
343        let request = request.into_inner();
344        let db = self.view_db(request.db.as_ref())?;
345        let order = match request.index.as_str() {
346            "eavt" => IndexOrder::Eavt,
347            "aevt" => IndexOrder::Aevt,
348            "avet" => IndexOrder::Avet,
349            "vaet" => IndexOrder::Vaet,
350            other => {
351                return Err(Status::invalid_argument(format!("unknown index {other:?}")));
352            }
353        };
354        let components = match decode_edn(&request.components, "components")? {
355            Edn::Nil => Vec::new(),
356            Edn::Vector(items) | Edn::List(items) => items,
357            other => {
358                return Err(Status::invalid_argument(format!(
359                    "components must be a vector, got {other}"
360                )));
361            }
362        };
363        // Components bind positionally in index order and must be a prefix.
364        let positions = match order {
365            IndexOrder::Eavt => [Slot::E, Slot::A, Slot::V],
366            IndexOrder::Aevt => [Slot::A, Slot::E, Slot::V],
367            IndexOrder::Avet => [Slot::A, Slot::V, Slot::E],
368            IndexOrder::Vaet => [Slot::V, Slot::A, Slot::E],
369        };
370        if components.len() > 3 {
371            return Err(Status::invalid_argument("at most three components"));
372        }
373        let mut e = None;
374        let mut a = None;
375        let mut v = None;
376        for (slot, form) in positions.iter().zip(&components) {
377            match slot {
378                Slot::E => e = Some(resolve_eid(&db, form)?),
379                Slot::A => {
380                    a = Some(match form {
381                        Edn::Keyword(keyword) => db.idents().entid(keyword).ok_or_else(|| {
382                            Status::invalid_argument(format!("unknown attribute {keyword}"))
383                        })?,
384                        other => resolve_eid(&db, other)?,
385                    });
386                }
387                Slot::V => {
388                    let value = boundary::edn_to_value(Some(&db), form)
389                        .ok_or_else(|| Status::invalid_argument(format!("bad value {form}")))?;
390                    let value = a
391                        .and_then(|a| db.schema().get(a))
392                        .map_or(value.clone(), |meta| {
393                            exec::coerce_for_type(value, meta.value_type)
394                        });
395                    v = Some(value);
396                }
397            }
398        }
399        let prefix = key_prefix(order, e, a, v.as_ref());
400        let limit = if request.limit == 0 {
401            self.config.max_datoms
402        } else {
403            usize::try_from(request.limit)
404                .unwrap_or(usize::MAX)
405                .min(self.config.max_datoms)
406        };
407        let datoms: Vec<corium_core::Datom> = db
408            .datoms_prefix(order, &prefix)
409            .take(limit)
410            .cloned()
411            .collect();
412        let interner = db.interner();
413        let mut chunks = Vec::new();
414        let groups: Vec<&[corium_core::Datom]> = if datoms.is_empty() {
415            vec![&[]]
416        } else {
417            datoms.chunks(self.config.chunk_size.max(1)).collect()
418        };
419        let total = groups.len();
420        for (index, group) in groups.into_iter().enumerate() {
421            let bytes = codec::encode_datoms(group, interner)
422                .map_err(|error| Status::internal(error.to_string()))?;
423            chunks.push(pb::DatomChunk {
424                datoms: bytes,
425                last: index + 1 == total,
426            });
427        }
428        Ok(Response::new(chunk_stream(chunks)))
429    }
430
431    type TxRangeStream = ChunkStream<pb::TxChunk>;
432
433    async fn tx_range(
434        &self,
435        request: Request<pb::TxRangeRequest>,
436    ) -> Result<Response<Self::TxRangeStream>, Status> {
437        self.authorize(&request, Access::on(Action::TxRange, self.db_target()))
438            .await?;
439        let request = request.into_inner();
440        if request.db != self.connection.db_name() {
441            return Err(Status::not_found(format!(
442                "this peer server hosts {:?}, not {:?}",
443                self.connection.db_name(),
444                request.db
445            )));
446        }
447        let end = if request.end == 0 {
448            None
449        } else {
450            Some(request.end)
451        };
452        let records = self.connection.tx_range(request.start, end);
453        let db = self.connection.db();
454        let interner = db.interner();
455        let mut txes = Vec::with_capacity(records.len());
456        for record in records {
457            txes.push(pb::TxReport {
458                t: record.t,
459                tx_instant: record.tx_instant,
460                datoms: codec::encode_datoms(&record.datoms, interner)
461                    .map_err(|error| Status::internal(error.to_string()))?,
462            });
463        }
464        let groups: Vec<Vec<pb::TxReport>> = if txes.is_empty() {
465            vec![Vec::new()]
466        } else {
467            txes.chunks(self.config.chunk_size.max(1))
468                .map(<[pb::TxReport]>::to_vec)
469                .collect()
470        };
471        let total = groups.len();
472        let chunks: Vec<pb::TxChunk> = groups
473            .into_iter()
474            .enumerate()
475            .map(|(index, group)| pb::TxChunk {
476                txes: group,
477                last: index + 1 == total,
478            })
479            .collect();
480        Ok(Response::new(chunk_stream(chunks)))
481    }
482
483    async fn db_stats(
484        &self,
485        request: Request<pb::DbStatsRequest>,
486    ) -> Result<Response<pb::DbStatsResponse>, Status> {
487        self.authorize(&request, Access::on(Action::Inspect, self.db_target()))
488            .await?;
489        let request = request.into_inner();
490        let db = self.view_db(request.db.as_ref())?;
491        let stats = db.stats();
492        Ok(Response::new(pb::DbStatsResponse {
493            basis_t: db.basis_t(),
494            datom_count: stats.datoms as u64,
495            entity_count: stats.entities as u64,
496            attribute_count: stats.attributes as u64,
497        }))
498    }
499
500    type SubscribeStream = tonic::Streaming<pb::SubscribeItem>;
501
502    async fn subscribe(
503        &self,
504        request: Request<pb::SubscribeRequest>,
505    ) -> Result<Response<Self::SubscribeStream>, Status> {
506        self.authorize(&request, Access::on(Action::Subscribe, self.db_target()))
507            .await?;
508        let request = request.into_inner();
509        if request.db != self.connection.db_name() {
510            return Err(Status::not_found(format!(
511                "this peer server hosts {:?}, not {:?}",
512                self.connection.db_name(),
513                request.db
514            )));
515        }
516        let upstream = self
517            .connection
518            .subscribe_raw(request.from_basis_t)
519            .await
520            .map_err(|error| match error {
521                crate::PeerError::Rpc(status) => status,
522                other => Status::unavailable(other.to_string()),
523            })?;
524        Ok(Response::new(upstream))
525    }
526}
527
528/// Serves the peer-server service until `shutdown` resolves.
529///
530/// # Errors
531/// Returns an error when the listener cannot be bound or TLS is invalid.
532pub async fn serve(
533    connection: Arc<Connection>,
534    addr: std::net::SocketAddr,
535    guard: Guard,
536    tls: Option<tonic::transport::ServerTlsConfig>,
537    config: PeerServerConfig,
538    shutdown: impl std::future::Future<Output = ()> + Send,
539) -> Result<(), tonic::transport::Error> {
540    serve_service(
541        PeerServerSvc::new(connection, config).with_guard(guard),
542        addr,
543        tls,
544        shutdown,
545    )
546    .await
547}
548
549/// Serves a pre-built peer service, allowing process wiring to expose its
550/// metrics handle before the server future is awaited.
551///
552/// # Errors
553/// Returns an error when the listener cannot be bound or TLS is invalid.
554pub async fn serve_service(
555    service: PeerServerSvc,
556    addr: std::net::SocketAddr,
557    tls: Option<tonic::transport::ServerTlsConfig>,
558    shutdown: impl std::future::Future<Output = ()> + Send,
559) -> Result<(), tonic::transport::Error> {
560    // The interceptor authenticates each request with the same guard the
561    // service uses to authorize it.
562    let interceptor = IdentityInterceptor::new(service.guard.clone());
563    let mut builder = tonic::transport::Server::builder();
564    if let Some(tls) = tls {
565        builder = builder.tls_config(tls)?;
566    }
567    builder
568        .add_service(PeerServerServer::with_interceptor(service, interceptor))
569        .serve_with_shutdown(addr, shutdown)
570        .await
571}
572
573/// Reassembles a chunked query result on the client side.
574///
575/// # Errors
576/// Returns [`Status`]-style codec failures as strings.
577pub fn assemble_query_result(chunks: &[pb::QueryResultChunk]) -> Result<Edn, String> {
578    let shape = chunks
579        .first()
580        .map_or(pb::ResultShape::Relation, pb::QueryResultChunk::shape);
581    match shape {
582        pb::ResultShape::Tuple | pb::ResultShape::Scalar => {
583            let bytes = &chunks
584                .first()
585                .ok_or_else(|| "empty result stream".to_owned())?
586                .rows;
587            codec::decode_edn(bytes).map_err(|error| error.to_string())
588        }
589        _ => {
590            let mut rows = Vec::new();
591            for chunk in chunks {
592                match codec::decode_edn(&chunk.rows).map_err(|error| error.to_string())? {
593                    Edn::Vector(items) => rows.extend(items),
594                    other => return Err(format!("bad result chunk {other}")),
595                }
596            }
597            Ok(Edn::Vector(rows))
598        }
599    }
600}