├── artwork ├── logo200x200.png ├── fm-logo2-200x200.png ├── fm-logo2-500x500.png ├── fm-logo2-1000x1000.png ├── logo200x200-transparent.png ├── little_rebusbus2_copy-1000x1000.png ├── little_rebusbus2_copy-200x200.png ├── little_rebusbus2_copy-500x500.png └── rebus2-logo-200x200-transparent.png ├── tools └── NuGet │ └── nuget.exe ├── Rebus.PostgreSql ├── PostgreSql │ ├── InternalsVisibleTo.cs │ ├── IsExternalInit.cs │ ├── Outbox │ │ ├── IOutboxConnectionProvider.cs │ │ ├── OutboxMessage.cs │ │ ├── OutboxConnection.cs │ │ ├── OutboxIncomingStep.cs │ │ ├── IOutboxStorage.cs │ │ ├── OutboxOutgoingStep.cs │ │ ├── OutboxClientTransportDecorator.cs │ │ ├── OutboxMessageBatch.cs │ │ ├── OutboxForwarder.cs │ │ └── PostgreSqlOutboxStorage.cs │ ├── MathExtensions.cs │ ├── Sagas │ │ ├── DefaultSagaSerializer.cs │ │ ├── ISagaSerializer.cs │ │ ├── JsonSagaSerializer.cs │ │ ├── PostgreSqlSagaSnapshotStorage.cs │ │ └── PostgreSqlSagaStorage.cs │ ├── IPostgresConnectionProvider.cs │ ├── Transport │ │ ├── DisabledTimeoutManager.cs │ │ └── PostgresqlTransport.cs │ ├── Retrier.cs │ ├── IDbConnection.cs │ ├── Reflection │ │ └── Ponder.cs │ ├── PostgresConnectionHelper.cs │ ├── CustomPostgresConnectionProvider.cs │ ├── PostgresConnection.cs │ ├── PostgreSqlMagic.cs │ ├── DbConnectionWrapper.cs │ ├── TableName.cs │ ├── Subscriptions │ │ └── PostgreSqlSubscriptionStorage.cs │ └── Timeouts │ │ └── PostgreSqlTimeoutManager.cs ├── Rebus.PostgreSql.csproj ├── Config │ ├── Outbox │ │ ├── OutboxConnectionProvider.cs │ │ ├── SqlServerOutboxConfigurationExtensions.cs │ │ └── OutboxExtensions.cs │ ├── PostgreSqlTransportConfigurationExtensions.cs │ └── PostgreSqlConfigurationExtensions.cs └── Internals │ └── AsyncHelpers.cs ├── Rebus.PostgreSql.Tests ├── TestCategory.cs ├── Categories.cs ├── Outbox │ ├── FlakySenderTransportDecoratorSettings.cs │ ├── RandomUnluckyException.cs │ ├── TestMathExtensions.cs │ ├── FlakySenderTransportDecorator.cs │ ├── TestIdGenerationAssumptions.cs │ ├── TestOutbox_InsideRebusHandler.cs │ ├── TestSqlServerOutboxStorage.cs │ └── TestOutbox_OutsideOfRebusHandler.cs ├── Sagas │ ├── ConcurrencyHandling.cs │ ├── SagaIntegrationTests.cs │ ├── PostgreSqlSagaSnapshotTest.cs │ ├── PostgreSqlBasicLoadAndSaveAndFindOperations.cs │ ├── PostgreSqlSagaStorageFactory.cs │ └── PostgreSqlSnapshotStorageFactory.cs ├── Subscriptions │ ├── PostgreSqlSubscriptionStorageBasicSubscriptionOperations.cs │ └── PostgreSqlSubscriptionStorageFactory.cs ├── Rebus.PostgreSql.Tests.csproj ├── docker-compose.yml ├── Assumptions │ └── CheckExceptionOnCancellation.cs ├── Timeouts │ └── TestPostgreSqlTimeoutManager.cs ├── PostgresTestContainerManager.cs ├── Transport │ ├── TestPostgreSqlTransportCleanup.cs │ ├── TestPostgreSqlTransportReceivePerformance.cs │ ├── PostgreSqlTransportTests.cs │ ├── TestPostgreSqlTransportMessageOrdering.cs │ └── TestPostgreSqlTransport.cs ├── Bugs │ ├── PublishWithinTransactionScopeTests.cs │ └── VerifyRebusTransactionScope.cs └── PostgreSqlTestHelper.cs ├── .github └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── appveyor.yml ├── README.md ├── scripts ├── build.cmd ├── push.cmd └── release.cmd ├── LICENSE.md ├── CONTRIBUTING.md ├── Rebus.PostgreSql.sln └── CHANGELOG.md /artwork/logo200x200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rebus-org/Rebus.PostgreSql/HEAD/artwork/logo200x200.png -------------------------------------------------------------------------------- /tools/NuGet/nuget.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rebus-org/Rebus.PostgreSql/HEAD/tools/NuGet/nuget.exe -------------------------------------------------------------------------------- /artwork/fm-logo2-200x200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rebus-org/Rebus.PostgreSql/HEAD/artwork/fm-logo2-200x200.png -------------------------------------------------------------------------------- /artwork/fm-logo2-500x500.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rebus-org/Rebus.PostgreSql/HEAD/artwork/fm-logo2-500x500.png -------------------------------------------------------------------------------- /artwork/fm-logo2-1000x1000.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rebus-org/Rebus.PostgreSql/HEAD/artwork/fm-logo2-1000x1000.png -------------------------------------------------------------------------------- /artwork/logo200x200-transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rebus-org/Rebus.PostgreSql/HEAD/artwork/logo200x200-transparent.png -------------------------------------------------------------------------------- /artwork/little_rebusbus2_copy-1000x1000.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rebus-org/Rebus.PostgreSql/HEAD/artwork/little_rebusbus2_copy-1000x1000.png -------------------------------------------------------------------------------- /artwork/little_rebusbus2_copy-200x200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rebus-org/Rebus.PostgreSql/HEAD/artwork/little_rebusbus2_copy-200x200.png -------------------------------------------------------------------------------- /artwork/little_rebusbus2_copy-500x500.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rebus-org/Rebus.PostgreSql/HEAD/artwork/little_rebusbus2_copy-500x500.png -------------------------------------------------------------------------------- /artwork/rebus2-logo-200x200-transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rebus-org/Rebus.PostgreSql/HEAD/artwork/rebus2-logo-200x200-transparent.png -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/InternalsVisibleTo.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | 3 | [assembly: InternalsVisibleTo("Rebus.PostgreSql.Tests")] 4 | -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/TestCategory.cs: -------------------------------------------------------------------------------- 1 | namespace Rebus.PostgreSql.Tests; 2 | 3 | class TestCategory 4 | { 5 | public const string Postgres = "postgresql"; 6 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/Categories.cs: -------------------------------------------------------------------------------- 1 | namespace Rebus.PostgreSql.Tests; 2 | 3 | public class Categories 4 | { 5 | public const string PostgreSql = "PostgreSql"; 6 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/Outbox/FlakySenderTransportDecoratorSettings.cs: -------------------------------------------------------------------------------- 1 | namespace Rebus.PostgreSql.Tests.Outbox; 2 | 3 | class FlakySenderTransportDecoratorSettings 4 | { 5 | public double SuccessRate { get; set; } = 1; 6 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/IsExternalInit.cs: -------------------------------------------------------------------------------- 1 | // ReSharper disable CheckNamespace 2 | // ReSharper disable UnusedMember.Global 3 | #pragma warning disable CS1591 4 | namespace System.Runtime.CompilerServices; 5 | 6 | public class IsExternalInit 7 | { 8 | } 9 | -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/Outbox/IOutboxConnectionProvider.cs: -------------------------------------------------------------------------------- 1 | namespace Rebus.PostgreSql.Outbox; 2 | 3 | interface IOutboxConnectionProvider 4 | { 5 | OutboxConnection GetDbConnection(); 6 | OutboxConnection GetDbConnectionWithoutTransaction(); 7 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/Outbox/RandomUnluckyException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Rebus.PostgreSql.Tests.Outbox; 4 | 5 | class RandomUnluckyException : ApplicationException 6 | { 7 | public RandomUnluckyException() : base("You were unfortunate") 8 | { 9 | } 10 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/Sagas/ConcurrencyHandling.cs: -------------------------------------------------------------------------------- 1 | using NUnit.Framework; 2 | using Rebus.Tests.Contracts.Sagas; 3 | 4 | namespace Rebus.PostgreSql.Tests.Sagas; 5 | 6 | [TestFixture, Category(TestCategory.Postgres)] 7 | public class ConcurrencyHandling : ConcurrencyHandling { } -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/Sagas/SagaIntegrationTests.cs: -------------------------------------------------------------------------------- 1 | using NUnit.Framework; 2 | using Rebus.Tests.Contracts.Sagas; 3 | 4 | namespace Rebus.PostgreSql.Tests.Sagas; 5 | 6 | [TestFixture, Category(TestCategory.Postgres)] 7 | public class SagaIntegrationTests : SagaIntegrationTests 8 | { 9 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/Sagas/PostgreSqlSagaSnapshotTest.cs: -------------------------------------------------------------------------------- 1 | using NUnit.Framework; 2 | using Rebus.Tests.Contracts.Sagas; 3 | 4 | namespace Rebus.PostgreSql.Tests.Sagas; 5 | 6 | [TestFixture, Category(TestCategory.Postgres)] 7 | public class PostgreSqlSagaSnapshotTest : SagaSnapshotStorageTest { } -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/MathExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | // ReSharper disable ArgumentsStyleLiteral 4 | 5 | namespace Rebus.PostgreSql; 6 | 7 | static class MathExtensions 8 | { 9 | public static int RoundUpToNextPowerOfTwo(this int number) => 1 << (int)Math.Ceiling(Math.Log(number, newBase: 2)); 10 | } 11 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | --- 2 | Rebus is [MIT-licensed](https://opensource.org/licenses/MIT). The code submitted in this pull request needs to carry the MIT license too. By leaving this text in, __I hereby acknowledge that the code submitted in the pull request has the MIT license and can be merged with the Rebus codebase__. 3 | -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/Sagas/PostgreSqlBasicLoadAndSaveAndFindOperations.cs: -------------------------------------------------------------------------------- 1 | using NUnit.Framework; 2 | using Rebus.Tests.Contracts.Sagas; 3 | 4 | namespace Rebus.PostgreSql.Tests.Sagas; 5 | 6 | [TestFixture, Category(TestCategory.Postgres)] 7 | public class PostgreSqlBasicLoadAndSaveAndFindOperations : BasicLoadAndSaveAndFindOperations { } -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/Subscriptions/PostgreSqlSubscriptionStorageBasicSubscriptionOperations.cs: -------------------------------------------------------------------------------- 1 | using NUnit.Framework; 2 | using Rebus.Tests.Contracts.Subscriptions; 3 | 4 | namespace Rebus.PostgreSql.Tests.Subscriptions; 5 | 6 | [TestFixture, Category(TestCategory.Postgres)] 7 | public class PostgreSqlSubscriptionStorageBasicSubscriptionOperations : BasicSubscriptionOperations 8 | { 9 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | obj 2 | bin 3 | deploy 4 | deploy/* 5 | _ReSharper.* 6 | *.csproj.user 7 | *.resharper.user 8 | *.ReSharper.user 9 | *.teamcity.user 10 | *.TeamCity.user 11 | *.resharper 12 | *.DotSettings.user 13 | *.dotsettings.user 14 | *.ncrunchproject 15 | *.ncrunchsolution 16 | *.suo 17 | *.cache 18 | ~$* 19 | .vs 20 | .vs/* 21 | .idea/ 22 | _NCrunch_* 23 | *.user 24 | *.backup 25 | 26 | # MS Guideline 27 | **/packages/* 28 | !**/packages/build/ 29 | 30 | AssemblyInfo_Patch.cs 31 | -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/Sagas/DefaultSagaSerializer.cs: -------------------------------------------------------------------------------- 1 | using Rebus.Serialization; 2 | 3 | namespace Rebus.PostgreSql.Sagas; 4 | 5 | /// 6 | /// The default serializer for serializing sql saga data, 7 | /// Implement to make your own custom serializer and register it using the UseSagaSerializer extension method. 8 | /// 9 | /// 10 | public class DefaultSagaSerializer : ObjectSerializer, ISagaSerializer 11 | { 12 | } -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | image: Visual Studio 2022 2 | 3 | shallow_clone: true 4 | 5 | cache: 6 | - packages -> **\packages.config 7 | - '%LocalAppData%\NuGet\Cache' 8 | 9 | services: 10 | - postgresql101 11 | 12 | before_build: 13 | - SET PGUSER=postgres 14 | - SET PGPASSWORD=Password12! 15 | - PATH=C:\Program Files\PostgreSQL\10\bin\;%PATH% 16 | - createdb rebus2_test 17 | - appveyor-retry dotnet restore -v Minimal 18 | 19 | build_script: 20 | - dotnet build -c Release --no-restore 21 | 22 | test_script: 23 | - dotnet test -c Release --no-restore 24 | -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/Outbox/TestMathExtensions.cs: -------------------------------------------------------------------------------- 1 | using NUnit.Framework; 2 | 3 | namespace Rebus.PostgreSql.Tests.Outbox; 4 | 5 | [TestFixture] 6 | public class TestMathExtensions 7 | { 8 | [TestCase(1, 1)] 9 | [TestCase(2, 2)] 10 | [TestCase(3, 4)] 11 | [TestCase(4, 4)] 12 | [TestCase(5, 8)] 13 | [TestCase(10, 16)] 14 | [TestCase(17, 32)] 15 | [TestCase(789, 1024)] 16 | public void CanRoundUpToNextPowerOfTwo(int input, int expectedOutput) => 17 | Assert.That(input.RoundUpToNextPowerOfTwo(), Is.EqualTo(expectedOutput)); 18 | } 19 | -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/Outbox/OutboxMessage.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Rebus.Messages; 3 | 4 | namespace Rebus.PostgreSql.Outbox; 5 | 6 | /// 7 | /// Represents one single message to be delivered to the transport 8 | /// 9 | public record OutboxMessage(long Id, string DestinationAddress, Dictionary Headers, byte[] Body) 10 | { 11 | /// 12 | /// Gets the and wrapped in a 13 | /// 14 | public TransportMessage ToTransportMessage() => new(Headers, Body); 15 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/IPostgresConnectionProvider.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | 3 | namespace Rebus.PostgreSql; 4 | 5 | /// 6 | /// PostgreSql Server database connection provider that allows for easily changing how the current is obtained, 7 | /// possibly also changing how transactions are handled 8 | /// 9 | public interface IPostgresConnectionProvider 10 | { 11 | /// 12 | /// Gets a wrapper with the current inside 13 | /// 14 | Task GetConnection(); 15 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rebus.PostgreSql 2 | 3 | [![install from nuget](https://img.shields.io/nuget/v/Rebus.PostgreSql.svg?style=flat-square)](https://www.nuget.org/packages/Rebus.PostgreSql) 4 | 5 | Provides a PostgreSQL-based persistence for [Rebus](https://github.com/rebus-org/Rebus) for 6 | 7 | * sagas 8 | * subscriptions 9 | * timeouts 10 | * transport 11 | 12 | Note: In your npgsql connection string, if you are using the default settings, set your maximum pool size=30 to avoid connection pool exhaustion issues. 13 | 14 | ![](https://raw.githubusercontent.com/rebus-org/Rebus/master/artwork/little_rebusbus2_copy-200x200.png) 15 | 16 | --- 17 | 18 | 19 | -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/Sagas/ISagaSerializer.cs: -------------------------------------------------------------------------------- 1 | namespace Rebus.PostgreSql.Sagas; 2 | 3 | public interface ISagaSerializer 4 | { 5 | /// 6 | /// Serializes the given object into a byte[] 7 | /// 8 | byte[] Serialize(object obj); 9 | 10 | /// 11 | /// Serializes the given object into a string 12 | /// 13 | string SerializeToString(object obj); 14 | 15 | /// 16 | /// Deserializes the given byte[] into an object 17 | /// 18 | object Deserialize(byte[] bytes); 19 | 20 | /// 21 | /// Deserializes the given string into an object 22 | /// 23 | object DeserializeFromString(string str); 24 | } -------------------------------------------------------------------------------- /scripts/build.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | set scriptsdir=%~dp0 4 | set root=%scriptsdir%\.. 5 | set project=%1 6 | set version=%2 7 | 8 | if "%project%"=="" ( 9 | echo Please invoke the build script with a project name as its first argument. 10 | echo. 11 | goto exit_fail 12 | ) 13 | 14 | if "%version%"=="" ( 15 | echo Please invoke the build script with a version as its second argument. 16 | echo. 17 | goto exit_fail 18 | ) 19 | 20 | set Version=%version% 21 | 22 | pushd %root% 23 | 24 | dotnet restore 25 | if %ERRORLEVEL% neq 0 ( 26 | popd 27 | goto exit_fail 28 | ) 29 | 30 | dotnet build "%root%\%project%" -c Release --no-restore 31 | if %ERRORLEVEL% neq 0 ( 32 | popd 33 | goto exit_fail 34 | ) 35 | 36 | popd 37 | 38 | 39 | 40 | 41 | 42 | 43 | goto exit_success 44 | :exit_fail 45 | exit /b 1 46 | :exit_success -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/Outbox/OutboxConnection.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Npgsql; 3 | 4 | // ReSharper disable ArgumentsStyleLiteral 5 | 6 | namespace Rebus.PostgreSql.Outbox; 7 | 8 | /// 9 | /// Holds an open 10 | /// 11 | public class OutboxConnection 12 | { 13 | /// 14 | /// Gets the connection 15 | /// 16 | public NpgsqlConnection Connection { get; } 17 | 18 | /// 19 | /// Gets the current transaction 20 | /// 21 | public NpgsqlTransaction Transaction { get; } 22 | 23 | internal OutboxConnection(NpgsqlConnection connection, NpgsqlTransaction transaction) 24 | { 25 | Connection = connection ?? throw new ArgumentNullException(nameof(connection)); 26 | Transaction = transaction; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/Rebus.PostgreSql.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | Library 4 | net8.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | all 13 | runtime; build; native; contentfiles; analyzers; buildtransitive 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.6' 2 | 3 | services: 4 | db: 5 | image: postgres 6 | ports: 7 | - 5432:5432 8 | command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all 9 | environment: 10 | POSTGRES_USER: postgres # The PostgreSQL user (useful to connect to the database) 11 | POSTGRES_PASSWORD: postgres # The PostgreSQL password (useful to connect to the database) 12 | adminer: 13 | image: adminer 14 | restart: always 15 | ports: 16 | - 8081:8080 17 | pgadmin: 18 | image: dpage/pgadmin4 19 | ports: 20 | - 8082:80 21 | environment: 22 | PGADMIN_DEFAULT_EMAIL: test@mailinator.com # The PostgreSQL user (useful to connect to the database) 23 | PGADMIN_DEFAULT_PASSWORD: postgres # The PostgreSQL password (useful to connect to the database) 24 | -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/Subscriptions/PostgreSqlSubscriptionStorageFactory.cs: -------------------------------------------------------------------------------- 1 | using Rebus.Logging; 2 | using Rebus.PostgreSql.Subscriptions; 3 | using Rebus.Subscriptions; 4 | using Rebus.Tests.Contracts.Subscriptions; 5 | 6 | namespace Rebus.PostgreSql.Tests.Subscriptions; 7 | 8 | public class PostgreSqlSubscriptionStorageFactory : ISubscriptionStorageFactory 9 | { 10 | public PostgreSqlSubscriptionStorageFactory() 11 | { 12 | Cleanup(); 13 | } 14 | 15 | public ISubscriptionStorage Create() 16 | { 17 | var subscriptionStorage = new PostgreSqlSubscriptionStorage(PostgreSqlTestHelper.ConnectionHelper, "subscriptions", true, new ConsoleLoggerFactory(false)); 18 | subscriptionStorage.EnsureTableIsCreated(); 19 | return subscriptionStorage; 20 | } 21 | 22 | public void Cleanup() 23 | { 24 | PostgreSqlTestHelper.DropTable("subscriptions"); 25 | } 26 | } -------------------------------------------------------------------------------- /scripts/push.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | set version=%1 4 | 5 | if "%version%"=="" ( 6 | echo Please remember to specify which version to push as an argument. 7 | goto exit_fail 8 | ) 9 | 10 | set reporoot=%~dp0\.. 11 | set destination=%reporoot%\deploy 12 | 13 | if not exist "%destination%" ( 14 | echo Could not find %destination% 15 | echo. 16 | echo Did you remember to build the packages before running this script? 17 | ) 18 | 19 | set nuget=%reporoot%\tools\NuGet\NuGet.exe 20 | 21 | if not exist "%nuget%" ( 22 | echo Could not find NuGet here: 23 | echo. 24 | echo "%nuget%" 25 | echo. 26 | goto exit_fail 27 | ) 28 | 29 | 30 | "%nuget%" push "%destination%\*.%version%.nupkg" -Source https://nuget.org 31 | if %ERRORLEVEL% neq 0 ( 32 | echo NuGet push failed. 33 | goto exit_fail 34 | ) 35 | 36 | 37 | 38 | 39 | 40 | 41 | goto exit_success 42 | :exit_fail 43 | exit /b 1 44 | :exit_success 45 | -------------------------------------------------------------------------------- /scripts/release.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | set scriptsdir=%~dp0 4 | set root=%scriptsdir%\.. 5 | set deploydir=%root%\deploy 6 | set project=%1 7 | set version=%2 8 | 9 | if "%project%"=="" ( 10 | echo Please invoke the build script with a project name as its first argument. 11 | echo. 12 | goto exit_fail 13 | ) 14 | 15 | if "%version%"=="" ( 16 | echo Please invoke the build script with a version as its second argument. 17 | echo. 18 | goto exit_fail 19 | ) 20 | 21 | set Version=%version% 22 | 23 | if exist "%deploydir%" ( 24 | rd "%deploydir%" /s/q 25 | ) 26 | 27 | pushd %root% 28 | 29 | dotnet restore 30 | if %ERRORLEVEL% neq 0 ( 31 | popd 32 | goto exit_fail 33 | ) 34 | 35 | dotnet pack "%root%/%project%" -c Release -o "%deploydir%" -p:PackageVersion=%version% --no-restore 36 | if %ERRORLEVEL% neq 0 ( 37 | popd 38 | goto exit_fail 39 | ) 40 | 41 | call scripts\push.cmd "%version%" 42 | 43 | popd 44 | 45 | 46 | 47 | 48 | 49 | 50 | goto exit_success 51 | :exit_fail 52 | exit /b 1 53 | :exit_success -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/Sagas/PostgreSqlSagaStorageFactory.cs: -------------------------------------------------------------------------------- 1 | using Rebus.Logging; 2 | using Rebus.PostgreSql.Sagas; 3 | using Rebus.Sagas; 4 | using Rebus.Tests.Contracts.Sagas; 5 | 6 | namespace Rebus.PostgreSql.Tests.Sagas; 7 | 8 | public class PostgreSqlSagaStorageFactory : ISagaStorageFactory 9 | { 10 | public PostgreSqlSagaStorageFactory() 11 | { 12 | PostgreSqlTestHelper.DropTable("sagas", "saga_index"); 13 | PostgreSqlTestHelper.DropTable("sagas","saga_data"); 14 | } 15 | 16 | public ISagaStorage GetSagaStorage() 17 | { 18 | var serializer = new DefaultSagaSerializer(); 19 | 20 | var postgreSqlSagaStorage = new PostgreSqlSagaStorage(PostgreSqlTestHelper.ConnectionHelper, "saga_data", "saga_index", new ConsoleLoggerFactory(false), serializer, schemaName: "sagas"); 21 | postgreSqlSagaStorage.EnsureTablesAreCreated(); 22 | return postgreSqlSagaStorage; 23 | } 24 | 25 | public void CleanUp() 26 | { 27 | //PostgreSqlTestHelper.DropTable("saga_index"); 28 | //PostgreSqlTestHelper.DropTable("saga_data"); 29 | } 30 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/Transport/DisabledTimeoutManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using Rebus.Extensions; 5 | using Rebus.Messages; 6 | using Rebus.Timeouts; 7 | #pragma warning disable 1998 8 | 9 | namespace Rebus.PostgreSql.Transport; 10 | 11 | class DisabledTimeoutManager : ITimeoutManager 12 | { 13 | public async Task Defer(DateTimeOffset approximateDueTime, Dictionary headers, byte[] body) 14 | { 15 | var messageIdToPrint = headers.GetValueOrNull(Headers.MessageId) ?? ""; 16 | 17 | var message = 18 | $"Received message with ID {messageIdToPrint} which is supposed to be deferred until {approximateDueTime} -" + 19 | " this is a problem, because the internal handling of deferred messages is" + 20 | " disabled when using PostgreSql Server as the transport layer in, which" + 21 | " case the native support for a specific visibility time is used..."; 22 | 23 | throw new InvalidOperationException(message); 24 | } 25 | 26 | public async Task GetDueMessages() 27 | { 28 | return DueMessagesResult.Empty; 29 | } 30 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/Outbox/OutboxIncomingStep.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Rebus.Config.Outbox; 4 | using Rebus.Pipeline; 5 | using Rebus.Transport; 6 | #pragma warning disable CS1998 7 | 8 | namespace Rebus.PostgreSql.Outbox; 9 | 10 | class OutboxIncomingStep : IIncomingStep 11 | { 12 | readonly IOutboxConnectionProvider _outboxConnectionProvider; 13 | 14 | public OutboxIncomingStep(IOutboxConnectionProvider outboxConnectionProvider) 15 | { 16 | _outboxConnectionProvider = outboxConnectionProvider ?? throw new ArgumentNullException(nameof(outboxConnectionProvider)); 17 | } 18 | 19 | public async Task Process(IncomingStepContext context, Func next) 20 | { 21 | var outboxConnection = _outboxConnectionProvider.GetDbConnection(); 22 | var transactionContext = context.Load(); 23 | 24 | transactionContext.Items[OutboxExtensions.CurrentOutboxConnectionKey] = outboxConnection; 25 | 26 | transactionContext.OnCommit(async _ => await outboxConnection.Transaction.CommitAsync()); 27 | 28 | transactionContext.OnDisposed(_ => 29 | { 30 | outboxConnection.Transaction.Dispose(); 31 | outboxConnection.Connection.Dispose(); 32 | }); 33 | 34 | await next(); 35 | } 36 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql/Rebus.PostgreSql.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | Library 4 | 12 5 | Rebus 6 | netstandard2.0 7 | mookid8000 8 | https://rebus.fm/what-is-rebus/ 9 | PostgreSQL-based persistence for Rebus 10 | Copyright Rebus FM ApS 2012 11 | rebus postgres sql postgresql 12 | https://github.com/rebus-org/Rebus 13 | git 14 | MIT 15 | little_rebusbus2_copy-500x500.png 16 | README.md 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | True 27 | 28 | 29 | 30 | True 31 | \ 32 | 33 | 34 | -------------------------------------------------------------------------------- /Rebus.PostgreSql/Config/Outbox/OutboxConnectionProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Npgsql; 3 | using Rebus.PostgreSql.Outbox; 4 | 5 | namespace Rebus.Config.Outbox; 6 | 7 | class OutboxConnectionProvider : IOutboxConnectionProvider 8 | { 9 | readonly string _connectionString; 10 | 11 | public OutboxConnectionProvider(string connectionString) 12 | { 13 | _connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString)); 14 | } 15 | 16 | public OutboxConnection GetDbConnection() 17 | { 18 | var connection = new NpgsqlConnection(_connectionString); 19 | 20 | try 21 | { 22 | connection.Open(); 23 | 24 | var transaction = connection.BeginTransaction(); 25 | 26 | return new OutboxConnection(connection, transaction); 27 | } 28 | catch 29 | { 30 | connection.Dispose(); 31 | throw; 32 | } 33 | } 34 | 35 | public OutboxConnection GetDbConnectionWithoutTransaction() 36 | { 37 | var connection = new NpgsqlConnection(_connectionString); 38 | 39 | try 40 | { 41 | connection.Open(); 42 | 43 | return new OutboxConnection(connection, null); 44 | } 45 | catch 46 | { 47 | connection.Dispose(); 48 | throw; 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/Outbox/FlakySenderTransportDecorator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using Rebus.Messages; 5 | using Rebus.Transport; 6 | 7 | namespace Rebus.PostgreSql.Tests.Outbox; 8 | 9 | class FlakySenderTransportDecorator : ITransport 10 | { 11 | readonly ITransport _transport; 12 | readonly FlakySenderTransportDecoratorSettings _flakySenderTransportDecoratorSettings; 13 | 14 | public FlakySenderTransportDecorator(ITransport transport, 15 | FlakySenderTransportDecoratorSettings flakySenderTransportDecoratorSettings) 16 | { 17 | _transport = transport; 18 | _flakySenderTransportDecoratorSettings = flakySenderTransportDecoratorSettings; 19 | } 20 | 21 | public void CreateQueue(string address) => _transport.CreateQueue(address); 22 | 23 | public Task Send(string destinationAddress, TransportMessage message, ITransactionContext context) 24 | { 25 | if (Random.Shared.NextDouble() > _flakySenderTransportDecoratorSettings.SuccessRate) 26 | { 27 | throw new RandomUnluckyException(); 28 | } 29 | 30 | return _transport.Send(destinationAddress, message, context); 31 | } 32 | 33 | public Task Receive(ITransactionContext context, CancellationToken cancellationToken) 34 | { 35 | return _transport.Receive(context, cancellationToken); 36 | } 37 | 38 | public string Address => _transport.Address; 39 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Rebus is licensed under [The MIT License (MIT)](http://opensource.org/licenses/MIT) 2 | 3 | # The MIT License (MIT) 4 | 5 | Copyright (c) 2012-2016 Mogens Heller Grabe 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in 15 | all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | THE SOFTWARE. 24 | 25 | ------- 26 | 27 | This license was chosen with the intention of making it easy for everyone to use Rebus. If the license has the opposite effect for your specific usage/organization/whatever, please contact me and we'll see if we can work something out. -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/Assumptions/CheckExceptionOnCancellation.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using Npgsql; 5 | using NUnit.Framework; 6 | using Rebus.Tests.Contracts; 7 | 8 | namespace Rebus.PostgreSql.Tests.Assumptions; 9 | 10 | [TestFixture] 11 | public class CheckExceptionOnCancellation : FixtureBase 12 | { 13 | [Test] 14 | [Description("Checks that the right exception is thrown by Npgsql when operation is cancelled")] 15 | public async Task VerifyItIsAnOrdinaryOperationCancelledException() 16 | { 17 | using var cancellationTokenSource = new CancellationTokenSource(); 18 | var cancellationToken = cancellationTokenSource.Token; 19 | await using var connection = new NpgsqlConnection(PostgreSqlTestHelper.ConnectionString); 20 | await connection.OpenAsync(cancellationToken); 21 | await using var command = connection.CreateCommand(); 22 | command.CommandText = @"select table_name from information_schema.tables"; 23 | 24 | cancellationTokenSource.Cancel(); 25 | 26 | var exception = Assert.ThrowsAsync(async () => 27 | { 28 | await using var reader = await command.ExecuteReaderAsync(cancellationToken); 29 | while (await reader.ReadAsync(cancellationToken)) 30 | { 31 | Console.WriteLine($"{reader["table_name"]}"); 32 | } 33 | }); 34 | 35 | Console.WriteLine(exception); 36 | } 37 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/Retrier.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | 7 | namespace Rebus.PostgreSql; 8 | 9 | /// 10 | /// Mini-Polly 🙂 11 | /// 12 | class Retrier 13 | { 14 | readonly List _delays; 15 | 16 | public Retrier(IEnumerable delays) 17 | { 18 | if (delays == null) 19 | { 20 | throw new ArgumentNullException(nameof(delays)); 21 | } 22 | 23 | _delays = delays.ToList(); 24 | } 25 | 26 | public async Task ExecuteAsync(Func execute, CancellationToken cancellationToken = default) 27 | { 28 | for (var index = 0; index <= _delays.Count; index++) 29 | { 30 | try 31 | { 32 | await execute(); 33 | return; 34 | } 35 | catch (Exception exception) 36 | { 37 | if (index == _delays.Count) 38 | { 39 | throw; 40 | } 41 | 42 | var delay = _delays[index]; 43 | 44 | try 45 | { 46 | await Task.Delay(delay, cancellationToken); 47 | } 48 | catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) 49 | { 50 | return; 51 | } 52 | } 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/Outbox/TestIdGenerationAssumptions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Globalization; 4 | using NUnit.Framework; 5 | 6 | namespace Rebus.PostgreSql.Tests.Outbox; 7 | 8 | [TestFixture] 9 | public class TestIdGenerationAssumptions 10 | { 11 | [TestCase(4)] 12 | [TestCase(5)] 13 | [TestCase(6)] 14 | [TestCase(7)] 15 | [TestCase(8)] 16 | [TestCase(16)] 17 | [Repeat(5)] 18 | public void CheckGuidPrefix(int length) => RunTest(() => Guid.NewGuid().ToString("N").Substring(0, length)); 19 | 20 | [Test] 21 | [Repeat(5)] 22 | public void CheckTimestampHashCode() => RunTest(() => DateTime.Now.GetHashCode().ToString(CultureInfo.InvariantCulture)); 23 | 24 | [Test] 25 | public void CheckLengthOfIntegers() 26 | { 27 | Console.WriteLine(int.MaxValue); 28 | Console.WriteLine(int.MaxValue.ToString().Length); 29 | } 30 | 31 | static void RunTest(Func getNextId) 32 | { 33 | var ids = new HashSet(); 34 | 35 | while (true) 36 | { 37 | var id = getNextId(); 38 | 39 | if (ids.Contains(id)) 40 | { 41 | Console.WriteLine($"The ID {id} already exists - found {ids.Count} unique ids"); 42 | return; 43 | } 44 | 45 | ids.Add(id); 46 | 47 | if (ids.Count == 1000 * 1000) 48 | { 49 | Console.WriteLine("OK that was 1M - quitting"); 50 | return; 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/Outbox/IOutboxStorage.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading.Tasks; 3 | using Rebus.Transport; 4 | 5 | namespace Rebus.PostgreSql.Outbox; 6 | 7 | /// 8 | /// Outbox abstraction that enables truly idempotent message processing and store-and-forward for outgoing messages 9 | /// 10 | public interface IOutboxStorage 11 | { 12 | /// 13 | /// Stores the given as being the result of processing message with ID 14 | /// in the queue of this particular endpoint. If is an empty sequence, a note is made of the fact 15 | /// that the message with ID has been processed. 16 | /// 17 | Task Save(IEnumerable outgoingMessages, string messageId = null, string sourceQueue = null, string correlationId = null); 18 | 19 | /// 20 | /// Stores the given using the given . 21 | /// 22 | Task Save(IEnumerable outgoingMessages, IDbConnection dbConnection); 23 | 24 | /// 25 | /// Gets the next message batch to be sent, possibly filtered by the given . MIGHT return messages from other send operations in the rare 26 | /// case where there is a colission between correlation IDs. Returns from 0 to messages in the batch. 27 | /// 28 | Task GetNextMessageBatch(string correlationId = null, int maxMessageBatchSize = 100); 29 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/Outbox/OutboxOutgoingStep.cs: -------------------------------------------------------------------------------- 1 | using Rebus.Config.Outbox; 2 | using Rebus.Pipeline; 3 | using Rebus.Transport; 4 | using System; 5 | using System.Threading.Tasks; 6 | using System.Transactions; 7 | 8 | namespace Rebus.PostgreSql.Outbox; 9 | 10 | class OutboxOutgoingStep : IOutgoingStep 11 | { 12 | readonly IOutboxConnectionProvider _outboxConnectionProvider; 13 | 14 | public OutboxOutgoingStep(IOutboxConnectionProvider outboxConnectionProvider) 15 | { 16 | _outboxConnectionProvider = outboxConnectionProvider; 17 | } 18 | 19 | public Task Process(OutgoingStepContext context, Func next) 20 | { 21 | var transactionContext = context.Load(); 22 | 23 | //in Rebus handler context, outbox is initialized by Incomming step 24 | //not in Rebus handler context and a rebus outbox transaction is explicitly (UseOutbox) created and outbox is initialized 25 | if (transactionContext.Items.ContainsKey(OutboxExtensions.CurrentOutboxConnectionKey)) 26 | { 27 | //all is done 28 | return next(); 29 | } 30 | 31 | //if an ambient transaction exists, create an oubox connection without transaction to connection enroll in current transaction 32 | if (Transaction.Current != null) 33 | { 34 | var outboxConnection = _outboxConnectionProvider.GetDbConnectionWithoutTransaction(); 35 | transactionContext.Items[OutboxExtensions.CurrentOutboxConnectionKey] = outboxConnection; 36 | 37 | transactionContext.OnDisposed(_ => 38 | { 39 | outboxConnection.Connection.Dispose(); 40 | }); 41 | } 42 | 43 | return next(); 44 | } 45 | } 46 | 47 | -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/Timeouts/TestPostgreSqlTimeoutManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using NUnit.Framework; 3 | using Rebus.Logging; 4 | using Rebus.PostgreSql.Timeouts; 5 | using Rebus.Tests.Contracts.Timeouts; 6 | using Rebus.Time; 7 | using Rebus.Timeouts; 8 | 9 | namespace Rebus.PostgreSql.Tests.Timeouts; 10 | 11 | [TestFixture, Category(TestCategory.Postgres)] 12 | public class TestPostgreSqlTimeoutManager : BasicStoreAndRetrieveOperations 13 | { 14 | } 15 | 16 | public class PostgreSqlTimeoutManagerFactory : ITimeoutManagerFactory 17 | { 18 | readonly FakeRebusTime _fakeRebusTime = new FakeRebusTime(); 19 | 20 | public PostgreSqlTimeoutManagerFactory() 21 | { 22 | PostgreSqlTestHelper.DropTable("timeouts"); 23 | } 24 | 25 | public ITimeoutManager Create() 26 | { 27 | var postgreSqlTimeoutManager = new PostgreSqlTimeoutManager(PostgreSqlTestHelper.ConnectionHelper, "timeouts", new ConsoleLoggerFactory(false), _fakeRebusTime); 28 | postgreSqlTimeoutManager.EnsureTableIsCreated(); 29 | return postgreSqlTimeoutManager; 30 | } 31 | 32 | public void Cleanup() 33 | { 34 | PostgreSqlTestHelper.DropTable("timeouts"); 35 | } 36 | 37 | public string GetDebugInfo() 38 | { 39 | return "could not provide debug info for this particular timeout manager.... implement if needed :)"; 40 | } 41 | 42 | public void FakeIt(DateTimeOffset fakeTime) 43 | { 44 | _fakeRebusTime.SetNow(fakeTime); 45 | } 46 | 47 | class FakeRebusTime : IRebusTime 48 | { 49 | Func _nowFactory = () => DateTimeOffset.Now; 50 | 51 | public DateTimeOffset Now => _nowFactory(); 52 | 53 | public void SetNow(DateTimeOffset fakeTime) => _nowFactory = () => fakeTime; 54 | } 55 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/PostgresTestContainerManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Runtime.ExceptionServices; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using NUnit.Framework; 6 | using Rebus.Tests.Contracts.Extensions; 7 | using Testcontainers.PostgreSql; 8 | 9 | namespace Rebus.PostgreSql.Tests; 10 | 11 | [SetUpFixture] 12 | public class PostgresTestContainerManager 13 | { 14 | static PostgreSqlContainer _container; 15 | 16 | public static Lazy TestContainerConnectionString = new(() => 17 | { 18 | _container = new PostgreSqlBuilder().Build(); 19 | 20 | ExceptionDispatchInfo exceptionDispatchInfo = null; 21 | 22 | using var done = new ManualResetEvent(initialState: false); 23 | 24 | Task.Run(async () => 25 | { 26 | try 27 | { 28 | await _container.StartAsync(); 29 | } 30 | catch (Exception exception) 31 | { 32 | exceptionDispatchInfo = ExceptionDispatchInfo.Capture(exception); 33 | } 34 | finally 35 | { 36 | done.Set(); 37 | } 38 | }); 39 | 40 | done.WaitOrDie(TimeSpan.FromMinutes(1), "PostgreSQL container did not start within 1 minute"); 41 | exceptionDispatchInfo?.Throw(); 42 | 43 | return _container.GetConnectionString(); 44 | }); 45 | 46 | [OneTimeTearDown] 47 | public void StopContainer() 48 | { 49 | async Task StopAndDispose() 50 | { 51 | try 52 | { 53 | await _container.StopAsync(); 54 | } 55 | finally 56 | { 57 | await _container.DisposeAsync(); 58 | _container = null; 59 | } 60 | } 61 | 62 | Task.Run(StopAndDispose).GetAwaiter().GetResult(); 63 | } 64 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/IDbConnection.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using Npgsql; 5 | using NpgsqlTypes; 6 | 7 | namespace Rebus.PostgreSql; 8 | 9 | /// 10 | /// Wrapper of that allows for easily changing how transactions are handled, and possibly how instances 11 | /// are reused by various services 12 | /// 13 | public interface IDbConnection : IDisposable 14 | { 15 | /// 16 | /// Creates a ready to used 17 | /// 18 | NpgsqlCommand CreateCommand(); 19 | 20 | /// 21 | /// Gets the names of all the tables in the current database for the current schema 22 | /// 23 | IEnumerable GetTableNames(); 24 | 25 | /// 26 | /// Marks that all work has been successfully done and the may have its transaction committed or whatever is natural to do at this time 27 | /// 28 | Task Complete(); 29 | 30 | /// 31 | /// Gets information about the columns in the table given by [].[] 32 | /// 33 | IEnumerable GetColumns(string schema, string dataTableName); 34 | } 35 | 36 | /// 37 | /// Represents a PostgreSql Server column 38 | /// 39 | public class DbColumn 40 | { 41 | /// 42 | /// Gets the name of the column 43 | /// 44 | public string Name { get; } 45 | 46 | /// 47 | /// Gets the SQL datatype of the column 48 | /// 49 | public NpgsqlDbType Type { get; } 50 | 51 | /// 52 | /// Creates the column 53 | /// 54 | public DbColumn(string name, NpgsqlDbType type) 55 | { 56 | Name = name; 57 | Type = type; 58 | } 59 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/Sagas/PostgreSqlSnapshotStorageFactory.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Rebus.Auditing.Sagas; 3 | using Rebus.PostgreSql.Sagas; 4 | using Rebus.Sagas; 5 | using Rebus.Serialization; 6 | using Rebus.Tests.Contracts.Sagas; 7 | 8 | namespace Rebus.PostgreSql.Tests.Sagas; 9 | 10 | public class PostgreSqlSnapshotStorageFactory : ISagaSnapshotStorageFactory 11 | { 12 | const string TableName = "SagaSnaps"; 13 | 14 | public PostgreSqlSnapshotStorageFactory() => PostgreSqlTestHelper.DropTable(TableName); 15 | 16 | public ISagaSnapshotStorage Create() 17 | { 18 | var snapshotStorage = new PostgreSqlSagaSnapshotStorage(PostgreSqlTestHelper.ConnectionHelper, TableName); 19 | 20 | snapshotStorage.EnsureTableIsCreated(); 21 | 22 | return snapshotStorage; 23 | } 24 | 25 | public IEnumerable GetAllSnapshots() 26 | { 27 | using var connection = PostgreSqlTestHelper.ConnectionHelper.GetConnection().Result; 28 | using var command = connection.CreateCommand(); 29 | command.CommandText = $@"SELECT ""data"", ""metadata"" FROM ""{TableName}"""; 30 | 31 | using var reader = command.ExecuteReader(); 32 | while (reader.Read()) 33 | { 34 | var data = (byte[])reader["data"]; 35 | var metadataString = (string)reader["metadata"]; 36 | 37 | var objectSerializer = new ObjectSerializer(); 38 | var dictionarySerializer = new DictionarySerializer(); 39 | 40 | var sagaData = objectSerializer.Deserialize(data); 41 | var metadata = dictionarySerializer.DeserializeFromString(metadataString); 42 | 43 | yield return new SagaDataSnapshot 44 | { 45 | SagaData = (ISagaData) sagaData, 46 | Metadata = metadata 47 | }; 48 | } 49 | } 50 | 51 | public void Dispose() => PostgreSqlTestHelper.DropTable(TableName); 52 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/Sagas/JsonSagaSerializer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text; 3 | using Newtonsoft.Json; 4 | 5 | namespace Rebus.PostgreSql.Sagas; 6 | 7 | public class JsonSagaSerializer : ISagaSerializer 8 | { 9 | readonly JsonSerializerSettings _settings; 10 | 11 | static readonly Encoding TextEncoding = Encoding.UTF8; 12 | 13 | static readonly JsonSerializerSettings DefaultSettings = new JsonSerializerSettings 14 | { 15 | TypeNameHandling = TypeNameHandling.All 16 | }; 17 | 18 | public JsonSagaSerializer(JsonSerializerSettings jsonSerializerSettings = null) 19 | { 20 | _settings = jsonSerializerSettings ?? DefaultSettings; 21 | } 22 | /// 23 | /// Serializes the given object into a byte[] 24 | /// 25 | public byte[] Serialize(object obj) 26 | { 27 | var jsonString = SerializeToString(obj); 28 | 29 | return TextEncoding.GetBytes(jsonString); 30 | } 31 | 32 | /// 33 | /// Serializes the given object into a string 34 | /// 35 | public string SerializeToString(object obj) 36 | { 37 | return JsonConvert.SerializeObject(obj, _settings); 38 | } 39 | 40 | /// 41 | /// Deserializes the given byte[] into an object 42 | /// 43 | public object Deserialize(byte[] bytes) 44 | { 45 | var jsonString = TextEncoding.GetString(bytes); 46 | 47 | return DeserializeFromString(jsonString); 48 | } 49 | 50 | /// 51 | /// Deserializes the given string into an object 52 | /// 53 | public object DeserializeFromString(string str) 54 | { 55 | try 56 | { 57 | return JsonConvert.DeserializeObject(str, _settings); 58 | } 59 | catch (Exception exception) 60 | { 61 | throw new JsonSerializationException($"Could not deserialize '{str}'", exception); 62 | } 63 | } 64 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/Reflection/Ponder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq.Expressions; 3 | 4 | namespace Rebus.PostgreSql.Reflection; 5 | 6 | class Reflect 7 | { 8 | public static string Path(Expression> expression) 9 | { 10 | return GetPropertyName(expression); 11 | } 12 | 13 | public static object Value(object obj, string path) 14 | { 15 | var dots = path.Split('.'); 16 | 17 | foreach(var dot in dots) 18 | { 19 | var propertyInfo = obj.GetType().GetProperty(dot); 20 | if (propertyInfo == null) return null; 21 | obj = propertyInfo.GetValue(obj, Array.Empty()); 22 | if (obj == null) break; 23 | } 24 | 25 | return obj; 26 | } 27 | 28 | static string GetPropertyName(Expression expression) 29 | { 30 | if (expression == null) return ""; 31 | 32 | if (expression is LambdaExpression) 33 | { 34 | expression = ((LambdaExpression) expression).Body; 35 | } 36 | 37 | if (expression is UnaryExpression) 38 | { 39 | expression = ((UnaryExpression)expression).Operand; 40 | } 41 | 42 | if (expression is MemberExpression) 43 | { 44 | dynamic memberExpression = expression; 45 | 46 | var lambdaExpression = (Expression)memberExpression.Expression; 47 | 48 | string prefix; 49 | if (lambdaExpression != null) 50 | { 51 | prefix = GetPropertyName(lambdaExpression); 52 | if (!string.IsNullOrEmpty(prefix)) 53 | { 54 | prefix += "."; 55 | } 56 | } 57 | else 58 | { 59 | prefix = ""; 60 | } 61 | 62 | var propertyName = memberExpression.Member.Name; 63 | 64 | return prefix + propertyName; 65 | } 66 | 67 | return ""; 68 | } 69 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributions are most welcome! :) 2 | 3 | I do prefer it if we communicate a little bit before you send PRs, though. 4 | This is because _I value your time_, and it would be a shame if you spent time 5 | working on something that could be made better in another way, or wasn't 6 | actually needed because what you wanted to achieve could be done better in 7 | another way, etc. 8 | 9 | ## "Beginners" 10 | 11 | Contributions are ALSO very welcome if you consider yourself a beginner 12 | at open source. Everyone has to start somewhere, right? 13 | 14 | Here's how you would ideally do it if you were to contribute to Rebus: 15 | 16 | * Pick an [issue](https://github.com/rebus-org/Rebus/issues) you're interested in doing, 17 | or dream up something yourself that you feel is missing. 18 | * If you talk to me first (either via comments on the issue or by email), I 19 | will guarantee that your contribution is accepted. 20 | * Send me a "pull request" (which is how you make contributions on GitHub) 21 | 22 | ### Here's how you create a pull request/PR 23 | 24 | * Fork Rebus ([Fork A Repo @ GitHub docs](https://help.github.com/articles/fork-a-repo/)) 25 | * Clone your fork ([Cloning A Repository @ GitHub docs](https://help.github.com/articles/cloning-a-repository/)) 26 | * Make changes to your local copy (e.g. `git commit -am"bam!!!11"` - check [this](https://git-scm.com/book/en/v2/Git-Basics-Recording-Changes-to-the-Repository) out for more info) 27 | * Push your local changes to your fork ([Pushing To A Remote @ GitHub docs](https://help.github.com/articles/pushing-to-a-remote/)) 28 | * Send me a pull request ([Using Pull Requests @ GitHub docs](https://help.github.com/articles/using-pull-requests/)) 29 | 30 | When you do this, your changes become visible to me. I can then review it, and we can discuss 31 | each line of code if necessary. 32 | 33 | If you push additional changes to your fork during this process, 34 | the changes become immediately available in the pull request. 35 | 36 | When all is good, I accept your PR by merging it, and then you're (even more) awesome! 37 | -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/Outbox/OutboxClientTransportDecorator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using Rebus.Config.Outbox; 6 | using Rebus.Messages; 7 | using Rebus.Transport; 8 | // ReSharper disable ArgumentsStyleLiteral 9 | 10 | namespace Rebus.PostgreSql.Outbox; 11 | 12 | class OutboxClientTransportDecorator : ITransport 13 | { 14 | const string OutgoingMessagesKey = "outbox-outgoing-messages"; 15 | readonly ITransport _transport; 16 | readonly IOutboxStorage _outboxStorage; 17 | 18 | public OutboxClientTransportDecorator(ITransport transport, IOutboxStorage outboxStorage) 19 | { 20 | _transport = transport ?? throw new ArgumentNullException(nameof(transport)); 21 | _outboxStorage = outboxStorage ?? throw new ArgumentNullException(nameof(outboxStorage)); 22 | } 23 | 24 | public void CreateQueue(string address) => _transport.CreateQueue(address); 25 | 26 | public Task Send(string destinationAddress, TransportMessage message, ITransactionContext context) 27 | { 28 | var connection = context.GetOrNull(OutboxExtensions.CurrentOutboxConnectionKey); 29 | 30 | if (connection == null) 31 | { 32 | return _transport.Send(destinationAddress, message, context); 33 | } 34 | 35 | var outgoingMessages = context.GetOrAdd(OutgoingMessagesKey, () => 36 | { 37 | var queue = new ConcurrentQueue(); 38 | var dbConnectionWrapper = new DbConnectionWrapper(connection.Connection, connection.Transaction, managedExternally: true); 39 | context.OnCommit(async _ => await _outboxStorage.Save(queue, dbConnectionWrapper)); 40 | return queue; 41 | }); 42 | 43 | outgoingMessages.Enqueue(new OutgoingTransportMessage(message, destinationAddress)); 44 | 45 | return Task.CompletedTask; 46 | } 47 | 48 | public Task Receive(ITransactionContext context, CancellationToken cancellationToken) => _transport.Receive(context, cancellationToken); 49 | 50 | public string Address => _transport.Address; 51 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/Transport/TestPostgreSqlTransportCleanup.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using NUnit.Framework; 6 | using Rebus.Activation; 7 | using Rebus.Config; 8 | using Rebus.Logging; 9 | using Rebus.Tests.Contracts; 10 | using Rebus.Tests.Contracts.Extensions; 11 | using Rebus.Tests.Contracts.Utilities; 12 | 13 | namespace Rebus.PostgreSql.Tests.Transport; 14 | 15 | [TestFixture] 16 | public class TestPostgreSqlTransportCleanup : FixtureBase 17 | { 18 | BuiltinHandlerActivator _activator; 19 | ListLoggerFactory _loggerFactory; 20 | IBusStarter _starter; 21 | 22 | protected override void SetUp() 23 | { 24 | var queueName = TestConfig.GetName("connection_timeout"); 25 | 26 | _activator = new BuiltinHandlerActivator(); 27 | 28 | Using(_activator); 29 | 30 | _loggerFactory = new ListLoggerFactory(outputToConsole: true); 31 | 32 | _starter = Configure.With(_activator) 33 | .Logging(l => l.Use(_loggerFactory)) 34 | .Transport(t => t.UsePostgreSql(PostgreSqlTestHelper.ConnectionString, "Messages", queueName)) 35 | .Create(); 36 | } 37 | 38 | [Test] 39 | public async Task DoesNotBarfInTheBackground() 40 | { 41 | var doneHandlingMessage = new ManualResetEvent(false); 42 | 43 | _activator.Handle(async str => 44 | { 45 | for (var count = 0; count < 5; count++) 46 | { 47 | Console.WriteLine("waiting..."); 48 | await Task.Delay(TimeSpan.FromSeconds(20)); 49 | } 50 | 51 | Console.WriteLine("done waiting!"); 52 | 53 | doneHandlingMessage.Set(); 54 | }); 55 | 56 | _starter.Start(); 57 | 58 | await _activator.Bus.SendLocal("hej med dig min ven!"); 59 | 60 | doneHandlingMessage.WaitOrDie(TimeSpan.FromMinutes(2)); 61 | 62 | var logLinesAboveInformation = _loggerFactory 63 | .Where(l => l.Level >= LogLevel.Warn) 64 | .ToList(); 65 | 66 | Assert.That(!logLinesAboveInformation.Any(), "Expected no warnings - got this: {0}", 67 | string.Join(Environment.NewLine, logLinesAboveInformation)); 68 | } 69 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/PostgresConnectionHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Data; 3 | using System.Threading.Tasks; 4 | using Npgsql; 5 | 6 | namespace Rebus.PostgreSql; 7 | 8 | /// 9 | /// Helps with managing s 10 | /// 11 | public class PostgresConnectionHelper : IPostgresConnectionProvider 12 | { 13 | readonly string _connectionString; 14 | readonly Action _additionalConnectionSetupCallback; 15 | 16 | /// 17 | /// Constructs this thingie 18 | /// 19 | public PostgresConnectionHelper(string connectionString) 20 | { 21 | _connectionString = connectionString; 22 | } 23 | 24 | /// 25 | /// Constructs this thingie 26 | /// 27 | /// Connection string. 28 | /// Additional setup to be performed prior to opening each connection. 29 | /// Useful for configuring client certificate authentication, as well as set up other callbacks. 30 | public PostgresConnectionHelper(string connectionString, Action additionalConnectionSetupCallback) 31 | { 32 | _connectionString = connectionString; 33 | _additionalConnectionSetupCallback = additionalConnectionSetupCallback; 34 | } 35 | 36 | 37 | /// 38 | /// Gets a fresh, open and ready-to-use connection wrapper 39 | /// 40 | public async Task GetConnection() 41 | { 42 | var connection = new NpgsqlConnection(_connectionString); 43 | 44 | if (_additionalConnectionSetupCallback != null) 45 | _additionalConnectionSetupCallback.Invoke(connection); 46 | 47 | await connection.OpenAsync(); 48 | var transaction = System.Transactions.Transaction.Current; 49 | if (transaction != null) 50 | { 51 | connection.EnlistTransaction(transaction); 52 | return new PostgresConnection(connection); 53 | } 54 | else 55 | { 56 | var currentTransaction = connection.BeginTransaction(IsolationLevel.ReadCommitted); 57 | return new PostgresConnection(connection, currentTransaction); 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql.sln: -------------------------------------------------------------------------------- 1 | Microsoft Visual Studio Solution File, Format Version 12.00 2 | # Visual Studio Version 16 3 | VisualStudioVersion = 16.0.29728.190 4 | MinimumVisualStudioVersion = 10.0.40219.1 5 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "stuff", "stuff", "{1ABDD7C1-1F8D-402D-8692-3E4B66962DE0}" 6 | ProjectSection(SolutionItems) = preProject 7 | CHANGELOG.md = CHANGELOG.md 8 | CONTRIBUTING.md = CONTRIBUTING.md 9 | LICENSE.md = LICENSE.md 10 | README.md = README.md 11 | EndProjectSection 12 | EndProject 13 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Rebus.PostgreSql", "Rebus.PostgreSql\Rebus.PostgreSql.csproj", "{DC73C2BE-BC8C-4700-8804-70C70D3384F6}" 14 | EndProject 15 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Rebus.PostgreSql.Tests", "Rebus.PostgreSql.Tests\Rebus.PostgreSql.Tests.csproj", "{7ECBEE2F-C448-40B0-8C5F-6F157AB4E79F}" 16 | EndProject 17 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{51316548-FAA3-47D9-ADBA-D135AB8213AE}" 18 | ProjectSection(SolutionItems) = preProject 19 | appveyor.yml = appveyor.yml 20 | EndProjectSection 21 | EndProject 22 | Global 23 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 24 | Debug|Any CPU = Debug|Any CPU 25 | Release|Any CPU = Release|Any CPU 26 | EndGlobalSection 27 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 28 | {DC73C2BE-BC8C-4700-8804-70C70D3384F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 29 | {DC73C2BE-BC8C-4700-8804-70C70D3384F6}.Debug|Any CPU.Build.0 = Debug|Any CPU 30 | {DC73C2BE-BC8C-4700-8804-70C70D3384F6}.Release|Any CPU.ActiveCfg = Release|Any CPU 31 | {DC73C2BE-BC8C-4700-8804-70C70D3384F6}.Release|Any CPU.Build.0 = Release|Any CPU 32 | {7ECBEE2F-C448-40B0-8C5F-6F157AB4E79F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 33 | {7ECBEE2F-C448-40B0-8C5F-6F157AB4E79F}.Debug|Any CPU.Build.0 = Debug|Any CPU 34 | {7ECBEE2F-C448-40B0-8C5F-6F157AB4E79F}.Release|Any CPU.ActiveCfg = Release|Any CPU 35 | {7ECBEE2F-C448-40B0-8C5F-6F157AB4E79F}.Release|Any CPU.Build.0 = Release|Any CPU 36 | EndGlobalSection 37 | GlobalSection(SolutionProperties) = preSolution 38 | HideSolutionNode = FALSE 39 | EndGlobalSection 40 | GlobalSection(ExtensibilityGlobals) = postSolution 41 | SolutionGuid = {F0B87093-B81F-4507-A95C-D4F000185A5B} 42 | EndGlobalSection 43 | EndGlobal 44 | -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/Outbox/OutboxMessageBatch.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | 7 | namespace Rebus.PostgreSql.Outbox; 8 | 9 | /// 10 | /// Wraps a batch of s along with a function that "completes" the batch (i.e. ensures that it will not be handled again -... e.g. by deleting it, or marking it as completed) 11 | /// 12 | public class OutboxMessageBatch : IDisposable, IReadOnlyList 13 | { 14 | /// 15 | /// Gets an empty outbox message batch that doesn't complete anything and only performs some kind of cleanup when done 16 | /// 17 | public static OutboxMessageBatch Empty(Action disposeFunction) => new(() => Task.CompletedTask, Array.Empty(), disposeFunction); 18 | 19 | readonly IReadOnlyList _messages; 20 | readonly Func _completionFunction; 21 | readonly Action _disposeFunction; 22 | 23 | /// 24 | /// Creates the batch 25 | /// 26 | public OutboxMessageBatch(Func completionFunction, IEnumerable messages, Action disposeFunction) 27 | { 28 | _messages = messages.ToList(); 29 | _completionFunction = completionFunction ?? throw new ArgumentNullException(nameof(completionFunction)); 30 | _disposeFunction = disposeFunction; 31 | } 32 | 33 | /// 34 | /// Marks the message batch as properly handled 35 | /// 36 | public async Task Complete() => await _completionFunction(); 37 | 38 | /// 39 | /// Performs any cleanup actions necessary 40 | /// 41 | public void Dispose() => _disposeFunction(); 42 | 43 | /// 44 | /// Gets how many 45 | /// 46 | public int Count => _messages.Count; 47 | 48 | /// 49 | /// Gets by index 50 | /// 51 | public OutboxMessage this[int index] => _messages[index]; 52 | 53 | /// 54 | /// Gets an enumerator for the wrapped sequence of s 55 | /// 56 | public IEnumerator GetEnumerator() => _messages.GetEnumerator(); 57 | 58 | IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); 59 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/Transport/TestPostgreSqlTransportReceivePerformance.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using NUnit.Framework; 6 | using Rebus.Activation; 7 | using Rebus.Config; 8 | using Rebus.Tests.Contracts; 9 | using Rebus.Tests.Contracts.Utilities; 10 | using Rebus.Logging; 11 | 12 | 13 | #pragma warning disable 1998 14 | 15 | namespace Rebus.PostgreSql.Tests.Transport; 16 | 17 | [TestFixture, Category(Categories.PostgreSql)] 18 | public class TestPostgreSqlTransportReceivePerformance : FixtureBase 19 | { 20 | BuiltinHandlerActivator _adapter; 21 | 22 | const string QueueName = "perftest"; 23 | 24 | static readonly string TableName = TestConfig.GetName("Messages"); 25 | 26 | protected override void SetUp() 27 | { 28 | PostgreSqlTestHelper.DropTable(TableName); 29 | 30 | _adapter = Using(new BuiltinHandlerActivator()); 31 | 32 | Configure.With(_adapter) 33 | .Logging(l => l.ColoredConsole(LogLevel.Warn)) 34 | .Transport(t => t.UsePostgreSql(PostgreSqlTestHelper.ConnectionString, TableName, QueueName)) 35 | .Options(o => 36 | { 37 | o.SetNumberOfWorkers(0); 38 | o.SetMaxParallelism(20); 39 | }) 40 | .Start(); 41 | } 42 | 43 | [TestCase(1000)] 44 | [TestCase(10000)] 45 | public async Task CheckReceivePerformance(int messageCount) 46 | { 47 | 48 | Console.WriteLine($"Sending {messageCount} messages..."); 49 | 50 | await Task.WhenAll(Enumerable.Range(0, messageCount) 51 | .Select(i => _adapter.Bus.SendLocal($"THIS IS MESSAGE {i}"))); 52 | 53 | var counter = Using(new SharedCounter(messageCount)); 54 | 55 | _adapter.Handle(async message => counter.Decrement()); 56 | 57 | Console.WriteLine("Waiting for messages to be received..."); 58 | 59 | var stopwtach = Stopwatch.StartNew(); 60 | 61 | _adapter.Bus.Advanced.Workers.SetNumberOfWorkers(3); 62 | 63 | counter.WaitForResetEvent(timeoutSeconds: messageCount / 100 + 5); 64 | 65 | var elapsedSeconds = stopwtach.Elapsed.TotalSeconds; 66 | 67 | Console.WriteLine( 68 | $"{messageCount} messages received in {elapsedSeconds:0.0} s - that's {messageCount/elapsedSeconds:0.0} msg/s"); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/CustomPostgresConnectionProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Data; 3 | using System.Threading.Tasks; 4 | using Npgsql; 5 | // ReSharper disable UnusedMember.Global 6 | 7 | namespace Rebus.PostgreSql; 8 | 9 | /// 10 | /// Implementation of that provides the connection using a user-provided function. 11 | /// Will optionally start transactions whenever the connection is provided. 12 | /// 13 | /// 14 | public class CustomPostgresConnectionProvider : IPostgresConnectionProvider 15 | { 16 | readonly Func> _provideConnection; 17 | readonly bool _autoStartTransactions; 18 | 19 | /// 20 | /// Constructor that allows specifying transaction behavior 21 | /// Defaults to not starting transactions 22 | /// 23 | /// Function that will provide an asynchronous when invoked 24 | /// Whether to automatically start transaction every time a new connection is provided 25 | public CustomPostgresConnectionProvider(Func> provideConnection, bool autoStartTransactions = false) 26 | { 27 | _provideConnection = provideConnection; 28 | _autoStartTransactions = autoStartTransactions; 29 | } 30 | 31 | 32 | 33 | /// 34 | /// Constructor that allows specifying transaction behavior 35 | /// Defaults to not starting transactions 36 | /// Allows providing the connection through a non-async Func 37 | /// 38 | /// Function that will provide an when invoked 39 | /// Whether to automatically start transaction every time a new connection is provided 40 | public CustomPostgresConnectionProvider(Func provideConnection, bool autoStartTransactions = false) : this(()=>Task.FromResult(provideConnection()), autoStartTransactions) 41 | { 42 | } 43 | 44 | 45 | 46 | /// 47 | /// Getst the connection by using user-provided Func. Will optionally start a transaction on the connection if configured to do so. 48 | /// 49 | /// 50 | /// The object wrapping the connection and transaction 51 | public async Task GetConnection() 52 | { 53 | var connection = await _provideConnection(); 54 | var transaction = _autoStartTransactions ? connection.BeginTransaction(IsolationLevel.ReadCommitted) : null; 55 | return new PostgresConnection(connection, transaction); 56 | } 57 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/Transport/PostgreSqlTransportTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using NUnit.Framework; 4 | using Rebus.Logging; 5 | using Rebus.PostgreSql.Transport; 6 | using Rebus.Tests.Contracts; 7 | using Rebus.Tests.Contracts.Transports; 8 | using Rebus.Threading.TaskParallelLibrary; 9 | using Rebus.Time; 10 | using Rebus.Transport; 11 | 12 | namespace Rebus.PostgreSql.Tests.Transport; 13 | 14 | public class PostgreSqlTransportFactory : ITransportFactory 15 | { 16 | 17 | readonly HashSet _tablesToDrop = new HashSet(); 18 | readonly List _disposables = new List(); 19 | 20 | 21 | [TestFixture, Category(Categories.PostgreSql)] 22 | public class PostgreSqlTransportBasicSendReceive : BasicSendReceive { } 23 | 24 | [TestFixture, Category(Categories.PostgreSql)] 25 | public class PostgreSqlTransportMessageExpiration : MessageExpiration { } 26 | 27 | 28 | public ITransport CreateOneWayClient() 29 | { 30 | var tableName = ("rebus_messages_" + TestConfig.Suffix).TrimEnd('_'); 31 | _tablesToDrop.Add(tableName); 32 | 33 | var consoleLoggerFactory = new ConsoleLoggerFactory(false); 34 | var connectionHelper = new PostgresConnectionHelper(PostgreSqlTestHelper.ConnectionString); 35 | var asyncTaskFactory = new TplAsyncTaskFactory(consoleLoggerFactory); 36 | var transport = new PostgreSqlTransport(connectionHelper, tableName, null, consoleLoggerFactory, asyncTaskFactory, new DefaultRebusTime()); 37 | 38 | _disposables.Add(transport); 39 | 40 | transport.EnsureTableIsCreated(); 41 | transport.Initialize(); 42 | 43 | return transport; 44 | } 45 | 46 | public ITransport Create(string inputQueueAddress) 47 | { 48 | var tableName = ("rebus_messages_" + TestConfig.Suffix).TrimEnd('_'); 49 | 50 | _tablesToDrop.Add(tableName); 51 | 52 | var consoleLoggerFactory = new ConsoleLoggerFactory(false); 53 | var connectionHelper = new PostgresConnectionHelper(PostgreSqlTestHelper.ConnectionString); 54 | var asyncTaskFactory = new TplAsyncTaskFactory(consoleLoggerFactory); 55 | var transport = new PostgreSqlTransport(connectionHelper, tableName, inputQueueAddress, consoleLoggerFactory, asyncTaskFactory, new DefaultRebusTime()); 56 | 57 | _disposables.Add(transport); 58 | 59 | transport.EnsureTableIsCreated(); 60 | transport.Initialize(); 61 | 62 | return transport; 63 | } 64 | 65 | public void CleanUp() 66 | { 67 | _disposables.ForEach(d => d.Dispose()); 68 | _disposables.Clear(); 69 | 70 | foreach (var tableName in _tablesToDrop) 71 | { 72 | PostgreSqlTestHelper.DropTable(tableName); 73 | } 74 | 75 | _tablesToDrop.Clear(); 76 | } 77 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/Bugs/PublishWithinTransactionScopeTests.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using System.Transactions; 5 | using NUnit.Framework; 6 | using Rebus.Extensions; 7 | using Rebus.Logging; 8 | using Rebus.Messages; 9 | using Rebus.PostgreSql.Transport; 10 | using Rebus.Tests.Contracts; 11 | using Rebus.Threading.TaskParallelLibrary; 12 | using Rebus.Time; 13 | using Rebus.Transport; 14 | 15 | namespace Rebus.PostgreSql.Tests.Bugs; 16 | 17 | [TestFixture, Category(Categories.PostgreSql)] 18 | public class PublishWithinTransactionScopeTests : FixtureBase 19 | { 20 | readonly string _tableName = "messages" + TestConfig.Suffix; 21 | PostgreSqlTransport _transport; 22 | CancellationToken _cancellationToken; 23 | const string QueueName = "input"; 24 | 25 | protected override void SetUp() 26 | { 27 | PostgreSqlTestHelper.DropTable(_tableName); 28 | var consoleLoggerFactory = new ConsoleLoggerFactory(false); 29 | var asyncTaskFactory = new TplAsyncTaskFactory(consoleLoggerFactory); 30 | var connectionHelper = new PostgresConnectionHelper(PostgreSqlTestHelper.ConnectionString); 31 | _transport = new PostgreSqlTransport(connectionHelper, _tableName, QueueName, consoleLoggerFactory, asyncTaskFactory, new DefaultRebusTime()); 32 | _transport.EnsureTableIsCreated(); 33 | 34 | Using(_transport); 35 | 36 | _transport.Initialize(); 37 | _cancellationToken = new CancellationTokenSource().Token; 38 | } 39 | 40 | [Test] 41 | public async Task ReceivesSentMessageWhenTransactionIsCommittedFromWithinAmbientDotNetTransactionScope() 42 | { 43 | using (var txScope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) 44 | { 45 | using (var scope = new RebusTransactionScope()) 46 | { 47 | await _transport.Send(QueueName, RecognizableMessage(), scope.TransactionContext); 48 | 49 | await scope.CompleteAsync(); 50 | } 51 | txScope.Complete(); 52 | } 53 | 54 | using (var scope = new RebusTransactionScope()) 55 | { 56 | var transportMessage = await _transport.Receive(scope.TransactionContext, _cancellationToken); 57 | 58 | await scope.CompleteAsync(); 59 | 60 | AssertMessageIsRecognized(transportMessage); 61 | } 62 | } 63 | 64 | void AssertMessageIsRecognized(TransportMessage transportMessage) 65 | { 66 | Assert.That(transportMessage.Headers.GetValue("recognizzle"), Is.EqualTo("hej")); 67 | } 68 | 69 | static TransportMessage RecognizableMessage(int id = 0) 70 | { 71 | var headers = new Dictionary 72 | { 73 | {"recognizzle", "hej"}, 74 | {"id", id.ToString()} 75 | }; 76 | return new TransportMessage(headers, new byte[] { 1, 2, 3 }); 77 | } 78 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/Bugs/VerifyRebusTransactionScope.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | using NUnit.Framework; 4 | using Rebus.Activation; 5 | using Rebus.Bus; 6 | using Rebus.Config; 7 | using Rebus.Tests.Contracts; 8 | using Rebus.Transport; 9 | 10 | #pragma warning disable 1998 11 | 12 | namespace Rebus.PostgreSql.Tests.Bugs; 13 | 14 | [TestFixture] 15 | public class VerifyRebusTransactionScope : FixtureBase 16 | { 17 | IBus _bus; 18 | PostgresConnectionCounter _counter; 19 | 20 | protected override void SetUp() 21 | { 22 | var activator = new BuiltinHandlerActivator(); 23 | 24 | Using(activator); 25 | 26 | activator.Handle(async _ => { }); 27 | 28 | _counter = new PostgresConnectionCounter( 29 | new PostgresConnectionHelper(PostgreSqlTestHelper.ConnectionString)); 30 | 31 | _bus = Configure.With(activator) 32 | .Transport(t => t.UsePostgreSql(_counter, "messages", "atomicity")) 33 | .Options(o => o.SetNumberOfWorkers(0)) 34 | .Start(); 35 | } 36 | 37 | [Test] 38 | public async Task CommitsMultipleSendsAsOneTransactionWhenUsingTransactionScope() 39 | { 40 | _counter.Reset(); 41 | 42 | Assert.That(_counter.Connections, Is.EqualTo(0)); 43 | 44 | using (var scope = new RebusTransactionScope()) 45 | { 46 | await _bus.SendLocal("HEJ"); 47 | await _bus.SendLocal("HEJ"); 48 | await _bus.SendLocal("HEJ"); 49 | await _bus.SendLocal("HEJ"); 50 | await _bus.SendLocal("HEJ"); 51 | 52 | await scope.CompleteAsync(); 53 | } 54 | 55 | Assert.That(_counter.Connections, Is.EqualTo(1)); 56 | } 57 | 58 | [Test] 59 | public async Task DoesNotCommitMultipleSendsAsOneTransactionWhenThereIsNoTransactionScope() 60 | { 61 | _counter.Reset(); 62 | 63 | Assert.That(_counter.Connections, Is.EqualTo(0)); 64 | 65 | await _bus.SendLocal("HEJ"); 66 | await _bus.SendLocal("HEJ"); 67 | await _bus.SendLocal("HEJ"); 68 | await _bus.SendLocal("HEJ"); 69 | await _bus.SendLocal("HEJ"); 70 | 71 | Assert.That(_counter.Connections, Is.EqualTo(5)); 72 | } 73 | 74 | class PostgresConnectionCounter : IPostgresConnectionProvider 75 | { 76 | readonly PostgresConnectionHelper _innerPostgresConnectionHelper; 77 | 78 | long _connections; 79 | 80 | public PostgresConnectionCounter(PostgresConnectionHelper innerPostgresConnectionHelper) 81 | { 82 | _innerPostgresConnectionHelper = innerPostgresConnectionHelper; 83 | } 84 | 85 | public Task GetConnection() 86 | { 87 | Interlocked.Increment(ref _connections); 88 | return _innerPostgresConnectionHelper.GetConnection(); 89 | } 90 | 91 | public void Reset() => Interlocked.Exchange(ref _connections, 0); 92 | 93 | public long Connections => Interlocked.Read(ref _connections); 94 | } 95 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql/Config/Outbox/SqlServerOutboxConfigurationExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Rebus.Logging; 3 | using Rebus.Pipeline; 4 | using Rebus.Retry.Simple; 5 | using Rebus.PostgreSql.Outbox; 6 | using Rebus.Threading; 7 | using Rebus.Transport; 8 | using Rebus.Pipeline.Send; 9 | 10 | namespace Rebus.Config.Outbox; 11 | 12 | /// 13 | /// Configuration extensions for the experimental outbox support 14 | /// 15 | public static class PostgreSqlOutboxConfigurationExtensions 16 | { 17 | /// 18 | /// Configures Rebus to use an outbox. 19 | /// This will store a (message ID, source queue) tuple for all processed messages, and under this tuple any messages sent/published will 20 | /// also be stored, thus enabling truly idempotent message processing. 21 | /// 22 | public static RebusConfigurer Outbox(this RebusConfigurer configurer, Action> configure) 23 | { 24 | if (configurer == null) throw new ArgumentNullException(nameof(configurer)); 25 | if (configure == null) throw new ArgumentNullException(nameof(configure)); 26 | 27 | configurer.Options(o => 28 | { 29 | configure(StandardConfigurer.GetConfigurerFrom(o)); 30 | 31 | // if no outbox storage was registered, no further calls must have been made... that's ok, so we just bail out here 32 | if (!o.Has()) return; 33 | 34 | o.Decorate(c => new OutboxClientTransportDecorator(c.Get(), c.Get())); 35 | 36 | o.Register(c => 37 | { 38 | var asyncTaskFactory = c.Get(); 39 | var rebusLoggerFactory = c.Get(); 40 | var outboxStorage = c.Get(); 41 | var transport = c.Get(); 42 | return new OutboxForwarder(asyncTaskFactory, rebusLoggerFactory, outboxStorage, transport); 43 | }); 44 | 45 | o.Decorate(c => 46 | { 47 | _ = c.Get(); 48 | return c.Get(); 49 | }); 50 | 51 | o.Decorate(c => 52 | { 53 | var pipeline = c.Get(); 54 | var outboxConnectionProvider = c.Get(); 55 | var step = new OutboxIncomingStep(outboxConnectionProvider); 56 | return new PipelineStepInjector(pipeline) 57 | .OnReceive(step, PipelineRelativePosition.After, typeof(DefaultRetryStep)); 58 | }); 59 | 60 | o.Decorate(c => 61 | { 62 | var pipeline = c.Get(); 63 | var outboxConnectionProvider = c.Get(); 64 | var step = new OutboxOutgoingStep(outboxConnectionProvider); 65 | return new PipelineStepInjector(pipeline) 66 | .OnSend(step, PipelineRelativePosition.Before, typeof(SendOutgoingMessageStep)); 67 | }); 68 | }); 69 | 70 | return configurer; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/PostgresConnection.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Npgsql; 4 | // ReSharper disable EmptyGeneralCatchClause 5 | #pragma warning disable 1998 6 | 7 | namespace Rebus.PostgreSql; 8 | 9 | /// 10 | /// Wraps an opened and makes it easier to work with it 11 | /// 12 | public class PostgresConnection : IDisposable 13 | { 14 | readonly NpgsqlConnection _currentConnection; 15 | NpgsqlTransaction _currentTransaction; 16 | 17 | readonly bool _managedExternally; 18 | bool _disposed; 19 | 20 | /// 21 | /// Constructs the wrapper with the given connection and transaction 22 | /// 23 | public PostgresConnection(NpgsqlConnection currentConnection, NpgsqlTransaction currentTransaction, bool managedExternally = false) 24 | { 25 | _currentConnection = currentConnection ?? throw new ArgumentNullException(nameof(currentConnection)); 26 | _currentTransaction = currentTransaction ?? throw new ArgumentNullException(nameof(currentTransaction)); 27 | _managedExternally = managedExternally; 28 | } 29 | 30 | /// 31 | /// Constructs the wrapper with the given connection which should already be enlisted in a transaction. 32 | /// 33 | public PostgresConnection(NpgsqlConnection currentConnection, bool managedExternally = false) 34 | { 35 | _currentConnection = currentConnection ?? throw new ArgumentNullException(nameof(currentConnection)); 36 | _managedExternally = managedExternally; 37 | } 38 | 39 | /// 40 | /// Creates a new command, enlisting it in the current transaction 41 | /// 42 | public NpgsqlCommand CreateCommand() 43 | { 44 | var command = _currentConnection.CreateCommand(); 45 | command.Transaction = _currentTransaction; 46 | return command; 47 | } 48 | 49 | /// 50 | /// Completes the transaction 51 | /// 52 | 53 | public async Task Complete() 54 | { 55 | if (_managedExternally) return; 56 | if (_currentTransaction == null) return; 57 | await using (_currentTransaction) 58 | { 59 | await _currentTransaction.CommitAsync(); 60 | _currentTransaction = null; 61 | } 62 | } 63 | 64 | /// 65 | /// Rolls back the transaction if it hasn't been completed 66 | /// 67 | public void Dispose() 68 | { 69 | if (_managedExternally) return; 70 | if (_disposed) return; 71 | 72 | try 73 | { 74 | try 75 | { 76 | if (_currentTransaction == null) return; 77 | using (_currentTransaction) 78 | { 79 | try 80 | { 81 | _currentTransaction.Rollback(); 82 | } 83 | catch { } 84 | _currentTransaction = null; 85 | } 86 | } 87 | finally 88 | { 89 | _currentConnection.Dispose(); 90 | } 91 | } 92 | finally 93 | { 94 | _disposed = true; 95 | } 96 | } 97 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/PostgreSqlTestHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Npgsql; 3 | using Rebus.Internals; 4 | using Rebus.Tests.Contracts; 5 | 6 | namespace Rebus.PostgreSql.Tests; 7 | 8 | public class PostgreSqlTestHelper 9 | { 10 | const string TableDoesNotExist = "42P01"; 11 | const string SchemaDoesNotExist = "3F000"; 12 | 13 | static readonly IPostgresConnectionProvider PostgresConnectionHelper = new PostgresConnectionHelper(ConnectionString); 14 | 15 | public static string DatabaseName => $"rebus2_test_{TestConfig.Suffix}".TrimEnd('_'); 16 | 17 | public static string ConnectionString => GetConnectionStringForDatabase(DatabaseName); 18 | 19 | public static IPostgresConnectionProvider ConnectionHelper => PostgresConnectionHelper; 20 | 21 | public static void DropAllTables() 22 | { 23 | AsyncHelpers.RunSync(async () => 24 | { 25 | using var connection = await PostgresConnectionHelper.GetConnection(); 26 | var tables = connection.GetTableNames(); 27 | 28 | foreach (var table in tables) 29 | { 30 | try 31 | { 32 | await using var command = connection.CreateCommand(); 33 | command.CommandText = $@"DROP TABLE {table}"; 34 | command.ExecuteNonQuery(); 35 | 36 | Console.WriteLine("Dropped postgres table '{0}'", table); 37 | } 38 | catch (PostgresException exception) when (exception.SqlState == TableDoesNotExist) 39 | { 40 | } 41 | } 42 | 43 | await connection.Complete(); 44 | }); 45 | } 46 | 47 | public static void DropTable(string table) 48 | { 49 | DropTable(string.Empty, table); 50 | } 51 | 52 | public static void DropTable(string schema, string table) 53 | { 54 | AsyncHelpers.RunSync(async () => 55 | { 56 | var tableName = new TableName(schema, table); 57 | using var connection = await PostgresConnectionHelper.GetConnection(); 58 | 59 | await using var command = connection.CreateCommand(); 60 | 61 | command.CommandText = $"drop table {tableName};"; 62 | 63 | try 64 | { 65 | command.ExecuteNonQuery(); 66 | 67 | Console.WriteLine("Dropped postgres table '{0}'", tableName); 68 | } 69 | catch (PostgresException exception) when (exception.SqlState is TableDoesNotExist or SchemaDoesNotExist) 70 | { 71 | } 72 | 73 | await connection.Complete(); 74 | }); 75 | } 76 | 77 | static string GetConnectionStringForDatabase(string databaseName) 78 | { 79 | var envConnectionString = Environment.GetEnvironmentVariable("REBUS_POSTGRES"); 80 | 81 | if (!string.IsNullOrWhiteSpace(envConnectionString)) 82 | { 83 | if (envConnectionString.Replace(" ", "").Equals("usedockercontainer=true", StringComparison.OrdinalIgnoreCase)) 84 | { 85 | return PostgresTestContainerManager.TestContainerConnectionString.Value; 86 | } 87 | 88 | return envConnectionString; 89 | } 90 | 91 | return envConnectionString 92 | ?? $"server=localhost; database={databaseName}; user id=postgres; password=postgres;maximum pool size=30;"; 93 | } 94 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql/Internals/AsyncHelpers.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Runtime.ExceptionServices; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | // ReSharper disable AsyncVoidLambda 7 | 8 | namespace Rebus.Internals; 9 | 10 | static class AsyncHelpers 11 | { 12 | /// 13 | /// Executes a task synchronously on the calling thread by installing a temporary synchronization context that queues continuations 14 | /// 15 | public static void RunSync(Func task) 16 | { 17 | var currentContext = SynchronizationContext.Current; 18 | var customContext = new CustomSynchronizationContext(task); 19 | 20 | try 21 | { 22 | SynchronizationContext.SetSynchronizationContext(customContext); 23 | 24 | customContext.Run(); 25 | } 26 | finally 27 | { 28 | SynchronizationContext.SetSynchronizationContext(currentContext); 29 | } 30 | } 31 | 32 | /// 33 | /// Synchronization context that can be "pumped" in order to have it execute continuations posted back to it 34 | /// 35 | class CustomSynchronizationContext : SynchronizationContext 36 | { 37 | readonly ConcurrentQueue> _items = new(); 38 | readonly AutoResetEvent _workItemsWaiting = new(false); 39 | readonly Func _task; 40 | 41 | ExceptionDispatchInfo _caughtException; 42 | 43 | bool _done; 44 | 45 | public CustomSynchronizationContext(Func task) 46 | { 47 | _task = task ?? throw new ArgumentNullException(nameof(task), "Please remember to pass a Task to be executed"); 48 | } 49 | 50 | public override void Post(SendOrPostCallback function, object state) 51 | { 52 | _items.Enqueue(Tuple.Create(function, state)); 53 | _workItemsWaiting.Set(); 54 | } 55 | 56 | /// 57 | /// Enqueues the function to be executed and executes all resulting continuations until it is completely done 58 | /// 59 | public void Run() 60 | { 61 | Post(async _ => 62 | { 63 | try 64 | { 65 | await _task(); 66 | } 67 | catch (Exception exception) 68 | { 69 | _caughtException = ExceptionDispatchInfo.Capture(exception); 70 | throw; 71 | } 72 | finally 73 | { 74 | Post(_ => _done = true, null); 75 | } 76 | }, null); 77 | 78 | while (!_done) 79 | { 80 | if (_items.TryDequeue(out var task)) 81 | { 82 | task.Item1(task.Item2); 83 | 84 | if (_caughtException == null) continue; 85 | 86 | _caughtException.Throw(); 87 | } 88 | else 89 | { 90 | _workItemsWaiting.WaitOne(); 91 | } 92 | } 93 | } 94 | 95 | public override void Send(SendOrPostCallback d, object state) 96 | { 97 | throw new NotSupportedException("Cannot send to same thread"); 98 | } 99 | 100 | public override SynchronizationContext CreateCopy() 101 | { 102 | return this; 103 | } 104 | } 105 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/Outbox/OutboxForwarder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using Rebus.Bus; 7 | using Rebus.Logging; 8 | using Rebus.Threading; 9 | using Rebus.Transport; 10 | 11 | namespace Rebus.PostgreSql.Outbox; 12 | 13 | class OutboxForwarder : IDisposable, IInitializable 14 | { 15 | static readonly Retrier SendRetrier = new(new[] 16 | { 17 | TimeSpan.FromSeconds(0.1), 18 | TimeSpan.FromSeconds(0.1), 19 | TimeSpan.FromSeconds(0.1), 20 | TimeSpan.FromSeconds(0.1), 21 | TimeSpan.FromSeconds(0.1), 22 | TimeSpan.FromSeconds(0.5), 23 | TimeSpan.FromSeconds(0.5), 24 | TimeSpan.FromSeconds(0.5), 25 | TimeSpan.FromSeconds(0.5), 26 | TimeSpan.FromSeconds(0.5), 27 | TimeSpan.FromSeconds(1), 28 | TimeSpan.FromSeconds(1), 29 | TimeSpan.FromSeconds(1), 30 | TimeSpan.FromSeconds(1), 31 | TimeSpan.FromSeconds(1), 32 | }); 33 | 34 | readonly CancellationTokenSource _cancellationTokenSource = new(); 35 | readonly IOutboxStorage _outboxStorage; 36 | readonly ITransport _transport; 37 | readonly IAsyncTask _forwarder; 38 | readonly IAsyncTask _cleaner; 39 | readonly ILog _logger; 40 | 41 | public OutboxForwarder(IAsyncTaskFactory asyncTaskFactory, IRebusLoggerFactory rebusLoggerFactory, IOutboxStorage outboxStorage, ITransport transport) 42 | { 43 | if (asyncTaskFactory == null) throw new ArgumentNullException(nameof(asyncTaskFactory)); 44 | _outboxStorage = outboxStorage; 45 | _transport = transport; 46 | _forwarder = asyncTaskFactory.Create("OutboxForwarder", RunForwarder, intervalSeconds: 1); 47 | _logger = rebusLoggerFactory.GetLogger(); 48 | } 49 | 50 | public void Initialize() 51 | { 52 | _forwarder.Start(); 53 | } 54 | 55 | async Task RunForwarder() 56 | { 57 | _logger.Debug("Checking outbox storage for pending messages"); 58 | 59 | var cancellationToken = _cancellationTokenSource.Token; 60 | 61 | while (!cancellationToken.IsCancellationRequested) 62 | { 63 | using var batch = await _outboxStorage.GetNextMessageBatch(); 64 | 65 | if (!batch.Any()) 66 | { 67 | _logger.Debug("No pending messages found"); 68 | return; 69 | } 70 | 71 | await ProcessMessageBatch(batch, cancellationToken); 72 | 73 | await batch.Complete(); 74 | } 75 | } 76 | 77 | async Task ProcessMessageBatch(IReadOnlyCollection batch, CancellationToken cancellationToken) 78 | { 79 | _logger.Debug("Sending {count} pending messages", batch.Count); 80 | 81 | using var scope = new RebusTransactionScope(); 82 | 83 | foreach (var message in batch) 84 | { 85 | var destinationAddress = message.DestinationAddress; 86 | var transportMessage = message.ToTransportMessage(); 87 | var transactionContext = scope.TransactionContext; 88 | 89 | Task SendMessage() => _transport.Send(destinationAddress, transportMessage, transactionContext); 90 | 91 | await SendRetrier.ExecuteAsync(SendMessage, cancellationToken); 92 | } 93 | 94 | await scope.CompleteAsync(); 95 | 96 | _logger.Debug("Successfully sent {count} messages", batch.Count); 97 | } 98 | 99 | public void Dispose() 100 | { 101 | _cancellationTokenSource.Cancel(); 102 | _forwarder?.Dispose(); 103 | _cleaner?.Dispose(); 104 | _cancellationTokenSource?.Dispose(); 105 | } 106 | 107 | #pragma warning restore CS4014 108 | } 109 | -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/PostgreSqlMagic.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Npgsql; 4 | using NpgsqlTypes; 5 | 6 | namespace Rebus.PostgreSql; 7 | 8 | static class PostgreSqlMagic 9 | { 10 | public static List GetTableNames(this PostgresConnection connection) 11 | { 12 | using var command = connection.CreateCommand(); 13 | return GetTableNames(command); 14 | } 15 | 16 | public static List GetTableNames(this NpgsqlConnection connection, NpgsqlTransaction transaction) 17 | { 18 | using var command = connection.CreateCommand(); 19 | command.Transaction = transaction; 20 | 21 | return GetTableNames(command); 22 | } 23 | 24 | static List GetTableNames(NpgsqlCommand command) 25 | { 26 | var tableNames = new List(); 27 | command.CommandText = "select * from information_schema.tables where table_schema not in ('pg_catalog', 'information_schema')"; 28 | 29 | using var reader = command.ExecuteReader(); 30 | 31 | while (reader.Read()) 32 | { 33 | var schemaName = reader["table_schema"].ToString(); 34 | var tableName = reader["table_name"].ToString(); 35 | 36 | tableNames.Add(new TableName(schemaName, tableName)); 37 | } 38 | 39 | return tableNames; 40 | } 41 | 42 | public static List GetSchemas(this PostgresConnection connection) 43 | { 44 | using var command = connection.CreateCommand(); 45 | return GetSchemas(command); 46 | } 47 | 48 | public static List GetSchemas(this NpgsqlConnection connection, NpgsqlTransaction transaction) 49 | { 50 | using var command = connection.CreateCommand(); 51 | command.Transaction = transaction; 52 | 53 | return GetSchemas(command); 54 | } 55 | 56 | static List GetSchemas(NpgsqlCommand command) 57 | { 58 | var schemaNames = new List(); 59 | command.CommandText = "SELECT schema_name FROM information_schema.schemata;"; 60 | 61 | using var reader = command.ExecuteReader(); 62 | 63 | while (reader.Read()) 64 | { 65 | var schemaName = reader["schema_name"].ToString(); 66 | 67 | schemaNames.Add(schemaName); 68 | } 69 | 70 | return schemaNames; 71 | } 72 | 73 | /// 74 | /// Gets the names of all tables in the current database 75 | /// 76 | public static Dictionary GetColumns(this NpgsqlConnection connection, string schema, string tableName, NpgsqlTransaction transaction = null) 77 | { 78 | var results = new Dictionary(); 79 | 80 | using var command = connection.CreateCommand(); 81 | if (transaction != null) 82 | { 83 | command.Transaction = transaction; 84 | } 85 | 86 | command.CommandText = $"SELECT [COLUMN_NAME] AS 'name', [DATA_TYPE] AS 'type' FROM [INFORMATION_SCHEMA].[COLUMNS] WHERE [TABLE_SCHEMA] = '{schema}' AND [TABLE_NAME] = '{tableName}'"; 87 | 88 | using var reader = command.ExecuteReader(); 89 | while (reader.Read()) 90 | { 91 | var name = (string)reader["name"]; 92 | var typeString = (string)reader["type"]; 93 | var type = GetDbType(typeString); 94 | 95 | results[name] = type; 96 | } 97 | 98 | return results; 99 | } 100 | 101 | static NpgsqlDbType GetDbType(string typeString) 102 | { 103 | try 104 | { 105 | return (NpgsqlDbType)Enum.Parse(typeof(NpgsqlDbType), typeString, true); 106 | } 107 | catch (Exception exception) 108 | { 109 | throw new FormatException($"Could not parse '{typeString}' into {typeof(NpgsqlDbType)}", exception); 110 | } 111 | } 112 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/Transport/TestPostgreSqlTransportMessageOrdering.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using NUnit.Framework; 7 | using Rebus.Logging; 8 | using Rebus.Messages; 9 | using Rebus.PostgreSql.Transport; 10 | using Rebus.Tests.Contracts; 11 | using Rebus.Threading.TaskParallelLibrary; 12 | using Rebus.Time; 13 | using Rebus.Transport; 14 | 15 | namespace Rebus.PostgreSql.Tests.Transport; 16 | 17 | [TestFixture] 18 | public class TestPostgreSqlTransportMessageOrdering : FixtureBase 19 | { 20 | const string QueueName = "test-ordering"; 21 | const string TableName = "Messages"; 22 | protected override void SetUp() => PostgreSqlTestHelper.DropTable(TableName); 23 | 24 | [Test] 25 | public async Task DeliversMessagesByVisibleTimeAndNotBeInsertionTime() 26 | { 27 | var transport = GetTransport(); 28 | 29 | var now = DateTime.Now; 30 | 31 | await PutInQueue(transport, GetTransportMessage("first message")); 32 | await PutInQueue(transport, GetTransportMessage("second message", deferredUntilTime: now.AddMinutes(-1))); 33 | await PutInQueue(transport, GetTransportMessage("third message", deferredUntilTime: now.AddMinutes(-2))); 34 | 35 | var firstMessage = await ReceiveMessageBody(transport); 36 | var secondMessage = await ReceiveMessageBody(transport); 37 | var thirdMessage = await ReceiveMessageBody(transport); 38 | 39 | // expect messages to be received in reverse order because of their visible times 40 | Assert.That(firstMessage, Is.EqualTo("third message")); 41 | Assert.That(secondMessage, Is.EqualTo("second message")); 42 | Assert.That(thirdMessage, Is.EqualTo("first message")); 43 | } 44 | 45 | static async Task ReceiveMessageBody(ITransport transport) 46 | { 47 | using (var scope = new RebusTransactionScope()) 48 | { 49 | var transportMessage = await transport.Receive(scope.TransactionContext, CancellationToken.None); 50 | 51 | if (transportMessage == null) return null; 52 | 53 | var body = Encoding.UTF8.GetString(transportMessage.Body); 54 | 55 | await scope.CompleteAsync(); 56 | 57 | return body; 58 | } 59 | } 60 | 61 | static TransportMessage GetTransportMessage(string body, DateTime? deferredUntilTime = null) 62 | { 63 | var headers = new Dictionary 64 | { 65 | {Headers.MessageId, Guid.NewGuid().ToString()} 66 | }; 67 | 68 | if (deferredUntilTime != null) 69 | { 70 | headers[Headers.DeferredRecipient] = QueueName; 71 | headers[Headers.DeferredUntil] = deferredUntilTime.Value.ToString("o"); 72 | } 73 | 74 | return new TransportMessage(headers, Encoding.UTF8.GetBytes(body)); 75 | } 76 | 77 | static async Task PutInQueue(ITransport transport, TransportMessage transportMessage) 78 | { 79 | using (var scope = new RebusTransactionScope()) 80 | { 81 | await transport.Send(QueueName, transportMessage, scope.TransactionContext); 82 | await scope.CompleteAsync(); 83 | } 84 | } 85 | 86 | static PostgreSqlTransport GetTransport() 87 | { 88 | var loggerFactory = new ConsoleLoggerFactory(false); 89 | var connectionProvider = new PostgresConnectionHelper(PostgreSqlTestHelper.ConnectionString); 90 | var asyncTaskFactory = new TplAsyncTaskFactory(loggerFactory); 91 | 92 | var transport = new PostgreSqlTransport( 93 | connectionProvider, 94 | TableName, 95 | QueueName, 96 | loggerFactory, 97 | asyncTaskFactory, 98 | new DefaultRebusTime() 99 | ); 100 | 101 | transport.EnsureTableIsCreated(); 102 | transport.Initialize(); 103 | 104 | return transport; 105 | } 106 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2.0.0-a1 4 | * Test release 5 | 6 | ## 2.0.0-b01 7 | * Test release 8 | 9 | ## 2.0.0 10 | * Release 2.0.0 11 | 12 | ## 2.1.0 13 | * Add PostgreSQL transport implementation - thanks [jmkelly] 14 | 15 | ## 2.2.0 16 | * Add one-way configuration extension for the transport - thanks [jmkelly] 17 | * Fix nuspec 18 | 19 | ## 3.0.0 20 | * Update to Rebus 3 21 | 22 | ## 4.0.0 23 | * Update to Rebus 4 24 | * Add .NET Core support 25 | * Add ability to customize `NpgsqlConnection` before it is used (e.g. to provide a certificate validation callback) - thanks [enriquein] 26 | 27 | ## 4.1.0 28 | * Add async bottleneck for outgoing messages to avoid concurrency issues accessing a shared connection - thanks [dtabuenc] 29 | 30 | ## 5.0.0 31 | * Make connection provider configurable - thanks [dtabuenc] 32 | 33 | ## 6.0.0 34 | * Change ordering such that priority is reversed (i.e. higher priorities are preferred) and such that visible time takes precedence over insertion order, meaning that deferred messages are ordered more naturally 35 | * Rename misleading parameter 36 | * Update Npgsql dependency to 4.1.3 and System.Data.SqlClient to 4.8.0 to get the latest security fixes 37 | * Update Rebus dependency to v. 5 38 | * Enable Postgres connection to enlist in ambient transaction - thanks [KasperDamgaard] 39 | 40 | ## 7.0.0 41 | * Update to Rebus 6 42 | 43 | ## 7.1.0 44 | * Delete expired messages regardless of their destination queue, thus making it possible for abandoned messages to expire - thanks [zabulus] 45 | 46 | ## 7.1.1 47 | * Add index to improve dequeueing performance for the transport - thanks [knutsr] 48 | 49 | ## 7.2.0 50 | * Add target for .NET 5 - thanks [mastersign] 51 | 52 | ## 7.3.0 53 | * Add flag in DB provider to indicate that the connection/transaction is managed externally - thanks [Laurianti] 54 | 55 | ## 7.3.1 56 | * Additional flag for indicating that connection/transaction is managed externally - thanks [Laurianti] 57 | 58 | ## 7.4.0 59 | * Optional parameters to enable configuring the expired messages cleanup interval - thanks [Laurianti] 60 | 61 | ## 8.0.0 62 | * Remove unnecessary System.Data.SqlClient dependency 63 | * Update Npgsql dependency to 6.0.4 64 | 65 | ## 8.0.1 66 | * Fix bug where `isCentralized` was not actually used - thanks [mts44] 67 | 68 | ## 8.1.0 69 | * Make saga data serializer configurable - thanks [mmdevterm] 70 | 71 | ## 8.2.0-b3 72 | * Add outbox - thanks [matt-psaltis] 73 | * Add schema support - thanks [patrick11994] 74 | * Add ambient transaction support for outbox and fix bug in outbox storage - thanks [jwoots] 75 | 76 | ## 9.0.0 77 | * Update to Rebus 8 78 | * Clean up outbox messages as they're processed - thanks [jwoots] 79 | * Expose optional `schemaName` parameter from underlying configuration method 80 | * Use now() instead of clock_timestamp() to allow better index on cleanup deletes - thanks [jmkelly] 81 | * Fix bug that would ignore it when a custom saga serializer was registered - thanks [mfahadi] 82 | 83 | ## 9.0.1 84 | * Fix bug that would require schema name to be explicitly specified for the saga persister, even though it's optional 85 | 86 | ## 9.1.0 87 | * Update Npgsql dependency to 8.0.3 88 | * Update Rebus dependency to 8.4.2 89 | 90 | ## 9.1.1 91 | * Add retry around schema initialization routines, because they're all idempotent, and they can collide when starting things up in parallel 92 | 93 | --- 94 | 95 | [dtabuenc]: https://github.com/dtabuenc 96 | [enriquein]: https://github.com/enriquein 97 | [jmkelly]: https://github.com/jmkelly 98 | [jwoots]: https://github.com/jwoots 99 | [KasperDamgaard]: https://github.com/KasperDamgaard 100 | [knutsr]: https://github.com/knutsr 101 | [Laurianti]: https://github.com/Laurianti 102 | [mastersign]: https://github.com/mastersign 103 | [matt-psaltis]: https://github.com/matt-psaltis 104 | [mfahadi]: https://github.com/mfahadi 105 | [mmdevterm]: https://github.com/mmdevterm 106 | [mts44]: https://github.com/mts44 107 | [patrick11994]: https://github.com/patrick11994 108 | [zabulus]: https://github.com/zabulus 109 | -------------------------------------------------------------------------------- /Rebus.PostgreSql/Config/Outbox/OutboxExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Npgsql; 3 | using Rebus.PostgreSql; 4 | using Rebus.PostgreSql.Outbox; 5 | using Rebus.Transport; 6 | 7 | // ReSharper disable ArgumentsStyleLiteral 8 | 9 | namespace Rebus.Config.Outbox; 10 | 11 | /// 12 | /// Configuration extensions for PostgreSql Server-based outbox 13 | /// 14 | public static class OutboxExtensions 15 | { 16 | internal const string CurrentOutboxConnectionKey = "current-outbox-connection"; 17 | 18 | /// 19 | /// Configures PostgreSql Server as the outbox storage 20 | /// 21 | public static void StoreInPostgreSql(this StandardConfigurer configurer, string connectionString, string tableName) 22 | { 23 | if (configurer == null) throw new ArgumentNullException(nameof(configurer)); 24 | if (connectionString == null) throw new ArgumentNullException(nameof(connectionString)); 25 | if (tableName == null) throw new ArgumentNullException(nameof(tableName)); 26 | 27 | StoreInPostgreSql(configurer, connectionString, TableName.Parse(tableName)); 28 | } 29 | 30 | /// 31 | /// Configures PostgreSql Server as the outbox storage 32 | /// 33 | public static void StoreInPostgreSql(this StandardConfigurer configurer, string connectionString, TableName tableName) 34 | { 35 | if (configurer == null) throw new ArgumentNullException(nameof(configurer)); 36 | if (connectionString == null) throw new ArgumentNullException(nameof(connectionString)); 37 | if (tableName == null) throw new ArgumentNullException(nameof(tableName)); 38 | 39 | IDbConnection ConnectionProvider(ITransactionContext context) 40 | { 41 | // if we find a connection in the context, use that (and accept that its lifestyle is managed somewhere else): 42 | if (context.Items.TryGetValue(CurrentOutboxConnectionKey, out var result) && result is OutboxConnection outboxConnection) 43 | { 44 | return new DbConnectionWrapper(outboxConnection.Connection, outboxConnection.Transaction, managedExternally: true); 45 | } 46 | 47 | var connection = new NpgsqlConnection(connectionString); 48 | 49 | connection.Open(); 50 | 51 | try 52 | { 53 | var transaction = connection.BeginTransaction(); 54 | 55 | return new DbConnectionWrapper(connection, transaction, managedExternally: false); 56 | } 57 | catch 58 | { 59 | connection.Dispose(); 60 | throw; 61 | } 62 | } 63 | 64 | configurer 65 | .OtherService() 66 | .Register(_ => new PostgreSqlOutboxStorage(ConnectionProvider, tableName)); 67 | 68 | configurer.OtherService() 69 | .Register(_ => new OutboxConnectionProvider(connectionString)); 70 | } 71 | 72 | /// 73 | /// Enables the use of outbox on the . Will enlist all outgoing message operations in the 74 | /// / passed to the method. 75 | /// 76 | public static void UseOutbox(this RebusTransactionScope rebusTransactionScope, NpgsqlConnection connection, NpgsqlTransaction transaction) 77 | { 78 | if (rebusTransactionScope == null) throw new ArgumentNullException(nameof(rebusTransactionScope)); 79 | if (connection == null) throw new ArgumentNullException(nameof(connection)); 80 | if (transaction == null) throw new ArgumentNullException(nameof(transaction)); 81 | 82 | var context = rebusTransactionScope.TransactionContext; 83 | 84 | if (!context.Items.TryAdd(CurrentOutboxConnectionKey, new OutboxConnection(connection, transaction))) 85 | { 86 | throw new InvalidOperationException("Cannot add the given connection/transaction to the current Rebus transaction, because a connection/transaction has already been added!"); 87 | } 88 | } 89 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/Outbox/TestOutbox_InsideRebusHandler.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using NUnit.Framework; 5 | using Rebus.Activation; 6 | using Rebus.Bus; 7 | using Rebus.Config; 8 | using Rebus.Config.Outbox; 9 | using Rebus.Persistence.InMem; 10 | using Rebus.Pipeline; 11 | using Rebus.Routing; 12 | using Rebus.Routing.TypeBased; 13 | using Rebus.Tests.Contracts; 14 | using Rebus.Tests.Contracts.Extensions; 15 | using Rebus.Transport; 16 | using Rebus.Transport.InMem; 17 | 18 | // ReSharper disable ArgumentsStyleLiteral 19 | // ReSharper disable AccessToDisposedClosure 20 | #pragma warning disable CS1998 21 | 22 | namespace Rebus.PostgreSql.Tests.Outbox; 23 | 24 | [TestFixture] 25 | public class TestOutbox_InsideRebusHandler : FixtureBase 26 | { 27 | static string ConnectionString => PostgreSqlTestHelper.ConnectionString; 28 | 29 | InMemNetwork _network; 30 | InMemorySubscriberStore _subscriberStore; 31 | 32 | protected override void SetUp() 33 | { 34 | base.SetUp(); 35 | 36 | PostgreSqlTestHelper.DropTable("RebusOutbox"); 37 | 38 | _network = new InMemNetwork(); 39 | _subscriberStore = new InMemorySubscriberStore(); 40 | } 41 | 42 | record SomeMessage; 43 | 44 | record AnotherMessage; 45 | 46 | [Test] 47 | public async Task CanHandleMessageAndSendOutgoingMessagesEvenWhenTransportIsFlaky() 48 | { 49 | using var gotSomeMessage = new ManualResetEvent(initialState: false); 50 | using var gotAnotherMessage = new ManualResetEvent(initialState: false); 51 | 52 | var flakySenderTransportDecoratorSettings = new FlakySenderTransportDecoratorSettings(); 53 | 54 | async Task HandlerFunction(IBus bus, IMessageContext context, SomeMessage message) 55 | { 56 | await bus.Advanced.Routing.Send("secondConsumer", new AnotherMessage()); 57 | 58 | gotSomeMessage.Set(); 59 | } 60 | 61 | using var firstConsumer = CreateConsumer("firstConsumer", activator => activator.Handle(HandlerFunction), flakySenderTransportDecoratorSettings); 62 | using var secondConsumer = CreateConsumer("secondConsumer", activator => activator.Handle(async _ => gotAnotherMessage.Set())); 63 | 64 | using var client = CreateOneWayClient(router => router.TypeBased().Map("firstConsumer")); 65 | 66 | // make it so that the first consumer cannot send 67 | flakySenderTransportDecoratorSettings.SuccessRate = 0; 68 | 69 | await client.Send(new SomeMessage()); 70 | 71 | // wait for SomeMessage to be handled 72 | gotSomeMessage.WaitOrDie(timeout: TimeSpan.FromSeconds(3)); 73 | 74 | // now make it possible for first consumer to send again 75 | flakySenderTransportDecoratorSettings.SuccessRate = 1; 76 | 77 | // wait for AnotherMessage to arrive 78 | gotAnotherMessage.WaitOrDie(timeout: TimeSpan.FromSeconds(15)); 79 | } 80 | 81 | IBus CreateConsumer(string queueName, Action handlers = null, FlakySenderTransportDecoratorSettings flakySenderTransportDecoratorSettings = null) 82 | { 83 | var activator = new BuiltinHandlerActivator(); 84 | 85 | handlers?.Invoke(activator); 86 | 87 | Configure.With(activator) 88 | .Transport(t => 89 | { 90 | t.UseInMemoryTransport(_network, queueName); 91 | 92 | if (flakySenderTransportDecoratorSettings != null) 93 | { 94 | t.Decorate(c => new FlakySenderTransportDecorator(c.Get(), 95 | flakySenderTransportDecoratorSettings)); 96 | } 97 | }) 98 | .Outbox(o => o.StoreInPostgreSql(ConnectionString, "RebusOutbox")) 99 | .Start(); 100 | 101 | return activator.Bus; 102 | } 103 | 104 | IBus CreateOneWayClient(Action> routing = null) 105 | { 106 | return Configure.With(new BuiltinHandlerActivator()) 107 | .Transport(t => t.UseInMemoryTransportAsOneWayClient(_network)) 108 | .Routing(r => routing?.Invoke(r)) 109 | .Start(); 110 | } 111 | 112 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/Sagas/PostgreSqlSagaSnapshotStorage.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using NpgsqlTypes; 5 | using Rebus.Auditing.Sagas; 6 | using Rebus.Internals; 7 | using Rebus.Sagas; 8 | using Rebus.Serialization; 9 | 10 | namespace Rebus.PostgreSql.Sagas; 11 | 12 | /// 13 | /// Implementation of that uses PostgreSql to store the snapshots 14 | /// 15 | public class PostgreSqlSagaSnapshotStorage : ISagaSnapshotStorage 16 | { 17 | readonly ObjectSerializer _objectSerializer = new ObjectSerializer(); 18 | readonly DictionarySerializer _dictionarySerializer = new DictionarySerializer(); 19 | readonly IPostgresConnectionProvider _connectionHelper; 20 | readonly TableName _tableName; 21 | 22 | /// 23 | /// Constructs the storage 24 | /// 25 | public PostgreSqlSagaSnapshotStorage(IPostgresConnectionProvider connectionHelper, string tableName, string schemaName = null) 26 | { 27 | if (tableName == null) throw new ArgumentNullException(nameof(tableName)); 28 | 29 | _connectionHelper = connectionHelper ?? throw new ArgumentNullException(nameof(connectionHelper)); 30 | _tableName = new TableName(schemaName ?? TableName.DefaultSchemaName, tableName); 31 | } 32 | 33 | /// 34 | /// Saves the snapshot and the accompanying 35 | /// 36 | public async Task Save(ISagaData sagaData, Dictionary sagaAuditMetadata) 37 | { 38 | using (var connection = await _connectionHelper.GetConnection()) 39 | { 40 | using (var command = connection.CreateCommand()) 41 | { 42 | command.CommandText = 43 | $@" 44 | 45 | INSERT 46 | INTO {_tableName} (""id"", ""revision"", ""data"", ""metadata"") 47 | VALUES (@id, @revision, @data, @metadata); 48 | 49 | "; 50 | command.Parameters.Add("id", NpgsqlDbType.Uuid).Value = sagaData.Id; 51 | command.Parameters.Add("revision", NpgsqlDbType.Integer).Value = sagaData.Revision; 52 | command.Parameters.Add("data", NpgsqlDbType.Bytea).Value = _objectSerializer.Serialize(sagaData); 53 | command.Parameters.Add("metadata", NpgsqlDbType.Jsonb).Value = 54 | _dictionarySerializer.SerializeToString(sagaAuditMetadata); 55 | 56 | await command.ExecuteNonQueryAsync(); 57 | } 58 | 59 | await connection.Complete(); 60 | } 61 | } 62 | 63 | /// 64 | /// Creates the necessary table if it does not already exist 65 | /// 66 | public void EnsureTableIsCreated() 67 | { 68 | async Task InnerEnsureTableIsCreated() 69 | { 70 | using (var connection = await _connectionHelper.GetConnection()) 71 | { 72 | var tableNames = connection.GetTableNames(); 73 | 74 | if (tableNames.Contains(_tableName)) return; 75 | 76 | var schemaNames = connection.GetSchemas(); 77 | 78 | if (!schemaNames.Contains(_tableName.Schema)) 79 | { 80 | using (var command = connection.CreateCommand()) 81 | { 82 | command.CommandText = $@"CREATE SCHEMA ""{_tableName.Schema}"";"; 83 | 84 | command.ExecuteNonQuery(); 85 | } 86 | } 87 | 88 | using (var command = connection.CreateCommand()) 89 | { 90 | command.CommandText = $@" 91 | CREATE TABLE {_tableName} ( 92 | ""id"" UUID NOT NULL, 93 | ""revision"" INTEGER NOT NULL, 94 | ""metadata"" JSONB NOT NULL, 95 | ""data"" BYTEA NOT NULL, 96 | PRIMARY KEY (""id"", ""revision"") 97 | ); 98 | "; 99 | 100 | command.ExecuteNonQuery(); 101 | } 102 | 103 | await connection.Complete(); 104 | } 105 | } 106 | 107 | var retrier = new Retrier([ 108 | TimeSpan.FromSeconds(1), 109 | TimeSpan.FromSeconds(2), 110 | TimeSpan.FromSeconds(3), 111 | TimeSpan.FromSeconds(4), 112 | TimeSpan.FromSeconds(5) 113 | ]); 114 | 115 | AsyncHelpers.RunSync(() => retrier.ExecuteAsync(InnerEnsureTableIsCreated)); 116 | } 117 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/DbConnectionWrapper.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using Npgsql; 5 | using Rebus.Exceptions; 6 | 7 | #pragma warning disable 1998 8 | 9 | namespace Rebus.PostgreSql; 10 | 11 | /// 12 | /// Wrapper of that allows for either handling automatically, or for handling it externally 13 | /// 14 | public class DbConnectionWrapper : IDbConnection 15 | { 16 | readonly NpgsqlConnection _connection; 17 | readonly bool _managedExternally; 18 | 19 | NpgsqlTransaction _currentTransaction; 20 | bool _disposed; 21 | 22 | /// 23 | /// Constructs the wrapper, wrapping the given connection and transaction. It must be indicated with whether this wrapper 24 | /// should commit/rollback the transaction (depending on whether is called before ), or if the transaction 25 | /// is handled outside of the wrapper 26 | /// 27 | public DbConnectionWrapper(NpgsqlConnection connection, NpgsqlTransaction currentTransaction, bool managedExternally) 28 | { 29 | _connection = connection; 30 | _currentTransaction = currentTransaction; 31 | _managedExternally = managedExternally; 32 | } 33 | 34 | /// 35 | /// Creates a ready to used 36 | /// 37 | public NpgsqlCommand CreateCommand() 38 | { 39 | var sqlCommand = _connection.CreateCommand(); 40 | sqlCommand.Transaction = _currentTransaction; 41 | return sqlCommand; 42 | } 43 | 44 | /// 45 | /// Gets the names of all the tables in the current database for the current schema 46 | /// 47 | public IEnumerable GetTableNames() 48 | { 49 | try 50 | { 51 | return _connection.GetTableNames(_currentTransaction); 52 | } 53 | catch (NpgsqlException exception) 54 | { 55 | throw new RebusApplicationException(exception, "Could not get table names"); 56 | } 57 | } 58 | 59 | /// 60 | /// Gets information about the columns in the table given by 61 | /// 62 | public IEnumerable GetColumns(string schema, string dataTableName) 63 | { 64 | try 65 | { 66 | return _connection 67 | .GetColumns(schema, dataTableName, _currentTransaction) 68 | .Select(kvp => new DbColumn(kvp.Key, kvp.Value)) 69 | .ToList(); 70 | } 71 | catch (NpgsqlException exception) 72 | { 73 | throw new RebusApplicationException(exception, "Could not get table names"); 74 | } 75 | } 76 | 77 | /// 78 | /// Marks that all work has been successfully done and the may have its transaction committed or whatever is natural to do at this time 79 | /// 80 | public async Task Complete() 81 | { 82 | if (_managedExternally) return; 83 | 84 | if (_currentTransaction != null) 85 | { 86 | using (_currentTransaction) 87 | { 88 | _currentTransaction.Commit(); 89 | _currentTransaction = null; 90 | } 91 | } 92 | } 93 | 94 | /// 95 | /// Finishes the transaction and disposes the connection in order to return it to the connection pool. If the transaction 96 | /// has not been committed (by calling ), the transaction will be rolled back. 97 | /// 98 | public void Dispose() 99 | { 100 | if (_managedExternally) return; 101 | if (_disposed) return; 102 | 103 | try 104 | { 105 | try 106 | { 107 | if (_currentTransaction != null) 108 | { 109 | using (_currentTransaction) 110 | { 111 | try 112 | { 113 | _currentTransaction.Rollback(); 114 | } 115 | catch { } 116 | _currentTransaction = null; 117 | } 118 | } 119 | } 120 | finally 121 | { 122 | _connection.Dispose(); 123 | } 124 | } 125 | finally 126 | { 127 | _disposed = true; 128 | } 129 | } 130 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/TableName.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.RegularExpressions; 3 | 4 | namespace Rebus.PostgreSql; 5 | 6 | /// 7 | /// Represents a (possibly schema-qualified) table name in PostgreSql Server 8 | /// 9 | public class TableName : IEquatable 10 | { 11 | /// 12 | /// Default schema name for postgres 13 | /// 14 | public const string DefaultSchemaName = "public"; 15 | 16 | /// 17 | /// Gets the schema name of the table 18 | /// 19 | public string Schema { get; } 20 | 21 | /// 22 | /// Gets the table's name 23 | /// 24 | public string Name { get; } 25 | 26 | internal string QualifiedName => string.IsNullOrEmpty(Schema) ? $"\"{Name}\"" : $"\"{Schema}\".\"{Name}\""; 27 | 28 | /// 29 | /// Creates a object with the given schema and table names 30 | /// 31 | public TableName(string tableName) 32 | : this(DefaultSchemaName, tableName) 33 | { 34 | } 35 | 36 | /// 37 | /// Creates a object with the given schema and table names 38 | /// 39 | public TableName(string schema, string tableName) 40 | { 41 | if (schema == null) throw new ArgumentNullException(nameof(schema)); 42 | if (tableName == null) throw new ArgumentNullException(nameof(tableName)); 43 | 44 | Schema = StripQuotes(schema); 45 | Name = StripQuotes(tableName); 46 | } 47 | 48 | /// 49 | /// Parses the given name into a , defaulting to using the 'dbo' schema unless the name is schema-qualified. 50 | /// E.g. 'table' will result in a representing the '[dbo].[table]' table, whereas 'accounting.messages' will 51 | /// represent the '[accounting].[messages]' table. 52 | /// 53 | public static TableName Parse(string name) 54 | { 55 | // special case: bare table name, or schema and table name separated by . (but without any brackets) 56 | if (!(name.StartsWith("\"") && name.EndsWith("\""))) 57 | { 58 | var parts = name.Split('.'); 59 | 60 | return TableNameFromParts(name, parts); 61 | } 62 | else 63 | { 64 | // name has [ and ] around it - we remove those 65 | var nameWithoutOutermostBrackets = name.Substring(1, name.Length - 2); 66 | 67 | // now the name either looks like this 68 | // 'name' 69 | // or like this 70 | // 'schema].[name' 71 | // or even like this (because there can be spaces between the parts 72 | // 'schema] . [name' 73 | // 74 | // there we split with this regex 75 | var parts = Regex.Split(nameWithoutOutermostBrackets, "\"[ ]*\\.[ ]*\"", RegexOptions.Compiled); 76 | 77 | return TableNameFromParts(name, parts); 78 | } 79 | } 80 | 81 | static TableName TableNameFromParts(string name, string[] parts) 82 | { 83 | if (parts.Length == 1) 84 | { 85 | return new TableName(parts[0]); 86 | } 87 | 88 | if (parts.Length == 2) 89 | { 90 | return new TableName(parts[0], parts[1]); 91 | } 92 | 93 | throw new ArgumentException( 94 | $"The table name '{name}' cannot be used because it contained multiple '.' characters - if you intend to use '.' as part of a table name, please be sure to enclose the name in brackets, e.g. like this: '[Table name with spaces and .s]'"); 95 | } 96 | 97 | static string StripQuotes(string value) 98 | { 99 | if (value.StartsWith("\"")) 100 | { 101 | value = value.Substring(1); 102 | } 103 | if (value.EndsWith("\"")) 104 | { 105 | value = value.Substring(0, value.Length - 1); 106 | } 107 | 108 | return value; 109 | } 110 | 111 | /// 112 | public override string ToString() => QualifiedName; 113 | 114 | /// 115 | public bool Equals(TableName other) 116 | { 117 | if (ReferenceEquals(null, other)) return false; 118 | if (ReferenceEquals(this, other)) return true; 119 | return string.Equals(Schema, other.Schema, StringComparison.Ordinal) 120 | && string.Equals(Name, other.Name, StringComparison.Ordinal); 121 | } 122 | 123 | /// 124 | public override bool Equals(object obj) 125 | { 126 | if (ReferenceEquals(null, obj)) return false; 127 | if (ReferenceEquals(this, obj)) return true; 128 | if (obj.GetType() != GetType()) return false; 129 | return Equals((TableName)obj); 130 | } 131 | 132 | /// 133 | public override int GetHashCode() 134 | { 135 | unchecked 136 | { 137 | return (Schema.GetHashCode() * 397) ^ Name.GetHashCode(); 138 | } 139 | } 140 | 141 | /// 142 | /// Checks whether the two objects are equal (i.e. represent the same table) 143 | /// 144 | public static bool operator ==(TableName left, TableName right) 145 | { 146 | return Equals(left, right); 147 | } 148 | 149 | /// 150 | /// Checks whether the two objects are not equal (i.e. do not represent the same table) 151 | /// 152 | public static bool operator !=(TableName left, TableName right) 153 | { 154 | return !Equals(left, right); 155 | } 156 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/Transport/TestPostgreSqlTransport.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using NUnit.Framework; 8 | using Rebus.Extensions; 9 | using Rebus.Logging; 10 | using Rebus.Messages; 11 | using Rebus.PostgreSql.Transport; 12 | using Rebus.Tests.Contracts; 13 | using Rebus.Threading.TaskParallelLibrary; 14 | using Rebus.Time; 15 | using Rebus.Transport; 16 | 17 | namespace Rebus.PostgreSql.Tests.Transport; 18 | 19 | [TestFixture, Category(Categories.PostgreSql)] 20 | public class TestPostgreSqlTransport : FixtureBase 21 | { 22 | readonly string _tableName = "messages" + TestConfig.Suffix; 23 | PostgreSqlTransport _transport; 24 | CancellationToken _cancellationToken; 25 | const string QueueName = "input"; 26 | 27 | protected override void SetUp() 28 | { 29 | PostgreSqlTestHelper.DropTable(_tableName); 30 | var consoleLoggerFactory = new ConsoleLoggerFactory(false); 31 | var asyncTaskFactory = new TplAsyncTaskFactory(consoleLoggerFactory); 32 | var connectionHelper = new PostgresConnectionHelper(PostgreSqlTestHelper.ConnectionString); 33 | _transport = new PostgreSqlTransport(connectionHelper, _tableName, QueueName, consoleLoggerFactory, asyncTaskFactory, new DefaultRebusTime()); 34 | _transport.EnsureTableIsCreated(); 35 | 36 | Using(_transport); 37 | 38 | _transport.Initialize(); 39 | _cancellationToken = new CancellationTokenSource().Token; 40 | 41 | } 42 | 43 | [Test] 44 | public async Task ReceivesSentMessageWhenTransactionIsCommitted() 45 | { 46 | using (var scope = new RebusTransactionScope()) 47 | { 48 | await _transport.Send(QueueName, RecognizableMessage(), scope.TransactionContext); 49 | 50 | await scope.CompleteAsync(); 51 | } 52 | 53 | using (var scope = new RebusTransactionScope()) 54 | { 55 | var transportMessage = await _transport.Receive(scope.TransactionContext, _cancellationToken); 56 | 57 | await scope.CompleteAsync(); 58 | 59 | AssertMessageIsRecognized(transportMessage); 60 | } 61 | } 62 | 63 | [Test] 64 | public async Task DoesNotReceiveSentMessageWhenTransactionIsNotCommitted() 65 | { 66 | using (var scope = new RebusTransactionScope()) 67 | { 68 | await _transport.Send(QueueName, RecognizableMessage(), scope.TransactionContext); 69 | 70 | //await context.Complete(); 71 | } 72 | 73 | using (var scope = new RebusTransactionScope()) 74 | { 75 | var transportMessage = await _transport.Receive(scope.TransactionContext, _cancellationToken); 76 | 77 | Assert.That(transportMessage, Is.Null); 78 | } 79 | } 80 | 81 | [TestCase(200)] 82 | public async Task LotsOfAsyncStuffGoingDown(int numberOfMessages) 83 | { 84 | var receivedMessages = 0; 85 | var messageIds = new ConcurrentDictionary(); 86 | 87 | Console.WriteLine("Sending {0} messages", numberOfMessages); 88 | 89 | await Task.WhenAll(Enumerable.Range(0, numberOfMessages) 90 | .Select(async i => 91 | { 92 | using (var scope = new RebusTransactionScope()) 93 | { 94 | await _transport.Send(QueueName, RecognizableMessage(i), scope.TransactionContext); 95 | 96 | await scope.CompleteAsync(); 97 | 98 | messageIds[i] = 0; 99 | } 100 | })); 101 | 102 | Console.WriteLine("Receiving {0} messages", numberOfMessages); 103 | 104 | using (new Timer(_ => Console.WriteLine("Received: {0} msgs", receivedMessages), null, 0, 1000)) 105 | { 106 | 107 | await Task.WhenAll(Enumerable.Range(0, numberOfMessages) 108 | .Select(async i => 109 | { 110 | using (var scope = new RebusTransactionScope()) 111 | { 112 | var msg = await _transport.Receive(scope.TransactionContext, _cancellationToken); 113 | 114 | await scope.CompleteAsync(); 115 | 116 | Interlocked.Increment(ref receivedMessages); 117 | 118 | var id = int.Parse(msg.Headers["id"]); 119 | 120 | messageIds.AddOrUpdate(id, 1, (_, existing) => existing + 1); 121 | } 122 | })); 123 | 124 | await Task.Delay(1000); 125 | } 126 | 127 | Assert.That(messageIds.Keys.OrderBy(k => k).ToArray(), Is.EqualTo(Enumerable.Range(0, numberOfMessages).ToArray())); 128 | 129 | var kvpsDifferentThanOne = messageIds.Where(kvp => kvp.Value != 1).ToList(); 130 | 131 | if (kvpsDifferentThanOne.Any()) 132 | { 133 | var message = $@"Oh no! the following IDs were not received exactly once: 134 | 135 | {string.Join(Environment.NewLine, kvpsDifferentThanOne.Select(kvp => $" {kvp.Key}: {kvp.Value}"))}"; 136 | 137 | 138 | Assert.Fail(message); 139 | } 140 | } 141 | 142 | void AssertMessageIsRecognized(TransportMessage transportMessage) 143 | { 144 | Assert.That(transportMessage.Headers.GetValue("recognizzle"), Is.EqualTo("hej")); 145 | } 146 | 147 | static TransportMessage RecognizableMessage(int id = 0) 148 | { 149 | var headers = new Dictionary 150 | { 151 | {"recognizzle", "hej"}, 152 | {"id", id.ToString()} 153 | }; 154 | return new TransportMessage(headers, new byte[] { 1, 2, 3 }); 155 | } 156 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql/Config/PostgreSqlTransportConfigurationExtensions.cs: -------------------------------------------------------------------------------- 1 | using Rebus.Logging; 2 | using Rebus.Pipeline; 3 | using Rebus.Pipeline.Receive; 4 | using Rebus.PostgreSql; 5 | using Rebus.PostgreSql.Transport; 6 | using Rebus.Threading; 7 | using Rebus.Time; 8 | using Rebus.Timeouts; 9 | using Rebus.Transport; 10 | using System; 11 | 12 | namespace Rebus.Config; 13 | 14 | /// 15 | /// Configuration extensions for the SQL transport 16 | /// 17 | public static class PostgreSqlTransportConfigurationExtensions 18 | { 19 | /// 20 | /// Configures Rebus to use PostgreSql as its transport. The table specified by will be used to 21 | /// store messages, and the "queue" specified by will be used when querying for messages. 22 | /// The message table will automatically be created if it does not exist. 23 | /// 24 | public static void UsePostgreSql(this StandardConfigurer configurer, string connectionString, string tableName, string inputQueueName, TimeSpan? expiredMessagesCleanupInterval = null, string schemaName = null) 25 | { 26 | UsePostgreSql( 27 | configurer: configurer, 28 | connectionProvider: new PostgresConnectionHelper(connectionString), 29 | tableName: tableName, 30 | inputQueueName: inputQueueName, 31 | expiredMessagesCleanupInterval: expiredMessagesCleanupInterval, 32 | schemaName: schemaName 33 | ); 34 | } 35 | 36 | /// 37 | /// Configures Rebus to use PostgreSql as its transport. The table specified by will be used to 38 | /// store messages, and the "queue" specified by will be used when querying for messages. 39 | /// The message table will automatically be created if it does not exist. 40 | /// 41 | public static void UsePostgreSql(this StandardConfigurer configurer, IPostgresConnectionProvider connectionProvider, string tableName, string inputQueueName, TimeSpan? expiredMessagesCleanupInterval = null, string schemaName = null) 42 | { 43 | Configure( 44 | configurer: configurer, 45 | connectionProvider: connectionProvider, 46 | tableName: tableName, 47 | inputQueueName: inputQueueName, 48 | expiredMessagesCleanupInterval: expiredMessagesCleanupInterval, 49 | schemaName: schemaName 50 | ); 51 | } 52 | 53 | /// 54 | /// Configures Rebus to use PostgreSql to transport messages as a one-way client (i.e. will not be able to receive any messages). 55 | /// The table specified by will be used to store messages. 56 | /// The message table will automatically be created if it does not exist. 57 | /// 58 | public static void UsePostgreSqlAsOneWayClient(this StandardConfigurer configurer, string connectionString, string tableName, TimeSpan? expiredMessagesCleanupInterval = null, string schemaName = null) 59 | { 60 | UsePostgreSqlAsOneWayClient( 61 | configurer: configurer, 62 | connectionProvider: new PostgresConnectionHelper(connectionString), 63 | tableName: tableName, 64 | expiredMessagesCleanupInterval: expiredMessagesCleanupInterval, 65 | schemaName: schemaName 66 | ); 67 | } 68 | 69 | /// 70 | /// Configures Rebus to use PostgreSql to transport messages as a one-way client (i.e. will not be able to receive any messages). 71 | /// The table specified by will be used to store messages. 72 | /// The message table will automatically be created if it does not exist. 73 | /// 74 | public static void UsePostgreSqlAsOneWayClient(this StandardConfigurer configurer, IPostgresConnectionProvider connectionProvider, string tableName, TimeSpan? expiredMessagesCleanupInterval = null, string schemaName = null) 75 | { 76 | Configure( 77 | configurer: configurer, 78 | connectionProvider: connectionProvider, 79 | tableName: tableName, 80 | inputQueueName: null, 81 | expiredMessagesCleanupInterval: expiredMessagesCleanupInterval, 82 | schemaName: schemaName 83 | ); 84 | 85 | OneWayClientBackdoor.ConfigureOneWayClient(configurer); 86 | } 87 | 88 | static void Configure(StandardConfigurer configurer, IPostgresConnectionProvider connectionProvider, string tableName, string inputQueueName, TimeSpan? expiredMessagesCleanupInterval, string schemaName = null) 89 | { 90 | configurer.Register(context => 91 | { 92 | var rebusLoggerFactory = context.Get(); 93 | var asyncTaskFactory = context.Get(); 94 | var rebusTime = context.Get(); 95 | var transport = new PostgreSqlTransport(connectionProvider, tableName, inputQueueName, rebusLoggerFactory, asyncTaskFactory, rebusTime, expiredMessagesCleanupInterval, schemaName); 96 | transport.EnsureTableIsCreated(); 97 | return transport; 98 | }); 99 | 100 | configurer.OtherService().Register(c => new DisabledTimeoutManager()); 101 | 102 | configurer.OtherService().Decorate(c => 103 | { 104 | var pipeline = c.Get(); 105 | 106 | return new PipelineStepRemover(pipeline) 107 | .RemoveIncomingStep(s => s.GetType() == typeof(HandleDeferredMessagesStep)); 108 | }); 109 | } 110 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/Outbox/TestSqlServerOutboxStorage.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Text; 4 | using System.Threading.Tasks; 5 | using Npgsql; 6 | using NUnit.Framework; 7 | using Rebus.Messages; 8 | using Rebus.PostgreSql.Outbox; 9 | using Rebus.Tests.Contracts; 10 | using Rebus.Transport; 11 | 12 | // ReSharper disable ArgumentsStyleLiteral 13 | 14 | namespace Rebus.PostgreSql.Tests.Outbox; 15 | 16 | [TestFixture] 17 | public class TestPostgreSqlOutboxStorage : FixtureBase 18 | { 19 | PostgreSqlOutboxStorage _storage; 20 | 21 | protected override void SetUp() 22 | { 23 | base.SetUp(); 24 | 25 | const string tableName = "Outbox"; 26 | 27 | PostgreSqlTestHelper.DropAllTables(); 28 | 29 | _storage = new PostgreSqlOutboxStorage(GetNewDbConnection, new TableName(tableName)); 30 | _storage.Initialize(); 31 | } 32 | 33 | [Test] 34 | public async Task CanStoreBatchOfMessages_Roundtrip() 35 | { 36 | var transportMessage = new TransportMessage(new Dictionary(), new byte[] { 1, 2, 3 }); 37 | var outgoingMessage = new OutgoingTransportMessage(transportMessage, "wherever"); 38 | 39 | await _storage.Save(new[] { outgoingMessage }); 40 | 41 | using var outboxMessageBatch = await _storage.GetNextMessageBatch(); 42 | 43 | Assert.That(outboxMessageBatch.Count, Is.EqualTo(1)); 44 | var outboxMessage = outboxMessageBatch.First(); 45 | Assert.That(outboxMessage.DestinationAddress, Is.EqualTo("wherever")); 46 | Assert.That(outboxMessage.Body, Is.EqualTo(new byte[] { 1, 2, 3 })); 47 | } 48 | 49 | [TestCase(true)] 50 | [TestCase(false)] 51 | public async Task CanStoreBatchOfMessages_ManagedExternally(bool commitAndExpectTheMessagesToBeThere) 52 | { 53 | await using (var connection = new NpgsqlConnection(PostgreSqlTestHelper.ConnectionString)) 54 | { 55 | await connection.OpenAsync(); 56 | 57 | await using var transaction = await connection.BeginTransactionAsync(); 58 | 59 | var dbConnection = new DbConnectionWrapper(connection, transaction, managedExternally: true); 60 | 61 | var transportMessage = new TransportMessage(new Dictionary(), new byte[] { 1, 2, 3 }); 62 | var outgoingMessage = new OutgoingTransportMessage(transportMessage, "wherever"); 63 | 64 | await _storage.Save(new[] { outgoingMessage }, dbConnection); 65 | 66 | if (commitAndExpectTheMessagesToBeThere) 67 | { 68 | await transaction.CommitAsync(); 69 | } 70 | } 71 | 72 | if (commitAndExpectTheMessagesToBeThere) 73 | { 74 | using var batch1 = await _storage.GetNextMessageBatch(); 75 | await batch1.Complete(); 76 | 77 | using var batch2 = await _storage.GetNextMessageBatch(); 78 | 79 | Assert.That(batch1.Count, Is.EqualTo(1)); 80 | Assert.That(batch2.Count, Is.EqualTo(0)); 81 | } 82 | else 83 | { 84 | using var batch = await _storage.GetNextMessageBatch(); 85 | 86 | Assert.That(batch.Count, Is.EqualTo(0)); 87 | } 88 | } 89 | 90 | [Test] 91 | public async Task CanStoreBatchOfMessages_Complete() 92 | { 93 | var transportMessage = new TransportMessage(new Dictionary(), new byte[] { 1, 2, 3 }); 94 | var outgoingMessage = new OutgoingTransportMessage(transportMessage, "wherever"); 95 | 96 | await _storage.Save(new[] { outgoingMessage }); 97 | 98 | using var batch1 = await _storage.GetNextMessageBatch(); 99 | await batch1.Complete(); 100 | 101 | using var batch2 = await _storage.GetNextMessageBatch(); 102 | 103 | Assert.That(batch1.Count, Is.EqualTo(1)); 104 | Assert.That(batch2.Count, Is.EqualTo(0)); 105 | } 106 | 107 | [Test] 108 | public async Task CanGetBatchesOfMessages_VaryingBatchSize() 109 | { 110 | static OutgoingTransportMessage CreateOutgoingMessage(string body) 111 | { 112 | var transportMessage = new TransportMessage(new Dictionary(), Encoding.UTF8.GetBytes(body)); 113 | var outgoingMessage1 = new OutgoingTransportMessage(transportMessage, "wherever"); 114 | return outgoingMessage1; 115 | } 116 | 117 | var texts = Enumerable.Range(0, 100).Select(n => $"message {n:000}").ToList(); 118 | await _storage.Save(texts.Select(CreateOutgoingMessage)); 119 | 120 | using var batch1 = await _storage.GetNextMessageBatch(maxMessageBatchSize: 10); 121 | Assert.That(batch1.Count, Is.EqualTo(10)); 122 | 123 | using var batch2 = await _storage.GetNextMessageBatch(maxMessageBatchSize: 12); 124 | Assert.That(batch2.Count, Is.EqualTo(12)); 125 | 126 | using var batch3 = await _storage.GetNextMessageBatch(maxMessageBatchSize: 77); 127 | Assert.That(batch3.Count, Is.EqualTo(77)); 128 | 129 | using var batch4 = await _storage.GetNextMessageBatch(maxMessageBatchSize: 1); 130 | Assert.That(batch4.Count, Is.EqualTo(1)); 131 | } 132 | 133 | [Test] 134 | public async Task CanGetBatchesOfMessages_TwoBatchesInParallel() 135 | { 136 | static OutgoingTransportMessage CreateOutgoingMessage(string body) 137 | { 138 | var transportMessage = new TransportMessage(new Dictionary(), Encoding.UTF8.GetBytes(body)); 139 | var outgoingMessage1 = new OutgoingTransportMessage(transportMessage, "wherever"); 140 | return outgoingMessage1; 141 | } 142 | 143 | var texts = Enumerable.Range(0, 200).Select(n => $"message {n:000}").ToList(); 144 | 145 | await _storage.Save(texts.Select(CreateOutgoingMessage)); 146 | 147 | using var batch1 = await _storage.GetNextMessageBatch(); 148 | Assert.That(batch1.Count, Is.EqualTo(100)); 149 | 150 | using var batch2 = await _storage.GetNextMessageBatch(); 151 | Assert.That(batch2.Count, Is.EqualTo(100)); 152 | 153 | var roundtrippedTexts = batch1.Concat(batch2).Select(b => Encoding.UTF8.GetString(b.Body)).ToList(); 154 | 155 | Assert.That(roundtrippedTexts.OrderBy(t => t), Is.EqualTo(texts)); 156 | } 157 | 158 | static IDbConnection GetNewDbConnection(ITransactionContext _) 159 | { 160 | var connection = new NpgsqlConnection(PostgreSqlTestHelper.ConnectionString); 161 | connection.Open(); 162 | var transaction = connection.BeginTransaction(); 163 | return new DbConnectionWrapper(connection, transaction, managedExternally: false); 164 | } 165 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/Subscriptions/PostgreSqlSubscriptionStorage.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using Npgsql; 5 | using NpgsqlTypes; 6 | using Rebus.Internals; 7 | using Rebus.Logging; 8 | using Rebus.Subscriptions; 9 | 10 | namespace Rebus.PostgreSql.Subscriptions; 11 | 12 | /// 13 | /// Implementation of that uses Postgres to do its thing 14 | /// 15 | public class PostgreSqlSubscriptionStorage : ISubscriptionStorage 16 | { 17 | const string UniqueKeyViolation = "23505"; 18 | 19 | readonly IPostgresConnectionProvider _connectionHelper; 20 | readonly TableName _tableName; 21 | readonly ILog _log; 22 | 23 | /// 24 | /// Constructs the subscription storage, storing subscriptions in the specified . 25 | /// If is true, subscribing/unsubscribing will be short-circuited by manipulating 26 | /// subscriptions directly, instead of requesting via messages 27 | /// 28 | public PostgreSqlSubscriptionStorage(IPostgresConnectionProvider connectionHelper, string tableName, bool isCentralized, IRebusLoggerFactory rebusLoggerFactory, string schemaName = null) 29 | { 30 | if (rebusLoggerFactory == null) throw new ArgumentNullException(nameof(rebusLoggerFactory)); 31 | _connectionHelper = connectionHelper ?? throw new ArgumentNullException(nameof(connectionHelper)); 32 | _tableName = new TableName(schemaName ?? TableName.DefaultSchemaName, tableName ?? throw new ArgumentNullException(nameof(tableName))); 33 | IsCentralized = isCentralized; 34 | _log = rebusLoggerFactory.GetLogger(); 35 | } 36 | 37 | /// 38 | /// Creates the subscriptions table if no table with the specified name exists 39 | /// 40 | public void EnsureTableIsCreated() 41 | { 42 | async Task InnerEnsureTableIsCreated() 43 | { 44 | using var connection = await _connectionHelper.GetConnection(); 45 | 46 | var tableNames = connection.GetTableNames(); 47 | 48 | if (tableNames.Contains(_tableName)) return; 49 | 50 | _log.Info("Table {tableName} does not exist - it will be created now", _tableName); 51 | 52 | var schemaNames = connection.GetSchemas(); 53 | 54 | if (!schemaNames.Contains(_tableName.Schema)) 55 | { 56 | _log.Info("Schema {schemaName} does not exist - it will be created now", _tableName.Schema); 57 | 58 | using (var command = connection.CreateCommand()) 59 | { 60 | command.CommandText = $@"CREATE SCHEMA ""{_tableName.Schema}"";"; 61 | 62 | command.ExecuteNonQuery(); 63 | } 64 | } 65 | 66 | using (var command = connection.CreateCommand()) 67 | { 68 | command.CommandText = $@" 69 | CREATE TABLE {_tableName} ( 70 | ""topic"" VARCHAR(200) NOT NULL, 71 | ""address"" VARCHAR(200) NOT NULL, 72 | PRIMARY KEY (""topic"", ""address"") 73 | ); 74 | "; 75 | command.ExecuteNonQuery(); 76 | } 77 | 78 | await connection.Complete(); 79 | } 80 | 81 | var retrier = new Retrier([ 82 | TimeSpan.FromSeconds(1), 83 | TimeSpan.FromSeconds(2), 84 | TimeSpan.FromSeconds(3), 85 | TimeSpan.FromSeconds(4), 86 | TimeSpan.FromSeconds(5) 87 | ]); 88 | 89 | AsyncHelpers.RunSync(() => retrier.ExecuteAsync(InnerEnsureTableIsCreated)); 90 | } 91 | 92 | /// 93 | /// Gets all destination addresses for the given topic 94 | /// 95 | public async Task> GetSubscriberAddresses(string topic) 96 | { 97 | using var connection = await _connectionHelper.GetConnection(); 98 | 99 | using var command = connection.CreateCommand(); 100 | 101 | command.CommandText = $@"select ""address"" from {_tableName} where ""topic"" = @topic"; 102 | command.Parameters.AddWithValue("topic", NpgsqlDbType.Text, topic); 103 | 104 | var endpoints = new List(); 105 | 106 | await using var reader = command.ExecuteReader(); 107 | 108 | while (reader.Read()) 109 | { 110 | endpoints.Add((string)reader["address"]); 111 | } 112 | 113 | return endpoints.ToArray(); 114 | } 115 | 116 | /// 117 | /// Registers the given as a subscriber of the given topic 118 | /// 119 | public async Task RegisterSubscriber(string topic, string subscriberAddress) 120 | { 121 | using var connection = await _connectionHelper.GetConnection(); 122 | 123 | using var command = connection.CreateCommand(); 124 | 125 | command.CommandText = $@"insert into {_tableName} (""topic"", ""address"") values (@topic, @address)"; 126 | 127 | command.Parameters.AddWithValue("topic", NpgsqlDbType.Text, topic); 128 | command.Parameters.AddWithValue("address", NpgsqlDbType.Text, subscriberAddress); 129 | 130 | try 131 | { 132 | command.ExecuteNonQuery(); 133 | } 134 | catch (PostgresException exception) when (exception.SqlState == UniqueKeyViolation) 135 | { 136 | // it's already there 137 | } 138 | 139 | await connection.Complete(); 140 | } 141 | 142 | /// 143 | /// Unregisters the given as a subscriber of the given topic 144 | /// 145 | public async Task UnregisterSubscriber(string topic, string subscriberAddress) 146 | { 147 | using var connection = await _connectionHelper.GetConnection(); 148 | 149 | using var command = connection.CreateCommand(); 150 | 151 | command.CommandText = $@"delete from {_tableName} where ""topic"" = @topic and ""address"" = @address;"; 152 | 153 | command.Parameters.AddWithValue("topic", NpgsqlDbType.Text, topic); 154 | command.Parameters.AddWithValue("address", NpgsqlDbType.Text, subscriberAddress); 155 | 156 | try 157 | { 158 | command.ExecuteNonQuery(); 159 | } 160 | catch (NpgsqlException exception) 161 | { 162 | Console.WriteLine(exception); 163 | } 164 | 165 | await connection.Complete(); 166 | } 167 | 168 | /// 169 | /// Gets whether the subscription storage is centralized and thus supports bypassing the usual subscription request 170 | /// 171 | public bool IsCentralized { get; } 172 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/Timeouts/PostgreSqlTimeoutManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using NpgsqlTypes; 5 | using Rebus.Internals; 6 | using Rebus.Logging; 7 | using Rebus.Serialization; 8 | using Rebus.Time; 9 | using Rebus.Timeouts; 10 | // ReSharper disable AccessToDisposedClosure 11 | 12 | #pragma warning disable 1998 13 | 14 | namespace Rebus.PostgreSql.Timeouts; 15 | 16 | /// 17 | /// Implementation of that uses PostgreSql to do its thing. Can be used safely by multiple processes competing 18 | /// over the same table of timeouts because row-level locking is used when querying for due timeouts. 19 | /// 20 | public class PostgreSqlTimeoutManager : ITimeoutManager 21 | { 22 | readonly DictionarySerializer _dictionarySerializer = new(); 23 | readonly IPostgresConnectionProvider _connectionHelper; 24 | readonly TableName _tableName; 25 | readonly IRebusTime _rebusTime; 26 | readonly ILog _log; 27 | 28 | /// 29 | /// Constructs the timeout manager 30 | /// 31 | public PostgreSqlTimeoutManager(IPostgresConnectionProvider connectionHelper, string tableName, IRebusLoggerFactory rebusLoggerFactory, IRebusTime rebusTime, string schemaName = null) 32 | { 33 | if (rebusLoggerFactory == null) throw new ArgumentNullException(nameof(rebusLoggerFactory)); 34 | _connectionHelper = connectionHelper ?? throw new ArgumentNullException(nameof(connectionHelper)); 35 | _tableName = new TableName(schemaName ?? TableName.DefaultSchemaName, tableName ?? throw new ArgumentNullException(nameof(tableName))); 36 | _rebusTime = rebusTime ?? throw new ArgumentNullException(nameof(rebusTime)); 37 | _log = rebusLoggerFactory.GetLogger(); 38 | } 39 | 40 | /// 41 | /// Stores the message with the given headers and body data, delaying it until the specified 42 | /// 43 | public async Task Defer(DateTimeOffset approximateDueTime, Dictionary headers, byte[] body) 44 | { 45 | using var connection = await _connectionHelper.GetConnection(); 46 | 47 | using var command = connection.CreateCommand(); 48 | 49 | command.CommandText = $@"INSERT INTO {_tableName} (""due_time"", ""headers"", ""body"") VALUES (@due_time, @headers, @body)"; 50 | 51 | command.Parameters.Add("due_time", NpgsqlDbType.Timestamp).Value = approximateDueTime.ToUniversalTime().DateTime; 52 | command.Parameters.Add("headers", NpgsqlDbType.Text).Value = _dictionarySerializer.SerializeToString(headers); 53 | command.Parameters.Add("body", NpgsqlDbType.Bytea).Value = body; 54 | 55 | await command.ExecuteNonQueryAsync(); 56 | 57 | await connection.Complete(); 58 | } 59 | 60 | /// 61 | /// Gets due messages as of now, given the approximate due time that they were stored with when was called 62 | /// 63 | public async Task GetDueMessages() 64 | { 65 | var connection = await _connectionHelper.GetConnection(); 66 | 67 | try 68 | { 69 | using var command = connection.CreateCommand(); 70 | 71 | command.CommandText = 72 | $@" 73 | 74 | SELECT 75 | ""id"", 76 | ""headers"", 77 | ""body"" 78 | 79 | FROM {_tableName} 80 | 81 | WHERE ""due_time"" <= @current_time 82 | 83 | ORDER BY ""due_time"" 84 | 85 | FOR UPDATE; 86 | 87 | "; 88 | command.Parameters.Add("current_time", NpgsqlDbType.Timestamp).Value = _rebusTime.Now.ToUniversalTime().DateTime; 89 | 90 | await using var reader = await command.ExecuteReaderAsync(); 91 | var dueMessages = new List(); 92 | 93 | while (reader.Read()) 94 | { 95 | var id = (long)reader["id"]; 96 | var headers = _dictionarySerializer.DeserializeFromString((string) reader["headers"]); 97 | var body = (byte[]) reader["body"]; 98 | 99 | dueMessages.Add(new DueMessage(headers, body, async () => 100 | { 101 | using var deleteCommand = connection.CreateCommand(); 102 | deleteCommand.CommandText = $@"DELETE FROM {_tableName} WHERE ""id"" = @id"; 103 | deleteCommand.Parameters.Add("id", NpgsqlDbType.Bigint).Value = id; 104 | await deleteCommand.ExecuteNonQueryAsync(); 105 | })); 106 | } 107 | 108 | return new DueMessagesResult(dueMessages, async () => 109 | { 110 | await connection.Complete(); 111 | connection.Dispose(); 112 | }); 113 | } 114 | catch (Exception) 115 | { 116 | connection.Dispose(); 117 | throw; 118 | } 119 | } 120 | 121 | /// 122 | /// Checks if the configured timeouts table exists - if it doesn't, it will be created. 123 | /// 124 | public void EnsureTableIsCreated() 125 | { 126 | async Task InnerEnsureTableIsCreated() 127 | { 128 | using var connection = await _connectionHelper.GetConnection(); 129 | 130 | var tableNames = connection.GetTableNames(); 131 | 132 | if (tableNames.Contains(_tableName)) 133 | { 134 | return; 135 | } 136 | 137 | _log.Info("Table {tableName} does not exist - it will be created now", _tableName); 138 | 139 | var schemaNames = connection.GetSchemas(); 140 | 141 | if (!schemaNames.Contains(_tableName.Schema)) 142 | { 143 | _log.Info("Schema {schemaName} does not exist - it will be created now", _tableName.Schema); 144 | 145 | using (var command = connection.CreateCommand()) 146 | { 147 | command.CommandText = $@"CREATE SCHEMA ""{_tableName.Schema}"";"; 148 | 149 | command.ExecuteNonQuery(); 150 | } 151 | } 152 | 153 | using (var command = connection.CreateCommand()) 154 | { 155 | command.CommandText = $@" 156 | CREATE TABLE {_tableName} ( 157 | ""id"" BIGSERIAL NOT NULL, 158 | ""due_time"" TIMESTAMP WITH TIME ZONE NOT NULL, 159 | ""headers"" TEXT NULL, 160 | ""body"" BYTEA NULL, 161 | PRIMARY KEY (""id"") 162 | ); 163 | "; 164 | 165 | command.ExecuteNonQuery(); 166 | } 167 | 168 | using (var command = connection.CreateCommand()) 169 | { 170 | command.CommandText = $@" 171 | CREATE INDEX ON {_tableName} (""due_time""); 172 | "; 173 | 174 | command.ExecuteNonQuery(); 175 | } 176 | 177 | await connection.Complete(); 178 | } 179 | 180 | var retrier = new Retrier([ 181 | TimeSpan.FromSeconds(1), 182 | TimeSpan.FromSeconds(2), 183 | TimeSpan.FromSeconds(3), 184 | TimeSpan.FromSeconds(4), 185 | TimeSpan.FromSeconds(5) 186 | ]); 187 | 188 | AsyncHelpers.RunSync(() => retrier.ExecuteAsync(InnerEnsureTableIsCreated)); 189 | } 190 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql/Config/PostgreSqlConfigurationExtensions.cs: -------------------------------------------------------------------------------- 1 | using Rebus.Auditing.Sagas; 2 | using System; 3 | using Npgsql; 4 | using Rebus.Logging; 5 | using Rebus.PostgreSql; 6 | using Rebus.PostgreSql.Sagas; 7 | using Rebus.PostgreSql.Subscriptions; 8 | using Rebus.PostgreSql.Timeouts; 9 | using Rebus.Sagas; 10 | using Rebus.Subscriptions; 11 | using Rebus.Time; 12 | using Rebus.Timeouts; 13 | // ReSharper disable UnusedMember.Global 14 | 15 | namespace Rebus.Config; 16 | 17 | /// 18 | /// Configuration extensions for Postgres persistence 19 | /// 20 | public static class PostgreSqlConfigurationExtensions 21 | { 22 | /// 23 | /// Configures Rebus to use PostgreSql to store saga data snapshots, using the specified table to store the data 24 | /// 25 | public static void StoreInPostgres(this StandardConfigurer configurer, string connectionString, string tableName, bool automaticallyCreateTables = true, Action additionalConnectionSetup = null) 26 | { 27 | var provider = new PostgresConnectionHelper(connectionString, additionalConnectionSetup); 28 | 29 | StoreInPostgres(configurer, provider, tableName, automaticallyCreateTables); 30 | } 31 | 32 | /// 33 | /// Configures Rebus to use PostgreSql to store saga data snapshots, using the specified table to store the data 34 | /// 35 | public static void StoreInPostgres(this StandardConfigurer configurer, IPostgresConnectionProvider connectionProvider, string tableName, bool automaticallyCreateTables = true, string schemaName = null) 36 | { 37 | configurer.Register(c => 38 | { 39 | var sagaStorage = new PostgreSqlSagaSnapshotStorage(connectionProvider, tableName, schemaName); 40 | 41 | if (automaticallyCreateTables) 42 | { 43 | sagaStorage.EnsureTableIsCreated(); 44 | } 45 | 46 | return sagaStorage; 47 | }); 48 | } 49 | 50 | /// 51 | /// Configures Rebus to use PostgreSql to store sagas, using the tables specified to store data and indexed properties respectively. 52 | /// 53 | public static void StoreInPostgres(this StandardConfigurer configurer, string connectionString, string dataTableName, string indexTableName, bool automaticallyCreateTables = true, Action additionalConnectionSetup = null, string schemaName = null) 54 | { 55 | var provider = new PostgresConnectionHelper(connectionString, additionalConnectionSetup); 56 | 57 | StoreInPostgres(configurer, provider, dataTableName, indexTableName, automaticallyCreateTables, schemaName); 58 | } 59 | 60 | /// 61 | /// Configures Rebus to use PostgreSql to store sagas, using the tables specified to store data and indexed properties respectively. 62 | /// 63 | public static void StoreInPostgres(this StandardConfigurer configurer, IPostgresConnectionProvider connectionProvider, string dataTableName, string indexTableName, bool automaticallyCreateTables = true, string schemaName = null) 64 | { 65 | configurer.Register(c => 66 | { 67 | var rebusLoggerFactory = c.Get(); 68 | var serializer = c.Has(false) ? c.Get() : new DefaultSagaSerializer(); 69 | 70 | var sagaStorage = new PostgreSqlSagaStorage(connectionProvider, dataTableName, indexTableName, rebusLoggerFactory, serializer, schemaName); 71 | 72 | if (automaticallyCreateTables) 73 | { 74 | sagaStorage.EnsureTablesAreCreated(); 75 | } 76 | 77 | return sagaStorage; 78 | }); 79 | } 80 | 81 | /// 82 | /// Configures Rebus to use PostgreSql to store timeouts. 83 | /// 84 | public static void StoreInPostgres(this StandardConfigurer configurer, string connectionString, string tableName, bool automaticallyCreateTables = true, Action additionalConnectionSetup = null) 85 | { 86 | var provider = new PostgresConnectionHelper(connectionString, additionalConnectionSetup); 87 | 88 | StoreInPostgres(configurer, provider, tableName, automaticallyCreateTables); 89 | } 90 | 91 | /// 92 | /// Configures Rebus to use PostgreSql to store timeouts. 93 | /// 94 | public static void StoreInPostgres(this StandardConfigurer configurer, IPostgresConnectionProvider connectionProvider, string tableName, bool automaticallyCreateTables = true, string schemaName = null) 95 | { 96 | configurer.Register(c => 97 | { 98 | var rebusLoggerFactory = c.Get(); 99 | var rebusTime = c.Get(); 100 | var subscriptionStorage = new PostgreSqlTimeoutManager(connectionProvider, tableName, rebusLoggerFactory, rebusTime, schemaName); 101 | 102 | if (automaticallyCreateTables) 103 | { 104 | subscriptionStorage.EnsureTableIsCreated(); 105 | } 106 | 107 | return subscriptionStorage; 108 | }); 109 | } 110 | 111 | /// 112 | /// Configures Rebus to use PostgreSql to store subscriptions. Use = true to indicate whether it's OK to short-circuit 113 | /// subscribing and unsubscribing by manipulating the subscription directly from the subscriber or just let it default to false to preserve the 114 | /// default behavior. 115 | /// 116 | public static void StoreInPostgres(this StandardConfigurer configurer, string connectionString, string tableName, bool isCentralized = false, bool automaticallyCreateTables = true, Action additionalConnectionSetup = null) 117 | { 118 | var provider = new PostgresConnectionHelper(connectionString, additionalConnectionSetup); 119 | 120 | StoreInPostgres(configurer, provider, tableName, isCentralized, automaticallyCreateTables); 121 | } 122 | 123 | /// 124 | /// Configures Rebus to use PostgreSql to store subscriptions. Use = true to indicate whether it's OK to short-circuit 125 | /// subscribing and unsubscribing by manipulating the subscription directly from the subscriber or just let it default to false to preserve the 126 | /// default behavior. 127 | /// 128 | public static void StoreInPostgres(this StandardConfigurer configurer, IPostgresConnectionProvider connectionProvider, string tableName, bool isCentralized = false, bool automaticallyCreateTables = true, string schemaName = null) 129 | { 130 | configurer.Register(c => 131 | { 132 | var rebusLoggerFactory = c.Get(); 133 | var subscriptionStorage = new PostgreSqlSubscriptionStorage(connectionProvider, tableName, isCentralized, rebusLoggerFactory, schemaName); 134 | 135 | if (automaticallyCreateTables) 136 | { 137 | subscriptionStorage.EnsureTableIsCreated(); 138 | } 139 | 140 | return subscriptionStorage; 141 | }); 142 | } 143 | 144 | public static void UseSagaSerializer(this StandardConfigurer configurer, ISagaSerializer serializer = null) 145 | { 146 | if (configurer == null) throw new ArgumentNullException(nameof(configurer)); 147 | if (serializer == null) 148 | { 149 | serializer = new DefaultSagaSerializer(); 150 | } 151 | 152 | configurer.OtherService().Decorate((c) => serializer); 153 | } 154 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/Outbox/PostgreSqlOutboxStorage.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using NpgsqlTypes; 6 | using Rebus.Bus; 7 | using Rebus.Internals; 8 | using Rebus.Serialization; 9 | using Rebus.Transport; 10 | 11 | namespace Rebus.PostgreSql.Outbox; 12 | 13 | /// 14 | /// Outbox implementation that uses a table in PostgreSql Server to store the necessary outbox information 15 | /// 16 | public class PostgreSqlOutboxStorage : IOutboxStorage, IInitializable 17 | { 18 | static readonly HeaderSerializer HeaderSerializer = new(); 19 | readonly Func _connectionProvider; 20 | readonly TableName _tableName; 21 | 22 | /// 23 | /// Creates the outbox storage 24 | /// 25 | public PostgreSqlOutboxStorage(Func connectionProvider, TableName tableName) 26 | { 27 | _connectionProvider = connectionProvider ?? throw new ArgumentNullException(nameof(connectionProvider)); 28 | _tableName = tableName ?? throw new ArgumentNullException(nameof(tableName)); 29 | } 30 | 31 | /// 32 | /// Initializes the outbox storage 33 | /// 34 | public void Initialize() 35 | { 36 | async Task InitializeAsync() 37 | { 38 | using var scope = new RebusTransactionScope(); 39 | using var connection = _connectionProvider(scope.TransactionContext); 40 | 41 | if (connection.GetTableNames().Contains(_tableName)) return; 42 | 43 | try 44 | { 45 | using var command = connection.CreateCommand(); 46 | 47 | command.CommandText = $@" 48 | CREATE TABLE {_tableName} 49 | ( 50 | ""Id"" BIGINT GENERATED BY DEFAULT AS IDENTITY, 51 | ""CorrelationId"" VARCHAR(16) NULL, 52 | ""MessageId"" VARCHAR(255) NULL, 53 | ""SourceQueue"" VARCHAR(255) NULL, 54 | ""DestinationAddress"" VARCHAR(255) NOT NULL, 55 | ""Headers"" TEXT NULL, 56 | ""Body"" BYTEA NULL, 57 | ""Sent"" BOOLEAN NOT NULL DEFAULT(FALSE), 58 | PRIMARY KEY (""Id"") 59 | ) 60 | "; 61 | 62 | await command.ExecuteNonQueryAsync(); 63 | 64 | await connection.Complete(); 65 | } 66 | catch (Exception) 67 | { 68 | if (!connection.GetTableNames().Contains(_tableName)) 69 | { 70 | throw; 71 | } 72 | } 73 | 74 | await scope.CompleteAsync(); 75 | } 76 | 77 | AsyncHelpers.RunSync(InitializeAsync); 78 | } 79 | 80 | /// 81 | /// Stores the given as being the result of processing message with ID 82 | /// in the queue of this particular endpoint. If is an empty sequence, a note is made of the fact 83 | /// that the message with ID has been processed. 84 | /// 85 | public async Task Save(IEnumerable outgoingMessages, string messageId = null, string sourceQueue = null, string correlationId = null) 86 | { 87 | if (outgoingMessages == null) throw new ArgumentNullException(nameof(outgoingMessages)); 88 | 89 | await InnerSave(outgoingMessages, messageId, sourceQueue, correlationId); 90 | } 91 | 92 | /// 93 | /// Stores the given using the given . 94 | /// 95 | public async Task Save(IEnumerable outgoingMessages, IDbConnection dbConnection) 96 | { 97 | if (outgoingMessages == null) throw new ArgumentNullException(nameof(outgoingMessages)); 98 | if (dbConnection == null) throw new ArgumentNullException(nameof(dbConnection)); 99 | 100 | await SaveUsingConnection(dbConnection, outgoingMessages); 101 | } 102 | 103 | /// 104 | public async Task GetNextMessageBatch(string correlationId = null, int maxMessageBatchSize = 100) 105 | { 106 | return await InnerGetMessageBatch(maxMessageBatchSize, correlationId); 107 | } 108 | 109 | async Task InnerGetMessageBatch(int maxMessageBatchSize, string correlationId) 110 | { 111 | if (maxMessageBatchSize <= 0) 112 | { 113 | throw new ArgumentException( 114 | $"Cannot retrieve {maxMessageBatchSize} messages - please pass in a value >= 1", 115 | nameof(maxMessageBatchSize)); 116 | } 117 | 118 | // no 'using' here, because this will be passed to the outbox message batch 119 | var scope = new RebusTransactionScope(); 120 | 121 | try 122 | { 123 | // no 'using' here either, because this will be passed to the outbox message batch 124 | var connection = _connectionProvider(scope.TransactionContext); 125 | 126 | // this must be done when cleanining up 127 | void Dispose() 128 | { 129 | connection.Dispose(); 130 | scope.Dispose(); 131 | } 132 | 133 | try 134 | { 135 | var messages = await GetOutboxMessages(connection, maxMessageBatchSize, correlationId); 136 | 137 | // bail out if no messages were found 138 | if (!messages.Any()) return OutboxMessageBatch.Empty(Dispose); 139 | 140 | // define what it means to complete the batch 141 | async Task Complete() 142 | { 143 | await connection.Complete(); 144 | await scope.CompleteAsync(); 145 | } 146 | 147 | return new OutboxMessageBatch(Complete, messages, Dispose); 148 | } 149 | catch (Exception) 150 | { 151 | connection.Dispose(); 152 | throw; 153 | } 154 | } 155 | catch (Exception) 156 | { 157 | scope.Dispose(); 158 | throw; 159 | } 160 | } 161 | 162 | async Task InnerSave(IEnumerable outgoingMessages, string messageId, string sourceQueue, string correlationId) 163 | { 164 | using var scope = new RebusTransactionScope(); 165 | using var connection = _connectionProvider(scope.TransactionContext); 166 | 167 | await SaveUsingConnection(connection, outgoingMessages, messageId, sourceQueue, correlationId); 168 | 169 | await connection.Complete(); 170 | await scope.CompleteAsync(); 171 | } 172 | 173 | async Task SaveUsingConnection(IDbConnection connection, IEnumerable outgoingMessages, string messageId = null, string sourceQueue = null, string correlationId = null) 174 | { 175 | foreach (var message in outgoingMessages) 176 | { 177 | using var command = connection.CreateCommand(); 178 | 179 | var transportMessage = message.TransportMessage; 180 | var body = message.TransportMessage.Body; 181 | var headers = SerializeHeaders(transportMessage.Headers); 182 | 183 | command.CommandText = $"INSERT INTO {_tableName} (\"CorrelationId\", \"MessageId\", \"SourceQueue\", \"DestinationAddress\", \"Headers\", \"Body\") VALUES (@correlationId, @messageId, @sourceQueue, @destinationAddress, @headers, @body)"; 184 | command.Parameters.Add("correlationId", NpgsqlDbType.Varchar, 16).Value = (object)correlationId ?? DBNull.Value; 185 | command.Parameters.Add("messageId", NpgsqlDbType.Varchar, 255).Value = (object)messageId ?? DBNull.Value; 186 | command.Parameters.Add("sourceQueue", NpgsqlDbType.Varchar, 255).Value = (object)sourceQueue ?? DBNull.Value; 187 | command.Parameters.Add("destinationAddress", NpgsqlDbType.Varchar, 255).Value = message.DestinationAddress; 188 | command.Parameters.Add("headers", NpgsqlDbType.Varchar, headers.Length.RoundUpToNextPowerOfTwo()).Value = headers; 189 | command.Parameters.Add("body", NpgsqlDbType.Bytea, body.Length.RoundUpToNextPowerOfTwo()).Value = body; 190 | 191 | await command.ExecuteNonQueryAsync(); 192 | } 193 | } 194 | 195 | async Task> GetOutboxMessages(IDbConnection connection, int maxMessageBatchSize, string correlationId) 196 | { 197 | using var command = connection.CreateCommand(); 198 | 199 | if (correlationId != null) 200 | { 201 | command.CommandText = $@" 202 | DELETE FROM {_tableName} 203 | WHERE ""Id"" in 204 | ( 205 | select ""Id"" 206 | from {_tableName} 207 | where ""CorrelationId"" = @correlationId 208 | order by ""Id"" asc 209 | for update skip locked 210 | limit {maxMessageBatchSize} 211 | ) 212 | returning ""Id"", ""DestinationAddress"", ""Headers"", ""Body"" 213 | "; 214 | command.Parameters.Add("correlationId", NpgsqlDbType.Varchar, 16).Value = correlationId; 215 | } 216 | else 217 | { 218 | command.CommandText = $@" 219 | DELETE FROM {_tableName} 220 | WHERE ""Id"" in 221 | ( 222 | select ""Id"" 223 | from {_tableName} 224 | order by ""Id"" asc 225 | for update skip locked 226 | limit {maxMessageBatchSize} 227 | ) 228 | returning ""Id"", ""DestinationAddress"", ""Headers"", ""Body"" 229 | "; 230 | } 231 | 232 | await using var reader = await command.ExecuteReaderAsync(); 233 | 234 | var messages = new List(); 235 | 236 | while (await reader.ReadAsync()) 237 | { 238 | var id = (long)reader["id"]; 239 | var destinationAddress = (string)reader["destinationAddress"]; 240 | var headers = HeaderSerializer.DeserializeFromString((string)reader["headers"]); 241 | var body = (byte[])reader["body"]; 242 | messages.Add(new OutboxMessage(id, destinationAddress, headers, body)); 243 | } 244 | 245 | return messages; 246 | } 247 | 248 | static string SerializeHeaders(Dictionary headers) => HeaderSerializer.SerializeToString(headers); 249 | } 250 | -------------------------------------------------------------------------------- /Rebus.PostgreSql.Tests/Outbox/TestOutbox_OutsideOfRebusHandler.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using System.Transactions; 5 | using Npgsql; 6 | using NUnit.Framework; 7 | using Rebus.Activation; 8 | using Rebus.Bus; 9 | using Rebus.Config; 10 | using Rebus.Config.Outbox; 11 | using Rebus.Persistence.InMem; 12 | using Rebus.Routing; 13 | using Rebus.Routing.TypeBased; 14 | using Rebus.Tests.Contracts; 15 | using Rebus.Transport; 16 | using Rebus.Transport.InMem; 17 | 18 | // ReSharper disable ArgumentsStyleLiteral 19 | // ReSharper disable AccessToDisposedClosure 20 | #pragma warning disable CS1998 21 | 22 | namespace Rebus.PostgreSql.Tests.Outbox; 23 | 24 | [TestFixture] 25 | public class TestOutbox_OutsideOfRebusHandler : FixtureBase 26 | { 27 | static string ConnectionString => PostgreSqlTestHelper.ConnectionString; 28 | 29 | InMemNetwork _network; 30 | InMemorySubscriberStore _subscriberStore; 31 | 32 | protected override void SetUp() 33 | { 34 | base.SetUp(); 35 | 36 | PostgreSqlTestHelper.DropTable("RebusOutbox"); 37 | 38 | _network = new InMemNetwork(); 39 | _subscriberStore = new InMemorySubscriberStore(); 40 | } 41 | 42 | record SomeMessage; 43 | 44 | [Test] 45 | public async Task CannotUseOutboxTwice() 46 | { 47 | await using var connection = new NpgsqlConnection(ConnectionString); 48 | await connection.OpenAsync(); 49 | await using var transaction = await connection.BeginTransactionAsync(); 50 | 51 | using var scope = new RebusTransactionScope(); 52 | scope.UseOutbox(connection, transaction); 53 | 54 | Assert.Throws(() => scope.UseOutbox(connection, transaction)); 55 | } 56 | 57 | [TestCase(true, true)] 58 | [TestCase(false, false)] 59 | public async Task CanUseOutboxOutsideOfRebusHandler_Publish(bool commitTransaction, bool expectMessageToBeReceived) 60 | { 61 | var settings = new FlakySenderTransportDecoratorSettings(); 62 | 63 | using var messageWasReceived = new ManualResetEvent(initialState: false); 64 | using var server = CreateConsumer("server", a => a.Handle(async _ => messageWasReceived.Set())); 65 | 66 | await server.Subscribe(); 67 | 68 | using var client = CreateOneWayClient(flakySenderTransportDecoratorSettings: settings); 69 | 70 | // set success rate pretty low, so we're sure that it's currently not possible to use the 71 | // real transport - this is a job for the outbox! 72 | settings.SuccessRate = 0; 73 | 74 | // pretending we're in a web app - we have these two bad boys at work: 75 | await using (var connection = new NpgsqlConnection(ConnectionString)) 76 | { 77 | await connection.OpenAsync(); 78 | await using var transaction = await connection.BeginTransactionAsync(); 79 | 80 | // this is how we would use the outbox for outgoing messages 81 | using var scope = new RebusTransactionScope(); 82 | scope.UseOutbox(connection, transaction); 83 | await client.Publish(new SomeMessage()); 84 | await scope.CompleteAsync(); 85 | 86 | if (commitTransaction) 87 | { 88 | // this is what we were all waiting for! 89 | await transaction.CommitAsync(); 90 | } 91 | } 92 | 93 | // we would not have gotten this far without the outbox - now let's pretend that the transport has recovered 94 | settings.SuccessRate = 1; 95 | 96 | // wait for server to receive the event 97 | Assert.That(messageWasReceived.WaitOne(TimeSpan.FromSeconds(15)), Is.EqualTo(expectMessageToBeReceived), 98 | $"When commitTransaction={commitTransaction} we {(expectMessageToBeReceived ? "expected the message to be sent and thus received" : "did NOT expect the message to be sent and therefore also not received")}"); 99 | } 100 | 101 | [TestCase(true, true)] 102 | [TestCase(false, false)] 103 | public async Task CanUseOutboxOutsideOfRebusHandler_AmbientTransaction_Publish(bool commitTransaction, bool expectMessageToBeReceived) 104 | { 105 | var settings = new FlakySenderTransportDecoratorSettings(); 106 | 107 | using var messageWasReceived = new ManualResetEvent(initialState: false); 108 | using var server = CreateConsumer("server", a => a.Handle(async _ => messageWasReceived.Set())); 109 | 110 | await server.Subscribe(); 111 | 112 | using var client = CreateOneWayClient(flakySenderTransportDecoratorSettings: settings); 113 | 114 | // set success rate pretty low, so we're sure that it's currently not possible to use the 115 | // real transport - this is a job for the outbox! 116 | settings.SuccessRate = 0; 117 | 118 | // pretending we're in a web app - we have these two bad boys at work: 119 | using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) 120 | { 121 | // this is how we would use the outbox for outgoing messages 122 | await client.Publish(new SomeMessage()); 123 | 124 | if (commitTransaction) 125 | { 126 | // this is what we were all waiting for! 127 | tx.Complete(); 128 | } 129 | } 130 | 131 | // we would not have gotten this far without the outbox - now let's pretend that the transport has recovered 132 | settings.SuccessRate = 1; 133 | 134 | // wait for server to receive the event 135 | Assert.That(messageWasReceived.WaitOne(TimeSpan.FromSeconds(15)), Is.EqualTo(expectMessageToBeReceived), 136 | $"When commitTransaction={commitTransaction} we {(expectMessageToBeReceived ? "expected the message to be sent and thus received" : "did NOT expect the message to be sent and therefore also not received")}"); 137 | } 138 | 139 | [TestCase(true, true)] 140 | [TestCase(false, false)] 141 | public async Task CanUseOutboxOutsideOfRebusHandler_Send(bool commitTransaction, bool expectMessageToBeReceived) 142 | { 143 | var settings = new FlakySenderTransportDecoratorSettings(); 144 | 145 | using var messageWasReceived = new ManualResetEvent(initialState: false); 146 | using var server = CreateConsumer("server", a => a.Handle(async _ => messageWasReceived.Set())); 147 | using var client = CreateOneWayClient(r => r.TypeBased().Map("server"), settings); 148 | 149 | // set success rate pretty low, so we're sure that it's currently not possible to use the 150 | // real transport - this is a job for the outbox! 151 | settings.SuccessRate = 0; 152 | 153 | // pretending we're in a web app - we have these two bad boys at work: 154 | await using (var connection = new NpgsqlConnection(ConnectionString)) 155 | { 156 | await connection.OpenAsync(); 157 | await using var transaction = await connection.BeginTransactionAsync(); 158 | 159 | // this is how we would use the outbox for outgoing messages 160 | using var scope = new RebusTransactionScope(); 161 | scope.UseOutbox(connection, transaction); 162 | await client.Send(new SomeMessage()); 163 | await scope.CompleteAsync(); 164 | 165 | if (commitTransaction) 166 | { 167 | // this is what we were all waiting for! 168 | await transaction.CommitAsync(); 169 | } 170 | } 171 | 172 | // we would not have gotten this far without the outbox - now let's pretend that the transport has recovered 173 | settings.SuccessRate = 1; 174 | 175 | // wait for server to receive the event 176 | Assert.That(messageWasReceived.WaitOne(TimeSpan.FromSeconds(15)), Is.EqualTo(expectMessageToBeReceived), 177 | $"When commitTransaction={commitTransaction} we {(expectMessageToBeReceived ? "expected the message to be sent and thus received" : "did NOT expect the message to be sent and therefore also not received")}"); 178 | } 179 | 180 | [TestCase(true, true)] 181 | [TestCase(false, false)] 182 | public async Task CanUseOutboxOutsideOfRebusHandler_AmbientTransaction_Send(bool commitTransaction, bool expectMessageToBeReceived) 183 | { 184 | var settings = new FlakySenderTransportDecoratorSettings(); 185 | 186 | using var messageWasReceived = new ManualResetEvent(initialState: false); 187 | using var server = CreateConsumer("server", a => a.Handle(async _ => messageWasReceived.Set())); 188 | using var client = CreateOneWayClient(r => r.TypeBased().Map("server"), settings); 189 | 190 | // set success rate pretty low, so we're sure that it's currently not possible to use the 191 | // real transport - this is a job for the outbox! 192 | settings.SuccessRate = 0; 193 | 194 | // pretending we're in a web app - we have these two bad boys at work: 195 | using (var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) 196 | { 197 | 198 | await client.Send(new SomeMessage()); 199 | 200 | if (commitTransaction) 201 | { 202 | // this is what we were all waiting for! 203 | tx.Complete(); 204 | } 205 | } 206 | 207 | // we would not have gotten this far without the outbox - now let's pretend that the transport has recovered 208 | settings.SuccessRate = 1; 209 | 210 | // wait for server to receive the event 211 | Assert.That(messageWasReceived.WaitOne(TimeSpan.FromSeconds(15)), Is.EqualTo(expectMessageToBeReceived), 212 | $"When commitTransaction={commitTransaction} we {(expectMessageToBeReceived ? "expected the message to be sent and thus received" : "did NOT expect the message to be sent and therefore also not received")}"); 213 | } 214 | 215 | IBus CreateConsumer(string queueName, Action handlers = null) 216 | { 217 | var activator = new BuiltinHandlerActivator(); 218 | 219 | handlers?.Invoke(activator); 220 | 221 | Configure.With(activator) 222 | .Transport(t => t.UseInMemoryTransport(_network, queueName)) 223 | .Start(); 224 | 225 | return activator.Bus; 226 | } 227 | 228 | IBus CreateOneWayClient(Action> routing = null, FlakySenderTransportDecoratorSettings flakySenderTransportDecoratorSettings = null) 229 | { 230 | return Configure.With(new BuiltinHandlerActivator()) 231 | .Transport(t => 232 | { 233 | t.UseInMemoryTransportAsOneWayClient(_network); 234 | 235 | if (flakySenderTransportDecoratorSettings != null) 236 | { 237 | t.Decorate(c => new FlakySenderTransportDecorator(c.Get(), 238 | flakySenderTransportDecoratorSettings)); 239 | } 240 | }) 241 | .Routing(r => routing?.Invoke(r)) 242 | .Outbox(o => o.StoreInPostgreSql(ConnectionString, "RebusOutbox")) 243 | .Start(); 244 | } 245 | } -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/Transport/PostgresqlTransport.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Data; 4 | using System.Diagnostics; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using NpgsqlTypes; 8 | using Rebus.Bus; 9 | using Rebus.Exceptions; 10 | using Rebus.Logging; 11 | using Rebus.Messages; 12 | using Rebus.Threading; 13 | using Rebus.Transport; 14 | using Npgsql; 15 | using Rebus.Extensions; 16 | using Rebus.Internals; 17 | using Rebus.Serialization; 18 | using Rebus.Time; 19 | 20 | namespace Rebus.PostgreSql.Transport 21 | { 22 | /// 23 | /// Implementation of that uses PostgreSql to move messages around 24 | /// 25 | public class PostgreSqlTransport : ITransport, IInitializable, IDisposable 26 | { 27 | const string CurrentConnectionKey = "PostgreSql-transport-current-connection"; 28 | 29 | static readonly HeaderSerializer HeaderSerializer = new(); 30 | 31 | readonly IPostgresConnectionProvider _connectionHelper; 32 | readonly TableName _tableName; 33 | readonly string _inputQueueName; 34 | readonly IRebusTime _rebusTime; 35 | readonly AsyncBottleneck _receiveBottleneck = new(20); 36 | readonly IAsyncTask _expiredMessagesCleanupTask; 37 | readonly ILog _log; 38 | 39 | bool _disposed; 40 | 41 | /// 42 | /// Header key of message priority which happens to be supported by this transport 43 | /// 44 | public const string MessagePriorityHeaderKey = "rbs2-msg-priority"; 45 | 46 | /// 47 | /// Indicates the default interval between which expired messages will be cleaned up 48 | /// 49 | public static readonly TimeSpan DefaultExpiredMessagesCleanupInterval = TimeSpan.FromSeconds(60); 50 | 51 | /// 52 | /// Creates the transport :) 53 | /// 54 | public PostgreSqlTransport(IPostgresConnectionProvider connectionHelper, string tableName, string inputQueueName, IRebusLoggerFactory rebusLoggerFactory, IAsyncTaskFactory asyncTaskFactory, IRebusTime rebusTime, TimeSpan? expiredMessagesCleanupInterval = null, string schemaName = null) 55 | { 56 | if (rebusLoggerFactory == null) throw new ArgumentNullException(nameof(rebusLoggerFactory)); 57 | if (asyncTaskFactory == null) throw new ArgumentNullException(nameof(asyncTaskFactory)); 58 | 59 | _log = rebusLoggerFactory.GetLogger(); 60 | _connectionHelper = connectionHelper ?? throw new ArgumentNullException(nameof(connectionHelper)); 61 | _tableName = new TableName(schemaName ?? TableName.DefaultSchemaName, tableName ?? throw new ArgumentNullException(nameof(tableName))); 62 | _inputQueueName = inputQueueName; 63 | _rebusTime = rebusTime; 64 | 65 | var cleanupInterval = expiredMessagesCleanupInterval ?? DefaultExpiredMessagesCleanupInterval; 66 | var intervalSeconds = (int)cleanupInterval.TotalSeconds; 67 | _expiredMessagesCleanupTask = asyncTaskFactory.Create("ExpiredMessagesCleanup", PerformExpiredMessagesCleanupCycle, intervalSeconds: intervalSeconds); 68 | } 69 | 70 | /// 71 | public void Initialize() 72 | { 73 | if (_inputQueueName == null) return; 74 | _expiredMessagesCleanupTask.Start(); 75 | } 76 | 77 | /// The SQL transport doesn't really have pre-defined queues, so this function does nothing 78 | public void CreateQueue(string address) 79 | { 80 | } 81 | 82 | /// 83 | public async Task Send(string destinationAddress, TransportMessage message, ITransactionContext context) 84 | { 85 | var connection = await context.GetOrAdd(CurrentConnectionKey, async () => 86 | { 87 | var dbConnection = await _connectionHelper.GetConnection(); 88 | var connectionWrapper = new ConnectionWrapper(dbConnection); 89 | context.OnCommit(async _ => await dbConnection.Complete()); 90 | context.OnDisposed(_ => connectionWrapper.Dispose()); 91 | return connectionWrapper; 92 | }); 93 | 94 | var semaphore = connection.Semaphore; 95 | 96 | // serialize access to the connection 97 | await semaphore.WaitAsync(); 98 | 99 | try 100 | { 101 | await InnerSend(destinationAddress, message, connection); 102 | } 103 | finally 104 | { 105 | semaphore.Release(); 106 | } 107 | } 108 | 109 | async Task InnerSend(string destinationAddress, TransportMessage message, ConnectionWrapper connection) 110 | { 111 | using var command = connection.Connection.CreateCommand(); 112 | 113 | command.CommandText = $@" 114 | INSERT INTO {_tableName} 115 | ( 116 | recipient, 117 | headers, 118 | body, 119 | priority, 120 | visible, 121 | expiration 122 | ) 123 | VALUES 124 | ( 125 | @recipient, 126 | @headers, 127 | @body, 128 | @priority, 129 | now() + @visible, 130 | now() + @ttlseconds 131 | )"; 132 | 133 | var headers = message.Headers.Clone(); 134 | 135 | var priority = GetMessagePriority(headers); 136 | var initialVisibilityDelay = new TimeSpan(0, 0, 0, GetInitialVisibilityDelay(headers)); 137 | var ttlSeconds = new TimeSpan(0, 0, 0, GetTtlSeconds(headers)); 138 | 139 | // must be last because the other functions on the headers might change them 140 | var serializedHeaders = HeaderSerializer.Serialize(headers); 141 | 142 | command.Parameters.Add("recipient", NpgsqlDbType.Text).Value = destinationAddress; 143 | command.Parameters.Add("headers", NpgsqlDbType.Bytea).Value = serializedHeaders; 144 | command.Parameters.Add("body", NpgsqlDbType.Bytea).Value = message.Body; 145 | command.Parameters.Add("priority", NpgsqlDbType.Integer).Value = priority; 146 | command.Parameters.Add("visible", NpgsqlDbType.Interval).Value = initialVisibilityDelay; 147 | command.Parameters.Add("ttlseconds", NpgsqlDbType.Interval).Value = ttlSeconds; 148 | 149 | await command.ExecuteNonQueryAsync(); 150 | } 151 | 152 | /// 153 | public async Task Receive(ITransactionContext context, CancellationToken cancellationToken) 154 | { 155 | using (await _receiveBottleneck.Enter(cancellationToken)) 156 | { 157 | var connection = await context.GetOrAdd(CurrentConnectionKey, async () => 158 | { 159 | var dbConnection = await _connectionHelper.GetConnection(); 160 | var connectionWrapper = new ConnectionWrapper(dbConnection); 161 | context.OnAck(async _ => await dbConnection.Complete()); 162 | context.OnDisposed(_ => connectionWrapper.Dispose()); 163 | return connectionWrapper; 164 | }); 165 | 166 | TransportMessage receivedTransportMessage; 167 | 168 | using (var selectCommand = connection.Connection.CreateCommand()) 169 | { 170 | selectCommand.CommandText = $@" 171 | DELETE from {_tableName} 172 | where id = 173 | ( 174 | select id from {_tableName} 175 | where recipient = @recipient 176 | and visible < now() 177 | and expiration > now() 178 | order by priority desc, visible asc, id asc 179 | for update skip locked 180 | limit 1 181 | ) 182 | returning id, 183 | headers, 184 | body 185 | "; 186 | 187 | selectCommand.Parameters.Add("recipient", NpgsqlDbType.Text).Value = _inputQueueName; 188 | 189 | await using (var reader = await selectCommand.ExecuteReaderAsync(cancellationToken)) 190 | { 191 | if (!await reader.ReadAsync(cancellationToken)) return null; 192 | 193 | var headers = reader["headers"]; 194 | var body = (byte[])reader["body"]; 195 | 196 | var headersDictionary = HeaderSerializer.Deserialize((byte[])headers); 197 | 198 | receivedTransportMessage = new TransportMessage(headersDictionary, body); 199 | } 200 | } 201 | 202 | return receivedTransportMessage; 203 | } 204 | } 205 | 206 | async Task PerformExpiredMessagesCleanupCycle() 207 | { 208 | var results = 0; 209 | var stopwatch = Stopwatch.StartNew(); 210 | 211 | while (true) 212 | { 213 | using var connection = await _connectionHelper.GetConnection(); 214 | 215 | int affectedRows; 216 | 217 | using (var command = connection.CreateCommand()) 218 | { 219 | command.CommandText = 220 | $@" 221 | delete from {_tableName} 222 | where expiration < now() 223 | "; 224 | affectedRows = await command.ExecuteNonQueryAsync(); 225 | } 226 | 227 | results += affectedRows; 228 | await connection.Complete(); 229 | 230 | if (affectedRows == 0) break; 231 | } 232 | 233 | if (results > 0) 234 | { 235 | _log.Info( 236 | "Performed expired messages cleanup in {0} - {1} expired messages with recipient {2} were deleted", 237 | stopwatch.Elapsed, results, _inputQueueName); 238 | } 239 | } 240 | 241 | /// 242 | /// Gets the address of the transport 243 | /// 244 | public string Address => _inputQueueName; 245 | 246 | /// 247 | /// Creates the necessary table 248 | /// 249 | public void EnsureTableIsCreated() 250 | { 251 | var retrier = new Retrier([ 252 | TimeSpan.FromSeconds(1), 253 | TimeSpan.FromSeconds(2), 254 | TimeSpan.FromSeconds(3), 255 | TimeSpan.FromSeconds(4), 256 | TimeSpan.FromSeconds(5) 257 | ]); 258 | 259 | AsyncHelpers.RunSync(() => retrier.ExecuteAsync(EnsureTableIsCreatedAsync)); 260 | } 261 | 262 | /// 263 | /// Creates asynchronously the necessary table 264 | /// 265 | public async Task EnsureTableIsCreatedAsync() 266 | { 267 | try 268 | { 269 | await CreateSchemaAsync(); 270 | } 271 | catch (Exception exception) 272 | { 273 | throw new RebusApplicationException(exception, $"Error attempting to initialize SQL transport schema with messages table {_tableName}"); 274 | } 275 | } 276 | 277 | async Task CreateSchemaAsync() 278 | { 279 | using var connection = await _connectionHelper.GetConnection(); 280 | 281 | var tableNames = connection.GetTableNames(); 282 | 283 | if (tableNames.Contains(_tableName)) 284 | { 285 | _log.Info("Database already contains a table named {tableName} - will not create anything", _tableName); 286 | return; 287 | } 288 | 289 | _log.Info("Table {tableName} does not exist - it will be created now", _tableName); 290 | 291 | var schemaNames = connection.GetSchemas(); 292 | 293 | if (!schemaNames.Contains(_tableName.Schema)) 294 | { 295 | _log.Info("Schema {schemaName} does not exist - it will be created now", _tableName.Schema); 296 | 297 | using (var command = connection.CreateCommand()) 298 | { 299 | command.CommandText = $@"CREATE SCHEMA ""{_tableName.Schema}"";"; 300 | 301 | command.ExecuteNonQuery(); 302 | } 303 | } 304 | 305 | ExecuteCommands(connection, $@" 306 | CREATE TABLE {_tableName} 307 | ( 308 | id serial NOT NULL, 309 | recipient text NOT NULL, 310 | priority int NOT NULL, 311 | expiration timestamp with time zone NOT NULL, 312 | visible timestamp with time zone NOT NULL, 313 | headers bytea NOT NULL, 314 | body bytea NOT NULL, 315 | PRIMARY KEY (recipient, priority, id) 316 | ); 317 | ---- 318 | CREATE INDEX ""idx_receive_{_tableName.Name}"" ON {_tableName} 319 | ( 320 | recipient ASC, 321 | expiration ASC, 322 | visible ASC 323 | ); 324 | ---- 325 | CREATE INDEX ""idx_dequeue_{_tableName.Name}"" ON {_tableName} 326 | ( 327 | priority DESC, 328 | visible ASC, 329 | id ASC 330 | ); 331 | ---- 332 | CREATE INDEX ""idx_expiration_{_tableName.Name}"" ON {_tableName} 333 | ( 334 | expiration 335 | ); 336 | "); 337 | 338 | await connection.Complete(); 339 | } 340 | 341 | static void ExecuteCommands(PostgresConnection connection, string sqlCommands) 342 | { 343 | foreach (var sqlCommand in sqlCommands.Split(new[] { "----" }, StringSplitOptions.RemoveEmptyEntries)) 344 | { 345 | using var command = connection.CreateCommand(); 346 | 347 | command.CommandText = sqlCommand; 348 | 349 | Execute(command); 350 | } 351 | } 352 | 353 | static void Execute(IDbCommand command) 354 | { 355 | try 356 | { 357 | command.ExecuteNonQuery(); 358 | } 359 | catch (NpgsqlException exception) 360 | { 361 | throw new RebusApplicationException(exception, $@"Error executing SQL command 362 | {command.CommandText} 363 | "); 364 | } 365 | } 366 | 367 | class ConnectionWrapper : IDisposable 368 | { 369 | public ConnectionWrapper(PostgresConnection connection) 370 | { 371 | Connection = connection; 372 | Semaphore = new SemaphoreSlim(1, 1); 373 | } 374 | 375 | public PostgresConnection Connection { get; } 376 | public SemaphoreSlim Semaphore { get; } 377 | 378 | public void Dispose() 379 | { 380 | Connection?.Dispose(); 381 | Semaphore?.Dispose(); 382 | } 383 | } 384 | 385 | /// 386 | public void Dispose() 387 | { 388 | if (_disposed) return; 389 | 390 | try 391 | { 392 | _expiredMessagesCleanupTask.Dispose(); 393 | } 394 | finally 395 | { 396 | _disposed = true; 397 | } 398 | } 399 | 400 | static int GetMessagePriority(Dictionary headers) 401 | { 402 | var valueOrNull = headers.GetValueOrNull(MessagePriorityHeaderKey); 403 | if (valueOrNull == null) return 0; 404 | 405 | try 406 | { 407 | return int.Parse(valueOrNull); 408 | } 409 | catch (Exception exception) 410 | { 411 | throw new FormatException($"Could not parse '{valueOrNull}' into an Int32!", exception); 412 | } 413 | } 414 | 415 | int GetInitialVisibilityDelay(IDictionary headers) 416 | { 417 | if (!headers.TryGetValue(Headers.DeferredUntil, out var deferredUntilDateTimeOffsetString)) 418 | { 419 | return 0; 420 | } 421 | 422 | var deferredUntilTime = deferredUntilDateTimeOffsetString.ToDateTimeOffset(); 423 | 424 | headers.Remove(Headers.DeferredUntil); 425 | 426 | return (int)(deferredUntilTime - _rebusTime.Now).TotalSeconds; 427 | } 428 | 429 | static int GetTtlSeconds(IReadOnlyDictionary headers) 430 | { 431 | const int defaultTtlSecondsAbout60Years = int.MaxValue; 432 | 433 | if (!headers.ContainsKey(Headers.TimeToBeReceived)) 434 | return defaultTtlSecondsAbout60Years; 435 | 436 | var timeToBeReceivedStr = headers[Headers.TimeToBeReceived]; 437 | var timeToBeReceived = TimeSpan.Parse(timeToBeReceivedStr); 438 | 439 | return (int)timeToBeReceived.TotalSeconds; 440 | } 441 | } 442 | } 443 | -------------------------------------------------------------------------------- /Rebus.PostgreSql/PostgreSql/Sagas/PostgreSqlSagaStorage.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | // ReSharper disable once RedundantUsingDirective (because .NET Core) 5 | using System.Reflection; 6 | using System.Threading.Tasks; 7 | using Npgsql; 8 | using NpgsqlTypes; 9 | using Rebus.Exceptions; 10 | using Rebus.Logging; 11 | using Rebus.PostgreSql.Reflection; 12 | using Rebus.Sagas; 13 | 14 | namespace Rebus.PostgreSql.Sagas; 15 | 16 | /// 17 | /// Implementation of that uses PostgreSql to do its thing 18 | /// 19 | public class PostgreSqlSagaStorage : ISagaStorage 20 | { 21 | static readonly string IdPropertyName = Reflect.Path(d => d.Id); 22 | 23 | readonly ISagaSerializer _sagaSerializer; 24 | readonly IPostgresConnectionProvider _connectionHelper; 25 | readonly TableName _dataTableName; 26 | readonly TableName _indexTableName; 27 | readonly ILog _log; 28 | 29 | /// 30 | /// Constructs the saga storage 31 | /// 32 | public PostgreSqlSagaStorage(IPostgresConnectionProvider connectionHelper, string dataTableName, string indexTableName, IRebusLoggerFactory rebusLoggerFactory, ISagaSerializer sagaSerializer, string schemaName = null) 33 | { 34 | if (rebusLoggerFactory == null) throw new ArgumentNullException(nameof(rebusLoggerFactory)); 35 | _connectionHelper = connectionHelper ?? throw new ArgumentNullException(nameof(connectionHelper)); 36 | _dataTableName = new TableName(schemaName ?? TableName.DefaultSchemaName, dataTableName ?? throw new ArgumentNullException(nameof(dataTableName))); 37 | _indexTableName = new TableName(schemaName ?? TableName.DefaultSchemaName, indexTableName ?? throw new ArgumentNullException(nameof(indexTableName))); 38 | _sagaSerializer = sagaSerializer; 39 | _log = rebusLoggerFactory.GetLogger(); 40 | } 41 | 42 | /// 43 | /// Checks to see if the configured saga data and saga index table exists. If they both exist, we'll continue, if 44 | /// neigther of them exists, we'll try to create them. If one of them exists, we'll throw an error. 45 | /// 46 | public void EnsureTablesAreCreated() 47 | { 48 | using (var connection = _connectionHelper.GetConnection().Result) 49 | { 50 | var tableNames = connection.GetTableNames(); 51 | 52 | var hasDataTable = tableNames.Contains(_dataTableName); 53 | var hasIndexTable = tableNames.Contains(_indexTableName); 54 | 55 | if (hasDataTable && hasIndexTable) 56 | { 57 | return; 58 | } 59 | 60 | if (hasDataTable) 61 | { 62 | throw new RebusApplicationException( 63 | $"The saga index table '{_indexTableName}' does not exist, so the automatic saga schema generation tried to run - but there was already a table named '{_dataTableName}', which was supposed to be created as the data table"); 64 | } 65 | 66 | if (hasIndexTable) 67 | { 68 | throw new RebusApplicationException( 69 | $"The saga data table '{_dataTableName}' does not exist, so the automatic saga schema generation tried to run - but there was already a table named '{_indexTableName}', which was supposed to be created as the index table"); 70 | } 71 | 72 | _log.Info("Saga tables {tableName} (data) and {tableName} (index) do not exist - they will be created now", _dataTableName, _indexTableName); 73 | 74 | var schemaNames = connection.GetSchemas(); 75 | 76 | if (!schemaNames.Contains(_dataTableName.Schema)) 77 | { 78 | _log.Info("Schema {schemaName} does not exist - it will be created now", _dataTableName.Schema); 79 | 80 | using (var command = connection.CreateCommand()) 81 | { 82 | command.CommandText = $@"CREATE SCHEMA ""{_dataTableName.Schema}"";"; 83 | 84 | command.ExecuteNonQuery(); 85 | } 86 | } 87 | 88 | using (var command = connection.CreateCommand()) 89 | { 90 | command.CommandText = 91 | $@" 92 | CREATE TABLE {_dataTableName} ( 93 | ""id"" UUID NOT NULL, 94 | ""revision"" INTEGER NOT NULL, 95 | ""data"" BYTEA NOT NULL, 96 | PRIMARY KEY (""id"") 97 | ); 98 | "; 99 | 100 | command.ExecuteNonQuery(); 101 | } 102 | 103 | using (var command = connection.CreateCommand()) 104 | { 105 | command.CommandText = $@" 106 | CREATE TABLE {_indexTableName} ( 107 | ""saga_type"" TEXT NOT NULL, 108 | ""key"" TEXT NOT NULL, 109 | ""value"" TEXT NOT NULL, 110 | ""saga_id"" UUID NOT NULL, 111 | PRIMARY KEY (""key"", ""value"", ""saga_type"") 112 | ); 113 | 114 | CREATE INDEX ON {_indexTableName} (""saga_id""); 115 | "; 116 | 117 | command.ExecuteNonQuery(); 118 | } 119 | 120 | Task.Run(async () => await connection.Complete()).Wait(); 121 | } 122 | } 123 | 124 | /// 125 | /// Finds an already-existing instance of the given saga data type that has a property with the given 126 | /// whose value matches . Returns null if no such instance could be found 127 | /// 128 | public async Task Find(Type sagaDataType, string propertyName, object propertyValue) 129 | { 130 | using (var connection = await _connectionHelper.GetConnection()) 131 | { 132 | using (var command = connection.CreateCommand()) 133 | { 134 | if (propertyName == IdPropertyName) 135 | { 136 | command.CommandText = $@" 137 | SELECT s.""data"" 138 | FROM {_dataTableName} s 139 | WHERE s.""id"" = @id 140 | "; 141 | command.Parameters.Add("id", NpgsqlDbType.Uuid).Value = ToGuid(propertyValue) ?? DBNull.Value; 142 | } 143 | else 144 | { 145 | command.CommandText = 146 | $@" 147 | SELECT s.""data"" 148 | FROM {_dataTableName} s 149 | JOIN {_indexTableName} i on s.id = i.saga_id 150 | WHERE i.""saga_type"" = @saga_type AND i.""key"" = @key AND i.value = @value; 151 | "; 152 | 153 | command.Parameters.Add("key", NpgsqlDbType.Text).Value = propertyName; 154 | command.Parameters.Add("saga_type", NpgsqlDbType.Text).Value = GetSagaTypeName(sagaDataType); 155 | command.Parameters.Add("value", NpgsqlDbType.Text).Value = (propertyValue ?? "").ToString(); 156 | } 157 | 158 | var data = (byte[])command.ExecuteScalar(); 159 | 160 | if (data == null) return null; 161 | 162 | try 163 | { 164 | var sagaData = (ISagaData)_sagaSerializer.Deserialize(data); 165 | 166 | if (!sagaDataType.IsInstanceOfType(sagaData)) 167 | { 168 | return null; 169 | } 170 | 171 | return sagaData; 172 | } 173 | catch (Exception exception) 174 | { 175 | var message = $"An error occurred while attempting to deserialize '{data}' into a {sagaDataType}"; 176 | 177 | throw new RebusApplicationException(exception, message); 178 | } 179 | finally 180 | { 181 | await connection.Complete(); 182 | } 183 | } 184 | } 185 | } 186 | 187 | static object ToGuid(object propertyValue) 188 | { 189 | if (ReferenceEquals(propertyValue, null)) return null; 190 | 191 | if (propertyValue is string stringPropertyValue) 192 | { 193 | if (string.IsNullOrWhiteSpace(stringPropertyValue)) return null; 194 | 195 | return Guid.TryParse(stringPropertyValue, out var result) 196 | ? result 197 | : throw new FormatException($"Could not parse the string '{stringPropertyValue}' into a Guid"); 198 | } 199 | 200 | return Convert.ChangeType(propertyValue, typeof(Guid)); 201 | } 202 | 203 | /// 204 | /// Inserts the given saga data as a new instance. Throws a if another saga data instance 205 | /// already exists with a correlation property that shares a value with this saga data. 206 | /// 207 | public async Task Insert(ISagaData sagaData, IEnumerable correlationProperties) 208 | { 209 | if (sagaData.Id == Guid.Empty) 210 | { 211 | throw new InvalidOperationException($"Saga data {sagaData.GetType()} has an uninitialized Id property!"); 212 | } 213 | 214 | if (sagaData.Revision != 0) 215 | { 216 | throw new InvalidOperationException($"Attempted to insert saga data with ID {sagaData.Id} and revision {sagaData.Revision}, but revision must be 0 on first insert!"); 217 | } 218 | 219 | using (var connection = await _connectionHelper.GetConnection()) 220 | { 221 | using (var command = connection.CreateCommand()) 222 | { 223 | command.Parameters.Add("id", NpgsqlDbType.Uuid).Value = sagaData.Id; 224 | command.Parameters.Add("revision", NpgsqlDbType.Integer).Value = sagaData.Revision; 225 | command.Parameters.Add("data", NpgsqlDbType.Bytea).Value = _sagaSerializer.Serialize(sagaData); 226 | 227 | command.CommandText = 228 | $@" 229 | 230 | INSERT 231 | INTO {_dataTableName} (""id"", ""revision"", ""data"") 232 | VALUES (@id, @revision, @data); 233 | 234 | "; 235 | 236 | try 237 | { 238 | command.ExecuteNonQuery(); 239 | } 240 | catch (NpgsqlException exception) 241 | { 242 | throw new ConcurrencyException(exception, $"Saga data {sagaData.GetType()} with ID {sagaData.Id} in table {_dataTableName} could not be inserted!"); 243 | } 244 | } 245 | 246 | var propertiesToIndex = GetPropertiesToIndex(sagaData, correlationProperties); 247 | 248 | if (propertiesToIndex.Any()) 249 | { 250 | await CreateIndex(sagaData, connection, propertiesToIndex); 251 | } 252 | 253 | await connection.Complete(); 254 | } 255 | } 256 | 257 | /// 258 | /// Updates the already-existing instance of the given saga data, throwing a if another 259 | /// saga data instance exists with a correlation property that shares a value with this saga data, or if the saga data 260 | /// instance no longer exists. 261 | /// 262 | public async Task Update(ISagaData sagaData, IEnumerable correlationProperties) 263 | { 264 | using (var connection = await _connectionHelper.GetConnection()) 265 | { 266 | var revisionToUpdate = sagaData.Revision; 267 | 268 | sagaData.Revision++; 269 | 270 | var nextRevision = sagaData.Revision; 271 | 272 | // first, delete existing index 273 | using (var command = connection.CreateCommand()) 274 | { 275 | command.CommandText = $@" 276 | 277 | DELETE FROM {_indexTableName} WHERE ""saga_id"" = @id; 278 | 279 | "; 280 | command.Parameters.Add("id", NpgsqlDbType.Uuid).Value = sagaData.Id; 281 | await command.ExecuteNonQueryAsync(); 282 | } 283 | 284 | // next, update or insert the saga 285 | using (var command = connection.CreateCommand()) 286 | { 287 | command.Parameters.Add("id", NpgsqlDbType.Uuid).Value = sagaData.Id; 288 | command.Parameters.Add("current_revision", NpgsqlDbType.Integer).Value = revisionToUpdate; 289 | command.Parameters.Add("next_revision", NpgsqlDbType.Integer).Value = nextRevision; 290 | command.Parameters.Add("data", NpgsqlDbType.Bytea).Value = _sagaSerializer.Serialize(sagaData); 291 | 292 | command.CommandText = 293 | $@" 294 | 295 | UPDATE {_dataTableName} 296 | SET ""data"" = @data, ""revision"" = @next_revision 297 | WHERE ""id"" = @id AND ""revision"" = @current_revision; 298 | 299 | "; 300 | 301 | var rows = await command.ExecuteNonQueryAsync(); 302 | 303 | if (rows == 0) 304 | { 305 | throw new ConcurrencyException($"Update of saga with ID {sagaData.Id} did not succeed because someone else beat us to it"); 306 | } 307 | } 308 | 309 | var propertiesToIndex = GetPropertiesToIndex(sagaData, correlationProperties); 310 | 311 | if (propertiesToIndex.Any()) 312 | { 313 | await CreateIndex(sagaData, connection, propertiesToIndex); 314 | } 315 | 316 | await connection.Complete(); 317 | } 318 | } 319 | 320 | /// 321 | /// Deletes the saga data instance, throwing a if the instance no longer exists 322 | /// 323 | public async Task Delete(ISagaData sagaData) 324 | { 325 | using (var connection = await _connectionHelper.GetConnection()) 326 | { 327 | using (var command = connection.CreateCommand()) 328 | { 329 | command.CommandText = 330 | $@" 331 | 332 | DELETE 333 | FROM {_dataTableName} 334 | WHERE ""id"" = @id AND ""revision"" = @current_revision; 335 | 336 | "; 337 | 338 | command.Parameters.Add("id", NpgsqlDbType.Uuid).Value = sagaData.Id; 339 | command.Parameters.Add("current_revision", NpgsqlDbType.Integer).Value = sagaData.Revision; 340 | 341 | var rows = await command.ExecuteNonQueryAsync(); 342 | 343 | if (rows == 0) 344 | { 345 | throw new ConcurrencyException($"Delete of saga with ID {sagaData.Id} did not succeed because someone else beat us to it"); 346 | } 347 | } 348 | 349 | using (var command = connection.CreateCommand()) 350 | { 351 | command.CommandText = 352 | $@" 353 | 354 | DELETE 355 | FROM {_indexTableName} 356 | WHERE ""saga_id"" = @id 357 | 358 | "; 359 | command.Parameters.Add("id", NpgsqlDbType.Uuid).Value = sagaData.Id; 360 | 361 | await command.ExecuteNonQueryAsync(); 362 | } 363 | 364 | await connection.Complete(); 365 | } 366 | 367 | sagaData.Revision++; 368 | } 369 | 370 | async Task CreateIndex(ISagaData sagaData, PostgresConnection connection, IEnumerable> propertiesToIndex) 371 | { 372 | var sagaTypeName = GetSagaTypeName(sagaData.GetType()); 373 | var parameters = propertiesToIndex 374 | .Select((p, i) => new 375 | { 376 | PropertyName = p.Key, 377 | PropertyValue = p.Value ?? "", 378 | PropertyNameParameter = $"@n{i}", 379 | PropertyValueParameter = $"@v{i}" 380 | }) 381 | .ToList(); 382 | 383 | // lastly, generate new index 384 | using (var command = connection.CreateCommand()) 385 | { 386 | // generate batch insert with SQL for each entry in the index 387 | var inserts = parameters 388 | .Select(a => 389 | $@" 390 | 391 | INSERT 392 | INTO {_indexTableName} (""saga_type"", ""key"", ""value"", ""saga_id"") 393 | VALUES (@saga_type, {a.PropertyNameParameter}, {a.PropertyValueParameter}, @saga_id) 394 | 395 | "); 396 | 397 | var sql = string.Join(";" + Environment.NewLine, inserts); 398 | 399 | command.CommandText = sql; 400 | 401 | foreach (var parameter in parameters) 402 | { 403 | command.Parameters.Add(parameter.PropertyNameParameter, NpgsqlDbType.Text).Value = parameter.PropertyName; 404 | command.Parameters.Add(parameter.PropertyValueParameter, NpgsqlDbType.Text).Value = parameter.PropertyValue; 405 | } 406 | 407 | command.Parameters.Add("saga_type", NpgsqlDbType.Text).Value = sagaTypeName; 408 | command.Parameters.Add("saga_id", NpgsqlDbType.Uuid).Value = sagaData.Id; 409 | 410 | await command.ExecuteNonQueryAsync(); 411 | } 412 | } 413 | 414 | string GetSagaTypeName(Type sagaDataType) 415 | { 416 | return sagaDataType.FullName; 417 | } 418 | 419 | static List> GetPropertiesToIndex(ISagaData sagaData, IEnumerable correlationProperties) 420 | { 421 | return correlationProperties 422 | .Select(p => p.PropertyName) 423 | .Select(path => 424 | { 425 | var value = Reflect.Value(sagaData, path); 426 | 427 | return new KeyValuePair(path, value?.ToString()); 428 | }) 429 | .Where(kvp => kvp.Value != null) 430 | .ToList(); 431 | } 432 | } --------------------------------------------------------------------------------