Chatter.IntegrationEventRelay

A story in nine chapters

Chatter.IntegrationEventRelay

Relays changes to a legacy SQL Server database's tables to message broker infrastructure as integration events, so systems that can't publish events themselves can take part in an event-driven architecture.

by Brennan Pike

Scroll to follow one row change from a legacy table to a topic on a message broker.

  • MIT license
  • By Brennan Pike
  • .NET 10 (net10.0)
  • Runs in a .NET Worker Service

Nine chapters, one change

Chapter 1 of 9

The island

Your legacy system writes to a SQL Server database. It can’t create or publish integration events: its as-is architecture, its technical debt, or an inability to integrate with message broker infrastructure keeps it out of an event-driven architecture.

Think of a monolithic desktop app (WinForms or Swing), a VBA Excel app, or people editing tables directly. The data changes. Nobody downstream hears about it.

One requirement. The legacy system’s backend is SQL Server. The relay is tested against SQL Server 2019 and 2022.

README: How it works

Chapter 2 of 9

The event-driven mainland

On the other side, systems publish and subscribe to events through message broker infrastructure. The relay publishes integration events through any Chatter message broker infrastructure implementation, such as Azure Service Bus.

An integration event is the message the relay publishes. An attribute names the topic it goes to, and consumers subscribe to that topic.

OrderShippedEvent, from the README
[BrokeredMessage("order-shipped-event")]
public class OrderShippedEvent : IEvent

README: Features

Chapter 3 of 9

Watch the table

The bridge is a change source. It watches your tables for row changes. Each row change becomes a source event: the row’s values, typed by the mapping’s source event type.

Two change sources are available, and the stage draws them differently on purpose: SQL Service Broker is the dashed teal bridge with square packets, and SQL Server CDC is the solid rust bridge with round packets. One relay can use both, but each table uses only one.

Our running example is an Orders table. When OrderStatus changes to Shipped, the relay should publish an OrderShippedEvent. The relay runs in a .NET Worker Service, and it checks its configuration when the host starts and stops with a list of problems.

Row change
An insert, update or delete of one row.
Source event
The row’s values, typed by the mapping’s source event type.
Change source
What watches the tables: SQL Service Broker or CDC.
Source
A named, configured instance of a change source. Each mapping names one.

README: How it works

Chapter 4 of 9

The mapper decides

A mapping in configuration pairs a table and a change type with a source event type and an integration event type. Each mapping has exactly one mapper.

The mapper is your code. It decides whether the source event becomes an integration event and builds it. Return null and nothing is published. For updates the mapper sees both OldValue and NewValue.

The mapper’s test, from the README
// mappingData.OldValue only has a value for an update
if (mappingData.OldValue?.OrderStatus != "Shipped" && mappingData.NewValue?.OrderStatus == "Shipped")

In the example, OrderStatus changing to Shipped makes the mapper publish OrderShippedEvent to the order-shipped-event topic.

Follow a change through the mapper

The stage dims the outcome you are not following. Where the browser can’t do this, both outcomes stay lit.

Mapping
Pairs a table and change type with a source event type and an integration event type.
Mapper
Your code: decides whether the source event becomes an integration event, and builds it.
Integration event
The message the relay publishes.

Chapter 5 of 9

Publish

The relay hands the integration event to the message broker infrastructure you configured. The topic named by [BrokeredMessage("order-shipped-event")] receives it, and consumers subscribe to that topic.

The legacy app never changed. It wrote one row, and the event-driven side heard an event.

Relay log (illustrative)
Processing Update of 'OrderChangedEvent' from source 'orders-cdc' with 1 mapping(s)
Published integration event 'OrderShippedEvent' to messaging infrastructure

Illustrative: the entity names and the count are made up. Only the message templates are the relay's.

README: Quick start

Chapter 6 of 9

Two ways across

Both bridges end at the same relay and the same mappers. They differ in how they see a change, and in what they promise. Pick the one that fits the table.

Look at one bridge

Dims the other bridge on the stage and the other card below. Both stay lit where the browser can’t do this.

SQL Service Broker

Dashed, teal, square packets

Capture
A trigger on each watched table, in the writing transaction, adds modified rows to a Service Broker queue. The Worker consumes it.
Needs
A SQL Server with Service Broker. Most PaaS offerings don’t support it.
Latency
No CDC capture latency.
Order
Not guaranteed.
Message id
Generated by Chatter.
Capabilities
None.
After downtime
Changes wait in the queue.

SQL Server CDC

