diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a0a7a8af..8a47d07a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -160,15 +160,28 @@ jobs: docker_services: "mqtt" # NATS connects to localhost:4222 via the nats_endpoint env var - - transport: NATS - name: NATS - filter: "Category=Integration&Transport=NATS" - docker_services: "nats" - - # Outbox – four parallel sub-legs: - # 1a. Sql.DbContext OutboxTests – AzureSB + Kafka; SQL Server via local docker - - transport: Outbox.SqlDbContext.Tests - name: "Outbox (SqlDbContext Tests)" + - transport: NATS + name: NATS + filter: "Category=Integration&Transport=NATS" + docker_services: "nats" + + # SQL transports use TestContainers directly + - transport: Sql + name: SQL Server + filter: "Category=Integration&Transport=Sql" + test_path: "Tests/SlimMessageBus.Host.Sql.Test" + docker_services: "" + + - transport: PostgreSql + name: PostgreSQL + filter: "Category=Integration&Transport=PostgreSql" + test_path: "Tests/SlimMessageBus.Host.PostgreSql.Test" + docker_services: "" + + # Outbox – four parallel sub-legs: + # 1a. Sql.DbContext OutboxTests – AzureSB + Kafka; SQL Server via local docker + - transport: Outbox.SqlDbContext.Tests + name: "Outbox (SqlDbContext Tests)" filter: "Category=Integration&ClassName=SlimMessageBus.Host.Outbox.Sql.DbContext.Test.OutboxTests" test_path: "Tests/SlimMessageBus.Host.Outbox.Sql.DbContext.Test" docker_services: "sqldb zookeeper kafka kafka-init" diff --git a/README.md b/README.md index d9154c01..863aac6b 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ The configuration can be [modularized](docs/intro.md#modularization-of-configura - [MQTT](docs/provider_mqtt.md) - [Memory](docs/provider_memory.md) - [NATS](docs/provider_nats.md) + - [PostgreSQL](docs/provider_postgresql.md) - [RabbitMQ](docs/provider_rabbitmq.md) - [Redis](docs/provider_redis.md) - [SQL](docs/provider_sql.md) @@ -155,9 +156,10 @@ The configuration can be [modularized](docs/intro.md#modularization-of-configura | `.Host.MQTT` | Transport provider for MQTT | [![NuGet](https://img.shields.io/nuget/v/SlimMessageBus.Host.MQTT.svg)](https://www.nuget.org/packages/SlimMessageBus.Host.MQTT) | | `.Host.Memory` | Transport provider implementation for in-process (in memory) message passing (no messaging infrastructure required) | [![NuGet](https://img.shields.io/nuget/v/SlimMessageBus.Host.Memory.svg)](https://www.nuget.org/packages/SlimMessageBus.Host.Memory) | | `.Host.NATS` | Transport provider for [NATS](https://nats.io/) | [![NuGet](https://img.shields.io/nuget/v/SlimMessageBus.Host.NATS.svg)](https://www.nuget.org/packages/SlimMessageBus.Host.NATS) | +| `.Host.PostgreSql` ![NEW](https://img.shields.io/badge/NEW-brightgreen) | Transport provider implementation for PostgreSQL database message passing | [![NuGet](https://img.shields.io/nuget/v/SlimMessageBus.Host.PostgreSql.svg)](https://www.nuget.org/packages/SlimMessageBus.Host.PostgreSql) | | `.Host.RabbitMQ` | Transport provider for RabbitMQ | [![NuGet](https://img.shields.io/nuget/v/SlimMessageBus.Host.RabbitMQ.svg)](https://www.nuget.org/packages/SlimMessageBus.Host.RabbitMQ) | | `.Host.Redis` | Transport provider for Redis | [![NuGet](https://img.shields.io/nuget/v/SlimMessageBus.Host.Redis.svg)](https://www.nuget.org/packages/SlimMessageBus.Host.Redis) | -| `.Host.Sql` (pending) | Transport provider implementation for SQL database message passing | [![NuGet](https://img.shields.io/nuget/v/SlimMessageBus.Host.Sql.svg)](https://www.nuget.org/packages/SlimMessageBus.Host.Sql) | +| `.Host.Sql` ![NEW](https://img.shields.io/badge/NEW-brightgreen) | Transport provider implementation for SQL Server / Azure SQL database message passing | [![NuGet](https://img.shields.io/nuget/v/SlimMessageBus.Host.Sql.svg)](https://www.nuget.org/packages/SlimMessageBus.Host.Sql) | | **Serialization** | | | | `.Host.Serialization.Json` | Serialization plugin for JSON (Newtonsoft.Json library) | [![NuGet](https://img.shields.io/nuget/v/SlimMessageBus.Host.Serialization.Json.svg)](https://www.nuget.org/packages/SlimMessageBus.Host.Serialization.Json) | | `.Host.Serialization.SystemTextJson` | Serialization plugin for JSON (System.Text.Json library) | [![NuGet](https://img.shields.io/nuget/v/SlimMessageBus.Host.Serialization.SystemTextJson.svg)](https://www.nuget.org/packages/SlimMessageBus.Host.Serialization.SystemTextJson) | @@ -172,7 +174,7 @@ The configuration can be [modularized](docs/intro.md#modularization-of-configura | `.Host.Outbox.PostgreSql.DbContext` | Transactional Outbox using PostgreSQL with EF DataContext integration | [![NuGet](https://img.shields.io/nuget/v/SlimMessageBus.Host.Outbox.PostgreSql.DbContext.svg)](https://www.nuget.org/packages/SlimMessageBus.Host.Outbox.PostgreSql.DbContext) | | `.Host.Outbox.Sql` | Transactional Outbox using MSSQL | [![NuGet](https://img.shields.io/nuget/v/SlimMessageBus.Host.Outbox.Sql.svg)](https://www.nuget.org/packages/SlimMessageBus.Host.Outbox.Sql) | | `.Host.Outbox.Sql.DbContext` | Transactional Outbox using MSSQL with EF DataContext integration | [![NuGet](https://img.shields.io/nuget/v/SlimMessageBus.Host.Outbox.Sql.DbContext.svg)](https://www.nuget.org/packages/SlimMessageBus.Host.Outbox.Sql.DbContext) | -| `.Host.Outbox.MongoDb` *(beta)* | [Transactional Outbox using MongoDB](docs/plugin_outbox_mongodb.md) | [![NuGet](https://img.shields.io/nuget/v/SlimMessageBus.Host.Outbox.MongoDb.svg)](https://www.nuget.org/packages/SlimMessageBus.Host.Outbox.MongoDb) | +| `.Host.Outbox.MongoDb` *(beta)* ![NEW](https://img.shields.io/badge/NEW-brightgreen) | [Transactional Outbox using MongoDB](docs/plugin_outbox_mongodb.md) | [![NuGet](https://img.shields.io/nuget/v/SlimMessageBus.Host.Outbox.MongoDb.svg)](https://www.nuget.org/packages/SlimMessageBus.Host.Outbox.MongoDb) | | `.Host.AsyncApi` | [AsyncAPI](https://www.asyncapi.com/) specification generation via [Saunter](https://github.com/tehmantra/saunter) | [![NuGet](https://img.shields.io/nuget/v/SlimMessageBus.Host.AsyncApi.svg)](https://www.nuget.org/packages/SlimMessageBus.Host.AsyncApi) | | `.Host.CircuitBreaker.HealthCheck` | Consumer circuit breaker based on [health checks](docs/intro.md#health-check-circuit-breaker) | [![NuGet](https://img.shields.io/nuget/v/SlimMessageBus.Host.CircuitBreaker.HealthCheck.svg)](https://www.nuget.org/packages/SlimMessageBus.Host.CircuitBreaker.HealthCheck) | diff --git a/build/tasks.ps1 b/build/tasks.ps1 index 03018f38..d2b660cf 100644 --- a/build/tasks.ps1 +++ b/build/tasks.ps1 @@ -32,8 +32,10 @@ $projects = @( "SlimMessageBus.Host.Redis", "SlimMessageBus.Host.Mqtt", "SlimMessageBus.Host.RabbitMQ", + "SlimMessageBus.Host.Relational", "SlimMessageBus.Host.Sql", "SlimMessageBus.Host.Sql.Common", + "SlimMessageBus.Host.PostgreSql", "SlimMessageBus.Host.Nats", "SlimMessageBus.Host.AmazonSQS", diff --git a/docs/README.md b/docs/README.md index c32138a6..820c65a3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,11 +8,12 @@ - [Azure ServiceBus](provider_azure_servicebus.md) - [Hybrid](provider_hybrid.md) - [MQTT](provider_mqtt.md) - - [Memory](provider_memory.md) - - [NATS](provider_nats.md) - - [RabbitMQ](provider_rabbitmq.md) - - [Redis](provider_redis.md) - - [SQL](provider_sql.md) + - [Memory](provider_memory.md) + - [NATS](provider_nats.md) + - [PostgreSQL](provider_postgresql.md) + - [RabbitMQ](provider_rabbitmq.md) + - [Redis](provider_redis.md) + - [SQL](provider_sql.md) - Plugins - [Serialization](serialization.md) - [Transactional Outbox](plugin_outbox.md) diff --git a/docs/provider_postgresql.md b/docs/provider_postgresql.md new file mode 100644 index 00000000..fd928622 --- /dev/null +++ b/docs/provider_postgresql.md @@ -0,0 +1,163 @@ +# PostgreSQL transport provider for SlimMessageBus + +Please read the [Introduction](intro.md) before reading this provider documentation. + +- [About](#about) +- [Configuration](#configuration) + - [Provider settings](#provider-settings) + - [Queues, topics, and request/response](#queues-topics-and-requestresponse) + - [Message id generation](#message-id-generation) +- [How it works](#how-it-works) + - [Polling and locking](#polling-and-locking) + - [Retries and failed messages](#retries-and-failed-messages) + - [Schema provisioning](#schema-provisioning) +- [Testing locally](#testing-locally) + +## About + +The PostgreSQL transport provider allows a shared PostgreSQL database to act as the message broker for collaborating producers and consumers. + +This transport is useful for applications that already operate PostgreSQL and do not need a dedicated messaging broker yet. + +## Configuration + +Install the transport package: + +```bash +dotnet add package SlimMessageBus.Host.PostgreSql +``` + +The configuration is arranged via the `.WithProviderPostgreSql(cfg => {})` method on the message bus builder. + +```cs +using SlimMessageBus.Host.PostgreSql; + +services.AddSlimMessageBus(mbb => +{ + mbb.WithProviderPostgreSql(cfg => + { + cfg.ConnectionString = "..."; + cfg.DatabaseSchemaName = "smb"; + cfg.DatabaseTableName = "messages"; + cfg.PollDelay = TimeSpan.FromMilliseconds(250); + cfg.PollBatchSize = 10; + cfg.LockDuration = TimeSpan.FromSeconds(30); + cfg.MaxDeliveryAttempts = 10; + }); + + mbb.Produce(x => x.DefaultQueue("ping-queue")); + mbb.Consume(x => x.Queue("ping-queue")); + + mbb.Produce(x => x.DefaultTopic("orders").ToTopic()); + mbb.Consume(x => x.Topic("orders", "billing")); + mbb.Consume(x => x.Topic("orders", "shipping")); + + mbb.AddServicesFromAssemblyContaining(); + mbb.AddJsonSerializer(); +}); +``` + +### Provider settings + +The most commonly configured settings are: + +- `ConnectionString` - required PostgreSQL connection string. +- `DatabaseSchemaName` - schema containing the transport tables. Defaults to `public`. +- `DatabaseTableName` - base message table name. The durable subscription table uses the same base name with a `_subscriptions` suffix. +- `DatabaseMigrationsTableName` - table used to track transport schema migrations. +- `CommandTimeout` - optional PostgreSQL command timeout. +- `TransactionIsolationLevel` - isolation level used for schema provisioning. Defaults to `ReadCommitted`. +- `PollDelay` - delay used when no message is available or after a transient polling error. +- `PollBatchSize` - maximum number of messages locked by one polling operation. +- `LockDuration` - how long a message lock is held before another consumer may pick it up. +- `MaxDeliveryAttempts` - number of processing attempts before a message is marked aborted. +- `NotifyOnPublish` - when enabled, producers call `pg_notify` after inserting messages. Polling remains the correctness mechanism. +- `SchemaCreationRetry` and `OperationRetry` - retry settings for schema creation and regular database operations. + +PostgreSQL identifiers configured through `DatabaseSchemaName`, `DatabaseTableName`, and `DatabaseMigrationsTableName` are validated and quoted. Use letters, numbers, and underscores, and do not start identifiers with a number. + +### Queues, topics, and request/response + +Use `DefaultQueue()` and `Queue()` for competing-consumer queues: + +```cs +mbb.Produce(x => x.DefaultQueue("ping-queue")); +mbb.Consume(x => x.Queue("ping-queue")); +``` + +Use `DefaultTopic().ToTopic()` and `Topic(topic, subscriptionName)` for durable pub/sub: + +```cs +mbb.Produce(x => x.DefaultTopic("orders").ToTopic()); +mbb.Consume(x => x.Topic("orders", "billing")); +mbb.Consume(x => x.Topic("orders", "shipping")); +``` + +Request/response endpoints can also use PostgreSQL queues or topics: + +```cs +mbb.Handle(x => x.Queue("ping-handler")); +mbb.ExpectRequestResponses(x => x.ReplyToQueue("replies")); +``` + +### Message id generation + +The transport stores messages with two identifiers: + +- `sequence_id` - a `bigserial` physical key used for insert locality and ordered polling. +- `id` - a logical `uuid` message id used by the transport when completing or failing messages. + +By default, PostgreSQL uses `PostgreSqlMessageIdGenerationMode.ClientGuidGenerator` with `PostgreSqlSequentialGuidGenerator`, which creates sequential-ish UUIDs client-side for better index locality than random UUIDs. + +You can change the id strategy: + +```cs +mbb.WithProviderPostgreSql(cfg => +{ + cfg.ConnectionString = "..."; + cfg.IdGeneration.Mode = PostgreSqlMessageIdGenerationMode.DatabaseRandomUuid; +}); +``` + +Available modes: + +- `ClientGuidGenerator` - client-side UUID generation. Defaults to `PostgreSqlSequentialGuidGenerator`, but `GuidGenerator` or `GuidGeneratorType` can be replaced. +- `DatabaseRandomUuid` - uses PostgreSQL `gen_random_uuid()`. + +## How it works + +- A messages table stores exchanged messages. +- A subscriptions table stores durable topic subscriptions configured by consumers. +- Queue consumers compete for rows using `FOR UPDATE SKIP LOCKED`. +- Topic publishes create one row per configured subscription. +- Message rows use a `bigserial` physical key for insert locality and a logical `uuid` message id. +- The default client-side id generator is sequential-ish for index locality. Random database ids can be selected through `cfg.IdGeneration`. +- Producers optionally call `pg_notify` after inserting messages. Polling remains the correctness mechanism. + +### Polling and locking + +Consumers poll the shared message table in batches. PostgreSQL uses `FOR UPDATE SKIP LOCKED` so competing consumers can skip rows already locked by another instance. + +When a consumer locks a row, the transport stores the consumer instance id and lock expiration. If the process stops before completing the message, the row becomes visible again after `LockDuration`. + +`pg_notify` is used as a lightweight wake-up hint when `NotifyOnPublish` is enabled. It is not used as the durable delivery mechanism; message rows in the database remain the source of truth. + +### Retries and failed messages + +Successful processing marks the row as complete. Failed processing increments `delivery_attempt`, clears the lock, and makes the row available for another attempt. Once `MaxDeliveryAttempts` is reached, the row is marked aborted and will no longer be delivered. + +The transport retries transient PostgreSQL errors around schema provisioning and operations according to `SchemaCreationRetry` and `OperationRetry`. + +### Schema provisioning + +The provider provisions the required message, subscription, and migration tables during bus startup. All cooperating services should use the same database, schema, and table names. + +## Testing locally + +The integration tests use Testcontainers and require Docker to be running: + +```bash +dotnet test src/Tests/SlimMessageBus.Host.PostgreSql.Test/SlimMessageBus.Host.PostgreSql.Test.csproj --filter "Category=Integration" +``` + +The repository also contains `infrastructure.ps1` for standing up shared development infrastructure used by broader integration test runs. diff --git a/docs/provider_sql.md b/docs/provider_sql.md index 5448e297..424d696b 100644 --- a/docs/provider_sql.md +++ b/docs/provider_sql.md @@ -2,13 +2,20 @@ Please read the [Introduction](intro.md) before reading this provider documentation. -- [About](#about) -- [SQL Compatibility](#sql-compatibility) -- [Configuration](#configuration) -- [How it works](#how-it-works) - -## About - +- [About](#about) +- [SQL Compatibility](#sql-compatibility) +- [Configuration](#configuration) + - [Provider settings](#provider-settings) + - [Queues, topics, and request/response](#queues-topics-and-requestresponse) + - [Message id generation](#message-id-generation) +- [How it works](#how-it-works) + - [Polling and locking](#polling-and-locking) + - [Retries and failed messages](#retries-and-failed-messages) + - [Schema provisioning](#schema-provisioning) +- [Testing locally](#testing-locally) + +## About + The SQL transport provider allows to leverage a single shared SQL database instance as a messaging broker for all the collaborating producers and consumers. This transport might be optimal for simpler applications that do not have a dedicated messaging infrastructure available, do not have high throughput needs, or want to target a simplistic deployment model. @@ -17,42 +24,153 @@ When the application grows over time, and given that SMB is an abstraction, the ## SQL Compatibility -This transport has been tested on SQL Azure (T-SQL), and should work on most other databases. -If you see an issue, please raise an github issue. - -## Configuration - -ToDo: Finish - -The configuration is arranged via the `.WithProviderSql(cfg => {})` method on the message bus builder. - -```cs -services.AddSlimMessageBus(mbb => -{ - mbb.WithProviderSql(cfg => - { - // ToDo - }); - - mbb.AddServicesFromAssemblyContaining(); - mbb.AddJsonSerializer(); -}); -``` - -## How it works - -The same SQL database instance is required for all the producers and consumers to collaborate. +This transport targets SQL Server / Azure SQL (T-SQL). + +## Configuration + +Install the transport package: + +```bash +dotnet add package SlimMessageBus.Host.Sql +``` + +The configuration is arranged via the `.WithProviderSql(cfg => {})` method on the message bus builder. + +```cs +using SlimMessageBus.Host.Sql; + +services.AddSlimMessageBus(mbb => +{ + mbb.WithProviderSql(cfg => + { + cfg.ConnectionString = "..."; + cfg.DatabaseSchemaName = "smb"; + cfg.DatabaseTableName = "Messages"; + cfg.PollDelay = TimeSpan.FromMilliseconds(250); + cfg.PollBatchSize = 10; + cfg.LockDuration = TimeSpan.FromSeconds(30); + cfg.MaxDeliveryAttempts = 10; + }); + + mbb.Produce(x => x.DefaultQueue("ping-queue")); + mbb.Consume(x => x.Queue("ping-queue")); + + mbb.Produce(x => x.DefaultTopic("orders").ToTopic()); + mbb.Consume(x => x.Topic("orders", "billing")); + mbb.Consume(x => x.Topic("orders", "shipping")); + + mbb.AddServicesFromAssemblyContaining(); + mbb.AddJsonSerializer(); +}); +``` + +### Provider settings + +The most commonly configured settings are: + +- `ConnectionString` - required SQL Server or Azure SQL connection string. +- `DatabaseSchemaName` - schema containing the transport tables. Defaults to `dbo`. +- `DatabaseTableName` - base message table name, for example `Messages`. The durable subscription table uses the same base name with a `Subscriptions` suffix. +- `DatabaseMigrationsTableName` - table used to track transport schema migrations. +- `CommandTimeout` - optional SQL command timeout. +- `TransactionIsolationLevel` - isolation level used by transport transactions. Defaults to `ReadCommitted`. +- `PollDelay` - delay used when no message is available or after a transient polling error. +- `PollBatchSize` - maximum number of messages locked by one polling operation. +- `LockDuration` - how long a message lock is held before another consumer may pick it up. +- `MaxDeliveryAttempts` - number of processing attempts before a message is marked aborted. +- `SchemaCreationRetry` and `OperationRetry` - retry settings for schema creation and regular database operations. + +### Queues, topics, and request/response + +Use `DefaultQueue()` and `Queue()` for competing-consumer queues: + +```cs +mbb.Produce(x => x.DefaultQueue("ping-queue")); +mbb.Consume(x => x.Queue("ping-queue")); +``` + +Use `DefaultTopic().ToTopic()` and `Topic(topic, subscriptionName)` for durable pub/sub: + +```cs +mbb.Produce(x => x.DefaultTopic("orders").ToTopic()); +mbb.Consume(x => x.Topic("orders", "billing")); +mbb.Consume(x => x.Topic("orders", "shipping")); +``` + +Request/response endpoints can also use SQL queues or topics: + +```cs +mbb.Handle(x => x.Queue("ping-handler")); +mbb.ExpectRequestResponses(x => x.ReplyToQueue("replies")); +``` + +### Message id generation + +The transport stores messages with two identifiers: + +- `SequenceId` - a clustered `bigint identity` physical key used for insert locality and ordered polling. +- `Id` - a logical `uniqueidentifier` message id used by the transport when completing or failing messages. + +By default, SQL uses `SqlMessageIdGenerationMode.ClientGuidGenerator` with `SqlSequentialGuidGenerator`, which creates sequential-ish GUIDs client-side for better index locality than random GUIDs. + +You can change the id strategy: + +```cs +mbb.WithProviderSql(cfg => +{ + cfg.ConnectionString = "..."; + cfg.IdGeneration.Mode = SqlMessageIdGenerationMode.DatabaseGeneratedSequentialGuid; +}); +``` + +Available modes: + +- `ClientGuidGenerator` - client-side GUID generation. Defaults to `SqlSequentialGuidGenerator`, but `GuidGenerator` or `GuidGeneratorType` can be replaced. +- `DatabaseGeneratedGuid` - uses `NEWID()`. +- `DatabaseGeneratedSequentialGuid` - uses the table default, which is intended for SQL Server sequential GUID generation. + +## How it works + +The same SQL database instance is required for all the producers and consumers to collaborate. Therefore ensure all of the service instances point to the same database cluster. -- Single table is used to store all the exchanged messages (by default table is called `Messages`). -- Producers send messages to the messages table. - - There are two types of entities (queues, and topics for pub/sub). - - In the case of a topic: - - Each subscription gets a copy of the message. - - Subscription has a lifetime, and can expire after certain idle time. Along with it, all the messages placed on the subscription. -- Consumers (queue consumers, or subscribers in pub/sub) long poll the table to pick up their respective message. - - Queue consumers compete for the message, and ensure only one consumer instance is processing the message. - - Topic subscribers complete for the message within the same subscription. -- In the future we might consider: - - Table per each entity, so that we can minimize table locking. - - Sessions to ensure order of processing within the same message session ID - similar to how Azure Service Bus feature or Apache Kafka topic-partition works. +- A messages table is used to store exchanged messages. +- A subscriptions table is used to store durable topic subscriptions configured by consumers. +- Producers send messages to the messages table. + - There are two types of entities (queues, and topics for pub/sub). + - In the case of a topic: + - Each configured durable subscription gets a copy of the message. +- Consumers (queue consumers, or subscribers in pub/sub) long poll the table to pick up their respective message. + - Queue consumers compete for the message, and ensure only one consumer instance is processing the message. + - Topic subscribers compete for the message within the same subscription. +- Message rows use a clustered `bigint identity` sequence for insert locality and a logical `uniqueidentifier` message id. +- The default client-side id generator is sequential-ish for SQL Server index locality. Random database ids and database-generated sequential ids can be selected through `cfg.IdGeneration`. +- In the future we might consider: + - Table per each entity, so that we can minimize table locking. + - Sessions to ensure order of processing within the same message session ID - similar to how Azure Service Bus feature or Apache Kafka topic-partition works. + +### Polling and locking + +Consumers poll the shared message table in batches. SQL Server locking hints (`ROWLOCK`, `UPDLOCK`, `READPAST`) are used so competing consumers can skip rows already locked by another instance. + +When a consumer locks a row, the transport stores the consumer instance id and lock expiration. If the process stops before completing the message, the row becomes visible again after `LockDuration`. + +### Retries and failed messages + +Successful processing marks the row as complete. Failed processing increments `DeliveryAttempt`, clears the lock, and makes the row available for another attempt. Once `MaxDeliveryAttempts` is reached, the row is marked aborted and will no longer be delivered. + +The transport retries transient SQL errors around schema provisioning and operations according to `SchemaCreationRetry` and `OperationRetry`. + +### Schema provisioning + +The provider provisions the required message, subscription, and migration tables during bus startup. All cooperating services should use the same database, schema, and table names. + +## Testing locally + +The integration tests use Testcontainers and require Docker to be running: + +```bash +dotnet test src/Tests/SlimMessageBus.Host.Sql.Test/SlimMessageBus.Host.Sql.Test.csproj --filter "Category=Integration" +``` + +The repository also contains `infrastructure.ps1` for standing up shared development infrastructure used by broader integration test runs. diff --git a/src/Host.Plugin.Properties.xml b/src/Host.Plugin.Properties.xml index 0035c964..50a17e79 100644 --- a/src/Host.Plugin.Properties.xml +++ b/src/Host.Plugin.Properties.xml @@ -1,10 +1,8 @@ - - 3.5.0-rc102 + 3.5.0-rc103 - - \ No newline at end of file + diff --git a/src/SlimMessageBus.Host.Configuration/SlimMessageBus.Host.Configuration.csproj b/src/SlimMessageBus.Host.Configuration/SlimMessageBus.Host.Configuration.csproj index 519d1b64..94e05083 100644 --- a/src/SlimMessageBus.Host.Configuration/SlimMessageBus.Host.Configuration.csproj +++ b/src/SlimMessageBus.Host.Configuration/SlimMessageBus.Host.Configuration.csproj @@ -1,19 +1,34 @@  - Core configuration interfaces of SlimMessageBus SlimMessageBus SlimMessageBus.Host - 3.5.0-rc102 + 3.5.0-rc103 - - - - + + + + @@ -25,5 +40,4 @@ - - \ No newline at end of file + diff --git a/src/SlimMessageBus.Host.PostgreSql/Config/PostgreSqlConsumerBuilderExtensions.cs b/src/SlimMessageBus.Host.PostgreSql/Config/PostgreSqlConsumerBuilderExtensions.cs new file mode 100644 index 00000000..1912a74b --- /dev/null +++ b/src/SlimMessageBus.Host.PostgreSql/Config/PostgreSqlConsumerBuilderExtensions.cs @@ -0,0 +1,18 @@ +namespace SlimMessageBus.Host.PostgreSql; + +public static class PostgreSqlConsumerBuilderExtensions +{ + internal const string PropertySubscriptionName = "PostgreSql_SubscriptionName"; + + public static ConsumerBuilder Queue(this ConsumerBuilder builder, string queue) + => RelationalBuilderExtensions.Queue(builder, queue); + + public static ConsumerBuilder Topic(this ConsumerBuilder builder, string topic, string subscriptionName) + => RelationalBuilderExtensions.Topic(builder, topic, subscriptionName, PropertySubscriptionName); + + public static ConsumerBuilder Subscription(this ConsumerBuilder builder, string subscriptionName) + => RelationalBuilderExtensions.Subscription(builder, subscriptionName, PropertySubscriptionName); + + internal static string GetSubscriptionName(this AbstractConsumerSettings settings) + => RelationalBuilderExtensions.GetSubscriptionName(settings, PropertySubscriptionName); +} diff --git a/src/SlimMessageBus.Host.PostgreSql/Config/PostgreSqlHandlerBuilderExtensions.cs b/src/SlimMessageBus.Host.PostgreSql/Config/PostgreSqlHandlerBuilderExtensions.cs new file mode 100644 index 00000000..d688cc64 --- /dev/null +++ b/src/SlimMessageBus.Host.PostgreSql/Config/PostgreSqlHandlerBuilderExtensions.cs @@ -0,0 +1,16 @@ +namespace SlimMessageBus.Host.PostgreSql; + +public static class PostgreSqlHandlerBuilderExtensions +{ + public static HandlerBuilder Queue(this HandlerBuilder builder, string queue) + => RelationalBuilderExtensions.Queue(builder, queue); + + public static HandlerBuilder Queue(this HandlerBuilder builder, string queue) + => RelationalBuilderExtensions.Queue(builder, queue); + + public static HandlerBuilder Topic(this HandlerBuilder builder, string topic, string subscriptionName) + => RelationalBuilderExtensions.Topic(builder, topic, subscriptionName, PostgreSqlConsumerBuilderExtensions.PropertySubscriptionName); + + public static HandlerBuilder Topic(this HandlerBuilder builder, string topic, string subscriptionName) + => RelationalBuilderExtensions.Topic(builder, topic, subscriptionName, PostgreSqlConsumerBuilderExtensions.PropertySubscriptionName); +} diff --git a/src/SlimMessageBus.Host.PostgreSql/Config/PostgreSqlProducerBuilderExtensions.cs b/src/SlimMessageBus.Host.PostgreSql/Config/PostgreSqlProducerBuilderExtensions.cs new file mode 100644 index 00000000..66c29ff4 --- /dev/null +++ b/src/SlimMessageBus.Host.PostgreSql/Config/PostgreSqlProducerBuilderExtensions.cs @@ -0,0 +1,13 @@ +namespace SlimMessageBus.Host.PostgreSql; + +public static class PostgreSqlProducerBuilderExtensions +{ + public static ProducerBuilder DefaultQueue(this ProducerBuilder producerBuilder, string queue) + => RelationalBuilderExtensions.DefaultQueue(producerBuilder, queue); + + public static ProducerBuilder ToTopic(this ProducerBuilder producerBuilder) + => RelationalBuilderExtensions.ToTopic(producerBuilder); + + public static ProducerBuilder ToQueue(this ProducerBuilder producerBuilder) + => RelationalBuilderExtensions.ToQueue(producerBuilder); +} diff --git a/src/SlimMessageBus.Host.PostgreSql/Config/PostgreSqlRequestResponseBuilderExtensions.cs b/src/SlimMessageBus.Host.PostgreSql/Config/PostgreSqlRequestResponseBuilderExtensions.cs new file mode 100644 index 00000000..fcf5bb01 --- /dev/null +++ b/src/SlimMessageBus.Host.PostgreSql/Config/PostgreSqlRequestResponseBuilderExtensions.cs @@ -0,0 +1,10 @@ +namespace SlimMessageBus.Host.PostgreSql; + +public static class PostgreSqlRequestResponseBuilderExtensions +{ + public static RequestResponseBuilder ReplyToQueue(this RequestResponseBuilder builder, string queue) + => RelationalBuilderExtensions.ReplyToQueue(builder, queue); + + public static RequestResponseBuilder ReplyToTopic(this RequestResponseBuilder builder, string topic, string subscriptionName) + => RelationalBuilderExtensions.ReplyToTopic(builder, topic, subscriptionName, PostgreSqlConsumerBuilderExtensions.PropertySubscriptionName); +} diff --git a/src/SlimMessageBus.Host.PostgreSql/Configuration/PostgreSqlMessageBusBuilderExtensions.cs b/src/SlimMessageBus.Host.PostgreSql/Configuration/PostgreSqlMessageBusBuilderExtensions.cs new file mode 100644 index 00000000..10949847 --- /dev/null +++ b/src/SlimMessageBus.Host.PostgreSql/Configuration/PostgreSqlMessageBusBuilderExtensions.cs @@ -0,0 +1,25 @@ +namespace SlimMessageBus.Host.PostgreSql; + +public static class PostgreSqlMessageBusBuilderExtensions +{ + public static MessageBusBuilder WithProviderPostgreSql(this MessageBusBuilder mbb, Action configure) + { + if (mbb == null) throw new ArgumentNullException(nameof(mbb)); + if (configure == null) throw new ArgumentNullException(nameof(configure)); + + var providerSettings = new PostgreSqlMessageBusSettings(); + configure(providerSettings); + + mbb.PostConfigurationActions.Add(services => + { + services.TryAddSingleton(providerSettings); + services.TryAddScoped(_ => new NpgsqlConnection(providerSettings.ConnectionString)); + services.TryAddSingleton(); + services.TryAddScoped(); + services.Replace(ServiceDescriptor.Scoped(svp => svp.GetRequiredService())); + services.TryAddSingleton(); + }); + + return mbb.WithProvider(settings => new PostgreSqlMessageBus(settings, providerSettings)); + } +} diff --git a/src/SlimMessageBus.Host.PostgreSql/Configuration/PostgreSqlMessageBusSettings.cs b/src/SlimMessageBus.Host.PostgreSql/Configuration/PostgreSqlMessageBusSettings.cs new file mode 100644 index 00000000..bb47d000 --- /dev/null +++ b/src/SlimMessageBus.Host.PostgreSql/Configuration/PostgreSqlMessageBusSettings.cs @@ -0,0 +1,38 @@ +namespace SlimMessageBus.Host.PostgreSql; + +public class PostgreSqlMessageBusSettings : IRelationalMessageBusSettings +{ + public string? ConnectionString { get; set; } + public string DatabaseSchemaName { get; set; } = "public"; + public string DatabaseTableName { get; set; } = "messages"; + public string DatabaseMigrationsTableName { get; set; } = "__EFMigrationsHistory"; + public TimeSpan? CommandTimeout { get; set; } + public IsolationLevel TransactionIsolationLevel { get; set; } = IsolationLevel.ReadCommitted; + public TimeSpan PollDelay { get; set; } = TimeSpan.FromMilliseconds(250); + public TimeSpan LockDuration { get; set; } = TimeSpan.FromSeconds(30); + public int PollBatchSize { get; set; } = 10; + public int MaxDeliveryAttempts { get; set; } = 10; + public bool NotifyOnPublish { get; set; } = true; + public PostgreSqlMessageIdGenerationSettings IdGeneration { get; set; } = new(); + + public RetrySettings SchemaCreationRetry { get; set; } = new() + { + RetryCount = 3, + RetryIntervalFactor = 1.2f, + RetryInterval = TimeSpan.FromSeconds(2), + }; + + public RetrySettings OperationRetry { get; set; } = new() + { + RetryCount = 5, + RetryIntervalFactor = 1.5f, + RetryInterval = TimeSpan.FromSeconds(2), + }; +} + +public class RetrySettings +{ + public int RetryCount { get; set; } + public TimeSpan RetryInterval { get; set; } + public float RetryIntervalFactor { get; set; } +} diff --git a/src/SlimMessageBus.Host.PostgreSql/Configuration/PostgreSqlMessageIdGenerationMode.cs b/src/SlimMessageBus.Host.PostgreSql/Configuration/PostgreSqlMessageIdGenerationMode.cs new file mode 100644 index 00000000..8943f2cb --- /dev/null +++ b/src/SlimMessageBus.Host.PostgreSql/Configuration/PostgreSqlMessageIdGenerationMode.cs @@ -0,0 +1,7 @@ +namespace SlimMessageBus.Host.PostgreSql; + +public enum PostgreSqlMessageIdGenerationMode +{ + ClientGuidGenerator, + DatabaseRandomUuid, +} diff --git a/src/SlimMessageBus.Host.PostgreSql/Configuration/PostgreSqlMessageIdGenerationSettings.cs b/src/SlimMessageBus.Host.PostgreSql/Configuration/PostgreSqlMessageIdGenerationSettings.cs new file mode 100644 index 00000000..572d892a --- /dev/null +++ b/src/SlimMessageBus.Host.PostgreSql/Configuration/PostgreSqlMessageIdGenerationSettings.cs @@ -0,0 +1,8 @@ +namespace SlimMessageBus.Host.PostgreSql; + +public class PostgreSqlMessageIdGenerationSettings +{ + public PostgreSqlMessageIdGenerationMode Mode { get; set; } = PostgreSqlMessageIdGenerationMode.ClientGuidGenerator; + public Type GuidGeneratorType { get; set; } = typeof(PostgreSqlSequentialGuidGenerator); + public IGuidGenerator? GuidGenerator { get; set; } +} diff --git a/src/SlimMessageBus.Host.PostgreSql/IPostgreSqlRepository.cs b/src/SlimMessageBus.Host.PostgreSql/IPostgreSqlRepository.cs new file mode 100644 index 00000000..bf4e5a38 --- /dev/null +++ b/src/SlimMessageBus.Host.PostgreSql/IPostgreSqlRepository.cs @@ -0,0 +1,7 @@ +namespace SlimMessageBus.Host.PostgreSql; + +public interface IPostgreSqlRepository : IRelationalRepository +{ + NpgsqlConnection Connection { get; } + Task EnsureConnection(CancellationToken cancellationToken); +} diff --git a/src/SlimMessageBus.Host.PostgreSql/ObjectToInferredTypesConverter.cs b/src/SlimMessageBus.Host.PostgreSql/ObjectToInferredTypesConverter.cs new file mode 100644 index 00000000..c705df77 --- /dev/null +++ b/src/SlimMessageBus.Host.PostgreSql/ObjectToInferredTypesConverter.cs @@ -0,0 +1,23 @@ +namespace SlimMessageBus.Host.PostgreSql; + +using System.Text.Json; +using System.Text.Json.Serialization; + +public class ObjectToInferredTypesConverter : JsonConverter +{ + public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + reader.TokenType switch + { + JsonTokenType.True => true, + JsonTokenType.False => false, + JsonTokenType.Number when reader.TryGetInt32(out var i) => i, + JsonTokenType.Number when reader.TryGetInt64(out var l) => l, + JsonTokenType.Number => reader.GetDouble(), + JsonTokenType.String when reader.TryGetDateTime(out var datetime) => datetime, + JsonTokenType.String => reader.GetString()!, + _ => JsonDocument.ParseValue(ref reader).RootElement.Clone() + }; + + public override void Write(Utf8JsonWriter writer, object objectToWrite, JsonSerializerOptions options) => + JsonSerializer.Serialize(writer, objectToWrite, objectToWrite.GetType(), options); +} diff --git a/src/SlimMessageBus.Host.PostgreSql/PostgreSqlConsumer.cs b/src/SlimMessageBus.Host.PostgreSql/PostgreSqlConsumer.cs new file mode 100644 index 00000000..525a278c --- /dev/null +++ b/src/SlimMessageBus.Host.PostgreSql/PostgreSqlConsumer.cs @@ -0,0 +1,19 @@ +namespace SlimMessageBus.Host.PostgreSql; + +public class PostgreSqlConsumer : RelationalConsumerBase +{ + public PostgreSqlConsumer( + ILogger logger, + IEnumerable consumerSettings, + IEnumerable interceptors, + IServiceProvider serviceProvider, + PostgreSqlMessageBusSettings providerSettings, + IMessageProcessor messageProcessor, + string path, + PathKind pathKind, + string? subscriptionName, + string instanceId) + : base(logger, consumerSettings, interceptors, serviceProvider, providerSettings, messageProcessor, path, pathKind, subscriptionName, instanceId, "PostgreSQL") + { + } +} diff --git a/src/SlimMessageBus.Host.PostgreSql/PostgreSqlConsumerErrorHandler.cs b/src/SlimMessageBus.Host.PostgreSql/PostgreSqlConsumerErrorHandler.cs new file mode 100644 index 00000000..60f2e35f --- /dev/null +++ b/src/SlimMessageBus.Host.PostgreSql/PostgreSqlConsumerErrorHandler.cs @@ -0,0 +1,5 @@ +namespace SlimMessageBus.Host.PostgreSql; + +public interface IPostgreSqlConsumerErrorHandler : IConsumerErrorHandler; + +public abstract class PostgreSqlConsumerErrorHandler : ConsumerErrorHandler, IPostgreSqlConsumerErrorHandler; diff --git a/src/SlimMessageBus.Host.PostgreSql/PostgreSqlHelper.cs b/src/SlimMessageBus.Host.PostgreSql/PostgreSqlHelper.cs new file mode 100644 index 00000000..4be0363a --- /dev/null +++ b/src/SlimMessageBus.Host.PostgreSql/PostgreSqlHelper.cs @@ -0,0 +1,113 @@ +namespace SlimMessageBus.Host.PostgreSql; + +public static partial class PostgreSqlHelper +{ + private static readonly HashSet TransientErrorNumbers = + [ + 40001, + 8000, + 8001, + 8003, + 8004, + 8006, + 8007, + 53300, + 55003, + 57003 + ]; + + public static async Task RetryIfError(ILogger logger, RetrySettings retrySettings, Func shouldRetry, Func> operation, CancellationToken token) + { + Exception? lastTransientException = null; + var nextRetryInterval = retrySettings.RetryInterval; + for (var tries = 1; tries <= retrySettings.RetryCount && !token.IsCancellationRequested; tries++) + { + try + { + if (tries > 1) + { + LogError(logger, retrySettings.RetryCount, tries); + await Task.Delay(nextRetryInterval, token).ConfigureAwait(false); + nextRetryInterval = nextRetryInterval.Multiply(retrySettings.RetryIntervalFactor); + } + return await operation().ConfigureAwait(false); + } + catch (NpgsqlException ex) when (shouldRetry(ex)) + { + lastTransientException = ex; + LogWillRetry(logger, ex.ErrorCode, ex); + } + } + + throw lastTransientException!; + } + + public static Task RetryIfTransientError(ILogger logger, RetrySettings retrySettings, Func> operation, CancellationToken token) => + RetryIfError(logger, retrySettings, sqlEx => TransientErrorNumbers.Contains(sqlEx.ErrorCode), operation, token); + + public static Task RetryIfTransientError(ILogger logger, RetrySettings retrySettings, Func operation, CancellationToken token) => + RetryIfTransientError(logger, retrySettings, async () => { await operation().ConfigureAwait(false); return null; }, token); + + public static string QuoteIdentifier(string identifier, string name) + { + if (!IsSafeIdentifier(identifier)) + { + throw new ArgumentException("PostgreSQL identifiers can only contain letters, numbers, and underscores, and cannot start with a number.", name); + } + + return $"\"{identifier}\""; + } + + private static bool IsSafeIdentifier(string identifier) + { + if (string.IsNullOrWhiteSpace(identifier) || !(char.IsLetter(identifier[0]) || identifier[0] == '_')) + { + return false; + } + + for (var i = 1; i < identifier.Length; i++) + { + var c = identifier[i]; + if (!(char.IsLetterOrDigit(c) || c == '_')) + { + return false; + } + } + + return true; + } + + [LoggerMessage( + EventId = 0, + Level = LogLevel.Information, + Message = "PostgreSQL error encountered. Will begin attempt number {RetryNumber} of {RetryCount} max...")] + private static partial void LogError(ILogger logger, int retryCount, int retryNumber); + + [LoggerMessage( + EventId = 1, + Level = LogLevel.Debug, + Message = "PostgreSQL error occurred {ErrorCode}. Will retry operation")] + private static partial void LogWillRetry(ILogger logger, int errorCode, NpgsqlException e); +} + +#if NETSTANDARD2_0 + +partial class PostgreSqlHelper +{ + private static partial void LogError(ILogger logger, int retryCount, int retryNumber) + { + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation("PostgreSQL error encountered. Will begin attempt number {RetryNumber} of {RetryCount} max...", retryNumber, retryCount); + } + } + + private static partial void LogWillRetry(ILogger logger, int errorCode, NpgsqlException e) + { + if (logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug(e, "PostgreSQL error occurred {ErrorCode}. Will retry operation", errorCode); + } + } +} +#endif diff --git a/src/SlimMessageBus.Host.PostgreSql/PostgreSqlMessageBus.cs b/src/SlimMessageBus.Host.PostgreSql/PostgreSqlMessageBus.cs new file mode 100644 index 00000000..7658e97d --- /dev/null +++ b/src/SlimMessageBus.Host.PostgreSql/PostgreSqlMessageBus.cs @@ -0,0 +1,73 @@ +namespace SlimMessageBus.Host.PostgreSql; + +public class PostgreSqlMessageBus : RelationalMessageBusBase +{ + public PostgreSqlMessageBus(MessageBusSettings settings, PostgreSqlMessageBusSettings providerSettings) + : base(settings, providerSettings) + { + } + + protected override IMessageBusSettingsValidationService ValidationService => new PostgreSqlMessageBusSettingsValidationService(Settings, ProviderSettings); + + protected override Type ConsumerErrorHandlerOpenGenericType => typeof(IPostgreSqlConsumerErrorHandler<>); + + public override async Task ProvisionTopology() + { + await base.ProvisionTopology().ConfigureAwait(false); + + var scope = Settings.ServiceProvider.CreateScope(); + try + { + var repository = (PostgreSqlRepository)scope.ServiceProvider.GetRequiredService(); + var template = scope.ServiceProvider.GetRequiredService(); + var topologyService = new PostgreSqlTopologyService(LoggerFactory.CreateLogger(), ProviderSettings, repository, template); + + await topologyService.Migrate(CancellationToken).ConfigureAwait(false); + + await ProvisionSubscriptions(repository).ConfigureAwait(false); + } + finally + { + await scope.DisposeAsyncScope().ConfigureAwait(false); + } + } + + protected override string GetSubscriptionName(AbstractConsumerSettings settings) + => settings.GetSubscriptionName(); + + protected override AbstractConsumer CreateConsumer(IEnumerable consumerSettings, IMessageProcessor processor, string path, PathKind pathKind, string subscriptionName, string instanceId) + => new PostgreSqlConsumer( + LoggerFactory.CreateLogger(), + consumerSettings, + Settings.ServiceProvider.GetServices(), + Settings.ServiceProvider, + ProviderSettings, + processor, + path, + pathKind, + subscriptionName, + instanceId); + + protected override PostgreSqlTransportMessage CreateTransportMessage(IServiceProvider serviceProvider, string path, PathKind pathKind, string subscriptionName, string messageType, byte[] payload, IDictionary headers) + { + var guidGenerator = ProviderSettings.IdGeneration.GuidGenerator ?? (IGuidGenerator)serviceProvider.GetRequiredService(ProviderSettings.IdGeneration.GuidGeneratorType); + return new PostgreSqlTransportMessage + { + Id = ProviderSettings.IdGeneration.Mode == PostgreSqlMessageIdGenerationMode.ClientGuidGenerator ? guidGenerator.NewGuid() : Guid.Empty, + Path = path, + PathKind = pathKind, + SubscriptionName = subscriptionName, + MessageType = messageType, + MessagePayload = payload, + Headers = headers != null ? new Dictionary(headers) : null + }; + } + + protected override async Task AfterProduce(IPostgreSqlRepository repository, string path, CancellationToken cancellationToken) + { + if (repository is PostgreSqlRepository postgreSqlRepository) + { + await postgreSqlRepository.Notify(path, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/SlimMessageBus.Host.PostgreSql/PostgreSqlMessageBusSettingsValidationService.cs b/src/SlimMessageBus.Host.PostgreSql/PostgreSqlMessageBusSettingsValidationService.cs new file mode 100644 index 00000000..91c3a115 --- /dev/null +++ b/src/SlimMessageBus.Host.PostgreSql/PostgreSqlMessageBusSettingsValidationService.cs @@ -0,0 +1,25 @@ +namespace SlimMessageBus.Host.PostgreSql; + +public class PostgreSqlMessageBusSettingsValidationService(MessageBusSettings settings, PostgreSqlMessageBusSettings providerSettings) + : DefaultMessageBusSettingsValidationService(settings) +{ + public override void AssertSettings() + { + base.AssertSettings(); + + if (string.IsNullOrWhiteSpace(providerSettings.ConnectionString)) + { + throw new ConfigurationMessageBusException("The PostgreSQL provider requires a connection string"); + } + + if (providerSettings.PollBatchSize <= 0) + { + throw new ConfigurationMessageBusException("The PostgreSQL provider poll batch size must be greater than 0"); + } + + if (providerSettings.MaxDeliveryAttempts <= 0) + { + throw new ConfigurationMessageBusException("The PostgreSQL provider max delivery attempts must be greater than 0"); + } + } +} diff --git a/src/SlimMessageBus.Host.PostgreSql/PostgreSqlRepository.cs b/src/SlimMessageBus.Host.PostgreSql/PostgreSqlRepository.cs new file mode 100644 index 00000000..cbdf35d9 --- /dev/null +++ b/src/SlimMessageBus.Host.PostgreSql/PostgreSqlRepository.cs @@ -0,0 +1,205 @@ +namespace SlimMessageBus.Host.PostgreSql; + +public class PostgreSqlRepository : IPostgreSqlRepository +{ + private const string SubscriptionNameParameter = "subscription_name"; + + private static readonly JsonSerializerOptions _jsonOptions = new() + { + Converters = { new ObjectToInferredTypesConverter() } + }; + + private readonly ILogger _logger; + private readonly PostgreSqlMessageBusSettings _settings; + private readonly PostgreSqlTemplate _template; + + public PostgreSqlRepository(ILogger logger, PostgreSqlMessageBusSettings settings, PostgreSqlTemplate template, NpgsqlConnection connection) + { + _logger = logger; + _settings = settings; + _template = template; + Connection = connection; + } + + public NpgsqlConnection Connection { get; } + + public async Task EnsureConnection(CancellationToken cancellationToken) + { + if (Connection.State != ConnectionState.Open) + { + await Connection.OpenAsync(cancellationToken).ConfigureAwait(false); + } + } + + public async Task UpsertSubscription(string path, string subscriptionName, CancellationToken cancellationToken) + { + await EnsureConnection(cancellationToken).ConfigureAwait(false); + await ExecuteNonQuery(_settings.OperationRetry, _template.UpsertSubscription, cmd => + { + cmd.Parameters.Add("path", NpgsqlDbType.Text).Value = path; + cmd.Parameters.Add(SubscriptionNameParameter, NpgsqlDbType.Text).Value = subscriptionName; + }, cancellationToken).ConfigureAwait(false); + } + + public async Task> GetSubscriptions(string path, CancellationToken cancellationToken) + { + await EnsureConnection(cancellationToken).ConfigureAwait(false); + + using var cmd = CreateCommand(); + cmd.CommandText = _template.GetSubscriptions; + cmd.Parameters.Add("path", NpgsqlDbType.Text).Value = path; + + using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + var subscriptions = new List(); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + subscriptions.Add(reader.GetString(0)); + } + + return subscriptions; + } + + public async Task Insert(PostgreSqlTransportMessage message, CancellationToken cancellationToken) + { + await EnsureConnection(cancellationToken).ConfigureAwait(false); + + var insertSql = _settings.IdGeneration.Mode switch + { + PostgreSqlMessageIdGenerationMode.DatabaseRandomUuid => _template.InsertWithDatabaseRandomUuid, + _ => _template.InsertWithClientId + }; + + await ExecuteScalarAsync(_settings.OperationRetry, insertSql, cmd => + { + if (_settings.IdGeneration.Mode == PostgreSqlMessageIdGenerationMode.ClientGuidGenerator) + { + cmd.Parameters.Add("id", NpgsqlDbType.Uuid).Value = message.Id; + } + cmd.Parameters.Add("path", NpgsqlDbType.Text).Value = message.Path; + cmd.Parameters.Add("path_kind", NpgsqlDbType.Smallint).Value = (short)message.PathKind; + cmd.Parameters.Add(SubscriptionNameParameter, NpgsqlDbType.Text).Value = (object?)message.SubscriptionName ?? DBNull.Value; + cmd.Parameters.Add("message_type", NpgsqlDbType.Text).Value = message.MessageType; + cmd.Parameters.Add("message_payload", NpgsqlDbType.Bytea).Value = message.MessagePayload; + cmd.Parameters.Add("headers", NpgsqlDbType.Jsonb).Value = message.Headers != null ? JsonSerializer.Serialize(message.Headers, _jsonOptions) : DBNull.Value; + }, cancellationToken).ConfigureAwait(false); + } + + public async Task> LockAndSelect(string path, PathKind pathKind, string? subscriptionName, string instanceId, int batchSize, TimeSpan lockDuration, CancellationToken cancellationToken) + { + await EnsureConnection(cancellationToken).ConfigureAwait(false); + + using var cmd = CreateCommand(); + cmd.CommandText = _template.LockAndSelect; + cmd.Parameters.Add("path", NpgsqlDbType.Text).Value = path; + cmd.Parameters.Add("path_kind", NpgsqlDbType.Smallint).Value = (short)pathKind; + cmd.Parameters.Add(SubscriptionNameParameter, NpgsqlDbType.Text).Value = (object?)subscriptionName ?? DBNull.Value; + cmd.Parameters.Add("instance_id", NpgsqlDbType.Text).Value = instanceId; + cmd.Parameters.Add("batch_size", NpgsqlDbType.Integer).Value = batchSize; + cmd.Parameters.Add("lock_duration", NpgsqlDbType.Interval).Value = lockDuration; + + using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + + var idOrdinal = reader.GetOrdinal("id"); + var pathOrdinal = reader.GetOrdinal("path"); + var pathKindOrdinal = reader.GetOrdinal("path_kind"); + var subscriptionNameOrdinal = reader.GetOrdinal("subscription_name"); + var messageTypeOrdinal = reader.GetOrdinal("message_type"); + var messagePayloadOrdinal = reader.GetOrdinal("message_payload"); + var headersOrdinal = reader.GetOrdinal("headers"); + + var messages = new List(batchSize); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + var headers = await reader.IsDBNullAsync(headersOrdinal, cancellationToken).ConfigureAwait(false) + ? null + : JsonSerializer.Deserialize>(reader.GetString(headersOrdinal), _jsonOptions); + + messages.Add(new PostgreSqlTransportMessage + { + Id = reader.GetGuid(idOrdinal), + Path = reader.GetString(pathOrdinal), + PathKind = (PathKind)reader.GetInt16(pathKindOrdinal), + SubscriptionName = await reader.IsDBNullAsync(subscriptionNameOrdinal, cancellationToken).ConfigureAwait(false) ? null : reader.GetString(subscriptionNameOrdinal), + MessageType = reader.GetString(messageTypeOrdinal), + MessagePayload = await reader.GetFieldValueAsync(messagePayloadOrdinal, cancellationToken).ConfigureAwait(false), + Headers = headers + }); + } + + return messages; + } + + public async Task Complete(IReadOnlyCollection messages, CancellationToken cancellationToken) + { + if (messages.Count == 0) + { + return; + } + + await ExecuteNonQuery(_settings.OperationRetry, _template.Complete, cmd => + { + cmd.Parameters.AddWithValue("ids", messages.Select(x => x.Id).ToArray()); + }, cancellationToken).ConfigureAwait(false); + } + + public async Task Fail(IReadOnlyCollection messages, int maxDeliveryAttempts, CancellationToken cancellationToken) + { + if (messages.Count == 0) + { + return; + } + + await ExecuteNonQuery(_settings.OperationRetry, _template.Fail, cmd => + { + cmd.Parameters.AddWithValue("ids", messages.Select(x => x.Id).ToArray()); + cmd.Parameters.Add("max_delivery_attempts", NpgsqlDbType.Integer).Value = maxDeliveryAttempts; + }, cancellationToken).ConfigureAwait(false); + } + + public async Task Notify(string path, CancellationToken cancellationToken) + { + if (!_settings.NotifyOnPublish) + { + return; + } + + await ExecuteNonQuery(_settings.OperationRetry, _template.Notify, cmd => + { + cmd.Parameters.Add("channel", NpgsqlDbType.Text).Value = "smb_messages"; + cmd.Parameters.Add("payload", NpgsqlDbType.Text).Value = path; + }, cancellationToken).ConfigureAwait(false); + } + + public async Task ExecuteNonQuery(RetrySettings retrySettings, string sql, Action? setParameters = null, CancellationToken cancellationToken = default) + { + var result = await PostgreSqlHelper.RetryIfTransientError(_logger, retrySettings, async () => + { + using var cmd = CreateCommand(); + cmd.CommandText = sql; + setParameters?.Invoke(cmd); + return await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + }, cancellationToken).ConfigureAwait(false); + + return result; + } + + public Task ExecuteScalarAsync(RetrySettings retrySettings, string sql, Action? setParameters = null, CancellationToken cancellationToken = default) => + PostgreSqlHelper.RetryIfTransientError(_logger, retrySettings, async () => + { + using var cmd = CreateCommand(); + cmd.CommandText = sql; + setParameters?.Invoke(cmd); + return await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + }, cancellationToken); + + private NpgsqlCommand CreateCommand() + { + var cmd = Connection.CreateCommand(); + if (_settings.CommandTimeout != null) + { + cmd.CommandTimeout = (int)_settings.CommandTimeout.Value.TotalSeconds; + } + + return cmd; + } +} diff --git a/src/SlimMessageBus.Host.PostgreSql/PostgreSqlSequentialGuidGenerator.cs b/src/SlimMessageBus.Host.PostgreSql/PostgreSqlSequentialGuidGenerator.cs new file mode 100644 index 00000000..8bfb6e72 --- /dev/null +++ b/src/SlimMessageBus.Host.PostgreSql/PostgreSqlSequentialGuidGenerator.cs @@ -0,0 +1,18 @@ +namespace SlimMessageBus.Host.PostgreSql; + +public class PostgreSqlSequentialGuidGenerator : IGuidGenerator +{ + public Guid NewGuid() + { + var guidBytes = Guid.NewGuid().ToByteArray(); + var timestamp = BitConverter.GetBytes(DateTime.UtcNow.Ticks); + + if (BitConverter.IsLittleEndian) + { + Array.Reverse(timestamp); + } + + Buffer.BlockCopy(timestamp, 2, guidBytes, 10, 6); + return new Guid(guidBytes); + } +} diff --git a/src/SlimMessageBus.Host.PostgreSql/PostgreSqlTemplate.cs b/src/SlimMessageBus.Host.PostgreSql/PostgreSqlTemplate.cs new file mode 100644 index 00000000..aa8942fa --- /dev/null +++ b/src/SlimMessageBus.Host.PostgreSql/PostgreSqlTemplate.cs @@ -0,0 +1,112 @@ +namespace SlimMessageBus.Host.PostgreSql; + +public class PostgreSqlTemplate +{ + public string TableNameQualified { get; } + public string SubscriptionsTableNameQualified { get; } + public string MigrationsTableNameQualified { get; } + public string SubscriptionsPrimaryKeyName { get; } + public string ReadyIndexName { get; } + public string LockIndexName { get; } + public string MigrationsPrimaryKeyName { get; } + public string InsertMigration { get; } + public string InsertWithClientId { get; } + public string InsertWithDatabaseRandomUuid { get; } + public string UpsertSubscription { get; } + public string GetSubscriptions { get; } + public string LockAndSelect { get; } + public string Complete { get; } + public string Fail { get; } + public string Notify { get; } + + public PostgreSqlTemplate(PostgreSqlMessageBusSettings settings) + { + var schemaName = PostgreSqlHelper.QuoteIdentifier(settings.DatabaseSchemaName, nameof(settings.DatabaseSchemaName)); + var tableName = PostgreSqlHelper.QuoteIdentifier(settings.DatabaseTableName, nameof(settings.DatabaseTableName)); + var subscriptionsTableName = PostgreSqlHelper.QuoteIdentifier($"{settings.DatabaseTableName}_subscriptions", nameof(settings.DatabaseTableName)); + var migrationsTableName = PostgreSqlHelper.QuoteIdentifier(settings.DatabaseMigrationsTableName, nameof(settings.DatabaseMigrationsTableName)); + + TableNameQualified = $"{schemaName}.{tableName}"; + SubscriptionsTableNameQualified = $"{schemaName}.{subscriptionsTableName}"; + MigrationsTableNameQualified = $"{schemaName}.{migrationsTableName}"; + SubscriptionsPrimaryKeyName = PostgreSqlHelper.QuoteIdentifier($"pk_{settings.DatabaseTableName}_subscriptions", nameof(settings.DatabaseTableName)); + ReadyIndexName = PostgreSqlHelper.QuoteIdentifier($"ix_{settings.DatabaseTableName}_ready", nameof(settings.DatabaseTableName)); + LockIndexName = PostgreSqlHelper.QuoteIdentifier($"ix_{settings.DatabaseTableName}_lock", nameof(settings.DatabaseTableName)); + MigrationsPrimaryKeyName = PostgreSqlHelper.QuoteIdentifier($"PK_{settings.DatabaseMigrationsTableName}", nameof(settings.DatabaseMigrationsTableName)); + + InsertMigration = $""" + with row_inserted as ( + insert into {MigrationsTableNameQualified} ("MigrationId", "ProductVersion") + values (:migration_id, :product_version) + on conflict ("MigrationId") do nothing + returning true as inserted + ) + select coalesce(inserted, false) + from row_inserted; + """; + + string insertWith(string idValue) => $""" + insert into {TableNameQualified} + (id, created_on, visible_on, path, path_kind, subscription_name, message_type, message_payload, headers, lock_instance_id, lock_expires_on, delivery_attempt, delivery_complete, delivery_aborted) + values ({idValue}, now() at time zone 'UTC', now() at time zone 'UTC', :path, :path_kind, :subscription_name, :message_type, :message_payload, :headers, null, null, 0, false, false) + returning id; + """; + + InsertWithClientId = insertWith(":id"); + InsertWithDatabaseRandomUuid = insertWith("gen_random_uuid()"); + + UpsertSubscription = $""" + insert into {SubscriptionsTableNameQualified} (path, subscription_name, created_on, last_seen_on) + values (:path, :subscription_name, now() at time zone 'UTC', now() at time zone 'UTC') + on conflict (path, subscription_name) + do update set last_seen_on = excluded.last_seen_on; + """; + + GetSubscriptions = $""" + select subscription_name + from {SubscriptionsTableNameQualified} + where path = :path; + """; + + LockAndSelect = $""" + with batch as ( + select ctid + from {TableNameQualified} + where path = :path + and path_kind = :path_kind + and subscription_name is not distinct from :subscription_name + and delivery_complete = false + and delivery_aborted = false + and visible_on <= now() at time zone 'UTC' + and (lock_instance_id = :instance_id or lock_expires_on is null or lock_expires_on < now() at time zone 'UTC') + order by created_on asc, sequence_id asc + limit :batch_size + for update skip locked + ) + update {TableNameQualified} t + set lock_instance_id = :instance_id, + lock_expires_on = now() at time zone 'UTC' + :lock_duration + from batch b + where t.ctid = b.ctid + returning t.id, t.path, t.path_kind, t.subscription_name, t.message_type, t.message_payload, t.headers; + """; + + Complete = $""" + update {TableNameQualified} + set delivery_complete = true, + delivery_attempt = delivery_attempt + 1 + where id = any(:ids); + """; + + Fail = $""" + update {TableNameQualified} + set delivery_attempt = delivery_attempt + 1, + delivery_aborted = delivery_attempt + 1 >= :max_delivery_attempts, + lock_instance_id = null, + lock_expires_on = null + where id = any(:ids); + """; + + Notify = "select pg_notify(:channel, :payload);"; + } +} diff --git a/src/SlimMessageBus.Host.PostgreSql/PostgreSqlTopologyService.cs b/src/SlimMessageBus.Host.PostgreSql/PostgreSqlTopologyService.cs new file mode 100644 index 00000000..7acddeb9 --- /dev/null +++ b/src/SlimMessageBus.Host.PostgreSql/PostgreSqlTopologyService.cs @@ -0,0 +1,106 @@ +namespace SlimMessageBus.Host.PostgreSql; + +using System.Reflection; + +public class PostgreSqlTopologyService +{ + private readonly ILogger _logger; + private readonly PostgreSqlMessageBusSettings _settings; + private readonly PostgreSqlRepository _repository; + private readonly PostgreSqlTemplate _template; + + public PostgreSqlTopologyService(ILogger logger, PostgreSqlMessageBusSettings settings, PostgreSqlRepository repository, PostgreSqlTemplate template) + { + _logger = logger; + _settings = settings; + _repository = repository; + _template = template; + } + + public async Task Migrate(CancellationToken cancellationToken) + { + await EnsureMigrationHistoryTable(cancellationToken).ConfigureAwait(false); + await ApplyMigration("20260630000000_SMB_PostgreSql_Transport_Init", $""" + create extension if not exists pgcrypto; + + create table {_template.TableNameQualified} ( + sequence_id bigserial not null primary key, + id uuid not null unique, + created_on timestamptz not null, + visible_on timestamptz not null, + path text not null, + path_kind smallint not null, + subscription_name text null, + message_type text not null, + message_payload bytea not null, + headers jsonb null, + lock_instance_id text null, + lock_expires_on timestamptz null, + delivery_attempt int not null, + delivery_complete boolean not null, + delivery_aborted boolean not null + ); + + create table {_template.SubscriptionsTableNameQualified} ( + path text not null, + subscription_name text not null, + created_on timestamptz not null, + last_seen_on timestamptz not null, + constraint {_template.SubscriptionsPrimaryKeyName} primary key (path, subscription_name) + ); + + create index {_template.ReadyIndexName} + on {_template.TableNameQualified} (path, path_kind, subscription_name, visible_on, created_on, sequence_id) + where delivery_complete = false and delivery_aborted = false; + + create index {_template.LockIndexName} + on {_template.TableNameQualified} (lock_instance_id, lock_expires_on) + where delivery_complete = false and delivery_aborted = false; + """, cancellationToken).ConfigureAwait(false); + } + + private async Task EnsureMigrationHistoryTable(CancellationToken cancellationToken) + { + await _repository.EnsureConnection(cancellationToken).ConfigureAwait(false); + await _repository.ExecuteNonQuery(_settings.SchemaCreationRetry, $""" + create table if not exists {_template.MigrationsTableNameQualified} + ( + "MigrationId" character varying(150) not null, + "ProductVersion" character varying(32) not null, + constraint {_template.MigrationsPrimaryKeyName} primary key ("MigrationId") + ); + """, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + private async Task ApplyMigration(string migrationId, string sql, CancellationToken cancellationToken) + { + var productVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "1.0.0"; + await PostgreSqlHelper.RetryIfTransientError(_logger, _settings.SchemaCreationRetry, async () => + { + await _repository.EnsureConnection(cancellationToken).ConfigureAwait(false); +#if NETSTANDARD2_0 + using var transaction = _repository.Connection.BeginTransaction(); +#else + await using var transaction = await _repository.Connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); +#endif + + using var cmd = _repository.Connection.CreateCommand(); + cmd.Transaction = transaction; + // SQL identifiers are validated and quoted by PostgreSqlTemplate; identifiers cannot be parameterized. NOSONAR + cmd.CommandText = _template.InsertMigration; + cmd.Parameters.AddWithValue("migration_id", migrationId); + cmd.Parameters.AddWithValue("product_version", productVersion); + + var inserted = (bool?)await cmd.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); + if (inserted == true) + { + cmd.Parameters.Clear(); + // Migration SQL is provider-owned and built from validated, quoted identifiers. NOSONAR + cmd.CommandText = sql; + await cmd.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + }, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/SlimMessageBus.Host.PostgreSql/PostgreSqlTransportMessage.cs b/src/SlimMessageBus.Host.PostgreSql/PostgreSqlTransportMessage.cs new file mode 100644 index 00000000..02e0b53a --- /dev/null +++ b/src/SlimMessageBus.Host.PostgreSql/PostgreSqlTransportMessage.cs @@ -0,0 +1,12 @@ +namespace SlimMessageBus.Host.PostgreSql; + +public class PostgreSqlTransportMessage : IRelationalTransportMessage +{ + public Guid Id { get; set; } + public string MessageType { get; set; } = null!; + public byte[] MessagePayload { get; set; } = null!; + public Dictionary? Headers { get; set; } + public string Path { get; set; } = null!; + public PathKind PathKind { get; set; } + public string? SubscriptionName { get; set; } +} diff --git a/src/SlimMessageBus.Host.PostgreSql/ServiceScopeExtensions.cs b/src/SlimMessageBus.Host.PostgreSql/ServiceScopeExtensions.cs new file mode 100644 index 00000000..c52bf126 --- /dev/null +++ b/src/SlimMessageBus.Host.PostgreSql/ServiceScopeExtensions.cs @@ -0,0 +1,16 @@ +namespace SlimMessageBus.Host.PostgreSql; + +internal static class ServiceScopeExtensions +{ + public static async ValueTask DisposeAsyncScope(this IServiceScope scope) + { + if (scope is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync().ConfigureAwait(false); + } + else + { + scope.Dispose(); + } + } +} diff --git a/src/SlimMessageBus.Host.PostgreSql/SlimMessageBus.Host.PostgreSql.csproj b/src/SlimMessageBus.Host.PostgreSql/SlimMessageBus.Host.PostgreSql.csproj new file mode 100644 index 00000000..147afee5 --- /dev/null +++ b/src/SlimMessageBus.Host.PostgreSql/SlimMessageBus.Host.PostgreSql.csproj @@ -0,0 +1,25 @@ + + + + + + PostgreSQL transport provider for SlimMessageBus + PostgreSQL transport provider SlimMessageBus MessageBus bus facade messaging client + enable + + + + + + + + + + + + + + + + + diff --git a/src/SlimMessageBus.Host.PostgreSql/Usings.cs b/src/SlimMessageBus.Host.PostgreSql/Usings.cs new file mode 100644 index 00000000..a65e4941 --- /dev/null +++ b/src/SlimMessageBus.Host.PostgreSql/Usings.cs @@ -0,0 +1,14 @@ +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.DependencyInjection.Extensions; +global using Microsoft.Extensions.Logging; + +global using Npgsql; +global using NpgsqlTypes; + +global using System.Data; +global using System.Text.Json; + +global using SlimMessageBus.Host.Collections; +global using SlimMessageBus.Host.Consumer.ErrorHandling; +global using SlimMessageBus.Host.Services; +global using SlimMessageBus.Host.Relational; diff --git a/src/SlimMessageBus.Host.Relational/IRelationalMessageBusSettings.cs b/src/SlimMessageBus.Host.Relational/IRelationalMessageBusSettings.cs new file mode 100644 index 00000000..c410e87c --- /dev/null +++ b/src/SlimMessageBus.Host.Relational/IRelationalMessageBusSettings.cs @@ -0,0 +1,9 @@ +namespace SlimMessageBus.Host.Relational; + +public interface IRelationalMessageBusSettings +{ + TimeSpan PollDelay { get; } + TimeSpan LockDuration { get; } + int PollBatchSize { get; } + int MaxDeliveryAttempts { get; } +} diff --git a/src/SlimMessageBus.Host.Relational/IRelationalRepository.cs b/src/SlimMessageBus.Host.Relational/IRelationalRepository.cs new file mode 100644 index 00000000..862cf1c7 --- /dev/null +++ b/src/SlimMessageBus.Host.Relational/IRelationalRepository.cs @@ -0,0 +1,12 @@ +namespace SlimMessageBus.Host.Relational; + +public interface IRelationalRepository + where TMessage : IRelationalTransportMessage +{ + Task UpsertSubscription(string path, string subscriptionName, CancellationToken cancellationToken); + Task> GetSubscriptions(string path, CancellationToken cancellationToken); + Task Insert(TMessage message, CancellationToken cancellationToken); + Task> LockAndSelect(string path, PathKind pathKind, string subscriptionName, string instanceId, int batchSize, TimeSpan lockDuration, CancellationToken cancellationToken); + Task Complete(IReadOnlyCollection messages, CancellationToken cancellationToken); + Task Fail(IReadOnlyCollection messages, int maxDeliveryAttempts, CancellationToken cancellationToken); +} diff --git a/src/SlimMessageBus.Host.Relational/IRelationalTransportMessage.cs b/src/SlimMessageBus.Host.Relational/IRelationalTransportMessage.cs new file mode 100644 index 00000000..da70b013 --- /dev/null +++ b/src/SlimMessageBus.Host.Relational/IRelationalTransportMessage.cs @@ -0,0 +1,8 @@ +namespace SlimMessageBus.Host.Relational; + +public interface IRelationalTransportMessage +{ + Guid Id { get; } + byte[] MessagePayload { get; } + Dictionary Headers { get; } +} diff --git a/src/SlimMessageBus.Host.Relational/RelationalBuilderExtensions.cs b/src/SlimMessageBus.Host.Relational/RelationalBuilderExtensions.cs new file mode 100644 index 00000000..6c90c7ab --- /dev/null +++ b/src/SlimMessageBus.Host.Relational/RelationalBuilderExtensions.cs @@ -0,0 +1,130 @@ +namespace SlimMessageBus.Host.Relational; + +public static class RelationalBuilderExtensions +{ + public static ConsumerBuilder Queue(ConsumerBuilder builder, string queue) + { + if (builder is null) throw new ArgumentNullException(nameof(builder)); + if (queue is null) throw new ArgumentNullException(nameof(queue)); + + builder.Path(queue); + builder.ConsumerSettings.PathKind = PathKind.Queue; + return builder; + } + + public static ConsumerBuilder Topic(ConsumerBuilder builder, string topic, string subscriptionName, string subscriptionPropertyName) + { + if (builder is null) throw new ArgumentNullException(nameof(builder)); + if (topic is null) throw new ArgumentNullException(nameof(topic)); + + builder.Topic(topic); + builder.ConsumerSettings.PathKind = PathKind.Topic; + SetSubscription(builder.ConsumerSettings, subscriptionName, subscriptionPropertyName); + return builder; + } + + public static ConsumerBuilder Subscription(ConsumerBuilder builder, string subscriptionName, string subscriptionPropertyName) + { + if (builder is null) throw new ArgumentNullException(nameof(builder)); + SetSubscription(builder.ConsumerSettings, subscriptionName, subscriptionPropertyName); + return builder; + } + + public static HandlerBuilder Queue(HandlerBuilder builder, string queue) + { + if (builder is null) throw new ArgumentNullException(nameof(builder)); + if (queue is null) throw new ArgumentNullException(nameof(queue)); + + builder.Path(queue); + builder.ConsumerSettings.PathKind = PathKind.Queue; + return builder; + } + + public static HandlerBuilder Queue(HandlerBuilder builder, string queue) + { + if (builder is null) throw new ArgumentNullException(nameof(builder)); + if (queue is null) throw new ArgumentNullException(nameof(queue)); + + builder.Path(queue); + builder.ConsumerSettings.PathKind = PathKind.Queue; + return builder; + } + + public static HandlerBuilder Topic(HandlerBuilder builder, string topic, string subscriptionName, string subscriptionPropertyName) + { + if (builder is null) throw new ArgumentNullException(nameof(builder)); + if (topic is null) throw new ArgumentNullException(nameof(topic)); + + builder.Path(topic); + builder.ConsumerSettings.PathKind = PathKind.Topic; + SetSubscription(builder.ConsumerSettings, subscriptionName, subscriptionPropertyName); + return builder; + } + + public static HandlerBuilder Topic(HandlerBuilder builder, string topic, string subscriptionName, string subscriptionPropertyName) + { + if (builder is null) throw new ArgumentNullException(nameof(builder)); + if (topic is null) throw new ArgumentNullException(nameof(topic)); + + builder.Path(topic); + builder.ConsumerSettings.PathKind = PathKind.Topic; + SetSubscription(builder.ConsumerSettings, subscriptionName, subscriptionPropertyName); + return builder; + } + + public static ProducerBuilder DefaultQueue(ProducerBuilder producerBuilder, string queue) + { + if (producerBuilder is null) throw new ArgumentNullException(nameof(producerBuilder)); + if (queue is null) throw new ArgumentNullException(nameof(queue)); + + producerBuilder.DefaultTopic(queue); + return ToQueue(producerBuilder); + } + + public static ProducerBuilder ToTopic(ProducerBuilder producerBuilder) + => SetProducerPathKind(producerBuilder, PathKind.Topic); + + public static ProducerBuilder ToQueue(ProducerBuilder producerBuilder) + => SetProducerPathKind(producerBuilder, PathKind.Queue); + + public static RequestResponseBuilder ReplyToQueue(RequestResponseBuilder builder, string queue) + { + if (builder is null) throw new ArgumentNullException(nameof(builder)); + if (queue is null) throw new ArgumentNullException(nameof(queue)); + + builder.Settings.Path = queue; + builder.Settings.PathKind = PathKind.Queue; + return builder; + } + + public static RequestResponseBuilder ReplyToTopic(RequestResponseBuilder builder, string topic, string subscriptionName, string subscriptionPropertyName) + { + if (builder is null) throw new ArgumentNullException(nameof(builder)); + if (topic is null) throw new ArgumentNullException(nameof(topic)); + + builder.Settings.Path = topic; + builder.Settings.PathKind = PathKind.Topic; + SetSubscription(builder.Settings, subscriptionName, subscriptionPropertyName); + return builder; + } + + public static string GetSubscriptionName(AbstractConsumerSettings settings, string subscriptionPropertyName) + => settings.Properties.TryGetValue(subscriptionPropertyName, out var value) + ? (string)value + : settings.Path; + + private static void SetSubscription(HasProviderExtensions settings, string subscriptionName, string subscriptionPropertyName) + { + if (subscriptionName is null) throw new ArgumentNullException(nameof(subscriptionName)); + + settings.Properties[subscriptionPropertyName] = subscriptionName; + } + + private static ProducerBuilder SetProducerPathKind(ProducerBuilder producerBuilder, PathKind pathKind) + { + if (producerBuilder is null) throw new ArgumentNullException(nameof(producerBuilder)); + + producerBuilder.Settings.PathKind = pathKind; + return producerBuilder; + } +} diff --git a/src/SlimMessageBus.Host.Relational/RelationalConsumerBase.cs b/src/SlimMessageBus.Host.Relational/RelationalConsumerBase.cs new file mode 100644 index 00000000..845018df --- /dev/null +++ b/src/SlimMessageBus.Host.Relational/RelationalConsumerBase.cs @@ -0,0 +1,151 @@ +namespace SlimMessageBus.Host.Relational; + +using System.Diagnostics; + +using Microsoft.Extensions.DependencyInjection; + +using SlimMessageBus.Host.Consumer; +using SlimMessageBus.Host.Interceptor; + +public abstract class RelationalConsumerBase : AbstractConsumer + where TMessage : IRelationalTransportMessage + where TRepository : IRelationalRepository +{ + private readonly IServiceProvider _serviceProvider; + private readonly IRelationalMessageBusSettings _providerSettings; + private readonly IMessageProcessor _messageProcessor; + private readonly PathKind _pathKind; + private readonly string _subscriptionName; + private readonly string _instanceId; + private readonly string _transportName; + private Task _task; + + protected RelationalConsumerBase( + ILogger logger, + IEnumerable consumerSettings, + IEnumerable interceptors, + IServiceProvider serviceProvider, + IRelationalMessageBusSettings providerSettings, + IMessageProcessor messageProcessor, + string path, + PathKind pathKind, + string subscriptionName, + string instanceId, + string transportName) + : base(logger, consumerSettings, path, interceptors) + { + _serviceProvider = serviceProvider; + _providerSettings = providerSettings; + _messageProcessor = messageProcessor; + _pathKind = pathKind; + _subscriptionName = subscriptionName; + _instanceId = instanceId; + _transportName = transportName; + } + + protected override Task OnStart() + { + _task = Run(); + return Task.CompletedTask; + } + + protected override async Task OnStop() + { + if (_task != null) + { + try + { + await _task.ConfigureAwait(false); + } + catch (OperationCanceledException e) when (CancellationToken.IsCancellationRequested) + { + Logger.LogDebug(e, "{TransportName} consumer for path {Path} stopped", _transportName, Path); + } + _task = null; + } + } + + private async Task Run() + { + var idle = Stopwatch.StartNew(); + + while (!CancellationToken.IsCancellationRequested) + { + try + { + await PollOnce(idle).ConfigureAwait(false); + } + catch (OperationCanceledException) when (CancellationToken.IsCancellationRequested) + { + return; + } + catch (Exception e) when (!CancellationToken.IsCancellationRequested) + { + Logger.LogWarning(e, "Error occurred while polling {TransportName} path {Path}", _transportName, Path); + try + { + await Task.Delay(_providerSettings.PollDelay, CancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (CancellationToken.IsCancellationRequested) + { + return; + } + } + } + } + + private async Task PollOnce(Stopwatch idle) + { + var scope = _serviceProvider.CreateScope(); + try + { + var repository = scope.ServiceProvider.GetRequiredService(); + var messages = await repository.LockAndSelect(Path, _pathKind, _subscriptionName, _instanceId, _providerSettings.PollBatchSize, _providerSettings.LockDuration, CancellationToken).ConfigureAwait(false); + + if (messages.Count == 0) + { + await DelayWhenIdle(idle).ConfigureAwait(false); + return; + } + + idle.Restart(); + await ProcessMessages(repository, messages).ConfigureAwait(false); + } + finally + { + if (scope is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync().ConfigureAwait(false); + } + else + { + scope.Dispose(); + } + } + } + + private async Task DelayWhenIdle(Stopwatch idle) + { + if (idle.Elapsed >= _providerSettings.PollDelay) + { + await Task.Delay(_providerSettings.PollDelay, CancellationToken).ConfigureAwait(false); + } + } + + private async Task ProcessMessages(TRepository repository, IReadOnlyCollection messages) + { + foreach (var message in messages) + { + var result = await _messageProcessor.ProcessMessage(message, message.Headers, cancellationToken: CancellationToken).ConfigureAwait(false); + if (result.Exception == null) + { + await repository.Complete([message], CancellationToken).ConfigureAwait(false); + } + else + { + Logger.LogError(result.Exception, "Error occurred while processing {TransportName} message {MessageId} on {Path}", _transportName, message.Id, Path); + await repository.Fail([message], _providerSettings.MaxDeliveryAttempts, CancellationToken).ConfigureAwait(false); + } + } + } +} diff --git a/src/SlimMessageBus.Host.Relational/RelationalMessageBusBase.cs b/src/SlimMessageBus.Host.Relational/RelationalMessageBusBase.cs new file mode 100644 index 00000000..fceb15b0 --- /dev/null +++ b/src/SlimMessageBus.Host.Relational/RelationalMessageBusBase.cs @@ -0,0 +1,162 @@ +namespace SlimMessageBus.Host.Relational; + +using Microsoft.Extensions.DependencyInjection; + +using SlimMessageBus.Host.Collections; +using SlimMessageBus.Host.Consumer.ErrorHandling; +using SlimMessageBus.Host.Interceptor; + +public abstract class RelationalMessageBusBase : MessageBusBase + where TSettings : class, IRelationalMessageBusSettings + where TMessage : IRelationalTransportMessage + where TRepository : IRelationalRepository +{ + private readonly KindMapping _kindMapping = new(); + private readonly string _instanceId = $"{Environment.MachineName}-{Guid.NewGuid():N}"; + + protected RelationalMessageBusBase(MessageBusSettings settings, TSettings providerSettings) + : base(settings, providerSettings) + { + OnBuildProvider(); + } + + protected abstract Type ConsumerErrorHandlerOpenGenericType { get; } + + protected override void Build() + { + base.Build(); + + _kindMapping.Configure(Settings); + InitTaskList.Add(ProvisionTopology, CancellationToken); + } + + protected async Task ProvisionSubscriptions(TRepository repository) + { + foreach (var consumer in Settings.Consumers.Where(x => x.PathKind == PathKind.Topic)) + { + await repository.UpsertSubscription(consumer.Path, GetSubscriptionName(consumer), CancellationToken).ConfigureAwait(false); + } + + if (Settings.RequestResponse?.PathKind == PathKind.Topic) + { + await repository.UpsertSubscription(Settings.RequestResponse.Path, GetSubscriptionName(Settings.RequestResponse), CancellationToken).ConfigureAwait(false); + } + } + + protected override async Task CreateConsumers() + { + await base.CreateConsumers().ConfigureAwait(false); + + MessageProvider GetMessageProvider(string path) + => SerializerProvider.GetSerializer(path).GetMessageProvider(t => t.MessagePayload); + + foreach (var ((path, pathKind, subscriptionName), consumerSettings) in Settings.Consumers + .GroupBy(x => (x.Path, x.PathKind, SubscriptionName: x.PathKind == PathKind.Topic ? GetSubscriptionName(x) : null)) + .ToDictionary(x => x.Key, x => x.ToList())) + { + var processor = new MessageProcessor( + consumerSettings, + messageBus: this, + messageProvider: GetMessageProvider(path), + path: path, + responseProducer: this, + consumerErrorHandlerOpenGenericType: ConsumerErrorHandlerOpenGenericType); + + AddRelationalConsumers(consumerSettings, processor, path, pathKind, subscriptionName); + } + + if (Settings.RequestResponse != null) + { + var path = Settings.RequestResponse.Path; + var pathKind = Settings.RequestResponse.PathKind; + var subscriptionName = pathKind == PathKind.Topic ? GetSubscriptionName(Settings.RequestResponse) : null; + var processor = new ResponseMessageProcessor(LoggerFactory, Settings.RequestResponse, GetMessageProvider(path), PendingRequestStore, TimeProvider); + + AddRelationalConsumers([Settings.RequestResponse], processor, path, pathKind, subscriptionName); + } + } + + public override async Task ProduceToTransport(object message, Type messageType, string path, IDictionary messageHeaders, IMessageBusTarget targetBus, CancellationToken cancellationToken) + { + try + { + OnProduceToTransport(message, messageType, path, messageHeaders); + + var serviceProvider = targetBus?.ServiceProvider ?? Settings.ServiceProvider; + var scope = serviceProvider.CreateScope(); + try + { + var repository = scope.ServiceProvider.GetRequiredService(); + var kind = _kindMapping.GetKind(messageType, path); + var payload = SerializerProvider.GetSerializer(path).Serialize(messageType, messageHeaders, message, null); + var messageTypeName = MessageTypeResolver.ToName(messageType); + + if (kind == PathKind.Topic) + { + var subscriptions = await repository.GetSubscriptions(path, cancellationToken).ConfigureAwait(false); + foreach (var subscriptionName in subscriptions) + { + await repository.Insert(CreateTransportMessage(serviceProvider, path, kind, subscriptionName, messageTypeName, payload, messageHeaders), cancellationToken).ConfigureAwait(false); + } + } + else + { + await repository.Insert(CreateTransportMessage(serviceProvider, path, kind, null, messageTypeName, payload, messageHeaders), cancellationToken).ConfigureAwait(false); + } + + await AfterProduce(repository, path, cancellationToken).ConfigureAwait(false); + } + finally + { + if (scope is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync().ConfigureAwait(false); + } + else + { + scope.Dispose(); + } + } + } + catch (Exception ex) when (ex is not ProducerMessageBusException && ex is not TaskCanceledException) + { + throw new ProducerMessageBusException(GetProducerErrorMessage(path, message, messageType, ex), ex); + } + } + + public override async Task> ProduceToTransportBulk(IReadOnlyCollection envelopes, string path, IMessageBusTarget targetBus, CancellationToken cancellationToken) + { + var dispatched = new List(); + try + { + foreach (var envelope in envelopes) + { + await ProduceToTransport(envelope.Message, envelope.MessageType, path, envelope.Headers, targetBus, cancellationToken).ConfigureAwait(false); + dispatched.Add(envelope); + } + return new ProduceToTransportBulkResult(dispatched, null); + } + catch (Exception ex) + { + return new ProduceToTransportBulkResult(dispatched, ex); + } + } + + private void AddRelationalConsumers(IEnumerable consumerSettings, IMessageProcessor processor, string path, PathKind pathKind, string subscriptionName) + { + var instances = consumerSettings.Max(x => x.Instances); + for (var i = 0; i < instances; i++) + { + AddConsumer(CreateConsumer(consumerSettings, processor, path, pathKind, subscriptionName, $"{_instanceId}-{path}-{subscriptionName}-{i}")); + } + } + + protected abstract string GetSubscriptionName(AbstractConsumerSettings settings); + + protected abstract AbstractConsumer CreateConsumer(IEnumerable consumerSettings, IMessageProcessor processor, string path, PathKind pathKind, string subscriptionName, string instanceId); + + protected abstract TMessage CreateTransportMessage(IServiceProvider serviceProvider, string path, PathKind pathKind, string subscriptionName, string messageType, byte[] payload, IDictionary headers); + + protected virtual Task AfterProduce(TRepository repository, string path, CancellationToken cancellationToken) + => Task.CompletedTask; +} diff --git a/src/SlimMessageBus.Host.Relational/SlimMessageBus.Host.Relational.csproj b/src/SlimMessageBus.Host.Relational/SlimMessageBus.Host.Relational.csproj new file mode 100644 index 00000000..c994d273 --- /dev/null +++ b/src/SlimMessageBus.Host.Relational/SlimMessageBus.Host.Relational.csproj @@ -0,0 +1,16 @@ + + + + + + Common relational transport logic for SlimMessageBus + SlimMessageBus relational transport common logic + icon.png + + + + + + + + diff --git a/src/SlimMessageBus.Host.Relational/Usings.cs b/src/SlimMessageBus.Host.Relational/Usings.cs new file mode 100644 index 00000000..bd2e3f64 --- /dev/null +++ b/src/SlimMessageBus.Host.Relational/Usings.cs @@ -0,0 +1,3 @@ +global using Microsoft.Extensions.Logging; + +global using SlimMessageBus.Host; diff --git a/src/SlimMessageBus.Host.Sql.Common/Usings.cs b/src/SlimMessageBus.Host.Sql.Common/Usings.cs index 6beb698d..162e29c0 100644 --- a/src/SlimMessageBus.Host.Sql.Common/Usings.cs +++ b/src/SlimMessageBus.Host.Sql.Common/Usings.cs @@ -1,4 +1,4 @@ global using System.Data; global using Microsoft.Data.SqlClient; -global using Microsoft.Extensions.Logging; +global using Microsoft.Extensions.Logging; diff --git a/src/SlimMessageBus.Host.Sql/Config/SqlConsumerBuilderExtensions.cs b/src/SlimMessageBus.Host.Sql/Config/SqlConsumerBuilderExtensions.cs new file mode 100644 index 00000000..f8f148fd --- /dev/null +++ b/src/SlimMessageBus.Host.Sql/Config/SqlConsumerBuilderExtensions.cs @@ -0,0 +1,18 @@ +namespace SlimMessageBus.Host.Sql; + +public static class SqlConsumerBuilderExtensions +{ + internal const string PropertySubscriptionName = "Sql_SubscriptionName"; + + public static ConsumerBuilder Queue(this ConsumerBuilder builder, string queue) + => RelationalBuilderExtensions.Queue(builder, queue); + + public static ConsumerBuilder Topic(this ConsumerBuilder builder, string topic, string subscriptionName) + => RelationalBuilderExtensions.Topic(builder, topic, subscriptionName, PropertySubscriptionName); + + public static ConsumerBuilder Subscription(this ConsumerBuilder builder, string subscriptionName) + => RelationalBuilderExtensions.Subscription(builder, subscriptionName, PropertySubscriptionName); + + internal static string GetSubscriptionName(this AbstractConsumerSettings settings) + => RelationalBuilderExtensions.GetSubscriptionName(settings, PropertySubscriptionName); +} diff --git a/src/SlimMessageBus.Host.Sql/Config/SqlHandlerBuilderExtensions.cs b/src/SlimMessageBus.Host.Sql/Config/SqlHandlerBuilderExtensions.cs new file mode 100644 index 00000000..535aed18 --- /dev/null +++ b/src/SlimMessageBus.Host.Sql/Config/SqlHandlerBuilderExtensions.cs @@ -0,0 +1,16 @@ +namespace SlimMessageBus.Host.Sql; + +public static class SqlHandlerBuilderExtensions +{ + public static HandlerBuilder Queue(this HandlerBuilder builder, string queue) + => RelationalBuilderExtensions.Queue(builder, queue); + + public static HandlerBuilder Queue(this HandlerBuilder builder, string queue) + => RelationalBuilderExtensions.Queue(builder, queue); + + public static HandlerBuilder Topic(this HandlerBuilder builder, string topic, string subscriptionName) + => RelationalBuilderExtensions.Topic(builder, topic, subscriptionName, SqlConsumerBuilderExtensions.PropertySubscriptionName); + + public static HandlerBuilder Topic(this HandlerBuilder builder, string topic, string subscriptionName) + => RelationalBuilderExtensions.Topic(builder, topic, subscriptionName, SqlConsumerBuilderExtensions.PropertySubscriptionName); +} diff --git a/src/SlimMessageBus.Host.Sql/Config/SqlProducerBuilderExtensions.cs b/src/SlimMessageBus.Host.Sql/Config/SqlProducerBuilderExtensions.cs new file mode 100644 index 00000000..c8503bed --- /dev/null +++ b/src/SlimMessageBus.Host.Sql/Config/SqlProducerBuilderExtensions.cs @@ -0,0 +1,13 @@ +namespace SlimMessageBus.Host.Sql; + +public static class SqlProducerBuilderExtensions +{ + public static ProducerBuilder DefaultQueue(this ProducerBuilder producerBuilder, string queue) + => RelationalBuilderExtensions.DefaultQueue(producerBuilder, queue); + + public static ProducerBuilder ToTopic(this ProducerBuilder producerBuilder) + => RelationalBuilderExtensions.ToTopic(producerBuilder); + + public static ProducerBuilder ToQueue(this ProducerBuilder producerBuilder) + => RelationalBuilderExtensions.ToQueue(producerBuilder); +} diff --git a/src/SlimMessageBus.Host.Sql/Config/SqlRequestResponseBuilderExtensions.cs b/src/SlimMessageBus.Host.Sql/Config/SqlRequestResponseBuilderExtensions.cs new file mode 100644 index 00000000..6cf71189 --- /dev/null +++ b/src/SlimMessageBus.Host.Sql/Config/SqlRequestResponseBuilderExtensions.cs @@ -0,0 +1,10 @@ +namespace SlimMessageBus.Host.Sql; + +public static class SqlRequestResponseBuilderExtensions +{ + public static RequestResponseBuilder ReplyToQueue(this RequestResponseBuilder builder, string queue) + => RelationalBuilderExtensions.ReplyToQueue(builder, queue); + + public static RequestResponseBuilder ReplyToTopic(this RequestResponseBuilder builder, string topic, string subscriptionName) + => RelationalBuilderExtensions.ReplyToTopic(builder, topic, subscriptionName, SqlConsumerBuilderExtensions.PropertySubscriptionName); +} diff --git a/src/SlimMessageBus.Host.Sql/Configuration/SqlMessageBusBuilderExtensions.cs b/src/SlimMessageBus.Host.Sql/Configuration/SqlMessageBusBuilderExtensions.cs index 8d51df02..096e2c95 100644 --- a/src/SlimMessageBus.Host.Sql/Configuration/SqlMessageBusBuilderExtensions.cs +++ b/src/SlimMessageBus.Host.Sql/Configuration/SqlMessageBusBuilderExtensions.cs @@ -12,25 +12,18 @@ public static MessageBusBuilder WithProviderSql(this MessageBusBuilder mbb, Acti var providerSettings = new SqlMessageBusSettings(); configure(providerSettings); - mbb.PostConfigurationActions.Add(services => - { - services.TryAddSingleton(providerSettings); - - services.TryAddScoped(); - services.Replace(ServiceDescriptor.Scoped(svp => svp.GetRequiredService())); - - /* - services.Replace(ServiceDescriptor.Transient(svp => svp.GetRequiredService())); - - services.TryAddEnumerable(ServiceDescriptor.Transient(typeof(IConsumerInterceptor<>), typeof(SqlTransactionConsumerInterceptor<>))); - - services.TryAddScoped(); - services.Replace(ServiceDescriptor.Scoped(svp => svp.GetRequiredService())); - services.Replace(ServiceDescriptor.Scoped(svp => svp.GetRequiredService())); - - services.TryAddSingleton(); - */ - }); + mbb.PostConfigurationActions.Add(services => + { + services.TryAddSingleton(providerSettings); + services.TryAddSingleton(providerSettings); + + services.TryAddScoped(_ => new SqlConnection(providerSettings.ConnectionString)); + services.TryAddScoped(); + services.TryAddSingleton(); + services.TryAddScoped(); + services.Replace(ServiceDescriptor.Scoped(svp => svp.GetRequiredService())); + services.TryAddSingleton(); + }); return mbb.WithProvider(settings => new SqlMessageBus(settings, providerSettings)); } diff --git a/src/SlimMessageBus.Host.Sql/Configuration/SqlMessageBusSettings.cs b/src/SlimMessageBus.Host.Sql/Configuration/SqlMessageBusSettings.cs index 41c48c2e..c7d27fbc 100644 --- a/src/SlimMessageBus.Host.Sql/Configuration/SqlMessageBusSettings.cs +++ b/src/SlimMessageBus.Host.Sql/Configuration/SqlMessageBusSettings.cs @@ -1,5 +1,11 @@ namespace SlimMessageBus.Host.Sql; -public class SqlMessageBusSettings : SqlSettings -{ -} \ No newline at end of file +public class SqlMessageBusSettings : SqlSettings, IRelationalMessageBusSettings +{ + public string ConnectionString { get; set; } + public TimeSpan PollDelay { get; set; } = TimeSpan.FromMilliseconds(250); + public TimeSpan LockDuration { get; set; } = TimeSpan.FromSeconds(30); + public int PollBatchSize { get; set; } = 10; + public int MaxDeliveryAttempts { get; set; } = 10; + public SqlMessageIdGenerationSettings IdGeneration { get; set; } = new(); +} diff --git a/src/SlimMessageBus.Host.Sql/Configuration/SqlMessageIdGenerationMode.cs b/src/SlimMessageBus.Host.Sql/Configuration/SqlMessageIdGenerationMode.cs new file mode 100644 index 00000000..ad3efc6d --- /dev/null +++ b/src/SlimMessageBus.Host.Sql/Configuration/SqlMessageIdGenerationMode.cs @@ -0,0 +1,8 @@ +namespace SlimMessageBus.Host.Sql; + +public enum SqlMessageIdGenerationMode +{ + DatabaseGeneratedGuid, + DatabaseGeneratedSequentialGuid, + ClientGuidGenerator, +} diff --git a/src/SlimMessageBus.Host.Sql/Configuration/SqlMessageIdGenerationSettings.cs b/src/SlimMessageBus.Host.Sql/Configuration/SqlMessageIdGenerationSettings.cs new file mode 100644 index 00000000..b639c6e4 --- /dev/null +++ b/src/SlimMessageBus.Host.Sql/Configuration/SqlMessageIdGenerationSettings.cs @@ -0,0 +1,8 @@ +namespace SlimMessageBus.Host.Sql; + +public class SqlMessageIdGenerationSettings +{ + public SqlMessageIdGenerationMode Mode { get; set; } = SqlMessageIdGenerationMode.ClientGuidGenerator; + public Type GuidGeneratorType { get; set; } = typeof(SqlSequentialGuidGenerator); + public IGuidGenerator GuidGenerator { get; set; } +} diff --git a/src/SlimMessageBus.Host.Sql/ISqlRepository.cs b/src/SlimMessageBus.Host.Sql/ISqlRepository.cs index 1220ddd6..372ff216 100644 --- a/src/SlimMessageBus.Host.Sql/ISqlRepository.cs +++ b/src/SlimMessageBus.Host.Sql/ISqlRepository.cs @@ -1,6 +1,6 @@ namespace SlimMessageBus.Host.Sql; -public interface ISqlRepository -{ - -} +public interface ISqlRepository : IRelationalRepository +{ + Task EnsureConnection(); +} diff --git a/src/SlimMessageBus.Host.Sql/ObjectToInferredTypesConverter.cs b/src/SlimMessageBus.Host.Sql/ObjectToInferredTypesConverter.cs new file mode 100644 index 00000000..4d2564e2 --- /dev/null +++ b/src/SlimMessageBus.Host.Sql/ObjectToInferredTypesConverter.cs @@ -0,0 +1,23 @@ +namespace SlimMessageBus.Host.Sql; + +using System.Text.Json; +using System.Text.Json.Serialization; + +public class ObjectToInferredTypesConverter : JsonConverter +{ + public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + reader.TokenType switch + { + JsonTokenType.True => true, + JsonTokenType.False => false, + JsonTokenType.Number when reader.TryGetInt32(out var i) => i, + JsonTokenType.Number when reader.TryGetInt64(out var l) => l, + JsonTokenType.Number => reader.GetDouble(), + JsonTokenType.String when reader.TryGetDateTime(out var datetime) => datetime, + JsonTokenType.String => reader.GetString(), + _ => JsonDocument.ParseValue(ref reader).RootElement.Clone() + }; + + public override void Write(Utf8JsonWriter writer, object objectToWrite, JsonSerializerOptions options) => + JsonSerializer.Serialize(writer, objectToWrite, objectToWrite.GetType(), options); +} diff --git a/src/SlimMessageBus.Host.Sql/ServiceScopeExtensions.cs b/src/SlimMessageBus.Host.Sql/ServiceScopeExtensions.cs new file mode 100644 index 00000000..32c6f5ef --- /dev/null +++ b/src/SlimMessageBus.Host.Sql/ServiceScopeExtensions.cs @@ -0,0 +1,16 @@ +namespace SlimMessageBus.Host.Sql; + +internal static class ServiceScopeExtensions +{ + public static async ValueTask DisposeAsyncScope(this IServiceScope scope) + { + if (scope is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync().ConfigureAwait(false); + } + else + { + scope.Dispose(); + } + } +} diff --git a/src/SlimMessageBus.Host.Sql/SlimMessageBus.Host.Sql.csproj b/src/SlimMessageBus.Host.Sql/SlimMessageBus.Host.Sql.csproj index 0a967d86..69569509 100644 --- a/src/SlimMessageBus.Host.Sql/SlimMessageBus.Host.Sql.csproj +++ b/src/SlimMessageBus.Host.Sql/SlimMessageBus.Host.Sql.csproj @@ -2,16 +2,17 @@ - - Redis transport provider for SlimMessageBus - Redis transport provider SlimMessageBus MessageBus bus facade messaging client - icon.png - - + + SQL Server transport provider for SlimMessageBus + SQL Server transport provider SlimMessageBus MessageBus bus facade messaging client + icon.png + + - - - - + + + + + diff --git a/src/SlimMessageBus.Host.Sql/SqlConsumer.cs b/src/SlimMessageBus.Host.Sql/SqlConsumer.cs new file mode 100644 index 00000000..7dc53549 --- /dev/null +++ b/src/SlimMessageBus.Host.Sql/SqlConsumer.cs @@ -0,0 +1,19 @@ +namespace SlimMessageBus.Host.Sql; + +public class SqlConsumer : RelationalConsumerBase +{ + public SqlConsumer( + ILogger logger, + IEnumerable consumerSettings, + IEnumerable interceptors, + IServiceProvider serviceProvider, + SqlMessageBusSettings providerSettings, + IMessageProcessor messageProcessor, + string path, + PathKind pathKind, + string subscriptionName, + string instanceId) + : base(logger, consumerSettings, interceptors, serviceProvider, providerSettings, messageProcessor, path, pathKind, subscriptionName, instanceId, "SQL") + { + } +} diff --git a/src/SlimMessageBus.Host.Sql/SqlConsumerErrorHandler.cs b/src/SlimMessageBus.Host.Sql/SqlConsumerErrorHandler.cs new file mode 100644 index 00000000..4a08ba74 --- /dev/null +++ b/src/SlimMessageBus.Host.Sql/SqlConsumerErrorHandler.cs @@ -0,0 +1,5 @@ +namespace SlimMessageBus.Host.Sql; + +public interface ISqlConsumerErrorHandler : IConsumerErrorHandler; + +public abstract class SqlConsumerErrorHandler : ConsumerErrorHandler, ISqlConsumerErrorHandler; diff --git a/src/SlimMessageBus.Host.Sql/SqlMessageBus.cs b/src/SlimMessageBus.Host.Sql/SqlMessageBus.cs index ecb5a577..c602997a 100644 --- a/src/SlimMessageBus.Host.Sql/SqlMessageBus.cs +++ b/src/SlimMessageBus.Host.Sql/SqlMessageBus.cs @@ -1,41 +1,64 @@ namespace SlimMessageBus.Host.Sql; -public class SqlMessageBus : MessageBusBase -{ - public SqlMessageBus(MessageBusSettings settings, SqlMessageBusSettings providerSettings) - : base(settings, providerSettings) - { - } - - protected override void Build() - { - base.Build(); - - InitTaskList.Add(ProvisionTopology, CancellationToken); - } - - public override async Task ProvisionTopology() - { - await base.ProvisionTopology(); - - using var scope = Settings.ServiceProvider.CreateScope(); - var sqlRepository = scope.ServiceProvider.GetService(); - var sqlTransactionService = scope.ServiceProvider.GetService(); - var provisioningService = new SqlTopologyService(LoggerFactory.CreateLogger(), (SqlRepository)sqlRepository, sqlTransactionService, ProviderSettings); - await provisioningService.Migrate(CancellationToken); // provisioning happens asynchronously - } - - public override Task ProduceToTransport(object message, Type messageType, string path, IDictionary messageHeaders, IMessageBusTarget targetBus, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public override Task> ProduceToTransportBulk(IReadOnlyCollection envelopes, string path, IMessageBusTarget targetBus, CancellationToken cancellationToken) - { - var sqlRepository = targetBus.ServiceProvider.GetService(); - - // ToDo: Save to table - - return Task.FromResult(new ProduceToTransportBulkResult([], new NotImplementedException())); - } -} \ No newline at end of file +public class SqlMessageBus : RelationalMessageBusBase +{ + public SqlMessageBus(MessageBusSettings settings, SqlMessageBusSettings providerSettings) + : base(settings, providerSettings) + { + } + + protected override IMessageBusSettingsValidationService ValidationService => new SqlMessageBusSettingsValidationService(Settings, ProviderSettings); + + protected override Type ConsumerErrorHandlerOpenGenericType => typeof(ISqlConsumerErrorHandler<>); + + public override async Task ProvisionTopology() + { + await base.ProvisionTopology().ConfigureAwait(false); + + var scope = Settings.ServiceProvider.CreateScope(); + try + { + var sqlRepository = scope.ServiceProvider.GetRequiredService(); + var sqlTransactionService = scope.ServiceProvider.GetService(); + var provisioningService = new SqlTopologyService(LoggerFactory.CreateLogger(), (SqlRepository)sqlRepository, sqlTransactionService, ProviderSettings); + await provisioningService.Migrate(CancellationToken).ConfigureAwait(false); + + await ProvisionSubscriptions(sqlRepository).ConfigureAwait(false); + } + finally + { + await scope.DisposeAsyncScope().ConfigureAwait(false); + } + } + + protected override string GetSubscriptionName(AbstractConsumerSettings settings) + => settings.GetSubscriptionName(); + + protected override AbstractConsumer CreateConsumer(IEnumerable consumerSettings, IMessageProcessor processor, string path, PathKind pathKind, string subscriptionName, string instanceId) + => new SqlConsumer( + LoggerFactory.CreateLogger(), + consumerSettings, + Settings.ServiceProvider.GetServices(), + Settings.ServiceProvider, + ProviderSettings, + processor, + path, + pathKind, + subscriptionName, + instanceId); + + protected override SqlTransportMessage CreateTransportMessage(IServiceProvider serviceProvider, string path, PathKind pathKind, string subscriptionName, string messageType, byte[] payload, IDictionary headers) + { + var guidGenerator = ProviderSettings.IdGeneration.GuidGenerator ?? (IGuidGenerator)serviceProvider.GetRequiredService(ProviderSettings.IdGeneration.GuidGeneratorType); + return new SqlTransportMessage + { + Id = ProviderSettings.IdGeneration.Mode == SqlMessageIdGenerationMode.ClientGuidGenerator ? guidGenerator.NewGuid() : Guid.Empty, + Path = path, + PathKind = pathKind, + SubscriptionName = subscriptionName, + MessageType = messageType, + MessagePayload = payload, + Headers = headers != null ? new Dictionary(headers) : null + }; + } +} diff --git a/src/SlimMessageBus.Host.Sql/SqlMessageBusSettingsValidationService.cs b/src/SlimMessageBus.Host.Sql/SqlMessageBusSettingsValidationService.cs new file mode 100644 index 00000000..0765ae98 --- /dev/null +++ b/src/SlimMessageBus.Host.Sql/SqlMessageBusSettingsValidationService.cs @@ -0,0 +1,25 @@ +namespace SlimMessageBus.Host.Sql; + +public class SqlMessageBusSettingsValidationService(MessageBusSettings settings, SqlMessageBusSettings providerSettings) + : DefaultMessageBusSettingsValidationService(settings) +{ + public override void AssertSettings() + { + base.AssertSettings(); + + if (string.IsNullOrWhiteSpace(providerSettings.ConnectionString)) + { + throw new ConfigurationMessageBusException("The SQL provider requires a connection string"); + } + + if (providerSettings.PollBatchSize <= 0) + { + throw new ConfigurationMessageBusException("The SQL provider poll batch size must be greater than 0"); + } + + if (providerSettings.MaxDeliveryAttempts <= 0) + { + throw new ConfigurationMessageBusException("The SQL provider max delivery attempts must be greater than 0"); + } + } +} diff --git a/src/SlimMessageBus.Host.Sql/SqlRepository.cs b/src/SlimMessageBus.Host.Sql/SqlRepository.cs index d97b5c11..b543ee3f 100644 --- a/src/SlimMessageBus.Host.Sql/SqlRepository.cs +++ b/src/SlimMessageBus.Host.Sql/SqlRepository.cs @@ -1,9 +1,150 @@ namespace SlimMessageBus.Host.Sql; -public class SqlRepository : CommonSqlRepository, ISqlRepository -{ - public SqlRepository(ILogger logger, SqlMessageBusSettings settings, SqlConnection connection, ISqlTransactionService transactionService) - : base(logger, settings, connection, transactionService) - { - } -} +public class SqlRepository : CommonSqlRepository, ISqlRepository +{ + private const string PathParameter = "@Path"; + private const string SubscriptionNameParameter = "@SubscriptionName"; + + private static readonly JsonSerializerOptions _jsonOptions = new() + { + Converters = { new ObjectToInferredTypesConverter() } + }; + + private readonly SqlMessageBusSettings _settings; + private readonly SqlTemplate _template; + + public SqlRepository(ILogger logger, SqlMessageBusSettings settings, SqlTemplate template, SqlConnection connection, ISqlTransactionService transactionService) + : base(logger, settings, connection, transactionService) + { + _settings = settings; + _template = template; + } + + public async Task UpsertSubscription(string path, string subscriptionName, CancellationToken cancellationToken) + { + await EnsureConnection(); + await ExecuteNonQuery(_settings.OperationRetry, _template.UpsertSubscription, cmd => + { + cmd.Parameters.Add(PathParameter, SqlDbType.NVarChar).Value = path; + cmd.Parameters.Add(SubscriptionNameParameter, SqlDbType.NVarChar).Value = subscriptionName; + }, cancellationToken); + } + + public async Task> GetSubscriptions(string path, CancellationToken cancellationToken) + { + await EnsureConnection(); + + using var cmd = CreateCommand(); + cmd.CommandText = _template.GetSubscriptions; + cmd.Parameters.Add(PathParameter, SqlDbType.NVarChar).Value = path; + + using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + var subscriptions = new List(); + while (await reader.ReadAsync(cancellationToken)) + { + subscriptions.Add(reader.GetString(0)); + } + + return subscriptions; + } + + public async Task Insert(SqlTransportMessage message, CancellationToken cancellationToken) + { + await EnsureConnection(); + + var insertSql = _settings.IdGeneration.Mode switch + { + SqlMessageIdGenerationMode.DatabaseGeneratedGuid => _template.InsertWithDatabaseGuid, + SqlMessageIdGenerationMode.DatabaseGeneratedSequentialGuid => _template.InsertWithDatabaseSequentialGuid, + _ => _template.InsertWithClientId + }; + + await ExecuteScalarAsync(_settings.OperationRetry, insertSql, cmd => + { + if (_settings.IdGeneration.Mode == SqlMessageIdGenerationMode.ClientGuidGenerator) + { + cmd.Parameters.Add("@Id", SqlDbType.UniqueIdentifier).Value = message.Id; + } + cmd.Parameters.Add(PathParameter, SqlDbType.NVarChar).Value = message.Path; + cmd.Parameters.Add("@PathKind", SqlDbType.TinyInt).Value = (byte)message.PathKind; + cmd.Parameters.Add(SubscriptionNameParameter, SqlDbType.NVarChar).Value = (object)message.SubscriptionName ?? DBNull.Value; + cmd.Parameters.Add("@MessageType", SqlDbType.NVarChar).Value = message.MessageType; + cmd.Parameters.Add("@MessagePayload", SqlDbType.VarBinary).Value = message.MessagePayload; + cmd.Parameters.Add("@Headers", SqlDbType.NVarChar).Value = message.Headers != null ? JsonSerializer.Serialize(message.Headers, _jsonOptions) : DBNull.Value; + }, cancellationToken); + } + + public async Task> LockAndSelect(string path, PathKind pathKind, string subscriptionName, string instanceId, int batchSize, TimeSpan lockDuration, CancellationToken cancellationToken) + { + await EnsureConnection(); + + using var cmd = CreateCommand(); + cmd.CommandText = _template.LockAndSelect; + cmd.Parameters.Add(PathParameter, SqlDbType.NVarChar).Value = path; + cmd.Parameters.Add("@PathKind", SqlDbType.TinyInt).Value = (byte)pathKind; + cmd.Parameters.Add(SubscriptionNameParameter, SqlDbType.NVarChar).Value = (object)subscriptionName ?? DBNull.Value; + cmd.Parameters.Add("@InstanceId", SqlDbType.NVarChar).Value = instanceId; + cmd.Parameters.Add("@BatchSize", SqlDbType.Int).Value = batchSize; + cmd.Parameters.Add("@LockDuration", SqlDbType.Int).Value = (int)lockDuration.TotalSeconds; + + using var reader = await cmd.ExecuteReaderAsync(cancellationToken); + + var idOrdinal = reader.GetOrdinal("Id"); + var pathOrdinal = reader.GetOrdinal("Path"); + var pathKindOrdinal = reader.GetOrdinal("PathKind"); + var subscriptionNameOrdinal = reader.GetOrdinal("SubscriptionName"); + var messageTypeOrdinal = reader.GetOrdinal("MessageType"); + var messagePayloadOrdinal = reader.GetOrdinal("MessagePayload"); + var headersOrdinal = reader.GetOrdinal("Headers"); + + var messages = new List(batchSize); + while (await reader.ReadAsync(cancellationToken)) + { + var headers = await reader.IsDBNullAsync(headersOrdinal, cancellationToken) + ? null + : JsonSerializer.Deserialize>(reader.GetString(headersOrdinal), _jsonOptions); + + messages.Add(new SqlTransportMessage + { + Id = reader.GetGuid(idOrdinal), + Path = reader.GetString(pathOrdinal), + PathKind = (PathKind)reader.GetByte(pathKindOrdinal), + SubscriptionName = await reader.IsDBNullAsync(subscriptionNameOrdinal, cancellationToken) ? null : reader.GetString(subscriptionNameOrdinal), + MessageType = reader.GetString(messageTypeOrdinal), + MessagePayload = reader.GetSqlBinary(messagePayloadOrdinal).Value, + Headers = headers + }); + } + + return messages; + } + + public async Task Complete(IReadOnlyCollection messages, CancellationToken cancellationToken) + { + if (messages.Count == 0) + { + return; + } + + await ExecuteNonQuery(_settings.OperationRetry, _template.Complete, cmd => + { + cmd.Parameters.Add("@Ids", SqlDbType.NVarChar).Value = ToIdsString(messages); + }, cancellationToken); + } + + public async Task Fail(IReadOnlyCollection messages, int maxDeliveryAttempts, CancellationToken cancellationToken) + { + if (messages.Count == 0) + { + return; + } + + await ExecuteNonQuery(_settings.OperationRetry, _template.Fail, cmd => + { + cmd.Parameters.Add("@Ids", SqlDbType.NVarChar).Value = ToIdsString(messages); + cmd.Parameters.Add("@MaxDeliveryAttempts", SqlDbType.Int).Value = maxDeliveryAttempts; + }, cancellationToken); + } + + private static string ToIdsString(IEnumerable messages) => string.Join("|", messages.Select(x => x.Id)); +} diff --git a/src/SlimMessageBus.Host.Sql/SqlSequentialGuidGenerator.cs b/src/SlimMessageBus.Host.Sql/SqlSequentialGuidGenerator.cs new file mode 100644 index 00000000..d1a4266e --- /dev/null +++ b/src/SlimMessageBus.Host.Sql/SqlSequentialGuidGenerator.cs @@ -0,0 +1,18 @@ +namespace SlimMessageBus.Host.Sql; + +public class SqlSequentialGuidGenerator : IGuidGenerator +{ + public Guid NewGuid() + { + var guidBytes = Guid.NewGuid().ToByteArray(); + var timestamp = BitConverter.GetBytes(DateTime.UtcNow.Ticks); + + if (BitConverter.IsLittleEndian) + { + Array.Reverse(timestamp); + } + + Buffer.BlockCopy(timestamp, 2, guidBytes, 10, 6); + return new Guid(guidBytes); + } +} diff --git a/src/SlimMessageBus.Host.Sql/SqlTemplate.cs b/src/SlimMessageBus.Host.Sql/SqlTemplate.cs index f7e0443d..262fa2c5 100644 --- a/src/SlimMessageBus.Host.Sql/SqlTemplate.cs +++ b/src/SlimMessageBus.Host.Sql/SqlTemplate.cs @@ -1,13 +1,96 @@ namespace SlimMessageBus.Host.Sql; -public class SqlTemplate -{ - public string TableNameQualified { get; } - public string MigrationsTableNameQualified { get; } - - public SqlTemplate(SqlMessageBusSettings settings) - { - TableNameQualified = $"[{settings.DatabaseSchemaName}].[{settings.DatabaseTableName}]"; - MigrationsTableNameQualified = $"[{settings.DatabaseSchemaName}].[{settings.DatabaseMigrationsTableName}]"; - } -} +public class SqlTemplate +{ + public string TableNameQualified { get; } + public string SubscriptionsTableNameQualified { get; } + public string MigrationsTableNameQualified { get; } + public string InsertWithClientId { get; } + public string InsertWithDatabaseGuid { get; } + public string InsertWithDatabaseSequentialGuid { get; } + public string UpsertSubscription { get; } + public string GetSubscriptions { get; } + public string LockAndSelect { get; } + public string Complete { get; } + public string Fail { get; } + + public SqlTemplate(SqlMessageBusSettings settings) + { + TableNameQualified = $"[{settings.DatabaseSchemaName}].[{settings.DatabaseTableName}]"; + SubscriptionsTableNameQualified = $"[{settings.DatabaseSchemaName}].[{settings.DatabaseTableName}Subscriptions]"; + MigrationsTableNameQualified = $"[{settings.DatabaseSchemaName}].[{settings.DatabaseMigrationsTableName}]"; + + string insertWith(string idFunc) => $""" + INSERT INTO {TableNameQualified} + ([Id], [CreatedOn], [VisibleOn], [Path], [PathKind], [SubscriptionName], [MessageType], [MessagePayload], [Headers], [LockInstanceId], [LockExpiresOn], [DeliveryAttempt], [DeliveryComplete], [DeliveryAborted]) + OUTPUT INSERTED.[Id] + VALUES ({idFunc}, SYSUTCDATETIME(), SYSUTCDATETIME(), @Path, @PathKind, @SubscriptionName, @MessageType, @MessagePayload, @Headers, NULL, NULL, 0, 0, 0) + """; + + InsertWithClientId = insertWith("@Id"); + InsertWithDatabaseGuid = insertWith("NEWID()"); + InsertWithDatabaseSequentialGuid = insertWith("DEFAULT"); + + UpsertSubscription = $""" + MERGE {SubscriptionsTableNameQualified} AS Target + USING (SELECT @Path AS [Path], @SubscriptionName AS [SubscriptionName]) AS Source + ON Target.[Path] = Source.[Path] AND Target.[SubscriptionName] = Source.[SubscriptionName] + WHEN MATCHED THEN + UPDATE SET [LastSeenOn] = SYSUTCDATETIME() + WHEN NOT MATCHED THEN + INSERT ([Path], [SubscriptionName], [CreatedOn], [LastSeenOn]) + VALUES (Source.[Path], Source.[SubscriptionName], SYSUTCDATETIME(), SYSUTCDATETIME()); + """; + + GetSubscriptions = $""" + SELECT [SubscriptionName] + FROM {SubscriptionsTableNameQualified} + WHERE [Path] = @Path + """; + + LockAndSelect = $""" + WITH Batch AS ( + SELECT TOP (@BatchSize) * + FROM {TableNameQualified} WITH (ROWLOCK, UPDLOCK, READPAST) + WHERE [Path] = @Path + AND [PathKind] = @PathKind + AND ((@SubscriptionName IS NULL AND [SubscriptionName] IS NULL) OR [SubscriptionName] = @SubscriptionName) + AND [DeliveryComplete] = 0 + AND [DeliveryAborted] = 0 + AND [VisibleOn] <= SYSUTCDATETIME() + AND ([LockInstanceId] = @InstanceId OR [LockExpiresOn] IS NULL OR [LockExpiresOn] < SYSUTCDATETIME()) + ORDER BY [CreatedOn] ASC, [SequenceId] ASC + ) + UPDATE Batch + SET [LockInstanceId] = @InstanceId, + [LockExpiresOn] = DATEADD(SECOND, @LockDuration, SYSUTCDATETIME()) + OUTPUT INSERTED.[Id], + INSERTED.[Path], + INSERTED.[PathKind], + INSERTED.[SubscriptionName], + INSERTED.[MessageType], + INSERTED.[MessagePayload], + INSERTED.[Headers]; + """; + + var idsSql = "SELECT CAST([value] AS uniqueidentifier) Id FROM STRING_SPLIT(@Ids, '|')"; + + Complete = $""" + UPDATE T + SET [DeliveryComplete] = 1, + [DeliveryAttempt] = [DeliveryAttempt] + 1 + FROM {TableNameQualified} T + INNER JOIN ({idsSql}) Ids ON T.[Id] = Ids.[Id]; + """; + + Fail = $""" + UPDATE T + SET [DeliveryAttempt] = [DeliveryAttempt] + 1, + [DeliveryAborted] = CASE WHEN [DeliveryAttempt] + 1 >= @MaxDeliveryAttempts THEN 1 ELSE 0 END, + [LockInstanceId] = NULL, + [LockExpiresOn] = NULL + FROM {TableNameQualified} T + INNER JOIN ({idsSql}) Ids ON T.[Id] = Ids.[Id]; + """; + } +} diff --git a/src/SlimMessageBus.Host.Sql/SqlTopologyService.cs b/src/SlimMessageBus.Host.Sql/SqlTopologyService.cs index 2b11a00c..04d4fb2d 100644 --- a/src/SlimMessageBus.Host.Sql/SqlTopologyService.cs +++ b/src/SlimMessageBus.Host.Sql/SqlTopologyService.cs @@ -1,11 +1,50 @@ namespace SlimMessageBus.Host.Sql; -public class SqlTopologyService : CommonSqlMigrationService +public class SqlTopologyService : CommonSqlMigrationService { public SqlTopologyService(ILogger logger, SqlRepository repository, ISqlTransactionService transactionService, SqlMessageBusSettings settings) : base(logger, repository, transactionService, settings) { } - protected override Task OnMigrate(CancellationToken token) => Task.CompletedTask; -} + protected override async Task OnMigrate(CancellationToken token) + { + await CreateTable(Settings.DatabaseTableName, [ + "SequenceId bigint IDENTITY(1,1) NOT NULL", + $"Id uniqueidentifier NOT NULL CONSTRAINT [DF_{Settings.DatabaseTableName}_Id] DEFAULT NEWSEQUENTIALID()", + "CreatedOn datetime2(7) NOT NULL", + "VisibleOn datetime2(7) NOT NULL", + "Path nvarchar(256) NOT NULL", + "PathKind tinyint NOT NULL", + "SubscriptionName nvarchar(256) NULL", + "MessageType nvarchar(512) NOT NULL", + "MessagePayload varbinary(max) NOT NULL", + "Headers nvarchar(max) NULL", + "LockInstanceId nvarchar(128) NULL", + "LockExpiresOn datetime2(7) NULL", + "DeliveryAttempt int NOT NULL", + "DeliveryComplete bit NOT NULL", + "DeliveryAborted bit NOT NULL", + $"CONSTRAINT [PK_{Settings.DatabaseTableName}] PRIMARY KEY CLUSTERED ([SequenceId] ASC)", + $"CONSTRAINT [UX_{Settings.DatabaseTableName}_Id] UNIQUE NONCLUSTERED ([Id] ASC)" + ], token); + + await CreateTable($"{Settings.DatabaseTableName}Subscriptions", [ + "Path nvarchar(256) NOT NULL", + "SubscriptionName nvarchar(256) NOT NULL", + "CreatedOn datetime2(7) NOT NULL", + "LastSeenOn datetime2(7) NOT NULL", + $"CONSTRAINT [PK_{Settings.DatabaseTableName}Subscriptions] PRIMARY KEY CLUSTERED ([Path] ASC, [SubscriptionName] ASC)" + ], token); + + await TryApplyMigration("20260630000000_SMB_SQL_Transport_Init", $""" + CREATE INDEX [IX_{Settings.DatabaseTableName}_Ready] + ON {Repository.GetQualifiedName(Settings.DatabaseTableName)} ([Path], [PathKind], [SubscriptionName], [VisibleOn], [CreatedOn], [SequenceId]) + WHERE [DeliveryComplete] = 0 AND [DeliveryAborted] = 0; + + CREATE INDEX [IX_{Settings.DatabaseTableName}_Lock] + ON {Repository.GetQualifiedName(Settings.DatabaseTableName)} ([LockInstanceId], [LockExpiresOn]) + WHERE [DeliveryComplete] = 0 AND [DeliveryAborted] = 0; + """, token); + } +} diff --git a/src/SlimMessageBus.Host.Sql/SqlTransportMessage.cs b/src/SlimMessageBus.Host.Sql/SqlTransportMessage.cs new file mode 100644 index 00000000..41598e5a --- /dev/null +++ b/src/SlimMessageBus.Host.Sql/SqlTransportMessage.cs @@ -0,0 +1,12 @@ +namespace SlimMessageBus.Host.Sql; + +public class SqlTransportMessage : IRelationalTransportMessage +{ + public Guid Id { get; set; } + public string MessageType { get; set; } + public byte[] MessagePayload { get; set; } + public Dictionary Headers { get; set; } + public string Path { get; set; } + public PathKind PathKind { get; set; } + public string SubscriptionName { get; set; } +} diff --git a/src/SlimMessageBus.Host.Sql/Usings.cs b/src/SlimMessageBus.Host.Sql/Usings.cs index 4beb59d1..b3b6a70a 100644 --- a/src/SlimMessageBus.Host.Sql/Usings.cs +++ b/src/SlimMessageBus.Host.Sql/Usings.cs @@ -1,6 +1,13 @@ global using Microsoft.Data.SqlClient; global using Microsoft.Extensions.DependencyInjection; -global using Microsoft.Extensions.DependencyInjection.Extensions; -global using Microsoft.Extensions.Logging; - -global using SlimMessageBus.Host.Sql.Common; +global using Microsoft.Extensions.DependencyInjection.Extensions; +global using Microsoft.Extensions.Logging; + +global using System.Data; +global using System.Text.Json; + +global using SlimMessageBus.Host.Sql.Common; +global using SlimMessageBus.Host.Relational; +global using SlimMessageBus.Host.Collections; +global using SlimMessageBus.Host.Consumer.ErrorHandling; +global using SlimMessageBus.Host.Services; diff --git a/src/SlimMessageBus.sln b/src/SlimMessageBus.sln index a25c5811..58e576c1 100644 --- a/src/SlimMessageBus.sln +++ b/src/SlimMessageBus.sln @@ -182,6 +182,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SlimMessageBus.Host.Seriali EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SlimMessageBus.Host.Memory.Benchmark", "Tests\SlimMessageBus.Host.Memory.Benchmark\SlimMessageBus.Host.Memory.Benchmark.csproj", "{3674417F-9856-4B11-8A03-4D0BDCBDC288}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SlimMessageBus.Host.Relational.Benchmark", "Tests\SlimMessageBus.Host.Relational.Benchmark\SlimMessageBus.Host.Relational.Benchmark.csproj", "{AD0030D6-A3C5-48E3-94B1-D8534542BE3D}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Interceptors", "Interceptors", "{B7CDA0D2-98AB-42B6-A41A-9D893C1B9DCB}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SlimMessageBus.Host.FluentValidation", "SlimMessageBus.Host.FluentValidation\SlimMessageBus.Host.FluentValidation.csproj", "{3B4C79C0-2469-4CA0-A03B-87575F6B9CB8}" @@ -224,8 +226,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SlimMessageBus.Host.RabbitM EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SlimMessageBus.Host.Sql", "SlimMessageBus.Host.Sql\SlimMessageBus.Host.Sql.csproj", "{5EED0E89-2475-40E0-81EF-0F05C9326612}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SlimMessageBus.Host.Sql.Test", "Tests\SlimMessageBus.Host.Sql.Test\SlimMessageBus.Host.Sql.Test.csproj", "{81C907E4-7604-4BD4-84E3-C1B9A0589B18}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SlimMessageBus.Host.Sql.Common", "SlimMessageBus.Host.Sql.Common\SlimMessageBus.Host.Sql.Common.csproj", "{F19B7A21-7749-465A-8810-4C274A9E8956}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SlimMessageBus.Host.PostgreSql", "SlimMessageBus.Host.PostgreSql\SlimMessageBus.Host.PostgreSql.csproj", "{743E0B53-8774-4B7E-9FF5-5D0DF64227F5}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SlimMessageBus.Host.PostgreSql.Test", "Tests\SlimMessageBus.Host.PostgreSql.Test\SlimMessageBus.Host.PostgreSql.Test.csproj", "{44255EA9-CE2F-4C47-A6F1-DB0E7594F81C}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SlimMessageBus.Host.Serialization.Avro.Test", "Tests\SlimMessageBus.Host.Serialization.Avro.Test\SlimMessageBus.Host.Serialization.Avro.Test.csproj", "{8507237C-68C3-46AD-B7DA-800791C6FDDB}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SlimMessageBus.Host.Serialization.Hybrid.Test", "Tests\SlimMessageBus.Host.Serialization.Hybrid.Test\SlimMessageBus.Host.Serialization.Hybrid.Test.csproj", "{DB624D5F-CB7C-4E16-B1E2-3B368FCB5A46}" @@ -629,6 +637,14 @@ Global {3674417F-9856-4B11-8A03-4D0BDCBDC288}.Release|Any CPU.Build.0 = Release|Any CPU {3674417F-9856-4B11-8A03-4D0BDCBDC288}.Release|x86.ActiveCfg = Release|Any CPU {3674417F-9856-4B11-8A03-4D0BDCBDC288}.Release|x86.Build.0 = Release|Any CPU + {AD0030D6-A3C5-48E3-94B1-D8534542BE3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AD0030D6-A3C5-48E3-94B1-D8534542BE3D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AD0030D6-A3C5-48E3-94B1-D8534542BE3D}.Debug|x86.ActiveCfg = Debug|Any CPU + {AD0030D6-A3C5-48E3-94B1-D8534542BE3D}.Debug|x86.Build.0 = Debug|Any CPU + {AD0030D6-A3C5-48E3-94B1-D8534542BE3D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AD0030D6-A3C5-48E3-94B1-D8534542BE3D}.Release|Any CPU.Build.0 = Release|Any CPU + {AD0030D6-A3C5-48E3-94B1-D8534542BE3D}.Release|x86.ActiveCfg = Release|Any CPU + {AD0030D6-A3C5-48E3-94B1-D8534542BE3D}.Release|x86.Build.0 = Release|Any CPU {3B4C79C0-2469-4CA0-A03B-87575F6B9CB8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3B4C79C0-2469-4CA0-A03B-87575F6B9CB8}.Debug|Any CPU.Build.0 = Debug|Any CPU {3B4C79C0-2469-4CA0-A03B-87575F6B9CB8}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -789,6 +805,14 @@ Global {5EED0E89-2475-40E0-81EF-0F05C9326612}.Release|Any CPU.Build.0 = Release|Any CPU {5EED0E89-2475-40E0-81EF-0F05C9326612}.Release|x86.ActiveCfg = Release|Any CPU {5EED0E89-2475-40E0-81EF-0F05C9326612}.Release|x86.Build.0 = Release|Any CPU + {81C907E4-7604-4BD4-84E3-C1B9A0589B18}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {81C907E4-7604-4BD4-84E3-C1B9A0589B18}.Debug|Any CPU.Build.0 = Debug|Any CPU + {81C907E4-7604-4BD4-84E3-C1B9A0589B18}.Debug|x86.ActiveCfg = Debug|Any CPU + {81C907E4-7604-4BD4-84E3-C1B9A0589B18}.Debug|x86.Build.0 = Debug|Any CPU + {81C907E4-7604-4BD4-84E3-C1B9A0589B18}.Release|Any CPU.ActiveCfg = Release|Any CPU + {81C907E4-7604-4BD4-84E3-C1B9A0589B18}.Release|Any CPU.Build.0 = Release|Any CPU + {81C907E4-7604-4BD4-84E3-C1B9A0589B18}.Release|x86.ActiveCfg = Release|Any CPU + {81C907E4-7604-4BD4-84E3-C1B9A0589B18}.Release|x86.Build.0 = Release|Any CPU {F19B7A21-7749-465A-8810-4C274A9E8956}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F19B7A21-7749-465A-8810-4C274A9E8956}.Debug|Any CPU.Build.0 = Debug|Any CPU {F19B7A21-7749-465A-8810-4C274A9E8956}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -797,6 +821,22 @@ Global {F19B7A21-7749-465A-8810-4C274A9E8956}.Release|Any CPU.Build.0 = Release|Any CPU {F19B7A21-7749-465A-8810-4C274A9E8956}.Release|x86.ActiveCfg = Release|Any CPU {F19B7A21-7749-465A-8810-4C274A9E8956}.Release|x86.Build.0 = Release|Any CPU + {743E0B53-8774-4B7E-9FF5-5D0DF64227F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {743E0B53-8774-4B7E-9FF5-5D0DF64227F5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {743E0B53-8774-4B7E-9FF5-5D0DF64227F5}.Debug|x86.ActiveCfg = Debug|Any CPU + {743E0B53-8774-4B7E-9FF5-5D0DF64227F5}.Debug|x86.Build.0 = Debug|Any CPU + {743E0B53-8774-4B7E-9FF5-5D0DF64227F5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {743E0B53-8774-4B7E-9FF5-5D0DF64227F5}.Release|Any CPU.Build.0 = Release|Any CPU + {743E0B53-8774-4B7E-9FF5-5D0DF64227F5}.Release|x86.ActiveCfg = Release|Any CPU + {743E0B53-8774-4B7E-9FF5-5D0DF64227F5}.Release|x86.Build.0 = Release|Any CPU + {44255EA9-CE2F-4C47-A6F1-DB0E7594F81C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {44255EA9-CE2F-4C47-A6F1-DB0E7594F81C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {44255EA9-CE2F-4C47-A6F1-DB0E7594F81C}.Debug|x86.ActiveCfg = Debug|Any CPU + {44255EA9-CE2F-4C47-A6F1-DB0E7594F81C}.Debug|x86.Build.0 = Debug|Any CPU + {44255EA9-CE2F-4C47-A6F1-DB0E7594F81C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {44255EA9-CE2F-4C47-A6F1-DB0E7594F81C}.Release|Any CPU.Build.0 = Release|Any CPU + {44255EA9-CE2F-4C47-A6F1-DB0E7594F81C}.Release|x86.ActiveCfg = Release|Any CPU + {44255EA9-CE2F-4C47-A6F1-DB0E7594F81C}.Release|x86.Build.0 = Release|Any CPU {8507237C-68C3-46AD-B7DA-800791C6FDDB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8507237C-68C3-46AD-B7DA-800791C6FDDB}.Debug|Any CPU.Build.0 = Debug|Any CPU {8507237C-68C3-46AD-B7DA-800791C6FDDB}.Debug|x86.ActiveCfg = Debug|Any CPU @@ -1025,6 +1065,7 @@ Global {2C04A6C4-34E7-441D-BE6F-27797EDF305A} = {9F005B5C-A856-4351-8C0C-47A8B785C637} {3200E118-DF24-4A20-B3FB-7AE49A49499B} = {D7DCD96A-C3B8-4D22-8216-0E8ADD30C03F} {3674417F-9856-4B11-8A03-4D0BDCBDC288} = {2C04A6C4-34E7-441D-BE6F-27797EDF305A} + {AD0030D6-A3C5-48E3-94B1-D8534542BE3D} = {2C04A6C4-34E7-441D-BE6F-27797EDF305A} {B7CDA0D2-98AB-42B6-A41A-9D893C1B9DCB} = {75BDDBB5-8DB8-4893-BD89-8FFC6C42244D} {3B4C79C0-2469-4CA0-A03B-87575F6B9CB8} = {B7CDA0D2-98AB-42B6-A41A-9D893C1B9DCB} {7A245522-423D-4D26-A58C-6F3CBE0C3DA5} = {A5B15524-93B8-4CCE-AC6D-A22984498BA0} @@ -1046,7 +1087,10 @@ Global {65BD0C01-EFDC-4775-B0A8-CF13073D9650} = {9291D340-B4FA-44A3-8060-C14743FB1712} {F5373E1D-A2B4-46CC-9B07-94F6655C8E29} = {9F005B5C-A856-4351-8C0C-47A8B785C637} {5EED0E89-2475-40E0-81EF-0F05C9326612} = {9291D340-B4FA-44A3-8060-C14743FB1712} + {81C907E4-7604-4BD4-84E3-C1B9A0589B18} = {9F005B5C-A856-4351-8C0C-47A8B785C637} {F19B7A21-7749-465A-8810-4C274A9E8956} = {9291D340-B4FA-44A3-8060-C14743FB1712} + {743E0B53-8774-4B7E-9FF5-5D0DF64227F5} = {9291D340-B4FA-44A3-8060-C14743FB1712} + {44255EA9-CE2F-4C47-A6F1-DB0E7594F81C} = {9F005B5C-A856-4351-8C0C-47A8B785C637} {8507237C-68C3-46AD-B7DA-800791C6FDDB} = {9F005B5C-A856-4351-8C0C-47A8B785C637} {DB624D5F-CB7C-4E16-B1E2-3B368FCB5A46} = {9F005B5C-A856-4351-8C0C-47A8B785C637} {AD05234E-A925-44C0-977E-FEAC2A75B98C} = {9F005B5C-A856-4351-8C0C-47A8B785C637} diff --git a/src/Tests/SlimMessageBus.Host.PostgreSql.Test/PostgreSqlCollection.cs b/src/Tests/SlimMessageBus.Host.PostgreSql.Test/PostgreSqlCollection.cs new file mode 100644 index 00000000..4c8bb0e0 --- /dev/null +++ b/src/Tests/SlimMessageBus.Host.PostgreSql.Test/PostgreSqlCollection.cs @@ -0,0 +1,4 @@ +namespace SlimMessageBus.Host.PostgreSql.Test; + +[CollectionDefinition(nameof(PostgreSqlCollection))] +public class PostgreSqlCollection : ICollectionFixture; diff --git a/src/Tests/SlimMessageBus.Host.PostgreSql.Test/PostgreSqlFixture.cs b/src/Tests/SlimMessageBus.Host.PostgreSql.Test/PostgreSqlFixture.cs new file mode 100644 index 00000000..abdf56b3 --- /dev/null +++ b/src/Tests/SlimMessageBus.Host.PostgreSql.Test/PostgreSqlFixture.cs @@ -0,0 +1,43 @@ +namespace SlimMessageBus.Host.PostgreSql.Test; + +using System.Diagnostics; + +public class PostgreSqlFixture : IAsyncLifetime +{ + private readonly PostgreSqlContainer _postgreSqlContainer; + + public PostgreSqlFixture() + { + var builder = new PostgreSqlBuilder("postgres:17.4") + .WithReuse(true); + + if (Debugger.IsAttached) + { + builder = builder.WithPortBinding(50001, 5432); + } + + _postgreSqlContainer = builder.Build(); + } + + public Task InitializeAsync() => _postgreSqlContainer.StartAsync(); + + public async Task DisposeAsync() + { + await _postgreSqlContainer.DisposeAsync(); + } + + public string GetConnectionString() => _postgreSqlContainer.GetConnectionString(); + + public async Task CreateSchema(string schema, CancellationToken cancellationToken = default) + { + await using var conn = new NpgsqlConnection(GetConnectionString()); + await conn.OpenAsync(cancellationToken); + + var cmd = conn.CreateCommand(); + cmd.CommandText = $""" + DROP SCHEMA IF EXISTS {schema} CASCADE; + CREATE SCHEMA {schema}; + """; + await cmd.ExecuteNonQueryAsync(cancellationToken); + } +} diff --git a/src/Tests/SlimMessageBus.Host.PostgreSql.Test/PostgreSqlHelperTests.cs b/src/Tests/SlimMessageBus.Host.PostgreSql.Test/PostgreSqlHelperTests.cs new file mode 100644 index 00000000..dcbcdb4f --- /dev/null +++ b/src/Tests/SlimMessageBus.Host.PostgreSql.Test/PostgreSqlHelperTests.cs @@ -0,0 +1,29 @@ +namespace SlimMessageBus.Host.PostgreSql.Test; + +public class PostgreSqlHelperTests +{ + [Theory] + [InlineData("messages", "\"messages\"")] + [InlineData("_messages_1", "\"_messages_1\"")] + [InlineData("Messages", "\"Messages\"")] + public void QuoteIdentifier_GivenSafeIdentifier_ThenQuotes(string identifier, string expected) + { + var actual = PostgreSqlHelper.QuoteIdentifier(identifier, nameof(identifier)); + + actual.Should().Be(expected); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("1messages")] + [InlineData("messages;drop")] + [InlineData("messages-name")] + [InlineData("messages.name")] + public void QuoteIdentifier_GivenUnsafeIdentifier_ThenThrows(string identifier) + { + var action = () => PostgreSqlHelper.QuoteIdentifier(identifier, nameof(identifier)); + + action.Should().Throw().WithParameterName(nameof(identifier)); + } +} diff --git a/src/Tests/SlimMessageBus.Host.PostgreSql.Test/PostgreSqlMessageBusIt.cs b/src/Tests/SlimMessageBus.Host.PostgreSql.Test/PostgreSqlMessageBusIt.cs new file mode 100644 index 00000000..b2ed7cbc --- /dev/null +++ b/src/Tests/SlimMessageBus.Host.PostgreSql.Test/PostgreSqlMessageBusIt.cs @@ -0,0 +1,94 @@ +namespace SlimMessageBus.Host.PostgreSql.Test; + +[Trait("Category", "Integration")] +[Trait("Transport", "PostgreSql")] +[Collection(nameof(PostgreSqlCollection))] +public class PostgreSqlMessageBusIt(ITestOutputHelper output, PostgreSqlFixture postgreSqlFixture) : BaseIntegrationTest(output) +{ + private readonly string _schemaName = $"smb_{Guid.NewGuid():N}"; + + protected override void SetupServices(ServiceCollection services, IConfigurationRoot configuration) + { + services.AddSlimMessageBus(mbb => + { + mbb.WithProviderPostgreSql(cfg => + { + cfg.ConnectionString = postgreSqlFixture.GetConnectionString(); + cfg.DatabaseSchemaName = _schemaName; + cfg.DatabaseTableName = "messages"; + cfg.PollDelay = TimeSpan.FromMilliseconds(100); + cfg.PollBatchSize = 5; + }); + + ApplyBusConfiguration(mbb); + + mbb.AddServicesFromAssemblyContaining(); + mbb.AddJsonSerializer(); + }); + + services.AddSingleton>(); + } + + public override async Task InitializeAsync() + { + await postgreSqlFixture.CreateSchema(_schemaName); + } + + [Fact] + public async Task BasicQueue() + { + var queue = $"queue-{Guid.NewGuid():N}"; + + AddBusConfiguration(mbb => + { + mbb.Produce(x => x.DefaultQueue(queue)); + mbb.Consume(x => x.Queue(queue)); + }); + + var messageBus = ServiceProvider.GetRequiredService(); + var consumedMessages = ServiceProvider.GetRequiredService>(); + + await messageBus.Publish(new PingMessage(1, "one")); + await consumedMessages.WaitUntilArriving(expectedCount: 1); + + consumedMessages.Snapshot().Should().ContainSingle(x => x.Id == 1 && x.Value == "one"); + } + + [Fact] + public async Task BasicTopicWithDurableSubscriptions() + { + var topic = $"topic-{Guid.NewGuid():N}"; + + AddBusConfiguration(mbb => + { + mbb.Produce(x => x.DefaultTopic(topic).ToTopic()); + mbb.Consume(x => x.Topic(topic, "subscriber-a")); + mbb.Consume(x => x.Topic(topic, "subscriber-b")); + }); + + var messageBus = ServiceProvider.GetRequiredService(); + var consumedMessages = ServiceProvider.GetRequiredService>(); + + await EnsureConsumersStarted(); + await messageBus.Publish(new PingMessage(2, "two")); + await consumedMessages.WaitUntilArriving(expectedCount: 2); + + consumedMessages.Snapshot().Should().HaveCount(2); + consumedMessages.Snapshot().Should().AllSatisfy(x => + { + x.Id.Should().Be(2); + x.Value.Should().Be("two"); + }); + } + + public record PingMessage(int Id, string Value); + + public class PingConsumer(TestEventCollector messages) : IConsumer + { + public Task OnHandle(PingMessage message, CancellationToken cancellationToken) + { + messages.Add(message); + return Task.CompletedTask; + } + } +} diff --git a/src/Tests/SlimMessageBus.Host.PostgreSql.Test/PostgreSqlMessageBusSettingsValidationServiceTests.cs b/src/Tests/SlimMessageBus.Host.PostgreSql.Test/PostgreSqlMessageBusSettingsValidationServiceTests.cs new file mode 100644 index 00000000..d1642ab4 --- /dev/null +++ b/src/Tests/SlimMessageBus.Host.PostgreSql.Test/PostgreSqlMessageBusSettingsValidationServiceTests.cs @@ -0,0 +1,65 @@ +namespace SlimMessageBus.Host.PostgreSql.Test; + +public class PostgreSqlMessageBusSettingsValidationServiceTests +{ + private readonly PostgreSqlMessageBusSettings _providerSettings = new() + { + ConnectionString = "Host=localhost;Database=smb;Username=postgres;Password=postgres" + }; + + private readonly PostgreSqlMessageBusSettingsValidationService _subject; + + public PostgreSqlMessageBusSettingsValidationServiceTests() + { + var settings = new MessageBusSettings + { + Name = "TestBus", + ServiceProvider = new ServiceCollection().BuildServiceProvider() + }; + _subject = new PostgreSqlMessageBusSettingsValidationService(settings, _providerSettings); + } + + [Fact] + public void AssertSettings_GivenValidSettings_ThenDoesNotThrow() + { + var act = _subject.AssertSettings; + + act.Should().NotThrow(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void AssertSettings_GivenConnectionStringIsMissing_ThenThrows(string connectionString) + { + _providerSettings.ConnectionString = connectionString; + + var act = _subject.AssertSettings; + + act.Should().Throw() + .WithMessage("*connection string*"); + } + + [Fact] + public void AssertSettings_GivenPollBatchSizeIsInvalid_ThenThrows() + { + _providerSettings.PollBatchSize = 0; + + var act = _subject.AssertSettings; + + act.Should().Throw() + .WithMessage("*poll batch size*"); + } + + [Fact] + public void AssertSettings_GivenMaxDeliveryAttemptsIsInvalid_ThenThrows() + { + _providerSettings.MaxDeliveryAttempts = 0; + + var act = _subject.AssertSettings; + + act.Should().Throw() + .WithMessage("*max delivery attempts*"); + } +} diff --git a/src/Tests/SlimMessageBus.Host.PostgreSql.Test/SlimMessageBus.Host.PostgreSql.Test.csproj b/src/Tests/SlimMessageBus.Host.PostgreSql.Test/SlimMessageBus.Host.PostgreSql.Test.csproj new file mode 100644 index 00000000..280b90e1 --- /dev/null +++ b/src/Tests/SlimMessageBus.Host.PostgreSql.Test/SlimMessageBus.Host.PostgreSql.Test.csproj @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + Always + + + + + + + + diff --git a/src/Tests/SlimMessageBus.Host.PostgreSql.Test/Usings.cs b/src/Tests/SlimMessageBus.Host.PostgreSql.Test/Usings.cs new file mode 100644 index 00000000..9200e02c --- /dev/null +++ b/src/Tests/SlimMessageBus.Host.PostgreSql.Test/Usings.cs @@ -0,0 +1,14 @@ +global using AwesomeAssertions; +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Logging; +global using Npgsql; +global using SecretStore; +global using SlimMessageBus; +global using SlimMessageBus.Host; +global using SlimMessageBus.Host.PostgreSql; +global using SlimMessageBus.Host.Serialization.Json; +global using SlimMessageBus.Host.Test.Common.IntegrationTest; +global using Testcontainers.PostgreSql; +global using Xunit; +global using Xunit.Abstractions; diff --git a/src/Tests/SlimMessageBus.Host.PostgreSql.Test/appsettings.json b/src/Tests/SlimMessageBus.Host.PostgreSql.Test/appsettings.json new file mode 100644 index 00000000..9f546052 --- /dev/null +++ b/src/Tests/SlimMessageBus.Host.PostgreSql.Test/appsettings.json @@ -0,0 +1,11 @@ +{ + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "SlimMessageBus": "Debug" + } + } + } +} diff --git a/src/Tests/SlimMessageBus.Host.Relational.Benchmark/AbstractRelationalTransportBenchmark.cs b/src/Tests/SlimMessageBus.Host.Relational.Benchmark/AbstractRelationalTransportBenchmark.cs new file mode 100644 index 00000000..e7e1e8bb --- /dev/null +++ b/src/Tests/SlimMessageBus.Host.Relational.Benchmark/AbstractRelationalTransportBenchmark.cs @@ -0,0 +1,126 @@ +namespace SlimMessageBus.Host.Relational.Benchmark; + +using BenchmarkDotNet.Attributes; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +using SlimMessageBus.Host; + +public abstract class AbstractRelationalTransportBenchmark +{ + private ServiceProvider _serviceProvider; + + protected string QueueName { get; private set; } + + protected string SchemaName { get; private set; } + + protected IServiceProvider ServiceProvider => _serviceProvider; + + protected IMessageBus Bus => _serviceProvider.GetRequiredService(); + + [Params(100, 1000)] + public int MessageCount { get; set; } + + [GlobalSetup] + public void GlobalSetup() + => StartInfrastructure().GetAwaiter().GetResult(); + + [IterationSetup] + public void IterationSetup() + => SetupIteration().GetAwaiter().GetResult(); + + [IterationCleanup] + public void IterationCleanup() + => CleanupIteration().GetAwaiter().GetResult(); + + [GlobalCleanup] + public void GlobalCleanup() + => StopInfrastructure().GetAwaiter().GetResult(); + + [Benchmark] + public async Task QueuePublishConsume() + { + var publishTasks = Enumerable + .Range(0, MessageCount) + .Select(x => Bus.Publish(new BenchmarkMessage(x, DateTimeOffset.UtcNow))); + + await Task.WhenAll(publishTasks).ConfigureAwait(false); + await WaitForMessages().ConfigureAwait(false); + } + + protected abstract Task StartInfrastructure(); + + protected abstract Task StopInfrastructure(); + + protected abstract Task CreateSchema(string schemaName); + + protected abstract Task DropSchema(string schemaName); + + protected abstract void ConfigureProvider(MessageBusBuilder mbb, string schemaName); + + private async Task SetupIteration() + { + QueueName = $"bench_queue_{Guid.NewGuid():N}"; + SchemaName = $"smb_{Guid.NewGuid():N}"; + + await CreateSchema(SchemaName).ConfigureAwait(false); + + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton(); + services.AddTransient(); + + services.AddSlimMessageBus(mbb => + { + ConfigureProvider(mbb, SchemaName); + + mbb.Produce(x => + { + x.DefaultPath(QueueName); + x.Settings.PathKind = PathKind.Queue; + }); + + mbb.Consume(x => + { + x.Path(QueueName); + x.ConsumerSettings.PathKind = PathKind.Queue; + x.WithConsumer(); + }); + + mbb.AddJsonSerializer(); + }); + + _serviceProvider = services.BuildServiceProvider(); + await _serviceProvider.GetRequiredService().Start().ConfigureAwait(false); + } + + private async Task CleanupIteration() + { + if (_serviceProvider is not null) + { + await _serviceProvider.GetRequiredService().Stop().ConfigureAwait(false); + await _serviceProvider.DisposeAsync().ConfigureAwait(false); + _serviceProvider = null; + } + + if (SchemaName is not null) + { + await DropSchema(SchemaName).ConfigureAwait(false); + SchemaName = null; + } + } + + private async Task WaitForMessages() + { + var testResult = _serviceProvider.GetRequiredService(); + using var cancellationTokenSource = new CancellationTokenSource(TimeSpan.FromMinutes(1)); + + while (testResult.ArrivedCount < MessageCount) + { + cancellationTokenSource.Token.ThrowIfCancellationRequested(); + await Task.Delay(1, cancellationTokenSource.Token).ConfigureAwait(false); + } + } +} diff --git a/src/Tests/SlimMessageBus.Host.Relational.Benchmark/BenchmarkMessage.cs b/src/Tests/SlimMessageBus.Host.Relational.Benchmark/BenchmarkMessage.cs new file mode 100644 index 00000000..424671bf --- /dev/null +++ b/src/Tests/SlimMessageBus.Host.Relational.Benchmark/BenchmarkMessage.cs @@ -0,0 +1,3 @@ +namespace SlimMessageBus.Host.Relational.Benchmark; + +public record BenchmarkMessage(long Id, DateTimeOffset Created); diff --git a/src/Tests/SlimMessageBus.Host.Relational.Benchmark/BenchmarkMessageConsumer.cs b/src/Tests/SlimMessageBus.Host.Relational.Benchmark/BenchmarkMessageConsumer.cs new file mode 100644 index 00000000..610bce13 --- /dev/null +++ b/src/Tests/SlimMessageBus.Host.Relational.Benchmark/BenchmarkMessageConsumer.cs @@ -0,0 +1,10 @@ +namespace SlimMessageBus.Host.Relational.Benchmark; + +public record BenchmarkMessageConsumer(TestResult TestResult) : IConsumer +{ + public Task OnHandle(BenchmarkMessage message, CancellationToken cancellationToken) + { + TestResult.OnArrived(); + return Task.CompletedTask; + } +} diff --git a/src/Tests/SlimMessageBus.Host.Relational.Benchmark/PostgreSqlQueueTransportBenchmark.cs b/src/Tests/SlimMessageBus.Host.Relational.Benchmark/PostgreSqlQueueTransportBenchmark.cs new file mode 100644 index 00000000..8c8345b5 --- /dev/null +++ b/src/Tests/SlimMessageBus.Host.Relational.Benchmark/PostgreSqlQueueTransportBenchmark.cs @@ -0,0 +1,61 @@ +namespace SlimMessageBus.Host.Relational.Benchmark; + +using BenchmarkDotNet.Attributes; + +using Npgsql; + +using SlimMessageBus.Host.PostgreSql; + +using Testcontainers.PostgreSql; + +[MemoryDiagnoser] +public class PostgreSqlQueueTransportBenchmark : AbstractRelationalTransportBenchmark +{ + private PostgreSqlContainer _container; + + protected override async Task StartInfrastructure() + { + _container = new PostgreSqlBuilder("postgres:17.4") + .WithReuse(true) + .Build(); + + await _container.StartAsync().ConfigureAwait(false); + } + + protected override async Task StopInfrastructure() + { + if (_container is not null) + { + await _container.DisposeAsync().ConfigureAwait(false); + _container = null; + } + } + + protected override async Task CreateSchema(string schemaName) + { + await using var connection = new NpgsqlConnection(_container.GetConnectionString()); + await connection.OpenAsync().ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = $"""CREATE SCHEMA "{schemaName}";"""; + await command.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + protected override async Task DropSchema(string schemaName) + { + await using var connection = new NpgsqlConnection(_container.GetConnectionString()); + await connection.OpenAsync().ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = $"""DROP SCHEMA IF EXISTS "{schemaName}" CASCADE;"""; + await command.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + protected override void ConfigureProvider(MessageBusBuilder mbb, string schemaName) + => mbb.WithProviderPostgreSql(settings => + { + settings.ConnectionString = _container.GetConnectionString(); + settings.DatabaseSchemaName = schemaName; + settings.DatabaseTableName = "messages"; + settings.PollBatchSize = 32; + settings.PollDelay = TimeSpan.FromMilliseconds(5); + }); +} diff --git a/src/Tests/SlimMessageBus.Host.Relational.Benchmark/Program.cs b/src/Tests/SlimMessageBus.Host.Relational.Benchmark/Program.cs new file mode 100644 index 00000000..aee7b47d --- /dev/null +++ b/src/Tests/SlimMessageBus.Host.Relational.Benchmark/Program.cs @@ -0,0 +1,9 @@ +namespace SlimMessageBus.Host.Relational.Benchmark; + +using BenchmarkDotNet.Running; + +public class Program +{ + public static void Main(string[] args) + => BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args); +} diff --git a/src/Tests/SlimMessageBus.Host.Relational.Benchmark/SlimMessageBus.Host.Relational.Benchmark.csproj b/src/Tests/SlimMessageBus.Host.Relational.Benchmark/SlimMessageBus.Host.Relational.Benchmark.csproj new file mode 100644 index 00000000..59bc4f8a --- /dev/null +++ b/src/Tests/SlimMessageBus.Host.Relational.Benchmark/SlimMessageBus.Host.Relational.Benchmark.csproj @@ -0,0 +1,27 @@ + + + + + + Exe + SlimMessageBus.Host.Relational.Benchmark.Program + + + + + + + + + + + + + + + + + + + + diff --git a/src/Tests/SlimMessageBus.Host.Relational.Benchmark/SqlQueueTransportBenchmark.cs b/src/Tests/SlimMessageBus.Host.Relational.Benchmark/SqlQueueTransportBenchmark.cs new file mode 100644 index 00000000..050a5daf --- /dev/null +++ b/src/Tests/SlimMessageBus.Host.Relational.Benchmark/SqlQueueTransportBenchmark.cs @@ -0,0 +1,71 @@ +namespace SlimMessageBus.Host.Relational.Benchmark; + +using BenchmarkDotNet.Attributes; + +using Microsoft.Data.SqlClient; + +using SlimMessageBus.Host.Sql; + +using Testcontainers.MsSql; + +[MemoryDiagnoser] +public class SqlQueueTransportBenchmark : AbstractRelationalTransportBenchmark +{ + private MsSqlContainer _container; + + protected override async Task StartInfrastructure() + { + _container = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2022-CU13-ubuntu-22.04") + .WithReuse(true) + .Build(); + + await _container.StartAsync().ConfigureAwait(false); + } + + protected override async Task StopInfrastructure() + { + if (_container is not null) + { + await _container.DisposeAsync().ConfigureAwait(false); + _container = null; + } + } + + protected override async Task CreateSchema(string schemaName) + { + await using var connection = new SqlConnection(_container.GetConnectionString()); + await connection.OpenAsync().ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = $"CREATE SCHEMA [{schemaName}]"; + await command.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + protected override async Task DropSchema(string schemaName) + { + await using var connection = new SqlConnection(_container.GetConnectionString()); + await connection.OpenAsync().ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = $""" + DECLARE @schemaName sysname = N'{schemaName}'; + DECLARE @sql nvarchar(max) = N''; + + SELECT @sql += N'DROP TABLE ' + QUOTENAME(SCHEMA_NAME(schema_id)) + N'.' + QUOTENAME(name) + N';' + FROM sys.tables + WHERE schema_id = SCHEMA_ID(@schemaName); + + EXEC sp_executesql @sql; + EXEC(N'DROP SCHEMA ' + QUOTENAME(@schemaName)); + """; + await command.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + protected override void ConfigureProvider(MessageBusBuilder mbb, string schemaName) + => mbb.WithProviderSql(settings => + { + settings.ConnectionString = _container.GetConnectionString(); + settings.DatabaseSchemaName = schemaName; + settings.DatabaseTableName = "Messages"; + settings.PollBatchSize = 32; + settings.PollDelay = TimeSpan.FromMilliseconds(5); + }); +} diff --git a/src/Tests/SlimMessageBus.Host.Relational.Benchmark/TestResult.cs b/src/Tests/SlimMessageBus.Host.Relational.Benchmark/TestResult.cs new file mode 100644 index 00000000..d9174abd --- /dev/null +++ b/src/Tests/SlimMessageBus.Host.Relational.Benchmark/TestResult.cs @@ -0,0 +1,10 @@ +namespace SlimMessageBus.Host.Relational.Benchmark; + +public class TestResult +{ + private long _arrivedCount; + + public long ArrivedCount => Interlocked.Read(ref _arrivedCount); + + public void OnArrived() => Interlocked.Increment(ref _arrivedCount); +} diff --git a/src/Tests/SlimMessageBus.Host.Relational.Benchmark/Usings.cs b/src/Tests/SlimMessageBus.Host.Relational.Benchmark/Usings.cs new file mode 100644 index 00000000..9aefcb1e --- /dev/null +++ b/src/Tests/SlimMessageBus.Host.Relational.Benchmark/Usings.cs @@ -0,0 +1,7 @@ +global using System; +global using System.Linq; +global using System.Threading; +global using System.Threading.Tasks; + +global using SlimMessageBus; +global using SlimMessageBus.Host.Serialization.Json; diff --git a/src/Tests/SlimMessageBus.Host.Sql.Test/SlimMessageBus.Host.Sql.Test.csproj b/src/Tests/SlimMessageBus.Host.Sql.Test/SlimMessageBus.Host.Sql.Test.csproj new file mode 100644 index 00000000..a1665c92 --- /dev/null +++ b/src/Tests/SlimMessageBus.Host.Sql.Test/SlimMessageBus.Host.Sql.Test.csproj @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + Always + + + + + + + + diff --git a/src/Tests/SlimMessageBus.Host.Sql.Test/SqlMessageBusIt.cs b/src/Tests/SlimMessageBus.Host.Sql.Test/SqlMessageBusIt.cs new file mode 100644 index 00000000..7dba6fb7 --- /dev/null +++ b/src/Tests/SlimMessageBus.Host.Sql.Test/SqlMessageBusIt.cs @@ -0,0 +1,94 @@ +namespace SlimMessageBus.Host.Sql.Test; + +[Trait("Category", "Integration")] +[Trait("Transport", "Sql")] +[Collection(nameof(SqlServerCollection))] +public class SqlMessageBusIt(ITestOutputHelper output, SqlServerFixture sqlServerFixture) : BaseIntegrationTest(output) +{ + private readonly string _schemaName = $"smb_{Guid.NewGuid():N}"; + + protected override void SetupServices(ServiceCollection services, IConfigurationRoot configuration) + { + services.AddSlimMessageBus(mbb => + { + mbb.WithProviderSql(cfg => + { + cfg.ConnectionString = sqlServerFixture.GetConnectionString(); + cfg.DatabaseSchemaName = _schemaName; + cfg.DatabaseTableName = "Messages"; + cfg.PollDelay = TimeSpan.FromMilliseconds(100); + cfg.PollBatchSize = 5; + }); + + ApplyBusConfiguration(mbb); + + mbb.AddServicesFromAssemblyContaining(); + mbb.AddJsonSerializer(); + }); + + services.AddSingleton>(); + } + + public override async Task InitializeAsync() + { + await sqlServerFixture.CreateSchema(_schemaName); + } + + [Fact] + public async Task BasicQueue() + { + var queue = $"queue-{Guid.NewGuid():N}"; + + AddBusConfiguration(mbb => + { + mbb.Produce(x => x.DefaultQueue(queue)); + mbb.Consume(x => x.Queue(queue)); + }); + + var messageBus = ServiceProvider.GetRequiredService(); + var consumedMessages = ServiceProvider.GetRequiredService>(); + + await messageBus.Publish(new PingMessage(1, "one")); + await consumedMessages.WaitUntilArriving(expectedCount: 1); + + consumedMessages.Snapshot().Should().ContainSingle(x => x.Id == 1 && x.Value == "one"); + } + + [Fact] + public async Task BasicTopicWithDurableSubscriptions() + { + var topic = $"topic-{Guid.NewGuid():N}"; + + AddBusConfiguration(mbb => + { + mbb.Produce(x => x.DefaultTopic(topic).ToTopic()); + mbb.Consume(x => x.Topic(topic, "subscriber-a")); + mbb.Consume(x => x.Topic(topic, "subscriber-b")); + }); + + var messageBus = ServiceProvider.GetRequiredService(); + var consumedMessages = ServiceProvider.GetRequiredService>(); + + await EnsureConsumersStarted(); + await messageBus.Publish(new PingMessage(2, "two")); + await consumedMessages.WaitUntilArriving(expectedCount: 2); + + consumedMessages.Snapshot().Should().HaveCount(2); + consumedMessages.Snapshot().Should().AllSatisfy(x => + { + x.Id.Should().Be(2); + x.Value.Should().Be("two"); + }); + } + + public record PingMessage(int Id, string Value); + + public class PingConsumer(TestEventCollector messages) : IConsumer + { + public Task OnHandle(PingMessage message, CancellationToken cancellationToken) + { + messages.Add(message); + return Task.CompletedTask; + } + } +} diff --git a/src/Tests/SlimMessageBus.Host.Sql.Test/SqlMessageBusSettingsValidationServiceTests.cs b/src/Tests/SlimMessageBus.Host.Sql.Test/SqlMessageBusSettingsValidationServiceTests.cs new file mode 100644 index 00000000..b1fec811 --- /dev/null +++ b/src/Tests/SlimMessageBus.Host.Sql.Test/SqlMessageBusSettingsValidationServiceTests.cs @@ -0,0 +1,65 @@ +namespace SlimMessageBus.Host.Sql.Test; + +public class SqlMessageBusSettingsValidationServiceTests +{ + private readonly SqlMessageBusSettings _providerSettings = new() + { + ConnectionString = "Server=localhost;Database=smb;User Id=sa;Password=Password1!;TrustServerCertificate=True" + }; + + private readonly SqlMessageBusSettingsValidationService _subject; + + public SqlMessageBusSettingsValidationServiceTests() + { + var settings = new MessageBusSettings + { + Name = "TestBus", + ServiceProvider = new ServiceCollection().BuildServiceProvider() + }; + _subject = new SqlMessageBusSettingsValidationService(settings, _providerSettings); + } + + [Fact] + public void AssertSettings_GivenValidSettings_ThenDoesNotThrow() + { + var act = _subject.AssertSettings; + + act.Should().NotThrow(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void AssertSettings_GivenConnectionStringIsMissing_ThenThrows(string connectionString) + { + _providerSettings.ConnectionString = connectionString; + + var act = _subject.AssertSettings; + + act.Should().Throw() + .WithMessage("*connection string*"); + } + + [Fact] + public void AssertSettings_GivenPollBatchSizeIsInvalid_ThenThrows() + { + _providerSettings.PollBatchSize = 0; + + var act = _subject.AssertSettings; + + act.Should().Throw() + .WithMessage("*poll batch size*"); + } + + [Fact] + public void AssertSettings_GivenMaxDeliveryAttemptsIsInvalid_ThenThrows() + { + _providerSettings.MaxDeliveryAttempts = 0; + + var act = _subject.AssertSettings; + + act.Should().Throw() + .WithMessage("*max delivery attempts*"); + } +} diff --git a/src/Tests/SlimMessageBus.Host.Sql.Test/SqlServerCollection.cs b/src/Tests/SlimMessageBus.Host.Sql.Test/SqlServerCollection.cs new file mode 100644 index 00000000..5ede28ed --- /dev/null +++ b/src/Tests/SlimMessageBus.Host.Sql.Test/SqlServerCollection.cs @@ -0,0 +1,4 @@ +namespace SlimMessageBus.Host.Sql.Test; + +[CollectionDefinition(nameof(SqlServerCollection))] +public class SqlServerCollection : ICollectionFixture; diff --git a/src/Tests/SlimMessageBus.Host.Sql.Test/SqlServerFixture.cs b/src/Tests/SlimMessageBus.Host.Sql.Test/SqlServerFixture.cs new file mode 100644 index 00000000..48906c1a --- /dev/null +++ b/src/Tests/SlimMessageBus.Host.Sql.Test/SqlServerFixture.cs @@ -0,0 +1,47 @@ +namespace SlimMessageBus.Host.Sql.Test; + +using System.Diagnostics; + +public class SqlServerFixture : IAsyncLifetime +{ + private readonly MsSqlContainer _sqlContainer; + + public SqlServerFixture() + { + var builder = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2022-CU13-ubuntu-22.04") + .WithReuse(true); + + if (Debugger.IsAttached) + { + builder = builder.WithPortBinding(50000, 1433); + } + + _sqlContainer = builder.Build(); + } + + public Task InitializeAsync() => _sqlContainer.StartAsync(); + + public async Task DisposeAsync() + { + await _sqlContainer.DisposeAsync(); + } + + public string GetConnectionString() => _sqlContainer.GetConnectionString(); + + public async Task CreateSchema(string schema, CancellationToken cancellationToken = default) + { + await using var conn = new SqlConnection(GetConnectionString()); + await conn.OpenAsync(cancellationToken); + + var cmd = conn.CreateCommand(); + cmd.CommandText = $""" + EXEC sp_MSforeachtable @command1 = 'DROP TABLE ?', @whereand = 'AND SCHEMA_NAME(schema_id) = "{schema}";' + DROP SCHEMA IF EXISTS "{schema}"; + """; + await cmd.ExecuteNonQueryAsync(cancellationToken); + + cmd = conn.CreateCommand(); + cmd.CommandText = $"CREATE SCHEMA \"{schema}\";"; + await cmd.ExecuteNonQueryAsync(cancellationToken); + } +} diff --git a/src/Tests/SlimMessageBus.Host.Sql.Test/Usings.cs b/src/Tests/SlimMessageBus.Host.Sql.Test/Usings.cs new file mode 100644 index 00000000..e4b41c44 --- /dev/null +++ b/src/Tests/SlimMessageBus.Host.Sql.Test/Usings.cs @@ -0,0 +1,14 @@ +global using AwesomeAssertions; +global using Microsoft.Data.SqlClient; +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Logging; +global using SecretStore; +global using SlimMessageBus; +global using SlimMessageBus.Host; +global using SlimMessageBus.Host.Sql; +global using SlimMessageBus.Host.Serialization.Json; +global using SlimMessageBus.Host.Test.Common.IntegrationTest; +global using Testcontainers.MsSql; +global using Xunit; +global using Xunit.Abstractions; diff --git a/src/Tests/SlimMessageBus.Host.Sql.Test/appsettings.json b/src/Tests/SlimMessageBus.Host.Sql.Test/appsettings.json new file mode 100644 index 00000000..9f546052 --- /dev/null +++ b/src/Tests/SlimMessageBus.Host.Sql.Test/appsettings.json @@ -0,0 +1,11 @@ +{ + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "SlimMessageBus": "Debug" + } + } + } +}