Browse Source

handle excessive server workers

imwald
Silberengel 1 month ago
parent
commit
d8ff82ccbc
  1. 20
      src/Controller/ArticleController.php
  2. 8
      src/Controller/AuthorController.php
  3. 18
      src/Http/PhpExecutionTime.php
  4. 7
      src/Service/ArticleCommentThreadLoader.php
  5. 15
      src/Service/Nip05VerificationService.php
  6. 4
      src/Service/NostrClient.php

20
src/Controller/ArticleController.php

@ -3,6 +3,7 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\Article; use App\Entity\Article;
use App\Http\PhpExecutionTime;
use App\Repository\ArticleRepository; use App\Repository\ArticleRepository;
use App\Service\ArticleBodyHtmlRenderer; use App\Service\ArticleBodyHtmlRenderer;
use App\Enum\KindsEnum; use App\Enum\KindsEnum;
@ -35,10 +36,11 @@ class ArticleController extends AbstractController
#[Route('/fragment/comments', name: 'article_comments_fragment', methods: ['GET'])] #[Route('/fragment/comments', name: 'article_comments_fragment', methods: ['GET'])]
public function commentsFragment(Request $request, ArticleCommentThreadLoader $loader, LoggerInterface $logger): Response public function commentsFragment(Request $request, ArticleCommentThreadLoader $loader, LoggerInterface $logger): Response
{ {
// {@see NostrClient::getArticleDiscussion} runs per-relay work in parallel CLI workers; allow headroom // {@see NostrClient::getArticleDiscussion} uses parallel CLI workers; cap below multi-minute defaults
// for all processes + Symfony (45s was too low and caused an uncatchable max-execution fatal → HTTP 500). // so Apache event MPM scoreboard slots are not held unnecessarily (see {@see PhpExecutionTime}).
@set_time_limit(300); $t = PhpExecutionTime::NOSTR_BOUND_WEB_SEC;
@ini_set('max_execution_time', '300'); @set_time_limit($t);
@ini_set('max_execution_time', (string) $t);
$t0 = microtime(true); $t0 = microtime(true);
$coordinate = $request->query->getString('coordinate'); $coordinate = $request->query->getString('coordinate');
@ -365,8 +367,9 @@ class ArticleController extends AbstractController
ArticleBodyHtmlRenderer $articleBodyHtmlRenderer, ArticleBodyHtmlRenderer $articleBodyHtmlRenderer,
NostrKeyHelper $nostrKeyHelper, NostrKeyHelper $nostrKeyHelper,
): Response { ): Response {
set_time_limit(300); // 5 minutes $t = PhpExecutionTime::NOSTR_BOUND_WEB_SEC;
ini_set('max_execution_time', '300'); set_time_limit($t);
ini_set('max_execution_time', (string) $t);
$html = $articleBodyHtmlRenderer->renderForArticle($article); $html = $articleBodyHtmlRenderer->renderForArticle($article);
@ -601,8 +604,9 @@ class ArticleController extends AbstractController
#[Route('/articles', name: 'articles')] #[Route('/articles', name: 'articles')]
public function latestArticles(Request $request, EntityManagerInterface $entityManager): Response public function latestArticles(Request $request, EntityManagerInterface $entityManager): Response
{ {
set_time_limit(300); // 5 minutes $t = PhpExecutionTime::LIGHT_WEB_SEC;
ini_set('max_execution_time', '300'); set_time_limit($t);
ini_set('max_execution_time', (string) $t);
$perPage = 25; $perPage = 25;
$page = max(1, $request->query->getInt('page', 1)); $page = max(1, $request->query->getInt('page', 1));

8
src/Controller/AuthorController.php

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Http\PhpExecutionTime;
use App\Repository\ArticleRepository; use App\Repository\ArticleRepository;
use App\Repository\FeaturedAuthorRepository; use App\Repository\FeaturedAuthorRepository;
use App\Service\CacheService; use App\Service\CacheService;
@ -36,10 +37,9 @@ class AuthorController extends AbstractController
ProfileIdentityLinksBuilder $profileIdentityLinks, ProfileIdentityLinksBuilder $profileIdentityLinks,
NostrKeyHelper $nostrKeyHelper, NostrKeyHelper $nostrKeyHelper,
): Response { ): Response {
// Profile pages chain several sequential Nostr REQ runs; match article pages so a slow relay // Profile pages chain several sequential Nostr REQ runs; cap wall time so Apache workers are not held for minutes.
// set does not hit PHP’s default 30s max_execution_time during Twig render. @set_time_limit(PhpExecutionTime::NOSTR_BOUND_WEB_SEC);
@set_time_limit(300); @ini_set('max_execution_time', (string) PhpExecutionTime::NOSTR_BOUND_WEB_SEC);
@ini_set('max_execution_time', '300');
$pubkey = $nostrKeyHelper->convertToHex($npub); $pubkey = $nostrKeyHelper->convertToHex($npub);

18
src/Http/PhpExecutionTime.php

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Http;
/**
* Web request caps for max_execution_time. High values tie up Apache event MPM scoreboard slots
* (and similar) for minutes per client; keep limits aligned with real Nostr + worker wall time.
*/
final class PhpExecutionTime
{
/** Article page, comments fragment, author profile (Nostr + parallel workers + Twig). */
public const NOSTR_BOUND_WEB_SEC = 120;
/** DB + Twig listing routes without relay fan-out. */
public const LIGHT_WEB_SEC = 60;
}

7
src/Service/ArticleCommentThreadLoader.php

@ -22,6 +22,9 @@ use Symfony\Contracts\Cache\ItemInterface;
final readonly class ArticleCommentThreadLoader final readonly class ArticleCommentThreadLoader
{ {
private const PARENT_REPLY_TEXT_PREVIEW_MAX = 200; private const PARENT_REPLY_TEXT_PREVIEW_MAX = 200;
/** Partial thread cache: long enough to avoid relay-storm on flaky relays; prewarm still fills full TTL. */
private const PARTIAL_THREAD_CACHE_TTL_SEC = 300;
/** PSR-6 pool backing {@see $cache}; used for true cache-only reads (SSR) without invoking Nostr. */ /** PSR-6 pool backing {@see $cache}; used for true cache-only reads (SSR) without invoking Nostr. */
public function __construct( public function __construct(
private NostrClient $nostrClient, private NostrClient $nostrClient,
@ -101,8 +104,8 @@ final readonly class ArticleCommentThreadLoader
// On failure, let this throw: Symfony cache will not store a value, so a prior good thread is not replaced by []. // On failure, let this throw: Symfony cache will not store a value, so a prior good thread is not replaced by [].
$out = $this->nostrClient->getArticleDiscussion($coordinate, $articleEventHexId); $out = $this->nostrClient->getArticleDiscussion($coordinate, $articleEventHexId);
$partial = (bool) ($out['partial'] ?? false); $partial = (bool) ($out['partial'] ?? false);
// Partial relay snapshots are intentionally short-lived so the next request can pick up late relays. // Partial: bounded TTL so late relays can still appear without re-fetching every few seconds.
$item->expiresAfter($partial ? 15 : 86400); $item->expiresAfter($partial ? self::PARTIAL_THREAD_CACHE_TTL_SEC : 86400);
$this->logger->info('comments.loader.nostr_ok', [ $this->logger->info('comments.loader.nostr_ok', [
'nostr_elapsed_ms' => (int) round((microtime(true) - $tNostr) * 1000), 'nostr_elapsed_ms' => (int) round((microtime(true) - $tNostr) * 1000),
'thread' => \count($out['thread'] ?? []), 'thread' => \count($out['thread'] ?? []),

15
src/Service/Nip05VerificationService.php

@ -16,7 +16,10 @@ final readonly class Nip05VerificationService
{ {
private const CACHE_PREFIX = 'nip05v1_'; private const CACHE_PREFIX = 'nip05v1_';
private const FETCH_TIMEOUT_SEC = 8; private const FETCH_TIMEOUT_SEC = 5;
/** Avoid sequential cold HTTP to many domains on one page tying up a worker. */
private const MAX_COLD_VERIFICATIONS_PER_ENRICH = 5;
public function __construct( public function __construct(
private CacheItemPoolInterface $appCache, private CacheItemPoolInterface $appCache,
@ -42,6 +45,7 @@ final readonly class Nip05VerificationService
}, $rows); }, $rows);
} }
$out = []; $out = [];
$coldDone = 0;
foreach ($rows as $r) { foreach ($rows as $r) {
$label = (string) ($r['label'] ?? ''); $label = (string) ($r['label'] ?? '');
$n = $this->normalizeNip05($label); $n = $this->normalizeNip05($label);
@ -56,13 +60,22 @@ final readonly class Nip05VerificationService
$item = $this->appCache->getItem($k); $item = $this->appCache->getItem($k);
if ($item->isHit() && \is_bool($item->get())) { if ($item->isHit() && \is_bool($item->get())) {
$verified = (bool) $item->get(); $verified = (bool) $item->get();
} elseif ($coldDone >= self::MAX_COLD_VERIFICATIONS_PER_ENRICH) {
$this->logger->info('nip05.verify_cold_skipped_budget', ['label' => $label]);
$verified = false;
} else { } else {
++$coldDone;
// Cold cache: verify now so the profile shows ✓ without a prior prewarm run. // Cold cache: verify now so the profile shows ✓ without a prior prewarm run.
$verified = $this->verifyAndCache($h, $label); $verified = $this->verifyAndCache($h, $label);
} }
} catch (InvalidArgumentException) { } catch (InvalidArgumentException) {
if ($coldDone >= self::MAX_COLD_VERIFICATIONS_PER_ENRICH) {
$verified = false;
} else {
++$coldDone;
$verified = $this->verifyAndCache($h, $label); $verified = $this->verifyAndCache($h, $label);
} }
}
$out[] = [...$r, 'verified' => $verified]; $out[] = [...$r, 'verified' => $verified];
} }

4
src/Service/NostrClient.php

@ -38,9 +38,9 @@ class NostrClient
{ {
/** /**
* Hard cap on unique relay URLs for article discussion. More relays do not help much (indexers duplicate) * Hard cap on unique relay URLs for article discussion. More relays do not help much (indexers duplicate)
* but blow up wall time when we fall back to sequential in-process {@see Request::send()}. * but each URL can spawn a CLI worker ({@see NostrRelayFanoutTransport::sendParallelWorkers}) → Apache/CPU load.
*/ */
private const MAX_DISCUSSION_RELAY_URLS = 10; private const MAX_DISCUSSION_RELAY_URLS = 8;
/** /**
* Kind-9802 highlight ingest ({@see fetchHighlightEventsForArticle} / prewarm): main + article + profile * Kind-9802 highlight ingest ({@see fetchHighlightEventsForArticle} / prewarm): main + article + profile

Loading…
Cancel
Save