Skip to content
Merged
2 changes: 1 addition & 1 deletion global.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"sdk": {
"version": "10.0.101",
"version": "10.0.100",
"rollForward": "latestFeature"
},
"msbuild-sdks": {
Expand Down
6 changes: 6 additions & 0 deletions src/Analyzers/KnownTypeSymbols.Net.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public sealed partial class KnownTypeSymbols
INamedTypeSymbol? cancellationToken;
INamedTypeSymbol? environment;
INamedTypeSymbol? httpClient;
INamedTypeSymbol? timeProvider;

/// <summary>
/// Gets a Guid type symbol.
Expand Down Expand Up @@ -75,4 +76,9 @@ public sealed partial class KnownTypeSymbols
/// Gets an HttpClient type symbol.
/// </summary>
public INamedTypeSymbol? HttpClient => this.GetOrResolveFullyQualifiedType(typeof(HttpClient).FullName, ref this.httpClient);

/// <summary>
/// Gets a TimeProvider type symbol.
/// </summary>
public INamedTypeSymbol? TimeProvider => this.GetOrResolveFullyQualifiedType("System.TimeProvider", ref this.timeProvider);
}
31 changes: 30 additions & 1 deletion src/Analyzers/Orchestration/DateTimeOrchestrationAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
namespace Microsoft.DurableTask.Analyzers.Orchestration;

/// <summary>
/// Analyzer that reports a warning when a non-deterministic DateTime or DateTimeOffset property is used in an orchestration method.
/// Analyzer that reports a warning when a non-deterministic DateTime, DateTimeOffset, or TimeProvider method is used in an orchestration method.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class DateTimeOrchestrationAnalyzer : OrchestrationAnalyzer<DateTimeOrchestrationVisitor>
Expand Down Expand Up @@ -41,12 +41,14 @@ public sealed class DateTimeOrchestrationVisitor : MethodProbeOrchestrationVisit
{
INamedTypeSymbol systemDateTimeSymbol = null!;
INamedTypeSymbol? systemDateTimeOffsetSymbol;
INamedTypeSymbol? systemTimeProviderSymbol;

/// <inheritdoc/>
public override bool Initialize()
{
this.systemDateTimeSymbol = this.Compilation.GetSpecialType(SpecialType.System_DateTime);
this.systemDateTimeOffsetSymbol = this.Compilation.GetTypeByMetadataName("System.DateTimeOffset");
this.systemTimeProviderSymbol = this.Compilation.GetTypeByMetadataName("System.TimeProvider");
return true;
}

Expand Down Expand Up @@ -85,6 +87,33 @@ protected override void VisitMethod(SemanticModel semanticModel, SyntaxNode meth
reportDiagnostic(RoslynExtensions.BuildDiagnostic(Rule, operation.Syntax, methodSymbol.Name, property.ToString(), orchestrationName));
}
}

