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.

Host Strata in a service

This guide runs a graph inside a long-running service. You register a store and a graph, read settings from configuration, run the background drivers, add health checks and telemetry, call the graph from a request handler, and stop without cutting a run short.

In .NET, Datalium.Strata.Hosting plugs into the Generic Host with AddStrata(). In Rust, datalium-strata-host has no container: you open the store and compile the graph yourself, and a Host runs the drivers as tasks. The examples use a refund graph: Prepare runs, the thread waits for approval, then Pay runs.

  • The packages from Installation. In .NET: Datalium.Strata.Hosting, plus Microsoft.Extensions.Hosting for a worker or the ASP.NET Core SDK for a web service. In Rust: datalium-strata, datalium-strata-memory, datalium-strata-host, serde_json, and tokio with the rt-multi-thread, macros and signal features.
  • A checkpoint store. The examples use SQLite because it needs no server. Choose a store compares the options.
  • Durable execution explains what the wake and recovery drivers do.

Register the store, the graph and the drivers

Section titled “Register the store, the graph and the drivers”
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddStrata()
.AddSqliteCheckpoints() // Strata:Sqlite
.AddInstrumentation()
.AddHealthChecks()
.AddGraph(_ => Refunds.Build()) // compiled when the host starts
.AddWakeDriver<Refund>() // Strata:Wake
.AddRecoveryDriver<Refund>() // Strata:Recovery
.AddRetention(); // Strata:Retention
builder.Services.AddSingleton<RefundDesk>();
using var host = builder.Build();
await host.RunAsync();
public sealed record Refund(string Customer, decimal Amount, string Status);
public static class Refunds
{
public static CompiledGraph<Refund> Build() =>
new StateGraph<Refund>()
.AddNode("Prepare", refund => refund with { Status = "awaiting approval" })
.AddInterruptBoundary("Prepare")
.AddNode("Pay", refund => refund with { Status = "paid" })
.AddEdge("Prepare", "Pay")
.Compile(new CompilationOptions { Checkpointing = CheckpointPolicy.EveryNode });
}

AddSqliteCheckpoints(), AddPostgresCheckpoints(), AddRedisCheckpoints() or AddInMemoryCheckpoints() registers one checkpoint store under every interface it implements. AddCheckpointStore(factory) registers your own. AddGraph(factory) registers the compiled graph and a StrataRunner<TState>. A container holds one checkpoint store, and one graph per state type.

When the host starts, it migrates the SQL stores, compiles every graph and checks that each driver has what it needs. If one of these fails, StartAsync throws and no driver starts. A graph with a structural error throws GraphCompilationException.

When the .NET host is up, its log shows one line per driver. The Rust host writes the same events through tracing.

Strata driver wake:Refund started, every 00:00:05
Strata driver recovery:Refund started, every 00:00:30
Strata driver retention started, every 01:00:00

The options come from the Strata section of configuration. Durations are TimeSpan strings, such as 00:00:30 or 30.00:00:00 for 30 days.

{
"Strata": {
"Sqlite": { "Path": "strata.db" },
"Runs": {
"Lease": { "Ttl": "00:00:30" }
},
"Wake": { "Interval": "00:00:05" },
"Recovery": { "Interval": "00:00:30" },
"Retention": { "Interval": "01:00:00", "OlderThan": "30.00:00:00" }
}
}

Each registration also takes a delegate, applied over configuration: AddRetention(o => o.OlderThan = TimeSpan.FromDays(30)). Configuration is bound by reflection, so if you publish with trimming, use delegates.

AddPostgresCheckpoints() reads the connection string ConnectionStrings:strata by default, and AddRedisCheckpoints() reads ConnectionStrings:strata-redis. Either store can also use an NpgsqlDataSource or IConnectionMultiplexer you registered.

The sections are the same in both languages:

Section (.NET / Rust) What it sets
Sqlite / sqlite Path, the database file, plus group commit, deltas and offload.
Postgres / postgres The connection, AutoMigrate (on by default), retries, group commit, deltas and offload.
Redis / redis The connection, the key Prefix, FinishedThreadTtl, retries, deltas and offload.
Runs / runs Lease: the Owner (machine name and process ID by default) and Ttl. Without a ttl, runs take no lease.
Wake / wake The wake driver: Interval, BatchSize, Metadata, MatchGraph, Priority.
Recovery / recovery The recovery driver: the same, plus StaleAfter and MaxRecoveries.
Retention / retention The retention driver: Interval, BatchSize, OlderThan, States.

With AutoMigrate off, the Postgres store only checks its tables, and a database that is behind stops the host from starting. Run the migration as a deploy step.

Inject StrataRunner<TState> wherever you start or answer a thread:

public sealed class RefundDesk(StrataRunner<Refund> runner)
{
public Task<GraphExecutionResult<Refund>> StartAsync(string id, Refund refund, CancellationToken ct = default) =>
runner.RunAsync(id, refund, cancellationToken: ct);
public Task<GraphExecutionResult<Refund>> ApproveAsync(string id, string reviewer, CancellationToken ct = default) =>
runner.ResumeAsync(id, HumanSignal.Approve(reviewer), cancellationToken: ct);
}

The runner holds the graph (runner.Graph), the store (runner.Store) and the host’s run defaults, and has RunAsync, ResumeAsync, SignalAsync, RecoverAsync and EndAsync. Its runs get the lease from Strata:Runs:Lease, the container’s clock, the registered history store and observer, and the container as NodeContext.Services. runner.CreateOptions() returns these defaults, for example to add metadata.

You can also inject CompiledGraph<Refund> and ICheckpointStore directly. Then you pass run options yourself, and runs take no lease unless you add one.

