Files
pixa/internal/imgcache/pendingaccesstimes_internal_test.go
T
clawbot ef70bb6fa0
check / check (push) Waiting to run
Split internal/imgcache into the image proxy and the cache (closes #317)
The new package internal/imageproxy holds Proxy, which was imgcache.Service,
with its Config, ImageResponse, CacheStatus and errors. imageproxy.New makes
and registers the counters of upstream fetches and transforms.
internal/imgcache holds the cache only, with its hit and miss counters, and
imgcache.CacheConfig is imgcache.Config. The cache reads and writes each kind
it keeps with one pair of methods: Variant and PutVariant, Source and
PutSource, Failure and PutFailure. Source replaces LookupSource followed by
GetSourceContent. imgcache.go and module.go are gone. The proxy's tests moved
with it. The cache's tests check that the reconciliation pass removes an empty
or cut file and its row. The proxy's tests remove them by hand, then check
that Get fetches or converts again.

Model: opus-5-5
2026-10-26 18:51:01 +00:00

280 lines
8.7 KiB
Go

package imgcache
import (
"bytes"
"context"
"database/sql"
"errors"
"io"
"log/slog"
"strings"
"testing"
"time"
"sneak.berlin/go/pixa/internal/imagerequest"
)
// pendingAccessTimesWait is how long a test waits for the database's
// connection, for an access time to reach the database, for a write to start
// waiting for the connection, or for StopPendingAccessTimeWrites to return.
const pendingAccessTimesWait = 5 * time.Second
// otherWritesWait is how long a test gives other writes, once one write waits
// for the database's connection, to start waiting for it too.
const otherWritesWait = 100 * time.Millisecond
// 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()
ctx, cancel := context.WithTimeout(t.Context(), pendingAccessTimesWait)
defer cancel()
conn, err := cache.db.Conn(ctx)
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(pendingAccessTimesWait)
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)
}
}
// TestStopPendingAccessTimeWrites_LetsTheWriteUnderWayFinish holds the
// database's one connection while the goroutine that writes the pending
// access times waits for it, hands over another access time, then stops that
// goroutine. The stop must leave the write under way to finish rather than
// cut it off, and once the connection is free it must write the access time
// that write left.
func TestStopPendingAccessTimeWrites_LetsTheWriteUnderWayFinish(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
anHourAgo := time.Now().Add(-time.Hour)
for _, key := range []imagerequest.VariantKey{testVariantKeyOne, testVariantKeyTwo} {
storeEvictionTestVariant(t, cache, key, []byte("variant bytes"))
setVariantLastAccessed(t, cache, key, anHourAgo)
}
cache.StartPendingAccessTimeWrites()
defer func() { _ = cache.StopPendingAccessTimeWrites(t.Context()) }()
releaseDatabase := holdDatabase(t, cache)
defer releaseDatabase()
waitsBefore := cache.db.Stats().WaitCount
cache.touchVariant(testVariantKeyOne)
// The goroutine's write waits for the connection.
waitForConnectionWaits(t, cache.db, waitsBefore+1)
// Handed over after that write took the pending access times
cache.touchVariant(testVariantKeyTwo)
// The goroutine is told to finish here as well as by the stop, so that it
// is told before its write under way ends, however late the stop runs.
// The access time above then reaches the database only through the
// stop's own write.
cache.pendingAccessTimesCancel()
stopped := make(chan error, 1)
go func() {
stopped <- cache.StopPendingAccessTimeWrites(t.Context())
}()
// Had the stop cut off the write under way, its own write would wait for
// the connection too.
time.Sleep(otherWritesWait)
waits := 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 1: "+
"the stop cut off the write under way", waits)
}
releaseDatabase()
select {
case err := <-stopped:
if err != nil {
t.Fatalf("StopPendingAccessTimeWrites() error = %v", err)
}
case <-time.After(pendingAccessTimesWait):
t.Fatal("StopPendingAccessTimeWrites() did not return once the database was free")
}
for _, key := range []imagerequest.VariantKey{testVariantKeyOne, testVariantKeyTwo} {
if countRows(t, cache, `
SELECT COUNT(*) FROM variant_content
WHERE cache_key = ? AND last_accessed_at > ?
`, string(key), anHourAgo.UTC().Format(sqliteTimestampFormat)) != 1 {
t.Errorf("the access time of variant %s was not written", key)
}
}
}
// TestStopPendingAccessTimeWrites_ReturnsWhenItsContextEnds holds the
// database's one connection while the goroutine that writes the pending
// access times waits for it, then stops that goroutine with a context that
// ends first. The stop must return the context's error, and the write under
// way must still reach the database once the connection is free.
func TestStopPendingAccessTimeWrites_ReturnsWhenItsContextEnds(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
storeEvictionTestVariant(t, cache, testVariantKeyOne, []byte("variant bytes"))
anHourAgo := time.Now().Add(-time.Hour)
setVariantLastAccessed(t, cache, testVariantKeyOne, anHourAgo)
cache.StartPendingAccessTimeWrites()
defer func() { _ = cache.StopPendingAccessTimeWrites(t.Context()) }()
releaseDatabase := holdDatabase(t, cache)
defer releaseDatabase()
waitsBefore := cache.db.Stats().WaitCount
cache.touchVariant(testVariantKeyOne)
// The goroutine's write waits for the connection.
waitForConnectionWaits(t, cache.db, waitsBefore+1)
stopCtx, cancelStop := context.WithCancel(t.Context())
cancelStop()
stopped := make(chan error, 1)
go func() {
stopped <- cache.StopPendingAccessTimeWrites(stopCtx)
}()
select {
case err := <-stopped:
if !errors.Is(err, context.Canceled) {
t.Errorf("StopPendingAccessTimeWrites() error = %v, want %v",
err, context.Canceled)
}
case <-time.After(pendingAccessTimesWait):
t.Fatal("StopPendingAccessTimeWrites() did not return once its context ended")
}
releaseDatabase()
deadline := time.Now().Add(pendingAccessTimesWait)
for countRows(t, cache, `
SELECT COUNT(*) FROM variant_content
WHERE cache_key = ? AND last_accessed_at > ?
`, string(testVariantKeyOne), anHourAgo.UTC().Format(sqliteTimestampFormat)) == 0 {
if time.Now().After(deadline) {
t.Fatal("the access time being written was not written")
}
time.Sleep(10 * time.Millisecond)
}
}
// TestSource_HandsAccessTimeToTheBackgroundWriter finds a cached source
// whose access time is an hour old. Source must not write the access
// time itself but hand it over to be written, and StopPendingAccessTimeWrites
// must then write it.
func TestSource_HandsAccessTimeToTheBackgroundWriter(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
key := storeEvictionTestSource(t, cache, "example.com", "/photo.jpg",
[]byte("source bytes"))
anHourAgo := time.Now().Add(-time.Hour)
setSourceLastAccessed(t, cache, key, anHourAgo)
touchedQuery := `
SELECT COUNT(*) FROM source_content
WHERE source_key = ? AND last_accessed_at > ?
`
anHourAgoInDatabase := anHourAgo.UTC().Format(sqliteTimestampFormat)
reader, _, err := cache.Source(t.Context(),
imagerequest.Source{Host: "example.com", Path: "/photo.jpg"})
if err != nil {
t.Fatalf("Source() error = %v; want the source of %q", err, key)
}
found, err := io.ReadAll(reader)
_ = reader.Close()
if err != nil || string(found) != "source bytes" {
t.Fatalf("Source() reads %q, %v; want the source of %q", found, err, key)
}
if countRows(t, cache, touchedQuery, string(key), anHourAgoInDatabase) != 0 {
t.Error("Source wrote the source's access time itself")
}
if waiting := len(cache.pendingSourceAccessTimes); waiting != 1 {
t.Errorf("%d access times of sources wait to be written, want 1", waiting)
}
err = cache.StopPendingAccessTimeWrites(t.Context())
if err != nil {
t.Fatalf("StopPendingAccessTimeWrites() error = %v", err)
}
if countRows(t, cache, touchedQuery, string(key), anHourAgoInDatabase) != 1 {
t.Error("the source's access time was not written")
}
}
// TestStopPendingAccessTimeWrites_LogsWhatWasNotWritten makes the write of
// the access times fail at shutdown, by dropping the table they are written
// to. The stop must return an error and log that access times were not
// written.
func TestStopPendingAccessTimeWrites_LogsWhatWasNotWritten(t *testing.T) {
t.Parallel()
cache, _ := newEvictionTestCache(t, 1<<30)
var logs bytes.Buffer
cache.log = slog.New(slog.NewJSONHandler(&logs, nil))
cache.touchVariant(testVariantKeyOne)
_, err := cache.db.ExecContext(t.Context(), `DROP TABLE variant_content`)
if err != nil {
t.Fatalf("dropping variant_content: %v", err)
}
err = cache.StopPendingAccessTimeWrites(t.Context())
t.Logf("StopPendingAccessTimeWrites() error = %v; logged:\n%s", err, logs.String())
if err == nil {
t.Error("StopPendingAccessTimeWrites() error = nil, want the access times' error")
}
if !strings.Contains(logs.String(), "access times not written at shutdown") {
t.Error("the access times not written were not logged")
}
}