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	/// Forces the next alarm sync to push to the engine even when it matches the
838	/// persisted `last_pushed_alarm`. A new generation cannot know whether the
839	/// engine still holds that alarm, because a push can be acked locally and
840	/// then lost during a drain or generation change, and the engine clears its
841	/// alarm once it fires. The marker only dedupes pushes within a generation.
842	pub(crate) fn force_next_alarm_push(&self) {
843		self.mark_schedule_dirty();
844		self.load_last_pushed_alarm(None);
845	}
846
847	/// Sets the durable Unix-millisecond deadline that should ensure the foreign
848	/// run handler is active, starting it if inactive. This deadline shares one
849	/// physical alarm with scheduled actions, but remains independently
850	/// addressable and clearable.
851	pub async fn set_run_wake_at(&self, wake_at: Option<i64>) -> Result<()> {
852		let _mutation = self.0.schedule_mutation_lock.lock().await;
853		self.persist_run_wake_at(wake_at).await?;
854		self.0.run_wake_revision.fetch_add(1, Ordering::SeqCst);
855		self.mark_schedule_dirty();
856		self.sync_alarm().await
857	}
858
859	pub(crate) async fn consume_due_run_wake(&self) -> Result<Option<(i64, u64)>> {
860		let _mutation = self.0.schedule_mutation_lock.lock().await;
861		let Some(wake_at) = self.run_wake_at() else {
862			return Ok(None);
863		};
864		if wake_at > self.schedule_now_timestamp_ms() {
865			return Ok(None);
866		}
867		self.persist_run_wake_at(None).await?;
868		let revision = self.0.run_wake_revision.fetch_add(1, Ordering::SeqCst) + 1;
869		self.mark_schedule_dirty();
870		Ok(Some((wake_at, revision)))
871	}
872
873	#[doc(hidden)]
874	pub async fn restore_run_wake_at_if_unchanged(
875		&self,
876		wake_at: i64,
877		consumed_revision: u64,
878	) -> Result<bool> {
879		let _mutation = self.0.schedule_mutation_lock.lock().await;
880		if self.0.run_wake_revision.load(Ordering::SeqCst) != consumed_revision {
881			return Ok(false);
882		}
883		self.persist_run_wake_at(Some(wake_at)).await?;
884		self.0.run_wake_revision.fetch_add(1, Ordering::SeqCst);
885		self.mark_schedule_dirty();
886		self.sync_alarm().await?;
887		Ok(true)
888	}
889
890	async fn next_schedule_timestamp(&self, future_only: bool) -> Result<Option<i64>> {
891		let (sql, params) = if future_only {
892			(
893				NEXT_FUTURE_SCHEDULE_SQL,
894				Some(vec![BindParam::Integer(self.schedule_now_timestamp_ms())]),
895			)
896		} else {
897			(NEXT_SCHEDULE_SQL, None)
898		};
899		let result = self.sql().query(sql, params).await?;
900		match result.rows.first().and_then(|row| row.first()) {
901			None | Some(ColumnValue::Null) => Ok(None),
902			Some(ColumnValue::Integer(timestamp)) => Ok(Some(*timestamp)),
903			Some(_) => Err(ScheduleRuntimeError::InvalidScheduleRow {
904				schedule_id: "<minimum>".to_owned(),
905				reason: "MIN(trigger_at) was not an integer".to_owned(),
906			}
907			.build()),
908		}
909	}
910
911	async fn sync_alarm(&self) -> Result<()> {
912		#[cfg(test)]
913		if self
914			.0
915			.schedule_sync_alarm_failures
916			.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| {
917				remaining.checked_sub(1)
918			})
919			.is_ok()
920		{
921			anyhow::bail!("injected schedule alarm sync failure");
922		}
923		let next_alarm = min_deadline(
924			self.next_schedule_timestamp(false).await?,
925			self.run_wake_at(),
926		);
927		self.sync_alarm_timestamp(next_alarm)
928	}
929
930	async fn sync_future_alarm(&self) -> Result<()> {
931		let next_alarm = min_deadline(
932			self.next_schedule_timestamp(true).await?,
933			self.run_wake_at(),
934		);
935		self.sync_alarm_timestamp(next_alarm)
936	}
937
938	fn sync_alarm_timestamp(&self, next_alarm: Option<i64>) -> Result<()> {
939		let should_push = self
940			.0
941			.schedule_dirty_since_push
942			.swap(false, Ordering::SeqCst);
943		self.arm_local_alarm(next_alarm);
944		if !should_push {
945			return Ok(());
946		}
947		if next_alarm.is_some() && self.last_pushed_alarm() == next_alarm {
948			return Ok(());
949		}
950		let Some(envoy_handle) = self.0.schedule_envoy_handle.lock().clone() else {
951			self.mark_schedule_dirty();
952			tracing::warn!(
953				actor_id = self.actor_id(),
954				"schedule alarm sync skipped because envoy handle is not configured"
955			);
956			return Ok(());
957		};
958		let generation = *self.0.schedule_generation.lock();
959		self.set_alarm_tracked(envoy_handle, next_alarm, generation);
960		Ok(())
961	}
962
963	pub(crate) async fn sync_alarm_logged(&self) {
964		if let Err(error) = self.sync_alarm().await {
965			tracing::error!(actor_id = %self.actor_id(), ?error, "failed to sync scheduled actor alarm");
966		}
967	}
968
969	pub(crate) async fn sync_future_alarm_logged(&self) {
970		if let Err(error) = self.sync_future_alarm().await {
971			tracing::error!(actor_id = %self.actor_id(), ?error, "failed to sync future scheduled actor alarm");
972		}
973	}
974
975	pub(crate) fn set_schedule_alarm(&self, timestamp_ms: Option<i64>) -> Result<()> {
976		let envoy_handle = self.0.schedule_envoy_handle.lock().clone().ok_or_else(|| {
977			crate::error::ActorRuntime::NotConfigured {
978				component: "schedule alarm handle".to_owned(),
979			}
980			.build()
981		})?;
982		let generation = *self.0.schedule_generation.lock();
983		self.set_alarm_tracked(envoy_handle, timestamp_ms, generation);
984		Ok(())
985	}
986
987	pub(crate) fn configure_schedule_envoy(
988		&self,
989		envoy_handle: EnvoyHandle,
990		generation: Option<u32>,
991	) {
992		*self.0.schedule_envoy_handle.lock() = Some(envoy_handle);
993		*self.0.schedule_generation.lock() = generation;
994	}
995
996	pub(crate) fn set_internal_keep_awake(&self, callback: Option<InternalKeepAwakeCallback>) {
997		*self.0.schedule_internal_keep_awake.lock() = callback;
998	}
999
1000	pub(crate) fn set_local_alarm_callback(&self, callback: Option<LocalAlarmCallback>) {
1001		*self.0.schedule_local_alarm_callback.lock() = callback;
1002	}
1003
1004	pub(crate) fn cancel_local_alarm_timeouts(&self) {
1005		self.0
1006			.schedule_local_alarm_epoch
1007			.fetch_add(1, Ordering::SeqCst);
1008		if let Some(handle) = self.0.schedule_local_alarm_task.lock().take() {
1009			handle.abort();
1010		}
1011	}
1012
1013	pub(crate) fn cancel_driver_alarm_logged(&self) {
1014		self.cancel_local_alarm_timeouts();
1015		#[cfg(test)]
1016		self.0
1017			.schedule_driver_alarm_cancel_count
1018			.fetch_add(1, Ordering::SeqCst);
1019		let Some(envoy_handle) = self.0.schedule_envoy_handle.lock().clone() else {
1020			return;
1021		};
1022		let generation = *self.0.schedule_generation.lock();
1023		self.set_alarm_tracked(envoy_handle, None, generation);
1024	}
1025
1026	#[cfg(test)]
1027	pub(crate) fn test_driver_alarm_cancel_count(&self) -> usize {
1028		self.0
1029			.schedule_driver_alarm_cancel_count
1030			.load(Ordering::SeqCst)
1031	}
1032
1033	#[cfg(test)]
1034	pub(crate) fn fail_next_schedule_alarm_sync_for_tests(&self) {
1035		self.0
1036			.schedule_sync_alarm_failures
1037			.fetch_add(1, Ordering::SeqCst);
1038	}
1039
1040	pub(crate) async fn wait_for_pending_alarm_writes(&self) {
1041		let pending = {
1042			let mut guard = self.0.schedule_pending_alarm_writes.lock();
1043			std::mem::take(&mut *guard)
1044		};
1045		for ack_rx in pending {
1046			let _ = ack_rx.await;
1047		}
1048	}
1049
1050	pub(crate) fn abandon_pending_alarm_writes(&self) {
1051		// Startup runs inside envoy's actor-start callback, so actor-local alarm
1052		// acks cannot arrive until that callback returns. Invalidate their
1053		// persistence epoch and drop the completion waiters instead of creating a
1054		// circular wait during failed-startup cleanup.
1055		self.0
1056			.schedule_alarm_push_epoch
1057			.fetch_add(1, Ordering::SeqCst);
1058		self.0.schedule_pending_alarm_writes.lock().clear();
1059	}
1060
1061	fn set_alarm_tracked(
1062		&self,
1063		envoy_handle: EnvoyHandle,
1064		timestamp_ms: Option<i64>,
1065		generation: Option<u32>,
1066	) {
1067		let push_epoch = self
1068			.0
1069			.schedule_alarm_push_epoch
1070			.fetch_add(1, Ordering::SeqCst)
1071			.wrapping_add(1);
1072		let (ack_tx, ack_rx) = oneshot::channel();
1073		envoy_handle.set_alarm_with_ack(
1074			self.actor_id().to_owned(),
1075			timestamp_ms,
1076			generation,
1077			Some(ack_tx),
1078		);
1079		self.load_last_pushed_alarm(timestamp_ms);
1080		if let Ok(handle) = Handle::try_current() {
1081			let state_ctx = self.clone();
1082			let (persist_done_tx, persist_done_rx) = oneshot::channel();
1083			handle.spawn(
1084				async move {
1085					let _ = ack_rx.await;
1086					let is_current = state_ctx.0.schedule_alarm_push_epoch.load(Ordering::SeqCst)
1087						== push_epoch && state_ctx.last_pushed_alarm() == timestamp_ms;
1088					if is_current
1089						&& let Err(error) = state_ctx.persist_last_pushed_alarm(timestamp_ms).await
1090					{
1091						tracing::error!(
1092							?error,
1093							?timestamp_ms,
1094							"failed to persist last pushed actor alarm"
1095						);
1096					}
1097					let _ = persist_done_tx.send(());
1098				}
1099				.in_current_span(),
1100			);
1101			self.0
1102				.schedule_pending_alarm_writes
1103				.lock()
1104				.push(persist_done_rx);
1105			return;
1106		}
1107		self.0.schedule_pending_alarm_writes.lock().push(ack_rx);
1108	}
1109
1110	fn arm_local_alarm(&self, next_alarm: Option<i64>) {
1111		self.cancel_local_alarm_timeouts();
1112		let Some(next_alarm) = next_alarm else {
1113			return;
1114		};
1115		if self.0.schedule_local_alarm_callback.lock().is_none() {
1116			return;
1117		}
1118		#[cfg(not(feature = "wasm-runtime"))]
1119		let tokio_handle = match Handle::try_current() {
1120			Ok(handle) => handle,
1121			Err(_) => return,
1122		};
1123		let delay_ms = next_alarm
1124			.saturating_sub(self.schedule_now_timestamp_ms())
1125			.max(0) as u64;
1126		let local_alarm_epoch = self.0.schedule_local_alarm_epoch.load(Ordering::SeqCst);
1127		let schedule = self.clone();
1128		let task = async move {
1129			sleep(Duration::from_millis(delay_ms)).await;
1130			if schedule.0.schedule_local_alarm_epoch.load(Ordering::SeqCst) != local_alarm_epoch {
1131				return;
1132			}
1133			let Some(callback) = schedule.0.schedule_local_alarm_callback.lock().clone() else {
1134				return;
1135			};
1136			callback().await;
1137		}
1138		.in_current_span();
1139		#[cfg(not(feature = "wasm-runtime"))]
1140		let handle = tokio_handle.spawn(task);
1141		#[cfg(feature = "wasm-runtime")]
1142		let handle = {
1143			let (handle, registration) = AbortHandle::new_pair();
1144			// Public Wasm callbacks run on the wasm-bindgen executor, outside the
1145			// Tokio LocalSet that owns the actor loop. Keep callback-triggered
1146			// alarm timers on that same executor so they can be armed safely.
1147			wasm_bindgen_futures::spawn_local(async move {
1148				let _ = Abortable::new(task, registration).await;
1149			});
1150			handle
1151		};
1152		*self.0.schedule_local_alarm_task.lock() = Some(handle);
1153	}
1154
1155	pub(crate) fn suspend_alarm_dispatch(&self) {
1156		self.0
1157			.schedule_alarm_dispatch_enabled
1158			.store(false, Ordering::SeqCst);
1159	}
1160}
1161
1162fn validate_name(name: &str) -> Result<()> {
1163	let reason = if name.is_empty() {
1164		Some("must not be empty")
1165	} else if name.len() > 128 {
1166		Some("must be at most 128 UTF-8 bytes")
1167	} else {
1168		None
1169	};
1170	match reason {
1171		Some(reason) => Err(ScheduleRuntimeError::InvalidName {
1172			reason: reason.to_owned(),
1173		}
1174		.build()),
1175		None => Ok(()),
1176	}
1177}
1178
1179fn validate_max_history(max_history: Option<i64>) -> Result<i64> {
1180	let max_history = max_history.unwrap_or(DEFAULT_MAX_HISTORY);
1181	if !(0..=MAX_HISTORY).contains(&max_history) {
1182		return Err(ScheduleRuntimeError::InvalidMaxHistory {
1183			max_history,
1184			maximum: MAX_HISTORY,
1185		}
1186		.build());
1187	}
1188	Ok(max_history)
1189}
1190
1191fn parse_timezone(timezone: &str) -> Result<Tz> {
1192	timezone.parse().map_err(|_| {
1193		ScheduleRuntimeError::InvalidTimezone {
1194			timezone: timezone.to_owned(),
1195		}
1196		.build()
1197	})
1198}
1199
1200fn parse_cron(expression: &str) -> Result<Cron> {
1201	if expression.split_whitespace().count() != 5 {
1202		return Err(ScheduleRuntimeError::InvalidCronExpression {
1203			reason: "expected exactly five fields".to_owned(),
1204		}
1205		.build());
1206	}
1207	Cron::new(expression).parse().map_err(|error| {
1208		ScheduleRuntimeError::InvalidCronExpression {
1209			reason: error.to_string(),
1210		}
1211		.build()
1212	})
1213}
1214
1215fn next_cron_timestamp_ms(cron: &Cron, timezone: Tz, after_ms: i64) -> Result<i64> {
1216	let after_utc = DateTime::<Utc>::from_timestamp_millis(after_ms).ok_or_else(|| {
1217		ScheduleRuntimeError::InvalidCronExpression {
1218			reason: "start time is outside the supported range".to_owned(),
1219		}
1220		.build()
1221	})?;
1222	let mut cursor = after_utc.with_timezone(&timezone);
1223	loop {
1224		let next = cron.find_next_occurrence(&cursor, false).map_err(|error| {
1225			ScheduleRuntimeError::InvalidCronExpression {
1226				reason: error.to_string(),
1227			}
1228			.build()
1229		})?;
1230		if cron.is_time_matching(&next).unwrap_or(false) {
1231			return Ok(next.timestamp_millis());
1232		}
1233		// Croner advances nonexistent DST wall times to the end of the gap. V1
1234		// semantics skip that occurrence, so search again from the adjusted time.
1235		cursor = next;
1236	}
1237}
1238
1239fn next_recurring_trigger(event: &StoredSchedule, now_ms: i64) -> Result<i64> {
1240	match event.kind {
1241		ScheduleKind::Cron => {
1242			let expression = event
1243				.cron_expression
1244				.as_deref()
1245				.ok_or_else(|| invalid_row(&event.event_id, "cron expression is missing"))?;
1246			let timezone = event
1247				.timezone
1248				.as_deref()
1249				.ok_or_else(|| invalid_row(&event.event_id, "timezone is missing"))?;
1250			next_cron_timestamp_ms(&parse_cron(expression)?, parse_timezone(timezone)?, now_ms)
1251		}
1252		ScheduleKind::Every => {
1253			let interval_ms = event
1254				.interval_ms
1255				.ok_or_else(|| invalid_row(&event.event_id, "interval is missing"))?;
1256			if interval_ms < MIN_INTERVAL_MS {
1257				return Err(invalid_row(&event.event_id, "interval is below minimum"));
1258			}
1259			let elapsed = now_ms.saturating_sub(event.trigger_at).max(0);
1260			let steps = elapsed / interval_ms + 1;
1261			Ok(event
1262				.trigger_at
1263				.saturating_add(interval_ms.saturating_mul(steps)))
1264		}
1265		ScheduleKind::At => Err(invalid_row(
1266			&event.event_id,
1267			"one-shot passed to recurring calculation",
1268		)),
1269	}
1270}
1271
1272fn append_history_statements(
1273	statements: &mut Vec<SqliteBatchStatement>,
1274	event: &StoredSchedule,
1275	now_ms: i64,
1276	result: i64,
1277	prune_global_history: bool,
1278) -> Result<Option<usize>> {
1279	if event.max_history == 0 {
1280		return Ok(None);
1281	}
1282	let index = statements.len();
1283	statements.push(SqliteBatchStatement {
1284		sql: INSERT_SCHEDULE_HISTORY_SQL.to_owned(),
1285		params: Some(vec![
1286			BindParam::Text(event.event_id.clone()),
1287			BindParam::Text(event.action.clone()),
1288			BindParam::Integer(event.trigger_at),
1289			BindParam::Integer(now_ms),
1290			if result == HISTORY_SKIPPED {
1291				BindParam::Integer(now_ms)
1292			} else {
1293				BindParam::Null
1294			},
1295			BindParam::Integer(result),
1296			BindParam::Null,
1297			BindParam::Null,
1298			BindParam::Null,
1299			BindParam::Null,
1300		]),
1301	});
1302	statements.extend(history_prune_statements(
1303		&event.event_id,
1304		event.max_history,
1305		prune_global_history,
1306	));
1307	Ok(Some(index))
1308}
1309
1310fn history_prune_statements(
1311	event_id: &str,
1312	max_history: i64,
1313	prune_global_history: bool,
1314) -> Vec<SqliteBatchStatement> {
1315	let mut statements = vec![SqliteBatchStatement {
1316		sql: PRUNE_SCHEDULE_HISTORY_SQL.to_owned(),
1317		params: Some(vec![
1318			BindParam::Text(event_id.to_owned()),
1319			BindParam::Integer(max_history),
1320		]),
1321	}];
1322	if prune_global_history {
1323		statements.push(SqliteBatchStatement {
1324			sql: PRUNE_GLOBAL_HISTORY_SQL.to_owned(),
1325			params: Some(vec![BindParam::Integer(GLOBAL_HISTORY_RETAINED_ROWS)]),
1326		});
1327	}
1328	statements
1329}
1330
1331fn stored_to_cron_info(event: StoredSchedule) -> Result<CronJobInfo> {
1332	if event.kind == ScheduleKind::At {
1333		return Err(invalid_row(&event.event_id, "expected recurring schedule"));
1334	}
1335	Ok(CronJobInfo {
1336		name: cron_name(&event.event_id)?.to_owned(),
1337		kind: event.kind,
1338		action: event.action,
1339		args: event.args,
1340		next_run_at: event.trigger_at,
1341		last_run_at: event.last_started_at,
1342		expression: event.cron_expression,
1343		timezone: event.timezone,
1344		interval_ms: event.interval_ms,
1345		max_history: event.max_history,
1346	})
1347}
1348
1349fn read_stored_schedule(row: &[ColumnValue]) -> Result<StoredSchedule> {
1350	let event_id = read_text(row, 0, "event_id")?;
1351	let kind = ScheduleKind::parse(read_i64(row, 4, "kind")?, &event_id)?;
1352	let event = StoredSchedule {
1353		event_id: event_id.clone(),
1354		trigger_at: read_i64(row, 1, "trigger_at")?,
1355		action: read_text(row, 2, "action")?,
1356		args: read_optional_blob(row, 3, "args")?.unwrap_or_default(),
1357		kind,
1358		cron_expression: read_optional_text(row, 5, "cron_expression")?,
1359		timezone: read_optional_text(row, 6, "timezone")?,
1360		interval_ms: read_optional_i64(row, 7, "interval_ms")?,
1361		last_started_at: read_optional_i64(row, 8, "last_started_at")?,
1362		max_history: read_i64(row, 9, "max_history")?,
1363	};
1364	match event.kind {
1365		ScheduleKind::At
1366			if event.cron_expression.is_some()
1367				|| event.timezone.is_some()
1368				|| event.interval_ms.is_some()
1369				|| event.max_history != 0 =>
1370		{
1371			Err(invalid_row(&event_id, "invalid one-shot columns"))
1372		}
1373		ScheduleKind::Cron
1374			if event.cron_expression.is_none()
1375				|| event.timezone.is_none()
1376				|| event.interval_ms.is_some() =>
1377		{
1378			Err(invalid_row(&event_id, "invalid cron columns"))
1379		}
1380		ScheduleKind::Every
1381			if event.cron_expression.is_some()
1382				|| event.timezone.is_some()
1383				|| event
1384					.interval_ms
1385					.is_none_or(|value| value < MIN_INTERVAL_MS) =>
1386		{
1387			Err(invalid_row(&event_id, "invalid interval columns"))
1388		}
1389		_ if !(0..=MAX_HISTORY).contains(&event.max_history) => {
1390			Err(invalid_row(&event_id, "max_history is out of range"))
1391		}
1392		_ => Ok(event),
1393	}
1394}
1395
1396fn read_due_schedule(row: &[ColumnValue]) -> Result<(StoredSchedule, IncomingTraceContext)> {
1397	let event = read_stored_schedule(row)?;
1398	let trace_context = read_trace_context(row, 10, "schedule trace context")?;
1399	Ok((event, trace_context))
1400}
1401
1402fn read_cron_fire(row: &[ColumnValue]) -> Result<CronFire> {
1403	let error_group = read_optional_text(row, 5, "error_group")?;
1404	let error_code = read_optional_text(row, 6, "error_code")?;
1405	let error_message = read_optional_text(row, 7, "error_message")?;
1406	let error_metadata = read_optional_blob(row, 8, "error_metadata")?
1407		.map(|value| decode_error_metadata(&value))
1408		.transpose()?;
1409	let error = match (error_group, error_code, error_message) {
1410		(None, None, None) => None,
1411		(Some(group), Some(code), Some(message)) => Some(ScheduleErrorInfo {
1412			group,
1413			code,
1414			message,
1415			metadata: error_metadata,
1416		}),
1417		_ => return Err(invalid_row("<history>", "error columns are incomplete")),
1418	};
1419	Ok(CronFire {
1420		action: read_text(row, 0, "action")?,
1421		scheduled_at: read_i64(row, 1, "scheduled_at")?,
1422		fired_at: read_i64(row, 2, "fired_at")?,
1423		finished_at: read_optional_i64(row, 3, "finished_at")?,
1424		result: history_result_name(read_i64(row, 4, "result")?)?.to_owned(),
1425		error,
1426	})
1427}
1428
1429fn sanitize_error(error: &anyhow::Error) -> ScheduleErrorInfo {
1430	let extracted = RivetError::extract(error);
1431	let metadata = extracted.metadata();
1432	ScheduleErrorInfo {
1433		group: extracted.group().to_owned(),
1434		code: extracted.code().to_owned(),
1435		message: client_error_message(extracted.group(), extracted.code(), extracted.message())
1436			.to_owned(),
1437		metadata: client_error_metadata(extracted.group(), extracted.code(), metadata.as_ref())
1438			.cloned(),
1439	}
1440}
1441
1442fn encode_error_metadata(metadata: &serde_json::Value) -> Result<Vec<u8>> {
1443	let mut output = Vec::new();
1444	ciborium::into_writer(metadata, &mut output)
1445		.context("encode schedule history error metadata")?;
1446	Ok(output)
1447}
1448
1449fn decode_error_metadata(value: &[u8]) -> Result<serde_json::Value> {
1450	ciborium::from_reader(value).context("decode schedule history error metadata")
1451}
1452
1453fn history_result_name(value: i64) -> Result<&'static str> {
1454	match value {
1455		HISTORY_RUNNING => Ok("running"),
1456		HISTORY_OK => Ok("ok"),
1457		HISTORY_ERROR => Ok("error"),
1458		HISTORY_SKIPPED => Ok("skipped"),
1459		_ => Err(invalid_row("<history>", "unknown result")),
1460	}
1461}
1462
1463fn cron_event_id(name: &str) -> String {
1464	format!("{CRON_ID_PREFIX}{name}")
1465}
1466
1467fn cron_name(event_id: &str) -> Result<&str> {
1468	event_id
1469		.strip_prefix(CRON_ID_PREFIX)
1470		.ok_or_else(|| invalid_row(event_id, "recurring id is missing cron prefix"))
1471}
1472
1473fn invalid_row(schedule_id: &str, reason: &str) -> anyhow::Error {
1474	ScheduleRuntimeError::InvalidScheduleRow {
1475		schedule_id: schedule_id.to_owned(),
1476		reason: reason.to_owned(),
1477	}
1478	.build()
1479}
1480
1481fn args_param(args: &[u8]) -> BindParam {
1482	if args.is_empty() {
1483		BindParam::Null
1484	} else {
1485		BindParam::Blob(args.to_vec())
1486	}
1487}
1488
1489fn optional_text_param(value: Option<&str>) -> BindParam {
1490	value
1491		.map(|value| BindParam::Text(value.to_owned()))
1492		.unwrap_or(BindParam::Null)
1493}
1494
1495fn optional_i64_param(value: Option<i64>) -> BindParam {
1496	value.map(BindParam::Integer).unwrap_or(BindParam::Null)
1497}
1498
1499fn read_text(row: &[ColumnValue], index: usize, label: &str) -> Result<String> {
1500	match row.get(index) {
1501		Some(ColumnValue::Text(value)) => Ok(value.clone()),
1502		_ => Err(invalid_row("<decode>", &format!("{label} is not text"))),
1503	}
1504}
1505
1506fn read_optional_text(row: &[ColumnValue], index: usize, label: &str) -> Result<Option<String>> {
1507	match row.get(index) {
1508		Some(ColumnValue::Null) | None => Ok(None),
1509		Some(ColumnValue::Text(value)) => Ok(Some(value.clone())),
1510		_ => Err(invalid_row("<decode>", &format!("{label} is not text"))),
1511	}
1512}
1513
1514fn read_i64(row: &[ColumnValue], index: usize, label: &str) -> Result<i64> {
1515	match row.get(index) {
1516		Some(ColumnValue::Integer(value)) => Ok(*value),
1517		_ => Err(invalid_row(
1518			"<decode>",
1519			&format!("{label} is not an integer"),
1520		)),
1521	}
1522}
1523
1524fn read_optional_i64(row: &[ColumnValue], index: usize, label: &str) -> Result<Option<i64>> {
1525	match row.get(index) {
1526		Some(ColumnValue::Null) | None => Ok(None),
1527		Some(ColumnValue::Integer(value)) => Ok(Some(*value)),
1528		_ => Err(invalid_row(
1529			"<decode>",
1530			&format!("{label} is not an integer"),
1531		)),
1532	}
1533}
1534
1535fn read_optional_blob(row: &[ColumnValue], index: usize, label: &str) -> Result<Option<Vec<u8>>> {
1536	match row.get(index) {
1537		Some(ColumnValue::Null) | None => Ok(None),
1538		Some(ColumnValue::Blob(value)) => Ok(Some(value.clone())),
1539		_ => Err(invalid_row("<decode>", &format!("{label} is not a blob"))),
1540	}
1541}
1542
1543fn system_now_timestamp_ms() -> i64 {
1544	let duration = SystemTime::now()
1545		.duration_since(UNIX_EPOCH)
1546		.unwrap_or_default();
1547	i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)
1548}
1549
1550#[cfg(test)]
1551#[path = "../../tests/schedule.rs"]
1552mod tests;