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}