19 changed files with 1835 additions and 182 deletions
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -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 |
||||||
|
) {} |
||||||
|
} |
||||||
|
|
||||||
@ -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 = [], |
||||||
|
) {} |
||||||
|
} |
||||||
|
|
||||||
@ -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 |
||||||
|
) {} |
||||||
|
} |
||||||
|
|
||||||
@ -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, |
||||||
|
) {} |
||||||
|
} |
||||||
@ -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'] ?? [], |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -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…
Reference in new issue