diff --git a/src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs b/src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs index fbdfe3375876..be192b24d0a2 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs @@ -143,14 +143,9 @@ private void TruncateContent() /// public async Task GetContentSourceAsync(Guid key, bool preview = false) { - Sql? sql = SqlContentSourcesSelect() - .Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document)) - .Append(SqlWhereNodeKey(SqlContext, key)) - .Append(SqlOrderByLevelIdSortOrder(SqlContext)); + ContentSourceDto? dto = await GetContentSourceDto(key); - ContentSourceDto? dto = await Database.FirstOrDefaultAsync(sql); - - if (dto == null) + if (dto is null) { return null; } @@ -160,6 +155,39 @@ private void TruncateContent() return null; } + return CreateContentNodeKit(preview, dto); + } + + /// + public async Task<(ContentCacheNode? Draft, ContentCacheNode? Published)> GetContentSourceForPublishStatesAsync(Guid key) + { + ContentSourceDto? dto = await GetContentSourceDto(key); + + if (dto is null) + { + return (null, null); + } + + ContentCacheNode draftNode = CreateContentNodeKit(true, dto); + ContentCacheNode? publishedNode = dto.PubDataRaw is null && dto.PubData is null + ? null + : CreateContentNodeKit(false, dto); + + return (draftNode, publishedNode); + } + + private async Task GetContentSourceDto(Guid key) + { + Sql? sql = SqlContentSourcesSelect() + .Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document)) + .Append(SqlWhereNodeKey(SqlContext, key)) + .Append(SqlOrderByLevelIdSortOrder(SqlContext)); + + return await Database.FirstOrDefaultAsync(sql); + } + + private ContentCacheNode CreateContentNodeKit(bool preview, ContentSourceDto dto) + { IContentCacheDataSerializer serializer = _contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Document); return CreateContentNodeKit(dto, serializer, preview); diff --git a/src/Umbraco.PublishedCache.HybridCache/Persistence/IDatabaseCacheRepository.cs b/src/Umbraco.PublishedCache.HybridCache/Persistence/IDatabaseCacheRepository.cs index ede7c426d168..be7b19ceeddd 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Persistence/IDatabaseCacheRepository.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Persistence/IDatabaseCacheRepository.cs @@ -16,11 +16,28 @@ internal interface IDatabaseCacheRepository /// /// Gets a single cache node for a document key. /// + /// The document key. + /// A flag indicating whether to get the draft (preview) version or the published version. Task GetContentSourceAsync(Guid key, bool preview = false); + /// + /// Gets both draft and published cache nodes for a document key in a single query. + /// + /// The document key. + /// A tuple containing the draft and published cache nodes (either may be null). + // TODO (V18): Remove the default implementation on this method. + async Task<(ContentCacheNode? Draft, ContentCacheNode? Published)> GetContentSourceForPublishStatesAsync(Guid key) + { + ContentCacheNode? draftNode = await GetContentSourceAsync(key, preview: true); + ContentCacheNode? publishedNode = await GetContentSourceAsync(key, preview: false); + return (draftNode, publishedNode); + } + /// /// Gets a collection of cache nodes for a collection of document keys. /// + /// The document keys. + /// A flag indicating whether to get the draft (preview) version or the published version. // TODO (V18): Remove the default implementation on this method. async Task> GetContentSourcesAsync(IEnumerable keys, bool preview = false) { diff --git a/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs b/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs index b47e6c58c821..7a0e0f9e622c 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs @@ -180,13 +180,13 @@ public async Task RefreshMemoryCacheAsync(Guid key) using ICoreScope scope = _scopeProvider.CreateCoreScope(); scope.ReadLock(Constants.Locks.ContentTree); - ContentCacheNode? draftNode = await _databaseCacheRepository.GetContentSourceAsync(key, true); + (ContentCacheNode? draftNode, ContentCacheNode? publishedNode) = await _databaseCacheRepository.GetContentSourceForPublishStatesAsync(key); + if (draftNode is not null) { await _hybridCache.SetAsync(GetCacheKey(draftNode.Key, true), draftNode, GetEntryOptions(draftNode.Key, true), GenerateTags(key)); } - ContentCacheNode? publishedNode = await _databaseCacheRepository.GetContentSourceAsync(key, false); if (publishedNode is not null && _publishStatusQueryService.HasPublishedAncestorPath(publishedNode.Key)) { var cacheKey = GetCacheKey(publishedNode.Key, false); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheMockTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheMockTests.cs index 1b683013802d..1f0831795fca 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheMockTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheMockTests.cs @@ -87,6 +87,9 @@ public void SetUp() _mockDatabaseCacheRepository.Setup(r => r.GetContentSourcesAsync(It.IsAny>(), false)) .ReturnsAsync([publishedTestCacheNode]); + _mockDatabaseCacheRepository.Setup(r => r.GetContentSourceForPublishStatesAsync(It.IsAny())) + .ReturnsAsync((draftTestCacheNode, publishedTestCacheNode)); + _mockDatabaseCacheRepository.Setup(r => r.GetContentByContentTypeKey(It.IsAny>(), ContentCacheDataSerializerEntityType.Document)).Returns( new List() { @@ -97,6 +100,7 @@ public void SetUp() var mockedPublishedStatusService = new Mock(); mockedPublishedStatusService.Setup(x => x.IsDocumentPublishedInAnyCulture(It.IsAny())).Returns(true); + mockedPublishedStatusService.Setup(x => x.HasPublishedAncestorPath(It.IsAny())).Returns(true); _documentCacheService = new DocumentCacheService( _mockDatabaseCacheRepository.Object, @@ -235,6 +239,47 @@ public async Task Content_Is_Not_Seeded_If_Unpublished_By_Key() _mockDatabaseCacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1)); } + [Test] + public async Task RefreshMemoryCache_Fetches_Draft_And_Published() + { + // Arrange + var hybridCache = GetRequiredService(); + + // Clear both draft and published cache entries. + await hybridCache.RemoveAsync($"{Textpage.Key}+draft"); + await hybridCache.RemoveAsync($"{Textpage.Key}"); + + // Act + await _documentCacheService.RefreshMemoryCacheAsync(Textpage.Key); + + // Assert - verify only a single call was made to the combined method for retrieving both states. + _mockDatabaseCacheRepository.Verify( + x => x.GetContentSourceForPublishStatesAsync(Textpage.Key), + Times.Exactly(1)); + + // Verify individual GetContentSourceAsync was NOT called + _mockDatabaseCacheRepository.Verify( + x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), + Times.Never); + + // Verify content is now cached - fetching should not hit the repository again. + var draftPage = await _mockedCache.GetByIdAsync(Textpage.Key, true); + var publishedPage = await _mockedCache.GetByIdAsync(Textpage.Key, false); + + Assert.IsNotNull(draftPage); + Assert.IsNotNull(publishedPage); + Assert.AreEqual(Textpage.Name, draftPage.Name); + Assert.AreEqual(Textpage.Name, publishedPage.Name); + + // Verify no additional repository calls were made (content served from cache). + _mockDatabaseCacheRepository.Verify( + x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), + Times.Never); + _mockDatabaseCacheRepository.Verify( + x => x.GetContentSourceForPublishStatesAsync(It.IsAny()), + Times.Exactly(1)); + } + private void AssertTextPage(IPublishedContent textPage) { Assert.Multiple(() =>