Serve a cache hit with no database read or write (closes #309)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
A hit opens the variant file and nothing else: its content type comes from the file's first bytes, and a file in no output format is a miss and is made again. Its access time and hit count are handed, without waiting, to the goroutine that wrote late counts, which now writes every count and the access times once a second; the access times wait in a channel of 10,000 that drops the rest. checkNegativeCache leaves expired rows to CleanExpired. The .meta files, metaCache, Lookup and golang-lru go, with their tests; tests that read counts after Get wait for the background write. make bench runs a parallel cache hit benchmark, outside make check. Model: opus-5-5
This commit is contained in:
+49
-202
@@ -12,7 +12,6 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
lru "github.com/hashicorp/golang-lru/v2"
|
||||
"sneak.berlin/go/pixa/internal/httpfetcher"
|
||||
"sneak.berlin/go/pixa/internal/imagerequest"
|
||||
)
|
||||
@@ -23,9 +22,10 @@ var (
|
||||
ErrNegativeCache = errors.New("negative cache hit")
|
||||
)
|
||||
|
||||
// metaCacheSize is how many variants' content types metaCache holds. A
|
||||
// variant not among them is served as before, reading its .meta file.
|
||||
const metaCacheSize = 10000
|
||||
// maxPendingAccessTimes is how many access times of variants served from
|
||||
// the cache may wait to be written, at about 80 bytes each. The access time
|
||||
// of a hit served while that many wait is dropped.
|
||||
const maxPendingAccessTimes = 10000
|
||||
|
||||
// cleanExpiredBatchSize is the most expired negative cache rows one
|
||||
// statement of CleanExpired deletes. Each batch is a statement of its
|
||||
@@ -82,19 +82,21 @@ type Cache struct {
|
||||
evictionCancel context.CancelFunc
|
||||
|
||||
// The pending counts: hits, misses, upstream fetches and transforms
|
||||
// whose write to the database missed its request's deadline, kept
|
||||
// here until they are written by the goroutine that
|
||||
// StartPendingCountWrites starts. As for eviction, the channels are
|
||||
// created in NewCache: pendingCountsAdded wakes that goroutine, and
|
||||
// pendingCountsDone is closed when it returns. pendingCountsCancel,
|
||||
// set by StartPendingCountWrites, cancels its context, which tells it
|
||||
// to finish.
|
||||
// that requests counted, kept here until they are written by the
|
||||
// goroutine that StartPendingCountWrites starts, so that no request
|
||||
// waits for the database to count it. pendingAccessTimes holds the
|
||||
// variants served from the cache whose access time is still to be
|
||||
// written by that goroutine. As for eviction, the channels are created
|
||||
// in NewCache, so that requests can hand over access times before that
|
||||
// goroutine starts; pendingCountsDone is closed when it returns.
|
||||
// pendingCountsCancel, set by StartPendingCountWrites, cancels its
|
||||
// context, which tells it to finish.
|
||||
pendingHits atomic.Int64
|
||||
pendingMisses atomic.Int64
|
||||
pendingUpstreamFetches atomic.Int64
|
||||
pendingUpstreamFetchBytes atomic.Int64
|
||||
pendingTransforms atomic.Int64
|
||||
pendingCountsAdded chan struct{}
|
||||
pendingAccessTimes chan imagerequest.VariantKey
|
||||
pendingCountsDone chan struct{}
|
||||
pendingCountsCancel context.CancelFunc
|
||||
|
||||
@@ -102,11 +104,6 @@ type Cache struct {
|
||||
// readHitAndMissCounts to read them
|
||||
pendingCountsWriteMutex sync.Mutex
|
||||
|
||||
// metaCache holds the content types of the variants most recently
|
||||
// stored or served, so a hit does not read the variant's .meta file.
|
||||
// It never stands in for the variant file, which is always opened.
|
||||
metaCache *lru.Cache[imagerequest.VariantKey, string]
|
||||
|
||||
// reconciliationPageSize is the most rows one read of a content table
|
||||
// returns in the reconciliation pass and in Stats. newCache sets it to
|
||||
// defaultReconciliationPageSize; tests set it smaller.
|
||||
@@ -134,11 +131,6 @@ func newCache(
|
||||
log = slog.Default()
|
||||
}
|
||||
|
||||
metaCache, err := lru.New[imagerequest.VariantKey, string](metaCacheSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create variant content type cache: %w", err)
|
||||
}
|
||||
|
||||
c := &Cache{
|
||||
db: db,
|
||||
config: config,
|
||||
@@ -146,9 +138,8 @@ func newCache(
|
||||
disabled: config.DisableDiskCache,
|
||||
evictionPressure: make(chan struct{}, 1),
|
||||
evictionDone: make(chan struct{}),
|
||||
metaCache: metaCache,
|
||||
|
||||
pendingCountsAdded: make(chan struct{}, 1),
|
||||
pendingAccessTimes: make(chan imagerequest.VariantKey, maxPendingAccessTimes),
|
||||
pendingCountsDone: make(chan struct{}),
|
||||
|
||||
reconciliationPageSize: defaultReconciliationPageSize,
|
||||
@@ -166,7 +157,7 @@ func newCache(
|
||||
}
|
||||
|
||||
variants, err := NewVariantStorage(
|
||||
filepath.Join(config.StateDir, "cache", "variants"), log,
|
||||
filepath.Join(config.StateDir, "cache", "variants"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create variant storage: %w", err)
|
||||
@@ -192,45 +183,11 @@ func newCache(
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// LookupResult contains the result of a cache lookup.
|
||||
type LookupResult struct {
|
||||
Hit bool
|
||||
CacheKey imagerequest.VariantKey
|
||||
ContentType string
|
||||
SizeBytes int64
|
||||
CacheStatus CacheStatus
|
||||
}
|
||||
|
||||
// Lookup checks if a processed variant exists on disk. Hits touch the
|
||||
// variant's LRU timestamp; a disabled cache always misses.
|
||||
func (c *Cache) Lookup(
|
||||
ctx context.Context, req *imagerequest.Request,
|
||||
) (*LookupResult, error) {
|
||||
cacheKey := imagerequest.CacheKey(req)
|
||||
|
||||
// Check variant storage directly - no DB needed for cache hits
|
||||
if !c.disabled && c.variants.Exists(cacheKey) {
|
||||
c.touchVariant(ctx, cacheKey)
|
||||
|
||||
return &LookupResult{
|
||||
Hit: true,
|
||||
CacheKey: cacheKey,
|
||||
CacheStatus: CacheHit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &LookupResult{
|
||||
Hit: false,
|
||||
CacheKey: cacheKey,
|
||||
CacheStatus: CacheMiss,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetVariant returns a reader, size, and content type for a cached
|
||||
// variant. The content type comes from metaCache, or else from the
|
||||
// variant's .meta file and is then kept in metaCache. A variant with
|
||||
// no .meta file is served as application/octet-stream, which is not
|
||||
// kept.
|
||||
// GetVariant opens a cached variant and returns a reader, its size and its
|
||||
// content type, read from the variant file's first bytes. It opens that one
|
||||
// file and makes no database call. A variant not in the cache, or any
|
||||
// variant of a disabled cache, is ErrNotFound; a variant file whose first
|
||||
// bytes are not those of an output format is another error.
|
||||
func (c *Cache) GetVariant(
|
||||
cacheKey imagerequest.VariantKey,
|
||||
) (io.ReadCloser, int64, string, error) {
|
||||
@@ -238,20 +195,7 @@ func (c *Cache) GetVariant(
|
||||
return nil, 0, "", ErrNotFound
|
||||
}
|
||||
|
||||
contentType, known := c.metaCache.Get(cacheKey)
|
||||
if !known {
|
||||
return c.loadVariantWithMeta(cacheKey)
|
||||
}
|
||||
|
||||
reader, size, err := c.variants.LoadWithSize(cacheKey)
|
||||
if err != nil {
|
||||
// The file is gone, e.g. deleted outside pixa
|
||||
c.metaCache.Remove(cacheKey)
|
||||
|
||||
return nil, 0, "", err
|
||||
}
|
||||
|
||||
return reader, size, contentType, nil
|
||||
return c.variants.LoadWithContentType(cacheKey)
|
||||
}
|
||||
|
||||
// StoreSource stores fetched source content: it writes the file named by
|
||||
@@ -321,13 +265,11 @@ func (c *Cache) StoreVariant(
|
||||
return nil
|
||||
}
|
||||
|
||||
size, err := c.variants.Store(cacheKey, content, contentType)
|
||||
size, err := c.variants.Store(cacheKey, content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.metaCache.Add(cacheKey, contentType)
|
||||
|
||||
_, err = c.db.ExecContext(ctx, `
|
||||
INSERT INTO variant_content (cache_key, size_bytes, content_type)
|
||||
VALUES (?, ?, ?)
|
||||
@@ -480,92 +422,34 @@ func (c *Cache) Stats(ctx context.Context) (*CacheStats, error) {
|
||||
|
||||
// IncrementStats counts a cache hit or miss, and an upstream fetch that read
|
||||
// fetchBytes bytes, as IncrementUpstreamFetch does. Like the other Increment
|
||||
// methods, it writes to the database with ctx's deadline but not its
|
||||
// cancellation, so an ended request is still counted but does not wait for
|
||||
// the database past its deadline; a count not written by then becomes a
|
||||
// pending count (see StartPendingCountWrites).
|
||||
// methods, it only adds to the pending counts, which the goroutine
|
||||
// StartPendingCountWrites starts writes to the database, so it never waits
|
||||
// for the database or fails.
|
||||
func (c *Cache) IncrementStats(ctx context.Context, hit bool, fetchBytes int64) {
|
||||
countCtx, cancel := withoutCancelKeepingDeadline(ctx)
|
||||
defer cancel()
|
||||
|
||||
var err error
|
||||
|
||||
pendingCount := &c.pendingMisses
|
||||
|
||||
if hit {
|
||||
pendingCount = &c.pendingHits
|
||||
|
||||
_, err = c.db.ExecContext(countCtx, `
|
||||
UPDATE cache_stats
|
||||
SET hit_count = hit_count + 1,
|
||||
last_updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = 1
|
||||
`)
|
||||
c.pendingHits.Add(1)
|
||||
} else {
|
||||
_, err = c.db.ExecContext(countCtx, `
|
||||
UPDATE cache_stats
|
||||
SET miss_count = miss_count + 1,
|
||||
last_updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = 1
|
||||
`)
|
||||
}
|
||||
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
c.addPendingCount(pendingCount, 1)
|
||||
case err != nil:
|
||||
c.log.Warn("failed to count cache hit or miss", "hit", hit, "error", err)
|
||||
c.pendingMisses.Add(1)
|
||||
}
|
||||
|
||||
c.IncrementUpstreamFetch(ctx, fetchBytes)
|
||||
}
|
||||
|
||||
// IncrementUpstreamFetch counts one upstream fetch that read fetchBytes bytes.
|
||||
// A fetch that read no bytes is not counted.
|
||||
func (c *Cache) IncrementUpstreamFetch(ctx context.Context, fetchBytes int64) {
|
||||
// A fetch that read no bytes is not counted. The context is not used.
|
||||
func (c *Cache) IncrementUpstreamFetch(_ context.Context, fetchBytes int64) {
|
||||
if fetchBytes <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
countCtx, cancel := withoutCancelKeepingDeadline(ctx)
|
||||
defer cancel()
|
||||
|
||||
_, err := c.db.ExecContext(countCtx, `
|
||||
UPDATE cache_stats
|
||||
SET upstream_fetch_count = upstream_fetch_count + 1,
|
||||
upstream_fetch_bytes = upstream_fetch_bytes + ?,
|
||||
last_updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = 1
|
||||
`, fetchBytes)
|
||||
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
c.addPendingCount(&c.pendingUpstreamFetches, 1)
|
||||
c.addPendingCount(&c.pendingUpstreamFetchBytes, fetchBytes)
|
||||
case err != nil:
|
||||
c.log.Warn("failed to count upstream fetch",
|
||||
"fetch_bytes", fetchBytes, "error", err)
|
||||
}
|
||||
c.pendingUpstreamFetches.Add(1)
|
||||
c.pendingUpstreamFetchBytes.Add(fetchBytes)
|
||||
}
|
||||
|
||||
// IncrementTransformCount counts one image transcoded by the image processor.
|
||||
func (c *Cache) IncrementTransformCount(ctx context.Context) {
|
||||
countCtx, cancel := withoutCancelKeepingDeadline(ctx)
|
||||
defer cancel()
|
||||
|
||||
_, err := c.db.ExecContext(countCtx, `
|
||||
UPDATE cache_stats
|
||||
SET transform_count = transform_count + 1,
|
||||
last_updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = 1
|
||||
`)
|
||||
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
c.addPendingCount(&c.pendingTransforms, 1)
|
||||
case err != nil:
|
||||
c.log.Warn("failed to count transform", "error", err)
|
||||
}
|
||||
// The context is not used.
|
||||
func (c *Cache) IncrementTransformCount(_ context.Context) {
|
||||
c.pendingTransforms.Add(1)
|
||||
}
|
||||
|
||||
// readHitAndMissCounts sets the hit and miss counts of stats to those of the
|
||||
@@ -587,41 +471,15 @@ func (c *Cache) readHitAndMissCounts(ctx context.Context, stats *CacheStats) err
|
||||
return err
|
||||
}
|
||||
|
||||
// loadVariantWithMeta is GetVariant for a variant metaCache does not
|
||||
// hold: it reads the content type from the variant's .meta file and
|
||||
// keeps it in metaCache, unless a StoreVariant has put one there
|
||||
// meanwhile, as the store's is newer. A read that finds no .meta file,
|
||||
// as one can when the store's .meta write failed or an eviction removed
|
||||
// the file after the read opened the variant, serves
|
||||
// application/octet-stream and keeps nothing, so metaCache only ever
|
||||
// holds a type read from a .meta file or passed to StoreVariant.
|
||||
func (c *Cache) loadVariantWithMeta(
|
||||
cacheKey imagerequest.VariantKey,
|
||||
) (io.ReadCloser, int64, string, error) {
|
||||
reader, size, contentType, err := c.variants.LoadWithMeta(cacheKey)
|
||||
if err != nil {
|
||||
return nil, 0, "", err
|
||||
}
|
||||
|
||||
if contentType == "" {
|
||||
return reader, size, fallbackContentType, nil
|
||||
}
|
||||
|
||||
c.metaCache.ContainsOrAdd(cacheKey, contentType)
|
||||
|
||||
return reader, size, contentType, nil
|
||||
}
|
||||
|
||||
// touchVariant updates the LRU timestamp of a variant, best-effort:
|
||||
// a failed touch only makes the entry look colder to eviction.
|
||||
func (c *Cache) touchVariant(ctx context.Context, cacheKey imagerequest.VariantKey) {
|
||||
_, err := c.db.ExecContext(ctx, `
|
||||
UPDATE variant_content SET last_accessed_at = CURRENT_TIMESTAMP
|
||||
WHERE cache_key = ?
|
||||
`, string(cacheKey))
|
||||
if err != nil {
|
||||
c.log.Debug("failed to touch variant LRU timestamp",
|
||||
"cache_key", cacheKey, "error", err)
|
||||
// touchVariant hands the access time of a variant served from the cache to
|
||||
// the goroutine StartPendingCountWrites starts, which writes it in its next
|
||||
// batch. It never waits: while maxPendingAccessTimes access times wait to be
|
||||
// written, this one is dropped, and the variant only looks colder to
|
||||
// eviction.
|
||||
func (c *Cache) touchVariant(cacheKey imagerequest.VariantKey) {
|
||||
select {
|
||||
case c.pendingAccessTimes <- cacheKey:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,7 +496,9 @@ func (c *Cache) touchSourceContent(ctx context.Context, key imagerequest.SourceK
|
||||
}
|
||||
}
|
||||
|
||||
// checkNegativeCache checks if a request is in the negative cache.
|
||||
// checkNegativeCache checks if a request is in the negative cache. It only
|
||||
// reads: a row past its expiry is not a hit, and the eviction goroutine
|
||||
// deletes it with the other expired rows (see CleanExpired).
|
||||
func (c *Cache) checkNegativeCache(
|
||||
ctx context.Context, req *imagerequest.Request,
|
||||
) (bool, error) {
|
||||
@@ -657,22 +517,9 @@ func (c *Cache) checkNegativeCache(
|
||||
return false, fmt.Errorf("failed to check negative cache: %w", err)
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if time.Now().After(expiresAt) {
|
||||
// Clean up expired entry
|
||||
_, err = c.db.ExecContext(ctx, `
|
||||
DELETE FROM negative_cache
|
||||
WHERE source_host = ? AND source_path = ? AND source_query = ?
|
||||
`, req.Source.Host, req.Source.Path, req.Source.Query)
|
||||
if err != nil {
|
||||
c.log.Warn("failed to delete expired negative cache entry",
|
||||
"host", req.Source.Host, "path", req.Source.Path, "error", err)
|
||||
}
|
||||
expired := time.Now().After(expiresAt)
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
return !expired, nil
|
||||
}
|
||||
|
||||
// cleanExpiredBatch deletes up to cleanExpiredBatchSize negative cache
|
||||
|
||||
Reference in New Issue
Block a user