--- title: "Handling failures" output: rmarkdown::html_vignette: toc: true toc_depth: 4 description: > Shows what happens when a step fails, how to keep a pipeline running with on_error = "continue", how to react to failed inputs with ~try() references, and how to recover once the cause is fixed. vignette: > %\VignetteIndexEntry{Handling failures} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r knitr-setup, include = FALSE} knitr::opts_chunk$set( comment = "#", prompt = FALSE, tidy = FALSE, cache = FALSE, collapse = TRUE ) old <- options(width = 100L) ``` In practice, steps fail: data is missing, a model does not converge, or an input violates an assumption. This vignette shows how {pipeflow} reports such failures, how a run can be continued for unaffected steps, and how to pick up the pieces once the cause is fixed. ### A pipeline that fails We use a small pipeline whose `check` step fails if the data exceeds a limit. ```{r define-pipeline} library(pipeflow) pip <- pip_new("analysis") |> pip_add("data", \(n = 5) seq_len(n)) |> pip_add("check", \(x = ~data, limit = 3) { if (max(x) > limit) stop("data exceeds limit") x }) |> pip_add("model", \(x = ~check) mean(x)) |> pip_add("total", \(x = ~data) sum(x)) ``` With `n = 5` and `limit = 3`, the `check` step will fail, and the `model` step downstream of it cannot run. The `total` step, however, only needs the `data` step. ### Stop at the first error By default, `pip_run()` stops at the first step that fails. ```{r run-and-stop, error = TRUE} pip_run(pip) pip pip_run_state(pip) ``` The pipeline stays in a defined state. The failed step is marked as `failed`, the steps that were not reached are still `new`, and the run state indicates that the last run ended with an error. Let's inspect the error object returned by `tryCatch()`. ```{r step-error-fields} err <- tryCatch(pip_run(pip, lgr = NULL), error = \(e) e) class(err) err$step class(err$parent) err$parent$message ``` The error is of class `pipeflow_step_error`. Its field `step` names the failed step and the field `parent` holds the original condition, which keeps its own class, so that errors can be handled by class: ### Continue after errors In the previous run, the `total` step was not run, although it does not depend on the failed step, because the run stopped early. With `on_error = "continue"` in `pip_run()`, a failing step is still marked as failed but the run can continue for all other steps that do not depend on it: ```{r run-and-continue, warning = TRUE} pip$reset() suppressWarnings(pip_run(pip, on_error = "continue")) pip pip_run_state(pip) ``` Now `total` was calculated, while `model` was skipped, because its input depends on the failed step. The `out` column of a failed step holds the condition, which is printed in short form as ``. To work with it programmatically, we can extract it as usual: ```{r failed-output} out <- pip[["check", "out"]] class(out) out$message ``` Let's inspect the warning object returned by `tryCatch()`. ```{r run-failed-warning} w <- tryCatch( pip_run(pip, lgr = NULL, on_error = "continue"), warning = \(w) w ) names(w$failed) w$skipped ``` The warning is signalled after all states and outputs are stored, so catching it with `tryCatch()` does not lose any results. ### Mitigate failed inputs with `try()` Sometimes a step should run even if one of its inputs failed, for example a report that mentions what is missing or if a fallback is available. Such an argument is marked by wrapping the step reference in `try()`: ```{r add-report-step} pip_add( pip, "report", \(total = ~total, m = ~try(model)) { if (inherits(m, "pipeflow_failure")) { paste("total:", total, "- no model:", m$message) } else { paste("total:", total, "- mean:", m) } } ) suppressWarnings(pip_run(pip, on_error = "continue")) pip[["report", "out"]] ``` Instead of the output of `model`, the `m` argument received a *failure object*, a condition of class `pipeflow_failure`, which stands in for the missing output. The message tells why the input is not available. It has the fields * `step`: the step that provided the input (`model`), * `failed_step`: the step that actually failed (`check`), and * `parent`: the original error condition. A step is only run despite a failed input if *all* its failed inputs are marked with `try()`. Everything else is skipped, as `model` above. Note that `try()` has an effect only in a continuing run. With `on_error = "stop"`, the run ends at the failed step anyway. ### Recover from failures Both skipped and failed steps are retried in the next run, and so are steps that got a failure object. Steps that were completed are not run again. Let's fix the cause by raising the limit and re-run: ```{r recover} pip_set_params(pip, list(limit = 10)) pip_run(pip) pip ``` The run log shows that `data` and `total` were skipped, since they were completed already. The failed step `check` and the skipped step `model` were executed now, and so was `report`, because it had received a failure object instead of the real output. The report now contains the model result and the run state is `ready` again. ```{r recovered-report} pip[["report", "out"]] pip_run_state(pip) ``` ### Failures in split pipelines Failures also work with the execution modes `split` and `reduce`, which are described in the vignette on [split, map, and reduce](v05a-split-map-reduce.html). Let's split a small data set by group and let the `spread` step fail for groups with a single row. ```{r define-split-pipeline} d <- data.frame(g = c("a", "a", "b", "b", "c"), y = c(1, 2, 3, 5, 8)) pip <- pip_new("groups") |> pip_add("data", \(data = NULL) data) |> pip_add("by_group", \(data = ~data) split(data, data$g), exec = "split") |> pip_add("spread", \(d = ~by_group) { if (nrow(d) < 2) stop("need at least 2 rows") diff(range(d$y)) }) |> pip_add( "combine", \(s = ~try(spread)) { if (inherits(s, "pipeflow_failure")) { return(s$message) } unlist(s) }, exec = "reduce" ) pip_set_params(pip, list(data = d)) ``` If one partition (group `c`) fails, the whole `spread` step fails. The condition tells the failed key and keeps the original one as `parent`: ```{r run-split-pipeline} suppressWarnings(pip_run(pip, on_error = "continue")) pip err <- pip[["spread", "out"]] class(err) err$key err$parent$message ``` The `combine` step reduces the outputs of all groups. Since the input `spread` failed and is marked with `try()`, `combine` ran once and received the failure object, with the key in the message: ```{r reduce-with-failure} pip[["combine", "out"]] ``` Likewise, if the pipeline is run with `on_error = "stop"`, the error is a `pipeflow_step_error` as before, with the `pipeflow_key_error` as its `parent`. Without `try()` in the `combine` step, it would just be skipped. Failures are currently tracked per step, not per key, so the groups `a` and `b` that were fine are not available as results of a run that failed in group `c`. Once the cause is fixed, here by removing the group, the pipeline completes: ```{r recover-split-pipeline} pip_set_params(pip, list(data = d[d$g != "c", ])) pip_run(pip) pip[["combine", "out"]] ``` ### Summary * By default, a failing step stops the run with a `pipeflow_step_error` that names the step and holds the original condition in `parent`. * `pip_run(on_error = "continue")` marks the step as `failed`, stores its condition as output, skips the steps that depend on it, and runs everything else. The run state becomes `continued`, and a `pipeflow_run_failed` warning lists both failed and skipped steps. * A reference wrapped in `try()`, e.g. `x = ~try(step)`, makes a step run despite a failed input. The argument then receives a `pipeflow_failure` condition. * The next run retries any failed and skipped steps (and those that received failure objects). * For steps that map over partitions, the condition of the failed partition is a `pipeflow_key_error` with the field `key`. A `reduce` step with a `try()` reference receives the failure. ```{r, include = FALSE} options(old) ```