Skip to main content

rust_etcd_utils/
lease.rs

1use {
2    crate::retry::retry_etcd,
3    futures::StreamExt,
4    std::time::Duration,
5    tokio::{
6        sync::{broadcast, mpsc, oneshot},
7        task::{JoinHandle, JoinSet},
8        time::Instant,
9    },
10    tracing::{error, warn},
11};
12
13// Jiffy is interval between system timer interrupts, typically 10ms for linux systems.
14const AT_LEAST_10_JIFFIES: Duration = Duration::from_millis(100);
15
16///
17/// Managed lease instance that will keep the lease alive until it is dropped.
18///
19/// See [`ManagedLeaseFactory::new_lease`] for more information.
20///
21pub struct ManagedLease {
22    etcd: etcd_client::Client,
23    pub lease_id: i64,
24    // Let this field dead, because when drop it will trigger a task to wake up and gracefully revoke lease.
25    #[allow(dead_code)]
26    _tx_terminate: oneshot::Sender<()>,
27
28    rx_lease_expire: broadcast::Receiver<()>,
29}
30
31///
32/// Weak reference to the managed lease.
33///
34/// This can be used to check if the lease is still alive.
35/// The reference is weak because dropping this reference will not revoke the lease.
36pub struct ManagedLeaseWeak {
37    lease_id: i64,
38    etcd: etcd_client::Client,
39    rx_lease_expire: broadcast::Receiver<()>,
40}
41
42impl ManagedLeaseWeak {
43    pub fn lease_id(&self) -> i64 {
44        self.lease_id
45    }
46
47    pub fn get_lease_expire_notify(&self) -> LeaseExpiredNotify {
48        LeaseExpiredNotify {
49            inner: self.rx_lease_expire.resubscribe(),
50        }
51    }
52
53    pub async fn is_alive(&self) -> Result<bool, etcd_client::Error> {
54        let result = retry_etcd(
55            self.etcd.clone(),
56            (self.lease_id,),
57            |etcd, (lease_id,)| async move {
58                let resp = etcd.lease_client().time_to_live(lease_id, None).await?;
59                Ok(resp.ttl() > 0)
60            },
61        )
62        .await;
63
64        match result {
65            Ok(is_alive) => Ok(is_alive),
66            Err(e) => match e {
67                etcd_client::Error::GRpcStatus(status) => {
68                    if status.code() == tonic::Code::NotFound {
69                        Ok(false)
70                    } else {
71                        Err(etcd_client::Error::GRpcStatus(status))
72                    }
73                }
74                _ => Err(e),
75            },
76        }
77    }
78}
79
80impl ManagedLease {
81    pub fn lease_id(&self) -> i64 {
82        self.lease_id
83    }
84
85    pub fn get_lease_expire_notify(&self) -> LeaseExpiredNotify {
86        LeaseExpiredNotify {
87            inner: self.rx_lease_expire.resubscribe(),
88        }
89    }
90
91    pub async fn is_alive(&self) -> Result<bool, etcd_client::Error> {
92        let result = retry_etcd(
93            self.etcd.clone(),
94            (self.lease_id,),
95            |etcd, (lease_id,)| async move {
96                let resp = etcd.lease_client().time_to_live(lease_id, None).await?;
97                Ok(resp.ttl() > 0)
98            },
99        )
100        .await;
101
102        match result {
103            Ok(is_alive) => Ok(is_alive),
104            Err(e) => match e {
105                etcd_client::Error::GRpcStatus(status) => {
106                    if status.code() == tonic::Code::NotFound {
107                        Ok(false)
108                    } else {
109                        Err(etcd_client::Error::GRpcStatus(status))
110                    }
111                }
112                _ => Err(e),
113            },
114        }
115    }
116
117    pub fn get_weak(&self) -> ManagedLeaseWeak {
118        ManagedLeaseWeak {
119            lease_id: self.lease_id,
120            etcd: self.etcd.clone(),
121            rx_lease_expire: self.rx_lease_expire.resubscribe(),
122        }
123    }
124}
125
126///
127/// Managed lease factory that will create a new lease and keep it alive until it is dropped.
128///
129#[derive(Clone)]
130pub struct ManagedLeaseFactory {
131    cnc_tx: mpsc::Sender<ManagedLeaseRuntimeCommand>,
132}
133
134///
135/// Notify when the lease has expired.
136///
137pub struct LeaseExpiredNotify {
138    inner: broadcast::Receiver<()>,
139}
140
141impl LeaseExpiredNotify {
142    ///
143    /// Wait until the lease has expired.
144    ///
145    pub async fn recv(mut self) {
146        let _ = self.inner.recv().await;
147    }
148}
149
150impl Clone for LeaseExpiredNotify {
151    fn clone(&self) -> Self {
152        Self {
153            inner: self.inner.resubscribe(),
154        }
155    }
156}
157
158struct CreateLeaseCommand {
159    ttl: Duration,
160    keepalive_interval: Option<Duration>,
161    auto_refresh_limit: Option<usize>,
162    callback: oneshot::Sender<Result<ManagedLease, CreateLeaseError>>,
163}
164
165enum ManagedLeaseRuntimeCommand {
166    CreateLease(CreateLeaseCommand),
167}
168
169///
170/// Managed lease factory runtime that will handle the lease creation and keep alive.
171/// This is a separate task that will run in the background.
172///
173struct ManagedLeaseFactoryRuntime {
174    ///
175    /// The etcd client to use.
176    ///
177    etcd: etcd_client::Client,
178
179    ///
180    /// The runtime handle to spawn tasks on.
181    ///
182    rt: tokio::runtime::Handle,
183
184    ///
185    /// The join set to manage the tasks.
186    ///
187    js: JoinSet<()>,
188
189    ///
190    /// The channel to notify the runtime to shutdown.
191    ///
192    cnc_rx: mpsc::Receiver<ManagedLeaseRuntimeCommand>,
193}
194
195#[derive(Debug, thiserror::Error)]
196pub enum CreateLeaseError {
197    #[error("lease creation failed")]
198    EtcdError(#[from] etcd_client::Error),
199    #[error("invalid lease ttl, must be at least 2 seconds")]
200    InvalidTTL,
201}
202
203impl ManagedLeaseFactoryRuntime {
204    async fn handle_create_lease(&mut self, cmd: CreateLeaseCommand) {
205        let CreateLeaseCommand {
206            ttl,
207            keepalive_interval,
208            auto_refresh_limit,
209            callback,
210        } = cmd;
211        let ttl_secs = ttl.as_secs() as i64;
212        let lease_result = retry_etcd(self.etcd.clone(), (), move |mut etcd, _| async move {
213            etcd.lease_grant(ttl_secs, None).await
214        })
215        .await;
216        let lease_id = match lease_result {
217            Ok(lease) => lease.id(),
218            Err(e) => {
219                let _ = callback.send(Err(e.into()));
220                return;
221            }
222        };
223        let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
224        let client = self.etcd.clone();
225        let (tx_expired, rx_expired) = broadcast::channel(1);
226        let _ah = self.js.spawn_on(async move {
227            let mut refresh_count = 0;
228            'outer: loop {
229                let first_keep_alive  = Instant::now();
230                let (mut keeper, mut keep_alive_resp_stream) = retry_etcd(
231                    client.clone(),
232                    (lease_id,),
233                    move |mut client, (lease_id,)| {
234                        async move {
235                            client.lease_keep_alive(lease_id).await
236                        }
237                    })
238                        .await
239                        .expect("failed to keep alive lease");  // if we have an error this will break out the entire loop
240                let mut last_keep_alive = first_keep_alive;
241                let keepalive_interval =
242                    keepalive_interval.unwrap_or(Duration::from_secs((ttl_secs / 2) as u64));
243                let mut next_renewal = first_keep_alive + keepalive_interval;
244                'inner: loop {
245
246                    if let Some(limit) = auto_refresh_limit {
247                        if refresh_count >= limit {
248                            warn!("auto refresh limit reached, stopping lease {lease_id:?} after {refresh_count} refreshes");
249                            break 'outer;
250                        }
251                    }
252
253                    tokio::select! {
254                        _ = tokio::time::sleep_until(next_renewal) => {
255                            let since_last_keep_alive = last_keep_alive.elapsed();
256                            if since_last_keep_alive > keepalive_interval {
257                                let dt = since_last_keep_alive - keepalive_interval;
258                                if dt >= AT_LEAST_10_JIFFIES {
259                                    warn!("last keep alive was {dt:?} late");
260                                }
261                            }
262                            if let Err(e) = keeper.keep_alive().await {
263                                warn!("failed to keep alive lease {lease_id:?}, got {e:?}");
264                                break 'inner;
265                            }
266                            last_keep_alive = Instant::now();
267                            next_renewal += keepalive_interval;
268                            let res = keep_alive_resp_stream.next().await;
269                            match res {
270                                Some(Ok(keep_alive_resp)) => {
271                                    refresh_count += 1;
272                                    if keep_alive_resp.ttl() == 0 {
273                                        error!("lease {lease_id:?} expired");
274                                        break 'outer;
275                                    }
276                                    let ttl = keep_alive_resp.ttl();
277                                    if ttl < ttl_secs {
278                                        warn!("lease {lease_id:?} ttl reduced to {ttl}, since_last_keep_alive: {since_last_keep_alive:?}");
279                                    }
280                                    tracing::trace!("keep alive lease {lease_id:?} at {since_last_keep_alive:?}");
281                                }
282                                Some(Err(e)) => {
283                                    warn!("keep alive stream for lease {lease_id:?} errored: {e:?}");
284                                    break 'inner;
285                                }
286                                None => {
287                                    warn!("keep alive stream for lease {lease_id:?} ended");
288                                    break 'inner;
289                                }
290                            }
291                        }
292                        _ = &mut stop_rx => {
293                            let since_last_keep_alive = last_keep_alive.elapsed();
294                            tracing::info!("revoking lease {lease_id:?}, last keep alive: {since_last_keep_alive:?}");
295                            let result = retry_etcd(
296                                client.clone(),
297                                (lease_id,),
298                                move |mut client, (lease_id,)| {
299                                    async move {
300                                        match client.lease_revoke(lease_id).await {
301                                            Ok(_) => Ok(()),
302                                            Err(etcd_client::Error::GRpcStatus(status)) => {
303                                                if status.code() == tonic::Code::NotFound {
304                                                    tracing::warn!("lease {lease_id:?} was already deleted");
305                                                    Ok(())
306                                                } else {
307                                                    Err(etcd_client::Error::GRpcStatus(status))
308                                                }
309                                            }
310                                            Err(e) => Err(e),
311                                        }
312                                    }
313                                }
314                            );
315                            if let Err(e) = result.await {
316                                error!("failed to revoke lease {lease_id:?}, got {e:?}");
317                            }
318                            break 'outer;
319                        }
320                    }
321                }
322            }
323            let _ = tx_expired.send(());
324        }, &self.rt);
325        let lease = ManagedLease {
326            etcd: self.etcd.clone(),
327            lease_id,
328            _tx_terminate: stop_tx,
329            rx_lease_expire: rx_expired,
330        };
331        let _ = callback.send(Ok(lease));
332    }
333
334    async fn handle_command(&mut self, cmd: ManagedLeaseRuntimeCommand) {
335        match cmd {
336            ManagedLeaseRuntimeCommand::CreateLease(cmd) => {
337                self.handle_create_lease(cmd).await;
338            }
339        }
340    }
341
342    async fn run(mut self) {
343        loop {
344            // Loops ends when both the command channel and the join set are closed.
345            // When command-and-control channel is closed, it means no `ManagedLease` exists anymore.
346            // However, the join set may still have tasks running, we must wait for them to finish.
347            tokio::select! {
348                Some(cmd) = self.cnc_rx.recv() => {
349                    self.handle_command(cmd).await;
350                }
351                Some(res) = self.js.join_next() => {
352                    match res {
353                        Ok(_) => {
354                            // task completed successfully
355                            tracing::trace!("managed lease task completed");
356                        }
357                        Err(e) => {
358                            tracing::warn!("task failed: {e:?}");
359                        }
360                    }
361                }
362                else => {
363                    break;
364                }
365            }
366        }
367        tracing::trace!("managed lease factory runtime exiting");
368    }
369}
370
371impl ManagedLeaseFactory {
372    ///
373    /// Create a new managed lease factory.
374    /// This will spawn a new task that will handle the lease creation and keep alive.
375    ///
376    pub fn spawn(etcd: etcd_client::Client) -> (Self, JoinHandle<()>) {
377        Self::spawn_on(etcd, tokio::runtime::Handle::current())
378    }
379
380    ///
381    /// Create a new managed lease factory.
382    /// This will spawn a new task that will handle the lease creation and keep alive.
383    ///
384    /// Arguments:
385    /// * `etcd` - The etcd client to use.
386    /// * `rt` - The runtime handle to spawn tasks on.
387    pub fn spawn_on(
388        etcd: etcd_client::Client,
389        rt: tokio::runtime::Handle,
390    ) -> (Self, JoinHandle<()>) {
391        let (cnc_tx, cnc_rx) = mpsc::channel(100);
392        let lease_rt = ManagedLeaseFactoryRuntime {
393            etcd,
394            rt: rt.clone(),
395            js: JoinSet::new(),
396            cnc_rx,
397        };
398        let jh = rt.spawn(lease_rt.run());
399        (
400            Self {
401                cnc_tx: cnc_tx.clone(),
402            },
403            jh,
404        )
405    }
406
407    ///
408    /// Create a new managed lease with the given time-to-live (TTL), keepalive interval and auto refresh limit.
409    /// The lease will be kept alive until it is dropped OR until the lease has been refresh `auto_refresh_limit` times.
410    ///
411    /// Arguments:
412    ///
413    /// * `ttl` - The time-to-live for the lease.
414    /// * `keepalive_interval` - The interval to keep the lease alive.
415    /// * `auto_refresh_limit` - The number of times to auto refresh the lease.
416    ///
417    pub async fn new_lease_with_auto_refresh_limit(
418        &self,
419        ttl: Duration,
420        keepalive_interval: Option<Duration>,
421        auto_refresh_limit: Option<usize>,
422    ) -> Result<ManagedLease, etcd_client::Error> {
423        let ttl_secs: i64 = ttl.as_secs() as i64;
424        assert!(ttl_secs >= 2, "lease ttl must be at least two (2) seconds");
425        let (callback_tx, callback_rx) = oneshot::channel();
426        let command = CreateLeaseCommand {
427            ttl,
428            keepalive_interval,
429            auto_refresh_limit,
430            callback: callback_tx,
431        };
432        self.cnc_tx
433            .send(ManagedLeaseRuntimeCommand::CreateLease(command))
434            .await
435            .expect("failed to send command to managed lease factory");
436
437        let result = callback_rx
438            .await
439            .expect("failed to receive result from managed lease factory");
440        match result {
441            Ok(lease) => Ok(lease),
442            Err(e) => match e {
443                CreateLeaseError::EtcdError(e) => Err(e),
444                CreateLeaseError::InvalidTTL => {
445                    panic!("lease ttl must be at least two (2) seconds");
446                }
447            },
448        }
449    }
450
451    ///
452    /// Create a new managed lease with the given time-to-live (TTL) and keepalive interval.
453    ///
454    /// Managed lease have automatic keep alive mechanism that will keep the lease alive until it is dropped.
455    ///
456    /// The ttl must be at least two (2) seconds.
457    ///
458    /// Keepalive interval is optional, if not provided it will be half of the ttl.
459    ///
460    /// Arguments:
461    ///
462    /// * `ttl` - The time-to-live for the lease.
463    /// * `keepalive_interval` - The interval to keep the lease alive.
464    ///
465    pub async fn new_lease(
466        &self,
467        ttl: Duration,
468        keepalive_interval: Option<Duration>,
469    ) -> Result<ManagedLease, etcd_client::Error> {
470        self.new_lease_with_auto_refresh_limit(ttl, keepalive_interval, None)
471            .await
472    }
473}