188 lines
5.7 KiB
Go
188 lines
5.7 KiB
Go
package idempotency
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
var (
|
|
ErrKeyConflict = errors.New("idempotency key was reused with different request content")
|
|
ErrInProgress = errors.New("idempotent operation is still in progress")
|
|
)
|
|
|
|
type Request struct {
|
|
TenantID *string
|
|
ActorKey string
|
|
Scope string
|
|
Key string
|
|
RequestHash string
|
|
ExpiresAfter time.Duration
|
|
}
|
|
|
|
type Result struct {
|
|
StatusCode int
|
|
Body json.RawMessage
|
|
ResourceID *string
|
|
Replayed bool
|
|
}
|
|
|
|
type Executor struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewExecutor(pool *pgxpool.Pool) *Executor { return &Executor{pool: pool} }
|
|
|
|
func Hash(body []byte) string {
|
|
digest := sha256.Sum256(body)
|
|
return hex.EncodeToString(digest[:])
|
|
}
|
|
|
|
// Lookup returns a completed result without opening the operation transaction.
|
|
// Callers with an expensive, side-effect-free precomputation can use it to
|
|
// avoid repeating that work on ordinary sequential idempotency replays. Execute
|
|
// remains the authority for races and must still be called when found is false.
|
|
func (e *Executor) Lookup(ctx context.Context, request Request) (result Result, found bool, err error) {
|
|
if err = validateRequest(request); err != nil {
|
|
return Result{}, false, err
|
|
}
|
|
var storedHash, state string
|
|
err = e.pool.QueryRow(ctx, `
|
|
SELECT request_hash,state,response_status,response_body,resource_id::text
|
|
FROM idempotency_record
|
|
WHERE actor_key=$1 AND scope=$2 AND idempotency_key=$3`,
|
|
request.ActorKey, request.Scope, request.Key).
|
|
Scan(&storedHash, &state, &result.StatusCode, &result.Body, &result.ResourceID)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return Result{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return Result{}, false, fmt.Errorf("lookup idempotency record: %w", err)
|
|
}
|
|
if storedHash != request.RequestHash {
|
|
return Result{}, true, ErrKeyConflict
|
|
}
|
|
if state != "completed" {
|
|
return Result{}, true, ErrInProgress
|
|
}
|
|
result.Replayed = true
|
|
return result, true, nil
|
|
}
|
|
|
|
func (e *Executor) Execute(
|
|
ctx context.Context,
|
|
request Request,
|
|
operation func(context.Context, pgx.Tx) (Result, error),
|
|
) (Result, error) {
|
|
return e.ExecuteGuarded(ctx, request, nil, operation)
|
|
}
|
|
|
|
// Guard executes in the operation transaction before mutation or replay. On
|
|
// replay it also receives the stored resource for current authorization checks.
|
|
type Guard func(context.Context, pgx.Tx, *Result) error
|
|
|
|
func (e *Executor) ExecuteGuarded(ctx context.Context, request Request, guard Guard, operation func(context.Context, pgx.Tx) (Result, error)) (Result, error) {
|
|
if err := validateRequest(request); err != nil {
|
|
return Result{}, err
|
|
}
|
|
tx, err := e.pool.BeginTx(ctx, pgx.TxOptions{})
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("begin idempotency transaction: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback(context.Background()) }()
|
|
if guard != nil {
|
|
if err := guard(ctx, tx, nil); err != nil {
|
|
return Result{}, err
|
|
}
|
|
}
|
|
|
|
tag, err := tx.Exec(ctx, `
|
|
INSERT INTO idempotency_record (
|
|
tenant_id, actor_key, scope, idempotency_key, request_hash,
|
|
state, expires_at
|
|
) VALUES ($1::uuid, $2, $3, $4, $5, 'processing', now() + $6::interval)
|
|
ON CONFLICT (actor_key, scope, idempotency_key) DO NOTHING`,
|
|
request.TenantID, request.ActorKey, request.Scope, request.Key,
|
|
request.RequestHash, postgresInterval(request.ExpiresAfter),
|
|
)
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("claim idempotency key: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return e.replay(ctx, tx, request, guard)
|
|
}
|
|
|
|
result, err := operation(ctx, tx)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
if !json.Valid(result.Body) {
|
|
return Result{}, errors.New("idempotent response body must be valid JSON")
|
|
}
|
|
_, err = tx.Exec(ctx, `
|
|
UPDATE idempotency_record
|
|
SET state = 'completed', response_status = $4, response_body = $5::jsonb,
|
|
resource_id = $6::uuid, updated_at = now()
|
|
WHERE actor_key = $1 AND scope = $2 AND idempotency_key = $3`,
|
|
request.ActorKey, request.Scope, request.Key, result.StatusCode, result.Body, result.ResourceID,
|
|
)
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("complete idempotency record: %w", err)
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return Result{}, fmt.Errorf("commit idempotent operation: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (e *Executor) replay(ctx context.Context, tx pgx.Tx, request Request, guard Guard) (Result, error) {
|
|
var storedHash, state string
|
|
var result Result
|
|
err := tx.QueryRow(ctx, `
|
|
SELECT request_hash, state, response_status, response_body, resource_id::text
|
|
FROM idempotency_record
|
|
WHERE actor_key = $1 AND scope = $2 AND idempotency_key = $3
|
|
FOR UPDATE`, request.ActorKey, request.Scope, request.Key,
|
|
).Scan(&storedHash, &state, &result.StatusCode, &result.Body, &result.ResourceID)
|
|
if err != nil {
|
|
return Result{}, fmt.Errorf("read idempotency record: %w", err)
|
|
}
|
|
if storedHash != request.RequestHash {
|
|
return Result{}, ErrKeyConflict
|
|
}
|
|
if state != "completed" {
|
|
return Result{}, ErrInProgress
|
|
}
|
|
result.Replayed = true
|
|
if guard != nil {
|
|
if err := guard(ctx, tx, &result); err != nil {
|
|
return Result{}, err
|
|
}
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return Result{}, fmt.Errorf("commit idempotency replay: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func validateRequest(request Request) error {
|
|
if request.ActorKey == "" || request.Scope == "" || request.Key == "" || request.RequestHash == "" {
|
|
return errors.New("actor key, scope, idempotency key and request hash are required")
|
|
}
|
|
if request.ExpiresAfter <= 0 {
|
|
return errors.New("idempotency expiry must be positive")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func postgresInterval(duration time.Duration) string {
|
|
return fmt.Sprintf("%f seconds", duration.Seconds())
|
|
}
|