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
Binary file added durable0011-warnings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/Analyzers/AnalyzerReleases.Unshipped.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@

Rule ID | Category | Severity | Notes
--------|----------|----------|-------
DURABLE0011 | Orchestration | Warning | ContinueAsNewOrchestrationAnalyzer
109 changes: 109 additions & 0 deletions src/Analyzers/Orchestration/ContinueAsNewOrchestrationAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;
using static Microsoft.DurableTask.Analyzers.Orchestration.ContinueAsNewOrchestrationAnalyzer;

namespace Microsoft.DurableTask.Analyzers.Orchestration;

/// <summary>
/// Analyzer that reports a warning when an orchestration contains an unconditional while loop
/// that calls any TaskOrchestrationContext method (e.g. CallActivityAsync, WaitForExternalEvent,
/// CallSubOrchestratorAsync, CreateTimer) but no ContinueAsNew call within that loop.
/// Every orchestration API call adds to the replay history, so unbounded loops without
/// ContinueAsNew lead to unbounded history growth.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class ContinueAsNewOrchestrationAnalyzer : OrchestrationAnalyzer<ContinueAsNewOrchestrationVisitor>
{
/// <summary>
/// Diagnostic ID supported for the analyzer.
/// </summary>
public const string DiagnosticId = "DURABLE0011";

static readonly LocalizableString Title = new LocalizableResourceString(nameof(Resources.ContinueAsNewOrchestrationAnalyzerTitle), Resources.ResourceManager, typeof(Resources));
static readonly LocalizableString MessageFormat = new LocalizableResourceString(nameof(Resources.ContinueAsNewOrchestrationAnalyzerMessageFormat), Resources.ResourceManager, typeof(Resources));

static readonly DiagnosticDescriptor Rule = new(
DiagnosticId,
Title,
MessageFormat,
AnalyzersCategories.Orchestration,
DiagnosticSeverity.Warning,
isEnabledByDefault: true,
helpLinkUri: "https://aka.ms/durabletask-analyzers");

/// <inheritdoc/>
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [Rule];

/// <summary>
/// Visitor that inspects orchestration methods for unbounded loops without ContinueAsNew.
/// Only direct invocations within the loop body are considered; calls made through helper
/// methods invoked from the loop are not tracked back to the loop context.
/// </summary>
public sealed class ContinueAsNewOrchestrationVisitor : MethodProbeOrchestrationVisitor
{
/// <inheritdoc/>
public override bool Initialize()
{
return this.KnownTypeSymbols.TaskOrchestrationContext is not null;
}

/// <inheritdoc/>
protected override void VisitMethod(SemanticModel semanticModel, SyntaxNode methodSyntax, IMethodSymbol methodSymbol, string orchestrationName, Action<Diagnostic> reportDiagnostic)
{
foreach (WhileStatementSyntax whileStatement in methodSyntax.DescendantNodes().OfType<WhileStatementSyntax>())
{
if (!IsAlwaysTrueCondition(whileStatement.Condition))
{
continue;
}

IOperation? whileOperation = semanticModel.GetOperation(whileStatement);
if (whileOperation is not IWhileLoopOperation whileLoop || whileLoop.Body is null)
{
continue;
}

bool hasHistoryGrowingCall = false;
bool hasContinueAsNew = false;

foreach (IInvocationOperation invocation in whileLoop.Body.Descendants().OfType<IInvocationOperation>())
{
IMethodSymbol targetMethod = invocation.TargetMethod;

if (targetMethod.IsEqualTo(this.KnownTypeSymbols.TaskOrchestrationContext, "ContinueAsNew"))
{
hasContinueAsNew = true;
}
else if (SymbolEqualityComparer.Default.Equals(targetMethod.ContainingType, this.KnownTypeSymbols.TaskOrchestrationContext) ||
SymbolEqualityComparer.Default.Equals(targetMethod.ContainingType?.OriginalDefinition, this.KnownTypeSymbols.TaskOrchestrationContext))
{
hasHistoryGrowingCall = true;
}

if (hasHistoryGrowingCall && hasContinueAsNew)
{
break;
}
}

if (hasHistoryGrowingCall && !hasContinueAsNew)
{
reportDiagnostic(Diagnostic.Create(Rule, whileStatement.WhileKeyword.GetLocation(), orchestrationName));
}
}
}

static bool IsAlwaysTrueCondition(ExpressionSyntax condition)
{
return condition is LiteralExpressionSyntax literal
&& literal.IsKind(SyntaxKind.TrueLiteralExpression);
}
}
}
6 changes: 6 additions & 0 deletions src/Analyzers/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -222,4 +222,10 @@
<data name="GetInputOrchestrationAnalyzerTitle" xml:space="preserve">
<value>Input parameter binding can be used instead of GetInput</value>
</data>
<data name="ContinueAsNewOrchestrationAnalyzerMessageFormat" xml:space="preserve">
<value>Orchestration '{0}' contains an unbounded while(true) loop that calls TaskOrchestrationContext methods but no ContinueAsNew within that loop. This can cause unbounded history growth leading to performance degradation and persistence failures.</value>
</data>
<data name="ContinueAsNewOrchestrationAnalyzerTitle" xml:space="preserve">
<value>Long-running orchestration loops should call ContinueAsNew to bound history growth</value>
</data>
</root>
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Microsoft.CodeAnalysis.Testing;
using Microsoft.DurableTask.Analyzers.Orchestration;

