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
54 changes: 51 additions & 3 deletions inc/models/class-membership.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ class Membership extends Base_Model implements Limitable, Billable, Notable {
*/
const META_PENDING_SITE = 'pending_site';

/**
* Delay before the pending-site watchdog fallback runs.
*
* @since 2.5.x
*/
const PENDING_SITE_PUBLISH_WATCHDOG_DELAY = 5 * MINUTE_IN_SECONDS;

/**
* ID of the customer attached to this membership.
*
Expand Down Expand Up @@ -2118,13 +2125,16 @@ public function publish_pending_site_async(): void {
* @param bool $use_loopback Whether to trigger the loopback request.
* @param Membership $membership The membership publishing its pending site.
*/
$use_loopback = (bool) apply_filters('wu_publish_pending_site_use_loopback', true, $this);
$use_loopback = (bool) apply_filters('wu_publish_pending_site_use_loopback', true, $this);
$can_finish_request = function_exists('litespeed_finish_request')
|| function_exists('fastcgi_finish_request');
$can_finish_request = (bool) apply_filters('wu_publish_pending_site_can_finish_request', $can_finish_request, $this);
$loopback_started = false;
$loopback_started = false;
$args = ['membership_id' => $this->get_id()];

if ($use_loopback) {
$this->schedule_pending_site_async_watchdog($args);

// We first try to generate the site through request to start earlier as possible.
// Generate a short-lived HMAC token for the loopback request.
$expires = time() + 60;
Expand Down Expand Up @@ -2174,13 +2184,49 @@ public function publish_pending_site_async(): void {
}
}

wu_enqueue_async_action('wu_async_publish_pending_site', ['membership_id' => $this->get_id()], 'membership');
if ($loopback_started && $can_finish_request) {
return;
}

wu_enqueue_async_action('wu_async_publish_pending_site', $args, 'membership');

if ( ! $loopback_started) {
$this->dispatch_pending_site_async_queue();
}
}

/**
* Schedules a delayed Action Scheduler watchdog for a loopback publish attempt.
*
* A blocking 2xx loopback only proves the HMAC-gated AJAX handler started and
* flushed its response; the long-running site publish can still be killed after
* that response. Keep a delayed fallback so the site is still published without
* creating the immediate duplicate AS race fixed by GH#1305.
*
* @since 2.5.x
*
* @param array $args Action Scheduler arguments.
* @return void
*/
protected function schedule_pending_site_async_watchdog($args) {

if (false !== wu_next_scheduled_action('wu_async_publish_pending_site', $args, 'membership')) {
return;
}

/**
* Filters the delayed fallback window for pending-site loopback publishing.
*
* @since 2.5.x
*
* @param int $delay Delay in seconds before the watchdog fallback runs.
* @param Membership $membership The membership publishing its pending site.
*/
$delay = (int) apply_filters('wu_publish_pending_site_watchdog_delay', self::PENDING_SITE_PUBLISH_WATCHDOG_DELAY, $this);

wu_schedule_single_action(time() + max(1, $delay), 'wu_async_publish_pending_site', $args, 'membership');
}

/**
* Dispatches the Action Scheduler async runner immediately.
*
Expand Down Expand Up @@ -2338,6 +2384,8 @@ public function publish_pending_site() {

$profile_stage = microtime(true);
$this->delete_pending_site();
wu_unschedule_action('wu_async_publish_pending_site', ['membership_id' => $this->get_id()], 'membership');

if (is_numeric($saved) && class_exists('\Ultimate_Multisite_Multi_Tenancy\Providers\Local_Provider')) {
\Ultimate_Multisite_Multi_Tenancy\Providers\Local_Provider::profile_stage(
(int) $saved,
Expand Down
125 changes: 94 additions & 31 deletions tests/WP_Ultimo/Managers/Membership_Manager_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -1260,13 +1260,15 @@ public function test_publish_pending_site_async_generates_token(): void {

// Mock wp_remote_request to capture the URL.
$captured_url = null;
$http_filter = function ($preempt, $r, $url) use (&$captured_url) {
$captured_url = $url;
// Return a successful response to prevent actual HTTP request.
return ['response' => ['code' => 200]];
};

add_filter(
'pre_http_request',
function ($preempt, $r, $url) use (&$captured_url) {
$captured_url = $url;
// Return a successful response to prevent actual HTTP request.
return ['response' => ['code' => 200]];
},
$http_filter,
10,
3
);
Expand Down Expand Up @@ -1298,7 +1300,54 @@ function ($preempt, $r, $url) use (&$captured_url) {
);

remove_filter('wu_publish_pending_site_can_finish_request', '__return_true');
remove_filter('pre_http_request', 10);
remove_filter('pre_http_request', $http_filter, 10);
}

/**
* Test successful blocking loopback gets a delayed watchdog fallback only.
*
* A blocking HTTP 2xx means the loopback publish handler started, not that the
* long-running publish completed. Schedule a delayed watchdog to preserve the
* retry path, but do not enqueue an immediate duplicate Action Scheduler job.
*/
public function test_publish_pending_site_async_schedules_delayed_watchdog_after_successful_loopback(): void {

$membership = $this->create_membership();

$pending_site = $membership->create_pending_site([
'title' => 'Test Site',
'domain' => 'test-' . wp_rand() . '.example.com',
]);

$membership->update_pending_site($pending_site);

$http_filter = function ($preempt, $r, $url) {
unset($preempt, $r, $url);

return ['response' => ['code' => 200]];
};

$delay_filter = function () {
return 120;
};

add_filter('pre_http_request', $http_filter, 10, 3);
add_filter('wu_publish_pending_site_can_finish_request', '__return_true');
add_filter('wu_publish_pending_site_watchdog_delay', $delay_filter);

$membership->publish_pending_site_async();

$scheduled_at = wu_next_scheduled_action('wu_async_publish_pending_site', ['membership_id' => $membership->get_id()], 'membership');

$this->assertIsInt(
$scheduled_at,
'Successful blocking loopback should schedule a delayed watchdog instead of an immediate async action.'
);
$this->assertGreaterThan(time() + 60, $scheduled_at, 'Watchdog fallback should be delayed to avoid an immediate duplicate publish race.');

remove_filter('wu_publish_pending_site_watchdog_delay', $delay_filter);
remove_filter('wu_publish_pending_site_can_finish_request', '__return_true');
remove_filter('pre_http_request', $http_filter, 10);
}

