ITopicRepository.Refresh() merges topics changed since a given time into an in-memory topic graph, but it doesn't raise an event and simply returns a bare Task, so a consumer that polls it cannot determine which topics were changed. This proposes a dedicated TopicUpdated event—with a corresponding OnTopicUpdated() method and a simple TopicUpdateEventArgs—that fires for each topic whose that Refresh() updates with newer data. As an immediate consumer, this will also allow for the per-topic cache eviction called for as part of the Output Caching update (#150).
Background
Every change a repository observes falls into one of three independent categories, with this event filling the one gap in that model:
| Category | Trigger | Event(s) |
|---|
| Local write | Save(), Move(), Delete(), Rollback() | TopicSaved, TopicMoved, TopicDeleted |
| Remote update | Refresh() | TopicUpdated (proposed) |
| Materialization | Load, EnsureLoaded | TopicLoaded |
TopicLoaded cannot serve this role as it conflates cold materialization and lazy loading (as EnsureLoaded() raises it per child), neither of which is a change in data. The new event should therefore fire only if Refresh() merged newer data into a topic that was already in memory, not from any Load() overload, not from Rollback() (which persists via Save(), and is thus already covered by TopicSaved), and not from EnsureLoaded() (a fill based on LoadState, whereas Refresh() is a replacement of already loaded data).
Implementation Notes
Interface
- New
TopicUpdateEventArgs : TopicEventArgs, mirroring TopicLoadEventArgs, only including the base Topic and passing isRecursive: false (Since and the data modified will be deferred until they're needed) - New
TopicUpdated event, backing field, and protected OnTopicUpdated() on ObservableTopicRepository, declared on ITopicRepository TopicRepositoryDecorator should re-subscribe and re-raise so a subscriber on CachedTopicRepository receives events raised by the inner SqlTopicRepository; CachedTopicRepository needs no override because Refresh() mutates its cached instances by reference
The event should fire once per topic (matching TopicSaved and TopicLoaded), not as a single batch: The same evict($"topic:{args.Topic.Id}") handler from #150 will then serve both local and poll-detected edits. Only genuinely updated (pre-existing) topics are reported; new topics pulled by the same batch evict nothing and handled by the coarse cache invalidation.
Collecting the change set
The change set must be the union of the first five result sets of GetTopicUpdates—core Topics (keyed TopicID), indexed Attributes, ExtendedAttributes, Relationships (keyed Source_TopicID), and TopicReferences (keyed Source_TopicID)—intersected with the topics in-memory prior to the merge. Result set 1 covers only core-field changes, so collecting from it alone would miss every attribute-only and association-only update, leaving those topics stale.
Collection should live in LoadTopicGraph(), via an optional output collection that Refresh() supplies:
internalstaticasyncTask<Topic?>LoadTopicGraph(thisDbDataReaderreader,intseedTopicId=-1,Topic?referenceTopic=null,bool?markDirty=null,ICollection<Topic>?updatedTopics=null,CancellationTokencancellationToken=default){IDs should accumulate into a HashSet<int> (as a topic can appear in multiple result sets), allocated only when updatedTopics is supplied, so every non-Refresh() callers can just use null-conditional guards. After all result sets are read, the union is filtered by preExistingIds (the updated-only filter) and materialized via the live index. SqlTopicRepository.Refresh() should pass a fresh List<Topic> and raise OnTopicUpdated() for each entry after the existing try/catch, so a SqlException fires nothing.
Structural limitation
Refresh() cannot (currently) notify about remote deletes or reorders: A deleted topic is simply absent from the batch, and HasChildren is NULL for every row. This event therefore covers value-updates to in-memory topics only. Downstream cross-server invalidation (the output-caching stage in #150) must therefore retain a periodic coarse sweep for structural changes.
Tasks
ITopicRepository.Refresh()merges topics changed since a given time into an in-memory topic graph, but it doesn't raise an event and simply returns a bareTask, so a consumer that polls it cannot determine which topics were changed. This proposes a dedicatedTopicUpdatedevent—with a correspondingOnTopicUpdated()method and a simpleTopicUpdateEventArgs—that fires for each topic whose thatRefresh()updates with newer data. As an immediate consumer, this will also allow for the per-topic cache eviction called for as part of the Output Caching update (#150).Background
Every change a repository observes falls into one of three independent categories, with this event filling the one gap in that model:
Save(),Move(),Delete(),Rollback()TopicSaved,TopicMoved,TopicDeletedRefresh()TopicUpdated(proposed)Load,EnsureLoadedTopicLoadedTopicLoadedcannot serve this role as it conflates cold materialization and lazy loading (asEnsureLoaded()raises it per child), neither of which is a change in data. The new event should therefore fire only ifRefresh()merged newer data into a topic that was already in memory, not from anyLoad()overload, not fromRollback()(which persists viaSave(), and is thus already covered byTopicSaved), and not fromEnsureLoaded()(a fill based onLoadState, whereasRefresh()is a replacement of already loaded data).Implementation Notes
Interface
TopicUpdateEventArgs : TopicEventArgs, mirroringTopicLoadEventArgs, only including the baseTopicand passingisRecursive: false(Sinceand the data modified will be deferred until they're needed)TopicUpdatedevent, backing field, and protectedOnTopicUpdated()onObservableTopicRepository, declared onITopicRepositoryTopicRepositoryDecoratorshould re-subscribe and re-raise so a subscriber onCachedTopicRepositoryreceives events raised by the innerSqlTopicRepository;CachedTopicRepositoryneeds no override becauseRefresh()mutates its cached instances by referenceThe event should fire once per topic (matching
TopicSavedandTopicLoaded), not as a single batch: The sameevict($"topic:{args.Topic.Id}")handler from #150 will then serve both local and poll-detected edits. Only genuinely updated (pre-existing) topics are reported; new topics pulled by the same batch evict nothing and handled by the coarse cache invalidation.Collecting the change set
The change set must be the union of the first five result sets of
GetTopicUpdates—coreTopics(keyedTopicID), indexedAttributes,ExtendedAttributes,Relationships(keyedSource_TopicID), andTopicReferences(keyedSource_TopicID)—intersected with the topics in-memory prior to the merge. Result set 1 covers only core-field changes, so collecting from it alone would miss every attribute-only and association-only update, leaving those topics stale.Collection should live in
LoadTopicGraph(), via an optional output collection thatRefresh()supplies:IDs should accumulate into a
HashSet<int>(as a topic can appear in multiple result sets), allocated only whenupdatedTopicsis supplied, so every non-Refresh()callers can just use null-conditional guards. After all result sets are read, the union is filtered bypreExistingIds(the updated-only filter) and materialized via the live index.SqlTopicRepository.Refresh()should pass a freshList<Topic>and raiseOnTopicUpdated()for each entry after the existingtry/catch, so aSqlExceptionfires nothing.Structural limitation
Refresh()cannot (currently) notify about remote deletes or reorders: A deleted topic is simply absent from the batch, andHasChildrenisNULLfor every row. This event therefore covers value-updates to in-memory topics only. Downstream cross-server invalidation (the output-caching stage in #150) must therefore retain a periodic coarse sweep for structural changes.Tasks
TopicUpdateEventArgs : TopicEventArgswith aTopicUpdateEventArgs(Topic topic)constructorTopicUpdatedevent, backing field, andOnTopicUpdated()toObservableTopicRepository, and declareTopicUpdatedonITopicRepositoryTopicUpdatedevent toTopicRepositoryDecoratorupdatedTopicsoutput collection toLoadTopicGraph(), populated from the union of content result sets, filtered bypreExistingIdsOnTopicUpdated()inSqlTopicRepository.Refresh()after a successful mergeLoadTopicGraph()merge:EnsureLoaded()orRollback()TopicRepositoryDecorator