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
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<?php

use Utopia\Balancer\Algorithm\First;
use Utopia\Balancer\Balancer;
use Utopia\Cdn\Cache;
use Utopia\Cdn\Cache\Adapter\Balancer as BalancerAdapter;
use Utopia\Cdn\Extend\CdnOption;

// $fastlyEdge, $fastlyRun and $cloudflare are adapters built as shown above.
$balancer = (new Balancer(new First()))
->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.
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
50 changes: 49 additions & 1 deletion composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

121 changes: 121 additions & 0 deletions src/Cdn/Cache/Adapter/Balancer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php

namespace Utopia\Cdn\Cache\Adapter;

use Utopia\Balancer\Balancer as OptionBalancer;
use Utopia\Cdn\Cache\Adapter;
use Utopia\Cdn\Domain;
use Utopia\Cdn\Exception\Configuration;
use Utopia\Cdn\Exception\Purge;
use Utopia\Cdn\Exception\UnsupportedOperation;
use Utopia\Cdn\Extend\CdnOption;

/**
* Purges through every option a balancer's filters leave standing.
*
* The balancer holds the full set of configured providers and the filters that
* narrow it down, which keeps provider selection declarative: a caller states
* what it wants purged and which options qualify, never which API to call. A
* domain cached by more than one provider is evicted from all of them, because
* leaving one provider holding a stale response is the same as not purging.
*
* Providers are attempted independently. One failing does not stop the rest,
* and the collected failures are raised together once every option has been
* tried, so a single provider outage cannot silently skip the others.
*/
class Balancer implements Adapter
{
public function __construct(private OptionBalancer $balancer)
{
}

public function purgePaths(string $domain, array $paths): void
{
$domain = Domain::validate($domain);
$paths = Domain::validatePaths($paths);

if ($paths === []) {
return;
}

$this->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<int, \Throwable> $errors */
$errors = [];
/** @var array<int, string> $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.');
}
}
}
27 changes: 27 additions & 0 deletions src/Cdn/Exception/Purge.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace Utopia\Cdn\Exception;

/**
* Raised when a purge that was attempted on several providers failed on at
* least one of them. Carries every underlying failure, so a caller can log per
* provider instead of seeing only whichever provider happened to fail first.
*/
class Purge extends \RuntimeException
{
/**
* @param array<int, \Throwable> $errors
*/
public function __construct(string $message, private array $errors = [])
{
parent::__construct($message, 0, $errors[0] ?? null);
}

/**
* @return array<int, \Throwable>
*/
public function getErrors(): array
{
return $this->errors;
}
}
71 changes: 71 additions & 0 deletions src/Cdn/Extend/CdnOption.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

namespace Utopia\Cdn\Extend;

use Utopia\Balancer\Option;
use Utopia\Cdn\Cache\Adapter;
use Utopia\Cdn\Exception\Configuration;

/**
* A balancer option that carries a cache adapter.
*
* The base option is an untyped state bag, so a filter written against it reads
* `$option->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;
}
}
Loading
Loading