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};
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                if state.interner.len() > before {
722                    state.db = state
723                        .db
724                        .clone()
725                        .with_naming(state.idents.clone(), state.interner.clone());
726                }
727                state.db = state.db.with_transaction(report.t, &datoms);
728                state.instants.insert(report.t, report.tx_instant);
729                PeerReport {
730                    t: report.t,
731                    tx_instant: report.tx_instant,
732                    datoms,
733                    db_after: state.db.clone(),
734                }
735            };
736            let _ = inner.basis.send_replace(peer_report.t);
737            let _ = inner.reports.send(peer_report);
738        }
739        pb::subscribe_item::Item::IndexBasis(index) => {
740            let _ = inner.index_basis.send_replace(index.index_basis_t);
741        }
742        pb::subscribe_item::Item::Heartbeat(_) | pb::subscribe_item::Item::Handshake(_) => {}
743    }
744    Ok(())
745}
746
747/// Long-running consume/reconnect loop: on stream end, error, or heartbeat
748/// silence past the timeout, rebuilds the channel with exponential backoff
749/// and resubscribes from the local basis; the server backfills the gap.
750/// Reconnection rotates through the candidate endpoints, so when the
751/// active transactor dies the loop lands on whichever standby takes over
752/// the lease (a standby rejects the subscription until then).
753async fn run_loop(inner: Arc<Inner>, initial: Option<tonic::Streaming<pb::SubscribeItem>>) {
754    let mut stream = initial;
755    let mut backoff = inner.config.reconnect_min;
756    let mut candidate = inner
757        .endpoint_index
758        .load(std::sync::atomic::Ordering::Relaxed);
759    loop {
760        if let Some(active) = stream.as_mut() {
761            let next = match inner.heartbeat_deadline() {
762                Some(deadline) => match tokio::time::timeout(deadline, active.message()).await {
763                    Ok(next) => next,
764                    // Heartbeat silence: the transactor is presumed dead
765                    // even though the transport has not noticed (partition,
766                    // stalled process); drop the stream and fail over.
767                    Err(_elapsed) => Ok(None),
768                },
769                None => active.message().await,
770            };
771            match next {
772                Ok(Some(item)) => {
773                    backoff = inner.config.reconnect_min;
774                    if let Some(item) = item.item
775                        && apply_item(&inner, item).is_err()
776                    {
777                        stream = None;
778                    }
779                    continue;
780                }
781                Ok(None) | Err(_) => {
782                    stream = None;
783                }
784            }
785        }
786        tokio::time::sleep(backoff).await;
787        backoff = (backoff * 2).min(inner.config.reconnect_max);
788        let endpoints = &inner.config.endpoints;
789        let endpoint = &endpoints[candidate % endpoints.len()];
790        let Ok(channel) = open_channel(&inner.config, endpoint).await else {
791            candidate = (candidate + 1) % endpoints.len();
792            continue;
793        };
794        let mut client = make_client(channel, inner.config.token.clone());
795        let from = *inner.basis.subscribe().borrow();
796        match subscribe_with(&mut client, &inner.config.db, from).await {
797            Ok(mut fresh) => {
798                if pump_handshake(&inner, &mut fresh).await.is_ok() {
799                    // Sticky success: transact/status/sync now go here too.
800                    *inner
801                        .client
802                        .lock()
803                        .unwrap_or_else(std::sync::PoisonError::into_inner) = client;
804                    inner.endpoint_index.store(
805                        candidate % endpoints.len(),
806                        std::sync::atomic::Ordering::Relaxed,
807                    );
808                    stream = Some(fresh);
809                } else {
810                    candidate = (candidate + 1) % endpoints.len();
811                }
812            }
813            Err(_) => {
814                candidate = (candidate + 1) % endpoints.len();
815            }
816        }
817    }
818}
819
820/// Catalog (admin) client for a transactor endpoint.
821pub struct Admin {
822    client: CatalogClient<InterceptedService<Channel, TokenInterceptor>>,
823}
824
825impl Admin {
826    /// Connects to a transactor's catalog service.
827    ///
828    /// # Errors
829    /// Returns [`PeerError`] when the endpoint is unreachable.
830    pub async fn connect(
831        endpoint: &str,
832        token: Option<String>,
833        tls: Option<ClientTlsConfig>,
834    ) -> Result<Self, PeerError> {
835        let config = ConnectConfig {
836            tls,
837            token: token.clone(),
838            ..ConnectConfig::new(endpoint, "")
839        };
840        let channel = open_channel(&config, endpoint).await?;
841        Ok(Self {
842            client: CatalogClient::with_interceptor(channel, TokenInterceptor::new(token)),
843        })
844    }
845
846    /// Creates a database with EDN schema forms; `false` when it existed.
847    ///
848    /// # Errors
849    /// Returns [`PeerError`] for invalid schema or transport failure.
850    pub async fn create_database(&mut self, db: &str, schema: &[Edn]) -> Result<bool, PeerError> {
851        let response = self
852            .client
853            .create_database(pb::CreateDatabaseRequest {
854                db: db.to_owned(),
855                schema: codec::encode_edn(&Edn::Vector(schema.to_vec())),
856            })
857            .await?;
858        Ok(response.into_inner().created)
859    }
860
861    /// Forks `db` into a new database `target` duplicating it at
862    /// transaction basis `as_of_t` (`None` forks at the current basis).
863    /// Returns the fork's basis, or `None` when the target already existed.
864    ///
865    /// # Errors
866    /// Returns [`PeerError`] on transport failure, an unknown source, an
867    /// invalid target name, or a basis ahead of the source's.
868    pub async fn fork_database(
869        &mut self,
870        db: &str,
871        target: &str,
872        as_of_t: Option<u64>,
873    ) -> Result<Option<u64>, PeerError> {
874        let response = self
875            .client
876            .fork_database(pb::ForkDatabaseRequest {
877                db: db.to_owned(),
878                target: target.to_owned(),
879                as_of_t: as_of_t.unwrap_or(0),
880            })
881            .await?
882            .into_inner();
883        Ok(response.created.then_some(response.basis_t))
884    }
885
886    /// Deletes a database; `false` when it did not exist.
887    ///
888    /// # Errors
889    /// Returns [`PeerError`] on transport failure.
890    pub async fn delete_database(&mut self, db: &str) -> Result<bool, PeerError> {
891        let response = self
892            .client
893            .delete_database(pb::DeleteDatabaseRequest { db: db.to_owned() })
894            .await?;
895        Ok(response.into_inner().deleted)
896    }
897
898    /// Lists hosted databases.
899    ///
900    /// # Errors
901    /// Returns [`PeerError`] on transport failure.
902    pub async fn list_databases(&mut self) -> Result<Vec<String>, PeerError> {
903        let response = self
904            .client
905            .list_databases(pb::ListDatabasesRequest {})
906            .await?;
907        Ok(response.into_inner().dbs)
908    }
909
910    /// Sweeps blobs unreachable from every live database root.
911    ///
912    /// # Errors
913    /// Returns [`PeerError`] on transport failure.
914    pub async fn gc_deleted_databases(&mut self) -> Result<u64, PeerError> {
915        self.gc_deleted_databases_with_retention(None).await
916    }
917
918    /// Sweeps unreachable blobs with an optional minimum retention window.
919    ///
920    /// # Errors
921    /// Returns [`PeerError`] on transport failure.
922    pub async fn gc_deleted_databases_with_retention(
923        &mut self,
924        retention: Option<Duration>,
925    ) -> Result<u64, PeerError> {
926        let response = self
927            .client
928            .gc_deleted_databases(pb::GcDeletedDatabasesRequest {
929                retention_millis: retention.map(duration_millis),
930            })
931            .await?;
932        Ok(response.into_inner().swept_blobs)
933    }
934
935    /// Asks the transactor to publish `db`'s covering indexes now,
936    /// bypassing its pacing policy; returns the resulting index basis.
937    ///
938    /// # Errors
939    /// Returns [`PeerError`] on transport failure or an unknown database.
940    pub async fn request_index(&mut self, db: &str) -> Result<u64, PeerError> {
941        let response = self
942            .client
943            .request_index(pb::RequestIndexRequest { db: db.to_owned() })
944            .await?;
945        Ok(response.into_inner().index_basis_t)
946    }
947
948    /// Overrides `db`'s index-publication pacing at runtime; `None` fields
949    /// are left unchanged, so an all-`None` update reads the current
950    /// policy. Returns the policy now in effect.
951    ///
952    /// # Errors
953    /// Returns [`PeerError`] on transport failure or an unknown database.
954    pub async fn set_index_policy(
955        &mut self,
956        db: &str,
957        update: IndexPolicySettings,
958    ) -> Result<IndexPolicySettings, PeerError> {
959        let response = self
960            .client
961            .set_index_policy(pb::SetIndexPolicyRequest {
962                db: db.to_owned(),
963                interval_ms: update.interval_ms,
964                backoff: update.backoff,
965                tail_threshold: update.tail_threshold,
966                tail_deadline_ms: update.tail_deadline_ms,
967            })
968            .await?
969            .into_inner();
970        Ok(IndexPolicySettings {
971            interval_ms: Some(response.interval_ms),
972            backoff: Some(response.backoff),
973            tail_threshold: Some(response.tail_threshold),
974            tail_deadline_ms: Some(response.tail_deadline_ms),
975        })
976    }
977
978    /// Fixes the current transaction basis and returns an independent
979    /// connection to the transactor's underlying storage service, for a
980    /// backup client or a storage-aware peer bootstrapping from storage.
981    ///
982    /// # Errors
983    /// Returns [`PeerError`] for an unknown database or transport failure.
984    pub async fn get_storage_info(
985        &mut self,
986        db: &str,
987    ) -> Result<pb::GetStorageInfoResponse, PeerError> {
988        Ok(self
989            .client
990            .get_storage_info(pb::GetStorageInfoRequest { db: db.to_owned() })
991            .await?
992            .into_inner())
993    }
994}
995
996/// Index-publication pacing fields for [`Admin::set_index_policy`]: `None`
997/// in a request leaves the field unchanged; responses set every field to
998/// the effective policy.
999#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
1000pub struct IndexPolicySettings {
1001    /// Base interval between publications (ms).
1002    pub interval_ms: Option<u64>,
1003    /// Minimum wait before the next publication, as a multiple of the
1004    /// previous publication's duration (0 disables the backoff).
1005    pub backoff: Option<u32>,
1006    /// Pending-datom count below which a due publication is deferred
1007    /// (0 publishes any pending work).
1008    pub tail_threshold: Option<u64>,
1009    /// Longest a below-threshold tail defers publication (ms).
1010    pub tail_deadline_ms: Option<u64>,
1011}
1012
1013fn duration_millis(duration: Duration) -> u64 {
1014    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
1015}
1016
1017#[cfg(test)]
1018mod tests {
1019    use super::*;
1020
1021    #[test]
1022    fn gc_retention_wire_value_preserves_zero_and_subseconds() {
1023        assert_eq!(duration_millis(Duration::ZERO), 0);
1024        assert_eq!(duration_millis(Duration::from_millis(500)), 500);
1025    }
1026}