Solid, rust, round packets

Capture
The Worker polls the change tables that CDC’s capture job fills from the transaction log. No trigger on the table. The capture job scans the log every 5 seconds by default.
Needs
SQL Server Agent, sysadmin to enable CDC on the database, db_owner per table.
Order
Commit order per table (capture instance), no order across tables. Keeps the before-image of updates.
Message id
Derived from the source name, the change’s position and the integration event type.
Commit time
Available as RelayContext.CommittedAt.
Capabilities
Ordered, Resumable, CommitTime (ParkRow drops Ordered).
After downtime
Resumes from a checkpoint while the changes are still in the change table (retention default 3 days).

Use CDC when

  • mappers compare old and new values or depend on the order of changes (for example soft delete followed by restore), since CDC delivers each table’s changes in commit order and keeps the before-image of every update;
  • you can’t, or don’t want to, add triggers to the legacy system’s tables, or the writes must not pay for a trigger and a Service Broker send;
  • the relay must resume where it stopped after downtime, from changes still in the change table.

Use SQL Service Broker when

  • SQL Server Agent isn’t available (CDC’s capture job needs it), or CDC can’t be enabled on the database;
  • changes must reach the relay without CDC’s capture latency (the capture job scans the log every few seconds by default);
  • the table is already relayed this way and works as needed.

A mapping can require capabilities with RequiredCapabilities. The relay stops at startup if the mapping’s source doesn’t provide them.

README: Choosing a change source README: Using the CDC change source

Chapter 7 of 9

Keep your place

Both change sources deliver at least once. That is a promise to plan around, so here is what keeps your place and what to do about repeats.

Checkpoint and same-id replay

With CDC, a batch is checkpointed only after every change in it is published. After a crash the batch is republished with the same message ids, derived from the source name, the change’s position and the integration event type. Enable duplicate detection on the topics (Azure Service Bus) or make consumers idempotent.

With SQL Service Broker, Chatter acknowledges by committing its receive transaction once processing succeeds, and Chatter’s recovery options retry failures.

Lease

Several relay instances can run with the same configuration. A lease (a SQL Server application lock) lets one of them poll each capture instance while the others stand by.

When a change keeps failing

Transient failures are retried with exponential backoff and never parked. For anything else you choose an error policy:

  • Halt (default) stops at the change and keeps retrying. Nothing after it is published, so order holds.
  • ParkRow parks the change in <Schema>.Chatter_RelayParkedChanges after MaxAttemptsBeforePark attempts. Later changes to the same row park behind it, and other rows carry on. On request, a replay republishes the row’s parked changes in order.
Request a replay, from the README
UPDATE Chatter.Chatter_RelayParkedChanges SET ReplayRequestedAt = SYSUTCDATETIME()
WHERE SourceName = N'orders-cdc' AND CaptureInstance = N'dbo_Orders' AND RowKey = N'<row key, e.g. [42]>';

Watching it

Metrics are published on the meter Chatter.IntegrationEventRelay.Cdc: chatter.relay.cdc.changes, chatter.relay.cdc.parked, chatter.relay.cdc.batch.failures, chatter.relay.cdc.batch.duration, chatter.relay.cdc.lag and chatter.relay.cdc.parked.current.

Health check, from the README
services.AddHealthChecks().AddCdcChangeSource(maxLag: TimeSpan.FromMinutes(5));

Tested SQL scripts enable CDC and grant the relay’s login least-privilege permissions. The integration tests run the relay under exactly those grants.

README: Delivery semantics README: Error policy and parked changes README: Metrics and health check

Chapter 8 of 9

Change bridges safely

You can move a table from one bridge to the other. Two scripts do it: .scripts/cdc/switch-to-cdc.sql and .scripts/cdc/switch-to-service-broker.sql.

Each script picks a switch point. The source you switch away from relays every change committed before it, and the source you switch to relays every change committed after it. Every change is relayed by only one of the two sources, and each source is still at least once.

Switching to CDC: who relays a change?

Before the switch point, SQL Service Broker relays. After it, CDC relays. Switching back reverses the roles.

  • The switch commits in one transaction, or the script rolls all of it back and changes nothing.
  • The script locks the table, so writes to it pause until the script ends, normally a few seconds. Its waits for the relay, the capture job and the CDC sources end within about @timeoutSeconds (default 300); past it, the script rolls back and changes nothing. Some reads, the checks before the lock and, switching back, SqlChangeFeed’s install procedures and the table’s triggers aren’t bounded by @timeoutSeconds. If the script doesn’t finish, cancel it: that rolls it back too.
  • Mappers don’t change.
  • Integration tests run both scripts in both directions.

