Skip to main content

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 running WfRun, 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 certain ThreadRun should be interrupted when a certain ExternalEvent 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 child ThreadRun of the Interrupted Thread).

Interrupt Handler Definitions are part of a ThreadSpec. That means that when an ExternalEvent of the specified type occurs, all ThreadRuns of that ThreadSpec will be simultaneously interrupted.

note

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 ThreadRuns must also be recursivvely halted. Sometimes, a ThreadRun can immediately move to the HALTED state; other times it may take a few seconds.

info

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 Variables.

tip

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.

tip

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.

note

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 ERRORs and user-defined exceptions which are in kebab-case).

In Practice

In order to use Interrupts in your workflows, you must:

  1. Steal Underpants.
  2. Define an ExternalEventDef that will trigger the interrupt.
  3. Write a WfSpec with an interrupt handler for that specific ExternalEventDef.
  4. Run the WfSpec and send an ExternalEvent of the appropriate type while the WfRun is still running.
  5. 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:

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();
}
}

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:

  1. Append the underpant to the all-underpants variable, which belongs to the Entrypoint Thread and represents a basket of all underpants.
  2. 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 ExternalEventDefs: underpant-collected and done-collecting-underpants.

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);
}
}

The WfSpec should look something like this:

A WfSpec diagram with a task, wait for event node, a task. There is also an interrupt handler ThreadSpec.
The Interrupt Example WfSpec

Running the Workflow

We will run the WfRun once, and we will post multiple ExternalEvents.

tip

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.

A WfRun diagram with a single running ThreadRun, which is blocked on an ExternalEventNode.
The Interrupt Example WfRun

Once the workflow is running, you can send ExternalEvents 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.

A WfRun diagram with a single running ThreadRun, which is blocked on an ExternalEventNode.
The Interrupt Example WfRun

Here are some examples:

lhctl postEvent my-wf underpant-collected STR anakin
lhctl postEvent my-wf underpant-collected STR yoda

After posting the three ExternalEvents, you'll see that the WfRun now has three additional ThreadRuns! You can see this in the top bar above the visualizer graph. Clicking on one of them shows what's going on:

A WfRun diagram with a single running ThreadRun, which is blocked on an ExternalEventNode.
The Interrupt Example WfRun

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 ExternalEvents, 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:

The profit TaskRun has been executed with the all-underpants variable, which was updated by each interrupt thread.
The Profit TaskRun