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