// Check for TimeProvider method invocations
if (this.systemTimeProviderSymbol is not null)
{
foreach (IInvocationOperation operation in methodOperation.Descendants().OfType<IInvocationOperation>())
{
IMethodSymbol invokedMethod = operation.TargetMethod;

// Check if the method is called on TimeProvider type
bool isTimeProvider = invokedMethod.ContainingType.Equals(this.systemTimeProviderSymbol, SymbolEqualityComparer.Default);

if (!isTimeProvider)
{
continue;
}

// Check for non-deterministic TimeProvider methods: GetUtcNow, GetLocalNow, GetTimestamp
bool isNonDeterministicMethod = invokedMethod.Name is "GetUtcNow" or "GetLocalNow" or "GetTimestamp";

if (isNonDeterministicMethod)
{
// e.g.: "The method 'Method1' uses 'System.TimeProvider.GetUtcNow()' that may cause non-deterministic behavior when invoked from orchestration 'MyOrchestrator'"
string timeProviderMethodName = $"{invokedMethod.ContainingType}.{invokedMethod.Name}()";
reportDiagnostic(RoslynExtensions.BuildDiagnostic(Rule, operation.Syntax, methodSymbol.Name, timeProviderMethodName, orchestrationName));
}
}
}
}
}
}
155 changes: 118 additions & 37 deletions src/Analyzers/Orchestration/DateTimeOrchestrationFixer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,51 +26,87 @@ public sealed class DateTimeOrchestrationFixer : OrchestrationContextFixer
/// <inheritdoc/>
protected override void RegisterCodeFixes(CodeFixContext context, OrchestrationCodeFixContext orchestrationContext)
{
// Parses the syntax node to see if it is a member access expression (e.g. DateTime.Now or DateTimeOffset.Now)
if (orchestrationContext.SyntaxNodeWithDiagnostic is not MemberAccessExpressionSyntax dateTimeExpression)
{
return;
}

// Gets the name of the TaskOrchestrationContext parameter (e.g. "context" or "ctx")
string contextParameterName = orchestrationContext.TaskOrchestrationContextSymbol.Name;

// Use semantic analysis to determine if this is a DateTimeOffset expression
SemanticModel semanticModel = orchestrationContext.SemanticModel;
ITypeSymbol? typeSymbol = semanticModel.GetTypeInfo(dateTimeExpression.Expression).Type;
bool isDateTimeOffset = typeSymbol?.ToDisplayString() == "System.DateTimeOffset";

bool isDateTimeToday = dateTimeExpression.Name.ToString() == "Today";

// Build the recommendation text
string recommendation;
if (isDateTimeOffset)
// Handle DateTime/DateTimeOffset property access (e.g. DateTime.Now or DateTimeOffset.Now)
if (orchestrationContext.SyntaxNodeWithDiagnostic is MemberAccessExpressionSyntax dateTimeExpression)
{
// For DateTimeOffset, we always just cast CurrentUtcDateTime
recommendation = $"(DateTimeOffset){contextParameterName}.CurrentUtcDateTime";
// Use semantic analysis to determine if this is a DateTimeOffset expression
ITypeSymbol? typeSymbol = semanticModel.GetTypeInfo(dateTimeExpression.Expression).Type;
bool isDateTimeOffset = typeSymbol?.ToDisplayString() == "System.DateTimeOffset";

bool isDateTimeToday = dateTimeExpression.Name.ToString() == "Today";

// Build the recommendation text
string recommendation;
if (isDateTimeOffset)
{
// For DateTimeOffset, we always just cast CurrentUtcDateTime
recommendation = $"(DateTimeOffset){contextParameterName}.CurrentUtcDateTime";
}
else
{
// For DateTime, we may need to add .Date for Today
string dateTimeTodaySuffix = isDateTimeToday ? ".Date" : string.Empty;
recommendation = $"{contextParameterName}.CurrentUtcDateTime{dateTimeTodaySuffix}";
}

// e.g: "Use 'context.CurrentUtcDateTime' instead of 'DateTime.Now'"
// e.g: "Use 'context.CurrentUtcDateTime.Date' instead of 'DateTime.Today'"
// e.g: "Use '(DateTimeOffset)context.CurrentUtcDateTime' instead of 'DateTimeOffset.Now'"
string title = string.Format(
CultureInfo.InvariantCulture,
Resources.UseInsteadFixerTitle,
recommendation,
dateTimeExpression.ToString());

context.RegisterCodeFix(
CodeAction.Create(
title: title,
createChangedDocument: c => ReplaceDateTime(context.Document, orchestrationContext.Root, dateTimeExpression, contextParameterName, isDateTimeToday, isDateTimeOffset),
equivalenceKey: title), // This key is used to prevent duplicate code fixes.
context.Diagnostics);
}
else

// Handle TimeProvider method invocations (e.g. TimeProvider.System.GetUtcNow())
else if (orchestrationContext.SyntaxNodeWithDiagnostic is InvocationExpressionSyntax timeProviderInvocation)
{
// For DateTime, we may need to add .Date for Today
string dateTimeTodaySuffix = isDateTimeToday ? ".Date" : string.Empty;
recommendation = $"{contextParameterName}.CurrentUtcDateTime{dateTimeTodaySuffix}";
}
// Determine the method being called
if (semanticModel.GetSymbolInfo(timeProviderInvocation).Symbol is IMethodSymbol methodSymbol)
{
string methodName = methodSymbol.Name;

// Check if the method returns DateTimeOffset
bool returnsDateTimeOffset = methodSymbol.ReturnType.ToDisplayString() == "System.DateTimeOffset";

// e.g: "Use 'context.CurrentUtcDateTime' instead of 'DateTime.Now'"
// e.g: "Use 'context.CurrentUtcDateTime.Date' instead of 'DateTime.Today'"
// e.g: "Use '(DateTimeOffset)context.CurrentUtcDateTime' instead of 'DateTimeOffset.Now'"
string title = string.Format(
CultureInfo.InvariantCulture,
Resources.UseInsteadFixerTitle,
recommendation,
dateTimeExpression.ToString());

context.RegisterCodeFix(
CodeAction.Create(
title: title,
createChangedDocument: c => ReplaceDateTime(context.Document, orchestrationContext.Root, dateTimeExpression, contextParameterName, isDateTimeToday, isDateTimeOffset),
equivalenceKey: title), // This key is used to prevent duplicate code fixes.
context.Diagnostics);
// Build the recommendation based on the method name
string recommendation = methodName switch
{
"GetUtcNow" when returnsDateTimeOffset => $"(DateTimeOffset){contextParameterName}.CurrentUtcDateTime",
"GetUtcNow" => $"{contextParameterName}.CurrentUtcDateTime",
"GetLocalNow" when returnsDateTimeOffset => $"(DateTimeOffset){contextParameterName}.CurrentUtcDateTime.ToLocalTime()",
"GetLocalNow" => $"{contextParameterName}.CurrentUtcDateTime.ToLocalTime()",
"GetTimestamp" => $"{contextParameterName}.CurrentUtcDateTime.Ticks",
_ => $"{contextParameterName}.CurrentUtcDateTime",
};

// e.g: "Use 'context.CurrentUtcDateTime' instead of 'TimeProvider.System.GetUtcNow()'"
string title = string.Format(
CultureInfo.InvariantCulture,
Resources.UseInsteadFixerTitle,
recommendation,
timeProviderInvocation.ToString());

context.RegisterCodeFix(
CodeAction.Create(
title: title,
createChangedDocument: c => ReplaceTimeProvider(context.Document, orchestrationContext.Root, timeProviderInvocation, contextParameterName, methodName, returnsDateTimeOffset),
equivalenceKey: title),
context.Diagnostics);
}
}
}

