1use crate::error::{ErrorData, Result};
2use crate::traits::{Binding, Kv, KvEntry, PutCondition, PutOptions, ScanResult};
3use alien_azure_clients::tables::{
4 AzureTableStorageClient, EntityQueryContinuation, EntityQueryOptions, TableEntity,
5 TableStorageApi,
6};
7use alien_error::{AlienError, Context, IntoAlienError};
8use async_trait::async_trait;
9use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13use std::collections::HashMap;
14use std::fmt::{Debug, Formatter};
15
16use super::{decode_version, encode_version, validate_key, validate_value};
17
18fn create_table_entity(
21 partition_key: String,
22 row_key: String,
23 value: &[u8],
24 expires_at: Option<DateTime<Utc>>,
25) -> TableEntity {
26 let mut properties = HashMap::new();
27
28 properties.insert("Value".to_string(), Value::String(BASE64.encode(value)));
31
32 properties.insert(
34 "CreatedAt".to_string(),
35 Value::String(Utc::now().to_rfc3339()),
36 );
37
38 if let Some(expiry) = expires_at {
40 properties.insert("ExpiresAt".to_string(), Value::String(expiry.to_rfc3339()));
41 }
42
43 TableEntity {
44 partition_key,
45 row_key,
46 timestamp: None, properties,
48 }
49}
50
51fn extract_value_from_entity(entity: &TableEntity) -> Result<Vec<u8>> {
53 let value_str = entity
54 .properties
55 .get("Value")
56 .and_then(|v| v.as_str())
57 .ok_or_else(|| {
58 AlienError::new(ErrorData::InvalidInput {
59 operation_context: "Azure Table Storage KV extract value".to_string(),
60 details: "Entity missing Value property or not a string".to_string(),
61 field_name: Some("Value".to_string()),
62 })
63 })?;
64
65 BASE64
67 .decode(value_str)
68 .into_alien_error()
69 .context(ErrorData::InvalidInput {
70 operation_context: "Azure Table Storage KV extract value".to_string(),
71 details: "Failed to decode base64 value".to_string(),
72 field_name: Some("Value".to_string()),
73 })
74}
75
76fn is_entity_expired(entity: &TableEntity) -> bool {
78 if let Some(expires_at_value) = entity.properties.get("ExpiresAt") {
79 if let Some(expires_at_str) = expires_at_value.as_str() {
80 if let Ok(expires_at) = DateTime::parse_from_rfc3339(expires_at_str) {
81 return Utc::now() > expires_at.with_timezone(&Utc);
82 }
83 }
84 }
85 false
86}
87
88fn entity_expiration_millis(entity: &TableEntity) -> Option<i64> {
89 entity
90 .properties
91 .get("ExpiresAt")
92 .and_then(Value::as_str)
93 .and_then(|value| DateTime::parse_from_rfc3339(value).ok())
94 .map(|value| value.timestamp_millis())
95}
96
97fn entity_etag(entity: &TableEntity) -> Option<String> {
98 entity
99 .properties
100 .get("odata.etag")
101 .and_then(Value::as_str)
102 .map(ToString::to_string)
103}
104
105#[derive(Serialize, Deserialize)]
107#[serde(rename_all = "camelCase")]
108struct CursorState {
109 version: u8,
110 prefix: String,
111 current_partition: u32,
112 continuation: Option<EntityQueryContinuation>,
113}
114
115pub struct AzureTableStorageKv {
117 client: AzureTableStorageClient,
118 resource_group_name: String,
119 account_name: String,
120 table_name: String,
121 num_partitions: u32,
122}
123
124impl Debug for AzureTableStorageKv {
125 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
126 f.debug_struct("AzureTableStorageKv")
127 .field("resource_group_name", &self.resource_group_name)
128 .field("account_name", &self.account_name)
129 .field("table_name", &self.table_name)
130 .field("num_partitions", &self.num_partitions)
131 .finish()
132 }
133}
134
135impl AzureTableStorageKv {
136 pub fn new(
137 client: AzureTableStorageClient,
138 resource_group_name: String,
139 account_name: String,
140 table_name: String,
141 ) -> Self {
142 Self {
143 client,
144 resource_group_name,
145 account_name,
146 table_name,
147 num_partitions: 16, }
149 }
150
151 fn hash_bucket(&self, key: &str) -> u32 {
153 use std::collections::hash_map::DefaultHasher;
154 use std::hash::{Hash, Hasher};
155
156 let mut hasher = DefaultHasher::new();
157 key.hash(&mut hasher);
158 hasher.finish() as u32 % self.num_partitions
159 }
160
161 async fn try_take_over_expired(
167 &self,
168 key: &str,
169 entity: &alien_azure_clients::azure::tables::TableEntity,
170 ) -> Result<bool> {
171 use alien_client_core::ErrorData as CloudErrorData;
172
173 let (partition_key, row_key) = self.split_key(key);
174 let existing = match self
175 .client
176 .get_entity(
177 &self.resource_group_name,
178 &self.account_name,
179 &self.table_name,
180 &partition_key,
181 &row_key,
182 None,
183 )
184 .await
185 {
186 Ok(existing) => existing,
187 Err(e)
190 if matches!(
191 e.error.as_ref(),
192 Some(CloudErrorData::RemoteResourceNotFound { .. })
193 ) =>
194 {
195 return Ok(false);
196 }
197 Err(e) => {
198 return Err(crate::error::map_cloud_client_error(
199 e,
200 format!("Failed to read existing entity for key '{}'", key),
201 Some(key.to_string()),
202 ));
203 }
204 };
205
206 if !is_entity_expired(&existing) {
207 return Ok(false);
208 }
209 let Some(etag) = existing
210 .properties
211 .get("odata.etag")
212 .and_then(|value| value.as_str())
213 else {
214 return Ok(false);
216 };
217
218 match self
219 .client
220 .update_entity(
221 &self.resource_group_name,
222 &self.account_name,
223 &self.table_name,
224 &partition_key,
225 &row_key,
226 entity,
227 Some(alien_azure_clients::azure::tables::ETag::from(etag)),
228 )
229 .await
230 {
231 Ok(_) => Ok(true),
232 Err(e)
234 if matches!(
235 e.error.as_ref(),
236 Some(CloudErrorData::RemoteResourceConflict { .. })
237 | Some(CloudErrorData::RemoteResourceNotFound { .. })
238 ) =>
239 {
240 Ok(false)
241 }
242 Err(e) => Err(crate::error::map_cloud_client_error(
243 e,
244 format!("Failed to take over expired entity for key '{}'", key),
245 Some(key.to_string()),
246 )),
247 }
248 }
249
250 fn split_key(&self, key: &str) -> (String, String) {
251 let partition_key = format!("p{}", self.hash_bucket(key));
253 (partition_key, key.to_string())
254 }
255
256 fn combine_key(&self, _partition_key: &str, row_key: &str) -> String {
258 row_key.to_string() }
260
261 fn encode_cursor(&self, state: &CursorState) -> Result<String> {
263 let json =
264 serde_json::to_vec(state)
265 .into_alien_error()
266 .context(ErrorData::InvalidInput {
267 operation_context: "Azure Table Storage KV cursor encoding".to_string(),
268 details: "Failed to serialize cursor state".to_string(),
269 field_name: Some("cursor".to_string()),
270 })?;
271 Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json))
272 }
273
274 fn decode_cursor(&self, prefix: &str, cursor: &str) -> Result<CursorState> {
276 let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
277 .decode(cursor)
278 .into_alien_error()
279 .context(ErrorData::InvalidInput {
280 operation_context: "Azure Table Storage KV cursor decoding".to_string(),
281 details: "Invalid cursor encoding".to_string(),
282 field_name: Some("cursor".to_string()),
283 })?;
284 let json =
285 String::from_utf8(decoded)
286 .into_alien_error()
287 .context(ErrorData::InvalidInput {
288 operation_context: "Azure Table Storage KV cursor decoding".to_string(),
289 details: "Invalid cursor UTF-8".to_string(),
290 field_name: Some("cursor".to_string()),
291 })?;
292 let state: CursorState =
293 serde_json::from_str(&json)
294 .into_alien_error()
295 .context(ErrorData::InvalidInput {
296 operation_context: "Azure Table Storage KV cursor decoding".to_string(),
297 details: "Invalid cursor JSON".to_string(),
298 field_name: Some("cursor".to_string()),
299 })?;
300 if state.version != 1
301 || state.prefix != prefix
302 || state.current_partition >= self.num_partitions
303 {
304 return Err(AlienError::new(ErrorData::InvalidInput {
305 operation_context: "Azure Table Storage KV cursor validation".to_string(),
306 details: "Cursor does not belong to this prefix scan".to_string(),
307 field_name: Some("cursor".to_string()),
308 }));
309 }
310 Ok(state)
311 }
312}
313
314impl Binding for AzureTableStorageKv {}
315
316#[async_trait]
317impl Kv for AzureTableStorageKv {
318 async fn get(&self, key: &str) -> Result<Option<KvEntry>> {
319 validate_key(key)?;
320
321 let (partition_key, row_key) = self.split_key(key);
322
323 match self
324 .client
325 .get_entity(
326 &self.resource_group_name,
327 &self.account_name,
328 &self.table_name,
329 &partition_key,
330 &row_key,
331 None,
332 )
333 .await
334 {
335 Ok(entity) => {
336 if is_entity_expired(&entity) {
338 return Ok(None); }
340
341 let value = extract_value_from_entity(&entity)?;
342 let etag = entity_etag(&entity).ok_or_else(|| {
343 AlienError::new(ErrorData::UnexpectedResponseFormat {
344 provider: "azure".to_string(),
345 binding_name: "table-storage".to_string(),
346 field: "odata.etag".to_string(),
347 response_json: serde_json::to_string(&entity).unwrap_or_default(),
348 })
349 })?;
350 Ok(Some(KvEntry {
351 key: key.to_string(),
352 value,
353 version: encode_version(key, etag, entity_expiration_millis(&entity))?,
354 }))
355 }
356 Err(e) => {
357 use alien_client_core::ErrorData as CloudErrorData;
358 match e.error.as_ref() {
359 Some(CloudErrorData::RemoteResourceNotFound { .. }) => Ok(None),
360 _ => Err(crate::error::map_cloud_client_error(
361 e,
362 format!("Failed to get entity for key '{}'", key),
363 Some(key.to_string()),
364 )),
365 }
366 }
367 }
368 }
369
370 async fn put(&self, key: &str, value: Vec<u8>, options: Option<PutOptions>) -> Result<bool> {
371 validate_key(key)?;
372 validate_value(&value)?;
373
374 let options = options.unwrap_or_default();
375 let (partition_key, row_key) = self.split_key(key);
376
377 let expires_at = options.ttl.map(|d| Utc::now() + d);
378 let entity =
379 create_table_entity(partition_key.clone(), row_key.clone(), &value, expires_at);
380
381 if matches!(options.condition, PutCondition::Absent) {
382 match self
383 .client
384 .insert_entity(
385 &self.resource_group_name,
386 &self.account_name,
387 &self.table_name,
388 &entity,
389 )
390 .await
391 {
392 Ok(_) => Ok(true),
393 Err(e) => {
394 use alien_client_core::ErrorData as CloudErrorData;
395 match e.error.as_ref() {
396 Some(CloudErrorData::RemoteResourceConflict { .. }) => {
397 self.try_take_over_expired(key, &entity).await
406 }
407 _ => Err(crate::error::map_cloud_client_error(
408 e,
409 format!("Failed to insert entity for key '{}'", key),
410 Some(key.to_string()),
411 )),
412 }
413 }
414 }
415 } else if let PutCondition::Version(ref version) = options.condition {
416 let expected = decode_version(key, version)?;
417 if expected.expired {
418 return Ok(false);
419 }
420 match self
421 .client
422 .update_entity(
423 &self.resource_group_name,
424 &self.account_name,
425 &self.table_name,
426 &partition_key,
427 &row_key,
428 &entity,
429 Some(alien_azure_clients::azure::tables::ETag::from(
430 expected.backend_version,
431 )),
432 )
433 .await
434 {
435 Ok(_) => Ok(true),
436 Err(error)
437 if matches!(
438 error.error.as_ref(),
439 Some(alien_client_core::ErrorData::RemoteResourceConflict { .. })
440 | Some(alien_client_core::ErrorData::RemoteResourceNotFound { .. })
441 ) =>
442 {
443 Ok(false)
444 }
445 Err(error) => Err(crate::error::map_cloud_client_error(
446 error,
447 format!("Failed to conditionally update entity for key '{}'", key),
448 Some(key.to_string()),
449 )),
450 }
451 } else {
452 self.client
454 .insert_or_replace_entity(
455 &self.resource_group_name,
456 &self.account_name,
457 &self.table_name,
458 &partition_key,
459 &row_key,
460 &entity,
461 )
462 .await
463 .map_err(|e| {
464 crate::error::map_cloud_client_error(
465 e,
466 format!("Failed to upsert entity for key '{}'", key),
467 Some(key.to_string()),
468 )
469 })?;
470 Ok(true)
471 }
472 }
473
474 async fn delete(&self, key: &str, if_version: Option<&str>) -> Result<bool> {
475 validate_key(key)?;
476
477 let (partition_key, row_key) = self.split_key(key);
478
479 let etag = if let Some(version) = if_version {
480 let expected = decode_version(key, version)?;
481 if expected.expired {
482 return Ok(false);
483 }
484 Some(alien_azure_clients::azure::tables::ETag::from(
485 expected.backend_version,
486 ))
487 } else {
488 None
489 };
490
491 match self
492 .client
493 .delete_entity(
494 &self.resource_group_name,
495 &self.account_name,
496 &self.table_name,
497 &partition_key,
498 &row_key,
499 etag,
500 )
501 .await
502 {
503 Ok(_) => Ok(true),
504 Err(e) => {
505 use alien_client_core::ErrorData as CloudErrorData;
506 match e.error.as_ref() {
507 Some(CloudErrorData::RemoteResourceNotFound { .. }) => Ok(if_version.is_none()),
508 Some(CloudErrorData::RemoteResourceConflict { .. }) if if_version.is_some() => {
509 Ok(false)
510 }
511 _ => Err(crate::error::map_cloud_client_error(
512 e,
513 format!("Failed to delete entity for key '{}'", key),
514 Some(key.to_string()),
515 )),
516 }
517 }
518 }
519 }
520
521 async fn exists(&self, key: &str) -> Result<bool> {
522 validate_key(key)?;
523
524 let (partition_key, row_key) = self.split_key(key);
525
526 match self
527 .client
528 .get_entity(
529 &self.resource_group_name,
530 &self.account_name,
531 &self.table_name,
532 &partition_key,
533 &row_key,
534 None,
535 )
536 .await
537 {
538 Ok(entity) => {
539 Ok(!is_entity_expired(&entity))
541 }
542 Err(e) => {
543 use alien_client_core::ErrorData as CloudErrorData;
544 match e.error.as_ref() {
545 Some(CloudErrorData::RemoteResourceNotFound { .. }) => Ok(false),
546 _ => Err(crate::error::map_cloud_client_error(
547 e,
548 format!("Failed to check existence of entity for key '{}'", key),
549 Some(key.to_string()),
550 )),
551 }
552 }
553 }
554 }
555
556 async fn scan_prefix(
557 &self,
558 prefix: &str,
559 limit: Option<usize>,
560 cursor: Option<String>,
561 ) -> Result<ScanResult> {
562 validate_key(prefix)?;
563 let limit = limit.unwrap_or(1000);
564 let initial = cursor
565 .as_deref()
566 .map(|cursor| self.decode_cursor(prefix, cursor))
567 .transpose()?
568 .unwrap_or(CursorState {
569 version: 1,
570 prefix: prefix.to_string(),
571 current_partition: 0,
572 continuation: None,
573 });
574 if limit == 0 {
575 return Ok(ScanResult {
576 items: Vec::new(),
577 next_cursor: cursor,
578 });
579 }
580
581 let mut items = Vec::with_capacity(limit);
582 let mut partition_id = initial.current_partition;
583 let mut continuation = initial.continuation;
584
585 while partition_id < self.num_partitions {
586 let partition_key = format!("p{}", partition_id);
587
588 let prefix_end = format!("{}~", prefix); let filter = format!(
592 "(PartitionKey eq '{}') and (RowKey ge '{}') and (RowKey lt '{}')",
593 partition_key, prefix, prefix_end
594 );
595
596 let filter_with_ttl = filter;
598
599 let query_options = EntityQueryOptions {
600 filter: Some(filter_with_ttl),
601 select: None,
602 top: Some(u32::try_from(limit - items.len()).unwrap_or(u32::MAX)),
603 continuation: continuation.clone(),
604 };
605
606 let response = self
607 .client
608 .query_entities(
609 &self.resource_group_name,
610 &self.account_name,
611 &self.table_name,
612 Some(query_options),
613 )
614 .await
615 .map_err(|e| {
616 crate::error::map_cloud_client_error(
617 e,
618 format!("Failed to query entities with prefix '{}'", prefix),
619 Some(prefix.to_string()),
620 )
621 })?;
622
623 for entity in response.entities {
624 if is_entity_expired(&entity) {
625 continue;
626 }
627
628 let key = self.combine_key(&entity.partition_key, &entity.row_key);
629 let value = extract_value_from_entity(&entity)?;
630
631 if let Some(etag) = entity_etag(&entity) {
632 items.push(KvEntry {
633 version: encode_version(&key, etag, entity_expiration_millis(&entity))?,
634 key,
635 value,
636 });
637 } else if let Some(entry) = self.get(&key).await? {
638 items.push(entry);
639 }
640 }
641
642 if items.len() == limit {
643 let (next_partition, next_continuation) = match response.continuation {
644 Some(continuation) => (partition_id, Some(continuation)),
645 None if partition_id + 1 < self.num_partitions => (partition_id + 1, None),
646 None => {
647 return Ok(ScanResult {
648 items,
649 next_cursor: None,
650 });
651 }
652 };
653 return Ok(ScanResult {
654 items,
655 next_cursor: Some(self.encode_cursor(&CursorState {
656 version: 1,
657 prefix: prefix.to_string(),
658 current_partition: next_partition,
659 continuation: next_continuation,
660 })?),
661 });
662 }
663
664 if let Some(next) = response.continuation {
665 continuation = Some(next);
666 } else {
667 partition_id += 1;
668 continuation = None;
669 }
670 }
671
672 Ok(ScanResult {
673 items,
674 next_cursor: None,
675 })
676 }
677}