1use crate::error::{map_cloud_client_error, ErrorData, Result};
2use crate::traits::{Binding, Kv, KvEntry, PutCondition, 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};
11use uuid::Uuid;
12
13use super::{decode_version, encode_version, validate_key, validate_value};
14
15const HASH_BUCKET_COUNT: u8 = 16;
16
17#[derive(Debug, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19struct CursorState {
20 version: u8,
21 prefix: String,
22 bucket: u8,
23 last_key: Option<String>,
24}
25
26pub struct AwsDynamodbKv {
31 client: DynamoDbClient,
32 table_name: String,
33}
34
35impl Debug for AwsDynamodbKv {
36 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
37 f.debug_struct("AwsDynamodbKv")
38 .field("table_name", &self.table_name)
39 .finish()
40 }
41}
42
43impl AwsDynamodbKv {
44 pub fn new(table_name: String, client: DynamoDbClient) -> Self {
45 Self { client, table_name }
46 }
47
48 fn hash_bucket(&self, key: &str) -> String {
50 use std::collections::hash_map::DefaultHasher;
51 use std::hash::{Hash, Hasher};
52
53 let mut hasher = DefaultHasher::new();
54 key.hash(&mut hasher);
55 let bucket_id = hasher.finish() % u64::from(HASH_BUCKET_COUNT);
56 format!("bucket_{}", bucket_id)
57 }
58
59 fn is_expired(&self, ttl_epoch: Option<i64>) -> bool {
61 if let Some(ttl_timestamp) = ttl_epoch {
62 let now = Utc::now().timestamp();
63 now >= ttl_timestamp
64 } else {
65 false
66 }
67 }
68
69 fn primary_key(&self, key: &str) -> HashMap<String, AttributeValue> {
70 HashMap::from([
71 ("pk".to_string(), AttributeValue::s(self.hash_bucket(key))),
72 ("sk".to_string(), AttributeValue::s(key.to_string())),
73 ])
74 }
75
76 async fn load_item(&self, key: &str) -> Result<Option<HashMap<String, AttributeValue>>> {
77 let request = GetItemRequest::builder()
78 .table_name(self.table_name.clone())
79 .key(self.primary_key(key))
80 .consistent_read(true)
81 .build();
82 self.client
83 .get_item(request)
84 .await
85 .map(|response| response.item)
86 .map_err(|error| {
87 map_cloud_client_error(
88 error,
89 format!("Failed to get item with key '{}'", key),
90 Some(key.to_string()),
91 )
92 })
93 }
94
95 fn item_ttl(item: &HashMap<String, AttributeValue>) -> Option<i64> {
96 item.get("ttl")
97 .and_then(|attribute| attribute.n.as_deref())
98 .and_then(|value| value.parse::<i64>().ok())
99 }
100
101 fn item_value(key: &str, item: &HashMap<String, AttributeValue>) -> Result<Vec<u8>> {
102 item.get("value")
103 .and_then(|attribute| attribute.b.as_ref())
104 .and_then(|value| base64::prelude::BASE64_STANDARD.decode(value).ok())
105 .ok_or_else(|| {
106 AlienError::new(ErrorData::CloudPlatformError {
107 message: format!("Missing or invalid value attribute for key '{}'", key),
108 resource_id: Some(key.to_string()),
109 })
110 })
111 }
112
113 fn encode_cursor(state: &CursorState) -> Result<String> {
114 let json =
115 serde_json::to_vec(state)
116 .into_alien_error()
117 .context(ErrorData::InvalidInput {
118 operation_context: "DynamoDB KV scan cursor encoding".to_string(),
119 details: "Failed to serialize cursor state".to_string(),
120 field_name: Some("cursor".to_string()),
121 })?;
122 Ok(BASE64_URL_SAFE_NO_PAD.encode(json))
123 }
124
125 fn decode_cursor(prefix: &str, cursor: &str) -> Result<CursorState> {
126 let decoded = BASE64_URL_SAFE_NO_PAD
127 .decode(cursor)
128 .into_alien_error()
129 .context(ErrorData::InvalidInput {
130 operation_context: "DynamoDB KV scan cursor decoding".to_string(),
131 details: "Invalid cursor encoding".to_string(),
132 field_name: Some("cursor".to_string()),
133 })?;
134 let state: CursorState = serde_json::from_slice(&decoded)
135 .into_alien_error()
136 .context(ErrorData::InvalidInput {
137 operation_context: "DynamoDB KV scan cursor decoding".to_string(),
138 details: "Invalid cursor data".to_string(),
139 field_name: Some("cursor".to_string()),
140 })?;
141 if state.version != 1 || state.prefix != prefix || state.bucket >= HASH_BUCKET_COUNT {
142 return Err(AlienError::new(ErrorData::InvalidInput {
143 operation_context: "DynamoDB KV scan cursor validation".to_string(),
144 details: "Cursor does not belong to this prefix scan".to_string(),
145 field_name: Some("cursor".to_string()),
146 }));
147 }
148 if state
149 .last_key
150 .as_ref()
151 .is_some_and(|last_key| !last_key.starts_with(prefix))
152 {
153 return Err(AlienError::new(ErrorData::InvalidInput {
154 operation_context: "DynamoDB KV scan cursor validation".to_string(),
155 details: "Cursor key does not match the scan prefix".to_string(),
156 field_name: Some("cursor".to_string()),
157 }));
158 }
159 Ok(state)
160 }
161}
162
163impl Binding for AwsDynamodbKv {}
164
165#[async_trait]
166impl Kv for AwsDynamodbKv {
167 async fn get(&self, key: &str) -> Result<Option<KvEntry>> {
168 validate_key(key)?;
169
170 let Some(item) = self.load_item(key).await? else {
171 return Ok(None);
172 };
173 let ttl = Self::item_ttl(&item);
174 if self.is_expired(ttl) {
175 return Ok(None);
176 }
177 let backend_version = item
178 .get("version")
179 .and_then(|value| value.s.clone())
180 .ok_or_else(|| {
181 AlienError::new(ErrorData::CloudPlatformError {
182 message: format!("Missing or invalid version attribute for key '{}'", key),
183 resource_id: Some(key.to_string()),
184 })
185 })?;
186 Ok(Some(KvEntry {
187 key: key.to_string(),
188 value: Self::item_value(key, &item)?,
189 version: encode_version(
190 key,
191 backend_version,
192 ttl.map(|expires_at| expires_at.saturating_mul(1000)),
193 )?,
194 }))
195 }
196
197 async fn put(&self, key: &str, value: Vec<u8>, options: Option<PutOptions>) -> Result<bool> {
198 validate_key(key)?;
199 validate_value(&value)?;
200
201 let bucket = self.hash_bucket(key);
202 let options = options.unwrap_or_default();
203
204 let mut item = HashMap::new();
205 item.insert("pk".to_string(), AttributeValue::s(bucket));
206 item.insert("sk".to_string(), AttributeValue::s(key.to_string()));
207 item.insert(
208 "value".to_string(),
209 AttributeValue::b(base64::prelude::BASE64_STANDARD.encode(&value)),
210 );
211 item.insert(
212 "version".to_string(),
213 AttributeValue::s(Uuid::new_v4().simple().to_string()),
214 );
215
216 if let Some(ttl) = options.ttl {
217 let expires_at = (Utc::now() + ttl).timestamp();
218 item.insert("ttl".to_string(), AttributeValue::n(expires_at.to_string()));
219 }
220
221 let request = if matches!(options.condition, PutCondition::Absent) {
222 let mut expression_attribute_names = HashMap::new();
229 expression_attribute_names.insert("#ttl".to_string(), "ttl".to_string());
230 let mut expression_attribute_values = HashMap::new();
231 expression_attribute_values.insert(
232 ":now".to_string(),
233 AttributeValue::n(Utc::now().timestamp().to_string()),
234 );
235 PutItemRequest::builder()
236 .table_name(self.table_name.clone())
237 .item(item)
238 .condition_expression(
239 "(attribute_not_exists(pk) AND attribute_not_exists(sk)) OR (attribute_exists(#ttl) AND #ttl <= :now)"
240 .to_string(),
241 )
242 .expression_attribute_names(expression_attribute_names)
243 .expression_attribute_values(expression_attribute_values)
244 .build()
245 } else if let PutCondition::Version(ref version) = options.condition {
246 let expected = decode_version(key, version)?;
247 if expected.expired {
248 return Ok(false);
249 }
250 PutItemRequest::builder()
251 .table_name(self.table_name.clone())
252 .item(item)
253 .condition_expression(
254 "#version = :version AND (attribute_not_exists(#ttl) OR #ttl > :now)"
255 .to_string(),
256 )
257 .expression_attribute_names(HashMap::from([
258 ("#version".to_string(), "version".to_string()),
259 ("#ttl".to_string(), "ttl".to_string()),
260 ]))
261 .expression_attribute_values(HashMap::from([
262 (
263 ":version".to_string(),
264 AttributeValue::s(expected.backend_version),
265 ),
266 (
267 ":now".to_string(),
268 AttributeValue::n(Utc::now().timestamp().to_string()),
269 ),
270 ]))
271 .build()
272 } else {
273 PutItemRequest::builder()
274 .table_name(self.table_name.clone())
275 .item(item)
276 .build()
277 };
278
279 match self.client.put_item(request).await {
280 Ok(_) => Ok(true),
281 Err(e) => {
282 if !matches!(options.condition, PutCondition::None) {
283 if let Some(alien_client_core::ErrorData::RemoteResourceConflict { .. }) =
284 &e.error
285 {
286 return Ok(false);
287 }
288 }
289 Err(map_cloud_client_error(
290 e,
291 format!("Failed to put item with key '{}'", key),
292 Some(key.to_string()),
293 ))
294 }
295 }
296 }
297
298 async fn delete(&self, key: &str, if_version: Option<&str>) -> Result<bool> {
299 validate_key(key)?;
300
301 let request = if let Some(version) = if_version {
302 let expected = decode_version(key, version)?;
303 if expected.expired {
304 return Ok(false);
305 }
306 DeleteItemRequest::builder()
307 .table_name(self.table_name.clone())
308 .key(self.primary_key(key))
309 .condition_expression(
310 "#version = :version AND (attribute_not_exists(#ttl) OR #ttl > :now)"
311 .to_string(),
312 )
313 .expression_attribute_names(HashMap::from([
314 ("#version".to_string(), "version".to_string()),
315 ("#ttl".to_string(), "ttl".to_string()),
316 ]))
317 .expression_attribute_values(HashMap::from([
318 (
319 ":version".to_string(),
320 AttributeValue::s(expected.backend_version),
321 ),
322 (
323 ":now".to_string(),
324 AttributeValue::n(Utc::now().timestamp().to_string()),
325 ),
326 ]))
327 .build()
328 } else {
329 DeleteItemRequest::builder()
330 .table_name(self.table_name.clone())
331 .key(self.primary_key(key))
332 .build()
333 };
334
335 match self.client.delete_item(request).await {
336 Ok(_) => Ok(true),
337 Err(error)
338 if if_version.is_some()
339 && matches!(
340 error.error.as_ref(),
341 Some(alien_client_core::ErrorData::RemoteResourceConflict { .. })
342 ) =>
343 {
344 Ok(false)
345 }
346 Err(error) => Err(map_cloud_client_error(
347 error,
348 format!("Failed to delete item with key '{}'", key),
349 Some(key.to_string()),
350 )),
351 }
352 }
353
354 async fn exists(&self, key: &str) -> Result<bool> {
355 validate_key(key)?;
356
357 let bucket = self.hash_bucket(key);
358 let mut primary_key = HashMap::new();
359 primary_key.insert("pk".to_string(), AttributeValue::s(bucket));
360 primary_key.insert("sk".to_string(), AttributeValue::s(key.to_string()));
361
362 let mut expression_attribute_names = HashMap::new();
364 expression_attribute_names.insert("#ttl".to_string(), "ttl".to_string());
365
366 let request = GetItemRequest::builder()
367 .table_name(self.table_name.clone())
368 .key(primary_key)
369 .projection_expression("pk, #ttl".to_string()) .expression_attribute_names(expression_attribute_names)
371 .consistent_read(true)
372 .build();
373
374 let response = self.client.get_item(request).await.map_err(|e| {
375 map_cloud_client_error(
376 e,
377 format!("Failed to check existence of item with key '{}'", key),
378 Some(key.to_string()),
379 )
380 })?;
381
382 if let Some(item) = response.item {
383 if let Some(ttl_attr) = item.get("ttl") {
385 if let Some(ttl_epoch) = ttl_attr.n.as_ref().and_then(|s| s.parse::<i64>().ok()) {
386 if self.is_expired(Some(ttl_epoch)) {
387 return Ok(false); }
389 }
390 }
391 Ok(true)
392 } else {
393 Ok(false)
394 }
395 }
396
397 async fn scan_prefix(
398 &self,
399 prefix: &str,
400 limit: Option<usize>,
401 cursor: Option<String>,
402 ) -> Result<ScanResult> {
403 validate_key(prefix)?;
404 let limit = limit.unwrap_or(1000);
405 let initial = cursor
406 .as_deref()
407 .map(|cursor| Self::decode_cursor(prefix, cursor))
408 .transpose()?
409 .unwrap_or(CursorState {
410 version: 1,
411 prefix: prefix.to_string(),
412 bucket: 0,
413 last_key: None,
414 });
415 if limit == 0 {
416 return Ok(ScanResult {
417 items: Vec::new(),
418 next_cursor: cursor,
419 });
420 }
421
422 let mut items = Vec::with_capacity(limit);
423 let mut bucket_id = initial.bucket;
424 let mut last_key = initial.last_key;
425
426 while bucket_id < HASH_BUCKET_COUNT {
427 let bucket = format!("bucket_{}", bucket_id);
428 let mut expression_attribute_values = HashMap::new();
429 expression_attribute_values
430 .insert(":bucket".to_string(), AttributeValue::s(bucket.clone()));
431 expression_attribute_values
432 .insert(":prefix".to_string(), AttributeValue::s(prefix.to_string()));
433
434 let exclusive_start_key = last_key.as_ref().map(|key| {
435 HashMap::from([
436 ("pk".to_string(), AttributeValue::s(bucket.clone())),
437 ("sk".to_string(), AttributeValue::s(key.clone())),
438 ])
439 });
440 let request = QueryRequest::builder()
441 .table_name(self.table_name.clone())
442 .key_condition_expression("pk = :bucket AND begins_with(sk, :prefix)".to_string())
443 .expression_attribute_values(expression_attribute_values)
444 .limit(i32::try_from(limit - items.len()).unwrap_or(i32::MAX))
445 .maybe_exclusive_start_key(exclusive_start_key)
446 .build();
447
448 let response = self.client.query(request).await.map_err(|e| {
449 map_cloud_client_error(
450 e,
451 format!("Failed to scan prefix '{}' in bucket {}", prefix, bucket_id),
452 Some(prefix.to_string()),
453 )
454 })?;
455
456 for item in response.items {
457 if let Some(ttl_attr) = item.get("ttl") {
458 if let Some(ttl_epoch) = ttl_attr.n.as_ref().and_then(|s| s.parse::<i64>().ok())
459 {
460 if self.is_expired(Some(ttl_epoch)) {
461 continue; }
463 }
464 }
465
466 if let (Some(key_attr), Some(value_attr)) = (item.get("sk"), item.get("value")) {
467 if let (Some(key), Some(base64_value)) =
468 (key_attr.s.as_ref(), value_attr.b.as_ref())
469 {
470 if let Ok(value) = base64::prelude::BASE64_STANDARD.decode(base64_value) {
471 let ttl = Self::item_ttl(&item);
472 if let Some(backend_version) =
473 item.get("version").and_then(|value| value.s.clone())
474 {
475 items.push(KvEntry {
476 key: key.clone(),
477 value,
478 version: encode_version(
479 key,
480 backend_version,
481 ttl.map(|expires_at| expires_at.saturating_mul(1000)),
482 )?,
483 });
484 } else {
485 return Err(AlienError::new(ErrorData::CloudPlatformError {
486 message: format!(
487 "Missing or invalid version attribute for key '{}'",
488 key
489 ),
490 resource_id: Some(key.clone()),
491 }));
492 }
493 }
494 }
495 }
496 }
497
498 let provider_last_key = response
499 .last_evaluated_key
500 .as_ref()
501 .and_then(|key| key.get("sk"))
502 .and_then(|value| value.s.clone());
503
504 if items.len() == limit {
505 let (next_bucket, next_key) = match provider_last_key {
506 Some(key) => (bucket_id, Some(key)),
507 None if bucket_id + 1 < HASH_BUCKET_COUNT => (bucket_id + 1, None),
508 None => {
509 return Ok(ScanResult {
510 items,
511 next_cursor: None,
512 });
513 }
514 };
515 return Ok(ScanResult {
516 items,
517 next_cursor: Some(Self::encode_cursor(&CursorState {
518 version: 1,
519 prefix: prefix.to_string(),
520 bucket: next_bucket,
521 last_key: next_key,
522 })?),
523 });
524 }
525
526 if let Some(key) = provider_last_key {
527 last_key = Some(key);
528 } else {
529 bucket_id += 1;
530 last_key = None;
531 }
532 }
533
534 Ok(ScanResult {
535 items,
536 next_cursor: None,
537 })
538 }
539}