notify router: read custom flows and disabled built-ins from a stored document
This commit is contained in:
parent
03fc548b0a
commit
bb08fac33c
11 changed files with 1418 additions and 342 deletions
|
|
@ -163,6 +163,22 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// The names a stored flow may read are the ones `fields` produces.
|
||||
#[test]
|
||||
fn the_fields_are_the_ones_a_stored_flow_may_name() {
|
||||
let d = Delivery::parse(&issue_assign("assigned", &["atlas"])).expect("parses");
|
||||
for event in ["issues", "issue_assign"] {
|
||||
let mut got: Vec<&str> = fields(event, &d, &Snapshot::default())
|
||||
.keys()
|
||||
.copied()
|
||||
.collect();
|
||||
let mut want = swarm_wire::notify_flows::FIELDS.to_vec();
|
||||
got.sort_unstable();
|
||||
want.sort_unstable();
|
||||
assert_eq!(got, want, "{event}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_issue_delivery_reads_back_its_fields() {
|
||||
let d = Delivery::parse(&issue_assign("assigned", &["atlas", "iris"])).expect("parses");
|
||||
|
|
|
|||
|
|
@ -1,26 +1,17 @@
|
|||
//! Flows: what the router does with an event.
|
||||
//! Running a flow over one event.
|
||||
//!
|
||||
//! A flow is a graph of nodes joined by edges, rooted at one `source` node.
|
||||
//! A `match` node passes when every one of its conditions holds, and then
|
||||
//! each of its children runs, so one event can reach several branches. A
|
||||
//! `sink` node is a leaf and emits one todo op per recipient.
|
||||
//!
|
||||
//! Flows are trees: one source, one parent for every other node, every node
|
||||
//! reachable from the source. [`Flow::validate`] checks that, and the router
|
||||
//! runs only flows that pass.
|
||||
//!
|
||||
//! The built-in flows ([`builtins`]) are ordinary flows defined in code.
|
||||
//! The flow types, their checks and the built-in flows are
|
||||
//! `swarm_wire::notify_flows`; the built-in and the custom flows are the same
|
||||
//! type and this walk runs both. A `match` node passes when every one of its
|
||||
//! conditions holds, and then each of its children runs, so one event can
|
||||
//! reach several branches. A `todo` node emits one op per recipient. The
|
||||
//! router runs only flows that pass [`Flow::validate`].
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use swarm_wire::notify_flows::{Condition, Flow, Node, NodeKind, Recipients, TodoSink};
|
||||
use swarm_wire::router_todo::{Op, RouterTodoOp};
|
||||
|
||||
/// The most nodes one flow may have.
|
||||
const MAX_NODES: usize = 64;
|
||||
|
||||
/// The deepest a node may sit below the source.
|
||||
const MAX_DEPTH: usize = 16;
|
||||
|
||||
/// One event, as the fields a flow reads.
|
||||
pub type Fields = BTreeMap<&'static str, Value>;
|
||||
|
||||
|
|
@ -31,56 +22,6 @@ pub enum Value {
|
|||
List(Vec<String>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Flow {
|
||||
pub id: String,
|
||||
pub nodes: Vec<Node>,
|
||||
pub edges: Vec<Edge>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Node {
|
||||
pub id: String,
|
||||
pub kind: NodeKind,
|
||||
}
|
||||
|
||||
/// A directed edge, parent to child.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Edge {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum NodeKind {
|
||||
/// The root: the forge event types the flow starts from.
|
||||
Source { events: Vec<String> },
|
||||
/// Passes when every condition holds.
|
||||
Match { when: Vec<Condition> },
|
||||
/// Emits a todo op to each recipient.
|
||||
Todo(TodoSink),
|
||||
}
|
||||
|
||||
/// `field` equals `value`. A list field holds when one of its items does.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Condition {
|
||||
pub field: String,
|
||||
pub equals: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TodoSink {
|
||||
/// The field naming the recipients, a list of agent names.
|
||||
pub to: String,
|
||||
pub op: Op,
|
||||
/// The todo key, with `{field}` placeholders.
|
||||
pub key: String,
|
||||
/// For a clear: `key` is a prefix.
|
||||
pub prefix: bool,
|
||||
/// The todo text, with `{field}` placeholders. Unused for a clear.
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// One op the walk emitted, for one recipient.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Action {
|
||||
|
|
@ -90,130 +31,61 @@ pub struct Action {
|
|||
pub op: RouterTodoOp,
|
||||
}
|
||||
|
||||
impl Flow {
|
||||
/// Check the flow is a tree within the caps, or say why not.
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.nodes.len() > MAX_NODES {
|
||||
return Err(format!("{} nodes, more than {MAX_NODES}", self.nodes.len()));
|
||||
}
|
||||
let mut ids = BTreeSet::new();
|
||||
for node in &self.nodes {
|
||||
if !ids.insert(node.id.as_str()) {
|
||||
return Err(format!("node id {} is used twice", node.id));
|
||||
}
|
||||
}
|
||||
for edge in &self.edges {
|
||||
for end in [&edge.from, &edge.to] {
|
||||
if !ids.contains(end.as_str()) {
|
||||
return Err(format!("an edge names {end}, which is no node"));
|
||||
/// Walk `flow` for one event of type `event_type` and return the ops it
|
||||
/// emits. Assumes [`Flow::validate`] passed.
|
||||
pub fn evaluate(flow: &Flow, event_type: &str, fields: &Fields) -> Vec<Action> {
|
||||
let Some(root) = flow
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| matches!(&n.kind, NodeKind::Source { events } if events.iter().any(|e| e == event_type)))
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut actions = Vec::new();
|
||||
let mut stack = vec![root.id.as_str()];
|
||||
while let Some(id) = stack.pop() {
|
||||
let Some(node) = flow.node(id) else { continue };
|
||||
match &node.kind {
|
||||
NodeKind::Source { .. } => stack.extend(flow.children(id)),
|
||||
NodeKind::Match { when } => {
|
||||
if when.iter().all(|c| holds(c, fields)) {
|
||||
stack.extend(flow.children(id));
|
||||
}
|
||||
}
|
||||
NodeKind::Todo(sink) => actions.extend(emit(flow, node, sink, fields)),
|
||||
}
|
||||
let roots: Vec<&Node> = self
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|n| matches!(n.kind, NodeKind::Source { .. }))
|
||||
.collect();
|
||||
let [root] = roots.as_slice() else {
|
||||
return Err(format!("{} source nodes, not one", roots.len()));
|
||||
};
|
||||
for node in &self.nodes {
|
||||
if matches!(node.kind, NodeKind::Todo(_)) && !self.children(&node.id).is_empty() {
|
||||
return Err(format!("sink {} has children", node.id));
|
||||
}
|
||||
}
|
||||
// From the one root, every node reached exactly once: a node reached
|
||||
// twice has a second parent or sits on a cycle, and one not reached is
|
||||
// cut off.
|
||||
let mut reached = 0;
|
||||
let mut stack = vec![(root.id.as_str(), 0)];
|
||||
while let Some((id, depth)) = stack.pop() {
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(format!("node {id} is deeper than {MAX_DEPTH}"));
|
||||
}
|
||||
reached += 1;
|
||||
if reached > self.nodes.len() {
|
||||
return Err(
|
||||
"a node is reached twice: it has two parents or sits on a cycle".to_owned(),
|
||||
);
|
||||
}
|
||||
stack.extend(self.children(id).into_iter().map(|c| (c, depth + 1)));
|
||||
}
|
||||
if reached != self.nodes.len() {
|
||||
return Err(format!(
|
||||
"{} of {} nodes are not reachable from the source",
|
||||
self.nodes.len() - reached,
|
||||
self.nodes.len()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
actions
|
||||
}
|
||||
|
||||
fn children(&self, id: &str) -> Vec<&str> {
|
||||
self.edges
|
||||
.iter()
|
||||
.filter(|e| e.from == id)
|
||||
.map(|e| e.to.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn node(&self, id: &str) -> Option<&Node> {
|
||||
self.nodes.iter().find(|n| n.id == id)
|
||||
}
|
||||
|
||||
/// Walk the flow for one event of type `event_type` and return the ops it
|
||||
/// emits. Assumes [`Self::validate`] passed.
|
||||
pub fn evaluate(&self, event_type: &str, fields: &Fields) -> Vec<Action> {
|
||||
let Some(root) = self
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| matches!(&n.kind, NodeKind::Source { events } if events.iter().any(|e| e == event_type)))
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut actions = Vec::new();
|
||||
let mut stack = vec![root.id.as_str()];
|
||||
while let Some(id) = stack.pop() {
|
||||
let Some(node) = self.node(id) else { continue };
|
||||
match &node.kind {
|
||||
NodeKind::Source { .. } => stack.extend(self.children(id)),
|
||||
NodeKind::Match { when } => {
|
||||
if when.iter().all(|c| holds(c, fields)) {
|
||||
stack.extend(self.children(id));
|
||||
}
|
||||
}
|
||||
NodeKind::Todo(sink) => actions.extend(self.emit(node, sink, fields)),
|
||||
}
|
||||
}
|
||||
actions
|
||||
}
|
||||
|
||||
fn emit(&self, node: &Node, sink: &TodoSink, fields: &Fields) -> Vec<Action> {
|
||||
let recipients = match fields.get(sink.to.as_str()) {
|
||||
fn emit(flow: &Flow, node: &Node, sink: &TodoSink, fields: &Fields) -> Vec<Action> {
|
||||
let recipients = match &sink.to {
|
||||
Recipients::Agent(agent) => vec![agent.clone()],
|
||||
Recipients::Field(name) => match fields.get(name.as_str()) {
|
||||
Some(Value::List(items)) => items.clone(),
|
||||
Some(Value::Text(one)) => vec![one.clone()],
|
||||
None => Vec::new(),
|
||||
};
|
||||
let op = RouterTodoOp {
|
||||
op: sink.op,
|
||||
key: expand(&sink.key, fields),
|
||||
prefix: sink.op == Op::Clear && sink.prefix,
|
||||
summary: match sink.op {
|
||||
Op::Upsert => expand(&sink.text, fields),
|
||||
Op::Clear => String::new(),
|
||||
},
|
||||
source: format!("router:{}", self.id),
|
||||
};
|
||||
recipients
|
||||
.into_iter()
|
||||
.map(|agent| Action {
|
||||
flow: self.id.clone(),
|
||||
node: node.id.clone(),
|
||||
agent,
|
||||
op: op.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
},
|
||||
};
|
||||
let op = RouterTodoOp {
|
||||
op: sink.op,
|
||||
key: expand(&sink.key, fields),
|
||||
prefix: sink.op == Op::Clear && sink.prefix,
|
||||
summary: match sink.op {
|
||||
Op::Upsert => expand(&sink.text, fields),
|
||||
Op::Clear => String::new(),
|
||||
},
|
||||
source: format!("router:{}", flow.id),
|
||||
};
|
||||
recipients
|
||||
.into_iter()
|
||||
.map(|agent| Action {
|
||||
flow: flow.id.clone(),
|
||||
node: node.id.clone(),
|
||||
agent,
|
||||
op: op.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn holds(condition: &Condition, fields: &Fields) -> bool {
|
||||
|
|
@ -252,91 +124,11 @@ pub fn expand(template: &str, fields: &Fields) -> String {
|
|||
out
|
||||
}
|
||||
|
||||
/// The flows the router ships with.
|
||||
///
|
||||
/// - `assigned`: a newly assigned agent gets a todo for the issue or PR.
|
||||
/// - `unassigned`: an agent taken off it gets that todo rewritten to say so.
|
||||
/// Both write the same key, so the second replaces the first.
|
||||
/// - `closed`: closing it clears every router todo the router queued for it.
|
||||
pub fn builtins() -> Vec<Flow> {
|
||||
let assignment_key = "forge:{repo}#{number}:assignment";
|
||||
vec![
|
||||
chain(
|
||||
"assigned",
|
||||
&["issue_assign", "pull_request_assign"],
|
||||
"assigned",
|
||||
TodoSink {
|
||||
to: "assignees_added".to_owned(),
|
||||
op: Op::Upsert,
|
||||
key: assignment_key.to_owned(),
|
||||
prefix: false,
|
||||
text: "you are assigned to {kind} {repo}#{number}: {title} ({url})".to_owned(),
|
||||
},
|
||||
),
|
||||
chain(
|
||||
"unassigned",
|
||||
&["issue_assign", "pull_request_assign"],
|
||||
"unassigned",
|
||||
TodoSink {
|
||||
to: "assignees_removed".to_owned(),
|
||||
op: Op::Upsert,
|
||||
key: assignment_key.to_owned(),
|
||||
prefix: false,
|
||||
text: "you are not assigned to {kind} {repo}#{number} anymore: {title} ({url})"
|
||||
.to_owned(),
|
||||
},
|
||||
),
|
||||
chain(
|
||||
"closed",
|
||||
&["issues", "pull_request"],
|
||||
"closed",
|
||||
TodoSink {
|
||||
to: "todo_holders".to_owned(),
|
||||
op: Op::Clear,
|
||||
key: "forge:{repo}#{number}:".to_owned(),
|
||||
prefix: true,
|
||||
text: String::new(),
|
||||
},
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// source(`events`) → match(`action` = `action`) → `sink`.
|
||||
fn chain(id: &str, events: &[&str], action: &str, sink: TodoSink) -> Flow {
|
||||
let node = |id: &str, kind| Node {
|
||||
id: id.to_owned(),
|
||||
kind,
|
||||
};
|
||||
let edge = |from: &str, to: &str| Edge {
|
||||
from: from.to_owned(),
|
||||
to: to.to_owned(),
|
||||
};
|
||||
Flow {
|
||||
id: id.to_owned(),
|
||||
nodes: vec![
|
||||
node(
|
||||
"source",
|
||||
NodeKind::Source {
|
||||
events: events.iter().map(|e| (*e).to_owned()).collect(),
|
||||
},
|
||||
),
|
||||
node(
|
||||
"match",
|
||||
NodeKind::Match {
|
||||
when: vec![Condition {
|
||||
field: "action".to_owned(),
|
||||
equals: action.to_owned(),
|
||||
}],
|
||||
},
|
||||
),
|
||||
node("sink", NodeKind::Todo(sink)),
|
||||
],
|
||||
edges: vec![edge("source", "match"), edge("match", "sink")],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use swarm_wire::notify_flows::{Edge, builtins};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn text(v: &str) -> Value {
|
||||
|
|
@ -368,16 +160,11 @@ mod tests {
|
|||
.expect("a built-in")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_builtin_is_a_valid_tree() {
|
||||
for f in builtins() {
|
||||
assert_eq!(f.validate(), Ok(()), "{}", f.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assigning_queues_a_todo_for_each_newly_assigned_agent_only() {
|
||||
let got = flow("assigned").evaluate(
|
||||
let assigned = flow("assigned");
|
||||
let got = evaluate(
|
||||
&assigned,
|
||||
"issue_assign",
|
||||
&event("assigned", &["atlas", "iris"], &[], &[]),
|
||||
);
|
||||
|
|
@ -392,21 +179,28 @@ mod tests {
|
|||
assert_eq!(got[0].op.source, "router:assigned");
|
||||
// The match gates: an unassign event reaches no assigned sink.
|
||||
assert!(
|
||||
flow("assigned")
|
||||
.evaluate("issue_assign", &event("unassigned", &["atlas"], &[], &[]))
|
||||
.is_empty()
|
||||
evaluate(
|
||||
&assigned,
|
||||
"issue_assign",
|
||||
&event("unassigned", &["atlas"], &[], &[])
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
// The source gates: another event type starts nothing.
|
||||
assert!(
|
||||
flow("assigned")
|
||||
.evaluate("issues", &event("assigned", &["atlas"], &[], &[]))
|
||||
.is_empty()
|
||||
evaluate(
|
||||
&assigned,
|
||||
"issues",
|
||||
&event("assigned", &["atlas"], &[], &[])
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unassigning_rewrites_the_same_key_for_the_removed_agent() {
|
||||
let got = flow("unassigned").evaluate(
|
||||
let got = evaluate(
|
||||
&flow("unassigned"),
|
||||
"pull_request_assign",
|
||||
&event("unassigned", &[], &["argus"], &[]),
|
||||
);
|
||||
|
|
@ -423,8 +217,12 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn closing_clears_by_prefix_for_every_holder() {
|
||||
let got =
|
||||
flow("closed").evaluate("issues", &event("closed", &[], &[], &["atlas", "argus"]));
|
||||
let closed = flow("closed");
|
||||
let got = evaluate(
|
||||
&closed,
|
||||
"issues",
|
||||
&event("closed", &[], &[], &["atlas", "argus"]),
|
||||
);
|
||||
assert_eq!(got.len(), 2);
|
||||
for a in &got {
|
||||
assert_eq!(a.op.op, Op::Clear);
|
||||
|
|
@ -433,9 +231,12 @@ mod tests {
|
|||
assert_eq!(a.op.summary, "");
|
||||
}
|
||||
assert!(
|
||||
flow("closed")
|
||||
.evaluate("issues", &event("reopened", &[], &[], &["atlas"]))
|
||||
.is_empty()
|
||||
evaluate(
|
||||
&closed,
|
||||
"issues",
|
||||
&event("reopened", &[], &[], &["atlas"])
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -450,7 +251,7 @@ mod tests {
|
|||
f.nodes.push(Node {
|
||||
id: "second".to_owned(),
|
||||
kind: NodeKind::Todo(TodoSink {
|
||||
to: "assignees_removed".to_owned(),
|
||||
to: Recipients::Field("assignees_removed".to_owned()),
|
||||
..sink
|
||||
}),
|
||||
});
|
||||
|
|
@ -459,7 +260,8 @@ mod tests {
|
|||
to: "second".to_owned(),
|
||||
});
|
||||
assert_eq!(f.validate(), Ok(()));
|
||||
let got = f.evaluate(
|
||||
let got = evaluate(
|
||||
&f,
|
||||
"issue_assign",
|
||||
&event("assigned", &["atlas"], &["argus"], &[]),
|
||||
);
|
||||
|
|
@ -471,40 +273,26 @@ mod tests {
|
|||
assert_eq!(nodes, [("second", "argus"), ("sink", "atlas")]);
|
||||
}
|
||||
|
||||
/// A custom flow's sink can name one agent, whatever the event holds.
|
||||
#[test]
|
||||
fn a_flow_that_is_not_a_tree_is_refused() {
|
||||
let edge = |from: &str, to: &str| Edge {
|
||||
from: from.to_owned(),
|
||||
to: to.to_owned(),
|
||||
};
|
||||
let mut cycle = flow("assigned");
|
||||
cycle.edges[0] = edge("sink", "match");
|
||||
cycle.edges.push(edge("match", "sink"));
|
||||
assert!(cycle.validate().is_err(), "a cycle");
|
||||
|
||||
let mut orphan = flow("assigned");
|
||||
orphan.edges.pop();
|
||||
assert!(orphan.validate().is_err(), "an unreachable sink");
|
||||
|
||||
let mut two_parents = flow("assigned");
|
||||
two_parents.edges.push(edge("source", "sink"));
|
||||
assert!(two_parents.validate().is_err(), "a sink with two parents");
|
||||
|
||||
let mut sink_child = flow("assigned");
|
||||
sink_child.nodes.push(Node {
|
||||
id: "m2".to_owned(),
|
||||
kind: NodeKind::Match { when: Vec::new() },
|
||||
fn a_sink_to_a_named_agent_reaches_it_once() {
|
||||
let mut f = flow("assigned");
|
||||
f.nodes[2].kind = NodeKind::Todo(TodoSink {
|
||||
to: Recipients::Agent("triage".to_owned()),
|
||||
op: Op::Upsert,
|
||||
key: "forge:{repo}#{number}:triage".to_owned(),
|
||||
prefix: false,
|
||||
text: "triage {repo}#{number}".to_owned(),
|
||||
});
|
||||
sink_child.edges.push(edge("sink", "m2"));
|
||||
assert!(sink_child.validate().is_err(), "a sink with a child");
|
||||
|
||||
let mut dangling = flow("assigned");
|
||||
dangling.edges.push(edge("match", "nowhere"));
|
||||
assert!(dangling.validate().is_err(), "an edge to no node");
|
||||
|
||||
let mut two_sources = flow("assigned");
|
||||
two_sources.nodes[1].kind = NodeKind::Source { events: Vec::new() };
|
||||
assert!(two_sources.validate().is_err(), "two sources");
|
||||
let got = evaluate(
|
||||
&f,
|
||||
"issue_assign",
|
||||
&event("assigned", &["atlas", "iris"], &[], &[]),
|
||||
);
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].agent, "triage");
|
||||
assert_eq!(got[0].op.summary, "triage hyperhive/hyperhive#7");
|
||||
assert_eq!(got[0].op.source, "router:assigned");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
//! (`swarm_wire::forge_events`), runs every flow over each delivery
|
||||
//! ([`flow`], [`route`]), and queues the todo ops the flows emit on each
|
||||
//! recipient agent's subject of `ROUTER_TODOS` (`swarm_wire::router_todo`).
|
||||
//! Each agent's harness applies them to its own todo list.
|
||||
//! Each agent's harness applies them to its own todo list. The flows are the
|
||||
//! built-in ones, changed by the `notify-flows` document ([`stored`]).
|
||||
//!
|
||||
//! Per issue it keeps a snapshot in its state bucket ([`event::Snapshot`]):
|
||||
//! the assignees it last saw and the agents it queued a todo for. An event is
|
||||
|
|
@ -31,6 +32,7 @@ mod event;
|
|||
mod flow;
|
||||
mod route;
|
||||
mod settle;
|
||||
mod stored;
|
||||
|
||||
/// The cert-auth role this service logs in to the store as. `swarm-bao.nix`
|
||||
/// writes it, matching the leaf's subject.
|
||||
|
|
@ -83,17 +85,7 @@ async fn main() -> Result<()> {
|
|||
})
|
||||
.await?;
|
||||
|
||||
let flows: Vec<flow::Flow> = flow::builtins()
|
||||
.into_iter()
|
||||
.filter(|f| match f.validate() {
|
||||
Ok(()) => true,
|
||||
Err(reason) => {
|
||||
tracing::error!(flow = %f.id, %reason, "notify router: skipping a flow that is not a tree");
|
||||
false
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut router = Router::open(&queue, flows).await?;
|
||||
let mut router = Router::open(&queue).await?;
|
||||
tracing::info!(
|
||||
stream = forge_events::STREAM,
|
||||
"notify router: routing forge events"
|
||||
|
|
@ -113,12 +105,12 @@ struct Router {
|
|||
js: jetstream::Context,
|
||||
events: jetstream::consumer::Consumer<pull::Config>,
|
||||
state: kv::Store,
|
||||
flows: Vec<flow::Flow>,
|
||||
flows: stored::Flows,
|
||||
roster: Option<(Instant, BTreeSet<String>)>,
|
||||
}
|
||||
|
||||
impl Router {
|
||||
async fn open(queue: &async_nats::Client, flows: Vec<flow::Flow>) -> Result<Self> {
|
||||
async fn open(queue: &async_nats::Client) -> Result<Self> {
|
||||
let js = jetstream::new(queue.clone());
|
||||
let events = swarm_queue_client::forge_events::open_or_create(queue)
|
||||
.await
|
||||
|
|
@ -154,7 +146,7 @@ impl Router {
|
|||
js,
|
||||
events,
|
||||
state,
|
||||
flows,
|
||||
flows: stored::Flows::builtins(),
|
||||
roster: None,
|
||||
})
|
||||
}
|
||||
|
|
@ -193,8 +185,9 @@ impl Router {
|
|||
}),
|
||||
None => event::Snapshot::default(),
|
||||
};
|
||||
self.flows.refresh(&self.js).await;
|
||||
let roster = self.roster().await.map_err(Skip::Retry)?;
|
||||
let routed = route::route(&event_type, &delivery, &snapshot, &self.flows, |a| {
|
||||
let routed = route::route(&event_type, &delivery, &snapshot, &self.flows.active, |a| {
|
||||
roster.contains(a)
|
||||
});
|
||||
for action in &routed.actions {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ use std::collections::BTreeSet;
|
|||
use swarm_wire::router_todo::Op;
|
||||
|
||||
use crate::event::{Delivery, Snapshot, fields, is_assign};
|
||||
use crate::flow::{Action, Flow};
|
||||
use swarm_wire::notify_flows::Flow;
|
||||
|
||||
use crate::flow::{Action, evaluate};
|
||||
|
||||
/// What one event comes to.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
|
|
@ -30,7 +32,7 @@ pub fn route(
|
|||
let fields = fields(event_type, delivery, snapshot);
|
||||
let mut actions = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
for action in flows.iter().flat_map(|f| f.evaluate(event_type, &fields)) {
|
||||
for action in flows.iter().flat_map(|f| evaluate(f, event_type, &fields)) {
|
||||
if is_agent(&action.agent) {
|
||||
actions.push(action);
|
||||
} else if !skipped.contains(&action.agent) {
|
||||
|
|
@ -68,7 +70,7 @@ pub fn route(
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::flow::builtins;
|
||||
use swarm_wire::notify_flows::builtins;
|
||||
|
||||
fn delivery(action: &str, assignees: &[&str]) -> Delivery {
|
||||
Delivery {
|
||||
|
|
|
|||
254
swarm-notify-router/src/stored.rs
Normal file
254
swarm-notify-router/src/stored.rs
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
//! The flows the router runs: the built-ins, changed by the document in the
|
||||
//! `notify-flows` bucket.
|
||||
//!
|
||||
//! The router reads the key again every [`REFRESH`] and runs what
|
||||
//! [`Document::active`] says. What it does with each thing it can read:
|
||||
//!
|
||||
//! | the key | the router runs |
|
||||
//! | ------------------------------------ | ----------------------------------- |
|
||||
//! | absent, deleted, or no such bucket | the built-ins |
|
||||
//! | a document it reads | its built-ins and custom flows |
|
||||
//! | a document it cannot read | what it ran before |
|
||||
//! | the read itself failing | what it ran before |
|
||||
//!
|
||||
//! "Cannot read" is not JSON, not the document's shape, or a version this
|
||||
//! build does not know. Before any document has been read, "what it ran
|
||||
//! before" is the built-ins, so a bad document never leaves the router with
|
||||
//! no flows.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use async_nats::jetstream::context::GetStreamErrorKind;
|
||||
use async_nats::jetstream::{self, kv};
|
||||
use async_nats::jetstream::ErrorCode;
|
||||
use swarm_wire::notify_flows::{self, Document, Flow};
|
||||
|
||||
/// How long a read of the key is reused.
|
||||
const REFRESH: Duration = Duration::from_secs(10);
|
||||
|
||||
/// What a read of the key came to.
|
||||
#[derive(Debug)]
|
||||
pub enum Read {
|
||||
/// No key, a deleted key, or no bucket.
|
||||
Absent,
|
||||
Document { revision: u64, bytes: Vec<u8> },
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// The flows in force, and which revision of the key they came from.
|
||||
pub struct Flows {
|
||||
pub active: Vec<Flow>,
|
||||
/// The revision `active` was read from; `None` for the built-ins alone.
|
||||
revision: Option<u64>,
|
||||
/// The last problem logged, so a standing one is logged once.
|
||||
problem: Option<String>,
|
||||
checked: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Flows {
|
||||
/// The built-ins, which is what the router runs until it reads a document.
|
||||
pub fn builtins() -> Self {
|
||||
Self {
|
||||
active: Document::default().active().0,
|
||||
revision: None,
|
||||
problem: None,
|
||||
checked: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Take what a read came to.
|
||||
pub fn apply(&mut self, read: Read) {
|
||||
match read {
|
||||
Read::Failed(why) => self.note(format!("reading the key failed: {why}")),
|
||||
Read::Absent => {
|
||||
if self.revision.take().is_some() {
|
||||
tracing::info!("notify router: the flows document is gone; running the built-in flows");
|
||||
}
|
||||
self.active = Document::default().active().0;
|
||||
self.problem = None;
|
||||
}
|
||||
Read::Document { revision, .. } if self.revision == Some(revision) => {}
|
||||
Read::Document { revision, bytes } => match Document::decode(&bytes) {
|
||||
Err(why) => {
|
||||
// The revision is not taken, so the next read looks at
|
||||
// the document again and the problem is logged once.
|
||||
self.note(format!("revision {revision} cannot be read: {why}"));
|
||||
}
|
||||
Ok(doc) => {
|
||||
let (active, skipped) = doc.active();
|
||||
for why in &skipped {
|
||||
tracing::error!(%why, "notify router: skipping a flow");
|
||||
}
|
||||
tracing::info!(
|
||||
revision,
|
||||
flows = active.len(),
|
||||
disabled = doc.disabled_builtins.len(),
|
||||
custom = doc.flows.len(),
|
||||
"notify router: running the flows document"
|
||||
);
|
||||
self.active = active;
|
||||
self.revision = Some(revision);
|
||||
self.problem = None;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn note(&mut self, problem: String) {
|
||||
if self.problem.as_ref() != Some(&problem) {
|
||||
tracing::error!(%problem, "notify router: keeping the flows it has");
|
||||
self.problem = Some(problem);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the key if the last read is older than [`REFRESH`].
|
||||
pub async fn refresh(&mut self, js: &jetstream::Context) {
|
||||
if self.checked.is_some_and(|at| at.elapsed() < REFRESH) {
|
||||
return;
|
||||
}
|
||||
self.checked = Some(Instant::now());
|
||||
self.apply(read(js).await);
|
||||
}
|
||||
}
|
||||
|
||||
async fn read(js: &jetstream::Context) -> Read {
|
||||
// A bucket that does not exist is the answer "no document". Any other
|
||||
// failure to look, such as a timeout, says nothing about the document.
|
||||
match js.get_stream(format!("KV_{}", notify_flows::BUCKET)).await {
|
||||
Ok(_) => {}
|
||||
Err(e)
|
||||
if matches!(e.kind(), GetStreamErrorKind::JetStream(j)
|
||||
if j.error_code() == ErrorCode::STREAM_NOT_FOUND) =>
|
||||
{
|
||||
return Read::Absent;
|
||||
}
|
||||
Err(e) => return Read::Failed(e.to_string()),
|
||||
}
|
||||
let store = match js.get_key_value(notify_flows::BUCKET).await {
|
||||
Ok(store) => store,
|
||||
Err(e) => return Read::Failed(e.to_string()),
|
||||
};
|
||||
match store.entry(notify_flows::KEY).await {
|
||||
Ok(Some(entry)) if entry.operation == kv::Operation::Put => Read::Document {
|
||||
revision: entry.revision,
|
||||
bytes: entry.value.to_vec(),
|
||||
},
|
||||
Ok(_) => Read::Absent,
|
||||
Err(e) => Read::Failed(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use swarm_wire::notify_flows::{Document, VERSION};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn ids(f: &Flows) -> Vec<&str> {
|
||||
f.active.iter().map(|f| f.id.as_str()).collect()
|
||||
}
|
||||
|
||||
fn doc(disabled: &[&str], custom: &[&str]) -> Read {
|
||||
let mut flows = Vec::new();
|
||||
for id in custom {
|
||||
let mut f = notify_flows::builtins().remove(0);
|
||||
f.id = (*id).to_owned();
|
||||
flows.push(f);
|
||||
}
|
||||
let d = Document {
|
||||
version: VERSION,
|
||||
disabled_builtins: disabled.iter().map(|s| (*s).to_owned()).collect(),
|
||||
flows,
|
||||
};
|
||||
Read::Document {
|
||||
revision: 0,
|
||||
bytes: serde_json::to_vec(&d).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn at(revision: u64, read: Read) -> Read {
|
||||
match read {
|
||||
Read::Document { bytes, .. } => Read::Document { revision, bytes },
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn junk(revision: u64, bytes: &[u8]) -> Read {
|
||||
Read::Document {
|
||||
revision,
|
||||
bytes: bytes.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_no_document_the_router_runs_the_builtins() {
|
||||
let mut f = Flows::builtins();
|
||||
assert_eq!(ids(&f), ["assigned", "unassigned", "closed"]);
|
||||
f.apply(Read::Absent);
|
||||
assert_eq!(ids(&f), ["assigned", "unassigned", "closed"]);
|
||||
// An empty document is the same.
|
||||
f.apply(at(1, doc(&[], &[])));
|
||||
assert_eq!(ids(&f), ["assigned", "unassigned", "closed"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_switches_builtins_off_and_adds_custom_flows() {
|
||||
let mut f = Flows::builtins();
|
||||
f.apply(at(1, doc(&["unassigned"], &["triage"])));
|
||||
assert_eq!(ids(&f), ["assigned", "closed", "triage"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_it_cannot_read_leaves_the_flows_as_they_were() {
|
||||
let mut f = Flows::builtins();
|
||||
f.apply(at(1, doc(&["closed"], &["triage"])));
|
||||
let before = ids(&f).join(",");
|
||||
assert_eq!(before, "assigned,unassigned,triage");
|
||||
for bad in [
|
||||
junk(2, b"not json"),
|
||||
junk(3, br#"{"version":2,"flows":[]}"#),
|
||||
junk(4, br#"{"flows":[{"id":"x"}]}"#),
|
||||
Read::Failed("timeout".to_owned()),
|
||||
] {
|
||||
f.apply(bad);
|
||||
assert_eq!(ids(&f).join(","), before);
|
||||
}
|
||||
// The next good revision replaces them: the control that the bad
|
||||
// ones were kept out by being bad, not by the loader being stuck.
|
||||
f.apply(at(5, doc(&[], &[])));
|
||||
assert_eq!(ids(&f), ["assigned", "unassigned", "closed"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_first_document_is_the_builtins_not_nothing() {
|
||||
let mut f = Flows::builtins();
|
||||
f.apply(junk(1, br#"{"version":9}"#));
|
||||
assert_eq!(ids(&f), ["assigned", "unassigned", "closed"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deleted_document_goes_back_to_the_builtins() {
|
||||
let mut f = Flows::builtins();
|
||||
f.apply(at(1, doc(&["closed"], &["triage"])));
|
||||
f.apply(Read::Absent);
|
||||
assert_eq!(ids(&f), ["assigned", "unassigned", "closed"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flow_that_is_not_valid_is_left_out_and_the_rest_run() {
|
||||
let mut f = Flows::builtins();
|
||||
let mut broken = notify_flows::builtins().remove(0);
|
||||
broken.id = "broken".to_owned();
|
||||
broken.edges.clear();
|
||||
let d = Document {
|
||||
version: VERSION,
|
||||
disabled_builtins: Vec::new(),
|
||||
flows: vec![broken],
|
||||
};
|
||||
f.apply(Read::Document {
|
||||
revision: 1,
|
||||
bytes: serde_json::to_vec(&d).unwrap(),
|
||||
});
|
||||
assert_eq!(ids(&f), ["assigned", "unassigned", "closed"]);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue