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.
819 lines
37 KiB
819 lines
37 KiB
<?php |
|
|
|
declare(strict_types=1); |
|
|
|
namespace App\Command; |
|
|
|
use App\Entity\Article; |
|
use App\Repository\ArticleRepository; |
|
use App\Repository\FeaturedAuthorRepository; |
|
use App\Service\ArticleCommentThreadLoader; |
|
use App\Service\CacheService; |
|
use App\Service\FeaturedAuthorSync; |
|
use App\Service\MagazineContentService; |
|
use App\Service\Nip05VerificationService; |
|
use App\Service\HighlightSyncService; |
|
use App\Service\MagazineRefresher; |
|
use App\Service\Nip09DeletionApplier; |
|
use App\Service\NostrClient; |
|
use App\Service\NostrKeyHelper; |
|
use App\Service\ProfileIdentityLinksBuilder; |
|
use Psr\Log\LoggerInterface; |
|
use Symfony\Component\Console\Attribute\AsCommand; |
|
use Symfony\Component\Console\Command\Command; |
|
use Symfony\Component\Console\Helper\Helper; |
|
use Symfony\Component\Console\Helper\ProgressBar; |
|
use Symfony\Component\Console\Input\InputInterface; |
|
use Symfony\Component\Console\Input\InputOption; |
|
use Symfony\Component\Console\Output\OutputInterface; |
|
use Symfony\Component\Console\Style\SymfonyStyle; |
|
use Symfony\Component\Console\Terminal; |
|
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; |
|
|
|
/** |
|
* Prewarms magazine index cache, author metadata cache, optional comment thread cache, and |
|
* kind-9802 highlights into MySQL. Comments remain cache-only; highlights use `article_highlight`. |
|
*/ |
|
#[AsCommand( |
|
name: 'app:prewarm', |
|
description: 'Refresh magazine indices, NIP-09 deletions, profile metadata, NIP-05, comment caches, and highlight DB', |
|
)] |
|
final class PrewarmCommand extends Command |
|
{ |
|
public function __construct( |
|
private readonly MagazineRefresher $magazineRefresher, |
|
private readonly Nip09DeletionApplier $nip09DeletionApplier, |
|
private readonly CacheService $cacheService, |
|
private readonly NostrClient $nostrClient, |
|
private readonly ArticleRepository $articleRepository, |
|
private readonly MagazineContentService $magazineContent, |
|
private readonly ArticleCommentThreadLoader $commentThreadLoader, |
|
private readonly ParameterBagInterface $params, |
|
private readonly LoggerInterface $logger, |
|
private readonly FeaturedAuthorSync $featuredAuthorSync, |
|
private readonly Nip05VerificationService $nip05Verification, |
|
private readonly ProfileIdentityLinksBuilder $profileIdentityLinks, |
|
private readonly FeaturedAuthorRepository $featuredAuthorRepository, |
|
private readonly HighlightSyncService $highlightSyncService, |
|
private readonly NostrKeyHelper $nostrKeyHelper, |
|
) { |
|
parent::__construct(); |
|
} |
|
|
|
protected function configure(): void |
|
{ |
|
$this |
|
->addOption('no-magazine', null, InputOption::VALUE_NONE, 'Skip magazine 30040 index fetch') |
|
->addOption('no-deletions', null, InputOption::VALUE_NONE, 'Skip NIP-09 kind 5 deletion sync (articles + event rows for stored kinds)') |
|
->addOption('deletion-since', null, InputOption::VALUE_REQUIRED, 'strtotime() window start for kind 5 fetch', '-2 month') |
|
->addOption('no-metadata', null, InputOption::VALUE_NONE, 'Skip batched kind-0 profile prewarm (MySQL event table)') |
|
->addOption('no-comments', null, InputOption::VALUE_NONE, 'Skip comment thread cache') |
|
->addOption('magazine-budget', null, InputOption::VALUE_REQUIRED, 'Seconds wall time for the category 30040 phase only (root fetch is not counted; capped at 600s). If many slugs, raise this or set MAGAZINE_PREWARM_PREFER_SLUGS', '90') |
|
->addOption('metadata-limit', null, InputOption::VALUE_REQUIRED, 'Max distinct author pubkeys to warm (0 = all)', '0') |
|
->addOption('metadata-batch', null, InputOption::VALUE_REQUIRED, 'Kind-0 metadata: pubkeys per Nostr REQ (batched)', '50') |
|
->addOption('comments-max', null, InputOption::VALUE_REQUIRED, 'Newest N magazine category articles to warm comment cache for (0 = all, order: createdAt DESC; excludes generic /articles feed-only rows)', '10') |
|
->addOption('comments-budget', null, InputOption::VALUE_REQUIRED, 'Wall-clock seconds for the whole comments phase (Nostr fetches are slow; a single long thread can exceed a short budget; use 1200+ if prewarming many articles)', '600') |
|
->addOption('no-highlights', null, InputOption::VALUE_NONE, 'Skip kind-9802 highlight fetch → MySQL') |
|
->addOption('highlights-max', null, InputOption::VALUE_REQUIRED, 'Newest N magazine category articles to sync highlights for (0 = all; each Nostr fetch is slow — default 25 keeps prewarm bounded)', '25') |
|
->addOption('highlights-budget', null, InputOption::VALUE_REQUIRED, 'Wall-clock seconds for the highlight sync phase', '600'); |
|
} |
|
|
|
protected function execute(InputInterface $input, OutputInterface $output): int |
|
{ |
|
$this->disableCliExecutionTimeLimit(); |
|
$socketTo = (int) $this->params->get('nostr_relay_request_timeout_sec'); |
|
if ($socketTo > 0) { |
|
// Align PHP stream layer with Nostr WebSocket timeout (avoids 60s default stalling a relay step). |
|
ini_set('default_socket_timeout', (string) $socketTo); |
|
} |
|
|
|
$io = new SymfonyStyle($input, $output); |
|
|
|
if (!$input->getOption('no-magazine')) { |
|
$budget = max(1, (int) $input->getOption('magazine-budget')); |
|
$io->section('Magazine index (kinds 30040)'); |
|
try { |
|
$hb = (object) ['silent' => false]; |
|
$bar = null; |
|
$this->runWithPcntlHeartbeat($io, 5, function () use ($io, $budget, &$bar, $hb): void { |
|
$this->magazineRefresher->refreshFromRelays($budget, [], function (string $phase, array $p) use ($io, &$bar, $hb): void { |
|
if ($phase === 'before_root') { |
|
$io->writeln(' <comment>Fetching magazine root index (30040) from relays…</comment>'); |
|
} elseif ($phase === 'aborted') { |
|
$hb->silent = true; |
|
$this->cancelPcntlAlarm(); |
|
} elseif ($phase === 'after_root') { |
|
$hb->silent = true; |
|
$this->cancelPcntlAlarm(); |
|
$planned = $p['slugs'] ?? null; |
|
if (!\is_array($planned)) { |
|
$planned = []; |
|
} |
|
if ($planned === []) { |
|
$io->writeln(' <comment>Magazine root has no child <info>a</info> tag categories; only the root index was stored.</comment>'); |
|
} else { |
|
$n = \count($planned); |
|
$io->writeln(sprintf(' <comment>Magazine child categories in root</comment> <info>(%d)</info><comment>:</comment>', $n)); |
|
foreach ($planned as $slug) { |
|
$s = (string) $slug; |
|
if (strlen($s) > 120) { |
|
$s = substr($s, 0, 117).'…'; |
|
} |
|
$io->writeln(sprintf(' · <info>%s</info>', $s)); |
|
} |
|
$io->writeln(sprintf( |
|
' <comment>Progress bar: <info>%d</info> steps = <info>1</info> (root) + <info>%d</info> (categor%s).</comment>', |
|
1 + $n, |
|
$n, |
|
$n === 1 ? 'y' : 'ies' |
|
)); |
|
} |
|
$bar = $this->createPrewarmProgressBar( |
|
$io, |
|
max(1, (int) ($p['total_steps'] ?? 1)), |
|
'Magazine index', |
|
); |
|
$bar->start(); |
|
$bar->setProgress(1); |
|
} elseif ($phase === 'category_fetched' && $bar !== null) { |
|
$bar->advance(1); |
|
$slug = (string) ($p['slug'] ?? ''); |
|
$tSlug = $slug; |
|
if (strlen($tSlug) > 70) { |
|
$tSlug = substr($tSlug, 0, 67).'…'; |
|
} |
|
$bar->setMessage($tSlug !== '' ? 'Category: '.$tSlug : 'Category'); |
|
if ($tSlug !== '') { |
|
$ci = (int) ($p['category_index'] ?? 0); |
|
$ct = (int) ($p['category_total'] ?? 0); |
|
if ($ci > 0 && $ct > 0) { |
|
$io->writeln(sprintf( |
|
' <info>[category %d/%d]</info> <comment>Fetched category index</comment> — <info>%s</info>', |
|
$ci, |
|
$ct, |
|
$tSlug |
|
)); |
|
} else { |
|
$st = (int) ($p['step'] ?? 0); |
|
$tot = (int) ($p['total_steps'] ?? 0); |
|
if ($tot > 0) { |
|
$io->writeln(sprintf( |
|
' <info>[%d/%d]</info> <comment>Fetched category index</comment> — <info>%s</info>', |
|
$st, |
|
$tot, |
|
$tSlug |
|
)); |
|
} else { |
|
$io->writeln(sprintf(' <comment>Fetched category index</comment> — <info>%s</info>', $tSlug)); |
|
} |
|
} |
|
} |
|
} |
|
}); |
|
}, $hb); |
|
if ($bar !== null) { |
|
$bar->finish(); |
|
$io->newLine(2); |
|
} |
|
$io->success('Magazine indices refreshed (within budget).'); |
|
} catch (\Throwable $e) { |
|
$this->logger->error('app:prewarm magazine failed', ['e' => $e]); |
|
$io->warning('Magazine refresh failed: '.$e->getMessage()); |
|
} finally { |
|
$this->cancelPcntlAlarm(); |
|
} |
|
} else { |
|
$io->note('Skipping magazine (--no-magazine).'); |
|
} |
|
|
|
// MagazineRefresher used to set max_execution_time (~2×budget); re-assert unlimited before |
|
// any later Nostr phase (long-form can exceed that old cap and was causing max-time fatals). |
|
$this->disableCliExecutionTimeLimit(); |
|
|
|
$io->section('Long-form in DB (category `a` tags — refresh from Nostr)'); |
|
try { |
|
$n = $this->magazineContent->ingestLongformForAllMagazineCategories(); |
|
if ($n === 0) { |
|
$io->note('No category `a` coordinates in the magazine store (or empty category indices).'); |
|
} else { |
|
$io->writeln(sprintf('Fetched latest long-form for <info>%d</info> coordinate(s) (new rows + NIP-33 updates).', $n)); |
|
} |
|
$report = $this->magazineContent->buildCategoryArticleDbCoverageReport(); |
|
$missingCoords = $this->magazineContent->missingInDbCoordinatesFromCoverageReport($report); |
|
$attempt = 0; |
|
while ($missingCoords !== [] && $attempt < 2) { |
|
$attempt++; |
|
$io->writeln(sprintf( |
|
'Retrying unresolved category coordinates from relays (attempt <info>%d</info>, coordinates: <comment>%d</comment>)…', |
|
$attempt, |
|
\count($missingCoords) |
|
)); |
|
$this->nostrClient->ingestLongformForCategoryCoordinates($missingCoords); |
|
$report = $this->magazineContent->buildCategoryArticleDbCoverageReport(); |
|
$missingCoords = $this->magazineContent->missingInDbCoordinatesFromCoverageReport($report); |
|
} |
|
$this->printCategoryCoverageSummary($io, $report); |
|
} catch (\Throwable $e) { |
|
$this->logger->error('app:prewarm longform ingest failed', ['e' => $e]); |
|
$io->warning('Long-form backfill failed: '.$e->getMessage()); |
|
} |
|
|
|
$io->section('Featured authors / NIP-05 source list'); |
|
try { |
|
$fa = $this->featuredAuthorSync->reconcileListedAuthorsFromMagazineCategories(); |
|
$io->writeln(sprintf( |
|
'Derived from category `a` tags: listed now <info>%d</info> · added <info>%d</info> · relisted <info>%d</info> · unlisted <comment>%d</comment>', |
|
$fa['listed_total'], |
|
$fa['added'], |
|
$fa['relisted'], |
|
$fa['unlisted'], |
|
)); |
|
} catch (\Throwable $e) { |
|
$this->logger->warning('app:prewarm featured author reconcile', ['e' => $e->getMessage()]); |
|
$io->warning('Featured author reconcile failed: '.$e->getMessage()); |
|
} |
|
|
|
$this->disableCliExecutionTimeLimit(); |
|
|
|
if (!$input->getOption('no-deletions')) { |
|
$io->section('NIP-09 deletions (kind 5 → 30023/30024 / 30040)'); |
|
$sinceStr = (string) $input->getOption('deletion-since'); |
|
$since = strtotime($sinceStr); |
|
if ($since === false) { |
|
$since = strtotime('-2 month'); |
|
} |
|
$until = time(); |
|
$deletionPubkeys = []; |
|
foreach ($this->articleRepository->findDistinctAuthorPubkeys() as $pk) { |
|
if (\is_string($pk) && 64 === \strlen($pk)) { |
|
$deletionPubkeys[] = $pk; |
|
} |
|
} |
|
$npubParam = (string) $this->params->get('npub'); |
|
if (str_starts_with($npubParam, 'npub')) { |
|
try { |
|
$sitePk = $this->nostrKeyHelper->convertToHex($npubParam); |
|
if ($sitePk !== '' && 64 === \strlen($sitePk) && !\in_array($sitePk, $deletionPubkeys, true)) { |
|
$deletionPubkeys[] = $sitePk; |
|
} |
|
} catch (\Throwable) { |
|
} |
|
} |
|
if ($deletionPubkeys === []) { |
|
$io->note('No author pubkeys; skipping kind 5 deletion fetch.'); |
|
} else { |
|
$chunks = array_chunk($deletionPubkeys, 40); |
|
$nChunks = \count($chunks); |
|
$nipBar = $this->createPrewarmProgressBar($io, max(1, $nChunks), 'NIP-09 kind 5 (by author batch)'); |
|
$nipBar->start(); |
|
try { |
|
$kind5 = $this->nostrClient->fetchKind5DeletionEventsForAuthors( |
|
$deletionPubkeys, |
|
$since, |
|
$until, |
|
40, |
|
function (int $index, int $totalChunks, int $pubkeyCount) use ($nipBar): void { |
|
$nipBar->advance(1); |
|
$nipBar->setMessage(sprintf('Batch %d/%d · %d pubkeys', $index, $totalChunks, $pubkeyCount)); |
|
} |
|
); |
|
} finally { |
|
$nipBar->finish(); |
|
$io->newLine(2); |
|
} |
|
try { |
|
$st = $this->nip09DeletionApplier->apply($kind5); |
|
$io->writeln(sprintf( |
|
'Kind 5 events: <info>%d</info> (deduped). NIP-23 long-form in DB (30023/30024) removed: <info>%d</info>. Magazine index in cache (30040) removed: root <info>%d</info>, category <info>%d</info>.', |
|
\count($kind5), |
|
$st['articles_removed'], |
|
$st['magazine_roots'], |
|
$st['magazine_categories'] |
|
)); |
|
} catch (\Throwable $e) { |
|
$this->logger->error('app:prewarm NIP-09 failed', ['exception' => $e]); |
|
$io->warning('NIP-09 step failed: '.$e->getMessage()); |
|
} |
|
} |
|
} else { |
|
$io->note('Skipping NIP-09 deletions (--no-deletions).'); |
|
} |
|
|
|
$this->disableCliExecutionTimeLimit(); |
|
|
|
if (!$input->getOption('no-metadata')) { |
|
$io->section('Author metadata (cache)'); |
|
$pubkeys = $this->articleRepository->findDistinctAuthorPubkeys(); |
|
$npubParam = (string) $this->params->get('npub'); |
|
if (str_starts_with($npubParam, 'npub')) { |
|
try { |
|
$sitePk = $this->nostrKeyHelper->convertToHex($npubParam); |
|
if ($sitePk !== '' && !\in_array($sitePk, $pubkeys, true)) { |
|
$pubkeys[] = $sitePk; |
|
} |
|
} catch (\Throwable) { |
|
// ignore bad npub |
|
} |
|
} |
|
$limit = (int) $input->getOption('metadata-limit'); |
|
if ($limit > 0) { |
|
$pubkeys = \array_slice($pubkeys, 0, $limit); |
|
} |
|
$pubkeysSeen = []; |
|
foreach ($pubkeys as $pk) { |
|
if (!\is_string($pk) || 64 !== \strlen($pk)) { |
|
continue; |
|
} |
|
$h = strtolower($pk); |
|
if (ctype_xdigit($h) && !isset($pubkeysSeen[$h])) { |
|
$pubkeysSeen[$h] = true; |
|
} |
|
} |
|
$pubkeys = array_keys($pubkeysSeen); |
|
foreach ($this->featuredAuthorRepository->findAllListedOrderByLocalPart() as $fa) { |
|
$hx = strtolower($fa->getPubkeyHex()); |
|
if (64 === \strlen($hx) && ctype_xdigit($hx) && !isset($pubkeysSeen[$hx])) { |
|
$pubkeys[] = $hx; |
|
$pubkeysSeen[$hx] = true; |
|
} |
|
} |
|
$toWarm = $pubkeys; |
|
$total = \count($toWarm); |
|
$n = 0; |
|
if ($total === 0) { |
|
$io->note('No valid author pubkeys to warm.'); |
|
} else { |
|
$batchSize = max(1, min(200, (int) $input->getOption('metadata-batch'))); |
|
$io->writeln(sprintf( |
|
'Fetching kind-0 metadata: <info>%d</info> author(s) in Nostr requests of up to <info>%d</info> pubkeys each.', |
|
$total, |
|
$batchSize |
|
)); |
|
$bar = $this->createPrewarmProgressBar($io, $total, 'Kind-0 metadata'); |
|
$bar->start(); |
|
try { |
|
foreach (array_chunk($toWarm, $batchSize) as $chunk) { |
|
$fetched = $this->nostrClient->fetchKind0WireEventsForAuthors($chunk, $batchSize); |
|
$n += $this->cacheService->putPrewarmMetadataBatch($chunk, $fetched); |
|
$bar->advance(\count($chunk)); |
|
$p0 = (string) ($chunk[0] ?? ''); |
|
$bar->setMessage('Batch up to · '.substr($p0, 0, 8).'…'); |
|
} |
|
} catch (\Throwable $e) { |
|
$this->logger->error('app:prewarm metadata batch failed', ['exception' => $e]); |
|
$io->error($e->getMessage()); |
|
$bar->finish(); |
|
$io->newLine(2); |
|
|
|
return Command::FAILURE; |
|
} |
|
$bar->finish(); |
|
$io->newLine(2); |
|
} |
|
$io->success(sprintf('Warmed metadata for %d of %d author(s).', $n, $total)); |
|
|
|
if ($toWarm !== []) { |
|
$domain = trim((string) $this->params->get('nip05_domain')); |
|
if ($domain !== '') { |
|
$this->waitForSiteWellKnownBeforeVerification($io, $domain); |
|
} |
|
$io->writeln('Verifying <comment>NIP-05</comment> (HTTPS <comment>/.well-known/nostr.json</comment>, per identifier)…'); |
|
$nt = 0; |
|
$nv = 0; |
|
foreach ($toWarm as $hex) { |
|
if (64 !== \strlen($hex) || !ctype_xdigit($hex)) { |
|
continue; |
|
} |
|
$hex = strtolower($hex); |
|
$npub = $this->nostrKeyHelper->convertPublicKeyToBech32($hex); |
|
$bundle = $this->cacheService->getMetadataBundle($npub); |
|
$rows = $this->profileIdentityLinks->buildNip05($bundle['content'], $bundle['kind0_tags'] ?? []); |
|
$fa = $this->featuredAuthorRepository->findOneByPubkeyHex($hex); |
|
if ($fa !== null && $fa->isListed() && $domain !== '') { |
|
$rows = $this->profileIdentityLinks->mergeSiteNip05IntoList( |
|
$rows, |
|
$fa->getLocalPart().'@'.$domain |
|
); |
|
} |
|
foreach ($rows as $r) { |
|
++$nt; |
|
$label = (string) ($r['label'] ?? ''); |
|
if ($this->nip05Verification->verifyAndCache($hex, $label)) { |
|
++$nv; |
|
} |
|
} |
|
} |
|
$failed = $nt - $nv; |
|
$io->writeln(sprintf( |
|
' <info>%d</info> identifier(s) checked: <info>%d</info> verified, <comment>%d</comment> not verified.', |
|
$nt, |
|
$nv, |
|
$failed |
|
)); |
|
} |
|
} else { |
|
$io->note('Skipping metadata (--no-metadata).'); |
|
} |
|
|
|
if ($input->getOption('no-comments')) { |
|
$io->note('Skipping comments (--no-comments).'); |
|
} else { |
|
$maxArticles = (int) $input->getOption('comments-max'); |
|
|
|
$io->section('Comment / interaction cache'); |
|
$commentBudgetSeconds = max(1, (int) $input->getOption('comments-budget')); |
|
$commentPhaseStart = microtime(true); |
|
$deadline = $commentPhaseStart + $commentBudgetSeconds; |
|
$magazineList = $this->magazineContent->getAllMagazineCategoryArticlesForSyndication(); |
|
if ($maxArticles > 0) { |
|
$magazineList = \array_slice($magazineList, 0, $maxArticles); |
|
} |
|
$articles = $magazineList; |
|
$articleCount = \count($articles); |
|
$w = 0; |
|
if ($articleCount === 0) { |
|
$io->note('No articles in DB to scan for comment cache.'); |
|
} else { |
|
$cBar = $this->createPrewarmProgressBar($io, $articleCount, 'Comment threads'); |
|
$cBar->start(); |
|
try { |
|
/** @var Article $article */ |
|
foreach ($articles as $article) { |
|
if (microtime(true) >= $deadline) { |
|
$io->warning(sprintf( |
|
'Comment phase stopped: comments-budget reached (%s).', |
|
$this->formatCommentBudgetSecondsPair(microtime(true) - $commentPhaseStart, $commentBudgetSeconds), |
|
)); |
|
break; |
|
} |
|
$slug = trim((string) $article->getSlug()); |
|
$pubkey = (string) $article->getPubkey(); |
|
if ($slug === '' || strlen($pubkey) !== 64) { |
|
$cBar->advance(1); |
|
$cBar->setMessage('skip · invalid row'); |
|
|
|
continue; |
|
} |
|
$kind = $article->getKind()?->value ?? 30023; |
|
$coordinate = $kind.':'.$pubkey.':'.$slug; |
|
$msg = $slug; |
|
if (strlen($msg) > 56) { |
|
$msg = substr($msg, 0, 53).'…'; |
|
} |
|
$cBar->setMessage($msg); |
|
$eventHex = (string) ($article->getEventId() ?? ''); |
|
try { |
|
$this->commentThreadLoader->load($coordinate, $eventHex !== '' ? $eventHex : null); |
|
++$w; |
|
} catch (\Throwable $e) { |
|
$this->logger->warning('app:prewarm comment load', ['coord' => $coordinate, 'error' => $e->getMessage()]); |
|
} |
|
$cBar->advance(1); |
|
} |
|
} finally { |
|
$this->finishPrewarmProgressBarWithoutFillingToMax($cBar, $io); |
|
} |
|
} |
|
$io->success(sprintf( |
|
'Warmed comment cache for %d of %d article(s). Comment phase wall time %s.', |
|
$w, |
|
$articleCount, |
|
$this->formatCommentBudgetSecondsPair(microtime(true) - $commentPhaseStart, $commentBudgetSeconds), |
|
)); |
|
} |
|
|
|
if ($input->getOption('no-highlights')) { |
|
$io->note('Skipping highlight DB sync (--no-highlights).'); |
|
|
|
return Command::SUCCESS; |
|
} |
|
|
|
$maxH = (int) $input->getOption('highlights-max'); |
|
$io->section('Highlights (kind 9802 → MySQL)'); |
|
$hBudget = max(1, (int) $input->getOption('highlights-budget')); |
|
$hStart = microtime(true); |
|
$hDeadline = $hStart + $hBudget; |
|
$hList = $this->magazineContent->getAllMagazineCategoryArticlesForSyndication(); |
|
if ($maxH > 0) { |
|
$hList = \array_slice($hList, 0, $maxH); |
|
} |
|
$hCount = \count($hList); |
|
$hW = 0; |
|
$hScanned = 0; |
|
if ($hCount === 0) { |
|
$io->note('No articles in DB to scan for highlights.'); |
|
} else { |
|
$hBar = $this->createPrewarmProgressBar($io, $hCount, 'Kind 9802 highlights'); |
|
$hBar->start(); |
|
try { |
|
/** @var Article $article */ |
|
foreach ($hList as $article) { |
|
if (microtime(true) >= $hDeadline) { |
|
$io->warning(sprintf('Highlight phase stopped: highlights-budget reached (%d s).', $hBudget)); |
|
break; |
|
} |
|
++$hScanned; |
|
$slug = trim((string) $article->getSlug()); |
|
$pubkey = (string) $article->getPubkey(); |
|
if ($slug === '' || strlen($pubkey) !== 64) { |
|
$hBar->advance(1); |
|
$hBar->setMessage('skip · invalid row'); |
|
continue; |
|
} |
|
$tmsg = $slug; |
|
if (strlen($tmsg) > 56) { |
|
$tmsg = substr($tmsg, 0, 53).'…'; |
|
} |
|
$hBar->setMessage($tmsg); |
|
try { |
|
$hW += $this->highlightSyncService->syncForArticle($article); |
|
} catch (\Throwable $e) { |
|
$this->logger->warning('app:prewarm highlight', ['slug' => $slug, 'error' => $e->getMessage()]); |
|
} |
|
$hBar->advance(1); |
|
} |
|
} finally { |
|
$this->finishPrewarmProgressBarWithoutFillingToMax($hBar, $io); |
|
} |
|
} |
|
$io->success(sprintf( |
|
'Highlight rows written/updated: <info>%d</info> (articles scanned: <info>%d</info> of %d, wall time <info>%.0f</info>s / %d s budget).', |
|
$hW, |
|
$hScanned, |
|
$hCount, |
|
microtime(true) - $hStart, |
|
$hBudget |
|
)); |
|
|
|
return Command::SUCCESS; |
|
} |
|
|
|
private function waitForSiteWellKnownBeforeVerification(SymfonyStyle $io, string $domain): void |
|
{ |
|
$expected = []; |
|
foreach ($this->featuredAuthorRepository->findAllListedOrderByLocalPart() as $row) { |
|
$local = trim((string) $row->getLocalPart()); |
|
$hex = strtolower(trim((string) $row->getPubkeyHex())); |
|
if ($local === '' || 64 !== \strlen($hex) || !ctype_xdigit($hex)) { |
|
continue; |
|
} |
|
$expected[$local] = $hex; |
|
} |
|
if ($expected === []) { |
|
return; |
|
} |
|
|
|
$io->writeln(sprintf( |
|
'Ensuring site NIP-05 directory is current before verification (<comment>%s</comment>, names: <info>%d</info>)…', |
|
$domain, |
|
\count($expected) |
|
)); |
|
$url = 'https://'.$domain.'/.well-known/nostr.json'; |
|
$attempt = 0; |
|
$maxAttempts = 4; |
|
while ($attempt < $maxAttempts) { |
|
$attempt++; |
|
$payload = $this->fetchWellKnownNamesMap($url); |
|
if ($payload !== null && $this->wellKnownHasExpectedNames($payload, $expected)) { |
|
$io->writeln(sprintf(' <info>OK</info> /.well-known/nostr.json is up-to-date (attempt %d/%d).', $attempt, $maxAttempts)); |
|
|
|
return; |
|
} |
|
if ($attempt < $maxAttempts) { |
|
usleep(1_500_000); |
|
} |
|
} |
|
$io->warning('Site /.well-known/nostr.json did not reflect current featured authors before verification; NIP-05 checks may fail transiently.'); |
|
} |
|
|
|
/** |
|
* @return array<string, string>|null |
|
*/ |
|
private function fetchWellKnownNamesMap(string $url): ?array |
|
{ |
|
$ctx = stream_context_create([ |
|
'http' => [ |
|
'method' => 'GET', |
|
'header' => "User-Agent: Unfold-Prewarm/1.0\r\nAccept: application/json\r\n", |
|
'timeout' => 8, |
|
'ignore_errors' => true, |
|
], |
|
'ssl' => [ |
|
'verify_peer' => true, |
|
'verify_peer_name' => true, |
|
], |
|
]); |
|
$raw = @file_get_contents($url, false, $ctx); |
|
if ($raw === false) { |
|
return null; |
|
} |
|
try { |
|
$decoded = json_decode($raw, true, 512, \JSON_THROW_ON_ERROR); |
|
} catch (\JsonException) { |
|
return null; |
|
} |
|
if (!\is_array($decoded) || !isset($decoded['names']) || !\is_array($decoded['names'])) { |
|
return null; |
|
} |
|
$out = []; |
|
foreach ($decoded['names'] as $k => $v) { |
|
if (!\is_string($k) || !\is_string($v)) { |
|
continue; |
|
} |
|
$key = trim($k); |
|
$hex = strtolower(trim($v)); |
|
if ($key === '' || 64 !== \strlen($hex) || !ctype_xdigit($hex)) { |
|
continue; |
|
} |
|
$out[$key] = $hex; |
|
} |
|
|
|
return $out; |
|
} |
|
|
|
/** |
|
* @param array<string, string> $names |
|
* @param array<string, string> $expected |
|
*/ |
|
private function wellKnownHasExpectedNames(array $names, array $expected): bool |
|
{ |
|
foreach ($expected as $local => $hex) { |
|
if (!isset($names[$local]) || !hash_equals($hex, $names[$local])) { |
|
return false; |
|
} |
|
} |
|
|
|
return true; |
|
} |
|
|
|
/** |
|
* Absolute used/budget wall seconds for the comment phase, e.g. "127.4/600 s" (not a percentage). |
|
*/ |
|
private function formatCommentBudgetSecondsPair(float $usedSeconds, int $budgetSeconds): string |
|
{ |
|
$r = round($usedSeconds, 1); |
|
$uStr = abs($r - (float) (int) $r) < 0.01 ? (string) (int) $r : sprintf('%.1f', $r); |
|
|
|
return sprintf('%s/%d s', $uStr, $budgetSeconds); |
|
} |
|
|
|
private function createPrewarmProgressBar(SymfonyStyle $io, int $max, string $message = ''): ProgressBar |
|
{ |
|
$bar = $io->createProgressBar($max); |
|
if (method_exists($bar, 'setMinSecondsBetweenRedraws')) { |
|
$bar->setMinSecondsBetweenRedraws(5.0); |
|
} |
|
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%%'."\n".' <comment>%message%</comment> <info>%elapsed:6s%</info> '); |
|
$bar->setMessage($message); |
|
|
|
// Long %message% lines (e.g. category slugs) wider than the terminal make Symfony’s ProgressBar |
|
// shrink/expand the bar on every redraw; truncate so each line fits and the bar stays stable |
|
// and can use the full width to the right. |
|
$tw = (new Terminal())->getWidth(); |
|
if ($tw < 40) { |
|
$tw = 80; |
|
} |
|
$messageMaxWidth = max(12, $tw - 18); |
|
$bar->setPlaceholderFormatter('message', function (ProgressBar $b) use ($messageMaxWidth): string { |
|
$m = (string) ($b->getMessage() ?? ''); |
|
if ($m === '') { |
|
return ''; |
|
} |
|
if (Helper::width($m) > $messageMaxWidth) { |
|
return Helper::substr($m, 0, max(1, $messageMaxWidth - 1)).'…'; |
|
} |
|
|
|
return $m; |
|
}); |
|
$bar->setBarWidth(max(20, $tw - 32)); |
|
|
|
return $bar; |
|
} |
|
|
|
/** |
|
* ProgressBar::finish() sets progress to max; when the comment phase stops on budget, we only |
|
* completed part of the steps, so cap max to the current step first (or clear if nothing ran). |
|
*/ |
|
private function finishPrewarmProgressBarWithoutFillingToMax(ProgressBar $bar, SymfonyStyle $io): void |
|
{ |
|
$max = (int) $bar->getMaxSteps(); |
|
$done = (int) $bar->getProgress(); |
|
if ($max > 0 && $done < $max && $done === 0) { |
|
if (method_exists($bar, 'clear')) { |
|
$bar->clear(); |
|
} |
|
} else { |
|
if ($max > 0 && $done < $max && $done > 0) { |
|
$bar->setMaxSteps($done); |
|
} |
|
$bar->finish(); |
|
} |
|
$io->newLine(2); |
|
} |
|
|
|
/** |
|
* @param object{silent?: bool} $controller |
|
*/ |
|
private function runWithPcntlHeartbeat(SymfonyStyle $io, int $sec, callable $work, ?object $controller = null): void |
|
{ |
|
if (!\function_exists('pcntl_async_signals') || !\function_exists('pcntl_signal') || !\function_exists('pcntl_alarm')) { |
|
$work(); |
|
|
|
return; |
|
} |
|
$t0 = time(); |
|
$handler = function () use ($io, $t0, $sec, $controller): void { |
|
if ($controller !== null && !empty($controller->silent)) { |
|
return; |
|
} |
|
$io->writeln(sprintf(' <comment>… %ds elapsed</comment>', time() - $t0)); |
|
\pcntl_alarm((int) ceil($sec)); |
|
}; |
|
\pcntl_async_signals(true); |
|
\pcntl_signal(\SIGALRM, $handler); |
|
\pcntl_alarm((int) ceil($sec)); |
|
try { |
|
$work(); |
|
} finally { |
|
\pcntl_alarm(0); |
|
\pcntl_signal(\SIGALRM, \SIG_DFL); |
|
} |
|
} |
|
|
|
private function cancelPcntlAlarm(): void |
|
{ |
|
if (\function_exists('pcntl_alarm')) { |
|
@\pcntl_alarm(0); |
|
} |
|
} |
|
|
|
private function disableCliExecutionTimeLimit(): void |
|
{ |
|
@set_time_limit(0); |
|
@ini_set('max_execution_time', '0'); |
|
} |
|
|
|
/** |
|
* @param array{ |
|
* categories: list<array{ |
|
* slug: string, |
|
* title: string, |
|
* event_id: string, |
|
* listed_total: int, |
|
* resolved_total: int, |
|
* missing_total: int, |
|
* entries: list<array{ |
|
* coordinate: string, |
|
* status: string, |
|
* reason: string, |
|
* article_title?: string |
|
* }> |
|
* }>, |
|
* totals: array{categories: int, listed: int, resolved: int, missing: int} |
|
* } $report |
|
*/ |
|
private function printCategoryCoverageSummary(SymfonyStyle $io, array $report): void |
|
{ |
|
$io->section('Category index -> DB coverage'); |
|
$tot = $report['totals'] ?? ['categories' => 0, 'listed' => 0, 'resolved' => 0, 'missing' => 0]; |
|
$io->writeln(sprintf( |
|
'Categories: <info>%d</info> · listed coordinates: <info>%d</info> · in DB: <info>%d</info> · missing: <comment>%d</comment>', |
|
(int) ($tot['categories'] ?? 0), |
|
(int) ($tot['listed'] ?? 0), |
|
(int) ($tot['resolved'] ?? 0), |
|
(int) ($tot['missing'] ?? 0), |
|
)); |
|
foreach ($report['categories'] ?? [] as $cat) { |
|
$title = trim((string) ($cat['title'] ?? '')); |
|
$slug = (string) ($cat['slug'] ?? ''); |
|
$eventId = (string) ($cat['event_id'] ?? ''); |
|
$io->writeln(sprintf( |
|
' - <info>%s</info> (%s) · event <comment>%s</comment> · listed <info>%d</info>, in DB <info>%d</info>, missing <comment>%d</comment>', |
|
$title !== '' ? $title : $slug, |
|
$slug, |
|
$eventId !== '' ? $eventId : 'n/a', |
|
(int) ($cat['listed_total'] ?? 0), |
|
(int) ($cat['resolved_total'] ?? 0), |
|
(int) ($cat['missing_total'] ?? 0), |
|
)); |
|
foreach ($cat['entries'] ?? [] as $entry) { |
|
$coord = (string) ($entry['coordinate'] ?? ''); |
|
if ($coord === '') { |
|
continue; |
|
} |
|
$status = (string) ($entry['status'] ?? 'missing'); |
|
if ($status === 'resolved') { |
|
$titleOut = trim((string) ($entry['article_title'] ?? '')); |
|
$io->writeln(sprintf( |
|
' + <info>OK</info> %s%s', |
|
$coord, |
|
$titleOut !== '' ? ' -> '.$titleOut : '' |
|
)); |
|
} else { |
|
$reason = (string) ($entry['reason'] ?? 'unknown'); |
|
$io->writeln(sprintf(' - <comment>MISSING</comment> %s (%s)', $coord, $reason)); |
|
} |
|
} |
|
} |
|
} |
|
}
|
|
|