Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## Unreleased

- Tax rates, customer groups and currency rates as cache entries in the local files: no select per request for them. A tax rule, rate, class or group save cleans the tax entries; a rate import cleans the currency entries.
- A process keeps the entries it read in memory until they expire; a worker answers them without the include.

## 0.2.0-rc7

- Promote entries that Mage-OS stores through its compression decorator to the local files; the lifetime rule reads the packed record as the frontend answers it.
Expand Down
3 changes: 3 additions & 0 deletions dev/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ Production installs use the default-enabled optimizations. These switches suppor
| `view_config` | Reuse of theme/area view XML as PHP data. | Yes |
| `guest_tax_factor` | Reuse of guest tax-factor calculations. | Yes |
| `placeholder_url` | Cached placeholder URLs, including theme and transport identity. | Yes |
| `tax_rates` | Tax rates and applied rates per rate request from the cache, for requests that are not customer-specific. | Yes |
| `customer_groups` | Customer groups by id from the cache, with their excluded websites. | Yes |
| `currency_rates` | Currency rates from the cache; a rate import cleans them. | Yes |

These switches do not enable PHP class preload; that requires `opcache.preload` at FPM startup.

Expand Down
65 changes: 65 additions & 0 deletions src/FastBoot/Plugin/Customer/GroupFromCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);

namespace GraphCommerce\FastBoot\Plugin\Customer;

use GraphCommerce\FastBootCache\Model\Feature;
use GraphCommerce\FastBootCache\Model\Tag;
use Magento\Customer\Api\Data\GroupInterface;
use Magento\Customer\Api\Data\GroupInterfaceFactory;
use Magento\Customer\Api\GroupRepositoryInterface;
use Magento\Framework\App\Cache\Type\Config as ConfigCache;
use Magento\Framework\App\CacheInterface;
use Magento\Framework\Api\ExtensionAttributesFactory;

/**
* A customer group by id from the cache: its code, tax class and excluded websites, as the
* repository and core's excluded-website plugin answer them. A group or tax class save
* cleans the entries. The plugin wraps core's excluded-website plugin, so the entry holds
* its result.
*/
class GroupFromCache
{
private const SWITCH = 'customer_groups';
private const KEY = 'FASTBOOT_GROUP_';

public function __construct(
private readonly CacheInterface $cache,
private readonly Feature $feature,
private readonly GroupInterfaceFactory $groupFactory,
private readonly ExtensionAttributesFactory $extensionFactory,
) {
}

public function aroundGetById(GroupRepositoryInterface $subject, callable $proceed, $id): GroupInterface
{
if (!$this->feature->on(self::SWITCH)) {
return $proceed($id);
}
$key = self::KEY . (int)$id;
$cached = $this->cache->load($key);
$data = is_string($cached) ? json_decode($cached, true) : null;
if (is_array($data)) {
$group = $this->groupFactory->create()
->setId($data['id'])
->setCode($data['code'])
->setTaxClassId($data['tax_class_id'])
->setTaxClassName($data['tax_class_name']);
if ($data['exclude_website_ids']) {
$group->setExtensionAttributes($this->extensionFactory->create(GroupInterface::class)->setExcludeWebsiteIds($data['exclude_website_ids']));
}

return $group;
}
$group = $proceed($id);
$this->cache->save(json_encode([
'id' => $group->getId(),
'code' => $group->getCode(),
'tax_class_id' => $group->getTaxClassId(),
'tax_class_name' => $group->getTaxClassName(),
'exclude_website_ids' => $group->getExtensionAttributes()?->getExcludeWebsiteIds() ?? [],
]), $key, [ConfigCache::CACHE_TAG, Tag::TAX]);

return $group;
}
}
63 changes: 63 additions & 0 deletions src/FastBoot/Plugin/Directory/CurrencyRatesFromCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);

namespace GraphCommerce\FastBoot\Plugin\Directory;

use GraphCommerce\FastBootCache\Model\Feature;
use GraphCommerce\FastBootCache\Model\Tag;
use Magento\Directory\Model\Currency as CurrencyModel;
use Magento\Directory\Model\ResourceModel\Currency;
use Magento\Framework\App\Cache\Type\Config as ConfigCache;
use Magento\Framework\App\CacheInterface;

