Skip to content

Published Cache: Stop writing unread member rows to the database cache (closes #23070) - #23730

Merged
AndyButland merged 7 commits into
v17/devfrom
v17/bugfix/23070-stop-caching-members
Aug 25, 2026
Merged

AndyButland merged 7 commits into
v17/devfrom
v17/bugfix/23070-stop-caching-members

Conversation

@AndyButland

@AndyButland AndyButland commented Aug 21, 2026 •

Copy link
Copy Markdown
Contributor

Description

Members are written into cmsContentNu by a full published-cache rebuild, but nothing ever reads those rows. This PR removes the write, and fixes a pre-existing bug that the removal exposed.

Fixes #23070.

Why this is justified

Nothing reads member rows. MemberCache.Get(IMember) goes to MemberCacheService.Get, which calls IPublishedContentFactory.ToPublishedMember and maps the supplied IMember entity on the fly. DatabaseCacheRepository is the only class in the codebase that queries cmsContentNu, and its only member-scoped code was the write. There is no second reader anywhere, including the Examine MembersIndex which populates from IMemberService.

Digging through the history suggests this is an unfinished or abandoned feature. The comment in PublishedContentFactory.ToPublishedMember has said so since the first HybridCache commit:

Members are only "mapped" never cached, so these default values are a bit weird, but they are not used.

IPublishedMember exposes the underlying IMember, which cannot be reconstructed from a cache row, so the read path needs the entity regardless. That removes the benefit a cached row could provide, which is presumably why the read side was never wired up — even the original 2016 NuCache MemberCache took IMemberService rather than the data source.

The rows are also stale, not merely unused. Up to v14 the write side was self-consistent: PublishedSnapshotServiceEventHandler handled MemberRefreshNotification to refresh a member's row on save, and member-type changes triggered a rebuild. Removing NuCache in v15 took both with it, and HybridCache never replaced them. Since then the only thing writing member rows has been the bulk rebuild, so a row reflects whatever the last full rebuild happened to see and nothing else.

Effect. A full rebuild — the Settings → Published status button, the post-migration rebuild, and the boot-time serializer-changed rebuild — no longer serialises and inserts a row per member. On a site with a lot of members, that is a considerable amount of pointless work removed from every rebuild, inside the single transaction the rebuild holds.

No longer requesting the Member serializer flag also drops a _memberTypeService.GetAll() from every rebuild, and makes the corresponding branch in MsgPackContentNestedDataSerializerFactory unreachable — that branch and its injected IMemberTypeService go too. The public ContentCacheDataSerializerEntityType.Member enum value stays, since it is in the package baseline.

Why not the change requested in the issue

The issue asks for a setting to disable member caching, or for IDatabaseCacheRepository/DatabaseCacheRepository to become public and unsealed so the rebuild can be overridden as it could in v13. Neither is needed if the work simply isn't done, and a setting would only be a flag over dead code. Making the repository public would also commit us to a large surface for a major, to work around a defect.

Existing rows

Not migrated. I considered adding a 17.7/18.2 migration step for this, but it doesn't seem necessary. Other than taking space, the stale records don't really do any harm — they're unread, and cmsContentNu.nodeId has an ON DELETE CASCADE foreign key to umbracoContent, so a deleted member takes its row with it.

A full rebuild clears the table before repopulating, so that removes them — though only thanks to the fix below, without which it would never have happened on SQL Server. Worth being precise in the release note: an upgrade won't necessarily perform a full rebuild, because the post-migration rebuild only runs when an executed migration sets RebuildCache, and targeted per-content-type rebuilds never truncate. So the rows can outlive several upgrades until someone rebuilds.

Fix to a pre-existing bug in the rebuild's table clear

In local testing, a full rebuild left member rows behind on SQL Server. The method that clears the table was a silent no-op there:

private void TruncateContent()
{
    if (Database.DatabaseType == DatabaseType.SqlServer2012)
    {
        Database.Execute($"TRUNCATE TABLE cmsContentNu");
    }

    if (Database.DatabaseType == DatabaseType.SQLite)
    {
        Database.Execute($"DELETE FROM cmsContentNu");
    }
}

Both are reference comparisons against NPoco's singleton instances. On SQL Server, SqlServerSyntaxProvider.GetUpdatedDatabaseType returns UmbracoSqlServerDatabaseType — a subclass of SqlServer2012DatabaseType, added so that bulk inserts keep foreign key constraints trusted. Being a subclass, it is not the singleton, so neither branch matched and nothing was executed.

That went unnoticed because each rebuild arm already deletes its own rows via RemoveByObjectTypeInBatches before repopulating, leaving this as a pure optimisation whose failure had no visible effect. Removing the member arm is what made it load-bearing: with nothing else deleting member rows, they would have survived every rebuild on SQL Server.

It is now one provider-agnostic statement, renamed to say what it does:

// Deletes rather than truncates. Truncating needs ALTER permission on the table where deleting needs
// only DELETE, and this runs from a backoffice action on sites whose runtime database user may have
// been reduced to read/write.
private void ClearContent()
    => Database.Execute($"DELETE FROM {QuoteTableName(Constants.DatabaseSchema.Tables.NodeData)}");

DELETE rather than TRUNCATE is deliberate. TRUNCATE has never actually executed on SQL Server previously, so deleting is what SQL Server already does today and introduces no new permission requirement on a backoffice action.

Also included

SqliteSyntaxProvider now overrides TruncateTable as DELETE FROM {0}. Nothing in production calls Database.TruncateTable, but the base provider's format is TRUNCATE TABLE {0} and SQLite has no such statement, so the extension would have handed invalid SQL to the first caller that tried it. Happy to drop this if it feels like scope creep, but it seemed worth closing while it was in view.

Testing

Automated

Three integration tests in MemberCacheServiceTests replace the two that asserted the removed behaviour.

Although now removed from the call site, TruncateTableTests covers Database.TruncateTable the fix to truncating tables.

Manual

Clear the member data from the cache with a rebuild from the backoffice via Settings > Published Status > Rebuild database cache. Verify no member records exist via:

SELECT COUNT(*) FROM cmsContentNu n
INNER JOIN umbracoNode un ON un.id = n.nodeId
WHERE un.nodeObjectType = '39EB0F98-B348-42A1-8662-E7EB18487560';

Then render a member in a template and confirm the values still resolve — including a custom property, which is the part that used to be serialised into the row. Built-in fields such as Email and UserName come off the member entity's own columns and would work either way.

Template code
@using Umbraco.Cms.Core.Models
@using Umbraco.Cms.Core.Models.PublishedContent
@using Umbraco.Cms.Core.PublishedCache
@using Umbraco.Cms.Core.Services
@using Umbraco.Extensions
@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage
@inject IMemberService MemberService
@inject IPublishedMemberCache MemberCache
@{
    Layout = null;

    // Set these to match your site.
    const string usernameToLookUp = "testmember";
    const string customPropertyAlias = "memberBio";
}

<h2>Member lookup via IPublishedMemberCache</h2>
@{
    IMember? member = MemberService.GetByUsername(usernameToLookUp);
}
@if (member is null)
{
    <p>No member with username '@usernameToLookUp'.</p>
}
else
{
    IPublishedMember? published = await MemberCache.GetAsync(member);

    if (published is null)
    {
        <p>Mapping returned null — that would be a real failure.</p>
    }
    else
    {
        <ul>
            <li>Name: @published.Name</li>
            <li>Email: @published.Email</li>
            <li>UserName: @published.UserName</li>
            <li>IsApproved: @published.IsApproved</li>
            <li>LastLoginDate: @published.LastLoginDate</li>
            <li>Content type: @published.ContentType.Alias</li>
        </ul>

        <h3>Custom property</h3>
        @if (published.HasProperty(customPropertyAlias))
        {
            <p><code>@customPropertyAlias</code> = <strong>@(published.Value<string>(customPropertyAlias))</strong></p>
        }
        else
        {
            <p>No property '@customPropertyAlias' on this member type — add one and set a value,
               otherwise this page only proves the built-in fields work.</p>
        }

        <h3>All properties</h3>
        <ul>
            @foreach (IPublishedProperty property in published.Properties)
            {
                <li>@property.Alias = @(property.GetValue()?.ToString() ?? "(null)")</li>
            }
        </ul>
    }
}

Merge notes

Umbraco 18 has a four-arm rebuild — content, media, member, element — and element rows are read, so the merge must drop only the member arm. Two specifics:

  • The guard around the table clear on main covers four collections, so it needs the same reduction there rather than taking this branch's two-collection version wholesale.
  • The CLAUDE.md line ("two arms (documents and media), not three") becomes three-of-four.

Copilot AI lite review requested due to automatic review settings August 21, 2026 21:29
@claude

claude Bot commented Aug 21, 2026 •

Copy link
Copy Markdown

Claude finished @AndyButland's task in 4m 2s —— View job


PR Review

Target: origin/v17/dev · Based on commit: 22bbed28
Skipped: 0 noise files out of 10 total

Removes the dead write path that serialised member rows into cmsContentNu on every cache rebuild — a path that has had no corresponding read since HybridCache replaced NuCache in v15. Members continue to be mapped on-the-fly from IMember entities as they always were.

  • Modified public API: IMemberCacheService.Rebuild — obsoleted (no-op default implementation added); IDatabaseCacheRepository.Rebuild — memberTypeIds parameter removed (internal interface, not a public API surface)
  • Other changes: Full rebuild no longer serialises or inserts member rows; ContentCacheDataSerializerEntityType.Member no longer passed to the serializer on rebuild (stops the implicit _memberTypeService.GetAll() call); existing member rows in cmsContentNu are silently removed on the next full rebuild via the table truncation path.

Suggestions

  • src/Umbraco.PublishedCache.HybridCache/Services/IMemberCacheService.cs:19: The TODO comment // TODO (V19): remove this member. is ambiguous — "member" reads as Umbraco Member entity rather than C# interface member. Suggest: // TODO (V19): remove this method.

Approved

Thorough, well-scoped removal of confirmed dead code. The public API handling is correct — IMemberCacheService.Rebuild is kept, no-opped, and obsoleted for v19 per the deprecation policy; IDatabaseCacheRepository is internal so its signature changes are not externally breaking. The truncation condition correctly narrows from three empty-collection guards to two, the serializer flag is correctly removed, all internal callers are updated, and the three replacement integration tests cover the cases that matter (no rows written on full rebuild, stale rows cleared, obsolete method is a no-op). Checking the box on the PR description's claim that the tests were confirmed failing before the production change is the only thing I'd ask of the author — the PR text says it was done, which is sufficient.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request stops full published-cache rebuilds from writing unused member rows to cmsContentNu, while preserving entity-based member mapping and API compatibility.

Changes:

  • Removes member serialization and database-cache rebuild work.
  • Retains IMemberCacheService.Rebuild as an obsolete no-op.
  • Updates rebuild contracts, callers, tests, and documentation.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.

Show a summary per file
File Summary
tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/MemberCacheServiceTests.cs Verifies member rows are not created or retained after full rebuilds.
tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentCacheServiceTests.cs Updates full-rebuild documentation coverage.
src/Umbraco.PublishedCache.HybridCache/Services/MemberCacheService.cs Removes database-cache dependencies and rebuild logic.
src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs Adapts to the simplified repository signature.
src/Umbraco.PublishedCache.HybridCache/Services/IMemberCacheService.cs Documents entity-based member mapping and retains the obsolete no-op rebuild.
src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs Adapts to the simplified repository signature.
src/Umbraco.PublishedCache.HybridCache/Persistence/IDatabaseCacheRepository.cs Removes member rebuild parameters and overloads.
src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs Removes member serialization and rebuild processing.
src/Umbraco.PublishedCache.HybridCache/DatabaseCacheRebuilder.cs Rebuilds only documents and media.
src/Umbraco.PublishedCache.HybridCache/CLAUDE.md Documents that members are mapped rather than database-cached.
Suppressed comments (2)

src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs:93

  • The regression tests configure NuCacheSerializerType.JSON, whose factory ignores the entity flags, so they do not exercise this Member-flag removal in the MessagePack path where it triggers _memberTypeService.GetAll(). A future reintroduction of that lookup would leave the tests green; add a MessagePack full-rebuild case or an assertion/spy proving member types are not loaded.
            | ContentCacheDataSerializerEntityType.Media);

src/Umbraco.PublishedCache.HybridCache/Services/IMemberCacheService.cs:15

  • There is no lookup or not-found result in this API: every non-null IMember is mapped, and the implementation's only null path is a null runtime argument. The return documentation should reflect that behavior instead of implying that an entity may be absent from the cache.
    /// <returns>The published member, or <c>null</c> if not found.</returns>

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Umbraco.PublishedCache.HybridCache/Services/IMemberCacheService.cs Outdated
Comment thread src/Umbraco.PublishedCache.HybridCache/Services/IMemberCacheService.cs Outdated
@sonarqubecloud

Copy link
Copy Markdown

@Zeegaan Zeegaan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like a reasonable change for me 🤔

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants