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