Atomic Locks

Atomic Locks and Cache Failover

Cache::lock('report', 30) expires after 30 s even if its holder dies. get() returns true to one caller only, block(5) waits 5 s and then throws LockTimeoutException, and owner() gives a token for Cache::restoreLock('report', $owner)->release() in a queued job. The classic use is stampede protection: when a hot key expires, every concurrent request runs the query.

routes/console.php: rebuilding the report, optionally under a lockPHP
Artisan::command('shop:top {--lock}', function () {
    $t = hrtime(true);
    $get = fn () => Cache::remember('shop:top3', 600,
        fn () => Cache::increment('shop:builds') ? ShopReports::bestsellers() : []);
    $this->option('lock')
        ? Cache::get('shop:top3') ?? Cache::lock('shop:top3:build', 30)->block(15, $get)
        : $get();
    $this->line(round((hrtime(true) - $t) / 1e6));
});
Six processes hit a cold key at oncePHP
for opt in '' --lock; do
  php artisan cache:forget shop:top3 -q; php artisan cache:forget shop:builds -q
  seq 6 | xargs -P6 -I{} php artisan shop:top $opt | sort -n | xargs echo "${opt:-none}: ms"
  php artisan tinker --execute "echo 'builds ', Cache::get('shop:builds'), PHP_EOL;"
done
Output
none: ms 1290 1353 1441 1455 1476 1495
builds 6
--lock: ms 1013 1014 1014 1014 1014 1021
builds 1

Unlocked, MySQL 524 ran the report six times at once; locked, one process built it while five waited. Cache::withoutOverlapping() and Cache::funnel('reports')->limit(3) wrap the same machinery.

A failover store tries its stores in order. With 'resilient' => ['driver' => 'failover', 'stores' => ['redis', 'database']] in config/cache.php and Redis 2,763 stopped, Cache::store('resilient')->put('shop:banner', 'Autumn sale', 600) still succeeded: a CacheFailedOver listener reported store redis with a StreamInitException, the value landed in the cache table, and get() returned it. Send that event to your monitoring, because locks taken in the fallback store do not exclude processes still reaching Redis.