Browse Source

Faster page loads with smarter caching

imwald
Nuša Pukšič 1 month ago
parent
commit
eaaa9cc326
  1. 20
      compose.yaml
  2. 168
      src/Command/CacheLatestArticlesCommand.php
  3. 148
      src/Command/CacheLatestHighlightsCommand.php
  4. 126
      src/Command/ProcessArticleHtmlCommand.php
  5. 126
      src/Command/SubscribeLocalRelayCommand.php
  6. 25
      src/Controller/ArticleController.php
  7. 46
      src/Controller/AuthorController.php
  8. 90
      src/Controller/DefaultController.php
  9. 16
      src/Entity/Article.php
  10. 26
      src/ReadModel/RedisView/RedisArticleView.php
  11. 31
      src/ReadModel/RedisView/RedisBaseObject.php
  12. 20
      src/ReadModel/RedisView/RedisHighlightView.php
  13. 23
      src/ReadModel/RedisView/RedisProfileView.php
  14. 301
      src/ReadModel/RedisView/RedisViewFactory.php
  15. 69
      src/Repository/HighlightRepository.php
  16. 113
      src/Service/ArticleEventProjector.php
  17. 223
      src/Service/NostrRelayPool.php
  18. 50
      src/Service/RedisCacheService.php
  19. 234
      src/Service/RedisViewStore.php

20
compose.yaml

@ -89,6 +89,26 @@ services:
DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@database:5432/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-17}&charset=${POSTGRES_CHARSET:-utf8}} DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@database:5432/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-17}&charset=${POSTGRES_CHARSET:-utf8}}
NOSTR_DEFAULT_RELAY: ${NOSTR_DEFAULT_RELAY:-} NOSTR_DEFAULT_RELAY: ${NOSTR_DEFAULT_RELAY:-}
article_hydration_worker:
build:
context: .
dockerfile: Dockerfile
working_dir: /app
entrypoint: ["php"] # run PHP CLI, not Caddy/FrankenPHP
command:
- bin/console
- articles:subscribe-local-relay
- -vv
restart: unless-stopped
depends_on:
- php
- database
- strfry
environment:
APP_ENV: prod
DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@database:5432/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-17}&charset=${POSTGRES_CHARSET:-utf8}}
NOSTR_DEFAULT_RELAY: ${NOSTR_DEFAULT_RELAY:-ws://strfry:7777}
###> strfry relay ### ###> strfry relay ###
strfry: strfry:
image: dockurr/strfry:latest image: dockurr/strfry:latest

168
src/Command/CacheLatestArticlesCommand.php

@ -4,112 +4,142 @@ declare(strict_types=1);
namespace App\Command; namespace App\Command;
use App\Entity\Article;
use App\Repository\ArticleRepository;
use App\Service\RedisCacheService; use App\Service\RedisCacheService;
use App\Util\NostrKeyUtil; use App\Service\RedisViewStore;
use FOS\ElasticaBundle\Finder\FinderInterface; use App\ReadModel\RedisView\RedisViewFactory;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Elastica\Query\BoolQuery;
use Elastica\Query;
use Elastica\Collapse;
use Elastica\Query\Terms;
use Psr\Cache\CacheItemPoolInterface;
use swentel\nostr\Key\Key; use swentel\nostr\Key\Key;
#[AsCommand(name: 'app:cache_latest_articles', description: 'Cache the latest articles list')] #[AsCommand(
name: 'app:cache_latest_articles',
description: 'Cache the latest articles list to Redis views'
)]
class CacheLatestArticlesCommand extends Command class CacheLatestArticlesCommand extends Command
{ {
private FinderInterface $finder;
private CacheItemPoolInterface $articlesCache;
private ParameterBagInterface $params;
private RedisCacheService $redisCacheService;
public function __construct( public function __construct(
FinderInterface $finder, private readonly ArticleRepository $articleRepository,
CacheItemPoolInterface $articlesCache, private readonly RedisCacheService $redisCacheService,
ParameterBagInterface $params, private readonly RedisViewStore $viewStore,
RedisCacheService $redisCacheService private readonly RedisViewFactory $viewFactory,
) ) {
{
parent::__construct(); parent::__construct();
$this->finder = $finder; }
$this->articlesCache = $articlesCache;
$this->params = $params; protected function configure(): void
$this->redisCacheService = $redisCacheService; {
$this->addOption(
'limit',
'l',
InputOption::VALUE_OPTIONAL,
'Maximum number of articles to cache',
50
);
} }
protected function execute(InputInterface $input, OutputInterface $output): int protected function execute(InputInterface $input, OutputInterface $output): int
{ {
$env = $this->params->get('kernel.environment'); $limit = (int) $input->getOption('limit');
$cacheKey = 'latest_articles_list_' . $env;
$cacheItem = $this->articlesCache->getItem($cacheKey);
// Define excluded pubkeys (bots, spam accounts)
$key = new Key(); $key = new Key();
$excludedPubkeys = [ $excludedPubkeys = [
$key->convertToHex('npub1etsrcjz24fqewg4zmjze7t5q8c6rcwde5zdtdt4v3t3dz2navecscjjz94'), // Bitcoin Magazine (News Bot) $key->convertToHex('npub1etsrcjz24fqewg4zmjze7t5q8c6rcwde5zdtdt4v3t3dz2navecscjjz94'), // Bitcoin Magazine (News Bot)
$key->convertToHex('npub1m7szwpud3jh2k3cqe73v0fd769uzsj6rzmddh4dw67y92sw22r3sk5m3ys'), // No Bullshit Bitcoin (News Bot) $key->convertToHex('npub1m7szwpud3jh2k3cqe73v0fd769uzsj6rzmddh4dw67y92sw22r3sk5m3ys'), // No Bullshit Bitcoin (News Bot)
$key->convertToHex('npub13wke9s6njrmugzpg6mqtvy2d49g4d6t390ng76dhxxgs9jn3f2jsmq82pk'), // TFTC (News Bot) $key->convertToHex('npub13wke9s6njrmugzpg6mqtvy2d49g4d6t390ng76dhxxgs9jn3f2jsmq82pk'), // TFTC (News Bot)
$key->convertToHex('npub10akm29ejpdns52ca082skmc3hr75wmv3ajv4987c9lgyrfynrmdqduqwlx'), // Discreet Log (News Bot) $key->convertToHex('npub10akm29ejpdns52ca082skmc3hr75wmv3ajv4987c9lgyrfynrmdqduqwlx'), // Discreet Log (News Bot)
$key->convertToHex('npub13uvnw9qehqkds68ds76c4nfcn3y99c2rl9z8tr0p34v7ntzsmmzspwhh99'), // Batcoinz (Just annoying) $key->convertToHex('npub13uvnw9qehqkds68ds76c4nfcn3y99c2rl9z8tr0p34v7ntzsmmzspwhh99'), // Batcoinz
$key->convertToHex('npub1fls5au5fxj6qj0t36sage857cs4tgfpla0ll8prshlhstagejtkqc9s2yl'), // AGORA Marketplace - feed 𝚋𝚘𝚝 (Just annoying) $key->convertToHex('npub1fls5au5fxj6qj0t36sage857cs4tgfpla0ll8prshlhstagejtkqc9s2yl'), // AGORA Marketplace
$key->convertToHex('npub1t5d8kcn0hu8zmt6dpkgatd5hwhx76956g7qmdzwnca6fzgprzlhqnqks86'), // NSFW $key->convertToHex('npub1t5d8kcn0hu8zmt6dpkgatd5hwhx76956g7qmdzwnca6fzgprzlhqnqks86'), // NSFW
$key->convertToHex('npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss'), // LNgigs, job offers feed $key->convertToHex('npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss'), // LNgigs
$key->convertToHex('npub1sztw66ap7gdrwyaag7js8m3h0vxw9uv8k0j68t5sngjmsjgpqx9sm9wyaq'), // Now Playing bot $key->convertToHex('npub1sztw66ap7gdrwyaag7js8m3h0vxw9uv8k0j68t5sngjmsjgpqx9sm9wyaq'), // Now Playing bot
]; ];
// if (!$cacheItem->isHit()) { $output->writeln('<comment>Querying database for latest articles...</comment>');
$boolQuery = new BoolQuery();
$boolQuery->addMustNot(new Terms('pubkey', $excludedPubkeys));
$query = new Query($boolQuery); // Query database directly - get latest published articles, one per author
$query->setSize(50); // Using DQL to get the most recent article per pubkey
$query->setSort(['createdAt' => ['order' => 'desc']]); $qb = $this->articleRepository->createQueryBuilder('a');
$qb->where('a.publishedAt IS NOT NULL')
->andWhere('a.slug IS NOT NULL')
->andWhere('a.title IS NOT NULL')
->andWhere($qb->expr()->notIn('a.pubkey', ':excludedPubkeys'))
->setParameter('excludedPubkeys', $excludedPubkeys)
->orderBy('a.createdAt', 'DESC')
->setMaxResults($limit * 2); // Get more initially, will dedupe by author
$collapse = new Collapse(); /** @var Article[] $allArticles */
$collapse->setFieldname('slug'); $allArticles = $qb->getQuery()->getResult();
$query->setCollapse($collapse);
$collapse2 = new Collapse(); $output->writeln(sprintf('<info>Found %d articles from database</info>', count($allArticles)));
$collapse2->setFieldname('pubkey');
$query->setCollapse($collapse2);
$articles = $this->finder->find($query); // Deduplicate: Keep only the most recent article per author
$articlesByAuthor = [];
// Pre-fetch and cache author metadata for all articles foreach ($allArticles as $article) {
$authorPubkeys = [];
foreach ($articles as $article) {
// Debug: check what type of object we have
if ($article instanceof \App\Entity\Article) {
$pubkey = $article->getPubkey(); $pubkey = $article->getPubkey();
if ($pubkey && NostrKeyUtil::isHexPubkey($pubkey)) { if (!$pubkey) {
$authorPubkeys[] = $pubkey; continue;
} }
} elseif (is_object($article)) {
// Elastica result object // Keep first (most recent) article per author
if (isset($article->pubkey) && NostrKeyUtil::isHexPubkey($article->pubkey)) { if (!isset($articlesByAuthor[$pubkey])) {
$authorPubkeys[] = $article->pubkey; $articlesByAuthor[$pubkey] = $article;
} elseif (isset($article->npub) && NostrKeyUtil::isNpub($article->npub)) {
$authorPubkeys[] = NostrKeyUtil::npubToHex($article->npub);
} }
} }
// Take only the requested limit
$articles = array_slice($articlesByAuthor, 0, $limit);
$output->writeln(sprintf('<info>Selected %d articles (one per author)</info>', count($articles)));
if (empty($articles)) {
$output->writeln('<error>No articles found matching criteria</error>');
return Command::FAILURE;
} }
$authorPubkeys = array_unique($authorPubkeys);
$output->writeln('<comment>Pre-fetching metadata for ' . count($authorPubkeys) . ' authors...</comment>'); // Collect author pubkeys for metadata fetching
$authorPubkeys = array_keys($articlesByAuthor);
$output->writeln(sprintf('<comment>Fetching metadata for %d authors...</comment>', count($authorPubkeys)));
$authorsMetadata = $this->redisCacheService->getMultipleMetadata($authorPubkeys); $authorsMetadata = $this->redisCacheService->getMultipleMetadata($authorPubkeys);
$output->writeln('<comment>Fetched ' . count($authorsMetadata) . ' author profiles.</comment>'); $output->writeln(sprintf('<info>✓ Fetched %d author profiles</info>', count($authorsMetadata)));
$cacheItem->set($articles); // Build Redis view objects - we now have exact Article entities!
$cacheItem->expiresAfter(3600); // Cache for 1 hour $output->writeln('<comment>Building Redis view objects...</comment>');
$this->articlesCache->save($cacheItem); $baseObjects = [];
$output->writeln('<info>Cached ' . count($articles) . ' articles with author metadata.</info>');
// } else { foreach ($articles as $article) {
// $output->writeln('<comment>Cache already exists for key: ' . $cacheKey . '</comment>'); $authorMeta = $authorsMetadata[$article->getPubkey()] ?? null;
// }
try {
$baseObject = $this->viewFactory->articleBaseObject($article, $authorMeta);
$baseObjects[] = $baseObject;
} catch (\Exception $e) {
$output->writeln(sprintf(
'<error>Failed to build view object for article %s: %s</error>',
$article->getSlug(),
$e->getMessage()
));
}
}
if (empty($baseObjects)) {
$output->writeln('<error>No view objects created</error>');
return Command::FAILURE;
}
// Store to Redis views
$output->writeln('<comment>Storing to Redis...</comment>');
$this->viewStore->storeLatestArticles($baseObjects);
$output->writeln('');
$output->writeln(sprintf('<info>✓ Successfully cached %d articles to Redis views</info>', count($baseObjects)));
$output->writeln('<info> Key: view:articles:latest</info>');
return Command::SUCCESS; return Command::SUCCESS;
} }

