ash_lock/client.rs
1//! The [`LockClient`] and its [`LockConfig`].
2
3use std::net::SocketAddr;
4use std::sync::Arc;
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::time::Duration;
7
8use tracing::info;
9
10use crate::backend::{InMemoryBackend, LockBackend};
11use crate::builder::LockBuilder;
12use crate::clock::Clock;
13use crate::error::{Error, Result};
14
15/// Configuration for connecting a [`LockClient`] to a lock cluster.
16#[derive(Clone, Debug)]
17pub struct LockConfig {
18 /// Seed addresses of the lock cluster.
19 ///
20 /// MOCK: the spec resolves these through `ash-discovery` via `ash-rpc`.
21 /// That crate does not exist yet, so the seeds are recorded but otherwise
22 /// unused (the default backend runs in-process; an empty list is fine).
23 /// Kept so the real transport can be dropped in unchanged.
24 pub cluster_seed: Vec<SocketAddr>,
25 /// Default lease TTL for locks that do not override it.
26 pub default_ttl: Duration,
27 /// How often the watchdog renews a lease. Conventionally ~½ the TTL.
28 pub watchdog_interval: Duration,
29}
30
31impl Default for LockConfig {
32 fn default() -> Self {
33 Self {
34 cluster_seed: vec![],
35 default_ttl: Duration::from_secs(30),
36 watchdog_interval: Duration::from_secs(15),
37 }
38 }
39}
40
41/// Shared, cheaply-cloneable client state (held behind an `Arc` in the client
42/// and borrowed by [`LockBuilder`]).
43pub struct ClientInner {
44 pub(crate) backend: Arc<dyn LockBackend>,
45 pub(crate) clock: Clock,
46 pub(crate) node_id: u64,
47 pub(crate) default_ttl: Duration,
48 pub(crate) watchdog_interval: Duration,
49}
50
51/// A handle to the distributed lock service.
52///
53/// Cloning is cheap (`Arc` bump) and all clones share the same backend, clock,
54/// and node identity.
55#[derive(Clone)]
56pub struct LockClient {
57 inner: Arc<ClientInner>,
58}
59
60/// Process-wide node id source. In a real deployment this would come from
61/// `ash-discovery`; here we assign a distinct id per constructed client.
62static NEXT_NODE_ID: AtomicU64 = AtomicU64::new(1);
63
64impl LockClient {
65 /// Connect using the default [`InMemoryBackend`] — single-process locking
66 /// only. For real distributed locking, connect an `EtcdBackend` (behind
67 /// the `etcd` feature) and pass it to [`connect_with`](Self::connect_with).
68 pub async fn connect(config: LockConfig) -> Result<Self> {
69 Self::connect_with(config, Arc::new(InMemoryBackend::new())).await
70 }
71
72 /// Connect using an explicit [`LockBackend`] (e.g. `InMemoryBackend` for
73 /// tests, or `EtcdBackend` for a real cluster). Bypasses discovery
74 /// entirely.
75 pub async fn connect_with(config: LockConfig, backend: Arc<dyn LockBackend>) -> Result<Self> {
76 if config.default_ttl.is_zero() {
77 return Err(Error::Config("default_ttl must be non-zero".into()));
78 }
79 if config.watchdog_interval.is_zero() {
80 return Err(Error::Config("watchdog_interval must be non-zero".into()));
81 }
82 if config.watchdog_interval >= config.default_ttl {
83 return Err(Error::Config(
84 "watchdog_interval must be shorter than default_ttl".into(),
85 ));
86 }
87
88 // MOCK discovery: with the real ash-discovery this is where we'd resolve
89 // `cluster_seed` to a live membership view. For now we just note it.
90 let node_id = NEXT_NODE_ID.fetch_add(1, Ordering::Relaxed);
91 info!(
92 node_id,
93 backend = backend.name(),
94 seeds = config.cluster_seed.len(),
95 "ash-lock client connected (discovery mocked)"
96 );
97
98 Ok(Self {
99 inner: Arc::new(ClientInner {
100 backend,
101 clock: Clock::new(),
102 node_id,
103 default_ttl: config.default_ttl,
104 watchdog_interval: config.watchdog_interval,
105 }),
106 })
107 }
108
109 /// This client's node id.
110 pub fn node_id(&self) -> u64 {
111 self.inner.node_id
112 }
113
114 /// Begin acquiring `resource`. Returns a [`LockBuilder`] for fluent
115 /// configuration; nothing is acquired until
116 /// [`acquire`](LockBuilder::acquire) is awaited.
117 ///
118 /// Note: this is `async` to match the spec signature, though it currently
119 /// does no awaiting itself.
120 pub async fn lock(&self, resource: &str) -> LockBuilder<'_> {
121 let ttl = self.inner.default_ttl;
122 LockBuilder {
123 inner: &self.inner,
124 resource: resource.to_string(),
125 ttl,
126 fair: false,
127 // Default wait budget: one full TTL of retries before timing out.
128 wait_budget: ttl,
129 }
130 }
131}