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.

Use SQLite

The SQLite store keeps your threads in one file on the local disk. In this guide, you open the checkpoint and history stores on a file, turn on group commit for many concurrent runs, configure the store in a host, and learn what to do when several processes share the file. You also back the file up, read it with the strata CLI, and share it between C# and Rust.

Choose a store compares SQLite with the other stores. Read it first if you haven’t picked one yet.

  • Add Strata to your project, as Installation shows. In .NET, the SQLite stores are in Datalium.Strata.Storage, which Datalium.Strata.Hosting brings. They are built on EF Core’s SQLite provider, Microsoft.EntityFrameworkCore.Sqlite. In Rust, they are in the datalium-strata-memory crate, which compiles SQLite in. You don’t install SQLite in either language.
  • You need a compiled graph. The examples use the refund graph from Hosting: Prepare, then an interrupt boundary, then Pay.
using Datalium.Strata.Storage;
using Datalium.Strata.Storage.Memory;
using Microsoft.EntityFrameworkCore;
var path = "strata.db";
using var checkpoints = new EfCoreCheckpointStore(
new CheckpointDbContext(new DbContextOptionsBuilder<CheckpointDbContext>()
.UseSqlite($"Data Source={path}").Options),
options: new SqliteStoreOptions { GroupCommit = new GroupCommitOptions() });
using var history = new EfCoreHistoryStore(
new HistoryDbContext(new DbContextOptionsBuilder<HistoryDbContext>()
.UseSqlite($"Data Source={path}").Options));

The constructor opens the file and prepares its tables before it returns. Its other parameters are optional: ownedConnection, a SqliteConnection the store disposes with itself, and timeProvider, the clock that thread leases expire on.

A missing file is created. The two stores can share one file, as here. Each store keeps one connection and makes its calls one at a time, so open each store once per process and share it between runs.

SqliteStoreOptions has three options. Each is off until you set it:

C# Rust What it does
GroupCommit with_group_commit Writes concurrent saves together. See Run many threads at once.
Deltas with_deltas Stores a running checkpoint as the changes to the one before.
Offload with_offload Stores large state values once, in a blob store.

State explains deltas and offload. In SQLite they mostly shrink the file: a save’s cost is the disk’s sync, so in the benchmarks, with a 64 KiB value in the state, deltas made saves 13 to 22% faster and offload added nothing more (see Performance). The history store takes no options.

Opening a file runs a migration in one SQLite BEGIN IMMEDIATE transaction, so two processes that open a new file at once don’t collide. It creates these tables, or adds what an older version of Strata left out:

Table What it holds
checkpoints The checkpoint log: one row per save.
checkpoint_threads One row per thread, kept current by a trigger. Thread listing reads it.
checkpoint_leases The thread leases.
checkpoint_blobs Offloaded values, when offload uses the store’s own file.
strata_schema_version The version of the checkpoint tables. In 0.9.0-preview.1 it is 2.

The SQLite stores always migrate when they open a file. Unlike the Postgres stores, they have no switch to turn that off. Migrations only go forward. A file that a newer version of Strata wrote is refused, and left as it is:

database is at checkpoints schema version 3, and this build knows 2: upgrade the runtime

In C# the constructor throws InvalidOperationException with this message. In Rust, open returns it as a CheckpointStoreError.

The history store is simpler. It creates its one table if it’s missing, and records no version.

Without group commit, each save is its own transaction and its own sync to disk. Saves then take turns, however many runs you start. With group commit, the saves that arrive while a write is under way wait, and then go in together: one transaction and one sync for all of them. A save still returns only after its transaction commits, so it is as durable as before.

You turned it on in Open the stores. Then start runs concurrently against the one store:

var runs = Enumerable.Range(1, 64).Select(i =>
graph.RunAsync($"refund-{i}", new Refund($"customer-{i}", 10m * i, "new"), checkpoints));
await Task.WhenAll(runs);
var page = await checkpoints.ListThreadsAsync(new ThreadQuery
{
States = new HashSet<CheckpointThreadState> { CheckpointThreadState.Interrupted },
});
Console.WriteLine($"{page.Threads.Count} refunds wait for approval");
64 refunds wait for approval

When it helps, and what it costs, from the benchmarks:

  • Many writers gain the most. Without group commit, SQLite saved about 415 checkpoints a second whatever the number of writers. With it, 64 writers saved 6,433 a second in Rust and 6,362 in .NET.
  • One writer gains nothing. It saved about as many with group commit as without: 409 against 415 a second in Rust, and 421 against 410 in .NET.
  • It works inside one store object. Two processes, or two store objects on one file, each have their own queue, and their writes still take turns for the file.

