Skip to content

CachedTopicMappingService: Cache Invalidation #153

Description

@JeremyCaney

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 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).
EventChecks
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 handlers
  • 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: MappingRelates to one of the `ITopicMappingService` interfaces or implementations.Priority: 2Severity 1: MinorStatus 2: ScheduledPlanned for an upcoming release.Type: ImprovementImproves the functionality or interface of an existing feature.

    Type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions