From d54d946edab8174174408de12e3820e36827f731 Mon Sep 17 00:00:00 2001 From: Zeegaan Date: Wed, 18 Feb 2026 17:21:51 +0900 Subject: [PATCH 01/12] Keep track of rebuilding in memory --- .../Examine/ExamineIndexRebuilder.cs | 113 ++++++++++-------- .../UmbracoBuilderExtensions.cs | 6 +- 2 files changed, 63 insertions(+), 56 deletions(-) diff --git a/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs b/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs index 6e6d8e813182..3e462b171d22 100644 --- a/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs +++ b/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs @@ -1,26 +1,25 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System.Collections.Concurrent; using Examine; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Runtime; using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Cms.Infrastructure.Models; -using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Examine; internal class ExamineIndexRebuilder : IIndexRebuilder { - private const string RebuildAllOperationTypeName = "RebuildAllExamineIndexes"; + // Static because the class is registered as Transient - all instances must share the same state. + private static readonly ConcurrentDictionary RebuildTasks = new(); private readonly IExamineManager _examineManager; private readonly ILogger _logger; private readonly IMainDom _mainDom; private readonly IEnumerable _populators; - private readonly ILongRunningOperationService _longRunningOperationService; private readonly IRuntimeState _runtimeState; /// @@ -31,15 +30,13 @@ public ExamineIndexRebuilder( IRuntimeState runtimeState, ILogger logger, IExamineManager examineManager, - IEnumerable populators, - ILongRunningOperationService longRunningOperationService) + IEnumerable populators) { _mainDom = mainDom; _runtimeState = runtimeState; _logger = logger; _examineManager = examineManager; _populators = populators; - _longRunningOperationService = longRunningOperationService; } /// @@ -68,26 +65,48 @@ public virtual async Task> RebuildIndexAsync(string return Attempt.Fail(IndexRebuildResult.NotAllowedToRun); } - Attempt attempt = await _longRunningOperationService.RunAsync( - GetRebuildOperationTypeName(indexName), - async ct => - { - await RebuildIndex(indexName, delay.Value, ct); - return Task.CompletedTask; - }, - allowConcurrentExecution: false, - runInBackground: useBackgroundThread); - - if (attempt.Success) + if (RebuildTasks.TryGetValue(indexName, out Task? existing) && !existing.IsCompleted) { - return Attempt.Succeed(IndexRebuildResult.Success); + _logger.LogWarning("Call was made to RebuildIndex but a rebuild for {IndexName} is already running.", indexName); + return Attempt.Fail(IndexRebuildResult.AlreadyRebuilding); } - return attempt.Status switch + if (useBackgroundThread) + { + // Do not flow AsyncLocal to the child thread + using (ExecutionContext.SuppressFlow()) + { + Task rebuildTask = Task.Run(() => + { + try + { + PerformRebuildIndex(indexName, delay.Value, CancellationToken.None); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while rebuilding index {IndexName} in the background.", indexName); + } + finally + { + RebuildTasks.TryRemove(indexName, out _); + } + }); + RebuildTasks[indexName] = rebuildTask; + } + } + else { - LongRunningOperationEnqueueStatus.AlreadyRunning => Attempt.Fail(IndexRebuildResult.AlreadyRebuilding), - _ => Attempt.Fail(IndexRebuildResult.Unknown), - }; + try + { + PerformRebuildIndex(indexName, delay.Value, CancellationToken.None); + } + finally + { + RebuildTasks.TryRemove(indexName, out _); + } + } + + return Attempt.Succeed(IndexRebuildResult.Success); } /// @@ -105,46 +124,36 @@ public virtual async Task> RebuildIndexesAsync(bool return Attempt.Fail(IndexRebuildResult.NotAllowedToRun); } - Attempt attempt = await _longRunningOperationService.RunAsync( - RebuildAllOperationTypeName, - async ct => + if (useBackgroundThread) + { + // Do not flow AsyncLocal to the child thread + using (ExecutionContext.SuppressFlow()) { - await RebuildIndexes(onlyEmptyIndexes, delay.Value, ct); - return Task.CompletedTask; - }, - allowConcurrentExecution: false, - runInBackground: useBackgroundThread); - - if (attempt.Success) + _ = Task.Run(() => + { + PerformRebuildIndexes(onlyEmptyIndexes, delay.Value, CancellationToken.None); + }); + } + } + else { - return Attempt.Succeed(IndexRebuildResult.Success); + PerformRebuildIndexes(onlyEmptyIndexes, delay.Value, CancellationToken.None); } - return attempt.Status switch - { - LongRunningOperationEnqueueStatus.AlreadyRunning => Attempt.Fail(IndexRebuildResult.AlreadyRebuilding), - _ => Attempt.Fail(IndexRebuildResult.Unknown), - }; + return Attempt.Succeed(IndexRebuildResult.Success); } /// - public async Task IsRebuildingAsync(string indexName) - => (await _longRunningOperationService.GetByTypeAsync(GetRebuildOperationTypeName(indexName), 0, 0)).Total != 0; - - private static string GetRebuildOperationTypeName(string indexName) - { - // Truncate to a maximum of 200 characters to ensure the type name doesn't overflow the database field. - const int TypeFieldSize = 200; - return $"RebuildExamineIndex-{indexName}".TruncateWithUniqueHash(TypeFieldSize); - } + public Task IsRebuildingAsync(string indexName) + => Task.FromResult(RebuildTasks.TryGetValue(indexName, out Task? task) && task.IsCompleted is false); private bool CanRun() => _mainDom.IsMainDom && _runtimeState.Level == RuntimeLevel.Run; - private async Task RebuildIndex(string indexName, TimeSpan delay, CancellationToken cancellationToken) + private void PerformRebuildIndex(string indexName, TimeSpan delay, CancellationToken cancellationToken) { if (delay > TimeSpan.Zero) { - await Task.Delay(delay, cancellationToken); + Thread.Sleep(delay); } if (!_examineManager.TryGetIndex(indexName, out IIndex index)) @@ -164,11 +173,11 @@ private async Task RebuildIndex(string indexName, TimeSpan delay, CancellationTo } } - private async Task RebuildIndexes(bool onlyEmptyIndexes, TimeSpan delay, CancellationToken cancellationToken) + private void PerformRebuildIndexes(bool onlyEmptyIndexes, TimeSpan delay, CancellationToken cancellationToken) { if (delay > TimeSpan.Zero) { - await Task.Delay(delay, cancellationToken); + Thread.Sleep(delay); } // If an index exists but it has zero docs we'll consider it empty and rebuild diff --git a/tests/Umbraco.Tests.Integration/DependencyInjection/UmbracoBuilderExtensions.cs b/tests/Umbraco.Tests.Integration/DependencyInjection/UmbracoBuilderExtensions.cs index 51445c86e509..5ff285818355 100644 --- a/tests/Umbraco.Tests.Integration/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/tests/Umbraco.Tests.Integration/DependencyInjection/UmbracoBuilderExtensions.cs @@ -154,15 +154,13 @@ public TestBackgroundIndexRebuilder( IRuntimeState runtimeState, ILogger logger, IExamineManager examineManager, - IEnumerable populators, - ILongRunningOperationService longRunningOperationService) + IEnumerable populators) : base( mainDom, runtimeState, logger, examineManager, - populators, - longRunningOperationService) + populators) { } From ac719d2eb582dea54b8a0c9d549aa17bee1a0a4b Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:46:55 +0900 Subject: [PATCH 02/12] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs b/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs index 3e462b171d22..87629da4dc76 100644 --- a/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs +++ b/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs @@ -13,7 +13,7 @@ namespace Umbraco.Cms.Infrastructure.Examine; internal class ExamineIndexRebuilder : IIndexRebuilder { - // Static because the class is registered as Transient - all instances must share the same state. + // Static so that all instances of this type share the same rebuild task state across the application. private static readonly ConcurrentDictionary RebuildTasks = new(); private readonly IExamineManager _examineManager; From 72b6102c639bdff456e9211e8e03f0ef011efe62 Mon Sep 17 00:00:00 2001 From: Zeegaan Date: Thu, 19 Feb 2026 13:49:34 +0900 Subject: [PATCH 03/12] Remove comment --- src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs b/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs index 3e462b171d22..1ff3e7109549 100644 --- a/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs +++ b/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs @@ -67,7 +67,6 @@ public virtual async Task> RebuildIndexAsync(string if (RebuildTasks.TryGetValue(indexName, out Task? existing) && !existing.IsCompleted) { - _logger.LogWarning("Call was made to RebuildIndex but a rebuild for {IndexName} is already running.", indexName); return Attempt.Fail(IndexRebuildResult.AlreadyRebuilding); } From e42c288507c486dfccbb77a13dcdad8c4f0674dc Mon Sep 17 00:00:00 2001 From: Zeegaan Date: Thu, 19 Feb 2026 14:58:37 +0900 Subject: [PATCH 04/12] Revert back to original with lock --- .../Examine/ExamineIndexRebuilder.cs | 223 ++++++++++-------- .../Services/IndexingRebuilderService.cs | 47 +++- .../UmbracoBuilderExtensions.cs | 9 +- 3 files changed, 171 insertions(+), 108 deletions(-) diff --git a/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs b/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs index 929c7944a889..afdbf5cef2d4 100644 --- a/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs +++ b/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs @@ -1,25 +1,24 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Collections.Concurrent; using Examine; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Runtime; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.HostedServices; using Umbraco.Cms.Infrastructure.Models; namespace Umbraco.Cms.Infrastructure.Examine; internal class ExamineIndexRebuilder : IIndexRebuilder { - // Static so that all instances of this type share the same rebuild task state across the application. - private static readonly ConcurrentDictionary RebuildTasks = new(); - + private readonly IBackgroundTaskQueue _backgroundTaskQueue; private readonly IExamineManager _examineManager; private readonly ILogger _logger; private readonly IMainDom _mainDom; private readonly IEnumerable _populators; + private readonly object _rebuildLocker = new(); private readonly IRuntimeState _runtimeState; /// @@ -30,13 +29,15 @@ public ExamineIndexRebuilder( IRuntimeState runtimeState, ILogger logger, IExamineManager examineManager, - IEnumerable populators) + IEnumerable populators, + IBackgroundTaskQueue backgroundTaskQueue) { _mainDom = mainDom; _runtimeState = runtimeState; _logger = logger; _examineManager = examineManager; _populators = populators; + _backgroundTaskQueue = backgroundTaskQueue; } /// @@ -47,172 +48,204 @@ public bool CanRebuild(string indexName) throw new InvalidOperationException("No index found by name " + indexName); } - return HasRegisteredPopulator(index); + return _populators.Any(x => x.IsRegistered(index)); } /// [Obsolete("Use RebuildIndexAsync() instead. Scheduled for removal in Umbraco 19.")] public virtual void RebuildIndex(string indexName, TimeSpan? delay = null, bool useBackgroundThread = true) - => RebuildIndexAsync(indexName, delay, useBackgroundThread).GetAwaiter().GetResult(); - - /// - public virtual async Task> RebuildIndexAsync(string indexName, TimeSpan? delay = null, bool useBackgroundThread = true) { - delay ??= TimeSpan.Zero; - - if (!CanRun()) + if (delay == null) { - return Attempt.Fail(IndexRebuildResult.NotAllowedToRun); + delay = TimeSpan.Zero; } - if (RebuildTasks.TryGetValue(indexName, out Task? existing) && !existing.IsCompleted) + if (!CanRun()) { - return Attempt.Fail(IndexRebuildResult.AlreadyRebuilding); + return; } if (useBackgroundThread) { - // Do not flow AsyncLocal to the child thread - using (ExecutionContext.SuppressFlow()) - { - Task rebuildTask = Task.Run(() => + _logger.LogInformation("Starting async background thread for rebuilding index {indexName}.", indexName); + + _backgroundTaskQueue.QueueBackgroundWorkItem( + cancellationToken => { - try + // Do not flow AsyncLocal to the child thread + using (ExecutionContext.SuppressFlow()) { - PerformRebuildIndex(indexName, delay.Value, CancellationToken.None); - } - catch (Exception ex) - { - _logger.LogError(ex, "An error occurred while rebuilding index {IndexName} in the background.", indexName); - } - finally - { - RebuildTasks.TryRemove(indexName, out _); + Task.Run(() => RebuildIndex(indexName, delay.Value, cancellationToken)); + + // immediately return so the queue isn't waiting. + return Task.CompletedTask; } }); - RebuildTasks[indexName] = rebuildTask; - } } else { - try - { - PerformRebuildIndex(indexName, delay.Value, CancellationToken.None); - } - finally - { - RebuildTasks.TryRemove(indexName, out _); - } + RebuildIndex(indexName, delay.Value, CancellationToken.None); } + } - return Attempt.Succeed(IndexRebuildResult.Success); + /// + public virtual Task> RebuildIndexAsync(string indexName, TimeSpan? delay = null, bool useBackgroundThread = true) + { + RebuildIndex(indexName, delay, useBackgroundThread); + return Task.FromResult(Attempt.Succeed(IndexRebuildResult.Success)); } /// [Obsolete("Use RebuildIndexesAsync() instead. Scheduled for removal in Umbraco 19.")] public virtual void RebuildIndexes(bool onlyEmptyIndexes, TimeSpan? delay = null, bool useBackgroundThread = true) - => RebuildIndexesAsync(onlyEmptyIndexes, delay, useBackgroundThread).GetAwaiter().GetResult(); - - /// - public virtual async Task> RebuildIndexesAsync(bool onlyEmptyIndexes, TimeSpan? delay = null, bool useBackgroundThread = true) { - delay ??= TimeSpan.Zero; + if (delay == null) + { + delay = TimeSpan.Zero; + } if (!CanRun()) { - return Attempt.Fail(IndexRebuildResult.NotAllowedToRun); + return; } if (useBackgroundThread) { - // Do not flow AsyncLocal to the child thread - using (ExecutionContext.SuppressFlow()) + if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) { - _ = Task.Run(() => + _logger.LogDebug($"Queuing background job for {nameof(RebuildIndexes)}."); + } + + _backgroundTaskQueue.QueueBackgroundWorkItem( + cancellationToken => { - PerformRebuildIndexes(onlyEmptyIndexes, delay.Value, CancellationToken.None); + // Do not flow AsyncLocal to the child thread + using (ExecutionContext.SuppressFlow()) + { + // This is a fire/forget task spawned by the background thread queue (which means we + // don't need to worry about ExecutionContext flowing). + Task.Run(() => RebuildIndexes(onlyEmptyIndexes, delay.Value, cancellationToken)); + + // immediately return so the queue isn't waiting. + return Task.CompletedTask; + } }); - } } else { - PerformRebuildIndexes(onlyEmptyIndexes, delay.Value, CancellationToken.None); + RebuildIndexes(onlyEmptyIndexes, delay.Value, CancellationToken.None); } + } - return Attempt.Succeed(IndexRebuildResult.Success); + /// + public virtual Task> RebuildIndexesAsync(bool onlyEmptyIndexes, TimeSpan? delay = null, bool useBackgroundThread = true) + { + RebuildIndexes(onlyEmptyIndexes, delay, useBackgroundThread); + return Task.FromResult(Attempt.Succeed(IndexRebuildResult.Success)); } /// - public Task IsRebuildingAsync(string indexName) - => Task.FromResult(RebuildTasks.TryGetValue(indexName, out Task? task) && task.IsCompleted is false); + public Task IsRebuildingAsync(string indexName) => Task.FromResult(false); private bool CanRun() => _mainDom.IsMainDom && _runtimeState.Level == RuntimeLevel.Run; - private void PerformRebuildIndex(string indexName, TimeSpan delay, CancellationToken cancellationToken) + private void RebuildIndex(string indexName, TimeSpan delay, CancellationToken cancellationToken) { if (delay > TimeSpan.Zero) { Thread.Sleep(delay); } - if (!_examineManager.TryGetIndex(indexName, out IIndex index)) + try { - throw new InvalidOperationException($"No index found with name {indexName}"); - } + if (!Monitor.TryEnter(_rebuildLocker)) + { + _logger.LogWarning("Call was made to RebuildIndexes but the task runner for rebuilding is already running"); + } + else + { + if (!_examineManager.TryGetIndex(indexName, out IIndex index)) + { + throw new InvalidOperationException($"No index found with name {indexName}"); + } + + index.CreateIndex(); // clear the index + foreach (IIndexPopulator populator in _populators) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } - index.CreateIndex(); // clear the index - foreach (IIndexPopulator populator in _populators) + populator.Populate(index); + } + } + } + finally { - if (cancellationToken.IsCancellationRequested) + if (Monitor.IsEntered(_rebuildLocker)) { - return; + Monitor.Exit(_rebuildLocker); } - - populator.Populate(index); } } - private void PerformRebuildIndexes(bool onlyEmptyIndexes, TimeSpan delay, CancellationToken cancellationToken) + private void RebuildIndexes(bool onlyEmptyIndexes, TimeSpan delay, CancellationToken cancellationToken) { if (delay > TimeSpan.Zero) { Thread.Sleep(delay); } - // If an index exists but it has zero docs we'll consider it empty and rebuild - // Only include indexes that have at least one populator registered, this is to avoid emptying out indexes - // that we have no chance of repopulating, for example our own search package - IIndex[] indexes = (onlyEmptyIndexes - ? _examineManager.Indexes.Where(ShouldRebuild) - : _examineManager.Indexes) - .Where(HasRegisteredPopulator) - .ToArray(); - - if (indexes.Length == 0) - { - return; - } - - foreach (IIndex index in indexes) - { - index.CreateIndex(); // clear the index - } - - // run each populator over the indexes - foreach (IIndexPopulator populator in _populators) + try { - if (cancellationToken.IsCancellationRequested) + if (!Monitor.TryEnter(_rebuildLocker)) { - return; + _logger.LogWarning($"Call was made to {nameof(RebuildIndexes)} but the task runner for rebuilding is already running"); } - - try + else { - populator.Populate(indexes); + // If an index exists but it has zero docs we'll consider it empty and rebuild + IIndex[] indexes = (onlyEmptyIndexes + ? _examineManager.Indexes.Where(ShouldRebuild) + : _examineManager.Indexes) + .Where(HasRegisteredPopulator) + .ToArray(); + + if (indexes.Length == 0) + { + return; + } + + foreach (IIndex index in indexes) + { + index.CreateIndex(); // clear the index + } + + // run each populator over the indexes + foreach (IIndexPopulator populator in _populators) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + try + { + populator.Populate(indexes); + } + catch (Exception e) + { + _logger.LogError(e, "Index populating failed for populator {Populator}", populator.GetType()); + } + } } - catch (Exception e) + } + finally + { + if (Monitor.IsEntered(_rebuildLocker)) { - _logger.LogError(e, "Index populating failed for populator {Populator}", populator.GetType()); + Monitor.Exit(_rebuildLocker); } } } diff --git a/src/Umbraco.Infrastructure/Services/IndexingRebuilderService.cs b/src/Umbraco.Infrastructure/Services/IndexingRebuilderService.cs index 00cc9d7d022f..7c67f9e415dc 100644 --- a/src/Umbraco.Infrastructure/Services/IndexingRebuilderService.cs +++ b/src/Umbraco.Infrastructure/Services/IndexingRebuilderService.cs @@ -1,34 +1,40 @@ using Examine; using Microsoft.Extensions.Logging; -using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Infrastructure.Examine; -using Umbraco.Cms.Infrastructure.Models; namespace Umbraco.Cms.Infrastructure.Services; /// public class IndexingRebuilderService : IIndexingRebuilderService { + private const string IsRebuildingIndexRuntimeCacheKeyPrefix = "temp_indexing_op_"; + + private readonly IAppPolicyCache _runtimeCache; private readonly IIndexRebuilder _indexRebuilder; private readonly ILogger _logger; public IndexingRebuilderService( + AppCaches runtimeCache, IIndexRebuilder indexRebuilder, ILogger logger) { _indexRebuilder = indexRebuilder; _logger = logger; + _runtimeCache = runtimeCache.RuntimeCache; } - [Obsolete("Use the non-obsolete constructor instead. Scheduled for removal in Umbraco 19.")] + [Obsolete("Use the constructor that accepts AppCaches. Scheduled for removal in Umbraco 19.")] public IndexingRebuilderService( - AppCaches runtimeCache, IIndexRebuilder indexRebuilder, ILogger logger) + : this( + StaticServiceProvider.Instance.GetRequiredService(), + indexRebuilder, + logger) { - _indexRebuilder = indexRebuilder; - _logger = logger; } /// @@ -44,11 +50,13 @@ public async Task TryRebuildAsync(IIndex index, string indexName) { try { - Attempt attempt = await _indexRebuilder.RebuildIndexAsync(indexName); - return attempt.Success; + Set(indexName); + await _indexRebuilder.RebuildIndexAsync(indexName); + return true; } catch (Exception exception) { + Clear(indexName); _logger.LogError(exception, "An error occurred rebuilding index"); return false; } @@ -57,9 +65,28 @@ public async Task TryRebuildAsync(IIndex index, string indexName) /// [Obsolete("Use IsRebuildingAsync() instead. Scheduled for removal in Umbraco 19.")] public bool IsRebuilding(string indexName) - => IsRebuildingAsync(indexName).GetAwaiter().GetResult(); + { + var cacheKey = IsRebuildingIndexRuntimeCacheKeyPrefix + indexName; + return _runtimeCache.Get(cacheKey) is not null; + } /// public Task IsRebuildingAsync(string indexName) - => _indexRebuilder.IsRebuildingAsync(indexName); + { + return Task.FromResult(IsRebuilding(indexName)); + } + + private void Set(string indexName) + { + var cacheKey = IsRebuildingIndexRuntimeCacheKeyPrefix + indexName; + + // put temp val in cache which is used as a rudimentary way to know when the indexing is done + _runtimeCache.Insert(cacheKey, () => "tempValue", TimeSpan.FromMinutes(5)); + } + + private void Clear(string indexName) + { + var cacheKey = IsRebuildingIndexRuntimeCacheKeyPrefix + indexName; + _runtimeCache.Clear(cacheKey); + } } diff --git a/tests/Umbraco.Tests.Integration/DependencyInjection/UmbracoBuilderExtensions.cs b/tests/Umbraco.Tests.Integration/DependencyInjection/UmbracoBuilderExtensions.cs index 5ff285818355..dc75c856f2df 100644 --- a/tests/Umbraco.Tests.Integration/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/tests/Umbraco.Tests.Integration/DependencyInjection/UmbracoBuilderExtensions.cs @@ -20,8 +20,9 @@ using Umbraco.Cms.Core.Runtime; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Core.HostedServices; using Umbraco.Cms.Infrastructure.Examine; -using Umbraco.Cms.Infrastructure.HostedServices; +using Umbraco.Cms.Infrastructure.Services; using Umbraco.Cms.Infrastructure.PublishedCache; using Umbraco.Cms.Persistence.EFCore.Locking; using Umbraco.Cms.Persistence.EFCore.Scoping; @@ -154,13 +155,15 @@ public TestBackgroundIndexRebuilder( IRuntimeState runtimeState, ILogger logger, IExamineManager examineManager, - IEnumerable populators) + IEnumerable populators, + IBackgroundTaskQueue backgroundTaskQueue) : base( mainDom, runtimeState, logger, examineManager, - populators) + populators, + backgroundTaskQueue) { } From 1296ea2683338e1f3b08518ad01b07228ef3e923 Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:38:22 +0900 Subject: [PATCH 05/12] Apply suggestion from @Zeegaan --- src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs b/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs index afdbf5cef2d4..3994e170d65c 100644 --- a/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs +++ b/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs @@ -48,7 +48,7 @@ public bool CanRebuild(string indexName) throw new InvalidOperationException("No index found by name " + indexName); } - return _populators.Any(x => x.IsRegistered(index)); + return HasRegisteredPopulator(index); } /// From 2164695fa5a9a97e2072e9ef9393fef662bbd5d5 Mon Sep 17 00:00:00 2001 From: Zeegaan Date: Thu, 19 Feb 2026 16:15:10 +0900 Subject: [PATCH 06/12] Remove unused --- src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs b/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs index 3994e170d65c..adba7335753a 100644 --- a/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs +++ b/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs @@ -144,9 +144,6 @@ public virtual Task> RebuildIndexesAsync(bool onlyEm return Task.FromResult(Attempt.Succeed(IndexRebuildResult.Success)); } - /// - public Task IsRebuildingAsync(string indexName) => Task.FromResult(false); - private bool CanRun() => _mainDom.IsMainDom && _runtimeState.Level == RuntimeLevel.Run; private void RebuildIndex(string indexName, TimeSpan delay, CancellationToken cancellationToken) From 54e3d73c0e3aa03b156c02eb3a355551301167a7 Mon Sep 17 00:00:00 2001 From: Zeegaan Date: Thu, 19 Feb 2026 16:27:37 +0900 Subject: [PATCH 07/12] Adress review comments --- .../Examine/ExamineIndexRebuilder.cs | 20 +++++++++++++++++++ .../Services/IndexingRebuilderService.cs | 11 +++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs b/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs index adba7335753a..fc4d06a1daa5 100644 --- a/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs +++ b/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs @@ -91,6 +91,21 @@ public virtual void RebuildIndex(string indexName, TimeSpan? delay = null, bool /// public virtual Task> RebuildIndexAsync(string indexName, TimeSpan? delay = null, bool useBackgroundThread = true) { + if (!CanRun()) + { + return Task.FromResult(Attempt.Fail(IndexRebuildResult.NotAllowedToRun)); + } + + if (!_examineManager.TryGetIndex(indexName, out IIndex index)) + { + return Task.FromResult(Attempt.Fail(IndexRebuildResult.Unknown)); + } + + if (!HasRegisteredPopulator(index)) + { + return Task.FromResult(Attempt.Fail(IndexRebuildResult.Unknown)); + } + RebuildIndex(indexName, delay, useBackgroundThread); return Task.FromResult(Attempt.Succeed(IndexRebuildResult.Success)); } @@ -140,6 +155,11 @@ public virtual void RebuildIndexes(bool onlyEmptyIndexes, TimeSpan? delay = null /// public virtual Task> RebuildIndexesAsync(bool onlyEmptyIndexes, TimeSpan? delay = null, bool useBackgroundThread = true) { + if (!CanRun()) + { + return Task.FromResult(Attempt.Fail(IndexRebuildResult.NotAllowedToRun)); + } + RebuildIndexes(onlyEmptyIndexes, delay, useBackgroundThread); return Task.FromResult(Attempt.Succeed(IndexRebuildResult.Success)); } diff --git a/src/Umbraco.Infrastructure/Services/IndexingRebuilderService.cs b/src/Umbraco.Infrastructure/Services/IndexingRebuilderService.cs index 7c67f9e415dc..9ba4800d0bc6 100644 --- a/src/Umbraco.Infrastructure/Services/IndexingRebuilderService.cs +++ b/src/Umbraco.Infrastructure/Services/IndexingRebuilderService.cs @@ -1,9 +1,11 @@ using Examine; using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Infrastructure.Examine; +using Umbraco.Cms.Infrastructure.Models; namespace Umbraco.Cms.Infrastructure.Services; @@ -51,7 +53,14 @@ public async Task TryRebuildAsync(IIndex index, string indexName) try { Set(indexName); - await _indexRebuilder.RebuildIndexAsync(indexName); + Attempt result = await _indexRebuilder.RebuildIndexAsync(indexName); + + if (result.Success is false) + { + Clear(indexName); + return false; + } + return true; } catch (Exception exception) From e5c5bbccf4e43c71027ad1ae668d37b20c02f42d Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Fri, 20 Feb 2026 16:26:12 +0100 Subject: [PATCH 08/12] Improve in-memory rebuild tracking for index rebuilder. --- .../UmbracoBuilder.Examine.cs | 2 - .../Examine/ExamineIndexRebuilder.cs | 148 +++++++++++------- .../Services/IndexingRebuilderService.cs | 53 +------ 3 files changed, 96 insertions(+), 107 deletions(-) diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Examine.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Examine.cs index 80ea4fd2ff00..1498ff556e5d 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Examine.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Examine.cs @@ -72,8 +72,6 @@ public static IUmbracoBuilder AddExamine(this IUmbracoBuilder builder) builder.Services.AddUnique(); - builder.Services.AddTransient(); - builder.AddNotificationHandler(); builder.AddNotificationHandler(); builder.AddNotificationHandler(); diff --git a/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs b/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs index fc4d06a1daa5..58785bc508db 100644 --- a/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs +++ b/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs @@ -1,6 +1,7 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System.Collections.Concurrent; using Examine; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core; @@ -13,12 +14,14 @@ namespace Umbraco.Cms.Infrastructure.Examine; internal class ExamineIndexRebuilder : IIndexRebuilder { + private const string RebuildAllKey = "__rebuild_all__"; + private readonly IBackgroundTaskQueue _backgroundTaskQueue; + private readonly ConcurrentDictionary _rebuilding = new(); private readonly IExamineManager _examineManager; private readonly ILogger _logger; private readonly IMainDom _mainDom; private readonly IEnumerable _populators; - private readonly object _rebuildLocker = new(); private readonly IRuntimeState _runtimeState; /// @@ -106,7 +109,25 @@ public virtual Task> RebuildIndexAsync(string indexN return Task.FromResult(Attempt.Fail(IndexRebuildResult.Unknown)); } - RebuildIndex(indexName, delay, useBackgroundThread); + if (useBackgroundThread) + { + _logger.LogInformation("Starting async background thread for rebuilding index {indexName}.", indexName); + + _backgroundTaskQueue.QueueBackgroundWorkItem(async cancellationToken => + { + if (delay > TimeSpan.Zero) + { + await Task.Delay(delay.Value, cancellationToken); + } + + RebuildIndex(indexName, TimeSpan.Zero, cancellationToken); + }); + } + else + { + RebuildIndex(indexName, delay ?? TimeSpan.Zero, CancellationToken.None); + } + return Task.FromResult(Attempt.Succeed(IndexRebuildResult.Success)); } @@ -160,10 +181,35 @@ public virtual Task> RebuildIndexesAsync(bool onlyEm return Task.FromResult(Attempt.Fail(IndexRebuildResult.NotAllowedToRun)); } - RebuildIndexes(onlyEmptyIndexes, delay, useBackgroundThread); + if (useBackgroundThread) + { + if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) + { + _logger.LogDebug($"Queuing background job for {nameof(RebuildIndexes)}."); + } + + _backgroundTaskQueue.QueueBackgroundWorkItem(async cancellationToken => + { + if (delay > TimeSpan.Zero) + { + await Task.Delay(delay.Value, cancellationToken); + } + + RebuildIndexes(onlyEmptyIndexes, TimeSpan.Zero, cancellationToken); + }); + } + else + { + RebuildIndexes(onlyEmptyIndexes, delay ?? TimeSpan.Zero, CancellationToken.None); + } + return Task.FromResult(Attempt.Succeed(IndexRebuildResult.Success)); } + /// + public Task IsRebuildingAsync(string indexName) + => Task.FromResult(_rebuilding.ContainsKey(indexName) || _rebuilding.ContainsKey(RebuildAllKey)); + private bool CanRun() => _mainDom.IsMainDom && _runtimeState.Level == RuntimeLevel.Run; private void RebuildIndex(string indexName, TimeSpan delay, CancellationToken cancellationToken) @@ -173,37 +219,33 @@ private void RebuildIndex(string indexName, TimeSpan delay, CancellationToken ca Thread.Sleep(delay); } + if (!_rebuilding.TryAdd(indexName, 0)) + { + _logger.LogWarning("Call was made to RebuildIndex but a rebuild for {IndexName} is already running", indexName); + return; + } + try { - if (!Monitor.TryEnter(_rebuildLocker)) + if (!_examineManager.TryGetIndex(indexName, out IIndex index)) { - _logger.LogWarning("Call was made to RebuildIndexes but the task runner for rebuilding is already running"); + throw new InvalidOperationException($"No index found with name {indexName}"); } - else + + index.CreateIndex(); // clear the index + foreach (IIndexPopulator populator in _populators) { - if (!_examineManager.TryGetIndex(indexName, out IIndex index)) + if (cancellationToken.IsCancellationRequested) { - throw new InvalidOperationException($"No index found with name {indexName}"); + return; } - index.CreateIndex(); // clear the index - foreach (IIndexPopulator populator in _populators) - { - if (cancellationToken.IsCancellationRequested) - { - return; - } - - populator.Populate(index); - } + populator.Populate(index); } } finally { - if (Monitor.IsEntered(_rebuildLocker)) - { - Monitor.Exit(_rebuildLocker); - } + _rebuilding.TryRemove(indexName, out _); } } @@ -214,56 +256,52 @@ private void RebuildIndexes(bool onlyEmptyIndexes, TimeSpan delay, CancellationT Thread.Sleep(delay); } + if (!_rebuilding.TryAdd(RebuildAllKey, 0)) + { + _logger.LogWarning($"Call was made to {nameof(RebuildIndexes)} but the task runner for rebuilding is already running"); + return; + } + try { - if (!Monitor.TryEnter(_rebuildLocker)) + // If an index exists but it has zero docs we'll consider it empty and rebuild + IIndex[] indexes = (onlyEmptyIndexes + ? _examineManager.Indexes.Where(ShouldRebuild) + : _examineManager.Indexes) + .Where(HasRegisteredPopulator) + .ToArray(); + + if (indexes.Length == 0) + { + return; + } + + foreach (IIndex index in indexes) { - _logger.LogWarning($"Call was made to {nameof(RebuildIndexes)} but the task runner for rebuilding is already running"); + index.CreateIndex(); // clear the index } - else + + // run each populator over the indexes + foreach (IIndexPopulator populator in _populators) { - // If an index exists but it has zero docs we'll consider it empty and rebuild - IIndex[] indexes = (onlyEmptyIndexes - ? _examineManager.Indexes.Where(ShouldRebuild) - : _examineManager.Indexes) - .Where(HasRegisteredPopulator) - .ToArray(); - - if (indexes.Length == 0) + if (cancellationToken.IsCancellationRequested) { return; } - foreach (IIndex index in indexes) + try { - index.CreateIndex(); // clear the index + populator.Populate(indexes); } - - // run each populator over the indexes - foreach (IIndexPopulator populator in _populators) + catch (Exception e) { - if (cancellationToken.IsCancellationRequested) - { - return; - } - - try - { - populator.Populate(indexes); - } - catch (Exception e) - { - _logger.LogError(e, "Index populating failed for populator {Populator}", populator.GetType()); - } + _logger.LogError(e, "Index populating failed for populator {Populator}", populator.GetType()); } } } finally { - if (Monitor.IsEntered(_rebuildLocker)) - { - Monitor.Exit(_rebuildLocker); - } + _rebuilding.TryRemove(RebuildAllKey, out _); } } diff --git a/src/Umbraco.Infrastructure/Services/IndexingRebuilderService.cs b/src/Umbraco.Infrastructure/Services/IndexingRebuilderService.cs index 9ba4800d0bc6..a886807902d0 100644 --- a/src/Umbraco.Infrastructure/Services/IndexingRebuilderService.cs +++ b/src/Umbraco.Infrastructure/Services/IndexingRebuilderService.cs @@ -1,9 +1,6 @@ using Examine; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Cache; -using Microsoft.Extensions.DependencyInjection; -using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Infrastructure.Examine; using Umbraco.Cms.Infrastructure.Models; @@ -12,31 +9,15 @@ namespace Umbraco.Cms.Infrastructure.Services; /// public class IndexingRebuilderService : IIndexingRebuilderService { - private const string IsRebuildingIndexRuntimeCacheKeyPrefix = "temp_indexing_op_"; - - private readonly IAppPolicyCache _runtimeCache; private readonly IIndexRebuilder _indexRebuilder; private readonly ILogger _logger; public IndexingRebuilderService( - AppCaches runtimeCache, IIndexRebuilder indexRebuilder, ILogger logger) { _indexRebuilder = indexRebuilder; _logger = logger; - _runtimeCache = runtimeCache.RuntimeCache; - } - - [Obsolete("Use the constructor that accepts AppCaches. Scheduled for removal in Umbraco 19.")] - public IndexingRebuilderService( - IIndexRebuilder indexRebuilder, - ILogger logger) - : this( - StaticServiceProvider.Instance.GetRequiredService(), - indexRebuilder, - logger) - { } /// @@ -52,20 +33,11 @@ public async Task TryRebuildAsync(IIndex index, string indexName) { try { - Set(indexName); Attempt result = await _indexRebuilder.RebuildIndexAsync(indexName); - - if (result.Success is false) - { - Clear(indexName); - return false; - } - - return true; + return result.Success; } catch (Exception exception) { - Clear(indexName); _logger.LogError(exception, "An error occurred rebuilding index"); return false; } @@ -74,28 +46,9 @@ public async Task TryRebuildAsync(IIndex index, string indexName) /// [Obsolete("Use IsRebuildingAsync() instead. Scheduled for removal in Umbraco 19.")] public bool IsRebuilding(string indexName) - { - var cacheKey = IsRebuildingIndexRuntimeCacheKeyPrefix + indexName; - return _runtimeCache.Get(cacheKey) is not null; - } + => IsRebuildingAsync(indexName).GetAwaiter().GetResult(); /// public Task IsRebuildingAsync(string indexName) - { - return Task.FromResult(IsRebuilding(indexName)); - } - - private void Set(string indexName) - { - var cacheKey = IsRebuildingIndexRuntimeCacheKeyPrefix + indexName; - - // put temp val in cache which is used as a rudimentary way to know when the indexing is done - _runtimeCache.Insert(cacheKey, () => "tempValue", TimeSpan.FromMinutes(5)); - } - - private void Clear(string indexName) - { - var cacheKey = IsRebuildingIndexRuntimeCacheKeyPrefix + indexName; - _runtimeCache.Clear(cacheKey); - } + => _indexRebuilder.IsRebuildingAsync(indexName); } From f2211d51cbe960b3d6f5ed990a18cb65ce8cae60 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Fri, 20 Feb 2026 17:38:55 +0100 Subject: [PATCH 09/12] Add cross-server rebuild status tracking via ILongRunningOperationService. --- .../Indexer/AllIndexerController.cs | 23 +++-- .../Indexer/DetailsIndexerController.cs | 6 +- .../Services/IndexingRebuilderService.cs | 93 +++++++++++++++++-- .../IndexPresentationFactoryTests.cs | 8 +- 4 files changed, 111 insertions(+), 19 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Indexer/AllIndexerController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Indexer/AllIndexerController.cs index a1df07b14260..37f08dbccc10 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Indexer/AllIndexerController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Indexer/AllIndexerController.cs @@ -32,16 +32,27 @@ public AllIndexerController( [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] [EndpointSummary("Gets a collection of indexers.")] [EndpointDescription("Gets a collection of configured search indexers in the Umbraco installation.")] - public Task> All( + public async Task> All( CancellationToken cancellationToken, int skip = 0, int take = 100) { - IndexResponseModel[] indexes = _examineManager.Indexes - .Select(_indexPresentationFactory.Create) - .OrderBy(indexModel => indexModel.Name.TrimEnd("Indexer")).ToArray(); + var indexes = new List(); - var viewModel = new PagedViewModel { Items = indexes.Skip(skip).Take(take), Total = indexes.Length }; - return Task.FromResult(viewModel); + foreach (IIndex index in _examineManager.Indexes) + { + indexes.Add(await _indexPresentationFactory.CreateAsync(index)); + } + + indexes.Sort((a, b) => + string.Compare(a.Name.TrimEnd("Indexer"), b.Name.TrimEnd("Indexer"), StringComparison.Ordinal)); + + var viewModel = new PagedViewModel + { + Items = indexes.Skip(skip).Take(take), + Total = indexes.Count, + }; + + return viewModel; } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Indexer/DetailsIndexerController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Indexer/DetailsIndexerController.cs index 889c9345d66e..9ec00194fe7e 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Indexer/DetailsIndexerController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Indexer/DetailsIndexerController.cs @@ -37,11 +37,11 @@ public DetailsIndexerController( [ProducesResponseType(typeof(IndexResponseModel), StatusCodes.Status200OK)] [EndpointSummary("Gets indexer details.")] [EndpointDescription("Gets detailed information about the indexer identified by the provided name.")] - public Task> Details(CancellationToken cancellationToken, string indexName) + public async Task> Details(CancellationToken cancellationToken, string indexName) { if (_examineManager.TryGetIndex(indexName, out IIndex? index)) { - return Task.FromResult>(_indexPresentationFactory.Create(index)); + return await _indexPresentationFactory.CreateAsync(index); } var invalidModelProblem = new ProblemDetails @@ -52,6 +52,6 @@ public DetailsIndexerController( Type = "Error", }; - return Task.FromResult>(NotFound(invalidModelProblem)); + return NotFound(invalidModelProblem); } } diff --git a/src/Umbraco.Infrastructure/Services/IndexingRebuilderService.cs b/src/Umbraco.Infrastructure/Services/IndexingRebuilderService.cs index a886807902d0..ffa39bf13a35 100644 --- a/src/Umbraco.Infrastructure/Services/IndexingRebuilderService.cs +++ b/src/Umbraco.Infrastructure/Services/IndexingRebuilderService.cs @@ -1,6 +1,12 @@ using Examine; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Core.Sync; using Umbraco.Cms.Infrastructure.Examine; using Umbraco.Cms.Infrastructure.Models; @@ -9,17 +15,42 @@ namespace Umbraco.Cms.Infrastructure.Services; /// public class IndexingRebuilderService : IIndexingRebuilderService { + private const string OperationType = "ExamineIndexRebuild"; + private readonly IIndexRebuilder _indexRebuilder; private readonly ILogger _logger; + private readonly ILongRunningOperationService _longRunningOperationService; + private readonly IServerRoleAccessor _serverRoleAccessor; public IndexingRebuilderService( IIndexRebuilder indexRebuilder, - ILogger logger) + ILogger logger, + ILongRunningOperationService longRunningOperationService, + IServerRoleAccessor serverRoleAccessor) { _indexRebuilder = indexRebuilder; _logger = logger; + _longRunningOperationService = longRunningOperationService; + _serverRoleAccessor = serverRoleAccessor; + } + + [Obsolete("Use the constructor with all parameters. Scheduled for removal in Umbraco 19.")] + public IndexingRebuilderService( + IIndexRebuilder indexRebuilder, + ILogger logger) + : this( + indexRebuilder, + logger, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService()) + { } + // Only use database tracking on servers with write access. + // Subscriber servers have read-only DB access and don't serve the backoffice. + private bool UseDatabaseOperationTracking => + _serverRoleAccessor.CurrentServerRole is ServerRole.Single or ServerRole.SchedulingPublisher; + /// public bool CanRebuild(string indexName) => _indexRebuilder.CanRebuild(indexName); @@ -33,12 +64,34 @@ public async Task TryRebuildAsync(IIndex index, string indexName) { try { - Attempt result = await _indexRebuilder.RebuildIndexAsync(indexName); - return result.Success; + if (UseDatabaseOperationTracking is false) + { + // Subscriber/Unknown servers: delegate directly without operation tracking. + Attempt result = await _indexRebuilder.RebuildIndexAsync(indexName); + return result.Success; + } + + Attempt enqueueResult = + await _longRunningOperationService.RunAsync( + OperationType, + async ct => + { + // useBackgroundThread: false because ILongRunningOperationService already handles backgrounding. + await _indexRebuilder.RebuildIndexAsync(indexName, useBackgroundThread: false); + }, + allowConcurrentExecution: false); + + if (enqueueResult.Status == LongRunningOperationEnqueueStatus.AlreadyRunning) + { + _logger.LogWarning("Index rebuild for {IndexName} is already running", indexName); + return false; + } + + return enqueueResult.Success; } catch (Exception exception) { - _logger.LogError(exception, "An error occurred rebuilding index"); + _logger.LogError(exception, "An error occurred rebuilding index {IndexName}", indexName); return false; } } @@ -49,6 +102,34 @@ public bool IsRebuilding(string indexName) => IsRebuildingAsync(indexName).GetAwaiter().GetResult(); /// - public Task IsRebuildingAsync(string indexName) - => _indexRebuilder.IsRebuildingAsync(indexName); + public async Task IsRebuildingAsync(string indexName) + { + // Check local in-memory state first (fast path — covers this instance). + if (await _indexRebuilder.IsRebuildingAsync(indexName)) + { + return true; + } + + if (UseDatabaseOperationTracking is false) + { + return false; + } + + // Check database for cross-server visibility (load-balanced backoffice). + try + { + PagedModel activeOps = + await _longRunningOperationService.GetByTypeAsync( + OperationType, + skip: 0, + take: 0); + + return activeOps.Total > 0; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to check long-running operation status for index rebuild; falling back to local state only"); + return false; + } + } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Factories/IndexPresentationFactoryTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Factories/IndexPresentationFactoryTests.cs index 87c5ae7b5dec..9bb0d1443b65 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Factories/IndexPresentationFactoryTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Factories/IndexPresentationFactoryTests.cs @@ -14,7 +14,7 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Management.Factories; public class IndexPresentationFactoryTests { [Test] - public void Create_Should_Set_HealthStatusMessage_On_Diagnostics_Failure() + public async Task Create_Should_Set_HealthStatusMessage_On_Diagnostics_Failure() { var indexDiagnosticsFailureMessage = "something is wrong"; // arrange @@ -46,8 +46,8 @@ public void Create_Should_Set_HealthStatusMessage_On_Diagnostics_Failure() var indexRebuilderServiceMock = new Mock(); indexRebuilderServiceMock - .Setup(rebuilder => rebuilder.IsRebuilding(It.IsAny())) - .Returns(false); + .Setup(rebuilder => rebuilder.IsRebuildingAsync(It.IsAny())) + .ReturnsAsync(false); var factory = new IndexPresentationFactory( indexDiagnosticsFactoryMock.Object, @@ -57,7 +57,7 @@ public void Create_Should_Set_HealthStatusMessage_On_Diagnostics_Failure() // act - var responseModel = factory.Create(indexMock.Object); + var responseModel = await factory.CreateAsync(indexMock.Object); // assert Assert.AreEqual(indexDiagnosticsFailureMessage, responseModel.HealthStatus.Message); From 7fdfc232c4b8e36c54c2471c72317ac531884d03 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Fri, 20 Feb 2026 17:47:06 +0100 Subject: [PATCH 10/12] Ensure index is used in operations, to allow rebuild of different indexes concurrently. --- .../Services/IndexingRebuilderService.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Infrastructure/Services/IndexingRebuilderService.cs b/src/Umbraco.Infrastructure/Services/IndexingRebuilderService.cs index ffa39bf13a35..83905633ea8a 100644 --- a/src/Umbraco.Infrastructure/Services/IndexingRebuilderService.cs +++ b/src/Umbraco.Infrastructure/Services/IndexingRebuilderService.cs @@ -15,7 +15,7 @@ namespace Umbraco.Cms.Infrastructure.Services; /// public class IndexingRebuilderService : IIndexingRebuilderService { - private const string OperationType = "ExamineIndexRebuild"; + private const string OperationTypePrefix = "ExamineIndexRebuild"; private readonly IIndexRebuilder _indexRebuilder; private readonly ILogger _logger; @@ -46,6 +46,8 @@ public IndexingRebuilderService( { } + private static string GetOperationType(string indexName) => $"{OperationTypePrefix}:{indexName}"; + // Only use database tracking on servers with write access. // Subscriber servers have read-only DB access and don't serve the backoffice. private bool UseDatabaseOperationTracking => @@ -73,7 +75,7 @@ public async Task TryRebuildAsync(IIndex index, string indexName) Attempt enqueueResult = await _longRunningOperationService.RunAsync( - OperationType, + GetOperationType(indexName), async ct => { // useBackgroundThread: false because ILongRunningOperationService already handles backgrounding. @@ -120,7 +122,7 @@ public async Task IsRebuildingAsync(string indexName) { PagedModel activeOps = await _longRunningOperationService.GetByTypeAsync( - OperationType, + GetOperationType(indexName), skip: 0, take: 0); From 7aee451b6138c7c9513493c92f78234e24a2528a Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Fri, 20 Feb 2026 17:53:55 +0100 Subject: [PATCH 11/12] Use Task.Delay. --- .../Examine/ExamineIndexRebuilder.cs | 60 +++++++------------ 1 file changed, 23 insertions(+), 37 deletions(-) diff --git a/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs b/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs index 58785bc508db..4eb91dcc3ab8 100644 --- a/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs +++ b/src/Umbraco.Infrastructure/Examine/ExamineIndexRebuilder.cs @@ -78,7 +78,7 @@ public virtual void RebuildIndex(string indexName, TimeSpan? delay = null, bool // Do not flow AsyncLocal to the child thread using (ExecutionContext.SuppressFlow()) { - Task.Run(() => RebuildIndex(indexName, delay.Value, cancellationToken)); + Task.Run(() => RebuildIndexCoreAsync(indexName, delay.Value, cancellationToken)); // immediately return so the queue isn't waiting. return Task.CompletedTask; @@ -87,48 +87,41 @@ public virtual void RebuildIndex(string indexName, TimeSpan? delay = null, bool } else { - RebuildIndex(indexName, delay.Value, CancellationToken.None); + RebuildIndexCoreAsync(indexName, delay.Value, CancellationToken.None).GetAwaiter().GetResult(); } } /// - public virtual Task> RebuildIndexAsync(string indexName, TimeSpan? delay = null, bool useBackgroundThread = true) + public virtual async Task> RebuildIndexAsync(string indexName, TimeSpan? delay = null, bool useBackgroundThread = true) { if (!CanRun()) { - return Task.FromResult(Attempt.Fail(IndexRebuildResult.NotAllowedToRun)); + return Attempt.Fail(IndexRebuildResult.NotAllowedToRun); } if (!_examineManager.TryGetIndex(indexName, out IIndex index)) { - return Task.FromResult(Attempt.Fail(IndexRebuildResult.Unknown)); + return Attempt.Fail(IndexRebuildResult.Unknown); } if (!HasRegisteredPopulator(index)) { - return Task.FromResult(Attempt.Fail(IndexRebuildResult.Unknown)); + return Attempt.Fail(IndexRebuildResult.Unknown); } if (useBackgroundThread) { _logger.LogInformation("Starting async background thread for rebuilding index {indexName}.", indexName); - _backgroundTaskQueue.QueueBackgroundWorkItem(async cancellationToken => - { - if (delay > TimeSpan.Zero) - { - await Task.Delay(delay.Value, cancellationToken); - } - - RebuildIndex(indexName, TimeSpan.Zero, cancellationToken); - }); + _backgroundTaskQueue.QueueBackgroundWorkItem( + ct => RebuildIndexCoreAsync(indexName, delay ?? TimeSpan.Zero, ct)); } else { - RebuildIndex(indexName, delay ?? TimeSpan.Zero, CancellationToken.None); + await RebuildIndexCoreAsync(indexName, delay ?? TimeSpan.Zero, CancellationToken.None); } - return Task.FromResult(Attempt.Succeed(IndexRebuildResult.Success)); + return Attempt.Succeed(IndexRebuildResult.Success); } /// @@ -160,7 +153,7 @@ public virtual void RebuildIndexes(bool onlyEmptyIndexes, TimeSpan? delay = null { // This is a fire/forget task spawned by the background thread queue (which means we // don't need to worry about ExecutionContext flowing). - Task.Run(() => RebuildIndexes(onlyEmptyIndexes, delay.Value, cancellationToken)); + Task.Run(() => RebuildIndexesCoreAsync(onlyEmptyIndexes, delay.Value, cancellationToken)); // immediately return so the queue isn't waiting. return Task.CompletedTask; @@ -169,41 +162,34 @@ public virtual void RebuildIndexes(bool onlyEmptyIndexes, TimeSpan? delay = null } else { - RebuildIndexes(onlyEmptyIndexes, delay.Value, CancellationToken.None); + RebuildIndexesCoreAsync(onlyEmptyIndexes, delay.Value, CancellationToken.None).GetAwaiter().GetResult(); } } /// - public virtual Task> RebuildIndexesAsync(bool onlyEmptyIndexes, TimeSpan? delay = null, bool useBackgroundThread = true) + public virtual async Task> RebuildIndexesAsync(bool onlyEmptyIndexes, TimeSpan? delay = null, bool useBackgroundThread = true) { if (!CanRun()) { - return Task.FromResult(Attempt.Fail(IndexRebuildResult.NotAllowedToRun)); + return Attempt.Fail(IndexRebuildResult.NotAllowedToRun); } if (useBackgroundThread) { - if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) + if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug($"Queuing background job for {nameof(RebuildIndexes)}."); } - _backgroundTaskQueue.QueueBackgroundWorkItem(async cancellationToken => - { - if (delay > TimeSpan.Zero) - { - await Task.Delay(delay.Value, cancellationToken); - } - - RebuildIndexes(onlyEmptyIndexes, TimeSpan.Zero, cancellationToken); - }); + _backgroundTaskQueue.QueueBackgroundWorkItem( + ct => RebuildIndexesCoreAsync(onlyEmptyIndexes, delay ?? TimeSpan.Zero, ct)); } else { - RebuildIndexes(onlyEmptyIndexes, delay ?? TimeSpan.Zero, CancellationToken.None); + await RebuildIndexesCoreAsync(onlyEmptyIndexes, delay ?? TimeSpan.Zero, CancellationToken.None); } - return Task.FromResult(Attempt.Succeed(IndexRebuildResult.Success)); + return Attempt.Succeed(IndexRebuildResult.Success); } /// @@ -212,11 +198,11 @@ public Task IsRebuildingAsync(string indexName) private bool CanRun() => _mainDom.IsMainDom && _runtimeState.Level == RuntimeLevel.Run; - private void RebuildIndex(string indexName, TimeSpan delay, CancellationToken cancellationToken) + private async Task RebuildIndexCoreAsync(string indexName, TimeSpan delay, CancellationToken cancellationToken) { if (delay > TimeSpan.Zero) { - Thread.Sleep(delay); + await Task.Delay(delay, cancellationToken); } if (!_rebuilding.TryAdd(indexName, 0)) @@ -249,11 +235,11 @@ private void RebuildIndex(string indexName, TimeSpan delay, CancellationToken ca } } - private void RebuildIndexes(bool onlyEmptyIndexes, TimeSpan delay, CancellationToken cancellationToken) + private async Task RebuildIndexesCoreAsync(bool onlyEmptyIndexes, TimeSpan delay, CancellationToken cancellationToken) { if (delay > TimeSpan.Zero) { - Thread.Sleep(delay); + await Task.Delay(delay, cancellationToken); } if (!_rebuilding.TryAdd(RebuildAllKey, 0)) From d05f496f00b05844a60242e0bca8790f1a8b25d2 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Sun, 22 Feb 2026 10:03:12 +0100 Subject: [PATCH 12/12] Resolve breaking change in constructor. --- .../SearchManagementBuilderExtensions.cs | 14 +++++++++++++- .../Services/IndexingRebuilderService.cs | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/SearchManagementBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/SearchManagementBuilderExtensions.cs index 5852907b8820..f9317f04d02b 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/SearchManagementBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/SearchManagementBuilderExtensions.cs @@ -1,5 +1,8 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; using Umbraco.Cms.Infrastructure.Examine; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.Services; @@ -13,7 +16,16 @@ internal static IUmbracoBuilder AddSearchManagement(this IUmbracoBuilder builder { // Add examine service builder.Services.AddTransient(); - builder.Services.AddTransient(); + + // TODO (V19): Revert to simple AddTransient() + // when the obsolete constructors in IndexingRebuilderService are removed. + // The explicit factory is needed to avoid ambiguous constructor resolution. + builder.Services.AddTransient(sp => + new IndexingRebuilderService( + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService())); // Add factories builder.Services.AddTransient(); diff --git a/src/Umbraco.Infrastructure/Services/IndexingRebuilderService.cs b/src/Umbraco.Infrastructure/Services/IndexingRebuilderService.cs index 83905633ea8a..1f0d5b975576 100644 --- a/src/Umbraco.Infrastructure/Services/IndexingRebuilderService.cs +++ b/src/Umbraco.Infrastructure/Services/IndexingRebuilderService.cs @@ -1,7 +1,9 @@ +using System.Diagnostics.CodeAnalysis; using Examine; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; @@ -22,6 +24,7 @@ public class IndexingRebuilderService : IIndexingRebuilderService private readonly ILongRunningOperationService _longRunningOperationService; private readonly IServerRoleAccessor _serverRoleAccessor; + [ActivatorUtilitiesConstructor] public IndexingRebuilderService( IIndexRebuilder indexRebuilder, ILogger logger, @@ -34,6 +37,19 @@ public IndexingRebuilderService( _serverRoleAccessor = serverRoleAccessor; } + [Obsolete("Use the constructor with all parameters. Scheduled for removal in Umbraco 19.")] + public IndexingRebuilderService( + AppCaches appCaches, + IIndexRebuilder indexRebuilder, + ILogger logger) + : this( + indexRebuilder, + logger, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService()) + { + } + [Obsolete("Use the constructor with all parameters. Scheduled for removal in Umbraco 19.")] public IndexingRebuilderService( IIndexRebuilder indexRebuilder,