148
src/Command/CacheLatestHighlightsCommand.php

@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Repository\HighlightRepository;
use App\Repository\ArticleRepository;
use App\Service\RedisCacheService;
use App\Service\RedisViewStore;
use App\ReadModel\RedisView\RedisViewFactory;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Psr\Log\LoggerInterface;
#[AsCommand(
name: 'app:cache-latest-highlights',
description: 'Cache the latest highlights list with articles and author profiles'
)]
class CacheLatestHighlightsCommand extends Command
{
public function __construct(
private readonly HighlightRepository $highlightRepository,
private readonly ArticleRepository $articleRepository,
private readonly RedisCacheService $redisCacheService,
private readonly RedisViewStore $viewStore,
private readonly RedisViewFactory $viewFactory,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption(
'limit',
'l',
InputOption::VALUE_OPTIONAL,
'Maximum number of highlights to cache',
50
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$limit = (int) $input->getOption('limit');
$output->writeln("<comment>Fetching latest {$limit} highlights from database...</comment>");
try {
// Get latest highlights with their articles
$highlightsWithArticles = $this->highlightRepository->findLatestWithArticles($limit);
$output->writeln(sprintf('<info>Found %d highlights</info>', count($highlightsWithArticles)));
if (empty($highlightsWithArticles)) {
$output->writeln('<comment>No highlights found in database</comment>');
return Command::SUCCESS;
}
// Collect unique pubkeys for batch metadata fetch
$pubkeys = [];
foreach ($highlightsWithArticles as $item) {
$highlight = $item['highlight'];
$article = $item['article'];
if ($highlight->getPubkey()) {
$pubkeys[] = $highlight->getPubkey();
}
if ($article && $article->getPubkey()) {
$pubkeys[] = $article->getPubkey();
}
}
$pubkeys = array_unique(array_filter($pubkeys));
$output->writeln(sprintf('<comment>Pre-fetching metadata for %d unique authors...</comment>', count($pubkeys)));
$metadataMap = $this->redisCacheService->getMultipleMetadata($pubkeys);
$output->writeln(sprintf('<comment>Fetched %d author profiles</comment>', count($metadataMap)));
// Build Redis view objects
$output->writeln('<comment>Building Redis view objects...</comment>');
$baseObjects = [];
$skipped = 0;
foreach ($highlightsWithArticles as $item) {
$highlight = $item['highlight'];
$article = $item['article'];
// Skip if article not found
if (!$article) {
$skipped++;
$this->logger->debug('Skipping highlight - article not found', [
'highlight_id' => $highlight->getEventId(),
'coordinate' => $highlight->getArticleCoordinate(),
]);
continue;
}
// Get metadata for both authors
$highlightAuthorMeta = $metadataMap[$highlight->getPubkey()] ?? null;
$articleAuthorMeta = $metadataMap[$article->getPubkey()] ?? null;
// Build base object
try {
$baseObject = $this->viewFactory->highlightBaseObject(
$highlight,
$article,
$highlightAuthorMeta,
$articleAuthorMeta
);
$baseObjects[] = $baseObject;
} catch (\Exception $e) {
$this->logger->error('Failed to build highlight base object', [
'highlight_id' => $highlight->getEventId(),
'error' => $e->getMessage(),
]);
$skipped++;
}
}
if ($skipped > 0) {
$output->writeln(sprintf('<comment>Skipped %d highlights (missing articles or errors)</comment>', $skipped));
}
// Store to Redis views
if (!empty($baseObjects)) {
$this->viewStore->storeLatestHighlights($baseObjects);
$output->writeln(sprintf('<info>✓ Stored %d highlights to Redis views (view:highlights:latest)</info>', count($baseObjects)));
} else {
$output->writeln('<error>No valid highlights to cache</error>');
return Command::FAILURE;
}
return Command::SUCCESS;
} catch (\Exception $e) {
$output->writeln(sprintf('<error>Error: %s</error>', $e->getMessage()));
$this->logger->error('Failed to cache highlights', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return Command::FAILURE;
}
}
}

126
src/Command/ProcessArticleHtmlCommand.php

@ -0,0 +1,126 @@
<?php
namespace App\Command;
use App\Entity\Article;
use App\Util\CommonMark\Converter;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'articles:process-html',
description: 'Process and cache HTML for articles that are missing processed HTML content'
)]
class ProcessArticleHtmlCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Converter $converter
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('force', 'f', InputOption::VALUE_NONE, 'Force reprocessing of all articles (including those with existing HTML)')
->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Limit the number of articles to process', null)
->setHelp(
'This command processes markdown content to HTML for articles and caches the result in the database. ' .
'By default, it only processes articles that are missing processed HTML. ' .
'Use --force to reprocess all articles.'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$force = $input->getOption('force');
$limit = $input->getOption('limit');
$io->title('Article HTML Processing');
// Build query
$queryBuilder = $this->entityManager->createQueryBuilder()
->select('a')
->from(Article::class, 'a')
->where('a.content IS NOT NULL');
if (!$force) {
$queryBuilder->andWhere('a.processedHtml IS NULL');
}
if ($limit) {
$queryBuilder->setMaxResults((int) $limit);
}
$articles = $queryBuilder->getQuery()->getResult();
$total = count($articles);
if ($total === 0) {
$io->success('No articles to process.');
return Command::SUCCESS;
}
$io->info(sprintf('Found %d article(s) to process', $total));
$io->newLine();
$progressBar = $io->createProgressBar($total);
$progressBar->setFormat('very_verbose');
$progressBar->start();
$processed = 0;
$failed = 0;
$batchSize = 20;
foreach ($articles as $index => $article) {
try {
$html = $this->converter->convertToHTML($article->getContent());
$article->setProcessedHtml($html);
$processed++;
// Flush in batches for better performance
if (($index + 1) % $batchSize === 0) {
$this->entityManager->flush();
$this->entityManager->clear();
}
} catch (\Exception $e) {
$failed++;
$io->writeln('');
$io->warning(sprintf(
'Failed to process article %s: %s',
$article->getEventId() ?? $article->getId(),
$e->getMessage()
));
}
$progressBar->advance();
}
// Final flush for remaining articles
$this->entityManager->flush();
$this->entityManager->clear();
$progressBar->finish();
$io->newLine(2);
// Summary
$io->success(sprintf(
'Processing complete: %d processed, %d failed',
$processed,
$failed
));
if ($failed > 0) {
$io->note('Some articles failed to process. Check the warnings above for details.');
}
return Command::SUCCESS;
}
}

