Skip to main content

rivetkit_core/actor/
schedule.rs

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		// The due rows are already claimed/advanced at this point. Alarm resync is
710		// best effort so a transient sync failure cannot discard valid dispatches.
711		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		// Croner advances nonexistent DST wall times to the end of the gap. V1
1119		// semantics skip that occurrence, so search again from the adjusted time.
1120		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;