Skip to main content

vv_agent/runtime/stores/
redis.rs

1use std::io::{Error, ErrorKind, Result};
2use std::sync::Mutex;
3use std::time::Duration;
4
5use redis::{cmd, pipe, Commands, Connection, ConnectionLike, Pipeline, RedisResult};
6
7use crate::runtime::checkpoint_codec;
8use crate::runtime::state::{
9    check_claim, claim_matches, clear_claim, validate_claim, validate_renew, Checkpoint,
10    LeaseOperationClock, StateStore, StateStoreSpec,
11};
12
13const KEY_PREFIX: &str = "vv_agent:checkpoint:";
14const IO_TIMEOUT: Duration = Duration::from_secs(1);
15const TRANSACTION_MAX_ATTEMPTS: usize = 8;
16const RENEW_CLAIM_SCRIPT: &str = r#"
17local redis_time = redis.call("TIME")
18local server_now_ms = tonumber(redis_time[1]) * 1000 + math.floor(tonumber(redis_time[2]) / 1000)
19local client_now_ms = tonumber(ARGV[5])
20local current_now_ms = math.max(server_now_ms, client_now_ms)
21local previous_expiry_ms = tonumber(ARGV[3])
22local requested_expiry_ms = tonumber(ARGV[4])
23if previous_expiry_ms <= current_now_ms or requested_expiry_ms <= current_now_ms then
24  return 2
25end
26local current = redis.call("GET", KEYS[1])
27if current ~= ARGV[1] then
28  return 0
29end
30redis.call("SET", KEYS[1], ARGV[2])
31return 1
32"#;
33
34pub struct RedisStateStore {
35    connection: Mutex<Connection>,
36    redis_url: String,
37}
38
39impl std::fmt::Debug for RedisStateStore {
40    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        formatter
42            .debug_struct("RedisStateStore")
43            .finish_non_exhaustive()
44    }
45}
46
47impl RedisStateStore {
48    pub fn new(redis_url: impl AsRef<str>) -> Result<Self> {
49        let redis_url = redis_url.as_ref().to_string();
50        let client = redis::Client::open(redis_url.as_str()).map_err(redis_to_io)?;
51        let connection = client
52            .get_connection_with_timeout(IO_TIMEOUT)
53            .map_err(redis_to_io)?;
54        connection
55            .set_read_timeout(Some(IO_TIMEOUT))
56            .map_err(redis_to_io)?;
57        connection
58            .set_write_timeout(Some(IO_TIMEOUT))
59            .map_err(redis_to_io)?;
60        Ok(Self {
61            connection: Mutex::new(connection),
62            redis_url,
63        })
64    }
65
66    pub fn checkpoint_key(task_id: &str) -> String {
67        format!("{KEY_PREFIX}{task_id}")
68    }
69
70    pub fn checkpoint_to_json(checkpoint: &Checkpoint) -> Result<String> {
71        checkpoint_codec::checkpoint_to_json(checkpoint)
72    }
73
74    pub fn checkpoint_from_json(raw: &str) -> Result<Checkpoint> {
75        checkpoint_codec::checkpoint_from_json(raw)
76    }
77
78    fn transaction<T>(
79        &self,
80        key: &str,
81        operation: impl FnMut(&mut Connection, &mut redis::Pipeline) -> redis::RedisResult<Option<T>>,
82    ) -> Result<T> {
83        let mut connection = self
84            .connection
85            .lock()
86            .map_err(|_| Error::other("redis state store lock is poisoned"))?;
87        transaction_with_connection(&mut *connection, key, operation)
88    }
89}
90
91fn transaction_with_connection<C, T>(
92    connection: &mut C,
93    key: &str,
94    mut operation: impl FnMut(&mut C, &mut Pipeline) -> RedisResult<Option<T>>,
95) -> Result<T>
96where
97    C: ConnectionLike,
98{
99    for _attempt in 0..TRANSACTION_MAX_ATTEMPTS {
100        cmd("WATCH")
101            .arg(key)
102            .exec(&mut *connection)
103            .map_err(redis_to_io)?;
104        let mut pipeline = pipe();
105        if let Some(result) = operation(connection, pipeline.atomic()).map_err(redis_to_io)? {
106            cmd("UNWATCH").exec(&mut *connection).map_err(redis_to_io)?;
107            return Ok(result);
108        }
109    }
110    cmd("UNWATCH").exec(&mut *connection).map_err(redis_to_io)?;
111    Err(Error::new(
112        ErrorKind::TimedOut,
113        "redis checkpoint transaction retry limit exceeded",
114    ))
115}
116
117fn query_committed_transaction<C, T>(
118    connection: &mut C,
119    pipeline: &Pipeline,
120    result: T,
121) -> RedisResult<Option<T>>
122where
123    C: ConnectionLike,
124{
125    pipeline
126        .query::<Option<()>>(connection)
127        .map(|committed| committed.map(|()| result))
128}
129
130fn renew_checkpoint_claim_with_connection<C>(
131    connection: &mut C,
132    key: &str,
133    claim_token: &str,
134    expected_revision: u64,
135    lease_expires_at_ms: u64,
136    clock: &LeaseOperationClock,
137) -> Result<bool>
138where
139    C: ConnectionLike,
140{
141    let Some(raw) = connection
142        .get::<_, Option<String>>(key)
143        .map_err(redis_to_io)?
144    else {
145        return Ok(false);
146    };
147    let mut checkpoint = RedisStateStore::checkpoint_from_json(&raw)?;
148    let current_now_ms = clock.now_ms();
149    if checkpoint.revision != expected_revision
150        || checkpoint.claim_token.as_deref() != Some(claim_token)
151        || checkpoint.lease_expires_at_ms.unwrap_or(0) <= current_now_ms
152        || lease_expires_at_ms <= current_now_ms
153    {
154        return Ok(false);
155    }
156    let previous_lease_expires_at_ms = checkpoint
157        .lease_expires_at_ms
158        .expect("validated lease expiry must be present");
159    checkpoint.lease_expires_at_ms = Some(lease_expires_at_ms);
160    let payload = RedisStateStore::checkpoint_to_json(&checkpoint)?;
161    let result = cmd("EVAL")
162        .arg(RENEW_CLAIM_SCRIPT)
163        .arg(1)
164        .arg(key)
165        .arg(&raw)
166        .arg(payload)
167        .arg(previous_lease_expires_at_ms)
168        .arg(lease_expires_at_ms)
169        .arg(clock.now_ms())
170        .query::<i64>(connection)
171        .map_err(redis_to_io)?;
172    match result {
173        0 => Ok(false),
174        1 => Ok(true),
175        2 => Err(Error::new(ErrorKind::TimedOut, "claim lease expired")),
176        unexpected => Err(Error::new(
177            ErrorKind::InvalidData,
178            format!("redis checkpoint renewal returned unexpected result: {unexpected}"),
179        )),
180    }
181}
182
183impl StateStore for RedisStateStore {
184    fn create_checkpoint(&self, checkpoint: Checkpoint) -> Result<bool> {
185        let key = Self::checkpoint_key(&checkpoint.task_id);
186        let payload = Self::checkpoint_to_json(&checkpoint)?;
187        self.connection
188            .lock()
189            .map_err(|_| Error::other("redis state store lock is poisoned"))?
190            .set_nx(key, payload)
191            .map_err(redis_to_io)
192    }
193
194    fn save_checkpoint(&self, checkpoint: Checkpoint) -> Result<()> {
195        let key = Self::checkpoint_key(&checkpoint.task_id);
196        let payload = Self::checkpoint_to_json(&checkpoint)?;
197        self.connection
198            .lock()
199            .map_err(|_| Error::other("redis state store lock is poisoned"))?
200            .set::<_, _, ()>(key, payload)
201            .map_err(redis_to_io)
202    }
203
204    fn load_checkpoint(&self, task_id: &str) -> Result<Option<Checkpoint>> {
205        let key = Self::checkpoint_key(task_id);
206        let raw = self
207            .connection
208            .lock()
209            .map_err(|_| Error::other("redis state store lock is poisoned"))?
210            .get::<_, Option<String>>(key)
211            .map_err(redis_to_io)?;
212        raw.as_deref().map(Self::checkpoint_from_json).transpose()
213    }
214
215    fn claim_checkpoint(
216        &self,
217        task_id: &str,
218        cycle_index: u32,
219        claim_token: &str,
220        lease_expires_at_ms: u64,
221        now_ms: u64,
222    ) -> Result<Option<Checkpoint>> {
223        validate_claim(cycle_index, claim_token, lease_expires_at_ms, now_ms)?;
224        let key = Self::checkpoint_key(task_id);
225        self.transaction(&key, |connection, pipe| {
226            let Some(raw) = connection.get::<_, Option<String>>(&key)? else {
227                return Ok(Some(None));
228            };
229            let mut checkpoint = Self::checkpoint_from_json(&raw).map_err(io_to_redis)?;
230            check_claim(&checkpoint, cycle_index, now_ms).map_err(io_to_redis)?;
231            checkpoint.revision = checkpoint.revision.checked_add(1).ok_or_else(|| {
232                io_to_redis(Error::new(
233                    ErrorKind::InvalidData,
234                    "checkpoint revision overflow",
235                ))
236            })?;
237            checkpoint.claim_token = Some(claim_token.to_string());
238            checkpoint.claimed_cycle = Some(cycle_index);
239            checkpoint.lease_expires_at_ms = Some(lease_expires_at_ms);
240            let payload = Self::checkpoint_to_json(&checkpoint).map_err(io_to_redis)?;
241            pipe.set(&key, payload).ignore();
242            query_committed_transaction(connection, pipe, Some(checkpoint))
243        })
244    }
245
246    fn commit_checkpoint(
247        &self,
248        mut checkpoint: Checkpoint,
249        claim_token: &str,
250        expected_revision: u64,
251    ) -> Result<bool> {
252        let key = Self::checkpoint_key(&checkpoint.task_id);
253        self.transaction(&key, |connection, pipe| {
254            let current = connection.get::<_, Option<String>>(&key)?;
255            let current = current
256                .as_deref()
257                .map(Self::checkpoint_from_json)
258                .transpose()
259                .map_err(io_to_redis)?;
260            if !claim_matches(
261                current.as_ref(),
262                &checkpoint,
263                claim_token,
264                expected_revision,
265            ) {
266                return Ok(Some(false));
267            }
268            checkpoint.revision = expected_revision.checked_add(1).ok_or_else(|| {
269                io_to_redis(Error::new(
270                    ErrorKind::InvalidData,
271                    "checkpoint revision overflow",
272                ))
273            })?;
274            clear_claim(&mut checkpoint);
275            let payload = Self::checkpoint_to_json(&checkpoint).map_err(io_to_redis)?;
276            pipe.set(&key, payload).ignore();
277            query_committed_transaction(connection, pipe, true)
278        })
279    }
280
281    fn renew_checkpoint_claim(
282        &self,
283        task_id: &str,
284        claim_token: &str,
285        expected_revision: u64,
286        lease_expires_at_ms: u64,
287        now_ms: u64,
288    ) -> Result<bool> {
289        validate_renew(claim_token, expected_revision, lease_expires_at_ms, now_ms)?;
290        let clock = LeaseOperationClock::new(now_ms);
291        let key = Self::checkpoint_key(task_id);
292        let mut connection = self
293            .connection
294            .lock()
295            .map_err(|_| Error::other("redis state store lock is poisoned"))?;
296        renew_checkpoint_claim_with_connection(
297            &mut *connection,
298            &key,
299            claim_token,
300            expected_revision,
301            lease_expires_at_ms,
302            &clock,
303        )
304    }
305
306    fn finalize_checkpoint(
307        &self,
308        mut checkpoint: Checkpoint,
309        expected_revision: u64,
310    ) -> Result<bool> {
311        if checkpoint.terminal_result.is_none() {
312            return Err(Error::new(
313                ErrorKind::InvalidInput,
314                "finalized checkpoint must include terminal_result",
315            ));
316        }
317        let key = Self::checkpoint_key(&checkpoint.task_id);
318        self.transaction(&key, |connection, pipe| {
319            let current = connection.get::<_, Option<String>>(&key)?;
320            let current = current
321                .as_deref()
322                .map(Self::checkpoint_from_json)
323                .transpose()
324                .map_err(io_to_redis)?;
325            if current.as_ref().is_none_or(|current| {
326                current.revision != expected_revision
327                    || current.claim_token.is_some()
328                    || current.terminal_result.is_some()
329            }) {
330                return Ok(Some(false));
331            }
332            checkpoint.revision = expected_revision.checked_add(1).ok_or_else(|| {
333                io_to_redis(Error::new(
334                    ErrorKind::InvalidData,
335                    "checkpoint revision overflow",
336                ))
337            })?;
338            clear_claim(&mut checkpoint);
339            let payload = Self::checkpoint_to_json(&checkpoint).map_err(io_to_redis)?;
340            pipe.set(&key, payload).ignore();
341            query_committed_transaction(connection, pipe, true)
342        })
343    }
344
345    fn delete_checkpoint(&self, task_id: &str) -> Result<()> {
346        let key = Self::checkpoint_key(task_id);
347        self.connection
348            .lock()
349            .map_err(|_| Error::other("redis state store lock is poisoned"))?
350            .del::<_, ()>(key)
351            .map_err(redis_to_io)
352    }
353
354    fn acknowledge_terminal(&self, task_id: &str, expected_revision: u64) -> Result<bool> {
355        let key = Self::checkpoint_key(task_id);
356        self.transaction(&key, |connection, pipe| {
357            let current = connection.get::<_, Option<String>>(&key)?;
358            let matches = current
359                .as_deref()
360                .map(Self::checkpoint_from_json)
361                .transpose()
362                .map_err(io_to_redis)?
363                .is_some_and(|checkpoint| {
364                    checkpoint.revision == expected_revision && checkpoint.terminal_result.is_some()
365                });
366            if !matches {
367                return Ok(Some(false));
368            }
369            pipe.del(&key).ignore();
370            query_committed_transaction(connection, pipe, true)
371        })
372    }
373
374    fn list_checkpoints(&self) -> Result<Vec<String>> {
375        let pattern = format!("{KEY_PREFIX}*");
376        let mut connection = self
377            .connection
378            .lock()
379            .map_err(|_| Error::other("redis state store lock is poisoned"))?;
380        let iter = connection
381            .scan_match::<_, String>(pattern)
382            .map_err(redis_to_io)?;
383        let mut keys = iter
384            .map(|key| key.strip_prefix(KEY_PREFIX).unwrap_or(&key).to_string())
385            .collect::<Vec<_>>();
386        keys.sort();
387        Ok(keys)
388    }
389
390    fn state_store_spec(&self) -> Option<StateStoreSpec> {
391        StateStoreSpec::redis(&self.redis_url).ok()
392    }
393}
394
395fn redis_to_io(error: redis::RedisError) -> Error {
396    Error::other(error.to_string())
397}
398
399fn io_to_redis(error: Error) -> redis::RedisError {
400    redis::RedisError::from((
401        redis::ErrorKind::TypeError,
402        "checkpoint state error",
403        error.to_string(),
404    ))
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use std::collections::VecDeque;
411
412    struct ScriptedConnection {
413        exec_responses: VecDeque<redis::Value>,
414        watch_calls: usize,
415        unwatch_calls: usize,
416        exec_calls: usize,
417    }
418
419    impl ScriptedConnection {
420        fn new(exec_responses: impl IntoIterator<Item = redis::Value>) -> Self {
421            Self {
422                exec_responses: exec_responses.into_iter().collect(),
423                watch_calls: 0,
424                unwatch_calls: 0,
425                exec_calls: 0,
426            }
427        }
428    }
429
430    impl ConnectionLike for ScriptedConnection {
431        fn req_packed_command(&mut self, command: &[u8]) -> RedisResult<redis::Value> {
432            if command
433                .windows(b"UNWATCH".len())
434                .any(|window| window == b"UNWATCH")
435            {
436                self.unwatch_calls += 1;
437            } else if command
438                .windows(b"WATCH".len())
439                .any(|window| window == b"WATCH")
440            {
441                self.watch_calls += 1;
442            } else {
443                panic!("unexpected standalone Redis command");
444            }
445            Ok(redis::Value::Okay)
446        }
447
448        fn req_packed_commands(
449            &mut self,
450            _command: &[u8],
451            _offset: usize,
452            _count: usize,
453        ) -> RedisResult<Vec<redis::Value>> {
454            self.exec_calls += 1;
455            let response = self
456                .exec_responses
457                .pop_front()
458                .expect("scripted EXEC response");
459            Ok(vec![response])
460        }
461
462        fn get_db(&self) -> i64 {
463            0
464        }
465
466        fn check_connection(&mut self) -> bool {
467            true
468        }
469
470        fn is_open(&self) -> bool {
471            true
472        }
473    }
474
475    struct AtomicRenewConnection {
476        value: Option<String>,
477        replace_before_eval: Option<String>,
478        server_now_ms: u64,
479        get_calls: usize,
480        eval_calls: usize,
481    }
482
483    impl AtomicRenewConnection {
484        fn new(value: String, server_now_ms: u64) -> Self {
485            Self {
486                value: Some(value),
487                replace_before_eval: None,
488                server_now_ms,
489                get_calls: 0,
490                eval_calls: 0,
491            }
492        }
493
494        fn replace_before_eval(mut self, value: String) -> Self {
495            self.replace_before_eval = Some(value);
496            self
497        }
498    }
499
500    impl ConnectionLike for AtomicRenewConnection {
501        fn req_packed_command(&mut self, command: &[u8]) -> RedisResult<redis::Value> {
502            let arguments = packed_command_arguments(command);
503            match arguments.first().map(Vec::as_slice) {
504                Some(b"GET") => {
505                    self.get_calls += 1;
506                    Ok(self
507                        .value
508                        .as_ref()
509                        .map(|value| redis::Value::BulkString(value.as_bytes().to_vec()))
510                        .unwrap_or(redis::Value::Nil))
511                }
512                Some(b"EVAL") => {
513                    self.eval_calls += 1;
514                    assert_eq!(arguments[1], RENEW_CLAIM_SCRIPT.as_bytes());
515                    assert_eq!(arguments[2], b"1");
516                    if let Some(replacement) = self.replace_before_eval.take() {
517                        self.value = Some(replacement);
518                    }
519                    let expected = std::str::from_utf8(&arguments[4]).expect("expected payload");
520                    let updated = std::str::from_utf8(&arguments[5]).expect("updated payload");
521                    let previous_expiry = parse_u64_argument(&arguments[6]);
522                    let requested_expiry = parse_u64_argument(&arguments[7]);
523                    let client_now = parse_u64_argument(&arguments[8]);
524                    let current_now = self.server_now_ms.max(client_now);
525                    if previous_expiry <= current_now || requested_expiry <= current_now {
526                        return Ok(redis::Value::Int(2));
527                    }
528                    if self.value.as_deref() != Some(expected) {
529                        return Ok(redis::Value::Int(0));
530                    }
531                    self.value = Some(updated.to_string());
532                    Ok(redis::Value::Int(1))
533                }
534                command => panic!("unexpected standalone Redis command: {command:?}"),
535            }
536        }
537
538        fn req_packed_commands(
539            &mut self,
540            _command: &[u8],
541            _offset: usize,
542            _count: usize,
543        ) -> RedisResult<Vec<redis::Value>> {
544            panic!("atomic renewal does not use a pipeline")
545        }
546
547        fn get_db(&self) -> i64 {
548            0
549        }
550
551        fn check_connection(&mut self) -> bool {
552            true
553        }
554
555        fn is_open(&self) -> bool {
556            true
557        }
558    }
559
560    fn packed_command_arguments(command: &[u8]) -> Vec<Vec<u8>> {
561        let mut cursor = 0;
562        assert_eq!(command.get(cursor), Some(&b'*'));
563        cursor += 1;
564        let count = read_resp_number(command, &mut cursor);
565        (0..count)
566            .map(|_| {
567                assert_eq!(command.get(cursor), Some(&b'$'));
568                cursor += 1;
569                let length = read_resp_number(command, &mut cursor);
570                let value = command[cursor..cursor + length].to_vec();
571                cursor += length;
572                assert_eq!(&command[cursor..cursor + 2], b"\r\n");
573                cursor += 2;
574                value
575            })
576            .collect()
577    }
578
579    fn read_resp_number(command: &[u8], cursor: &mut usize) -> usize {
580        let line_start = *cursor;
581        while &command[*cursor..*cursor + 2] != b"\r\n" {
582            *cursor += 1;
583        }
584        let number = std::str::from_utf8(&command[line_start..*cursor])
585            .expect("RESP number")
586            .parse()
587            .expect("valid RESP number");
588        *cursor += 2;
589        number
590    }
591
592    fn parse_u64_argument(value: &[u8]) -> u64 {
593        std::str::from_utf8(value)
594            .expect("integer argument")
595            .parse()
596            .expect("valid u64 argument")
597    }
598
599    #[test]
600    fn exec_nil_retries_until_a_transaction_commits() {
601        let mut connection = ScriptedConnection::new([
602            redis::Value::Nil,
603            redis::Value::Nil,
604            redis::Value::Array(vec![redis::Value::Okay]),
605        ]);
606        let mut operation_calls = 0;
607
608        let committed =
609            transaction_with_connection(&mut connection, "checkpoint", |connection, pipe| {
610                operation_calls += 1;
611                pipe.set("checkpoint", "value").ignore();
612                query_committed_transaction(connection, pipe, true)
613            })
614            .expect("third transaction attempt commits");
615
616        assert!(committed);
617        assert_eq!(operation_calls, 3);
618        assert_eq!(connection.watch_calls, 3);
619        assert_eq!(connection.exec_calls, 3);
620        assert_eq!(connection.unwatch_calls, 1);
621    }
622
623    #[test]
624    fn exec_nil_stops_after_the_transaction_retry_limit() {
625        let mut connection = ScriptedConnection::new(std::iter::repeat_n(
626            redis::Value::Nil,
627            TRANSACTION_MAX_ATTEMPTS,
628        ));
629        let mut operation_calls = 0;
630
631        let error =
632            transaction_with_connection(&mut connection, "checkpoint", |connection, pipe| {
633                operation_calls += 1;
634                pipe.set("checkpoint", "value").ignore();
635                query_committed_transaction(connection, pipe, true)
636            })
637            .expect_err("transaction conflicts must be bounded");
638
639        assert_eq!(error.kind(), ErrorKind::TimedOut);
640        assert_eq!(
641            error.to_string(),
642            "redis checkpoint transaction retry limit exceeded"
643        );
644        assert_eq!(operation_calls, TRANSACTION_MAX_ATTEMPTS);
645        assert_eq!(connection.watch_calls, TRANSACTION_MAX_ATTEMPTS);
646        assert_eq!(connection.exec_calls, TRANSACTION_MAX_ATTEMPTS);
647        assert_eq!(connection.unwatch_calls, 1);
648    }
649
650    #[test]
651    fn renewal_atomic_script_updates_an_active_owner() {
652        let checkpoint = renewal_checkpoint("renewal-active", 200);
653        let raw = RedisStateStore::checkpoint_to_json(&checkpoint).expect("checkpoint json");
654        let mut connection = AtomicRenewConnection::new(raw, 150);
655        let clock = LeaseOperationClock::new(150);
656
657        let renewed = renew_checkpoint_claim_with_connection(
658            &mut connection,
659            "checkpoint",
660            "owner",
661            1,
662            300,
663            &clock,
664        )
665        .expect("renewal outcome");
666
667        assert!(renewed);
668        let persisted = RedisStateStore::checkpoint_from_json(
669            connection.value.as_deref().expect("persisted checkpoint"),
670        )
671        .expect("persisted checkpoint json");
672        assert_eq!(persisted.lease_expires_at_ms, Some(300));
673        assert_eq!(connection.get_calls, 1);
674        assert_eq!(connection.eval_calls, 1);
675    }
676
677    #[test]
678    fn renewal_atomic_script_rejects_owner_expired_at_write() {
679        let checkpoint = renewal_checkpoint("renewal-expired-at-write", 110);
680        let raw = RedisStateStore::checkpoint_to_json(&checkpoint).expect("checkpoint json");
681        let mut connection = AtomicRenewConnection::new(raw.clone(), 110);
682        let clock = LeaseOperationClock::new(100);
683
684        let error = renew_checkpoint_claim_with_connection(
685            &mut connection,
686            "checkpoint",
687            "owner",
688            1,
689            1_000,
690            &clock,
691        )
692        .expect_err("an owner expired at the atomic write boundary must fail explicitly");
693
694        assert_eq!(error.kind(), ErrorKind::TimedOut);
695        assert_eq!(error.to_string(), "claim lease expired");
696        assert_eq!(connection.value.as_deref(), Some(raw.as_str()));
697        let persisted = RedisStateStore::checkpoint_from_json(&raw).expect("checkpoint json");
698        check_claim(&persisted, 1, 110).expect("an expired claim is immediately reclaimable");
699        assert_eq!(connection.get_calls, 1);
700        assert_eq!(connection.eval_calls, 1);
701    }
702
703    #[test]
704    fn renewal_atomic_script_does_not_overwrite_a_new_owner() {
705        let checkpoint = renewal_checkpoint("renewal-cas-mismatch", 200);
706        let replacement = Checkpoint {
707            revision: 2,
708            claim_token: Some("contender".to_string()),
709            lease_expires_at_ms: Some(500),
710            ..checkpoint.clone()
711        };
712        let raw = RedisStateStore::checkpoint_to_json(&checkpoint).expect("checkpoint json");
713        let replacement_raw =
714            RedisStateStore::checkpoint_to_json(&replacement).expect("replacement checkpoint json");
715        let mut connection =
716            AtomicRenewConnection::new(raw, 150).replace_before_eval(replacement_raw.clone());
717        let clock = LeaseOperationClock::new(150);
718
719        let renewed = renew_checkpoint_claim_with_connection(
720            &mut connection,
721            "checkpoint",
722            "owner",
723            1,
724            300,
725            &clock,
726        )
727        .expect("renewal outcome");
728
729        assert!(!renewed);
730        assert_eq!(connection.value.as_deref(), Some(replacement_raw.as_str()));
731    }
732
733    #[test]
734    fn renewal_atomic_script_prioritizes_expiry_over_cas_mismatch() {
735        let checkpoint = renewal_checkpoint("renewal-expiry-before-cas", 200);
736        let replacement = Checkpoint {
737            revision: 2,
738            claim_token: None,
739            claimed_cycle: None,
740            lease_expires_at_ms: None,
741            ..checkpoint.clone()
742        };
743        let raw = RedisStateStore::checkpoint_to_json(&checkpoint).expect("checkpoint json");
744        let replacement_raw =
745            RedisStateStore::checkpoint_to_json(&replacement).expect("replacement checkpoint json");
746        let mut connection =
747            AtomicRenewConnection::new(raw, 200).replace_before_eval(replacement_raw.clone());
748        let clock = LeaseOperationClock::new(150);
749
750        let error = renew_checkpoint_claim_with_connection(
751            &mut connection,
752            "checkpoint",
753            "owner",
754            1,
755            300,
756            &clock,
757        )
758        .expect_err("authoritative expiry must take precedence over CAS loss");
759
760        assert_eq!(error.kind(), ErrorKind::TimedOut);
761        assert_eq!(error.to_string(), "claim lease expired");
762        assert_eq!(connection.value.as_deref(), Some(replacement_raw.as_str()));
763    }
764
765    fn renewal_checkpoint(task_id: &str, lease_expires_at_ms: u64) -> Checkpoint {
766        Checkpoint {
767            task_id: task_id.to_string(),
768            cycle_index: 0,
769            status: crate::types::AgentStatus::Running,
770            messages: Vec::new(),
771            cycles: Vec::new(),
772            shared_state: Default::default(),
773            revision: 1,
774            claim_token: Some("owner".to_string()),
775            claimed_cycle: Some(1),
776            lease_expires_at_ms: Some(lease_expires_at_ms),
777            terminal_result: None,
778            budget_usage: None,
779        }
780    }
781}