Skip to content

Commit 21603ac

Browse files
authored
Merge pull request #1559 from algolia/feature/MAGE-938
Feature/mage 938 - Replica CLI utilities
2 parents 1743f99 + 68bdc43 commit 21603ac

14 files changed

+822
-54
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
namespace Algolia\AlgoliaSearch\Api\Console;
4+
5+
interface ReplicaDeleteCommandInterface
6+
{
7+
public function deleteReplicas(array $storeIds = [], bool $unused = false): void;
8+
public function deleteReplicasForStore(int $storeId, bool $unused = false): void;
9+
public function deleteReplicasForAllStores(bool $unused = false): void;
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
namespace Algolia\AlgoliaSearch\Api\Console;
4+
5+
interface ReplicaSyncCommandInterface
6+
{
7+
public function syncReplicas(array $storeIds = []): void;
8+
public function syncReplicasForStore(int $storeId): void;
9+
public function syncReplicasForAllStores(): void;
10+
}

Api/Product/ReplicaManagerInterface.php

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,20 @@ interface ReplicaManagerInterface
2525
* @throws AlgoliaException
2626
* @throws ExceededRetriesException
2727
* @throws LocalizedException
28-
* @throws NoSuchEntityException
2928
*/
3029
public function syncReplicasToAlgolia(int $storeId, array $primaryIndexSettings): void;
3130

31+
/**
32+
* Delete the replica indices on a store index
33+
* @param int $storeId
34+
* @param bool $unused Defaults to false - if true identifies any straggler indices and deletes those, otherwise deletes the replicas it knows aobut
35+
* @return void
36+
*
37+
* @throws LocalizedException
38+
* @throws AlgoliaException
39+
*/
40+
public function deleteReplicasFromAlgolia(int $storeId, bool $unused = false): void;
41+
3242
/**
3343
* For standard Magento front end (e.g. Luma) replicas will likely only be needed if InstantSearch is enabled
3444
* Headless implementations may wish to override this behavior via plugin
@@ -44,4 +54,14 @@ public function isReplicaSyncEnabled(int $storeId): bool;
4454
* @return int
4555
*/
4656
public function getMaxVirtualReplicasPerIndex() : int;
57+
58+
/**
59+
* For a given store return replicas that do not appear to be managed by Magento
60+
* @param int $storeId
61+
* @return string[]
62+
* @throws NoSuchEntityException
63+
* @throws LocalizedException
64+
* @throws AlgoliaException
65+
*/
66+
public function getUnusedReplicaIndices(int $storeId): array;
4767
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
namespace Algolia\AlgoliaSearch\Console\Command;
4+
5+
use Magento\Framework\App\Area;
6+
use Magento\Framework\App\State;
7+
use Magento\Framework\Exception\LocalizedException;
8+
use Symfony\Component\Console\Command\Command;
9+
use Symfony\Component\Console\Input\InputArgument;
10+
use Symfony\Component\Console\Input\InputInterface;
11+
use Symfony\Component\Console\Output\OutputInterface;
12+
13+
abstract class AbstractReplicaCommand extends Command
14+
{
15+
protected const STORE_ARGUMENT = 'store';
16+
17+
protected ?OutputInterface $output = null;
18+
protected ?InputInterface $input = null;
19+
20+
public function __construct(
21+
protected State $state,
22+
?string $name = null
23+
)
24+
{
25+
parent::__construct($name);
26+
}
27+
28+
abstract protected function getReplicaCommandName(): string;
29+
30+
abstract protected function getCommandDescription(): string;
31+
32+
abstract protected function getStoreArgumentDescription(): string;
33+
34+
abstract protected function getAdditionalDefinition(): array;
35+
36+
/**
37+
* @inheritDoc
38+
*/
39+
protected function configure(): void
40+
{
41+
$definition = [$this->getStoreArgumentDefinition()];
42+
$definition = array_merge($definition, $this->getAdditionalDefinition());
43+
44+
$this->setName($this->getCommandName())
45+
->setDescription($this->getCommandDescription())
46+
->setDefinition($definition);
47+
48+
parent::configure();
49+
}
50+
51+
protected function getStoreArgumentDefinition(): InputArgument {
52+
return new InputArgument(
53+
self::STORE_ARGUMENT,
54+
InputArgument::OPTIONAL | InputArgument::IS_ARRAY,
55+
$this->getStoreArgumentDescription()
56+
);
57+
}
58+
59+
public function getCommandName(): string
60+
{
61+
return 'algolia:replicas:' . $this->getReplicaCommandName();
62+
}
63+
64+
protected function setAreaCode(): void
65+
{
66+
try {
67+
$this->state->setAreaCode(Area::AREA_CRONTAB);
68+
} catch (LocalizedException) {
69+
// Area code is already set - nothing to do
70+
}
71+
}
72+
73+
protected function getStoreIds(InputInterface $input): array
74+
{
75+
return (array) $input->getArgument(self::STORE_ARGUMENT);
76+
}
77+
78+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
<?php
2+
3+
namespace Algolia\AlgoliaSearch\Console\Command;
4+
5+
use Algolia\AlgoliaSearch\Api\Console\ReplicaDeleteCommandInterface;
6+
use Algolia\AlgoliaSearch\Api\Product\ReplicaManagerInterface;
7+
use Algolia\AlgoliaSearch\Console\Traits\ReplicaDeleteCommandTrait;
8+
use Algolia\AlgoliaSearch\Exceptions\BadRequestException;
9+
use Algolia\AlgoliaSearch\Service\StoreNameFetcher;
10+
use Magento\Framework\App\State;
11+
use Magento\Framework\Console\Cli;
12+
use Magento\Store\Model\StoreManagerInterface;
13+
use Symfony\Component\Console\Input\InputInterface;
14+
use Symfony\Component\Console\Input\InputOption;
15+
use Symfony\Component\Console\Output\OutputInterface;
16+
use Symfony\Component\Console\Question\ConfirmationQuestion;
17+
18+
class ReplicaDeleteCommand extends AbstractReplicaCommand implements ReplicaDeleteCommandInterface
19+
{
20+
use ReplicaDeleteCommandTrait;
21+
22+
protected const UNUSED_OPTION = 'unused';
23+
protected const UNUSED_OPTION_SHORTCUT = 'u';
24+
25+
public function __construct(
26+
protected ReplicaManagerInterface $replicaManager,
27+
protected StoreManagerInterface $storeManager,
28+
protected StoreNameFetcher $storeNameFetcher,
29+
protected State $state,
30+
?string $name = null
31+
)
32+
{
33+
parent::__construct($state, $name);
34+
}
35+
36+
protected function getReplicaCommandName(): string
37+
{
38+
return 'delete';
39+
}
40+
41+
protected function getCommandDescription(): string
42+
{
43+
return 'Delete associated replica indices in Algolia';
44+
}
45+
46+
protected function getStoreArgumentDescription(): string
47+
{
48+
return 'ID(s) for store(s) to delete replicas in Algolia (optional), if not specified, replicas for all stores will be deleted';
49+
}
50+
51+
protected function getAdditionalDefinition(): array
52+
{
53+
return [
54+
new InputOption(
55+
self::UNUSED_OPTION,
56+
'-' . self::UNUSED_OPTION_SHORTCUT,
57+
InputOption::VALUE_NONE,
58+
'Delete unused replicas only'
59+
)
60+
];
61+
}
62+
63+
protected function execute(InputInterface $input, OutputInterface $output): int
64+
{
65+
$this->output = $output;
66+
$this->input = $input;
67+
68+
$storeIds = $this->getStoreIds($input);
69+
$unused = $input->getOption(self::UNUSED_OPTION);
70+
71+
$msg = 'Deleting' . ($unused ? ' unused ' : ' ') . 'replicas for ' . ($storeIds ? count($storeIds) : 'all') . ' store' . (!$storeIds || count($storeIds) > 1 ? 's' : '');
72+
if ($storeIds) {
73+
$output->writeln("<info>$msg: " . join(", ", $this->storeNameFetcher->getStoreNames($storeIds)) . '</info>');
74+
} else {
75+
$output->writeln("<info>$msg</info>");
76+
}
77+
78+
if ($unused) {
79+
$unusedReplicas = $this->getUnusedReplicas($storeIds);
80+
if (!$unusedReplicas) {
81+
$output->writeln('<comment>No unused replicas found.</comment>');
82+
return Cli::RETURN_SUCCESS;
83+
}
84+
if (!$this->confirmDeleteUnused($unusedReplicas)) {
85+
return Cli::RETURN_SUCCESS;
86+
}
87+
} else if (!$this->confirmDelete()) {
88+
return Cli::RETURN_SUCCESS;
89+
}
90+
91+
try {
92+
$this->deleteReplicas($storeIds, $unused);
93+
} catch (BadRequestException $e) {
94+
$this->output->writeln("<error>Error encountered while attempting to delete replica: {$e->getMessage()}</error>");
95+
$this->output->writeln('<comment>It is likely that the Magento integration does not manage the index. You should review your application configuration in Algolia.</comment>');
96+
return CLI::RETURN_FAILURE;
97+
}
98+
99+
return Cli::RETURN_SUCCESS;
100+
}
101+
102+
/**
103+
* @param int[] $storeIds
104+
* @return string[]
105+
*/
106+
protected function getUnusedReplicas(array $storeIds): array
107+
{
108+
return array_reduce(
109+
$storeIds,
110+
function ($allUnused, $storeId) {
111+
$unused = [];
112+
try {
113+
$unused = $this->replicaManager->getUnusedReplicaIndices($storeId);
114+
} catch (\Exception $e) {
115+
$this->output->writeln("<error>Unable to retrieve unused replicas for $storeId: {$e->getMessage()}</error>");
116+
}
117+
return array_unique(array_merge($allUnused, $unused));
118+
},
119+
[]
120+
);
121+
}
122+
123+
/**
124+
* Deleting unused replica indices is potentially risky, especially if they have enabled query suggestions on their index
125+
* Verify with the end user first!
126+
*
127+
* @param string[] $unusedReplicas
128+
* @return bool
129+
*/
130+
protected function confirmDeleteUnused(array $unusedReplicas): bool
131+
{
132+
$this->output->writeln('<info>The following replicas appear to be unused and will be deleted:</info>');
133+
foreach ($unusedReplicas as $unusedReplica) {
134+
$this->output->writeln('<info> - ' . $unusedReplica . '</info>');
135+
}
136+
$helper = $this->getHelper('question');
137+
$question = new ConfirmationQuestion('<question>Do you want to proceed? (y/n)</question> ', false);
138+
139+
if (!$helper->ask($this->input, $this->output, $question)) {
140+
$this->output->writeln('<comment>Operation cancelled.</comment>');
141+
return false;
142+
}
143+
return true;
144+
}
145+
146+
147+
protected function confirmDelete(): bool
148+
{
149+
$helper = $this->getHelper('question');
150+
$question = new ConfirmationQuestion('<question>Are you sure wish to proceed? (y/n)</question> ', false);
151+
if (!$helper->ask($this->input, $this->output, $question)) {
152+
$this->output->writeln('<comment>Operation cancelled.</comment>');
153+
return false;
154+
}
155+
156+
$this->output->writeln('<comment>Please note that you can restore these deleted replicas by running "algolia:replicas:sync".</comment>');
157+
return true;
158+
}
159+
160+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<?php
2+
3+
namespace Algolia\AlgoliaSearch\Console\Command;
4+
5+
use Algolia\AlgoliaSearch\Api\Console\ReplicaDeleteCommandInterface;
6+
use Algolia\AlgoliaSearch\Api\Console\ReplicaSyncCommandInterface;
7+
use Algolia\AlgoliaSearch\Api\Product\ReplicaManagerInterface;
8+
use Algolia\AlgoliaSearch\Console\Traits\ReplicaDeleteCommandTrait;
9+
use Algolia\AlgoliaSearch\Console\Traits\ReplicaSyncCommandTrait;
10+
use Algolia\AlgoliaSearch\Exception\ReplicaLimitExceededException;
11+
use Algolia\AlgoliaSearch\Exceptions\BadRequestException;
12+
use Algolia\AlgoliaSearch\Helper\Entity\ProductHelper;
13+
use Algolia\AlgoliaSearch\Service\StoreNameFetcher;
14+
use Magento\Framework\App\State;
15+
use Magento\Framework\Console\Cli;
16+
use Symfony\Component\Console\Input\InputInterface;
17+
use Symfony\Component\Console\Output\OutputInterface;
18+
19+
class ReplicaRebuildCommand
20+
extends AbstractReplicaCommand
21+
implements ReplicaSyncCommandInterface, ReplicaDeleteCommandInterface
22+
{
23+
use ReplicaSyncCommandTrait;
24+
use ReplicaDeleteCommandTrait;
25+
26+
public function __construct(
27+
protected ReplicaManagerInterface $replicaManager,
28+
protected StoreNameFetcher $storeNameFetcher,
29+
protected ProductHelper $productHelper,
30+
State $state,
31+
?string $name = null
32+
)
33+
{
34+
parent::__construct($state, $name);
35+
}
36+
37+
protected function getReplicaCommandName(): string
38+
{
39+
return 'rebuild';
40+
}
41+
42+
protected function getCommandDescription(): string
43+
{
44+
return "Delete and rebuild replica configuration for Magento sorting attributes (only run this operation if errors are encountered during regular sync)";
45+
}
46+
47+
protected function getStoreArgumentDescription(): string
48+
{
49+
return 'ID(s) for store(s) to rebuild replicas (optional), if not specified all store replicas will be rebuilt';
50+
}
51+
52+
protected function getAdditionalDefinition(): array
53+
{
54+
return [];
55+
}
56+
57+
protected function execute(InputInterface $input, OutputInterface $output): int
58+
{
59+
$this->output = $output;
60+
$this->setAreaCode();
61+
62+
$storeIds = $this->getStoreIds($input);
63+
64+
$msg = 'Rebuilding replicas for ' . ($storeIds ? count($storeIds) : 'all') . ' store' . (!$storeIds || count($storeIds) > 1 ? 's' : '');
65+
if ($storeIds) {
66+
$output->writeln("<info>$msg: " . join(", ", $this->storeNameFetcher->getStoreNames($storeIds)) . '</info>');
67+
} else {
68+
$output->writeln("<info>$msg</info>");
69+
}
70+
71+
$this->deleteReplicas($storeIds);
72+
try {
73+
$this->syncReplicas($storeIds);
74+
} catch (ReplicaLimitExceededException $e) {
75+
$this->output->writeln('<error>' . $e->getMessage() . '</error>');
76+
$this->output->writeln('<comment>Reduce the number of sorting attributes that have enabled virtual replicas and try again.</comment>');
77+
return CLI::RETURN_FAILURE;
78+
} catch (BadRequestException $e) {
79+
$this->output->writeln('<error>' . $e->getMessage() . '</error>');
80+
if ($storeIds) {
81+
$this->output->writeln('<comment>Your Algolia application may contain cris-crossed replicas. Try running "algolia:replicas:rebuild" for all stores to correct this.');
82+
}
83+
return CLI::RETURN_FAILURE;
84+
}
85+
86+
return Cli::RETURN_SUCCESS;
87+
}
88+
89+
}

0 commit comments

Comments
 (0)