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.

Control storage size

A checkpoint store keeps every checkpoint of every thread until you delete it, and each row holds the whole state as JSON. Under the EveryNode policy, a ten-node run of a 100 KB state writes about 1 MB. In this guide you purge finished threads, store running checkpoints as deltas, and move large values into a blob store. The examples use the SQLite store. The Postgres and Redis stores take the same options.

Every built-in self-hosted store (SQLite, Postgres, Redis and in-memory) has a purge method on its retention port. The engine never calls it: you decide how long to keep threads.

C# Rust
Method PurgeAsync(DateTimeOffset olderThan, IReadOnlySet<CheckpointThreadState>? states = null, int limit = 1000, CancellationToken cancellationToken = default) purge(older_than: DateTime<Utc>, states: &PurgeStates, limit: usize)
Default states null, which means CheckpointRetention.Finished PurgeStates::finished()
Other states a set of CheckpointThreadState PurgeStates::of([ThreadState::Failed, ...])
Returns how many threads it purged (int) how many threads it purged (usize)

This example runs four orders through a graph that stops at Check for approval. It approves three, so they complete. The fourth is still waiting. Then it purges in batches of 100, calling the purge again while a batch comes back full:

using Datalium.Strata.Interruption;
var olderThan = DateTimeOffset.UtcNow.AddDays(-30);
Console.WriteLine($"Older than 30 days: {await PurgeAllAsync(store, olderThan)} threads purged");
// A cutoff just ahead of now, so the threads this example just finished count as old.
Console.WriteLine($"Older than now: {await PurgeAllAsync(store, DateTimeOffset.UtcNow.AddSeconds(1))} threads purged");
foreach (var id in new[] { "order-1", "order-4" })
{
var latest = await store.LoadAsync(id);
Console.WriteLine($"{id}: {(latest is null ? "gone" : $"kept ({latest.Status})")}");
}
static async Task<int> PurgeAllAsync(ICheckpointRetention store, DateTimeOffset olderThan)
{
const int batch = 100;
int total = 0, purged;
do
{
purged = await store.PurgeAsync(olderThan, limit: batch);
total += purged;
}
while (purged == batch);
return total;
}

Both print:

Older than 30 days: 0 threads purged
Older than now: 3 threads purged
order-1: gone
order-4: kept (Interrupted)

The 30-day purge takes nothing, because every thread is new. The second purge takes the three completed orders and keeps order-4, because a waiting thread isn’t in the default states.

A purge looks at each thread’s latest checkpoint:

  • Its state must be one you named. By default that is completed and ended: threads that won’t run again. You can also name failed and cancelled, but a failed thread can still be resumed until you purge it. If you name interrupted, running or resumed, you delete threads that are still in use, and a waiting approval is gone.
  • Its time must be before olderThan, compared to the second. The cutoff itself is excluded.

The oldest threads go first, up to limit (1 to 1000). A purge deletes a thread whole: every checkpoint, its row in the thread listing, its lease, and its offloaded values. It never deletes a thread that a live lease holds, or one that changed while it was being purged. A purged thread ID starts over at sequence 0 if you run it again.

In a service, let the host’s retention driver call the purge for you: AddRetention() in .NET, Host::retain in Rust. It runs a round every Interval (an hour by default), purges in batches of BatchSize (1000) until a batch comes back short, and takes the States you give, or completed and ended. OlderThan has no default, and the host doesn’t start without it:

In appsettings.json:

{
"Strata": {
"Sqlite": { "Path": "strata.db" },
"Retention": { "Interval": "01:00:00", "OlderThan": "30.00:00:00" }
}
}
builder.Services.AddStrata()
.AddSqliteCheckpoints()
.AddRetention();

Without OlderThan, starting the host throws:

InvalidOperationException: the retention driver needs Strata:Retention:OlderThan: how long a thread is kept after its latest checkpoint

Every replica can run the driver: purging twice deletes nothing more. Host Strata in a service covers the drivers’ intervals, back-off and status.

