use crate::ctx::{ChildOptions, Ctx};
use crate::error::{Error, Result};
use crate::runtime::{ParentClose, Registry, RunOutcome, WorkflowFn};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::cell::{Cell, RefCell};
use std::collections::VecDeque;
use std::future::Future;
use std::pin::Pin;
use std::rc::Rc;
use std::sync::Arc;
use std::task::{Poll, Waker};
use std::time::Duration;
pub const DEBOUNCE_TYPE: &str = "fusee.debounce";
pub const BATCH_TYPE: &str = "fusee.batch";
pub const EVENT: &str = "fusee.event";
pub const ENDED: &[u8] = b"the workflow ended before the update finished";
pub const DEFAULT_DEDUP_MS: u64 = 600_000;
pub const MAX_BATCH: u32 = 10_000;
const SEEN_CAP: usize = 1_024;
const CONTINUE_AT: u32 = 400;
const MIN_LIFE_MS: u64 = 1_000;
pub fn debounce_id(workflow: &str, key: &str) -> String {
format!("{DEBOUNCE_TYPE}/{workflow}/{key}")
}
pub fn batch_id(workflow: &str, key: &str) -> String {
format!("{BATCH_TYPE}/{workflow}/{key}")
}
pub fn request(workflow: &str, key: &str, event: &Event) -> (&'static str, String, Value, Value) {
let (wtype, id) = match event.rule {
Rule::Debounce(_) => (DEBOUNCE_TYPE, debounce_id(workflow, key)),
Rule::Batch(_) => (BATCH_TYPE, batch_id(workflow, key)),
};
let state = QueueState {
workflow: workflow.to_owned(),
key: key.to_owned(),
..Default::default()
};
(
wtype,
id,
serde_json::to_value(&state).unwrap_or_default(),
serde_json::to_value(event).unwrap_or_default(),
)
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Debounce {
pub window_ms: u64,
pub max_wait_ms: u64,
pub dedup_ms: u64,
}
impl Default for Debounce {
fn default() -> Self {
Self {
window_ms: 0,
max_wait_ms: 0,
dedup_ms: DEFAULT_DEDUP_MS,
}
}
}
impl Debounce {
pub fn new(window: Duration) -> Self {
Self {
window_ms: crate::ctx::millis(window),
..Self::default()
}
}
pub fn max_wait(mut self, d: Duration) -> Self {
self.max_wait_ms = crate::ctx::millis(d);
self
}
pub fn dedup_window(mut self, d: Duration) -> Self {
self.dedup_ms = crate::ctx::millis(d);
self
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct Batch {
pub max_events: u32,
pub max_wait_ms: u64,
pub dedup_ms: u64,
}
impl Default for Batch {
fn default() -> Self {
Self {
max_events: 100,
max_wait_ms: 1_000,
dedup_ms: DEFAULT_DEDUP_MS,
}
}
}
impl Batch {
pub fn new(max_events: u32, max_wait: Duration) -> Self {
Self {
max_events,
max_wait_ms: crate::ctx::millis(max_wait),
..Self::default()
}
}
pub fn dedup_window(mut self, d: Duration) -> Self {
self.dedup_ms = crate::ctx::millis(d);
self
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Rule {
Debounce(Debounce),
Batch(Batch),
}
impl Rule {
pub fn validate(&self) -> Result<()> {
let bad = |m: &str| Err(Error::Rejected(m.into()));
match self {
Rule::Debounce(_) => Ok(()),
Rule::Batch(b) if b.max_events == 0 => bad("a batch takes at least one event"),
Rule::Batch(b) if b.max_events > MAX_BATCH => {
bad(&format!("a batch takes at most {MAX_BATCH} events"))
}
Rule::Batch(_) => Ok(()),
}
}
fn dedup_ms(&self) -> u64 {
match self {
Rule::Debounce(d) => d.dedup_ms,
Rule::Batch(b) => b.dedup_ms,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Event {
#[serde(default)]
pub id: String,
pub rule: Rule,
pub input: Value,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Receipt {
pub id: String,
pub due_ms: u64,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Window {
pub id: String,
pub opened_ms: u64,
pub last_ms: u64,
pub rule: Rule,
pub inputs: Vec<Value>,
}
impl Window {
fn due(&self) -> u64 {
match &self.rule {
Rule::Debounce(d) => {
let quiet = self.last_ms.saturating_add(d.window_ms);
if d.max_wait_ms > 0 {
quiet.min(self.opened_ms.saturating_add(d.max_wait_ms))
} else {
quiet
}
}
Rule::Batch(b) if self.inputs.len() >= b.max_events as usize => self.last_ms,
Rule::Batch(b) => self.opened_ms.saturating_add(b.max_wait_ms),
}
}
fn input(&self) -> Value {
match &self.rule {
Rule::Debounce(_) => self.inputs.last().cloned().unwrap_or(Value::Null),
Rule::Batch(_) => Value::Array(self.inputs.clone()),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Seen {
pub id: String,
pub receipt: Receipt,
pub until_ms: u64,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct QueueState {
pub workflow: String,
pub key: String,
pub open: Option<Window>,
pub seen: VecDeque<Seen>,
pub started: u64,
#[doc(hidden)]
#[serde(skip_serializing_if = "is_zero")]
pub continue_at: u32,
}
fn is_zero(n: &u32) -> bool {
*n == 0
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Kind {
Debounce,
Batch,
}
struct Shared {
state: RefCell<QueueState>,
armed: Cell<Option<u64>>,
woken: Cell<bool>,
waker: RefCell<Option<Waker>>,
continue_at: u32,
}
impl Shared {
fn wake(&self) {
self.woken.set(true);
if let Some(w) = self.waker.borrow_mut().take() {
w.wake();
}
}
}
fn fire(ctx: &Ctx, sh: &Shared) -> Result<()> {
let (workflow, id, input) = {
let st = sh.state.borrow();
let Some(w) = st.open.as_ref() else {
return Ok(());
};
(st.workflow.clone(), w.id.clone(), w.input())
};
ctx.run_start_child(
&workflow,
&id,
&input,
ChildOptions {
close: ParentClose::Abandon,
timeout: None,
},
)?;
let mut st = sh.state.borrow_mut();
st.open = None;
st.started += 1;
Ok(())
}
fn check(kind: Kind, ev: &Event) -> Result<()> {
match (&ev.rule, kind) {
(Rule::Debounce(_), Kind::Debounce) | (Rule::Batch(_), Kind::Batch) => ev.rule.validate(),
_ => Err(Error::Rejected(
"the event's rule does not match its coordinator".into(),
)),
}
}
fn accept(ctx: &Ctx, kind: Kind, sh: &Shared, ev: Event) -> Result<Receipt> {
check(kind, &ev)?;
if !ev.id.is_empty()
&& let Some(s) = sh.state.borrow().seen.iter().find(|s| s.id == ev.id)
{
return Ok(s.receipt.clone());
}
let now = ctx.now_ms()?;
let opening = sh.state.borrow().open.is_none();
let fresh = if opening {
let key = sh.state.borrow().key.clone();
Some(format!("{key}@{}", ctx.uuid()?))
} else {
None
};
let (receipt, full) = {
let mut st = sh.state.borrow_mut();
let w = st.open.get_or_insert_with(|| Window {
id: fresh.unwrap_or_default(),
opened_ms: now,
last_ms: now,
rule: ev.rule.clone(),
inputs: Vec::new(),
});
w.last_ms = w.last_ms.max(now);
w.rule = ev.rule.clone();
if matches!(w.rule, Rule::Debounce(_)) {
w.inputs.clear();
}
w.inputs.push(ev.input);
let full = matches!(&w.rule, Rule::Batch(b) if w.inputs.len() >= b.max_events as usize);
let receipt = Receipt {
id: w.id.clone(),
due_ms: w.due(),
};
if !ev.id.is_empty() {
st.seen.push_back(Seen {
id: ev.id,
receipt: receipt.clone(),
until_ms: now.saturating_add(ev.rule.dedup_ms()),
});
while st.seen.len() > SEEN_CAP {
st.seen.pop_front();
}
}
(receipt, full)
};
if full {
fire(ctx, sh)?;
}
let due = sh.state.borrow().open.as_ref().map(Window::due);
let earlier = matches!((due, sh.armed.get()), (Some(d), Some(a)) if d < a);
if earlier || ctx.journal_size() >= sh.continue_at {
sh.wake();
}
Ok(receipt)
}
async fn wait(ctx: &Ctx, sh: &Shared, until: u64) -> Result<()> {
let mut timer = std::pin::pin!(ctx.sleep_until("window", until));
std::future::poll_fn(|cx| {
if sh.woken.replace(false) {
return Poll::Ready(Ok(()));
}
*sh.waker.borrow_mut() = Some(cx.waker().clone());
timer.as_mut().poll(cx)
})
.await
}
async fn coordinate(ctx: Ctx, kind: Kind, input: QueueState) -> Result<Value> {
let continue_at = match input.continue_at {
0 => CONTINUE_AT,
n => n.max(8),
};
let sh = Rc::new(Shared {
state: RefCell::new(input),
armed: Cell::new(None),
woken: Cell::new(false),
waker: RefCell::new(None),
continue_at,
});
{
let s = sh.clone();
ctx.on_query("describe", move |_: Value| {
let st = s.state.borrow();
serde_json::json!({
"workflow": st.workflow,
"key": st.key,
"open": st.open.as_ref().map(|w| serde_json::json!({
"id": w.id,
"events": w.inputs.len(),
"dueMs": w.due(),
})),
"started": st.started,
"rememberedIds": st.seen.len(),
})
});
}
{
let (s, c) = (sh.clone(), ctx.clone());
ctx.on_update(
EVENT,
move |ev: &Event| check(kind, ev).map_err(|e| e.to_string()),
move |ev: Event| {
let r = accept(&c, kind, &s, ev);
async move { r }
},
);
}
loop {
if ctx.journal_size() >= continue_at {
let st = sh.state.borrow().clone();
return ctx.continue_as_new(&st).await;
}
let now = ctx.now_ms()?;
let due = sh.state.borrow().open.as_ref().map(Window::due);
let until = match due {
Some(due) if due <= now => {
fire(&ctx, &sh)?;
continue;
}
Some(due) => due,
None => {
let mut st = sh.state.borrow_mut();
st.seen.retain(|s| s.until_ms > now);
let linger = st
.seen
.iter()
.map(|s| s.until_ms)
.max()
.unwrap_or(0)
.max(ctx.started_ms().saturating_add(MIN_LIFE_MS));
if linger <= now {
return Ok(serde_json::json!({ "started": st.started }));
}
linger
}
};
sh.armed.set(Some(until));
let r = wait(&ctx, &sh, until).await;
sh.armed.set(None);
r?;
}
}
pub fn register_builtins(r: &mut Registry) {
for (name, kind) in [(DEBOUNCE_TYPE, Kind::Debounce), (BATCH_TYPE, Kind::Batch)] {
let f: WorkflowFn = Arc::new(move |ctx: Ctx| {
let st = ctx.input_bytes(crate::ctx::decode::<QueueState>);
Box::pin(async move {
let st: QueueState = st.map_err(crate::engine::outcome_of)?;
let out = coordinate(ctx, kind, st)
.await
.map_err(crate::engine::outcome_of)?;
crate::ctx::encode(&out).map_err(crate::engine::outcome_of)
}) as Pin<Box<dyn Future<Output = RunOutcome>>>
});
r.register(name, 1, f);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn windows_close_on_their_rule() {
let w = |rule, n| Window {
id: "k@1".into(),
opened_ms: 1_000,
last_ms: 1_400,
rule,
inputs: vec![Value::Null; n],
};
let d = Debounce::new(Duration::from_millis(300));
assert_eq!(w(Rule::Debounce(d.clone()), 1).due(), 1_700);
let capped = d.max_wait(Duration::from_millis(500));
assert_eq!(w(Rule::Debounce(capped), 1).due(), 1_500);
let b = Batch::new(3, Duration::from_millis(2_000));
assert_eq!(w(Rule::Batch(b.clone()), 2).due(), 3_000);
assert_eq!(w(Rule::Batch(b), 3).due(), 1_400);
assert!(
Rule::Batch(Batch::new(0, Duration::ZERO))
.validate()
.is_err()
);
assert!(
Rule::Batch(Batch::new(MAX_BATCH + 1, Duration::ZERO))
.validate()
.is_err()
);
}
#[test]
fn events_have_a_stable_json_form() {
let ev = Event {
id: "e-1".into(),
rule: Rule::Batch(Batch::new(10, Duration::from_secs(1))),
input: serde_json::json!({"n": 1}),
};
let v = serde_json::to_value(&ev).unwrap();
assert_eq!(
v,
serde_json::json!({
"id": "e-1",
"rule": {"kind": "batch", "maxEvents": 10, "maxWaitMs": 1000, "dedupMs": 600000},
"input": {"n": 1},
})
);
let d: Event = serde_json::from_value(serde_json::json!({
"rule": {"kind": "debounce", "windowMs": 50},
"input": 7,
}))
.unwrap();
assert_eq!(d.id, "");
assert_eq!(
d.rule,
Rule::Debounce(Debounce {
window_ms: 50,
max_wait_ms: 0,
dedup_ms: DEFAULT_DEDUP_MS,
})
);
}
}