@@ -17,7 +17,7 @@ use crate::repositories::{
1717use crate :: utils:: RedisConnections ;
1818use async_trait:: async_trait;
1919use chrono:: Utc ;
20- use redis:: AsyncCommands ;
20+ use redis:: { AsyncCommands , Script } ;
2121use std:: fmt;
2222use std:: sync:: Arc ;
2323use tracing:: { debug, error, warn} ;
@@ -1297,17 +1297,16 @@ impl TransactionRepository for RedisTransactionRepository {
12971297 const MAX_RETRIES : u32 = 3 ;
12981298 const BACKOFF_MS : u64 = 100 ;
12991299
1300- // Fetch the original transaction state ONCE before retrying.
1301- // This is critical: if conn.set() succeeds but update_indexes() fails,
1302- // subsequent retries must still reference the original state to remove
1303- // stale index entries. Otherwise, get_by_id() returns the already-updated
1304- // record and update_indexes() skips removing the old indexes.
1305- let original_tx = self . get_by_id ( tx_id. clone ( ) ) . await ?;
1300+ // Optimistic CAS: only apply update if the current stored value still matches the
1301+ // expected pre-update value. This avoids duplicate status metric updates on races.
1302+ let mut original_tx = self . get_by_id ( tx_id. clone ( ) ) . await ?;
13061303 let mut updated_tx = original_tx. clone ( ) ;
13071304 updated_tx. apply_partial_update ( update. clone ( ) ) ;
13081305
13091306 let key = self . tx_key ( & updated_tx. relayer_id , & tx_id) ;
1310- let value = self . serialize_entity ( & updated_tx, |t| & t. id , "transaction" ) ?;
1307+ let mut original_value = self . serialize_entity ( & original_tx, |t| & t. id , "transaction" ) ?;
1308+ let mut updated_value = self . serialize_entity ( & updated_tx, |t| & t. id , "transaction" ) ?;
1309+ let mut data_updated = false ;
13111310
13121311 let mut last_error = None ;
13131312
@@ -1327,19 +1326,68 @@ impl TransactionRepository for RedisTransactionRepository {
13271326 }
13281327 } ;
13291328
1330- // Try to update transaction data
1331- let result: Result < ( ) , _ > = conn. set ( & key, & value) . await ;
1332- match result {
1333- Ok ( _) => { }
1334- Err ( e) => {
1329+ if !data_updated {
1330+ let cas_script = Script :: new (
1331+ r#"
1332+ local current = redis.call('GET', KEYS[1])
1333+ if not current then
1334+ return -1
1335+ end
1336+ if current == ARGV[1] then
1337+ redis.call('SET', KEYS[1], ARGV[2])
1338+ return 1
1339+ end
1340+ return 0
1341+ "# ,
1342+ ) ;
1343+
1344+ let cas_result: i32 = match cas_script
1345+ . key ( & key)
1346+ . arg ( & original_value)
1347+ . arg ( & updated_value)
1348+ . invoke_async ( & mut conn)
1349+ . await
1350+ {
1351+ Ok ( result) => result,
1352+ Err ( e) => {
1353+ if attempt < MAX_RETRIES - 1 {
1354+ warn ! ( tx_id = %tx_id, attempt = %attempt, error = %e, "failed CAS transaction update, retrying" ) ;
1355+ last_error = Some ( self . map_redis_error ( e, "partial_update_cas" ) ) ;
1356+ tokio:: time:: sleep ( tokio:: time:: Duration :: from_millis ( BACKOFF_MS ) )
1357+ . await ;
1358+ continue ;
1359+ }
1360+ return Err ( self . map_redis_error ( e, "partial_update_cas" ) ) ;
1361+ }
1362+ } ;
1363+
1364+ if cas_result == -1 {
1365+ return Err ( RepositoryError :: NotFound ( format ! (
1366+ "Transaction with ID {} not found" ,
1367+ tx_id
1368+ ) ) ) ;
1369+ }
1370+
1371+ if cas_result == 0 {
13351372 if attempt < MAX_RETRIES - 1 {
1336- warn ! ( tx_id = %tx_id, attempt = %attempt, error = %e, "failed to set transaction data, retrying" ) ;
1337- last_error = Some ( self . map_redis_error ( e, "partial_update" ) ) ;
1373+ warn ! ( tx_id = %tx_id, attempt = %attempt, "concurrent transaction update detected, rebasing retry" ) ;
1374+ original_tx = self . get_by_id ( tx_id. clone ( ) ) . await ?;
1375+ updated_tx = original_tx. clone ( ) ;
1376+ updated_tx. apply_partial_update ( update. clone ( ) ) ;
1377+ original_value =
1378+ self . serialize_entity ( & original_tx, |t| & t. id , "transaction" ) ?;
1379+ updated_value =
1380+ self . serialize_entity ( & updated_tx, |t| & t. id , "transaction" ) ?;
13381381 tokio:: time:: sleep ( tokio:: time:: Duration :: from_millis ( BACKOFF_MS ) ) . await ;
13391382 continue ;
13401383 }
1341- return Err ( self . map_redis_error ( e, "partial_update" ) ) ;
1384+ return Err ( RepositoryError :: TransactionFailure ( format ! (
1385+ "Concurrent update conflict for transaction {}" ,
1386+ tx_id
1387+ ) ) ) ;
13421388 }
1389+
1390+ data_updated = true ;
13431391 }
13441392
13451393 // Try to update indexes with the original pre-update state
@@ -1380,12 +1428,17 @@ impl TransactionRepository for RedisTransactionRepository {
13801428
13811429 // Track status distribution (update gauge when status changes)
13821430 if original_tx. status != * new_status {
1383- // Decrement old status
1431+ // Decrement old status and clamp to zero to avoid negative gauges.
13841432 let old_status = & original_tx. status ;
13851433 let old_status_str = format ! ( "{old_status:?}" ) . to_lowercase ( ) ;
1386- TRANSACTIONS_BY_STATUS
1387- . with_label_values ( & [ relayer_id, & network_type, & old_status_str] )
1388- . dec ( ) ;
1434+ let old_status_gauge = TRANSACTIONS_BY_STATUS . with_label_values ( & [
1435+ relayer_id,
1436+ & network_type,
1437+ & old_status_str,
1438+ ] ) ;
1439+ let clamped_value = ( old_status_gauge. get ( ) - 1.0 ) . max ( 0.0 ) ;
1440+ old_status_gauge. set ( clamped_value) ;
1441+
13891442 // Increment new status
13901443 let new_status_str = format ! ( "{new_status:?}" ) . to_lowercase ( ) ;
13911444 TRANSACTIONS_BY_STATUS
@@ -2832,10 +2885,11 @@ mod tests {
28322885 let tolerance = Duration :: minutes ( 5 ) ;
28332886
28342887 assert ! (
2835- duration_from_before >= expected_duration - tolerance &&
2836- duration_from_before <= expected_duration + tolerance,
2888+ duration_from_before >= expected_duration - tolerance
2889+ && duration_from_before <= expected_duration + tolerance,
28372890 "delete_at should be approximately 6 hours from now for status: {:?}. Duration: {:?}" ,
2838- status, duration_from_before
2891+ status,
2892+ duration_from_before
28392893 ) ;
28402894 }
28412895
0 commit comments