Skip to content

Commit 220ef24

Browse files
committed
Add SQL and PostgreSQL transports
Signed-off-by: Tomasz Maruszak <maruszaktomasz@gmail.com>
1 parent d4330c4 commit 220ef24

71 files changed

Lines changed: 2936 additions & 130 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/build.yml

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -160,15 +160,28 @@ jobs:
160160
docker_services: "mqtt"
161161

162162
# NATS connects to localhost:4222 via the nats_endpoint env var
163-
- transport: NATS
164-
name: NATS
165-
filter: "Category=Integration&Transport=NATS"
166-
docker_services: "nats"
167-
168-
# Outbox – four parallel sub-legs:
169-
# 1a. Sql.DbContext OutboxTests – AzureSB + Kafka; SQL Server via local docker
170-
- transport: Outbox.SqlDbContext.Tests
171-
name: "Outbox (SqlDbContext Tests)"
163+
- transport: NATS
164+
name: NATS
165+
filter: "Category=Integration&Transport=NATS"
166+
docker_services: "nats"
167+
168+
# SQL transports use TestContainers directly
169+
- transport: Sql
170+
name: SQL Server
171+
filter: "Category=Integration&Transport=Sql"
172+
test_path: "Tests/SlimMessageBus.Host.Sql.Test"
173+
docker_services: ""
174+
175+
- transport: PostgreSql
176+
name: PostgreSQL
177+
filter: "Category=Integration&Transport=PostgreSql"
178+
test_path: "Tests/SlimMessageBus.Host.PostgreSql.Test"
179+
docker_services: ""
180+
181+
# Outbox – four parallel sub-legs:
182+
# 1a. Sql.DbContext OutboxTests – AzureSB + Kafka; SQL Server via local docker
183+
- transport: Outbox.SqlDbContext.Tests
184+
name: "Outbox (SqlDbContext Tests)"
172185
filter: "Category=Integration&ClassName=SlimMessageBus.Host.Outbox.Sql.DbContext.Test.OutboxTests"
173186
test_path: "Tests/SlimMessageBus.Host.Outbox.Sql.DbContext.Test"
174187
docker_services: "sqldb zookeeper kafka kafka-init"

