Skip to content

Commit 6263889

Browse files
committed
Ensure consistent behavior with previous ListAddAsync expires in behavior.
1 parent 296489c commit 6263889

File tree

4 files changed

+85
-13
lines changed

4 files changed

+85
-13
lines changed

docs/guide/caching.md

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -353,23 +353,59 @@ await cache.SetIfLowerAsync("fastest-response-ms", responseTime);
353353

354354
### List Operations
355355

356-
Store and manage lists:
356+
Foundatio lists support **per-value expiration**, where each item in the list can have its own independent TTL. This is different from standard cache keys where expiration applies to the entire key.
357+
358+
#### Why Per-Value Expiration?
359+
360+
Per-value expiration prevents unbounded list growth. Consider tracking recently deleted items:
361+
362+
```csharp
363+
// Without per-value expiration (sliding expiration problem):
364+
// Adding ANY item resets the entire list's TTL, causing indefinite growth
365+
await cache.ListAddAsync("deleted-items", [itemId], TimeSpan.FromDays(7));
366+
// After months: list has 100,000+ items because TTL keeps resetting!
367+
368+
// With per-value expiration (Foundatio's approach):
369+
// Each item expires independently after 7 days
370+
await cache.ListAddAsync("deleted-items", [itemId], TimeSpan.FromDays(7));
371+
// List stays bounded - old items expire even as new ones are added
372+
```
373+
374+
**Real-world use cases:**
375+
- **Soft-delete tracking**: Track deleted document IDs that should be filtered from queries
376+
- **Recent activity feeds**: Each activity expires independently (e.g., "active in last 5 minutes")
377+
- **Rate limiting windows**: Track individual requests with their own expiration
378+
- **Session tracking**: Track user sessions where each session has its own timeout
379+
380+
#### Basic List Usage
357381

358382
```csharp
359-
// Add to a list
360-
await cache.ListAddAsync("user:123:recent-searches", new[] { "query1" });
383+
// Add items with per-value expiration (each item expires in 1 hour)
384+
await cache.ListAddAsync("user:123:recent-searches", new[] { "query1" }, TimeSpan.FromHours(1));
385+
await cache.ListAddAsync("user:123:recent-searches", new[] { "query2" }, TimeSpan.FromHours(1));
386+
387+
// Items expire independently - query1 expires 1 hour after it was added,
388+
// query2 expires 1 hour after IT was added (not when query1 was added)
361389
362-
// Get paginated list
390+
// Get paginated list (expired items are automatically filtered)
363391
var searches = await cache.GetListAsync<string>(
364392
"user:123:recent-searches",
365393
page: 0,
366394
pageSize: 10
367395
);
368396

369-
// Remove from list
397+
// Remove specific items from list
370398
await cache.ListRemoveAsync("user:123:recent-searches", new[] { "query1" });
371399
```
372400

401+
#### List Expiration Behavior
402+
403+
| `expiresIn` Value | Behavior |
404+
|-------------------|----------|
405+
| `null` | Values will not expire. Key expiration is set to max of all item expirations. |
406+
| Positive `TimeSpan` | Each value expires independently after this duration. |
407+
| Zero or negative | The specified values are removed from the list (if present), returns 0. |
408+
373409
### Bulk Operations
374410

375411
Efficiently work with multiple keys:

src/Foundatio.TestHarness/Caching/CacheClientTestsBase.cs

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -989,13 +989,40 @@ public virtual async Task ListAddAsync_WithExpiration_SetsExpirationCorrectly()
989989
Assert.False(await cache.ExistsAsync("list-past-exp-new"));
990990
Assert.False((await cache.GetListAsync<int>("list-past-exp-new")).HasValue);
991991

