Created
August 27, 2025 13:51
-
-
Save enkelmedia/727cdfbc0e741f9ecf6bf3e0a08c2308 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System.Diagnostics; | |
using Microsoft.Extensions.Caching.Hybrid; | |
using Microsoft.Extensions.Logging; | |
using Microsoft.Extensions.Options; | |
using Umbraco.Cms.Core; | |
using Umbraco.Cms.Core.Cache; | |
using Umbraco.Cms.Core.Models; | |
using Umbraco.Cms.Core.Models.PublishedContent; | |
using Umbraco.Cms.Core.PublishedCache; | |
using Umbraco.Cms.Core.Scoping; | |
using Umbraco.Cms.Core.Services; | |
using Umbraco.Cms.Core.Services.Navigation; | |
using Umbraco.Cms.Infrastructure.HybridCache.Extensions; | |
using Umbraco.Cms.Infrastructure.HybridCache.Factories; | |
using Umbraco.Cms.Infrastructure.HybridCache.Persistence; | |
using Umbraco.Cms.Infrastructure.HybridCache.Serialization; | |
using Umbraco.Extensions; | |
namespace Umbraco.Cms.Infrastructure.HybridCache.Services; | |
internal sealed class InMemoryDocumentCacheService : IDocumentCacheService | |
{ | |
private readonly IDatabaseCacheRepository _databaseCacheRepository; | |
private readonly IIdKeyMap _idKeyMap; | |
private readonly ICoreScopeProvider _scopeProvider; | |
private readonly IPublishedContentFactory _publishedContentFactory; | |
private readonly ICacheNodeFactory _cacheNodeFactory; | |
private readonly IEnumerable<IDocumentSeedKeyProvider> _seedKeyProviders; | |
private readonly IPublishedModelFactory _publishedModelFactory; | |
private readonly IPreviewService _previewService; | |
private readonly IPublishStatusQueryService _publishStatusQueryService; | |
private readonly CacheSettings _cacheSettings; | |
private readonly ILogger<DocumentCacheService> _logger; | |
private HashSet<Guid>? _seedKeys; | |
private readonly IAppPolicyCache _runtimeCache; | |
private HashSet<Guid> SeedKeys | |
{ | |
get | |
{ | |
if (_seedKeys is not null) | |
{ | |
return _seedKeys; | |
} | |
_seedKeys = []; | |
foreach (IDocumentSeedKeyProvider provider in _seedKeyProviders) | |
{ | |
_seedKeys.UnionWith(provider.GetSeedKeys()); | |
} | |
return _seedKeys; | |
} | |
} | |
public InMemoryDocumentCacheService( | |
IDatabaseCacheRepository databaseCacheRepository, | |
IIdKeyMap idKeyMap, | |
ICoreScopeProvider scopeProvider, | |
IPublishedContentFactory publishedContentFactory, | |
ICacheNodeFactory cacheNodeFactory, | |
IEnumerable<IDocumentSeedKeyProvider> seedKeyProviders, | |
IOptions<CacheSettings> cacheSettings, | |
IPublishedModelFactory publishedModelFactory, | |
IPreviewService previewService, | |
IPublishStatusQueryService publishStatusQueryService, | |
ILogger<DocumentCacheService> logger, | |
AppCaches appCaches | |
) | |
{ | |
_databaseCacheRepository = databaseCacheRepository; | |
_idKeyMap = idKeyMap; | |
_scopeProvider = scopeProvider; | |
_publishedContentFactory = publishedContentFactory; | |
_cacheNodeFactory = cacheNodeFactory; | |
_seedKeyProviders = seedKeyProviders; | |
_publishedModelFactory = publishedModelFactory; | |
_previewService = previewService; | |
_publishStatusQueryService = publishStatusQueryService; | |
_cacheSettings = cacheSettings.Value; | |
_logger = logger; | |
_runtimeCache = appCaches.RuntimeCache; | |
} | |
public async Task<IPublishedContent?> GetByKeyAsync(Guid key, bool? preview = null) | |
{ | |
bool calculatedPreview = preview ?? GetPreview(); | |
return await GetNodeAsync(key, calculatedPreview); | |
} | |
public async Task<IPublishedContent?> GetByIdAsync(int id, bool? preview = null) | |
{ | |
Attempt<Guid> keyAttempt = _idKeyMap.GetKeyForId(id, UmbracoObjectTypes.Document); | |
if (keyAttempt.Success is false) | |
{ | |
return null; | |
} | |
bool calculatedPreview = preview ?? GetPreview(); | |
Guid key = keyAttempt.Result; | |
return await GetNodeAsync(key, calculatedPreview); | |
} | |
private async Task<IPublishedContent?> GetNodeAsync(Guid key, bool preview) | |
{ | |
var cacheKey = GetCacheKey(key, preview); | |
var publishedContent = _runtimeCache.GetCacheItem(cacheKey, () => | |
{ | |
using ICoreScope scope = _scopeProvider.CreateCoreScope(); | |
ContentCacheNode? contentCacheNode = _databaseCacheRepository.GetContentSourceAsync(key, preview).ConfigureAwait(false).GetAwaiter().GetResult(); | |
// If we can resolve the content cache node, we still need to check if the ancestor path is published. | |
// This does cost some performance, but it's necessary to ensure that the content is actually published. | |
// When unpublishing a node, a payload with RefreshBranch is published, so we don't have to worry about this. | |
// Similarly, when a branch is published, next time the content is requested, the parent will be published, | |
// this works because we don't cache null values. | |
if (preview is false && contentCacheNode is not null && | |
_publishStatusQueryService.HasPublishedAncestorPath(contentCacheNode.Key) is false) | |
{ | |
// Careful not to early return here. We need to complete the scope even if returning null. | |
contentCacheNode = null; | |
} | |
scope.Complete(); | |
if (contentCacheNode == null) | |
return null; | |
var published = _publishedContentFactory.ToIPublishedContent(contentCacheNode, preview).CreateModel(_publishedModelFactory); | |
return published; | |
}); | |
return publishedContent; | |
} | |
private bool GetPreview() => _previewService.IsInPreview(); | |
public IEnumerable<IPublishedContent> GetByContentType(IPublishedContentType contentType) | |
{ | |
using ICoreScope scope = _scopeProvider.CreateCoreScope(); | |
IEnumerable<ContentCacheNode> nodes = _databaseCacheRepository.GetContentByContentTypeKey([contentType.Key], ContentCacheDataSerializerEntityType.Document); | |
scope.Complete(); | |
return nodes | |
.Select(x => _publishedContentFactory.ToIPublishedContent(x, x.IsDraft).CreateModel(_publishedModelFactory)) | |
.WhereNotNull(); | |
} | |
public async Task ClearMemoryCacheAsync(CancellationToken cancellationToken) | |
{ | |
_runtimeCache.ClearByKey("content__"); | |
// We have to run seeding again after the cache is cleared | |
await SeedAsync(cancellationToken); | |
} | |
public async Task RefreshMemoryCacheAsync(Guid key) | |
{ | |
using ICoreScope scope = _scopeProvider.CreateCoreScope(); | |
ContentCacheNode? draftNode = await _databaseCacheRepository.GetContentSourceAsync(key, true); | |
if (draftNode is not null) | |
{ | |
_runtimeCache.Insert(GetCacheKey(draftNode.Key, true), () => | |
{ | |
return _publishedContentFactory.ToIPublishedContent(draftNode, true).CreateModel(_publishedModelFactory); | |
}); | |
//await _hybridCache.SetAsync(GetCacheKey(draftNode.Key, true), draftNode, GetEntryOptions(draftNode.Key, true), GenerateTags(key)); | |
} | |
ContentCacheNode? publishedNode = await _databaseCacheRepository.GetContentSourceAsync(key, false); | |
if (publishedNode is not null && _publishStatusQueryService.HasPublishedAncestorPath(publishedNode.Key)) | |
{ | |
_runtimeCache.Insert(GetCacheKey(publishedNode.Key, false), () => | |
{ | |
return _publishedContentFactory.ToIPublishedContent(publishedNode, false).CreateModel(_publishedModelFactory); | |
}); | |
//await _hybridCache.SetAsync(GetCacheKey(publishedNode.Key, false), publishedNode, GetEntryOptions(publishedNode.Key, false), GenerateTags(key)); | |
} | |
scope.Complete(); | |
} | |
public async Task RemoveFromMemoryCacheAsync(Guid key) | |
{ | |
_runtimeCache.Clear(GetCacheKey(key, true)); | |
_runtimeCache.Clear(GetCacheKey(key, false)); | |
} | |
public async Task SeedAsync(CancellationToken cancellationToken) | |
{ | |
#if DEBUG | |
var sw = new Stopwatch(); | |
sw.Start(); | |
#endif | |
foreach (IEnumerable<Guid> group in SeedKeys.InGroupsOf(_cacheSettings.DocumentSeedBatchSize)) | |
{ | |
var uncachedKeys = new HashSet<Guid>(); | |
foreach (Guid key in group) | |
{ | |
if (cancellationToken.IsCancellationRequested) | |
{ | |
break; | |
} | |
var cacheKey = GetCacheKey(key, false); | |
var existsInCache = _runtimeCache.Get(cacheKey) != null; | |
if (existsInCache is false) | |
{ | |
uncachedKeys.Add(key); | |
} | |
} | |
_logger.LogDebug("Uncached key count {KeyCount}", uncachedKeys.Count); | |
if (uncachedKeys.Count == 0) | |
{ | |
continue; | |
} | |
using ICoreScope scope = _scopeProvider.CreateCoreScope(); | |
IEnumerable<ContentCacheNode> cacheNodes = await _databaseCacheRepository.GetContentSourcesAsync(uncachedKeys); | |
scope.Complete(); | |
_logger.LogDebug("Document nodes to cache {NodeCount}", cacheNodes.Count()); | |
foreach (ContentCacheNode cacheNode in cacheNodes) | |
{ | |
var cacheKey = GetCacheKey(cacheNode.Key, false); | |
_runtimeCache.Insert(GetCacheKey(cacheNode.Key, false), () => | |
{ | |
return _publishedContentFactory.ToIPublishedContent(cacheNode, false).CreateModel(_publishedModelFactory); | |
}); | |
//await _hybridCache.SetAsync( | |
// cacheKey, | |
// cacheNode, | |
// GetSeedEntryOptions(), | |
// GenerateTags(cacheNode.Key), | |
// cancellationToken: cancellationToken); | |
} | |
} | |
#if DEBUG | |
sw.Stop(); | |
_logger.LogInformation("Document cache seeding completed in {ElapsedMilliseconds} ms with {SeedCount} seed keys.", sw.ElapsedMilliseconds, SeedKeys.Count); | |
#else | |
_logger.LogInformation("Document cache seeding completed with {SeedCount} seed keys.", SeedKeys.Count); | |
#endif | |
} | |
// Internal for test purposes. | |
internal void ResetSeedKeys() => _seedKeys = null; | |
private HybridCacheEntryOptions GetSeedEntryOptions() => new() | |
{ | |
Expiration = _cacheSettings.Entry.Document.SeedCacheDuration, | |
LocalCacheExpiration = _cacheSettings.Entry.Document.SeedCacheDuration | |
}; | |
private HybridCacheEntryOptions GetEntryOptions(Guid key, bool preview) | |
{ | |
if (SeedKeys.Contains(key) && preview is false) | |
{ | |
return GetSeedEntryOptions(); | |
} | |
return new HybridCacheEntryOptions | |
{ | |
Expiration = _cacheSettings.Entry.Document.RemoteCacheDuration, | |
LocalCacheExpiration = _cacheSettings.Entry.Document.LocalCacheDuration, | |
}; | |
} | |
public async Task<bool> HasContentByIdAsync(int id, bool preview = false) | |
{ | |
Attempt<Guid> keyAttempt = _idKeyMap.GetKeyForId(id, UmbracoObjectTypes.Document); | |
if (keyAttempt.Success is false) | |
{ | |
return false; | |
} | |
return _runtimeCache.Get(GetCacheKey(keyAttempt.Result, preview)) != null; | |
} | |
public async Task RefreshContentAsync(IContent content) | |
{ | |
using ICoreScope scope = _scopeProvider.CreateCoreScope(); | |
// Always set draft node | |
// We have nodes seperate in the cache, cause 99% of the time, you are only using one | |
// and thus we won't get too much data when retrieving from the cache. | |
var draftCacheNode = _cacheNodeFactory.ToContentCacheNode(content, true); | |
await _databaseCacheRepository.RefreshContentAsync(draftCacheNode, content.PublishedState); | |
if (content.PublishedState == PublishedState.Publishing || content.PublishedState == PublishedState.Unpublishing) | |
{ | |
var publishedCacheNode = _cacheNodeFactory.ToContentCacheNode(content, false); | |
await _databaseCacheRepository.RefreshContentAsync(publishedCacheNode, content.PublishedState); | |
if (content.PublishedState == PublishedState.Unpublishing) | |
{ | |
_runtimeCache.Clear(GetCacheKey(publishedCacheNode.Key, false)); | |
} | |
} | |
scope.Complete(); | |
} | |
private static string GetCacheKey(Guid key, bool preview) => preview ? $"content__{key}+draft" : $"{key}"; | |
// Generates the cache tags for a given CacheNode | |
// We use the tags to be able to clear all cache entries that are related to a given content item. | |
// Tags for now are only content/media, but can be expanded with draft/published later. | |
private static HashSet<string> GenerateTags(Guid? key) => key is null ? [] : [Constants.Cache.Tags.Content]; | |
public async Task DeleteItemAsync(IContentBase content) | |
{ | |
using ICoreScope scope = _scopeProvider.CreateCoreScope(); | |
await _databaseCacheRepository.DeleteContentItemAsync(content.Id); | |
scope.Complete(); | |
} | |
public void Rebuild(IReadOnlyCollection<int> contentTypeIds) | |
{ | |
using ICoreScope scope = _scopeProvider.CreateCoreScope(); | |
_databaseCacheRepository.Rebuild(contentTypeIds.ToList()); | |
RebuildMemoryCacheByContentTypeAsync(contentTypeIds).GetAwaiter().GetResult(); | |
scope.Complete(); | |
} | |
public async Task RebuildMemoryCacheByContentTypeAsync(IEnumerable<int> contentTypeIds) | |
{ | |
using ICoreScope scope = _scopeProvider.CreateCoreScope(); | |
IEnumerable<ContentCacheNode> contentByContentTypeKey = _databaseCacheRepository.GetContentByContentTypeKey(contentTypeIds.Select(x => _idKeyMap.GetKeyForId(x, UmbracoObjectTypes.DocumentType).Result), ContentCacheDataSerializerEntityType.Document); | |
scope.Complete(); | |
foreach (ContentCacheNode content in contentByContentTypeKey) | |
{ | |
_runtimeCache.Clear(GetCacheKey(content.Key, true)); | |
//_hybridCache.RemoveAsync().GetAwaiter().GetResult(); | |
if (content.IsDraft is false) | |
{ | |
_runtimeCache.Clear(GetCacheKey(content.Key, false)); | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment