Skip to content
Spice Framework on GitHub

Transactional outbox

frameworkMaturity: previewSource: spice@c0641b3Exact reviewed source

event/outbox defines the storage and delivery protocol for durable, at-least-once event publication. It does not claim durability without a database-backed Store.

Create the immutable serialized message before application work commits, then enqueue it through the same transaction executor:

err := transactions.Within(ctx, boundary, func(ctx context.Context, tx data.Executor) error {
if err := orders.Save(ctx, tx, order); err != nil {
return err
}
message, err := outbox.NewMessage(outbox.MessageSpec{
ID: command.ID,
Topic: "orders.OrderPlaced",
Module: "example.com/shop/orders",
ContentType: "application/json",
Payload: payload,
OccurredAt: clock(),
})
if err != nil {
return err
}
return store.Enqueue(ctx, tx, message)
})

Message IDs are caller-owned idempotency keys. Payloads are copied and limited to 1 MiB; metadata is validated and bounded. A store atomically claims the oldest available messages ordered by occurrence time and ID, returning opaque lease receipts and one-based attempts.

Dispatcher.RunOnce publishes a bounded batch sequentially, completes successful leases, and releases failed publishes with an explicit delay. It starts no goroutine; applications can invoke it through Spice scheduling or their own lifecycle loop. Cancellation stops before the next message.

Delivery is deliberately at least once. If publishing succeeds and completion fails, the lease eventually expires and the message can be published again. Transport publishers must therefore use the message ID as their downstream idempotency key.

Publisher panics are observed and re-raised; the lease is left to expire. Observations contain topic/module/attempt and outcome metadata, never payloads or lease receipts.

SQL store

SQLStore accepts a long-lived data.Executor plus four trusted, dialect-owned statements. It performs no database operation during construction.

  • Insert arguments: ID, topic, module, content type, payload, occurrence time.
  • Claim arguments: owner, current time, lease expiry, limit.
  • Claim columns: ID, topic, module, content type, payload, occurrence time, opaque receipt, one-based attempt.
  • Complete arguments: owner, receipt.
  • Release arguments: owner, receipt, next availability time.

The claim statement must atomically select and lease rows in occurrence-time/ID order. Spice reconstructs and validates every message, rejects duplicate or unordered results, closes and checks rows, and requires completion/release to affect exactly one row. Locking syntax and migrations remain dialect-owned.

PostgreSQL

github.com/spice-framework/starter-postgres supplies reviewed SQL and deterministic initial DDL:

schemaSQL, err := postgres.OutboxSchemaSQL(postgres.OutboxOptions{
Schema: "orders",
})
if err != nil {
return err
}
// Commit schemaSQL through an application-owned migration.
store, err := postgres.NewOutboxStore(database, postgres.OutboxOptions{
Schema: "orders",
})

Empty options select public.spice_event_outbox. Identifiers are validated PostgreSQL identifiers and are always quoted. Construction never connects or applies schema. Enqueue uses the executor supplied by the application transaction; claims use FOR UPDATE SKIP LOCKED, stable occurrence-time/ID ordering, unique receipts, bounded leases, and one-based attempts. Release clears ownership and sets the explicit next availability time. Completion and release reject stale owner/receipt pairs by requiring exactly one affected row.

The tagged PostgreSQL race integration proves transaction rollback, committed visibility, deterministic ordering, delayed retry with a fresh receipt, stale-receipt rejection, and exclusive concurrent claims.