From 3493cc089a546d64ee7d704b034276a019fc9d46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Thu, 13 Aug 2026 21:40:41 +0200 Subject: [PATCH] Add balancer cache adapter with typed CDN options Provider selection lived in every consumer: read the env, decide which of Fastly and Cloudflare applies, build one Cache per provider, call a different purge method on each. That logic is the same everywhere and is wrong in the same way everywhere, so it moves here. Cache\Adapter\Balancer takes a utopia-php/balancer Balancer and purges through every option its filters leave standing, attempting each independently and aggregating failures into Exception\Purge, so one provider outage cannot silently skip the rest. No option matching the filters raises Exception\Configuration rather than passing quietly. Extend\CdnOption wraps a balancer Option with typed accessors, so a filter reads getProvider()/isEdge() instead of getState('provider'), and carries the provider names as constants rather than a separate enum class. purgeZone() is implemented too, since #6 put it on the interface: it fans out like the others, which makes it the widest purge available here, and only the filters keep it away from the options they exclude. AdapterTest now covers this adapter as well, so a composite that lagged the interface would fail there rather than silently stop forwarding an operation. Requires utopia-php/balancer 0.4.1 for getFilteredOptions(): run() returns one option and $filters is private, while a purge has to reach every provider that may hold a response for the domain. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 44 +++++ composer.json | 3 +- composer.lock | 50 ++++- src/Cdn/Cache/Adapter/Balancer.php | 121 +++++++++++++ src/Cdn/Exception/Purge.php | 27 +++ src/Cdn/Extend/CdnOption.php | 71 ++++++++ tests/Cdn/Cache/Adapter/BalancerTest.php | 221 +++++++++++++++++++++++ tests/Cdn/Cache/AdapterTest.php | 5 +- tests/Cdn/Extend/CdnOptionTest.php | 77 ++++++++ 9 files changed, 616 insertions(+), 3 deletions(-) create mode 100644 src/Cdn/Cache/Adapter/Balancer.php create mode 100644 src/Cdn/Exception/Purge.php create mode 100644 src/Cdn/Extend/CdnOption.php create mode 100644 tests/Cdn/Cache/Adapter/BalancerTest.php create mode 100644 tests/Cdn/Extend/CdnOptionTest.php diff --git a/README.md b/README.md index 8bb1884..2e37411 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,50 @@ Keys are sent as given, in the request body, batched up to 256 per request. A Fa `purgeZone()` purges everything on the service (`purge_all`), which Fastly documents as taking up to two minutes, being incompatible with soft purge, and likely to spike origin traffic on a busy service. Prefer `purgeDomain()` or `purgeKeys()`. +### Cache balancing + +`Cache\Adapter\Balancer` turns provider selection into configuration. Every provider is declared once as an option, filters decide which options a purge applies to, and the purge then reaches **all** of them — content cached by two providers has to be evicted from both. + +Options are `Extend\CdnOption`, a [utopia-php/balancer](https://github.com/utopia-php/balancer) `Option` with typed accessors, so a filter reads `$option->getProvider()` rather than guessing a state key. The balancer itself is the library's own, unwrapped: + +```php +addOption(new CdnOption($fastlyEdge, CdnOption::PROVIDER_FASTLY, edge: true)) + ->addOption(new CdnOption($fastlyRun, CdnOption::PROVIDER_FASTLY)) + ->addOption(new CdnOption($cloudflare, CdnOption::PROVIDER_CLOUDFLARE)); + +// Custom domains are cached by the run service and by Cloudflare, so purge both. +$balancer->addFilter(fn (CdnOption $option): bool => !$option->isEdge()); + +$cache = new Cache(new BalancerAdapter($balancer)); + +// One call, two providers: a Fastly surrogate key purge and a Cloudflare cache-tag purge. +$cache->purgeKeys(['domain-customer.example.com']); +``` + +`isEdge()` marks options that front the platform's own edge network rather than customer-owned custom domains. Filters compose, so narrowing to a single option is just a matter of adding another: + +```php +$balancer + ->addFilter(fn (CdnOption $option): bool => $option->getProvider() === CdnOption::PROVIDER_FASTLY) + ->addFilter(fn (CdnOption $option): bool => $option->isEdge()); +``` + +Failures are aggregated rather than short-circuiting: every matching option is attempted, then the collected errors are raised together as `Exception\Purge`, whose `getErrors()` returns one throwable per failed provider. A provider outage therefore cannot stop the purge from reaching the others. When no option matches the filters, the purge raises `Exception\Configuration` instead of passing silently. + +`purgeZone()` fans out like the rest, so one call drops every matching provider's whole cache — every domain it holds, not only the ones these options front. Filters still apply, which is the only thing keeping it from reaching the options they exclude. + +Options stay ordinary balancer options, so `run()` still picks a single one through the `Algorithm` for callers that want exactly that. + ### The adapter contract `Cache\Adapter` declares the four purges every adapter offers — `purgePaths()`, `purgeDomain()`, `purgeKeys()` and `purgeZone()` — so a caller never has to know which provider is behind it. Providers differ in what they expose natively, and the adapter absorbs the difference: Cloudflare purges a hostname directly, while Fastly maps the same call onto a surrogate key. Where an adapter cannot serve an operation with the configuration it was given it raises `Exception\UnsupportedOperation`, rather than quietly doing something wider. diff --git a/composer.json b/composer.json index 061f8d3..6da2278 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,8 @@ "php": ">=8.5.0", "ext-curl": "*", "ext-json": "*", - "utopia-php/client": "^0.3" + "utopia-php/client": "^0.3", + "utopia-php/balancer": "^0.4.1" }, "require-dev": { "phpunit/phpunit": "^10.5", diff --git a/composer.lock b/composer.lock index 1184657..b10f4e4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9d8e3f40f0ec1f09031e899e6c3ca55c", + "content-hash": "448aa1f9f6b4a04d151922aa2a1c1a0b", "packages": [ { "name": "brick/math", @@ -1877,6 +1877,54 @@ }, "time": "2025-06-29T15:42:06+00:00" }, + { + "name": "utopia-php/balancer", + "version": "0.4.1", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/balancer.git", + "reference": "7fc8922da3e381c227cd88435cb12b17040c6ee1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/balancer/zipball/7fc8922da3e381c227cd88435cb12b17040c6ee1", + "reference": "7fc8922da3e381c227cd88435cb12b17040c6ee1", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "utopia-php/telemetry": "^0.4.0" + }, + "require-dev": { + "laravel/pint": "1.2.*", + "phpstan/phpstan": "^1.8", + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Balancer\\": "src/Balancer" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A simple library to balance choices between multiple options.", + "keywords": [ + "balancer", + "balancing", + "framework", + "php", + "upf", + "utopia" + ], + "support": { + "issues": "https://github.com/utopia-php/balancer/issues", + "source": "https://github.com/utopia-php/balancer/tree/0.4.1" + }, + "time": "2026-08-13T19:36:49+00:00" + }, { "name": "utopia-php/client", "version": "0.3.0", diff --git a/src/Cdn/Cache/Adapter/Balancer.php b/src/Cdn/Cache/Adapter/Balancer.php new file mode 100644 index 0000000..72030e4 --- /dev/null +++ b/src/Cdn/Cache/Adapter/Balancer.php @@ -0,0 +1,121 @@ +each('path purging', static function (Adapter $adapter) use ($domain, $paths): void { + $adapter->purgePaths($domain, $paths); + }); + } + + public function purgeDomain(string $domain): void + { + $domain = Domain::validate($domain); + + $this->each('domain purging', static function (Adapter $adapter) use ($domain): void { + $adapter->purgeDomain($domain); + }); + } + + public function purgeKeys(array $keys): void + { + if ($keys === []) { + return; + } + + $this->each('cache key purging', static function (Adapter $adapter) use ($keys): void { + $adapter->purgeKeys($keys); + }); + } + + /** + * Purges every zone behind a matching option, which is as wide as a purge gets here: each + * provider drops everything it holds, for every domain, not only the ones these options front. + */ + public function purgeZone(): void + { + $this->each('zone purging', static function (Adapter $adapter): void { + $adapter->purgeZone(); + }); + } + + /** + * @param callable(Adapter): void $purge + */ + private function each(string $operation, callable $purge): void + { + $options = $this->balancer->getFilteredOptions(); + + if ($options === []) { + throw new Configuration('No cache options matched the balancer filters.'); + } + + /** @var array $errors */ + $errors = []; + /** @var array $failed */ + $failed = []; + $purged = false; + + foreach ($options as $option) { + // A balancer accepts any option, so what it is holding is checked here. + if (!$option instanceof CdnOption) { + throw new Configuration('Cache options must be instances of ' . CdnOption::class . '.'); + } + + try { + $purge($option->getAdapter()); + $purged = true; + } catch (UnsupportedOperation) { + // An option that cannot serve this operation is not a failure; + // the remaining options still have to be purged. + continue; + } catch (\Throwable $error) { + $errors[] = $error; + $failed[] = $option->getProvider(); + } + } + + if ($errors !== []) { + throw new Purge('Cache ' . $operation . ' failed for ' . \implode(', ', \array_unique($failed)) . '.', $errors); + } + + if (!$purged) { + throw new UnsupportedOperation('Cache ' . $operation . ' is not supported by any matching option.'); + } + } +} diff --git a/src/Cdn/Exception/Purge.php b/src/Cdn/Exception/Purge.php new file mode 100644 index 0000000..7eee7bf --- /dev/null +++ b/src/Cdn/Exception/Purge.php @@ -0,0 +1,27 @@ + $errors + */ + public function __construct(string $message, private array $errors = []) + { + parent::__construct($message, 0, $errors[0] ?? null); + } + + /** + * @return array + */ + public function getErrors(): array + { + return $this->errors; + } +} diff --git a/src/Cdn/Extend/CdnOption.php b/src/Cdn/Extend/CdnOption.php new file mode 100644 index 0000000..5b5ed38 --- /dev/null +++ b/src/Cdn/Extend/CdnOption.php @@ -0,0 +1,71 @@ +getState('adapter')` and has to trust the key spelling and the + * value's type. This subclass fixes both ends: the constructor names what an + * option needs and the getters return it typed. + */ +class CdnOption extends Option +{ + public const string ADAPTER = 'adapter'; + + public const string PROVIDER = 'provider'; + + public const string EDGE = 'edge'; + + public const string PROVIDER_FASTLY = 'fastly'; + + public const string PROVIDER_CLOUDFLARE = 'cloudflare'; + + /** + * @param Adapter $adapter Purges cached content for this option. + * @param string $provider Vendor the adapter talks to, one of the PROVIDER_* constants. + * @param bool $edge Whether the option fronts the platform's own edge network rather than customer-owned custom domains. + */ + public function __construct(Adapter $adapter, string $provider, bool $edge = false) + { + parent::__construct([ + self::ADAPTER => $adapter, + self::PROVIDER => $provider, + self::EDGE => $edge, + ]); + } + + public function getAdapter(): Adapter + { + $adapter = $this->getState(self::ADAPTER); + + // State stays publicly writable through setState(), so the type the + // constructor guaranteed is checked again on the way out. + if (!$adapter instanceof Adapter) { + throw new Configuration('Option state "' . self::ADAPTER . '" must be a ' . Adapter::class . '.'); + } + + return $adapter; + } + + public function getProvider(): string + { + $provider = $this->getState(self::PROVIDER); + + if (!\is_string($provider)) { + throw new Configuration('Option state "' . self::PROVIDER . '" must be a string.'); + } + + return $provider; + } + + public function isEdge(): bool + { + return $this->getState(self::EDGE, false) === true; + } +} diff --git a/tests/Cdn/Cache/Adapter/BalancerTest.php b/tests/Cdn/Cache/Adapter/BalancerTest.php new file mode 100644 index 0000000..fecf3c0 --- /dev/null +++ b/tests/Cdn/Cache/Adapter/BalancerTest.php @@ -0,0 +1,221 @@ +addOption(new CdnOption($this->adapter('fastly-edge', $calls), CdnOption::PROVIDER_FASTLY, true)) + ->addOption(new CdnOption($this->adapter('fastly-run', $calls), CdnOption::PROVIDER_FASTLY)) + ->addOption(new CdnOption($this->adapter('cloudflare', $calls), CdnOption::PROVIDER_CLOUDFLARE)); + + $cache = new Cache(new Balancer($balancer)); + + $cache->purgeDomain('example.com'); + $cache->purgePaths('example.com', ['/index.html']); + $cache->purgeKeys(['domain-example.com']); + + $this->assertSame([ + 'fastly-edge:domain', 'fastly-run:domain', 'cloudflare:domain', + 'fastly-edge:paths', 'fastly-run:paths', 'cloudflare:paths', + 'fastly-edge:keys', 'fastly-run:keys', 'cloudflare:keys', + ], $calls->getArrayCopy()); + } + + public function testZonePurgeReachesEveryMatchingOption(): void + { + $calls = new \ArrayObject(); + $balancer = (new OptionBalancer(new First())) + ->addOption(new CdnOption($this->adapter('fastly-edge', $calls), CdnOption::PROVIDER_FASTLY, true)) + ->addOption(new CdnOption($this->adapter('fastly-run', $calls), CdnOption::PROVIDER_FASTLY)) + ->addOption(new CdnOption($this->adapter('cloudflare', $calls), CdnOption::PROVIDER_CLOUDFLARE)); + + $balancer->addFilter(fn (CdnOption $option): bool => !$option->isEdge()); + + (new Cache(new Balancer($balancer)))->purgeZone(); + + // Filters still apply: the edge service keeps its cache. + $this->assertSame(['fastly-run:zone', 'cloudflare:zone'], $calls->getArrayCopy()); + } + + public function testFiltersNarrowTheOptionsPurged(): void + { + $calls = new \ArrayObject(); + $balancer = (new OptionBalancer(new First())) + ->addOption(new CdnOption($this->adapter('fastly-edge', $calls), CdnOption::PROVIDER_FASTLY, true)) + ->addOption(new CdnOption($this->adapter('fastly-run', $calls), CdnOption::PROVIDER_FASTLY)) + ->addOption(new CdnOption($this->adapter('cloudflare', $calls), CdnOption::PROVIDER_CLOUDFLARE)); + + $balancer + ->addFilter(fn (CdnOption $option): bool => $option->getProvider() === CdnOption::PROVIDER_FASTLY) + ->addFilter(fn (CdnOption $option): bool => $option->isEdge()); + + (new Cache(new Balancer($balancer)))->purgeDomain('example.com'); + + $this->assertSame(['fastly-edge:domain'], $calls->getArrayCopy()); + } + + public function testCustomDomainsReachBothProviders(): void + { + $calls = new \ArrayObject(); + $balancer = (new OptionBalancer(new First())) + ->addOption(new CdnOption($this->adapter('fastly-edge', $calls), CdnOption::PROVIDER_FASTLY, true)) + ->addOption(new CdnOption($this->adapter('fastly-run', $calls), CdnOption::PROVIDER_FASTLY)) + ->addOption(new CdnOption($this->adapter('cloudflare', $calls), CdnOption::PROVIDER_CLOUDFLARE)); + + $balancer->addFilter(fn (CdnOption $option): bool => !$option->isEdge()); + + (new Cache(new Balancer($balancer)))->purgeDomain('customer.example.com'); + + $this->assertSame(['fastly-run:domain', 'cloudflare:domain'], $calls->getArrayCopy()); + } + + public function testOneFailingProviderStillPurgesTheOthers(): void + { + $calls = new \ArrayObject(); + $balancer = (new OptionBalancer(new First())) + ->addOption(new CdnOption($this->adapter('fastly', $calls, fails: true), CdnOption::PROVIDER_FASTLY)) + ->addOption(new CdnOption($this->adapter('cloudflare', $calls), CdnOption::PROVIDER_CLOUDFLARE)); + + try { + (new Cache(new Balancer($balancer)))->purgeKeys(['domain-example.com']); + $this->fail('Expected the failed provider to be reported.'); + } catch (Purge $error) { + $this->assertSame('Cache cache key purging failed for fastly.', $error->getMessage()); + $this->assertCount(1, $error->getErrors()); + } + + // The point of the fan-out: Cloudflare was still purged after Fastly failed. + $this->assertSame(['fastly:keys', 'cloudflare:keys'], $calls->getArrayCopy()); + } + + public function testUnsupportedOptionsAreSkippedButStillPurgeTheRest(): void + { + $calls = new \ArrayObject(); + $balancer = (new OptionBalancer(new First())) + ->addOption(new CdnOption($this->adapter('fastly-no-service', $calls, supportsKeys: false), CdnOption::PROVIDER_FASTLY)) + ->addOption(new CdnOption($this->adapter('cloudflare', $calls), CdnOption::PROVIDER_CLOUDFLARE)); + + (new Cache(new Balancer($balancer)))->purgeKeys(['domain-example.com']); + + $this->assertSame(['cloudflare:keys'], $calls->getArrayCopy()); + } + + public function testFailsWhenEveryOptionIsUnsupported(): void + { + $balancer = (new OptionBalancer(new First())) + ->addOption(new CdnOption($this->adapter('fastly', new \ArrayObject(), supportsKeys: false), CdnOption::PROVIDER_FASTLY)); + + $this->expectException(UnsupportedOperation::class); + (new Cache(new Balancer($balancer)))->purgeKeys(['domain-example.com']); + } + + public function testFailsWhenNoOptionMatchesTheFilters(): void + { + $balancer = (new OptionBalancer(new First())) + ->addOption(new CdnOption($this->adapter('fastly', new \ArrayObject()), CdnOption::PROVIDER_FASTLY)); + + $balancer->addFilter(fn (CdnOption $option): bool => $option->getProvider() === CdnOption::PROVIDER_CLOUDFLARE); + + $this->expectException(Configuration::class); + $this->expectExceptionMessage('No cache options matched the balancer filters.'); + (new Cache(new Balancer($balancer)))->purgeDomain('example.com'); + } + + public function testRejectsOptionsThatCarryNoAdapter(): void + { + // A balancer takes any Option, so an untyped one has to be caught here + // rather than purging against whatever its state happens to hold. + $balancer = (new OptionBalancer(new First())) + ->addOption(new Option(['adapter' => $this->adapter('fastly', new \ArrayObject())])); + + $this->expectException(Configuration::class); + $this->expectExceptionMessage('must be instances of'); + (new Cache(new Balancer($balancer)))->purgeDomain('example.com'); + } + + public function testEmptyPurgesTouchNoProvider(): void + { + $calls = new \ArrayObject(); + $balancer = (new OptionBalancer(new First())) + ->addOption(new CdnOption($this->adapter('fastly', $calls), CdnOption::PROVIDER_FASTLY)); + + $cache = new Cache(new Balancer($balancer)); + $cache->purgePaths('example.com', []); + $cache->purgeKeys([]); + + $this->assertSame([], $calls->getArrayCopy()); + } + + public function testRejectsInvalidDomain(): void + { + $balancer = (new OptionBalancer(new First())) + ->addOption(new CdnOption($this->adapter('fastly', new \ArrayObject()), CdnOption::PROVIDER_FASTLY)); + + $this->expectException(\InvalidArgumentException::class); + (new Balancer($balancer))->purgeDomain('https://example.com'); + } + + /** @param \ArrayObject $calls */ + private function adapter(string $name, \ArrayObject $calls, bool $supportsKeys = true, bool $fails = false): Adapter + { + return new class ($name, $calls, $supportsKeys, $fails) implements Adapter { + /** @param \ArrayObject $calls */ + public function __construct( + private string $name, + private \ArrayObject $calls, + private bool $supportsKeys, + private bool $fails, + ) { + } + + public function purgePaths(string $domain, array $paths): void + { + $this->record('paths'); + } + + public function purgeDomain(string $domain): void + { + $this->record('domain'); + } + + public function purgeKeys(array $keys): void + { + if (!$this->supportsKeys) { + throw new UnsupportedOperation($this->name . ' cannot purge keys.'); + } + + $this->record('keys'); + } + + public function purgeZone(): void + { + $this->record('zone'); + } + + private function record(string $operation): void + { + $this->calls->append($this->name . ':' . $operation); + + if ($this->fails) { + throw new \RuntimeException($this->name . ' purge failed.'); + } + } + }; + } +} diff --git a/tests/Cdn/Cache/AdapterTest.php b/tests/Cdn/Cache/AdapterTest.php index bfe25a4..4a76ab1 100644 --- a/tests/Cdn/Cache/AdapterTest.php +++ b/tests/Cdn/Cache/AdapterTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\TestCase; use Utopia\Cdn\Cache; use Utopia\Cdn\Cache\Adapter; +use Utopia\Cdn\Cache\Adapter\Balancer; use Utopia\Cdn\Cache\Adapter\Cloudflare; use Utopia\Cdn\Cache\Adapter\Fastly; @@ -21,7 +22,9 @@ public function testEveryAdapterOffersTheSameOperations(): void { $this->assertSame(self::OPERATIONS, \get_class_methods(Adapter::class)); - foreach ([Fastly::class, Cloudflare::class] as $adapter) { + // Balancer included: a composite adapter that lagged the interface would + // silently stop forwarding whichever operation it had not caught up with. + foreach ([Fastly::class, Cloudflare::class, Balancer::class] as $adapter) { $this->assertContains(Adapter::class, \class_implements($adapter), $adapter . ' must implement the adapter interface'); foreach (self::OPERATIONS as $operation) { diff --git a/tests/Cdn/Extend/CdnOptionTest.php b/tests/Cdn/Extend/CdnOptionTest.php new file mode 100644 index 0000000..8af4d16 --- /dev/null +++ b/tests/Cdn/Extend/CdnOptionTest.php @@ -0,0 +1,77 @@ +adapter(); + $option = new CdnOption($adapter, CdnOption::PROVIDER_FASTLY, true); + + $this->assertSame($adapter, $option->getAdapter()); + $this->assertSame('fastly', $option->getProvider()); + $this->assertTrue($option->isEdge()); + $this->assertFalse((new CdnOption($adapter, CdnOption::PROVIDER_CLOUDFLARE))->isEdge()); + } + + public function testRejectsStateOverwrittenWithTheWrongType(): void + { + $option = new CdnOption($this->adapter(), CdnOption::PROVIDER_FASTLY); + $option->setState(CdnOption::ADAPTER, 'fastly'); + + $this->expectException(Configuration::class); + $option->getAdapter(); + } + + public function testFiltersOnTypedAccessorsInsteadOfStateKeys(): void + { + $edge = new CdnOption($this->adapter(), CdnOption::PROVIDER_FASTLY, true); + $run = new CdnOption($this->adapter(), CdnOption::PROVIDER_FASTLY); + $cloudflare = new CdnOption($this->adapter(), CdnOption::PROVIDER_CLOUDFLARE); + + $balancer = (new Balancer(new First())) + ->addOption($edge) + ->addOption($run) + ->addOption($cloudflare); + + $balancer->addFilter(fn (CdnOption $option): bool => !$option->isEdge()); + + $this->assertSame([$run, $cloudflare], $balancer->getFilteredOptions()); + + $balancer->addFilter(fn (CdnOption $option): bool => $option->getProvider() === CdnOption::PROVIDER_CLOUDFLARE); + + $this->assertSame([$cloudflare], $balancer->getFilteredOptions()); + + // Still an ordinary balancer option, so run() picks one as it always did. + $this->assertSame($cloudflare, $balancer->run()); + } + + private function adapter(): Adapter + { + return new class () implements Adapter { + public function purgePaths(string $domain, array $paths): void + { + } + + public function purgeDomain(string $domain): void + { + } + + public function purgeKeys(array $keys): void + { + } + + public function purgeZone(): void + { + } + }; + } +}