@@ -206,50 +206,56 @@ public async ValueTask<bool> TryGetAsync(
206206
207207 internal TimeSpan ? GetTtl ( DistributedCacheEntryOptions options )
208208 {
209- if ( options . AbsoluteExpiration . HasValue && options . AbsoluteExpiration . Value <= TimeProvider . GetUtcNow ( ) )
209+ // Maximum-value sentinels mean "never expire": normalize them to no expiration so they are not
210+ // rejected as out-of-range below. Callers commonly use DateTimeOffset.MaxValue / TimeSpan.MaxValue
211+ // as a "cache forever" idiom, which is equivalent to omitting expiration entirely.
212+ var absoluteExpiration = EffectiveAbsoluteExpiration ( options ) ;
213+ var relativeToNow = EffectiveRelativeToNow ( options ) ;
214+ var slidingExpiration = EffectiveSlidingExpiration ( options ) ;
215+
216+ if ( absoluteExpiration . HasValue && absoluteExpiration . Value <= TimeProvider . GetUtcNow ( ) )
210217 {
211218 throw new ArgumentOutOfRangeException (
212219 nameof ( DistributedCacheEntryOptions . AbsoluteExpiration ) ,
213- options . AbsoluteExpiration . Value ,
220+ absoluteExpiration . Value ,
214221 "The absolute expiration value must be in the future." ) ;
215222 }
216223
217- if ( options . AbsoluteExpirationRelativeToNow . HasValue &&
218- options . AbsoluteExpirationRelativeToNow . Value <= TimeSpan . Zero )
224+ if ( relativeToNow . HasValue && relativeToNow . Value <= TimeSpan . Zero )
219225 {
220226 throw new ArgumentOutOfRangeException (
221227 nameof ( DistributedCacheEntryOptions . AbsoluteExpirationRelativeToNow ) ,
222- options . AbsoluteExpirationRelativeToNow . Value ,
228+ relativeToNow . Value ,
223229 "The relative expiration value must be positive." ) ;
224230 }
225231
226- if ( options . SlidingExpiration . HasValue && options . SlidingExpiration . Value <= TimeSpan . Zero )
232+ if ( slidingExpiration . HasValue && slidingExpiration . Value <= TimeSpan . Zero )
227233 {
228234 throw new ArgumentOutOfRangeException (
229235 nameof ( DistributedCacheEntryOptions . SlidingExpiration ) ,
230- options . SlidingExpiration . Value ,
236+ slidingExpiration . Value ,
231237 "The sliding expiration value must be positive." ) ;
232238 }
233239
234240 // Reject sliding windows the serializer/TTL encoding could not round-trip. The read path fails
235241 // closed above MaxTtlTicks, so accepting a larger value here would silently store an entry that
236242 // later reads back as an undeserializable cache miss.
237- if ( options . SlidingExpiration . HasValue &&
238- options . SlidingExpiration . Value . Ticks > CacheEntryBinarySerializer . MaxTtlTicks )
243+ if ( slidingExpiration . HasValue &&
244+ slidingExpiration . Value . Ticks > CacheEntryBinarySerializer . MaxTtlTicks )
239245 {
240246 throw new ArgumentOutOfRangeException (
241247 nameof ( DistributedCacheEntryOptions . SlidingExpiration ) ,
242- options . SlidingExpiration . Value ,
243- "The sliding expiration value is too large." ) ;
248+ slidingExpiration . Value ,
249+ $ "The sliding expiration value is too large. The maximum is { CacheEntryBinarySerializer . MaxTtl } .") ;
244250 }
245251
246- var absoluteExpiration = ResolveAbsoluteExpiration ( options ) ;
247- if ( ! absoluteExpiration . HasValue )
252+ var resolvedAbsolute = ResolveAbsoluteExpiration ( options ) ;
253+ if ( ! resolvedAbsolute . HasValue )
248254 {
249- return options . SlidingExpiration ;
255+ return slidingExpiration ;
250256 }
251257
252- var ttl = absoluteExpiration . Value - TimeProvider . GetUtcNow ( ) ;
258+ var ttl = resolvedAbsolute . Value - TimeProvider . GetUtcNow ( ) ;
253259 if ( ttl . TotalMilliseconds <= 0 )
254260 {
255261 // Value is in the past, remove it
@@ -258,28 +264,28 @@ public async ValueTask<bool> TryGetAsync(
258264
259265 // If there's also a sliding expiration, use the minimum of the two. Sliding is bounded to
260266 // MaxTtlTicks above, so the minimum is always within the encodable range.
261- if ( options . SlidingExpiration . HasValue )
267+ if ( slidingExpiration . HasValue )
262268 {
263- return TimeSpan . FromTicks ( Math . Min ( ttl . Ticks , options . SlidingExpiration . Value . Ticks ) ) ;
269+ return TimeSpan . FromTicks ( Math . Min ( ttl . Ticks , slidingExpiration . Value . Ticks ) ) ;
264270 }
265271
266272 // Absolute-only: the TTL spans the full window to the absolute instant. Reject windows the NATS
267273 // TTL encoding cannot represent (see CacheEntryBinarySerializer.MaxTtlTicks) rather than emitting
268274 // an overflowed header.
269275 if ( ttl . Ticks > CacheEntryBinarySerializer . MaxTtlTicks )
270276 {
271- if ( options . AbsoluteExpirationRelativeToNow . HasValue )
277+ if ( relativeToNow . HasValue )
272278 {
273279 throw new ArgumentOutOfRangeException (
274280 nameof ( DistributedCacheEntryOptions . AbsoluteExpirationRelativeToNow ) ,
275- options . AbsoluteExpirationRelativeToNow . Value ,
276- "The relative expiration value is too large." ) ;
281+ relativeToNow . Value ,
282+ $ "The relative expiration value is too large. The maximum is { CacheEntryBinarySerializer . MaxTtl } .") ;
277283 }
278284
279285 throw new ArgumentOutOfRangeException (
280286 nameof ( DistributedCacheEntryOptions . AbsoluteExpiration ) ,
281- options . AbsoluteExpiration ! . Value ,
282- "The absolute expiration is too far in the future." ) ;
287+ absoluteExpiration ! . Value ,
288+ $ "The absolute expiration is too far in the future. The maximum window is { CacheEntryBinarySerializer . MaxTtl } .") ;
283289 }
284290
285291 return ttl ;
@@ -290,7 +296,7 @@ internal CacheEntry CreateCacheEntry(byte[] value, DistributedCacheEntryOptions
290296 {
291297 Data = value ,
292298 AbsoluteExpiration = ResolveAbsoluteExpiration ( options ) ,
293- SlidingExpirationTicks = options . SlidingExpiration ? . Ticks
299+ SlidingExpirationTicks = EffectiveSlidingExpiration ( options ) ? . Ticks
294300 } ;
295301
296302 // An entry is absolutely expired once the clock reaches its absolute expiration instant. The
@@ -326,12 +332,27 @@ internal NatsKVConfig BuildBucketConfig()
326332 return config ;
327333 }
328334
335+ // "Never expire" sentinels: a DateTimeOffset.MaxValue absolute instant or a TimeSpan.MaxValue window
336+ // is normalized to no expiration, so it is not rejected as out-of-range and the entry lives forever.
337+ private static DateTimeOffset ? EffectiveAbsoluteExpiration ( DistributedCacheEntryOptions options ) =>
338+ options . AbsoluteExpiration == DateTimeOffset . MaxValue ? null : options . AbsoluteExpiration ;
339+
340+ private static TimeSpan ? EffectiveRelativeToNow ( DistributedCacheEntryOptions options ) =>
341+ options . AbsoluteExpirationRelativeToNow == TimeSpan . MaxValue ? null : options . AbsoluteExpirationRelativeToNow ;
342+
343+ private static TimeSpan ? EffectiveSlidingExpiration ( DistributedCacheEntryOptions options ) =>
344+ options . SlidingExpiration == TimeSpan . MaxValue ? null : options . SlidingExpiration ;
345+
329346 // Resolves the effective absolute expiration instant: a relative expiration (offset from the
330347 // current clock) takes precedence over an explicit absolute expiration when both are set.
331- private DateTimeOffset ? ResolveAbsoluteExpiration ( DistributedCacheEntryOptions options ) =>
332- options . AbsoluteExpirationRelativeToNow . HasValue
333- ? TimeProvider . GetUtcNow ( ) . Add ( options . AbsoluteExpirationRelativeToNow . Value )
334- : options . AbsoluteExpiration ;
348+ // Maximum-value sentinels are treated as "no expiration" (see the Effective* helpers).
349+ private DateTimeOffset ? ResolveAbsoluteExpiration ( DistributedCacheEntryOptions options )
350+ {
351+ var relativeToNow = EffectiveRelativeToNow ( options ) ;
352+ return relativeToNow . HasValue
353+ ? TimeProvider . GetUtcNow ( ) . Add ( relativeToNow . Value )
354+ : EffectiveAbsoluteExpiration ( options ) ;
355+ }
335356
336357 private string GetEncodedKey ( string key ) =>
337358 string . IsNullOrEmpty ( _keyPrefix )
0 commit comments