# kptn

kptn is a meta orchestration framework for building R and Python data pipelines. It right-sizes tasks, tests changes, and streamlines development and operation.

Define your tasks and their dependencies in YAML and kptn will render a pipeline runnable locally or on AWS with one click.

## Features

  - **Testable tasks**: Built-in change detection
  - **Right-sizing**: CPU and memory requirements per task
  - **Branch-based development**: Clone pipeline state across branches
  - **Update in-place**: Tweak and run a subset of the pipeline
  - **Less boilerplate**: Launch R scripts without writing any wrapper code
  - **Less vendor lock-in**: Any orchestration framework could be rendered, not just Prefect

<p style="text-align: center">
  <img alt="kptn UI" src="https://github.com/rti-international/kptn/blob/main/ui/ui-nov24.png?raw=true">
</p>

## Basic Example

Here's a toy example of a pipeline called `example` with 3 tasks: `A`, `B`, `C`. We first define the graph of tasks, where `A` has no dependencies, `B` depends on `A`, and `C` depends on `A` and `B`.

```yaml
graphs:
  example:
    tasks:
      A:
      B: A
      C: [A, B]
```

In the same YAML file, we can define the tasks (2 R tasks, 1 Python task):

```yaml
tasks:
  A:
    file: "A/run.R"
    outputs: ["A.csv"]
  B:
    file: "B/run.R"
    outputs: ["B.csv"]
  C:
    file: C/run.py
    outputs: ["C.csv"]
```

The `file` fields define the location of the script to run. In the Python case, the file must contain a function with the same name as the task `C`. The `outputs` field are the files generated by the script.

## Configuration

There are two main configuration files: pyproject.toml and kptn.yaml

### pyproject.toml

Your project is expected to use a pyproject.toml and contain a `tool.kptn` section.

<table>
  <thead>
  <tr><td>Field</td><td>Description</td><td>Required</td></tr>
  </thead>
  <tbody>
  <tr><td>flows_dir</td><td>The output directory where kptn will render your flow files to</td><td>Yes</td></tr>
  <tr><td>py_tasks_dir</td><td>The directory (or list of directories) where Python task code exists</td><td>Yes</td></tr>
  <tr><td>tasks_conf_path</td><td>The filepath to the kptn.yaml file defining your pipeline</td><td>Yes</td></tr>
  <tr><td>docker_image</td><td>The name of the Docker image to build and push to Prefect</td><td>Yes</td></tr>
  <tr><td>cache_namespace</td><td>Optional cache namespace shared across graphs/pipelines; defaults to the graph name</td><td>No</td></tr>
  </tbody>
</table>

Example:

```toml
[tool.kptn]
flows_dir = "py_src/flows"
py_tasks_dir = "py_src/tasks"
tasks_conf_path = "py_src/kptn.yaml"
docker_image = "nibrs-estimation-pipeline:latest"
```

### kptn.yaml

Create a file, `kptn.yaml` that contains definitions of the graphs of tasks and the tasks themselves.

