Cache stampede, and a lock shorter than the work

A cache entry expiring under load means every request recomputes it at once, and a lock that expires before the computation finishes makes it worse.

// the lock TTL must exceed the work, or two holders exist
$lock = Cache::lock('report:monthly', seconds: 120);

if ($lock->get()) {
    try {
        $value = $this->compute();      // measured p99: 40s
        Cache::put('report:monthly', $value, now()->addHour());
    } finally {
        $lock->release();
    }
} else {
    $value = Cache::get('report:monthly:stale');   // serve stale
}

Two things have to be true: the lock outlives the p99 of the work, and the losers have something to serve. Writing a second, longer-lived key holding the previous value is what makes the waiting path acceptable — without it the losers either block or return an error, and blocking under a stampede is how a pool of workers gets exhausted.