/**
* A currency rate from the cache instead of a select per request; a rate import cleans the
* entries. A missing rate is an entry too, as core answers false for it.
*/
class CurrencyRatesFromCache
{
private const SWITCH = 'currency_rates';
private const KEY = 'FASTBOOT_CURRENCY_RATE_';

public function __construct(
private readonly CacheInterface $cache,
private readonly Feature $feature,
) {
}

public function aroundGetRate(Currency $subject, callable $proceed, $currencyFrom, $currencyTo)
{
return $this->rate('rate', $currencyFrom, $currencyTo, static fn() => $proceed($currencyFrom, $currencyTo));
}

public function aroundGetAnyRate(Currency $subject, callable $proceed, $currencyFrom, $currencyTo)
{
return $this->rate('any', $currencyFrom, $currencyTo, static fn() => $proceed($currencyFrom, $currencyTo));
}

public function afterSaveRates(Currency $subject, $result)
{
$this->cache->clean([Tag::CURRENCY]);

return $result;
}

private function rate(string $kind, $currencyFrom, $currencyTo, callable $compute)
{
$from = strtoupper($currencyFrom instanceof CurrencyModel ? (string)$currencyFrom->getCode() : (string)$currencyFrom);
$to = strtoupper($currencyTo instanceof CurrencyModel ? (string)$currencyTo->getCode() : (string)$currencyTo);
if ($from === $to || !$this->feature->on(self::SWITCH)) {
return $compute();
}
$key = self::KEY . $kind . '_' . $from . '_' . $to;
$cached = $this->cache->load($key);
$entry = is_string($cached) ? json_decode($cached, true) : null;
if (is_array($entry) && array_key_exists('rate', $entry)) {
return $entry['rate'];
}
$rate = $compute();
$this->cache->save(json_encode(['rate' => $rate], JSON_PRESERVE_ZERO_FRACTION), $key, [ConfigCache::CACHE_TAG, Tag::CURRENCY]);

return $rate;
}
}
33 changes: 33 additions & 0 deletions src/FastBoot/Plugin/Tax/ForgetRates.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);

namespace GraphCommerce\FastBoot\Plugin\Tax;

use GraphCommerce\FastBootCache\Model\Tag;
use Magento\Framework\App\CacheInterface;

/**
* A tax rule, rate or class save or delete, and a customer group save or delete, drop the
* tax entries: the rates, the groups and the guest tax factor.
*/
class ForgetRates
{
public function __construct(
private readonly CacheInterface $cache,
) {
}

public function afterSave($subject, $result)
{
$this->cache->clean([Tag::TAX]);

return $result;
}

public function afterDelete($subject, $result)
{
$this->cache->clean([Tag::TAX]);

return $result;
}
}
73 changes: 73 additions & 0 deletions src/FastBoot/Plugin/Tax/RatesFromCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);

namespace GraphCommerce\FastBoot\Plugin\Tax;

use GraphCommerce\FastBootCache\Model\Feature;
use GraphCommerce\FastBootCache\Model\Tag;
use Magento\Authorization\Model\UserContextInterface;
use Magento\Framework\App\Cache\Type\Config as ConfigCache;
use Magento\Framework\App\CacheInterface;
use Magento\Store\Model\StoreManagerInterface;
use Magento\Tax\Model\ResourceModel\Calculation;

/**
* The tax rate and the applied rates of a rate request from the cache, keyed as core's
* calculation keys its own per-request memory: store, product and customer tax class,
* country, region and postcode. A tax rule, rate or class save cleans the entries. A
* signed-in customer's request carries the customer's own address, one entry per
* address, so it stays with core's per-request memory.
*/
class RatesFromCache
{
private const SWITCH = 'tax_rates';
private const KEY = 'FASTBOOT_TAX_RATE_';

public function __construct(
private readonly CacheInterface $cache,
private readonly Feature $feature,
private readonly StoreManagerInterface $storeManager,
private readonly UserContextInterface $userContext,
) {
}

public function aroundGetRateInfo(Calculation $subject, callable $proceed, $request): array
{
return $this->entry('info', $request, static fn(): array => $proceed($request));
}

public function aroundGetCalculationProcess(Calculation $subject, callable $proceed, $request, $rates = null): array
{
if ($rates !== null) {
return $proceed($request, $rates);
}

return $this->entry('process', $request, static fn(): array => $proceed($request));
}

private function entry(string $kind, $request, callable $compute): array
{
if (!$this->feature->on(self::SWITCH) || (int)$this->userContext->getUserType() === UserContextInterface::USER_TYPE_CUSTOMER) {
return $compute();
}
$key = self::KEY . $kind . '_' . md5(implode('|', [
$this->storeManager->getStore($request->getStore())->getId(),
implode(',', (array)$request->getProductClassId()),
$request->getCustomerClassId(),
$request->getCountryId(),
$request->getRegionId(),
$request->getPostcode(),
]));
$cached = $this->cache->load($key);
if (is_string($cached)) {
$value = json_decode($cached, true);
if (is_array($value)) {
return $value;
}
}
$value = $compute();
$this->cache->save(json_encode($value, JSON_PRESERVE_ZERO_FRACTION), $key, [ConfigCache::CACHE_TAG, Tag::TAX]);

return $value;
}
}
90 changes: 90 additions & 0 deletions src/FastBoot/Test/Unit/Plugin/Customer/GroupFromCacheTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);

