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	pub(crate) fn abandon_pending_alarm_writes(&self) {
1022		// Startup runs inside envoy's actor-start callback, so actor-local alarm
1023		// acks cannot arrive until that callback returns. Invalidate their
1024		// persistence epoch and drop the completion waiters instead of creating a
1025		// circular wait during failed-startup cleanup.
1026		self.0
1027			.schedule_alarm_push_epoch
1028			.fetch_add(1, Ordering::SeqCst);
1029		self.0.schedule_pending_alarm_writes.lock().clear();
1030	}
1031
1032	fn set_alarm_tracked(
1033		&self,
1034		envoy_handle: EnvoyHandle,
1035		timestamp_ms: Option<i64>,
1036		generation: Option<u32>,
1037	) {
1038		let push_epoch = self
1039			.0
1040			.schedule_alarm_push_epoch
1041			.fetch_add(1, Ordering::SeqCst)
1042			.wrapping_add(1);
1043		let (ack_tx, ack_rx) = oneshot::channel();
1044		envoy_handle.set_alarm_with_ack(
1045			self.actor_id().to_owned(),
1046			timestamp_ms,
1047			generation,
1048			Some(ack_tx),
1049		);
1050		self.load_last_pushed_alarm(timestamp_ms);
1051		if let Ok(handle) = Handle::try_current() {
1052			let state_ctx = self.clone();
1053			let (persist_done_tx, persist_done_rx) = oneshot::channel();
1054			handle.spawn(
1055				async move {
1056					let _ = ack_rx.await;
1057					let is_current = state_ctx.0.schedule_alarm_push_epoch.load(Ordering::SeqCst)
1058						== push_epoch && state_ctx.last_pushed_alarm() == timestamp_ms;
1059					if is_current
1060						&& let Err(error) = state_ctx.persist_last_pushed_alarm(timestamp_ms).await
1061					{
1062						tracing::error!(
1063							?error,
1064							?timestamp_ms,
1065							"failed to persist last pushed actor alarm"
1066						);
1067					}
1068					let _ = persist_done_tx.send(());
1069				}
1070				.in_current_span(),
1071			);
1072			self.0
1073				.schedule_pending_alarm_writes
1074				.lock()
1075				.push(persist_done_rx);
1076			return;
1077		}
1078		self.0.schedule_pending_alarm_writes.lock().push(ack_rx);
1079	}
1080
1081	fn arm_local_alarm(&self, next_alarm: Option<i64>) {
1082		self.cancel_local_alarm_timeouts();
1083		let Some(next_alarm) = next_alarm else {
1084			return;
1085		};
1086		if self.0.schedule_local_alarm_callback.lock().is_none() {
1087			return;
1088		}
1089		#[cfg(not(feature = "wasm-runtime"))]
1090		let tokio_handle = match Handle::try_current() {
1091			Ok(handle) => handle,
1092			Err(_) => return,
1093		};
1094		let delay_ms = next_alarm
1095			.saturating_sub(self.schedule_now_timestamp_ms())
1096			.max(0) as u64;
1097		let local_alarm_epoch = self.0.schedule_local_alarm_epoch.load(Ordering::SeqCst);
1098		let schedule = self.clone();
1099		let task = async move {
1100			sleep(Duration::from_millis(delay_ms)).await;
1101			if schedule.0.schedule_local_alarm_epoch.load(Ordering::SeqCst) != local_alarm_epoch {
1102				return;
1103			}
1104			let Some(callback) = schedule.0.schedule_local_alarm_callback.lock().clone() else {
1105				return;
1106			};
1107			callback().await;
1108		}
1109		.in_current_span();
1110		#[cfg(not(feature = "wasm-runtime"))]
1111		let handle = tokio_handle.spawn(task);
1112		#[cfg(feature = "wasm-runtime")]
1113		let handle = {
1114			let (handle, registration) = AbortHandle::new_pair();
1115			// Public Wasm callbacks run on the wasm-bindgen executor, outside the
1116			// Tokio LocalSet that owns the actor loop. Keep callback-triggered
1117			// alarm timers on that same executor so they can be armed safely.
1118			wasm_bindgen_futures::spawn_local(async move {
1119				let _ = Abortable::new(task, registration).await;
1120			});
1121			handle
1122		};
1123		*self.0.schedule_local_alarm_task.lock() = Some(handle);
1124	}
1125
1126	pub(crate) fn suspend_alarm_dispatch(&self) {
1127		self.0
1128			.schedule_alarm_dispatch_enabled
1129			.store(false, Ordering::SeqCst);
1130	}
1131}
1132
1133fn validate_name(name: &str) -> Result<()> {
1134	let reason = if name.is_empty() {
1135		Some("must not be empty")
1136	} else if name.len() > 128 {
1137		Some("must be at most 128 UTF-8 bytes")
1138	} else {
1139		None
1140	};
1141	match reason {
1142		Some(reason) => Err(ScheduleRuntimeError::InvalidName {
1143			reason: reason.to_owned(),
1144		}
1145		.build()),
1146		None => Ok(()),
1147	}
1148}
1149
1150fn validate_max_history(max_history: Option<i64>) -> Result<i64> {
1151	let max_history = max_history.unwrap_or(DEFAULT_MAX_HISTORY);
1152	if !(0..=MAX_HISTORY).contains(&max_history) {
1153		return Err(ScheduleRuntimeError::InvalidMaxHistory {
1154			max_history,
1155			maximum: MAX_HISTORY,
1156		}
1157		.build());
1158	}
1159	Ok(max_history)
1160}
1161
1162fn parse_timezone(timezone: &str) -> Result<Tz> {
1163	timezone.parse().map_err(|_| {
1164		ScheduleRuntimeError::InvalidTimezone {
1165			timezone: timezone.to_owned(),
1166		}
1167		.build()
1168	})
1169}
1170
1171fn parse_cron(expression: &str) -> Result<Cron> {
1172	if expression.split_whitespace().count() != 5 {
1173		return Err(ScheduleRuntimeError::InvalidCronExpression {
1174			reason: "expected exactly five fields".to_owned(),
1175		}
1176		.build());
1177	}
1178	Cron::new(expression).parse().map_err(|error| {
1179		ScheduleRuntimeError::InvalidCronExpression {
1180			reason: error.to_string(),
1181		}
1182		.build()
1183	})
1184}
1185
1186fn next_cron_timestamp_ms(cron: &Cron, timezone: Tz, after_ms: i64) -> Result<i64> {
1187	let after_utc = DateTime::<Utc>::from_timestamp_millis(after_ms).ok_or_else(|| {
1188		ScheduleRuntimeError::InvalidCronExpression {
1189			reason: "start time is outside the supported range".to_owned(),
1190		}
1191		.build()
1192	})?;
1193	let mut cursor = after_utc.with_timezone(&timezone);
1194	loop {
1195		let next = cron.find_next_occurrence(&cursor, false).map_err(|error| {
1196			ScheduleRuntimeError::InvalidCronExpression {
1197				reason: error.to_string(),
1198			}
1199			.build()
1200		})?;
1201		if cron.is_time_matching(&next).unwrap_or(false) {
1202			return Ok(next.timestamp_millis());
1203		}
1204		// Croner advances nonexistent DST wall times to the end of the gap. V1
1205		// semantics skip that occurrence, so search again from the adjusted time.
1206		cursor = next;
1207	}
1208}
1209
1210fn next_recurring_trigger(event: &StoredSchedule, now_ms: i64) -> Result<i64> {
1211	match event.kind {
1212		ScheduleKind::Cron => {
1213			let expression = event
1214				.cron_expression
1215				.as_deref()
1216				.ok_or_else(|| invalid_row(&event.event_id, "cron expression is missing"))?;
1217			let timezone = event
1218				.timezone
1219				.as_deref()
1220				.ok_or_else(|| invalid_row(&event.event_id, "timezone is missing"))?;
1221			next_cron_timestamp_ms(&parse_cron(expression)?, parse_timezone(timezone)?, now_ms)
1222		}
1223		ScheduleKind::Every => {
1224			let interval_ms = event
1225				.interval_ms
1226				.ok_or_else(|| invalid_row(&event.event_id, "interval is missing"))?;
1227			if interval_ms < MIN_INTERVAL_MS {
1228				return Err(invalid_row(&event.event_id, "interval is below minimum"));
1229			}
1230			let elapsed = now_ms.saturating_sub(event.trigger_at).max(0);
1231			let steps = elapsed / interval_ms + 1;
1232			Ok(event
1233				.trigger_at
1234				.saturating_add(interval_ms.saturating_mul(steps)))
1235		}
1236		ScheduleKind::At => Err(invalid_row(
1237			&event.event_id,
1238			"one-shot passed to recurring calculation",
1239		)),
1240	}
1241}
1242
1243fn append_history_statements(
1244	statements: &mut Vec<SqliteBatchStatement>,
1245	event: &StoredSchedule,
1246	now_ms: i64,
1247	result: i64,
1248	prune_global_history: bool,
1249) -> Result<Option<usize>> {
1250	if event.max_history == 0 {
1251		return Ok(None);
1252	}
1253	let index = statements.len();
1254	statements.push(SqliteBatchStatement {
1255		sql: INSERT_SCHEDULE_HISTORY_SQL.to_owned(),
1256		params: Some(vec![
1257			BindParam::Text(event.event_id.clone()),
1258			BindParam::Text(event.action.clone()),
1259			BindParam::Integer(event.trigger_at),
1260			BindParam::Integer(now_ms),
1261			if result == HISTORY_SKIPPED {
1262				BindParam::Integer(now_ms)
1263			} else {
1264				BindParam::Null
1265			},
1266			BindParam::Integer(result),
1267			BindParam::Null,
1268			BindParam::Null,
1269			BindParam::Null,
1270			BindParam::Null,
1271		]),
1272	});
1273	statements.extend(history_prune_statements(
1274		&event.event_id,
1275		event.max_history,
1276		prune_global_history,
1277	));
1278	Ok(Some(index))
1279}
1280
1281fn history_prune_statements(
1282	event_id: &str,
1283	max_history: i64,
1284	prune_global_history: bool,
1285) -> Vec<SqliteBatchStatement> {
1286	let mut statements = vec![SqliteBatchStatement {
1287		sql: PRUNE_SCHEDULE_HISTORY_SQL.to_owned(),
1288		params: Some(vec![
1289			BindParam::Text(event_id.to_owned()),
1290			BindParam::Integer(max_history),
1291		]),
1292	}];
1293	if prune_global_history {
1294		statements.push(SqliteBatchStatement {
1295			sql: PRUNE_GLOBAL_HISTORY_SQL.to_owned(),
1296			params: Some(vec![BindParam::Integer(GLOBAL_HISTORY_RETAINED_ROWS)]),
1297		});
1298	}
1299	statements
1300}
1301
1302fn stored_to_cron_info(event: StoredSchedule) -> Result<CronJobInfo> {
1303	if event.kind == ScheduleKind::At {
1304		return Err(invalid_row(&event.event_id, "expected recurring schedule"));
1305	}
1306	Ok(CronJobInfo {
1307		name: cron_name(&event.event_id)?.to_owned(),
1308		kind: event.kind,
1309		action: event.action,
1310		args: event.args,
1311		next_run_at: event.trigger_at,
1312		last_run_at: event.last_started_at,
1313		expression: event.cron_expression,
1314		timezone: event.timezone,
1315		interval_ms: event.interval_ms,
1316		max_history: event.max_history,
1317	})
1318}
1319
1320fn read_stored_schedule(row: &[ColumnValue]) -> Result<StoredSchedule> {
1321	let event_id = read_text(row, 0, "event_id")?;
1322	let kind = ScheduleKind::parse(read_i64(row, 4, "kind")?, &event_id)?;
1323	let event = StoredSchedule {
1324		event_id: event_id.clone(),
1325		trigger_at: read_i64(row, 1, "trigger_at")?,
1326		action: read_text(row, 2, "action")?,
1327		args: read_optional_blob(row, 3, "args")?.unwrap_or_default(),
1328		kind,
1329		cron_expression: read_optional_text(row, 5, "cron_expression")?,
1330		timezone: read_optional_text(row, 6, "timezone")?,
1331		interval_ms: read_optional_i64(row, 7, "interval_ms")?,
1332		last_started_at: read_optional_i64(row, 8, "last_started_at")?,
1333		max_history: read_i64(row, 9, "max_history")?,
1334	};
1335	match event.kind {
1336		ScheduleKind::At
1337			if event.cron_expression.is_some()
1338				|| event.timezone.is_some()
1339				|| event.interval_ms.is_some()
1340				|| event.max_history != 0 =>
1341		{
1342			Err(invalid_row(&event_id, "invalid one-shot columns"))
1343		}
1344		ScheduleKind::Cron
1345			if event.cron_expression.is_none()
1346				|| event.timezone.is_none()
1347				|| event.interval_ms.is_some() =>
1348		{
1349			Err(invalid_row(&event_id, "invalid cron columns"))
1350		}
1351		ScheduleKind::Every
1352			if event.cron_expression.is_some()
1353				|| event.timezone.is_some()
1354				|| event
1355					.interval_ms
1356					.is_none_or(|value| value < MIN_INTERVAL_MS) =>
1357		{
1358			Err(invalid_row(&event_id, "invalid interval columns"))
1359		}
1360		_ if !(0..=MAX_HISTORY).contains(&event.max_history) => {
1361			Err(invalid_row(&event_id, "max_history is out of range"))
1362		}
1363		_ => Ok(event),
1364	}
1365}
1366
1367fn read_cron_fire(row: &[ColumnValue]) -> Result<CronFire> {
1368	let error_group = read_optional_text(row, 5, "error_group")?;
1369	let error_code = read_optional_text(row, 6, "error_code")?;
1370	let error_message = read_optional_text(row, 7, "error_message")?;
1371	let error_metadata = read_optional_blob(row, 8, "error_metadata")?
1372		.map(|value| decode_error_metadata(&value))
1373		.transpose()?;
1374	let error = match (error_group, error_code, error_message) {
1375		(None, None, None) => None,
1376		(Some(group), Some(code), Some(message)) => Some(ScheduleErrorInfo {
1377			group,
1378			code,
1379			message,
1380			metadata: error_metadata,
1381		}),
1382		_ => return Err(invalid_row("<history>", "error columns are incomplete")),
1383	};
1384	Ok(CronFire {
1385		action: read_text(row, 0, "action")?,
1386		scheduled_at: read_i64(row, 1, "scheduled_at")?,
1387		fired_at: read_i64(row, 2, "fired_at")?,
1388		finished_at: read_optional_i64(row, 3, "finished_at")?,
1389		result: history_result_name(read_i64(row, 4, "result")?)?.to_owned(),
1390		error,
1391	})
1392}
1393
1394fn sanitize_error(error: &anyhow::Error) -> ScheduleErrorInfo {
1395	let extracted = RivetError::extract(error);
1396	let metadata = extracted.metadata();
1397	ScheduleErrorInfo {
1398		group: extracted.group().to_owned(),
1399		code: extracted.code().to_owned(),
1400		message: client_error_message(extracted.group(), extracted.code(), extracted.message())
1401			.to_owned(),
1402		metadata: client_error_metadata(extracted.group(), extracted.code(), metadata.as_ref())
1403			.cloned(),
1404	}
1405}
1406
1407fn encode_error_metadata(metadata: &serde_json::Value) -> Result<Vec<u8>> {
1408	let mut output = Vec::new();
1409	ciborium::into_writer(metadata, &mut output)
1410		.context("encode schedule history error metadata")?;
1411	Ok(output)
1412}
1413
1414fn decode_error_metadata(value: &[u8]) -> Result<serde_json::Value> {
1415	ciborium::from_reader(value).context("decode schedule history error metadata")
1416}
1417
1418fn history_result_name(value: i64) -> Result<&'static str> {
1419	match value {
1420		HISTORY_RUNNING => Ok("running"),
1421		HISTORY_OK => Ok("ok"),
1422		HISTORY_ERROR => Ok("error"),
1423		HISTORY_SKIPPED => Ok("skipped"),
1424		_ => Err(invalid_row("<history>", "unknown result")),
1425	}
1426}
1427
1428fn cron_event_id(name: &str) -> String {
1429	format!("{CRON_ID_PREFIX}{name}")
1430}
1431
1432fn cron_name(event_id: &str) -> Result<&str> {
1433	event_id
1434		.strip_prefix(CRON_ID_PREFIX)
1435		.ok_or_else(|| invalid_row(event_id, "recurring id is missing cron prefix"))
1436}
1437
1438fn invalid_row(schedule_id: &str, reason: &str) -> anyhow::Error {
1439	ScheduleRuntimeError::InvalidScheduleRow {
1440		schedule_id: schedule_id.to_owned(),
1441		reason: reason.to_owned(),
1442	}
1443	.build()
1444}
1445
1446fn args_param(args: &[u8]) -> BindParam {
1447	if args.is_empty() {
1448		BindParam::Null
1449	} else {
1450		BindParam::Blob(args.to_vec())
1451	}
1452}
1453
1454fn optional_text_param(value: Option<&str>) -> BindParam {
1455	value
1456		.map(|value| BindParam::Text(value.to_owned()))
1457		.unwrap_or(BindParam::Null)
1458}
1459
1460fn optional_owned_text_param(value: Option<String>) -> BindParam {
1461	value.map(BindParam::Text).unwrap_or(BindParam::Null)
1462}
1463
1464fn optional_i64_param(value: Option<i64>) -> BindParam {
1465	value.map(BindParam::Integer).unwrap_or(BindParam::Null)
1466}
1467
1468fn read_text(row: &[ColumnValue], index: usize, label: &str) -> Result<String> {
1469	match row.get(index) {
1470		Some(ColumnValue::Text(value)) => Ok(value.clone()),
1471		_ => Err(invalid_row("<decode>", &format!("{label} is not text"))),
1472	}
1473}
1474
1475fn read_optional_text(row: &[ColumnValue], index: usize, label: &str) -> Result<Option<String>> {
1476	match row.get(index) {
1477		Some(ColumnValue::Null) | None => Ok(None),
1478		Some(ColumnValue::Text(value)) => Ok(Some(value.clone())),
1479		_ => Err(invalid_row("<decode>", &format!("{label} is not text"))),
1480	}
1481}
1482
1483fn read_i64(row: &[ColumnValue], index: usize, label: &str) -> Result<i64> {
1484	match row.get(index) {
1485		Some(ColumnValue::Integer(value)) => Ok(*value),
1486		_ => Err(invalid_row(
1487			"<decode>",
1488			&format!("{label} is not an integer"),
1489		)),
1490	}
1491}
1492
1493fn read_optional_i64(row: &[ColumnValue], index: usize, label: &str) -> Result<Option<i64>> {
1494	match row.get(index) {
1495		Some(ColumnValue::Null) | None => Ok(None),
1496		Some(ColumnValue::Integer(value)) => Ok(Some(*value)),
1497		_ => Err(invalid_row(
1498			"<decode>",
1499			&format!("{label} is not an integer"),
1500		)),
1501	}
1502}
1503
1504fn read_optional_blob(row: &[ColumnValue], index: usize, label: &str) -> Result<Option<Vec<u8>>> {
1505	match row.get(index) {
1506		Some(ColumnValue::Null) | None => Ok(None),
1507		Some(ColumnValue::Blob(value)) => Ok(Some(value.clone())),
1508		_ => Err(invalid_row("<decode>", &format!("{label} is not a blob"))),
1509	}
1510}
1511
1512fn system_now_timestamp_ms() -> i64 {
1513	let duration = SystemTime::now()
1514		.duration_since(UNIX_EPOCH)
1515		.unwrap_or_default();
1516	i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)
1517}
1518
1519#[cfg(test)]
1520#[path = "../../tests/schedule.rs"]
1521mod tests;