Page cache & purging
Every tenant site sits behind three page caches. A content change is only visible once the right ones are gone, and emptying them carelessly takes the site (and its neighbours) down for a while. SiteCacheManager clears them in a fixed order and re-warms the origin in between.
The three layers
- WP Fastest Cache: static HTML in
wp-content/cache/all. nginx serves these files directly (try_filesin the vhost), PHP never runs. A client with wp-admin access can disable or delete this layer. - nginx fastcgi_cache: the shared
WPCACHEzone in/var/cache/nginx/wpworks(provisioning/nginx/fastcgi-cache.conf), key$scheme$request_method$host$request_uri, 5 minutes for 200/301/302. This is the floor that survives anything done inside WordPress. - Cloudflare edge: one zone for all
*.wpworks.plsites, Free plan, HTML cached for anonymous GETs by the cache ruleCloudflareCacheManagerinstalls (CLOUDFLARE_EDGE_CACHE_TTL, default 300 s). Sites on a customer domain point straight at the origin and have no edge layer.
Why order matters
Until October 2026 a purge deleted layers 1 and 2 and then called purge_everything on the Cloudflare zone. That emptied the edge for all 13 sites at once, so every site's next visitors hit a cold origin together. Each site's PHP-FPM pool has four ondemand children and a cold Divi render takes seconds, so the logs filled with server reached max_children setting (4) and nginx logged 499s: the "site is unreachable for a moment after a purge" the team reported. nginx's fastcgi_cache_use_stale cannot help here, because the stale files had just been deleted.
What a purge does now
SiteCacheManager::purge(Site, includeEdge, allowPurgeEverything), under a box-wide lock:
- Delete WPFC files:
find <root>/wp-content/cache/all -mindepth 1 -deleteas the site user. - Delete this site's nginx entries: cache files store their key on a
KEY:line in the header, sogrep -rlaE '^KEY: https?(GET|HEAD)(host1|host2|…)/'over the zone directory lists exactly this site's files, which are then removed. Hosts are the site'sfqdn,canonical_fqdn, every row inSite::domains, and the www/bare twin of each. - Build the URL list (
SiteUrlCollector): the sitemap is read from the origin (WP_HOSTER_CACHE_ORIGIN_URL, defaulthttps://127.0.0.1, with the site'sHostheader, TLS unverified, redirects not followed). It tries/sitemap_index.xml(Yoast, Rank Math),/sitemap.xml, then/wp-sitemap.xml(core); a sitemap index is expanded up tositemap_max_childrenchild sitemaps. Without a sitemap it falls back to/wp-json/wp/v2/pagesand/posts. URLs with a query string or on a foreign hostname are dropped. The result is the home page first, then URLs by path depth (top-level pages before deep posts), capped atedge_max_urls(500). - Warm the origin (
SiteCacheWarmer): the home page plus the firstwarm_urls(20) URLs are requested one at a time against the origin with the site'sHostheader, no cookies, no query string, 20 s per URL, at mostwarm_budget_seconds(120) in total. Each request renders once through PHP and lands in both WPFC and fastcgi_cache under the same keys public traffic uses. The edge still serves its old copy meanwhile, so these are the only requests reaching the cold origin. - Purge the edge by URL: the home page on every zone hostname of the site plus the URL list, in batches of 30 (the Free-plan limit per call), via
CloudflareCacheManager::purgeUrls(). Hostnames outsideWP_HOSTER_BASE_DOMAINare left out, because one foreign URL makes Cloudflare reject the whole call. The log lineSiteCacheManager: edge purged by URLcarries the counts.
If no listing could be built at all (url_source = none), only the home pages are purged at the edge and a warning is logged; everything else expires with the edge TTL. Pass allowPurgeEverything = true (CLI: --purge-everything) to purge the whole zone in that case instead. wphoster:purge-cache without --site still purges the whole zone once at the end, on purpose: it is a box-wide operation.
Serialisation
The panel button, the WP admin-bar button (POST /api/agent/purge-cache) and the CLI all end in the same code, and the client button will be clicked repeatedly.
PurgeSiteCacheJobisShouldBeUniqueper site (uniqueFor600 s): a second click while a purge is queued or running is dropped, not stacked.- It carries
WithoutOverlapping('site-cache-purge'): purges of different sites run one at a time. A waiting job is released back to the queue every 15 s, which is why the job has$tries = 0and uses$maxExceptionsas its failure limit. SiteCacheManageritself takes the cache lockwphoster:cache-purge(waits up tolock_wait_seconds, then gives up withedge.mode = busy), so a CLI run cannot overlap a queued job either.
A purge can take a couple of minutes (sitemap, up to 21 renders, a few API calls). The job timeout is 600 s; the queue connection's retry_after must be higher than that or a second worker would start the same job again.
nginx: [crit] unlink() … No such file or directory
After a purge, nginx's error log shows one of these per deleted entry. They are harmless. nginx indexes cache entries in shared memory and only touches the file when the entry is requested or expired. We delete the file underneath it; a request in the meantime is simply a MISS, and when the cache manager later expires the entry (inactive=60m) its unlink() fails and is logged at crit, after which the entry is dropped from memory anyway. Nothing is leaked and nothing stale is served.
A "soft" invalidation that avoids the deletes (a per-host cache version in the key, loaded from a map file) was considered and rejected: nginx only re-reads such a file on nginx -s reload, so every purge would reload nginx for all tenants, and the old entries would still sit on disk until inactive evicts them. The open-source build has no purge directive (fastcgi_cache_purge is nginx Plus / a third-party module). Deleting the files is the least invasive option; the warming step is what makes it safe.
Key files
app/Services/Cache/SiteCacheManager.php:purge(),purgeWpFastestCache(),purgeFastcgiCache(),purgeEdgeForSite(),purgeEdge()(whole zone)app/Services/Cache/SiteUrlCollector.php:hosts(),edgeHosts(),siteUrls(),originGet()app/Services/Cache/SiteCacheWarmer.php:warm()app/Services/Dns/CloudflareCacheManager.php:purgeUrls(),purgeEverything(), and the edge cache ruleapp/Jobs/PurgeSiteCacheJob.php,app/Console/Commands/PurgeSiteCache.php- Tests:
tests/Unit/SiteUrlCollectorTest.php,tests/Feature/SiteCachePurgeTest.php
Config
Key (wphoster.cache.*) | Env | Default | Purpose |
|---|---|---|---|
fastcgi_path | WP_HOSTER_FASTCGI_CACHE_PATH | /var/cache/nginx/wpworks | nginx cache zone directory |
origin_url | WP_HOSTER_CACHE_ORIGIN_URL | https://127.0.0.1 | Where the origin is reached directly (Host header selects the site) |
warm_urls | WP_HOSTER_CACHE_WARM_URLS | 20 | Sitemap URLs warmed after the home page; 0 disables warming |
warm_timeout | WP_HOSTER_CACHE_WARM_TIMEOUT | 20 | Seconds per warm request |
warm_budget_seconds | WP_HOSTER_CACHE_WARM_BUDGET | 120 | Total warming time before giving up on the rest |
edge_batch_size | WP_HOSTER_CACHE_EDGE_BATCH_SIZE | 30 | URLs per Cloudflare purge call (Free plan maximum) |
edge_max_urls | WP_HOSTER_CACHE_EDGE_MAX_URLS | 500 | Cap on URLs purged per site |
sitemap_max_children | WP_HOSTER_CACHE_SITEMAP_MAX_CHILDREN | 10 | Child sitemaps read from a sitemap index |
lock_wait_seconds | WP_HOSTER_CACHE_LOCK_WAIT | 120 | How long a purge waits for the box-wide lock |
Checking it worked
grep 'SiteCacheWarmer: origin warmed' storage/logs/laravel.log | tail -1
grep 'edge purged by URL' storage/logs/laravel.log | tail -1
curl -sI https://<site>/ | grep -i -e x-cache -e cf-cache-status # X-Cache: HIT from nginx, cf-cache-status: MISS once, then HIT