From 712053dafc76503665507d7d0282466e8f065044 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Tue, 20 Oct 2026 16:59:29 +0200 Subject: [PATCH] Use Lucene for new and rebuilt indexes --- .../IndexSetupTests.cs | 124 +++++++++++++++++- src/ServiceControl.RavenDB/IndexDeployment.cs | 108 ++++++++++----- 2 files changed, 196 insertions(+), 36 deletions(-) diff --git a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/IndexSetupTests.cs b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/IndexSetupTests.cs index 034a759c7e..e88806aa6a 100644 --- a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/IndexSetupTests.cs +++ b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/IndexSetupTests.cs @@ -10,6 +10,8 @@ namespace ServiceControl.Audit.Persistence.Tests; using Raven.Client.Documents.Operations.Indexes; using Raven.Client.Exceptions; using Raven.Client.Exceptions.Documents.Indexes; +using Raven.Client.ServerWide.Operations; +using Raven.Client.ServerWide.Operations.Configuration; using ServiceControl.RavenDB; [TestFixture] @@ -70,7 +72,7 @@ public async Task Free_text_search_index_can_be_opted_out_from() } [Test] - public async Task New_indexes_should_be_created_with_lucene() + public async Task New_index_should_inherit_lucene_from_a_lucene_database() { var index = new FailedAuditImportIndex(); @@ -79,9 +81,27 @@ public async Task New_indexes_should_be_created_with_lucene() await DatabaseSetup.CreateIndexes(configuration.DocumentStore, true, TestTimeoutCancellationToken); var definition = await configuration.DocumentStore.Maintenance.SendAsync(new GetIndexOperation(index.IndexName), TestTimeoutCancellationToken); + var stats = await configuration.DocumentStore.Maintenance.SendAsync(new GetIndexStatisticsOperation(index.IndexName), TestTimeoutCancellationToken); - // The search engine is set on the index, not inherited. It then also applies to databases that default to Corax. - Assert.That(definition.Configuration, Does.ContainKey(IndexDeployment.StaticSearchEngineTypeKey).WithValue(SearchEngineType.Lucene.ToString())); + Assert.That(stats.SearchEngineType, Is.EqualTo(SearchEngineType.Lucene)); + Assert.That(definition.Configuration, Does.Not.ContainKey(IndexDeployment.StaticSearchEngineTypeKey), "The database is set to Lucene, so the index inherits it"); + } + + [Test] + public async Task New_index_in_a_corax_database_should_be_pinned_to_lucene() + { + var index = new FailedAuditImportIndex(); + + await configuration.DocumentStore.Maintenance.SendAsync(new DeleteIndexOperation(index.IndexName), TestTimeoutCancellationToken); + await SetDatabaseSearchEngine(SearchEngineType.Corax); + + await DatabaseSetup.CreateIndexes(configuration.DocumentStore, true, TestTimeoutCancellationToken); + + var definition = await configuration.DocumentStore.Maintenance.SendAsync(new GetIndexOperation(index.IndexName), TestTimeoutCancellationToken); + var stats = await configuration.DocumentStore.Maintenance.SendAsync(new GetIndexStatisticsOperation(index.IndexName), TestTimeoutCancellationToken); + + Assert.That(stats.SearchEngineType, Is.EqualTo(SearchEngineType.Lucene)); + Assert.That(definition.Configuration, Does.ContainKey(IndexDeployment.StaticSearchEngineTypeKey).WithValue(nameof(SearchEngineType.Lucene)), "The database is set to Corax, so Lucene is set on the index"); } [TestCase(true, TestName = "Search engine set in the index definition through SearchEngineType should take precedence over the one on the server")] @@ -169,6 +189,65 @@ public async Task Pending_replacement_using_the_database_default_should_be_disca } } + [Test] + public async Task Changed_index_without_a_search_engine_setting_in_a_corax_database_should_be_pinned_to_lucene() + { + // The index runs on Lucene, the database default, without a setting of its own: the state after an operator migrated + // the whole database and reset the indexes. + var statsBefore = await PutIndexWithoutSearchEngineSetting(customize: true); + + Assert.That(statsBefore.SearchEngineType, Is.EqualTo(SearchEngineType.Lucene)); + + // Versions 6.20.0 to 6.21.2 then set the database default back to Corax. + await SetDatabaseSearchEngine(SearchEngineType.Corax); + + await DatabaseSetup.CreateIndexes(configuration.DocumentStore, true, TestTimeoutCancellationToken); + + var statsAfter = await WaitForIndexDefinitionUpdate(statsBefore); + var definitionAfter = await configuration.DocumentStore.Maintenance.SendAsync(new GetIndexOperation(statsBefore.Name), TestTimeoutCancellationToken); + + Assert.That(definitionAfter.Fields, Does.Not.ContainKey(CustomizedField), "The definition change should be applied"); + Assert.That(statsAfter.SearchEngineType, Is.EqualTo(SearchEngineType.Lucene), "The rebuilt index should use Lucene, not the database default"); + Assert.That(definitionAfter.Configuration, Does.ContainKey(IndexDeployment.StaticSearchEngineTypeKey).WithValue(nameof(SearchEngineType.Lucene))); + } + + [Test] + public async Task Changed_corax_index_without_a_search_engine_setting_in_a_lucene_database_should_inherit_lucene() + { + // A Corax index without a setting of its own, the state of an existing database before any migration. + await SetDatabaseSearchEngine(SearchEngineType.Corax); + var statsBefore = await PutIndexWithoutSearchEngineSetting(customize: true); + + Assert.That(statsBefore.SearchEngineType, Is.EqualTo(SearchEngineType.Corax)); + + // The operator sets the database to Lucene. The next definition change moves the index to Lucene. + await SetDatabaseSearchEngine(SearchEngineType.Lucene); + + await DatabaseSetup.CreateIndexes(configuration.DocumentStore, true, TestTimeoutCancellationToken); + + var statsAfter = await WaitForIndexDefinitionUpdate(statsBefore); + var definitionAfter = await configuration.DocumentStore.Maintenance.SendAsync(new GetIndexOperation(statsBefore.Name), TestTimeoutCancellationToken); + + Assert.That(statsAfter.SearchEngineType, Is.EqualTo(SearchEngineType.Lucene)); + Assert.That(definitionAfter.Configuration, Does.Not.ContainKey(IndexDeployment.StaticSearchEngineTypeKey), "The database is set to Lucene, so the rebuilt index inherits it"); + } + + [Test] + public async Task Unchanged_index_without_a_search_engine_setting_should_not_be_rebuilt_on_setup() + { + var statsBefore = await PutIndexWithoutSearchEngineSetting(customize: false); + + await DatabaseSetup.CreateIndexes(configuration.DocumentStore, true, TestTimeoutCancellationToken); + + var replacement = await configuration.DocumentStore.Maintenance.SendAsync(new GetIndexOperation(Constants.Documents.Indexing.SideBySideIndexNamePrefix + statsBefore.Name), TestTimeoutCancellationToken); + var statsAfter = await configuration.DocumentStore.Maintenance.SendAsync(new GetIndexStatisticsOperation(statsBefore.Name), TestTimeoutCancellationToken); + var definitionAfter = await configuration.DocumentStore.Maintenance.SendAsync(new GetIndexOperation(statsBefore.Name), TestTimeoutCancellationToken); + + Assert.That(replacement, Is.Null, "Adding a search engine setting to an unchanged index would make RavenDB rebuild it"); + Assert.That(statsAfter.CreatedTimestamp, Is.EqualTo(statsBefore.CreatedTimestamp)); + Assert.That(definitionAfter.Configuration, Does.Not.ContainKey(IndexDeployment.StaticSearchEngineTypeKey)); + } + [Test] public async Task Indexes_should_not_be_reset_on_setup_when_locked_as_ignore() { @@ -211,6 +290,45 @@ async Task PutCustomizedIndex(SearchEngineType searchEngineType, Ind return await WaitForIndexDefinitionUpdate(statsBefore); } + // Simulates an index that inherits the search engine from the database, for example after a reset in RavenDB Studio. + async Task PutIndexWithoutSearchEngineSetting(bool customize) + { + var index = new MessagesViewIndexWithFullTextSearch { Conventions = configuration.DocumentStore.Conventions }; + var definition = index.CreateIndexDefinition(); + definition.Name = index.IndexName; + + if (customize) + { + definition.Fields[CustomizedField] = new IndexFieldOptions { Storage = FieldStorage.Yes }; + } + + var statsBefore = await configuration.DocumentStore.Maintenance.SendAsync(new GetIndexStatisticsOperation(index.IndexName), TestTimeoutCancellationToken); + var definitionBefore = await configuration.DocumentStore.Maintenance.SendAsync(new GetIndexOperation(index.IndexName), TestTimeoutCancellationToken); + + await configuration.DocumentStore.Maintenance.SendAsync(new PutIndexesOperation(definition), TestTimeoutCancellationToken); + + // In a Lucene database setup creates the index without a setting of its own. The put is then a no-op and there is no update to wait for. + var stats = definitionBefore.Compare(definition) == IndexDefinitionCompareDifferences.None ? statsBefore : await WaitForIndexDefinitionUpdate(statsBefore); + var storedDefinition = await configuration.DocumentStore.Maintenance.SendAsync(new GetIndexOperation(index.IndexName), TestTimeoutCancellationToken); + + Assert.That(storedDefinition.Configuration, Does.Not.ContainKey(IndexDeployment.StaticSearchEngineTypeKey)); + + return stats; + } + + async Task SetDatabaseSearchEngine(SearchEngineType searchEngineType) + { + var store = configuration.DocumentStore; + var record = await store.Maintenance.Server.SendAsync(new GetDatabaseRecordOperation(store.Database), TestTimeoutCancellationToken); + + record.Settings["Indexing.Auto.SearchEngineType"] = searchEngineType.ToString(); + record.Settings["Indexing.Static.SearchEngineType"] = searchEngineType.ToString(); + + await store.Maintenance.ForDatabase(store.Database).SendAsync(new PutDatabaseSettingsOperation(store.Database, record.Settings), TestTimeoutCancellationToken); + await store.Maintenance.Server.SendAsync(new ToggleDatabasesStateOperation(store.Database, true), TestTimeoutCancellationToken); + await store.Maintenance.Server.SendAsync(new ToggleDatabasesStateOperation(store.Database, false), TestTimeoutCancellationToken); + } + async Task UpdateIndex(IAbstractIndexCreationTask index) { var statsBefore = await configuration.DocumentStore.Maintenance.SendAsync(new GetIndexStatisticsOperation(index.IndexName), TestTimeoutCancellationToken); diff --git a/src/ServiceControl.RavenDB/IndexDeployment.cs b/src/ServiceControl.RavenDB/IndexDeployment.cs index 38188f075c..25b4f69e01 100644 --- a/src/ServiceControl.RavenDB/IndexDeployment.cs +++ b/src/ServiceControl.RavenDB/IndexDeployment.cs @@ -7,6 +7,7 @@ namespace ServiceControl.RavenDB using Raven.Client.Documents; using Raven.Client.Documents.Indexes; using Raven.Client.Documents.Operations.Indexes; + using Raven.Client.ServerWide.Operations; using ServiceControl.Infrastructure; public static class IndexDeployment @@ -24,64 +25,105 @@ public static async Task CreateIndexesAsync(IEnumerable d.Name, StringComparer.OrdinalIgnoreCase); + var databaseDefault = await GetDatabaseSearchEngineType(store, cancellationToken); + var databaseDefaultIsLucene = string.Equals(databaseDefault, nameof(SearchEngineType.Lucene), StringComparison.OrdinalIgnoreCase); + foreach (var index in indexList) { + var replacementName = Constants.Documents.Indexing.SideBySideIndexNamePrefix + index.IndexName; + existingByName.TryGetValue(index.IndexName, out var existing); + existingByName.TryGetValue(replacementName, out var pendingReplacement); + + var changes = existing == null ? IndexDefinitionCompareDifferences.None : RebuildChanges(existing, index, store); + var changed = changes != IndexDefinitionCompareDifferences.None; + // 1. A search engine set in our index definition always wins. It can be set through SearchEngineType or Configuration. - if (index.SearchEngineType.HasValue - || (index.Configuration.TryGetValue(StaticSearchEngineTypeKey, out var configuredSearchEngineType) && !string.IsNullOrEmpty(configuredSearchEngineType))) + if (index.SearchEngineType.HasValue) { + Logger.LogInformation("Index {IndexName} uses the {SearchEngineType} search engine set in its definition", index.IndexName, index.SearchEngineType); continue; } - var replacementName = Constants.Documents.Indexing.SideBySideIndexNamePrefix + index.IndexName; + if (index.Configuration.TryGetValue(StaticSearchEngineTypeKey, out var definedSearchEngineType) && !string.IsNullOrEmpty(definedSearchEngineType)) + { + Logger.LogInformation("Index {IndexName} uses the {SearchEngineType} search engine set in its definition", index.IndexName, definedSearchEngineType); + continue; + } - // 2. Keep the search engine set on the index on the server. The definitions then match and RavenDB does not - // rebuild the index. A pending replacement is checked first. When the operator changes the search engine, - // the replacement holds the latest choice. ServiceControl 6.20.0 and 6.21.0 also created replacements when - // they reset a migrated index at start-up. Those replacements have no search engine. The search engine of - // the original index is then used. The definition matches the original index again and RavenDB discards - // the replacement. - if (TryGetSearchEngineType(existingByName, replacementName, out var searchEngineType) - || TryGetSearchEngineType(existingByName, index.IndexName, out searchEngineType)) + // 2. Keep a search engine set on the index on the server, for example by an operator in RavenDB Studio. The + // definitions then match and RavenDB does not rebuild the index. A pending replacement is checked first: + // when the operator changes the search engine, the replacement holds the latest choice. A replacement + // without a search engine, created by 6.20.0 and 6.21.0 at start-up, is discarded by RavenDB once the + // definition matches the original index again. + if (TryGetSearchEngineType(pendingReplacement, out var searchEngineType) || TryGetSearchEngineType(existing, out searchEngineType)) { index.Configuration[StaticSearchEngineTypeKey] = searchEngineType; - Logger.LogInformation("Index {IndexName} keeps the configured {SearchEngineType} search engine", index.IndexName, searchEngineType); + Logger.LogInformation("Index {IndexName} keeps the {SearchEngineType} search engine set on the index", index.IndexName, searchEngineType); + continue; } - // 3. An index that does not exist yet is created with Lucene. Lucene performs better for our workload, also - // in databases that still default to Corax. The search engine is set on the index itself. The index then - // stays on Lucene when the database default changes. - else if (!existingByName.ContainsKey(index.IndexName) && !existingByName.ContainsKey(replacementName)) + + // 3. A new index, or an index that RavenDB rebuilds because the definition changed, uses Lucene, the + // application default. It inherits Lucene when the database is set to Lucene. Otherwise Lucene is set on + // the index, so the index does not follow a database default of Corax. + if (existing == null || changed) { - index.Configuration[StaticSearchEngineTypeKey] = nameof(SearchEngineType.Lucene); - Logger.LogInformation("Index {IndexName} is created with the Lucene search engine", index.IndexName); + var reason = existing == null ? "is created" : $"is rebuilt because its definition changed ({changes})"; + + if (databaseDefaultIsLucene) + { + Logger.LogInformation("Index {IndexName} {Reason} and inherits the Lucene search engine from the database", index.IndexName, reason); + } + else + { + index.Configuration[StaticSearchEngineTypeKey] = nameof(SearchEngineType.Lucene); + Logger.LogInformation("Index {IndexName} {Reason} with the Lucene search engine set on the index, the database setting is {DatabaseDefault}", index.IndexName, reason, databaseDefault ?? "not set"); + } + + continue; } - // 4. An existing index without a search engine of its own continues to use the database default. A search - // engine set now changes the definition and starts the full rebuild that this code prevents. + // 4. An unchanged index without a search engine of its own is left alone. RavenDB treats a search engine added + // to the index configuration as a change and rebuilds the index, also when the engine is the same. + Logger.LogInformation("Index {IndexName} is unchanged and inherits the search engine from the database", index.IndexName); } await IndexCreation.CreateIndexesAsync(indexList, store, null, null, cancellationToken); } - static bool TryGetSearchEngineType(Dictionary definitions, string indexName, out string searchEngineType) + static async Task GetDatabaseSearchEngineType(IDocumentStore store, CancellationToken cancellationToken) + { + var record = await store.Maintenance.Server.SendAsync(new GetDatabaseRecordOperation(store.Database), cancellationToken); + + return record?.Settings != null && record.Settings.TryGetValue(StaticSearchEngineTypeKey, out var value) && !string.IsNullOrEmpty(value) + ? value + : null; + } + + // The differences that make RavenDB build a side-by-side replacement. + const IndexDefinitionCompareDifferences RebuildDifferences = IndexDefinitionCompareDifferences.Maps + | IndexDefinitionCompareDifferences.Reduce + | IndexDefinitionCompareDifferences.Fields + | IndexDefinitionCompareDifferences.AdditionalSources + | IndexDefinitionCompareDifferences.AdditionalAssemblies; + + static IndexDefinitionCompareDifferences RebuildChanges(IndexDefinition existingDefinition, AbstractIndexCreationTask index, IDocumentStore store) + { + index.Conventions ??= store.Conventions; + + return existingDefinition.Compare(index.CreateIndexDefinition()) & RebuildDifferences; + } + + static bool TryGetSearchEngineType(IndexDefinition definition, out string searchEngineType) { searchEngineType = null; - return definitions.TryGetValue(indexName, out var definition) - && definition.Configuration != null + return definition?.Configuration != null && definition.Configuration.TryGetValue(StaticSearchEngineTypeKey, out searchEngineType) && !string.IsNullOrEmpty(searchEngineType); }