use crate::dlq::DlqConfig;
use crate::error::FaucetError;
use crate::join::HashJoin;
use crate::observability::{Labels, RunStreamOptions, instrumented_apply_stages};
use crate::pipeline::{DEFAULT_BATCH_SIZE, StreamPage, run_stream};
use crate::stage::CompiledStage;
use crate::state::StateStore;
use crate::traits::{Sink, Source};
use futures::StreamExt;
use metrics::{Label, SharedString, counter, histogram};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
pub use crate::join::{JoinConfig, JoinMode, KeyNormalize, OnCollision, OnDuplicate, Projection};
pub const DEFAULT_CHANNEL_CAPACITY: usize = 4;
#[derive(Debug, Clone)]
pub struct JoinNode {
pub config: JoinConfig,
pub build_edge: String,
pub probe_edge: String,
}
pub enum NodeKind {
Source(Box<dyn Source>),
Transform(Vec<CompiledStage>),
Tee {
capacity: usize,
fanout: Option<usize>,
},
Merge,
Join(JoinNode),
Sink(Box<dyn Sink>),
}
impl std::fmt::Debug for NodeKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.kind_str())
}
}
impl NodeKind {
pub fn kind_str(&self) -> &'static str {
match self {
NodeKind::Source(_) => "source",
NodeKind::Transform(_) => "transform",
NodeKind::Tee { .. } => "tee",
NodeKind::Merge => "merge",
NodeKind::Join(_) => "join",
NodeKind::Sink(_) => "sink",
}
}
fn is_source(&self) -> bool {
matches!(self, NodeKind::Source(_))
}
fn is_sink(&self) -> bool {
matches!(self, NodeKind::Sink(_))
}
}
#[derive(Debug)]
pub struct Node {
pub id: String,
pub kind: NodeKind,
}
#[derive(Debug, Clone)]
pub struct Edge {
pub from: String,
pub to: String,
pub label: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TopologyOnError {
#[default]
Propagate,
Continue,
}
#[derive(Clone, Default)]
#[non_exhaustive]
pub struct TopologyGovernance {
#[cfg(feature = "quality")]
pub quality: Option<Arc<crate::quality::CompiledQuality>>,
#[cfg(feature = "contract")]
pub contract: Option<Arc<crate::contract::CompiledContract>>,
#[cfg(feature = "masking")]
pub masking_by_sink: HashMap<String, Arc<crate::masking::CompiledMasking>>,
pub schema_drift: Option<crate::drift::SchemaDriftPolicy>,
pub resilience: Option<crate::resilience::ResiliencePolicy>,
pub delivery: crate::idempotency::DeliveryMode,
pub dlq_by_sink: HashMap<String, DlqConfig>,
}
impl TopologyGovernance {
pub fn new() -> Self {
Self::default()
}
}
#[derive(Clone)]
pub struct TopologyOptions {
pub pipeline_name: String,
pub run_id: String,
pub batch_size: usize,
pub state_store: Option<Arc<dyn StateStore>>,
pub dlq: Option<DlqConfig>,
pub cancel: Option<CancellationToken>,
pub on_error: TopologyOnError,
pub default_channel_capacity: usize,
}
pub const STOP_FLUSH_GRACE: Duration = Duration::from_secs(30);
impl Default for TopologyOptions {
fn default() -> Self {
Self {
pipeline_name: "unnamed".into(),
run_id: String::new(),
batch_size: DEFAULT_BATCH_SIZE,
state_store: None,
dlq: None,
cancel: None,
on_error: TopologyOnError::default(),
default_channel_capacity: DEFAULT_CHANNEL_CAPACITY,
}
}
}
impl TopologyOptions {
pub fn new(pipeline_name: impl Into<String>) -> Self {
Self {
pipeline_name: pipeline_name.into(),
..Default::default()
}
}
pub fn with_state_store(mut self, store: Arc<dyn StateStore>) -> Self {
self.state_store = Some(store);
self
}
pub fn with_dlq(mut self, dlq: DlqConfig) -> Self {
self.dlq = Some(dlq);
self
}
pub fn with_cancel(mut self, cancel: CancellationToken) -> Self {
self.cancel = Some(cancel);
self
}
pub fn with_on_error(mut self, on_error: TopologyOnError) -> Self {
self.on_error = on_error;
self
}
pub fn with_batch_size(mut self, batch_size: usize) -> Self {
self.batch_size = batch_size;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum NodeErrorKind {
CircuitOpen,
Other,
}
impl NodeErrorKind {
pub fn classify(error: &FaucetError) -> Self {
match error {
FaucetError::CircuitOpen { .. } => Self::CircuitOpen,
_ => Self::Other,
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct NodeReport {
pub node_id: String,
pub kind: &'static str,
pub records: usize,
pub bookmark: Option<Value>,
pub error: Option<String>,
pub error_kind: Option<NodeErrorKind>,
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct TopologyRun {
pub result: TopologyResult,
pub nodes: Vec<NodeReport>,
pub per_source: HashMap<String, usize>,
}
#[derive(Debug, Clone, Default)]
pub struct TopologyResult {
pub records_written: usize,
pub per_sink: HashMap<String, usize>,
pub bookmarks: HashMap<String, Option<Value>>,
pub errors: Vec<String>,
}
struct InEdge {
label: Option<String>,
from: String,
rx: mpsc::Receiver<StreamPage>,
}
fn take_single(mut ins: Vec<InEdge>) -> Option<mpsc::Receiver<StreamPage>> {
ins.drain(..).next().map(|ie| ie.rx)
}
fn take_by_label(ins: &mut Vec<InEdge>, label: &str) -> Option<mpsc::Receiver<StreamPage>> {
ins.iter()
.position(|ie| ie.label.as_deref() == Some(label))
.map(|pos| ins.remove(pos).rx)
}
enum NodeOutcome {
Sink {
node_id: String,
records: usize,
bookmark: Option<Value>,
sink: Box<dyn Sink>,
deferred: Option<DeferredState>,
},
Source {
node_id: String,
records: usize,
},
Other,
}
#[derive(Debug)]
pub struct Topology {
nodes: Vec<Node>,
edges: Vec<Edge>,
}
impl Topology {
pub fn builder() -> TopologyBuilder {
TopologyBuilder::default()
}
pub fn nodes(&self) -> &[Node] {
&self.nodes
}
pub fn edges(&self) -> &[Edge] {
&self.edges
}
pub fn validate(&self) -> Result<(), FaucetError> {
if self.nodes.is_empty() {
return Err(cfg("topology has no nodes"));
}
let mut seen = HashSet::new();
for n in &self.nodes {
if !seen.insert(n.id.as_str()) {
return Err(cfg(format!("duplicate node id '{}'", n.id)));
}
}
let ids: HashSet<&str> = seen;
for e in &self.edges {
if !ids.contains(e.from.as_str()) {
return Err(cfg(format!(
"edge references unknown 'from' node '{}'",
e.from
)));
}
if !ids.contains(e.to.as_str()) {
return Err(cfg(format!("edge references unknown 'to' node '{}'", e.to)));
}
}
let mut in_deg: HashMap<&str, usize> = HashMap::new();
let mut out_deg: HashMap<&str, usize> = HashMap::new();
for e in &self.edges {
*out_deg.entry(e.from.as_str()).or_default() += 1;
*in_deg.entry(e.to.as_str()).or_default() += 1;
}
let mut has_source = false;
let mut has_sink = false;
for n in &self.nodes {
let i = in_deg.get(n.id.as_str()).copied().unwrap_or(0);
let o = out_deg.get(n.id.as_str()).copied().unwrap_or(0);
match &n.kind {
NodeKind::Source(source) => {
has_source = true;
arity(&n.id, "source", i == 0, o == 1, "0 in, exactly 1 out")?;
if source.consumes_destructively() {
return Err(cfg(format!(
"source '{}' ({}) acknowledges messages as it is read past each \
page, and a graph polls its sources ahead of its sinks, so a \
failed or cancelled sink would lose messages that were already \
acked; run this source in a linear pipeline instead",
n.id,
source.connector_name()
)));
}
}
NodeKind::Transform(_) => {
arity(&n.id, "transform", i == 1, o == 1, "exactly 1 in, 1 out")?;
}
NodeKind::Tee { fanout, .. } => {
arity(&n.id, "tee", i == 1, o >= 2, "exactly 1 in, 2+ out")?;
if let Some(f) = fanout
&& *f != o
{
return Err(cfg(format!(
"tee '{}' declares fanout {f} but has {o} outgoing edges",
n.id
)));
}
}
NodeKind::Merge => {
arity(&n.id, "merge", i >= 2, o == 1, "2+ in, exactly 1 out")?;
}
NodeKind::Join(j) => {
arity(&n.id, "join", i == 2, o == 1, "exactly 2 in, 1 out")?;
self.validate_join_edges(&n.id, j)?;
}
NodeKind::Sink(_) => {
has_sink = true;
arity(&n.id, "sink", i == 1, o == 0, "exactly 1 in, 0 out")?;
}
}
}
if !has_source {
return Err(cfg("topology has no source node"));
}
if !has_sink {
return Err(cfg("topology has no sink node"));
}
self.detect_cycle()?;
self.check_reachability()?;
self.check_join_inputs()?;
Ok(())
}
fn check_join_inputs(&self) -> Result<(), FaucetError> {
let mut rev: HashMap<&str, Vec<&Edge>> = HashMap::new();
for e in &self.edges {
rev.entry(e.to.as_str()).or_default().push(e);
}
for n in &self.nodes {
let NodeKind::Join(j) = &n.kind else {
continue;
};
let side = |label: &str| -> Option<HashSet<String>> {
let from = rev
.get(n.id.as_str())?
.iter()
.find(|e| e.label.as_deref() == Some(label))?
.from
.as_str();
let mut set = ancestors_of(from, &rev);
set.insert(from.to_string());
Some(set)
};
let (Some(build), Some(probe)) = (side(&j.build_edge), side(&j.probe_edge)) else {
continue;
};
let mut shared: Vec<&String> = build.intersection(&probe).collect();
shared.sort();
if let Some(node) = shared.first() {
return Err(cfg(format!(
"join '{}' reads both its build and probe sides from node '{node}'; the \
join drains its build side before reading the probe side, so a shared \
upstream blocks once the probe channel fills and the run never ends. \
Feed the two sides from separate source nodes",
n.id
)));
}
}
Ok(())
}
fn validate_join_edges(&self, node_id: &str, j: &JoinNode) -> Result<(), FaucetError> {
let labels: Vec<&str> = self
.edges
.iter()
.filter(|e| e.to == node_id)
.filter_map(|e| e.label.as_deref())
.collect();
for want in [j.build_edge.as_str(), j.probe_edge.as_str()] {
if !labels.contains(&want) {
return Err(cfg(format!(
"join '{node_id}' has no incoming edge labelled '{want}' (known labels: {labels:?})"
)));
}
}
if j.build_edge == j.probe_edge {
return Err(cfg(format!(
"join '{node_id}' build_edge and probe_edge must differ"
)));
}
Ok(())
}
fn detect_cycle(&self) -> Result<(), FaucetError> {
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
for e in &self.edges {
adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
}
#[derive(Clone, Copy, PartialEq)]
enum Color {
White,
Gray,
Black,
}
let mut color: HashMap<&str, Color> = self
.nodes
.iter()
.map(|n| (n.id.as_str(), Color::White))
.collect();
for start in self.nodes.iter().map(|n| n.id.as_str()) {
if color[start] != Color::White {
continue;
}
let mut stack: Vec<(&str, usize)> = vec![(start, 0)];
*color.get_mut(start).unwrap() = Color::Gray;
while let Some((node, idx)) = stack.last().copied() {
let neighbours = adj.get(node).map(|v| v.as_slice()).unwrap_or(&[]);
if idx < neighbours.len() {
stack.last_mut().unwrap().1 += 1;
let next = neighbours[idx];
match color[next] {
Color::Gray => {
return Err(cfg(format!("topology has a cycle through node '{next}'")));
}
Color::White => {
*color.get_mut(next).unwrap() = Color::Gray;
stack.push((next, 0));
}
Color::Black => {}
}
} else {
*color.get_mut(node).unwrap() = Color::Black;
stack.pop();
}
}
}
Ok(())
}
fn check_reachability(&self) -> Result<(), FaucetError> {
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
let mut radj: HashMap<&str, Vec<&str>> = HashMap::new();
for e in &self.edges {
adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
radj.entry(e.to.as_str()).or_default().push(e.from.as_str());
}
let sink_ids: HashSet<&str> = self
.nodes
.iter()
.filter(|n| n.kind.is_sink())
.map(|n| n.id.as_str())
.collect();
let source_ids: HashSet<&str> = self
.nodes
.iter()
.filter(|n| n.kind.is_source())
.map(|n| n.id.as_str())
.collect();
for src in &source_ids {
if !reaches_any(src, &adj, &sink_ids) {
return Err(cfg(format!("source '{src}' does not reach any sink node")));
}
}
for sink in &sink_ids {
if !reaches_any(sink, &radj, &source_ids) {
return Err(cfg(format!(
"sink '{sink}' is not reachable from any source node"
)));
}
}
Ok(())
}
pub async fn run(self, opts: TopologyOptions) -> Result<TopologyResult, FaucetError> {
self.run_with(opts, TopologyGovernance::default()).await
}
pub async fn run_with(
self,
opts: TopologyOptions,
governance: TopologyGovernance,
) -> Result<TopologyResult, FaucetError> {
self.run_reported(opts, governance).await.map(|r| r.result)
}
pub async fn run_reported(
self,
opts: TopologyOptions,
governance: TopologyGovernance,
) -> Result<TopologyRun, FaucetError> {
match self.run_attributed(opts, governance).await {
(_, Some(e)) => Err(e),
(run, None) => Ok(run),
}
}
#[allow(clippy::disallowed_methods)] pub async fn run_attributed(
self,
opts: TopologyOptions,
governance: TopologyGovernance,
) -> (TopologyRun, Option<FaucetError>) {
if let Err(e) = self.validate() {
return (TopologyRun::default(), Some(e));
}
let facts = GraphFacts::of(&self.nodes, &self.edges);
let Topology { nodes, edges } = self;
let order: Vec<(String, &'static str)> = nodes
.iter()
.map(|n| (n.id.clone(), n.kind.kind_str()))
.collect();
let mut sources: HashMap<String, Arc<dyn Source>> = HashMap::new();
let mut rest: Vec<Node> = Vec::with_capacity(nodes.len());
for n in nodes {
match n.kind {
NodeKind::Source(s) => {
sources.insert(n.id, Arc::from(s));
}
kind => rest.push(Node { id: n.id, kind }),
}
}
let source_count = sources.len();
let only_source: Option<Arc<dyn Source>> = if source_count == 1 {
sources.values().next().cloned()
} else {
None
};
let tee_cap: HashMap<String, usize> = rest
.iter()
.filter_map(|n| match &n.kind {
NodeKind::Tee { capacity, .. } => Some((n.id.clone(), *capacity)),
_ => None,
})
.collect();
let source_replay = only_source.as_ref().map(|s| s.replay_guarantee());
let codec = only_source
.as_ref()
.map(|s| crate::state_version::StateCodec::for_source(s.as_ref(), false))
.unwrap_or(crate::state_version::StateCodec {
owner: "topology".into(),
schema: 0,
legacy: false,
});
let sink_refs: Vec<(String, Option<&dyn Sink>)> = rest
.iter()
.filter_map(|n| match &n.kind {
NodeKind::Sink(s) => Some((n.id.clone(), Some(s.as_ref()))),
_ => None,
})
.collect();
let plan = match compute_resume(
&opts,
&sink_refs,
source_count,
governance.delivery,
only_source.as_deref(),
)
.await
{
Ok(p) => p,
Err(e) => return (TopologyRun::default(), Some(e)),
};
drop(sink_refs);
let coop = opts.cancel.clone().unwrap_or_default().child_token();
let failed: FailedNodes = Arc::new(std::sync::Mutex::new(HashMap::new()));
let mut outs: HashMap<String, Vec<mpsc::Sender<StreamPage>>> = HashMap::new();
let mut ins: HashMap<String, Vec<InEdge>> = HashMap::new();
for e in &edges {
let cap = tee_cap
.get(e.from.as_str())
.copied()
.unwrap_or(opts.default_channel_capacity)
.max(1);
let (tx, rx) = mpsc::channel(cap);
outs.entry(e.from.clone()).or_default().push(tx);
ins.entry(e.to.clone()).or_default().push(InEdge {
label: e.label.clone(),
from: e.from.clone(),
rx,
});
}
type NodeFut = Pin<Box<dyn Future<Output = Result<NodeOutcome, FaucetError>> + Send>>;
let mut by_id: HashMap<String, NodeFut> = HashMap::new();
let source_kinds: HashMap<String, &'static str> = sources
.iter()
.map(|(id, s)| (id.clone(), s.connector_name()))
.collect();
for (id, source) in sources {
let node_outs = outs.remove(&id).unwrap_or_default();
let keep = node_outs.clone();
let fut: NodeFut = Box::pin(run_source_node(
Labels::new(opts.pipeline_name.clone(), id.clone(), opts.run_id.clone()),
source,
plan.start.clone(),
opts.batch_size,
node_outs,
coop.clone(),
));
by_id.insert(
id.clone(),
track_failure(id, fut, keep, Arc::clone(&failed)),
);
}
for node in rest {
let node_outs = outs.remove(&node.id).unwrap_or_default();
let keep = node_outs.clone();
let mut node_ins = ins.remove(&node.id).unwrap_or_default();
let pipeline = opts.pipeline_name.clone();
let cancel = coop.clone();
let Node { id, kind } = node;
let built: Result<NodeFut, FaucetError> = match kind {
NodeKind::Source(_) => Err(cfg(format!("source '{id}' was not prepared"))),
NodeKind::Transform(stages) => take_single(node_ins)
.ok_or_else(|| cfg(format!("transform '{id}' has no input edge")))
.map(|rx| {
let labels = Labels::new(pipeline.clone(), id.clone(), opts.run_id.clone());
Box::pin(run_transform_node(stages, labels, rx, node_outs, cancel))
as NodeFut
}),
NodeKind::Tee { .. } => take_single(node_ins)
.ok_or_else(|| cfg(format!("tee '{id}' has no input edge")))
.map(|rx| {
Box::pin(run_tee_node(id.clone(), pipeline, rx, node_outs, cancel))
as NodeFut
}),
NodeKind::Merge => {
let rxs: Vec<mpsc::Receiver<StreamPage>> =
node_ins.into_iter().map(|ie| ie.rx).collect();
let mode = facts
.merge_modes
.get(&id)
.copied()
.unwrap_or(MergeMode::Passthrough);
Ok(Box::pin(run_merge_node(
id.clone(),
pipeline,
rxs,
node_outs,
cancel,
mode,
)) as NodeFut)
}
NodeKind::Join(j) => {
let build_upstream: HashSet<String> = node_ins
.iter()
.find(|ie| ie.label.as_deref() == Some(j.build_edge.as_str()))
.map(|ie| {
let mut set =
facts.ancestors.get(&ie.from).cloned().unwrap_or_default();
set.insert(ie.from.clone());
set
})
.unwrap_or_default();
let build_rx = take_by_label(&mut node_ins, &j.build_edge);
let probe_rx = take_by_label(&mut node_ins, &j.probe_edge);
match (build_rx, probe_rx) {
(Some(b), Some(p)) => Ok(Box::pin(run_join_node(
id.clone(),
pipeline,
j,
JoinInputs {
build_rx: b,
probe_rx: p,
build_upstream,
failed: Arc::clone(&failed),
},
node_outs,
cancel,
)) as NodeFut),
_ => Err(cfg(format!(
"join '{id}' is missing its build/probe input edges"
))),
}
}
NodeKind::Sink(sink) => match take_single(node_ins) {
None => Err(cfg(format!("sink '{id}' has no input edge"))),
Some(rx) => {
let resume = plan.sinks.get(&id).cloned();
let sopts = SinkNodeOpts {
pipeline_name: pipeline,
run_id: opts.run_id.clone(),
state_store: opts.state_store.clone(),
dlq: governance
.dlq_by_sink
.get(&id)
.cloned()
.or_else(|| opts.dlq.clone()),
cancel: cancel.clone(),
#[cfg(feature = "masking")]
masking: governance.masking_by_sink.get(&id).cloned(),
#[cfg(feature = "quality")]
quality: governance.quality.clone(),
#[cfg(feature = "contract")]
contract: governance.contract.clone(),
schema_drift: governance.schema_drift,
resilience: governance.resilience.clone(),
delivery: governance.delivery,
replay: source_replay,
codec: codec.clone(),
resume,
position_source: only_source.clone(),
source_label: source_label(facts.ancestors.get(&id), &source_kinds),
upstream: facts.ancestors.get(&id).cloned().unwrap_or_default(),
failed: Arc::clone(&failed),
};
Ok(Box::pin(run_sink_node(id.clone(), sink, rx, sopts)) as NodeFut)
}
},
};
match built {
Ok(fut) => {
by_id.insert(
id.clone(),
track_failure(id, fut, keep, Arc::clone(&failed)),
);
}
Err(e) => return (TopologyRun::default(), Some(e)),
}
}
drop(outs);
drop(ins);
let futs: Vec<NodeFut> = order
.iter()
.filter_map(|(id, _)| by_id.remove(id))
.collect();
let handles: Vec<tokio::task::JoinHandle<Result<NodeOutcome, FaucetError>>> = futs
.into_iter()
.map(|f| {
tokio::spawn(tracing::Instrument::in_current_span(
crate::http::inherit_reference_policy(f),
))
})
.collect();
let aborts: Vec<tokio::task::AbortHandle> =
handles.iter().map(|h| h.abort_handle()).collect();
let _abort_on_drop = AbortOnDrop(aborts.clone());
let abort_all = || {
for a in &aborts {
a.abort();
}
};
let joined = handles.into_iter().map(|h| async move {
match h.await {
Ok(r) => r,
Err(e) if e.is_panic() => {
Err(FaucetError::Source(format!("topology node panicked: {e}")))
}
Err(e) => Err(FaucetError::Source(format!("topology node aborted: {e}"))),
}
});
let externally_cancelled = || cancelled(&opts.cancel);
match opts.on_error {
TopologyOnError::Propagate => {
let first_err: Arc<std::sync::Mutex<Option<FaucetError>>> =
Arc::new(std::sync::Mutex::new(None));
let failures: Arc<std::sync::Mutex<Vec<(String, String, NodeErrorKind)>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let wrapped = joined.zip(order.clone()).map(|(f, (node_id, _))| {
let coop = coop.clone();
let slot = Arc::clone(&first_err);
let failed = Arc::clone(&failures);
async move {
match f.await {
Ok(o) => Some(o),
Err(e) => {
failed.lock().unwrap_or_else(|p| p.into_inner()).push((
node_id,
e.to_string(),
NodeErrorKind::classify(&e),
));
tracing::error!(
error = %e,
"topology node failed; cancelling siblings so they flush"
);
let mut guard = slot.lock().unwrap_or_else(|p| p.into_inner());
if guard.is_none() {
*guard = Some(e);
}
coop.cancel();
None
}
}
}
});
let all = futures::future::join_all(wrapped);
let grace = STOP_FLUSH_GRACE;
let deadline = {
let coop = coop.clone();
async move {
coop.cancelled().await;
tokio::time::sleep(grace).await;
}
};
let outcomes = tokio::select! {
biased;
v = all => v,
() = deadline => {
tracing::warn!(
grace_secs = grace.as_secs(),
"topology: nodes did not stop within the flush grace after a failure; \
aborting them"
);
abort_all();
Vec::new()
}
};
let outcomes: Vec<NodeOutcome> = outcomes.into_iter().flatten().collect();
let err = first_err.lock().unwrap_or_else(|p| p.into_inner()).take();
let complete = err.is_none() && !externally_cancelled();
let finish = finish_sinks(&outcomes, complete).await;
let (result, per_source) = aggregate(outcomes);
let failed = failures.lock().unwrap_or_else(|p| p.into_inner()).clone();
let nodes = reports(&order, &result, &per_source, &failed, &facts.ancestors);
let run = TopologyRun {
result,
nodes,
per_source,
};
(run, err.or(finish.err()))
}
TopologyOnError::Continue => {
let results = futures::future::join_all(joined).await;
let mut ok = Vec::new();
let mut errs = Vec::new();
let mut failed: Vec<(String, String, NodeErrorKind)> = Vec::new();
for (r, (node_id, _)) in results.into_iter().zip(order.clone()) {
match r {
Ok(o) => ok.push(o),
Err(e) => {
tracing::error!(
node = %node_id,
error = %e,
"topology node failed (on_error: continue)"
);
errs.push(e.to_string());
failed.push((node_id, e.to_string(), NodeErrorKind::classify(&e)));
}
}
}
let complete = errs.is_empty() && !externally_cancelled();
let finish = finish_sinks(&ok, complete).await;
let (mut result, per_source) = aggregate(ok);
result.errors = errs;
let nodes = reports(&order, &result, &per_source, &failed, &facts.ancestors);
let run = TopologyRun {
result,
nodes,
per_source,
};
(run, finish.err())
}
}
}
}
type FailedNodes = Arc<std::sync::Mutex<HashMap<String, (String, NodeErrorKind)>>>;
fn track_failure(
id: String,
fut: Pin<Box<dyn Future<Output = Result<NodeOutcome, FaucetError>> + Send>>,
keep: Vec<mpsc::Sender<StreamPage>>,
failed: FailedNodes,
) -> Pin<Box<dyn Future<Output = Result<NodeOutcome, FaucetError>> + Send>> {
Box::pin(async move {
use futures::FutureExt;
let result = match std::panic::AssertUnwindSafe(fut).catch_unwind().await {
Ok(r) => r,
Err(payload) => {
let msg = payload
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "non-string panic payload".into());
Err(FaucetError::Source(format!(
"topology node '{id}' panicked: {msg}"
)))
}
};
if let Err(e) = &result {
failed
.lock()
.unwrap_or_else(|p| p.into_inner())
.insert(id, (e.to_string(), NodeErrorKind::classify(e)));
}
drop(keep);
result
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MergeMode {
Passthrough,
Gated,
Strip,
}
struct GraphFacts {
ancestors: HashMap<String, HashSet<String>>,
merge_modes: HashMap<String, MergeMode>,
}
impl GraphFacts {
fn of(nodes: &[Node], edges: &[Edge]) -> Self {
let mut rev: HashMap<&str, Vec<&Edge>> = HashMap::new();
for e in edges {
rev.entry(e.to.as_str()).or_default().push(e);
}
let ancestors: HashMap<String, HashSet<String>> = nodes
.iter()
.map(|n| (n.id.clone(), ancestors_of(&n.id, &rev)))
.collect();
let kinds: HashMap<&str, &NodeKind> =
nodes.iter().map(|n| (n.id.as_str(), &n.kind)).collect();
let mut origins: HashMap<String, HashSet<String>> = HashMap::new();
for n in nodes {
bookmark_origins(&n.id, &kinds, &rev, &mut origins);
}
let mut merge_modes = HashMap::new();
for n in nodes {
if !matches!(n.kind, NodeKind::Merge) {
continue;
}
let inputs: Vec<HashSet<String>> = rev
.get(n.id.as_str())
.map(|es| {
es.iter()
.map(|e| origins.get(&e.from).cloned().unwrap_or_default())
.collect()
})
.unwrap_or_default();
merge_modes.insert(n.id.clone(), merge_mode(&inputs));
}
Self {
ancestors,
merge_modes,
}
}
}
fn ancestors_of(id: &str, rev: &HashMap<&str, Vec<&Edge>>) -> HashSet<String> {
let mut seen: HashSet<String> = HashSet::new();
let mut stack: Vec<&str> = rev
.get(id)
.map(|es| es.iter().map(|e| e.from.as_str()).collect())
.unwrap_or_default();
while let Some(n) = stack.pop() {
if !seen.insert(n.to_string()) {
continue;
}
if let Some(es) = rev.get(n) {
stack.extend(es.iter().map(|e| e.from.as_str()));
}
}
seen
}
fn bookmark_origins(
id: &str,
kinds: &HashMap<&str, &NodeKind>,
rev: &HashMap<&str, Vec<&Edge>>,
memo: &mut HashMap<String, HashSet<String>>,
) -> HashSet<String> {
if let Some(o) = memo.get(id) {
return o.clone();
}
let inputs: Vec<&Edge> = rev.get(id).cloned().unwrap_or_default();
let origins: HashSet<String> = match kinds.get(id) {
Some(NodeKind::Source(_)) => HashSet::from([id.to_string()]),
Some(NodeKind::Join(j)) => inputs
.iter()
.filter(|e| e.label.as_deref() == Some(j.probe_edge.as_str()))
.flat_map(|e| bookmark_origins(&e.from, kinds, rev, memo))
.collect(),
_ => inputs
.iter()
.flat_map(|e| bookmark_origins(&e.from, kinds, rev, memo))
.collect(),
};
memo.insert(id.to_string(), origins.clone());
origins
}
fn merge_mode(inputs: &[HashSet<String>]) -> MergeMode {
let disjoint = inputs
.iter()
.enumerate()
.all(|(i, a)| inputs[i + 1..].iter().all(|b| a.is_disjoint(b)));
if disjoint {
return MergeMode::Passthrough;
}
let first = &inputs[0];
if first.len() == 1 && inputs.iter().all(|o| o == first) {
MergeMode::Gated
} else {
MergeMode::Strip
}
}
#[derive(Debug)]
struct BookmarkGate {
delivered: Vec<u64>,
closed: Vec<bool>,
released: u64,
pending: std::collections::VecDeque<Value>,
}
impl BookmarkGate {
fn new(inputs: usize) -> Self {
Self {
delivered: vec![0; inputs],
closed: vec![false; inputs],
released: 0,
pending: std::collections::VecDeque::new(),
}
}
fn arrive(&mut self, input: usize, bookmark: Value) -> Option<Value> {
self.delivered[input] += 1;
let seen = self.released + self.pending.len() as u64;
if self.delivered[input] > seen {
self.pending.push_back(bookmark);
}
self.release()
}
fn close(&mut self, input: usize) -> Option<Value> {
self.closed[input] = true;
self.release()
}
fn release(&mut self) -> Option<Value> {
let live_min = self
.delivered
.iter()
.zip(&self.closed)
.filter(|(_, closed)| !**closed)
.map(|(d, _)| *d)
.min()
.unwrap_or_else(|| self.released + self.pending.len() as u64);
let mut out = None;
while self.released < live_min {
match self.pending.pop_front() {
Some(bm) => {
self.released += 1;
out = Some(bm);
}
None => break,
}
}
out
}
}
fn reports(
order: &[(String, &'static str)],
sinks: &TopologyResult,
per_source: &HashMap<String, usize>,
errors: &[(String, String, NodeErrorKind)],
ancestors: &HashMap<String, HashSet<String>>,
) -> Vec<NodeReport> {
order
.iter()
.map(|(id, kind)| {
let own = errors.iter().find(|(nid, _, _)| nid == id);
let upstream = (own.is_none() && *kind == "sink")
.then(|| {
errors
.iter()
.find(|(nid, _, _)| ancestors.get(id).is_some_and(|a| a.contains(nid)))
})
.flatten();
let (error, error_kind) = match (own, upstream) {
(Some((_, e, k)), _) => (Some(e.clone()), Some(*k)),
(None, Some((nid, e, k))) => (
Some(format!(
"upstream node '{nid}' failed, so this sink did not receive its \
complete input: {e}"
)),
Some(*k),
),
(None, None) => (None, None),
};
NodeReport {
node_id: id.clone(),
kind,
records: sinks
.per_sink
.get(id)
.or_else(|| per_source.get(id))
.copied()
.unwrap_or(0),
bookmark: sinks.bookmarks.get(id).cloned().flatten(),
error,
error_kind,
}
})
.collect()
}
#[allow(deprecated)] async fn finish_sinks(outcomes: &[NodeOutcome], complete: bool) -> Result<(), FaucetError> {
struct Finish<'a> {
sink: &'a dyn Sink,
deferred: Option<&'a DeferredState>,
bookmark: Option<&'a Value>,
}
let sinks: Vec<Finish<'_>> = outcomes
.iter()
.filter_map(|o| match o {
NodeOutcome::Sink {
sink,
deferred,
bookmark,
..
} => Some(Finish {
sink: sink.as_ref(),
deferred: deferred.as_ref(),
bookmark: bookmark.as_ref(),
}),
_ => None,
})
.collect();
if !complete {
for f in &sinks {
if f.sink.is_overwrite() {
abort_quietly(f.sink).await;
}
}
return Ok(());
}
for (i, f) in sinks.iter().enumerate() {
if !f.sink.is_overwrite() {
continue;
}
if let Err(e) = f.sink.commit_overwrite().await {
for rest in &sinks[i + 1..] {
if rest.sink.is_overwrite() {
abort_quietly(rest.sink).await;
}
}
return Err(e);
}
if let (Some(d), Some(bm)) = (f.deferred, f.bookmark) {
d.store.put(&d.key, bm).await?;
}
}
for f in &sinks {
f.sink.complete_run().await?;
}
Ok(())
}
#[allow(deprecated)] async fn abort_quietly(sink: &dyn Sink) {
if let Err(e) = sink.abort_overwrite().await {
tracing::warn!(
error = %e,
"failed to discard overwrite staging after an unsuccessful or cancelled \
topology run; the destination is unchanged"
);
}
}
fn aggregate(outcomes: Vec<NodeOutcome>) -> (TopologyResult, HashMap<String, usize>) {
let mut result = TopologyResult::default();
let mut per_source = HashMap::new();
for o in outcomes {
match o {
NodeOutcome::Sink {
node_id,
records,
bookmark,
..
} => {
result.records_written += records;
result.per_sink.insert(node_id.clone(), records);
result.bookmarks.insert(node_id, bookmark);
}
NodeOutcome::Source { node_id, records } => {
per_source.insert(node_id, records);
}
NodeOutcome::Other => {}
}
}
(result, per_source)
}
fn cfg(msg: impl Into<String>) -> FaucetError {
FaucetError::Config(format!("topology: {}", msg.into()))
}
fn arity(
node_id: &str,
kind: &str,
in_ok: bool,
out_ok: bool,
expected: &str,
) -> Result<(), FaucetError> {
if in_ok && out_ok {
Ok(())
} else {
Err(cfg(format!(
"{kind} '{node_id}' has the wrong edge arity (expected {expected})"
)))
}
}
fn reaches_any(start: &str, adj: &HashMap<&str, Vec<&str>>, targets: &HashSet<&str>) -> bool {
let mut stack = vec![start];
let mut seen = HashSet::new();
while let Some(n) = stack.pop() {
if targets.contains(n) {
return true;
}
if !seen.insert(n) {
continue;
}
if let Some(ns) = adj.get(n) {
stack.extend(ns.iter().copied());
}
}
false
}
#[derive(Debug, Clone, PartialEq)]
struct SinkResume {
start_seq: u64,
skip_through: Option<Value>,
}
#[derive(Debug, Default)]
struct ResumePlan {
start: Option<Value>,
sinks: HashMap<String, SinkResume>,
}
struct AbortOnDrop(Vec<tokio::task::AbortHandle>);
impl Drop for AbortOnDrop {
fn drop(&mut self) {
for a in &self.0 {
a.abort();
}
}
}
#[cfg(test)]
async fn compute_start_bookmark(
opts: &TopologyOptions,
sink_ids: &[String],
source_count: usize,
delivery: crate::idempotency::DeliveryMode,
source: Option<&dyn Source>,
) -> Result<Option<Value>, FaucetError> {
let sinks: Vec<(String, Option<&dyn Sink>)> =
sink_ids.iter().map(|id| (id.clone(), None)).collect();
compute_resume(opts, &sinks, source_count, delivery, source)
.await
.map(|p| p.start)
}
#[allow(deprecated)] async fn compute_resume(
opts: &TopologyOptions,
sinks: &[(String, Option<&dyn Sink>)],
source_count: usize,
delivery: crate::idempotency::DeliveryMode,
source: Option<&dyn Source>,
) -> Result<ResumePlan, FaucetError> {
let Some(store) = opts.state_store.as_ref() else {
return Ok(ResumePlan::default());
};
if sinks.is_empty() {
return Ok(ResumePlan::default());
}
let mut stored: Vec<(String, Option<Value>)> = Vec::with_capacity(sinks.len());
for (id, _) in sinks {
let key = crate::state_key::StateKey::invocation(opts.pipeline_name.as_str(), id.as_str())
.render();
let value = match store.get(&key).await {
Ok(Some(v)) => Some(match source {
Some(src) => crate::state_version::resolve_for_source(&key, &v, src)?.data,
None => crate::state_version::peel_versioned(&v),
}),
Ok(None) => None,
Err(e) => return Err(e),
};
stored.push((id.clone(), value));
}
if delivery != crate::idempotency::DeliveryMode::ExactlyOnce {
if stored.iter().any(|(_, v)| v.is_none()) {
return Ok(ResumePlan::default());
}
let values: Vec<Value> = stored.into_iter().filter_map(|(_, v)| v).collect();
return Ok(ResumePlan {
start: start_bookmark(&values, source_count),
sinks: HashMap::new(),
});
}
let mut positions: Vec<(String, Option<Value>, u64)> = Vec::with_capacity(sinks.len());
for ((id, sink), (_, value)) in sinks.iter().zip(stored) {
let (mut bm, mut seq) = value
.as_ref()
.map(crate::idempotency::unwrap_state)
.unwrap_or((None, 0));
let key = crate::state_key::StateKey::invocation(opts.pipeline_name.as_str(), id.as_str())
.render();
if let (Some(sink), Some(src)) = (sink, source)
&& sink.supports_idempotent_writes()
&& src.replay_guarantee() == crate::idempotency::ReplayGuarantee::Deterministic
&& let Some(token) = sink.last_committed_token(&key).await?
&& let Some((token_seq, Some(token_bm))) = crate::idempotency::parse_token_parts(&token)
&& token_seq > seq
{
bm = Some(token_bm);
seq = token_seq;
}
positions.push((id.clone(), bm, seq));
}
plan_exactly_once(&positions, source_count, source, &opts.pipeline_name)
}
fn plan_exactly_once(
positions: &[(String, Option<Value>, u64)],
source_count: usize,
source: Option<&dyn Source>,
pipeline: &str,
) -> Result<ResumePlan, FaucetError> {
let own = |skip: &dyn Fn(&Option<Value>) -> Option<Value>| -> HashMap<String, SinkResume> {
positions
.iter()
.map(|(id, bm, seq)| {
(
id.clone(),
SinkResume {
start_seq: *seq,
skip_through: skip(bm),
},
)
})
.collect()
};
if source_count != 1 {
if source_count > 1 {
tracing::warn!(
sources = source_count,
"topology: multi-source graph cannot attribute a sink bookmark to a source; \
replaying every source in full"
);
}
return Ok(ResumePlan {
start: None,
sinks: own(&|_| None),
});
}
let known: Vec<Value> = positions
.iter()
.filter_map(|(_, bm, _)| bm.clone())
.collect();
let all_known = known.len() == positions.len();
if all_known && known.iter().all(|b| b == &known[0]) {
return Ok(ResumePlan {
start: known.first().cloned(),
sinks: own(&|_| None),
});
}
let ordering = source.filter(|src| {
known
.first()
.is_some_and(|b| src.position_le(b, b).is_some())
});
if let Some(src) = ordering {
let start = if all_known {
src.position_min(&known)
} else {
None
};
if start.is_some() || !all_known {
let skip = |bm: &Option<Value>| bm.clone().filter(|b| Some(b) != start.as_ref());
return Ok(ResumePlan {
start: start.clone(),
sinks: own(&skip),
});
}
}
if positions
.iter()
.all(|(_, bm, seq)| bm.is_none() && *seq == 0)
{
return Ok(ResumePlan {
start: None,
sinks: own(&|_| None),
});
}
let committed: Vec<String> = positions
.iter()
.map(|(id, bm, seq)| match bm {
Some(bm) => format!("'{id}' at seq {seq} ({bm})"),
None => format!("'{id}' with no committed position"),
})
.collect();
let keys: Vec<String> = positions
.iter()
.map(|(id, _, _)| {
let key = crate::state_key::StateKey::invocation(pipeline, id.as_str());
format!("'{key}'")
})
.collect();
Err(FaucetError::State(format!(
"topology exactly-once: the sink nodes committed different positions ({}), and the \
source '{}' cannot order its positions (Source::position_le), so no resume point \
avoids both lost and duplicated records; resuming by page count is unsafe because \
a replay need not produce the same pages. To recover, either delete every sink \
node's state ({}) and its commit-token watermark row under the same scope, then \
rerun to replay from the start (each sink re-writes what it already holds), or \
reset only the sinks that are ahead to the furthest-behind sink's position and \
accept duplicates in between; a source that implements Source::position_le resumes \
automatically. See the topology cookbook, \"Exactly-once delivery\"",
committed.join(", "),
source.map_or("unknown", |s| s.connector_name()),
keys.join(", "),
)))
}
pub fn eo_start_bookmark(ranked: &[(u64, Option<Value>)], source_count: usize) -> Option<Value> {
if ranked.is_empty() || source_count != 1 {
if source_count > 1 && !ranked.is_empty() {
tracing::warn!(
sources = source_count,
"topology: multi-source graph cannot attribute a sink bookmark to a source; \
replaying every source in full"
);
}
return None;
}
ranked
.iter()
.min_by_key(|(seq, _)| *seq)
.and_then(|(_, bm)| bm.clone())
}
pub fn start_bookmark(sink_bookmarks: &[Value], source_count: usize) -> Option<Value> {
if sink_bookmarks.is_empty() || source_count != 1 {
if source_count > 1 && !sink_bookmarks.is_empty() {
tracing::warn!(
sources = source_count,
"topology: multi-source graph cannot attribute a sink bookmark to a source; \
replaying every source in full. Make the sinks idempotent \
(`write_mode: upsert`) or split the graph into one pipeline per source."
);
}
return None;
}
let first = &sink_bookmarks[0];
if sink_bookmarks.iter().any(|v| v != first) {
tracing::warn!(
"topology: sink bookmarks have diverged and resume positions are not safely \
ordered; replaying the source in full so no sink is skipped past. Faster sinks \
will re-see already-written pages — make them idempotent."
);
return None;
}
Some(first.clone())
}
async fn recv_or_cancel(
rx: &mut mpsc::Receiver<StreamPage>,
cancel: &CancellationToken,
) -> Option<StreamPage> {
tokio::select! {
biased;
() = cancel.cancelled() => None,
page = rx.recv() => page,
}
}
async fn broadcast(
page: StreamPage,
outs: &mut Vec<mpsc::Sender<StreamPage>>,
cancel: &CancellationToken,
) -> bool {
if outs.is_empty() {
return false;
}
let last = outs.len() - 1;
let mut page = Some(page);
let mut closed: Vec<usize> = Vec::new();
for (i, tx) in outs.iter().enumerate() {
let item = if i == last { page.take() } else { page.clone() };
let Some(item) = item else { break };
tokio::select! {
biased;
() = cancel.cancelled() => return false,
r = tx.send(item) => if r.is_err() { closed.push(i) },
}
}
for &i in closed.iter().rev() {
outs.remove(i);
}
!outs.is_empty()
}
fn cancelled(cancel: &Option<CancellationToken>) -> bool {
cancel.as_ref().is_some_and(|c| c.is_cancelled())
}
async fn run_source_node(
labels: Labels,
source: Arc<dyn Source>,
start_bookmark: Option<Value>,
batch_size: usize,
mut outs: Vec<mpsc::Sender<StreamPage>>,
cancel: CancellationToken,
) -> Result<NodeOutcome, FaucetError> {
let node_id = labels.row.to_string();
source.set_roundtrip_recorder(Arc::new(crate::observability::RoundtripRecorder::new(
crate::observability::RoundtripSide::Source,
labels.pipeline.to_string(),
node_id.clone(),
source.connector_name(),
)));
let source = crate::observability::InstrumentedSource::new(source.as_ref(), labels);
if let Some(bm) = start_bookmark {
source.apply_start_bookmark(bm).await?;
}
let ctx = std::collections::HashMap::new();
let mut pages = source.stream_pages(&ctx, batch_size);
let mut records = 0usize;
loop {
let item = tokio::select! {
biased;
() = cancel.cancelled() => break,
item = pages.next() => item,
};
let Some(item) = item else { break };
let page = item?;
records += page.records.len();
if !broadcast(page, &mut outs, &cancel).await {
break;
}
}
Ok(NodeOutcome::Source { node_id, records })
}
async fn run_transform_node(
stages: Vec<CompiledStage>,
labels: Labels,
mut rx: mpsc::Receiver<StreamPage>,
mut outs: Vec<mpsc::Sender<StreamPage>>,
cancel: CancellationToken,
) -> Result<NodeOutcome, FaucetError> {
while let Some(page) = recv_or_cancel(&mut rx, &cancel).await {
let records = instrumented_apply_stages(page.records, &stages, &labels)?;
let out = StreamPage {
records,
bookmark: page.bookmark,
};
if !broadcast(out, &mut outs, &cancel).await {
break;
}
}
Ok(NodeOutcome::Other)
}
fn node_labels(pipeline: &str, node: &str) -> Vec<Label> {
vec![
Label::new("pipeline", SharedString::from(pipeline.to_string())),
Label::new("node", SharedString::from(node.to_string())),
]
}
async fn run_tee_node(
node_id: String,
pipeline: String,
mut rx: mpsc::Receiver<StreamPage>,
mut outs: Vec<mpsc::Sender<StreamPage>>,
cancel: CancellationToken,
) -> Result<NodeOutcome, FaucetError> {
let labels = node_labels(&pipeline, &node_id);
while let Some(page) = recv_or_cancel(&mut rx, &cancel).await {
counter!("faucet_tee_records_total", labels.clone()).increment(page.records.len() as u64);
if !broadcast(page, &mut outs, &cancel).await {
break;
}
}
Ok(NodeOutcome::Other)
}
async fn run_merge_node(
node_id: String,
pipeline: String,
rxs: Vec<mpsc::Receiver<StreamPage>>,
mut outs: Vec<mpsc::Sender<StreamPage>>,
cancel: CancellationToken,
mode: MergeMode,
) -> Result<NodeOutcome, FaucetError> {
let labels = node_labels(&pipeline, &node_id);
if mode == MergeMode::Strip {
tracing::warn!(
node = %node_id,
"topology: merge '{node_id}' combines inputs that share a source with inputs that \
do not, so their positions cannot be lined up; it forwards no bookmarks and the \
next run replays in full"
);
}
let mut gate = BookmarkGate::new(rxs.len());
let streams = rxs.into_iter().enumerate().map(|(i, mut rx)| {
Box::pin(async_stream::stream! {
while let Some(p) = rx.recv().await {
yield (i, Some(p));
}
yield (i, None);
}) as Pin<Box<dyn futures::Stream<Item = (usize, Option<StreamPage>)> + Send>>
});
let mut sel = futures::stream::select_all(streams);
loop {
let next = tokio::select! {
biased;
() = cancel.cancelled() => break,
next = sel.next() => next,
};
let Some((input, page)) = next else { break };
let out = match (mode, page) {
(MergeMode::Gated, None) => match gate.close(input) {
Some(bm) => StreamPage {
records: Vec::new(),
bookmark: Some(bm),
},
None => continue,
},
(_, None) => continue,
(MergeMode::Gated, Some(p)) => StreamPage {
bookmark: p.bookmark.and_then(|bm| gate.arrive(input, bm)),
records: p.records,
},
(MergeMode::Strip, Some(p)) => StreamPage {
records: p.records,
bookmark: None,
},
(MergeMode::Passthrough, Some(p)) => p,
};
counter!("faucet_merge_records_total", labels.clone()).increment(out.records.len() as u64);
if !broadcast(out, &mut outs, &cancel).await {
break;
}
}
Ok(NodeOutcome::Other)
}
struct JoinInputs {
build_rx: mpsc::Receiver<StreamPage>,
probe_rx: mpsc::Receiver<StreamPage>,
build_upstream: HashSet<String>,
failed: FailedNodes,
}
async fn run_join_node(
node_id: String,
pipeline: String,
j: JoinNode,
inputs: JoinInputs,
mut outs: Vec<mpsc::Sender<StreamPage>>,
cancel: CancellationToken,
) -> Result<NodeOutcome, FaucetError> {
let JoinInputs {
mut build_rx,
mut probe_rx,
build_upstream,
failed,
} = inputs;
let mode = j.config.mode;
let mut join = HashJoin::new(j.config);
let build_start = std::time::Instant::now();
while let Some(page) = recv_or_cancel(&mut build_rx, &cancel).await {
join.add_build_page(page.records)?;
}
if let Some((node, error)) = failed_upstream(&failed, &build_upstream) {
return Err(FaucetError::Source(format!(
"join '{node_id}': build-side node '{node}' failed, so its hash table is \
incomplete and nothing was probed: {error}"
)));
}
if cancel.is_cancelled() {
return Ok(NodeOutcome::Other);
}
let labels = node_labels(&pipeline, &node_id);
histogram!("faucet_join_build_duration_seconds", labels.clone())
.record(build_start.elapsed().as_secs_f64());
while let Some(page) = recv_or_cancel(&mut probe_rx, &cancel).await {
let enriched = join.probe_page(page.records)?;
let out = StreamPage {
records: enriched,
bookmark: page.bookmark,
};
if !broadcast(out, &mut outs, &cancel).await {
break;
}
}
emit_join_metrics(&labels, mode, join.stats());
Ok(NodeOutcome::Other)
}
fn emit_join_metrics(labels: &[Label], mode: JoinMode, stats: &crate::join::JoinStats) {
counter!("faucet_join_build_records_total", labels.to_vec()).increment(stats.build_records);
counter!("faucet_join_build_nulls_total", labels.to_vec()).increment(stats.build_nulls);
counter!("faucet_join_duplicates_total", labels.to_vec()).increment(stats.duplicates);
counter!("faucet_join_probe_records_total", labels.to_vec()).increment(stats.probe_records);
counter!("faucet_join_project_misses_total", labels.to_vec()).increment(stats.project_misses);
let mut match_labels = labels.to_vec();
match_labels.push(Label::new("kind", SharedString::from(mode.to_string())));
counter!("faucet_join_matches_total", match_labels.clone()).increment(stats.matches);
counter!("faucet_join_misses_total", match_labels).increment(stats.misses);
}
struct DeferredState {
store: Arc<dyn StateStore>,
key: String,
}
struct SinkNodeOpts {
pipeline_name: String,
run_id: String,
state_store: Option<Arc<dyn StateStore>>,
dlq: Option<DlqConfig>,
cancel: CancellationToken,
#[cfg(feature = "masking")]
masking: Option<Arc<crate::masking::CompiledMasking>>,
#[cfg(feature = "quality")]
quality: Option<Arc<crate::quality::CompiledQuality>>,
#[cfg(feature = "contract")]
contract: Option<Arc<crate::contract::CompiledContract>>,
schema_drift: Option<crate::drift::SchemaDriftPolicy>,
resilience: Option<crate::resilience::ResiliencePolicy>,
delivery: crate::idempotency::DeliveryMode,
replay: Option<crate::idempotency::ReplayGuarantee>,
codec: crate::state_version::StateCodec,
resume: Option<SinkResume>,
position_source: Option<Arc<dyn Source>>,
source_label: String,
upstream: HashSet<String>,
failed: FailedNodes,
}
fn count_node_run(mut labels: Vec<Label>, err: Option<&FaucetError>) {
labels.push(Label::new(
"status",
SharedString::const_str(if err.is_some() { "err" } else { "ok" }),
));
if let Some(e) = err {
labels.push(Label::new(
"kind",
SharedString::const_str(crate::observability::decorator::error_kind(e)),
));
}
counter!("faucet_pipeline_runs_total", labels).increment(1);
}
fn source_label(
upstream: Option<&HashSet<String>>,
kinds: &HashMap<String, &'static str>,
) -> String {
let mut found: Vec<&str> = upstream
.into_iter()
.flatten()
.filter_map(|id| kinds.get(id).copied())
.collect();
found.sort_unstable();
found.dedup();
match found.as_slice() {
[one] => (*one).to_string(),
[] => "unknown".to_string(),
_ => "multiple".to_string(),
}
}
fn failed_upstream(failed: &FailedNodes, upstream: &HashSet<String>) -> Option<(String, String)> {
let failed = failed.lock().unwrap_or_else(|p| p.into_inner());
let mut hits: Vec<(&String, &(String, NodeErrorKind))> = failed
.iter()
.filter(|(id, _)| upstream.contains(*id))
.collect();
hits.sort_by(|a, b| a.0.cmp(b.0));
hits.first()
.map(|(id, (msg, _))| ((*id).clone(), msg.clone()))
}
#[derive(Debug, PartialEq)]
enum PageCoverage {
Committed,
Straddles,
Past,
}
fn covered_page(src: &dyn Source, page: &StreamPage, through: &Value) -> PageCoverage {
let Some(bm) = page.bookmark.as_ref() else {
return PageCoverage::Straddles;
};
if src.position_le(bm, through) == Some(true) {
PageCoverage::Committed
} else if src.position_le(through, bm) == Some(true) {
PageCoverage::Past
} else {
PageCoverage::Straddles
}
}
fn trim_covered(
records: &mut Vec<Value>,
src: &dyn Source,
through: &Value,
keyed: bool,
sink_id: &str,
) -> Result<(), FaucetError> {
let mut unknown = false;
records.retain(|r| match src.record_covered(r, through) {
Some(covered) => !covered,
None => {
unknown = true;
true
}
});
if unknown && !keyed {
return Err(FaucetError::State(format!(
"topology exactly-once: a replayed page straddles the position sink '{sink_id}' \
already committed, and source '{}' cannot tell from a record reaching it whether \
it was delivered — a transform removed the source's position fields (`topic` / \
`partition` / `offset` for kafka, `lsn` for postgres-cdc / mysql-cdc / mssql-cdc). \
Keep those fields until the sink, or write with `write_mode: upsert` + `key`",
src.connector_name()
)));
}
Ok(())
}
#[allow(deprecated)] async fn run_sink_node(
node_id: String,
sink: Box<dyn Sink>,
mut rx: mpsc::Receiver<StreamPage>,
opts: SinkNodeOpts,
) -> Result<NodeOutcome, FaucetError> {
let failed = Arc::clone(&opts.failed);
let upstream = opts.upstream.clone();
let position_source = opts.position_source.clone();
let mut skip_through = opts.resume.as_ref().and_then(|r| r.skip_through.clone());
let keyed = sink.dedups_by_key();
let sink_id = node_id.clone();
let pages = Box::pin(async_stream::stream! {
loop {
match rx.recv().await {
Some(mut page) => {
if let (Some(through), Some(src)) = (skip_through.as_ref(), position_source.as_ref()) {
let coverage = covered_page(src.as_ref(), &page, through);
if coverage == PageCoverage::Committed {
continue;
}
if let Err(e) = trim_covered(&mut page.records, src.as_ref(), through, keyed, &sink_id) {
yield Err(e);
break;
}
if coverage == PageCoverage::Past {
skip_through = None;
}
}
yield Ok::<StreamPage, FaucetError>(page);
}
None => {
if let Some((node, error)) = failed_upstream(&failed, &upstream) {
yield Err(FaucetError::Source(format!(
"upstream node '{node}' failed, so sink '{sink_id}' did not \
receive its complete input: {error}"
)));
}
break;
}
}
}
});
let mut run_opts = RunStreamOptions::new()
.with_name(opts.pipeline_name.clone())
.with_row(node_id.clone())
.with_run_id(opts.run_id.clone());
let overwriting = sink.is_overwrite();
let mut deferred = None;
if let Some(store) = opts.state_store {
let key =
crate::state_key::StateKey::invocation(opts.pipeline_name.as_str(), node_id.as_str())
.render();
let store: Arc<dyn StateStore> = Arc::new(crate::state_version::VersionedStateStore::new(
store,
key.clone(),
opts.codec,
));
if opts.delivery == crate::idempotency::DeliveryMode::ExactlyOnce {
let seq = match opts.resume.as_ref() {
Some(r) => r.start_seq,
None => match store.get(&key).await? {
Some(prior) => crate::idempotency::unwrap_state(&prior).1,
None => 0,
},
};
run_opts = run_opts.with_delivery(opts.delivery).with_start_seq(seq);
if let Some(replay) = opts.replay {
run_opts = run_opts.with_replay_guarantee(replay);
}
}
if overwriting {
deferred = Some(DeferredState {
store: Arc::clone(&store),
key: key.clone(),
});
}
run_opts = run_opts.with_state(store, key);
}
if let Some(dlq) = opts.dlq {
run_opts = run_opts.with_dlq(dlq);
}
run_opts = run_opts.with_cancel(opts.cancel);
#[cfg(feature = "masking")]
if let Some(m) = opts.masking {
run_opts = run_opts.with_masking(m);
}
#[cfg(feature = "quality")]
if let Some(q) = opts.quality {
run_opts = run_opts.with_quality(q);
}
#[cfg(feature = "contract")]
if let Some(c) = opts.contract {
run_opts = run_opts.with_contract(c);
}
if let Some(d) = opts.schema_drift {
run_opts.schema_drift = Some(d);
}
if let Some(r) = opts.resilience {
run_opts.resilience = Some(r);
}
if overwriting {
sink.begin_overwrite().await?;
}
let labels = Labels::new(
opts.pipeline_name.clone(),
node_id.clone(),
opts.run_id.clone(),
);
sink.set_roundtrip_recorder(Arc::new(crate::observability::RoundtripRecorder::new(
crate::observability::RoundtripSide::Sink,
opts.pipeline_name.clone(),
node_id.clone(),
sink.connector_name(),
)));
let run_labels = vec![
Label::new("pipeline", SharedString::from(opts.pipeline_name.clone())),
Label::new("row", SharedString::from(node_id.clone())),
Label::new("source", SharedString::from(opts.source_label.clone())),
Label::new("sink", SharedString::const_str(sink.connector_name())),
];
let timer = crate::observability::DurationGuard::new(
"faucet_pipeline_run_duration_seconds",
run_labels.clone(),
);
let instrumented = crate::observability::InstrumentedSink::new(sink.as_ref(), labels);
let outcome = run_stream(pages, &instrumented, run_opts).await;
drop(instrumented);
drop(timer);
count_node_run(run_labels, outcome.as_ref().err());
let result = match outcome {
Ok(r) => r,
Err(e) => {
if overwriting {
abort_quietly(sink.as_ref()).await;
}
return Err(e);
}
};
Ok(NodeOutcome::Sink {
node_id,
records: result.records_written,
bookmark: result.bookmark,
sink,
deferred,
})
}
#[derive(Default)]
pub struct TopologyBuilder {
nodes: Vec<Node>,
edges: Vec<Edge>,
}
impl TopologyBuilder {
pub fn node(mut self, id: impl Into<String>, kind: NodeKind) -> Self {
self.nodes.push(Node {
id: id.into(),
kind,
});
self
}
pub fn source(self, id: impl Into<String>, source: Box<dyn Source>) -> Self {
self.node(id, NodeKind::Source(source))
}
pub fn transform(self, id: impl Into<String>, stages: Vec<CompiledStage>) -> Self {
self.node(id, NodeKind::Transform(stages))
}
pub fn tee(self, id: impl Into<String>, capacity: usize, fanout: Option<usize>) -> Self {
self.node(id, NodeKind::Tee { capacity, fanout })
}
pub fn merge(self, id: impl Into<String>) -> Self {
self.node(id, NodeKind::Merge)
}
pub fn join(self, id: impl Into<String>, join: JoinNode) -> Self {
self.node(id, NodeKind::Join(join))
}
pub fn sink(self, id: impl Into<String>, sink: Box<dyn Sink>) -> Self {
self.node(id, NodeKind::Sink(sink))
}
pub fn edge(mut self, from: impl Into<String>, to: impl Into<String>) -> Self {
self.edges.push(Edge {
from: from.into(),
to: to.into(),
label: None,
});
self
}
pub fn labelled_edge(
mut self,
from: impl Into<String>,
to: impl Into<String>,
label: impl Into<String>,
) -> Self {
self.edges.push(Edge {
from: from.into(),
to: to.into(),
label: Some(label.into()),
});
self
}
pub fn build(self) -> Result<Topology, FaucetError> {
let t = Topology {
nodes: self.nodes,
edges: self.edges,
};
t.validate()?;
Ok(t)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::join::{JoinConfig, JoinMode, Projection};
use crate::state::MemoryStateStore;
use async_trait::async_trait;
use serde_json::json;
use std::sync::Mutex;
pub(super) struct VecSource {
records: Vec<Value>,
bookmark: Option<Value>,
}
impl VecSource {
pub(super) fn boxed(records: Vec<Value>) -> Box<dyn Source> {
Box::new(VecSource {
records,
bookmark: None,
})
}
fn boxed_bm(records: Vec<Value>, bm: Value) -> Box<dyn Source> {
Box::new(VecSource {
records,
bookmark: Some(bm),
})
}
}
#[async_trait]
impl Source for VecSource {
async fn fetch_with_context(
&self,
_c: &std::collections::HashMap<String, Value>,
) -> Result<Vec<Value>, FaucetError> {
Ok(self.records.clone())
}
async fn fetch_with_context_incremental(
&self,
_c: &std::collections::HashMap<String, Value>,
) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
Ok((self.records.clone(), self.bookmark.clone()))
}
}
struct FailingSource;
#[async_trait]
impl Source for FailingSource {
async fn fetch_with_context(
&self,
_c: &std::collections::HashMap<String, Value>,
) -> Result<Vec<Value>, FaucetError> {
Err(FaucetError::Source("boom".into()))
}
}
struct RecordingSource {
records: Vec<Value>,
applied: Arc<Mutex<Option<Value>>>,
}
#[async_trait]
impl Source for RecordingSource {
async fn fetch_with_context(
&self,
_c: &std::collections::HashMap<String, Value>,
) -> Result<Vec<Value>, FaucetError> {
Ok(self.records.clone())
}
async fn apply_start_bookmark(&self, bm: Value) -> Result<(), FaucetError> {
*self.applied.lock().unwrap() = Some(bm);
Ok(())
}
}
#[derive(Clone)]
pub(super) struct CollectSink {
store: Arc<Mutex<Vec<Value>>>,
}
impl CollectSink {
pub(super) fn new() -> (Self, Arc<Mutex<Vec<Value>>>) {
let store = Arc::new(Mutex::new(Vec::new()));
(
Self {
store: store.clone(),
},
store,
)
}
}
#[async_trait]
impl Sink for CollectSink {
async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
self.store.lock().unwrap().extend_from_slice(records);
Ok(records.len())
}
}
pub(super) struct FailingSink;
#[async_trait]
impl Sink for FailingSink {
async fn write_batch(&self, _records: &[Value]) -> Result<usize, FaucetError> {
Err(FaucetError::Sink("sink boom".into()))
}
}
pub(super) struct BreakerSink;
#[async_trait]
impl Sink for BreakerSink {
async fn write_batch(&self, _records: &[Value]) -> Result<usize, FaucetError> {
Err(FaucetError::CircuitOpen {
failures: 3,
cooldown: std::time::Duration::from_secs(30),
})
}
}
struct FlushTrackingSink {
flushed: Arc<Mutex<bool>>,
}
#[async_trait]
impl Sink for FlushTrackingSink {
async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
Ok(records.len())
}
async fn flush(&self) -> Result<(), FaucetError> {
*self.flushed.lock().unwrap() = true;
Ok(())
}
}
pub(super) fn recs(n: usize) -> Vec<Value> {
(0..n).map(|i| json!({ "i": i })).collect()
}
#[test]
fn validate_rejects_a_source_that_consumes_destructively() {
struct Queue;
#[async_trait]
impl Source for Queue {
async fn fetch_with_context(
&self,
_: &std::collections::HashMap<String, Value>,
) -> Result<Vec<Value>, FaucetError> {
Ok(Vec::new())
}
fn consumes_destructively(&self) -> bool {
true
}
fn connector_name(&self) -> &'static str {
"queue"
}
}
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
assert!(rt.block_on(Queue.fetch_all()).unwrap().is_empty());
let (sink, _) = CollectSink::new();
let err = Topology::builder()
.source("q", Box::new(Queue))
.sink("out", Box::new(sink))
.edge("q", "out")
.build()
.unwrap_err()
.to_string();
assert!(err.contains("source 'q' (queue)"), "{err}");
}
#[test]
fn validate_rejects_empty() {
let err = Topology {
nodes: vec![],
edges: vec![],
}
.validate()
.unwrap_err();
assert!(err.to_string().contains("no nodes"));
}
#[test]
fn validate_rejects_duplicate_id() {
let (sink, _) = CollectSink::new();
let err = Topology::builder()
.source("a", VecSource::boxed(recs(1)))
.sink("a", Box::new(sink))
.edge("a", "a")
.build()
.unwrap_err();
assert!(err.to_string().contains("duplicate node id"));
}
#[test]
fn validate_rejects_unknown_endpoint() {
let err = Topology::builder()
.source("s", VecSource::boxed(recs(1)))
.edge("s", "ghost")
.build()
.unwrap_err();
assert!(err.to_string().contains("unknown 'to' node 'ghost'"));
}
#[test]
fn validate_rejects_unknown_from_endpoint() {
let (sink, _) = CollectSink::new();
let err = Topology::builder()
.sink("k", Box::new(sink))
.edge("ghost", "k")
.build()
.unwrap_err();
assert!(err.to_string().contains("unknown 'from' node 'ghost'"));
}
#[test]
fn validate_rejects_source_with_incoming_edge() {
let (sink, _) = CollectSink::new();
let err = Topology::builder()
.source("s", VecSource::boxed(recs(1)))
.sink("k", Box::new(sink))
.edge("s", "k")
.edge("k", "s") .build()
.unwrap_err();
assert!(err.to_string().contains("arity") || err.to_string().contains("cycle"));
}
#[test]
fn validate_rejects_tee_fanout_mismatch() {
let (s1, _) = CollectSink::new();
let (s2, _) = CollectSink::new();
let err = Topology::builder()
.source("s", VecSource::boxed(recs(1)))
.tee("t", 4, Some(3))
.sink("a", Box::new(s1))
.sink("b", Box::new(s2))
.edge("s", "t")
.edge("t", "a")
.edge("t", "b")
.build()
.unwrap_err();
assert!(err.to_string().contains("fanout 3 but has 2"));
}
#[test]
fn validate_rejects_tee_with_one_output() {
let (s1, _) = CollectSink::new();
let err = Topology::builder()
.source("s", VecSource::boxed(recs(1)))
.tee("t", 4, None)
.sink("a", Box::new(s1))
.edge("s", "t")
.edge("t", "a")
.build()
.unwrap_err();
assert!(err.to_string().contains("tee 't'"));
}
#[test]
fn validate_rejects_merge_with_one_input() {
let (s1, _) = CollectSink::new();
let err = Topology::builder()
.source("s", VecSource::boxed(recs(1)))
.merge("m")
.sink("a", Box::new(s1))
.edge("s", "m")
.edge("m", "a")
.build()
.unwrap_err();
assert!(err.to_string().contains("merge 'm'"));
}
#[test]
fn validate_rejects_join_missing_label() {
let (s1, _) = CollectSink::new();
let jn = JoinNode {
config: JoinConfig::default(),
build_edge: "build".into(),
probe_edge: "probe".into(),
};
let err = Topology::builder()
.source("b", VecSource::boxed(recs(1)))
.source("p", VecSource::boxed(recs(1)))
.join("j", jn)
.sink("a", Box::new(s1))
.labelled_edge("b", "j", "build")
.edge("p", "j") .edge("j", "a")
.build()
.unwrap_err();
assert!(err.to_string().contains("labelled 'probe'"));
}
#[test]
fn validate_rejects_join_same_labels() {
let (s1, _) = CollectSink::new();
let jn = JoinNode {
config: JoinConfig::default(),
build_edge: "x".into(),
probe_edge: "x".into(),
};
let err = Topology::builder()
.source("b", VecSource::boxed(recs(1)))
.source("p", VecSource::boxed(recs(1)))
.join("j", jn)
.sink("a", Box::new(s1))
.labelled_edge("b", "j", "x")
.labelled_edge("p", "j", "x")
.edge("j", "a")
.build()
.unwrap_err();
assert!(err.to_string().contains("must differ"));
}
#[test]
fn validate_rejects_cycle() {
let (sink, _) = CollectSink::new();
let err = Topology::builder()
.source("s", VecSource::boxed(recs(1)))
.merge("m")
.tee("t", 4, None)
.sink("k", Box::new(sink))
.edge("s", "m")
.edge("m", "t")
.edge("t", "m")
.edge("t", "k")
.build()
.unwrap_err();
assert!(err.to_string().contains("cycle"), "{err}");
}
#[test]
fn validate_rejects_no_source() {
let err = Topology::builder()
.transform("t1", vec![])
.transform("t2", vec![])
.edge("t1", "t2")
.edge("t2", "t1")
.build()
.unwrap_err();
assert!(err.to_string().contains("no source"), "{err}");
}
#[test]
fn validate_rejects_no_sink() {
let err = Topology::builder()
.source("s1", VecSource::boxed(recs(1)))
.source("s2", VecSource::boxed(recs(1)))
.merge("m")
.edge("s1", "m")
.edge("s2", "m")
.edge("m", "m")
.build()
.unwrap_err();
assert!(err.to_string().contains("no sink"), "{err}");
}
#[tokio::test]
async fn simple_source_to_sink() {
let (sink, store) = CollectSink::new();
let topo = Topology::builder()
.source("s", VecSource::boxed(recs(5)))
.sink("k", Box::new(sink))
.edge("s", "k")
.build()
.unwrap();
let result = topo.run(TopologyOptions::new("p")).await.unwrap();
assert_eq!(result.records_written, 5);
assert_eq!(store.lock().unwrap().len(), 5);
assert_eq!(result.per_sink.get("k"), Some(&5));
}
#[tokio::test]
async fn source_transform_sink() {
let (sink, store) = CollectSink::new();
let topo = Topology::builder()
.source("s", VecSource::boxed(recs(3)))
.transform("t", vec![]) .sink("k", Box::new(sink))
.edge("s", "t")
.edge("t", "k")
.build()
.unwrap();
let result = topo.run(TopologyOptions::new("p")).await.unwrap();
assert_eq!(result.records_written, 3);
assert_eq!(store.lock().unwrap().len(), 3);
}
#[tokio::test]
async fn tee_fans_out_to_three_sinks() {
let (s1, st1) = CollectSink::new();
let (s2, st2) = CollectSink::new();
let (s3, st3) = CollectSink::new();
let topo = Topology::builder()
.source("s", VecSource::boxed(recs(10)))
.tee("t", 4, Some(3))
.sink("a", Box::new(s1))
.sink("b", Box::new(s2))
.sink("c", Box::new(s3))
.edge("s", "t")
.edge("t", "a")
.edge("t", "b")
.edge("t", "c")
.build()
.unwrap();
let result = topo.run(TopologyOptions::new("p")).await.unwrap();
assert_eq!(st1.lock().unwrap().len(), 10);
assert_eq!(st2.lock().unwrap().len(), 10);
assert_eq!(st3.lock().unwrap().len(), 10);
assert_eq!(result.records_written, 30);
}
#[tokio::test]
async fn merge_fans_in_two_sources() {
let (sink, store) = CollectSink::new();
let topo = Topology::builder()
.source("s1", VecSource::boxed(recs(4)))
.source("s2", VecSource::boxed(recs(6)))
.merge("m")
.sink("k", Box::new(sink))
.edge("s1", "m")
.edge("s2", "m")
.edge("m", "k")
.build()
.unwrap();
let result = topo.run(TopologyOptions::new("p")).await.unwrap();
assert_eq!(result.records_written, 10);
assert_eq!(store.lock().unwrap().len(), 10);
}
#[tokio::test]
async fn join_enriches_end_to_end() {
let (sink, store) = CollectSink::new();
let customers = vec![
json!({"id": 1, "tier": "gold"}),
json!({"id": 2, "tier": "silver"}),
];
let orders = vec![
json!({"order": "A", "cust": 1}),
json!({"order": "B", "cust": 2}),
json!({"order": "C", "cust": 99}),
];
let jn = JoinNode {
config: JoinConfig {
mode: JoinMode::Inner,
build_key: "id".into(),
probe_key: "cust".into(),
projections: vec![Projection {
from: "tier".into(),
as_: "tier".into(),
}],
..Default::default()
},
build_edge: "customers".into(),
probe_edge: "orders".into(),
};
let topo = Topology::builder()
.source("c", VecSource::boxed(customers))
.source("o", VecSource::boxed(orders))
.join("j", jn)
.sink("k", Box::new(sink))
.labelled_edge("c", "j", "customers")
.labelled_edge("o", "j", "orders")
.edge("j", "k")
.build()
.unwrap();
let result = topo.run(TopologyOptions::new("p")).await.unwrap();
assert_eq!(result.records_written, 2);
let written = store.lock().unwrap();
assert!(
written
.iter()
.any(|r| r["order"] == json!("A") && r["tier"] == json!("gold"))
);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn source_and_sink_nodes_are_instrumented() {
use metrics_util::debugging::DebugValue;
let _g = crate::observability::decorator::source_tests::LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let snap = crate::observability::decorator::source_tests::snapshotter();
let probe = crate::forwarding::HookSink::default();
let topo = Topology::builder()
.source("src-node", VecSource::boxed(recs(3)))
.sink("sink-node", Box::new(probe.clone()))
.edge("src-node", "sink-node")
.build()
.unwrap();
topo.run(TopologyOptions::new("topo-metrics"))
.await
.unwrap();
assert!(probe.reached().contains(&"set_roundtrip_recorder"));
type Counter = (String, Vec<(String, String)>, u64);
let counters: Vec<Counter> = snap
.snapshot()
.into_vec()
.into_iter()
.filter_map(|(k, _, _, v)| match v {
DebugValue::Counter(c) => Some((
k.key().name().to_string(),
k.key()
.labels()
.map(|l| (l.key().to_string(), l.value().to_string()))
.collect::<Vec<_>>(),
c,
)),
_ => None,
})
.filter(|(_, l, _)| l.contains(&("pipeline".into(), "topo-metrics".into())))
.collect();
let has = |name: &str, label: (&str, &str), n: u64| {
counters.iter().any(|(m, l, c)| {
m == name && l.contains(&(label.0.into(), label.1.into())) && *c == n
})
};
assert!(
has("faucet_source_records_total", ("row", "src-node"), 3),
"{counters:?}"
);
assert!(
has("faucet_sink_records_total", ("row", "sink-node"), 3),
"{counters:?}"
);
assert!(
has("faucet_pipeline_runs_total", ("source", "VecSource"), 1),
"{counters:?}"
);
assert!(
has("faucet_pipeline_runs_total", ("status", "ok"), 1),
"{counters:?}"
);
}
#[test]
fn a_node_fed_by_several_sources_is_labelled_multiple() {
let kinds: HashMap<String, &'static str> = [
("a".to_string(), "rest"),
("b".to_string(), "csv"),
("c".to_string(), "rest"),
]
.into();
let up = |ids: &[&str]| ids.iter().map(|s| s.to_string()).collect::<HashSet<_>>();
assert_eq!(source_label(Some(&up(&["a", "t"])), &kinds), "rest");
assert_eq!(source_label(Some(&up(&["a", "c"])), &kinds), "rest");
assert_eq!(source_label(Some(&up(&["a", "b"])), &kinds), "multiple");
assert_eq!(source_label(None, &kinds), "unknown");
}
#[tokio::test]
async fn a_state_read_error_fails_the_run_instead_of_replaying() {
struct Unreadable;
#[async_trait]
impl StateStore for Unreadable {
async fn get(&self, _k: &str) -> Result<Option<Value>, FaucetError> {
Err(FaucetError::State("store unreachable".into()))
}
async fn put(&self, _k: &str, _v: &Value) -> Result<(), FaucetError> {
Ok(())
}
async fn delete(&self, _k: &str) -> Result<(), FaucetError> {
Ok(())
}
}
let (sink, store) = CollectSink::new();
let topo = Topology::builder()
.source("s", VecSource::boxed(recs(2)))
.sink("k", Box::new(sink))
.edge("s", "k")
.build()
.unwrap();
let mut opts = TopologyOptions::new("p");
opts.state_store = Some(Arc::new(Unreadable));
let err = topo.run(opts).await.unwrap_err();
assert!(err.to_string().contains("store unreachable"), "{err}");
assert!(store.lock().unwrap().is_empty());
}
#[tokio::test]
async fn a_failed_build_side_is_never_probed() {
struct HalfSource;
#[async_trait]
impl Source for HalfSource {
async fn fetch_with_context(
&self,
_c: &std::collections::HashMap<String, Value>,
) -> Result<Vec<Value>, FaucetError> {
Ok(Vec::new())
}
fn stream_pages<'a>(
&'a self,
_c: &'a std::collections::HashMap<String, Value>,
_b: usize,
) -> Pin<Box<dyn crate::Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>>
{
Box::pin(futures::stream::iter(vec![
Ok(StreamPage {
records: vec![json!({"id": 1, "tier": "gold"})],
bookmark: None,
}),
Err(FaucetError::Source("build side broke".into())),
]))
}
}
for on_error in [TopologyOnError::Continue, TopologyOnError::Propagate] {
let (sink, store) = CollectSink::new();
let jn = JoinNode {
config: JoinConfig {
mode: JoinMode::Left,
build_key: "id".into(),
probe_key: "cust".into(),
projections: vec![Projection {
from: "tier".into(),
as_: "tier".into(),
}],
..Default::default()
},
build_edge: "customers".into(),
probe_edge: "orders".into(),
};
let topo = Topology::builder()
.source("c", Box::new(HalfSource))
.source(
"o",
VecSource::boxed(vec![
json!({"order": "A", "cust": 1}),
json!({"order": "B", "cust": 2}),
]),
)
.join("j", jn)
.sink("k", Box::new(sink))
.labelled_edge("c", "j", "customers")
.labelled_edge("o", "j", "orders")
.edge("j", "k")
.build()
.unwrap();
let mut opts = TopologyOptions::new("p");
opts.on_error = on_error;
let (run, err) = topo
.run_attributed(opts, TopologyGovernance::default())
.await;
let errors = format!("{err:?} {:?}", run.result.errors);
assert!(errors.contains("build side broke"), "{errors}");
assert!(
store.lock().unwrap().is_empty(),
"{on_error:?}: nothing probed"
);
}
}
#[tokio::test]
async fn propagate_aborts_on_sink_failure() {
let topo = Topology::builder()
.source("s", VecSource::boxed(recs(3)))
.sink("k", Box::new(FailingSink))
.edge("s", "k")
.build()
.unwrap();
let err = topo.run(TopologyOptions::new("p")).await.unwrap_err();
assert!(matches!(err, FaucetError::Sink(_)));
}
#[tokio::test]
async fn propagate_aborts_on_source_failure() {
let (sink, _) = CollectSink::new();
let topo = Topology::builder()
.source("s", Box::new(FailingSource))
.sink("k", Box::new(sink))
.edge("s", "k")
.build()
.unwrap();
let err = topo.run(TopologyOptions::new("p")).await.unwrap_err();
assert!(matches!(err, FaucetError::Source(_)));
}
struct CompletingSink(Arc<Mutex<u32>>);
#[async_trait]
impl Sink for CompletingSink {
async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
Ok(records.len())
}
async fn complete_run(&self) -> Result<(), FaucetError> {
*self.0.lock().unwrap() += 1;
Ok(())
}
}
#[tokio::test]
async fn complete_run_fires_only_for_a_fully_successful_graph() {
let done = Arc::new(Mutex::new(0));
let topo = Topology::builder()
.source("s", VecSource::boxed(recs(2)))
.sink("k", Box::new(CompletingSink(done.clone())))
.edge("s", "k")
.build()
.unwrap();
topo.run(TopologyOptions::new("p")).await.unwrap();
assert_eq!(*done.lock().unwrap(), 1);
let failed = Arc::new(Mutex::new(0));
let topo = Topology::builder()
.source("s", Box::new(FailingSource))
.sink("k", Box::new(CompletingSink(failed.clone())))
.edge("s", "k")
.build()
.unwrap();
assert!(topo.run(TopologyOptions::new("p")).await.is_err());
assert_eq!(*failed.lock().unwrap(), 0);
let partial = Arc::new(Mutex::new(0));
let topo = Topology::builder()
.source("s", VecSource::boxed(recs(4)))
.tee("t", 4, Some(2))
.sink("bad", Box::new(FailingSink))
.sink("good", Box::new(CompletingSink(partial.clone())))
.edge("s", "t")
.edge("t", "bad")
.edge("t", "good")
.build()
.unwrap();
let opts = TopologyOptions::new("p").with_on_error(TopologyOnError::Continue);
topo.run(opts).await.unwrap();
assert_eq!(*partial.lock().unwrap(), 0);
let cancelled = Arc::new(Mutex::new(0));
let token = CancellationToken::new();
token.cancel();
let topo = Topology::builder()
.source("s", VecSource::boxed(recs(2)))
.sink("k", Box::new(CompletingSink(cancelled.clone())))
.edge("s", "k")
.build()
.unwrap();
let _ = topo.run(TopologyOptions::new("p").with_cancel(token)).await;
assert_eq!(*cancelled.lock().unwrap(), 0);
}
#[tokio::test]
async fn continue_lets_healthy_branch_finish() {
let (good, store) = CollectSink::new();
let topo = Topology::builder()
.source("s", VecSource::boxed(recs(8)))
.tee("t", 8, Some(2))
.sink("bad", Box::new(FailingSink))
.sink("good", Box::new(good))
.edge("s", "t")
.edge("t", "bad")
.edge("t", "good")
.build()
.unwrap();
let opts = TopologyOptions::new("p").with_on_error(TopologyOnError::Continue);
let result = topo.run(opts).await.unwrap();
assert_eq!(store.lock().unwrap().len(), 8);
assert!(!result.errors.is_empty(), "failing sink should be recorded");
}
#[tokio::test]
async fn state_agreeing_bookmarks_resume_the_source() {
let store = Arc::new(MemoryStateStore::new());
store.put("p::a", &json!(100)).await.unwrap();
store.put("p::b", &json!(100)).await.unwrap();
let applied = Arc::new(Mutex::new(None));
let src = RecordingSource {
records: recs(1),
applied: applied.clone(),
};
let (s1, _) = CollectSink::new();
let (s2, _) = CollectSink::new();
let topo = Topology::builder()
.source("s", Box::new(src))
.tee("t", 4, Some(2))
.sink("a", Box::new(s1))
.sink("b", Box::new(s2))
.edge("s", "t")
.edge("t", "a")
.edge("t", "b")
.build()
.unwrap();
let opts = TopologyOptions::new("p").with_state_store(store.clone());
topo.run(opts).await.unwrap();
assert_eq!(*applied.lock().unwrap(), Some(json!(100)));
}
#[tokio::test]
async fn state_diverged_bookmarks_replay_in_full() {
let store = Arc::new(MemoryStateStore::new());
store.put("p::a", &json!(250)).await.unwrap();
store.put("p::b", &json!(100)).await.unwrap();
let applied = Arc::new(Mutex::new(None));
let src = RecordingSource {
records: recs(1),
applied: applied.clone(),
};
let (s1, _) = CollectSink::new();
let (s2, _) = CollectSink::new();
let topo = Topology::builder()
.source("s", Box::new(src))
.tee("t", 4, Some(2))
.sink("a", Box::new(s1))
.sink("b", Box::new(s2))
.edge("s", "t")
.edge("t", "a")
.edge("t", "b")
.build()
.unwrap();
let opts = TopologyOptions::new("p").with_state_store(store.clone());
topo.run(opts).await.unwrap();
assert_eq!(
*applied.lock().unwrap(),
None,
"diverged bookmarks must replay, never guess an order"
);
}
#[tokio::test]
async fn state_multi_source_never_cross_applies_a_bookmark() {
let store = Arc::new(MemoryStateStore::new());
store.put("p::k", &json!(500)).await.unwrap();
let a_applied = Arc::new(Mutex::new(None));
let b_applied = Arc::new(Mutex::new(None));
let a = RecordingSource {
records: recs(1),
applied: a_applied.clone(),
};
let b = RecordingSource {
records: recs(1),
applied: b_applied.clone(),
};
let (sink, _) = CollectSink::new();
let topo = Topology::builder()
.source("a", Box::new(a))
.source("b", Box::new(b))
.merge("m")
.sink("k", Box::new(sink))
.edge("a", "m")
.edge("b", "m")
.edge("m", "k")
.build()
.unwrap();
let opts = TopologyOptions::new("p").with_state_store(store.clone());
topo.run(opts).await.unwrap();
assert_eq!(*a_applied.lock().unwrap(), None);
assert_eq!(*b_applied.lock().unwrap(), None);
}
#[tokio::test]
async fn state_no_bookmark_when_a_sink_is_missing() {
let store = Arc::new(MemoryStateStore::new());
store.put("p::a", &json!(100)).await.unwrap();
let applied = Arc::new(Mutex::new(None));
let src = RecordingSource {
records: recs(1),
applied: applied.clone(),
};
let (s1, _) = CollectSink::new();
let (s2, _) = CollectSink::new();
let topo = Topology::builder()
.source("s", Box::new(src))
.tee("t", 4, Some(2))
.sink("a", Box::new(s1))
.sink("b", Box::new(s2))
.edge("s", "t")
.edge("t", "a")
.edge("t", "b")
.build()
.unwrap();
let opts = TopologyOptions::new("p").with_state_store(store.clone());
topo.run(opts).await.unwrap();
assert_eq!(*applied.lock().unwrap(), None);
}
#[tokio::test]
async fn sink_persists_bookmark() {
let store = Arc::new(MemoryStateStore::new());
let (sink, _) = CollectSink::new();
let topo = Topology::builder()
.source("s", VecSource::boxed_bm(recs(2), json!("v9")))
.sink("k", Box::new(sink))
.edge("s", "k")
.build()
.unwrap();
let opts = TopologyOptions::new("p").with_state_store(store.clone());
let result = topo.run(opts).await.unwrap();
assert_eq!(result.bookmarks.get("k"), Some(&Some(json!("v9"))));
assert_eq!(
store
.get("p::k")
.await
.unwrap()
.map(|v| crate::state_version::peel_versioned(&v)),
Some(json!("v9"))
);
}
#[tokio::test]
async fn propagate_lets_siblings_flush_before_returning_the_error() {
let flushed = Arc::new(Mutex::new(false));
let tracker = FlushTrackingSink {
flushed: flushed.clone(),
};
let topo = Topology::builder()
.source("s", VecSource::boxed(recs(64)))
.tee("t", 4, Some(2))
.sink("bad", Box::new(FailingSink))
.sink("good", Box::new(tracker))
.edge("s", "t")
.edge("t", "bad")
.edge("t", "good")
.build()
.unwrap();
let opts = TopologyOptions::new("p").with_on_error(TopologyOnError::Propagate);
let err = topo.run(opts).await.unwrap_err();
assert!(matches!(err, FaucetError::Sink(_)), "{err:?}");
assert!(
*flushed.lock().unwrap(),
"the healthy sink node must be flushed, not dropped mid-write"
);
}
#[cfg(feature = "masking")]
#[tokio::test]
async fn masking_applies_to_a_sink_node() {
use crate::masking::{CompiledMasking, MaskingSpec};
let spec: MaskingSpec = serde_json::from_value(json!({
"rules": [{
"name": "hide-email",
"match": { "fields": ["email"] },
"action": { "type": "redact", "mask": "***" }
}]
}))
.unwrap();
let compiled = Arc::new(CompiledMasking::compile(&spec).unwrap());
let (sink, store) = CollectSink::new();
let topo = Topology::builder()
.source(
"s",
VecSource::boxed(vec![json!({"id": 1, "email": "a@b.c"})]),
)
.sink("k", Box::new(sink))
.edge("s", "k")
.build()
.unwrap();
let mut governance = TopologyGovernance::new();
governance.masking_by_sink.insert("k".to_string(), compiled);
topo.run_with(TopologyOptions::new("p"), governance)
.await
.unwrap();
let written = store.lock().unwrap();
assert_eq!(written.len(), 1);
assert_eq!(written[0]["email"], json!("***"), "PII must be masked");
assert_eq!(written[0]["id"], json!(1));
}
#[tokio::test]
async fn cancellation_stops_the_run() {
let cancel = CancellationToken::new();
cancel.cancel(); let (sink, store) = CollectSink::new();
let topo = Topology::builder()
.source("s", VecSource::boxed(recs(1000)))
.sink("k", Box::new(sink))
.edge("s", "k")
.build()
.unwrap();
let opts = TopologyOptions::new("p").with_cancel(cancel);
let result = topo.run(opts).await.unwrap();
assert!(store.lock().unwrap().len() < 1000);
let _ = result;
}
#[test]
fn start_bookmark_only_resumes_when_provably_safe() {
assert_eq!(
start_bookmark(&[json!(100), json!(100)], 1),
Some(json!(100))
);
let lsn = json!({"slot": "s", "lsn": "0/16B3748"});
assert_eq!(
start_bookmark(&[lsn.clone(), lsn.clone()], 1),
Some(lsn.clone())
);
assert_eq!(start_bookmark(&[json!(250), json!(100)], 1), None);
assert_eq!(
start_bookmark(
&[json!({"lsn": "0/9FFFFFF"}), json!({"lsn": "0/10000000"}),],
1
),
None
);
assert_eq!(start_bookmark(&[json!(100), json!(100)], 2), None);
assert_eq!(start_bookmark(&[], 1), None);
assert_eq!(start_bookmark(&[], 3), None);
}
#[test]
fn kind_str_matches() {
assert_eq!(NodeKind::Merge.kind_str(), "merge");
assert_eq!(
NodeKind::Tee {
capacity: 1,
fanout: None
}
.kind_str(),
"tee"
);
}
#[test]
fn builder_exposes_nodes_and_edges() {
let (sink, _) = CollectSink::new();
let topo = Topology::builder()
.source("s", VecSource::boxed(recs(1)))
.sink("k", Box::new(sink))
.edge("s", "k")
.build()
.unwrap();
assert_eq!(topo.nodes().len(), 2);
assert_eq!(topo.edges().len(), 1);
}
#[cfg(feature = "transform-keys-case")]
#[tokio::test]
async fn transform_node_applies_stage() {
use crate::stage::{TransformStage, compile_stage};
use crate::transform::{KeyCaseMode, RecordTransform};
let stage = compile_stage(&TransformStage::Map(RecordTransform::KeysCase {
mode: KeyCaseMode::Snake,
on_collision: Default::default(),
}))
.unwrap();
let (sink, store) = CollectSink::new();
let topo = Topology::builder()
.source("s", VecSource::boxed(vec![json!({"FooBar": 1})]))
.transform("t", vec![stage])
.sink("k", Box::new(sink))
.edge("s", "t")
.edge("t", "k")
.build()
.unwrap();
topo.run(TopologyOptions::new("p")).await.unwrap();
let w = store.lock().unwrap();
assert!(w[0].get("foo_bar").is_some());
}
}
#[cfg(test)]
mod delivery_and_report_tests {
use super::tests::{BreakerSink, CollectSink, FailingSink, VecSource, recs};
use super::*;
use crate::Stream;
use crate::idempotency::{DeliveryMode, format_token_with_bookmark, wrap_state};
use crate::state::{MemoryStateStore, StateStore};
use async_trait::async_trait;
use serde_json::json;
use std::sync::Mutex;
struct TokenSink {
rows: Arc<Mutex<Vec<Value>>>,
tokens: Arc<Mutex<std::collections::HashMap<String, String>>>,
}
#[async_trait]
impl Sink for TokenSink {
async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
self.rows.lock().unwrap().extend_from_slice(records);
Ok(records.len())
}
fn supports_idempotent_writes(&self) -> bool {
true
}
async fn write_batch_idempotent(
&self,
records: &[Value],
scope: &str,
token: &str,
) -> Result<usize, FaucetError> {
self.rows.lock().unwrap().extend_from_slice(records);
self.tokens
.lock()
.unwrap()
.insert(scope.to_string(), token.to_string());
Ok(records.len())
}
async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
Ok(self.tokens.lock().unwrap().get(scope).cloned())
}
}
#[tokio::test]
async fn exactly_once_commits_a_token_per_sink_node_scope() {
let rows = Arc::new(Mutex::new(Vec::new()));
let tokens = Arc::new(Mutex::new(std::collections::HashMap::new()));
let sink = TokenSink {
rows: rows.clone(),
tokens: tokens.clone(),
};
let store = Arc::new(MemoryStateStore::new());
let topo = Topology::builder()
.source("s", Box::new(EoSource(recs(3))))
.sink("k", Box::new(sink))
.edge("s", "k")
.build()
.unwrap();
let mut gov = TopologyGovernance::new();
gov.delivery = DeliveryMode::ExactlyOnce;
let opts = TopologyOptions::new("p").with_state_store(store.clone());
topo.run_with(opts, gov).await.unwrap();
assert_eq!(rows.lock().unwrap().len(), 3);
let committed = tokens.lock().unwrap();
assert!(
committed.contains_key("p::k"),
"token must be scoped to the sink node's own state key, got {:?}",
committed.keys().collect::<Vec<_>>()
);
}
struct EoSource(Vec<Value>);
#[async_trait]
impl Source for EoSource {
async fn fetch_with_context(
&self,
_ctx: &std::collections::HashMap<String, Value>,
) -> Result<Vec<Value>, FaucetError> {
Ok(self.0.clone())
}
fn stream_pages<'a>(
&'a self,
_ctx: &'a std::collections::HashMap<String, Value>,
_batch: usize,
) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
let rows = self.0.clone();
Box::pin(async_stream::try_stream! {
yield StreamPage { records: rows, bookmark: Some(json!({"pos": 1})) };
})
}
fn replay_guarantee(&self) -> crate::idempotency::ReplayGuarantee {
crate::idempotency::ReplayGuarantee::Deterministic
}
fn supports_exactly_once(&self) -> bool {
true
}
}
#[test]
fn eo_resume_picks_the_lowest_sequence() {
let a = (7u64, Some(json!({"pos": 7})));
let b = (4u64, Some(json!({"pos": 4})));
let c = (9u64, Some(json!({"pos": 9})));
assert_eq!(
eo_start_bookmark(&[a.clone(), b.clone(), c.clone()], 1),
Some(json!({"pos": 4})),
"resume from the laggard, not the leader"
);
assert_eq!(eo_start_bookmark(&[a, b, c], 2), None);
assert_eq!(eo_start_bookmark(&[], 1), None);
}
#[tokio::test]
async fn eo_resume_unwraps_the_state_envelope() {
let store = Arc::new(MemoryStateStore::new());
store
.put("p::k", &wrap_state(Some(&json!({"pos": 5})), 5))
.await
.unwrap();
let opts = TopologyOptions::new("p").with_state_store(store.clone());
let bm = compute_start_bookmark(
&opts,
&["k".to_string()],
1,
DeliveryMode::ExactlyOnce,
None,
)
.await
.unwrap();
assert_eq!(bm, Some(json!({"pos": 5})), "envelope must be unwrapped");
let t = format_token_with_bookmark(5, Some(&json!({"pos": 5})));
assert!(t.contains('#'), "token embeds the bookmark: {t}");
}
#[test]
fn node_error_kind_classifies_only_the_breaker() {
assert_eq!(
NodeErrorKind::classify(&FaucetError::CircuitOpen {
failures: 3,
cooldown: std::time::Duration::from_secs(30),
}),
NodeErrorKind::CircuitOpen
);
assert_eq!(
NodeErrorKind::classify(&FaucetError::Sink(
"Circuit open after 3 consecutive failures".into()
)),
NodeErrorKind::Other
);
}
#[tokio::test]
async fn a_breaker_trip_is_reported_on_the_node_that_raised_it_only() {
let (healthy, _) = CollectSink::new();
let topo = Topology::builder()
.source("s", VecSource::boxed(recs(4)))
.tee("t", 4, Some(2))
.sink("tripped", Box::new(BreakerSink))
.sink("healthy", Box::new(healthy))
.edge("s", "t")
.edge("t", "tripped")
.edge("t", "healthy")
.build()
.unwrap();
let run = topo
.run_reported(
TopologyOptions::new("p").with_on_error(TopologyOnError::Continue),
TopologyGovernance::new(),
)
.await
.unwrap();
let tripped = run.nodes.iter().find(|n| n.node_id == "tripped").unwrap();
assert_eq!(
tripped.error_kind,
Some(NodeErrorKind::CircuitOpen),
"the node that raised the trip must carry the class that earns the cooldown"
);
let classified: Vec<&str> = run
.nodes
.iter()
.filter(|n| n.error_kind == Some(NodeErrorKind::CircuitOpen))
.map(|n| n.node_id.as_str())
.collect();
assert_eq!(
classified,
vec!["tripped"],
"exactly one node may be classified as the breaker trip"
);
let healthy = run.nodes.iter().find(|n| n.node_id == "healthy").unwrap();
assert_eq!(healthy.error_kind, None);
}
#[tokio::test]
async fn run_reported_attributes_failures_to_their_node() {
let (good, _) = CollectSink::new();
let topo = Topology::builder()
.source("s", VecSource::boxed(recs(4)))
.tee("t", 4, Some(2))
.sink("bad", Box::new(FailingSink))
.sink("good", Box::new(good))
.edge("s", "t")
.edge("t", "bad")
.edge("t", "good")
.build()
.unwrap();
let run = topo
.run_reported(
TopologyOptions::new("p").with_on_error(TopologyOnError::Continue),
TopologyGovernance::new(),
)
.await
.unwrap();
let bad = run.nodes.iter().find(|n| n.node_id == "bad").unwrap();
assert!(bad.error.is_some(), "the failing sink is attributed");
assert_eq!(
bad.error_kind,
Some(NodeErrorKind::Other),
"an ordinary sink failure is classified, just not as a breaker trip"
);
let good = run.nodes.iter().find(|n| n.node_id == "good").unwrap();
assert!(good.error.is_none(), "the healthy sink is not");
assert_eq!(good.records, 4);
assert_eq!(run.nodes.len(), 4);
assert!(run.nodes.iter().any(|n| n.kind == "source"));
assert!(run.nodes.iter().any(|n| n.kind == "tee"));
}
}
#[cfg(test)]
#[allow(clippy::disallowed_methods)] mod graph_safety_tests {
use super::tests::{CollectSink, FailingSink, VecSource, recs};
use super::*;
use crate::Stream;
use crate::idempotency::{DeliveryMode, format_token_with_bookmark, wrap_state};
use crate::state::MemoryStateStore;
use async_trait::async_trait;
use serde_json::json;
use std::sync::Mutex;
struct LogSource {
total: u64,
per_page: u64,
ordered: bool,
start: Mutex<u64>,
applied: Arc<Mutex<Option<Value>>>,
}
impl LogSource {
fn new(total: u64, per_page: u64, ordered: bool) -> (Self, Arc<Mutex<Option<Value>>>) {
let applied = Arc::new(Mutex::new(None));
(
Self {
total,
per_page,
ordered,
start: Mutex::new(0),
applied: applied.clone(),
},
applied,
)
}
}
#[async_trait]
impl Source for LogSource {
async fn fetch_with_context(
&self,
_c: &HashMap<String, Value>,
) -> Result<Vec<Value>, FaucetError> {
Ok(Vec::new())
}
fn stream_pages<'a>(
&'a self,
_ctx: &'a HashMap<String, Value>,
_batch: usize,
) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
let mut next = *self.start.lock().unwrap() + 1;
let (total, per_page) = (self.total, self.per_page);
Box::pin(async_stream::try_stream! {
while next <= total {
let last = (next + per_page - 1).min(total);
let records: Vec<Value> = (next..=last).map(|n| json!({ "n": n })).collect();
yield StreamPage { records, bookmark: Some(json!({ "pos": last })) };
next = last + 1;
}
})
}
async fn apply_start_bookmark(&self, bm: Value) -> Result<(), FaucetError> {
*self.start.lock().unwrap() = bm["pos"].as_u64().unwrap_or(0);
*self.applied.lock().unwrap() = Some(bm);
Ok(())
}
fn replay_guarantee(&self) -> crate::idempotency::ReplayGuarantee {
crate::idempotency::ReplayGuarantee::Deterministic
}
fn supports_exactly_once(&self) -> bool {
true
}
fn position_le(&self, a: &Value, b: &Value) -> Option<bool> {
if !self.ordered {
return None;
}
Some(a["pos"].as_u64()? <= b["pos"].as_u64()?)
}
fn record_covered(&self, record: &Value, position: &Value) -> Option<bool> {
if !self.ordered {
return None;
}
Some(record["n"].as_u64()? <= position["pos"].as_u64()?)
}
}
#[derive(Clone)]
struct TokenSink {
rows: Arc<Mutex<Vec<Value>>>,
tokens: Arc<Mutex<HashMap<String, String>>>,
}
impl TokenSink {
fn new() -> Self {
Self {
rows: Arc::new(Mutex::new(Vec::new())),
tokens: Arc::new(Mutex::new(HashMap::new())),
}
}
fn committed(self, scope: &str, seq: u64, pos: u64) -> Self {
self.tokens.lock().unwrap().insert(
scope.into(),
format_token_with_bookmark(seq, Some(&json!({ "pos": pos }))),
);
self
}
fn ns(&self) -> Vec<u64> {
self.rows
.lock()
.unwrap()
.iter()
.filter_map(|r| r["n"].as_u64())
.collect()
}
}
#[async_trait]
impl Sink for TokenSink {
async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
self.rows.lock().unwrap().extend_from_slice(records);
Ok(records.len())
}
fn supports_idempotent_writes(&self) -> bool {
true
}
async fn write_batch_idempotent(
&self,
records: &[Value],
scope: &str,
token: &str,
) -> Result<usize, FaucetError> {
self.rows.lock().unwrap().extend_from_slice(records);
self.tokens
.lock()
.unwrap()
.insert(scope.to_string(), token.to_string());
Ok(records.len())
}
async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
Ok(self.tokens.lock().unwrap().get(scope).cloned())
}
}
async fn seed(store: &MemoryStateStore, key: &str, pos: u64, seq: u64) {
store
.put(key, &wrap_state(Some(&json!({ "pos": pos })), seq))
.await
.unwrap();
}
async fn stored_seq(store: &MemoryStateStore, key: &str) -> u64 {
let v = store.get(key).await.unwrap().unwrap();
crate::idempotency::unwrap_state(&crate::state_version::peel_versioned(&v)).1
}
fn eo() -> TopologyGovernance {
let mut g = TopologyGovernance::new();
g.delivery = DeliveryMode::ExactlyOnce;
g
}
fn two_sink_graph(source: LogSource, ahead: TokenSink, behind: TokenSink) -> Topology {
Topology::builder()
.source("s", Box::new(source))
.tee("t", 4, Some(2))
.sink("ahead", Box::new(ahead))
.sink("behind", Box::new(behind))
.edge("s", "t")
.edge("t", "ahead")
.edge("t", "behind")
.build()
.unwrap()
}
#[tokio::test]
async fn eo_diverged_sinks_refuse_when_the_source_cannot_order_positions() {
let store = Arc::new(MemoryStateStore::new());
seed(&store, "p::ahead", 9, 9).await;
seed(&store, "p::behind", 5, 5).await;
let ahead = TokenSink::new().committed("p::ahead", 9, 9);
let behind = TokenSink::new().committed("p::behind", 5, 5);
let (source, applied) = LogSource::new(10, 1, false);
let topo = two_sink_graph(source, ahead.clone(), behind.clone());
let err = topo
.run_with(
TopologyOptions::new("p").with_state_store(store.clone()),
eo(),
)
.await
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("cannot order its positions"), "{msg}");
assert!(msg.contains("'ahead' at seq 9"), "{msg}");
assert_eq!(*applied.lock().unwrap(), None, "nothing was replayed");
assert!(ahead.ns().is_empty() && behind.ns().is_empty());
assert_eq!(stored_seq(&store, "p::ahead").await, 9);
}
#[tokio::test]
async fn eo_a_straddling_replayed_page_drops_the_committed_records() {
let store = Arc::new(MemoryStateStore::new());
seed(&store, "p::ahead", 6, 6).await;
seed(&store, "p::behind", 5, 5).await;
let ahead = TokenSink::new().committed("p::ahead", 6, 6);
let behind = TokenSink::new().committed("p::behind", 5, 5);
let (source, _) = LogSource::new(10, 2, true);
let topo = two_sink_graph(source, ahead.clone(), behind.clone());
topo.run_with(
TopologyOptions::new("p").with_state_store(store.clone()),
eo(),
)
.await
.unwrap();
assert_eq!(ahead.ns(), vec![7, 8, 9, 10], "record 6 was committed");
assert_eq!(behind.ns(), vec![6, 7, 8, 9, 10]);
}
#[tokio::test]
async fn eo_a_straddling_page_without_position_fields_is_refused() {
let store = Arc::new(MemoryStateStore::new());
seed(&store, "p::ahead", 6, 6).await;
seed(&store, "p::behind", 5, 5).await;
let ahead = TokenSink::new().committed("p::ahead", 6, 6);
let behind = TokenSink::new().committed("p::behind", 5, 5);
let (source, _) = LogSource::new(10, 2, true);
let strip: crate::stage::PageFnBox = Arc::new(|records: Vec<Value>| {
Ok(records
.into_iter()
.map(|r| json!({ "m": r["n"].clone() }))
.collect())
});
let topo = Topology::builder()
.source("s", Box::new(source))
.transform("x", vec![CompiledStage::PageFn(strip)])
.tee("t", 4, Some(2))
.sink("ahead", Box::new(ahead.clone()))
.sink("behind", Box::new(behind))
.edge("s", "x")
.edge("x", "t")
.edge("t", "ahead")
.edge("t", "behind")
.build()
.unwrap();
let err = topo
.run_with(
TopologyOptions::new("p").with_state_store(store.clone()),
eo(),
)
.await
.unwrap_err()
.to_string();
assert!(
err.contains("position fields") && err.contains("'ahead'"),
"{err}"
);
assert!(
ahead.rows.lock().unwrap().is_empty(),
"nothing was written twice"
);
}
#[test]
fn trim_covered_keeps_unclassified_records_for_a_keyed_sink() {
let (src, _) = LogSource::new(1, 1, true);
let mut records = vec![json!({ "n": 1 }), json!({ "n": 3 }), json!({ "m": 2 })];
trim_covered(&mut records, &src, &json!({ "pos": 2 }), true, "k").unwrap();
assert_eq!(records, vec![json!({ "n": 3 }), json!({ "m": 2 })]);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn a_slow_page_stage_in_a_transform_node_does_not_pin_the_worker() {
use std::sync::atomic::{AtomicUsize, Ordering};
let slow: crate::stage::PageFnBox = Arc::new(|records: Vec<Value>| {
std::thread::sleep(std::time::Duration::from_millis(400));
Ok(records)
});
let (source, _) = LogSource::new(1, 1, false);
let sink = TokenSink::new();
let topo = Topology::builder()
.source("s", Box::new(source))
.transform("x", vec![CompiledStage::PageFn(slow)])
.sink("k", Box::new(sink.clone()))
.edge("s", "x")
.edge("x", "k")
.build()
.unwrap();
let ticks = Arc::new(AtomicUsize::new(0));
let t = ticks.clone();
let ticker = tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
t.fetch_add(1, Ordering::SeqCst);
}
});
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
let before = ticks.load(Ordering::SeqCst);
topo.run(TopologyOptions::new("p")).await.unwrap();
let during = ticks.load(Ordering::SeqCst) - before;
ticker.abort();
assert_eq!(sink.ns(), vec![1]);
assert!(during >= 20, "the ticker starved: {during} ticks");
}
#[test]
fn covered_page_follows_a_partial_order() {
struct Pair;
#[async_trait]
impl Source for Pair {
async fn fetch_with_context(
&self,
_ctx: &HashMap<String, Value>,
) -> Result<Vec<Value>, FaucetError> {
Ok(Vec::new())
}
fn position_le(&self, a: &Value, b: &Value) -> Option<bool> {
Some(a["x"].as_u64()? <= b["x"].as_u64()? && a["y"].as_u64()? <= b["y"].as_u64()?)
}
}
let through = json!({ "x": 5, "y": 5 });
let page = |bm: Option<Value>| StreamPage {
records: Vec::new(),
bookmark: bm,
};
let cov = |bm| covered_page(&Pair, &page(bm), &through);
assert_eq!(
cov(Some(json!({ "x": 4, "y": 5 }))),
PageCoverage::Committed
);
assert_eq!(
cov(Some(json!({ "x": 9, "y": 3 }))),
PageCoverage::Straddles
);
assert_eq!(cov(Some(json!({ "x": 9, "y": 5 }))), PageCoverage::Past);
assert_eq!(cov(None), PageCoverage::Straddles);
}
#[tokio::test]
async fn eo_diverged_sinks_skip_by_position_when_the_source_orders_them() {
let store = Arc::new(MemoryStateStore::new());
seed(&store, "p::ahead", 9, 9).await;
seed(&store, "p::behind", 5, 5).await;
let ahead = TokenSink::new().committed("p::ahead", 9, 9);
let behind = TokenSink::new().committed("p::behind", 5, 5);
let (source, applied) = LogSource::new(10, 2, true);
let topo = two_sink_graph(source, ahead.clone(), behind.clone());
topo.run_with(
TopologyOptions::new("p").with_state_store(store.clone()),
eo(),
)
.await
.unwrap();
assert_eq!(*applied.lock().unwrap(), Some(json!({ "pos": 5 })));
assert_eq!(ahead.ns(), vec![10]);
assert_eq!(behind.ns(), vec![6, 7, 8, 9, 10]);
assert_eq!(stored_seq(&store, "p::ahead").await, 10);
}
#[tokio::test]
async fn eo_resume_reanchors_from_the_sinks_committed_token() {
let store = Arc::new(MemoryStateStore::new());
seed(&store, "p::k", 4, 4).await;
let sink = TokenSink::new().committed("p::k", 5, 7);
let (source, applied) = LogSource::new(9, 1, false);
let topo = Topology::builder()
.source("s", Box::new(source))
.sink("k", Box::new(sink.clone()))
.edge("s", "k")
.build()
.unwrap();
topo.run_with(
TopologyOptions::new("p").with_state_store(store.clone()),
eo(),
)
.await
.unwrap();
assert_eq!(*applied.lock().unwrap(), Some(json!({ "pos": 7 })));
assert_eq!(sink.ns(), vec![8, 9]);
assert_eq!(stored_seq(&store, "p::k").await, 7);
}
#[test]
fn eo_plan_without_a_committed_position_replays_from_zero() {
let (src, _) = LogSource::new(1, 1, false);
let err = plan_exactly_once(
&[
("a".into(), Some(json!({ "pos": 3 })), 3),
("b".into(), None, 0),
],
1,
Some(&src),
"p",
)
.unwrap_err();
assert!(
err.to_string().contains("'b' with no committed position"),
"an unordered source cannot skip by count (#862 H-07): {err}"
);
assert!(
err.to_string().contains("'p::a', 'p::b'")
&& err.to_string().contains("delete every sink node's state"),
"the refusal names the recovery: {err}"
);
let plan = plan_exactly_once(
&[("a".into(), None, 0), ("b".into(), None, 0)],
1,
Some(&src),
"p",
)
.unwrap();
assert_eq!(plan.start, None);
assert!(plan.sinks.values().all(|r| r.start_seq == 0));
let (ordered, _) = LogSource::new(1, 1, true);
let plan = plan_exactly_once(
&[
("a".into(), Some(json!({ "pos": 3 })), 3),
("b".into(), None, 0),
],
1,
Some(&ordered),
"p",
)
.unwrap();
assert_eq!(plan.start, None);
assert_eq!(plan.sinks["a"].skip_through, Some(json!({ "pos": 3 })));
assert_eq!(plan.sinks["a"].start_seq, 3);
assert_eq!(plan.sinks["b"].skip_through, None);
}
#[test]
fn eo_plan_for_several_sources_keeps_each_sinks_sequence() {
let plan = plan_exactly_once(
&[
("a".into(), Some(json!({ "pos": 3 })), 3),
("b".into(), Some(json!({ "pos": 4 })), 4),
],
2,
None,
"p",
)
.unwrap();
assert_eq!(plan.start, None);
assert_eq!(plan.sinks["a"].start_seq, 3);
assert_eq!(plan.sinks["b"].start_seq, 4);
}
#[test]
fn eo_plan_agreeing_sinks_resume_without_skipping() {
let plan = plan_exactly_once(
&[
("a".into(), Some(json!({ "pos": 3 })), 3),
("b".into(), Some(json!({ "pos": 3 })), 6),
],
1,
None,
"p",
)
.unwrap();
assert_eq!(plan.start, Some(json!({ "pos": 3 })));
assert_eq!(plan.sinks["b"].start_seq, 6);
assert!(plan.sinks.values().all(|r| r.skip_through.is_none()));
}
#[test]
fn validate_rejects_a_join_fed_by_one_tee_on_both_sides() {
let jn = JoinNode {
config: JoinConfig {
mode: JoinMode::Inner,
build_key: "i".into(),
probe_key: "i".into(),
..Default::default()
},
build_edge: "b".into(),
probe_edge: "p".into(),
};
let (sink, _) = CollectSink::new();
let err = Topology::builder()
.source("s", VecSource::boxed(recs(1)))
.tee("t", 4, Some(2))
.join("j", jn)
.sink("k", Box::new(sink))
.edge("s", "t")
.labelled_edge("t", "j", "b")
.labelled_edge("t", "j", "p")
.edge("j", "k")
.build()
.unwrap_err()
.to_string();
assert!(err.contains("join 'j'"), "{err}");
assert!(err.contains("'s'") || err.contains("'t'"), "{err}");
}
#[tokio::test]
async fn dropping_the_run_future_aborts_its_node_tasks() {
struct Wedged {
alive: Arc<()>,
entered: Arc<tokio::sync::Notify>,
}
#[async_trait::async_trait]
impl Sink for Wedged {
async fn write_batch(&self, _: &[Value]) -> Result<usize, FaucetError> {
let _hold = Arc::clone(&self.alive);
self.entered.notify_one();
std::future::pending().await
}
}
let alive = Arc::new(());
let entered = Arc::new(tokio::sync::Notify::new());
let topology = Topology::builder()
.source("s", VecSource::boxed(recs(2)))
.sink(
"k",
Box::new(Wedged {
alive: Arc::clone(&alive),
entered: Arc::clone(&entered),
}),
)
.edge("s", "k")
.build()
.unwrap();
let run = tokio::spawn(topology.run(TopologyOptions::default()));
entered.notified().await;
run.abort();
let _ = run.await;
tokio::time::timeout(Duration::from_secs(5), async {
while Arc::strong_count(&alive) > 1 {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("the wedged sink node must be aborted with the run");
}
#[tokio::test]
async fn broadcast_gives_up_on_a_full_channel_when_cancelled() {
let (tx, mut rx) = mpsc::channel::<StreamPage>(1);
tx.send(StreamPage {
records: Vec::new(),
bookmark: None,
})
.await
.unwrap();
let cancel = CancellationToken::new();
let trip = cancel.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(20)).await;
trip.cancel();
});
let mut outs = vec![tx.clone(), tx];
let sent = tokio::time::timeout(
Duration::from_secs(5),
broadcast(
StreamPage {
records: recs(1),
bookmark: None,
},
&mut outs,
&cancel,
),
)
.await
.expect("a cancelled broadcast must not wait on a full channel");
assert!(!sent);
assert!(rx.recv().await.is_some());
}
#[tokio::test]
async fn broadcast_drops_closed_outputs() {
let (live_tx, mut live_rx) = mpsc::channel::<StreamPage>(4);
let (dead_tx, dead_rx) = mpsc::channel::<StreamPage>(4);
drop(dead_rx);
let mut outs = vec![dead_tx, live_tx];
let cancel = CancellationToken::new();
let page = StreamPage {
records: recs(2),
bookmark: None,
};
assert!(broadcast(page, &mut outs, &cancel).await);
assert_eq!(outs.len(), 1);
assert_eq!(live_rx.recv().await.unwrap().records.len(), 2);
let mut none: Vec<mpsc::Sender<StreamPage>> = Vec::new();
let page = StreamPage {
records: Vec::new(),
bookmark: None,
};
assert!(!broadcast(page, &mut none, &cancel).await);
}
#[tokio::test]
async fn a_cancelled_receive_returns_none() {
let (_tx, mut rx) = mpsc::channel::<StreamPage>(1);
let cancel = CancellationToken::new();
cancel.cancel();
assert!(recv_or_cancel(&mut rx, &cancel).await.is_none());
}
#[test]
fn merge_mode_follows_the_inputs_origins() {
let set = |ids: &[&str]| ids.iter().map(|s| s.to_string()).collect::<HashSet<_>>();
assert_eq!(
merge_mode(&[set(&["a"]), set(&["b"])]),
MergeMode::Passthrough
);
assert_eq!(merge_mode(&[set(&["a"]), set(&["a"])]), MergeMode::Gated);
assert_eq!(
merge_mode(&[set(&["a", "b"]), set(&["a"])]),
MergeMode::Strip
);
}
#[test]
fn bookmark_gate_releases_a_position_only_once_every_input_delivered_it() {
let mut gate = BookmarkGate::new(2);
assert_eq!(gate.arrive(0, json!(1)), None);
assert_eq!(gate.arrive(0, json!(2)), None);
assert_eq!(gate.arrive(1, json!(1)), Some(json!(1)));
assert_eq!(gate.arrive(1, json!(2)), Some(json!(2)));
assert_eq!(gate.arrive(1, json!(3)), None);
assert_eq!(gate.close(0), Some(json!(3)));
assert_eq!(gate.close(1), None);
}
#[test]
fn graph_facts_gate_a_merge_that_rejoins_one_source() {
let (k, _) = CollectSink::new();
let topo = Topology::builder()
.source("s", VecSource::boxed(recs(1)))
.tee("t", 4, Some(2))
.transform("x", Vec::new())
.transform("y", Vec::new())
.merge("m")
.sink("k", Box::new(k))
.edge("s", "t")
.edge("t", "x")
.edge("t", "y")
.edge("x", "m")
.edge("y", "m")
.edge("m", "k")
.build()
.unwrap();
let facts = GraphFacts::of(topo.nodes(), topo.edges());
assert_eq!(facts.merge_modes["m"], MergeMode::Gated);
assert!(facts.ancestors["k"].contains("s"));
assert!(facts.ancestors["k"].contains("x"));
}
struct LoggingStore {
inner: MemoryStateStore,
log: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
impl StateStore for LoggingStore {
async fn get(&self, key: &str) -> Result<Option<Value>, FaucetError> {
self.inner.get(key).await
}
async fn put(&self, key: &str, value: &Value) -> Result<(), FaucetError> {
let data = crate::state_version::peel_versioned(value);
self.log
.lock()
.unwrap()
.push(format!("put {}", data["pos"].as_u64().unwrap_or(0)));
self.inner.put(key, value).await
}
async fn delete(&self, key: &str) -> Result<(), FaucetError> {
self.inner.delete(key).await
}
}
struct LoggingSink {
log: Arc<Mutex<Vec<String>>>,
}
#[async_trait]
impl Sink for LoggingSink {
async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
let mut log = self.log.lock().unwrap();
for r in records {
log.push(format!("row {}", r["n"].as_u64().unwrap_or(0)));
}
Ok(records.len())
}
}
#[tokio::test]
async fn a_diamond_persists_a_position_only_after_both_copies_landed() {
let log = Arc::new(Mutex::new(Vec::new()));
let store = Arc::new(LoggingStore {
inner: MemoryStateStore::new(),
log: log.clone(),
});
let (source, _) = LogSource::new(6, 1, false);
let topo = Topology::builder()
.source("s", Box::new(source))
.tee("t", 1, Some(2))
.transform("x", Vec::new())
.transform("y", Vec::new())
.merge("m")
.sink("k", Box::new(LoggingSink { log: log.clone() }))
.edge("s", "t")
.edge("t", "x")
.edge("t", "y")
.edge("x", "m")
.edge("y", "m")
.edge("m", "k")
.build()
.unwrap();
topo.run(TopologyOptions::new("p").with_state_store(store))
.await
.unwrap();
let log = log.lock().unwrap().clone();
for (i, entry) in log.iter().enumerate() {
let Some(pos) = entry.strip_prefix("put ") else {
continue;
};
let copies = log[..i]
.iter()
.filter(|e| **e == format!("row {pos}"))
.count();
assert_eq!(
copies, 2,
"position {pos} persisted before both copies: {log:?}"
);
}
assert_eq!(log.iter().filter(|e| e.starts_with("row")).count(), 12);
assert!(log.iter().any(|e| e == "put 6"), "{log:?}");
}
struct OverwriteSink {
log: Arc<Mutex<Vec<String>>>,
fail_commit: bool,
}
#[async_trait]
impl Sink for OverwriteSink {
async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
self.log.lock().unwrap().push("write".into());
Ok(records.len())
}
fn is_overwrite(&self) -> bool {
true
}
async fn begin_overwrite(&self) -> Result<(), FaucetError> {
self.log.lock().unwrap().push("begin".into());
Ok(())
}
async fn commit_overwrite(&self) -> Result<(), FaucetError> {
if self.fail_commit {
return Err(FaucetError::Sink("swap failed".into()));
}
self.log.lock().unwrap().push("commit".into());
Ok(())
}
async fn abort_overwrite(&self) -> Result<(), FaucetError> {
self.log.lock().unwrap().push("abort".into());
Ok(())
}
async fn complete_run(&self) -> Result<(), FaucetError> {
self.log.lock().unwrap().push("complete".into());
Ok(())
}
}
#[tokio::test]
async fn an_overwrite_sink_node_commits_then_persists_its_position() {
let log = Arc::new(Mutex::new(Vec::new()));
let store = Arc::new(LoggingStore {
inner: MemoryStateStore::new(),
log: log.clone(),
});
let (source, _) = LogSource::new(3, 1, false);
let topo = Topology::builder()
.source("s", Box::new(source))
.sink(
"k",
Box::new(OverwriteSink {
log: log.clone(),
fail_commit: false,
}),
)
.edge("s", "k")
.build()
.unwrap();
topo.run(TopologyOptions::new("p").with_state_store(store))
.await
.unwrap();
let log = log.lock().unwrap().clone();
assert_eq!(
log,
vec![
"begin", "write", "write", "write", "commit", "put 3", "complete"
],
"staged, swapped, then the position — never before the swap"
);
}
#[tokio::test]
async fn an_overwrite_sink_node_discards_staging_when_another_node_fails() {
let log = Arc::new(Mutex::new(Vec::new()));
let topo = Topology::builder()
.source("s", VecSource::boxed(recs(3)))
.tee("t", 4, Some(2))
.sink(
"k",
Box::new(OverwriteSink {
log: log.clone(),
fail_commit: false,
}),
)
.sink("bad", Box::new(FailingSink))
.edge("s", "t")
.edge("t", "k")
.edge("t", "bad")
.build()
.unwrap();
let run = topo
.run_reported(
TopologyOptions::new("p").with_on_error(TopologyOnError::Continue),
TopologyGovernance::new(),
)
.await
.unwrap();
assert!(!run.result.errors.is_empty());
let log = log.lock().unwrap().clone();
assert!(log.contains(&"abort".to_string()), "{log:?}");
assert!(!log.contains(&"commit".to_string()), "{log:?}");
}
#[tokio::test]
async fn an_overwrite_sink_node_discards_staging_on_cancel() {
let log = Arc::new(Mutex::new(Vec::new()));
let cancel = CancellationToken::new();
cancel.cancel();
let topo = Topology::builder()
.source("s", VecSource::boxed(recs(3)))
.sink(
"k",
Box::new(OverwriteSink {
log: log.clone(),
fail_commit: false,
}),
)
.edge("s", "k")
.build()
.unwrap();
topo.run(TopologyOptions::new("p").with_cancel(cancel))
.await
.unwrap();
let log = log.lock().unwrap().clone();
assert_eq!(log.first().map(String::as_str), Some("begin"));
assert!(log.contains(&"abort".to_string()), "{log:?}");
assert!(!log.contains(&"commit".to_string()), "{log:?}");
}
#[tokio::test]
async fn a_failed_swap_fails_the_run_and_discards_the_other_staging() {
let first = Arc::new(Mutex::new(Vec::new()));
let second = Arc::new(Mutex::new(Vec::new()));
let topo = Topology::builder()
.source("s", VecSource::boxed(recs(2)))
.tee("t", 4, Some(2))
.sink(
"a",
Box::new(OverwriteSink {
log: first.clone(),
fail_commit: true,
}),
)
.sink(
"b",
Box::new(OverwriteSink {
log: second.clone(),
fail_commit: false,
}),
)
.edge("s", "t")
.edge("t", "a")
.edge("t", "b")
.build()
.unwrap();
let err = topo
.run(TopologyOptions::new("p"))
.await
.unwrap_err()
.to_string();
assert!(err.contains("swap failed"), "{err}");
let second = second.lock().unwrap().clone();
assert!(second.contains(&"abort".to_string()), "{second:?}");
assert!(!second.contains(&"commit".to_string()), "{second:?}");
}
struct BrokenSource;
#[async_trait]
impl Source for BrokenSource {
async fn fetch_with_context(
&self,
_c: &HashMap<String, Value>,
) -> Result<Vec<Value>, FaucetError> {
Err(FaucetError::Source("source down".into()))
}
}
#[tokio::test]
async fn a_sink_fed_by_a_failed_source_is_reported_failed() {
let (sink, _) = CollectSink::new();
let topo = Topology::builder()
.source("src", Box::new(BrokenSource))
.transform("x", Vec::new())
.sink("k", Box::new(sink))
.edge("src", "x")
.edge("x", "k")
.build()
.unwrap();
let run = topo
.run_reported(
TopologyOptions::new("p").with_on_error(TopologyOnError::Continue),
TopologyGovernance::new(),
)
.await
.unwrap();
let k = run.nodes.iter().find(|n| n.node_id == "k").unwrap();
let err = k.error.as_deref().unwrap_or_default();
assert!(err.contains("upstream node 'src' failed"), "{err}");
assert_eq!(k.error_kind, Some(NodeErrorKind::Other));
}
#[tokio::test]
async fn a_sink_fed_by_a_panicking_transform_is_reported_failed() {
let boom: crate::stage::PageFnBox = Arc::new(|_records: Vec<Value>| panic!("boom"));
let (source, _) = LogSource::new(1, 1, false);
let (sink, _) = CollectSink::new();
let topo = Topology::builder()
.source("s", Box::new(source))
.transform("x", vec![CompiledStage::PageFn(boom)])
.sink("k", Box::new(sink))
.edge("s", "x")
.edge("x", "k")
.build()
.unwrap();
let run = topo
.run_reported(
TopologyOptions::new("p").with_on_error(TopologyOnError::Continue),
TopologyGovernance::new(),
)
.await
.unwrap();
let k = run.nodes.iter().find(|n| n.node_id == "k").unwrap();
let err = k.error.as_deref().unwrap_or_default();
assert!(err.contains("upstream node 'x' failed"), "{err}");
assert!(
run.result
.errors
.iter()
.any(|e| e.contains("topology node 'x' panicked: boom")),
"{:?}",
run.result.errors
);
}
#[tokio::test]
async fn run_attributed_keeps_the_per_node_report_on_failure() {
let (sink, _) = CollectSink::new();
let topo = Topology::builder()
.source("src", Box::new(BrokenSource))
.sink("k", Box::new(sink))
.edge("src", "k")
.build()
.unwrap();
let (run, err) = topo
.run_attributed(TopologyOptions::new("p"), TopologyGovernance::new())
.await;
assert!(err.is_some(), "Propagate surfaces the failure");
let k = run.nodes.iter().find(|n| n.node_id == "k").unwrap();
assert!(k.error.is_some(), "the sink is reported, not dropped");
let src = run.nodes.iter().find(|n| n.node_id == "src").unwrap();
assert!(
src.error
.as_deref()
.unwrap_or_default()
.contains("source down")
);
}
#[tokio::test]
async fn run_attributed_reports_an_invalid_graph() {
let (run, err) = Topology {
nodes: Vec::new(),
edges: Vec::new(),
}
.run_attributed(TopologyOptions::new("p"), TopologyGovernance::new())
.await;
assert!(err.is_some());
assert!(run.nodes.is_empty());
}
}