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::fmt;
12use std::sync::{Arc, Mutex};
13use std::time::{Duration, Instant};
14
15use futures::future::BoxFuture;
16
17/// The checkpoint value marking a shard fully consumed; its children may start.
18pub const SHARD_END: &str = "SHARD_END";
19
20/// The name one shard's lease and checkpoint live under: the stream and the shard id.
21///
22/// Shard ids repeat across streams (the first shard of every stream is `shardId-000000000000`),
23/// so a store keys by both, and one store serves every stream a broker consumes. The stream is the
24/// name the subscription's [`KinesisStream`](crate::KinesisStream) was given.
25///
26/// # Examples
27///
28/// A store over a string-keyed table writes each shard's row under the key's `stream:shard` form:
29///
30/// ```
31/// use std::collections::HashMap;
32/// use std::sync::Mutex;
33/// # use std::time::Duration;
34///
35/// use futures::future::BoxFuture;
36/// use ruststream_kinesis::{LeaseError, LeaseKey, LeaseState, LeaseStore};
37///
38/// /// Checkpoints for a single instance, which owns every shard.
39/// #[derive(Default)]
40/// pub struct Checkpoints(Mutex<HashMap<String, String>>);
41///
42/// impl LeaseStore for Checkpoints {
43///     fn checkpoint<'a>(
44///         &'a self,
45///         key: &'a LeaseKey,
46///         _owner: &'a str,
47///         sequence: &'a str,
48///     ) -> BoxFuture<'a, Result<bool, LeaseError>> {
49///         Box::pin(async move {
50///             let mut rows = self.0.lock().map_err(|e| e.to_string())?;
51///             rows.insert(key.to_string(), sequence.to_owned());
52///             Ok(true)
53///         })
54///     }
55///
56///     fn read<'a>(&'a self, key: &'a LeaseKey) -> BoxFuture<'a, Result<LeaseState, LeaseError>> {
57///         Box::pin(async move {
58///             let rows = self.0.lock().map_err(|e| e.to_string())?;
59///             let mut state = LeaseState::default();
60///             state.checkpoint = rows.get(&key.to_string()).cloned();
61///             Ok(state)
62///         })
63///     }
64/// #
65/// #     fn acquire<'a>(
66/// #         &'a self,
67/// #         _key: &'a LeaseKey,
68/// #         _owner: &'a str,
69/// #         _ttl: Duration,
70/// #     ) -> BoxFuture<'a, Result<bool, LeaseError>> {
71/// #         Box::pin(async { Ok(true) })
72/// #     }
73/// #
74/// #     fn renew<'a>(
75/// #         &'a self,
76/// #         _key: &'a LeaseKey,
77/// #         _owner: &'a str,
78/// #         _ttl: Duration,
79/// #     ) -> BoxFuture<'a, Result<bool, LeaseError>> {
80/// #         Box::pin(async { Ok(true) })
81/// #     }
82/// #
83/// #     fn release<'a>(
84/// #         &'a self,
85/// #         _key: &'a LeaseKey,
86/// #         _owner: &'a str,
87/// #     ) -> BoxFuture<'a, Result<(), LeaseError>> {
88/// #         Box::pin(async { Ok(()) })
89/// #     }
90/// }
91/// ```
92#[derive(Debug, Clone, PartialEq, Eq, Hash)]
93pub struct LeaseKey {
94    // Shared, not owned: a reader stamps its key onto every record it delivers, and the
95    // per-delivery context reads the shard back, so both stay reference-count bumps.
96    stream: Arc<str>,
97    shard: Arc<str>,
98}
99
100impl LeaseKey {
101    /// The lease of `shard` on `stream`.
102    #[must_use]
103    pub fn new(stream: impl Into<Arc<str>>, shard: impl Into<Arc<str>>) -> Self {
104        Self {
105            stream: stream.into(),
106            shard: shard.into(),
107        }
108    }
109
110    /// The stream the shard belongs to.
111    #[must_use]
112    pub fn stream(&self) -> &str {
113        &self.stream
114    }
115
116    /// The shard id, unique within its stream only.
117    #[must_use]
118    pub fn shard(&self) -> &str {
119        &self.shard
120    }
121
122    pub(crate) const fn shard_shared(&self) -> &Arc<str> {
123        &self.shard
124    }
125}
126
127/// `stream:shard`, the form a store that keeps one string key writes. A stream name holds no
128/// colon, so the form is unambiguous.
129impl fmt::Display for LeaseKey {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        write!(f, "{}:{}", self.stream, self.shard)
132    }
133}
134
135/// The persisted state of one shard's lease.
136#[derive(Debug, Clone, Default, PartialEq, Eq)]
137#[non_exhaustive]
138pub struct LeaseState {
139    /// The last checkpointed sequence number, [`SHARD_END`] when the shard is finished, or
140    /// `None` when never checkpointed.
141    pub checkpoint: Option<String>,
142}
143
144/// The boxed error lease stores report; the crate wraps it with the stream and the shard for
145/// diagnostics.
146pub type LeaseError = Box<dyn StdError + Send + Sync>;
147
148/// Durable coordination for shard leases and checkpoints.
149///
150/// The contract mirrors the vendor's own consumer library: `acquire` takes a shard for an
151/// owner (stealing expired leases), `renew` heartbeats it - a failed renew means the owner
152/// has been fenced and must stop processing immediately - and `checkpoint` records progress
153/// conditionally on still holding the lease. Every call names the shard by its [`LeaseKey`]: a
154/// store keeps one lease per stream and shard.
155pub trait LeaseStore: Send + Sync + 'static {
156    /// Attempts to take the shard's lease for `owner`, valid for `ttl`. Returns `false` when
157    /// another live owner holds it.
158    fn acquire<'a>(
159        &'a self,
160        key: &'a LeaseKey,
161        owner: &'a str,
162        ttl: Duration,
163    ) -> BoxFuture<'a, Result<bool, LeaseError>>;
164
165    /// Heartbeats the lease. Returns `false` when the lease is no longer held (fenced).
166    fn renew<'a>(
167        &'a self,
168        key: &'a LeaseKey,
169        owner: &'a str,
170        ttl: Duration,
171    ) -> BoxFuture<'a, Result<bool, LeaseError>>;
172
173    /// Records `sequence` as the shard's checkpoint, conditional on `owner` holding the
174    /// lease. Returns `false` when fenced.
175    fn checkpoint<'a>(
176        &'a self,
177        key: &'a LeaseKey,
178        owner: &'a str,
179        sequence: &'a str,
180    ) -> BoxFuture<'a, Result<bool, LeaseError>>;
181
182    /// Reads the shard's persisted state (regardless of ownership).
183    fn read<'a>(&'a self, key: &'a LeaseKey) -> BoxFuture<'a, Result<LeaseState, LeaseError>>;
184
185    /// Releases the lease so another owner can take it without waiting for expiry.
186    fn release<'a>(
187        &'a self,
188        key: &'a LeaseKey,
189        owner: &'a str,
190    ) -> BoxFuture<'a, Result<(), LeaseError>>;
191}
192
193#[derive(Debug, Default)]
194struct MemoryLease {
195    owner: Option<String>,
196    expires: Option<Instant>,
197    checkpoint: Option<String>,
198}
199
200/// In-process lease coordination, correct for a single service instance.
201///
202/// Workers within the process still gate children on parents and checkpoint, but nothing
203/// survives a restart. Multiple instances need a shared store such as the `DynamoDB` one.
204#[derive(Debug, Default)]
205pub struct MemoryLeaseStore {
206    leases: Mutex<HashMap<LeaseKey, MemoryLease>>,
207}
208
209impl MemoryLeaseStore {
210    /// Creates an empty store.
211    #[must_use]
212    pub fn new() -> Self {
213        Self::default()
214    }
215
216    fn with_lease<R>(&self, key: &LeaseKey, f: impl FnOnce(&mut MemoryLease) -> R) -> R {
217        let mut leases = self.leases.lock().expect("lease store mutex poisoned");
218        // A checkpoint lands on every acknowledgement that advances the watermark, and by then
219        // the lease exists: look it up first, so that path clones no key.
220        if let Some(lease) = leases.get_mut(key) {
221            return f(lease);
222        }
223        f(leases.entry(key.clone()).or_default())
224    }
225}
226
227impl LeaseStore for MemoryLeaseStore {
228    fn acquire<'a>(
229        &'a self,
230        key: &'a LeaseKey,
231        owner: &'a str,
232        ttl: Duration,
233    ) -> BoxFuture<'a, Result<bool, LeaseError>> {
234        Box::pin(async move {
235            Ok(self.with_lease(key, |lease| {
236                let now = Instant::now();
237                let held = lease.owner.as_deref().is_some_and(|current| {
238                    current != owner && lease.expires.is_some_and(|expiry| expiry > now)
239                });
240                if held {
241                    false
242                } else {
243                    lease.owner = Some(owner.to_owned());
244                    lease.expires = Some(now + ttl);
245                    true
246                }
247            }))
248        })
249    }
250
251    fn renew<'a>(
252        &'a self,
253        key: &'a LeaseKey,
254        owner: &'a str,
255        ttl: Duration,
256    ) -> BoxFuture<'a, Result<bool, LeaseError>> {
257        Box::pin(async move {
258            Ok(self.with_lease(key, |lease| {
259                if lease.owner.as_deref() == Some(owner) {
260                    lease.expires = Some(Instant::now() + ttl);
261                    true
262                } else {
263                    false
264                }
265            }))
266        })
267    }
268
269    fn checkpoint<'a>(
270        &'a self,
271        key: &'a LeaseKey,
272        owner: &'a str,
273        sequence: &'a str,
274    ) -> BoxFuture<'a, Result<bool, LeaseError>> {
275        Box::pin(async move {
276            Ok(self.with_lease(key, |lease| {
277                if lease.owner.as_deref() == Some(owner) {
278                    lease.checkpoint = Some(sequence.to_owned());
279                    true
280                } else {
281                    false
282                }
283            }))
284        })
285    }
286
287    fn read<'a>(&'a self, key: &'a LeaseKey) -> BoxFuture<'a, Result<LeaseState, LeaseError>> {
288        Box::pin(async move {
289            Ok(self.with_lease(key, |lease| LeaseState {
290                checkpoint: lease.checkpoint.clone(),
291            }))
292        })
293    }
294
295    fn release<'a>(
296        &'a self,
297        key: &'a LeaseKey,
298        owner: &'a str,
299    ) -> BoxFuture<'a, Result<(), LeaseError>> {
300        Box::pin(async move {
301            self.with_lease(key, |lease| {
302                if lease.owner.as_deref() == Some(owner) {
303                    lease.owner = None;
304                    lease.expires = None;
305                }
306            });
307            Ok(())
308        })
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    const TTL: Duration = Duration::from_mins(1);
317
318    fn key(shard: &str) -> LeaseKey {
319        LeaseKey::new("orders", shard)
320    }
321
322    #[tokio::test]
323    async fn a_live_lease_is_exclusive_and_an_expired_one_is_stealable() {
324        let store = MemoryLeaseStore::new();
325        let s1 = key("s1");
326        assert!(store.acquire(&s1, "a", TTL).await.expect("acquire"));
327        assert!(!store.acquire(&s1, "b", TTL).await.expect("acquire"));
328
329        // Expired: b may steal.
330        let store = MemoryLeaseStore::new();
331        assert!(
332            store
333                .acquire(&s1, "a", Duration::ZERO)
334                .await
335                .expect("acquire")
336        );
337        assert!(store.acquire(&s1, "b", TTL).await.expect("steal"));
338        // a has been fenced.
339        assert!(!store.renew(&s1, "a", TTL).await.expect("renew"));
340        assert!(!store.checkpoint(&s1, "a", "42").await.expect("checkpoint"));
341    }
342
343    #[tokio::test]
344    async fn checkpoints_survive_release() {
345        let store = MemoryLeaseStore::new();
346        let s1 = key("s1");
347        assert!(store.acquire(&s1, "a", TTL).await.expect("acquire"));
348        assert!(store.checkpoint(&s1, "a", "41").await.expect("checkpoint"));
349        store.release(&s1, "a").await.expect("release");
350        assert_eq!(
351            store.read(&s1).await.expect("read").checkpoint.as_deref(),
352            Some("41")
353        );
354    }
355
356    /// Every stream's first shard has the same id, so one store serving two streams must keep
357    /// their leases and their progress apart: neither the lease, nor the checkpoint, nor the
358    /// finished mark of one stream's shard reaches the other's.
359    #[tokio::test]
360    async fn the_same_shard_id_on_two_streams_is_two_leases() {
361        let store = MemoryLeaseStore::new();
362        let orders = LeaseKey::new("orders", "shardId-000000000000");
363        let refunds = LeaseKey::new("refunds", "shardId-000000000000");
364
365        assert!(store.acquire(&orders, "a", TTL).await.expect("acquire"));
366        assert!(
367            store.acquire(&refunds, "b", TTL).await.expect("acquire"),
368            "a lease on one stream must not hold the same shard id on another",
369        );
370        assert!(
371            store
372                .checkpoint(&orders, "a", "41")
373                .await
374                .expect("checkpoint")
375        );
376        assert_eq!(
377            store.read(&refunds).await.expect("read").checkpoint,
378            None,
379            "one stream's progress must not become another's",
380        );
381        assert!(
382            store
383                .checkpoint(&refunds, "b", SHARD_END)
384                .await
385                .expect("checkpoint")
386        );
387        assert_eq!(
388            store
389                .read(&orders)
390                .await
391                .expect("read")
392                .checkpoint
393                .as_deref(),
394            Some("41"),
395            "a shard finished on one stream must stay open on another",
396        );
397    }
398}