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        let tx_data = codec::encode_edn(&Edn::Vector(forms));
428        let deadline = tokio::time::Instant::now() + self.inner.config.failover_timeout;
429        let response = loop {
430            match self.transact_raw(tx_data.clone()).await {
431                Ok(response) => break response,
432                Err(error) if retry_is_safe(&error) && tokio::time::Instant::now() < deadline => {
433                    tokio::time::sleep(self.inner.config.reconnect_min).await;
434                }
435                Err(error) => return Err(error),
436            }
437        };
438        let tempids = decode_tempids(&response.tempids)?;
439        let db_after = self.sync_to(response.basis_t).await?;
440        Ok(TxResult {
441            basis_before: response.basis_before,
442            basis_t: response.basis_t,
443            tx_instant: response.tx_instant,
444            tempids,
445            db_after,
446        })
447    }
448
449    /// Submits already-encoded transaction data, returning the raw wire
450    /// response (used by the peer server's transact proxy).
451    ///
452    /// # Errors
453    /// Returns [`PeerError`] for rejected transactions or transport failure.
454    pub async fn transact_raw(&self, tx_data: Vec<u8>) -> Result<pb::TransactResponse, PeerError> {
455        let request = pb::TransactRequest {
456            db: self.inner.config.db.clone(),
457            protocol_version: corium_protocol::PROTOCOL_VERSION,
458            tx_data,
459        };
460        let mut client = self
461            .inner
462            .client
463            .lock()
464            .unwrap_or_else(std::sync::PoisonError::into_inner)
465            .clone();
466        Ok(client.transact(request).await?.into_inner())
467    }
468
469    /// Waits until the local basis reaches the transactor's current basis.
470    ///
471    /// # Errors
472    /// Returns [`PeerError`] on transport failure.
473    pub async fn sync(&self) -> Result<Db, PeerError> {
474        let mut client = self
475            .inner
476            .client
477            .lock()
478            .unwrap_or_else(std::sync::PoisonError::into_inner)
479            .clone();
480        let response = client
481            .sync(pb::SyncRequest {
482                db: self.inner.config.db.clone(),
483                t: 0,
484            })
485            .await?
486            .into_inner();
487        self.sync_to(response.basis_t).await
488    }
489
490    /// Waits until the local basis reaches `t`, returning the database value.
491    ///
492    /// # Errors
493    /// Returns [`PeerError::Closed`] if the connection task stops.
494    pub async fn sync_to(&self, t: u64) -> Result<Db, PeerError> {
495        let mut basis = self.inner.basis.subscribe();
496        loop {
497            if *basis.borrow() >= t {
498                return Ok(self.db());
499            }
500            basis.changed().await.map_err(|_| PeerError::Closed)?;
501        }
502    }
503
504    /// Transactor-side status for the connected database.
505    ///
506    /// # Errors
507    /// Returns [`PeerError`] on transport failure.
508    pub async fn status(&self) -> Result<pb::StatusResponse, PeerError> {
509        let mut client = self
510            .inner
511            .client
512            .lock()
513            .unwrap_or_else(std::sync::PoisonError::into_inner)
514            .clone();
515        Ok(client
516            .status(pb::StatusRequest {
517                db: self.inner.config.db.clone(),
518            })
519            .await?
520            .into_inner())
521    }
522
523    /// Opens an independent upstream subscription (used by the peer server
524    /// to relay tx-report streams to thin clients).
525    ///
526    /// # Errors
527    /// Returns [`PeerError`] on transport failure.
528    pub async fn subscribe_raw(
529        &self,
530        from_basis_t: u64,
531    ) -> Result<tonic::Streaming<pb::SubscribeItem>, PeerError> {
532        let mut client = self
533            .inner
534            .client
535            .lock()
536            .unwrap_or_else(std::sync::PoisonError::into_inner)
537            .clone();
538        Ok(client
539            .subscribe(pb::SubscribeRequest {
540                db: self.inner.config.db.clone(),
541                protocol_version: corium_protocol::PROTOCOL_VERSION,
542                from_basis_t,
543            })
544            .await?
545            .into_inner())
546    }
547}
548
549/// Whether a transact failure is provably pre-commit and therefore safe to
550/// retry without risking a duplicate transaction.
551fn retry_is_safe(error: &PeerError) -> bool {
552    match error {
553        // The channel could not even be built; no request was sent.
554        PeerError::Transport(_) => true,
555        PeerError::Rpc(status) => match status.code() {
556            // A standby (not lease holder) or freshly deposed transactor
557            // refuses before reaching the commit point.
558            tonic::Code::FailedPrecondition => {
559                let message = status.message();
560                message.contains("standby") || message.contains("deposed")
561            }
562            // Unavailable with a connect-phase source means the request was
563            // never sent. Anything else (a connection that died mid-call)
564            // is ambiguous and must surface.
565            tonic::Code::Unavailable => status.message().contains("connect"),
566            _ => false,
567        },
568        _ => false,
569    }
570}
571
572fn decode_tempids(bytes: &[u8]) -> Result<BTreeMap<String, EntityId>, PeerError> {
573    let Edn::Map(pairs) = codec::decode_edn(bytes)? else {
574        return Err(PeerError::Protocol("tempids must be a map".into()));
575    };
576    let mut tempids = BTreeMap::new();
577    for (key, value) in pairs {
578        let (Edn::Str(name), Edn::Long(raw)) = (key, value) else {
579            return Err(PeerError::Protocol("bad tempid entry".into()));
580        };
581        let raw = u64::try_from(raw).map_err(|_| PeerError::Protocol("bad entity id".into()))?;
582        tempids.insert(name, EntityId::from_raw(raw));
583    }
584    Ok(tempids)
585}
586
587async fn subscribe_with(
588    client: &mut Client,
589    db: &str,
590    from_basis_t: u64,
591) -> Result<tonic::Streaming<pb::SubscribeItem>, PeerError> {
592    Ok(client
593        .subscribe(pb::SubscribeRequest {
594            db: db.to_owned(),
595            protocol_version: corium_protocol::PROTOCOL_VERSION,
596            from_basis_t,
597        })
598        .await?
599        .into_inner())
600}
601
602/// Consumes the stream's handshake, installing schema/naming, and returns
603/// the server basis at subscription time.
604async fn pump_handshake(
605    inner: &Arc<Inner>,
606    stream: &mut tonic::Streaming<pb::SubscribeItem>,
607) -> Result<u64, PeerError> {
608    let first = stream
609        .message()
610        .await?
611        .and_then(|item| item.item)
612        .ok_or_else(|| PeerError::Protocol("subscription ended before handshake".into()))?;
613    let pb::subscribe_item::Item::Handshake(handshake) = first else {
614        return Err(PeerError::Protocol(
615            "subscription must begin with a handshake".into(),
616        ));
617    };
618    let local_basis = *inner.basis.subscribe().borrow();
619    if handshake.basis_t < local_basis {
620        return Err(PeerError::Protocol(format!(
621            "published snapshot basis {local_basis} is newer than transactor basis {}",
622            handshake.basis_t
623        )));
624    }
625    let (schema, idents) = codec::decode_schema(&handshake.schema)?;
626    inner.heartbeat_ms.store(
627        handshake.heartbeat_interval_ms,
628        std::sync::atomic::Ordering::Relaxed,
629    );
630    {
631        let mut guard = inner
632            .state
633            .write()
634            .unwrap_or_else(std::sync::PoisonError::into_inner);
635        if let Some(state) = guard.as_mut() {
636            // Reconnect: schema/idents are fixed after bootstrap; keep the
637            // locally accumulated database value and naming.
638            state.schema = schema;
639            state.idents = idents;
640        } else {
641            let interner = KeywordInterner::default();
642            let db = Db::new(schema.clone()).with_naming(idents.clone(), interner.clone());
643            *guard = Some(PeerState {
644                schema,
645                idents,
646                interner,
647                db,
648                instants: BTreeMap::new(),
649            });
650        }
651    }
652    let _ = inner.index_basis.send_replace(handshake.index_basis_t);
653    Ok(handshake.basis_t)
654}
655
656/// Applies stream items until the local basis reaches `target`.
657async fn drain_until(
658    inner: &Arc<Inner>,
659    stream: &mut tonic::Streaming<pb::SubscribeItem>,
660    target: u64,
661) -> Result<(), PeerError> {
662    while *inner.basis.subscribe().borrow() < target {
663        let Some(item) = stream.message().await? else {
664            return Err(PeerError::Protocol(
665                "subscription ended during backfill".into(),
666            ));
667        };
668        if let Some(item) = item.item {
669            apply_item(inner, item)?;
670        }
671    }
672    Ok(())
673}
674
675fn apply_item(inner: &Arc<Inner>, item: pb::subscribe_item::Item) -> Result<(), PeerError> {
676    match item {
677        pb::subscribe_item::Item::Report(report) => {
678            let peer_report = {
679                let mut guard = inner
680                    .state
681                    .write()
682                    .unwrap_or_else(std::sync::PoisonError::into_inner);
683                let state = guard
684                    .as_mut()
685                    .ok_or_else(|| PeerError::Protocol("report before handshake".into()))?;
686                if report.t <= state.db.basis_t() {
687                    return Ok(());
688                }
689                let before = state.interner.len();
690                let datoms = codec::decode_datoms(&report.datoms, &mut state.interner)?;
691                if state.interner.len() > before {
692                    state.db = state
693                        .db
694                        .clone()
695                        .with_naming(state.idents.clone(), state.interner.clone());
696                }
697                state.db = state.db.with_transaction(report.t, &datoms);
698                state.instants.insert(report.t, report.tx_instant);
699                PeerReport {
700                    t: report.t,
701                    tx_instant: report.tx_instant,
702                    datoms,
703                    db_after: state.db.clone(),
704                }
705            };
706            let _ = inner.basis.send_replace(peer_report.t);
707            let _ = inner.reports.send(peer_report);
708        }
709        pb::subscribe_item::Item::IndexBasis(index) => {
710            let _ = inner.index_basis.send_replace(index.index_basis_t);
711        }
712        pb::subscribe_item::Item::Heartbeat(_) | pb::subscribe_item::Item::Handshake(_) => {}
713    }
714    Ok(())
715}
716
717/// Long-running consume/reconnect loop: on stream end, error, or heartbeat
718/// silence past the timeout, rebuilds the channel with exponential backoff
719/// and resubscribes from the local basis; the server backfills the gap.
720/// Reconnection rotates through the candidate endpoints, so when the
721/// active transactor dies the loop lands on whichever standby takes over
722/// the lease (a standby rejects the subscription until then).
723async fn run_loop(inner: Arc<Inner>, initial: Option<tonic::Streaming<pb::SubscribeItem>>) {
724    let mut stream = initial;
725    let mut backoff = inner.config.reconnect_min;
726    let mut candidate = inner
727        .endpoint_index
728        .load(std::sync::atomic::Ordering::Relaxed);
729    loop {
730        if let Some(active) = stream.as_mut() {
731            let next = match inner.heartbeat_deadline() {
732                Some(deadline) => match tokio::time::timeout(deadline, active.message()).await {
733                    Ok(next) => next,
734                    // Heartbeat silence: the transactor is presumed dead
735                    // even though the transport has not noticed (partition,
736                    // stalled process); drop the stream and fail over.
737                    Err(_elapsed) => Ok(None),
738                },
739                None => active.message().await,
740            };
741            match next {
742                Ok(Some(item)) => {
743                    backoff = inner.config.reconnect_min;
744                    if let Some(item) = item.item
745                        && apply_item(&inner, item).is_err()
746                    {
747                        stream = None;
748                    }
749                    continue;
750                }
751                Ok(None) | Err(_) => {
752                    stream = None;
753                }
754            }
755        }
756        tokio::time::sleep(backoff).await;
757        backoff = (backoff * 2).min(inner.config.reconnect_max);
758        let endpoints = &inner.config.endpoints;
759        let endpoint = &endpoints[candidate % endpoints.len()];
760        let Ok(channel) = open_channel(&inner.config, endpoint).await else {
761            candidate = (candidate + 1) % endpoints.len();
762            continue;
763        };
764        let mut client = make_client(channel, inner.config.token.clone());
765        let from = *inner.basis.subscribe().borrow();
766        match subscribe_with(&mut client, &inner.config.db, from).await {
767            Ok(mut fresh) => {
768                if pump_handshake(&inner, &mut fresh).await.is_ok() {
769                    // Sticky success: transact/status/sync now go here too.
770                    *inner
771                        .client
772                        .lock()
773                        .unwrap_or_else(std::sync::PoisonError::into_inner) = client;
774                    inner.endpoint_index.store(
775                        candidate % endpoints.len(),
776                        std::sync::atomic::Ordering::Relaxed,
777                    );
778                    stream = Some(fresh);
779                } else {
780                    candidate = (candidate + 1) % endpoints.len();
781                }
782            }
783            Err(_) => {
784                candidate = (candidate + 1) % endpoints.len();
785            }
786        }
787    }
788}
789
790/// Catalog (admin) client for a transactor endpoint.
791pub struct Admin {
792    client: CatalogClient<InterceptedService<Channel, TokenInterceptor>>,
793}
794
795impl Admin {
796    /// Connects to a transactor's catalog service.
797    ///
798    /// # Errors
799    /// Returns [`PeerError`] when the endpoint is unreachable.
800    pub async fn connect(
801        endpoint: &str,
802        token: Option<String>,
803        tls: Option<ClientTlsConfig>,
804    ) -> Result<Self, PeerError> {
805        let config = ConnectConfig {
806            tls,
807            token: token.clone(),
808            ..ConnectConfig::new(endpoint, "")
809        };
810        let channel = open_channel(&config, endpoint).await?;
811        Ok(Self {
812            client: CatalogClient::with_interceptor(channel, TokenInterceptor::new(token)),
813        })
814    }
815
816    /// Creates a database with EDN schema forms; `false` when it existed.
817    ///
818    /// # Errors
819    /// Returns [`PeerError`] for invalid schema or transport failure.
820    pub async fn create_database(&mut self, db: &str, schema: &[Edn]) -> Result<bool, PeerError> {
821        let response = self
822            .client
823            .create_database(pb::CreateDatabaseRequest {
824                db: db.to_owned(),
825                schema: codec::encode_edn(&Edn::Vector(schema.to_vec())),
826            })
827            .await?;
828        Ok(response.into_inner().created)
829    }
830
831    /// Forks `db` into a new database `target` duplicating it at
832    /// transaction basis `as_of_t` (`None` forks at the current basis).
833    /// Returns the fork's basis, or `None` when the target already existed.
834    ///
835    /// # Errors
836    /// Returns [`PeerError`] on transport failure, an unknown source, an
837    /// invalid target name, or a basis ahead of the source's.
838    pub async fn fork_database(
839        &mut self,
840        db: &str,
841        target: &str,
842        as_of_t: Option<u64>,
843    ) -> Result<Option<u64>, PeerError> {
844        let response = self
845            .client
846            .fork_database(pb::ForkDatabaseRequest {
847                db: db.to_owned(),
848                target: target.to_owned(),
849                as_of_t: as_of_t.unwrap_or(0),
850            })
851            .await?
852            .into_inner();
853        Ok(response.created.then_some(response.basis_t))
854    }
855
856    /// Deletes a database; `false` when it did not exist.
857    ///
858    /// # Errors
859    /// Returns [`PeerError`] on transport failure.
860    pub async fn delete_database(&mut self, db: &str) -> Result<bool, PeerError> {
861        let response = self
862            .client
863            .delete_database(pb::DeleteDatabaseRequest { db: db.to_owned() })
864            .await?;
865        Ok(response.into_inner().deleted)
866    }
867
868    /// Lists hosted databases.
869    ///
870    /// # Errors
871    /// Returns [`PeerError`] on transport failure.
872    pub async fn list_databases(&mut self) -> Result<Vec<String>, PeerError> {
873        let response = self
874            .client
875            .list_databases(pb::ListDatabasesRequest {})
876            .await?;
877        Ok(response.into_inner().dbs)
878    }
879
880    /// Sweeps blobs unreachable from every live database root.
881    ///
882    /// # Errors
883    /// Returns [`PeerError`] on transport failure.
884    pub async fn gc_deleted_databases(&mut self) -> Result<u64, PeerError> {
885        self.gc_deleted_databases_with_retention(None).await
886    }
887
888    /// Sweeps unreachable blobs with an optional minimum retention window.
889    ///
890    /// # Errors
891    /// Returns [`PeerError`] on transport failure.
892    pub async fn gc_deleted_databases_with_retention(
893        &mut self,
894        retention: Option<Duration>,
895    ) -> Result<u64, PeerError> {
896        let response = self
897            .client
898            .gc_deleted_databases(pb::GcDeletedDatabasesRequest {
899                retention_millis: retention.map(duration_millis),
900            })
901            .await?;
902        Ok(response.into_inner().swept_blobs)
903    }
904
905    /// Asks the transactor to publish `db`'s covering indexes now,
906    /// bypassing its pacing policy; returns the resulting index basis.
907    ///
908    /// # Errors
909    /// Returns [`PeerError`] on transport failure or an unknown database.
910    pub async fn request_index(&mut self, db: &str) -> Result<u64, PeerError> {
911        let response = self
912            .client
913            .request_index(pb::RequestIndexRequest { db: db.to_owned() })
914            .await?;
915        Ok(response.into_inner().index_basis_t)
916    }
917
918    /// Overrides `db`'s index-publication pacing at runtime; `None` fields
919    /// are left unchanged, so an all-`None` update reads the current
920    /// policy. Returns the policy now in effect.
921    ///
922    /// # Errors
923    /// Returns [`PeerError`] on transport failure or an unknown database.
924    pub async fn set_index_policy(
925        &mut self,
926        db: &str,
927        update: IndexPolicySettings,
928    ) -> Result<IndexPolicySettings, PeerError> {
929        let response = self
930            .client
931            .set_index_policy(pb::SetIndexPolicyRequest {
932                db: db.to_owned(),
933                interval_ms: update.interval_ms,
934                backoff: update.backoff,
935                tail_threshold: update.tail_threshold,
936                tail_deadline_ms: update.tail_deadline_ms,
937            })
938            .await?
939            .into_inner();
940        Ok(IndexPolicySettings {
941            interval_ms: Some(response.interval_ms),
942            backoff: Some(response.backoff),
943            tail_threshold: Some(response.tail_threshold),
944            tail_deadline_ms: Some(response.tail_deadline_ms),
945        })
946    }
947
948    /// Fixes the current transaction basis and returns an independent
949    /// connection to the transactor's underlying storage service, for a
950    /// backup client or a storage-aware peer bootstrapping from storage.
951    ///
952    /// # Errors
953    /// Returns [`PeerError`] for an unknown database or transport failure.
954    pub async fn get_storage_info(
955        &mut self,
956        db: &str,
957    ) -> Result<pb::GetStorageInfoResponse, PeerError> {
958        Ok(self
959            .client
960            .get_storage_info(pb::GetStorageInfoRequest { db: db.to_owned() })
961            .await?
962            .into_inner())
963    }
964}
965
966/// Index-publication pacing fields for [`Admin::set_index_policy`]: `None`
967/// in a request leaves the field unchanged; responses set every field to
968/// the effective policy.
969#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
970pub struct IndexPolicySettings {
971    /// Base interval between publications (ms).
972    pub interval_ms: Option<u64>,
973    /// Minimum wait before the next publication, as a multiple of the
974    /// previous publication's duration (0 disables the backoff).
975    pub backoff: Option<u32>,
976    /// Pending-datom count below which a due publication is deferred
977    /// (0 publishes any pending work).
978    pub tail_threshold: Option<u64>,
979    /// Longest a below-threshold tail defers publication (ms).
980    pub tail_deadline_ms: Option<u64>,
981}
982
983fn duration_millis(duration: Duration) -> u64 {
984    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
985}
986
987#[cfg(test)]
988mod tests {
989    use super::*;
990
991    #[test]
992    fn gc_retention_wire_value_preserves_zero_and_subseconds() {
993        assert_eq!(duration_millis(Duration::ZERO), 0);
994        assert_eq!(duration_millis(Duration::from_millis(500)), 500);
995    }
996}