992-
// Past expiration on existing key: should return 0 and remove the key
992+
// Past expiration on existing key: should return 0 and only remove the values being added (not the whole key)
993993
Assert.Equal(1, await cache.ListAddAsync("list-past-exp-existing", [1]));
994994
Assert.True(await cache.ExistsAsync("list-past-exp-existing"));
995995
result = await cache.ListAddAsync("list-past-exp-existing", [2], TimeSpan.FromSeconds(-1));
996996
Assert.Equal(0, result);
997-
Assert.False(await cache.ExistsAsync("list-past-exp-existing"));
998-
Assert.False((await cache.GetListAsync<int>("list-past-exp-existing")).HasValue);
997+
Assert.True(await cache.ExistsAsync("list-past-exp-existing")); // Key still exists!
998+
var existingList = await cache.GetListAsync<int>("list-past-exp-existing");
999+
Assert.True(existingList.HasValue);
1000+
Assert.Single(existingList.Value);
1001+
Assert.Contains(1, existingList.Value); // Item 1 still present
1002+
1003+
// Past expiration on existing key with multiple items: should only remove the values being added
1004+
Assert.Equal(1, await cache.ListAddAsync("list-past-exp-multi", [1]));
1005+
Assert.Equal(1, await cache.ListAddAsync("list-past-exp-multi", [2]));
1006+
Assert.Equal(2, (await cache.GetListAsync<int>("list-past-exp-multi")).Value.Count);
1007+
// Add item 3 with negative expiration - should NOT delete existing items
1008+
result = await cache.ListAddAsync("list-past-exp-multi", [3], TimeSpan.FromSeconds(-1));
1009+
Assert.Equal(0, result);
1010+
Assert.True(await cache.ExistsAsync("list-past-exp-multi")); // Key still exists!
1011+
var multiList = await cache.GetListAsync<int>("list-past-exp-multi");
1012+
Assert.Equal(2, multiList.Value.Count); // Items 1 and 2 still present
1013+
Assert.Contains(1, multiList.Value);
1014+
Assert.Contains(2, multiList.Value);
1015+
1016+
// Past expiration removes existing item if it matches
1017+
Assert.Equal(1, await cache.ListAddAsync("list-past-exp-remove", [1]));
1018+
Assert.Equal(1, await cache.ListAddAsync("list-past-exp-remove", [2]));
1019+
result = await cache.ListAddAsync("list-past-exp-remove", [1], TimeSpan.FromSeconds(-1)); // Remove item 1
1020+
Assert.Equal(0, result);
1021+
Assert.True(await cache.ExistsAsync("list-past-exp-remove"));
1022+
var removeList = await cache.GetListAsync<int>("list-past-exp-remove");
1023+
Assert.Single(removeList.Value);
1024+
Assert.Contains(2, removeList.Value); // Only item 2 remains
1025+
Assert.DoesNotContain(1, removeList.Value); // Item 1 was removed
9991026

10001027
// Zero expiration: should also be treated as expired
10011028
result = await cache.ListAddAsync("list-zero-exp", [1], TimeSpan.Zero);
@@ -1039,6 +1066,7 @@ public virtual async Task ListAddAsync_WithExpiration_SetsExpirationCorrectly()
10391066
Assert.True(cacheValue.HasValue);
10401067
Assert.Single(cacheValue.Value);
10411068
Assert.Contains(3, cacheValue.Value);
1069+
Assert.DoesNotContain(2, cacheValue.Value); // Explicit verification item 2 expired
10421070

10431071
// Wait for second item to expire
10441072
await Task.Delay(100);

src/Foundatio/Caching/ICacheClient.cs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -418,14 +418,22 @@ public interface ICacheClient : IDisposable
418418
/// <param name="key">The unique identifier for the cache entry. Cannot be null or empty.</param>
419419
/// <param name="values">The values to add to the list. Cannot be null. Null values within the collection are ignored.</param>
420420
/// <param name="expiresIn">
421-
/// Optional expiration time for the cache entry.
421+
/// Optional expiration time for each value being added (per-value expiration, NOT per-key).
422422
/// <list type="bullet">
423-
/// <item><description><b>null</b>: Entry will not expire.</description></item>
424-
/// <item><description><b>Positive value</b>: Entry expires after this duration.</description></item>
425-
/// <item><description><b>Zero or negative</b>: Any existing key is removed, returns 0.</description></item>
423+
/// <item><description><b>null</b>: Values will not expire.</description></item>
424+
/// <item><description><b>Positive value</b>: Each value expires independently after this duration.</description></item>
425+
/// <item><description><b>Zero or negative</b>: The specified values are removed from the list if present, returns 0.</description></item>
426426
/// </list>
427+
/// <para>
428+
/// <b>Design Note:</b> Per-value expiration prevents unbounded list growth. Without it, adding any item
429+
/// would reset the entire list's TTL (sliding expiration), causing lists to grow indefinitely in
430+
/// scenarios like tracking deleted items or recent activity.
431+
/// </para>
427432
/// </param>
428433
/// <returns>The number of values that were added to the list.</returns>
434+
/// <remarks>
435+
/// The key's overall expiration is automatically set to the maximum expiration of all items in the list.
436+
/// </remarks>
429437
/// <exception cref="ArgumentNullException">Thrown when <paramref name="key"/> or <paramref name="values"/> is null.</exception>
430438
/// <exception cref="ArgumentException">Thrown when <paramref name="key"/> is empty.</exception>
431439
Task<long> ListAddAsync<T>(string key, IEnumerable<T> values, TimeSpan? expiresIn = null);

src/Foundatio/Caching/InMemoryCacheClient.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -515,7 +515,7 @@ public async Task<long> ListAddAsync<T>(string key, IEnumerable<T> values, TimeS
515515

516516
if (expiresIn is { Ticks: <= 0 })
517517
{
518-
RemoveExpiredKey(key);
518+
await ListRemoveAsync(key, values).AnyContext();
519519
return 0;
520520
}
521521

0 commit comments

Comments
 (0)