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 async fn set_run_wake_at(&self, wake_at: Option<i64>) -> Result<()> {
842 let _mutation = self.0.schedule_mutation_lock.lock().await;
843 self.persist_run_wake_at(wake_at).await?;
844 self.0.run_wake_revision.fetch_add(1, Ordering::SeqCst);
845 self.mark_schedule_dirty();
846 self.sync_alarm().await
847 }
848
849 pub(crate) async fn consume_due_run_wake(&self) -> Result<Option<(i64, u64)>> {
850 let _mutation = self.0.schedule_mutation_lock.lock().await;
851 let Some(wake_at) = self.run_wake_at() else {
852 return Ok(None);
853 };
854 if wake_at > self.schedule_now_timestamp_ms() {
855 return Ok(None);
856 }
857 self.persist_run_wake_at(None).await?;
858 let revision = self.0.run_wake_revision.fetch_add(1, Ordering::SeqCst) + 1;
859 self.mark_schedule_dirty();
860 Ok(Some((wake_at, revision)))
861 }
862
863 #[doc(hidden)]
864 pub async fn restore_run_wake_at_if_unchanged(
865 &self,
866 wake_at: i64,
867 consumed_revision: u64,
868 ) -> Result<bool> {
869 let _mutation = self.0.schedule_mutation_lock.lock().await;
870 if self.0.run_wake_revision.load(Ordering::SeqCst) != consumed_revision {
871 return Ok(false);
872 }
873 self.persist_run_wake_at(Some(wake_at)).await?;
874 self.0.run_wake_revision.fetch_add(1, Ordering::SeqCst);
875 self.mark_schedule_dirty();
876 self.sync_alarm().await?;
877 Ok(true)
878 }
879
880 async fn next_schedule_timestamp(&self, future_only: bool) -> Result<Option<i64>> {
881 let (sql, params) = if future_only {
882 (
883 NEXT_FUTURE_SCHEDULE_SQL,
884 Some(vec![BindParam::Integer(self.schedule_now_timestamp_ms())]),
885 )
886 } else {
887 (NEXT_SCHEDULE_SQL, None)
888 };
889 let result = self.sql().query(sql, params).await?;
890 match result.rows.first().and_then(|row| row.first()) {
891 None | Some(ColumnValue::Null) => Ok(None),
892 Some(ColumnValue::Integer(timestamp)) => Ok(Some(*timestamp)),
893 Some(_) => Err(ScheduleRuntimeError::InvalidScheduleRow {
894 schedule_id: "<minimum>".to_owned(),
895 reason: "MIN(trigger_at) was not an integer".to_owned(),
896 }
897 .build()),
898 }
899 }
900
901 async fn sync_alarm(&self) -> Result<()> {
902 #[cfg(test)]
903 if self
904 .0
905 .schedule_sync_alarm_failures
906 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| {
907 remaining.checked_sub(1)
908 })
909 .is_ok()
910 {
911 anyhow::bail!("injected schedule alarm sync failure");
912 }
913 let next_alarm = min_deadline(
914 self.next_schedule_timestamp(false).await?,
915 self.run_wake_at(),
916 );
917 self.sync_alarm_timestamp(next_alarm)
918 }
919
920 async fn sync_future_alarm(&self) -> Result<()> {
921 let next_alarm = min_deadline(
922 self.next_schedule_timestamp(true).await?,
923 self.run_wake_at(),
924 );
925 self.sync_alarm_timestamp(next_alarm)
926 }
927
928 fn sync_alarm_timestamp(&self, next_alarm: Option<i64>) -> Result<()> {
929 let should_push = self
930 .0
931 .schedule_dirty_since_push
932 .swap(false, Ordering::SeqCst);
933 self.arm_local_alarm(next_alarm);
934 if !should_push {
935 return Ok(());
936 }
937 if next_alarm.is_some() && self.last_pushed_alarm() == next_alarm {
938 return Ok(());
939 }
940 let Some(envoy_handle) = self.0.schedule_envoy_handle.lock().clone() else {
941 self.mark_schedule_dirty();
942 tracing::warn!(
943 actor_id = self.actor_id(),
944 "schedule alarm sync skipped because envoy handle is not configured"
945 );
946 return Ok(());
947 };
948 let generation = *self.0.schedule_generation.lock();
949 self.set_alarm_tracked(envoy_handle, next_alarm, generation);
950 Ok(())
951 }
952
953 pub(crate) async fn sync_alarm_logged(&self) {
954 if let Err(error) = self.sync_alarm().await {
955 tracing::error!(actor_id = %self.actor_id(), ?error, "failed to sync scheduled actor alarm");
956 }
957 }
958
959 pub(crate) async fn sync_future_alarm_logged(&self) {
960 if let Err(error) = self.sync_future_alarm().await {
961 tracing::error!(actor_id = %self.actor_id(), ?error, "failed to sync future scheduled actor alarm");
962 }
963 }
964
965 pub(crate) fn set_schedule_alarm(&self, timestamp_ms: Option<i64>) -> Result<()> {
966 let envoy_handle = self.0.schedule_envoy_handle.lock().clone().ok_or_else(|| {
967 crate::error::ActorRuntime::NotConfigured {
968 component: "schedule alarm handle".to_owned(),
969 }
970 .build()
971 })?;
972 let generation = *self.0.schedule_generation.lock();
973 self.set_alarm_tracked(envoy_handle, timestamp_ms, generation);
974 Ok(())
975 }
976
977 pub(crate) fn configure_schedule_envoy(
978 &self,
979 envoy_handle: EnvoyHandle,
980 generation: Option<u32>,
981 ) {
982 *self.0.schedule_envoy_handle.lock() = Some(envoy_handle);
983 *self.0.schedule_generation.lock() = generation;
984 }
985
986 pub(crate) fn set_internal_keep_awake(&self, callback: Option<InternalKeepAwakeCallback>) {
987 *self.0.schedule_internal_keep_awake.lock() = callback;
988 }
989
990 pub(crate) fn set_local_alarm_callback(&self, callback: Option<LocalAlarmCallback>) {
991 *self.0.schedule_local_alarm_callback.lock() = callback;
992 }
993
994 pub(crate) fn cancel_local_alarm_timeouts(&self) {
995 self.0
996 .schedule_local_alarm_epoch
997 .fetch_add(1, Ordering::SeqCst);
998 if let Some(handle) = self.0.schedule_local_alarm_task.lock().take() {
999 handle.abort();
1000 }
1001 }
1002
1003 pub(crate) fn cancel_driver_alarm_logged(&self) {
1004 self.cancel_local_alarm_timeouts();
1005 #[cfg(test)]
1006 self.0
1007 .schedule_driver_alarm_cancel_count
1008 .fetch_add(1, Ordering::SeqCst);
1009 let Some(envoy_handle) = self.0.schedule_envoy_handle.lock().clone() else {
1010 return;
1011 };
1012 let generation = *self.0.schedule_generation.lock();
1013 self.set_alarm_tracked(envoy_handle, None, generation);
1014 }
1015
1016 #[cfg(test)]
1017 pub(crate) fn test_driver_alarm_cancel_count(&self) -> usize {
1018 self.0
1019 .schedule_driver_alarm_cancel_count
1020 .load(Ordering::SeqCst)
1021 }
1022
1023 #[cfg(test)]
1024 pub(crate) fn fail_next_schedule_alarm_sync_for_tests(&self) {
1025 self.0
1026 .schedule_sync_alarm_failures
1027 .fetch_add(1, Ordering::SeqCst);
1028 }
1029
1030 pub(crate) async fn wait_for_pending_alarm_writes(&self) {
1031 let pending = {
1032 let mut guard = self.0.schedule_pending_alarm_writes.lock();
1033 std::mem::take(&mut *guard)
1034 };
1035 for ack_rx in pending {
1036 let _ = ack_rx.await;
1037 }
1038 }
1039
1040 pub(crate) fn abandon_pending_alarm_writes(&self) {
1041 self.0
1046 .schedule_alarm_push_epoch
1047 .fetch_add(1, Ordering::SeqCst);
1048 self.0.schedule_pending_alarm_writes.lock().clear();
1049 }
1050
1051 fn set_alarm_tracked(
1052 &self,
1053 envoy_handle: EnvoyHandle,
1054 timestamp_ms: Option<i64>,
1055 generation: Option<u32>,
1056 ) {
1057 let push_epoch = self
1058 .0
1059 .schedule_alarm_push_epoch
1060 .fetch_add(1, Ordering::SeqCst)
1061 .wrapping_add(1);
1062 let (ack_tx, ack_rx) = oneshot::channel();
1063 envoy_handle.set_alarm_with_ack(
1064 self.actor_id().to_owned(),
1065 timestamp_ms,
1066 generation,
1067 Some(ack_tx),
1068 );
1069 self.load_last_pushed_alarm(timestamp_ms);
1070 if let Ok(handle) = Handle::try_current() {
1071 let state_ctx = self.clone();
1072 let (persist_done_tx, persist_done_rx) = oneshot::channel();
1073 handle.spawn(
1074 async move {
1075 let _ = ack_rx.await;
1076 let is_current = state_ctx.0.schedule_alarm_push_epoch.load(Ordering::SeqCst)
1077 == push_epoch && state_ctx.last_pushed_alarm() == timestamp_ms;
1078 if is_current
1079 && let Err(error) = state_ctx.persist_last_pushed_alarm(timestamp_ms).await
1080 {
1081 tracing::error!(
1082 ?error,
1083 ?timestamp_ms,
1084 "failed to persist last pushed actor alarm"
1085 );
1086 }
1087 let _ = persist_done_tx.send(());
1088 }
1089 .in_current_span(),
1090 );
1091 self.0
1092 .schedule_pending_alarm_writes
1093 .lock()
1094 .push(persist_done_rx);
1095 return;
1096 }
1097 self.0.schedule_pending_alarm_writes.lock().push(ack_rx);
1098 }
1099
1100 fn arm_local_alarm(&self, next_alarm: Option<i64>) {
1101 self.cancel_local_alarm_timeouts();
1102 let Some(next_alarm) = next_alarm else {
1103 return;
1104 };
1105 if self.0.schedule_local_alarm_callback.lock().is_none() {
1106 return;
1107 }
1108 #[cfg(not(feature = "wasm-runtime"))]
1109 let tokio_handle = match Handle::try_current() {
1110 Ok(handle) => handle,
1111 Err(_) => return,
1112 };
1113 let delay_ms = next_alarm
1114 .saturating_sub(self.schedule_now_timestamp_ms())
1115 .max(0) as u64;
1116 let local_alarm_epoch = self.0.schedule_local_alarm_epoch.load(Ordering::SeqCst);
1117 let schedule = self.clone();
1118 let task = async move {
1119 sleep(Duration::from_millis(delay_ms)).await;
1120 if schedule.0.schedule_local_alarm_epoch.load(Ordering::SeqCst) != local_alarm_epoch {
1121 return;
1122 }
1123 let Some(callback) = schedule.0.schedule_local_alarm_callback.lock().clone() else {
1124 return;
1125 };
1126 callback().await;
1127 }
1128 .in_current_span();
1129 #[cfg(not(feature = "wasm-runtime"))]
1130 let handle = tokio_handle.spawn(task);
1131 #[cfg(feature = "wasm-runtime")]
1132 let handle = {
1133 let (handle, registration) = AbortHandle::new_pair();
1134 wasm_bindgen_futures::spawn_local(async move {
1138 let _ = Abortable::new(task, registration).await;
1139 });
1140 handle
1141 };
1142 *self.0.schedule_local_alarm_task.lock() = Some(handle);
1143 }
1144
1145 pub(crate) fn suspend_alarm_dispatch(&self) {
1146 self.0
1147 .schedule_alarm_dispatch_enabled
1148 .store(false, Ordering::SeqCst);
1149 }
1150}
1151
1152fn validate_name(name: &str) -> Result<()> {
1153 let reason = if name.is_empty() {
1154 Some("must not be empty")
1155 } else if name.len() > 128 {
1156 Some("must be at most 128 UTF-8 bytes")
1157 } else {
1158 None
1159 };
1160 match reason {
1161 Some(reason) => Err(ScheduleRuntimeError::InvalidName {
1162 reason: reason.to_owned(),
1163 }
1164 .build()),
1165 None => Ok(()),
1166 }
1167}
1168
1169fn validate_max_history(max_history: Option<i64>) -> Result<i64> {
1170 let max_history = max_history.unwrap_or(DEFAULT_MAX_HISTORY);
1171 if !(0..=MAX_HISTORY).contains(&max_history) {
1172 return Err(ScheduleRuntimeError::InvalidMaxHistory {
1173 max_history,
1174 maximum: MAX_HISTORY,
1175 }
1176 .build());
1177 }
1178 Ok(max_history)
1179}
1180
1181fn parse_timezone(timezone: &str) -> Result<Tz> {
1182 timezone.parse().map_err(|_| {
1183 ScheduleRuntimeError::InvalidTimezone {
1184 timezone: timezone.to_owned(),
1185 }
1186 .build()
1187 })
1188}
1189
1190fn parse_cron(expression: &str) -> Result<Cron> {
1191 if expression.split_whitespace().count() != 5 {
1192 return Err(ScheduleRuntimeError::InvalidCronExpression {
1193 reason: "expected exactly five fields".to_owned(),
1194 }
1195 .build());
1196 }
1197 Cron::new(expression).parse().map_err(|error| {
1198 ScheduleRuntimeError::InvalidCronExpression {
1199 reason: error.to_string(),
1200 }
1201 .build()
1202 })
1203}
1204
1205fn next_cron_timestamp_ms(cron: &Cron, timezone: Tz, after_ms: i64) -> Result<i64> {
1206 let after_utc = DateTime::<Utc>::from_timestamp_millis(after_ms).ok_or_else(|| {
1207 ScheduleRuntimeError::InvalidCronExpression {
1208 reason: "start time is outside the supported range".to_owned(),
1209 }
1210 .build()
1211 })?;
1212 let mut cursor = after_utc.with_timezone(&timezone);
1213 loop {
1214 let next = cron.find_next_occurrence(&cursor, false).map_err(|error| {
1215 ScheduleRuntimeError::InvalidCronExpression {
1216 reason: error.to_string(),
1217 }
1218 .build()
1219 })?;
1220 if cron.is_time_matching(&next).unwrap_or(false) {
1221 return Ok(next.timestamp_millis());
1222 }
1223 cursor = next;
1226 }
1227}
1228
1229fn next_recurring_trigger(event: &StoredSchedule, now_ms: i64) -> Result<i64> {
1230 match event.kind {
1231 ScheduleKind::Cron => {
1232 let expression = event
1233 .cron_expression
1234 .as_deref()
1235 .ok_or_else(|| invalid_row(&event.event_id, "cron expression is missing"))?;
1236 let timezone = event
1237 .timezone
1238 .as_deref()
1239 .ok_or_else(|| invalid_row(&event.event_id, "timezone is missing"))?;
1240 next_cron_timestamp_ms(&parse_cron(expression)?, parse_timezone(timezone)?, now_ms)
1241 }
1242 ScheduleKind::Every => {
1243 let interval_ms = event
1244 .interval_ms
1245 .ok_or_else(|| invalid_row(&event.event_id, "interval is missing"))?;
1246 if interval_ms < MIN_INTERVAL_MS {
1247 return Err(invalid_row(&event.event_id, "interval is below minimum"));
1248 }
1249 let elapsed = now_ms.saturating_sub(event.trigger_at).max(0);
1250 let steps = elapsed / interval_ms + 1;
1251 Ok(event
1252 .trigger_at
1253 .saturating_add(interval_ms.saturating_mul(steps)))
1254 }
1255 ScheduleKind::At => Err(invalid_row(
1256 &event.event_id,
1257 "one-shot passed to recurring calculation",
1258 )),
1259 }
1260}
1261
1262fn append_history_statements(
1263 statements: &mut Vec<SqliteBatchStatement>,
1264 event: &StoredSchedule,
1265 now_ms: i64,
1266 result: i64,
1267 prune_global_history: bool,
1268) -> Result<Option<usize>> {
1269 if event.max_history == 0 {
1270 return Ok(None);
1271 }
1272 let index = statements.len();
1273 statements.push(SqliteBatchStatement {
1274 sql: INSERT_SCHEDULE_HISTORY_SQL.to_owned(),
1275 params: Some(vec![
1276 BindParam::Text(event.event_id.clone()),
1277 BindParam::Text(event.action.clone()),
1278 BindParam::Integer(event.trigger_at),
1279 BindParam::Integer(now_ms),
1280 if result == HISTORY_SKIPPED {
1281 BindParam::Integer(now_ms)
1282 } else {
1283 BindParam::Null
1284 },
1285 BindParam::Integer(result),
1286 BindParam::Null,
1287 BindParam::Null,
1288 BindParam::Null,
1289 BindParam::Null,
1290 ]),
1291 });
1292 statements.extend(history_prune_statements(
1293 &event.event_id,
1294 event.max_history,
1295 prune_global_history,
1296 ));
1297 Ok(Some(index))
1298}
1299
1300fn history_prune_statements(
1301 event_id: &str,
1302 max_history: i64,
1303 prune_global_history: bool,
1304) -> Vec<SqliteBatchStatement> {
1305 let mut statements = vec![SqliteBatchStatement {
1306 sql: PRUNE_SCHEDULE_HISTORY_SQL.to_owned(),
1307 params: Some(vec![
1308 BindParam::Text(event_id.to_owned()),
1309 BindParam::Integer(max_history),
1310 ]),
1311 }];
1312 if prune_global_history {
1313 statements.push(SqliteBatchStatement {
1314 sql: PRUNE_GLOBAL_HISTORY_SQL.to_owned(),
1315 params: Some(vec![BindParam::Integer(GLOBAL_HISTORY_RETAINED_ROWS)]),
1316 });
1317 }
1318 statements
1319}
1320
1321fn stored_to_cron_info(event: StoredSchedule) -> Result<CronJobInfo> {
1322 if event.kind == ScheduleKind::At {
1323 return Err(invalid_row(&event.event_id, "expected recurring schedule"));
1324 }
1325 Ok(CronJobInfo {
1326 name: cron_name(&event.event_id)?.to_owned(),
1327 kind: event.kind,
1328 action: event.action,
1329 args: event.args,
1330 next_run_at: event.trigger_at,
1331 last_run_at: event.last_started_at,
1332 expression: event.cron_expression,
1333 timezone: event.timezone,
1334 interval_ms: event.interval_ms,
1335 max_history: event.max_history,
1336 })
1337}
1338
1339fn read_stored_schedule(row: &[ColumnValue]) -> Result<StoredSchedule> {
1340 let event_id = read_text(row, 0, "event_id")?;
1341 let kind = ScheduleKind::parse(read_i64(row, 4, "kind")?, &event_id)?;
1342 let event = StoredSchedule {
1343 event_id: event_id.clone(),
1344 trigger_at: read_i64(row, 1, "trigger_at")?,
1345 action: read_text(row, 2, "action")?,
1346 args: read_optional_blob(row, 3, "args")?.unwrap_or_default(),
1347 kind,
1348 cron_expression: read_optional_text(row, 5, "cron_expression")?,
1349 timezone: read_optional_text(row, 6, "timezone")?,
1350 interval_ms: read_optional_i64(row, 7, "interval_ms")?,
1351 last_started_at: read_optional_i64(row, 8, "last_started_at")?,
1352 max_history: read_i64(row, 9, "max_history")?,
1353 };
1354 match event.kind {
1355 ScheduleKind::At
1356 if event.cron_expression.is_some()
1357 || event.timezone.is_some()
1358 || event.interval_ms.is_some()
1359 || event.max_history != 0 =>
1360 {
1361 Err(invalid_row(&event_id, "invalid one-shot columns"))
1362 }
1363 ScheduleKind::Cron
1364 if event.cron_expression.is_none()
1365 || event.timezone.is_none()
1366 || event.interval_ms.is_some() =>
1367 {
1368 Err(invalid_row(&event_id, "invalid cron columns"))
1369 }
1370 ScheduleKind::Every
1371 if event.cron_expression.is_some()
1372 || event.timezone.is_some()
1373 || event
1374 .interval_ms
1375 .is_none_or(|value| value < MIN_INTERVAL_MS) =>
1376 {
1377 Err(invalid_row(&event_id, "invalid interval columns"))
1378 }
1379 _ if !(0..=MAX_HISTORY).contains(&event.max_history) => {
1380 Err(invalid_row(&event_id, "max_history is out of range"))
1381 }
1382 _ => Ok(event),
1383 }
1384}
1385
1386fn read_due_schedule(row: &[ColumnValue]) -> Result<(StoredSchedule, IncomingTraceContext)> {
1387 let event = read_stored_schedule(row)?;
1388 let trace_context = read_trace_context(row, 10, "schedule trace context")?;
1389 Ok((event, trace_context))
1390}
1391
1392fn read_cron_fire(row: &[ColumnValue]) -> Result<CronFire> {
1393 let error_group = read_optional_text(row, 5, "error_group")?;
1394 let error_code = read_optional_text(row, 6, "error_code")?;
1395 let error_message = read_optional_text(row, 7, "error_message")?;
1396 let error_metadata = read_optional_blob(row, 8, "error_metadata")?
1397 .map(|value| decode_error_metadata(&value))
1398 .transpose()?;
1399 let error = match (error_group, error_code, error_message) {
1400 (None, None, None) => None,
1401 (Some(group), Some(code), Some(message)) => Some(ScheduleErrorInfo {
1402 group,
1403 code,
1404 message,
1405 metadata: error_metadata,
1406 }),
1407 _ => return Err(invalid_row("<history>", "error columns are incomplete")),
1408 };
1409 Ok(CronFire {
1410 action: read_text(row, 0, "action")?,
1411 scheduled_at: read_i64(row, 1, "scheduled_at")?,
1412 fired_at: read_i64(row, 2, "fired_at")?,
1413 finished_at: read_optional_i64(row, 3, "finished_at")?,
1414 result: history_result_name(read_i64(row, 4, "result")?)?.to_owned(),
1415 error,
1416 })
1417}
1418
1419fn sanitize_error(error: &anyhow::Error) -> ScheduleErrorInfo {
1420 let extracted = RivetError::extract(error);
1421 let metadata = extracted.metadata();
1422 ScheduleErrorInfo {
1423 group: extracted.group().to_owned(),
1424 code: extracted.code().to_owned(),
1425 message: client_error_message(extracted.group(), extracted.code(), extracted.message())
1426 .to_owned(),
1427 metadata: client_error_metadata(extracted.group(), extracted.code(), metadata.as_ref())
1428 .cloned(),
1429 }
1430}
1431
1432fn encode_error_metadata(metadata: &serde_json::Value) -> Result<Vec<u8>> {
1433 let mut output = Vec::new();
1434 ciborium::into_writer(metadata, &mut output)
1435 .context("encode schedule history error metadata")?;
1436 Ok(output)
1437}
1438
1439fn decode_error_metadata(value: &[u8]) -> Result<serde_json::Value> {
1440 ciborium::from_reader(value).context("decode schedule history error metadata")
1441}
1442
1443fn history_result_name(value: i64) -> Result<&'static str> {
1444 match value {
1445 HISTORY_RUNNING => Ok("running"),
1446 HISTORY_OK => Ok("ok"),
1447 HISTORY_ERROR => Ok("error"),
1448 HISTORY_SKIPPED => Ok("skipped"),
1449 _ => Err(invalid_row("<history>", "unknown result")),
1450 }
1451}
1452
1453fn cron_event_id(name: &str) -> String {
1454 format!("{CRON_ID_PREFIX}{name}")
1455}
1456
1457fn cron_name(event_id: &str) -> Result<&str> {
1458 event_id
1459 .strip_prefix(CRON_ID_PREFIX)
1460 .ok_or_else(|| invalid_row(event_id, "recurring id is missing cron prefix"))
1461}
1462
1463fn invalid_row(schedule_id: &str, reason: &str) -> anyhow::Error {
1464 ScheduleRuntimeError::InvalidScheduleRow {
1465 schedule_id: schedule_id.to_owned(),
1466 reason: reason.to_owned(),
1467 }
1468 .build()
1469}
1470
1471fn args_param(args: &[u8]) -> BindParam {
1472 if args.is_empty() {
1473 BindParam::Null
1474 } else {
1475 BindParam::Blob(args.to_vec())
1476 }
1477}
1478
1479fn optional_text_param(value: Option<&str>) -> BindParam {
1480 value
1481 .map(|value| BindParam::Text(value.to_owned()))
1482 .unwrap_or(BindParam::Null)
1483}
1484
1485fn optional_i64_param(value: Option<i64>) -> BindParam {
1486 value.map(BindParam::Integer).unwrap_or(BindParam::Null)
1487}
1488
1489fn read_text(row: &[ColumnValue], index: usize, label: &str) -> Result<String> {
1490 match row.get(index) {
1491 Some(ColumnValue::Text(value)) => Ok(value.clone()),
1492 _ => Err(invalid_row("<decode>", &format!("{label} is not text"))),
1493 }
1494}
1495
1496fn read_optional_text(row: &[ColumnValue], index: usize, label: &str) -> Result<Option<String>> {
1497 match row.get(index) {
1498 Some(ColumnValue::Null) | None => Ok(None),
1499 Some(ColumnValue::Text(value)) => Ok(Some(value.clone())),
1500 _ => Err(invalid_row("<decode>", &format!("{label} is not text"))),
1501 }
1502}
1503
1504fn read_i64(row: &[ColumnValue], index: usize, label: &str) -> Result<i64> {
1505 match row.get(index) {
1506 Some(ColumnValue::Integer(value)) => Ok(*value),
1507 _ => Err(invalid_row(
1508 "<decode>",
1509 &format!("{label} is not an integer"),
1510 )),
1511 }
1512}
1513
1514fn read_optional_i64(row: &[ColumnValue], index: usize, label: &str) -> Result<Option<i64>> {
1515 match row.get(index) {
1516 Some(ColumnValue::Null) | None => Ok(None),
1517 Some(ColumnValue::Integer(value)) => Ok(Some(*value)),
1518 _ => Err(invalid_row(
1519 "<decode>",
1520 &format!("{label} is not an integer"),
1521 )),
1522 }
1523}
1524
1525fn read_optional_blob(row: &[ColumnValue], index: usize, label: &str) -> Result<Option<Vec<u8>>> {
1526 match row.get(index) {
1527 Some(ColumnValue::Null) | None => Ok(None),
1528 Some(ColumnValue::Blob(value)) => Ok(Some(value.clone())),
1529 _ => Err(invalid_row("<decode>", &format!("{label} is not a blob"))),
1530 }
1531}
1532
1533fn system_now_timestamp_ms() -> i64 {
1534 let duration = SystemTime::now()
1535 .duration_since(UNIX_EPOCH)
1536 .unwrap_or_default();
1537 i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)
1538}
1539
1540#[cfg(test)]
1541#[path = "../../tests/schedule.rs"]
1542mod tests;