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