use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use aws_config::SdkConfig;
use aws_sdk_dynamodb::Client;
use aws_sdk_dynamodb::operation::update_item::UpdateItemError;
use aws_sdk_dynamodb::types::AttributeValue;
use futures::future::BoxFuture;
use crate::clients::RuntimeClients;
use crate::lease::{LeaseError, LeaseKey, LeaseState, LeaseStore};
const KEY: &str = "lease_key";
const CHECKPOINT: &str = "checkpoint";
#[derive(Debug, Clone)]
pub struct DynamoLeaseStore {
clients: Arc<RuntimeClients<Client>>,
table: String,
legacy_stream: Option<String>,
}
impl DynamoLeaseStore {
#[must_use]
pub fn new(config: &SdkConfig, table: impl Into<String>) -> Self {
Self {
clients: Arc::new(RuntimeClients::new(config.clone())),
table: table.into(),
legacy_stream: None,
}
}
#[must_use]
pub fn legacy_stream(mut self, stream: impl Into<String>) -> Self {
self.legacy_stream = Some(stream.into());
self
}
async fn item(
&self,
key: AttributeValue,
) -> Result<Option<HashMap<String, AttributeValue>>, LeaseError> {
let output = self
.clients
.with(Client::get_item)
.table_name(&self.table)
.key(KEY, key)
.consistent_read(true)
.send()
.await
.map_err(boxed)?;
Ok(output.item)
}
async fn adopt(&self, key: &LeaseKey, checkpoint: &str) -> Result<bool, LeaseError> {
let outcome = self
.clients
.with(Client::update_item)
.table_name(&self.table)
.key(KEY, row_key(key))
.update_expression("SET checkpoint = :seq ADD lease_counter :one")
.condition_expression("attribute_not_exists(checkpoint)")
.expression_attribute_values(":seq", AttributeValue::S(checkpoint.to_owned()))
.expression_attribute_values(":one", AttributeValue::N("1".to_owned()))
.send()
.await;
match outcome {
Ok(_) => Ok(true),
Err(err)
if err
.as_service_error()
.is_some_and(UpdateItemError::is_conditional_check_failed_exception) =>
{
Ok(false)
}
Err(err) => Err(boxed(err)),
}
}
fn now_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
}
}
fn row_key(key: &LeaseKey) -> AttributeValue {
AttributeValue::S(key.to_string())
}
fn checkpoint_of(item: Option<&HashMap<String, AttributeValue>>) -> Option<String> {
item.and_then(|item| item.get(CHECKPOINT))
.and_then(|value| value.as_s().ok())
.cloned()
}
#[derive(Debug, thiserror::Error)]
#[error(
"lease table '{table}' holds a checkpoint for shard '{shard}' under the bare shard id, the key \
earlier versions wrote without the stream, so it may belong to another stream than \
'{stream}'; name the stream those rows belong to with `DynamoLeaseStore::legacy_stream`, or \
delete the row to start the shard without it"
)]
struct UnattributedLegacyRow {
table: String,
shard: String,
stream: String,
}
fn boxed<E>(err: E) -> LeaseError
where
E: std::error::Error + Send + Sync + 'static,
{
Box::new(err)
}
impl LeaseStore for DynamoLeaseStore {
fn acquire<'a>(
&'a self,
key: &'a LeaseKey,
owner: &'a str,
ttl: Duration,
) -> BoxFuture<'a, Result<bool, LeaseError>> {
Box::pin(async move {
let now = Self::now_millis();
let expiry = now + u64::try_from(ttl.as_millis()).unwrap_or(u64::MAX);
let outcome = self
.clients
.with(Client::update_item)
.table_name(&self.table)
.key(KEY, row_key(key))
.update_expression(
"SET lease_owner = :me, lease_expiry = :expiry \
ADD lease_counter :one",
)
.condition_expression(
"attribute_not_exists(lease_owner) OR lease_owner = :me \
OR lease_expiry < :now",
)
.expression_attribute_values(":me", AttributeValue::S(owner.to_owned()))
.expression_attribute_values(":expiry", AttributeValue::N(expiry.to_string()))
.expression_attribute_values(":now", AttributeValue::N(now.to_string()))
.expression_attribute_values(":one", AttributeValue::N("1".to_owned()))
.send()
.await;
match outcome {
Ok(_) => Ok(true),
Err(err) => {
if err
.as_service_error()
.is_some_and(UpdateItemError::is_conditional_check_failed_exception)
{
Ok(false)
} else {
Err(boxed(err))
}
}
}
})
}
fn renew<'a>(
&'a self,
key: &'a LeaseKey,
owner: &'a str,
ttl: Duration,
) -> BoxFuture<'a, Result<bool, LeaseError>> {
Box::pin(async move {
let expiry = Self::now_millis() + u64::try_from(ttl.as_millis()).unwrap_or(u64::MAX);
let outcome = self
.clients
.with(Client::update_item)
.table_name(&self.table)
.key(KEY, row_key(key))
.update_expression("SET lease_expiry = :expiry ADD lease_counter :one")
.condition_expression("lease_owner = :me")
.expression_attribute_values(":me", AttributeValue::S(owner.to_owned()))
.expression_attribute_values(":expiry", AttributeValue::N(expiry.to_string()))
.expression_attribute_values(":one", AttributeValue::N("1".to_owned()))
.send()
.await;
match outcome {
Ok(_) => Ok(true),
Err(err) => {
if err
.as_service_error()
.is_some_and(UpdateItemError::is_conditional_check_failed_exception)
{
Ok(false)
} else {
Err(boxed(err))
}
}
}
})
}
fn checkpoint<'a>(
&'a self,
key: &'a LeaseKey,
owner: &'a str,
sequence: &'a str,
) -> BoxFuture<'a, Result<bool, LeaseError>> {
Box::pin(async move {
let outcome = self
.clients
.with(Client::update_item)
.table_name(&self.table)
.key(KEY, row_key(key))
.update_expression("SET checkpoint = :seq ADD lease_counter :one")
.condition_expression("lease_owner = :me")
.expression_attribute_values(":me", AttributeValue::S(owner.to_owned()))
.expression_attribute_values(":seq", AttributeValue::S(sequence.to_owned()))
.expression_attribute_values(":one", AttributeValue::N("1".to_owned()))
.send()
.await;
match outcome {
Ok(_) => Ok(true),
Err(err) => {
if err
.as_service_error()
.is_some_and(UpdateItemError::is_conditional_check_failed_exception)
{
Ok(false)
} else {
Err(boxed(err))
}
}
}
})
}
fn read<'a>(&'a self, key: &'a LeaseKey) -> BoxFuture<'a, Result<LeaseState, LeaseError>> {
Box::pin(async move {
let own = self.item(row_key(key)).await?;
if let Some(checkpoint) = checkpoint_of(own.as_ref()) {
return Ok(LeaseState {
checkpoint: Some(checkpoint),
});
}
if self
.legacy_stream
.as_deref()
.is_some_and(|stream| stream != key.stream())
{
return Ok(LeaseState::default());
}
let legacy = self.item(AttributeValue::S(key.shard().to_owned())).await?;
let Some(checkpoint) = checkpoint_of(legacy.as_ref()) else {
return Ok(LeaseState::default());
};
match self.legacy_stream.as_deref() {
Some(_) => {
if self.adopt(key, &checkpoint).await? {
return Ok(LeaseState {
checkpoint: Some(checkpoint),
});
}
let own = self.item(row_key(key)).await?;
Ok(LeaseState {
checkpoint: checkpoint_of(own.as_ref()).or(Some(checkpoint)),
})
}
None => Err(boxed(UnattributedLegacyRow {
table: self.table.clone(),
shard: key.shard().to_owned(),
stream: key.stream().to_owned(),
})),
}
})
}
fn release<'a>(
&'a self,
key: &'a LeaseKey,
owner: &'a str,
) -> BoxFuture<'a, Result<(), LeaseError>> {
Box::pin(async move {
let outcome = self
.clients
.with(Client::update_item)
.table_name(&self.table)
.key(KEY, row_key(key))
.update_expression("REMOVE lease_owner, lease_expiry ADD lease_counter :one")
.condition_expression("lease_owner = :me")
.expression_attribute_values(":me", AttributeValue::S(owner.to_owned()))
.expression_attribute_values(":one", AttributeValue::N("1".to_owned()))
.send()
.await;
match outcome {
Ok(_) => Ok(()),
Err(err) => {
if err
.as_service_error()
.is_some_and(UpdateItemError::is_conditional_check_failed_exception)
{
Ok(()) } else {
Err(boxed(err))
}
}
}
})
}
}