Transactional outbox
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.