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:
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user