Skip to content

Commit 3973c12

Browse files
committed
Merge branch 'ACP2E-3744' of https://github.com/adobe-commerce-tier-4/magento2ce into PR-04-03-2025
2 parents 12f7a7e + 2ae2c3f commit 3973c12

File tree

7 files changed

+234
-0
lines changed

7 files changed

+234
-0
lines changed
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
/**
3+
* Copyright 2025 Adobe
4+
* All Rights Reserved.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\Catalog\Model;
9+
10+
use Magento\Framework\Lock\LockManagerInterface;
11+
12+
class ProductMutex implements ProductMutexInterface
13+
{
14+
private const LOCK_PREFIX = 'product_mutex_';
15+
16+
private const LOCK_TIMEOUT = 60;
17+
18+
/**
19+
* @param LockManagerInterface $lockManager
20+
* @param int $lockWaitTimeout
21+
*/
22+
public function __construct(
23+
private readonly LockManagerInterface $lockManager,
24+
private readonly int $lockWaitTimeout = self::LOCK_TIMEOUT
25+
) {
26+
}
27+
28+
/**
29+
* @inheritdoc
30+
*/
31+
public function execute(string $sku, callable $callable, ...$args): mixed
32+
{
33+
if ($this->lockManager->lock(self::LOCK_PREFIX . $sku, $this->lockWaitTimeout)) {
34+
try {
35+
$result = $callable(...$args);
36+
} finally {
37+
$this->lockManager->unlock(self::LOCK_PREFIX . $sku);
38+
}
39+
} else {
40+
throw new ProductMutexException(
41+
__('Could not acquire lock for SKU %1', $sku)
42+
);
43+
}
44+
return $result;
45+
}
46+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
/**
3+
* Copyright 2025 Adobe
4+
* All Rights Reserved.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\Catalog\Model;
9+
10+
use Magento\Framework\Exception\StateException;
11+
12+
class ProductMutexException extends StateException
13+
{
14+
15+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
/**
3+
* Copyright 2025 Adobe
4+
* All Rights Reserved.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\Catalog\Model;
9+
10+
/**
11+
* Prevents race conditions during concurrent product save operations.
12+
*/
13+
interface ProductMutexInterface
14+
{
15+
/**
16+
* Acquires a lock for SKU, executes callable and releases the lock after.
17+
*
18+
* @param string $sku
19+
* @param callable $callable
20+
* @param array $args
21+
* @return mixed
22+
* @throws ProductMutexException
23+
*/
24+
public function execute(string $sku, callable $callable, ...$args): mixed;
25+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
/**
3+
* Copyright 2025 Adobe
4+
* All Rights Reserved.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\Catalog\Plugin;
9+
10+
use Magento\Catalog\Api\Data\ProductInterface;
11+
use Magento\Catalog\Api\ProductRepositoryInterface;
12+
use Magento\Catalog\Model\Product;
13+
use Magento\Catalog\Model\ProductMutexException;
14+
use Magento\Catalog\Model\ProductMutexInterface;
15+
use Magento\Framework\Exception\CouldNotSaveException;
16+
17+
class ProductRepositorySaveOperationSynchronizer
18+
{
19+
/**
20+
* @param ProductMutexInterface $productMutex
21+
*/
22+
public function __construct(
23+
private readonly ProductMutexInterface $productMutex
24+
) {
25+
}
26+
27+
/**
28+
* Synchronizes product save operations to avoid data corruption from concurrent requests.
29+
*
30+
* @param ProductRepositoryInterface $subject
31+
* @param callable $proceed
32+
* @param Product $product
33+
* @param mixed $saveOptions
34+
* @return ProductInterface
35+
* @throws CouldNotSaveException
36+
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
37+
*/
38+
public function aroundSave(
39+
ProductRepositoryInterface $subject,
40+
callable $proceed,
41+
ProductInterface $product,
42+
mixed $saveOptions = false
43+
): ProductInterface {
44+
try {
45+
return $this->productMutex->execute((string) $product->getSku(), $proceed, $product, $saveOptions);
46+
} catch (ProductMutexException $e) {
47+
throw new CouldNotSaveException(
48+
__('The product was unable to be saved. Please try again.'),
49+
$e
50+
);
51+
}
52+
}
53+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
/**
3+
* Copyright 2025 Adobe
4+
* All Rights Reserved.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\Catalog\Test\Unit\Model;
9+
10+
use Exception;
11+
use Magento\Catalog\Model\ProductMutex;
12+
use Magento\Catalog\Model\ProductMutexException;
13+
use Magento\Framework\Lock\LockManagerInterface;
14+
use PHPUnit\Framework\MockObject\MockObject;
15+
use PHPUnit\Framework\TestCase;
16+
17+
class ProductMutexTest extends TestCase
18+
{
19+
private const LOCK_PREFIX = 'product_mutex_';
20+
21+
private const LOCK_TIMEOUT = 60;
22+
23+
/**
24+
* @var ProductMutex
25+
*/
26+
private $model;
27+
28+
/**
29+
* @var LockManagerInterface|MockObject
30+
*/
31+
private $lockManager;
32+
33+
protected function setUp(): void
34+
{
35+
$this->lockManager = $this->createMock(LockManagerInterface::class);
36+
$this->model = new ProductMutex($this->lockManager);
37+
}
38+
39+
public function testShouldAcquireLockExecuteCallbackReleaseLockAndReturnResult(): void
40+
{
41+
$sku = 'sku';
42+
$callable = function (...$args) {
43+
return 'result: ' . implode(',', $args);
44+
};
45+
$args = ['arg1', 'arg2'];
46+
$this->lockManager->expects($this->once())
47+
->method('lock')
48+
->with(self::LOCK_PREFIX . $sku, self::LOCK_TIMEOUT)
49+
->willReturn(true);
50+
$this->lockManager->expects($this->once())
51+
->method('unlock')
52+
->with(self::LOCK_PREFIX . $sku)
53+
->willReturn(true);
54+
$this->assertEquals('result: arg1,arg2', $this->model->execute($sku, $callable, ...$args));
55+
}
56+
57+
public function testShouldThrowExceptionIfLockCannotBeAcquired(): void
58+
{
59+
$sku = 'sku';
60+
$callable = function (...$args) {
61+
return 'result: ' . implode(',', $args);
62+
};
63+
$args = ['arg1', 'arg2'];
64+
$this->lockManager->expects($this->once())
65+
->method('lock')
66+
->with(self::LOCK_PREFIX . $sku, self::LOCK_TIMEOUT)
67+
->willReturn(false);
68+
$this->lockManager->expects($this->never())
69+
->method('unlock');
70+
$this->expectException(ProductMutexException::class);
71+
$this->model->execute($sku, $callable, ...$args);
72+
}
73+
74+
public function testShouldReleaseLockIfCallbackThrowsException(): void
75+
{
76+
$sku = 'sku';
77+
$callable = function () {
78+
throw new Exception('callback exception');
79+
};
80+
$args = ['arg1', 'arg2'];
81+
$this->lockManager->expects($this->once())
82+
->method('lock')
83+
->with(self::LOCK_PREFIX . $sku, self::LOCK_TIMEOUT)
84+
->willReturn(true);
85+
$this->lockManager->expects($this->once())
86+
->method('unlock')
87+
->with(self::LOCK_PREFIX . $sku)
88+
->willReturn(true);
89+
$this->expectExceptionMessage('callback exception');
90+
$this->model->execute($sku, $callable, ...$args);
91+
}
92+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
<preference for="Magento\Theme\CustomerData\MessagesProviderInterface" type="Magento\Catalog\Model\Theme\CustomerData\MessagesProvider"/>
7979
<preference for="Magento\Catalog\Api\ProductAttributeIsFilterableManagementInterface" type="Magento\Catalog\Model\Product\Attribute\IsFilterableManagement" />
8080
<preference for="Magento\Catalog\Model\Product\Attribute\AttributeSetUnassignValidatorInterface" type="Magento\Catalog\Model\Product\Attribute\AttributeSetUnassignValidator" />
81+
<preference for="Magento\Catalog\Model\ProductMutexInterface" type="Magento\Catalog\Model\ProductMutex" />
8182
<type name="Magento\Customer\Model\ResourceModel\Visitor">
8283
<plugin name="catalogLog" type="Magento\Catalog\Model\Plugin\Log" />
8384
</type>
@@ -1335,6 +1336,7 @@
13351336
<type name="Magento\Catalog\Api\ProductRepositoryInterface">
13361337
<plugin name="remove_images_from_gallery_after_removing_product"
13371338
type="Magento\Catalog\Plugin\RemoveImagesFromGalleryAfterRemovingProduct"/>
1339+
<plugin name="add_mutex_to_save_operation" type="Magento\Catalog\Plugin\ProductRepositorySaveOperationSynchronizer"/>
13381340
</type>
13391341
<type name="Magento\Catalog\Observer\ImageResizeAfterProductSave">
13401342
<arguments>

app/code/Magento/Catalog/i18n/en_US.csv

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -868,3 +868,4 @@ Details,Details
868868
"Add To Compare","Add To Compare"
869869
"Learn more","Learn more"
870870
"Recently Viewed","Recently Viewed"
871+
"Could not acquire lock for SKU %1","Could not acquire lock for SKU %1"

0 commit comments

Comments
 (0)