126
src/Command/SubscribeLocalRelayCommand.php

@ -0,0 +1,126 @@
<?php
namespace App\Command;
use App\Service\ArticleEventProjector;
use App\Service\NostrRelayPool;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'articles:subscribe-local-relay',
description: 'Subscribe to local relay for new article events and save them to the database in real-time'
)]
class SubscribeLocalRelayCommand extends Command
{
public function __construct(
private readonly NostrRelayPool $relayPool,
private readonly ArticleEventProjector $projector,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
protected function configure(): void
{
$this->setHelp(
'This command subscribes to the local Nostr relay for article events (kind 30023) ' .
'and automatically persists them to the database. It runs as a long-lived daemon process.'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$localRelay = $this->relayPool->getLocalRelay();
if (!$localRelay) {
$io->error('Local relay not configured. Please set NOSTR_DEFAULT_RELAY environment variable.');
return Command::FAILURE;
}
$io->title('Article Hydration Worker');
$io->info(sprintf('Subscribing to local relay: %s', $localRelay));
$io->info('Listening for article events (kind 30023)...');
$io->newLine();
try {
// Start the long-lived subscription
// This blocks forever and processes events via the callback
$this->relayPool->subscribeLocalArticles(
function (object $event, string $relayUrl) use ($io) {
$timestamp = date('Y-m-d H:i:s');
$eventId = substr($event->id ?? 'unknown', 0, 16) . '...';
$pubkey = substr($event->pubkey ?? 'unknown', 0, 16) . '...';
$kind = $event->kind ?? 'unknown';
$title = '';
// Extract title from tags if available
if (isset($event->tags) && is_array($event->tags)) {
foreach ($event->tags as $tag) {
if (is_array($tag) && isset($tag[0]) && $tag[0] === 'title' && isset($tag[1])) {
$title = mb_substr($tag[1], 0, 50);
if (mb_strlen($tag[1]) > 50) {
$title .= '...';
}
break;
}
}
}
// Log to console
$io->writeln(sprintf(
'[%s] <fg=green>Event received:</> %s (kind: %s, pubkey: %s)%s',
$timestamp,
$eventId,
$kind,
$pubkey,
$title ? ' - ' . $title : ''
));
// Project the event to the database
try {
$this->projector->projectArticleFromEvent($event, $relayUrl);
$io->writeln(sprintf(
'[%s] <fg=green></> Article saved to database',
date('Y-m-d H:i:s')
));
} catch (\InvalidArgumentException $e) {
// Invalid event (wrong kind, bad signature, etc.)
$io->writeln(sprintf(
'[%s] <fg=yellow></> Skipped invalid event: %s',
date('Y-m-d H:i:s'),
$e->getMessage()
));
} catch (\Exception $e) {
// Database or other errors
$io->writeln(sprintf(
'[%s] <fg=red></> Error saving article: %s',
date('Y-m-d H:i:s'),
$e->getMessage()
));
}
$io->newLine();
}
);
// @phpstan-ignore-next-line - This line should never be reached (infinite loop in subscribeLocalArticles)
return Command::SUCCESS;
} catch (\Exception $e) {
$io->error('Subscription failed: ' . $e->getMessage());
$this->logger->error('Article hydration worker failed', [
'error' => $e->getMessage(),
'exception' => get_class($e),
'trace' => $e->getTraceAsString()
]);
return Command::FAILURE;
}
}
}

25
src/Controller/ArticleController.php

@ -128,8 +128,8 @@ class ArticleController extends AbstractController
$slug, $slug,
EntityManagerInterface $entityManager, EntityManagerInterface $entityManager,
RedisCacheService $redisCacheService, RedisCacheService $redisCacheService,
CacheItemPoolInterface $articlesCache,
Converter $converter, Converter $converter,
LoggerInterface $logger,
HighlightService $highlightService HighlightService $highlightService
): Response ): Response
{ {
@ -143,12 +143,23 @@ class ArticleController extends AbstractController
if (!$article) { if (!$article) {
throw $this->createNotFoundException('The article could not be found'); throw $this->createNotFoundException('The article could not be found');
} }
$cacheKey = 'article_' . $article->getEventId();
$cacheItem = $articlesCache->getItem($cacheKey); // Use cached processedHtml from database if available
if (!$cacheItem->isHit()) { $htmlContent = $article->getProcessedHtml();
$cacheItem->set($converter->convertToHTML($article->getContent())); $logger->info('Article content retrieval', [
$articlesCache->save($cacheItem); 'article_id' => $article->getId(),
'slug' => $article->getSlug(),
'pubkey' => $article->getPubkey(),
'has_cached_html' => $htmlContent !== null
]);
if (!$htmlContent) {
// Fall back to converting on-the-fly and save for future requests
$htmlContent = $converter->convertToHTML($article->getContent());
$article->setProcessedHtml($htmlContent);
$entityManager->flush();
} }
$author = $redisCacheService->getMetadata($article->getPubkey()); $author = $redisCacheService->getMetadata($article->getPubkey());
$canEdit = false; $canEdit = false;
$user = $this->getUser(); $user = $this->getUser();
@ -170,7 +181,7 @@ class ArticleController extends AbstractController
'article' => $article, 'article' => $article,
'author' => $author, 'author' => $author,
'npub' => $npub, 'npub' => $npub,
'content' => $cacheItem->get(), 'content' => $htmlContent,
'canEdit' => $canEdit, 'canEdit' => $canEdit,
'canonical' => $canonical, 'canonical' => $canonical,
'highlights' => $highlights 'highlights' => $highlights

46
src/Controller/AuthorController.php

@ -232,14 +232,29 @@ class AuthorController extends AbstractController
#[Route('/p/{npub}', name: 'author-profile', requirements: ['npub' => '^npub1.*'])] #[Route('/p/{npub}', name: 'author-profile', requirements: ['npub' => '^npub1.*'])]
#[Route('/p/{npub}/articles', name: 'author-articles', requirements: ['npub' => '^npub1.*'])] #[Route('/p/{npub}/articles', name: 'author-articles', requirements: ['npub' => '^npub1.*'])]
public function index($npub, RedisCacheService $redisCacheService, FinderInterface $finder, public function index($npub, RedisCacheService $redisCacheService, FinderInterface $finder,
MessageBusInterface $messageBus): Response MessageBusInterface $messageBus, RedisViewStore $viewStore,
RedisViewFactory $viewFactory): Response
{ {
$keys = new Key(); $keys = new Key();
$pubkey = $keys->convertToHex($npub); $pubkey = $keys->convertToHex($npub);
$author = $redisCacheService->getMetadata($pubkey); $author = $redisCacheService->getMetadata($pubkey);
// Get articles using Elasticsearch with collapse on slug // Try to get cached view first
$cachedArticles = $viewStore->fetchUserArticles($pubkey);
$fromCache = false;
if ($cachedArticles !== null) {
// Redis view data already matches template - just extract articles
$articles = [];
foreach ($cachedArticles as $baseObject) {
if (isset($baseObject['article'])) {
$articles[] = (object) $baseObject['article'];
}
}
$fromCache = true;
} else {
// Cache miss - query from Elasticsearch
$boolQuery = new BoolQuery(); $boolQuery = new BoolQuery();
$boolQuery->addMust(new Term(['pubkey' => $pubkey])); $boolQuery->addMust(new Term(['pubkey' => $pubkey]));
$query = new \Elastica\Query($boolQuery); $query = new \Elastica\Query($boolQuery);
@ -249,9 +264,35 @@ class AuthorController extends AbstractController
$query->setCollapse($collapse); $query->setCollapse($collapse);
$articles = $finder->find($query); $articles = $finder->find($query);
// Build and cache Redis views for next time
if (!empty($articles)) {
try {
$baseObjects = [];
foreach ($articles as $article) {
if ($article instanceof Article) {
$baseObjects[] = $viewFactory->articleBaseObject($article, $author);
}
}
if (!empty($baseObjects)) {
$viewStore->storeUserArticles($pubkey, $baseObjects);
}
} catch (\Exception $e) {
// Log but don't fail the request
error_log('Failed to cache user articles view: ' . $e->getMessage());
}
}
}
// Get latest createdAt for dispatching fetch message // Get latest createdAt for dispatching fetch message
if (!empty($articles)) { if (!empty($articles)) {
// Handle both Article entities and cached arrays
if (is_array($articles[0])) {
$latest = isset($articles[0]['article']['publishedAt'])
? strtotime($articles[0]['article']['publishedAt'])
: time();
} else {
$latest = $articles[0]->getCreatedAt()->getTimestamp(); $latest = $articles[0]->getCreatedAt()->getTimestamp();
}
// Dispatch async message to fetch new articles since latest + 1 // Dispatch async message to fetch new articles since latest + 1
$messageBus->dispatch(new FetchAuthorArticlesMessage($pubkey, $latest + 1)); $messageBus->dispatch(new FetchAuthorArticlesMessage($pubkey, $latest + 1));
} else { } else {
@ -266,6 +307,7 @@ class AuthorController extends AbstractController
'pubkey' => $pubkey, 'pubkey' => $pubkey,
'articles' => $articles, 'articles' => $articles,
'is_author_profile' => true, 'is_author_profile' => true,
'from_cache' => $fromCache,
]); ]);
} }

90
src/Controller/DefaultController.php

@ -9,6 +9,7 @@ use App\Entity\Event;
use App\Enum\KindsEnum; use App\Enum\KindsEnum;
use App\Service\NostrClient; use App\Service\NostrClient;
use App\Service\RedisCacheService; use App\Service\RedisCacheService;
use App\Service\RedisViewStore;
use App\Util\CommonMark\Converter; use App\Util\CommonMark\Converter;
use App\Util\ForumTopics; use App\Util\ForumTopics;
use App\Util\NostrKeyUtil; use App\Util\NostrKeyUtil;
@ -32,7 +33,6 @@ use Psr\Log\LoggerInterface;
class DefaultController extends AbstractController class DefaultController extends AbstractController
{ {
/** /**
* @throws Exception * @throws Exception
*/ */
@ -58,11 +58,34 @@ class DefaultController extends AbstractController
public function discover( public function discover(
FinderInterface $finder, FinderInterface $finder,
RedisCacheService $redisCacheService, RedisCacheService $redisCacheService,
RedisViewStore $viewStore,
CacheItemPoolInterface $articlesCache CacheItemPoolInterface $articlesCache
): Response ): Response
{ {
// Fast path: Try to get from Redis views first (single GET)
$cachedView = $viewStore->fetchLatestArticles();
$fromCache = false;
if ($cachedView !== null) {
// Redis view data already matches template expectations!
// Just extract articles and profiles - NO MAPPING NEEDED
$articles = [];
$authorsMetadata = [];
foreach ($cachedView as $baseObject) {
if (isset($baseObject['article'])) {
$articles[] = (object) $baseObject['article']; // Cast to object for template
}
if (isset($baseObject['profiles'])) {
foreach ($baseObject['profiles'] as $pubkey => $profile) {
$authorsMetadata[$pubkey] = (object) $profile; // Cast to object for template
}
}
}
$fromCache = true;
} else {
// Fallback path: Use old cache system if Redis view not available
$env = $this->getParameter('kernel.environment'); $env = $this->getParameter('kernel.environment');
// Reuse previous latest list cache key to show same set as old 'latest'
$cacheKey = 'latest_articles_list_' . $env; $cacheKey = 'latest_articles_list_' . $env;
$cacheItem = $articlesCache->getItem($cacheKey); $cacheItem = $articlesCache->getItem($cacheKey);
@ -93,14 +116,13 @@ class DefaultController extends AbstractController
$query->setCollapse($collapseSlug); $query->setCollapse($collapseSlug);
$articles = $finder->find($query); $articles = $finder->find($query);
$cacheItem->set($articles); $cacheItem->set($articles);
$cacheItem->expiresAfter(3600); // 1 hour to match command cache duration $cacheItem->expiresAfter(3600);
$articlesCache->save($cacheItem); $articlesCache->save($cacheItem);
} } else {
$articles = $cacheItem->get(); $articles = $cacheItem->get();
}
// Fetch author metadata - this is now much faster because // Fetch author metadata for fallback path
// metadata is pre-cached by the CacheLatestArticlesCommand
$authorPubkeys = []; $authorPubkeys = [];
foreach ($articles as $article) { foreach ($articles as $article) {
if ($article instanceof \App\Entity\Article) { if ($article instanceof \App\Entity\Article) {
@ -109,7 +131,6 @@ class DefaultController extends AbstractController
$authorPubkeys[] = $pubkey; $authorPubkeys[] = $pubkey;
} }
} elseif (is_object($article)) { } elseif (is_object($article)) {
// Elastica result object fallback
if (isset($article->pubkey) && NostrKeyUtil::isHexPubkey($article->pubkey)) { if (isset($article->pubkey) && NostrKeyUtil::isHexPubkey($article->pubkey)) {
$authorPubkeys[] = $article->pubkey; $authorPubkeys[] = $article->pubkey;
} elseif (isset($article->npub) && NostrKeyUtil::isNpub($article->npub)) { } elseif (isset($article->npub) && NostrKeyUtil::isNpub($article->npub)) {
@ -119,6 +140,7 @@ class DefaultController extends AbstractController
} }
$authorPubkeys = array_unique($authorPubkeys); $authorPubkeys = array_unique($authorPubkeys);
$authorsMetadata = $redisCacheService->getMultipleMetadata($authorPubkeys); $authorsMetadata = $redisCacheService->getMultipleMetadata($authorPubkeys);
}
// Build main topics key => display name map from ForumTopics constant // Build main topics key => display name map from ForumTopics constant
$mainTopicsMap = []; $mainTopicsMap = [];
@ -131,6 +153,7 @@ class DefaultController extends AbstractController
'articles' => $articles, 'articles' => $articles,
'authorsMetadata' => $authorsMetadata, 'authorsMetadata' => $authorsMetadata,
'mainTopicsMap' => $mainTopicsMap, 'mainTopicsMap' => $mainTopicsMap,
'from_redis_view' => $fromCache,
]); ]);
} }
@ -140,26 +163,51 @@ class DefaultController extends AbstractController
#[Route('/latest-articles', name: 'latest_articles')] #[Route('/latest-articles', name: 'latest_articles')]
public function latestArticles( public function latestArticles(
RedisCacheService $redisCacheService, RedisCacheService $redisCacheService,
NostrClient $nostrClient NostrClient $nostrClient,
RedisViewStore $viewStore
): Response ): Response
{ {
set_time_limit(300); // 5 minutes // Define excluded pubkeys (needed for template)
ini_set('max_execution_time', '300');
// Direct feed: always fetch fresh from relay, no caching
$key = new Key(); $key = new Key();
$excludedPubkeys = [ $excludedPubkeys = [
$key->convertToHex('npub1etsrcjz24fqewg4zmjze7t5q8c6rcwde5zdtdt4v3t3dz2navecscjjz94'), // Bitcoin Magazine (News Bot) $key->convertToHex('npub1etsrcjz24fqewg4zmjze7t5q8c6rcwde5zdtdt4v3t3dz2navecscjjz94'), // Bitcoin Magazine (News Bot)
$key->convertToHex('npub1m7szwpud3jh2k3cqe73v0fd769uzsj6rzmddh4dw67y92sw22r3sk5m3ys'), // No Bullshit Bitcoin (News Bot) $key->convertToHex('npub1m7szwpud3jh2k3cqe73v0fd769uzsj6rzmddh4dw67y92sw22r3sk5m3ys'), // No Bullshit Bitcoin (News Bot)
$key->convertToHex('npub13wke9s6njrmugzpg6mqtvy2d49g4d6t390ng76dhxxgs9jn3f2jsmq82pk'), // TFTC (News Bot) $key->convertToHex('npub13wke9s6njrmugzpg6mqtvy2d49g4d6t390ng76dhxxgs9jn3f2jsmq82pk'), // TFTC (News Bot)
$key->convertToHex('npub10akm29ejpdns52ca082skmc3hr75wmv3ajv4987c9lgyrfynrmdqduqwlx'), // Discreet Log (News Bot) $key->convertToHex('npub10akm29ejpdns52ca082skmc3hr75wmv3ajv4987c9lgyrfynrmdqduqwlx'), // Discreet Log (News Bot)
$key->convertToHex('npub13uvnw9qehqkds68ds76c4nfcn3y99c2rl9z8tr0p34v7ntzsmmzspwhh99'), // Batcoinz (Just annoying) $key->convertToHex('npub13uvnw9qehqkds68ds76c4nfcn3y99c2rl9z8tr0p34v7ntzsmmzspwhh99'), // Batcoinz
$key->convertToHex('npub1fls5au5fxj6qj0t36sage857cs4tgfpla0ll8prshlhstagejtkqc9s2yl'), // AGORA Marketplace - feed bot $key->convertToHex('npub1fls5au5fxj6qj0t36sage857cs4tgfpla0ll8prshlhstagejtkqc9s2yl'), // AGORA Marketplace
$key->convertToHex('npub1t5d8kcn0hu8zmt6dpkgatd5hwhx76956g7qmdzwnca6fzgprzlhqnqks86'), // NSFW $key->convertToHex('npub1t5d8kcn0hu8zmt6dpkgatd5hwhx76956g7qmdzwnca6fzgprzlhqnqks86'), // NSFW
$key->convertToHex('npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss'), // LNgigs $key->convertToHex('npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss'), // LNgigs
]; ];
// Fetch raw latest articles (limit 50) directly from relay // Fast path: Try Redis cache first (single GET - super fast!)
$cachedView = $viewStore->fetchLatestArticles();
if ($cachedView !== null) {
// Use cached articles - already filtered and with metadata
$articles = [];
$authorsMetadata = [];
foreach ($cachedView as $baseObject) {
if (isset($baseObject['article'])) {
$articles[] = (object) $baseObject['article'];
}
if (isset($baseObject['profiles'])) {
foreach ($baseObject['profiles'] as $pubkey => $profile) {
$authorsMetadata[$pubkey] = (object) $profile;
}
}
}
$fromCache = true;
} else {
// Cache miss: Fetch fresh from relay (slower but ensures we have data)
set_time_limit(300);
ini_set('max_execution_time', '300');
try {
// Fetch fresh from relay
$articles = $nostrClient->getLatestLongFormArticles(50); $articles = $nostrClient->getLatestLongFormArticles(50);
// Filter out excluded pubkeys // Filter out excluded pubkeys
@ -182,10 +230,18 @@ class DefaultController extends AbstractController
$authorPubkeys = array_unique($authorPubkeys); $authorPubkeys = array_unique($authorPubkeys);
$authorsMetadata = $redisCacheService->getMultipleMetadata($authorPubkeys); $authorsMetadata = $redisCacheService->getMultipleMetadata($authorPubkeys);
$fromCache = false;
} catch (\Exception $e) {
// Relay fetch failed and no cache available
throw $e;
}
}
return $this->render('pages/latest-articles.html.twig', [ return $this->render('pages/latest-articles.html.twig', [
'articles' => $articles, 'articles' => $articles,
'newsBots' => array_slice($excludedPubkeys, 0, 4), 'newsBots' => array_slice($excludedPubkeys, 0, 4),
'authorsMetadata' => $authorsMetadata 'authorsMetadata' => $authorsMetadata,
'from_redis_view' => $fromCache ?? false,
]); ]);
} }

