Skip to content

Commit 6510216

Browse files
authored
Merge pull request #1562 from algolia/feature/MAGE-848
Feature/mage 848 - Granular virtual replica migration
2 parents 21603ac + 42d5c03 commit 6510216

14 files changed

+552
-77
lines changed

Console/Command/AbstractReplicaCommand.php

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22

33
namespace Algolia\AlgoliaSearch\Console\Command;
44

5+
use Algolia\AlgoliaSearch\Service\StoreNameFetcher;
56
use Magento\Framework\App\Area;
67
use Magento\Framework\App\State;
78
use Magento\Framework\Exception\LocalizedException;
89
use Symfony\Component\Console\Command\Command;
910
use Symfony\Component\Console\Input\InputArgument;
1011
use Symfony\Component\Console\Input\InputInterface;
1112
use Symfony\Component\Console\Output\OutputInterface;
13+
use Symfony\Component\Console\Question\ConfirmationQuestion;
1214

1315
abstract class AbstractReplicaCommand extends Command
1416
{
@@ -18,8 +20,9 @@ abstract class AbstractReplicaCommand extends Command
1820
protected ?InputInterface $input = null;
1921

2022
public function __construct(
21-
protected State $state,
22-
?string $name = null
23+
protected State $state,
24+
protected StoreNameFetcher $storeNameFetcher,
25+
?string $name = null
2326
)
2427
{
2528
parent::__construct($name);
@@ -70,9 +73,52 @@ protected function setAreaCode(): void
7073
}
7174
}
7275

76+
/**
77+
* @param InputInterface $input
78+
* @return int[]
79+
*/
7380
protected function getStoreIds(InputInterface $input): array
7481
{
7582
return (array) $input->getArgument(self::STORE_ARGUMENT);
7683
}
7784

85+
/**
86+
* @param int[] $storeIds
87+
* @return string
88+
*/
89+
protected function getOperationTargetLabel(array $storeIds): string
90+
{
91+
return ($storeIds ? count($storeIds) : 'all') . ' store' . (!$storeIds || count($storeIds) > 1 ? 's' : '');
92+
}
93+
94+
/**
95+
* Generate a CLI operation announcement based on passed store arguments
96+
* @param string $msg Use {{target} in message as a placeholder for inserting the generated target label
97+
* @param int[] $storeIds
98+
* @return string
99+
*/
100+
protected function decorateOperationAnnouncementMessage(string $msg, array $storeIds): string
101+
{
102+
$msg = str_replace('{{target}}', $this->getOperationTargetLabel($storeIds), $msg);
103+
return ($storeIds)
104+
? "<info>$msg: " . join(", ", $this->storeNameFetcher->getStoreNames($storeIds)) . '</info>'
105+
: "<info>$msg</info>";
106+
}
107+
108+
protected function confirmOperation(string $okMessage = '', string $cancelMessage = 'Operation cancelled'): bool
109+
{
110+
$helper = $this->getHelper('question');
111+
$question = new ConfirmationQuestion('<question>Are you sure wish to proceed? (y/n)</question> ', false);
112+
if (!$helper->ask($this->input, $this->output, $question)) {
113+
if ($cancelMessage) {
114+
$this->output->writeln("<comment>$cancelMessage</comment>");
115+
}
116+
return false;
117+
}
118+
119+
if ($okMessage) {
120+
$this->output->writeln("<comment>$okMessage</comment>");
121+
}
122+
return true;
123+
}
78124
}

Console/Command/ReplicaDeleteCommand.php

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,12 @@ class ReplicaDeleteCommand extends AbstractReplicaCommand implements ReplicaDele
2525
public function __construct(
2626
protected ReplicaManagerInterface $replicaManager,
2727
protected StoreManagerInterface $storeManager,
28-
protected StoreNameFetcher $storeNameFetcher,
29-
protected State $state,
28+
State $state,
29+
StoreNameFetcher $storeNameFetcher,
3030
?string $name = null
3131
)
3232
{
33-
parent::__construct($state, $name);
33+
parent::__construct($state, $storeNameFetcher, $name);
3434
}
3535

