Skip to content

Commit f73971a

Browse files
committed
Merge branch 'ACP2E-75' of https://github.com/magento-l3/magento2ce into L3-PR-20210908
2 parents a57ae0d + 290cd97 commit f73971a

File tree

6 files changed

+232
-19
lines changed

6 files changed

+232
-19
lines changed
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
/**
3+
* Copyright © Magento, Inc. All rights reserved.
4+
* See COPYING.txt for license details.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\Quote\Model;
9+
10+
use Magento\Framework\App\ResourceConnection;
11+
12+
/**
13+
* @inheritDoc
14+
*/
15+
class QuoteMutex implements QuoteMutexInterface
16+
{
17+
/**
18+
* @var ResourceConnection
19+
*/
20+
private $resourceConnection;
21+
22+
/**
23+
* @param ResourceConnection $resourceConnection
24+
*/
25+
public function __construct(
26+
ResourceConnection $resourceConnection
27+
) {
28+
$this->resourceConnection = $resourceConnection;
29+
}
30+
31+
/**
32+
* @inheritDoc
33+
*/
34+
public function execute(array $maskedIds, callable $callable, array $args = [])
35+
{
36+
if (empty($maskedIds)) {
37+
throw new \InvalidArgumentException('Quote masked ids must be provided');
38+
}
39+
40+
$connection = $this->resourceConnection->getConnection();
41+
$connection->beginTransaction();
42+
$query = $connection->select()
43+
->from($this->resourceConnection->getTableName('quote_id_mask'), 'entity_id')
44+
->where('masked_id IN (?)', $maskedIds)
45+
->forUpdate(true);
46+
$connection->query($query);
47+
48+
try {
49+
$result = $callable(...$args);
50+
$this->resourceConnection->getConnection()->commit();
51+
return $result;
52+
} catch (\Throwable $e) {
53+
$this->resourceConnection->getConnection()->rollBack();
54+
throw $e;
55+
}
56+
}
57+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
/**
3+
* Copyright © Magento, Inc. All rights reserved.
4+
* See COPYING.txt for license details.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\Quote\Model;
9+
10+
/**
11+
* Intended to prevent race conditions during quote update by concurrent requests.
12+
*/
13+
interface QuoteMutexInterface
14+
{
15+
/**
16+
* Acquires a lock for quote, executes callable and releases the lock after.
17+
*
18+
* @param string[] $maskedIds
19+
* @param callable $callable
20+
* @param array $args
21+
* @return mixed
22+
*/
23+
public function execute(array $maskedIds, callable $callable, array $args = []);
24+
}

app/code/Magento/Quote/etc/di.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
<preference for="Magento\Quote\Api\Data\EstimateAddressInterface" type="Magento\Quote\Model\EstimateAddress" />
4545
<preference for="Magento\Quote\Api\Data\ProductOptionInterface" type="Magento\Quote\Model\Quote\ProductOption" />
4646
<preference for="Magento\Quote\Model\ValidationRules\QuoteValidationRuleInterface" type="Magento\Quote\Model\ValidationRules\QuoteValidationComposite\Proxy"/>
47+
<preference for="Magento\Quote\Model\QuoteMutexInterface" type="Magento\Quote\Model\QuoteMutex"/>
4748
<preference for="Magento\Quote\Model\Quote\Item\Option\ComparatorInterface" type="Magento\Quote\Model\Quote\Item\Option\Comparator"/>
4849
<type name="Magento\Webapi\Controller\Rest\ParamsOverrider">
4950
<arguments>

app/code/Magento/QuoteGraphQl/Model/Resolver/AddProductsToCart.php

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Magento\Quote\Model\Cart\AddProductsToCart as AddProductsToCartService;
1616
use Magento\Quote\Model\Cart\Data\AddProductsToCartOutput;
1717
use Magento\Quote\Model\Cart\Data\CartItemFactory;
18+
use Magento\Quote\Model\QuoteMutexInterface;
1819
use Magento\QuoteGraphQl\Model\Cart\GetCartForUser;
1920
use Magento\Quote\Model\Cart\Data\Error;
2021
use Magento\QuoteGraphQl\Model\CartItem\DataProvider\Processor\ItemDataProcessorInterface;
@@ -41,19 +42,27 @@ class AddProductsToCart implements ResolverInterface
4142
*/
4243
private $itemDataProcessor;
4344

45+
/**
46+
* @var QuoteMutexInterface
47+
*/
48+
private $quoteMutex;
49+
4450
/**
4551
* @param GetCartForUser $getCartForUser
4652
* @param AddProductsToCartService $addProductsToCart
47-
* @param ItemDataProcessorInterface $itemDataProcessor
53+
* @param ItemDataProcessorInterface $itemDataProcessor
54+
* @param QuoteMutexInterface $quoteMutex
4855
*/
4956
public function __construct(
5057
GetCartForUser $getCartForUser,
5158
AddProductsToCartService $addProductsToCart,
52-
ItemDataProcessorInterface $itemDataProcessor
59+
ItemDataProcessorInterface $itemDataProcessor,
60+
QuoteMutexInterface $quoteMutex
5361
) {
5462
$this->getCartForUser = $getCartForUser;
5563
$this->addProductsToCartService = $addProductsToCart;
5664
$this->itemDataProcessor = $itemDataProcessor;
65+
$this->quoteMutex = $quoteMutex;
5766
}
5867

5968
/**
@@ -69,13 +78,29 @@ public function resolve(Field $field, $context, ResolveInfo $info, array $value
6978
throw new GraphQlInputException(__('Required parameter "cartItems" is missing'));
7079
}
7180

81+
return $this->quoteMutex->execute(
82+
[$args['cartId']],
83+
\Closure::fromCallable([$this, 'run']),
84+
[$context, $args]
85+
);
86+
}
87+
88+
/**
89+
* Run the resolver.
90+
*
91+
* @param ContextInterface $context
92+
* @param array|null $args
93+
* @return array
94+
* @throws GraphQlInputException
95+
*/
96+
private function run($context, ?array $args): array
97+
{
7298
$maskedCartId = $args['cartId'];
7399
$cartItemsData = $args['cartItems'];
74100
$storeId = (int)$context->getExtensionAttributes()->getStore()->getId();
75101

76102
// Shopping Cart validation
77103
$this->getCartForUser->execute($maskedCartId, $context->getUserId(), $storeId);
78-
79104
$cartItems = [];
80105
foreach ($cartItemsData as $cartItemData) {
81106
if (!$this->itemIsAllowedToCart($cartItemData, $context)) {

app/code/Magento/QuoteGraphQl/Model/Resolver/AddSimpleProductsToCart.php

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@
99

1010
use Magento\Framework\GraphQl\Config\Element\Field;
1111
use Magento\Framework\GraphQl\Exception\GraphQlInputException;
12+
use Magento\Framework\GraphQl\Query\Resolver\ContextInterface;
1213
use Magento\Framework\GraphQl\Query\ResolverInterface;
1314
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
15+
use Magento\Quote\Model\QuoteMutexInterface;
1416
use Magento\QuoteGraphQl\Model\Cart\AddProductsToCart;
1517
use Magento\QuoteGraphQl\Model\Cart\GetCartForUser;
16-
use Magento\Framework\Lock\LockManagerInterface;
1718

1819
/**
1920
* Add simple products to cart GraphQl resolver
@@ -32,23 +33,23 @@ class AddSimpleProductsToCart implements ResolverInterface
3233
private $addProductsToCart;
3334

3435
/**
35-
* @var LockManagerInterface
36+
* @var QuoteMutexInterface
3637
*/
37-
private $lockManager;
38+
private $quoteMutex;
3839

3940
/**
4041
* @param GetCartForUser $getCartForUser
4142
* @param AddProductsToCart $addProductsToCart
42-
* @param LockManagerInterface $lockManager
43+
* @param QuoteMutexInterface $quoteMutex
4344
*/
4445
public function __construct(
4546
GetCartForUser $getCartForUser,
4647
AddProductsToCart $addProductsToCart,
47-
LockManagerInterface $lockManager
48+
QuoteMutexInterface $quoteMutex
4849
) {
4950
$this->getCartForUser = $getCartForUser;
5051
$this->addProductsToCart = $addProductsToCart;
51-
$this->lockManager = $lockManager;
52+
$this->quoteMutex = $quoteMutex;
5253
}
5354

5455
/**
@@ -59,28 +60,37 @@ public function resolve(Field $field, $context, ResolveInfo $info, array $value
5960
if (empty($args['input']['cart_id'])) {
6061
throw new GraphQlInputException(__('Required parameter "cart_id" is missing'));
6162
}
62-
$maskedCartId = $args['input']['cart_id'];
6363

6464
if (empty($args['input']['cart_items'])
6565
|| !is_array($args['input']['cart_items'])
6666
) {
6767
throw new GraphQlInputException(__('Required parameter "cart_items" is missing'));
6868
}
69-
$cartItems = $args['input']['cart_items'];
70-
$storeId = (int)$context->getExtensionAttributes()->getStore()->getId();
7169

72-
$lockName = 'cart_processing_lock_' . $maskedCartId;
73-
while ($this->lockManager->isLocked($lockName)) {
74-
// wait till other process working with the same cart complete
75-
usleep(rand(100, 600));
76-
}
77-
$this->lockManager->lock($lockName, 1);
70+
return $this->quoteMutex->execute(
71+
[$args['input']['cart_id']],
72+
\Closure::fromCallable([$this, 'run']),
73+
[$context, $args]
74+
);
75+
}
7876

77+
/**
78+
* Run the resolver.
79+
*
80+
* @param ContextInterface $context
81+
* @param array|null $args
82+
* @return array[]
83+
* @throws GraphQlInputException
84+
*/
85+
private function run($context, ?array $args): array
86+
{
87+
$maskedCartId = $args['input']['cart_id'];
88+
$cartItems = $args['input']['cart_items'];
89+
$storeId = (int)$context->getExtensionAttributes()->getStore()->getId();
7990
$cart = $this->getCartForUser->execute($maskedCartId, $context->getUserId(), $storeId);
8091
$this->addProductsToCart->execute($cart, $cartItems);
8192
$cart = $this->getCartForUser->execute($maskedCartId, $context->getUserId(), $storeId);
8293

83-
$this->lockManager->unlock($lockName);
8494
return [
8595
'cart' => [
8696
'model' => $cart,
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<?php
2+
/**
3+
* Copyright © Magento, Inc. All rights reserved.
4+
* See COPYING.txt for license details.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\Quote\Model;
9+
10+
use Magento\Quote\Api\GuestCartManagementInterface;
11+
use Magento\TestFramework\Helper\Bootstrap as BootstrapHelper;
12+
13+
class QuoteMutexTest extends \PHPUnit\Framework\TestCase
14+
{
15+
/**
16+
* @var GuestCartManagementInterface
17+
*/
18+
private $guestCartManagement;
19+
20+
/**
21+
* @var QuoteMutexInterface
22+
*/
23+
private $quoteMutex;
24+
25+
protected function setUp(): void
26+
{
27+
$objectManager = BootstrapHelper::getObjectManager();
28+
$this->quoteMutex = $objectManager->create(QuoteMutexInterface::class);
29+
$this->guestCartManagement = $objectManager->create(GuestCartManagementInterface::class);
30+
}
31+
32+
/**
33+
* Tests quote mutex execution with different callables.
34+
*
35+
* @param callable $callable
36+
* @param array $args
37+
* @param mixed $expectedResult
38+
* @return void
39+
* @dataProvider callableDataProvider
40+
*/
41+
public function testSuccessfulExecution(callable $callable, array $args, $expectedResult): void
42+
{
43+
$maskedQuoteId = $this->guestCartManagement->createEmptyCart();
44+
$result = $this->quoteMutex->execute([$maskedQuoteId], $callable, $args);
45+
46+
$this->assertEquals($expectedResult, $result);
47+
}
48+
49+
/**
50+
* @return array[]
51+
*/
52+
public function callableDataProvider(): array
53+
{
54+
$functionWithArgs = function (int $a, int $b) {
55+
return $a + $b;
56+
};
57+
58+
$functionWithoutArgs = function () {
59+
return 'Function without args';
60+
};
61+
62+
return [
63+
['callable' => $functionWithoutArgs, 'args' => [], 'expectedResult' => 'Function without args'],
64+
['callable' => $functionWithArgs, 'args' => [1,2], 'expectedResult' => 3],
65+
[
66+
'callable' => \Closure::fromCallable([$this, 'privateMethod']),
67+
'args' => ['test'],
68+
'expectedResult' => 'test'
69+
],
70+
];
71+
}
72+
73+
/**
74+
* Private method for data provider.
75+
*
76+
* @param string $var
77+
* @return string
78+
*/
79+
private function privateMethod(string $var)
80+
{
81+
return $var;
82+
}
83+
84+
/**
85+
* Tests exception when empty maskIds array has been provided.
86+
*
87+
* @return void
88+
*/
89+
public function testWithEmptyMaskIdsArgument(): void
90+
{
91+
$this->expectException(\InvalidArgumentException::class);
92+
$callable = function () {
93+
};
94+
$this->quoteMutex->execute([], $callable);
95+
}
96+
}

0 commit comments

Comments
 (0)