RUBY ON RAILS · 21 MIN READ ·

Rails Transactional Outbox Pattern: Betrouwbaar Events Publiceren Zonder Dual-Write Fouten

Rails transactional outbox pattern: voorkom dual-write fouten en verloren webhooks door events atomair te publiceren binnen je database-transacties.

Rails Transactional Outbox Pattern: Betrouwbaar Events Publiceren Zonder Dual-Write Fouten

Drie weken nadat een nieuwe fulfilment-integratie live ging, belde een klant mij op een vrijdagmiddag. Zeventien bestellingen waren niet verzonden. De bestellingen stonden als status: "fulfilled" in de Rails-database. Het warehousemanagementsysteem had geen enkel record van ze.

Het verschil? Een Rails-proces was gestopt tijdens een rollende deploy — Kamal was containers aan het wisselen en zeven requests waren halverwege. Elk daarvan had de database-transactie succesvol gecommit, de bestelling geüpdatet naar fulfilled, en daarna een Net::ReadTimeout gegenereerd op de HTTP POST naar de warehouse-API. Geen enkel event bereikte het warehouse. Geen retry ingepland. De database en het externe systeem waren stil uit elkaar gedreven, en geen van beide wist het.

Dit is het dual-write probleem. Het zit niet in een fout in je code. Het is een structurele garantieschending die onzichtbaar is totdat het schade veroorzaakt, en het overkomt iedereen die naar een database schrijft én een externe service aanroept in hetzelfde request.

Het Rails transactional outbox pattern elimineert dit probleem.

Het Dual-Write Probleem, Precies

Elke keer dat je dit doet, heb je een dual-write:

def fulfill_order(order)
  order.update!(status: :fulfilled, fulfilled_at: Time.current)   # Schrijf 1: database
  WarehouseClient.post("/shipments", order.to_shipment_payload)   # Schrijf 2: extern systeem
end

Er zijn vier mogelijke uitkomsten. Drie zijn prima. Eén is catastrofaal:

  1. Beide slagen — prima.
  2. DB-schrijfactie mislukt, HTTP-call vindt nooit plaats — prima, consistente staat, herhaal de hele operatie.
  3. DB-schrijfactie slaagt, HTTP-call mislukt met een schone fout — theoretisch af te handelen met een rescue en retry, maar je schrijft nu retry-logica in het request-pad, wat de response vertraagt en onder concurrency moeilijk goed te krijgen is.
  4. DB-schrijfactie slaagt, HTTP-call timed out of het proces sterft — je zult nooit weten dat het event verloren is gegaan, want de exception vond plaats na de commit en de transactie is al weg.

Scenario vier veroorzaakte die zeventien onverzonden bestellingen op die vrijdagmiddag. Het proces stierf. Geen exception werd gegooid vanuit het perspectief van de aanroeper. Geen dead letter queue. Geen alert. Alleen stilte.

De neiging is om de volgorde om te draaien — eerst de externe service aanroepen, dan de database committen. Dat helpt niet. Je krijgt nu de omgekeerde stille fout: de webhook vuurt maar de database-update commit nooit. Je hebt een fundamenteel andere aanpak nodig.

Het Transactional Outbox Pattern

Het patroon is eenvoudig. In plaats van het externe systeem direct aan te roepen, schrijf je de intentie om het aan te roepen in een database-tabel — de outbox — binnen dezelfde transactie als je bedrijfslogicawijziging. Een apart achtergrondproces leest de outbox-tabel en verzorgt de daadwerkelijke levering. Omdat het outbox-record en het bedrijfsrecord atomair worden weggeschreven in één transactie, slagen ze allebei of mislukken ze allebei. De achtergrondprocessor herprobeert levering totdat het slaagt.

Het resultaat is at-least-once delivery: een event kan meer dan eens worden afgeleverd als de relay crasht tussen het markeren als afgeleverd en het persisteren van die status. Je consumers moeten idempotent zijn. In de praktijk is idempotentie aan de consumentenkant gemakkelijker te implementeren dan het klinkt — meestal volstaat een unieke index op een event-ID.