3636
protected function getReplicaCommandName(): string
@@ -68,12 +68,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int
6868
$storeIds = $this->getStoreIds($input);
6969
$unused = $input->getOption(self::UNUSED_OPTION);
7070

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-
}
71+
$output->writeln(
72+
$this->decorateOperationAnnouncementMessage(
73+
'Deleting' . ($unused ? ' unused ' : ' ') . 'replicas for {{target}}',
74+
$storeIds
75+
)
76+
);
7777

7878
if ($unused) {
7979
$unusedReplicas = $this->getUnusedReplicas($storeIds);
@@ -143,18 +143,10 @@ protected function confirmDeleteUnused(array $unusedReplicas): bool
143143
return true;
144144
}
145145

146-
147146
protected function confirmDelete(): bool
148147
{
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;
148+
$okMsg = 'Please note that you can restore these deleted replicas by running "algolia:replicas:sync".';
149+
return $this->confirmOperation($okMsg);
158150
}
159151

160152
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
<?php
2+
3+
namespace Algolia\AlgoliaSearch\Console\Command;
4+
5+
use Algolia\AlgoliaSearch\Api\Console\ReplicaSyncCommandInterface;
6+
use Algolia\AlgoliaSearch\Api\Product\ReplicaManagerInterface;
7+
use Algolia\AlgoliaSearch\Console\Traits\ReplicaSyncCommandTrait;
8+
use Algolia\AlgoliaSearch\Helper\ConfigHelper;
9+
use Algolia\AlgoliaSearch\Helper\Configuration\ConfigChecker;
10+
use Algolia\AlgoliaSearch\Helper\Entity\ProductHelper;
11+
use Algolia\AlgoliaSearch\Service\StoreNameFetcher;
12+
use Magento\Framework\App\Cache\Manager as CacheManager;
13+
use Magento\Framework\App\Config\ReinitableConfigInterface;
14+
use Magento\Framework\App\Config\ScopeConfigInterface;
15+
use Magento\Framework\App\Config\Storage\WriterInterface;
16+
use Magento\Framework\App\State;
17+
use Magento\Framework\Console\Cli;
18+
use Magento\Framework\Serialize\SerializerInterface;
19+
use Magento\Store\Model\ScopeInterface;
20+
use Magento\Store\Model\StoreManagerInterface;
21+
use Symfony\Component\Console\Input\InputInterface;
22+
use Symfony\Component\Console\Output\OutputInterface;
23+
24+
class ReplicaDisableVirtualCommand extends AbstractReplicaCommand implements ReplicaSyncCommandInterface {
25+
use ReplicaSyncCommandTrait;
26+
27+
public function __construct(
28+
protected WriterInterface $configWriter,
29+
protected ConfigChecker $configChecker,
30+
protected ReinitableConfigInterface $scopeConfig,
31+
protected SerializerInterface $serializer,
32+
protected ConfigHelper $configHelper,
33+
protected CacheManager $cacheManager,
34+
protected ReplicaManagerInterface $replicaManager,
35+
protected StoreManagerInterface $storeManager,
36+
protected ProductHelper $productHelper,
37+
State $state,
38+
StoreNameFetcher $storeNameFetcher,
39+
?string $name = null
40+
)
41+
{
42+
parent::__construct($state, $storeNameFetcher, $name);
43+
}
44+
45+
protected function getReplicaCommandName(): string
46+
{
47+
return 'disable-virtual-replicas';
48+
}
49+
50+
protected function getCommandDescription(): string
51+
{
52+
return 'Disable virtual replicas for all product sorting attributes and revert to standard replicas';
53+
}
54+
55+
protected function getStoreArgumentDescription(): string
56+
{
57+
return 'ID(s) for store(s) to disable virtual replicas (optional), if not specified disable virtual replicas for all stores';
58+
}
59+
60+
protected function getAdditionalDefinition(): array
61+
{
62+
return [];
63+
}
64+
65+
protected function execute(InputInterface $input, OutputInterface $output): int
66+
{
67+
$this->output = $output;
68+
$this->input = $input;
69+
$this->setAreaCode();
70+
71+
$storeIds = $this->getStoreIds($input);
72+
73+
$output->writeln($this->decorateOperationAnnouncementMessage('Disabling virtual replicas for {{target}}', $storeIds));
74+
75+
$okMsg = 'Configure virtual replicas by attribute under: Stores > Configuration > Algolia Search > InstantSearch Results Page > Sorting';
76+
if (!$this->confirmOperation($okMsg)) {
77+
return CLI::RETURN_SUCCESS;
78+
}
79+
80+
$this->disableVirtualReplicas($storeIds);
81+
82+
return Cli::RETURN_SUCCESS;
83+
}
84+
85+
/**
86+
* @param int[] $storeIds
87+
* @return void
88+
*/
89+
protected function disableVirtualReplicas(array $storeIds = []): void
90+
{
91+
$updates = [];
92+
if (count($storeIds)) {
93+
foreach ($storeIds as $storeId) {
94+
if ($this->disableVirtualReplicasForStore($storeId)) {
95+
$updates[] = $storeId;
96+
}
97+
}
98+
if ($updates) {
99+
$this->scopeConfig->reinit();
100+
foreach ($updates as $storeId) {
101+
$this->syncReplicasForStore($storeId);
102+
}
103+
}
104+
} else {
105+
$this->disableVirtualReplicasForAllStores();
106+
}
107+
}
108+
109+
protected function disableVirtualReplicasForStore(int $storeId): bool
110+
{
111+
$storeName = $this->storeNameFetcher->getStoreName($storeId);
112+
$isStoreScoped = false;
113+
114+
if ($this->configChecker->isSettingAppliedForScopeAndCode(
115+
ConfigHelper::LEGACY_USE_VIRTUAL_REPLICA_ENABLED,
116+
ScopeInterface::SCOPE_STORES,
117+
$storeId)
118+
) {
119+
$isStoreScoped = true;
120+
$this->removeLegacyVirtualReplicaConfig(ScopeInterface::SCOPE_STORES, $storeId);
121+
}
122+
123+
if ($this->configChecker->isSettingAppliedForScopeAndCode(
124+
ConfigHelper::SORTING_INDICES,
125+
ScopeInterface::SCOPE_STORES,
126+
$storeId)
127+
) {
128+
$isStoreScoped = true;
129+
$this->disableVirtualReplicaSortConfig(ScopeInterface::SCOPE_STORES, $storeId);
130+
}
131+
132+
if (!$isStoreScoped) {
133+
$this->output->writeln("<info>Virtual replicas are not configured at the store level for $storeName. You will need to re-run this command for all stores.</info>");
134+
return false;
135+
}
136+
137+
return true;
138+
}
139+
140+
protected function disableVirtualReplicasForAllStores(): void
141+
{
142+
$this->configChecker->checkAndApplyAllScopes(ConfigHelper::LEGACY_USE_VIRTUAL_REPLICA_ENABLED, [$this, 'removeLegacyVirtualReplicaConfig']);
143+
144+
$this->configChecker->checkAndApplyAllScopes(ConfigHelper::SORTING_INDICES, [$this, 'disableVirtualReplicaSortConfig']);
145+
146+
$this->scopeConfig->reinit();
147+
148+
$this->syncReplicasForAllStores();
149+
}
150+
151+
public function removeLegacyVirtualReplicaConfig(string $scope = ScopeConfigInterface::SCOPE_TYPE_DEFAULT, int $scopeId = 0): void
152+
{
153+
$value = $this->scopeConfig->getValue(ConfigHelper::LEGACY_USE_VIRTUAL_REPLICA_ENABLED, $scope, $scopeId);
154+
if (is_null($value)) {
155+
return;
156+
}
157+
$this->output->writeln("<info>Removing legacy configuration " . ConfigHelper::LEGACY_USE_VIRTUAL_REPLICA_ENABLED . " for $scope scope" . ($scope != ScopeConfigInterface::SCOPE_TYPE_DEFAULT ? " (ID=$scopeId)" : "") . "</info>");
158+
$this->configWriter->delete(ConfigHelper::LEGACY_USE_VIRTUAL_REPLICA_ENABLED, $scope, $scopeId);
159+
}
160+
161+
public function disableVirtualReplicaSortConfig(string $scope = ScopeConfigInterface::SCOPE_TYPE_DEFAULT, int $scopeId = 0): void
162+
{
163+
$raw = $this->scopeConfig->getValue(ConfigHelper::SORTING_INDICES, $scope, $scopeId);
164+
if (!$raw) {
165+
return;
166+
}
167+
$sorting = array_map(
168+
function($sort) {
169+
$sort[ReplicaManagerInterface::SORT_KEY_VIRTUAL_REPLICA] = 0;
170+
return $sort;
171+
},
172+
$this->serializer->unserialize($raw)
173+
);
174+
$this->output->writeln("<info>Disabling all virtual replicas in " . ConfigHelper::SORTING_INDICES . " for $scope scope" . ($scope != ScopeConfigInterface::SCOPE_TYPE_DEFAULT ? " (ID=$scopeId)" : "") . "</info>");
175+
$this->configHelper->setSorting($sorting, $scope, $scopeId);
176+
}
177+
178+
}

