package imgcache import ( "context" "database/sql" "errors" "testing" "time" ) // pendingCountsWait is how long a test waits for a count to reach the // database, or for a write to start waiting for the database's connection. const pendingCountsWait = 5 * time.Second // holdDatabase takes the one connection of cache's database, so that every // other query waits for it, and returns the func that frees it. func holdDatabase(t *testing.T, cache *Cache) func() { t.Helper() conn, err := cache.db.Conn(t.Context()) if err != nil { t.Fatalf("failed to take the database connection: %v", err) } return func() { _ = conn.Close() } } // waitForConnectionWaits waits until db.Stats().WaitCount, the number of // times a caller has waited for a connection, reaches waits. func waitForConnectionWaits(t *testing.T, db *sql.DB, waits int64) { t.Helper() deadline := time.Now().Add(pendingCountsWait) for db.Stats().WaitCount < waits { if time.Now().After(deadline) { t.Fatalf("callers waited for the database connection %d times, want %d", db.Stats().WaitCount, waits) } time.Sleep(10 * time.Millisecond) } } // waitForCounters waits until the cache_stats row holds want. func waitForCounters(t *testing.T, cache *Cache, want cacheStatsCounters) { t.Helper() deadline := time.Now().Add(pendingCountsWait) for { got := readCacheStatsCounters(t, cache) if got == want { return } if time.Now().After(deadline) { t.Fatalf("counters = %+v, want %+v", got, want) } time.Sleep(10 * time.Millisecond) } } // TestService_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy holds the // database's one connection while a request whose fetch is held reaches its // deadline. The request must still return by its deadline with the // deadline's error, and its miss must reach the database once the // connection is free. func TestService_Get_ReturnsByItsDeadlineWhileTheDatabaseIsBusy(t *testing.T) { t.Parallel() svc, fixtures, fetcher := setupHeldFetchService(t) svc.cache.StartPendingCountWrites() defer func() { _ = svc.cache.StopPendingCountWrites(t.Context()) }() const timeout = 200 * time.Millisecond ctx, cancel := context.WithTimeout(t.Context(), timeout) defer cancel() results := startGet(ctx, svc, photoVariant(fixtures, 85, FitCover)) // The request has made its database reads by the time it fetches. <-fetcher.started releaseDatabase := holdDatabase(t, svc.cache) defer releaseDatabase() select { case got := <-results: deadline, _ := ctx.Deadline() t.Logf("Get() returned %v after its deadline, error = %v", time.Since(deadline), got.err) if !errors.Is(got.err, context.DeadlineExceeded) { t.Errorf("Get() error = %v, want %v", got.err, context.DeadlineExceeded) } case <-time.After(timeout + time.Second): t.Fatal("request did not return by its deadline while the database was busy") } releaseDatabase() waitForCounters(t, svc.cache, cacheStatsCounters{missCount: 1}) } // TestService_Get_ManyRequestsPastTheirDeadlineLeaveOneWriteWaiting holds the // database's one connection while many requests whose fetches are held reach // their deadline. At most one write may then wait for the connection, and // every miss must reach the database once it is free. func TestService_Get_ManyRequestsPastTheirDeadlineLeaveOneWriteWaiting(t *testing.T) { t.Parallel() svc, fixtures, fetcher := setupHeldFetchService(t) svc.cache.StartPendingCountWrites() defer func() { _ = svc.cache.StopPendingCountWrites(t.Context()) }() const ( requests = 20 timeout = 200 * time.Millisecond ) ctx, cancel := context.WithTimeout(t.Context(), timeout) defer cancel() // Each request asks for a variant of its own, so each fetches. results := make([]<-chan getResult, requests) for i := range results { results[i] = startGet(ctx, svc, photoVariant(fixtures, i+1, FitCover)) } // Each request has made its database reads by the time it fetches. for range requests { <-fetcher.started } releaseDatabase := holdDatabase(t, svc.cache) defer releaseDatabase() waitsBefore := svc.cache.db.Stats().WaitCount // Once one write waits for the connection, any others start waiting too. waitForConnectionWaits(t, svc.cache.db, waitsBefore+1) time.Sleep(arrivalWait) waits := svc.cache.db.Stats().WaitCount - waitsBefore t.Logf("%d writes waited for the database", waits) if waits > 1 { t.Errorf("%d writes waited for the database, want at most 1", waits) } for _, result := range results { select { case got := <-result: if !errors.Is(got.err, context.DeadlineExceeded) { t.Errorf("Get() error = %v, want %v", got.err, context.DeadlineExceeded) } case <-time.After(time.Second): t.Fatal("request did not return by its deadline while the database was busy") } } releaseDatabase() waitForCounters(t, svc.cache, cacheStatsCounters{missCount: requests}) } // TestStats_IncludesPendingCounts counts hits and misses whose writes miss // their deadline, and checks that Stats adds them to the database's counts // before they are written. func TestStats_IncludesPendingCounts(t *testing.T) { t.Parallel() cache, _ := newEvictionTestCache(t, 1<<30) _, err := cache.db.ExecContext(t.Context(), `UPDATE cache_stats SET hit_count = 75, miss_count = 25 WHERE id = 1`) if err != nil { t.Fatal(err) } // Writes given a context past its deadline fail at once. ended, cancel := context.WithDeadline(t.Context(), time.Now()) defer cancel() cache.IncrementStats(ended, true, 0) cache.IncrementStats(ended, true, 0) cache.IncrementStats(ended, false, 0) stats, err := cache.Stats(t.Context()) if err != nil { t.Fatalf("Stats() error = %v", err) } if stats.HitCount != 77 || stats.MissCount != 26 { t.Errorf("HitCount = %d, MissCount = %d, want 77 and 26", stats.HitCount, stats.MissCount) } want := cacheStatsCounters{hitCount: 75, missCount: 25} if got := readCacheStatsCounters(t, cache); got != want { t.Errorf("counters in the database = %+v, want %+v", got, want) } } // TestStopPendingCountWrites_WritesPendingCounts holds the database's one // connection while the goroutine that writes the pending counts waits for it, // then stops that goroutine. The stop must write the counts once the // connection is free. func TestStopPendingCountWrites_WritesPendingCounts(t *testing.T) { t.Parallel() cache, _ := newEvictionTestCache(t, 1<<30) cache.StartPendingCountWrites() defer func() { _ = cache.StopPendingCountWrites(t.Context()) }() releaseDatabase := holdDatabase(t, cache) defer releaseDatabase() waitsBefore := cache.db.Stats().WaitCount // Writes given a context past its deadline fail at once. ended, cancel := context.WithDeadline(t.Context(), time.Now()) defer cancel() cache.IncrementStats(ended, true, 0) cache.IncrementStats(ended, false, 0) cache.IncrementUpstreamFetch(ended, 1024) cache.IncrementTransformCount(ended) // The goroutine's write waits for the connection. waitForConnectionWaits(t, cache.db, waitsBefore+1) stopped := make(chan error, 1) go func() { stopped <- cache.StopPendingCountWrites(t.Context()) }() // The stop's own write waits for it once the goroutine has returned. waitForConnectionWaits(t, cache.db, waitsBefore+2) releaseDatabase() select { case err := <-stopped: if err != nil { t.Fatalf("StopPendingCountWrites() error = %v", err) } case <-time.After(pendingCountsWait): t.Fatal("StopPendingCountWrites() did not return once the database was free") } want := cacheStatsCounters{1, 1, 1, 1024, 1} if got := readCacheStatsCounters(t, cache); got != want { t.Errorf("counters = %+v, want %+v", got, want) } }