mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-08 00:02:12 +00:00
215 lines
6.5 KiB
Go
215 lines
6.5 KiB
Go
package campaign
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestReadCacheGetOrComputeCoalesces proves that many concurrent callers hitting
|
|
// a cold cache for the same key share a single computation instead of each
|
|
// starting their own planner walk. It releases the shared compute only after
|
|
// every caller has actually parked on the flight (via onWait), so it exercises
|
|
// coalescing rather than the fast cache path.
|
|
func TestReadCacheGetOrComputeCoalesces(t *testing.T) {
|
|
c := newReadCache[int](time.Minute)
|
|
|
|
const callers = 32
|
|
var calls int32
|
|
release := make(chan struct{})
|
|
waiting := make(chan struct{}, callers)
|
|
c.onWait = func() { waiting <- struct{}{} }
|
|
|
|
compute := func(context.Context) (int, error) {
|
|
atomic.AddInt32(&calls, 1)
|
|
// Hold the flight open so every caller is parked on it at once.
|
|
<-release
|
|
return 42, nil
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
results := make([]int, callers)
|
|
errs := make([]error, callers)
|
|
for i := 0; i < callers; i++ {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
results[idx], errs[idx] = c.getOrCompute(context.Background(), "k", compute)
|
|
}(i)
|
|
}
|
|
|
|
// Release only once every caller has reached the wait point, so the result
|
|
// is not yet cached and every caller genuinely coalesces onto the flight.
|
|
for i := 0; i < callers; i++ {
|
|
<-waiting
|
|
}
|
|
close(release)
|
|
wg.Wait()
|
|
|
|
if got := atomic.LoadInt32(&calls); got != 1 {
|
|
t.Fatalf("expected exactly one computation, got %d", got)
|
|
}
|
|
for i := 0; i < callers; i++ {
|
|
if errs[i] != nil {
|
|
t.Fatalf("caller %d returned error: %v", i, errs[i])
|
|
}
|
|
if results[i] != 42 {
|
|
t.Fatalf("caller %d got %d, want 42", i, results[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestReadCacheGetOrComputeCachesResult proves a successful result is cached so
|
|
// a later caller is served without recomputing, and that a different key is
|
|
// computed independently.
|
|
func TestReadCacheGetOrComputeCachesResult(t *testing.T) {
|
|
c := newReadCache[int](time.Minute)
|
|
|
|
var calls int32
|
|
compute := func(v int) func(context.Context) (int, error) {
|
|
return func(context.Context) (int, error) {
|
|
atomic.AddInt32(&calls, 1)
|
|
return v, nil
|
|
}
|
|
}
|
|
|
|
if v, err := c.getOrCompute(context.Background(), "a", compute(1)); err != nil || v != 1 {
|
|
t.Fatalf("first call: v=%d err=%v", v, err)
|
|
}
|
|
if v, err := c.getOrCompute(context.Background(), "a", compute(99)); err != nil || v != 1 {
|
|
t.Fatalf("second call should be cached: v=%d err=%v", v, err)
|
|
}
|
|
if v, err := c.getOrCompute(context.Background(), "b", compute(2)); err != nil || v != 2 {
|
|
t.Fatalf("different key: v=%d err=%v", v, err)
|
|
}
|
|
if got := atomic.LoadInt32(&calls); got != 2 {
|
|
t.Fatalf("expected 2 computations (one per key), got %d", got)
|
|
}
|
|
}
|
|
|
|
// TestReadCacheGetOrComputeDoesNotCacheErrors proves an error is not cached, so
|
|
// the next caller retries rather than being served a failure.
|
|
func TestReadCacheGetOrComputeDoesNotCacheErrors(t *testing.T) {
|
|
c := newReadCache[int](time.Minute)
|
|
sentinel := errors.New("boom")
|
|
|
|
if _, err := c.getOrCompute(context.Background(), "k", func(context.Context) (int, error) { return 0, sentinel }); !errors.Is(err, sentinel) {
|
|
t.Fatalf("expected sentinel error, got %v", err)
|
|
}
|
|
v, err := c.getOrCompute(context.Background(), "k", func(context.Context) (int, error) { return 7, nil })
|
|
if err != nil || v != 7 {
|
|
t.Fatalf("retry after error should recompute: v=%d err=%v", v, err)
|
|
}
|
|
}
|
|
|
|
// TestReadCacheGetOrComputeRecoversPanic proves a panic in compute is turned
|
|
// into an error and releases every waiter, not only the leader. It parks both a
|
|
// leader and a waiter on the flight (via onWait) before the compute panics.
|
|
func TestReadCacheGetOrComputeRecoversPanic(t *testing.T) {
|
|
c := newReadCache[int](time.Minute)
|
|
|
|
const callers = 2
|
|
release := make(chan struct{})
|
|
waiting := make(chan struct{}, callers)
|
|
c.onWait = func() { waiting <- struct{}{} }
|
|
|
|
compute := func(context.Context) (int, error) {
|
|
// Hold until both callers have joined the flight, so the test proves
|
|
// the panic releases the waiter and not only the leader.
|
|
<-release
|
|
panic("kaboom")
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
results := make([]error, callers)
|
|
for i := 0; i < callers; i++ {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
_, results[idx] = c.getOrCompute(context.Background(), "k", compute)
|
|
}(i)
|
|
}
|
|
done := make(chan struct{})
|
|
go func() { wg.Wait(); close(done) }()
|
|
|
|
for i := 0; i < callers; i++ {
|
|
<-waiting
|
|
}
|
|
close(release)
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("getOrCompute deadlocked after compute panicked")
|
|
}
|
|
for i, err := range results {
|
|
if err == nil {
|
|
t.Errorf("caller %d: expected an error from a panicking compute", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestReadCacheGetOrComputeCanceledWaiterDoesNotAffectFlight proves a waiter
|
|
// whose own context is canceled returns promptly with its ctx.Err() while the
|
|
// shared walk carries on, caches its result, and runs exactly once.
|
|
func TestReadCacheGetOrComputeCanceledWaiterDoesNotAffectFlight(t *testing.T) {
|
|
c := newReadCache[int](time.Minute)
|
|
|
|
var calls int32
|
|
release := make(chan struct{})
|
|
started := make(chan struct{})
|
|
compute := func(ctx context.Context) (int, error) {
|
|
atomic.AddInt32(&calls, 1)
|
|
close(started)
|
|
<-release
|
|
// The shared walk's context must survive a waiter's cancellation.
|
|
if ctx.Err() != nil {
|
|
return 0, ctx.Err()
|
|
}
|
|
return 7, nil
|
|
}
|
|
|
|
// The leader starts the flight and blocks inside compute.
|
|
leaderDone := make(chan struct{})
|
|
go func() {
|
|
defer close(leaderDone)
|
|
v, err := c.getOrCompute(context.Background(), "k", compute)
|
|
if err != nil || v != 7 {
|
|
t.Errorf("leader: v=%d err=%v", v, err)
|
|
}
|
|
}()
|
|
<-started
|
|
|
|
// A waiter joins the live flight, then has its own context canceled. It must
|
|
// return promptly with context.Canceled without starting a second compute.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
waiterErr := make(chan error, 1)
|
|
go func() {
|
|
_, err := c.getOrCompute(ctx, "k", compute)
|
|
waiterErr <- err
|
|
}()
|
|
cancel()
|
|
|
|
select {
|
|
case err := <-waiterErr:
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("canceled waiter: want context.Canceled, got %v", err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("canceled waiter did not return promptly while the flight was in progress")
|
|
}
|
|
|
|
// The flight still runs; release it and confirm it completed and cached.
|
|
close(release)
|
|
<-leaderDone
|
|
if got := atomic.LoadInt32(&calls); got != 1 {
|
|
t.Fatalf("expected exactly one computation, got %d", got)
|
|
}
|
|
if v, ok := c.get("k"); !ok || v != 7 {
|
|
t.Fatalf("flight result should be cached for other callers: v=%d ok=%v", v, ok)
|
|
}
|
|
}
|