Run the reconciliation pass at startup and once a day (closes #250)
check / check (push) Waiting to run

The pass makes a database query for every cached file and ran on every
eviction tick, so on a large cache one pass could outlast the tick and
the next started at once. It now runs on a timer of its own that starts
when a pass ends, a day long. Eviction and the removal of expired
negative cache rows keep the eviction interval.

Model: opus-5-5
This commit is contained in:
2026-10-15 20:43:30 +00:00
parent af6ab4a668
commit b984b27cf8
3 changed files with 59 additions and 40 deletions
+1 -1
View File
@@ -168,7 +168,7 @@ references rather than using filesystem refcounting.
Source and transformed image files, and the `.meta` file beside each transformed Source and transformed image files, and the `.meta` file beside each transformed
image, are written under a temporary name, synced to disk, and only then renamed image, are written under a temporary name, synced to disk, and only then renamed
into place, so a crash leaves no file under its own name with part of its data. into place, so a crash leaves no file under its own name with part of its data.
When pixa starts, and every five minutes after, it removes each cached source or When pixa starts, and once a day after, it removes each cached source or
transformed image whose file is empty, or not the size the database records for transformed image whose file is empty, or not the size the database records for
it, with a warning naming the file; the next request for it fetches the source it, with a warning naming the file; the next request for it fetches the source
or converts the image again. or converts the image again.
+17 -7
View File
@@ -30,6 +30,16 @@ P2: security: per-origin rate limiting
# Completed Steps # Completed Steps
- 2026-10-15 the reconciliation pass runs at startup and then once a day (closes
#250): it ran on every eviction tick, every 5 minutes in pixad, and makes a
database query for every cached file, on the one connection requests also use.
On a large cache a pass could take longer than 5 minutes, and the next one
then started at once, so the passes never stopped. The next pass now starts a
day after the previous one ends, so passes never run back to back. Eviction
and the removal of expired negative cache rows keep their 5-minute tick, and
eviction also runs after each reconciliation pass. A variant file whose
database row failed to be written is now counted in the cache size up to about
a day later, rather than 5 minutes.
- 2026-10-15 the canonical files are re-vendored from `sneak/prompts` commit - 2026-10-15 the canonical files are re-vendored from `sneak/prompts` commit
`e3f79c4` (closes #221): `.dockerignore`, `.editorconfig`, `e3f79c4` (closes #221): `.dockerignore`, `.editorconfig`,
`.gitea/workflows/check.yml`, `.gitignore`, `.golangci.yml`, `.gitea/workflows/check.yml`, `.gitignore`, `.golangci.yml`,
@@ -81,13 +91,13 @@ P2: security: per-origin rate limiting
each transformed image's `.meta` file, are synced to disk before they are each transformed image's `.meta` file, are synced to disk before they are
renamed into place, and their directory after; the `.meta` file is written renamed into place, and their directory after; the `.meta` file is written
before the image, and a failed directory sync removes the renamed file. The before the image, and a failed directory sync removes the renamed file. The
reconciliation pass, at startup and every five minutes, removes a cached reconciliation pass, at startup and once a day, removes a cached source or
source or transformed image whose file is empty, or not the size the database transformed image whose file is empty, or not the size the database records
records for it, with its rows and a warning naming the file, so the next for it, with its rows and a warning naming the file, so the next request
request fetches the source or converts the image again; requests compare no fetches the source or converts the image again; requests compare no sizes.
sizes. Storing a source over a file of another size replaces that file. A Storing a source over a file of another size replaces that file. A source that
source that fails to decode is not removed for that alone: a damaged upstream fails to decode is not removed for that alone: a damaged upstream image would
image would then be fetched on every request. then be fetched on every request.
- 2026-10-15 a transcode stops once its request's `downstream_timeout` has - 2026-10-15 a transcode stops once its request's `downstream_timeout` has
ended, unless it has begun encoding (closes #243): the image processor looked ended, unless it has begun encoding (closes #243): the image processor looked
at its context only while it waited for a processing slot, so an image was at its context only while it waited for a processing slot, so an image was
+41 -32
View File
@@ -18,6 +18,11 @@ import (
// write-pressure wakeups triggered by stores. // write-pressure wakeups triggered by stores.
const DefaultEvictionInterval = 5 * time.Minute const DefaultEvictionInterval = 5 * time.Minute
// reconciliationInterval is how long after one reconciliation pass ends
// the next one starts. A pass reads a row for every cached file, so its
// cost grows with the cache, and it runs this rarely for that reason.
const reconciliationInterval = 24 * time.Hour
// evictionBatchSize is how many LRU candidates of each class (variants // evictionBatchSize is how many LRU candidates of each class (variants
// and source blobs) one eviction pass fetches from the database. // and source blobs) one eviction pass fetches from the database.
const evictionBatchSize = 100 const evictionBatchSize = 100
@@ -437,15 +442,17 @@ func (c *Cache) notifyWritePressure() {
} }
} }
// StartEviction launches the background eviction goroutine, which // StartEviction launches the background eviction goroutine. At startup
// reconciles the database accounting with the cache directories and // it reconciles the database accounting with the cache directories,
// removes the expired negative cache rows at startup and again on every // removes the expired negative cache rows and evicts to the configured
// periodic tick thereafter, and evicts to the configured limit on the // limit. After that it reconciles again a day after each reconciliation
// given periodic interval and on write-pressure notifications. On a // pass ends, removes the expired negative cache rows on every tick of
// disabled cache it only removes the expired negative cache rows, as // the given interval, and evicts to the limit on every tick, after each
// the negative cache is used either way. It is a no-op when already // reconciliation pass and on write-pressure notifications. On a disabled
// started. The goroutine outlives the caller, so it runs with its own // cache it only removes the expired negative cache rows, as the negative
// context, which StopEviction cancels. // 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) { func (c *Cache) StartEviction(interval time.Duration) {
if c.evictionCancel != nil { if c.evictionCancel != nil {
return return
@@ -486,6 +493,16 @@ func (c *Cache) evictionLoop(ctx context.Context, interval time.Duration) {
c.runCleanExpired(ctx) c.runCleanExpired(ctx)
c.runEvictionPass(ctx) c.runEvictionPass(ctx)
// Reconciliation walks the cache directories and reads a row for
// every file, so it runs on a timer of its own rather than on the
// periodic tick or on write-pressure wakeups. The timer starts only
// once a pass has ended, so passes never run back to back, however
// long one takes. Running it daily, not just at startup, bounds how
// long a store's best-effort accounting insert can stay silently
// unaccounted for to about one day, plus the time the passes take.
reconciliationTimer := time.NewTimer(reconciliationInterval)
defer reconciliationTimer.Stop()
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
defer ticker.Stop() defer ticker.Stop()
@@ -493,17 +510,10 @@ func (c *Cache) evictionLoop(ctx context.Context, interval time.Duration) {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-ticker.C: case <-reconciliationTimer.C:
// Reconciliation walks the cache directories, so it only
// runs on the periodic ticker rather than on every
// write-pressure wakeup, keeping it off the per-store hot
// path. Reusing the eviction interval itself (rather than a
// separate, longer one) is a deliberate choice: it is the
// simplest option that still bounds how long a store's
// best-effort accounting insert can stay silently
// unaccounted for to one interval, on a process that is
// already running this loop regardless.
c.runReconciliationPass(ctx) c.runReconciliationPass(ctx)
reconciliationTimer.Reset(reconciliationInterval)
case <-ticker.C:
c.runCleanExpired(ctx) c.runCleanExpired(ctx)
case <-c.evictionPressure: case <-c.evictionPressure:
} }
@@ -556,19 +566,18 @@ func (c *Cache) runCleanExpired(ctx context.Context) {
// reconcileAccounting synchronizes the database size accounting with // reconcileAccounting synchronizes the database size accounting with
// the actual contents of the cache directories. It runs at startup and // the actual contents of the cache directories. It runs at startup and
// again on every periodic eviction tick thereafter, off the request // then once a day, off the request hot path: it adopts variant files
// hot path: it adopts variant files that predate the accounting table // that predate the accounting table (or whose accounting insert failed,
// (or whose accounting insert failed, e.g. StoreVariant's best-effort // e.g. StoreVariant's best-effort insert under transient DB contention),
// insert under transient DB contention), drops accounting rows whose // drops accounting rows whose files are missing, removes source blob
// files are missing, removes source blob files the database does not // files the database does not know (and rows whose files are gone),
// know (and rows whose files are gone), removes source and variant files // removes source and variant files that are empty or not the size their
// that are empty or not the size their rows record, sweeps stale temp // rows record, sweeps stale temp files left behind by crashed writes,
// files left behind by crashed writes, and last checks the total cache // and last checks the total cache usage against the tables. It reads the
// usage against the tables. It reads the tables a page at a time. // tables a page at a time. Running it daily, not just once, bounds how
// Running it periodically, not just once, bounds how long such drift can // long such drift can accumulate unaccounted for on a long-running
// accumulate unaccounted for on a long-running process to one eviction // process to about one day. Once ctx is cancelled, it stops at the next
// interval. Once ctx is cancelled, it stops at the next file or row and // file or row and returns ctx's error.
// returns ctx's error.
func (c *Cache) reconcileAccounting(ctx context.Context) error { func (c *Cache) reconcileAccounting(ctx context.Context) error {
if c.disabled { if c.disabled {
return nil return nil