everruns_local/
schedule_runner.rs1use std::sync::Arc;
2use std::time::Duration;
3
4use chrono::Utc;
5use futures::StreamExt;
6use tokio::sync::watch;
7use tokio::task::JoinHandle;
8use tokio::time::MissedTickBehavior;
9
10use crate::{LocalError, LocalResult, LocalScheduleStore, LocalSessionRunner};
11
12#[derive(Clone, Debug)]
14pub struct LocalScheduleRunnerConfig {
15 pub poll_interval: Duration,
17 pub claim_timeout: Duration,
20 pub batch_size: usize,
22}
23
24const MAX_BATCH_SIZE: usize = 1_000;
25
26impl Default for LocalScheduleRunnerConfig {
27 fn default() -> Self {
28 Self {
29 poll_interval: Duration::from_secs(1),
30 claim_timeout: Duration::from_secs(30),
31 batch_size: 32,
32 }
33 }
34}
35
36pub struct LocalScheduleRunner {
38 store: LocalScheduleStore,
39 session_runner: Arc<dyn LocalSessionRunner>,
40 config: LocalScheduleRunnerConfig,
41}
42
43impl LocalScheduleRunner {
44 pub fn new(store: LocalScheduleStore, session_runner: Arc<dyn LocalSessionRunner>) -> Self {
45 Self {
46 store,
47 session_runner,
48 config: LocalScheduleRunnerConfig::default(),
49 }
50 }
51
52 pub fn with_config(mut self, config: LocalScheduleRunnerConfig) -> Self {
53 self.config = config;
54 self
55 }
56
57 pub fn start(self) -> LocalResult<LocalScheduleRunnerHandle> {
59 if self.config.poll_interval.is_zero() {
60 return Err(LocalError::Config(
61 "schedule poll interval must be positive".into(),
62 ));
63 }
64 if self.config.claim_timeout.is_zero() {
65 return Err(LocalError::Config(
66 "schedule claim timeout must be positive".into(),
67 ));
68 }
69 if self.config.batch_size == 0 {
70 return Err(LocalError::Config(
71 "schedule batch size must be positive".into(),
72 ));
73 }
74 if self.config.batch_size > MAX_BATCH_SIZE {
75 return Err(LocalError::Config(format!(
76 "schedule batch size must not exceed {MAX_BATCH_SIZE}"
77 )));
78 }
79 let runner_id = uuid::Uuid::now_v7().to_string();
80 let (shutdown_tx, mut shutdown_rx) = watch::channel(false);
81 let join = tokio::spawn(async move {
82 tracing::info!(
83 runner_id,
84 poll_interval_ms = self.config.poll_interval.as_millis(),
85 claim_timeout_ms = self.config.claim_timeout.as_millis(),
86 batch_size = self.config.batch_size,
87 "Local schedule runner started"
88 );
89 let mut interval = tokio::time::interval(self.config.poll_interval);
90 interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
91 loop {
92 tokio::select! {
93 biased;
94 changed = shutdown_rx.changed() => {
95 if changed.is_err() || *shutdown_rx.borrow() {
96 break;
97 }
98 }
99 _ = interval.tick() => {
100 if let Err(error) = self.poll_once(&runner_id).await {
101 tracing::error!(runner_id, error = %error, "Local schedule poll failed");
102 }
103 }
104 }
105 }
106 tracing::info!(runner_id, "Local schedule runner stopped");
107 });
108 Ok(LocalScheduleRunnerHandle {
109 shutdown_tx,
110 join: Some(join),
111 })
112 }
113
114 async fn poll_once(&self, runner_id: &str) -> everruns_core::Result<()> {
115 let routable_session_ids = self.session_runner.routable_session_ids().await?;
116 let claimed = self.store.claim_due(
117 runner_id,
118 Utc::now(),
119 self.config.claim_timeout,
120 self.config.batch_size,
121 routable_session_ids.as_deref(),
122 )?;
123 if !claimed.is_empty() {
124 tracing::debug!(
125 runner_id,
126 count = claimed.len(),
127 "Claimed due local schedules"
128 );
129 }
130 futures::stream::iter(claimed)
131 .for_each_concurrent(self.config.batch_size, |claim| async move {
132 self.process_claim(claim, runner_id).await;
133 })
134 .await;
135 Ok(())
136 }
137
138 async fn process_claim(&self, claim: crate::schedule_store::ClaimedSchedule, runner_id: &str) {
139 let schedule = &claim.schedule;
140 tracing::debug!(
141 runner_id,
142 schedule_id = %schedule.id,
143 session_id = %schedule.session_id,
144 "Delivering local schedule"
145 );
146 let delivery = self.deliver_with_heartbeat(&claim, runner_id).await;
147 match delivery {
148 Ok(()) => match self.store.complete_delivery(&claim, runner_id, Utc::now()) {
149 Ok(()) => tracing::info!(
150 runner_id,
151 schedule_id = %schedule.id,
152 session_id = %schedule.session_id,
153 "Local schedule delivered"
154 ),
155 Err(error) => tracing::error!(
156 runner_id,
157 schedule_id = %schedule.id,
158 session_id = %schedule.session_id,
159 error = %error,
160 "Local schedule was delivered but its claim could not be completed"
161 ),
162 },
163 Err(error) => {
164 let error_text = bounded_error(&error.to_string());
165 if let Err(release_error) =
166 self.store
167 .fail_delivery(&claim, runner_id, Utc::now(), &error_text)
168 {
169 tracing::error!(
170 runner_id,
171 schedule_id = %schedule.id,
172 session_id = %schedule.session_id,
173 error = %release_error,
174 "Local schedule failed and its claim could not be released"
175 );
176 }
177 tracing::warn!(
178 runner_id,
179 schedule_id = %schedule.id,
180 session_id = %schedule.session_id,
181 error = %error,
182 "Local schedule delivery failed; occurrence retained for retry"
183 );
184 }
185 }
186 }
187
188 async fn deliver_with_heartbeat(
189 &self,
190 claim: &crate::schedule_store::ClaimedSchedule,
191 runner_id: &str,
192 ) -> everruns_core::Result<()> {
193 let schedule = &claim.schedule;
194 let delivery = self
195 .session_runner
196 .send_message(schedule.session_id, &schedule.description);
197 tokio::pin!(delivery);
198 let heartbeat_interval = (self.config.claim_timeout / 3).max(Duration::from_millis(1));
199 let mut heartbeat = tokio::time::interval(heartbeat_interval);
200 heartbeat.set_missed_tick_behavior(MissedTickBehavior::Skip);
201 heartbeat.tick().await;
202 loop {
203 tokio::select! {
204 result = &mut delivery => return result,
205 _ = heartbeat.tick() => {
206 if !self.store.renew_claim(claim, runner_id, Utc::now())? {
207 return Err(everruns_core::AgentLoopError::store(format!(
208 "local schedule claim {} was lost during delivery",
209 claim.claim_id
210 )));
211 }
212 }
213 }
214 }
215 }
216}
217
218fn bounded_error(error: &str) -> String {
219 const MAX_ERROR_BYTES: usize = 4_096;
220 if error.len() <= MAX_ERROR_BYTES {
221 return error.to_string();
222 }
223 let mut end = MAX_ERROR_BYTES;
224 while !error.is_char_boundary(end) {
225 end -= 1;
226 }
227 format!("{}…", &error[..end])
228}
229
230pub struct LocalScheduleRunnerHandle {
232 shutdown_tx: watch::Sender<bool>,
233 join: Option<JoinHandle<()>>,
234}
235
236impl LocalScheduleRunnerHandle {
237 pub async fn shutdown(mut self) -> LocalResult<()> {
239 let _ = self.shutdown_tx.send(true);
240 if let Some(join) = self.join.take() {
241 join.await.map_err(|error| {
242 LocalError::Other(format!("local schedule runner task failed: {error}"))
243 })?;
244 }
245 Ok(())
246 }
247
248 pub fn abort(mut self) {
250 if let Some(join) = self.join.take() {
251 join.abort();
252 }
253 }
254}
255
256impl Drop for LocalScheduleRunnerHandle {
257 fn drop(&mut self) {
258 let _ = self.shutdown_tx.send(true);
259 }
260}