Web200 / magento2-elasticsuite-ajax

Add Elasticsuite ajax navigation
22 stars 8 forks source link

X-Magento-Tags header missing in AJAX responses #16

Open mfickers opened 1 year ago

mfickers commented 1 year ago

When Magento is set up with Varnish as a full page cache, the page responses contain an X-Magento-Tags header with cache tags for entities relevant to the rendered page. This is a live example from a category page from our production environment:

-   BerespHeader   X-Magento-Tags: store,cms_b,cms_b_newsletter_modal_content,cms_b_mobile-menu-links,cms_b_header-store-name,cms_b_header-store-slogan,cms_b_footer-main-links,cms_b_558,cms_b_checkout_minicart_empty_cta,cat_c_8584,cat_c_p_8584,cat_p_3035035,cat_p,cat_p_303

Notice that there are cache tags for CMS blocks, the current category, the category products and more. If one of these entities is updated, Magento sends a PURGE request to Varnish with an X-Magento-Tags-Pattern header containing a regex matching the objects to invalidate. This is the purging logic from the default configuration:

if (req.method == "PURGE") {

        [...]

        if (req.http.X-Magento-Tags-Pattern) {
            ban("obj.http.X-Magento-Tags ~ " + req.http.X-Magento-Tags-Pattern);
        }

        [...]
    }

This header is even used for a full cache clear. bin/magento cache:flush for example uses ".*" as X-Magento-Tags-Pattern, as this should match every object in cache. Note that this does not match objects without an X-Magento-Tags header present in the response.

For the AJAX responses of this extension, the X-Magento-Tags header is missing. The generated cache objects will thus stay in cache until cleared manually. Even running bin/magento cache:flush will not delete these objects from cache. This can lead to a situation where a product grid shows different products depending on if it was loaded directly or via AJAX, as the AJAX request will deliver stale responses.

Prerequisites

Magento 2.4.5-p1 Elasticsuite AJAX v1.0.2 Varnish with Magento default configuration

Steps to reproduce

Use varnishlog to inspect the requests handled by Varnish and see the headers present on responses.

  1. Go to category page
  2. Use pagination to navigate to page 2
  3. Product grid is loaded via AJAX
  4. Reload the page
  5. Page is loaded synchronously

Expected result

Actual result

mfickers commented 1 year ago

I've fixed this in our project via a plugin. Since this introduces an additional dependency to the Magento_PageCache module, I wasn't sure if this should be included in the extension or not.

Setting the X-Magento-Tags header is handled by this plugin: \Magento\PageCache\Model\Layout\LayoutPlugin::afterGetOutput. The AjaxResponse doesn't use the Layout to render the output and instead calls the toHtml method on the blocks directly, preventing the plugin from being called. Maybe it's possible to create a solution where the layout rendering is used and no dependency on the PageCache module is needed. That would be preferable IMO.

Here is my solution if someone else also runs into the same issue. Let me know if you want this added to this repo and I will create a PR:

<?php
declare(strict_types = 1);

namespace Basecom\ElasticsuiteAjax\Plugin;

use Magento\Framework\App\Response\HttpInterface;
use Magento\Framework\App\ResponseInterface;
use Magento\Framework\Controller\Result\Json;
use Magento\Framework\DataObject\IdentityInterface;
use Magento\Framework\View\LayoutInterface;
use Magento\PageCache\Model\Config;
use Web200\ElasticsuiteAjax\Model\AjaxResponse;

/**
 * Plugin on {@link AjaxResponse}
 *
 * Fixes an issue with Varnish caching and AJAX responses
 * https://github.com/Web200/magento2-elasticsuite-ajax/issues/16
 */
class SetCacheTagsHeader
{
    public function __construct(
        private readonly ResponseInterface $response,
        private readonly LayoutInterface $layout,
        private readonly Config $cacheConfig
    ) {
    }

    /**
     * If Varnish is used, we need to add the missing cache tags to the response,
     * otherwise the AJAX responses can not be cleared from cache
     */
    public function afterExecute(AjaxResponse $ajaxResponse, Json $result): Json
    {
        if ($this->isCacheable()) {
            $this->addCacheTagHeader($ajaxResponse);
        }

        return $result;
    }

    /**
     * Check if output is going to be cached by Varnish
     */
    private function isCacheable(): bool
    {
        return $this->layout->isCacheable() &&
            $this->cacheConfig->isEnabled() &&
            $this->cacheConfig->getType() === Config::VARNISH;
    }

    /**
     * Add the X-Magento-Tags header to the response
     */
    private function addCacheTagHeader(AjaxResponse $ajaxResponse): void
    {
        $blockNames = [
            $ajaxResponse->getProductListBlock(),
            $ajaxResponse->getLeftNavBlock()
        ];

        $tags = $this->getTags($blockNames);
        if ($this->response instanceof HttpInterface && count($tags) > 0) {
            $this->response->setHeader('X-Magento-Tags', implode(',', $tags));
        }
    }

    /**
     * Get all cache tags for the given blocks
     *
     * @param string[] $blockNames
     *
     * @return string[]
     */
    private function getTags(array $blockNames): array
    {
        $tags = [];
        foreach ($blockNames as $blockName) {
            $block = $this->layout->getBlock($blockName);
            if ($block instanceof IdentityInterface) {
                $tags[] = $block->getIdentities();
            }
        }

        return array_unique(array_merge([], ...$tags));
    }
}