Skip to main content

chronon_executor/
lib.rs

1//! Script registry lookup, context build, and async run lifecycle.
2//!
3//! Resolves registered script handlers, builds execution context from stored actor JSON,
4//! and dispatches async runs with lifecycle events back to the runtime.
5//!
6//! # Documentation map
7//!
8//! - **Register handlers** — [`ScriptRegistry`], link-time inventory via `#[chronon::script]`
9//! - **Dispatch runs** — [`Executor::spawn_run`], [`execute_script`]
10//! - **Observe lifecycle** — [`ExecutorEvent`]
11//! - **Per-run log capture** — [`execute_script`] returns [`ExecuteScriptOutcome::logs`]
12//!   ([`chronon_telemetry::ChrononLogCapture`]); runtime persists on success **and** failure
13//!
14//! # Concern → API
15//!
16//! | Concern | API |
17//! |---------|-----|
18//! | Invoke + capture logs | [`execute_script`] → [`ExecuteScriptOutcome`] |
19//! | Async dispatch | [`Executor::spawn_run`] |
20//! | Lifecycle to runtime | [`ExecutorEvent`] (includes [`CapturedLogs`]) |
21//!
22//! # Notes
23//!
24//! [`Executor::spawn_run`] uses run-level `params_json`, not job defaults. Missing scripts
25//! surface as [`ChrononError::ScriptNotFound`](chronon_core::ChrononError::ScriptNotFound).
26//! Concurrent `spawn_run` work is capped by a semaphore (`CHRONON_EXECUTOR_CONCURRENCY`,
27//! default 4); lifecycle events use a bounded channel (`CHRONON_EVENT_CHANNEL_CAPACITY`,
28//! default 1024).
29
30mod 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
46/// Default max in-flight [`Executor::spawn_run`] tasks.
47pub const DEFAULT_EXECUTOR_CONCURRENCY: usize = 4;
48
49/// Default capacity for the executor → runtime lifecycle event channel.
50pub const DEFAULT_EVENT_CHANNEL_CAPACITY: usize = 1024;
51
52/// Reads `CHRONON_EXECUTOR_CONCURRENCY` (default [`DEFAULT_EXECUTOR_CONCURRENCY`]).
53#[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/// Reads `CHRONON_EVENT_CHANNEL_CAPACITY` (default [`DEFAULT_EVENT_CHANNEL_CAPACITY`]).
63#[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/// Event sent from the executor to the runtime for run status updates.
73///
74/// Consumed by `chronon-runtime` to persist run state and forward metrics.
75#[derive(Debug, Clone)]
76pub enum ExecutorEvent {
77    /// A run task was spawned and execution has begun.
78    RunStarted {
79        /// Run identifier matching [`Run::run_id`](chronon_core::Run::run_id).
80        run_id: String,
81    },
82    /// Handler returned successfully.
83    RunCompleted {
84        /// Run identifier matching [`Run::run_id`](chronon_core::Run::run_id).
85        run_id: String,
86        /// Wall-clock duration from spawn to handler completion, in milliseconds.
87        duration_ms: i64,
88        /// Captured tracing text for `stdout_text` / `stderr_text`.
89        logs: CapturedLogs,
90    },
91    /// Handler returned an error or context build failed.
92    RunFailed {
93        /// Run identifier matching [`Run::run_id`](chronon_core::Run::run_id).
94        run_id: String,
95        /// Display-formatted error message for logs and persistence.
96        error: String,
97        /// Captured tracing text (flushed even on failure).
98        logs: CapturedLogs,
99    },
100}
101
102/// Executor for running registered scripts against scheduled jobs.
103///
104/// Constructed by `ChrononBuilder` in `chronon-runtime` and called when workers claim runs.
105pub struct Executor {
106    /// Script catalog used to resolve handler functions by name.
107    pub registry: Arc<ScriptRegistry>,
108    /// Rebuilds [`ScriptContext`](chronon_core::ScriptContext) from the **run** snapshot's
109    /// `actor_json` (see [`Self::spawn_run`]), not the live job row.
110    pub context_factory: Arc<dyn ContextFactory>,
111    /// Metrics and structured error events for invoke phases.
112    pub telemetry: Arc<dyn TelemetrySink>,
113    event_tx: mpsc::Sender<ExecutorEvent>,
114    /// Caps concurrent [`Self::spawn_run`] executions.
115    run_slots: Arc<Semaphore>,
116}
117
118impl Executor {
119    /// Builds an executor wired to the given registry, factory, telemetry, and event channel.
120    ///
121    /// The runtime typically clones [`Self::event_sender`] before passing `event_tx` so both
122    /// sides can send lifecycle updates. `max_in_flight` bounds concurrent `spawn_run` work
123    /// (use [`executor_concurrency_from_env`] at the builder).
124    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    /// Clones the bounded sender for [`ExecutorEvent`] lifecycle updates.
142    ///
143    /// Used by the runtime to subscribe without holding an [`Executor`] reference.
144    pub fn event_sender(&self) -> mpsc::Sender<ExecutorEvent> {
145        self.event_tx.clone()
146    }
147
148    /// Returns the number of scripts currently registered.
149    pub fn script_count(&self) -> usize {
150        self.registry.len()
151    }
152
153    /// Spawn asynchronous execution for one run of the given job.
154    ///
155    /// Acquires a run-slot permit before invoking the script so enqueue cannot start
156    /// unlimited concurrent work. Emits [`ExecutorEvent::RunStarted`] after the slot is
157    /// held, then invokes via [`execute_script`]. Uses the run's snapshotted `actor_json`
158    /// and `params_json` (not the live job row) so queued identity cannot change under a
159    /// worker.
160    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: &registry,
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}