No description
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2025-12-17 13:37:10 +01:00
.gitea/workflows Add DI Pipelines 2025-12-17 11:29:27 +01:00
demo/TgwOutboxDispatcher.Demo Fix: Demo app sends messages to retry to the DLQ 2025-12-17 13:37:10 +01:00
docs Add documentation on configuration values 2025-12-16 17:53:52 +01:00
src/TgwOutboxDispatcher Fix: Demo app sends messages to retry to the DLQ 2025-12-17 13:37:10 +01:00
tests/TgwOutboxDispatcher.Tests Fix: Demo app sends messages to retry to the DLQ 2025-12-17 13:37:10 +01:00
.gitignore Remove accidentially commited user configuration of Rider 2025-12-15 10:27:14 +01:00
Directory.Build.props Add DI Pipelines 2025-12-17 11:29:27 +01:00
global.json Add DI Pipelines 2025-12-17 11:29:27 +01:00
LICENSE-Moq.txt Add Lincense hints 2025-12-17 13:37:10 +01:00
LICENSE-Shouldly.txt Add Lincense hints 2025-12-17 13:37:10 +01:00
LICENSE.txt Add MIT LICENSE and README with setup and usage guidance 2025-12-14 16:36:27 +01:00
README.md Add Lincense hints 2025-12-17 13:37:10 +01:00
TgwOutboxDispatcher.slnx Move demo into seperate folder, since it is not tests and not production code 2025-12-16 18:07:52 +01:00

TgwOutboxDispatcher

Reliable, backpressureaware background dispatcher for the Transactional Outbox pattern in .NET 8.

This library reads events from a durable outbox (your storage) and publishes them to an event bus (your broker), using leases, bounded channels, controlled concurrency, retries, and graceful shutdown.

Why use it

  • Prevents lost messages by decoupling write and publish via a transactional outbox
  • Respects backpressure via bounded channels and configurable parallelism
  • Safe multiinstance operation with leasing to avoid duplicate work
  • Retries with attempt tracking and failure callbacks for deadlettering
  • Clean shutdown with inflight draining

For background and detailed requirements, see docs/Requirements.md.


Installation

The project targets .NET 8 (C# 12). Until a NuGet package is published, include the project in your solution and reference it directly:

  1. Add the project to your solution:
dotnet sln add src/TgwOutboxDispatcher/TgwOutboxDispatcher.csproj
  1. Reference it from your application:
dotnet add <YourProject>.csproj reference src/TgwOutboxDispatcher/TgwOutboxDispatcher.csproj

Core abstractions

You integrate by implementing two small interfaces and handing them to the dispatcher.

  1. IOutboxStore
public interface IOutboxStore
{
    Task<IReadOnlyList<OutboxMessage>> LeaseAsync(
        int maxCount,
        TimeSpan leaseTime,
        CancellationToken ct);

    Task MarkDispatchedAsync(Guid id, CancellationToken ct);

    Task OnDispatchFailedAsync(
        Guid id,
        Exception ex,
        int nextAttemptInSeconds,
        CancellationToken ct);
}
  1. IEventBus
public interface IEventBus
{
    Task PublishAsync(
        string topic,
        ReadOnlyMemory<byte> payload,
        string? dedupKey,
        CancellationToken ct);
}
  1. OutboxMessage
public record OutboxMessage(
    Guid Id,
    string Topic,
    byte[] Payload,
    DateTimeOffset CreatedAt,
    int Attempt = 0,
    string? DedupKey = null);
  1. OutboxDispatcherOptions
public sealed class OutboxDispatcherOptions
{
    // See Configuration
}

How it works (high level)

  • The dispatcher periodically leases up to MaxBatchSize messages from your outbox for LeaseTime.
  • Messages are pushed into a bounded channel (ChannelCapacity), applying backpressure if consumers are slow.
  • Up to DegreeOfParallelism workers call your IEventBus.PublishAsync.
  • On success, MarkDispatchedAsync is called so you can delete or archive the message.
  • On failure, OnDispatchFailedAsync is called. You decide how/when to reschedule or deadletter. The library passes a suggested delay via nextAttemptInSeconds and increments Attempt on the message you return later.
  • After MaxAttempts, you should stop retrying and deadletter (policy is yours; the interface gives you the hook).
  • On shutdown, the dispatcher stops leasing, drains the channel for up to ShutdownDrainTimeout, and exits gracefully.

Quick start

  1. Implement IOutboxStore for your persistence (e.g., SQL table with an outbox and a leasing column/lock).
  2. Implement IEventBus for your broker (e.g., Kafka, RabbitMQ, Azure Service Bus, SNS/SQS, or a test stub).
  3. Configure OutboxDispatcherOptions as needed.
  4. Register and run the dispatcher as a hosted background service in your app.

Example sketch (simplified):

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using TgwOutboxDispatcher;

var builder = Host.CreateApplicationBuilder(args);

builder.Services.AddSingleton<IOutboxStore, SqlOutboxStore>();
builder.Services.AddSingleton<IEventBus, KafkaEventBus>();
builder.Services.Configure<OutboxDispatcherOptions>();

builder.Services.AddHostedService<OutboxDispatcher>();

var app = builder.Build();
await app.RunAsync();

Note: This repo currently provides the contracts and options. The concrete dispatcher service may live in this package or a companion one depending on how the repository evolves. See docs/Requirements.md for the intended behavior.


Configuration

This section lists all OutboxDispatcherOptions with their purpose and default values, and shows how to configure the dispatcher via DI.

Options (defaults in parentheses):

  • DegreeOfParallelism (4)
    • Number of concurrent consumer workers publishing to your IEventBus.
  • ChannelCapacity (512)
    • Size of the in-memory bounded channel used between the producer loop and consumers; controls backpressure.
  • MaxBatchSize (128)
    • Maximum number of messages to lease from the outbox per producer iteration.
  • MaxAttempts (10)
    • Maximum total publish attempts before you should dead-letter in your store (signaled via nextAttemptInSeconds: -1).
  • LeaseTime (00:02:00)
    • Visibility timeout for leased outbox rows. While the lease is active, other dispatcher instances must not see them.
  • PollInterval (00:00:00.250)
    • Minimum time between two leasing attempts. The producer adapts around execution time; this is the baseline cadence when idle.
  • ShutdownDrainTimeout (00:00:30)
    • Maximum time to drain in-flight work on application shutdown before forcing cancellation.
  • MinRetryDelay (00:00:01)
    • Lower bound for computed retry delays (exponential backoff with full jitter never goes below this).
  • MaxRetryDelay (00:05:00)
    • Upper bound for computed retry delays; caps exponential growth.
  • ExponentialDelayBaseInSeconds (5)
    • Base used when calculating the exponential backoff curve;

Registering in DI:

using Microsoft.Extensions.DependencyInjection;
using TgwOutboxDispatcher;

var services = new ServiceCollection();

// Your implementations
services.AddSingleton<IOutboxStore, SqlOutboxStore>();
services.AddSingleton<IEventBus, KafkaEventBus>();

// Add the dispatcher (registers hosted service and options)
services.AddOutboxDispatcher();

// Configure options (override defaults)
services.Configure<OutboxDispatcherOptions>(o =>{ ... });

Leasing and clock skew guidance:

  • Multiple instances can run concurrently and rely on LeaseTime for exclusive visibility. To avoid accidental overlap due to time differences between machines, keep cross-instance clock skew significantly smaller than LeaseTime / 2.
  • Practical recommendation: enable NTP time sync and target skew well under a few seconds. For example, with LeaseTime = 2 minutes, keep skew ≪ 60 seconds (ideally < 12 seconds) across hosts.

Delivery semantics and idempotency

  • The dispatcher provides atleastonce delivery semantics. Duplicates can occur (e.g., process restarts, timeouts, multiinstance leasing, or broker acks raced with persistence updates).
  • Downstream must ensure idempotency using the message key. Use OutboxMessage.DedupKey as the idempotency key at the broker and in consumers.
  • Recommended:
    • Set DedupKey to a unique, stable identifier per logical event.
    • Forward the key in IEventBus.PublishAsync to the broker if supported (e.g., Kafka key, SQS FIFO dedup). If the broker lacks native dedup, make your consumer idempotent by tracking processed keys.

Outbox storage guidance

  • Use the same DB transaction that performs domain changes to insert OutboxMessage rows.
  • Implement leasing so that multiple app instances can safely run the dispatcher concurrently.
  • Persist and increment Attempt on each retry.
  • Implement exponential backoff or a similar policy in OnDispatchFailedAsync when scheduling the next attempt.

Event bus guidance

  • Forward DedupKey to the broker if it supports deduplication (e.g., Kafka keys, SQS FIFO dedup).
  • Ensure PublishAsync is idempotent or safe under retries.

Development

Prerequisites:

  • .NET 10 SDK (see global.json)

Build:

dotnet build

Run tests:

dotnet test

Project layout:

  • src/TgwOutboxDispatcher — contracts and options
  • tests/TgwOutboxDispatcher.Tests — test project scaffold
  • docs/Requirements.md — full requirements and design notes

Third-Party Licenses

This project uses Shouldly and Moq for unit testing. Shouldly is licensed under the BSD-3-Clause License. See LICENSE-Shouldly.txt and LICENSE-Moq.txt for details.