Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ jobs:
- name: Build
run: dotnet build Orleans.Graph.slnx --configuration Release --no-restore

- name: Verify formatting
run: dotnet format Orleans.Graph.slnx --verify-no-changes --no-restore

- name: Test
run: dotnet test --solution Orleans.Graph.slnx --configuration Release --no-build --verbosity normal

Expand Down Expand Up @@ -84,7 +87,6 @@ jobs:

- name: Publish to NuGet
id: publish
continue-on-error: true
run: |
set +e
OUTPUT=""
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<AnalysisLevel>latest-recommended</AnalysisLevel>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<NoWarn>$(NoWarn);CS1591;CA1707;CA1848;CA1859;CA1873</NoWarn>
<Version>10.0.5</Version>
<Version>10.0.6</Version>
<PackageVersion>$(Version)</PackageVersion>
</PropertyGroup>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using ManagedCode.Orleans.Graph.Tests.Cluster.Grains.Interfaces;
using Orleans.Placement;

namespace ManagedCode.Orleans.Graph.Tests.SystemTargetCluster;

[PreferLocalPlacement]
public sealed class SystemTargetCallerGrain(SystemTargetProbeClient client, ILocalSiloDetails localSilo)
: Grain, ISystemTargetCallerGrain
{
public Task<SystemTargetProbeReply> CallServiceAsync(CancellationToken cancellationToken) =>
client.ProbeAsync(localSilo.SiloAddress, cancellationToken);

public Task<int> CallAllowedApplicationAsync(int input) =>
GrainFactory.GetGrain<IGrainB>(this.GetPrimaryKey().ToString()).MethodB1(input);

public Task<int> CallDeniedApplicationAsync(int input) =>
GrainFactory.GetGrain<IGrainC>(this.GetPrimaryKey().ToString()).MethodC1(input);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using Orleans.Runtime.Services;

namespace ManagedCode.Orleans.Graph.Tests.SystemTargetCluster;

public sealed class SystemTargetProbeClient(IServiceProvider services) : GrainServiceClient<ISystemTargetProbe>(services)
{
public Task<SystemTargetProbeReply> ProbeAsync(SiloAddress destination, CancellationToken cancellationToken) =>
GetGrainService(destination).ProbeAsync(cancellationToken).WaitAsync(cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using Orleans.Services;

namespace ManagedCode.Orleans.Graph.Tests.SystemTargetCluster;

[Alias(SystemTargetTestProtocol.ProbeAlias)]
public interface ISystemTargetProbe : IGrainService
{
[Alias(SystemTargetTestProtocol.ProbeMethodAlias)]
Task<SystemTargetProbeReply> ProbeAsync(CancellationToken cancellationToken);
}

[Immutable]
[GenerateSerializer]
[Alias(SystemTargetTestProtocol.ReplyAlias)]
public sealed record SystemTargetProbeReply(
[property: Id(0)] bool ExecutedBeforeGrainServicesCompleted,
[property: Id(1)] int ApplicationHistoryDepth,
[property: Id(2)] bool ContainsSystemTargetHistory,
[property: Id(3)] int HighestCompletedStage);

[Alias(SystemTargetTestProtocol.CallerAlias)]
public interface ISystemTargetCallerGrain : IGrainWithGuidKey
{
[Alias(SystemTargetTestProtocol.ServiceMethodAlias)]
Task<SystemTargetProbeReply> CallServiceAsync(CancellationToken cancellationToken);

[Alias(SystemTargetTestProtocol.AllowedMethodAlias)]
Task<int> CallAllowedApplicationAsync(int input);

[Alias(SystemTargetTestProtocol.DeniedMethodAlias)]
Task<int> CallDeniedApplicationAsync(int input);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using ManagedCode.Orleans.Graph.Interfaces;
using ManagedCode.Orleans.Graph.Models;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace ManagedCode.Orleans.Graph.Tests.SystemTargetCluster;

public sealed class SystemTargetProbeGrainService(GrainId id, Silo silo, ILoggerFactory loggerFactory,
ILocalSiloDetails localSilo, ISiloLifecycle lifecycle, SystemTargetProbeObservation observation) : GrainService(id, silo, loggerFactory), ISystemTargetProbe
{
public override async Task Init(IServiceProvider serviceProvider)
{
await base.Init(serviceProvider);
using var deadline = new CancellationTokenSource(SystemTargetTestProtocol.RpcTimeout, TimeProvider.System);
try
{
var client = serviceProvider.GetRequiredService<SystemTargetProbeClient>();
observation.RecordInitialization(await client.ProbeAsync(localSilo.SiloAddress, deadline.Token));
}
catch (Exception exception)
{
// Keep startup healthy so the assertion reports the genuine pre-Active RPC failure and cleanup can run.
observation.RecordInitializationFailure(exception, lifecycle.HighestCompletedStage);
}
}

public Task<SystemTargetProbeReply> ProbeAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
observation.RecordExecution();
var history = RequestContext.Get(Constants.RequestContextKey) as CallHistory;
var completedStage = lifecycle.HighestCompletedStage;
var beforeCompletion = completedStage >= ServiceLifecycleStage.RuntimeInitialize
&& completedStage < ServiceLifecycleStage.RuntimeGrainServices;
return Task.FromResult(new SystemTargetProbeReply(beforeCompletion,
history?.History.Count ?? 0,
history?.History.Any(call => call.Interface == typeof(ISystemTargetProbe).FullName) ?? false, completedStage));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace ManagedCode.Orleans.Graph.Tests.SystemTargetCluster;

public sealed class SystemTargetProbeObservation
{
private int _executions;
private SystemTargetProbeReply? _initializationReply;
private SystemTargetInitializationFailure? _initializationFailure;

public int Executions => Volatile.Read(ref _executions);
public SystemTargetProbeReply? InitializationReply => Volatile.Read(ref _initializationReply);
public SystemTargetInitializationFailure? InitializationFailure => Volatile.Read(ref _initializationFailure);

public void RecordExecution() => Interlocked.Increment(ref _executions);

public void RecordInitialization(SystemTargetProbeReply reply) => Volatile.Write(ref _initializationReply, reply);

public void RecordInitializationFailure(Exception exception, int highestCompletedStage) =>
Volatile.Write(ref _initializationFailure, new SystemTargetInitializationFailure(highestCompletedStage, exception.GetType().FullName!));
}

public sealed record SystemTargetInitializationFailure(int HighestCompletedStage, string ExceptionType);
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Microsoft.Extensions.DependencyInjection;
using Orleans.TestingHost;

namespace ManagedCode.Orleans.Graph.Tests.SystemTargetCluster;

public sealed class SystemTargetTestCluster : IAsyncDisposable
{
public SystemTargetTestCluster()
{
var builder = new TestClusterBuilder(SystemTargetTestProtocol.SiloCount);
builder.AddSiloBuilderConfigurator<SystemTargetTestSiloConfiguration>();
Cluster = builder.Build();
Cluster.Deploy();
}

public TestCluster Cluster { get; }
public IServiceProvider PrimaryServices => Cluster.GetSiloServiceProvider(Cluster.Primary!.SiloAddress);

public ISystemTargetCallerGrain CreateCaller() => PrimaryServices.GetRequiredService<IGrainFactory>()
.GetGrain<ISystemTargetCallerGrain>(Guid.NewGuid());

public ValueTask DisposeAsync() => Cluster.DisposeAsync();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace ManagedCode.Orleans.Graph.Tests.SystemTargetCluster;

internal static class SystemTargetTestProtocol
{
internal const string SharedClusterKey = nameof(SystemTargetTestCluster);
internal const string ProbeAlias = "Graph.Tests.SystemTargetProbe";
internal const string ReplyAlias = "Graph.Tests.SystemTargetProbeReply";
internal const string CallerAlias = "Graph.Tests.SystemTargetCaller";
internal const string ProbeMethodAlias = "probe";
internal const string ServiceMethodAlias = "call-service";
internal const string AllowedMethodAlias = "call-allowed";
internal const string DeniedMethodAlias = "call-denied";
internal const string TransitionDeniedPrefix = "Transition from";
internal const int SiloCount = 2;
internal const int ApplicationInput = 41;
internal const int ApplicationResult = 42;
internal const int TestTimeoutMilliseconds = 60_000;
internal static TimeSpan RpcTimeout { get; } = TimeSpan.FromSeconds(10);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using ManagedCode.Orleans.Graph.Extensions;
using ManagedCode.Orleans.Graph.Tests.Cluster.Grains.Interfaces;
using Microsoft.Extensions.DependencyInjection;
using Orleans.TestingHost;

namespace ManagedCode.Orleans.Graph.Tests.SystemTargetCluster;

public sealed class SystemTargetTestSiloConfiguration : ISiloConfigurator
{
public void Configure(ISiloBuilder siloBuilder)
{
siloBuilder.Services.AddSingleton<SystemTargetProbeObservation>();
siloBuilder.Services.AddSingleton<SystemTargetProbeClient>();
siloBuilder.AddGrainService<SystemTargetProbeGrainService>();
siloBuilder.AddOrleansGraph(configureGraph: graph =>
{
graph.AllowClientCallGrain<ISystemTargetCallerGrain>();
graph.AddGrainTransition<ISystemTargetCallerGrain, IGrainB>().MethodByName(
nameof(ISystemTargetCallerGrain.CallAllowedApplicationAsync), nameof(IGrainB.MethodB1));
});
}
}
114 changes: 114 additions & 0 deletions ManagedCode.Orleans.Graph.Tests/SystemTargetTrackingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
using ManagedCode.Orleans.Graph.Models;
using ManagedCode.Orleans.Graph.Tests.SystemTargetCluster;
using Microsoft.Extensions.DependencyInjection;

namespace ManagedCode.Orleans.Graph.Tests;

[ClassDataSource<SystemTargetTestCluster>(Shared = SharedType.PerClass)]
[NotInParallel(SystemTargetTestProtocol.SharedClusterKey)]
[Timeout(SystemTargetTestProtocol.TestTimeoutMilliseconds)]
public class SystemTargetTrackingTests(SystemTargetTestCluster fixture)
{
[Test]
public void EarlyInitializationRpcRunsBeforeActivationWithoutGraphHistory(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
// KeyLoad AC-ROUTE-003/006: the real system-target transport must not depend on ordinary telemetry grains.
foreach (var silo in fixture.Cluster.Silos)
{
var observation = fixture.Cluster.GetSiloServiceProvider(silo.SiloAddress)
.GetRequiredService<SystemTargetProbeObservation>();
observation.InitializationFailure.ShouldBeNull();
var reply = observation.InitializationReply.ShouldNotBeNull();
reply.ExecutedBeforeGrainServicesCompleted.ShouldBeTrue();
reply.HighestCompletedStage.ShouldBeGreaterThanOrEqualTo(ServiceLifecycleStage.RuntimeInitialize);
reply.HighestCompletedStage.ShouldBeLessThan(ServiceLifecycleStage.RuntimeGrainServices);
reply.ApplicationHistoryDepth.ShouldBe(0);
reply.ContainsSystemTargetHistory.ShouldBeFalse();
}
}

[Test]
public async Task NativeServiceCallsFromSiloCodeAreNotTrackedAsync(CancellationToken cancellationToken)
{
using var deadline = CreateDeadline(cancellationToken);
var client = fixture.PrimaryServices.GetRequiredService<SystemTargetProbeClient>();
foreach (var silo in fixture.Cluster.Silos)
{
var observation = fixture.Cluster.GetSiloServiceProvider(silo.SiloAddress)
.GetRequiredService<SystemTargetProbeObservation>();
var before = observation.Executions;
var reply = await client.ProbeAsync(silo.SiloAddress, deadline.Token);
reply.ExecutedBeforeGrainServicesCompleted.ShouldBeFalse();
reply.ApplicationHistoryDepth.ShouldBe(0);
reply.ContainsSystemTargetHistory.ShouldBeFalse();
observation.Executions.ShouldBe(before + 1);
}
}

[Test]
public async Task NativeServiceCallsFromOrdinaryGrainsPreserveApplicationHistoryAsync(CancellationToken cancellationToken)
{
using var deadline = CreateDeadline(cancellationToken);
var reply = await fixture.CreateCaller().CallServiceAsync(deadline.Token).WaitAsync(deadline.Token);
reply.ExecutedBeforeGrainServicesCompleted.ShouldBeFalse();
reply.ApplicationHistoryDepth.ShouldBeGreaterThan(0);
reply.ContainsSystemTargetHistory.ShouldBeFalse();
}

[Test]
public async Task ApplicationTransitionsStillAllowConfiguredAndDenyMissingEdgesAsync(CancellationToken cancellationToken)
{
using var deadline = CreateDeadline(cancellationToken);
var caller = fixture.CreateCaller();
(await caller.CallAllowedApplicationAsync(SystemTargetTestProtocol.ApplicationInput).WaitAsync(deadline.Token))
.ShouldBe(SystemTargetTestProtocol.ApplicationResult);
var exception = await Should.ThrowAsync<InvalidOperationException>(() =>
caller.CallDeniedApplicationAsync(SystemTargetTestProtocol.ApplicationInput).WaitAsync(deadline.Token));
exception.Message.ShouldStartWith(SystemTargetTestProtocol.TransitionDeniedPrefix);
}

[Test]
public async Task ExplicitTrackingChecksIncomingSystemTargetCallsBeforeExecutionAsync(CancellationToken cancellationToken)
{
var client = fixture.PrimaryServices.GetRequiredService<SystemTargetProbeClient>();
await AssertExplicitTrackingDeniedAsync(token => client.ProbeAsync(fixture.Cluster.Primary!.SiloAddress, token), cancellationToken);
}

[Test]
public async Task ExplicitTrackingChecksOutgoingSystemTargetCallsBeforeExecutionAsync(CancellationToken cancellationToken)
{
using var deadline = CreateDeadline(cancellationToken);
var caller = fixture.CreateCaller();
// Prime the ordinary activation and telemetry worker before intentionally tracking runtime calls.
await caller.CallServiceAsync(deadline.Token).WaitAsync(deadline.Token);
await AssertExplicitTrackingDeniedAsync(token => caller.CallServiceAsync(token).WaitAsync(token), cancellationToken);
}

private async Task AssertExplicitTrackingDeniedAsync(Func<CancellationToken, Task<SystemTargetProbeReply>> invoke, CancellationToken cancellationToken)
{
var configuration = fixture.PrimaryServices.GetRequiredService<GraphCallFilterConfig>();
var observation = fixture.PrimaryServices.GetRequiredService<SystemTargetProbeObservation>();
var before = observation.Executions;
using var deadline = CreateDeadline(cancellationToken);
configuration.TrackOrleansCalls = true;
try
{
var exception = await Should.ThrowAsync<InvalidOperationException>(() => invoke(deadline.Token));
exception.Message.ShouldStartWith(SystemTargetTestProtocol.TransitionDeniedPrefix);
exception.Message.ShouldContain(typeof(ISystemTargetProbe).FullName!);
observation.Executions.ShouldBe(before);
}
finally
{
configuration.TrackOrleansCalls = false;
}
}

private static CancellationTokenSource CreateDeadline(CancellationToken cancellationToken)
{
var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
deadline.CancelAfter(SystemTargetTestProtocol.RpcTimeout);
return deadline;
}
}
3 changes: 2 additions & 1 deletion ManagedCode.Orleans.Graph/Extensions/RequestContextHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@ public static async Task RunWithCurrentCallerAsync(string caller, string method,

private static bool ShouldSkipTracking(this IGrainCallContext context, GraphCallFilterConfig graphCallFilterConfig, string moduleName)
{
if (!graphCallFilterConfig.TrackOrleansCalls && moduleName.StartsWith("Orleans.", StringComparison.Ordinal))
if (!graphCallFilterConfig.TrackOrleansCalls &&
(context.TargetId.IsSystemTarget() || moduleName.StartsWith("Orleans.", StringComparison.Ordinal)))
{
return true;
}
Expand Down
20 changes: 20 additions & 0 deletions docs/ReleaseNotes/10.0.6.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# ManagedCode.Orleans.Graph 10.0.6

Custom Orleans grain services are native system targets even when their implementation lives in an application assembly. Default call tracking now checks the target's native Orleans identity before applying application transition policies. This allows a grain service to communicate during silo initialization without requiring an ordinary telemetry grain to activate first.

`TrackOrleansCalls = true` retains explicit runtime-call tracking and policy enforcement. Ordinary application grains still require configured transitions. No public configuration or graph contracts changed.

The regression fixture uses two real Orleans silos, an actual `GrainServiceClient` and a real service invoked from `GrainService.Init`. The target records the public `ISiloLifecycle.HighestCompletedStage`; the test requires `RuntimeInitialize <= stage < RuntimeGrainServices`. A failed initialization RPC records its stage and exception type, then allows startup to complete so native cluster disposal remains available.

Six TUnit/Shouldly regressions cover:

- A successful initialization RPC before the grain-service stage completes, without application graph history.
- Local and remote calls from silo code without runtime call tracking.
- An ordinary grain calling the real service while retaining its application history.
- A configured application transition succeeding and an unconfigured transition failing.
- Explicit incoming runtime-call tracking rejecting the service call before its body executes.
- Explicit outgoing runtime-call tracking rejecting the service call before its body executes.

The shared test fixture uses a class-scoped TUnit constraint key. Tests restore the actual singleton tracking setting in `finally`.

Release verification now runs the formatter before tests. A failed NuGet publication step fails the release job instead of being ignored; package delivery still requires successful publication and a verified feed receipt.
Loading