SquidStd.Persistence
Embeddable in-memory entity store with durable binary snapshot + journal (write-ahead log) persistence.
Full state lives in memory (synchronous reads), every mutation is appended to a length+checksum-framed
binary journal, and a periodic snapshot captures all state and trims the journal. On startup the engine
loads the snapshot and replays the journal tail. The engine is serializer-agnostic (via SquidStd's
IDataSerializer/IDataDeserializer) and has no MessagePack or domain dependency.
Install
dotnet add package SquidStd.Persistence
dotnet add package SquidStd.Persistence.MessagePack # recommended binary serializer
Usage (standalone, no bootstrap)
Wire the stack by hand when you are not using SquidStdBootstrap - construct the registry, journal,
snapshot service, and PersistenceService yourself:
using SquidStd.Persistence.Abstractions.Data;
using SquidStd.Persistence.Data;
using SquidStd.Persistence.MessagePack;
using SquidStd.Persistence.Services;
public sealed class Player
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
}
var serializer = new MessagePackDataSerializer();
var registry = new PersistenceEntityRegistry();
registry.Register(new PersistenceEntityDescriptor<Player, int>(
serializer, serializer, typeId: 1, typeName: "Player", schemaVersion: 1, keySelector: p => p.Id));
var config = new PersistenceConfig { SaveDirectory = "./save" };
var journal = new BinaryJournalService(Path.Combine(config.SaveDirectory, config.JournalFileName));
var snapshot = new SnapshotService(config.SaveDirectory, config.SnapshotFileSuffix);
var persistence = new PersistenceService(registry, journal, snapshot, config);
await persistence.InitializeAsync(); // load snapshot + replay journal
var players = persistence.GetStore<Player, int>();
await players.UpsertAsync(new Player { Id = 1, Name = "Bob" }); // appended to the journal
await persistence.SaveSnapshotAsync(); // snapshot + trim journal
var bob = await players.GetByIdAsync(1); // detached clone
Manual DI registration (without RegisterPersistence)
container.RegisterPersistedEntity<Player, int>(typeId: 1, typeName: "Player", schemaVersion: 1, p => p.Id);
container.ApplyPersistedEntityRegistrations(); // builds descriptors into IPersistenceEntityRegistry
Only needed when the rest of the stack (registry, journal, snapshot, lifecycle service) is assembled by
hand instead of through RegisterPersistence(), which applies these registrations itself - do not call
ApplyPersistedEntityRegistrations() when RegisterPersistence() is in use.
Bootstrap registration
The one-call path for SquidStdBootstrap apps: register a serializer, register the persistence stack,
then declare the persisted entities.
using SquidStd.Persistence.Abstractions.Interfaces.Persistence;
using SquidStd.Persistence.Extensions;
using SquidStd.Persistence.MessagePack.Extensions;
bootstrap.ConfigureServices(c =>
{
c.RegisterMessagePackSerializer(); // or RegisterDataSerializer() for JSON
c.RegisterPersistence(); // or RegisterPersistence(new PersistenceConfig { ... })
c.RegisterPersistedEntity<Player, int>(1, "Player", 1, p => p.Id);
return c;
});
// after StartAsync: snapshot loaded, journal replayed, autosave running
var players = bootstrap.Resolve<IPersistenceService>().GetStore<Player, int>();
- Serializer prerequisite: register a serializer (
RegisterMessagePackSerializer(),RegisterDataSerializer()for JSON, orRegisterYamlDataSerializer()for human-readable saves) beforeRegisterPersistence()- it throwsInvalidOperationExceptionotherwise, so a missing serializer fails fast instead of at first use. - Config source:
RegisterPersistence()binds thepersistenceYAML section by default; pass an explicitPersistenceConfiginstance to skip the file entirely for that section (it is then ignored). Either way,SaveDirectorydefaults to the managedsavedirectory under the bootstrap root when left blank. - Lifecycle:
IPersistenceServiceis registered as a lifecycle service - the snapshot loads and the journal replays at start, autosave runs while the bootstrap is up, and a final snapshot is written at stop.
Seeding a fresh store
Seeders populate initial data into a brand-new save (one that has no snapshot and no journal). They run after snapshot load and journal replay, in registration order, and their writes go through the normal entity stores - so subsequent boots are no longer fresh and seeders never run again. If a seeder exception occurs, startup fails immediately (fail-fast). A seeder that performs no writes leaves the save fresh, so it runs again at every boot.
Delegate seeder
Register an inline seeding callback:
bootstrap.ConfigureServices(c =>
{
c.RegisterPersistence();
c.RegisterPersistedEntity<Player, int>(1, "Player", 1, p => p.Id);
// Inline delegate seeder
c.RegisterPersistenceSeeder(async (persistence, ct) =>
{
var players = persistence.GetStore<Player, int>();
await players.UpsertAsync(new Player { Id = 1, Name = "Admin" }, ct);
});
return c;
});
Class seeder
Implement IPersistenceSeeder and register it by type:
public sealed class AdminPlayerSeeder : IPersistenceSeeder
{
public async ValueTask SeedAsync(IPersistenceService persistence, CancellationToken cancellationToken = default)
{
var players = persistence.GetStore<Player, int>();
await players.UpsertAsync(new Player { Id = 1, Name = "Admin" }, cancellationToken);
}
}
bootstrap.ConfigureServices(c =>
{
c.RegisterPersistence();
c.RegisterPersistedEntity<Player, int>(1, "Player", 1, p => p.Id);
c.RegisterPersistenceSeeder<AdminPlayerSeeder>();
return c;
});
Key semantics
- Fresh-save detection: Seeders run only when the save is brand-new (neither snapshot nor journal existed before). An emptied-but-old save (entities removed through the normal store API) is not fresh at the immediately following boot - the journal still records the removals. Once a snapshot captures the fully-emptied state (autosave or clean stop), the save becomes indistinguishable from a brand-new one and seeders run again at the next boot. Deleting the save files from disk also makes the next boot fresh.
- No re-runs: Since writes go through the normal stores, subsequent boots record the seeded state in the snapshot and journal. The save is no longer fresh.
- Constructor constraints: Class-form seeders must not constructor-inject
IPersistenceService(it causes circular resolution). Receive the service as theSeedAsyncparameter instead. - Execution order: Seeders run in registration order. Multiple seeders can be registered via chained
RegisterPersistenceSeeder()calls; plugins interleave naturally. - Fail-fast behavior: If a seeder exception occurs, startup fails immediately and no remaining seeders run. If an earlier seeder's writes reach the journal before a later seeder fails, the save is no longer fresh at the next boot and the remaining seeders never run - prefer a single seeder, or make the set safe to lose a tail.
Key types
| Type | Purpose |
|---|---|
PersistenceService |
Lifecycle: load + replay, autosave, GetStore<T,TKey>(). |
IEntityStore<TEntity,TKey> |
In-memory CRUD; reads clone, writes journal. |
PersistenceEntityDescriptor<T,TKey> |
Serializer-injected descriptor (serialize/clone/key). |
PersistenceEntityRegistry |
Maps typeId ↔ descriptor; freezes after registration. |
BinaryJournalService |
Append-only framed binary WAL with tail-corruption recovery. |
SnapshotService |
Atomic per-type binary snapshot files with payload checksum. |
RegisterPersistedEntity<T,TKey>() |
DI helper recording an entity for descriptor construction. |
Durability
PersistenceConfig.DurabilityMode selects how writes reach disk. Buffered (default) flushes to the OS
cache - fast, and safe across a process crash. Durable fsyncs each journal append and the snapshot temp
file before its atomic rename, so committed data survives power loss. Pass it through when constructing the
services: new BinaryJournalService(path, config.DurabilityMode) and
new SnapshotService(dir, suffix, config.DurabilityMode). (.NET has no portable directory fsync, so the
guarantee is per-file content durability plus atomic rename.)
Related
- Tutorial: Persistence
License
MIT - part of SquidStd.