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.

ApproachDB writes for 1,000 playersLatency per save
Direct vault save1,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:

SettingAttributeConfigFallback
Interval[Autosave(120, AutosaveCycle.Seconds)]default-interval"*/5 * * * *"
Batch size[Autosave(BatchSize = 50)]default-batch-size100
  1. Explicit attribute value — always wins
  2. Config overridealtruist:game:autosave:* in config.yml — used when attribute doesn't specify
  3. 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

Loading diagram...

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.

Loading diagram...

How It Works

  1. MarkDirty() appends to an in-memory buffer (zero I/O)
  2. Every 10 seconds, the buffer is flushed to data/wal/PlayerVault.wal (one async sequential write)
  3. On DB flush, the WAL file is deleted (data is safe in the database)
  4. 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 EnabledWAL Disabled
Data loss on crashMax 10 secondsMax flush interval (e.g. 5 minutes)
Disk I/OOne sequential write every 10sNone
Recovery on restartAutomatic replayNo recovery
Use casePlayer stats, inventory, goldPosition 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;