De Outbox-tabel Opzetten

Begin met de migratie:

# db/migrate/20260728000000_create_outbox_events.rb
class CreateOutboxEvents < ActiveRecord::Migration[8.0]
  def change
    create_table :outbox_events do |t|
      t.string  :aggregate_type,  null: false
      t.bigint  :aggregate_id,    null: false
      t.string  :event_type,      null: false
      t.jsonb   :payload,         null: false, default: {}
      t.string  :idempotency_key, null: false
      t.datetime :published_at
      t.integer  :attempts,       null: false, default: 0
      t.datetime :last_attempted_at
      t.text     :last_error
      t.timestamps
    end

    add_index :outbox_events, :idempotency_key, unique: true
    add_index :outbox_events, :created_at,
              where:  "published_at IS NULL",
              name:   "idx_outbox_events_pending"
  end
end

aggregate_type en aggregate_id identificeren welk record dit event toebehoort — Order en 42, bijvoorbeeld. event_type is een genaamruimde string zoals order.fulfilled. payload is de data die de consumer nodig heeft. idempotency_key is een unieke string per logisch event die double-processing in de consumer voorkomt als de relay hetzelfde event twee keer aflevert. De gedeeltelijke index op created_at WHERE published_at IS NULL houdt de pollingquery snel, zelfs met miljoenen al-afgeleverde events in de tabel.

Het model:

# app/models/outbox_event.rb
class OutboxEvent < ApplicationRecord
  MAXIMUM_ATTEMPTS = 10

  scope :pending, -> {
    where(published_at: nil)
      .where("attempts < ?", MAXIMUM_ATTEMPTS)
      .order(:created_at)
  }
end

Atomair Publiceren naar de Outbox

De volledige waarde van dit patroon komt voort uit het schrijven naar de outbox-tabel binnen hetzelfde transaction-blok als je bedrijfslogica. Rails wikkelt elke save!-aanroep in zijn eigen impliciete transactie, dus je hebt een expliciet blok nodig om de twee schrijfacties samen te bundelen:

class Order < ApplicationRecord
  def fulfill!
    transaction do
      update!(status: :fulfilled, fulfilled_at: Time.current)

      OutboxEvent.create!(
        aggregate_type:  "Order",
        aggregate_id:    id,
        event_type:      "order.fulfilled",
        payload:         {
          order_id:    id,
          customer_id: customer_id,
          line_items:  line_items.map(&:to_event_payload),
          fulfilled_at: fulfilled_at.iso8601
        },
        idempotency_key: "order.fulfilled.#{id}"
      )
    end
  end
end

Als OutboxEvent.create! gooit — een uniqueness-overtreding omdat het event al in de wachtrij staat, een database-constraint — rolt de hele transactie terug. Als het proces sterft na update! maar voor OutboxEvent.create!, rolt Postgres automatisch terug. Hoe dan ook: de bestelling is ofwel fulfilled met een outbox-record, of geen van beiden is gebeurd. Geen stille divergentie.

Voor hergebruik over meerdere modellen, extraheer je een concern:

# app/models/concerns/outbox_publisher.rb
module OutboxPublisher
  extend ActiveSupport::Concern

  def publish_outbox_event(event_type, payload = {}, idempotency_key: nil)
    OutboxEvent.create!(
      aggregate_type:  self.class.name,
      aggregate_id:    id,
      event_type:      event_type,
      payload:         payload,
      idempotency_key: idempotency_key || "#{self.class.name.underscore}.#{event_type}.#{id}"
    )
  end
end

Include het en roep het aan binnen je transacties:

class Subscription < ApplicationRecord
  include OutboxPublisher

  def upgrade!(new_plan)
    transaction do
      old_plan = plan
      update!(plan: new_plan, upgraded_at: Time.current)
      publish_outbox_event("subscription.upgraded", {
        subscription_id: id,
        old_plan:        old_plan,
        new_plan:        new_plan,
        upgraded_at:     upgraded_at.iso8601
      })
    end
  end
