Skip to content

Commit 8e1ea88

Browse files
niemyjskicursoragentCopilot
authored
Enforce 5ms minimum expiration across all cache implementations (#463)
* Enforce 5ms minimum expiration across all cache implementations Prevents 'ERR invalid expire time in setex' errors from Redis when sub-millisecond TTLs are passed. Redis represents TTLs as integers and StackExchange.Redis truncates via (long)TimeSpan.TotalMilliseconds, so a 0.9ms TTL becomes 0ms and Redis rejects it. This is a real production race condition when computing expiresAtUtc - DateTime.UtcNow near now. - Add public CacheClientExtensions.MinimumExpiration constant (5ms) - Clamp sub-minimum values to TimeSpan.Zero in ToExpiresIn so the downstream guard fires uniformly regardless of how expiration is supplied - Replace all Ticks <= 0 guards with less-than MinimumExpiration in InMemoryCacheClient and HybridCacheClient - Add missing guards on SetExpirationAsync and SetAllExpirationAsync in InMemoryCacheClient (previously unguarded code paths) - Add test cases for sub-ms (1 tick), below-threshold (3ms), at-threshold (5ms), and DateTime-based near-boundary in CacheClientTestsBase - Document the behavior and rationale in docs/guide/caching.md Co-authored-by: Cursor <cursoragent@cursor.com> * Update docs/guide/caching.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 6a80209 commit 8e1ea88

File tree

5 files changed

+138
-30
lines changed

5 files changed

+138
-30
lines changed

docs/guide/caching.md

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,50 @@ Many cache methods accept an optional `expiresIn` parameter that controls the TT
5757
| `expiresIn` Value | Behavior |
5858
|-------------------|----------|
5959
| `null` | Entry will not expire. **Removes any existing TTL** on the key. |
60-
| Positive `TimeSpan` | Entry expires after the specified duration from now. |
60+
| Positive `TimeSpan` ≥ 5ms | Entry expires after the specified duration from now. |
61+
| Greater than 0 and less than 5ms | **Treated as already expired.** Key is removed, operation returns failure value. See [Minimum Expiration](#minimum-expiration) below. |
6162
| Zero or negative | **Treated as already expired.** Key is removed, operation returns failure value. |
6263
| `TimeSpan.MaxValue` | Entry will not expire (equivalent to `null`). |
6364

65+
### Minimum Expiration
66+
67+
Foundatio enforces a **minimum expiration of 5 milliseconds** (`CacheClientExtensions.MinimumExpiration`) on all cache operations. Any `expiresIn` value greater than zero but less than 5ms is treated as already-expired: the key is removed and the operation returns its failure value.
68+
69+
**Why 5ms?**
70+
71+
External cache providers—most notably Redis—represent TTLs as integers. StackExchange.Redis converts a `TimeSpan` to milliseconds via `(long)timeSpan.TotalMilliseconds`, which **truncates** the fractional part. A TTL of 0.9ms becomes `0`, and Redis rejects `SET key PX 0` with:
72+
73+
```
74+
ERR invalid expire time in 'setex'
75+
```
76+
77+
This truncation-to-zero can happen legitimately in production when computing `expiresAtUtc - DateTime.UtcNow` on a time very close to "now"—a common race condition in high-throughput systems. The 5ms floor provides a safe margin above the 1ms truncation boundary while remaining far below any real-world cache TTL.
78+
79+
**Behavior summary:**
80+
81+
```csharp
82+
// Below threshold: treated as expired (key is removed, returns false/0)
83+
await cache.SetAsync("key", value, TimeSpan.FromTicks(1)); // 100ns < 5ms → expired
84+
await cache.SetAsync("key", value, TimeSpan.FromMilliseconds(3)); // 3ms < 5ms → expired
85+
86+
// At threshold: accepted
87+
await cache.SetAsync("key", value, TimeSpan.FromMilliseconds(5)); // 5ms == 5ms → succeeds
88+
89+
// Above threshold: accepted
90+
await cache.SetAsync("key", value, TimeSpan.FromMilliseconds(100)); // 100ms > 5ms → succeeds
91+
```
92+
93+
The constant is accessible for consumers that need to validate TTLs before calling cache methods:
94+
95+
```csharp
96+
using Foundatio.Extensions;
97+
98+
if (myExpiration < CacheClientExtensions.MinimumExpiration)
99+
{
100+
// TTL too short; skip the cache operation or use a longer TTL
101+
}
102+
```
103+
64104
### TTL Behavior by Method
65105

66106
Different methods handle the `expiresIn` parameter slightly differently. The table below shows exactly what happens for each method:

src/Foundatio.TestHarness/Caching/CacheClientTestsBase.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,18 @@ public virtual async Task AddAsync_WithExpiration_SetsExpirationCorrectly()
117117
Assert.False(await cache.ExistsAsync("add-zero-exp"));
118118
Assert.False((await cache.GetAsync<string>("add-zero-exp")).HasValue);
119119

120+
// Sub-millisecond expiration (1 tick): treated as expired by MinimumExpiration guard
121+
Assert.False(await cache.AddAsync("add-sub-ms-exp", "value", TimeSpan.FromTicks(1)));
122+
Assert.False(await cache.ExistsAsync("add-sub-ms-exp"));
123+
124+
// Below MinimumExpiration threshold (3ms < 5ms): treated as expired
125+
Assert.False(await cache.AddAsync("add-below-min-exp", "value", TimeSpan.FromMilliseconds(3)));
126+
Assert.False(await cache.ExistsAsync("add-below-min-exp"));
127+
128+
// At MinimumExpiration threshold (5ms): should succeed
129+
Assert.True(await cache.AddAsync("add-at-min-exp", "value", TimeSpan.FromMilliseconds(5)));
130+
Assert.True(await cache.ExistsAsync("add-at-min-exp"));
131+
120132
// Max expiration: should return true, key should exist with no expiration (null)
121133
Assert.True(await cache.AddAsync("add-max-exp", "value", TimeSpan.MaxValue));
122134
Assert.True(await cache.ExistsAsync("add-max-exp"));
@@ -724,6 +736,16 @@ public virtual async Task IncrementAsync_WithExpiration_SetsExpirationCorrectly(
724736
Assert.False(await cache.ExistsAsync("increment-zero-exp"));
725737
Assert.False((await cache.GetAsync<long>("increment-zero-exp")).HasValue);
726738

739+
// Sub-millisecond expiration: treated as expired by MinimumExpiration guard
740+
longResult = await cache.IncrementAsync("increment-sub-ms", 5L, TimeSpan.FromTicks(1));
741+
Assert.Equal(0, longResult);
742+
Assert.False(await cache.ExistsAsync("increment-sub-ms"));
743+
744+
// Below MinimumExpiration threshold (3ms < 5ms): treated as expired
745+
longResult = await cache.IncrementAsync("increment-below-min", 5L, TimeSpan.FromMilliseconds(3));
746+
Assert.Equal(0, longResult);
747+
Assert.False(await cache.ExistsAsync("increment-below-min"));
748+
727749
// Max expiration (long): should succeed and key should exist with no expiration
728750
longResult = await cache.IncrementAsync("increment-max-exp-long", 100L, TimeSpan.MaxValue);
729751
Assert.Equal(100, longResult);
@@ -2678,6 +2700,23 @@ public virtual async Task SetAsync_WithExpiration_SetsExpirationCorrectly()
26782700
Assert.False(await cache.ExistsAsync("test9"));
26792701
Assert.Null(await cache.GetExpirationAsync("test9"));
26802702

2703+
// Sub-millisecond expiration (1 tick): treated as expired by MinimumExpiration guard
2704+
Assert.False(await cache.SetAsync("set-sub-ms", 1, TimeSpan.FromTicks(1)));
2705+
Assert.False(await cache.ExistsAsync("set-sub-ms"));
2706+
2707+
// Below MinimumExpiration threshold (3ms < 5ms): treated as expired
2708+
Assert.False(await cache.SetAsync("set-below-min", 1, TimeSpan.FromMilliseconds(3)));
2709+
Assert.False(await cache.ExistsAsync("set-below-min"));
2710+
2711+
// At MinimumExpiration threshold (5ms): should succeed
2712+
Assert.True(await cache.SetAsync("set-at-min", 1, TimeSpan.FromMilliseconds(5)));
2713+
Assert.True(await cache.ExistsAsync("set-at-min"));
2714+
2715+
// DateTime-based near-boundary: expiration sub-5ms in the future (via ToExpiresIn)
2716+
var nearFuture = cache.GetTimeProvider().GetUtcNow().UtcDateTime.AddTicks(100);
2717+
Assert.False(await cache.SetAsync("set-near-future", 1, nearFuture));
2718+
Assert.False(await cache.ExistsAsync("set-near-future"));
2719+
26812720
// Null expiration: should succeed and remove expiration
26822721
Assert.True(await cache.SetAsync("set-null-exp", "value", TimeSpan.FromHours(1)));
26832722
Assert.True(await cache.ExistsAsync("set-null-exp"));

src/Foundatio/Caching/HybridCacheClient.cs

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ public async Task<bool> AddAsync<T>(string key, T value, TimeSpan? expiresIn = n
237237
{
238238
ArgumentException.ThrowIfNullOrEmpty(key);
239239

240-
if (expiresIn is { Ticks: <= 0 })
240+
if (expiresIn < CacheClientExtensions.MinimumExpiration)
241241
{
242242
await RemoveAsync(key).AnyContext();
243243
return false;
@@ -255,7 +255,7 @@ public async Task<bool> SetAsync<T>(string key, T value, TimeSpan? expiresIn = n
255255
{
256256
ArgumentException.ThrowIfNullOrEmpty(key);
257257

258-
if (expiresIn is { Ticks: <= 0 })
258+
if (expiresIn < CacheClientExtensions.MinimumExpiration)
259259
{
260260
await RemoveAsync(key).AnyContext();
261261
return false;
@@ -285,7 +285,7 @@ public async Task<int> SetAllAsync<T>(IDictionary<string, T> values, TimeSpan? e
285285
if (values.Count is 0)
286286
return 0;
287287

288-
if (expiresIn is { Ticks: <= 0 })
288+
if (expiresIn < CacheClientExtensions.MinimumExpiration)
289289
{
290290
await RemoveAllAsync(values.Keys).AnyContext();
291291
return 0;
@@ -312,7 +312,7 @@ public async Task<bool> ReplaceAsync<T>(string key, T value, TimeSpan? expiresIn
312312
{
313313
ArgumentException.ThrowIfNullOrEmpty(key);
314314

315-
if (expiresIn is { Ticks: <= 0 })
315+
if (expiresIn < CacheClientExtensions.MinimumExpiration)
316316
{
317317
await RemoveAsync(key).AnyContext();
318318
return false;
@@ -337,7 +337,7 @@ public async Task<bool> ReplaceIfEqualAsync<T>(string key, T value, T expected,
337337
{
338338
ArgumentException.ThrowIfNullOrEmpty(key);
339339

340-
if (expiresIn is { Ticks: <= 0 })
340+
if (expiresIn < CacheClientExtensions.MinimumExpiration)
341341
{
342342
await RemoveAsync(key).AnyContext();
343343
return false;
@@ -364,7 +364,7 @@ public async Task<double> IncrementAsync(string key, double amount, TimeSpan? ex
364364
{
365365
ArgumentException.ThrowIfNullOrEmpty(key);
366366

367-
if (expiresIn is { Ticks: <= 0 })
367+
if (expiresIn < CacheClientExtensions.MinimumExpiration)
368368
{
369369
await RemoveAsync(key).AnyContext();
370370
return 0;
@@ -394,7 +394,7 @@ public async Task<long> IncrementAsync(string key, long amount, TimeSpan? expire
394394
{
395395
ArgumentException.ThrowIfNullOrEmpty(key);
396396

397-
if (expiresIn is { Ticks: <= 0 })
397+
if (expiresIn < CacheClientExtensions.MinimumExpiration)
398398
{
399399
await RemoveAsync(key).AnyContext();
400400
return 0;
@@ -512,7 +512,7 @@ public async Task<double> SetIfHigherAsync(string key, double value, TimeSpan? e
512512
{
513513
ArgumentException.ThrowIfNullOrEmpty(key);
514514

515-
if (expiresIn is { Ticks: <= 0 })
515+
if (expiresIn < CacheClientExtensions.MinimumExpiration)
516516
{
517517
await RemoveAsync(key).AnyContext();
518518
return 0;
@@ -540,7 +540,7 @@ public async Task<long> SetIfHigherAsync(string key, long value, TimeSpan? expir
540540
{
541541
ArgumentException.ThrowIfNullOrEmpty(key);
542542

543-
if (expiresIn is { Ticks: <= 0 })
543+
if (expiresIn < CacheClientExtensions.MinimumExpiration)
544544
{
545545
await RemoveAsync(key).AnyContext();
546546
return 0;
@@ -568,7 +568,7 @@ public async Task<double> SetIfLowerAsync(string key, double value, TimeSpan? ex
568568
{
569569
ArgumentException.ThrowIfNullOrEmpty(key);
570570

571-
if (expiresIn is { Ticks: <= 0 })
571+
if (expiresIn < CacheClientExtensions.MinimumExpiration)
572572
{
573573
await RemoveAsync(key).AnyContext();
574574
return 0;
@@ -596,7 +596,7 @@ public async Task<long> SetIfLowerAsync(string key, long value, TimeSpan? expire
596596
{
597597
ArgumentException.ThrowIfNullOrEmpty(key);
598598

599-
if (expiresIn is { Ticks: <= 0 })
599+
if (expiresIn < CacheClientExtensions.MinimumExpiration)
600600
{
601601
await RemoveAsync(key).AnyContext();
602602
return 0;

src/Foundatio/Caching/InMemoryCacheClient.cs

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,7 @@ public Task<bool> AddAsync<T>(string key, T value, TimeSpan? expiresIn = null)
454454
{
455455
ArgumentException.ThrowIfNullOrEmpty(key);
456456

457-
if (expiresIn is { Ticks: <= 0 })
457+
if (expiresIn < CacheClientExtensions.MinimumExpiration)
458458
{
459459
RemoveExpiredKey(key);
460460
return Task.FromResult(false);
@@ -472,7 +472,7 @@ public Task<bool> SetAsync<T>(string key, T value, TimeSpan? expiresIn = null)
472472
{
473473
ArgumentException.ThrowIfNullOrEmpty(key);
474474

475-
if (expiresIn is { Ticks: <= 0 })
475+
if (expiresIn < CacheClientExtensions.MinimumExpiration)
476476
{
477477
RemoveExpiredKey(key);
478478
return Task.FromResult(false);
@@ -496,7 +496,7 @@ public async Task<double> SetIfHigherAsync(string key, double value, TimeSpan? e
496496
{
497497
ArgumentException.ThrowIfNullOrEmpty(key);
498498

499-
if (expiresIn?.Ticks <= 0)
499+
if (expiresIn < CacheClientExtensions.MinimumExpiration)
500500
{
501501
RemoveExpiredKey(key);
502502
return 0;
@@ -559,7 +559,7 @@ public async Task<long> SetIfHigherAsync(string key, long value, TimeSpan? expir
559559
{
560560
ArgumentException.ThrowIfNullOrEmpty(key);
561561

562-
if (expiresIn?.Ticks <= 0)
562+
if (expiresIn < CacheClientExtensions.MinimumExpiration)
563563
{
564564
RemoveExpiredKey(key);
565565
return 0;
@@ -622,7 +622,7 @@ public async Task<double> SetIfLowerAsync(string key, double value, TimeSpan? ex
622622
{
623623
ArgumentException.ThrowIfNullOrEmpty(key);
624624

625-
if (expiresIn?.Ticks <= 0)
625+
if (expiresIn < CacheClientExtensions.MinimumExpiration)
626626
{
627627
RemoveExpiredKey(key);
628628
return 0;
@@ -685,7 +685,7 @@ public async Task<long> SetIfLowerAsync(string key, long value, TimeSpan? expire
685685
{
686686
ArgumentException.ThrowIfNullOrEmpty(key);
687687

688-
if (expiresIn?.Ticks <= 0)
688+
if (expiresIn < CacheClientExtensions.MinimumExpiration)
689689
{
690690
RemoveExpiredKey(key);
691691
return 0;
@@ -749,7 +749,7 @@ public async Task<long> ListAddAsync<T>(string key, IEnumerable<T> values, TimeS
749749
ArgumentException.ThrowIfNullOrEmpty(key);
750750
ArgumentNullException.ThrowIfNull(values);
751751

752-
if (expiresIn is { Ticks: <= 0 })
752+
if (expiresIn < CacheClientExtensions.MinimumExpiration)
753753
{
754754
await ListRemoveAsync(key, values).AnyContext();
755755
return 0;
@@ -1063,7 +1063,7 @@ public async Task<int> SetAllAsync<T>(IDictionary<string, T> values, TimeSpan? e
10631063
if (values.Count is 0)
10641064
return 0;
10651065

1066-
if (expiresIn?.Ticks <= 0)
1066+
if (expiresIn < CacheClientExtensions.MinimumExpiration)
10671067
{
10681068
foreach (string key in values.Keys)
10691069
RemoveExpiredKey(key);
@@ -1108,7 +1108,7 @@ public async Task<bool> ReplaceIfEqualAsync<T>(string key, T value, T expected,
11081108
{
11091109
ArgumentException.ThrowIfNullOrEmpty(key);
11101110

1111-
if (expiresIn?.Ticks <= 0)
1111+
if (expiresIn < CacheClientExtensions.MinimumExpiration)
11121112
{
11131113
RemoveExpiredKey(key);
11141114
return false;
@@ -1156,7 +1156,7 @@ public async Task<double> IncrementAsync(string key, double amount, TimeSpan? ex
11561156
{
11571157
ArgumentException.ThrowIfNullOrEmpty(key);
11581158

1159-
if (expiresIn is { Ticks: <= 0 })
1159+
if (expiresIn < CacheClientExtensions.MinimumExpiration)
11601160
{
11611161
RemoveExpiredKey(key);
11621162
return 0;
@@ -1213,7 +1213,7 @@ public async Task<long> IncrementAsync(string key, long amount, TimeSpan? expire
12131213
{
12141214
ArgumentException.ThrowIfNullOrEmpty(key);
12151215

1216-
if (expiresIn is { Ticks: <= 0 })
1216+
if (expiresIn < CacheClientExtensions.MinimumExpiration)
12171217
{
12181218
RemoveExpiredKey(key);
12191219
return 0;
@@ -1353,6 +1353,12 @@ public async Task SetExpirationAsync(string key, TimeSpan expiresIn)
13531353
{
13541354
ArgumentException.ThrowIfNullOrEmpty(key);
13551355

1356+
if (expiresIn < CacheClientExtensions.MinimumExpiration)
1357+
{
1358+
RemoveExpiredKey(key);
1359+
return;
1360+
}
1361+
13561362
var utcNow = _timeProvider.GetUtcNow().UtcDateTime;
13571363
var expiresAt = utcNow.SafeAdd(expiresIn);
13581364
if (expiresAt < utcNow)
@@ -1397,16 +1403,23 @@ public async Task SetAllExpirationAsync(IDictionary<string, TimeSpan?> expiratio
13971403
}
13981404
else
13991405
{
1400-
var expiresAt = utcNow.SafeAdd(kvp.Value.Value);
1401-
if (expiresAt < utcNow)
1406+
if (kvp.Value.Value < CacheClientExtensions.MinimumExpiration)
14021407
{
14031408
RemoveExpiredKey(kvp.Key);
14041409
}
1405-
else if (existingEntry.ExpiresAt != expiresAt)
1410+
else
14061411
{
1407-
Interlocked.Increment(ref _writes);
1408-
existingEntry.ExpiresAt = expiresAt;
1409-
updated++;
1412+
var expiresAt = utcNow.SafeAdd(kvp.Value.Value);
1413+
if (expiresAt < utcNow)
1414+
{
1415+
RemoveExpiredKey(kvp.Key);
1416+
}
1417+
else if (existingEntry.ExpiresAt != expiresAt)
1418+
{
1419+
Interlocked.Increment(ref _writes);
1420+
existingEntry.ExpiresAt = expiresAt;
1421+
updated++;
1422+
}
14101423
}
14111424
}
14121425
}

src/Foundatio/Extensions/CacheClientExtensions.cs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ namespace Foundatio.Caching;
88

99
public static class CacheClientExtensions
1010
{
11+
/// <summary>
12+
/// Minimum meaningful cache expiration. Values below this threshold are treated as already-expired
13+
/// because sub-millisecond TTLs are truncated to zero by external providers (e.g., Redis PSETEX
14+
/// converts TimeSpan to milliseconds via integer cast, so 0.9ms becomes 0ms and is rejected).
15+
/// 5ms provides a safe margin above the 1ms integer-truncation boundary while remaining far
16+
/// below any real-world cache TTL.
17+
/// </summary>
18+
public static readonly TimeSpan MinimumExpiration = TimeSpan.FromMilliseconds(5);
19+
1120
public static async Task<T> GetAsync<T>(this ICacheClient client, string key, T defaultValue)
1221
{
1322
var cacheValue = await client.GetAsync<T>(key).AnyContext();
@@ -153,12 +162,19 @@ public static Task<bool> SetUnixTimeSecondsAsync(this ICacheClient client, strin
153162
/// <summary>
154163
/// Converts a DateTime expiration to a TimeSpan relative to now.
155164
/// DateTime.MaxValue is treated as null (no expiration).
165+
/// Returns TimeSpan.Zero when the computed TTL is below <see cref="MinimumExpiration"/>,
166+
/// so downstream guards treat it as already-expired.
156167
/// </summary>
157168
private static TimeSpan? ToExpiresIn(this ICacheClient client, DateTime? expiresAtUtc)
158169
{
159170
if (!expiresAtUtc.HasValue || expiresAtUtc.Value == DateTime.MaxValue)
160171
return null;
161172

162-
return expiresAtUtc.Value.Subtract(client.GetTimeProvider().GetUtcNow().UtcDateTime);
173+
var expiresIn = expiresAtUtc.Value.Subtract(client.GetTimeProvider().GetUtcNow().UtcDateTime);
174+
175+
if (expiresIn < MinimumExpiration)
176+
return TimeSpan.Zero;
177+
178+
return expiresIn;
163179
}
164180
}

0 commit comments

Comments
 (0)