diff --git a/src/ServiceControl.AcceptanceTests.PostgreSql/ServiceControl.AcceptanceTests.PostgreSql.csproj b/src/ServiceControl.AcceptanceTests.PostgreSql/ServiceControl.AcceptanceTests.PostgreSql.csproj index 74ed777f0b..c873aa8eb7 100644 --- a/src/ServiceControl.AcceptanceTests.PostgreSql/ServiceControl.AcceptanceTests.PostgreSql.csproj +++ b/src/ServiceControl.AcceptanceTests.PostgreSql/ServiceControl.AcceptanceTests.PostgreSql.csproj @@ -52,23 +52,15 @@ - - - - - - + - + - - - \ No newline at end of file diff --git a/src/ServiceControl.AcceptanceTests.SqlServer/ServiceControl.AcceptanceTests.SqlServer.csproj b/src/ServiceControl.AcceptanceTests.SqlServer/ServiceControl.AcceptanceTests.SqlServer.csproj index 5dda932f0e..6677b0b3dd 100644 --- a/src/ServiceControl.AcceptanceTests.SqlServer/ServiceControl.AcceptanceTests.SqlServer.csproj +++ b/src/ServiceControl.AcceptanceTests.SqlServer/ServiceControl.AcceptanceTests.SqlServer.csproj @@ -52,20 +52,12 @@ - - - - - - + - - - diff --git a/src/ServiceControl.AcceptanceTests/Recoverability/MessageFailures/When_ingesting_failed_message_with_missing_headers.cs b/src/ServiceControl.AcceptanceTests/Recoverability/MessageFailures/When_ingesting_failed_message_with_missing_headers.cs index cb0b280106..6196d97536 100644 --- a/src/ServiceControl.AcceptanceTests/Recoverability/MessageFailures/When_ingesting_failed_message_with_missing_headers.cs +++ b/src/ServiceControl.AcceptanceTests/Recoverability/MessageFailures/When_ingesting_failed_message_with_missing_headers.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Threading.Tasks; using AcceptanceTesting; using AcceptanceTesting.EndpointTemplates; @@ -67,7 +68,7 @@ public async Task Should_include_headers_required_by_ServicePulse() [Test] public async Task TimeSent_should_not_be_casted() { - var sentTime = DateTime.Parse("2014-11-11T02:26:58.000462Z"); + var sentTime = DateTime.Parse("2014-11-11T02:26:58.000462Z", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); var context = await Define(c => { @@ -81,7 +82,9 @@ public async Task TimeSent_should_not_be_casted() var failure = context.Failure; Assert.That(failure, Is.Not.Null); - Assert.That(failure.TimeSent, Is.EqualTo(sentTime)); + + // Raven hands back a local DateTime and EF a UTC one, so compare the instant rather than the wall clock. + Assert.That(failure.TimeSent?.ToUniversalTime(), Is.EqualTo(sentTime)); } async Task TryGetFailureFromApi(TestContext context) diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerServiceControlDbContext.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerServiceControlDbContext.cs index 9fff389d1f..3403cd3c6c 100644 --- a/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerServiceControlDbContext.cs +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/SqlServerServiceControlDbContext.cs @@ -1,5 +1,6 @@ namespace ServiceControl.Persistence.EFCore.SqlServer; +using System; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using ServiceControl.MessageFailures; @@ -8,6 +9,14 @@ namespace ServiceControl.Persistence.EFCore.SqlServer; public class SqlServerServiceControlDbContext(DbContextOptions options) : ServiceControlDbContext(options) { + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + base.ConfigureConventions(configurationBuilder); + + configurationBuilder.Properties().HaveConversion(); + configurationBuilder.Properties().HaveConversion(); + } + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); diff --git a/src/ServiceControl.Persistence.EFCore.SqlServer/UtcDateTimeConverters.cs b/src/ServiceControl.Persistence.EFCore.SqlServer/UtcDateTimeConverters.cs new file mode 100644 index 0000000000..e464acc5b6 --- /dev/null +++ b/src/ServiceControl.Persistence.EFCore.SqlServer/UtcDateTimeConverters.cs @@ -0,0 +1,14 @@ +namespace ServiceControl.Persistence.EFCore.SqlServer; + +using System; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +// datetime2 stores no offset, so values come back as DateTimeKind.Unspecified and are then serialized +// by the API without the UTC marker. Everything persisted here is UTC. +sealed class UtcDateTimeConverter() : ValueConverter( + value => value.Kind == DateTimeKind.Local ? value.ToUniversalTime() : value, + value => DateTime.SpecifyKind(value, DateTimeKind.Utc)); + +sealed class NullableUtcDateTimeConverter() : ValueConverter( + value => value.HasValue && value.Value.Kind == DateTimeKind.Local ? value.Value.ToUniversalTime() : value, + value => value.HasValue ? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc) : value); diff --git a/src/ServiceControl.Persistence.Tests/EFCore/DateTimeKindTests.cs b/src/ServiceControl.Persistence.Tests/EFCore/DateTimeKindTests.cs new file mode 100644 index 0000000000..9f42206a56 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests/EFCore/DateTimeKindTests.cs @@ -0,0 +1,28 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Threading.Tasks; +using NUnit.Framework; + +class DateTimeKindTests : ErrorIngestionTestBase +{ + [Test] + public async Task Timestamps_are_read_back_as_utc() + { + var failure = new IngestedFailure(); + + await Ingest(failure); + + var row = await GetFailedMessage(failure.UniqueMessageId); + + using (Assert.EnterMultipleScope()) + { + Assert.That(row.TimeSent.Value.Kind, Is.EqualTo(DateTimeKind.Utc)); + Assert.That(row.LastAttemptedAt.Kind, Is.EqualTo(DateTimeKind.Utc)); + Assert.That(row.FirstTimeOfFailure.Kind, Is.EqualTo(DateTimeKind.Utc)); + Assert.That(row.LastTimeOfFailure.Kind, Is.EqualTo(DateTimeKind.Utc)); + Assert.That(row.LastModified.Kind, Is.EqualTo(DateTimeKind.Utc)); + Assert.That(row.StatusChangedAt.Kind, Is.EqualTo(DateTimeKind.Utc)); + } + } +}