Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -173,8 +173,8 @@ public override void RefreshInternal(JsonPayload[] payloads)
// By INT Id
isolatedCache.Clear(RepositoryCacheKeys.GetKey<IContent, int>(payload.Id));

// By GUID Key
isolatedCache.Clear(RepositoryCacheKeys.GetKey<IContent, Guid?>(payload.Key));
// By GUID Key (GUID-keyed read repository uses a separate "uRepoGuid_" prefix)
isolatedCache.Clear(RepositoryCacheKeys.GetGuidKey<IContent>(payload.Key.GetValueOrDefault()));
}

// remove those that are in the branch
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ public override void RefreshInternal(JsonPayload[] payloads)
if (dataTypeCache.Success)
{
dataTypeCache.Result?.Clear(RepositoryCacheKeys.GetKey<IDataType, int>(payload.Id));
dataTypeCache.Result?.Clear(RepositoryCacheKeys.GetGuidKey<IDataType>(payload.Key));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,8 @@ public override void RefreshInternal(JsonPayload[] payloads)
// it *was* done for each pathId but really that does not make sense
// only need to do it for the current media
mediaCache.Result.Clear(RepositoryCacheKeys.GetKey<IMedia, int>(payload.Id));
mediaCache.Result.Clear(RepositoryCacheKeys.GetKey<IMedia, Guid?>(payload.Key));
// GUID-keyed read repository uses a separate "uRepoGuid_" prefix
mediaCache.Result.Clear(RepositoryCacheKeys.GetGuidKey<IMedia>(payload.Key.GetValueOrDefault()));

// remove those that are in the branch
if (payload.ChangeTypes.HasTypesAny(TreeChangeTypes.RefreshBranch | TreeChangeTypes.Remove))
Expand Down
28 changes: 28 additions & 0 deletions src/Umbraco.Core/Persistence/Repositories/RepositoryCacheKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ public static class RepositoryCacheKeys
/// </summary>
private static readonly ConcurrentDictionary<Type, string> _keys = new();

private static readonly ConcurrentDictionary<Type, string> _guidKeys = new();

/// <summary>
/// Gets the repository cache key for the provided type.
/// </summary>
Expand All @@ -20,6 +22,32 @@ public static class RepositoryCacheKeys
public static string GetKey<T>()
=> _keys.GetOrAdd(typeof(T), static type => "uRepo_" + type.Name + "_");

/// <summary>
/// Gets the GUID-specific repository cache key for the provided type.
/// Uses a distinct prefix so that GUID-keyed entries don't interfere with
/// the int-keyed repository's prefix-based search and count validation.
/// </summary>
/// <typeparam name="T">The entity type to get the cache key for.</typeparam>
/// <returns>A cache key string in the format "uRepoGuid_{TypeName}_".</returns>
public static string GetGuidKey<T>()
=> _guidKeys.GetOrAdd(typeof(T), static type => "uRepoGuid_" + type.Name + "_");

/// <summary>
/// Gets the GUID-specific repository cache key for the provided type and GUID.
/// </summary>
/// <typeparam name="T">The entity type to get the cache key for.</typeparam>
/// <param name="id">The entity GUID identifier.</param>
/// <returns>A cache key string in the format "uRepoGuid_{TypeName}_{Guid}", or an empty string if the id is <see cref="Guid.Empty"/>.</returns>
public static string GetGuidKey<T>(Guid id)
{
if (id == Guid.Empty)
{
return string.Empty;
}

return GetGuidKey<T>() + id;
}

/// <summary>
/// Gets the repository cache key for the provided type and Id.
/// </summary>
Expand Down
12 changes: 6 additions & 6 deletions src/Umbraco.Infrastructure/Cache/DefaultRepositoryCachePolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ namespace Umbraco.Cms.Core.Cache;
/// <typeparam name="TEntity">The type of the entity.</typeparam>
/// <typeparam name="TId">The type of the identifier.</typeparam>
/// <remarks>
/// <para>The default cache policy caches entities with a 5 minutes sliding expiration.</para>
/// <para>The default cache policy caches entities with a sliding expiration (see <see cref="RepositoryCacheConstants.DefaultCacheDuration"/>).</para>
/// <para>Each entity is cached individually.</para>
/// <para>If options.GetAllCacheAllowZeroCount then a 'zero-count' array is cached when GetAll finds nothing.</para>
/// <para>If options.GetAllCacheValidateCount then we check against the db when getting many entities.</para>
Expand Down Expand Up @@ -67,7 +67,7 @@ public override void Create(TEntity entity, Action<TEntity> persistNew)
// just to be safe, we cannot cache an item without an identity
if (entity.HasIdentity)
{
Cache.Insert(GetEntityCacheKey(entity.Id), () => entity, TimeSpan.FromMinutes(5), true);
Cache.Insert(GetEntityCacheKey(entity.Id), () => entity, RepositoryCacheConstants.DefaultCacheDuration, true);
}

// if there's a GetAllCacheAllowZeroCount cache, ensure it is cleared
Expand Down Expand Up @@ -102,7 +102,7 @@ public override void Update(TEntity entity, Action<TEntity> persistUpdated)
// just to be safe, we cannot cache an item without an identity
if (entity.HasIdentity)
{
Cache.Insert(GetEntityCacheKey(entity.Id), () => entity, TimeSpan.FromMinutes(5), true);
Cache.Insert(GetEntityCacheKey(entity.Id), () => entity, RepositoryCacheConstants.DefaultCacheDuration, true);
}

// if there's a GetAllCacheAllowZeroCount cache, ensure it is cleared
Expand Down Expand Up @@ -295,15 +295,15 @@ protected string GetEntityCacheKey(TId? id)
}

