From 061ec971fd3f2edaa76390c0fba4510f4604d1d3 Mon Sep 17 00:00:00 2001 From: Daniel Bohlin Date: Fri, 27 Mar 2026 14:02:09 +0100 Subject: [PATCH 01/19] feat: deprecate IMemoryWithRedis and add ReturnDefaultOnFirstLoad option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two feature requests from Eplicta.Core: 1. Mark IMemoryWithRedis and MemoryWithRedis as [Obsolete] — consumers should use IRedis or IMemory explicitly instead. 2. Add ReturnDefaultOnFirstLoad option to CacheTypeOptions that returns default(T) immediately on first cache miss, running the factory in the background. Works independently of StaleWhileRevalidate. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/feature.md | 26 +++++ .claude/mission.md | 1 + .claude/plan.md | 18 ++++ Tharga.Cache.Redis.Tests/ObsoleteTests.cs | 25 +++++ Tharga.Cache.Redis/IMemoryWithRedis.cs | 1 + Tharga.Cache.Redis/MemoryWithRedis.cs | 1 + Tharga.Cache.Tests/Helper/CacheTypeLoader.cs | 11 ++- .../ReturnDefaultOnFirstLoadTests.cs | 96 +++++++++++++++++++ Tharga.Cache/CacheTypeInfo.cs | 1 + Tharga.Cache/CacheTypeOptions.cs | 7 ++ Tharga.Cache/Core/CacheBase.cs | 13 ++- Tharga.Cache/Core/CacheMonitor.cs | 3 +- Tharga.Cache/Core/IManagedCacheMonitor.cs | 2 +- 13 files changed, 196 insertions(+), 9 deletions(-) create mode 100644 .claude/feature.md create mode 100644 .claude/plan.md create mode 100644 Tharga.Cache.Redis.Tests/ObsoleteTests.cs create mode 100644 Tharga.Cache.Tests/ReturnDefaultOnFirstLoadTests.cs diff --git a/.claude/feature.md b/.claude/feature.md new file mode 100644 index 0000000..ea7626f --- /dev/null +++ b/.claude/feature.md @@ -0,0 +1,26 @@ +# Feature: eplicta-cache-requests + +## Goal +Address two pending feature requests from Eplicta.Core (`c:\dev\Eplicta\.claude\requests.md`). + +## Originating Branch +`develop` + +## Scope + +### Request 1: Deprecate IMemoryWithRedis +Mark `IMemoryWithRedis` and `MemoryWithRedis` as `[Obsolete]` — the type couples two storage concerns and consumers should use `IRedis` or `IMemory` explicitly. + +### Request 2: ReturnDefaultOnFirstLoad option +Add a `ReturnDefaultOnFirstLoad` property to `CacheTypeOptions` that returns `default(T)` immediately on the first cache miss instead of blocking. The factory runs in the background. Works independently of `StaleWhileRevalidate`. + +## Acceptance Criteria +- [ ] `IMemoryWithRedis` and `MemoryWithRedis` are marked `[Obsolete]` +- [ ] `ReturnDefaultOnFirstLoad` option exists on `CacheTypeOptions` +- [ ] When enabled, first cache miss returns `default(T)` and triggers background load +- [ ] Works independently of `StaleWhileRevalidate` (all 4 combinations valid) +- [ ] Tests cover all scenarios +- [ ] All existing tests pass + +## Done Condition +Both requests implemented, all tests pass, user confirms done. diff --git a/.claude/mission.md b/.claude/mission.md index 047c61d..a481b00 100644 --- a/.claude/mission.md +++ b/.claude/mission.md @@ -4,3 +4,4 @@ Caching library with MongoDB backend support, cache partitioning, and a Blazor m ## External References - **Backlog**: `c:\Users\danie\SynologyDrive\Documents\Notes\Tharga\Toolkit\Cache.md` +- **Incoming requests**: `c:\dev\Eplicta\.claude\requests.md` — check for pending requests **To: Tharga.Cache** on startup; write back notifications when completed diff --git a/.claude/plan.md b/.claude/plan.md new file mode 100644 index 0000000..aea6788 --- /dev/null +++ b/.claude/plan.md @@ -0,0 +1,18 @@ +# Plan: eplicta-cache-requests + +## Steps + +### Request 1: Deprecate IMemoryWithRedis +- [x] 1. Mark `IMemoryWithRedis` and `MemoryWithRedis` as `[Obsolete]` with descriptive message +- [x] 2. Write test verifying obsolete attribute is present +- [x] 3. Run tests, commit + +### Request 2: ReturnDefaultOnFirstLoad +- [x] 4. Add `ReturnDefaultOnFirstLoad` property to `CacheTypeOptions` +- [x] 5. Add `ReturnDefaultOnFirstLoad` to `CacheTypeInfo` +- [x] 6. Modify `GetCoreAsync()` in `CacheBase.cs` to return `default(T)` and trigger background load when option is enabled and no cache exists +- [x] 7. Write tests covering all 4 combinations of SWR × ReturnDefaultOnFirstLoad +- [x] 8. Run full test suite, commit + +### Finalize +- [x] 9. Run full test suite, summarize for review diff --git a/Tharga.Cache.Redis.Tests/ObsoleteTests.cs b/Tharga.Cache.Redis.Tests/ObsoleteTests.cs new file mode 100644 index 0000000..9131031 --- /dev/null +++ b/Tharga.Cache.Redis.Tests/ObsoleteTests.cs @@ -0,0 +1,25 @@ +using FluentAssertions; +using Xunit; + +namespace Tharga.Cache.Redis.Tests; + +public class ObsoleteTests +{ + [Fact] + public void IMemoryWithRedis_should_be_obsolete() + { + var attribute = Attribute.GetCustomAttribute(typeof(IMemoryWithRedis), typeof(ObsoleteAttribute)); + attribute.Should().NotBeNull("IMemoryWithRedis should be marked as [Obsolete]"); + } + + [Fact] + public void MemoryWithRedis_should_be_obsolete() + { + var type = typeof(IMemoryWithRedis).Assembly + .GetTypes() + .Single(t => t.Name == "MemoryWithRedis"); + + var attribute = Attribute.GetCustomAttribute(type, typeof(ObsoleteAttribute)); + attribute.Should().NotBeNull("MemoryWithRedis should be marked as [Obsolete]"); + } +} diff --git a/Tharga.Cache.Redis/IMemoryWithRedis.cs b/Tharga.Cache.Redis/IMemoryWithRedis.cs index 80d59a8..a5d5553 100644 --- a/Tharga.Cache.Redis/IMemoryWithRedis.cs +++ b/Tharga.Cache.Redis/IMemoryWithRedis.cs @@ -3,4 +3,5 @@ /// /// Should only be used when running a single instance, since data is stored in memory. /// +[Obsolete("IMemoryWithRedis couples two storage concerns. Use IRedis or IMemory explicitly instead.")] public interface IMemoryWithRedis : IPersist; \ No newline at end of file diff --git a/Tharga.Cache.Redis/MemoryWithRedis.cs b/Tharga.Cache.Redis/MemoryWithRedis.cs index f2141fb..80956e8 100644 --- a/Tharga.Cache.Redis/MemoryWithRedis.cs +++ b/Tharga.Cache.Redis/MemoryWithRedis.cs @@ -3,6 +3,7 @@ namespace Tharga.Cache.Redis; +[Obsolete("MemoryWithRedis couples two storage concerns. Use Redis or Memory explicitly instead.")] internal class MemoryWithRedis : IMemoryWithRedis, IAsyncDisposable, IDisposable { private readonly IMemory _memory; diff --git a/Tharga.Cache.Tests/Helper/CacheTypeLoader.cs b/Tharga.Cache.Tests/Helper/CacheTypeLoader.cs index b431afc..8d10eb5 100644 --- a/Tharga.Cache.Tests/Helper/CacheTypeLoader.cs +++ b/Tharga.Cache.Tests/Helper/CacheTypeLoader.cs @@ -6,20 +6,20 @@ namespace Tharga.Cache.Tests.Helper; internal static class CacheTypeLoader { - public static (T Cache, ICacheMonitor Monitor) GetCache(Type cacheType, EvictionPolicy? evictionPolicy, bool staleWhileRevalidate, TimeSpan? defaultFreshSpan = null, string connectionString = "LOCAL") + public static (T Cache, ICacheMonitor Monitor) GetCache(Type cacheType, EvictionPolicy? evictionPolicy, bool staleWhileRevalidate, TimeSpan? defaultFreshSpan = null, string connectionString = "LOCAL", bool returnDefaultOnFirstLoad = false) where T : ICache where TPersist : IPersist { - var item = GetCache(cacheType, evictionPolicy, staleWhileRevalidate, defaultFreshSpan, connectionString); + var item = GetCache(cacheType, evictionPolicy, staleWhileRevalidate, defaultFreshSpan, connectionString, returnDefaultOnFirstLoad); return ((T)item.Cache, item.Monitor); } - public static (ICache Cache, ICacheMonitor Monitor) GetCache(Type cacheType, EvictionPolicy? evictionPolicy, bool staleWhileRevalidate, TimeSpan? defaultFreshSpan = null, string connectionString = "LOCAL") + public static (ICache Cache, ICacheMonitor Monitor) GetCache(Type cacheType, EvictionPolicy? evictionPolicy, bool staleWhileRevalidate, TimeSpan? defaultFreshSpan = null, string connectionString = "LOCAL", bool returnDefaultOnFirstLoad = false) { - return GetCache(cacheType, evictionPolicy, staleWhileRevalidate, defaultFreshSpan = null, connectionString); + return GetCache(cacheType, evictionPolicy, staleWhileRevalidate, defaultFreshSpan = null, connectionString, returnDefaultOnFirstLoad); } - public static (ICache Cache, ICacheMonitor Monitor) GetCache(Type cacheType, EvictionPolicy? evictionPolicy, bool staleWhileRevalidate, TimeSpan? defaultFreshSpan = null, string connectionString = "LOCAL") + public static (ICache Cache, ICacheMonitor Monitor) GetCache(Type cacheType, EvictionPolicy? evictionPolicy, bool staleWhileRevalidate, TimeSpan? defaultFreshSpan = null, string connectionString = "LOCAL", bool returnDefaultOnFirstLoad = false) where TPersist : IPersist { var options = new CacheOptions @@ -33,6 +33,7 @@ public static (ICache Cache, ICacheMonitor Monitor) GetCache(Type cach options.RegisterType(s => { s.StaleWhileRevalidate = staleWhileRevalidate; + s.ReturnDefaultOnFirstLoad = returnDefaultOnFirstLoad; s.DefaultFreshSpan = defaultFreshSpan ?? TimeSpan.FromSeconds(10); s.EvictionPolicy = evictionPolicy ?? EvictionPolicy.FirstInFirstOut; }); diff --git a/Tharga.Cache.Tests/ReturnDefaultOnFirstLoadTests.cs b/Tharga.Cache.Tests/ReturnDefaultOnFirstLoadTests.cs new file mode 100644 index 0000000..4589128 --- /dev/null +++ b/Tharga.Cache.Tests/ReturnDefaultOnFirstLoadTests.cs @@ -0,0 +1,96 @@ +using FluentAssertions; +using Tharga.Cache.Core; +using Tharga.Cache.Persist; +using Tharga.Cache.Tests.Helper; +using Xunit; + +namespace Tharga.Cache.Tests; + +public class ReturnDefaultOnFirstLoadTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FirstCall_with_ReturnDefaultOnFirstLoad_returns_default(bool staleWhileRevalidate) + { + //Arrange + var result = CacheTypeLoader.GetCache( + typeof(TimeToLiveCache), null, staleWhileRevalidate, + returnDefaultOnFirstLoad: true); + var sut = result.Cache; + var fetchStarted = new TaskCompletionSource(); + var fetchGate = new TaskCompletionSource(); + + //Act + var item = await sut.GetAsync("Key", async () => + { + fetchStarted.TrySetResult(); + await fetchGate.Task; + return "fetched"; + }); + + //Assert + item.Should().BeNull("first call should return default(T) without blocking"); + await fetchStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + fetchGate.TrySetResult(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FirstCall_without_ReturnDefaultOnFirstLoad_blocks(bool staleWhileRevalidate) + { + //Arrange + var result = CacheTypeLoader.GetCache( + typeof(TimeToLiveCache), null, staleWhileRevalidate, + returnDefaultOnFirstLoad: false); + var sut = result.Cache; + + //Act + var item = await sut.GetAsync("Key", async () => + { + await Task.Delay(50); + return "fetched"; + }); + + //Assert + item.Should().Be("fetched", "first call should block until factory completes"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SecondCall_returns_fetched_data_after_background_load(bool staleWhileRevalidate) + { + //Arrange + var result = CacheTypeLoader.GetCache( + typeof(TimeToLiveCache), null, staleWhileRevalidate, + returnDefaultOnFirstLoad: true); + var sut = result.Cache; + + //Act - first call returns default, factory runs in background + var first = await sut.GetAsync("Key", () => Task.FromResult("fetched")); + await Task.Delay(200); + + //Assert - second call should return the now-cached data + var second = await sut.GetAsync("Key", () => Task.FromResult("should-not-be-called")); + second.Should().Be("fetched"); + } + + [Fact] + public async Task ReturnDefaultOnFirstLoad_with_existing_fresh_data_returns_cached() + { + //Arrange + var result = CacheTypeLoader.GetCache( + typeof(TimeToLiveCache), null, staleWhileRevalidate: false, + returnDefaultOnFirstLoad: true); + var sut = result.Cache; + await sut.SetAsync("Key", "cached"); + + //Act + var item = await sut.GetAsync("Key", () => Task.FromResult("should-not-be-called")); + + //Assert + item.Should().Be("cached", "fresh cached data should be returned regardless of ReturnDefaultOnFirstLoad"); + } +} diff --git a/Tharga.Cache/CacheTypeInfo.cs b/Tharga.Cache/CacheTypeInfo.cs index 5e130a4..c8da8a6 100644 --- a/Tharga.Cache/CacheTypeInfo.cs +++ b/Tharga.Cache/CacheTypeInfo.cs @@ -6,5 +6,6 @@ public record CacheTypeInfo { public required Type Type { get; init; } public required bool StaleWhileRevalidate { get; init; } + public required bool ReturnDefaultOnFirstLoad { get; init; } public required ConcurrentDictionary Items { get; init; } } \ No newline at end of file diff --git a/Tharga.Cache/CacheTypeOptions.cs b/Tharga.Cache/CacheTypeOptions.cs index 22a3bdb..39bf09e 100644 --- a/Tharga.Cache/CacheTypeOptions.cs +++ b/Tharga.Cache/CacheTypeOptions.cs @@ -7,6 +7,13 @@ public record CacheTypeOptions /// When the data is updated the event DataSetEvent will be fired and the method 'xx'. /// public bool StaleWhileRevalidate { get; set; } + + /// + /// If this is set to true and there is no cached data at all (first load), + /// default(T) will be returned immediately and the factory will run in the background. + /// Works independently of . + /// + public bool ReturnDefaultOnFirstLoad { get; set; } public long? MaxSize { get; set; } public int? MaxCount { get; set; } public EvictionPolicy EvictionPolicy { get; set; } = EvictionPolicy.FirstInFirstOut; diff --git a/Tharga.Cache/Core/CacheBase.cs b/Tharga.Cache/Core/CacheBase.cs index 09cffb8..188f921 100644 --- a/Tharga.Cache/Core/CacheBase.cs +++ b/Tharga.Cache/Core/CacheBase.cs @@ -44,7 +44,9 @@ public virtual async Task GetAsync(Key key, Func> fetch) return (result.GetData(), true); } - if (GetTypeOptions().StaleWhileRevalidate && result != null) + var typeOptions = GetTypeOptions(); + + if (typeOptions.StaleWhileRevalidate && result != null) { var response = result.GetData(); await OnGetAsync(key); @@ -53,6 +55,12 @@ public virtual async Task GetAsync(Key key, Func> fetch) return (response, false); } + if (typeOptions.ReturnDefaultOnFirstLoad && result == null) + { + BackgroundLoad(key, fetch, callback, fs); + return (default, false); + } + var loadResponse = await _fetchQueue.LoadData(key, fetch, fs, FetchCallback); await OnGetAsync(key); return (loadResponse, true); @@ -178,7 +186,8 @@ private async Task OnSetAsync(Key key, CacheItem item, bool staleWhileReva await EvictItems(item.Data); DataSetEvent?.Invoke(this, new DataSetEventArgs(key, item.Data)); - _cacheMonitor.Set(typeof(T), key, item, staleWhileRevalidate); + var returnDefaultOnFirstLoad = GetTypeOptions().ReturnDefaultOnFirstLoad; + _cacheMonitor.Set(typeof(T), key, item, staleWhileRevalidate, returnDefaultOnFirstLoad); } protected virtual Task OnGetAsync(Key key) diff --git a/Tharga.Cache/Core/CacheMonitor.cs b/Tharga.Cache/Core/CacheMonitor.cs index b294549..a9cbfc4 100644 --- a/Tharga.Cache/Core/CacheMonitor.cs +++ b/Tharga.Cache/Core/CacheMonitor.cs @@ -20,7 +20,7 @@ public CacheMonitor(IPersistLoader persistLoader, CacheOptions cacheOptions) public event EventHandler DataSetEvent; public event EventHandler DataDropEvent; - public void Set(Type type, Key key, CacheItem item, bool staleWhileRevalidate) + public void Set(Type type, Key key, CacheItem item, bool staleWhileRevalidate, bool returnDefaultOnFirstLoad) { var size = item.Data.ToSize(); @@ -28,6 +28,7 @@ public void Set(Type type, Key key, CacheItem item, bool staleWhileRevalid { Type = type, StaleWhileRevalidate = staleWhileRevalidate, + ReturnDefaultOnFirstLoad = returnDefaultOnFirstLoad, Items = new ConcurrentDictionary(new Dictionary { { diff --git a/Tharga.Cache/Core/IManagedCacheMonitor.cs b/Tharga.Cache/Core/IManagedCacheMonitor.cs index b48dd0c..93906c7 100644 --- a/Tharga.Cache/Core/IManagedCacheMonitor.cs +++ b/Tharga.Cache/Core/IManagedCacheMonitor.cs @@ -4,7 +4,7 @@ internal interface IManagedCacheMonitor : ICacheMonitor { event EventHandler RequestEvictEvent; - void Set(Type type, Key key, CacheItem item, bool staleWhileRevalidate); + void Set(Type type, Key key, CacheItem item, bool staleWhileRevalidate, bool returnDefaultOnFirstLoad); void Accessed(Type type, Key key, bool buyMoreTime); void Drop(Type type, Key key); Key Get(EvictionPolicy evictionPolicy); From 67a34409e6cf5a3eef2f12570b9475db4e7adc64 Mon Sep 17 00:00:00 2001 From: Daniel Bohlin Date: Fri, 27 Mar 2026 14:48:17 +0100 Subject: [PATCH 02/19] feat: eplicta-cache-requests complete Co-Authored-By: Claude Opus 4.6 (1M context) --- .../eplicta-cache-requests.md} | 15 +++++++------ .claude/plan.md | 18 ---------------- README.md | 21 +++++++++++++++++++ 3 files changed, 30 insertions(+), 24 deletions(-) rename .claude/{feature.md => features-done/eplicta-cache-requests.md} (68%) delete mode 100644 .claude/plan.md diff --git a/.claude/feature.md b/.claude/features-done/eplicta-cache-requests.md similarity index 68% rename from .claude/feature.md rename to .claude/features-done/eplicta-cache-requests.md index ea7626f..452a29c 100644 --- a/.claude/feature.md +++ b/.claude/features-done/eplicta-cache-requests.md @@ -15,12 +15,15 @@ Mark `IMemoryWithRedis` and `MemoryWithRedis` as `[Obsolete]` — the type coupl Add a `ReturnDefaultOnFirstLoad` property to `CacheTypeOptions` that returns `default(T)` immediately on the first cache miss instead of blocking. The factory runs in the background. Works independently of `StaleWhileRevalidate`. ## Acceptance Criteria -- [ ] `IMemoryWithRedis` and `MemoryWithRedis` are marked `[Obsolete]` -- [ ] `ReturnDefaultOnFirstLoad` option exists on `CacheTypeOptions` -- [ ] When enabled, first cache miss returns `default(T)` and triggers background load -- [ ] Works independently of `StaleWhileRevalidate` (all 4 combinations valid) -- [ ] Tests cover all scenarios -- [ ] All existing tests pass +- [x] `IMemoryWithRedis` and `MemoryWithRedis` are marked `[Obsolete]` +- [x] `ReturnDefaultOnFirstLoad` option exists on `CacheTypeOptions` +- [x] When enabled, first cache miss returns `default(T)` and triggers background load +- [x] Works independently of `StaleWhileRevalidate` (all 4 combinations valid) +- [x] Tests cover all scenarios +- [x] All existing tests pass ## Done Condition Both requests implemented, all tests pass, user confirms done. + +## Completed +2026-03-27 on branch `feature/eplicta-cache-requests` diff --git a/.claude/plan.md b/.claude/plan.md deleted file mode 100644 index aea6788..0000000 --- a/.claude/plan.md +++ /dev/null @@ -1,18 +0,0 @@ -# Plan: eplicta-cache-requests - -## Steps - -### Request 1: Deprecate IMemoryWithRedis -- [x] 1. Mark `IMemoryWithRedis` and `MemoryWithRedis` as `[Obsolete]` with descriptive message -- [x] 2. Write test verifying obsolete attribute is present -- [x] 3. Run tests, commit - -### Request 2: ReturnDefaultOnFirstLoad -- [x] 4. Add `ReturnDefaultOnFirstLoad` property to `CacheTypeOptions` -- [x] 5. Add `ReturnDefaultOnFirstLoad` to `CacheTypeInfo` -- [x] 6. Modify `GetCoreAsync()` in `CacheBase.cs` to return `default(T)` and trigger background load when option is enabled and no cache exists -- [x] 7. Write tests covering all 4 combinations of SWR × ReturnDefaultOnFirstLoad -- [x] 8. Run full test suite, commit - -### Finalize -- [x] 9. Run full test suite, summarize for review diff --git a/README.md b/README.md index 91e5e9b..cd24782 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,27 @@ ![Nuget](https://img.shields.io/nuget/dt/Tharga.Cache) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +## Cache Type Options + +When registering a cache type, you can configure the following options: + +```csharp +builder.Services.RegisterCache(o => +{ + o.RegisterType(s => + { + s.StaleWhileRevalidate = true; + s.ReturnDefaultOnFirstLoad = true; + s.DefaultFreshSpan = TimeSpan.FromSeconds(30); + }); +}); +``` + +- **StaleWhileRevalidate** — When `true`, stale data is returned immediately while fresh data is fetched in the background. +- **ReturnDefaultOnFirstLoad** — When `true`, returns `default(T)` immediately on the first cache miss instead of blocking. The factory runs in the background and populates the cache for subsequent reads. Works independently of `StaleWhileRevalidate`. + +> **Note:** `IMemoryWithRedis` is deprecated. Use `IRedis` or `IMemory` explicitly instead. + ## Get Started Register the service From 1fadf769a2a8e9948821c0c8d2fe948997cdcf91 Mon Sep 17 00:00:00 2001 From: Daniel Bohlin Date: Fri, 27 Mar 2026 14:56:31 +0100 Subject: [PATCH 03/19] add settings --- .claude/settings.json | 10 ++++++++++ .gitignore | 1 + 2 files changed, 11 insertions(+) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..414fd67 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,10 @@ +{ + "attribution": { + "commit": "", + "pr": "" + }, + "permissions": { + "allow": [ + ] + } +} diff --git a/.gitignore b/.gitignore index a4fe18b..0bc30ee 100644 --- a/.gitignore +++ b/.gitignore @@ -398,3 +398,4 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml +/.claude/settings.local.json From 713d015c87dca6fbc54ae6b76485bf94ac674a38 Mon Sep 17 00:00:00 2001 From: Daniel Bohlin Date: Mon, 30 Mar 2026 11:08:12 +0200 Subject: [PATCH 04/19] add request --- .claude/requests.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .claude/requests.md diff --git a/.claude/requests.md b/.claude/requests.md new file mode 100644 index 0000000..43f2442 --- /dev/null +++ b/.claude/requests.md @@ -0,0 +1,8 @@ +## Pending + +### Fast path for fresh cache hits in IMemory +- **From:** Florida (`c:\dev\tharga\Florida`) +- **Date:** 2026-03-28 +- **Priority:** Medium +- **Description:** When a cached item is fresh and already in the ConcurrentDictionary, the current implementation still goes through the semaphore/fetch queue/dispatch loop. For use cases where IMemoryCache-level performance is needed (e.g. caching external API lookups that happen on every request), a fast path that skips the synchronization overhead for fresh hits would be valuable. This would keep the smart features (StaleWhileRevalidate, background refresh, eviction) but make cache hits nearly as fast as a raw dictionary lookup. +- **Status:** Pending From 6c7da148cda896022d65e26ba9dc23ed038addc6 Mon Sep 17 00:00:00 2001 From: Daniel Bohlin Date: Wed, 1 Apr 2026 21:24:43 +0200 Subject: [PATCH 05/19] update instructions --- .claude/CLAUDE.md | 30 ++++-------------------------- .claude/mission.md | 2 +- .claude/requests.md | 8 -------- .claude/settings.json | 3 ++- 4 files changed, 7 insertions(+), 36 deletions(-) delete mode 100644 .claude/requests.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 24c1cda..9ab1bcf 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -98,34 +98,12 @@ When all planned steps are done: ## Feature Requests (cross-project) -Projects can request features from each other via `.claude/requests.md`. +Cross-project requests are handled via `mission.md` — see the "Incoming requests" reference there for the central location. -- Read `~/.claude/projects.md` (or `$OBSIDIAN_VAULT/Tharga/projects.md`) to discover other projects -- Read `.claude/requests.md` on startup — show pending requests and new notifications to the user -- Writing feature requests to other projects is **exempt from the cross-project guard** -- For mono-repos: requests go to the root, not sub-projects (see projects.md for details) +- On startup, check `mission.md` for the requests location and show pending requests to the user +- Writing feature requests is **exempt from the cross-project guard** - Never mark a request as done without user confirmation -- When a request is completed: update status to Done and write a notification back to the requester's `.claude/requests.md` - -### Request format -```markdown -## Pending - -### -- **From:** (``) -- **Date:** -- **Priority:** -- **Description:** -- **Status:** Pending - -## Notifications - -### — DONE -- **From:** (``) -- **Completed:** -- **Summary:** -- **Branch/Version:** -``` +- When a request is completed: update its status to Done in the central file, add completion date and summary ## Backlog Hygiene - When a task from the backlog (in `mission.md` or linked external files) is completed, mark it as done or remove it diff --git a/.claude/mission.md b/.claude/mission.md index a481b00..9d75eeb 100644 --- a/.claude/mission.md +++ b/.claude/mission.md @@ -4,4 +4,4 @@ Caching library with MongoDB backend support, cache partitioning, and a Blazor m ## External References - **Backlog**: `c:\Users\danie\SynologyDrive\Documents\Notes\Tharga\Toolkit\Cache.md` -- **Incoming requests**: `c:\dev\Eplicta\.claude\requests.md` — check for pending requests **To: Tharga.Cache** on startup; write back notifications when completed +- **Incoming requests**: `c:\Users\danie\SynologyDrive\Documents\Notes\Tharga\Requests.md` — check for pending requests for this project on startup diff --git a/.claude/requests.md b/.claude/requests.md deleted file mode 100644 index 43f2442..0000000 --- a/.claude/requests.md +++ /dev/null @@ -1,8 +0,0 @@ -## Pending - -### Fast path for fresh cache hits in IMemory -- **From:** Florida (`c:\dev\tharga\Florida`) -- **Date:** 2026-03-28 -- **Priority:** Medium -- **Description:** When a cached item is fresh and already in the ConcurrentDictionary, the current implementation still goes through the semaphore/fetch queue/dispatch loop. For use cases where IMemoryCache-level performance is needed (e.g. caching external API lookups that happen on every request), a fast path that skips the synchronization overhead for fresh hits would be valuable. This would keep the smart features (StaleWhileRevalidate, background refresh, eviction) but make cache hits nearly as fast as a raw dictionary lookup. -- **Status:** Pending diff --git a/.claude/settings.json b/.claude/settings.json index 71f8791..a302b6c 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -22,7 +22,8 @@ "Bash(dotnet test:*)", "Bash(git add:*)", "Bash(git commit:*)", - "Bash(git merge:*)" + "Bash(git merge:*)", + "Bash(git log:*)" ], "additionalDirectories": [ ] From 424690776f5d50c40376ee2f484e3823e0a2013b Mon Sep 17 00:00:00 2001 From: Daniel Bohlin Date: Fri, 3 Apr 2026 12:27:19 +0200 Subject: [PATCH 06/19] update instructions --- .claude/CLAUDE.md | 50 ++++++++++++------- .../features-done/eplicta-cache-requests.md | 29 ----------- .claude/features-done/readme-documentation.md | 24 --------- .claude/mission.md | 2 + .claude/requests.md | 22 ++++++++ 5 files changed, 57 insertions(+), 70 deletions(-) delete mode 100644 .claude/features-done/eplicta-cache-requests.md delete mode 100644 .claude/features-done/readme-documentation.md create mode 100644 .claude/requests.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 9ab1bcf..ae70c4b 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -5,19 +5,19 @@ - If uncommitted changes exist, alert me immediately and stop - Do not proceed until I have confirmed how to handle them (commit, stash, or discard) 2. Check if `.claude/mission.md` exists and read the project mission and context. -3. Check if `.claude/plan.md` exists. - If it does, read it and summarize what has been done and what the next step is. - If it does not exist, ask me how I would like to proceed. -4. Check if `.claude/feature.md` exists and read the current feature scope. +3. Check if `plan/` exists in the project root. + - If `plan/plan.md` exists, summarize what has been done and what the next step is. + - If `plan/feature.md` exists, read the current feature scope. + - If neither exists, ask me how I would like to proceed. ### During a session After completing each step in the plan: -- Mark it as `[x]` done in `.claude/plan.md` +- Mark it as `[x]` done in `plan/plan.md` - Add a brief note about what was done and any important decisions made - Mark the next step as `[~]` in progress ### Ending a session -- Update `.claude/plan.md` with the current status of all steps +- Update `plan/plan.md` with the current status of all steps - Add a "Last session" note summarizing what was completed and what comes next - Note any README.md changes that will be needed when the feature is complete @@ -62,24 +62,34 @@ dotnet test -c Release - Commit at logical milestones (e.g. a component is complete and tested) - Never commit failing tests - Use conventional commits: `feat:`, `fix:`, `test:`, `docs:` -- Never merge to main — leave that for me to review and merge +- Never merge to master/main — leave that for me to review and merge +- Default branch strategy: `master` is production, `develop` is integration. Feature branches branch from and merge to `develop`. +- When merging a completed feature back to the originating branch, use `--no-ff` (no fast-forward) to preserve the feature branch history as a merge commit ## Feature Workflow +Active feature tracking lives in `plan/` in the project root (committed with the feature branch). +Planned and completed features are stored externally in the **Plan directory** defined in `.claude/mission.md`. + ### Planning features -- Multiple features can be planned ahead in `.claude/features-planned/` -- Each file represents one feature and they are executed in order (e.g. `01-feature-name.md`, `02-feature-name.md`) -- When starting a new feature, check `features-planned/` first for the next planned feature +- Future features are stored in the Plan directory under `planned/` +- Each file represents one feature, executed in order (e.g. `01-feature-name.md`, `02-feature-name.md`) +- When starting a new feature, check the Obsidian `planned/` directory first ### Starting a feature When told to start a new feature: 1. Ask for the feature name and goal if not provided 2. Note the current branch as the originating branch for the feature 3. Create a new branch: `git checkout -b feature/` -4. Create `.claude/feature.md` with goal, scope, acceptance criteria, and done condition -5. Create or update `.claude/plan.md` with the steps to implement the feature +4. Create `plan/feature.md` with goal, scope, acceptance criteria, and done condition +5. Create `plan/plan.md` with the steps to implement the feature 6. Confirm the plan before starting any code changes +### During implementation +- Update `plan/plan.md` continuously as changes are made +- Commit `plan/` together with code changes at logical milestones +- Run tests before each commit + ### Completing implementation When all planned steps are done: - All tests pass @@ -88,13 +98,13 @@ When all planned steps are done: - Do NOT close the feature — wait for the user to confirm it is done ### Closing a feature (only when the user says it is done) -- All acceptance criteria in `.claude/feature.md` are met +- All acceptance criteria in `plan/feature.md` are met - All tests pass - README.md has been updated to reflect the new feature -- `.claude/feature.md` is archived to `.claude/features-done/.md` and both `.claude/feature.md` and `.claude/plan.md` should be deleted -- Remove the corresponding file from `.claude/features-planned/` if one exists +- Archive `plan/feature.md` to the Plan directory `done/.md` +- Delete the `plan/` directory from the project - A final commit is made with message: `feat: complete` -- Merge to originating branch and delete feature branch only when the user explicitly asks +- Merge to originating branch with `--no-ff` and delete feature branch only when the user explicitly asks ## Feature Requests (cross-project) @@ -103,7 +113,13 @@ Cross-project requests are handled via `mission.md` — see the "Incoming reques - On startup, check `mission.md` for the requests location and show pending requests to the user - Writing feature requests is **exempt from the cross-project guard** - Never mark a request as done without user confirmation -- When a request is completed: update its status to Done in the central file, add completion date and summary +- When a request is completed: + 1. Update its status to Done in the central requests file, add completion date and summary + 2. Add a follow-up entry under `## Uppföljning` at the top of the central requests file so the consuming project knows to update: + ``` + - [ ] ska uppdatera till + ``` + 3. The follow-up is checked off when the consuming project has updated and verified the new version ## Backlog Hygiene - When a task from the backlog (in `mission.md` or linked external files) is completed, mark it as done or remove it diff --git a/.claude/features-done/eplicta-cache-requests.md b/.claude/features-done/eplicta-cache-requests.md deleted file mode 100644 index 452a29c..0000000 --- a/.claude/features-done/eplicta-cache-requests.md +++ /dev/null @@ -1,29 +0,0 @@ -# Feature: eplicta-cache-requests - -## Goal -Address two pending feature requests from Eplicta.Core (`c:\dev\Eplicta\.claude\requests.md`). - -## Originating Branch -`develop` - -## Scope - -### Request 1: Deprecate IMemoryWithRedis -Mark `IMemoryWithRedis` and `MemoryWithRedis` as `[Obsolete]` — the type couples two storage concerns and consumers should use `IRedis` or `IMemory` explicitly. - -### Request 2: ReturnDefaultOnFirstLoad option -Add a `ReturnDefaultOnFirstLoad` property to `CacheTypeOptions` that returns `default(T)` immediately on the first cache miss instead of blocking. The factory runs in the background. Works independently of `StaleWhileRevalidate`. - -## Acceptance Criteria -- [x] `IMemoryWithRedis` and `MemoryWithRedis` are marked `[Obsolete]` -- [x] `ReturnDefaultOnFirstLoad` option exists on `CacheTypeOptions` -- [x] When enabled, first cache miss returns `default(T)` and triggers background load -- [x] Works independently of `StaleWhileRevalidate` (all 4 combinations valid) -- [x] Tests cover all scenarios -- [x] All existing tests pass - -## Done Condition -Both requests implemented, all tests pass, user confirms done. - -## Completed -2026-03-27 on branch `feature/eplicta-cache-requests` diff --git a/.claude/features-done/readme-documentation.md b/.claude/features-done/readme-documentation.md deleted file mode 100644 index 362e2f9..0000000 --- a/.claude/features-done/readme-documentation.md +++ /dev/null @@ -1,24 +0,0 @@ -# Feature: readme-documentation - -## Goal -Improve all README files in the repository. The root README should provide comprehensive usage documentation. Each per-project README should be a concise NuGet sales pitch. - -## Scope -- Root README.md — full documentation -- Tharga.Cache/README.md — NuGet pitch -- Tharga.Cache.Redis/README.md — NuGet pitch -- Tharga.Cache.MongoDB/README.md — NuGet pitch -- Tharga.Cache.File/README.md — NuGet pitch -- Tharga.Cache.Blazor/README.md — NuGet pitch - -## Acceptance Criteria -- Root README covers all cache types, persistence backends, configuration, monitoring, events, and key building -- Each per-project README has a compelling description, key features, install + usage snippet, and link to full docs -- All code examples are accurate against the current API -- Build still passes - -## Done Condition -User confirms the READMEs are satisfactory. - -## Originating Branch -develop diff --git a/.claude/mission.md b/.claude/mission.md index 9d75eeb..fee2721 100644 --- a/.claude/mission.md +++ b/.claude/mission.md @@ -3,5 +3,7 @@ Caching library with MongoDB backend support, cache partitioning, and a Blazor monitoring UI. ## External References +- **Shared instructions**: `$DOC_ROOT/Tharga/shared-instructions.md` +- **Plan directory**: `$DOC_ROOT/Tharga/plans/Toolkit/Cache` - **Backlog**: `c:\Users\danie\SynologyDrive\Documents\Notes\Tharga\Toolkit\Cache.md` - **Incoming requests**: `c:\Users\danie\SynologyDrive\Documents\Notes\Tharga\Requests.md` — check for pending requests for this project on startup diff --git a/.claude/requests.md b/.claude/requests.md new file mode 100644 index 0000000..2302bd2 --- /dev/null +++ b/.claude/requests.md @@ -0,0 +1,22 @@ +## Pending + +### AddCache should be idempotent (multiple calls must not throw) +- **From:** Florida +- **Date:** 2026-04-05 +- **Priority:** High +- **Description:** Calling `AddCache()` more than once throws `System.InvalidOperationException: The type EnvironmentOption[] has already been registered` in `AppendPreviousRegistrations`. This happens when both Platform (`AddThargaTeamBlazor`) and Quilt4Net (`AddQuilt4NetApplicationInsightsClient`) register cache types — both internally call `AddCache`. It also breaks `WebApplicationFactory` integration tests which rebuild the host. Fix: `AddCache` should detect previous registrations and merge rather than throw. Multiple `AddCache` calls should be safe and additive. +- **Status:** Pending + +### SetAsync for IMemory-registered types +- **From:** Florida +- **Date:** 2026-04-05 +- **Priority:** High +- **Description:** `ITimeToLiveCache.SetAsync` currently only works with `IFile`-registered types (used for file-based persistence). Need `SetAsync(Key key, T value)` to work for `IMemory`-registered types too, so consumers can directly write/update cached values without going through `DropAsync` + `GetAsync` (which forces a full re-fetch from the source). Use case: after saving an article to Fortnox, update the cached article list by adding/replacing the single changed item — avoiding a full reload of 3000+ articles from Fortnox. The value should be stored as fresh with the type's configured `DefaultFreshSpan`. +- **Status:** Pending + +### Fast path for fresh cache hits in IMemory +- **From:** Florida +- **Date:** 2026-03-28 +- **Priority:** Medium +- **Description:** When a cached item is fresh and already in the ConcurrentDictionary, the current implementation still goes through the semaphore/fetch queue/dispatch loop. For use cases where IMemoryCache-level performance is needed (e.g. caching external API lookups that happen on every request), a fast path that skips the synchronization overhead for fresh hits would be valuable. This would keep the smart features (StaleWhileRevalidate, background refresh, eviction) but make cache hits nearly as fast as a raw dictionary lookup. +- **Status:** Pending From b262cdd360f480903a7c2baddbcd15ab2884f7f2 Mon Sep 17 00:00:00 2001 From: Daniel Bohlin Date: Mon, 6 Apr 2026 00:11:32 +0200 Subject: [PATCH 07/19] fix: make AddCache idempotent for multiple calls AppendPreviousRegistrations now skips duplicate type registrations instead of throwing. All DI service registrations use TryAdd* to prevent duplicate entries. First registration wins when the same type is registered across multiple AddCache calls. Fixes the InvalidOperationException thrown when both Platform and Quilt4Net internally call AddCache with overlapping type registrations. --- .../AddCacheIdempotencyTests.cs | 123 ++++++++++++++++++ Tharga.Cache/CacheOptions.cs | 5 + Tharga.Cache/CacheRegistrationExtensions.cs | 61 +++++---- plan/feature.md | 22 ++++ plan/plan.md | 8 ++ 5 files changed, 188 insertions(+), 31 deletions(-) create mode 100644 Tharga.Cache.Tests/AddCacheIdempotencyTests.cs create mode 100644 plan/feature.md create mode 100644 plan/plan.md diff --git a/Tharga.Cache.Tests/AddCacheIdempotencyTests.cs b/Tharga.Cache.Tests/AddCacheIdempotencyTests.cs new file mode 100644 index 0000000..0e7b827 --- /dev/null +++ b/Tharga.Cache.Tests/AddCacheIdempotencyTests.cs @@ -0,0 +1,123 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Tharga.Cache.Persist; +using Xunit; + +namespace Tharga.Cache.Tests; + +public class AddCacheIdempotencyTests : IDisposable +{ + public AddCacheIdempotencyTests() + { + CacheRegistrationExtensions.ResetRegistrations(); + } + + public void Dispose() + { + CacheRegistrationExtensions.ResetRegistrations(); + } + + [Fact] + public void AddCache_CalledTwice_WithDifferentTypes_DoesNotThrow() + { + //Arrange + var services = new ServiceCollection(); + services.AddLogging(); + + //Act + var act = () => + { + services.AddCache(o => o.RegisterType()); + services.AddCache(o => o.RegisterType()); + }; + + //Assert + act.Should().NotThrow(); + } + + [Fact] + public void AddCache_CalledTwice_WithSameType_DoesNotThrow() + { + //Arrange + var services = new ServiceCollection(); + services.AddLogging(); + + //Act + var act = () => + { + services.AddCache(o => o.RegisterType()); + services.AddCache(o => o.RegisterType()); + }; + + //Assert + act.Should().NotThrow(); + } + + [Fact] + public void AddCache_CalledTwice_WithSameType_FirstRegistrationWins() + { + //Arrange + var services = new ServiceCollection(); + services.AddLogging(); + + //Act + services.AddCache(o => o.RegisterType(t => t.DefaultFreshSpan = TimeSpan.FromMinutes(5))); + services.AddCache(o => o.RegisterType(t => t.DefaultFreshSpan = TimeSpan.FromMinutes(99))); + + //Assert + var provider = services.BuildServiceProvider(); + var cache = provider.GetRequiredService(); + cache.Should().NotBeNull(); + } + + [Fact] + public void AddCache_CalledTwice_WithNoTypes_DoesNotThrow() + { + //Arrange + var services = new ServiceCollection(); + services.AddLogging(); + + //Act + var act = () => + { + services.AddCache(); + services.AddCache(); + }; + + //Assert + act.Should().NotThrow(); + } + + [Fact] + public void AddCache_CalledTwice_BothTypesResolvable() + { + //Arrange + var services = new ServiceCollection(); + services.AddLogging(); + + //Act + services.AddCache(o => o.RegisterType()); + services.AddCache(o => o.RegisterType()); + + //Assert + var provider = services.BuildServiceProvider(); + var cache = provider.GetRequiredService(); + cache.Should().NotBeNull(); + } + + [Fact] + public void AddCache_CalledTwice_RegistersSingletonOnce() + { + //Arrange + var services = new ServiceCollection(); + services.AddLogging(); + + //Act + services.AddCache(); + services.AddCache(); + + //Assert + var cacheMonitorCount = services.Count(s => s.ServiceType == typeof(ICacheMonitor)); + cacheMonitorCount.Should().Be(1); + } +} diff --git a/Tharga.Cache/CacheOptions.cs b/Tharga.Cache/CacheOptions.cs index 0b7309e..cc7e791 100644 --- a/Tharga.Cache/CacheOptions.cs +++ b/Tharga.Cache/CacheOptions.cs @@ -38,6 +38,11 @@ internal void AddType(Type type, CacheTypeOptions typeOptions) } } + internal bool TryAddType(Type type, CacheTypeOptions typeOptions) + { + return _typeOptions.TryAdd(type, typeOptions); + } + internal CacheTypeOptions Get() { return _typeOptions.GetValueOrDefault(typeof(T)) diff --git a/Tharga.Cache/CacheRegistrationExtensions.cs b/Tharga.Cache/CacheRegistrationExtensions.cs index 6e2c2f4..11de71e 100644 --- a/Tharga.Cache/CacheRegistrationExtensions.cs +++ b/Tharga.Cache/CacheRegistrationExtensions.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.Reflection; @@ -11,6 +12,11 @@ public static class CacheRegistrationExtensions { private static readonly Dictionary _configuredPersistTypes = new(); + internal static void ResetRegistrations() + { + _configuredPersistTypes.Clear(); + } + public static void AddCache(this IServiceCollection serviceCollection, Action options = null) { var o = new CacheOptions @@ -21,17 +27,19 @@ public static void AddCache(this IServiceCollection serviceCollection, Action on each call so it carries the merged type registrations. + serviceCollection.RemoveAll>(); serviceCollection.AddSingleton(Options.Create(o)); - serviceCollection.AddSingleton(s => s.GetService()); - serviceCollection.AddSingleton(s => + serviceCollection.TryAddSingleton(s => s.GetService()); + serviceCollection.TryAddSingleton(s => { var cacheMonitor = s.GetService(); var loggerFactory = s.GetService(); var logger = loggerFactory?.CreateLogger(); return new FetchQueue(cacheMonitor, o, logger); }); - serviceCollection.AddSingleton(s => + serviceCollection.TryAddSingleton(s => { var persistLoader = s.GetService(); var cacheMonitor = new CacheMonitor(persistLoader, o); @@ -41,44 +49,36 @@ public static void AddCache(this IServiceCollection serviceCollection, Action(s => + serviceCollection.TryAddSingleton(s => { throw new NotImplementedException($"Direct use of {nameof(ICache)} has not yet been implemented."); - //var cacheMonitor = s.GetService(); - //var persistLoader = s.GetService(); - //var fetchQueue = s.GetService(); - //return new GenericCache(cacheMonitor, persistLoader, fetchQueue, o); }); - serviceCollection.AddSingleton(s => + serviceCollection.TryAddSingleton(s => { throw new NotImplementedException($"Direct use of {nameof(ITimeCache)} has not yet been implemented."); - //var cacheMonitor = s.GetService(); - //var persistLoader = s.GetService(); - //var fetchQueue = s.GetService(); - //return new GenericTimeCache(cacheMonitor, persistLoader, fetchQueue, o); }); - serviceCollection.AddSingleton(s => + serviceCollection.TryAddSingleton(s => { var cacheMonitor = s.GetService(); var persistLoader = s.GetService(); var fetchQueue = s.GetService(); return new EternalCache(cacheMonitor, persistLoader, fetchQueue, o); }); - serviceCollection.AddSingleton(s => + serviceCollection.TryAddSingleton(s => { var cacheMonitor = s.GetService(); var persistLoader = s.GetService(); var fetchQueue = s.GetService(); return new TimeToLiveCache(cacheMonitor, persistLoader, fetchQueue, o); }); - serviceCollection.AddSingleton(s => + serviceCollection.TryAddSingleton(s => { var cacheMonitor = s.GetService(); var persistLoader = s.GetService(); var fetchQueue = s.GetService(); return new TimeToIdleCache(cacheMonitor, persistLoader, fetchQueue, o); }); - serviceCollection.AddScoped(s => + serviceCollection.TryAddScoped(s => { var cacheMonitor = s.GetService(); var persistLoader = s.GetService(); @@ -86,8 +86,11 @@ public static void AddCache(this IServiceCollection serviceCollection, Action(); - serviceCollection.AddHostedService(); + serviceCollection.TryAddSingleton(); + if (!serviceCollection.Any(s => s.ServiceType == typeof(Microsoft.Extensions.Hosting.IHostedService) && s.ImplementationType == typeof(WatchDogService))) + { + serviceCollection.AddHostedService(); + } InvokeAllPersistRegistrations(serviceCollection); @@ -95,31 +98,27 @@ public static void AddCache(this IServiceCollection serviceCollection, Action - /// If AddCache are called several times, this feature appends all registrations so they can be used in the end. + /// If AddCache is called several times, this method merges all registrations so they can be used in the end. + /// First registration wins — duplicate types are silently skipped. /// - /// - /// private static void AppendPreviousRegistrations(CacheOptions o) { var previouslyRegisteredTypes = _configuredPersistTypes.ToArray(); foreach (var item in o.GetRegistered()) { - if (!_configuredPersistTypes.TryAdd(item.Key, item.Value)) - { - throw new InvalidOperationException($"The type {item.Key.Name} has already been registered."); - } + _configuredPersistTypes.TryAdd(item.Key, item.Value); } foreach (var previouslyRegisteredType in previouslyRegisteredTypes) { - o.AddType(previouslyRegisteredType.Key, previouslyRegisteredType.Value); + o.TryAddType(previouslyRegisteredType.Key, previouslyRegisteredType.Value); } } private static void RegisterPersist(IServiceCollection serviceCollection) { - serviceCollection.AddTransient(); - serviceCollection.AddSingleton(_ => throw new InvalidOperationException($"Cannot inject {nameof(IPersist)} directly, use {nameof(IPersistLoader)} instead.")); - serviceCollection.AddSingleton(); + serviceCollection.TryAddTransient(); + serviceCollection.TryAddSingleton(_ => throw new InvalidOperationException($"Cannot inject {nameof(IPersist)} directly, use {nameof(IPersistLoader)} instead.")); + serviceCollection.TryAddSingleton(); } private static void RegisterAllIPersistImplementations(IServiceCollection services, CacheOptions options) @@ -153,7 +152,7 @@ private static void RegisterIPersistFromAssembly(IServiceCollection services, As if (implementation != null) { - services.AddSingleton(iface, implementation); + services.TryAddSingleton(iface, implementation); } } } diff --git a/plan/feature.md b/plan/feature.md new file mode 100644 index 0000000..cd2bfb6 --- /dev/null +++ b/plan/feature.md @@ -0,0 +1,22 @@ +# Feature: idempotent-addcache + +## Goal +Make `AddCache()` safe to call multiple times, merging type registrations instead of throwing. + +## Scope +- `CacheRegistrationExtensions.AddCache` — guard against duplicate DI registrations +- `CacheRegistrationExtensions.AppendPreviousRegistrations` — skip duplicate types instead of throwing +- `CacheOptions.AddType` — skip duplicate types instead of throwing +- Tests verifying idempotent behavior + +## Acceptance Criteria +- Calling `AddCache()` twice with the same type registration does not throw +- Calling `AddCache()` twice with different types merges them +- First registration wins when same type is registered with different options +- All existing tests still pass + +## Done Condition +User confirms the fix is satisfactory. + +## Originating Branch +develop diff --git a/plan/plan.md b/plan/plan.md new file mode 100644 index 0000000..fabf24f --- /dev/null +++ b/plan/plan.md @@ -0,0 +1,8 @@ +# Plan: idempotent-addcache + +## Steps +- [~] 1. Write tests reproducing the bug +- [ ] 2. Fix `AppendPreviousRegistrations` — skip duplicates instead of throwing +- [ ] 3. Fix `CacheOptions.AddType` — skip duplicates instead of throwing +- [ ] 4. Guard DI registrations with `TryAdd*` methods +- [ ] 5. Run full test suite, commit From d97cae9616d27f7036cc2809f1f30009ba19ec06 Mon Sep 17 00:00:00 2001 From: Daniel Bohlin Date: Mon, 6 Apr 2026 10:04:41 +0200 Subject: [PATCH 08/19] feat: idempotent-addcache complete --- .../features-done/idempotent-addcache.md | 0 plan/plan.md | 8 -------- 2 files changed, 8 deletions(-) rename plan/feature.md => .claude/features-done/idempotent-addcache.md (100%) delete mode 100644 plan/plan.md diff --git a/plan/feature.md b/.claude/features-done/idempotent-addcache.md similarity index 100% rename from plan/feature.md rename to .claude/features-done/idempotent-addcache.md diff --git a/plan/plan.md b/plan/plan.md deleted file mode 100644 index fabf24f..0000000 --- a/plan/plan.md +++ /dev/null @@ -1,8 +0,0 @@ -# Plan: idempotent-addcache - -## Steps -- [~] 1. Write tests reproducing the bug -- [ ] 2. Fix `AppendPreviousRegistrations` — skip duplicates instead of throwing -- [ ] 3. Fix `CacheOptions.AddType` — skip duplicates instead of throwing -- [ ] 4. Guard DI registrations with `TryAdd*` methods -- [ ] 5. Run full test suite, commit From b16566d38088d1319cdab7b00b93c0434fd4790f Mon Sep 17 00:00:00 2001 From: Daniel Bohlin Date: Mon, 6 Apr 2026 10:39:50 +0200 Subject: [PATCH 09/19] fix: subscribe MongoDB to RequestEvictEvent for ClearCache/ClearStale MongoDB.cs was missing the RequestEvictEvent subscription that Memory, Redis, and File all have. Without it, ICacheMonitor.ClearAll() and ClearStale() never reached MongoDB-persisted items. Added IManagedCacheMonitor to the constructor and wired up the same eviction pattern used by the other persistence backends. --- Tharga.Cache.MongoDB/MongoDB.cs | 15 ++++++++++++++- plan/feature.md | 18 ++++++++++++++++++ plan/plan.md | 6 ++++++ 3 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 plan/feature.md create mode 100644 plan/plan.md diff --git a/Tharga.Cache.MongoDB/MongoDB.cs b/Tharga.Cache.MongoDB/MongoDB.cs index 40d96b7..4636bc1 100644 --- a/Tharga.Cache.MongoDB/MongoDB.cs +++ b/Tharga.Cache.MongoDB/MongoDB.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Text.Json; using MongoDB.Driver; +using Tharga.Cache.Core; using Tharga.MongoDB; namespace Tharga.Cache.MongoDB; @@ -13,11 +14,23 @@ internal class MongoDB : IMongoDB private readonly ICollectionProvider _collectionProvider; private readonly MongoDBCacheOptions _options; - public MongoDB(ICollectionProvider collectionProvider, IOptions options, ILogger logger) + public MongoDB(ICollectionProvider collectionProvider, IManagedCacheMonitor cacheMonitor, IOptions options, ILogger logger) { _collectionProvider = collectionProvider; _logger = logger; _options = options.Value; + + cacheMonitor.RequestEvictEvent += async (_, e) => + { + var dropAsyncMethod = typeof(MongoDB) + .GetMethod("DropAsync")! + .MakeGenericMethod(e.Type); + + var task = (Task)dropAsyncMethod.Invoke(this, [e.Key])!; + await task; + + cacheMonitor.Drop(e.Type, e.Key); + }; } public async Task> GetAsync(Key key) diff --git a/plan/feature.md b/plan/feature.md new file mode 100644 index 0000000..b428fc6 --- /dev/null +++ b/plan/feature.md @@ -0,0 +1,18 @@ +# Feature: mongodb-clear-cache + +## Goal +Fix ClearCache/ClearStale for MongoDB-backed cache types by subscribing to `RequestEvictEvent`. + +## Scope +- `MongoDB.cs` constructor — add `IManagedCacheMonitor` parameter and `RequestEvictEvent` subscription + +## Acceptance Criteria +- `ICacheMonitor.ClearAll()` removes MongoDB-persisted items +- `ICacheMonitor.ClearStale()` removes stale MongoDB-persisted items +- Existing tests still pass + +## Done Condition +User confirms the fix is satisfactory. + +## Originating Branch +develop diff --git a/plan/plan.md b/plan/plan.md new file mode 100644 index 0000000..02013da --- /dev/null +++ b/plan/plan.md @@ -0,0 +1,6 @@ +# Plan: mongodb-clear-cache + +## Steps +- [~] 1. Add `IManagedCacheMonitor` to MongoDB constructor and subscribe to `RequestEvictEvent` +- [ ] 2. Write test verifying eviction +- [ ] 3. Run full test suite, commit From f8f524646a256e60208c194eea3e6d9d5e4ebb49 Mon Sep 17 00:00:00 2001 From: Daniel Bohlin Date: Mon, 6 Apr 2026 10:42:45 +0200 Subject: [PATCH 10/19] feat: mongodb-clear-cache complete --- .../features-done/mongodb-clear-cache.md | 0 plan/plan.md | 6 ------ 2 files changed, 6 deletions(-) rename plan/feature.md => .claude/features-done/mongodb-clear-cache.md (100%) delete mode 100644 plan/plan.md diff --git a/plan/feature.md b/.claude/features-done/mongodb-clear-cache.md similarity index 100% rename from plan/feature.md rename to .claude/features-done/mongodb-clear-cache.md diff --git a/plan/plan.md b/plan/plan.md deleted file mode 100644 index 02013da..0000000 --- a/plan/plan.md +++ /dev/null @@ -1,6 +0,0 @@ -# Plan: mongodb-clear-cache - -## Steps -- [~] 1. Add `IManagedCacheMonitor` to MongoDB constructor and subscribe to `RequestEvictEvent` -- [ ] 2. Write test verifying eviction -- [ ] 3. Run full test suite, commit From 651e31974ee369db1ff00a2b325357825a5a33ca Mon Sep 17 00:00:00 2001 From: Daniel Bohlin Date: Mon, 6 Apr 2026 10:56:03 +0200 Subject: [PATCH 11/19] fix: set default ConfigurationName to "Default" in MongoDBCacheOptions ConfigurationName was null by default, which caused NullReferenceException in Tharga.MongoDB's CollectionFingerprint when the cache collection was accessed. Now defaults to "Default" to match Tharga.MongoDB's Constants.DefaultConfigurationName. --- Tharga.Cache.MongoDB/MongoDBCacheOptions.cs | 2 +- plan/feature.md | 17 +++++++++++++++++ plan/plan.md | 5 +++++ 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 plan/feature.md create mode 100644 plan/plan.md diff --git a/Tharga.Cache.MongoDB/MongoDBCacheOptions.cs b/Tharga.Cache.MongoDB/MongoDBCacheOptions.cs index e7bef0f..4b1dbc3 100644 --- a/Tharga.Cache.MongoDB/MongoDBCacheOptions.cs +++ b/Tharga.Cache.MongoDB/MongoDBCacheOptions.cs @@ -3,5 +3,5 @@ public record MongoDBCacheOptions { public string CollectionName { get; set; } = "_cache"; - public string ConfigurationName { get; set; } + public string ConfigurationName { get; set; } = "Default"; } \ No newline at end of file diff --git a/plan/feature.md b/plan/feature.md new file mode 100644 index 0000000..af638d7 --- /dev/null +++ b/plan/feature.md @@ -0,0 +1,17 @@ +# Feature: mongodb-default-config + +## Goal +Fix MongoDB setup when not using "default" config by giving `ConfigurationName` a sensible default. + +## Scope +- `MongoDBCacheOptions.ConfigurationName` — set default to `"Default"` + +## Acceptance Criteria +- `AddMongoDBOptions()` without explicit ConfigurationName uses `"Default"` +- Existing tests still pass + +## Done Condition +User confirms the fix is satisfactory. + +## Originating Branch +develop diff --git a/plan/plan.md b/plan/plan.md new file mode 100644 index 0000000..be124c1 --- /dev/null +++ b/plan/plan.md @@ -0,0 +1,5 @@ +# Plan: mongodb-default-config + +## Steps +- [~] 1. Set default value for `ConfigurationName` in `MongoDBCacheOptions` +- [ ] 2. Build and run tests, commit From 825330287e683b5d682445076a5ef9b6b9f6f463 Mon Sep 17 00:00:00 2001 From: Daniel Bohlin Date: Mon, 6 Apr 2026 11:01:54 +0200 Subject: [PATCH 12/19] feat: mongodb-default-config complete --- .../features-done/mongodb-default-config.md | 0 plan/plan.md | 5 ----- 2 files changed, 5 deletions(-) rename plan/feature.md => .claude/features-done/mongodb-default-config.md (100%) delete mode 100644 plan/plan.md diff --git a/plan/feature.md b/.claude/features-done/mongodb-default-config.md similarity index 100% rename from plan/feature.md rename to .claude/features-done/mongodb-default-config.md diff --git a/plan/plan.md b/plan/plan.md deleted file mode 100644 index be124c1..0000000 --- a/plan/plan.md +++ /dev/null @@ -1,5 +0,0 @@ -# Plan: mongodb-default-config - -## Steps -- [~] 1. Set default value for `ConfigurationName` in `MongoDBCacheOptions` -- [ ] 2. Build and run tests, commit From e7b8b593636d1d80ca2af597ffd9fda59ac6ad58 Mon Sep 17 00:00:00 2001 From: Daniel Bohlin Date: Mon, 6 Apr 2026 11:09:32 +0200 Subject: [PATCH 13/19] fix: track persisted cache items in monitor on first access Added Track method to CacheMonitor that registers items in the monitoring dictionary without firing DataSetEvent. CacheBase.GetCoreAsync now calls TrackIfNeeded when a fresh item is found from persistence, making items from MongoDB/Redis/File visible in the Blazor UI immediately on first access after app restart. --- Tharga.Cache.Tests/TrackPersistedTests.cs | 143 ++++++++++++++++++++++ Tharga.Cache/Core/CacheBase.cs | 8 ++ Tharga.Cache/Core/CacheMonitor.cs | 33 +++++ Tharga.Cache/Core/IManagedCacheMonitor.cs | 1 + plan/feature.md | 21 ++++ plan/plan.md | 7 ++ 6 files changed, 213 insertions(+) create mode 100644 Tharga.Cache.Tests/TrackPersistedTests.cs create mode 100644 plan/feature.md create mode 100644 plan/plan.md diff --git a/Tharga.Cache.Tests/TrackPersistedTests.cs b/Tharga.Cache.Tests/TrackPersistedTests.cs new file mode 100644 index 0000000..7d2c947 --- /dev/null +++ b/Tharga.Cache.Tests/TrackPersistedTests.cs @@ -0,0 +1,143 @@ +using FluentAssertions; +using Moq; +using Tharga.Cache.Core; +using Tharga.Cache.Persist; +using Xunit; + +namespace Tharga.Cache.Tests; + +public class TrackPersistedTests +{ + [Fact] + public async Task GetAsync_FreshItemInPersistence_AppearsInMonitor() + { + //Arrange + var options = new CacheOptions + { + Default = new CacheTypeOptions { DefaultFreshSpan = TimeSpan.FromSeconds(30) } + }; + options.RegisterType(s => s.DefaultFreshSpan = TimeSpan.FromSeconds(30)); + + var persistLoader = new Mock(MockBehavior.Strict); + var cacheMonitor = new CacheMonitor(persistLoader.Object, options); + var memory = new Memory(cacheMonitor); + persistLoader.Setup(x => x.GetPersist(It.IsAny())).Returns(memory); + var fetchQueue = new FetchQueue(cacheMonitor, options, null); + var cache = new TimeToLiveCache(cacheMonitor, persistLoader.Object, fetchQueue, options); + + // Pre-populate persistence (simulating data surviving a restart) + var item = CacheItemBuilder.BuildCacheItem(new Dictionary(), "pre-existing", TimeSpan.FromSeconds(30)); + var key = ((Key)"PrePopulated").SetTypeKey(); + await memory.SetAsync(key, item, false); + + // Verify monitor is empty before access + cacheMonitor.GetInfos().SelectMany(x => x.Items).Should().BeEmpty(); + + //Act + var result = await cache.GetAsync("PrePopulated", () => Task.FromResult("should-not-be-called")); + + //Assert + result.Should().Be("pre-existing"); + cacheMonitor.GetInfos().SelectMany(x => x.Items).Should().HaveCount(1); + cacheMonitor.GetInfos().SelectMany(x => x.Items).First().Value.Size.Should().BeGreaterThan(0); + } + + [Fact] + public async Task GetAsync_FreshItemInPersistence_DoesNotFireDataSetEvent() + { + //Arrange + var dataSetEventCount = 0; + var dataGetEventCount = 0; + + var options = new CacheOptions + { + Default = new CacheTypeOptions { DefaultFreshSpan = TimeSpan.FromSeconds(30) } + }; + options.RegisterType(s => s.DefaultFreshSpan = TimeSpan.FromSeconds(30)); + + var persistLoader = new Mock(MockBehavior.Strict); + var cacheMonitor = new CacheMonitor(persistLoader.Object, options); + var memory = new Memory(cacheMonitor); + persistLoader.Setup(x => x.GetPersist(It.IsAny())).Returns(memory); + var fetchQueue = new FetchQueue(cacheMonitor, options, null); + var cache = new TimeToLiveCache(cacheMonitor, persistLoader.Object, fetchQueue, options); + + cache.DataSetEvent += (_, _) => dataSetEventCount++; + cache.DataGetEvent += (_, _) => dataGetEventCount++; + + // Pre-populate persistence + var item = CacheItemBuilder.BuildCacheItem(new Dictionary(), "pre-existing", TimeSpan.FromSeconds(30)); + var key = ((Key)"PrePopulated").SetTypeKey(); + await memory.SetAsync(key, item, false); + + //Act + await cache.GetAsync("PrePopulated", () => Task.FromResult("should-not-be-called")); + + //Assert + dataSetEventCount.Should().Be(0, "Track should not fire DataSetEvent"); + dataGetEventCount.Should().Be(1); + } + + [Fact] + public async Task GetAsync_FreshItemInPersistence_TrackIsIdempotent() + { + //Arrange + var options = new CacheOptions + { + Default = new CacheTypeOptions { DefaultFreshSpan = TimeSpan.FromSeconds(30) } + }; + options.RegisterType(s => s.DefaultFreshSpan = TimeSpan.FromSeconds(30)); + + var persistLoader = new Mock(MockBehavior.Strict); + var cacheMonitor = new CacheMonitor(persistLoader.Object, options); + var memory = new Memory(cacheMonitor); + persistLoader.Setup(x => x.GetPersist(It.IsAny())).Returns(memory); + var fetchQueue = new FetchQueue(cacheMonitor, options, null); + var cache = new TimeToLiveCache(cacheMonitor, persistLoader.Object, fetchQueue, options); + + // Pre-populate persistence + var item = CacheItemBuilder.BuildCacheItem(new Dictionary(), "pre-existing", TimeSpan.FromSeconds(30)); + var key = ((Key)"PrePopulated").SetTypeKey(); + await memory.SetAsync(key, item, false); + + //Act — access twice + await cache.GetAsync("PrePopulated", () => Task.FromResult("fallback")); + await cache.GetAsync("PrePopulated", () => Task.FromResult("fallback")); + + //Assert — still only one item tracked + cacheMonitor.GetInfos().SelectMany(x => x.Items).Should().HaveCount(1); + } + + [Fact] + public async Task GetAsync_FreshItemInPersistence_MonitorSetEventNotFired() + { + //Arrange + var monitorSetEventCount = 0; + + var options = new CacheOptions + { + Default = new CacheTypeOptions { DefaultFreshSpan = TimeSpan.FromSeconds(30) } + }; + options.RegisterType(s => s.DefaultFreshSpan = TimeSpan.FromSeconds(30)); + + var persistLoader = new Mock(MockBehavior.Strict); + var cacheMonitor = new CacheMonitor(persistLoader.Object, options); + var memory = new Memory(cacheMonitor); + persistLoader.Setup(x => x.GetPersist(It.IsAny())).Returns(memory); + var fetchQueue = new FetchQueue(cacheMonitor, options, null); + var cache = new TimeToLiveCache(cacheMonitor, persistLoader.Object, fetchQueue, options); + + cacheMonitor.DataSetEvent += (_, _) => monitorSetEventCount++; + + // Pre-populate persistence + var item = CacheItemBuilder.BuildCacheItem(new Dictionary(), "pre-existing", TimeSpan.FromSeconds(30)); + var key = ((Key)"PrePopulated").SetTypeKey(); + await memory.SetAsync(key, item, false); + + //Act + await cache.GetAsync("PrePopulated", () => Task.FromResult("fallback")); + + //Assert + monitorSetEventCount.Should().Be(0, "Track should not fire monitor DataSetEvent"); + } +} diff --git a/Tharga.Cache/Core/CacheBase.cs b/Tharga.Cache/Core/CacheBase.cs index 188f921..b4e6633 100644 --- a/Tharga.Cache/Core/CacheBase.cs +++ b/Tharga.Cache/Core/CacheBase.cs @@ -40,6 +40,7 @@ public virtual async Task GetAsync(Key key, Func> fetch) if (result.IsValid()) { + TrackIfNeeded(key, result); await OnGetAsync(key); return (result.GetData(), true); } @@ -48,6 +49,7 @@ public virtual async Task GetAsync(Key key, Func> fetch) if (typeOptions.StaleWhileRevalidate && result != null) { + TrackIfNeeded(key, result); var response = result.GetData(); await OnGetAsync(key); BackgroundLoad(key, fetch, callback, fs); @@ -214,6 +216,12 @@ private void OnDrop(Key key) _cacheMonitor.Drop(typeof(T), key); } + private void TrackIfNeeded(Key key, CacheItem item) + { + var typeOptions = GetTypeOptions(); + _cacheMonitor.Track(typeof(T), key, item, typeOptions.StaleWhileRevalidate, typeOptions.ReturnDefaultOnFirstLoad); + } + private IPersist GetPersist() { var persist = _persistLoader.GetPersist(_options.Get().PersistType); diff --git a/Tharga.Cache/Core/CacheMonitor.cs b/Tharga.Cache/Core/CacheMonitor.cs index a9cbfc4..0e72d21 100644 --- a/Tharga.Cache/Core/CacheMonitor.cs +++ b/Tharga.Cache/Core/CacheMonitor.cs @@ -56,6 +56,39 @@ public void Set(Type type, Key key, CacheItem item, bool staleWhileRevalid DataSetEvent?.Invoke(this, new DataSetEventArgs(key, item.Data)); } + public void Track(Type type, Key key, CacheItem item, bool staleWhileRevalidate, bool returnDefaultOnFirstLoad) + { + if (_caches.ContainsKey(type) && _caches[type].Items.ContainsKey(key)) + return; + + var size = item.Data.ToSize(); + + _caches.AddOrUpdate(type, new CacheTypeInfo + { + Type = type, + StaleWhileRevalidate = staleWhileRevalidate, + ReturnDefaultOnFirstLoad = returnDefaultOnFirstLoad, + Items = new ConcurrentDictionary(new Dictionary + { + { + key, new CacheItemInfo(item.CreateTime) + { + Size = size, + FreshSpan = item.FreshSpan + } + } + }) + }, (_, b) => + { + b.Items.TryAdd(key, new CacheItemInfo(item.CreateTime) + { + Size = size, + FreshSpan = item.FreshSpan + }); + return b; + }); + } + public void Accessed(Type type, Key key, bool buyMoreTime) { if (_caches.TryGetValue(type, out var info)) diff --git a/Tharga.Cache/Core/IManagedCacheMonitor.cs b/Tharga.Cache/Core/IManagedCacheMonitor.cs index 93906c7..2d3f496 100644 --- a/Tharga.Cache/Core/IManagedCacheMonitor.cs +++ b/Tharga.Cache/Core/IManagedCacheMonitor.cs @@ -5,6 +5,7 @@ internal interface IManagedCacheMonitor : ICacheMonitor event EventHandler RequestEvictEvent; void Set(Type type, Key key, CacheItem item, bool staleWhileRevalidate, bool returnDefaultOnFirstLoad); + void Track(Type type, Key key, CacheItem item, bool staleWhileRevalidate, bool returnDefaultOnFirstLoad); void Accessed(Type type, Key key, bool buyMoreTime); void Drop(Type type, Key key); Key Get(EvictionPolicy evictionPolicy); diff --git a/plan/feature.md b/plan/feature.md new file mode 100644 index 0000000..0d3e7b9 --- /dev/null +++ b/plan/feature.md @@ -0,0 +1,21 @@ +# Feature: monitor-track-persisted + +## Goal +Make cache items loaded from persistence (MongoDB, Redis, File) visible in the Blazor monitoring UI on first access, not only after re-fetch. + +## Scope +- `IManagedCacheMonitor` — add `Track` method +- `CacheMonitor` — implement `Track` (register in `_caches` without firing `DataSetEvent`) +- `CacheBase.GetCoreAsync` — call `Track` when a fresh item is found from persistence but isn't yet in the monitor +- Tests verifying items are tracked on persistence hit + +## Acceptance Criteria +- Fresh items found in persistence appear in `ICacheMonitor.GetInfos()` after first access +- `DataSetEvent` is NOT fired for items loaded from persistence (only `DataGetEvent`) +- All existing tests still pass + +## Done Condition +User confirms the fix is satisfactory. + +## Originating Branch +develop diff --git a/plan/plan.md b/plan/plan.md new file mode 100644 index 0000000..ed2d866 --- /dev/null +++ b/plan/plan.md @@ -0,0 +1,7 @@ +# Plan: monitor-track-persisted + +## Steps +- [~] 1. Add `Track` to `IManagedCacheMonitor` and implement in `CacheMonitor` +- [ ] 2. Call `Track` from `CacheBase.GetCoreAsync` on fresh persistence hits +- [ ] 3. Write tests +- [ ] 4. Build, run full test suite, commit From 8bd844f758c46b359d365c2eb4c915b015f3c501 Mon Sep 17 00:00:00 2001 From: Daniel Bohlin Date: Mon, 6 Apr 2026 12:00:17 +0200 Subject: [PATCH 14/19] feat: monitor-track-persisted complete --- .../features-done/monitor-track-persisted.md | 0 .../Components/Pages/Weather.razor | 25 ++++++++++++------- Sample/Tharga.Cache.BlazorServer/Program.cs | 11 +++++++- .../Tharga.Cache.BlazorServer.csproj | 1 + .../appsettings.json | 3 +++ .../Tharga.Cache.WebApi.csproj | 2 +- .../Tharga.Cache.MongoDB.csproj | 2 +- Tharga.Cache.Redis/Tharga.Cache.Redis.csproj | 2 +- plan/plan.md | 7 ------ 9 files changed, 33 insertions(+), 20 deletions(-) rename plan/feature.md => .claude/features-done/monitor-track-persisted.md (100%) delete mode 100644 plan/plan.md diff --git a/plan/feature.md b/.claude/features-done/monitor-track-persisted.md similarity index 100% rename from plan/feature.md rename to .claude/features-done/monitor-track-persisted.md diff --git a/Sample/Tharga.Cache.BlazorServer/Components/Pages/Weather.razor b/Sample/Tharga.Cache.BlazorServer/Components/Pages/Weather.razor index 49a44dc..d086380 100644 --- a/Sample/Tharga.Cache.BlazorServer/Components/Pages/Weather.razor +++ b/Sample/Tharga.Cache.BlazorServer/Components/Pages/Weather.razor @@ -1,5 +1,6 @@ @page "/weather" @attribute [StreamRendering] +@inject ITimeToLiveCache TimeToLiveCache Weather @@ -7,7 +8,7 @@

