Skip to content

Commit ee00d14

Browse files
committed
Fixes: Prevents ArgumentOutOfRangeException in DoMaintenance
Addresses a potential ArgumentOutOfRangeException in `InMemoryCacheClient.DoMaintenanceAsync` when dealing with expired cache entries that have a `DateTime.MinValue` expiration and the server's timezone has a positive offset. The issue arose during comparison of `expiresAt` (DateTime.MinValue) with a DateTimeOffset. Now the DateTime.MinValue is converted using UtcDateTime which won't throw an exception for values before the Unix Epoch. A test case is added to reproduce the error. Fixes InMemoryCacheClient: DoMaintenance: Starting - InMemoryCacheClient 09:01.641 E: Error trying to find expired cache items: The UTC time represented when the offset is applied must be between year 0 and 10,000. (Parameter 'offset') Fixes potential timezone conversion issues Ensures correct DateTime comparisons in the InMemoryCacheClient maintenance process by using UtcDateTime. Adds overflow protection to SafeAddMilliseconds for DateTime to prevent exceptions. Adds tests for DateTimeExtensions to ensure overflow protection. Removes unused usings Cleans up the InMemoryCacheClientTests file by removing unused namespace imports.
1 parent 65d442d commit ee00d14

4 files changed

Lines changed: 109 additions & 3 deletions

File tree

src/Foundatio/Caching/InMemoryCacheClient.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1611,7 +1611,6 @@ private string FindWorstSizeToUsageRatio()
16111611
double accessWeight = Math.Log10(accessRecency);
16121612

16131613
double wasteScore = sizeWeight + (ageWeight * 0.5) + (accessWeight * 2.0); // Access recency weighted more heavily
1614-
16151614
if (wasteScore > worstRatio)
16161615
{
16171616
worstRatio = wasteScore;
@@ -1626,7 +1625,9 @@ private string FindWorstSizeToUsageRatio()
16261625
private async Task DoMaintenanceAsync()
16271626
{
16281627
_logger.LogTrace("DoMaintenance: Starting");
1629-
var utcNow = _timeProvider.GetUtcNow().SafeAddMilliseconds(50);
1628+
1629+
// NOTE: We want to ensure we are comparing DateTimes to avoid using the systems timezone for implicit conversion between DateTime and DateTimeOffset.
1630+
var utcNow = _timeProvider.GetUtcNow().UtcDateTime.SafeAddMilliseconds(50);
16301631

16311632
// Remove expired items and items that are infrequently accessed as they may be updated by add.
16321633
long lastAccessMaximumTicks = utcNow.SafeAddMilliseconds(-300).Ticks;

src/Foundatio/Extensions/DateTimeExtensions.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System;
1+
using System;
22

33
namespace Foundatio.Utility;
44

@@ -75,4 +75,16 @@ public static DateTimeOffset SafeAddMilliseconds(this DateTimeOffset date, doubl
7575

7676
return date.SafeAdd(TimeSpan.FromMilliseconds(milliseconds));
7777
}
78+
79+
public static DateTime SafeAddMilliseconds(this DateTime date, double milliseconds)
80+
{
81+
// Check for overflow before creating TimeSpan to avoid exception in TimeSpan.FromMilliseconds
82+
if (milliseconds > TimeSpan.MaxValue.TotalMilliseconds)
83+
return DateTime.MaxValue;
84+
85+
if (milliseconds < TimeSpan.MinValue.TotalMilliseconds)
86+
return DateTime.MinValue;
87+
88+
return date.SafeAdd(TimeSpan.FromMilliseconds(milliseconds));
89+
}
7890
}

tests/Foundatio.Tests/Caching/InMemoryCacheClientTests.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Linq;
23
using System.Threading.Tasks;
34
using Foundatio.Caching;
45
using Microsoft.Extensions.Logging;
@@ -1551,6 +1552,50 @@ public async Task SetAsync_WithEntryUnderLimit_CachesSuccessfully()
15511552
Assert.Equal(smallString, cached.Value);
15521553
}
15531554
}
1555+
1556+
[Fact]
1557+
public async Task DoMaintenanceAsync_WithPositiveTimezoneOffset_ShouldNotThrowOnDateTimeMinValue()
1558+
{
1559+
// This test reproduces an issue where RemoveIfEqualAsync sets ExpiresAt to DateTime.MinValue,
1560+
// and DoMaintenanceAsync then compares it with a DateTimeOffset. When the system's local timezone
1561+
// has a positive offset (like Beirut UTC+2/+3), converting DateTime.MinValue to DateTimeOffset
1562+
// throws ArgumentOutOfRangeException because the resulting UTC time would be before year 0001.
1563+
//
1564+
// The bug is in InMemoryCacheClient.cs when checking the expiration:
1565+
// if (expiresAt < DateTime.MaxValue && expiresAt <= utcNow)
1566+
//
1567+
// When expiresAt is DateTime.MinValue (Kind=Unspecified) and utcNow is a DateTimeOffset,
1568+
// the implicit conversion of DateTime.MinValue to DateTimeOffset uses the system's local
1569+
// timezone offset. If that offset is positive, the conversion fails.
1570+
1571+
var timeProvider = new FakeTimeProvider();
1572+
var cache = new InMemoryCacheClient(o => o.CloneValues(true).TimeProvider(timeProvider).LoggerFactory(Log));
1573+
using (cache)
1574+
{
1575+
await cache.RemoveAllAsync();
1576+
1577+
// Set up a cache entry and then remove it via RemoveIfEqualAsync
1578+
// This sets ExpiresAt to DateTime.MinValue on the entry
1579+
await cache.SetAsync("test-key", "test-value", TimeSpan.FromMinutes(1));
1580+
await cache.RemoveIfEqualAsync("test-key", "test-value");
1581+
1582+
1583+
// Advance time to ensure the entry is considered "infrequently accessed"
1584+
// (LastAccessTicks must be < utcNow - 300ms for maintenance to process it)
1585+
timeProvider.Advance(TimeSpan.FromSeconds(1));
1586+
1587+
// Trigger another cache operation to start maintenance
1588+
// This causes DoMaintenanceAsync to iterate over entries including the one
1589+
// with ExpiresAt = DateTime.MinValue
1590+
await cache.SetAsync("trigger", "value");
1591+
1592+
// Wait a moment to allow maintenance to complete
1593+
await Task.Delay(100, TestCancellationToken);
1594+
1595+
// Check for the error log that indicates the bug
1596+
Assert.Null(Log.LogEntries.SingleOrDefault(l => l.LogLevel == LogLevel.Error));
1597+
}
1598+
}
15541599
}
15551600

