V15: Hybrid Caching (#16938)

* Update to dotnet 9 and update nuget packages

* Update umbraco code version

* Update Directory.Build.props

Co-authored-by: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com>

* Include preview version in pipeline

* update template projects

* update global json with specific version

* Update version.json to v15

* Rename TrimStart and TrimEnd to string specific

* Rename to Exact

* Update global.json

Co-authored-by: Ronald Barendse <ronald@barend.se>

* Remove includePreviewVersion

* Rename to trim exact

* Add new Hybridcache project

* Add tests

* Start implementing PublishedContent.cs

* Implement repository for content

* Refactor to use async everywhere

* Add cache refresher

* make public as needed for serialization

* Use content type cache to get content type out

* Refactor to use ContentCacheNode model, that goes in the memory cache

* Remove content node kit as its not needed

* Implement tests for ensuring caching

* Implement better asserts

* Implement published property

* Refactor to use mapping

* Rename to document tests

* Update to test properties

* Create more tests

* Refactor mock tests into own file

* Update property test

* Fix published version of content

* Change default cache level to elements

* Refactor to always have draft

* Refactor to not use PublishedModelFactory

* Added tests

* Added and updated tests

* Fixed tests

* Don't return empty object with id

* More tests

* Added key

* Another key

* Refactor CacheService to be responsible for using the hybrid cache

* Use notification handler to remove deleted content from cache

* Add more tests for missing functions

* Implement missing methods

* Remove HasContent as it pertains to routing

* Fik up test

* formatting

* refactor variable names

* Implement variant tests

* Map all the published content properties

* Get item out of cache first, to assert updated

* Implement member cache

* Add member test

* Implement media cache

* Implement property tests for media tests

* Refactor tests to use extension method

* Add more media tests

* Refactor properties to no longer have element caching

* Don't use property cache level

* Start implementing seeding

* Only seed when main

* Add Immutable for performance

* Implement permanent seeding of content

* Implement cache settings

* Implement tests for seeding

* Update package version

* start refactoring nurepo

* Refactor so draft & published nodes are cached individually

* Refactor RefreshContent to take node instead of IContent

* Refactor media to also use cache nodes

* Remove member from repo as it isn't cached

* Refactor media to not include preview, as media has no draft

* create new benchmark project

* POC Integration benchmarks with custom api controllers

* Start implementing content picker tests

* Implement domain cache

* Rework content cache to implement interface

* Start implementing elements cache

* Implement published snapshot service

* Publish snapshot tests

* Use snapshot for elements cache

* Create test proving we don't clear cache when updating content picker

* Clear entire elements cache

* Remove properties from element cache, when content gets updated.

* Rename methods to async

* Refactor to use old cache interfaces instead of new ones

* Remove snapshot, as it is no longer needed

* Fix tests building

* Refactor domaincache to not have snapshots

* Delete benchmarks

* Delete benchmarks

* Add HybridCacheProject to Umbraco

* Add comment to route value transformer

* Implement is draft

* remove snapshot from property

* V15 updated the hybrid caching integration tests to use ContentEditingService (#16947)

* Added builder extension withParentKey

* Created builder with ContentEditingService

* Added usage of the ContentEditingService to SETUP

* Started using ContentEditingService builder in tests

* Updated builder extensions

* Fixed builder

* Clean up

* Clean up, not done

* Added Ids

* Remove entries from cache on delete

* Fix up seeding logic

* Don't register hybrid cache twice

* Change seeded entry options

* Update hybrid cache package

* Fix up published property to work with delivery api again

* Fix dependency injection to work with tests

* Fix naming

* Dont make caches nullable

* Make content node sealed

* Remove path and other unused from content node

* Remove hacky 2 phase ctor

* Refactor to actually set content templates

* Remove umbraco context

* Remove "HasBy" methods

* rename property data

* Delete obsolete legacy stuff

* Add todo for making expiration configurable

* Add todo in UmbracoContext

* Add clarifying comment in content factory

* Remove xml stuff from published property

* Fix according to review

* Make content type cache injectible

* Make content type cache injectible

* Rename to database cache repository

* Rename to document cache

* Add TODO

* Refactor to async

* Rename to async

* Make everything async

* Remove duplicate line from json schema

* Move Hybrid cache project

* Remove leftover file

* Refactor to use keys

* Refactor published content to no longer have content data, as it is on the node itself

* Refactor to member to use proper content node ctor

* Move tests to own folder

* Add immutable objects to property and content data for performance

* Make property data public

* Fix member caching to be singleton

* Obsolete GetContentType

* Remove todo

* Fix naming

* Fix lots of exposed errors due to scope test

* Add final scope tests

* Rename to document cache service

* Rename test files

* Create new doc type tests

* Add ignore to tests

* Start implementing refresh for content type save

* Clear contenttype cache when contenttype is updated

* Fix test

Teh contenttype is not upated unless the property is dirty

* Use init for ContentSourceDto

* Fix get by key in PublishedContentTypeCache

* Remove ContentType from PublishedContentTypeCache when contenttype is deleted

* Update to preview 7

* Fix versions

* Increase timeout for sqlite integration tests

* Undo timeout increase

* Try and undo init change to ContentSourceDto

* That wasn't it chief

* Try and make DomainAndUrlsTests non NonParallelizable

* Update versions

* Only run cache tests on linux for now

---------

Co-authored-by: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com>
Co-authored-by: Ronald Barendse <ronald@barend.se>
Co-authored-by: Andreas Zerbst <andr317c@live.dk>
Co-authored-by: Sven Geusens <sge@umbraco.dk>
Co-authored-by: Andreas Zerbst <73799582+andr317c@users.noreply.github.com>
Co-authored-by: nikolajlauridsen <nikolajlauridsen@protonmail.ch>
This commit is contained in:
Nikolaj Geisle
2024-09-10 00:49:18 +09:00
committed by GitHub
parent dcd6f1fbf4
commit 2704d4a34a
102 changed files with 5881 additions and 132 deletions

View File

@@ -0,0 +1,27 @@
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.PublishedCache;
namespace Umbraco.Cms.Infrastructure.HybridCache;
/// <inheritdoc />
public class CacheManager : ICacheManager
{
public CacheManager(IPublishedContentCache content, IPublishedMediaCache media, IPublishedMemberCache members, IDomainCache domains, IElementsCache elementsCache)
{
ElementsCache = elementsCache;
Content = content;
Media = media;
Members = members;
Domains = domains;
}
public IPublishedContentCache Content { get; }
public IPublishedMediaCache Media { get; }
public IPublishedMemberCache Members { get; }
public IDomainCache Domains { get; }
public IAppCache ElementsCache { get; }
}

View File

@@ -0,0 +1,24 @@
using System.ComponentModel;
namespace Umbraco.Cms.Infrastructure.HybridCache;
// This is for cache performance reasons, see https://learn.microsoft.com/en-us/aspnet/core/performance/caching/hybrid?view=aspnetcore-9.0#reuse-objects
[ImmutableObject(true)]
internal sealed class ContentCacheNode
{
public int Id { get; set; }
public Guid Key { get; set; }
public int SortOrder { get; set; }
public DateTime CreateDate { get; set; }
public int CreatorId { get; set; }
public int ContentTypeId { get; set; }
public bool IsDraft { get; set; }
public ContentData? Data { get; set; }
}

View File

@@ -0,0 +1,45 @@
using System.ComponentModel;
namespace Umbraco.Cms.Infrastructure.HybridCache;
/// <summary>
/// Represents everything that is specific to an edited or published content version
/// </summary>
// This is for cache performance reasons, see https://learn.microsoft.com/en-us/aspnet/core/performance/caching/hybrid?view=aspnetcore-9.0#reuse-objects
[ImmutableObject(true)]
internal sealed class ContentData
{
public ContentData(string? name, string? urlSegment, int versionId, DateTime versionDate, int writerId, int? templateId, bool published, Dictionary<string, PropertyData[]>? properties, IReadOnlyDictionary<string, CultureVariation>? cultureInfos)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
UrlSegment = urlSegment;
VersionId = versionId;
VersionDate = versionDate;
WriterId = writerId;
TemplateId = templateId;
Published = published;
Properties = properties ?? throw new ArgumentNullException(nameof(properties));
CultureInfos = cultureInfos;
}
public string Name { get; }
public string? UrlSegment { get; }
public int VersionId { get; }
public DateTime VersionDate { get; }
public int WriterId { get; }
public int? TemplateId { get; }
public bool Published { get; }
public Dictionary<string, PropertyData[]> Properties { get; }
/// <summary>
/// The collection of language Id to name for the content item
/// </summary>
public IReadOnlyDictionary<string, CultureVariation>? CultureInfos { get; }
}

View File

@@ -0,0 +1,61 @@
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
namespace Umbraco.Cms.Infrastructure.HybridCache;
// represents a content "node" ie a pair of draft + published versions
// internal, never exposed, to be accessed from ContentStore (only!)
internal sealed class ContentNode
{
// everything that is common to both draft and published versions
// keep this as small as possible
#pragma warning disable IDE1006 // Naming Styles
public readonly int Id;
// draft and published version (either can be null, but not both)
// are models not direct PublishedContent instances
private ContentData? _draftData;
private ContentData? _publishedData;
public ContentNode(
int id,
Guid key,
int sortOrder,
DateTime createDate,
int creatorId,
IPublishedContentType contentType,
ContentData? draftData,
ContentData? publishedData)
{
Id = id;
Key = key;
SortOrder = sortOrder;
CreateDate = createDate;
CreatorId = creatorId;
ContentType = contentType;
if (draftData == null && publishedData == null)
{
throw new ArgumentException("Both draftData and publishedData cannot be null at the same time.");
}
_draftData = draftData;
_publishedData = publishedData;
}
public bool HasPublished => _publishedData != null;
public ContentData? DraftModel => _draftData;
public ContentData? PublishedModel => _publishedData;
public readonly Guid Key;
public IPublishedContentType ContentType;
public readonly int SortOrder;
public readonly DateTime CreateDate;
public readonly int CreatorId;
public bool HasPublishedCulture(string culture) => _publishedData != null && (_publishedData.CultureInfos?.ContainsKey(culture) ?? false);
#pragma warning restore IDE1006 // Naming Styles
}

View File

@@ -0,0 +1,29 @@
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using Umbraco.Cms.Infrastructure.Serialization;
namespace Umbraco.Cms.Infrastructure.HybridCache;
/// <summary>
/// Represents the culture variation information on a content item
/// </summary>
[DataContract] // NOTE: Use DataContract annotations here to control how MessagePack serializes/deserializes the data to use INT keys
public class CultureVariation
{
[DataMember(Order = 0)]
[JsonPropertyName("nm")]
public string? Name { get; set; }
[DataMember(Order = 1)]
[JsonPropertyName("us")]
public string? UrlSegment { get; set; }
[DataMember(Order = 2)]
[JsonPropertyName("dt")]
[JsonConverter(typeof(JsonUniversalDateTimeConverter))]
public DateTime Date { get; set; }
[DataMember(Order = 3)]
[JsonPropertyName("isd")]
public bool IsDraft { get; set; }
}

View File

@@ -0,0 +1,67 @@

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Infrastructure.HybridCache;
using Umbraco.Cms.Infrastructure.HybridCache.Factories;
using Umbraco.Cms.Infrastructure.HybridCache.NotificationHandlers;
using Umbraco.Cms.Infrastructure.HybridCache.Persistence;
using Umbraco.Cms.Infrastructure.HybridCache.Serialization;
using Umbraco.Cms.Infrastructure.HybridCache.Services;
namespace Umbraco.Extensions;
/// <summary>
/// Extension methods for <see cref="IUmbracoBuilder" /> for the Umbraco's NuCache
/// </summary>
public static class UmbracoBuilderExtensions
{
/// <summary>
/// Adds Umbraco NuCache dependencies
/// </summary>
public static IUmbracoBuilder AddUmbracoHybridCache(this IUmbracoBuilder builder)
{
builder.Services.AddHybridCache();
builder.Services.AddSingleton<IDatabaseCacheRepository, DatabaseCacheRepository>();
builder.Services.AddSingleton<IPublishedContentCache, DocumentCache>();
builder.Services.AddSingleton<IPublishedMediaCache, MediaCache>();
builder.Services.AddSingleton<IPublishedMemberCache, MemberCache>();
builder.Services.AddSingleton<IDomainCache, DomainCache>();
builder.Services.AddSingleton<IElementsCache, ElementsDictionaryAppCache>();
builder.Services.AddSingleton<IPublishedContentTypeCache, PublishedContentTypeCache>();
builder.Services.AddSingleton<IDocumentCacheService, DocumentCacheService>();
builder.Services.AddSingleton<IMediaCacheService, MediaCacheService>();
builder.Services.AddSingleton<IMemberCacheService, MemberCacheService>();
builder.Services.AddSingleton<IDomainCacheService, DomainCacheService>();
builder.Services.AddSingleton<IPublishedContentFactory, PublishedContentFactory>();
builder.Services.AddSingleton<ICacheNodeFactory, CacheNodeFactory>();
builder.Services.AddSingleton<ICacheManager, CacheManager>();
builder.Services.AddSingleton<IContentCacheDataSerializerFactory>(s =>
{
IOptions<NuCacheSettings> options = s.GetRequiredService<IOptions<NuCacheSettings>>();
switch (options.Value.NuCacheSerializerType)
{
case NuCacheSerializerType.JSON:
return new JsonContentNestedDataSerializerFactory();
case NuCacheSerializerType.MessagePack:
return ActivatorUtilities.CreateInstance<MsgPackContentNestedDataSerializerFactory>(s);
default:
throw new IndexOutOfRangeException();
}
});
builder.Services.AddSingleton<IPropertyCacheCompressionOptions, NoopPropertyCacheCompressionOptions>();
builder.AddNotificationAsyncHandler<ContentRefreshNotification, CacheRefreshingNotificationHandler>();
builder.AddNotificationAsyncHandler<ContentDeletedNotification, CacheRefreshingNotificationHandler>();
builder.AddNotificationAsyncHandler<MediaRefreshNotification, CacheRefreshingNotificationHandler>();
builder.AddNotificationAsyncHandler<MediaDeletedNotification, CacheRefreshingNotificationHandler>();
builder.AddNotificationAsyncHandler<UmbracoApplicationStartedNotification, SeedingNotificationHandler>();
builder.AddNotificationAsyncHandler<ContentTypeRefreshedNotification, CacheRefreshingNotificationHandler>();
return builder;
}
}

View File

@@ -0,0 +1,64 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Infrastructure.HybridCache.Services;
namespace Umbraco.Cms.Infrastructure.HybridCache;
public sealed class DocumentCache : IPublishedContentCache
{
private readonly IDocumentCacheService _documentCacheService;
private readonly IPublishedContentTypeCache _publishedContentTypeCache;
public DocumentCache(IDocumentCacheService documentCacheService, IPublishedContentTypeCache publishedContentTypeCache)
{
_documentCacheService = documentCacheService;
_publishedContentTypeCache = publishedContentTypeCache;
}
public async Task<IPublishedContent?> GetByIdAsync(int id, bool preview = false) => await _documentCacheService.GetByIdAsync(id, preview);
public async Task<IPublishedContent?> GetByIdAsync(Guid key, bool preview = false) => await _documentCacheService.GetByKeyAsync(key, preview);
public IPublishedContent? GetById(bool preview, int contentId) => GetByIdAsync(contentId, preview).GetAwaiter().GetResult();
public IPublishedContent? GetById(bool preview, Guid contentId) => GetByIdAsync(contentId, preview).GetAwaiter().GetResult();
public IPublishedContent? GetById(int contentId) => GetByIdAsync(contentId, false).GetAwaiter().GetResult();
public IPublishedContent? GetById(Guid contentId) => GetByIdAsync(contentId, false).GetAwaiter().GetResult();
public IPublishedContentType? GetContentType(int id) => _publishedContentTypeCache.Get(PublishedItemType.Content, id);
public IPublishedContentType? GetContentType(string alias) => _publishedContentTypeCache.Get(PublishedItemType.Content, alias);
public IPublishedContentType? GetContentType(Guid key) => _publishedContentTypeCache.Get(PublishedItemType.Content, key);
// FIXME: These need to be refactored when removing nucache
// Thats the time where we can change the IPublishedContentCache interface.
public IPublishedContent? GetById(bool preview, Udi contentId) => throw new NotImplementedException();
public IPublishedContent? GetById(Udi contentId) => throw new NotImplementedException();
public IEnumerable<IPublishedContent> GetAtRoot(bool preview, string? culture = null) => throw new NotImplementedException();
public IEnumerable<IPublishedContent> GetAtRoot(string? culture = null) => throw new NotImplementedException();
public bool HasContent(bool preview) => throw new NotImplementedException();
public bool HasContent() => throw new NotImplementedException();
public IEnumerable<IPublishedContent> GetByContentType(IPublishedContentType contentType) => throw new NotImplementedException();
public IPublishedContent? GetByRoute(bool preview, string route, bool? hideTopLevelNode = null, string? culture = null) => throw new NotImplementedException();
public IPublishedContent? GetByRoute(string route, bool? hideTopLevelNode = null, string? culture = null) => throw new NotImplementedException();
public string? GetRouteById(bool preview, int contentId, string? culture = null) => throw new NotImplementedException();
public string? GetRouteById(int contentId, string? culture = null) => throw new NotImplementedException();
}

View File

@@ -0,0 +1,34 @@
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Infrastructure.HybridCache.Services;
namespace Umbraco.Cms.Infrastructure.HybridCache;
/// <summary>
/// Implements <see cref="IDomainCache" /> for NuCache.
/// </summary>
public class DomainCache : IDomainCache
{
private readonly IDomainCacheService _domainCacheService;
/// <summary>
/// Initializes a new instance of the <see cref="DomainCache" /> class.
/// </summary>
public DomainCache(IDefaultCultureAccessor defaultCultureAccessor, IDomainCacheService domainCacheService)
{
_domainCacheService = domainCacheService;
DefaultCulture = defaultCultureAccessor.DefaultCulture;
}
/// <inheritdoc />
public string DefaultCulture { get; }
/// <inheritdoc />
public IEnumerable<Domain> GetAll(bool includeWildcards) => _domainCacheService.GetAll(includeWildcards);
/// <inheritdoc />
public IEnumerable<Domain> GetAssigned(int documentId, bool includeWildcards = false) => _domainCacheService.GetAssigned(documentId, includeWildcards);
/// <inheritdoc />
public bool HasAssigned(int documentId, bool includeWildcards = false) => _domainCacheService.HasAssigned(documentId, includeWildcards);
}

View File

@@ -0,0 +1,7 @@
using Umbraco.Cms.Core.Cache;
namespace Umbraco.Cms.Infrastructure.HybridCache;
public class ElementsDictionaryAppCache : FastDictionaryAppCache, IElementsCache
{
}

View File

@@ -0,0 +1,120 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Strings;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.HybridCache.Factories;
internal class CacheNodeFactory : ICacheNodeFactory
{
private readonly IShortStringHelper _shortStringHelper;
private readonly UrlSegmentProviderCollection _urlSegmentProviders;
public CacheNodeFactory(IShortStringHelper shortStringHelper, UrlSegmentProviderCollection urlSegmentProviders)
{
_shortStringHelper = shortStringHelper;
_urlSegmentProviders = urlSegmentProviders;
}
public ContentCacheNode ToContentCacheNode(IContent content, bool preview)
{
ContentData contentData = GetContentData(content, !preview, preview ? content.PublishTemplateId : content.TemplateId);
return new ContentCacheNode
{
Id = content.Id,
Key = content.Key,
SortOrder = content.SortOrder,
CreateDate = content.CreateDate,
CreatorId = content.CreatorId,
ContentTypeId = content.ContentTypeId,
Data = contentData,
IsDraft = preview,
};
}
public ContentCacheNode ToContentCacheNode(IMedia media)
{
ContentData contentData = GetContentData(media, false, null);
return new ContentCacheNode
{
Id = media.Id,
Key = media.Key,
SortOrder = media.SortOrder,
CreateDate = media.CreateDate,
CreatorId = media.CreatorId,
ContentTypeId = media.ContentTypeId,
Data = contentData,
IsDraft = false,
};
}
private ContentData GetContentData(IContentBase content, bool published, int? templateId)
{
var propertyData = new Dictionary<string, PropertyData[]>();
foreach (IProperty prop in content.Properties)
{
var pdatas = new List<PropertyData>();
foreach (IPropertyValue pvalue in prop.Values.OrderBy(x => x.Culture))
{
// sanitize - properties should be ok but ... never knows
if (!prop.PropertyType.SupportsVariation(pvalue.Culture, pvalue.Segment))
{
continue;
}
// note: at service level, invariant is 'null', but here invariant becomes 'string.Empty'
var value = published ? pvalue.PublishedValue : pvalue.EditedValue;
if (value != null)
{
pdatas.Add(new PropertyData
{
Culture = pvalue.Culture ?? string.Empty,
Segment = pvalue.Segment ?? string.Empty,
Value = value,
});
}
}
propertyData[prop.Alias] = pdatas.ToArray();
}
var cultureData = new Dictionary<string, CultureVariation>();
// sanitize - names should be ok but ... never knows
if (content.ContentType.VariesByCulture())
{
ContentCultureInfosCollection? infos = content is IContent document
? published
? document.PublishCultureInfos
: document.CultureInfos
: content.CultureInfos;
// ReSharper disable once UseDeconstruction
if (infos is not null)
{
foreach (ContentCultureInfos cultureInfo in infos)
{
var cultureIsDraft = !published && content is IContent d && d.IsCultureEdited(cultureInfo.Culture);
cultureData[cultureInfo.Culture] = new CultureVariation
{
Name = cultureInfo.Name,
UrlSegment =
content.GetUrlSegment(_shortStringHelper, _urlSegmentProviders, cultureInfo.Culture),
Date = content.GetUpdateDate(cultureInfo.Culture) ?? DateTime.MinValue,
IsDraft = cultureIsDraft,
};
}
}
}
return new ContentData(
content.Name,
null,
content.VersionId,
content.UpdateDate,
content.CreatorId,
templateId,
published,
propertyData,
cultureData);
}
}

View File

@@ -0,0 +1,9 @@
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Infrastructure.HybridCache.Factories;
internal interface ICacheNodeFactory
{
ContentCacheNode ToContentCacheNode(IContent content, bool preview);
ContentCacheNode ToContentCacheNode(IMedia media);
}

View File

@@ -0,0 +1,12 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Cms.Infrastructure.HybridCache.Factories;
internal interface IPublishedContentFactory
{
IPublishedContent? ToIPublishedContent(ContentCacheNode contentCacheNode, bool preview);
IPublishedContent? ToIPublishedMedia(ContentCacheNode contentCacheNode);
IPublishedMember ToPublishedMember(IMember member);
}

View File

@@ -0,0 +1,159 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
namespace Umbraco.Cms.Infrastructure.HybridCache.Factories;
internal class PublishedContentFactory : IPublishedContentFactory
{
private readonly IElementsCache _elementsCache;
private readonly IVariationContextAccessor _variationContextAccessor;
private readonly IPublishedContentTypeCache _publishedContentTypeCache;
public PublishedContentFactory(
IElementsCache elementsCache,
IVariationContextAccessor variationContextAccessor,
IPublishedContentTypeCache publishedContentTypeCache)
{
_elementsCache = elementsCache;
_variationContextAccessor = variationContextAccessor;
_publishedContentTypeCache = publishedContentTypeCache;
}
public IPublishedContent? ToIPublishedContent(ContentCacheNode contentCacheNode, bool preview)
{
IPublishedContentType contentType = _publishedContentTypeCache.Get(PublishedItemType.Content, contentCacheNode.ContentTypeId);
var contentNode = new ContentNode(
contentCacheNode.Id,
contentCacheNode.Key,
contentCacheNode.SortOrder,
contentCacheNode.CreateDate,
contentCacheNode.CreatorId,
contentType,
preview ? contentCacheNode.Data : null,
preview ? null : contentCacheNode.Data);
IPublishedContent? model = GetModel(contentNode, preview);
if (preview)
{
return model ?? GetPublishedContentAsDraft(model);
}
return model;
}
public IPublishedContent? ToIPublishedMedia(ContentCacheNode contentCacheNode)
{
IPublishedContentType contentType = _publishedContentTypeCache.Get(PublishedItemType.Media, contentCacheNode.ContentTypeId);
var contentNode = new ContentNode(
contentCacheNode.Id,
contentCacheNode.Key,
contentCacheNode.SortOrder,
contentCacheNode.CreateDate,
contentCacheNode.CreatorId,
contentType,
null,
contentCacheNode.Data);
return GetModel(contentNode, false);
}
public IPublishedMember ToPublishedMember(IMember member)
{
IPublishedContentType contentType = _publishedContentTypeCache.Get(PublishedItemType.Member, member.ContentTypeId);
// Members are only "mapped" never cached, so these default values are a bit wierd, but they are not used.
var contentData = new ContentData(
member.Name,
null,
0,
member.UpdateDate,
member.CreatorId,
null,
true,
GetPropertyValues(contentType, member),
null);
var contentNode = new ContentNode(
member.Id,
member.Key,
member.SortOrder,
member.UpdateDate,
member.CreatorId,
contentType,
null,
contentData);
return new PublishedMember(member, contentNode, _elementsCache, _variationContextAccessor);
}
private Dictionary<string, PropertyData[]> GetPropertyValues(IPublishedContentType contentType, IMember member)
{
var properties = member
.Properties
.ToDictionary(
x => x.Alias,
x => new[] { new PropertyData { Value = x.GetValue(), Culture = string.Empty, Segment = string.Empty } },
StringComparer.OrdinalIgnoreCase);
// Add member properties
AddIf(contentType, properties, nameof(IMember.Email), member.Email);
AddIf(contentType, properties, nameof(IMember.Username), member.Username);
AddIf(contentType, properties, nameof(IMember.Comments), member.Comments);
AddIf(contentType, properties, nameof(IMember.IsApproved), member.IsApproved);
AddIf(contentType, properties, nameof(IMember.IsLockedOut), member.IsLockedOut);
AddIf(contentType, properties, nameof(IMember.LastLockoutDate), member.LastLockoutDate);
AddIf(contentType, properties, nameof(IMember.CreateDate), member.CreateDate);
AddIf(contentType, properties, nameof(IMember.LastLoginDate), member.LastLoginDate);
AddIf(contentType, properties, nameof(IMember.LastPasswordChangeDate), member.LastPasswordChangeDate);
return properties;
}
private void AddIf(IPublishedContentType contentType, IDictionary<string, PropertyData[]> properties, string alias, object? value)
{
IPublishedPropertyType? propertyType = contentType.GetPropertyType(alias);
if (propertyType == null || propertyType.IsUserProperty)
{
return;
}
properties[alias] = new[] { new PropertyData { Value = value, Culture = string.Empty, Segment = string.Empty } };
}
private IPublishedContent? GetModel(ContentNode node, bool preview)
{
ContentData? contentData = preview ? node.DraftModel : node.PublishedModel;
return contentData == null
? null
: new PublishedContent(
node,
preview,
_elementsCache,
_variationContextAccessor);
}
private IPublishedContent? GetPublishedContentAsDraft(IPublishedContent? content) =>
content == null ? null :
// an object in the cache is either an IPublishedContentOrMedia,
// or a model inheriting from PublishedContentExtended - in which
// case we need to unwrap to get to the original IPublishedContentOrMedia.
UnwrapIPublishedContent(content);
private PublishedContent UnwrapIPublishedContent(IPublishedContent content)
{
while (content is PublishedContentWrapped wrapped)
{
content = wrapped.Unwrap();
}
if (!(content is PublishedContent inner))
{
throw new InvalidOperationException("Innermost content is not PublishedContent.");
}
return inner;
}
}

View File

@@ -0,0 +1,7 @@
using Umbraco.Cms.Core.Cache;
namespace Umbraco.Cms.Infrastructure.HybridCache;
public interface IElementsCache : IAppCache
{
}

View File

@@ -0,0 +1,56 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Infrastructure.HybridCache.Services;
namespace Umbraco.Cms.Infrastructure.HybridCache;
public class MediaCache : IPublishedMediaCache
{
private readonly IMediaCacheService _mediaCacheService;
private readonly IPublishedContentTypeCache _publishedContentTypeCache;
public MediaCache(IMediaCacheService mediaCacheService, IPublishedContentTypeCache publishedContentTypeCache)
{
_mediaCacheService = mediaCacheService;
_publishedContentTypeCache = publishedContentTypeCache;
}
public async Task<IPublishedContent?> GetByIdAsync(int id) => await _mediaCacheService.GetByIdAsync(id);
public async Task<IPublishedContent?> GetByKeyAsync(Guid key) => await _mediaCacheService.GetByKeyAsync(key);
public IPublishedContent? GetById(bool preview, int contentId) => GetByIdAsync(contentId).GetAwaiter().GetResult();
public IPublishedContent? GetById(bool preview, Guid contentId) =>
GetByKeyAsync(contentId).GetAwaiter().GetResult();
public IPublishedContent? GetById(int contentId) => GetByIdAsync(contentId).GetAwaiter().GetResult();
public IPublishedContent? GetById(Guid contentId) => GetByKeyAsync(contentId).GetAwaiter().GetResult();
public IPublishedContentType? GetContentType(Guid key) => _publishedContentTypeCache.Get(PublishedItemType.Media, key);
public IPublishedContentType GetContentType(int id) => _publishedContentTypeCache.Get(PublishedItemType.Media, id);
public IPublishedContentType GetContentType(string alias) => _publishedContentTypeCache.Get(PublishedItemType.Media, alias);
// FIXME - these need to be removed when removing nucache
public IPublishedContent? GetById(bool preview, Udi contentId) => throw new NotImplementedException();
public IPublishedContent? GetById(Udi contentId) => throw new NotImplementedException();
public IEnumerable<IPublishedContent> GetAtRoot(bool preview, string? culture = null) => throw new NotImplementedException();
public IEnumerable<IPublishedContent> GetAtRoot(string? culture = null) => throw new NotImplementedException();
public bool HasContent(bool preview) => throw new NotImplementedException();
public bool HasContent() => throw new NotImplementedException();
public IEnumerable<IPublishedContent> GetByContentType(IPublishedContentType contentType) =>
throw new NotImplementedException();
}

View File

@@ -0,0 +1,27 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Infrastructure.HybridCache.Services;
namespace Umbraco.Cms.Infrastructure.HybridCache;
public class MemberCache : IPublishedMemberCache
{
private readonly IMemberCacheService _memberCacheService;
private readonly IPublishedContentTypeCache _publishedContentTypeCache;
public MemberCache(IMemberCacheService memberCacheService, IPublishedContentTypeCache publishedContentTypeCache)
{
_memberCacheService = memberCacheService;
_publishedContentTypeCache = publishedContentTypeCache;
}
public async Task<IPublishedMember?> GetAsync(IMember member) =>
await _memberCacheService.Get(member);
public IPublishedMember? Get(IMember member) => GetAsync(member).GetAwaiter().GetResult();
public IPublishedContentType GetContentType(int id) => _publishedContentTypeCache.Get(PublishedItemType.Member, id);
public IPublishedContentType GetContentType(string alias) => _publishedContentTypeCache.Get(PublishedItemType.Member, alias);
}

View File

@@ -0,0 +1,124 @@
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Changes;
using Umbraco.Cms.Infrastructure.HybridCache.Services;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.HybridCache.NotificationHandlers;
internal sealed class CacheRefreshingNotificationHandler :
INotificationAsyncHandler<ContentRefreshNotification>,
INotificationAsyncHandler<ContentDeletedNotification>,
INotificationAsyncHandler<MediaRefreshNotification>,
INotificationAsyncHandler<MediaDeletedNotification>,
INotificationAsyncHandler<ContentTypeRefreshedNotification>
{
private readonly IDocumentCacheService _documentCacheService;
private readonly IMediaCacheService _mediaCacheService;
private readonly IElementsCache _elementsCache;
private readonly IRelationService _relationService;
private readonly IPublishedContentTypeCache _publishedContentTypeCache;
public CacheRefreshingNotificationHandler(
IDocumentCacheService documentCacheService,
IMediaCacheService mediaCacheService,
IElementsCache elementsCache,
IRelationService relationService,
IPublishedContentTypeCache publishedContentTypeCache)
{
_documentCacheService = documentCacheService;
_mediaCacheService = mediaCacheService;
_elementsCache = elementsCache;
_relationService = relationService;
_publishedContentTypeCache = publishedContentTypeCache;
}
public async Task HandleAsync(ContentRefreshNotification notification, CancellationToken cancellationToken)
{
await RefreshElementsCacheAsync(notification.Entity);
await _documentCacheService.RefreshContentAsync(notification.Entity);
}
public async Task HandleAsync(ContentDeletedNotification notification, CancellationToken cancellationToken)
{
foreach (IContent deletedEntity in notification.DeletedEntities)
{
await RefreshElementsCacheAsync(deletedEntity);
await _documentCacheService.DeleteItemAsync(deletedEntity.Id);
}
}
public async Task HandleAsync(MediaRefreshNotification notification, CancellationToken cancellationToken)
{
await RefreshElementsCacheAsync(notification.Entity);
await _mediaCacheService.RefreshMediaAsync(notification.Entity);
}
public async Task HandleAsync(MediaDeletedNotification notification, CancellationToken cancellationToken)
{
foreach (IMedia deletedEntity in notification.DeletedEntities)
{
await RefreshElementsCacheAsync(deletedEntity);
await _mediaCacheService.DeleteItemAsync(deletedEntity.Id);
}
}
private async Task RefreshElementsCacheAsync(IUmbracoEntity content)
{
IEnumerable<IRelation> parentRelations = _relationService.GetByParent(content)!;
IEnumerable<IRelation> childRelations = _relationService.GetByChild(content);
var ids = parentRelations.Select(x => x.ChildId).Concat(childRelations.Select(x => x.ParentId)).ToHashSet();
foreach (var id in ids)
{
if (await _documentCacheService.HasContentByIdAsync(id) is false)
{
continue;
}
IPublishedContent? publishedContent = await _documentCacheService.GetByIdAsync(id);
if (publishedContent is null)
{
continue;
}
foreach (IPublishedProperty publishedProperty in publishedContent.Properties)
{
var property = (PublishedProperty) publishedProperty;
if (property.ReferenceCacheLevel != PropertyCacheLevel.Elements)
{
continue;
}
_elementsCache.ClearByKey(property.ValuesCacheKey);
}
}
}
public Task HandleAsync(ContentTypeRefreshedNotification notification, CancellationToken cancellationToken)
{
const ContentTypeChangeTypes types // only for those that have been refreshed
= ContentTypeChangeTypes.RefreshMain | ContentTypeChangeTypes.RefreshOther | ContentTypeChangeTypes.Remove;
var contentTypeIds = notification.Changes.Where(x => x.ChangeTypes.HasTypesAny(types)).Select(x => x.Item.Id)
.ToArray();
if (contentTypeIds.Length != 0)
{
foreach (var contentTypeId in contentTypeIds)
{
_publishedContentTypeCache.ClearContentType(contentTypeId);
}
_documentCacheService.Rebuild(contentTypeIds);
}
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,21 @@
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Infrastructure.HybridCache.Services;
namespace Umbraco.Cms.Infrastructure.HybridCache.NotificationHandlers;
internal class SeedingNotificationHandler : INotificationAsyncHandler<UmbracoApplicationStartedNotification>
{
private readonly IDocumentCacheService _documentCacheService;
private readonly CacheSettings _cacheSettings;
public SeedingNotificationHandler(IDocumentCacheService documentCacheService, IOptions<CacheSettings> cacheSettings)
{
_documentCacheService = documentCacheService;
_cacheSettings = cacheSettings.Value;
}
public async Task HandleAsync(UmbracoApplicationStartedNotification notification, CancellationToken cancellationToken) => await _documentCacheService.SeedAsync(_cacheSettings.ContentTypeKeys);
}

View File

@@ -0,0 +1,67 @@
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Infrastructure.HybridCache.Persistence
{
// read-only dto
internal class ContentSourceDto : IReadOnlyContentBase
{
public int Id { get; init; }
public Guid Key { get; init; }
public int ContentTypeId { get; init; }
public int Level { get; init; }
public string Path { get; init; } = string.Empty;
public int SortOrder { get; init; }
public int ParentId { get; init; }
public bool Published { get; init; }
public bool Edited { get; init; }
public DateTime CreateDate { get; init; }
public int CreatorId { get; init; }
// edited data
public int VersionId { get; init; }
public string? EditName { get; init; }
public DateTime EditVersionDate { get; init; }
public int EditWriterId { get; init; }
public int EditTemplateId { get; init; }
public string? EditData { get; init; }
public byte[]? EditDataRaw { get; init; }
// published data
public int PublishedVersionId { get; init; }
public string? PubName { get; init; }
public DateTime PubVersionDate { get; init; }
public int PubWriterId { get; init; }
public int PubTemplateId { get; init; }
public string? PubData { get; init; }
public byte[]? PubDataRaw { get; init; }
// Explicit implementation
DateTime IReadOnlyContentBase.UpdateDate => EditVersionDate;
string? IReadOnlyContentBase.Name => EditName;
int IReadOnlyContentBase.WriterId => EditWriterId;
}
}

View File

@@ -0,0 +1,895 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NPoco;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Persistence.Querying;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Strings;
using Umbraco.Cms.Infrastructure.HybridCache.Serialization;
using Umbraco.Cms.Infrastructure.Persistence;
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement;
using Umbraco.Cms.Infrastructure.Persistence.SqlSyntax;
using Umbraco.Cms.Infrastructure.Scoping;
using Umbraco.Extensions;
using static Umbraco.Cms.Core.Persistence.SqlExtensionsStatics;
namespace Umbraco.Cms.Infrastructure.HybridCache.Persistence;
internal sealed class DatabaseCacheRepository : RepositoryBase, IDatabaseCacheRepository
{
private readonly IContentCacheDataSerializerFactory _contentCacheDataSerializerFactory;
private readonly IDocumentRepository _documentRepository;
private readonly ILogger<DatabaseCacheRepository> _logger;
private readonly IMediaRepository _mediaRepository;
private readonly IMemberRepository _memberRepository;
private readonly IOptions<NuCacheSettings> _nucacheSettings;
private readonly IShortStringHelper _shortStringHelper;
private readonly UrlSegmentProviderCollection _urlSegmentProviders;
/// <summary>
/// Initializes a new instance of the <see cref="DatabaseCacheRepository" /> class.
/// </summary>
public DatabaseCacheRepository(
IScopeAccessor scopeAccessor,
AppCaches appCaches,
ILogger<DatabaseCacheRepository> logger,
IMemberRepository memberRepository,
IDocumentRepository documentRepository,
IMediaRepository mediaRepository,
IShortStringHelper shortStringHelper,
UrlSegmentProviderCollection urlSegmentProviders,
IContentCacheDataSerializerFactory contentCacheDataSerializerFactory,
IOptions<NuCacheSettings> nucacheSettings)
: base(scopeAccessor, appCaches)
{
_logger = logger;
_memberRepository = memberRepository;
_documentRepository = documentRepository;
_mediaRepository = mediaRepository;
_shortStringHelper = shortStringHelper;
_urlSegmentProviders = urlSegmentProviders;
_contentCacheDataSerializerFactory = contentCacheDataSerializerFactory;
_nucacheSettings = nucacheSettings;
}
public async Task DeleteContentItemAsync(int id)
=> await Database.ExecuteAsync("DELETE FROM cmsContentNu WHERE nodeId=@id", new { id = id });
public async Task RefreshContentAsync(ContentCacheNode contentCacheNode, PublishedState publishedState)
{
IContentCacheDataSerializer serializer = _contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Document);
// always refresh the edited data
await OnRepositoryRefreshed(serializer, contentCacheNode, true);
switch (publishedState)
{
case PublishedState.Publishing:
await OnRepositoryRefreshed(serializer, contentCacheNode, false);
break;
case PublishedState.Unpublishing:
await Database.ExecuteAsync("DELETE FROM cmsContentNu WHERE nodeId=@id AND published=1", new { id = contentCacheNode.Id });
break;
}
}
public async Task RefreshMediaAsync(ContentCacheNode contentCacheNode)
{
IContentCacheDataSerializer serializer = _contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Media);
await OnRepositoryRefreshed(serializer, contentCacheNode, false);
}
/// <inheritdoc/>
public void Rebuild(
IReadOnlyCollection<int>? contentTypeIds = null,
IReadOnlyCollection<int>? mediaTypeIds = null,
IReadOnlyCollection<int>? memberTypeIds = null)
{
IContentCacheDataSerializer serializer = _contentCacheDataSerializerFactory.Create(
ContentCacheDataSerializerEntityType.Document
| ContentCacheDataSerializerEntityType.Media
| ContentCacheDataSerializerEntityType.Member);
// If contentTypeIds, mediaTypeIds and memberTypeIds are null, truncate table as all records will be deleted (as these 3 are the only types in the table).
if (contentTypeIds != null && !contentTypeIds.Any()
&& mediaTypeIds != null && !mediaTypeIds.Any()
&& memberTypeIds != null && !memberTypeIds.Any())
{
if (Database.DatabaseType == DatabaseType.SqlServer2012)
{
Database.Execute($"TRUNCATE TABLE cmsContentNu");
}
if (Database.DatabaseType == DatabaseType.SQLite)
{
Database.Execute($"DELETE FROM cmsContentNu");
}
}
if (contentTypeIds != null)
{
RebuildContentDbCache(serializer, _nucacheSettings.Value.SqlPageSize, contentTypeIds);
}
if (mediaTypeIds != null)
{
RebuildMediaDbCache(serializer, _nucacheSettings.Value.SqlPageSize, mediaTypeIds);
}
if (memberTypeIds != null)
{
RebuildMemberDbCache(serializer, _nucacheSettings.Value.SqlPageSize, memberTypeIds);
}
}
// assumes content tree lock
public bool VerifyContentDbCache()
{
// every document should have a corresponding row for edited properties
// and if published, may have a corresponding row for published properties
Guid contentObjectType = Constants.ObjectTypes.Document;
var count = Database.ExecuteScalar<int>(
$@"SELECT COUNT(*)
FROM umbracoNode
JOIN {Constants.DatabaseSchema.Tables.Document} ON umbracoNode.id={Constants.DatabaseSchema.Tables.Document}.nodeId
LEFT JOIN cmsContentNu nuEdited ON (umbracoNode.id=nuEdited.nodeId AND nuEdited.published=0)
LEFT JOIN cmsContentNu nuPublished ON (umbracoNode.id=nuPublished.nodeId AND nuPublished.published=1)
WHERE umbracoNode.nodeObjectType=@objType
AND nuEdited.nodeId IS NULL OR ({Constants.DatabaseSchema.Tables.Document}.published=1 AND nuPublished.nodeId IS NULL);",
new { objType = contentObjectType });
return count == 0;
}
// assumes media tree lock
public bool VerifyMediaDbCache()
{
// every media item should have a corresponding row for edited properties
Guid mediaObjectType = Constants.ObjectTypes.Media;
var count = Database.ExecuteScalar<int>(
@"SELECT COUNT(*)
FROM umbracoNode
LEFT JOIN cmsContentNu ON (umbracoNode.id=cmsContentNu.nodeId AND cmsContentNu.published=0)
WHERE umbracoNode.nodeObjectType=@objType
AND cmsContentNu.nodeId IS NULL
",
new { objType = mediaObjectType });
return count == 0;
}
// assumes member tree lock
public bool VerifyMemberDbCache()
{
// every member item should have a corresponding row for edited properties
Guid memberObjectType = Constants.ObjectTypes.Member;
var count = Database.ExecuteScalar<int>(
@"SELECT COUNT(*)
FROM umbracoNode
LEFT JOIN cmsContentNu ON (umbracoNode.id=cmsContentNu.nodeId AND cmsContentNu.published=0)
WHERE umbracoNode.nodeObjectType=@objType
AND cmsContentNu.nodeId IS NULL
",
new { objType = memberObjectType });
return count == 0;
}
public async Task<ContentCacheNode?> GetContentSourceAsync(int id, bool preview = false)
{
Sql<ISqlContext>? sql = SqlContentSourcesSelect()
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document))
.Append(SqlWhereNodeId(SqlContext, id))
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
ContentSourceDto? dto = await Database.FirstOrDefaultAsync<ContentSourceDto>(sql);
if (dto == null)
{
return null;
}
if (preview is false && dto.PubDataRaw is null && dto.PubData is null)
{
return null;
}
IContentCacheDataSerializer serializer =
_contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Document);
return CreateContentNodeKit(dto, serializer, preview);
}
public IEnumerable<ContentCacheNode> GetContentByContentTypeKey(IEnumerable<Guid> keys)
{
if (keys.Any() is false)
{
yield break;
}
Sql<ISqlContext>? sql = SqlContentSourcesSelect()
.InnerJoin<NodeDto>("n")
.On<NodeDto, ContentDto>((n, c) => n.NodeId == c.ContentTypeId, "n", "umbracoContent")
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document))
.WhereIn<NodeDto>(x => x.UniqueId, keys,"n")
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
IContentCacheDataSerializer serializer =
_contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Document);
IEnumerable<ContentSourceDto> dtos = GetContentNodeDtos(sql);
foreach (ContentSourceDto row in dtos)
{
yield return CreateContentNodeKit(row, serializer, row.Published is false);
}
}
public async Task<ContentCacheNode?> GetMediaSourceAsync(int id)
{
Sql<ISqlContext>? sql = SqlMediaSourcesSelect()
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Media))
.Append(SqlWhereNodeId(SqlContext, id))
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
ContentSourceDto? dto = await Database.FirstOrDefaultAsync<ContentSourceDto>(sql);
if (dto is null)
{
return null;
}
IContentCacheDataSerializer serializer =
_contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Media);
return CreateMediaNodeKit(dto, serializer);
}
private async Task OnRepositoryRefreshed(IContentCacheDataSerializer serializer, ContentCacheNode content, bool preview)
{
// use a custom SQL to update row version on each update
// db.InsertOrUpdate(dto);
ContentNuDto dto = GetDtoFromCacheNode(content, !preview, serializer);
await Database.InsertOrUpdateAsync(
dto,
"SET data=@data, dataRaw=@dataRaw, rv=rv+1 WHERE nodeId=@id AND published=@published",
new
{
dataRaw = dto.RawData ?? Array.Empty<byte>(),
data = dto.Data,
id = dto.NodeId,
published = dto.Published,
});
}
// assumes content tree lock
private void RebuildContentDbCache(IContentCacheDataSerializer serializer, int groupSize, IReadOnlyCollection<int>? contentTypeIds)
{
Guid contentObjectType = Constants.ObjectTypes.Document;
// remove all - if anything fails the transaction will rollback
if (contentTypeIds == null || contentTypeIds.Count == 0)
{
// must support SQL-CE
Database.Execute(
@"DELETE FROM cmsContentNu
WHERE cmsContentNu.nodeId IN (
SELECT id FROM umbracoNode WHERE umbracoNode.nodeObjectType=@objType
)",
new { objType = contentObjectType });
}
else
{
// assume number of ctypes won't blow IN(...)
// must support SQL-CE
Database.Execute(
$@"DELETE FROM cmsContentNu
WHERE cmsContentNu.nodeId IN (
SELECT id FROM umbracoNode
JOIN {Constants.DatabaseSchema.Tables.Content} ON {Constants.DatabaseSchema.Tables.Content}.nodeId=umbracoNode.id
WHERE umbracoNode.nodeObjectType=@objType
AND {Constants.DatabaseSchema.Tables.Content}.contentTypeId IN (@ctypes)
)",
new { objType = contentObjectType, ctypes = contentTypeIds });
}
// insert back - if anything fails the transaction will rollback
IQuery<IContent> query = SqlContext.Query<IContent>();
if (contentTypeIds != null && contentTypeIds.Count > 0)
{
query = query.WhereIn(x => x.ContentTypeId, contentTypeIds); // assume number of ctypes won't blow IN(...)
}
long pageIndex = 0;
long processed = 0;
long total;
do
{
// the tree is locked, counting and comparing to total is safe
IEnumerable<IContent> descendants =
_documentRepository.GetPage(query, pageIndex++, groupSize, out total, null, Ordering.By("Path"));
var items = new List<ContentNuDto>();
var count = 0;
foreach (IContent c in descendants)
{
// always the edited version
items.Add(GetDtoFromContent(c, false, serializer));
// and also the published version if it makes any sense
if (c.Published)
{
items.Add(GetDtoFromContent(c, true, serializer));
}
count++;
}
Database.BulkInsertRecords(items);
processed += count;
} while (processed < total);
}
// assumes media tree lock
private void RebuildMediaDbCache(IContentCacheDataSerializer serializer, int groupSize,
IReadOnlyCollection<int>? contentTypeIds)
{
Guid mediaObjectType = Constants.ObjectTypes.Media;
// remove all - if anything fails the transaction will rollback
if (contentTypeIds is null || contentTypeIds.Count == 0)
{
// must support SQL-CE
Database.Execute(
@"DELETE FROM cmsContentNu
WHERE cmsContentNu.nodeId IN (
SELECT id FROM umbracoNode WHERE umbracoNode.nodeObjectType=@objType
)",
new { objType = mediaObjectType });
}
else
{
// assume number of ctypes won't blow IN(...)
// must support SQL-CE
Database.Execute(
$@"DELETE FROM cmsContentNu
WHERE cmsContentNu.nodeId IN (
SELECT id FROM umbracoNode
JOIN {Constants.DatabaseSchema.Tables.Content} ON {Constants.DatabaseSchema.Tables.Content}.nodeId=umbracoNode.id
WHERE umbracoNode.nodeObjectType=@objType
AND {Constants.DatabaseSchema.Tables.Content}.contentTypeId IN (@ctypes)
)",
new { objType = mediaObjectType, ctypes = contentTypeIds });
}
// insert back - if anything fails the transaction will rollback
IQuery<IMedia> query = SqlContext.Query<IMedia>();
if (contentTypeIds is not null && contentTypeIds.Count > 0)
{
query = query.WhereIn(x => x.ContentTypeId, contentTypeIds); // assume number of ctypes won't blow IN(...)
}
long pageIndex = 0;
long processed = 0;
long total;
do
{
// the tree is locked, counting and comparing to total is safe
IEnumerable<IMedia> descendants =
_mediaRepository.GetPage(query, pageIndex++, groupSize, out total, null, Ordering.By("Path"));
var items = descendants.Select(m => GetDtoFromContent(m, false, serializer)).ToArray();
Database.BulkInsertRecords(items);
processed += items.Length;
} while (processed < total);
}
// assumes member tree lock
private void RebuildMemberDbCache(IContentCacheDataSerializer serializer, int groupSize,
IReadOnlyCollection<int>? contentTypeIds)
{
Guid memberObjectType = Constants.ObjectTypes.Member;
// remove all - if anything fails the transaction will rollback
if (contentTypeIds == null || contentTypeIds.Count == 0)
{
// must support SQL-CE
Database.Execute(
@"DELETE FROM cmsContentNu
WHERE cmsContentNu.nodeId IN (
SELECT id FROM umbracoNode WHERE umbracoNode.nodeObjectType=@objType
)",
new { objType = memberObjectType });
}
else
{
// assume number of ctypes won't blow IN(...)
// must support SQL-CE
Database.Execute(
$@"DELETE FROM cmsContentNu
WHERE cmsContentNu.nodeId IN (
SELECT id FROM umbracoNode
JOIN {Constants.DatabaseSchema.Tables.Content} ON {Constants.DatabaseSchema.Tables.Content}.nodeId=umbracoNode.id
WHERE umbracoNode.nodeObjectType=@objType
AND {Constants.DatabaseSchema.Tables.Content}.contentTypeId IN (@ctypes)
)",
new { objType = memberObjectType, ctypes = contentTypeIds });
}
// insert back - if anything fails the transaction will rollback
IQuery<IMember> query = SqlContext.Query<IMember>();
if (contentTypeIds != null && contentTypeIds.Count > 0)
{
query = query.WhereIn(x => x.ContentTypeId, contentTypeIds); // assume number of ctypes won't blow IN(...)
}
long pageIndex = 0;
long processed = 0;
long total;
do
{
IEnumerable<IMember> descendants =
_memberRepository.GetPage(query, pageIndex++, groupSize, out total, null, Ordering.By("Path"));
ContentNuDto[] items = descendants.Select(m => GetDtoFromContent(m, false, serializer)).ToArray();
Database.BulkInsertRecords(items);
processed += items.Length;
} while (processed < total);
}
private ContentNuDto GetDtoFromCacheNode(ContentCacheNode cacheNode, bool published, IContentCacheDataSerializer serializer)
{
// the dictionary that will be serialized
var contentCacheData = new ContentCacheDataModel
{
PropertyData = cacheNode.Data?.Properties,
CultureData = cacheNode.Data?.CultureInfos?.ToDictionary(),
UrlSegment = cacheNode.Data?.UrlSegment,
};
// TODO: We should probably fix all serialization to only take ContentTypeId, for now it takes an IReadOnlyContentBase
// but it is only the content type id that is needed.
ContentCacheDataSerializationResult serialized = serializer.Serialize(new ContentSourceDto { ContentTypeId = cacheNode.ContentTypeId, }, contentCacheData, published);
var dto = new ContentNuDto
{
NodeId = cacheNode.Id, Published = published, Data = serialized.StringData, RawData = serialized.ByteData,
};
return dto;
}
private ContentNuDto GetDtoFromContent(IContentBase content, bool published, IContentCacheDataSerializer serializer)
{
// should inject these in ctor
// BUT for the time being we decide not to support ConvertDbToXml/String
// var propertyEditorResolver = PropertyEditorResolver.Current;
// var dataTypeService = ApplicationContext.Current.Services.DataTypeService;
var propertyData = new Dictionary<string, PropertyData[]>();
foreach (IProperty prop in content.Properties)
{
var pdatas = new List<PropertyData>();
foreach (IPropertyValue pvalue in prop.Values.OrderBy(x => x.Culture))
{
// sanitize - properties should be ok but ... never knows
if (!prop.PropertyType.SupportsVariation(pvalue.Culture, pvalue.Segment))
{
continue;
}
// note: at service level, invariant is 'null', but here invariant becomes 'string.Empty'
var value = published ? pvalue.PublishedValue : pvalue.EditedValue;
if (value != null)
{
pdatas.Add(new PropertyData
{
Culture = pvalue.Culture ?? string.Empty,
Segment = pvalue.Segment ?? string.Empty,
Value = value,
});
}
}
propertyData[prop.Alias] = pdatas.ToArray();
}
var cultureData = new Dictionary<string, CultureVariation>();
// sanitize - names should be ok but ... never knows
if (content.ContentType.VariesByCulture())
{
ContentCultureInfosCollection? infos = content is IContent document
? published
? document.PublishCultureInfos
: document.CultureInfos
: content.CultureInfos;
// ReSharper disable once UseDeconstruction
if (infos is not null)
{
foreach (ContentCultureInfos cultureInfo in infos)
{
var cultureIsDraft = !published && content is IContent d && d.IsCultureEdited(cultureInfo.Culture);
cultureData[cultureInfo.Culture] = new CultureVariation
{
Name = cultureInfo.Name,
UrlSegment =
content.GetUrlSegment(_shortStringHelper, _urlSegmentProviders, cultureInfo.Culture),
Date = content.GetUpdateDate(cultureInfo.Culture) ?? DateTime.MinValue,
IsDraft = cultureIsDraft,
};
}
}
}
// the dictionary that will be serialized
var contentCacheData = new ContentCacheDataModel
{
PropertyData = propertyData,
CultureData = cultureData,
UrlSegment = content.GetUrlSegment(_shortStringHelper, _urlSegmentProviders),
};
ContentCacheDataSerializationResult serialized =
serializer.Serialize(ReadOnlyContentBaseAdapter.Create(content), contentCacheData, published);
var dto = new ContentNuDto
{
NodeId = content.Id, Published = published, Data = serialized.StringData, RawData = serialized.ByteData,
};
return dto;
}
// we want arrays, we want them all loaded, not an enumerable
private Sql<ISqlContext> SqlContentSourcesSelect(Func<ISqlContext, Sql<ISqlContext>>? joins = null)
{
SqlTemplate sqlTemplate = SqlContext.Templates.Get(
Constants.SqlTemplates.NuCacheDatabaseDataSource.ContentSourcesSelect,
tsql =>
tsql.Select<NodeDto>(
x => Alias(x.NodeId, "Id"),
x => Alias(x.UniqueId, "Key"),
x => Alias(x.Level, "Level"),
x => Alias(x.Path, "Path"),
x => Alias(x.SortOrder, "SortOrder"),
x => Alias(x.ParentId, "ParentId"),
x => Alias(x.CreateDate, "CreateDate"),
x => Alias(x.UserId, "CreatorId"))
.AndSelect<ContentDto>(x => Alias(x.ContentTypeId, "ContentTypeId"))
.AndSelect<DocumentDto>(x => Alias(x.Published, "Published"), x => Alias(x.Edited, "Edited"))
.AndSelect<ContentVersionDto>(
x => Alias(x.Id, "VersionId"),
x => Alias(x.Text, "EditName"),
x => Alias(x.VersionDate, "EditVersionDate"),
x => Alias(x.UserId, "EditWriterId"))
.AndSelect<DocumentVersionDto>(x => Alias(x.TemplateId, "EditTemplateId"))
.AndSelect<ContentVersionDto>(
"pcver",
x => Alias(x.Id, "PublishedVersionId"),
x => Alias(x.Text, "PubName"),
x => Alias(x.VersionDate, "PubVersionDate"),
x => Alias(x.UserId, "PubWriterId"))
.AndSelect<DocumentVersionDto>("pdver", x => Alias(x.TemplateId, "PubTemplateId"))
.AndSelect<ContentNuDto>("nuEdit", x => Alias(x.Data, "EditData"))
.AndSelect<ContentNuDto>("nuPub", x => Alias(x.Data, "PubData"))
.AndSelect<ContentNuDto>("nuEdit", x => Alias(x.RawData, "EditDataRaw"))
.AndSelect<ContentNuDto>("nuPub", x => Alias(x.RawData, "PubDataRaw"))
.From<NodeDto>());
Sql<ISqlContext>? sql = sqlTemplate.Sql();
// TODO: I'm unsure how we can format the below into SQL templates also because right.Current and right.Published end up being parameters
if (joins != null)
{
sql = sql.Append(joins(sql.SqlContext));
}
sql = sql
.InnerJoin<ContentDto>().On<NodeDto, ContentDto>((left, right) => left.NodeId == right.NodeId)
.InnerJoin<DocumentDto>().On<NodeDto, DocumentDto>((left, right) => left.NodeId == right.NodeId)
.InnerJoin<ContentVersionDto>()
.On<NodeDto, ContentVersionDto>((left, right) => left.NodeId == right.NodeId && right.Current)
.InnerJoin<DocumentVersionDto>()
.On<ContentVersionDto, DocumentVersionDto>((left, right) => left.Id == right.Id)
.LeftJoin<ContentVersionDto>(
j =>
j.InnerJoin<DocumentVersionDto>("pdver")
.On<ContentVersionDto, DocumentVersionDto>(
(left, right) => left.Id == right.Id && right.Published == true, "pcver", "pdver"),
"pcver")
.On<NodeDto, ContentVersionDto>((left, right) => left.NodeId == right.NodeId, aliasRight: "pcver")
.LeftJoin<ContentNuDto>("nuEdit").On<NodeDto, ContentNuDto>(
(left, right) => left.NodeId == right.NodeId && right.Published == false, aliasRight: "nuEdit")
.LeftJoin<ContentNuDto>("nuPub").On<NodeDto, ContentNuDto>(
(left, right) => left.NodeId == right.NodeId && right.Published == true, aliasRight: "nuPub");
return sql;
}
private Sql<ISqlContext> SqlContentSourcesSelectUmbracoNodeJoin(ISqlContext sqlContext)
{
ISqlSyntaxProvider syntax = sqlContext.SqlSyntax;
SqlTemplate sqlTemplate = sqlContext.Templates.Get(
Constants.SqlTemplates.NuCacheDatabaseDataSource.SourcesSelectUmbracoNodeJoin, builder =>
builder.InnerJoin<NodeDto>("x")
.On<NodeDto, NodeDto>(
(left, right) => left.NodeId == right.NodeId ||
SqlText<bool>(left.Path, right.Path,
(lp, rp) => $"({lp} LIKE {syntax.GetConcat(rp, "',%'")})"),
aliasRight: "x"));
Sql<ISqlContext> sql = sqlTemplate.Sql();
return sql;
}
private Sql<ISqlContext> SqlWhereNodeId(ISqlContext sqlContext, int id)
{
ISqlSyntaxProvider syntax = sqlContext.SqlSyntax;
SqlTemplate sqlTemplate = sqlContext.Templates.Get(
Constants.SqlTemplates.NuCacheDatabaseDataSource.WhereNodeId,
builder =>
builder.Where<NodeDto>(x => x.NodeId == SqlTemplate.Arg<int>("id")));
Sql<ISqlContext> sql = sqlTemplate.Sql(id);
return sql;
}
private Sql<ISqlContext> SqlOrderByLevelIdSortOrder(ISqlContext sqlContext)
{
ISqlSyntaxProvider syntax = sqlContext.SqlSyntax;
SqlTemplate sqlTemplate = sqlContext.Templates.Get(
Constants.SqlTemplates.NuCacheDatabaseDataSource.OrderByLevelIdSortOrder, s =>
s.OrderBy<NodeDto>(x => x.Level, x => x.ParentId, x => x.SortOrder));
Sql<ISqlContext> sql = sqlTemplate.Sql();
return sql;
}
private Sql<ISqlContext> SqlObjectTypeNotTrashed(ISqlContext sqlContext, Guid nodeObjectType)
{
ISqlSyntaxProvider syntax = sqlContext.SqlSyntax;
SqlTemplate sqlTemplate = sqlContext.Templates.Get(
Constants.SqlTemplates.NuCacheDatabaseDataSource.ObjectTypeNotTrashedFilter, s =>
s.Where<NodeDto>(x =>
x.NodeObjectType == SqlTemplate.Arg<Guid?>("nodeObjectType") &&
x.Trashed == SqlTemplate.Arg<bool>("trashed")));
Sql<ISqlContext> sql = sqlTemplate.Sql(nodeObjectType, false);
return sql;
}
/// <summary>
/// Returns a slightly more optimized query to use for the document counting when paging over the content sources
/// </summary>
/// <param name="joins"></param>
/// <returns></returns>
private Sql<ISqlContext> SqlContentSourcesCount(Func<ISqlContext, Sql<ISqlContext>>? joins = null)
{
SqlTemplate sqlTemplate = SqlContext.Templates.Get(
Constants.SqlTemplates.NuCacheDatabaseDataSource.ContentSourcesCount, tsql =>
tsql.Select<NodeDto>(x => Alias(x.NodeId, "Id"))
.From<NodeDto>()
.InnerJoin<ContentDto>().On<NodeDto, ContentDto>((left, right) => left.NodeId == right.NodeId)
.InnerJoin<DocumentDto>().On<NodeDto, DocumentDto>((left, right) => left.NodeId == right.NodeId));
Sql<ISqlContext>? sql = sqlTemplate.Sql();
if (joins != null)
{
sql = sql.Append(joins(sql.SqlContext));
}
// TODO: We can't use a template with this one because of the 'right.Current' and 'right.Published' ends up being a parameter so not sure how we can do that
sql = sql
.InnerJoin<ContentVersionDto>()
.On<NodeDto, ContentVersionDto>((left, right) => left.NodeId == right.NodeId && right.Current)
.InnerJoin<DocumentVersionDto>()
.On<ContentVersionDto, DocumentVersionDto>((left, right) => left.Id == right.Id)
.LeftJoin<ContentVersionDto>(
j =>
j.InnerJoin<DocumentVersionDto>("pdver")
.On<ContentVersionDto, DocumentVersionDto>(
(left, right) => left.Id == right.Id && right.Published,
"pcver",
"pdver"),
"pcver")
.On<NodeDto, ContentVersionDto>((left, right) => left.NodeId == right.NodeId, aliasRight: "pcver");
return sql;
}
private Sql<ISqlContext> SqlMediaSourcesSelect(Func<ISqlContext, Sql<ISqlContext>>? joins = null)
{
SqlTemplate sqlTemplate = SqlContext.Templates.Get(
Constants.SqlTemplates.NuCacheDatabaseDataSource.MediaSourcesSelect, tsql =>
tsql.Select<NodeDto>(
x => Alias(x.NodeId, "Id"),
x => Alias(x.UniqueId, "Key"),
x => Alias(x.Level, "Level"),
x => Alias(x.Path, "Path"),
x => Alias(x.SortOrder, "SortOrder"),
x => Alias(x.ParentId, "ParentId"),
x => Alias(x.CreateDate, "CreateDate"),
x => Alias(x.UserId, "CreatorId"))
.AndSelect<ContentDto>(x => Alias(x.ContentTypeId, "ContentTypeId"))
.AndSelect<ContentVersionDto>(
x => Alias(x.Id, "VersionId"),
x => Alias(x.Text, "EditName"),
x => Alias(x.VersionDate, "EditVersionDate"),
x => Alias(x.UserId, "EditWriterId"))
.AndSelect<ContentNuDto>("nuEdit", x => Alias(x.Data, "EditData"))
.AndSelect<ContentNuDto>("nuEdit", x => Alias(x.RawData, "EditDataRaw"))
.From<NodeDto>());
Sql<ISqlContext>? sql = sqlTemplate.Sql();
if (joins != null)
{
sql = sql.Append(joins(sql.SqlContext));
}
// TODO: We can't use a template with this one because of the 'right.Published' ends up being a parameter so not sure how we can do that
sql = sql
.InnerJoin<ContentDto>().On<NodeDto, ContentDto>((left, right) => left.NodeId == right.NodeId)
.InnerJoin<ContentVersionDto>()
.On<NodeDto, ContentVersionDto>((left, right) => left.NodeId == right.NodeId && right.Current)
.LeftJoin<ContentNuDto>("nuEdit")
.On<NodeDto, ContentNuDto>(
(left, right) => left.NodeId == right.NodeId && !right.Published,
aliasRight: "nuEdit");
return sql;
}
private ContentCacheNode CreateContentNodeKit(ContentSourceDto dto, IContentCacheDataSerializer serializer, bool preview)
{
if (preview)
{
if (dto.EditData == null && dto.EditDataRaw == null)
{
if (Debugger.IsAttached)
{
throw new InvalidOperationException("Missing cmsContentNu edited content for node " + dto.Id +
", consider rebuilding.");
}
_logger.LogWarning(
"Missing cmsContentNu edited content for node {NodeId}, consider rebuilding.",
dto.Id);
}
else
{
bool published = false;
ContentCacheDataModel? deserializedDraftContent =
serializer.Deserialize(dto, dto.EditData, dto.EditDataRaw, published);
var draftContentData = new ContentData(
dto.EditName,
null,
dto.VersionId,
dto.EditVersionDate,
dto.CreatorId,
dto.EditTemplateId == 0 ? null : dto.EditTemplateId,
published,
deserializedDraftContent?.PropertyData,
deserializedDraftContent?.CultureData);
return new ContentCacheNode
{
Id = dto.Id,
Key = dto.Key,
SortOrder = dto.SortOrder,
CreateDate = dto.CreateDate,
CreatorId = dto.CreatorId,
ContentTypeId = dto.ContentTypeId,
Data = draftContentData,
IsDraft = true,
};
}
}
if (dto.PubData == null && dto.PubDataRaw == null)
{
if (Debugger.IsAttached)
{
throw new InvalidOperationException("Missing cmsContentNu published content for node " + dto.Id +
", consider rebuilding.");
}
_logger.LogWarning(
"Missing cmsContentNu published content for node {NodeId}, consider rebuilding.",
dto.Id);
}
ContentCacheDataModel? deserializedContent = serializer.Deserialize(dto, dto.PubData, dto.PubDataRaw, true);
var publishedContentData = new ContentData(
dto.PubName,
null,
dto.VersionId,
dto.PubVersionDate,
dto.CreatorId,
dto.EditTemplateId == 0 ? null : dto.EditTemplateId,
true,
deserializedContent?.PropertyData,
deserializedContent?.CultureData);
return new ContentCacheNode
{
Id = dto.Id,
Key = dto.Key,
SortOrder = dto.SortOrder,
CreateDate = dto.CreateDate,
CreatorId = dto.CreatorId,
ContentTypeId = dto.ContentTypeId,
Data = publishedContentData,
IsDraft = false,
};
}
private ContentCacheNode CreateMediaNodeKit(ContentSourceDto dto, IContentCacheDataSerializer serializer)
{
if (dto.EditData == null && dto.EditDataRaw == null)
{
throw new InvalidOperationException("No data for media " + dto.Id);
}
ContentCacheDataModel? deserializedMedia = serializer.Deserialize(dto, dto.EditData, dto.EditDataRaw, true);
var publishedContentData = new ContentData(
dto.EditName,
null,
dto.VersionId,
dto.EditVersionDate,
dto.CreatorId,
dto.EditTemplateId == 0 ? null : dto.EditTemplateId,
true,
deserializedMedia?.PropertyData,
deserializedMedia?.CultureData);
return new ContentCacheNode
{
Id = dto.Id,
Key = dto.Key,
SortOrder = dto.SortOrder,
CreateDate = dto.CreateDate,
CreatorId = dto.CreatorId,
ContentTypeId = dto.ContentTypeId,
Data = publishedContentData,
IsDraft = false,
};
}
private IEnumerable<ContentSourceDto> GetContentNodeDtos(Sql<ISqlContext> sql)
{
// We need to page here. We don't want to iterate over every single row in one connection cuz this can cause an SQL Timeout.
// We also want to read with a db reader and not load everything into memory, QueryPaged lets us do that.
// QueryPaged is very slow on large sites however, so use fetch if UsePagedSqlQuery is disabled.
IEnumerable<ContentSourceDto> dtos;
if (_nucacheSettings.Value.UsePagedSqlQuery)
{
// Use a more efficient COUNT query
Sql<ISqlContext>? sqlCountQuery = SqlContentSourcesCount()
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document));
Sql<ISqlContext>? sqlCount =
SqlContext.Sql("SELECT COUNT(*) FROM (").Append(sqlCountQuery).Append(") npoco_tbl");
dtos = Database.QueryPaged<ContentSourceDto>(_nucacheSettings.Value.SqlPageSize, sql, sqlCount);
}
else
{
dtos = Database.Fetch<ContentSourceDto>(sql);
}
return dtos;
}
}

