From 22c0a50fdc8dc4b0fe211e92cea338ece7706065 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 15 Oct 2026 02:11:52 +0000 Subject: [PATCH 1/6] Test that the eviction loop removes expired negative cache rows A failed fetch stored in the negative cache must be removed by the eviction loop once it has expired, although its URL is never requested again, with the disk cache enabled and disabled; a row that has not expired stays. Fails until the fix for https://git.eeqj.de/sneak/pixa/issues/244 lands. Model: opus-5-5 --- .../imgcache/negative_cache_internal_test.go | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/internal/imgcache/negative_cache_internal_test.go b/internal/imgcache/negative_cache_internal_test.go index ffb6079..de00cc5 100644 --- a/internal/imgcache/negative_cache_internal_test.go +++ b/internal/imgcache/negative_cache_internal_test.go @@ -159,6 +159,97 @@ func TestService_Get_ServesCachedVariantOfNegativeCachedURL(t *testing.T) { } } +// TestEvictionLoopRemovesExpiredNegativeCacheRows stores a failed fetch +// that expires a second later and starts the eviction loop with a short +// interval: a later tick must remove its row although its URL is never +// requested again, and must keep a row that has not expired. It runs +// with the disk cache enabled and disabled, as the negative cache is +// used either way. +func TestEvictionLoopRemovesExpiredNegativeCacheRows(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + disableDiskCache bool + }{ + {name: "disk cache enabled", disableDiskCache: false}, + {name: "disk cache disabled", disableDiskCache: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cache, err := NewCache(evictionTestDB(t), CacheConfig{ + StateDir: t.TempDir(), + NegativeTTL: time.Second, + DisableDiskCache: tt.disableDiskCache, + }) + if err != nil { + t.Fatalf("failed to create cache: %v", err) + } + + expired := &ImageRequest{ + SourceHost: testHostExample, SourcePath: "/gone.jpg", + } + + err = cache.StoreNegative(t.Context(), expired, 404, "not found") + if err != nil { + t.Fatalf("StoreNegative failed: %v", err) + } + + const notExpiredPath = "/later.jpg" + + _, err = cache.db.ExecContext(t.Context(), ` + INSERT INTO negative_cache + (source_host, source_path, status_code, expires_at) + VALUES (?, ?, 404, datetime('now', '+1 hour')) + `, testHostExample, notExpiredPath) + if err != nil { + t.Fatalf("failed to insert a row that has not expired: %v", err) + } + + started := time.Now() + + cache.StartEviction(100 * time.Millisecond) + defer func() { _ = cache.StopEviction(t.Context()) }() + + for negativeCacheRowCount(t, cache, expired.SourcePath) > 0 { + if time.Since(started) > 10*time.Second { + t.Fatal("the expired row is still in negative_cache") + } + + time.Sleep(50 * time.Millisecond) + } + + t.Logf("the expired row was removed %s after the loop started", + time.Since(started)) + + if negativeCacheRowCount(t, cache, notExpiredPath) != 1 { + t.Error("the row that has not expired was removed") + } + }) + } +} + +// negativeCacheRowCount returns how many negative_cache rows hold +// sourcePath. +func negativeCacheRowCount(tb testing.TB, cache *Cache, sourcePath string) int { + tb.Helper() + + var count int + + err := cache.db.QueryRowContext(tb.Context(), + `SELECT COUNT(*) FROM negative_cache WHERE source_path = ?`, + sourcePath, + ).Scan(&count) + if err != nil { + tb.Fatalf("failed to count negative_cache rows: %v", err) + } + + return count +} + func TestService_Get_ReturnsErrorForNegativeCachedURL(t *testing.T) { t.Parallel() -- 2.54.0 From 47246d1d1cbff955faadeade0c8ed336ed79f197 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 15 Oct 2026 02:18:13 +0000 Subject: [PATCH 2/6] Remove expired negative cache rows in the eviction loop (closes #244) A negative cache row was deleted only when its source URL was requested again after it expired, and CleanExpired was never called, so a client asking for a new missing path each time grew the database without limit. The eviction goroutine now calls CleanExpired at startup and on every periodic tick, so a row stays at most NegativeTTL plus one interval. The goroutine also starts with the disk cache disabled, where reconciliation and eviction already do nothing, since the negative cache is used either way. Model: opus-5-5 --- TODO.md | 9 +++++++++ internal/handlers/handlers.go | 3 ++- internal/imgcache/cache.go | 10 +++++++--- internal/imgcache/eviction.go | 32 +++++++++++++++++++++++++------- 4 files changed, 43 insertions(+), 11 deletions(-) diff --git a/TODO.md b/TODO.md index 712a369..eb5461a 100644 --- a/TODO.md +++ b/TODO.md @@ -76,6 +76,15 @@ P2: security: per-origin rate limiting answered 500. Once the limit is used up, the fetcher now reads one more byte: if the body has ended it is accepted, and if there is more it is refused with `ErrResponseTooLarge`, as before. +- 2026-10-15 expired negative cache rows are removed on a schedule (closes + #244): the row of a failed fetch, which makes pixa answer 502 for that source + URL for 5 minutes, was deleted only when the same URL was requested again + after that, so a client asking for a new missing path each time added rows + that stayed for good. The eviction goroutine now calls `CleanExpired` at + startup and on each periodic tick, every 5 minutes in pixad, so a row stays at + most about 10 minutes. It runs with the disk cache disabled + (`cache_max_bytes: 0`) too, where it does nothing else, as the negative cache + is used either way. - 2026-10-13 `fit=outside` no longer makes an output above 8192 on a side (closes #257): it scales the image to cover the requested size, and the side scaled past that size was held to no maximum, so `1x8192?fit=outside` of a diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 297ba2d..da9572f 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -124,7 +124,8 @@ func (s *Handlers) initImageService() error { s.imgCache = cache // Background eviction: startup reconciliation, then periodic and - // write-pressure passes. No-op when the disk cache is disabled. + // write-pressure passes, and the removal of expired negative cache + // rows, which is all it does when the disk cache is disabled. cache.StartEviction(imgcache.DefaultEvictionInterval) // Writes the counts requests could not write by their deadline diff --git a/internal/imgcache/cache.go b/internal/imgcache/cache.go index 1f73019..f5e4e47 100644 --- a/internal/imgcache/cache.go +++ b/internal/imgcache/cache.go @@ -50,8 +50,9 @@ type CacheConfig struct { // DisableDiskCache turns the disk cache off entirely: no cache // directories are created, lookups always miss, stores are - // no-ops, and no eviction machinery runs. The config layer sets - // this when the operator configures cache_max_bytes: 0. + // no-ops, and the eviction goroutine only removes the expired + // negative cache rows. The config layer sets this when the + // operator configures cache_max_bytes: 0. DisableDiskCache bool // Logger receives accounting and eviction log output. A nil @@ -482,7 +483,10 @@ func (c *Cache) GetSourceContent( return c.srcContent.LoadWithSize(contentHash) } -// CleanExpired removes expired entries from the cache. +// CleanExpired removes the negative cache rows past their expiry. The +// eviction goroutine calls it at startup and on every periodic tick, so +// a row stays at most NegativeTTL plus one eviction interval, whether or +// not its URL is requested again. func (c *Cache) CleanExpired(ctx context.Context) error { // Clean expired negative cache entries _, err := c.db.ExecContext(ctx, ` diff --git a/internal/imgcache/eviction.go b/internal/imgcache/eviction.go index a5aff55..71f1fb0 100644 --- a/internal/imgcache/eviction.go +++ b/internal/imgcache/eviction.go @@ -437,14 +437,16 @@ func (c *Cache) notifyWritePressure() { } // StartEviction launches the background eviction goroutine, which -// reconciles the database accounting with the cache directories at -// startup and again on every periodic tick thereafter, and evicts to -// the configured limit on the given periodic interval and on -// write-pressure notifications. It is a no-op on a disabled cache or -// when already started. The goroutine outlives the caller, so it runs -// with its own context, which StopEviction cancels. +// reconciles the database accounting with the cache directories and +// removes the expired negative cache rows at startup and again on every +// periodic tick thereafter, and evicts to the configured limit on the +// given periodic interval and on write-pressure notifications. On a +// disabled cache it only removes the expired negative cache rows, as +// the negative cache is used either way. It is a no-op when already +// started. The goroutine outlives the caller, so it runs with its own +// context, which StopEviction cancels. func (c *Cache) StartEviction(interval time.Duration) { - if c.disabled || c.evictionCancel != nil { + if c.evictionCancel != nil { return } @@ -480,6 +482,7 @@ func (c *Cache) evictionLoop(ctx context.Context, interval time.Duration) { defer close(c.evictionDone) c.runReconciliationPass(ctx) + c.runCleanExpired(ctx) c.runEvictionPass(ctx) ticker := time.NewTicker(interval) @@ -500,6 +503,7 @@ func (c *Cache) evictionLoop(ctx context.Context, interval time.Duration) { // unaccounted for to one interval, on a process that is // already running this loop regardless. c.runReconciliationPass(ctx) + c.runCleanExpired(ctx) case <-c.evictionPressure: } @@ -535,6 +539,20 @@ func (c *Cache) runReconciliationPass(ctx context.Context) { } } +// runCleanExpired runs CleanExpired, logging a failure instead of +// propagating it (the loop must keep running). It does nothing once ctx +// is cancelled. +func (c *Cache) runCleanExpired(ctx context.Context) { + if ctx.Err() != nil { + return + } + + err := c.CleanExpired(ctx) + if err != nil { + c.log.Warn("negative cache cleanup failed", "error", err) + } +} + // reconcileAccounting synchronizes the database size accounting with // the actual contents of the cache directories. It runs at startup and // again on every periodic eviction tick thereafter, off the request -- 2.54.0 From 4834e2444cb757ac408f645e58c12ad198a43097 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 15 Oct 2026 03:30:04 +0000 Subject: [PATCH 3/6] Test that expired negative cache rows are deleted in batches One statement deletes one batch and no more, CleanExpired removes the rest, and it deletes nothing once its context has ended. Model: opus-5-5 --- .../imgcache/negative_cache_internal_test.go | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/internal/imgcache/negative_cache_internal_test.go b/internal/imgcache/negative_cache_internal_test.go index de00cc5..c027540 100644 --- a/internal/imgcache/negative_cache_internal_test.go +++ b/internal/imgcache/negative_cache_internal_test.go @@ -3,6 +3,7 @@ package imgcache import ( "context" "errors" + "strconv" "testing" "time" ) @@ -250,6 +251,120 @@ func negativeCacheRowCount(tb testing.TB, cache *Cache, sourcePath string) int { return count } +// TestCleanExpiredDeletesInBatches stores one expired negative cache row +// more than one batch holds, and one row that has not expired. One +// statement must delete one batch and no more, so a request waits behind +// one batch at most; CleanExpired must then remove the expired row left +// over and keep the row that has not expired. +func TestCleanExpiredDeletesInBatches(t *testing.T) { + t.Parallel() + + cache, err := NewCache(evictionTestDB(t), CacheConfig{StateDir: t.TempDir()}) + if err != nil { + t.Fatalf("failed to create cache: %v", err) + } + + ctx := t.Context() + + insertExpiredNegativeCacheRows(t, cache, cleanExpiredBatchSize+1) + + const notExpiredPath = "/later.jpg" + + _, err = cache.db.ExecContext(ctx, ` + INSERT INTO negative_cache + (source_host, source_path, status_code, expires_at) + VALUES (?, ?, 404, datetime('now', '+1 hour')) + `, testHostExample, notExpiredPath) + if err != nil { + t.Fatalf("failed to insert a row that has not expired: %v", err) + } + + deleted, err := cache.cleanExpiredBatch(ctx) + if err != nil { + t.Fatalf("cleanExpiredBatch failed: %v", err) + } + + t.Logf("one statement deleted %d of %d expired rows", + deleted, cleanExpiredBatchSize+1) + + if deleted != cleanExpiredBatchSize { + t.Errorf("one statement deleted %d rows, want %d", + deleted, cleanExpiredBatchSize) + } + + err = cache.CleanExpired(ctx) + if err != nil { + t.Fatalf("CleanExpired failed: %v", err) + } + + if count := negativeCacheTableRowCount(t, cache); count != 1 { + t.Errorf("negative_cache holds %d rows after CleanExpired, want 1", count) + } + + if negativeCacheRowCount(t, cache, notExpiredPath) != 1 { + t.Error("the row that has not expired was removed") + } +} + +// TestCleanExpiredStopsOnceContextEnds runs CleanExpired with a context +// that has already ended: it must return the context's error and delete +// no row. +func TestCleanExpiredStopsOnceContextEnds(t *testing.T) { + t.Parallel() + + cache, err := NewCache(evictionTestDB(t), CacheConfig{StateDir: t.TempDir()}) + if err != nil { + t.Fatalf("failed to create cache: %v", err) + } + + insertExpiredNegativeCacheRows(t, cache, 1) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + err = cache.CleanExpired(ctx) + if !errors.Is(err, context.Canceled) { + t.Errorf("CleanExpired() error = %v, want context.Canceled", err) + } + + if count := negativeCacheTableRowCount(t, cache); count != 1 { + t.Errorf("negative_cache holds %d rows, want the expired row kept", count) + } +} + +// insertExpiredNegativeCacheRows inserts count negative_cache rows that +// expired an hour ago, each for a different source path. +func insertExpiredNegativeCacheRows(t *testing.T, cache *Cache, count int) { + t.Helper() + + for i := range count { + _, err := cache.db.ExecContext(t.Context(), ` + INSERT INTO negative_cache + (source_host, source_path, status_code, expires_at) + VALUES (?, ?, 404, datetime('now', '-1 hour')) + `, testHostExample, "/expired/"+strconv.Itoa(i)+".jpg") + if err != nil { + t.Fatalf("failed to insert an expired row: %v", err) + } + } +} + +// negativeCacheTableRowCount returns how many rows negative_cache holds. +func negativeCacheTableRowCount(tb testing.TB, cache *Cache) int { + tb.Helper() + + var count int + + err := cache.db.QueryRowContext(tb.Context(), + `SELECT COUNT(*) FROM negative_cache`, + ).Scan(&count) + if err != nil { + tb.Fatalf("failed to count negative_cache rows: %v", err) + } + + return count +} + func TestService_Get_ReturnsErrorForNegativeCachedURL(t *testing.T) { t.Parallel() -- 2.54.0 From 581c5e0872b9bbe87a944fd99a20d03406ab7b33 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 15 Oct 2026 04:18:02 +0000 Subject: [PATCH 4/6] Delete expired negative cache rows in batches CleanExpired deletes at most 1000 expired rows per statement and starts no further statement once its context ends, so a request on the one database connection waits behind one batch at most. Model: opus-5-5 --- TODO.md | 20 +++++++------ internal/imgcache/cache.go | 61 ++++++++++++++++++++++++++++++-------- 2 files changed, 60 insertions(+), 21 deletions(-) diff --git a/TODO.md b/TODO.md index eb5461a..364f094 100644 --- a/TODO.md +++ b/TODO.md @@ -59,6 +59,17 @@ P2: security: per-origin rate limiting and the query now have each `%` written as `%25` and each `:` as `%3A`; a path and query holding neither are signed and keyed as before. Signature Specification in `README.md` says so. +- 2026-10-15 expired negative cache rows are removed on a schedule (closes + #244): the row of a failed fetch, which makes pixa answer 502 for that source + URL for 5 minutes, was deleted only when the same URL was requested again + after that, so a client asking for a new missing path each time added rows + that stayed for good. The eviction goroutine now calls `CleanExpired` at + startup and on each periodic tick, every 5 minutes in pixad, so a row stays at + most about 10 minutes. `CleanExpired` deletes at most 1000 rows per statement, + so a request waits behind 1000 deletions at most, however many rows have + expired. The goroutine runs with the disk cache disabled + (`cache_max_bytes: 0`) too, where it does nothing else, as the negative cache + is used either way. - 2026-10-15 the example config allowlists no host (closes #249): it allowlisted `github.com` and `user-images.githubusercontent.com`, where anyone can put an image, so a deployment set up as Getting Started says served those images to @@ -76,15 +87,6 @@ P2: security: per-origin rate limiting answered 500. Once the limit is used up, the fetcher now reads one more byte: if the body has ended it is accepted, and if there is more it is refused with `ErrResponseTooLarge`, as before. -- 2026-10-15 expired negative cache rows are removed on a schedule (closes - #244): the row of a failed fetch, which makes pixa answer 502 for that source - URL for 5 minutes, was deleted only when the same URL was requested again - after that, so a client asking for a new missing path each time added rows - that stayed for good. The eviction goroutine now calls `CleanExpired` at - startup and on each periodic tick, every 5 minutes in pixad, so a row stays at - most about 10 minutes. It runs with the disk cache disabled - (`cache_max_bytes: 0`) too, where it does nothing else, as the negative cache - is used either way. - 2026-10-13 `fit=outside` no longer makes an output above 8192 on a side (closes #257): it scales the image to cover the requested size, and the side scaled past that size was held to no maximum, so `1x8192?fit=outside` of a diff --git a/internal/imgcache/cache.go b/internal/imgcache/cache.go index f5e4e47..dc49692 100644 --- a/internal/imgcache/cache.go +++ b/internal/imgcache/cache.go @@ -32,6 +32,12 @@ const httpStatusOK = 200 // variant not among them is served as before, reading its .meta file. const metaCacheSize = 10000 +// cleanExpiredBatchSize is the most expired negative cache rows one +// statement of CleanExpired deletes. Each batch is a statement of its +// own, so a request waits behind one batch at most, however many rows +// have expired. +const cleanExpiredBatchSize = 1000 + // CacheConfig holds cache configuration. type CacheConfig struct { StateDir string @@ -483,20 +489,27 @@ func (c *Cache) GetSourceContent( return c.srcContent.LoadWithSize(contentHash) } -// CleanExpired removes the negative cache rows past their expiry. The -// eviction goroutine calls it at startup and on every periodic tick, so -// a row stays at most NegativeTTL plus one eviction interval, whether or -// not its URL is requested again. +// CleanExpired removes the negative cache rows past their expiry, one +// batch per statement, until none is left. Once ctx ends it starts no +// further batch and returns ctx's error. The eviction goroutine calls it +// at startup and on every periodic tick, so a row stays about +// NegativeTTL plus one eviction interval, whether or not its URL is +// requested again. func (c *Cache) CleanExpired(ctx context.Context) error { - // Clean expired negative cache entries - _, err := c.db.ExecContext(ctx, ` - DELETE FROM negative_cache WHERE expires_at < CURRENT_TIMESTAMP - `) - if err != nil { - return fmt.Errorf("failed to clean negative cache: %w", err) - } + for { + if ctx.Err() != nil { + return ctx.Err() + } - return nil + deleted, err := c.cleanExpiredBatch(ctx) + if err != nil { + return err + } + + if deleted < cleanExpiredBatchSize { + return nil + } + } } // Stats returns cache statistics. @@ -760,3 +773,27 @@ func (c *Cache) checkNegativeCache( return true, nil } + +// cleanExpiredBatch deletes up to cleanExpiredBatchSize negative cache +// rows past their expiry in one statement, and returns how many it +// deleted. DELETE ... LIMIT needs a SQLite build option, so the subquery +// picks the rows. +func (c *Cache) cleanExpiredBatch(ctx context.Context) (int64, error) { + result, err := c.db.ExecContext(ctx, ` + DELETE FROM negative_cache WHERE id IN ( + SELECT id FROM negative_cache + WHERE expires_at < CURRENT_TIMESTAMP + LIMIT ? + ) + `, cleanExpiredBatchSize) + if err != nil { + return 0, fmt.Errorf("failed to clean negative cache: %w", err) + } + + deleted, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("failed to count cleaned negative cache rows: %w", err) + } + + return deleted, nil +} -- 2.54.0 From 288bbfbe4a707a44986516306cbb2ced35aef01d Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 15 Oct 2026 08:53:52 +0000 Subject: [PATCH 5/6] Test that CleanExpired removes more than one batch of expired rows TestCleanExpiredRemovesMoreThanOneBatch stores one expired negative cache row more than a batch holds and calls CleanExpired alone, so a CleanExpired that stops after its first batch fails it. Model: opus-5-5 --- .../imgcache/negative_cache_internal_test.go | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/internal/imgcache/negative_cache_internal_test.go b/internal/imgcache/negative_cache_internal_test.go index c027540..dae2433 100644 --- a/internal/imgcache/negative_cache_internal_test.go +++ b/internal/imgcache/negative_cache_internal_test.go @@ -306,6 +306,37 @@ func TestCleanExpiredDeletesInBatches(t *testing.T) { } } +// TestCleanExpiredRemovesMoreThanOneBatch stores one expired negative +// cache row more than one batch holds and calls CleanExpired once: it +// must remove them all, so it fails if CleanExpired stops after its +// first batch. +func TestCleanExpiredRemovesMoreThanOneBatch(t *testing.T) { + t.Parallel() + + cache, err := NewCache(evictionTestDB(t), CacheConfig{StateDir: t.TempDir()}) + if err != nil { + t.Fatalf("failed to create cache: %v", err) + } + + insertExpiredNegativeCacheRows(t, cache, cleanExpiredBatchSize+1) + + before := negativeCacheTableRowCount(t, cache) + + err = cache.CleanExpired(t.Context()) + if err != nil { + t.Fatalf("CleanExpired failed: %v", err) + } + + after := negativeCacheTableRowCount(t, cache) + + t.Logf("CleanExpired removed %d of %d expired rows, %d per batch", + before-after, before, cleanExpiredBatchSize) + + if after != 0 { + t.Errorf("negative_cache holds %d rows after CleanExpired, want 0", after) + } +} + // TestCleanExpiredStopsOnceContextEnds runs CleanExpired with a context // that has already ended: it must return the context's error and delete // no row. -- 2.54.0 From ac5db758fffe5e383b9bd2100c5e197605721b96 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 15 Oct 2026 12:17:03 +0000 Subject: [PATCH 6/6] Move the TODO.md entry for the negative cache cleanup to the top The rebase onto next left it below the entries next gained since. Model: opus-5-5 --- TODO.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/TODO.md b/TODO.md index 364f094..8d09a38 100644 --- a/TODO.md +++ b/TODO.md @@ -30,6 +30,17 @@ P2: security: per-origin rate limiting # Completed Steps +- 2026-10-15 expired negative cache rows are removed on a schedule (closes + #244): the row of a failed fetch, which makes pixa answer 502 for that source + URL for 5 minutes, was deleted only when the same URL was requested again + after that, so a client asking for a new missing path each time added rows + that stayed for good. The eviction goroutine now calls `CleanExpired` at + startup and on each periodic tick, every 5 minutes in pixad, so a row stays at + most about 10 minutes. `CleanExpired` deletes at most 1000 rows per statement, + so a request waits behind 1000 deletions at most, however many rows have + expired. The goroutine runs with the disk cache disabled + (`cache_max_bytes: 0`) too, where it does nothing else, as the negative cache + is used either way. - 2026-10-15 a cached variant is served while its source URL is in the negative cache (closes #245): `Get` checked the negative cache before the variant cache, so once a fetch of the source failed, as one does when another variant @@ -59,17 +70,6 @@ P2: security: per-origin rate limiting and the query now have each `%` written as `%25` and each `:` as `%3A`; a path and query holding neither are signed and keyed as before. Signature Specification in `README.md` says so. -- 2026-10-15 expired negative cache rows are removed on a schedule (closes - #244): the row of a failed fetch, which makes pixa answer 502 for that source - URL for 5 minutes, was deleted only when the same URL was requested again - after that, so a client asking for a new missing path each time added rows - that stayed for good. The eviction goroutine now calls `CleanExpired` at - startup and on each periodic tick, every 5 minutes in pixad, so a row stays at - most about 10 minutes. `CleanExpired` deletes at most 1000 rows per statement, - so a request waits behind 1000 deletions at most, however many rows have - expired. The goroutine runs with the disk cache disabled - (`cache_max_bytes: 0`) too, where it does nothing else, as the negative cache - is used either way. - 2026-10-15 the example config allowlists no host (closes #249): it allowlisted `github.com` and `user-images.githubusercontent.com`, where anyone can put an image, so a deployment set up as Getting Started says served those images to -- 2.54.0