Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -52,23 +52,15 @@
<Compile Remove="..\ServiceControl.AcceptanceTests\Recoverability\ExternalIntegration\When_encountered_an_error.cs" />
<Compile Remove="..\ServiceControl.AcceptanceTests\Recoverability\When_edited_message_fails_to_process.cs" />

<!-- Remaining EF retry scenarios are deferred for separate investigation. -->
<Compile Remove="..\ServiceControl.AcceptanceTests\Recoverability\When_a_message_is_retried.cs" />
<Compile Remove="..\ServiceControl.AcceptanceTests\Recoverability\When_a_message_is_retried_with_a_replyTo_header.cs" />
<Compile Remove="..\ServiceControl.AcceptanceTests\Recoverability\When_a_native_integration_message_is_retried.cs" />

<!-- EF persistence retains only the latest processing attempt. -->
<Compile Remove="..\ServiceControl.AcceptanceTests\Recoverability\MessageFailures\When_errors_with_same_uniqueid_are_imported.cs" />
<Compile Remove="..\ServiceControl.AcceptanceTests\Recoverability\MessageFailures\When_a_messages_fails_multiple_times.cs" />

<!-- The EF custom-check query does not provide an ETag. -->
<!-- The EF custom-check query does not provide an ETag. Addressed by a separate PR. -->
<Compile Remove="..\ServiceControl.AcceptanceTests\WebApi\When_a_request_is_repeated_with_its_etag.cs" />

<!-- The PostgreSQL event-log query does not provide an ETag. -->
<!-- The PostgreSQL event-log query does not provide an ETag. Addressed by a separate PR. -->
<Compile Remove="..\ServiceControl.AcceptanceTests\EventLogs\When_the_event_log_is_polled_with_an_etag.cs" />

<!-- The EF DateTime round trip changes the offset expected by this scenario. -->
<Compile Remove="..\ServiceControl.AcceptanceTests\Recoverability\MessageFailures\When_ingesting_failed_message_with_missing_headers.cs" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -52,20 +52,12 @@
<Compile Remove="..\ServiceControl.AcceptanceTests\Recoverability\ExternalIntegration\When_encountered_an_error.cs" />
<Compile Remove="..\ServiceControl.AcceptanceTests\Recoverability\When_edited_message_fails_to_process.cs" />

<!-- Remaining EF retry scenarios are deferred for separate investigation. -->
<Compile Remove="..\ServiceControl.AcceptanceTests\Recoverability\When_a_message_is_retried.cs" />
<Compile Remove="..\ServiceControl.AcceptanceTests\Recoverability\When_a_message_is_retried_with_a_replyTo_header.cs" />
<Compile Remove="..\ServiceControl.AcceptanceTests\Recoverability\When_a_native_integration_message_is_retried.cs" />

<!-- EF persistence retains only the latest processing attempt. -->
<Compile Remove="..\ServiceControl.AcceptanceTests\Recoverability\MessageFailures\When_errors_with_same_uniqueid_are_imported.cs" />
<Compile Remove="..\ServiceControl.AcceptanceTests\Recoverability\MessageFailures\When_a_messages_fails_multiple_times.cs" />

<!-- The EF custom-check query does not provide an ETag. -->
<!-- The EF custom-check query does not provide an ETag. Addressed by a separate PR. -->
<Compile Remove="..\ServiceControl.AcceptanceTests\WebApi\When_a_request_is_repeated_with_its_etag.cs" />

<!-- The EF DateTime round trip changes the offset expected by this scenario. -->
<Compile Remove="..\ServiceControl.AcceptanceTests\Recoverability\MessageFailures\When_ingesting_failed_message_with_missing_headers.cs" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading.Tasks;
using AcceptanceTesting;
using AcceptanceTesting.EndpointTemplates;
Expand Down Expand Up @@ -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<TestContext>(c =>
{
Expand All @@ -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<bool> TryGetFailureFromApi(TestContext context)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
namespace ServiceControl.Persistence.EFCore.SqlServer;

using System;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using ServiceControl.MessageFailures;
Expand All @@ -8,6 +9,14 @@ namespace ServiceControl.Persistence.EFCore.SqlServer;

public class SqlServerServiceControlDbContext(DbContextOptions<SqlServerServiceControlDbContext> options) : ServiceControlDbContext(options)
{
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
base.ConfigureConventions(configurationBuilder);

configurationBuilder.Properties<DateTime>().HaveConversion<UtcDateTimeConverter>();
configurationBuilder.Properties<DateTime?>().HaveConversion<NullableUtcDateTimeConverter>();
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<DateTime, DateTime>(
value => value.Kind == DateTimeKind.Local ? value.ToUniversalTime() : value,
value => DateTime.SpecifyKind(value, DateTimeKind.Utc));

sealed class NullableUtcDateTimeConverter() : ValueConverter<DateTime?, DateTime?>(
value => value.HasValue && value.Value.Kind == DateTimeKind.Local ? value.Value.ToUniversalTime() : value,
value => value.HasValue ? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc) : value);
28 changes: 28 additions & 0 deletions src/ServiceControl.Persistence.Tests/EFCore/DateTimeKindTests.cs
Original file line number Diff line number Diff line change
@@ -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));
}
}
}
Loading