Derive outbox idempotency keys from the entry's creation time

The outbox sent each Stripe create with the key
member-console-outbox-<entry id>. The id restarts with the outbox
sequence after a database reset, a restore, or in a second database on
the same Stripe account, and Stripe refuses a key it has seen with
other parameters, so the entry dead-lettered and every retry sent the
same refused key (#147).

The key now carries the row's created_at to the microsecond, read back
from the row before each attempt: the same on every attempt of one
entry, different for every entry that ever held the id. No migration.
This commit is contained in:
2026-09-28 18:32:26 -05:00
parent 94334c0e64
commit d862b46920
2 changed files with 48 additions and 8 deletions
@@ -35,6 +35,10 @@ type OutboxEntry struct {
// MaxAttempts is no longer read: Temporal's retry budget bounds the
// attempts. It stays for the handles tests build from rows.
MaxAttempts int `db:"max_attempts"`
// CreatedAt is when the row was written. ExecuteOutboxEntry reads it
// from the row before acting, whatever the workflow input carried,
// because the idempotency key is derived from it.
CreatedAt time.Time `db:"created_at"`
}
// PollIntegrationOutboxInput configures the outbox polling workflow.
@@ -242,8 +246,8 @@ func (a *OutboxActivities) PollPendingOutboxEntries(ctx context.Context, batchSi
func (a *OutboxActivities) ExecuteOutboxEntry(ctx context.Context, entry OutboxEntry) error {
var status string
if err := a.DB.QueryRowContext(ctx,
`SELECT status, payload, action_type FROM core.outbox WHERE id = $1`, entry.ID,
).Scan(&status, &entry.Payload, &entry.ActionType); err != nil {
`SELECT status, payload, action_type, created_at FROM core.outbox WHERE id = $1`, entry.ID,
).Scan(&status, &entry.Payload, &entry.ActionType, &entry.CreatedAt); err != nil {
return fmt.Errorf("read outbox entry %d: %w", entry.ID, err)
}
if status == "completed" {
@@ -315,9 +319,13 @@ func classifyStripeError(err error) error {
// outboxIdempotencyKey is the Stripe idempotency key for an entry's create:
// the same key on every attempt, so a retry after a lost result returns the
// object the first attempt made (Stripe keeps keys for 24 hours, the retry
// budget).
func outboxIdempotencyKey(entryID int64) string {
return fmt.Sprintf("member-console-outbox-%d", entryID)
// budget). The id alone is not enough: it restarts with the outbox's
// sequence after a database reset, a restore, or in a second database on
// the same Stripe account, and Stripe refuses a key it has seen with other
// parameters. The row's creation time, to the microsecond Postgres keeps,
// tells those entries apart and never changes between attempts.
func outboxIdempotencyKey(entry OutboxEntry) string {
return fmt.Sprintf("member-console-outbox-%d-%d", entry.ID, entry.CreatedAt.UnixMicro())
}
// executeAction dispatches to the appropriate action executor based on action type.
@@ -352,7 +360,7 @@ func (a *OutboxActivities) executeCreateStripeCustomer(ctx context.Context, entr
params.AddMetadata("org_id", payload.OrgID)
params.AddMetadata("org_name", payload.OrgName)
params.AddMetadata("billing_account_id", payload.BillingAccountID)
params.SetIdempotencyKey(outboxIdempotencyKey(entry.ID))
params.SetIdempotencyKey(outboxIdempotencyKey(entry))
cust, err := customer.New(params)
if err != nil {
@@ -418,7 +426,7 @@ func (a *OutboxActivities) executeCreateStripeProduct(ctx context.Context, entry
}
params := buildStripeProductParams(payload)
params.SetIdempotencyKey(outboxIdempotencyKey(entry.ID))
params.SetIdempotencyKey(outboxIdempotencyKey(entry))
prod, err := product.New(params)
if err != nil {
return fmt.Errorf("stripe create product: %w", err)
@@ -482,7 +490,7 @@ func (a *OutboxActivities) executeCreateStripePrice(ctx context.Context, entry O
}
params.AddMetadata("price_id", payload.PriceID)
params.AddMetadata("product_id", payload.ProductID)
params.SetIdempotencyKey(outboxIdempotencyKey(entry.ID))
params.SetIdempotencyKey(outboxIdempotencyKey(entry))
p, err := price.New(params)
if err != nil {
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package workflows
import (
"testing"
"time"
)
// An entry's idempotency key is the same on every attempt and differs from
// the key of any other entry that ever had its id: after a database reset,
// a restore, or in a second database on the same Stripe account, the id
// restarts but the row's creation time does not repeat (forge #147).
func TestOutboxIdempotencyKeyOutlivesTheSequence(t *testing.T) {
morning := OutboxEntry{ID: 1, CreatedAt: time.Date(2026, 9, 27, 9, 0, 0, 123456000, time.UTC)}
evening := OutboxEntry{ID: 1, CreatedAt: time.Date(2026, 9, 27, 18, 0, 0, 654321000, time.UTC)}
if outboxIdempotencyKey(morning) == outboxIdempotencyKey(evening) {
t.Errorf("two entries with id 1 made at different times share the key %q", outboxIdempotencyKey(morning))
}
retry := morning
retry.Attempts = 3
if outboxIdempotencyKey(retry) != outboxIdempotencyKey(morning) {
t.Errorf("a retry changed the key: %q, then %q", outboxIdempotencyKey(morning), outboxIdempotencyKey(retry))
}
// Postgres keeps microseconds; a time read back from the row must give
// the key the first attempt used.
if outboxIdempotencyKey(morning) != "member-console-outbox-1-1790499600123456" {
t.Errorf("key = %q", outboxIdempotencyKey(morning))
}
}