feat: implement new loaders across languages (#487)

This commit is contained in:
stefan-gorules
2026-07-20 12:20:48 +02:00
committed by GitHub
parent fe4790bee4
commit 1818371c90
19 changed files with 938 additions and 43 deletions
Generated
+1
View File
@@ -5642,6 +5642,7 @@ dependencies = [
"serde_json",
"strum",
"tokio",
"tokio-util",
"zen-engine",
"zen-expression",
"zen-tmpl",
+1
View File
@@ -12,6 +12,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
strum = { workspace = true, features = ["derive"] }
tokio = { workspace = true, features = ["rt"] }
tokio-util = { workspace = true, features = ["rt"] }
zen-engine = { path = "../../core/engine", features = ["arbitrary_precision"] }
zen-expression = { path = "../../core/expression", features = ["arbitrary_precision"] }
zen-tmpl = { path = "../../core/template" }
+10 -1
View File
@@ -3,8 +3,17 @@ use std::env;
fn main() {
let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap();
let config = cbindgen::Config {
language: cbindgen::Language::C,
enumeration: cbindgen::EnumConfig {
prefix_with_name: true,
..Default::default()
},
..Default::default()
};
cbindgen::Builder::new()
.with_language(cbindgen::Language::C)
.with_config(config)
.with_crate(crate_dir)
.generate()
.expect("Unable to generate bindings")
+268 -2
View File
@@ -6,12 +6,15 @@ use std::sync::Arc;
use zen_engine::{DecisionEngine, EvaluationOptions};
use crate::custom_node::DynamicCustomNode;
use crate::custom_node::ZenCustomNodeResult;
use crate::decision::{ZenDecision, ZenDecisionStruct};
use crate::error::ZenError;
use crate::helper::safe_str_from_ptr;
use crate::loader::DynamicDecisionLoader;
use crate::mt::tokio_runtime;
use crate::languages::native::NativeCustomNode;
use crate::loader::{DynamicDecisionLoader, ZenEngineLoaderConfig};
use crate::mt::{tokio_runtime, worker_pool};
use crate::result::ZenResult;
use serde_json::json;
pub(crate) struct ZenEngine(DecisionEngine);
@@ -72,6 +75,29 @@ pub extern "C" fn zen_engine_new() -> *mut ZenEngineStruct {
Box::into_raw(Box::new(ZenEngine::default())) as *mut ZenEngineStruct
}
/// Creates a new ZenEngine instance from a loader configuration, caller is responsible for
/// freeing the returned reference by calling zen_engine_free.
#[no_mangle]
pub extern "C" fn zen_engine_new_with_loader_config(
config: ZenEngineLoaderConfig,
maybe_custom_node: Option<extern "C" fn(request: *const c_char) -> ZenCustomNodeResult>,
) -> ZenResult<ZenEngineStruct> {
let loader = match config.to_dynamic_loader() {
Ok(loader) => loader,
Err(error) => return ZenResult::error(error),
};
let custom_node = match maybe_custom_node {
Some(callback) => DynamicCustomNode::Native(NativeCustomNode::new(callback)),
None => DynamicCustomNode::default(),
};
let engine = ZenEngine::new(DynamicDecisionLoader::Config(loader), custom_node);
engine.compile();
ZenResult::ok(Box::into_raw(Box::new(engine)) as *mut ZenEngineStruct)
}
/// Frees the ZenEngine instance reference from the memory
#[no_mangle]
pub extern "C" fn zen_engine_free(engine: *mut ZenEngineStruct) {
@@ -148,6 +174,102 @@ pub extern "C" fn zen_engine_evaluate(
ZenResult::ok(cstring_result.into_raw())
}
#[repr(C)]
pub struct ZenEngineEvaluateBatchRequest {
key: *const c_char,
context: *const c_char,
}
enum BatchTask {
Failed(serde_json::Value),
Pending(tokio::task::JoinHandle<Result<serde_json::Value, serde_json::Value>>),
}
/// Evaluates a batch of requests in parallel using a DecisionEngine reference via loader.
/// Returns a JSON array of { success, data?, error? } envelopes in request order.
/// Caller is responsible for freeing: requests and ZenResult.
#[no_mangle]
pub extern "C" fn zen_engine_evaluate_batch(
engine: *const ZenEngineStruct,
requests: *const ZenEngineEvaluateBatchRequest,
requests_len: usize,
options: ZenEngineEvaluationOptions,
) -> ZenResult<c_char> {
if engine.is_null() || (requests.is_null() && requests_len > 0) {
return ZenResult::error(ZenError::InvalidArgument);
}
let request_slice = match requests_len {
0 => &[],
_ => unsafe { std::slice::from_raw_parts(requests, requests_len) },
};
let mut parsed: Vec<(String, Result<Value, String>)> = Vec::with_capacity(requests_len);
for request in request_slice {
let Some(key) = safe_str_from_ptr(request.key) else {
return ZenResult::error(ZenError::InvalidArgument);
};
if request.context.is_null() {
return ZenResult::error(ZenError::InvalidArgument);
}
let cstr_context = unsafe { CStr::from_ptr(request.context) };
let context =
serde_json::from_slice::<Value>(cstr_context.to_bytes()).map_err(|e| e.to_string());
parsed.push((key.to_string(), context));
}
let zen_engine = unsafe { &*(engine as *const ZenEngine) };
let decision_engine: DecisionEngine = DecisionEngine::clone(zen_engine);
let eval_options: EvaluationOptions = options.into();
let pool = worker_pool();
let tasks: Vec<BatchTask> = parsed
.into_iter()
.map(|(key, context)| match context {
Err(message) => BatchTask::Failed(json!(format!("invalid context: {message}"))),
Ok(value) => {
let engine = decision_engine.clone();
BatchTask::Pending(pool.spawn_pinned(move || async move {
engine
.evaluate_with_opts(key, value.into(), eval_options)
.await
.map(|response| serde_json::to_value(&response).unwrap_or(Value::Null))
.map_err(|e| {
serde_json::to_value(&e).unwrap_or_else(|_| json!(e.to_string()))
})
}))
}
})
.collect();
let results = tokio_runtime().block_on(async move {
let mut items = Vec::with_capacity(tasks.len());
for task in tasks {
let item = match task {
BatchTask::Failed(error) => json!({ "success": false, "error": error }),
BatchTask::Pending(handle) => match handle.await {
Ok(Ok(data)) => json!({ "success": true, "data": data }),
Ok(Err(error)) => json!({ "success": false, "error": error }),
Err(_) => {
json!({ "success": false, "error": "evaluation worker panicked" })
}
},
};
items.push(item);
}
Value::Array(items)
});
let Ok(serialized_results) = serde_json::to_string(&results) else {
return ZenResult::error(ZenError::JsonSerializationFailed);
};
let cstring_result = unsafe { CString::from_vec_unchecked(serialized_results.into_bytes()) };
ZenResult::ok(cstring_result.into_raw())
}
/// Loads a Decision through DecisionEngine
/// Caller is responsible for freeing: key and ZenResult.
#[no_mangle]
@@ -174,3 +296,147 @@ pub extern "C" fn zen_engine_get_decision(
let zen_decision = ZenDecision::from(decision);
ZenResult::ok(Box::into_raw(Box::new(zen_decision)) as *mut ZenDecisionStruct)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::ZenErrorDiscriminants;
use crate::loader::ZenLoaderConfigKind;
use std::ffi::CString;
use std::ptr::null;
fn evaluate_table(engine: *mut ZenEngineStruct) -> Value {
let key = CString::new("table.json").unwrap();
let context = CString::new(r#"{"input":12}"#).unwrap();
let result = zen_engine_evaluate(
engine,
key.as_ptr(),
context.as_ptr(),
ZenEngineEvaluationOptions {
trace: false,
max_depth: 5,
},
);
assert_eq!(result.error_code(), 0);
let response = unsafe { CString::from_raw(result.result_ptr()) };
serde_json::from_slice(response.to_bytes()).unwrap()
}
#[test]
fn engine_from_static_loader_config() {
let content = CString::new(format!(
r#"{{"table.json": {}}}"#,
include_str!("../../../test-data/table.json")
))
.unwrap();
let config = ZenEngineLoaderConfig {
kind: ZenLoaderConfigKind::Static,
content: content.as_ptr(),
bytes: null(),
bytes_len: 0,
};
let result = zen_engine_new_with_loader_config(config, None);
assert_eq!(result.error_code(), 0);
let engine = result.result_ptr();
let response = evaluate_table(engine);
assert_eq!(response["result"]["output"], serde_json::json!(10));
zen_engine_free(engine);
}
#[test]
fn engine_from_fs_loader_config() {
let path = CString::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../test-data")).unwrap();
let config = ZenEngineLoaderConfig {
kind: ZenLoaderConfigKind::Filesystem,
content: path.as_ptr(),
bytes: null(),
bytes_len: 0,
};
let result = zen_engine_new_with_loader_config(config, None);
assert_eq!(result.error_code(), 0);
let engine = result.result_ptr();
let response = evaluate_table(engine);
assert_eq!(response["result"]["output"], serde_json::json!(10));
zen_engine_free(engine);
}
#[test]
fn engine_evaluate_batch_mixed_results() {
let path = CString::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../test-data")).unwrap();
let config = ZenEngineLoaderConfig {
kind: ZenLoaderConfigKind::Filesystem,
content: path.as_ptr(),
bytes: null(),
bytes_len: 0,
};
let engine = zen_engine_new_with_loader_config(config, None).result_ptr();
let keys = [
CString::new("table.json").unwrap(),
CString::new("missing.json").unwrap(),
CString::new("table.json").unwrap(),
];
let contexts = [
CString::new(r#"{"input":12}"#).unwrap(),
CString::new(r#"{}"#).unwrap(),
CString::new(r#"{"input":5}"#).unwrap(),
];
let requests: Vec<ZenEngineEvaluateBatchRequest> = keys
.iter()
.zip(contexts.iter())
.map(|(key, context)| ZenEngineEvaluateBatchRequest {
key: key.as_ptr(),
context: context.as_ptr(),
})
.collect();
let result = zen_engine_evaluate_batch(
engine,
requests.as_ptr(),
requests.len(),
ZenEngineEvaluationOptions {
trace: false,
max_depth: 5,
},
);
assert_eq!(result.error_code(), 0);
let response = unsafe { CString::from_raw(result.result_ptr()) };
let items: Value = serde_json::from_slice(response.to_bytes()).unwrap();
assert_eq!(items[0]["success"], serde_json::json!(true));
assert_eq!(items[0]["data"]["result"]["output"], serde_json::json!(10));
assert_eq!(items[1]["success"], serde_json::json!(false));
assert_eq!(items[2]["success"], serde_json::json!(true));
assert_eq!(items[2]["data"]["result"]["output"], serde_json::json!(0));
zen_engine_free(engine);
}
#[test]
fn engine_from_invalid_zip_loader_config() {
let bytes = [0u8; 4];
let config = ZenEngineLoaderConfig {
kind: ZenLoaderConfigKind::Zip,
content: null(),
bytes: bytes.as_ptr(),
bytes_len: bytes.len(),
};
let result = zen_engine_new_with_loader_config(config, None);
assert_eq!(
result.error_code(),
ZenErrorDiscriminants::LoaderConfigError as u8
);
assert!(result.result_ptr().is_null());
}
}
+5
View File
@@ -21,6 +21,8 @@ pub enum ZenError {
LoaderInternalError { key: String, message: String },
TemplateEngineError { template: String, message: String },
LoaderConfigError { message: String },
}
impl ZenError {
@@ -35,6 +37,9 @@ impl ZenError {
ZenError::TemplateEngineError { template, message } => {
Some(json!({ "template": template, "message": message }).to_string())
}
ZenError::LoaderConfigError { message } => {
Some(json!({ "message": message }).to_string())
}
_ => None,
}
}
+23 -1
View File
@@ -1,6 +1,7 @@
use crate::custom_node::{DynamicCustomNode, ZenCustomNodeResult};
use crate::engine::{ZenEngine, ZenEngineStruct};
use crate::loader::{DynamicDecisionLoader, ZenDecisionLoaderResult};
use crate::loader::{DynamicDecisionLoader, ZenDecisionLoaderResult, ZenEngineLoaderConfig};
use crate::result::ZenResult;
use std::ffi::{c_char, CString};
use std::future::Future;
use std::pin::Pin;
@@ -103,6 +104,27 @@ pub extern "C" fn zen_engine_new_golang(
Box::into_raw(Box::new(engine)) as *mut ZenEngineStruct
}
/// Creates a DecisionEngine from a loader configuration using GoLang handler (optional). Caller is responsible for freeing DecisionEngine.
#[no_mangle]
pub extern "C" fn zen_engine_new_golang_with_loader_config(
config: ZenEngineLoaderConfig,
maybe_custom_node: Option<&usize>,
) -> ZenResult<ZenEngineStruct> {
let loader = match config.to_dynamic_loader() {
Ok(loader) => loader,
Err(error) => return ZenResult::error(error),
};
let custom_node = GoCustomNode::new(map_handler(maybe_custom_node.cloned()));
let engine = ZenEngine::new(
DynamicDecisionLoader::Config(loader),
DynamicCustomNode::Go(custom_node),
);
engine.compile();
ZenResult::ok(Box::into_raw(Box::new(engine)) as *mut ZenEngineStruct)
}
#[allow(unused_doc_comments)]
/// cbindgen:ignore
extern "C" {
+58 -1
View File
@@ -5,15 +5,20 @@ use std::sync::Arc;
use anyhow::anyhow;
use zen_engine::loader::{DecisionLoader, LoaderError, LoaderResponse, NoopLoader};
use zen_engine::loader::{
DecisionLoader, DynamicLoader, LoaderConfig, LoaderError, LoaderResponse, NoopLoader,
};
use zen_engine::model::DecisionContent;
use crate::error::ZenError;
use crate::helper::safe_str_from_ptr;
use crate::languages::native::NativeDecisionLoader;
#[derive(Debug)]
pub(crate) enum DynamicDecisionLoader {
Noop(NoopLoader),
Native(NativeDecisionLoader),
Config(DynamicLoader),
#[cfg(feature = "go")]
Go(crate::languages::go::GoDecisionLoader),
}
@@ -33,6 +38,7 @@ impl DecisionLoader for DynamicDecisionLoader {
match self {
DynamicDecisionLoader::Noop(loader) => loader.load(key).await,
DynamicDecisionLoader::Native(loader) => loader.load(key).await,
DynamicDecisionLoader::Config(loader) => loader.load(key).await,
#[cfg(feature = "go")]
DynamicDecisionLoader::Go(loader) => loader.load(key).await,
}
@@ -40,6 +46,57 @@ impl DecisionLoader for DynamicDecisionLoader {
}
}
#[allow(dead_code)]
#[repr(C)]
pub enum ZenLoaderConfigKind {
Static,
Filesystem,
Zip,
}
#[repr(C)]
pub struct ZenEngineLoaderConfig {
pub(crate) kind: ZenLoaderConfigKind,
pub(crate) content: *const c_char,
pub(crate) bytes: *const u8,
pub(crate) bytes_len: usize,
}
impl ZenEngineLoaderConfig {
pub(crate) fn to_dynamic_loader(&self) -> Result<DynamicLoader, ZenError> {
let config = match self.kind {
ZenLoaderConfigKind::Static => {
let content = safe_str_from_ptr(self.content).ok_or(ZenError::InvalidArgument)?;
LoaderConfig::Static {
content: serde_json::from_str(content)
.map_err(|_| ZenError::JsonDeserializationFailed)?,
}
}
ZenLoaderConfigKind::Filesystem => {
let path = safe_str_from_ptr(self.content).ok_or(ZenError::InvalidArgument)?;
LoaderConfig::Filesystem {
path: path.to_string(),
}
}
ZenLoaderConfigKind::Zip => {
if self.bytes.is_null() {
return Err(ZenError::InvalidArgument);
}
let bytes =
unsafe { std::slice::from_raw_parts(self.bytes, self.bytes_len) }.to_vec();
LoaderConfig::Zip { bytes }
}
};
config
.into_loader()
.map_err(|e| ZenError::LoaderConfigError {
message: e.to_string(),
})
}
}
#[repr(C)]
pub struct ZenDecisionLoaderResult {
content: *mut c_char,
+9
View File
@@ -1,7 +1,16 @@
use std::sync::{Arc, OnceLock};
use std::thread::available_parallelism;
use tokio::runtime;
use tokio::runtime::Runtime;
use tokio_util::task::LocalPoolHandle;
pub(crate) fn worker_pool() -> LocalPoolHandle {
static LOCAL_POOL: OnceLock<LocalPoolHandle> = OnceLock::new();
LOCAL_POOL
.get_or_init(|| LocalPoolHandle::new(available_parallelism().map(Into::into).unwrap_or(1)))
.clone()
}
pub(crate) fn tokio_runtime() -> Arc<Runtime> {
static RUNTIME: OnceLock<Arc<Runtime>> = OnceLock::new();
+11
View File
@@ -51,6 +51,17 @@ impl<T> ZenResult<T> {
}
}
#[cfg(test)]
impl<T> ZenResult<T> {
pub(crate) fn result_ptr(&self) -> *mut T {
self.result
}
pub(crate) fn error_code(&self) -> u8 {
self.error
}
}
impl<T> From<&Box<EvaluationError>> for ZenResult<T> {
fn from(evaluation_error: &Box<EvaluationError>) -> Self {
let Ok(value) = serde_json::to_value(evaluation_error) else {
+56 -5
View File
@@ -3,6 +3,12 @@
#include <stdint.h>
#include <stdlib.h>
typedef enum ZenLoaderConfigKind {
ZenLoaderConfigKind_Static,
ZenLoaderConfigKind_Filesystem,
ZenLoaderConfigKind_Zip,
} ZenLoaderConfigKind;
typedef struct ZenDecisionStruct {
uint8_t _data[0];
} ZenDecisionStruct;
@@ -26,6 +32,28 @@ typedef struct ZenEngineStruct {
uint8_t _data[0];
} ZenEngineStruct;
/**
* CResult can be seen as Either<Result, Error>. It cannot, and should not, be initialized
* manually. Instead, use error or ok functions for initialisation.
*/
typedef struct ZenResult_ZenEngineStruct {
struct ZenEngineStruct *result;
uint8_t error;
char *details;
} ZenResult_ZenEngineStruct;
typedef struct ZenEngineLoaderConfig {
enum ZenLoaderConfigKind kind;
const char *content;
const uint8_t *bytes;
uintptr_t bytes_len;
} ZenEngineLoaderConfig;
typedef struct ZenCustomNodeResult {
char *content;
char *error;
} ZenCustomNodeResult;
/**
* CResult can be seen as Either<Result, Error>. It cannot, and should not, be initialized
* manually. Instead, use error or ok functions for initialisation.
@@ -36,6 +64,11 @@ typedef struct ZenResult_ZenDecisionStruct {
char *details;
} ZenResult_ZenDecisionStruct;
typedef struct ZenEngineEvaluateBatchRequest {
const char *key;
const char *context;
} ZenEngineEvaluateBatchRequest;
/**
* CResult can be seen as Either<Result, Error>. It cannot, and should not, be initialized
* manually. Instead, use error or ok functions for initialisation.
@@ -53,11 +86,6 @@ typedef struct ZenDecisionLoaderResult {
typedef struct ZenDecisionLoaderResult (*ZenDecisionLoaderNativeCallback)(const char *key);
typedef struct ZenCustomNodeResult {
char *content;
char *error;
} ZenCustomNodeResult;
typedef struct ZenCustomNodeResult (*ZenCustomNodeNativeCallback)(const char *request);
/**
@@ -79,6 +107,13 @@ struct ZenResult_c_char zen_decision_evaluate(const struct ZenDecisionStruct *de
*/
struct ZenEngineStruct *zen_engine_new(void);
/**
* Creates a new ZenEngine instance from a loader configuration, caller is responsible for
* freeing the returned reference by calling zen_engine_free.
*/
struct ZenResult_ZenEngineStruct zen_engine_new_with_loader_config(struct ZenEngineLoaderConfig config,
struct ZenCustomNodeResult (*maybe_custom_node)(const char *request));
/**
* Frees the ZenEngine instance reference from the memory
*/
@@ -100,6 +135,16 @@ struct ZenResult_c_char zen_engine_evaluate(const struct ZenEngineStruct *engine
const char *context,
struct ZenEngineEvaluationOptions options);
/**
* Evaluates a batch of requests in parallel using a DecisionEngine reference via loader.
* Returns a JSON array of { success, data?, error? } envelopes in request order.
* Caller is responsible for freeing: requests and ZenResult.
*/
struct ZenResult_c_char zen_engine_evaluate_batch(const struct ZenEngineStruct *engine,
const struct ZenEngineEvaluateBatchRequest *requests,
uintptr_t requests_len,
struct ZenEngineEvaluationOptions options);
/**
* Loads a Decision through DecisionEngine
* Caller is responsible for freeing: key and ZenResult.
@@ -135,3 +180,9 @@ struct ZenEngineStruct *zen_engine_new_native(ZenDecisionLoaderNativeCallback lo
*/
struct ZenEngineStruct *zen_engine_new_golang(const uintptr_t *maybe_loader,
const uintptr_t *maybe_custom_node);
/**
* Creates a DecisionEngine from a loader configuration using GoLang handler (optional). Caller is responsible for freeing DecisionEngine.
*/
struct ZenResult_ZenEngineStruct zen_engine_new_golang_with_loader_config(struct ZenEngineLoaderConfig config,
const uintptr_t *maybe_custom_node);
+128 -11
View File
@@ -8,13 +8,15 @@ use crate::mt::{block_on, worker_pool};
use crate::value::PyValue;
use crate::variable::PyVariable;
use anyhow::{anyhow, Context};
use pyo3::prelude::{PyAnyMethods, PyDictMethods};
use pyo3::types::PyDict;
use pyo3::prelude::{PyAnyMethods, PyDictMethods, PyListMethods};
use pyo3::types::{PyDict, PyList};
use pyo3::{pyclass, pymethods, Bound, FromPyObject, IntoPyObjectExt, Py, PyAny, PyResult, Python};
use pyo3_async_runtimes::tokio::get_current_locals;
use pyo3_async_runtimes::{tokio, TaskLocals};
use pythonize::{depythonize, pythonize};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use zen_engine::loader::{DynamicLoader, LoaderConfig};
use zen_engine::{DecisionEngine, EvaluationOptions};
#[pyclass]
@@ -76,6 +78,53 @@ impl Default for PyZenEngine {
}
}
pub struct PyBatchRequest {
key: String,
context: Value,
}
impl<'py> FromPyObject<'py> for PyBatchRequest {
fn extract_bound(ob: &Bound<'py, PyAny>) -> PyResult<Self> {
let dict = ob.downcast::<PyDict>()?;
let key: String = dict
.get_item("key")?
.ok_or_else(|| anyhow!("batch request requires a 'key'"))?
.extract()?;
let context = dict
.get_item("context")?
.ok_or_else(|| anyhow!("batch request requires a 'context'"))?
.extract::<PyValue>()?;
Ok(Self {
key,
context: context.0,
})
}
}
impl PyZenEngine {
fn config_loader(config: &Bound<'_, PyDict>) -> PyResult<DynamicLoader> {
let loader_type: Option<String> =
config.get_item("type")?.map(|v| v.extract()).transpose()?;
let loader_config = match loader_type.as_deref() {
Some("zip") => {
let bytes = config
.get_item("bytes")?
.ok_or_else(|| anyhow!("zip loader requires a 'bytes' value"))?;
LoaderConfig::Zip {
bytes: bytes.extract()?,
}
}
_ => depythonize(config.as_any())?,
};
Ok(loader_config.into_loader()?)
}
}
#[pymethods]
impl PyZenEngine {
#[new]
@@ -85,11 +134,6 @@ impl PyZenEngine {
return Ok(Default::default());
};
let loader = match options.get_item("loader")? {
Some(loader) => Some(loader.into_py_any(py)?),
None => None,
};
let custom_node = match options.get_item("customHandler")? {
Some(custom_node) => Some(custom_node.into_py_any(py)?),
None => None,
@@ -102,11 +146,25 @@ impl PyZenEngine {
.flatten()
};
let loader: DynamicLoader = match options.get_item("loader")? {
Some(loader) => match loader.downcast::<PyDict>() {
Ok(config) => Self::config_loader(config)?,
Err(_) => Arc::new(PyDecisionLoader::new(
Some(loader.into_py_any(py)?),
make_locals(),
)),
},
None => Arc::new(PyDecisionLoader::default()),
};
let engine = DecisionEngine::new(
loader,
Arc::new(PyCustomNode::new(custom_node, make_locals())),
);
engine.compile();
Ok(Self {
engine: Arc::new(DecisionEngine::new(
Arc::new(PyDecisionLoader::new(loader, make_locals())),
Arc::new(PyCustomNode::new(custom_node, make_locals())),
)),
engine: Arc::new(engine),
})
}
@@ -131,6 +189,65 @@ impl PyZenEngine {
crate::convert::response_to_py(py, result)
}
#[pyo3(signature = (requests, opts=None))]
pub fn evaluate_batch(
&self,
py: Python,
requests: Vec<PyBatchRequest>,
opts: Option<PyZenEvaluateOptions>,
) -> PyResult<Py<PyAny>> {
let options: EvaluationOptions = opts.unwrap_or_default().into();
let handles: Vec<_> = requests
.into_iter()
.map(|request| {
let engine = self.engine.clone();
worker_pool().spawn_pinned(move || async move {
engine
.evaluate_with_opts(request.key, request.context.into(), options)
.await
.map(crate::convert::PortableResponse::build)
.map_err(|e| {
serde_json::to_value(e.as_ref())
.unwrap_or_else(|_| Value::String(e.to_string()))
})
})
})
.collect();
let results = py.allow_threads(|| {
block_on(async move {
let mut out = Vec::with_capacity(handles.len());
for handle in handles {
out.push(handle.await);
}
out
})
});
let list = PyList::empty(py);
for result in results {
let item = PyDict::new(py);
match result {
Ok(Ok(response)) => {
item.set_item("success", true)?;
item.set_item("data", response.into_py(py)?)?;
}
Ok(Err(error)) => {
item.set_item("success", false)?;
item.set_item("error", pythonize(py, &error)?)?;
}
Err(_) => {
item.set_item("success", false)?;
item.set_item("error", "evaluation worker panicked")?;
}
}
list.append(item)?;
}
Ok(list.into_py_any(py)?)
}
#[pyo3(signature = (key, ctx, opts=None))]
pub fn async_evaluate<'py>(
&'py self,
+56
View File
@@ -68,6 +68,62 @@ class ZenEngine(unittest.TestCase):
self.assertEqual(r2["result"]["sum"], 30)
self.assertEqual(r3["result"]["sum"], 40)
def test_static_loader_config(self):
with open("../../test-data/table.json", "r") as f:
table_content = json.loads(f.read())
engine = zen.ZenEngine({"loader": {"type": "static", "content": {"table.json": table_content}}})
r1 = engine.evaluate("table.json", {"input": 2})
r2 = engine.evaluate("table.json", {"input": 12})
self.assertEqual(r1["result"]["output"], 0)
self.assertEqual(r2["result"]["output"], 10)
self.assertRaises(RuntimeError, engine.evaluate, "missing.json", {})
def test_fs_loader_config(self):
engine = zen.ZenEngine({"loader": {"type": "fs", "path": "../../test-data"}})
r1 = engine.evaluate("table.json", {"input": 2})
r2 = engine.evaluate("table.json", {"input": 12})
self.assertEqual(r1["result"]["output"], 0)
self.assertEqual(r2["result"]["output"], 10)
def test_zip_loader_config(self):
import io
import zipfile
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
with open("../../test-data/table.json", "rb") as f:
archive.writestr("table.json", f.read())
engine = zen.ZenEngine({"loader": {"type": "zip", "bytes": buffer.getvalue()}})
r1 = engine.evaluate("table.json", {"input": 2})
r2 = engine.evaluate("table.json", {"input": 12})
self.assertEqual(r1["result"]["output"], 0)
self.assertEqual(r2["result"]["output"], 10)
def test_evaluate_batch(self):
engine = zen.ZenEngine({"loader": {"type": "fs", "path": "../../test-data"}})
results = engine.evaluate_batch([
{"key": "table.json", "context": {"input": 12}},
{"key": "missing.json", "context": {}},
{"key": "table.json", "context": {"input": 5}},
])
self.assertEqual(len(results), 3)
self.assertTrue(results[0]["success"])
self.assertEqual(results[0]["data"]["result"]["output"], 10)
self.assertFalse(results[1]["success"])
self.assertIn("error", results[1])
self.assertTrue(results[2]["success"])
self.assertEqual(results[2]["data"]["result"]["output"], 0)
def test_evaluate_batch_empty(self):
engine = zen.ZenEngine({"loader": loader})
self.assertEqual(engine.evaluate_batch([]), [])
def test_evaluate_expression(self):
result = zen.evaluate_expression("sum(a)", {"a": [1, 2, 3, 4]})
self.assertEqual(result, 10)
+40 -2
View File
@@ -1,4 +1,4 @@
from collections.abc import Awaitable
from collections.abc import Awaitable, Callable
from typing import Any, Optional, TypedDict, Literal, TypeAlias, Union
@@ -17,12 +17,50 @@ ZenContext: TypeAlias = Union[str, bytes, dict]
ZenDecisionContentInput: TypeAlias = Union[str, ZenDecisionContent]
class StaticLoaderConfig(TypedDict):
type: Literal["static"]
content: dict[str, dict]
class FilesystemLoaderConfig(TypedDict):
type: Literal["fs"]
path: str
class ZipLoaderConfig(TypedDict):
type: Literal["zip"]
bytes: bytes
ZenLoaderConfig: TypeAlias = Union[StaticLoaderConfig, FilesystemLoaderConfig, ZipLoaderConfig]
ZenLoaderCallback: TypeAlias = Callable[[str], Union[str, dict, ZenDecisionContent, Awaitable[Union[str, dict, ZenDecisionContent]]]]
class ZenEngineOptions(TypedDict, total=False):
loader: Union[ZenLoaderCallback, ZenLoaderConfig]
customHandler: Callable
class EvaluateBatchRequest(TypedDict):
key: str
context: Any
class EvaluateBatchResult(TypedDict, total=False):
success: bool
data: EvaluateResponse
error: Any
class ZenEngine:
def __init__(self, options: Optional[dict] = None) -> None: ...
def __init__(self, options: Optional[ZenEngineOptions] = None) -> None: ...
def evaluate(self, key: str, context: ZenContext,
options: Optional[DecisionEvaluateOptions] = None) -> EvaluateResponse: ...
def evaluate_batch(self, requests: list[EvaluateBatchRequest],
options: Optional[DecisionEvaluateOptions] = None) -> list[EvaluateBatchResult]: ...
def async_evaluate(self, key: str, context: ZenContext, options: Optional[DecisionEvaluateOptions] = None) -> \
Awaitable[EvaluateResponse]: ...
+3
View File
@@ -21,6 +21,9 @@ serde = { workspace = true, features = ["derive"] }
async-trait = "0.1"
tokio = "1.46"
[dev-dependencies]
tokio = { version = "1.46", features = ["macros", "rt-multi-thread"] }
[build-dependencies]
uniffi = { version = "0.29", features = ["build"] }
+12
View File
@@ -22,6 +22,18 @@ Console.WriteLine(response.result);
```
## Loader Configurations
The `loader` argument accepts either a callback (`ZenLoader.Callback`) or a loader configuration
of a known type. With a configuration, decisions are pre-loaded and pre-compiled at engine
creation for faster evaluations:
```csharp
var fsEngine = new ZenEngine(loader: new ZenLoader.Filesystem("decisions"));
var zipEngine = new ZenEngine(loader: new ZenLoader.Zip(File.ReadAllBytes("decisions.zip")));
var cbEngine = new ZenEngine(loader: new ZenLoader.Callback(new FileLoader()));
```
## Features
- **Decision Tables** - Rule tables with first/collect hit policies
+192 -16
View File
@@ -3,14 +3,13 @@ use crate::custom_node::{
};
use crate::decision::ZenDecision;
use crate::error::ZenError;
use crate::loader::{
NoopDecisionLoader, ZenDecisionLoaderCallback, ZenDecisionLoaderCallbackWrapper,
};
use crate::types::{JsonBuffer, ZenEngineResponse};
use crate::loader::{NoopDecisionLoader, ZenDecisionLoaderCallbackWrapper, ZenLoader};
use crate::types::{JsonBuffer, ZenBatchRequest, ZenBatchResult, ZenEngineResponse};
use serde_json::Value;
use std::sync::Arc;
use tokio::runtime::Handle;
use tokio::task;
use zen_engine::loader::DynamicLoader;
use zen_engine::{DecisionEngine, EvaluationOptions};
#[derive(uniffi::Object)]
@@ -44,21 +43,29 @@ impl From<ZenEvaluateOptions> for EvaluationOptions {
#[uniffi::export(async_runtime = "tokio")]
impl ZenEngine {
#[uniffi::constructor]
#[uniffi::constructor(default(loader = None, custom_node = None))]
pub fn new(
loader: Option<Box<dyn ZenDecisionLoaderCallback>>,
loader: Option<ZenLoader>,
custom_node: Option<Box<dyn ZenCustomNodeCallback>>,
) -> Self {
Self {
engine: Arc::new(DecisionEngine::new(
Arc::new(ZenDecisionLoaderCallbackWrapper(
loader.unwrap_or_else(|| Box::new(NoopDecisionLoader)),
)),
Arc::new(ZenCustomNodeCallbackWrapper(
custom_node.unwrap_or_else(|| Box::new(NoopCustomNodeCallback)),
)),
) -> Result<Self, ZenError> {
let loader: DynamicLoader = match loader {
Some(loader) => loader.into_dynamic_loader()?,
None => Arc::new(ZenDecisionLoaderCallbackWrapper(Arc::new(
NoopDecisionLoader,
))),
};
let engine = DecisionEngine::new(
loader,
Arc::new(ZenCustomNodeCallbackWrapper(
custom_node.unwrap_or_else(|| Box::new(NoopCustomNodeCallback)),
)),
}
);
engine.compile();
Ok(Self {
engine: Arc::new(engine),
})
}
pub async fn evaluate(
@@ -94,6 +101,61 @@ impl ZenEngine {
Ok(response)
}
pub async fn evaluate_batch(
&self,
requests: Vec<ZenBatchRequest>,
options: Option<ZenEvaluateOptions>,
) -> Vec<ZenBatchResult> {
let options: EvaluationOptions = options.unwrap_or_default().into();
let handles: Vec<_> = requests
.into_iter()
.map(|request| {
let engine = self.engine.clone();
task::spawn_blocking(move || {
Handle::current().block_on(async move {
let context: Value = request.context.try_into()?;
let response = engine
.evaluate_with_opts(request.key, context.into(), options)
.await
.map_err(|err| {
ZenError::EvaluationError(
serde_json::to_string(&err.as_ref())
.unwrap_or_else(|_| err.to_string()),
)
})?;
ZenEngineResponse::try_from(response)
})
})
})
.collect();
let mut results = Vec::with_capacity(handles.len());
for handle in handles {
let result = match handle.await {
Ok(Ok(data)) => ZenBatchResult {
success: true,
data: Some(data),
error: None,
},
Ok(Err(error)) => ZenBatchResult {
success: false,
data: None,
error: Some(error.details()),
},
Err(_) => ZenBatchResult {
success: false,
data: None,
error: Some("evaluation worker panicked".to_string()),
},
};
results.push(result);
}
results
}
pub fn create_decision(&self, content: JsonBuffer) -> Result<ZenDecision, ZenError> {
let decision = self
.engine
@@ -131,3 +193,117 @@ impl ZenEngine {
Ok(decision)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::loader::ZenLoader;
use std::collections::HashMap;
async fn assert_table_output(engine: &ZenEngine) {
let response = engine
.evaluate(
"table.json".to_string(),
JsonBuffer(br#"{"input":12}"#.to_vec()),
None,
)
.await
.unwrap();
let result: Value = response.result.try_into().unwrap();
assert_eq!(result["output"], serde_json::json!(10));
}
#[tokio::test(flavor = "multi_thread")]
async fn with_static_loader_config() {
let content = HashMap::from([(
"table.json".to_string(),
JsonBuffer(include_bytes!("../../../test-data/table.json").to_vec()),
)]);
let engine = ZenEngine::new(Some(ZenLoader::Static { content }), None).unwrap();
assert_table_output(&engine).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn with_filesystem_loader_config() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../test-data").to_string();
let engine = ZenEngine::new(Some(ZenLoader::Filesystem { path }), None).unwrap();
assert_table_output(&engine).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn with_callback_loader() {
struct FsCallback;
#[async_trait::async_trait]
impl crate::loader::ZenDecisionLoaderCallback for FsCallback {
async fn load(&self, key: String) -> Result<Option<JsonBuffer>, ZenError> {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../test-data");
Ok(std::fs::read(format!("{path}/{key}")).ok().map(JsonBuffer))
}
}
let engine = ZenEngine::new(
Some(ZenLoader::Callback {
callback: Arc::new(FsCallback),
}),
None,
)
.unwrap();
assert_table_output(&engine).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn evaluate_batch_mixed_results() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../test-data").to_string();
let engine = ZenEngine::new(Some(ZenLoader::Filesystem { path }), None).unwrap();
let requests = vec![
ZenBatchRequest {
key: "table.json".to_string(),
context: JsonBuffer(br#"{"input":12}"#.to_vec()),
},
ZenBatchRequest {
key: "missing.json".to_string(),
context: JsonBuffer(b"{}".to_vec()),
},
ZenBatchRequest {
key: "table.json".to_string(),
context: JsonBuffer(br#"{"input":5}"#.to_vec()),
},
];
let results = engine.evaluate_batch(requests, None).await;
assert_eq!(results.len(), 3);
assert!(results[0].success);
let first: Value =
serde_json::from_slice(&results[0].data.as_ref().unwrap().result.0).unwrap();
assert_eq!(first["output"], serde_json::json!(10));
assert!(!results[1].success);
assert!(results[1].error.is_some());
assert!(results[2].success);
let third: Value =
serde_json::from_slice(&results[2].data.as_ref().unwrap().result.0).unwrap();
assert_eq!(third["output"], serde_json::json!(0));
}
#[tokio::test(flavor = "multi_thread")]
async fn with_static_loader_config_missing_key() {
let engine = ZenEngine::new(
Some(ZenLoader::Static {
content: HashMap::new(),
}),
None,
)
.unwrap();
let result = engine
.evaluate("missing.json".to_string(), JsonBuffer(b"{}".to_vec()), None)
.await;
assert!(result.is_err());
}
}
+51 -3
View File
@@ -1,14 +1,17 @@
use crate::error::ZenError;
use crate::types::JsonBuffer;
use std::collections::HashMap;
use std::fmt::{Debug, Formatter};
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use uniffi::deps::anyhow::anyhow;
use zen_engine::loader::{DecisionLoader, LoaderError, LoaderResponse};
use zen_engine::loader::{
DecisionLoader, DynamicLoader, LoaderConfig, LoaderError, LoaderResponse,
};
use zen_engine::model::DecisionContent;
#[uniffi::export(callback_interface)]
#[uniffi::export(with_foreign)]
#[async_trait::async_trait]
pub trait ZenDecisionLoaderCallback: Send + Sync {
async fn load(&self, key: String) -> Result<Option<JsonBuffer>, ZenError>;
@@ -23,7 +26,52 @@ impl ZenDecisionLoaderCallback for NoopDecisionLoader {
}
}
pub struct ZenDecisionLoaderCallbackWrapper(pub Box<dyn ZenDecisionLoaderCallback>);
#[derive(uniffi::Enum)]
pub enum ZenLoader {
Callback {
callback: Arc<dyn ZenDecisionLoaderCallback>,
},
Static {
content: HashMap<String, JsonBuffer>,
},
Filesystem {
path: String,
},
Zip {
bytes: Vec<u8>,
},
}
impl ZenLoader {
pub fn into_dynamic_loader(self) -> Result<DynamicLoader, ZenError> {
let config = match self {
ZenLoader::Callback { callback } => {
return Ok(Arc::new(ZenDecisionLoaderCallbackWrapper(callback)));
}
ZenLoader::Static { content } => {
let content = content
.into_iter()
.map(|(key, buffer)| {
let decision_content: DecisionContent =
serde_json::from_slice(buffer.0.as_slice())
.map_err(|_| ZenError::JsonDeserializationFailed)?;
Ok((key, decision_content))
})
.collect::<Result<HashMap<_, _>, ZenError>>()?;
LoaderConfig::Static { content }
}
ZenLoader::Filesystem { path } => LoaderConfig::Filesystem { path },
ZenLoader::Zip { bytes } => LoaderConfig::Zip { bytes },
};
config
.into_loader()
.map_err(|e| ZenError::ValidationError(e.to_string()))
}
}
pub struct ZenDecisionLoaderCallbackWrapper(pub Arc<dyn ZenDecisionLoaderCallback>);
impl Debug for ZenDecisionLoaderCallbackWrapper {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+13
View File
@@ -98,6 +98,19 @@ impl TryFrom<DecisionGraphResponse> for ZenEngineResponse {
}
}
#[derive(uniffi::Record)]
pub struct ZenBatchRequest {
pub key: String,
pub context: JsonBuffer,
}
#[derive(uniffi::Record)]
pub struct ZenBatchResult {
pub success: bool,
pub data: Option<ZenEngineResponse>,
pub error: Option<String>,
}
#[derive(uniffi::Record)]
pub struct ZenEngineHandlerResponse {
pub output: JsonBuffer,
+1 -1
View File
@@ -34,7 +34,7 @@ impl Debug for DecisionEngine {
}
}
#[derive(Debug)]
#[derive(Debug, Clone, Copy)]
pub struct EvaluationOptions {
pub trace: bool,
pub max_depth: u8,