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.
Before you start
Section titled “Before you start”- Install Strata for your language. See Installation.
- Know the thread states (
completed,interruptedand so on). See Threads and checkpoints. - To create the store itself, see Use SQLite, Use Postgres or Use Redis.
Delete finished threads
Section titled “Delete finished threads”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) |
Purge on demand
Section titled “Purge on demand”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;}use chrono::{DateTime, Duration, Utc};use datalium_strata::checkpoint::{CheckpointRetention, CheckpointStore, PurgeStates};
async fn purge_all(store: &impl CheckpointRetention, older_than: DateTime<Utc>) -> usize { const BATCH: usize = 100; let mut total = 0; loop { let purged = store .purge(older_than, &PurgeStates::finished(), BATCH) .await .expect("the purge succeeds"); total += purged; if purged < BATCH { return total; } }}
// In main, with `store` an Arc<SqliteCheckpointStore>:let older_than = Utc::now() - Duration::days(30);println!("Older than 30 days: {} threads purged", purge_all(store.as_ref(), older_than).await);
// A cutoff just ahead of now, so the threads this example just finished count as old.let cutoff = Utc::now() + Duration::seconds(1);println!("Older than now: {} threads purged", purge_all(store.as_ref(), cutoff).await);
for id in ["order-1", "order-4"] { match store.load(id).await.expect("the load succeeds") { Some(latest) => println!("{id}: kept ({:?})", latest.status), None => println!("{id}: gone"), }}Both print:
Older than 30 days: 0 threads purgedOlder than now: 3 threads purgedorder-1: goneorder-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.
What a purge takes
Section titled “What a purge takes”A purge looks at each thread’s latest checkpoint:
- Its state must be one you named. By default that is
completedandended: threads that won’t run again. You can also namefailedandcancelled, but a failed thread can still be resumed until you purge it. If you nameinterrupted,runningorresumed, 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.
Purge on a schedule
Section titled “Purge on a schedule”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 checkpointIn strata.json:
{ "sqlite": { "path": "strata.db" }, "retention": { "interval": "1h", "older_than": "30d" }}use std::sync::Arc;
use datalium_strata::CancellationToken;use datalium_strata_host::{Host, StrataSettings};
let settings: StrataSettings = serde_json::from_str(&std::fs::read_to_string("strata.json")?)?;let store = Arc::new(settings.sqlite.open()?);let host = Host::new().retain(store.clone(), settings.retention.clone());host.run(CancellationToken::new()).await?;Without older_than, run returns an error before any driver starts:
HostError("the retention driver needs an older_than: 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.
Let Redis expire finished threads
Section titled “Let Redis expire finished threads”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.
What retention doesn’t delete
Section titled “What retention doesn’t delete”- 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.
Shrink the checkpoints you keep
Section titled “Shrink the checkpoints you keep”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.
Turn on deltas and offload
Section titled “Turn on deltas and offload”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);use std::sync::Arc;
use datalium_strata::checkpoint::CheckpointStore;use datalium_strata::graph::{CheckpointPolicy, CompileOptions, StateGraph};use datalium_strata_memory::checkpoint::SqliteCheckpointStore;use datalium_strata_memory::{Deltas, Offload, SqliteStoreOptions};use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]struct Report { evidence: String, step: u32,}
#[tokio::main]async fn main() { let options = SqliteStoreOptions::new() .with_deltas(Deltas::new()) // a whole row every 16 .with_offload(Offload::new()); // values of 64 KiB or more, into this file let store = Arc::new( SqliteCheckpointStore::open_with("reports.sqlite", options).expect("the file opens"), );
let graph = StateGraph::<Report>::new() .add_node("Gather", |r: Report| Report { evidence: "x".repeat(100_000), ..r }) .add_node("Analyse", |r: Report| Report { step: r.step + 1, ..r }) .add_node("Draft", |r: Report| Report { step: r.step + 1, ..r }) .add_node("Review", |r: Report| Report { step: r.step + 1, ..r }) .add_edge("Gather", "Analyse") .add_edge("Analyse", "Draft") .add_edge("Draft", "Review") .compile_with(CompileOptions::new().with_checkpointing(CheckpointPolicy::EveryNode)) .expect("the graph is valid");
let initial = Report { evidence: String::new(), step: 0 }; graph.run("report-1", initial, store.clone()).await;
let latest = store.load("report-1").await.expect("the load succeeds").expect("a checkpoint"); println!("Loaded {:?}: {} bytes of state", latest.status, latest.frozen_state.to_string().len());}It prints:
Loaded Completed: 100024 bytes of stateThe 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 241 running 1.9.0 1922 running 1.8.0 813 running 1.8.0 814 completed 1.9.0 194
hash length(content)sha256:55bb3c98333f4f20aa23ec49458f604541b9b5413cdf3b90ad17ceae5653b036 100002Row 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.
How deltas work
Section titled “How deltas work”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) |
How offload works
Section titled “How offload works”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.
Use a filesystem blob store
Section titled “Use a filesystem blob store”To keep blobs out of the database, name a folder:
var options = new SqliteStoreOptions{ Deltas = new DeltaOptions(), Offload = new OffloadOptions { Blobs = new FileSystemCheckpointBlobStore("blobs") },};use datalium_strata_memory::FileSystemBlobStore;
let options = SqliteStoreOptions::new() .with_deltas(Deltas::new()) .with_offload(Offload::new().with_blobs(Arc::new(FileSystemBlobStore::new("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/55bb3c98333f4f20aa23ec49458f604541b9b5413cdf3b90ad17ceae5653b036The 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:
strata checkpoint log report-1 --storage reports.sqlitestrata: query failed: checkpoint 'report-1' #1 refers to blob sha256:55bb3c98333f4f20aa23ec49458f604541b9b5413cdf3b90ad17ceae5653b036 at 'Evidence', which the blob store doesn't haveWith the folder, it reads the thread:
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:52ZUpgrade every reader first
Section titled “Upgrade every reader first”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.
Configure it in a host
Section titled “Configure it in a host”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.
{ "sqlite": { "path": "strata.db", "deltas": { "enabled": true }, "offload": { "enabled": true, "path": "blobs" } }, "retention": { "interval": "1h", "older_than": "30d" }}The same deltas and offload sections go under postgres and redis, and open() applies them. To use your own blob store, open the store in code with Offload::with_blobs.
FullEvery, CachedThreads and Threshold (full_every, cached_threads, threshold) are settings too, with the defaults above.
Keep state small in the first place
Section titled “Keep state small in the first place”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.
Related
Section titled “Related”- State: what deltas and offload store, and why small state matters.
- Choose a store: every store and its options.
- Host Strata in a service: the retention driver and the other drivers.
- CLI reference: reading offloaded values with
--blobs. - Known limitations: what retention doesn’t cover.