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