Console/Command/ReplicaRebuildCommand.php

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
1010
use Algolia\AlgoliaSearch\Exception\ReplicaLimitExceededException;
1111
use Algolia\AlgoliaSearch\Exceptions\BadRequestException;
1212
use Algolia\AlgoliaSearch\Helper\Entity\ProductHelper;
13+
use Algolia\AlgoliaSearch\Registry\ReplicaState;
1314
use Algolia\AlgoliaSearch\Service\StoreNameFetcher;
14-
use Magento\Framework\App\State;
15+
use Magento\Framework\App\State as AppState;
1516
use Magento\Framework\Console\Cli;
17+
use Magento\Store\Model\StoreManagerInterface;
1618
use Symfony\Component\Console\Input\InputInterface;
1719
use Symfony\Component\Console\Output\OutputInterface;
1820

@@ -24,14 +26,16 @@ class ReplicaRebuildCommand
2426
use ReplicaDeleteCommandTrait;
2527

2628
public function __construct(
27-
protected ReplicaManagerInterface $replicaManager,
28-
protected StoreNameFetcher $storeNameFetcher,
2929
protected ProductHelper $productHelper,
30-
State $state,
30+
protected ReplicaManagerInterface $replicaManager,
31+
protected StoreManagerInterface $storeManager,
32+
protected ReplicaState $replicaState,
33+
AppState $appState,
34+
StoreNameFetcher $storeNameFetcher,
3135
?string $name = null
3236
)
3337
{
34-
parent::__construct($state, $name);
38+
parent::__construct($appState, $storeNameFetcher, $name);
3539
}
3640