static Task<Document> ReplaceDateTime(Document document, SyntaxNode oldRoot, MemberAccessExpressionSyntax incorrectDateTimeSyntax, string contextParameterName, bool isDateTimeToday, bool isDateTimeOffset)
Expand Down Expand Up @@ -106,4 +142,49 @@ static Task<Document> ReplaceDateTime(Document document, SyntaxNode oldRoot, Mem

return Task.FromResult(newDocument);
}

static Task<Document> ReplaceTimeProvider(Document document, SyntaxNode oldRoot, InvocationExpressionSyntax incorrectTimeProviderSyntax, string contextParameterName, string methodName, bool returnsDateTimeOffset)
{
// Build the correct expression based on the method name
ExpressionSyntax correctExpression = methodName switch
{
"GetUtcNow" => MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
IdentifierName(contextParameterName),
IdentifierName("CurrentUtcDateTime")),
"GetLocalNow" => InvocationExpression(
MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
IdentifierName(contextParameterName),
IdentifierName("CurrentUtcDateTime")),
IdentifierName("ToLocalTime"))),
"GetTimestamp" => MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
IdentifierName(contextParameterName),
IdentifierName("CurrentUtcDateTime")),
IdentifierName("Ticks")),
_ => MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
IdentifierName(contextParameterName),
IdentifierName("CurrentUtcDateTime")),
};

// If the method returns DateTimeOffset, we need to cast the DateTime to DateTimeOffset
if (returnsDateTimeOffset)
{
correctExpression = CastExpression(
IdentifierName("DateTimeOffset"),
correctExpression);
}

// Replaces the old invocation with the new expression
SyntaxNode newRoot = oldRoot.ReplaceNode(incorrectTimeProviderSyntax, correctExpression);
Document newDocument = document.WithSyntaxRoot(newRoot);

return Task.FromResult(newDocument);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,130 @@ public async Task FuncOrchestratorWithDateTimeOffsetHasDiag()
await VerifyCS.VerifyDurableTaskCodeFixAsync(code, expected, fix);
}

