Skip to content

Commit cd96b7f

Browse files
committed
deactivation on idle
1 parent 64a0e05 commit cd96b7f

11 files changed

Lines changed: 199 additions & 28 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ Update guidelines:
3232
- **Call Tracking**: Resolve outgoing call identities from the Orleans context (`SourceId`/interface metadata) instead of guessing via reflection order to avoid mislabelling callers.
3333
- **Configuration Flags**: Treat `AllowAll`/`DisallowAll` on `GrainCallsBuilder` as authoritative defaults; ensure runtime checks respect the builder’s chosen baseline before reporting violations.
3434
- **Runtime Graph Telemetry**: For live graph features, have filters report observed calls to stateless worker aggregators that periodically flush to an in-memory grain; do not rely on per-request `CallHistory` alone for a global runtime graph.
35+
- **Runtime Graph Telemetry Lifecycle**: Do not pin live telemetry grains with `DelayDeactivation(Timeout.InfiniteTimeSpan)`; graph diagnostics must become idle and eligible for Orleans activation collection when no observed calls remain.
36+
- **Runtime Graph Telemetry Timers**: Keep the package default live telemetry flush period low-frequency, such as 30 seconds, and flush buffered worker calls during deactivation so idle collection does not drop pending observed edges.
3537
- **Runtime Graph Identity**: Live graph nodes must never silently fall back to the Orleans base `Grain` type or any guessed identity; use a concrete grain implementation class or a real grain interface, and use the explicit `UNKNOWN_CALLER` vertex when Orleans exposes neither.
3638
- **Type Identity Literals**: Do not hardcode framework type identity strings such as Orleans interface full names; resolve them from `typeof(...).FullName` or an equivalent type-safe API so renames stay correct.
3739
- **Runtime Graph API Shape**: Expose live telemetry as a graph model with explicit `Vertices` and `Edges`; Mermaid output is only a renderer for that same graph, while methods/counts/timestamps belong on edges.

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<AnalysisLevel>latest-recommended</AnalysisLevel>
1212
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
1313
<NoWarn>$(NoWarn);CS1591;CA1707;CA1848;CA1859;CA1873</NoWarn>
14-
<Version>10.0.3</Version>
14+
<Version>10.0.4</Version>
1515
<PackageVersion>$(Version)</PackageVersion>
1616
</PropertyGroup>
1717

ManagedCode.Orleans.Graph.Tests/Cluster/Grains/GrainA.cs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ public class GrainA : Grain, IGrainA, IRemindable
88
private const int TimerOriginatedCallInput = 41;
99
private const int ReminderOriginatedCallInput = 41;
1010
private const string ReminderOriginatedCallName = "reminder-originated-call";
11-
private static readonly TimeSpan TimerOriginatedCallDueTime = TimeSpan.FromMilliseconds(25);
12-
private static readonly TimeSpan TimerOriginatedCallPeriod = Timeout.InfiniteTimeSpan;
13-
private static readonly TimeSpan ReminderOriginatedCallDueTime = TimeSpan.FromMilliseconds(100);
14-
private static readonly TimeSpan ReminderOriginatedCallPeriod = TimeSpan.FromSeconds(1);
11+
private static readonly TimeSpan _timerOriginatedCallDueTime = TimeSpan.FromMilliseconds(25);
12+
private static readonly TimeSpan _timerOriginatedCallPeriod = Timeout.InfiniteTimeSpan;
13+
private static readonly TimeSpan _reminderOriginatedCallDueTime = TimeSpan.FromMilliseconds(100);
14+
private static readonly TimeSpan _reminderOriginatedCallPeriod = TimeSpan.FromSeconds(1);
1515
private IGrainTimer? _timerOriginatedCall;
1616
private int? _timerOriginatedCallResult;
1717
private string? _timerOriginatedCallFailure;
@@ -63,8 +63,8 @@ public Task StartTimerOriginatedCallAsync()
6363
RunTimerOriginatedCallAsync,
6464
new GrainTimerCreationOptions
6565
{
66-
DueTime = TimerOriginatedCallDueTime,
67-
Period = TimerOriginatedCallPeriod,
66+
DueTime = _timerOriginatedCallDueTime,
67+
Period = _timerOriginatedCallPeriod,
6868
Interleave = true,
6969
KeepAlive = false
7070
});
@@ -91,8 +91,8 @@ public async Task StartReminderOriginatedCallAsync()
9191

9292
await this.RegisterOrUpdateReminder(
9393
ReminderOriginatedCallName,
94-
ReminderOriginatedCallDueTime,
95-
ReminderOriginatedCallPeriod);
94+
_reminderOriginatedCallDueTime,
95+
_reminderOriginatedCallPeriod);
9696
}
9797

9898
public Task<int?> GetReminderOriginatedCallResultAsync()

ManagedCode.Orleans.Graph.Tests/Cluster/Grains/StatelessWorkerCallerGrain.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ namespace ManagedCode.Orleans.Graph.Tests.Cluster.Grains;
77
public class StatelessWorkerCallerGrain : Grain, IStatelessWorkerCallerGrain
88
{
99
private const int TimerOriginatedCallInput = 41;
10-
private static readonly TimeSpan TimerOriginatedCallDueTime = TimeSpan.FromMilliseconds(25);
11-
private static readonly TimeSpan TimerOriginatedCallPeriod = Timeout.InfiniteTimeSpan;
10+
private static readonly TimeSpan _timerOriginatedCallDueTime = TimeSpan.FromMilliseconds(25);
11+
private static readonly TimeSpan _timerOriginatedCallPeriod = Timeout.InfiniteTimeSpan;
1212
private IGrainTimer? _timerOriginatedCall;
1313

1414
public async Task<int> CallGrainBAsync(int input)
@@ -36,8 +36,8 @@ public async Task StartTimerOriginatedCallAsync()
3636
RunTimerOriginatedCallAsync,
3737
new GrainTimerCreationOptions
3838
{
39-
DueTime = TimerOriginatedCallDueTime,
40-
Period = TimerOriginatedCallPeriod,
39+
DueTime = _timerOriginatedCallDueTime,
40+
Period = _timerOriginatedCallPeriod,
4141
Interleave = true,
4242
KeepAlive = false
4343
});

ManagedCode.Orleans.Graph.Tests/GraphCallFilterConfigTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ public void Defaults_DoNotTrackOrleansOrOrleansGraphInternalCalls()
1111

1212
config.TrackOrleansCalls.ShouldBeFalse();
1313
config.TrackOrleansGraphInternalCalls.ShouldBeFalse();
14-
config.LiveGraphFlushPeriod.ShouldBeGreaterThan(TimeSpan.Zero);
14+
config.LiveGraphFlushPeriod.ShouldBe(TimeSpan.FromSeconds(30));
1515
}
1616