15561601
/// <summary>

tests/Foundatio.Tests/Extensions/DateTimeExtensionsTests.cs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,54 @@ public void SafeAddMilliseconds_WithNormalValue_AddsCorrectly()
100100
Assert.Equal(new DateTimeOffset(2020, 1, 1, 0, 0, 1, TimeSpan.Zero), result);
101101
}
102102

103+
[Fact]
104+
public void SafeAddMilliseconds_DateTime_WithPositiveOverflow_ReturnsMaxValue()
105+
{
106+
var date = DateTime.MaxValue.AddDays(-1);
107+
var result = date.SafeAddMilliseconds(Double.MaxValue);
108+
Assert.Equal(DateTime.MaxValue, result);
109+
}
110+
111+
[Fact]
112+
public void SafeAddMilliseconds_DateTime_WithNegativeOverflow_ReturnsMinValue()
113+
{
114+
var date = DateTime.MinValue.AddDays(1);
115+
var result = date.SafeAddMilliseconds(Double.MinValue);
116+
Assert.Equal(DateTime.MinValue, result);
117+
}
118+
119+
[Fact]
120+
public void SafeAddMilliseconds_DateTime_WithNormalValue_AddsCorrectly()
121+
{
122+
var date = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc);
123+
var result = date.SafeAddMilliseconds(1000);
124+
Assert.Equal(new DateTime(2020, 1, 1, 0, 0, 1, DateTimeKind.Utc), result);
125+
}
126+
127+
[Fact]
128+
public void SafeAddMilliseconds_DateTime_WithNegativeValue_SubtractsCorrectly()
129+
{
130+
var date = new DateTime(2020, 1, 1, 0, 0, 1, DateTimeKind.Utc);
131+
var result = date.SafeAddMilliseconds(-1000);
132+
Assert.Equal(new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc), result);
133+
}
134+
135+
[Fact]
136+
public void SafeAddMilliseconds_DateTime_PreservesDateTimeKind()
137+
{
138+
var utcDate = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc);
139+
var utcResult = utcDate.SafeAddMilliseconds(1000);
140+
Assert.Equal(DateTimeKind.Utc, utcResult.Kind);
141+
142+
var localDate = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Local);
143+
var localResult = localDate.SafeAddMilliseconds(1000);
144+
Assert.Equal(DateTimeKind.Local, localResult.Kind);
145+
146+
var unspecifiedDate = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Unspecified);
147+
var unspecifiedResult = unspecifiedDate.SafeAddMilliseconds(1000);
148+
Assert.Equal(DateTimeKind.Unspecified, unspecifiedResult.Kind);
149+
}
150+
103151
[Fact]
104152
public void Floor_WithMinuteInterval_FloorsCorrectly()
105153
{

0 commit comments

Comments
 (0)