Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Task Lifecycle

This is the detailed, step-by-step version of what ratect-compat <task> actually does, covering dependency (sidecar) container resolution and cleanup in depth. For the broader architecture (config loading, CLI parsing, logging), see how it works; this page is the equivalent of Batect’s own task lifecycle page, describing Ratect’s own (deliberately simplified) version of the same idea.

Task ordering

Ratect only ever runs one task’s containers at a time. A task’s prerequisites just order sequential task executions — each prerequisite task runs to completion (including its own cleanup, described below) before the next one starts, and before the originally-requested task itself runs.

tasks:
  compile:
    run:
      container: build-env
      command: ./build.sh
  test:
    prerequisites:
      - compile
    run:
      container: build-env
      command: ./test.sh

Running ratect-compat test here runs compile to completion first, fully cleaning up after it, then runs test.

A task doesn’t strictly need a run of its own — a task with only prerequisites is valid (see config reference), and exists purely to chain other tasks together:

tasks:
  ci:
    prerequisites:
      - compile
      - test

Running ratect-compat ci here runs compile then test to completion, same as above, then stops — there’s no container of ci’s own left to run.

Per-task steps

Every task execution gets its own Docker network, whether or not its container declares dependencies — so a task’s container is never left running on Docker’s shared default bridge network, reachable by or able to reach anything else on the host. If the container does declare dependencies, those are started on that network before the task’s own container, so the task’s container can reach them by name — and so is anything named in the task’s own dependencies (sidecars scoped to this task specifically, distinct from the container-level field — see config reference), unioned in alongside the container-level ones. All of this — network, dependencies, and the task’s own container — is scoped to this one task execution and torn down before moving on, regardless of whether the task succeeded — unless --no-cleanup/--no-cleanup-after-failure/ --no-cleanup-after-success says otherwise, in which case everything below is left genuinely running instead, for investigation (see CLI reference):

