Skip to content

Commit a6948b6

Browse files
committed
ACP2E-4303: Reindexing stuck due to high memory usage
1 parent 9a62604 commit a6948b6

File tree

3 files changed

+441
-0
lines changed

3 files changed

+441
-0
lines changed
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
<?php
2+
/**
3+
* Copyright 2025 Adobe
4+
* All Rights Reserved.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\CatalogRule\Model\ResourceModel\Product;
9+
10+
use Magento\Eav\Model\Entity\Attribute\AbstractAttribute;
11+
use Magento\Framework\Exception\LocalizedException;
12+
13+
/**
14+
* Lazy-loading attribute values container with bounded memory usage
15+
*
16+
* @implements \ArrayAccess<int, array<int, mixed>>
17+
* @implements \Countable
18+
*/
19+
class AttributeValuesLoader implements \ArrayAccess, \Countable
20+
{
21+
/**
22+
* Number of products to load per batch
23+
*/
24+
private const BATCH_SIZE = 5000;
25+
26+
/**
27+
* Maximum number of batches to keep in memory
28+
* With BATCH_SIZE=5000, this means ~10k products in memory max
29+
*/
30+
private const MAX_BATCHES_IN_MEMORY = 2;
31+
32+
/**
33+
* @var Collection
34+
*/
35+
private Collection $collection;
36+
37+
/**
38+
* @var AbstractAttribute
39+
*/
40+
private AbstractAttribute $attribute;
41+
42+
/**
43+
* @var array<int, array<int, mixed>>
44+
*/
45+
private array $loadedData = [];
46+
47+
/**
48+
* @var array<int, int>
49+
*/
50+
private array $entityToBatch = [];
51+
52+
/**
53+
* @var array<int, bool>
54+
*/
55+
private array $loadedBatches = [];
56+
57+
/**
58+
* @var array<int>
59+
*/
60+
private array $batchQueue = [];
61+
62+
/**
63+
* @var int|null
64+
*/
65+
private ?int $totalCount = null;
66+
67+
/**
68+
* @var array<int>
69+
*/
70+
private array $allEntityIds = [];
71+
72+
/**
73+
* @param Collection $collection
74+
* @param AbstractAttribute $attribute
75+
*/
76+
public function __construct(Collection $collection, AbstractAttribute $attribute)
77+
{
78+
$this->collection = $collection;
79+
$this->attribute = $attribute;
80+
}
81+
82+
/**
83+
* Check if entity has attribute values
84+
*
85+
* @param mixed $offset
86+
* @return bool
87+
*/
88+
public function offsetExists($offset): bool
89+
{
90+
$this->ensureEntityLoaded((int)$offset);
91+
return isset($this->loadedData[$offset]);
92+
}
93+
94+
/**
95+
* Get attribute values for entity
96+
*
97+
* @param mixed $offset
98+
* @return array<int, mixed>|null
99+
*/
100+
public function offsetGet($offset): ?array
101+
{
102+
$this->ensureEntityLoaded((int)$offset);
103+
return $this->loadedData[$offset] ?? null;
104+
}
105+
106+
/**
107+
* Set offset not supported
108+
*
109+
* @param mixed $offset
110+
* @param mixed $value
111+
* @return void
112+
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
113+
*/
114+
public function offsetSet($offset, $value): void
115+
{
116+
throw new \LogicException('AttributeValuesLoader is read-only');
117+
}
118+
119+
/**
120+
* Offset unset not supported
121+
*
122+
* @param mixed $offset
123+
* @return void
124+
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
125+
*/
126+
public function offsetUnset($offset): void
127+
{
128+
throw new \LogicException('AttributeValuesLoader is read-only');
129+
}
130+
131+
/**
132+
* Get total count of entities
133+
*
134+
* @return int
135+
*/
136+
public function count(): int
137+
{
138+
if ($this->totalCount === null) {
139+
$this->loadEntityIds();
140+
}
141+
return $this->totalCount;
142+
}
143+
144+
/**
145+
* Ensure the batch containing the given entity is loaded
146+
*
147+
* @param int $entityId
148+
* @return void
149+
* @throws LocalizedException
150+
*/
151+
private function ensureEntityLoaded(int $entityId): void
152+
{
153+
$batchNumber = $this->getBatchNumberForEntity($entityId);
154+
155+
if ($batchNumber === null || isset($this->loadedBatches[$batchNumber])) {
156+
return;
157+
}
158+
159+
$this->loadBatch($batchNumber);
160+
161+
$this->evictOldBatchesIfNeeded();
162+
}
163+
164+
/**
165+
* Get batch number for entity
166+
*
167+
* @param int $entityId
168+
* @return int|null
169+
*/
170+
private function getBatchNumberForEntity(int $entityId): ?int
171+
{
172+
if (empty($this->allEntityIds)) {
173+
$this->loadEntityIds();
174+
}
175+
176+
if (!isset($this->entityToBatch[$entityId])) {
177+
return null;
178+
}
179+
180+
return $this->entityToBatch[$entityId];
181+
}
182+
183+
/**
184+
* Load all entity IDs and build batch map
185+
*
186+
* @return void
187+
*/
188+
private function loadEntityIds(): void
189+
{
190+
$connection = $this->collection->getConnection();
191+
$select = $connection->select()
192+
->from($this->collection->getMainTable(), ['entity_id'])
193+
->order('entity_id ASC');
194+
195+
$this->allEntityIds = $connection->fetchCol($select);
196+
$this->totalCount = count($this->allEntityIds);
197+
198+
$batchNumber = 0;
199+
foreach (array_chunk($this->allEntityIds, self::BATCH_SIZE) as $batchEntityIds) {
200+
foreach ($batchEntityIds as $entityId) {
201+
$this->entityToBatch[(int)$entityId] = $batchNumber;
202+
}
203+
$batchNumber++;
204+
}
205+
}
206+
207+
/**
208+
* Load attribute values for a specific batch
209+
*
210+
* @param int $batchNumber
211+
* @return void
212+
* @throws LocalizedException
213+
*/
214+
private function loadBatch(int $batchNumber): void
215+
{
216+
if (empty($this->allEntityIds)) {
217+
$this->loadEntityIds();
218+
}
219+
220+
$offset = $batchNumber * self::BATCH_SIZE;
221+
$batchEntityIds = array_slice($this->allEntityIds, $offset, self::BATCH_SIZE);
222+
223+
if (empty($batchEntityIds)) {
224+
return;
225+
}
226+
227+
$attributeId = (int)$this->attribute->getId();
228+
$fieldMainTable = $this->collection->getConnection()->getAutoIncrementField(
229+
$this->collection->getMainTable()
230+
);
231+
$fieldJoinTable = $this->attribute->getEntity()->getLinkField();
232+
$connection = $this->collection->getConnection();
233+
234+
$select = $connection->select()
235+
->from(['cpe' => $this->collection->getMainTable()], ['entity_id'])
236+
->join(
237+
['cpa' => $this->attribute->getBackend()->getTable()],
238+
'cpe.' . $fieldMainTable . ' = cpa.' . $fieldJoinTable,
239+
['store_id', 'value']
240+
)
241+
->where('attribute_id = ?', $attributeId)
242+
->where('cpe.entity_id IN (?)', $batchEntityIds);
243+
244+
$data = $connection->fetchAll($select);
245+
246+
foreach ($data as $row) {
247+
$entityId = (int)$row['entity_id'];
248+
$storeId = (int)$row['store_id'];
249+
$this->loadedData[$entityId][$storeId] = $row['value'];
250+
}
251+
252+
$this->loadedBatches[$batchNumber] = true;
253+
$this->batchQueue[] = $batchNumber;
254+
255+
unset($data);
256+
}
257+
258+
/**
259+
* Evict oldest batches if memory limit exceeded
260+
*
261+
* @return void
262+
*/
263+
private function evictOldBatchesIfNeeded(): void
264+
{
265+
while (count($this->batchQueue) > self::MAX_BATCHES_IN_MEMORY) {
266+
$oldestBatch = array_shift($this->batchQueue);
267+
unset($this->loadedBatches[$oldestBatch]);
268+
269+
$offset = $oldestBatch * self::BATCH_SIZE;
270+
$batchEntityIds = array_slice($this->allEntityIds, $offset, self::BATCH_SIZE);
271+
272+
foreach ($batchEntityIds as $entityId) {
273+
unset($this->loadedData[(int)$entityId]);
274+
}
275+
}
276+
}
277+
}

0 commit comments

Comments
 (0)