Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions src/Repository/CardDocumentRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,34 @@ public function findDocument(int $cardId): ?array
return $row !== false ? $this->hydrate($row) : null;
}

/**
* Build documents for every card belonging to the given CardGroups, in one query.
* Used to bulk-reindex a small, targeted set of groups (e.g. after a gameplay-format
* import) without the N+1 HTTP-per-card cost of calling findDocument() in a loop.
*
* @param int[] $cardGroupIds
* @return array<int, array<string, mixed>>
*/
public function findDocumentsByCardGroupIds(array $cardGroupIds): array
{
if (empty($cardGroupIds)) {
return [];
}

$result = $this->connection->executeQuery(
$this->buildSql(whereCardGroupIds: true),
['cardGroupIds' => $cardGroupIds],
['cardGroupIds' => \Doctrine\DBAL\ArrayParameterType::INTEGER],
);

$docs = [];
while (($row = $result->fetchAssociative()) !== false) {
$docs[] = $this->hydrate($row);
}

return $docs;
}

/**
* Count total cards (for progress bars, etc.).
*/
Expand All @@ -171,9 +199,13 @@ private function sanitize(?string $text): ?string
return preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $clean);
}

private function buildSql(bool $whereCardId = false): string
private function buildSql(bool $whereCardId = false, bool $whereCardGroupIds = false): string
{
$where = $whereCardId ? 'WHERE c.id = :id' : '';
$where = match (true) {
$whereCardId => 'WHERE c.id = :id',
$whereCardGroupIds => 'WHERE cg.id IN (:cardGroupIds)',
default => '',
};

return <<<SQL
SELECT
Expand Down
59 changes: 51 additions & 8 deletions src/Service/GameplayFormatImportService.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@

namespace App\Service;

use App\EventListener\CardSearchListener;
use App\EventListener\MeilisearchSyncListener;
use App\Repository\CardDocumentRepository;
use App\Repository\CardGroupRepository;
use App\Repository\CardRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
Expand All @@ -19,7 +23,11 @@ public function __construct(
private HttpClientInterface $httpClient,
private CardRepository $cardRepository,
private CardGroupRepository $cardGroupRepository,
private CardDocumentRepository $cardDocumentRepository,
private CardSearchUpdater $cardSearchUpdater,
private MeilisearchService $meilisearch,
private EntityManagerInterface $em,
private LoggerInterface $logger,
) {}

/**
Expand Down Expand Up @@ -58,6 +66,13 @@ public function fetchAndValidate(string $url): GameplayFormatImportResult
/**
* Adds $formatName to the gameplayFormat array of every given CardGroup (idempotent).
*
* A CardGroup can have dozens of Card prints (reprints, serialized variants...).
* The default per-entity listeners (MeilisearchSyncListener, CardSearchListener)
* would fire once per Card on every touched CardGroup — for a few hundred groups
* that's thousands of synchronous Meilisearch HTTP calls in one request, which is
* what caused the 504 on import. Disable them for the bulk write and resync the
* affected groups in two batched calls instead.
*
* @param int[] $cardGroupIds
* @return int Number of CardGroups actually modified.
*/
Expand All @@ -68,17 +83,45 @@ public function apply(string $formatName, array $cardGroupIds): int
return 0;
}

$updated = 0;
foreach ($this->cardGroupRepository->findBy(['id' => $cardGroupIds]) as $cardGroup) {
$current = $cardGroup->getGameplayFormat();
if (!in_array($formatName, $current, true)) {
$cardGroup->setGameplayFormat([...$current, $formatName]);
$updated++;
$updatedGroupIds = [];

MeilisearchSyncListener::$disabled = true;
CardSearchListener::$disabled = true;
try {
foreach ($this->cardGroupRepository->findBy(['id' => $cardGroupIds]) as $cardGroup) {
$current = $cardGroup->getGameplayFormat();
if (!in_array($formatName, $current, true)) {
$cardGroup->setGameplayFormat([...$current, $formatName]);
$updatedGroupIds[] = $cardGroup->getId();
}
}
$this->em->flush();
} finally {
MeilisearchSyncListener::$disabled = false;
CardSearchListener::$disabled = false;
}

if (empty($updatedGroupIds)) {
return 0;
}

foreach ($updatedGroupIds as $cardGroupId) {
$this->cardSearchUpdater->upsertByCardGroupId($cardGroupId);
}

try {
$this->meilisearch->indexDocuments(
$this->cardDocumentRepository->findDocumentsByCardGroupIds($updatedGroupIds)
);
} catch (\Throwable $e) {
// The gameplayFormat write already succeeded in Postgres — a Meilisearch
// hiccup here shouldn't fail the import, just leave the index briefly stale.
$this->logger->error('Gameplay-format import: bulk Meilisearch reindex failed', [
'error' => $e->getMessage(),
]);
}
$this->em->flush();

return $updated;
return count($updatedGroupIds);
}

/** @return string[] */
Expand Down
14 changes: 14 additions & 0 deletions src/Service/MeilisearchService.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,20 @@ public function indexCard(Card $card): void
}
}

/**
* Push multiple prebuilt documents in a single request (bulk update, e.g. after
* a batch write that disabled the per-entity MeilisearchSyncListener).
*
* @param array<int, array<string, mixed>> $docs
*/
public function indexDocuments(array $docs): void
{
if (empty($docs)) {
return;
}
$this->getIndex()->addDocuments($docs);
}

/**
* Delete a single card from the index.
*/
Expand Down
Loading