#![allow(dead_code)]
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FanOutTask {
pub id: String,
pub parent_id: String,
pub payload: String,
}
impl FanOutTask {
#[must_use]
pub fn new(
id: impl Into<String>,
parent_id: impl Into<String>,
payload: impl Into<String>,
) -> Self {
Self {
id: id.into(),
parent_id: parent_id.into(),
payload: payload.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MergeStrategy {
WaitAll,
WaitAny,
WaitN(usize),
}
#[derive(Debug, Clone)]
pub struct FanOutGroup {
pub parent_step: String,
pub children: Vec<FanOutTask>,
pub merge_strategy: MergeStrategy,
}
#[derive(Debug)]
struct FanOutGroupState {
group: FanOutGroup,
completed: HashSet<String>,
}
impl FanOutGroupState {
fn new(group: FanOutGroup) -> Self {
Self {
group,
completed: HashSet::new(),
}
}
fn mark_complete(&mut self, task_id: &str) {
if self.group.children.iter().any(|t| t.id == task_id) {
self.completed.insert(task_id.to_string());
}
}
fn is_done(&self) -> bool {
let n_complete = self.completed.len();
let n_total = self.group.children.len();
match &self.group.merge_strategy {
MergeStrategy::WaitAll => n_complete >= n_total,
MergeStrategy::WaitAny => n_complete >= 1,
MergeStrategy::WaitN(n) => n_complete >= *n,
}
}
fn completed_count(&self) -> usize {
self.completed.len()
}
fn total_count(&self) -> usize {
self.group.children.len()
}
}
#[derive(Debug, Default)]
pub struct FanOutExecutor {
groups: HashMap<String, FanOutGroupState>,
task_to_group: HashMap<String, String>,
}
impl FanOutExecutor {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn submit_group(&mut self, group: FanOutGroup) {
if let Some(old_state) = self.groups.get(&group.parent_step) {
for task in &old_state.group.children {
self.task_to_group.remove(&task.id);
}
}
for task in &group.children {
self.task_to_group
.insert(task.id.clone(), group.parent_step.clone());
}
self.groups
.insert(group.parent_step.clone(), FanOutGroupState::new(group));
}
pub fn mark_complete(&mut self, task_id: &str) {
if let Some(group_id) = self.task_to_group.get(task_id).cloned() {
if let Some(state) = self.groups.get_mut(&group_id) {
state.mark_complete(task_id);
}
}
}
#[must_use]
pub fn is_merge_condition_met(&self, group_id: &str) -> bool {
self.groups
.get(group_id)
.map(|s| s.is_done())
.unwrap_or(false)
}
#[must_use]
pub fn completed_count(&self, group_id: &str) -> usize {
self.groups
.get(group_id)
.map(|s| s.completed_count())
.unwrap_or(0)
}
#[must_use]
pub fn total_count(&self, group_id: &str) -> usize {
self.groups
.get(group_id)
.map(|s| s.total_count())
.unwrap_or(0)
}
#[must_use]
pub fn group_count(&self) -> usize {
self.groups.len()
}
pub fn remove_group(&mut self, group_id: &str) -> Option<FanOutGroup> {
if let Some(state) = self.groups.remove(group_id) {
for task in &state.group.children {
self.task_to_group.remove(&task.id);
}
Some(state.group)
} else {
None
}
}
}
#[derive(Debug, Clone)]
pub struct FanoutStep {
pub branches: Vec<String>,
}
impl FanoutStep {
#[must_use]
pub fn new(branches: Vec<String>) -> Self {
Self { branches }
}
#[must_use]
pub fn execute(&self, input: &str) -> Vec<String> {
self.branches
.iter()
.map(|branch| format!("{branch}:{input}"))
.collect()
}
#[must_use]
pub fn branch_count(&self) -> usize {
self.branches.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn three_task_group(strategy: MergeStrategy) -> FanOutGroup {
FanOutGroup {
parent_step: "encode-variants".to_string(),
children: vec![
FanOutTask::new("t-1080p", "encode-variants", r#"{"profile":"1080p"}"#),
FanOutTask::new("t-720p", "encode-variants", r#"{"profile":"720p"}"#),
FanOutTask::new("t-480p", "encode-variants", r#"{"profile":"480p"}"#),
],
merge_strategy: strategy,
}
}
#[test]
fn wait_all_not_done_until_all_complete() {
let mut exec = FanOutExecutor::new();
exec.submit_group(three_task_group(MergeStrategy::WaitAll));
assert!(!exec.is_merge_condition_met("encode-variants"));
exec.mark_complete("t-1080p");
assert!(!exec.is_merge_condition_met("encode-variants"));
exec.mark_complete("t-720p");
assert!(!exec.is_merge_condition_met("encode-variants"));
exec.mark_complete("t-480p");
assert!(exec.is_merge_condition_met("encode-variants"));
}
#[test]
fn wait_all_idempotent_double_complete() {
let mut exec = FanOutExecutor::new();
exec.submit_group(three_task_group(MergeStrategy::WaitAll));
exec.mark_complete("t-1080p");
exec.mark_complete("t-1080p"); exec.mark_complete("t-720p");
exec.mark_complete("t-480p");
assert!(exec.is_merge_condition_met("encode-variants"));
assert_eq!(exec.completed_count("encode-variants"), 3);
}
#[test]
fn wait_any_done_after_first_complete() {
let mut exec = FanOutExecutor::new();
exec.submit_group(three_task_group(MergeStrategy::WaitAny));
assert!(!exec.is_merge_condition_met("encode-variants"));
exec.mark_complete("t-720p");
assert!(exec.is_merge_condition_met("encode-variants"));
}
#[test]
fn wait_n_done_after_n_completions() {
let mut exec = FanOutExecutor::new();
exec.submit_group(three_task_group(MergeStrategy::WaitN(2)));
exec.mark_complete("t-1080p");
assert!(!exec.is_merge_condition_met("encode-variants"));
exec.mark_complete("t-480p");
assert!(exec.is_merge_condition_met("encode-variants"));
}
#[test]
fn wait_n_1_same_as_wait_any() {
let mut exec = FanOutExecutor::new();
exec.submit_group(three_task_group(MergeStrategy::WaitN(1)));
assert!(!exec.is_merge_condition_met("encode-variants"));
exec.mark_complete("t-1080p");
assert!(exec.is_merge_condition_met("encode-variants"));
}
#[test]
fn unknown_group_returns_false() {
let exec = FanOutExecutor::new();
assert!(!exec.is_merge_condition_met("non-existent-group"));
}
#[test]
fn unknown_task_id_is_silently_ignored() {
let mut exec = FanOutExecutor::new();
exec.submit_group(three_task_group(MergeStrategy::WaitAll));
exec.mark_complete("ghost-task-xyz"); assert!(!exec.is_merge_condition_met("encode-variants"));
assert_eq!(exec.completed_count("encode-variants"), 0);
}
#[test]
fn multiple_groups_independent() {
let mut exec = FanOutExecutor::new();
exec.submit_group(FanOutGroup {
parent_step: "group-a".to_string(),
children: vec![FanOutTask::new("a1", "group-a", "")],
merge_strategy: MergeStrategy::WaitAll,
});
exec.submit_group(FanOutGroup {
parent_step: "group-b".to_string(),
children: vec![
FanOutTask::new("b1", "group-b", ""),
FanOutTask::new("b2", "group-b", ""),
],
merge_strategy: MergeStrategy::WaitAll,
});
exec.mark_complete("a1");
assert!(exec.is_merge_condition_met("group-a"));
assert!(!exec.is_merge_condition_met("group-b"));
exec.mark_complete("b1");
exec.mark_complete("b2");
assert!(exec.is_merge_condition_met("group-b"));
}
#[test]
fn submit_group_replaces_existing() {
let mut exec = FanOutExecutor::new();
exec.submit_group(three_task_group(MergeStrategy::WaitAll));
exec.mark_complete("t-1080p");
assert_eq!(exec.completed_count("encode-variants"), 1);
exec.submit_group(three_task_group(MergeStrategy::WaitAll));
assert_eq!(exec.completed_count("encode-variants"), 0);
assert!(!exec.is_merge_condition_met("encode-variants"));
}
#[test]
fn remove_group_clears_task_mappings() {
let mut exec = FanOutExecutor::new();
exec.submit_group(three_task_group(MergeStrategy::WaitAll));
let removed = exec.remove_group("encode-variants");
assert!(removed.is_some());
assert_eq!(exec.group_count(), 0);
exec.mark_complete("t-1080p");
assert!(!exec.is_merge_condition_met("encode-variants"));
}
#[test]
fn wait_all_on_empty_group_is_immediately_done() {
let mut exec = FanOutExecutor::new();
exec.submit_group(FanOutGroup {
parent_step: "empty-group".to_string(),
children: vec![],
merge_strategy: MergeStrategy::WaitAll,
});
assert!(exec.is_merge_condition_met("empty-group"));
}
#[test]
fn wait_any_on_empty_group_is_not_done() {
let mut exec = FanOutExecutor::new();
exec.submit_group(FanOutGroup {
parent_step: "empty-any".to_string(),
children: vec![],
merge_strategy: MergeStrategy::WaitAny,
});
assert!(!exec.is_merge_condition_met("empty-any"));
}
#[test]
fn total_count_and_completed_count() {
let mut exec = FanOutExecutor::new();
exec.submit_group(three_task_group(MergeStrategy::WaitAll));
assert_eq!(exec.total_count("encode-variants"), 3);
assert_eq!(exec.completed_count("encode-variants"), 0);
exec.mark_complete("t-480p");
assert_eq!(exec.completed_count("encode-variants"), 1);
}
#[test]
fn counts_for_unknown_group_are_zero() {
let exec = FanOutExecutor::new();
assert_eq!(exec.total_count("ghost"), 0);
assert_eq!(exec.completed_count("ghost"), 0);
}
}