Skip to main content

Orleans Grains Overview

Titan uses Microsoft Orleans virtual actors (grains) to manage distributed state.

Virtual Actor Model

Orleans grains are:

  • Single-threaded: One request at a time per grain instance
  • Location-transparent: Orleans routes calls to the correct silo
  • Persistent: State is automatically saved to storage
  • Lazy-activated: Created on first access, deactivated when idle

Grain Types by Host

Identity Host (Titan.IdentityHost)

GrainKeyDescription
AccountGrainGuid (userId)Global account state, character list
CharacterGrainGuid + string (charId + seasonId)Per-season character state
RefreshTokenGrainGuid (userId)JWT refresh token management
PlayerPresenceGrainGuid (userId)Online status tracking
SessionLogGrainGuid (userId)Session persistence
UserIdentityGrainGuid (userId)Provider identity mapping
UserProfileGrainGuid (userId)User profile data
SocialGrainGuid (userId)Friends, blocks

Inventory Host (Titan.InventoryHost)

GrainKeyDescription
CharacterInventoryGrainGuid + stringCharacter bag and equipment
AccountStashGrainGuid + stringShared stash tabs
ItemGeneratorGrainlong (stateless)Item generation worker
BaseTypeRegistryGrainstring ("default")Base type definitions
ModifierRegistryGrainstring ("default")Modifier definitions
ItemHistoryGrainGuid (itemId)Item audit trail

Trading Host (Titan.TradingHost)

GrainKeyDescription
TradeGrainGuid (tradeId)Trade session state
SeasonRegistryGrainstring ("default")Season definitions
SeasonMigrationGrainstring (seasonId)Character migration
RateLimitConfigGrainstring ("default")Rate limit configuration

Grain Keys

Simple Keys

Most grains use a single GUID:

var accountGrain = clusterClient.GetGrain<IAccountGrain>(userId);
var tradeGrain = clusterClient.GetGrain<ITradeGrain>(tradeId);

Compound Keys

Character-related grains use compound keys (GUID + string):

// characterId + seasonId
var charGrain = clusterClient.GetGrain<ICharacterGrain>(characterId, seasonId);
var invGrain = clusterClient.GetGrain<ICharacterInventoryGrain>(characterId, seasonId);

This allows the same character ID to have different states in different seasons (e.g., after migration).

String Keys

Singleton-style grains use string keys:

var registry = clusterClient.GetGrain<IBaseTypeRegistryGrain>("default");
var seasons = clusterClient.GetGrain<ISeasonRegistryGrain>("default");

Persistence

Grains persist state to PostgreSQL (or CockroachDB) using MemoryPack serialization:

public class AccountGrain : Grain, IAccountGrain
{
private readonly IPersistentState<AccountGrainState> _state;

public AccountGrain(
[PersistentState("account", "OrleansStorage")]
IPersistentState<AccountGrainState> _state)
{
_state = state;
}

public async Task UpdateAsync()
{
_state.State.SomeValue = newValue;
await _state.WriteStateAsync(); // Persist to DB
}
}

State Serialization

Grain state uses MemoryPack for efficient binary serialization:

[GenerateSerializer]
[MemoryPackable]
public partial class AccountGrainState
{
[Id(0), MemoryPackOrder(0)]
public Account? Account { get; set; }

[Id(1), MemoryPackOrder(1)]
public List<CharacterSummary> Characters { get; set; } = new();
}

Benefits:

  • ~40% smaller than JSON
  • Fast serialization via source generators
  • Compatible with Orleans' streaming

Stateless Workers

Some grains are stateless workers that can run on any silo:

[StatelessWorker]
public class ItemGeneratorGrain : Grain, IItemGeneratorGrain
{
// No state - can have multiple instances
public async Task<Item> GenerateAsync(string baseTypeId, ItemRarity rarity)
{
// Generate item logic
}
}

Next Steps