You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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 topic X counting hops to R. Applies to every event.
Subtree Root:X is an ancestor of R, tested by walking up from R (bounded by R's depth). Applies to rename, delete, recursive save, and real move, where the changed topic's derived WebPath propagates 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) ≤ tiers via TopicMoveEventArgs.Source, catching navigation that rendered X under its old parent. Applies only to a real move (Source != Target).
Move old slot:distance(Source, R) ≤ tiers via TopicMoveEventArgs.Source. A move repoints X.Parent to its new parent before the event fires, so the within-region walk from X finds only its new home; the home X left is reachable only through Source. Applies only to a real move (Source != Target).
Event
Checks
TopicUpdated, TopicSaved (non-recursive), TopicLoaded (Version is null)
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.
OnTopic/Mapping/CachedTopicMappingService: Convert the primary constructor, subscription, full-clear handlers
AddTopicNavigation<TViewModel>() (Dependency Injection migration #137) and the SampleActivator initialization sites in OnTopic.AspNetCore.Mvc.Host and OnTopic.AspNetCore.Mvc.IntegrationTests.Host need to pass the repository
OnTopic.Tests: New eviction tests
Tasks
CachedHierarchicalTopicMappingService
Rekey the nav _cache to (int Id, int Tiers); update the GetRootViewModelAsync() lookup and insert sites
Add a unit test asserting the same root cached at two different tiers yields independently scoped graphs
Inject ITopicRepository into CachedHierarchicalTopicMappingService<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 ITopicRepository into CachedTopicMappingService
Perform full-clear on every subscribed event
Add tests asserting that each event clears a populated _cache but a versioned TopicLoaded does not
Depends on #151, #152; wired most cleanly after #137. Sibling to #150.
Both cached mapping services hold a
ConcurrentDictionarykeyed by the rootTopic.Idwith 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 theITopicRepositoryevents 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 lateralRelationshipsandReferencesthat 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
IOutputCacheStoretag mechanism. They can, however, subscribe to the sameITopicRepositoryevents the output-cache invalidation consumes: Local writes, plus the poll-based changes exposed by the newTopicUpdatedevent (#151) and the structural events raised fromRefresh()(#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
Idis not the key. Evictingtopic:{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
RrendersRplustierslevels of descendants. The cache is currently keyed by the rootIdalone, 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 leveltiers. 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:distance(X, R) ≤ tiers, an upward walk from the changed topicXcounting hops toR. Applies to every event.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.distance(Source, R) ≤ tiersviaTopicMoveEventArgs.Source, catching navigation that renderedXunder its old parent. Applies only to a real move (Source != Target).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).TopicUpdated,TopicSaved(non-recursive),TopicLoaded(Version is null)TopicRenamed,TopicDeleted,TopicSaved(recursive)TopicMoved(Source != Target)TopicMoved(Source == Target, reorder)TopicLoadedsignifies a new topic and should be honored so a new page appears in the navigation, guarded byVersion is nullto 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-Idapproach would correctly evict parents or children, but silently miss associations. The existing(Id, Type?, AssociationTypes)key is untouched. The sameVersion is nullguard applies toTopicLoaded.Wiring
Each cached service should accept an
ITopicRepositoryvia 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 testsTasks
CachedHierarchicalTopicMappingService_cacheto(int Id, int Tiers); update theGetRootViewModelAsync()lookup and insert sitestiersyields independently scoped graphsITopicRepositoryintoCachedHierarchicalTopicMappingService<T>DistanceToRoot()and ancestor-of-root helpers, updating construction sitesCachedTopicMappingServiceITopicRepositoryintoCachedTopicMappingService_cachebut a versionedTopicLoadeddoes notDepends on #151, #152; wired most cleanly after #137. Sibling to #150.