Skip to content

When the message is money ​

Closing the last gap between "the row is committed" and "somebody was told about it".

Transactional outbox ​

Publishing after the commit still loses the message if the process dies in between. Writing the message into the same transaction as the work, and draining it with a job, does not — at the cost of a table, a tick of latency, and duplicates the consumer already had to tolerate.

go
package main

import (
	"encoding/json"
	"time"

	"gorm.io/gorm"

	core "gitlab.finema.co/finema/idin-core/v2"
	"gitlab.finema.co/finema/idin-core/v2/repository"
	"gitlab.finema.co/finema/idin-core/v2/utils"
)

// --- Example 5: transactional outbox -----------------------------------------
//
// Publishing after the commit is the right default and still leaves a hole: the
// commit succeeds, the process dies, and nobody is ever told the order exists.
// The database says one thing, the rest of the system another, and nothing
// anywhere notices.
//
// The outbox closes it by making the message part of the transaction. The row
// and the message are written together or not at all, and a job does the
// publishing afterwards from a table that survives the crash.
//
// The price is honest: one more table, latency of up to one job tick, and
// duplicate sends when the publish succeeds but the sent_at update does not.
// That last one is the same at-least-once every consumer already has to
// tolerate, which is why the outbox is not an excuse to stop being idempotent.
//
// Use it when the message is money, or a promise to another service. Not for
// every event.

// OrderRow is the work. Outbox is the message that says the work happened.
type OrderRow struct {
	ID    string `gorm:"primaryKey" json:"id"`
	Total int64  `json:"total"`
}

func (OrderRow) TableName() string { return "orders" }

type Outbox struct {
	ID         int64 `gorm:"primaryKey"`
	Exchange   string
	RoutingKey string
	MessageID  string
	Payload    []byte
	SentAt     *time.Time
	CreatedAt  *time.Time
}

func (Outbox) TableName() string { return "outbox" }

// createOrderWithOutbox writes both rows in one transaction. Either the order
// exists and the message is queued to go, or neither happened.
func createOrderWithOutbox(ctx core.IContext, order OrderRow) core.IError {
	payload, err := json.Marshal(order)
	if err != nil {
		return core.Wrap(err, "outbox: marshal")
	}

	return repository.New[OrderRow](ctx).Transaction(func(tx *gorm.DB) error {
		if err := repository.NewWithDB[OrderRow](ctx, tx).Create(&order); err != nil {
			return err
		}
		return repository.NewWithDB[Outbox](ctx, tx).Create(&Outbox{
			Exchange:   ordersExchange,
			RoutingKey: "order.created",
			// A stable id taken from the work itself. The drain may publish this
			// row twice; dedupe on the consumer is what makes that harmless, and
			// it has nothing to match on if this is a fresh UUID each time.
			MessageID: order.ID,
			Payload:   payload,
			CreatedAt: utils.ToPointer(time.Now()),
		})
	})
}

// registerOutboxDrain adds the sender to the service's own job registry. It
// needs no broker to register — only to run.
func registerOutboxDrain(reg *core.JobRegistry) {
	_ = reg.Register(core.JobDef{
		Name:        "outbox-drain",
		Description: "publish everything the outbox table is still holding",
		Schedule:    core.Every(5 * time.Second),
		// Two replicas draining at once publish the same rows twice. This collapses
		// the overlap; on more than one replica the limit has to be counted
		// somewhere shared (see the jobs example).
		MaxConcurrent: 1,
		Concurrency:   core.ConcurrencySkip,
	}, drainOutbox)
}

func drainOutbox(ctx core.ICronjobContext) error {
	rows, err := repository.New[Outbox](ctx).
		Where("sent_at IS NULL").
		Order("id").
		Limit(100).
		FindAll()
	if err != nil {
		return err
	}

	for _, row := range rows {
		// Payload is []byte that is already JSON, so the content type has to be
		// said out loud — otherwise it goes out as application/octet-stream.
		if err := ctx.MQ().PublishWith(row.Exchange, row.RoutingKey, row.Payload,
			core.PublishOptions{
				ContentType: "application/json",
				MessageID:   row.MessageID,
			}); err != nil {
			// Stop at the first failure and leave the rest for the next tick.
			// Nothing is lost, and the messages keep leaving in the order they
			// were written — which continuing past a failure would break.
			return err
		}

		now := time.Now()
		if err := repository.New[Outbox](ctx).
			Where("id = ?", row.ID).
			Update("sent_at", now); err != nil {
			return err
		}
	}

	if len(rows) > 0 {
		ctx.Log().Info("outbox drained", "rows", len(rows))
	}
	return nil
}

Maintained by Passakon Puttasuwan & Dev Core Team.