use aws_sdk_dynamodb::error::{ProvideErrorMetadata, SdkError};
use aws_sdk_dynamodb::primitives::Blob;
use aws_sdk_dynamodb::types::{AttributeValue, ReturnValue};
use aws_sdk_dynamodb::Client;
use rainier_support::{Error, Result};
use crate::aws::{sdk_error, AwsConnector};
pub const KEY_ATTRIBUTE: &str = "key";
pub const VALUE_ATTRIBUTE: &str = "value";
pub const TTL_ATTRIBUTE: &str = "expires_at";
pub const COUNTER_ATTRIBUTE: &str = "counter";
#[derive(Debug, Clone)]
pub struct DynamoItem {
pub value: Option<Vec<u8>>,
pub expires_at: Option<i64>,
}
impl DynamoItem {
pub fn is_expired(&self, now: i64) -> bool {
matches!(self.expires_at, Some(expires_at) if expires_at <= now)
}
}
#[derive(Clone)]
pub struct DynamoDbClient {
client: Client,
table: String,
}
impl DynamoDbClient {
pub fn new(connector: &AwsConnector, table: impl Into<String>) -> Self {
Self { client: connector.dynamodb(), table: table.into() }
}
pub fn with_client(client: Client, table: impl Into<String>) -> Self {
Self { client, table: table.into() }
}
pub fn table(&self) -> &str {
&self.table
}
pub fn inner(&self) -> &Client {
&self.client
}
pub async fn get(&self, key: &str) -> Result<Option<DynamoItem>> {
let output = self
.client
.get_item()
.table_name(&self.table)
.key(KEY_ATTRIBUTE, AttributeValue::S(key.to_string()))
.consistent_read(true)
.send()
.await
.map_err(|e| sdk_error("DynamoDB get_item", e))?;
let Some(item) = output.item() else { return Ok(None) };
Ok(Some(DynamoItem {
value: match item.get(VALUE_ATTRIBUTE) {
Some(AttributeValue::B(blob)) => Some(blob.as_ref().to_vec()),
_ => None,
},
expires_at: match item.get(TTL_ATTRIBUTE) {
Some(AttributeValue::N(seconds)) => seconds.parse().ok(),
_ => None,
},
}))
}
pub async fn put(&self, key: &str, value: &[u8], expires_at: Option<i64>) -> Result<()> {
let mut request = self
.client
.put_item()
.table_name(&self.table)
.item(KEY_ATTRIBUTE, AttributeValue::S(key.to_string()))
.item(VALUE_ATTRIBUTE, AttributeValue::B(Blob::new(value.to_vec())));
if let Some(expires_at) = expires_at {
request = request.item(TTL_ATTRIBUTE, AttributeValue::N(expires_at.to_string()));
}
request.send().await.map_err(|e| sdk_error("DynamoDB put_item", e))?;
Ok(())
}
pub async fn put_if_absent(
&self,
key: &str,
value: &[u8],
expires_at: Option<i64>,
now: i64,
) -> Result<bool> {
let mut request = self
.client
.put_item()
.table_name(&self.table)
.item(KEY_ATTRIBUTE, AttributeValue::S(key.to_string()))
.item(VALUE_ATTRIBUTE, AttributeValue::B(Blob::new(value.to_vec())))
.condition_expression("attribute_not_exists(#k) OR #ttl < :now")
.expression_attribute_names("#k", KEY_ATTRIBUTE)
.expression_attribute_names("#ttl", TTL_ATTRIBUTE)
.expression_attribute_values(":now", AttributeValue::N(now.to_string()));
if let Some(expires_at) = expires_at {
request = request.item(TTL_ATTRIBUTE, AttributeValue::N(expires_at.to_string()));
}
match request.send().await {
Ok(_) => Ok(true),
Err(e) if is_conditional_check_failure(&e) => Ok(false),
Err(e) => Err(sdk_error("DynamoDB put_item", e)),
}
}
pub async fn delete_if(&self, key: &str, expected: &[u8]) -> Result<bool> {
let result = self
.client
.delete_item()
.table_name(&self.table)
.key(KEY_ATTRIBUTE, AttributeValue::S(key.to_string()))
.condition_expression("#v = :expected")
.expression_attribute_names("#v", VALUE_ATTRIBUTE)
.expression_attribute_values(
":expected",
AttributeValue::B(Blob::new(expected.to_vec())),
)
.send()
.await;
match result {
Ok(_) => Ok(true),
Err(e) if is_conditional_check_failure(&e) => Ok(false),
Err(e) => Err(sdk_error("DynamoDB delete_item", e)),
}
}
pub async fn delete(&self, key: &str) -> Result<bool> {
let output = self
.client
.delete_item()
.table_name(&self.table)
.key(KEY_ATTRIBUTE, AttributeValue::S(key.to_string()))
.return_values(ReturnValue::AllOld)
.send()
.await
.map_err(|e| sdk_error("DynamoDB delete_item", e))?;
Ok(output.attributes().is_some())
}
pub async fn add_to_counter(&self, key: &str, delta: i64) -> Result<i64> {
let output = self
.client
.update_item()
.table_name(&self.table)
.key(KEY_ATTRIBUTE, AttributeValue::S(key.to_string()))
.update_expression("ADD #counter :delta")
.expression_attribute_names("#counter", COUNTER_ATTRIBUTE)
.expression_attribute_values(":delta", AttributeValue::N(delta.to_string()))
.return_values(ReturnValue::UpdatedNew)
.send()
.await
.map_err(|e| sdk_error("DynamoDB update_item", e))?;
match output.attributes().and_then(|attributes| attributes.get(COUNTER_ATTRIBUTE)) {
Some(AttributeValue::N(value)) => value
.parse()
.map_err(|_| Error::internal("DynamoDB returned a non-numeric counter")),
_ => Err(Error::internal("DynamoDB did not return the updated counter")),
}
}
}
impl std::fmt::Debug for DynamoDbClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DynamoDbClient").field("table", &self.table).finish()
}
}
pub fn is_conditional_check_failure<E, R>(error: &SdkError<E, R>) -> bool
where
E: ProvideErrorMetadata,
{
match error {
SdkError::ServiceError(service) => {
matches!(service.err().code(), Some("ConditionalCheckFailedException"))
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
async fn client() -> DynamoDbClient {
DynamoDbClient::new(
&AwsConnector::with_credentials("id", "secret", "us-east-1").await,
"rainier_cache",
)
}
#[tokio::test]
async fn it_remembers_its_table() {
assert_eq!(client().await.table(), "rainier_cache");
}
#[test]
fn an_item_with_no_expiry_never_expires() {
let item = DynamoItem { value: Some(vec![1]), expires_at: None };
assert!(!item.is_expired(i64::MAX));
}
#[test]
fn an_item_past_its_expiry_is_expired() {
let item = DynamoItem { value: Some(vec![1]), expires_at: Some(1_000) };
assert!(item.is_expired(1_000), "expiry is inclusive");
assert!(item.is_expired(1_001));
assert!(!item.is_expired(999));
}
#[test]
fn the_attribute_names_are_the_documented_ones() {
assert_eq!(KEY_ATTRIBUTE, "key");
assert_eq!(VALUE_ATTRIBUTE, "value");
assert_eq!(TTL_ATTRIBUTE, "expires_at");
assert_eq!(COUNTER_ATTRIBUTE, "counter");
}
}