protected virtual void InsertEntity(string cacheKey, TEntity entity)
=> Cache.Insert(cacheKey, () => entity, TimeSpan.FromMinutes(5), true);
=> Cache.Insert(cacheKey, () => entity, RepositoryCacheConstants.DefaultCacheDuration, true);

protected virtual void InsertNull(string cacheKey)
{
// We can't actually cache a null value, as in doing so wouldn't be able to distinguish between
// a value that does exist but isn't yet cached, or a value that has been explicitly cached with a null value.
// Both would return null when we retrieve from the cache and we couldn't distinguish between the two.
// So we cache a special value that represents null, and then we can check for that value when we retrieve from the cache.
Cache.Insert(cacheKey, () => Constants.Cache.NullRepresentationInCache, TimeSpan.FromMinutes(5), true);
Cache.Insert(cacheKey, () => Constants.Cache.NullRepresentationInCache, RepositoryCacheConstants.DefaultCacheDuration, true);
}

protected virtual void InsertEntities(TId[]? ids, TEntity[]? entities)
Expand All @@ -323,7 +323,7 @@ protected virtual void InsertEntities(TId[]? ids, TEntity[]? entities)
foreach (TEntity entity in entities)
{
TEntity capture = entity;
Cache.Insert(GetEntityCacheKey(entity.Id), () => capture, TimeSpan.FromMinutes(5), true);
Cache.Insert(GetEntityCacheKey(entity.Id), () => capture, RepositoryCacheConstants.DefaultCacheDuration, true);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ private void InsertEntities(TEntity[]? entities)

if (_expires)
{
Cache.Insert(key, () => new DeepCloneableList<TEntity>(entities), TimeSpan.FromMinutes(5), true);
Cache.Insert(key, () => new DeepCloneableList<TEntity>(entities), RepositoryCacheConstants.DefaultCacheDuration, true);
}
else
{
Expand Down
151 changes: 151 additions & 0 deletions src/Umbraco.Infrastructure/Cache/GuidReadRepositoryCachePolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.

using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Infrastructure.Scoping;
using Umbraco.Extensions;

namespace Umbraco.Cms.Core.Cache;

/// <summary>
/// A cache policy for GUID-keyed read repositories that share an isolated cache
/// with their parent int-keyed repository.
/// </summary>
/// <typeparam name="TEntity">The type of the entity.</typeparam>
/// <remarks>
/// <para>
/// GUID-keyed read repositories and their parent int-keyed repositories both resolve
/// to the same <see cref="IAppPolicyCache" /> via <c>IsolatedCaches.GetOrCreate&lt;TEntity&gt;()</c>.
/// If both used <see cref="DefaultRepositoryCachePolicy{TEntity, TId}" />, they would share
/// the same cache key prefix (<c>"uRepo_{TypeName}_"</c>), causing the int-keyed repository's
/// prefix-based count validation to always fail (finding 2× the expected entries).
/// </para>
/// <para>
/// This policy uses a separate prefix (<c>"uRepoGuid_{TypeName}_"</c>) so that GUID-keyed
/// entries don't interfere with the int-keyed repository's cache operations.
/// </para>
/// <para>
/// For <see cref="GetAll" /> without specific IDs, this policy always delegates to the
/// repository rather than caching the full set, since the parent int-keyed repository
/// already handles full-set caching with proper count validation.
/// </para>
/// </remarks>
internal sealed class GuidReadRepositoryCachePolicy<TEntity> : RepositoryCachePolicyBase<TEntity, Guid>
where TEntity : class, IEntity
{
/// <summary>
/// Gets the cache key prefix used for storing GUIDs associated with the entity type.
/// </summary>
internal static string GuidCacheKeyPrefix { get; } = RepositoryCacheKeys.GetGuidKey<TEntity>();

/// <summary>
/// Initializes a new instance of the <see cref="GuidReadRepositoryCachePolicy{TEntity}"/> class.
/// </summary>
public GuidReadRepositoryCachePolicy(
IAppPolicyCache cache,
IScopeAccessor scopeAccessor,
IRepositoryCacheVersionService repositoryCacheVersionService,
ICacheSyncService cacheSyncService)
: base(cache, scopeAccessor, repositoryCacheVersionService, cacheSyncService)
{
}

/// <inheritdoc />
public override TEntity? Get(Guid id, Func<Guid, TEntity?> performGet, Func<Guid[]?, IEnumerable<TEntity>?> performGetAll)
{
EnsureCacheIsSynced();

var cacheKey = GuidCacheKeyPrefix + id;
TEntity? fromCache = Cache.GetCacheItem<TEntity>(cacheKey);

if (fromCache is not null)
{
return fromCache;
}

TEntity? entity = performGet(id);

if (entity is { HasIdentity: true })
{
Cache.Insert(cacheKey, () => entity, RepositoryCacheConstants.DefaultCacheDuration, true);
}

return entity;
}

/// <inheritdoc />
public override TEntity? GetCached(Guid id)
{
EnsureCacheIsSynced();
return Cache.GetCacheItem<TEntity>(GuidCacheKeyPrefix + id);
}

/// <inheritdoc />
public override bool Exists(Guid id, Func<Guid, bool> performExists, Func<Guid[], IEnumerable<TEntity>?> performGetAll)
{
EnsureCacheIsSynced();

TEntity? fromCache = Cache.GetCacheItem<TEntity>(GuidCacheKeyPrefix + id);
return fromCache is not null || performExists(id);
}

/// <inheritdoc />
public override TEntity[] GetAll(Guid[]? ids, Func<Guid[]?, IEnumerable<TEntity>?> performGetAll)
{
EnsureCacheIsSynced();

// For specific IDs, try cache first.
if (ids?.Length > 0)
{
TEntity[] cached = ids
.Select(id => Cache.GetCacheItem<TEntity>(GuidCacheKeyPrefix + id))
.WhereNotNull()
.ToArray();

if (cached.Length == ids.Length)
{
return cached;
}
}

// Cache miss (partial or full set) — delegate to the repository.
TEntity[] entities = performGetAll(ids)?.WhereNotNull().ToArray() ?? [];

// For specific IDs, populate the GUID cache so subsequent lookups hit the cache.
// For the full set (no IDs), skip, as the parent int-keyed repository handles full-set caching.
if (ids?.Length > 0)
{
foreach (TEntity entity in entities)
{
if (entity.HasIdentity)
{
var cacheKey = GuidCacheKeyPrefix + entity.Key;
Cache.Insert(cacheKey, () => entity, RepositoryCacheConstants.DefaultCacheDuration, true);
}
}
}

return entities;
}

/// <inheritdoc />
public override void Create(TEntity entity, Action<TEntity> persistNew)
=> throw new InvalidOperationException("This method won't be implemented.");

/// <inheritdoc />
public override void Update(TEntity entity, Action<TEntity> persistUpdated)
=> throw new InvalidOperationException("This method won't be implemented.");

/// <inheritdoc />
public override void Delete(TEntity entity, Action<TEntity> persistDeleted)
=> throw new InvalidOperationException("This method won't be implemented.");

/// <inheritdoc />
public override void ClearAll() => Cache.ClearByKey(GuidCacheKeyPrefix);

/// <summary>
/// Gets the GUID-prefixed cache key for the given entity key.
/// </summary>
internal static string GetCacheKey(Guid key) => GuidCacheKeyPrefix + key;
}
15 changes: 15 additions & 0 deletions src/Umbraco.Infrastructure/Cache/RepositoryCacheConstants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.

namespace Umbraco.Cms.Core.Cache;

/// <summary>
/// Provides default values for repository caching.
/// </summary>
internal static class RepositoryCacheConstants
{
/// <summary>
/// The default sliding expiration for individual entity cache entries in repositories.
/// </summary>
public static readonly TimeSpan DefaultCacheDuration = TimeSpan.FromMinutes(5);
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ public ContentTypeCommonRepository(
/// <inheritdoc />
public IEnumerable<IContentTypeComposition>? GetAllTypes() =>

// use a 5 minutes sliding cache - same as FullDataSet cache policy
_appCaches.RuntimeCache.GetCacheItem(CacheKey, GetAllTypesInternal, TimeSpan.FromMinutes(5), true);
// use a sliding cache - same as FullDataSet cache policy
_appCaches.RuntimeCache.GetCacheItem(CacheKey, GetAllTypesInternal, RepositoryCacheConstants.DefaultCacheDuration, true);

/// <inheritdoc />
public void ClearCache() => _appCaches.RuntimeCache.Clear(CacheKey);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -533,7 +533,7 @@ private void PopulateCacheById(IDataType entity)
if (entity.HasIdentity)
{
var cacheKey = GetCacheKey(entity.Id);
IsolatedCache.Insert(cacheKey, () => entity, TimeSpan.FromMinutes(5), true);
IsolatedCache.Insert(cacheKey, () => entity, RepositoryCacheConstants.DefaultCacheDuration, true);
}
}

Expand Down Expand Up @@ -571,6 +571,16 @@ public DataTypeByGuidReadRepository(
cacheSyncService) =>
_outerRepo = outerRepo;

// Use a GUID-specific cache policy with a distinct prefix ("uRepoGuid_IDataType_")
// so that GUID-keyed cache entries don't interfere with the parent int-keyed repository's
// prefix-based search and count validation in DefaultRepositoryCachePolicy.
protected override IRepositoryCachePolicy<IDataType, Guid> CreateCachePolicy()
=> new GuidReadRepositoryCachePolicy<IDataType>(
GlobalIsolatedCache,
ScopeAccessor,
RepositoryCacheVersionService,
CacheSyncService);

protected override IDataType? PerformGet(Guid id)
{
Sql<ISqlContext> sql = _outerRepo.GetBaseQuery(false)
Expand Down Expand Up @@ -649,7 +659,7 @@ public void PopulateCacheByKey(IDataType entity)
if (entity.HasIdentity)
{
var cacheKey = GetCacheKey(entity.Key);
IsolatedCache.Insert(cacheKey, () => entity, TimeSpan.FromMinutes(5), true);
IsolatedCache.Insert(cacheKey, () => entity, RepositoryCacheConstants.DefaultCacheDuration, true);
}
}

Expand All @@ -675,7 +685,7 @@ public void ClearCacheByKey(Guid key)
IsolatedCache.Clear(cacheKey);
}

private static string GetCacheKey(Guid key) => RepositoryCacheKeys.GetKey<IDataType>() + key;
private static string GetCacheKey(Guid key) => GuidReadRepositoryCachePolicy<IDataType>.GetCacheKey(key);
}

#endregion
Expand Down
Loading
Loading