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