languageID = $languageID; } public function updateSelection($selection) { $root = &$this->cache; $item = null; foreach ($selection as &$item_id) { if (!isset($root[$item_id])) { break; } $item = &$root[$item_id]; $root = &$item['submenu']; $item['opened'] = true; } if ($item) { $item['selected'] = true; } } /** * @return \KupShop\CatalogBundle\Entity\Section[] */ protected function createTreeMenuProducts($rootSection = 0) { $retTree = []; $where = ($rootSection) ? 'S1.id = :rootSection' : 'SR1.id_topsection IS NULL'; $recursiveQuery = " WITH RECURSIVE cte (level, id, name, id_topsection, position) AS ( SELECT 0, S1.id, S1.name, SR1.id_topsection, SR1.position FROM sections S1 LEFT JOIN sections_relation SR1 on (S1.id = SR1.id_section) WHERE {$where} UNION ALL SELECT cte.level + 1, S2.id, S2.name, SR2.id_topsection, SR2.position FROM sections S2 INNER JOIN sections_relation SR2 on S2.id = SR2.id_section INNER JOIN cte ON (cte.id = SR2.id_topsection) WHERE cte.level < 50 ) SELECT * FROM cte "; $defaultFilterParams = (new ProductList())->applydefaultFilterParams(); $productsSubQuery = sqlQueryBuilder() // Počítání produktů v sekcích - Kvůli přesnosti počtů produktů se musí používat 'applydefaultFilterParams()'. // To na sebe může joinout 1:n tabulky, takže se to musí groupnout podle 'p.id' a agregovat až ve vnější query. // Alternativní možnost je joinout 'products_in_sections' a agregovat to už tady, // ale kvůli možným 1:n joinům by se musel vytvořit další subquery, což už by byly 3 subquery // v sobě a rychlostně to skončí podobně. ->select('p.id, p.in_store') ->from('products', 'p') ->joinProducersOnProducts() ->andWhere($defaultFilterParams->getSpec()) ->groupBy('p.id'); $qb = sqlQueryBuilder()->select('sr.level, sr.id_topsection, s.*') ->from('('.$recursiveQuery.')', 'sr') ->innerJoin('sr', 'sections', 's', 's.id = sr.id') ->leftJoin('s', 'products_in_sections', 'ps', 's.id = ps.id_section') ->leftJoinSubQuery('ps', $productsSubQuery, 'p', 'ps.id_product = p.id') ->addSelect('COUNT(p.id) count, SUM(IF(p.in_store > 0, 1, 0)) AS in_stock_count') ->andWhere( \Query\Translation::coalesceTranslatedFields( \KupShop\I18nBundle\Translations\SectionsTranslation::class, function ($columns) { $columns['data'] = 'data_translated'; return $columns; } ) ) ->groupBy('s.id') ->orderBy('sr.level, sr.position')->addOrderBy('s.name'); if ($rootSection) { $qb->setParameter('rootSection', $rootSection); } if (findModule(\Modules::PRODUCTS_SUPPLIERS)) { $productsSuppliersSubQuery = sqlQueryBuilder() ->select('ppos.id_product, SUM(IF(ppos.in_store > 0, 1, 0)) count') ->from('products_of_suppliers', 'ppos') ->groupBy('ppos.id_product'); $qb->leftJoinSubQuery('p', $productsSuppliersSubQuery, 'pos', 'pos.id_product = p.id'); $qb->addSelect('SUM(COALESCE((pos.count),0)) AS in_stock_suppliers_count'); } $SQL = $qb->execute()->fetchAll(); $productsFilterSpecs = ServiceContainer::getService(\KupShop\CatalogBundle\Util\ProductsFilterSpecs::class); foreach ($SQL as $row) { unset($section); $sectionData = array_replace_recursive(json_decode($row['data'] ?? '{}', true) ?: [], json_decode($row['data_translated'] ?? '{}', true) ?: []); /* @var $parent \KupShop\CatalogBundle\Entity\Section */ $parent = ($row['id_topsection']) ? $this->mapping[$row['id_topsection']] ?? null : null; // skip section when parent is not found, but id_topsection is set // for example when hide_not_translated_objects is enabled for sections and parent section is not translated // the child section is passed to section tree as root section, and it is invalid behaviour! if (!$parent && $row['id_topsection']) { continue; } $section = new \KupShop\CatalogBundle\Entity\Section(); $section->setId($row['id']) ->setIdBlock($row['id_block']) ->setName($row['name']) ->setNameShort($row['name_short'] ?? $row['name']) ->setFigure($row['figure']) ->setShowInSearch($row['show_in_search']) ->setBehaviour($row['behaviour']) ->setOrderby($row['orderby']) ->setOrderdir($row['orderdir']) ->setDateUpdated(new DateTime($row['date_updated'])) ->setListVariations(findModule(\Modules::PRODUCTS_VARIATIONS) ? $row['list_variations'] : 'N') ->setLeadFigure($row['lead_figure']) ->setLeadText($row['lead_text']) ->setLeadProducts($row['lead_products']) ->setPhoto($row['photo']) ->setMetaTitle($row['meta_title']) ->setMetaDescription($row['meta_description']) ->setMetaKeywords($row['meta_keywords']) ->setFeedHeureka($row['feed_heureka']) ->setFeedGoogle($row['feed_google']) ->setFeedHeurekaSk($row['feed_heureka_sk']) ->setFeedGlami($row['feed_glami'] ?? null) ->setIdSlider($row['id_slider'] ?? null) ->setFlags(!$row['flags'] ? [] : explodeFlags($row['flags'])) ->setFeedSeznam($row['feed_seznam']) ->setProducersFilter($row['producers_filter']) ->setParent($parent) ->setData($sectionData) ->setVirtual($row['virtual']) ->setCount($row['count']) ->setAnnotation($row['annotation'] ?? null) ->setRedirectUrl($row['redirect_url']) ->setPriority($row['priority']); $inStockCount = $row['in_stock_count']; if (findModule(\Modules::PRODUCTS_SUPPLIERS)) { $inStockCount += (int) $row['in_stock_suppliers_count']; } $section->setInStockCount($inStockCount); if ($section->getId() === 0 && empty($row['url'])) { $section->setUrl(trim(path('kupshop_catalog_catalog_category'), '/')); } elseif (findModule('products_sections', 'custom_url')) { $section->setUrl($row['url']); } // nacteni poctu produktu ve virtualni sekci, ale pouze pokud nejsou zapnuty materializovany virt. sekce if (!findModule(Modules::PRODUCTS_SECTIONS, Modules::SUB_VIRTUAL_TO_DB) && $section->isVirtual()) { $virtualSettings = $section->getData()['virtual_settings'] ?? []; $virtualCount = sqlQueryBuilder()->select('COUNT(DISTINCT p.id) as count') ->from('products', 'p') ->andWhere($productsFilterSpecs->getSpecs($virtualSettings)) ->andWhere($defaultFilterParams->getSpec()) ->execute()->fetchColumn(); $section->setCount($virtualCount); $section->setInStockCount($virtualCount); } $this->mapping[$section->getId()] = &$section; if ($parent && $section->getId() != $rootSection) { $siblings = $parent->getChildren(); $siblings->set($section->getId(), $section); $parent->setChildren($siblings); } elseif ($section->getId() > 0) { $retTree[$section->getId()] = $section; } } foreach (array_reverse($this->mapping) as &$section) { if ($section->getBehaviour() != 1) { $count = $section->getCount(); $inStockCount = $section->getInStockCount(); foreach ($section->getChildren() as $child) { /* @var $child \KupShop\CatalogBundle\Entity\Section */ $count += $child->getCount(); $inStockCount += $child->getInStockCount(); } $section->setCount($count); $section->setInStockCount($inStockCount); } } if (!empty($this->mapping[0])) { $section0 = $this->mapping[0]; // vytvoreni podsekci $section0->setChildren(new \Doctrine\Common\Collections\ArrayCollection($retTree)); // calc products count $count = $section0->getCount(); $inStockCount = $section0->getInStockCount(); foreach ($section0->getChildren() as $child) { /* @var $child \KupShop\CatalogBundle\Entity\Section */ $count += $child->getCount(); $inStockCount += $child->getInStockCount(); } $section0->setCount($count); $section0->setInStockCount($inStockCount); } return $retTree; } /** * @return \KupShop\CatalogBundle\Entity\Section|null */ public function getSectionById($id) { $this->initializeTree(); if (!empty($this->mapping[$id])) { return $this->mapping[$id]; } else { return null; } } /** * @param string $glue * * @return string|null */ public function getFullPath($id, $glue = '/') { $this->initializeTree(); if (!empty($this->mapping[$id])) { return join($glue, $this->mapping[$id]['parents']); } else { return null; } } public function &getTree() { $this->initializeTree(); return $this->cache; } public function getTimeCached(): ?int { $this->initializeTree(); return $this->timeCached; } /** * @throws CacheException */ public function initializeTree() { if (!$this->cache) { $cacheContext = Contexts::get(CacheContext::class); $contextManager = ServiceContainer::getService(ContextManager::class); $contextManager->activateContexts([LanguageContext::class => $this->languageID], function () use ($cacheContext) { $cacheKey = implode('-', [ 'sections', $cacheContext->getKey([CacheContext::TYPE_TEXT, CacheContext::TYPE_AVAILABILITY]), ]); // First try local cache // Administrators do not use local cache - problem with local cache cleaning in multiple shop instances $isLocalFresh = null; $localData = null; if (!getAdminUser() && $localData = $this->getLocalCache($cacheKey, $isLocalFresh)) { // If local cache is young enough, use it if ($isLocalFresh) { $this->useCache($localData); return; } } // TODO: Temporary hack for OOM on menu load. Increase whenever we load second menu from memcached increaseMemoryLimit(60); $remoteData = $this->getRemoteCache($cacheKey); // Then try remote if ($remoteData && !$this->isCacheInvalidated($remoteData['timeCached'] ?? null)) { // Update/Create local cache $this->setLocalCache($cacheKey, $remoteData); $this->useCache($remoteData); return; } // TODO: Budu načítat a ukládat menu. Zvětším paměť, protože APC store, memcached store a další žerou hodně paměti // TODO: Tohle si můžu dovolit, protože sem bych se neměl moc často dostat, cache je validní dlouho increaseMemoryLimit(100); $debugFirstRemoteData = (bool) $remoteData; // Use local blocking store - better to not rely on any remote locks store $store = new FlockStore(); $factory = new LockFactory($store); // lock releases after 90s $lock = $factory->createLock('lock-'.$cacheKey, 90); // Pokud máme lokální/remote cache, nemá cenu stát na locku. Použiju lokální/remote, i když není fresh a spustím async job na vygenerování sekcí // Když nemáme lokální/remote cache, musím čekat :-( $SYNC_JOB = !$localData && !$remoteData; $debugSecondRemoteData = null; $debugSetRemoteData = null; $jobId = md5(time().rand()); if ($SYNC_JOB) { // no cache is available, generate it synchronously or wait for the result $this->kibanaLog('MenuSectionTree: blocking lock - old cache not available', ['job' => $jobId]); if ($lock->acquire(blocking: true)) { $debugAcquired = true; $remoteData = $this->getRemoteCache($cacheKey); $debugSecondRemoteData = (bool) $remoteData; if (!$remoteData || $this->isCacheInvalidated($remoteData['timeCached'] ?? null)) { $this->kibanaLog('MenuSectionTree: generating tree SYNC '.$this->languageID, ['job' => $jobId]); $this->generateSectionsTreeJob($cacheKey); $debugSetRemoteData = 'sync'; } else { // cache has been already updated while waiting $this->useCache($remoteData); } $lock->release(); } else { throw new \LogicException('MenuSectionTree - Blocking acquire should never return false!'); } } else { // Schedule an async job to generate the tree. Use old cache in the meantime $asyncQueue = ServiceContainer::getService(\KupShop\KupShopBundle\Util\System\AsyncQueue::class); $asyncQueue->addHandler(function () use ($cacheKey, $lock, $jobId) { // blocking lock should not be needed. If the lock is already acquired, the tree is already being generated and is no need to wait for anything if ($lock->acquire(blocking: false)) { $this->kibanaLog('MenuSectionTree: generating tree ASYNC '.$this->languageID, ['job' => $jobId]); $this->generateSectionsTreeAsyncJob($cacheKey); $lock->release(); } }, "SECTIONS - generate menuSectionTree ({$cacheKey})"); if (!$localData && !$remoteData) { throw new \LogicException('MenuSectionTree - When non-blocking acquire fails, local or remote cache should be always available!'); } $debugSetRemoteData = 'async'; $this->useCache($localData ?: $remoteData); } }); } } protected function generateSectionsTreeAsyncJob(string $cacheKey) { $contextManager = ServiceContainer::getService(ContextManager::class); return $contextManager->activateContexts([LanguageContext::class => $this->languageID], function () use ($cacheKey) { return $this->generateSectionsTreeJob($cacheKey); }); } protected function generateSectionsTreeJob(string $cacheKey) { $this->timeCached = time(); $this->cache = $this->createTreeMenuProducts(0); $remoteData = [ 'cache' => $this->cache, 'mapping' => $this->mapping ?? [], 'timeCached' => $this->timeCached, ]; setCache($cacheKey, $remoteData, 3600); // update apcu - should be needed only after regeneration, otherwise all released processes would spam setLocalCache $this->setLocalCache($cacheKey, $remoteData); return $remoteData; } protected function getRemoteCache(string $fromKey) { return getCache($fromKey); } protected function useCache(array $cache) { if (!empty($cache['cache'])) { $this->cache = $cache['cache']; } if (!empty($cache['mapping'])) { $this->mapping = $cache['mapping']; } $this->timeCached = $cache['timeCached'] ?? null; return $this->cache; } protected function getLocalCache(string $key, ?bool &$isFresh) { $cache = getLocalCache(); $item = $cache->getItem($key); if ($item->isHit()) { $data = $item->get(); $isFresh = (time() - $data['time']) <= 5; return $data; } $isFresh = false; return false; } // The only correct method for clearing sectionsCache // clearCache('sections', true) and other old ways are FORBIDDEN unless you know exactly what you are doing public static function invalidateCache(?string $languageID = null) { $now = time(); setCache(self::SECTIONS_LAST_UPDATED_KEY.($languageID ? '-'.$languageID : ''), $now, 0); } protected function isCacheInvalidated(?int $cacheUpdated) { $lastUpdated = $this->getSectionsLastUpdated(); if (is_null($lastUpdated)) { return false; } if (is_null($cacheUpdated)) { return true; } return $cacheUpdated < $lastUpdated; } protected function getSectionsLastUpdated(): ?int { $lastUpdatedGlobal = getCache(self::SECTIONS_LAST_UPDATED_KEY); $lastUpdatedLang = getCache(self::SECTIONS_LAST_UPDATED_KEY.'-'.$this->languageID); if ($lastUpdatedGlobal && $lastUpdatedLang) { return max($lastUpdatedGlobal, $lastUpdatedLang); } return $lastUpdatedLang ?: $lastUpdatedGlobal ?: null; } private function kibanaLog(string $message, array $data = []): void { if (!isProduction()) { return; } $logger = ServiceContainer::getService('logger'); $logger->notice($message, array_merge( $data, [ 'languageID' => $this->languageID, ]) ); } protected function setLocalCache(string $key, array $data) { $cache = getLocalCache(); $item = $cache->getItem($key); $data['time'] = time(); $item->set($data); $cache->save($item); } }