1mod descriptor;
31mod invoke;
32mod registry;
33
34pub use descriptor::{InvokeFn, ScriptDescriptor};
35pub use invoke::{execute_script, ExecuteScriptOutcome, ExecuteScriptRequest};
36pub use registry::{ScriptDescriptorRef, ScriptRegistry};
37
38use std::sync::Arc;
39
40use chrono::Utc;
41use chronon_core::{ContextFactory, Job, Run};
42use chronon_telemetry::{CapturedLogs, TelemetrySink};
43use tokio::sync::{mpsc, Semaphore};
44use tracing::Instrument;
45
46pub const DEFAULT_EXECUTOR_CONCURRENCY: usize = 4;
48
49pub const DEFAULT_EVENT_CHANNEL_CAPACITY: usize = 1024;
51
52#[must_use]
54pub fn executor_concurrency_from_env() -> usize {
55 std::env::var("CHRONON_EXECUTOR_CONCURRENCY")
56 .ok()
57 .and_then(|s| s.parse::<usize>().ok())
58 .filter(|&n| n >= 1)
59 .unwrap_or(DEFAULT_EXECUTOR_CONCURRENCY)
60}
61
62#[must_use]
64pub fn event_channel_capacity_from_env() -> usize {
65 std::env::var("CHRONON_EVENT_CHANNEL_CAPACITY")
66 .ok()
67 .and_then(|s| s.parse::<usize>().ok())
68 .filter(|&n| n >= 1)
69 .unwrap_or(DEFAULT_EVENT_CHANNEL_CAPACITY)
70}
71
72#[derive(Debug, Clone)]
76pub enum ExecutorEvent {
77 RunStarted {
79 run_id: String,
81 },
82 RunCompleted {
84 run_id: String,
86 duration_ms: i64,
88 logs: CapturedLogs,
90 },
91 RunFailed {
93 run_id: String,
95 error: String,
97 logs: CapturedLogs,
99 },
100}
101
102pub struct Executor {
106 pub registry: Arc<ScriptRegistry>,
108 pub context_factory: Arc<dyn ContextFactory>,
111 pub telemetry: Arc<dyn TelemetrySink>,
113 event_tx: mpsc::Sender<ExecutorEvent>,
114 run_slots: Arc<Semaphore>,
116}
117
118impl Executor {
119 pub fn new(
125 registry: Arc<ScriptRegistry>,
126 context_factory: Arc<dyn ContextFactory>,
127 telemetry: Arc<dyn TelemetrySink>,
128 event_tx: mpsc::Sender<ExecutorEvent>,
129 max_in_flight: usize,
130 ) -> Self {
131 let slots = max_in_flight.max(1);
132 Self {
133 registry,
134 context_factory,
135 telemetry,
136 event_tx,
137 run_slots: Arc::new(Semaphore::new(slots)),
138 }
139 }
140
141 pub fn event_sender(&self) -> mpsc::Sender<ExecutorEvent> {
145 self.event_tx.clone()
146 }
147
148 pub fn script_count(&self) -> usize {
150 self.registry.len()
151 }
152
153 pub fn spawn_run(&self, job: &Job, run: Run) {
161 let registry = Arc::clone(&self.registry);
162 let context_factory = Arc::clone(&self.context_factory);
163 let telemetry = Arc::clone(&self.telemetry);
164 let event_tx = self.event_tx.clone();
165 let run_slots = Arc::clone(&self.run_slots);
166
167 let script_name = job.script_name.clone();
168 let job_name = job.job_name.clone();
169 let params_json = run.params_json.clone();
170 let actor_json = run.actor_json.clone();
171 let run_id = run.run_id;
172
173 let span = tracing::info_span!(
174 "spawn_run",
175 run_id = %run_id,
176 job_name = %job_name,
177 script_name = %script_name,
178 );
179 tokio::spawn(
180 async move {
181 let Ok(_permit) = run_slots.acquire_owned().await else {
182 tracing::warn!(
183 run_id = %run_id,
184 "executor run-slot semaphore closed; dropping spawn_run"
185 );
186 return;
187 };
188
189 if event_tx
190 .send(ExecutorEvent::RunStarted {
191 run_id: run_id.clone(),
192 })
193 .await
194 .is_err()
195 {
196 tracing::warn!(
197 run_id = %run_id,
198 "executor event channel closed on RunStarted"
199 );
200 return;
201 }
202 telemetry.record_counter(
203 "chronon_runs_started",
204 &[("script", script_name.as_str()), ("job", job_name.as_str())],
205 1,
206 );
207 tracing::info!("run started");
208
209 let started = Utc::now();
210 let outcome = invoke::execute_script(invoke::ExecuteScriptRequest {
211 registry: ®istry,
212 context_factory: &context_factory,
213 telemetry: &telemetry,
214 script_name: &script_name,
215 actor_json: &actor_json,
216 params_json,
217 job_name: &job_name,
218 run_id: &run_id,
219 })
220 .await;
221
222 let duration_ms = (Utc::now() - started).num_milliseconds();
223 match outcome.result {
224 Ok(()) => {
225 if event_tx
226 .send(ExecutorEvent::RunCompleted {
227 run_id: run_id.clone(),
228 duration_ms,
229 logs: outcome.logs,
230 })
231 .await
232 .is_err()
233 {
234 tracing::warn!(
235 run_id = %run_id,
236 "executor event channel closed on RunCompleted"
237 );
238 }
239 telemetry.record_counter(
240 "chronon_runs_completed",
241 &[("script", script_name.as_str()), ("job", job_name.as_str())],
242 1,
243 );
244 tracing::info!(duration_ms, "run completed");
245 }
246 Err(e) => {
247 let error_msg = e.to_string();
248 if event_tx
249 .send(ExecutorEvent::RunFailed {
250 run_id: run_id.clone(),
251 error: error_msg.clone(),
252 logs: outcome.logs,
253 })
254 .await
255 .is_err()
256 {
257 tracing::warn!(
258 run_id = %run_id,
259 "executor event channel closed on RunFailed"
260 );
261 }
262 telemetry.record_counter(
263 "chronon_runs_failed",
264 &[("script", script_name.as_str()), ("job", job_name.as_str())],
265 1,
266 );
267 telemetry.log_event(
268 "chronon_run_failed",
269 &[
270 ("run_id", run_id.as_str()),
271 ("job", job_name.as_str()),
272 ("error", error_msg.as_str()),
273 ],
274 );
275 tracing::warn!(duration_ms, error = %error_msg, "run failed");
276 }
277 }
278 }
279 .instrument(span),
280 );
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 #![allow(clippy::unwrap_used, clippy::expect_used)]
287
288 use super::*;
289 use chronon_core::{NoOpContextFactory, Result, ScriptContext};
290 use serde_json::{json, Value};
291 use std::future::Future;
292 use std::pin::Pin;
293 use std::sync::Mutex;
294
295 static LAST_PARAMS: Mutex<Option<Value>> = Mutex::new(None);
296 static LAST_ACTOR: Mutex<Option<Value>> = Mutex::new(None);
297
298 fn param_probe(
299 _ctx: Box<dyn ScriptContext>,
300 params: Value,
301 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
302 Box::pin(async move {
303 *LAST_PARAMS.lock().unwrap() = Some(params);
304 Ok(())
305 })
306 }
307
308 fn actor_probe(
309 ctx: Box<dyn ScriptContext>,
310 _params: Value,
311 ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
312 Box::pin(async move {
313 *LAST_ACTOR.lock().unwrap() = Some(ctx.actor_json().clone());
314 Ok(())
315 })
316 }
317
318 struct RecordingFactory;
319
320 impl chronon_core::ContextFactory for RecordingFactory {
321 fn build(&self, actor_json: &Value) -> Result<Box<dyn ScriptContext>> {
322 Ok(Box::new(RecordingCtx {
323 actor_json: actor_json.clone(),
324 }))
325 }
326 }
327
328 struct RecordingCtx {
329 actor_json: Value,
330 }
331
332 impl ScriptContext for RecordingCtx {
333 fn label(&self) -> &'static str {
334 "recording"
335 }
336
337 fn actor_json(&self) -> &Value {
338 &self.actor_json
339 }
340 }
341
342 #[tokio::test]
343 async fn spawn_run_uses_run_params() {
344 *LAST_PARAMS.lock().unwrap() = None;
345 let registry = Arc::new({
346 let mut r = ScriptRegistry::new();
347 r.register(&ScriptDescriptor::new("probe", param_probe));
348 r
349 });
350 let (tx, mut rx) = mpsc::channel(16);
351 let executor = Executor::new(
352 registry,
353 Arc::new(NoOpContextFactory),
354 Arc::new(chronon_telemetry::NoOpSink),
355 tx,
356 4,
357 );
358
359 let mut job = Job::new("job", "probe");
360 let mut run = chronon_core::Run::for_job(&job.job_id, "probe", Utc::now());
361 run.params_json = json!({ "source": "run" });
362 job.params_json = json!({ "source": "job" });
363
364 executor.spawn_run(&job, run);
365
366 for _ in 0..20 {
367 if let Some(ExecutorEvent::RunCompleted { .. }) = rx.recv().await {
368 break;
369 }
370 }
371 assert_eq!(
372 *LAST_PARAMS.lock().unwrap(),
373 Some(json!({ "source": "run" }))
374 );
375 }
376
377 #[tokio::test]
378 async fn spawn_run_uses_run_actor_json_not_live_job() {
379 *LAST_ACTOR.lock().unwrap() = None;
380 let registry = Arc::new({
381 let mut r = ScriptRegistry::new();
382 r.register(&ScriptDescriptor::new("actor_probe", actor_probe));
383 r
384 });
385 let (tx, mut rx) = mpsc::channel(16);
386 let executor = Executor::new(
387 registry,
388 Arc::new(RecordingFactory),
389 Arc::new(chronon_telemetry::NoOpSink),
390 tx,
391 4,
392 );
393
394 let mut job = Job::new("job", "actor_probe");
395 job.actor_json = json!({ "user": "elevated" });
396 let mut run = chronon_core::Run::for_job(&job.job_id, "actor_probe", Utc::now());
397 run.actor_json = json!({ "user": "snapshotted" });
398
399 executor.spawn_run(&job, run);
400
401 for _ in 0..20 {
402 if let Some(ExecutorEvent::RunCompleted { .. }) = rx.recv().await {
403 break;
404 }
405 }
406 assert_eq!(
407 *LAST_ACTOR.lock().unwrap(),
408 Some(json!({ "user": "snapshotted" }))
409 );
410 }
411}