Autosave
The Autosave system provides automatic dirty-tracking and batched database writes for vault models. Instead of saving to the database on every change, you mark entities as dirty and the framework flushes them in batches on a configurable interval.
Why Autosave?
Without autosave, saving 1,000 players every few seconds means 1,000 individual INSERT ... ON CONFLICT UPDATE queries. With autosave, those become ~10 batched queries of 100 entities each — 50x fewer database round-trips.
| Approach | DB writes for 1,000 players | Latency per save |
|---|---|---|
| Direct vault save | 1,000 queries | ~5ms each |
| Autosave (batch 100) | 10 queries | ~5ms each |
| Total time | ~5 seconds | ~50ms |
The Pattern: Autosave + Vault
With autosave, the recommended pattern is:
IAutosaveService<T>— your daily driver for all runtime reads and writes (cache-first, batched DB flush)IVault<T>— only for initial queries and creating brand new records
[Service]
public class CharacterService
{
private readonly IAutosaveService<PlayerVault> _autosave; // Runtime read/write
private readonly IVault<PlayerVault> _vault; // Queries and creation
public CharacterService(
IAutosaveService<PlayerVault> autosave,
IVault<PlayerVault> vault)
{
_autosave = autosave;
_vault = vault;
}
// Player joins — load from cache (or DB fallback)
public async Task<PlayerVault?> OnPlayerJoin(string playerId)
{
// Cache-first: returns cached version if available, otherwise queries DB
return await _autosave.LoadAsync(playerId);
}
// Player moves — hot path, NO database hit
public void OnPlayerMove(PlayerVault player, int x, int y)
{
player.X = x;
player.Y = y;
_autosave.MarkDirty(player, player.StorageId); // Saves to cache only
// DB write happens automatically on the next flush interval
}
// Trade — important operation, force-save immediately
public async Task OnTrade(PlayerVault player)
{
player.Gold -= 500;
await _autosave.SaveAsync(player); // Cache + DB immediately
}
// New account — use vault for INSERT
public async Task CreateNewPlayer(string name)
{
var player = new PlayerVault { Name = name, Level = 1 };
await _vault.SaveAsync(player); // Direct DB insert
}
// Complex query — use vault for SELECT
public async Task<List<PlayerVault>> GetTopPlayers()
{
return await _vault
.OrderByDescending(p => p.Level)
.Take(10)
.ToListAsync();
}
}
Note:
Think of it this way: IVault<T> is the database layer (queries, inserts). IAutosaveService<T> is the runtime layer (cached reads, dirty tracking, batched writes). For hot-path operations like movement, combat, and stat changes, always use autosave.
Step 1: Mark Your Vault Model
Add [Autosave] to any vault model that should be auto-saved:
[Autosave(120, AutosaveCycle.Seconds)]
[Vault("players")]
public class PlayerVault : VaultModel
{
[VaultColumn] public override string StorageId { get; set; } = "";
[VaultColumn] public string Name { get; set; } = "";
[VaultColumn] public int Level { get; set; } = 1;
[VaultColumn] public int X { get; set; }
[VaultColumn] public int Y { get; set; }
[VaultColumn] public int Gold { get; set; }
}
Interval Options
// Time-based: flush every 120 seconds
[Autosave(120, AutosaveCycle.Seconds)]
// Time-based: flush every 5 minutes
[Autosave(5, AutosaveCycle.Minutes)]
// Cron-based: flush every 5 minutes
[Autosave("*/5 * * * *")]
// Default: uses config value, or 5 minutes if not configured
[Autosave]
Priority Chain
Both interval and batch size follow the same resolution order:
| Setting | Attribute | Config | Fallback |
|---|---|---|---|
| Interval | [Autosave(120, AutosaveCycle.Seconds)] | default-interval | "*/5 * * * *" |
| Batch size | [Autosave(BatchSize = 50)] | default-batch-size | 100 |
- Explicit attribute value — always wins
- Config override —
altruist:game:autosave:*inconfig.yml— used when attribute doesn't specify - Fallback — hardcoded defaults (5 minutes, batch 100)
Configuring Global Defaults
Override the default interval and batch size for all [Autosave] models that don't specify their own:
altruist:
game:
autosave:
default-interval: "*/1 * * * *" # Global default flush interval
default-batch-size: 200 # Global default batch size
[Autosave] // Uses config: interval "*/1 * * * *", batch 200
[Vault("players")]
public class PlayerVault : VaultModel { ... }
[Autosave(30, AutosaveCycle.Seconds)] // Ignores config interval (30s), uses config batch (200)
[Vault("player_items")]
public class ItemVault : VaultModel { ... }
[Autosave(60, AutosaveCycle.Seconds, BatchSize = 50)] // Ignores both config values
[Vault("quest_progress")]
public class QuestVault : VaultModel { ... }
Note:
Use config overrides to tune autosave per environment. Development might use "*/1 * * * *" (every minute) with batch size 50 for faster feedback, while production uses the default 5 minutes with batch size 200 for throughput.
Batch Size
Control how many entities are saved per database query:
[Autosave(60, AutosaveCycle.Seconds, BatchSize = 50)]
Priority: [Autosave(BatchSize = 50)] on attribute > default-batch-size in config > 100 fallback.
Note:
One batched INSERT ... ON CONFLICT UPDATE with 100 rows is roughly 50x faster than 100 individual saves. Increase the batch size if you have many entities and a fast database connection.
Note:
[Autosave] only works on classes that extend VaultModel (which implements IVaultModel). It does NOT work on in-memory-only objects like GameItem. For inventory items, map them to a vault model and autosave that.
Step 2: Inject and Use
The framework auto-creates IAutosaveService<T> for any vault model with [Autosave]. Just inject it:
[Service]
public class GameService
{
private readonly IAutosaveService<PlayerVault> _playerSave;
public GameService(IAutosaveService<PlayerVault> playerSave)
{
_playerSave = playerSave;
}
}
MarkDirty — Cache Write, No DB Hit
// Player takes damage — update in memory, mark dirty
player.Hp -= damage;
_playerSave.MarkDirty(player, player.StorageId);
// Writes to cache immediately, DB write deferred to next flush
SaveAsync — Immediate DB Write
// Critical operation — save to cache AND database right now
await _playerSave.SaveAsync(player);
LoadAsync — Cache-First Read
// Checks cache first, falls back to database
var player = await _playerSave.LoadAsync(playerId);
Step 3: Flush on Disconnect
When a player disconnects, flush their dirty data immediately using the IAutosaveCoordinator:
[Service]
public class DisconnectHandler
{
private readonly IAutosaveCoordinator _coordinator;
public DisconnectHandler(IAutosaveCoordinator coordinator)
{
_coordinator = coordinator;
}
public async Task OnPlayerDisconnect(string playerId)
{
// Flush ALL dirty entities owned by this player, across all autosave services
await _coordinator.FlushByOwnerAsync(playerId);
}
}
Note:
FlushByOwnerAsync flushes across all autosave services — if you have [Autosave] on PlayerVault, InventoryVault, and QuestVault, one call saves all of them for that player.
Step 4: Flush on Shutdown
On server shutdown, flush everything:
await _coordinator.FlushAllAsync();
How It Works Internally
Multiple Autosave Models
You can have [Autosave] on multiple vault models with different intervals:
// Player data: save every 2 minutes
[Autosave(120, AutosaveCycle.Seconds)]
[Vault("players")]
public class PlayerVault : VaultModel { ... }
// Quest progress: save every 5 minutes (less critical)
[Autosave(5, AutosaveCycle.Minutes)]
[Vault("quest_progress")]
public class QuestProgressVault : VaultModel { ... }
// Guild data: save every 30 seconds (changes affect many players)
[Autosave(30, AutosaveCycle.Seconds)]
[Vault("guilds")]
public class GuildVault : VaultModel { ... }
Each model gets its own IAutosaveService<T> instance with its own flush timer. The IAutosaveCoordinator tracks all of them.
Write-Ahead Log (WAL)
By default, autosave keeps dirty data in memory until the next flush interval. If the server crashes between flushes, that data is lost. The WAL (Write-Ahead Log) solves this by periodically writing the in-memory buffer to disk.
How It Works
MarkDirty()appends to an in-memory buffer (zero I/O)- Every 10 seconds, the buffer is flushed to
data/wal/PlayerVault.wal(one async sequential write) - On DB flush, the WAL file is deleted (data is safe in the database)
- On server restart, the WAL is replayed — unflushed data is recovered — then the WAL is deleted
Worst case data loss: 10 seconds (down from 5 minutes without WAL).
Configuration
altruist:
game:
autosave:
wal:
enabled: true # Default: true
flush-interval-seconds: 10 # Buffer to disk interval
directory: "data/wal" # Dedicated WAL directory
Note:
WAL is enabled by default. The data/wal/ directory is created automatically on first write.
Per-Entity Opt-Out
For high-frequency, low-value data (like position updates every tick), you can disable WAL to avoid disk overhead:
[Autosave(Wal = false)] // Disable WAL for position updates
[Vault("player_positions")]
public class PositionVault : VaultModel { ... }
Note:
Disabling WAL means data written since the last DB flush is lost on crash. Only disable it for data you can afford to lose (e.g., position updates that happen 30 times per second — losing 5 minutes of position data is rarely critical).
WAL vs No WAL
| WAL Enabled | WAL Disabled | |
|---|---|---|
| Data loss on crash | Max 10 seconds | Max flush interval (e.g. 5 minutes) |
| Disk I/O | One sequential write every 10s | None |
| Recovery on restart | Automatic replay | No recovery |
| Use case | Player stats, inventory, gold | Position updates, animation state |
Monitoring
Check how many entities are waiting to be saved:
// Per service
int dirtyPlayers = _playerSave.DirtyCount;
// Total across all autosave services
int totalDirty = _coordinator.TotalDirtyCount;