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}