Export cache, eviction, upstream and transform metrics (closes #85)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
New pixa_ metrics, none with labels: - source cache hits and misses, counted once per variant made or checked - cache usage and limit gauges; usage is set each time eviction reads it - eviction count and bytes, from the size_bytes the deleted row returns - upstream fetch duration histogram and fetch error counter - transform duration histogram and an in-progress gauge, both including the wait for a processing slot Removing a damaged file during reconciliation is not counted as an eviction. README lists every metric; the metrics config comment now says the pixa_ metrics are measured whether or not /metrics is served. Model: opus-5-5
This commit is contained in:
1 parent
fe5bf393b1
commit
7014d88956
9 files changed
+314
-113
No files matched your search
+12
-17
@@ -111,10 +111,16 @@ type Cache struct {
|
||||
pendingAccessTimesDone chan struct{}
|
||||
pendingAccessTimesCancel context.CancelFunc
|
||||
|
||||
// The counts of cache hits and misses, made and registered by
|
||||
// registerCounters (see counters.go)
|
||||
cacheHits prometheus.Counter
|
||||
cacheMisses prometheus.Counter
|
||||
// The cache's metrics, made and registered by registerMetrics (see
|
||||
// metrics.go)
|
||||
cacheHits prometheus.Counter
|
||||
cacheMisses prometheus.Counter
|
||||
sourceCacheHits prometheus.Counter
|
||||
sourceCacheMisses prometheus.Counter
|
||||
usageBytes prometheus.Gauge
|
||||
maxBytes prometheus.Gauge
|
||||
evictions prometheus.Counter
|
||||
evictedBytes prometheus.Counter
|
||||
|
||||
// reconciliationPageSize is the most rows one read of a content table
|
||||
// returns in the reconciliation pass. NewCache sets it to
|
||||
@@ -128,7 +134,7 @@ type Cache struct {
|
||||
reconciliationReadTestHook func(rows int)
|
||||
}
|
||||
|
||||
// NewCache creates a new cache instance and registers its counters on
|
||||
// NewCache creates a new cache instance and registers its metrics on
|
||||
// metricsRegistry. pixad passes Prometheus' default registry, the one
|
||||
// /metrics serves. It makes no database call.
|
||||
func NewCache(
|
||||
@@ -157,7 +163,7 @@ func NewCache(
|
||||
reconciliationPageSize: defaultReconciliationPageSize,
|
||||
}
|
||||
|
||||
err := c.registerCounters(metricsRegistry)
|
||||
err := c.registerMetrics(metricsRegistry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -656,17 +662,6 @@ func (c *Cache) CleanExpired(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// IncrementStats counts a cache hit or miss. It only adds to the cache's
|
||||
// Prometheus counters in memory (see counters.go), so it never waits for the
|
||||
// database or fails.
|
||||
func (c *Cache) IncrementStats(hit bool) {
|
||||
if hit {
|
||||
c.cacheHits.Inc()
|
||||
} else {
|
||||
c.cacheMisses.Inc()
|
||||
}
|
||||
}
|
||||
|
||||
// touchVariant hands the access time of a variant served from the cache to
|
||||
// the goroutine StartPendingAccessTimeWrites starts, which writes it in its
|
||||
// next batch. It never waits: while maxPendingAccessTimes access times of
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package imgcache
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
// registerCounters makes the cache's counters and registers them on
|
||||
// metricsRegistry. The counters are kept in memory, so adding to one waits
|
||||
// for nothing and makes no database call. It fails when metricsRegistry
|
||||
// already holds counters of the same names.
|
||||
func (c *Cache) registerCounters(metricsRegistry prometheus.Registerer) error {
|
||||
c.cacheHits = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "pixa_cache_hits_total",
|
||||
Help: "Image requests served from the cache.",
|
||||
})
|
||||
c.cacheMisses = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "pixa_cache_misses_total",
|
||||
Help: "Image requests not served from the cache.",
|
||||
})
|
||||
|
||||
for _, counter := range []prometheus.Counter{c.cacheHits, c.cacheMisses} {
|
||||
err := metricsRegistry.Register(counter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to register the cache's counters: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -92,8 +92,9 @@ type evictionCandidate struct {
|
||||
}
|
||||
|
||||
// EvictToLimit evicts least-recently-used cache entries until total
|
||||
// tracked usage is at or below the configured MaxBytes limit. It is a
|
||||
// no-op when the cache is disabled.
|
||||
// tracked usage is at or below the configured MaxBytes limit. It sets the
|
||||
// pixa_cache_usage_bytes gauge to each usage it reads. It is a no-op when
|
||||
// the cache is disabled.
|
||||
func (c *Cache) EvictToLimit(ctx context.Context) error {
|
||||
if c.config.MaxBytes == 0 {
|
||||
return nil
|
||||
@@ -105,6 +106,8 @@ func (c *Cache) EvictToLimit(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
c.usageBytes.Set(float64(usage))
|
||||
|
||||
if usage <= c.config.MaxBytes {
|
||||
return nil
|
||||
}
|
||||
@@ -345,37 +348,95 @@ func (c *Cache) sourceCandidates(ctx context.Context) ([]evictionCandidate, erro
|
||||
return candidates, nil
|
||||
}
|
||||
|
||||
// evictVariant removes one variant: accounting row first, then the
|
||||
// file, so the database never references a deleted file.
|
||||
// evictVariant removes one variant (see removeVariant) and counts the
|
||||
// eviction with the size its row recorded.
|
||||
func (c *Cache) evictVariant(
|
||||
ctx context.Context, cacheKey imagerequest.VariantKey,
|
||||
) error {
|
||||
_, err := c.db.ExecContext(ctx,
|
||||
`DELETE FROM variant_content WHERE cache_key = ?`, string(cacheKey))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete variant accounting row: %w", err)
|
||||
}
|
||||
|
||||
err = c.variants.Delete(string(cacheKey))
|
||||
sizeBytes, err := c.removeVariant(ctx, cacheKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.countEviction(sizeBytes)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// evictSourceBlob removes one source: its row first, then its file, as
|
||||
// evictVariant does for a variant. It holds no lock: a store of the same
|
||||
// source in between can leave the source's row with no file, which
|
||||
// Source treats as a miss.
|
||||
func (c *Cache) evictSourceBlob(ctx context.Context, key imagerequest.SourceKey) error {
|
||||
_, err := c.db.ExecContext(ctx,
|
||||
`DELETE FROM source_content WHERE source_key = ?`, string(key))
|
||||
// removeVariant removes one variant: accounting row first, then the
|
||||
// file, so the database never references a deleted file. It returns the
|
||||
// size the row recorded, or 0 when the variant had no row.
|
||||
func (c *Cache) removeVariant(
|
||||
ctx context.Context, cacheKey imagerequest.VariantKey,
|
||||
) (int64, error) {
|
||||
sizeBytes, err := c.deleteRowReturningSize(ctx,
|
||||
`DELETE FROM variant_content WHERE cache_key = ? RETURNING size_bytes`,
|
||||
string(cacheKey))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete source content row: %w", err)
|
||||
return 0, fmt.Errorf("failed to delete variant accounting row: %w", err)
|
||||
}
|
||||
|
||||
return c.srcContent.Delete(string(key))
|
||||
err = c.variants.Delete(string(cacheKey))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return sizeBytes, nil
|
||||
}
|
||||
|
||||
// evictSourceBlob removes one source (see removeSourceBlob) and counts the
|
||||
// eviction with the size its row recorded.
|
||||
func (c *Cache) evictSourceBlob(ctx context.Context, key imagerequest.SourceKey) error {
|
||||
sizeBytes, err := c.removeSourceBlob(ctx, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.countEviction(sizeBytes)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeSourceBlob removes one source: its row first, then its file, as
|
||||
// removeVariant does for a variant, and returns the size the row recorded,
|
||||
// or 0 when the source had no row. It holds no lock: a store of the same
|
||||
// source in between can leave the source's row with no file, which Source
|
||||
// treats as a miss.
|
||||
func (c *Cache) removeSourceBlob(
|
||||
ctx context.Context, key imagerequest.SourceKey,
|
||||
) (int64, error) {
|
||||
sizeBytes, err := c.deleteRowReturningSize(ctx,
|
||||
`DELETE FROM source_content WHERE source_key = ? RETURNING size_bytes`,
|
||||
string(key))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to delete source content row: %w", err)
|
||||
}
|
||||
|
||||
err = c.srcContent.Delete(string(key))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return sizeBytes, nil
|
||||
}
|
||||
|
||||
// deleteRowReturningSize runs deleteQuery, which deletes the row of key and
|
||||
// returns its size_bytes, and returns that size, or 0 when there was no row.
|
||||
func (c *Cache) deleteRowReturningSize(
|
||||
ctx context.Context, deleteQuery string, key string,
|
||||
) (int64, error) {
|
||||
var sizeBytes int64
|
||||
|
||||
err := c.db.QueryRowContext(ctx, deleteQuery, key).Scan(&sizeBytes)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return sizeBytes, nil
|
||||
}
|
||||
|
||||
// notifyWritePressure wakes the background evictor after a store, so
|
||||
@@ -673,7 +734,9 @@ func (c *Cache) reconcileVariantFile(
|
||||
c.log.Warn("removing damaged cached variant file", "path", path,
|
||||
"file_bytes", info.Size(), "recorded_bytes", recordedSize)
|
||||
|
||||
return c.evictVariant(ctx, cacheKey)
|
||||
_, err = c.removeVariant(ctx, cacheKey)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// adoptVariantFile inserts an accounting row for a variant file that
|
||||
@@ -835,7 +898,9 @@ func (c *Cache) reconcileSourceFile(
|
||||
c.log.Warn("removing damaged cached source file", "path", path,
|
||||
"file_bytes", info.Size(), "recorded_bytes", recordedSize)
|
||||
|
||||
return c.evictSourceBlob(ctx, key)
|
||||
_, err = c.removeSourceBlob(ctx, key)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// removeUntrackedSourceFile deletes a source blob file that has no
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package imgcache
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
// registerMetrics makes the cache's metrics and registers them on
|
||||
// metricsRegistry. The metrics are kept in memory, so updating one waits
|
||||
// for nothing and makes no database call. It fails when metricsRegistry
|
||||
// already holds metrics of the same names.
|
||||
func (c *Cache) registerMetrics(metricsRegistry prometheus.Registerer) error {
|
||||
c.cacheHits = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "pixa_cache_hits_total",
|
||||
Help: "Image requests served from the cache.",
|
||||
})
|
||||
c.cacheMisses = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "pixa_cache_misses_total",
|
||||
Help: "Image requests not served from the cache.",
|
||||
})
|
||||
c.sourceCacheHits = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "pixa_source_cache_hits_total",
|
||||
Help: "Variants made or checked whose source was in the cache.",
|
||||
})
|
||||
c.sourceCacheMisses = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "pixa_source_cache_misses_total",
|
||||
Help: "Variants made or checked whose source was not in the cache.",
|
||||
})
|
||||
c.usageBytes = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "pixa_cache_usage_bytes",
|
||||
Help: "Bytes of sources and variants in the cache, as eviction last read them.",
|
||||
})
|
||||
c.maxBytes = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||
Name: "pixa_cache_max_bytes",
|
||||
Help: "The cache size limit in bytes, cache_max_bytes.",
|
||||
})
|
||||
c.evictions = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "pixa_cache_evictions_total",
|
||||
Help: "Sources and variants removed by eviction.",
|
||||
})
|
||||
c.evictedBytes = prometheus.NewCounter(prometheus.CounterOpts{
|
||||
Name: "pixa_cache_evicted_bytes_total",
|
||||
Help: "Bytes of the sources and variants removed by eviction.",
|
||||
})
|
||||
|
||||
c.maxBytes.Set(float64(c.config.MaxBytes))
|
||||
|
||||
for _, metric := range []prometheus.Collector{
|
||||
c.cacheHits, c.cacheMisses, c.sourceCacheHits, c.sourceCacheMisses,
|
||||
c.usageBytes, c.maxBytes, c.evictions, c.evictedBytes,
|
||||
} {
|
||||
err := metricsRegistry.Register(metric)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to register the cache's metrics: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IncrementStats counts a cache hit or miss of an image request. It only
|
||||
// adds to the cache's counters in memory, so it never waits for the
|
||||
// database or fails.
|
||||
func (c *Cache) IncrementStats(hit bool) {
|
||||
if hit {
|
||||
c.cacheHits.Inc()
|
||||
} else {
|
||||
c.cacheMisses.Inc()
|
||||
}
|
||||
}
|
||||
|
||||
// IncrementSourceStats counts whether a variant being made or checked found
|
||||
// its source in the cache. Like IncrementStats, it never waits or fails.
|
||||
func (c *Cache) IncrementSourceStats(hit bool) {
|
||||
if hit {
|
||||
c.sourceCacheHits.Inc()
|
||||
} else {
|
||||
c.sourceCacheMisses.Inc()
|
||||
}
|
||||
}
|
||||
|
||||
// countEviction counts one source or variant of sizeBytes bytes removed by
|
||||
// eviction.
|
||||
func (c *Cache) countEviction(sizeBytes int64) {
|
||||
c.evictions.Inc()
|
||||
c.evictedBytes.Add(float64(sizeBytes))
|
||||
}
|
||||
Reference in new issue
Block a user