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