chore: establish local platform baseline
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
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) {
|
||||
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()) }()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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) (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 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())
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package idempotency
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestExecutorReplaysAndRejectsHashConflict(t *testing.T) {
|
||||
pool := integrationPool(t)
|
||||
executor := NewExecutor(pool)
|
||||
key := fmt.Sprintf("test-%d", time.Now().UnixNano())
|
||||
tenantID := "10000000-0000-0000-0000-000000000001"
|
||||
request := Request{
|
||||
TenantID: &tenantID, ActorKey: "integration-user", Scope: "integration.create",
|
||||
Key: key, RequestHash: Hash([]byte(`{"value":1}`)), ExpiresAfter: time.Hour,
|
||||
}
|
||||
var calls atomic.Int32
|
||||
operation := func(context.Context, pgx.Tx) (Result, error) {
|
||||
calls.Add(1)
|
||||
return Result{StatusCode: 201, Body: json.RawMessage(`{"created":true}`)}, nil
|
||||
}
|
||||
first, err := executor.Execute(context.Background(), request, operation)
|
||||
if err != nil || first.Replayed {
|
||||
t.Fatalf("first execute = %#v, %v", first, err)
|
||||
}
|
||||
second, err := executor.Execute(context.Background(), request, operation)
|
||||
if err != nil || !second.Replayed || string(second.Body) != `{"created": true}` {
|
||||
t.Fatalf("second execute = %#v, %v", second, err)
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("operation calls = %d, want 1", calls.Load())
|
||||
}
|
||||
lookedUp, found, err := executor.Lookup(context.Background(), request)
|
||||
if err != nil || !found || !lookedUp.Replayed || string(lookedUp.Body) != `{"created": true}` {
|
||||
t.Fatalf("lookup=%#v found=%v err=%v", lookedUp, found, err)
|
||||
}
|
||||
request.RequestHash = Hash([]byte(`{"value":2}`))
|
||||
if _, err := executor.Execute(context.Background(), request, operation); !errors.Is(err, ErrKeyConflict) {
|
||||
t.Fatalf("hash conflict error = %v, want ErrKeyConflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorSerializesConcurrentRequests(t *testing.T) {
|
||||
pool := integrationPool(t)
|
||||
executor := NewExecutor(pool)
|
||||
key := fmt.Sprintf("concurrent-%d", time.Now().UnixNano())
|
||||
request := Request{
|
||||
ActorKey: "integration-concurrent", Scope: "integration.create", Key: key,
|
||||
RequestHash: Hash([]byte(`{"value":1}`)), ExpiresAfter: time.Hour,
|
||||
}
|
||||
var calls atomic.Int32
|
||||
operation := func(context.Context, pgx.Tx) (Result, error) {
|
||||
calls.Add(1)
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
return Result{StatusCode: 201, Body: json.RawMessage(`{"ok":true}`)}, nil
|
||||
}
|
||||
var waitGroup sync.WaitGroup
|
||||
errorsChannel := make(chan error, 5)
|
||||
for range 5 {
|
||||
waitGroup.Add(1)
|
||||
go func() {
|
||||
defer waitGroup.Done()
|
||||
_, err := executor.Execute(context.Background(), request, operation)
|
||||
errorsChannel <- err
|
||||
}()
|
||||
}
|
||||
waitGroup.Wait()
|
||||
close(errorsChannel)
|
||||
for err := range errorsChannel {
|
||||
if err != nil {
|
||||
t.Errorf("concurrent execute returned %v", err)
|
||||
}
|
||||
}
|
||||
if calls.Load() != 1 {
|
||||
t.Fatalf("operation calls = %d, want 1", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func integrationPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
pool, err := pgxpool.New(context.Background(), databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect integration database: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
return pool
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package idempotency
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHashIsStable(t *testing.T) {
|
||||
first := Hash([]byte(`{"name":"test"}`))
|
||||
second := Hash([]byte(`{"name":"test"}`))
|
||||
if first != second || len(first) != 64 {
|
||||
t.Fatalf("unexpected hashes: %q %q", first, second)
|
||||
}
|
||||
if first == Hash([]byte(`{"name":"other"}`)) {
|
||||
t.Fatal("different bodies should not have the same hash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequest(t *testing.T) {
|
||||
if err := validateRequest(Request{}); err == nil {
|
||||
t.Fatal("empty request should fail")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user