Skip to main content

corium_peer/
lib.rs

1//! Peer library: remote connection to a transactor, tx-report handling,
2//! local database values, sync, and the segment cache
3//! (see `docs/architecture.md` and `docs/design/protocol.md`).
4//!
5//! A [`Connection`] subscribes to the transactor's tx-report stream and
6//! folds every report into an immutable [`Db`] value locally; queries never
7//! block on the transactor. On disconnect it reconnects and resubscribes
8//! from its basis, and the server backfills the gap from the durable log.
9
10pub mod metrics;
11pub mod segment;
12pub mod server;
13
14use std::collections::BTreeMap;
15use std::sync::{Arc, Mutex, RwLock};
16use std::time::Duration;
17
18use corium_core::{Datom, EntityId, KeywordInterner, Schema};
19use corium_db::{Db, Idents};
20use corium_log::TxRecord;
21use corium_protocol::auth::TokenInterceptor;
22use corium_protocol::codec::{self, CodecError};
23use corium_protocol::pb;
24use corium_protocol::pb::catalog_client::CatalogClient;
25use corium_protocol::pb::transactor_client::TransactorClient;
26use corium_query::edn::Edn;
27use thiserror::Error;
28use tokio::sync::{broadcast, watch};
29use tonic::Status;
30use tonic::service::interceptor::InterceptedService;
31use tonic::transport::{Channel, ClientTlsConfig, Endpoint};
32
33type Client = TransactorClient<InterceptedService<Channel, TokenInterceptor>>;
34
35/// Peer failure.
36#[derive(Debug, Error)]
37pub enum PeerError {
38    /// Transport-level failure.
39    #[error(transparent)]
40    Transport(#[from] tonic::transport::Error),
41    /// RPC failure.
42    #[error(transparent)]
43    Rpc(#[from] Status),
44    /// Payload failed to decode.
45    #[error(transparent)]
46    Codec(#[from] CodecError),
47    /// Protocol contract violation.
48    #[error("protocol error: {0}")]
49    Protocol(String),
50    /// The connection background task has stopped.
51    #[error("connection closed")]
52    Closed,
53}
54
55/// Connection configuration.
56#[derive(Clone, Debug)]
57pub struct ConnectConfig {
58    /// Transactor endpoint, e.g. `http://127.0.0.1:4334`.
59    pub endpoint: String,
60    /// Database name.
61    pub db: String,
62    /// Optional bearer token.
63    pub token: Option<String>,
64    /// Optional TLS configuration (`https` endpoints).
65    pub tls: Option<ClientTlsConfig>,
66    /// Minimum reconnect backoff.
67    pub reconnect_min: Duration,
68    /// Maximum reconnect backoff.
69    pub reconnect_max: Duration,
70}
71
72impl ConnectConfig {
73    /// Plaintext connection with default backoff.
74    #[must_use]
75    pub fn new(endpoint: impl Into<String>, db: impl Into<String>) -> Self {
76        Self {
77            endpoint: endpoint.into(),
78            db: db.into(),
79            token: None,
80            tls: None,
81            reconnect_min: Duration::from_millis(100),
82            reconnect_max: Duration::from_secs(5),
83        }
84    }
85}
86
87/// One transaction applied to the peer's local database value.
88#[derive(Clone, Debug)]
89pub struct PeerReport {
90    /// Transaction number.
91    pub t: u64,
92    /// Commit timestamp (Unix milliseconds).
93    pub tx_instant: i64,
94    /// Datoms asserted/retracted by the transaction.
95    pub datoms: Vec<Datom>,
96    /// Database value including the transaction.
97    pub db_after: Db,
98}
99
100/// Result of a transaction submitted through a peer.
101#[derive(Clone, Debug)]
102pub struct TxResult {
103    /// Basis before the transaction.
104    pub basis_before: u64,
105    /// The transaction's `t`.
106    pub basis_t: u64,
107    /// Commit timestamp.
108    pub tx_instant: i64,
109    /// Tempid allocations.
110    pub tempids: BTreeMap<String, EntityId>,
111    /// Database value including the transaction.
112    pub db_after: Db,
113}
114
115struct PeerState {
116    schema: Schema,
117    idents: Idents,
118    interner: KeywordInterner,
119    db: Db,
120    instants: BTreeMap<u64, i64>,
121}
122
123struct Inner {
124    config: ConnectConfig,
125    state: RwLock<Option<PeerState>>,
126    basis: watch::Sender<u64>,
127    index_basis: watch::Sender<u64>,
128    reports: broadcast::Sender<PeerReport>,
129    client: Mutex<Client>,
130}
131
132/// A live peer connection to a transactor-hosted database.
133pub struct Connection {
134    inner: Arc<Inner>,
135    task: tokio::task::JoinHandle<()>,
136}
137
138impl Drop for Connection {
139    fn drop(&mut self) {
140        self.task.abort();
141    }
142}
143
144async fn open_channel(config: &ConnectConfig) -> Result<Channel, PeerError> {
145    let mut endpoint = Endpoint::from_shared(config.endpoint.clone())
146        .map_err(|error| PeerError::Protocol(format!("bad endpoint: {error}")))?
147        .connect_timeout(Duration::from_secs(10));
148    if let Some(tls) = &config.tls {
149        endpoint = endpoint.tls_config(tls.clone())?;
150    }
151    Ok(endpoint.connect().await?)
152}
153
154fn make_client(channel: Channel, token: Option<String>) -> Client {
155    TransactorClient::with_interceptor(channel, TokenInterceptor::new(token))
156}
157
158impl Connection {
159    /// Connects, subscribes from basis 0, and waits until the handshake and
160    /// its backfill have been applied locally.
161    ///
162    /// # Errors
163    /// Returns [`PeerError`] when the endpoint is unreachable or the
164    /// subscription cannot be established.
165    pub async fn connect(config: ConnectConfig) -> Result<Self, PeerError> {
166        let channel = open_channel(&config).await?;
167        let client = make_client(channel, config.token.clone());
168        let inner = Arc::new(Inner {
169            config,
170            state: RwLock::new(None),
171            basis: watch::channel(0).0,
172            index_basis: watch::channel(0).0,
173            reports: broadcast::channel(1024).0,
174            client: Mutex::new(client.clone()),
175        });
176        // Establish the first subscription before returning so `db()` is
177        // populated and connection errors surface synchronously.
178        let mut stream = subscribe(&inner, 0).await?;
179        let handshake_basis = pump_handshake(&inner, &mut stream).await?;
180        drain_until(&inner, &mut stream, handshake_basis).await?;
181        let task_inner = Arc::clone(&inner);
182        let task = tokio::spawn(async move {
183            run_loop(task_inner, Some(stream)).await;
184        });
185        Ok(Self { inner, task })
186    }
187
188    /// The connected database name.
189    #[must_use]
190    pub fn db_name(&self) -> &str {
191        &self.inner.config.db
192    }
193
194    /// Returns the current local database value without blocking on the
195    /// transactor.
196    ///
197    /// # Panics
198    /// Panics if called before the initial handshake (impossible through
199    /// [`Connection::connect`]).
200    #[must_use]
201    pub fn db(&self) -> Db {
202        self.inner
203            .state
204            .read()
205            .unwrap_or_else(std::sync::PoisonError::into_inner)
206            .as_ref()
207            .expect("connection is initialized")
208            .db
209            .clone()
210    }
211
212    /// Basis of the newest locally applied transaction.
213    #[must_use]
214    pub fn basis_t(&self) -> u64 {
215        *self.inner.basis.subscribe().borrow()
216    }
217
218    /// Basis of the newest published durable index announced by the
219    /// transactor.
220    #[must_use]
221    pub fn index_basis_t(&self) -> u64 {
222        *self.inner.index_basis.subscribe().borrow()
223    }
224
225    /// Subscribes to reports applied after this call.
226    #[must_use]
227    pub fn tx_reports(&self) -> broadcast::Receiver<PeerReport> {
228        self.inner.reports.subscribe()
229    }
230
231    /// Transaction instants recorded locally for `[start, end)`, paired
232    /// with their datoms (the peer-side `tx-range`).
233    #[must_use]
234    pub fn tx_range(&self, start: u64, end: Option<u64>) -> Vec<TxRecord> {
235        let guard = self
236            .inner
237            .state
238            .read()
239            .unwrap_or_else(std::sync::PoisonError::into_inner);
240        let Some(state) = guard.as_ref() else {
241            return Vec::new();
242        };
243        state
244            .db
245            .tx_range(start, end)
246            .into_iter()
247            .map(|(t, datoms)| TxRecord {
248                t,
249                tx_instant: state.instants.get(&t).copied().unwrap_or_default(),
250                datoms,
251            })
252            .collect()
253    }
254
255    /// Submits a transaction (EDN transaction forms) and waits until it is
256    /// applied locally, so a following [`Connection::db`] observes it.
257    ///
258    /// # Errors
259    /// Returns [`PeerError`] for rejected transactions or transport failure.
260    pub async fn transact(&self, forms: Vec<Edn>) -> Result<TxResult, PeerError> {
261        let response = self
262            .transact_raw(codec::encode_edn(&Edn::Vector(forms)))
263            .await?;
264        let tempids = decode_tempids(&response.tempids)?;
265        let db_after = self.sync_to(response.basis_t).await?;
266        Ok(TxResult {
267            basis_before: response.basis_before,
268            basis_t: response.basis_t,
269            tx_instant: response.tx_instant,
270            tempids,
271            db_after,
272        })
273    }
274
275    /// Submits already-encoded transaction data, returning the raw wire
276    /// response (used by the peer server's transact proxy).
277    ///
278    /// # Errors
279    /// Returns [`PeerError`] for rejected transactions or transport failure.
280    pub async fn transact_raw(&self, tx_data: Vec<u8>) -> Result<pb::TransactResponse, PeerError> {
281        let request = pb::TransactRequest {
282            db: self.inner.config.db.clone(),
283            protocol_version: corium_protocol::PROTOCOL_VERSION,
284            tx_data,
285        };
286        let mut client = self
287            .inner
288            .client
289            .lock()
290            .unwrap_or_else(std::sync::PoisonError::into_inner)
291            .clone();
292        Ok(client.transact(request).await?.into_inner())
293    }
294
295    /// Waits until the local basis reaches the transactor's current basis.
296    ///
297    /// # Errors
298    /// Returns [`PeerError`] on transport failure.
299    pub async fn sync(&self) -> Result<Db, PeerError> {
300        let mut client = self
301            .inner
302            .client
303            .lock()
304            .unwrap_or_else(std::sync::PoisonError::into_inner)
305            .clone();
306        let response = client
307            .sync(pb::SyncRequest {
308                db: self.inner.config.db.clone(),
309                t: 0,
310            })
311            .await?
312            .into_inner();
313        self.sync_to(response.basis_t).await
314    }
315
316    /// Waits until the local basis reaches `t`, returning the database value.
317    ///
318    /// # Errors
319    /// Returns [`PeerError::Closed`] if the connection task stops.
320    pub async fn sync_to(&self, t: u64) -> Result<Db, PeerError> {
321        let mut basis = self.inner.basis.subscribe();
322        loop {
323            if *basis.borrow() >= t {
324                return Ok(self.db());
325            }
326            basis.changed().await.map_err(|_| PeerError::Closed)?;
327        }
328    }
329
330    /// Transactor-side status for the connected database.
331    ///
332    /// # Errors
333    /// Returns [`PeerError`] on transport failure.
334    pub async fn status(&self) -> Result<pb::StatusResponse, PeerError> {
335        let mut client = self
336            .inner
337            .client
338            .lock()
339            .unwrap_or_else(std::sync::PoisonError::into_inner)
340            .clone();
341        Ok(client
342            .status(pb::StatusRequest {
343                db: self.inner.config.db.clone(),
344            })
345            .await?
346            .into_inner())
347    }
348
349    /// Opens an independent upstream subscription (used by the peer server
350    /// to relay tx-report streams to thin clients).
351    ///
352    /// # Errors
353    /// Returns [`PeerError`] on transport failure.
354    pub async fn subscribe_raw(
355        &self,
356        from_basis_t: u64,
357    ) -> Result<tonic::Streaming<pb::SubscribeItem>, PeerError> {
358        let mut client = self
359            .inner
360            .client
361            .lock()
362            .unwrap_or_else(std::sync::PoisonError::into_inner)
363            .clone();
364        Ok(client
365            .subscribe(pb::SubscribeRequest {
366                db: self.inner.config.db.clone(),
367                protocol_version: corium_protocol::PROTOCOL_VERSION,
368                from_basis_t,
369            })
370            .await?
371            .into_inner())
372    }
373}
374
375fn decode_tempids(bytes: &[u8]) -> Result<BTreeMap<String, EntityId>, PeerError> {
376    let Edn::Map(pairs) = codec::decode_edn(bytes)? else {
377        return Err(PeerError::Protocol("tempids must be a map".into()));
378    };
379    let mut tempids = BTreeMap::new();
380    for (key, value) in pairs {
381        let (Edn::Str(name), Edn::Long(raw)) = (key, value) else {
382            return Err(PeerError::Protocol("bad tempid entry".into()));
383        };
384        let raw = u64::try_from(raw).map_err(|_| PeerError::Protocol("bad entity id".into()))?;
385        tempids.insert(name, EntityId::from_raw(raw));
386    }
387    Ok(tempids)
388}
389
390async fn subscribe(
391    inner: &Arc<Inner>,
392    from_basis_t: u64,
393) -> Result<tonic::Streaming<pb::SubscribeItem>, PeerError> {
394    let mut client = inner
395        .client
396        .lock()
397        .unwrap_or_else(std::sync::PoisonError::into_inner)
398        .clone();
399    Ok(client
400        .subscribe(pb::SubscribeRequest {
401            db: inner.config.db.clone(),
402            protocol_version: corium_protocol::PROTOCOL_VERSION,
403            from_basis_t,
404        })
405        .await?
406        .into_inner())
407}
408
409/// Consumes the stream's handshake, installing schema/naming, and returns
410/// the server basis at subscription time.
411async fn pump_handshake(
412    inner: &Arc<Inner>,
413    stream: &mut tonic::Streaming<pb::SubscribeItem>,
414) -> Result<u64, PeerError> {
415    let first = stream
416        .message()
417        .await?
418        .and_then(|item| item.item)
419        .ok_or_else(|| PeerError::Protocol("subscription ended before handshake".into()))?;
420    let pb::subscribe_item::Item::Handshake(handshake) = first else {
421        return Err(PeerError::Protocol(
422            "subscription must begin with a handshake".into(),
423        ));
424    };
425    let (schema, idents) = codec::decode_schema(&handshake.schema)?;
426    {
427        let mut guard = inner
428            .state
429            .write()
430            .unwrap_or_else(std::sync::PoisonError::into_inner);
431        if let Some(state) = guard.as_mut() {
432            // Reconnect: schema/idents are fixed after bootstrap; keep the
433            // locally accumulated database value and naming.
434            state.schema = schema;
435            state.idents = idents;
436        } else {
437            let interner = KeywordInterner::default();
438            let db = Db::new(schema.clone()).with_naming(idents.clone(), interner.clone());
439            *guard = Some(PeerState {
440                schema,
441                idents,
442                interner,
443                db,
444                instants: BTreeMap::new(),
445            });
446        }
447    }
448    let _ = inner.index_basis.send_replace(handshake.index_basis_t);
449    Ok(handshake.basis_t)
450}
451
452/// Applies stream items until the local basis reaches `target`.
453async fn drain_until(
454    inner: &Arc<Inner>,
455    stream: &mut tonic::Streaming<pb::SubscribeItem>,
456    target: u64,
457) -> Result<(), PeerError> {
458    while *inner.basis.subscribe().borrow() < target {
459        let Some(item) = stream.message().await? else {
460            return Err(PeerError::Protocol(
461                "subscription ended during backfill".into(),
462            ));
463        };
464        if let Some(item) = item.item {
465            apply_item(inner, item)?;
466        }
467    }
468    Ok(())
469}
470
471fn apply_item(inner: &Arc<Inner>, item: pb::subscribe_item::Item) -> Result<(), PeerError> {
472    match item {
473        pb::subscribe_item::Item::Report(report) => {
474            let peer_report = {
475                let mut guard = inner
476                    .state
477                    .write()
478                    .unwrap_or_else(std::sync::PoisonError::into_inner);
479                let state = guard
480                    .as_mut()
481                    .ok_or_else(|| PeerError::Protocol("report before handshake".into()))?;
482                if report.t <= state.db.basis_t() {
483                    return Ok(());
484                }
485                let before = state.interner.len();
486                let datoms = codec::decode_datoms(&report.datoms, &mut state.interner)?;
487                if state.interner.len() > before {
488                    state.db = state
489                        .db
490                        .clone()
491                        .with_naming(state.idents.clone(), state.interner.clone());
492                }
493                state.db = state.db.with_transaction(report.t, &datoms);
494                state.instants.insert(report.t, report.tx_instant);
495                PeerReport {
496                    t: report.t,
497                    tx_instant: report.tx_instant,
498                    datoms,
499                    db_after: state.db.clone(),
500                }
501            };
502            let _ = inner.basis.send_replace(peer_report.t);
503            let _ = inner.reports.send(peer_report);
504        }
505        pb::subscribe_item::Item::IndexBasis(index) => {
506            let _ = inner.index_basis.send_replace(index.index_basis_t);
507        }
508        pb::subscribe_item::Item::Heartbeat(_) | pb::subscribe_item::Item::Handshake(_) => {}
509    }
510    Ok(())
511}
512
513/// Long-running consume/reconnect loop: on stream end or error, rebuilds
514/// the channel with exponential backoff and resubscribes from the local
515/// basis; the server backfills the gap.
516async fn run_loop(inner: Arc<Inner>, initial: Option<tonic::Streaming<pb::SubscribeItem>>) {
517    let mut stream = initial;
518    let mut backoff = inner.config.reconnect_min;
519    loop {
520        if let Some(active) = stream.as_mut() {
521            match active.message().await {
522                Ok(Some(item)) => {
523                    backoff = inner.config.reconnect_min;
524                    if let Some(item) = item.item {
525                        if apply_item(&inner, item).is_err() {
526                            stream = None;
527                        }
528                    }
529                    continue;
530                }
531                Ok(None) | Err(_) => {
532                    stream = None;
533                }
534            }
535        }
536        tokio::time::sleep(backoff).await;
537        backoff = (backoff * 2).min(inner.config.reconnect_max);
538        match open_channel(&inner.config).await {
539            Ok(channel) => {
540                let client = make_client(channel, inner.config.token.clone());
541                *inner
542                    .client
543                    .lock()
544                    .unwrap_or_else(std::sync::PoisonError::into_inner) = client;
545            }
546            Err(_) => continue,
547        }
548        let from = *inner.basis.subscribe().borrow();
549        if let Ok(mut fresh) = subscribe(&inner, from).await {
550            if pump_handshake(&inner, &mut fresh).await.is_ok() {
551                stream = Some(fresh);
552            }
553        }
554    }
555}
556
557/// Catalog (admin) client for a transactor endpoint.
558pub struct Admin {
559    client: CatalogClient<InterceptedService<Channel, TokenInterceptor>>,
560}
561
562impl Admin {
563    /// Connects to a transactor's catalog service.
564    ///
565    /// # Errors
566    /// Returns [`PeerError`] when the endpoint is unreachable.
567    pub async fn connect(
568        endpoint: &str,
569        token: Option<String>,
570        tls: Option<ClientTlsConfig>,
571    ) -> Result<Self, PeerError> {
572        let config = ConnectConfig {
573            tls,
574            token: token.clone(),
575            ..ConnectConfig::new(endpoint, "")
576        };
577        let channel = open_channel(&config).await?;
578        Ok(Self {
579            client: CatalogClient::with_interceptor(channel, TokenInterceptor::new(token)),
580        })
581    }
582
583    /// Creates a database with EDN schema forms; `false` when it existed.
584    ///
585    /// # Errors
586    /// Returns [`PeerError`] for invalid schema or transport failure.
587    pub async fn create_database(&mut self, db: &str, schema: &[Edn]) -> Result<bool, PeerError> {
588        let response = self
589            .client
590            .create_database(pb::CreateDatabaseRequest {
591                db: db.to_owned(),
592                schema: codec::encode_edn(&Edn::Vector(schema.to_vec())),
593            })
594            .await?;
595        Ok(response.into_inner().created)
596    }
597
598    /// Deletes a database; `false` when it did not exist.
599    ///
600    /// # Errors
601    /// Returns [`PeerError`] on transport failure.
602    pub async fn delete_database(&mut self, db: &str) -> Result<bool, PeerError> {
603        let response = self
604            .client
605            .delete_database(pb::DeleteDatabaseRequest { db: db.to_owned() })
606            .await?;
607        Ok(response.into_inner().deleted)
608    }
609
610    /// Lists hosted databases.
611    ///
612    /// # Errors
613    /// Returns [`PeerError`] on transport failure.
614    pub async fn list_databases(&mut self) -> Result<Vec<String>, PeerError> {
615        let response = self
616            .client
617            .list_databases(pb::ListDatabasesRequest {})
618            .await?;
619        Ok(response.into_inner().dbs)
620    }
621
622    /// Sweeps blobs unreachable from every live database root.
623    ///
624    /// # Errors
625    /// Returns [`PeerError`] on transport failure.
626    pub async fn gc_deleted_databases(&mut self) -> Result<u64, PeerError> {
627        self.gc_deleted_databases_with_retention(None).await
628    }
629
630    /// Sweeps unreachable blobs with an optional minimum retention window.
631    ///
632    /// # Errors
633    /// Returns [`PeerError`] on transport failure.
634    pub async fn gc_deleted_databases_with_retention(
635        &mut self,
636        retention: Option<Duration>,
637    ) -> Result<u64, PeerError> {
638        let response = self
639            .client
640            .gc_deleted_databases(pb::GcDeletedDatabasesRequest {
641                retention_millis: retention.map(duration_millis),
642            })
643            .await?;
644        Ok(response.into_inner().swept_blobs)
645    }
646}
647
648fn duration_millis(duration: Duration) -> u64 {
649    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
650}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655
656    #[test]
657    fn gc_retention_wire_value_preserves_zero_and_subseconds() {
658        assert_eq!(duration_millis(Duration::ZERO), 0);
659        assert_eq!(duration_millis(Duration::from_millis(500)), 500);
660    }
661}