View File

@@ -0,0 +1,57 @@
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Infrastructure.HybridCache.Persistence;
internal interface IDatabaseCacheRepository
{
Task DeleteContentItemAsync(int id);
Task<ContentCacheNode?> GetContentSourceAsync(int id, bool preview = false);
Task<ContentCacheNode?> GetMediaSourceAsync(int id);
IEnumerable<ContentCacheNode> GetContentByContentTypeKey(IEnumerable<Guid> keys);
/// <summary>
/// Refreshes the nucache database row for the given cache node />
/// </summary>
/// <returns><placeholder>A <see cref="Task"/> representing the asynchronous operation.</placeholder></returns>
Task RefreshContentAsync(ContentCacheNode contentCacheNode, PublishedState publishedState);
/// <summary>
/// Refreshes the nucache database row for the given cache node />
/// </summary>
/// <returns><placeholder>A <see cref="Task"/> representing the asynchronous operation.</placeholder></returns>
Task RefreshMediaAsync(ContentCacheNode contentCacheNode);
/// <summary>
/// Rebuilds the caches for content, media and/or members based on the content type ids specified
/// </summary>
/// <param name="contentTypeIds">
/// If not null will process content for the matching content types, if empty will process all
/// content
/// </param>
/// <param name="mediaTypeIds">
/// If not null will process content for the matching media types, if empty will process all
/// media
/// </param>
/// <param name="memberTypeIds">
/// If not null will process content for the matching members types, if empty will process all
/// members
/// </param>
void Rebuild(
IReadOnlyCollection<int>? contentTypeIds = null,
IReadOnlyCollection<int>? mediaTypeIds = null,
IReadOnlyCollection<int>? memberTypeIds = null);
/// <summary>
/// Verifies the content cache by asserting that every document should have a corresponding row for edited properties and if published,
/// may have a corresponding row for published properties
/// </summary>
bool VerifyContentDbCache();
/// <summary>
/// Rebuilds the caches for content, media and/or members based on the content type ids specified
/// </summary>
bool VerifyMediaDbCache();
}

View File

@@ -0,0 +1,43 @@
using System.ComponentModel;
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using Umbraco.Cms.Infrastructure.Serialization;
namespace Umbraco.Cms.Infrastructure.HybridCache;
// This is for cache performance reasons, see https://learn.microsoft.com/en-us/aspnet/core/performance/caching/hybrid?view=aspnetcore-9.0#reuse-objects
[ImmutableObject(true)]
[DataContract] // NOTE: Use DataContract annotations here to control how MessagePack serializes/deserializes the data to use INT keys
public sealed class PropertyData
{
private string? _culture;
private string? _segment;
[DataMember(Order = 0)]
[JsonConverter(typeof(JsonStringInternConverter))]
[DefaultValue("")]
[JsonPropertyName("c")]
public string? Culture
{
get => _culture;
set => _culture =
value ?? throw new ArgumentNullException(
nameof(value)); // TODO: or fallback to string.Empty? CANNOT be null
}
[DataMember(Order = 1)]
[JsonConverter(typeof(JsonStringInternConverter))]
[DefaultValue("")]
[JsonPropertyName("s")]
public string? Segment
{
get => _segment;
set => _segment =
value ?? throw new ArgumentNullException(
nameof(value)); // TODO: or fallback to string.Empty? CANNOT be null
}
[DataMember(Order = 2)]
[JsonPropertyName("v")]
public object? Value { get; set; }
}

