1use crate::coordinated_store::{
2 CoordinatedClaim, CoordinatedLeaseConfig, CoordinatedPendingTrigger, CoordinatedRuntimeState,
3 CoordinatedStateStore,
4};
5use crate::error::{ExecutionGuardErrorKind, StoreErrorKind};
6use crate::execution_guard::{ExecutionGuardRenewal, ExecutionGuardScope, ExecutionLease};
7use crate::model::JobState;
8use crate::valkey_execution_support::{
9 next_token, now_millis, occurrence_index_key, occurrence_lease_key, resource_lock_key,
10};
11use crate::valkey_runtime::{ValkeyCommandOutcome, ValkeyRecoveryConfig, ValkeyRuntime};
12use crate::valkey_scripts;
13use crate::valkey_store::ValkeyStoreError;
14use chrono::SecondsFormat;
15use chrono::{DateTime, Utc};
16use redis::{AsyncCommands, Client, cmd};
17use std::collections::HashMap;
18use std::sync::atomic::AtomicU64;
19
20const DEFAULT_STATE_KEY_PREFIX: &str = "scheduler:valkey:job-state:";
21const LEGACY_DEFAULT_STATE_KEY_PREFIX: &str = "scheduler:job-state:";
22const DEFAULT_EXECUTION_KEY_PREFIX: &str = "scheduler:valkey:execution-lease:";
23
24const FIELD_VERSION: &str = "version";
25const FIELD_STATE: &str = "state";
26const FIELD_PAUSED: &str = "paused";
27const FIELD_INFLIGHT_SCHEDULED_AT: &str = "inflight_scheduled_at";
28const FIELD_INFLIGHT_CATCH_UP: &str = "inflight_catch_up";
29const FIELD_INFLIGHT_TRIGGER_COUNT: &str = "inflight_trigger_count";
30const FIELD_INFLIGHT_RESOURCE_ID: &str = "inflight_resource_id";
31const FIELD_INFLIGHT_SCOPE: &str = "inflight_scope";
32const FIELD_INFLIGHT_TOKEN: &str = "inflight_token";
33const FIELD_INFLIGHT_LEASE_KEY: &str = "inflight_lease_key";
34const FIELD_INFLIGHT_LEASE_EXPIRES_AT: &str = "inflight_lease_expires_at";
35
36static COORDINATED_TOKEN_COUNTER: AtomicU64 = AtomicU64::new(1);
37
38#[derive(Debug, Clone)]
39pub struct ValkeyCoordinatedStateStore {
40 runtime: ValkeyRuntime,
41 state_key_prefix: String,
42 execution_key_prefix: String,
43}
44
45impl ValkeyCoordinatedStateStore {
46 pub async fn new(url: impl AsRef<str>) -> Result<Self, redis::RedisError> {
47 Self::with_prefixes(url, DEFAULT_STATE_KEY_PREFIX, DEFAULT_EXECUTION_KEY_PREFIX).await
48 }
49
50 pub async fn with_prefixes(
51 url: impl AsRef<str>,
52 state_key_prefix: impl Into<String>,
53 execution_key_prefix: impl Into<String>,
54 ) -> Result<Self, redis::RedisError> {
55 Self::with_prefixes_and_recovery_config(
56 url,
57 state_key_prefix,
58 execution_key_prefix,
59 ValkeyRecoveryConfig::default(),
60 )
61 .await
62 }
63
64 pub async fn with_recovery_config(
65 url: impl AsRef<str>,
66 config: ValkeyRecoveryConfig,
67 ) -> Result<Self, redis::RedisError> {
68 Self::with_prefixes_and_recovery_config(
69 url,
70 DEFAULT_STATE_KEY_PREFIX,
71 DEFAULT_EXECUTION_KEY_PREFIX,
72 config,
73 )
74 .await
75 }
76
77 pub async fn with_prefixes_and_recovery_config(
78 url: impl AsRef<str>,
79 state_key_prefix: impl Into<String>,
80 execution_key_prefix: impl Into<String>,
81 config: ValkeyRecoveryConfig,
82 ) -> Result<Self, redis::RedisError> {
83 let client = Client::open(url.as_ref())?;
84 let runtime = ValkeyRuntime::from_client(client, config).await?;
85 Ok(Self {
86 runtime,
87 state_key_prefix: state_key_prefix.into(),
88 execution_key_prefix: execution_key_prefix.into(),
89 })
90 }
91
92 fn state_key(&self, job_id: &str) -> String {
93 format!("{}{}", self.state_key_prefix, job_id)
94 }
95
96 fn legacy_state_key(&self, job_id: &str) -> Option<String> {
97 if self.state_key_prefix == DEFAULT_STATE_KEY_PREFIX {
98 Some(format!("{LEGACY_DEFAULT_STATE_KEY_PREFIX}{job_id}"))
99 } else {
100 None
101 }
102 }
103
104 fn resource_lock_key(&self, resource_id: &str) -> String {
105 resource_lock_key(&self.execution_key_prefix, resource_id)
106 }
107
108 fn occurrence_index_key(&self, resource_id: &str) -> String {
109 occurrence_index_key(&self.execution_key_prefix, resource_id)
110 }
111
112 fn occurrence_lease_key(&self, resource_id: &str, scheduled_at: DateTime<Utc>) -> String {
113 occurrence_lease_key(&self.execution_key_prefix, resource_id, scheduled_at)
114 }
115
116 async fn key_type(&self, key: &str) -> Result<String, ValkeyStoreError> {
117 let key = key.to_string();
118 let result: ValkeyCommandOutcome<String> = self
119 .runtime
120 .execute(move |mut connection| {
121 let key = key.clone();
122 async move { cmd("TYPE").arg(key).query_async(&mut connection).await }
123 })
124 .await
125 .map_err(ValkeyStoreError::from)?;
126 match result {
127 ValkeyCommandOutcome::Available(value) => Ok(value),
128 ValkeyCommandOutcome::Degraded => Err(ValkeyStoreError::Unavailable),
129 }
130 }
131
132 async fn load_hash(
133 &self,
134 key: &str,
135 ) -> Result<Option<CoordinatedRuntimeState>, ValkeyStoreError> {
136 let key = key.to_string();
137 let result: ValkeyCommandOutcome<HashMap<String, String>> = self
138 .runtime
139 .execute(move |mut connection| {
140 let key = key.clone();
141 async move { connection.hgetall(key).await }
142 })
143 .await
144 .map_err(ValkeyStoreError::from)?;
145 let ValkeyCommandOutcome::Available(fields) = result else {
146 return Err(ValkeyStoreError::Unavailable);
147 };
148 if fields.is_empty() {
149 return Ok(None);
150 }
151
152 Ok(Some(parse_runtime_state(&fields)?))
153 }
154
155 async fn migrate_string_state(
156 &self,
157 key: &str,
158 payload: String,
159 ) -> Result<CoordinatedRuntimeState, ValkeyStoreError> {
160 let state: JobState = serde_json::from_str(&payload).map_err(ValkeyStoreError::from)?;
161 let runtime = CoordinatedRuntimeState {
162 state,
163 revision: 0,
164 paused: false,
165 };
166 self.write_runtime(key, &runtime).await?;
167 Ok(runtime)
168 }
169
170 async fn write_runtime(
171 &self,
172 key: &str,
173 runtime: &CoordinatedRuntimeState,
174 ) -> Result<(), ValkeyStoreError> {
175 let payload = serde_json::to_string(&runtime.state).map_err(ValkeyStoreError::from)?;
176 let key = key.to_string();
177 let revision = runtime.revision;
178 let paused = runtime.paused;
179 let result = self
180 .runtime
181 .execute(move |mut connection| {
182 let key = key.clone();
183 let payload = payload.clone();
184 async move {
185 let _: () = cmd("DEL").arg(&key).query_async(&mut connection).await?;
186 let _: () = cmd("HSET")
187 .arg(key)
188 .arg(FIELD_VERSION)
189 .arg(revision)
190 .arg(FIELD_STATE)
191 .arg(payload)
192 .arg(FIELD_PAUSED)
193 .arg(if paused { "1" } else { "0" })
194 .query_async(&mut connection)
195 .await?;
196 Ok(())
197 }
198 })
199 .await
200 .map_err(ValkeyStoreError::from)?;
201 match result {
202 ValkeyCommandOutcome::Available(()) => Ok(()),
203 ValkeyCommandOutcome::Degraded => Err(ValkeyStoreError::Unavailable),
204 }
205 }
206
207 async fn load_payload_state(&self, key: &str) -> Result<Option<String>, ValkeyStoreError> {
208 let key = key.to_string();
209 let result = self
210 .runtime
211 .execute(move |mut connection| {
212 let key = key.clone();
213 async move { connection.get(key).await }
214 })
215 .await
216 .map_err(ValkeyStoreError::from)?;
217 match result {
218 ValkeyCommandOutcome::Available(value) => Ok(value),
219 ValkeyCommandOutcome::Degraded => Err(ValkeyStoreError::Unavailable),
220 }
221 }
222}
223
224impl CoordinatedStateStore for ValkeyCoordinatedStateStore {
225 type Error = ValkeyStoreError;
226
227 async fn load_or_initialize(
228 &self,
229 job_id: &str,
230 initial_state: JobState,
231 ) -> Result<CoordinatedRuntimeState, Self::Error> {
232 let key = self.state_key(job_id);
233 match self.key_type(&key).await?.as_str() {
234 "hash" => {
235 if let Some(runtime) = self.load_hash(&key).await? {
236 return Ok(runtime);
237 }
238 }
239 "string" => {
240 if let Some(payload) = self.load_payload_state(&key).await? {
241 return self.migrate_string_state(&key, payload).await;
242 }
243 }
244 "none" => {}
245 _ => {}
246 }
247
248 if let Some(legacy_key) = self.legacy_state_key(job_id) {
249 if self.key_type(&legacy_key).await?.as_str() == "string" {
250 if let Some(payload) = self.load_payload_state(&legacy_key).await? {
251 let runtime = self.migrate_string_state(&key, payload).await?;
252 let result: ValkeyCommandOutcome<()> = self
253 .runtime
254 .execute(move |mut connection| {
255 let legacy_key = legacy_key.clone();
256 async move {
257 cmd("DEL")
258 .arg(legacy_key)
259 .query_async(&mut connection)
260 .await
261 }
262 })
263 .await
264 .map_err(ValkeyStoreError::from)?;
265 if matches!(result, ValkeyCommandOutcome::Degraded) {
266 return Err(ValkeyStoreError::Unavailable);
267 }
268 return Ok(runtime);
269 }
270 }
271 }
272
273 let runtime = CoordinatedRuntimeState {
274 state: initial_state,
275 revision: 0,
276 paused: false,
277 };
278 self.write_runtime(&key, &runtime).await?;
279 Ok(runtime)
280 }
281
282 async fn save_state(
283 &self,
284 job_id: &str,
285 revision: u64,
286 state: &JobState,
287 ) -> Result<bool, Self::Error> {
288 let key = self.state_key(job_id);
289 let payload = serde_json::to_string(state).map_err(ValkeyStoreError::from)?;
290 let result: ValkeyCommandOutcome<i32> = self
291 .runtime
292 .execute(move |mut connection| {
293 let key = key.clone();
294 let payload = payload.clone();
295 async move {
296 valkey_scripts::script(valkey_scripts::coordinated::SAVE_STATE)
297 .key(key)
298 .arg(FIELD_VERSION)
299 .arg(revision)
300 .arg(FIELD_INFLIGHT_TOKEN)
301 .arg(FIELD_STATE)
302 .arg(payload)
303 .invoke_async(&mut connection)
304 .await
305 }
306 })
307 .await
308 .map_err(ValkeyStoreError::from)?;
309 match result {
310 ValkeyCommandOutcome::Available(updated) => Ok(updated == 1),
311 ValkeyCommandOutcome::Degraded => Err(ValkeyStoreError::Unavailable),
312 }
313 }
314
315 async fn reclaim_inflight(
316 &self,
317 job_id: &str,
318 resource_id: &str,
319 lease_config: CoordinatedLeaseConfig,
320 ) -> Result<Option<CoordinatedClaim>, Self::Error> {
321 let key = self.state_key(job_id);
322 let lease_key = self.occurrence_lease_key(resource_id, Utc::now());
323 let token = next_token(&COORDINATED_TOKEN_COUNTER, "coord");
324 let ttl_millis = u64::try_from(lease_config.ttl.as_millis()).unwrap_or(u64::MAX);
325 let now_millis = now_millis();
326 let expires_at_millis = now_millis.saturating_add(ttl_millis);
327 let resource_lock_key = self.resource_lock_key(resource_id);
328 let occurrence_index_key = self.occurrence_index_key(resource_id);
329 let occurrence_prefix = format!("{}{}:occurrence:", self.execution_key_prefix, resource_id);
330 let result: ValkeyCommandOutcome<Option<Vec<String>>> = self
331 .runtime
332 .execute({
333 let token = token.clone();
334 let lease_key = lease_key.clone();
335 move |mut connection| {
336 let key = key.clone();
337 let resource_lock_key = resource_lock_key.clone();
338 let lease_key = lease_key.clone();
339 let occurrence_index_key = occurrence_index_key.clone();
340 let occurrence_prefix = occurrence_prefix.clone();
341 let token = token.clone();
342 async move {
343 valkey_scripts::script(valkey_scripts::coordinated::RECLAIM_INFLIGHT)
344 .key(key)
345 .key(resource_lock_key)
346 .key(lease_key)
347 .key(occurrence_index_key)
348 .arg(FIELD_INFLIGHT_SCHEDULED_AT)
353 .arg(FIELD_INFLIGHT_CATCH_UP)
354 .arg(FIELD_INFLIGHT_TRIGGER_COUNT)
355 .arg(FIELD_INFLIGHT_RESOURCE_ID)
356 .arg(FIELD_INFLIGHT_SCOPE)
357 .arg(FIELD_INFLIGHT_LEASE_EXPIRES_AT)
358 .arg(FIELD_STATE)
359 .arg(FIELD_VERSION)
360 .arg(FIELD_PAUSED)
361 .arg(now_millis)
362 .arg(occurrence_prefix)
363 .arg(&token)
364 .arg(ttl_millis)
365 .arg(expires_at_millis)
366 .arg(FIELD_INFLIGHT_TOKEN)
367 .arg(FIELD_INFLIGHT_LEASE_KEY)
368 .invoke_async(&mut connection)
369 .await
370 }
371 }
372 })
373 .await
374 .map_err(ValkeyStoreError::from)?;
375
376 let ValkeyCommandOutcome::Available(result) = result else {
377 return Err(ValkeyStoreError::Unavailable);
378 };
379
380 let Some(values) = result else {
381 return Ok(None);
382 };
383 if values.len() != 8 {
384 return Ok(None);
385 }
386 let revision = values[0].parse::<u64>().unwrap_or(0);
387 let state: JobState = serde_json::from_str(&values[1]).map_err(ValkeyStoreError::from)?;
388 let scheduled_at = DateTime::parse_from_rfc3339(&values[2])
389 .map_err(|error| {
390 ValkeyStoreError::Codec(serde_json::Error::io(std::io::Error::other(
391 error.to_string(),
392 )))
393 })?
394 .with_timezone(&Utc);
395 let catch_up = values[3].parse::<bool>().unwrap_or(false);
396 let trigger_count = values[4].parse::<u32>().unwrap_or(0);
397 let scope = parse_scope(&values[5]);
398 Ok(Some(CoordinatedClaim {
399 state: CoordinatedRuntimeState {
400 state,
401 revision,
402 paused: false,
403 },
404 trigger: CoordinatedPendingTrigger {
405 scheduled_at,
406 catch_up,
407 trigger_count,
408 },
409 lease: ExecutionLease::new(
410 job_id.to_string(),
411 resource_id.to_string(),
412 scope,
413 Some(scheduled_at),
414 values[7].clone(),
415 values[6].clone(),
416 ),
417 replayed: true,
418 }))
419 }
420
421 async fn claim_trigger(
422 &self,
423 job_id: &str,
424 resource_id: &str,
425 revision: u64,
426 trigger: CoordinatedPendingTrigger,
427 next_state: &JobState,
428 lease_config: CoordinatedLeaseConfig,
429 ) -> Result<Option<CoordinatedClaim>, Self::Error> {
430 let key = self.state_key(job_id);
431 let lease_key = self.occurrence_lease_key(resource_id, trigger.scheduled_at);
432 let token = next_token(&COORDINATED_TOKEN_COUNTER, "coord");
433 let ttl_millis = u64::try_from(lease_config.ttl.as_millis()).unwrap_or(u64::MAX);
434 let now_millis = now_millis();
435 let expires_at_millis = now_millis.saturating_add(ttl_millis);
436 let next_state_payload =
437 serde_json::to_string(next_state).map_err(ValkeyStoreError::from)?;
438 let resource_lock_key = self.resource_lock_key(resource_id);
439 let occurrence_index_key = self.occurrence_index_key(resource_id);
440 let scheduled_at = trigger
441 .scheduled_at
442 .to_rfc3339_opts(SecondsFormat::Nanos, true);
443 let resource_id_arg = resource_id.to_string();
444 let command_trigger = trigger.clone();
445 let result: ValkeyCommandOutcome<i64> = self
446 .runtime
447 .execute({
448 let token = token.clone();
449 let lease_key = lease_key.clone();
450 let command_trigger = command_trigger.clone();
451 move |mut connection| {
452 let key = key.clone();
453 let resource_lock_key = resource_lock_key.clone();
454 let lease_key = lease_key.clone();
455 let occurrence_index_key = occurrence_index_key.clone();
456 let token = token.clone();
457 let next_state_payload = next_state_payload.clone();
458 let scheduled_at = scheduled_at.clone();
459 let resource_id_arg = resource_id_arg.clone();
460 let command_trigger = command_trigger.clone();
461 async move {
462 valkey_scripts::script(valkey_scripts::coordinated::CLAIM_TRIGGER)
463 .key(key)
464 .key(resource_lock_key)
465 .key(&lease_key)
466 .key(occurrence_index_key)
467 .arg(FIELD_VERSION)
468 .arg(FIELD_INFLIGHT_TOKEN)
469 .arg(FIELD_INFLIGHT_LEASE_EXPIRES_AT)
470 .arg(FIELD_PAUSED)
471 .arg(now_millis)
472 .arg(revision)
473 .arg(&token)
474 .arg(ttl_millis)
475 .arg(expires_at_millis)
476 .arg(FIELD_STATE)
477 .arg(next_state_payload)
478 .arg(FIELD_INFLIGHT_SCHEDULED_AT)
479 .arg(scheduled_at)
480 .arg(FIELD_INFLIGHT_CATCH_UP)
481 .arg(command_trigger.catch_up)
482 .arg(FIELD_INFLIGHT_TRIGGER_COUNT)
483 .arg(command_trigger.trigger_count)
484 .arg(FIELD_INFLIGHT_RESOURCE_ID)
485 .arg(resource_id_arg)
486 .arg(FIELD_INFLIGHT_SCOPE)
487 .arg("occurrence")
488 .arg(FIELD_INFLIGHT_TOKEN)
489 .arg(FIELD_INFLIGHT_LEASE_KEY)
490 .invoke_async(&mut connection)
491 .await
492 }
493 }
494 })
495 .await
496 .map_err(ValkeyStoreError::from)?;
497 let ValkeyCommandOutcome::Available(new_revision) = result else {
498 return Err(ValkeyStoreError::Unavailable);
499 };
500
501 if new_revision <= 0 {
502 return Ok(None);
503 }
504
505 Ok(Some(CoordinatedClaim {
506 state: CoordinatedRuntimeState {
507 state: next_state.clone(),
508 revision: new_revision as u64,
509 paused: false,
510 },
511 trigger: trigger.clone(),
512 lease: ExecutionLease::new(
513 job_id.to_string(),
514 resource_id.to_string(),
515 ExecutionGuardScope::Occurrence,
516 Some(trigger.scheduled_at),
517 token,
518 lease_key,
519 ),
520 replayed: false,
521 }))
522 }
523
524 async fn renew(
525 &self,
526 lease: &ExecutionLease,
527 lease_config: CoordinatedLeaseConfig,
528 ) -> Result<ExecutionGuardRenewal, Self::Error> {
529 let ttl_millis = u64::try_from(lease_config.ttl.as_millis()).unwrap_or(u64::MAX);
530 let expires_at_millis = now_millis().saturating_add(ttl_millis);
531 let lease = lease.clone();
532 let occurrence_index_key = self.occurrence_index_key(&lease.resource_id);
533 let state_key = self.state_key(&lease.job_id);
534 let result: ValkeyCommandOutcome<i32> = self
535 .runtime
536 .execute(move |mut connection| {
537 let lease = lease.clone();
538 let occurrence_index_key = occurrence_index_key.clone();
539 let state_key = state_key.clone();
540 async move {
541 valkey_scripts::script(valkey_scripts::coordinated::RENEW_LEASE)
542 .key(&lease.lease_key)
543 .key(occurrence_index_key)
544 .key(state_key)
545 .arg(&lease.token)
546 .arg(ttl_millis)
547 .arg(expires_at_millis)
548 .arg(FIELD_INFLIGHT_LEASE_EXPIRES_AT)
549 .invoke_async(&mut connection)
550 .await
551 }
552 })
553 .await
554 .map_err(ValkeyStoreError::from)?;
555 let ValkeyCommandOutcome::Available(renewed) = result else {
556 return Ok(ExecutionGuardRenewal::Lost);
557 };
558 Ok(if renewed == 1 {
559 ExecutionGuardRenewal::Renewed
560 } else {
561 ExecutionGuardRenewal::Lost
562 })
563 }
564
565 async fn complete(
566 &self,
567 job_id: &str,
568 revision: u64,
569 lease: &ExecutionLease,
570 state: &JobState,
571 ) -> Result<bool, Self::Error> {
572 let key = self.state_key(job_id);
573 let payload = serde_json::to_string(state).map_err(ValkeyStoreError::from)?;
574 let lease = lease.clone();
575 let occurrence_index_key = self.occurrence_index_key(&lease.resource_id);
576 let result: ValkeyCommandOutcome<i32> = self
577 .runtime
578 .execute(move |mut connection| {
579 let key = key.clone();
580 let lease = lease.clone();
581 let occurrence_index_key = occurrence_index_key.clone();
582 let payload = payload.clone();
583 async move {
584 valkey_scripts::script(valkey_scripts::coordinated::COMPLETE)
585 .key(key)
586 .key(&lease.lease_key)
587 .key(occurrence_index_key)
588 .arg(FIELD_VERSION)
589 .arg(FIELD_INFLIGHT_TOKEN)
590 .arg(revision)
591 .arg(&lease.token)
592 .arg(FIELD_STATE)
593 .arg(payload)
594 .arg(FIELD_INFLIGHT_SCHEDULED_AT)
595 .arg(FIELD_INFLIGHT_CATCH_UP)
596 .arg(FIELD_INFLIGHT_TRIGGER_COUNT)
597 .arg(FIELD_INFLIGHT_RESOURCE_ID)
598 .arg(FIELD_INFLIGHT_SCOPE)
599 .arg(FIELD_INFLIGHT_LEASE_KEY)
600 .invoke_async(&mut connection)
601 .await
602 }
603 })
604 .await
605 .map_err(ValkeyStoreError::from)?;
606 match result {
607 ValkeyCommandOutcome::Available(completed) => Ok(completed == 1),
608 ValkeyCommandOutcome::Degraded => Err(ValkeyStoreError::Unavailable),
609 }
610 }
611
612 async fn delete(&self, job_id: &str) -> Result<(), Self::Error> {
613 let key = self.state_key(job_id);
614 let result = self
615 .runtime
616 .execute(move |mut connection| {
617 let key = key.clone();
618 async move { cmd("DEL").arg(key).query_async(&mut connection).await }
619 })
620 .await
621 .map_err(ValkeyStoreError::from)?;
622 match result {
623 ValkeyCommandOutcome::Available(()) => Ok(()),
624 ValkeyCommandOutcome::Degraded => Err(ValkeyStoreError::Unavailable),
625 }
626 }
627
628 async fn pause(&self, job_id: &str) -> Result<bool, Self::Error> {
629 let key = self.state_key(job_id);
630 let result: ValkeyCommandOutcome<i32> = self
631 .runtime
632 .execute(move |mut connection| {
633 let key = key.clone();
634 async move {
635 valkey_scripts::script(valkey_scripts::coordinated::PAUSE)
636 .key(key)
637 .arg(FIELD_PAUSED)
638 .invoke_async(&mut connection)
639 .await
640 }
641 })
642 .await
643 .map_err(ValkeyStoreError::from)?;
644 match result {
645 ValkeyCommandOutcome::Available(changed) => Ok(changed == 1),
646 ValkeyCommandOutcome::Degraded => Err(ValkeyStoreError::Unavailable),
647 }
648 }
649
650 async fn resume(&self, job_id: &str) -> Result<bool, Self::Error> {
651 let key = self.state_key(job_id);
652 let result: ValkeyCommandOutcome<i32> = self
653 .runtime
654 .execute(move |mut connection| {
655 let key = key.clone();
656 async move {
657 valkey_scripts::script(valkey_scripts::coordinated::RESUME)
658 .key(key)
659 .arg(FIELD_PAUSED)
660 .invoke_async(&mut connection)
661 .await
662 }
663 })
664 .await
665 .map_err(ValkeyStoreError::from)?;
666 match result {
667 ValkeyCommandOutcome::Available(changed) => Ok(changed == 1),
668 ValkeyCommandOutcome::Degraded => Err(ValkeyStoreError::Unavailable),
669 }
670 }
671
672 fn classify_store_error(error: &Self::Error) -> StoreErrorKind
673 where
674 Self: Sized,
675 {
676 if matches!(error, ValkeyStoreError::Codec(_)) {
677 StoreErrorKind::Data
678 } else if error.is_connection_issue() {
679 StoreErrorKind::Connection
680 } else {
681 StoreErrorKind::Unknown
682 }
683 }
684
685 fn classify_guard_error(error: &Self::Error) -> ExecutionGuardErrorKind
686 where
687 Self: Sized,
688 {
689 if matches!(error, ValkeyStoreError::Codec(_)) {
690 ExecutionGuardErrorKind::Data
691 } else if error.is_connection_issue() {
692 ExecutionGuardErrorKind::Connection
693 } else {
694 ExecutionGuardErrorKind::Unknown
695 }
696 }
697}
698
699fn parse_runtime_state(
700 fields: &HashMap<String, String>,
701) -> Result<CoordinatedRuntimeState, ValkeyStoreError> {
702 let revision = fields
703 .get(FIELD_VERSION)
704 .and_then(|value| value.parse::<u64>().ok())
705 .unwrap_or(0);
706 let paused = fields
707 .get(FIELD_PAUSED)
708 .map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
709 .unwrap_or(false);
710 let state = serde_json::from_str(fields.get(FIELD_STATE).map(String::as_str).unwrap_or("{}"))
711 .map_err(ValkeyStoreError::from)?;
712 Ok(CoordinatedRuntimeState {
713 state,
714 revision,
715 paused,
716 })
717}
718
719fn parse_scope(raw: &str) -> ExecutionGuardScope {
720 match raw {
721 "resource" => ExecutionGuardScope::Resource,
722 _ => ExecutionGuardScope::Occurrence,
723 }
724}