From 32ba2795d85ef2a0b4fc4ff49133731f5e1b7947 Mon Sep 17 00:00:00 2001 From: Paul Hachmang Date: Thu, 24 Sep 2026 18:42:32 +0200 Subject: [PATCH 1/4] Tax rates, customer groups and currency rates are local entries, and a process keeps its entries in memory The documents path of a catalog request runs without a select: the rates of a rate request, the group of the visitor and the currency rates come from the local files under the FastBoot version. A tax rule, rate, class or group save cleans the tax entries, a rate import the currency entries. PhpFiles keeps what a process read or wrote until it expires, so a worker answers without the include and a php-fpm request reads an entry once. --- CHANGELOG.md | 5 ++ dev/README.md | 3 + .../Plugin/Customer/GroupFromCache.php | 65 ++++++++++++++ .../Directory/CurrencyRatesFromCache.php | 63 +++++++++++++ src/FastBoot/Plugin/Tax/ForgetRates.php | 33 +++++++ src/FastBoot/Plugin/Tax/RatesFromCache.php | 69 ++++++++++++++ .../Plugin/Customer/GroupFromCacheTest.php | 90 +++++++++++++++++++ .../Directory/CurrencyRatesFromCacheTest.php | 54 +++++++++++ .../Unit/Plugin/Tax/RatesFromCacheTest.php | 86 ++++++++++++++++++ .../Unit/_files/customer-group-extension.php | 29 ++++++ src/FastBoot/etc/di.xml | 30 ++++++- src/FastBoot/etc/module.xml | 3 + src/FastBootCache/Model/Feature.php | 2 +- src/FastBootCache/Model/PhpFiles.php | 26 +++++- src/FastBootCache/Model/Tag.php | 14 +++ .../Test/Unit/Model/PhpFilesTest.php | 17 +++- .../Plugin/CacheId/GuestTaxFactor.php | 3 +- .../Plugin/Tax/ForgetTaxFactors.php | 33 ------- src/FastBootGraphQl/etc/di.xml | 12 --- 19 files changed, 587 insertions(+), 50 deletions(-) create mode 100644 src/FastBoot/Plugin/Customer/GroupFromCache.php create mode 100644 src/FastBoot/Plugin/Directory/CurrencyRatesFromCache.php create mode 100644 src/FastBoot/Plugin/Tax/ForgetRates.php create mode 100644 src/FastBoot/Plugin/Tax/RatesFromCache.php create mode 100644 src/FastBoot/Test/Unit/Plugin/Customer/GroupFromCacheTest.php create mode 100644 src/FastBoot/Test/Unit/Plugin/Directory/CurrencyRatesFromCacheTest.php create mode 100644 src/FastBoot/Test/Unit/Plugin/Tax/RatesFromCacheTest.php create mode 100644 src/FastBoot/Test/Unit/_files/customer-group-extension.php create mode 100644 src/FastBootCache/Model/Tag.php delete mode 100644 src/FastBootGraphQl/Plugin/Tax/ForgetTaxFactors.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ccb47e..f726054 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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-rc6 - Read the di.xml files through the runtime loader of Magento when the installation has no compiled metadata, so a developer-mode install serves requests. diff --git a/dev/README.md b/dev/README.md index 31eefb3..6c48575 100644 --- a/dev/README.md +++ b/dev/README.md @@ -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. | 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. diff --git a/src/FastBoot/Plugin/Customer/GroupFromCache.php b/src/FastBoot/Plugin/Customer/GroupFromCache.php new file mode 100644 index 0000000..854997a --- /dev/null +++ b/src/FastBoot/Plugin/Customer/GroupFromCache.php @@ -0,0 +1,65 @@ +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; + } +} diff --git a/src/FastBoot/Plugin/Directory/CurrencyRatesFromCache.php b/src/FastBoot/Plugin/Directory/CurrencyRatesFromCache.php new file mode 100644 index 0000000..9926fa0 --- /dev/null +++ b/src/FastBoot/Plugin/Directory/CurrencyRatesFromCache.php @@ -0,0 +1,63 @@ +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; + } +} diff --git a/src/FastBoot/Plugin/Tax/ForgetRates.php b/src/FastBoot/Plugin/Tax/ForgetRates.php new file mode 100644 index 0000000..b7903c2 --- /dev/null +++ b/src/FastBoot/Plugin/Tax/ForgetRates.php @@ -0,0 +1,33 @@ +cache->clean([Tag::TAX]); + + return $result; + } + + public function afterDelete($subject, $result) + { + $this->cache->clean([Tag::TAX]); + + return $result; + } +} diff --git a/src/FastBoot/Plugin/Tax/RatesFromCache.php b/src/FastBoot/Plugin/Tax/RatesFromCache.php new file mode 100644 index 0000000..61bdfd9 --- /dev/null +++ b/src/FastBoot/Plugin/Tax/RatesFromCache.php @@ -0,0 +1,69 @@ +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)) { + 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; + } +} diff --git a/src/FastBoot/Test/Unit/Plugin/Customer/GroupFromCacheTest.php b/src/FastBoot/Test/Unit/Plugin/Customer/GroupFromCacheTest.php new file mode 100644 index 0000000..c1d7e2a --- /dev/null +++ b/src/FastBoot/Test/Unit/Plugin/Customer/GroupFromCacheTest.php @@ -0,0 +1,90 @@ +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()); + } +} diff --git a/src/FastBoot/Test/Unit/Plugin/Directory/CurrencyRatesFromCacheTest.php b/src/FastBoot/Test/Unit/Plugin/Directory/CurrencyRatesFromCacheTest.php new file mode 100644 index 0000000..ee82da4 --- /dev/null +++ b/src/FastBoot/Test/Unit/Plugin/Directory/CurrencyRatesFromCacheTest.php @@ -0,0 +1,54 @@ +cache = $this->createMock(CacheInterface::class); + $this->cache->method('load')->willReturnCallback(fn(string $id) => $this->entries[$id] ?? false); + $this->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); + + return new CurrencyRatesFromCache($this->cache, $feature); + } + + public function testARateAndAMissingRateAreEntriesAndARateImportCleansThem(): void + { + $plugin = $this->plugin(); + $subject = $this->createStub(Currency::class); + $calls = 0; + $proceed = function () use (&$calls) { + $calls++; + + return $calls === 1 ? '0.8500' : false; + }; + self::assertSame('0.8500', $plugin->aroundGetRate($subject, $proceed, 'EUR', 'usd')); + self::assertSame('0.8500', $plugin->aroundGetRate($subject, $proceed, 'eur', 'USD')); + self::assertFalse($plugin->aroundGetAnyRate($subject, $proceed, 'EUR', 'GBP')); + self::assertFalse($plugin->aroundGetAnyRate($subject, $proceed, 'EUR', 'GBP')); + self::assertSame(2, $calls); + self::assertSame(1, $plugin->aroundGetRate($subject, static fn() => 1, 'EUR', 'EUR')); + self::assertCount(2, $this->entries); + $this->cache->expects(self::once())->method('clean')->with([Tag::CURRENCY]); + $plugin->afterSaveRates($subject, null); + } +} diff --git a/src/FastBoot/Test/Unit/Plugin/Tax/RatesFromCacheTest.php b/src/FastBoot/Test/Unit/Plugin/Tax/RatesFromCacheTest.php new file mode 100644 index 0000000..044379b --- /dev/null +++ b/src/FastBoot/Test/Unit/Plugin/Tax/RatesFromCacheTest.php @@ -0,0 +1,86 @@ + */ + private array $entries = []; + private array $tags = []; + + private function plugin(bool $on = true): RatesFromCache + { + $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, array $tags) { + $this->entries[$id] = $data; + $this->tags[$id] = $tags; + + return true; + }); + $feature = $this->createStub(Feature::class); + $feature->method('on')->willReturn($on); + $store = $this->createStub(StoreInterface::class); + $store->method('getId')->willReturn(1); + $stores = $this->createStub(StoreManagerInterface::class); + $stores->method('getStore')->willReturn($store); + + return new RatesFromCache($cache, $feature, $stores); + } + + private function request(string $country = 'NL', int $productClass = 2): DataObject + { + return new DataObject(['store' => 1, 'product_class_id' => $productClass, 'customer_class_id' => 3, 'country_id' => $country, 'region_id' => 0, 'postcode' => '*']); + } + + public function testTheRateInfoOfARequestKeyIsComputedOnceAndTaggedForTaxSaves(): void + { + $plugin = $this->plugin(); + $subject = $this->createStub(Calculation::class); + $calls = 0; + $proceed = function () use (&$calls): array { + $calls++; + + return ['process' => [['id' => 'NL', 'percent' => 21.0, 'rates' => [['code' => 'NL', 'title' => 'BTW', 'percent' => 21.0, 'position' => 1, 'priority' => 1]]]], 'value' => 21.0]; + }; + $first = $plugin->aroundGetRateInfo($subject, $proceed, $this->request()); + $second = $plugin->aroundGetRateInfo($subject, $proceed, $this->request()); + self::assertSame(1, $calls); + self::assertSame($first, $second); + self::assertSame(21.0, $second['value']); + self::assertContains(Tag::TAX, $this->tags[array_key_first($this->tags)]); + $plugin->aroundGetRateInfo($subject, $proceed, $this->request('DE')); + self::assertSame(2, $calls, 'Another destination is another entry'); + } + + public function testAProcessWithGivenRatesAndAnOffSwitchBypassTheCache(): void + { + $subject = $this->createStub(Calculation::class); + $calls = 0; + $proceed = function () use (&$calls): array { + $calls++; + + return []; + }; + $plugin = $this->plugin(); + $plugin->aroundGetCalculationProcess($subject, $proceed, $this->request(), [['code' => 'x']]); + $plugin->aroundGetCalculationProcess($subject, $proceed, $this->request(), [['code' => 'x']]); + self::assertSame(2, $calls); + self::assertSame([], $this->entries); + $off = $this->plugin(false); + $off->aroundGetCalculationProcess($subject, $proceed, $this->request()); + $off->aroundGetCalculationProcess($subject, $proceed, $this->request()); + self::assertSame(4, $calls); + } +} diff --git a/src/FastBoot/Test/Unit/_files/customer-group-extension.php b/src/FastBoot/Test/Unit/_files/customer-group-extension.php new file mode 100644 index 0000000..e94d419 --- /dev/null +++ b/src/FastBoot/Test/Unit/_files/customer-group-extension.php @@ -0,0 +1,29 @@ +_get('exclude_website_ids'); + } + + public function setExcludeWebsiteIds($excludeWebsiteIds) + { + return $this->setData('exclude_website_ids', $excludeWebsiteIds); + } + } +} diff --git a/src/FastBoot/etc/di.xml b/src/FastBoot/etc/di.xml index 1779d7e..0bf2428 100644 --- a/src/FastBoot/etc/di.xml +++ b/src/FastBoot/etc/di.xml @@ -7,7 +7,13 @@ FASTBOOT_SCOPES FASTBOOT_CONFIG_UNCHANGED_ FASTBOOT_WEBSITE_STORES_ - PRODUCT_LISTING_SORT_BY_ATTRIBUTES + FASTBOOT_TAX_RATE_ + FASTBOOT_GROUP_ + FASTBOOT_CURRENCY_RATE_ + + + GraphCommerce\FastBootCache\Model\Tag::TAX + GraphCommerce\FastBootCache\Model\Tag::CURRENCY @@ -53,6 +59,28 @@ + + + + + + + + + + + + + + + + + + + + + + GraphCommerce\FastBoot\Console\StatusCommand diff --git a/src/FastBoot/etc/module.xml b/src/FastBoot/etc/module.xml index c069bc5..5dcb174 100644 --- a/src/FastBoot/etc/module.xml +++ b/src/FastBoot/etc/module.xml @@ -6,6 +6,9 @@ + + + diff --git a/src/FastBootCache/Model/Feature.php b/src/FastBootCache/Model/Feature.php index 09dd5d2..486dc80 100644 --- a/src/FastBootCache/Model/Feature.php +++ b/src/FastBootCache/Model/Feature.php @@ -15,7 +15,7 @@ class Feature { private ?array $switches = null; - private const CACHED = ['schema_array' => true,'cache_files' => true,'system_config_array' => true,'view_config' => true,'parsed_queries' => true,'validated_queries' => true,'scopes_cache' => true,'website_stores' => true,'deploy_config_unchanged' => true,'placeholder_url' => true,'guest_tax_factor' => true]; + private const CACHED = ['schema_array' => true,'cache_files' => true,'system_config_array' => true,'view_config' => true,'parsed_queries' => true,'validated_queries' => true,'scopes_cache' => true,'website_stores' => true,'deploy_config_unchanged' => true,'placeholder_url' => true,'guest_tax_factor' => true,'tax_rates' => true,'customer_groups' => true,'currency_rates' => true]; public function __construct( private readonly DeploymentConfig $deploymentConfig, diff --git a/src/FastBootCache/Model/PhpFiles.php b/src/FastBootCache/Model/PhpFiles.php index 7fc9d8e..1851d89 100644 --- a/src/FastBootCache/Model/PhpFiles.php +++ b/src/FastBootCache/Model/PhpFiles.php @@ -12,6 +12,9 @@ class PhpFiles { private ?string $root = null; + + /** @var array the entries this process read or wrote, by index path */ + private array $loaded = []; public const LOADED_LIFETIME = 0; // Unknown backend lifetimes must never be extended. public function __construct(private Filesystem $filesystem, private Version $version, private DeploymentConfig $deploymentConfig, private Release $release) { @@ -44,6 +47,9 @@ public function read(string $group, string $id): mixed return null; } $index = $this->path($group, $id); + if (isset($this->loaded[$index]) && ($this->loaded[$index]['expires'] === null || $this->loaded[$index]['expires'] > microtime(true))) { + return $this->loaded[$index]['value']; + } $record = is_file($index) ? json_decode((string)@file_get_contents($index), true) : null; if (!is_array($record) || !isset($record['hash']) || !is_string($record['hash']) || !preg_match('/^[a-f0-9]{64}$/D', $record['hash']) || !array_key_exists('expires', $record) || ($record['expires'] !== null && !is_numeric($record['expires'])) || ($record['expires'] !== null && $record['expires'] <= microtime(true))) { return null; @@ -65,8 +71,21 @@ public function read(string $group, string $id): mixed @unlink($file); return null; } + $this->remember($index, $value['value'], $record['expires'] === null ? null : (float)$record['expires']); return $value['value']; } + + /** + * A worker process answers the entry from memory until it expires; the index path carries + * the version, so a bump leaves the memory behind. + */ + private function remember(string $index, mixed $value, ?float $expires): void + { + if (count($this->loaded) >= 512) { + $this->loaded = []; + } + $this->loaded[$index] = ['value' => $value, 'expires' => $expires]; + } public function write(string $group, string $id, mixed $value, ?int $lifeTime = null, ?string $expectedVersion = null): void { if ($this->release->id() === null) { @@ -126,7 +145,10 @@ public function write(string $group, string $id, mixed $value, ?int $lifeTime = if (!is_dir(dirname($index)) && !@mkdir(dirname($index), 0700, true) && !is_dir(dirname($index))) { return; } - $this->atomic($index, json_encode(['hash' => $hash,'expires' => $lifeTime === null ? null : microtime(true) + $lifeTime], JSON_THROW_ON_ERROR)); + $expires = $lifeTime === null ? null : microtime(true) + $lifeTime; + if ($this->atomic($index, json_encode(['hash' => $hash,'expires' => $expires], JSON_THROW_ON_ERROR))) { + $this->remember($index, $value, $expires); + } } finally { flock($lock, LOCK_UN); fclose($lock); @@ -162,11 +184,13 @@ private static function exportable(mixed $value, int $depth = 0): bool } public function remove(string $group, string $id): void { + unset($this->loaded[$this->path($group, $id)]); @unlink($this->path($group, $id)); } /** Remove obsolete indexes only. Blobs stay reusable and bounded until release cleanup/FPM restart. */ public function sweep(): void { + $this->loaded = []; $root = $this->namespaceDirectory().'/indexes'; foreach (glob($root.'/*', GLOB_ONLYDIR) ?: [] as $dir) { $this->removeDirectory($dir); diff --git a/src/FastBootCache/Model/Tag.php b/src/FastBootCache/Model/Tag.php new file mode 100644 index 0000000..ebcb35a --- /dev/null +++ b/src/FastBootCache/Model/Tag.php @@ -0,0 +1,14 @@ + true], $b->read('CONFIG', 'a/b')); self::assertSame('different', $b->read('CONFIG', 'a_b')); $b->sweep(); - self::assertNull($a->read('CONFIG', 'a/b')); self::assertNull($b->read('CONFIG', 'a/b')); + self::assertNull($this->files('a')->read('CONFIG', 'a/b'), 'A sweep comes with a version bump, so another process reads under a new path'); } public function testPublicationGenerationAndExpiryAreRespected(): void { @@ -81,4 +81,19 @@ public function testAdmissionIsBoundedAndObjectsAreNeverExported(): void $f->write('CONFIG', 'object', new \stdClass()); self::assertNull($f->read('CONFIG', 'object')); } + + public function testAProcessAnswersAnEntryFromMemoryUntilItExpiresOrIsRemoved(): void + { + $files = $this->files('v1'); + $files->write('G', 'kept', ['a' => 1]); + $files->write('G', 'brief', ['b' => 2], 1); + $this->remove($this->var.'/fastboot'); + self::assertSame(['a' => 1], $files->read('G', 'kept'), 'The memory of this process answers after the files are gone'); + self::assertSame(['b' => 2], $files->read('G', 'brief')); + usleep(1100000); + self::assertNull($files->read('G', 'brief'), 'An expired entry leaves the memory'); + $files->remove('G', 'kept'); + self::assertNull($files->read('G', 'kept')); + self::assertNull($this->files('v1')->read('G', 'kept'), 'Another process reads the files'); + } } diff --git a/src/FastBootGraphQl/Plugin/CacheId/GuestTaxFactor.php b/src/FastBootGraphQl/Plugin/CacheId/GuestTaxFactor.php index ec216b2..053ce62 100644 --- a/src/FastBootGraphQl/Plugin/CacheId/GuestTaxFactor.php +++ b/src/FastBootGraphQl/Plugin/CacheId/GuestTaxFactor.php @@ -4,6 +4,7 @@ namespace GraphCommerce\FastBootGraphQl\Plugin\CacheId; use GraphCommerce\FastBootCache\Model\Feature; +use GraphCommerce\FastBootCache\Model\Tag; use Magento\CustomerGraphQl\CacheIdFactorProviders\CustomerTaxRateProvider; use Magento\Framework\App\Cache\Type\Config as ConfigCache; use Magento\Framework\App\CacheInterface; @@ -21,7 +22,7 @@ class GuestTaxFactor { private const SWITCH = 'guest_tax_factor'; - public const TAG = 'FASTBOOT_TAX'; + public const TAG = Tag::TAX; private const KEY = 'FASTBOOT_TAX_FACTOR_'; diff --git a/src/FastBootGraphQl/Plugin/Tax/ForgetTaxFactors.php b/src/FastBootGraphQl/Plugin/Tax/ForgetTaxFactors.php deleted file mode 100644 index 61a7f8c..0000000 --- a/src/FastBootGraphQl/Plugin/Tax/ForgetTaxFactors.php +++ /dev/null @@ -1,33 +0,0 @@ -cache->clean([GuestTaxFactor::TAG]); - - return $result; - } - - public function afterDelete($subject, $result) - { - $this->cache->clean([GuestTaxFactor::TAG]); - - return $result; - } -} diff --git a/src/FastBootGraphQl/etc/di.xml b/src/FastBootGraphQl/etc/di.xml index d5f092f..b81f447 100644 --- a/src/FastBootGraphQl/etc/di.xml +++ b/src/FastBootGraphQl/etc/di.xml @@ -40,18 +40,6 @@ - - - - - - - - - - - - From b1f7dbdee7aa1982a2f16bb1b62c1f88f4a934c7 Mon Sep 17 00:00:00 2001 From: Paul Hachmang Date: Thu, 24 Sep 2026 19:01:16 +0200 Subject: [PATCH 2/4] The group test declares the generated factory when the unit run has no generated code --- .../Unit/Plugin/Customer/GroupFromCacheTest.php | 2 +- ...-extension.php => customer-group-generated.php} | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) rename src/FastBoot/Test/Unit/_files/{customer-group-extension.php => customer-group-generated.php} (64%) diff --git a/src/FastBoot/Test/Unit/Plugin/Customer/GroupFromCacheTest.php b/src/FastBoot/Test/Unit/Plugin/Customer/GroupFromCacheTest.php index c1d7e2a..5eafa49 100644 --- a/src/FastBoot/Test/Unit/Plugin/Customer/GroupFromCacheTest.php +++ b/src/FastBoot/Test/Unit/Plugin/Customer/GroupFromCacheTest.php @@ -14,7 +14,7 @@ use Magento\Framework\DataObject; use PHPUnit\Framework\TestCase; -require_once __DIR__ . '/../../_files/customer-group-extension.php'; +require_once __DIR__ . '/../../_files/customer-group-generated.php'; class GroupFromCacheTest extends TestCase { diff --git a/src/FastBoot/Test/Unit/_files/customer-group-extension.php b/src/FastBoot/Test/Unit/_files/customer-group-generated.php similarity index 64% rename from src/FastBoot/Test/Unit/_files/customer-group-extension.php rename to src/FastBoot/Test/Unit/_files/customer-group-generated.php index e94d419..ebdb35e 100644 --- a/src/FastBoot/Test/Unit/_files/customer-group-extension.php +++ b/src/FastBoot/Test/Unit/_files/customer-group-generated.php @@ -1,12 +1,12 @@ Date: Thu, 24 Sep 2026 19:07:33 +0200 Subject: [PATCH 3/4] Tax rate entries serve requests that are not customer-specific A signed-in customer's rate request carries the customer's own address, which would add an entry per address to the cache and the local blobs. --- dev/README.md | 2 +- src/FastBoot/Plugin/Tax/RatesFromCache.php | 8 ++++++-- .../Test/Unit/Plugin/Tax/RatesFromCacheTest.php | 15 ++++++++++++--- src/FastBoot/etc/module.xml | 1 + 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/dev/README.md b/dev/README.md index 6c48575..0a097f1 100644 --- a/dev/README.md +++ b/dev/README.md @@ -27,7 +27,7 @@ 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. | 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 | diff --git a/src/FastBoot/Plugin/Tax/RatesFromCache.php b/src/FastBoot/Plugin/Tax/RatesFromCache.php index 61bdfd9..906b819 100644 --- a/src/FastBoot/Plugin/Tax/RatesFromCache.php +++ b/src/FastBoot/Plugin/Tax/RatesFromCache.php @@ -5,6 +5,7 @@ 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; @@ -13,7 +14,9 @@ /** * 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. + * 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 { @@ -24,6 +27,7 @@ public function __construct( private readonly CacheInterface $cache, private readonly Feature $feature, private readonly StoreManagerInterface $storeManager, + private readonly UserContextInterface $userContext, ) { } @@ -43,7 +47,7 @@ public function aroundGetCalculationProcess(Calculation $subject, callable $proc private function entry(string $kind, $request, callable $compute): array { - if (!$this->feature->on(self::SWITCH)) { + if (!$this->feature->on(self::SWITCH) || (int)$this->userContext->getUserType() === UserContextInterface::USER_TYPE_CUSTOMER) { return $compute(); } $key = self::KEY . $kind . '_' . md5(implode('|', [ diff --git a/src/FastBoot/Test/Unit/Plugin/Tax/RatesFromCacheTest.php b/src/FastBoot/Test/Unit/Plugin/Tax/RatesFromCacheTest.php index 044379b..bac77c1 100644 --- a/src/FastBoot/Test/Unit/Plugin/Tax/RatesFromCacheTest.php +++ b/src/FastBoot/Test/Unit/Plugin/Tax/RatesFromCacheTest.php @@ -6,6 +6,7 @@ use GraphCommerce\FastBoot\Plugin\Tax\RatesFromCache; use GraphCommerce\FastBootCache\Model\Feature; use GraphCommerce\FastBootCache\Model\Tag; +use Magento\Authorization\Model\UserContextInterface; use Magento\Framework\App\CacheInterface; use Magento\Framework\DataObject; use Magento\Store\Api\Data\StoreInterface; @@ -19,7 +20,7 @@ class RatesFromCacheTest extends TestCase private array $entries = []; private array $tags = []; - private function plugin(bool $on = true): RatesFromCache + private function plugin(bool $on = true, int $userType = UserContextInterface::USER_TYPE_GUEST): RatesFromCache { $cache = $this->createStub(CacheInterface::class); $cache->method('load')->willReturnCallback(fn(string $id) => $this->entries[$id] ?? false); @@ -36,7 +37,10 @@ private function plugin(bool $on = true): RatesFromCache $stores = $this->createStub(StoreManagerInterface::class); $stores->method('getStore')->willReturn($store); - return new RatesFromCache($cache, $feature, $stores); + $user = $this->createStub(UserContextInterface::class); + $user->method('getUserType')->willReturn($userType); + + return new RatesFromCache($cache, $feature, $stores, $user); } private function request(string $country = 'NL', int $productClass = 2): DataObject @@ -64,7 +68,7 @@ public function testTheRateInfoOfARequestKeyIsComputedOnceAndTaggedForTaxSaves() self::assertSame(2, $calls, 'Another destination is another entry'); } - public function testAProcessWithGivenRatesAndAnOffSwitchBypassTheCache(): void + public function testGivenRatesAnOffSwitchAndACustomerRequestBypassTheCache(): void { $subject = $this->createStub(Calculation::class); $calls = 0; @@ -82,5 +86,10 @@ public function testAProcessWithGivenRatesAndAnOffSwitchBypassTheCache(): void $off->aroundGetCalculationProcess($subject, $proceed, $this->request()); $off->aroundGetCalculationProcess($subject, $proceed, $this->request()); self::assertSame(4, $calls); + $customer = $this->plugin(true, UserContextInterface::USER_TYPE_CUSTOMER); + $customer->aroundGetRateInfo($subject, $proceed, $this->request()); + $customer->aroundGetRateInfo($subject, $proceed, $this->request()); + self::assertSame(6, $calls, 'A customer request keeps its own address out of the entries'); + self::assertSame([], $this->entries); } } diff --git a/src/FastBoot/etc/module.xml b/src/FastBoot/etc/module.xml index 5dcb174..6ea29ec 100644 --- a/src/FastBoot/etc/module.xml +++ b/src/FastBoot/etc/module.xml @@ -7,6 +7,7 @@ + From 40df73e9f17dc6de34ce0917d8099ed3f9eaee4d Mon Sep 17 00:00:00 2001 From: Paul Hachmang Date: Thu, 24 Sep 2026 19:26:56 +0200 Subject: [PATCH 4/4] The changelog lists the entries of this branch as unreleased above 0.2.0-rc7 --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9f1814..a56e80b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,12 @@ # Changelog -## 0.2.0-rc7 +## 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. ## 0.2.0-rc6