View File

@@ -0,0 +1,195 @@
using Umbraco.Cms.Core.Exceptions;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.HybridCache;
internal class PublishedContent : PublishedContentBase
{
private IPublishedProperty[] _properties;
private readonly ContentNode _contentNode;
private IReadOnlyDictionary<string, PublishedCultureInfo>? _cultures;
private readonly string? _urlSegment;
private readonly IReadOnlyDictionary<string, CultureVariation>? _cultureInfos;
private readonly string _contentName;
private readonly bool _published;
public PublishedContent(
ContentNode contentNode,
bool preview,
IElementsCache elementsCache,
IVariationContextAccessor variationContextAccessor)
: base(variationContextAccessor)
{
VariationContextAccessor = variationContextAccessor;
_contentNode = contentNode;
ContentData? contentData = preview ? _contentNode.DraftModel : _contentNode.PublishedModel;
if (contentData is null)
{
throw new ArgumentNullException(nameof(contentData));
}
_cultureInfos = contentData.CultureInfos;
_contentName = contentData.Name;
_urlSegment = contentData.UrlSegment;
_published = contentData.Published;
var properties = new IPublishedProperty[_contentNode.ContentType.PropertyTypes.Count()];
var i = 0;
foreach (IPublishedPropertyType propertyType in _contentNode.ContentType.PropertyTypes)
{
// add one property per property type - this is required, for the indexing to work
// if contentData supplies pdatas, use them, else use null
contentData.Properties.TryGetValue(propertyType.Alias, out PropertyData[]? propertyDatas); // else will be null
properties[i++] = new PublishedProperty(propertyType, this, propertyDatas, elementsCache, propertyType.CacheLevel);
}
_properties = properties;
Id = contentNode.Id;
Key = contentNode.Key;
CreatorId = contentNode.CreatorId;
CreateDate = contentNode.CreateDate;
SortOrder = contentNode.SortOrder;
WriterId = contentData.WriterId;
TemplateId = contentData.TemplateId;
UpdateDate = contentData.VersionDate;
}
public override IPublishedContentType ContentType => _contentNode.ContentType;
public override Guid Key { get; }
public override IEnumerable<IPublishedProperty> Properties => _properties;
public override int Id { get; }
public override int SortOrder { get; }
// TODO: Remove path.
public override string Path => string.Empty;
public override int? TemplateId { get; }
public override int CreatorId { get; }
public override DateTime CreateDate { get; }
public override int WriterId { get; }
public override DateTime UpdateDate { get; }
public bool IsPreviewing { get; } = false;
// Needed for publishedProperty
internal IVariationContextAccessor VariationContextAccessor { get; }
public override int Level { get; } = 0;
public override IEnumerable<IPublishedContent> ChildrenForAllCultures { get; } = Enumerable.Empty<IPublishedContent>();
public override IPublishedContent? Parent { get; } = null!;
/// <inheritdoc />
public override IReadOnlyDictionary<string, PublishedCultureInfo> Cultures
{
get
{
if (_cultures != null)
{
return _cultures;
}
if (!ContentType.VariesByCulture())
{
return _cultures = new Dictionary<string, PublishedCultureInfo>
{
{ string.Empty, new PublishedCultureInfo(string.Empty, _contentName, _urlSegment, CreateDate) },
};
}
if (_cultureInfos == null)
{
throw new PanicException("_contentDate.CultureInfos is null.");
}
return _cultures = _cultureInfos
.ToDictionary(
x => x.Key,
x => new PublishedCultureInfo(x.Key, x.Value.Name, x.Value.UrlSegment, x.Value.Date),
StringComparer.OrdinalIgnoreCase);
}
}
/// <inheritdoc/>
public override PublishedItemType ItemType => _contentNode.ContentType.ItemType;
public override IPublishedProperty? GetProperty(string alias)
{
var index = _contentNode.ContentType.GetPropertyIndex(alias);
if (index < 0)
{
return null; // happens when 'alias' does not match a content type property alias
}
// should never happen - properties array must be in sync with property type
if (index >= _properties.Length)
{
throw new IndexOutOfRangeException(
"Index points outside the properties array, which means the properties array is corrupt.");
}
IPublishedProperty property = _properties[index];
return property;
}
public override bool IsDraft(string? culture = null)
{
// if this is the 'published' published content, nothing can be draft
if (_published)
{
return false;
}
// not the 'published' published content, and does not vary = must be draft
if (!ContentType.VariesByCulture())
{
return true;
}
// handle context culture
culture ??= VariationContextAccessor?.VariationContext?.Culture ?? string.Empty;
// not the 'published' published content, and varies
// = depends on the culture
return _cultureInfos is not null && _cultureInfos.TryGetValue(culture, out CultureVariation? cvar) && cvar.IsDraft;
}
public override bool IsPublished(string? culture = null)
{
// whether we are the 'draft' or 'published' content, need to determine whether
// there is a 'published' version for the specified culture (or at all, for
// invariant content items)
// if there is no 'published' published content, no culture can be published
if (!_contentNode.HasPublished)
{
return false;
}
// if there is a 'published' published content, and does not vary = published
if (!ContentType.VariesByCulture())
{
return true;
}
// handle context culture
culture ??= VariationContextAccessor.VariationContext?.Culture ?? string.Empty;
// there is a 'published' published content, and varies
// = depends on the culture
return _contentNode.HasPublishedCulture(culture);
}
}

