diff --git a/src/MessageHandler/FetchCommentsHandler.php b/src/MessageHandler/FetchCommentsHandler.php index aacf723..f9820b6 100644 --- a/src/MessageHandler/FetchCommentsHandler.php +++ b/src/MessageHandler/FetchCommentsHandler.php @@ -14,6 +14,10 @@ use Symfony\Component\Mercure\Update; #[AsMessageHandler] class FetchCommentsHandler { + private const SOFT_TTL = 10; // serve cached instantly within 10s + private const HARD_TTL = 60; // force refresh after 60s + private const CACHE_TTL = 90; // stored item TTL (just > HARD_TTL) + public function __construct( private readonly NostrClient $nostrClient, private readonly NostrLinkParser $nostrLinkParser, @@ -25,43 +29,142 @@ class FetchCommentsHandler public function __invoke(FetchCommentsMessage $message): void { $coordinate = $message->getCoordinate(); - $comments = $this->nostrClient->getComments($coordinate); - // Collect all pubkeys: authors and zappers - $allPubKeys = []; + // 0) Try cache + $cached = $this->redisCacheService->getCommentsPayload($coordinate); + $now = time(); + $age = $cached ? $now - (int)($cached['stored_at'] ?? 0) : PHP_INT_MAX; + + // 1) Fresh enough? Publish and return + if ($cached && $age <= self::SOFT_TTL) { + $this->publish($coordinate, $cached['comments'], $cached['profiles']); + return; + } + + // 2) Soft-stale: publish cached immediately, then refresh incrementally + if ($cached && $age <= self::HARD_TTL) { + $this->publish($coordinate, $cached['comments'], $cached['profiles']); + $this->refreshIncremental($coordinate, $cached); + return; + } + + // 3) No cache or hard-stale: full refresh + $this->refreshFull($coordinate, $cached); + } + + private function refreshIncremental(string $coordinate, array $cached): void + { + try { + $sinceTs = (int)($cached['max_ts'] ?? 0); + + // Prefer incremental fetch if your NostrClient supports it + // e.g. getComments(string $coordinate, ?int $since = null): array + $new = $this->nostrClient->getComments($coordinate, $sinceTs + 1); + + if (!empty($new)) { + $merged = $this->mergeComments($cached['comments'], $new); + [$profiles, $maxTs] = $this->hydrateProfilesAndTs($merged); + + $payload = [ + 'comments' => $merged, + 'profiles' => $profiles, + 'max_ts' => $maxTs, + 'stored_at' => time(), + ]; + + $this->redisCacheService->setCommentsPayload($coordinate, $payload, self::CACHE_TTL); + $this->publish($coordinate, $merged, $profiles); + } + } catch (\Throwable $e) { + $this->logger->warning('Incremental comments refresh failed', [ + 'coord' => $coordinate, 'err' => $e->getMessage() + ]); + } + } + + private function refreshFull(string $coordinate, ?array $cached): void + { + try { + $comments = $this->nostrClient->getComments($coordinate); + [$profiles, $maxTs] = $this->hydrateProfilesAndTs($comments); + + $payload = [ + 'comments' => $comments, + 'profiles' => $profiles, + 'max_ts' => $maxTs, + 'stored_at' => time(), + ]; + + $this->redisCacheService->setCommentsPayload($coordinate, $payload, self::CACHE_TTL); + $this->publish($coordinate, $comments, $profiles); + } catch (\Throwable $e) { + $this->logger->error('Full comments refresh failed', [ + 'coord' => $coordinate, 'err' => $e->getMessage() + ]); + + // If we had *any* cache, at least publish that so clients see something + if ($cached) { + $this->publish($coordinate, $cached['comments'], $cached['profiles']); + } + } + } + + /** Merge + sort desc by created_at, dedupe by id */ + private function mergeComments(array $existing, array $new): array + { + $byId = []; + foreach ($existing as $c) { $byId[$c->id] = $c; } + foreach ($new as $c) { $byId[$c->id] = $c; } + + $all = array_values($byId); + usort($all, fn($a, $b) => ($b->created_at ?? 0) <=> ($a->created_at ?? 0)); + return $all; + } + + /** Collect pubkeys (authors + zappers), hydrate profiles via your Redis cache, compute max_ts */ + private function hydrateProfilesAndTs(array $comments): array + { + $keys = []; + $maxTs = 0; + foreach ($comments as $c) { - $allPubKeys[] = $c->pubkey; - if ($c->kind == 9735) { - $tags = $c->tags ?? []; - foreach ($tags as $tag) { - if ($tag[0] === 'p' && isset($tag[1])) { - $allPubKeys[] = $tag[1]; + $maxTs = max($maxTs, (int)($c->created_at ?? 0)); + if (!empty($c->pubkey)) { + $keys[] = $c->pubkey; + } + if (($c->kind ?? null) == 9735) { + foreach (($c->tags ?? []) as $tag) { + if (($tag[0] ?? null) === 'p' && isset($tag[1])) { + $keys[] = $tag[1]; } } } } - $allPubKeys = array_unique($allPubKeys); - $authorsMetadata = $this->redisCacheService->getMultipleMetadata($allPubKeys); - $this->logger->info('Fetched ' . count($comments) . ' comments for coordinate: ' . $coordinate); - $this->logger->info('Fetched ' . count($authorsMetadata) . ' profiles for ' . count($allPubKeys) . ' pubkeys'); - - usort($comments, fn($a, $b) => ($b->created_at ?? 0) <=> ($a->created_at ?? 0)); - // Optionally, reuse parseNostrLinks and parseZaps logic here if needed - // For now, just send the raw comments array + + $keys = array_values(array_unique($keys)); + $profiles = $this->redisCacheService->getMultipleMetadata($keys); + + return [$profiles, $maxTs]; + } + + private function publish(string $coordinate, array $comments, array $profiles): void + { $data = [ 'coordinate' => $coordinate, - 'comments' => $comments, - 'profiles' => $authorsMetadata + 'comments' => $comments, + 'profiles' => $profiles, ]; + try { - $topic = "/comments/" . $coordinate; + $topic = "/comments/" . $coordinate; $update = new Update($topic, json_encode($data), false); - $this->logger->info('Publishing comments update for coordinate: ' . $coordinate); + $this->logger->info(sprintf( + 'Publishing comments update for %s (%d comments, %d profiles)', + $coordinate, count($comments), count($profiles) + )); $this->hub->publish($update); - } catch (\Exception $e) { - // Handle exception (log it, etc.) + } catch (\Throwable $e) { $this->logger->error('Error publishing comments update: ' . $e->getMessage()); } - } } diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index ebd66e8..436e443 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -458,7 +458,7 @@ class NostrClient * @return array Array of comment events * @throws \Exception */ - public function getComments(string $coordinate): array + public function getComments(string $coordinate, ?int $since = null): array { $this->logger->info('Getting comments for coordinate', ['coordinate' => $coordinate]); @@ -475,13 +475,21 @@ class NostrClient // Turn into a relaySet $relaySet = $this->createRelaySet($authorRelays); + // filters + $filters = [ + 'tag' => ['#A', [$coordinate]], // #A means root event + ]; + if (is_int($since) && $since > 0) { + $filters['since'] = $since; + } + // Create request using the helper method $request = $this->createNostrRequest( kinds: [ KindsEnum::COMMENTS->value, // KindsEnum::ZAP_RECEIPT->value // Not yet ], - filters: ['tag' => ['#A', [$coordinate]]], // #A means root event + filters: $filters, relaySet: $relaySet ); diff --git a/src/Service/RedisCacheService.php b/src/Service/RedisCacheService.php index 7e95805..5855247 100644 --- a/src/Service/RedisCacheService.php +++ b/src/Service/RedisCacheService.php @@ -404,4 +404,40 @@ readonly class RedisCacheService } } + private function commentsKey(string $coordinate): string + { + return 'comments_' . $coordinate; + } + + /** Return cached comments payload or null */ + public function getCommentsPayload(string $coordinate): ?array + { + $key = $this->commentsKey($coordinate); + try { + $item = $this->npubCache->getItem($key); + if ($item->isHit()) { + $val = $item->get(); + return is_array($val) ? $val : null; + } + } catch (\Throwable $e) { + $this->logger->warning('Comments cache get failed', ['e' => $e->getMessage(), 'coord' => $coordinate]); + } + return null; + } + + /** Save payload with TTL (seconds) */ + public function setCommentsPayload(string $coordinate, array $payload, int $ttl): void + { + $key = $this->commentsKey($coordinate); + try { + $item = $this->npubCache->getItem($key); + $item->set($payload); + $item->expiresAfter($ttl); + $this->npubCache->save($item); + } catch (\Throwable $e) { + $this->logger->warning('Comments cache set failed', ['e' => $e->getMessage(), 'coord' => $coordinate]); + } + } + + }