Skip to main content

rivetkit_core/actor/
schedule.rs

1use std::sync::Arc;
2use std::sync::atomic::Ordering;
3use std::time::Duration;
4
5use crate::time::{SystemTime, UNIX_EPOCH, sleep};
6
7use anyhow::Result;
8use futures::future::BoxFuture;
9use rivet_envoy_client::handle::EnvoyHandle;
10use tokio::runtime::Handle;
11use tokio::sync::oneshot;
12use tracing::Instrument;
13use uuid::Uuid;
14
15use crate::actor::context::ActorContext;
16use crate::actor::state::PersistedScheduleEvent;
17use crate::error::ActorRuntime;
18#[cfg(feature = "wasm-runtime")]
19use crate::runtime::RuntimeSpawner;
20
21pub(super) type InternalKeepAwakeCallback =
22	Arc<dyn Fn(BoxFuture<'static, Result<()>>) -> BoxFuture<'static, Result<()>> + Send + Sync>;
23pub(super) type LocalAlarmCallback = Arc<dyn Fn() -> BoxFuture<'static, ()> + Send + Sync>;
24
25impl ActorContext {
26	#[cfg(test)]
27	pub(crate) fn new_for_schedule_tests(actor_id: impl Into<String>) -> Self {
28		Self::new(actor_id, "schedule-test", Vec::new(), "local")
29	}
30
31	pub fn after(&self, duration: Duration, action_name: &str, args: &[u8]) {
32		let duration_ms = i64::try_from(duration.as_millis()).unwrap_or(i64::MAX);
33		let timestamp_ms = now_timestamp_ms().saturating_add(duration_ms);
34		self.at(timestamp_ms, action_name, args);
35	}
36
37	pub fn at(&self, timestamp_ms: i64, action_name: &str, args: &[u8]) {
38		if let Err(error) = self.schedule_event(timestamp_ms, action_name, args) {
39			tracing::error!(
40				actor_id = %self.actor_id(),
41				?error,
42				action_name,
43				timestamp_ms,
44				"failed to schedule actor event"
45			);
46		}
47	}
48
49	pub(crate) fn set_schedule_alarm(&self, timestamp_ms: Option<i64>) -> Result<()> {
50		let envoy_handle = self.0.schedule_envoy_handle.lock().clone().ok_or_else(|| {
51			ActorRuntime::NotConfigured {
52				component: "schedule alarm handle".to_owned(),
53			}
54			.build()
55		})?;
56		let generation = *self.0.schedule_generation.lock();
57		self.set_alarm_tracked(envoy_handle, timestamp_ms, generation);
58		Ok(())
59	}
60
61	pub(crate) fn configure_schedule_envoy(
62		&self,
63		envoy_handle: EnvoyHandle,
64		generation: Option<u32>,
65	) {
66		*self.0.schedule_envoy_handle.lock() = Some(envoy_handle);
67		*self.0.schedule_generation.lock() = generation;
68	}
69
70	pub(crate) fn set_internal_keep_awake(&self, callback: Option<InternalKeepAwakeCallback>) {
71		*self.0.schedule_internal_keep_awake.lock() = callback;
72	}
73
74	pub(crate) fn set_local_alarm_callback(&self, callback: Option<LocalAlarmCallback>) {
75		*self.0.schedule_local_alarm_callback.lock() = callback;
76	}
77
78	pub(crate) fn cancel_scheduled_event(&self, event_id: &str) -> bool {
79		let removed = self.update_scheduled_events(|events| {
80			let before = events.len();
81			events.retain(|event| event.event_id != event_id);
82			before != events.len()
83		});
84
85		if removed {
86			tracing::debug!(
87				actor_id = %self.actor_id(),
88				event_id,
89				"scheduled actor event cancelled"
90			);
91			self.mark_dirty_since_push();
92			self.persist_scheduled_events("schedule_cancel");
93			self.sync_alarm_logged();
94		}
95
96		removed
97	}
98
99	pub(crate) fn next_event(&self) -> Option<PersistedScheduleEvent> {
100		self.scheduled_events().into_iter().next()
101	}
102
103	pub(crate) fn all_events(&self) -> Vec<PersistedScheduleEvent> {
104		self.scheduled_events()
105	}
106
107	pub(crate) fn cancel_local_alarm_timeouts(&self) {
108		self.0
109			.schedule_local_alarm_epoch
110			.fetch_add(1, Ordering::SeqCst);
111		if let Some(handle) = self.0.schedule_local_alarm_task.lock().take() {
112			handle.abort();
113		}
114	}
115
116	pub(crate) fn cancel_driver_alarm_logged(&self) {
117		self.cancel_local_alarm_timeouts();
118		#[cfg(test)]
119		self.0
120			.schedule_driver_alarm_cancel_count
121			.fetch_add(1, Ordering::SeqCst);
122
123		let envoy_handle = self.0.schedule_envoy_handle.lock().clone();
124		let Some(envoy_handle) = envoy_handle else {
125			return;
126		};
127
128		let generation = *self.0.schedule_generation.lock();
129		self.set_alarm_tracked(envoy_handle, None, generation);
130	}
131
132	#[cfg(test)]
133	pub(crate) fn test_driver_alarm_cancel_count(&self) -> usize {
134		self.0
135			.schedule_driver_alarm_cancel_count
136			.load(Ordering::SeqCst)
137	}
138
139	pub(crate) async fn wait_for_pending_alarm_writes(&self) {
140		let pending = {
141			let mut guard = self.0.schedule_pending_alarm_writes.lock();
142			std::mem::take(&mut *guard)
143		};
144		tracing::debug!(
145			actor_id = %self.actor_id(),
146			pending_alarm_writes = pending.len(),
147			"waiting for pending actor alarm writes"
148		);
149
150		for ack_rx in pending {
151			let _ = ack_rx.await;
152		}
153		tracing::debug!(
154			actor_id = %self.actor_id(),
155			"pending actor alarm writes drained"
156		);
157	}
158
159	pub(crate) fn due_scheduled_events(&self, now_ms: i64) -> Vec<PersistedScheduleEvent> {
160		if !self
161			.0
162			.schedule_alarm_dispatch_enabled
163			.load(Ordering::SeqCst)
164		{
165			return Vec::new();
166		}
167
168		self.all_events()
169			.into_iter()
170			.filter(|event| event.timestamp <= now_ms)
171			.collect()
172	}
173
174	fn schedule_event(&self, timestamp_ms: i64, action_name: &str, args: &[u8]) -> Result<()> {
175		let event = PersistedScheduleEvent {
176			event_id: Uuid::new_v4().to_string(),
177			timestamp: timestamp_ms,
178			action: action_name.to_owned(),
179			args: (!args.is_empty()).then(|| args.to_vec()),
180		};
181		let event_id = event.event_id.clone();
182		let args_len = event.args.as_ref().map_or(0, Vec::len);
183
184		self.insert_event_sorted(event);
185		tracing::debug!(
186			actor_id = %self.actor_id(),
187			event_id,
188			action_name,
189			timestamp_ms,
190			args_len,
191			"scheduled actor event added"
192		);
193		self.mark_dirty_since_push();
194		self.persist_scheduled_events("schedule_insert");
195		self.sync_alarm()
196	}
197
198	fn insert_event_sorted(&self, event: PersistedScheduleEvent) {
199		self.update_scheduled_events(|events| {
200			let position = events
201				.binary_search_by(|existing| {
202					existing
203						.timestamp
204						.cmp(&event.timestamp)
205						.then_with(|| existing.event_id.cmp(&event.event_id))
206				})
207				.unwrap_or_else(|index| index);
208			events.insert(position, event);
209		});
210	}
211
212	fn persist_scheduled_events(&self, description: &'static str) {
213		self.persist_now_tracked(description);
214	}
215
216	fn mark_dirty_since_push(&self) {
217		self.0
218			.schedule_dirty_since_push
219			.store(true, Ordering::SeqCst);
220	}
221
222	fn sync_alarm(&self) -> Result<()> {
223		let should_push = self
224			.0
225			.schedule_dirty_since_push
226			.swap(false, Ordering::SeqCst);
227		let next_alarm = self.next_event().map(|event| event.timestamp);
228		self.arm_local_alarm(next_alarm);
229		if !should_push {
230			return Ok(());
231		}
232		// Only dedup concrete future alarms; a dirty `None` still needs to clear
233		// the driver alarm on fresh/no-event schedules.
234		if next_alarm.is_some() && self.last_pushed_alarm() == next_alarm {
235			return Ok(());
236		}
237
238		let envoy_handle = self.0.schedule_envoy_handle.lock().clone();
239
240		let Some(envoy_handle) = envoy_handle else {
241			self.mark_dirty_since_push();
242			tracing::warn!(
243				actor_id = self.actor_id(),
244				sleep_timeout_ms = self.sleep_state_config().sleep_timeout.as_millis() as u64,
245				"schedule alarm sync skipped because envoy handle is not configured"
246			);
247			return Ok(());
248		};
249
250		let generation = *self.0.schedule_generation.lock();
251		self.set_alarm_tracked(envoy_handle, next_alarm, generation);
252		Ok(())
253	}
254
255	fn sync_future_alarm(&self) -> Result<()> {
256		let should_push = self
257			.0
258			.schedule_dirty_since_push
259			.swap(false, Ordering::SeqCst);
260		let now_ms = now_timestamp_ms();
261		let next_alarm = self
262			.next_event()
263			.and_then(|event| (event.timestamp > now_ms).then_some(event.timestamp));
264		self.arm_local_alarm(next_alarm);
265		if !should_push {
266			return Ok(());
267		}
268		// Only dedup concrete future alarms; a dirty `None` still needs to clear
269		// the driver alarm on fresh/no-event schedules.
270		if next_alarm.is_some() && self.last_pushed_alarm() == next_alarm {
271			return Ok(());
272		}
273
274		let envoy_handle = self.0.schedule_envoy_handle.lock().clone();
275
276		let Some(envoy_handle) = envoy_handle else {
277			self.mark_dirty_since_push();
278			tracing::warn!(
279				actor_id = self.actor_id(),
280				sleep_timeout_ms = self.sleep_state_config().sleep_timeout.as_millis() as u64,
281				"future schedule alarm sync skipped because envoy handle is not configured"
282			);
283			return Ok(());
284		};
285
286		let generation = *self.0.schedule_generation.lock();
287		self.set_alarm_tracked(envoy_handle, next_alarm, generation);
288		Ok(())
289	}
290
291	fn set_alarm_tracked(
292		&self,
293		envoy_handle: EnvoyHandle,
294		timestamp_ms: Option<i64>,
295		generation: Option<u32>,
296	) {
297		let previous_alarm = self.last_pushed_alarm();
298		tracing::debug!(
299			actor_id = %self.actor_id(),
300			generation,
301			old_timestamp_ms = previous_alarm,
302			new_timestamp_ms = timestamp_ms,
303			"pushing actor alarm to envoy"
304		);
305		let (ack_tx, ack_rx) = oneshot::channel();
306		envoy_handle.set_alarm_with_ack(
307			self.actor_id().to_owned(),
308			timestamp_ms,
309			generation,
310			Some(ack_tx),
311		);
312		self.load_last_pushed_alarm(timestamp_ms);
313		if let Ok(handle) = Handle::try_current() {
314			let state_ctx = self.clone();
315			let (persist_done_tx, persist_done_rx) = oneshot::channel();
316			handle.spawn(
317				async move {
318					let _ = ack_rx.await;
319					if let Err(error) = state_ctx.persist_last_pushed_alarm(timestamp_ms).await {
320						tracing::error!(
321							?error,
322							?timestamp_ms,
323							"failed to persist last pushed actor alarm"
324						);
325					}
326					let _ = persist_done_tx.send(());
327				}
328				.in_current_span(),
329			);
330			self.0
331				.schedule_pending_alarm_writes
332				.lock()
333				.push(persist_done_rx);
334			return;
335		}
336
337		self.0.schedule_pending_alarm_writes.lock().push(ack_rx);
338	}
339
340	fn arm_local_alarm(&self, next_alarm: Option<i64>) {
341		self.cancel_local_alarm_timeouts();
342
343		let Some(next_alarm) = next_alarm else {
344			return;
345		};
346
347		let has_callback = self.0.schedule_local_alarm_callback.lock().is_some();
348		if !has_callback {
349			return;
350		}
351
352		#[cfg(not(feature = "wasm-runtime"))]
353		let tokio_handle = match Handle::try_current() {
354			Ok(handle) => handle,
355			Err(_) => return,
356		};
357
358		let delay_ms = next_alarm.saturating_sub(now_timestamp_ms()).max(0) as u64;
359		let local_alarm_epoch = self.0.schedule_local_alarm_epoch.load(Ordering::SeqCst);
360		let schedule = self.clone();
361		tracing::debug!(
362			actor_id = %self.actor_id(),
363			timestamp_ms = next_alarm,
364			delay_ms,
365			local_alarm_epoch,
366			"local actor alarm armed"
367		);
368		// Intentionally detached but abortable: the handle is stored in
369		// `local_alarm_task` and cancelled when alarms are resynced or stopped.
370		let task = async move {
371			sleep(Duration::from_millis(delay_ms)).await;
372			if schedule.0.schedule_local_alarm_epoch.load(Ordering::SeqCst) != local_alarm_epoch {
373				return;
374			}
375			tracing::debug!(
376				timestamp_ms = next_alarm,
377				local_alarm_epoch,
378				"local actor alarm fired"
379			);
380			let Some(callback) = schedule.0.schedule_local_alarm_callback.lock().clone() else {
381				return;
382			};
383			callback().await;
384		}
385		.in_current_span();
386
387		#[cfg(not(feature = "wasm-runtime"))]
388		let handle = tokio_handle.spawn(task);
389
390		#[cfg(feature = "wasm-runtime")]
391		let handle = RuntimeSpawner::spawn(task);
392
393		*self.0.schedule_local_alarm_task.lock() = Some(handle);
394	}
395
396	pub(crate) fn sync_alarm_logged(&self) {
397		if let Err(error) = self.sync_alarm() {
398			tracing::error!(
399				actor_id = %self.actor_id(),
400				?error,
401				"failed to sync scheduled actor alarm"
402			);
403		}
404	}
405
406	pub(crate) fn sync_future_alarm_logged(&self) {
407		if let Err(error) = self.sync_future_alarm() {
408			tracing::error!(
409				actor_id = %self.actor_id(),
410				?error,
411				"failed to sync future scheduled actor alarm"
412			);
413		}
414	}
415
416	pub(crate) fn suspend_alarm_dispatch(&self) {
417		self.0
418			.schedule_alarm_dispatch_enabled
419			.store(false, Ordering::SeqCst);
420	}
421}
422
423fn now_timestamp_ms() -> i64 {
424	let duration = SystemTime::now()
425		.duration_since(UNIX_EPOCH)
426		.unwrap_or_default();
427	i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)
428}
429
430// Test shim keeps moved tests in crate-root tests/ with private-module access.
431#[cfg(test)]
432#[path = "../../tests/schedule.rs"]
433mod tests;