Skip to content
These docs cover Strata 0.9.0-preview.1.It is a preview, and the API can still change before 1.0.

End a thread

Some threads stop and never go on: an approval that will never come, a payment the customer cancelled, a failed run nobody will retry. In this guide you end such a thread with EndAsync (C#) or end (Rust), check the result, and see what Strata refuses. You also see how cancelling a run differs from ending a thread.

Threads and checkpoints explains the statuses this guide uses.

The samples use a small transfer graph: Prepare, an interrupt boundary at Prepare, then Execute.

How Latest checkpoint Can it continue?
The run reaches the end of the graph None under the default Boundaries policy, completed under EveryNode No
You end it with EndAsync / end ended, reason requested No
A wait times out and has no timeout route ended, reason timed_out No
You cancel the run, or its deadline passes cancelled Yes: a resume runs the stopped node again

A cancelled thread isn’t finished. It stays resumable until you resume it or end it.

Call EndAsync / end with the thread ID and the store. ThreadEnd.By / ThreadEnd::by records who ended it. Run options are optional. Their metadata is stamped on the ended checkpoint, the same way a resume stamps it. Nothing runs, so the token, the deadline and the hop limit don’t apply.

var result = await transfers.EndAsync(
"transfer-acme",
ThreadEnd.By("oncall"),
store,
new RunOptions { Metadata = { ["ticket"] = "OPS-7" } });
switch (result)
{
case GraphExecutionResult<Transfer>.Cancelled { Reason: CancelReason.Ended } ended:
Console.WriteLine($"Ended at {ended.Node} ([{string.Join(", ", ended.StoppedAt)}])");
break;
case GraphExecutionResult<Transfer>.Failed refused:
Console.WriteLine($"Refused: {refused.Message}");
break;
}
var checkpoint = store.Load("transfer-acme")!;
Console.WriteLine(
$"checkpoint: status {checkpoint.Status}, next {checkpoint.NextNode ?? "null"}, " +
$"{ThreadEnd.ReasonKey}={checkpoint.Metadata![ThreadEnd.ReasonKey]}, " +
$"{ThreadEnd.EndedByKey}={checkpoint.Metadata[ThreadEnd.EndedByKey]}, ticket={checkpoint.Metadata["ticket"]}");

The C# version prints:

Ended at Prepare ([Prepare])
checkpoint: status Ended, next null, strata.end_reason=requested, strata.ended_by=oncall, ticket=OPS-7

The result is Cancelled with the reason Ended, not a separate result type. So code that already handles a cancellation handles an ending too, and code that needs the difference checks the reason. Node is where the thread stood, State its last committed state, and StoppedAt / stopped_at the node path, which is longer when the thread waited inside a subgraph.

Strata appends one ended checkpoint to the thread’s log. It keeps the node, the state and any decision, sets the next node to null, and clears the wait. Its metadata holds your run metadata, strata.end_reason and, when you said who, strata.ended_by. Ending a thread inside a subgraph ends every level of it. The keys are public constants: ThreadEnd.ReasonKey, ThreadEnd.EndedByKey and ThreadEnd.Requested in C#, END_REASON_KEY, ENDED_BY_KEY and END_REQUESTED in Rust. Use them to filter threads with ThreadQuery.

Without who or metadata, call EndAsync("transfer-acme", store) / end("transfer-acme", store).

An answer that arrives after the end is refused. A resume, an event signal or a wake returns Failed and writes nothing:

late approval: Failed: thread 'transfer-acme' was ended: an ended thread can't be resumed

Ending an ended thread again returns the same Cancelled result and writes nothing. The first caller’s EndedBy stays. So two operators who both press cancel, or a caller that retries after a timeout, get the same answer.

Starting a new run with an ended thread’s ID isn’t refused: a run doesn’t read the store. Give each new execution its own ID.

You can end a thread whose latest checkpoint is interrupted, failed or cancelled. You can also end a running thread whose process died, as long as no live lease holds it. Strata ends it at the node that was to run next. See Durable execution.

Everything else is refused with Failed, and nothing is written:

The thread The message
Has no checkpoint no checkpoint found for thread 't'
Completed (under EveryNode) thread 't' has completed: only a waiting, failed or cancelled thread can be ended
Was resumed, and may still be running thread 't' was resumed at <time> and may still be running: only a waiting, failed or cancelled thread can be ended
Is running under a live lease thread 't' is held by '<owner>' until <time>

Under the default Boundaries policy, a thread that was approved and then finished has a resume stamp as its latest checkpoint, so it gets the “was resumed” refusal:

end globex: Failed: thread 'transfer-globex' was resumed at 2026-10-30T14:18:31Z and may still be running: only a waiting, failed or cancelled thread can be ended

In Rust, Failed holds a StrataError with the same message: NodeNotFound for a missing checkpoint, ExecutionAborted for the others.

The save is conditional. If a resume and an end race on one thread, only one wins. The end reloads the thread and decides again, so it returns the idempotent result or a refusal.

Cancelling stops a run that is in progress in your process. Ending acts on a thread that isn’t running. Cancel a run with a cancellation token, or let its deadline pass (RunOptions.Deadline in C#, RunConfig::with_deadline in Rust). The run returns Cancelled with the reason Requested or Deadline, and writes a cancelled checkpoint at the node that was running. That thread is resumable. Decide later whether to resume it or end it.

using (var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)))
{
// Execute is still running when the token fires.
var cancelled = await transfers.ResumeAsync(
"transfer-initech", HumanSignal.Approve("alice"), store, cts.Token);
Console.WriteLine("cancelled: " + Show(cancelled));
}
// Later: run Execute again with ResumeAsync, or give up on the thread.
Console.WriteLine("end cancelled: " + Show(await transfers.EndAsync("transfer-initech", store)));

Show / show formats a result as one line. The output:

cancelled: Cancelled at Execute, reason Requested, stopped at [Execute]
end cancelled: Cancelled at Execute, reason Ended, stopped at [Execute]

In C#, an async node handler receives the token as its last parameter. Pass it on to what the node awaits, so the node stops when the run is cancelled.

A wait with a timeout and no timeout route ends its thread when the wake driver finds it due. The ended checkpoint records strata.end_reason as timed_out (ThreadEnd.TimedOut, END_TIMED_OUT) and has no strata.ended_by. The wake returns Cancelled with the reason Ended. See Event waits and timers.

Ending deletes nothing. The thread’s log keeps every checkpoint, including the ones before the end, with their error or cancel reason. Ended threads list under the ended state. Stores never delete on their own. Your application can call the retention port’s PurgeAsync / purge, which by default deletes completed and ended threads older than a time you give. See Threads and checkpoints.

The CLI is read-only: it can’t end a thread, but it shows one. strata thread inspect prints the reason and who ended it:

Terminal window
strata thread inspect transfer-acme --storage transfers.db
Thread: transfer-acme
Status: ended
Thread state: ended
Current node: Prepare
Reason: requested
Ended by: oncall
Metadata: ticket=OPS-7
Next node: -
Checkpoint count: 2
Created at: 2026-10-30T14:20:08Z
Resumed at: -
State:
{
"invoice": "INV-1042",
"sent": false
}

strata thread list --status ended lists ended threads, and strata checkpoint log shows the interrupted checkpoint followed by the ended one. See strata thread inspect.