From 5668c49cfa18835c9c2822e482a60a43f01800a9 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Fri, 23 Oct 2026 07:13:32 +0200 Subject: [PATCH 1/9] Retry database load timeouts for external RavenDB servers Only the embedded lifecycle retried DatabaseLoadTimeoutException. An instance connected to an external server or container failed to start while the server was still loading the database. Both external lifecycles now retry until the host cancels startup, using a shared helper. --- .../RavenExternalPersistenceLifecycle.cs | 5 +- .../RavenExternalPersistenceLifecycle.cs | 5 +- .../DatabaseLoadRetryTests.cs | 57 +++++++++++++++++++ .../DatabaseLoadRetry.cs | 33 +++++++++++ 4 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 src/ServiceControl.Persistence.Tests.RavenDB/DatabaseLoadRetryTests.cs create mode 100644 src/ServiceControl.RavenDB/DatabaseLoadRetry.cs diff --git a/src/ServiceControl.Audit.Persistence.RavenDB/RavenExternalPersistenceLifecycle.cs b/src/ServiceControl.Audit.Persistence.RavenDB/RavenExternalPersistenceLifecycle.cs index 316128e68e..63a279dd6b 100644 --- a/src/ServiceControl.Audit.Persistence.RavenDB/RavenExternalPersistenceLifecycle.cs +++ b/src/ServiceControl.Audit.Persistence.RavenDB/RavenExternalPersistenceLifecycle.cs @@ -5,11 +5,12 @@ namespace ServiceControl.Audit.Persistence.RavenDB using System; using System.Threading; using System.Threading.Tasks; + using Microsoft.Extensions.Logging; using Raven.Client.Documents; using Raven.Client.Documents.Conventions; using ServiceControl.RavenDB; - sealed class RavenExternalPersistenceLifecycle(DatabaseConfiguration configuration) : IRavenPersistenceLifecycle, IRavenDocumentStoreProvider, IDisposable + sealed class RavenExternalPersistenceLifecycle(DatabaseConfiguration configuration, ILogger logger) : IRavenPersistenceLifecycle, IRavenDocumentStoreProvider, IDisposable { public async ValueTask GetDocumentStore(CancellationToken cancellationToken = default) { @@ -51,7 +52,7 @@ public async Task Initialize(CancellationToken cancellationToken = default) await StartupChecks.EnsureServerVersion(store, cancellationToken); var databaseSetup = new DatabaseSetup(configuration); - await databaseSetup.Execute(store, cancellationToken); + await DatabaseLoadRetry.Execute(token => databaseSetup.Execute(store, token), logger, cancellationToken); } finally { diff --git a/src/ServiceControl.Persistence.RavenDB/RavenExternalPersistenceLifecycle.cs b/src/ServiceControl.Persistence.RavenDB/RavenExternalPersistenceLifecycle.cs index 690d373b5e..fe7a2d0c02 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenExternalPersistenceLifecycle.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenExternalPersistenceLifecycle.cs @@ -5,11 +5,12 @@ namespace ServiceControl.Persistence.RavenDB using System; using System.Threading; using System.Threading.Tasks; + using Microsoft.Extensions.Logging; using Raven.Client.Documents; using Raven.Client.Documents.Conventions; using ServiceControl.RavenDB; - sealed class RavenExternalPersistenceLifecycle(RavenPersisterSettings settings) : IRavenPersistenceLifecycle, IRavenDocumentStoreProvider, IDisposable + sealed class RavenExternalPersistenceLifecycle(RavenPersisterSettings settings, ILogger logger) : IRavenPersistenceLifecycle, IRavenDocumentStoreProvider, IDisposable { public async ValueTask GetDocumentStore(CancellationToken cancellationToken = default) { @@ -51,7 +52,7 @@ public async Task Initialize(CancellationToken cancellationToken = default) await StartupChecks.EnsureServerVersion(store, cancellationToken); var databaseSetup = new DatabaseSetup(settings, store); - await databaseSetup.Execute(cancellationToken).ConfigureAwait(false); + await DatabaseLoadRetry.Execute(databaseSetup.Execute, logger, cancellationToken); } finally { diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/DatabaseLoadRetryTests.cs b/src/ServiceControl.Persistence.Tests.RavenDB/DatabaseLoadRetryTests.cs new file mode 100644 index 0000000000..5d05ce2c0a --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.RavenDB/DatabaseLoadRetryTests.cs @@ -0,0 +1,57 @@ +namespace ServiceControl.Persistence.Tests; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; +using Raven.Client.Exceptions.Database; +using ServiceControl.RavenDB; + +[TestFixture] +public class DatabaseLoadRetryTests +{ + [Test] + public async Task Should_retry_when_database_load_times_out() + { + var attempts = 0; + + await DatabaseLoadRetry.Execute(_ => + { + attempts++; + return attempts < 3 ? Task.FromException(new DatabaseLoadTimeoutException("Database is loading")) : Task.CompletedTask; + }, NullLogger.Instance, TimeSpan.FromMilliseconds(1)); + + Assert.That(attempts, Is.EqualTo(3)); + } + + [Test] + public void Should_not_retry_other_exceptions() + { + var attempts = 0; + + Assert.ThrowsAsync(() => DatabaseLoadRetry.Execute(_ => + { + attempts++; + return Task.FromException(new InvalidOperationException()); + }, NullLogger.Instance, TimeSpan.FromMilliseconds(1))); + + Assert.That(attempts, Is.EqualTo(1)); + } + + [Test] + public void Should_stop_retrying_when_cancelled() + { + using var tokenSource = new CancellationTokenSource(); + var attempts = 0; + + Assert.CatchAsync(() => DatabaseLoadRetry.Execute(_ => + { + attempts++; + tokenSource.Cancel(); + return Task.FromException(new DatabaseLoadTimeoutException("Database is loading")); + }, NullLogger.Instance, TimeSpan.FromMinutes(1), tokenSource.Token)); + + Assert.That(attempts, Is.EqualTo(1)); + } +} diff --git a/src/ServiceControl.RavenDB/DatabaseLoadRetry.cs b/src/ServiceControl.RavenDB/DatabaseLoadRetry.cs new file mode 100644 index 0000000000..dd114c9e0e --- /dev/null +++ b/src/ServiceControl.RavenDB/DatabaseLoadRetry.cs @@ -0,0 +1,33 @@ +namespace ServiceControl.RavenDB +{ + using System.Threading; + using Microsoft.Extensions.Logging; + using Raven.Client.Exceptions.Database; + + public static class DatabaseLoadRetry + { + public static readonly TimeSpan DefaultRetryDelay = TimeSpan.FromMilliseconds(500); + + public static Task Execute(Func action, ILogger logger, CancellationToken cancellationToken = default) => + Execute(action, logger, DefaultRetryDelay, cancellationToken); + + public static async Task Execute(Func action, ILogger logger, TimeSpan retryDelay, CancellationToken cancellationToken = default) + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + await action(cancellationToken); + return; + } + catch (DatabaseLoadTimeoutException e) + { + logger.LogWarning(e, "Connecting to the RavenDB database timed out. Retrying in {RetryDelay}ms...", retryDelay.TotalMilliseconds); + await Task.Delay(retryDelay, cancellationToken); + } + } + } + } +} From 6a83add10270753f04c89ea365868bd64f7abf0d Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Fri, 23 Oct 2026 07:23:05 +0200 Subject: [PATCH 2/9] Publish the shared embedded RavenDB test server only after setup completes GetInstance assigned the static field before the server was reachable and before the database setup finished, while the fast path returned it without taking the semaphore. A test running in parallel could use the server too early and fail with a connection refused error in its setup. --- .../SharedEmbeddedServer.cs | 5 +++-- .../SharedEmbeddedServer.cs | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/SharedEmbeddedServer.cs b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/SharedEmbeddedServer.cs index 43524c8d80..67d3a91045 100644 --- a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/SharedEmbeddedServer.cs +++ b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/SharedEmbeddedServer.cs @@ -43,10 +43,10 @@ public static async Task GetInstance(CancellationToken cancell // TODO: See if more refactoring can be done in configuration classes var embeddedConfig = new EmbeddedDatabaseConfiguration(serverConfig.ServerUrl, databaseConfiguration.Name, serverConfig.DbPath, serverConfig.LogPath, serverConfig.LogsMode); - embeddedDatabase = EmbeddedDatabase.Start(embeddedConfig, new ApplicationLifetime(new NullLogger())); + var server = EmbeddedDatabase.Start(embeddedConfig, new ApplicationLifetime(new NullLogger())); //make sure that the database is up - using var documentStore = await embeddedDatabase.Connect(cancellationToken); + using var documentStore = await server.Connect(cancellationToken); var databaseSetup = new DatabaseSetup(databaseConfiguration); await databaseSetup.Execute(documentStore, cancellationToken); @@ -63,6 +63,7 @@ public static async Task GetInstance(CancellationToken cancell await documentStore.Maintenance.Server.SendAsync(cleanupOperation, CancellationToken.None); } + embeddedDatabase = server; return embeddedDatabase; } finally diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/SharedEmbeddedServer.cs b/src/ServiceControl.Persistence.Tests.RavenDB/SharedEmbeddedServer.cs index 1bcaaf8e59..f35095a9a5 100644 --- a/src/ServiceControl.Persistence.Tests.RavenDB/SharedEmbeddedServer.cs +++ b/src/ServiceControl.Persistence.Tests.RavenDB/SharedEmbeddedServer.cs @@ -47,10 +47,10 @@ public static async Task GetInstance(CancellationToken cancell // TODO: See if more refactoring can be done between this and the RavenPersisterSettings above var embeddedConfig = new EmbeddedDatabaseConfiguration(settings.ServerUrl, RavenPersisterSettings.DatabaseNameDefault, dbPath, logPath, logsMode) { RunInMemory = true }; - embeddedDatabase = EmbeddedDatabase.Start(embeddedConfig, new ApplicationLifetime(new NullLogger())); + var server = EmbeddedDatabase.Start(embeddedConfig, new ApplicationLifetime(new NullLogger())); //make sure that the database is up - using var documentStore = await embeddedDatabase.Connect(cancellationToken); + using var documentStore = await server.Connect(cancellationToken); var databaseSetup = new DatabaseSetup(settings, documentStore); await databaseSetup.Execute(cancellationToken); @@ -67,6 +67,7 @@ public static async Task GetInstance(CancellationToken cancell await documentStore.Maintenance.Server.SendAsync(cleanupOperation, CancellationToken.None); } + embeddedDatabase = server; return embeddedDatabase; } finally From cf432db4e42a973353406be095c5bcd27875b404 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Fri, 23 Oct 2026 07:33:16 +0200 Subject: [PATCH 3/9] Add characterization tests for RavenDB server contracts Pin the persister behavior that a RavenDB upgrade can change without a compile error. Approve the client-generated definition of every primary, throughput, and audit index, including the search engine setting that IndexDeployment applies, so a change that would start a side-by-side rebuild shows up as a diff. Deploy each index on its own to an empty database. Check the JSON shape of /admin/debug/memory/stats and /license/status that both persisters read into hand-written types. Check where the JavaScript patches and bulk inserts set @expires, how ingestion orders and de-duplicates attempts by AttemptedAt, and how a retry confirmation compares times. Read throughput time series back through LicensingDataStore. Run audit queries against Lucene and Corax databases. Check that the embedded server writes log files into the log path. --- ...ts.VerifyGeneratedDefinitions.approved.txt | 121 ++++++++ .../AuditTestData.cs | 57 ++++ .../CleanDatabaseFixture.cs | 55 ++++ .../ExpirationMetadataTests.cs | 71 +++++ .../IndexDefinitionTests.cs | 79 ++++++ .../IndexDeploymentTests.cs | 150 ++++++++++ .../LicenseTest.cs | 34 +++ .../MemoryInformationRetrieverTests.cs | 45 +++ .../RetentionTests.cs | 27 ++ .../SearchEngineQueryTests.cs | 225 +++++++++++++++ ...dex_definitions_are_unchanged.approved.txt | 265 ++++++++++++++++++ .../EmbeddedServerLoggingTests.cs | 38 +++ .../Expiration/ExpiresMetadataTests.cs | 152 ++++++++++ .../IndexDefinitionTests.cs | 115 ++++++++ .../IndexDeploymentTests.cs | 43 +++ .../PrimaryIndexes.cs | 18 ++ .../RavenServerContractTests.cs | 77 +++++ .../RecoverabilityIngestionPatchTests.cs | 101 +++++++ .../ScratchDatabase.cs | 54 ++++ .../SharedEmbeddedServer.cs | 8 +- .../Throughput/ThroughputTimeSeriesTests.cs | 86 ++++++ 21 files changed, 1819 insertions(+), 2 deletions(-) create mode 100644 src/ServiceControl.Audit.Persistence.Tests.RavenDB/ApprovalFiles/IndexDefinitionTests.VerifyGeneratedDefinitions.approved.txt create mode 100644 src/ServiceControl.Audit.Persistence.Tests.RavenDB/AuditTestData.cs create mode 100644 src/ServiceControl.Audit.Persistence.Tests.RavenDB/CleanDatabaseFixture.cs create mode 100644 src/ServiceControl.Audit.Persistence.Tests.RavenDB/ExpirationMetadataTests.cs create mode 100644 src/ServiceControl.Audit.Persistence.Tests.RavenDB/IndexDefinitionTests.cs create mode 100644 src/ServiceControl.Audit.Persistence.Tests.RavenDB/IndexDeploymentTests.cs create mode 100644 src/ServiceControl.Audit.Persistence.Tests.RavenDB/MemoryInformationRetrieverTests.cs create mode 100644 src/ServiceControl.Audit.Persistence.Tests.RavenDB/SearchEngineQueryTests.cs create mode 100644 src/ServiceControl.Persistence.Tests.RavenDB/ApprovalFiles/IndexDefinitionTests.Primary_index_definitions_are_unchanged.approved.txt create mode 100644 src/ServiceControl.Persistence.Tests.RavenDB/EmbeddedServerLoggingTests.cs create mode 100644 src/ServiceControl.Persistence.Tests.RavenDB/Expiration/ExpiresMetadataTests.cs create mode 100644 src/ServiceControl.Persistence.Tests.RavenDB/IndexDefinitionTests.cs create mode 100644 src/ServiceControl.Persistence.Tests.RavenDB/IndexDeploymentTests.cs create mode 100644 src/ServiceControl.Persistence.Tests.RavenDB/PrimaryIndexes.cs create mode 100644 src/ServiceControl.Persistence.Tests.RavenDB/RavenServerContractTests.cs create mode 100644 src/ServiceControl.Persistence.Tests.RavenDB/Recoverability/RecoverabilityIngestionPatchTests.cs create mode 100644 src/ServiceControl.Persistence.Tests.RavenDB/ScratchDatabase.cs create mode 100644 src/ServiceControl.Persistence.Tests.RavenDB/Throughput/ThroughputTimeSeriesTests.cs diff --git a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/ApprovalFiles/IndexDefinitionTests.VerifyGeneratedDefinitions.approved.txt b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/ApprovalFiles/IndexDefinitionTests.VerifyGeneratedDefinitions.approved.txt new file mode 100644 index 0000000000..d14ec4f9cd --- /dev/null +++ b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/ApprovalFiles/IndexDefinitionTests.VerifyGeneratedDefinitions.approved.txt @@ -0,0 +1,121 @@ +Index: FailedAuditImportIndex +Type: Map +SourceType: Documents +Maps: + docs.FailedAuditImports.Select(cc => new { + Id = Id(cc) +}) +Reduce: (none) +Fields: +Configuration: + Indexing.Static.SearchEngineType=Lucene +AdditionalSources: +AdditionalAssemblies: 0 +OutputReduceToCollection: (none) + +Index: MessagesViewIndex +Type: Map +SourceType: Documents +Maps: + docs.ProcessedMessages.Select(message => new { + MessageId = ((string) message.MessageMetadata["MessageId"]), + MessageType = ((string) message.MessageMetadata["MessageType"]), + IsSystemMessage = ((bool) message.MessageMetadata["IsSystemMessage"]), + Status = ((bool) message.MessageMetadata["IsRetried"]) ? 4 : 3, + TimeSent = ((DateTime) message.MessageMetadata["TimeSent"]), + ProcessedAt = message.ProcessedAt, + ReceivingEndpointName = ((message.MessageMetadata["ReceivingEndpoint"])).Name, + SendingEndpointName = ((message.MessageMetadata["SendingEndpoint"])).Name, + CriticalTime = ((TimeSpan ? ) message.MessageMetadata["CriticalTime"]), + ProcessingTime = ((TimeSpan ? ) message.MessageMetadata["ProcessingTime"]), + DeliveryTime = ((TimeSpan ? ) message.MessageMetadata["DeliveryTime"]), + Query = Enumerable.ToArray(DynamicEnumerable.Union(message.MessageMetadata.ToDictionary(e1 => e1.Key, e1 => e1.Value).Select(_ => _.Value.ToString()), new string[] { + String.Join(" ", message.Headers.ToDictionary(e1 => e1.Key, e1 => e1.Value).Select(x => x.Value)) + })), + ConversationId = ((string) message.MessageMetadata["ConversationId"]) +}) +Reduce: (none) +Fields: + Query: Storage=, Indexing=Search, TermVector=, Suggestions=, Analyzer=Lucene.Net.Analysis.Standard.StandardAnalyzer, Lucene.Net, Version=3.0.3.0, Culture=neutral, PublicKeyToken=85089178b9ac3181, HasSpatial=False +Configuration: + Indexing.Static.SearchEngineType=Lucene +AdditionalSources: +AdditionalAssemblies: 0 +OutputReduceToCollection: (none) + +Index: MessagesViewIndexWithFullTextSearch +Type: Map +SourceType: Documents +Maps: + docs.ProcessedMessages.Select(message => new { + MessageId = ((string) message.MessageMetadata["MessageId"]), + MessageType = ((string) message.MessageMetadata["MessageType"]), + IsSystemMessage = ((bool) message.MessageMetadata["IsSystemMessage"]), + Status = ((bool) message.MessageMetadata["IsRetried"]) ? 4 : 3, + TimeSent = ((DateTime) message.MessageMetadata["TimeSent"]), + ProcessedAt = message.ProcessedAt, + ReceivingEndpointName = ((message.MessageMetadata["ReceivingEndpoint"])).Name, + SendingEndpointName = ((message.MessageMetadata["SendingEndpoint"])).Name, + CriticalTime = ((TimeSpan ? ) message.MessageMetadata["CriticalTime"]), + ProcessingTime = ((TimeSpan ? ) message.MessageMetadata["ProcessingTime"]), + DeliveryTime = ((TimeSpan ? ) message.MessageMetadata["DeliveryTime"]), + Query = Enumerable.ToArray(DynamicEnumerable.Union(message.MessageMetadata.ToDictionary(e1 => e1.Key, e1 => e1.Value).Select(_ => _.Value.ToString()), new string[] { + String.Join(" ", message.Headers.ToDictionary(e1 => e1.Key, e1 => e1.Value).Select(x => x.Value)), + this.LoadAttachment(message, "body").GetContentAsString() + })), + ConversationId = ((string) message.MessageMetadata["ConversationId"]) +}) +Reduce: (none) +Fields: + Query: Storage=, Indexing=Search, TermVector=, Suggestions=, Analyzer=Lucene.Net.Analysis.Standard.StandardAnalyzer, Lucene.Net, Version=3.0.3.0, Culture=neutral, PublicKeyToken=85089178b9ac3181, HasSpatial=False +Configuration: + Indexing.Static.SearchEngineType=Lucene +AdditionalSources: +AdditionalAssemblies: 0 +OutputReduceToCollection: (none) + +Index: SagaDetailsIndex +Type: MapReduce +SourceType: Documents +Maps: + docs.SagaHistories.Select(doc => new { + SagaId = doc.SagaId, + Id = doc.SagaId, + SagaType = doc.SagaType, + Changes = doc.Changes +}) + docs.SagaSnapshots.Select(doc => new { + SagaId = doc.SagaId, + Id = doc.SagaId, + SagaType = doc.SagaType, + Changes = new[] { + new { + Endpoint = doc.Endpoint, + FinishTime = doc.FinishTime, + InitiatingMessage = doc.InitiatingMessage, + OutgoingMessages = doc.OutgoingMessages, + StartTime = doc.StartTime, + StateAfterChange = doc.StateAfterChange, + Status = doc.Status + } + } +}) +Reduce: results.GroupBy(result => result.SagaId).Select(g => new { + g = g, + first = DynamicEnumerable.FirstOrDefault(g) +}).Select(this0 => new { + Id = this0.first.SagaId, + SagaId = this0.first.SagaId, + SagaType = this0.first.SagaType, + Changes = Enumerable.ToList(DynamicEnumerable.OrderByDescending(this0.g.SelectMany(x => x.Changes), x0 => x0.FinishTime).Take(50000)) +}) +Fields: + Changes: Storage=, Indexing=No, TermVector=, Suggestions=, Analyzer=(none), HasSpatial=False + SagaId: Storage=, Indexing=Exact, TermVector=, Suggestions=, Analyzer=(none), HasSpatial=False + SagaType: Storage=, Indexing=No, TermVector=, Suggestions=, Analyzer=(none), HasSpatial=False +Configuration: + Indexing.Static.SearchEngineType=Lucene +AdditionalSources: +AdditionalAssemblies: 0 +OutputReduceToCollection: (none) + diff --git a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/AuditTestData.cs b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/AuditTestData.cs new file mode 100644 index 0000000000..1260adfb16 --- /dev/null +++ b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/AuditTestData.cs @@ -0,0 +1,57 @@ +namespace ServiceControl.Audit.Persistence.Tests; + +using System; +using System.Collections.Generic; +using Auditing; +using Monitoring; +using NServiceBus; + +static class AuditTestData +{ + // Carries every metadata entry that the audit indexes read, as the audit ingestion does. + public static ProcessedMessage CreateProcessedMessage( + string messageId = null, + string messageType = "MyMessageType", + string conversationId = null, + string receivingEndpoint = "ReceivingEndpoint", + DateTime? timeSent = null, + TimeSpan? criticalTime = null, + TimeSpan? processingTime = null, + TimeSpan? deliveryTime = null, + DateTimeOffset? processedAt = null) + { + messageId ??= Guid.NewGuid().ToString(); + conversationId ??= Guid.NewGuid().ToString(); + timeSent ??= DateTime.UtcNow; + processedAt ??= DateTimeOffset.UtcNow; + + var metadata = new Dictionary + { + { "MessageId", messageId }, + { "MessageIntent", MessageIntent.Send }, + { "TimeSent", timeSent.Value }, + { "CriticalTime", criticalTime ?? TimeSpan.FromSeconds(5) }, + { "ProcessingTime", processingTime ?? TimeSpan.FromSeconds(1) }, + { "DeliveryTime", deliveryTime ?? TimeSpan.FromSeconds(4) }, + { "IsSystemMessage", false }, + { "MessageType", messageType }, + { "IsRetried", false }, + { "ConversationId", conversationId }, + { "SendingEndpoint", new EndpointDetails { Name = "SendingEndpoint", HostId = Guid.Empty, Host = "host" } }, + { "ReceivingEndpoint", new EndpointDetails { Name = receivingEndpoint, HostId = Guid.Empty, Host = "host" } } + }; + + var headers = new Dictionary + { + { Headers.MessageId, messageId }, + { Headers.ProcessingEndpoint, receivingEndpoint }, + { Headers.MessageIntent, MessageIntent.Send.ToString() }, + { Headers.ConversationId, conversationId }, + { Headers.ProcessingStarted, DateTimeOffsetHelper.ToWireFormattedString(processedAt.Value) }, + { Headers.ProcessingEnded, DateTimeOffsetHelper.ToWireFormattedString(processedAt.Value) }, + { Headers.EnclosedMessageTypes, messageType } + }; + + return new ProcessedMessage(headers, metadata); + } +} diff --git a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/CleanDatabaseFixture.cs b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/CleanDatabaseFixture.cs new file mode 100644 index 0000000000..0c6861fa48 --- /dev/null +++ b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/CleanDatabaseFixture.cs @@ -0,0 +1,55 @@ +namespace ServiceControl.Audit.Persistence.Tests; + +using System; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Raven.Client.Documents; +using Raven.Client.Documents.Conventions; +using Raven.Client.ServerWide; +using Raven.Client.ServerWide.Operations; + +// A new database on the shared embedded server with no settings and no indexes, as opposed to the one DatabaseSetup prepares. +abstract class CleanDatabaseFixture +{ + protected IDocumentStore Store { get; private set; } + + protected CancellationToken TestTimeoutCancellationToken => testCancellationTokenSource.Token; + + [SetUp] + public async Task CreateCleanDatabase() + { + testCancellationTokenSource = new CancellationTokenSource(TestTimeout); + + var embeddedDatabase = await SharedEmbeddedServer.GetInstance(TestContext.CurrentContext.CancellationToken); + databaseName = Guid.NewGuid().ToString(); + + Store = new DocumentStore + { + Urls = [embeddedDatabase.ServerUrl], + Database = databaseName, + Conventions = new DocumentConventions { SaveEnumsAsIntegers = true } + }; + Store.Initialize(); + + await Store.Maintenance.Server.SendAsync(new CreateDatabaseOperation(new DatabaseRecord(databaseName)), TestTimeoutCancellationToken); + } + + [TearDown] + public async Task DeleteCleanDatabase() + { + if (Store != null) + { + await Store.Maintenance.Server.SendAsync(new DeleteDatabasesOperation( + new DeleteDatabasesOperation.Parameters { DatabaseNames = [databaseName], HardDelete = true })); + Store.Dispose(); + } + + testCancellationTokenSource?.Dispose(); + } + + string databaseName; + CancellationTokenSource testCancellationTokenSource; + + static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(60); +} diff --git a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/ExpirationMetadataTests.cs b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/ExpirationMetadataTests.cs new file mode 100644 index 0000000000..a098a44c5e --- /dev/null +++ b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/ExpirationMetadataTests.cs @@ -0,0 +1,71 @@ +namespace ServiceControl.Audit.Persistence.Tests; + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using System.Threading.Tasks; +using Auditing; +using NUnit.Framework; +using Raven.Client; +using SagaAudit; + +[TestFixture] +class ExpirationMetadataTests : PersistenceTestFixture +{ + static readonly TimeSpan RetentionPeriod = TimeSpan.FromDays(7); + + public override Task Setup() + { + SetSettings = s => s.AuditRetentionPeriod = RetentionPeriod; + return base.Setup(); + } + + [Test] + public async Task Processed_message_should_expire_with_its_body_after_the_retention_period() + { + var message = AuditTestData.CreateProcessedMessage(); + + await using (var unitOfWork = await StartAuditUnitOfWork(1)) + { + await unitOfWork.RecordProcessedMessage(message, Encoding.UTF8.GetBytes("The body")); + await unitOfWork.Complete(); + } + + using var session = configuration.DocumentStore.OpenAsyncSession(); + var document = await session.LoadAsync(message.Id, TestContext.CurrentContext.CancellationToken); + var metadata = session.Advanced.GetMetadataFor(document); + + using (Assert.EnterMultipleScope()) + { + AssertExpiresAfterRetentionPeriod(metadata); + Assert.That(await session.Advanced.Attachments.ExistsAsync(message.Id, "body", TestContext.CurrentContext.CancellationToken), Is.True, "The body is an attachment of the message document, so it expires with it"); + } + } + + [Test] + public async Task Saga_snapshot_should_expire_after_the_retention_period() + { + var snapshot = new SagaSnapshot { SagaId = Guid.NewGuid() }; + + await using (var unitOfWork = await StartAuditUnitOfWork(1)) + { + await unitOfWork.RecordSagaSnapshot(snapshot); + await unitOfWork.Complete(); + } + + using var session = configuration.DocumentStore.OpenAsyncSession(); + var document = await session.LoadAsync(snapshot.Id, TestContext.CurrentContext.CancellationToken); + + AssertExpiresAfterRetentionPeriod(session.Advanced.GetMetadataFor(document)); + } + + static void AssertExpiresAfterRetentionPeriod(IDictionary metadata) + { + Assert.That(metadata.TryGetValue(Constants.Documents.Metadata.Expires, out var expiresValue), Is.True, $"{Constants.Documents.Metadata.Expires} is missing from the document metadata"); + + var expires = DateTime.Parse((string)expiresValue, null, DateTimeStyles.RoundtripKind); + + Assert.That(expires, Is.EqualTo(DateTime.UtcNow.Add(RetentionPeriod)).Within(TimeSpan.FromMinutes(5))); + } +} diff --git a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/IndexDefinitionTests.cs b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/IndexDefinitionTests.cs new file mode 100644 index 0000000000..4441582926 --- /dev/null +++ b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/IndexDefinitionTests.cs @@ -0,0 +1,79 @@ +namespace ServiceControl.Audit.Persistence.Tests; + +using System; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using NUnit.Framework; +using Particular.Approvals; +using Persistence.RavenDB.Indexes; +using Raven.Client.Documents.Indexes; +using ServiceControl.RavenDB; +using ServiceControl.SagaAudit; + +[TestFixture] +class IndexDefinitionTests : CleanDatabaseFixture +{ + // A definition that differs from the one on the server makes RavenDB rebuild the index side by side, which takes + // days for the full-text index of a large audit database. Do not approve a change without a plan for the rebuild. + [Test] + public async Task VerifyGeneratedDefinitions() + { + AbstractIndexCreationTask[] indexes = + [ + new FailedAuditImportIndex(), + new MessagesViewIndex(), + new MessagesViewIndexWithFullTextSearch(), + new SagaDetailsIndex() + ]; + + await IndexDeployment.CreateIndexesAsync(indexes, Store, TestTimeoutCancellationToken); + + var description = new StringBuilder(); + + foreach (var index in indexes.OrderBy(i => i.IndexName, StringComparer.Ordinal)) + { + index.Conventions = Store.Conventions; + Describe(description, index.IndexName, index.CreateIndexDefinition()); + } + + Approver.Verify(description.ToString().ReplaceLineEndings()); + } + + static void Describe(StringBuilder description, string indexName, IndexDefinition definition) + { + description.AppendLine($"Index: {indexName}"); + description.AppendLine($"Type: {definition.Type}"); + description.AppendLine($"SourceType: {definition.SourceType}"); + + description.AppendLine("Maps:"); + foreach (var map in definition.Maps.OrderBy(m => m, StringComparer.Ordinal)) + { + description.AppendLine($" {map}"); + } + + description.AppendLine($"Reduce: {definition.Reduce ?? "(none)"}"); + + description.AppendLine("Fields:"); + foreach (var (name, options) in definition.Fields.OrderBy(f => f.Key, StringComparer.Ordinal)) + { + description.AppendLine($" {name}: Storage={options.Storage}, Indexing={options.Indexing}, TermVector={options.TermVector}, Suggestions={options.Suggestions}, Analyzer={options.Analyzer ?? "(none)"}, HasSpatial={options.Spatial != null}"); + } + + description.AppendLine("Configuration:"); + foreach (var (key, value) in definition.Configuration.OrderBy(c => c.Key, StringComparer.Ordinal)) + { + description.AppendLine($" {key}={value}"); + } + + description.AppendLine("AdditionalSources:"); + foreach (var (name, source) in definition.AdditionalSources.OrderBy(s => s.Key, StringComparer.Ordinal)) + { + description.AppendLine($" {name}: {source}"); + } + + description.AppendLine($"AdditionalAssemblies: {definition.AdditionalAssemblies.Count}"); + description.AppendLine($"OutputReduceToCollection: {definition.OutputReduceToCollection ?? "(none)"}"); + description.AppendLine(); + } +} diff --git a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/IndexDeploymentTests.cs b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/IndexDeploymentTests.cs new file mode 100644 index 0000000000..4943a3fefd --- /dev/null +++ b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/IndexDeploymentTests.cs @@ -0,0 +1,150 @@ +namespace ServiceControl.Audit.Persistence.Tests; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Auditing; +using NUnit.Framework; +using Persistence.RavenDB.Indexes; +using Raven.Client.Documents; +using Raven.Client.Documents.Indexes; +using Raven.Client.Documents.Operations.Indexes; +using Raven.Client.Documents.Session; +using ServiceControl.RavenDB; +using ServiceControl.SagaAudit; + +[TestFixture] +class IndexDeploymentTests : CleanDatabaseFixture +{ + const string BodyText = "The zebrafish swims upstream"; + const string BodySearchTerm = "zebrafish"; + const string AnalyzerTypeName = "Lucene.Net.Analysis.Standard.StandardAnalyzer, Lucene.Net, Version=3.0.3.0, Culture=neutral, PublicKeyToken=85089178b9ac3181"; + + [TestCase(typeof(MessagesViewIndexWithFullTextSearch))] + [TestCase(typeof(MessagesViewIndex))] + [TestCase(typeof(SagaDetailsIndex))] + [TestCase(typeof(FailedAuditImportIndex))] + public async Task Index_should_deploy_to_a_clean_database_and_be_queryable(Type indexType) + { + var index = (AbstractIndexCreationTask)Activator.CreateInstance(indexType); + + await IndexDeployment.CreateIndexesAsync([index], Store, TestTimeoutCancellationToken); + + var definition = await Store.Maintenance.SendAsync(new GetIndexOperation(index.IndexName), TestTimeoutCancellationToken); + Assert.That(definition, Is.Not.Null); + + if (index is MessagesViewIndexWithFullTextSearch) + { + await AssertMessagesAreSearchable(index.IndexName, bodyIsSearchable: true); + } + else if (index is MessagesViewIndex) + { + await AssertMessagesAreSearchable(index.IndexName, bodyIsSearchable: false); + } + else if (index is SagaDetailsIndex) + { + await AssertSagaHistoryIsReduced(); + } + else + { + await AssertFailedAuditImportIsIndexed(); + } + + var stats = await Store.Maintenance.SendAsync(new GetIndexStatisticsOperation(index.IndexName), TestTimeoutCancellationToken); + + using (Assert.EnterMultipleScope()) + { + Assert.That(stats.State, Is.EqualTo(IndexState.Normal)); + Assert.That(stats.ErrorsCount, Is.Zero); + Assert.That(stats.SearchEngineType, Is.EqualTo(SearchEngineType.Lucene)); + } + } + + [TestCase(typeof(MessagesViewIndexWithFullTextSearch))] + [TestCase(typeof(MessagesViewIndex))] + public async Task Full_text_indexes_should_use_the_lucene_standard_analyzer_on_the_query_field(Type indexType) + { + var index = (AbstractIndexCreationTask)Activator.CreateInstance(indexType); + + await IndexDeployment.CreateIndexesAsync([index], Store, TestTimeoutCancellationToken); + + var definition = await Store.Maintenance.SendAsync(new GetIndexOperation(index.IndexName), TestTimeoutCancellationToken); + + Assert.That(definition.Fields[nameof(MessagesViewIndex.SortAndFilterOptions.Query)].Analyzer, Is.EqualTo(AnalyzerTypeName)); + } + + async Task AssertMessagesAreSearchable(string indexName, bool bodyIsSearchable) + { + var message = AuditTestData.CreateProcessedMessage(messageId: "MyMessageId"); + message.Id = $"ProcessedMessages-{DateTime.UtcNow.Ticks}-{message.GetProcessingId()}"; + + using (var session = Store.OpenAsyncSession()) + { + await session.StoreAsync(message, message.Id, TestTimeoutCancellationToken); + session.Advanced.Attachments.Store(message.Id, "body", new MemoryStream(Encoding.UTF8.GetBytes(BodyText)), "text/plain"); + await session.SaveChangesAsync(TestTimeoutCancellationToken); + } + + await Store.WaitForIndexingAsync(TestTimeoutCancellationToken); + + using (var session = Store.OpenAsyncSession()) + { + var byMetadata = await Search(session, indexName, "MyMessageId"); + var byBody = await Search(session, indexName, BodySearchTerm); + + using (Assert.EnterMultipleScope()) + { + Assert.That(byMetadata, Has.Count.EqualTo(1), "A search over the message metadata should find the message"); + Assert.That(byBody, Has.Count.EqualTo(bodyIsSearchable ? 1 : 0), "A search over the stored body should find the message only with the full-text index"); + } + } + + static Task> Search(IAsyncDocumentSession session, string indexName, string searchTerm) => + session.Query(indexName) + .Search(x => x.Query, searchTerm) + .ToListAsync(); + } + + async Task AssertSagaHistoryIsReduced() + { + var sagaId = Guid.NewGuid(); + + using (var session = Store.OpenAsyncSession()) + { + await session.StoreAsync(new SagaSnapshot { SagaId = sagaId, SagaType = "MySagaType", FinishTime = DateTime.UtcNow }, TestTimeoutCancellationToken); + await session.StoreAsync(new SagaSnapshot { SagaId = sagaId, SagaType = "MySagaType", FinishTime = DateTime.UtcNow.AddSeconds(1) }, TestTimeoutCancellationToken); + await session.SaveChangesAsync(TestTimeoutCancellationToken); + } + + await Store.WaitForIndexingAsync(TestTimeoutCancellationToken); + + using (var session = Store.OpenAsyncSession()) + { + var history = await session.Query().SingleOrDefaultAsync(x => x.SagaId == sagaId, TestTimeoutCancellationToken); + + Assert.That(history, Is.Not.Null); + Assert.That(history.Changes, Has.Count.EqualTo(2)); + } + } + + async Task AssertFailedAuditImportIsIndexed() + { + using (var session = Store.OpenAsyncSession()) + { + await session.StoreAsync(new FailedAuditImport(), "FailedAuditImports/1", TestTimeoutCancellationToken); + await session.SaveChangesAsync(TestTimeoutCancellationToken); + } + + await Store.WaitForIndexingAsync(TestTimeoutCancellationToken); + + using (var session = Store.OpenAsyncSession()) + { + var count = await session.Query().CountAsync(TestTimeoutCancellationToken); + + Assert.That(count, Is.EqualTo(1)); + } + } +} diff --git a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/LicenseTest.cs b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/LicenseTest.cs index 62d456afa0..dc8bb4c437 100644 --- a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/LicenseTest.cs +++ b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/LicenseTest.cs @@ -4,8 +4,10 @@ using System.Linq; using System.Net.Http; using System.Text; + using System.Text.Json; using System.Threading.Tasks; using NUnit.Framework; + using Persistence.RavenDB; [NonParallelizable] class LicenseTest : PersistenceTestFixture @@ -44,6 +46,38 @@ public async Task EnsureLicenseIsValid() } } + // LicenseStatusCheck deserializes the response into its own type. A renamed property leaves the default value in place, + // so the check waits for the timeout or reports no problem, which is why the names are asserted on the raw response. + [Test] + public async Task License_status_should_contain_the_properties_read_by_the_license_check() + { + var ravenUrl = configuration.DocumentStore.Urls.First(); + + using var http = new HttpClient(); + var jsonText = await http.GetStringAsync(ravenUrl + "/license/status", TestTimeoutCancellationToken); + + using var document = JsonDocument.Parse(jsonText); + var license = document.RootElement; + + using (Assert.EnterMultipleScope()) + { + Assert.That(license.TryGetProperty("Id", out var id), Is.True, "Id is missing from the response"); + Assert.That(id.ValueKind, Is.EqualTo(JsonValueKind.String)); + Assert.That(license.TryGetProperty("LicensedTo", out var licensedTo), Is.True, "LicensedTo is missing from the response"); + Assert.That(licensedTo.ValueKind, Is.EqualTo(JsonValueKind.String)); + Assert.That(license.TryGetProperty("Status", out var status), Is.True, "Status is missing from the response"); + Assert.That(status.ValueKind, Is.EqualTo(JsonValueKind.String)); + Assert.That(license.TryGetProperty("Expired", out var expired), Is.True, "Expired is missing from the response"); + Assert.That(expired.ValueKind, Is.AnyOf(JsonValueKind.True, JsonValueKind.False)); + } + } + + [Test] + public async Task License_status_check_should_accept_the_license_of_the_server() + { + await LicenseStatusCheck.WaitForLicenseOrThrow(configuration.DocumentStore, TestTimeoutCancellationToken); + } + class LicenseDetails { public string Id { get; set; } diff --git a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/MemoryInformationRetrieverTests.cs b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/MemoryInformationRetrieverTests.cs new file mode 100644 index 0000000000..bcaed33521 --- /dev/null +++ b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/MemoryInformationRetrieverTests.cs @@ -0,0 +1,45 @@ +namespace ServiceControl.Audit.Persistence.Tests; + +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Persistence.RavenDB; + +[TestFixture] +class MemoryInformationRetrieverTests : PersistenceTestFixture +{ + // MemoryInformationRetriever deserializes the response into its own types. A renamed property leaves the default + // value in place and the dirty memory check reports no problem, so the names are asserted on the raw response. + [Test] + public async Task Memory_stats_should_contain_the_properties_read_by_the_dirty_memory_check() + { + var requestUrl = configuration.DocumentStore.Urls.First().TrimEnd('/') + "/admin/debug/memory/stats?includeThreads=false&includeMappings=false"; + + using var http = new HttpClient(); + var json = await http.GetStringAsync(requestUrl, TestTimeoutCancellationToken); + + using var document = JsonDocument.Parse(json); + Assert.That(document.RootElement.TryGetProperty("MemoryInformation", out var memoryInformation), Is.True, "MemoryInformation is missing from the response"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(memoryInformation.TryGetProperty("IsHighDirty", out var isHighDirty), Is.True, "MemoryInformation.IsHighDirty is missing from the response"); + Assert.That(isHighDirty.ValueKind, Is.AnyOf(JsonValueKind.True, JsonValueKind.False)); + Assert.That(memoryInformation.TryGetProperty("DirtyMemory", out var dirtyMemory), Is.True, "MemoryInformation.DirtyMemory is missing from the response"); + Assert.That(dirtyMemory.ValueKind, Is.EqualTo(JsonValueKind.String)); + } + } + + [Test] + public async Task Should_retrieve_the_dirty_memory_of_the_server() + { + var retriever = ServiceProvider.GetRequiredService(); + + var (_, dirtyMemory) = await retriever.GetMemoryInformation(TestTimeoutCancellationToken); + + Assert.That(dirtyMemory, Is.Not.Null.And.Not.Empty); + } +} diff --git a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/RetentionTests.cs b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/RetentionTests.cs index 7bdc6f00a3..99eb8718c9 100644 --- a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/RetentionTests.cs +++ b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/RetentionTests.cs @@ -2,6 +2,7 @@ { using System; using System.Collections.Generic; + using System.Text; using System.Threading; using System.Threading.Tasks; using Auditing; @@ -92,6 +93,32 @@ Task> QuerySagaHistory(CancellationToken cancellationTo SagaHistoryStore.QuerySagaHistoryById(sagaId, cancellationToken); } + [Test] + public async Task MessageBodyRetention() + { + await WaitForIndexesToBeBuilt(); + + var message = MakeMessage("MyMessageId"); + + await using (var unitOfWork = await StartAuditUnitOfWork(1)) + { + await unitOfWork.RecordProcessedMessage(message, Encoding.UTF8.GetBytes("The body")); + await unitOfWork.Complete(); + } + + Assert.That(await BodyExists(message.Id, TestContext.CurrentContext.CancellationToken), Is.True); + + var bodyExistsAfterExpiration = await WaitUntil(token => BodyExists(message.Id, token), exists => !exists); + + Assert.That(bodyExistsAfterExpiration, Is.False); + + async Task BodyExists(string documentId, CancellationToken cancellationToken) + { + using var session = configuration.DocumentStore.OpenAsyncSession(); + return await session.Advanced.Attachments.ExistsAsync(documentId, "body", cancellationToken); + } + } + // The retention window starts at ingestion, so building the indexes of the fresh database must // not happen inside it: on a busy runner that alone has taken longer than the window. Task WaitForIndexesToBeBuilt() => configuration.CompleteDBOperation(); diff --git a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/SearchEngineQueryTests.cs b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/SearchEngineQueryTests.cs new file mode 100644 index 0000000000..b08808c2d2 --- /dev/null +++ b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/SearchEngineQueryTests.cs @@ -0,0 +1,225 @@ +namespace ServiceControl.Audit.Persistence.Tests; + +using System; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Auditing.MessagesView; +using NUnit.Framework; +using Persistence.RavenDB; +using Persistence.RavenDB.Indexes; +using Raven.Client.Documents.Indexes; +using Raven.Client.Documents.Operations.Indexes; +using Raven.Client.ServerWide; +using Raven.Client.ServerWide.Operations; +using ServiceControl.Audit.Infrastructure; +using ServiceControl.RavenDB; +using ServiceControl.SagaAudit; + +// Databases created before ServiceControl pinned the engine have no search engine setting, and DatabaseSetup pins them to Corax. +// The Corax fixture reproduces that: the database and its indexes exist, pinned to Corax, before the persistence starts. +[TestFixture(SearchEngineType.Lucene)] +[TestFixture(SearchEngineType.Corax)] +class SearchEngineQueryTests(SearchEngineType searchEngine) : PersistenceTestFixture +{ + static readonly Guid SagaId = Guid.NewGuid(); + static readonly DateTime FirstTimeSent = DateTime.UtcNow.AddMinutes(-10); + + public override async Task Setup() + { + databaseName = Guid.NewGuid().ToString(); + + if (searchEngine == SearchEngineType.Corax) + { + await CreateExistingDatabaseWithCoraxIndexes(); + } + + SetSettings = s => s.PersisterSpecificSettings[RavenPersistenceConfiguration.DatabaseNameKey] = databaseName; + + await base.Setup(); + + await IngestAudits(); + } + + [Test] + public async Task Database_and_indexes_should_use_the_search_engine_under_test() + { + var databaseRecord = await configuration.DocumentStore.Maintenance.Server.SendAsync(new GetDatabaseRecordOperation(databaseName), TestTimeoutCancellationToken); + var indexStats = await configuration.DocumentStore.Maintenance.SendAsync(new GetIndexesStatisticsOperation(), TestTimeoutCancellationToken); + + using (Assert.EnterMultipleScope()) + { + Assert.That(databaseRecord.Settings["Indexing.Static.SearchEngineType"], Is.EqualTo(searchEngine.ToString())); + Assert.That(indexStats, Is.Not.Empty); + Assert.That(indexStats.Select(i => i.SearchEngineType), Has.All.EqualTo(searchEngine)); + } + } + + [Test] + public async Task Message_list_should_be_sorted_by_every_sort_option() + { + (string Sort, string Direction, string ExpectedOrder)[] sortOptions = + [ + ("time_sent", "asc", "A,B,C"), + ("time_sent", "desc", "C,B,A"), + ("message_id", "asc", "A,B,C"), + ("message_id", "desc", "C,B,A"), + ("message_type", "asc", "A,B,C"), + ("message_type", "desc", "C,B,A"), + ("critical_time", "asc", "B,C,A"), + ("critical_time", "desc", "A,C,B"), + ("processing_time", "asc", "C,A,B"), + ("processing_time", "desc", "B,A,C"), + ("delivery_time", "asc", "A,B,C"), + ("delivery_time", "desc", "C,B,A"), + ("processed_at", "asc", "A,B,C"), + ("processed_at", "desc", "C,B,A") + ]; + + using (Assert.EnterMultipleScope()) + { + foreach (var (sort, direction, expectedOrder) in sortOptions) + { + var result = await MessagesViewStore.GetMessages(false, new PagingInfo(), new SortInfo(sort, direction), cancellationToken: TestTimeoutCancellationToken); + + Assert.That(result.Results.Select(m => m.MessageId), Is.EqualTo(MessageIds(expectedOrder)), $"Sorted by {sort} {direction}"); + } + } + } + + [Test] + public async Task Message_list_should_be_paged_after_sorting() + { + var result = await MessagesViewStore.GetMessages(false, new PagingInfo(page: 2, pageSize: 1), new SortInfo("time_sent", "asc"), cancellationToken: TestTimeoutCancellationToken); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result.Results.Select(m => m.MessageId), Is.EqualTo(MessageIds("B"))); + Assert.That(result.QueryStats.TotalCount, Is.EqualTo(3)); + } + } + + [Test] + public async Task Full_text_search_should_find_messages() + { + (string SearchTerm, string ExpectedMessages, string Description)[] searches = + [ + ("zebrafish", "A,C", "A word in the body"), + ("ZEBRAFISH", "A,C", "Case is ignored"), + ("zebra*", "A,C", "A trailing wildcard"), + ("bravo", "B", "A word in the body of one message"), + ("TypeB", "B", "A message type from the metadata"), + ("EndpointB", "B", "An endpoint name from the headers"), + ("no-such-term", "", "No match") + ]; + + using (Assert.EnterMultipleScope()) + { + foreach (var (searchTerm, expectedMessages, description) in searches) + { + var result = await MessagesViewStore.QueryMessages(searchTerm, new PagingInfo(), new SortInfo("time_sent", "asc"), cancellationToken: TestTimeoutCancellationToken); + + Assert.That(result.Results.Select(m => m.MessageId), Is.EqualTo(MessageIds(expectedMessages)), $"Search for '{searchTerm}': {description}"); + } + } + } + + [Test] + public async Task Full_text_search_should_combine_with_the_receiving_endpoint() + { + var endpointA = await MessagesViewStore.QueryMessagesByReceivingEndpointAndKeyword("EndpointA", "zebrafish", new PagingInfo(), new SortInfo("time_sent", "desc"), cancellationToken: TestTimeoutCancellationToken); + var endpointB = await MessagesViewStore.QueryMessagesByReceivingEndpointAndKeyword("EndpointB", "zebrafish", new PagingInfo(), new SortInfo("time_sent", "desc"), cancellationToken: TestTimeoutCancellationToken); + + using (Assert.EnterMultipleScope()) + { + Assert.That(endpointA.Results.Select(m => m.MessageId), Is.EqualTo(MessageIds("C,A"))); + Assert.That(endpointB.Results, Is.Empty); + } + } + + [Test] + public async Task Receiving_endpoint_view_should_list_the_messages_of_the_endpoint() + { + var result = await MessagesViewStore.QueryMessagesByReceivingEndpoint(false, "EndpointA", new PagingInfo(), new SortInfo("critical_time", "asc"), cancellationToken: TestTimeoutCancellationToken); + + Assert.That(result.Results.Select(m => m.MessageId), Is.EqualTo(MessageIds("C,A"))); + } + + [Test] + public async Task Conversation_view_should_list_the_messages_of_the_conversation() + { + var result = await MessagesViewStore.QueryMessagesByConversationId("conversation-1", new PagingInfo(), new SortInfo("time_sent", "desc"), TestTimeoutCancellationToken); + + Assert.That(result.Results.Select(m => m.MessageId), Is.EqualTo(MessageIds("B,A"))); + } + + [Test] + public async Task Audit_counts_should_count_the_processed_messages_of_the_endpoint() + { + var result = await MessagesViewStore.QueryAuditCounts("EndpointA", TestTimeoutCancellationToken); + + Assert.That(result.Results.Sum(c => c.Count), Is.EqualTo(2)); + } + + [Test] + public async Task Saga_view_should_contain_the_state_changes_of_the_saga_newest_first() + { + var result = await SagaHistoryStore.QuerySagaHistoryById(SagaId, TestTimeoutCancellationToken); + + Assert.That(result.Results, Is.Not.Null); + Assert.That(result.Results.Changes.Select(c => c.StateAfterChange), Is.EqualTo(new[] { "second", "first" })); + } + + static string[] MessageIds(string messages) => messages.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(m => $"message-{m.ToLowerInvariant()}").ToArray(); + + async Task CreateExistingDatabaseWithCoraxIndexes() + { + var embeddedDatabase = await SharedEmbeddedServer.GetInstance(); + using var store = await embeddedDatabase.Connect(); + + await store.Maintenance.Server.SendAsync(new CreateDatabaseOperation(new DatabaseRecord(databaseName))); + + AbstractIndexCreationTask[] indexes = [new FailedAuditImportIndex(), new SagaDetailsIndex(), new MessagesViewIndexWithFullTextSearch()]; + + foreach (var index in indexes) + { + index.Configuration[IndexDeployment.StaticSearchEngineTypeKey] = nameof(SearchEngineType.Corax); + await index.ExecuteAsync(store, store.Conventions, databaseName); + } + } + + async Task IngestAudits() + { + await using var unitOfWork = await StartAuditUnitOfWork(3); + + // Each message differs in every sort field, in a different order + await RecordMessage("A", "conversation-1", "EndpointA", 0, criticalTime: 3, processingTime: 2, deliveryTime: 1, "alpha zebrafish"); + await RecordMessage("B", "conversation-1", "EndpointB", 1, criticalTime: 1, processingTime: 3, deliveryTime: 2, "bravo"); + await RecordMessage("C", "conversation-2", "EndpointA", 2, criticalTime: 2, processingTime: 1, deliveryTime: 3, "charlie zebrafish"); + + await unitOfWork.RecordSagaSnapshot(new SagaSnapshot { SagaId = SagaId, SagaType = "MySagaType", Endpoint = "EndpointA", StateAfterChange = "first", Status = SagaStateChangeStatus.New, StartTime = FirstTimeSent, FinishTime = FirstTimeSent.AddSeconds(1) }); + await unitOfWork.RecordSagaSnapshot(new SagaSnapshot { SagaId = SagaId, SagaType = "MySagaType", Endpoint = "EndpointA", StateAfterChange = "second", Status = SagaStateChangeStatus.Updated, StartTime = FirstTimeSent.AddSeconds(2), FinishTime = FirstTimeSent.AddSeconds(3) }); + + await unitOfWork.Complete(); + await configuration.CompleteDBOperation(); + + Task RecordMessage(string name, string conversationId, string endpoint, int minutesAfterFirst, int criticalTime, int processingTime, int deliveryTime, string body) + { + var timeSent = FirstTimeSent.AddMinutes(minutesAfterFirst); + var message = AuditTestData.CreateProcessedMessage( + messageId: $"message-{name.ToLowerInvariant()}", + messageType: $"Type{name}", + conversationId: conversationId, + receivingEndpoint: endpoint, + timeSent: timeSent, + criticalTime: TimeSpan.FromSeconds(criticalTime), + processingTime: TimeSpan.FromSeconds(processingTime), + deliveryTime: TimeSpan.FromSeconds(deliveryTime), + processedAt: new DateTimeOffset(timeSent)); + + return unitOfWork.RecordProcessedMessage(message, Encoding.UTF8.GetBytes(body)); + } + } + + string databaseName; +} diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/ApprovalFiles/IndexDefinitionTests.Primary_index_definitions_are_unchanged.approved.txt b/src/ServiceControl.Persistence.Tests.RavenDB/ApprovalFiles/IndexDefinitionTests.Primary_index_definitions_are_unchanged.approved.txt new file mode 100644 index 0000000000..d8a1484c04 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.RavenDB/ApprovalFiles/IndexDefinitionTests.Primary_index_definitions_are_unchanged.approved.txt @@ -0,0 +1,265 @@ +Index: ArchivedGroupsViewIndex + Type: MapReduce + SourceType: Documents + Map: + docs.FailedMessages.Where(doc => doc.Status == 4).Select(doc => new { + doc = doc, + failureTimes = doc.ProcessingAttempts.Select(x => x.FailureDetails.TimeOfFailure) + }).SelectMany(this0 => this0.doc.FailureGroups, (this0, failureGroup) => new { + Id = failureGroup.Id, + Title = failureGroup.Title, + Count = 1, + First = DynamicEnumerable.Min(this0.failureTimes), + Last = DynamicEnumerable.Max(this0.failureTimes), + Type = failureGroup.Type + }) + Reduce: + results.GroupBy(result => new { + Id = result.Id, + Title = result.Title, + Type = result.Type + }).Select(g => new { + Id = g.Key.Id, + Title = g.Key.Title, + Count = Enumerable.Sum(g, x => ((int) x.Count)), + First = DynamicEnumerable.Min(g, x0 => ((DateTime) x0.First)), + Last = DynamicEnumerable.Max(g, x1 => ((DateTime) x1.Last)), + Type = g.Key.Type + }) + Configuration Indexing.Static.SearchEngineType: Lucene + +Index: CustomChecksIndex + Type: Map + SourceType: Documents + Map: + docs.CustomChecks.Select(cc => new { + Status = cc.Status, + ReportedAt = cc.ReportedAt, + Category = cc.Category, + CustomCheckId = cc.CustomCheckId + }) + Configuration Indexing.Static.SearchEngineType: Lucene + +Index: EndpointIndex + Type: Map + SourceType: Documents + Map: + docs.EndpointDocuments.Select(message => new { + ThroughputSource = message.EndpointId.ThroughputSource, + Name = message.EndpointId.Name + }) + Configuration Indexing.Static.SearchEngineType: Lucene + +Index: FailedErrorImportIndex + Type: Map + SourceType: Documents + Map: + docs.FailedErrorImports.Select(cc => new { + Id = Id(cc) + }) + Configuration Indexing.Static.SearchEngineType: Lucene + +Index: FailedMessageFacetsIndex + Type: Map + SourceType: Documents + Map: + docs.FailedMessages.Where(failure => failure.Status == 1).Select(failure => new { + failure = failure, + t = ((DynamicEnumerable.LastOrDefault(failure.ProcessingAttempts)).MessageMetadata["ReceivingEndpoint"]) + }).Select(this0 => new { + Name = this0.t.Name, + Host = this0.t.Host, + MessageType = (DynamicEnumerable.LastOrDefault(this0.failure.ProcessingAttempts)).MessageMetadata["MessageType"] + }) + Field Host: {"Indexing":"Exact"} + Field MessageType: {"Indexing":"Exact"} + Field Name: {"Indexing":"Exact"} + Configuration Indexing.Static.SearchEngineType: Lucene + +Index: FailedMessageRetries/ByBatch + Type: Map + SourceType: Documents + Map: + docs.FailedMessageRetries.Select(doc => new { + RetryBatchId = doc.RetryBatchId + }) + Configuration Indexing.Static.SearchEngineType: Lucene + +Index: FailedMessageViewIndex + Type: Map + SourceType: Documents + Map: + docs.FailedMessages.Select(message => new { + message = message, + processingAttemptsLast = DynamicEnumerable.LastOrDefault(message.ProcessingAttempts) + }).Select(this0 => new { + MessageId = this0.processingAttemptsLast.MessageMetadata["MessageId"], + MessageType = this0.processingAttemptsLast.MessageMetadata["MessageType"], + Status = this0.message.Status, + TimeSent = ((DateTime) this0.processingAttemptsLast.MessageMetadata["TimeSent"]), + ReceivingEndpointName = ((this0.processingAttemptsLast.MessageMetadata["ReceivingEndpoint"])).Name, + QueueAddress = this0.processingAttemptsLast.FailureDetails.AddressOfFailingEndpoint, + TimeOfFailure = this0.processingAttemptsLast.FailureDetails.TimeOfFailure, + LastModified = (this.MetadataFor(this0.message).Value < DateTime > ("@last-modified")).Ticks + }) + Configuration Indexing.Static.SearchEngineType: Lucene + +Index: FailedMessages/ByGroup + Type: Map + SourceType: Documents + Map: + docs.FailedMessages.Select(doc => new { + doc = doc, + processingAttemptsLast = DynamicEnumerable.LastOrDefault(doc.ProcessingAttempts) + }).SelectMany(this0 => this0.doc.FailureGroups, (this0, failureGroup) => new { + Id = Id(this0.doc), + MessageId = this0.doc.UniqueMessageId, + FailureGroupId = failureGroup.Id, + FailureGroupName = failureGroup.Title, + Status = this0.doc.Status, + MessageType = ((string) this0.processingAttemptsLast.MessageMetadata["MessageType"]), + TimeSent = ((DateTime) this0.processingAttemptsLast.MessageMetadata["TimeSent"]), + TimeOfFailure = this0.processingAttemptsLast.FailureDetails.TimeOfFailure, + LastModified = (this.MetadataFor(this0.doc).Value < DateTime > ("@last-modified")).Ticks + }) + Field __all_fields: {"Storage":"Yes"} + Configuration Indexing.Static.SearchEngineType: Lucene + +Index: FailureGroupsViewIndex + Type: MapReduce + SourceType: Documents + Map: + docs.FailedMessages.Where(doc => doc.Status == 1).Select(doc => new { + doc = doc, + failureTimes = doc.ProcessingAttempts.Select(x => x.FailureDetails.TimeOfFailure) + }).SelectMany(this0 => this0.doc.FailureGroups, (this0, failureGroup) => new { + Id = failureGroup.Id, + Title = failureGroup.Title, + Count = 1, + First = DynamicEnumerable.Min(this0.failureTimes), + Last = DynamicEnumerable.Max(this0.failureTimes), + Type = failureGroup.Type + }) + Reduce: + results.GroupBy(result => new { + Id = result.Id, + Title = result.Title, + Type = result.Type + }).Select(g => new { + Id = g.Key.Id, + Title = g.Key.Title, + Count = Enumerable.Sum(g, x => ((int) x.Count)), + First = DynamicEnumerable.Min(g, x0 => ((DateTime) x0.First)), + Last = DynamicEnumerable.Max(g, x1 => ((DateTime) x1.Last)), + Type = g.Key.Type + }) + Configuration Indexing.Static.SearchEngineType: Lucene + +Index: GroupCommentIndex + Type: Map + SourceType: Documents + Map: + docs.GroupComments.Select(gc => new { + Id = Id(gc), + Comment = gc.Comment + }) + Configuration Indexing.Static.SearchEngineType: Lucene + +Index: KnownEndpointIndex + Type: Map + SourceType: Documents + Map: + docs.KnownEndpoints.Select(message => new { + EndpointDetails_Name = message.EndpointDetails.Name, + EndpointDetails_Host = message.EndpointDetails.Host, + HostDisplayName = message.HostDisplayName, + Monitored = message.Monitored + }) + Configuration Indexing.Static.SearchEngineType: Lucene + +Index: MessagesViewIndex + Type: Map + SourceType: Documents + Map: + docs.FailedMessages.Select(message => new { + message = message, + last = DynamicEnumerable.LastOrDefault(message.ProcessingAttempts) + }).Select(this0 => new { + MessageId = this0.last.MessageId, + MessageType = ((string) this0.last.MessageMetadata["MessageType"]), + IsSystemMessage = ((bool) this0.last.MessageMetadata["IsSystemMessage"]), + Status = this0.message.Status == 4 ? 5 : (this0.message.Status == 2 ? 4 : (this0.message.ProcessingAttempts.Count == 1 ? 1 : 2)), + TimeSent = ((DateTime) this0.last.MessageMetadata["TimeSent"]), + ProcessedAt = this0.last.AttemptedAt, + ReceivingEndpointName = ((this0.last.MessageMetadata["ReceivingEndpoint"])).Name, + CriticalTime = ((TimeSpan ? ) this0.last.MessageMetadata["CriticalTime"]), + ProcessingTime = ((TimeSpan ? ) this0.last.MessageMetadata["ProcessingTime"]), + DeliveryTime = ((TimeSpan ? ) this0.last.MessageMetadata["DeliveryTime"]), + Query = Enumerable.ToArray(DynamicEnumerable.Union(this0.last.MessageMetadata.ToDictionary(e1 => e1.Key, e1 => e1.Value).Select(_ => _.Value.ToString()), new string[] { + String.Join(" ", this0.last.Headers.ToDictionary(e1 => e1.Key, e1 => e1.Value).Select(x => x.Value)) + })), + ConversationId = ((string) this0.last.MessageMetadata["ConversationId"]) + }) + Field Query: {"Indexing":"Search"} + Configuration Indexing.Static.SearchEngineType: Lucene + +Index: QueueAddressIndex + Type: MapReduce + SourceType: Documents + Map: + docs.FailedMessages.Select(message => new { + message = message, + processingAttemptsLast = DynamicEnumerable.LastOrDefault(message.ProcessingAttempts) + }).Select(this0 => new { + PhysicalAddress = this0.processingAttemptsLast.FailureDetails.AddressOfFailingEndpoint, + FailedMessageCount = 1 + }) + Reduce: + results.GroupBy(result => result.PhysicalAddress).Select(g => new { + PhysicalAddress = g.Key, + FailedMessageCount = Enumerable.Sum(g, m => ((int) m.FailedMessageCount)) + }) + Configuration Indexing.Static.SearchEngineType: Lucene + +Index: RetryBatches/ByStatus/ReduceInitialBatchSize + Type: MapReduce + SourceType: Documents + Map: + docs.RetryBatches.Select(doc => new { + RequestId = doc.RequestId, + RetryType = doc.RetryType, + HasStagingBatches = doc.Status == 2, + HasForwardingBatches = doc.Status == 3, + InitialBatchSize = doc.InitialBatchSize, + Originator = doc.Originator, + Classifier = doc.Classifier, + StartTime = doc.StartTime, + Last = doc.Last + }) + Reduce: + results.GroupBy(result => new { + RequestId = result.RequestId, + RetryType = result.RetryType + }).Select(g => new { + RequestId = g.Key.RequestId, + RetryType = g.Key.RetryType, + Originator = (DynamicEnumerable.FirstOrDefault(g)).Originator, + HasStagingBatches = Enumerable.Any(g, x => x.HasStagingBatches), + HasForwardingBatches = Enumerable.Any(g, x0 => x0.HasForwardingBatches), + InitialBatchSize = Enumerable.Sum(g, x1 => ((int) x1.InitialBatchSize)), + StartTime = (DynamicEnumerable.FirstOrDefault(g)).StartTime, + Last = DynamicEnumerable.Max(g, x2 => ((DateTime) x2.Last)), + Classifier = (DynamicEnumerable.FirstOrDefault(g)).Classifier + }) + Configuration Indexing.Static.SearchEngineType: Lucene + +Index: RetryBatches/ByStatusAndSession + Type: Map + SourceType: Documents + Map: + docs.RetryBatches.Select(doc => new { + RetrySessionId = doc.RetrySessionId, + Status = doc.Status + }) + Configuration Indexing.Static.SearchEngineType: Lucene + diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/EmbeddedServerLoggingTests.cs b/src/ServiceControl.Persistence.Tests.RavenDB/EmbeddedServerLoggingTests.cs new file mode 100644 index 0000000000..bce40613b4 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.RavenDB/EmbeddedServerLoggingTests.cs @@ -0,0 +1,38 @@ +namespace ServiceControl.Persistence.Tests.RavenDB; + +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; + +[TestFixture] +class EmbeddedServerLoggingTests +{ + [Test] + public async Task Server_writes_log_files_to_the_configured_log_path() + { + // The shared server starts with SharedEmbeddedServer.LogsMode and SharedEmbeddedServer.LogPath + await SharedEmbeddedServer.GetInstance(); + + // Log files from earlier test runs stay in the folder, so only files written by this run count + var startedAfter = Process.GetCurrentProcess().StartTime.ToUniversalTime(); + var timeout = TimeSpan.FromSeconds(30); + var stopwatch = Stopwatch.StartNew(); + + while (!HasRecentLogFile(startedAfter) && stopwatch.Elapsed < timeout) + { + await Task.Delay(TimeSpan.FromMilliseconds(250)); + } + + Assert.That(HasRecentLogFile(startedAfter), Is.True, + $"The server should write a log file to {SharedEmbeddedServer.LogPath} at the {SharedEmbeddedServer.LogsMode} level within {timeout.TotalSeconds} seconds"); + } + + static bool HasRecentLogFile(DateTime startedAfter) => + Directory.Exists(SharedEmbeddedServer.LogPath) + && new DirectoryInfo(SharedEmbeddedServer.LogPath) + .EnumerateFiles("*", SearchOption.AllDirectories) + .Any(file => file.Length > 0 && file.LastWriteTimeUtc >= startedAfter); +} diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/Expiration/ExpiresMetadataTests.cs b/src/ServiceControl.Persistence.Tests.RavenDB/Expiration/ExpiresMetadataTests.cs new file mode 100644 index 0000000000..2756127ce5 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.RavenDB/Expiration/ExpiresMetadataTests.cs @@ -0,0 +1,152 @@ +namespace ServiceControl.Persistence.Tests.RavenDB.Expiration; + +using System; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using Raven.Client; +using Raven.Client.ServerWide.Operations; +using ServiceControl.MessageFailures; +using ServiceControl.Persistence.RavenDB; +using ServiceControl.Recoverability; + +// The server deletes a document once its @expires value has passed. These tests check where ServiceControl +// puts the value on and takes it off, because the JavaScript patches that do it fail silently when they break. +[TestFixture] +class ExpiresMetadataTests : RavenPersistenceTestBase +{ + [Test] + public async Task Database_setup_configures_the_server_to_delete_expired_documents() + { + var settings = (RavenPersisterSettings)PersistenceSettings; + + var databaseRecord = await DocumentStore.Maintenance.Server.SendAsync(new GetDatabaseRecordOperation(settings.DatabaseName)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(databaseRecord.Expiration.Disabled, Is.False); + Assert.That(databaseRecord.Expiration.DeleteFrequencyInSec, Is.EqualTo(settings.ExpirationProcessTimerInSeconds)); + } + } + + [Test] + public async Task Archiving_a_group_sets_the_expiry_and_unarchiving_it_removes_it() + { + var group = new FailedMessage.FailureGroup { Id = Guid.NewGuid().ToString(), Title = "OrderPlaced", Type = "Message Type" }; + var failure = new IngestedFailure { Groups = [group] }; + var documentId = await Seed(failure, FailedMessageStatus.Unresolved, expires: false); + + await ArchiveMessages.ArchiveAllInGroup(group.Id); + var archived = await Load(documentId); + + await CompleteDatabaseOperation(); + await ArchiveMessages.UnarchiveAllInGroup(group.Id); + var unarchived = await Load(documentId); + + using (Assert.EnterMultipleScope()) + { + Assert.That(archived.Status, Is.EqualTo(FailedMessageStatus.Archived)); + Assert.That(archived.HasExpiry, Is.True); + Assert.That(unarchived.Status, Is.EqualTo(FailedMessageStatus.Unresolved)); + Assert.That(unarchived.HasExpiry, Is.False); + } + } + + [Test] + public async Task Unarchiving_messages_by_id_removes_the_expiry() + { + var failure = new IngestedFailure(); + var documentId = await Seed(failure, FailedMessageStatus.Archived, expires: true); + + await FailedMessageLifecycleStore.UnArchiveMessages([failure.UniqueMessageIdString]); + + var message = await Load(documentId); + + using (Assert.EnterMultipleScope()) + { + Assert.That(message.Status, Is.EqualTo(FailedMessageStatus.Unresolved)); + Assert.That(message.HasExpiry, Is.False); + } + } + + [Test] + public async Task Unarchiving_messages_by_range_unarchives_only_the_messages_in_the_range_and_removes_their_expiry() + { + var failure = new IngestedFailure(); + var documentId = await Seed(failure, FailedMessageStatus.Archived, expires: true); + var now = DateTime.UtcNow; + + var outsideRange = await FailedMessageLifecycleStore.UnArchiveMessagesByRange(now.AddHours(1), now.AddHours(2)); + var untouched = await Load(documentId); + + var insideRange = await FailedMessageLifecycleStore.UnArchiveMessagesByRange(now.AddHours(-1), now.AddHours(1)); + var unarchived = await Load(documentId); + + using (Assert.EnterMultipleScope()) + { + Assert.That(outsideRange, Is.Empty); + Assert.That(untouched.Status, Is.EqualTo(FailedMessageStatus.Archived)); + Assert.That(untouched.HasExpiry, Is.True); + Assert.That(insideRange, Is.EqualTo(new[] { documentId })); + Assert.That(unarchived.Status, Is.EqualTo(FailedMessageStatus.Unresolved)); + Assert.That(unarchived.HasExpiry, Is.False); + } + } + + [Test] + public async Task Issuing_a_retry_removes_the_expiry() + { + var failure = new IngestedFailure(); + var documentId = await Seed(failure, FailedMessageStatus.Unresolved, expires: true); + + var batchId = await RetryBatchStore.CreateBatch( + RetryDocumentManager.RetrySessionId, + "request-1", + RetryType.MultipleMessages, + [failure.UniqueMessageIdString], + "a retry request", + DateTime.UtcNow); + await RetryBatchStore.AssignMessagesToBatch(batchId, [failure.UniqueMessageIdString]); + await RetryBatchStore.MoveBatchToStaging(batchId); + + await RetryStagingStore.MarkBatchAsForwarding(batchId, "staging-1", [failure.UniqueMessageIdString]); + + var message = await Load(documentId); + + using (Assert.EnterMultipleScope()) + { + Assert.That(message.Status, Is.EqualTo(FailedMessageStatus.RetryIssued)); + Assert.That(message.HasExpiry, Is.False); + } + } + + async Task Seed(IngestedFailure failure, FailedMessageStatus status, bool expires) + { + var message = failure.ToFailedMessage(status); + message.Id = PersistenceTestsContext.GenerateFailedMessageRecordId(message.UniqueMessageId); + + using (var session = await SessionProvider.OpenSession()) + { + await session.StoreAsync(message); + + if (expires) + { + session.Advanced.GetMetadataFor(message)[Constants.Documents.Metadata.Expires] = DateTime.UtcNow.AddDays(30); + } + + await session.SaveChangesAsync(); + } + + await CompleteDatabaseOperation(); + + return message.Id; + } + + async Task<(FailedMessageStatus Status, bool HasExpiry)> Load(string documentId) + { + using var session = DocumentStore.OpenAsyncSession(); + var message = await session.LoadAsync(documentId); + + return (message.Status, session.Advanced.GetMetadataFor(message).ContainsKey(Constants.Documents.Metadata.Expires)); + } +} diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/IndexDefinitionTests.cs b/src/ServiceControl.Persistence.Tests.RavenDB/IndexDefinitionTests.cs new file mode 100644 index 0000000000..2ac433e684 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.RavenDB/IndexDefinitionTests.cs @@ -0,0 +1,115 @@ +namespace ServiceControl.Persistence.Tests.RavenDB; + +using System; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using NUnit.Framework; +using Particular.Approvals; +using Raven.Client.Documents.Indexes; +using ServiceControl.RavenDB; + +// RavenDB rebuilds an index side by side when the definition the client sends differs from the stored one, which +// takes days on a large database. A change to the approved file needs a plan for that rebuild. +[TestFixture] +class IndexDefinitionTests +{ + [Test] + public async Task Primary_index_definitions_are_unchanged() + { + await using var database = await ScratchDatabase.Create(); + + var indexes = PrimaryIndexes.Types.Select(PrimaryIndexes.Create).ToList(); + foreach (var index in indexes) + { + index.Conventions = database.Store.Conventions; + } + + // Deploying sets the search engine on each index. That setting is part of what the server compares. + await IndexDeployment.CreateIndexesAsync(indexes, database.Store); + + var approved = new StringBuilder(); + foreach (var index in indexes.OrderBy(index => index.IndexName, StringComparer.Ordinal)) + { + Describe(approved, index.IndexName, index.CreateIndexDefinition()); + } + + Approver.Verify(approved.ToString()); + } + + static void Describe(StringBuilder text, string name, IndexDefinition definition) + { + text.AppendLine($"Index: {name}"); + text.AppendLine($" Type: {definition.Type}"); + text.AppendLine($" SourceType: {definition.SourceType}"); + AppendIfSet(text, nameof(definition.LockMode), definition.LockMode); + AppendIfSet(text, nameof(definition.Priority), definition.Priority); + AppendIfSet(text, nameof(definition.State), definition.State); + AppendIfSet(text, nameof(definition.DeploymentMode), definition.DeploymentMode); + AppendIfSet(text, nameof(definition.ArchivedDataProcessingBehavior), definition.ArchivedDataProcessingBehavior); + + foreach (var map in definition.Maps.OrderBy(map => map, StringComparer.Ordinal)) + { + text.AppendLine(" Map:"); + AppendBlock(text, map); + } + + if (definition.Reduce is not null) + { + text.AppendLine(" Reduce:"); + AppendBlock(text, definition.Reduce); + } + + foreach (var (field, options) in definition.Fields.OrderBy(field => field.Key, StringComparer.Ordinal)) + { + text.AppendLine($" Field {field}: {JsonSerializer.Serialize(options, JsonOptions)}"); + } + + foreach (var compoundField in definition.CompoundFields ?? []) + { + text.AppendLine($" CompoundField: {string.Join(", ", compoundField)}"); + } + + foreach (var (key, value) in definition.Configuration.OrderBy(setting => setting.Key, StringComparer.Ordinal)) + { + text.AppendLine($" Configuration {key}: {value}"); + } + + foreach (var (key, value) in definition.AdditionalSources.OrderBy(source => source.Key, StringComparer.Ordinal)) + { + text.AppendLine($" AdditionalSource {key}:"); + AppendBlock(text, value); + } + + foreach (var assembly in definition.AdditionalAssemblies.Select(assembly => JsonSerializer.Serialize(assembly, JsonOptions)).OrderBy(assembly => assembly, StringComparer.Ordinal)) + { + text.AppendLine($" AdditionalAssembly: {assembly}"); + } + + AppendIfSet(text, nameof(definition.OutputReduceToCollection), definition.OutputReduceToCollection); + AppendIfSet(text, nameof(definition.ReduceOutputIndex), definition.ReduceOutputIndex); + AppendIfSet(text, nameof(definition.PatternForOutputReduceToCollectionReferences), definition.PatternForOutputReduceToCollectionReferences); + AppendIfSet(text, nameof(definition.PatternReferencesCollectionName), definition.PatternReferencesCollectionName); + text.AppendLine(); + } + + static void AppendBlock(StringBuilder text, string block) + { + foreach (var line in block.Replace("\r\n", "\n").Split('\n')) + { + text.AppendLine($" {line}"); + } + } + + static void AppendIfSet(StringBuilder text, string name, object value) + { + if (value is not null) + { + text.AppendLine($" {name}: {value}"); + } + } + + static readonly JsonSerializerOptions JsonOptions = new() { Converters = { new JsonStringEnumConverter() }, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; +} diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/IndexDeploymentTests.cs b/src/ServiceControl.Persistence.Tests.RavenDB/IndexDeploymentTests.cs new file mode 100644 index 0000000000..c967c4bb07 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.RavenDB/IndexDeploymentTests.cs @@ -0,0 +1,43 @@ +namespace ServiceControl.Persistence.Tests.RavenDB; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using NUnit.Framework; +using Raven.Client.Documents.Indexes; +using Raven.Client.Documents.Operations.Indexes; +using ServiceControl.RavenDB; + +// IndexDeployment deploys all indexes in one atomic call, so one index that the server cannot compile blocks +// the deployment of every other index. Each index has to deploy on its own. +[TestFixture] +class IndexDeploymentTests +{ + [TestCaseSource(nameof(IndexTypes))] + public async Task Index_deploys_to_a_clean_database(Type indexType) + { + await using var database = await ScratchDatabase.Create(); + var index = PrimaryIndexes.Create(indexType); + + Assert.That(await database.Store.Maintenance.SendAsync(new GetIndexOperation(index.IndexName)), Is.Null, "the database starts without the index"); + + await IndexDeployment.CreateIndexesAsync([index], database.Store); + + var deployed = await database.Store.Maintenance.SendAsync(new GetIndexStatisticsOperation(index.IndexName)); + + using (Assert.EnterMultipleScope()) + { + Assert.That(deployed, Is.Not.Null); + Assert.That(deployed.State, Is.EqualTo(IndexState.Normal)); + Assert.That(deployed.SearchEngineType, Is.EqualTo(SearchEngineType.Lucene)); + } + } + + public static IEnumerable IndexTypes() + { + foreach (var type in PrimaryIndexes.Types) + { + yield return new TestCaseData(type).SetArgDisplayNames(type.Name); + } + } +} diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/PrimaryIndexes.cs b/src/ServiceControl.Persistence.Tests.RavenDB/PrimaryIndexes.cs new file mode 100644 index 0000000000..caf571dcfc --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.RavenDB/PrimaryIndexes.cs @@ -0,0 +1,18 @@ +namespace ServiceControl.Persistence.Tests.RavenDB; + +using System; +using System.Collections.Generic; +using System.Linq; +using Raven.Client.Documents.Indexes; +using ServiceControl.Persistence.RavenDB; + +// The indexes that DatabaseSetup deploys, found the way IndexDeployment finds them. +static class PrimaryIndexes +{ + public static IReadOnlyList Types { get; } = typeof(DatabaseSetup).Assembly.GetTypes() + .Where(type => type.IsClass && !type.IsAbstract && type.IsSubclassOf(typeof(AbstractIndexCreationTask))) + .OrderBy(type => type.FullName, StringComparer.Ordinal) + .ToArray(); + + public static AbstractIndexCreationTask Create(Type type) => (AbstractIndexCreationTask)Activator.CreateInstance(type); +} diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/RavenServerContractTests.cs b/src/ServiceControl.Persistence.Tests.RavenDB/RavenServerContractTests.cs new file mode 100644 index 0000000000..b350a86d02 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.RavenDB/RavenServerContractTests.cs @@ -0,0 +1,77 @@ +namespace ServiceControl.Persistence.Tests.RavenDB; + +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using ServiceControl.Persistence.RavenDB; + +// The persister reads these two endpoints into hand-written types. A field that the server renames +// deserializes to its default value instead of failing, so the checks that rely on them would pass silently. +[TestFixture] +class RavenServerContractTests : RavenPersistenceTestBase +{ + [Test] + public async Task Memory_stats_contain_the_fields_the_dirty_memory_check_reads() + { + var json = await GetJson("/admin/debug/memory/stats?includeThreads=false&includeMappings=false"); + + Assert.That(json.TryGetProperty("MemoryInformation", out var memoryInformation), Is.True); + using (Assert.EnterMultipleScope()) + { + Assert.That(memoryInformation.TryGetProperty("IsHighDirty", out var isHighDirty) && isHighDirty.ValueKind is JsonValueKind.True or JsonValueKind.False, Is.True, "IsHighDirty is a boolean"); + Assert.That(memoryInformation.TryGetProperty("DirtyMemory", out var dirtyMemory) && dirtyMemory.ValueKind == JsonValueKind.String, Is.True, "DirtyMemory is a string"); + } + } + + [Test] + public async Task Memory_information_is_read_from_the_server() + { + var retriever = ServiceProvider.GetRequiredService(); + + var (_, dirtyMemory) = await retriever.GetMemoryInformation(); + + Assert.That(dirtyMemory, Is.Not.Null.And.Not.Empty); + } + + [Test] + public async Task License_status_contains_the_fields_the_license_check_reads() + { + var json = await GetJson("/license/status"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(json.TryGetProperty("Id", out var id) && id.ValueKind == JsonValueKind.String, Is.True, "Id is a string"); + Assert.That(json.TryGetProperty("LicensedTo", out var licensedTo) && licensedTo.ValueKind == JsonValueKind.String, Is.True, "LicensedTo is a string"); + Assert.That(json.TryGetProperty("Status", out var status) && status.ValueKind == JsonValueKind.String, Is.True, "Status is a string"); + Assert.That(json.TryGetProperty("Expired", out var expired) && expired.ValueKind is JsonValueKind.True or JsonValueKind.False, Is.True, "Expired is a boolean"); + } + } + + [Test] + public async Task License_is_valid() + { + var json = await GetJson("/license/status"); + + using (Assert.EnterMultipleScope()) + { + Assert.That(json.GetProperty("Id").GetString(), Is.EqualTo("64c6a174-3f3a-4e7d-ac5d-b3eedd801460")); + Assert.That(json.GetProperty("LicensedTo").GetString(), Is.EqualTo("NserviceBus Ltd")); + Assert.That(json.GetProperty("Status").GetString(), Is.EqualTo("Commercial")); + Assert.That(json.GetProperty("Expired").GetBoolean(), Is.False); + } + + Assert.DoesNotThrowAsync(() => LicenseStatusCheck.WaitForLicenseOrThrow(DocumentStore)); + } + + async Task GetJson(string path) + { + var client = DocumentStore.GetRequestExecutor().HttpClient; + + using var response = await client.GetAsync(DocumentStore.Urls[0].TrimEnd('/') + path); + response.EnsureSuccessStatusCode(); + + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return document.RootElement.Clone(); + } +} diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/Recoverability/RecoverabilityIngestionPatchTests.cs b/src/ServiceControl.Persistence.Tests.RavenDB/Recoverability/RecoverabilityIngestionPatchTests.cs new file mode 100644 index 0000000000..185c8e58b4 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.RavenDB/Recoverability/RecoverabilityIngestionPatchTests.cs @@ -0,0 +1,101 @@ +namespace ServiceControl.Persistence.Tests.RavenDB.Recoverability; + +using System; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using Raven.Client; +using ServiceControl.MessageFailures; + +// Ingestion writes failed messages through JavaScript patches. They compare the serialized AttemptedAt +// values as strings, and write the collection and the CLR type of a new document by hand. +[TestFixture] +class RecoverabilityIngestionPatchTests : RavenPersistenceTestBase +{ + [Test] + public async Task The_first_failure_creates_the_document_in_the_failed_messages_collection() + { + var failure = new IngestedFailure(); + + await Ingest(failure); + + using var session = DocumentStore.OpenAsyncSession(); + var message = await session.LoadAsync(FailedMessageIdGenerator.MakeDocumentId(failure.UniqueMessageIdString)); + var metadata = session.Advanced.GetMetadataFor(message); + + using (Assert.EnterMultipleScope()) + { + Assert.That(metadata[Constants.Documents.Metadata.Collection], Is.EqualTo("FailedMessages")); + Assert.That(metadata[Constants.Documents.Metadata.RavenClrType], Is.EqualTo(typeof(FailedMessage).AssemblyQualifiedName)); + Assert.That(message.UniqueMessageId, Is.EqualTo(failure.UniqueMessageIdString)); + Assert.That(message.Status, Is.EqualTo(FailedMessageStatus.Unresolved)); + Assert.That(message.FailureGroups.Select(group => group.Id), Is.EqualTo(failure.Groups.Select(group => group.Id))); + Assert.That(message.ProcessingAttempts, Has.Count.EqualTo(1)); + } + } + + [Test] + public async Task A_redelivered_attempt_is_stored_once() + { + var failure = new IngestedFailure(); + + await Ingest(failure); + await Ingest(failure); + + var message = await FailedMessageQueryStore.GetFailedMessage(failure.UniqueMessageIdString); + + Assert.That(message.ProcessingAttempts, Has.Count.EqualTo(1)); + } + + [TestCase(TimeSpan.TicksPerMinute)] + [TestCase(1)] + public async Task An_older_attempt_arriving_late_is_stored_in_order_and_leaves_the_status_and_groups_of_the_newest(long newerByTicks) + { + var older = new IngestedFailure(); + var newerGroups = new[] { new FailedMessage.FailureGroup { Id = Guid.NewGuid().ToString(), Title = "Newer", Type = "Message Type" } }; + var newer = older.NextAttempt(older.AttemptedAt.AddTicks(newerByTicks), [.. newerGroups]); + + await Ingest(newer); + await Ingest(older); + + var message = await FailedMessageQueryStore.GetFailedMessage(older.UniqueMessageIdString); + + using (Assert.EnterMultipleScope()) + { + Assert.That(message.ProcessingAttempts.Select(attempt => attempt.AttemptedAt), Is.EqualTo(new[] { older.AttemptedAt, newer.AttemptedAt })); + Assert.That(message.FailureGroups.Select(group => group.Id), Is.EqualTo(newerGroups.Select(group => group.Id))); + Assert.That(message.Status, Is.EqualTo(FailedMessageStatus.Unresolved)); + } + } + + [TestCase(0, FailedMessageStatus.Resolved)] + [TestCase(-1, FailedMessageStatus.Unresolved)] + public async Task A_retry_resolves_the_message_only_when_it_succeeded_at_or_after_the_newest_attempt(long succeededAfterAttemptByTicks, FailedMessageStatus expected) + { + var failure = new IngestedFailure(); + await Ingest(failure); + + await using (var unitOfWork = await UnitOfWorkFactory.StartNew()) + { + await unitOfWork.Recoverability.RecordSuccessfulRetry(failure.UniqueMessageIdString, failure.AttemptedAt.AddTicks(succeededAfterAttemptByTicks)); + await unitOfWork.Complete(TestContext.CurrentContext.CancellationToken); + } + + await CompleteDatabaseOperation(); + + var message = await FailedMessageQueryStore.GetFailedMessage(failure.UniqueMessageIdString); + + Assert.That(message.Status, Is.EqualTo(expected)); + } + + async Task Ingest(IngestedFailure failure) + { + await using (var unitOfWork = await UnitOfWorkFactory.StartNew()) + { + await unitOfWork.Recoverability.RecordFailedProcessingAttempt(failure.Context, failure.ProcessingAttempt, failure.Groups); + await unitOfWork.Complete(TestContext.CurrentContext.CancellationToken); + } + + await CompleteDatabaseOperation(); + } +} diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/ScratchDatabase.cs b/src/ServiceControl.Persistence.Tests.RavenDB/ScratchDatabase.cs new file mode 100644 index 0000000000..da8ce81558 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.RavenDB/ScratchDatabase.cs @@ -0,0 +1,54 @@ +namespace ServiceControl.Persistence.Tests.RavenDB; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Raven.Client.Documents; +using Raven.Client.Documents.Conventions; +using Raven.Client.ServerWide; +using Raven.Client.ServerWide.Operations; +using ServiceControl.RavenDB; +using TestHelper; + +// An empty database on the shared embedded server, created with the search engine settings that DatabaseSetup uses for new databases. +sealed class ScratchDatabase : IAsyncDisposable +{ + ScratchDatabase(EmbeddedDatabase server, string name, DocumentStore store) + { + this.server = server; + this.name = name; + Store = store; + } + + public IDocumentStore Store { get; } + + public static async Task Create(CancellationToken cancellationToken = default) + { + var server = await SharedEmbeddedServer.GetInstance(cancellationToken); + var name = Guid.NewGuid().ToString("n"); + + var store = new DocumentStore + { + Urls = [server.ServerUrl], + Database = name, + Conventions = new DocumentConventions { SaveEnumsAsIntegers = true } + }; + store.Initialize(); + + var databaseRecord = new DatabaseRecord(name); + databaseRecord.Settings.Add("Indexing.Auto.SearchEngineType", "Lucene"); + databaseRecord.Settings.Add("Indexing.Static.SearchEngineType", "Lucene"); + await store.Maintenance.Server.SendAsync(new CreateDatabaseOperation(databaseRecord), cancellationToken); + + return new ScratchDatabase(server, name, store); + } + + public async ValueTask DisposeAsync() + { + Store.Dispose(); + await server.DeleteDatabase(name); + } + + readonly EmbeddedDatabase server; + readonly string name; +} diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/SharedEmbeddedServer.cs b/src/ServiceControl.Persistence.Tests.RavenDB/SharedEmbeddedServer.cs index f35095a9a5..02be90e83e 100644 --- a/src/ServiceControl.Persistence.Tests.RavenDB/SharedEmbeddedServer.cs +++ b/src/ServiceControl.Persistence.Tests.RavenDB/SharedEmbeddedServer.cs @@ -16,6 +16,10 @@ static class SharedEmbeddedServer { + public const string LogsMode = "Operations"; + + public static string LogPath => Path.Combine(TestContext.CurrentContext.WorkDirectory, "Logs", "Primary"); + public static async Task GetInstance(CancellationToken cancellationToken = default) { if (embeddedDatabase != null) @@ -33,8 +37,8 @@ public static async Task GetInstance(CancellationToken cancell } var dbPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "Tests", "PrimaryData"); - var logPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "Logs", "Primary"); - var logsMode = "Operations"; + var logPath = LogPath; + var logsMode = LogsMode; var settings = new RavenPersisterSettings { diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/Throughput/ThroughputTimeSeriesTests.cs b/src/ServiceControl.Persistence.Tests.RavenDB/Throughput/ThroughputTimeSeriesTests.cs new file mode 100644 index 0000000000..624f260c71 --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.RavenDB/Throughput/ThroughputTimeSeriesTests.cs @@ -0,0 +1,86 @@ +namespace ServiceControl.Persistence.Tests.RavenDB.Throughput; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using Particular.LicensingComponent.Contracts; + +// The usage report is built from the incremental time series that these calls write and read +[TestFixture] +class ThroughputTimeSeriesTests : RavenPersistenceTestBase +{ + static DateOnly Today => DateOnly.FromDateTime(DateTime.UtcNow); + + [Test] + public async Task Throughput_recorded_for_the_same_day_adds_up() + { + await SaveEndpoint("Endpoint", ThroughputSource.Audit); + + await LicensingDataStore.RecordEndpointThroughput("Endpoint", ThroughputSource.Audit, Today.AddDays(-1), 30); + await LicensingDataStore.RecordEndpointThroughput("Endpoint", ThroughputSource.Audit, Today, 12); + await LicensingDataStore.RecordEndpointThroughput("Endpoint", ThroughputSource.Audit, Today, 30); + + var throughput = (await LicensingDataStore.GetEndpointThroughputByQueueName(["Endpoint"]))["Endpoint"].Single(); + + Assert.That(throughput, Is.EqualTo(new Dictionary + { + [Today.AddDays(-1)] = 30, + [Today] = 42 + })); + } + + [Test] + public async Task Throughput_is_read_up_to_the_last_day_asked_for() + { + await SaveEndpoint("Endpoint", ThroughputSource.Audit); + + await LicensingDataStore.RecordEndpointThroughput("Endpoint", ThroughputSource.Audit, Today.AddDays(-2), 5); + await LicensingDataStore.RecordEndpointThroughput("Endpoint", ThroughputSource.Audit, Today.AddDays(-1), 6); + await LicensingDataStore.RecordEndpointThroughput("Endpoint", ThroughputSource.Audit, Today, 7); + + var throughput = (await LicensingDataStore.GetEndpointThroughputByQueueName(["Endpoint"], Today))["Endpoint"].Single(); + + Assert.That(throughput.Keys, Is.EqualTo(new[] { Today.AddDays(-2), Today.AddDays(-1) })); + } + + [Test] + public async Task The_last_collected_date_of_an_endpoint_is_its_newest_recorded_day() + { + await SaveEndpoint("Endpoint", ThroughputSource.Audit); + + await LicensingDataStore.RecordEndpointThroughput("Endpoint", ThroughputSource.Audit, Today.AddDays(-5), 10); + await LicensingDataStore.RecordEndpointThroughput("Endpoint", ThroughputSource.Audit, Today.AddDays(-1), 10); + await LicensingDataStore.RecordEndpointThroughput("Endpoint", ThroughputSource.Audit, Today.AddDays(-3), 10); + + var endpoint = await LicensingDataStore.GetEndpoint("Endpoint", ThroughputSource.Audit); + + Assert.That(endpoint.LastCollectedDate, Is.EqualTo(Today.AddDays(-1))); + } + + [Test] + public async Task The_last_collected_date_is_read_for_every_requested_endpoint() + { + await SaveEndpoint("Endpoint", ThroughputSource.Audit); + await SaveEndpoint("Endpoint", ThroughputSource.Broker); + await LicensingDataStore.RecordEndpointThroughput("Endpoint", ThroughputSource.Audit, Today, 10); + await LicensingDataStore.RecordEndpointThroughput("Endpoint", ThroughputSource.Broker, Today, 10); + var audit = new EndpointIdentifier("Endpoint", ThroughputSource.Audit); + var broker = new EndpointIdentifier("Endpoint", ThroughputSource.Broker); + var unknown = new EndpointIdentifier("Unknown", ThroughputSource.Audit); + + var endpoints = (await LicensingDataStore.GetEndpoints([audit, broker, unknown])).ToList(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(endpoints.Select(endpoint => endpoint.Id), Is.EqualTo(new[] { audit, broker, unknown })); + Assert.That(endpoints[0].Endpoint.LastCollectedDate, Is.EqualTo(Today)); + Assert.That(endpoints[1].Endpoint.LastCollectedDate, Is.EqualTo(Today)); + Assert.That(endpoints[2].Endpoint, Is.Null); + } + } + + Task SaveEndpoint(string name, ThroughputSource source) => + LicensingDataStore.SaveEndpoint(new Endpoint(name, source) { SanitizedName = name }); +} From f3acb53bed0c4b7cebb77cdfc14e4fbd6895231b Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Fri, 23 Oct 2026 07:31:52 +0200 Subject: [PATCH 4/9] Update RavenDB to 7.2.7 RavenDB 7.2 is the only supported 7.x line, and RavenDB 6.2 support ends in 2027. 7.2.7 contains the compaction, storage, and sorted-query memory fixes that earlier 7.2 patches lack. RavenDB 7 replaced Logs.Mode with Logs.MinLevel and rejects the old option at startup. RavenDBLogLevel now accepts NLog level names as is and translates the legacy mode names the way RavenDB does: None to Off, Operations to Info, Information to Debug. The default stays Warn, which Storage.LogLevel now reports as Warn instead of Operations. The client-side Sparrow.Logging setup no longer exists in RavenDB 7, so the embedded Raven.Embedded log is gone. UseOptimisticConcurrency is obsolete; OptimisticConcurrencyMode.Writes keeps the previous behavior. The ravendb/ravendb images exist only for Ubuntu 24.04 from 7.2.4 on. --- .github/workflows/build-db-container.yml | 2 +- docs/usage-report.md | 2 +- src/Directory.Packages.props | 2 +- .../RavenEmbeddedPersistenceLifecycle.cs | 2 +- .../RavenPersistenceConfiguration.cs | 6 +-- .../ServerConfiguration.cs | 6 +-- .../SharedEmbeddedServer.cs | 6 +-- ...avenDbLogLevelToLogsMinLevelMapperTests.cs | 28 ++++++++++++++ .../RavenDbLogLevelToLogsMinLevelMapper.cs | 38 +++++++++++++++++++ .../RavenDbLogLevelToLogsModeMapper.cs | 30 --------------- .../Editing/EditFailedMessagesDataStore.cs | 3 +- .../ErrorMessagesDataStore.cs | 4 +- .../RavenEmbeddedPersistenceLifecycle.cs | 2 +- .../RavenEnvironmentDataProvider.cs | 2 +- .../RavenPersistenceConfiguration.cs | 4 +- .../RavenPersisterSettings.cs | 4 +- .../Archiving/MessageArchiver.cs | 5 ++- .../RetryDocumentDataStore.cs | 2 +- .../RetryStagingStore.cs | 2 +- .../EmbeddedServerLoggingTests.cs | 36 +++++++----------- .../EnvironmentDataConfigurationTests.cs | 2 +- .../MessagesViewIndexTests.cs | 36 ++++++++++++++++++ .../SharedEmbeddedServer.cs | 8 ++-- .../EmbeddedDatabase.cs | 19 ++-------- .../EmbeddedDatabaseConfiguration.cs | 4 +- 25 files changed, 154 insertions(+), 101 deletions(-) create mode 100644 src/ServiceControl.Infrastructure.Tests/RavenDbLogLevelToLogsMinLevelMapperTests.cs create mode 100644 src/ServiceControl.Infrastructure/RavenDbLogLevelToLogsMinLevelMapper.cs delete mode 100644 src/ServiceControl.Infrastructure/RavenDbLogLevelToLogsModeMapper.cs create mode 100644 src/ServiceControl.Persistence.Tests.RavenDB/MessagesViewIndexTests.cs diff --git a/.github/workflows/build-db-container.yml b/.github/workflows/build-db-container.yml index 643f44ce30..c6b61cd4cb 100644 --- a/.github/workflows/build-db-container.yml +++ b/.github/workflows/build-db-container.yml @@ -55,7 +55,7 @@ jobs: $containers = cat src/ServiceControl.RavenDB/containers.json | ConvertFrom-Json $containers | ForEach-Object -Process { $FULLTAG="${{ env.TAG_NAME }}-$($_.tag)" - $BASETAG="$($RavenVersion)-ubuntu.22.04-$($_.tag)" + $BASETAG="$($RavenVersion)-ubuntu.24.04-$($_.tag)" Write-Output "::group::Building $FULLTAG for architecture $($_.arch) from $BASETAG" docker build -t ghcr.io/particular/servicecontrol-ravendb:$FULLTAG --file src/ServiceControl.RavenDB/Dockerfile --build-arg VERSION=${{ env.MinVerVersion }} --build-arg BASETAG=$BASETAG --platform linux/$($_.arch) . Write-Output "::endgroup::" diff --git a/docs/usage-report.md b/docs/usage-report.md index 3b56231f9a..0e110b9e89 100644 --- a/docs/usage-report.md +++ b/docs/usage-report.md @@ -89,7 +89,7 @@ Releases up to 6.21.2 report `SqlVersion` as the full text of `@@VERSION` on SQL | `Storage.BodyStorage.Type` | `RavenAttachments`, `FileSystem`, `AzureBlob`, `S3` | the persister | 6.20.0 | | `Limits.MaxBodySizeToStore` | bytes | `MaxBodySizeToStore`, SQL Server and PostgreSQL only | 6.20.0 | | `Storage.Schema` | `Default`, `Custom` | `Database/Schema`, SQL Server and PostgreSQL only | Unreleased | -| `Storage.LogLevel` | `None`, `Information`, `Operations` | `RavenDBLogLevel`, RavenDB only | Unreleased | +| `Storage.LogLevel` | `Trace`, `Debug`, `Info`, `Warn`, `Error`, `Fatal`, `Off` | `RavenDBLogLevel`, RavenDB only | Unreleased | | `Storage.CommandTimeoutSeconds` | `Default` or number | `Database/CommandTimeout`, SQL Server and PostgreSQL only | Unreleased | | `Storage.QueryTimeoutSeconds` | `Default` or number | `QueryTimeoutInSeconds` | Unreleased | | `Storage.SubscriptionCacheSeconds` | `Default` or number | `SubscriptionCacheDuration`, SQL Server and PostgreSQL only | Unreleased | diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 2860b108d4..8469bc4f51 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -79,7 +79,7 @@ - + diff --git a/src/ServiceControl.Audit.Persistence.RavenDB/RavenEmbeddedPersistenceLifecycle.cs b/src/ServiceControl.Audit.Persistence.RavenDB/RavenEmbeddedPersistenceLifecycle.cs index 99c74f6a15..104e6da960 100644 --- a/src/ServiceControl.Audit.Persistence.RavenDB/RavenEmbeddedPersistenceLifecycle.cs +++ b/src/ServiceControl.Audit.Persistence.RavenDB/RavenEmbeddedPersistenceLifecycle.cs @@ -39,7 +39,7 @@ public async Task Initialize(CancellationToken cancellationToken = default) var serverConfig = databaseConfiguration.ServerConfiguration; - var embeddedConfig = new EmbeddedDatabaseConfiguration(serverConfig.ServerUrl, databaseConfiguration.Name, serverConfig.DbPath, serverConfig.LogPath, serverConfig.LogsMode); + var embeddedConfig = new EmbeddedDatabaseConfiguration(serverConfig.ServerUrl, databaseConfiguration.Name, serverConfig.DbPath, serverConfig.LogPath, serverConfig.LogsMinLevel); database = EmbeddedDatabase.Start(embeddedConfig, lifetime); diff --git a/src/ServiceControl.Audit.Persistence.RavenDB/RavenPersistenceConfiguration.cs b/src/ServiceControl.Audit.Persistence.RavenDB/RavenPersistenceConfiguration.cs index 787a06d9c1..68ffed5c3d 100644 --- a/src/ServiceControl.Audit.Persistence.RavenDB/RavenPersistenceConfiguration.cs +++ b/src/ServiceControl.Audit.Persistence.RavenDB/RavenPersistenceConfiguration.cs @@ -109,14 +109,14 @@ internal static DatabaseConfiguration GetDatabaseConfiguration(PersistenceSettin var logPath = GetLogPath(settings); - var logsMode = "Operations"; + var logsMinLevel = "Warn"; if (settings.PersisterSpecificSettings.TryGetValue(RavenDbLogLevelKey, out var ravenDbLogLevel)) { - logsMode = RavenDbLogLevelToLogsModeMapper.Map(ravenDbLogLevel, Logger); + logsMinLevel = RavenDbLogLevelToLogsMinLevelMapper.Map(ravenDbLogLevel, Logger); } - serverConfiguration = new ServerConfiguration(dbPath, serverUrl, logPath, logsMode); + serverConfiguration = new ServerConfiguration(dbPath, serverUrl, logPath, logsMinLevel); } var dataSpaceRemainingThreshold = CheckFreeDiskSpace.Parse(settings.PersisterSpecificSettings, Logger); diff --git a/src/ServiceControl.Audit.Persistence.RavenDB/ServerConfiguration.cs b/src/ServiceControl.Audit.Persistence.RavenDB/ServerConfiguration.cs index 53c3199f31..fc60106427 100644 --- a/src/ServiceControl.Audit.Persistence.RavenDB/ServerConfiguration.cs +++ b/src/ServiceControl.Audit.Persistence.RavenDB/ServerConfiguration.cs @@ -10,13 +10,13 @@ public ServerConfiguration(string connectionString) ConnectionString = connectionString; } - public ServerConfiguration(string dbPath, string serverUrl, string logPath, string logsMode) + public ServerConfiguration(string dbPath, string serverUrl, string logPath, string logsMinLevel) { UseEmbeddedServer = true; DbPath = dbPath; ServerUrl = serverUrl; LogPath = logPath; - LogsMode = logsMode; + LogsMinLevel = logsMinLevel; } public string ConnectionString { get; } @@ -27,6 +27,6 @@ public ServerConfiguration(string dbPath, string serverUrl, string logPath, stri public string DbPath { get; internal set; } //Setter for ATT only public string ServerUrl { get; } public string LogPath { get; } - public string LogsMode { get; set; } + public string LogsMinLevel { get; set; } } } diff --git a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/SharedEmbeddedServer.cs b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/SharedEmbeddedServer.cs index 67d3a91045..666be0dcac 100644 --- a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/SharedEmbeddedServer.cs +++ b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/SharedEmbeddedServer.cs @@ -34,14 +34,14 @@ public static async Task GetInstance(CancellationToken cancell var dbPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "Tests", "AuditData"); var logPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "Logs", "Audit"); - var logsMode = "Operations"; + var logsMinLevel = "Warn"; var serverUrl = $"http://localhost:{PortUtility.GetAssignedOrAvailablePort(33334)}"; - var databaseConfiguration = new DatabaseConfiguration("audit", 60, true, TimeSpan.FromMinutes(5), 120000, 5, 5, new ServerConfiguration(dbPath, serverUrl, logPath, logsMode), TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(30)); + var databaseConfiguration = new DatabaseConfiguration("audit", 60, true, TimeSpan.FromMinutes(5), 120000, 5, 5, new ServerConfiguration(dbPath, serverUrl, logPath, logsMinLevel), TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(30)); var serverConfig = databaseConfiguration.ServerConfiguration; // TODO: See if more refactoring can be done in configuration classes - var embeddedConfig = new EmbeddedDatabaseConfiguration(serverConfig.ServerUrl, databaseConfiguration.Name, serverConfig.DbPath, serverConfig.LogPath, serverConfig.LogsMode); + var embeddedConfig = new EmbeddedDatabaseConfiguration(serverConfig.ServerUrl, databaseConfiguration.Name, serverConfig.DbPath, serverConfig.LogPath, serverConfig.LogsMinLevel); var server = EmbeddedDatabase.Start(embeddedConfig, new ApplicationLifetime(new NullLogger())); diff --git a/src/ServiceControl.Infrastructure.Tests/RavenDbLogLevelToLogsMinLevelMapperTests.cs b/src/ServiceControl.Infrastructure.Tests/RavenDbLogLevelToLogsMinLevelMapperTests.cs new file mode 100644 index 0000000000..2d58bed45f --- /dev/null +++ b/src/ServiceControl.Infrastructure.Tests/RavenDbLogLevelToLogsMinLevelMapperTests.cs @@ -0,0 +1,28 @@ +namespace ServiceControl.Infrastructure.Tests; + +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; + +[TestFixture] +public class RavenDbLogLevelToLogsMinLevelMapperTests +{ + [TestCase("Trace", "Trace")] + [TestCase("Debug", "Debug")] + [TestCase("Info", "Info")] + [TestCase("Warn", "Warn")] + [TestCase("Error", "Error")] + [TestCase("Fatal", "Fatal")] + [TestCase("Off", "Off")] + [TestCase("warn", "Warn")] + [TestCase("OFF", "Off")] + [TestCase("None", "Off")] + [TestCase("Operations", "Info")] + [TestCase("Information", "Debug")] + [TestCase("operations", "Info")] + public void Should_map_the_configured_level_to_the_server_minimum_level(string configured, string expected) => + Assert.That(RavenDbLogLevelToLogsMinLevelMapper.Map(configured, NullLogger.Instance), Is.EqualTo(expected)); + + [Test] + public void Should_fall_back_to_Warn_for_an_unknown_level() => + Assert.That(RavenDbLogLevelToLogsMinLevelMapper.Map("Verbose", NullLogger.Instance), Is.EqualTo("Warn")); +} diff --git a/src/ServiceControl.Infrastructure/RavenDbLogLevelToLogsMinLevelMapper.cs b/src/ServiceControl.Infrastructure/RavenDbLogLevelToLogsMinLevelMapper.cs new file mode 100644 index 0000000000..d1957ee0d2 --- /dev/null +++ b/src/ServiceControl.Infrastructure/RavenDbLogLevelToLogsMinLevelMapper.cs @@ -0,0 +1,38 @@ +namespace ServiceControl +{ + using Microsoft.Extensions.Logging; + + public class RavenDbLogLevelToLogsMinLevelMapper + { + public static string Map(string ravenDbLogLevel, ILogger logger) + { + switch (ravenDbLogLevel.ToLowerInvariant()) + { + case "trace": + return "Trace"; + case "debug": + return "Debug"; + case "info": + return "Info"; + case "warn": + return "Warn"; + case "error": + return "Error"; + case "fatal": + return "Fatal"; + case "off": + return "Off"; + // Legacy Logs.Mode names, translated the way RavenDB translates them + case "none": + return "Off"; + case "operations": + return "Info"; + case "information": + return "Debug"; + default: + logger.LogWarning("Unknown log level '{RavenDbLogLevel}', mapped to 'Warn'", ravenDbLogLevel); + return "Warn"; + } + } + } +} diff --git a/src/ServiceControl.Infrastructure/RavenDbLogLevelToLogsModeMapper.cs b/src/ServiceControl.Infrastructure/RavenDbLogLevelToLogsModeMapper.cs deleted file mode 100644 index d3316ba830..0000000000 --- a/src/ServiceControl.Infrastructure/RavenDbLogLevelToLogsModeMapper.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace ServiceControl -{ - using Microsoft.Extensions.Logging; - - public class RavenDbLogLevelToLogsModeMapper - { - public static string Map(string ravenDbLogLevel, ILogger logger) - { - switch (ravenDbLogLevel.ToLower()) - { - case "off": // Backwards compatibility with 4.x - case "none": - return "None"; - case "trace": // Backwards compatibility with 4.x - case "debug": // Backwards compatibility with 4.x - case "info": // Backwards compatibility with 4.x - case "information": - return "Information"; - case "error": // Backwards compatibility with 4.x - case "warn": // Backwards compatibility with 4.x - case "fatal": // Backwards compatibility with 4.x - case "operations": - return "Operations"; - default: - logger.LogWarning("Unknown log level '{RavenDbLogLevel}', mapped to 'Operations'", ravenDbLogLevel); - return "Operations"; - } - } - } -} \ No newline at end of file diff --git a/src/ServiceControl.Persistence.RavenDB/Editing/EditFailedMessagesDataStore.cs b/src/ServiceControl.Persistence.RavenDB/Editing/EditFailedMessagesDataStore.cs index 55cd75225d..5c5b078e65 100644 --- a/src/ServiceControl.Persistence.RavenDB/Editing/EditFailedMessagesDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/Editing/EditFailedMessagesDataStore.cs @@ -2,6 +2,7 @@ namespace ServiceControl.Persistence.RavenDB.Editing { using System.Threading; using System.Threading.Tasks; + using Raven.Client.Documents.Session; using Raven.Client.Exceptions; using ServiceControl.MessageFailures; using ServiceControl.Persistence.Recoverability.Editing; @@ -18,7 +19,7 @@ public async Task GetCurrentEditingRequestId(string failedMessageId, Can public async Task TryBeginEdit(string failedMessageId, string editingMessageId, CancellationToken cancellationToken = default) { using var session = await sessionProvider.OpenSession(cancellationToken: cancellationToken); - session.Advanced.UseOptimisticConcurrency = true; + session.Advanced.OptimisticConcurrencyMode = OptimisticConcurrencyMode.Writes; var failedMessage = await session.LoadAsync(FailedMessageIdGenerator.MakeDocumentId(failedMessageId), cancellationToken); if (failedMessage is null) diff --git a/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs b/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs index 52bb6f3b5e..970a74f7d5 100644 --- a/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/ErrorMessagesDataStore.cs @@ -358,7 +358,7 @@ public async Task MarkAsResolved(string failedMessageId, CancellationToken var documentId = FailedMessageIdGenerator.MakeDocumentId(failedMessageId); using var session = await sessionProvider.OpenSession(cancellationToken: cancellationToken); - session.Advanced.UseOptimisticConcurrency = true; + session.Advanced.OptimisticConcurrencyMode = OptimisticConcurrencyMode.Writes; var failedMessage = await session.LoadAsync(documentId, cancellationToken); @@ -452,7 +452,7 @@ public async Task UnArchiveMessages(IEnumerable failedMessageI Dictionary failedMessages; using var session = await sessionProvider.OpenSession(cancellationToken: cancellationToken); - session.Advanced.UseOptimisticConcurrency = true; + session.Advanced.OptimisticConcurrencyMode = OptimisticConcurrencyMode.Writes; var documentIds = failedMessageIds.Select(FailedMessageIdGenerator.MakeDocumentId); diff --git a/src/ServiceControl.Persistence.RavenDB/RavenEmbeddedPersistenceLifecycle.cs b/src/ServiceControl.Persistence.RavenDB/RavenEmbeddedPersistenceLifecycle.cs index 34af6b3f7a..2cc9207afd 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenEmbeddedPersistenceLifecycle.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenEmbeddedPersistenceLifecycle.cs @@ -39,7 +39,7 @@ public async Task Initialize(CancellationToken cancellationToken = default) await initializeSemaphore.WaitAsync(cancellationToken); // TODO: See if more can be refactored out of RavenPersisterSettings - var embeddedConfig = new EmbeddedDatabaseConfiguration(databaseConfiguration.ServerUrl, databaseConfiguration.DatabaseName, databaseConfiguration.DatabasePath, databaseConfiguration.LogPath, databaseConfiguration.LogsMode); + var embeddedConfig = new EmbeddedDatabaseConfiguration(databaseConfiguration.ServerUrl, databaseConfiguration.DatabaseName, databaseConfiguration.DatabasePath, databaseConfiguration.LogPath, databaseConfiguration.LogsMinLevel); database = EmbeddedDatabase.Start(embeddedConfig, lifetime); diff --git a/src/ServiceControl.Persistence.RavenDB/RavenEnvironmentDataProvider.cs b/src/ServiceControl.Persistence.RavenDB/RavenEnvironmentDataProvider.cs index 41ba550345..2dde1be256 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenEnvironmentDataProvider.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenEnvironmentDataProvider.cs @@ -33,7 +33,7 @@ public IEnumerable GetData() => Deferred("Health.Error.FailedImports", FailedImports), Value("Storage.FullTextSearch", () => settings.EnableFullTextSearchOnBodies ? "Enabled" : "Disabled"), Value("Storage.BodyStorage.Type", () => "RavenAttachments"), - Value("Storage.LogLevel", () => settings.LogsMode), + Value("Storage.LogLevel", () => settings.LogsMinLevel), Value("Storage.QueryTimeoutSeconds", () => WhenConfigured(QueryTimeLimit.SettingName, () => Number((int)Math.Round(settings.QueryTimeout.TotalSeconds, MidpointRounding.AwayFromZero)))), Value("Storage.FreeSpaceThresholdPercent", () => WhenConfigured(RavenPersistenceConfiguration.DataSpaceRemainingThresholdKey, () => Number(settings.DataSpaceRemainingThreshold))), Value("Storage.MinimumFreeSpaceForIngestionPercent", () => WhenConfigured(RavenBootstrapper.MinimumStorageLeftRequiredForIngestionKey, () => Number(settings.MinimumStorageLeftRequiredForIngestion))), diff --git a/src/ServiceControl.Persistence.RavenDB/RavenPersistenceConfiguration.cs b/src/ServiceControl.Persistence.RavenDB/RavenPersistenceConfiguration.cs index 7dfaeb715c..ee5071d7ac 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenPersistenceConfiguration.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenPersistenceConfiguration.cs @@ -22,7 +22,7 @@ class RavenPersistenceConfiguration : PersistenceConfiguration, IPersistenceConf public PersistenceSettings CreateSettings(SettingsRootNamespace settingsRootNamespace) { var ravenDbLogLevel = SettingsReader.Read(settingsRootNamespace, RavenBootstrapper.RavenDbLogLevelKey, "Warn"); - var logsMode = RavenDbLogLevelToLogsModeMapper.Map(ravenDbLogLevel, LoggerUtil.CreateStaticLogger()); + var logsMinLevel = RavenDbLogLevelToLogsMinLevelMapper.Map(ravenDbLogLevel, LoggerUtil.CreateStaticLogger()); var settings = new RavenPersisterSettings { @@ -43,7 +43,7 @@ public PersistenceSettings CreateSettings(SettingsRootNamespace settingsRootName QueryTimeout = QueryTimeLimit.Read(settingsRootNamespace, LoggerUtil.CreateStaticLogger()), MaintenanceMode = SettingsReader.Read(settingsRootNamespace, MaintenanceModeKey, false), LogPath = SettingsReader.Read(settingsRootNamespace, RavenBootstrapper.LogsPathKey, DefaultLogLocation()), - LogsMode = logsMode, + LogsMinLevel = logsMinLevel, EnableFullTextSearchOnBodies = SettingsReader.Read(settingsRootNamespace, "EnableFullTextSearchOnBodies", true), ThroughputDatabaseName = SettingsReader.Read(ThroughputSettings.SettingsNamespace, ThroughputSettings.DatabaseNameKey, ThroughputSettings.DefaultDatabaseName) }; diff --git a/src/ServiceControl.Persistence.RavenDB/RavenPersisterSettings.cs b/src/ServiceControl.Persistence.RavenDB/RavenPersisterSettings.cs index 0f59fd9554..b4625773a7 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenPersisterSettings.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenPersisterSettings.cs @@ -29,13 +29,13 @@ class RavenPersisterSettings : PersistenceSettings, IRavenClientCertificateInfo public string ClientCertificatePassword { get; set; } public bool UseEmbeddedServer => string.IsNullOrWhiteSpace(ConnectionString); public string LogPath { get; set; } - public string LogsMode { get; set; } = LogsModeDefault; + public string LogsMinLevel { get; set; } = LogsMinLevelDefault; public string DatabaseName { get; set; } = DatabaseNameDefault; public string ThroughputDatabaseName { get; set; } = ThroughputSettings.DefaultDatabaseName; public const string DatabaseNameDefault = "primary"; public const int DatabaseMaintenancePortDefault = 33334; public const int ExpirationProcessTimerInSecondsDefault = 600; - public const string LogsModeDefault = "Operations"; + public const string LogsMinLevelDefault = "Warn"; public const int ExternalIntegrationsDispatchingBatchSizeDefault = 100; } \ No newline at end of file diff --git a/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/MessageArchiver.cs b/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/MessageArchiver.cs index 9171a7df6f..2294c0347a 100644 --- a/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/MessageArchiver.cs +++ b/src/ServiceControl.Persistence.RavenDB/Recoverability/Archiving/MessageArchiver.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; + using Raven.Client.Documents.Session; using RavenDB; using ServiceControl.Infrastructure.Auth; using ServiceControl.Infrastructure.DomainEvents; @@ -45,7 +46,7 @@ public async Task ArchiveAllInGroup(string groupId, AuditUser? initiatedBy = nul using (var session = await sessionProvider.OpenSession(cancellationToken: cancellationToken)) { - session.Advanced.UseOptimisticConcurrency = true; // Ensure 2 messages don't split the same operation into batches at once + session.Advanced.OptimisticConcurrencyMode = OptimisticConcurrencyMode.Writes; // Ensure 2 messages don't split the same operation into batches at once archiveOperation = await archiveDocumentManager.LoadArchiveOperation(session, groupId, ArchiveType.FailureGroup, cancellationToken); @@ -145,7 +146,7 @@ public async Task UnarchiveAllInGroup(string groupId, AuditUser? initiatedBy = n using (var session = await sessionProvider.OpenSession(cancellationToken: cancellationToken)) { - session.Advanced.UseOptimisticConcurrency = true; // Ensure 2 messages don't split the same operation into batches at once + session.Advanced.OptimisticConcurrencyMode = OptimisticConcurrencyMode.Writes; // Ensure 2 messages don't split the same operation into batches at once unarchiveOperation = await unarchiveDocumentManager.LoadUnarchiveOperation(session, groupId, ArchiveType.FailureGroup, cancellationToken); diff --git a/src/ServiceControl.Persistence.RavenDB/RetryDocumentDataStore.cs b/src/ServiceControl.Persistence.RavenDB/RetryDocumentDataStore.cs index 3b67232127..bc0a8bc583 100644 --- a/src/ServiceControl.Persistence.RavenDB/RetryDocumentDataStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/RetryDocumentDataStore.cs @@ -29,7 +29,7 @@ public async Task AssignMessagesToBatch(string batchId, string[] messageIds, Can using var session = await sessionProvider.OpenSession(cancellationToken: cancellationToken); var documentStore = await documentStoreProvider.GetDocumentStore(cancellationToken); - var batch = new SingleNodeBatchCommand(documentStore.Conventions, session.Advanced.Context, commands); + var batch = new SingleNodeBatchCommand(documentStore.Conventions, commands); await session.Advanced.RequestExecutor.ExecuteAsync(batch, session.Advanced.Context, token: cancellationToken); } diff --git a/src/ServiceControl.Persistence.RavenDB/RetryStagingStore.cs b/src/ServiceControl.Persistence.RavenDB/RetryStagingStore.cs index 1c64c3bc67..c2dec5ddf7 100644 --- a/src/ServiceControl.Persistence.RavenDB/RetryStagingStore.cs +++ b/src/ServiceControl.Persistence.RavenDB/RetryStagingStore.cs @@ -158,7 +158,7 @@ public async Task RecordStagingFailure(IReadOnlyCollection uniqueMessage using var session = await sessionProvider.OpenSession(cancellationToken: cancellationToken); var documentStore = await documentStoreProvider.GetDocumentStore(cancellationToken); - var batch = new SingleNodeBatchCommand(documentStore.Conventions, session.Advanced.Context, commands); + var batch = new SingleNodeBatchCommand(documentStore.Conventions, commands); await session.Advanced.RequestExecutor.ExecuteAsync(batch, session.Advanced.Context, token: cancellationToken); } catch (ConcurrencyException) diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/EmbeddedServerLoggingTests.cs b/src/ServiceControl.Persistence.Tests.RavenDB/EmbeddedServerLoggingTests.cs index bce40613b4..9d6466eda9 100644 --- a/src/ServiceControl.Persistence.Tests.RavenDB/EmbeddedServerLoggingTests.cs +++ b/src/ServiceControl.Persistence.Tests.RavenDB/EmbeddedServerLoggingTests.cs @@ -1,38 +1,30 @@ namespace ServiceControl.Persistence.Tests.RavenDB; -using System; -using System.Diagnostics; using System.IO; -using System.Linq; +using System.Text.Json; using System.Threading.Tasks; using NUnit.Framework; [TestFixture] -class EmbeddedServerLoggingTests +class EmbeddedServerLoggingTests : RavenPersistenceTestBase { + // Asks the server instead of waiting for a log file, because at Warn a quiet server never creates one [Test] - public async Task Server_writes_log_files_to_the_configured_log_path() + public async Task Server_uses_the_configured_log_path_and_minimum_level() { - // The shared server starts with SharedEmbeddedServer.LogsMode and SharedEmbeddedServer.LogPath - await SharedEmbeddedServer.GetInstance(); + var client = DocumentStore.GetRequestExecutor().HttpClient; - // Log files from earlier test runs stay in the folder, so only files written by this run count - var startedAfter = Process.GetCurrentProcess().StartTime.ToUniversalTime(); - var timeout = TimeSpan.FromSeconds(30); - var stopwatch = Stopwatch.StartNew(); + using var response = await client.GetAsync(DocumentStore.Urls[0].TrimEnd('/') + "/admin/logs/configuration"); + response.EnsureSuccessStatusCode(); - while (!HasRecentLogFile(startedAfter) && stopwatch.Elapsed < timeout) + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var logs = document.RootElement.GetProperty("Logs"); + + using (Assert.EnterMultipleScope()) { - await Task.Delay(TimeSpan.FromMilliseconds(250)); + Assert.That(Path.GetFullPath(logs.GetProperty("Path").GetString()!).TrimEnd(Path.DirectorySeparatorChar), Is.EqualTo(Path.GetFullPath(SharedEmbeddedServer.LogPath).TrimEnd(Path.DirectorySeparatorChar))); + Assert.That(logs.GetProperty("MinLevel").GetString(), Is.EqualTo(SharedEmbeddedServer.LogsMinLevel)); + Assert.That(Directory.Exists(SharedEmbeddedServer.LogPath), Is.True); } - - Assert.That(HasRecentLogFile(startedAfter), Is.True, - $"The server should write a log file to {SharedEmbeddedServer.LogPath} at the {SharedEmbeddedServer.LogsMode} level within {timeout.TotalSeconds} seconds"); } - - static bool HasRecentLogFile(DateTime startedAfter) => - Directory.Exists(SharedEmbeddedServer.LogPath) - && new DirectoryInfo(SharedEmbeddedServer.LogPath) - .EnumerateFiles("*", SearchOption.AllDirectories) - .Any(file => file.Length > 0 && file.LastWriteTimeUtc >= startedAfter); } diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/EnvironmentDataConfigurationTests.cs b/src/ServiceControl.Persistence.Tests.RavenDB/EnvironmentDataConfigurationTests.cs index 8f1d0d0783..00067d65d0 100644 --- a/src/ServiceControl.Persistence.Tests.RavenDB/EnvironmentDataConfigurationTests.cs +++ b/src/ServiceControl.Persistence.Tests.RavenDB/EnvironmentDataConfigurationTests.cs @@ -27,7 +27,7 @@ public async Task Should_report_defaults_when_nothing_is_configured() using (Assert.EnterMultipleScope()) { - Assert.That(data["Storage.LogLevel"], Is.EqualTo("Operations")); + Assert.That(data["Storage.LogLevel"], Is.EqualTo("Warn")); Assert.That(data["Storage.QueryTimeoutSeconds"], Is.EqualTo("Default")); Assert.That(data["Storage.FreeSpaceThresholdPercent"], Is.EqualTo("Default")); Assert.That(data["Storage.MinimumFreeSpaceForIngestionPercent"], Is.EqualTo("Default")); diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/MessagesViewIndexTests.cs b/src/ServiceControl.Persistence.Tests.RavenDB/MessagesViewIndexTests.cs new file mode 100644 index 0000000000..3ce26112df --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.RavenDB/MessagesViewIndexTests.cs @@ -0,0 +1,36 @@ +namespace ServiceControl.Persistence.Tests.RavenDB +{ + using System.Linq; + using System.Threading.Tasks; + using NUnit.Framework; + using Raven.Client.Documents; + using Raven.Client.Documents.Linq; + using Raven.Client.Documents.Operations.Indexes; + using ServiceControl.MessageFailures; + using ServiceControl.Persistence; + + [TestFixture] + class MessagesViewIndexTests : RavenPersistenceTestBase + { + [Test] + public async Task Should_map_failed_messages_without_errors_and_find_them_by_header_value() + { + var failedMessage = await SeedFailedMessage(FailedMessageBuilder.Default(m => m.ProcessingAttempts[0].Headers["HeaderA"] = "Gandalf")); + await DocumentStore.WaitForIndexingAsync(); + + var indexErrors = await DocumentStore.Maintenance.SendAsync(new GetIndexErrorsOperation([nameof(MessagesViewIndex)])); + + using var session = DocumentStore.OpenAsyncSession(); + var results = await session.Query() + .Search(x => x.Query, "Gandalf") + .OfType() + .ToListAsync(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(indexErrors.Single().Errors, Is.Empty); + Assert.That(results.Select(x => x.Id), Is.EqualTo(new[] { failedMessage.Id })); + } + } + } +} diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/SharedEmbeddedServer.cs b/src/ServiceControl.Persistence.Tests.RavenDB/SharedEmbeddedServer.cs index 02be90e83e..cdb2fe4799 100644 --- a/src/ServiceControl.Persistence.Tests.RavenDB/SharedEmbeddedServer.cs +++ b/src/ServiceControl.Persistence.Tests.RavenDB/SharedEmbeddedServer.cs @@ -16,7 +16,7 @@ static class SharedEmbeddedServer { - public const string LogsMode = "Operations"; + public const string LogsMinLevel = "Warn"; public static string LogPath => Path.Combine(TestContext.CurrentContext.WorkDirectory, "Logs", "Primary"); @@ -38,18 +38,18 @@ public static async Task GetInstance(CancellationToken cancell var dbPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "Tests", "PrimaryData"); var logPath = LogPath; - var logsMode = LogsMode; + var logsMinLevel = LogsMinLevel; var settings = new RavenPersisterSettings { DatabasePath = dbPath, LogPath = logPath, - LogsMode = logsMode, + LogsMinLevel = logsMinLevel, DatabaseMaintenancePort = PortUtility.GetAssignedOrAvailablePort(RavenPersisterSettings.DatabaseMaintenancePortDefault) }; // TODO: See if more refactoring can be done between this and the RavenPersisterSettings above - var embeddedConfig = new EmbeddedDatabaseConfiguration(settings.ServerUrl, RavenPersisterSettings.DatabaseNameDefault, dbPath, logPath, logsMode) { RunInMemory = true }; + var embeddedConfig = new EmbeddedDatabaseConfiguration(settings.ServerUrl, RavenPersisterSettings.DatabaseNameDefault, dbPath, logPath, logsMinLevel) { RunInMemory = true }; var server = EmbeddedDatabase.Start(embeddedConfig, new ApplicationLifetime(new NullLogger())); diff --git a/src/ServiceControl.RavenDB/EmbeddedDatabase.cs b/src/ServiceControl.RavenDB/EmbeddedDatabase.cs index efdf66b06f..b409831c99 100644 --- a/src/ServiceControl.RavenDB/EmbeddedDatabase.cs +++ b/src/ServiceControl.RavenDB/EmbeddedDatabase.cs @@ -15,7 +15,6 @@ namespace ServiceControl.RavenDB using Raven.Client.Documents.Conventions; using Raven.Embedded; using ServiceControl.Infrastructure; - using Sparrow.Logging; public sealed class EmbeddedDatabase : IDisposable { @@ -53,19 +52,7 @@ public static EmbeddedDatabase Start(EmbeddedDatabaseConfiguration databaseConfi var nugetPackagesPath = Path.Combine(databaseConfiguration.DbPath, "Packages", "NuGet"); - var logMode = Enum.Parse(databaseConfiguration.LogsMode); - - if (logMode == LogMode.Information) // Most verbose - { - LoggingSource.Instance.EnableConsoleLogging(); - LoggingSource.Instance.SetupLogMode( - logMode, - Path.Combine(databaseConfiguration.LogPath, "Raven.Embedded"), - retentionTime: TimeSpan.FromDays(14), - retentionSize: 1024 * 1024 * 10, - compress: false - ); - } + Directory.CreateDirectory(databaseConfiguration.LogPath); logger.LogInformation("Loading RavenDB license from {LicenseFileName}", licenseFileNameAndServerDirectory.LicenseFileName); @@ -79,7 +66,7 @@ public static EmbeddedDatabase Start(EmbeddedDatabaseConfiguration databaseConfi { CommandLineArgs = [ - $"--Logs.Mode={databaseConfiguration.LogsMode}", + $"--Logs.MinLevel={databaseConfiguration.LogsMinLevel}", // HINT: If this is not set, then Raven will pick a default location relative to the server binaries // See https://github.com/ravendb/ravendb/issues/15694 $"--Indexing.NuGetPackagesPath=\"{nugetPackagesPath}\"", @@ -281,7 +268,7 @@ static void RecordStartup(EmbeddedDatabaseConfiguration configuration) Database Path: {configuration.DbPath} Database Size: {ByteSize.FromBytes(dataSize).ToString("#.##", CultureInfo.InvariantCulture)} Database Folder Size: {ByteSize.FromBytes(folderSize).ToString("#.##", CultureInfo.InvariantCulture)} -RavenDB Logging Level: {configuration.LogsMode} +RavenDB Logging Level: {configuration.LogsMinLevel} -------------------------------------------------------------"; logger.LogInformation(startupMessage); diff --git a/src/ServiceControl.RavenDB/EmbeddedDatabaseConfiguration.cs b/src/ServiceControl.RavenDB/EmbeddedDatabaseConfiguration.cs index 6aec695755..6b9bbb6849 100644 --- a/src/ServiceControl.RavenDB/EmbeddedDatabaseConfiguration.cs +++ b/src/ServiceControl.RavenDB/EmbeddedDatabaseConfiguration.cs @@ -1,12 +1,12 @@ namespace ServiceControl.RavenDB { - public class EmbeddedDatabaseConfiguration(string serverUrl, string dbName, string dbPath, string logPath, string logsMode) + public class EmbeddedDatabaseConfiguration(string serverUrl, string dbName, string dbPath, string logPath, string logsMinLevel) { public string Name { get; } = dbName; public string DbPath { get; } = dbPath; public string ServerUrl { get; } = serverUrl; public string LogPath { get; } = logPath; - public string LogsMode { get; } = logsMode; + public string LogsMinLevel { get; } = logsMinLevel; public bool RunInMemory { get; set; } } From 554c90c94a8b5bdf48bd372a372160d121fa00da Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Fri, 23 Oct 2026 07:31:52 +0200 Subject: [PATCH 5/9] Escape control characters in throughput endpoint document IDs Databases created by RavenDB 7.2.5 or later reject document IDs that contain control characters. Throughput document IDs embed the endpoint name, so such a name could not be stored in a new database. IDs without control characters are unchanged, so existing documents still resolve. --- .../Throughput/EndpointExtensions.cs | 7 +++- .../Throughput/EndpointDocumentIdTests.cs | 39 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 src/ServiceControl.Persistence.Tests.RavenDB/Throughput/EndpointDocumentIdTests.cs diff --git a/src/ServiceControl.Persistence.RavenDB/Throughput/EndpointExtensions.cs b/src/ServiceControl.Persistence.RavenDB/Throughput/EndpointExtensions.cs index ebe95db5ed..3278cdfa04 100644 --- a/src/ServiceControl.Persistence.RavenDB/Throughput/EndpointExtensions.cs +++ b/src/ServiceControl.Persistence.RavenDB/Throughput/EndpointExtensions.cs @@ -1,15 +1,20 @@ #nullable enable namespace ServiceControl.Persistence.RavenDB.Throughput; +using System.Linq; using Particular.LicensingComponent.Contracts; using ServiceControl.Persistence.RavenDB.Throughput.Models; static class EndpointExtensions { - public static string GenerateDocumentId(this EndpointIdentifier endpointId) => $"{endpointId.Name}/{endpointId.ThroughputSource}"; + public static string GenerateDocumentId(this EndpointIdentifier endpointId) => $"{EscapeControlCharacters(endpointId.Name)}/{endpointId.ThroughputSource}"; public static string GenerateDocumentId(this EndpointDocument endpoint) => endpoint.EndpointId.GenerateDocumentId(); + // Databases created by RavenDB 7.2.5 or later reject control characters in document IDs + static string? EscapeControlCharacters(string? name) => + name is not null && name.Any(char.IsControl) ? string.Concat(name.Select(c => char.IsControl(c) ? $"%{(int)c:X2}" : c.ToString())) : name; + public static Endpoint ToEndpoint(this EndpointDocument endpointDocument) => new(endpointDocument.EndpointId) { SanitizedName = endpointDocument.SanitizedName, diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/Throughput/EndpointDocumentIdTests.cs b/src/ServiceControl.Persistence.Tests.RavenDB/Throughput/EndpointDocumentIdTests.cs new file mode 100644 index 0000000000..c04de4132c --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.RavenDB/Throughput/EndpointDocumentIdTests.cs @@ -0,0 +1,39 @@ +namespace ServiceControl.Persistence.Tests.RavenDB.Throughput; + +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using Particular.LicensingComponent.Contracts; +using ServiceControl.Persistence.RavenDB.Throughput; + +[TestFixture] +class EndpointDocumentIdTests : RavenPersistenceTestBase +{ + [TestCase("Sales", "Sales/Audit")] + [TestCase("Sales.Billing@machine", "Sales.Billing@machine/Audit")] + [TestCase("Sales%01Billing", "Sales%01Billing/Audit")] + [TestCase("Sales\\u0001Billing", "Sales\\u0001Billing/Audit")] + [TestCase("Ünïcode Endpoint", "Ünïcode Endpoint/Audit")] + public void Should_not_change_the_document_id_of_a_name_without_control_characters(string name, string expectedId) => + Assert.That(new EndpointIdentifier(name, ThroughputSource.Audit).GenerateDocumentId(), Is.EqualTo(expectedId)); + + [TestCase("Sales\u0001Billing", "Sales%01Billing/Audit")] + [TestCase("Sales\tBilling", "Sales%09Billing/Audit")] + [TestCase("Sales\u007FBilling", "Sales%7FBilling/Audit")] + [TestCase("Sales\u0085Billing", "Sales%85Billing/Audit")] + [TestCase("\u0000Sales\u001F", "%00Sales%1F/Audit")] + public void Should_escape_control_characters_in_the_document_id(string name, string expectedId) => + Assert.That(new EndpointIdentifier(name, ThroughputSource.Audit).GenerateDocumentId(), Is.EqualTo(expectedId)); + + [Test] + public async Task Endpoint_name_containing_a_control_character_can_be_stored_and_read_back_in_a_new_database() + { + var endpoint = new Endpoint("Endpoint\u0001Name", ThroughputSource.Audit); + + await LicensingDataStore.SaveEndpoint(endpoint); + + var endpoints = await LicensingDataStore.GetAllEndpoints(true); + + Assert.That(endpoints.Single().Id.Name, Is.EqualTo("Endpoint\u0001Name")); + } +} From 94734b65b5bcc654dd0b1a844c3e57088a4a9753 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Fri, 23 Oct 2026 07:38:27 +0200 Subject: [PATCH 6/9] Save enums as integers in RavenDB patches SaveEnumsAsIntegers does not apply to patch scripts built from typed patches. Set SaveEnumsAsIntegersForPatching wherever SaveEnumsAsIntegers is set, so a future typed patch cannot write enums as strings. --- .../RavenExternalPersistenceLifecycle.cs | 3 ++- .../CleanDatabaseFixture.cs | 2 +- .../RavenExternalPersistenceLifecycle.cs | 3 ++- .../ScratchDatabase.cs | 2 +- src/ServiceControl.RavenDB/EmbeddedDatabase.cs | 3 ++- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/ServiceControl.Audit.Persistence.RavenDB/RavenExternalPersistenceLifecycle.cs b/src/ServiceControl.Audit.Persistence.RavenDB/RavenExternalPersistenceLifecycle.cs index 63a279dd6b..0dbbce5e7e 100644 --- a/src/ServiceControl.Audit.Persistence.RavenDB/RavenExternalPersistenceLifecycle.cs +++ b/src/ServiceControl.Audit.Persistence.RavenDB/RavenExternalPersistenceLifecycle.cs @@ -43,7 +43,8 @@ public async Task Initialize(CancellationToken cancellationToken = default) Certificate = RavenClientCertificate.FindClientCertificate(configuration.ServerConfiguration), Conventions = new DocumentConventions { - SaveEnumsAsIntegers = true + SaveEnumsAsIntegers = true, + SaveEnumsAsIntegersForPatching = true } }; diff --git a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/CleanDatabaseFixture.cs b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/CleanDatabaseFixture.cs index 0c6861fa48..b209e7f4ab 100644 --- a/src/ServiceControl.Audit.Persistence.Tests.RavenDB/CleanDatabaseFixture.cs +++ b/src/ServiceControl.Audit.Persistence.Tests.RavenDB/CleanDatabaseFixture.cs @@ -28,7 +28,7 @@ public async Task CreateCleanDatabase() { Urls = [embeddedDatabase.ServerUrl], Database = databaseName, - Conventions = new DocumentConventions { SaveEnumsAsIntegers = true } + Conventions = new DocumentConventions { SaveEnumsAsIntegers = true, SaveEnumsAsIntegersForPatching = true } }; Store.Initialize(); diff --git a/src/ServiceControl.Persistence.RavenDB/RavenExternalPersistenceLifecycle.cs b/src/ServiceControl.Persistence.RavenDB/RavenExternalPersistenceLifecycle.cs index fe7a2d0c02..15ee6b5cc4 100644 --- a/src/ServiceControl.Persistence.RavenDB/RavenExternalPersistenceLifecycle.cs +++ b/src/ServiceControl.Persistence.RavenDB/RavenExternalPersistenceLifecycle.cs @@ -43,7 +43,8 @@ public async Task Initialize(CancellationToken cancellationToken = default) Certificate = RavenClientCertificate.FindClientCertificate(settings), Conventions = new DocumentConventions { - SaveEnumsAsIntegers = true + SaveEnumsAsIntegers = true, + SaveEnumsAsIntegersForPatching = true } }; diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/ScratchDatabase.cs b/src/ServiceControl.Persistence.Tests.RavenDB/ScratchDatabase.cs index da8ce81558..b63605715f 100644 --- a/src/ServiceControl.Persistence.Tests.RavenDB/ScratchDatabase.cs +++ b/src/ServiceControl.Persistence.Tests.RavenDB/ScratchDatabase.cs @@ -31,7 +31,7 @@ public static async Task Create(CancellationToken cancellationT { Urls = [server.ServerUrl], Database = name, - Conventions = new DocumentConventions { SaveEnumsAsIntegers = true } + Conventions = new DocumentConventions { SaveEnumsAsIntegers = true, SaveEnumsAsIntegersForPatching = true } }; store.Initialize(); diff --git a/src/ServiceControl.RavenDB/EmbeddedDatabase.cs b/src/ServiceControl.RavenDB/EmbeddedDatabase.cs index b409831c99..14714fe4ba 100644 --- a/src/ServiceControl.RavenDB/EmbeddedDatabase.cs +++ b/src/ServiceControl.RavenDB/EmbeddedDatabase.cs @@ -160,7 +160,8 @@ public async Task Connect(CancellationToken cancellationToken = { Conventions = new DocumentConventions { - SaveEnumsAsIntegers = true + SaveEnumsAsIntegers = true, + SaveEnumsAsIntegersForPatching = true }, SkipCreatingDatabase = true }; From fdd49c456c267f32961fb790ff23ccaae98f7f8c Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Fri, 23 Oct 2026 07:43:21 +0200 Subject: [PATCH 7/9] Exclude unused RavenDB server libraries from the package RavenDB 7.2.7 ships the native ONNX runtime libraries and a local embeddings model that ServiceControl does not use. The server starts and serves Studio without them, which saves about 19 MB in RavenDBServer.zip. The managed AI assemblies stay because Raven.Server loads them at startup. --- .../Validation/PackagedRavenDBTests.cs | 9 ++++++++- .../ServiceControlInstaller.Packaging.csproj | 6 +++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/ServiceControlInstaller.Engine.UnitTests/Validation/PackagedRavenDBTests.cs b/src/ServiceControlInstaller.Engine.UnitTests/Validation/PackagedRavenDBTests.cs index c32b2b619e..20b1f1156b 100644 --- a/src/ServiceControlInstaller.Engine.UnitTests/Validation/PackagedRavenDBTests.cs +++ b/src/ServiceControlInstaller.Engine.UnitTests/Validation/PackagedRavenDBTests.cs @@ -21,7 +21,7 @@ namespace ServiceControlInstaller.Engine.UnitTests.Validation class PackagedRavenDBTests { [Test] - public async Task CanPersistAndIndexDocumentsWithoutNativeKafkaDependencies() + public async Task CanPersistAndIndexDocumentsWithoutExcludedFiles() { var root = Path.Combine(Path.GetTempPath(), "PackagedRavenDB", Guid.NewGuid().ToString("N")); var serverDirectory = Path.Combine(root, "Server"); @@ -43,6 +43,13 @@ public async Task CanPersistAndIndexDocumentsWithoutNativeKafkaDependencies() Assert.That(Path.Combine(serverDirectory, "libcurl.dll"), Does.Not.Exist); Assert.That(Path.Combine(serverDirectory, "librdkafka.dll"), Does.Not.Exist); Assert.That(Path.Combine(serverDirectory, "librdkafkacpp.dll"), Does.Not.Exist); + Assert.That(Path.Combine(serverDirectory, "onnxruntime.dll"), Does.Not.Exist); + Assert.That(Path.Combine(serverDirectory, "onnxruntime.lib"), Does.Not.Exist); + Assert.That(Path.Combine(serverDirectory, "onnxruntime_providers_shared.dll"), Does.Not.Exist); + Assert.That(Path.Combine(serverDirectory, "onnxruntime_providers_shared.lib"), Does.Not.Exist); + Assert.That(Path.Combine(serverDirectory, "onnxruntime-genai.dll"), Does.Not.Exist); + Assert.That(Path.Combine(serverDirectory, "onnxruntime-genai.lib"), Does.Not.Exist); + Assert.That(Path.Combine(serverDirectory, "LocalEmbeddings"), Does.Not.Exist); Assert.That(Path.Combine(serverDirectory, "Raven.Server.exe"), Does.Exist); } diff --git a/src/ServiceControlInstaller.Packaging/ServiceControlInstaller.Packaging.csproj b/src/ServiceControlInstaller.Packaging/ServiceControlInstaller.Packaging.csproj index 7a4136f323..b0b7ce6df3 100644 --- a/src/ServiceControlInstaller.Packaging/ServiceControlInstaller.Packaging.csproj +++ b/src/ServiceControlInstaller.Packaging/ServiceControlInstaller.Packaging.csproj @@ -46,7 +46,11 @@ + Exclude="$(ArtifactsPath)$(RavenDBServer)\libcurl.dll;$(ArtifactsPath)$(RavenDBServer)\librdkafka.dll;$(ArtifactsPath)$(RavenDBServer)\librdkafkacpp.dll; + $(ArtifactsPath)$(RavenDBServer)\onnxruntime.dll;$(ArtifactsPath)$(RavenDBServer)\onnxruntime.lib; + $(ArtifactsPath)$(RavenDBServer)\onnxruntime_providers_shared.dll;$(ArtifactsPath)$(RavenDBServer)\onnxruntime_providers_shared.lib; + $(ArtifactsPath)$(RavenDBServer)\onnxruntime-genai.dll;$(ArtifactsPath)$(RavenDBServer)\onnxruntime-genai.lib; + $(ArtifactsPath)$(RavenDBServer)\LocalEmbeddings\**" /> From e318706290a7c3a23ec93aa821e4f5dfa3c28406 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Fri, 23 Oct 2026 07:43:06 +0200 Subject: [PATCH 8/9] Reject RavenDB 7.2 servers that corrupt migrated databases on compaction RavenDB 7.2.0 to 7.2.4 corrupt the attachments index when an operator compacts a database migrated from 6.2 or 7.1. 7.2.5 fixed it and repairs the stale schema when the database loads, so refusing to start on those patches moves operators to a server that repairs their database before a compaction can damage it. The check keeps RavenDB's compatibility rule, which ignores the patch: the server's major.minor must be at least the client's. The 7.2.5 floor is fixed, so a later client patch does not raise the server requirement. --- .../StartupChecksTests.cs | 96 +++++++++++++++++++ src/ServiceControl.RavenDB/StartupChecks.cs | 60 ++++++++---- 2 files changed, 140 insertions(+), 16 deletions(-) create mode 100644 src/ServiceControl.Persistence.Tests.RavenDB/StartupChecksTests.cs diff --git a/src/ServiceControl.Persistence.Tests.RavenDB/StartupChecksTests.cs b/src/ServiceControl.Persistence.Tests.RavenDB/StartupChecksTests.cs new file mode 100644 index 0000000000..179e2d919e --- /dev/null +++ b/src/ServiceControl.Persistence.Tests.RavenDB/StartupChecksTests.cs @@ -0,0 +1,96 @@ +namespace ServiceControl.Persistence.Tests.RavenDB; + +using System; +using NUnit.Framework; +using ServiceControl.RavenDB; + +[TestFixture] +class StartupChecksTests +{ + static readonly Version Client727 = new(7, 2, 7); + + [TestCase("6.2.0")] + [TestCase("6.2.20")] + [TestCase("7.1.9")] + public void Should_reject_servers_with_a_lower_major_minor_version_than_the_client(string serverVersion) + { + var isValid = StartupChecks.TryValidateServerVersion(serverVersion, Client727, out var error); + + using (Assert.EnterMultipleScope()) + { + Assert.That(isValid, Is.False); + Assert.That(error, Does.Contain("expects RavenDB Server version 7.2 or higher") + .And.Contain($"the server is using {serverVersion}") + .And.Contain("servicecontrol-ravendb container")); + } + } + + [Test] + public void Should_reject_servers_with_a_lower_major_minor_version_than_a_newer_client() + { + var isValid = StartupChecks.TryValidateServerVersion("7.2.9", new Version(7, 3, 0), out var error); + + using (Assert.EnterMultipleScope()) + { + Assert.That(isValid, Is.False); + Assert.That(error, Does.Contain("expects RavenDB Server version 7.3 or higher").And.Contain("the server is using 7.2.9")); + } + } + + [TestCase("7.2.0", "7.2.0")] + [TestCase("7.2.4", "7.2.4")] + [TestCase("7.2.4-nightly-20260101-0100", "7.2.4")] + public void Should_reject_servers_with_the_known_attachments_index_corruption(string serverVersion, string reportedVersion) + { + var isValid = StartupChecks.TryValidateServerVersion(serverVersion, Client727, out var error); + + using (Assert.EnterMultipleScope()) + { + Assert.That(isValid, Is.False); + Assert.That(error, Does.Contain($"RavenDB Server {reportedVersion} can corrupt the attachments index") + .And.Contain("to 7.2.5 or later") + .And.Contain("servicecontrol-ravendb container")); + } + } + + [TestCase("7.2.5")] + [TestCase("7.2.6")] + [TestCase("7.2.7")] + [TestCase("7.2.10")] + [TestCase("7.2.7+441f983")] + [TestCase("7.2.8-nightly-20260101-0100")] + [TestCase("7.3.0")] + [TestCase("8.0.0")] + public void Should_accept_compatible_servers_without_the_known_corruption(string serverVersion) + { + var isValid = StartupChecks.TryValidateServerVersion(serverVersion, Client727, out var error); + + using (Assert.EnterMultipleScope()) + { + Assert.That(isValid, Is.True); + Assert.That(error, Is.Null); + } + } + + [TestCase("7.2.5")] + [TestCase("7.2.7")] + public void Should_not_raise_the_floor_with_the_client_patch_version(string serverVersion) + { + var isValid = StartupChecks.TryValidateServerVersion(serverVersion, new Version(7, 2, 10), out _); + + Assert.That(isValid, Is.True); + } + + [TestCase("")] + [TestCase("unknown")] + public void Should_reject_unreadable_server_versions(string serverVersion) + { + var isValid = StartupChecks.TryValidateServerVersion(serverVersion, Client727, out var error); + + using (Assert.EnterMultipleScope()) + { + Assert.That(isValid, Is.False); + Assert.That(error, Does.Contain($"could not read the server version '{serverVersion}'")); + } + } +} diff --git a/src/ServiceControl.RavenDB/StartupChecks.cs b/src/ServiceControl.RavenDB/StartupChecks.cs index a8c44278db..8b0730f1e2 100644 --- a/src/ServiceControl.RavenDB/StartupChecks.cs +++ b/src/ServiceControl.RavenDB/StartupChecks.cs @@ -43,27 +43,55 @@ public static async Task FindIndexesUsingCorax(IDocumentStore store, s public static async Task EnsureServerVersion(IDocumentStore store, CancellationToken cancellationToken = default) { - // RavenDB compatibility policy is that the major/minor version of the server must be - // equal or higher than the client and ignores the patch version. - // - // https://docs.ravendb.net/6.2/client-api/faq/backward-compatibility/#compatibility---ravendb-42-and-higher - // - // > Starting with version 4.2, RavenDB clients are compatible with any server of their own version and higher. - // > E.g. - - // > - // > Client 4.2 is compatible with Server 4.2, Server 4.5, Server 5.2, and any other server of a higher version. - var build = await store.Maintenance.Server.SendAsync(new GetBuildNumberOperation(), cancellationToken); - var serverProductVersion = new Version(build.ProductVersion); - var clientVersion = typeof(Raven.Client.Constants).Assembly.GetCustomAttributes().First().InformationalVersion; - var parts = clientVersion.Split('.'); - var clientProductVersion = new Version($"{parts[0]}.{parts[1]}"); + if (!TryValidateServerVersion(build.FullVersion, ClientVersion, out var error)) + { + throw new Exception(error); + } + } + + public static bool TryValidateServerVersion(string serverFullVersion, Version clientVersion, out string error) + { + var clientMajorMinor = new Version(clientVersion.Major, clientVersion.Minor); + + // FullVersion can carry a suffix such as "7.2.8-nightly-20260101-0100" or "7.2.7+commit" + var numericPart = serverFullVersion.Split('-', '+')[0]; + + if (!Version.TryParse(numericPart, out var serverVersion)) + { + error = $"ServiceControl expects RavenDB Server version {clientMajorMinor} or higher, but could not read the server version '{serverFullVersion}'."; + return false; + } - if (clientProductVersion > serverProductVersion) + // RavenDB ignores the patch for compatibility: https://docs.ravendb.net/7.2/client-api/faq/backward-compatibility + if (new Version(serverVersion.Major, serverVersion.Minor) < clientMajorMinor) { - throw new Exception($"ServiceControl expects RavenDB Server version {clientProductVersion} or higher, but the server is using {serverProductVersion}."); + error = $"ServiceControl expects RavenDB Server version {clientMajorMinor} or higher, but the server is using {serverVersion}. " + + "Upgrade the external RavenDB server or the servicecontrol-ravendb container before starting ServiceControl."; + return false; } + + // Compacting a migrated database corrupts its attachments index before 7.2.5: https://github.com/ravendb/ravendb/releases/tag/7.2.5 + if (serverVersion.Major == 7 && serverVersion.Minor == 2 && serverVersion < FirstServerWithAttachmentsIndexFix) + { + error = $"RavenDB Server {serverVersion} can corrupt the attachments index of migrated databases during compaction. " + + $"Upgrade the external RavenDB server or the servicecontrol-ravendb container to {FirstServerWithAttachmentsIndexFix} or later before starting ServiceControl."; + return false; + } + + error = null; + return true; + } + + static readonly Version FirstServerWithAttachmentsIndexFix = new(7, 2, 5); + + static readonly Version ClientVersion = ParseClientVersion(); + + static Version ParseClientVersion() + { + var clientVersion = typeof(Raven.Client.Constants).Assembly.GetCustomAttributes().First().InformationalVersion; + return Version.Parse(clientVersion.Split('-', '+')[0]); } static readonly ILogger Logger = LoggerUtil.CreateStaticLogger(typeof(StartupChecks)); From 089959848538c23bdb1783bf41c51f46599c21c8 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Fri, 23 Oct 2026 07:05:08 +0200 Subject: [PATCH 9/9] Require the latest 6.x before upgrading to 7 The RavenDB 6.2 to 7.2 data upgrade is one-way, and the 6.x data migrations must finish on RavenDB 6.2 first. Add 6.21.0 as the last version of major 6 to the upgrade path, as 5.11.0 was added for the move to 6. Instances on an older version are told to upgrade to 6.21.0 first. --- .../Configuration/UpgradeInfoTests.cs | 56 +++++++++++++++++++ .../ServiceControl/UpgradeInfo.cs | 3 +- 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 src/ServiceControlInstaller.Engine.UnitTests/Configuration/UpgradeInfoTests.cs diff --git a/src/ServiceControlInstaller.Engine.UnitTests/Configuration/UpgradeInfoTests.cs b/src/ServiceControlInstaller.Engine.UnitTests/Configuration/UpgradeInfoTests.cs new file mode 100644 index 0000000000..72a088984e --- /dev/null +++ b/src/ServiceControlInstaller.Engine.UnitTests/Configuration/UpgradeInfoTests.cs @@ -0,0 +1,56 @@ +namespace ServiceControlInstaller.Engine.UnitTests.Configuration +{ + using NuGet.Versioning; + using NUnit.Framework; + using ServiceControlInstaller.Engine.Configuration.ServiceControl; + + [TestFixture] + public class UpgradeInfoTests + { + [TestCase("6.21.0")] + [TestCase("6.21.0-beta.1")] + [TestCase("6.21.2")] + [TestCase("6.22.0")] + [TestCase("7.0.0")] + public void Should_accept_the_latest_version_of_major_6_and_later(string current) + { + var upgradeInfo = UpgradeInfo.GetUpgradePathFor(SemanticVersion.Parse(current)); + + Assert.Multiple(() => + { + Assert.That(upgradeInfo.HasIncompatibleVersion, Is.False); + Assert.That(upgradeInfo.UpgradePath, Is.Empty); + }); + } + + [TestCase("6.0.0", "6.21.0")] + [TestCase("6.20.0", "6.21.0")] + public void Should_require_the_latest_version_of_major_6_from_an_older_6(string current, string expectedPath) + { + var upgradeInfo = UpgradeInfo.GetUpgradePathFor(SemanticVersion.Parse(current)); + + Assert.Multiple(() => + { + Assert.That(upgradeInfo.HasIncompatibleVersion, Is.True); + Assert.That(upgradeInfo.ToString(), Is.EqualTo(expectedPath)); + }); + } + + [TestCase("5.11.0", "6.21.0")] + [TestCase("5.11.11", "6.21.0")] + [TestCase("5.0.0", "5.11.0 ➡️ 6.21.0")] + [TestCase("4.33.0", "5.11.0 ➡️ 6.21.0")] + [TestCase("4.26.0", "4.33.0 ➡️ 5.11.0 ➡️ 6.21.0")] + [TestCase("1.0.0", "1.48.0 ➡️ 2.1.5 ➡️ 3.8.4 ➡️ 4.33.0 ➡️ 5.11.0 ➡️ 6.21.0")] + public void Should_keep_requiring_the_last_version_of_each_earlier_major(string current, string expectedPath) + { + var upgradeInfo = UpgradeInfo.GetUpgradePathFor(SemanticVersion.Parse(current)); + + Assert.Multiple(() => + { + Assert.That(upgradeInfo.HasIncompatibleVersion, Is.True); + Assert.That(upgradeInfo.ToString(), Is.EqualTo(expectedPath)); + }); + } + } +} diff --git a/src/ServiceControlInstaller.Engine/Configuration/ServiceControl/UpgradeInfo.cs b/src/ServiceControlInstaller.Engine/Configuration/ServiceControl/UpgradeInfo.cs index eb4a738a8d..d2280bdca0 100644 --- a/src/ServiceControlInstaller.Engine/Configuration/ServiceControl/UpgradeInfo.cs +++ b/src/ServiceControlInstaller.Engine/Configuration/ServiceControl/UpgradeInfo.cs @@ -11,7 +11,8 @@ public class UpgradeInfo new(2, 1, 5), new(3, 8, 4), new(4, 33, 0), // Added IngestErrorMessages setting to enable message replay during 4to5 upgrade guide scenario - new(5, 11, 0) + new(5, 11, 0), + new(6, 21, 0) ]; public SemanticVersion[] UpgradePath { get; private init; }