<table>
  <thead>
  <tr><td>Field</td><td>Value Description</td><td>Required</td></tr>
  </thead>
  <tbody>
  <tr><td>graphs</td><td>A dictionary of graph IDs and graph objects</td><td>Yes</td></tr>
  <tr><td>graphs.[id]</td><td>A dictionary representing a graph (nodes and edges)</td><td>Yes</td></tr>
  <tr><td>graphs.[id].extends</td><td>Optional string or list of graph IDs to inherit tasks from; the earliest occurrence of a task name wins (parents first, then child). If provided, `tasks` can be omitted to reuse a parent graph as-is.</td><td>No</td></tr>
  <tr><td>graphs.[id].tasks</td><td>An ordered dictionary of task IDs (nodes) and their dependencies (edges)</td><td>Yes</td></tr>
  <tr><td>graphs.[id].tasks.[task_id]</td><td>A list of dependency task IDs. If no dependencies, leave blank or use an empty list `[]`.</td><td>Yes</td></tr>
  <tr><td>tasks</td><td>A dictionary of task names and task objects</td><td>Yes</td></tr>
  <tr><td>tasks.[task_id]</td><td>A dictionary representing a task</td><td>Yes</td></tr>
  <tr><td>tasks.[task_id].file</td><td>A string with the filepath to the Python or R script</td><td>Yes</td></tr>
  <tr><td>tasks.[task_id].prefix_args</td><td>A string that will be inserted before the `Rscript` command-line call</td><td>No</td></tr>
  <tr><td>tasks.[task_id].cli_args</td><td>A string that will be inserted at the end of the `Rscript` command-line call</td><td>No</td></tr>
  <tr><td>tasks.[task_id].cache_result</td><td>A boolean, if `true`, the Python script return value will be saved in the cache database, DynamoDB. If this value is a large list (e.g. 50k items), it will be sharded across DynamoDB items for scalability.</td><td>No</td></tr>
  <tr><td>tasks.[task_id].iterable_item</td><td>If `cache_result` is true and the result is a list, `iterable_item` is a string naming each item. The iterable item can also be a combination of values delimited by commas, e.g. US_STATE,ZIP_CODE</td><td>No</td></tr>
  <tr><td>tasks.[task_id].map_over</td><td>A string that corresponds to an `iterable_item` in a dependency. Setting `map_over` will call this task for each item in the dependency result list. If this task is a Python task, the `iterable_item` will be passed as a function argument. If this task is an R task, the `iterable_item` will be passed as an environment variable to the R script. If the `iterable_item` is a comma-delimited combo, the values will be split and passed in separately.</td><td>No</td></tr>
  <tr><td>tasks.[task_id].bundle_size</td><td>If `map_over` is set, an integer defining the number of subtasks sent to the Dask worker at one time. Default is 1. For a large number of subtasks, increasing this number can speedup the run time.</td><td>No</td></tr>
  <tr><td>tasks.[task_id].group_size</td><td>If `map_over` is set, an integer defining the number of subtasks sent to the Dask scheduler at one time. Default is infinity. For a large number of subtasks, setting a max on this number can prevent the scheduler from getting overwhelmed.</td><td>No</td></tr>
  <tr><td>tasks.[task_id].outputs</td><td>A list of files that the script outputs</td><td>No</td></tr>
  <tr><td>tasks.[task_id].compute</td><td>A dictionary that can contain two fields, cpu and memory, corresponding to the <a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_definition_parameters.html#task_size">Fargate task definition values</a></td><td>No</td></tr>
  <tr><td>tasks.[task_id].tags</td><td>A list of strings, which will be used as the Prefect task's <a href="https://docs.prefect.io/v3/develop/write-tasks#tags">tags</a>; useful for <a href="https://docs.prefect.io/v3/develop/task-run-limits#limit-concurrent-task-runs-with-tags">concurrency limits</a></td><td>No</td></tr>
  </tbody>
</table>

Example:

```yaml
graphs:
  my_graph:
    tasks:
      A:
      B: [A]

tasks:
  A:
    file: true
    cache_result: true
    iterable_item: US_STATE
    compute:
      cpu: 256
      memory: 512
  B:
    file: "B/run.R"
    map_over: US_STATE
    outputs:
    - "${US_STATE}.csv"
```

In this example, `my_graph` is the name of a graph (also known as a pipeline or a DAG). The graph object includes the `tasks` field, which is a dictionary of task names and a list of their corresponding dependencies (other tasks).

Task `A` will call the `A()` function in `A.py` and store the result in the cache database.

Task `B` will map over the result list, setting `US_STATE=${US_STATE}` as an environment variable for every call of the R script `B/run.R`

For example, if `A` returned the list `['NC', 'SC']`, `B/run.R` would be called 2 times, once with `US_STATE=NC` and once with `US_STATE=SC`

## Local runners for Step Functions projects

Projects that render AWS Step Functions flows still need a way to run pipelines locally. The `codegen` command emits a vanilla Python runner next to the Step Functions JSON files whenever the project’s `flow_type` is `stepfunctions`.


## Frequently Asked Questions (FAQ)

> Why not just use Prefect?

tldr: kptn simplifies pipeline configuration and improves base implementation

As we tried to scale with Prefect and ran into bugs and wondered if Airflow or Dagster might work better, but we'd already written our pipeline in formulaic Prefect code of "call task A, call task B". It was simple to pull out the logic into a YAML representation of a graph. This is a portable definition, allowing us to render to any orchestrator's code.

A major goal was to test our pipeline. If we change the code for task `A`, we need to be able to run task `A` by itself and check if its outputs changed compared to its previous outputs. Prefect 2 didn't support running tasks by themselves, and its caching wasn't designed for this use case. Rendering Prefect code allows us to render runnable pieces of the pipeline and use a cache accessible for snapshot testing.

Another major goal is scaling to handle a large number of mapped tasks. Mapped tasks create a single subtask for each input. Prefect has two task runners that allow running subtasks in parallel: Dask and Ray. In our testing with Dask, we've found that we can speedup runtime by batching subtasks into bundles for Dask workers, and prevent the scheduler from crashing by batching subtasks into groups that are run sequentially. kptn offers both of these features via its API. Caching is particularly important for mapped tasks, because mapped tasks can be very expensive to run and more prone to crash. In the event some subtasks fail, re-running will only re-run failed tasks.

Overall, kptn makes maintenance easier. Developing and testing new features, copying production state to a local environment for debugging, and running subsets of subtasks are use cases its designed for.