A flush takes at most MaxBatch saves (C#) or with_max_batch (Rust), 256 by default. A SQLite store runs one flush at a time, because it has one connection. It ignores MaxConcurrentFlushes. Only saves are batched: loads, listings, leases and purges aren’t.

In a host, you set the file and the options in settings instead of code. Hosting explains the rest of the settings.

{
"Strata": {
"Sqlite": {
"Path": "strata.db",
"GroupCommit": { "Enabled": true }
}
}
}
builder.Services.AddStrata()
.AddSqliteCheckpoints() // Strata:Sqlite
.AddSqliteHistory(); // the same file

The host opens the file when it starts. Without Strata:Sqlite:Path, the start fails with Strata's SQLite store has no file: set Strata:Sqlite:Path.

The GroupCommit section also takes MaxBatch (max_batch in Rust). The Deltas and Offload sections turn on with Enabled (enabled) in the same way.

Several processes can open the same file. SQLite lets one write through at a time, and a writer waits for the file’s lock. Strata doesn’t set SQLite’s journal mode or its lock timeout, so you get SQLite’s default rollback journal and the driver’s wait:

  • .NET waits up to the connection’s command timeout, 30 seconds by default. Change it with Default Timeout in the connection string, such as Data Source=strata.db;Default Timeout=60.
  • Rust waits 5 seconds, the rusqlite default. The store takes a path, not a connection, so you can’t change it.

A write that waits longer fails with database is locked. Neither store retries it.

Two things to set up when more than one process can run the same thread:

  • Use thread leases. If two processes make a plain save to the same thread at once, one of them fails. The engine’s resumes and leased saves settle such a race safely, so turn on leases for every run: Strata:Runs:Lease:Ttl in .NET or runs.lease.ttl in Rust (see Hosting). Durable execution explains leases.
  • Keep the processes on one machine. Leases expire on each process’s own clock, which the processes of one machine share. SQLite also relies on file locks, which network file systems often don’t provide reliably. Put the file on a local disk.

When writers on several machines need one store, or SQLite’s turn-taking limits you, use Postgres.

Don’t copy the file while a process writes to it. A plain copy can catch a transaction half-written. Make the copy through SQLite instead, which takes a consistent snapshot while your application runs. From any SQLite connection, or the sqlite3 shell:

VACUUM INTO 'backup.db'

The sqlite3 shell’s .backup command does the same. To restore, stop every process that uses the file, then put the copy in its place.

In .NET, Microsoft.Data.Sqlite pools connections, so the file stays open after you dispose the store. Call SqliteConnection.ClearAllPools() before you move or delete the file.

Stores never delete old threads on their own. Retention and storage size explains purging, which keeps the file from growing.

The strata CLI reads a SQLite file by its path:

Terminal window
strata thread list --storage strata.db --status interrupted
strata checkpoint log refund-1 --storage strata.db
+-----------+--------------+-------------+-------------+-------------+----------------------+
| Thread ID | Current Node | Sequence ID | State | Waiting For | Updated At |
+-----------+--------------+-------------+-------------+-------------+----------------------+
| refund-9 | Prepare | 1 | interrupted | - | 2026-10-30T14:48:22Z |
+-----------+--------------+-------------+-------------+-------------+----------------------+
...
#0 [running] node=- -> Prepare at=2026-10-30T14:48:22Z
#1 [interrupted] node=Prepare -> Pay at=2026-10-30T14:48:22Z

The CLI opens the file the way your application does, so it migrates the file too. A CLI that is newer than your application can upgrade the file when it opens it, and your application then refuses the file. Upgrade the CLI and your application together. The CLI reference has every command.

Both engines write the same checkpoint tables and the same schema version, so a thread that one engine paused can be resumed by the other. The state’s JSON field names must match in both languages. See Architecture. Demo 09-cross-language-interop hands a thread across this way (C#, Rust).

History isn’t shared. The .NET history store writes a table named HistoryRecords and saves roles as "User". The Rust one writes history_records and saves "user". On a file that .NET wrote, the Rust history store finds its own empty table:

rust sees 64 threads in the .NET file
rust sees 0 history messages for refund-1 in the .NET file

Keep history that both engines read in Postgres or Redis. See Known limitations.