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