Skip to content

Lazy Loading Topic Repository - #145

Merged
JeremyCaney merged 343 commits into
developfrom
feature/ITopicRepository-lazy-loading
Aug 7, 2026
Merged

Lazy Loading Topic Repository#145
JeremyCaney merged 343 commits into
developfrom
feature/ITopicRepository-lazy-loading

Conversation

@JeremyCaney

Copy link
Copy Markdown
Member

This implements the lazy-loading project (#111), so that ITopicRepository.Load() returns a sparsely populated topic graph by default and fills in extended attributes, associations, children, and version history on demand, instead of eagerly loading the entire tree top-to-bottom.

It also folds in the related tasks that grew out of that work:

This is a large, deeply interdependent change targeting OnTopic 6.0.0, and will be the primary feature shaping that major update.

Because the effort spanned several sub-branches, the merged tasks (#116#120) are summarized and cross-referenced to their corresponding issue and merge commit rather than re-enumerated their individual commits; see the linked issues for their internal breakdown.

Breaking Changes

Major

  • Asynchronous repository contract (a3b7360, caab55a, ec9f740, 74b5e42, a2d63a7): Load(), EnsureLoaded(), Save(), Move(), Delete(), Rollback(), and Refresh() now return Task
    • xUnit test suite moved to async tests to match (eb5c6a3)
  • Sparse graph by default (cf92ed3): Load() now returns just the root topic, with everything else resolved on demand, rather than eagerly loading the whole tree
  • Load() signature (5852408, cac32ba): A TopicPayload flags argument selects which content to load, and an int depth replaces the isRecursive boolean
  • Load(topicId, version) is now detached (142b966, f22adb7): The version overload no longer merges into a live graph—that responsibility moved into Rollback()—and the now-unused referenceTopic parameter was removed
    • Also dropped the redundant includeExternalReferences parameter (10c5144)
  • GetTopics stored procedure parameters (8c2c81d): Conditional-load @Include… flags replace the old @DeepLoad, and a @Depth parameter replaces @LoadChildren/@LoadDescendants
  • OnTopic.Querying limitations (4d83e0e, 92a9995): FindFirst() and FindAll() only traverse topics already resident in memory, returning incomplete results on a sparse graph
  • StubTopicRepository ID offset (4f11cf8, 6ae375e): Seeded IDs and keys now start at 1 rather than 0 to avoid e.g., 00, 000, and 00000 all collapsing to 0

Minor

  • TopicRelationshipMultiMap constructor made internal (50f4376): This is Topic-specific infrastructure, so external callers should use the underlying TopicMultiMap
  • Deleted obsolete members (50f4376, 2fe5427):
    • The SetTopic()/RemoveTopic() overloads that pointed at already-removed SetValue()/Remove() overloads
    • Unused TopicIsDirty()/MarkClean() overloads not required by ITrackDirtyKeys
    • The TopicCollectionExtensions.AnyDirty() extension
  • Removed the extended sitemap (c199203): The Extended action and its includeMetadata plumbing are gone
    • Google's Custom Search Engine (its intended consumer) was retired
    • See Sitemap

Lazy Loading

Features

Extended Attributes

Extended attributes resolve on demand the first time an unindexed attribute is requested.

  • GetValue() trigger (9e712ff): When AttributeCollection.LoadState is NotLoaded, a miss triggers EnsureLoaded() for the extended attributes
  • HasExtendedAttributes (9475ec3, 5adf48c, aa19e64): Returned by the GetTopics stored procedure so LoadState can be set
    • Loaded when there are none
    • NotLoaded otherwise
  • autoLoad bypass (aecee85, 42495b9): A new GetValue(…, autoLoad) overload lets indexed reads skip the lazy load
    • GetBoolean(), GetInteger(), GetDouble(), GetDateTime(), and GetUri(), plus Title and View, presume their attributes are indexed
    • Trades a small risk of false negatives (e.g., a boolean stored as an extended attribute) to avoid recursive chain loads of Parent and/or BaseTopic
  • Enumeration note (a709500): Documented that enumerating Topic.Attributes doesn't trigger a load, unlike the other lazy collections

Associations

Relationships and references are always returned cheaply (as key and topicId pairs) with Load(), now held internally on a Deferred collection when their target isn't yet in the graph, and connected to real topics on demand.

  • DeferredAssociation record (ba72f23) and Deferred collections (f210866) on TopicRelationshipMultiMap and TopicReferenceCollection
  • Deferred bookkeeping is automatic:
    • Targets are always returned with Load() (3c27813)
    • Added to Deferred if unavailable in the topic graph (fce368b)
    • Removed from Deferred once the association is resolved (520ce3c)
    • LoadState derives from the Deferred count rather than manual tracking
      • This supersedes the retired IsFullyLoaded bit (c65307f)
  • On-demand resolution (257545e): EnsureLoaded(Relationships|References) loads the Deferred targets
    • Triggered by access to Topic.Relationships or Topic.References (f7a86d8)

Deferred Targets

  • ResolveAssociations(): Connects deferred targets to any in-memory topics
    • The fallBackToLoad flag decides whether an unresolved target is loaded via Load() or left deferred
    • See LazyLoadingTopicRepository below
  • Ancestor loading (c33f4a4, 1968876, 2e4b125): A non-root topic loads its full ancestor chain so it can be grounded in the graph
    • Ancestors are "stamped" with a ILazyLoader via StampAscendants()

Relationships & References

  • SqlTopicRepository (dc975bf): EnsureLoaded() only requests associations when loading new children, since they always accompany Load()ed topics
  • Relations exception (01b04d2, 5ca272d): The LoadState of Relationships and References isn't set directly via EnsureLoaded()
    • LoadState is computed from the Deferred count; NotLoaded while any target remains deferred, Loaded once all are resolved
  • BaseTopic is always lazy loaded on reference to ensure integrity of attribute inheritance (951eba0)

Children

Accessing an unloaded Topic.Children collection loads its immediate children on demand.

  • Backing field (39bf06d): _children lets internal callers (e.g., SetParent()) reach the collection without tripping a load, even when it is NotLoaded
  • Access trigger (168ba8e): Accessing Topic.Children while NotLoaded invokes the loader's EnsureLoaded()
  • SQL support (74db740, 2637714): SqlTopicRepository fills children by via the GetTopics stored procedure (via the new @Depth parameter)
  • AddChildTopic() (0224cea) and FillChildren() (f7fc266, f4a3e53): Internal methods for processing children during EnsureLoaded()
  • Recursion gates: Recursive Save() (110584b) and FindFirst()/FindAll() (4d83e0e, 92a9995, a304e90) check LoadState before recursing, so never trigger cascading lazy loading

Version History

  • VersionHistoryCollection (7251476, 609b5aa): A dedicated backing collection with LoadState
    • Integrated with ITopicBackingAccessor (10959ed)
    • Added to TopicPayload (8fa4a93)
  • State tracking (d9723f0): IsLoaded() and SetLoadState() cover version history
    • SetVersionHistory() (155a605) marks it Loaded
  • On-demand loading (09f4d0b): EnsureLoaded() (b28a31a) and Load() (dea4bc9) conditionally set the GetTopics stored procedure's @IncludeHistory parameter

Implementation

Topic

  • IsLoaded(payload) (e5ddc26) and IsLoaded(payload, depth) (b339d61): Check whether the requested payload is already loaded
  • SetLoadState(payload, state) (03a1e24, 5fc6da9): Sets the load state for each site named in a payload, so callers don't need to know where each lives or risk tripping a load
  • FilterPayload(payload) (3e3d538, 3ea5d45, 31b986a): Strips already-loaded flags from a payload
  • rawTopic convention (2c4e23d): Lazy-loaded properties are reached through ITopicBackingAccessor (see below) internally to avoid re-tripping the contract they fulfill

Interfaces

  • ITopicBackingAccessor (55241c6, a9035bb, 951eba0): Direct access to the backing fields of lazy-loaded properties on Topic without triggering a load
    • Covers Children, associations (Relationships, References) (e1b28c7), (extended) Attributes (d081413), and VersionHistory
  • ITopicLazyLoadable (1007d6a, 248411e, e66cea7): The lazy-loading surface on Topic, exposed as explicit default interface members so it's hidden from adopters but available to infrastructure
    • Exposes Loader (819e373), EnsureLoaded() (9df2db7), IsLoaded()/IsLoaded(depth), and SetLoadState()
  • ITopicLazyLoader (59fa20d, a159b61, 9c88454): A narrow, per-repository loader exposing only EnsureLoaded(), modeled on EF Core's ILazyLoader
    • Core logic implemented on every ITopicRepository (e25f843)
    • Accessed via ITopicLazyLoadable on Topic via EnsureLoaded() (9df2db7) and a Loader property
      • Loader is written to by StampLoader() (819e373) and, for Children, EnsureLoaded() (5b293fd)

Enums

  • LoadState (94c8666, 486f963, a013fe0): Replaces the IsFullyLoaded bit,
    • Applied to TopicReferenceCollection, TopicRelationshipMultiMap, AttributeCollection, and KeyedTopicCollection<T>
  • TopicPayload (a0071d6, 07a01cf, f242fcb, c7b356a): A flags enum naming data what to load per topic
    • Covers Children, Relationships, References, ExtendedAttributes, and VersionHistory

Stored Procedures

Repositories

LazyLoadingTopicRepository

A new base sitting between ObservableTopicRepository and both TopicRepository and TopicRepositoryDecorator, centralizing lazy-loading logic (f93a982).

  • StampLoader() (2a1ce8e, 6cd4e80, 70005a1, cd21597): Associates a Topic with the ITopicLazyLoader that most recently loaded it, so it can resolve missing content on demand
    • Driven internally by the TopicLoaded and TopicSaved events, so consumers never invoke it directly
  • StampAscendants() (29969d1): Stamps a loaded topic's ancestors so they, too, lazy-load
  • LoadDeferredAssociations(): Resolves a topic's deferred associations, implemented on both this base (b787626, 1935995) and SqlTopicRepository (cf376a7)
  • ResolveAssociations() (167067f): Connects resident associations without loading missing ones
  • TopicLoaded for children (4caa4b4): EnsureLoaded(Children) now fires TopicLoaded, matching Load() and association loads
  • Concurrency (362b9fb): Per-topic SemaphoreSlim gates via WithLoadGate() (9351513) in Load(topicId), Load(uniqueKey), and EnsureLoaded() prevent concurrent lazy loads of the same topic from racing

SqlTopicRepository

  • EnsureLoaded() (de60afb): Fills the requested payload from the GetTopics stored procedures, wiring the enum to the parameters via AddEnsureLoadedParameters()
    • Loads extended attributes before a save so the XML blob isn't truncated (d9ce6e5)
    • Preserves dirty extended attributes rather than overwriting them with stale data (afa2512, 9e27887, 3e84eab)
  • Always-load ancestors for non-root loads, returning the requested seed topic rather than the root
    • Distinguishes the requested topic (seedTopicId) to conditionally set Children.LoadState for ancestors (5f92c5f, 5b4bf6b, 1b602d0)
      • Ancestors always have one child loaded, but they may have additional children
    • Ensures the seed topic is returned, and not the root topic (c14bff7, bdeac02)
    • See Deferred Targets
  • LoadDeferredAssociations(): Resolves a topic's deferred associations (cf376a7)
  • ConvergeLoadState() (47769ad, b5da944): Reconciles a collection's LoadState when a subsequent Load() merges a different TopicPayload into an existing topic
  • Clear associations after a successful read rather than before opening the connection (5cdf644)
  • Delete() lazy awareness (665e7f5): Check for children via LoadState and ITopicBackingAccessor to prevent lazy-loading children when validating the isRecursive
  • AddTopic() root lookup (fa2dd93): Returns the topic directly instead of re-looking it up by ID

CachedTopicRepository

  • Local index (22161b1): Key-based index replace a full-graph crawl per lookup
  • Active loading of misses: An ID or key not in the cache is fetched from the underlying repository rather treated as a miss
    • Fetched with the caller's exact arguments (599c5be, a54bebd, 1853602)
    • An index of missing keys guard against repeated data store queries (06cae07)
    • uniqueKey normalized against the cached root (a31a054)
  • On-demand fill: EnsureLoaded() delegates to the inner loader (3cc30d4) and stamps any loaded children (78b314b)
    • A private depth-aware EnsureLoaded() overload re-runs and merges a query when a cached topic doesn't satisfy the requested payload (012c37c)
  • Cache seeding: The cache seeds a bare root (dd0c251) plus its immediate children (1c84037), with Root:Configuration eager-loaded since it's commonly referenced and richly associated
    • Accompanied by cleanup of CachedTopicRepository (4c14f31), including removal of its _topicRepository initialization (2804a55), with the seed's unit test updated to match (8d0af7d)
  • MergeIntoCache() (599c5be, fa416f3): Removed as redundant, once LoadTopicGraph() and ConvergeLoadState() came to merge divergent payloads into the referenceTopic directly

Collections

  • ChildTopicCollection: A dedicated collection type for Topic.Children
    • Relocated LoadState here from the shared KeyedTopicCollection<T> base, since it's uniquely relevant to children
      • KeyedTopicCollection<T> is also used by ContentTypeDescriptorCollection and AttributeDescriptorCollection, which don't us this
    • Includes the TopicIndexRegistry hooks for maintaining the index when children are attached or detached
  • TopicRelationshipMultiMap / TopicReferenceCollection: Gained the Deferred collections and LoadState
    • Source tightened to private protected to preserve the read-only promise (482a4ff)
    • isIncoming now comes exclusively from the constructor rather than per-call parameters
    • TopicRelationshipMultiMap.Clear() (2589d79): Removes an entire relationship set at once, honoring dirty state and reciprocal IncomingRelationships removal (see Bug Fixes)
  • DeferredAssociationCollection (687d1ef, f2add3a): The Deferred property type, with deduplicating SetValue() and Remove()
    • Its DeferredAssociation is dirty-aware for rollback (see Version Management)
    • ReplaceAll() swaps the full set for Rollback() (41be0d8)
  • TopicIndexRegistry: A ConditionalWeakTable of one live TopicIndex per graph, keyed by the root topic instance
    • Maintained as topics attach, detach, or save, replacing per-call GetTopicIndex() recomputation with GetLiveTopicIndex()
    • TopicIndex converted to ConcurrentDictionary and filtering out IsNew topics (4037de9)
    • See Create static TopicIndex cache #116 (cb8e2ca)
  • VersionHistoryCollection: See Version History

Mapping (#118)

Lazy loading turned mapping into a potentially I/O-bound, concurrent operation, surfacing defects that never occurred with an eager-loaded tree. This work was developed on its own branch and is summarized here.

  • AssociationMap.PayloadMappings (5253452, e7d8ec4): Maps the CollectionType enum to TopicPayload so the mapper fires EnsureLoaded() as early in the chain as possible, the practical hook through which most lazy loading is triggered
  • TopicMappingService: Closed lazy-loading defects that:
    • Forced eager loads
    • Returned incomplete data
    • Allowed duplicate property mapping
    • Allowed unsafe concurrent mapping of the same topic and view-model pair
  • ReverseTopicMappingService:
    • Fixed Relationships.Clear() to purge Deferred
    • Serialized collection population
    • Warmed target extended attributes and children
  • HierarchicalTopicMappingService:
    • Preloads a whole tier via Load(…, depth) in one round-trip instead of an EnsureLoaded() per unloaded Children
    • Dropped a task queue that interfered with proper ordering
  • See PR Topic Mapping: Addressed concurrency issues introduced by Lazy-Loading #144 (3acee3b) for the full breakdown

Depth-limited loading (#120)

  • Load() now accepts an int depth (replacing isRecursive)
  • Threaded through TopicLoadEventArgs, IsLoaded(), and every stub
  • Backed by the GetTopics stored procedure's @Depth parameter (replacing @LoadChildren/@LoadDescendants) and a new ParentID index
  • Its primary payoff is letting HierarchicalTopicMappingService warm a full tier in one round-trip
  • See Allow specifying depth of ITopicRepository.Load() #120 (c1e80a5, 9351513)

Testing

New infrastructure for genuinely exercising partial and lazy loads, where prior tests only covered eager graphs.

  • StubLazyLoadingTopicRepository (aa2c476): Actual partial- and lazy-loading test double
    • StubLazyLoadingTopicRepositoryBuilder (198ae09): Fluent builder that materializes TopicRecord fixtures into real topics
      • TopicRecord (864d712): Barebones topic metadata for configuring the builder to load new topics
    • Convergence (b511349) and depth-aware fill (e0959e8) added to match the live merge behavior
  • FakeSqlTopicRepository (de1784e, 14eae80): Mimics SqlTopicRepository via LoadTopicGraph(), returning fresh instances to test cross-instance merges
  • LazyLoadingTopicRepositoryTest (db8fbe8): Tests specific to the new base
    • Duplicated TopicRepositoryBaseTest cases were removed (fa60597) and the remainder migrated and improved (3338fed)
  • ITopicLazyLoadableTest (8893273, d578cbf): Hosts the IsLoaded(), SetLoadState(), and EnsureLoaded() tests
  • SqlTopicRepositoryTest: Coverage for the SQL load path
    • HasExtendedAttributes schema coverage (5e4f923), with LoadTopicGraph() extended/blob load-state tests (421c7e0)
    • HasChildren column added to TopicsDataTable (78602c2), with basic (7da41f5) and load-state (d347e3c) tests
    • Stored-procedure parameter coverage in StoredProcedures.resx (5e93790)
    • LoadTopicGraph() and ConvergeLoadState() tests (86aaf98)
    • Refresh()-path tests (3ff79e3)
  • Additional coverage across the load contract:
    • Load() overloads (a05ff70) and Load(TopicPayload.Children) (f247c23)
    • Extended-attribute autoload bypass (f94932d)
    • IsLoaded() and EnsureLoaded() tests (c8bb082) and ITopicLazyLoader coverage (dedb2af), backed by a Load overload for TestTopicRepository (305c35c)
    • Dynamic association loading (e2b8f75) and lazy-loaded relations (d7c81d6), backed by Relationships and Related added to the stub repository (6bf7650) and Deferred cleared during setup (6e9cde8)
    • FindFirst() and FindAll() gates (7fa99cc), with test expectations updated after the gate fixes (a8a95bb)
    • Recursive-save gate (3eae283)
    • Deferred VersionHistory (c5c1267)
    • Ancestor stamping (60276b8)
  • Centralized the repeated (ITopicLazyLoadable) cast into locals (0202356) and threaded CancellationToken through the async test calls (ba648d7)
  • Test doubles taught to bypass lazy loading where appropriate (2e536be)

Version Management

Beyond loading, this untangles version handling from the live graph.

  • Load(topicId, version) (142b966, f22adb7): Now only fetches a detached historical topic, no longer merging it into the live topic graph
  • Rollback() (142b966): Now responsible for merging the detached topic into the live graph via a new private MergeVersion() on TopicRepository, shareable by any persistence implementation
  • Deferred associations are dirty-aware: DeferredAssociation.IsDirty (eb15c3d) propagates through resolution (03445b3) and into the collection's IsDirty() (3cd78b3), so associations merged during a rollback persist correctly
  • Deferred associations always persist (778fe9a): Save() includes Deferred entries in its Relationships/References mappings
    • @DeleteUnmatched is now always set, since the full association set is available even when unresolved in memory (2986685)
  • Per-key persistence (cceae9e): PersistRelationships() only calls UpdateRelationships for keys that actually have dirty associations, avoiding a round-trip per key
  • Supporting test scaffolding: A delegate-based LoadTopicGraph() intermediary (ff134c0) and a Load(topicId, version) fake (30deeb3) that registers historical relationships (3e4e019), exercising rollback (bc98eb3) and dirty-deferred resolution (a0b2c3c)

Sitemap

The SitemapController needs the entire tree, which is exactly what lazy loading avoids — loading and caching the whole graph would defeat the purpose. This gives the sitemap its own lean read path.

  • ISitemapTopicRepository (f9aa14d): A minimal read-only repository exposing only Load()
  • SqlSitemapTopicRepository (bfd6fe2): A SQL implementation backed by the new lean GetSitemap stored procedure (1eb817d), returning the bare minimum the sitemap needs
  • Controller integration (c0a919e): SitemapController.Index() is now async and drops its rootTopic guard, since the new contract guarantees non-null
    • The legacy extended sitemap is removed (c199203)
  • Output caching (cbf8aab, a188249): A default CacheOutput() policy — one hour, varying by hostname, query string, and scheme — since sitemaps are polled every few minutes
    • Opt-in via MapTopicSitemap(cacheDuration)
  • Test doubles: A StubSitemapTopicRepository (755c060) wrapping an existing ITopicRepository, wired into Host (79ec7a6, 3b81cf1) and SitemapControllerTest (966d5c7)

Bug Fixes

Fixes not otherwise specific to this branch's new code.

  • Refresh() ordering and orphans: A long-standing GetTopicUpdates ordering bug, where ascendants weren't guaranteed before descendants, potentially orphaning newly created topics
  • Reciprocal Clear() (7bfd09e): Clearing a relationship set left stale entries in the reciprocal IncomingRelationships. Now each is removed individually, reusing the existing reciprocal deletion logic (tested by 60e0cf9)
  • ID offset collisions: Zero-based test IDs collapsed 00000 to 0, causing duplicate-key exceptions

Cleanup

Largely mechanical passes, batched here to keep the substantive history readable.

When I originally introduced (55241c6) and implemented (a9035bb) `ITopicBackingAccessor`, I hadn't included `Attributes` because a) it doesn't have a backing field, and b) it doesn't trigger lazy-loading based on access to the `Attributes` property. But this is inconsistent, leaks internal implementation details to understand, and fails to insulate us in case we decide to change how attributes are lazy-loaded in the future. Based on that, I've retrofitted `ITopicBackingAccessor` with `Attributes`, and added the explicit interface implementation for it to `Topic`.
Throughout the lazy-loading infrastructure (#111), I ensured that access to the lazy-loaded properties on the `Topic` object are accessed through `ITopicBackingAccessor` (55241c6, 819e373) to ensure that they don't trip a loop on triggering the lazy-loading contract they're attempting to fulfill.
As part of this, I established the convention of calling this `rawTopic`. There were several places where this was the only topic reference needed, and I could have changed the signature, but I kept the boxing from `topic` to `rawTopic` and variable name to reinforce that this is going through the backing fields.
Previously, when restoring a topic to a previous version, we cleared the `Relationships` and `References` before establishing the database connection, so that they could be repopulated with new content. This moves that within the connection and after the reader is executed so that this isn't performed unless we have successfully connected to the server and (presumably) retrieved the requested content.
As part of this, I also clear the `Deferred` collections, as related to #111. They will be repopulated if there are any misses from the versioned associations.
In the `EnsureLoaded()` and `EnsureLoadedAsync()` in the `SqlTopicRepository`, we only need to include `@IncludeRelationships` or `@IncludeAssociations` if the caller requested `TopicPayload.Children`, as we ALWAYS load those when introducing new topics to the topic graph (3c27813), and then store them in the `Deferred` collections (f210866, fce368b). If we're including new children in the call, however, those won't have that data and so, just like with `@IncludeExtendedAttributes`, they need to be accounted for.
This gets confusing because `EnsureLoaded(Relationships)` or `EnsureLoaded(References)` don't actually do anything in the `SqlTopicRepository` right now. Those take the `Deferred` values, load them via `Load()≈, and incorporate them into the topic graph. Perhaps that should be in `SqlTopicRepository`, and I'll be revisiting that, but for now it means that those have no purpose here. As a result, I also added an early check-out at the top of `EnsureLoaded()` and `EnsureLoadedAsync()` if `TopicPayload` doesn't include `Children` or `ExtendedAttributes` so we avoid an unnecessary SQL calls.
Finally, I also preemptively clear the `Deferred` collections on the parent/seed node if children are being loaded since, in that one case, it'll be processing those entries fresh. By clearing them, we avoid the possibility of creating duplicate entries—which doesn't hurt anything, but also doesn't add any value.
As part of this, I made updates to a lot of the comments to help clarify this behavior.
This contributes to #111.
Previously, I ensured that ascendants were loaded if we were calling a topic other than the root and the `referenceTopic` was null (1968876). The assumption was that `referenceTopic` would include the parent and, therefore, the loaded topic would have an existing home in the topic graph without needing the ascendant context.
That assumption is invalid, and especially in context of lazy loading (#111). The `referenceTopic` is intended to connect this back to a graph, but it could still be well below any parent nodes that it needs to connect to that graph. As such, we _always_ need this, unless we're pulling the root node.
(Even with a root node, this wouldn't hurt anything—there simply wouldn't be any data—but might as well save that quick query if we know to that to be the case, and especially given this is an existing capability.)
This is the core implementation of `EnsureLoaded()` and `EnsureLoadedAsync()` for `TopicPayload.Relationships` and/or `TopicPayload.References`, and thus a key deliverable for the lazy-loading project (#111).
In `Load()`, the `Relationships` and `References` arguments instruct the stored procedure to return the keys and topic IDs for those alongside topics (3c27813), which is a cheap call and we can store orphans in `Deferred` (f210866, fce368b) without needing to look them up again.
In `EnsureLoaded()`, instead, we want to ensure that those topic IDs resolve to actual topics in the topic tree. That's a different task entirely. For now, this is is exclusively being implemented in `CachedTopicRepository`, not the underlying e.g., `SqlTopicRepository` (dc975bf), though I'll almost certainly want to revisit that later, ideally by some type of base class that supports all repositories. In the meanwhile, however, this establishes the business logic and allows this to work in real-world scenarios.
This is the final wiring required for lazy-loading associations (#111)! Now, calls to `Topic.Relationships` or `Topic.References()` will trigger the lazy-loading implementation in e.g., `CachedTopicRepository` (257545e), assuming it's being used.
I need to revisit the strategy here for other cases (e.g., where someone is using `SqlTopicRepository` without the `CachedTopicRepository` for some reason, such as a temporary in-memory programmatic crawl), but this satisfies the core use case that most implementations will follow.
This clears the new `Deferred` collection (f210866, fce368b) at the end of each `EnsureLoaded()` call to prevent test data from infecting subsequent calls to this shared resource. If this becomes a problem, we'll need to instead spin up a new instance for each test. This lays the foundation for testing the lazy-loading implementation (#111).
The prior setup of the tests will now trigger lazy-loading. In practice, at this point, that doesn't hurt anything—but it should, and will in subsequent test versions. This prevents that eventuality.
This contributes to the testing of #111.
In xUnit 3.x, the `Assert.NotNull()` establishes that the variable isn't null (as it should) and thus we can remove the null-forgiving or assurance operators.
The `parentId` parameter of `AddRow()` is `null` by default, so doesn't need to be explicitly defined.
Still not clear if this is just a JetBrains Rider issue or a legitimate issue with how XML docblocks are parsed. Regardless, keeping quoted identifiers on the same line addresses the issue. Also removed some implicit values while I was at it.
This is a massive architectural shift and, obviously, a breaking change, but a) allows the lazy-loading (#111) of associations to happen in parallel, and b) will support migrating to .NET built-in dependency injection and, thus, taking advantage of its support for asynchronous loading, which isn't supported by the activator model today.
This also required moving `EnsureLoaded()` to async only, and thus removing the original `EnsureLoaded()` and replacing it with the `EnsureLoadedAsync()`.
A number of these cases still require `GetAwaiter().GetResult()`, which is unfortunately, though quite a few of those will be resolved in subsequent commits.
One case that will be persisted is the synchronous lazy-loading when calling lazy-loading properties (#111) directly. That said, typically we're working through the `TopicMappingService` and, thus, the hope is that we can forecast those calls and asynchronously load them before the `TopicMappingService` gets that far.
This also required rehoming the `SqlDataReaderExtensions` off of `DbDataReader` instead of the `IDataReader`. That's an unfortunate compromise, but needed to permit asynchronous operation.
Following the lead of `Load()`, moved `Save()` (and `SaveTopic()`), `Move()` (and `MoveTopic()`), `Delete()` (and `DeleteTopic()`), as well as `Rollback()` and `Refresh()` from synchronous to asynchronous. The benefit of this won't be as strong as with `Load()` in most cases, but it's more consistent and, since these are typically called from controllers, will be compatible with their existing call structure.
This is tangentially related to #111.
This is not only possible, but encouraged with xunit 3, which we recently upgraded to. Further, it helps us utilize the newly introduced async `ITopicRepository` implementations (a3b7360, caab55a).
This continues a change that was first implemented narrowly (aff6491).
This continues a one-off implementation previously (56379ac).
This may just be a particularity of Jetbrains Rider, but it doesn't otherwise accept `cref` references unless the entire quoted identifier is on its own line. An irritating limitation, and especially because it can otherwise lead to awkward wrapping, but it is what it is. I just hope Visual Studio is cool with this, and it's not a mutual incompatibility.
This partially undoes work committed previously (5c6bd85) in preferring implicit constructors where the type is known. There were some false positives!
This continues previous implementations (56379ac, 5c6bd85), picking up some missing references.
If defining a constructor, the base default constructor will always be called. This isn't intuitive to me, but you learn something new every day. (Also, a word of caution when implementing base default constructors!).
This continues previous implementations (56379ac, 5c6bd85, e05427f), picking up some missing references.
Using the same lock used to synchronize the creation of the collection itself (86c6f7e), lock the addition of items to the collection via `PopulateTargetCollectionAsync()`, so that two concurrent additions don't conflict with one another. This addresses one of the last big items for `TopicMappingService` concurrency (#118). (That said, we'll still have smaller passes to go through after this.)
This complements the existing `ArmEnsureLoadedGate()` and `ReleaseEnsureLoadedGate()` (2875725, 8b56f3c) with a new `FaultEnsureLoadedGate()`, which will allow us to simulate an exception as part of the testing of the mapped collection concurrently (86c6f7e, 9dfc24c) tests (#118).
These two view models setup the conditions that will allow us to test the locks on `SetCollectionValueAsync()` (86c6f7e), which prevents duplicate collections from being created, and `PopulateTargetCollectionAsync()` (9dfc24c), which prevents two items from being added concurrently. This contributes to the testing of the `TopicMappingService` concurrency (#118).
The `RendezvousTopicLazyLoader` allows us to test the locks on `SetCollectionValueAsync()` (86c6f7e), which prevents duplicate collections from being created, and `PopulateTargetCollectionAsync()` (9dfc24c), which prevents two items from being added concurrently.
Unlike all other `ITopicLazyLoader` implementations, this is _purely_ a lazy loader; it is not bundled with an actual `ITopicRepository`, nor does it rely on e.g., `Load()` or any other integration with a persistence store to serve its narrow testing purpose.
This contributes to the testing of the `TopicMappingService` concurrency (#118).
The `RendezvousTopicLazyLoader` allows us to test the locks on `SetCollectionValueAsync()` (86c6f7e), which prevents duplicate collections from being created, and `PopulateTargetCollectionAsync()` (9dfc24c), which prevents two items from being added concurrently.
This relies on the `RendezvousTopicLazyLoader` (f6a85a2), the `ConcurrentExpansion` topic view models (96eae01), and the new `FaultEnsureLoadedGate()` (a101412). It also introduces a new `BuildConcurrentExpansionGraph()` helper to construct the graph itself.
This contributes to the testing of the mapped collection concurrently (86c6f7e, 9dfc24c) tests (#118).
This ensures that the order of siblings is honored. This wasn't an issue prior to lazy loading (#111) since tasks were CPU bound, but with the possibility of some siblings being fully loaded while others need to `await` a call to the persistence store, it's very easy for this to occur.
This contributes to the concurrency fixes for `TopicMappingService` (#118).
This provides a new `ITopicLazyLoader` test double which allows a `delay` to be registered as part of its construction, which is executed as part of `EnsureLoaded()`. This allows multiple calls to different instances of the loader to set different delays and, thus, result in out-of-order resolution of siblings, thus providing a test harness for the new `WhenAll()` fix (c5f191b).
This contributes to the testing of the `ITopicMappingService` concurrency (#118).
This utilizes the newly introduced `StaggeredTopicLazyLoader` (3359853) to validate the `WhenAll()` fix (c5f191b) for ensuring source order is maintained when mapping collections.
This contributes to the concurrency fixes for `TopicMappingService` (#118).
Previously, the queue was being wrapped in another array unnecessarily.
This updates the `Clear()` method on the `TopicRelationshipMultiMap()` to not only include the `Deferred` collection, but also to mark the collection as dirty if only deferred items were cleared.
Unlike the rest of this branch, this doesn't pertain to concurrency issues created by lazy loading (#111), but it does relate to a bug exposed in the `ReverseTopicMappingService` by the introduction of the `Deferred` associations.
This confirms that the `Topic.Relationships.Clear()` call successfully removes `Deferred` items as well as resolved items, as per the recent fix (3a16836).
Unlike the rest of this branch, this doesn't pertain to concurrency issues created by lazy loading (#111), but it does relate to a bug exposed in the `ReverseTopicMappingService` by the introduction of the `Deferred` associations.
In the previous unit test (e308bc5), I validated the base `Clear()` fix, ensuring that `Deferred` items are cleared alongside resolved targets (3a16836). This test complements that by ensuring that fix prevents `EnsureLoaded()` from "resurrecting" the deferred items after a `Clear()`, which is the core bug that the `ReverseTopicMappingService` was running into.
Unlike the rest of this branch, this doesn't pertain to concurrency issues created by lazy loading (#111), but it does relate to a bug exposed in the `ReverseTopicMappingService` by the introduction of the `Deferred` associations (#118).
Previously, both `MapAsync()`'s calls to `PopulateTargetCollectionAsync()` as well as `PopulateTargetCollectionAsync()` own mapping process were handled via task queues, allowing collections of view models to be asynchronously mapped and inserted into the target collection on `Topic`.
The problem with this is that the `TopicMultiMap` that underlies `Topic.Relationships` is fundamentally not thread safe. And because it's composed of a collection of collections, it would be very difficult to make it thread safe, and especially because of the fact that the topic references themselves may be referenced multiple times, including via reciprocal associations (e.g., `IncomingRelationships`) and, thus, we can't just map a topic once and then serialize its insertion into the `TopicMultiMap`.
This is an unfortunate compromise. That said, in practice, the `ReverseTopicMappingService` is typically used for processing a binding model from a form into a single topic and, thus, won't incur the costs, and even if it did, it's a rare process, not something that's happening multiple times for pretty much every request on the site, as it is with the `TopicMappingService`.
This contributes to the concurrency updates to the topic mapping services (#118) in response to the lazy-loading updates (#111).
The `StaggeredStubTopicRepository` performs the same task as `StaggeredTopicLazyLoader` (3359853), except for `ITopicRepository.Load()` instead of `ITopicLazyLoader.EnsureLoaded()`.
That said, because `StaggeredTopicLazyLoader` could have difference instances "stamped" onto each topic, it only required a simple `delay` argument in its constructor. With the `ITopicMappingService`, however, the same instance needs to be used for the entire workflow. As such, instead of a single `delay` argument, it takes the messier approach of accepting a map of topic keys to delays, which it cross-references with every `Load()` request.
This provides a test double for aiding in the testing of the `ReverseTopicMappingService`'s `PopulateTargetCollectionAsync())` serialization (c6e644d).
This contributes to the concurrency updates to the topic mapping services (#118) in response to the lazy-loading updates (#111).
The `NestedReferenceAttributeTopicBindingModel` provides a binding model for testing the serialization fix of the `ReverseTopicMappingService`'s `PopulateTargetCollectionAsync()` method (c6e644d).
This contributes to the concurrency updates to the topic mapping services (#118) in response to the lazy-loading updates (#111).
This provides a unit test for serialization fix of the `ReverseTopicMappingService`'s `PopulateTargetCollectionAsync()` method (c6e644d), using the newly introduced `StaggeredStubTopicRepository` (277edab) and `NestedReferenceAttributeTopicBindingModel` (909121d).
This contributes to the concurrency updates to the topic mapping services (#118) in response to the lazy-loading updates (#111).
When mapping a binding model to a topic via the `ReverseTopicMappingService`, we need to make sure, at minimum, that the extended attributes are loaded, as otherwise unchanged values will be marked dirty, potentially resulting in e.g., the blob (in the `SqlTopicRepository`) being versioned despite no actual changes. To solve this, I `EnsureLoaded()` on `MapAsync()`.
In addition, if any nested topics are present, I `EnsureLoaded()` children on both the topic as well as its nested topic container via `SetNestedTopicsAsync()`. This isn't strictly necessary, since these would be lazy loaded on access, but because property calls can only load synchronously, doing this preemptively allows the call to be asynchronous.
This contributes to the concurrency updates to the `ReverseTopicMappingService` (part of #118) in response to the lazy-loading updates (#111).
On the `TrackingTopicLazyLoader` test double, update it to track every load (via a new `_payloads` field) and, optionally, `SetLoadState()` for the topic, like a normal `ITopicLazyLoader` would be expected to do on `EnsureLoaded()`. This updates `WasCalled` to be readonly, automatically reflecting whether there are any calls recorded in the `_payloads` field.
This will be necessary to test the warmup of the target topic(s) via the `ReverseTopicMappingService` (f0c65ea).
This contributes to the concurrency testing of the `ReverseTopicMappingService` (part of #118) in response to the lazy-loading updates (#111).
This tests the warmup of the target topic(s) via the `ReverseTopicMappingService` (f0c65ea), relying on the updates to the `TrackingTopicLazyLoader` to support counting and marking loads.
This contributes to the concurrency testing of the `ReverseTopicMappingService` (part of #118) in response to the lazy-loading updates (#111).
Instead of blocking construction by potentially eagerly loading the entire `Root:Configuration` tree, instead opt to quickly initialize the `ReverseTopicMappingService`, and then call `GetContentTypeDescriptors()` on its first use.
This addresses a blocking call in the `ReverseTopicMappingService` constructor, plus a potentially stale reference to the `Configuration` topic graph.
In practice, we generally expect this data will be cached as part of the underlying `ITopicRepository`, even if it's not using e.g., the `CachedTopicRepository`, and so this wasn't actually buying us any performance benefit, while the staleness concern is introduced if the underlying layer doesn't do that (e.g., if it relied and a fast, no-cache persistence layer).
This is the final update to make the topic mapping services (and, in this case, the `ReverseTopicMappingService`) compatible (#118) with concurrency concerns introduced by lazy loading (#111).
This addresses issues related to the `TopicMappingService` and `ReverseTopicMappingService` introduced by lazy-loading (#111) and the possibility of genuine I/O-bound concurrency, thus closing #118. (Many of these issues were preexisting, but would never occur with an eager-loaded tree with mapping being a CPU-bound operation.) See PR #144 for a detailed breakdown of updates.
@JeremyCaneyJeremyCaney added this to the OnTopic 6.0.0 milestone Aug 5, 2026
@JeremyCaneyJeremyCaney self-assigned this Aug 5, 2026
@JeremyCaneyJeremyCaney added Area: Repositories Relates to the `ITopicRepository` interface or one of its implementations. Severity 2: Major Priority: 1 Type: Feature Introduces a major area of functionality. Status 5: Complete Task is considered complete, and ready for deployment. labels Aug 5, 2026
@JeremyCaney
JeremyCaney merged commit e740c3e into developAug 7, 2026
1 check passed
@JeremyCaneyJeremyCaney linked an issue Aug 14, 2026 that may be closed by this pull request
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area: RepositoriesRelates to the `ITopicRepository` interface or one of its implementations.Priority: 1Severity 2: MajorStatus 5: CompleteTask is considered complete, and ready for deployment.Type: FeatureIntroduces a major area of functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support lazy-loading in CachedTopicRepository

1 participant

@JeremyCaney