This component demonstrates showing data.

-@if (forecasts == null) +@if (_forecasts == null) {

Loading...

} @@ -23,7 +24,7 @@ else - @foreach (var forecast in forecasts) + @foreach (var forecast in _forecasts) { @forecast.Date.ToShortDateString() @@ -37,7 +38,7 @@ else } @code { - private WeatherForecast[] forecasts; + private WeatherForecast[] _forecasts; protected override async Task OnInitializedAsync() { @@ -46,15 +47,21 @@ else var startDate = DateOnly.FromDateTime(DateTime.Now); var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; - forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast + + _forecasts = await TimeToLiveCache.GetAsync("Weather", () => { - Date = startDate.AddDays(index), - TemperatureC = Random.Shared.Next(-20, 55), - Summary = summaries[Random.Shared.Next(summaries.Length)] - }).ToArray(); + var items = Enumerable.Range(1, 5).Select(index => new WeatherForecast + { + Date = startDate.AddDays(index), + TemperatureC = Random.Shared.Next(-20, 55), + Summary = summaries[Random.Shared.Next(summaries.Length)] + }).ToArray(); + + return Task.FromResult(items); + }); } - private class WeatherForecast + public class WeatherForecast { public DateOnly Date { get; set; } public int TemperatureC { get; set; } diff --git a/Sample/Tharga.Cache.BlazorServer/Program.cs b/Sample/Tharga.Cache.BlazorServer/Program.cs index 3184a42..b480836 100644 --- a/Sample/Tharga.Cache.BlazorServer/Program.cs +++ b/Sample/Tharga.Cache.BlazorServer/Program.cs @@ -1,7 +1,10 @@ using Radzen; using Tharga.Cache; using Tharga.Cache.BlazorServer.Components; +using Tharga.Cache.BlazorServer.Components.Pages; +using Tharga.Cache.MongoDB; using Tharga.Cache.Persist; +using Tharga.MongoDB; var builder = WebApplication.CreateBuilder(args); @@ -10,13 +13,19 @@ .AddInteractiveServerComponents(); builder.Services.AddRadzenComponents(); - +builder.AddMongoDB(); builder.Services.AddCache(o => { o.RegisterType(s => { s.DefaultFreshSpan = TimeSpan.FromSeconds(3); }); + + o.RegisterType(s => + { + s.DefaultFreshSpan = TimeSpan.FromSeconds(60); + s.StaleWhileRevalidate = true; + }); }); var app = builder.Build(); diff --git a/Sample/Tharga.Cache.BlazorServer/Tharga.Cache.BlazorServer.csproj b/Sample/Tharga.Cache.BlazorServer/Tharga.Cache.BlazorServer.csproj index bae69be..d0c8acc 100644 --- a/Sample/Tharga.Cache.BlazorServer/Tharga.Cache.BlazorServer.csproj +++ b/Sample/Tharga.Cache.BlazorServer/Tharga.Cache.BlazorServer.csproj @@ -8,6 +8,7 @@ +
diff --git a/Sample/Tharga.Cache.BlazorServer/appsettings.json b/Sample/Tharga.Cache.BlazorServer/appsettings.json index 10f68b8..20380f8 100644 --- a/Sample/Tharga.Cache.BlazorServer/appsettings.json +++ b/Sample/Tharga.Cache.BlazorServer/appsettings.json @@ -5,5 +5,8 @@ "Microsoft.AspNetCore": "Warning" } }, + "ConnectionStrings": { + "Default": "mongodb://localhost:27017/Tharga_Cache_BlazorServer" + }, "AllowedHosts": "*" } diff --git a/Sample/Tharga.Cache.WebApi/Tharga.Cache.WebApi.csproj b/Sample/Tharga.Cache.WebApi/Tharga.Cache.WebApi.csproj index e0c1eec..6f389a9 100644 --- a/Sample/Tharga.Cache.WebApi/Tharga.Cache.WebApi.csproj +++ b/Sample/Tharga.Cache.WebApi/Tharga.Cache.WebApi.csproj @@ -7,7 +7,7 @@ - + diff --git a/Tharga.Cache.MongoDB/Tharga.Cache.MongoDB.csproj b/Tharga.Cache.MongoDB/Tharga.Cache.MongoDB.csproj index be5ae16..526efa5 100644 --- a/Tharga.Cache.MongoDB/Tharga.Cache.MongoDB.csproj +++ b/Tharga.Cache.MongoDB/Tharga.Cache.MongoDB.csproj @@ -39,7 +39,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Tharga.Cache.Redis/Tharga.Cache.Redis.csproj b/Tharga.Cache.Redis/Tharga.Cache.Redis.csproj index ad6a6ce..e7e3813 100644 --- a/Tharga.Cache.Redis/Tharga.Cache.Redis.csproj +++ b/Tharga.Cache.Redis/Tharga.Cache.Redis.csproj @@ -41,7 +41,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/plan/plan.md b/plan/plan.md deleted file mode 100644 index ed2d866..0000000 --- a/plan/plan.md +++ /dev/null @@ -1,7 +0,0 @@ -# Plan: monitor-track-persisted - -## Steps -- [~] 1. Add `Track` to `IManagedCacheMonitor` and implement in `CacheMonitor` -- [ ] 2. Call `Track` from `CacheBase.GetCoreAsync` on fresh persistence hits -- [ ] 3. Write tests -- [ ] 4. Build, run full test suite, commit From ff5df865b922b2997902279a7231a83d793929f9 Mon Sep 17 00:00:00 2001 From: Daniel Bohlin Date: Mon, 6 Apr 2026 12:19:21 +0200 Subject: [PATCH 15/19] feat: track and display fetch load duration Stopwatch wraps the fetch delegate in FetchQueue to measure load time. LoadDuration flows through CacheItem -> CacheMonitor -> CacheItemInfo. Blazor ListView shows a "Load Time" column. Items loaded from persistence (Track path) show null load duration. Removed TODO comment from CacheItemInfo. --- Tharga.Cache.Blazor/ListView.razor | 5 ++ Tharga.Cache.Tests/LoadDurationTests.cs | 106 +++++++++++++++++++++++ Tharga.Cache/CacheItem.cs | 1 + Tharga.Cache/CacheItemInfo.cs | 2 +- Tharga.Cache/Core/CacheMonitor.cs | 12 ++- Tharga.Cache/Core/FetchQueue.cs | 5 +- Tharga.Cache/Persist/CacheItemBuilder.cs | 4 +- plan/feature.md | 26 ++++++ plan/plan.md | 10 +++ 9 files changed, 163 insertions(+), 8 deletions(-) create mode 100644 Tharga.Cache.Tests/LoadDurationTests.cs create mode 100644 plan/feature.md create mode 100644 plan/plan.md diff --git a/Tharga.Cache.Blazor/ListView.razor b/Tharga.Cache.Blazor/ListView.razor index 65795cb..ded970c 100644 --- a/Tharga.Cache.Blazor/ListView.razor +++ b/Tharga.Cache.Blazor/ListView.razor @@ -48,6 +48,11 @@ else + + +