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}