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