build/tasks.ps1

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ $projects = @(
3434
"SlimMessageBus.Host.RabbitMQ",
3535
"SlimMessageBus.Host.Sql",
3636
"SlimMessageBus.Host.Sql.Common",
37+
"SlimMessageBus.Host.PostgreSql",
3738
"SlimMessageBus.Host.Nats",
3839
"SlimMessageBus.Host.AmazonSQS",
3940

docs/provider_postgresql.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# PostgreSQL transport provider for SlimMessageBus <!-- omit in toc -->
2+
3+
Please read the [Introduction](intro.md) before reading this provider documentation.
4+
5+
- [About](#about)
6+
- [Configuration](#configuration)
7+
- [How it works](#how-it-works)
8+
9+
## About
10+
11+
The PostgreSQL transport provider allows a shared PostgreSQL database to act as the message broker for collaborating producers and consumers.
12+
13+
This transport is useful for applications that already operate PostgreSQL and do not need a dedicated messaging broker yet.
14+
15+
## Configuration
16+
17+
The configuration is arranged via the `.WithProviderPostgreSql(cfg => {})` method on the message bus builder.
18+
19+
```cs
20+
services.AddSlimMessageBus(mbb =>
21+
{
22+
mbb.WithProviderPostgreSql(cfg =>
23+
{
24+
cfg.ConnectionString = "...";
25+
cfg.DatabaseSchemaName = "smb";
26+
cfg.DatabaseTableName = "messages";
27+
cfg.PollDelay = TimeSpan.FromMilliseconds(250);
28+
cfg.PollBatchSize = 10;
29+
});
30+
31+
mbb.Produce<PingMessage>(x => x.DefaultQueue("ping-queue"));
32+
mbb.Consume<PingMessage>(x => x.Queue("ping-queue"));
33+
34+
mbb.Produce<OrderSubmitted>(x => x.DefaultTopic("orders").ToTopic());
35+
mbb.Consume<OrderSubmitted>(x => x.Topic("orders", "billing"));
36+
mbb.Consume<OrderSubmitted>(x => x.Topic("orders", "shipping"));
37+
38+
mbb.AddServicesFromAssemblyContaining<PingConsumer>();
39+
mbb.AddJsonSerializer();
40+
});
41+
```
42+
43+
## How it works
44+
45+
- A messages table stores exchanged messages.
46+
- A subscriptions table stores durable topic subscriptions configured by consumers.
47+
- Queue consumers compete for rows using `FOR UPDATE SKIP LOCKED`.
48+
- Topic publishes create one row per configured subscription.
49+
- Message rows use a `bigserial` physical key for insert locality and a logical `uuid` message id.
50+
- The default client-side id generator is sequential-ish for index locality. Random database ids can be selected through `cfg.IdGeneration`.
51+
- Producers optionally call `pg_notify` after inserting messages. Polling remains the correctness mechanism.

docs/provider_sql.md

Lines changed: 40 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -17,42 +17,52 @@ When the application grows over time, and given that SMB is an abstraction, the
1717

1818
## SQL Compatibility
1919

20-
This transport has been tested on SQL Azure (T-SQL), and should work on most other databases.
21-
If you see an issue, please raise an github issue.
20+
This transport targets SQL Server / Azure SQL (T-SQL).
2221

2322
## Configuration
2423

25-
ToDo: Finish
26-
27-
The configuration is arranged via the `.WithProviderSql(cfg => {})` method on the message bus builder.
28-
29-
```cs
30-
services.AddSlimMessageBus(mbb =>
31-
{
32-
mbb.WithProviderSql(cfg =>
33-
{
34-
// ToDo
35-
});
36-
37-
mbb.AddServicesFromAssemblyContaining<PingConsumer>();
38-
mbb.AddJsonSerializer();
39-
});
40-
```
24+
The configuration is arranged via the `.WithProviderSql(cfg => {})` method on the message bus builder.
25+
26+
```cs
27+
services.AddSlimMessageBus(mbb =>
28+
{
29+
mbb.WithProviderSql(cfg =>
30+
{
31+
cfg.ConnectionString = "...";
32+
cfg.DatabaseSchemaName = "smb";
33+
cfg.DatabaseTableName = "Messages";
34+
cfg.PollDelay = TimeSpan.FromMilliseconds(250);
35+
cfg.PollBatchSize = 10;
36+
});
37+
38+
mbb.Produce<PingMessage>(x => x.DefaultQueue("ping-queue"));
39+
mbb.Consume<PingMessage>(x => x.Queue("ping-queue"));
40+
41+
mbb.Produce<OrderSubmitted>(x => x.DefaultTopic("orders").ToTopic());
42+
mbb.Consume<OrderSubmitted>(x => x.Topic("orders", "billing"));
43+
mbb.Consume<OrderSubmitted>(x => x.Topic("orders", "shipping"));
44+
45+
mbb.AddServicesFromAssemblyContaining<PingConsumer>();
46+
mbb.AddJsonSerializer();
47+
});
48+
```
4149

4250
## How it works
4351

4452
The same SQL database instance is required for all the producers and consumers to collaborate.
4553
Therefore ensure all of the service instances point to the same database cluster.
4654

47-
- Single table is used to store all the exchanged messages (by default table is called `Messages`).
48-
- Producers send messages to the messages table.
49-
- There are two types of entities (queues, and topics for pub/sub).
50-
- In the case of a topic:
51-
- Each subscription gets a copy of the message.
52-
- Subscription has a lifetime, and can expire after certain idle time. Along with it, all the messages placed on the subscription.
53-
- Consumers (queue consumers, or subscribers in pub/sub) long poll the table to pick up their respective message.
54-
- Queue consumers compete for the message, and ensure only one consumer instance is processing the message.
55-
- Topic subscribers complete for the message within the same subscription.
56-
- In the future we might consider:
57-
- Table per each entity, so that we can minimize table locking.
58-
- 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.
55+
- A messages table is used to store exchanged messages (by default table is called `Messages`).
56+
- A subscriptions table is used to store durable topic subscriptions configured by consumers.
57+
- Producers send messages to the messages table.
58+
- There are two types of entities (queues, and topics for pub/sub).
59+
- In the case of a topic:
60+
- Each configured durable subscription gets a copy of the message.
61+
- Consumers (queue consumers, or subscribers in pub/sub) long poll the table to pick up their respective message.
62+
- Queue consumers compete for the message, and ensure only one consumer instance is processing the message.
63+
- Topic subscribers compete for the message within the same subscription.
64+
- Message rows use a clustered `bigint identity` sequence for insert locality and a logical `uniqueidentifier` message id.
65+
- 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`.
66+
- In the future we might consider:
67+
- Table per each entity, so that we can minimize table locking.
68+
- 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.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
namespace SlimMessageBus.Host.PostgreSql;
2+
3+
public static class PostgreSqlConsumerBuilderExtensions
4+
{
5+
internal const string PropertySubscriptionName = "PostgreSql_SubscriptionName";
6+
7+
public static ConsumerBuilder<T> Queue<T>(this ConsumerBuilder<T> builder, string queue)
8+
{
9+
if (builder is null) throw new ArgumentNullException(nameof(builder));
10+
if (queue is null) throw new ArgumentNullException(nameof(queue));
11+
12+
builder.Path(queue);
13+
builder.ConsumerSettings.PathKind = PathKind.Queue;
14+
return builder;
15+
}
16+
17+
public static ConsumerBuilder<T> Topic<T>(this ConsumerBuilder<T> builder, string topic, string subscriptionName)
18+
{
19+
if (builder is null) throw new ArgumentNullException(nameof(builder));
20+
if (topic is null) throw new ArgumentNullException(nameof(topic));
21+
if (subscriptionName is null) throw new ArgumentNullException(nameof(subscriptionName));
22+
23+
builder.Topic(topic);
24+
builder.ConsumerSettings.PathKind = PathKind.Topic;
25+
builder.ConsumerSettings.Properties[PropertySubscriptionName] = subscriptionName;
26+
return builder;
27+
}
28+
29+
public static ConsumerBuilder<T> Subscription<T>(this ConsumerBuilder<T> builder, string subscriptionName)
30+
{
31+
if (builder is null) throw new ArgumentNullException(nameof(builder));
32+
if (subscriptionName is null) throw new ArgumentNullException(nameof(subscriptionName));
33+
34+
builder.ConsumerSettings.Properties[PropertySubscriptionName] = subscriptionName;
35+
return builder;
36+
}
37+
38+
internal static string GetSubscriptionName(this AbstractConsumerSettings settings)
39+
=> settings.Properties.TryGetValue(PropertySubscriptionName, out var value)
40+
? (string)value
41+
: settings.Path;
42+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
namespace SlimMessageBus.Host.PostgreSql;
2+
3+
public static class PostgreSqlHandlerBuilderExtensions
4+
{
5+
public static HandlerBuilder<TRequest, TResponse> Queue<TRequest, TResponse>(this HandlerBuilder<TRequest, TResponse> builder, string queue)
6+
{
7+
if (builder is null) throw new ArgumentNullException(nameof(builder));
8+
if (queue is null) throw new ArgumentNullException(nameof(queue));
9+
10+
builder.Path(queue);
11+
builder.ConsumerSettings.PathKind = PathKind.Queue;
12+
return builder;
13+
}
14+
15+
public static HandlerBuilder<TRequest> Queue<TRequest>(this HandlerBuilder<TRequest> builder, string queue)
16+
{
17+
if (builder is null) throw new ArgumentNullException(nameof(builder));
18+
if (queue is null) throw new ArgumentNullException(nameof(queue));
19+
20+
builder.Path(queue);
21+
builder.ConsumerSettings.PathKind = PathKind.Queue;
22+
return builder;
23+
}
24+
25+
public static HandlerBuilder<TRequest, TResponse> Topic<TRequest, TResponse>(this HandlerBuilder<TRequest, TResponse> builder, string topic, string subscriptionName)
26+
{
27+
if (builder is null) throw new ArgumentNullException(nameof(builder));
28+
if (topic is null) throw new ArgumentNullException(nameof(topic));
29+
if (subscriptionName is null) throw new ArgumentNullException(nameof(subscriptionName));
30+
31+
builder.Path(topic);
32+
builder.ConsumerSettings.PathKind = PathKind.Topic;
33+
builder.ConsumerSettings.Properties[PostgreSqlConsumerBuilderExtensions.PropertySubscriptionName] = subscriptionName;
34+
return builder;
35+
}
36+
37+
public static HandlerBuilder<TRequest> Topic<TRequest>(this HandlerBuilder<TRequest> builder, string topic, string subscriptionName)
38+
{
39+
if (builder is null) throw new ArgumentNullException(nameof(builder));
40+
if (topic is null) throw new ArgumentNullException(nameof(topic));
41+
if (subscriptionName is null) throw new ArgumentNullException(nameof(subscriptionName));
42+
43+
builder.Path(topic);
44+
builder.ConsumerSettings.PathKind = PathKind.Topic;
45+
builder.ConsumerSettings.Properties[PostgreSqlConsumerBuilderExtensions.PropertySubscriptionName] = subscriptionName;
46+
return builder;
47+
}
48+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
namespace SlimMessageBus.Host.PostgreSql;
2+
3+
public static class PostgreSqlProducerBuilderExtensions
4+
{
5+
public static ProducerBuilder<T> DefaultQueue<T>(this ProducerBuilder<T> producerBuilder, string queue)
6+
{
7+
if (producerBuilder is null) throw new ArgumentNullException(nameof(producerBuilder));
8+
if (queue is null) throw new ArgumentNullException(nameof(queue));
9+
10+
producerBuilder.DefaultTopic(queue);
11+
producerBuilder.ToQueue();
12+
return producerBuilder;
13+
}
14+
15+
public static ProducerBuilder<T> ToTopic<T>(this ProducerBuilder<T> producerBuilder)
16+
{
17+
if (producerBuilder is null) throw new ArgumentNullException(nameof(producerBuilder));
18+
19+
producerBuilder.Settings.PathKind = PathKind.Topic;
20+
return producerBuilder;
21+
}
22+
23+
public static ProducerBuilder<T> ToQueue<T>(this ProducerBuilder<T> producerBuilder)
24+
{
25+
if (producerBuilder is null) throw new ArgumentNullException(nameof(producerBuilder));
26+
27+
producerBuilder.Settings.PathKind = PathKind.Queue;
28+
return producerBuilder;
29+
}
30+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
namespace SlimMessageBus.Host.PostgreSql;
2+
3+
public static class PostgreSqlRequestResponseBuilderExtensions
4+
{
5+
public static RequestResponseBuilder ReplyToQueue(this RequestResponseBuilder builder, string queue)
6+
{
7+
if (builder is null) throw new ArgumentNullException(nameof(builder));
8+
if (queue is null) throw new ArgumentNullException(nameof(queue));
9+
10+
builder.Settings.Path = queue;
11+
builder.Settings.PathKind = PathKind.Queue;
12+
return builder;
13+
}
14+
15+
public static RequestResponseBuilder ReplyToTopic(this RequestResponseBuilder builder, string topic, string subscriptionName)
16+
{
17+
if (builder is null) throw new ArgumentNullException(nameof(builder));
18+
if (topic is null) throw new ArgumentNullException(nameof(topic));
19+
if (subscriptionName is null) throw new ArgumentNullException(nameof(subscriptionName));
20+
21+
builder.Settings.Path = topic;
22+
builder.Settings.PathKind = PathKind.Topic;
23+
builder.Settings.Properties[PostgreSqlConsumerBuilderExtensions.PropertySubscriptionName] = subscriptionName;
24+
return builder;
25+
}
26+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
namespace SlimMessageBus.Host.PostgreSql;
2+
3+
public static class PostgreSqlMessageBusBuilderExtensions
4+
{
5+
public static MessageBusBuilder WithProviderPostgreSql(this MessageBusBuilder mbb, Action<PostgreSqlMessageBusSettings> configure)
6+
{
7+
if (mbb == null) throw new ArgumentNullException(nameof(mbb));
8+
if (configure == null) throw new ArgumentNullException(nameof(configure));
9+
10+
var providerSettings = new PostgreSqlMessageBusSettings();
11+
configure(providerSettings);
12+
13+
mbb.PostConfigurationActions.Add(services =>
14+
{
15+
services.TryAddSingleton(providerSettings);
16+
services.TryAddScoped(_ => new NpgsqlConnection(providerSettings.ConnectionString));
17+
services.TryAddSingleton<PostgreSqlSequentialGuidGenerator>();
18+
services.TryAddScoped<PostgreSqlRepository>();
19+
services.Replace(ServiceDescriptor.Scoped<IPostgreSqlRepository>(svp => svp.GetRequiredService<PostgreSqlRepository>()));
20+
services.TryAddSingleton<PostgreSqlTemplate>();
21+
});
22+
23+
return mbb.WithProvider(settings => new PostgreSqlMessageBus(settings, providerSettings));
24+
}
25+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
namespace SlimMessageBus.Host.PostgreSql;
2+
3+
public class PostgreSqlMessageBusSettings
4+
{
5+
public string? ConnectionString { get; set; }
6+
public string DatabaseSchemaName { get; set; } = "public";
7+
public string DatabaseTableName { get; set; } = "messages";
8+
public string DatabaseMigrationsTableName { get; set; } = "__EFMigrationsHistory";
9+
public TimeSpan? CommandTimeout { get; set; }
10+
public IsolationLevel TransactionIsolationLevel { get; set; } = IsolationLevel.ReadCommitted;
11+
public TimeSpan PollDelay { get; set; } = TimeSpan.FromMilliseconds(250);
12+
public TimeSpan LockDuration { get; set; } = TimeSpan.FromSeconds(30);
13+
public int PollBatchSize { get; set; } = 10;
14+
public int MaxDeliveryAttempts { get; set; } = 10;
15+
public bool NotifyOnPublish { get; set; } = true;
16+
public PostgreSqlMessageIdGenerationSettings IdGeneration { get; set; } = new();
17+
18+
public RetrySettings SchemaCreationRetry { get; set; } = new()
19+
{
20+
RetryCount = 3,
21+
RetryIntervalFactor = 1.2f,
22+
RetryInterval = TimeSpan.FromSeconds(2),
23+
};
24+
25+
public RetrySettings OperationRetry { get; set; } = new()
26+
{
27+
RetryCount = 5,
28+
RetryIntervalFactor = 1.5f,
29+
RetryInterval = TimeSpan.FromSeconds(2),
30+
};
31+
}
32+
33+
public class RetrySettings
34+
{
35+
public int RetryCount { get; set; }
36+
public TimeSpan RetryInterval { get; set; }
37+
public float RetryIntervalFactor { get; set; }
38+
}

0 commit comments

Comments
 (0)