diff --git a/Directory.Packages.props b/Directory.Packages.props
index 01e3bf1628..5f2a028c77 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -12,27 +12,29 @@
-
+
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -83,7 +85,7 @@
-
+
diff --git a/global.json b/global.json
index 5db4761d46..a718288b1a 100644
--- a/global.json
+++ b/global.json
@@ -1,6 +1,6 @@
{
"sdk": {
- "version": "9.0.100-preview.5.24307.3",
+ "version": "9.0.100-preview.7.24407.12",
"rollForward": "latestFeature",
"allowPrerelease": true
}
diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOffice.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOffice.cs
index 907e3057af..f77afa7347 100644
--- a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOffice.cs
+++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilder.BackOffice.cs
@@ -35,6 +35,7 @@ public static partial class UmbracoBuilderExtensions
.AddWebServer()
.AddRecurringBackgroundJobs()
.AddNuCache()
+ .AddUmbracoHybridCache()
.AddDistributedCache()
.AddCoreNotifications()
.AddExamine()
diff --git a/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj b/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj
index 37ab3611de..9ff61d58fd 100644
--- a/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj
+++ b/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj
@@ -14,6 +14,7 @@
+
diff --git a/src/Umbraco.Core/Cache/Refreshers/Implement/DomainCacheRefresher.cs b/src/Umbraco.Core/Cache/Refreshers/Implement/DomainCacheRefresher.cs
index a6e46ee2e4..9c5030e553 100644
--- a/src/Umbraco.Core/Cache/Refreshers/Implement/DomainCacheRefresher.cs
+++ b/src/Umbraco.Core/Cache/Refreshers/Implement/DomainCacheRefresher.cs
@@ -17,8 +17,10 @@ public sealed class DomainCacheRefresher : PayloadCacheRefresherBase
+ : base(appCaches, serializer, eventAggregator, factory)
+ {
_publishedSnapshotService = publishedSnapshotService;
+ }
#region Json
diff --git a/src/Umbraco.Core/Constants-Configuration.cs b/src/Umbraco.Core/Constants-Configuration.cs
index 459becd320..60b3397eeb 100644
--- a/src/Umbraco.Core/Constants-Configuration.cs
+++ b/src/Umbraco.Core/Constants-Configuration.cs
@@ -65,6 +65,7 @@ public static partial class Constants
public const string ConfigDataTypes = ConfigPrefix + "DataTypes";
public const string ConfigPackageManifests = ConfigPrefix + "PackageManifests";
public const string ConfigWebhook = ConfigPrefix + "Webhook";
+ public const string ConfigCache = ConfigPrefix + "Cache";
public static class NamedOptions
{
diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs
index 6832bbe789..6c771f5023 100644
--- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs
+++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Configuration.cs
@@ -5,7 +5,7 @@ using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Configuration.Models.Validation;
-using Umbraco.Extensions;
+using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.DependencyInjection;
@@ -85,7 +85,8 @@ public static partial class UmbracoBuilderExtensions
.AddUmbracoOptions()
.AddUmbracoOptions()
.AddUmbracoOptions()
- .AddUmbracoOptions();
+ .AddUmbracoOptions()
+ .AddUmbracoOptions();
// Configure connection string and ensure it's updated when the configuration changes
builder.Services.AddSingleton, ConfigureConnectionStrings>();
diff --git a/src/Umbraco.Core/Models/CacheSettings.cs b/src/Umbraco.Core/Models/CacheSettings.cs
new file mode 100644
index 0000000000..dcd7211347
--- /dev/null
+++ b/src/Umbraco.Core/Models/CacheSettings.cs
@@ -0,0 +1,13 @@
+using Umbraco.Cms.Core.Configuration.Models;
+
+namespace Umbraco.Cms.Core.Models;
+
+[UmbracoOptions(Constants.Configuration.ConfigCache)]
+public class CacheSettings
+{
+ ///
+ /// Gets or sets a value for the collection of content type ids to always have in the cache.
+ ///
+ public List ContentTypeKeys { get; set; } =
+ new();
+}
diff --git a/src/Umbraco.Core/Models/PublishedContent/IPublishedMember.cs b/src/Umbraco.Core/Models/PublishedContent/IPublishedMember.cs
new file mode 100644
index 0000000000..9095a4aaa3
--- /dev/null
+++ b/src/Umbraco.Core/Models/PublishedContent/IPublishedMember.cs
@@ -0,0 +1,22 @@
+namespace Umbraco.Cms.Core.Models.PublishedContent;
+
+public interface IPublishedMember : IPublishedContent
+{
+ public string Email { get; }
+
+ public string UserName { get; }
+
+ public string? Comments { get; }
+
+ public bool IsApproved { get; }
+
+ public bool IsLockedOut { get; }
+
+ public DateTime? LastLockoutDate { get; }
+
+ public DateTime CreationDate { get; }
+
+ public DateTime? LastLoginDate { get; }
+
+ public DateTime? LastPasswordChangedDate { get; }
+}
diff --git a/src/Umbraco.Core/Models/PublishedContent/IPublishedMemberCache.cs b/src/Umbraco.Core/Models/PublishedContent/IPublishedMemberCache.cs
index cefb51241e..11cb52a57d 100644
--- a/src/Umbraco.Core/Models/PublishedContent/IPublishedMemberCache.cs
+++ b/src/Umbraco.Core/Models/PublishedContent/IPublishedMemberCache.cs
@@ -10,7 +10,7 @@ public interface IPublishedMemberCache
///
///
///
- IPublishedContent? Get(IMember member);
+ IPublishedMember? Get(IMember member);
///
/// Gets a content type identified by its unique identifier.
@@ -26,4 +26,12 @@ public interface IPublishedMemberCache
/// The content type, or null.
/// The alias is case-insensitive.
IPublishedContentType GetContentType(string alias);
+
+ ///
+ /// Get an from an
+ ///
+ /// The key of the member to fetch
+ /// Will fetch draft if this is set to true
+ ///
+ Task GetAsync(IMember member);
}
diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedPropertyType.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedPropertyType.cs
index 398e855343..1c3818b592 100644
--- a/src/Umbraco.Core/Models/PublishedContent/PublishedPropertyType.cs
+++ b/src/Umbraco.Core/Models/PublishedContent/PublishedPropertyType.cs
@@ -196,7 +196,7 @@ namespace Umbraco.Cms.Core.Models.PublishedContent
var deliveryApiPropertyValueConverter = _converter as IDeliveryApiPropertyValueConverter;
- _cacheLevel = _converter?.GetPropertyCacheLevel(this) ?? PropertyCacheLevel.Snapshot;
+ _cacheLevel = _converter?.GetPropertyCacheLevel(this) ?? PropertyCacheLevel.Elements;
_deliveryApiCacheLevel = deliveryApiPropertyValueConverter?.GetDeliveryApiPropertyCacheLevel(this) ?? _cacheLevel;
_deliveryApiCacheLevelForExpansion = deliveryApiPropertyValueConverter?.GetDeliveryApiPropertyCacheLevelForExpansion(this) ?? _cacheLevel;
_modelClrType = _converter?.GetPropertyValueType(this) ?? typeof(object);
diff --git a/src/Umbraco.Core/PropertyEditors/ContentPickerPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/ContentPickerPropertyEditor.cs
index 34d9956e88..1c5d240c8f 100644
--- a/src/Umbraco.Core/PropertyEditors/ContentPickerPropertyEditor.cs
+++ b/src/Umbraco.Core/PropertyEditors/ContentPickerPropertyEditor.cs
@@ -67,7 +67,7 @@ public class ContentPickerPropertyEditor : DataEditor
// starting in v14 the passed in value is always a guid, we store it as a document Udi string. Else it's an invalid value
public override object? FromEditor(ContentPropertyData editorValue, object? currentValue) =>
editorValue.Value is not null
- && Guid.TryParse(editorValue.Value as string, out Guid guidValue)
+ && Guid.TryParse(editorValue.Value.ToString(), out Guid guidValue)
? GuidUdi.Create(Constants.UdiEntityType.Document, guidValue).ToString()
: null;
diff --git a/src/Umbraco.Core/PropertyEditors/PropertyCacheLevel.cs b/src/Umbraco.Core/PropertyEditors/PropertyCacheLevel.cs
index c835c0ae95..82e246feb6 100644
--- a/src/Umbraco.Core/PropertyEditors/PropertyCacheLevel.cs
+++ b/src/Umbraco.Core/PropertyEditors/PropertyCacheLevel.cs
@@ -30,6 +30,7 @@ public enum PropertyCacheLevel
/// In most cases, a snapshot is created per request, and therefore this is
/// equivalent to cache the value for the duration of the request.
///
+ [Obsolete("Caching no longer supports snapshotting")]
Snapshot = 3,
///
diff --git a/src/Umbraco.Core/PropertyEditors/ValueConverters/ContentPickerValueConverter.cs b/src/Umbraco.Core/PropertyEditors/ValueConverters/ContentPickerValueConverter.cs
index 470a95e54e..972f7af03d 100644
--- a/src/Umbraco.Core/PropertyEditors/ValueConverters/ContentPickerValueConverter.cs
+++ b/src/Umbraco.Core/PropertyEditors/ValueConverters/ContentPickerValueConverter.cs
@@ -17,14 +17,14 @@ public class ContentPickerValueConverter : PropertyValueConverterBase, IDelivery
Constants.Conventions.Content.Redirect.ToLower(CultureInfo.InvariantCulture),
};
- private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor;
+ private readonly IPublishedContentCache _publishedContentCache;
private readonly IApiContentBuilder _apiContentBuilder;
public ContentPickerValueConverter(
- IPublishedSnapshotAccessor publishedSnapshotAccessor,
+ IPublishedContentCache publishedContentCache,
IApiContentBuilder apiContentBuilder)
{
- _publishedSnapshotAccessor = publishedSnapshotAccessor;
+ _publishedContentCache = publishedContentCache;
_apiContentBuilder = apiContentBuilder;
}
@@ -105,10 +105,9 @@ public class ContentPickerValueConverter : PropertyValueConverterBase, IDelivery
PropertiesToExclude.Contains(propertyType.Alias.ToLower(CultureInfo.InvariantCulture))) == false)
{
IPublishedContent? content;
- IPublishedSnapshot publishedSnapshot = _publishedSnapshotAccessor.GetRequiredPublishedSnapshot();
if (inter is int id)
{
- content = publishedSnapshot.Content?.GetById(id);
+ content = _publishedContentCache.GetById(id);
if (content != null)
{
return content;
@@ -121,7 +120,7 @@ public class ContentPickerValueConverter : PropertyValueConverterBase, IDelivery
return null;
}
- content = publishedSnapshot.Content?.GetById(udi.Guid);
+ content = _publishedContentCache.GetById(udi.Guid);
if (content != null && content.ContentType.ItemType == PublishedItemType.Content)
{
return content;
diff --git a/src/Umbraco.Core/PublishedCache/ICacheManager.cs b/src/Umbraco.Core/PublishedCache/ICacheManager.cs
new file mode 100644
index 0000000000..062a802adb
--- /dev/null
+++ b/src/Umbraco.Core/PublishedCache/ICacheManager.cs
@@ -0,0 +1,37 @@
+using Umbraco.Cms.Core.Cache;
+
+namespace Umbraco.Cms.Core.PublishedCache;
+
+public interface ICacheManager
+{
+ ///
+ /// Gets the .
+ ///
+ IPublishedContentCache Content { get; }
+
+ ///
+ /// Gets the .
+ ///
+ IPublishedMediaCache Media { get; }
+
+ ///
+ /// Gets the .
+ ///
+ IPublishedMemberCache Members { get; }
+
+ ///
+ /// Gets the .
+ ///
+ IDomainCache Domains { get; }
+
+ ///
+ /// Gets the elements-level cache.
+ ///
+ ///
+ ///
+ /// The elements-level cache is shared by all snapshots relying on the same elements,
+ /// ie all snapshots built on top of unchanging content / media / etc.
+ ///
+ ///
+ IAppCache ElementsCache { get; }
+}
diff --git a/src/Umbraco.Core/PublishedCache/IDomainCacheService.cs b/src/Umbraco.Core/PublishedCache/IDomainCacheService.cs
new file mode 100644
index 0000000000..5d856ff349
--- /dev/null
+++ b/src/Umbraco.Core/PublishedCache/IDomainCacheService.cs
@@ -0,0 +1,31 @@
+using Umbraco.Cms.Core.Cache;
+using Umbraco.Cms.Core.Routing;
+
+namespace Umbraco.Cms.Core.PublishedCache;
+
+public interface IDomainCacheService
+{
+ ///
+ /// Gets all in the current domain cache, including any domains that may be referenced by
+ /// documents that are no longer published.
+ ///
+ ///
+ ///
+ IEnumerable GetAll(bool includeWildcards);
+
+ ///
+ /// Gets all assigned for specified document, even if it is not published.
+ ///
+ /// The document identifier.
+ /// A value indicating whether to consider wildcard domains.
+ IEnumerable GetAssigned(int documentId, bool includeWildcards = false);
+
+ ///
+ /// Determines whether a document has domains.
+ ///
+ /// The document identifier.
+ /// A value indicating whether to consider wildcard domains.
+ bool HasAssigned(int documentId, bool includeWildcards = false);
+
+ void Refresh(DomainCacheRefresher.JsonPayload[] payloads);
+}
diff --git a/src/Umbraco.Core/PublishedCache/IPublishedCache.cs b/src/Umbraco.Core/PublishedCache/IPublishedCache.cs
index 0bf12d8fbb..e4d8a2311c 100644
--- a/src/Umbraco.Core/PublishedCache/IPublishedCache.cs
+++ b/src/Umbraco.Core/PublishedCache/IPublishedCache.cs
@@ -32,6 +32,7 @@ public interface IPublishedCache
/// The content Udi identifier.
/// The content, or null.
/// The value of overrides defaults.
+ [Obsolete] // FIXME: Remove when replacing nucache
IPublishedContent? GetById(bool preview, Udi contentId);
///
@@ -56,25 +57,9 @@ public interface IPublishedCache
/// The content unique identifier.
/// The content, or null.
/// Considers published or unpublished content depending on defaults.
+ [Obsolete] // FIXME: Remove when replacing nucache
IPublishedContent? GetById(Udi contentId);
- ///
- /// Gets a value indicating whether the cache contains a specified content.
- ///
- /// A value indicating whether to consider unpublished content.
- /// The content unique identifier.
- /// A value indicating whether to the cache contains the specified content.
- /// The value of overrides defaults.
- bool HasById(bool preview, int contentId);
-
- ///
- /// Gets a value indicating whether the cache contains a specified content.
- ///
- /// The content unique identifier.
- /// A value indicating whether to the cache contains the specified content.
- /// Considers published or unpublished content depending on defaults.
- bool HasById(int contentId);
-
///
/// Gets contents at root.
///
@@ -82,6 +67,7 @@ public interface IPublishedCache
/// A culture.
/// The contents.
/// The value of overrides defaults.
+ [Obsolete] // FIXME: Remove when replacing nucache
IEnumerable GetAtRoot(bool preview, string? culture = null);
///
@@ -90,6 +76,7 @@ public interface IPublishedCache
/// A culture.
/// The contents.
/// Considers published or unpublished content depending on defaults.
+ [Obsolete] // FIXME: Remove when replacing nucache
IEnumerable GetAtRoot(string? culture = null);
///
@@ -98,6 +85,7 @@ public interface IPublishedCache
/// A value indicating whether to consider unpublished content.
/// A value indicating whether the cache contains published content.
/// The value of overrides defaults.
+ [Obsolete] // FIXME: Remove when replacing nucache
bool HasContent(bool preview);
///
@@ -105,6 +93,7 @@ public interface IPublishedCache
///
/// A value indicating whether the cache contains published content.
/// Considers published or unpublished content depending on defaults.
+ [Obsolete] // FIXME: Remove when replacing nucache
bool HasContent();
///
@@ -112,6 +101,7 @@ public interface IPublishedCache
///
/// The content type unique identifier.
/// The content type, or null.
+ [Obsolete("Please use the IContentTypeCacheService instead, scheduled for removal in V16")]
IPublishedContentType? GetContentType(int id);
///
@@ -120,6 +110,7 @@ public interface IPublishedCache
/// The content type alias.
/// The content type, or null.
/// The alias is case-insensitive.
+ [Obsolete("Please use the IContentTypeCacheService instead, scheduled for removal in V16")]
IPublishedContentType? GetContentType(string alias);
///
@@ -127,6 +118,7 @@ public interface IPublishedCache
///
/// The content type.
/// The contents.
+ [Obsolete] // FIXME: Remove when replacing nucache
IEnumerable GetByContentType(IPublishedContentType contentType);
///
@@ -134,5 +126,6 @@ public interface IPublishedCache
///
/// The content type key.
/// The content type, or null.
+ [Obsolete("Please use the IContentTypeCacheService instead, scheduled for removal in V16")]
IPublishedContentType? GetContentType(Guid key);
}
diff --git a/src/Umbraco.Core/PublishedCache/IPublishedContentCache.cs b/src/Umbraco.Core/PublishedCache/IPublishedContentCache.cs
index 6d5fa9b4e8..8353225f10 100644
--- a/src/Umbraco.Core/PublishedCache/IPublishedContentCache.cs
+++ b/src/Umbraco.Core/PublishedCache/IPublishedContentCache.cs
@@ -4,6 +4,25 @@ namespace Umbraco.Cms.Core.PublishedCache;
public interface IPublishedContentCache : IPublishedCache
{
+ ///
+ /// Gets a content identified by its unique identifier.
+ ///
+ /// The content unique identifier.
+ /// A value indicating whether to consider unpublished content.
+ /// The content, or null.
+ /// Considers published or unpublished content depending on defaults.
+ Task GetByIdAsync(int id, bool preview = false);
+
+ ///
+ /// Gets a content identified by its unique identifier.
+ ///
+ /// The content unique identifier.
+ /// A value indicating whether to consider unpublished content.
+ /// The content, or null.
+ /// Considers published or unpublished content depending on defaults.
+ Task GetByIdAsync(Guid key, bool preview = false);
+
+ // FIXME: All these routing methods needs to be removed, as they are no longer part of the content cache
///
/// Gets content identified by a route.
///
@@ -24,6 +43,7 @@ public interface IPublishedContentCache : IPublishedCache
///
/// The value of overrides defaults.
///
+ [Obsolete]
IPublishedContent? GetByRoute(bool preview, string route, bool? hideTopLevelNode = null, string? culture = null);
///
@@ -45,6 +65,7 @@ public interface IPublishedContentCache : IPublishedCache
///
/// Considers published or unpublished content depending on defaults.
///
+ [Obsolete]
IPublishedContent? GetByRoute(string route, bool? hideTopLevelNode = null, string? culture = null);
///
@@ -62,6 +83,7 @@ public interface IPublishedContentCache : IPublishedCache
///
/// The value of overrides defaults.
///
+ [Obsolete]
string? GetRouteById(bool preview, int contentId, string? culture = null);
///
@@ -76,5 +98,6 @@ public interface IPublishedContentCache : IPublishedCache
/// for the current route. If a domain is present the string will be prefixed with the domain ID integer, example:
/// {domainId}/route-path-of-item
///
+ [Obsolete]
string? GetRouteById(int contentId, string? culture = null);
}
diff --git a/src/Umbraco.Core/PublishedCache/IPublishedContentTypeCache.cs b/src/Umbraco.Core/PublishedCache/IPublishedContentTypeCache.cs
new file mode 100644
index 0000000000..318e7046c1
--- /dev/null
+++ b/src/Umbraco.Core/PublishedCache/IPublishedContentTypeCache.cs
@@ -0,0 +1,47 @@
+using Umbraco.Cms.Core.Models.PublishedContent;
+
+namespace Umbraco.Cms.Core.PublishedCache;
+
+public interface IPublishedContentTypeCache
+{
+ ///
+ /// Clears the entire cache.
+ ///
+ public void ClearAll();
+
+ ///
+ /// Clears a cached content type.
+ ///
+ /// An identifier.
+ public void ClearContentType(int id);
+
+ ///
+ /// Clears all cached content types referencing a data type.
+ ///
+ /// A data type identifier.
+ public void ClearDataType(int id);
+
+ ///
+ /// Gets a published content type.
+ ///
+ /// An item type.
+ /// An key.
+ /// The published content type corresponding to the item key.
+ public IPublishedContentType Get(PublishedItemType itemType, Guid key);
+
+ ///
+ /// Gets a published content type.
+ ///
+ /// An item type.
+ /// An alias.
+ /// The published content type corresponding to the item type and alias.
+ public IPublishedContentType Get(PublishedItemType itemType, string alias);
+
+ ///
+ /// Gets a published content type.
+ ///
+ /// An item type.
+ /// An identifier.
+ /// The published content type corresponding to the item type and identifier.
+ public IPublishedContentType Get(PublishedItemType itemType, int id);
+}
diff --git a/src/Umbraco.Core/PublishedCache/IPublishedMediaCache.cs b/src/Umbraco.Core/PublishedCache/IPublishedMediaCache.cs
index b0fd46748e..eb78109607 100644
--- a/src/Umbraco.Core/PublishedCache/IPublishedMediaCache.cs
+++ b/src/Umbraco.Core/PublishedCache/IPublishedMediaCache.cs
@@ -1,5 +1,22 @@
+using Umbraco.Cms.Core.Models.PublishedContent;
+
namespace Umbraco.Cms.Core.PublishedCache;
public interface IPublishedMediaCache : IPublishedCache
{
+ ///
+ /// Gets a content identified by its unique identifier.
+ ///
+ /// The content unique identifier.
+ /// The content, or null.
+ /// Considers published or unpublished content depending on defaults.
+ Task GetByIdAsync(int id);
+
+ ///
+ /// Gets a content identified by its unique identifier.
+ ///
+ /// The content unique identifier.
+ /// The content, or null.
+ /// Considers published or unpublished content depending on defaults.
+ Task GetByKeyAsync(Guid key);
}
diff --git a/src/Umbraco.Core/PublishedCache/Internal/InternalPublishedContentCache.cs b/src/Umbraco.Core/PublishedCache/Internal/InternalPublishedContentCache.cs
index 9987607f62..5b57236d4f 100644
--- a/src/Umbraco.Core/PublishedCache/Internal/InternalPublishedContentCache.cs
+++ b/src/Umbraco.Core/PublishedCache/Internal/InternalPublishedContentCache.cs
@@ -14,6 +14,12 @@ public sealed class InternalPublishedContentCache : PublishedCacheBase, IPublish
{
}
+ public Task GetByIdAsync(int id, bool preview = false) => throw new NotImplementedException();
+
+ public Task GetByIdAsync(Guid key, bool preview = false) => throw new NotImplementedException();
+
+ public Task HasByIdAsync(int id, bool preview = false) => 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) =>
@@ -49,4 +55,9 @@ public sealed class InternalPublishedContentCache : PublishedCacheBase, IPublish
// public void Add(InternalPublishedContent content) => _content[content.Id] = content.CreateModel(Mock.Of());
public void Clear() => _content.Clear();
+ public Task GetByIdAsync(int id) => throw new NotImplementedException();
+
+ public Task GetByKeyAsync(Guid key) => throw new NotImplementedException();
+
+ public Task HasByIdAsync(int id) => throw new NotImplementedException();
}
diff --git a/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs b/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs
index c79506fb5f..dc0fbf281d 100644
--- a/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs
+++ b/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs
@@ -23,13 +23,8 @@ public sealed class HtmlLocalLinkParser
private readonly IPublishedUrlProvider _publishedUrlProvider;
- private readonly IUmbracoContextAccessor _umbracoContextAccessor;
-
- public HtmlLocalLinkParser(
- IUmbracoContextAccessor umbracoContextAccessor,
- IPublishedUrlProvider publishedUrlProvider)
+ public HtmlLocalLinkParser(IPublishedUrlProvider publishedUrlProvider)
{
- _umbracoContextAccessor = umbracoContextAccessor;
_publishedUrlProvider = publishedUrlProvider;
}
@@ -50,23 +45,7 @@ public sealed class HtmlLocalLinkParser
///
///
///
- public string EnsureInternalLinks(string text, bool preview)
- {
- if (!_umbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext? umbracoContext))
- {
- throw new InvalidOperationException("Could not parse internal links, there is no current UmbracoContext");
- }
-
- if (!preview)
- {
- return EnsureInternalLinks(text);
- }
-
- using (umbracoContext.ForcedPreview(preview)) // force for URL provider
- {
- return EnsureInternalLinks(text);
- }
- }
+ public string EnsureInternalLinks(string text, bool preview) => EnsureInternalLinks(text);
///
/// Parses the string looking for the {localLink} syntax and updates them to their correct links.
@@ -75,11 +54,6 @@ public sealed class HtmlLocalLinkParser
///
public string EnsureInternalLinks(string text)
{
- if (!_umbracoContextAccessor.TryGetUmbracoContext(out _))
- {
- throw new InvalidOperationException("Could not parse internal links, there is no current UmbracoContext");
- }
-
foreach (LocalLinkTag tagData in FindLocalLinkIds(text))
{
if (tagData.Udi is not null)
diff --git a/src/Umbraco.Core/Web/IUmbracoContext.cs b/src/Umbraco.Core/Web/IUmbracoContext.cs
index 17ffc515a2..7c0bb311bf 100644
--- a/src/Umbraco.Core/Web/IUmbracoContext.cs
+++ b/src/Umbraco.Core/Web/IUmbracoContext.cs
@@ -31,6 +31,7 @@ public interface IUmbracoContext : IDisposable
///
IPublishedSnapshot PublishedSnapshot { get; }
+ // TODO: Obsolete these, and use cache manager to get
///
/// Gets the published content cache.
///
diff --git a/src/Umbraco.Infrastructure/Persistence/NPocoDatabaseExtensions.cs b/src/Umbraco.Infrastructure/Persistence/NPocoDatabaseExtensions.cs
index 1dc12a805f..bebf59cbe7 100644
--- a/src/Umbraco.Infrastructure/Persistence/NPocoDatabaseExtensions.cs
+++ b/src/Umbraco.Infrastructure/Persistence/NPocoDatabaseExtensions.cs
@@ -120,6 +120,7 @@ public static partial class NPocoDatabaseExtensions
/// once T1 and T2 have completed. Whereas here, it could contain T1's value.
///
///
+ [Obsolete("Use InsertOrUpdateAsync instead")]
public static RecordPersistenceType InsertOrUpdate(this IUmbracoDatabase db, T poco)
where T : class =>
db.InsertOrUpdate(poco, null, null);
@@ -150,7 +151,7 @@ public static partial class NPocoDatabaseExtensions
/// once T1 and T2 have completed. Whereas here, it could contain T1's value.
///
///
- public static RecordPersistenceType InsertOrUpdate(
+ public static async Task InsertOrUpdateAsync(
this IUmbracoDatabase db,
T poco,
string? updateCommand,
@@ -167,7 +168,7 @@ public static partial class NPocoDatabaseExtensions
// try to update
var rowCount = updateCommand.IsNullOrWhiteSpace() || updateArgs is null
- ? db.Update(poco)
+ ? await db.UpdateAsync(poco)
: db.Update(updateCommand!, updateArgs);
if (rowCount > 0)
{
@@ -182,7 +183,7 @@ public static partial class NPocoDatabaseExtensions
try
{
// try to insert
- db.Insert(poco);
+ await db.InsertAsync(poco);
return RecordPersistenceType.Insert;
}
catch (DbException)
@@ -193,7 +194,7 @@ public static partial class NPocoDatabaseExtensions
// try to update
rowCount = updateCommand.IsNullOrWhiteSpace() || updateArgs is null
- ? db.Update(poco)
+ ? await db.UpdateAsync(poco)
: db.Update(updateCommand!, updateArgs);
if (rowCount > 0)
{
@@ -209,6 +210,14 @@ public static partial class NPocoDatabaseExtensions
throw new DataException("Record could not be inserted or updated.");
}
+ public static RecordPersistenceType InsertOrUpdate(
+ this IUmbracoDatabase db,
+ T poco,
+ string? updateCommand,
+ object? updateArgs)
+ where T : class =>
+ db.InsertOrUpdateAsync(poco, updateCommand, updateArgs).GetAwaiter().GetResult();
+
///
/// This will escape single @ symbols for npoco values so it doesn't think it's a parameter
///
diff --git a/src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs b/src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs
index b90ceb9c23..70c41277d0 100644
--- a/src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs
+++ b/src/Umbraco.Infrastructure/Persistence/NPocoSqlExtensions.cs
@@ -60,6 +60,21 @@ namespace Umbraco.Extensions
return sql;
}
+ ///
+ /// Appends a WHERE IN clause to the Sql statement.
+ ///
+ /// The type of the Dto.
+ /// The Sql statement.
+ /// An expression specifying the field.
+ /// The values.
+ /// The Sql statement.
+ public static Sql WhereIn(this Sql sql, Expression> field, IEnumerable? values, string alias)
+ {
+ var fieldName = sql.SqlContext.SqlSyntax.GetFieldName(field, alias);
+ sql.Where(fieldName + " IN (@values)", new { values });
+ return sql;
+ }
+
///
/// Appends a WHERE IN clause to the Sql statement.
///
diff --git a/src/Umbraco.Infrastructure/PublishedCache/PublishedContentTypeCache.cs b/src/Umbraco.Infrastructure/PublishedCache/PublishedContentTypeCache.cs
index f21635d2df..74f27ba8dd 100644
--- a/src/Umbraco.Infrastructure/PublishedCache/PublishedContentTypeCache.cs
+++ b/src/Umbraco.Infrastructure/PublishedCache/PublishedContentTypeCache.cs
@@ -9,7 +9,7 @@ namespace Umbraco.Cms.Core.PublishedCache;
/// Represents a content type cache.
///
/// This cache is not snapshotted, so it refreshes any time things change.
-public class PublishedContentTypeCache : IDisposable
+public class PublishedContentTypeCache : IPublishedContentTypeCache
{
private readonly IContentTypeService? _contentTypeService;
private readonly Dictionary _keyToIdMap = new();
@@ -23,11 +23,13 @@ public class PublishedContentTypeCache : IDisposable
// NOTE: These are not concurrent dictionaries because all access is done within a lock
private readonly Dictionary _typesByAlias = new();
private readonly Dictionary _typesById = new();
- private bool _disposedValue;
// default ctor
- public PublishedContentTypeCache(IContentTypeService? contentTypeService, IMediaTypeService? mediaTypeService,
- IMemberTypeService? memberTypeService, IPublishedContentTypeFactory publishedContentTypeFactory,
+ public PublishedContentTypeCache(
+ IContentTypeService? contentTypeService,
+ IMediaTypeService? mediaTypeService,
+ IMemberTypeService? memberTypeService,
+ IPublishedContentTypeFactory publishedContentTypeFactory,
ILogger logger)
{
_contentTypeService = contentTypeService;
@@ -48,13 +50,6 @@ public class PublishedContentTypeCache : IDisposable
_publishedContentTypeFactory = publishedContentTypeFactory;
}
- public void Dispose() =>
-
- // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
- Dispose(true);
-
- // note: cache clearing is performed by XmlStore
-
///
/// Clears all cached content types.
///
@@ -175,7 +170,10 @@ public class PublishedContentTypeCache : IDisposable
if (_keyToIdMap.TryGetValue(key, out var id))
{
- return Get(itemType, id);
+ if (_typesById.TryGetValue(id, out IPublishedContentType? foundType))
+ {
+ return foundType;
+ }
}
IPublishedContentType type = CreatePublishedContentType(itemType, key);
@@ -289,19 +287,6 @@ public class PublishedContentTypeCache : IDisposable
}
}
- protected virtual void Dispose(bool disposing)
- {
- if (!_disposedValue)
- {
- if (disposing)
- {
- _lock.Dispose();
- }
-
- _disposedValue = true;
- }
- }
-
private static string GetAliasKey(PublishedItemType itemType, string alias)
{
string k;
diff --git a/src/Umbraco.PublishedCache.HybridCache/CacheManager.cs b/src/Umbraco.PublishedCache.HybridCache/CacheManager.cs
new file mode 100644
index 0000000000..28dfa6ee58
--- /dev/null
+++ b/src/Umbraco.PublishedCache.HybridCache/CacheManager.cs
@@ -0,0 +1,27 @@
+using Umbraco.Cms.Core.Cache;
+using Umbraco.Cms.Core.PublishedCache;
+
+namespace Umbraco.Cms.Infrastructure.HybridCache;
+
+///
+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; }
+}
diff --git a/src/Umbraco.PublishedCache.HybridCache/ContentCacheNode.cs b/src/Umbraco.PublishedCache.HybridCache/ContentCacheNode.cs
new file mode 100644
index 0000000000..e72d4f234b
--- /dev/null
+++ b/src/Umbraco.PublishedCache.HybridCache/ContentCacheNode.cs
@@ -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; }
+}
diff --git a/src/Umbraco.PublishedCache.HybridCache/ContentData.cs b/src/Umbraco.PublishedCache.HybridCache/ContentData.cs
new file mode 100644
index 0000000000..c314241479
--- /dev/null
+++ b/src/Umbraco.PublishedCache.HybridCache/ContentData.cs
@@ -0,0 +1,45 @@
+using System.ComponentModel;
+
+namespace Umbraco.Cms.Infrastructure.HybridCache;
+
+///
+/// Represents everything that is specific to an edited or published content version
+///
+// 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? properties, IReadOnlyDictionary? 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 Properties { get; }
+
+ ///
+ /// The collection of language Id to name for the content item
+ ///
+ public IReadOnlyDictionary? CultureInfos { get; }
+}
diff --git a/src/Umbraco.PublishedCache.HybridCache/ContentNode.cs b/src/Umbraco.PublishedCache.HybridCache/ContentNode.cs
new file mode 100644
index 0000000000..7db0b284ba
--- /dev/null
+++ b/src/Umbraco.PublishedCache.HybridCache/ContentNode.cs
@@ -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
+}
diff --git a/src/Umbraco.PublishedCache.HybridCache/CultureVariation.cs b/src/Umbraco.PublishedCache.HybridCache/CultureVariation.cs
new file mode 100644
index 0000000000..e8d74daa02
--- /dev/null
+++ b/src/Umbraco.PublishedCache.HybridCache/CultureVariation.cs
@@ -0,0 +1,29 @@
+using System.Runtime.Serialization;
+using System.Text.Json.Serialization;
+using Umbraco.Cms.Infrastructure.Serialization;
+
+namespace Umbraco.Cms.Infrastructure.HybridCache;
+
+///
+/// Represents the culture variation information on a content item
+///
+[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; }
+}
diff --git a/src/Umbraco.PublishedCache.HybridCache/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.PublishedCache.HybridCache/DependencyInjection/UmbracoBuilderExtensions.cs
new file mode 100644
index 0000000000..6ad695c154
--- /dev/null
+++ b/src/Umbraco.PublishedCache.HybridCache/DependencyInjection/UmbracoBuilderExtensions.cs
@@ -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;
+
+///
+/// Extension methods for for the Umbraco's NuCache
+///
+public static class UmbracoBuilderExtensions
+{
+ ///
+ /// Adds Umbraco NuCache dependencies
+ ///
+ public static IUmbracoBuilder AddUmbracoHybridCache(this IUmbracoBuilder builder)
+ {
+ builder.Services.AddHybridCache();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton(s =>
+ {
+ IOptions options = s.GetRequiredService>();
+ switch (options.Value.NuCacheSerializerType)
+ {
+ case NuCacheSerializerType.JSON:
+ return new JsonContentNestedDataSerializerFactory();
+ case NuCacheSerializerType.MessagePack:
+ return ActivatorUtilities.CreateInstance(s);
+ default:
+ throw new IndexOutOfRangeException();
+ }
+ });
+ builder.Services.AddSingleton();
+ builder.AddNotificationAsyncHandler();
+ builder.AddNotificationAsyncHandler();
+ builder.AddNotificationAsyncHandler();
+ builder.AddNotificationAsyncHandler();
+ builder.AddNotificationAsyncHandler();
+ builder.AddNotificationAsyncHandler();
+ return builder;
+ }
+}
diff --git a/src/Umbraco.PublishedCache.HybridCache/DocumentCache.cs b/src/Umbraco.PublishedCache.HybridCache/DocumentCache.cs
new file mode 100644
index 0000000000..2723a281c2
--- /dev/null
+++ b/src/Umbraco.PublishedCache.HybridCache/DocumentCache.cs
@@ -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 GetByIdAsync(int id, bool preview = false) => await _documentCacheService.GetByIdAsync(id, preview);
+
+
+ public async Task 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 GetAtRoot(bool preview, string? culture = null) => throw new NotImplementedException();
+
+ public IEnumerable GetAtRoot(string? culture = null) => throw new NotImplementedException();
+
+ public bool HasContent(bool preview) => throw new NotImplementedException();
+
+ public bool HasContent() => throw new NotImplementedException();
+
+ public IEnumerable 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();
+}
diff --git a/src/Umbraco.PublishedCache.HybridCache/DomainCache.cs b/src/Umbraco.PublishedCache.HybridCache/DomainCache.cs
new file mode 100644
index 0000000000..8d07ef7dd7
--- /dev/null
+++ b/src/Umbraco.PublishedCache.HybridCache/DomainCache.cs
@@ -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;
+
+///
+/// Implements for NuCache.
+///
+public class DomainCache : IDomainCache
+{
+ private readonly IDomainCacheService _domainCacheService;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public DomainCache(IDefaultCultureAccessor defaultCultureAccessor, IDomainCacheService domainCacheService)
+ {
+ _domainCacheService = domainCacheService;
+ DefaultCulture = defaultCultureAccessor.DefaultCulture;
+ }
+
+ ///
+ public string DefaultCulture { get; }
+
+ ///
+ public IEnumerable GetAll(bool includeWildcards) => _domainCacheService.GetAll(includeWildcards);
+
+ ///
+ public IEnumerable GetAssigned(int documentId, bool includeWildcards = false) => _domainCacheService.GetAssigned(documentId, includeWildcards);
+
+ ///
+ public bool HasAssigned(int documentId, bool includeWildcards = false) => _domainCacheService.HasAssigned(documentId, includeWildcards);
+}
diff --git a/src/Umbraco.PublishedCache.HybridCache/ElementsDictionaryAppCache.cs b/src/Umbraco.PublishedCache.HybridCache/ElementsDictionaryAppCache.cs
new file mode 100644
index 0000000000..6415629b38
--- /dev/null
+++ b/src/Umbraco.PublishedCache.HybridCache/ElementsDictionaryAppCache.cs
@@ -0,0 +1,7 @@
+using Umbraco.Cms.Core.Cache;
+
+namespace Umbraco.Cms.Infrastructure.HybridCache;
+
+public class ElementsDictionaryAppCache : FastDictionaryAppCache, IElementsCache
+{
+}
diff --git a/src/Umbraco.PublishedCache.HybridCache/Factories/CacheNodeFactory.cs b/src/Umbraco.PublishedCache.HybridCache/Factories/CacheNodeFactory.cs
new file mode 100644
index 0000000000..7fd91c4603
--- /dev/null
+++ b/src/Umbraco.PublishedCache.HybridCache/Factories/CacheNodeFactory.cs
@@ -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();
+ foreach (IProperty prop in content.Properties)
+ {
+ var pdatas = new List();
+ 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();
+
+ // 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);
+ }
+}
diff --git a/src/Umbraco.PublishedCache.HybridCache/Factories/ICacheNodeFactory.cs b/src/Umbraco.PublishedCache.HybridCache/Factories/ICacheNodeFactory.cs
new file mode 100644
index 0000000000..f16ea2b162
--- /dev/null
+++ b/src/Umbraco.PublishedCache.HybridCache/Factories/ICacheNodeFactory.cs
@@ -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);
+}
diff --git a/src/Umbraco.PublishedCache.HybridCache/Factories/IPublishedContentFactory.cs b/src/Umbraco.PublishedCache.HybridCache/Factories/IPublishedContentFactory.cs
new file mode 100644
index 0000000000..c5bfe4fe9e
--- /dev/null
+++ b/src/Umbraco.PublishedCache.HybridCache/Factories/IPublishedContentFactory.cs
@@ -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);
+}
diff --git a/src/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactory.cs b/src/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactory.cs
new file mode 100644
index 0000000000..1afc363555
--- /dev/null
+++ b/src/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactory.cs
@@ -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 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 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;
+ }
+}
diff --git a/src/Umbraco.PublishedCache.HybridCache/IElementsCache.cs b/src/Umbraco.PublishedCache.HybridCache/IElementsCache.cs
new file mode 100644
index 0000000000..873a128d53
--- /dev/null
+++ b/src/Umbraco.PublishedCache.HybridCache/IElementsCache.cs
@@ -0,0 +1,7 @@
+using Umbraco.Cms.Core.Cache;
+
+namespace Umbraco.Cms.Infrastructure.HybridCache;
+
+public interface IElementsCache : IAppCache
+{
+}
diff --git a/src/Umbraco.PublishedCache.HybridCache/MediaCache.cs b/src/Umbraco.PublishedCache.HybridCache/MediaCache.cs
new file mode 100644
index 0000000000..53d59da72c
--- /dev/null
+++ b/src/Umbraco.PublishedCache.HybridCache/MediaCache.cs
@@ -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 GetByIdAsync(int id) => await _mediaCacheService.GetByIdAsync(id);
+
+ public async Task 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 GetAtRoot(bool preview, string? culture = null) => throw new NotImplementedException();
+
+ public IEnumerable GetAtRoot(string? culture = null) => throw new NotImplementedException();
+
+ public bool HasContent(bool preview) => throw new NotImplementedException();
+
+ public bool HasContent() => throw new NotImplementedException();
+
+
+ public IEnumerable GetByContentType(IPublishedContentType contentType) =>
+ throw new NotImplementedException();
+}
diff --git a/src/Umbraco.PublishedCache.HybridCache/MemberCache.cs b/src/Umbraco.PublishedCache.HybridCache/MemberCache.cs
new file mode 100644
index 0000000000..e5029d16e6
--- /dev/null
+++ b/src/Umbraco.PublishedCache.HybridCache/MemberCache.cs
@@ -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 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);
+}
diff --git a/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/CacheRefreshingNotificationHandler.cs b/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/CacheRefreshingNotificationHandler.cs
new file mode 100644
index 0000000000..105fad1d9d
--- /dev/null
+++ b/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/CacheRefreshingNotificationHandler.cs
@@ -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,
+ INotificationAsyncHandler,
+ INotificationAsyncHandler,
+ INotificationAsyncHandler,
+ INotificationAsyncHandler
+{
+ 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 parentRelations = _relationService.GetByParent(content)!;
+ IEnumerable 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;
+ }
+}
diff --git a/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/SeedingNotificationHandler.cs b/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/SeedingNotificationHandler.cs
new file mode 100644
index 0000000000..d0dfa76b67
--- /dev/null
+++ b/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/SeedingNotificationHandler.cs
@@ -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
+{
+ private readonly IDocumentCacheService _documentCacheService;
+ private readonly CacheSettings _cacheSettings;
+
+ public SeedingNotificationHandler(IDocumentCacheService documentCacheService, IOptions cacheSettings)
+ {
+ _documentCacheService = documentCacheService;
+ _cacheSettings = cacheSettings.Value;
+ }
+
+ public async Task HandleAsync(UmbracoApplicationStartedNotification notification, CancellationToken cancellationToken) => await _documentCacheService.SeedAsync(_cacheSettings.ContentTypeKeys);
+}
diff --git a/src/Umbraco.PublishedCache.HybridCache/Persistence/ContentSourceDto.cs b/src/Umbraco.PublishedCache.HybridCache/Persistence/ContentSourceDto.cs
new file mode 100644
index 0000000000..4d4fcae73d
--- /dev/null
+++ b/src/Umbraco.PublishedCache.HybridCache/Persistence/ContentSourceDto.cs
@@ -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;
+ }
+}
diff --git a/src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs b/src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs
new file mode 100644
index 0000000000..d49d2f8799
--- /dev/null
+++ b/src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs
@@ -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 _logger;
+ private readonly IMediaRepository _mediaRepository;
+ private readonly IMemberRepository _memberRepository;
+ private readonly IOptions _nucacheSettings;
+ private readonly IShortStringHelper _shortStringHelper;
+ private readonly UrlSegmentProviderCollection _urlSegmentProviders;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public DatabaseCacheRepository(
+ IScopeAccessor scopeAccessor,
+ AppCaches appCaches,
+ ILogger logger,
+ IMemberRepository memberRepository,
+ IDocumentRepository documentRepository,
+ IMediaRepository mediaRepository,
+ IShortStringHelper shortStringHelper,
+ UrlSegmentProviderCollection urlSegmentProviders,
+ IContentCacheDataSerializerFactory contentCacheDataSerializerFactory,
+ IOptions 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);
+ }
+
+ ///
+ public void Rebuild(
+ IReadOnlyCollection? contentTypeIds = null,
+ IReadOnlyCollection? mediaTypeIds = null,
+ IReadOnlyCollection? 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(
+ $@"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(
+ @"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(
+ @"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 GetContentSourceAsync(int id, bool preview = false)
+ {
+ Sql? sql = SqlContentSourcesSelect()
+ .Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document))
+ .Append(SqlWhereNodeId(SqlContext, id))
+ .Append(SqlOrderByLevelIdSortOrder(SqlContext));
+
+ ContentSourceDto? dto = await Database.FirstOrDefaultAsync(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 GetContentByContentTypeKey(IEnumerable keys)
+ {
+ if (keys.Any() is false)
+ {
+ yield break;
+ }
+
+ Sql? sql = SqlContentSourcesSelect()
+ .InnerJoin("n")
+ .On((n, c) => n.NodeId == c.ContentTypeId, "n", "umbracoContent")
+ .Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document))
+ .WhereIn(x => x.UniqueId, keys,"n")
+ .Append(SqlOrderByLevelIdSortOrder(SqlContext));
+
+ IContentCacheDataSerializer serializer =
+ _contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Document);
+
+ IEnumerable dtos = GetContentNodeDtos(sql);
+
+ foreach (ContentSourceDto row in dtos)
+ {
+ yield return CreateContentNodeKit(row, serializer, row.Published is false);
+ }
+ }
+
+ public async Task GetMediaSourceAsync(int id)
+ {
+ Sql? sql = SqlMediaSourcesSelect()
+ .Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Media))
+ .Append(SqlWhereNodeId(SqlContext, id))
+ .Append(SqlOrderByLevelIdSortOrder(SqlContext));
+
+ ContentSourceDto? dto = await Database.FirstOrDefaultAsync(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(),
+ data = dto.Data,
+ id = dto.NodeId,
+ published = dto.Published,
+ });
+ }
+
+ // assumes content tree lock
+ private void RebuildContentDbCache(IContentCacheDataSerializer serializer, int groupSize, IReadOnlyCollection? 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 query = SqlContext.Query();
+ 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 descendants =
+ _documentRepository.GetPage(query, pageIndex++, groupSize, out total, null, Ordering.By("Path"));
+ var items = new List();
+ 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? 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 query = SqlContext.Query();
+ 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 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? 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 query = SqlContext.Query();
+ 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 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();
+ foreach (IProperty prop in content.Properties)
+ {
+ var pdatas = new List();
+ 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();
+
+ // 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 SqlContentSourcesSelect(Func>? joins = null)
+ {
+ SqlTemplate sqlTemplate = SqlContext.Templates.Get(
+ Constants.SqlTemplates.NuCacheDatabaseDataSource.ContentSourcesSelect,
+ tsql =>
+ tsql.Select(
+ 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(x => Alias(x.ContentTypeId, "ContentTypeId"))
+ .AndSelect(x => Alias(x.Published, "Published"), x => Alias(x.Edited, "Edited"))
+ .AndSelect(
+ x => Alias(x.Id, "VersionId"),
+ x => Alias(x.Text, "EditName"),
+ x => Alias(x.VersionDate, "EditVersionDate"),
+ x => Alias(x.UserId, "EditWriterId"))
+ .AndSelect(x => Alias(x.TemplateId, "EditTemplateId"))
+ .AndSelect(
+ "pcver",
+ x => Alias(x.Id, "PublishedVersionId"),
+ x => Alias(x.Text, "PubName"),
+ x => Alias(x.VersionDate, "PubVersionDate"),
+ x => Alias(x.UserId, "PubWriterId"))
+ .AndSelect("pdver", x => Alias(x.TemplateId, "PubTemplateId"))
+ .AndSelect("nuEdit", x => Alias(x.Data, "EditData"))
+ .AndSelect("nuPub", x => Alias(x.Data, "PubData"))
+ .AndSelect("nuEdit", x => Alias(x.RawData, "EditDataRaw"))
+ .AndSelect("nuPub", x => Alias(x.RawData, "PubDataRaw"))
+ .From());
+
+ Sql? 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().On((left, right) => left.NodeId == right.NodeId)
+ .InnerJoin().On((left, right) => left.NodeId == right.NodeId)
+ .InnerJoin()
+ .On((left, right) => left.NodeId == right.NodeId && right.Current)
+ .InnerJoin()
+ .On((left, right) => left.Id == right.Id)
+ .LeftJoin(
+ j =>
+ j.InnerJoin("pdver")
+ .On(
+ (left, right) => left.Id == right.Id && right.Published == true, "pcver", "pdver"),
+ "pcver")
+ .On((left, right) => left.NodeId == right.NodeId, aliasRight: "pcver")
+ .LeftJoin("nuEdit").On(
+ (left, right) => left.NodeId == right.NodeId && right.Published == false, aliasRight: "nuEdit")
+ .LeftJoin("nuPub").On(
+ (left, right) => left.NodeId == right.NodeId && right.Published == true, aliasRight: "nuPub");
+
+ return sql;
+ }
+
+ private Sql SqlContentSourcesSelectUmbracoNodeJoin(ISqlContext sqlContext)
+ {
+ ISqlSyntaxProvider syntax = sqlContext.SqlSyntax;
+
+ SqlTemplate sqlTemplate = sqlContext.Templates.Get(
+ Constants.SqlTemplates.NuCacheDatabaseDataSource.SourcesSelectUmbracoNodeJoin, builder =>
+ builder.InnerJoin("x")
+ .On(
+ (left, right) => left.NodeId == right.NodeId ||
+ SqlText(left.Path, right.Path,
+ (lp, rp) => $"({lp} LIKE {syntax.GetConcat(rp, "',%'")})"),
+ aliasRight: "x"));
+
+ Sql sql = sqlTemplate.Sql();
+ return sql;
+ }
+
+ private Sql SqlWhereNodeId(ISqlContext sqlContext, int id)
+ {
+ ISqlSyntaxProvider syntax = sqlContext.SqlSyntax;
+
+ SqlTemplate sqlTemplate = sqlContext.Templates.Get(
+ Constants.SqlTemplates.NuCacheDatabaseDataSource.WhereNodeId,
+ builder =>
+ builder.Where(x => x.NodeId == SqlTemplate.Arg("id")));
+
+ Sql sql = sqlTemplate.Sql(id);
+ return sql;
+ }
+
+ private Sql SqlOrderByLevelIdSortOrder(ISqlContext sqlContext)
+ {
+ ISqlSyntaxProvider syntax = sqlContext.SqlSyntax;
+
+ SqlTemplate sqlTemplate = sqlContext.Templates.Get(
+ Constants.SqlTemplates.NuCacheDatabaseDataSource.OrderByLevelIdSortOrder, s =>
+ s.OrderBy(x => x.Level, x => x.ParentId, x => x.SortOrder));
+
+ Sql sql = sqlTemplate.Sql();
+ return sql;
+ }
+
+ private Sql SqlObjectTypeNotTrashed(ISqlContext sqlContext, Guid nodeObjectType)
+ {
+ ISqlSyntaxProvider syntax = sqlContext.SqlSyntax;
+
+ SqlTemplate sqlTemplate = sqlContext.Templates.Get(
+ Constants.SqlTemplates.NuCacheDatabaseDataSource.ObjectTypeNotTrashedFilter, s =>
+ s.Where(x =>
+ x.NodeObjectType == SqlTemplate.Arg("nodeObjectType") &&
+ x.Trashed == SqlTemplate.Arg("trashed")));
+
+ Sql sql = sqlTemplate.Sql(nodeObjectType, false);
+ return sql;
+ }
+
+ ///
+ /// Returns a slightly more optimized query to use for the document counting when paging over the content sources
+ ///
+ ///
+ ///
+ private Sql SqlContentSourcesCount(Func>? joins = null)
+ {
+ SqlTemplate sqlTemplate = SqlContext.Templates.Get(
+ Constants.SqlTemplates.NuCacheDatabaseDataSource.ContentSourcesCount, tsql =>
+ tsql.Select(x => Alias(x.NodeId, "Id"))
+ .From()
+ .InnerJoin().On((left, right) => left.NodeId == right.NodeId)
+ .InnerJoin().On((left, right) => left.NodeId == right.NodeId));
+
+ Sql? 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()
+ .On((left, right) => left.NodeId == right.NodeId && right.Current)
+ .InnerJoin()
+ .On((left, right) => left.Id == right.Id)
+ .LeftJoin(
+ j =>
+ j.InnerJoin("pdver")
+ .On(
+ (left, right) => left.Id == right.Id && right.Published,
+ "pcver",
+ "pdver"),
+ "pcver")
+ .On((left, right) => left.NodeId == right.NodeId, aliasRight: "pcver");
+
+ return sql;
+ }
+
+ private Sql SqlMediaSourcesSelect(Func>? joins = null)
+ {
+ SqlTemplate sqlTemplate = SqlContext.Templates.Get(
+ Constants.SqlTemplates.NuCacheDatabaseDataSource.MediaSourcesSelect, tsql =>
+ tsql.Select(
+ 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(x => Alias(x.ContentTypeId, "ContentTypeId"))
+ .AndSelect(
+ x => Alias(x.Id, "VersionId"),
+ x => Alias(x.Text, "EditName"),
+ x => Alias(x.VersionDate, "EditVersionDate"),
+ x => Alias(x.UserId, "EditWriterId"))
+ .AndSelect("nuEdit", x => Alias(x.Data, "EditData"))
+ .AndSelect("nuEdit", x => Alias(x.RawData, "EditDataRaw"))
+ .From());
+
+ Sql? 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().On((left, right) => left.NodeId == right.NodeId)
+ .InnerJoin()
+ .On((left, right) => left.NodeId == right.NodeId && right.Current)
+ .LeftJoin("nuEdit")
+ .On(
+ (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 GetContentNodeDtos(Sql 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 dtos;
+ if (_nucacheSettings.Value.UsePagedSqlQuery)
+ {
+ // Use a more efficient COUNT query
+ Sql? sqlCountQuery = SqlContentSourcesCount()
+ .Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document));
+
+ Sql? sqlCount =
+ SqlContext.Sql("SELECT COUNT(*) FROM (").Append(sqlCountQuery).Append(") npoco_tbl");
+
+ dtos = Database.QueryPaged(_nucacheSettings.Value.SqlPageSize, sql, sqlCount);
+ }
+ else
+ {
+ dtos = Database.Fetch(sql);
+ }
+
+ return dtos;
+ }
+}
diff --git a/src/Umbraco.PublishedCache.HybridCache/Persistence/IDatabaseCacheRepository.cs b/src/Umbraco.PublishedCache.HybridCache/Persistence/IDatabaseCacheRepository.cs
new file mode 100644
index 0000000000..47c18c07e1
--- /dev/null
+++ b/src/Umbraco.PublishedCache.HybridCache/Persistence/IDatabaseCacheRepository.cs
@@ -0,0 +1,57 @@
+using Umbraco.Cms.Core.Models;
+
+namespace Umbraco.Cms.Infrastructure.HybridCache.Persistence;
+
+internal interface IDatabaseCacheRepository
+{
+ Task DeleteContentItemAsync(int id);
+
+ Task GetContentSourceAsync(int id, bool preview = false);
+
+ Task GetMediaSourceAsync(int id);
+
+ IEnumerable GetContentByContentTypeKey(IEnumerable keys);
+
+ ///
+ /// Refreshes the nucache database row for the given cache node />
+ ///
+ /// A representing the asynchronous operation.
+ Task RefreshContentAsync(ContentCacheNode contentCacheNode, PublishedState publishedState);
+
+ ///
+ /// Refreshes the nucache database row for the given cache node />
+ ///
+ /// A representing the asynchronous operation.
+ Task RefreshMediaAsync(ContentCacheNode contentCacheNode);
+
+ ///
+ /// Rebuilds the caches for content, media and/or members based on the content type ids specified
+ ///
+ ///
+ /// If not null will process content for the matching content types, if empty will process all
+ /// content
+ ///
+ ///
+ /// If not null will process content for the matching media types, if empty will process all
+ /// media
+ ///
+ ///
+ /// If not null will process content for the matching members types, if empty will process all
+ /// members
+ ///
+ void Rebuild(
+ IReadOnlyCollection? contentTypeIds = null,
+ IReadOnlyCollection? mediaTypeIds = null,
+ IReadOnlyCollection? memberTypeIds = null);
+
+ ///
+ /// 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
+ ///
+ bool VerifyContentDbCache();
+
+ ///
+ /// Rebuilds the caches for content, media and/or members based on the content type ids specified
+ ///
+ bool VerifyMediaDbCache();
+}
diff --git a/src/Umbraco.PublishedCache.HybridCache/PropertyData.cs b/src/Umbraco.PublishedCache.HybridCache/PropertyData.cs
new file mode 100644
index 0000000000..80897e47ac
--- /dev/null
+++ b/src/Umbraco.PublishedCache.HybridCache/PropertyData.cs
@@ -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; }
+}
diff --git a/src/Umbraco.PublishedCache.HybridCache/PublishedContent.cs b/src/Umbraco.PublishedCache.HybridCache/PublishedContent.cs
new file mode 100644
index 0000000000..21bb651d59
--- /dev/null
+++ b/src/Umbraco.PublishedCache.HybridCache/PublishedContent.cs
@@ -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? _cultures;
+ private readonly string? _urlSegment;
+ private readonly IReadOnlyDictionary? _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 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 ChildrenForAllCultures { get; } = Enumerable.Empty();
+
+ public override IPublishedContent? Parent { get; } = null!;
+
+
+ ///
+ public override IReadOnlyDictionary Cultures
+ {
+ get
+ {
+ if (_cultures != null)
+ {
+ return _cultures;
+ }
+
+ if (!ContentType.VariesByCulture())
+ {
+ return _cultures = new Dictionary
+ {
+ { 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);
+ }
+ }
+
+ ///
+ 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);
+ }
+}
diff --git a/src/Umbraco.PublishedCache.HybridCache/PublishedMember.cs b/src/Umbraco.PublishedCache.HybridCache/PublishedMember.cs
new file mode 100644
index 0000000000..4253b1a4c3
--- /dev/null
+++ b/src/Umbraco.PublishedCache.HybridCache/PublishedMember.cs
@@ -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;
+}
diff --git a/src/Umbraco.PublishedCache.HybridCache/PublishedProperty.cs b/src/Umbraco.PublishedCache.HybridCache/PublishedProperty.cs
new file mode 100644
index 0000000000..91e69d9ed7
--- /dev/null
+++ b/src/Umbraco.PublishedCache.HybridCache/PublishedProperty.cs
@@ -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? _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