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.
Before you start
Section titled “Before you start”- Add Strata to your project, as Installation shows. In .NET, the SQLite stores are in
Datalium.Strata.Storage, whichDatalium.Strata.Hostingbrings. They are built on EF Core’s SQLite provider,Microsoft.EntityFrameworkCore.Sqlite. In Rust, they are in thedatalium-strata-memorycrate, 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, thenPay.
Open the stores
Section titled “Open the stores”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.
use std::sync::Arc;
use datalium_strata_memory::checkpoint::SqliteCheckpointStore;use datalium_strata_memory::history::SqliteHistoryStore;use datalium_strata_memory::{GroupCommit, SqliteStoreOptions};
let path = "strata.db";let checkpoints = Arc::new(SqliteCheckpointStore::open_with( path, SqliteStoreOptions::new().with_group_commit(GroupCommit::new()),)?);let history = SqliteHistoryStore::open(path)?;open(path) is the same without options. Both open the file and prepare its tables before they return.
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.
How the store sets up the file
Section titled “How the store sets up the file”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 runtimeIn 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.
Run many threads at once
Section titled “Run many threads at once”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");let mut runs = Vec::new();for i in 1..=64 { let (graph, store) = (graph.clone(), checkpoints.clone()); runs.push(tokio::spawn(async move { let refund = Refund { customer: format!("customer-{i}"), amount: 10.0 * i as f64, status: "new".into() }; graph.run(&format!("refund-{i}"), refund, store).await }));}for run in runs { run.await?;}
let query = ThreadQuery::default().with_states([ThreadState::Interrupted]);let page = checkpoints.list_threads(&query).await?;println!("{} refunds wait for approval", page.threads.len());Here graph is an Arc<CompiledGraph<Refund>>.
64 refunds wait for approvalWhen 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.
Configure it in a host
Section titled “Configure it in a host”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 fileThe 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.
{ "sqlite": { "path": "strata.db", "group_commit": { "enabled": true } }}let text = std::fs::read_to_string("strata.json")?;let settings: StrataSettings = serde_json::from_str(&text)?;let checkpoints = settings.sqlite.open()?;let history = settings.sqlite.open_history()?;Without sqlite.path, open fails with Strata's SQLite store has no file: set 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 on one machine
Section titled “Several processes on one machine”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 Timeoutin the connection string, such asData Source=strata.db;Default Timeout=60. - Rust waits 5 seconds, the
rusqlitedefault. 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:Ttlin .NET orruns.lease.ttlin 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.
Back up the file
Section titled “Back up the file”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.
Read it with the CLI
Section titled “Read it with the CLI”The strata CLI reads a SQLite file by its path:
strata thread list --storage strata.db --status interruptedstrata 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:22ZThe 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.
Share the file between C# and Rust
Section titled “Share the file between C# and Rust”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 filerust sees 0 history messages for refund-1 in the .NET fileKeep history that both engines read in Postgres or Redis. See Known limitations.
Related
Section titled “Related”- Choose a store: SQLite against the other stores.
- Threads and checkpoints: what the store keeps.
- Hosting: the host’s drivers, health checks and settings.
- Retention and storage size: purging old threads.
- Demo 05-hitl-interruption resumes threads from a SQLite file after a restart (C#, Rust).