Database & Cache Scaling

Altruist supports multi-instance Postgres and Redis configurations for scaling database reads, separating workloads, and adding redundancy.

Multi-Instance Postgres

Define multiple Postgres instances in config.yml — a primary for writes and one or more read replicas:

altruist:
  persistence:
    database:
      provider: postgres
      instances:
        - name: primary
          host: db-primary.example.com
          port: 5432
          username: gameserver
          password: secret
          database: mygame
          role: readwrite

        - name: replica
          host: db-replica-1.example.com
          port: 5432
          username: gameserver_ro
          password: secret
          database: mygame
          role: readonly

        - name: analytics
          host: db-analytics.example.com
          port: 5432
          username: analytics
          password: secret
          database: mygame_analytics
          role: readwrite
Loading diagram...

Each instance is identified by its name. Use this name in [Vault] to route models to specific instances.

Routing Vault Models to Instances

Use DbInstance on the [Vault] attribute to target a specific database instance:

// Default: uses the first readwrite instance ("primary")
[Vault("players")]
public class PlayerVault : VaultModel { ... }

// Explicitly targets the read replica
[Vault("player_stats", DbInstance = "replica")]
public class PlayerStatsReadVault : VaultModel { ... }

// Targets the analytics database
[Vault("event_log", DbInstance = "analytics")]
public class EventLogVault : VaultModel { ... }

Note:

If DbInstance is empty (the default), the vault uses the first available ISqlDatabaseProvider. When instances are configured, this is the first instance in the list. When no instances are configured (single database), it uses the standard provider.

Instance Properties

PropertyDescriptionDefault
nameUnique identifier for this instance (used as service key)Required
hostDatabase hostnamelocalhost
portDatabase port5432
usernameConnection usernameRequired
passwordConnection passwordRequired
databaseDatabase nameRequired
rolereadwrite or readonly — informational, for your own routing logicreadwrite
poolingEnable connection poolingtrue
ssl-modeSSL mode: disable, prefer, require, verify-ca, verify-fulldisable

Read/Write Split Pattern

A common pattern is to use the primary for writes and mutations, and a replica for heavy read queries:

[Service]
public class LeaderboardService
{
    // Writes go to primary (default)
    private readonly IAutosaveService<PlayerVault> _autosave;

    // Heavy reads go to replica
    private readonly IVault<LeaderboardReadVault> _leaderboard;

    public LeaderboardService(
        IAutosaveService<PlayerVault> autosave,
        IVault<LeaderboardReadVault> leaderboard)
    {
        _autosave = autosave;
        _leaderboard = leaderboard;
    }

    public void UpdateScore(PlayerVault player, int score)
    {
        player.Score = score;
        _autosave.MarkDirty(player, player.StorageId);
        // Writes to primary via autosave
    }

    public async Task<List<LeaderboardReadVault>> GetTopPlayers()
    {
        // Reads from replica — doesn't load the primary
        return await _leaderboard
            .OrderByDescending(p => p.Score)
            .Take(100)
            .ToListAsync();
    }
}

[Vault("players", DbInstance = "replica")]
public class LeaderboardReadVault : VaultModel
{
    [VaultColumn] public override string StorageId { get; set; } = "";
    [VaultColumn] public string Name { get; set; } = "";
    [VaultColumn] public int Score { get; set; }
}

Note:

Read replicas handle replication lag at the database level (typically milliseconds to low seconds). Leaderboard queries, statistics, and analytics are ideal candidates — they tolerate slightly stale data and the read load doesn't impact write performance on the primary.

Single Database (No Instances)

If you don't need multiple instances, use the simple config — no instances section needed:

altruist:
  persistence:
    database:
      provider: postgres
      host: localhost
      port: 5432
      username: gameserver
      password: secret
      database: mygame

Note:

The single-database config and the multi-instance config are mutually compatible. The framework detects which style you're using. You can start with a single database and add instances later without changing any vault model code (unless you want to route specific models to specific instances).


Redis Cache

Altruist uses Redis as a shared cache layer — for autosave dirty tracking, session data, and cross-channel communication.

Single Redis Instance

altruist:
  persistence:
    cache:
      provider: redis
    redis:
      connection-string: "localhost:6379"

Redis with Sentinel (High Availability)

StackExchange.Redis natively supports Sentinel for automatic failover:

altruist:
  persistence:
    cache:
      provider: redis
    redis:
      connection-string: "sentinel1:26379,sentinel2:26379,sentinel3:26379,serviceName=mymaster"
Loading diagram...

Note:

With Sentinel, if the master goes down, a replica is automatically promoted. The game server reconnects transparently — no code changes needed. Altruist's RedisConnectionFactory uses AbortOnConnectFail = false with infinite retry, so it handles transient connection loss gracefully.

Redis Cluster

For horizontal scaling across multiple Redis nodes:

altruist:
  persistence:
    cache:
      provider: redis
    redis:
      connection-string: "node1:6379,node2:6379,node3:6379"
Loading diagram...

Keys are automatically distributed across nodes via hash slots. Each node owns a range of the 16384 hash slots.

Note:

Redis Cluster requires that all keys accessed in a single operation belong to the same hash slot. Altruist's cache operations work per-entity (single key), so this is not an issue for normal autosave and session usage.


Full Production Architecture

A production game server typically combines multi-channel servers, read replicas, and Redis:

Loading diagram...
altruist:
  persistence:
    cache:
      provider: redis
    redis:
      connection-string: "redis-primary:6379,redis-replica:6380"
    database:
      provider: postgres
      instances:
        - name: primary
          host: pg-primary
          port: 5432
          username: gameserver
          password: "${DB_PASSWORD}"
          database: mygame
          role: readwrite
          ssl-mode: require
        - name: replica
          host: pg-replica
          port: 5432
          username: gameserver_ro
          password: "${DB_PASSWORD}"
          database: mygame
          role: readonly
          ssl-mode: require

Note:

Environment variables work in config values (e.g., ${DB_PASSWORD}). Combined with the ALTRUIST__ prefix for env var overrides, you can keep secrets out of config files entirely. See Multi-Channel Servers for Docker Compose examples.