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