perf(grain): move runtime access to grain context - #10117
Conversation
72c7709 to
91ec0aa
Compare
There was a problem hiding this comment.
Pull request overview
This PR reduces per-grain instance overhead by deferring IGrainRuntime resolution until first use, retrieving it via grain-context component lookup (with an activation-services fallback) and caching the result. This aligns runtime resolution across activation types while avoiding eagerly retaining a runtime reference for grains which never use runtime-backed helpers.
Changes:
- Updated
Grainto lazily resolve and cacheIGrainRuntimeon first access instead of resolving in the constructor. - Exposed
IGrainRuntimeviaGrainTypeSharedContext’s component lookup to support context-based runtime resolution. - Added focused regression tests covering lazy resolution, caching, explicit injection, and the activation-services fallback path.
Show a summary per file
| File | Description |
|---|---|
src/Orleans.Core.Abstractions/Core/Grain.cs |
Switches IGrainRuntime handling to lazy resolution with per-instance caching. |
src/Orleans.Runtime/Catalog/GrainTypeSharedContext.cs |
Makes IGrainRuntime available through shared-context GetComponent lookups. |
test/Orleans.Core.Tests/Runtime/GrainRuntimeResolutionTests.cs |
Adds regression coverage for lazy runtime resolution behavior and fallbacks. |
Review details
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Files reviewed: 3/3 changed files
- Comments generated: 0
- Review effort level: Lite
91ec0aa to
b9f31b6
Compare
b9f31b6 to
4df63b3
Compare
4df63b3 to
e7eb8d3
Compare
|
The current head has deterministic Functional test failures in existing scheduler tests.
Failed run: https://github.com/dotnet/orleans/actions/runs/32652451640 The lazy runtime change needs to preserve the supported scheduler-test/manual-grain setup or the affected test fixture must explicitly provide the intended runtime. |
e7eb8d3 to
0742784
Compare
0742784 to
91c87a1
Compare
Code coverage77.51% line coverage - 97,363 / 125,614 lines Coverage details
|
91c87a1 to
89af829
Compare
There was a problem hiding this comment.
Review details
Suppressed comments (1)
src/Orleans.Core.Abstractions/Core/Grain.cs:36
- The
<exception>XML doc forGrainFactorysays "activation process"/"no runtime was provided", but the thrown exception message (and tests) use "creation process"/"no runtime was specified". Aligning the documentation with the deterministic exception text will avoid confusion for consumers reading the API docs.
/// <exception cref="InvalidOperationException">
/// The grain was created outside of the Orleans activation process and no runtime was provided.
/// </exception>
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Lite
89af829 to
0aa10e4
Compare
0aa10e4 to
ce03418
Compare
ce03418 to
9b48c26
Compare
There was a problem hiding this comment.
Review details
Suppressed comments (1)
src/Orleans.Core.Abstractions/Core/Grain.cs:41
ServiceProvideris declared as non-nullableIServiceProvider, but its XML doc ("Null if this grain is not associated with a Runtime") and the newDirectConstruction_WithoutRuntime_PreservesOptionalRuntimeBehaviortest both require it to be able to return null. Consider updating the nullability contract (eg,IServiceProvider?or[return: MaybeNull]) so implementers/consumers get correct nullable flow analysis, and update the generated API surface accordingly.
/// <summary>
/// Gets the IServiceProvider managed by the runtime. Null if this grain is not associated with a Runtime, such as when created directly for unit testing.
/// </summary>
protected internal IServiceProvider ServiceProvider => GrainContext?.ActivationServices ?? GrainContext?.GrainRuntime?.ServiceProvider!;
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
Move IGrainRuntime ownership from each Grain instance to IGrainContext, retaining component and service fallbacks for custom test contexts. Runtime activations expose the configured shared runtime directly and support explicit component overrides without dictionary lookups. This removes one reference from each Grain object while preserving direct-construction behavior and unit-test injection.
9b48c26 to
36b45b8
Compare
There was a problem hiding this comment.
Copilot review overview
Review tier: Lite
Findings: 1
New issues introduced by this change (1)
| Severity | Finding |
|---|---|
src/Orleans.Runtime/Catalog/ActivationData.cs — ActivationData.GrainRuntime reads _extras using a plain read. Since the runtime override can be… |
Suppressed comments (1)
src/Orleans.Runtime/Catalog/ActivationData.cs:397
- In
SetComponent, the runtime override path assigns_extras = new()with a normal write and then relies on readers seeing it without synchronization. IfGrainRuntimeis meant to support concurrent override updates, publish the_extrasreference withVolatile.Write(and prefer using a localextrasvariable) so readers usingVolatile.Read(ref _extras)reliably observe the override container.
if (componentType == typeof(IGrainRuntime))
{
if (_extras is null)
{
if (instance is null)
{
return;
}
_extras = new();
}
Volatile.Write(ref _extras.GrainRuntime, (IGrainRuntime?)instance);
return;
}
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review tier: Lite
Findings: 1
New issues introduced by this change (1)
| Severity | Finding |
|---|---|
src/Orleans.Core.Abstractions/Core/Grain.cs — ServiceProvider is declared as non-nullable IServiceProvider, but the implementation can return… |
Issues resolved since last review (1)
| Severity | Finding |
|---|---|
src/Orleans.Runtime/Catalog/ActivationData.cs — ActivationData.GrainRuntime reads _extras using a plain read. Since the runtime override can be… View resolved comment |
There was a problem hiding this comment.
Copilot review overview
🔵 Needs a closer look
Review tier: Lite
Findings: None
Issues resolved since last review (1)
| Severity | Finding |
|---|---|
src/Orleans.Core.Abstractions/Core/Grain.cs — ServiceProvider is declared as non-nullable IServiceProvider, but the implementation can return… View resolved comment |


Reason
Each
Graininstance retained anIGrainRuntimereference even though its activation context already owns access to the configured runtime. Keeping runtime access on the grain duplicated state across every activation.Solution
Add
IGrainContext.GrainRuntimeand makeGraindelegate runtime access to its context. Runtime contexts expose the configured shared runtime directly, while the default interface implementation supports custom contexts through anIGrainRuntimecomponent or activation-service registration.The protected
Grain(IGrainContext, IGrainRuntime?)constructor now installs the supplied test runtime throughIGrainContext.GrainRuntime.ActivationDataandGrainTypeSharedContextuse dedicated, safely published override fields so normal runtime access performs field reads rather than component dictionary or DI lookups. Stateless-worker contexts delegate to the same shared runtime.Required runtime access retains the deterministic
InvalidOperationExceptionwhen no runtime is available. Optional direct-construction behavior remains compatible:ServiceProvidercan be null andRuntimeIdentityreturns an empty string.Measured tradeoff
BenchmarkDotNet on .NET 10 measured the eager grain shape at 32 bytes and the context-owned shape at 24 bytes. Retained managed memory scaled from approximately 40 to 32 bytes per array-held grain, confirming an 8-byte grain-object reduction: about 8 MB per million live grain objects, 24 MB at three million, and 48 MB at six million.
Activations whose
ActivationDataExtrais allocated also carry an 8-byte optional runtime-override slot, and each grain type has one additional shared override slot. Consequently, the full object-graph saving is approximately8 × (grains without extras) - 8 × grain types; stateless-worker activations always have extras, so their total retained-memory saving is effectively zero.Construction improved from 19.24 ns to 10.90 ns when runtime was never accessed and from 18.85 ns to 9.80 ns including first access. Cached ordinary access measured 0.97 ns versus 1.48 ns; the stateless/extras path measured 0.97 ns versus 1.87 ns. All paths remain allocation-free after construction.
OrleansTestKit migration
Current OrleansTestKit versions reflect the removed
Grain.Runtimebacking field. TestKit must instead setcontext.GrainRuntime(or register anIGrainRuntimecomponent/service) before grain creation and remove the backing-field reflection. The new context API includes a setter specifically for this test override seam.Focused coverage exercises component and DI fallback precedence, standard mock contexts, explicit runtime injection, concurrent access, unavailable-runtime diagnostics, direct construction, lifecycle callbacks, field removal, and the existing stateless-worker runtime path.