end

Een ontwerpbeslissing die expliciet de moeite waard is: de idempotency_key in het standaard concern is subscription.upgraded.123, wat uniek is per subscription. Als een subscription meerdere keren legitiem kan upgraden, voeg je een versie of timestamp toe in de sleutel om te voorkomen dat de uniqueness-constraint het tweede event blokkeert: "subscription.upgraded.#{id}.#{upgraded_at.to_i}".

De Relay-job

De relay-job pollt de outbox-tabel, levert elk pending event af aan zijn bestemming, en markeert het als afgeleverd. Hij draait elke paar seconden via Solid Queue’s recurring job scheduler — de volledige configuratie beschreef ik in de Solid Queue recurring jobs post.

# app/jobs/outbox_relay_job.rb
class OutboxRelayJob < ApplicationJob
  queue_as :outbox

  def perform
    OutboxEvent.pending.limit(100).lock("FOR UPDATE SKIP LOCKED").each do |event|
      relay(event)
    end
  end

  private

  def relay(event)
    event.update_columns(
      attempts:          event.attempts + 1,
      last_attempted_at: Time.current
    )

    EventRouter.dispatch(event)

    event.update_column(:published_at, Time.current)
  rescue => e
    event.update_column(:last_error, "#{e.class}: #{e.message}"[0, 2000])
    raise
  end
end

FOR UPDATE SKIP LOCKED is de sleutel tot veilige parallelle verwerking. Het vergrendelt de geselecteerde rijen en slaat rijen over die al vergrendeld zijn door een andere transactie. Als je twee relay-workers tegelijkertijd draait voor doorvoer, pikt elk een niet-overlappende batch op zonder te blokkeren of te racen. Zonder SKIP LOCKED staan workers in de rij achter elkaars vergrendelingen, waardoor parallelle aflevering wordt geserialiseerd. De Postgres-vergrendelingssemantiek beschreef ik uitgebreid in de advisory locks post.

Plan de relay frequent in via config/recurring.yml:

# config/recurring.yml
outbox_relay:
  class: OutboxRelayJob
  queue: outbox
  schedule: "*/5 * * * * *"   # elke 5 seconden (cron met seconden-ondersteuning)

Of inline in config/application.rb als je dat prefereert:

config.solid_queue.recurring_tasks = {
  outbox_relay: {
    class:    "OutboxRelayJob",
    schedule: "*/5 * * * * *"
  }
}

Events Routeren naar Hun Bestemming

De relay-job delegeert naar een EventRouter. De router mapt event-type patronen naar aflevering-adapters, waardoor de relay-job klein blijft en elke publisher onafhankelijk testbaar is:

# app/services/event_router.rb
class EventRouter
  ROUTES = {
    /^order\./        => OrderWebhookPublisher,
    /^subscription\./ => BillingSystemPublisher,
    /^customer\./     => CrmSyncPublisher
  }.freeze

  def self.dispatch(event)
    publisher_class = ROUTES.find { |pattern, _| pattern.match?(event.event_type) }&.last
    raise UnroutableEventError, "Geen publisher voor: #{event.event_type}" unless publisher_class

    publisher_class.new(event).call
  end
end

Een HTTP webhook publisher:

# app/publishers/order_webhook_publisher.rb
class OrderWebhookPublisher
  def initialize(event)
    @event = event
  end

  def call
    subscribers_for(@event.event_type).each do |subscriber|
      payload_json = @event.payload.to_json
      response = Faraday.post(
        subscriber.endpoint_url,
        payload_json,
        "Content-Type"      => "application/json",
        "X-Event-Type"      => @event.event_type,
        "X-Idempotency-Key" => @event.idempotency_key,
        "X-Signature"       => sign(payload_json, subscriber.secret)
      )
      raise WebhookDeliveryError, "HTTP #{response.status}" unless response.success?
    end
  end

  private

  def subscribers_for(event_type)
    WebhookSubscriber.active.for_event_type(event_type)
  end

  def sign(payload_json, secret)
    OpenSSL::HMAC.hexdigest("SHA256", secret, payload_json)
  end
