alien_bindings/providers/kv/
aws_dynamodb.rs1use crate::error::{map_cloud_client_error, ErrorData, Result};
2use crate::traits::{Binding, Kv, PutOptions, ScanResult};
3use alien_aws_clients::dynamodb::*;
4use alien_error::{AlienError, Context, IntoAlienError};
5use async_trait::async_trait;
6use base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine};
7use chrono::Utc;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::fmt::{Debug, Formatter};
11
12use super::{validate_key, validate_value};
13
14const HASH_BUCKET_COUNT: u8 = 16;
15
16#[derive(Debug, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18struct CursorState {
19 version: u8,
20 prefix: String,
21 bucket: u8,
22 last_key: Option<String>,
23}
24
25pub struct AwsDynamodbKv {
30 client: DynamoDbClient,
31 table_name: String,
32}
33
34impl Debug for AwsDynamodbKv {
35 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
36 f.debug_struct("AwsDynamodbKv")
37 .field("table_name", &self.table_name)
38 .finish()
39 }
40}
41
42impl AwsDynamodbKv {
43 pub fn new(table_name: String, client: DynamoDbClient) -> Self {
44 Self { client, table_name }
45 }
46
47 fn hash_bucket(&self, key: &str) -> String {
49 use std::collections::hash_map::DefaultHasher;
50 use std::hash::{Hash, Hasher};
51
52 let mut hasher = DefaultHasher::new();
53 key.hash(&mut hasher);
54 let bucket_id = hasher.finish() % u64::from(HASH_BUCKET_COUNT);
55 format!("bucket_{}", bucket_id)
56 }
57
58 fn is_expired(&self, ttl_epoch: Option<i64>) -> bool {
60 if let Some(ttl_timestamp) = ttl_epoch {
61 let now = Utc::now().timestamp();
62 now >= ttl_timestamp
63 } else {
64 false
65 }
66 }
67
68 fn encode_cursor(state: &CursorState) -> Result<String> {
69 let json =
70 serde_json::to_vec(state)
71 .into_alien_error()
72 .context(ErrorData::InvalidInput {
73 operation_context: "DynamoDB KV scan cursor encoding".to_string(),
74 details: "Failed to serialize cursor state".to_string(),
75 field_name: Some("cursor".to_string()),
76 })?;
77 Ok(BASE64_URL_SAFE_NO_PAD.encode(json))
78 }
79
80 fn decode_cursor(prefix: &str, cursor: &str) -> Result<CursorState> {
81 let decoded = BASE64_URL_SAFE_NO_PAD
82 .decode(cursor)
83 .into_alien_error()
84 .context(ErrorData::InvalidInput {
85 operation_context: "DynamoDB KV scan cursor decoding".to_string(),
86 details: "Invalid cursor encoding".to_string(),
87 field_name: Some("cursor".to_string()),
88 })?;
89 let state: CursorState = serde_json::from_slice(&decoded)
90 .into_alien_error()
91 .context(ErrorData::InvalidInput {
92 operation_context: "DynamoDB KV scan cursor decoding".to_string(),
93 details: "Invalid cursor data".to_string(),
94 field_name: Some("cursor".to_string()),
95 })?;
96 if state.version != 1 || state.prefix != prefix || state.bucket >= HASH_BUCKET_COUNT {
97 return Err(AlienError::new(ErrorData::InvalidInput {
98 operation_context: "DynamoDB KV scan cursor validation".to_string(),
99 details: "Cursor does not belong to this prefix scan".to_string(),
100 field_name: Some("cursor".to_string()),
101 }));
102 }
103 if state
104 .last_key
105 .as_ref()
106 .is_some_and(|last_key| !last_key.starts_with(prefix))
107 {
108 return Err(AlienError::new(ErrorData::InvalidInput {
109 operation_context: "DynamoDB KV scan cursor validation".to_string(),
110 details: "Cursor key does not match the scan prefix".to_string(),
111 field_name: Some("cursor".to_string()),
112 }));
113 }
114 Ok(state)
115 }
116}
117
118impl Binding for AwsDynamodbKv {}
119
120#[async_trait]
121impl Kv for AwsDynamodbKv {
122 async fn get(&self, key: &str) -> Result<Option<Vec<u8>>> {
123 validate_key(key)?;
124
125 let bucket = self.hash_bucket(key);
126 let mut primary_key = HashMap::new();
127 primary_key.insert("pk".to_string(), AttributeValue::s(bucket));
128 primary_key.insert("sk".to_string(), AttributeValue::s(key.to_string()));
129
130 let request = GetItemRequest::builder()
131 .table_name(self.table_name.clone())
132 .key(primary_key)
133 .consistent_read(true)
138 .build();
139
140 let response = self.client.get_item(request).await.map_err(|e| {
141 map_cloud_client_error(
142 e,
143 format!("Failed to get item with key '{}'", key),
144 Some(key.to_string()),
145 )
146 })?;
147
148 if let Some(item) = response.item {
149 if let Some(ttl_attr) = item.get("ttl") {
151 if let Some(ttl_epoch) = ttl_attr.n.as_ref().and_then(|s| s.parse::<i64>().ok()) {
152 if self.is_expired(Some(ttl_epoch)) {
153 return Ok(None); }
155 }
156 }
157
158 let value = item
159 .get("value")
160 .and_then(|attr| attr.b.as_ref())
161 .and_then(|base64_value| base64::prelude::BASE64_STANDARD.decode(base64_value).ok())
162 .ok_or_else(|| {
163 AlienError::new(ErrorData::CloudPlatformError {
164 message: format!("Missing or invalid value attribute for key '{}'", key),
165 resource_id: Some(key.to_string()),
166 })
167 })?;
168
169 Ok(Some(value))
170 } else {
171 Ok(None)
172 }
173 }
174
175 async fn put(&self, key: &str, value: Vec<u8>, options: Option<PutOptions>) -> Result<bool> {
176 validate_key(key)?;
177 validate_value(&value)?;
178
179 let bucket = self.hash_bucket(key);
180 let options = options.unwrap_or_default();
181
182 let mut item = HashMap::new();
183 item.insert("pk".to_string(), AttributeValue::s(bucket));
184 item.insert("sk".to_string(), AttributeValue::s(key.to_string()));
185 item.insert(
186 "value".to_string(),
187 AttributeValue::b(base64::prelude::BASE64_STANDARD.encode(&value)),
188 );
189
190 if let Some(ttl) = options.ttl {
191 let expires_at = (Utc::now() + ttl).timestamp();
192 item.insert("ttl".to_string(), AttributeValue::n(expires_at.to_string()));
193 }
194
195 let request = if options.if_not_exists {
196 let mut expression_attribute_names = HashMap::new();
203 expression_attribute_names.insert("#ttl".to_string(), "ttl".to_string());
204 let mut expression_attribute_values = HashMap::new();
205 expression_attribute_values.insert(
206 ":now".to_string(),
207 AttributeValue::n(Utc::now().timestamp().to_string()),
208 );
209 PutItemRequest::builder()
210 .table_name(self.table_name.clone())
211 .item(item)
212 .condition_expression(
213 "(attribute_not_exists(pk) AND attribute_not_exists(sk)) OR (attribute_exists(#ttl) AND #ttl <= :now)"
214 .to_string(),
215 )
216 .expression_attribute_names(expression_attribute_names)
217 .expression_attribute_values(expression_attribute_values)
218 .build()
219 } else {
220 PutItemRequest::builder()
221 .table_name(self.table_name.clone())
222 .item(item)
223 .build()
224 };
225
226 match self.client.put_item(request).await {
227 Ok(_) => Ok(true),
228 Err(e) => {
229 if options.if_not_exists {
231 if let Some(alien_client_core::ErrorData::RemoteResourceConflict { .. }) =
232 &e.error
233 {
234 return Ok(false);
235 }
236 }
237 Err(map_cloud_client_error(
238 e,
239 format!("Failed to put item with key '{}'", key),
240 Some(key.to_string()),
241 ))
242 }
243 }
244 }
245
246 async fn delete(&self, key: &str) -> Result<()> {
247 validate_key(key)?;
248
249 let bucket = self.hash_bucket(key);
250 let mut primary_key = HashMap::new();
251 primary_key.insert("pk".to_string(), AttributeValue::s(bucket));
252 primary_key.insert("sk".to_string(), AttributeValue::s(key.to_string()));
253
254 let request = DeleteItemRequest::builder()
255 .table_name(self.table_name.clone())
256 .key(primary_key)
257 .build();
258
259 self.client.delete_item(request).await.map_err(|e| {
260 map_cloud_client_error(
261 e,
262 format!("Failed to delete item with key '{}'", key),
263 Some(key.to_string()),
264 )
265 })?;
266
267 Ok(())
268 }
269
270 async fn exists(&self, key: &str) -> Result<bool> {
271 validate_key(key)?;
272
273 let bucket = self.hash_bucket(key);
274 let mut primary_key = HashMap::new();
275 primary_key.insert("pk".to_string(), AttributeValue::s(bucket));
276 primary_key.insert("sk".to_string(), AttributeValue::s(key.to_string()));
277
278 let mut expression_attribute_names = HashMap::new();
280 expression_attribute_names.insert("#ttl".to_string(), "ttl".to_string());
281
282 let request = GetItemRequest::builder()
283 .table_name(self.table_name.clone())
284 .key(primary_key)
285 .projection_expression("pk, #ttl".to_string()) .expression_attribute_names(expression_attribute_names)
287 .consistent_read(true)
288 .build();
289
290 let response = self.client.get_item(request).await.map_err(|e| {
291 map_cloud_client_error(
292 e,
293 format!("Failed to check existence of item with key '{}'", key),
294 Some(key.to_string()),
295 )
296 })?;
297
298 if let Some(item) = response.item {
299 if let Some(ttl_attr) = item.get("ttl") {
301 if let Some(ttl_epoch) = ttl_attr.n.as_ref().and_then(|s| s.parse::<i64>().ok()) {
302 if self.is_expired(Some(ttl_epoch)) {
303 return Ok(false); }
305 }
306 }
307 Ok(true)
308 } else {
309 Ok(false)
310 }
311 }
312
313 async fn scan_prefix(
314 &self,
315 prefix: &str,
316 limit: Option<usize>,
317 cursor: Option<String>,
318 ) -> Result<ScanResult> {
319 validate_key(prefix)?;
320 let limit = limit.unwrap_or(1000);
321 let initial = cursor
322 .as_deref()
323 .map(|cursor| Self::decode_cursor(prefix, cursor))
324 .transpose()?
325 .unwrap_or(CursorState {
326 version: 1,
327 prefix: prefix.to_string(),
328 bucket: 0,
329 last_key: None,
330 });
331 if limit == 0 {
332 return Ok(ScanResult {
333 items: Vec::new(),
334 next_cursor: cursor,
335 });
336 }
337
338 let mut items = Vec::with_capacity(limit);
339 let mut bucket_id = initial.bucket;
340 let mut last_key = initial.last_key;
341
342 while bucket_id < HASH_BUCKET_COUNT {
343 let bucket = format!("bucket_{}", bucket_id);
344 let mut expression_attribute_values = HashMap::new();
345 expression_attribute_values
346 .insert(":bucket".to_string(), AttributeValue::s(bucket.clone()));
347 expression_attribute_values
348 .insert(":prefix".to_string(), AttributeValue::s(prefix.to_string()));
349
350 let exclusive_start_key = last_key.as_ref().map(|key| {
351 HashMap::from([
352 ("pk".to_string(), AttributeValue::s(bucket.clone())),
353 ("sk".to_string(), AttributeValue::s(key.clone())),
354 ])
355 });
356 let request = QueryRequest::builder()
357 .table_name(self.table_name.clone())
358 .key_condition_expression("pk = :bucket AND begins_with(sk, :prefix)".to_string())
359 .expression_attribute_values(expression_attribute_values)
360 .limit(i32::try_from(limit - items.len()).unwrap_or(i32::MAX))
361 .maybe_exclusive_start_key(exclusive_start_key)
362 .build();
363
364 let response = self.client.query(request).await.map_err(|e| {
365 map_cloud_client_error(
366 e,
367 format!("Failed to scan prefix '{}' in bucket {}", prefix, bucket_id),
368 Some(prefix.to_string()),
369 )
370 })?;
371
372 for item in response.items {
373 if let Some(ttl_attr) = item.get("ttl") {
374 if let Some(ttl_epoch) = ttl_attr.n.as_ref().and_then(|s| s.parse::<i64>().ok())
375 {
376 if self.is_expired(Some(ttl_epoch)) {
377 continue; }
379 }
380 }
381
382 if let (Some(key_attr), Some(value_attr)) = (item.get("sk"), item.get("value")) {
383 if let (Some(key), Some(base64_value)) =
384 (key_attr.s.as_ref(), value_attr.b.as_ref())
385 {
386 if let Ok(value) = base64::prelude::BASE64_STANDARD.decode(base64_value) {
387 items.push((key.clone(), value));
388 }
389 }
390 }
391 }
392
393 let provider_last_key = response
394 .last_evaluated_key
395 .as_ref()
396 .and_then(|key| key.get("sk"))
397 .and_then(|value| value.s.clone());
398
399 if items.len() == limit {
400 let (next_bucket, next_key) = match provider_last_key {
401 Some(key) => (bucket_id, Some(key)),
402 None if bucket_id + 1 < HASH_BUCKET_COUNT => (bucket_id + 1, None),
403 None => {
404 return Ok(ScanResult {
405 items,
406 next_cursor: None,
407 });
408 }
409 };
410 return Ok(ScanResult {
411 items,
412 next_cursor: Some(Self::encode_cursor(&CursorState {
413 version: 1,
414 prefix: prefix.to_string(),
415 bucket: next_bucket,
416 last_key: next_key,
417 })?),
418 });
419 }
420
421 if let Some(key) = provider_last_key {
422 last_key = Some(key);
423 } else {
424 bucket_id += 1;
425 last_key = None;
426 }
427 }
428
429 Ok(ScanResult {
430 items,
431 next_cursor: None,
432 })
433 }
434}