Skip to content

Commit 2f5faf6

Browse files
committed
MAGE-938 Introduce delete replicas CLI
1 parent 8ff3a54 commit 2f5faf6

File tree

8 files changed

+235
-51
lines changed

8 files changed

+235
-51
lines changed

Api/Product/ReplicaManagerInterface.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ interface ReplicaManagerInterface
2929
*/
3030
public function syncReplicasToAlgolia(int $storeId, array $primaryIndexSettings): void;
3131

32+
public function deleteReplicasFromAlgolia(int $storeId, bool $unusedOnly = false): void;
33+
3234
/**
3335
* For standard Magento front end (e.g. Luma) replicas will likely only be needed if InstantSearch is enabled
3436
* Headless implementations may wish to override this behavior via plugin
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<?php
2+
3+
namespace Algolia\AlgoliaSearch\Console\Command;
4+
5+
use Algolia\AlgoliaSearch\Api\Product\ReplicaManagerInterface;
6+
use Algolia\AlgoliaSearch\Service\StoreNameFetcher;
7+
use Magento\Framework\App\Area;
8+
use Magento\Framework\App\State;
9+
use Magento\Framework\Console\Cli;
10+
use Magento\Store\Model\StoreManagerInterface;
11+
use Symfony\Component\Console\Command\Command;
12+
use Symfony\Component\Console\Input\InputArgument;
13+
use Symfony\Component\Console\Input\InputInterface;
14+
use Symfony\Component\Console\Input\InputOption;
15+
use Symfony\Component\Console\Output\OutputInterface;
16+
17+
class ReplicaDeleteCommand extends Command
18+
{
19+
protected const STORE_ARGUMENT = 'store';
20+
21+
protected const UNUSED_OPTION = 'unused';
22+
protected const UNUSED_OPTION_SHORTCUT = 'u';
23+
24+
protected ?OutputInterface $output = null;
25+
26+
public function __construct(
27+
protected State $state,
28+
protected ReplicaManagerInterface $replicaManager,
29+
protected StoreManagerInterface $storeManager,
30+
protected StoreNameFetcher $storeNameFetcher,
31+
?string $name = null
32+
)
33+
{
34+
parent::__construct($name);
35+
}
36+
37+
/**
38+
* @inheritDoc
39+
*/
40+
protected function configure(): void
41+
{
42+
$this->setName('algolia:replicas:delete')
43+
->setDescription('Delete associated replica indices in Algolia')
44+
->setDefinition([
45+
new InputArgument(
46+
self::STORE_ARGUMENT,
47+
InputArgument::OPTIONAL | InputArgument::IS_ARRAY,
48+
'ID(s) for store(s) to delete replicas in Algolia (optional), if not specified, replicas for all stores will be deleted'
49+
),
50+
new InputOption(
51+
self::UNUSED_OPTION,
52+
'-' . self::UNUSED_OPTION_SHORTCUT,
53+
InputOption::VALUE_NONE,
54+
'Delete unused replicas only'
55+
)
56+
]);
57+
58+
parent::configure();
59+
}
60+
61+
protected function execute(InputInterface $input, OutputInterface $output): int
62+
{
63+
$storeIds = (array) $input->getArgument(self::STORE_ARGUMENT);
64+
65+
$msg = 'Deleting replicas for ' . ($storeIds ? count($storeIds) : 'all') . ' store' . (!$storeIds || count($storeIds) > 1 ? 's' : '');
66+
if ($storeIds) {
67+
$output->writeln("<info>$msg: " . join(", ", $this->storeNameFetcher->getStoreNames($storeIds)) . '</info>');
68+
} else {
69+
$output->writeln("<info>$msg</info>");
70+
}
71+
72+
$this->output = $output;
73+
// $this->state->setAreaCode(Area::AREA_ADMINHTML);
74+
75+
$this->deleteReplicas($storeIds);
76+
77+
return Cli::RETURN_SUCCESS;
78+
}
79+
80+
81+
protected function deleteReplicas(array $storeIds = [], bool $unusedOnly = false): void
82+
{
83+
if (count($storeIds)) {
84+
foreach ($storeIds as $storeId) {
85+
$this->deleteReplicasForStore($storeId);
86+
}
87+
} else {
88+
$this->deleteReplicasForAllStores();
89+
}
90+
}
91+
92+
protected function deleteReplicasForStore(int $storeId): void
93+
{
94+
$this->output->writeln('<info>Deleting replicas for ' . $this->storeNameFetcher->getStoreName($storeId) . '...</info>');
95+
$this->replicaManager->deleteReplicasFromAlgolia($storeId);
96+
}
97+
98+
protected function deleteReplicasForAllStores(): void
99+
{
100+
$storeIds = array_keys($this->storeManager->getStores());
101+
foreach ($storeIds as $storeId) {
102+
$this->deleteReplicasForStore($storeId);
103+
}
104+
}
105+
}

