What
Membership::publish_pending_site_async() always enqueues a wu_async_publish_pending_site Action Scheduler job even when the loopback fast-path returned HTTP 2xx. Under FPM (where fastcgi_finish_request is available and the loopback is blocking), this creates a guaranteed duplicate-publish race for every checkout. The second AS run is wasted worker time at best, and contends for the is_publishing lock at worst.
Evidence
Observed in production at mygratis.site (Ultimate Multisite 2.5.x line, FrankenPHP 1.x with fastcgi_finish_request available):
- Median end-to-end provision time across recent runs of
wu_async_publish_pending_site: ~50s, with p95 outliers of 928s and 15.5 min (action 82170).
- Concrete duplicate enqueue: actions 82212 (ran 56s) and 82215 (ran 87s) were both scheduled with identical args (
{"membership_id":<same>}) within 3 seconds of each other for a single checkout. Both completed; the second was redundant.
- Frontend literal in
assets/js/checkout.js:1654 advertises "up to 60 seconds" — median matches, but the duplicate enqueue inflates p95 and pushes adversely against that promise.
- AS table sample latencies: 16, 26, 40, 45, 56, 56, 87, 123, 202, 404, 928s.
Where
inc/models/class-membership.php:2100 — publish_pending_site_async()
inc/models/class-membership.php:2138-2141 — loopback wp_remote_request()
inc/models/class-membership.php:2143-2155 — error/non-2xx logging branch
inc/models/class-membership.php:2157 — unconditional wu_enqueue_async_action() call (the bug)
How
Make the AS enqueue conditional on the loopback NOT being known-successful. Reference pattern: the same file already distinguishes blocking vs non-blocking at line 2134 (if ( ! function_exists('fastcgi_finish_request'))); apply the same split to the fallback decision.
Proposed shape (illustrative — adjust to repo style):
$loopback_succeeded = false;
if ( function_exists('fastcgi_finish_request') && ! is_wp_error($result) ) {
$code = (int) wp_remote_retrieve_response_code($result);
if ( $code >= 200 && $code < 300 ) {
$loopback_succeeded = true;
}
}
if ( ! $loopback_succeeded ) {
wu_enqueue_async_action('wu_async_publish_pending_site', ['membership_id' => $this->get_id()], 'membership');
}
Notes:
- When
fastcgi_finish_request is unavailable, blocking=false is set at line 2136 and wp_remote_request() returns immediately without a response code, so we cannot know if the loopback target ran — the AS fallback must stay as the safety net there.
publish_pending_site() is already largely idempotent via Site::is_publishing() + Site::is_publishing_stale() (2.5.3+) + the early ! $pending_site bail, so the duplicate is "merely" wasted work rather than corruption — but at scale the AS queue contention is measurable.
Acceptance criteria
- Under FPM with fastcgi_finish_request available, a successful loopback (HTTP 2xx) does NOT enqueue
wu_async_publish_pending_site for the same membership.
- Loopback HTTP non-2xx,
WP_Error, or fastcgi_finish_request-absent path still enqueues the AS fallback.
- Existing PHPUnit suite passes (
vendor/bin/phpunit).
- New test exercising both branches (loopback-2xx → no enqueue, loopback-fail → enqueue) added under
tests/WP_Ultimo/Models/ or equivalent.
- Manual verification on a staging multisite with checkout: observe single AS row per checkout in
wp_actionscheduler_actions filtered by hook='wu_async_publish_pending_site'.
Verification commands
# Test
vendor/bin/phpunit --filter Membership_Test
# Manual confirmation on a checkout
wp db query "SELECT action_id, hook, args, scheduled_date_gmt, status FROM wp_actionscheduler_actions WHERE hook='wu_async_publish_pending_site' ORDER BY action_id DESC LIMIT 10;"
Out of scope
- The 928s / 15.5 min p95 outliers are a separate problem in
Local_Provider::install_sovereign_tenant (multi-tenancy addon) — the wp_remote_get cleanup probe is the suspect. Will file separately.
- Adaptive thank-you poll cadence (
assets/js/thank-you.js) is fine; not changing.
Brief Workflow
This issue body is composed under .agents/workflows/brief.md. Before implementation, ensure it contains files to modify, a reference pattern, verification commands, and concrete acceptance criteria.
aidevops.sh v3.20.4 plugin for OpenCode v1.15.11 with claude-opus-4-7 spent 2h 29m and 99,487 tokens on this with the user in an interactive session.
What
Membership::publish_pending_site_async()always enqueues awu_async_publish_pending_siteAction Scheduler job even when the loopback fast-path returned HTTP 2xx. Under FPM (wherefastcgi_finish_requestis available and the loopback is blocking), this creates a guaranteed duplicate-publish race for every checkout. The second AS run is wasted worker time at best, and contends for theis_publishinglock at worst.Evidence
Observed in production at
mygratis.site(Ultimate Multisite 2.5.x line, FrankenPHP 1.x with fastcgi_finish_request available):wu_async_publish_pending_site: ~50s, with p95 outliers of 928s and 15.5 min (action 82170).{"membership_id":<same>}) within 3 seconds of each other for a single checkout. Both completed; the second was redundant.assets/js/checkout.js:1654advertises "up to 60 seconds" — median matches, but the duplicate enqueue inflates p95 and pushes adversely against that promise.Where
inc/models/class-membership.php:2100—publish_pending_site_async()inc/models/class-membership.php:2138-2141— loopbackwp_remote_request()inc/models/class-membership.php:2143-2155— error/non-2xx logging branchinc/models/class-membership.php:2157— unconditionalwu_enqueue_async_action()call (the bug)How
Make the AS enqueue conditional on the loopback NOT being known-successful. Reference pattern: the same file already distinguishes blocking vs non-blocking at line 2134 (
if ( ! function_exists('fastcgi_finish_request'))); apply the same split to the fallback decision.Proposed shape (illustrative — adjust to repo style):
Notes:
fastcgi_finish_requestis unavailable,blocking=falseis set at line 2136 andwp_remote_request()returns immediately without a response code, so we cannot know if the loopback target ran — the AS fallback must stay as the safety net there.publish_pending_site()is already largely idempotent viaSite::is_publishing()+Site::is_publishing_stale()(2.5.3+) + the early! $pending_sitebail, so the duplicate is "merely" wasted work rather than corruption — but at scale the AS queue contention is measurable.Acceptance criteria
wu_async_publish_pending_sitefor the same membership.WP_Error, or fastcgi_finish_request-absent path still enqueues the AS fallback.vendor/bin/phpunit).tests/WP_Ultimo/Models/or equivalent.wp_actionscheduler_actionsfiltered byhook='wu_async_publish_pending_site'.Verification commands
Out of scope
Local_Provider::install_sovereign_tenant(multi-tenancy addon) — thewp_remote_getcleanup probe is the suspect. Will file separately.assets/js/thank-you.js) is fine; not changing.Brief Workflow
This issue body is composed under
.agents/workflows/brief.md. Before implementation, ensure it contains files to modify, a reference pattern, verification commands, and concrete acceptance criteria.aidevops.sh v3.20.4 plugin for OpenCode v1.15.11 with claude-opus-4-7 spent 2h 29m and 99,487 tokens on this with the user in an interactive session.