use std::sync::Arc;
use chronon_core::{ChrononError, ContextFactory, Result};
use chronon_telemetry::{
CapturedLogs, ChrononLogCapture, TelemetrySink, DEFAULT_MAX_CAPTURE_BYTES,
};
use serde_json::Value;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::Registry;
use crate::registry::ScriptRegistry;
pub struct ExecuteScriptRequest<'a> {
pub registry: &'a ScriptRegistry,
pub context_factory: &'a Arc<dyn ContextFactory>,
pub telemetry: &'a Arc<dyn TelemetrySink>,
pub script_name: &'a str,
pub actor_json: &'a Value,
pub params_json: Value,
pub job_name: &'a str,
pub run_id: &'a str,
}
#[derive(Debug)]
pub struct ExecuteScriptOutcome {
pub result: Result<()>,
pub logs: CapturedLogs,
}
fn record_executor_error(
telemetry: &Arc<dyn TelemetrySink>,
job_name: &str,
run_id: &str,
script_name: &str,
phase: &str,
message: &str,
) {
telemetry.log_event(
"chronon_executor_error",
&[
("job_name", job_name),
("run_id", run_id),
("script_name", script_name),
("phase", phase),
("message", message),
],
);
}
#[tracing::instrument(
skip(req),
fields(
script_name = %req.script_name,
job_name = %req.job_name,
run_id = %req.run_id,
)
)]
pub async fn execute_script(req: ExecuteScriptRequest<'_>) -> ExecuteScriptOutcome {
let ExecuteScriptRequest {
registry,
context_factory,
telemetry,
script_name,
actor_json,
params_json,
job_name,
run_id,
} = req;
let capture = ChrononLogCapture::new(DEFAULT_MAX_CAPTURE_BYTES);
let subscriber = Registry::default().with(capture.clone());
let dispatch = tracing::dispatcher::Dispatch::new(subscriber);
let guard = tracing::dispatcher::set_default(&dispatch);
let scope = capture.enter();
let result = invoke_inner(ExecuteScriptRequest {
registry,
context_factory,
telemetry,
script_name,
actor_json,
params_json,
job_name,
run_id,
})
.await;
let mut logs = scope.finish();
drop(guard);
if let Err(ref e) = result {
logs.ensure_stderr_message(&e.to_string());
}
ExecuteScriptOutcome { result, logs }
}
async fn invoke_inner(
ExecuteScriptRequest {
registry,
context_factory,
telemetry,
script_name,
actor_json,
params_json,
job_name,
run_id,
}: ExecuteScriptRequest<'_>,
) -> Result<()> {
let descriptor = registry.get_or_err(script_name).inspect_err(|e| {
record_executor_error(
telemetry,
job_name,
run_id,
script_name,
"registry_lookup",
&e.to_string(),
);
})?;
let ctx = context_factory.build(actor_json).inspect_err(|e| {
record_executor_error(
telemetry,
job_name,
run_id,
script_name,
"context_build",
&e.to_string(),
);
})?;
(descriptor.invoke)(ctx, params_json).await.map_err(|e| {
record_executor_error(
telemetry,
job_name,
run_id,
script_name,
"script_invoke",
&e.to_string(),
);
map_invoke_error(e)
})
}
fn map_invoke_error(err: ChrononError) -> ChrononError {
match err {
ChrononError::ParamError(_)
| ChrononError::ScriptNotFound(_)
| ChrononError::Identity(_)
| ChrononError::InvalidCron(_)
| ChrononError::InvalidTimezone(_)
| ChrononError::ScriptMismatch { .. } => err,
ChrononError::Internal(message) if is_likely_param_error(&message) => {
ChrononError::ParamError(message)
}
other => other,
}
}
fn is_likely_param_error(message: &str) -> bool {
const PARAM_ERROR_HINTS: [&str; 6] = [
"missing field",
"invalid type",
"expected",
"unknown field",
"parameter error",
"deserializing",
];
let lower = message.to_ascii_lowercase();
PARAM_ERROR_HINTS.iter().any(|h| lower.contains(h))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use crate::descriptor::ScriptDescriptor;
use chronon_core::{NoOpContextFactory, Result, ScriptContext};
use serde_json::Value;
use std::future::Future;
use std::pin::Pin;
fn noop_invoke(
_ctx: Box<dyn ScriptContext>,
_params: Value,
) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
Box::pin(async {
tracing::info!("noop ran");
Ok(())
})
}
fn fail_invoke(
_ctx: Box<dyn ScriptContext>,
_params: Value,
) -> Pin<Box<dyn Future<Output = Result<()>> + Send>> {
Box::pin(async {
tracing::warn!("about to fail");
Err(ChrononError::Internal("probe failure".into()))
})
}
#[tokio::test]
async fn execute_registered_script() {
let mut registry = ScriptRegistry::new();
registry.register(&ScriptDescriptor::new("test_script", noop_invoke));
let factory: Arc<dyn chronon_core::ContextFactory> = Arc::new(NoOpContextFactory);
let telemetry: Arc<dyn chronon_telemetry::TelemetrySink> =
Arc::new(chronon_telemetry::NoOpSink);
let outcome = execute_script(ExecuteScriptRequest {
registry: ®istry,
context_factory: &factory,
telemetry: &telemetry,
script_name: "test_script",
actor_json: &Value::Null,
params_json: Value::Object(serde_json::Map::default()),
job_name: "job",
run_id: "run-1",
})
.await;
assert!(outcome.result.is_ok());
assert!(
outcome
.logs
.stdout_text
.as_deref()
.is_some_and(|s| s.contains("noop ran")),
"stdout={:?}",
outcome.logs.stdout_text
);
}
#[tokio::test]
async fn failed_script_still_returns_captured_logs() {
let mut registry = ScriptRegistry::new();
registry.register(&ScriptDescriptor::new("fail_script", fail_invoke));
let factory: Arc<dyn chronon_core::ContextFactory> = Arc::new(NoOpContextFactory);
let telemetry: Arc<dyn chronon_telemetry::TelemetrySink> =
Arc::new(chronon_telemetry::NoOpSink);
let outcome = execute_script(ExecuteScriptRequest {
registry: ®istry,
context_factory: &factory,
telemetry: &telemetry,
script_name: "fail_script",
actor_json: &Value::Null,
params_json: Value::Object(serde_json::Map::default()),
job_name: "job",
run_id: "run-fail",
})
.await;
assert!(outcome.result.is_err());
assert!(
outcome
.logs
.stderr_text
.as_deref()
.is_some_and(|s| s.contains("about to fail") || s.contains("probe failure")),
"stderr={:?}",
outcome.logs.stderr_text
);
}
}