Skip to main content

ruststream_kinesis/
dynamo.rs

1//! [`DynamoLeaseStore`]: shard leases in `DynamoDB`, so multiple service instances share the
2//! shards. Behind the `dynamodb-lease` feature.
3//!
4//! The schema is the minimal safe subset of the vendor's consumer-library table: one item per
5//! stream and shard (`lease_key`, written as `stream:shard`), an owner, an expiry, a fencing
6//! counter bumped on every write, and the checkpoint. Every mutation is a conditional write, so
7//! two instances cannot both believe they own a shard.
8
9use std::collections::HashMap;
10use std::sync::Arc;
11use std::time::{Duration, SystemTime, UNIX_EPOCH};
12
13use aws_config::SdkConfig;
14use aws_sdk_dynamodb::Client;
15use aws_sdk_dynamodb::operation::update_item::UpdateItemError;
16use aws_sdk_dynamodb::types::AttributeValue;
17use futures::future::BoxFuture;
18
19use crate::clients::RuntimeClients;
20use crate::lease::{LeaseError, LeaseKey, LeaseState, LeaseStore};
21
22/// The table's partition key attribute.
23const KEY: &str = "lease_key";
24/// The attribute holding a shard's progress.
25const CHECKPOINT: &str = "checkpoint";
26
27/// A `DynamoDB`-backed lease store.
28///
29/// The table needs a string partition key named `lease_key` and nothing else; create it with
30/// on-demand billing. Instances race leases with conditional writes and steal only expired
31/// ones. One row holds one shard of one stream, under the key `stream:shard`, so one table
32/// serves every stream a service consumes.
33///
34/// # Rows from earlier versions
35///
36/// Earlier versions of this crate keyed a row by the shard id alone (`shardId-000000000000`), and
37/// a table written by them still holds those rows. A row keyed by a bare shard id does not say
38/// which stream it belongs to, so the store reads one only for the stream named by
39/// [`legacy_stream`](Self::legacy_stream): when a shard has no checkpoint under its own key, the
40/// bare row's checkpoint is used and copied under the new key. For any other stream the bare rows
41/// are ignored. With no stream named, a shard whose bare row holds a checkpoint is refused with an
42/// error naming the table, the shard and the stream that asked, and it is not read until the
43/// operator names the stream or deletes the row.
44///
45/// The two versions lease different rows, so an instance of each can read the same shard at
46/// once: stop the old instances before starting the new ones, or accept the duplicates.
47#[derive(Debug, Clone)]
48pub struct DynamoLeaseStore {
49    /// The client of whichever runtime a request runs on: a checkpoint is written from the thread
50    /// that acknowledges, which may be a handler's dedicated thread.
51    clients: Arc<RuntimeClients<Client>>,
52    table: String,
53    legacy_stream: Option<String>,
54}
55
56impl DynamoLeaseStore {
57    /// Uses `table` in the account and region of `config`.
58    ///
59    /// # Examples
60    ///
61    /// ```
62    /// # mod demo {
63    /// use std::error::Error;
64    /// use std::sync::Arc;
65    ///
66    /// use aws_config::BehaviorVersion;
67    /// use ruststream_kinesis::prelude::*;
68    /// # use serde::Deserialize;
69    /// #
70    /// # #[derive(Deserialize)]
71    /// # struct Order {
72    /// #     id: u64,
73    /// # }
74    /// #
75    /// # #[subscriber(KinesisStream::new("orders"))]
76    /// # async fn handle(order: &Order) -> HandlerOutcome {
77    /// #     tracing::info!(order.id, "accepted");
78    /// #     HandlerOutcome::ack()
79    /// # }
80    ///
81    /// pub async fn run() -> Result<(), Box<dyn Error>> {
82    ///     let config = aws_config::defaults(BehaviorVersion::latest()).load().await;
83    ///     let leases = Arc::new(DynamoLeaseStore::new(&config, "orders-leases"));
84    ///     let broker = KinesisBroker::from_config(config).lease_store(leases);
85    ///
86    ///     RustStream::new(AppInfo::new("orders", "0.1.0"))
87    ///         .with_broker(broker, |b| {
88    ///             b.include(handle);
89    ///         })
90    ///         .run()
91    ///         .await?;
92    ///     Ok(())
93    /// }
94    /// # }
95    /// # fn main() {}
96    /// ```
97    #[must_use]
98    pub fn new(config: &SdkConfig, table: impl Into<String>) -> Self {
99        Self {
100            clients: Arc::new(RuntimeClients::new(config.clone())),
101            table: table.into(),
102            legacy_stream: None,
103        }
104    }
105
106    /// Names the stream the table's rows keyed by a bare shard id belong to: the rows an earlier
107    /// version of this crate wrote, before a row was kept per stream and shard.
108    ///
109    /// A shard of `stream` with no checkpoint under its own key resumes from its bare row, and the
110    /// first read copies that checkpoint under the new key. Other streams ignore the bare rows.
111    /// Once the upgraded service has started a subscription on every stream it consumes, every
112    /// bare row that held progress has its copy, and the bare rows and this setting can go.
113    ///
114    /// A table that served more than one stream before the upgrade holds, in each bare row,
115    /// whichever stream checkpointed last: no stream can resume from it safely. Delete those rows
116    /// and open each stream at a chosen position instead.
117    ///
118    /// # Examples
119    ///
120    /// ```
121    /// # mod demo {
122    /// use std::error::Error;
123    /// use std::sync::Arc;
124    ///
125    /// use aws_config::BehaviorVersion;
126    /// use ruststream_kinesis::prelude::*;
127    /// # use serde::Deserialize;
128    /// #
129    /// # #[derive(Deserialize)]
130    /// # struct Order {
131    /// #     id: u64,
132    /// # }
133    /// #
134    /// # #[subscriber(KinesisStream::new("orders"))]
135    /// # async fn handle(order: &Order) -> HandlerOutcome {
136    /// #     tracing::info!(order.id, "accepted");
137    /// #     HandlerOutcome::ack()
138    /// # }
139    ///
140    /// pub async fn run() -> Result<(), Box<dyn Error>> {
141    ///     let config = aws_config::defaults(BehaviorVersion::latest()).load().await;
142    ///     // The table was written by an earlier version for the `orders` stream only.
143    ///     let leases =
144    ///         Arc::new(DynamoLeaseStore::new(&config, "orders-leases").legacy_stream("orders"));
145    ///     let broker = KinesisBroker::from_config(config).lease_store(leases);
146    ///
147    ///     RustStream::new(AppInfo::new("orders", "0.1.0"))
148    ///         .with_broker(broker, |b| {
149    ///             b.include(handle);
150    ///         })
151    ///         .run()
152    ///         .await?;
153    ///     Ok(())
154    /// }
155    /// # }
156    /// # fn main() {}
157    /// ```
158    #[must_use]
159    pub fn legacy_stream(mut self, stream: impl Into<String>) -> Self {
160        self.legacy_stream = Some(stream.into());
161        self
162    }
163
164    /// The item under `key`, read consistently.
165    async fn item(
166        &self,
167        key: AttributeValue,
168    ) -> Result<Option<HashMap<String, AttributeValue>>, LeaseError> {
169        let output = self
170            .clients
171            .with(Client::get_item)
172            .table_name(&self.table)
173            .key(KEY, key)
174            .consistent_read(true)
175            .send()
176            .await
177            .map_err(boxed)?;
178        Ok(output.item)
179    }
180
181    /// Copies a checkpoint from the shard's bare row under its own key, unless one is already
182    /// there: a checkpoint written in between by the owner is newer, and it stays.
183    /// Copies a bare row's checkpoint onto the stream's own row, unless that row has one already.
184    /// Answers whether this copy wrote it: `false` when another owner saved progress first.
185    async fn adopt(&self, key: &LeaseKey, checkpoint: &str) -> Result<bool, LeaseError> {
186        let outcome = self
187            .clients
188            .with(Client::update_item)
189            .table_name(&self.table)
190            .key(KEY, row_key(key))
191            .update_expression("SET checkpoint = :seq ADD lease_counter :one")
192            .condition_expression("attribute_not_exists(checkpoint)")
193            .expression_attribute_values(":seq", AttributeValue::S(checkpoint.to_owned()))
194            .expression_attribute_values(":one", AttributeValue::N("1".to_owned()))
195            .send()
196            .await;
197        match outcome {
198            Ok(_) => Ok(true),
199            Err(err)
200                if err
201                    .as_service_error()
202                    .is_some_and(UpdateItemError::is_conditional_check_failed_exception) =>
203            {
204                Ok(false)
205            }
206            Err(err) => Err(boxed(err)),
207        }
208    }
209
210    fn now_millis() -> u64 {
211        SystemTime::now()
212            .duration_since(UNIX_EPOCH)
213            .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
214    }
215}
216
217/// The partition key of `key`'s row.
218fn row_key(key: &LeaseKey) -> AttributeValue {
219    AttributeValue::S(key.to_string())
220}
221
222/// The checkpoint an item holds, if any.
223fn checkpoint_of(item: Option<&HashMap<String, AttributeValue>>) -> Option<String> {
224    item.and_then(|item| item.get(CHECKPOINT))
225        .and_then(|value| value.as_s().ok())
226        .cloned()
227}
228
229/// A shard whose only progress is in a row keyed by its bare shard id, in a table that names no
230/// stream for those rows.
231#[derive(Debug, thiserror::Error)]
232#[error(
233    "lease table '{table}' holds a checkpoint for shard '{shard}' under the bare shard id, the key \
234     earlier versions wrote without the stream, so it may belong to another stream than \
235     '{stream}'; name the stream those rows belong to with `DynamoLeaseStore::legacy_stream`, or \
236     delete the row to start the shard without it"
237)]
238struct UnattributedLegacyRow {
239    table: String,
240    shard: String,
241    stream: String,
242}
243
244fn boxed<E>(err: E) -> LeaseError
245where
246    E: std::error::Error + Send + Sync + 'static,
247{
248    Box::new(err)
249}
250
251impl LeaseStore for DynamoLeaseStore {
252    fn acquire<'a>(
253        &'a self,
254        key: &'a LeaseKey,
255        owner: &'a str,
256        ttl: Duration,
257    ) -> BoxFuture<'a, Result<bool, LeaseError>> {
258        Box::pin(async move {
259            let now = Self::now_millis();
260            let expiry = now + u64::try_from(ttl.as_millis()).unwrap_or(u64::MAX);
261            let outcome = self
262                .clients
263                .with(Client::update_item)
264                .table_name(&self.table)
265                .key(KEY, row_key(key))
266                .update_expression(
267                    "SET lease_owner = :me, lease_expiry = :expiry \
268                     ADD lease_counter :one",
269                )
270                .condition_expression(
271                    "attribute_not_exists(lease_owner) OR lease_owner = :me \
272                     OR lease_expiry < :now",
273                )
274                .expression_attribute_values(":me", AttributeValue::S(owner.to_owned()))
275                .expression_attribute_values(":expiry", AttributeValue::N(expiry.to_string()))
276                .expression_attribute_values(":now", AttributeValue::N(now.to_string()))
277                .expression_attribute_values(":one", AttributeValue::N("1".to_owned()))
278                .send()
279                .await;
280            match outcome {
281                Ok(_) => Ok(true),
282                Err(err) => {
283                    if err
284                        .as_service_error()
285                        .is_some_and(UpdateItemError::is_conditional_check_failed_exception)
286                    {
287                        Ok(false)
288                    } else {
289                        Err(boxed(err))
290                    }
291                }
292            }
293        })
294    }
295
296    fn renew<'a>(
297        &'a self,
298        key: &'a LeaseKey,
299        owner: &'a str,
300        ttl: Duration,
301    ) -> BoxFuture<'a, Result<bool, LeaseError>> {
302        Box::pin(async move {
303            let expiry = Self::now_millis() + u64::try_from(ttl.as_millis()).unwrap_or(u64::MAX);
304            let outcome = self
305                .clients
306                .with(Client::update_item)
307                .table_name(&self.table)
308                .key(KEY, row_key(key))
309                .update_expression("SET lease_expiry = :expiry ADD lease_counter :one")
310                .condition_expression("lease_owner = :me")
311                .expression_attribute_values(":me", AttributeValue::S(owner.to_owned()))
312                .expression_attribute_values(":expiry", AttributeValue::N(expiry.to_string()))
313                .expression_attribute_values(":one", AttributeValue::N("1".to_owned()))
314                .send()
315                .await;
316            match outcome {
317                Ok(_) => Ok(true),
318                Err(err) => {
319                    if err
320                        .as_service_error()
321                        .is_some_and(UpdateItemError::is_conditional_check_failed_exception)
322                    {
323                        Ok(false)
324                    } else {
325                        Err(boxed(err))
326                    }
327                }
328            }
329        })
330    }
331
332    fn checkpoint<'a>(
333        &'a self,
334        key: &'a LeaseKey,
335        owner: &'a str,
336        sequence: &'a str,
337    ) -> BoxFuture<'a, Result<bool, LeaseError>> {
338        Box::pin(async move {
339            let outcome = self
340                .clients
341                .with(Client::update_item)
342                .table_name(&self.table)
343                .key(KEY, row_key(key))
344                .update_expression("SET checkpoint = :seq ADD lease_counter :one")
345                .condition_expression("lease_owner = :me")
346                .expression_attribute_values(":me", AttributeValue::S(owner.to_owned()))
347                .expression_attribute_values(":seq", AttributeValue::S(sequence.to_owned()))
348                .expression_attribute_values(":one", AttributeValue::N("1".to_owned()))
349                .send()
350                .await;
351            match outcome {
352                Ok(_) => Ok(true),
353                Err(err) => {
354                    if err
355                        .as_service_error()
356                        .is_some_and(UpdateItemError::is_conditional_check_failed_exception)
357                    {
358                        Ok(false)
359                    } else {
360                        Err(boxed(err))
361                    }
362                }
363            }
364        })
365    }
366
367    fn read<'a>(&'a self, key: &'a LeaseKey) -> BoxFuture<'a, Result<LeaseState, LeaseError>> {
368        Box::pin(async move {
369            let own = self.item(row_key(key)).await?;
370            if let Some(checkpoint) = checkpoint_of(own.as_ref()) {
371                return Ok(LeaseState {
372                    checkpoint: Some(checkpoint),
373                });
374            }
375            // The bare rows are another stream's progress: nothing read there could apply.
376            if self
377                .legacy_stream
378                .as_deref()
379                .is_some_and(|stream| stream != key.stream())
380            {
381                return Ok(LeaseState::default());
382            }
383            let legacy = self.item(AttributeValue::S(key.shard().to_owned())).await?;
384            let Some(checkpoint) = checkpoint_of(legacy.as_ref()) else {
385                return Ok(LeaseState::default());
386            };
387            match self.legacy_stream.as_deref() {
388                Some(_) => {
389                    if self.adopt(key, &checkpoint).await? {
390                        return Ok(LeaseState {
391                            checkpoint: Some(checkpoint),
392                        });
393                    }
394                    // Another owner saved progress on the stream's row meanwhile: that is the
395                    // position, not the older bare row's.
396                    let own = self.item(row_key(key)).await?;
397                    Ok(LeaseState {
398                        checkpoint: checkpoint_of(own.as_ref()).or(Some(checkpoint)),
399                    })
400                }
401                None => Err(boxed(UnattributedLegacyRow {
402                    table: self.table.clone(),
403                    shard: key.shard().to_owned(),
404                    stream: key.stream().to_owned(),
405                })),
406            }
407        })
408    }
409
410    fn release<'a>(
411        &'a self,
412        key: &'a LeaseKey,
413        owner: &'a str,
414    ) -> BoxFuture<'a, Result<(), LeaseError>> {
415        Box::pin(async move {
416            let outcome = self
417                .clients
418                .with(Client::update_item)
419                .table_name(&self.table)
420                .key(KEY, row_key(key))
421                .update_expression("REMOVE lease_owner, lease_expiry ADD lease_counter :one")
422                .condition_expression("lease_owner = :me")
423                .expression_attribute_values(":me", AttributeValue::S(owner.to_owned()))
424                .expression_attribute_values(":one", AttributeValue::N("1".to_owned()))
425                .send()
426                .await;
427            match outcome {
428                Ok(_) => Ok(()),
429                Err(err) => {
430                    if err
431                        .as_service_error()
432                        .is_some_and(UpdateItemError::is_conditional_check_failed_exception)
433                    {
434                        Ok(()) // already stolen; nothing to release
435                    } else {
436                        Err(boxed(err))
437                    }
438                }
439            }
440        })
441    }
442}