Build: Retries, Watermarks & Incremental Runs
Maps to: Task 4.2. Kind: Build — spec and test names only. No implementation.
Objective
Build the executor: the piece that owns the DAG, the pipelines, and the ledger, and runs everything in topological order. It adds the three behaviors that turn a bare topological sort into a real scheduler:
- Retries — each pipeline's extract goes through
with_retry, so a transient failure is retried per the policy before it counts as a failure. - Freshness-skip (incremental) — a source whose last successful run in the ledger is newer than its refresh window is skipped, not re-fetched.
- Failure-gating — a node whose prerequisite failed (or was itself blocked) does not run at all. The DAG edges gate execution, not merely order it.
This is where etl-orchestrate finally touches the etl-core traits — dyn Source, dyn Transform, dyn Sink, Ledger, with_retry — and still names no concrete source.
Scaffold
Create: crates/etl-orchestrate/src/executor.rs. Modify: src/lib.rs to pub mod executor; and re-export Executor, Pipeline, RunReport.
Dependencies: none new — etl-core and chrono were declared when you created the crate; async-trait, tokio, and pretty_assertions are already dev-dependencies for the stub sources the tests need.
Expected result: cargo test -p etl-orchestrate executor → the executor tests green (#[tokio::test], stub sources, no network).
The spec (givens)
Pipeline — one extract → transform → load chain, over trait objects:
- Fields:
source: Box<dyn Source>,transform: Box<dyn Transform>,sink: Box<dyn Sink>,policy: RetryPolicy,refresh_after: Option<Duration>. Pipeline::new(source, transform, sink)— defaultspolicytoRetryPolicy::default()andrefresh_aftertoNone. Callers setrefresh_afterafterward when they want incremental behavior.run_once(&self) -> Result<usize, EtlError>— extract throughwith_retry(self.policy, || self.source.extract()), transform every raw record (flat-mapping each into rows), thensink.load(&rows).awaitonce. Returns the row count the sink reports.- A private
is_fresh(&self, ledger, id) -> Result<bool, EtlError>—falseifrefresh_afterisNoneor the ledger has no prior success for this id; otherwiseUtc::now() - last.finished_at < refresh.
RunReport — the outcome of a whole DAG run. Derive Debug, Default. Three Vecs:
records: Vec<RunRecord>— one per node that actually ran (success or failure).skipped: Vec<TaskId>— nodes skipped because they were still fresh.blocked: Vec<TaskId>— nodes not run because a prerequisite failed or was itself blocked.
Executor — owns dag: Dag, pipelines: HashMap<TaskId, Pipeline>, ledger: Ledger.
new(ledger);add_pipeline(&mut self, id: &str, pipeline) -> TaskId(registers the task in the DAG and stores the pipeline);add_dep(&mut self, task, depends_on)(delegates to the DAG).run(&self) -> Result<RunReport, EtlError>— the heart of the arc:- Compute
dag.topological_order()?(a cycle short-circuits the whole run withEtlError::Parse). - Walk the order, carrying a
bad: HashSet<TaskId>of failed-or-blocked ids. - For each node: if any prerequisite is in
bad, add this node tobadand toreport.blocked, andcontinue— so the block propagates transitively down the graph. - Else if
is_fresh, push toreport.skippedandcontinue. - Else run
run_once. OnOk, recordOutcome::Successwith awatermark: Some(finished_at)and the row count. OnErr, add the id tobadand recordOutcome::Failed { reason }. - Append the
RunRecordto the ledger and toreport.records.
- Compute
Concepts exercised
- Scheduling over trait objects (
Box<dyn Source/Transform/Sink>) — the dependency-inversion payoff in code. - The retry middleware (
with_retry+RetryPolicy) wired into the extract step. - Watermark-driven incremental runs: reading
last_success().finished_atfrom the ledger. - Transitive failure propagation via a
badset threaded through the topological walk.
The build loop (you drive)
runs_pipelines_in_dependency_order. Two stub sourcesaandb,add_dep(b, a); each stub pushes its name to a shared log on extract. Assert the log is["a", "b"]andreport.records.len() == 2.records_a_run_per_node. Two independent stubs; assert oneRunRecordper node and every outcomeSuccess.failed_upstream_blocks_dependent. AFailingSourceata, a stub atb,add_dep(b, a). Predict first: how many records, how many blocked, and how many times isb's source extracted? Assertrecords.len() == 1,report.blocked == [b], andb's extract call-count is0.incremental_skips_up_to_date_source. Seed the ledger with a very recent success for the source; setrefresh_after = Some(Duration::hours(6)). Assertreport.skipped.len() == 1,report.recordsempty, and the source's extract call-count is0.- Run green, commit.
failed_upstream_blocks_dependent, a fails (it ran and errored — it gets a RunRecord with Outcome::Failed). b is blocked (it never ran — no record, an entry in blocked). Before running, be able to say which list each id lands in and why b's extract is never called. The counter proving zero extracts is the assertion that the gate actually stopped the work, not just relabeled it.
bad, not only to report.blocked. That is deliberate: in a diamond, if a fails, both b and c block — and d, which depends on b and c, must block too even though a is not its direct prerequisite. Because b and c were added to bad when they blocked, d sees a bad prerequisite and blocks in turn. Forget to re-add blocked nodes to bad and d would run against missing upstream data.
Done when
cargo test -p etl-orchestrate is green across graph and executor. You now have a scheduler that retries transient failures, skips fresh sources, and refuses to run a node whose upstream is broken — all over trait objects, with no concrete source in sight. The only thing missing is a front door: the CLI that names the real sources and calls run().