Skip to content
Draft
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
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);
}
33 changes: 31 additions & 2 deletions 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 All @@ -35,18 +35,20 @@ public sealed class DateTimeOrchestrationAnalyzer : OrchestrationAnalyzer<DateTi
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [Rule];

/// <summary>
/// Visitor that inspects the method body for DateTime and DateTimeOffset properties.
/// Visitor that inspects the method body for DateTime and DateTimeOffset properties, and TimeProvider method invocations.
/// </summary>
public sealed class DateTimeOrchestrationVisitor : MethodProbeOrchestrationVisitor
{
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);
}
}
Comment on lines +74 to +109
}

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
Loading
Loading