3741
protected function getReplicaCommandName(): string
@@ -61,14 +65,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int
6165

6266
$storeIds = $this->getStoreIds($input);
6367

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-
}
68+
$output->writeln($this->decorateOperationAnnouncementMessage('Rebuilding replicas for {{target}}', $storeIds));
7069

7170
$this->deleteReplicas($storeIds);
71+
$this->forceState($storeIds);
72+
7273
try {
7374
$this->syncReplicas($storeIds);
7475
} catch (ReplicaLimitExceededException $e) {
@@ -86,4 +87,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int
8687
return Cli::RETURN_SUCCESS;
8788
}
8889

90+
/**
91+
* Force the replica change state to always sync the replica configuration
92+
* Also serves to avoid latency from Algolia API when reading replica configuration for comparison with local Magento config
93+
* @param int[] $storeIds
94+
* @return void
95+
*/
96+
protected function forceState(array $storeIds): void
97+
{
98+
if (!count($storeIds)) {
99+
$storeIds = array_keys($this->storeManager->getStores());
100+
}
101+
foreach ($storeIds as $storeId) {
102+
$this->replicaState->setChangeState(ReplicaState::REPLICA_STATE_CHANGED, $storeId);
103+
}
104+
}
105+
89106
}

0 commit comments

Comments
 (0)