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 + { + } + }; + } +}