1use 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
15pub struct ExecuteScriptRequest<'a> {
17 pub registry: &'a ScriptRegistry,
19 pub context_factory: &'a Arc<dyn ContextFactory>,
21 pub telemetry: &'a Arc<dyn TelemetrySink>,
23 pub script_name: &'a str,
25 pub actor_json: &'a Value,
27 pub params_json: Value,
29 pub job_name: &'a str,
31 pub run_id: &'a str,
33}
34
35#[derive(Debug)]
37pub struct ExecuteScriptOutcome {
38 pub result: Result<()>,
40 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#[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: ®istry,
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: ®istry,
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}