Skip to main content

chronon_executor/
invoke.rs

1//! Synchronous script invocation (registry lookup, context build, invoke).
2
3use std::sync::Arc;
4
5use chronon_core::{ChrononError, ContextFactory, Result};
6use chronon_telemetry::{
7    CapturedLogs, ChrononLogCapture, TelemetrySink, DEFAULT_MAX_CAPTURE_BYTES,
8};
9use serde_json::Value;
10use tracing_subscriber::layer::SubscriberExt;
11use tracing_subscriber::Registry;
12
13use crate::registry::ScriptRegistry;
14
15/// Inputs for a single script execution attempt.
16pub struct ExecuteScriptRequest<'a> {
17    /// Script registry containing the target handler.
18    pub registry: &'a ScriptRegistry,
19    /// Factory that rebuilds [`ScriptContext`](chronon_core::ScriptContext) from stored actor JSON.
20    pub context_factory: &'a Arc<dyn ContextFactory>,
21    /// Sink for executor metrics and error events.
22    pub telemetry: &'a Arc<dyn TelemetrySink>,
23    /// Registered script name to invoke.
24    pub script_name: &'a str,
25    /// Actor JSON persisted on the job at schedule time.
26    pub actor_json: &'a Value,
27    /// Run-specific parameters JSON.
28    pub params_json: Value,
29    /// Human-readable job name for telemetry.
30    pub job_name: &'a str,
31    /// Run identifier for telemetry correlation.
32    pub run_id: &'a str,
33}
34
35/// Outcome of [`execute_script`]: handler result plus captured tracing text.
36#[derive(Debug)]
37pub struct ExecuteScriptOutcome {
38    /// Handler / lookup / context-build result.
39    pub result: Result<()>,
40    /// Tracing capture for persistence on the run row (including on failure).
41    pub logs: CapturedLogs,
42}
43
44fn record_executor_error(
45    telemetry: &Arc<dyn TelemetrySink>,
46    job_name: &str,
47    run_id: &str,
48    script_name: &str,
49    phase: &str,
50    message: &str,
51) {
52    telemetry.log_event(
53        "chronon_executor_error",
54        &[
55            ("job_name", job_name),
56            ("run_id", run_id),
57            ("script_name", script_name),
58            ("phase", phase),
59            ("message", message),
60        ],
61    );
62}
63
64/// Execute a script and capture tracing output for the run record.
65///
66/// Installs a scoped [`ChrononLogCapture`] dispatcher for the invoke so info/warn/error
67/// events are buffered. Logs are returned on **both** success and failure (failure also
68/// ensures `stderr_text` includes the error message).
69#[tracing::instrument(
70    skip(req),
71    fields(
72        script_name = %req.script_name,
73        job_name = %req.job_name,
74        run_id = %req.run_id,
75    )
76)]
77pub async fn execute_script(req: ExecuteScriptRequest<'_>) -> ExecuteScriptOutcome {
78    let ExecuteScriptRequest {
79        registry,
80        context_factory,
81        telemetry,
82        script_name,
83        actor_json,
84        params_json,
85        job_name,
86        run_id,
87    } = req;
88
89    let capture = ChrononLogCapture::new(DEFAULT_MAX_CAPTURE_BYTES);
90    let subscriber = Registry::default().with(capture.clone());
91    let dispatch = tracing::dispatcher::Dispatch::new(subscriber);
92    let guard = tracing::dispatcher::set_default(&dispatch);
93    let scope = capture.enter();
94
95    let result = invoke_inner(ExecuteScriptRequest {
96        registry,
97        context_factory,
98        telemetry,
99        script_name,
100        actor_json,
101        params_json,
102        job_name,
103        run_id,
104    })
105    .await;
106
107    let mut logs = scope.finish();
108    drop(guard);
109
110    if let Err(ref e) = result {
111        logs.ensure_stderr_message(&e.to_string());
112    }
113
114    ExecuteScriptOutcome { result, logs }
115}
116
117async fn invoke_inner(
118    ExecuteScriptRequest {
119        registry,
120        context_factory,
121        telemetry,
122        script_name,
123        actor_json,
124        params_json,
125        job_name,
126        run_id,
127    }: ExecuteScriptRequest<'_>,
128) -> Result<()> {
129    let descriptor = registry.get_or_err(script_name).inspect_err(|e| {
130        record_executor_error(
131            telemetry,
132            job_name,
133            run_id,
134            script_name,
135            "registry_lookup",
136            &e.to_string(),
137        );
138    })?;
139
140    let ctx = context_factory.build(actor_json).inspect_err(|e| {
141        record_executor_error(
142            telemetry,
143            job_name,
144            run_id,
145            script_name,
146            "context_build",
147            &e.to_string(),
148        );
149    })?;
150
151    (descriptor.invoke)(ctx, params_json).await.map_err(|e| {
152        record_executor_error(
153            telemetry,
154            job_name,
155            run_id,
156            script_name,
157            "script_invoke",
158            &e.to_string(),
159        );
160        map_invoke_error(e)
161    })
162}
163
164fn map_invoke_error(err: ChrononError) -> ChrononError {
165    match err {
166        ChrononError::ParamError(_)
167        | ChrononError::ScriptNotFound(_)
168        | ChrononError::Identity(_)
169        | ChrononError::InvalidCron(_)
170        | ChrononError::InvalidTimezone(_)
171        | ChrononError::ScriptMismatch { .. } => err,
172        ChrononError::Internal(message) if is_likely_param_error(&message) => {
173            ChrononError::ParamError(message)
174        }
175        other => other,
176    }
177}
178
179fn is_likely_param_error(message: &str) -> bool {
180    const PARAM_ERROR_HINTS: [&str; 6] = [
181        "missing field",
182        "invalid type",
183        "expected",
184        "unknown field",
185        "parameter error",
186        "deserializing",
187    ];
188    let lower = message.to_ascii_lowercase();
189    PARAM_ERROR_HINTS.iter().any(|h| lower.contains(h))
190}
191
192#[cfg(test)]
193mod tests {
194    #![allow(clippy::unwrap_used, clippy::expect_used)]
195
196    use super::*;
197    use crate::descriptor::ScriptDescriptor;
198    use chronon_core::{NoOpContextFactory, Result, ScriptContext};
199    use serde_json::Value;
200    use std::future::Future;
201    use std::pin::Pin;
202
203    fn noop_invoke(
204        _ctx: Box<dyn ScriptContext>,
205        _params: Value,
206    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
207        Box::pin(async {
208            tracing::info!("noop ran");
209            Ok(())
210        })
211    }
212
213    fn fail_invoke(
214        _ctx: Box<dyn ScriptContext>,
215        _params: Value,
216    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
217        Box::pin(async {
218            tracing::warn!("about to fail");
219            Err(ChrononError::Internal("probe failure".into()))
220        })
221    }
222
223    #[tokio::test]
224    async fn execute_registered_script() {
225        let mut registry = ScriptRegistry::new();
226        registry.register(&ScriptDescriptor::new("test_script", noop_invoke));
227        let factory: Arc<dyn chronon_core::ContextFactory> = Arc::new(NoOpContextFactory);
228        let telemetry: Arc<dyn chronon_telemetry::TelemetrySink> =
229            Arc::new(chronon_telemetry::NoOpSink);
230        let outcome = execute_script(ExecuteScriptRequest {
231            registry: &registry,
232            context_factory: &factory,
233            telemetry: &telemetry,
234            script_name: "test_script",
235            actor_json: &Value::Null,
236            params_json: Value::Object(serde_json::Map::default()),
237            job_name: "job",
238            run_id: "run-1",
239        })
240        .await;
241        assert!(outcome.result.is_ok());
242        assert!(
243            outcome
244                .logs
245                .stdout_text
246                .as_deref()
247                .is_some_and(|s| s.contains("noop ran")),
248            "stdout={:?}",
249            outcome.logs.stdout_text
250        );
251    }
252
253    #[tokio::test]
254    async fn failed_script_still_returns_captured_logs() {
255        let mut registry = ScriptRegistry::new();
256        registry.register(&ScriptDescriptor::new("fail_script", fail_invoke));
257        let factory: Arc<dyn chronon_core::ContextFactory> = Arc::new(NoOpContextFactory);
258        let telemetry: Arc<dyn chronon_telemetry::TelemetrySink> =
259            Arc::new(chronon_telemetry::NoOpSink);
260        let outcome = execute_script(ExecuteScriptRequest {
261            registry: &registry,
262            context_factory: &factory,
263            telemetry: &telemetry,
264            script_name: "fail_script",
265            actor_json: &Value::Null,
266            params_json: Value::Object(serde_json::Map::default()),
267            job_name: "job",
268            run_id: "run-fail",
269        })
270        .await;
271        assert!(outcome.result.is_err());
272        assert!(
273            outcome
274                .logs
275                .stderr_text
276                .as_deref()
277                .is_some_and(|s| s.contains("about to fail") || s.contains("probe failure")),
278            "stderr={:?}",
279            outcome.logs.stderr_text
280        );
281    }
282}