Add bounded advisory-lock helper; convert loyalty redemption to try-lock

Introduce acquireAdvisoryLock (pg_try_advisory_lock with a ~3s bounded retry) and acquireAdvisoryXactLockBlocking (deliberately blocking for the cancellation-refund path where silently dropping a refund is worse than waiting). Convert ApplyLoyaltyRedemption to the bounded variant: concurrent redemptions during an in-flight payment return 409 instead of pinning a pool connection. Add uncontended + contended-timeout unit tests and a lock-contended 409 redemption test.
This commit is contained in:
2026-08-22 00:34:49 +01:00
parent 12af3af3b3
commit e02567564e
4 changed files with 254 additions and 3 deletions
+86
View File
@@ -0,0 +1,86 @@
//go:build test && dev
package payments
import (
"context"
"testing"
"time"
"crussell/db"
)
// TestAdvisoryLock_Uncontended_Acquires verifies the happy path: an unlocked
// key is acquired immediately with a bounded try-lock.
func TestAdvisoryLock_Uncontended_Acquires(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn, err := db.Conn.Acquire(ctx)
if err != nil {
t.Fatalf("failed to acquire pool connection: %v", err)
}
defer conn.Release()
key := "crussell:payment:locktest-uncontended"
acquired, err := acquireAdvisoryLock(ctx, conn, key)
if err != nil {
t.Fatalf("acquireAdvisoryLock returned error: %v", err)
}
if !acquired {
t.Fatal("expected uncontended lock to be acquired")
}
if _, err := conn.Exec(ctx, `SELECT pg_advisory_unlock(hashtext($1))`, key); err != nil {
t.Fatalf("failed to release lock: %v", err)
}
}
// TestAdvisoryLock_Contended_TimesOutWithinBound verifies the bounded try-lock
// timeout branch: when another connection holds the same key, acquireAdvisoryLock
// returns (false, nil) after the ~3s retry bound instead of blocking forever
// (the pool-exhaustion defence).
func TestAdvisoryLock_Contended_TimesOutWithinBound(t *testing.T) {
t.Parallel()
ctx := context.Background()
holder, err := db.Conn.Acquire(ctx)
if err != nil {
t.Fatalf("failed to acquire holder connection: %v", err)
}
defer holder.Release()
key := "crussell:payment:locktest-contended"
// Hold the advisory lock on a dedicated connection so every try-lock
// attempt from the second connection fails.
if _, err := holder.Exec(ctx, `SELECT pg_advisory_lock(hashtext($1))`, key); err != nil {
t.Fatalf("failed to acquire holder lock: %v", err)
}
defer func() {
_, _ = holder.Exec(context.Background(), `SELECT pg_advisory_unlock(hashtext($1))`, key)
}()
waiter, err := db.Conn.Acquire(ctx)
if err != nil {
t.Fatalf("failed to acquire waiter connection: %v", err)
}
defer waiter.Release()
start := time.Now()
acquired, err := acquireAdvisoryLock(ctx, waiter, key)
elapsed := time.Since(start)
if err != nil {
t.Fatalf("acquireAdvisoryLock returned error (expected false, nil): %v", err)
}
if acquired {
t.Fatal("expected contended lock NOT to be acquired")
}
// The bound is 30 attempts × 100ms ≈ 3s. Assert it gave up within a sane
// window (did not hang) and did not return prematurely.
if elapsed < 2*time.Second {
t.Errorf("expected the timeout bound (~3s) to elapse before giving up, returned after %v", elapsed)
}
if elapsed > 10*time.Second {
t.Errorf("expected to give up within the ~3s bound, took %v", elapsed)
}
}