Skip to content

t534: fix(membership): skip AS fallback enqueue when loopback publish returned 2xx (duplicate publish race) #1305

@superdav42

Description

@superdav42

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:2100publish_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

  1. Under FPM with fastcgi_finish_request available, a successful loopback (HTTP 2xx) does NOT enqueue wu_async_publish_pending_site for the same membership.
  2. Loopback HTTP non-2xx, WP_Error, or fastcgi_finish_request-absent path still enqueues the AS fallback.
  3. Existing PHPUnit suite passes (vendor/bin/phpunit).
  4. New test exercising both branches (loopback-2xx → no enqueue, loopback-fail → enqueue) added under tests/WP_Ultimo/Models/ or equivalent.
  5. 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.

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingorigin:interactiveCreated by interactive user sessionsolved:interactiveTask was solved by an interactive session

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions