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