Serve a cache hit with no database read or write (closes #309)
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:
2026-10-21 15:10:31 +00:00
parent a19cca2f34
commit 83087db6a0
25 changed files with 963 additions and 1383 deletions
+76 -27
View File
@@ -2,17 +2,26 @@ package imgcache
import (
"context"
"errors"
"fmt"
"sync/atomic"
"time"
"sneak.berlin/go/pixa/internal/imagerequest"
)
// StartPendingCountWrites starts the goroutine that writes the pending
// counts to the database whenever there are some, one UPDATE at a time:
// however many requests pass their deadline before their counts are
// written, at most this one write waits for the database. It is a no-op
// when already started. The goroutine outlives the caller, so it runs with
// its own context, which StopPendingCountWrites cancels to tell it to
// finish.
// pendingWriteInterval is how often the goroutine StartPendingCountWrites
// starts writes the pending access times and counts. SQLite's
// CURRENT_TIMESTAMP, which an access time is written as, counts whole
// seconds.
const pendingWriteInterval = time.Second
// StartPendingCountWrites starts the goroutine that writes, every
// pendingWriteInterval, the pending access times in one transaction and then
// the pending counts in one UPDATE. Requests only hand these to it, so none
// waits for the database to record what it did, and only this goroutine's
// write ever waits for it. It is a no-op when already started. The goroutine
// outlives the caller, so it runs with its own context, which
// StopPendingCountWrites cancels to tell it to finish.
func (c *Cache) StartPendingCountWrites() {
if c.pendingCountsCancel != nil {
return
@@ -28,10 +37,10 @@ func (c *Cache) StartPendingCountWrites() {
// StopPendingCountWrites tells the goroutine StartPendingCountWrites
// started, if it did, to finish, and waits for its write under way to end
// and for it to return. It then writes the pending counts left, so that
// they are written before the database closes. It waits for all of this at
// most until ctx ends, then logs the counts not written and returns an
// error.
// and for it to return. It then writes the pending access times and counts
// left, once, so that they are written before the database closes. It waits
// for all of this at most until ctx ends, then logs the counts not written
// and returns an error.
func (c *Cache) StopPendingCountWrites(ctx context.Context) error {
var err error
@@ -50,7 +59,7 @@ func (c *Cache) StopPendingCountWrites(ctx context.Context) error {
}
if err == nil {
err = c.writePendingCounts(ctx)
err = errors.Join(c.writePendingAccessTimes(ctx), c.writePendingCounts(ctx))
}
if err != nil {
@@ -71,41 +80,81 @@ func (c *Cache) StopPendingCountWrites(ctx context.Context) error {
// pendingCountWriteLoop is the body of the goroutine
// StartPendingCountWrites starts. Once ctx is cancelled it starts no write
// and returns, but does not cut off a write under way then. A write that
// fails leaves the counts pending, for the next write.
// and returns, but does not cut off a write under way then. Counts whose
// write fails stay pending, for the next write; access times whose write
// fails are dropped.
func (c *Cache) pendingCountWriteLoop(ctx context.Context) {
defer close(c.pendingCountsDone)
ticker := time.NewTicker(pendingWriteInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-c.pendingCountsAdded:
case <-ticker.C:
}
// select picks at random when ctx is cancelled and a wakeup
// waits too.
// select picks at random when ctx is cancelled and a tick waits
// too.
if ctx.Err() != nil {
return
}
err := c.writePendingCounts(context.WithoutCancel(ctx))
err := c.writePendingAccessTimes(context.WithoutCancel(ctx))
if err != nil {
c.log.Warn("failed to write access times", "error", err)
}
err = c.writePendingCounts(context.WithoutCancel(ctx))
if err != nil {
c.log.Warn("failed to write pending counts", "error", err)
}
}
}
// addPendingCount adds n to pendingCount, one of the pending counts, and
// wakes the goroutine that writes them. pendingCountsAdded has capacity one,
// so a wakeup already waiting is enough.
func (c *Cache) addPendingCount(pendingCount *atomic.Int64, n int64) {
pendingCount.Add(n)
// writePendingAccessTimes takes out the variants waiting in
// pendingAccessTimes and sets their last_accessed_at to now in one
// transaction, once for each variant however often it was served. Nothing
// else takes from pendingAccessTimes while it runs: only the goroutine
// StartPendingCountWrites starts calls it, or StopPendingCountWrites once
// that goroutine has returned.
func (c *Cache) writePendingAccessTimes(ctx context.Context) error {
served := make(map[imagerequest.VariantKey]struct{})
select {
case c.pendingCountsAdded <- struct{}{}:
default:
for range len(c.pendingAccessTimes) {
served[<-c.pendingAccessTimes] = struct{}{}
}
if len(served) == 0 {
return nil
}
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("failed to write %d access times: %w", len(served), err)
}
// Does nothing once the transaction is committed
defer func() { _ = tx.Rollback() }()
for cacheKey := range served {
_, err = tx.ExecContext(ctx, `
UPDATE variant_content SET last_accessed_at = CURRENT_TIMESTAMP
WHERE cache_key = ?
`, string(cacheKey))
if err != nil {
return fmt.Errorf("failed to write %d access times: %w", len(served), err)
}
}
err = tx.Commit()
if err != nil {
return fmt.Errorf("failed to write %d access times: %w", len(served), err)
}
return nil
}
// writePendingCounts adds the pending counts to the cache_stats row in one