Skip to content

Commit 3f5e21b

Browse files
authored
Merge pull request #1 from simoneM93/feature/per-group-and-per-entry-cache-duration
feat: add per-group and per-entry cache duration with priority chain
2 parents 9450687 + 0668acd commit 3f5e21b

7 files changed

Lines changed: 339 additions & 75 deletions

File tree

AspNetCoreCacheKit.csproj

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,24 @@
77

88
<IsPackable>true</IsPackable>
99
<PackageId>AspNetCoreCacheKit</PackageId>
10-
<Version>1.0.0</Version>
10+
<Version>2.0.0</Version>
1111
<Authors>Simone Marano</Authors>
12-
<Description>Modern caching library for ASP.NET Core with group keys and validation</Description>
12+
<Description>Lightweight caching toolkit for ASP.NET Core with group-based keys, per-entry and per-group duration, configurable via appsettings.json and DI-ready design.</Description>
1313
<PackageLicenseExpression>MIT</PackageLicenseExpression>
14-
<PackageTags>cache;aspnetcore;memorycache</PackageTags>
14+
<PackageTags>caching;aspnetcore;dotnet;memorycache;dependency-injection;group-cache;cache-expiration;in-memory-cache</PackageTags>
1515
<RepositoryUrl>https://github.com/simoneM93/AspNetCoreCacheKit</RepositoryUrl>
16-
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
16+
<RepositoryType>git</RepositoryType>
17+
<PackageProjectUrl>https://github.com/simoneM93/AspNetCoreCacheKit</PackageProjectUrl>
1718
<PackageReadmeFile>README.md</PackageReadmeFile>
19+
<PackageReleaseNotes>https://github.com/simoneM93/AspNetCoreCacheKit/blob/main/CHANGELOG.md</PackageReleaseNotes>
20+
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
1821
</PropertyGroup>
1922

23+
<ItemGroup>
24+
<None Include="README.md" Pack="true" PackagePath="\" />
25+
<None Include="CHANGELOG.md" Pack="true" PackagePath="\" />
26+
</ItemGroup>
27+
2028
<ItemGroup>
2129
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0" />
2230
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.0" />

CHANGELOG.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
---
9+
10+
## [Unreleased]
11+
12+
---
13+
14+
## [2.0.0] - 2026-03-24
15+
16+
### Added
17+
- Per-group cache duration via `GroupDurations` in `appsettings.json` — each group key can now have its own expiration independent of the global default
18+
- Per-entry duration override — all `Set`, `GetOrCreate` and `GetOrCreateAsync` methods now accept an optional `TimeSpan? duration` parameter
19+
- Duration resolution priority chain: per-entry → per-group → global default
20+
- `CancellationToken` support on all async methods
21+
- Validation on `GroupDurations` values — all entries must be greater than zero, enforced at startup via `ValidateOnStart`
22+
- `RepositoryType`, `PackageProjectUrl` and `PackageReleaseNotes` metadata in `.csproj`
23+
24+
### Changed
25+
- `CacheOptions.Duration` renamed to `CacheOptions.DurationMinutes` for clarity — **breaking change**
26+
- `GetOrCreateAsync` now uses `IMemoryCache.GetOrCreateAsync` natively instead of wrapping the synchronous `GetOrCreate` — fixes potential cache stampede under concurrent requests
27+
- `ICacheService` registration changed from `Scoped` to `Singleton` in `AddAspNetCoreCacheKit` — aligns with `IOptions<T>` which is Singleton
28+
- Return types of `GetOrCreate<T>` and `GetOrCreateAsync<T>` corrected from `T` to `T?` — reflects the nullable contract of `IMemoryCache`
29+
- `GetFullKey` now returns the bare key when `groupKey` is empty, avoiding keys like `:mykey`
30+
- `PackageTags` expanded with `group-cache`, `cache-expiration`, `dotnet`, `dependency-injection`
31+
- `Description` updated to reflect new features
32+
33+
### Fixed
34+
- Null checks in constructor moved before field assignments and applied to parameters, not fields
35+
- `IOptionsSnapshot<CacheOptions>` replaced with `IOptions<CacheOptions>``IOptionsSnapshot` was being recreated on every HTTP request, causing unnecessary allocations for a static configuration
36+
- Redundant `services.Configure<CacheOptions>()` call removed from `AddAspNetCoreCacheKit``AddOptions().Bind()` already covers this
37+
- Redundant `.PostConfigure()` removed — default value is now declared directly on `CacheOptions.DurationMinutes`
38+
39+
### Breaking changes
40+
- `CacheOptions.Duration` (TimeSpan) → `CacheOptions.DurationMinutes` (int): update your `appsettings.json` accordingly
41+
```json
42+
// before
43+
"CacheOptions": { "Duration": "00:60:00" }
44+
45+
// after
46+
"CacheOptions": { "DurationMinutes": 60 }
47+
```
48+
- `ICacheService` methods now return `T?` instead of `T` — callers may need to handle null returns
49+
- `GetOrCreateAsync` and `GetOrCreate` signatures now include `TimeSpan? duration = null` — source-compatible (optional parameter), but binary-incompatible if you were using the interface via reflection
50+
51+
---
52+
53+
## [1.0.0] - 2025-01-01
54+
55+
### Added
56+
- Initial release
57+
- `ICacheService` interface with `Set`, `GetOrCreate`, `GetOrCreateAsync` and `Delete` methods
58+
- Group-based cache keys (`"group:key"` format)
59+
- `CacheOptions` with `IsEnabled` and `Duration` flags
60+
- `AddAspNetCoreCacheKit()` and `AddAspNetCoreCacheKit(IConfiguration)` DI extension methods
61+
- Configuration validation with DataAnnotations and `ValidateOnStart`
62+
- MIT license
63+
64+
---
65+
66+
[Unreleased]: https://github.com/simoneM93/AspNetCoreCacheKit/compare/v2.0.0...HEAD
67+
[2.0.0]: https://github.com/simoneM93/AspNetCoreCacheKit/compare/v1.0.0...v2.0.0
68+
[1.0.0]: https://github.com/simoneM93/AspNetCoreCacheKit/releases/tag/v1.0.0

CacheService.cs

Lines changed: 69 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,79 +2,119 @@
22
using AspNetCoreCacheKit.Models;
33
using Microsoft.Extensions.Caching.Memory;
44
using Microsoft.Extensions.Options;
5+
using Microsoft.Extensions.Primitives;
56

67
namespace AspNetCoreCacheKit
78
{
89
public class CacheService : ICacheService
910
{
1011
private readonly IMemoryCache _memoryCache;
1112
private readonly CacheOptions _cacheOptions;
12-
private readonly TimeSpan _expiration;
1313

14-
public CacheService(IMemoryCache memoryCache, IOptionsSnapshot<CacheOptions> cacheOptions)
14+
public CacheService(IMemoryCache memoryCache, IOptions<CacheOptions> cacheOptions)
1515
{
16+
ArgumentNullException.ThrowIfNull(memoryCache);
17+
ArgumentNullException.ThrowIfNull(cacheOptions);
18+
1619
_memoryCache = memoryCache;
1720
_cacheOptions = cacheOptions.Value;
18-
_expiration = _cacheOptions.Duration;
19-
20-
ArgumentNullException.ThrowIfNull(_memoryCache);
21-
ArgumentNullException.ThrowIfNull(_cacheOptions);
2221
}
2322

24-
public void Set(string groupKey, string key, object value)
23+
public void Set(string groupKey, string key, object value, TimeSpan? duration = null)
2524
{
2625
if (_cacheOptions.IsEnabled)
2726
{
2827
var fullKey = GetFullKey(key, groupKey);
2928
_memoryCache.Set(fullKey, value, new MemoryCacheEntryOptions
3029
{
31-
AbsoluteExpirationRelativeToNow = _expiration
30+
AbsoluteExpirationRelativeToNow = ResolveExpiration(groupKey, duration)
3231
});
3332
}
3433
}
3534

36-
public async Task<T> GetOrCreateAsync<T>(string groupKey, string key, Func<Task<T>> createFunc)
35+
public async Task<T?> GetOrCreateAsync<T>(
36+
string groupKey,
37+
string key,
38+
Func<ICacheEntry, Task<T>> createFunc,
39+
TimeSpan? duration = null,
40+
CancellationToken cancellationToken = default)
3741
{
3842
if (!_cacheOptions.IsEnabled)
39-
return await createFunc();
43+
return await createFunc(new NullCacheEntry());
4044

41-
var fullKey = GetFullKey(key, groupKey);
45+
cancellationToken.ThrowIfCancellationRequested();
4246

43-
return await _memoryCache.GetOrCreate(fullKey, entry =>
44-
{
45-
entry.AbsoluteExpirationRelativeToNow = _expiration;
46-
return createFunc();
47-
});
47+
var expiration = ResolveExpiration(groupKey, duration);
48+
49+
return await _memoryCache.GetOrCreateAsync(
50+
GetFullKey(groupKey, key),
51+
entry =>
52+
{
53+
entry.AbsoluteExpirationRelativeToNow = expiration;
54+
return createFunc(entry);
55+
});
4856
}
4957

50-
public T GetOrCreate<T>(string groupKey, string key, Func<T> createFunc)
58+
public T? GetOrCreate<T>(
59+
string groupKey,
60+
string key,
61+
Func<T> createFunc,
62+
TimeSpan? duration = null)
5163
{
5264
if (!_cacheOptions.IsEnabled)
5365
return createFunc();
5466

55-
var fullKey = GetFullKey(key, groupKey);
67+
var expiration = ResolveExpiration(groupKey, duration);
5668

57-
return _memoryCache.GetOrCreate(fullKey, entry =>
58-
{
59-
entry.AbsoluteExpirationRelativeToNow = _expiration;
60-
return createFunc();
61-
});
69+
return _memoryCache.GetOrCreate(
70+
GetFullKey(groupKey, key),
71+
entry =>
72+
{
73+
entry.AbsoluteExpirationRelativeToNow = expiration;
74+
return createFunc();
75+
});
6276
}
6377

6478
public void Delete(string groupKey, string key)
6579
{
6680
if (_cacheOptions.IsEnabled)
67-
_memoryCache.Remove(GetFullKey(key, groupKey));
81+
_memoryCache.Remove(GetFullKey(groupKey, key));
6882
}
6983

70-
public void Set(string key, object value) => Set(string.Empty, key, value);
84+
public void Set(string key, object value, TimeSpan? duration = null)
85+
=> Set(string.Empty, key, value, duration);
86+
87+
public async Task<T?> GetOrCreateAsync<T>(
88+
string key,
89+
Func<ICacheEntry, Task<T>> createFunc,
90+
TimeSpan? duration = null,
91+
CancellationToken cancellationToken = default)
92+
=> await GetOrCreateAsync(string.Empty, key, createFunc, duration, cancellationToken);
7193

72-
public async Task<T> GetOrCreateAsync<T>(string key, Func<Task<T>> createFunc) => await GetOrCreateAsync(string.Empty, key, createFunc);
94+
public T? GetOrCreate<T>(string key, Func<T> createFunc, TimeSpan? duration = null)
95+
=> GetOrCreate(string.Empty, key, createFunc, duration);
7396

74-
public T GetOrCreate<T>(string key, Func<T> createFunc) => GetOrCreate(string.Empty, key, createFunc);
97+
public void Delete(string key)
98+
=> Delete(string.Empty, key);
7599

76-
public void Delete(string key) => Delete(string.Empty, key);
100+
private static string GetFullKey(string groupKey, string key)
101+
=> string.IsNullOrEmpty(groupKey) ? key : $"{groupKey}:{key}";
77102

78-
private static string GetFullKey(string groupKey, string key) => $"{groupKey}:{key}";
103+
private TimeSpan ResolveExpiration(string groupKey, TimeSpan? duration)
104+
=> duration ?? _cacheOptions.GetGroupDuration(groupKey) ?? _cacheOptions.Duration;
105+
106+
private sealed class NullCacheEntry : ICacheEntry
107+
{
108+
public object Key => string.Empty;
109+
public object? Value { get; set; }
110+
public DateTimeOffset? AbsoluteExpiration { get; set; }
111+
public TimeSpan? AbsoluteExpirationRelativeToNow { get; set; }
112+
public TimeSpan? SlidingExpiration { get; set; }
113+
public IList<IChangeToken> ExpirationTokens { get; } = [];
114+
public IList<PostEvictionCallbackRegistration> PostEvictionCallbacks { get; } = [];
115+
public CacheItemPriority Priority { get; set; }
116+
public long? Size { get; set; }
117+
public void Dispose() { }
118+
}
79119
}
80120
}