The Redis store can expire threads by itself instead. Set FinishedThreadTtl in RedisStoreOptions (C#), or call with_finished_thread_ttl (Rust). A save that leaves a thread completed or ended gives its keys that time to live, offloaded values included, and a save in any other state clears it. It is off by default, and like a default purge it skips resumed threads. See Use Redis.

  • History. History stores have no retention. Delete old sessions in the database yourself. See Known limitations.
  • Strata Cloud. The store for the Strata Cloud private beta has no purge, and the retention driver refuses to start with it. See Use Strata Cloud.
  • Live threads. A purge removes whole threads. To shrink the threads you keep, use deltas and offload.

Delta checkpoints and value offload are store options, off by default. State explains what each one does. Both work inside the store: the engine and your nodes still see the whole state.

This example turns both on for a SQLite file. Its graph gathers 100 KB of evidence in its first node, and three more nodes each change a counter:

using Datalium.Strata.Graph;
using Datalium.Strata.Storage;
using Datalium.Strata.Storage.Memory;
using Microsoft.EntityFrameworkCore;
var options = new SqliteStoreOptions
{
Deltas = new DeltaOptions(), // a whole row every 16
Offload = new OffloadOptions(), // values of 64 KiB or more, into this file
};
using var store = new EfCoreCheckpointStore(
new CheckpointDbContext(new DbContextOptionsBuilder<CheckpointDbContext>()
.UseSqlite("Data Source=reports.sqlite").Options),
options: options);
var graph = new StateGraph<Report>()
.AddNode("Gather", r => r with { Evidence = new string('x', 100_000) })
.AddNode("Analyse", r => r with { Step = r.Step + 1 })
.AddNode("Draft", r => r with { Step = r.Step + 1 })
.AddNode("Review", r => r with { Step = r.Step + 1 })
.AddEdge("Gather", "Analyse")
.AddEdge("Analyse", "Draft")
.AddEdge("Draft", "Review")
.Compile(new CompilationOptions { Checkpointing = CheckpointPolicy.EveryNode });
await graph.RunAsync("report-1", new Report("", 0), store);
var latest = await store.LoadAsync("report-1");
Console.WriteLine($"Loaded {latest!.Status}: {latest.FrozenState!.ToJsonString().Length} bytes of state");
public sealed record Report(string Evidence, int Step);

It prints:

Loaded Completed: 100024 bytes of state

The load returns the whole state. To see what the store wrote, query the file with any SQLite client:

SELECT sequence, status, schema_version, length(frozen_state) FROM checkpoints WHERE thread_id = 'report-1';
SELECT hash, length(content) FROM checkpoint_blobs;

Both languages write the same rows:

sequence status schema_version length(frozen_state)
0 running 1.7.0 24
1 running 1.9.0 192
2 running 1.8.0 81
3 running 1.8.0 81
4 completed 1.9.0 194
hash length(content)
sha256:55bb3c98333f4f20aa23ec49458f604541b9b5413cdf3b90ad17ceae5653b036 100002

Row 0 is whole and plain: the evidence is still empty. Row 1 names the evidence’s blob instead of holding it. Rows 2 and 3 are deltas that hold only the changed counter, such as "set":{"step":1} ("Step" in C#). Row 4 is completed, so it is whole, but the evidence is still a reference. The 100 KB value is stored once, in checkpoint_blobs. Without the options, rows 1 to 4 would each hold it.

In the benchmark results, a state with a 64 KiB value that never changes stored 65.6 KB per save whole, 6.6 KB with deltas, and 3.4 to 3.7 KB with the value offloaded.

A store with deltas writes a running checkpoint as the top-level keys that changed or are new (set) and the keys that went (unset), compared with the thread’s previous row. It writes the row whole instead when:

  • the checkpoint isn’t running (interrupted, failed, cancelled, completed or ended),
  • the row is the FullEvery-th since the last whole one,
  • the store doesn’t remember the thread’s previous row, for example the first save after a restart, or
  • another writer appended to the thread in the meantime.

A load reads the latest row and the deltas back to the last whole row, so FullEvery caps how many rows a load reads. Only running checkpoints become deltas, so deltas help graphs compiled with EveryNode.

Option C# (DeltaOptions) Rust (Deltas) Default
A whole row at least every FullEvery with_full_every(u32) 16 (at least 2)
Threads whose latest row the store remembers, least recently used forgotten first CachedThreads with_cached_threads(usize) 10,000 (at least 1)

A store with offload moves each top-level value whose JSON is at least Threshold bytes into a blob store, and keeps its hash in the row. A blob belongs to one thread, and is not shared with other threads.

Option C# (OffloadOptions) Rust (Offload) Default
Size from which a value is offloaded Threshold with_threshold(usize) 64 KiB (at least 1)
Where blobs go Blobs (ICheckpointBlobStore) with_blobs(Arc<dyn BlobStore>) the checkpoint store’s own database

The blob stores that come with Strata:

Blob store C# Rust
The checkpoint store’s own database: a checkpoint_blobs table in SQLite and Postgres, a hash per thread in Redis leave Blobs null don’t call with_blobs
A folder FileSystemCheckpointBlobStore(root) FileSystemBlobStore::new(root)
Memory, for tests InMemoryCheckpointBlobStore InMemoryBlobStore::new()

For anything else, such as S3, implement the port: ICheckpointBlobStore (PutAsync, GetAsync, DeleteThreadAsync) or BlobStore (put, get, delete_thread). Strata has no cloud blob adapters.

In the store’s own database, a blob is written in the same transaction or script as its row. In another blob store, it is written first, so a save that then fails can leave an orphan blob. It goes when its thread is purged.

A load checks each blob against its hash, and fails, naming the blob, when it is missing. The store reads blobs only from where its options say, so keep naming a blob store for as long as rows refer to it. To stop offloading new values but keep reading old ones, set the threshold to int.MaxValue / usize::MAX.

To keep blobs out of the database, name a folder:

var options = new SqliteStoreOptions
{
Deltas = new DeltaOptions(),
Offload = new OffloadOptions { Blobs = new FileSystemCheckpointBlobStore("blobs") },
};

Each thread gets a subfolder named by the hex of its ID, and each blob a file named by its hash. The same run writes one file:

blobs/7265706f72742d31/55bb3c98333f4f20aa23ec49458f604541b9b5413cdf3b90ad17ceae5653b036

The strata CLI reads blobs from the store’s own database unless you give it the folder with --blobs (see the CLI reference). Without it, a command that reads state fails:

Terminal window
strata checkpoint log report-1 --storage reports.sqlite
strata: query failed: checkpoint 'report-1' #1 refers to blob sha256:55bb3c98333f4f20aa23ec49458f604541b9b5413cdf3b90ad17ceae5653b036 at 'Evidence', which the blob store doesn't have

With the folder, it reads the thread:

Terminal window
strata checkpoint log report-1 --storage reports.sqlite --blobs blobs
#0 [running] node=- -> Gather at=2026-10-30T14:49:52Z
#1 [running] node=Gather -> Analyse at=2026-10-30T14:49:52Z
#2 [running] node=Analyse -> Draft at=2026-10-30T14:49:52Z
#3 [running] node=Draft -> Review at=2026-10-30T14:49:52Z
#4 [completed] node=Review -> - at=2026-10-30T14:49:52Z

A delta row has schema_version 1.8.0, and a row with blobs 1.9.0. A runtime that doesn’t know these versions refuses the rows. So upgrade every process that reads the store, the strata CLI included, before any writer turns either option on. A current store reads encoded rows whatever its own options say, and they keep loading if you turn the options off later.

A current store also moves a SQL database’s checkpoints schema version to 2 when it opens it, offload or not, and an older runtime then refuses the database. The format is in schema/README.md.

In a host, turn on deltas and offload in the store’s settings section. Each part has an Enabled switch. Offload:Path (Rust offload.path) puts blobs in a folder instead of the database:

{
"Strata": {
"Sqlite": {
"Path": "strata.db",
"Deltas": { "Enabled": true },
"Offload": { "Enabled": true, "Path": "blobs" }
},
"Retention": { "Interval": "01:00:00", "OlderThan": "30.00:00:00" }
}
}

The same Deltas and Offload sections go under Strata:Postgres and Strata:Redis. To use your own blob store, register an ICheckpointBlobStore in the container instead of setting Path. The host refuses both at once.

FullEvery, CachedThreads and Threshold (full_every, cached_threads, threshold) are settings too, with the defaults above.

Deltas and offload make a large state cheaper to store, but not free: every node still serializes the whole state, and a load reads a chain of rows or a blob. The best fix is a smaller state. Keep documents, search results and evidence in your own storage, and put their IDs in the state. See Keep state small.