end

Voor interne consumers — Solid Queue-jobs, Sidekiq-workers — sla HTTP volledig over en zet direct in de wachtrij:

# app/publishers/billing_system_publisher.rb
class BillingSystemPublisher
  def initialize(event)
    @event = event
  end

  def call
    SyncSubscriptionToBillingJob.perform_later(
      subscription_id: @event.aggregate_id,
      event_type:      @event.event_type,
      payload:         @event.payload,
      idempotency_key: @event.idempotency_key
    )
  end
end

Dit is een punt dat mensen vaak verrast: het Rails transactional outbox pattern werkt even goed of je nu externe HTTP-webhooks aanroept of interne achtergrond-jobs dispatcht. De relay is slechts een dispatcher. Het patroon lost in beide gevallen hetzelfde dual-write probleem op.

Fouten en Dead Letters Afhandelen

De relay-job gooit bij een fout. Het ActiveJob-framework markeert de job als mislukt en de planner zet hem opnieuw in de wachtrij. Bij de volgende relay-run heeft het outbox-record nog steeds published_at: nil met attempts: N en wordt het opnieuw opgepikt — tot MAXIMUM_ATTEMPTS.

Na tien pogingen sluit de pending-scope het record uit. Op dit punt heb je zichtbaarheid nodig. Bouw een eenvoudige health check:

# app/checks/outbox_health_check.rb
class OutboxHealthCheck
  def self.call
    dead_count = OutboxEvent
      .where(published_at: nil)
      .where("attempts >= ?", OutboxEvent::MAXIMUM_ATTEMPTS)
      .count

    {
      status: dead_count.zero? ? :ok : :degraded,
      dead_letter_count: dead_count
    }
  end
end

Koppel het aan je monitoring. Op klantopdrachten sla ik dit meestal op als een Sentry cron-check of stel het beschikbaar via /up als een benoemde check. Voor individuele vastgelopen records voeg je een reset-methode toe:

class OutboxEvent < ApplicationRecord
  def retry!
    update!(attempts: 0, last_error: nil, last_attempted_at: nil)
  end
end

Roep OutboxEvent.find(id).retry! aan vanuit een Rails-console om een dead letter event terug in de retry-wachtrij te zetten. Voor batchherstel na het repareren van een kapotte consumer:

OutboxEvent
  .where(published_at: nil)
  .where("attempts >= ?", OutboxEvent::MAXIMUM_ATTEMPTS)
  .update_all(attempts: 0, last_error: nil)

Afgeleverde Events Opruimen

De outbox-tabel verzamelt afgeleverde events voor onbepaalde tijd zonder opruiming. Voeg een prune-job toe:

# app/jobs/outbox_prune_job.rb
class OutboxPruneJob < ApplicationJob
  queue_as :maintenance

  def perform
    OutboxEvent
      .where.not(published_at: nil)
      .where("published_at < ?", 30.days.ago)
      .delete_all
  end
end

Gebruik delete_all in plaats van destroy_all. Je hebt geen callbacks bij verwijdering nodig, en delete_all geeft één SQL DELETE uit in plaats van N afzonderlijke roundtrips. Plan het dagelijks naast de relay. Dertig dagen geeft een comfortabel auditvenster zonder onbegrensde tabelgroei.

Wat Te Testen

Het outbox-patroon splitst netjes in twee onafhankelijke concerns, wat het eenvoudig maakt om te testen:

# spec/models/order_spec.rb
RSpec.describe Order do
  describe "#fulfill!" do
    it "maakt een outbox event aan in dezelfde transactie" do
      order = create(:order, :ready_to_fulfill)

      expect { order.fulfill! }.to change(OutboxEvent, :count).by(1)

      event = OutboxEvent.last
      expect(event.event_type).to eq("order.fulfilled")
      expect(event.aggregate_id).to eq(order.id)
      expect(event.payload["order_id"]).to eq(order.id)
    end

    it "rolt het outbox event terug als de order update mislukt" do
      order = create(:order, :ready_to_fulfill)
      allow(order).to receive(:update!).and_raise(ActiveRecord::RecordInvalid.new(order))

      expect { order.fulfill! }.to raise_error(ActiveRecord::RecordInvalid)
      expect(OutboxEvent.where(event_type: "order.fulfilled").count).to eq(0)
    end
  end
end

Test de relay-job en elke publisher onafhankelijk met een vooraf gevuld outbox-record. Houd de twee testconcerns gescheiden: één spec verifieert dat het outbox-record correct wordt aangemaakt; een ander verifieert dat de publisher het correct aflevert.

Wanneer Is het Outbox-patroon Overdreven?

Het patroon voegt een polling-lus, een database-tabel en een relay-laag toe. Voor sommige use cases is dat meer dan nodig.

Als je event-volume erg laag is — een paar events per dag — en de gevolgen van een verloren event echt gering zijn, kan een goed geïmplementeerd rescue-blok met een achtergrond-retry voldoende zijn. Het outbox-patroon schittert waar eventverlies zakelijke gevolgen heeft en waar events procesherstarts moeten overleven.

Als je al een berichtenbroker zoals Kafka of RabbitMQ gebruikt met transactionele semantiek, kan de broker zelf de duurzaamheidsgaranties bieden die je nodig hebt. In de praktijk is het betrouwbaar coördineren van een database-transactie én een broker-schrijfactie precies het dual-write probleem dat dit patroon oplost — vaak is de outbox het juiste antwoord, zelfs als er een broker in het spel is.

Voor diepere retry-patronen aan de consumerkant — exponentieel uitstel, circuit breakers, discard-beleid — passen die natuurlijk naast de outbox. Ik behandelde die in de Rails Active Job retries post.

Productie-hardening Checklist

Elke keer dat ik het Rails transactional outbox pattern instelt op een klantsysteem, controleer ik dit voordat ik het als klaar beschouw:

  • Unieke index op idempotency_key — voorkomt dubbele outbox-records en geeft de constraint-overtreding die een rollback triggert als je per ongeluk hetzelfde event twee keer inplant.
  • Gedeeltelijke index op pending eventsWHERE published_at IS NULL houdt de pollingquery O(pending) in plaats van O(alles). Zonder dit vertraagt de query naarmate afgeleverde events zich opstapelen.
  • FOR UPDATE SKIP LOCKED — veilige parallelle relay-workers zonder applicatieniveau-coördinatie.
  • MAXIMUM_ATTEMPTS begrenzing — voorkomt oneindige retry-stormen tegen een permanent kapotte consumer.
  • Dead letter monitoring — een health check of Sentry-alert schiet af wanneer events hun retries uitputten.
  • retry! mechanisme — je hebt een veilige manier nodig om dead letter events te resetten nadat een kapotte consumer is gerepareerd.
  • Payload-groottebeveiliging — valideer tijdens development dat payload.to_json.bytesize onder een redelijke limiet blijft (256KB is royaal). Te grote payloads wijzen op ontwerpfouten.
  • Prune-job — voorkomt onbegrensde tabelgroei.
  • Idempotente consumers — documenteer en handhaaf dat alle event-consumers dubbele levering aankunnen.

Veelgestelde Vragen

Wat is het Rails transactional outbox pattern?