Extensions/CacheServiceExtensions.cs

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,28 +10,29 @@ namespace AspNetCoreCacheKit.Extensions
1010
{
1111
public static class CacheServiceExtensions
1212
{
13-
public static IServiceCollection AddAspNetCoreCacheKit(this IServiceCollection services, IConfiguration configuration)
13+
public static IServiceCollection AddAspNetCoreCacheKit(
14+
this IServiceCollection services,
15+
IConfiguration configuration)
1416
{
1517
ArgumentNullException.ThrowIfNull(services);
1618
ArgumentNullException.ThrowIfNull(configuration);
1719

1820
services.AddMemoryCache();
1921

2022
var cacheSection = configuration.GetSection("CacheOptions");
21-
services.Configure<CacheOptions>(cacheSection);
2223

2324
services.AddOptions<CacheOptions>()
2425
.Bind(cacheSection)
2526
.ValidateDataAnnotations()
26-
.Validate(options => options.Duration > TimeSpan.Zero, "Duration must be greater than zero")
27-
.PostConfigure(options =>
28-
{
29-
options.Duration = options.Duration == TimeSpan.Zero ? TimeSpan.FromMinutes(60) : options.Duration;
30-
})
27+
.Validate(
28+
options => options.DurationMinutes > 0,
29+
"DurationMinutes must be greater than zero.")
30+
.Validate(
31+
options => options.GroupDurations.Values.All(v => v > 0),
32+
"All GroupDurations values must be greater than zero.")
3133
.ValidateOnStart();
3234

33-
34-
services.AddScoped<ICacheService, CacheService>();
35+
services.AddSingleton<ICacheService, CacheService>();
3536

3637
return services;
3738
}

Interfaces/ICacheService.cs

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,33 @@
1-
namespace AspNetCoreCacheKit.Interfaces
1+
using Microsoft.Extensions.Caching.Memory;
2+
3+
namespace AspNetCoreCacheKit.Interfaces
24
{
35
public interface ICacheService
46
{
5-
void Set(string groupKey, string key, object value);
7+
void Set(string groupKey, string key, object value, TimeSpan? duration = null);
68

7-
void Set(string key, object value);
9+
void Set(string key, object value, TimeSpan? duration = null);
810

9-
Task<T> GetOrCreateAsync<T>(string groupKey, string key, Func<Task<T>> createFunc);
11+
Task<T?> GetOrCreateAsync<T>(
12+
string groupKey,
13+
string key,
14+
Func<ICacheEntry, Task<T>> createFunc,
15+
TimeSpan? duration = null,
16+
CancellationToken cancellationToken = default);
1017

11-
Task<T> GetOrCreateAsync<T>(string key, Func<Task<T>> createFunc);
18+
Task<T?> GetOrCreateAsync<T>(
19+
string key,
20+
Func<ICacheEntry, Task<T>> createFunc,
21+
TimeSpan? duration = null,
22+
CancellationToken cancellationToken = default);
1223

13-
T GetOrCreate<T>(string groupKey, string key, Func<T> createFunc);
24+
T? GetOrCreate<T>(string key, Func<T> createFunc, TimeSpan? duration = null);
1425

15-
T GetOrCreate<T>(string key, Func<T> createFunc);
26+
T? GetOrCreate<T>(
27+
string groupKey,
28+
string key,
29+
Func<T> createFunc,
30+
TimeSpan? duration = null);
1631

1732
void Delete(string groupKey, string key);
1833

Models/CacheOptions.cs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,21 @@ public class CacheOptions
66
{
77
public bool IsEnabled { get; set; } = true;
88

9-
[Range(typeof(TimeSpan), "00:00:01", "24:00:00", ErrorMessage = "Duration must be between 1 second and 24 hours")]
10-
public TimeSpan Duration { get; set; } = TimeSpan.FromMinutes(60);
9+
[Range(1, int.MaxValue, ErrorMessage = "Duration must be greater than 0.")]
10+
public int DurationMinutes { get; set; } = 60;
11+
12+
internal TimeSpan Duration => TimeSpan.FromMinutes(DurationMinutes);
13+
14+
public Dictionary<string, int> GroupDurations { get; set; } = [];
15+
16+
internal TimeSpan? GetGroupDuration(string groupKey)
17+
{
18+
if (string.IsNullOrEmpty(groupKey))
19+
return null;
20+
21+
return GroupDurations.TryGetValue(groupKey, out var minutes)
22+
? TimeSpan.FromMinutes(minutes)
23+
: null;
24+
}
1125
}
1226
}

0 commit comments

Comments
 (0)