1pub 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#[derive(Debug, Error)]
40pub enum PeerError {
41 #[error(transparent)]
43 Transport(#[from] tonic::transport::Error),
44 #[error(transparent)]
46 Rpc(#[from] Status),
47 #[error(transparent)]
49 Codec(#[from] CodecError),
50 #[error(transparent)]
52 Snapshot(#[from] SnapshotError),
53 #[error("protocol error: {0}")]
55 Protocol(String),
56 #[error("connection closed")]
58 Closed,
59}
60
61#[derive(Clone)]
63pub struct ConnectConfig {
64 pub endpoints: Vec<String>,
70 pub db: String,
72 pub token: Option<String>,
74 pub tls: Option<ClientTlsConfig>,
76 pub reconnect_min: Duration,
78 pub reconnect_max: Duration,
80 pub failover_timeout: Duration,
86 pub heartbeat_timeout: Option<Duration>,
90 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 #[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 #[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 #[must_use]
141 pub fn with_storage(mut self, storage: Arc<dyn PeerStorage>) -> Self {
142 self.storage = Some(storage);
143 self
144 }
145
146 #[must_use]
148 pub fn has_storage(&self) -> bool {
149 self.storage.is_some()
150 }
151}
152
153#[derive(Clone, Debug)]
155pub struct PeerReport {
156 pub t: u64,
158 pub tx_instant: i64,
160 pub datoms: Vec<Datom>,
162 pub db_after: Db,
164}
165
166#[derive(Clone, Debug)]
168pub struct TxResult {
169 pub basis_before: u64,
171 pub basis_t: u64,
173 pub tx_instant: i64,
175 pub tempids: BTreeMap<String, EntityId>,
177 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 endpoint_index: std::sync::atomic::AtomicUsize,
199 heartbeat_ms: std::sync::atomic::AtomicU64,
202}
203
204impl Inner {
205 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
216pub 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 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 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 #[must_use]
311 pub fn db_name(&self) -> &str {
312 &self.inner.config.db
313 }
314
315 #[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 #[must_use]
335 pub fn basis_t(&self) -> u64 {
336 *self.inner.basis.subscribe().borrow()
337 }
338
339 #[must_use]
342 pub fn index_basis_t(&self) -> u64 {
343 *self.inner.index_basis.subscribe().borrow()
344 }
345
346 #[must_use]
348 pub fn tx_reports(&self) -> broadcast::Receiver<PeerReport> {
349 self.inner.reports.subscribe()
350 }
351
352 #[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 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 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 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 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 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 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
513fn retry_is_safe(error: &PeerError) -> bool {
516 match error {
517 PeerError::Transport(_) => true,
519 PeerError::Rpc(status) => match status.code() {
520 tonic::Code::FailedPrecondition => {
523 let message = status.message();
524 message.contains("standby") || message.contains("deposed")
525 }
526 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
566async 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 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
620async 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
681async 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 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 *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
754pub struct Admin {
756 client: CatalogClient<InterceptedService<Channel, TokenInterceptor>>,
757}
758
759impl Admin {
760 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 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 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 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 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 pub async fn gc_deleted_databases(&mut self) -> Result<u64, PeerError> {
849 self.gc_deleted_databases_with_retention(None).await
850 }
851
852 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 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 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 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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
934pub struct IndexPolicySettings {
935 pub interval_ms: Option<u64>,
937 pub backoff: Option<u32>,
940 pub tail_threshold: Option<u64>,
943 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}