16
src/Entity/Article.php

@ -36,6 +36,10 @@ class Article
#[ORM\Column(type: Types::TEXT, nullable: true)] #[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $content = null; private ?string $content = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $processedHtml = null;
#[ORM\Column(nullable: true, enumType: KindsEnum::class)] #[ORM\Column(nullable: true, enumType: KindsEnum::class)]
private ?KindsEnum $kind = null; private ?KindsEnum $kind = null;
@ -130,6 +134,18 @@ class Article
return $this; return $this;
} }
public function getProcessedHtml(): ?string
{
return $this->processedHtml;
}
public function setProcessedHtml(?string $processedHtml): static
{
$this->processedHtml = $processedHtml;
return $this;
}
public function getKind(): ?KindsEnum public function getKind(): ?KindsEnum
{ {
return $this->kind; return $this->kind;

26
src/ReadModel/RedisView/RedisArticleView.php

@ -0,0 +1,26 @@
<?php
namespace App\ReadModel\RedisView;
/**
* Redis view model for Article - MATCHES TEMPLATE EXPECTATIONS
* Property names match what Twig templates expect from Article entities
* This eliminates need for mapping layers
*/
final class RedisArticleView
{
public function __construct(
public string $id,
public string $slug, // Template expects: article.slug
public string $title, // Template expects: article.title
public string $pubkey, // Template expects: article.pubkey
public ?\DateTimeImmutable $createdAt = null, // Template expects: article.createdAt
public ?string $summary = null, // Template expects: article.summary
public ?string $image = null, // Template expects: article.image
public ?string $eventId = null, // Nostr event id
public ?string $contentHtml = null, // processedHtml for article detail pages
public ?\DateTimeImmutable $publishedAt = null,
public array $topics = [], // For topic filtering
) {}
}

31
src/ReadModel/RedisView/RedisBaseObject.php

@ -0,0 +1,31 @@
<?php
namespace App\ReadModel\RedisView;
/**
* Redis base object - the fundamental unit stored in Redis view lists
* Contains all data needed to render an item without additional queries
*
* This is the top-level structure stored in Redis as JSON for:
* - view:articles:latest
* - view:highlights:latest
* - view:user:articles:<pubkey>
*/
final readonly class RedisBaseObject
{
/**
* @param RedisArticleView|null $article Article data (if applicable)
* @param RedisHighlightView|null $highlight Highlight data (if applicable)
* @param RedisProfileView|null $author Primary author (article or highlight author)
* @param array<string, RedisProfileView> $profiles Map of all referenced profiles (pubkey => profile)
* @param array $meta Extensible metadata (zaps, counts, etc.)
*/
public function __construct(
public ?RedisArticleView $article = null,
public ?RedisHighlightView $highlight = null,
public ?RedisProfileView $author = null,
public array $profiles = [],
public array $meta = [],
) {}
}

20
src/ReadModel/RedisView/RedisHighlightView.php

@ -0,0 +1,20 @@
<?php
namespace App\ReadModel\RedisView;
/**
* Redis view model for Highlight entity (kind 9802, NIP-84)
* Compact representation of highlight event
*/
final readonly class RedisHighlightView
{
public function __construct(
public string $eventId, // highlight event id
public string $pubkey, // highlight author pubkey
public \DateTimeImmutable $createdAt,
public ?string $content = null, // optional note/comment
public ?string $context = null, // highlighted text
public array $refs = [], // article references
) {}
}

23
src/ReadModel/RedisView/RedisProfileView.php

@ -0,0 +1,23 @@
<?php
namespace App\ReadModel\RedisView;
/**
* Redis view model for Profile - MATCHES TEMPLATE EXPECTATIONS
* Property names match what RedisCacheService::getMetadata() returns
* Templates expect: user.name, user.picture, user.nip05, user.display_name
*/
final class RedisProfileView
{
public function __construct(
public string $pubkey,
public ?string $name = null, // PRIMARY - Template expects: user.name
public ?string $display_name = null, // Template expects: user.display_name
public ?string $picture = null, // Template expects: user.picture
public ?string $nip05 = null, // Template expects: user.nip05
public ?string $about = null,
public ?string $website = null,
public ?string $lud16 = null,
public ?string $banner = null,
) {}
}

301
src/ReadModel/RedisView/RedisViewFactory.php

@ -0,0 +1,301 @@
<?php
namespace App\ReadModel\RedisView;
use App\Entity\Article;
use App\Entity\Highlight;
use App\Service\RedisCacheService;
use Psr\Log\LoggerInterface;
/**
* Factory service for converting entities into Redis view objects
* Handles normalization/denormalization for Redis storage
*/
class RedisViewFactory
{
public function __construct(
private readonly RedisCacheService $redisCacheService,
private readonly LoggerInterface $logger,
) {}
/**
* Convert Nostr kind 0 profile metadata to RedisProfileView
* @param array|\stdClass|null $metadata Profile metadata (from RedisCacheService)
*/
public function profileToView(array|\stdClass|null $metadata, string $pubkey): ?RedisProfileView
{
if (empty($metadata)) {
$this->logger->debug('No metadata found for pubkey', ['pubkey' => $pubkey]);
return null;
}
// Convert stdClass to array if needed
if ($metadata instanceof \stdClass) {
$metadata = json_decode(json_encode($metadata), true) ?? [];
}
// Helper to extract string from array or return string/null
$getString = function($value): ?string {
if (is_array($value)) {
return !empty($value) ? (string)$value[0] : null;
}
return $value ? (string)$value : null;
};
return new RedisProfileView(
pubkey: $pubkey,
name: $metadata['name'] ?? $metadata['display_name'] ?? null, // PRIMARY name field
display_name: $metadata['display_name'] ?? null,
picture: $metadata['picture'] ?? null, // Match template expectation
nip05: $getString($metadata['nip05'] ?? null),
about: $metadata['about'] ?? null,
website: $metadata['website'] ?? null,
lud16: $getString($metadata['lud16'] ?? null),
banner: $metadata['banner'] ?? null,
);
}
/**
* Convert Article entity to RedisArticleView
*/
public function articleToView(Article $article): RedisArticleView
{
return new RedisArticleView(
id: (string) $article->getId(),
slug: $article->getSlug() ?? '', // Template expects: article.slug
title: $article->getTitle() ?? '', // Template expects: article.title
pubkey: $article->getPubkey() ?? '', // Template expects: article.pubkey
createdAt: $article->getCreatedAt(), // Template expects: article.createdAt
summary: $article->getSummary(), // Template expects: article.summary
image: $article->getImage(), // Template expects: article.image
eventId: $article->getEventId() ?? '',
contentHtml: $article->getProcessedHtml(),
publishedAt: $article->getPublishedAt(),
topics: $article->getTopics() ?? [],
);
}
/**
* Convert Highlight entity to RedisHighlightView
*/
public function highlightToView(Highlight $highlight): RedisHighlightView
{
// Convert Unix timestamp to DateTimeImmutable
$createdAt = $highlight->getCreatedAt();
$createdAtDt = $createdAt instanceof \DateTimeImmutable
? $createdAt
: new \DateTimeImmutable('@' . $createdAt);
return new RedisHighlightView(
eventId: $highlight->getEventId() ?? '',
pubkey: $highlight->getPubkey() ?? '',
createdAt: $createdAtDt,
content: $highlight->getContent(),
context: $highlight->getContext(),
refs: [
'article_coordinate' => $highlight->getArticleCoordinate(),
],
);
}
/**
* Build a complete RedisBaseObject for an article
* Fetches author profile from Redis metadata cache
* @param Article $article
* @param array|\stdClass|null $authorMetadata Author profile metadata (from RedisCacheService)
*/
public function articleBaseObject(Article $article, array|\stdClass|null $authorMetadata = null): RedisBaseObject
{
$articleView = $this->articleToView($article);
// Fetch author metadata if not provided
if ($authorMetadata === null) {
$authorMetadata = $this->redisCacheService->getMetadata($article->getPubkey());
}
$authorView = $this->profileToView($authorMetadata, $article->getPubkey());
$profiles = [];
if ($authorView !== null) {
$profiles[$authorView->pubkey] = $authorView;
}
return new RedisBaseObject(
article: $articleView,
highlight: null,
author: $authorView,
profiles: $profiles,
meta: [],
);
}
/**
* Build a complete RedisBaseObject for a highlight
* Requires the highlighted article and fetches both author profiles
* @param Highlight $highlight
* @param Article $article
* @param array|\stdClass|null $highlightAuthorMetadata Highlight author metadata
* @param array|\stdClass|null $articleAuthorMetadata Article author metadata
*/
public function highlightBaseObject(
Highlight $highlight,
Article $article,
array|\stdClass|null $highlightAuthorMetadata = null,
array|\stdClass|null $articleAuthorMetadata = null
): RedisBaseObject {
$articleView = $this->articleToView($article);
$highlightView = $this->highlightToView($highlight);
// Fetch metadata if not provided
if ($highlightAuthorMetadata === null) {
$highlightAuthorMetadata = $this->redisCacheService->getMetadata($highlight->getPubkey());
}
if ($articleAuthorMetadata === null) {
$articleAuthorMetadata = $this->redisCacheService->getMetadata($article->getPubkey());
}
$highlightAuthorView = $this->profileToView($highlightAuthorMetadata, $highlight->getPubkey());
$articleAuthorView = $this->profileToView($articleAuthorMetadata, $article->getPubkey());
$profiles = [];
if ($articleAuthorView !== null) {
$profiles[$articleAuthorView->pubkey] = $articleAuthorView;
}
if ($highlightAuthorView !== null) {
$profiles[$highlightAuthorView->pubkey] = $highlightAuthorView;
}
return new RedisBaseObject(
article: $articleView,
highlight: $highlightView,
author: $highlightAuthorView, // Primary author is the highlight author
profiles: $profiles,
meta: [],
);
}
/**
* Normalize RedisBaseObject to array for JSON storage
*/
public function normalizeBaseObject(RedisBaseObject $obj): array
{
return [
'article' => $obj->article ? $this->normalizeArticleView($obj->article) : null,
'highlight' => $obj->highlight ? $this->normalizeHighlightView($obj->highlight) : null,
'author' => $obj->author ? $this->normalizeProfileView($obj->author) : null,
'profiles' => array_map(
fn(RedisProfileView $profile) => $this->normalizeProfileView($profile),
$obj->profiles
),
'meta' => $obj->meta,
];
}
/**
* Denormalize array back to RedisBaseObject
*/
public function denormalizeBaseObject(array $data): RedisBaseObject
{
$profiles = [];
foreach ($data['profiles'] ?? [] as $pubkey => $profileData) {
$profiles[$pubkey] = $this->denormalizeProfileView($profileData);
}
return new RedisBaseObject(
article: isset($data['article']) ? $this->denormalizeArticleView($data['article']) : null,
highlight: isset($data['highlight']) ? $this->denormalizeHighlightView($data['highlight']) : null,
author: isset($data['author']) ? $this->denormalizeProfileView($data['author']) : null,
profiles: $profiles,
meta: $data['meta'] ?? [],
);
}
private function normalizeProfileView(RedisProfileView $view): array
{
return [
'pubkey' => $view->pubkey,
'name' => $view->name,
'display_name' => $view->display_name,
'picture' => $view->picture,
'nip05' => $view->nip05,
'about' => $view->about,
'website' => $view->website,
'lud16' => $view->lud16,
'banner' => $view->banner,
];
}
private function denormalizeProfileView(array $data): RedisProfileView
{
return new RedisProfileView(
pubkey: $data['pubkey'],
name: $data['name'] ?? null,
display_name: $data['display_name'] ?? null,
picture: $data['picture'] ?? null,
nip05: $data['nip05'] ?? null,
about: $data['about'] ?? null,
website: $data['website'] ?? null,
lud16: $data['lud16'] ?? null,
banner: $data['banner'] ?? null,
);
}
private function normalizeArticleView(RedisArticleView $view): array
{
return [
'id' => $view->id,
'slug' => $view->slug,
'title' => $view->title,
'pubkey' => $view->pubkey,
'createdAt' => $view->createdAt?->format(\DateTimeInterface::ATOM),
'summary' => $view->summary,
'image' => $view->image,
'eventId' => $view->eventId,
'contentHtml' => $view->contentHtml,
'publishedAt' => $view->publishedAt?->format(\DateTimeInterface::ATOM),
'topics' => $view->topics,
];
}
private function denormalizeArticleView(array $data): RedisArticleView
{
return new RedisArticleView(
id: $data['id'],
slug: $data['slug'],
title: $data['title'],
pubkey: $data['pubkey'],
createdAt: isset($data['createdAt']) ? new \DateTimeImmutable($data['createdAt']) : null,
summary: $data['summary'] ?? null,
image: $data['image'] ?? null,
eventId: $data['eventId'] ?? null,
contentHtml: $data['contentHtml'] ?? null,
publishedAt: isset($data['publishedAt']) ? new \DateTimeImmutable($data['publishedAt']) : null,
topics: $data['topics'] ?? [],
);
}
private function normalizeHighlightView(RedisHighlightView $view): array
{
return [
'eventId' => $view->eventId,
'pubkey' => $view->pubkey,
'createdAt' => $view->createdAt->format(\DateTimeInterface::ATOM),
'content' => $view->content,
'context' => $view->context,
'refs' => $view->refs,
];
}
private function denormalizeHighlightView(array $data): RedisHighlightView
{
return new RedisHighlightView(
eventId: $data['eventId'],
pubkey: $data['pubkey'],
createdAt: new \DateTimeImmutable($data['createdAt']),
content: $data['content'] ?? null,
context: $data['context'] ?? null,
refs: $data['refs'] ?? [],
);
}
}

69
src/Repository/HighlightRepository.php

@ -94,5 +94,74 @@ class HighlightRepository extends ServiceEntityRepository
->getQuery() ->getQuery()
->execute(); ->execute();
} }
/**
* Get latest highlights across all articles
* @param int $limit Maximum number of highlights to return
* @return array<Highlight>
*/
public function findLatest(int $limit = 50): array
{
return $this->createQueryBuilder('h')
->orderBy('h.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
/**
* Get latest highlights with their corresponding articles
* Uses a join or separate query to efficiently fetch both
* @param int $limit Maximum number of highlights to return
* @return array<array{highlight: Highlight, article: ?\App\Entity\Article}>
*/
public function findLatestWithArticles(int $limit = 50): array
{
// First get the highlights
$highlights = $this->findLatest($limit);
// Extract article coordinates and fetch corresponding articles
$coordinates = array_unique(array_map(
fn(Highlight $h) => $h->getArticleCoordinate(),
$highlights
));
// Query articles by their coordinates
$articles = [];
if (!empty($coordinates)) {
$em = $this->getEntityManager();
$articleRepo = $em->getRepository(\App\Entity\Article::class);
// Build article coordinate map (kind:pubkey:identifier format)
foreach ($coordinates as $coordinate) {
// Parse coordinate: 30023:pubkey:identifier
$parts = explode(':', $coordinate, 3);
if (count($parts) === 3) {
[, $pubkey, $identifier] = $parts;
// Find article by pubkey and slug (identifier)
$article = $articleRepo->findOneBy([
'pubkey' => $pubkey,
'slug' => $identifier,
]);
if ($article) {
$articles[$coordinate] = $article;
}
}
}
}
// Combine highlights with their articles
$result = [];
foreach ($highlights as $highlight) {
$result[] = [
'highlight' => $highlight,
'article' => $articles[$highlight->getArticleCoordinate()] ?? null,
];
}
return $result;
}
} }

113
src/Service/ArticleEventProjector.php

@ -0,0 +1,113 @@
<?php
namespace App\Service;
use App\Entity\Article;
use App\Factory\ArticleFactory;
use App\Util\CommonMark\Converter;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
/**
* Projects Nostr article events into the database
* Handles the conversion from event format to Article entity and persistence
* Also processes markdown content to HTML for performance optimization
*/
class ArticleEventProjector
{
public function __construct(
private readonly ArticleFactory $articleFactory,
private readonly EntityManagerInterface $entityManager,
private readonly ManagerRegistry $managerRegistry,
private readonly LoggerInterface $logger,
private readonly Converter $converter
) {
}
/**
* Project a Nostr event into the database
* Creates or updates an Article entity from the event data
*
* @param object $event The Nostr event object (stdClass with id, kind, pubkey, content, tags, etc.)
* @param string $relayUrl The relay URL where the event was received from
* @throws \Exception
*/
public function projectArticleFromEvent(object $event, string $relayUrl): void
{
try {
// Create Article entity from the event using the factory
$article = $this->articleFactory->createFromLongFormContentEvent($event);
// Process markdown content to HTML for performance optimization
if ($article->getContent()) {
try {
$processedHtml = $this->converter->convertToHTML($article->getContent());
$article->setProcessedHtml($processedHtml);
$this->logger->debug('Processed article HTML', [
'event_id' => $article->getEventId(),
'content_length' => strlen($article->getContent()),
'html_length' => strlen($processedHtml)
]);
} catch (\Exception $e) {
// If HTML conversion fails, log but continue (HTML will be null)
$this->logger->warning('Failed to process article HTML', [
'event_id' => $article->getEventId(),
'error' => $e->getMessage()
]);
}
}
// Check if article with same eventId already exists in the database
$existingArticle = $this->entityManager
->getRepository(Article::class)
->findOneBy(['eventId' => $article->getEventId()]);
if (!$existingArticle) {
// New article - persist it
$this->logger->info('Persisting new article from relay', [
'event_id' => $article->getEventId(),
'kind' => $article->getKind()?->value,
'pubkey' => $article->getPubkey(),
'title' => $article->getTitle(),
'has_processed_html' => $article->getProcessedHtml() !== null,
'relay' => $relayUrl
]);
$this->entityManager->persist($article);
$this->entityManager->flush();
$this->logger->info('Article successfully saved to database', [
'event_id' => $article->getEventId(),
'db_id' => $article->getId()
]);
} else {
$this->logger->debug('Article already exists in database, skipping', [
'event_id' => $article->getEventId(),
'db_id' => $existingArticle->getId()
]);
}
} catch (\InvalidArgumentException $e) {
// Invalid event (wrong kind, invalid signature, etc.)
$this->logger->warning('Invalid article event received', [
'event_id' => $event->id ?? 'unknown',
'error' => $e->getMessage(),
'relay' => $relayUrl
]);
throw $e;
} catch (\Exception $e) {
// Database or other errors
$this->logger->error('Error projecting article event', [
'event_id' => $event->id ?? 'unknown',
'error' => $e->getMessage(),
'relay' => $relayUrl
]);
// Reset entity manager on error to prevent further issues
$this->managerRegistry->resetManager();
throw $e;
}
}
}

223
src/Service/NostrRelayPool.php

@ -3,8 +3,12 @@
namespace App\Service; namespace App\Service;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use swentel\nostr\Filter\Filter;
use swentel\nostr\Message\RequestMessage;
use swentel\nostr\Relay\Relay; use swentel\nostr\Relay\Relay;
use swentel\nostr\RelayResponse\RelayResponse; use swentel\nostr\RelayResponse\RelayResponse;
use swentel\nostr\Subscription\Subscription;
use Symfony\Component\HttpClient\Exception\TimeoutException;
use WebSocket\Exception\Exception; use WebSocket\Exception\Exception;
/** /**
@ -224,7 +228,7 @@ class NostrRelayPool
continue; continue;
} }
$relayResponse = \swentel\nostr\RelayResponse\RelayResponse::create($decoded); $relayResponse = RelayResponse::create($decoded);
$relayResponses[] = $relayResponse; $relayResponses[] = $relayResponse;
// Check for EOSE (End of Stored Events) or CLOSED // Check for EOSE (End of Stored Events) or CLOSED
@ -405,4 +409,221 @@ class NostrRelayPool
return $relayUrls; return $relayUrls;
} }
/**
* Subscribe to local relay for article events with long-lived connection
* This method blocks indefinitely and calls the callback for each received article event
*
* @param callable $onArticleEvent Callback function that receives (object $event, string $relayUrl)
* @throws \Exception|\Throwable If local relay is not configured or connection fails
*/
public function subscribeLocalArticles(callable $onArticleEvent): void
{
if (!$this->nostrDefaultRelay) {
throw new \Exception('Local relay not configured. Set NOSTR_DEFAULT_RELAY environment variable.');
}
$relayUrl = $this->normalizeRelayUrl($this->nostrDefaultRelay);
$this->logger->info('Starting long-lived subscription to local relay for articles', [
'relay' => $relayUrl,
'kind' => 30023
]);
// Get relay connection
$relay = $this->getRelay($relayUrl);
// Ensure relay is connected
if (!$relay->isConnected()) {
$relay->connect();
}
$client = $relay->getClient();
// Use reasonable timeout like TweakedRequest does (15 seconds per receive call)
// This allows for proper WebSocket handshake and message handling
$client->setTimeout(15);
// Create subscription for article events (kind 30023)
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
$filter = new Filter();
$filter->setKinds([30023]); // Longform article events only
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
$payload = $requestMessage->generate();
$this->logger->info('Sending REQ to local relay', [
'relay' => $relayUrl,
'subscription_id' => $subscriptionId,
'filter' => ['kinds' => [30023]]
]);
// Send the subscription request
$client->text($payload);
$this->logger->info('Entering infinite receive loop for article events...');
// Track if we've received EOSE
$eoseReceived = false;
// Infinite loop: receive and process messages
while (true) {
try {
$resp = $client->receive();
// Handle PING/PONG for keepalive (following TweakedRequest pattern)
if ($resp instanceof \WebSocket\Message\Ping) {
$client->text((new \WebSocket\Message\Pong())->getPayload());
$this->logger->debug('Received PING, sent PONG');
continue;
}
// Only process text messages
if (!($resp instanceof \WebSocket\Message\Text)) {
continue;
}
$content = $resp->getContent();
$decoded = json_decode($content); // Decode as object for RelayResponse compatibility
if (!$decoded) {
$this->logger->debug('Failed to decode message from relay', [
'relay' => $relayUrl,
'content_preview' => substr($content, 0, 100)
]);
continue;
}
// Parse relay response
$relayResponse = RelayResponse::create($decoded);
// Handle different response types
switch ($relayResponse->type) {
case 'EVENT':
// This is an article event - process it
if ($relayResponse instanceof \swentel\nostr\RelayResponse\RelayResponseEvent) {
$event = $relayResponse->event;
$this->logger->info('Received article event from relay', [
'relay' => $relayUrl,
'event_id' => $event->id ?? 'unknown',
'kind' => $event->kind ?? 'unknown',
'pubkey' => substr($event->pubkey ?? 'unknown', 0, 16) . '...'
]);
// Call the callback with the event
try {
$onArticleEvent($event, $relayUrl);
} catch (\Throwable $e) {
// Log callback errors but don't break the loop
$this->logger->error('Error in article event callback', [
'event_id' => $event->id ?? 'unknown',
'error' => $e->getMessage(),
'exception' => get_class($e)
]);
}
}
break;
case 'EOSE':
// End of stored events - all historical events received
// Unlike TweakedRequest, we DON'T close the subscription or disconnect
// We want to keep listening for new live events
$eoseReceived = true;
$this->logger->info('Received EOSE - all stored events processed, now listening for new events', [
'relay' => $relayUrl,
'subscription_id' => $subscriptionId
]);
// Continue the loop - this is the key difference from short-lived requests
break;
case 'CLOSED':
// Subscription closed by relay
// Decode as array to access message by index
$decodedArray = is_array($decoded) ? $decoded : json_decode(json_encode($decoded), true);
$this->logger->warning('Relay closed subscription', [
'relay' => $relayUrl,
'subscription_id' => $subscriptionId,
'message' => $decodedArray[2] ?? 'no message'
]);
throw new \Exception('Subscription closed by relay: ' . ($decodedArray[2] ?? 'no message'));
case 'NOTICE':
// Notice from relay - check if it's an error
$decodedArray = is_array($decoded) ? $decoded : json_decode(json_encode($decoded), true);
$message = $decodedArray[1] ?? 'no message';
if (str_starts_with($message, 'ERROR:')) {
$this->logger->error('Received ERROR NOTICE from relay', [
'relay' => $relayUrl,
'message' => $message
]);
throw new \Exception('Relay error: ' . $message);
}
$this->logger->info('Received NOTICE from relay', [
'relay' => $relayUrl,
'message' => $message
]);
break;
case 'OK':
// Command result (usually for EVENT, not REQ)
$this->logger->debug('Received OK from relay', [
'relay' => $relayUrl,
'message' => json_encode($decoded)
]);
break;
case 'AUTH':
// NIP-42 auth challenge - log but don't handle for now
// Most relays won't require auth for reading public events
$decodedArray = is_array($decoded) ? $decoded : json_decode(json_encode($decoded), true);
$this->logger->info('Received AUTH challenge from relay (ignoring for read-only subscription)', [
'relay' => $relayUrl,
'challenge' => $decodedArray[1] ?? 'no challenge'
]);
break;
default:
$this->logger->debug('Received unknown message type from relay', [
'relay' => $relayUrl,
'type' => $relayResponse->type ?? 'unknown',
'content' => json_encode($decoded)
]);
break;
}
} catch (TimeoutException $e) {
// Timeout is expected when no new events arrive
// Just continue the loop to keep listening
if ($eoseReceived) {
$this->logger->debug('WebSocket timeout (normal - waiting for new events)', [
'relay' => $relayUrl
]);
}
continue;
} catch (Exception $e) {
// WebSocket errors - log and rethrow to allow Docker restart
$this->logger->error('WebSocket error in subscription loop', [
'relay' => $relayUrl,
'error' => $e->getMessage(),
'exception' => get_class($e)
]);
throw $e;
} catch (\Throwable $e) {
// Unexpected errors - log and rethrow
$this->logger->error('Unexpected error in subscription loop', [
'relay' => $relayUrl,
'error' => $e->getMessage(),
'exception' => get_class($e)
]);
throw $e;
}
}
// This line should never be reached (infinite loop above)
// But if it is, log it
$this->logger->warning('Subscription loop exited unexpectedly');
}
} }

