Interrupts
Unix-like operating systems allow you to interrupt a process by sending a signal to it. The interrupted process can define handlers for that signal, allowing you to "catch" it and react appropriately. In LittleHorse, Interrupts serve the same purpose: you can interrupt a WfRun
(more specifically, a ThreadRun
), and your interrupted workflow can run special logic to handle that interrupt. Interrupts may be used for various reasons, such as to:
- Send heartbeats from an external system and allow the
WfRun
to keep track of the last seen activity (such as when determining when to invalidate an access token due to inactivity). - Update the value of some
Variable
in a runningWfRun
, such as to change contact info or add items to a shopping cart. - Kill a running
WfRun
and perform some cleanup action (such as notifying a customer) before making the Interrupted Thread fail. - Update a shopping cart by adding or removing items while waiting for the "checkout" event.
Concepts
The following concepts make up Interrupts in LittleHorse:
- An
ExternalEvent
, which is used to trigger the interrupt. - An Interrupt Handler Definition is part of a
WfSpec
that specifies to LittleHorse that a certainThreadRun
should be interrupted when a certainExternalEvent
occcurs. - The Interrupted Thread is the
ThreadRun
that was interrupted. - The Interrupt Handler is the
ThreadRun
spawned to handle the interrupt (and it is a childThreadRun
of the Interrupted Thread).
Interrupt Handler Definitions are part of a ThreadSpec
. That means that when an ExternalEvent
of the specified type occurs, all ThreadRun
s of that ThreadSpec
will be simultaneously interrupted.
Only one ThreadSpec
may register an Interrupt for a specific ExternalEventDef
. Furthermore, if you use an ExternalEventDef
as an Interrupt Trigger, your WfRun
cannot have an ExternalEventNode
that waits for that specific ExternalEventDef
.
Halting
An Interrupt in LittleHorse halts the interrupted ThreadRun
and starts an Interrupt Handler ThreadRun
(which is a child of the halted thread). Once the Interrupt Handler completes, then the halted parent thread can resume.
When a ThreadRun
halts, it requests the currently-running NodeRun
to halt. Furthermore, any child ThreadRun
s must also be recursivvely halted. Sometimes, a ThreadRun
can immediately move to the HALTED
state; other times it may take a few seconds.
Depending on what Node
is currently running, halting a ThreadRun
may be instantaneous, or it may take a few seconds.
If the current NodeRun
is doing active work (i.e. a TaskRun
has been dispatched to a Task Worker), then the NodeRun
can't become HALTED
until the Task Worker responds with an answer. However, nodes like SLEEP
and EXTERNAL_EVENT
can immediately move to HALTED
since there are no in-progress actions.
Thread Hierarchy
When a ThreadRun
is Interrupted, it must first halt. As per the WfRun Documentation, a ThreadRun
is not considered HALTED
until all of its Children are HALTED
as well. Therefore, interrupting a ThreadRun
causes all of the Children of the Interrupted ThreadRun
to halt as well.
The Interrupt Handler is a Child of the Interrupted Thread. Therefore, it has read/write access to all of the Interrupted Thread's Variable
s.
Using an Interrupt is a great pattern for long-running workflows in which you might occasionally need to modify variables inside your WfRun
due too external triggers. For example, you can use an Interrupt to update the preferred-email
variable.
Interrupt Handler Failures
Like any ThreadRun
, an Interrupt Handler thread can fail with an ERROR
or an EXCEPTION
. If the Interrupt Handler fails with an ERROR
, then the interrupted thread (specifically, the NodeRun
that was interrupted) will fail with the CHILD_FAILED
error code.
However, if the Interrupt Handler thread fails with an EXCEPTION
, then the same business exception will be propagated up to the interrupted NodeRun
on the parent.
One useful pattern we have seen is using Interrupts as a way to gracefully perform cleanup logic kill a running WfRun
. You can easily do that with an Interrupt Handler that uses wf.execute(...)
followed by wf.fail(...)
.
Interrupt Payloads
As we saw in the External Events section, an ExternalEvent
has a content
field. Since an Interrupt is triggered by an ExternalEvent
, an Interrupt itself has content. You can access the content of the ExternalEvent
by declaring a Variable
with the special, system-defined name INPUT
as follows.
WfRunVariable externalEventPayload = wf.declareStr("INPUT");
When declaring the variable, the declared type must match the type of the ExternalEvent
. If the ExternalEvent
cannot be cast to the declared type, then the Interrupt Handler will fail with an ERROR
. We have open issues on GitHub to enable strong typing for External Events which will alleviate this issue.
User-defined names (WfSpec
names, variable names, etc) in LittleHorse must all be in kebab-case
. System-defined names are all in UPPER_UNDERSCORE_CASE
. This is true of both Variable names (for example, INPUT
) and exception names (system-defined ERROR
s and user-defined exceptions which are in kebab-case
).
In Practice
In order to use Interrupts in your workflows, you must:
- Steal Underpants.
- Define an
ExternalEventDef
that will trigger the interrupt. - Write a
WfSpec
with an interrupt handler for that specificExternalEventDef
. - Run the
WfSpec
and send anExternalEvent
of the appropriate type while theWfRun
is still running. - Profit!
As you may have guessed, in this example we will implement the famous "Collect Underpants, ..., Profit!" workflow. Our WfSpec
's entrypoint thread will do the following:
- Execute the
start-underpants-collection
task. - Wait for the
done-collecting-underpants
ExternalEvent
. - Execute the
profit
task.
The Main Thread will have an Interrupt Handler for the add-underpants
External Event.
Background: The Tasks
The Task Workers in this example are unremmarkable:
- Java
- Python
- Go
- C#
package io.littlehorse.quickstart;
import java.util.List;
import io.littlehorse.sdk.common.config.LHConfig;
import io.littlehorse.sdk.worker.LHTaskMethod;
import io.littlehorse.sdk.worker.LHTaskWorker;
class UnderpantStealer {
@LHTaskMethod("start-underpants-collection")
public void startCollection() {
System.out.println("Starting collection of underpants!");
}
@LHTaskMethod("collect-underpant")
public String shipItem(String underpantOwner) {
String result = "Successfully collected underpant from " + underpantOwner;
System.out.println(result);
return result;
}
@LHTaskMethod("profit")
public String profit(List<String> underpants) {
String result = "Collected " + underpants.size() + " underpants!";
System.out.println(result);
return result;
}
}
public class Main {
public static void main(String[] args) throws Exception {
LHConfig config = new LHConfig();
UnderpantStealer taskFuncs = new UnderpantStealer();
LHTaskWorker startCollect = new LHTaskWorker(taskFuncs, "start-underpants-collection", config);
LHTaskWorker collectPant = new LHTaskWorker(taskFuncs, "collect-underpant", config);
LHTaskWorker profit = new LHTaskWorker(taskFuncs, "profit", config);
startCollect.registerTaskDef();
profit.registerTaskDef();
collectPant.registerTaskDef();
Runtime.getRuntime().addShutdownHook(new Thread(startCollect::close));
Runtime.getRuntime().addShutdownHook(new Thread(profit::close));
Runtime.getRuntime().addShutdownHook(new Thread(collectPant::close));
startCollect.start();
profit.start();
collectPant.start();
}
}
TODO
TODO
TODO
The WfSpec
In this example, we will implement the classic "Steal Underpants, ..., Profit!" workflow. However, we'll refactor the logic a little bit to allow us to use interrupts. We will first execute the start-underpant-collection
task, and then we will wait while our minions collect underpants for us (by an EXTERNAL_EVENT
node that waits for done-collecting-underpants
). Once all underpants have been collected by our minions, we will execute the profit
task!
We'll use Interrupts to represent when our minions successfully collect a pair of underpants. The underpant-collected
ExternalEvent
will trigger an interrupt whose handler will:
- Append the underpant to the
all-underpants
variable, which belongs to the Entrypoint Thread and represents a basket of all underpants. - Execute the
collect-underpant
task.
In our code, please notice that the Interrupt Handler thread is built just like any other ThreadSpec
in LittleHorse. It declares variables, executes logic, and so on. Next, notice that the Interrupt Handler thread mutates the all-underpants
variable, which is declared by the entrypoint ThreadRun
.
Also, note that we do register the two ExternalEventDef
s: underpant-collected
and done-collecting-underpants
.
- Java
- Python
- Go
- C#
package io.littlehorse.quickstart;
import io.littlehorse.sdk.common.config.LHConfig;
import io.littlehorse.sdk.common.proto.PutExternalEventDefRequest;
import io.littlehorse.sdk.common.proto.VariableMutationType;
import io.littlehorse.sdk.common.proto.LittleHorseGrpc.LittleHorseBlockingStub;
import io.littlehorse.sdk.wfsdk.WfRunVariable;
import io.littlehorse.sdk.wfsdk.Workflow;
import io.littlehorse.sdk.wfsdk.WorkflowThread;
class UnderpantsWorkflow {
// This is a trick to put the physical WfRunVariable somewhere that it
// can be accessed by both the entrypoint thread Java function and the interrupt
// handler Java function. It's specific to the Java SDK.
private WfRunVariable allUnderpants;
public UnderpantsWorkflow() {}
public void wfLogic(WorkflowThread wf) {
this.allUnderpants = wf.declareJsonArr("all-underpants");
wf.execute("start-underpants-collection");
wf.waitForEvent("done-collecting-underpants");
wf.execute("profit", allUnderpants);
wf.registerInterruptHandler("underpant-collected", this::handleUderpant);
}
public void handleUderpant(WorkflowThread wf) {
// This variable captures the content of the ExternalEvent that triggered the
// interrupt.
WfRunVariable interruptContent = wf.declareStr(WorkflowThread.HANDLER_INPUT_VAR);
// We can mutate the parent's variables here
wf.mutate(allUnderpants, VariableMutationType.ADD, interruptContent);
// We can execute tasks in the interrupt handler
wf.execute("collect-underpant", interruptContent);
}
public void register(LittleHorseBlockingStub client) {
Workflow.newWorkflow("collect-underpants", this::wfLogic).registerWfSpec(client);
}
}
public class Main {
public static void main(String[] args) throws Exception {
LHConfig config = new LHConfig();
LittleHorseBlockingStub client = config.getBlockingStub();
// Create ExternalEventDef's
client.putExternalEventDef(PutExternalEventDefRequest.newBuilder().setName("underpant-collected").build());
client.putExternalEventDef(PutExternalEventDefRequest.newBuilder().setName("done-collecting-underpants").build());
// Deploy WfSpec
new UnderpantsWorkflow().register(client);
}
}
TODO
TODO
TODO
The WfSpec
should look something like this:

Running the Workflow
We will run the WfRun
once, and we will post multiple ExternalEvent
s.
When you post an ExternalEvent
, you need a WfRunId
. Using a guid can be very cumbersome; therefore, we recommend you set your own WfRunId
by using the --wfRunId
flag in lhctl
. This will also allow you to just copy-and-paste the commands below.
For convenience we'll use the WfRunId
of my-wf
:
lhctl run collect-underpants --wfRunId my-wf all-underpants '[]'
The WfRun
will be in the RUNNING
state, waiting on the done-collecting-underpants
event.

Once the workflow is running, you can send ExternalEvent
s to it. We'll start by stealing Master Obi-Wan's underpants:
lhctl postEvent my-wf underpant-collected STR obiwan
If we inspect the WfRun
, we can see that a new ThreadRun
has been created to handle the interrupt. This new ThreadRun
is a child of the original ThreadRun
that was interrupted.

Here are some examples:
lhctl postEvent my-wf underpant-collected STR anakin
lhctl postEvent my-wf underpant-collected STR yoda
After posting the three ExternalEvent
s, you'll see that the WfRun
now has three additional ThreadRun
s! You can see this in the top bar above the visualizer graph. Clicking on one of them shows what's going on:

The coolest part is that if you go back to the parent ThreadRun
and look at the all-underpants
variable, you can see that it has been updated with the underpants that were collected! Let's use lhctl
to inspect the Variable
(you can also do this through the Dashboard).
->lhctl get variable my-wf 0 all-underpants
{
"id": {
"wfRunId": {
"id": "my-wf"
},
"threadRunNumber": 0,
"name": "all-underpants"
},
"value": {
"jsonArr": "[\"obiwan\"]"
},
"createdAt": "2025-03-15T13:58:48.695Z",
"wfSpecId": {
"name": "collect-underpants",
"majorVersion": 0,
"revision": 0
},
"masked": false
}
If we run a few more ExternalEvent
s, we can see the all-underpants
variable grow:
lhctl postEvent my-wf underpant-collected STR anakin
lhctl postEvent my-wf underpant-collected STR yoda
Finally, let's complete the WfRun
by posting the done-collecting-underpants
event:
lhctl postEvent my-wf done-collecting-underpants STR "done!"
When you inspect the profit
task, you'll see that the all-underpants
variable included yoda
, obi-wan
, and anakin
:
