You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

196 lines
6.6 KiB

<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Event;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
/**
* Pulls magazine indices from relays within a wall-clock budget and persists them to {@see MagazineIndexStore}.
*/
final class MagazineRefresher
{
private const RELAY_STAMP_KEY = 'mag_relay_v1';
public function __construct(
private readonly NostrClient $nostrClient,
private readonly MagazineIndexStore $store,
private readonly ParameterBagInterface $params,
private readonly LoggerInterface $logger,
private readonly CacheItemPoolInterface $appCache,
) {
}
/**
* Fetches the root index then each category index until $budgetSeconds elapses. $preferSlugs
* are requested first (e.g. current /cat route) so they are less likely to miss the budget.
*
* @param (callable(string, array<string, int|string|bool|null>): void)|null $onProgress
* Phases: `before_root`, `after_root` (total_steps, step, slug_count), `category_fetched` (step, total_steps, slug)
*/
public function refreshFromRelays(int $budgetSeconds = 8, array $preferSlugs = [], ?callable $onProgress = null): void
{
$budgetSeconds = max(1, min(30, $budgetSeconds));
$deadline = microtime(true) + $budgetSeconds;
$npub = (string) $this->params->get('npub');
$dTag = (string) $this->params->get('d_tag');
// Do not set max_execution_time to the *remaining* soft budget: PHP resets the timer, so
// after a 6s root fetch, "2s left" would become a 2s hard cap for the *next* relay I/O
// (e.g. slow TLS) and can fatal. Cap once with headroom; the $deadline loop limits work.
$this->applyExecutionTimeCap($budgetSeconds);
$defaultRelay = (string) $this->params->get('default_relay');
$relayLabel = (string) (parse_url($defaultRelay, \PHP_URL_HOST) ?: $defaultRelay);
$onProgress?->__invoke('before_root', []);
$root = $this->nostrClient->getMagazineIndex($npub, $dTag);
if ($root === null) {
$onProgress?->__invoke('aborted', ['reason' => 'no_root']);
$this->logger->warning(sprintf(
'MagazineRefresher: root index not returned (tried from %s)',
$relayLabel
), [
'd_tag' => $dTag,
'relay' => $defaultRelay,
]);
return;
}
$this->store->putRoot($npub, $dTag, $root);
$slugs = $this->orderedCategorySlugs($this->categorySlugsFromRoot($root), $preferSlugs);
$totalSteps = 1 + \count($slugs);
$onProgress?->__invoke('after_root', [
'total_steps' => $totalSteps,
'step' => 1,
'slug_count' => \count($slugs),
]);
$step = 1;
foreach ($slugs as $slug) {
if (microtime(true) >= $deadline) {
$this->logger->notice('MagazineRefresher: stopped at time budget; some categories not fetched', [
'unprocessed_from' => $slug,
]);
break;
}
try {
$cat = $this->nostrClient->getMagazineIndex($npub, $slug);
if ($cat !== null) {
$this->store->putCategory($slug, $cat);
}
} catch (\Throwable $e) {
$this->logger->error(sprintf(
'MagazineRefresher: category fetch failed (relays from %s): %s',
$relayLabel,
$e->getMessage()
), [
'slug' => $slug,
'message' => $e->getMessage(),
'relay' => $defaultRelay,
]);
} finally {
++$step;
$onProgress?->__invoke('category_fetched', [
'step' => $step,
'total_steps' => $totalSteps,
'slug' => $slug,
]);
}
}
$this->touchLastRelayTime();
}
/**
* @throws InvalidArgumentException
*/
public function getSecondsSinceLastRelayRun(): ?int
{
try {
$item = $this->appCache->getItem(self::RELAY_STAMP_KEY);
} catch (InvalidArgumentException) {
return null;
}
if (!$item->isHit()) {
return null;
}
return time() - (int) $item->get();
}
/**
* Child category indices are kind 30040; each root "a" tag is a NIP-33 address
* kind:hexpubkey:d-identifier. The third segment is the child #d (e.g. the long
* newsroom-…-category-… string), not a shortened title.
*
* @return list<string>
*/
private function categorySlugsFromRoot(Event $root): array
{
$slugs = [];
foreach ($root->getTags() as $tag) {
if (($tag[0] ?? null) !== 'a' || !isset($tag[1])) {
continue;
}
$parts = explode(':', (string) $tag[1], 3);
if (\count($parts) < 3) {
continue;
}
$s = trim((string) end($parts));
if ($s !== '' && !\in_array($s, $slugs, true)) {
$slugs[] = $s;
}
}
return $slugs;
}
/**
* @param list<string> $allFromRoot
* @param list<string> $prefer
* @return list<string>
*/
private function orderedCategorySlugs(array $allFromRoot, array $prefer): array
{
$prefer = array_values(array_filter($prefer, static function (string $s): bool {
return $s !== '';
}));
$out = $prefer;
foreach ($allFromRoot as $s) {
if (!\in_array($s, $out, true)) {
$out[] = $s;
}
}
return $out;
}
/**
* @throws InvalidArgumentException
*/
private function touchLastRelayTime(): void
{
$item = $this->appCache->getItem(self::RELAY_STAMP_KEY);
$item->set((string) time());
$item->expiresAfter(86_400);
$this->appCache->save($item);
}
/**
* One generous ceiling for PHP so relay/WebSocket I/O in one Nostr call can outlast the soft
* $deadline by seconds without a fatal, while the loop still stops *starting* new fetches in time.
*/
private function applyExecutionTimeCap(int $budgetSeconds): void
{
$sec = max(30, min(120, $budgetSeconds + 30));
@set_time_limit($sec);
@ini_set('max_execution_time', (string) $sec);
}
}