From 7761f1ffa6c39f6f9232bf56feb74bb26557ff84 Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Fri, 2 Oct 2026 02:50:02 +0200 Subject: [PATCH] fix: preserve native system-target calls by default --- .github/workflows/release.yml | 4 +- Directory.Build.props | 2 +- .../SystemTargetCallerGrain.cs | 18 +++ .../SystemTargetProbeClient.cs | 9 ++ .../SystemTargetProbeContracts.cs | 32 +++++ .../SystemTargetProbeGrainService.cs | 39 ++++++ .../SystemTargetProbeObservation.cs | 21 ++++ .../SystemTargetTestCluster.cs | 23 ++++ .../SystemTargetTestProtocol.cs | 19 +++ .../SystemTargetTestSiloConfiguration.cs | 22 ++++ .../SystemTargetTrackingTests.cs | 114 ++++++++++++++++++ .../Extensions/RequestContextHelper.cs | 3 +- docs/ReleaseNotes/10.0.6.md | 20 +++ 13 files changed, 323 insertions(+), 3 deletions(-) create mode 100644 ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetCallerGrain.cs create mode 100644 ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetProbeClient.cs create mode 100644 ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetProbeContracts.cs create mode 100644 ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetProbeGrainService.cs create mode 100644 ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetProbeObservation.cs create mode 100644 ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetTestCluster.cs create mode 100644 ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetTestProtocol.cs create mode 100644 ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetTestSiloConfiguration.cs create mode 100644 ManagedCode.Orleans.Graph.Tests/SystemTargetTrackingTests.cs create mode 100644 docs/ReleaseNotes/10.0.6.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d593709..44cb9e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -84,7 +87,6 @@ jobs: - name: Publish to NuGet id: publish - continue-on-error: true run: | set +e OUTPUT="" diff --git a/Directory.Build.props b/Directory.Build.props index 5d86d65..949f52b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -11,7 +11,7 @@ latest-recommended false $(NoWarn);CS1591;CA1707;CA1848;CA1859;CA1873 - 10.0.5 + 10.0.6 $(Version) diff --git a/ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetCallerGrain.cs b/ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetCallerGrain.cs new file mode 100644 index 0000000..6654c94 --- /dev/null +++ b/ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetCallerGrain.cs @@ -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 CallServiceAsync(CancellationToken cancellationToken) => + client.ProbeAsync(localSilo.SiloAddress, cancellationToken); + + public Task CallAllowedApplicationAsync(int input) => + GrainFactory.GetGrain(this.GetPrimaryKey().ToString()).MethodB1(input); + + public Task CallDeniedApplicationAsync(int input) => + GrainFactory.GetGrain(this.GetPrimaryKey().ToString()).MethodC1(input); +} diff --git a/ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetProbeClient.cs b/ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetProbeClient.cs new file mode 100644 index 0000000..34a7dc4 --- /dev/null +++ b/ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetProbeClient.cs @@ -0,0 +1,9 @@ +using Orleans.Runtime.Services; + +namespace ManagedCode.Orleans.Graph.Tests.SystemTargetCluster; + +public sealed class SystemTargetProbeClient(IServiceProvider services) : GrainServiceClient(services) +{ + public Task ProbeAsync(SiloAddress destination, CancellationToken cancellationToken) => + GetGrainService(destination).ProbeAsync(cancellationToken).WaitAsync(cancellationToken); +} diff --git a/ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetProbeContracts.cs b/ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetProbeContracts.cs new file mode 100644 index 0000000..ee74635 --- /dev/null +++ b/ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetProbeContracts.cs @@ -0,0 +1,32 @@ +using Orleans.Services; + +namespace ManagedCode.Orleans.Graph.Tests.SystemTargetCluster; + +[Alias(SystemTargetTestProtocol.ProbeAlias)] +public interface ISystemTargetProbe : IGrainService +{ + [Alias(SystemTargetTestProtocol.ProbeMethodAlias)] + Task 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 CallServiceAsync(CancellationToken cancellationToken); + + [Alias(SystemTargetTestProtocol.AllowedMethodAlias)] + Task CallAllowedApplicationAsync(int input); + + [Alias(SystemTargetTestProtocol.DeniedMethodAlias)] + Task CallDeniedApplicationAsync(int input); +} diff --git a/ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetProbeGrainService.cs b/ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetProbeGrainService.cs new file mode 100644 index 0000000..4a25ddc --- /dev/null +++ b/ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetProbeGrainService.cs @@ -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(); + 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 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)); + } +} diff --git a/ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetProbeObservation.cs b/ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetProbeObservation.cs new file mode 100644 index 0000000..9f6c426 --- /dev/null +++ b/ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetProbeObservation.cs @@ -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); diff --git a/ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetTestCluster.cs b/ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetTestCluster.cs new file mode 100644 index 0000000..49436a6 --- /dev/null +++ b/ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetTestCluster.cs @@ -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(); + Cluster = builder.Build(); + Cluster.Deploy(); + } + + public TestCluster Cluster { get; } + public IServiceProvider PrimaryServices => Cluster.GetSiloServiceProvider(Cluster.Primary!.SiloAddress); + + public ISystemTargetCallerGrain CreateCaller() => PrimaryServices.GetRequiredService() + .GetGrain(Guid.NewGuid()); + + public ValueTask DisposeAsync() => Cluster.DisposeAsync(); +} diff --git a/ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetTestProtocol.cs b/ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetTestProtocol.cs new file mode 100644 index 0000000..254bb6f --- /dev/null +++ b/ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetTestProtocol.cs @@ -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); +} diff --git a/ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetTestSiloConfiguration.cs b/ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetTestSiloConfiguration.cs new file mode 100644 index 0000000..42677c9 --- /dev/null +++ b/ManagedCode.Orleans.Graph.Tests/SystemTargetCluster/SystemTargetTestSiloConfiguration.cs @@ -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(); + siloBuilder.Services.AddSingleton(); + siloBuilder.AddGrainService(); + siloBuilder.AddOrleansGraph(configureGraph: graph => + { + graph.AllowClientCallGrain(); + graph.AddGrainTransition().MethodByName( + nameof(ISystemTargetCallerGrain.CallAllowedApplicationAsync), nameof(IGrainB.MethodB1)); + }); + } +} diff --git a/ManagedCode.Orleans.Graph.Tests/SystemTargetTrackingTests.cs b/ManagedCode.Orleans.Graph.Tests/SystemTargetTrackingTests.cs new file mode 100644 index 0000000..5ee5ac7 --- /dev/null +++ b/ManagedCode.Orleans.Graph.Tests/SystemTargetTrackingTests.cs @@ -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(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(); + 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(); + foreach (var silo in fixture.Cluster.Silos) + { + var observation = fixture.Cluster.GetSiloServiceProvider(silo.SiloAddress) + .GetRequiredService(); + 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(() => + caller.CallDeniedApplicationAsync(SystemTargetTestProtocol.ApplicationInput).WaitAsync(deadline.Token)); + exception.Message.ShouldStartWith(SystemTargetTestProtocol.TransitionDeniedPrefix); + } + + [Test] + public async Task ExplicitTrackingChecksIncomingSystemTargetCallsBeforeExecutionAsync(CancellationToken cancellationToken) + { + var client = fixture.PrimaryServices.GetRequiredService(); + 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> invoke, CancellationToken cancellationToken) + { + var configuration = fixture.PrimaryServices.GetRequiredService(); + var observation = fixture.PrimaryServices.GetRequiredService(); + var before = observation.Executions; + using var deadline = CreateDeadline(cancellationToken); + configuration.TrackOrleansCalls = true; + try + { + var exception = await Should.ThrowAsync(() => 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; + } +} diff --git a/ManagedCode.Orleans.Graph/Extensions/RequestContextHelper.cs b/ManagedCode.Orleans.Graph/Extensions/RequestContextHelper.cs index acaadcd..7b7f84b 100644 --- a/ManagedCode.Orleans.Graph/Extensions/RequestContextHelper.cs +++ b/ManagedCode.Orleans.Graph/Extensions/RequestContextHelper.cs @@ -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; } diff --git a/docs/ReleaseNotes/10.0.6.md b/docs/ReleaseNotes/10.0.6.md new file mode 100644 index 0000000..ace0782 --- /dev/null +++ b/docs/ReleaseNotes/10.0.6.md @@ -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.