Child Workflows
In LittleHorse, threads let you run multiple steps in parallel within a single WfRun. But what if you want to start an entirely separate WfRun from inside your workflow? That's where child workflows come in.
A child workflow is a separate WfRun (with its own WfSpec) that is launched by a parent WfRun. The parent can continue doing other work while the child executes, and later wait for the child to complete and retrieve its output.
Common use-cases for child workflows include:
- Reusing a
WfSpecas a building block across multiple parent workflows. - Isolating a sub-process so that it has its own
WfRunlifecycle, variables, and visibility in the dashboard. - Delegating a piece of work to a different team's
WfSpec. - Dynamically deciding which
WfSpecto run based on runtime data.
Concepts
Child Workflows vs. Child Threads
Both child workflows and child threads let you run work in parallel, but there are important differences:
| Child Thread | Child Workflow | |
|---|---|---|
| Runs inside | The same WfRun | A brand-new WfRun |
| Variable access | Can read/write parent variables | Can only access parent variables if INHERITED |
| Defined in | The same WfSpec | A separate, independently registered WfSpec |
| Lifecycle | Tied to the parent ThreadRun | Has its own lifecycle and shows up as a separate WfRun in the dashboard |
| Reusability | Only within the same WfSpec | Any WfSpec can launch it |
Use child threads when the work is tightly coupled to the parent and shares state. Use child workflows when you want isolation, reusability, or independent lifecycle tracking.
How It Works
Running a child workflow involves two nodes in the parent WfSpec:
RunChildWfNode: Launches a newWfRunof a specifiedWfSpec, passing in input variables. The node completes as soon as the childWfRunis started, and produces a handle (SpawnedChildWf) that you can use later.WaitForChildWfNode: Blocks the parentThreadRununtil the childWfRuncompletes, and returns the output of the child.
Because these are two separate nodes, the parent can continue doing other work (executing tasks, sleeping, etc.) between spawning the child and waiting for it.
The waitForChildWf() call must be made in the same ThreadSpec that called runWf(). This constraint is enforced at compile time by all SDKs.
Output from Child Workflows
A child WfSpec can produce an output using the complete() method. When the parent calls waitForChildWf(), the return value is a NodeOutput that contains whatever the child passed to complete(). If the child does not call complete(), the output will be null.
Failure Handling
If a child WfRun fails reaches the ERROR status, the WaitForChildWfNode in the parent will throw an ERROR of type CHILD_FAILURE. If the child WfRun throws an EXCEPTION, the WaitForChildWfNode will throw the same EXCEPTION thrown by the child. You can handle this using LittleHorse's exception handling mechanisms, just like you would handle a failed TaskRun or child thread.
In Practice
In this example, we will build two WfSpecs:
- A child
WfSpeccalledgreeting-childthat takes a name as input, greets the person, and returns the greeting as output. - A parent
WfSpeccalledgreeting-parentthat takes a name, launches the child with that name, does some work of its own, and then waits for the child's output.
For the sake of simplicity, every code sample in this document can be compiled and run on its own as a single file. You can even mix-and-match between different languages—try doing one step in Java and another in Python!
If you do want to follow along with the code here, we recommend checking out our installation docs to:
- Install
lhctl. - Set up a LittleHorse Kernel for local development.
- Add the dependency for your SDK of choice.
Building the WfSpec
Background: The TaskDef
We'll use a simple greet TaskDef that takes a name and returns a greeting string. Run the following code in a terminal to register the TaskDef and start the Task Worker. Keep it running for the duration of this exercise.
- Java
- Python
- C#
package io.littlehorse.quickstart;
import io.littlehorse.sdk.common.config.LHConfig;
import io.littlehorse.sdk.worker.LHTaskMethod;
import io.littlehorse.sdk.worker.LHTaskWorker;
class Greeter {
@LHTaskMethod("greet")
public String greet(String name) {
String result = "Hello, " + name + "!";
System.out.println(result);
return result;
}
}
public class Main {
public static void main(String[] args) {
LHConfig config = new LHConfig();
LHTaskWorker worker = new LHTaskWorker(new Greeter(), "greet", config);
Runtime.getRuntime().addShutdownHook(new Thread(worker::close));
worker.registerTaskDef();
worker.start();
}
}
import asyncio
import littlehorse
from littlehorse import create_task_def
from littlehorse.worker import LHTaskWorker
from littlehorse.config import LHConfig
config = LHConfig()
async def greet(name: str) -> str:
result = f"Hello, {name}!"
print(result)
return result
async def main():
worker = LHTaskWorker(greet, "greet", config)
await littlehorse.start(worker)
if __name__ == "__main__":
create_task_def(greet, "greet", config)
asyncio.run(main())
using LittleHorse.Sdk;
using LittleHorse.Sdk.Worker;
namespace Quickstart;
class Greeter
{
[LHTaskMethod("greet")]
public string Greet(string name)
{
string result = $"Hello, {name}!";
Console.WriteLine(result);
return result;
}
}
public class Program
{
static void Main(string[] args)
{
var config = new LHConfig();
var worker = new LHTaskWorker<Greeter>(new Greeter(), "greet", config);
worker.RegisterTaskDef();
AppDomain.CurrentDomain.ProcessExit += (sender, e) => { worker.Close(); };
worker.Start();
}
}
Registering the WfSpecs
The child WfSpec is a standalone workflow—there is nothing special about it. It declares a required input variable, executes a task, and returns a result using complete(). Notice how wf.complete(wf.execute("greet", name)) passes the task output as the workflow output. This value is what the parent will receive when it calls waitForChildWf().
The parent WfSpec uses two key methods:
runWf()to start the childWfRun, passing in input variables as a map.waitForChildWf()to block until the child completes and retrieve its output.
We register the child first, then the parent (since the parent references the child's WfSpec):
- Java
- Python
- C#
package io.littlehorse.quickstart;
import io.littlehorse.sdk.common.config.LHConfig;
import io.littlehorse.sdk.wfsdk.SpawnedChildWf;
import io.littlehorse.sdk.wfsdk.WfRunVariable;
import io.littlehorse.sdk.wfsdk.Workflow;
import io.littlehorse.sdk.wfsdk.WorkflowThread;
import io.littlehorse.sdk.wfsdk.internal.WorkflowImpl;
import java.util.Map;
public class Main {
public static void childLogic(WorkflowThread wf) {
WfRunVariable name = wf.declareStr("name").required();
wf.complete(wf.execute("greet", name));
}
public static void parentLogic(WorkflowThread wf) {
WfRunVariable inputName = wf.declareStr("input-name").required();
WfRunVariable childOutput = wf.declareStr("child-output");
// Start the child WfRun
SpawnedChildWf child = wf.runWf("greeting-child", Map.of("name", inputName));
// The parent can do other work while the child is running
wf.execute("greet", "hi from parent");
// Wait for the child and capture its output
childOutput.assign(wf.waitForChildWf(child));
}
public static void main(String[] args) {
LHConfig config = new LHConfig();
Workflow child = new WorkflowImpl("greeting-child", Main::childLogic);
child.registerWfSpec(config.getBlockingStub());
Workflow parent = new WorkflowImpl("greeting-parent", Main::parentLogic);
parent.registerWfSpec(config.getBlockingStub());
}
}
from littlehorse import create_workflow_spec
from littlehorse.config import LHConfig
from littlehorse.workflow import Workflow, WorkflowThread
def child_logic(wf: WorkflowThread) -> None:
name = wf.declare_str("name").required()
wf.complete(wf.execute("greet", name))
def parent_logic(wf: WorkflowThread) -> None:
input_name = wf.declare_str("input-name").required()
child_output = wf.declare_str("child-output")
# Start the child WfRun
child = wf.run_wf("greeting-child", inputs={"name": input_name})
# The parent can do other work while the child is running
wf.execute("greet", "hi from parent")
# Wait for the child and capture its output
child_output.assign(wf.wait_for_child_wf(child))
if __name__ == "__main__":
config = LHConfig()
create_workflow_spec(Workflow("greeting-child", child_logic), config)
create_workflow_spec(Workflow("greeting-parent", parent_logic), config)
using LittleHorse.Sdk;
using LittleHorse.Sdk.Workflow.Spec;
namespace Quickstart;
public class Program
{
public static void ChildLogic(WorkflowThread wf)
{
WfRunVariable name = wf.DeclareStr("name").Required();
wf.Complete(wf.Execute("greet", name));
}
public static void ParentLogic(WorkflowThread wf)
{
WfRunVariable inputName = wf.DeclareStr("input-name").Required();
WfRunVariable childOutput = wf.DeclareStr("child-output");
// Start the child WfRun
SpawnedChildWf child = wf.RunWf("greeting-child",
new Dictionary<string, object> { { "name", inputName } });
// The parent can do other work while the child is running
wf.Execute("greet", "hi from parent");
// Wait for the child and capture its output
childOutput.Assign(wf.WaitForChildWf(child));
}
static void Main(string[] args)
{
var config = new LHConfig();
var child = new Workflow("greeting-child", ChildLogic);
child.RegisterWfSpec(config.GetGrpcClientInstance());
var parent = new Workflow("greeting-parent", ParentLogic);
parent.RegisterWfSpec(config.GetGrpcClientInstance());
}
}
There are three key calls in the parent logic:
runWf("greeting-child", Map.of("name", inputName))creates a newWfRunof thegreeting-childWfSpec, passinginputNameas thenameinput variable. The parent continues immediately—it does not block here.wf.execute("greet", "hi from parent")runs in the parent while the child may still be executing. This demonstrates that the parent is free to do other work.waitForChildWf(child)blocks the parentThreadRununtil the childWfRuncompletes, and returns the child's output (whatever was passed tocomplete()in the child).
Run the Workflow
Now that we've registered our TaskDef and both WfSpecs, let's run the parent and observe the child being spawned automatically.
Start a WfRun
Start the parent WfRun using lhctl:
lhctl run greeting-parent input-name "Obi-Wan"
Make note of the WfRunId from the output.
What Happens
When the parent WfRun starts:
- The parent reaches the
RunChildWfNodeand spawns a childWfRunof thegreeting-childWfSpec, passing"Obi-Wan"as thenameinput. - The parent immediately moves on and executes the
greettask with"hi from parent". - The parent then reaches the
WaitForChildWfNodeand blocks until the child completes. - Meanwhile, the child
WfRunexecutes thegreettask with"Obi-Wan"and returns"Hello, Obi-Wan!"as its output viacomplete(). - The parent receives
"Hello, Obi-Wan!"and stores it inchild-output.
You can inspect the parent WfRun and see the child's WfRunId in the RunChildWfNode:
lhctl get wfRun <parentWfRunId>
You can also inspect the child WfRun directly—it's a fully independent WfRun in the system.
Further Resources
Congrats on learning how to use child workflows in LittleHorse! Here are some related topics to explore:
- Threads for running work in parallel within a single
WfRun. - Exception Handling for handling failures from child workflows.
- Variables for understanding how input/output data flows between workflows.