Skip to content

Run a Child Workflow ​

A step does one unit of work. When one part of a workflow needs several steps, put those steps in a second workflow. Then start that workflow from the first workflow as a child run. The child node of the parent waits for the child run. The wait does not use a worker. Its output is the typed result of the child run.

Declare a Child Node ​

Child is a method on the workflow that the node starts:

go
var (
    price = cyclone.NewStep("price", priceOrder) // string -> Order
    pack  = cyclone.NewStep("pack", packOrder)   // Order -> Boxes
    label = cyclone.NewStep("label", printLabel) // Boxes -> Receipt
    email = cyclone.NewStep("email", sendEmail)  // Receipt -> string
)

var Fulfillment = cyclone.DefineResult("fulfillment", func(b *cyclone.Builder, o Order) (*cyclone.Future[Receipt], error) {
    return label.Do(b, pack.Do(b, cyclone.Lit(o))), nil
})

var Checkout = cyclone.DefineResult("checkout", func(b *cyclone.Builder, id string) (*cyclone.Future[string], error) {
    order := price.Do(b, cyclone.Lit(id))
    receipt := Fulfillment.Child(b, order) // *Future[Receipt]
    return email.Do(b, receipt), nil
})

The compiler checks the types. Fulfillment is a ResultWorkflow[Order, Receipt]. Its input must be an Input[Order]. The node gives a *Future[Receipt]. The input can be a future, Lit, or a join.

The node ID is the workflow name. The node ID of a step is its kind. Use ID to set a different node ID. Child also accepts After, OnDepFailure, and WithMaxWait.

A Workflow from Define has no result. Its Child method returns a *Node. The node gives order only. The node needs no result. It completes when the child run completes. Use it with After:

go
audited := Audit.Child(b, cyclone.Lit(in))
archive.Do(b, cyclone.Lit(in), cyclone.After(audited))

Use DefineResult when a next step needs the result of the child run.

Register the Child Workflow on Workers ​

A worker starts a child run only for a workflow that it registered. Give the workflow to WithHandlers with the steps:

go
client, err := cyclone.New(pool, cyclone.WithHandlers(price, pack, label, email, Fulfillment))

A workflow is not a kind. A client with only workflows has no handler. That client is producer-only.

A worker finds a child workflow by its name. Register the same workflow that the parent graph uses. A Workflow from Define with the name of a DefineResult workflow starts a child run with no result node. The engine then discards a ResultWorkflow.Child node. That node needs a result.

The workers that do the steps of the child workflow need its steps, as for any run. The worker that starts a child run does not check the kinds of the child graph.

What Happens at Run Time ​

  1. The child node is pending until its dependencies complete.
  2. The engine moves the node to waiting_child. No worker claims it. No worker holds it.
  3. A worker that registered the workflow builds the child graph from the input of the node. It inserts the child run, its graph, and its parent link in one transaction. The child run gets its own run ID.
  4. Workers do the steps of the child run like the steps of any run.
  5. When the child run completes, the engine completes the child node with the result of the child run. Then the nodes after the child node become available.

The engine starts a child run right after the commit that made the node wait, when the same process has the workflow. Otherwise the wait scanner of a worker with the workflow starts it within one poll interval.

Identity and Durability ​

  • The parent run ID and the node ID identify the child run. A unique index in Postgres keeps one child run for each child node. This is true also when several workers try at the same time.
  • The child run, its graph, and the parent link commit together, or not at all. A crash cannot leave a child run without its parent link.
  • The child run does not lock its parent run when it ends. After its commit, the engine completes the parent node in a new transaction. After a crash between the two commits, the wait scanner finds the ended child run. The scanner completes the parent node.
  • Each transaction locks the parent run before the child run. A cancel and a child start cannot deadlock. A child run cannot start after the cancel of its parent.

Limits ​

  • Attempts and attempt timeouts do not apply to the child node. It has one attempt. It has no lease.
  • The run deadline of the parent run applies. When it elapses, the engine discards the child node with error kind deadline. The engine cancels the child run.
  • WithMaxWait(d) limits the wait of the child node, from the time its dependencies complete. When the limit elapses, the engine discards the node with error kind permanent. The engine cancels the child run. The limit also applies when no worker starts the child run.
  • A child run has no run deadline of its own. Use WithMaxWait on the child node.
  • Cyclone does not limit how deep child runs nest. A workflow that always starts itself as a child run starts child runs with no end. Give it an end condition on its input.
go
receipt := Fulfillment.Child(b, order, cyclone.WithMaxWait(2*time.Hour))

Failures ​

The child node fails when the child run does not give a result:

Child runChild nodeWithErrorHandler
CompletedCompleted with the result of the child runCyclone does not call the hook
FailedDiscarded. The error names the child run, its failed node, and the error of that nodeCyclone does not call the hook. The failed step of the child run already called it
CancelledDiscarded. The error names the cancelled child runCyclone calls the hook with an error that matches ErrRunNotCompleted
Completed, but its result node did not completeDiscarded. One example is OnDepFailure(CancelBranch) in the child runCyclone calls the hook with an error that matches ErrNotCompleted
Completed with no result node, for a ResultWorkflow.Child nodeDiscarded. One example is a worker that registered a Workflow from Define with the same nameCyclone calls the hook with an error that matches ErrNoOutput
Did not end within WithMaxWaitDiscarded. The engine cancels the child runCyclone calls the hook with an error that matches context.DeadlineExceeded
The input does not decode. The workflow does not build. Or a payload is larger than a payload limit of the workerDiscarded. The child run does not startCyclone calls the hook with the build error

