The Problem: Finding Grains by State #
Microsoft Orleans is a fantastic framework for building distributed applications. Grains provide a natural way to model entities with identity-based access patterns. But there's a challenge: how do you find a grain when you don't know its key?
Consider a user management system. You have a UserGrain keyed by user ID, but users log in with their email address. Without a search mechanism, you'd need to:
- Maintain a separate lookup table
- Manually keep it synchronized with grain state
- Handle eventual consistency issues
This boilerplate adds complexity and potential bugs. I wanted a solution that would:
- Automatically sync indexed properties when grain state changes
- Support LINQ queries using familiar patterns
- Require zero boilerplate through source generation
A Problem I'd Tried to Solve Before #
This wasn't my first attempt at solving grain search in Orleans. Over three years ago, I started building TGHarker.Orleans.Indexing, a library that took a different approach using Azure Cognitive Search as the backend.
That project taught me a lot, but I hit significant roadblocks:
- Source generator tooling pain - The developer experience for building and debugging C# source generators was frustrating. Hot reload didn't work well, IDE support was inconsistent, and the feedback loop was slow
- External service dependency - Requiring Azure Cognitive Search meant additional infrastructure costs and complexity for what should be a simple indexing solution
- Performance concerns - Without source generators working smoothly, I couldn't achieve the zero-overhead abstraction I wanted
I eventually shelved the project, but the problem never stopped bothering me.
What Changed #
Recently, I revisited the problem with fresh eyes and a new tool: Claude Opus 4.5. What previously took me weeks of struggling with source generator tooling, I was able to build in just a few hours. The AI helped me:
- Navigate the source generator APIs and Roslyn syntax trees
- Debug compilation issues that would have taken hours to track down
- Generate the boilerplate code patterns that source generators require
The core of TGHarker.Orleans.Search (the source generator, storage decorator, and query provider) came together remarkably fast. It's a testament to how AI-assisted development can unlock projects that were previously too friction-heavy to complete.
Architecture Overview #
The library uses a decorator pattern where a custom grain storage wrapper intercepts state operations:
When a grain writes its state, the storage wrapper:
- Persists to the primary storage (unchanged behavior)
- Extracts properties marked with
[Queryable]
- Upserts them into a PostgreSQL index table
Queries flow through a separate path, translating LINQ expressions to SQL and returning matching grain keys.
Defining Searchable State #
Mark your state class with [Searchable] and tag properties with [Queryable]:
[Searchable(typeof(IUserGrain))]
public class UserState
{
[Queryable]
public string Email { get; set; } = string.Empty;
[Queryable]
public string Name { get; set; } = string.Empty;
[Queryable]
public bool IsActive { get; set; }
[Queryable]
public DateTime CreatedAt { get; set; }
// Non-indexed properties work normally
public string PasswordHash { get; set; } = string.Empty;
}The source generator produces:
- An EF Core entity matching your indexed properties
- A typed query provider for LINQ support
- Registration extension methods
Source Generation Flow #
Querying Grains #
The query API feels natural if you've used Entity Framework:
public class UserService
{
private readonly IClusterClient _client;
public async Task<IUserGrain?> FindByEmail(string email)
{
return await _client
.Search<IUserGrain>()
.Where(u => u.Email == email)
.FirstOrDefaultAsync();
}
public async Task<List<IUserGrain>> GetActiveUsers()
{
return await _client
.Search<IUserGrain>()
.Where(u => u.IsActive)
.ToListAsync();
}
public async Task<int> CountRecentSignups(DateTime since)
{
return await _client
.Search<IUserGrain>()
.Where(u => u.CreatedAt >= since)
.CountAsync();
}
}Query Translation #
LINQ expressions are translated to SQL at runtime:
Supported operations include:
- Equality:
u.Email == "value" - Comparison:
u.Age >= 18
- String patterns:
u.Name.Contains("john") - Boolean:
u.IsActive == true
- Null checks:
u.DeletedAt == null
Data Flow on State Changes #
An important design decision: index synchronization failures don't fail the primary write. This ensures the grain remains functional even if PostgreSQL is temporarily unavailable. The trade-off is eventual consistency in the search index.
Configuration #
Silo Setup #
var builder = Host.CreateDefaultBuilder(args)
.UseOrleans(silo =>
{
silo.UseLocalhostClustering();
// Your primary storage
silo.AddMemoryGrainStorage("Default");
// Wrap with searchable storage
silo.AddSearchableGrainStorage("Default");
// Add search services
silo.AddOrleansSearch(options =>
{
options.ConnectionString = "Host=localhost;Database=orleans;...";
});
});var builder = Host.CreateDefaultBuilder(args)
.UseOrleansClient(client =>
{
client.UseLocalhostClustering();
// Enable search queries
client.AddOrleansSearch(options =>
{
options.ConnectionString = "Host=localhost;Database=orleans;...";
});
});- Abstractions: Core interfaces,
[Searchable]and[Queryable]attributes - Core: Query provider base, storage wrapper, client extensions
- PostgreSQL: EF Core DbContext, migrations, SQL generation
- SourceGenerator: Roslyn analyzer that generates typed providers
Limitations and Future Work #
Current constraints:
- String-keyed grains only - Composite and Guid keys are on the roadmap
- Single namespace - All searchable states must share a namespace
- PostgreSQL only - The architecture supports other backends, but only PostgreSQL is implemented
Future plans:
- SQL Server and SQLite providers
- Compound key support
- Async index refresh for bulk operations
- Full-text search ranking
Conclusion #
TGHarker.Orleans.Search bridges the gap between Orleans' identity-based access model and the need to query grains by their state. By leveraging source generators and the decorator pattern, it provides a seamless developer experience with minimal configuration.
The library is open source and available on GitHub and NuGet.
If you're building Orleans applications that need search capabilities, give it a try and let me know what you think!