Appearance
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
- The child node is
pendinguntil its dependencies complete. - The engine moves the node to
waiting_child. No worker claims it. No worker holds it. - 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.
- Workers do the steps of the child run like the steps of any run.
- 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 kindpermanent. 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
WithMaxWaiton 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 run | Child node | WithErrorHandler |
|---|---|---|
| Completed | Completed with the result of the child run | Cyclone does not call the hook |
| Failed | Discarded. The error names the child run, its failed node, and the error of that node | Cyclone does not call the hook. The failed step of the child run already called it |
| Cancelled | Discarded. The error names the cancelled child run | Cyclone calls the hook with an error that matches ErrRunNotCompleted |
| Completed, but its result node did not complete | Discarded. One example is OnDepFailure(CancelBranch) in the child run | Cyclone calls the hook with an error that matches ErrNotCompleted |
Completed with no result node, for a ResultWorkflow.Child node | Discarded. One example is a worker that registered a Workflow from Define with the same name | Cyclone calls the hook with an error that matches ErrNoOutput |
Did not end within WithMaxWait | Discarded. The engine cancels the child run | Cyclone 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 worker | Discarded. The child run does not start | Cyclone 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.Cancelon 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).
Cancelon 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
RetryAllon the child run.RetryAdditionalAttemptsapplies to its steps too. Each reopened discarded step gets arequeuedevent with theRetryReason. - 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.
RetryandRetryAllreturn an error that matchesErrChildNotRetryable. 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. ItsWithMaxWaitlimit 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.
Resumecompletes 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.ChildWorkflowandStepStatus.ChildRunIDgive the workflow and the child run of a child node.ChildRunIDis empty until the child run starts.RunStatus.ParentRunIDandRunStatus.ParentNodegive the parent of a child run.RunSummaryfromListRunshas the same fields.cyclone steps <runID>shows the child run of a child node in theCHILDcolumn.cyclone runsshows the parent run and node of a child run in thePARENTcolumn. See CLI.- The kind of a child node is
@child. Its state iswaiting_childwhile 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.