Browse Source

Merge pull request #25703 from gdlcf88/fix/datetime-converter-unspecified-minvalue-warning

Stop logging misleading warning when normalizing Unspecified DateTime near range boundaries
pull/25705/head
Ma Liming 3 months ago
committed by GitHub
parent
commit
7f704d04be
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ModelBinding/AbpDateTimeModelBinder.cs
  2. 6
      framework/src/Volo.Abp.Json.Newtonsoft/Volo/Abp/Json/Newtonsoft/AbpDateTimeConverter.cs
  3. 6
      framework/src/Volo.Abp.Json.SystemTextJson/Volo/Abp/Json/SystemTextJson/JsonConverters/AbpDateTimeConverterBase.cs
  4. 37
      framework/src/Volo.Abp.Timing/Volo/Abp/Timing/TimezoneProviderExtensions.cs
  5. 200
      framework/test/Volo.Abp.Json.Tests/Volo/Abp/Json/AbpDateTimeConverterTimezone_Tests.cs
  6. 60
      framework/test/Volo.Abp.Timing.Tests/Volo/Abp/Timing/TimezoneProviderExtensions_Tests.cs
  7. 16
      modules/docs/src/Volo.Docs.Domain/Volo/Docs/GitHub/Documents/GithubDocumentSource.cs

6
framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ModelBinding/AbpDateTimeModelBinder.cs

@ -42,14 +42,14 @@ public class AbpDateTimeModelBinder : IModelBinder
_clock.SupportsMultipleTimezone &&
!_currentTimezoneProvider.TimeZone.IsNullOrWhiteSpace())
{
var timeZone = _currentTimezoneProvider.TimeZone;
try
{
var timezoneInfo = _timezoneProvider.GetTimeZoneInfo(_currentTimezoneProvider.TimeZone);
dateTime = new DateTimeOffset(dateTime, timezoneInfo.GetUtcOffset(dateTime)).UtcDateTime;
dateTime = _timezoneProvider.ConvertUnspecifiedToUtc(dateTime, timeZone);
}
catch
{
_logger.LogWarning("Could not convert DateTime with unspecified Kind using timezone '{TimeZone}'.", _currentTimezoneProvider.TimeZone);
_logger.LogWarning("Could not convert DateTime with unspecified Kind using timezone '{TimeZone}'.", timeZone);
}
}

6
framework/src/Volo.Abp.Json.Newtonsoft/Volo/Abp/Json/Newtonsoft/AbpDateTimeConverter.cs

@ -134,14 +134,14 @@ public class AbpDateTimeConverter : DateTimeConverterBase, ITransientDependency
return _skipDateTimeNormalization ? dateTime : _clock.Normalize(dateTime);
}
var timeZone = _currentTimezoneProvider.TimeZone;
try
{
var timezoneInfo = _timezoneProvider.GetTimeZoneInfo(_currentTimezoneProvider.TimeZone);
dateTime = new DateTimeOffset(dateTime, timezoneInfo.GetUtcOffset(dateTime)).UtcDateTime;
dateTime = _timezoneProvider.ConvertUnspecifiedToUtc(dateTime, timeZone);
}
catch
{
Logger.LogWarning("Could not convert DateTime with unspecified Kind using timezone '{TimeZone}'.", _currentTimezoneProvider.TimeZone);
Logger.LogWarning("Could not convert DateTime with unspecified Kind using timezone '{TimeZone}'.", timeZone);
}
return _skipDateTimeNormalization

6
framework/src/Volo.Abp.Json.SystemTextJson/Volo/Abp/Json/SystemTextJson/JsonConverters/AbpDateTimeConverterBase.cs

@ -101,14 +101,14 @@ public abstract class AbpDateTimeConverterBase<T> : JsonConverter<T>
return IsSkipDateTimeNormalization ? dateTime : Clock.Normalize(dateTime);
}
var timeZone = CurrentTimezoneProvider.TimeZone;
try
{
var timezoneInfo = TimezoneProvider.GetTimeZoneInfo(CurrentTimezoneProvider.TimeZone);
dateTime = new DateTimeOffset(dateTime, timezoneInfo.GetUtcOffset(dateTime)).UtcDateTime;
dateTime = TimezoneProvider.ConvertUnspecifiedToUtc(dateTime, timeZone);
}
catch
{
Logger.LogWarning("Could not convert DateTime with unspecified Kind using timezone '{TimeZone}'.", CurrentTimezoneProvider.TimeZone);
Logger.LogWarning("Could not convert DateTime with unspecified Kind using timezone '{TimeZone}'.", timeZone);
}
return IsSkipDateTimeNormalization ? dateTime : Clock.Normalize(dateTime);

