Repository navigation
CachedTopicMappingService: Cache Invalidation #153
Description
Both cached mapping services hold a ConcurrentDictionary keyed by the root Topic.Id with no eviction, so a mapped topic graph remains stale from the time a topic inside of it changes until the process recycles. This proposes wiring both services to the ITopicRepository events so that cached view models are evicted when the topics they were built from change. CachedHierarchicalTopicMappingService<T> (navigation) should evict by intersecting each modified topic against each cached entry's region; CachedTopicMappingService (flat) should do a full-clear, since its graph can follow lateral Relationships and References that would be missed if we were just targeting Topic ID.
Background
These two caches sit below the MVC output cache (#150), so they cannot take advantage of its IOutputCacheStore tag mechanism. They can, however, subscribe to the same ITopicRepository events the output-cache invalidation consumes: Local writes, plus the poll-based changes exposed by the new TopicUpdated event (#151) and the structural events raised from Refresh() (#152). This is the mapping-layer follow-up those plans deferred.
The mapping challenge is that the caches are keyed by the root topic while the events report the changed topic, and the changed topic's Id is not the key. Evicting topic:{changedId} (the output cache's approach) finds nothing here unless the changed topic happens to be a root.
Implementation Notes
Navigation cache (CachedHierarchicalTopicMappingService<T>)
A cached entry rooted at R renders R plus tiers levels of descendants. The cache is currently keyed by the root Id alone, presuming only one depth is needed per root, and thus serves the initially cached graph to a later caller, even if they ask for a different level tiers. To resolve this, the key should become (int Id, int Tiers), which fixes that latent bug and provides the depth information for the eviction process.
An entry (R, tiers) should be evicted when the edited topic intersects its rendered region, tested with up to three checks:
- Within Region:
distance(X, R) ≤ tiers, an upward walk from the changed topicXcounting hops toR. Applies to every event. - Subtree Root:
Xis an ancestor ofR, tested by walking up fromR(bounded byR's depth). Applies to rename, delete, recursive save, and real move, where the changed topic's derivedWebPathpropagates to every descendant, as well as a nav rooted belowX, which would be missed by the upward walk alone. - Move Old Slot:
distance(Source, R) ≤ tiersviaTopicMoveEventArgs.Source, catching navigation that renderedXunder its old parent. Applies only to a real move (Source != Target). - Move old slot:
distance(Source, R) ≤ tiersviaTopicMoveEventArgs.Source. A move repointsX.Parentto its new parent before the event fires, so the within-region walk fromXfinds only its new home; the homeXleft is reachable only throughSource. Applies only to a real move (Source != Target).
| Event | Checks |
|---|---|
TopicUpdated, TopicSaved (non-recursive), TopicLoaded (Version is null) |
Within region |
TopicRenamed, TopicDeleted, TopicSaved (recursive) |
Within region, subtree root |
TopicMoved (Source != Target) |
Within region, subtree root, move old slot |
TopicMoved (Source == Target, reorder) |
Within region |
TopicLoaded signifies a new topic and should be honored so a new page appears in the navigation, guarded by Version is null to skip detached version-preview loads. No handler should clear the whole cache.
Flat cache (CachedTopicMappingService)
Because a mapped graph can traverse arbitrary (lateral) associations, every subscribed event should call _cache.Clear(). A per-Id approach would correctly evict parents or children, but silently miss associations. The existing (Id, Type?, AssociationTypes) key is untouched. The same Version is null guard applies to TopicLoaded.
Wiring
Each cached service should accept an ITopicRepository via constructor injection so that it can subscribe to the relevant events. The inner mapping service owns no cache and has no callback to bubble staleness up, so the decorator must subscribe itself. This is safe because both services are singletons: The repository's event holds the subscriber for the app's lifetime, so there's no need to unsubscribe.
Affected Files
OnTopic/Mapping/Hierarchical/CachedHierarchicalTopicMappingService<T>: Rekey, subscription, eviction handlersOnTopic/Mapping/CachedTopicMappingService: Convert the primary constructor, subscription, full-clear handlersAddTopicNavigation<TViewModel>()(Dependency Injection migration #137) and theSampleActivatorinitialization sites inOnTopic.AspNetCore.Mvc.HostandOnTopic.AspNetCore.Mvc.IntegrationTests.Hostneed to pass the repositoryOnTopic.Tests: New eviction tests
Tasks
CachedHierarchicalTopicMappingService
- Rekey the nav
_cacheto(int Id, int Tiers); update theGetRootViewModelAsync()lookup and insert sites - Add a unit test asserting the same root cached at two different
tiersyields independently scoped graphs - Inject
ITopicRepositoryintoCachedHierarchicalTopicMappingService<T> - Add the
DistanceToRoot()and ancestor-of-root helpers, updating construction sites - Subscribe the per-event eviction handlers
- Add nav eviction tests covering:
- In-range
- Out-of-depth
- Other-branch
- Subtree-root
- Move old-slot
- Recursive save
- New topic
- Rebuild
CachedTopicMappingService
- Inject
ITopicRepositoryintoCachedTopicMappingService - Perform full-clear on every subscribed event
- Add tests asserting that each event clears a populated
_cachebut a versionedTopicLoadeddoes not
Depends on #151, #152; wired most cleanly after #137. Sibling to #150.
Activity
Refresh()#152Refresh()via a newTopicUpdatedevent #151