The drivers are the loops you would otherwise write yourself. Each one runs a round, waits its interval, and goes again.

Driver .NET Rust Default interval What it does
Wake AddWakeDriver<TState>() Host::wake 5 seconds Resumes timers that are due and times out waits whose timeout has passed.
Recovery AddRecoveryDriver<TState>() Host::recover 30 seconds Continues running threads whose process died.
Retention AddRetention() Host::retain 1 hour Deletes completed and ended threads whose latest checkpoint is older than OlderThan.
  • The first round runs when the host starts, so a host that comes back after a crash recovers at once.
  • A round that fills its batch goes again without waiting. The interval is the wait once a round has caught up.
  • A round that fails is logged, counted and tried again after a wait that doubles, up to 5 minutes. A driver never stops the host.
  • Every replica can run every driver. Wakes and recoveries claim each thread with a conditional save, and purging twice deletes nothing more.

The host refuses to start a driver that can’t work. Check these before you deploy:

  • Wake and recovery: a graph registered for the state type (in .NET, AddGraph<TState>), and a store that lists threads. The SQLite, Postgres, Redis and in-memory stores do.
  • Recovery: a graph compiled with CheckpointPolicy.EveryNode. Under the default policy, a crashed run leaves nothing to recover.
  • Recovery: a lease ttl in Runs:Lease (in Rust, passed to the driver with with_config), or a StaleAfter longer than your slowest node. Without leases, the age of the latest checkpoint is the only sign that a run died. See how Strata decides a thread is dead.
  • Retention: an OlderThan. There is no default: you decide how long you keep threads.
  • Retention: a store that purges. The SQLite, Postgres, Redis and in-memory stores do. The store for the Strata Cloud private beta doesn’t.
  • All drivers: an interval above zero, and a batch size from 1 to 1000.

To see the wake driver’s engine call on its own, run demo 19, waits and timers (C#, Rust).

AddHealthChecks() adds two checks to the host’s health check service:

  • strata-checkpoints loads the latest checkpoint of a thread nobody has (strata.health). An answer is healthy. A failure, or no answer within 5 seconds, is unhealthy. The description is the exception’s type, not its message, because a store’s message can hold a connection string.
  • strata-drivers is degraded while any driver’s last round failed, and names those drivers. It is never unhealthy, because a failing driver keeps the host running.

AddHealthChecks(tags, timeout) sets tags and a different timeout. In ASP.NET Core, expose the checks with app.MapHealthChecks("/health"). To read the drivers’ state yourself, inject IStrataDriverStatus.

AddInstrumentation() adds Strata’s activity sources and meters to the OpenTelemetry providers you set up. It creates no provider and exports nothing, so you still add OpenTelemetry and an exporter:

builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing.AddConsoleExporter())
.WithMetrics(metrics => metrics.AddConsoleExporter());

The names, if you wire them by hand (StrataInstrumentation.ActivitySourceNames and StrataInstrumentation.MeterNames):

  • Activity sources: Datalium.Strata, Datalium.Strata.Cloud.
  • Meters: Datalium.Strata, Datalium.Strata.Storage, Datalium.Strata.Cloud, Datalium.Strata.Hosting.

The drivers’ own metrics are strata.host.driver.rounds, by driver and outcome, and strata.host.driver.threads, the threads woken, recovered or purged.

A handler starts a thread with an ID it chooses, or resumes one by ID. The thread lives in the store, so any replica can take the next request about it.

With ASP.NET Core minimal APIs, the handler asks for the runner as a parameter:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddStrata()
.AddSqliteCheckpoints()
.AddHealthChecks()
.AddGraph(_ => Refunds.Build())
.AddWakeDriver<Refund>()
.AddRecoveryDriver<Refund>();
var app = builder.Build();
app.MapPost("/refunds/{id}", async (string id, Refund refund, StrataRunner<Refund> runner) =>
Describe(await runner.RunAsync(id, refund)));
app.MapPost("/refunds/{id}/approve", async (string id, string reviewer, StrataRunner<Refund> runner) =>
Describe(await runner.ResumeAsync(id, HumanSignal.Approve(reviewer))));
app.MapHealthChecks("/health");
app.Run();
static IResult Describe(GraphExecutionResult<Refund> result) => result switch
{
GraphExecutionResult<Refund>.Completed done => Results.Ok(done.State),
GraphExecutionResult<Refund>.Interrupted waiting => Results.Accepted(value: new { waitingAt = waiting.SuspendedNode }),
GraphExecutionResult<Refund>.Failed failed => Results.Conflict(new { error = failed.Message }),
_ => Results.StatusCode(StatusCodes.Status503ServiceUnavailable),
};

The first request returns 202 Accepted with {"waitingAt":"Prepare"}. The approval returns 200 OK with the state, "status":"paid". A second approval of the same thread returns 409 Conflict, because a completed thread can’t be resumed.

The handlers don’t pass the request’s cancellation token to the run. A client that disconnects would otherwise cancel the run and leave the thread cancelled.

When the host stops, each driver starts nothing more and lets the run in flight finish. Nothing is cancelled. A driver that is waiting between rounds stops at once. This covers the runs the drivers start. Runs your own code starts end as your code decides.

The Generic Host waits for the drivers up to its shutdown timeout, 30 seconds by default. If your nodes can take longer, raise it:

builder.Services.Configure<HostOptions>(options => options.ShutdownTimeout = TimeSpan.FromMinutes(2));

If a run outlasts the timeout and the process exits, its thread stays running. With EveryNode and the recovery driver, the next host continues it.