Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d571045d01 |
@@ -159,14 +159,14 @@ func TestCache_StoreAndLookup(t *testing.T) {
|
|||||||
Headers: map[string][]string{testHeaderContentType: {testContentTypeJPEG}},
|
Headers: map[string][]string{testHeaderContentType: {testContentTypeJPEG}},
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceKey, err := cache.StoreSource(
|
contentHash, err := cache.StoreSource(
|
||||||
ctx, req, bytes.NewReader(sourceContent), fetchResult)
|
ctx, req, bytes.NewReader(sourceContent), fetchResult)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreSource() error = %v", err)
|
t.Fatalf("StoreSource() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if sourceKey == "" {
|
if contentHash == "" {
|
||||||
t.Error("StoreSource() returned empty key")
|
t.Error("StoreSource() returned empty hash")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store variant
|
// Store variant
|
||||||
|
|||||||
@@ -155,9 +155,8 @@ func TestUsageTotalNotCorrectedFromAnOlderSum(t *testing.T) {
|
|||||||
// pass. No read the pass makes of a content table, to check its rows or
|
// pass. No read the pass makes of a content table, to check its rows or
|
||||||
// to sum them, may cover more than two rows, so a request's query waits
|
// to sum them, may cover more than two rows, so a request's query waits
|
||||||
// for one page at most. Between them the reads must still cover every
|
// for one page at most. Between them the reads must still cover every
|
||||||
// variant row twice, once to check it and once to sum it, and every
|
// row of both tables twice, once to check it and once to sum it, and the
|
||||||
// source row once, as source rows are read only to sum them, and the sum
|
// sum must put a wrong total right.
|
||||||
// must put a wrong total right.
|
|
||||||
func TestReconciliationReadsAPageAtATime(t *testing.T) {
|
func TestReconciliationReadsAPageAtATime(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -208,10 +207,9 @@ func TestReconciliationReadsAPageAtATime(t *testing.T) {
|
|||||||
coveredRows += rows
|
coveredRows += rows
|
||||||
}
|
}
|
||||||
|
|
||||||
// Five variant rows, each read once to check it and once to sum it,
|
// Ten rows, each read once to check it and once to sum it.
|
||||||
// and five source rows, read only to sum them.
|
if coveredRows != 20 {
|
||||||
if coveredRows != 15 {
|
t.Errorf("the reads covered %d rows in all, want 20", coveredRows)
|
||||||
t.Errorf("the reads covered %d rows in all, want 15", coveredRows)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
usage, err := cache.UsageBytes(ctx)
|
usage, err := cache.UsageBytes(ctx)
|
||||||
|
|||||||
@@ -487,12 +487,12 @@ func (c *Cache) runCleanExpired(ctx context.Context) {
|
|||||||
// as long after the last pass ended as that pass took, off the request
|
// as long after the last pass ended as that pass took, off the request
|
||||||
// hot path: it adopts variant files that predate the accounting table
|
// hot path: it adopts variant files that predate the accounting table
|
||||||
// (or whose accounting insert failed, e.g. StoreVariant's best-effort
|
// (or whose accounting insert failed, e.g. StoreVariant's best-effort
|
||||||
// insert under transient DB contention), drops variant accounting rows
|
// insert under transient DB contention), drops accounting rows whose
|
||||||
// whose files are missing, removes source blob files the database does
|
// files are missing, removes source blob files the database does not
|
||||||
// not know, removes source and variant files that are empty or not the
|
// know (and rows whose files are gone), removes source and variant files
|
||||||
// size their rows record, sweeps stale temp files left behind by crashed
|
// that are empty or not the size their rows record, sweeps stale temp
|
||||||
// writes, and last checks the total cache usage against the tables. It
|
// files left behind by crashed writes, and last checks the total cache
|
||||||
// reads the tables a page at a time.
|
// usage against the tables. It reads the tables a page at a time.
|
||||||
// Running it periodically, not just once, bounds how long such drift can
|
// Running it periodically, not just once, bounds how long such drift can
|
||||||
// accumulate unaccounted for on a long-running process to about one
|
// accumulate unaccounted for on a long-running process to about one
|
||||||
// eviction interval plus ten times the last pass's time.
|
// eviction interval plus ten times the last pass's time.
|
||||||
@@ -518,6 +518,11 @@ func (c *Cache) reconcileAccounting(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = c.reconcileSourceRows(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return c.reconcileUsageTotal(ctx)
|
return c.reconcileUsageTotal(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -795,6 +800,59 @@ func (c *Cache) removeUntrackedSourceFile(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reconcileSourceRows removes source_content rows whose blob files are
|
||||||
|
// missing on disk.
|
||||||
|
func (c *Cache) reconcileSourceRows(ctx context.Context) error {
|
||||||
|
var after imagerequest.SourceKey
|
||||||
|
|
||||||
|
for {
|
||||||
|
keys, err := c.sourceKeysAfter(ctx, after)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.reconciliationReadTestHook != nil {
|
||||||
|
c.reconciliationReadTestHook(len(keys))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.srcContent.Exists(key) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// The blob file is already gone; evictSourceBlob removes the
|
||||||
|
// row and tolerates the missing file.
|
||||||
|
err := c.evictSourceBlob(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.log.Info("dropped row for missing source content file", "source_key", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
after = keys[len(keys)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceKeysAfter returns, in order, up to c.reconciliationPageSize
|
||||||
|
// tracked source keys that sort after the given one.
|
||||||
|
func (c *Cache) sourceKeysAfter(
|
||||||
|
ctx context.Context, after imagerequest.SourceKey,
|
||||||
|
) ([]imagerequest.SourceKey, error) {
|
||||||
|
return queryStringColumn[imagerequest.SourceKey](ctx, c.db, `
|
||||||
|
SELECT source_key FROM source_content
|
||||||
|
WHERE source_key > ? ORDER BY source_key LIMIT ?
|
||||||
|
`, "source keys", "source key", string(after), c.reconciliationPageSize)
|
||||||
|
}
|
||||||
|
|
||||||
// Each of these queries sums size_bytes over the next page of rows of
|
// Each of these queries sums size_bytes over the next page of rows of
|
||||||
// one content table, the rows that sort after a key, and returns the
|
// one content table, the rows that sort after a key, and returns the
|
||||||
// page's last key, the sum and the number of rows in the page. Past the
|
// page's last key, the sum and the number of rows in the page. Past the
|
||||||
|
|||||||
@@ -10,11 +10,10 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
"sneak.berlin/go/pixa/internal/database"
|
"sneak.berlin/go/pixa/internal/database"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||||
@@ -111,14 +110,14 @@ func storeEvictionTestSource(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
key, err := cache.StoreSource(
|
hash, err := cache.StoreSource(
|
||||||
context.Background(), req, bytes.NewReader(content), result,
|
context.Background(), req, bytes.NewReader(content), result,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreSource(%s%s) failed: %v", host, path, err)
|
t.Fatalf("StoreSource(%s%s) failed: %v", host, path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return key
|
return hash
|
||||||
}
|
}
|
||||||
|
|
||||||
// storeEvictionTestVariant stores content as a processed variant under
|
// storeEvictionTestVariant stores content as a processed variant under
|
||||||
@@ -163,13 +162,13 @@ func setVariantLastAccessed(
|
|||||||
// setSourceLastAccessed backdates the last access time of a tracked
|
// setSourceLastAccessed backdates the last access time of a tracked
|
||||||
// source content blob.
|
// source content blob.
|
||||||
func setSourceLastAccessed(
|
func setSourceLastAccessed(
|
||||||
t *testing.T, cache *Cache, key imagerequest.SourceKey, when time.Time,
|
t *testing.T, cache *Cache, hash imagerequest.SourceKey, when time.Time,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
res, err := cache.db.ExecContext(t.Context(),
|
res, err := cache.db.ExecContext(t.Context(),
|
||||||
`UPDATE source_content SET last_accessed_at = ? WHERE source_key = ?`,
|
`UPDATE source_content SET last_accessed_at = ? WHERE source_key = ?`,
|
||||||
when.UTC().Format(sqliteTimestampFormat), string(key),
|
when.UTC().Format(sqliteTimestampFormat), string(hash),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to set source last_accessed_at: %v", err)
|
t.Fatalf("failed to set source last_accessed_at: %v", err)
|
||||||
@@ -181,7 +180,7 @@ func setSourceLastAccessed(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if affected != 1 {
|
if affected != 1 {
|
||||||
t.Fatalf("source %s has no accounting row (affected=%d)", key, affected)
|
t.Fatalf("source %s has no accounting row (affected=%d)", hash, affected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,16 +214,16 @@ func assertNoDanglingReferences(t *testing.T, cache *Cache) {
|
|||||||
defer func() { _ = rows.Close() }()
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var key string
|
var hash string
|
||||||
|
|
||||||
err := rows.Scan(&key)
|
err := rows.Scan(&hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to scan source_key: %v", err)
|
t.Fatalf("failed to scan source_key: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !cache.srcContent.Exists(imagerequest.SourceKey(key)) {
|
if !cache.srcContent.Exists(imagerequest.SourceKey(hash)) {
|
||||||
t.Errorf("source_content references content %s but the file is missing",
|
t.Errorf("source_content references content %s but the file is missing",
|
||||||
key)
|
hash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,44 +365,6 @@ func TestEvictToLimitEvictsLeastRecentlyUsedFirst(t *testing.T) {
|
|||||||
assertNoDanglingReferences(t, cache)
|
assertNoDanglingReferences(t, cache)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEvictionKeepsEverythingWhenUnderLimit(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
|
||||||
|
|
||||||
content := bytes.Repeat([]byte{0xDF}, 800)
|
|
||||||
keys := []imagerequest.SourceKey{
|
|
||||||
storeEvictionTestSource(t, cache, "src.example.com", "/a.jpg", content),
|
|
||||||
storeEvictionTestSource(t, cache, "src.example.com", "/b.jpg", content),
|
|
||||||
}
|
|
||||||
|
|
||||||
storeEvictionTestVariant(t, cache, testVariantKeyOne,
|
|
||||||
bytes.Repeat([]byte{0xE0}, 500))
|
|
||||||
|
|
||||||
err := cache.EvictToLimit(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("EvictToLimit failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, key := range keys {
|
|
||||||
if !cache.srcContent.Exists(key) {
|
|
||||||
t.Errorf("source %s must not be evicted while usage is under the limit", key)
|
|
||||||
}
|
|
||||||
|
|
||||||
if n := countRows(t, cache,
|
|
||||||
`SELECT COUNT(*) FROM source_content WHERE source_key = ?`, string(key),
|
|
||||||
); n != 1 {
|
|
||||||
t.Errorf("source %s has %d source_content rows, want 1", key, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !cache.variants.Exists(testVariantKeyOne) {
|
|
||||||
t.Error("variant must not be evicted while usage is under the limit")
|
|
||||||
}
|
|
||||||
|
|
||||||
assertNoDanglingReferences(t, cache)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestZeroMaxBytesDisablesDiskCache(t *testing.T) {
|
func TestZeroMaxBytesDisablesDiskCache(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -450,13 +411,13 @@ func assertDisabledCacheWritesAreNoOps(
|
|||||||
Headers: map[string][]string{},
|
Headers: map[string][]string{},
|
||||||
}
|
}
|
||||||
|
|
||||||
key, err := cache.StoreSource(ctx, req, bytes.NewReader([]byte("data")), result)
|
hash, err := cache.StoreSource(ctx, req, bytes.NewReader([]byte("data")), result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("StoreSource on disabled cache must be a no-op, got error: %v", err)
|
t.Fatalf("StoreSource on disabled cache must be a no-op, got error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if key != "" {
|
if hash != "" {
|
||||||
t.Errorf("StoreSource on disabled cache returned key %q, want empty", key)
|
t.Errorf("StoreSource on disabled cache returned hash %q, want empty", hash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,14 +439,14 @@ func assertDisabledCacheReadsAlwaysMiss(
|
|||||||
t.Error("Lookup on disabled cache must always miss")
|
t.Error("Lookup on disabled cache must always miss")
|
||||||
}
|
}
|
||||||
|
|
||||||
key, srcType, err := cache.LookupSource(ctx, req)
|
srcHash, srcType, err := cache.LookupSource(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LookupSource on disabled cache failed: %v", err)
|
t.Fatalf("LookupSource on disabled cache failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if key != "" || srcType != "" {
|
if srcHash != "" || srcType != "" {
|
||||||
t.Errorf("LookupSource on disabled cache = (%q, %q), want empty",
|
t.Errorf("LookupSource on disabled cache = (%q, %q), want empty",
|
||||||
key, srcType)
|
srcHash, srcType)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -947,202 +908,6 @@ func TestStopEvictionInterruptsPassInProgress(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEvictToLimitStopsAtNextCandidateOnceCancelled cancels the context
|
|
||||||
// while the oldest of three sources is being evicted, and checks that
|
|
||||||
// EvictToLimit then returns context.Canceled, with that source gone and
|
|
||||||
// the other two kept, and logs no warning. The test database has one
|
|
||||||
// connection. When the deletion of the oldest source's row commits, a
|
|
||||||
// commit hook holds the deletion until the test waits for the
|
|
||||||
// connection, so the test takes it as soon as the deletion is done. The
|
|
||||||
// eviction of the next source then waits for the connection, and the
|
|
||||||
// test cancels the context while it waits.
|
|
||||||
func TestEvictToLimitStopsAtNextCandidateOnceCancelled(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1)
|
|
||||||
|
|
||||||
var logBuf bytes.Buffer
|
|
||||||
|
|
||||||
cache.log = slog.New(slog.NewJSONHandler(&logBuf, nil))
|
|
||||||
|
|
||||||
keys := []imagerequest.SourceKey{
|
|
||||||
storeEvictionTestSource(t, cache, "cancel.example.com", "/a.jpg",
|
|
||||||
bytes.Repeat([]byte{0x61}, 1000)),
|
|
||||||
storeEvictionTestSource(t, cache, "cancel.example.com", "/b.jpg",
|
|
||||||
bytes.Repeat([]byte{0x62}, 1000)),
|
|
||||||
storeEvictionTestSource(t, cache, "cancel.example.com", "/c.jpg",
|
|
||||||
bytes.Repeat([]byte{0x63}, 1000)),
|
|
||||||
}
|
|
||||||
|
|
||||||
base := time.Now().Add(-time.Hour)
|
|
||||||
|
|
||||||
for i, key := range keys {
|
|
||||||
setSourceLastAccessed(t, cache, key, base.Add(time.Duration(i)*time.Minute))
|
|
||||||
}
|
|
||||||
|
|
||||||
waitsBefore := cache.db.Stats().WaitCount
|
|
||||||
oldestRowDeleted := make(chan struct{})
|
|
||||||
|
|
||||||
var oldestRowDeletedOnce sync.Once
|
|
||||||
|
|
||||||
setCommitHook(t, cache, func() int32 {
|
|
||||||
oldestRowDeletedOnce.Do(func() {
|
|
||||||
close(oldestRowDeleted)
|
|
||||||
|
|
||||||
deadline := time.Now().Add(5 * time.Second)
|
|
||||||
|
|
||||||
for cache.db.Stats().WaitCount == waitsBefore && time.Now().Before(deadline) {
|
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
evicted := make(chan error, 1)
|
|
||||||
|
|
||||||
go func() { evicted <- cache.EvictToLimit(ctx) }()
|
|
||||||
|
|
||||||
<-oldestRowDeleted
|
|
||||||
|
|
||||||
conn, err := cache.db.Conn(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to take the database connection: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = conn.Close() }()
|
|
||||||
|
|
||||||
waitForConnectionWaits(t, cache.db, waitsBefore+2)
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
err = <-evicted
|
|
||||||
t.Logf("EvictToLimit() error = %v", err)
|
|
||||||
t.Logf("log output: %s", logBuf.String())
|
|
||||||
|
|
||||||
// The checks below read the database, so the connection goes back.
|
|
||||||
_ = conn.Close()
|
|
||||||
|
|
||||||
setCommitHook(t, cache, nil)
|
|
||||||
|
|
||||||
if !errors.Is(err, context.Canceled) {
|
|
||||||
t.Errorf("EvictToLimit() error = %v, want context.Canceled", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertOnlyOldestSourceEvicted(t, cache, keys)
|
|
||||||
|
|
||||||
if strings.Contains(logBuf.String(), `"level":"WARN"`) {
|
|
||||||
t.Errorf("EvictToLimit logged a warning after the context was cancelled")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// setCommitHook makes the SQLite driver call hook each time a write on
|
|
||||||
// the test database's one connection commits, or stops it when hook is
|
|
||||||
// nil.
|
|
||||||
func setCommitHook(t *testing.T, cache *Cache, hook sqlite.CommitHookFn) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
conn, err := cache.db.Conn(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to take the database connection: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = conn.Close() }()
|
|
||||||
|
|
||||||
err = conn.Raw(func(driverConn any) error {
|
|
||||||
hooks, ok := driverConn.(sqlite.HookRegisterer)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("the database connection is a %T, which takes no hooks",
|
|
||||||
driverConn)
|
|
||||||
}
|
|
||||||
|
|
||||||
hooks.RegisterCommitHook(hook)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to set the commit hook: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertOnlyOldestSourceEvicted checks that the first of keys has neither
|
|
||||||
// its file nor its row, and that every other one has both.
|
|
||||||
func assertOnlyOldestSourceEvicted(
|
|
||||||
t *testing.T, cache *Cache, keys []imagerequest.SourceKey,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
if cache.srcContent.Exists(keys[0]) {
|
|
||||||
t.Errorf("source %s, evicted when the context was cancelled, "+
|
|
||||||
"is still on disk", keys[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, key := range keys[1:] {
|
|
||||||
if !cache.srcContent.Exists(key) {
|
|
||||||
t.Errorf("source %s was evicted after the context was cancelled", key)
|
|
||||||
}
|
|
||||||
|
|
||||||
if n := countRows(t, cache,
|
|
||||||
`SELECT COUNT(*) FROM source_content WHERE source_key = ?`, string(key),
|
|
||||||
); n != 1 {
|
|
||||||
t.Errorf("source %s, kept, has %d source_content rows, want 1", key, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assertNoDanglingReferences(t, cache)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStopEvictionReturnsWhenItsContextEnds pauses the startup
|
|
||||||
// reconciliation pass in the hook called after each of its reads, where
|
|
||||||
// cancellation cannot reach it, and checks that StopEviction returns an
|
|
||||||
// error wrapping its context's error when that context ends, instead of
|
|
||||||
// waiting for the pass. Once the pass goes on, the goroutine exits, and a
|
|
||||||
// second StopEviction returns nil.
|
|
||||||
func TestStopEvictionReturnsWhenItsContextEnds(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
|
||||||
|
|
||||||
paused := make(chan struct{})
|
|
||||||
resume := make(chan struct{})
|
|
||||||
|
|
||||||
var pauseOnce sync.Once
|
|
||||||
|
|
||||||
cache.reconciliationReadTestHook = func(int) {
|
|
||||||
pauseOnce.Do(func() {
|
|
||||||
close(paused)
|
|
||||||
<-resume
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
cache.StartEviction(time.Hour)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-paused:
|
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
t.Fatal("the reconciliation pass never made a read")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
err := cache.StopEviction(ctx)
|
|
||||||
t.Logf("StopEviction() error = %v", err)
|
|
||||||
|
|
||||||
if !errors.Is(err, context.DeadlineExceeded) {
|
|
||||||
t.Errorf("StopEviction() error = %v, want context.DeadlineExceeded", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
close(resume)
|
|
||||||
|
|
||||||
err = cache.StopEviction(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("second StopEviction() error = %v, want nil", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestReconciliationWalksStopOnceCancelled checks that both directory
|
// TestReconciliationWalksStopOnceCancelled checks that both directory
|
||||||
// walks of a reconciliation pass return the context's error once it is
|
// walks of a reconciliation pass return the context's error once it is
|
||||||
// cancelled, leaving in place a stale temp file they would otherwise
|
// cancelled, leaving in place a stale temp file they would otherwise
|
||||||
|
|||||||
@@ -77,11 +77,11 @@ func TestService_Get_WaitsForSlotBeforeReadingCachedSource(t *testing.T) {
|
|||||||
|
|
||||||
_ = resp.Content.Close()
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
sourceKey, _, err := svc.cache.LookupSource(t.Context(),
|
contentHash, _, err := svc.cache.LookupSource(t.Context(),
|
||||||
widthOnlyRequest(fixtures, 50))
|
widthOnlyRequest(fixtures, 50))
|
||||||
if err != nil || sourceKey == "" {
|
if err != nil || contentHash == "" {
|
||||||
t.Fatalf("LookupSource() = %q, %v; want the cached source",
|
t.Fatalf("LookupSource() = %q, %v; want the cached source",
|
||||||
sourceKey, err)
|
contentHash, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
release := holdProcessingSlot(t, svc.processor)
|
release := holdProcessingSlot(t, svc.processor)
|
||||||
@@ -103,7 +103,7 @@ func TestService_Get_WaitsForSlotBeforeReadingCachedSource(t *testing.T) {
|
|||||||
// before waiting, it would have read it by now.
|
// before waiting, it would have read it by now.
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
err = os.WriteFile(svc.cache.srcContent.hashToPath(sourceKey),
|
err = os.WriteFile(svc.cache.srcContent.hashToPath(contentHash),
|
||||||
generateTestJPEG(t, 100, 50, color.RGBA{0, 0, 255, 255}), 0o600)
|
generateTestJPEG(t, 100, 50, color.RGBA{0, 0, 255, 255}), 0o600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to rewrite the cached source: %v", err)
|
t.Fatalf("failed to rewrite the cached source: %v", err)
|
||||||
|
|||||||
@@ -147,13 +147,13 @@ func TestService_Get_ServesCachedVariantOfNegativeCachedURL(t *testing.T) {
|
|||||||
|
|
||||||
_ = resp.Content.Close()
|
_ = resp.Content.Close()
|
||||||
|
|
||||||
sourceKey, _, err := svc.cache.LookupSource(t.Context(),
|
contentHash, _, err := svc.cache.LookupSource(t.Context(),
|
||||||
photoVariant(fixtures, 85, imagerequest.FitCover))
|
photoVariant(fixtures, 85, imagerequest.FitCover))
|
||||||
if err != nil || sourceKey == "" {
|
if err != nil || contentHash == "" {
|
||||||
t.Fatalf("LookupSource() = %q, %v, want the cached source", sourceKey, err)
|
t.Fatalf("LookupSource() = %q, %v, want the cached source", contentHash, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = svc.cache.evictSourceBlob(t.Context(), sourceKey)
|
err = svc.cache.evictSourceBlob(t.Context(), contentHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("evictSourceBlob() error = %v", err)
|
t.Fatalf("evictSourceBlob() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package imgcache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@@ -65,12 +67,12 @@ func cachedSourcePath(
|
|||||||
) string {
|
) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
sourceKey, _, err := svc.cache.LookupSource(t.Context(), req)
|
contentHash, _, err := svc.cache.LookupSource(t.Context(), req)
|
||||||
if err != nil || sourceKey == "" {
|
if err != nil || contentHash == "" {
|
||||||
t.Fatalf("LookupSource() = %q, %v; want the cached source", sourceKey, err)
|
t.Fatalf("LookupSource() = %q, %v; want the cached source", contentHash, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return svc.cache.srcContent.hashToPath(sourceKey)
|
return svc.cache.srcContent.hashToPath(contentHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconcileAsAtStartup runs the cache's reconciliation pass, as pixa does
|
// reconcileAsAtStartup runs the cache's reconciliation pass, as pixa does
|
||||||
@@ -315,7 +317,7 @@ func TestVariantStorage_StoreReplacesMetaFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestContentStorage_StoreReplacesFileOfAnotherSize checks that a
|
// TestContentStorage_StoreReplacesFileOfAnotherSize checks that a
|
||||||
// store over a file of another size at the source's path, such as a
|
// store over a file of another size at the content's path, such as a
|
||||||
// partial file a crash left, replaces it with the content.
|
// partial file a crash left, replaces it with the content.
|
||||||
func TestContentStorage_StoreReplacesFileOfAnotherSize(t *testing.T) {
|
func TestContentStorage_StoreReplacesFileOfAnotherSize(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -326,12 +328,13 @@ func TestContentStorage_StoreReplacesFileOfAnotherSize(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
content := []byte("the whole source image")
|
content := []byte("the whole source image")
|
||||||
key := imagerequest.Source{Host: testHostCDN, Path: testPathCat}.Key()
|
sum := sha256.Sum256(content)
|
||||||
path := storage.hashToPath(key)
|
hash := imagerequest.SourceKey(hex.EncodeToString(sum[:]))
|
||||||
|
path := storage.hashToPath(hash)
|
||||||
|
|
||||||
err = os.MkdirAll(filepath.Dir(path), StorageDirPerm)
|
err = os.MkdirAll(filepath.Dir(path), StorageDirPerm)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("creating the source's directory: %v", err)
|
t.Fatalf("creating the content's directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = os.WriteFile(path, content[:5], StorageFilePerm)
|
err = os.WriteFile(path, content[:5], StorageFilePerm)
|
||||||
@@ -339,12 +342,12 @@ func TestContentStorage_StoreReplacesFileOfAnotherSize(t *testing.T) {
|
|||||||
t.Fatalf("writing a partial file: %v", err)
|
t.Fatalf("writing a partial file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = storage.Store(key, content)
|
_, err = storage.Store(hash, content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Store() error = %v", err)
|
t.Fatalf("Store() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stored, err := storage.Load(key)
|
stored, err := storage.Load(hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load() error = %v", err)
|
t.Fatalf("Load() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import (
|
|||||||
"sneak.berlin/go/pixa/internal/handlers"
|
"sneak.berlin/go/pixa/internal/handlers"
|
||||||
"sneak.berlin/go/pixa/internal/healthcheck"
|
"sneak.berlin/go/pixa/internal/healthcheck"
|
||||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
|
||||||
"sneak.berlin/go/pixa/internal/logger"
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
"sneak.berlin/go/pixa/internal/middleware"
|
"sneak.berlin/go/pixa/internal/middleware"
|
||||||
)
|
)
|
||||||
@@ -42,8 +41,8 @@ const upstreamHost = "192.0.2.10"
|
|||||||
// upstream fetcher, image processor, disk cache and database, with only the
|
// upstream fetcher, image processor, disk cache and database, with only the
|
||||||
// upstream origin replaced by a local test server. The first request for a URL
|
// upstream origin replaced by a local test server. The first request for a URL
|
||||||
// is fetched and converted; the second is served from the cache without
|
// is fetched and converted; the second is served from the cache without
|
||||||
// another upstream request. The source and the converted image are then on
|
// another upstream request. The converted image is then on disk, with its row
|
||||||
// disk, with their rows in the database.
|
// in the database.
|
||||||
func TestImageProxyFlow(t *testing.T) {
|
func TestImageProxyFlow(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -112,7 +111,6 @@ func TestImageProxyFlow(t *testing.T) {
|
|||||||
t.Errorf("upstream received %d requests, want 1", got)
|
t.Errorf("upstream received %d requests, want 1", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
checkSourceCached(t, db, stateDir, source)
|
|
||||||
checkVariantCached(t, db, stateDir, first.Body.Bytes(), tt.contentType)
|
checkVariantCached(t, db, stateDir, first.Body.Bytes(), tt.contentType)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -253,28 +251,6 @@ func getImage(t *testing.T, s *Server, target string) *httptest.ResponseRecorder
|
|||||||
return rec
|
return rec
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkSourceCached checks that source is stored in cache/sources under the
|
|
||||||
// source key of upstreamHost and /photo.png, and recorded under that key in
|
|
||||||
// source_content.
|
|
||||||
func checkSourceCached(t *testing.T, db *sql.DB, stateDir string, source []byte) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
key := string(imagerequest.Source{Host: upstreamHost, Path: "/photo.png"}.Key())
|
|
||||||
|
|
||||||
checkFile(t, filepath.Join(stateDir, "cache", "sources", key[0:2], key[2:4], key),
|
|
||||||
source)
|
|
||||||
|
|
||||||
var rows int
|
|
||||||
|
|
||||||
err := db.QueryRowContext(t.Context(),
|
|
||||||
"SELECT COUNT(*) FROM source_content WHERE source_key = ?", key,
|
|
||||||
).Scan(&rows)
|
|
||||||
if err != nil || rows != 1 {
|
|
||||||
t.Errorf("source_content rows for the source = %d (error %v), want 1",
|
|
||||||
rows, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkVariantCached checks that the converted image served is recorded in
|
// checkVariantCached checks that the converted image served is recorded in
|
||||||
// variant_content with contentType, and stored under its cache key in
|
// variant_content with contentType, and stored under its cache key in
|
||||||
// cache/variants.
|
// cache/variants.
|
||||||
|
|||||||
Reference in New Issue
Block a user