1use std::sync::Arc;
2use std::sync::atomic::Ordering;
3use std::time::Duration;
4
5use anyhow::{Context, Result};
6use chrono::{DateTime, Utc};
7use chrono_tz::Tz;
8use croner::Cron;
9use futures::future::BoxFuture;
10#[cfg(feature = "wasm-runtime")]
11use futures::future::{AbortHandle, Abortable};
12use rivet_envoy_client::handle::EnvoyHandle;
13use rivet_error::RivetError;
14use serde::{Deserialize, Serialize};
15use tokio::runtime::Handle;
16use tokio::sync::oneshot;
17use tracing::Instrument;
18use uuid::Uuid;
19
20use crate::actor::context::ActorContext;
21use crate::actor::internal_storage::queries::*;
22use crate::error::{ScheduleRuntimeError, client_error_message, client_error_metadata};
23use crate::sqlite::{BindParam, ColumnValue, SqliteBatchStatement};
24use crate::time::{SystemTime, UNIX_EPOCH, sleep};
25
26const CRON_ID_PREFIX: &str = "cron:";
27const HISTORY_RUNNING: i64 = 0;
28const HISTORY_OK: i64 = 1;
29const HISTORY_ERROR: i64 = 2;
30const HISTORY_SKIPPED: i64 = 3;
31pub const DEFAULT_MAX_HISTORY: i64 = 100;
32pub const MAX_HISTORY: i64 = 1_000;
33pub const MAX_ACTOR_HISTORY: i64 = 10_000;
34pub const MIN_INTERVAL_MS: i64 = 5_000;
35const DEFAULT_HISTORY_LIMIT: i64 = 20;
36const CLAIM_ONE_SHOT_BATCH_SIZE: usize = 128;
37pub(crate) const GLOBAL_HISTORY_PRUNE_INTERVAL: usize = 100;
38pub(crate) const GLOBAL_HISTORY_RETAINED_ROWS: i64 =
39 MAX_ACTOR_HISTORY - GLOBAL_HISTORY_PRUNE_INTERVAL as i64;
40
41fn min_deadline(left: Option<i64>, right: Option<i64>) -> Option<i64> {
42 match (left, right) {
43 (Some(left), Some(right)) => Some(left.min(right)),
44 (Some(value), None) | (None, Some(value)) => Some(value),
45 (None, None) => None,
46 }
47}
48
49pub(super) type InternalKeepAwakeCallback =
50 Arc<dyn Fn(BoxFuture<'static, Result<()>>) -> BoxFuture<'static, Result<()>> + Send + Sync>;
51pub(super) type LocalAlarmCallback = Arc<dyn Fn() -> BoxFuture<'static, ()> + Send + Sync>;
52
53#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
54#[serde(rename_all = "lowercase")]
55pub enum ScheduleKind {
56 At,
57 Cron,
58 Every,
59}
60
61impl ScheduleKind {
62 pub fn as_str(self) -> &'static str {
63 match self {
64 Self::At => "at",
65 Self::Cron => "cron",
66 Self::Every => "every",
67 }
68 }
69
70 fn as_i64(self) -> i64 {
71 match self {
72 Self::At => 0,
73 Self::Cron => 1,
74 Self::Every => 2,
75 }
76 }
77
78 fn parse(value: i64, schedule_id: &str) -> Result<Self> {
79 match value {
80 0 => Ok(Self::At),
81 1 => Ok(Self::Cron),
82 2 => Ok(Self::Every),
83 other => Err(ScheduleRuntimeError::InvalidScheduleRow {
84 schedule_id: schedule_id.to_owned(),
85 reason: format!("unknown kind {other}"),
86 }
87 .build()),
88 }
89 }
90}
91
92#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
93#[serde(rename_all = "camelCase")]
94pub struct ScheduledEventInfo {
95 pub id: String,
96 pub action: String,
97 pub args: Vec<u8>,
98 pub run_at: i64,
99}
100
101#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
102#[serde(rename_all = "camelCase")]
103pub struct CronJobInfo {
104 pub name: String,
105 pub kind: ScheduleKind,
106 pub action: String,
107 pub args: Vec<u8>,
108 pub next_run_at: i64,
109 pub last_run_at: Option<i64>,
110 pub expression: Option<String>,
111 pub timezone: Option<String>,
112 pub interval_ms: Option<i64>,
113 pub max_history: i64,
114}
115
116#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
117#[serde(rename_all = "camelCase")]
118pub struct ScheduledFireInfo {
119 pub kind: ScheduleKind,
120 pub id: String,
121 pub name: Option<String>,
122 pub scheduled_at: i64,
123 pub fired_at: i64,
124}
125
126#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
127pub struct ScheduleErrorInfo {
128 pub group: String,
129 pub code: String,
130 pub message: String,
131 pub metadata: Option<serde_json::Value>,
132}
133
134#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
135#[serde(rename_all = "camelCase")]
136pub struct CronFire {
137 pub action: String,
138 pub scheduled_at: i64,
139 pub fired_at: i64,
140 pub finished_at: Option<i64>,
141 pub result: String,
142 pub error: Option<ScheduleErrorInfo>,
143}
144
145pub(crate) struct DueScheduleDispatch {
146 pub event_id: String,
147 pub action: String,
148 pub args: Vec<u8>,
149 pub fire: ScheduledFireInfo,
150 pub history_id: Option<i64>,
151}
152
153#[derive(Clone, Debug)]
154struct StoredSchedule {
155 event_id: String,
156 trigger_at: i64,
157 action: String,
158 args: Vec<u8>,
159 kind: ScheduleKind,
160 cron_expression: Option<String>,
161 timezone: Option<String>,
162 interval_ms: Option<i64>,
163 last_started_at: Option<i64>,
164 max_history: i64,
165}
166
167impl ActorContext {
168 #[cfg(any(test, feature = "test-support"))]
169 pub fn set_schedule_time_for_tests(&self, timestamp_ms: i64) {
170 self.0
171 .schedule_now_override
172 .store(timestamp_ms, Ordering::SeqCst);
173 }
174
175 pub(crate) fn schedule_now_timestamp_ms(&self) -> i64 {
176 #[cfg(any(test, feature = "test-support"))]
177 {
178 let timestamp_ms = self.0.schedule_now_override.load(Ordering::SeqCst);
179 if timestamp_ms != i64::MIN {
180 return timestamp_ms;
181 }
182 }
183 system_now_timestamp_ms()
184 }
185
186 pub async fn after(
187 &self,
188 duration: Duration,
189 action_name: &str,
190 args: &[u8],
191 ) -> Result<String> {
192 let duration_ms = i64::try_from(duration.as_millis()).unwrap_or(i64::MAX);
193 let timestamp_ms = self.schedule_now_timestamp_ms().saturating_add(duration_ms);
194 self.at(timestamp_ms, action_name, args).await
195 }
196
197 pub async fn at(&self, timestamp_ms: i64, action_name: &str, args: &[u8]) -> Result<String> {
198 let _mutation = self.0.schedule_mutation_lock.lock().await;
199 self.ensure_schedule_capacity(false).await?;
200 let event_id = Uuid::new_v4().to_string();
201 self.sql()
202 .execute(
203 INSERT_SCHEDULE_EVENT_SQL,
204 Some(vec![
205 BindParam::Text(event_id.clone()),
206 BindParam::Integer(timestamp_ms),
207 BindParam::Text(action_name.to_owned()),
208 args_param(args),
209 BindParam::Integer(ScheduleKind::At.as_i64()),
210 BindParam::Null,
211 BindParam::Null,
212 BindParam::Null,
213 BindParam::Null,
214 BindParam::Integer(0),
215 ]),
216 )
217 .await
218 .context("insert one-shot schedule")?;
219 self.mark_schedule_dirty();
220 self.record_schedules_updated();
221 self.sync_alarm().await?;
222 Ok(event_id)
223 }
224
225 pub async fn cancel_schedule(&self, event_id: &str) -> Result<bool> {
226 let _mutation = self.0.schedule_mutation_lock.lock().await;
227 let result = self
228 .sql()
229 .execute(
230 CANCEL_SCHEDULE_SQL,
231 Some(vec![
232 BindParam::Text(event_id.to_owned()),
233 BindParam::Integer(ScheduleKind::At.as_i64()),
234 ]),
235 )
236 .await
237 .context("cancel one-shot schedule")?;
238 let removed = result.changes > 0;
239 if removed {
240 self.mark_schedule_dirty();
241 self.record_schedules_updated();
242 self.sync_alarm().await?;
243 }
244 Ok(removed)
245 }
246
247 pub async fn get_scheduled_event(&self, event_id: &str) -> Result<Option<ScheduledEventInfo>> {
248 let result = self
249 .sql()
250 .query(
251 GET_SCHEDULED_EVENT_SQL,
252 Some(vec![
253 BindParam::Text(event_id.to_owned()),
254 BindParam::Integer(ScheduleKind::At.as_i64()),
255 ]),
256 )
257 .await
258 .context("get one-shot schedule")?;
259 result
260 .rows
261 .first()
262 .map(|row| read_stored_schedule(row))
263 .transpose()
264 .map(|row| {
265 row.map(|event| ScheduledEventInfo {
266 id: event.event_id,
267 action: event.action,
268 args: event.args,
269 run_at: event.trigger_at,
270 })
271 })
272 }
273
274 pub async fn list_scheduled_events(&self) -> Result<Vec<ScheduledEventInfo>> {
275 let result = self
276 .sql()
277 .query(
278 LIST_SCHEDULED_EVENTS_SQL,
279 Some(vec![BindParam::Integer(ScheduleKind::At.as_i64())]),
280 )
281 .await
282 .context("list one-shot schedules")?;
283 result
284 .rows
285 .iter()
286 .map(|row| read_stored_schedule(row))
287 .map(|event| {
288 event.map(|event| ScheduledEventInfo {
289 id: event.event_id,
290 action: event.action,
291 args: event.args,
292 run_at: event.trigger_at,
293 })
294 })
295 .collect()
296 }
297
298 pub async fn cron_set(
299 &self,
300 name: &str,
301 expression: &str,
302 timezone: Option<&str>,
303 action_name: &str,
304 args: &[u8],
305 max_history: Option<i64>,
306 ) -> Result<()> {
307 validate_name(name)?;
308 let timezone = timezone.unwrap_or("UTC");
309 let timezone_parsed = parse_timezone(timezone)?;
310 let cron = parse_cron(expression)?;
311 let max_history = validate_max_history(max_history)?;
312 let now_ms = self.schedule_now_timestamp_ms();
313 let event_id = cron_event_id(name);
314 let _mutation = self.0.schedule_mutation_lock.lock().await;
315 let existing = self.load_schedule(&event_id).await?;
316 self.ensure_schedule_capacity(existing.is_some()).await?;
317 let cadence_unchanged = existing.as_ref().is_some_and(|existing| {
318 existing.kind == ScheduleKind::Cron
319 && existing.cron_expression.as_deref() == Some(expression)
320 && existing.timezone.as_deref() == Some(timezone)
321 });
322 let trigger_at = if cadence_unchanged {
323 existing.as_ref().expect("checked above").trigger_at
324 } else {
325 next_cron_timestamp_ms(&cron, timezone_parsed, now_ms)?
326 };
327 self.upsert_recurring(
328 &event_id,
329 trigger_at,
330 action_name,
331 args,
332 ScheduleKind::Cron,
333 Some(expression),
334 Some(timezone),
335 None,
336 max_history,
337 )
338 .await?;
339 self.prune_schedule_history(&event_id, max_history).await?;
340 self.mark_schedule_dirty();
341 self.record_schedules_updated();
342 self.sync_alarm().await
343 }
344
345 pub async fn cron_every(
346 &self,
347 name: &str,
348 interval_ms: i64,
349 action_name: &str,
350 args: &[u8],
351 max_history: Option<i64>,
352 ) -> Result<()> {
353 validate_name(name)?;
354 if interval_ms < MIN_INTERVAL_MS {
355 return Err(ScheduleRuntimeError::InvalidInterval {
356 interval_ms,
357 minimum_ms: MIN_INTERVAL_MS,
358 }
359 .build());
360 }
361 let max_history = validate_max_history(max_history)?;
362 let event_id = cron_event_id(name);
363 let now_ms = self.schedule_now_timestamp_ms();
364 let _mutation = self.0.schedule_mutation_lock.lock().await;
365 let existing = self.load_schedule(&event_id).await?;
366 self.ensure_schedule_capacity(existing.is_some()).await?;
367 let cadence_unchanged = existing.as_ref().is_some_and(|existing| {
368 existing.kind == ScheduleKind::Every && existing.interval_ms == Some(interval_ms)
369 });
370 let trigger_at = if cadence_unchanged {
371 existing.as_ref().expect("checked above").trigger_at
372 } else {
373 now_ms.saturating_add(interval_ms)
374 };
375 self.upsert_recurring(
376 &event_id,
377 trigger_at,
378 action_name,
379 args,
380 ScheduleKind::Every,
381 None,
382 None,
383 Some(interval_ms),
384 max_history,
385 )
386 .await?;
387 self.prune_schedule_history(&event_id, max_history).await?;
388 self.mark_schedule_dirty();
389 self.record_schedules_updated();
390 self.sync_alarm().await
391 }
392
393 #[allow(clippy::too_many_arguments)]
394 async fn upsert_recurring(
395 &self,
396 event_id: &str,
397 trigger_at: i64,
398 action_name: &str,
399 args: &[u8],
400 kind: ScheduleKind,
401 cron_expression: Option<&str>,
402 timezone: Option<&str>,
403 interval_ms: Option<i64>,
404 max_history: i64,
405 ) -> Result<()> {
406 self.sql()
407 .execute(
408 UPSERT_RECURRING_SCHEDULE_SQL,
409 Some(vec![
410 BindParam::Text(event_id.to_owned()),
411 BindParam::Integer(trigger_at),
412 BindParam::Text(action_name.to_owned()),
413 args_param(args),
414 BindParam::Integer(kind.as_i64()),
415 optional_text_param(cron_expression),
416 optional_text_param(timezone),
417 optional_i64_param(interval_ms),
418 BindParam::Null,
419 BindParam::Integer(max_history),
420 ]),
421 )
422 .await
423 .context("upsert recurring schedule")?;
424 Ok(())
425 }
426
427 pub async fn cron_delete(&self, name: &str) -> Result<bool> {
428 validate_name(name)?;
429 let _mutation = self.0.schedule_mutation_lock.lock().await;
430 let event_id = cron_event_id(name);
431 let results = self
432 .sql()
433 .execute_batch(vec![
434 SqliteBatchStatement {
435 sql: DELETE_CRON_HISTORY_SQL.to_owned(),
436 params: Some(vec![
437 BindParam::Text(event_id.clone()),
438 BindParam::Text(event_id.clone()),
439 BindParam::Integer(ScheduleKind::At.as_i64()),
440 ]),
441 },
442 SqliteBatchStatement {
443 sql: DELETE_CRON_SQL.to_owned(),
444 params: Some(vec![
445 BindParam::Text(event_id),
446 BindParam::Integer(ScheduleKind::At.as_i64()),
447 ]),
448 },
449 ])
450 .await
451 .context("delete recurring schedule and history")?;
452 let removed = results
453 .get(1)
454 .is_some_and(|event_result| event_result.changes > 0);
455 if removed {
456 self.mark_schedule_dirty();
457 self.record_schedules_updated();
458 self.sync_alarm().await?;
459 }
460 Ok(removed)
461 }
462
463 pub(crate) async fn cron_delete_if_action(&self, name: &str, action: &str) -> Result<bool> {
464 validate_name(name)?;
465 let _mutation = self.0.schedule_mutation_lock.lock().await;
466 let result = self
467 .sql()
468 .execute(
469 DELETE_CRON_IF_ACTION_SQL,
470 Some(vec![
471 BindParam::Text(cron_event_id(name)),
472 BindParam::Integer(ScheduleKind::At.as_i64()),
473 BindParam::Text(action.to_owned()),
474 ]),
475 )
476 .await
477 .context("delete recurring schedule with matching action")?;
478 let removed = result.changes > 0;
479 if removed {
480 self.mark_schedule_dirty();
481 self.record_schedules_updated();
482 self.sync_alarm().await?;
483 }
484 Ok(removed)
485 }
486
487 pub async fn cron_get(&self, name: &str) -> Result<Option<CronJobInfo>> {
488 validate_name(name)?;
489 self.load_schedule(&cron_event_id(name))
490 .await?
491 .map(stored_to_cron_info)
492 .transpose()
493 }
494
495 pub async fn cron_list(&self) -> Result<Vec<CronJobInfo>> {
496 let result = self
497 .sql()
498 .query(
499 LIST_CRONS_SQL,
500 Some(vec![BindParam::Integer(ScheduleKind::At.as_i64())]),
501 )
502 .await
503 .context("list recurring schedules")?;
504 result
505 .rows
506 .iter()
507 .map(|row| read_stored_schedule(row))
508 .map(|row| row.and_then(stored_to_cron_info))
509 .collect()
510 }
511
512 pub async fn cron_history(&self, name: &str, limit: Option<i64>) -> Result<Vec<CronFire>> {
513 validate_name(name)?;
514 let limit = limit.unwrap_or(DEFAULT_HISTORY_LIMIT);
515 if !(1..=MAX_HISTORY).contains(&limit) {
516 return Err(ScheduleRuntimeError::InvalidMaxHistory {
517 max_history: limit,
518 maximum: MAX_HISTORY,
519 }
520 .build());
521 }
522 let result = self
523 .sql()
524 .query(
525 CRON_HISTORY_SQL,
526 Some(vec![
527 BindParam::Text(cron_event_id(name)),
528 BindParam::Integer(limit),
529 ]),
530 )
531 .await
532 .context("read recurring schedule history")?;
533 result.rows.iter().map(|row| read_cron_fire(row)).collect()
534 }
535
536 async fn load_schedule(&self, event_id: &str) -> Result<Option<StoredSchedule>> {
537 let result = self
538 .sql()
539 .query(
540 LOAD_SCHEDULE_SQL,
541 Some(vec![BindParam::Text(event_id.to_owned())]),
542 )
543 .await
544 .context("load schedule")?;
545 result
546 .rows
547 .first()
548 .map(|row| read_stored_schedule(row))
549 .transpose()
550 }
551
552 async fn ensure_schedule_capacity(&self, replacing_existing: bool) -> Result<()> {
553 if replacing_existing {
554 return Ok(());
555 }
556 let result = self
557 .sql()
558 .query(COUNT_SCHEDULES_SQL, None)
559 .await
560 .context("count pending schedules")?;
561 let count = result
562 .rows
563 .first()
564 .map(|row| read_i64(row, 0, "schedule count"))
565 .transpose()?
566 .unwrap_or_default();
567 if count >= i64::from(self.0.max_schedules) {
568 return Err(ScheduleRuntimeError::MaxSchedulesExceeded {
569 maximum: self.0.max_schedules,
570 }
571 .build());
572 }
573 Ok(())
574 }
575
576 pub(crate) async fn take_due_schedule_dispatches(&self) -> Result<Vec<DueScheduleDispatch>> {
577 if !self
578 .0
579 .schedule_alarm_dispatch_enabled
580 .load(Ordering::SeqCst)
581 {
582 return Ok(Vec::new());
583 }
584 let now_ms = self.schedule_now_timestamp_ms();
585 let _mutation = self.0.schedule_mutation_lock.lock().await;
586 let result = self
587 .sql()
588 .query(
589 TAKE_DUE_SCHEDULES_SQL,
590 Some(vec![BindParam::Integer(now_ms)]),
591 )
592 .await
593 .context("load due schedules")?;
594 let due_schedules = result
595 .rows
596 .iter()
597 .map(|row| read_stored_schedule(row))
598 .collect::<Result<Vec<_>>>()?;
599 let claim_statements = due_schedules
600 .iter()
601 .filter(|event| event.kind == ScheduleKind::At)
602 .collect::<Vec<_>>()
603 .chunks(CLAIM_ONE_SHOT_BATCH_SIZE)
604 .map(|events| {
605 let mut params = Vec::with_capacity(events.len() * 2 + 1);
606 params.push(BindParam::Integer(ScheduleKind::At.as_i64()));
607 for event in events {
608 params.push(BindParam::Text(event.event_id.clone()));
609 params.push(BindParam::Integer(event.trigger_at));
610 }
611 SqliteBatchStatement {
612 sql: claim_one_shots_sql(events.len()),
613 params: Some(params),
614 }
615 })
616 .collect::<Vec<_>>();
617 if !claim_statements.is_empty() {
618 self.sql()
619 .execute_batch(claim_statements)
620 .await
621 .context("claim due one-shot schedules")?;
622 }
623 let mut dispatches = Vec::new();
624 for event in due_schedules {
625 if event.kind == ScheduleKind::At {
626 dispatches.push(DueScheduleDispatch {
627 event_id: event.event_id.clone(),
628 action: event.action,
629 args: event.args,
630 fire: ScheduledFireInfo {
631 kind: ScheduleKind::At,
632 id: event.event_id,
633 name: None,
634 scheduled_at: event.trigger_at,
635 fired_at: now_ms,
636 },
637 history_id: None,
638 });
639 continue;
640 }
641
642 let next_trigger_at = next_recurring_trigger(&event, now_ms)?;
643 let is_running = self
644 .0
645 .schedule_running
646 .insert_sync(event.event_id.clone())
647 .is_err();
648 let mut statements = vec![SqliteBatchStatement {
649 sql: if is_running {
650 ADVANCE_SKIPPED_SCHEDULE_SQL.to_owned()
651 } else {
652 ADVANCE_SCHEDULE_SQL.to_owned()
653 },
654 params: Some(if is_running {
655 vec![
656 BindParam::Integer(next_trigger_at),
657 BindParam::Text(event.event_id.clone()),
658 ]
659 } else {
660 vec![
661 BindParam::Integer(next_trigger_at),
662 BindParam::Integer(now_ms),
663 BindParam::Text(event.event_id.clone()),
664 ]
665 }),
666 }];
667 let history_result = if is_running {
668 HISTORY_SKIPPED
669 } else {
670 HISTORY_RUNNING
671 };
672 let prune_global_history = event.max_history > 0 && self.should_prune_global_history();
673 let history_index = append_history_statements(
674 &mut statements,
675 &event,
676 now_ms,
677 history_result,
678 prune_global_history,
679 )?;
680 let results = match self.sql().execute_batch(statements).await {
681 Ok(results) => results,
682 Err(error) => {
683 if !is_running {
684 self.0.schedule_running.remove_sync(&event.event_id);
685 }
686 return Err(error).context("advance due recurring schedule");
687 }
688 };
689 if event.max_history > 0 {
690 self.record_schedule_history_inserted();
691 }
692 if is_running {
693 continue;
694 }
695 let history_id = history_index
696 .and_then(|index| results.get(index))
697 .and_then(|result| result.last_insert_row_id);
698 let name = cron_name(&event.event_id)?.to_owned();
699 dispatches.push(DueScheduleDispatch {
700 event_id: event.event_id.clone(),
701 action: event.action,
702 args: event.args,
703 fire: ScheduledFireInfo {
704 kind: event.kind,
705 id: name.clone(),
706 name: Some(name),
707 scheduled_at: event.trigger_at,
708 fired_at: now_ms,
709 },
710 history_id,
711 });
712 }
713 self.mark_schedule_dirty();
714 if !result.rows.is_empty() {
715 self.record_schedules_updated();
716 }
717 self.sync_alarm_logged().await;
720 Ok(dispatches)
721 }
722
723 pub(crate) async fn finish_schedule_dispatch(
724 &self,
725 event_id: &str,
726 history_id: Option<i64>,
727 error: Option<&anyhow::Error>,
728 ) {
729 self.0.schedule_running.remove_sync(event_id);
730 let Some(history_id) = history_id else {
731 return;
732 };
733 let finished_at = self.schedule_now_timestamp_ms();
734 let (result, error) = match error {
735 Some(error) => (HISTORY_ERROR, Some(sanitize_error(error))),
736 None => (HISTORY_OK, None),
737 };
738 let error_metadata = error
739 .as_ref()
740 .and_then(|error| error.metadata.as_ref())
741 .and_then(|metadata| encode_error_metadata(metadata).ok());
742 match self
743 .sql()
744 .execute(
745 FINISH_HISTORY_SQL,
746 Some(vec![
747 BindParam::Integer(finished_at),
748 BindParam::Integer(result),
749 optional_owned_text_param(error.as_ref().map(|error| error.group.clone())),
750 optional_owned_text_param(error.as_ref().map(|error| error.code.clone())),
751 optional_owned_text_param(error.as_ref().map(|error| error.message.clone())),
752 error_metadata
753 .map(BindParam::Blob)
754 .unwrap_or(BindParam::Null),
755 BindParam::Integer(history_id),
756 BindParam::Integer(HISTORY_RUNNING),
757 ]),
758 )
759 .await
760 {
761 Ok(_) => self.record_schedules_updated(),
762 Err(error) => {
763 tracing::error!(?error, history_id, "failed to finish schedule history row");
764 }
765 }
766 }
767
768 pub(crate) async fn recover_interrupted_schedule_history(&self) -> Result<()> {
769 let error = ScheduleErrorInfo {
770 group: "schedule".to_owned(),
771 code: "interrupted".to_owned(),
772 message: "Scheduled action was interrupted before completion.".to_owned(),
773 metadata: None,
774 };
775 self.sql()
776 .execute(
777 RECOVER_HISTORY_SQL,
778 Some(vec![
779 BindParam::Integer(self.schedule_now_timestamp_ms()),
780 BindParam::Integer(HISTORY_ERROR),
781 BindParam::Text(error.group),
782 BindParam::Text(error.code),
783 BindParam::Text(error.message),
784 BindParam::Null,
785 ]),
786 )
787 .await
788 .context("recover interrupted schedule history")?;
789 self.record_schedules_updated();
790 Ok(())
791 }
792
793 async fn prune_schedule_history(&self, event_id: &str, max_history: i64) -> Result<()> {
794 self.sql()
795 .execute_batch(history_prune_statements(event_id, max_history, false))
796 .await
797 .context("prune schedule history")?;
798 Ok(())
799 }
800
801 fn should_prune_global_history(&self) -> bool {
802 self.0.schedule_history_insert_count.load(Ordering::Relaxed) % GLOBAL_HISTORY_PRUNE_INTERVAL
803 == 0
804 }
805
806 fn record_schedule_history_inserted(&self) {
807 self.0
808 .schedule_history_insert_count
809 .fetch_add(1, Ordering::Relaxed);
810 }
811
812 fn mark_schedule_dirty(&self) {
813 self.0
814 .schedule_dirty_since_push
815 .store(true, Ordering::SeqCst);
816 }
817
818 pub async fn set_run_wake_at(&self, wake_at: Option<i64>) -> Result<()> {
823 let _mutation = self.0.schedule_mutation_lock.lock().await;
824 self.persist_run_wake_at(wake_at).await?;
825 self.0.run_wake_revision.fetch_add(1, Ordering::SeqCst);
826 self.mark_schedule_dirty();
827 self.sync_alarm().await
828 }
829
830 pub(crate) async fn consume_due_run_wake(&self) -> Result<Option<(i64, u64)>> {
831 let _mutation = self.0.schedule_mutation_lock.lock().await;
832 let Some(wake_at) = self.run_wake_at() else {
833 return Ok(None);
834 };
835 if wake_at > self.schedule_now_timestamp_ms() {
836 return Ok(None);
837 }
838 self.persist_run_wake_at(None).await?;
839 let revision = self.0.run_wake_revision.fetch_add(1, Ordering::SeqCst) + 1;
840 self.mark_schedule_dirty();
841 Ok(Some((wake_at, revision)))
842 }
843
844 #[doc(hidden)]
845 pub async fn restore_run_wake_at_if_unchanged(
846 &self,
847 wake_at: i64,
848 consumed_revision: u64,
849 ) -> Result<bool> {
850 let _mutation = self.0.schedule_mutation_lock.lock().await;
851 if self.0.run_wake_revision.load(Ordering::SeqCst) != consumed_revision {
852 return Ok(false);
853 }
854 self.persist_run_wake_at(Some(wake_at)).await?;
855 self.0.run_wake_revision.fetch_add(1, Ordering::SeqCst);
856 self.mark_schedule_dirty();
857 self.sync_alarm().await?;
858 Ok(true)
859 }
860
861 async fn next_schedule_timestamp(&self, future_only: bool) -> Result<Option<i64>> {
862 let (sql, params) = if future_only {
863 (
864 NEXT_FUTURE_SCHEDULE_SQL,
865 Some(vec![BindParam::Integer(self.schedule_now_timestamp_ms())]),
866 )
867 } else {
868 (NEXT_SCHEDULE_SQL, None)
869 };
870 let result = self.sql().query(sql, params).await?;
871 match result.rows.first().and_then(|row| row.first()) {
872 None | Some(ColumnValue::Null) => Ok(None),
873 Some(ColumnValue::Integer(timestamp)) => Ok(Some(*timestamp)),
874 Some(_) => Err(ScheduleRuntimeError::InvalidScheduleRow {
875 schedule_id: "<minimum>".to_owned(),
876 reason: "MIN(trigger_at) was not an integer".to_owned(),
877 }
878 .build()),
879 }
880 }
881
882 async fn sync_alarm(&self) -> Result<()> {
883 #[cfg(test)]
884 if self
885 .0
886 .schedule_sync_alarm_failures
887 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| {
888 remaining.checked_sub(1)
889 })
890 .is_ok()
891 {
892 anyhow::bail!("injected schedule alarm sync failure");
893 }
894 let next_alarm = min_deadline(
895 self.next_schedule_timestamp(false).await?,
896 self.run_wake_at(),
897 );
898 self.sync_alarm_timestamp(next_alarm)
899 }
900
901 async fn sync_future_alarm(&self) -> Result<()> {
902 let next_alarm = min_deadline(
903 self.next_schedule_timestamp(true).await?,
904 self.run_wake_at(),
905 );
906 self.sync_alarm_timestamp(next_alarm)
907 }
908
909 fn sync_alarm_timestamp(&self, next_alarm: Option<i64>) -> Result<()> {
910 let should_push = self
911 .0
912 .schedule_dirty_since_push
913 .swap(false, Ordering::SeqCst);
914 self.arm_local_alarm(next_alarm);
915 if !should_push {
916 return Ok(());
917 }
918 if next_alarm.is_some() && self.last_pushed_alarm() == next_alarm {
919 return Ok(());
920 }
921 let Some(envoy_handle) = self.0.schedule_envoy_handle.lock().clone() else {
922 self.mark_schedule_dirty();
923 tracing::warn!(
924 actor_id = self.actor_id(),
925 "schedule alarm sync skipped because envoy handle is not configured"
926 );
927 return Ok(());
928 };
929 let generation = *self.0.schedule_generation.lock();
930 self.set_alarm_tracked(envoy_handle, next_alarm, generation);
931 Ok(())
932 }
933
934 pub(crate) async fn sync_alarm_logged(&self) {
935 if let Err(error) = self.sync_alarm().await {
936 tracing::error!(actor_id = %self.actor_id(), ?error, "failed to sync scheduled actor alarm");
937 }
938 }
939
940 pub(crate) async fn sync_future_alarm_logged(&self) {
941 if let Err(error) = self.sync_future_alarm().await {
942 tracing::error!(actor_id = %self.actor_id(), ?error, "failed to sync future scheduled actor alarm");
943 }
944 }
945
946 pub(crate) fn set_schedule_alarm(&self, timestamp_ms: Option<i64>) -> Result<()> {
947 let envoy_handle = self.0.schedule_envoy_handle.lock().clone().ok_or_else(|| {
948 crate::error::ActorRuntime::NotConfigured {
949 component: "schedule alarm handle".to_owned(),
950 }
951 .build()
952 })?;
953 let generation = *self.0.schedule_generation.lock();
954 self.set_alarm_tracked(envoy_handle, timestamp_ms, generation);
955 Ok(())
956 }
957
958 pub(crate) fn configure_schedule_envoy(
959 &self,
960 envoy_handle: EnvoyHandle,
961 generation: Option<u32>,
962 ) {
963 *self.0.schedule_envoy_handle.lock() = Some(envoy_handle);
964 *self.0.schedule_generation.lock() = generation;
965 }
966
967 pub(crate) fn set_internal_keep_awake(&self, callback: Option<InternalKeepAwakeCallback>) {
968 *self.0.schedule_internal_keep_awake.lock() = callback;
969 }
970
971 pub(crate) fn set_local_alarm_callback(&self, callback: Option<LocalAlarmCallback>) {
972 *self.0.schedule_local_alarm_callback.lock() = callback;
973 }
974
975 pub(crate) fn cancel_local_alarm_timeouts(&self) {
976 self.0
977 .schedule_local_alarm_epoch
978 .fetch_add(1, Ordering::SeqCst);
979 if let Some(handle) = self.0.schedule_local_alarm_task.lock().take() {
980 handle.abort();
981 }
982 }
983
984 pub(crate) fn cancel_driver_alarm_logged(&self) {
985 self.cancel_local_alarm_timeouts();
986 #[cfg(test)]
987 self.0
988 .schedule_driver_alarm_cancel_count
989 .fetch_add(1, Ordering::SeqCst);
990 let Some(envoy_handle) = self.0.schedule_envoy_handle.lock().clone() else {
991 return;
992 };
993 let generation = *self.0.schedule_generation.lock();
994 self.set_alarm_tracked(envoy_handle, None, generation);
995 }
996
997 #[cfg(test)]
998 pub(crate) fn test_driver_alarm_cancel_count(&self) -> usize {
999 self.0
1000 .schedule_driver_alarm_cancel_count
1001 .load(Ordering::SeqCst)
1002 }
1003
1004 #[cfg(test)]
1005 pub(crate) fn fail_next_schedule_alarm_sync_for_tests(&self) {
1006 self.0
1007 .schedule_sync_alarm_failures
1008 .fetch_add(1, Ordering::SeqCst);
1009 }
1010
1011 pub(crate) async fn wait_for_pending_alarm_writes(&self) {
1012 let pending = {
1013 let mut guard = self.0.schedule_pending_alarm_writes.lock();
1014 std::mem::take(&mut *guard)
1015 };
1016 for ack_rx in pending {
1017 let _ = ack_rx.await;
1018 }
1019 }
1020
1021 fn set_alarm_tracked(
1022 &self,
1023 envoy_handle: EnvoyHandle,
1024 timestamp_ms: Option<i64>,
1025 generation: Option<u32>,
1026 ) {
1027 let push_epoch = self
1028 .0
1029 .schedule_alarm_push_epoch
1030 .fetch_add(1, Ordering::SeqCst)
1031 .wrapping_add(1);
1032 let (ack_tx, ack_rx) = oneshot::channel();
1033 envoy_handle.set_alarm_with_ack(
1034 self.actor_id().to_owned(),
1035 timestamp_ms,
1036 generation,
1037 Some(ack_tx),
1038 );
1039 self.load_last_pushed_alarm(timestamp_ms);
1040 if let Ok(handle) = Handle::try_current() {
1041 let state_ctx = self.clone();
1042 let (persist_done_tx, persist_done_rx) = oneshot::channel();
1043 handle.spawn(
1044 async move {
1045 let _ = ack_rx.await;
1046 let is_current = state_ctx.0.schedule_alarm_push_epoch.load(Ordering::SeqCst)
1047 == push_epoch && state_ctx.last_pushed_alarm() == timestamp_ms;
1048 if is_current
1049 && let Err(error) = state_ctx.persist_last_pushed_alarm(timestamp_ms).await
1050 {
1051 tracing::error!(
1052 ?error,
1053 ?timestamp_ms,
1054 "failed to persist last pushed actor alarm"
1055 );
1056 }
1057 let _ = persist_done_tx.send(());
1058 }
1059 .in_current_span(),
1060 );
1061 self.0
1062 .schedule_pending_alarm_writes
1063 .lock()
1064 .push(persist_done_rx);
1065 return;
1066 }
1067 self.0.schedule_pending_alarm_writes.lock().push(ack_rx);
1068 }
1069
1070 fn arm_local_alarm(&self, next_alarm: Option<i64>) {
1071 self.cancel_local_alarm_timeouts();
1072 let Some(next_alarm) = next_alarm else {
1073 return;
1074 };
1075 if self.0.schedule_local_alarm_callback.lock().is_none() {
1076 return;
1077 }
1078 #[cfg(not(feature = "wasm-runtime"))]
1079 let tokio_handle = match Handle::try_current() {
1080 Ok(handle) => handle,
1081 Err(_) => return,
1082 };
1083 let delay_ms = next_alarm
1084 .saturating_sub(self.schedule_now_timestamp_ms())
1085 .max(0) as u64;
1086 let local_alarm_epoch = self.0.schedule_local_alarm_epoch.load(Ordering::SeqCst);
1087 let schedule = self.clone();
1088 let task = async move {
1089 sleep(Duration::from_millis(delay_ms)).await;
1090 if schedule.0.schedule_local_alarm_epoch.load(Ordering::SeqCst) != local_alarm_epoch {
1091 return;
1092 }
1093 let Some(callback) = schedule.0.schedule_local_alarm_callback.lock().clone() else {
1094 return;
1095 };
1096 callback().await;
1097 }
1098 .in_current_span();
1099 #[cfg(not(feature = "wasm-runtime"))]
1100 let handle = tokio_handle.spawn(task);
1101 #[cfg(feature = "wasm-runtime")]
1102 let handle = {
1103 let (handle, registration) = AbortHandle::new_pair();
1104 wasm_bindgen_futures::spawn_local(async move {
1108 let _ = Abortable::new(task, registration).await;
1109 });
1110 handle
1111 };
1112 *self.0.schedule_local_alarm_task.lock() = Some(handle);
1113 }
1114
1115 pub(crate) fn suspend_alarm_dispatch(&self) {
1116 self.0
1117 .schedule_alarm_dispatch_enabled
1118 .store(false, Ordering::SeqCst);
1119 }
1120}
1121
1122fn validate_name(name: &str) -> Result<()> {
1123 let reason = if name.is_empty() {
1124 Some("must not be empty")
1125 } else if name.len() > 128 {
1126 Some("must be at most 128 UTF-8 bytes")
1127 } else {
1128 None
1129 };
1130 match reason {
1131 Some(reason) => Err(ScheduleRuntimeError::InvalidName {
1132 reason: reason.to_owned(),
1133 }
1134 .build()),
1135 None => Ok(()),
1136 }
1137}
1138
1139fn validate_max_history(max_history: Option<i64>) -> Result<i64> {
1140 let max_history = max_history.unwrap_or(DEFAULT_MAX_HISTORY);
1141 if !(0..=MAX_HISTORY).contains(&max_history) {
1142 return Err(ScheduleRuntimeError::InvalidMaxHistory {
1143 max_history,
1144 maximum: MAX_HISTORY,
1145 }
1146 .build());
1147 }
1148 Ok(max_history)
1149}
1150
1151fn parse_timezone(timezone: &str) -> Result<Tz> {
1152 timezone.parse().map_err(|_| {
1153 ScheduleRuntimeError::InvalidTimezone {
1154 timezone: timezone.to_owned(),
1155 }
1156 .build()
1157 })
1158}
1159
1160fn parse_cron(expression: &str) -> Result<Cron> {
1161 if expression.split_whitespace().count() != 5 {
1162 return Err(ScheduleRuntimeError::InvalidCronExpression {
1163 reason: "expected exactly five fields".to_owned(),
1164 }
1165 .build());
1166 }
1167 Cron::new(expression).parse().map_err(|error| {
1168 ScheduleRuntimeError::InvalidCronExpression {
1169 reason: error.to_string(),
1170 }
1171 .build()
1172 })
1173}
1174
1175fn next_cron_timestamp_ms(cron: &Cron, timezone: Tz, after_ms: i64) -> Result<i64> {
1176 let after_utc = DateTime::<Utc>::from_timestamp_millis(after_ms).ok_or_else(|| {
1177 ScheduleRuntimeError::InvalidCronExpression {
1178 reason: "start time is outside the supported range".to_owned(),
1179 }
1180 .build()
1181 })?;
1182 let mut cursor = after_utc.with_timezone(&timezone);
1183 loop {
1184 let next = cron.find_next_occurrence(&cursor, false).map_err(|error| {
1185 ScheduleRuntimeError::InvalidCronExpression {
1186 reason: error.to_string(),
1187 }
1188 .build()
1189 })?;
1190 if cron.is_time_matching(&next).unwrap_or(false) {
1191 return Ok(next.timestamp_millis());
1192 }
1193 cursor = next;
1196 }
1197}
1198
1199fn next_recurring_trigger(event: &StoredSchedule, now_ms: i64) -> Result<i64> {
1200 match event.kind {
1201 ScheduleKind::Cron => {
1202 let expression = event
1203 .cron_expression
1204 .as_deref()
1205 .ok_or_else(|| invalid_row(&event.event_id, "cron expression is missing"))?;
1206 let timezone = event
1207 .timezone
1208 .as_deref()
1209 .ok_or_else(|| invalid_row(&event.event_id, "timezone is missing"))?;
1210 next_cron_timestamp_ms(&parse_cron(expression)?, parse_timezone(timezone)?, now_ms)
1211 }
1212 ScheduleKind::Every => {
1213 let interval_ms = event
1214 .interval_ms
1215 .ok_or_else(|| invalid_row(&event.event_id, "interval is missing"))?;
1216 if interval_ms < MIN_INTERVAL_MS {
1217 return Err(invalid_row(&event.event_id, "interval is below minimum"));
1218 }
1219 let elapsed = now_ms.saturating_sub(event.trigger_at).max(0);
1220 let steps = elapsed / interval_ms + 1;
1221 Ok(event
1222 .trigger_at
1223 .saturating_add(interval_ms.saturating_mul(steps)))
1224 }
1225 ScheduleKind::At => Err(invalid_row(
1226 &event.event_id,
1227 "one-shot passed to recurring calculation",
1228 )),
1229 }
1230}
1231
1232fn append_history_statements(
1233 statements: &mut Vec<SqliteBatchStatement>,
1234 event: &StoredSchedule,
1235 now_ms: i64,
1236 result: i64,
1237 prune_global_history: bool,
1238) -> Result<Option<usize>> {
1239 if event.max_history == 0 {
1240 return Ok(None);
1241 }
1242 let index = statements.len();
1243 statements.push(SqliteBatchStatement {
1244 sql: INSERT_SCHEDULE_HISTORY_SQL.to_owned(),
1245 params: Some(vec![
1246 BindParam::Text(event.event_id.clone()),
1247 BindParam::Text(event.action.clone()),
1248 BindParam::Integer(event.trigger_at),
1249 BindParam::Integer(now_ms),
1250 if result == HISTORY_SKIPPED {
1251 BindParam::Integer(now_ms)
1252 } else {
1253 BindParam::Null
1254 },
1255 BindParam::Integer(result),
1256 BindParam::Null,
1257 BindParam::Null,
1258 BindParam::Null,
1259 BindParam::Null,
1260 ]),
1261 });
1262 statements.extend(history_prune_statements(
1263 &event.event_id,
1264 event.max_history,
1265 prune_global_history,
1266 ));
1267 Ok(Some(index))
1268}
1269
1270fn history_prune_statements(
1271 event_id: &str,
1272 max_history: i64,
1273 prune_global_history: bool,
1274) -> Vec<SqliteBatchStatement> {
1275 let mut statements = vec![SqliteBatchStatement {
1276 sql: PRUNE_SCHEDULE_HISTORY_SQL.to_owned(),
1277 params: Some(vec![
1278 BindParam::Text(event_id.to_owned()),
1279 BindParam::Integer(max_history),
1280 ]),
1281 }];
1282 if prune_global_history {
1283 statements.push(SqliteBatchStatement {
1284 sql: PRUNE_GLOBAL_HISTORY_SQL.to_owned(),
1285 params: Some(vec![BindParam::Integer(GLOBAL_HISTORY_RETAINED_ROWS)]),
1286 });
1287 }
1288 statements
1289}
1290
1291fn stored_to_cron_info(event: StoredSchedule) -> Result<CronJobInfo> {
1292 if event.kind == ScheduleKind::At {
1293 return Err(invalid_row(&event.event_id, "expected recurring schedule"));
1294 }
1295 Ok(CronJobInfo {
1296 name: cron_name(&event.event_id)?.to_owned(),
1297 kind: event.kind,
1298 action: event.action,
1299 args: event.args,
1300 next_run_at: event.trigger_at,
1301 last_run_at: event.last_started_at,
1302 expression: event.cron_expression,
1303 timezone: event.timezone,
1304 interval_ms: event.interval_ms,
1305 max_history: event.max_history,
1306 })
1307}
1308
1309fn read_stored_schedule(row: &[ColumnValue]) -> Result<StoredSchedule> {
1310 let event_id = read_text(row, 0, "event_id")?;
1311 let kind = ScheduleKind::parse(read_i64(row, 4, "kind")?, &event_id)?;
1312 let event = StoredSchedule {
1313 event_id: event_id.clone(),
1314 trigger_at: read_i64(row, 1, "trigger_at")?,
1315 action: read_text(row, 2, "action")?,
1316 args: read_optional_blob(row, 3, "args")?.unwrap_or_default(),
1317 kind,
1318 cron_expression: read_optional_text(row, 5, "cron_expression")?,
1319 timezone: read_optional_text(row, 6, "timezone")?,
1320 interval_ms: read_optional_i64(row, 7, "interval_ms")?,
1321 last_started_at: read_optional_i64(row, 8, "last_started_at")?,
1322 max_history: read_i64(row, 9, "max_history")?,
1323 };
1324 match event.kind {
1325 ScheduleKind::At
1326 if event.cron_expression.is_some()
1327 || event.timezone.is_some()
1328 || event.interval_ms.is_some()
1329 || event.max_history != 0 =>
1330 {
1331 Err(invalid_row(&event_id, "invalid one-shot columns"))
1332 }
1333 ScheduleKind::Cron
1334 if event.cron_expression.is_none()
1335 || event.timezone.is_none()
1336 || event.interval_ms.is_some() =>
1337 {
1338 Err(invalid_row(&event_id, "invalid cron columns"))
1339 }
1340 ScheduleKind::Every
1341 if event.cron_expression.is_some()
1342 || event.timezone.is_some()
1343 || event
1344 .interval_ms
1345 .is_none_or(|value| value < MIN_INTERVAL_MS) =>
1346 {
1347 Err(invalid_row(&event_id, "invalid interval columns"))
1348 }
1349 _ if !(0..=MAX_HISTORY).contains(&event.max_history) => {
1350 Err(invalid_row(&event_id, "max_history is out of range"))
1351 }
1352 _ => Ok(event),
1353 }
1354}
1355
1356fn read_cron_fire(row: &[ColumnValue]) -> Result<CronFire> {
1357 let error_group = read_optional_text(row, 5, "error_group")?;
1358 let error_code = read_optional_text(row, 6, "error_code")?;
1359 let error_message = read_optional_text(row, 7, "error_message")?;
1360 let error_metadata = read_optional_blob(row, 8, "error_metadata")?
1361 .map(|value| decode_error_metadata(&value))
1362 .transpose()?;
1363 let error = match (error_group, error_code, error_message) {
1364 (None, None, None) => None,
1365 (Some(group), Some(code), Some(message)) => Some(ScheduleErrorInfo {
1366 group,
1367 code,
1368 message,
1369 metadata: error_metadata,
1370 }),
1371 _ => return Err(invalid_row("<history>", "error columns are incomplete")),
1372 };
1373 Ok(CronFire {
1374 action: read_text(row, 0, "action")?,
1375 scheduled_at: read_i64(row, 1, "scheduled_at")?,
1376 fired_at: read_i64(row, 2, "fired_at")?,
1377 finished_at: read_optional_i64(row, 3, "finished_at")?,
1378 result: history_result_name(read_i64(row, 4, "result")?)?.to_owned(),
1379 error,
1380 })
1381}
1382
1383fn sanitize_error(error: &anyhow::Error) -> ScheduleErrorInfo {
1384 let extracted = RivetError::extract(error);
1385 let metadata = extracted.metadata();
1386 ScheduleErrorInfo {
1387 group: extracted.group().to_owned(),
1388 code: extracted.code().to_owned(),
1389 message: client_error_message(extracted.group(), extracted.code(), extracted.message())
1390 .to_owned(),
1391 metadata: client_error_metadata(extracted.group(), extracted.code(), metadata.as_ref())
1392 .cloned(),
1393 }
1394}
1395
1396fn encode_error_metadata(metadata: &serde_json::Value) -> Result<Vec<u8>> {
1397 let mut output = Vec::new();
1398 ciborium::into_writer(metadata, &mut output)
1399 .context("encode schedule history error metadata")?;
1400 Ok(output)
1401}
1402
1403fn decode_error_metadata(value: &[u8]) -> Result<serde_json::Value> {
1404 ciborium::from_reader(value).context("decode schedule history error metadata")
1405}
1406
1407fn history_result_name(value: i64) -> Result<&'static str> {
1408 match value {
1409 HISTORY_RUNNING => Ok("running"),
1410 HISTORY_OK => Ok("ok"),
1411 HISTORY_ERROR => Ok("error"),
1412 HISTORY_SKIPPED => Ok("skipped"),
1413 _ => Err(invalid_row("<history>", "unknown result")),
1414 }
1415}
1416
1417fn cron_event_id(name: &str) -> String {
1418 format!("{CRON_ID_PREFIX}{name}")
1419}
1420
1421fn cron_name(event_id: &str) -> Result<&str> {
1422 event_id
1423 .strip_prefix(CRON_ID_PREFIX)
1424 .ok_or_else(|| invalid_row(event_id, "recurring id is missing cron prefix"))
1425}
1426
1427fn invalid_row(schedule_id: &str, reason: &str) -> anyhow::Error {
1428 ScheduleRuntimeError::InvalidScheduleRow {
1429 schedule_id: schedule_id.to_owned(),
1430 reason: reason.to_owned(),
1431 }
1432 .build()
1433}
1434
1435fn args_param(args: &[u8]) -> BindParam {
1436 if args.is_empty() {
1437 BindParam::Null
1438 } else {
1439 BindParam::Blob(args.to_vec())
1440 }
1441}
1442
1443fn optional_text_param(value: Option<&str>) -> BindParam {
1444 value
1445 .map(|value| BindParam::Text(value.to_owned()))
1446 .unwrap_or(BindParam::Null)
1447}
1448
1449fn optional_owned_text_param(value: Option<String>) -> BindParam {
1450 value.map(BindParam::Text).unwrap_or(BindParam::Null)
1451}
1452
1453fn optional_i64_param(value: Option<i64>) -> BindParam {
1454 value.map(BindParam::Integer).unwrap_or(BindParam::Null)
1455}
1456
1457fn read_text(row: &[ColumnValue], index: usize, label: &str) -> Result<String> {
1458 match row.get(index) {
1459 Some(ColumnValue::Text(value)) => Ok(value.clone()),
1460 _ => Err(invalid_row("<decode>", &format!("{label} is not text"))),
1461 }
1462}
1463
1464fn read_optional_text(row: &[ColumnValue], index: usize, label: &str) -> Result<Option<String>> {
1465 match row.get(index) {
1466 Some(ColumnValue::Null) | None => Ok(None),
1467 Some(ColumnValue::Text(value)) => Ok(Some(value.clone())),
1468 _ => Err(invalid_row("<decode>", &format!("{label} is not text"))),
1469 }
1470}
1471
1472fn read_i64(row: &[ColumnValue], index: usize, label: &str) -> Result<i64> {
1473 match row.get(index) {
1474 Some(ColumnValue::Integer(value)) => Ok(*value),
1475 _ => Err(invalid_row(
1476 "<decode>",
1477 &format!("{label} is not an integer"),
1478 )),
1479 }
1480}
1481
1482fn read_optional_i64(row: &[ColumnValue], index: usize, label: &str) -> Result<Option<i64>> {
1483 match row.get(index) {
1484 Some(ColumnValue::Null) | None => Ok(None),
1485 Some(ColumnValue::Integer(value)) => Ok(Some(*value)),
1486 _ => Err(invalid_row(
1487 "<decode>",
1488 &format!("{label} is not an integer"),
1489 )),
1490 }
1491}
1492
1493fn read_optional_blob(row: &[ColumnValue], index: usize, label: &str) -> Result<Option<Vec<u8>>> {
1494 match row.get(index) {
1495 Some(ColumnValue::Null) | None => Ok(None),
1496 Some(ColumnValue::Blob(value)) => Ok(Some(value.clone())),
1497 _ => Err(invalid_row("<decode>", &format!("{label} is not a blob"))),
1498 }
1499}
1500
1501fn system_now_timestamp_ms() -> i64 {
1502 let duration = SystemTime::now()
1503 .duration_since(UNIX_EPOCH)
1504 .unwrap_or_default();
1505 i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)
1506}
1507
1508#[cfg(test)]
1509#[path = "../../tests/schedule.rs"]
1510mod tests;