check / check (push) Waiting to run
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. Its access time and count, and the access time of a cached source a miss uses, go without waiting to one background goroutine that writes them once a second; each kind of access time waits in a channel of 10,000 that drops the rest. Expired negative cache rows are left to CleanExpired. The .meta files, metaCache, Lookup and golang-lru go, with their tests; the two reconciliation walks, identical without the .meta skip, share one. make bench runs a parallel cache hit benchmark on the host, outside make check. Model: opus-5-5
222 lines
6.8 KiB
Go
222 lines
6.8 KiB
Go
package imgcache
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
c.pendingCountsCancel = cancel
|
|
|
|
go func() {
|
|
c.pendingCountWriteLoop(ctx)
|
|
}()
|
|
}
|
|
|
|
// 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 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. It logs what was not written,
|
|
// access times or counts, and returns an error.
|
|
func (c *Cache) StopPendingCountWrites(ctx context.Context) error {
|
|
var accessTimesErr, countsErr error
|
|
|
|
if c.pendingCountsCancel != nil {
|
|
c.pendingCountsCancel()
|
|
|
|
select {
|
|
case <-c.pendingCountsDone:
|
|
case <-ctx.Done():
|
|
// The write given up on here, of access times or of counts,
|
|
// may not reach the database: database/sql closes without
|
|
// waiting for a query, and a write still waiting for the
|
|
// connection fails when the database closes. Neither is
|
|
// written here.
|
|
accessTimesErr = fmt.Errorf("access times still being written: %w", ctx.Err())
|
|
countsErr = fmt.Errorf("pending counts still being written: %w", ctx.Err())
|
|
}
|
|
}
|
|
|
|
if accessTimesErr == nil && countsErr == nil {
|
|
accessTimesErr = c.writePendingAccessTimes(ctx)
|
|
countsErr = c.writePendingCounts(ctx)
|
|
}
|
|
|
|
if accessTimesErr != nil {
|
|
c.log.Error("access times not written at shutdown", "error", accessTimesErr)
|
|
}
|
|
|
|
if countsErr != nil {
|
|
c.log.Error("counts not written at shutdown",
|
|
"hits", c.pendingHits.Load(),
|
|
"misses", c.pendingMisses.Load(),
|
|
"upstream_fetches", c.pendingUpstreamFetches.Load(),
|
|
"upstream_fetch_bytes", c.pendingUpstreamFetchBytes.Load(),
|
|
"transforms", c.pendingTransforms.Load(),
|
|
"error", countsErr,
|
|
)
|
|
}
|
|
|
|
return errors.Join(accessTimesErr, countsErr)
|
|
}
|
|
|
|
// 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. 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 <-ticker.C:
|
|
}
|
|
|
|
// select picks at random when ctx is cancelled and a tick waits
|
|
// too.
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// writePendingAccessTimes takes out the variants waiting in
|
|
// pendingVariantAccessTimes and the sources waiting in
|
|
// pendingSourceAccessTimes, and sets their last_accessed_at to now in one
|
|
// transaction, once for each however often it was used. Nothing else takes
|
|
// from those channels 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 {
|
|
variants := make(map[imagerequest.VariantKey]struct{})
|
|
|
|
for range len(c.pendingVariantAccessTimes) {
|
|
variants[<-c.pendingVariantAccessTimes] = struct{}{}
|
|
}
|
|
|
|
sources := make(map[imagerequest.SourceKey]struct{})
|
|
|
|
for range len(c.pendingSourceAccessTimes) {
|
|
sources[<-c.pendingSourceAccessTimes] = struct{}{}
|
|
}
|
|
|
|
accessTimes := len(variants) + len(sources)
|
|
if accessTimes == 0 {
|
|
return nil
|
|
}
|
|
|
|
tx, err := c.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to write %d access times: %w", accessTimes, err)
|
|
}
|
|
|
|
// Does nothing once the transaction is committed
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
for cacheKey := range variants {
|
|
_, 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", accessTimes, err)
|
|
}
|
|
}
|
|
|
|
for sourceKey := range sources {
|
|
_, err = tx.ExecContext(ctx, `
|
|
UPDATE source_content SET last_accessed_at = CURRENT_TIMESTAMP
|
|
WHERE source_key = ?
|
|
`, string(sourceKey))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to write %d access times: %w", accessTimes, err)
|
|
}
|
|
}
|
|
|
|
err = tx.Commit()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to write %d access times: %w", accessTimes, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// writePendingCounts adds the pending counts to the cache_stats row in one
|
|
// UPDATE, then takes what it wrote out of them, leaving any added
|
|
// meanwhile. When the UPDATE fails, they all stay pending.
|
|
func (c *Cache) writePendingCounts(ctx context.Context) error {
|
|
c.pendingCountsWriteMutex.Lock()
|
|
defer c.pendingCountsWriteMutex.Unlock()
|
|
|
|
hits := c.pendingHits.Load()
|
|
misses := c.pendingMisses.Load()
|
|
upstreamFetches := c.pendingUpstreamFetches.Load()
|
|
upstreamFetchBytes := c.pendingUpstreamFetchBytes.Load()
|
|
transforms := c.pendingTransforms.Load()
|
|
|
|
if hits+misses+upstreamFetches+upstreamFetchBytes+transforms == 0 {
|
|
return nil
|
|
}
|
|
|
|
_, err := c.db.ExecContext(ctx, `
|
|
UPDATE cache_stats
|
|
SET hit_count = hit_count + ?,
|
|
miss_count = miss_count + ?,
|
|
upstream_fetch_count = upstream_fetch_count + ?,
|
|
upstream_fetch_bytes = upstream_fetch_bytes + ?,
|
|
transform_count = transform_count + ?,
|
|
last_updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = 1
|
|
`, hits, misses, upstreamFetches, upstreamFetchBytes, transforms)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to write pending counts: %w", err)
|
|
}
|
|
|
|
c.pendingHits.Add(-hits)
|
|
c.pendingMisses.Add(-misses)
|
|
c.pendingUpstreamFetches.Add(-upstreamFetches)
|
|
c.pendingUpstreamFetchBytes.Add(-upstreamFetchBytes)
|
|
c.pendingTransforms.Add(-transforms)
|
|
|
|
return nil
|
|
}
|