View File

@@ -0,0 +1,38 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Cms.Infrastructure.HybridCache;
// note
// the whole PublishedMember thing should be refactored because as soon as a member
// is wrapped on in a model, the inner IMember and all associated properties are lost
internal class PublishedMember : PublishedContent, IPublishedMember
{
private readonly IMember _member;
public PublishedMember(
IMember member,
ContentNode contentNode,
IElementsCache elementsCache,
IVariationContextAccessor variationContextAccessor)
: base(contentNode, false, elementsCache, variationContextAccessor) =>
_member = member;
public string Email => _member.Email;
public string UserName => _member.Username;
public string? Comments => _member.Comments;
public bool IsApproved => _member.IsApproved;
public bool IsLockedOut => _member.IsLockedOut;
public DateTime? LastLockoutDate => _member.LastLockoutDate;
public DateTime CreationDate => _member.CreateDate;
public DateTime? LastLoginDate => _member.LastLoginDate;
public DateTime? LastPasswordChangedDate => _member.LastPasswordChangeDate;
}

View File

@@ -0,0 +1,330 @@
using System.Collections.Concurrent;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Collections;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.HybridCache;
internal class PublishedProperty : PublishedPropertyBase
{
private readonly PublishedContent _content;
private readonly bool _isPreviewing;
private readonly IElementsCache _elementsCache;
private readonly bool _isMember;
private string? _valuesCacheKey;
// the invariant-neutral source and inter values
private readonly object? _sourceValue;
private readonly ContentVariation _variations;
private readonly ContentVariation _sourceVariations;
// the variant and non-variant object values
private bool _interInitialized;
private object? _interValue;
private CacheValues? _cacheValues;
// the variant source and inter values
private readonly object _locko = new();
private ConcurrentDictionary<CompositeStringStringKey, SourceInterValue>? _sourceValues;
// initializes a published content property with a value
public PublishedProperty(
IPublishedPropertyType propertyType,
PublishedContent content,
PropertyData[]? sourceValues,
IElementsCache elementsElementsCache,
PropertyCacheLevel referenceCacheLevel = PropertyCacheLevel.Element)
: base(propertyType, referenceCacheLevel)
{
if (sourceValues != null)
{
foreach (PropertyData sourceValue in sourceValues)
{
if (sourceValue.Culture == string.Empty && sourceValue.Segment == string.Empty)
{
_sourceValue = sourceValue.Value;
}
else
{
EnsureSourceValuesInitialized();
_sourceValues![new CompositeStringStringKey(sourceValue.Culture, sourceValue.Segment)]
= new SourceInterValue
{
Culture = sourceValue.Culture,
Segment = sourceValue.Segment,
SourceValue = sourceValue.Value,
};
}
}
}
_content = content;
_isPreviewing = content.IsPreviewing;
_isMember = content.ContentType.ItemType == PublishedItemType.Member;
_elementsCache = elementsElementsCache;
// this variable is used for contextualizing the variation level when calculating property values.
// it must be set to the union of variance (the combination of content type and property type variance).
_variations = propertyType.Variations | content.ContentType.Variations;
_sourceVariations = propertyType.Variations;
}
// used to cache the CacheValues of this property
internal string ValuesCacheKey => _valuesCacheKey ??= PropertyCacheValues(_content.Key, Alias, _isPreviewing);
private string PropertyCacheValues(Guid contentUid, string typeAlias, bool previewing)
{
if (previewing)
{
return "Cache.Property.CacheValues[D:" + contentUid + ":" + typeAlias + "]";
}
return "Cache.Property.CacheValues[P:" + contentUid + ":" + typeAlias + "]";
}
// determines whether a property has value
public override bool HasValue(string? culture = null, string? segment = null)
{
_content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, ref culture, ref segment);
var value = GetSourceValue(culture, segment);
var hasValue = PropertyType.IsValue(value, PropertyValueLevel.Source);
if (hasValue.HasValue)
{
return hasValue.Value;
}
return PropertyType.IsValue(GetInterValue(culture, segment), PropertyValueLevel.Object) ?? false;
}
public override object? GetSourceValue(string? culture = null, string? segment = null)
{
_content.VariationContextAccessor.ContextualizeVariation(_sourceVariations, _content.Id, ref culture, ref segment);
// source values are tightly bound to the property/schema culture and segment configurations, so we need to
// sanitize the contextualized culture/segment states before using them to access the source values.
culture = _sourceVariations.VariesByCulture() ? culture : string.Empty;
segment = _sourceVariations.VariesBySegment() ? segment : string.Empty;
if (culture == string.Empty && segment == string.Empty)
{
return _sourceValue;
}
if (_sourceValues == null)
{
return null;
}
return _sourceValues.TryGetValue(
new CompositeStringStringKey(culture, segment),
out SourceInterValue? sourceValue)
? sourceValue.SourceValue
: null;
}
private object? GetInterValue(string? culture, string? segment)
{
if (culture is "" && segment is "")
{
if (_interInitialized)
{
return _interValue;
}
_interValue = PropertyType.ConvertSourceToInter(_content, _sourceValue, _isPreviewing);
_interInitialized = true;
return _interValue;
}
return PropertyType.ConvertSourceToInter(_content, GetSourceValue(culture, segment), _isPreviewing);
}
public override object? GetValue(string? culture = null, string? segment = null)
{
_content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, ref culture, ref segment);
object? value;
CacheValue cacheValues = GetCacheValues(PropertyType.CacheLevel).For(culture, segment);
// initial reference cache level always is .Content
const PropertyCacheLevel initialCacheLevel = PropertyCacheLevel.Element;
if (cacheValues.ObjectInitialized)
{
return cacheValues.ObjectValue;
}
cacheValues.ObjectValue = PropertyType.ConvertInterToObject(_content, initialCacheLevel, GetInterValue(culture, segment), _isPreviewing);
cacheValues.ObjectInitialized = true;
value = cacheValues.ObjectValue;
return value;
}
private CacheValues GetCacheValues(PropertyCacheLevel cacheLevel)
{
CacheValues cacheValues;
IAppCache? cache;
switch (cacheLevel)
{
case PropertyCacheLevel.None:
// never cache anything
cacheValues = new CacheValues();
break;
case PropertyCacheLevel.Snapshot: // Snapshot is obsolete, so for now treat as element
case PropertyCacheLevel.Element:
// cache within the property object itself, ie within the content object
cacheValues = _cacheValues ??= new CacheValues();
break;
case PropertyCacheLevel.Elements:
// cache within the elements cache, unless previewing, then use the snapshot or
// elements cache (if we don't want to pollute the elements cache with short-lived
// data) depending on settings
// for members, always cache in the snapshot cache - never pollute elements cache
cache = _isMember == false ? _elementsCache : null;
cacheValues = GetCacheValues(cache);
break;
default:
throw new InvalidOperationException("Invalid cache level.");
}
return cacheValues;
}
private CacheValues GetCacheValues(IAppCache? cache)
{
// no cache, don't cache
if (cache == null)
{
return new CacheValues();
}
return (CacheValues)cache.Get(ValuesCacheKey, () => new CacheValues())!;
}
public override object? GetDeliveryApiValue(bool expanding, string? culture = null, string? segment = null)
{
_content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, ref culture, ref segment);
object? value;
CacheValue cacheValues = GetCacheValues(expanding ? PropertyType.DeliveryApiCacheLevelForExpansion : PropertyType.DeliveryApiCacheLevel).For(culture, segment);
// initial reference cache level always is .Content
const PropertyCacheLevel initialCacheLevel = PropertyCacheLevel.Element;
object? GetDeliveryApiObject() => PropertyType.ConvertInterToDeliveryApiObject(_content, initialCacheLevel, GetInterValue(culture, segment), _isPreviewing, expanding);
value = expanding
? GetDeliveryApiExpandedObject(cacheValues, GetDeliveryApiObject)
: GetDeliveryApiDefaultObject(cacheValues, GetDeliveryApiObject);
return value;
}
private object? GetDeliveryApiDefaultObject(CacheValue cacheValues, Func<object?> getValue)
{
if (cacheValues.DeliveryApiDefaultObjectInitialized == false)
{
cacheValues.DeliveryApiDefaultObjectValue = getValue();
cacheValues.DeliveryApiDefaultObjectInitialized = true;
}
return cacheValues.DeliveryApiDefaultObjectValue;
}
private object? GetDeliveryApiExpandedObject(CacheValue cacheValues, Func<object?> getValue)
{
if (cacheValues.DeliveryApiExpandedObjectInitialized == false)
{
cacheValues.DeliveryApiExpandedObjectValue = getValue();
cacheValues.DeliveryApiExpandedObjectInitialized = true;
}
return cacheValues.DeliveryApiExpandedObjectValue;
}
private class SourceInterValue
{
private string? _culture;
private string? _segment;
public string? Culture
{
get => _culture;
internal set => _culture = value?.ToLowerInvariant();
}
public string? Segment
{
get => _segment;
internal set => _segment = value?.ToLowerInvariant();
}
public object? SourceValue { get; set; }
}
private class CacheValues : CacheValue
{
private readonly object _locko = new();
private ConcurrentDictionary<CompositeStringStringKey, CacheValue>? _values;
public CacheValue For(string? culture, string? segment)
{
if (culture == string.Empty && segment == string.Empty)
{
return this;
}
if (_values == null)
{
lock (_locko)
{
_values ??= InitializeConcurrentDictionary<CompositeStringStringKey, CacheValue>();
}
}
var k = new CompositeStringStringKey(culture, segment);
CacheValue value = _values.GetOrAdd(k, _ => new CacheValue());
return value;
}
}
private class CacheValue
{
public bool ObjectInitialized { get; set; }
public object? ObjectValue { get; set; }
public bool DeliveryApiDefaultObjectInitialized { get; set; }
public object? DeliveryApiDefaultObjectValue { get; set; }
public bool DeliveryApiExpandedObjectInitialized { get; set; }
public object? DeliveryApiExpandedObjectValue { get; set; }
}
private static ConcurrentDictionary<TKey, TValue> InitializeConcurrentDictionary<TKey, TValue>()
where TKey : notnull
=> new(-1, 5);
private void EnsureSourceValuesInitialized()
{
if (_sourceValues is not null)
{
return;
}
lock (_locko)
{
_sourceValues ??= InitializeConcurrentDictionary<CompositeStringStringKey, SourceInterValue>();
}
}
}

