assertions++; if(!$condition)throw new \RuntimeException('Check failed: '.$message); } public static function newRun(string $project):string { $base=$project.'/runtime'; if(is_link($base))throw new \RuntimeException('Redirected runtime is not allowed.'); if(!is_dir($base)&&!mkdir($base,0700,true))throw new \RuntimeException('Cannot create project runtime.'); $resolved=realpath($base);$expected=realpath($project).DIRECTORY_SEPARATOR.'runtime'; if($resolved!==$expected)throw new \RuntimeException('Runtime must remain inside this project.'); $run=$base.'/run-'.bin2hex(random_bytes(8)); if(!mkdir($run,0700))throw new \RuntimeException('Cannot create new isolated run.'); return $run; } private function pool(string $scene,string $namespace='demo'):FilesystemAdapter { return new FilesystemAdapter($namespace,0,$this->root.'/'.$scene); } private function files(string $scene):int { $path=$this->root.'/'.$scene;if(!is_dir($path))return 0;$count=0; $it=new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path,\FilesystemIterator::SKIP_DOTS)); foreach($it as$file)if($file->isFile())$count++; return $count; } private function scene(string $id,string $title,string $purpose,array $events,array $checks,string $code):array { return compact('id','title','purpose','events','checks','code'); } public function run():array { $scenes=[]; $cache=$this->pool('hit');$calls=0;$events=[]; $loader=function(ItemInterface $item)use(&$calls):array{$calls++;$item->expiresAfter(300);return ['id'=>42,'price'=>12500];}; $a=$cache->get('product.42',$loader,0);$events[]=['action'=>'Первое get','result'=>$a,'callbackCalls'=>$calls]; $b=$cache->get('product.42',$loader,0);$events[]=['action'=>'Повторное get','result'=>$b,'callbackCalls'=>$calls]; $this->expect($a===$b&&$calls===1,'callback runs once for hit'); $this->expect($cache->getItem('product.42')->isHit(),'PSR-6 item confirms hit'); $scenes[]=$this->scene('hit','01 · Callback и попадание','Два чтения карточки товара обращаются к загрузчику только один раз.',$events,['calls'=>1,'reads'=>2,'isHit'=>true], <<<'PHP' $cache = new FilesystemAdapter('catalog_v1', 0, $cacheDirectory); $value = $cache->get('product.42', function (ItemInterface $item): array { $item->expiresAfter(300); return ['id' => 42, 'price' => 12500]; }, 0); // beta=0 makes this particular learning experiment deterministic PHP); $cache=$this->pool('ttl');$calls=0;$events=[]; $loader=function(ItemInterface $item)use(&$calls):array{$calls++;$item->expiresAfter(1);return ['revision'=>$calls];}; $first=$cache->get('summary',$loader,0);$events[]=['action'=>'Записать с TTL 1 секунда','result'=>$first,'callbackCalls'=>$calls]; sleep(2);$expired=!$cache->getItem('summary')->isHit(); $second=$cache->get('summary',$loader,0);$events[]=['action'=>'Прочитать после реального ожидания 2 секунд','result'=>$second,'callbackCalls'=>$calls]; $this->expect($expired&&$calls===2&&$second['revision']===2,'TTL actually expired'); $scenes[]=$this->scene('ttl','02 · Истечение TTL','Короткое время жизни действительно истекает во время PHP-прогона.',$events,['expiredAfterWait'=>true,'calls'=>2,'waitSeconds'=>2], <<<'PHP' $cache->get('summary', function (ItemInterface $item) { $item->expiresAfter(1); return loadSummary(); }, 0); sleep(2); // Only for the small CLI experiment, never in a web request. $miss = !$cache->getItem('summary')->isHit(); PHP); $cache=$this->pool('delete');$price=12500;$calls=0;$events=[]; $loader=function(ItemInterface $item)use(&$price,&$calls):int{$calls++;$item->expiresAfter(300);return $price;}; $events[]=['action'=>'Сохранить первоначальную цену','value'=>$cache->get('product.42',$loader,0)]; $price=14000;$stale=$cache->get('product.42',$loader,0);$events[]=['action'=>'Источник изменён; get пока возвращает кеш','sourcePrice'=>$price,'value'=>$stale]; $deleted=$cache->delete('product.42');$fresh=$cache->get('product.42',$loader,0);$events[]=['action'=>'delete и следующее get','value'=>$fresh,'callbackCalls'=>$calls]; $this->expect($deleted&&$stale===12500&&$fresh===14000&&$calls===2,'delete makes next read refresh'); $scenes[]=$this->scene('delete','03 · Обновление источника и delete','Изменение базы само по себе не обновляет уже сохранённую карточку.',$events,['stalePrice'=>12500,'freshPrice'=>14000,'calls'=>2], <<<'PHP' // First commit the new price in the source of truth. $repository->updatePrice(42, 14000); $cache->delete('product.42'); // The following get() recomputes the value on demand. PHP); $cache=new TagAwareAdapter($this->pool('tags'));$calls=['a'=>0,'b'=>0,'other'=>0];$events=[]; $load=function(string $id,string $tag)use(&$calls):\Closure{return function(ItemInterface $item)use(&$calls,$id,$tag):array{$calls[$id]++;$item->expiresAfter(300);$item->tag($tag);return ['item'=>$id,'revision'=>$calls[$id]];};}; foreach(['a','b','other']as$id)$cache->get($id,$load($id,$id==='other'?'category.9':'category.7'),0); $events[]=['action'=>'Загрузить два объекта category.7 и один category.9','callbackCalls'=>$calls]; $invalidated=$cache->invalidateTags(['category.7']); foreach(['a','b','other']as$id)$cache->get($id,$load($id,$id==='other'?'category.9':'category.7'),0); $events[]=['action'=>'invalidateTags(category.7), затем повторное чтение','callbackCalls'=>$calls]; $this->expect($invalidated&&$calls===['a'=>2,'b'=>2,'other'=>1],'tag invalidates only related items'); $scenes[]=$this->scene('tags','04 · Связанные данные и теги','Одна категория обновляет карточки и список, не затрагивая другую категорию.',$events,['calls'=>$calls], <<<'PHP' $cache = new TagAwareAdapter(new FilesystemAdapter('catalog_v1', 0, $cacheDirectory)); $cache->get('product.42', function (ItemInterface $item) { $item->expiresAfter(300); $item->tag(['product.42', 'category.7']); return loadProduct(42); }, 0); $cache->invalidateTags(['category.7']); PHP); $one=$this->pool('namespace','tenant_a_v1');$two=$this->pool('namespace','tenant_b_v1');$events=[]; $load=static function(string $tenant):\Closure{return static function(ItemInterface $item)use($tenant):array{$item->expiresAfter(300);return ['tenant'=>$tenant,'orders'=>$tenant==='A'?12:7];};}; $a=$one->get('summary',$load('A'),0);$b=$two->get('summary',$load('B'),0);$one->clear(); $events[]=['action'=>'Одинаковый ключ summary в двух namespace','tenantA'=>$a,'tenantB'=>$b]; $events[]=['action'=>'clear только tenant_a_v1','tenantAHit'=>$one->getItem('summary')->isHit(),'tenantBHit'=>$two->getItem('summary')->isHit()]; $this->expect(!$one->getItem('summary')->isHit()&&$two->getItem('summary')->isHit()&&$a!==$b,'namespace isolation'); $scenes[]=$this->scene('namespace','05 · Пространства имён','Одинаковые ключи разных клиентов не смешиваются внутри отдельных пулов.',$events,['tenantAHit'=>false,'tenantBHit'=>true], <<<'PHP' $tenantA = new FilesystemAdapter('tenant_a_v1', 0, $cacheDirectory); $tenantB = new FilesystemAdapter('tenant_b_v1', 0, $cacheDirectory); // Both may use key "summary". Namespace separation does not replace authorization. $tenantA->clear(); // Does not clear tenant_b_v1. PHP); $cache=$this->pool('negative');$calls=0;$events=[]; $loader=function(ItemInterface $item)use(&$calls):?array{$calls++;$item->expiresAfter(30);return null;}; $a=$cache->get('product.404',$loader,0);$b=$cache->get('product.404',$loader,0);$hit=$cache->getItem('product.404')->isHit(); $events[]=['action'=>'Два запроса отсутствующего товара','first'=>$a,'second'=>$b,'callbackCalls'=>$calls,'isHit'=>$hit]; $this->expect($a===null&&$b===null&&$calls===1&&$hit,'null is a cached result, not a miss'); $scenes[]=$this->scene('negative','06 · Отрицательное кеширование','Коротко сохраняем результат «не найдено», чтобы не повторять бесполезный запрос.',$events,['cachedNull'=>true,'calls'=>1,'isHit'=>true], <<<'PHP' $product = $cache->get('product.404', function (ItemInterface $item): ?array { $item->expiresAfter(30); return $repository->find(404); // null means a confirmed missing product. }, 0); // A network failure is different from a successful lookup returning null. PHP); $cache=$this->pool('error');$calls=0;$events=[];$caught=false; try{$cache->get('rates',function(ItemInterface $item)use(&$calls):array{$calls++;$item->expiresAfter(60);throw new \RuntimeException('Учебный источник временно недоступен');},0);} catch(\RuntimeException $e){$caught=true;$events[]=['action'=>'Callback выбрасывает исключение','error'=>$e->getMessage(),'isHit'=>$cache->getItem('rates')->isHit()];} $value=$cache->get('rates',function(ItemInterface $item)use(&$calls):array{$calls++;$item->expiresAfter(60);return ['status'=>'ok','value'=>100];},0); $events[]=['action'=>'Следующая успешная попытка','result'=>$value,'callbackCalls'=>$calls]; $this->expect($caught&&$calls===2&&$value['status']==='ok','exception is not cached as successful value'); $scenes[]=$this->scene('error','07 · Ошибка загрузчика','Исключение не превращается автоматически в успешное кешированное значение.',$events,['errorWasCaught'=>true,'calls'=>2,'nextAttempt'=>'ok'], <<<'PHP' try { $rates = $cache->get('rates', function (ItemInterface $item) { $item->expiresAfter(60); return $api->fetchRates(); // May throw; handle the failure deliberately. }); } catch (\RuntimeException $error) { // Report temporary unavailability; do not pretend this is a zero rate. } PHP); $cache=$this->pool('beta');$calls=0;$events=[]; $loader=function(ItemInterface $item)use(&$calls):array{$calls++;$item->expiresAfter(300);return ['revision'=>$calls];}; foreach([0.0,0.0,INF,0.0]as$beta){$value=$cache->get('popular',$loader,$beta);$events[]=['action'=>'get с beta '.(is_infinite($beta)?'INF':'0'),'result'=>$value,'callbackCalls'=>$calls];} $this->expect($calls===2&&$events[1]['result']['revision']===1&&$events[2]['result']['revision']===2,'beta zero hit and INF recompute'); $scenes[]=$this->scene('beta','08 · Управляемый пересчёт beta','Сравниваем детерминированные крайние режимы; вероятностную конкуренцию здесь не измеряем.',$events,['calls'=>2,'testedBeta'=>['0','INF'],'concurrencyBenchmark'=>false], <<<'PHP' $value = $cache->get('popular', $loader, 0); // Disable probabilistic early recompute. $fresh = $cache->get('popular', $loader, INF); // Force recompute on this get(). // Default beta=1 is probabilistic; test it under your actual workload. PHP); $cache=$this->pool('prune'); $short=$cache->getItem('short');$short->set('expires soon')->expiresAfter(1);$cache->save($short); $long=$cache->getItem('long');$long->set('keep')->expiresAfter(300);$cache->save($long); $before=$this->files('prune');sleep(2);$pruned=$cache->prune();$after=$this->files('prune');$events=[]; $events[]=['action'=>'Записать короткую и длинную запись','files'=>$before]; $events[]=['action'=>'После 2 секунд выполнить prune','success'=>$pruned,'files'=>$after,'longIsHit'=>$cache->getItem('long')->isHit()]; $this->expect($pruned&&$before===2&&$after===1&&$cache->getItem('long')->isHit(),'prune removes expired file only'); $scenes[]=$this->scene('prune','09 · Prune и физическая очистка','Истечение срока и освобождение файлов на диске — разные события.',$events,['beforeFiles'=>2,'afterFiles'=>1,'liveItemRetained'=>true], <<<'PHP' $cache = new FilesystemAdapter('catalog_v1', 0, $cacheDirectory); $success = $cache->prune(); // Remove expired entries, keep valid data. // Run from a dedicated CLI job with write access to this specific cache directory. PHP); return ['component'=>'symfony/cache','version'=>self::VERSION,'kind'=>'recorded-real-php-run','adapter'=>'FilesystemAdapter + TagAwareAdapter','scenarios'=>$scenes,'assertions'=>$this->assertions,'externalServicesUsed'=>false,'timingBenchmark'=>false]; } }