Skip to main content

ruststream_kinesis/
lease.rs

1//! [`LeaseStore`]: pluggable shard-lease and checkpoint coordination.
2//!
3//! Leasing needs durable state, and whether the crate owns that choice or hands it to the
4//! user is a real design decision - so it is a trait. The built-in
5//! [`MemoryLeaseStore`] coordinates within one process (a single service instance); the
6//! `DynamoDB` store behind the `dynamodb-lease` feature lets multiple instances share the
7//! shards with conditional-write fencing.
8
9use std::collections::HashMap;
10use std::error::Error as StdError;
11use std::sync::Mutex;
12use std::time::Duration;
13
14use futures::future::BoxFuture;
15
16/// The checkpoint value marking a shard fully consumed; its children may start.
17pub const SHARD_END: &str = "SHARD_END";
18
19/// The persisted state of one shard's lease.
20#[derive(Debug, Clone, Default, PartialEq, Eq)]
21#[non_exhaustive]
22pub struct LeaseState {
23    /// The last checkpointed sequence number, [`SHARD_END`] when the shard is finished, or
24    /// `None` when never checkpointed.
25    pub checkpoint: Option<String>,
26}
27
28/// The boxed error lease stores report; the crate wraps it with the shard for diagnostics.
29pub type LeaseError = Box<dyn StdError + Send + Sync>;
30
31/// Durable coordination for shard leases and checkpoints.
32///
33/// The contract mirrors the vendor's own consumer library: `acquire` takes a shard for an
34/// owner (stealing expired leases), `renew` heartbeats it - a failed renew means the owner
35/// has been fenced and must stop processing immediately - and `checkpoint` records progress
36/// conditionally on still holding the lease.
37pub trait LeaseStore: Send + Sync + 'static {
38    /// Attempts to take the shard's lease for `owner`, valid for `ttl`. Returns `false` when
39    /// another live owner holds it.
40    fn acquire<'a>(
41        &'a self,
42        shard: &'a str,
43        owner: &'a str,
44        ttl: Duration,
45    ) -> BoxFuture<'a, Result<bool, LeaseError>>;
46
47    /// Heartbeats the lease. Returns `false` when the lease is no longer held (fenced).
48    fn renew<'a>(
49        &'a self,
50        shard: &'a str,
51        owner: &'a str,
52        ttl: Duration,
53    ) -> BoxFuture<'a, Result<bool, LeaseError>>;
54
55    /// Records `sequence` as the shard's checkpoint, conditional on `owner` holding the
56    /// lease. Returns `false` when fenced.
57    fn checkpoint<'a>(
58        &'a self,
59        shard: &'a str,
60        owner: &'a str,
61        sequence: &'a str,
62    ) -> BoxFuture<'a, Result<bool, LeaseError>>;
63
64    /// Reads the shard's persisted state (regardless of ownership).
65    fn read<'a>(&'a self, shard: &'a str) -> BoxFuture<'a, Result<LeaseState, LeaseError>>;
66
67    /// Releases the lease so another owner can take it without waiting for expiry.
68    fn release<'a>(
69        &'a self,
70        shard: &'a str,
71        owner: &'a str,
72    ) -> BoxFuture<'a, Result<(), LeaseError>>;
73}
74
75#[derive(Debug, Default)]
76struct MemoryLease {
77    owner: Option<String>,
78    expires: Option<std::time::Instant>,
79    checkpoint: Option<String>,
80}
81
82/// In-process lease coordination, correct for a single service instance.
83///
84/// Workers within the process still gate children on parents and checkpoint, but nothing
85/// survives a restart. Multiple instances need a shared store such as the `DynamoDB` one.
86#[derive(Debug, Default)]
87pub struct MemoryLeaseStore {
88    leases: Mutex<HashMap<String, MemoryLease>>,
89}
90
91impl MemoryLeaseStore {
92    /// Creates an empty store.
93    #[must_use]
94    pub fn new() -> Self {
95        Self::default()
96    }
97
98    fn with_lease<R>(&self, shard: &str, f: impl FnOnce(&mut MemoryLease) -> R) -> R {
99        let mut leases = self.leases.lock().expect("lease store mutex poisoned");
100        f(leases.entry(shard.to_owned()).or_default())
101    }
102}
103
104impl LeaseStore for MemoryLeaseStore {
105    fn acquire<'a>(
106        &'a self,
107        shard: &'a str,
108        owner: &'a str,
109        ttl: Duration,
110    ) -> BoxFuture<'a, Result<bool, LeaseError>> {
111        Box::pin(async move {
112            Ok(self.with_lease(shard, |lease| {
113                let now = std::time::Instant::now();
114                let held = lease.owner.as_deref().is_some_and(|current| {
115                    current != owner && lease.expires.is_some_and(|expiry| expiry > now)
116                });
117                if held {
118                    false
119                } else {
120                    lease.owner = Some(owner.to_owned());
121                    lease.expires = Some(now + ttl);
122                    true
123                }
124            }))
125        })
126    }
127
128    fn renew<'a>(
129        &'a self,
130        shard: &'a str,
131        owner: &'a str,
132        ttl: Duration,
133    ) -> BoxFuture<'a, Result<bool, LeaseError>> {
134        Box::pin(async move {
135            Ok(self.with_lease(shard, |lease| {
136                if lease.owner.as_deref() == Some(owner) {
137                    lease.expires = Some(std::time::Instant::now() + ttl);
138                    true
139                } else {
140                    false
141                }
142            }))
143        })
144    }
145
146    fn checkpoint<'a>(
147        &'a self,
148        shard: &'a str,
149        owner: &'a str,
150        sequence: &'a str,
151    ) -> BoxFuture<'a, Result<bool, LeaseError>> {
152        Box::pin(async move {
153            Ok(self.with_lease(shard, |lease| {
154                if lease.owner.as_deref() == Some(owner) {
155                    lease.checkpoint = Some(sequence.to_owned());
156                    true
157                } else {
158                    false
159                }
160            }))
161        })
162    }
163
164    fn read<'a>(&'a self, shard: &'a str) -> BoxFuture<'a, Result<LeaseState, LeaseError>> {
165        Box::pin(async move {
166            Ok(self.with_lease(shard, |lease| LeaseState {
167                checkpoint: lease.checkpoint.clone(),
168            }))
169        })
170    }
171
172    fn release<'a>(
173        &'a self,
174        shard: &'a str,
175        owner: &'a str,
176    ) -> BoxFuture<'a, Result<(), LeaseError>> {
177        Box::pin(async move {
178            self.with_lease(shard, |lease| {
179                if lease.owner.as_deref() == Some(owner) {
180                    lease.owner = None;
181                    lease.expires = None;
182                }
183            });
184            Ok(())
185        })
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[tokio::test]
194    async fn a_live_lease_is_exclusive_and_an_expired_one_is_stealable() {
195        let store = MemoryLeaseStore::new();
196        let ttl = Duration::from_mins(1);
197        assert!(store.acquire("s1", "a", ttl).await.expect("acquire"));
198        assert!(!store.acquire("s1", "b", ttl).await.expect("acquire"));
199
200        // Expired: b may steal.
201        let store = MemoryLeaseStore::new();
202        assert!(
203            store
204                .acquire("s1", "a", Duration::ZERO)
205                .await
206                .expect("acquire")
207        );
208        assert!(store.acquire("s1", "b", ttl).await.expect("steal"));
209        // a has been fenced.
210        assert!(!store.renew("s1", "a", ttl).await.expect("renew"));
211        assert!(!store.checkpoint("s1", "a", "42").await.expect("checkpoint"));
212    }
213
214    #[tokio::test]
215    async fn checkpoints_survive_release() {
216        let store = MemoryLeaseStore::new();
217        let ttl = Duration::from_mins(1);
218        assert!(store.acquire("s1", "a", ttl).await.expect("acquire"));
219        assert!(store.checkpoint("s1", "a", "41").await.expect("checkpoint"));
220        store.release("s1", "a").await.expect("release");
221        assert_eq!(
222            store.read("s1").await.expect("read").checkpoint.as_deref(),
223            Some("41")
224        );
225    }
226}