37
framework/src/Volo.Abp.Timing/Volo/Abp/Timing/TimezoneProviderExtensions.cs

@ -0,0 +1,37 @@
using System;
namespace Volo.Abp.Timing;
public static class TimezoneProviderExtensions
{
/// <summary>
/// Interprets <paramref name="dateTime"/> as local time in <paramref name="windowsOrIanaTimeZoneId"/>
/// and converts it to its UTC equivalent. The caller is expected to pass a
/// <see cref="DateTimeKind.Unspecified"/> value; the kind is not inspected, so a value is always
/// treated as wall-clock time in the given timezone regardless of its kind.
/// </summary>
/// <remarks>
/// Returns <paramref name="dateTime"/> unchanged when applying the timezone offset would move it
/// outside the supported <see cref="DateTime"/> range. This happens for values within the offset
/// distance of <see cref="DateTime.MinValue"/>/<see cref="DateTime.MaxValue"/>, typically the
/// <see cref="DateTime.MinValue"/> placeholder that does not represent a real instant. Computing the
/// UTC ticks directly avoids the <see cref="ArgumentOutOfRangeException"/> that
/// <c>new DateTimeOffset(dateTime, offset)</c> would throw for such values.
/// </remarks>
public static DateTime ConvertUnspecifiedToUtc(
this ITimezoneProvider timezoneProvider,
DateTime dateTime,
string windowsOrIanaTimeZoneId)
{
Check.NotNull(timezoneProvider, nameof(timezoneProvider));
var timezoneInfo = timezoneProvider.GetTimeZoneInfo(windowsOrIanaTimeZoneId);
var utcTicks = dateTime.Ticks - timezoneInfo.GetUtcOffset(dateTime).Ticks;
if (utcTicks < DateTime.MinValue.Ticks || utcTicks > DateTime.MaxValue.Ticks)
{
return dateTime;
}
return new DateTime(utcTicks, DateTimeKind.Utc);
}
}

200
framework/test/Volo.Abp.Json.Tests/Volo/Abp/Json/AbpDateTimeConverterTimezone_Tests.cs

