Build: The Task Graph + Topological Execution
Maps to: Task 4.1. Kind: Build — spec and test names only. No implementation.
Objective
Create the etl-orchestrate crate and its first module: a typed task graph, Dag, with a deterministic topological sort. This is the pure-data foundation the executor stands on — no async, no I/O, no traits from etl-core beyond the error type. By the end, topological_order() turns a set of tasks and dependencies into a stable run order, and rejects any graph that contains a cycle.
Scaffold
Create the crate: crates/etl-orchestrate/Cargo.toml, src/lib.rs, src/graph.rs. Add "crates/etl-orchestrate" to the workspace members.
crates/etl-orchestrate/Cargo.toml — this is the crate where the dependency-inversion lesson is enforced by the manifest, so the dependency list is deliberately short:
[dependencies]
etl-core = { path = "../etl-core" } # for EtlError only, in this module
chrono = { workspace = true } # the executor module needs it next
[dev-dependencies]
pretty_assertions = { workspace = true }
tokio = { workspace = true }
async-trait = { workspace = true }
There is no
etl-sourcesdependency, and there never will be. That absence is the point of the whole arc — see Concept: Dependency Inversion. Prove it after this build withcargo tree -p etl-orchestrate.
Expected result: cargo test -p etl-orchestrate graph → the three graph tests green.
The spec (givens)
TaskId(pub String)— a newtype over the task's name. DeriveClone, PartialEq, Eq, Hash, PartialOrd, Ord— theOrdis what lets the ready-set sort. ATaskId::new(impl Into<String>)constructor.Dag— holdsnodes: HashSet<TaskId>andprereqs: HashMap<TaskId, Vec<TaskId>>(task → its prerequisites). DeriveDefault.add_task(&mut self, id: impl Into<String>) -> TaskId— register a node with an empty prereq list, return its id.add_dep(&mut self, task: &TaskId, depends_on: &TaskId)— record thattaskdepends ondepends_on. Must insert both ids as nodes (so an edge can introduce a node that was neveradd_task-ed) and pushdepends_onontotask's prereq list.prerequisites(&self, task: &TaskId) -> &[TaskId]— the direct prereqs, or an empty slice for an unknown task.topological_order(&self) -> Result<Vec<TaskId>, EtlError>— Kahn's algorithm with aBTreeSetready-set so ties break deterministically (alphabetically). A cycle (ordered count ≠ node count) returnsEtlError::Parse("cycle detected: …").
The algorithm is spelled out step by step, with a runnable std-only mirror, on Concept: Modeling a Pipeline as a Typed DAG. This build is that concept made real over TaskId and EtlError.
Concepts exercised
- Kahn's topological sort; indegree bookkeeping and the reverse
dependentsindex. - Deterministic iteration with
BTreeSet(vs the run-to-run wobble ofHashSet). - Cycle detection as a length-mismatch invariant, not a separate search.
- A newtype (
TaskId) whose derivedOrdis what makes the sort stable.
The build loop (you drive)
TaskId+Dagskeleton. Define the newtype with its derives and the two fields; writeadd_task/add_dep/prerequisites. Nothing to run yet.linear_chain_orders_correctly. Three tasksa → b → c; assert the order is exactly["a", "b", "c"]. Implement enough oftopological_orderto pass it.diamond_orders_deps_before_dependents.afeedsbandc;dneeds both. Predict first: with a sorted ready-set, do you get["a", "b", "c", "d"]or["a", "c", "b", "d"]? Decide from the tie-break rule before running.cycle_is_rejected.adepends onbandbdepends ona; asserttopological_order()isErr(EtlError::Parse(_)). Confirm the length check is what fires.- Run green, prove the boundary:
cargo tree -p etl-orchestrate | grep etl-sourcesreturns nothing. Commit.
BTreeSet<TaskId>, and TaskId derives Ord from its inner String. When b and c are both ready, which comes out of ready.iter().next() first? Write the expected Vec down, then let the test confirm it. If you had reached for a Vec or HashSet instead, what could go wrong across repeated runs?
Done when
cargo test -p etl-orchestrate graph is green and cargo tree -p etl-orchestrate shows only etl-core and chrono — no concrete-source crate anywhere in the closure. You now have a deterministic, cycle-safe run order. The executor is next: it walks this order and adds the two behaviors a bare sort lacks — freshness-skip and failure-gating.