50
src/Service/RedisCacheService.php

@ -15,15 +15,25 @@ use swentel\nostr\Key\Key;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Cache\ItemInterface;
readonly class RedisCacheService class RedisCacheService
{ {
private ?RedisViewStore $viewStore = null;
public function __construct( public function __construct(
private NostrClient $nostrClient, private readonly NostrClient $nostrClient,
private CacheItemPoolInterface $npubCache, private readonly CacheItemPoolInterface $npubCache,
private EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private LoggerInterface $logger private readonly LoggerInterface $logger
) {} ) {}
/**
* Inject RedisViewStore (using setter to avoid circular dependency)
*/
public function setViewStore(RedisViewStore $viewStore): void
{
$this->viewStore = $viewStore;
}
/** /**
* Generate the cache key for user metadata (hex pubkey only). * Generate the cache key for user metadata (hex pubkey only).
*/ */
@ -487,5 +497,35 @@ readonly class RedisCacheService
} }
} }
/**
* Invalidate Redis views that contain this profile
* Called when profile metadata (kind 0) is updated
*
* @param string $pubkey Hex pubkey whose profile was updated
*/
public function invalidateProfileViews(string $pubkey): void
{
if ($this->viewStore === null) {
return; // ViewStore not injected, skip invalidation
}
try {
// Invalidate user's articles view
$this->viewStore->invalidateUserArticles($pubkey);
// For now, we invalidate all latest views since we don't track which profiles are in them
// In future, this could be more selective
$this->viewStore->invalidateAll();
$this->logger->debug('Invalidated Redis views for profile update', [
'pubkey' => $pubkey,
]);
} catch (\Exception $e) {
$this->logger->error('Failed to invalidate Redis views', [
'pubkey' => $pubkey,
'error' => $e->getMessage(),
]);
}
}
} }

234
src/Service/RedisViewStore.php

@ -0,0 +1,234 @@
<?php
namespace App\Service;
use App\ReadModel\RedisView\RedisViewFactory;
use App\ReadModel\RedisView\RedisBaseObject;
use Psr\Log\LoggerInterface;
/**
* Service for storing and fetching Redis view objects
* Enables single-GET page rendering by caching complete view objects
*/
class RedisViewStore
{
private const KEY_LATEST_ARTICLES = 'view:articles:latest';
private const KEY_LATEST_HIGHLIGHTS = 'view:highlights:latest';
private const KEY_USER_ARTICLES = 'view:user:articles:%s'; // sprintf with pubkey
private const DEFAULT_TTL = 3600; // 1 hour
public function __construct(
private readonly \Redis $redis,
private readonly RedisViewFactory $factory,
private readonly LoggerInterface $logger,
) {}
/**
* Store latest articles view
* @param array<RedisBaseObject> $baseObjects
*/
public function storeLatestArticles(array $baseObjects): void
{
$this->storeView(self::KEY_LATEST_ARTICLES, $baseObjects, self::DEFAULT_TTL);
}
/**
* Fetch latest articles view
* @return array|null Array of denormalized base objects or null if not cached
*/
public function fetchLatestArticles(): ?array
{
return $this->fetchView(self::KEY_LATEST_ARTICLES);
}
/**
* Store latest highlights view
* @param array<RedisBaseObject> $baseObjects
*/
public function storeLatestHighlights(array $baseObjects): void
{
$this->storeView(self::KEY_LATEST_HIGHLIGHTS, $baseObjects, self::DEFAULT_TTL);
}
/**
* Fetch latest highlights view
* @return array|null Array of denormalized base objects or null if not cached
*/
public function fetchLatestHighlights(): ?array
{
return $this->fetchView(self::KEY_LATEST_HIGHLIGHTS);
}
/**
* Store user articles view
* @param string $pubkey User's pubkey
* @param array<RedisBaseObject> $baseObjects
*/
public function storeUserArticles(string $pubkey, array $baseObjects): void
{
$key = sprintf(self::KEY_USER_ARTICLES, $pubkey);
$this->storeView($key, $baseObjects, self::DEFAULT_TTL);
}
/**
* Fetch user articles view
* @param string $pubkey User's pubkey
* @return array|null Array of denormalized base objects or null if not cached
*/
public function fetchUserArticles(string $pubkey): ?array
{
$key = sprintf(self::KEY_USER_ARTICLES, $pubkey);
return $this->fetchView($key);
}
/**
* Invalidate user articles cache
*/
public function invalidateUserArticles(string $pubkey): void
{
$key = sprintf(self::KEY_USER_ARTICLES, $pubkey);
try {
$this->redis->del($key);
$this->logger->debug('Invalidated user articles cache', ['pubkey' => $pubkey, 'key' => $key]);
} catch (\Exception $e) {
$this->logger->error('Failed to invalidate user articles cache', [
'pubkey' => $pubkey,
'error' => $e->getMessage(),
]);
}
}
/**
* Invalidate all view caches
*/
public function invalidateAll(): void
{
try {
$this->redis->del(self::KEY_LATEST_ARTICLES);
$this->redis->del(self::KEY_LATEST_HIGHLIGHTS);
$this->logger->info('Invalidated all view caches');
} catch (\Exception $e) {
$this->logger->error('Failed to invalidate all caches', ['error' => $e->getMessage()]);
}
}
/**
* Store normalized view objects to Redis
* @param string $key Redis key
* @param array<RedisBaseObject> $baseObjects
* @param int|null $ttl Time to live in seconds
*/
private function storeView(string $key, array $baseObjects, ?int $ttl = null): void
{
try {
// Normalize all base objects to arrays
$normalizedObjects = array_map(
fn(RedisBaseObject $obj) => $this->factory->normalizeBaseObject($obj),
$baseObjects
);
// Encode to JSON
$json = json_encode($normalizedObjects, JSON_THROW_ON_ERROR);
// Optionally compress for large payloads
$data = $this->shouldCompress($json) ? gzcompress($json, 6) : $json;
$isCompressed = $data !== $json;
// Store in Redis
if ($ttl !== null) {
$this->redis->setex($key, $ttl, $data);
} else {
$this->redis->set($key, $data);
}
// Store compression flag
if ($isCompressed) {
$this->redis->setex($key . ':compressed', $ttl ?? self::DEFAULT_TTL, '1');
}
$this->logger->debug('Stored view in Redis', [
'key' => $key,
'count' => count($baseObjects),
'size_bytes' => strlen($data),
'compressed' => $isCompressed,
'ttl' => $ttl,
]);
} catch (\JsonException $e) {
$this->logger->error('JSON encoding failed', [
'key' => $key,
'error' => $e->getMessage(),
]);
throw $e;
} catch (\Exception $e) {
$this->logger->error('Failed to store view in Redis', [
'key' => $key,
'error' => $e->getMessage(),
]);
throw $e;
}
}
/**
* Fetch and denormalize view objects from Redis
* @return array|null Array of arrays (not denormalized to objects) or null if not found
*/
private function fetchView(string $key): ?array
{
try {
$data = $this->redis->get($key);
if ($data === false || $data === null) {
$this->logger->debug('View not found in Redis', ['key' => $key]);
return null;
}
// Check if data is compressed
$isCompressed = $this->redis->get($key . ':compressed') !== false;
// Decompress if needed
if ($isCompressed) {
$json = gzuncompress($data);
if ($json === false) {
$this->logger->error('Failed to decompress data', ['key' => $key]);
return null;
}
} else {
$json = $data;
}
// Decode JSON
$normalized = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
$this->logger->debug('Fetched view from Redis', [
'key' => $key,
'count' => count($normalized),
'compressed' => $isCompressed,
]);
return $normalized;
} catch (\JsonException $e) {
$this->logger->error('JSON decoding failed', [
'key' => $key,
'error' => $e->getMessage(),
]);
return null;
} catch (\Exception $e) {
$this->logger->error('Failed to fetch view from Redis', [
'key' => $key,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Determine if data should be compressed
* Compress if larger than 10KB
*/
private function shouldCompress(string $data): bool
{
return strlen($data) > 10240; // 10KB
}
}
Loading…
Cancel
Save