@ -0,0 +1,200 @@
using System;
using System.Collections.Concurrent;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Shouldly;
using Volo.Abp.Timing;
using Xunit;
namespace Volo.Abp.Json;
/// <summary>
/// Regression tests for the warning
/// "Could not convert DateTime with unspecified Kind using timezone '...'."
/// logged by <c>AbpDateTimeConverterBase.Normalize</c>.
///
/// When <see cref="AbpClockOptions.Kind"/> is <see cref="DateTimeKind.Utc"/> the converter treats
/// an <see cref="DateTimeKind.Unspecified"/> value as local time in the current user's timezone and
/// converts it to UTC. For a value within the offset distance of <see cref="DateTime.MinValue"/>/
/// <see cref="DateTime.MaxValue"/> (most commonly the <see cref="DateTime.MinValue"/> placeholder) a
/// non-zero offset pushes the UTC equivalent outside the supported range, which used to be swallowed
/// and logged as a warning on every serialization. The converter now detects this boundary case and
/// keeps the value unchanged, so no warning is emitted while the serialized output stays the same.
/// </summary>
public class AbpDateTimeConverterTimezone_Tests : AbpJsonSystemTextJsonTestBase
{
private const string WarningFragment = "Could not convert DateTime with unspecified Kind";
private static readonly CapturingLoggerProvider LogCapture = new();
private readonly IJsonSerializer _jsonSerializer;
private readonly ICurrentTimezoneProvider _currentTimezoneProvider;
public AbpDateTimeConverterTimezone_Tests()
{
_jsonSerializer = GetRequiredService<IJsonSerializer>();
_currentTimezoneProvider = GetRequiredService<ICurrentTimezoneProvider>();
}
protected override void AfterAddApplication(IServiceCollection services)
{
// The warning only happens with a UTC clock, where Unspecified values get converted to UTC.
services.Configure<AbpClockOptions>(options => options.Kind = DateTimeKind.Utc);
LogCapture.Clear();
services.AddSingleton<ILoggerProvider>(LogCapture);
base.AfterAddApplication(services);
}
private sealed class FileModel
{
public DateTime DateModified { get; set; }
public DateTime DateCreated { get; set; }
}
[Theory]
[InlineData("Asia/Shanghai")] // +08:00
[InlineData("Europe/Brussels")] // +01:00 / +02:00
public void Should_Not_Warn_When_Serializing_MinValue_Under_Positive_Offset_Timezone(string timeZoneId)
{
_currentTimezoneProvider.TimeZone = timeZoneId;
LogCapture.Clear();
DateTime.MinValue.Kind.ShouldBe(DateTimeKind.Unspecified);
var json = _jsonSerializer.Serialize(new FileModel
{
DateModified = DateTime.MinValue,
DateCreated = DateTime.MinValue
});
// The placeholder is serialized unchanged and no warning is logged.
json.ShouldContain("0001-01-01");
LogCapture.Warnings.ShouldNotContain(m => m.Contains(WarningFragment));
}
[Fact]
public void Should_Not_Warn_When_Serializing_Value_Near_MinValue_Under_Positive_Offset_Timezone()
{
// Not exactly DateTime.MinValue: any value within the offset distance of the lower bound
// overflows the same way, so the converter must absorb it rather than warn.
_currentTimezoneProvider.TimeZone = "Asia/Shanghai"; // +08:00
LogCapture.Clear();
var nearMin = DateTime.MinValue.AddHours(3); // 0001-01-01T03:00:00 - 08:00 underflows
_jsonSerializer.Serialize(new FileModel
{
DateModified = nearMin,
DateCreated = nearMin
});
LogCapture.Warnings.ShouldNotContain(m => m.Contains(WarningFragment));
}
[Fact]
public void Should_Not_Warn_When_Serializing_MaxValue_Under_Negative_Offset_Timezone()
{
// The symmetric case: a negative offset would push MaxValue past DateTime.MaxValue.
_currentTimezoneProvider.TimeZone = "America/New_York";
LogCapture.Clear();
var json = _jsonSerializer.Serialize(new FileModel
{
DateModified = DateTime.MaxValue,
DateCreated = DateTime.MaxValue
});
json.ShouldContain("9999-12-31");
LogCapture.Warnings.ShouldNotContain(m => m.Contains(WarningFragment));
}
[Fact]
public void Should_Not_Warn_When_Serializing_Real_Utc_Timestamp_Under_Positive_Offset_Timezone()
{
_currentTimezoneProvider.TimeZone = "Asia/Shanghai";
LogCapture.Clear();
var utc = new DateTime(2026, 6, 27, 10, 28, 7, DateTimeKind.Utc);
var json = _jsonSerializer.Serialize(new FileModel
{
DateModified = utc,
DateCreated = utc
});
json.ShouldContain("2026-06-27T10:28:07Z");
LogCapture.Warnings.ShouldNotContain(m => m.Contains(WarningFragment));
}
[Fact]
public void Should_Still_Convert_Real_Unspecified_Timestamp_To_Utc_Under_Positive_Offset_Timezone()
{
// A genuine (non-sentinel) Unspecified value must still be converted to UTC using the offset.
_currentTimezoneProvider.TimeZone = "Asia/Shanghai"; // +08:00
LogCapture.Clear();
var unspecified = new DateTime(2026, 6, 27, 18, 0, 0, DateTimeKind.Unspecified);
var json = _jsonSerializer.Serialize(new FileModel
{
DateModified = unspecified,
DateCreated = unspecified
});
// 18:00 in +08:00 == 10:00 UTC.
json.ShouldContain("2026-06-27T10:00:00Z");
LogCapture.Warnings.ShouldNotContain(m => m.Contains(WarningFragment));
}
private sealed class CapturingLoggerProvider : ILoggerProvider
{
public ConcurrentQueue<string> Warnings { get; } = new();
public void Clear()
{
Warnings.Clear();
}
public ILogger CreateLogger(string categoryName)
{
return new CapturingLogger(Warnings);
}
public void Dispose()
{
}
private sealed class CapturingLogger : ILogger
{
private readonly ConcurrentQueue<string> _sink;
public CapturingLogger(ConcurrentQueue<string> sink)
{
_sink = sink;
}
public IDisposable BeginScope<TState>(TState state) where TState : notnull
{
return null;
}
public bool IsEnabled(LogLevel logLevel)
{
return logLevel >= LogLevel.Warning;
}
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception,
Func<TState, Exception, string> formatter)
{
if (logLevel >= LogLevel.Warning)
{
_sink.Enqueue(formatter(state, exception));
}
}
}
}
}

60
framework/test/Volo.Abp.Timing.Tests/Volo/Abp/Timing/TimezoneProviderExtensions_Tests.cs

