use super::DAL;
use crate::database::schema::unified::{reactor_firings, reactor_trigger_subscriptions};
use crate::database::universal_types::{
UniversalBinary, UniversalBool, UniversalTimestamp, UniversalUuid,
};
use crate::error::ValidationError;
use diesel::prelude::*;
use uuid::Uuid;
#[derive(Debug, Clone, Queryable)]
pub struct ReactorFiring {
pub id: UniversalUuid,
pub reactor_name: String,
pub tenant_id: String,
pub payload: Option<UniversalBinary>,
pub fired_at: UniversalTimestamp,
pub created_at: UniversalTimestamp,
}
#[derive(Debug, Clone, Queryable)]
pub struct ReactorSubscription {
pub id: UniversalUuid,
pub reactor_name: String,
pub workflow_name: String,
pub tenant_id: String,
pub enabled: UniversalBool,
pub last_seen_fired_at: Option<UniversalTimestamp>,
pub created_at: UniversalTimestamp,
pub updated_at: UniversalTimestamp,
pub predicate_expression: Option<String>,
pub predicate_error_count: i32,
pub predicate_error_firing_id: Option<UniversalUuid>,
pub last_predicate_error: Option<String>,
pub last_predicate_error_at: Option<UniversalTimestamp>,
pub predicate_degraded: UniversalBool,
}
pub const PREDICATE_VARIABLES: [&str; 3] = ["payload", "reactor", "tenant"];
pub const PREDICATE_TEXT_TRUNCATE_LEN: usize = 240;
pub fn truncate_predicate_text(s: &str) -> String {
if s.chars().count() <= PREDICATE_TEXT_TRUNCATE_LEN {
return s.to_string();
}
let head: String = s.chars().take(PREDICATE_TEXT_TRUNCATE_LEN).collect();
format!("{}…[truncated]", head)
}
pub fn compile_predicate(expr: &str) -> Result<cel_interpreter::Program, String> {
match std::panic::catch_unwind(|| cel_interpreter::Program::compile(expr)) {
Ok(Ok(program)) => Ok(program),
Ok(Err(e)) => Err(e.to_string()),
Err(_) => Err(format!(
"could not parse CEL predicate '{}' (the CEL parser rejected it)",
truncate_predicate_text(expr)
)),
}
}
pub fn lint_predicate_variables(expr: &str) -> Result<(), String> {
use cel_parser::ast::{EntryExpr, Expr, IdedExpr};
use std::collections::BTreeSet;
let parsed = match std::panic::catch_unwind(|| cel_parser::Parser::default().parse(expr)) {
Ok(Ok(parsed)) => parsed,
Ok(Err(e)) => return Err(format!("{}", e)),
Err(_) => {
return Err(format!(
"could not parse CEL predicate '{}' (the CEL parser rejected it)",
truncate_predicate_text(expr)
))
}
};
fn walk(node: &IdedExpr, bound: &mut Vec<String>, free: &mut BTreeSet<String>) {
match &node.expr {
Expr::Unspecified | Expr::Literal(_) => {}
Expr::Ident(name) => {
if !name.starts_with('@') && !bound.iter().any(|b| b == name) {
free.insert(name.clone());
}
}
Expr::Select(select) => walk(&select.operand, bound, free),
Expr::Call(call) => {
if let Some(target) = &call.target {
walk(target, bound, free);
}
for arg in &call.args {
walk(arg, bound, free);
}
}
Expr::List(list) => {
for elem in &list.elements {
walk(elem, bound, free);
}
}
Expr::Map(map) => {
for entry in &map.entries {
walk_entry(&entry.expr, bound, free);
}
}
Expr::Struct(s) => {
for entry in &s.entries {
walk_entry(&entry.expr, bound, free);
}
}
Expr::Comprehension(comp) => {
walk(&comp.iter_range, bound, free);
walk(&comp.accu_init, bound, free);
let depth = bound.len();
bound.push(comp.iter_var.clone());
if let Some(v2) = &comp.iter_var2 {
bound.push(v2.clone());
}
bound.push(comp.accu_var.clone());
walk(&comp.loop_cond, bound, free);
walk(&comp.loop_step, bound, free);
walk(&comp.result, bound, free);
bound.truncate(depth);
}
}
}
fn walk_entry(entry: &EntryExpr, bound: &mut Vec<String>, free: &mut BTreeSet<String>) {
match entry {
EntryExpr::StructField(field) => walk(&field.value, bound, free),
EntryExpr::MapEntry(e) => {
walk(&e.key, bound, free);
walk(&e.value, bound, free);
}
}
}
let mut bound: Vec<String> = Vec::new();
let mut free: BTreeSet<String> = BTreeSet::new();
walk(&parsed, &mut bound, &mut free);
let unknown: Vec<String> = free
.into_iter()
.filter(|name| !PREDICATE_VARIABLES.contains(&name.as_str()))
.collect();
if unknown.is_empty() {
return Ok(());
}
Err(format!(
"predicate references unknown variable(s): {}. Available variables are: {}. \
(Did you mean a field of `payload`, e.g. `payload.{}`?)",
unknown.join(", "),
PREDICATE_VARIABLES.join(", "),
unknown[0],
))
}
#[derive(Clone)]
pub struct ReactorSubscriptionsDAL<'a> {
dal: &'a DAL,
}
impl<'a> ReactorSubscriptionsDAL<'a> {
pub fn new(dal: &'a DAL) -> Self {
Self { dal }
}
pub async fn insert_firing(
&self,
reactor: &str,
tenant: &str,
payload: Option<Vec<u8>>,
fired_at: UniversalTimestamp,
) -> Result<Uuid, ValidationError> {
let id = UniversalUuid::new_v4();
let now = UniversalTimestamp::now();
let reactor = reactor.to_string();
let tenant = tenant.to_string();
let id_for_move = id;
crate::interact_on_backend!(self.dal, |conn| {
diesel::insert_into(reactor_firings::table)
.values((
reactor_firings::id.eq(id_for_move),
reactor_firings::reactor_name.eq(reactor),
reactor_firings::tenant_id.eq(tenant),
reactor_firings::payload.eq(payload.map(UniversalBinary::new)),
reactor_firings::fired_at.eq(fired_at),
reactor_firings::created_at.eq(now),
))
.execute(conn)
})?;
Ok(id.0)
}
pub async fn poll_unconsumed(
&self,
tenant: &str,
reactor: &str,
after: Option<UniversalTimestamp>,
limit: i64,
) -> Result<Vec<ReactorFiring>, ValidationError> {
let tenant = tenant.to_string();
let reactor = reactor.to_string();
let rows: Vec<ReactorFiring> = crate::interact_on_backend!(self.dal, |conn| {
let mut q = reactor_firings::table
.filter(reactor_firings::tenant_id.eq(tenant))
.filter(reactor_firings::reactor_name.eq(reactor))
.into_boxed();
if let Some(after) = after {
q = q.filter(reactor_firings::fired_at.gt(after));
}
q.order(reactor_firings::fired_at.asc())
.limit(limit)
.load::<ReactorFiring>(conn)
})?;
Ok(rows)
}
pub async fn prune_firings_older_than(
&self,
cutoff: UniversalTimestamp,
) -> Result<usize, ValidationError> {
let n = crate::interact_on_backend!(self.dal, |conn| {
diesel::delete(reactor_firings::table.filter(reactor_firings::fired_at.lt(cutoff)))
.execute(conn)
})?;
Ok(n)
}
pub async fn subscribe(
&self,
reactor: &str,
workflow: &str,
tenant: &str,
predicate: Option<&str>,
) -> Result<Uuid, ValidationError> {
if let Some(expr) = predicate {
compile_predicate(expr).map_err(ValidationError::InvalidPredicate)?;
lint_predicate_variables(expr).map_err(ValidationError::InvalidPredicate)?;
}
let predicate = predicate.map(str::to_string);
crate::dispatch_backend!(
self.dal.backend(),
self.subscribe_postgres(reactor, workflow, tenant, predicate.clone())
.await,
self.subscribe_sqlite(reactor, workflow, tenant, predicate)
.await
)
}
#[cfg(feature = "postgres")]
async fn subscribe_postgres(
&self,
reactor: &str,
workflow: &str,
tenant: &str,
predicate: Option<String>,
) -> Result<Uuid, ValidationError> {
let conn = self
.dal
.database
.get_postgres_connection()
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?;
let id = UniversalUuid::new_v4();
let now = UniversalTimestamp::now();
let reactor = reactor.to_string();
let workflow = workflow.to_string();
let tenant = tenant.to_string();
let predicate_for_update = predicate.clone();
let row: ReactorSubscription = conn
.interact(move |conn| {
diesel::insert_into(reactor_trigger_subscriptions::table)
.values((
reactor_trigger_subscriptions::id.eq(id),
reactor_trigger_subscriptions::reactor_name.eq(&reactor),
reactor_trigger_subscriptions::workflow_name.eq(&workflow),
reactor_trigger_subscriptions::tenant_id.eq(&tenant),
reactor_trigger_subscriptions::enabled.eq(UniversalBool::from(true)),
reactor_trigger_subscriptions::created_at.eq(now),
reactor_trigger_subscriptions::updated_at.eq(now),
reactor_trigger_subscriptions::predicate_expression.eq(&predicate),
))
.on_conflict((
reactor_trigger_subscriptions::reactor_name,
reactor_trigger_subscriptions::workflow_name,
reactor_trigger_subscriptions::tenant_id,
))
.do_update()
.set((
reactor_trigger_subscriptions::updated_at.eq(now),
reactor_trigger_subscriptions::predicate_expression
.eq(&predicate_for_update),
))
.get_result::<ReactorSubscription>(conn)
})
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))??;
Ok(row.id.0)
}
#[cfg(feature = "sqlite")]
async fn subscribe_sqlite(
&self,
reactor: &str,
workflow: &str,
tenant: &str,
predicate: Option<String>,
) -> Result<Uuid, ValidationError> {
let conn = self
.dal
.database
.get_sqlite_connection()
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))?;
let new_id = UniversalUuid::new_v4();
let now = UniversalTimestamp::now();
let reactor = reactor.to_string();
let workflow = workflow.to_string();
let tenant = tenant.to_string();
let row: ReactorSubscription = conn
.interact(move |conn| {
let insert_result = diesel::insert_into(reactor_trigger_subscriptions::table)
.values((
reactor_trigger_subscriptions::id.eq(new_id),
reactor_trigger_subscriptions::reactor_name.eq(&reactor),
reactor_trigger_subscriptions::workflow_name.eq(&workflow),
reactor_trigger_subscriptions::tenant_id.eq(&tenant),
reactor_trigger_subscriptions::enabled.eq(UniversalBool::from(true)),
reactor_trigger_subscriptions::created_at.eq(now),
reactor_trigger_subscriptions::updated_at.eq(now),
reactor_trigger_subscriptions::predicate_expression.eq(&predicate),
))
.execute(conn);
match insert_result {
Ok(_) => reactor_trigger_subscriptions::table
.filter(reactor_trigger_subscriptions::id.eq(new_id))
.first::<ReactorSubscription>(conn),
Err(diesel::result::Error::DatabaseError(
diesel::result::DatabaseErrorKind::UniqueViolation,
_,
)) => {
diesel::update(
reactor_trigger_subscriptions::table
.filter(reactor_trigger_subscriptions::reactor_name.eq(&reactor))
.filter(reactor_trigger_subscriptions::workflow_name.eq(&workflow))
.filter(reactor_trigger_subscriptions::tenant_id.eq(&tenant)),
)
.set((
reactor_trigger_subscriptions::updated_at.eq(now),
reactor_trigger_subscriptions::predicate_expression.eq(&predicate),
))
.execute(conn)?;
reactor_trigger_subscriptions::table
.filter(reactor_trigger_subscriptions::reactor_name.eq(&reactor))
.filter(reactor_trigger_subscriptions::workflow_name.eq(&workflow))
.filter(reactor_trigger_subscriptions::tenant_id.eq(&tenant))
.first::<ReactorSubscription>(conn)
}
Err(e) => Err(e),
}
})
.await
.map_err(|e| ValidationError::ConnectionPool(e.to_string()))??;
Ok(row.id.0)
}
pub async fn advance_watermark(
&self,
subscription_id: Uuid,
new_last_seen: UniversalTimestamp,
) -> Result<(), ValidationError> {
let sid = UniversalUuid(subscription_id);
let now = UniversalTimestamp::now();
crate::interact_on_backend!(self.dal, |conn| {
diesel::update(
reactor_trigger_subscriptions::table
.filter(reactor_trigger_subscriptions::id.eq(sid)),
)
.set((
reactor_trigger_subscriptions::last_seen_fired_at.eq(Some(new_last_seen)),
reactor_trigger_subscriptions::updated_at.eq(now),
))
.execute(conn)
})?;
Ok(())
}
pub async fn record_predicate_error(
&self,
subscription_id: Uuid,
firing_id: UniversalUuid,
error: &str,
) -> Result<i32, ValidationError> {
let sid = UniversalUuid(subscription_id);
let now = UniversalTimestamp::now();
let error = truncate_predicate_text(error);
let count = crate::interact_on_backend!(self.dal, |conn| {
let (prev_count, prev_firing): (i32, Option<UniversalUuid>) =
reactor_trigger_subscriptions::table
.filter(reactor_trigger_subscriptions::id.eq(sid))
.select((
reactor_trigger_subscriptions::predicate_error_count,
reactor_trigger_subscriptions::predicate_error_firing_id,
))
.first(conn)?;
let next = if prev_firing == Some(firing_id) {
prev_count.saturating_add(1)
} else {
1
};
diesel::update(
reactor_trigger_subscriptions::table
.filter(reactor_trigger_subscriptions::id.eq(sid)),
)
.set((
reactor_trigger_subscriptions::predicate_error_count.eq(next),
reactor_trigger_subscriptions::predicate_error_firing_id.eq(Some(firing_id)),
reactor_trigger_subscriptions::last_predicate_error.eq(Some(error)),
reactor_trigger_subscriptions::last_predicate_error_at.eq(Some(now)),
reactor_trigger_subscriptions::updated_at.eq(now),
))
.execute(conn)?;
Ok::<i32, diesel::result::Error>(next)
})?;
Ok(count)
}
pub async fn mark_predicate_degraded(
&self,
subscription_id: Uuid,
firing_id: UniversalUuid,
error: &str,
) -> Result<(), ValidationError> {
let sid = UniversalUuid(subscription_id);
let now = UniversalTimestamp::now();
let error = truncate_predicate_text(error);
crate::interact_on_backend!(self.dal, |conn| {
diesel::update(
reactor_trigger_subscriptions::table
.filter(reactor_trigger_subscriptions::id.eq(sid)),
)
.set((
reactor_trigger_subscriptions::predicate_degraded.eq(UniversalBool::from(true)),
reactor_trigger_subscriptions::predicate_error_count.eq(0),
reactor_trigger_subscriptions::predicate_error_firing_id.eq(Some(firing_id)),
reactor_trigger_subscriptions::last_predicate_error.eq(Some(error)),
reactor_trigger_subscriptions::last_predicate_error_at.eq(Some(now)),
reactor_trigger_subscriptions::updated_at.eq(now),
))
.execute(conn)
})?;
Ok(())
}
pub async fn clear_predicate_error(
&self,
subscription_id: Uuid,
) -> Result<(), ValidationError> {
let sid = UniversalUuid(subscription_id);
let now = UniversalTimestamp::now();
crate::interact_on_backend!(self.dal, |conn| {
diesel::update(
reactor_trigger_subscriptions::table
.filter(reactor_trigger_subscriptions::id.eq(sid)),
)
.set((
reactor_trigger_subscriptions::predicate_error_count.eq(0),
reactor_trigger_subscriptions::predicate_error_firing_id.eq(None::<UniversalUuid>),
reactor_trigger_subscriptions::predicate_degraded.eq(UniversalBool::from(false)),
reactor_trigger_subscriptions::updated_at.eq(now),
))
.execute(conn)
})?;
Ok(())
}
pub async fn get_subscription(
&self,
subscription_id: Uuid,
) -> Result<Option<ReactorSubscription>, ValidationError> {
let sid = UniversalUuid(subscription_id);
let row = crate::interact_on_backend!(self.dal, |conn| {
reactor_trigger_subscriptions::table
.filter(reactor_trigger_subscriptions::id.eq(sid))
.first::<ReactorSubscription>(conn)
.optional()
})?;
Ok(row)
}
pub async fn unsubscribe(
&self,
reactor: &str,
workflow: &str,
tenant: &str,
) -> Result<bool, ValidationError> {
let reactor = reactor.to_string();
let workflow = workflow.to_string();
let tenant = tenant.to_string();
let n = crate::interact_on_backend!(self.dal, |conn| {
diesel::delete(
reactor_trigger_subscriptions::table
.filter(reactor_trigger_subscriptions::reactor_name.eq(reactor))
.filter(reactor_trigger_subscriptions::workflow_name.eq(workflow))
.filter(reactor_trigger_subscriptions::tenant_id.eq(tenant)),
)
.execute(conn)
})?;
Ok(n > 0)
}
pub async fn list_all_enabled(&self) -> Result<Vec<ReactorSubscription>, ValidationError> {
let rows = crate::interact_on_backend!(self.dal, |conn| {
reactor_trigger_subscriptions::table
.filter(reactor_trigger_subscriptions::enabled.eq(UniversalBool::from(true)))
.load::<ReactorSubscription>(conn)
})?;
Ok(rows)
}
pub async fn list_subscriptions(
&self,
tenant: &str,
) -> Result<Vec<ReactorSubscription>, ValidationError> {
let tenant = tenant.to_string();
let rows = crate::interact_on_backend!(self.dal, |conn| {
reactor_trigger_subscriptions::table
.filter(reactor_trigger_subscriptions::tenant_id.eq(tenant))
.filter(reactor_trigger_subscriptions::enabled.eq(UniversalBool::from(true)))
.load::<ReactorSubscription>(conn)
})?;
Ok(rows)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lint_accepts_the_bound_variables() {
lint_predicate_variables("payload.x > 1 && tenant == 'acme'").expect("payload + tenant");
lint_predicate_variables("reactor == 'pricing'").expect("reactor");
lint_predicate_variables("payload.price > 100 && payload.region == 'us-east'")
.expect("nested payload selects");
lint_predicate_variables("tenant == 'acme' || tenant == 'public'").expect("tenant only");
}
#[test]
fn lint_rejects_a_typod_identifier() {
let err = lint_predicate_variables("payload.x > 1 && tennant == 'acme'")
.expect_err("typo'd `tennant` must be rejected");
assert!(
err.contains("tennant"),
"error should name the offending variable, got: {}",
err
);
assert!(
err.contains("payload") && err.contains("reactor") && err.contains("tenant"),
"error should list the allowed variables, got: {}",
err
);
}
#[test]
fn lint_rejects_a_bare_unknown_identifier() {
assert!(lint_predicate_variables("foo").is_err());
assert!(lint_predicate_variables("payload.a == bar").is_err());
}
#[test]
fn lint_accepts_comprehension_iteration_variables() {
lint_predicate_variables("payload.items.exists(i, i.price > 100)").expect("exists");
lint_predicate_variables("payload.items.all(x, x > 0)").expect("all");
lint_predicate_variables("payload.items.filter(v, v != 0).size() > 0").expect("filter");
lint_predicate_variables("payload.items.map(m, m.id).size() > 1").expect("map");
lint_predicate_variables("payload.a.exists(i, payload.b.exists(j, i == j))")
.expect("nested comprehensions");
}
#[test]
fn lint_rejects_iteration_variable_used_outside_its_scope() {
assert!(
lint_predicate_variables("payload.items.exists(i, i > 0) && i > 1").is_err(),
"`i` outside the comprehension body is a free variable"
);
}
#[test]
fn lint_accepts_builtin_functions_and_literals() {
lint_predicate_variables("size(payload.items) > 0").expect("size()");
lint_predicate_variables("has(payload.price)").expect("has()");
lint_predicate_variables("payload.name.startsWith('acme')").expect("receiver call");
lint_predicate_variables("1 > 0").expect("no variables at all");
lint_predicate_variables("payload.tags == ['a', 'b']").expect("list literal");
lint_predicate_variables("{'k': payload.v}['k'] == 1").expect("map literal");
}
#[test]
fn malformed_predicates_error_instead_of_panicking() {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let compile = std::panic::catch_unwind(|| compile_predicate("payload.x >"));
let lint = std::panic::catch_unwind(|| lint_predicate_variables("payload.x >"));
std::panic::set_hook(previous);
let compile = compile.expect("compile_predicate must not unwind");
assert!(compile.is_err(), "malformed predicate must be an error");
let lint = lint.expect("lint_predicate_variables must not unwind");
assert!(lint.is_err(), "malformed predicate must be an error");
}
#[test]
fn compile_predicate_accepts_valid_expressions() {
assert!(compile_predicate("payload.x > 1 && tenant == 'acme'").is_ok());
assert!(compile_predicate("this is not cel ((").is_err());
}
#[test]
fn lint_allowed_set_matches_the_documented_variables() {
assert_eq!(PREDICATE_VARIABLES, ["payload", "reactor", "tenant"]);
}
#[test]
fn truncate_predicate_text_is_char_safe_and_marks_truncation() {
let short = "payload.x > 1";
assert_eq!(truncate_predicate_text(short), short);
let long = "é".repeat(PREDICATE_TEXT_TRUNCATE_LEN + 50);
let out = truncate_predicate_text(&long);
assert!(out.ends_with("…[truncated]"));
assert_eq!(
out.chars().count(),
PREDICATE_TEXT_TRUNCATE_LEN + "…[truncated]".chars().count()
);
}
}