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 .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}