@ -0,0 +1,60 @@
using System;
using Shouldly;
using Volo.Abp.Testing;
using Xunit;
namespace Volo.Abp.Timing;
public class TimezoneProviderExtensions_Tests : AbpIntegratedTest<AbpTimingTestModule>
{
private readonly ITimezoneProvider _timezoneProvider;
public TimezoneProviderExtensions_Tests()
{
_timezoneProvider = GetRequiredService<ITimezoneProvider>();
}
[Theory]
[InlineData("Asia/Shanghai")] // +08:00
[InlineData("Europe/Brussels")] // +01:00 / +02:00
public void Should_Keep_MinValue_Unchanged_Under_Positive_Offset(string timeZoneId)
{
// A positive offset would push DateTime.MinValue below the supported range; keep it as-is.
var result = _timezoneProvider.ConvertUnspecifiedToUtc(DateTime.MinValue, timeZoneId);
result.ShouldBe(DateTime.MinValue);
result.Kind.ShouldBe(DateTimeKind.Unspecified);
}
[Fact]
public void Should_Keep_Value_Near_MinValue_Unchanged_Under_Positive_Offset()
{
var nearMin = DateTime.MinValue.AddHours(3); // 0001-01-01T03:00:00 - 08:00 underflows
var result = _timezoneProvider.ConvertUnspecifiedToUtc(nearMin, "Asia/Shanghai");
result.ShouldBe(nearMin);
result.Kind.ShouldBe(DateTimeKind.Unspecified);
}
[Fact]
public void Should_Keep_MaxValue_Unchanged_Under_Negative_Offset()
{
var result = _timezoneProvider.ConvertUnspecifiedToUtc(DateTime.MaxValue, "America/New_York");
result.ShouldBe(DateTime.MaxValue);
result.Kind.ShouldBe(DateTimeKind.Unspecified);
}
[Fact]
public void Should_Convert_Unspecified_Value_To_Utc_Using_Offset()
{
var unspecified = new DateTime(2026, 6, 27, 18, 0, 0, DateTimeKind.Unspecified);
var result = _timezoneProvider.ConvertUnspecifiedToUtc(unspecified, "Asia/Shanghai"); // +08:00
// 18:00 in +08:00 == 10:00 UTC.
result.ShouldBe(new DateTime(2026, 6, 27, 10, 0, 0, DateTimeKind.Utc));
result.Kind.ShouldBe(DateTimeKind.Utc);
}
}

16
modules/docs/src/Volo.Docs.Domain/Volo/Docs/GitHub/Documents/GithubDocumentSource.cs

@ -173,7 +173,7 @@ namespace Volo.Docs.GitHub.Documents
{
if (commits == null)
{
return DateTime.MinValue;
return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
}
var gitHubCommit = isFirstCommit ?
@ -182,20 +182,20 @@ namespace Volo.Docs.GitHub.Documents
if (gitHubCommit == null)
{
return DateTime.MinValue;
return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
}
if (gitHubCommit.Commit == null)
{
return DateTime.MinValue;
return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
}
if (gitHubCommit.Commit.Author == null)
{
return DateTime.MinValue;
return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
}
return gitHubCommit.Commit.Author.Date.DateTime;
return gitHubCommit.Commit.Author.Date.UtcDateTime;
}
private async Task<IReadOnlyList<GitHubCommit>> GetGitHubCommitsOrNull(Project project, string documentName, string languageCode, string version)
@ -230,8 +230,8 @@ namespace Volo.Docs.GitHub.Documents
var fileCommitsAfterCreation = commits.Take(commits.Count - 1);
var commitsToEvaluate = (lastKnownSignificantUpdateTime != null
? fileCommitsAfterCreation.Where(c => c.Commit.Author.Date.DateTime > lastKnownSignificantUpdateTime)
: fileCommitsAfterCreation).Where(c => c.Commit.Author.Date.DateTime > DateTime.Now.AddDays(-14));
? fileCommitsAfterCreation.Where(c => c.Commit.Author.Date.UtcDateTime > lastKnownSignificantUpdateTime)
: fileCommitsAfterCreation).Where(c => c.Commit.Author.Date.UtcDateTime > DateTime.UtcNow.AddDays(-14));
foreach (var gitHubCommit in commitsToEvaluate)
{
@ -243,7 +243,7 @@ namespace Volo.Docs.GitHub.Documents
if (_githubPatchAnalyzer.HasPatchSignificantChanges(fullCommit.Files.First(f => f.Filename == fileName).Patch))
{
return gitHubCommit.Commit.Author.Date.DateTime;
return gitHubCommit.Commit.Author.Date.UtcDateTime;
}
}

Loading…
Cancel
Save