check / check (push) Canceled after 0s
magic.DetectFormat returns an imagerequest.Format. ValidateMagicBytes returns the format it checked against the upstream Content-Type. The cache stores that format in the source's row, so a variant made from a cached source uses it too. The proxy hands it to Process, which still refuses input libvips would load with another loader, such as an SVG passing as AVIF. Format.MIMEType declares each MIME string once, and the fetcher, the cache's variant read and the Accept matching use it. magic.MIMEType, magic.ImageFormat, MIMEToImageFormat, magic's SVG detection, detectFormat, formatFromString, FormatToMIME and Result.InputFormat are gone. Model: opus-5-5
280 lines
8.7 KiB
Go
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")
|
|
}
|
|
}
|