[Theory]
[InlineData("TimeProvider.System.GetUtcNow()")]
[InlineData("TimeProvider.System.GetLocalNow()")]
public async Task DurableFunctionOrchestrationUsingTimeProviderNonDeterministicMethodsHasDiag(string expression)
{
string code = Wrapper.WrapDurableFunctionOrchestration($@"
[Function(""Run"")]
DateTimeOffset Run([OrchestrationTrigger] TaskOrchestrationContext context)
{{
return {{|#0:{expression}|}};
}}
");

string expectedReplacement = expression.Contains("GetLocalNow")
? "(DateTimeOffset)context.CurrentUtcDateTime.ToLocalTime()"
: "(DateTimeOffset)context.CurrentUtcDateTime";

string fix = Wrapper.WrapDurableFunctionOrchestration($@"
[Function(""Run"")]
DateTimeOffset Run([OrchestrationTrigger] TaskOrchestrationContext context)
{{
return {expectedReplacement};
}}
");

// The analyzer reports the method name as "System.TimeProvider.GetUtcNow()" or "System.TimeProvider.GetLocalNow()"
string methodName = expression.Contains("GetLocalNow") ? "System.TimeProvider.GetLocalNow()" : "System.TimeProvider.GetUtcNow()";
DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("Run", methodName, "Run");

await VerifyCS.VerifyDurableTaskCodeFixAsync(code, expected, fix);
}

[Fact]
public async Task DurableFunctionOrchestrationUsingTimeProviderGetTimestampHasDiag()
{
string code = Wrapper.WrapDurableFunctionOrchestration(@"
[Function(""Run"")]
long Run([OrchestrationTrigger] TaskOrchestrationContext context)
{
return {|#0:TimeProvider.System.GetTimestamp()|};
}
");

string fix = Wrapper.WrapDurableFunctionOrchestration(@"
[Function(""Run"")]
long Run([OrchestrationTrigger] TaskOrchestrationContext context)
{
return context.CurrentUtcDateTime.Ticks;
}
");

DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("Run", "System.TimeProvider.GetTimestamp()", "Run");

await VerifyCS.VerifyDurableTaskCodeFixAsync(code, expected, fix);
}

[Fact]
public async Task TaskOrchestratorUsingTimeProviderHasDiag()
{
string code = Wrapper.WrapTaskOrchestrator(@"
public class MyOrchestrator : TaskOrchestrator<string, DateTimeOffset>
{
public override Task<DateTimeOffset> RunAsync(TaskOrchestrationContext context, string input)
{
return Task.FromResult({|#0:TimeProvider.System.GetUtcNow()|});
}
}
");

string fix = Wrapper.WrapTaskOrchestrator(@"
public class MyOrchestrator : TaskOrchestrator<string, DateTimeOffset>
{
public override Task<DateTimeOffset> RunAsync(TaskOrchestrationContext context, string input)
{
return Task.FromResult((DateTimeOffset)context.CurrentUtcDateTime);
}
}
");

DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("RunAsync", "System.TimeProvider.GetUtcNow()", "MyOrchestrator");

await VerifyCS.VerifyDurableTaskCodeFixAsync(code, expected, fix);
}

[Fact]
public async Task FuncOrchestratorWithTimeProviderHasDiag()
{
string code = Wrapper.WrapFuncOrchestrator(@"
tasks.AddOrchestratorFunc(""HelloSequence"", context =>
{
return {|#0:TimeProvider.System.GetUtcNow()|};
});
");

string fix = Wrapper.WrapFuncOrchestrator(@"
tasks.AddOrchestratorFunc(""HelloSequence"", context =>
{
return (DateTimeOffset)context.CurrentUtcDateTime;
});
");

DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("Main", "System.TimeProvider.GetUtcNow()", "HelloSequence");

await VerifyCS.VerifyDurableTaskCodeFixAsync(code, expected, fix);
}

[Fact]
public async Task DurableFunctionOrchestrationInvokingMethodWithTimeProviderHasDiag()
{
string code = Wrapper.WrapDurableFunctionOrchestration(@"
[Function(""Run"")]
DateTimeOffset Run([OrchestrationTrigger] TaskOrchestrationContext context)
{
return GetTime();
}

DateTimeOffset GetTime() => {|#0:TimeProvider.System.GetUtcNow()|};
");

DiagnosticResult expected = BuildDiagnostic().WithLocation(0).WithArguments("GetTime", "System.TimeProvider.GetUtcNow()", "Run");

await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected);
}

static DiagnosticResult BuildDiagnostic()
{
return VerifyCS.Diagnostic(DateTimeOrchestrationAnalyzer.DiagnosticId);
Expand Down
2 changes: 1 addition & 1 deletion test/Analyzers.Tests/Verifiers/References.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public static class References

public static ReferenceAssemblies CommonAssemblies => durableAssemblyReferences.Value;

static ReferenceAssemblies BuildReferenceAssemblies() => ReferenceAssemblies.Net.Net60.AddPackages([
static ReferenceAssemblies BuildReferenceAssemblies() => ReferenceAssemblies.Net.Net80.AddPackages([
new PackageIdentity("Azure.Storage.Blobs", "12.17.0"),
new PackageIdentity("Azure.Storage.Queues", "12.17.0"),
new PackageIdentity("Azure.Data.Tables", "12.8.3"),
Expand Down
Loading