Each of these discards has error kind permanent. Then OnDepFailure applies, as for any discarded node. By default the parent run fails. RunStatus.FailedNode is the child node. RunStatus.Error names the child run. See Per-Branch Failure Policy.

Cancel ​

  • run.Cancel on the parent run cancels its active child runs in the same transaction. A child run that has its own child run cancels that run too.
  • A parent run that fails also cancels its active child runs. A failed sibling step is one example. A run deadline is another example.
  • In a contained branch, a discard or a cancel of the child node cancels its child run.
  • A completed child run stays completed. This version does not undo the work of a child run. This version defers compensation (#45).
  • Cancel on a child run discards its parent node after the commit.

Retry ​

Retry and RetryAll on the parent run reuse the child run of each child node that they reopen. They never start a second child run for a node:

  • A failed child run starts again. Its discarded and cancelled steps reopen, as with RetryAll on the child run. RetryAdditionalAttempts applies to its steps too. Each reopened discarded step gets a requeued event with the RetryReason.
  • A child run that the parent failure cancelled starts again in the same way.
  • A completed child run gives its result to the reopened node at once.
  • A child run that completed, but whose result node did not complete, cannot give a result again. The same is true for a child run with no result node, when the node needs a result. Retry and RetryAll return an error that matches ErrChildNotRetryable. They change no state.
  • A node whose child run did not start starts it again.

Retry the parent run, not the child run. A retry of only the child run does not reopen the discarded parent node.

Pause ​

A pause applies to one run. It does not pause the child runs of the run.

  • The waiting child node of a paused parent run becomes paused. Its WithMaxWait limit stops. No child run starts for it.
  • A child run in progress at the pause continues. To stop it too, pause it with its own handle.
  • When the child run ends during the pause, the parent node keeps the result in the child run. The parent node does not move. Resume completes or discards the node.

Retention ​

PurgeTerminalRuns and the retention sweeper keep a terminal child run until its parent run completes or a delete removes that parent run. A running, paused, or failed parent run can still need the result. A retry of the parent run reuses the child run. A cancelled parent run starts again when a retry of its own parent reopens it. A parent run that you never retry keeps its child runs until a purge deletes the parent run. Then a later purge deletes the child runs.

Producer-Only Clients ​

A producer-only client from cyclone.New(pool) submits a workflow with a child node without registration. It does not call Start. It does not start child runs. Its Cancel, Retry, and RetryAll work on parent runs and on child runs.

A client with handlers refuses to submit a workflow with a child workflow that it did not register. The refusal matches the refusal of an unregistered kind. A worker that did not register the child workflow leaves the child node waiting. It does not discard the node. A worker that registered the workflow starts the child run.

Checks at Submit ​

The input of a child node often comes from an upstream node. Then the child graph exists only at run time. The worker builds it when the child run starts. For a child node with a literal input, the submit builds the child graph and checks it. The check covers its kinds, its child workflows, and the payload sizes. workflow.Validate does the same. The check goes down 16 levels of child runs. Cyclone checks a deeper level when its child run starts.

Inspect Child Runs ​

  • StepStatus.ChildWorkflow and StepStatus.ChildRunID give the workflow and the child run of a child node. ChildRunID is empty until the child run starts.
  • RunStatus.ParentRunID and RunStatus.ParentNode give the parent of a child run. RunSummary from ListRuns has the same fields.
  • cyclone steps <runID> shows the child run of a child node in the CHILD column. cyclone runs shows the parent run and node of a child run in the PARENT column. See CLI.
  • The kind of a child node is @child. Its state is waiting_child while it waits.

Testing ​

cyclonetest starts child runs like any run. env.ChildRun(runID, node) waits for the child run of a node. It returns the child run ID. AdvanceTime and ElapseWait move the WithMaxWait limit of a child node:

go
childID := env.ChildRun(run.ID(), "fulfillment")
env.RequireCompleted(env.RunUntilComplete(childID).ID)
cyclonetest.RequireResult(t, env, run, "sent")

Child Workflows and Fan-Out ​

ForEach starts one step for each item of a list. Use it when each item needs one step. Use a child workflow when one item needs several steps.

This version has no fan-out over a child workflow. A workflow has no ForEach method. A fan-out over a child workflow does not compile. For a list that is known at submit, call Child once for each item in the builder.

Deployment ​

Apply the child run migration before you deploy a binary of this version. Upgrade every worker before you submit a workflow that uses Child. Upgrade every client that purges runs. Upgrade the CLI too. An older worker does not know a child node. That worker discards the node. An older purge can delete a child run that a failed parent run needs for a retry. See Migrations.

Cyclone stores each run in Postgres.