sequenceDiagram
    participant Engine as TaskEngine
    participant Docker
    participant Dep as Dependency container(s)
    participant Main as Task's own container

    Engine->>Docker: create_network()

    par independent branches of the dependency graph
        Engine->>Docker: pull_image()/build_image() (per image_pull_policy, unless already decided this run)
        Engine->>Docker: start_background_container(alias, network)
        Docker-->>Dep: created, started, joined to network
        Engine->>Docker: wait_for_container_healthy()
        Docker-->>Engine: healthy (immediate if no health check)
        loop for each setup command, in declared order
            Engine->>Docker: exec_in_container(command)
            Dep-->>Engine: exit code 0 (non-zero fails the task)
        end
    end

    Note over Engine: a container with dependencies of its own doesn't start<br/>its own branch above until all of them are ready

    Engine->>Docker: pull_image() (task's own image, per image_pull_policy, unless already decided)
    Engine->>Docker: run_container(name, network)
    Docker-->>Main: created, started, joined to network
    Main-->>Engine: runs to completion, logs streamed live to stdout

    Note over Engine: cleanup — runs even if the task's container failed,<br/>unless --no-cleanup* says otherwise
    Engine->>Docker: stop_and_remove_container() for the task's own container
    Engine->>Docker: stop_and_remove_container() for each dependency
    Engine->>Docker: remove_network()

If the container has no dependencies, the dependency steps (the loop above) are skipped — but the network is still created and the task’s own container still joins it, isolating it just the same as a task with dependencies.

--no-cleanup-after-failure skips the cleanup step above for a genuine infrastructure failure (a build/pull/health-check/setup-command failure, or anything else before the task’s own container gets to run); --no-cleanup-after-success skips it when the task’s own container ran to completion instead, regardless of its exit code (a non-zero exit is still “success” for this purpose — it’s the task’s own container actually running that matters, not what it returned); --no-cleanup is both at once. Either way, everything above is left genuinely running, not just present-but-stopped — see CLI reference.

pull_image() in the diagram above is conditional on image_pull_policy (see config reference): IfNotPresent, the default, checks whether the image already exists locally first and skips the pull entirely if so; Always skips that check and pulls unconditionally. Either way, the decision (pull or don’t) is made once per image name per ratect invocation, same as before this field existed — a dependency and the task’s own container sharing an image name don’t re-decide for each other.

Passing --use-network <name> skips network creation and teardown entirely for every task in this invocation: the named network is checked to exist up front (a clear error if it doesn’t), and reused instead — dependencies and the task’s own container all join it exactly as they would a freshly-created one, but it’s never removed at cleanup, since Ratect didn’t create it. See CLI reference.

Dependency resolution

Dependencies are resolved concurrently, gated by readiness: a container with dependencies of its own never starts before every one of them is ready (see below), but two containers with no dependency relationship to each other start at the same time rather than one after the other. For example:

containers:
  app:
    image: my-app
    dependencies:
      - database
  database:
    image: postgres:16
    dependencies:
      - cache
  cache:
    image: redis:7-alpine
graph TD
    app["app (task's container)"] --> database
    database --> cache

Running a task against app starts cache first (nothing else is holding it back), then database once cache is ready, then app once database is ready — a straight chain, so each one is genuinely waiting on the last. All three share one network and are reachable by their container-config name (e.g. app’s command can reach database:5432 and cache:6379).

Add a second container that also depends on cache — say queue, also one of app’s dependencies, but with no relationship to database — and cache is now a shared dependency of two others, forming a diamond rather than a straight chain:

graph TD
    app["app (task's container)"] --> database
    app --> queue
    database --> cache
    queue --> cache
sequenceDiagram
    participant Engine as TaskEngine
    participant Cache as cache
    participant Database as database
    participant Queue as queue
    participant App as app (task's container)

    Note over Engine: cache has no dependencies of its own — starts immediately
    Engine->>Cache: start, wait for healthy, run setup commands
    Note over Cache: ready

    par database and queue both depend only on cache — start together,<br/>the moment it's ready, not one after the other
        Engine->>Database: start, wait for healthy, run setup commands
        Note over Database: ready
    and
        Engine->>Queue: start, wait for healthy, run setup commands
        Note over Queue: ready
    end

    Note over Engine: app depends on both database and queue —<br/>waits for whichever is slower before starting
    Engine->>App: start (runs to completion)

cache is only ever started once, even though both database and queue depend on it: whichever of the two reaches it first triggers the actual start, and the other waits on that same in-flight readiness rather than starting a second instance or pulling its image twice (see below — this holds generally, not just for a leaf like cache). database and queue then genuinely overlap in time — both start the moment cache’s readiness gate has actually passed, not just once its container exists, and neither waits on the other since they share no relationship. app is gated on whichever of the two takes longer, not just the first one to finish.

This concurrency is unbounded by default — every independent branch’s pull/build, create+start, and setup commands can all be in flight at once, across the whole invocation, not just within one task. --max-parallelism <N> caps it: at most N of those specific operations run at a time, invocation-wide. The health-check wait itself is deliberately not capped (it’s a polling wait, not real work), so two dependencies can still become healthy at the same time even under a low cap — only the pull/build/ start/setup-command steps queue up behind it. See CLI reference and differences from Batect for exactly what’s covered.

A task’s own dependencies (sidecars scoped to that task specifically) join this same resolution at the root, alongside app’s own — each still resolves its own container-level dependencies transitively from there, same as any other dependency, and is just as eligible to start concurrently with an unrelated branch. And a task’s customise map, if it has one, is checked against whichever dependency is starting: a match overrides that container’s environment/ports/ working_directory for this task’s run of it specifically (merged the same way a task’s own run overrides its main container — see config reference), before it starts, regardless of how deep in this graph it sits.

Started isn’t ready, though: each dependency must become ready before whatever depends on it starts — it must report healthy (immediately so for a container with no Docker health check at all, from neither its image nor the health_check field), and then every one of its setup_commands must succeed, in declared order. In the example above, database’s migrations (a setup command) provably finish before app’s command gets to run. A dependency that’s reported unhealthy — or that exits before a verdict, or whose setup command exits non-zero — fails the task; already-started containers are still cleaned up as usual.

Health is a one-time gate in this sequence, not ongoing monitoring: Ratect waits for Docker’s first health verdict and never re-checks — matching Batect, a dependency that turns unhealthy after its dependents have started doesn’t affect the rest of the task, even though Docker itself keeps running the check for the container’s whole lifetime. How long the wait for that first verdict can take (and why an unhealthy verdict can’t arrive quickly) is Docker’s own verdict lifecycle — see How Docker reaches its verdict in the config reference.

Not re-checking health doesn’t mean staying silent, though: a dependency that has already become ready and then exits on its own — while the task’s own command, or a later dependency’s own health/setup wait, is still going — prints a warning naming the container and its exit code, in every output mode. Without it, that container’s own death would otherwise surface later as a confusing symptom in whatever depended on it (a connection refused, a timeout) rather than the real cause. This is a notification only — the run isn’t failed or stopped because of it — and it’s never printed for a container cleanup itself stops: Ratect stops watching a dependency for this the moment the task’s own execution finishes, strictly before cleanup ever touches a container.

More generally, within one task’s resolution any dependency shared by two others — not just a leaf like cache above — is only ever started once, no matter how many dependents reach it or how deep in the graph they sit, including when they reach it genuinely concurrently: the second to arrive waits on the first’s already-in-flight readiness rather than starting a second instance or double-pulling its image. A circular container dependency (a depends on b depends on a) is detected up front, before any container starts, and reported as an error rather than hanging.

Cross-task isolation

Because dependency resolution is scoped to a single task execution, two different tasks that each depend on the same container name get their own separate instance — nothing is shared or deduped across tasks, even within one ratect invocation:

tasks:
  migrate:
    run:
      container: app
      command: run-migrations.sh
  test:
    prerequisites:
      - migrate
    run:
      container: app
      command: run-tests.sh

Both migrate and test here depend on database (via app’s container config). Running ratect-compat test starts a database instance, its own network, runs migrate, cleans both up — then starts a second, independent database instance and network for test. This matches Batect’s own documented behavior (“each task will start its own instance of each container, even if multiple tasks share the same container”) and is also what makes concurrent ratect invocations on the same host safe: each task execution’s network is named with a random UUID, so there’s no risk of two runs colliding.

Known simplifications relative to Batect

  • The task’s own container’s readiness gate can race a fast main command. Since 0.21.0, the task’s own container goes through the same readiness gate a dependency always has — health-check wait, then setup_commands, in order — run concurrently with its main command rather than gating anything on it (nothing else in the graph depends on the task container’s own readiness). A setup command or health-check failure fails the task even if the main command already succeeded. One race this doesn’t close, matching Batect’s own (its RunStage completion is driven purely by the container’s exit event, not its readiness): a main command that exits very quickly — especially with no health_check configured, since the readiness gate then starts its setup_commands almost immediately after the container starts — can finish before a setup_commands entry gets a chance to docker exec into it, surfacing Docker’s own “container is not running” error instead of that setup command’s actual outcome. In practice this only bites a near-instant main command; anything taking more than a few tens of milliseconds gives the setup command time to run and report its real result. Also unlike Batect: the main command itself is never cancelled early just because the readiness gate fails first — it always runs to completion, and the task is still reported as failed overall either way.
  • Prerequisite tasks stay sequential, matching Batect exactlyprerequisites entries run one after another, each to completion, never concurrently with each other or with the task that named them (see “Task ordering” above). This is Batect’s own behavior (TaskExecutionOrderResolver/SessionRunner), not a Ratect simplification — Batect doesn’t parallelize independent prerequisite tasks either. Running independent prerequisites concurrently remains a possible Rust-specific enhancement beyond Batect, tracked under Rust Enhancements, not something planned currently.
  • Minimal networking. The network created here exists only to make dependency containers reachable by name for the duration of one task (or, with --use-network, an existing network you reuse instead). It’s not the fully-configurable Docker networking Batect offers (custom drivers, other than by pre-creating the network yourself) — see differences from Batect.