namespace GraphCommerce\FastBoot\Test\Unit\Plugin\Customer;

use GraphCommerce\FastBoot\Plugin\Customer\GroupFromCache;
use GraphCommerce\FastBootCache\Model\Feature;
use Magento\Customer\Api\Data\GroupExtension;
use Magento\Customer\Api\Data\GroupInterface;
use Magento\Customer\Api\Data\GroupInterfaceFactory;
use Magento\Customer\Api\GroupRepositoryInterface;
use Magento\Framework\Api\ExtensionAttributesFactory;
use Magento\Framework\App\CacheInterface;
use Magento\Framework\DataObject;
use PHPUnit\Framework\TestCase;

require_once __DIR__ . '/../../_files/customer-group-generated.php';

class GroupFromCacheTest extends TestCase
{
private array $entries = [];

private function plugin(): GroupFromCache
{
$cache = $this->createStub(CacheInterface::class);
$cache->method('load')->willReturnCallback(fn(string $id) => $this->entries[$id] ?? false);
$cache->method('save')->willReturnCallback(function (string $data, string $id) {
$this->entries[$id] = $data;

return true;
});
$feature = $this->createStub(Feature::class);
$feature->method('on')->willReturn(true);
$groups = $this->createStub(GroupInterfaceFactory::class);
$groups->method('create')->willReturnCallback(fn(): GroupInterface => $this->group());
$extensions = $this->createStub(ExtensionAttributesFactory::class);
$extensions->method('create')->willReturnCallback(static fn(): GroupExtension => new GroupExtension());

return new GroupFromCache($cache, $feature, $groups, $extensions);
}

/** A group data object that keeps what its setters receive. */
private function group(): GroupInterface
{
return new class extends DataObject implements GroupInterface {
public function getId() { return $this->getData('id'); }
public function setId($id) { return $this->setData('id', $id); }
public function getCode() { return $this->getData('code'); }
public function setCode($code) { return $this->setData('code', $code); }
public function getTaxClassId() { return $this->getData('tax_class_id'); }
public function setTaxClassId($taxClassId) { return $this->setData('tax_class_id', $taxClassId); }
public function getTaxClassName() { return $this->getData('tax_class_name'); }
public function setTaxClassName($taxClassName) { return $this->setData('tax_class_name', $taxClassName); }
public function getExtensionAttributes() { return $this->getData('extension_attributes'); }
public function setExtensionAttributes(\Magento\Customer\Api\Data\GroupExtensionInterface $extensionAttributes) { return $this->setData('extension_attributes', $extensionAttributes); }
};
}

public function testAGroupWithExcludedWebsitesComesBackWholeFromTheEntry(): void
{
$plugin = $this->plugin();
$subject = $this->createStub(GroupRepositoryInterface::class);
$calls = 0;
$proceed = function () use (&$calls): GroupInterface {
$calls++;
$group = $this->group()->setId(3)->setCode('Wholesale')->setTaxClassId(5)->setTaxClassName('Retail Customer');

return $group->setExtensionAttributes((new GroupExtension())->setExcludeWebsiteIds([2]));
};
$plugin->aroundGetById($subject, $proceed, 3);
$group = $plugin->aroundGetById($subject, $proceed, 3);
self::assertSame(1, $calls);
self::assertSame(3, $group->getId());
self::assertSame('Wholesale', $group->getCode());
self::assertSame(5, $group->getTaxClassId());
self::assertSame('Retail Customer', $group->getTaxClassName());
self::assertSame([2], $group->getExtensionAttributes()->getExcludeWebsiteIds());
}

public function testAGroupWithoutExcludedWebsitesCarriesNoExtensionAttributes(): void
{
$plugin = $this->plugin();
$subject = $this->createStub(GroupRepositoryInterface::class);
$proceed = fn(): GroupInterface => $this->group()->setId(0)->setCode('NOT LOGGED IN')->setTaxClassId(3)->setTaxClassName('Retail Customer');
$plugin->aroundGetById($subject, $proceed, 0);
$group = $plugin->aroundGetById($subject, $proceed, 0);
self::assertSame('NOT LOGGED IN', $group->getCode());
self::assertNull($group->getExtensionAttributes());
}
}
Loading