/**
Expand All @@ -1321,15 +1370,17 @@ public function test_publish_pending_site_async_can_skip_loopback_via_filter():
$membership->update_pending_site($pending_site);

$captured_url = null;
$http_filter = function ($preempt, $r, $url) use (&$captured_url) {
if (strpos($url, 'wu_publish_pending_site') !== false) {
$captured_url = $url;
}

return ['response' => ['code' => 200]];
};

add_filter(
'pre_http_request',
function ($preempt, $r, $url) use (&$captured_url) {
if (strpos($url, 'wu_publish_pending_site') !== false) {
$captured_url = $url;
}

return ['response' => ['code' => 200]];
},
$http_filter,
10,
3
);
Expand All @@ -1339,9 +1390,13 @@ function ($preempt, $r, $url) use (&$captured_url) {
$membership->publish_pending_site_async();

$this->assertNull($captured_url, 'Loopback HTTP request should not fire when the filter returns false.');
$this->assertTrue(
as_has_scheduled_action('wu_async_publish_pending_site', ['membership_id' => $membership->get_id()], 'membership'),
'Action Scheduler fallback should be queued when loopback is disabled.'
);

remove_filter('wu_publish_pending_site_use_loopback', '__return_false');
remove_filter('pre_http_request', 10);
remove_filter('pre_http_request', $http_filter, 10);
}

/**
Expand All @@ -1363,15 +1418,17 @@ public function test_publish_pending_site_async_uses_non_blocking_loopback_witho
$membership->update_pending_site($pending_site);

$captured_args = null;
$http_filter = function ($preempt, $r, $url) use (&$captured_args) {
if (strpos($url, 'wu_publish_pending_site') !== false) {
$captured_args = $r;
}

return ['response' => ['code' => false]];
};

add_filter(
'pre_http_request',
function ($preempt, $r, $url) use (&$captured_args) {
if (strpos($url, 'wu_publish_pending_site') !== false) {
$captured_args = $r;
}

return ['response' => ['code' => false]];
},
$http_filter,
10,
3
);
Expand All @@ -1389,7 +1446,7 @@ function ($preempt, $r, $url) use (&$captured_args) {
);

remove_filter('wu_publish_pending_site_can_finish_request', '__return_false');
remove_filter('pre_http_request', 10);
remove_filter('pre_http_request', $http_filter, 10);
}

/**
Expand All @@ -1411,16 +1468,22 @@ public function test_publish_pending_site_async_enqueues_fallback_on_http_errors
$membership->update_pending_site($pending_site);

// Mock wp_remote_request to return a 400 error.
$http_filter = function ($preempt, $r, $url) {
unset($preempt, $r, $url);

return [
'response' => [
'code' => 400,
'message' => 'Bad Request',
],
];
};

add_filter(
'pre_http_request',
function () {
return [
'response' => [
'code' => 400,
'message' => 'Bad Request',
],
];
}
$http_filter,
10,
3
);

add_filter('wu_publish_pending_site_can_finish_request', '__return_true');
Expand All @@ -1434,6 +1497,6 @@ function () {
);

remove_filter('wu_publish_pending_site_can_finish_request', '__return_true');
remove_filter('pre_http_request', 10);
remove_filter('pre_http_request', $http_filter, 10);
}
}
Loading