using VerifyCS = Microsoft.DurableTask.Analyzers.Tests.Verifiers.CSharpAnalyzerVerifier<Microsoft.DurableTask.Analyzers.Orchestration.ContinueAsNewOrchestrationAnalyzer>;

namespace Microsoft.DurableTask.Analyzers.Tests.Orchestration;

public class ContinueAsNewOrchestrationAnalyzerTests
{
[Fact]
public async Task EmptyCodeHasNoDiag()
{
string code = @"";

await VerifyCS.VerifyDurableTaskAnalyzerAsync(code);
}

[Fact]
public async Task TaskOrchestratorWhileTrueWithExternalEventNoContinueAsNew_ReportsDiagnostic()
{
string code = Wrapper.WrapTaskOrchestrator(@"
public class MyOrchestrator : TaskOrchestrator<object, object>
{
public override async Task<object> RunAsync(TaskOrchestrationContext context, object input)
{
{|#0:while|} (true)
{
var item = await context.WaitForExternalEvent<string>(""new-work"");
await context.CallActivityAsync<string>(""ProcessItem"", item);
}
}
}
");
DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("MyOrchestrator");

await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected);
}

[Fact]
public async Task TaskOrchestratorWhileTrueWithSubOrchestratorNoContinueAsNew_ReportsDiagnostic()
{
string code = Wrapper.WrapTaskOrchestrator(@"
public class MyOrchestrator : TaskOrchestrator<object, object>
{
public override async Task<object> RunAsync(TaskOrchestrationContext context, object input)
{
{|#0:while|} (true)
{
await context.CallSubOrchestratorAsync<string>(""ProcessItem"", null);
}
}
}
");
DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("MyOrchestrator");

await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected);
}

[Fact]
public async Task TaskOrchestratorWhileTrueWithContextCallsNoContinueAsNew_ReportsDiagnostic()
{
string code = Wrapper.WrapTaskOrchestrator(@"
public class MyOrchestrator : TaskOrchestrator<object, object>
{
public override async Task<object> RunAsync(TaskOrchestrationContext context, object input)
{
{|#0:while|} (true)
{
await context.CallActivityAsync<string>(""DoWork"", ""input"");
await context.CreateTimer(TimeSpan.FromSeconds(30), CancellationToken.None);
}
}
}
");
DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("MyOrchestrator");

await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected);
}

[Fact]
public async Task TaskOrchestratorWhileTrueWithContinueAsNew_NoDiagnostic()
{
string code = Wrapper.WrapTaskOrchestrator(@"
public class MyOrchestrator : TaskOrchestrator<object, object>
{
public override async Task<object> RunAsync(TaskOrchestrationContext context, object input)
{
int count = 0;
while (true)
{
var item = await context.WaitForExternalEvent<string>(""new-work"");
await context.CallSubOrchestratorAsync<string>(""ProcessItem"", item);
count++;
if (count >= 100)
{
context.ContinueAsNew(count);
return null;
}
}
}
}
");

await VerifyCS.VerifyDurableTaskAnalyzerAsync(code);
}

[Fact]
public async Task TaskOrchestratorWhileTrueWithOnlyActivitiesAndContinueAsNew_NoDiagnostic()
{
string code = Wrapper.WrapTaskOrchestrator(@"
public class MyOrchestrator : TaskOrchestrator<object, object>
{
public override async Task<object> RunAsync(TaskOrchestrationContext context, object input)
{
while (true)
{
await context.CallActivityAsync<string>(""DoWork"", ""input"");
await context.CreateTimer(TimeSpan.FromSeconds(30), CancellationToken.None);
context.ContinueAsNew(null);
return null;
}
}
}
");

await VerifyCS.VerifyDurableTaskAnalyzerAsync(code);
}

[Fact]
public async Task TaskOrchestratorNoWhileTrue_NoDiagnostic()
{
string code = Wrapper.WrapTaskOrchestrator(@"
public class MyOrchestrator : TaskOrchestrator<string, string>
{
public override async Task<string> RunAsync(TaskOrchestrationContext context, string input)
{
var result = await context.CallActivityAsync<string>(""DoWork"", input);
return result;
}
}
");

await VerifyCS.VerifyDurableTaskAnalyzerAsync(code);
}

[Fact]
public async Task TaskOrchestratorWhileTrueNoContextCalls_NoDiagnostic()
{
string code = Wrapper.WrapTaskOrchestrator(@"
public class MyOrchestrator : TaskOrchestrator<string, string>
{
public override async Task<string> RunAsync(TaskOrchestrationContext context, string input)
{
int i = 0;
while (true)
{
i++;
if (i > 10) return ""done"";
}
}
}
");

await VerifyCS.VerifyDurableTaskAnalyzerAsync(code);
}

[Fact]
public async Task DurableFunctionWhileTrueWithExternalEventNoContinueAsNew_ReportsDiagnostic()
{
string code = Wrapper.WrapDurableFunctionOrchestration(@"
[Function(""Run"")]
async Task<object> Method([OrchestrationTrigger] TaskOrchestrationContext context)
{
{|#0:while|} (true)
{
var item = await context.WaitForExternalEvent<string>(""new-work"");
await context.CallActivityAsync<string>(""ProcessItem"", item);
}
}
");
DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("Run");

await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected);
}

[Fact]
public async Task FuncOrchestratorWhileTrueWithContextCallsNoContinueAsNew_ReportsDiagnostic()
{
string code = Wrapper.WrapFuncOrchestrator(@"
tasks.AddOrchestratorFunc<string>(""Run"", async context =>
{
{|#0:while|} (true)
{
var item = await context.WaitForExternalEvent<string>(""new-work"");
await context.CallActivityAsync<string>(""ProcessItem"", item);
}
});
");
DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("Run");

await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected);
}

[Fact]
public async Task FuncOrchestratorWhileTrueNoContextCalls_NoDiagnostic()
{
string code = Wrapper.WrapFuncOrchestrator(@"
tasks.AddOrchestratorFunc<string>(""Run"", async context =>
{
int i = 0;
while (true)
{
i++;
if (i > 10) return ""done"";
}
});
");

await VerifyCS.VerifyDurableTaskAnalyzerAsync(code);
}

static DiagnosticResult BuildDiagnostic()
{
return VerifyCS.Diagnostic(ContinueAsNewOrchestrationAnalyzer.DiagnosticId);
}
}
Loading