README: Switching a table from SQL Service Broker to CDC

Chapter 9 of 9

Start

Three NuGet packages. The core maps row changes to integration events and publishes them, and a change source package captures the changes.

  • Chatter.IntegrationEventRelay

    Core: mapping configuration, mapper interfaces, and the processor that maps row changes to integration events and publishes them. It doesn’t capture changes itself.

    Chatter.IntegrationEventRelay on NuGet
  • Chatter.IntegrationEventRelay.ServiceBroker

    The SQL Service Broker change source. It uses Chatter.SqlChangeFeed, which installs a trigger on each watched table that sends changed rows to a SQL Service Broker queue.

    Chatter.IntegrationEventRelay.ServiceBroker on NuGet
  • Chatter.IntegrationEventRelay.Cdc

    The CDC change source. It polls each capture instance’s change table, hands changes to the processor in commit order, and saves a checkpoint in the source database once a batch is published.

    Chatter.IntegrationEventRelay.Cdc on NuGet

It targets .NET 10 (net10.0) and runs in a .NET Worker Service.

Add the packages, from the README
dotnet add package Chatter.IntegrationEventRelay
dotnet add package Chatter.IntegrationEventRelay.ServiceBroker
dotnet add package Chatter.MessageBrokers.AzureServiceBus
dotnet add package Chatter.MessageBrokers.AzureServiceBus.Auth
Wire it up with SQL Service Broker (Program.cs), from the README
// Wires up the mapper of each mapping in configuration
.AddIntegrationEventRelay()
    // Captures changes to the mapped tables with triggers and SQL Service Broker
    .UseServiceBrokerChangeSource();
Or with CDC (Program.cs), from the README
.AddIntegrationEventRelay()
    .UseCdcChangeSource()
    .UseServiceBrokerChangeSource();
CDC package, from the README
dotnet add package Chatter.IntegrationEventRelay.Cdc
The mapper, from the README
public class OrderShippedMapper : IMapSourceUpdateToIntegrationEvent<OrderChangedEvent, OrderShippedEvent>
{
    public Task<OrderShippedEvent?> MapAsync(MappingData<OrderChangedEvent> mappingData, RelayContext context, EventMappingConfigurationItem? mappingConfig)
    {
        OrderShippedEvent? orderShippedEvent = null;

        // mappingData.OldValue only has a value for an update
        if (mappingData.OldValue?.OrderStatus != "Shipped" && mappingData.NewValue?.OrderStatus == "Shipped")
        {
            orderShippedEvent = new OrderShippedEvent
            {
                Id = mappingData.NewValue.Id,
                CustomerId = mappingData.NewValue.CustomerId,
                ProductId = mappingData.NewValue.ProductId,
                OrderPlacedOn = mappingData.NewValue.OrderPlacedOn,
                OccurredAt = DateTime.UtcNow
            };
        }

        return Task.FromResult(orderShippedEvent);
    }
}
Configure a CDC source, from the README
"IntegrationEventRelay": {
  "ConnectionString": "...",
  "Schema": "Chatter",
  "Sources": {
    "orders-cdc": { "Type": "Cdc", "PollInterval": "00:00:01" }
  },
  "Mappings": [
    {
      "Source": "orders-cdc",
      "SourceEventTypeName": "MyNewIntegrationEventRelay.OrderChangedEvent",
      "IntegrationEventTypeName": "MyNewIntegrationEventRelay.OrderShippedEvent",
      "SourceChangeType": "Update",
      "TableName": "Orders",
      "TableSchema": "dbo",
      "CaptureInstance": "dbo_Orders"
    }
  ]
}

Then ship the seeded order and watch the event arrive:

Ship an order, from the README
UPDATE [dbo].[Orders]
   SET [OrderStatus] = 'Shipped'
 WHERE Id = '52CBE9FF-2496-4F46-9331-7F122AA3D973'
GO

Or run the demo

No Azure subscription needed. This starts SQL Server, the Azure Service Bus emulator, two Worker Services and two Consumer Services.

From the root of the repo
docker compose up --build

The demo uses both change sources. 100 database operations produce:

  • 50Event1DeletedEvent via CDC
  • 25Event1RestoredEvent via CDC
  • 25Event2CreatedEvent via SQL Service Broker

README: Quick start README: Packages README: Running this repo locally