use std::collections::BTreeMap;
use std::fmt;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use serde::de::DeserializeOwned;
use serde_json::Value;
use taquba_workflow::{Step, StepError};
use crate::task::TaskIdentity;
pub mod http;
pub mod object_exists;
pub mod shell;
pub mod subprocess;
use http::Http;
use object_exists::ObjectExists;
use shell::Shell;
use subprocess::Subprocess;
#[derive(Debug, Clone, Copy)]
pub struct Task<'a> {
pub step: &'a Step,
pub identity: &'a TaskIdentity,
pub params: &'a Value,
pub inputs: &'a BTreeMap<String, Value>,
pub state: Option<&'a Value>,
pub now_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Succeeded(Value),
Failed(String),
Continue {
state: Value,
after: Duration,
},
}
pub trait Operator: Send + Sync + 'static {
type Params: DeserializeOwned + Send;
fn run(
&self,
task: &Task<'_>,
params: Self::Params,
) -> impl Future<Output = Result<Outcome, StepError>> + Send;
}
type Check = Box<dyn Fn(&toml::Table) -> Result<(), String> + Send + Sync>;
type BoxFuture<'a> = Pin<Box<dyn Future<Output = Result<Outcome, StepError>> + Send + 'a>>;
type Run = Box<dyn for<'a> Fn(&'a Task<'a>, Value) -> BoxFuture<'a> + Send + Sync>;
struct Entry {
check: Check,
run: Option<Run>,
}
#[derive(Default)]
pub struct OperatorSet {
entries: BTreeMap<String, Entry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OperatorError {
Unknown,
InvalidParams(String),
}
impl OperatorSet {
pub fn new() -> Self {
Self::default()
}
pub fn builtin() -> Self {
let mut set = Self::new();
set.add("subprocess", Subprocess::default());
set.add("shell", Shell::default());
set.add("http", Http::default());
set.add("object_exists", ObjectExists::default());
set
}
pub fn register<P: DeserializeOwned + 'static>(&mut self, name: &str) {
self.entries.insert(
name.to_string(),
Entry {
check: check_fn::<P>(),
run: None,
},
);
}
pub fn add<O: Operator>(&mut self, name: &str, operator: O) {
let operator = Arc::new(operator);
let run: Run = Box::new(move |task, params| {
let operator = operator.clone();
Box::pin(async move {
let params: O::Params = serde_json::from_value(params).map_err(|e| {
StepError::permanent(format!("the rendered parameters are invalid: {e}"))
})?;
operator.run(task, params).await
})
});
self.entries.insert(
name.to_string(),
Entry {
check: check_fn::<O::Params>(),
run: Some(run),
},
);
}
pub fn contains(&self, name: &str) -> bool {
self.entries.contains_key(name)
}
pub fn runs(&self, name: &str) -> bool {
self.entries.get(name).is_some_and(|e| e.run.is_some())
}
pub fn names(&self) -> impl Iterator<Item = &str> {
self.entries.keys().map(String::as_str)
}
pub fn check(&self, name: &str, params: &toml::Table) -> Result<(), OperatorError> {
let entry = self.entries.get(name).ok_or(OperatorError::Unknown)?;
(entry.check)(params).map_err(OperatorError::InvalidParams)
}
pub async fn run(
&self,
name: &str,
task: &Task<'_>,
params: Value,
) -> Result<Outcome, StepError> {
let run = self
.entries
.get(name)
.and_then(|e| e.run.as_ref())
.ok_or_else(|| {
StepError::permanent(format!("operator `{name}` cannot run in this process"))
})?;
run(task, params).await
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Lease {
pub extension: Duration,
pub interval: Duration,
}
impl Default for Lease {
fn default() -> Self {
Lease {
extension: Duration::from_secs(60),
interval: Duration::from_secs(20),
}
}
}
pub(crate) async fn keep_lease(step: &Step, lease: Lease) -> StepError {
let mut ticks = tokio::time::interval(lease.interval);
ticks.tick().await;
loop {
ticks.tick().await;
if let Err(e) = step.lease.ensure_at_least(lease.extension) {
return StepError::transient(format!("lease extension failed: {e}"));
}
}
}
pub(crate) fn tail(bytes: &[u8]) -> String {
let text = String::from_utf8_lossy(bytes);
let start = text.len().saturating_sub(512);
text[text.floor_char_boundary(start)..].to_string()
}
fn check_fn<P: DeserializeOwned + 'static>() -> Check {
Box::new(|params: &toml::Table| {
P::deserialize(params.clone())
.map(|_| ())
.map_err(|e| e.to_string())
})
}
impl fmt::Debug for OperatorSet {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_set().entries(self.entries.keys()).finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::partition::Partition;
fn table(text: &str) -> toml::Table {
text.parse().unwrap()
}
#[test]
fn builtin_set_runs_the_four_operators() {
let set = OperatorSet::builtin();
assert_eq!(
set.names().collect::<Vec<_>>(),
["http", "object_exists", "shell", "subprocess"]
);
for name in ["http", "object_exists", "shell", "subprocess"] {
assert!(set.runs(name), "{name}");
}
assert_eq!(
set.check("object_exists", &table(r#"url = "s3://landing/k""#)),
Ok(())
);
assert_eq!(
set.check("subprocess", &table(r#"argv = ["python", "x.py"]"#)),
Ok(())
);
assert_eq!(set.check("shell", &table(r#"command = "true""#)), Ok(()));
assert_eq!(
set.check("http", &table(r#"url = "https://example.test/""#)),
Ok(())
);
}
#[test]
fn unknown_operator_is_reported_as_unknown() {
assert_eq!(
OperatorSet::builtin().check("sql", &table("")),
Err(OperatorError::Unknown)
);
}
#[test]
fn params_that_do_not_deserialize_are_invalid() {
let set = OperatorSet::builtin();
for params in ["argv = []", "argv = [\"a\"]\nextra = 1", ""] {
assert!(
matches!(
set.check("subprocess", &table(params)),
Err(OperatorError::InvalidParams(_))
),
"{params}"
);
}
}
#[test]
fn register_adds_a_check_only_operator() {
#[derive(serde::Deserialize)]
struct Params {
#[allow(dead_code)]
query: String,
}
let mut set = OperatorSet::new();
assert!(!set.contains("sql"));
set.register::<Params>("sql");
assert!(set.contains("sql"));
assert!(!set.runs("sql"));
assert_eq!(set.check("sql", &table(r#"query = "select 1""#)), Ok(()));
}
struct Echo;
#[derive(serde::Deserialize)]
struct EchoParams {
text: String,
}
impl Operator for Echo {
type Params = EchoParams;
async fn run(&self, task: &Task<'_>, params: EchoParams) -> Result<Outcome, StepError> {
Ok(Outcome::Succeeded(serde_json::json!({
"text": params.text,
"node": task.identity.node,
})))
}
}
fn task<'a>(
step: &'a Step,
identity: &'a TaskIdentity,
params: &'a Value,
inputs: &'a BTreeMap<String, Value>,
) -> Task<'a> {
Task {
step,
identity,
params,
inputs,
state: None,
now_ms: 0,
}
}
#[tokio::test]
async fn add_registers_a_check_and_runs_the_operator_with_typed_params() {
let mut set = OperatorSet::new();
set.add("echo", Echo);
assert_eq!(set.check("echo", &table(r#"text = "hi""#)), Ok(()));
assert!(matches!(
set.check("echo", &table("")),
Err(OperatorError::InvalidParams(_))
));
let step = Step::detached(Vec::new());
let identity = TaskIdentity {
graph: "g".into(),
partition: Partition::none(),
node: "n".into(),
asset: None,
definition: "d".into(),
rerun: 0,
};
let params = serde_json::json!({"text": "hi"});
let inputs = BTreeMap::new();
let task = task(&step, &identity, ¶ms, &inputs);
assert_eq!(
set.run("echo", &task, params.clone()).await.unwrap(),
Outcome::Succeeded(serde_json::json!({"text": "hi", "node": "n"}))
);
let err = set
.run("echo", &task, serde_json::json!({}))
.await
.unwrap_err();
assert_eq!(err.kind, taquba_workflow::StepErrorKind::Permanent);
let err = set.run("missing", &task, params.clone()).await.unwrap_err();
assert_eq!(err.kind, taquba_workflow::StepErrorKind::Permanent);
}
}