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 authz;
11pub mod metrics;
12pub mod segment;
13pub mod server;
14
15use std::collections::BTreeMap;
16use std::sync::{Arc, Mutex, RwLock};
17use std::time::Duration;
18
19use corium_core::{Datom, EntityId, KeywordInterner, Schema};
20use corium_db::{Db, Idents, bootstrap};
21use corium_log::TxRecord;
22use corium_protocol::auth::TokenInterceptor;
23use corium_protocol::codec::{self, CodecError};
24use corium_protocol::pb;
25use corium_protocol::pb::catalog_client::CatalogClient;
26use corium_protocol::pb::transactor_client::TransactorClient;
27use corium_query::edn::Edn;
28use thiserror::Error;
29use tokio::sync::{broadcast, watch};
30use tonic::Status;
31use tonic::service::interceptor::InterceptedService;
32use tonic::transport::{Channel, ClientTlsConfig, Endpoint};
33
34pub use crate::segment::CachedPeerStorage;
35use crate::segment::{PeerStorage, SnapshotError, load_current_snapshot};
36pub use corium_store::{SegmentCacheConfig, SegmentCacheMetrics};
37
38type Client = TransactorClient<InterceptedService<Channel, TokenInterceptor>>;
39
40/// Peer failure.
41#[derive(Debug, Error)]
42pub enum PeerError {
43    /// Transport-level failure.
44    #[error(transparent)]
45    Transport(#[from] tonic::transport::Error),
46    /// RPC failure.
47    #[error(transparent)]
48    Rpc(#[from] Status),
49    /// Payload failed to decode.
50    #[error(transparent)]
51    Codec(#[from] CodecError),
52    /// Published storage snapshot could not be loaded.
53    #[error(transparent)]
54    Snapshot(#[from] SnapshotError),
55    /// Protocol contract violation.
56    #[error("protocol error: {0}")]
57    Protocol(String),
58    /// The connection background task has stopped.
59    #[error("connection closed")]
60    Closed,
61}
62
63/// Connection configuration.
64#[derive(Clone)]
65pub struct ConnectConfig {
66    /// Candidate transactor endpoints in preference order, e.g.
67    /// `http://127.0.0.1:4334`. With an HA pair, list the active and every
68    /// standby: the connection sticks to whichever endpoint accepts its
69    /// subscription and rotates through the rest on failure, so failover
70    /// needs no reconfiguration.
71    pub endpoints: Vec<String>,
72    /// Database name.
73    pub db: String,
74    /// Optional bearer token.
75    pub token: Option<String>,
76    /// Optional TLS configuration (`https` endpoints).
77    pub tls: Option<ClientTlsConfig>,
78    /// Minimum reconnect backoff.
79    pub reconnect_min: Duration,
80    /// Maximum reconnect backoff.
81    pub reconnect_max: Duration,
82    /// How long [`Connection::transact`] keeps retrying failures that are
83    /// provably pre-commit (standby/deposed rejections, connection
84    /// establishment) while a failover completes. Ambiguous failures — a
85    /// connection that died with the request in flight — are surfaced
86    /// immediately; the transaction may or may not have committed.
87    pub failover_timeout: Duration,
88    /// Heartbeat-silence timeout override. `None` derives three times the
89    /// server-advertised heartbeat interval; streams silent for longer are
90    /// dropped and the connection fails over.
91    pub heartbeat_timeout: Option<Duration>,
92    /// Optional direct blob/root storage used to bootstrap from the newest
93    /// published index before subscribing to the transaction-log tail.
94    storage: Option<Arc<dyn PeerStorage>>,
95    segment_cache_metrics: Option<SegmentCacheMetricsHandle>,
96}
97
98#[derive(Clone)]
99pub(crate) struct SegmentCacheMetricsHandle {
100    pub(crate) metrics: Arc<SegmentCacheMetrics>,
101    pub(crate) disk_capacity: u64,
102    pub(crate) memory_capacity: u64,
103}
104
105impl std::fmt::Debug for ConnectConfig {
106    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        formatter
108            .debug_struct("ConnectConfig")
109            .field("endpoints", &self.endpoints)
110            .field("db", &self.db)
111            .field("token", &self.token.as_ref().map(|_| "[REDACTED]"))
112            .field("tls", &self.tls.is_some())
113            .field("reconnect_min", &self.reconnect_min)
114            .field("reconnect_max", &self.reconnect_max)
115            .field("failover_timeout", &self.failover_timeout)
116            .field("heartbeat_timeout", &self.heartbeat_timeout)
117            .field("storage", &self.storage.is_some())
118            .field("segment_cache", &self.segment_cache_metrics.is_some())
119            .finish()
120    }
121}
122
123impl ConnectConfig {
124    /// Plaintext connection with default backoff.
125    #[must_use]
126    pub fn new(endpoint: impl Into<String>, db: impl Into<String>) -> Self {
127        Self::with_failover(vec![endpoint.into()], db)
128    }
129
130    /// Plaintext connection over an ordered endpoint candidate list.
131    #[must_use]
132    pub fn with_failover(endpoints: Vec<String>, db: impl Into<String>) -> Self {
133        Self {
134            endpoints,
135            db: db.into(),
136            token: None,
137            tls: None,
138            reconnect_min: Duration::from_millis(100),
139            reconnect_max: Duration::from_secs(5),
140            failover_timeout: Duration::from_secs(30),
141            heartbeat_timeout: None,
142            storage: None,
143            segment_cache_metrics: None,
144        }
145    }
146
147    /// Gives this peer direct read access to the transactor's blob/root
148    /// storage service.
149    ///
150    /// A storage-aware connection loads the newest published EAVT snapshot
151    /// and asks the transactor only for transactions after that snapshot.
152    #[must_use]
153    pub fn with_storage(mut self, storage: Arc<dyn PeerStorage>) -> Self {
154        self.storage = Some(storage);
155        self.segment_cache_metrics = None;
156        self
157    }
158
159    /// Gives this peer direct storage access through a bounded local SSD cache.
160    ///
161    /// # Errors
162    /// Returns an error for an invalid, unwritable, or already-owned cache directory.
163    pub fn with_storage_cache(
164        mut self,
165        storage: Arc<dyn PeerStorage>,
166        cache: &SegmentCacheConfig,
167    ) -> std::io::Result<Self> {
168        let cached = CachedPeerStorage::open(storage, cache)?;
169        self.segment_cache_metrics = Some(SegmentCacheMetricsHandle {
170            metrics: cached.metrics(),
171            disk_capacity: cache.capacity_bytes,
172            memory_capacity: cache.memory_capacity_bytes,
173        });
174        self.storage = Some(Arc::new(cached));
175        Ok(self)
176    }
177
178    /// Whether direct peer storage has been configured.
179    #[must_use]
180    pub fn has_storage(&self) -> bool {
181        self.storage.is_some()
182    }
183}
184
185/// One transaction applied to the peer's local database value.
186#[derive(Clone, Debug)]
187pub struct PeerReport {
188    /// Transaction number.
189    pub t: u64,
190    /// Commit timestamp (Unix milliseconds).
191    pub tx_instant: i64,
192    /// Datoms asserted/retracted by the transaction.
193    pub datoms: Vec<Datom>,
194    /// Database value including the transaction.
195    pub db_after: Db,
196}
197
198/// Result of a transaction submitted through a peer.
199#[derive(Clone, Debug)]
200pub struct TxResult {
201    /// Basis before the transaction.
202    pub basis_before: u64,
203    /// The transaction's `t`.
204    pub basis_t: u64,
205    /// Commit timestamp.
206    pub tx_instant: i64,
207    /// Tempid allocations.
208    pub tempids: BTreeMap<String, EntityId>,
209    /// Database value including the transaction.
210    pub db_after: Db,
211}
212
213struct PeerState {
214    schema: Schema,
215    idents: Idents,
216    interner: KeywordInterner,
217    db: Db,
218    instants: BTreeMap<u64, i64>,
219}
220
221struct Inner {
222    config: ConnectConfig,
223    state: RwLock<Option<PeerState>>,
224    basis: watch::Sender<u64>,
225    index_basis: watch::Sender<u64>,
226    reports: broadcast::Sender<PeerReport>,
227    client: Mutex<Client>,
228    /// Index into `config.endpoints` of the endpoint currently serving the
229    /// subscription (and the cached client).
230    endpoint_index: std::sync::atomic::AtomicUsize,
231    /// Server-advertised heartbeat interval (ms); 0 disables the
232    /// heartbeat-silence timeout.
233    heartbeat_ms: std::sync::atomic::AtomicU64,
234}
235
236impl Inner {
237    /// Deadline of stream silence after which the transactor is presumed
238    /// dead and the connection fails over.
239    fn heartbeat_deadline(&self) -> Option<Duration> {
240        if let Some(timeout) = self.config.heartbeat_timeout {
241            return Some(timeout);
242        }
243        let advertised = self.heartbeat_ms.load(std::sync::atomic::Ordering::Relaxed);
244        (advertised > 0).then(|| Duration::from_millis(advertised.saturating_mul(3)))
245    }
246}
247
248/// A live peer connection to a transactor-hosted database.
249pub struct Connection {
250    inner: Arc<Inner>,
251    task: tokio::task::JoinHandle<()>,
252}
253
254impl Drop for Connection {
255    fn drop(&mut self) {
256        self.task.abort();
257    }
258}
259
260async fn open_channel(config: &ConnectConfig, endpoint: &str) -> Result<Channel, PeerError> {
261    let mut endpoint = Endpoint::from_shared(endpoint.to_owned())
262        .map_err(|error| PeerError::Protocol(format!("bad endpoint: {error}")))?
263        .connect_timeout(Duration::from_secs(10));
264    if let Some(tls) = &config.tls {
265        endpoint = endpoint.tls_config(tls.clone())?;
266    }
267    Ok(endpoint.connect().await?)
268}
269
270fn make_client(channel: Channel, token: Option<String>) -> Client {
271    TransactorClient::with_interceptor(channel, TokenInterceptor::new(token))
272}
273
274impl Connection {
275    pub(crate) fn segment_cache_metrics(&self) -> Option<SegmentCacheMetricsHandle> {
276        self.inner.config.segment_cache_metrics.clone()
277    }
278
279    /// Connects and waits until the handshake and required log tail have
280    /// been applied locally. With direct storage configured, the peer first
281    /// loads the newest published index and subscribes from its basis;
282    /// otherwise it subscribes from basis zero. Candidate endpoints are tried
283    /// in order; a standby transactor rejects the subscription and the next
284    /// candidate is tried.
285    ///
286    /// # Errors
287    /// Returns [`PeerError`] when no endpoint accepts the subscription.
288    pub async fn connect(config: ConnectConfig) -> Result<Self, PeerError> {
289        if config.endpoints.is_empty() {
290            return Err(PeerError::Protocol("no endpoints configured".into()));
291        }
292        let snapshot = match &config.storage {
293            Some(storage) => load_current_snapshot(storage.as_ref(), &config.db).await?,
294            None => None,
295        };
296        let start_basis = snapshot.as_ref().map_or(0, Db::basis_t);
297        // Establish the first subscription before returning so `db()` is
298        // populated and connection errors surface synchronously.
299        let mut last_error = PeerError::Closed;
300        let mut first = None;
301        for (index, endpoint) in config.endpoints.iter().enumerate() {
302            match open_channel(&config, endpoint).await {
303                Ok(channel) => {
304                    let mut client = make_client(channel, config.token.clone());
305                    match subscribe_with(&mut client, &config.db, start_basis).await {
306                        Ok(stream) => {
307                            first = Some((index, client, stream));
308                            break;
309                        }
310                        Err(error) => last_error = error,
311                    }
312                }
313                Err(error) => last_error = error,
314            }
315        }
316        let Some((index, client, mut stream)) = first else {
317            return Err(last_error);
318        };
319        let initial_state = snapshot.map(|db| PeerState {
320            schema: db.schema().clone(),
321            idents: db.idents().clone(),
322            interner: db.interner().clone(),
323            db,
324            instants: BTreeMap::new(),
325        });
326        let inner = Arc::new(Inner {
327            state: RwLock::new(initial_state),
328            basis: watch::channel(start_basis).0,
329            index_basis: watch::channel(0).0,
330            reports: broadcast::channel(1024).0,
331            client: Mutex::new(client),
332            endpoint_index: std::sync::atomic::AtomicUsize::new(index),
333            heartbeat_ms: std::sync::atomic::AtomicU64::new(0),
334            config,
335        });
336        let handshake_basis = pump_handshake(&inner, &mut stream).await?;
337        drain_until(&inner, &mut stream, handshake_basis).await?;
338        let task_inner = Arc::clone(&inner);
339        let task = tokio::spawn(async move {
340            run_loop(task_inner, Some(stream)).await;
341        });
342        Ok(Self { inner, task })
343    }
344
345    /// The connected database name.
346    #[must_use]
347    pub fn db_name(&self) -> &str {
348        &self.inner.config.db
349    }
350
351    /// Returns the current local database value without blocking on the
352    /// transactor.
353    ///
354    /// # Panics
355    /// Panics if called before the initial handshake (impossible through
356    /// [`Connection::connect`]).
357    #[must_use]
358    pub fn db(&self) -> Db {
359        self.inner
360            .state
361            .read()
362            .unwrap_or_else(std::sync::PoisonError::into_inner)
363            .as_ref()
364            .expect("connection is initialized")
365            .db
366            .clone()
367    }
368
369    /// Basis of the newest locally applied transaction.
370    #[must_use]
371    pub fn basis_t(&self) -> u64 {
372        *self.inner.basis.subscribe().borrow()
373    }
374
375    /// Basis of the newest published durable index announced by the
376    /// transactor.
377    #[must_use]
378    pub fn index_basis_t(&self) -> u64 {
379        *self.inner.index_basis.subscribe().borrow()
380    }
381
382    /// Subscribes to reports applied after this call.
383    #[must_use]
384    pub fn tx_reports(&self) -> broadcast::Receiver<PeerReport> {
385        self.inner.reports.subscribe()
386    }
387
388    /// Transaction instants recorded locally for `[start, end)`, paired
389    /// with their datoms (the peer-side `tx-range`).
390    #[must_use]
391    pub fn tx_range(&self, start: u64, end: Option<u64>) -> Vec<TxRecord> {
392        let guard = self
393            .inner
394            .state
395            .read()
396            .unwrap_or_else(std::sync::PoisonError::into_inner);
397        let Some(state) = guard.as_ref() else {
398            return Vec::new();
399        };
400        state
401            .db
402            .tx_range(start, end)
403            .into_iter()
404            .map(|(t, datoms)| TxRecord {
405                t,
406                tx_instant: state.instants.get(&t).copied().unwrap_or_default(),
407                datoms,
408            })
409            .collect()
410    }
411
412    /// Submits a transaction (EDN transaction forms) and waits until it is
413    /// applied locally, so a following [`Connection::db`] observes it.
414    ///
415    /// Failures that are provably pre-commit — a standby or deposed
416    /// transactor rejecting the request, or a connection that could not be
417    /// established — are retried until [`ConnectConfig::failover_timeout`],
418    /// riding out an HA takeover. A connection that dies with the request
419    /// in flight is ambiguous (the transaction may or may not have
420    /// committed) and surfaces as an error, exactly like a transactor
421    /// crash between durability and reply; callers decide whether to check
422    /// and resubmit.
423    ///
424    /// # Errors
425    /// Returns [`PeerError`] for rejected transactions or transport failure.
426    pub async fn transact(&self, forms: Vec<Edn>) -> Result<TxResult, PeerError> {
427        self.transact_at(forms, None).await
428    }
429
430    /// Submits a transaction conditionally against `expected_basis_t`.
431    ///
432    /// # Errors
433    /// Returns [`PeerError`] for a stale basis, rejected transaction, or
434    /// transport failure.
435    pub async fn transact_at(
436        &self,
437        forms: Vec<Edn>,
438        expected_basis_t: Option<u64>,
439    ) -> Result<TxResult, PeerError> {
440        let tx_data = codec::encode_edn(&Edn::Vector(forms));
441        let deadline = tokio::time::Instant::now() + self.inner.config.failover_timeout;
442        let response = loop {
443            match self
444                .transact_raw_at(tx_data.clone(), expected_basis_t)
445                .await
446            {
447                Ok(response) => break response,
448                Err(error) if retry_is_safe(&error) && tokio::time::Instant::now() < deadline => {
449                    tokio::time::sleep(self.inner.config.reconnect_min).await;
450                }
451                Err(error) => return Err(error),
452            }
453        };
454        let tempids = decode_tempids(&response.tempids)?;
455        let db_after = self.sync_to(response.basis_t).await?;
456        Ok(TxResult {
457            basis_before: response.basis_before,
458            basis_t: response.basis_t,
459            tx_instant: response.tx_instant,
460            tempids,
461            db_after,
462        })
463    }
464
465    /// Submits already-encoded transaction data, returning the raw wire
466    /// response (used by the peer server's transact proxy).
467    ///
468    /// # Errors
469    /// Returns [`PeerError`] for rejected transactions or transport failure.
470    pub async fn transact_raw(&self, tx_data: Vec<u8>) -> Result<pb::TransactResponse, PeerError> {
471        self.transact_raw_at(tx_data, None).await
472    }
473
474    /// Submits already-encoded transaction data with an optional basis fence.
475    ///
476    /// # Errors
477    /// Returns [`PeerError`] for a stale basis, rejected transaction, or
478    /// transport failure.
479    pub async fn transact_raw_at(
480        &self,
481        tx_data: Vec<u8>,
482        expected_basis_t: Option<u64>,
483    ) -> Result<pb::TransactResponse, PeerError> {
484        let request = pb::TransactRequest {
485            db: self.inner.config.db.clone(),
486            protocol_version: corium_protocol::PROTOCOL_VERSION,
487            tx_data,
488            expected_basis_t,
489        };
490        let mut client = self
491            .inner
492            .client
493            .lock()
494            .unwrap_or_else(std::sync::PoisonError::into_inner)
495            .clone();
496        Ok(client.transact(request).await?.into_inner())
497    }
498
499    /// Waits until the local basis reaches the transactor's current basis.
500    ///
501    /// # Errors
502    /// Returns [`PeerError`] on transport failure.
503    pub async fn sync(&self) -> Result<Db, PeerError> {
504        let mut client = self
505            .inner
506            .client
507            .lock()
508            .unwrap_or_else(std::sync::PoisonError::into_inner)
509            .clone();
510        let response = client
511            .sync(pb::SyncRequest {
512                db: self.inner.config.db.clone(),
513                t: 0,
514            })
515            .await?
516            .into_inner();
517        self.sync_to(response.basis_t).await
518    }
519
520    /// Waits until the local basis reaches `t`, returning the database value.
521    ///
522    /// # Errors
523    /// Returns [`PeerError::Closed`] if the connection task stops.
524    pub async fn sync_to(&self, t: u64) -> Result<Db, PeerError> {
525        let mut basis = self.inner.basis.subscribe();
526        loop {
527            if *basis.borrow() >= t {
528                return Ok(self.db());
529            }
530            basis.changed().await.map_err(|_| PeerError::Closed)?;
531        }
532    }
533
534    /// Transactor-side status for the connected database.
535    ///
536    /// # Errors
537    /// Returns [`PeerError`] on transport failure.
538    pub async fn status(&self) -> Result<pb::StatusResponse, PeerError> {
539        let mut client = self
540            .inner
541            .client
542            .lock()
543            .unwrap_or_else(std::sync::PoisonError::into_inner)
544            .clone();
545        Ok(client
546            .status(pb::StatusRequest {
547                db: self.inner.config.db.clone(),
548            })
549            .await?
550            .into_inner())
551    }
552
553    /// Opens an independent upstream subscription (used by the peer server
554    /// to relay tx-report streams to thin clients).
555    ///
556    /// # Errors
557    /// Returns [`PeerError`] on transport failure.
558    pub async fn subscribe_raw(
559        &self,
560        from_basis_t: u64,
561    ) -> Result<tonic::Streaming<pb::SubscribeItem>, PeerError> {
562        let mut client = self
563            .inner
564            .client
565            .lock()
566            .unwrap_or_else(std::sync::PoisonError::into_inner)
567            .clone();
568        Ok(client
569            .subscribe(pb::SubscribeRequest {
570                db: self.inner.config.db.clone(),
571                protocol_version: corium_protocol::PROTOCOL_VERSION,
572                from_basis_t,
573            })
574            .await?
575            .into_inner())
576    }
577}
578
579/// Whether a transact failure is provably pre-commit and therefore safe to
580/// retry without risking a duplicate transaction.
581fn retry_is_safe(error: &PeerError) -> bool {
582    match error {
583        // The channel could not even be built; no request was sent.
584        PeerError::Transport(_) => true,
585        PeerError::Rpc(status) => match status.code() {
586            // A standby (not lease holder) or freshly deposed transactor
587            // refuses before reaching the commit point.
588            tonic::Code::FailedPrecondition => {
589                let message = status.message();
590                message.contains("standby") || message.contains("deposed")
591            }
592            // Unavailable with a connect-phase source means the request was
593            // never sent. Anything else (a connection that died mid-call)
594            // is ambiguous and must surface.
595            tonic::Code::Unavailable => status.message().contains("connect"),
596            _ => false,
597        },
598        _ => false,
599    }
600}
601
602fn decode_tempids(bytes: &[u8]) -> Result<BTreeMap<String, EntityId>, PeerError> {
603    let Edn::Map(pairs) = codec::decode_edn(bytes)? else {
604        return Err(PeerError::Protocol("tempids must be a map".into()));
605    };
606    let mut tempids = BTreeMap::new();
607    for (key, value) in pairs {
608        let (Edn::Str(name), Edn::Long(raw)) = (key, value) else {
609            return Err(PeerError::Protocol("bad tempid entry".into()));
610        };
611        let raw = u64::try_from(raw).map_err(|_| PeerError::Protocol("bad entity id".into()))?;
612        tempids.insert(name, EntityId::from_raw(raw));
613    }
614    Ok(tempids)
615}
616
617async fn subscribe_with(
618    client: &mut Client,
619    db: &str,
620    from_basis_t: u64,
621) -> Result<tonic::Streaming<pb::SubscribeItem>, PeerError> {
622    Ok(client
623        .subscribe(pb::SubscribeRequest {
624            db: db.to_owned(),
625            protocol_version: corium_protocol::PROTOCOL_VERSION,
626            from_basis_t,
627        })
628        .await?
629        .into_inner())
630}
631
632/// Consumes the stream's handshake, installing schema/naming, and returns
633/// the server basis at subscription time.
634async fn pump_handshake(
635    inner: &Arc<Inner>,
636    stream: &mut tonic::Streaming<pb::SubscribeItem>,
637) -> Result<u64, PeerError> {
638    let first = stream
639        .message()
640        .await?
641        .and_then(|item| item.item)
642        .ok_or_else(|| PeerError::Protocol("subscription ended before handshake".into()))?;
643    let pb::subscribe_item::Item::Handshake(handshake) = first else {
644        return Err(PeerError::Protocol(
645            "subscription must begin with a handshake".into(),
646        ));
647    };
648    let local_basis = *inner.basis.subscribe().borrow();
649    if handshake.basis_t < local_basis {
650        return Err(PeerError::Protocol(format!(
651            "published snapshot basis {local_basis} is newer than transactor basis {}",
652            handshake.basis_t
653        )));
654    }
655    let (schema, idents) = codec::decode_schema(&handshake.schema)?;
656    inner.heartbeat_ms.store(
657        handshake.heartbeat_interval_ms,
658        std::sync::atomic::Ordering::Relaxed,
659    );
660    {
661        let mut guard = inner
662            .state
663            .write()
664            .unwrap_or_else(std::sync::PoisonError::into_inner);
665        if let Some(state) = guard.as_mut() {
666            // Reconnect: schema/idents are fixed after bootstrap; keep the
667            // locally accumulated database value and naming.
668            state.schema = schema;
669            state.idents = idents;
670        } else {
671            let interner = KeywordInterner::default();
672            let db = Db::new(schema.clone()).with_naming(idents.clone(), interner.clone());
673            *guard = Some(PeerState {
674                schema,
675                idents,
676                interner,
677                db,
678                instants: BTreeMap::new(),
679            });
680        }
681    }
682    let _ = inner.index_basis.send_replace(handshake.index_basis_t);
683    Ok(handshake.basis_t)
684}
685
686/// Applies stream items until the local basis reaches `target`.
687async fn drain_until(
688    inner: &Arc<Inner>,
689    stream: &mut tonic::Streaming<pb::SubscribeItem>,
690    target: u64,
691) -> Result<(), PeerError> {
692    while *inner.basis.subscribe().borrow() < target {
693        let Some(item) = stream.message().await? else {
694            return Err(PeerError::Protocol(
695                "subscription ended during backfill".into(),
696            ));
697        };
698        if let Some(item) = item.item {
699            apply_item(inner, item)?;
700        }
701    }
702    Ok(())
703}
704
705fn apply_item(inner: &Arc<Inner>, item: pb::subscribe_item::Item) -> Result<(), PeerError> {
706    match item {
707        pb::subscribe_item::Item::Report(report) => {
708            let peer_report = {
709                let mut guard = inner
710                    .state
711                    .write()
712                    .unwrap_or_else(std::sync::PoisonError::into_inner);
713                let state = guard
714                    .as_mut()
715                    .ok_or_else(|| PeerError::Protocol("report before handshake".into()))?;
716                if report.t <= state.db.basis_t() {
717                    return Ok(());
718                }
719                let before = state.interner.len();
720                let datoms = codec::decode_datoms(&report.datoms, &mut state.interner)?;
721                let tx_instant =
722                    bootstrap::asserted_instant(report.t, &datoms).unwrap_or(report.tx_instant);
723                if state.interner.len() > before {
724                    state.db = state
725                        .db
726                        .clone()
727                        .with_naming(state.idents.clone(), state.interner.clone());
728                }
729                state.db = state.db.with_transaction_at(report.t, tx_instant, &datoms);
730                state.instants.insert(report.t, tx_instant);
731                PeerReport {
732                    t: report.t,
733                    tx_instant,
734                    datoms,
735                    db_after: state.db.clone(),
736                }
737            };
738            let _ = inner.basis.send_replace(peer_report.t);
739            let _ = inner.reports.send(peer_report);
740        }
741        pb::subscribe_item::Item::IndexBasis(index) => {
742            let _ = inner.index_basis.send_replace(index.index_basis_t);
743        }
744        pb::subscribe_item::Item::Heartbeat(_) | pb::subscribe_item::Item::Handshake(_) => {}
745    }
746    Ok(())
747}
748
749/// Long-running consume/reconnect loop: on stream end, error, or heartbeat
750/// silence past the timeout, rebuilds the channel with exponential backoff
751/// and resubscribes from the local basis; the server backfills the gap.
752/// Reconnection rotates through the candidate endpoints, so when the
753/// active transactor dies the loop lands on whichever standby takes over
754/// the lease (a standby rejects the subscription until then).
755async fn run_loop(inner: Arc<Inner>, initial: Option<tonic::Streaming<pb::SubscribeItem>>) {
756    let mut stream = initial;
757    let mut backoff = inner.config.reconnect_min;
758    let mut candidate = inner
759        .endpoint_index
760        .load(std::sync::atomic::Ordering::Relaxed);
761    loop {
762        if let Some(active) = stream.as_mut() {
763            let next = match inner.heartbeat_deadline() {
764                Some(deadline) => match tokio::time::timeout(deadline, active.message()).await {
765                    Ok(next) => next,
766                    // Heartbeat silence: the transactor is presumed dead
767                    // even though the transport has not noticed (partition,
768                    // stalled process); drop the stream and fail over.
769                    Err(_elapsed) => Ok(None),
770                },
771                None => active.message().await,
772            };
773            match next {
774                Ok(Some(item)) => {
775                    backoff = inner.config.reconnect_min;
776                    if let Some(item) = item.item
777                        && apply_item(&inner, item).is_err()
778                    {
779                        stream = None;
780                    }
781                    continue;
782                }
783                Ok(None) | Err(_) => {
784                    stream = None;
785                }
786            }
787        }
788        tokio::time::sleep(backoff).await;
789        backoff = (backoff * 2).min(inner.config.reconnect_max);
790        let endpoints = &inner.config.endpoints;
791        let endpoint = &endpoints[candidate % endpoints.len()];
792        let Ok(channel) = open_channel(&inner.config, endpoint).await else {
793            candidate = (candidate + 1) % endpoints.len();
794            continue;
795        };
796        let mut client = make_client(channel, inner.config.token.clone());
797        let from = *inner.basis.subscribe().borrow();
798        match subscribe_with(&mut client, &inner.config.db, from).await {
799            Ok(mut fresh) => {
800                if pump_handshake(&inner, &mut fresh).await.is_ok() {
801                    // Sticky success: transact/status/sync now go here too.
802                    *inner
803                        .client
804                        .lock()
805                        .unwrap_or_else(std::sync::PoisonError::into_inner) = client;
806                    inner.endpoint_index.store(
807                        candidate % endpoints.len(),
808                        std::sync::atomic::Ordering::Relaxed,
809                    );
810                    stream = Some(fresh);
811                } else {
812                    candidate = (candidate + 1) % endpoints.len();
813                }
814            }
815            Err(_) => {
816                candidate = (candidate + 1) % endpoints.len();
817            }
818        }
819    }
820}
821
822/// Catalog (admin) client for a transactor endpoint.
823pub struct Admin {
824    client: CatalogClient<InterceptedService<Channel, TokenInterceptor>>,
825}
826
827impl Admin {
828    /// Connects to a transactor's catalog service.
829    ///
830    /// # Errors
831    /// Returns [`PeerError`] when the endpoint is unreachable.
832    pub async fn connect(
833        endpoint: &str,
834        token: Option<String>,
835        tls: Option<ClientTlsConfig>,
836    ) -> Result<Self, PeerError> {
837        let config = ConnectConfig {
838            tls,
839            token: token.clone(),
840            ..ConnectConfig::new(endpoint, "")
841        };
842        let channel = open_channel(&config, endpoint).await?;
843        Ok(Self {
844            client: CatalogClient::with_interceptor(channel, TokenInterceptor::new(token)),
845        })
846    }
847
848    /// Creates a database with EDN schema forms; `false` when it existed.
849    ///
850    /// # Errors
851    /// Returns [`PeerError`] for invalid schema or transport failure.
852    pub async fn create_database(&mut self, db: &str, schema: &[Edn]) -> Result<bool, PeerError> {
853        let response = self
854            .client
855            .create_database(pb::CreateDatabaseRequest {
856                db: db.to_owned(),
857                schema: codec::encode_edn(&Edn::Vector(schema.to_vec())),
858            })
859            .await?;
860        Ok(response.into_inner().created)
861    }
862
863    /// Forks `db` into a new database `target` duplicating it at
864    /// transaction basis `as_of_t` (`None` forks at the current basis).
865    /// Returns the fork's basis, or `None` when the target already existed.
866    ///
867    /// # Errors
868    /// Returns [`PeerError`] on transport failure, an unknown source, an
869    /// invalid target name, or a basis ahead of the source's.
870    pub async fn fork_database(
871        &mut self,
872        db: &str,
873        target: &str,
874        as_of_t: Option<u64>,
875    ) -> Result<Option<u64>, PeerError> {
876        let response = self
877            .client
878            .fork_database(pb::ForkDatabaseRequest {
879                db: db.to_owned(),
880                target: target.to_owned(),
881                as_of_t: as_of_t.unwrap_or(0),
882            })
883            .await?
884            .into_inner();
885        Ok(response.created.then_some(response.basis_t))
886    }
887
888    /// Deletes a database; `false` when it did not exist.
889    ///
890    /// # Errors
891    /// Returns [`PeerError`] on transport failure.
892    pub async fn delete_database(&mut self, db: &str) -> Result<bool, PeerError> {
893        let response = self
894            .client
895            .delete_database(pb::DeleteDatabaseRequest { db: db.to_owned() })
896            .await?;
897        Ok(response.into_inner().deleted)
898    }
899
900    /// Lists hosted databases.
901    ///
902    /// # Errors
903    /// Returns [`PeerError`] on transport failure.
904    pub async fn list_databases(&mut self) -> Result<Vec<String>, PeerError> {
905        let response = self
906            .client
907            .list_databases(pb::ListDatabasesRequest {})
908            .await?;
909        Ok(response.into_inner().dbs)
910    }
911
912    /// Sweeps blobs unreachable from every live database root.
913    ///
914    /// # Errors
915    /// Returns [`PeerError`] on transport failure.
916    pub async fn gc_deleted_databases(&mut self) -> Result<u64, PeerError> {
917        self.gc_deleted_databases_with_retention(None).await
918    }
919
920    /// Sweeps unreachable blobs with an optional minimum retention window.
921    ///
922    /// # Errors
923    /// Returns [`PeerError`] on transport failure.
924    pub async fn gc_deleted_databases_with_retention(
925        &mut self,
926        retention: Option<Duration>,
927    ) -> Result<u64, PeerError> {
928        let response = self
929            .client
930            .gc_deleted_databases(pb::GcDeletedDatabasesRequest {
931                retention_millis: retention.map(duration_millis),
932            })
933            .await?;
934        Ok(response.into_inner().swept_blobs)
935    }
936
937    /// Asks the transactor to publish `db`'s covering indexes now,
938    /// bypassing its pacing policy; returns the resulting index basis.
939    ///
940    /// # Errors
941    /// Returns [`PeerError`] on transport failure or an unknown database.
942    pub async fn request_index(&mut self, db: &str) -> Result<u64, PeerError> {
943        let response = self
944            .client
945            .request_index(pb::RequestIndexRequest { db: db.to_owned() })
946            .await?;
947        Ok(response.into_inner().index_basis_t)
948    }
949
950    /// Overrides `db`'s index-publication pacing at runtime; `None` fields
951    /// are left unchanged, so an all-`None` update reads the current
952    /// policy. Returns the policy now in effect.
953    ///
954    /// # Errors
955    /// Returns [`PeerError`] on transport failure or an unknown database.
956    pub async fn set_index_policy(
957        &mut self,
958        db: &str,
959        update: IndexPolicySettings,
960    ) -> Result<IndexPolicySettings, PeerError> {
961        let response = self
962            .client
963            .set_index_policy(pb::SetIndexPolicyRequest {
964                db: db.to_owned(),
965                interval_ms: update.interval_ms,
966                backoff: update.backoff,
967                tail_threshold: update.tail_threshold,
968                tail_deadline_ms: update.tail_deadline_ms,
969            })
970            .await?
971            .into_inner();
972        Ok(IndexPolicySettings {
973            interval_ms: Some(response.interval_ms),
974            backoff: Some(response.backoff),
975            tail_threshold: Some(response.tail_threshold),
976            tail_deadline_ms: Some(response.tail_deadline_ms),
977        })
978    }
979
980    /// Fixes the current transaction basis and returns an independent
981    /// connection to the transactor's underlying storage service, for a
982    /// backup client or a storage-aware peer bootstrapping from storage.
983    ///
984    /// # Errors
985    /// Returns [`PeerError`] for an unknown database or transport failure.
986    pub async fn get_storage_info(
987        &mut self,
988        db: &str,
989    ) -> Result<pb::GetStorageInfoResponse, PeerError> {
990        Ok(self
991            .client
992            .get_storage_info(pb::GetStorageInfoRequest { db: db.to_owned() })
993            .await?
994            .into_inner())
995    }
996}
997
998/// Index-publication pacing fields for [`Admin::set_index_policy`]: `None`
999/// in a request leaves the field unchanged; responses set every field to
1000/// the effective policy.
1001#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1002pub struct IndexPolicySettings {
1003    /// Base interval between publications (ms).
1004    pub interval_ms: Option<u64>,
1005    /// Minimum wait before the next publication, as a multiple of the
1006    /// previous publication's duration (0 disables the backoff).
1007    pub backoff: Option<u32>,
1008    /// Pending-datom count below which a due publication is deferred
1009    /// (0 publishes any pending work).
1010    pub tail_threshold: Option<u64>,
1011    /// Longest a below-threshold tail defers publication (ms).
1012    pub tail_deadline_ms: Option<u64>,
1013}
1014
1015fn duration_millis(duration: Duration) -> u64 {
1016    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021    use super::*;
1022
1023    #[test]
1024    fn gc_retention_wire_value_preserves_zero_and_subseconds() {
1025        assert_eq!(duration_millis(Duration::ZERO), 0);
1026        assert_eq!(duration_millis(Duration::from_millis(500)), 500);
1027    }
1028}