Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
namespace ServiceControl.AcceptanceTests.RavenDB.Recoverability.MessageFailures
{
using System;
using System.Threading.Tasks;
using AcceptanceTesting;
using AcceptanceTesting.EndpointTemplates;
using CompositeViews.Messages;
using NServiceBus;
using NServiceBus.AcceptanceTesting;
using NServiceBus.AcceptanceTesting.Customization;
using NUnit.Framework;

// EnableFullTextSearchOnBodies is honoured by the RavenDB persister only: it decides at ingestion
// whether the body is indexed. The EF persisters always index it, so this test cannot be shared.
class When_body_search_is_disabled : AcceptanceTest
{
[Test]
public async Task Should_not_be_found()
{
SetSettings = settings => settings.PersisterSpecificSettings.EnableFullTextSearchOnBodies = false;

var searchString = "forty-two";

var context = await Define<MyContext>()
.WithEndpoint<Sender>(b => b.When((bus, c) => bus.Send(new MyMessage
{
Something = "Somewhere in the body is the answer to all of the questions. forty-two"
})))
.WithEndpoint<Receiver>(b => b.DoNotFailOnErrorMessages())
.Done(async c =>
{
if (c.MessageId != null && await this.TryGetMany<MessagesView>($"/api/messages/search/{c.MessageId}"))
{
c.MessageIngested = true;
}

if (!c.MessageIngested)
{
return false;
}

c.MessageFound = await this.TryGetMany<MessagesView>($"/api/messages/search/{searchString}");
return true;
})
.Run();

using (Assert.EnterMultipleScope())
{
Assert.That(context.MessageIngested, Is.True);
Assert.That(context.MessageFound, Is.False);
}
}

public class Sender : EndpointConfigurationBuilder
{
public Sender() =>
EndpointSetup<DefaultServerWithoutAudit>(c =>
{
var routing = c.ConfigureRouting();
routing.RouteToEndpoint(typeof(MyMessage), typeof(Receiver));
});
}

public class Receiver : EndpointConfigurationBuilder
{
public Receiver() =>
EndpointSetup<DefaultServerWithoutAudit>(c => c.NoRetries());

[Handler]
public class MyMessageHandler(MyContext scenarioContext) : IHandleMessages<MyMessage>
{
public Task Handle(MyMessage message, IMessageHandlerContext context)
{
scenarioContext.MessageId = context.MessageId;
throw new Exception("Simulated exception");
}
}
}

public class MyMessage : ICommand
{
public string Something { get; set; }
}

public class MyContext : ScenarioContext
{
public string MessageId { get; set; }

public bool MessageIngested { get; set; }

public bool MessageFound { get; set; }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,8 @@
class When_failed_message_searched_by_body_content : AcceptanceTest
{
[Test]
public async Task Should_be_found_when_fulltext_search_enabled()
public async Task Should_be_found()
{
// setting it even if it is the default
SetSettings = settings => settings.PersisterSpecificSettings.EnableFullTextSearchOnBodies = true;

var searchString = "forty-two";

var context = await Define<MyContext>()
Expand Down Expand Up @@ -45,43 +42,6 @@ public async Task Should_be_found_when_fulltext_search_enabled()
Assert.That(context.MessageFound, Is.True);
}

[Test]
public async Task Should_not_be_found_when_fulltext_search_disabled()
{
SetSettings = settings => settings.PersisterSpecificSettings.EnableFullTextSearchOnBodies = false;

var searchString = "forty-two";

var context = await Define<MyContext>()
.WithEndpoint<Sender>(b => b.When((bus, c) => bus.Send(new MyMessage
{
Something = "Somewhere in the body is the answer to all of the questions. forty-two"
})))
.WithEndpoint<Receiver>(b => b.DoNotFailOnErrorMessages())
.Done(async c =>
{
if (c.MessageId != null && await this.TryGetMany<MessagesView>($"/api/messages/search/{c.MessageId}"))
{
c.MessageIngested = true;
}

if (!c.MessageIngested)
{
return false;
}

c.MessageFound = await this.TryGetMany<MessagesView>($"/api/messages/search/{searchString}");
return true;
})
.Run();

using (Assert.EnterMultipleScope())
{
Assert.That(context.MessageIngested, Is.True);
Assert.That(context.MessageFound, Is.False);
}
}

public class Sender : EndpointConfigurationBuilder
{
public Sender() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,21 @@ static class FullTextSearchSql

// 'simple' rather than 'english': message and header content is technical, stemming and
// stopword removal do more harm than good.
// The default parser reads a dotted name as a single host token, so
// "ServiceControl.MessageFailures.MyMessage" would not match a search for "MyMessage". The
// message type is therefore also indexed with its separators replaced by spaces, mirroring
// the SearchableMessageType that MessageTypeEnricher already produces for RavenDB.
public const string Up = $"""
CREATE INDEX {IndexName}
ON failed_messages
USING GIN (to_tsvector('simple',
coalesce(headers_json, '') || ' ' ||
coalesce(body_text, '') || ' ' ||
replace(replace(coalesce(message_type, ''), '.', ' '), '+', ' ')))
""";
public const string Configuration = "simple";

// Written the way PostgreSqlFullTextSearchDialect makes EF Core render it, down to the casing
// and the redundant looking parentheses: PostgreSQL only uses an expression index when the
// query expression parses to the same tree, and a mismatch downgrades search to a sequential
// scan silently. FullTextSearchIndexTests fails if the two drift apart.
// The message type is indexed a second time with its separators replaced by spaces because the
// default parser reads a dotted name as a single host token, so
// "ServiceControl.MessageFailures.MyMessage" would not otherwise match a search for
// "MyMessage". It mirrors the SearchableMessageType that MessageTypeEnricher produces for
// RavenDB, and is not the duplicate of the headers it looks like.
public const string IndexedExpression =
$"""to_tsvector('{Configuration}', headers_json || ' ' || COALESCE(body_text, '') || ' ' || replace(replace(COALESCE(message_type, ''), '.', ' '), '+', ' '))""";

public const string Up = $"CREATE INDEX {IndexName} ON failed_messages USING GIN ({IndexedExpression})";

public const string Down = $"DROP INDEX IF EXISTS {IndexName}";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
namespace ServiceControl.Persistence.EFCore.PostgreSql;

using Microsoft.EntityFrameworkCore;
using ServiceControl.Persistence.EFCore.Entities;
using ServiceControl.Persistence.EFCore.Infrastructure;

class PostgreSqlFullTextSearchDialect : IFullTextSearchDialect
{
// The tsvector expression has to be the one FullTextSearchSql indexes, character for character,
// or the planner cannot use the GIN index and the search degrades to a sequential scan instead
// of failing. FullTextSearchIndexTests pins the two together.
// The document has to be built by concatenation: an interpolated string compiles to
// string.Format, which EF Core cannot translate, and the query then throws.
public IQueryable<FailedMessageEntity> Search(IQueryable<FailedMessageEntity> source, string searchTerms) =>
source.Where(message =>
EF.Functions.ToTsVector(FullTextSearchSql.Configuration,
message.HeadersJson + " " +
(message.BodyText ?? "") + " " +
(message.MessageType ?? "").Replace(".", " ").Replace("+", " "))
.Matches(EF.Functions.WebSearchToTsQuery(FullTextSearchSql.Configuration, ToOrQuery(searchTerms))));

// websearch_to_tsquery ANDs bare terms; the RavenDB persister ORs them, so the terms are
// rejoined with the operator that syntax understands. It also never throws on odd input, which
// a hand built tsquery would.
static string ToOrQuery(string searchTerms) =>
string.Join(" OR ", searchTerms.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public void AddPersistence(IServiceCollection services)
RegisterDataStores(services, settings);

services.AddSingleton<IIngestionSqlDialect, PostgreSqlIngestionSqlDialect>();
services.AddSingleton<IFullTextSearchDialect, PostgreSqlFullTextSearchDialect>();
}

public void AddInstaller(IServiceCollection services)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,20 @@ static class FullTextSearchSql
{
const string CatalogName = "ServiceControlFullTextCatalog";

// Both statements are guarded: an instance without Full-Text Search installed still migrates,
// it just has no full text index. They also cannot run inside a transaction, so the migration
// passes suppressTransaction.
// Message search is not optional, so an instance without Full-Text Search installed is not a
// degraded instance, it is a broken one: every /messages/search request would fail on a missing
// index. Failing the migration says so once, at setup, instead of at the first search.
public const string RequireFullTextSearch = """
IF SERVERPROPERTY('IsFullTextInstalled') <> 1
BEGIN
THROW 50000, 'ServiceControl requires the SQL Server Full-Text Search feature, which is not installed on this instance. Install it and run setup again.', 1;
END
""";

// The statements are idempotent so that a re-run is harmless. They also cannot run inside a
// transaction, so the migration passes suppressTransaction.
public const string CreateCatalog = $"""
IF SERVERPROPERTY('IsFullTextInstalled') = 1
AND NOT EXISTS (SELECT 1 FROM sys.fulltext_catalogs WHERE name = '{CatalogName}')
IF NOT EXISTS (SELECT 1 FROM sys.fulltext_catalogs WHERE name = '{CatalogName}')
BEGIN
EXEC('CREATE FULLTEXT CATALOG {CatalogName}');
END
Expand All @@ -26,8 +34,7 @@ AND NOT EXISTS (SELECT 1 FROM sys.fulltext_catalogs WHERE name = '{CatalogName}'
// The message type needs no dedicated column here: the word breaker splits dotted names, and
// the headers already carry the type.
public const string CreateIndex = $"""
IF SERVERPROPERTY('IsFullTextInstalled') = 1
AND NOT EXISTS (SELECT 1 FROM sys.fulltext_indexes WHERE object_id = OBJECT_ID('FailedMessages'))
IF NOT EXISTS (SELECT 1 FROM sys.fulltext_indexes WHERE object_id = OBJECT_ID('FailedMessages'))
BEGIN
EXEC('CREATE FULLTEXT INDEX ON FailedMessages(HeadersJson LANGUAGE 0, BodyText LANGUAGE 0)
KEY INDEX PK_FailedMessages
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public partial class AddFullTextSearch : Migration
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(FullTextSearchSql.RequireFullTextSearch, suppressTransaction: true);
migrationBuilder.Sql(FullTextSearchSql.CreateCatalog, suppressTransaction: true);
migrationBuilder.Sql(FullTextSearchSql.CreateIndex, suppressTransaction: true);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace ServiceControl.Persistence.EFCore.SqlServer;

using Microsoft.EntityFrameworkCore;
using ServiceControl.Persistence.EFCore.Entities;
using ServiceControl.Persistence.EFCore.Infrastructure;

class SqlServerFullTextSearchDialect : IFullTextSearchDialect
{
// FREETEXT ORs the terms itself, so the search string needs no parsing here. Both columns are
// covered by the index the AddFullTextSearch migration creates, and a NULL body simply does not
// match.
public IQueryable<FailedMessageEntity> Search(IQueryable<FailedMessageEntity> source, string searchTerms) =>
source.Where(message =>
EF.Functions.FreeText(message.HeadersJson, searchTerms) ||
EF.Functions.FreeText(message.BodyText!, searchTerms));
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public void AddPersistence(IServiceCollection services)
RegisterDataStores(services, settings);

services.AddSingleton<IIngestionSqlDialect, SqlServerIngestionSqlDialect>();
services.AddSingleton<IFullTextSearchDialect, SqlServerFullTextSearchDialect>();
}

public void AddInstaller(IServiceCollection services)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ public abstract class EFPersistenceConfigurationBase : PersistenceConfiguration,
const string MaxBodySizeToStoreKey = "MaxBodySizeToStore";
const string ErrorRetentionPeriodKey = "ErrorRetentionPeriod";
const string EventsRetentionPeriodKey = "EventsRetentionPeriod";
const string EnableFullTextSearchOnBodiesKey = "EnableFullTextSearchOnBodies";
const string SubscriptionCacheDurationKey = "SubscriptionCacheDuration";

public PersistenceSettings CreateSettings(SettingsRootNamespace settingsRootNamespace)
Expand All @@ -38,7 +37,6 @@ public PersistenceSettings CreateSettings(SettingsRootNamespace settingsRootName
settings.CommandTimeout = SettingsReader.Read(settingsRootNamespace, CommandTimeoutKey, EFPersisterSettings.DefaultCommandTimeout);
settings.ErrorRetentionPeriod = GetRequiredSetting<TimeSpan>(settingsRootNamespace, ErrorRetentionPeriodKey);
settings.EventsRetentionPeriod = SettingsReader.Read(settingsRootNamespace, EventsRetentionPeriodKey, EFPersisterSettings.DefaultEventsRetentionPeriod);
settings.EnableFullTextSearchOnBodies = SettingsReader.Read(settingsRootNamespace, EnableFullTextSearchOnBodiesKey, true);
settings.SubscriptionCacheDuration = SettingsReader.Read(settingsRootNamespace, SubscriptionCacheDurationKey, EFPersisterSettings.DefaultSubscriptionCacheDuration);

return settings;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
namespace ServiceControl.Persistence.EFCore.Implementation;

using System.Text.Json;
using ServiceControl.Contracts.Operations;
using ServiceControl.MessageFailures;
using ServiceControl.MessageFailures.Api;
using ServiceControl.Operations;
using ServiceControl.Persistence.EFCore.Entities;
using ServiceControl.Persistence.EFCore.Infrastructure;
using ServiceControl.Persistence.EFCore.Implementation.UnitOfWork;

static class FailedMessageViewMapper
{
Expand Down Expand Up @@ -128,7 +126,7 @@ static ExceptionDetails ToExceptionDetails(this FailedMessageEntity entity, Dict
StackTrace = headers.GetValueOrDefault(ExceptionStackTraceHeader)
};

static EndpointDetails? ToSendingEndpoint(this FailedMessageEntity entity) =>
public static EndpointDetails? ToSendingEndpoint(this FailedMessageEntity entity) =>
entity.SendingEndpointName == null
? null
: new EndpointDetails
Expand All @@ -138,7 +136,7 @@ static ExceptionDetails ToExceptionDetails(this FailedMessageEntity entity, Dict
HostId = entity.SendingEndpointHostId ?? Guid.Empty
};

static EndpointDetails? ToReceivingEndpoint(this FailedMessageEntity entity) =>
public static EndpointDetails? ToReceivingEndpoint(this FailedMessageEntity entity) =>
entity.ReceivingEndpointName == null
? null
: new EndpointDetails
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,49 @@
namespace ServiceControl.Persistence.EFCore.Implementation;

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using ServiceControl.CompositeViews.Messages;
using ServiceControl.Persistence.EFCore.Entities;
using ServiceControl.Persistence.EFCore.Infrastructure;
using ServiceControl.Persistence.Infrastructure;

public class MessagesViewDataStore : IMessagesViewDataStore
public class MessagesViewDataStore(IServiceScopeFactory scopeFactory, IFullTextSearchDialect fullTextSearch) : DataStoreBase(scopeFactory), IMessagesViewDataStore
{
public Task<QueryResult<IList<MessagesView>>> GetAllMessages(PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages, DateTimeRange? timeSentRange = null) =>
throw new NotImplementedException();
ExecuteWithDbContext(dbContext => dbContext.FailedMessages
.AsNoTracking()
.IncludeSystemMessagesWhere(includeSystemMessages)
.FilterBySentTimeRange(timeSentRange)
.ToPagedMessagesResult(pagingInfo, sortInfo));

public Task<QueryResult<IList<MessagesView>>> GetAllMessagesForEndpoint(string endpointName, PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages, DateTimeRange? timeSentRange = null) =>
throw new NotImplementedException();
ExecuteWithDbContext(dbContext => dbContext.FailedMessages
.AsNoTracking()
.Where(message => message.ReceivingEndpointName == endpointName)
.IncludeSystemMessagesWhere(includeSystemMessages)
.FilterBySentTimeRange(timeSentRange)
.ToPagedMessagesResult(pagingInfo, sortInfo));

// includeSystemMessages is unused here: a conversation is incomplete without the system messages that took part in it.
public Task<QueryResult<IList<MessagesView>>> GetAllMessagesByConversation(string conversationId, PagingInfo pagingInfo, SortInfo sortInfo, bool includeSystemMessages) =>
throw new NotImplementedException();
ExecuteWithDbContext(dbContext => dbContext.FailedMessages
.AsNoTracking()
.Where(message => message.ConversationId == conversationId)
.ToPagedMessagesResult(pagingInfo, sortInfo));

public Task<QueryResult<IList<MessagesView>>> GetAllMessagesForSearch(string searchTerms, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null) =>
throw new NotImplementedException();
ExecuteWithDbContext(dbContext => Search(dbContext.FailedMessages.AsNoTracking(), searchTerms)
.FilterBySentTimeRange(timeSentRange)
.ToPagedMessagesResult(pagingInfo, sortInfo));

public Task<QueryResult<IList<MessagesView>>> SearchEndpointMessages(string endpointName, string searchKeyword, PagingInfo pagingInfo, SortInfo sortInfo, DateTimeRange? timeSentRange = null) =>
throw new NotImplementedException();
ExecuteWithDbContext(dbContext => Search(dbContext.FailedMessages.AsNoTracking(), searchKeyword)
.Where(message => message.ReceivingEndpointName == endpointName)
.FilterBySentTimeRange(timeSentRange)
.ToPagedMessagesResult(pagingInfo, sortInfo));

// Neither search hides system messages: a caller who searched
// for something specific is not helped by hiding the message that matched it.
IQueryable<FailedMessageEntity> Search(IQueryable<FailedMessageEntity> source, string searchTerms) =>
string.IsNullOrWhiteSpace(searchTerms) ? source : fullTextSearch.Search(source, searchTerms);
}
Loading
Loading