Skip to main content

scheduler/
valkey_coordinated_store.rs

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_scripts;
12use crate::valkey_store::ValkeyStoreError;
13use chrono::SecondsFormat;
14use chrono::{DateTime, Utc};
15use redis::{AsyncCommands, Client, aio::ConnectionManager, cmd};
16use std::collections::HashMap;
17use std::sync::atomic::AtomicU64;
18
19const DEFAULT_STATE_KEY_PREFIX: &str = "scheduler:valkey:job-state:";
20const LEGACY_DEFAULT_STATE_KEY_PREFIX: &str = "scheduler:job-state:";
21const DEFAULT_EXECUTION_KEY_PREFIX: &str = "scheduler:valkey:execution-lease:";
22
23const FIELD_VERSION: &str = "version";
24const FIELD_STATE: &str = "state";
25const FIELD_PAUSED: &str = "paused";
26const FIELD_INFLIGHT_SCHEDULED_AT: &str = "inflight_scheduled_at";
27const FIELD_INFLIGHT_CATCH_UP: &str = "inflight_catch_up";
28const FIELD_INFLIGHT_TRIGGER_COUNT: &str = "inflight_trigger_count";
29const FIELD_INFLIGHT_RESOURCE_ID: &str = "inflight_resource_id";
30const FIELD_INFLIGHT_SCOPE: &str = "inflight_scope";
31const FIELD_INFLIGHT_TOKEN: &str = "inflight_token";
32const FIELD_INFLIGHT_LEASE_KEY: &str = "inflight_lease_key";
33const FIELD_INFLIGHT_LEASE_EXPIRES_AT: &str = "inflight_lease_expires_at";
34
35static COORDINATED_TOKEN_COUNTER: AtomicU64 = AtomicU64::new(1);
36
37#[derive(Debug, Clone)]
38pub struct ValkeyCoordinatedStateStore {
39    connection: ConnectionManager,
40    state_key_prefix: String,
41    execution_key_prefix: String,
42}
43
44impl ValkeyCoordinatedStateStore {
45    pub async fn new(url: impl AsRef<str>) -> Result<Self, redis::RedisError> {
46        Self::with_prefixes(url, DEFAULT_STATE_KEY_PREFIX, DEFAULT_EXECUTION_KEY_PREFIX).await
47    }
48
49    pub async fn with_prefixes(
50        url: impl AsRef<str>,
51        state_key_prefix: impl Into<String>,
52        execution_key_prefix: impl Into<String>,
53    ) -> Result<Self, redis::RedisError> {
54        let client = Client::open(url.as_ref())?;
55        let connection = client.get_connection_manager().await?;
56        Ok(Self {
57            connection,
58            state_key_prefix: state_key_prefix.into(),
59            execution_key_prefix: execution_key_prefix.into(),
60        })
61    }
62
63    fn state_key(&self, job_id: &str) -> String {
64        format!("{}{}", self.state_key_prefix, job_id)
65    }
66
67    fn legacy_state_key(&self, job_id: &str) -> Option<String> {
68        if self.state_key_prefix == DEFAULT_STATE_KEY_PREFIX {
69            Some(format!("{LEGACY_DEFAULT_STATE_KEY_PREFIX}{job_id}"))
70        } else {
71            None
72        }
73    }
74
75    fn resource_lock_key(&self, resource_id: &str) -> String {
76        resource_lock_key(&self.execution_key_prefix, resource_id)
77    }
78
79    fn occurrence_index_key(&self, resource_id: &str) -> String {
80        occurrence_index_key(&self.execution_key_prefix, resource_id)
81    }
82
83    fn occurrence_lease_key(&self, resource_id: &str, scheduled_at: DateTime<Utc>) -> String {
84        occurrence_lease_key(&self.execution_key_prefix, resource_id, scheduled_at)
85    }
86
87    async fn key_type(&self, key: &str) -> Result<String, ValkeyStoreError> {
88        let mut connection = self.connection.clone();
89        cmd("TYPE")
90            .arg(key)
91            .query_async(&mut connection)
92            .await
93            .map_err(ValkeyStoreError::from)
94    }
95
96    async fn load_hash(
97        &self,
98        key: &str,
99    ) -> Result<Option<CoordinatedRuntimeState>, ValkeyStoreError> {
100        let mut connection = self.connection.clone();
101        let fields: HashMap<String, String> = connection
102            .hgetall(key)
103            .await
104            .map_err(ValkeyStoreError::from)?;
105        if fields.is_empty() {
106            return Ok(None);
107        }
108
109        Ok(Some(parse_runtime_state(&fields)?))
110    }
111
112    async fn migrate_string_state(
113        &self,
114        key: &str,
115        payload: String,
116    ) -> Result<CoordinatedRuntimeState, ValkeyStoreError> {
117        let state: JobState = serde_json::from_str(&payload).map_err(ValkeyStoreError::from)?;
118        let runtime = CoordinatedRuntimeState {
119            state,
120            revision: 0,
121            paused: false,
122        };
123        self.write_runtime(key, &runtime).await?;
124        Ok(runtime)
125    }
126
127    async fn write_runtime(
128        &self,
129        key: &str,
130        runtime: &CoordinatedRuntimeState,
131    ) -> Result<(), ValkeyStoreError> {
132        let mut connection = self.connection.clone();
133        let payload = serde_json::to_string(&runtime.state).map_err(ValkeyStoreError::from)?;
134        let _: () = cmd("DEL")
135            .arg(key)
136            .query_async(&mut connection)
137            .await
138            .map_err(ValkeyStoreError::from)?;
139        let _: () = cmd("HSET")
140            .arg(key)
141            .arg(FIELD_VERSION)
142            .arg(runtime.revision)
143            .arg(FIELD_STATE)
144            .arg(payload)
145            .arg(FIELD_PAUSED)
146            .arg(if runtime.paused { "1" } else { "0" })
147            .query_async(&mut connection)
148            .await
149            .map_err(ValkeyStoreError::from)?;
150        Ok(())
151    }
152
153    async fn load_payload_state(&self, key: &str) -> Result<Option<String>, ValkeyStoreError> {
154        let mut connection = self.connection.clone();
155        connection.get(key).await.map_err(ValkeyStoreError::from)
156    }
157}
158
159impl CoordinatedStateStore for ValkeyCoordinatedStateStore {
160    type Error = ValkeyStoreError;
161
162    async fn load_or_initialize(
163        &self,
164        job_id: &str,
165        initial_state: JobState,
166    ) -> Result<CoordinatedRuntimeState, Self::Error> {
167        let key = self.state_key(job_id);
168        match self.key_type(&key).await?.as_str() {
169            "hash" => {
170                if let Some(runtime) = self.load_hash(&key).await? {
171                    return Ok(runtime);
172                }
173            }
174            "string" => {
175                if let Some(payload) = self.load_payload_state(&key).await? {
176                    return self.migrate_string_state(&key, payload).await;
177                }
178            }
179            "none" => {}
180            _ => {}
181        }
182
183        if let Some(legacy_key) = self.legacy_state_key(job_id) {
184            if self.key_type(&legacy_key).await?.as_str() == "string" {
185                if let Some(payload) = self.load_payload_state(&legacy_key).await? {
186                    let runtime = self.migrate_string_state(&key, payload).await?;
187                    let mut connection = self.connection.clone();
188                    let _: () = cmd("DEL")
189                        .arg(legacy_key)
190                        .query_async(&mut connection)
191                        .await
192                        .map_err(ValkeyStoreError::from)?;
193                    return Ok(runtime);
194                }
195            }
196        }
197
198        let runtime = CoordinatedRuntimeState {
199            state: initial_state,
200            revision: 0,
201            paused: false,
202        };
203        self.write_runtime(&key, &runtime).await?;
204        Ok(runtime)
205    }
206
207    async fn save_state(
208        &self,
209        job_id: &str,
210        revision: u64,
211        state: &JobState,
212    ) -> Result<bool, Self::Error> {
213        let key = self.state_key(job_id);
214        let payload = serde_json::to_string(state).map_err(ValkeyStoreError::from)?;
215        let mut connection = self.connection.clone();
216        let updated: i32 = valkey_scripts::script(valkey_scripts::coordinated::SAVE_STATE)
217            .key(key)
218            .arg(FIELD_VERSION)
219            .arg(revision)
220            .arg(FIELD_INFLIGHT_TOKEN)
221            .arg(FIELD_STATE)
222            .arg(payload)
223            .invoke_async(&mut connection)
224            .await
225            .map_err(ValkeyStoreError::from)?;
226        Ok(updated == 1)
227    }
228
229    async fn reclaim_inflight(
230        &self,
231        job_id: &str,
232        resource_id: &str,
233        lease_config: CoordinatedLeaseConfig,
234    ) -> Result<Option<CoordinatedClaim>, Self::Error> {
235        let key = self.state_key(job_id);
236        let lease_key = self.occurrence_lease_key(resource_id, Utc::now());
237        let token = next_token(&COORDINATED_TOKEN_COUNTER, "coord");
238        let ttl_millis = u64::try_from(lease_config.ttl.as_millis()).unwrap_or(u64::MAX);
239        let now_millis = now_millis();
240        let expires_at_millis = now_millis.saturating_add(ttl_millis);
241        let mut connection = self.connection.clone();
242        let result: Option<Vec<String>> =
243            valkey_scripts::script(valkey_scripts::coordinated::RECLAIM_INFLIGHT)
244                .key(key)
245                .key(self.resource_lock_key(resource_id))
246                .key(lease_key.clone())
247                .key(self.occurrence_index_key(resource_id))
248                // ARGV layout:
249                //  1..9  = hash field names read from KEYS[1]
250                // 10..14 = reclaim timing + new lease payload
251                // 15..16 = hash field names updated on successful reclaim
252                .arg(FIELD_INFLIGHT_SCHEDULED_AT)
253                .arg(FIELD_INFLIGHT_CATCH_UP)
254                .arg(FIELD_INFLIGHT_TRIGGER_COUNT)
255                .arg(FIELD_INFLIGHT_RESOURCE_ID)
256                .arg(FIELD_INFLIGHT_SCOPE)
257                .arg(FIELD_INFLIGHT_LEASE_EXPIRES_AT)
258                .arg(FIELD_STATE)
259                .arg(FIELD_VERSION)
260                .arg(FIELD_PAUSED)
261                .arg(now_millis)
262                .arg(format!(
263                    "{}{}:occurrence:",
264                    self.execution_key_prefix, resource_id
265                ))
266                .arg(&token)
267                .arg(ttl_millis)
268                .arg(expires_at_millis)
269                .arg(FIELD_INFLIGHT_TOKEN)
270                .arg(FIELD_INFLIGHT_LEASE_KEY)
271                .invoke_async(&mut connection)
272                .await
273                .map_err(ValkeyStoreError::from)?;
274
275        let Some(values) = result else {
276            return Ok(None);
277        };
278        if values.len() != 8 {
279            return Ok(None);
280        }
281        let revision = values[0].parse::<u64>().unwrap_or(0);
282        let state: JobState = serde_json::from_str(&values[1]).map_err(ValkeyStoreError::from)?;
283        let scheduled_at = DateTime::parse_from_rfc3339(&values[2])
284            .map_err(|error| {
285                ValkeyStoreError::Codec(serde_json::Error::io(std::io::Error::other(
286                    error.to_string(),
287                )))
288            })?
289            .with_timezone(&Utc);
290        let catch_up = values[3].parse::<bool>().unwrap_or(false);
291        let trigger_count = values[4].parse::<u32>().unwrap_or(0);
292        let scope = parse_scope(&values[5]);
293        Ok(Some(CoordinatedClaim {
294            state: CoordinatedRuntimeState {
295                state,
296                revision,
297                paused: false,
298            },
299            trigger: CoordinatedPendingTrigger {
300                scheduled_at,
301                catch_up,
302                trigger_count,
303            },
304            lease: ExecutionLease::new(
305                job_id.to_string(),
306                resource_id.to_string(),
307                scope,
308                Some(scheduled_at),
309                values[7].clone(),
310                values[6].clone(),
311            ),
312            replayed: true,
313        }))
314    }
315
316    async fn claim_trigger(
317        &self,
318        job_id: &str,
319        resource_id: &str,
320        revision: u64,
321        trigger: CoordinatedPendingTrigger,
322        next_state: &JobState,
323        lease_config: CoordinatedLeaseConfig,
324    ) -> Result<Option<CoordinatedClaim>, Self::Error> {
325        let key = self.state_key(job_id);
326        let lease_key = self.occurrence_lease_key(resource_id, trigger.scheduled_at);
327        let token = next_token(&COORDINATED_TOKEN_COUNTER, "coord");
328        let ttl_millis = u64::try_from(lease_config.ttl.as_millis()).unwrap_or(u64::MAX);
329        let now_millis = now_millis();
330        let expires_at_millis = now_millis.saturating_add(ttl_millis);
331        let next_state_payload =
332            serde_json::to_string(next_state).map_err(ValkeyStoreError::from)?;
333        let mut connection = self.connection.clone();
334        let new_revision: i64 = valkey_scripts::script(valkey_scripts::coordinated::CLAIM_TRIGGER)
335            .key(key)
336            .key(self.resource_lock_key(resource_id))
337            .key(&lease_key)
338            .key(self.occurrence_index_key(resource_id))
339            .arg(FIELD_VERSION)
340            .arg(FIELD_INFLIGHT_TOKEN)
341            .arg(FIELD_INFLIGHT_LEASE_EXPIRES_AT)
342            .arg(FIELD_PAUSED)
343            .arg(now_millis)
344            .arg(revision)
345            .arg(&token)
346            .arg(ttl_millis)
347            .arg(expires_at_millis)
348            .arg(FIELD_STATE)
349            .arg(next_state_payload)
350            .arg(FIELD_INFLIGHT_SCHEDULED_AT)
351            .arg(
352                trigger
353                    .scheduled_at
354                    .to_rfc3339_opts(SecondsFormat::Nanos, true),
355            )
356            .arg(FIELD_INFLIGHT_CATCH_UP)
357            .arg(trigger.catch_up)
358            .arg(FIELD_INFLIGHT_TRIGGER_COUNT)
359            .arg(trigger.trigger_count)
360            .arg(FIELD_INFLIGHT_RESOURCE_ID)
361            .arg(resource_id)
362            .arg(FIELD_INFLIGHT_SCOPE)
363            .arg("occurrence")
364            .arg(FIELD_INFLIGHT_TOKEN)
365            .arg(FIELD_INFLIGHT_LEASE_KEY)
366            .invoke_async(&mut connection)
367            .await
368            .map_err(ValkeyStoreError::from)?;
369
370        if new_revision <= 0 {
371            return Ok(None);
372        }
373
374        Ok(Some(CoordinatedClaim {
375            state: CoordinatedRuntimeState {
376                state: next_state.clone(),
377                revision: new_revision as u64,
378                paused: false,
379            },
380            trigger: trigger.clone(),
381            lease: ExecutionLease::new(
382                job_id.to_string(),
383                resource_id.to_string(),
384                ExecutionGuardScope::Occurrence,
385                Some(trigger.scheduled_at),
386                token,
387                lease_key,
388            ),
389            replayed: false,
390        }))
391    }
392
393    async fn renew(
394        &self,
395        lease: &ExecutionLease,
396        lease_config: CoordinatedLeaseConfig,
397    ) -> Result<ExecutionGuardRenewal, Self::Error> {
398        let ttl_millis = u64::try_from(lease_config.ttl.as_millis()).unwrap_or(u64::MAX);
399        let expires_at_millis = now_millis().saturating_add(ttl_millis);
400        let mut connection = self.connection.clone();
401        let renewed: i32 = valkey_scripts::script(valkey_scripts::coordinated::RENEW_LEASE)
402            .key(&lease.lease_key)
403            .key(self.occurrence_index_key(&lease.resource_id))
404            .key(self.state_key(&lease.job_id))
405            .arg(&lease.token)
406            .arg(ttl_millis)
407            .arg(expires_at_millis)
408            .arg(FIELD_INFLIGHT_LEASE_EXPIRES_AT)
409            .invoke_async(&mut connection)
410            .await
411            .map_err(ValkeyStoreError::from)?;
412        Ok(if renewed == 1 {
413            ExecutionGuardRenewal::Renewed
414        } else {
415            ExecutionGuardRenewal::Lost
416        })
417    }
418
419    async fn complete(
420        &self,
421        job_id: &str,
422        revision: u64,
423        lease: &ExecutionLease,
424        state: &JobState,
425    ) -> Result<bool, Self::Error> {
426        let key = self.state_key(job_id);
427        let payload = serde_json::to_string(state).map_err(ValkeyStoreError::from)?;
428        let mut connection = self.connection.clone();
429        let completed: i32 = valkey_scripts::script(valkey_scripts::coordinated::COMPLETE)
430            .key(key)
431            .key(&lease.lease_key)
432            .key(self.occurrence_index_key(&lease.resource_id))
433            .arg(FIELD_VERSION)
434            .arg(FIELD_INFLIGHT_TOKEN)
435            .arg(revision)
436            .arg(&lease.token)
437            .arg(FIELD_STATE)
438            .arg(payload)
439            .arg(FIELD_INFLIGHT_SCHEDULED_AT)
440            .arg(FIELD_INFLIGHT_CATCH_UP)
441            .arg(FIELD_INFLIGHT_TRIGGER_COUNT)
442            .arg(FIELD_INFLIGHT_RESOURCE_ID)
443            .arg(FIELD_INFLIGHT_SCOPE)
444            .arg(FIELD_INFLIGHT_LEASE_KEY)
445            .invoke_async(&mut connection)
446            .await
447            .map_err(ValkeyStoreError::from)?;
448        Ok(completed == 1)
449    }
450
451    async fn delete(&self, job_id: &str) -> Result<(), Self::Error> {
452        let key = self.state_key(job_id);
453        let mut connection = self.connection.clone();
454        let _: () = cmd("DEL")
455            .arg(key)
456            .query_async(&mut connection)
457            .await
458            .map_err(ValkeyStoreError::from)?;
459        Ok(())
460    }
461
462    async fn pause(&self, job_id: &str) -> Result<bool, Self::Error> {
463        let key = self.state_key(job_id);
464        let mut connection = self.connection.clone();
465        let changed: i32 = valkey_scripts::script(valkey_scripts::coordinated::PAUSE)
466            .key(key)
467            .arg(FIELD_PAUSED)
468            .invoke_async(&mut connection)
469            .await
470            .map_err(ValkeyStoreError::from)?;
471        Ok(changed == 1)
472    }
473
474    async fn resume(&self, job_id: &str) -> Result<bool, Self::Error> {
475        let key = self.state_key(job_id);
476        let mut connection = self.connection.clone();
477        let changed: i32 = valkey_scripts::script(valkey_scripts::coordinated::RESUME)
478            .key(key)
479            .arg(FIELD_PAUSED)
480            .invoke_async(&mut connection)
481            .await
482            .map_err(ValkeyStoreError::from)?;
483        Ok(changed == 1)
484    }
485
486    fn classify_store_error(error: &Self::Error) -> StoreErrorKind
487    where
488        Self: Sized,
489    {
490        if matches!(error, ValkeyStoreError::Codec(_)) {
491            StoreErrorKind::Data
492        } else if error.is_connection_issue() {
493            StoreErrorKind::Connection
494        } else {
495            StoreErrorKind::Unknown
496        }
497    }
498
499    fn classify_guard_error(error: &Self::Error) -> ExecutionGuardErrorKind
500    where
501        Self: Sized,
502    {
503        if matches!(error, ValkeyStoreError::Codec(_)) {
504            ExecutionGuardErrorKind::Data
505        } else if error.is_connection_issue() {
506            ExecutionGuardErrorKind::Connection
507        } else {
508            ExecutionGuardErrorKind::Unknown
509        }
510    }
511}
512
513fn parse_runtime_state(
514    fields: &HashMap<String, String>,
515) -> Result<CoordinatedRuntimeState, ValkeyStoreError> {
516    let revision = fields
517        .get(FIELD_VERSION)
518        .and_then(|value| value.parse::<u64>().ok())
519        .unwrap_or(0);
520    let paused = fields
521        .get(FIELD_PAUSED)
522        .map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
523        .unwrap_or(false);
524    let state = serde_json::from_str(fields.get(FIELD_STATE).map(String::as_str).unwrap_or("{}"))
525        .map_err(ValkeyStoreError::from)?;
526    Ok(CoordinatedRuntimeState {
527        state,
528        revision,
529        paused,
530    })
531}
532
533fn parse_scope(raw: &str) -> ExecutionGuardScope {
534    match raw {
535        "resource" => ExecutionGuardScope::Resource,
536        _ => ExecutionGuardScope::Occurrence,
537    }
538}