Skip to content

Fix Fastly domain purge, and make both cache adapters consistent - #6

Merged
Meldiron merged 2 commits into
mainfrom
feat-fastly-domain-key
Aug 13, 2026
Merged

Fix Fastly domain purge, and make both cache adapters consistent#6
Meldiron merged 2 commits into
mainfrom
feat-fastly-domain-key

Conversation

@Meldiron

@Meldiron Meldiron commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes a real defect in Fastly::purgeDomain(), applies the same scrutiny to Cloudflare, and moves the part both providers repeat into one place. Targets main and is independent of #5 — that PR only adds files and touches no existing adapter, so the two can merge in either order.

Everything below comes from reading the provider docs rather than from assumption: Fastly purge API, Fastly purging concepts, Cloudflare purge API, Cloudflare purge cache.

1. Fastly::purgeDomain() never purged a domain

On main the argument is validated and then discarded:

public function purgeDomain(string $domain): void
{
    Domain::validate($domain);   // ← $domain is used here and nowhere else
    $this->requireServiceId('domain purging');
    $result = $this->request(Method::POST, '/service/' . $this->serviceId . '/purge_all');
    //                                   ← the domain never reaches the request

Two different domains therefore produce byte-identical requests:

purgeDomain('alice.example.com') -> https://api.fastly.com/service/shared-svc/purge_all
purgeDomain('bob.example.com')   -> https://api.fastly.com/service/shared-svc/purge_all
identical requests? YES

On a service fronting many domains that evicts all of them. And purge_all is no mild substitute: Fastly documents it as taking up to two minutes, warns that "purging a large amount of content from a high traffic service is likely to result in a rapid increase in traffic to origin", and states it "is not compatible with soft purge or bulk purge" — so softPurge: true was silently ignored here. A caller asking to soft purge one domain got a hard purge of everything.

Fastly has no purge-by-host operation. Its API is URL purge, surrogate key purge (single or batch), and purge-all — nothing in between. A domain is therefore addressed by the surrogate key the origin attaches, which is Fastly's own recommendation: surrogate key purges "can be used in place of single URL purge and purge-all".

domainKeyPrefix is therefore required, and there is no longer any configuration in which purgeDomain() means purge_all. Pass '' when the key is the bare hostname.

2. Cloudflare needed no such fix, and gained what it was missing

Cloudflare purges a hostname natively ({"hosts": [...]}), so purgeDomain() already sent the domain rather than discarding it. Worth stating plainly: the bug was Fastly's, not a shared design flaw.

What Cloudflare was missing is a zone-wide purge — purge_everything was not implemented at all. It is now purgeZone().

Its batch ceiling is also now configurable, because Cloudflare's own pages disagree: the purge overview tables say 100 operations per request for tags/hostnames/prefixes (and 100 URLs for single-file purge, 500 on Enterprise), while the purge-by-hostname page says 30 hostnames at a time. The default stays at the lower figure, which is within both readings; itemsPerPurge raises it. Also relevant to batching: Cloudflare's Free tier allows only 5 purge requests per minute.

3. purge_all / purge_everything becomes purgeZone() on both

Renamed from purgeService() per review. Both adapters use the same name, and both keep it off the Adapter interface, so a routing adapter can never reach it by fanning out an interface method — a test asserts its absence from the interface.

4. A shared base so bulk operations and provider-specific methods are easy to add

Cache\Adapter\Api owns the sequence both providers repeat — authenticate a request, send it, decide whether the answer means success, turn a failure into a message. Each adapter supplies only those four pieces plus a USER_AGENT, and an operation becomes the request it makes:

// One call.
$this->send(Method::POST, '/zones/' . $this->zoneId . '/purge_cache', ['purge_everything' => true]);

// As few calls as the provider's per-request ceiling allows.
$this->batch($keys, self::KEYS_PER_PURGE, function (array $chunk): void {
    $this->send(Method::POST, '/service/' . $this->serviceId . '/purge', ['surrogate_keys' => $chunk]);
});

Both adapters previously carried their own copy of the request-building, JSON decoding and error-formatting code. Per-request ceilings are now named constants (Fastly::KEYS_PER_PURGE, Cloudflare::ITEMS_PER_PURGE) rather than inline magic numbers.

Fastly::purgeKeys() uses this to adopt the batch endpoint: POST /service/{sid}/purge with {"surrogate_keys": [...]}, up to 256 keys per request instead of one request per key. Soft purge still applies — Fastly: "Single object, surrogate key, and bulk surrogate key purges all support soft purge". Keys in a body also need no percent-encoding, which would purge a key the origin never attached.

Test Plan

composer test — 40 tests, 71 assertions, from 26/49 on main. composer analyse (level 6) [OK] No errors; composer lint passes.

The extraction changed no wire behaviour. I captured method, URL, every header and body for Cloudflare's three operations and Fastly's path purge on main and on this branch, and diffed: IDENTICAL: no wire change for these operations. Only Fastly's key and domain purges differ, which is the point of the PR.

Three behaviours seen red before being claimed:

Regression introduced Result
purgeDomain() pointed back at purgeZone() Failures: 2, printing +'…/service/shared-service/purge_all' — the original bug
rawurlencode re-added to the batch body Failures: 1, -'domain-example.com-summer sale' / +'…summer%20sale'
purgeZone() sending hosts instead of purge_everything Failures: 1, -'purge_everything' => true
Cloudflare isSuccess() reduced to the HTTP status Failures: 1 — a {"success":false} body stops being caught

New coverage: Fastly domain purge as exactly one key-purge request; an empty prefix yielding the bare hostname; keys asserted unencoded; 257 keys becoming 2 requests not 257; purgeZone() on both adapters; purgeZone absent from the interface; zone purge requiring a service ID; Cloudflare batching URLs and tags with the boundary asserted; a configurable ceiling collapsing 100 tags into one request; Cloudflare rejecting a 2xx whose body says success: false; empty purges touching nothing.

Every README example for both providers was extracted from the markdown and executed against this branch, so the reordered Fastly constructor and the new Cloudflare snippets are known to resolve and construct rather than merely to look right.

Not verified against the live Fastly or Cloudflare APIs — coverage is at request-construction level throughout, as with the rest of the suite.

Breaking changes

  • Fastly: domainKeyPrefix is a required second constructor argument, so positional calls shift.
  • Fastly::purgeDomain() no longer purges the whole service; callers who wanted that call purgeZone().
  • Fastly::purgeKeys() sends raw keys in a body, so callers that pre-encoded keys must stop.
  • Both adapters now extend Cache\Adapter\Api; anyone subclassing them will see the new abstract methods.
  • Cloudflare's constructor gains a trailing itemsPerPurge; existing calls are unaffected.

Library is 0.0.x. The only consumer I know of is appwrite-labs/cloud#5259, which passes named arguments, calls neither purgeZone(), and is updated for the Fastly wire format.

Related PRs and Issues

Split out of #5, then widened after reading the provider docs. Both are needed by appwrite-labs/cloud#5259, which tracks the integration/balancer-and-fastly branch (a merge of the two) until they are merged and tagged.

Checklist

  • I read the contributing guide
  • I ran composer lint
  • I ran composer analyse
  • I ran composer test

🤖 Generated with Claude Code

@greptile-apps

greptile-apps Bot commented Aug 13, 2026

Copy link
Copy Markdown

Greptile Summary

The PR corrects Fastly domain purging by mapping domains to surrogate keys and adds explicit whole-zone purge operations for Fastly and Cloudflare.

  • Batches Fastly surrogate-key purges through the provider’s bulk endpoint.
  • Adds Cloudflare and Fastly purgeZone() implementations.
  • Consolidates provider request handling and expands request-construction coverage.
  • Documents the revised adapter behavior and Fastly constructor contract.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
src/Cdn/Cache/Adapter/Fastly.php Replaces service-wide domain purges with batched surrogate-key purges and exposes whole-service purging separately.
src/Cdn/Cache/Adapter/Cloudflare.php Consolidates purge requests, names batching limits, validates body-level success, and adds whole-zone purging.
src/Cdn/Cache/Adapter.php Adds whole-zone purging to the common adapter contract; the previously reported fanout path is absent from the current repository.
src/Cdn/Cache.php Delegates whole-zone purging to the facade’s single configured adapter.
tests/Cdn/Cache/AdapterTest.php Verifies consistent adapter operations and provider-specific batching constants.

Reviews (8): Last reviewed commit: "Correct the adapter comments and docs" | Re-trigger Greptile

@Meldiron Meldiron changed the title Purge one domain off a shared Fastly service Fix Fastly domain purge, and batch surrogate key purges Aug 13, 2026
@Meldiron
Meldiron force-pushed the feat-fastly-domain-key branch from c8d467b to bfad39e Compare August 13, 2026 18:21
@Meldiron
Meldiron changed the base branch from feat-balancer-adapter to main August 13, 2026 18:21
@Meldiron Meldiron changed the title Fix Fastly domain purge, and batch surrogate key purges Fix Fastly domain purge, and make both cache adapters consistent Aug 13, 2026

@Meldiron Meldiron left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

Comment thread src/Cdn/Cache/Adapter.php
@Meldiron Meldiron mentioned this pull request Aug 13, 2026
4 tasks
purgeDomain() ran purge_all on Fastly and never sent the domain: the
argument was validated and discarded, so two different domains produced
byte-identical requests. On a service fronting many domains that evicts all
of them, and Fastly documents purge_all as taking up to two minutes,
spiking origin traffic, and being incompatible with soft purge -- so a
caller asking for a soft purge of one domain got a hard purge of
everything.

Fastly has no purge-by-host operation; its API offers URL, surrogate key
and whole-service purges and nothing else. A domain is therefore the
surrogate key the origin attaches, and the adapter has to know how those
keys are named, so domainKeyPrefix is required rather than optional. There
is no configuration in which purgeDomain() means purge_all.

Cloudflare needed no such fix -- it purges a hostname natively -- but it was
missing a zone purge entirely. Cache\Adapter now declares purgeZone()
alongside the other three, so both providers offer the same set and Cache
exposes it. Naming is aligned across adapters: purgeZone() for the widest
purge either provider has, and PATHS_PER_PURGE / KEYS_PER_PURGE for the
per-request ceilings, provider-specific numbers behind identical names.

purgeKeys() on Fastly uses the batch endpoint: keys in the request body, up
to 256 per request rather than one request each. A body also means no
percent-encoding, which would purge a key the origin never attached.

tests/Cdn/Cache/AdapterTest.php asserts the contract itself, so a provider
growing a purge the others lack, or spelling one differently, fails there
rather than in a caller that assumed they behaved alike.

Requests are byte-identical to main for Cloudflare's three operations and
Fastly's path purge, headers included.

Refs Fastly purging API and purging concepts, Cloudflare purge cache docs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Meldiron
Meldiron force-pushed the feat-fastly-domain-key branch from 564938a to b4e4afd Compare August 13, 2026 18:59
Audit of what the diff actually claims, against the code and the provider
documentation.

- Fastly::PATHS_PER_PURGE was read by nothing but a test. A URL purge takes
  one URL, so there is no ceiling to name; only Cloudflare batches paths.
- Cloudflare's batch constants claimed the provider's pages disagree about
  the ceiling. They do about hostnames and prefixes, not about the URLs and
  tags this adapter batches: those are documented at 100 per request, 500 on
  Enterprise for URLs. 30 is a conservative default and, more to the point,
  the number this adapter already sent -- the constants name existing
  behaviour rather than change it.
- purgeZone() said the purge is disruptive to origin "for both providers".
  Fastly documents that; for Cloudflare it was my inference. Replaced with
  the consequence that holds either way.
- The README still advertised three purge modes while listing four.
- The Fastly note omitted zone purges from the operations that need a
  service ID.
- Dropped a "now" that dated a comment to this change rather than describing
  the code, and restored the empty-paths guard so both adapters read alike.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Meldiron
Meldiron merged commit 0a40c5b into main Aug 13, 2026
4 checks passed
Meldiron added a commit that referenced this pull request Aug 13, 2026
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) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants