Compare commits
138
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5c81fee2d | ||
|
|
4f77db47b4 | ||
|
|
4491995863 | ||
|
|
62663d9573 | ||
|
|
b582f9d2ef | ||
|
|
ad90db8b38 | ||
|
|
8e3b821a55 | ||
|
|
8ac989c4e3 | ||
|
|
3913a61b74 | ||
|
|
90bedcd42e | ||
|
|
c54189aa90 | ||
|
|
88ec0a248f | ||
|
|
3e22733081 | ||
|
|
232077e820 | ||
|
|
943d1eeccd | ||
|
|
b3666dad8b | ||
|
|
28a403361e | ||
|
|
38d73b3a41 | ||
|
|
5c0cb154f5 | ||
|
|
4a621a13bc | ||
|
|
bc2048573c | ||
|
|
424209ac06 | ||
|
|
5cf577bf57 | ||
|
|
ed4b207fe7 | ||
|
|
40e027d0ea | ||
|
|
a6f6bdf8bc | ||
|
|
8e6a791de0 | ||
|
|
5ffea3152b | ||
|
|
2043ff1dbd | ||
|
|
f1bc1db6ce | ||
|
|
c4d5b89fc5 | ||
|
|
7597a8ad40 | ||
|
|
d28507e2e5 | ||
|
|
7b75324172 | ||
|
|
172a3be5ac | ||
|
|
ca28195b7c | ||
|
|
4c1fde9e0c | ||
|
|
f0013330e6 | ||
|
|
bd2c985187 | ||
|
|
9711a5d012 | ||
|
|
808cba2747 | ||
|
|
da0117f240 | ||
|
|
61d3e4c53d | ||
|
|
51d70877d1 | ||
|
|
2dcfe68208 | ||
|
|
ce06c4ba4d | ||
|
|
a91de6e677 | ||
|
|
faf3824a0a | ||
|
|
58ed9899be | ||
|
|
bc7bd9a32a | ||
|
|
8160ede4b6 | ||
|
|
e06a583f1a | ||
|
|
61cc37ad1d | ||
|
|
dc7b34eb58 | ||
|
|
dfe98ffa3b | ||
|
|
6b8e8935fd | ||
|
|
53c74efd35 | ||
|
|
1c058a32d9 | ||
|
|
12f838277c | ||
|
|
1ae2a780dd | ||
|
|
04f0e229c7 | ||
|
|
5d76706553 | ||
|
|
1fdcb835bc | ||
|
|
6b3bdb59b7 | ||
|
|
65ab1c0b2b | ||
|
|
a54769758b | ||
|
|
1616997409 | ||
|
|
609b74b475 | ||
|
|
63289e22cb | ||
|
|
82b2991a18 | ||
|
|
721cf53d40 | ||
|
|
f8ba3d8cfc | ||
|
|
7f832d261d | ||
|
|
f79e9586b4 | ||
|
|
c74a58246f | ||
|
|
06b15157cf | ||
|
|
82f7830d26 | ||
|
|
b87d519bf2 | ||
|
|
8aaac65f83 | ||
|
|
8d5826c61f | ||
|
|
5a73f63cfd | ||
|
|
e463cd3a0c | ||
|
|
2901be793a | ||
|
|
f255fd7bff | ||
|
|
7527de7c56 | ||
|
|
df12a3e467 | ||
|
|
0b86312f52 | ||
|
|
f4592111fa | ||
|
|
4c909d8ce8 | ||
|
|
12c699d5bd | ||
|
|
ba29b91301 | ||
|
|
336bffe4c4 | ||
|
|
426e516c61 | ||
|
|
c718a3ce12 | ||
|
|
1637d9b158 | ||
|
|
7737cd3d40 | ||
|
|
139ac6ad72 | ||
|
|
cd4521bd77 | ||
|
|
80e2764eda | ||
|
|
d9bb17de2a | ||
|
|
72fdf281fd | ||
|
|
b24c9ba8ac | ||
|
|
2377e9a555 | ||
|
|
f964a18b5b | ||
|
|
2369f00544 | ||
|
|
0f438c551c | ||
|
|
d822518dd3 | ||
|
|
abfa8cb144 | ||
|
|
21ab470d88 | ||
|
|
5dd8378c57 | ||
|
|
3aa87fec96 | ||
|
|
8e7440580a | ||
|
|
358d435948 | ||
|
|
dcf1595e74 | ||
|
|
0add0f5b18 | ||
|
|
4921ab9257 | ||
|
|
e07f188bd4 | ||
|
|
63bae5958a | ||
|
|
55fec1dc2a | ||
|
|
35d726ad31 | ||
|
|
dfe93c5639 | ||
|
|
22a7a9577b | ||
|
|
fc9ca861b0 | ||
|
|
c784858b32 | ||
|
|
68194e1a27 | ||
|
|
5ae17ace6a | ||
|
|
cd476ab6ed | ||
|
|
4b66c114c4 | ||
|
|
0bdb1bb1ed | ||
|
|
1c1787c445 | ||
|
|
6c5873047b | ||
|
|
a434ad7b33 | ||
|
|
3714ebbb29 | ||
|
|
58b047bf7e | ||
|
|
1214771847 | ||
|
|
396497a921 | ||
|
|
bf32f9e5a6 | ||
|
|
3220739faa |
@@ -435,6 +435,14 @@ When a PR changes Management API controllers or models, the `OpenApi.json` file
|
||||
|
||||
The backoffice is published to npm as `@umbraco-cms/backoffice`. Runtime dependencies are provided via importmap; npm peerDependencies provide types only. For full details on dependency hoisting, version range logic, and plugin development, see `/src/Umbraco.Web.UI.Client/CLAUDE.md` → "npm Package Publishing".
|
||||
|
||||
### SQL Server 2100-parameter limit
|
||||
|
||||
Any `WHERE IN (@0, @1, ...)` built from a runtime-sized collection risks hitting SQL Server's 2100-parameter ceiling and throwing `SqlException` 8003 in production.
|
||||
|
||||
Batch with `IEnumerable<T>.InGroupsOf(Constants.Sql.MaxParameterCount)` or `Database.FetchByGroups(...)` whenever the collection size is driven by user data — not just when it currently fits. Watch for products of two scaling dimensions (documents × languages, properties × versions) and config-tunable batch sizes whose defaults are safe but ceilings aren't.
|
||||
|
||||
Full guidance, safe patterns and decision rule: see `/src/Umbraco.Infrastructure/CLAUDE.md` → "Avoiding the SQL Server 2100-parameter limit".
|
||||
|
||||
### Known Limitations
|
||||
|
||||
1. **Circular Dependencies**: Avoided via `Lazy<T>` or event notifications
|
||||
@@ -531,6 +539,14 @@ Allowed, but cheap to write and cheaper to leave behind. Keep them short and tra
|
||||
|
||||
---
|
||||
|
||||
## 9. Testing Practices
|
||||
|
||||
### Tests for a bug fix must fail before the fix
|
||||
|
||||
Verify any test you add for a bug fix actually catches the bug: either write the failing test first (TDD), or temporarily revert the production change and confirm the test fails before re-applying. A test that passes both ways proves nothing. Watch for coincidental passes — default seed/sort orders can make a buggy path produce the right answer for the test's specific inputs; construct inputs so the broken and fixed behaviours give visibly different results.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Essential Commands
|
||||
|
||||
@@ -45,8 +45,8 @@
|
||||
<PackageVersion Include="Asp.Versioning.Mvc" Version="8.1.1" />
|
||||
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.1" />
|
||||
<PackageVersion Include="Dazinator.Extensions.FileProviders" Version="2.0.0" />
|
||||
<PackageVersion Include="Examine" Version="3.7.1" />
|
||||
<PackageVersion Include="Examine.Core" Version="3.7.1" />
|
||||
<PackageVersion Include="Examine" Version="3.8.0" />
|
||||
<PackageVersion Include="Examine.Core" Version="3.8.0" />
|
||||
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
|
||||
<PackageVersion Include="JsonPatch.Net" Version="3.3.0" />
|
||||
<PackageVersion Include="K4os.Compression.LZ4" Version="1.3.8" />
|
||||
@@ -92,4 +92,4 @@
|
||||
<!-- TODO: Remove this pinned dependency when Examine updates its Microsoft.AspNetCore.DataProtection reference. -->
|
||||
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.6" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -5,10 +5,10 @@ trigger: none
|
||||
|
||||
schedules:
|
||||
- cron: '0 3 * * *'
|
||||
displayName: Daily 3AM build (main)
|
||||
displayName: Daily 3AM build (v17/dev)
|
||||
branches:
|
||||
include:
|
||||
- main
|
||||
- v17/dev
|
||||
|
||||
parameters:
|
||||
- name: skipIntegrationTests
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Umbraco.Cms.Api.Common.DependencyInjection;
|
||||
using Umbraco.Cms.Api.Delivery.Accessors;
|
||||
@@ -162,6 +163,12 @@ public static class UmbracoBuilderExtensions
|
||||
builder.Services.AddUnique<IDeliveryApiOutputCacheRequestFilter, DefaultDeliveryApiOutputCacheRequestFilter>();
|
||||
builder.Services.AddUnique<IDeliveryApiOutputCacheManager, DeliveryApiOutputCacheManager>();
|
||||
|
||||
// Signal that Umbraco has enabled output caching so the application builder registers
|
||||
// the output cache middleware. Gated via a marker rather than IOutputCacheStore so that
|
||||
// applications calling services.AddOutputCache(...) for their own purposes are not
|
||||
// affected by Umbraco's automatic middleware registration.
|
||||
builder.Services.TryAddSingleton<IUmbracoManagedOutputCacheMarker, UmbracoManagedOutputCacheMarker>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -53,11 +53,14 @@ public class SearchDataTypeItemController : DatatypeItemControllerBase
|
||||
return Ok(new PagedModel<DataTypeItemResponseModel> { Total = searchResult.Total });
|
||||
}
|
||||
|
||||
IEnumerable<IDataType> dataTypes = await _dataTypeService.GetAllAsync(searchResult.Items.Select(item => item.Key).ToArray());
|
||||
Guid[] keys = searchResult.Items.Select(x => x.Key).ToArray();
|
||||
IEnumerable<IDataType> dataTypes = await _dataTypeService.GetAllAsync(keys);
|
||||
IEnumerable<IDataType> orderedDataTypes = OrderByRequestedIds(dataTypes, keys);
|
||||
|
||||
var result = new PagedModel<DataTypeItemResponseModel>
|
||||
{
|
||||
Items = _mapper.MapEnumerable<IDataType, DataTypeItemResponseModel>(dataTypes),
|
||||
Total = searchResult.Total
|
||||
Items = _mapper.MapEnumerable<IDataType, DataTypeItemResponseModel>(orderedDataTypes),
|
||||
Total = searchResult.Total,
|
||||
};
|
||||
|
||||
return Ok(result);
|
||||
|
||||
+6
-3
@@ -54,11 +54,14 @@ public class SearchMediaTypeItemController : MediaTypeItemControllerBase
|
||||
return Task.FromResult<IActionResult>(Ok(new PagedModel<MediaTypeItemResponseModel> { Total = searchResult.Total }));
|
||||
}
|
||||
|
||||
IEnumerable<IMediaType> mediaTypes = _mediaTypeService.GetMany(searchResult.Items.Select(item => item.Key).ToArray().EmptyNull());
|
||||
Guid[] keys = searchResult.Items.Select(item => item.Key).ToArray();
|
||||
IEnumerable<IMediaType> mediaTypes = _mediaTypeService.GetMany(keys.EmptyNull());
|
||||
IEnumerable<IMediaType> orderedMediaTypes = OrderByRequestedIds(mediaTypes, keys);
|
||||
|
||||
var result = new PagedModel<MediaTypeItemResponseModel>
|
||||
{
|
||||
Items = _mapper.MapEnumerable<IMediaType, MediaTypeItemResponseModel>(mediaTypes),
|
||||
Total = searchResult.Total
|
||||
Items = _mapper.MapEnumerable<IMediaType, MediaTypeItemResponseModel>(orderedMediaTypes),
|
||||
Total = searchResult.Total,
|
||||
};
|
||||
|
||||
return Task.FromResult<IActionResult>(Ok(result));
|
||||
|
||||
+14
-3
@@ -32,6 +32,14 @@ public class SearchMemberTypeItemController : MemberTypeItemControllerBase
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for member type items matching the specified query, with support for pagination.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <param name="query">The search query used to filter member type items.</param>
|
||||
/// <param name="skip">The number of items to skip before starting to collect the result set (used for pagination).</param>
|
||||
/// <param name="take">The maximum number of items to return in the result set (used for pagination).</param>
|
||||
/// <returns>A task representing the asynchronous operation. The task result contains an <see cref="IActionResult"/> with a <see cref="PagedModel{MemberTypeItemResponseModel}"/> containing the search results.</returns>
|
||||
[HttpGet("search")]
|
||||
[MapToApiVersion("1.0")]
|
||||
[ProducesResponseType(typeof(PagedModel<MemberTypeItemResponseModel>), StatusCodes.Status200OK)]
|
||||
@@ -45,11 +53,14 @@ public class SearchMemberTypeItemController : MemberTypeItemControllerBase
|
||||
return Task.FromResult<IActionResult>(Ok(new PagedModel<MemberTypeItemResponseModel> { Total = searchResult.Total }));
|
||||
}
|
||||
|
||||
IEnumerable<IMemberType> memberTypes = _memberTypeService.GetMany(searchResult.Items.Select(item => item.Key).ToArray());
|
||||
Guid[] keys = searchResult.Items.Select(item => item.Key).ToArray();
|
||||
IEnumerable<IMemberType> memberTypes = _memberTypeService.GetMany(keys);
|
||||
IEnumerable<IMemberType> orderedMemberTypes = OrderByRequestedIds(memberTypes, keys);
|
||||
|
||||
var result = new PagedModel<MemberTypeItemResponseModel>
|
||||
{
|
||||
Items = _mapper.MapEnumerable<IMemberType, MemberTypeItemResponseModel>(memberTypes),
|
||||
Total = searchResult.Total
|
||||
Items = _mapper.MapEnumerable<IMemberType, MemberTypeItemResponseModel>(orderedMemberTypes),
|
||||
Total = searchResult.Total,
|
||||
};
|
||||
|
||||
return Task.FromResult<IActionResult>(Ok(result));
|
||||
|
||||
+16
-45
@@ -8,67 +8,38 @@ using Umbraco.Cms.Core.Security;
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.RedirectUrlManagement;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for setting the redirect URL tracking status.
|
||||
/// Controller for setting the redirect URL tracking status. Retained for backwards compatibility only;
|
||||
/// the endpoint no longer modifies any configuration.
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Obsolete("This controller is deprecated and no longer modifies the configuration. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
|
||||
public class SetStatusRedirectUrlManagementController : RedirectUrlManagementControllerBase
|
||||
{
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
private readonly IConfigManipulator _configManipulator;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SetStatusRedirectUrlManagementController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="backOfficeSecurityAccessor">The back office security accessor.</param>
|
||||
/// <param name="configManipulator">The configuration manipulator.</param>
|
||||
/// <param name="backOfficeSecurityAccessor">Ignored. Retained for binary compatibility.</param>
|
||||
/// <param name="configManipulator">Ignored. Retained for binary compatibility.</param>
|
||||
public SetStatusRedirectUrlManagementController(
|
||||
#pragma warning disable IDE0060 // Remove unused parameter
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
|
||||
IConfigManipulator configManipulator)
|
||||
#pragma warning restore IDE0060 // Remove unused parameter
|
||||
{
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
_configManipulator = configManipulator;
|
||||
}
|
||||
|
||||
// TODO: Consider if we should even allow this, or only allow using the appsettings
|
||||
// We generally don't want to edit the appsettings from our code.
|
||||
// But maybe there is a valid use case for doing it on the fly.
|
||||
/// <summary>
|
||||
/// Sets the redirect URL tracking status.
|
||||
/// Deprecated. Returns an OK response without modifying any configuration. To toggle redirect URL tracking,
|
||||
/// set the <c>Umbraco:CMS:WebRouting:DisableRedirectUrlTracking</c> configuration key instead.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token for the HTTP request.</param>
|
||||
/// <param name="status">The redirect status to set.</param>
|
||||
/// <returns>An OK result if successful.</returns>
|
||||
/// <param name="status">The redirect status (ignored).</param>
|
||||
/// <returns>An OK result.</returns>
|
||||
[HttpPost("status")]
|
||||
[EndpointSummary("Sets the redirect URL tracking status.")]
|
||||
[EndpointDescription("Updates the redirect URL tracking configuration according to the provided status.")]
|
||||
[EndpointSummary("Deprecated. No longer changes the redirect URL tracking status.")]
|
||||
[EndpointDescription("This endpoint is deprecated and no longer modifies the configuration. To toggle redirect URL tracking, set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead.")]
|
||||
[MapToApiVersion("1.0")]
|
||||
public async Task<IActionResult> SetStatus(CancellationToken cancellationToken, [FromQuery] RedirectStatus status)
|
||||
{
|
||||
// TODO: uncomment this when auth is implemented.
|
||||
// var userIsAdmin = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.IsAdmin();
|
||||
// if (userIsAdmin is null or false)
|
||||
// {
|
||||
// return Unauthorized();
|
||||
// }
|
||||
|
||||
var enable = status switch
|
||||
{
|
||||
RedirectStatus.Enabled => true,
|
||||
RedirectStatus.Disabled => false,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status), status, "Unknown redirect status")
|
||||
};
|
||||
|
||||
// For now I'm not gonna change this to limit breaking, but it's weird to have a "disabled" switch,
|
||||
// since you're essentially negating the boolean from the get go,
|
||||
// it's much easier to reason with enabled = false == disabled.
|
||||
await _configManipulator.SaveDisableRedirectUrlTrackingAsync(!enable);
|
||||
|
||||
// Taken from the existing implementation in RedirectUrlManagementController
|
||||
// TODO this is ridiculous, but we need to ensure the configuration is reloaded, before this request is ended.
|
||||
// otherwise we can read the old value in GetEnableState.
|
||||
// The value is equal to JsonConfigurationSource.ReloadDelay
|
||||
Thread.Sleep(250);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
[Obsolete("This endpoint is deprecated and no longer modifies the configuration. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
|
||||
public Task<IActionResult> SetStatus(CancellationToken cancellationToken, [FromQuery] RedirectStatus status)
|
||||
=> Task.FromResult<IActionResult>(Ok());
|
||||
}
|
||||
|
||||
+14
-3
@@ -32,6 +32,14 @@ public class SearchTemplateItemController : TemplateItemControllerBase
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for template items matching the specified query, with support for pagination.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <param name="query">The search query used to filter template items.</param>
|
||||
/// <param name="skip">The number of items to skip before starting to collect the result set (used for pagination).</param>
|
||||
/// <param name="take">The maximum number of items to return in the result set (used for pagination).</param>
|
||||
/// <returns>A task representing the asynchronous operation. The task result contains an <see cref="IActionResult"/> with a <see cref="PagedModel{TemplateItemResponseModel}"/> containing the search results.</returns>
|
||||
[HttpGet("search")]
|
||||
[MapToApiVersion("1.0")]
|
||||
[ProducesResponseType(typeof(PagedModel<TemplateItemResponseModel>), StatusCodes.Status200OK)]
|
||||
@@ -45,11 +53,14 @@ public class SearchTemplateItemController : TemplateItemControllerBase
|
||||
return Ok(new PagedModel<TemplateItemResponseModel> { Total = searchResult.Total });
|
||||
}
|
||||
|
||||
IEnumerable<ITemplate> templates = await _templateService.GetAllAsync(searchResult.Items.Select(item => item.Key).ToArray());
|
||||
Guid[] keys = searchResult.Items.Select(x => x.Key).ToArray();
|
||||
IEnumerable<ITemplate> templates = await _templateService.GetAllAsync(keys);
|
||||
IEnumerable<ITemplate> orderedTemplates = OrderByRequestedIds(templates, keys);
|
||||
|
||||
var result = new PagedModel<TemplateItemResponseModel>
|
||||
{
|
||||
Items = _mapper.MapEnumerable<ITemplate, TemplateItemResponseModel>(templates),
|
||||
Total = searchResult.Total
|
||||
Items = _mapper.MapEnumerable<ITemplate, TemplateItemResponseModel>(orderedTemplates),
|
||||
Total = searchResult.Total,
|
||||
};
|
||||
|
||||
return Ok(result);
|
||||
|
||||
@@ -5,6 +5,7 @@ using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Hosting;
|
||||
using Umbraco.Cms.Core.IO;
|
||||
using Umbraco.Cms.Web.Common.Hosting;
|
||||
using Umbraco.Cms.Web.Common.Middleware;
|
||||
|
||||
namespace Umbraco.Extensions;
|
||||
|
||||
@@ -68,6 +69,10 @@ public static partial class UmbracoBuilderExtensions
|
||||
builder.Services.AddSingleton<IBackOfficeEnabledMarker, BackOfficeEnabledMarker>();
|
||||
|
||||
builder.Services.AddUnique<IBackOfficePathGenerator, UmbracoBackOfficePathGenerator>();
|
||||
// Registered here rather than in AddWebComponents because the middleware depends on
|
||||
// IBackOfficePathGenerator (registered just above). DI scope validation would otherwise
|
||||
// fail in Delivery-only/Website-only bootstraps that never call AddBackOffice().
|
||||
builder.Services.AddSingleton<UmbracoBackOfficeCacheHeadersMiddleware>();
|
||||
builder.Services.AddUnique<IPhysicalFileSystem>(factory =>
|
||||
{
|
||||
var path = "~/";
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.DataType;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.PropertyEditors;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
@@ -16,6 +19,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
private readonly IDataValueEditorFactory _dataValueEditorFactory;
|
||||
private readonly IConfigurationEditorJsonSerializer _configurationEditorJsonSerializer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<DataTypePresentationFactory> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DataTypePresentationFactory"/> class, which is responsible for creating data type presentation models.
|
||||
@@ -25,18 +29,46 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
/// <param name="dataValueEditorFactory">Factory for creating data value editors.</param>
|
||||
/// <param name="configurationEditorJsonSerializer">Serializer for configuration editor JSON data.</param>
|
||||
/// <param name="timeProvider">Provides the current time for time-dependent operations.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public DataTypePresentationFactory(
|
||||
IDataTypeContainerService dataTypeContainerService,
|
||||
PropertyEditorCollection propertyEditorCollection,
|
||||
IDataValueEditorFactory dataValueEditorFactory,
|
||||
IConfigurationEditorJsonSerializer configurationEditorJsonSerializer,
|
||||
TimeProvider timeProvider)
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DataTypePresentationFactory> logger)
|
||||
{
|
||||
_dataTypeContainerService = dataTypeContainerService;
|
||||
_propertyEditorCollection = propertyEditorCollection;
|
||||
_dataValueEditorFactory = dataValueEditorFactory;
|
||||
_configurationEditorJsonSerializer = configurationEditorJsonSerializer;
|
||||
_timeProvider = timeProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DataTypePresentationFactory"/> class, which is responsible for creating data type presentation models.
|
||||
/// </summary>
|
||||
/// <param name="dataTypeContainerService">Service used to manage data type containers.</param>
|
||||
/// <param name="propertyEditorCollection">A collection containing all available property editors.</param>
|
||||
/// <param name="dataValueEditorFactory">Factory for creating data value editors.</param>
|
||||
/// <param name="configurationEditorJsonSerializer">Serializer for configuration editor JSON data.</param>
|
||||
/// <param name="timeProvider">Provides the current time for time-dependent operations.</param>
|
||||
[Obsolete("Please use the constructor that takes all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
public DataTypePresentationFactory(
|
||||
IDataTypeContainerService dataTypeContainerService,
|
||||
PropertyEditorCollection propertyEditorCollection,
|
||||
IDataValueEditorFactory dataValueEditorFactory,
|
||||
IConfigurationEditorJsonSerializer configurationEditorJsonSerializer,
|
||||
TimeProvider timeProvider)
|
||||
: this(
|
||||
dataTypeContainerService,
|
||||
propertyEditorCollection,
|
||||
dataValueEditorFactory,
|
||||
configurationEditorJsonSerializer,
|
||||
timeProvider,
|
||||
StaticServiceProvider.Instance.GetRequiredService<ILogger<DataTypePresentationFactory>>())
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -72,7 +104,6 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
dataType.Key = requestModel.Id.Value;
|
||||
}
|
||||
|
||||
|
||||
return Attempt.SucceedWithStatus<IDataType, DataTypeOperationStatus>(DataTypeOperationStatus.Success, dataType);
|
||||
}
|
||||
|
||||
@@ -82,7 +113,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
{
|
||||
try
|
||||
{
|
||||
var parent = await _dataTypeContainerService.GetAsync(requestModel.Parent.Id);
|
||||
EntityContainer? parent = await _dataTypeContainerService.GetAsync(requestModel.Parent.Id);
|
||||
|
||||
return parent is null
|
||||
? Attempt.FailWithStatus(DataTypeOperationStatus.ParentNotFound, 0)
|
||||
@@ -97,6 +128,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
return Attempt.SucceedWithStatus(DataTypeOperationStatus.Success, Constants.System.Root);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<Attempt<IDataType, DataTypeOperationStatus>> CreateAsync(UpdateDataTypeRequestModel requestModel, IDataType current)
|
||||
{
|
||||
if (!_propertyEditorCollection.TryGet(requestModel.EditorAlias, out IDataEditor? editor))
|
||||
@@ -104,7 +136,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
return Task.FromResult(Attempt.FailWithStatus<IDataType, DataTypeOperationStatus>(DataTypeOperationStatus.PropertyEditorNotFound, new DataType(new VoidEditor(_dataValueEditorFactory), _configurationEditorJsonSerializer) ));
|
||||
}
|
||||
|
||||
IDataType dataType = (IDataType)current.DeepClone();
|
||||
var dataType = (IDataType)current.DeepClone();
|
||||
|
||||
IDictionary<string, object> configurationData = MapConfigurationData(requestModel, editor);
|
||||
dataType.Name = requestModel.Name;
|
||||
@@ -119,12 +151,26 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
|
||||
private ValueStorageType GetEditorValueStorageType(IDataEditor editor, IDictionary<string, object> configurationData)
|
||||
{
|
||||
var configurationObject = editor.GetConfigurationEditor()
|
||||
.ToConfigurationObject(configurationData, _configurationEditorJsonSerializer);
|
||||
|
||||
if (configurationObject is IConfigureValueType configureValueType)
|
||||
// Only editors whose configuration object implements IConfigureValueType derive their storage
|
||||
// type from the configuration. Building the typed configuration object can throw for editors
|
||||
// whose stored configuration doesn't cleanly deserialize into their configuration type; that
|
||||
// must not fail the save, so fall back to the value editor's value type in that case.
|
||||
try
|
||||
{
|
||||
return ValueTypes.ToStorageType(configureValueType.ValueType);
|
||||
if (editor.GetConfigurationEditor().ToConfigurationObject(configurationData, _configurationEditorJsonSerializer)
|
||||
is IConfigureValueType configureValueType)
|
||||
{
|
||||
return ValueTypes.ToStorageType(configureValueType.ValueType);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Configuration editors are third-party and can throw anything when the stored configuration
|
||||
// doesn't deserialize into their configuration type. Fall back to the value editor's value type
|
||||
// rather than failing the save, but log so the misconfiguration remains observable.
|
||||
_logger.LogError(
|
||||
"Could not build the configuration object for editor {EditorAlias} to determine its value storage type; falling back to the value editor's value type.",
|
||||
editor.Alias);
|
||||
}
|
||||
|
||||
var valueType = editor.GetValueEditor().ValueType;
|
||||
|
||||
+3
-2
@@ -27860,8 +27860,8 @@
|
||||
"tags": [
|
||||
"Redirect Management"
|
||||
],
|
||||
"summary": "Sets the redirect URL tracking status.",
|
||||
"description": "Updates the redirect URL tracking configuration according to the provided status.",
|
||||
"summary": "Deprecated. No longer changes the redirect URL tracking status.",
|
||||
"description": "This endpoint is deprecated and no longer modifies the configuration. To toggle redirect URL tracking, set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead.",
|
||||
"operationId": "PostRedirectManagementStatus",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -27907,6 +27907,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"deprecated": true,
|
||||
"security": [
|
||||
{
|
||||
"Backoffice-User": [ ]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,8 +15,9 @@ SQLite-specific EF Core provider for Umbraco CMS. Contains SQLite migrations and
|
||||
This is a thin provider project that implements SQLite-specific functionality for the EF Core persistence layer:
|
||||
|
||||
1. **Migration Provider** - Executes SQLite-specific migrations
|
||||
2. **Migration Provider Setup** - Configures DbContext to use SQLite
|
||||
2. **Migration Provider Setup** - Configures DbContext to use SQLite (incl. transient-error retry)
|
||||
3. **Migrations** - SQLite-specific migration files for OpenIddict tables
|
||||
4. **Retrying Execution Strategy** - Retries transient SQLite lock errors on EF Core operations
|
||||
|
||||
### Folder Structure
|
||||
|
||||
@@ -30,7 +31,8 @@ Umbraco.Cms.Persistence.EFCore.Sqlite/
|
||||
│ └── UmbracoDbContextModelSnapshot.cs # Current model state
|
||||
├── EFCoreSqliteComposer.cs # DI registration
|
||||
├── SqliteMigrationProvider.cs # IMigrationProvider impl
|
||||
└── SqliteMigrationProviderSetup.cs # IMigrationProviderSetup impl
|
||||
├── SqliteMigrationProviderSetup.cs # IMigrationProviderSetup impl
|
||||
└── SqliteRetryingExecutionStrategy.cs # IExecutionStrategy for transient lock errors
|
||||
```
|
||||
|
||||
### Relationship with Parent Project
|
||||
@@ -65,7 +67,19 @@ Registers `IMigrationProvider` and `IMigrationProviderSetup` for SQLite.
|
||||
|
||||
### SqliteMigrationProviderSetup (line 11-14)
|
||||
|
||||
Configures `DbContextOptionsBuilder` with `UseSqlite` and migrations assembly.
|
||||
Configures `DbContextOptionsBuilder` with `UseSqlite`, the migrations assembly, and the
|
||||
`SqliteRetryingExecutionStrategy` (see below). Invoked from
|
||||
`UmbracoDbContext.ConfigureOptions` for every `UmbracoDbContext` instance, so all EF Core
|
||||
access to the Umbraco database (including OpenIddict's token store) inherits the retry.
|
||||
|
||||
### SqliteRetryingExecutionStrategy
|
||||
|
||||
Custom `Microsoft.EntityFrameworkCore.Storage.ExecutionStrategy` that retries on transient
|
||||
SQLite errors (`SQLITE_BUSY`, `SQLITE_LOCKED`) using `SqliteExceptionExtensions.IsBusyOrLocked`
|
||||
from the parent project. Defaults inherit `ExecutionStrategy.DefaultMaxRetryCount` (6) and
|
||||
`ExecutionStrategy.DefaultMaxDelay` (30s), giving a ~56-second retry budget — see the class's
|
||||
XML doc for the rationale and the unattended-upgrade escape hatch for very long migrations.
|
||||
Added to resolve issue #22939 (OpenIddict token reads failing during long migrations).
|
||||
|
||||
---
|
||||
|
||||
@@ -122,7 +136,8 @@ All tables prefixed with `umbraco`:
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `SqliteMigrationProvider.cs` | Migration execution |
|
||||
| `SqliteMigrationProviderSetup.cs` | DbContext configuration |
|
||||
| `SqliteMigrationProviderSetup.cs` | DbContext configuration (UseSqlite + retry strategy) |
|
||||
| `SqliteRetryingExecutionStrategy.cs` | Retry on transient SQLite BUSY/LOCKED errors |
|
||||
| `EFCoreSqliteComposer.cs` | DI registration |
|
||||
| `Migrations/*.cs` | Migration files |
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Persistence.EFCore.Migrations;
|
||||
|
||||
namespace Umbraco.Cms.Persistence.EFCore.Sqlite;
|
||||
@@ -15,6 +14,15 @@ public class SqliteMigrationProviderSetup : IMigrationProviderSetup
|
||||
/// <inheritdoc />
|
||||
public void Setup(DbContextOptionsBuilder builder, string? connectionString)
|
||||
{
|
||||
builder.UseSqlite(connectionString, x => x.MigrationsAssembly(GetType().Assembly.FullName));
|
||||
builder.UseSqlite(connectionString, x =>
|
||||
{
|
||||
x.MigrationsAssembly(GetType().Assembly.FullName);
|
||||
|
||||
// Retry transient SQLite errors (BUSY / LOCKED). See SqliteRetryingExecutionStrategy
|
||||
// for the rationale — long-running migrations or schema-modifying operations can
|
||||
// briefly lock the database in a way that surfaces as a hard error to concurrent
|
||||
// EF Core readers (notably OpenIddict token validation). See issue #22939.
|
||||
x.ExecutionStrategy(deps => new SqliteRetryingExecutionStrategy(deps));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace Umbraco.Cms.Persistence.EFCore.Sqlite;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core execution strategy that retries on transient SQLite errors (BUSY / LOCKED).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// SQLite serialises writers at the database level, and schema-modifying statements briefly
|
||||
/// block readers — even in WAL mode. Without retries, concurrent EF Core reads (for example
|
||||
/// OpenIddict's token validation against <c>umbracoOpenIddictTokens</c>) surface those
|
||||
/// transient locks as <see cref="SqliteException"/> and fail the caller's request.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Microsoft does not ship a built-in execution strategy for SQLite (only the SQL Server
|
||||
/// equivalent), so we provide this one. It piggy-backs on <see cref="ExecutionStrategy"/>'s
|
||||
/// default exponential backoff and re-uses its inherited
|
||||
/// <see cref="ExecutionStrategy.DefaultMaxRetryCount"/> (6) and
|
||||
/// <see cref="ExecutionStrategy.DefaultMaxDelay"/> (30 seconds), which produce a delay
|
||||
/// schedule of roughly 0s, 1s, 3s, 7s, 15s, 30s — a ~56-second retry window.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// On top of those EF Core delays, <c>SQLITE_BUSY</c> (error 5) is also retried internally
|
||||
/// by Microsoft.Data.Sqlite for up to the connection's <c>Default Timeout</c> (30 seconds
|
||||
/// by default) per attempt. <c>SQLITE_LOCKED</c> (error 6) is not — it returns immediately,
|
||||
/// so EF Core's retry budget is the only buffer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class SqliteRetryingExecutionStrategy : ExecutionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SqliteRetryingExecutionStrategy"/> class
|
||||
/// with default retry settings inherited from <see cref="ExecutionStrategy"/>.
|
||||
/// </summary>
|
||||
/// <param name="dependencies">Parameter object containing service dependencies.</param>
|
||||
public SqliteRetryingExecutionStrategy(ExecutionStrategyDependencies dependencies)
|
||||
: this(dependencies, DefaultMaxRetryCount, DefaultMaxDelay)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SqliteRetryingExecutionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dependencies">Parameter object containing service dependencies.</param>
|
||||
/// <param name="maxRetryCount">The maximum number of retry attempts.</param>
|
||||
/// <param name="maxRetryDelay">The maximum delay between retries.</param>
|
||||
public SqliteRetryingExecutionStrategy(
|
||||
ExecutionStrategyDependencies dependencies,
|
||||
int maxRetryCount,
|
||||
TimeSpan maxRetryDelay)
|
||||
: base(dependencies, maxRetryCount, maxRetryDelay)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override bool ShouldRetryOn(Exception exception)
|
||||
{
|
||||
// EF Core wraps provider exceptions, so walk the inner-exception chain.
|
||||
for (Exception? current = exception; current is not null; current = current.InnerException)
|
||||
{
|
||||
if (current is SqliteException sqlite && sqlite.IsBusyOrLocked())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+1
-7
@@ -184,17 +184,11 @@ internal sealed class SqliteEFCoreDistributedLockingMechanism<T> : IDistributedL
|
||||
throw new ArgumentException($"LockObject with id={LockId} does not exist.");
|
||||
}
|
||||
}
|
||||
catch (SqliteException ex) when (IsBusyOrLocked(ex))
|
||||
catch (SqliteException ex) when (ex.IsBusyOrLocked())
|
||||
{
|
||||
throw new DistributedWriteLockTimeoutException(LockId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static bool IsBusyOrLocked(SqliteException ex) =>
|
||||
ex.SqliteErrorCode
|
||||
is raw.SQLITE_BUSY
|
||||
or raw.SQLITE_LOCKED
|
||||
or raw.SQLITE_LOCKED_SHAREDCACHE;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using SQLitePCL;
|
||||
|
||||
namespace Umbraco.Cms.Persistence.EFCore;
|
||||
|
||||
/// <summary>
|
||||
/// SQLite-specific exception helpers for code running on the EF Core persistence stack.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A parallel helper exists at <c>Umbraco.Cms.Persistence.Sqlite.Services.SqliteExceptionExtensions</c>
|
||||
/// for the NPoco stack. Both stacks are independent (neither references the other) so the small
|
||||
/// duplication is intentional — keeps the layering clean.
|
||||
/// </remarks>
|
||||
public static class SqliteExceptionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines if the SQLite exception is a BUSY or LOCKED error.
|
||||
/// </summary>
|
||||
/// <param name="ex">The SQLite exception to check.</param>
|
||||
/// <returns><c>true</c> if the error is BUSY, LOCKED, or LOCKED_SHAREDCACHE; otherwise <c>false</c>.</returns>
|
||||
public static bool IsBusyOrLocked(this SqliteException ex) =>
|
||||
ex.SqliteErrorCode
|
||||
is raw.SQLITE_BUSY
|
||||
or raw.SQLITE_LOCKED
|
||||
or raw.SQLITE_LOCKED_SHAREDCACHE;
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
var backOfficeAssetsPath = BackOfficePathGenerator.BackOfficeAssetsPath;
|
||||
var loginLogoImageAlternative = Url.RouteUrl(BackOfficeGraphicsController.LoginLogoAlternativeRouteName, new {Version= "1"});
|
||||
}<!doctype html>
|
||||
<html lang="@GlobalSettings.Value.DefaultUILanguage">
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -61,7 +61,7 @@
|
||||
<p>Here are the <a href="https://www.enable-javascript.com/" target="_blank" rel="noopener" style="text-decoration: underline;">instructions how to enable JavaScript in your web browser</a>.</p>
|
||||
</div>
|
||||
</noscript>
|
||||
<umb-app @(SecuritySettings.Value.KeepUserLoggedIn ? "keep-user-logged-in" : "")></umb-app>
|
||||
<umb-app lang="@GlobalSettings.Value.DefaultUILanguage" @(SecuritySettings.Value.KeepUserLoggedIn ? "keep-user-logged-in" : "")></umb-app>
|
||||
|
||||
@if (isDebug)
|
||||
{
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="@GlobalSettings.Value.DefaultUILanguage">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<base href="@backOfficePath.EnsureEndsWith('/')" />
|
||||
@@ -83,6 +83,7 @@
|
||||
</noscript>
|
||||
|
||||
<umb-auth
|
||||
lang="@GlobalSettings.Value.DefaultUILanguage"
|
||||
return-url="@backOfficePath"
|
||||
logo-image="@loginLogoImage"
|
||||
logo-image-alternative="@loginLogoImageAlternative"
|
||||
|
||||
@@ -16,21 +16,16 @@
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
The Razor editor in VS2026 and the C# extension for VS Code uses the Razor source generator
|
||||
The Razor editor in modern Visual Studio and the C# extension for VS Code use the Razor source generator
|
||||
for IDE functionality. We need to add some things to make sure it works correctly, but we
|
||||
only do them for design time builds, so that we don't impact regular builds or CI.
|
||||
We also have an escape hatch in case it does cause issues, users can set the appropriate property
|
||||
We also have an escape hatch in case it does cause issues, users can set EnableCohostEditorCompatibility=false
|
||||
in their project file to disable this.
|
||||
|
||||
CompilerVisibleProperty is surfaced to generators via AnalyzerConfigOptionsProvider, not as a source-generator input file,
|
||||
so it doesn't enter the hintName-collision codepath that AdditionalFiles does. Keeping it at evaluation time is safe.
|
||||
-->
|
||||
<ItemGroup Condition="'$(DesignTimeBuild)' == 'true' and '$(EnableCohostEditorCompatibility)' != 'false'">
|
||||
<!--
|
||||
We have to make sure the source generator can see the .cshtml files, so make them AdditionalFiles.
|
||||
-->
|
||||
<AdditionalFiles Include="**\*.cshtml" />
|
||||
|
||||
<!--
|
||||
Make sure the source generator knows where the project is, so it can compute target paths.
|
||||
-->
|
||||
<CompilerVisibleProperty Include="MSBuildProjectDirectory" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -49,4 +49,39 @@
|
||||
<ContentWithTargetPath Include="@(_UmbracoFolderFiles)" Exclude="@(ContentWithTargetPath)" TargetPath="%(Identity)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
<!--
|
||||
The Razor source generator needs .cshtml files in @(AdditionalFiles). The Razor SDK adds them
|
||||
via @(RazorGenerate), but only inside a target that runs during the build — so during cohost
|
||||
design-time builds they may not be present yet, which is what PR #21861 worked around.
|
||||
|
||||
Doing the include at evaluation time (as PR #21861 did) causes duplicates with the SDK during
|
||||
dotnet watch / hot reload design-time builds: the SDK adds the same .cshtml under a different
|
||||
item Identity (slash form / relative vs absolute) and the generator then sees two inputs that
|
||||
derive the same hintName, which crashes it with CS8785 (see issue #22773).
|
||||
|
||||
Run as a target before CoreCompile (hot-reload path) and CompileDesignTime (IDE design-time path)
|
||||
so the SDK's contribution is visible in both cases. Then add only the .cshtml files that are not already
|
||||
present. Both sides are normalized to %(FullPath) so items with different Identity forms still compare equal.
|
||||
|
||||
Set EnableCohostEditorCompatibility=false in a project to opt out entirely.
|
||||
-->
|
||||
<Target Name="_UmbracoEnsureRazorAdditionalFilesForCohostEditor"
|
||||
BeforeTargets="CoreCompile;CompileDesignTime"
|
||||
Condition="'$(DesignTimeBuild)' == 'true' and '$(EnableCohostEditorCompatibility)' != 'false'">
|
||||
<ItemGroup>
|
||||
<_UmbracoCshtmlCandidate Include="**\*.cshtml" />
|
||||
<_UmbracoCshtmlCandidateFull Include="@(_UmbracoCshtmlCandidate->'%(FullPath)')" />
|
||||
|
||||
<_UmbracoExistingAdditionalCshtmlFull
|
||||
Include="@(AdditionalFiles->'%(FullPath)')"
|
||||
Condition="'%(Extension)' == '.cshtml'" />
|
||||
|
||||
<_UmbracoCshtmlMissingFromAdditional
|
||||
Include="@(_UmbracoCshtmlCandidateFull)"
|
||||
Exclude="@(_UmbracoExistingAdditionalCshtmlFull)" />
|
||||
|
||||
<AdditionalFiles Include="@(_UmbracoCshtmlMissingFromAdditional)" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -305,6 +305,8 @@ public class MyEntityCacheRefresher : CacheRefresherBase<MyEntityCacheRefresher>
|
||||
- `Attempt.Succeed(value)` / `Attempt.Fail<T>()`
|
||||
- `Attempt<Content, ContentEditingOperationStatus>` - typed result with status
|
||||
|
||||
> Writing or reviewing a query with a `WHERE IN` on a runtime-sized collection? See "Avoiding the SQL Server 2100-parameter limit" in `/src/Umbraco.Infrastructure/CLAUDE.md` — that's where the full helper list (`Constants.Sql.MaxParameterCount`, `InGroupsOf`, NPoco's `FetchByGroups`) and the decision rules live.
|
||||
|
||||
### Configuration
|
||||
|
||||
Configuration models in `/Configuration/Models`:
|
||||
|
||||
@@ -26,9 +26,21 @@ public interface IRepositoryCacheVersionAccessor
|
||||
/// Notifies of a version change on a given cache key.
|
||||
/// </summary>
|
||||
/// <param name="cacheKey">Key of the changed version.</param>
|
||||
[Obsolete("Use version that takes newVersion, scheduled for removal in V19")]
|
||||
void VersionChanged(string cacheKey)
|
||||
{ }
|
||||
|
||||
/// <summary>
|
||||
/// Notifies of a version change on a given cache key, providing the new version so internal caches
|
||||
/// can be updated in-place without a database round-trip.
|
||||
/// </summary>
|
||||
/// <param name="cacheKey">Key of the changed version.</param>
|
||||
/// <param name="newVersion">The new version GUID that was just written to the database.</param>
|
||||
void VersionChanged(string cacheKey, Guid newVersion)
|
||||
{
|
||||
VersionChanged(cacheKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notifies the accessor that caches have been synchronized.
|
||||
/// </summary>
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
|
||||
namespace Umbraco.Cms.Core.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Defines an asynchronous handler for a <typeparamref name="TNotification" /> that should be invoked when notifications are dispatched in a distributed cache scope (e.g. to trigger a distributed cache refresher).
|
||||
/// </summary>
|
||||
/// <typeparam name="TNotification">The type of the notification.</typeparam>
|
||||
public interface IDistributedCacheAsyncNotificationHandler<in TNotification> : INotificationAsyncHandler<TNotification>, IDistributedCacheNotificationHandler
|
||||
where TNotification : INotification
|
||||
{ }
|
||||
@@ -368,8 +368,17 @@ public class ObjectCacheAppCache : IAppPolicyCache, IDisposable
|
||||
}
|
||||
|
||||
// Ensure key is removed from set when evicted from cache
|
||||
return options.RegisterPostEvictionCallback((key, _, _, _) =>
|
||||
return options.RegisterPostEvictionCallback((key, _, reason, _) =>
|
||||
{
|
||||
// Removed and Replaced evictions don't need pruning here: the Remove/Clear call sites already
|
||||
// prune the tracking set synchronously under the write lock, and a Replaced key still has a
|
||||
// live entry (the synchronous Set re-added it). Pruning here instead runs on a background
|
||||
// thread and races with that re-add, dropping a key whose entry is still cached. (#23064)
|
||||
if (reason is EvictionReason.Removed or EvictionReason.Replaced)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_locker.TryEnterWriteLock(_writeLockTimeout) is false)
|
||||
|
||||
@@ -345,15 +345,15 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase<ContentCac
|
||||
if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshNode))
|
||||
{
|
||||
Guid key = payload.Key ?? _idKeyMap.GetKeyForId(payload.Id, UmbracoObjectTypes.Document).Result;
|
||||
_documentUrlService.CreateOrUpdateUrlSegmentsAsync(key).GetAwaiter().GetResult();
|
||||
_documentUrlAliasService.CreateOrUpdateAliasesAsync(key).GetAwaiter().GetResult();
|
||||
_documentUrlService.UpdateUrlSegmentCacheAsync(key).GetAwaiter().GetResult();
|
||||
_documentUrlAliasService.UpdateAliasCacheAsync(key).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch))
|
||||
{
|
||||
Guid key = payload.Key ?? _idKeyMap.GetKeyForId(payload.Id, UmbracoObjectTypes.Document).Result;
|
||||
_documentUrlService.CreateOrUpdateUrlSegmentsWithDescendantsAsync(key).GetAwaiter().GetResult();
|
||||
_documentUrlAliasService.CreateOrUpdateAliasesWithDescendantsAsync(key).GetAwaiter().GetResult();
|
||||
_documentUrlService.UpdateUrlSegmentCacheWithDescendantsAsync(key).GetAwaiter().GetResult();
|
||||
_documentUrlAliasService.UpdateAliasCacheWithDescendantsAsync(key).GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core.Collections;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Persistence.Repositories;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
@@ -14,6 +15,7 @@ internal class RepositoryCacheVersionService : IRepositoryCacheVersionService
|
||||
private readonly ILogger<RepositoryCacheVersionService> _logger;
|
||||
private readonly IRepositoryCacheVersionAccessor _repositoryCacheVersionAccessor;
|
||||
private readonly ConcurrentDictionary<string, Guid> _cacheVersions = new();
|
||||
private readonly ConcurrentDictionary<Guid, ConcurrentHashSet<string>> _writtenKeysByScope = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RepositoryCacheVersionService" /> class.
|
||||
@@ -44,7 +46,6 @@ internal class RepositoryCacheVersionService : IRepositoryCacheVersionService
|
||||
|
||||
var cacheKey = GetCacheKey<TEntity>();
|
||||
|
||||
// The cache version accessor will take a read lock if the version is not in request cache, so we don't need to take one here.
|
||||
RepositoryCacheVersion? databaseVersion = await _repositoryCacheVersionAccessor.GetAsync(cacheKey);
|
||||
|
||||
if (databaseVersion?.Version is null)
|
||||
@@ -84,18 +85,23 @@ internal class RepositoryCacheVersionService : IRepositoryCacheVersionService
|
||||
public async Task SetCacheUpdatedAsync<TEntity>()
|
||||
where TEntity : class
|
||||
{
|
||||
using ICoreScope scope = _scopeProvider.CreateCoreScope();
|
||||
string cacheKey = GetCacheKey<TEntity>();
|
||||
|
||||
// We have to take a write lock to ensure the cache is not being read while we update the version.
|
||||
ConcurrentHashSet<string>? writtenKeys = GetOrRegisterScopeWrittenKeys();
|
||||
if (writtenKeys?.TryAdd(cacheKey) is false)
|
||||
{
|
||||
_logger.LogDebug("Cache version for {EntityType} already written in this scope, skipping", typeof(TEntity).Name);
|
||||
return;
|
||||
}
|
||||
|
||||
using ICoreScope scope = _scopeProvider.CreateCoreScope();
|
||||
scope.WriteLock(Constants.Locks.CacheVersion);
|
||||
|
||||
var cacheKey = GetCacheKey<TEntity>();
|
||||
var newVersion = Guid.NewGuid();
|
||||
|
||||
_logger.LogDebug("Setting cache for {EntityType} to version {Version}", typeof(TEntity).Name, newVersion);
|
||||
await _repositoryCacheVersionRepository.SaveAsync(new RepositoryCacheVersion { Identifier = cacheKey, Version = newVersion.ToString() });
|
||||
_cacheVersions[cacheKey] = newVersion;
|
||||
_repositoryCacheVersionAccessor.VersionChanged(cacheKey);
|
||||
_repositoryCacheVersionAccessor.VersionChanged(cacheKey, newVersion);
|
||||
|
||||
scope.Complete();
|
||||
}
|
||||
@@ -104,7 +110,6 @@ internal class RepositoryCacheVersionService : IRepositoryCacheVersionService
|
||||
public async Task SetCachesSyncedAsync()
|
||||
{
|
||||
using ICoreScope scope = _scopeProvider.CreateCoreScope();
|
||||
scope.ReadLock(Constants.Locks.CacheVersion);
|
||||
|
||||
// We always sync all caches versions, so it's safe to assume all caches are synced at this point.
|
||||
IEnumerable<RepositoryCacheVersion> cacheVersions = await _repositoryCacheVersionRepository.GetAllAsync();
|
||||
@@ -131,4 +136,22 @@ internal class RepositoryCacheVersionService : IRepositoryCacheVersionService
|
||||
internal string GetCacheKey<TEntity>()
|
||||
where TEntity : class =>
|
||||
typeof(TEntity).FullName ?? typeof(TEntity).Name;
|
||||
|
||||
private ConcurrentHashSet<string>? GetOrRegisterScopeWrittenKeys()
|
||||
{
|
||||
IScopeContext? context = _scopeProvider.Context;
|
||||
if (context is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Guid contextId = context.InstanceId;
|
||||
ConcurrentHashSet<string> writtenKeys = _writtenKeysByScope.GetOrAdd(contextId, _ => new ConcurrentHashSet<string>());
|
||||
|
||||
context.Enlist(
|
||||
$"RepositoryCacheVersionService_{contextId}",
|
||||
completed => _writtenKeysByScope.TryRemove(contextId, out _));
|
||||
|
||||
return writtenKeys;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ public interface IConfigManipulator
|
||||
/// </summary>
|
||||
/// <param name="disable">The value to save.</param>
|
||||
/// <returns></returns>
|
||||
[Obsolete("This method is no longer used by Umbraco. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
|
||||
Task SaveDisableRedirectUrlTrackingAsync(bool disable);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -16,6 +16,7 @@ public class UnattendedSettings
|
||||
private const bool StaticInstallUnattended = false;
|
||||
private const bool StaticUpgradeUnattended = false;
|
||||
private const TelemetryLevel StaticTelemetryLevel = TelemetryLevel.Detailed;
|
||||
private const string StaticMigrationClaimTimeout = "02:00:00";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether unattended installs are enabled.
|
||||
@@ -45,6 +46,17 @@ public class UnattendedSettings
|
||||
/// </remarks>
|
||||
public bool PackageMigrationsUnattended { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum time a migration leadership claim is considered valid before
|
||||
/// another server may take over. Protects against a leader crashing mid-migration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only relevant in load-balanced deployments with <see cref="UpgradeUnattended"/> enabled.
|
||||
/// Default is 2 hours, which should exceed the longest reasonable migration run time.
|
||||
/// </remarks>
|
||||
[DefaultValue(StaticMigrationClaimTimeout)]
|
||||
public TimeSpan MigrationClaimTimeout { get; set; } = TimeSpan.Parse(StaticMigrationClaimTimeout);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value to use for creating a user with a name for Unattended Installs
|
||||
/// </summary>
|
||||
|
||||
@@ -36,6 +36,14 @@ public static partial class Constants
|
||||
/// The key used to store the Umbraco pre-migrations upgrade plan state.
|
||||
/// </summary>
|
||||
public const string UmbracoUpgradePlanPremigrationsKey = KeyValuePrefix + UmbracoUpgradePlanPremigrationsName;
|
||||
|
||||
/// <summary>
|
||||
/// The key used to coordinate migration leadership across servers in a load-balanced
|
||||
/// environment. The value is either empty (no active leader) or
|
||||
/// <c>"{machineIdentifier}|{claimedAtUtc:O}"</c> when a server holds the claim,
|
||||
/// where <c>machineIdentifier</c> is the value returned by <see cref="Umbraco.Cms.Core.Factories.IMachineInfoFactory.GetMachineIdentifier"/>.
|
||||
/// </summary>
|
||||
public const string UpgradeLockKey = "Umbraco.Core.Upgrader.Lock";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Umbraco.Cms.Core.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface indicating that Umbraco itself has enabled ASP.NET Core output caching
|
||||
/// (via Website template caching or Delivery API caching configuration).
|
||||
/// Used to gate Umbraco's automatic registration of the output cache middleware so that
|
||||
/// applications calling <c>services.AddOutputCache(...)</c> for their own purposes do not
|
||||
/// inadvertently trigger a duplicate <c>UseOutputCache()</c> registration.
|
||||
/// </summary>
|
||||
public interface IUmbracoManagedOutputCacheMarker { }
|
||||
|
||||
/// <summary>
|
||||
/// Marker class implementation for <see cref="IUmbracoManagedOutputCacheMarker"/>.
|
||||
/// </summary>
|
||||
public sealed class UmbracoManagedOutputCacheMarker : IUmbracoManagedOutputCacheMarker { }
|
||||
@@ -458,6 +458,7 @@ namespace Umbraco.Cms.Core.DependencyInjection
|
||||
Services.AddUnique<IDocumentUrlAliasService, DocumentUrlAliasService>();
|
||||
Services.AddNotificationAsyncHandler<UmbracoApplicationStartingNotification, DocumentUrlAliasServiceInitializerNotificationHandler>();
|
||||
Services.AddNotificationAsyncHandler<ContentTypeChangedNotification, DocumentUrlServiceContentTypeChangedNotificationHandler>();
|
||||
Services.AddNotificationAsyncHandler<ContentTreeChangeNotification, DocumentUrlServiceContentTreeChangeNotificationHandler>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,7 +405,8 @@
|
||||
0: Comma delimitted list of failed folder paths
|
||||
-->
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' je postavljen na <strong>%0%</strong>.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, pa će se URL aplikacije automatski otkriti iz dolaznih zahtjeva. Preporučuje se da ga postavite izričito.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, a automatsko otkrivanje URL-a aplikacije je onemogućeno ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' je 'None'). Značajke koje zahtijevaju apsolutni URL, poput e-pošte za poništavanje lozinke i pozivnica, neće raditi. Postavite URL aplikacije izričito ili omogućite automatsko otkrivanje.]]></key>
|
||||
<!-- The following key get these tokens passed in:
|
||||
0: Comma delimitted list of headers found
|
||||
-->
|
||||
|
||||
@@ -454,7 +454,8 @@
|
||||
<key alias="httpsCheckConfigurationRectifyNotPossible">Mae gosodiad ap 'Umbraco:CMS:Global:UseHttps' wedi'i osod i 'false' yn eich ffeil appSettings.json. Unwaith y byddwch yn cyrchu'r wefan hon gan ddefnyddio'r cynllun HTTPS, dylid gosod hwnnw i 'true'.</key>
|
||||
<key alias="httpsCheckConfigurationCheckResult">Mae'r gosodiad ap 'Umbraco:CMS:Global:UseHttps' wedi'i osod i '%0%' yn eich ffeil appSettings.json, mae eich cwcis %1% wedi'u marcio'n ddiogel.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue">Mae gosodiad yr ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod i <strong>%0%</strong>.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod, felly bydd URL y rhaglen yn cael ei ganfod yn awtomatig o geisiadau sy'n dod i mewn. Argymhellir ei osod yn benodol.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod ac mae canfod URL y rhaglen yn awtomatig wedi'i analluogi (mae 'Umbraco:CMS:WebRouting:ApplicationUrlDetection' yn 'None'). Ni fydd nodweddion sydd angen URL absoliwt, fel e-byst ailosod cyfrinair a gwahoddiadau, yn gweithio. Gosodwch URL y rhaglen yn benodol, neu galluogwch ganfod yn awtomatig.]]></key>
|
||||
<key alias="smtpMailSettingsNotFound">Nid oedd modd dod o hyd i'r ffurfweddiad 'Umbraco:CMS:Global:Smtp'.</key>
|
||||
<key alias="smtpMailSettingsHostNotConfigured">Nid oedd modd dod o hyd i'r ffurfweddiad 'Umbraco:CMS:Global:Smtp:Host'.</key>
|
||||
<key alias="smtpMailSettingsConnectionFail">Methwyd cyrraedd y gweinydd SMTP a ffurfweddwyd gyda gwesteiwr '%0%' a phorth '%1%'. Gwiriwch i sicrhau bod y gosodiadau SMTP yn y ffurfweddiad 'Umbraco:CMS:Global:Smtp' yn gywir.</key>
|
||||
|
||||
@@ -463,7 +463,8 @@
|
||||
0: Comma delimitted list of failed folder paths
|
||||
-->
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is set to <strong>%0%</strong>.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set, so the application URL will be auto-detected from incoming requests. Setting it explicitly is recommended.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set and application URL auto-detection is disabled ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' is 'None'). Features that require an absolute URL, such as password reset and invitation emails, will not work. Set the application URL explicitly, or enable auto-detection.]]></key>
|
||||
<!-- The following key get these tokens passed in:
|
||||
0: Comma delimitted list of headers found
|
||||
-->
|
||||
|
||||
@@ -452,7 +452,8 @@
|
||||
0: Comma delimitted list of failed folder paths
|
||||
-->
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is set to <strong>%0%</strong>.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set, so the application URL will be auto-detected from incoming requests. Setting it explicitly is recommended.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set and application URL auto-detection is disabled ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' is 'None'). Features that require an absolute URL, such as password reset and invitation emails, will not work. Set the application URL explicitly, or enable auto-detection.]]></key>
|
||||
<key alias="clickJackingCheckHeaderFound">
|
||||
<![CDATA[The header or meta-tag <strong>X-Frame-Options</strong> used to control whether a site can be IFRAMEd by another was found.]]></key>
|
||||
<key alias="clickJackingCheckHeaderNotFound">
|
||||
|
||||
@@ -403,7 +403,8 @@
|
||||
0: Comma delimitted list of failed folder paths
|
||||
-->
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' je postavljen na <strong>%0%</strong>.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, pa će se URL aplikacije automatski otkriti iz dolaznih zahtjeva. Preporučuje se da ga postavite izričito.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, a automatsko otkrivanje URL-a aplikacije je onemogućeno ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' je 'None'). Značajke koje zahtijevaju apsolutni URL, poput e-pošte za poništavanje lozinke i pozivnica, neće raditi. Postavite URL aplikacije izričito ili omogućite automatsko otkrivanje.]]></key>
|
||||
<!-- The following key get these tokens passed in:
|
||||
0: Comma delimitted list of headers found
|
||||
-->
|
||||
|
||||
@@ -2230,9 +2230,9 @@ public static class PublishedContentExtensions
|
||||
// with a non-existing published node, will get cache misses and call the DB
|
||||
// making it a very slow operation.
|
||||
|
||||
return publishedStatusFilteringService
|
||||
.FilterAvailable(childrenKeys, culture)
|
||||
.OrderBy(x => x.SortOrder);
|
||||
// INavigationQueryService.TryGetChildrenKeys returns keys already ordered by SortOrder
|
||||
// and FilterAvailable preserves enumeration order, so no further OrderBy is needed.
|
||||
return publishedStatusFilteringService.FilterAvailable(childrenKeys, culture);
|
||||
}
|
||||
|
||||
private static IEnumerable<IPublishedContent> EnumerateDescendantsOrSelfInternal(
|
||||
|
||||
@@ -730,6 +730,10 @@ public static partial class StringExtensions
|
||||
/// </summary>
|
||||
/// <param name="fileName">The file name to convert.</param>
|
||||
/// <returns>A friendly name with the extension stripped, underscores and dashes converted to spaces, and title case applied.</returns>
|
||||
/// <remarks>
|
||||
/// Mirrored client-side in <c>src/Umbraco.Web.UI.Client/src/packages/media/media/utils/to-friendly-name.function.ts</c>;
|
||||
/// keep the two implementations in sync.
|
||||
/// </remarks>
|
||||
public static string ToFriendlyName(this string fileName)
|
||||
{
|
||||
// strip the file extension
|
||||
|
||||
@@ -44,28 +44,34 @@ public class UmbracoApplicationUrlCheck : HealthCheck
|
||||
|
||||
private HealthCheckStatus CheckUmbracoApplicationUrl()
|
||||
{
|
||||
var url = _webRoutingSettings.CurrentValue.UmbracoApplicationUrl;
|
||||
WebRoutingSettings settings = _webRoutingSettings.CurrentValue;
|
||||
var url = settings.UmbracoApplicationUrl;
|
||||
|
||||
string resultMessage;
|
||||
StatusResultType resultType;
|
||||
var success = false;
|
||||
|
||||
if (url.IsNullOrWhiteSpace())
|
||||
if (url.IsNullOrWhiteSpace() is false)
|
||||
{
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultFalse");
|
||||
resultType = StatusResultType.Warning;
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", [url]);
|
||||
resultType = StatusResultType.Success;
|
||||
}
|
||||
else if (settings.ApplicationUrlDetection == ApplicationUrlDetection.None)
|
||||
{
|
||||
// No explicit URL and auto-detection is disabled, so the application URL can never be established.
|
||||
// Features that require an absolute URL (e.g. password reset and invitation emails) will not work.
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultError");
|
||||
resultType = StatusResultType.Error;
|
||||
}
|
||||
else
|
||||
{
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", new[] { url });
|
||||
resultType = StatusResultType.Success;
|
||||
success = true;
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultFalse");
|
||||
resultType = StatusResultType.Warning;
|
||||
}
|
||||
|
||||
return new HealthCheckStatus(resultMessage)
|
||||
{
|
||||
ResultType = resultType,
|
||||
ReadMoreLink = success
|
||||
ReadMoreLink = resultType == StatusResultType.Success
|
||||
? null
|
||||
: Constants.HealthChecks.DocumentationLinks.Security.UmbracoApplicationUrlCheck,
|
||||
};
|
||||
|
||||
@@ -36,7 +36,14 @@ internal sealed partial class ShadowFileSystem : IFileSystem
|
||||
/// <summary>
|
||||
/// Gets the dictionary of shadow nodes tracking file and directory changes.
|
||||
/// </summary>
|
||||
private Dictionary<string, ShadowNode> Nodes => _nodes ??= new Dictionary<string, ShadowNode>();
|
||||
/// <remarks>
|
||||
/// Uses <see cref="StringComparer.OrdinalIgnoreCase"/> so the shadow exposes case-insensitive
|
||||
/// path semantics (matching Windows file system behavior) while preserving the original case
|
||||
/// of paths. Preserving case is required for <see cref="Complete"/>: the stored key is also
|
||||
/// used to locate the shadow file via <c>_sfs.GetFullPath</c>, which on case-sensitive
|
||||
/// file systems (e.g. Linux) must match the case the file was actually written with.
|
||||
/// </remarks>
|
||||
private Dictionary<string, ShadowNode> Nodes => _nodes ??= new Dictionary<string, ShadowNode>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<string> GetDirectories(string path)
|
||||
@@ -66,7 +73,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
|
||||
var normPath = NormPath(path);
|
||||
if (recursive)
|
||||
{
|
||||
Nodes[normPath] = new ShadowNode(true, true);
|
||||
Nodes[normPath] = new ShadowNode(true, true, normPath);
|
||||
var remove = Nodes.Where(x => IsDescendant(normPath, x.Key)).ToList();
|
||||
foreach (KeyValuePair<string, ShadowNode> kvp in remove)
|
||||
{
|
||||
@@ -84,7 +91,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
|
||||
throw new InvalidOperationException("Directory is not empty.");
|
||||
}
|
||||
|
||||
Nodes[path] = new ShadowNode(true, true);
|
||||
Nodes[normPath] = new ShadowNode(true, true, normPath);
|
||||
var remove = Nodes.Where(x => IsChild(normPath, x.Key)).ToList();
|
||||
foreach (KeyValuePair<string, ShadowNode> kvp in remove)
|
||||
{
|
||||
@@ -131,7 +138,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
|
||||
|
||||
if (sd.IsDelete)
|
||||
{
|
||||
Nodes[dirPath] = new ShadowNode(false, true);
|
||||
Nodes[dirPath] = new ShadowNode(false, true, dirPath);
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -146,12 +153,13 @@ internal sealed partial class ShadowFileSystem : IFileSystem
|
||||
throw new InvalidOperationException("Invalid path.");
|
||||
}
|
||||
|
||||
Nodes[dirPath] = new ShadowNode(false, true);
|
||||
Nodes[dirPath] = new ShadowNode(false, true, dirPath);
|
||||
}
|
||||
}
|
||||
|
||||
_sfs.AddFile(path, stream, overrideIfExists);
|
||||
Nodes[normPath] = new ShadowNode(false, false);
|
||||
var canonicalPath = sf?.CanonicalPath ?? path;
|
||||
_sfs.AddFile(canonicalPath, stream, overrideIfExists);
|
||||
Nodes[normPath] = new ShadowNode(false, false, canonicalPath);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -178,7 +186,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
|
||||
{
|
||||
if (Nodes.TryGetValue(NormPath(path), out ShadowNode? sf))
|
||||
{
|
||||
return sf.IsDir || sf.IsDelete ? Stream.Null : _sfs.OpenFile(path);
|
||||
return sf.IsDir || sf.IsDelete ? Stream.Null : _sfs.OpenFile(sf.CanonicalPath);
|
||||
}
|
||||
|
||||
return Inner.OpenFile(path);
|
||||
@@ -192,7 +200,8 @@ internal sealed partial class ShadowFileSystem : IFileSystem
|
||||
return;
|
||||
}
|
||||
|
||||
Nodes[NormPath(path)] = new ShadowNode(true, false);
|
||||
var normPath = NormPath(path);
|
||||
Nodes[normPath] = new ShadowNode(true, false, normPath);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -226,7 +235,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
|
||||
|
||||
if (sd.IsDelete)
|
||||
{
|
||||
Nodes[dirPath] = new ShadowNode(false, true);
|
||||
Nodes[dirPath] = new ShadowNode(false, true, dirPath);
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -241,13 +250,15 @@ internal sealed partial class ShadowFileSystem : IFileSystem
|
||||
throw new InvalidOperationException("Invalid path.");
|
||||
}
|
||||
|
||||
Nodes[dirPath] = new ShadowNode(false, true);
|
||||
Nodes[dirPath] = new ShadowNode(false, true, dirPath);
|
||||
}
|
||||
}
|
||||
|
||||
_sfs.MoveFile(normSource, normTarget, overrideIfExists);
|
||||
Nodes[normSource] = new ShadowNode(true, false);
|
||||
Nodes[normTarget] = new ShadowNode(false, false);
|
||||
var sourceCanonical = sf?.CanonicalPath ?? normSource;
|
||||
var targetCanonical = tf?.CanonicalPath ?? normTarget;
|
||||
_sfs.MoveFile(sourceCanonical, targetCanonical, overrideIfExists);
|
||||
Nodes[normSource] = new ShadowNode(true, false, sourceCanonical);
|
||||
Nodes[normTarget] = new ShadowNode(false, false, targetCanonical);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -269,7 +280,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
|
||||
{
|
||||
if (Nodes.TryGetValue(NormPath(path), out ShadowNode? sf))
|
||||
{
|
||||
return sf.IsDir || sf.IsDelete ? string.Empty : _sfs.GetFullPath(path);
|
||||
return sf.IsDir || sf.IsDelete ? string.Empty : _sfs.GetFullPath(sf.CanonicalPath);
|
||||
}
|
||||
|
||||
return Inner.GetFullPath(path);
|
||||
@@ -291,7 +302,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
|
||||
throw new InvalidOperationException("Invalid path.");
|
||||
}
|
||||
|
||||
return _sfs.GetLastModified(path);
|
||||
return _sfs.GetLastModified(sf.CanonicalPath);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -307,7 +318,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
|
||||
throw new InvalidOperationException("Invalid path.");
|
||||
}
|
||||
|
||||
return _sfs.GetCreated(path);
|
||||
return _sfs.GetCreated(sf.CanonicalPath);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -323,7 +334,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
|
||||
throw new InvalidOperationException("Invalid path.");
|
||||
}
|
||||
|
||||
return _sfs.GetSize(path);
|
||||
return _sfs.GetSize(sf.CanonicalPath);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -348,7 +359,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
|
||||
|
||||
if (sd.IsDelete)
|
||||
{
|
||||
Nodes[dirPath] = new ShadowNode(false, true);
|
||||
Nodes[dirPath] = new ShadowNode(false, true, dirPath);
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -363,12 +374,13 @@ internal sealed partial class ShadowFileSystem : IFileSystem
|
||||
throw new InvalidOperationException("Invalid path.");
|
||||
}
|
||||
|
||||
Nodes[dirPath] = new ShadowNode(false, true);
|
||||
Nodes[dirPath] = new ShadowNode(false, true, dirPath);
|
||||
}
|
||||
}
|
||||
|
||||
_sfs.AddFile(path, physicalPath, overrideIfExists, copy);
|
||||
Nodes[normPath] = new ShadowNode(false, false);
|
||||
var canonicalPath = sf?.CanonicalPath ?? path;
|
||||
_sfs.AddFile(canonicalPath, physicalPath, overrideIfExists, copy);
|
||||
Nodes[normPath] = new ShadowNode(false, false, canonicalPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -393,11 +405,11 @@ internal sealed partial class ShadowFileSystem : IFileSystem
|
||||
{
|
||||
if (Inner.CanAddPhysical)
|
||||
{
|
||||
Inner.AddFile(kvp.Key, _sfs.GetFullPath(kvp.Key)); // overwrite, move
|
||||
Inner.AddFile(kvp.Key, _sfs.GetFullPath(kvp.Value.CanonicalPath)); // overwrite, move
|
||||
}
|
||||
else
|
||||
{
|
||||
using (Stream stream = _sfs.OpenFile(kvp.Key))
|
||||
using (Stream stream = _sfs.OpenFile(kvp.Value.CanonicalPath))
|
||||
{
|
||||
Inner.AddFile(kvp.Key, stream, true);
|
||||
}
|
||||
@@ -441,11 +453,15 @@ internal sealed partial class ShadowFileSystem : IFileSystem
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a path to lowercase with forward slashes.
|
||||
/// Normalizes a path's directory separators to forward slashes.
|
||||
/// </summary>
|
||||
/// <param name="path">The path to normalize.</param>
|
||||
/// <returns>The normalized path.</returns>
|
||||
private static string NormPath(string path) => path.ToLowerInvariant().Replace("\\", "/");
|
||||
/// <remarks>
|
||||
/// Case is preserved. Case-insensitive matching is handled by <see cref="Nodes"/>'s
|
||||
/// <see cref="StringComparer.OrdinalIgnoreCase"/> comparer.
|
||||
/// </remarks>
|
||||
private static string NormPath(string path) => path.Replace("\\", "/");
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the input path is a direct child of the specified path.
|
||||
@@ -456,7 +472,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
|
||||
/// <remarks>Values can be "" (root), "foo", "foo/bar"...</remarks>
|
||||
private static bool IsChild(string path, string input)
|
||||
{
|
||||
if (input.StartsWith(path) == false || input.Length < path.Length + 2)
|
||||
if (input.StartsWith(path, StringComparison.OrdinalIgnoreCase) == false || input.Length < path.Length + 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -466,7 +482,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
|
||||
return false;
|
||||
}
|
||||
|
||||
var pos = input.IndexOf("/", path.Length + 1, StringComparison.OrdinalIgnoreCase);
|
||||
var pos = input.IndexOf('/', path.Length + 1);
|
||||
return pos < 0;
|
||||
}
|
||||
|
||||
@@ -478,7 +494,7 @@ internal sealed partial class ShadowFileSystem : IFileSystem
|
||||
/// <returns><c>true</c> if input is a descendant of path; otherwise, <c>false</c>.</returns>
|
||||
private static bool IsDescendant(string path, string input)
|
||||
{
|
||||
if (input.StartsWith(path) == false || input.Length < path.Length + 2)
|
||||
if (input.StartsWith(path, StringComparison.OrdinalIgnoreCase) == false || input.Length < path.Length + 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -495,12 +511,14 @@ internal sealed partial class ShadowFileSystem : IFileSystem
|
||||
{
|
||||
foreach (var file in Inner.GetFiles(path))
|
||||
{
|
||||
Nodes[NormPath(file)] = new ShadowNode(true, false);
|
||||
var normFile = NormPath(file);
|
||||
Nodes[normFile] = new ShadowNode(true, false, normFile);
|
||||
}
|
||||
|
||||
foreach (var dir in Inner.GetDirectories(path))
|
||||
{
|
||||
Nodes[NormPath(dir)] = new ShadowNode(true, true);
|
||||
var normDir = NormPath(dir);
|
||||
Nodes[normDir] = new ShadowNode(true, true, normDir);
|
||||
if (recurse)
|
||||
{
|
||||
Delete(dir, true);
|
||||
@@ -612,10 +630,12 @@ internal sealed partial class ShadowFileSystem : IFileSystem
|
||||
/// </summary>
|
||||
/// <param name="isDelete">Whether this node represents a deletion.</param>
|
||||
/// <param name="isdir">Whether this node represents a directory.</param>
|
||||
public ShadowNode(bool isDelete, bool isdir)
|
||||
/// <param name="canonicalPath">The original-case path tracked by this node.</param>
|
||||
public ShadowNode(bool isDelete, bool isdir, string canonicalPath)
|
||||
{
|
||||
IsDelete = isDelete;
|
||||
IsDir = isdir;
|
||||
CanonicalPath = canonicalPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -628,6 +648,17 @@ internal sealed partial class ShadowFileSystem : IFileSystem
|
||||
/// </summary>
|
||||
public bool IsDir { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the original-case path tracked by this node. For existing-file nodes this is
|
||||
/// the path used the first time the file was staged in the current shadow scope.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// All operations against the inner shadow file system (<c>_sfs</c>) must use this
|
||||
/// path so that re-staging the same logical path with a different case still reaches
|
||||
/// the same on-disk file on case-sensitive file systems (e.g. Linux).
|
||||
/// </remarks>
|
||||
public string CanonicalPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this node represents an existing item (not deleted).
|
||||
/// </summary>
|
||||
|
||||
@@ -454,7 +454,24 @@ public static class ContentRepositoryExtensions
|
||||
/// Clears all publish culture information from the content item.
|
||||
/// </summary>
|
||||
/// <param name="content">The content item to clear publish information from.</param>
|
||||
public static void ClearPublishInfos(this IContent content) => content.PublishCultureInfos = null;
|
||||
public static void ClearPublishInfos(this IContent content)
|
||||
{
|
||||
if (content.PublishCultureInfos is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass each published culture through ClearPublishInfo([culture]) to ensure correct change tracking.
|
||||
var cultures = content.PublishCultureInfos.Values.Select(c => c.Culture).ToArray();
|
||||
foreach (var culture in cultures)
|
||||
{
|
||||
content.ClearPublishInfo(culture);
|
||||
}
|
||||
|
||||
// Following #22799 the explicit calls to `ClearPublishInfo` for each culture cause the unpublish in all cultures.
|
||||
// `PublishCultureInfos` is set to null purely to retain previous behaviour at a property level.
|
||||
content.PublishCultureInfos = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns false if the culture is already unpublished
|
||||
|
||||
@@ -8,7 +8,25 @@ namespace Umbraco.Cms.Core.Models.Navigation;
|
||||
/// </summary>
|
||||
public sealed class NavigationNode
|
||||
{
|
||||
private ConcurrentHashSet<Guid> _children;
|
||||
private static readonly Comparison<(Guid Key, int SortOrder)> _sortBySortOrder =
|
||||
static (a, b) => a.SortOrder.CompareTo(b.SortOrder);
|
||||
|
||||
private readonly ConcurrentHashSet<Guid> _children;
|
||||
|
||||
/// <summary>
|
||||
/// Cached snapshot of <see cref="Children"/> ordered by each child's <c>SortOrder</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Built lazily by <see cref="GetOrderedChildren"/> on first access and invalidated
|
||||
/// (set to <c>null</c>) by <see cref="AddChild"/> / <see cref="RemoveChild"/> /
|
||||
/// <see cref="InvalidateOrderedChildren"/>. Reads are lock-free on the fast path; the
|
||||
/// build and invalidation paths take <see cref="_orderedChildrenLock"/> so concurrent
|
||||
/// first-access threads agree on a single canonical array and an in-flight build
|
||||
/// cannot finish after a concurrent invalidation has cleared it.
|
||||
/// </remarks>
|
||||
private Guid[]? _orderedChildren;
|
||||
|
||||
private readonly Lock _orderedChildrenLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique key of this navigation node.
|
||||
@@ -53,6 +71,17 @@ public sealed class NavigationNode
|
||||
/// Updates the sort order of this node.
|
||||
/// </summary>
|
||||
/// <param name="newSortOrder">The new sort order value.</param>
|
||||
/// <remarks>
|
||||
/// The parent node's cached ordered-children list (if any) is now stale because it sorts
|
||||
/// by child <c>SortOrder</c>. Callers that hold a reference to the parent should call
|
||||
/// <see cref="InvalidateOrderedChildren"/> on it; <see cref="NavigationNode"/> does not
|
||||
/// hold a reference to its parent <see cref="NavigationNode"/> so cannot invalidate it
|
||||
/// itself.
|
||||
/// </remarks>
|
||||
// TODO (V19): Make internal. The contract requires the caller to invalidate the parent's
|
||||
// ordered-children cache (InvalidateOrderedChildren is internal, so external callers cannot
|
||||
// satisfy that contract and would silently observe stale ordering on subsequent reads).
|
||||
// Internal callers in ContentNavigationServiceBase already do the invalidation correctly.
|
||||
public void UpdateSortOrder(int newSortOrder) => SortOrder = newSortOrder;
|
||||
|
||||
/// <summary>
|
||||
@@ -74,6 +103,8 @@ public sealed class NavigationNode
|
||||
child.SortOrder = _children.Count;
|
||||
|
||||
_children.Add(childKey);
|
||||
|
||||
InvalidateOrderedChildren();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -91,5 +122,91 @@ public sealed class NavigationNode
|
||||
|
||||
_children.Remove(childKey);
|
||||
child.Parent = null;
|
||||
|
||||
InvalidateOrderedChildren();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns this node's children ordered by <c>SortOrder</c>.
|
||||
/// </summary>
|
||||
/// <param name="navigationStructure">The navigation structure dictionary containing all nodes; needed to look up each child's current <c>SortOrder</c>.</param>
|
||||
/// <returns>An immutable, sort-order-presorted snapshot of the children. The result is cached and reused across calls until the children set or a child's <c>SortOrder</c> is mutated.</returns>
|
||||
/// <remarks>
|
||||
/// Lock-free fast path: a non-null cached array is returned without acquiring the lock.
|
||||
/// If the cache is empty, <see cref="BuildOrderedChildren"/> is called under the lock to
|
||||
/// build (with double-checked re-read) and store the canonical array.
|
||||
/// </remarks>
|
||||
internal IReadOnlyList<Guid> GetOrderedChildren(ConcurrentDictionary<Guid, NavigationNode> navigationStructure)
|
||||
{
|
||||
// Volatile.Read provides the acquire fence that pairs with the release fence on the
|
||||
// lock-protected stores in BuildOrderedChildren / InvalidateOrderedChildren. On weak
|
||||
// memory architectures (e.g. ARM64) a plain read can observe writes out of order with
|
||||
// the lock release, so without this barrier a reader could in principle see a torn or
|
||||
// unpublished reference; on x86/x64 the TSO model already gives acquire semantics so
|
||||
// this compiles to a normal load. Matches the lock-free read idiom in System.Lazy<T>
|
||||
// and LazyInitializer.EnsureInitialized.
|
||||
Guid[]? cached = Volatile.Read(ref _orderedChildren);
|
||||
if (cached is not null)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
return BuildOrderedChildren(navigationStructure);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates the cached ordered-children snapshot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Called by <see cref="AddChild"/> and <see cref="RemoveChild"/> automatically. Must be
|
||||
/// called externally when a child's <c>SortOrder</c> changes (the parent's cache sorts by
|
||||
/// child <c>SortOrder</c> and so is stale after such an update).
|
||||
/// </remarks>
|
||||
internal void InvalidateOrderedChildren()
|
||||
{
|
||||
lock (_orderedChildrenLock)
|
||||
{
|
||||
_orderedChildren = null;
|
||||
}
|
||||
}
|
||||
|
||||
private Guid[] BuildOrderedChildren(ConcurrentDictionary<Guid, NavigationNode> navigationStructure)
|
||||
{
|
||||
lock (_orderedChildrenLock)
|
||||
{
|
||||
// Double-check under the lock — another thread may have built the cache while we
|
||||
// were waiting to acquire it.
|
||||
Guid[]? cached = _orderedChildren;
|
||||
if (cached is not null)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (_children.Count == 0)
|
||||
{
|
||||
_orderedChildren = [];
|
||||
return _orderedChildren;
|
||||
}
|
||||
|
||||
var sorted = new List<(Guid Key, int SortOrder)>(_children.Count);
|
||||
foreach (Guid childKey in _children)
|
||||
{
|
||||
if (navigationStructure.TryGetValue(childKey, out NavigationNode? childNode))
|
||||
{
|
||||
sorted.Add((childKey, childNode.SortOrder));
|
||||
}
|
||||
}
|
||||
|
||||
sorted.Sort(_sortBySortOrder);
|
||||
|
||||
var result = new Guid[sorted.Count];
|
||||
for (var i = 0; i < sorted.Count; i++)
|
||||
{
|
||||
result[i] = sorted[i].Key;
|
||||
}
|
||||
|
||||
_orderedChildren = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,10 @@ public class PublishedValueFallback : IPublishedValueFallback
|
||||
}
|
||||
|
||||
break;
|
||||
case Fallback.Ancestors:
|
||||
// Ancestors fallback only applies at IPublishedContent level (tree-aware).
|
||||
// Skip silently here so chained fallbacks still work and direct element calls don't throw.
|
||||
continue;
|
||||
default:
|
||||
throw NotSupportedFallbackMethod(f, "property");
|
||||
}
|
||||
@@ -127,6 +131,10 @@ public class PublishedValueFallback : IPublishedValueFallback
|
||||
}
|
||||
|
||||
break;
|
||||
case Fallback.Ancestors:
|
||||
// Ancestors fallback only applies at IPublishedContent level (tree-aware).
|
||||
// Skip silently here so chained fallbacks still work and direct element calls don't throw.
|
||||
continue;
|
||||
default:
|
||||
throw NotSupportedFallbackMethod(f, "element");
|
||||
}
|
||||
|
||||
@@ -28,6 +28,27 @@ public interface IDocumentCacheService
|
||||
/// <returns>The published content, or <c>null</c> if not found.</returns>
|
||||
Task<IPublishedContent?> GetByIdAsync(int id, bool? preview = null);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to retrieve a content item from the in-memory converted-content cache without
|
||||
/// touching the distributed cache or the database.
|
||||
/// </summary>
|
||||
/// <param name="key">The unique key of the content.</param>
|
||||
/// <param name="preview">Whether to consider unpublished content.</param>
|
||||
/// <param name="content">When this method returns, contains the cached published content if a hit was made; otherwise <c>null</c>.</param>
|
||||
/// <returns><c>true</c> if the content was served from the in-memory cache; <c>false</c> if a slower retrieval (HybridCache or database) is required.</returns>
|
||||
/// <remarks>
|
||||
/// Synchronous fast-path used by sync consumers (e.g. <c>IPublishedContentCache.GetById(bool, Guid)</c>)
|
||||
/// to avoid setting up the async state machine on the dominant warm-cache case. On a miss
|
||||
/// the caller falls back to the existing async path. The default implementation always
|
||||
/// returns <c>false</c> so the caller takes the async path.
|
||||
/// </remarks>
|
||||
// TODO (V19): Remove the default implementation.
|
||||
bool TryGetCached(Guid key, bool preview, out IPublishedContent? content)
|
||||
{
|
||||
content = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds the cache with initial content data.
|
||||
/// </summary>
|
||||
|
||||
@@ -26,6 +26,26 @@ public interface IMediaCacheService
|
||||
/// <returns>The published media content, or <c>null</c> if not found.</returns>
|
||||
Task<IPublishedContent?> GetByIdAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to retrieve a media item from the in-memory converted-content cache without
|
||||
/// touching the distributed cache or the database.
|
||||
/// </summary>
|
||||
/// <param name="key">The unique key of the media.</param>
|
||||
/// <param name="content">When this method returns, contains the cached published media if a hit was made; otherwise <c>null</c>.</param>
|
||||
/// <returns><c>true</c> if the media was served from the in-memory cache; <c>false</c> if a slower retrieval (HybridCache or database) is required.</returns>
|
||||
/// <remarks>
|
||||
/// Synchronous fast-path used by sync consumers (e.g. <c>IPublishedMediaCache.GetById(bool, Guid)</c>)
|
||||
/// to avoid setting up the async state machine on the dominant warm-cache case. On a miss
|
||||
/// the caller falls back to the existing async path. The default implementation always
|
||||
/// returns <c>false</c> so the caller takes the async path.
|
||||
/// </remarks>
|
||||
// TODO (V19): Remove the default implementation.
|
||||
bool TryGetCached(Guid key, out IPublishedContent? content)
|
||||
{
|
||||
content = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether media with the specified identifier exists in the cache.
|
||||
/// </summary>
|
||||
|
||||
@@ -1670,9 +1670,7 @@ public class ContentService : RepositoryService, IContentService
|
||||
{
|
||||
// Determine cultures publishing/unpublishing which will be based on previous calls to content.PublishCulture and ClearPublishInfo
|
||||
culturesUnpublishing = content.GetCulturesUnpublishing();
|
||||
culturesPublishing = variesByCulture
|
||||
? content.PublishCultureInfos?.Values.Where(x => x.IsDirty()).Select(x => x.Culture).ToList()
|
||||
: null;
|
||||
culturesPublishing = GetCulturesPublishing(content);
|
||||
|
||||
// ensure that the document can be published, and publish handling events, business rules, etc
|
||||
publishResult = StrategyCanPublish(
|
||||
@@ -1743,6 +1741,12 @@ public class ContentService : RepositoryService, IContentService
|
||||
// won't happen in a branch
|
||||
if (unpublishing)
|
||||
{
|
||||
if (culturesUnpublishing is null)
|
||||
{
|
||||
culturesUnpublishing = content.GetCulturesUnpublishing();
|
||||
culturesPublishing = GetCulturesPublishing(content);
|
||||
}
|
||||
|
||||
IContent? newest = GetById(content.Id); // ensure we have the newest version - in scope
|
||||
if (content.VersionId != newest?.VersionId)
|
||||
{
|
||||
@@ -1805,12 +1809,13 @@ public class ContentService : RepositoryService, IContentService
|
||||
var langs = GetLanguageDetailsForAuditEntry(allLangs, culturesUnpublishing);
|
||||
Audit(AuditType.UnpublishVariant, userId, content.Id, $"Unpublished languages: {langs}", langs);
|
||||
|
||||
if (publishResult == null)
|
||||
PublishResultType? publishResultType = publishResult?.Result ?? unpublishResult?.Result;
|
||||
if (publishResultType == null)
|
||||
{
|
||||
throw new PanicException("publishResult == null - should not happen");
|
||||
throw new PanicException("publishResultType == null - should not happen");
|
||||
}
|
||||
|
||||
switch (publishResult.Result)
|
||||
switch (publishResultType)
|
||||
{
|
||||
case PublishResultType.FailedPublishMandatoryCultureMissing:
|
||||
// Occurs when a mandatory culture was unpublished (which means we tried publishing the document without a mandatory culture)
|
||||
@@ -2428,6 +2433,11 @@ public class ContentService : RepositoryService, IContentService
|
||||
return result;
|
||||
}
|
||||
|
||||
private IReadOnlyList<string>? GetCulturesPublishing(IContent content)
|
||||
=> content.ContentType.VariesByCulture()
|
||||
? content.PublishCultureInfos?.Values.Where(x => x.IsDirty()).Select(x => x.Culture).ToList()
|
||||
: null;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Delete
|
||||
|
||||
@@ -305,13 +305,40 @@ public class DocumentUrlAliasService : IDocumentUrlAliasService
|
||||
scope.Complete();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task UpdateAliasCacheAsync(Guid documentKey)
|
||||
{
|
||||
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
|
||||
await CreateOrUpdateAliasesInternalAsync(documentKey, forceSkipDatabaseWrite: true);
|
||||
scope.Complete();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task UpdateAliasCacheWithDescendantsAsync(Guid documentKey)
|
||||
{
|
||||
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
|
||||
|
||||
var documentKeys = new List<Guid> { documentKey };
|
||||
if (_documentNavigationQueryService.TryGetDescendantsKeys(documentKey, out IEnumerable<Guid> descendantKeys))
|
||||
{
|
||||
documentKeys.AddRange(descendantKeys);
|
||||
}
|
||||
|
||||
foreach (Guid key in documentKeys)
|
||||
{
|
||||
await CreateOrUpdateAliasesInternalAsync(key, forceSkipDatabaseWrite: true);
|
||||
}
|
||||
|
||||
scope.Complete();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal implementation that processes a single document without creating its own scope.
|
||||
/// Caller must ensure a scope is active. A write lock on <see cref="Constants.Locks.DocumentUrlAliases"/>
|
||||
/// is required whenever this method may perform database writes — i.e. on all server roles except
|
||||
/// <see cref="ServerRole.Subscriber"/>, where persistence is skipped and the write lock is not taken.
|
||||
/// </summary>
|
||||
private async Task CreateOrUpdateAliasesInternalAsync(Guid documentKey)
|
||||
private async Task CreateOrUpdateAliasesInternalAsync(Guid documentKey, bool forceSkipDatabaseWrite = false)
|
||||
{
|
||||
IContent? document = _contentService.GetById(documentKey);
|
||||
if (document is null || document.Trashed || document.Blueprint)
|
||||
@@ -329,7 +356,7 @@ public class DocumentUrlAliasService : IDocumentUrlAliasService
|
||||
// Save to database (handles insert/update/delete via diff) and add to cache.
|
||||
// On subscribers we skip the persistence — the publisher has already written the aliases — but the
|
||||
// in-memory cache is still refreshed via the deferred enlistments so routing keeps working locally.
|
||||
bool skipDatabaseWrites = SkipDatabaseWrites();
|
||||
bool skipDatabaseWrites = forceSkipDatabaseWrite || SkipDatabaseWrites();
|
||||
if (aliases.Count > 0)
|
||||
{
|
||||
if (skipDatabaseWrites is false)
|
||||
|
||||
@@ -154,7 +154,7 @@ public class DocumentUrlService : IDocumentUrlService
|
||||
IPublishStatusQueryService publishStatusQueryService,
|
||||
IDomainCacheService domainCacheService)
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
:this(
|
||||
: this(
|
||||
logger,
|
||||
documentUrlRepository,
|
||||
documentRepository,
|
||||
@@ -604,7 +604,35 @@ public class DocumentUrlService : IDocumentUrlService
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task CreateOrUpdateUrlSegmentsAsync(IEnumerable<IContent> documentsEnumerable)
|
||||
public async Task CreateOrUpdateUrlSegmentsAsync(IEnumerable<IContent> documents)
|
||||
=> await CreateOrUpdateUrlSegmentsInternalAsync(documents, skipDatabaseWrite: false);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task UpdateUrlSegmentCacheAsync(Guid key)
|
||||
{
|
||||
IContent? content = _contentService.GetById(key);
|
||||
if (content is not null)
|
||||
{
|
||||
await CreateOrUpdateUrlSegmentsInternalAsync(content.Yield(), skipDatabaseWrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task UpdateUrlSegmentCacheWithDescendantsAsync(Guid key)
|
||||
{
|
||||
var id = _idKeyMap.GetIdForKey(key, UmbracoObjectTypes.Document).Result;
|
||||
IContent? item = _contentService.GetById(id);
|
||||
if (item is null)
|
||||
{
|
||||
_logger.LogDebug("Skipping URL segment cache update for document with key {DocumentKey} — document not found.", key);
|
||||
return;
|
||||
}
|
||||
|
||||
IEnumerable<IContent> descendants = _contentService.GetPagedDescendants(id, 0, int.MaxValue, out _);
|
||||
await CreateOrUpdateUrlSegmentsInternalAsync(new List<IContent>(descendants) { item }, skipDatabaseWrite: true);
|
||||
}
|
||||
|
||||
private async Task CreateOrUpdateUrlSegmentsInternalAsync(IEnumerable<IContent> documentsEnumerable, bool skipDatabaseWrite)
|
||||
{
|
||||
IEnumerable<IContent> documents = documentsEnumerable as IContent[] ?? documentsEnumerable.ToArray();
|
||||
if (documents.Any() is false)
|
||||
@@ -664,7 +692,7 @@ public class DocumentUrlService : IDocumentUrlService
|
||||
}
|
||||
}
|
||||
|
||||
if (toSave.Count > 0 && SkipDatabaseWrites() is false)
|
||||
if (!skipDatabaseWrite && toSave.Count > 0 && SkipDatabaseWrites() is false)
|
||||
{
|
||||
scope.WriteLock(Constants.Locks.DocumentUrls);
|
||||
_documentUrlRepository.Save(toSave);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Services.Changes;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="ContentTreeChangeNotification"/> to persist URL segments and aliases to the database
|
||||
/// on the originating server. This fires post-commit (during scope disposal) before the cache instruction
|
||||
/// is delivered to other servers, ensuring URL data is in the database before any server processes the instruction.
|
||||
/// </summary>
|
||||
public class DocumentUrlServiceContentTreeChangeNotificationHandler
|
||||
: INotificationAsyncHandler<ContentTreeChangeNotification>
|
||||
{
|
||||
private readonly IDocumentUrlService _documentUrlService;
|
||||
private readonly IDocumentUrlAliasService _documentUrlAliasService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DocumentUrlServiceContentTreeChangeNotificationHandler"/> class.
|
||||
/// </summary>
|
||||
public DocumentUrlServiceContentTreeChangeNotificationHandler(
|
||||
IDocumentUrlService documentUrlService,
|
||||
IDocumentUrlAliasService documentUrlAliasService)
|
||||
{
|
||||
_documentUrlService = documentUrlService;
|
||||
_documentUrlAliasService = documentUrlAliasService;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task HandleAsync(ContentTreeChangeNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_documentUrlService.IsInitialized is false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var refreshNodeItems = new List<IContent>();
|
||||
|
||||
foreach (TreeChange<IContent> change in notification.Changes)
|
||||
{
|
||||
if (change.ChangeTypes.HasType(TreeChangeTypes.RefreshNode))
|
||||
{
|
||||
refreshNodeItems.Add(change.Item);
|
||||
}
|
||||
|
||||
if (change.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch))
|
||||
{
|
||||
await _documentUrlService.CreateOrUpdateUrlSegmentsWithDescendantsAsync(change.Item.Key);
|
||||
await _documentUrlAliasService.CreateOrUpdateAliasesWithDescendantsAsync(change.Item.Key);
|
||||
}
|
||||
}
|
||||
|
||||
if (refreshNodeItems.Count > 0)
|
||||
{
|
||||
await _documentUrlService.CreateOrUpdateUrlSegmentsAsync(refreshNodeItems);
|
||||
|
||||
foreach (IContent item in refreshNodeItems)
|
||||
{
|
||||
await _documentUrlAliasService.CreateOrUpdateAliasesAsync(item.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,4 +60,20 @@ public interface IDocumentUrlAliasService
|
||||
/// </summary>
|
||||
/// <returns><c>true</c> if there are any aliases in the cache; otherwise, <c>false</c>.</returns>
|
||||
bool HasAny();
|
||||
|
||||
/// <summary>
|
||||
/// Updates the in-memory alias cache for a single document without writing to the database.
|
||||
/// </summary>
|
||||
/// <param name="documentKey">The document key.</param>
|
||||
// TODO (V19): Remove default implementation when external implementations have had time to adopt.
|
||||
Task UpdateAliasCacheAsync(Guid documentKey)
|
||||
=> CreateOrUpdateAliasesAsync(documentKey);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the in-memory alias cache for a document and its descendants without writing to the database.
|
||||
/// </summary>
|
||||
/// <param name="documentKey">The document key.</param>
|
||||
// TODO (V19): Remove default implementation when external implementations have had time to adopt.
|
||||
Task UpdateAliasCacheWithDescendantsAsync(Guid documentKey)
|
||||
=> CreateOrUpdateAliasesWithDescendantsAsync(documentKey);
|
||||
}
|
||||
|
||||
@@ -100,4 +100,20 @@ public interface IDocumentUrlService
|
||||
/// Gets a value indicating whether any URLs have been cached.
|
||||
/// </summary>
|
||||
bool HasAny();
|
||||
|
||||
/// <summary>
|
||||
/// Updates the in-memory URL segment cache for a single document without writing to the database.
|
||||
/// </summary>
|
||||
/// <param name="key">The document key.</param>
|
||||
// TODO (V19): Remove default implementation when external implementations have had time to adopt.
|
||||
Task UpdateUrlSegmentCacheAsync(Guid key)
|
||||
=> CreateOrUpdateUrlSegmentsAsync(key);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the in-memory URL segment cache for a document and its descendants without writing to the database.
|
||||
/// </summary>
|
||||
/// <param name="key">The document key.</param>
|
||||
// TODO (V19): Remove default implementation when external implementations have had time to adopt.
|
||||
Task UpdateUrlSegmentCacheWithDescendantsAsync(Guid key)
|
||||
=> CreateOrUpdateUrlSegmentsWithDescendantsAsync(key);
|
||||
}
|
||||
|
||||
@@ -30,11 +30,48 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
/// <summary>
|
||||
/// Bundles a navigation structure dictionary and its root keys into a single reference so that
|
||||
/// <see cref="HandleRebuildAsync"/> can swap both atomically with one <see cref="Interlocked.Exchange{T}"/>
|
||||
/// call and readers always observe a consistent pair.
|
||||
/// call and readers always observe a consistent pair. Also carries the per-snapshot
|
||||
/// descendants cache populated by <see cref="TryGetDescendantsKeysFromStructure"/>.
|
||||
/// </summary>
|
||||
private sealed record NavigationSnapshot(
|
||||
ConcurrentDictionary<Guid, NavigationNode> Structure,
|
||||
HashSet<Guid> Roots);
|
||||
HashSet<Guid> Roots)
|
||||
{
|
||||
private long _generation;
|
||||
|
||||
/// <summary>
|
||||
/// Cache of descendants <c>Guid[]</c> keyed by parent and an optional content-type
|
||||
/// filter. Populated lazily by <see cref="TryGetDescendantsKeysFromStructure"/> and
|
||||
/// cleared by <see cref="Invalidate"/> on any structural mutation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The composite key allows both <c>TryGetDescendantsKeys</c> (content-type =
|
||||
/// <c>null</c>) and <c>TryGetDescendantsKeysOfType</c> (content-type = the resolved
|
||||
/// <c>Guid</c>) to share one cache without their results contaminating each other.
|
||||
/// Realistic per-parent fan-out is bounded by the "allowed types" content model
|
||||
/// (typically 1-5 types per parent), and the cache is populated only for queries
|
||||
/// that actually run, so memory grows with the templates exercised rather than the
|
||||
/// theoretical product of (parents × content types).
|
||||
/// </remarks>
|
||||
public ConcurrentDictionary<(Guid Parent, Guid? ContentType), Guid[]> DescendantsCache { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// A monotonic counter incremented on every mutation. Used by readers to detect a
|
||||
/// concurrent mutation that occurred during their compute, so they can avoid writing
|
||||
/// a now-stale result back to <see cref="DescendantsCache"/>.
|
||||
/// </summary>
|
||||
public long Generation => Interlocked.Read(ref _generation);
|
||||
|
||||
/// <summary>
|
||||
/// Clears the descendants cache and bumps the generation. Call after any mutation to
|
||||
/// this snapshot's <see cref="Structure"/> or <see cref="Roots"/>.
|
||||
/// </summary>
|
||||
public void Invalidate()
|
||||
{
|
||||
Interlocked.Increment(ref _generation);
|
||||
DescendantsCache.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private NavigationSnapshot _navigation = new(new(), []);
|
||||
private NavigationSnapshot _recycleBinNavigation = new(new(), []);
|
||||
@@ -164,7 +201,12 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
/// </param>
|
||||
/// <returns><c>true</c> if the parent node exists in the structure; otherwise, <c>false</c>.</returns>
|
||||
public bool TryGetDescendantsKeys(Guid parentKey, out IEnumerable<Guid> descendantsKeys)
|
||||
=> TryGetDescendantsKeysFromStructure(_navigation.Structure, parentKey, out descendantsKeys);
|
||||
{
|
||||
// Snapshot to a local so cache lookups, the structure walk, and the generation check
|
||||
// all see the same NavigationSnapshot instance even if a rebuild swaps it in mid-call.
|
||||
NavigationSnapshot snapshot = _navigation;
|
||||
return TryGetDescendantsKeysFromStructure(snapshot.Structure, parentKey, out descendantsKeys, contentTypeKey: null, cachingSnapshot: snapshot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to get all descendant node keys of a specific content type under a parent node.
|
||||
@@ -182,7 +224,11 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
{
|
||||
if (TryGetContentTypeKey(contentTypeAlias, out Guid? contentTypeKey))
|
||||
{
|
||||
return TryGetDescendantsKeysFromStructure(_navigation.Structure, parentKey, out descendantsKeys, contentTypeKey);
|
||||
// Snapshot to a local so cache lookups, the structure walk, and the generation
|
||||
// check all see the same NavigationSnapshot instance even if a rebuild swaps it
|
||||
// in mid-call.
|
||||
NavigationSnapshot snapshot = _navigation;
|
||||
return TryGetDescendantsKeysFromStructure(snapshot.Structure, parentKey, out descendantsKeys, contentTypeKey, cachingSnapshot: snapshot);
|
||||
}
|
||||
|
||||
// Content type alias doesn't exist
|
||||
@@ -297,7 +343,10 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
/// </param>
|
||||
/// <returns><c>true</c> if the parent node exists in the recycle bin; otherwise, <c>false</c>.</returns>
|
||||
public bool TryGetDescendantsKeysInBin(Guid parentKey, out IEnumerable<Guid> descendantsKeys)
|
||||
=> TryGetDescendantsKeysFromStructure(_recycleBinNavigation.Structure, parentKey, out descendantsKeys);
|
||||
{
|
||||
NavigationSnapshot snapshot = _recycleBinNavigation;
|
||||
return TryGetDescendantsKeysFromStructure(snapshot.Structure, parentKey, out descendantsKeys, contentTypeKey: null, cachingSnapshot: snapshot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to get all ancestor node keys of a child node in the recycle bin navigation structure.
|
||||
@@ -375,8 +424,14 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
|
||||
// Reset the SortOrder based on its new position in the bin
|
||||
nodeToRemove.UpdateSortOrder(_recycleBinNavigation.Structure.Count);
|
||||
return _recycleBinNavigation.Structure.TryAdd(nodeToRemove.Key, nodeToRemove) &&
|
||||
_navigation.Structure.TryRemove(key, out _);
|
||||
var moved = _recycleBinNavigation.Structure.TryAdd(nodeToRemove.Key, nodeToRemove) &&
|
||||
_navigation.Structure.TryRemove(key, out _);
|
||||
|
||||
// Both snapshots' descendant lists are now potentially stale.
|
||||
_navigation.Invalidate();
|
||||
_recycleBinNavigation.Invalidate();
|
||||
|
||||
return moved;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -418,6 +473,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
|
||||
parentNode?.AddChild(_navigation.Structure, key);
|
||||
|
||||
_navigation.Invalidate();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -468,6 +524,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
// Set the new parent for the node (if parent node is null - the node is moved to root)
|
||||
targetParentNode?.AddChild(_navigation.Structure, key);
|
||||
|
||||
_navigation.Invalidate();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -488,6 +545,18 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
|
||||
node.UpdateSortOrder(newSortOrder);
|
||||
|
||||
// The parent's cached ordered-children snapshot sorts by child SortOrder and is now
|
||||
// stale — invalidate so the next read rebuilds against the new value.
|
||||
if (node.Parent is not null
|
||||
&& _navigation.Structure.TryGetValue(node.Parent.Value, out NavigationNode? parentNode))
|
||||
{
|
||||
parentNode.InvalidateOrderedChildren();
|
||||
}
|
||||
|
||||
// Descendants lists are sort-order-presorted (depth-first using each parent's
|
||||
// ordered children), so re-ordering a child re-orders any cached ancestor descendants.
|
||||
_navigation.Invalidate();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -510,7 +579,9 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
|
||||
RemoveDescendantsRecursively(nodeToRemove);
|
||||
|
||||
return _recycleBinNavigation.Structure.TryRemove(key, out _);
|
||||
var removed = _recycleBinNavigation.Structure.TryRemove(key, out _);
|
||||
_recycleBinNavigation.Invalidate();
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -545,8 +616,14 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
// Restore the node and its descendants from the recycle bin to the main structure
|
||||
RestoreNodeAndDescendantsRecursively(nodeToRestore);
|
||||
|
||||
return _navigation.Structure.TryAdd(nodeToRestore.Key, nodeToRestore) &&
|
||||
_recycleBinNavigation.Structure.TryRemove(key, out _);
|
||||
var restored = _navigation.Structure.TryAdd(nodeToRestore.Key, nodeToRestore) &&
|
||||
_recycleBinNavigation.Structure.TryRemove(key, out _);
|
||||
|
||||
// Both snapshots' descendant lists are now potentially stale.
|
||||
_navigation.Invalidate();
|
||||
_recycleBinNavigation.Invalidate();
|
||||
|
||||
return restored;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -655,10 +732,9 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
ConcurrentDictionary<Guid, NavigationNode> structure,
|
||||
Guid parentKey,
|
||||
out IEnumerable<Guid> descendantsKeys,
|
||||
Guid? contentTypeKey = null)
|
||||
Guid? contentTypeKey = null,
|
||||
NavigationSnapshot? cachingSnapshot = null)
|
||||
{
|
||||
var descendants = new List<Guid>();
|
||||
|
||||
if (structure.TryGetValue(parentKey, out NavigationNode? parentNode) is false)
|
||||
{
|
||||
// Parent doesn't exist
|
||||
@@ -666,9 +742,50 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
return false;
|
||||
}
|
||||
|
||||
// Both unfiltered and content-type-filtered queries are cached, distinguished by the
|
||||
// optional contentTypeKey in the composite key. Realistic per-parent fan-out is bounded
|
||||
// by the "allowed types" model (a few types per parent), and entries are populated
|
||||
// lazily for queries that actually run — so memory tracks the templates exercised, not
|
||||
// the theoretical product of (parents × types).
|
||||
var useCache = cachingSnapshot is not null;
|
||||
#pragma warning disable IDE0008 // Use explicit type (in this case using var improves the readability of the tuple key).
|
||||
var cacheKey = (parentKey, contentTypeKey);
|
||||
#pragma warning restore IDE0008 // Use explicit type
|
||||
|
||||
if (useCache && cachingSnapshot!.DescendantsCache.TryGetValue(cacheKey, out Guid[]? cached))
|
||||
{
|
||||
descendantsKeys = cached;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Capture the snapshot's mutation generation BEFORE walking. If a mutation invalidates
|
||||
// between here and the cache write, the result we computed may be stale relative to
|
||||
// the now-current Structure; we still hand it to the caller (it was correct at the
|
||||
// moment we read), but skip the cache write so future readers don't see stale data.
|
||||
var startGeneration = useCache ? cachingSnapshot!.Generation : 0;
|
||||
|
||||
var descendants = new List<Guid>();
|
||||
GetDescendantsRecursively(structure, parentNode, descendants, contentTypeKey);
|
||||
|
||||
descendantsKeys = descendants;
|
||||
if (useCache)
|
||||
{
|
||||
Guid[] result = [.. descendants];
|
||||
|
||||
// Only install if no mutation happened during compute, and skip caching empty
|
||||
// results — they're cheap to recompute and caching them bloats the dictionary with
|
||||
// one entry per (parent, type) pair queried with no measurable benefit.
|
||||
if (result.Length > 0 && cachingSnapshot!.Generation == startGeneration)
|
||||
{
|
||||
cachingSnapshot.DescendantsCache[cacheKey] = result;
|
||||
}
|
||||
|
||||
descendantsKeys = result;
|
||||
}
|
||||
else
|
||||
{
|
||||
descendantsKeys = descendants;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -859,6 +976,15 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
return [];
|
||||
}
|
||||
|
||||
// Unfiltered case uses the cached snapshot maintained on the node — returns the same
|
||||
// sorted Guid[] across calls until the children set or a child's SortOrder changes.
|
||||
if (contentTypeKey.HasValue is false)
|
||||
{
|
||||
return node.GetOrderedChildren(structure);
|
||||
}
|
||||
|
||||
// Filtered-by-content-type case stays uncached: it would need a composite (node, type)
|
||||
// key to memoise, and the call site is rare enough not to be worth it.
|
||||
var childrenWithSortOrder = new List<(Guid ChildNodeKey, int SortOrder)>(node.Children.Count);
|
||||
foreach (Guid childNodeKey in node.Children)
|
||||
{
|
||||
@@ -867,8 +993,7 @@ internal abstract class ContentNavigationServiceBase<TContentType, TContentTypeS
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply contentTypeKey filter
|
||||
if (contentTypeKey.HasValue && childNode.ContentTypeKey != contentTypeKey.Value)
|
||||
if (childNode.ContentTypeKey != contentTypeKey.Value)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -144,14 +144,14 @@ public PublishStatusService(
|
||||
{
|
||||
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
|
||||
ISet<string> publishedCultures = await _publishStatusRepository.GetPublishStatusAsync(documentKey, cancellationToken);
|
||||
_publishedCultures[documentKey] = publishedCultures;
|
||||
UpdatePublishedCultures(documentKey, publishedCultures);
|
||||
scope.Complete();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task RemoveAsync(Guid documentKey, CancellationToken cancellationToken)
|
||||
{
|
||||
_publishedCultures.TryRemove(documentKey, out _);
|
||||
RemovePublishedCultures(documentKey);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -167,7 +167,22 @@ public PublishStatusService(
|
||||
|
||||
foreach ((Guid documentKey, ISet<string> publishedCultures) in publishStatus)
|
||||
{
|
||||
_publishedCultures[documentKey] = publishedCultures;
|
||||
UpdatePublishedCultures(documentKey, publishedCultures);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdatePublishedCultures(Guid documentKey, ISet<string> publishedCultures)
|
||||
{
|
||||
if (publishedCultures.Count > 0)
|
||||
{
|
||||
_publishedCultures[documentKey] = publishedCultures;
|
||||
}
|
||||
else
|
||||
{
|
||||
RemovePublishedCultures(documentKey);
|
||||
}
|
||||
}
|
||||
|
||||
private void RemovePublishedCultures(Guid documentKey)
|
||||
=> _publishedCultures.TryRemove(documentKey, out _);
|
||||
}
|
||||
|
||||
@@ -56,14 +56,17 @@ internal sealed class PublishedContentStatusFilteringService : IPublishedContent
|
||||
_publishStatusQueryService.IsDocumentPublished(key, culture)
|
||||
&& _publishStatusQueryService.HasPublishedAncestorPath(key, culture));
|
||||
|
||||
return WhereIsInvariantOrHasCultureOrRequestedAllCultures(candidateKeys, culture, preview).ToArray();
|
||||
// Returned lazily so consumers like .FirstOrDefault() / .Take(n) can short-circuit
|
||||
// without materialising the full result. Callers that need to enumerate the result
|
||||
// more than once should buffer it themselves (.ToList() / .ToArray()).
|
||||
return WhereIsInvariantOrHasCultureOrRequestedAllCultures(candidateKeys, culture, preview);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<IPublishedContent> Unfiltered(IEnumerable<Guid> candidateKeys)
|
||||
{
|
||||
var preview = _previewService.IsInPreview();
|
||||
return candidateKeys.Select(key => _publishedContentCache.GetById(preview, key)).WhereNotNull().ToArray();
|
||||
return candidateKeys.Select(key => _publishedContentCache.GetById(preview, key)).WhereNotNull();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -24,10 +24,15 @@ internal sealed class PublishedMediaStatusFilteringService : IPublishedMediaStat
|
||||
=> _publishedMediaCache = publishedMediaCache;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Returned lazily so consumers like .FirstOrDefault() / .Take(n) can short-circuit without
|
||||
/// materialising the full result. Callers that need to enumerate the result more than once
|
||||
/// should buffer it themselves (.ToList() / .ToArray()).
|
||||
/// </remarks>
|
||||
public IEnumerable<IPublishedContent> FilterAvailable(IEnumerable<Guid> candidateKeys, string? culture)
|
||||
=> candidateKeys.Select(_publishedMediaCache.GetById).WhereNotNull().ToArray();
|
||||
=> candidateKeys.Select(_publishedMediaCache.GetById).WhereNotNull();
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<IPublishedContent> Unfiltered(IEnumerable<Guid> candidateKeys)
|
||||
=> candidateKeys.Select(_publishedMediaCache.GetById).WhereNotNull().ToArray();
|
||||
=> candidateKeys.Select(_publishedMediaCache.GetById).WhereNotNull();
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ public static class UdiEntityTypeHelper
|
||||
return Constants.UdiEntityType.DataTypeContainer;
|
||||
case UmbracoObjectTypes.MemberType:
|
||||
return Constants.UdiEntityType.MemberType;
|
||||
case UmbracoObjectTypes.MemberTypeContainer:
|
||||
return Constants.UdiEntityType.MemberTypeContainer;
|
||||
case UmbracoObjectTypes.MemberGroup:
|
||||
return Constants.UdiEntityType.MemberGroup;
|
||||
case UmbracoObjectTypes.RelationType:
|
||||
@@ -75,6 +77,8 @@ public static class UdiEntityTypeHelper
|
||||
return UmbracoObjectTypes.Document;
|
||||
case Constants.UdiEntityType.DocumentBlueprint:
|
||||
return UmbracoObjectTypes.DocumentBlueprint;
|
||||
case Constants.UdiEntityType.DocumentBlueprintContainer:
|
||||
return UmbracoObjectTypes.DocumentBlueprintContainer;
|
||||
case Constants.UdiEntityType.Media:
|
||||
return UmbracoObjectTypes.Media;
|
||||
case Constants.UdiEntityType.Member:
|
||||
@@ -95,6 +99,8 @@ public static class UdiEntityTypeHelper
|
||||
return UmbracoObjectTypes.DataTypeContainer;
|
||||
case Constants.UdiEntityType.MemberType:
|
||||
return UmbracoObjectTypes.MemberType;
|
||||
case Constants.UdiEntityType.MemberTypeContainer:
|
||||
return UmbracoObjectTypes.MemberTypeContainer;
|
||||
case Constants.UdiEntityType.MemberGroup:
|
||||
return UmbracoObjectTypes.MemberGroup;
|
||||
case Constants.UdiEntityType.RelationType:
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core.Configuration;
|
||||
|
||||
@@ -14,37 +9,48 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs
|
||||
public class DelayCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optonal
|
||||
/// configuration for the first run time is available.
|
||||
/// Determines the delay before the first run of a recurring task, using a <see cref="TimeProvider" /> for the current time.
|
||||
/// </summary>
|
||||
/// <param name="firstRunTime">The configured time to first run the task in crontab format.</param>
|
||||
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser"/></param>
|
||||
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser" />.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="timeProvider">The time provider used to determine the current time.</param>
|
||||
/// <param name="defaultDelay">The default delay to use when a first run time is not configured.</param>
|
||||
/// <returns>The delay before first running the recurring task.</returns>
|
||||
public static TimeSpan GetDelay(
|
||||
string firstRunTime,
|
||||
ICronTabParser cronTabParser,
|
||||
ILogger logger,
|
||||
TimeSpan defaultDelay) => GetDelay(firstRunTime, cronTabParser, logger, DateTime.Now, defaultDelay);
|
||||
/// <returns>
|
||||
/// The delay before first running the recurring task.
|
||||
/// </returns>
|
||||
public static TimeSpan GetDelay(string firstRunTime, ICronTabParser cronTabParser, ILogger logger, TimeProvider timeProvider, TimeSpan defaultDelay)
|
||||
=> GetDelay(firstRunTime, cronTabParser, logger, timeProvider.GetLocalNow().DateTime, defaultDelay);
|
||||
|
||||
/// <summary>
|
||||
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optonal
|
||||
/// configuration for the first run time is available.
|
||||
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optional configuration for the first run time is available.
|
||||
/// </summary>
|
||||
/// <param name="firstRunTime">The configured time to first run the task in crontab format.</param>
|
||||
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser"/></param>
|
||||
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser" />.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="defaultDelay">The default delay to use when a first run time is not configured.</param>
|
||||
/// <returns>
|
||||
/// The delay before first running the recurring task.
|
||||
/// </returns>
|
||||
[Obsolete("Use the overload accepting TimeProvider. Scheduled for removal in Umbraco 19.")]
|
||||
public static TimeSpan GetDelay(string firstRunTime, ICronTabParser cronTabParser, ILogger logger, TimeSpan defaultDelay)
|
||||
=> GetDelay(firstRunTime, cronTabParser, logger, DateTime.Now, defaultDelay);
|
||||
|
||||
/// <summary>
|
||||
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optional configuration for the first run time is available.
|
||||
/// </summary>
|
||||
/// <param name="firstRunTime">The configured time to first run the task in crontab format.</param>
|
||||
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser" />.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="now">The current datetime.</param>
|
||||
/// <param name="defaultDelay">The default delay to use when a first run time is not configured.</param>
|
||||
/// <returns>The delay before first running the recurring task.</returns>
|
||||
/// <remarks>Internal to expose for unit tests.</remarks>
|
||||
internal static TimeSpan GetDelay(
|
||||
string firstRunTime,
|
||||
ICronTabParser cronTabParser,
|
||||
ILogger logger,
|
||||
DateTime now,
|
||||
TimeSpan defaultDelay)
|
||||
/// <returns>
|
||||
/// The delay before first running the recurring task.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Internal to expose for unit tests.
|
||||
/// </remarks>
|
||||
internal static TimeSpan GetDelay(string firstRunTime, ICronTabParser cronTabParser, ILogger logger, DateTime now, TimeSpan defaultDelay)
|
||||
{
|
||||
// If first run time not set, start with just small delay after application start.
|
||||
if (string.IsNullOrEmpty(firstRunTime))
|
||||
@@ -56,12 +62,14 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs
|
||||
if (!cronTabParser.IsValidCronTab(firstRunTime))
|
||||
{
|
||||
logger.LogWarning("Could not parse {FirstRunTime} as a crontab expression. Defaulting to default delay for hosted service start.", firstRunTime);
|
||||
|
||||
return defaultDelay;
|
||||
}
|
||||
|
||||
// Otherwise start at scheduled time according to cron expression, unless within the default delay period.
|
||||
DateTime firstRunOccurance = cronTabParser.GetNextOccurrence(firstRunTime, now);
|
||||
TimeSpan delay = firstRunOccurance - now;
|
||||
DateTime firstRunOccurrence = cronTabParser.GetNextOccurrence(firstRunTime, now);
|
||||
TimeSpan delay = firstRunOccurrence - now;
|
||||
|
||||
return delay < defaultDelay
|
||||
? defaultDelay
|
||||
: delay;
|
||||
|
||||
@@ -97,7 +97,7 @@ public class DistributedBackgroundJobHostedService : BackgroundService
|
||||
{
|
||||
try
|
||||
{
|
||||
await RunRunnableJob();
|
||||
await RunRunnableJob(stoppingToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
@@ -117,7 +117,7 @@ public class DistributedBackgroundJobHostedService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RunRunnableJob()
|
||||
private async Task RunRunnableJob(CancellationToken stoppingToken)
|
||||
{
|
||||
IDistributedBackgroundJob? job = await _distributedJobService.TryTakeRunnableAsync();
|
||||
|
||||
@@ -129,7 +129,7 @@ public class DistributedBackgroundJobHostedService : BackgroundService
|
||||
|
||||
try
|
||||
{
|
||||
await job.ExecuteAsync();
|
||||
await job.ExecuteAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -20,4 +20,13 @@ public interface IDistributedBackgroundJob
|
||||
/// Run the job.
|
||||
/// </summary>
|
||||
Task ExecuteAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Run the job with a cancellation token that signals when the host is shutting down.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The default implementation delegates to <see cref="ExecuteAsync()"/>.
|
||||
/// Override this method to respond to graceful shutdown.
|
||||
/// </remarks>
|
||||
Task ExecuteAsync(CancellationToken cancellationToken) => ExecuteAsync();
|
||||
}
|
||||
|
||||
@@ -1,38 +1,98 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
|
||||
|
||||
/// <summary>
|
||||
/// A recurring background job
|
||||
/// A recurring background job.
|
||||
/// </summary>
|
||||
public interface IRecurringBackgroundJob
|
||||
{
|
||||
static readonly TimeSpan DefaultDelay = System.TimeSpan.FromMinutes(3);
|
||||
static readonly ServerRole[] DefaultServerRoles = new[] { ServerRole.Single, ServerRole.SchedulingPublisher };
|
||||
/// <summary>
|
||||
/// The default delay to use for recurring tasks for the first run after application start-up if no alternative is configured.
|
||||
/// </summary>
|
||||
[Obsolete("Use RecurringBackgroundJobBase.DefaultDelay instead. Scheduled for removal in Umbraco 19.")]
|
||||
static readonly TimeSpan DefaultDelay = RecurringBackgroundJobBase.DefaultDelay;
|
||||
|
||||
/// <summary>
|
||||
/// The default server roles that recurring background jobs run on.
|
||||
/// </summary>
|
||||
[Obsolete("Use RecurringBackgroundJobBase.DefaultServerRoles instead. Scheduled for removal in Umbraco 19.")]
|
||||
static readonly ServerRole[] DefaultServerRoles = RecurringBackgroundJobBase.DefaultServerRoles;
|
||||
|
||||
/// <summary>
|
||||
/// Timespan representing how often the task should recur.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// The period.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// Set to <see cref="Timeout.InfiniteTimeSpan" /> to (temporarily) disable automatic scheduling and turn the job into a manually triggered one (via <see cref="IRecurringBackgroundJobTrigger{TJob}" />). To change the period at runtime, subclasses of <see cref="RecurringBackgroundJobBase" /> assign the protected setter on <see cref="RecurringBackgroundJobBase.Period" /> (which auto-raises <see cref="PeriodChanged" />); direct implementors of this interface must raise <see cref="PeriodChanged" /> themselves after updating the backing value.
|
||||
/// </remarks>
|
||||
TimeSpan Period { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Timespan representing the initial delay after application start-up before the first run of the task
|
||||
/// occurs.
|
||||
/// Timespan representing the initial delay after application start-up before the first run of the task occurs.
|
||||
/// </summary>
|
||||
TimeSpan Delay { get => DefaultDelay; }
|
||||
/// <value>
|
||||
/// The delay.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// Set to <see cref="Timeout.InfiniteTimeSpan" /> to skip the automatic first run entirely; the first execution then only occurs when manually triggered via <see cref="IRecurringBackgroundJobTrigger{TJob}" />.
|
||||
/// </remarks>
|
||||
TimeSpan Delay => RecurringBackgroundJobBase.DefaultDelay; // TODO (V19): Remove the default implementation
|
||||
|
||||
/// <summary>
|
||||
/// Gets the server roles for which this recurring background job is intended.
|
||||
/// Timespan to wait before re-evaluating execution conditions when an execution is ignored (e.g. runtime not ready, wrong server role or not main domain).
|
||||
/// </summary>
|
||||
ServerRole[] ServerRoles { get => DefaultServerRoles; }
|
||||
|
||||
event EventHandler PeriodChanged;
|
||||
/// <value>
|
||||
/// The ignored delay.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// This back-off prevents tight looping when <see cref="Period" /> is short (or <see cref="TimeSpan.Zero" />) and an execution is skipped without invoking <see cref="RunJobAsync(CancellationToken)" />.
|
||||
/// Set to <see cref="Timeout.InfiniteTimeSpan" /> to disable the job for the remaining application lifecycle once an ignored condition is encountered — useful when the condition is known not to change (e.g. a server role that will not be promoted on this instance). To change the ignored delay at runtime, subclasses of <see cref="RecurringBackgroundJobBase" /> assign the protected setter on <see cref="RecurringBackgroundJobBase.IgnoredDelay" /> (which auto-raises <see cref="IgnoredDelayChanged" />); direct implementors of this interface must raise <see cref="IgnoredDelayChanged" /> themselves after updating the backing value.
|
||||
/// </remarks>
|
||||
TimeSpan IgnoredDelay => RecurringBackgroundJobBase.DefaultIgnoredDelay; // TODO (V19): Remove the default implementation
|
||||
|
||||
/// <summary>
|
||||
/// Executes the logic associated with the recurring background job asynchronously.
|
||||
/// Gets the server roles the task executes on.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="System.Threading.Tasks.Task"/> that represents the asynchronous execution of the background job.</returns>
|
||||
/// <value>
|
||||
/// The server roles.
|
||||
/// </value>
|
||||
ServerRole[] ServerRoles => RecurringBackgroundJobBase.DefaultServerRoles; // TODO (V19): Remove the default implementation
|
||||
|
||||
/// <summary>
|
||||
/// This event should be raised when the <see cref="Period" /> property changes to notify the background job manager to update the schedule for this job.
|
||||
/// </summary>
|
||||
event EventHandler PeriodChanged; // TODO (V19): Change to `event EventHandler? PeriodChanged;` so implementations can use field-like event syntax without manual backing-delegate accessors.
|
||||
|
||||
/// <summary>
|
||||
/// This event should be raised when the <see cref="IgnoredDelay" /> property changes (e.g. from <see cref="Timeout.InfiniteTimeSpan" /> back to a finite value) to interrupt any in-progress ignored back-off and re-read the new value.
|
||||
/// </summary>
|
||||
event EventHandler IgnoredDelayChanged
|
||||
{
|
||||
add { }
|
||||
remove { }
|
||||
} // TODO (V19): Remove the default implementation and change to `event EventHandler? IgnoredDelayChanged;` so implementations can use field-like event syntax without manual backing-delegate accessors.
|
||||
|
||||
/// <summary>
|
||||
/// Runs the background job.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// A task representing the asynchronous operation.
|
||||
/// </returns>
|
||||
[Obsolete("Use RunJobAsync(CancellationToken) instead. Scheduled for removal in Umbraco 19.")]
|
||||
Task RunJobAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the background job with cancellation support.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
|
||||
/// <returns>
|
||||
/// A task representing the asynchronous operation.
|
||||
/// </returns>
|
||||
Task RunJobAsync(CancellationToken cancellationToken)
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
=> RunJobAsync(); // TODO (V19): Remove the default implementation when RunJobAsync() is removed
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Infrastructure.HostedServices;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
|
||||
|
||||
/// <summary>
|
||||
/// Provides methods to signal a specific recurring background job to execute immediately.
|
||||
/// </summary>
|
||||
/// <typeparam name="TJob">The type of the recurring background job to trigger, as registered via <see cref="ServiceCollectionExtensions.AddRecurringBackgroundJob{TJob}(IServiceCollection)" />.</typeparam>
|
||||
public interface IRecurringBackgroundJobTrigger<TJob>
|
||||
where TJob : class, ITriggerableRecurringBackgroundJob
|
||||
{
|
||||
/// <summary>
|
||||
/// Signals the background loop to execute immediately.
|
||||
/// After the triggered execution, the original schedule is kept.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the job was found and triggered; <c>false</c> if no hosted service is running for this job type.
|
||||
/// </returns>
|
||||
/// <seealso cref="NextExecutionStrategy.None" />
|
||||
bool TriggerExecution();
|
||||
|
||||
/// <summary>
|
||||
/// Signals the background loop to execute immediately, with the specified strategy for determining the next execution after the triggered one completes.
|
||||
/// </summary>
|
||||
/// <param name="strategy">Controls the delay after the triggered execution.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the job was found and triggered; <c>false</c> if no hosted service is running for this job type.
|
||||
/// </returns>
|
||||
bool TriggerExecution(NextExecutionStrategy strategy);
|
||||
|
||||
/// <summary>
|
||||
/// Signals the background loop to execute immediately.
|
||||
/// After the triggered execution, the next execution is scheduled after the specified delay (measured from execution start; execution time is subtracted to prevent drift).
|
||||
/// </summary>
|
||||
/// <param name="nextDelay">The target interval from execution start to the next execution. Execution time is subtracted to prevent drift.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the job was found and triggered; <c>false</c> if no hosted service is running for this job type.
|
||||
/// </returns>
|
||||
bool TriggerExecution(TimeSpan nextDelay);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
|
||||
|
||||
/// <summary>
|
||||
/// Marker interface for recurring background jobs that support being triggered manually.
|
||||
/// Only jobs implementing this interface can be triggered via <see cref="IRecurringBackgroundJobTrigger{TJob}" />.
|
||||
/// </summary>
|
||||
public interface ITriggerableRecurringBackgroundJob : IRecurringBackgroundJob
|
||||
{ }
|
||||
@@ -1,7 +1,5 @@
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
using Umbraco.Cms.Core.Telemetry;
|
||||
@@ -12,33 +10,18 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
|
||||
/// <summary>
|
||||
/// Represents a background job that collects and reports information about the current Umbraco site, typically for analytics, diagnostics, or telemetry purposes.
|
||||
/// </summary>
|
||||
public class ReportSiteJob : IRecurringBackgroundJob
|
||||
public class ReportSiteJob : RecurringBackgroundJobBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the period at which the report site job runs.
|
||||
/// </summary>
|
||||
public TimeSpan Period => TimeSpan.FromDays(1);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the time interval to wait between executions of the <see cref="ReportSiteJob"/>.
|
||||
/// The delay is set to 5 minutes.
|
||||
/// </summary>
|
||||
public TimeSpan Delay => TimeSpan.FromMinutes(5);
|
||||
public override TimeSpan Delay => TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an array containing all possible values of the <see cref="ServerRole"/> enumeration.
|
||||
/// </summary>
|
||||
public ServerRole[] ServerRoles => Enum.GetValues<ServerRole>();
|
||||
|
||||
/// <summary>
|
||||
/// Event that is triggered when the reporting period for the site job is changed.
|
||||
/// </summary>
|
||||
/// <remarks>No-op event as the period never changes on this job</remarks>
|
||||
public event EventHandler PeriodChanged
|
||||
{
|
||||
add { }
|
||||
remove { }
|
||||
}
|
||||
public override ServerRole[] ServerRoles => Enum.GetValues<ServerRole>();
|
||||
|
||||
private readonly ILogger<ReportSiteJob> _logger;
|
||||
private readonly ITelemetryService _telemetryService;
|
||||
@@ -57,6 +40,7 @@ public class ReportSiteJob : IRecurringBackgroundJob
|
||||
ITelemetryService telemetryService,
|
||||
IJsonSerializer jsonSerializer,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
: base(TimeSpan.FromDays(1))
|
||||
{
|
||||
_logger = logger;
|
||||
_telemetryService = telemetryService;
|
||||
@@ -67,8 +51,11 @@ public class ReportSiteJob : IRecurringBackgroundJob
|
||||
/// <summary>
|
||||
/// Executes the background job that sends the anonymous site ID to the telemetry service.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task RunJobAsync()
|
||||
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
|
||||
/// <returns>
|
||||
/// A task that represents the asynchronous operation.
|
||||
/// </returns>
|
||||
public override async Task RunJobAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
TelemetryReportData? telemetryReportData = await _telemetryService.GetTelemetryReportDataAsync().ConfigureAwait(false);
|
||||
if (telemetryReportData is null)
|
||||
@@ -100,7 +87,7 @@ public class ReportSiteJob : IRecurringBackgroundJob
|
||||
// Make a HTTP Post to telemetry service
|
||||
// https://telemetry.umbraco.com/installs/
|
||||
// Fire & Forget, do not need to know if its a 200, 500 etc
|
||||
using (await httpClient.SendAsync(request))
|
||||
using (await httpClient.SendAsync(request, cancellationToken))
|
||||
{ }
|
||||
}
|
||||
catch
|
||||
|
||||
+9
-19
@@ -3,7 +3,6 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
@@ -13,28 +12,17 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration;
|
||||
/// <summary>
|
||||
/// Implements periodic database instruction processing as a hosted service.
|
||||
/// </summary>
|
||||
public class InstructionProcessJob : IRecurringBackgroundJob
|
||||
public class InstructionProcessJob : RecurringBackgroundJobBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the interval between executions of the instruction process job.
|
||||
/// </summary>
|
||||
public TimeSpan Period { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the delay time before the job is executed. The delay is fixed at one minute.
|
||||
/// </summary>
|
||||
public TimeSpan Delay { get => TimeSpan.FromMinutes(1); }
|
||||
public override TimeSpan Delay => TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Gets an array containing all possible values of the <see cref="ServerRole"/> enumeration.
|
||||
/// </summary>
|
||||
public ServerRole[] ServerRoles { get => Enum.GetValues<ServerRole>(); }
|
||||
|
||||
/// <summary>
|
||||
/// Event that is raised when the execution period of the <see cref="InstructionProcessJob"/> is changed.
|
||||
/// </summary>
|
||||
/// <remarks>No-op event as the period never changes on this job</remarks>
|
||||
public event EventHandler PeriodChanged { add { } remove { } }
|
||||
public override ServerRole[] ServerRoles => Enum.GetValues<ServerRole>();
|
||||
|
||||
private readonly ILogger<InstructionProcessJob> _logger;
|
||||
private readonly IServerMessenger _messenger;
|
||||
@@ -49,19 +37,21 @@ public class InstructionProcessJob : IRecurringBackgroundJob
|
||||
IServerMessenger messenger,
|
||||
ILogger<InstructionProcessJob> logger,
|
||||
IOptions<GlobalSettings> globalSettings)
|
||||
: base(globalSettings.Value.DatabaseServerMessenger.TimeBetweenSyncOperations)
|
||||
{
|
||||
_messenger = messenger;
|
||||
_logger = logger;
|
||||
|
||||
Period = globalSettings.Value.DatabaseServerMessenger.TimeBetweenSyncOperations;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the instruction processing job asynchronously by synchronizing messages using the messenger service.
|
||||
/// Logs an error if the synchronization fails, but always completes the task.
|
||||
/// </summary>
|
||||
/// <returns>A completed task representing the asynchronous operation.</returns>
|
||||
public Task RunJobAsync()
|
||||
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
|
||||
/// <returns>
|
||||
/// A completed task representing the asynchronous operation.
|
||||
/// </returns>
|
||||
public override Task RunJobAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
+38
-35
@@ -3,53 +3,35 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Hosting;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration;
|
||||
|
||||
/// <summary>
|
||||
/// Implements periodic server "touching" (to mark as active/deactive) as a hosted service.
|
||||
/// </summary>
|
||||
public class TouchServerJob : IRecurringBackgroundJob
|
||||
public class TouchServerJob : RecurringBackgroundJobBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the period that defines how often the server should be touched.
|
||||
/// </summary>
|
||||
public TimeSpan Period { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the fixed delay interval of 15 seconds between executions of the touch server job.
|
||||
/// This interval determines how often the server registration is updated.
|
||||
/// </summary>
|
||||
public TimeSpan Delay { get => TimeSpan.FromSeconds(15); }
|
||||
public override TimeSpan Delay => TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all server roles on which this job runs. This property returns every possible <see cref="ServerRole"/> value, indicating the job runs on all server roles.
|
||||
/// </summary>
|
||||
/// <remarks>Runs on all servers</remarks>
|
||||
public ServerRole[] ServerRoles { get => Enum.GetValues<ServerRole>(); }
|
||||
|
||||
private event EventHandler? _periodChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the period of the TouchServerJob changes.
|
||||
/// </summary>
|
||||
public event EventHandler PeriodChanged
|
||||
{
|
||||
add { _periodChanged += value; }
|
||||
remove { _periodChanged -= value; }
|
||||
}
|
||||
|
||||
public override ServerRole[] ServerRoles => Enum.GetValues<ServerRole>();
|
||||
|
||||
private readonly IHostingEnvironment _hostingEnvironment;
|
||||
private readonly ILogger<TouchServerJob> _logger;
|
||||
private readonly IServerRegistrationService _serverRegistrationService;
|
||||
private readonly IServerRoleAccessor _serverRoleAccessor;
|
||||
private readonly IDisposable? _onChangeRegistration;
|
||||
private GlobalSettings _globalSettings;
|
||||
|
||||
/// <summary>
|
||||
@@ -66,21 +48,18 @@ public class TouchServerJob : IRecurringBackgroundJob
|
||||
ILogger<TouchServerJob> logger,
|
||||
IOptionsMonitor<GlobalSettings> globalSettings,
|
||||
IServerRoleAccessor serverRoleAccessor)
|
||||
: base(globalSettings.CurrentValue.DatabaseServerRegistrar.WaitTimeBetweenCalls)
|
||||
{
|
||||
_serverRegistrationService = serverRegistrationService ??
|
||||
throw new ArgumentNullException(nameof(serverRegistrationService));
|
||||
_serverRegistrationService = serverRegistrationService ?? throw new ArgumentNullException(nameof(serverRegistrationService));
|
||||
_hostingEnvironment = hostingEnvironment;
|
||||
_logger = logger;
|
||||
_globalSettings = globalSettings.CurrentValue;
|
||||
_serverRoleAccessor = serverRoleAccessor;
|
||||
|
||||
Period = _globalSettings.DatabaseServerRegistrar.WaitTimeBetweenCalls;
|
||||
globalSettings.OnChange(x =>
|
||||
_onChangeRegistration = globalSettings.OnChange(x =>
|
||||
{
|
||||
_globalSettings = x;
|
||||
Period = x.DatabaseServerRegistrar.WaitTimeBetweenCalls;
|
||||
|
||||
_periodChanged?.Invoke(this, EventArgs.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -88,10 +67,12 @@ public class TouchServerJob : IRecurringBackgroundJob
|
||||
/// Executes the job that updates the server registration by touching the server record in the database.
|
||||
/// This keeps the server's registration active and ensures its status remains current.
|
||||
/// </summary>
|
||||
/// <returns>A completed task when the job has finished running.</returns>
|
||||
public Task RunJobAsync()
|
||||
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
|
||||
/// <returns>
|
||||
/// A completed task when the job has finished running.
|
||||
/// </returns>
|
||||
public override Task RunJobAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
// If the IServerRoleAccessor has been changed away from ElectedServerRoleAccessor this task no longer makes sense,
|
||||
// since all it's used for is to allow the ElectedServerRoleAccessor
|
||||
// to figure out what role a given server has, so we just stop this task.
|
||||
@@ -101,16 +82,27 @@ public class TouchServerJob : IRecurringBackgroundJob
|
||||
}
|
||||
|
||||
var serverAddress = _hostingEnvironment.ApplicationMainUrl?.ToString();
|
||||
if (serverAddress.IsNullOrWhiteSpace())
|
||||
if (string.IsNullOrWhiteSpace(serverAddress))
|
||||
{
|
||||
_logger.LogWarning("No umbracoApplicationUrl for service (yet), skip.");
|
||||
return Task.CompletedTask;
|
||||
// No application URL is known yet: either detection is off (WebRouting:ApplicationUrlDetection is
|
||||
// None with no UmbracoApplicationUrl set), or detection is on but no request has been served yet.
|
||||
// Register with the machine name as a placeholder so server-role election can still proceed (uniqueness
|
||||
// comes from the server identity, not this address). If a URL is later detected from a request, the next
|
||||
// touch overwrites the placeholder.
|
||||
serverAddress = Environment.MachineName;
|
||||
_logger.LogDebug(
|
||||
"No application URL available; registering server with placeholder address {ServerAddress}.",
|
||||
serverAddress);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Registering server with application URL {ServerAddress}.", serverAddress);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_serverRegistrationService.TouchServer(
|
||||
serverAddress!,
|
||||
serverAddress,
|
||||
_globalSettings.DatabaseServerRegistrar.StaleServerTimeout);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -120,4 +112,15 @@ public class TouchServerJob : IRecurringBackgroundJob
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_onChangeRegistration?.Dispose();
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core.IO;
|
||||
using Umbraco.Cms.Core.Runtime;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
|
||||
@@ -15,24 +14,13 @@ namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs;
|
||||
/// Will run on all servers - even though file upload should only be handled on the scheduling publisher, this will
|
||||
/// ensure that in the case it happens on subscribers that they are cleaned up too.
|
||||
/// </remarks>
|
||||
public class TempFileCleanupJob : IRecurringBackgroundJob
|
||||
public class TempFileCleanupJob : RecurringBackgroundJobBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the time interval between each execution of the temporary file cleanup job.
|
||||
/// </summary>
|
||||
public TimeSpan Period { get => TimeSpan.FromMinutes(60); }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the server roles on which this job runs. This job is configured to run on all server roles.
|
||||
/// </summary>
|
||||
/// <remarks>Runs on all servers</remarks>
|
||||
public ServerRole[] ServerRoles { get => Enum.GetValues<ServerRole>(); }
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the period of the TempFileCleanupJob changes.
|
||||
/// </summary>
|
||||
/// <remarks>No-op event as the period never changes on this job</remarks>
|
||||
public event EventHandler PeriodChanged { add { } remove { } }
|
||||
public override ServerRole[] ServerRoles => Enum.GetValues<ServerRole>();
|
||||
|
||||
private readonly TimeSpan _age = TimeSpan.FromDays(1);
|
||||
private readonly IIOHelper _ioHelper;
|
||||
@@ -45,28 +33,33 @@ public class TempFileCleanupJob : IRecurringBackgroundJob
|
||||
/// <param name="ioHelper">Helper service for IO operations.</param>
|
||||
/// <param name="logger">The typed logger.</param>
|
||||
public TempFileCleanupJob(IIOHelper ioHelper, ILogger<TempFileCleanupJob> logger)
|
||||
: base(TimeSpan.FromMinutes(60))
|
||||
{
|
||||
_ioHelper = ioHelper;
|
||||
_logger = logger;
|
||||
|
||||
_tempFolders = _ioHelper.GetTempFolders();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously executes the cleanup of temporary files in the configured temporary folders.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous cleanup operation.</returns>
|
||||
public Task RunJobAsync()
|
||||
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
|
||||
/// <returns>
|
||||
/// A task that represents the asynchronous cleanup operation.
|
||||
/// </returns>
|
||||
public override Task RunJobAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (DirectoryInfo folder in _tempFolders)
|
||||
{
|
||||
CleanupFolder(folder);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
CleanupFolder(folder, cancellationToken);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void CleanupFolder(DirectoryInfo folder)
|
||||
private void CleanupFolder(DirectoryInfo folder, CancellationToken cancellationToken)
|
||||
{
|
||||
CleanFolderResult result = _ioHelper.CleanFolder(folder, _age);
|
||||
switch (result.Status)
|
||||
@@ -96,6 +89,8 @@ public class TempFileCleanupJob : IRecurringBackgroundJob
|
||||
FileInfo[] files = folder.GetFiles("*.*", SearchOption.AllDirectories);
|
||||
foreach (FileInfo file in files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (DateTime.UtcNow - file.LastWriteTimeUtc > _age)
|
||||
{
|
||||
try
|
||||
@@ -110,5 +105,4 @@ public class TempFileCleanupJob : IRecurringBackgroundJob
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for recurring background jobs that provides default values for common properties.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Implementors must pass an initial <see cref="Period" /> to the base constructor and implement <see cref="RunJobAsync(CancellationToken)" />.
|
||||
/// </remarks>
|
||||
public abstract class RecurringBackgroundJobBase : IRecurringBackgroundJob, IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The default delay to use for recurring tasks for the first run after application start-up if no alternative is configured.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The default of 3 minutes is chosen to allow the application to finish starting up and stabilize before the first execution of recurring tasks.
|
||||
/// </remarks>
|
||||
protected internal static readonly TimeSpan DefaultDelay = TimeSpan.FromMinutes(3);
|
||||
|
||||
/// <summary>
|
||||
/// The default back-off to use when an execution is ignored, before re-evaluating execution conditions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The default of 1 minute prevents tight looping when an execution is skipped (e.g. runtime not ready, wrong server role or not main domain) and the configured <see cref="IRecurringBackgroundJob.Period" /> is short or <see cref="TimeSpan.Zero" />.
|
||||
/// </remarks>
|
||||
protected internal static readonly TimeSpan DefaultIgnoredDelay = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// The default server roles that recurring background jobs run on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The default of running on both <see cref="ServerRole.Single" /> and <see cref="ServerRole.SchedulingPublisher" /> is chosen to ensure recurring background jobs do not run on every server (in a load-balanced environment).
|
||||
/// </remarks>
|
||||
protected internal static readonly ServerRole[] DefaultServerRoles = [ServerRole.Single, ServerRole.SchedulingPublisher];
|
||||
|
||||
private TimeSpan _period;
|
||||
private TimeSpan _ignoredDelay = DefaultIgnoredDelay;
|
||||
private EventHandler? _periodChanged;
|
||||
private EventHandler? _ignoredDelayChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RecurringBackgroundJobBase" /> class with the specified initial <paramref name="period" />. The initial value is stored directly without raising <see cref="PeriodChanged" />.
|
||||
/// </summary>
|
||||
/// <param name="period">The initial period between executions. Set to <see cref="Timeout.InfiniteTimeSpan" /> for a manual-trigger-only job.</param>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="period" /> is negative and not <see cref="Timeout.InfiniteTimeSpan" />.</exception>
|
||||
protected RecurringBackgroundJobBase(TimeSpan period)
|
||||
{
|
||||
if (period != Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(period, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
_period = period;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Setting this property to a different value raises <see cref="PeriodChanged" />. The initial value passed to the constructor is stored without raising the event.
|
||||
/// </remarks>
|
||||
public virtual TimeSpan Period
|
||||
{
|
||||
get => _period;
|
||||
protected set
|
||||
{
|
||||
if (value != Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(value, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
if (_period == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_period = value;
|
||||
OnPeriodChanged(EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual TimeSpan Delay => DefaultDelay;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Setting this property to a different value raises <see cref="IgnoredDelayChanged" />. The initial value (<see cref="DefaultIgnoredDelay" />) is set without raising the event.
|
||||
/// </remarks>
|
||||
public virtual TimeSpan IgnoredDelay
|
||||
{
|
||||
get => _ignoredDelay;
|
||||
protected set
|
||||
{
|
||||
if (value != Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(value, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
if (_ignoredDelay == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ignoredDelay = value;
|
||||
OnIgnoredDelayChanged(EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual ServerRole[] ServerRoles => DefaultServerRoles;
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual event EventHandler PeriodChanged
|
||||
{
|
||||
add { _periodChanged += value; }
|
||||
remove { _periodChanged -= value; }
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual event EventHandler IgnoredDelayChanged
|
||||
{
|
||||
add { _ignoredDelayChanged += value; }
|
||||
remove { _ignoredDelayChanged -= value; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises the <see cref="PeriodChanged" /> event.
|
||||
/// </summary>
|
||||
/// <param name="e">The <see cref="EventArgs" /> instance containing the event data.</param>
|
||||
/// <remarks>
|
||||
/// Override this when overriding <see cref="PeriodChanged" /> to dispatch through the overridden event's backing delegate.
|
||||
/// </remarks>
|
||||
protected virtual void OnPeriodChanged(EventArgs e)
|
||||
=> _periodChanged?.Invoke(this, e);
|
||||
|
||||
/// <summary>
|
||||
/// Raises the <see cref="IgnoredDelayChanged" /> event.
|
||||
/// </summary>
|
||||
/// <param name="e">The <see cref="EventArgs" /> instance containing the event data.</param>
|
||||
/// <remarks>
|
||||
/// Override this when overriding <see cref="IgnoredDelayChanged" /> to dispatch through the overridden event's backing delegate.
|
||||
/// </remarks>
|
||||
protected virtual void OnIgnoredDelayChanged(EventArgs e)
|
||||
=> _ignoredDelayChanged?.Invoke(this, e);
|
||||
|
||||
/// <inheritdoc />
|
||||
[Obsolete("Use RunJobAsync(CancellationToken) instead. Scheduled for removal in Umbraco 19.")]
|
||||
public Task RunJobAsync() => RunJobAsync(CancellationToken.None);
|
||||
|
||||
/// <inheritdoc />
|
||||
public abstract Task RunJobAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the resources used by this job. Subclasses adding disposable state should override this method, dispose their own resources, and call <c>base.Dispose(disposing)</c>.
|
||||
/// </summary>
|
||||
/// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
// Clear the subscriber delegates so the job does not retain references to (or invoke) listeners after disposal.
|
||||
_periodChanged = null;
|
||||
_ignoredDelayChanged = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Serilog.Core;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Runtime;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
using Umbraco.Cms.Infrastructure.HostedServices;
|
||||
using Umbraco.Cms.Infrastructure.Notifications;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
|
||||
|
||||
@@ -23,32 +22,72 @@ public static class RecurringBackgroundJobHostedService
|
||||
/// Creates a factory function that produces hosted services for recurring background jobs.
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">The service provider used to create hosted service instances.</param>
|
||||
/// <returns>A function that takes an <see cref="IRecurringBackgroundJob"/> and returns an <see cref="IHostedService"/>.</returns>
|
||||
public static Func<IRecurringBackgroundJob, IHostedService> CreateHostedServiceFactory(IServiceProvider serviceProvider) =>
|
||||
(IRecurringBackgroundJob job) =>
|
||||
/// <returns>
|
||||
/// A function that takes an <see cref="IRecurringBackgroundJob" /> and returns an <see cref="IHostedService" />.
|
||||
/// </returns>
|
||||
public static Func<IRecurringBackgroundJob, IHostedService> CreateHostedServiceFactory(IServiceProvider serviceProvider)
|
||||
=> (IRecurringBackgroundJob job) =>
|
||||
{
|
||||
Type hostedServiceType = typeof(RecurringBackgroundJobHostedService<>).MakeGenericType(job.GetType());
|
||||
|
||||
return (IHostedService)ActivatorUtilities.CreateInstance(serviceProvider, hostedServiceType, job);
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a recurring background job inside a hosted service.
|
||||
/// Generic version for DependencyInjection
|
||||
/// </summary>
|
||||
/// <typeparam name="TJob">Type of the Job</typeparam>
|
||||
public class RecurringBackgroundJobHostedService<TJob> : RecurringHostedServiceBase where TJob : IRecurringBackgroundJob
|
||||
/// <typeparam name="TJob">The type of the job.</typeparam>
|
||||
public class RecurringBackgroundJobHostedService<TJob> : RecurringHostedServiceBase
|
||||
where TJob : IRecurringBackgroundJob
|
||||
{
|
||||
|
||||
private readonly IRuntimeState _runtimeState;
|
||||
private readonly ILogger<RecurringBackgroundJobHostedService<TJob>> _logger;
|
||||
private readonly IMainDom _mainDom;
|
||||
private readonly IRuntimeState _runtimeState;
|
||||
private readonly IServerRoleAccessor _serverRoleAccessor;
|
||||
private readonly IEventAggregator _eventAggregator;
|
||||
private readonly IEventMessagesFactory _eventMessagesFactory;
|
||||
private readonly IRecurringBackgroundJob _job;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private CancellationTokenSource _ignoredDelayChangeCts = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RecurringBackgroundJobHostedService{TJob}"/> class, which manages the execution of a recurring background job.
|
||||
/// Initializes a new instance of the <see cref="RecurringBackgroundJobHostedService{TJob}" /> class, which manages the execution of a recurring background job.
|
||||
/// </summary>
|
||||
/// <param name="runtimeState">Provides information about the current runtime state of the Umbraco application.</param>
|
||||
/// <param name="logger">The logger used to record diagnostic and operational information for this hosted service.</param>
|
||||
/// <param name="mainDom">The main domain instance responsible for coordinating single-instance operations across multiple application domains.</param>
|
||||
/// <param name="serverRoleAccessor">Determines the current server's role in a multi-server environment.</param>
|
||||
/// <param name="eventAggregator">Handles the publishing and subscribing of application events.</param>
|
||||
/// <param name="eventMessagesFactory">The event messages factory.</param>
|
||||
/// <param name="job">The recurring background job instance to be managed and executed by this service.</param>
|
||||
/// <param name="timeProvider">The time provider used for scheduling and elapsed time measurement.</param>
|
||||
public RecurringBackgroundJobHostedService(
|
||||
IRuntimeState runtimeState,
|
||||
ILogger<RecurringBackgroundJobHostedService<TJob>> logger,
|
||||
IMainDom mainDom,
|
||||
IServerRoleAccessor serverRoleAccessor,
|
||||
IEventAggregator eventAggregator,
|
||||
IEventMessagesFactory eventMessagesFactory,
|
||||
TJob job,
|
||||
TimeProvider timeProvider)
|
||||
: base(logger, job.Period, job.Delay, timeProvider)
|
||||
{
|
||||
_runtimeState = runtimeState;
|
||||
_logger = logger;
|
||||
_mainDom = mainDom;
|
||||
_serverRoleAccessor = serverRoleAccessor;
|
||||
_eventAggregator = eventAggregator;
|
||||
_eventMessagesFactory = eventMessagesFactory;
|
||||
_job = job;
|
||||
_timeProvider = timeProvider;
|
||||
|
||||
_job.PeriodChanged += OnPeriodChanged;
|
||||
_job.IgnoredDelayChanged += OnIgnoredDelayChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RecurringBackgroundJobHostedService{TJob}" /> class, which manages the execution of a recurring background job.
|
||||
/// </summary>
|
||||
/// <param name="runtimeState">Provides information about the current runtime state of the Umbraco application.</param>
|
||||
/// <param name="logger">The logger used to record diagnostic and operational information for this hosted service.</param>
|
||||
@@ -56,6 +95,7 @@ public class RecurringBackgroundJobHostedService<TJob> : RecurringHostedServiceB
|
||||
/// <param name="serverRoleAccessor">Determines the current server's role in a multi-server environment.</param>
|
||||
/// <param name="eventAggregator">Handles the publishing and subscribing of application events.</param>
|
||||
/// <param name="job">The recurring background job instance to be managed and executed by this service.</param>
|
||||
[Obsolete("Use the constructor accepting IEventMessagesFactory and TimeProvider instead. Scheduled for removal in Umbraco 19.")]
|
||||
public RecurringBackgroundJobHostedService(
|
||||
IRuntimeState runtimeState,
|
||||
ILogger<RecurringBackgroundJobHostedService<TJob>> logger,
|
||||
@@ -63,94 +103,176 @@ public class RecurringBackgroundJobHostedService<TJob> : RecurringHostedServiceB
|
||||
IServerRoleAccessor serverRoleAccessor,
|
||||
IEventAggregator eventAggregator,
|
||||
TJob job)
|
||||
: base(logger, job.Period, job.Delay)
|
||||
{
|
||||
_runtimeState = runtimeState;
|
||||
_logger = logger;
|
||||
_mainDom = mainDom;
|
||||
_serverRoleAccessor = serverRoleAccessor;
|
||||
_eventAggregator = eventAggregator;
|
||||
_job = job;
|
||||
|
||||
_job.PeriodChanged += (sender, e) => ChangePeriod(_job.Period);
|
||||
}
|
||||
: this(runtimeState, logger, mainDom, serverRoleAccessor, eventAggregator, StaticServiceProvider.Instance.GetRequiredService<IEventMessagesFactory>(), job, TimeProvider.System)
|
||||
{ }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task PerformExecuteAsync(object? state)
|
||||
public override async Task PerformExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var executingNotification = new Notifications.RecurringBackgroundJobExecutingNotification(_job, new EventMessages());
|
||||
await _eventAggregator.PublishAsync(executingNotification);
|
||||
EventMessages eventMessages = _eventMessagesFactory.Get();
|
||||
var executingNotification = new RecurringBackgroundJobExecutingNotification(_job, eventMessages);
|
||||
await _eventAggregator.PublishAsync(executingNotification, stoppingToken);
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
if (_runtimeState.Level != RuntimeLevel.Run)
|
||||
{
|
||||
_logger.LogDebug("Job not running as runlevel not yet ready");
|
||||
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobIgnoredNotification(_job, new EventMessages()).WithStateFrom(executingNotification));
|
||||
await IgnoreAndWaitAsync("Job not running as runlevel not yet ready", eventMessages, executingNotification, stoppingToken);
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't run on replicas nor unknown role servers
|
||||
if (!_job.ServerRoles.Contains(_serverRoleAccessor.CurrentServerRole))
|
||||
{
|
||||
_logger.LogDebug("Job not running on this server role");
|
||||
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobIgnoredNotification(_job, new EventMessages()).WithStateFrom(executingNotification));
|
||||
await IgnoreAndWaitAsync("Job not running on this server role", eventMessages, executingNotification, stoppingToken);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure we do not run if not main domain, but do NOT lock it
|
||||
if (!_mainDom.IsMainDom)
|
||||
{
|
||||
_logger.LogDebug("Job not running as not MainDom");
|
||||
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobIgnoredNotification(_job, new EventMessages()).WithStateFrom(executingNotification));
|
||||
await IgnoreAndWaitAsync("Job not running as not MainDom", eventMessages, executingNotification, stoppingToken);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
await _job.RunJobAsync();
|
||||
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobExecutedNotification(_job, new EventMessages()).WithStateFrom(executingNotification));
|
||||
|
||||
|
||||
await _job.RunJobAsync(stoppingToken);
|
||||
await _eventAggregator.PublishAsync(new RecurringBackgroundJobExecutedNotification(_job, eventMessages).WithStateFrom(executingNotification), stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogDebug("Job canceled during shutdown.");
|
||||
await _eventAggregator.PublishAsync(new RecurringBackgroundJobCanceledNotification(_job, eventMessages).WithStateFrom(executingNotification), CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobFailedNotification(_job, new EventMessages()).WithStateFrom(executingNotification));
|
||||
_logger.LogError(ex, "Unhandled exception in recurring background job.");
|
||||
await _eventAggregator.PublishAsync(new RecurringBackgroundJobFailedNotification(_job, eventMessages).WithStateFrom(executingNotification), stoppingToken);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously starts the recurring background job and publishes notifications before and after the job is started.
|
||||
/// This method first publishes a <see cref="Notifications.RecurringBackgroundJobStartingNotification"/> prior to starting the job,
|
||||
/// then calls the base implementation to start the job, and finally publishes a <see cref="Notifications.RecurringBackgroundJobStartedNotification"/>.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A task that represents the asynchronous start operation.</returns>
|
||||
/// <inheritdoc />
|
||||
[Obsolete("Override PerformExecuteAsync(CancellationToken) instead. Scheduled for removal in Umbraco 19.")]
|
||||
public override Task PerformExecuteAsync(object? state) => PerformExecuteAsync(CancellationToken.None);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var startingNotification = new Notifications.RecurringBackgroundJobStartingNotification(_job, new EventMessages());
|
||||
await _eventAggregator.PublishAsync(startingNotification);
|
||||
EventMessages eventMessages = _eventMessagesFactory.Get();
|
||||
var startingNotification = new RecurringBackgroundJobStartingNotification(_job, eventMessages);
|
||||
await _eventAggregator.PublishAsync(startingNotification, cancellationToken);
|
||||
|
||||
await base.StartAsync(cancellationToken);
|
||||
// Suppress execution context flow around base.StartAsync so the fire-and-forget ExecuteAsync loop
|
||||
// does not capture AsyncLocal state from the host — in particular Umbraco's static AmbientScopeStack,
|
||||
// which uses a ConcurrentStack<IScope> reference that, once non-null, would be shared across every
|
||||
// hosted service that inherits this ExecutionContext. Without this, concurrent scope pushes/pops
|
||||
// across recurring loops and other hosted services interleave and trigger "not the ambient scope"
|
||||
// errors at Scope.Dispose (see DistributedJobService.EnsureJobsAsync for the original repro).
|
||||
Task startTask;
|
||||
using (ExecutionContext.IsFlowSuppressed() ? null : (IDisposable?)ExecutionContext.SuppressFlow())
|
||||
{
|
||||
startTask = base.StartAsync(cancellationToken);
|
||||
}
|
||||
|
||||
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobStartedNotification(_job, new EventMessages()).WithStateFrom(startingNotification));
|
||||
await startTask;
|
||||
|
||||
await _eventAggregator.PublishAsync(new RecurringBackgroundJobStartedNotification(_job, eventMessages).WithStateFrom(startingNotification), cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously stops the recurring background job service, publishing notifications before and after stopping.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A task that represents the asynchronous stop operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var stoppingNotification = new Notifications.RecurringBackgroundJobStoppingNotification(_job, new EventMessages());
|
||||
await _eventAggregator.PublishAsync(stoppingNotification);
|
||||
EventMessages eventMessages = _eventMessagesFactory.Get();
|
||||
var stoppingNotification = new RecurringBackgroundJobStoppingNotification(_job, eventMessages);
|
||||
await _eventAggregator.PublishAsync(stoppingNotification, cancellationToken);
|
||||
|
||||
await base.StopAsync(cancellationToken);
|
||||
|
||||
await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobStoppedNotification(_job, new EventMessages()).WithStateFrom(stoppingNotification));
|
||||
await _eventAggregator.PublishAsync(new RecurringBackgroundJobStoppedNotification(_job, eventMessages).WithStateFrom(stoppingNotification), cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_job.PeriodChanged -= OnPeriodChanged;
|
||||
_job.IgnoredDelayChanged -= OnIgnoredDelayChanged;
|
||||
|
||||
_ignoredDelayChangeCts.Dispose();
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the <see cref="IRecurringBackgroundJob.PeriodChanged" /> event by updating the base class period.
|
||||
/// </summary>
|
||||
/// <param name="sender">The sender.</param>
|
||||
/// <param name="e">The <see cref="EventArgs"/> instance containing the event data.</param>
|
||||
private void OnPeriodChanged(object? sender, EventArgs e)
|
||||
=> ChangePeriod(_job.Period);
|
||||
|
||||
/// <summary>
|
||||
/// Handles the <see cref="IRecurringBackgroundJob.IgnoredDelayChanged" /> event by interrupting any in-progress ignored back-off so it re-reads the new <see cref="IRecurringBackgroundJob.IgnoredDelay" /> value.
|
||||
/// </summary>
|
||||
/// <param name="sender">The sender.</param>
|
||||
/// <param name="e">The <see cref="EventArgs"/> instance containing the event data.</param>
|
||||
private void OnIgnoredDelayChanged(object? sender, EventArgs e)
|
||||
=> CancellationTokenSourceRotation.RotateAndCancel(ref _ignoredDelayChangeCts);
|
||||
|
||||
/// <summary>
|
||||
/// Publishes the ignored notification and waits for <see cref="IRecurringBackgroundJob.IgnoredDelay" /> before allowing the next iteration, preventing tight looping when execution is skipped.
|
||||
/// </summary>
|
||||
/// <param name="message">The full debug message describing why the execution is ignored.</param>
|
||||
/// <param name="eventMessages">The event messages for the notification.</param>
|
||||
/// <param name="executingNotification">The originating executing notification to carry state from.</param>
|
||||
/// <param name="stoppingToken">A cancellation token that is signaled when the host is shutting down.</param>
|
||||
private async Task IgnoreAndWaitAsync(
|
||||
string message,
|
||||
EventMessages eventMessages,
|
||||
RecurringBackgroundJobExecutingNotification executingNotification,
|
||||
CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogDebug(message);
|
||||
await _eventAggregator.PublishAsync(new RecurringBackgroundJobIgnoredNotification(_job, eventMessages).WithStateFrom(executingNotification), stoppingToken);
|
||||
|
||||
long waitStart = _timeProvider.GetTimestamp();
|
||||
|
||||
while (true)
|
||||
{
|
||||
TimeSpan ignoredDelay = _job.IgnoredDelay;
|
||||
|
||||
// Skip back-off for zero/negative; Timeout.InfiniteTimeSpan means wait until shutdown or IgnoredDelayChanged.
|
||||
if (ignoredDelay != Timeout.InfiniteTimeSpan && ignoredDelay <= TimeSpan.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TimeSpan remaining = ComputeNextDelay(ignoredDelay, _timeProvider.GetElapsedTime(waitStart));
|
||||
if (remaining == TimeSpan.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CancellationToken ignoredDelayChangeToken = _ignoredDelayChangeCts.Token;
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, ignoredDelayChangeToken);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(remaining, _timeProvider, linkedCts.Token);
|
||||
|
||||
// Back-off complete
|
||||
return;
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
// Back-off interrupted by shutdown; the ignored notification has already been published, so do not also publish canceled
|
||||
return;
|
||||
}
|
||||
catch (OperationCanceledException) when (ignoredDelayChangeToken.IsCancellationRequested)
|
||||
{
|
||||
// IgnoredDelay changed — loop to re-read and recompute the remaining wait
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+88
-45
@@ -1,25 +1,26 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Infrastructure.HostedServices;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
|
||||
|
||||
/// <summary>
|
||||
/// A hosted service that discovers and starts hosted services for any recurring background jobs in the DI container.
|
||||
/// A hosted service that discovers and starts hosted services for any recurring background jobs in the DI container.
|
||||
/// </summary>
|
||||
public class RecurringBackgroundJobHostedServiceRunner : IHostedService
|
||||
{
|
||||
private readonly ILogger<RecurringBackgroundJobHostedServiceRunner> _logger;
|
||||
private readonly List<IRecurringBackgroundJob> _jobs;
|
||||
private readonly Func<IRecurringBackgroundJob, IHostedService> _jobFactory;
|
||||
private readonly List<NamedServiceJob> _hostedServices = new();
|
||||
|
||||
private readonly ConcurrentDictionary<Type, IHostedService> _hostedServices = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RecurringBackgroundJobHostedServiceRunner"/> class.
|
||||
/// Initializes a new instance of the <see cref="RecurringBackgroundJobHostedServiceRunner" /> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">An <see cref="ILogger{RecurringBackgroundJobHostedServiceRunner}"/> used for logging within the runner.</param>
|
||||
/// <param name="jobs">A collection of <see cref="IRecurringBackgroundJob"/> instances to be managed by the runner.</param>
|
||||
/// <param name="jobFactory">A factory function that creates an <see cref="IHostedService"/> for each <see cref="IRecurringBackgroundJob"/>.</param>
|
||||
/// <param name="logger">An <see cref="ILogger{RecurringBackgroundJobHostedServiceRunner}" /> used for logging within the runner.</param>
|
||||
/// <param name="jobs">A collection of <see cref="IRecurringBackgroundJob" /> instances to be managed by the runner.</param>
|
||||
/// <param name="jobFactory">A factory function that creates an <see cref="IHostedService" /> for each <see cref="IRecurringBackgroundJob" />.</param>
|
||||
public RecurringBackgroundJobHostedServiceRunner(
|
||||
ILogger<RecurringBackgroundJobHostedServiceRunner> logger,
|
||||
IEnumerable<IRecurringBackgroundJob> jobs,
|
||||
@@ -30,80 +31,122 @@ public class RecurringBackgroundJobHostedServiceRunner : IHostedService
|
||||
_jobFactory = jobFactory;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Starting recurring background jobs hosted services");
|
||||
|
||||
foreach (IRecurringBackgroundJob job in _jobs)
|
||||
{
|
||||
var jobName = job.GetType().Name;
|
||||
Type jobType = job.GetType();
|
||||
var added = false;
|
||||
|
||||
try
|
||||
{
|
||||
IHostedService hostedService = _hostedServices.GetOrAdd(jobType, _ =>
|
||||
{
|
||||
_logger.LogDebug("Creating background hosted service for {JobTypeName}", jobType.Name);
|
||||
|
||||
_logger.LogDebug("Creating background hosted service for {job}", jobName);
|
||||
IHostedService hostedService = _jobFactory(job);
|
||||
IHostedService hostedService = _jobFactory(job);
|
||||
added = true;
|
||||
|
||||
_logger.LogInformation("Starting a background hosted service for {job} with a delay of {delay}, running every {period}", jobName, job.Delay, job.Period);
|
||||
return hostedService;
|
||||
});
|
||||
|
||||
if (!added)
|
||||
{
|
||||
_logger.LogWarning("A background hosted service for {JobTypeName} is already registered, skipping duplicate", jobType.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Starting a background hosted service for {JobTypeName} with a delay of {Delay}, running every {Period}", jobType.Name, job.Delay, job.Period);
|
||||
|
||||
await hostedService.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_hostedServices.Add(new NamedServiceJob(jobName, hostedService));
|
||||
}
|
||||
catch (Exception exception)
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(exception, "Failed to start background hosted service for {job}", jobName);
|
||||
if (added)
|
||||
{
|
||||
// Ensure we don't stop hosted services that were not successfully started
|
||||
_hostedServices.TryRemove(jobType, out _);
|
||||
}
|
||||
|
||||
_logger.LogError(ex, "Failed to start background hosted service for {JobTypeName}", jobType.Name);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Completed starting recurring background jobs hosted services");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously stops all recurring background job hosted services managed by this runner.
|
||||
/// </summary>
|
||||
/// <param name="stoppingToken">A <see cref="CancellationToken"/> that can be used to cancel the stop operation.</param>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous stop operation.</returns>
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Stopping recurring background jobs hosted services");
|
||||
|
||||
foreach (NamedServiceJob namedServiceJob in _hostedServices)
|
||||
foreach (Type jobType in _hostedServices.Keys)
|
||||
{
|
||||
try
|
||||
if (_hostedServices.TryRemove(jobType, out IHostedService? hostedService))
|
||||
{
|
||||
_logger.LogInformation("Stopping background hosted service for {job}", namedServiceJob.Name);
|
||||
await namedServiceJob.HostedService.StopAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "Failed to stop background hosted service for {job}", namedServiceJob.Name);
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Stopping background hosted service for {JobTypeName}", jobType.Name);
|
||||
|
||||
await hostedService.StopAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to stop background hosted service for {JobTypeName}", jobType.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Completed stopping recurring background jobs hosted services");
|
||||
}
|
||||
|
||||
private sealed class NamedServiceJob
|
||||
/// <summary>
|
||||
/// Signals the background loop for the specified job type to execute immediately, with the specified strategy for determining the next execution after the triggered one completes.
|
||||
/// </summary>
|
||||
/// <typeparam name="TJob">The type of the recurring background job to trigger.</typeparam>
|
||||
/// <param name="strategy">Controls the delay after the triggered execution.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the job was found and triggered; <c>false</c> if no hosted service is running for this job type.
|
||||
/// </returns>
|
||||
internal bool TriggerExecution<TJob>(NextExecutionStrategy strategy)
|
||||
where TJob : ITriggerableRecurringBackgroundJob
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="NamedServiceJob"/> class using the specified job name and hosted service instance.
|
||||
/// </summary>
|
||||
/// <param name="name">The unique name identifying the job.</param>
|
||||
/// <param name="hostedService">The <see cref="IHostedService"/> instance to be executed as the background job.</param>
|
||||
public NamedServiceJob(string name, IHostedService hostedService)
|
||||
if (FindHostedService<TJob>() is not { } hostedService)
|
||||
{
|
||||
Name = name;
|
||||
HostedService = hostedService;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique name that identifies this background job.
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
hostedService.TriggerExecution(strategy);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the hosted service instance associated with the named service job.
|
||||
/// </summary>
|
||||
public IHostedService HostedService { get; }
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signals the background loop for the specified job type to execute immediately.
|
||||
/// After the triggered execution, the next execution is scheduled after the specified delay (measured from execution start; execution time is subtracted to prevent drift).
|
||||
/// </summary>
|
||||
/// <typeparam name="TJob">The type of the recurring background job to trigger.</typeparam>
|
||||
/// <param name="nextDelay">The target interval from execution start to the next execution. Execution time is subtracted to prevent drift.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the job was found and triggered; <c>false</c> if no hosted service is running for this job type.
|
||||
/// </returns>
|
||||
internal bool TriggerExecution<TJob>(TimeSpan nextDelay)
|
||||
where TJob : ITriggerableRecurringBackgroundJob
|
||||
{
|
||||
if (FindHostedService<TJob>() is not { } hostedService)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
hostedService.TriggerExecution(nextDelay);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private RecurringHostedServiceBase? FindHostedService<TJob>()
|
||||
where TJob : ITriggerableRecurringBackgroundJob
|
||||
=> _hostedServices.TryGetValue(typeof(TJob), out IHostedService? service) ? service as RecurringHostedServiceBase : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
using Umbraco.Cms.Infrastructure.HostedServices;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IRecurringBackgroundJobTrigger{TJob}" /> that delegates to the hosted service runner.
|
||||
/// </summary>
|
||||
/// <typeparam name="TJob">The type of the recurring background job to trigger.</typeparam>
|
||||
internal sealed class RecurringBackgroundJobTrigger<TJob> : IRecurringBackgroundJobTrigger<TJob>
|
||||
where TJob : class, ITriggerableRecurringBackgroundJob
|
||||
{
|
||||
private readonly RecurringBackgroundJobHostedServiceRunner _runner;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RecurringBackgroundJobTrigger{TJob}" /> class.
|
||||
/// </summary>
|
||||
/// <param name="runner">The runner.</param>
|
||||
public RecurringBackgroundJobTrigger(RecurringBackgroundJobHostedServiceRunner runner)
|
||||
=> _runner = runner;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TriggerExecution()
|
||||
=> TriggerExecution(NextExecutionStrategy.None);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TriggerExecution(NextExecutionStrategy strategy)
|
||||
=> _runner.TriggerExecution<TJob>(strategy);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TriggerExecution(TimeSpan nextDelay)
|
||||
=> _runner.TriggerExecution<TJob>(nextDelay);
|
||||
}
|
||||
@@ -384,6 +384,57 @@ using (ICoreScope scope = ScopeProvider.CreateCoreScope())
|
||||
3. **Lazy loading outside scope** - NPoco relationships must load within scope
|
||||
4. **Large migrations** - Split into multiple steps if > 1000 lines
|
||||
5. **Repository logic in services** - Keep repos thin, logic in services
|
||||
6. **Unbatched `WHERE IN` on user-sized collections** - See "Avoiding the SQL Server 2100-parameter limit" below
|
||||
|
||||
### Avoiding the SQL Server 2100-parameter limit
|
||||
|
||||
SQL Server caps a single statement at 2100 parameters. When an `IN` clause is built from a collection sized by user data, that cap can be hit — and the symptom is a runtime `SqlException` (error 8003) on customer installs that nobody hit in dev.
|
||||
|
||||
**The constant and helpers**:
|
||||
- `Constants.Sql.MaxParameterCount = 2000` (in `Umbraco.Core`, `Constants-Sql.cs`) — the ceiling we target (2100 minus headroom for joined predicates already in the SQL).
|
||||
- `IEnumerable<T>.InGroupsOf(groupSize)` (in `Umbraco.Core`, `Extensions/EnumerableExtensions.cs`) — extension method to batch a collection.
|
||||
- `Database.FetchByGroups<TResult, TSource>(source, groupSize, sqlFactory)` (in `Umbraco.Infrastructure`, `Persistence/NPocoDatabaseExtensions.cs`) — NPoco helper that batches a fetch.
|
||||
|
||||
**The safe patterns** (use one of these any time the collection size is user-driven):
|
||||
|
||||
```csharp
|
||||
// Pattern 1: batch a DeleteMany / Execute / Fetch by looping.
|
||||
foreach (IEnumerable<int> group in ids.InGroupsOf(Constants.Sql.MaxParameterCount))
|
||||
{
|
||||
Database.DeleteMany<FooDto>().Where(x => group.Contains(x.Id)).Execute();
|
||||
}
|
||||
|
||||
// Pattern 2: batched fetch with NPoco helper.
|
||||
List<FooDto> dtos = Database.FetchByGroups<FooDto, int>(
|
||||
ids,
|
||||
Constants.Sql.MaxParameterCount,
|
||||
batch => Sql().Select<FooDto>().From<FooDto>().WhereIn<FooDto>(x => x.Id, batch));
|
||||
|
||||
// Pattern 3: reserve headroom for other parameters in the same statement.
|
||||
foreach (IEnumerable<int> group in entityIds.InGroupsOf(Constants.Sql.MaxParameterCount - userGroupIds.Length))
|
||||
{
|
||||
// statement uses entityIds + userGroupIds, so subtract the other predicate's parameter count from the budget
|
||||
}
|
||||
```
|
||||
|
||||
**Decision rule when writing or reviewing a `WHERE IN`-style query**:
|
||||
|
||||
Look at what drives the size of the collection feeding the `IN`. Ask: *could this realistically exceed 2000 on a large install?* Risky drivers — batch any query backed by these:
|
||||
- All content / media / member nodes (or descendants of a deep tree).
|
||||
- A product of two scaling dimensions, e.g. `documents × languages`, `properties × versions`, `relations × endpoints`.
|
||||
- Configuration-tunable batch sizes (`CacheSettings.DocumentSeedBatchSize`, `NuCacheSettings.SqlPageSize`, etc.). The default may be safe but the customer can raise it.
|
||||
- Anything that scans property data, version history, relations, or audit logs across many nodes.
|
||||
|
||||
Safe drivers — don't bother batching:
|
||||
- Languages / content types / member groups / user groups — bounded by install configuration, typically <100.
|
||||
- "Per single content item" collections — properties on one document, versions of one document, tokens for one external login.
|
||||
- IDs supplied directly by a user action through the UI (picker selections, bulk actions on a page of results).
|
||||
|
||||
If you're not sure, batch — the cost is one loop and an `IEnumerable<T>` allocation per batch; the cost of being wrong is a SqlException on a customer's biggest site.
|
||||
|
||||
**For new public APIs** that take an `IEnumerable<int>`/`IEnumerable<Guid>` and feed it into a query, batch internally even if no current caller is large — package authors and future callers will not know about the 2000-limit ceiling.
|
||||
|
||||
**Don't** rely on `if (ids.Length > MaxParameterCount) throw` as a substitute for batching. Throwing only moves the problem; the caller has no obvious way to recover and will most likely just fail in production.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ internal sealed class JsonConfigManipulator : IConfigManipulator
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
[Obsolete("This method is no longer used by Umbraco. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
|
||||
public async Task SaveDisableRedirectUrlTrackingAsync(bool disable)
|
||||
=> await CreateOrUpdateConfigValueAsync(DisableRedirectUrlTrackingPath, disable);
|
||||
|
||||
|
||||
@@ -38,7 +38,9 @@ public static partial class UmbracoBuilderExtensions
|
||||
builder.Services.AddHostedService<DistributedBackgroundJobHostedService>();
|
||||
|
||||
builder.Services.AddSingleton(RecurringBackgroundJobHostedService.CreateHostedServiceFactory);
|
||||
builder.Services.AddHostedService<RecurringBackgroundJobHostedServiceRunner>();
|
||||
builder.Services.AddSingleton<RecurringBackgroundJobHostedServiceRunner>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<RecurringBackgroundJobHostedServiceRunner>());
|
||||
builder.Services.AddSingleton(typeof(IRecurringBackgroundJobTrigger<>), typeof(RecurringBackgroundJobTrigger<>));
|
||||
builder.Services.AddHostedService<QueuedHostedService>();
|
||||
builder.AddNotificationAsyncHandler<PostRuntimePremigrationsUpgradeNotification, NavigationInitializationNotificationHandler>();
|
||||
builder.AddNotificationAsyncHandler<PostRuntimePremigrationsUpgradeNotification, PublishStatusInitializationNotificationHandler>();
|
||||
|
||||
@@ -94,6 +94,7 @@ public static partial class UmbracoBuilderExtensions
|
||||
builder.AddNotificationAsyncHandler<RuntimeUnattendedInstallNotification, UnattendedInstaller>();
|
||||
builder.AddNotificationAsyncHandler<RuntimeUnattendedUpgradeNotification, UnattendedUpgrader>();
|
||||
builder.AddNotificationAsyncHandler<RuntimePremigrationsUpgradeNotification, PremigrationUpgrader>();
|
||||
builder.Services.AddSingleton<IMigrationCoordinator, MigrationCoordinator>();
|
||||
builder.Services.AddHostedService<UnattendedUpgradeBackgroundService>();
|
||||
|
||||
// Database availability check.
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Umbraco.Cms.Core.Composing;
|
||||
using Umbraco.Cms.Infrastructure.BackgroundJobs;
|
||||
|
||||
namespace Umbraco.Extensions;
|
||||
@@ -11,27 +9,22 @@ namespace Umbraco.Extensions;
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a recurring background job with an implementation type of
|
||||
/// <typeparamref name="TJob" /> to the specified <see cref="IServiceCollection" />.
|
||||
/// Adds a recurring background job with an implementation type of <typeparamref name="TJob" />.
|
||||
/// </summary>
|
||||
/// <param name="services">The <see cref="IServiceCollection" /> to add the recurring background job to.</param>
|
||||
public static void AddRecurringBackgroundJob<TJob>(
|
||||
this IServiceCollection services)
|
||||
where TJob : class, IRecurringBackgroundJob =>
|
||||
services.AddSingleton<IRecurringBackgroundJob, TJob>();
|
||||
where TJob : class, IRecurringBackgroundJob
|
||||
=> services.AddSingleton<IRecurringBackgroundJob, TJob>();
|
||||
|
||||
/// <summary>
|
||||
/// Adds a recurring background job with an implementation type of
|
||||
/// <typeparamref name="TJob" /> using the factory <paramref name="implementationFactory"/>
|
||||
/// to the specified <see cref="IServiceCollection" />.
|
||||
/// Adds a recurring background job with an implementation type of <typeparamref name="TJob" /> using the factory <paramref name="implementationFactory" />.
|
||||
/// </summary>
|
||||
/// <param name="services">The <see cref="IServiceCollection" /> to add the recurring background job to.</param>
|
||||
/// <param name="implementationFactory">A factory function to create an instance of <typeparamref name="TJob" /> using the provided <see cref="IServiceProvider" />.</param>
|
||||
public static void AddRecurringBackgroundJob<TJob>(
|
||||
this IServiceCollection services,
|
||||
Func<IServiceProvider, TJob> implementationFactory)
|
||||
where TJob : class, IRecurringBackgroundJob =>
|
||||
services.AddSingleton<IRecurringBackgroundJob, TJob>(implementationFactory);
|
||||
|
||||
where TJob : class, IRecurringBackgroundJob
|
||||
=> services.AddSingleton<IRecurringBackgroundJob, TJob>(implementationFactory);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.HostedServices;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for rotating <see cref="CancellationTokenSource" /> instances used to interrupt cooperative waits.
|
||||
/// </summary>
|
||||
internal static class CancellationTokenSourceRotation
|
||||
{
|
||||
/// <summary>
|
||||
/// Atomically installs a fresh <see cref="CancellationTokenSource" /> at <paramref name="field" /> and cancels the previous one without disposing it.
|
||||
/// If the previous CTS has already been disposed (lost the shutdown race), the newly installed CTS is also disposed since no waiter will observe it.
|
||||
/// </summary>
|
||||
/// <param name="field">A reference to the field holding the active CTS.</param>
|
||||
/// <remarks>
|
||||
/// The previous CTS is not disposed because the wait loop may still be registering against its token. Once cancelled it is small and will be collected by the GC.
|
||||
/// </remarks>
|
||||
public static void RotateAndCancel(ref CancellationTokenSource field)
|
||||
{
|
||||
var newCts = new CancellationTokenSource();
|
||||
CancellationTokenSource oldCts = Interlocked.Exchange(ref field, newCts);
|
||||
|
||||
try
|
||||
{
|
||||
oldCts.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
newCts.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.HostedServices;
|
||||
|
||||
/// <summary>
|
||||
/// Determines the next execution strategy after a manually triggered execution completes.
|
||||
/// </summary>
|
||||
public enum NextExecutionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Keep the current scheduled run unchanged.
|
||||
/// The next execution occurs at the originally-scheduled time.
|
||||
/// If that time has already passed (e.g. the triggered execution took longer than the remaining wait), it is skipped and the next period tick is awaited instead.
|
||||
/// </summary>
|
||||
None,
|
||||
|
||||
/// <summary>
|
||||
/// Reset the period: wait a full period after the triggered execution completes.
|
||||
/// The triggered execution effectively shifts the schedule forward.
|
||||
/// </summary>
|
||||
Reset,
|
||||
|
||||
/// <summary>
|
||||
/// The triggered execution replaces the next scheduled run.
|
||||
/// The following execution occurs one full period after the originally-scheduled time.
|
||||
/// Use this when the manual trigger is an early execution of the next scheduled run.
|
||||
/// </summary>
|
||||
Replace,
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core;
|
||||
@@ -10,168 +9,268 @@ using Umbraco.Cms.Core.Configuration;
|
||||
namespace Umbraco.Cms.Infrastructure.HostedServices;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a base class for recurring background tasks implemented as hosted services.
|
||||
/// Provides a base class for recurring background tasks implemented as hosted services.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// See: <see href="https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-3.1&tabs=visual-studio#timed-background-tasks"/>.
|
||||
/// </remarks>
|
||||
public abstract class RecurringHostedServiceBase : IHostedService, IDisposable
|
||||
public abstract class RecurringHostedServiceBase : BackgroundService
|
||||
{
|
||||
/// <summary>
|
||||
/// The default delay to use for recurring tasks for the first run after application start-up if no alternative is
|
||||
/// configured.
|
||||
/// The default delay to use for recurring tasks for the first run after application start-up if no alternative is configured.
|
||||
/// </summary>
|
||||
protected static readonly TimeSpan DefaultDelay = TimeSpan.FromMinutes(3);
|
||||
|
||||
private readonly TimeSpan _delay;
|
||||
|
||||
private readonly ILogger? _logger;
|
||||
private bool _disposedValue;
|
||||
private TimeSpan _period;
|
||||
private Timer? _timer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly SemaphoreSlim _signal = new(0, 1);
|
||||
private CancellationTokenSource _periodChangeCts = new();
|
||||
private long _periodTicks;
|
||||
private TriggerState _triggerState = TriggerState.Default;
|
||||
private volatile bool _nextExecutionSkipOnOvershoot;
|
||||
private int _isDisposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RecurringHostedServiceBase" /> class.
|
||||
/// Initializes a new instance of the <see cref="RecurringHostedServiceBase" /> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger.</param>
|
||||
/// <param name="period">Timespan representing how often the task should recur. Set to <see cref="Timeout.InfiniteTimeSpan" /> to disable automatic scheduling and only run when manually triggered via <see cref="TriggerExecution()" />.</param>
|
||||
/// <param name="delay">Timespan representing the initial delay after application start-up before the first run of the task occurs. Set to <see cref="Timeout.InfiniteTimeSpan" /> to skip the automatic first run; the first execution then only occurs when manually triggered via <see cref="TriggerExecution()" />.</param>
|
||||
/// <param name="timeProvider">The time provider used for scheduling and elapsed time measurement.</param>
|
||||
protected RecurringHostedServiceBase(ILogger? logger, TimeSpan period, TimeSpan delay, TimeProvider timeProvider)
|
||||
{
|
||||
if (period != Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(period, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
if (delay != Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(delay, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
_logger = logger;
|
||||
Interlocked.Exchange(ref _periodTicks, period.Ticks);
|
||||
_delay = delay;
|
||||
_timeProvider = timeProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RecurringHostedServiceBase" /> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger.</param>
|
||||
/// <param name="period">Timespan representing how often the task should recur.</param>
|
||||
/// <param name="delay">
|
||||
/// Timespan representing the initial delay after application start-up before the first run of the task
|
||||
/// occurs.
|
||||
/// </param>
|
||||
/// <param name="delay">Timespan representing the initial delay after application start-up before the first run of the task occurs.</param>
|
||||
[Obsolete("Use the constructor accepting TimeProvider. Scheduled for removal in Umbraco 19.")]
|
||||
protected RecurringHostedServiceBase(ILogger? logger, TimeSpan period, TimeSpan delay)
|
||||
{
|
||||
_logger = logger;
|
||||
_period = period;
|
||||
_delay = delay;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
: this(logger, period, delay, TimeProvider.System)
|
||||
{ }
|
||||
|
||||
/// <summary>
|
||||
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optonal
|
||||
/// configuration for the first run time is available.
|
||||
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optional configuration for the first run time is available.
|
||||
/// </summary>
|
||||
/// <param name="firstRunTime">The configured time to first run the task in crontab format.</param>
|
||||
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser"/></param>
|
||||
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser" />.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="defaultDelay">The default delay to use when a first run time is not configured.</param>
|
||||
/// <returns>The delay before first running the recurring task.</returns>
|
||||
protected static TimeSpan GetDelay(
|
||||
string firstRunTime,
|
||||
ICronTabParser cronTabParser,
|
||||
ILogger logger,
|
||||
TimeSpan defaultDelay) => GetDelay(firstRunTime, cronTabParser, logger, DateTime.Now, defaultDelay);
|
||||
/// <returns>
|
||||
/// The delay before first running the recurring task.
|
||||
/// </returns>
|
||||
[Obsolete("Use DelayCalculator.GetDelay instead. Scheduled for removal in Umbraco 19.")]
|
||||
protected static TimeSpan GetDelay(string firstRunTime, ICronTabParser cronTabParser, ILogger logger, TimeSpan defaultDelay)
|
||||
=> BackgroundJobs.DelayCalculator.GetDelay(firstRunTime, cronTabParser, logger, defaultDelay);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Initial delay (also interruptible via signal)
|
||||
bool signaled = false;
|
||||
if (_delay != TimeSpan.Zero)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Do not cancel/signal the wait when the period changes during the initial delay
|
||||
signaled = await WaitForSignalAsync(_delay, CancellationToken.None, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Honor a TriggerExecution(TimeSpan) issued during the initial delay on the first wait cycle.
|
||||
// Strategy-only triggers (None/Reset/Replace) have no custom delay and collapse to the normal Period —
|
||||
// there is no "next scheduled tick" yet for Replace to skip, and None/Reset reduce to "use Period" in this phase.
|
||||
TimeSpan nextDelayBasis = ReadPeriod();
|
||||
if (signaled)
|
||||
{
|
||||
TriggerState initialTrigger = Interlocked.Exchange(ref _triggerState, TriggerState.Default);
|
||||
if (initialTrigger.Delay.HasValue)
|
||||
{
|
||||
nextDelayBasis = initialTrigger.Delay.Value;
|
||||
}
|
||||
}
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
long startTimestamp = _timeProvider.GetTimestamp();
|
||||
try
|
||||
{
|
||||
await PerformExecuteAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ILogger logger = _logger ?? StaticApplicationLogging.CreateLogger(GetType());
|
||||
logger.LogError(ex, "Unhandled exception in recurring hosted service.");
|
||||
}
|
||||
|
||||
TimeSpan executionElapsed = _timeProvider.GetElapsedTime(startTimestamp);
|
||||
nextDelayBasis = await WaitForNextExecutionAsync(nextDelayBasis, executionElapsed, stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the delay before the first run of a recurring task implemented as a hosted service when an optonal
|
||||
/// configuration for the first run time is available.
|
||||
/// Waits for the remaining period (minus execution time) before the next execution.
|
||||
/// If <see cref="TriggerExecution()" /> is called, the wait exits immediately and returns the delay basis for the execution after the triggered one.
|
||||
/// </summary>
|
||||
/// <param name="firstRunTime">The configured time to first run the task in crontab format.</param>
|
||||
/// <param name="cronTabParser">An instance of <see cref="ICronTabParser"/></param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="now">The current datetime.</param>
|
||||
/// <param name="defaultDelay">The default delay to use when a first run time is not configured.</param>
|
||||
/// <returns>The delay before first running the recurring task.</returns>
|
||||
/// <remarks>Internal to expose for unit tests.</remarks>
|
||||
internal static TimeSpan GetDelay(
|
||||
string firstRunTime,
|
||||
ICronTabParser cronTabParser,
|
||||
ILogger logger,
|
||||
DateTime now,
|
||||
TimeSpan defaultDelay)
|
||||
/// <param name="delayBasis">The delay basis.</param>
|
||||
/// <param name="executionElapsed">The execution elapsed.</param>
|
||||
/// <param name="stoppingToken">The stopping token.</param>
|
||||
/// <returns>
|
||||
/// The delay basis to use for the next wait cycle.
|
||||
/// </returns>
|
||||
private async Task<TimeSpan> WaitForNextExecutionAsync(TimeSpan delayBasis, TimeSpan executionElapsed, CancellationToken stoppingToken)
|
||||
{
|
||||
// If first run time not set, start with just small delay after application start.
|
||||
if (string.IsNullOrEmpty(firstRunTime))
|
||||
TimeSpan period = ReadPeriod();
|
||||
TimeSpan delay = ComputeNextDelay(delayBasis, executionElapsed);
|
||||
|
||||
// If the delay basis was from a NextExecutionStrategy.None trigger and the execution overshot the scheduled time,
|
||||
// advance to the next period tick instead of executing immediately.
|
||||
// The flag is consumed unconditionally so it never leaks into later cycles.
|
||||
bool skipOnOvershoot = _nextExecutionSkipOnOvershoot;
|
||||
_nextExecutionSkipOnOvershoot = false;
|
||||
|
||||
if (delay == TimeSpan.Zero && skipOnOvershoot)
|
||||
{
|
||||
return defaultDelay;
|
||||
delay = ComputeNextDelay(delayBasis + period, executionElapsed);
|
||||
}
|
||||
|
||||
// If first run time not a valid cron tab, log, and revert to small delay after application start.
|
||||
if (!cronTabParser.IsValidCronTab(firstRunTime))
|
||||
if (delay == TimeSpan.Zero)
|
||||
{
|
||||
logger.LogWarning("Could not parse {FirstRunTime} as a crontab expression. Defaulting to default delay for hosted service start.", firstRunTime);
|
||||
return defaultDelay;
|
||||
return period;
|
||||
}
|
||||
|
||||
// Otherwise start at scheduled time according to cron expression, unless within the default delay period.
|
||||
DateTime firstRunOccurance = cronTabParser.GetNextOccurrence(firstRunTime, now);
|
||||
TimeSpan delay = firstRunOccurance - now;
|
||||
return delay < defaultDelay
|
||||
? defaultDelay
|
||||
: delay;
|
||||
}
|
||||
long waitStart = _timeProvider.GetTimestamp();
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using (!ExecutionContext.IsFlowSuppressed() ? (IDisposable)ExecutionContext.SuppressFlow() : null)
|
||||
while (true)
|
||||
{
|
||||
_timer = new Timer(ExecuteAsync, null, _delay, _period);
|
||||
CancellationToken periodChangeToken = _periodChangeCts.Token;
|
||||
bool signaled;
|
||||
try
|
||||
{
|
||||
signaled = await WaitForSignalAsync(delay, periodChangeToken, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
return ReadPeriod();
|
||||
}
|
||||
|
||||
if (signaled is false && periodChangeToken.IsCancellationRequested)
|
||||
{
|
||||
// Period changed — re-read and recalculate remaining delay with the new period.
|
||||
period = ReadPeriod();
|
||||
TimeSpan totalElapsed = executionElapsed + _timeProvider.GetElapsedTime(waitStart);
|
||||
delay = ComputeNextDelay(period, totalElapsed);
|
||||
if (delay == TimeSpan.Zero)
|
||||
{
|
||||
return period;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (signaled is false)
|
||||
{
|
||||
return period; // Normal timeout — next wait uses normal period.
|
||||
}
|
||||
|
||||
return ComputeNextDelayFromTriggerState(delay, waitStart, period);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_period = Timeout.InfiniteTimeSpan;
|
||||
_timer?.Change(Timeout.Infinite, 0);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the task.
|
||||
/// Computes the next wait cycle's delay basis from the pending <see cref="TriggerState" />, consuming it in the process.
|
||||
/// </summary>
|
||||
/// <param name="delay">The delay that was being waited on when the trigger arrived.</param>
|
||||
/// <param name="waitStart">The timestamp at which the wait started, used to measure how much of <paramref name="delay" /> remains.</param>
|
||||
/// <param name="period">The current period, used by the <see cref="NextExecutionStrategy.Reset" /> and <see cref="NextExecutionStrategy.Replace" /> strategies.</param>
|
||||
/// <returns>
|
||||
/// The delay basis for the next wait cycle.
|
||||
/// </returns>
|
||||
private TimeSpan ComputeNextDelayFromTriggerState(TimeSpan delay, long waitStart, TimeSpan period)
|
||||
{
|
||||
TriggerState triggerState = Interlocked.Exchange(ref _triggerState, TriggerState.Default);
|
||||
if (triggerState.Delay.HasValue)
|
||||
{
|
||||
return triggerState.Delay.Value;
|
||||
}
|
||||
|
||||
TimeSpan waitElapsed = _timeProvider.GetElapsedTime(waitStart);
|
||||
TimeSpan remaining = ComputeNextDelay(delay, waitElapsed);
|
||||
|
||||
switch (triggerState.Strategy)
|
||||
{
|
||||
case NextExecutionStrategy.None:
|
||||
_nextExecutionSkipOnOvershoot = true;
|
||||
return remaining;
|
||||
case NextExecutionStrategy.Replace:
|
||||
return remaining == Timeout.InfiniteTimeSpan || period == Timeout.InfiniteTimeSpan
|
||||
? Timeout.InfiniteTimeSpan
|
||||
: remaining + period;
|
||||
case NextExecutionStrategy.Reset:
|
||||
default:
|
||||
return period;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implements the work of the recurring task.
|
||||
/// </summary>
|
||||
/// <param name="stoppingToken">A cancellation token that is signaled when the host is shutting down.</param>
|
||||
/// <returns>
|
||||
/// A task representing the asynchronous operation.
|
||||
/// </returns>
|
||||
public virtual Task PerformExecuteAsync(CancellationToken stoppingToken)
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
=> PerformExecuteAsync(null);
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
|
||||
/// <summary>
|
||||
/// Implements the work of the recurring task.
|
||||
/// </summary>
|
||||
/// <param name="state">The task state.</param>
|
||||
public virtual async void ExecuteAsync(object? state)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
// First, stop the timer, we do not want tasks to execute in parallel
|
||||
_timer?.Change(Timeout.Infinite, 0);
|
||||
|
||||
// Delegate work to method returning a task, that can be called and asserted in a unit test.
|
||||
// Without this there can be behaviour where tests pass, but an error within them causes the test
|
||||
// running process to crash.
|
||||
// Hat-tip: https://stackoverflow.com/a/14207615/489433
|
||||
await PerformExecuteAsync(state);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ILogger logger = _logger ?? StaticApplicationLogging.CreateLogger(GetType());
|
||||
logger.LogError(ex, "Unhandled exception in recurring hosted service.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
|
||||
// If the service has been stopped, _period is set to InfiniteTimeSpan in StopAsync.
|
||||
// Preserve it to keep the timer disabled.
|
||||
TimeSpan remaining = _period == Timeout.InfiniteTimeSpan
|
||||
? Timeout.InfiniteTimeSpan
|
||||
: ComputeNextDelay(_period, sw.Elapsed);
|
||||
_timer?.Change(remaining, _period);
|
||||
}
|
||||
}
|
||||
/// <returns>
|
||||
/// A task representing the asynchronous operation.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// This overload does not receive a <see cref="CancellationToken" />, so shutdown cancellation is not propagated to the implementation.
|
||||
/// </remarks>
|
||||
[Obsolete("Override PerformExecuteAsync(CancellationToken) instead. Scheduled for removal in Umbraco 19.")]
|
||||
public virtual Task PerformExecuteAsync(object? state)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Executes the core logic of the recurring hosted service asynchronously.
|
||||
/// Executes the task.
|
||||
/// </summary>
|
||||
/// <param name="state">An optional object containing state information for the execution.</param>
|
||||
/// <returns>A <see cref="Task"/> that represents the asynchronous execution of the recurring task.</returns>
|
||||
public abstract Task PerformExecuteAsync(object? state);
|
||||
/// <param name="state">The task state.</param>
|
||||
[Obsolete("No longer used. The base class now uses BackgroundService.ExecuteAsync(CancellationToken). Scheduled for removal in Umbraco 19.")]
|
||||
public virtual void ExecuteAsync(object? state)
|
||||
{ }
|
||||
|
||||
/// <summary>
|
||||
/// Computes the delay before the next execution, subtracting the elapsed execution time from the period to prevent drift.
|
||||
/// Clamps to <see cref="TimeSpan.Zero" /> if execution exceeded the period.
|
||||
/// </summary>
|
||||
/// <param name="period">The configured period between executions.</param>
|
||||
/// <param name="elapsed">The elapsed time of the current execution.</param>
|
||||
@@ -183,30 +282,155 @@ public abstract class RecurringHostedServiceBase : IHostedService, IDisposable
|
||||
/// </remarks>
|
||||
internal static TimeSpan ComputeNextDelay(TimeSpan period, TimeSpan elapsed)
|
||||
{
|
||||
if (period == Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
return Timeout.InfiniteTimeSpan;
|
||||
}
|
||||
|
||||
TimeSpan remaining = period - elapsed;
|
||||
|
||||
// A negative period (e.g. Timeout.InfiniteTimeSpan = -1ms, set by StopAsync) will always produce a
|
||||
// negative remaining value. The caller in ExecuteAsync guards against this by checking for InfiniteTimeSpan
|
||||
// before calling this method, to avoid scheduling an extra execution after stop.
|
||||
return remaining < TimeSpan.Zero ? TimeSpan.Zero : remaining;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Change the period between operations.
|
||||
/// Change the period between operations. The new period takes effect immediately, interrupting the current wait if necessary.
|
||||
/// </summary>
|
||||
/// <param name="newPeriod">The new period between tasks</param>
|
||||
protected void ChangePeriod(TimeSpan newPeriod) => _period = newPeriod;
|
||||
|
||||
protected virtual void Dispose(bool disposing)
|
||||
/// <param name="newPeriod">The new period between tasks. Set to <see cref="Timeout.InfiniteTimeSpan" /> to (temporarily) disable automatic scheduling and turn the loop into a manually triggered one; change back to a finite period to resume scheduling.</param>
|
||||
protected void ChangePeriod(TimeSpan newPeriod)
|
||||
{
|
||||
if (!_disposedValue)
|
||||
if (newPeriod != Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_timer?.Dispose();
|
||||
}
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(newPeriod, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
_disposedValue = true;
|
||||
Interlocked.Exchange(ref _periodTicks, newPeriod.Ticks);
|
||||
|
||||
CancellationTokenSourceRotation.RotateAndCancel(ref _periodChangeCts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signals the background loop to execute immediately.
|
||||
/// After the triggered execution, the original schedule is kept.
|
||||
/// If the scheduled time has already passed during the triggered execution, it is skipped and the next period tick is awaited.
|
||||
/// </summary>
|
||||
/// <seealso cref="NextExecutionStrategy.None" />
|
||||
protected internal void TriggerExecution()
|
||||
=> TriggerExecution(NextExecutionStrategy.None);
|
||||
|
||||
/// <summary>
|
||||
/// Signals the background loop to execute immediately, with the specified strategy for determining the next execution after the triggered one completes.
|
||||
/// </summary>
|
||||
/// <param name="strategy">Controls the delay after the triggered execution.</param>
|
||||
protected internal void TriggerExecution(NextExecutionStrategy strategy)
|
||||
{
|
||||
Interlocked.Exchange(ref _triggerState, new TriggerState(Strategy: strategy));
|
||||
ReleaseSignal();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signals the background loop to execute immediately.
|
||||
/// After the triggered execution, the next execution is scheduled after the specified delay (measured from execution start; execution time is subtracted to prevent drift).
|
||||
/// </summary>
|
||||
/// <param name="nextDelay">The target interval from execution start to the next execution. Execution time is subtracted to prevent drift. Set to <see cref="Timeout.InfiniteTimeSpan" /> to leave the loop in manually triggered mode after this execution.</param>
|
||||
protected internal void TriggerExecution(TimeSpan nextDelay)
|
||||
{
|
||||
if (nextDelay != Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(nextDelay, TimeSpan.Zero);
|
||||
}
|
||||
|
||||
Interlocked.Exchange(ref _triggerState, new TriggerState(Delay: nextDelay));
|
||||
ReleaseSignal();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the current period in a thread-safe manner.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// The current period between executions.
|
||||
/// </returns>
|
||||
private TimeSpan ReadPeriod()
|
||||
=> TimeSpan.FromTicks(Interlocked.Read(ref _periodTicks));
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the semaphore to be signaled or for the timeout to expire, using the injected <see cref="TimeProvider" />.
|
||||
/// </summary>
|
||||
/// <param name="timeout">The maximum time to wait.</param>
|
||||
/// <param name="periodChangeToken">A cancellation token that is signaled when the period changes.</param>
|
||||
/// <param name="stoppingToken">A cancellation token for shutdown.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the semaphore was signaled; <c>false</c> if the timeout expired or the period changed.
|
||||
/// </returns>
|
||||
/// <exception cref="OperationCanceledException">Thrown when <paramref name="stoppingToken" /> is cancelled.</exception>
|
||||
private async Task<bool> WaitForSignalAsync(TimeSpan timeout, CancellationToken periodChangeToken, CancellationToken stoppingToken)
|
||||
{
|
||||
using var timeoutCts = new CancellationTokenSource(timeout, _timeProvider);
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, periodChangeToken, stoppingToken);
|
||||
|
||||
try
|
||||
{
|
||||
await _signal.WaitAsync(linkedCts.Token);
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
return false; // Timeout expired or period changed
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the semaphore to wake the background loop. If the semaphore is already signaled, the call is a no-op.
|
||||
/// </summary>
|
||||
private void ReleaseSignal()
|
||||
{
|
||||
try
|
||||
{
|
||||
_signal.Release();
|
||||
}
|
||||
catch (SemaphoreFullException)
|
||||
{
|
||||
// Already signaled
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public sealed override void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases unmanaged and optionally managed resources.
|
||||
/// </summary>
|
||||
/// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
|
||||
protected virtual void Dispose(bool disposing)
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _isDisposed, 1, 0) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
_signal.Dispose();
|
||||
_periodChangeCts.Dispose();
|
||||
}
|
||||
|
||||
base.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable snapshot of the trigger state.
|
||||
/// </summary>
|
||||
private sealed record TriggerState(NextExecutionStrategy Strategy = default, TimeSpan? Delay = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the default trigger state with no strategy and no custom delay.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// The default trigger state.
|
||||
/// </value>
|
||||
public static TriggerState Default { get; } = new();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Umbraco.Cms.Infrastructure.Install;
|
||||
|
||||
/// <summary>
|
||||
/// Coordinates migration leadership across servers in a load-balanced environment.
|
||||
/// </summary>
|
||||
internal interface IMigrationCoordinator
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to become the migration leader, blocking until either this server wins the claim
|
||||
/// or another server completes all migrations.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token that cancels the leadership wait loop.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> if this server is the migration leader and must call <see cref="ReleaseLeadership"/> after
|
||||
/// running migrations; <c>false</c> if another server completed migrations and this server should skip them.
|
||||
/// </returns>
|
||||
Task<bool> TryBecomeLeaderAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Releases the migration leadership claim if it is still held by this instance.
|
||||
/// Must be called in a <c>finally</c> block to ensure release even on failure.
|
||||
/// </summary>
|
||||
void ReleaseLeadership();
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Factories;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.Install;
|
||||
|
||||
/// <summary>
|
||||
/// Coordinates migration leadership across servers in a load-balanced environment.
|
||||
/// Exactly one server claims leadership, runs all migrations, then releases the claim.
|
||||
/// All other servers wait until the leader finishes, then proceed with per-server initialization.
|
||||
/// </summary>
|
||||
internal sealed class MigrationCoordinator : IMigrationCoordinator
|
||||
{
|
||||
private readonly ICoreScopeProvider _scopeProvider;
|
||||
private readonly IKeyValueService _keyValueService;
|
||||
private readonly IRuntimeState _runtimeState;
|
||||
private readonly IMachineInfoFactory _machineInfoFactory;
|
||||
private readonly IOptions<UnattendedSettings> _unattendedSettings;
|
||||
private readonly ILogger<MigrationCoordinator> _logger;
|
||||
private string? _leaderClaim;
|
||||
|
||||
public MigrationCoordinator(
|
||||
ICoreScopeProvider scopeProvider,
|
||||
IKeyValueService keyValueService,
|
||||
IRuntimeState runtimeState,
|
||||
IMachineInfoFactory machineInfoFactory,
|
||||
IOptions<UnattendedSettings> unattendedSettings,
|
||||
ILogger<MigrationCoordinator> logger)
|
||||
{
|
||||
_scopeProvider = scopeProvider;
|
||||
_keyValueService = keyValueService;
|
||||
_runtimeState = runtimeState;
|
||||
_machineInfoFactory = machineInfoFactory;
|
||||
_unattendedSettings = unattendedSettings;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<bool> TryBecomeLeaderAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var machineIdentifier = _machineInfoFactory.GetMachineIdentifier();
|
||||
|
||||
while (cancellationToken.IsCancellationRequested is false)
|
||||
{
|
||||
if (TryClaimLeadership(machineIdentifier))
|
||||
{
|
||||
// Re-check after claiming: the previous leader may have finished between our last
|
||||
// DetermineRuntimeLevel call and our successful claim of the now-empty lock.
|
||||
try
|
||||
{
|
||||
_runtimeState.DetermineRuntimeLevel();
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not re-determine runtime level after claiming leadership; proceeding as leader.");
|
||||
}
|
||||
|
||||
if (_runtimeState.Level == RuntimeLevel.Run)
|
||||
{
|
||||
ReleaseLeadership();
|
||||
_logger.LogInformation("Migrations completed by another server; proceeding as follower.");
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogInformation("This server claimed migration leadership.");
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_runtimeState.DetermineRuntimeLevel();
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not determine runtime level during migration wait; will retry.");
|
||||
}
|
||||
|
||||
switch (_runtimeState.Level)
|
||||
{
|
||||
case RuntimeLevel.Run:
|
||||
_logger.LogInformation("Migrations completed by another server; proceeding as follower.");
|
||||
return false;
|
||||
case RuntimeLevel.BootFailed:
|
||||
_logger.LogError("Runtime entered BootFailed state while waiting for migrations.");
|
||||
return false;
|
||||
default:
|
||||
_logger.LogDebug("Waiting for migration leader to finish...");
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void ReleaseLeadership()
|
||||
{
|
||||
if (_leaderClaim is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using ICoreScope scope = _scopeProvider.CreateCoreScope();
|
||||
scope.WriteLock(Constants.Locks.KeyValues);
|
||||
|
||||
string? current = _keyValueService.GetValue(Constants.Conventions.Migrations.UpgradeLockKey);
|
||||
if (current == _leaderClaim)
|
||||
{
|
||||
_keyValueService.SetValue(Constants.Conventions.Migrations.UpgradeLockKey, string.Empty);
|
||||
}
|
||||
|
||||
scope.Complete();
|
||||
_leaderClaim = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to release migration leadership; continuing shutdown because leadership release is best-effort.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsStale(string claim, TimeSpan timeout)
|
||||
{
|
||||
var separatorIndex = claim.IndexOf('|');
|
||||
return separatorIndex < 0
|
||||
|| !DateTimeOffset.TryParse(claim.AsSpan(separatorIndex + 1), out DateTimeOffset timestamp)
|
||||
|| DateTimeOffset.UtcNow - timestamp > timeout;
|
||||
}
|
||||
|
||||
// Acquires WriteLock(KeyValues) so the read-then-write is serialized across all servers.
|
||||
// Inner GetValue and SetValue calls create nested scopes that join the outer transaction;
|
||||
// their internal WriteLock requests are no-ops because the lock is already held.
|
||||
private bool TryClaimLeadership(string machineIdentifier)
|
||||
{
|
||||
TimeSpan timeout = _unattendedSettings.Value.MigrationClaimTimeout;
|
||||
|
||||
using ICoreScope scope = _scopeProvider.CreateCoreScope();
|
||||
scope.WriteLock(Constants.Locks.KeyValues);
|
||||
|
||||
string? current = _keyValueService.GetValue(Constants.Conventions.Migrations.UpgradeLockKey);
|
||||
|
||||
bool canClaim = string.IsNullOrEmpty(current)
|
||||
|| IsStale(current, timeout)
|
||||
|| current.StartsWith(machineIdentifier + "|", StringComparison.Ordinal);
|
||||
|
||||
if (canClaim)
|
||||
{
|
||||
_leaderClaim = $"{machineIdentifier}|{DateTimeOffset.UtcNow:O}";
|
||||
_keyValueService.SetValue(Constants.Conventions.Migrations.UpgradeLockKey, _leaderClaim);
|
||||
}
|
||||
|
||||
scope.Complete();
|
||||
return canClaim;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ internal sealed class UnattendedUpgradeBackgroundService : BackgroundService
|
||||
private readonly IEventAggregator _eventAggregator;
|
||||
private readonly ComponentCollection _components;
|
||||
private readonly IHostApplicationLifetime _hostApplicationLifetime;
|
||||
private readonly IMigrationCoordinator _coordinator;
|
||||
private readonly ILogger<UnattendedUpgradeBackgroundService> _logger;
|
||||
|
||||
/// <summary>
|
||||
@@ -33,18 +34,21 @@ internal sealed class UnattendedUpgradeBackgroundService : BackgroundService
|
||||
/// <param name="eventAggregator">The event aggregator used to publish upgrade notifications.</param>
|
||||
/// <param name="components">The component collection to initialize after migration completes.</param>
|
||||
/// <param name="hostApplicationLifetime">The host application lifetime for registering started/stopped callbacks.</param>
|
||||
/// <param name="coordinator">Coordinates migration leadership across servers in a load-balanced environment.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public UnattendedUpgradeBackgroundService(
|
||||
IRuntimeState runtimeState,
|
||||
IEventAggregator eventAggregator,
|
||||
ComponentCollection components,
|
||||
IHostApplicationLifetime hostApplicationLifetime,
|
||||
IMigrationCoordinator coordinator,
|
||||
ILogger<UnattendedUpgradeBackgroundService> logger)
|
||||
{
|
||||
_runtimeState = runtimeState;
|
||||
_eventAggregator = eventAggregator;
|
||||
_components = components;
|
||||
_hostApplicationLifetime = hostApplicationLifetime;
|
||||
_coordinator = coordinator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -59,9 +63,30 @@ internal sealed class UnattendedUpgradeBackgroundService : BackgroundService
|
||||
|
||||
_logger.LogInformation("Unattended upgrade background service started.");
|
||||
|
||||
bool isLeader = false;
|
||||
|
||||
try
|
||||
{
|
||||
await RunMigrationsAsync(stoppingToken);
|
||||
isLeader = await _coordinator.TryBecomeLeaderAsync(stoppingToken);
|
||||
|
||||
if (_runtimeState.Level == RuntimeLevel.BootFailed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLeader)
|
||||
{
|
||||
// Belt-and-suspenders for graceful shutdowns (e.g. Azure SIGTERM): release the claim
|
||||
// as soon as the host begins stopping, even if a migration step is still blocking.
|
||||
_hostApplicationLifetime.ApplicationStopping.Register(() => _coordinator.ReleaseLeadership());
|
||||
await RunMigrationsAsync(stoppingToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Follower: rebuild per-server in-memory navigation and publish status
|
||||
// from the fully-migrated database.
|
||||
await _eventAggregator.PublishAsync(new PostRuntimePremigrationsUpgradeNotification(), stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -69,14 +94,20 @@ internal sealed class UnattendedUpgradeBackgroundService : BackgroundService
|
||||
_runtimeState.Configure(RuntimeLevel.BootFailed, RuntimeLevelReason.BootFailedOnException, ex);
|
||||
return;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Always release the claim — even on leader failure — so other servers
|
||||
// can detect completion or take over.
|
||||
if (isLeader)
|
||||
{
|
||||
_coordinator.ReleaseLeadership();
|
||||
}
|
||||
}
|
||||
|
||||
// Re-evaluate runtime level after migrations complete. This handles all result cases:
|
||||
// - CoreUpgradeComplete / PackageMigrationComplete: confirms the new Run level.
|
||||
// - NotRequired: another instance may have already run migrations; re-check to get Run level.
|
||||
// - HasErrors: BootFailedException is set, so DetermineRuntimeLevel() returns early (no-op).
|
||||
// For the leader: confirms migrations succeeded and level transitions to Run.
|
||||
// For followers: level is already Run (set during TryBecomeLeaderAsync polling).
|
||||
DetermineRuntimeLevel();
|
||||
|
||||
// RunMigrationsAsync may have set BootFailed via a non-throwing error path (HasErrors result).
|
||||
if (_runtimeState.Level == RuntimeLevel.BootFailed)
|
||||
{
|
||||
return;
|
||||
|
||||
@@ -52,8 +52,8 @@ internal sealed class DatabaseDataCreator
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "Find all logs that are from the namespace 'Umbraco.Core'",
|
||||
Query = "StartsWith(SourceContext, 'Umbraco.Core')",
|
||||
Name = "Find all logs that are within the namespace 'Umbraco.Cms'",
|
||||
Query = "StartsWith(SourceContext, 'Umbraco.Cms')",
|
||||
},
|
||||
new()
|
||||
{
|
||||
|
||||
+3
-12
@@ -1,4 +1,3 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core;
|
||||
@@ -8,6 +7,7 @@ using Umbraco.Cms.Core.Scoping;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Web;
|
||||
using Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0.LocalLinks;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0;
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0;
|
||||
/// Migration responsible for converting rich text editor properties to the new format as part of the upgrade process to Umbraco version 15.0.0.
|
||||
/// </summary>
|
||||
[Obsolete("Scheduled for removal in Umbraco 18.")]
|
||||
public partial class ConvertRichTextEditorProperties : ConvertBlockEditorPropertiesBase
|
||||
public class ConvertRichTextEditorProperties : ConvertBlockEditorPropertiesBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConvertRichTextEditorProperties"/> class.
|
||||
@@ -62,13 +62,7 @@ public partial class ConvertRichTextEditorProperties : ConvertBlockEditorPropert
|
||||
return base.UpdateEditorValue(editorValue);
|
||||
}
|
||||
|
||||
richTextEditorValue.Markup = BlockRegex().Replace(
|
||||
richTextEditorValue.Markup,
|
||||
match => UdiParser.TryParse(match.Groups["udi"].Value, out GuidUdi? guidUdi)
|
||||
? match.Value
|
||||
.Replace(match.Groups["attribute"].Value, "data-content-key")
|
||||
.Replace(match.Groups["udi"].Value, guidUdi.Guid.ToString("D"))
|
||||
: string.Empty);
|
||||
richTextEditorValue.Markup = RteBlockHelper.ConvertBlockUdisToKeys(richTextEditorValue.Markup);
|
||||
|
||||
return richTextEditorValue;
|
||||
}
|
||||
@@ -100,7 +94,4 @@ public partial class ConvertRichTextEditorProperties : ConvertBlockEditorPropert
|
||||
protected override bool IsCandidateForMigration(IPropertyType propertyType, IDataType dataType)
|
||||
=> dataType.ConfigurationObject is RichTextConfiguration richTextConfiguration
|
||||
&& richTextConfiguration.Blocks?.Any() is true;
|
||||
|
||||
[GeneratedRegex("<umb-rte-block.*(?<attribute>data-content-udi)=\"(?<udi>.[^\"]*)\".*<\\/umb-rte-block")]
|
||||
private static partial Regex BlockRegex();
|
||||
}
|
||||
|
||||
+60
-3
@@ -1,3 +1,4 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
@@ -11,6 +12,10 @@ namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0.LocalLinks;
|
||||
[Obsolete("Scheduled for removal in Umbraco 18.")]
|
||||
public class LocalLinkProcessor
|
||||
{
|
||||
private static readonly Regex _dataAnchorPattern = new(
|
||||
@"data-anchor=['""](?<anchor>[^'""]*)['""]",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private readonly HtmlLocalLinkParser _localLinkParser;
|
||||
private readonly IIdKeyMap _idKeyMap;
|
||||
private readonly IEnumerable<ITypedLocalLinkProcessor> _localLinkProcessors;
|
||||
@@ -121,18 +126,70 @@ public class LocalLinkProcessor
|
||||
}
|
||||
|
||||
// Extract any trailing href content (fragment, query string) between the localLink and closing quote.
|
||||
var trailingHrefContent = input.Substring(afterTagHref, closingQuoteIndex - afterTagHref);
|
||||
var existingTrailingHrefContent = input.Substring(afterTagHref, closingQuoteIndex - afterTagHref);
|
||||
var closingQuote = input[closingQuoteIndex];
|
||||
|
||||
// If the anchor tag carries a data-anchor attribute and that value is not already part
|
||||
// of the href (e.g. when migrating older content that stored the anchor only in data-anchor),
|
||||
// append it to the href so the link resolves correctly in the v15+ RTE (#22860).
|
||||
// When the href already contains a different fragment, we trust the href and skip the append
|
||||
// rather than producing an invalid URL with two '#' separators.
|
||||
var newTrailingHrefContent = existingTrailingHrefContent;
|
||||
var anchorFromAttribute = ExtractDataAnchorValue(input, tagHrefIndex);
|
||||
if (anchorFromAttribute is not null
|
||||
&& existingTrailingHrefContent.Contains(anchorFromAttribute, StringComparison.Ordinal) is false
|
||||
&& existingTrailingHrefContent.Contains('#') is false)
|
||||
{
|
||||
newTrailingHrefContent += anchorFromAttribute;
|
||||
}
|
||||
|
||||
// Build the replacement: converted localLink + trailing content + close quote + type attribute
|
||||
var oldSegment = tag.TagHref + trailingHrefContent + closingQuote;
|
||||
var newSegment = convertedLocalLink + trailingHrefContent + closingQuote + $" type=\"{entityType}\"";
|
||||
var oldSegment = tag.TagHref + existingTrailingHrefContent + closingQuote;
|
||||
var newSegment = convertedLocalLink + newTrailingHrefContent + closingQuote + $" type=\"{entityType}\"";
|
||||
input = input.Remove(tagHrefIndex, oldSegment.Length).Insert(tagHrefIndex, newSegment);
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
// Searches for a non-empty data-anchor attribute within the opening anchor tag that contains the
|
||||
// local link href at tagHrefIndex. Returns the attribute value (e.g. "#" or "#section-1"),
|
||||
// or null when no usable data-anchor is present.
|
||||
// The legacy local link pattern matches any href attribute (not just anchors), so this check
|
||||
// is scoped to elements whose tag name is "a" — data-anchor on other elements is not relevant.
|
||||
private static string? ExtractDataAnchorValue(string input, int tagHrefIndex)
|
||||
{
|
||||
var tagStartIndex = input.LastIndexOf('<', tagHrefIndex);
|
||||
if (tagStartIndex < 0 || tagStartIndex + 2 >= input.Length)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify the surrounding element is an anchor tag: "<a" followed by whitespace.
|
||||
// (An href attribute always implies at least one whitespace separator after the tag name.)
|
||||
var nameChar = input[tagStartIndex + 1];
|
||||
if ((nameChar != 'a' && nameChar != 'A') || char.IsWhiteSpace(input[tagStartIndex + 2]) is false)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tagEndIndex = input.IndexOf('>', tagHrefIndex);
|
||||
if (tagEndIndex < tagStartIndex)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var openingTag = input.Substring(tagStartIndex, tagEndIndex - tagStartIndex);
|
||||
Match anchorMatch = _dataAnchorPattern.Match(openingTag);
|
||||
if (anchorMatch.Success is false)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var anchorValue = anchorMatch.Groups["anchor"].Value;
|
||||
return string.IsNullOrEmpty(anchorValue) ? null : anchorValue;
|
||||
}
|
||||
|
||||
private (Guid Key, string EntityType)? CreateIntBasedKeyType(int id)
|
||||
{
|
||||
// very old data, best effort replacement.
|
||||
|
||||
+3
-23
@@ -1,4 +1,3 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
|
||||
@@ -51,14 +50,9 @@ public class LocalLinkRteProcessor : ITypedLocalLinkProcessor
|
||||
|
||||
var newMarkup = processStringValue.Invoke(richTextValue.Markup);
|
||||
|
||||
// fix recursive hickup in ConvertRichTextEditorProperties
|
||||
newMarkup = RteBlockHelper.BlockRegex().Replace(
|
||||
newMarkup,
|
||||
match => UdiParser.TryParse(match.Groups["udi"].Value, out GuidUdi? guidUdi)
|
||||
? match.Value
|
||||
.Replace(match.Groups["attribute"].Value, "data-content-key")
|
||||
.Replace(match.Groups["udi"].Value, guidUdi.Guid.ToString("D"))
|
||||
: string.Empty);
|
||||
// Re-apply block UDI→key conversion in case ConvertRichTextEditorProperties missed any
|
||||
// (e.g. under recursive / nested block structures the primary migration could leave behind).
|
||||
newMarkup = RteBlockHelper.ConvertBlockUdisToKeys(newMarkup);
|
||||
|
||||
if (newMarkup.Equals(richTextValue.Markup) == false)
|
||||
{
|
||||
@@ -85,17 +79,3 @@ public class LocalLinkRteProcessor : ITypedLocalLinkProcessor
|
||||
return hasChanged;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides helper methods for processing rich text editor (RTE) blocks containing local links during the upgrade to Umbraco version 15.0.0.
|
||||
/// </summary>
|
||||
[Obsolete("Scheduled for removal in Umbraco 18.")]
|
||||
public static partial class RteBlockHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a <see cref="Regex"/> that matches <c>umb-rte-block</c> elements containing a <c>data-content-udi</c> attribute in the input HTML.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Regex"/> instance for identifying <c>umb-rte-block</c> elements with a <c>data-content-udi</c> attribute.</returns>
|
||||
[GeneratedRegex("<umb-rte-block.*(?<attribute>data-content-udi)=\"(?<udi>.[^\"]*)\".*<\\/umb-rte-block")]
|
||||
public static partial Regex BlockRegex();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Umbraco.Cms.Core;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0.LocalLinks;
|
||||
|
||||
/// <summary>
|
||||
/// Provides helper methods for processing rich text editor (RTE) block markup by rewriting block UDIs to keys.
|
||||
/// </summary>
|
||||
[Obsolete("Scheduled for removal in Umbraco 18.")]
|
||||
public static partial class RteBlockHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a <see cref="Regex"/> that matches <c>umb-rte-block</c> elements containing a <c>data-content-udi</c> attribute in the input HTML.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Regex"/> instance for identifying <c>umb-rte-block</c> elements with a <c>data-content-udi</c> attribute.</returns>
|
||||
// Non-greedy on both [^>]*? and .*? so consecutive sibling <umb-rte-block> elements are matched
|
||||
// individually rather than collapsed into one span (which left all-but-last sibling UDIs un-converted).
|
||||
[GeneratedRegex("<umb-rte-block\\b[^>]*?(?<attribute>data-content-udi)=\"(?<udi>[^\"]+)\"[^>]*>.*?<\\/umb-rte-block>")]
|
||||
public static partial Regex BlockRegex();
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites every <c><umb-rte-block></c> element in <paramref name="markup"/> from the legacy
|
||||
/// <c>data-content-udi="umb://element/..."</c> form to the v15+ <c>data-content-key="<guid>"</c>
|
||||
/// form.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Blocks whose UDI fails to parse are <b>dropped</b> from the output rather than preserved.
|
||||
/// This mirrors the original behaviour of <c>ConvertRichTextEditorProperties</c> and should not be
|
||||
/// changed without considering migrated content that may contain malformed UDIs.
|
||||
/// </remarks>
|
||||
/// <param name="markup">The RTE markup to convert.</param>
|
||||
/// <returns>The converted markup, or the input unchanged if no convertible blocks are present.</returns>
|
||||
public static string ConvertBlockUdisToKeys(string markup) =>
|
||||
BlockRegex().Replace(
|
||||
markup,
|
||||
match => UdiParser.TryParse(match.Groups["udi"].Value, out GuidUdi? guidUdi)
|
||||
? match.Value
|
||||
.Replace(match.Groups["attribute"].Value, "data-content-key")
|
||||
.Replace(match.Groups["udi"].Value, guidUdi.Guid.ToString("D"))
|
||||
: string.Empty);
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Infrastructure.BackgroundJobs;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Notification that is raised when a recurring background job is cancelled during host shutdown.
|
||||
/// </summary>
|
||||
public sealed class RecurringBackgroundJobCanceledNotification : RecurringBackgroundJobNotification
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RecurringBackgroundJobCanceledNotification" /> class.
|
||||
/// </summary>
|
||||
/// <param name="target">The instance of the recurring background job that was cancelled.</param>
|
||||
/// <param name="messages">The <see cref="EventMessages" /> associated with the cancellation.</param>
|
||||
public RecurringBackgroundJobCanceledNotification(IRecurringBackgroundJob target, EventMessages messages)
|
||||
: base(target, messages)
|
||||
{ }
|
||||
}
|
||||
+1
-1
@@ -49,7 +49,7 @@ internal class DistributedJobRepository(IScopeAccessor scopeAccessor) : IDistrib
|
||||
{
|
||||
if (scopeAccessor.AmbientScope is null)
|
||||
{
|
||||
return;
|
||||
throw new InvalidOperationException("No scope, could not update distributed job");
|
||||
}
|
||||
|
||||
DistributedJobDto dto = MapToDto(distributedBackgroundJob);
|
||||
|
||||
+15
-5
@@ -249,14 +249,24 @@ internal sealed class RedirectUrlRepository : EntityRepositoryBase<Guid, IRedire
|
||||
|
||||
protected override IEnumerable<IRedirectUrl> PerformGetAll(params Guid[]? ids)
|
||||
{
|
||||
if (ids?.Length > Constants.Sql.MaxParameterCount)
|
||||
if (ids is null || ids.Length == 0)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
$"This repository does not support more than {Constants.Sql.MaxParameterCount} ids.");
|
||||
return Database.Fetch<RedirectUrlDto>(GetBaseQuery(false))
|
||||
.WhereNotNull()
|
||||
.Select(Map)
|
||||
.WhereNotNull();
|
||||
}
|
||||
|
||||
// Batch the WhereIn fetch so we never exceed SQL Server's 2100 parameter limit.
|
||||
// EntityRepositoryBase.GetMany already groups IDs, but we keep the batching here as
|
||||
// a defensive measure for safety and consistency at the repository boundary.
|
||||
var dtos = new List<RedirectUrlDto>(ids.Length);
|
||||
foreach (IEnumerable<Guid> group in ids.InGroupsOf(Constants.Sql.MaxParameterCount))
|
||||
{
|
||||
Sql<ISqlContext> sql = GetBaseQuery(false).WhereIn<RedirectUrlDto>(x => x.Id, group);
|
||||
dtos.AddRange(Database.Fetch<RedirectUrlDto>(sql));
|
||||
}
|
||||
|
||||
Sql<ISqlContext> sql = GetBaseQuery(false).WhereIn<RedirectUrlDto>(x => x.Id, ids);
|
||||
List<RedirectUrlDto> dtos = Database.Fetch<RedirectUrlDto>(sql);
|
||||
return dtos.WhereNotNull().Select(Map).WhereNotNull();
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -74,6 +74,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement
|
||||
string[] columns = [
|
||||
sx.ColumnWithAlias("x", "otherId", "nodeId"),
|
||||
sx.ColumnWithAlias("n", "uniqueId", "nodeKey"),
|
||||
sx.ColumnWithAlias("n", "text", "nodeName"),
|
||||
sx.ColumnWithAlias("n", "nodeObjectType", "nodeObjectType"),
|
||||
sx.ColumnWithAlias("d", "published", "nodePublished"),
|
||||
sx.ColumnWithAlias("ctn", "uniqueId", "contentTypeKey"),
|
||||
|
||||
@@ -122,11 +122,30 @@ internal sealed class IndexedEntitySearchService : IIndexedEntitySearchService
|
||||
.Where(key => key != Guid.Empty)
|
||||
.ToArray();
|
||||
|
||||
// EntityService.GetAll returns entities in database (not Lucene score) order, which
|
||||
// would discard the relevance ranking. Re-order to match the search result sequence.
|
||||
IEnumerable<IEntitySlim> orderedItems;
|
||||
if (keys.Length > 0)
|
||||
{
|
||||
var keyOrder = new Dictionary<Guid, int>(keys.Length);
|
||||
for (var i = 0; i < keys.Length; i++)
|
||||
{
|
||||
keyOrder.TryAdd(keys[i], i);
|
||||
}
|
||||
|
||||
orderedItems = _entityService
|
||||
.GetAll(objectType, keys)
|
||||
.OrderBy(entity => keyOrder.TryGetValue(entity.Key, out var index) ? index : int.MaxValue)
|
||||
.ToArray();
|
||||
}
|
||||
else
|
||||
{
|
||||
orderedItems = [];
|
||||
}
|
||||
|
||||
return Task.FromResult(new PagedModel<IEntitySlim>
|
||||
{
|
||||
Items = keys.Any()
|
||||
? _entityService.GetAll(objectType, keys)
|
||||
: Enumerable.Empty<IEntitySlim>(),
|
||||
Items = orderedItems,
|
||||
Total = totalFound
|
||||
});
|
||||
}
|
||||
|
||||
@@ -47,30 +47,21 @@ public class LogViewerRepository : LogViewerRepositoryBase
|
||||
|
||||
var filesForCurrentDay = Directory.GetFiles(_loggingConfiguration.LogDirectory, filesToFind);
|
||||
|
||||
// Foreach file we find - open it
|
||||
// Foreach file we find - open it. Any failure reading a single file (open error,
|
||||
// unrecoverable parse error, etc.) should not prevent the remaining files for the
|
||||
// day or date range from being read.
|
||||
foreach (var filePath in filesForCurrentDay)
|
||||
{
|
||||
// Open log file & add contents to the log collection
|
||||
// Which we then use LINQ to page over
|
||||
using (var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
||||
try
|
||||
{
|
||||
using (var stream = new StreamReader(fs))
|
||||
{
|
||||
var reader = new LogEventReader(stream);
|
||||
while (TryRead(reader, out LogEvent? evt))
|
||||
{
|
||||
// We may get a null if log line is malformed
|
||||
if (evt == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (logFilter.TakeLogEvent(evt))
|
||||
{
|
||||
logs.Add(evt);
|
||||
}
|
||||
}
|
||||
}
|
||||
ReadLogFile(filePath, logFilter, logs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Skipped log file {FilePath} after a file-level error; the file may be inaccessible or unreadable.",
|
||||
filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,6 +79,63 @@ public class LogViewerRepository : LogViewerRepositoryBase
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
private void ReadLogFile(string filePath, ILogFilter logFilter, List<LogEvent> logs)
|
||||
{
|
||||
using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
using var stream = new StreamReader(fs);
|
||||
var reader = new LogEventReader(stream);
|
||||
|
||||
var errorCount = 0;
|
||||
Exception? firstError = null;
|
||||
|
||||
while (true)
|
||||
{
|
||||
LogEvent? evt;
|
||||
try
|
||||
{
|
||||
if (!reader.TryRead(out evt))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is Newtonsoft.Json.JsonException or InvalidDataException)
|
||||
{
|
||||
// Serilog.Formatting.Compact.Reader uses Newtonsoft.Json internally and surfaces
|
||||
// its exceptions (Umbraco's own serialization is on System.Text.Json, but that
|
||||
// doesn't apply here — we have to catch what the reader actually throws).
|
||||
// JsonException covers parse failures (e.g. an unterminated string in a truncated
|
||||
// entry); InvalidDataException covers structurally-valid JSON that isn't a valid
|
||||
// Serilog Compact event. Either way the offending line has been consumed from the
|
||||
// underlying StreamReader and the next TryRead call advances. Anything else
|
||||
// (IOException, decoder failures, etc.) is propagated to the file-level catch in
|
||||
// GetLogs so we don't risk a tight loop or silently swallow a more serious failure.
|
||||
errorCount++;
|
||||
firstError ??= ex;
|
||||
continue;
|
||||
}
|
||||
|
||||
// LogEventReader may return true with a null event for a benign skip.
|
||||
if (evt is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (logFilter.TakeLogEvent(evt))
|
||||
{
|
||||
logs.Add(evt);
|
||||
}
|
||||
}
|
||||
|
||||
if (errorCount > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
firstError,
|
||||
"Encountered {ErrorCount} unreadable line(s) while reading log file {FilePath}. The file may contain partially-written or corrupt entries; affected lines were skipped.",
|
||||
errorCount,
|
||||
filePath);
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyDictionary<string, string?> MapLogMessageProperties(IReadOnlyDictionary<string, LogEventPropertyValue>? properties)
|
||||
{
|
||||
var result = new Dictionary<string, string?>();
|
||||
@@ -121,21 +169,4 @@ public class LogViewerRepository : LogViewerRepositoryBase
|
||||
}
|
||||
|
||||
private static string GetSearchPattern(DateTime day) => $"*{day:yyyyMMdd}*.json";
|
||||
|
||||
private bool TryRead(LogEventReader reader, out LogEvent? evt)
|
||||
{
|
||||
try
|
||||
{
|
||||
return reader.TryRead(out evt);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// As we are reading/streaming one line at a time in the JSON file
|
||||
// Thus we can not report the line number, as it will always be 1
|
||||
_logger.LogError(ex, "Unable to parse a line in the JSON log file");
|
||||
|
||||
evt = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,19 @@ public sealed class DocumentCache : IPublishedContentCache
|
||||
|
||||
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(bool preview, Guid contentId)
|
||||
{
|
||||
// Sync fast path: when the converted-content L0 cache already holds the item we can
|
||||
// return it without spinning up an async state machine. This is the dominant case on
|
||||
// a warm site and is hit per-key by the FilterAvailable lazy chain. On a miss we fall
|
||||
// through to the async path which handles HybridCache (L1/L2) and database lookups.
|
||||
if (_documentCacheService.TryGetCached(contentId, preview, out IPublishedContent? cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
return GetByIdAsync(contentId, preview).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
|
||||
public IPublishedContent? GetById(int contentId) => GetByIdAsync(contentId).GetAwaiter().GetResult();
|
||||
|
||||
@@ -24,8 +24,19 @@ public sealed class MediaCache : IPublishedMediaCache
|
||||
|
||||
public IPublishedContent? GetById(bool preview, int contentId) => GetByIdAsync(contentId).GetAwaiter().GetResult();
|
||||
|
||||
public IPublishedContent? GetById(bool preview, Guid contentId) =>
|
||||
GetByIdAsync(contentId).GetAwaiter().GetResult();
|
||||
public IPublishedContent? GetById(bool preview, Guid contentId)
|
||||
{
|
||||
// Sync fast path: when the converted-content L0 cache already holds the item we can
|
||||
// return it without spinning up an async state machine. This is the dominant case on
|
||||
// a warm site and is hit per-key by the FilterAvailable lazy chain. On a miss we fall
|
||||
// through to the async path which handles HybridCache (L1/L2) and database lookups.
|
||||
if (_mediaCacheService.TryGetCached(contentId, out IPublishedContent? cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
return GetByIdAsync(contentId).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
|
||||
public IPublishedContent? GetById(int contentId) => GetByIdAsync(contentId).GetAwaiter().GetResult();
|
||||
|
||||
@@ -207,21 +207,28 @@ internal sealed class DatabaseCacheRepository : RepositoryBase, IDatabaseCacheRe
|
||||
/// <inheritdoc/>
|
||||
public async Task<IEnumerable<ContentCacheNode>> GetContentSourcesAsync(IEnumerable<Guid> keys, bool preview = false)
|
||||
{
|
||||
Sql<ISqlContext>? sql = SqlContentSourcesSelect()
|
||||
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document))
|
||||
.WhereIn<NodeDto>(x => x.UniqueId, keys)
|
||||
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
|
||||
// Batch the WHERE IN to stay within SQL Server's parameter limit.
|
||||
// The configurable document seed batch size is applied upstream; this method only enforces MaxParameterCount.
|
||||
Guid[] keysArray = keys as Guid[] ?? keys.ToArray();
|
||||
var dtos = new List<ContentSourceDto>(keysArray.Length);
|
||||
foreach (IEnumerable<Guid> group in keysArray.InGroupsOf(Constants.Sql.MaxParameterCount))
|
||||
{
|
||||
Sql<ISqlContext>? sql = SqlContentSourcesSelect()
|
||||
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document))
|
||||
.WhereIn<NodeDto>(x => x.UniqueId, group)
|
||||
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
|
||||
|
||||
List<ContentSourceDto> dtos = await Database.FetchAsync<ContentSourceDto>(sql);
|
||||
dtos.AddRange(await Database.FetchAsync<ContentSourceDto>(sql));
|
||||
}
|
||||
|
||||
dtos = dtos
|
||||
var filtered = dtos
|
||||
.Where(x => x is not null)
|
||||
.Where(x => preview || ((x.PubDataRaw is not null || x.PubData is not null) && (!x.Published || x.PubName is not null)))
|
||||
.ToList();
|
||||
|
||||
IContentCacheDataSerializer serializer =
|
||||
_contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Document);
|
||||
return dtos
|
||||
return filtered
|
||||
.Select(x => CreateContentNodeKit(x, serializer, preview))
|
||||
.OfType<ContentCacheNode>();
|
||||
}
|
||||
@@ -379,20 +386,27 @@ internal sealed class DatabaseCacheRepository : RepositoryBase, IDatabaseCacheRe
|
||||
/// <inheritdoc/>
|
||||
public async Task<IEnumerable<ContentCacheNode>> GetMediaSourcesAsync(IEnumerable<Guid> keys)
|
||||
{
|
||||
Sql<ISqlContext>? sql = SqlMediaSourcesSelect()
|
||||
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Media))
|
||||
.WhereIn<NodeDto>(x => x.UniqueId, keys)
|
||||
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
|
||||
// Batch the WHERE IN by Constants.Sql.MaxParameterCount so callers configuring
|
||||
// CacheSettings.MediaSeedBatchSize above that limit do not hit SQL Server's 2100 parameter limit.
|
||||
Guid[] keysArray = keys as Guid[] ?? keys.ToArray();
|
||||
var dtos = new List<ContentSourceDto>(keysArray.Length);
|
||||
foreach (IEnumerable<Guid> group in keysArray.InGroupsOf(Constants.Sql.MaxParameterCount))
|
||||
{
|
||||
Sql<ISqlContext>? sql = SqlMediaSourcesSelect()
|
||||
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Media))
|
||||
.WhereIn<NodeDto>(x => x.UniqueId, group)
|
||||
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
|
||||
|
||||
List<ContentSourceDto> dtos = await Database.FetchAsync<ContentSourceDto>(sql);
|
||||
dtos.AddRange(await Database.FetchAsync<ContentSourceDto>(sql));
|
||||
}
|
||||
|
||||
dtos = dtos
|
||||
var filtered = dtos
|
||||
.Where(x => x is not null)
|
||||
.ToList();
|
||||
|
||||
IContentCacheDataSerializer serializer =
|
||||
_contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Media);
|
||||
return dtos
|
||||
return filtered
|
||||
.Select(x => CreateMediaNodeKit(x, serializer));
|
||||
}
|
||||
|
||||
@@ -578,107 +592,135 @@ internal sealed class DatabaseCacheRepository : RepositoryBase, IDatabaseCacheRe
|
||||
/// </summary>
|
||||
private List<CacheRebuildDocumentDto> GetDocumentMetadataForNodes(List<int> nodeIds)
|
||||
{
|
||||
// Query content metadata with both edit and published version info
|
||||
// Query content metadata with both edit and published version info.
|
||||
// Uses nested join pattern to ensure we only get the published ContentVersion
|
||||
// (where a DocumentVersionDto with Published=true exists)
|
||||
Sql<ISqlContext> sql = Sql()
|
||||
.Select<NodeDto>(
|
||||
x => x.NodeId,
|
||||
x => x.UniqueId,
|
||||
x => x.Text,
|
||||
x => x.Path,
|
||||
x => x.Level,
|
||||
x => x.ParentId,
|
||||
x => x.SortOrder,
|
||||
x => x.CreateDate,
|
||||
x => Alias(x.UserId, "CreatorId"))
|
||||
.AndSelect<ContentDto>(x => x.ContentTypeId)
|
||||
.AndSelect<DocumentDto>(x => x.Published)
|
||||
.AndSelect<ContentVersionDto>(
|
||||
x => Alias(x.Id, "EditVersionId"),
|
||||
x => Alias(x.Text, "EditName"),
|
||||
x => Alias(x.VersionDate, "EditVersionDate"),
|
||||
x => Alias(x.UserId, "EditWriterId"))
|
||||
.AndSelect<ContentVersionDto>(
|
||||
"pcv",
|
||||
x => Alias(x.Id, "PublishedVersionId"),
|
||||
x => Alias(x.Text, "PublishedName"),
|
||||
x => Alias(x.VersionDate, "PublishedVersionDate"),
|
||||
x => Alias(x.UserId, "PublishedWriterId"))
|
||||
.From<NodeDto>()
|
||||
.InnerJoin<ContentDto>().On<NodeDto, ContentDto>((n, c) => n.NodeId == c.NodeId)
|
||||
.InnerJoin<DocumentDto>().On<NodeDto, DocumentDto>((n, d) => n.NodeId == d.NodeId)
|
||||
.InnerJoin<ContentVersionDto>().On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId && cv.Current)
|
||||
// (where a DocumentVersionDto with Published=true exists).
|
||||
// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works.
|
||||
var results = new List<CacheRebuildDocumentDto>(nodeIds.Count);
|
||||
foreach (IEnumerable<int> group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount))
|
||||
{
|
||||
Sql<ISqlContext> sql = Sql()
|
||||
.Select<NodeDto>(
|
||||
x => x.NodeId,
|
||||
x => x.UniqueId,
|
||||
x => x.Text,
|
||||
x => x.Path,
|
||||
x => x.Level,
|
||||
x => x.ParentId,
|
||||
x => x.SortOrder,
|
||||
x => x.CreateDate,
|
||||
x => Alias(x.UserId, "CreatorId"))
|
||||
.AndSelect<ContentDto>(x => x.ContentTypeId)
|
||||
.AndSelect<DocumentDto>(x => x.Published)
|
||||
.AndSelect<ContentVersionDto>(
|
||||
x => Alias(x.Id, "EditVersionId"),
|
||||
x => Alias(x.Text, "EditName"),
|
||||
x => Alias(x.VersionDate, "EditVersionDate"),
|
||||
x => Alias(x.UserId, "EditWriterId"))
|
||||
.AndSelect<ContentVersionDto>(
|
||||
"pcv",
|
||||
x => Alias(x.Id, "PublishedVersionId"),
|
||||
x => Alias(x.Text, "PublishedName"),
|
||||
x => Alias(x.VersionDate, "PublishedVersionDate"),
|
||||
x => Alias(x.UserId, "PublishedWriterId"))
|
||||
.From<NodeDto>()
|
||||
.InnerJoin<ContentDto>().On<NodeDto, ContentDto>((n, c) => n.NodeId == c.NodeId)
|
||||
.InnerJoin<DocumentDto>().On<NodeDto, DocumentDto>((n, d) => n.NodeId == d.NodeId)
|
||||
.InnerJoin<ContentVersionDto>().On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId && cv.Current)
|
||||
|
||||
// Nested join: ContentVersionDto "pcv" INNER JOIN DocumentVersionDto "pdv" ON published=true
|
||||
// This ensures pcv only includes rows where there's a published DocumentVersion
|
||||
.LeftJoin<ContentVersionDto>(
|
||||
j => j.InnerJoin<DocumentVersionDto>("pdv")
|
||||
.On<ContentVersionDto, DocumentVersionDto>(
|
||||
(left, right) => left.Id == right.Id && right.Published == true, "pcv", "pdv"),
|
||||
"pcv")
|
||||
// Nested join: ContentVersionDto "pcv" INNER JOIN DocumentVersionDto "pdv" ON published=true.
|
||||
// This ensures pcv only includes rows where there's a published DocumentVersion.
|
||||
.LeftJoin<ContentVersionDto>(
|
||||
j => j.InnerJoin<DocumentVersionDto>("pdv")
|
||||
.On<ContentVersionDto, DocumentVersionDto>(
|
||||
(left, right) => left.Id == right.Id && right.Published == true, "pcv", "pdv"),
|
||||
"pcv")
|
||||
|
||||
.On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId, aliasRight: "pcv")
|
||||
.WhereIn<NodeDto>(x => x.NodeId, nodeIds);
|
||||
.On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId, aliasRight: "pcv")
|
||||
.WhereIn<NodeDto>(x => x.NodeId, group);
|
||||
|
||||
return Database.Fetch<CacheRebuildDocumentDto>(sql);
|
||||
results.AddRange(Database.Fetch<CacheRebuildDocumentDto>(sql));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets property data for the specified node IDs using efficient JOIN on nodeId.
|
||||
/// This avoids the expensive WHERE IN on versionId that causes index scans.
|
||||
/// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works.
|
||||
/// </summary>
|
||||
private List<CacheRebuildPropertyDto> GetPropertyDataForNodes(List<int> nodeIds)
|
||||
{
|
||||
// JOIN through nodeId → versionId path for efficient query plan
|
||||
Sql<ISqlContext> sql = Sql()
|
||||
.Select<PropertyDataDto>(
|
||||
x => x.VersionId,
|
||||
x => x.LanguageId,
|
||||
x => x.Segment,
|
||||
x => x.IntegerValue,
|
||||
x => x.DecimalValue,
|
||||
x => x.DateValue,
|
||||
x => x.VarcharValue,
|
||||
x => x.TextValue)
|
||||
.AndSelect<PropertyTypeDto>(x => Alias(x.Alias, "PropertyAlias"))
|
||||
.From<PropertyDataDto>()
|
||||
.InnerJoin<PropertyTypeDto>().On<PropertyDataDto, PropertyTypeDto>((pd, pt) => pd.PropertyTypeId == pt.Id)
|
||||
.InnerJoin<ContentVersionDto>().On<PropertyDataDto, ContentVersionDto>((pd, cv) => pd.VersionId == cv.Id)
|
||||
.WhereIn<ContentVersionDto>(x => x.NodeId, nodeIds);
|
||||
var results = new List<CacheRebuildPropertyDto>();
|
||||
foreach (IEnumerable<int> group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount))
|
||||
{
|
||||
// JOIN through nodeId → versionId path for efficient query plan.
|
||||
Sql<ISqlContext> sql = Sql()
|
||||
.Select<PropertyDataDto>(
|
||||
x => x.VersionId,
|
||||
x => x.LanguageId,
|
||||
x => x.Segment,
|
||||
x => x.IntegerValue,
|
||||
x => x.DecimalValue,
|
||||
x => x.DateValue,
|
||||
x => x.VarcharValue,
|
||||
x => x.TextValue)
|
||||
.AndSelect<PropertyTypeDto>(x => Alias(x.Alias, "PropertyAlias"))
|
||||
.From<PropertyDataDto>()
|
||||
.InnerJoin<PropertyTypeDto>().On<PropertyDataDto, PropertyTypeDto>((pd, pt) => pd.PropertyTypeId == pt.Id)
|
||||
.InnerJoin<ContentVersionDto>().On<PropertyDataDto, ContentVersionDto>((pd, cv) => pd.VersionId == cv.Id)
|
||||
.WhereIn<ContentVersionDto>(x => x.NodeId, group);
|
||||
|
||||
return Database.Fetch<CacheRebuildPropertyDto>(sql);
|
||||
results.AddRange(Database.Fetch<CacheRebuildPropertyDto>(sql));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets culture variation data for the specified node IDs.
|
||||
/// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works.
|
||||
/// </summary>
|
||||
private List<CacheRebuildCultureDto> GetCultureDataForNodes(List<int> nodeIds)
|
||||
{
|
||||
Sql<ISqlContext> sql = Sql()
|
||||
.Select<ContentVersionCultureVariationDto>(x => x.VersionId, x => x.Name, x => x.UpdateDate)
|
||||
.AndSelect<LanguageDto>(x => Alias(x.IsoCode, "IsoCode"))
|
||||
.From<ContentVersionCultureVariationDto>()
|
||||
.InnerJoin<LanguageDto>().On<ContentVersionCultureVariationDto, LanguageDto>((cv, l) => cv.LanguageId == l.Id)
|
||||
.InnerJoin<ContentVersionDto>().On<ContentVersionCultureVariationDto, ContentVersionDto>((ccv, cv) => ccv.VersionId == cv.Id)
|
||||
.WhereIn<ContentVersionDto>(x => x.NodeId, nodeIds);
|
||||
var results = new List<CacheRebuildCultureDto>();
|
||||
foreach (IEnumerable<int> group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount))
|
||||
{
|
||||
Sql<ISqlContext> sql = Sql()
|
||||
.Select<ContentVersionCultureVariationDto>(x => x.VersionId, x => x.Name, x => x.UpdateDate)
|
||||
.AndSelect<LanguageDto>(x => Alias(x.IsoCode, "IsoCode"))
|
||||
.From<ContentVersionCultureVariationDto>()
|
||||
.InnerJoin<LanguageDto>().On<ContentVersionCultureVariationDto, LanguageDto>((cv, l) => cv.LanguageId == l.Id)
|
||||
.InnerJoin<ContentVersionDto>().On<ContentVersionCultureVariationDto, ContentVersionDto>((ccv, cv) => ccv.VersionId == cv.Id)
|
||||
.WhereIn<ContentVersionDto>(x => x.NodeId, group);
|
||||
|
||||
return Database.Fetch<CacheRebuildCultureDto>(sql);
|
||||
results.AddRange(Database.Fetch<CacheRebuildCultureDto>(sql));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets document culture variation data (edited status per culture) for the specified node IDs.
|
||||
/// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works.
|
||||
/// </summary>
|
||||
private List<CacheRebuildDocumentCultureDto> GetDocumentCultureDataForNodes(List<int> nodeIds)
|
||||
{
|
||||
Sql<ISqlContext> sql = Sql()
|
||||
.Select<DocumentCultureVariationDto>(x => x.NodeId, x => x.Edited)
|
||||
.AndSelect<LanguageDto>(x => Alias(x.IsoCode, "IsoCode"))
|
||||
.From<DocumentCultureVariationDto>()
|
||||
.InnerJoin<LanguageDto>().On<DocumentCultureVariationDto, LanguageDto>((dcv, l) => dcv.LanguageId == l.Id)
|
||||
.WhereIn<DocumentCultureVariationDto>(x => x.NodeId, nodeIds);
|
||||
var results = new List<CacheRebuildDocumentCultureDto>();
|
||||
foreach (IEnumerable<int> group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount))
|
||||
{
|
||||
Sql<ISqlContext> sql = Sql()
|
||||
.Select<DocumentCultureVariationDto>(x => x.NodeId, x => x.Edited)
|
||||
.AndSelect<LanguageDto>(x => Alias(x.IsoCode, "IsoCode"))
|
||||
.From<DocumentCultureVariationDto>()
|
||||
.InnerJoin<LanguageDto>().On<DocumentCultureVariationDto, LanguageDto>((dcv, l) => dcv.LanguageId == l.Id)
|
||||
.WhereIn<DocumentCultureVariationDto>(x => x.NodeId, group);
|
||||
|
||||
return Database.Fetch<CacheRebuildDocumentCultureDto>(sql);
|
||||
results.AddRange(Database.Fetch<CacheRebuildDocumentCultureDto>(sql));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1207,31 +1249,38 @@ internal sealed class DatabaseCacheRepository : RepositoryBase, IDatabaseCacheRe
|
||||
|
||||
/// <summary>
|
||||
/// Gets content metadata for the specified node IDs using efficient JOIN. Used for media and members.
|
||||
/// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works.
|
||||
/// </summary>
|
||||
private List<CacheRebuildContentDto> GetContentMetadataForNodes(List<int> nodeIds)
|
||||
{
|
||||
Sql<ISqlContext> sql = Sql()
|
||||
.Select<NodeDto>(
|
||||
x => x.NodeId,
|
||||
x => x.UniqueId,
|
||||
x => x.Text,
|
||||
x => x.Path,
|
||||
x => x.Level,
|
||||
x => x.ParentId,
|
||||
x => x.SortOrder,
|
||||
x => x.CreateDate,
|
||||
x => Alias(x.UserId, "CreatorId"))
|
||||
.AndSelect<ContentDto>(x => x.ContentTypeId)
|
||||
.AndSelect<ContentVersionDto>(
|
||||
x => Alias(x.Id, "VersionId"),
|
||||
x => Alias(x.VersionDate, "VersionDate"),
|
||||
x => Alias(x.UserId, "WriterId"))
|
||||
.From<NodeDto>()
|
||||
.InnerJoin<ContentDto>().On<NodeDto, ContentDto>((n, c) => n.NodeId == c.NodeId)
|
||||
.InnerJoin<ContentVersionDto>().On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId && cv.Current)
|
||||
.WhereIn<NodeDto>(x => x.NodeId, nodeIds);
|
||||
var results = new List<CacheRebuildContentDto>(nodeIds.Count);
|
||||
foreach (IEnumerable<int> group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount))
|
||||
{
|
||||
Sql<ISqlContext> sql = Sql()
|
||||
.Select<NodeDto>(
|
||||
x => x.NodeId,
|
||||
x => x.UniqueId,
|
||||
x => x.Text,
|
||||
x => x.Path,
|
||||
x => x.Level,
|
||||
x => x.ParentId,
|
||||
x => x.SortOrder,
|
||||
x => x.CreateDate,
|
||||
x => Alias(x.UserId, "CreatorId"))
|
||||
.AndSelect<ContentDto>(x => x.ContentTypeId)
|
||||
.AndSelect<ContentVersionDto>(
|
||||
x => Alias(x.Id, "VersionId"),
|
||||
x => Alias(x.VersionDate, "VersionDate"),
|
||||
x => Alias(x.UserId, "WriterId"))
|
||||
.From<NodeDto>()
|
||||
.InnerJoin<ContentDto>().On<NodeDto, ContentDto>((n, c) => n.NodeId == c.NodeId)
|
||||
.InnerJoin<ContentVersionDto>().On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId && cv.Current)
|
||||
.WhereIn<NodeDto>(x => x.NodeId, group);
|
||||
|
||||
return Database.Fetch<CacheRebuildContentDto>(sql);
|
||||
results.AddRange(Database.Fetch<CacheRebuildContentDto>(sql));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -3,19 +3,32 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Exceptions;
|
||||
using Umbraco.Cms.Core.Extensions;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.Navigation;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
using Umbraco.Extensions;
|
||||
using Umbraco.Cms.Core.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.HybridCache;
|
||||
|
||||
internal class PublishedContent : PublishedContentBase
|
||||
{
|
||||
private IPublishedProperty[] _properties;
|
||||
/// <summary>
|
||||
/// Backing array of materialized properties for this content item. Built lazily on first
|
||||
/// access via <see cref="EnsureProperties"/>; <c>null</c> until then.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Lazy construction avoids allocating a <see cref="PublishedProperty"/> wrapper per
|
||||
/// property type for traversal-only operations (e.g. <c>Children().Count()</c>,
|
||||
/// <c>Descendants()</c> without property reads), which is a significant slice of
|
||||
/// allocation for tree traversals.
|
||||
/// </remarks>
|
||||
private IPublishedProperty[]? _properties;
|
||||
|
||||
private readonly Dictionary<string, PropertyData[]> _propertyData;
|
||||
private readonly IElementsCache _elementsCache;
|
||||
|
||||
private readonly ContentNode _contentNode;
|
||||
private IReadOnlyDictionary<string, PublishedCultureInfo>? _cultures;
|
||||
private readonly string? _urlSegment;
|
||||
@@ -44,21 +57,11 @@ internal class PublishedContent : PublishedContentBase
|
||||
_contentName = contentData.Name;
|
||||
_urlSegment = contentData.UrlSegment;
|
||||
_published = contentData.Published;
|
||||
_propertyData = contentData.Properties;
|
||||
_elementsCache = elementsCache;
|
||||
|
||||
IsPreviewing = preview;
|
||||
|
||||
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;
|
||||
@@ -73,7 +76,7 @@ internal class PublishedContent : PublishedContentBase
|
||||
|
||||
public override Guid Key { get; }
|
||||
|
||||
public override IEnumerable<IPublishedProperty> Properties => _properties;
|
||||
public override IEnumerable<IPublishedProperty> Properties => EnsureProperties();
|
||||
|
||||
public override int Id { get; }
|
||||
|
||||
@@ -213,15 +216,45 @@ internal class PublishedContent : PublishedContentBase
|
||||
return null; // happens when 'alias' does not match a content type property alias
|
||||
}
|
||||
|
||||
IPublishedProperty[] properties = EnsureProperties();
|
||||
|
||||
// should never happen - properties array must be in sync with property type
|
||||
if (index >= _properties.Length)
|
||||
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;
|
||||
return properties[index];
|
||||
}
|
||||
|
||||
private IPublishedProperty[] EnsureProperties()
|
||||
{
|
||||
IPublishedProperty[]? properties = _properties;
|
||||
if (properties is not null)
|
||||
{
|
||||
return properties;
|
||||
}
|
||||
|
||||
return BuildProperties();
|
||||
}
|
||||
|
||||
private IPublishedProperty[] BuildProperties()
|
||||
{
|
||||
IEnumerable<IPublishedPropertyType> propertyTypes = _contentNode.ContentType.PropertyTypes;
|
||||
var newProperties = new IPublishedProperty[propertyTypes.Count()];
|
||||
var i = 0;
|
||||
foreach (IPublishedPropertyType propertyType in propertyTypes)
|
||||
{
|
||||
// add one property per property type - this is required for the indexing to work
|
||||
// if propertyData supplies pdatas, use them, else use null
|
||||
_propertyData.TryGetValue(propertyType.Alias, out PropertyData[]? propertyDatas);
|
||||
newProperties[i++] = new PublishedProperty(propertyType, this, propertyDatas, _elementsCache, propertyType.CacheLevel);
|
||||
}
|
||||
|
||||
// Use CompareExchange so concurrent first-access threads agree on a single canonical
|
||||
// array — losers discard their newly built array and use the winner's.
|
||||
return Interlocked.CompareExchange(ref _properties, newProperties, null) ?? newProperties;
|
||||
}
|
||||
|
||||
public override bool IsDraft(string? culture = null)
|
||||
|
||||
@@ -107,6 +107,18 @@ internal sealed class DocumentCacheService : IDocumentCacheService
|
||||
return await GetNodeAsync(key, calculatedPreview);
|
||||
}
|
||||
|
||||
public bool TryGetCached(Guid key, bool preview, out IPublishedContent? content)
|
||||
{
|
||||
// Mirror the L0 (published content cache) fast path in GetNodeAsync.
|
||||
if (preview is false && _publishedContentCache.TryGetValue(GetCacheKey(key, preview), out content))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
content = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<IPublishedContent?> GetNodeAsync(Guid key, bool preview)
|
||||
{
|
||||
var cacheKey = GetCacheKey(key, preview);
|
||||
|
||||
@@ -103,6 +103,18 @@ internal sealed class MediaCacheService : IMediaCacheService
|
||||
return await GetNodeAsync(key);
|
||||
}
|
||||
|
||||
public bool TryGetCached(Guid key, out IPublishedContent? content)
|
||||
{
|
||||
// Mirror the L0 (published content cache) fast path in GetNodeAsync.
|
||||
if (_publishedContentCache.TryGetValue(key, out content))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
content = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<IPublishedContent?> GetNodeAsync(Guid key)
|
||||
{
|
||||
if (_publishedContentCache.TryGetValue(key, out IPublishedContent? cached))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user