1717
[Test]
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
using ManagedCode.Orleans.Graph.Extensions;
2+
using Orleans.TestingHost;
3+
4+
namespace ManagedCode.Orleans.Graph.Tests.RuntimeGraphCluster;
5+
6+
public class TestRuntimeGraphSlowFlushClusterApplication : IDisposable, IAsyncDisposable
7+
{
8+
private bool _disposed;
9+
10+
public TestRuntimeGraphSlowFlushClusterApplication()
11+
{
12+
var builder = new TestClusterBuilder();
13+
builder.AddSiloBuilderConfigurator<TestRuntimeGraphSlowFlushSiloConfigurations>();
14+
builder.AddClientBuilderConfigurator<TestRuntimeGraphClientConfigurations>();
15+
Cluster = builder.Build();
16+
Cluster.Deploy();
17+
}
18+
19+
public TestCluster Cluster { get; }
20+
21+
public void Dispose()
22+
{
23+
if (_disposed)
24+
{
25+
return;
26+
}
27+
28+
_disposed = true;
29+
Cluster.Dispose();
30+
GC.SuppressFinalize(this);
31+
}
32+
33+
public async ValueTask DisposeAsync()
34+
{
35+
if (_disposed)
36+
{
37+
return;
38+
}
39+
40+
_disposed = true;
41+
await Cluster.DisposeAsync();
42+
GC.SuppressFinalize(this);
43+
}
44+
}
45+
46+
public class TestRuntimeGraphSlowFlushSiloConfigurations : ISiloConfigurator
47+
{
48+
public void Configure(ISiloBuilder siloBuilder)
49+
{
50+
siloBuilder.AddOrleansGraph(
51+
configureFilters: filters =>
52+
{
53+
filters.LiveGraphFlushPeriod = TimeSpan.FromMinutes(30);
54+
},
55+
configureGraph: graph => graph.AllowAll());
56+
}
57+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
using ManagedCode.Orleans.Graph.Interfaces;
2+
using ManagedCode.Orleans.Graph.Models;
3+
using ManagedCode.Orleans.Graph.Tests.RuntimeGraphCluster;
4+
5+
namespace ManagedCode.Orleans.Graph.Tests;
6+
7+
public class RuntimeGraphTelemetryLifecycleTests
8+
{
9+
[Test]
10+
public async Task TelemetryGrain_EmptyGraphAccess_AllowsIdleActivationCollectionAsync()
11+
{
12+
await using var fixture = new TestRuntimeGraphClusterApplication();
13+
var telemetry = fixture.Cluster.Client.GetGrain<IOrleansGraphTelemetryGrain>(Constants.LiveGraphTelemetryGrainKey);
14+
15+
var graph = await telemetry.GetObservedGraphAsync();
16+
graph.Edges.Count.ShouldBe(0);
17+
18+
await AssertActivationCollectedAsync(fixture.Cluster.Client, telemetry);
19+
}
20+
21+
[Test]
22+
public async Task TelemetryWorker_DeactivationFlushesBufferedObservedCallsAsync()
23+
{
24+
await using var fixture = new TestRuntimeGraphSlowFlushClusterApplication();
25+
var worker = fixture.Cluster.Client.GetGrain<IOrleansGraphTelemetryWorker>(Constants.LiveGraphTelemetryGrainKey);
26+
27+
await worker.RecordObservedCallAsync(ObservedGrainCall.Create(
28+
"source",
29+
"target",
30+
"SourceMethod",
31+
"TargetMethod"));
32+
33+
await fixture.Cluster.Client
34+
.GetGrain<IManagementGrain>(0)
35+
.ForceActivationCollection(TimeSpan.Zero);
36+
37+
var graph = await WaitForObservedGraphAsync(
38+
fixture.Cluster.Client,
39+
graph => graph.Edges.Any(IsExpectedObservedEdge));
40+
41+
graph.Edges.ShouldContain(edge => IsExpectedObservedEdge(edge));
42+
}
43+
44+
private static async Task AssertActivationCollectedAsync(IGrainFactory grainFactory, IAddressable grainReference)
45+
{
46+
var management = grainFactory.GetGrain<IManagementGrain>(0);
47+
for (var attempt = 0; attempt < 20; attempt++)
48+
{
49+
await management.ForceActivationCollection(TimeSpan.Zero);
50+
await Task.Delay(100);
51+
52+
var currentAddress = await management.GetActivationAddress(grainReference);
53+
if (currentAddress is null)
54+
{
55+
return;
56+
}
57+
}
58+
59+
var finalAddress = await management.GetActivationAddress(grainReference);
60+
finalAddress.ShouldBeNull();
61+
}
62+
63+
private static async Task<ObservedGrainCallGraph> WaitForObservedGraphAsync(
64+
IGrainFactory grainFactory,
65+
Func<ObservedGrainCallGraph, bool> predicate)
66+
{
67+
var telemetry = grainFactory.GetGrain<IOrleansGraphTelemetryGrain>(Constants.LiveGraphTelemetryGrainKey);
68+
for (var attempt = 0; attempt < 20; attempt++)
69+
{
70+
var graph = await telemetry.GetObservedGraphAsync();
71+
if (predicate(graph))
72+
{
73+
return graph;
74+
}
75+
76+
await Task.Delay(100);
77+
}
78+
79+
return await telemetry.GetObservedGraphAsync();
80+
}
81+
82+
private static bool IsExpectedObservedEdge(ObservedGrainCall edge)
83+
{
84+
return string.Equals(edge.Source, "source", StringComparison.Ordinal) &&
85+
string.Equals(edge.Target, "target", StringComparison.Ordinal) &&
86+
string.Equals(edge.SourceMethod, "SourceMethod", StringComparison.Ordinal) &&
87+
string.Equals(edge.TargetMethod, "TargetMethod", StringComparison.Ordinal);
88+
}
89+
}

ManagedCode.Orleans.Graph/Models/GraphCallFilterConfig.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ namespace ManagedCode.Orleans.Graph.Models;
55
[Alias("MC.GraphCallFilterConfig")]
66
public class GraphCallFilterConfig
77
{
8+
public static readonly TimeSpan DefaultLiveGraphFlushPeriod = TimeSpan.FromSeconds(30);
9+
810
[Id(0)]
911
public bool TrackOrleansCalls { get; set; } = false;
1012

1113
[Id(1)]
1214
public bool TrackOrleansGraphInternalCalls { get; set; } = false;
1315

1416
[Id(2)]
15-
public TimeSpan LiveGraphFlushPeriod { get; set; } = TimeSpan.FromSeconds(1);
17+
public TimeSpan LiveGraphFlushPeriod { get; set; } = DefaultLiveGraphFlushPeriod;
1618
}

ManagedCode.Orleans.Graph/Telemetry/OrleansGraphTelemetryGrain.cs

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,6 @@ public sealed class OrleansGraphTelemetryGrain : Grain, IOrleansGraphTelemetryGr
88
{
99
private readonly Dictionary<ObservedGrainCallKey, ObservedGrainCallAccumulator> _observedCalls = new();
1010

11-
public override Task OnActivateAsync(CancellationToken cancellationToken)
12-
{
13-
DelayDeactivation(Timeout.InfiniteTimeSpan);
14-
return Task.CompletedTask;
15-
}
16-
1711
public Task MergeObservedCallsAsync(IReadOnlyCollection<ObservedGrainCall> observedCalls)
1812
{
1913
ArgumentNullException.ThrowIfNull(observedCalls);
@@ -24,21 +18,34 @@ public Task MergeObservedCallsAsync(IReadOnlyCollection<ObservedGrainCall> obser
2418

2519
public Task<ObservedGrainCallGraph> GetObservedGraphAsync()
2620
{
27-
return Task.FromResult(GrainTransitionManager.BuildObservedGraphFromSnapshot(CreateSnapshot()));
21+
var snapshot = CreateSnapshot();
22+
DeactivateIfEmpty(snapshot);
23+
return Task.FromResult(GrainTransitionManager.BuildObservedGraphFromSnapshot(snapshot));
2824
}
2925

3026
public Task<string> GenerateLiveMermaidDiagramAsync()
3127
{
32-
var observedGraph = GrainTransitionManager.BuildObservedGraphFromSnapshot(CreateSnapshot());
28+
var snapshot = CreateSnapshot();
29+
DeactivateIfEmpty(snapshot);
30+
var observedGraph = GrainTransitionManager.BuildObservedGraphFromSnapshot(snapshot);
3331
return Task.FromResult(GrainTransitionManager.GenerateObservedGraphMermaidDiagram(observedGraph));
3432
}
3533

3634
public Task ClearAsync()
3735
{
3836
_observedCalls.Clear();
37+
DeactivateOnIdle();
3938
return Task.CompletedTask;
4039
}
4140

41+
private void DeactivateIfEmpty(ObservedGrainCall[] snapshot)
42+
{
43+
if (snapshot.Length == 0)
44+
{
45+
DeactivateOnIdle();
46+
}
47+
}
48+
4249
public void RecordObservedCalls(IReadOnlyCollection<ObservedGrainCall> observedCalls)
4350
{
4451
foreach (var observedCall in observedCalls)

ManagedCode.Orleans.Graph/Telemetry/OrleansGraphTelemetryWorker.cs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,13 @@ namespace ManagedCode.Orleans.Graph.Telemetry;
99
[StatelessWorker(1)]
1010
public sealed class OrleansGraphTelemetryWorker(GraphCallFilterConfig graphCallFilterConfig) : Grain, IOrleansGraphTelemetryWorker, IObservedGrainCallSink
1111
{
12-
private static readonly TimeSpan _defaultFlushPeriod = TimeSpan.FromSeconds(1);
1312
private readonly Dictionary<ObservedGrainCallKey, ObservedGrainCallAccumulator> _observedCalls = new();
1413

1514
public override Task OnActivateAsync(CancellationToken cancellationToken)
1615
{
1716
var flushPeriod = graphCallFilterConfig.LiveGraphFlushPeriod > TimeSpan.Zero
1817
? graphCallFilterConfig.LiveGraphFlushPeriod
19-
: _defaultFlushPeriod;
18+
: GraphCallFilterConfig.DefaultLiveGraphFlushPeriod;
2019

2120
this.RegisterGrainTimer(
2221
FlushTimerAsync,
@@ -31,6 +30,11 @@ public override Task OnActivateAsync(CancellationToken cancellationToken)
3130
return Task.CompletedTask;
3231
}
3332

33+
public override Task OnDeactivateAsync(DeactivationReason reason, CancellationToken cancellationToken)
34+
{
35+
return FlushBufferedCallsAsync(deactivateWhenEmpty: false);
36+
}
37+
3438
public Task RecordObservedCallsAsync(IReadOnlyCollection<ObservedGrainCall> observedCalls)
3539
{
3640
ArgumentNullException.ThrowIfNull(observedCalls);
@@ -45,10 +49,20 @@ public Task RecordObservedCallAsync(ObservedGrainCall observedCall)
4549
return Task.CompletedTask;
4650
}
4751

48-
public async Task FlushAsync()
52+
public Task FlushAsync()
53+
{
54+
return FlushBufferedCallsAsync(deactivateWhenEmpty: true);
55+
}
56+
57+
private async Task FlushBufferedCallsAsync(bool deactivateWhenEmpty)
4958
{
5059
if (_observedCalls.Count == 0)
5160
{
61+
if (deactivateWhenEmpty)
62+
{
63+
DeactivateOnIdle();
64+
}
65+
5266
return;
5367
}
5468

0 commit comments

Comments
 (0)