package server import ( "context" "sync" "time" "git.eeqj.de/sneak/routewatch/internal/database" ) const ( // statsRefreshInterval is how often the cached database statistics are // recomputed. The scans behind GetStatsContext grow with the tables, so a // request serves the cached copy instead of running them. statsRefreshInterval = 30 * time.Second // statsComputeTimeout bounds a single statistics computation so a stuck scan // cannot block the refresh forever. statsComputeTimeout = 20 * time.Second ) // statsFetch computes fresh statistics. It is the expensive database scan that // the cache runs at most once per interval. type statsFetch func(ctx context.Context) (database.Stats, error) // statsCache serves the most recent database statistics and recomputes them at // most once per interval. The first request computes synchronously so it has // real data to return; afterwards requests serve the cached copy immediately // and a stale copy triggers a single background refresh, so no request waits on // the scans. type statsCache struct { fetch statsFetch interval time.Duration now func() time.Time mu sync.Mutex stats database.Stats haveStats bool fetchedAt time.Time refreshing bool } // newStatsCache returns a cache that recomputes statistics with fetch no more // than once per statsRefreshInterval. func newStatsCache(fetch statsFetch) *statsCache { return &statsCache{ fetch: fetch, interval: statsRefreshInterval, now: time.Now, } } // get returns the cached statistics. On the first call it computes them // synchronously and returns any error. Later calls return the cached copy, and // when that copy is older than the interval they start one background refresh. func (c *statsCache) get() (database.Stats, error) { c.mu.Lock() if !c.haveStats { // Cold start: compute once under the lock so concurrent first callers // wait for this single computation rather than each starting their own. stats, err := c.compute() if err != nil { c.mu.Unlock() return database.Stats{}, err } c.store(stats) c.mu.Unlock() return stats, nil } if c.now().Sub(c.fetchedAt) >= c.interval && !c.refreshing { c.refreshing = true go c.refresh() } stats := c.stats c.mu.Unlock() return stats, nil } // refresh recomputes the statistics in the background and replaces the cached // copy. A failed computation leaves the previous copy in place. func (c *statsCache) refresh() { stats, err := c.compute() c.mu.Lock() defer c.mu.Unlock() c.refreshing = false if err == nil { c.store(stats) } } // compute runs the fetch with its own bounded context, independent of any // request, so one request's cancellation cannot abort a shared refresh. func (c *statsCache) compute() (database.Stats, error) { ctx, cancel := context.WithTimeout(context.Background(), statsComputeTimeout) defer cancel() return c.fetch(ctx) } // store records a fresh result. The caller must hold the mutex. func (c *statsCache) store(stats database.Stats) { c.stats = stats c.haveStats = true c.fetchedAt = c.now() }