Console/Command/ReplicaCommand.php renamed to Console/Command/ReplicaSyncCommand.php

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
use Symfony\Component\Console\Input\InputInterface;
2121
use Symfony\Component\Console\Output\OutputInterface;
2222

23-
class ReplicaCommand extends Command
23+
class ReplicaSyncCommand extends Command
2424
{
2525
protected const STORE_ARGUMENT = 'store';
2626

@@ -55,7 +55,7 @@ protected function configure(): void
5555
new InputArgument(
5656
self::STORE_ARGUMENT,
5757
InputArgument::OPTIONAL | InputArgument::IS_ARRAY,
58-
'ID(s) for store to be synced with Algolia (optional), if not specified all stores will be synced'
58+
'ID(s) for store(s) to be synced with Algolia (optional), if not specified all stores will be synced'
5959
)
6060
]);
6161

@@ -78,14 +78,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
7878

7979
$msg = 'Syncing replicas for ' . ($storeIds ? count($storeIds) : 'all') . ' store' . (!$storeIds || count($storeIds) > 1 ? 's' : '');
8080
if ($storeIds) {
81-
/** @var string[] $storeNames */
82-
$storeNames = array_map(
83-
function($storeId) {
84-
return $this->storeNameFetcher->getStoreName($storeId);
85-
},
86-
$storeIds
87-
);
88-
$output->writeln("<info>$msg: " . join(", ", $storeNames) . '</info>');
81+
$output->writeln("<info>$msg: " . join(", ", $this->storeNameFetcher->getStoreNames($storeIds)) . '</info>');
8982
} else {
9083
$output->writeln("<info>$msg</info>");
9184
}
@@ -156,6 +149,5 @@ protected function syncReplicasForAllStores(): void
156149
foreach ($storeIds as $storeId) {
157150
$this->syncReplicasForStore($storeId);
158151
}
159-
160152
}
161153
}

Helper/Entity/ProductHelper.php

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1202,38 +1202,6 @@ protected function getAttributesForFaceting($storeId)
12021202
return $attributesForFaceting;
12031203
}
12041204

1205-
/**
1206-
* @param string $indexName
1207-
* @param array $replicas
1208-
* @param int $setReplicasTaskId
1209-
* @return void
1210-
* @throws AlgoliaException
1211-
* @throws ExceededRetriesException
1212-
*/
1213-
protected function deleteUnusedReplicas(string $indexName, array $replicas, int $setReplicasTaskId): void
1214-
{
1215-
$indicesToDelete = [];
1216-
1217-
$allIndices = $this->algoliaHelper->listIndexes();
1218-
foreach ($allIndices['items'] as $indexInfo) {
1219-
if (mb_strpos($indexInfo['name'], $indexName) !== 0 || $indexInfo['name'] === $indexName) {
1220-
continue;
1221-
}
1222-
1223-
if (mb_strpos($indexInfo['name'], IndexNameFetcher::INDEX_TEMP_SUFFIX) === false && in_array($indexInfo['name'], $replicas) === false) {
1224-
$indicesToDelete[] = $indexInfo['name'];
1225-
}
1226-
}
1227-
1228-
if (count($indicesToDelete) > 0) {
1229-
$this->algoliaHelper->waitLastTask($indexName, $setReplicasTaskId);
1230-
1231-
foreach ($indicesToDelete as $indexToDelete) {
1232-
$this->algoliaHelper->deleteIndex($indexToDelete);
1233-
}
1234-
}
1235-
}
1236-
12371205
/**
12381206
* @param $indexName
12391207
* @return void

Service/IndexNameFetcher.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,9 @@ public function getProductIndexName(int $storeId, bool $tmp = false): string
4848
return $this->getIndexName(ProductHelper::INDEX_NAME_SUFFIX, $storeId, $tmp);
4949
}
5050

51+
public function isTempIndex($indexName): bool
52+
{
53+
return str_ends_with($indexName, self::INDEX_TEMP_SUFFIX);
54+
}
55+
5156
}

Service/Product/ReplicaManager.php

Lines changed: 103 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@
3939
*/
4040
class ReplicaManager implements ReplicaManagerInterface
4141
{
42+
public const ALGOLIA_SETTINGS_KEY_REPLICAS = 'replicas';
43+
4244
protected const _DEBUG = true;
4345
protected array $_algoliaReplicaConfig = [];
4446
protected array $_magentoReplicaPossibleConfig = [];
@@ -94,7 +96,7 @@ protected function getReplicaConfigurationFromAlgolia($primaryIndexName, bool $r
9496
try {
9597
$currentSettings = $this->algoliaHelper->getSettings($primaryIndexName);
9698
$this->_algoliaReplicaConfig[$primaryIndexName] = array_key_exists('replicas', $currentSettings)
97-
? $currentSettings['replicas']
99+
? $currentSettings[self::ALGOLIA_SETTINGS_KEY_REPLICAS]
98100
: [];
99101
} catch (\Exception $e) {
100102
$msg = "Unable to retrieve replica settings for $primaryIndexName: " . $e->getMessage();
@@ -119,7 +121,7 @@ protected function clearAlgoliaReplicaSettingCache($primaryIndexName = null): vo
119121
* relevant to the Magento integration
120122
*
121123
* @param string $primaryIndexName
122-
* @return string[]
124+
* @return string[] Array of replica index names
123125
* @throws LocalizedException
124126
*/
125127
protected function getMagentoReplicaConfigurationFromAlgolia(string $primaryIndexName): array
@@ -130,21 +132,37 @@ protected function getMagentoReplicaConfigurationFromAlgolia(string $primaryInde
130132
}
131133

132134
/**
133-
* Replicas will be considered Magento managed if they are prefixed with the primary index name
135+
* Filter out non Magento managed replicas
134136
* @param string $baseIndexName
135137
* @param string[] $algoliaReplicas
136138
* @return string[]
139+
* @throws NoSuchEntityException
137140
*/
138141
protected function getMagentoReplicaSettings(string $baseIndexName, array $algoliaReplicas): array
139142
{
140143
return array_filter(
141144
$algoliaReplicas,
142145
function ($algoliaReplicaSetting) use ($baseIndexName) {
143-
return str_starts_with($this->getBareIndexNameFromReplicaSetting($algoliaReplicaSetting), $baseIndexName);
146+
return $this->isMagentoReplicaIndex($this->getBareIndexNameFromReplicaSetting($algoliaReplicaSetting), $baseIndexName);
144147
}
145148
);
146149
}
147150

151+
/**
152+
* Perform logic to determine if this is a Magento managed replica index
153+
* (By default replicas will be considered Magento managed if they are prefixed with the primary index name)
154+
*
155+
* @param string $replicaIndexName
156+
* @param int|string $storeIdOrIndex
157+
* @return bool
158+
* @throws NoSuchEntityException
159+
*/
160+
protected function isMagentoReplicaIndex(string $replicaIndexName, int|string $storeIdOrIndex): bool
161+
{
162+
$primaryIndexName = is_string($storeIdOrIndex) ? $storeIdOrIndex : $this->indexNameFetcher->getProductIndexName($storeIdOrIndex);
163+
return str_starts_with($replicaIndexName, $primaryIndexName);
164+
}
165+
148166
/**
149167
* @param string $primaryIndexName
150168
* @return array
@@ -160,12 +178,15 @@ protected function getNonMagentoReplicaConfigurationFromAlgolia(string $primaryI
160178
/**
161179
* In order to avoid interfering with replicas configured directly in the Algolia dashboard,
162180
* we must know which replica indices are Magento managed and which are not.
181+
* This method seeks to determine this based on Magento before/after state on a sorting config change
182+
* The downside here is that it will not work if Magento and Algolia get out of sync
163183
*
164184
* @param int $storeId
165185
* @param bool $refreshCache
166186
* @return array
167187
* @throws LocalizedException
168188
* @throws NoSuchEntityException
189+
* @deprecated This method has been supplanted by the much simpler getMagentoReplicaSettings() method
169190
*/
170191
protected function getMagentoReplicaSettingsFromConfig(int $storeId, bool $refreshCache = false): array
171192
{
@@ -220,12 +241,12 @@ protected function setReplicasOnPrimaryIndex(int $storeId): array
220241

221242
$this->algoliaHelper->setSettings(
222243
$indexName,
223-
['replicas' => array_merge($newMagentoReplicasSetting, $nonMagentoReplicasSetting)]
244+
[self::ALGOLIA_SETTINGS_KEY_REPLICAS => array_merge($newMagentoReplicasSetting, $nonMagentoReplicasSetting)]
224245
);
225246
$setReplicasTaskId = $this->algoliaHelper->getLastTaskId();
226247
$this->algoliaHelper->waitLastTask($indexName, $setReplicasTaskId);
227248
$this->clearAlgoliaReplicaSettingCache($indexName);
228-
$this->deleteReplicas($replicasToDelete);
249+
$this->deleteReplicaIndices($replicasToDelete);
229250

230251
if (self::_DEBUG) {
231252
$this->logger->log(
@@ -317,7 +338,7 @@ protected function getBareIndexNameFromReplicaSetting(string $replicaSetting): s
317338
* @return void
318339
* @throws AlgoliaException
319340
*/
320-
protected function deleteReplicas(array $replicasToDelete): void
341+
protected function deleteReplicaIndices(array $replicasToDelete): void
321342
{
322343
foreach ($replicasToDelete as $deletedReplica) {
323344
$this->algoliaHelper->deleteIndex($deletedReplica);
@@ -381,4 +402,79 @@ public function getMaxVirtualReplicasPerIndex() : int
381402
{
382403
return self::MAX_VIRTUAL_REPLICA_LIMIT;
383404
}
405+
406+
/**
407+
* @param string $indexName
408+
* @param array $replicas
409+
* @param int $setReplicasTaskId
410+
* @return void
411+
* @throws AlgoliaException
412+
* @throws ExceededRetriesException
413+
*/
414+
protected function deleteUnusedReplicas(string $indexName, array $replicas, int $setReplicasTaskId): void
415+
{
416+
$indicesToDelete = [];
417+
418+
$allIndices = $this->algoliaHelper->listIndexes();
419+
foreach ($allIndices['items'] as $indexInfo) {
420+
//skip any indices that don't match the primary index
421+
if (mb_strpos($indexInfo['name'], $indexName) !== 0 || $indexInfo['name'] === $indexName) {
422+
continue;
423+
}
424+
425+
// skip temp indices and expected replicas
426+
if (mb_strpos($indexInfo['name'], IndexNameFetcher::INDEX_TEMP_SUFFIX) === false && in_array($indexInfo['name'], $replicas) === false) {
427+
$indicesToDelete[] = $indexInfo['name'];
428+
}
429+
}
430+
431+
if (count($indicesToDelete) > 0) {
432+
$this->algoliaHelper->waitLastTask($indexName, $setReplicasTaskId);
433+
434+
foreach ($indicesToDelete as $indexToDelete) {
435+
$this->algoliaHelper->deleteIndex($indexToDelete);
436+
}
437+
}
438+
}
439+
440+
/**
441+
* @throws AlgoliaException
442+
*/
443+
protected function clearReplicasSettingInAlgolia(string $primaryIndexName): void
444+
{
445+
$this->algoliaHelper->setSettings($primaryIndexName, [ self::ALGOLIA_SETTINGS_KEY_REPLICAS => []]);
446+
}
447+
448+
/**
449+
* @throws AlgoliaException
450+
* @throws NoSuchEntityException
451+
* @throws LocalizedException
452+
*/
453+
public function deleteReplicasFromAlgolia(int $storeId, bool $unusedOnly = false): void
454+
{
455+
$primaryIndexName = $this->indexNameFetcher->getProductIndexName($storeId);
456+
457+
// get all possible Magento managed product indices for this store
458+
$allIndices = $this->algoliaHelper->listIndexes();
459+
460+
$currentReplicas = $this->getBareIndexNamesFromReplicaSetting($this->getMagentoReplicaConfigurationFromAlgolia($primaryIndexName));
461+
462+
if (!$unusedOnly) {
463+
$this->clearReplicasSettingInAlgolia($primaryIndexName);
464+
}
465+
466+
$replicasToDelete = [];
467+
468+
foreach ($allIndices['items'] as $indexInfo) {
469+
$indexName = $indexInfo['name'];
470+
if ($this->isMagentoReplicaIndex($indexName, $primaryIndexName)
471+
&& !$this->indexNameFetcher->isTempIndex($indexName)
472+
&& (!$unusedOnly || !array_search($indexName, $currentReplicas))
473+
) {
474+
$replicasToDelete[] = $indexName;
475+
}
476+
}
477+
478+
$this->deleteReplicaIndices($replicasToDelete);
479+
}
384480
}

0 commit comments

Comments
 (0)