Het Rails transactional outbox pattern lost het dual-write probleem op: het risico dat een database-schrijfactie en een externe API-aanroep onafhankelijk slagen of mislukken, waardoor je systeem in een inconsistente staat terechtkomt. Je schrijft het event naar een outbox_events-tabel binnen dezelfde database-transactie als de bedrijfsdatawijziging. Omdat beide schrijfacties atomair zijn in Postgres, slagen ze allebei of mislukken ze allebei. Een afzonderlijke achtergrond-job leest de outbox en verzorgt levering met retries. Het resultaat is at-least-once event-levering zonder risico op stil dataverlies door procesherstarts of netwerkfouten.

Hoe verschilt het outbox-patroon van gewoon een achtergrond-job inplannen?

Een achtergrond-job inplannen is zelf een schrijfactie naar een extern systeem — Redis voor Sidekiq, of de jobs-tabel voor Solid Queue. Als je Rails-proces sterft nadat de database-transactie is gecommit maar voordat de job succesvol is ingepland, gaat het event verloren. De transactional outbox vermijdt dit door de leveringsintentie te schrijven naar dezelfde Postgres-database, binnen dezelfde transactie. Postgres garandeert dat als de transactie commit, het outbox-record bestaat. De relay-job leest dan van Postgres, dat een gecommit record nooit verliest. Zelfs als je Solid Queue gebruikt — dat jobs in Postgres opslaat — is de solid_queue_jobs-tabel een andere databaseverbinding en valt buiten je applicatietransactie.

Hoe voorkomt FOR UPDATE SKIP LOCKED conflicten tussen relay-workers?

SELECT ... FOR UPDATE SKIP LOCKED vergrendelt de geselecteerde rijen en slaat, cruciaal, rijen over die al vergrendeld zijn door een andere transactie. Als twee relay-workers tegelijkertijd draaien, pikt elk een niet-overlappende batch op — ze blokkeren elkaar niet. Zonder SKIP LOCKED staan workers in de rij achter elkaars vergrendelingen, waardoor parallelle aflevering wordt geserialiseerd. Het patroon voorkomt ook dubbele aflevering: de vergrendeling op een rij die door worker A wordt vastgehouden, voorkomt dat worker B hetzelfde event selecteert totdat A commit of teruggooit. Ik bespreek Postgres-vergrendelingssemantiek uitgebreid in de advisory locks post.

Kan ik het outbox-patroon gebruiken met Sidekiq in plaats van Solid Queue?

Ja. De relay-job is een standaard ActiveJob en de planner is losgekoppeld van de outbox-tabel zelf. Vervang OutboxRelayJob door een Sidekiq-worker en plan het met een sidekiq-cron of whenever/cron-invoer. Het OutboxEvent-model en de FOR UPDATE SKIP LOCKED-query werken identiek ongeacht de job-backend. Als je migreert van Sidekiq naar Solid Queue, hoeft je outbox-implementatie niet te veranderen — alleen de plannerconfiguratie.

Stil dataverlies tussen je database en externe systemen is een van de moeilijkste productiefouten om te diagnosticeren, want je weet niet dat het is gebeurd totdat een klant het merkt. Na negentien jaar Rails-productieondersteuning is het transactional outbox pattern standaardinfrastructuur bij elke klantopdracht waarbij webhooks, factureringsintegraties of warehousesystemen betrokken zijn. TTB Software helpt teams betrouwbare event-gedreven Rails-applicaties bouwen die deploys, netwerkfouten en procesherstarts overleven. Als je integraties events droppen onder belasting of tijdens rolling deploys, kunnen wij helpen.

#rails-transactional-outbox #rails-reliable-event-publishing #rails-outbox-pattern-postgres #rails-dual-write-problem #activerecord-outbox-pattern

Related Articles

Laatste sectie. Bel dan alsjeblieft.

Het is een telefoongesprek. Erger dan dat kan het niet worden.

Geen discovery-deck. Geen 45-minuten "kwalificatiegesprek." 30 minuten, jouw probleem, mijn mening. Als we een fit zijn weet je dat in minuut 12.

Directe lijn — Roger neemt zelf op
+31 6 5123 6132
Ma–vr, 09:00–18:00 CET · Nu beschikbaar

OF
info@ttb.software