View File

@@ -0,0 +1,30 @@
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using MessagePack;
using Umbraco.Cms.Infrastructure.Serialization;
namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization;
/// <summary>
/// The content model stored in the content cache database table serialized as JSON
/// </summary>
[DataContract] // NOTE: Use DataContract annotations here to control how MessagePack serializes/deserializes the data to use INT keys
public sealed class ContentCacheDataModel
{
[DataMember(Order = 0)]
[JsonPropertyName("pd")]
[JsonConverter(typeof(JsonDictionaryStringInternIgnoreCaseConverter<PropertyData[]>))]
[MessagePackFormatter(typeof(MessagePackDictionaryStringInternIgnoreCaseFormatter<PropertyData[]>))]
public Dictionary<string, PropertyData[]>? PropertyData { get; set; }
[DataMember(Order = 1)]
[JsonPropertyName("cd")]
[JsonConverter(typeof(JsonDictionaryStringInternIgnoreCaseConverter<CultureVariation>))]
[MessagePackFormatter(typeof(MessagePackDictionaryStringInternIgnoreCaseFormatter<CultureVariation>))]
public Dictionary<string, CultureVariation>? CultureData { get; set; }
// TODO: Remove this when routing cache is in place
[DataMember(Order = 2)]
[JsonPropertyName("us")]
public string? UrlSegment { get; set; }
}

View File

@@ -0,0 +1,47 @@
namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization;
/// <summary>
/// The serialization result from <see cref="IContentCacheDataSerializer" /> for which the serialized value
/// will be either a string or a byte[]
/// </summary>
public struct ContentCacheDataSerializationResult : IEquatable<ContentCacheDataSerializationResult>
{
public ContentCacheDataSerializationResult(string? stringData, byte[]? byteData)
{
StringData = stringData;
ByteData = byteData;
}
public string? StringData { get; }
public byte[]? ByteData { get; }
public static bool operator ==(ContentCacheDataSerializationResult left, ContentCacheDataSerializationResult right)
=> left.Equals(right);
public static bool operator !=(ContentCacheDataSerializationResult left, ContentCacheDataSerializationResult right)
=> !(left == right);
public override bool Equals(object? obj)
=> obj is ContentCacheDataSerializationResult result && Equals(result);
public bool Equals(ContentCacheDataSerializationResult other)
=> StringData == other.StringData &&
EqualityComparer<byte[]>.Default.Equals(ByteData, other.ByteData);
public override int GetHashCode()
{
var hashCode = 1910544615;
if (StringData is not null)
{
hashCode = (hashCode * -1521134295) + EqualityComparer<string>.Default.GetHashCode(StringData);
}
if (ByteData is not null)
{
hashCode = (hashCode * -1521134295) + EqualityComparer<byte[]>.Default.GetHashCode(ByteData);
}
return hashCode;
}
}

View File

@@ -0,0 +1,9 @@
namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization;
[Flags]
public enum ContentCacheDataSerializerEntityType
{
Document = 1,
Media = 2,
Member = 4,
}

View File

@@ -0,0 +1,22 @@
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization;
/// <summary>
/// Serializes/Deserializes <see cref="ContentCacheDataModel" /> document to the SQL Database as a string
/// </summary>
/// <remarks>
/// Resolved from the <see cref="IContentCacheDataSerializerFactory" />. This cannot be resolved from DI.
/// </remarks>
internal interface IContentCacheDataSerializer
{
/// <summary>
/// Deserialize the data into a <see cref="ContentCacheDataModel" />
/// </summary>
ContentCacheDataModel? Deserialize(IReadOnlyContentBase content, string? stringData, byte[]? byteData, bool published);
/// <summary>
/// Serializes the <see cref="ContentCacheDataModel" />
/// </summary>
ContentCacheDataSerializationResult Serialize(IReadOnlyContentBase content, ContentCacheDataModel model, bool published);
}

View File

@@ -0,0 +1,15 @@
namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization;
internal interface IContentCacheDataSerializerFactory
{
/// <summary>
/// Gets or creates a new instance of <see cref="IContentCacheDataSerializer" />
/// </summary>
/// <returns></returns>
/// <remarks>
/// This method may return the same instance, however this depends on the state of the application and if any
/// underlying data has changed.
/// This method may also be used to initialize anything before a serialization/deserialization session occurs.
/// </remarks>
IContentCacheDataSerializer Create(ContentCacheDataSerializerEntityType types);
}

View File

@@ -0,0 +1,39 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization;
internal class JsonContentNestedDataSerializer : IContentCacheDataSerializer
{
private static readonly JsonSerializerOptions _jsonSerializerOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
/// <inheritdoc />
public ContentCacheDataModel? Deserialize(
IReadOnlyContentBase content,
string? stringData,
byte[]? byteData,
bool published)
{
if (stringData == null && byteData != null)
{
throw new NotSupportedException(
$"{typeof(JsonContentNestedDataSerializer)} does not support byte[] serialization");
}
return JsonSerializer.Deserialize<ContentCacheDataModel>(stringData!, _jsonSerializerOptions);
}
/// <inheritdoc />
public ContentCacheDataSerializationResult Serialize(
IReadOnlyContentBase content,
ContentCacheDataModel model,
bool published)
{
var json = JsonSerializer.Serialize(model, _jsonSerializerOptions);
return new ContentCacheDataSerializationResult(json, null);
}
}

View File

@@ -0,0 +1,8 @@
namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization;
internal class JsonContentNestedDataSerializerFactory : IContentCacheDataSerializerFactory
{
private readonly Lazy<JsonContentNestedDataSerializer> _serializer = new();
public IContentCacheDataSerializer Create(ContentCacheDataSerializerEntityType types) => _serializer.Value;
}

View File

@@ -0,0 +1,105 @@
using System.Diagnostics;
using System.Text;
using K4os.Compression.LZ4;
using Umbraco.Cms.Core.Exceptions;
namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization;
/// <summary>
/// Lazily decompresses a LZ4 Pickler compressed UTF8 string
/// </summary>
[DebuggerDisplay("{Display}")]
internal struct LazyCompressedString
{
private readonly object _locker;
private byte[]? _bytes;
private string? _str;
/// <summary>
/// Constructor
/// </summary>
/// <param name="bytes">LZ4 Pickle compressed UTF8 String</param>
public LazyCompressedString(byte[] bytes)
{
_locker = new object();
_bytes = bytes;
_str = null;
}
/// <summary>
/// Used to display debugging output since ToString() can only be called once
/// </summary>
private string Display
{
get
{
if (_str != null)
{
return $"Decompressed: {_str}";
}
lock (_locker)
{
if (_str != null)
{
// double check
return $"Decompressed: {_str}";
}
if (_bytes == null)
{
// This shouldn't happen
throw new PanicException("Bytes have already been cleared");
}
return $"Compressed Bytes: {_bytes.Length}";
}
}
}
public static implicit operator string(LazyCompressedString l) => l.ToString();
public byte[] GetBytes()
{
if (_bytes == null)
{
throw new InvalidOperationException("The bytes have already been expanded");
}
return _bytes;
}
/// <summary>
/// Returns the decompressed string from the bytes. This methods can only be called once.
/// </summary>
/// <returns></returns>
/// <exception cref="InvalidOperationException">Throws if this is called more than once</exception>
public string DecompressString()
{
if (_str != null)
{
return _str;
}
lock (_locker)
{
if (_str != null)
{
// double check
return _str;
}
if (_bytes == null)
{
throw new InvalidOperationException("Bytes have already been cleared");
}
_str = Encoding.UTF8.GetString(LZ4Pickler.Unpickle(_bytes));
_bytes = null;
}
return _str;
}
public override string ToString() => DecompressString();
}

View File

@@ -0,0 +1,27 @@
using MessagePack;
using MessagePack.Formatters;
namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization;
/// <summary>
/// A MessagePack formatter (deserializer) for a string key dictionary that uses <see cref="StringComparer.OrdinalIgnoreCase" /> for the key string comparison and interns the string.
/// </summary>
/// <typeparam name="TValue">The type of the value.</typeparam>
public sealed class MessagePackDictionaryStringInternIgnoreCaseFormatter<TValue> : DictionaryFormatterBase<string, TValue, Dictionary<string, TValue>, Dictionary<string, TValue>.Enumerator, Dictionary<string, TValue>>
{
/// <inheritdoc />
protected override void Add(Dictionary<string, TValue> collection, int index, string key, TValue value, MessagePackSerializerOptions options)
=> collection.Add(string.Intern(key), value);
/// <inheritdoc />
protected override Dictionary<string, TValue> Complete(Dictionary<string, TValue> intermediateCollection)
=> intermediateCollection;
/// <inheritdoc />
protected override Dictionary<string, TValue>.Enumerator GetSourceEnumerator(Dictionary<string, TValue> source)
=> source.GetEnumerator();
/// <inheritdoc />
protected override Dictionary<string, TValue> Create(int count, MessagePackSerializerOptions options)
=> new(count, StringComparer.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,147 @@
using System.Text;
using K4os.Compression.LZ4;
using MessagePack;
using MessagePack.Resolvers;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.PropertyEditors;
namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization;
/// <summary>
/// Serializes/Deserializes <see cref="ContentCacheDataModel" /> document to the SQL Database as bytes using
/// MessagePack
/// </summary>
internal sealed class MsgPackContentNestedDataSerializer : IContentCacheDataSerializer
{
private readonly MessagePackSerializerOptions _options;
private readonly IPropertyCacheCompression _propertyOptions;
public MsgPackContentNestedDataSerializer(IPropertyCacheCompression propertyOptions)
{
_propertyOptions = propertyOptions ?? throw new ArgumentNullException(nameof(propertyOptions));
MessagePackSerializerOptions? defaultOptions = ContractlessStandardResolver.Options;
IFormatterResolver? resolver = CompositeResolver.Create(
// TODO: We want to be able to intern the strings for aliases when deserializing like we do for Newtonsoft but I'm unsure exactly how
// to do that but it would seem to be with a custom message pack resolver but I haven't quite figured out based on the docs how
// to do that since that is part of the int key -> string mapping operation, might have to see the source code to figure that one out.
// There are docs here on how to build one of these: https://github.com/neuecc/MessagePack-CSharp/blob/master/README.md#low-level-api-imessagepackformattert
// and there are a couple examples if you search on google for them but this will need to be a separate project.
// NOTE: resolver custom types first
// new ContentNestedDataResolver(),
// finally use standard resolver
defaultOptions.Resolver);
_options = defaultOptions
.WithResolver(resolver)
.WithCompression(MessagePackCompression.Lz4BlockArray)
.WithSecurity(MessagePackSecurity.UntrustedData);
}
public ContentCacheDataModel? Deserialize(IReadOnlyContentBase content, string? stringData, byte[]? byteData, bool published)
{
if (byteData != null)
{
ContentCacheDataModel? cacheModel =
MessagePackSerializer.Deserialize<ContentCacheDataModel>(byteData, _options);
Expand(content, cacheModel, published);
return cacheModel;
}
if (stringData != null)
{
// NOTE: We don't really support strings but it's possible if manually used (i.e. tests)
var bin = Convert.FromBase64String(stringData);
ContentCacheDataModel? cacheModel = MessagePackSerializer.Deserialize<ContentCacheDataModel>(bin, _options);
Expand(content, cacheModel, published);
return cacheModel;
}
return null;
}
public ContentCacheDataSerializationResult Serialize(IReadOnlyContentBase content, ContentCacheDataModel model, bool published)
{
Compress(content, model, published);
var bytes = MessagePackSerializer.Serialize(model, _options);
return new ContentCacheDataSerializationResult(null, bytes);
}
public string ToJson(byte[] bin)
{
var json = MessagePackSerializer.ConvertToJson(bin, _options);
return json;
}
/// <summary>
/// Used during serialization to compress properties
/// </summary>
/// <param name="content"></param>
/// <param name="model"></param>
/// <param name="published"></param>
/// <remarks>
/// This will essentially 'double compress' property data. The MsgPack data as a whole will already be compressed
/// but this will go a step further and double compress property data so that it is stored in the nucache file
/// as compressed bytes and therefore will exist in memory as compressed bytes. That is, until the bytes are
/// read/decompressed as a string to be displayed on the front-end. This allows for potentially a significant
/// memory savings but could also affect performance of first rendering pages while decompression occurs.
/// </remarks>
private void Compress(IReadOnlyContentBase content, ContentCacheDataModel model, bool published)
{
if (model.PropertyData is null)
{
return;
}
foreach (KeyValuePair<string, PropertyData[]> propertyAliasToData in model.PropertyData)
{
if (_propertyOptions.IsCompressed(content, propertyAliasToData.Key, published))
{
foreach (PropertyData property in propertyAliasToData.Value.Where(x =>
x.Value != null && x.Value is string))
{
if (property.Value is string propertyValue)
{
property.Value = LZ4Pickler.Pickle(Encoding.UTF8.GetBytes(propertyValue));
}
}
foreach (PropertyData property in propertyAliasToData.Value.Where(x =>
x.Value != null && x.Value is int intVal))
{
property.Value = Convert.ToBoolean((int?)property.Value);
}
}
}
}
/// <summary>
/// Used during deserialization to map the property data as lazy or expand the value
/// </summary>
/// <param name="content"></param>
/// <param name="nestedData"></param>
/// <param name="published"></param>
private void Expand(IReadOnlyContentBase content, ContentCacheDataModel nestedData, bool published)
{
if (nestedData.PropertyData is null)
{
return;
}
foreach (KeyValuePair<string, PropertyData[]> propertyAliasToData in nestedData.PropertyData)
{
if (_propertyOptions.IsCompressed(content, propertyAliasToData.Key, published))
{
foreach (PropertyData property in propertyAliasToData.Value.Where(x => x.Value != null))
{
if (property.Value is byte[] byteArrayValue)
{
property.Value = new LazyCompressedString(byteArrayValue);
}
}
}
}
}
}

View File

@@ -0,0 +1,69 @@
using System.Collections.Concurrent;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Infrastructure.HybridCache.Serialization;
internal class MsgPackContentNestedDataSerializerFactory : IContentCacheDataSerializerFactory
{
private readonly IPropertyCacheCompressionOptions _compressionOptions;
private readonly IContentTypeService _contentTypeService;
private readonly ConcurrentDictionary<(int, string, bool), bool> _isCompressedCache = new();
private readonly IMediaTypeService _mediaTypeService;
private readonly IMemberTypeService _memberTypeService;
private readonly PropertyEditorCollection _propertyEditors;
public MsgPackContentNestedDataSerializerFactory(
IContentTypeService contentTypeService,
IMediaTypeService mediaTypeService,
IMemberTypeService memberTypeService,
PropertyEditorCollection propertyEditors,
IPropertyCacheCompressionOptions compressionOptions)
{
_contentTypeService = contentTypeService;
_mediaTypeService = mediaTypeService;
_memberTypeService = memberTypeService;
_propertyEditors = propertyEditors;
_compressionOptions = compressionOptions;
}
public IContentCacheDataSerializer Create(ContentCacheDataSerializerEntityType types)
{
// Depending on which entity types are being requested, we need to look up those content types
// to initialize the compression options.
// We need to initialize these options now so that any data lookups required are completed and are not done while the content cache
// is performing DB queries which will result in errors since we'll be trying to query with open readers.
// NOTE: The calls to GetAll() below should be cached if the data has not been changed.
var contentTypes = new Dictionary<int, IContentTypeComposition>();
if ((types & ContentCacheDataSerializerEntityType.Document) == ContentCacheDataSerializerEntityType.Document)
{
foreach (IContentType ct in _contentTypeService.GetAll())
{
contentTypes[ct.Id] = ct;
}
}
if ((types & ContentCacheDataSerializerEntityType.Media) == ContentCacheDataSerializerEntityType.Media)
{
foreach (IMediaType ct in _mediaTypeService.GetAll())
{
contentTypes[ct.Id] = ct;
}
}
if ((types & ContentCacheDataSerializerEntityType.Member) == ContentCacheDataSerializerEntityType.Member)
{
foreach (IMemberType ct in _memberTypeService.GetAll())
{
contentTypes[ct.Id] = ct;
}
}
var compression =
new PropertyCacheCompression(_compressionOptions, contentTypes, _propertyEditors, _isCompressedCache);
var serializer = new MsgPackContentNestedDataSerializer(compression);
return serializer;
}
}

View File

@@ -0,0 +1,174 @@
using Microsoft.Extensions.Caching.Hybrid;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.HybridCache.Factories;
using Umbraco.Cms.Infrastructure.HybridCache.Persistence;
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
internal sealed class DocumentCacheService : IDocumentCacheService
{
private readonly IDatabaseCacheRepository _databaseCacheRepository;
private readonly IIdKeyMap _idKeyMap;
private readonly ICoreScopeProvider _scopeProvider;
private readonly Microsoft.Extensions.Caching.Hybrid.HybridCache _hybridCache;
private readonly IPublishedContentFactory _publishedContentFactory;
private readonly ICacheNodeFactory _cacheNodeFactory;
public DocumentCacheService(
IDatabaseCacheRepository databaseCacheRepository,
IIdKeyMap idKeyMap,
ICoreScopeProvider scopeProvider,
Microsoft.Extensions.Caching.Hybrid.HybridCache hybridCache,
IPublishedContentFactory publishedContentFactory,
ICacheNodeFactory cacheNodeFactory)
{
_databaseCacheRepository = databaseCacheRepository;
_idKeyMap = idKeyMap;
_scopeProvider = scopeProvider;
_hybridCache = hybridCache;
_publishedContentFactory = publishedContentFactory;
_cacheNodeFactory = cacheNodeFactory;
}
// TODO: Stop using IdKeyMap for these, but right now we both need key and id for caching..
public async Task<IPublishedContent?> GetByKeyAsync(Guid key, bool preview = false)
{
Attempt<int> idAttempt = _idKeyMap.GetIdForKey(key, UmbracoObjectTypes.Document);
if (idAttempt.Success is false)
{
return null;
}
using ICoreScope scope = _scopeProvider.CreateCoreScope();
ContentCacheNode? contentCacheNode = await _hybridCache.GetOrCreateAsync(
GetCacheKey(key, preview), // Unique key to the cache entry
async cancel => await _databaseCacheRepository.GetContentSourceAsync(idAttempt.Result, preview));
scope.Complete();
return contentCacheNode is null ? null : _publishedContentFactory.ToIPublishedContent(contentCacheNode, preview);
}
public async Task<IPublishedContent?> GetByIdAsync(int id, bool preview = false)
{
Attempt<Guid> keyAttempt = _idKeyMap.GetKeyForId(id, UmbracoObjectTypes.Document);
if (keyAttempt.Success is false)
{
return null;
}
using ICoreScope scope = _scopeProvider.CreateCoreScope();
ContentCacheNode? contentCacheNode = await _hybridCache.GetOrCreateAsync(
GetCacheKey(keyAttempt.Result, preview), // Unique key to the cache entry
async cancel => await _databaseCacheRepository.GetContentSourceAsync(id, preview));
scope.Complete();
return contentCacheNode is null ? null : _publishedContentFactory.ToIPublishedContent(contentCacheNode, preview);
}
public async Task SeedAsync(IReadOnlyCollection<Guid> contentTypeKeys)
{
using ICoreScope scope = _scopeProvider.CreateCoreScope();
IEnumerable<ContentCacheNode> contentCacheNodes = _databaseCacheRepository.GetContentByContentTypeKey(contentTypeKeys);
foreach (ContentCacheNode contentCacheNode in contentCacheNodes)
{
if (contentCacheNode.IsDraft)
{
continue;
}
// TODO: Make these expiration dates configurable.
// Never expire seeded values, we cannot do TimeSpan.MaxValue sadly, so best we can do is a year.
var entryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromDays(365),
LocalCacheExpiration = TimeSpan.FromDays(365),
};
await _hybridCache.SetAsync(
GetCacheKey(contentCacheNode.Key, false),
contentCacheNode,
entryOptions);
}
scope.Complete();
}
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;
}
ContentCacheNode? contentCacheNode = await _hybridCache.GetOrCreateAsync<ContentCacheNode?>(
GetCacheKey(keyAttempt.Result, preview), // Unique key to the cache entry
cancel => ValueTask.FromResult<ContentCacheNode?>(null));
if (contentCacheNode is null)
{
await _hybridCache.RemoveAsync(GetCacheKey(keyAttempt.Result, preview));
}
return contentCacheNode is not 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 _hybridCache.RemoveAsync(GetCacheKey(content.Key, true));
await _databaseCacheRepository.RefreshContentAsync(draftCacheNode, content.PublishedState);
if (content.PublishedState == PublishedState.Publishing)
{
var publishedCacheNode = _cacheNodeFactory.ToContentCacheNode(content, false);
await _hybridCache.RemoveAsync(GetCacheKey(content.Key, false));
await _databaseCacheRepository.RefreshContentAsync(publishedCacheNode, content.PublishedState);
}
scope.Complete();
}
private string GetCacheKey(Guid key, bool preview) => preview ? $"{key}+draft" : $"{key}";
public async Task DeleteItemAsync(int id)
{
using ICoreScope scope = _scopeProvider.CreateCoreScope();
await _databaseCacheRepository.DeleteContentItemAsync(id);
Attempt<Guid> keyAttempt = _idKeyMap.GetKeyForId(id, UmbracoObjectTypes.Document);
await _hybridCache.RemoveAsync(GetCacheKey(keyAttempt.Result, true));
await _hybridCache.RemoveAsync(GetCacheKey(keyAttempt.Result, false));
_idKeyMap.ClearCache(keyAttempt.Result);
_idKeyMap.ClearCache(id);
scope.Complete();
}
public void Rebuild(IReadOnlyCollection<int> contentTypeKeys)
{
using ICoreScope scope = _scopeProvider.CreateCoreScope();
_databaseCacheRepository.Rebuild(contentTypeKeys.ToList());
IEnumerable<ContentCacheNode> contentByContentTypeKey = _databaseCacheRepository.GetContentByContentTypeKey(contentTypeKeys.Select(x => _idKeyMap.GetKeyForId(x, UmbracoObjectTypes.DocumentType).Result));
foreach (ContentCacheNode content in contentByContentTypeKey)
{
_hybridCache.RemoveAsync(GetCacheKey(content.Key, true)).GetAwaiter().GetResult();
if (content.IsDraft is false)
{
_hybridCache.RemoveAsync(GetCacheKey(content.Key, false)).GetAwaiter().GetResult();
}
}
scope.Complete();
}
}

View File

@@ -0,0 +1,111 @@
using System.Collections.Concurrent;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Changes;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
public class DomainCacheService : IDomainCacheService
{
private readonly IDomainService _domainService;
private readonly ICoreScopeProvider _coreScopeProvider;
private readonly ConcurrentDictionary<int, Domain> _domains;
public DomainCacheService(IDomainService domainService, ICoreScopeProvider coreScopeProvider)
{
_domainService = domainService;
_coreScopeProvider = coreScopeProvider;
_domains = new ConcurrentDictionary<int, Domain>();
}
public IEnumerable<Domain> GetAll(bool includeWildcards)
{
return includeWildcards == false
? _domains.Select(x => x.Value).Where(x => x.IsWildcard == false).OrderBy(x => x.SortOrder)
: _domains.Select(x => x.Value).OrderBy(x => x.SortOrder);
}
/// <inheritdoc />
public IEnumerable<Domain> GetAssigned(int documentId, bool includeWildcards = false)
{
// probably this could be optimized with an index
// but then we'd need a custom DomainStore of some sort
IEnumerable<Domain> list = _domains.Select(x => x.Value).Where(x => x.ContentId == documentId);
if (includeWildcards == false)
{
list = list.Where(x => x.IsWildcard == false);
}
return list.OrderBy(x => x.SortOrder);
}
/// <inheritdoc />
public bool HasAssigned(int documentId, bool includeWildcards = false)
=> documentId > 0 && GetAssigned(documentId, includeWildcards).Any();
public void Refresh(DomainCacheRefresher.JsonPayload[] payloads)
{
foreach (DomainCacheRefresher.JsonPayload payload in payloads)
{
switch (payload.ChangeType)
{
case DomainChangeTypes.RefreshAll:
using (ICoreScope scope = _coreScopeProvider.CreateCoreScope())
{
scope.ReadLock(Constants.Locks.Domains);
LoadDomains();
scope.Complete();
}
break;
case DomainChangeTypes.Remove:
_domains.Remove(payload.Id, out _);
break;
case DomainChangeTypes.Refresh:
IDomain? domain = _domainService.GetById(payload.Id);
if (domain == null)
{
continue;
}
if (domain.RootContentId.HasValue == false)
{
continue; // anomaly
}
var culture = domain.LanguageIsoCode;
if (string.IsNullOrWhiteSpace(culture))
{
continue; // anomaly
}
var newDomain = new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder);
// Feels wierd to use key and oldvalue, but we're using neither when updating.
_domains.AddOrUpdate(
domain.Id,
new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder),
(key, oldValue) => newDomain);
break;
}
}
}
private void LoadDomains()
{
IEnumerable<IDomain> domains = _domainService.GetAll(true);
foreach (Domain domain in domains
.Where(x => x.RootContentId.HasValue && x.LanguageIsoCode.IsNullOrWhiteSpace() == false)
.Select(x => new Domain(x.Id, x.DomainName, x.RootContentId!.Value, x.LanguageIsoCode!, x.IsWildcard, x.SortOrder)))
{
_domains.AddOrUpdate(domain.Id, domain, (key, oldValue) => domain);
}
}
}

View File

@@ -0,0 +1,21 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
public interface IDocumentCacheService
{
Task<IPublishedContent?> GetByKeyAsync(Guid key, bool preview = false);
Task<IPublishedContent?> GetByIdAsync(int id, bool preview = false);
Task SeedAsync(IReadOnlyCollection<Guid> contentTypeKeys);
Task<bool> HasContentByIdAsync(int id, bool preview = false);
Task RefreshContentAsync(IContent content);
Task DeleteItemAsync(int id);
void Rebuild(IReadOnlyCollection<int> contentTypeKeys);
}

View File

@@ -0,0 +1,17 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
public interface IMediaCacheService
{
Task<IPublishedContent?> GetByKeyAsync(Guid key);
Task<IPublishedContent?> GetByIdAsync(int id);
Task<bool> HasContentByIdAsync(int id);
Task RefreshMediaAsync(IMedia media);
Task DeleteItemAsync(int id);
}

View File

@@ -0,0 +1,9 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
public interface IMemberCacheService
{
Task<IPublishedMember?> Get(IMember member);
}

View File

@@ -0,0 +1,120 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.HybridCache.Factories;
using Umbraco.Cms.Infrastructure.HybridCache.Persistence;
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
internal class MediaCacheService : IMediaCacheService
{
private readonly IDatabaseCacheRepository _databaseCacheRepository;
private readonly IIdKeyMap _idKeyMap;
private readonly ICoreScopeProvider _scopeProvider;
private readonly Microsoft.Extensions.Caching.Hybrid.HybridCache _hybridCache;
private readonly IPublishedContentFactory _publishedContentFactory;
private readonly ICacheNodeFactory _cacheNodeFactory;
public MediaCacheService(
IDatabaseCacheRepository databaseCacheRepository,
IIdKeyMap idKeyMap,
ICoreScopeProvider scopeProvider,
Microsoft.Extensions.Caching.Hybrid.HybridCache hybridCache,
IPublishedContentFactory publishedContentFactory,
ICacheNodeFactory cacheNodeFactory)
{
_databaseCacheRepository = databaseCacheRepository;
_idKeyMap = idKeyMap;
_scopeProvider = scopeProvider;
_hybridCache = hybridCache;
_publishedContentFactory = publishedContentFactory;
_cacheNodeFactory = cacheNodeFactory;
}
public async Task<IPublishedContent?> GetByKeyAsync(Guid key)
{
Attempt<int> idAttempt = _idKeyMap.GetIdForKey(key, UmbracoObjectTypes.Media);
if (idAttempt.Success is false)
{
return null;
}
using ICoreScope scope = _scopeProvider.CreateCoreScope();
ContentCacheNode? contentCacheNode = await _hybridCache.GetOrCreateAsync(
$"{key}", // Unique key to the cache entry
async cancel => await _databaseCacheRepository.GetMediaSourceAsync(idAttempt.Result));
scope.Complete();
return contentCacheNode is null ? null : _publishedContentFactory.ToIPublishedMedia(contentCacheNode);
}
public async Task<IPublishedContent?> GetByIdAsync(int id)
{
Attempt<Guid> keyAttempt = _idKeyMap.GetKeyForId(id, UmbracoObjectTypes.Media);
if (keyAttempt.Success is false)
{
return null;
}
using ICoreScope scope = _scopeProvider.CreateCoreScope();
ContentCacheNode? contentCacheNode = await _hybridCache.GetOrCreateAsync(
$"{keyAttempt.Result}", // Unique key to the cache entry
async cancel => await _databaseCacheRepository.GetMediaSourceAsync(id));
scope.Complete();
return contentCacheNode is null ? null : _publishedContentFactory.ToIPublishedMedia(contentCacheNode);
}
public async Task<bool> HasContentByIdAsync(int id)
{
Attempt<Guid> keyAttempt = _idKeyMap.GetKeyForId(id, UmbracoObjectTypes.Media);
if (keyAttempt.Success is false)
{
return false;
}
ContentCacheNode? contentCacheNode = await _hybridCache.GetOrCreateAsync<ContentCacheNode?>(
$"{keyAttempt.Result}", // Unique key to the cache entry
cancel => ValueTask.FromResult<ContentCacheNode?>(null));
if (contentCacheNode is null)
{
await _hybridCache.RemoveAsync($"{keyAttempt.Result}");
}
return contentCacheNode is not null;
}
public async Task RefreshMediaAsync(IMedia media)
{
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 cacheNode = _cacheNodeFactory.ToContentCacheNode(media);
await _hybridCache.SetAsync(GetCacheKey(media.Key, false), cacheNode);
await _databaseCacheRepository.RefreshMediaAsync(cacheNode);
scope.Complete();
}
public async Task DeleteItemAsync(int id)
{
using ICoreScope scope = _scopeProvider.CreateCoreScope();
await _databaseCacheRepository.DeleteContentItemAsync(id);
Attempt<Guid> keyAttempt = _idKeyMap.GetKeyForId(id, UmbracoObjectTypes.Media);
if (keyAttempt.Success)
{
await _hybridCache.RemoveAsync(keyAttempt.Result.ToString());
}
_idKeyMap.ClearCache(keyAttempt.Result);
_idKeyMap.ClearCache(id);
scope.Complete();
}
private string GetCacheKey(Guid key, bool preview) => preview ? $"{key}+draft" : $"{key}";
}

View File

@@ -0,0 +1,15 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.HybridCache.Factories;
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
internal class MemberCacheService : IMemberCacheService
{
private readonly IPublishedContentFactory _publishedContentFactory;
public MemberCacheService(IPublishedContentFactory publishedContentFactory) => _publishedContentFactory = publishedContentFactory;
public async Task<IPublishedMember?> Get(IMember member) => member is null ? null : _publishedContentFactory.ToPublishedMember(member);
}

View File

@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<PackageId>Umbraco.Cms.PublishedCache.HybridCache</PackageId>
<Title>Umbraco CMS - Published cache - HybridCache</Title>
<Description>Contains the published cache assembly needed to run Umbraco CMS.</Description>
<RootNamespace>Umbraco.Cms.Infrastructure.HybridCache</RootNamespace>
<!-- TODO: Enable package validation in v16 by removing this line -->
<EnablePackageValidation>false</EnablePackageValidation>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" />
<PackageReference Include="MessagePack" />
<PackageReference Include="K4os.Compression.LZ4" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>Umbraco.Tests</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>Umbraco.Tests.Integration</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>DynamicProxyGenAssembly2</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Umbraco.Core\Umbraco.Core.csproj" />
<ProjectReference Include="..\Umbraco.Infrastructure\Umbraco.Infrastructure.csproj" />
</ItemGroup>
</Project>