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
36 changes: 35 additions & 1 deletion inc/models/class-membership.php
Original file line number Diff line number Diff line change
Expand Up @@ -2178,7 +2178,41 @@ public function publish_pending_site() {
$is_publishing = $pending_site->is_publishing();

if ($is_publishing) {
return true;
/*
* If is_publishing has been true for longer than the stale
* timeout (default 5 minutes), the PHP process that set the
* flag is presumed dead (OOM, max_execution_time, fatal,
* server restart). The poller resets the flag in that case,
* but if a competing call (e.g. a duplicate checkout order)
* lands here while the flag is still set we would otherwise
* silently bail and the "Creating your site" overlay would
* hang until the next Action Scheduler retry — which often
* never fires because the duplicate caller never reschedules.
*
* Detect the stale flag here and fall through to the publish
* path so the second caller can finish the job the first
* caller never completed. Site::is_publishing_stale() was
* added in 2.5.3; method_exists() keeps this safe for any
* pre-2.5.3 serialized Site objects deserialized from meta.
*
* @since 2.5.4
*/
$is_stale = method_exists($pending_site, 'is_publishing_stale')
? $pending_site->is_publishing_stale()
: false;

if ( ! $is_stale) {
return true;
}

wu_log_add(
"membership-{$this->get_id()}",
sprintf(
// translators: %d: membership ID.
__('Detected stale is_publishing flag on pending site for membership %d during publish_pending_site(); the previous publish attempt appears to have been killed before completing. Proceeding with retry.', 'ultimate-multisite'),
$this->get_id()
)
);
}

$pending_site->set_publishing(true);
Expand Down
164 changes: 164 additions & 0 deletions tests/WP_Ultimo/Models/Membership_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,17 +58,17 @@
// Create product directly.
$this->product = new Product(
[
'name' => 'Test Plan',

Check warning on line 61 in tests/WP_Ultimo/Models/Membership_Test.php

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Array double arrow not aligned correctly; expected 10 space(s) between "'name'" and double arrow, but found 9.
'slug' => 'test-plan-' . wp_generate_password(6, false),

Check warning on line 62 in tests/WP_Ultimo/Models/Membership_Test.php

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Array double arrow not aligned correctly; expected 10 space(s) between "'slug'" and double arrow, but found 9.
'description' => 'A test plan',

Check warning on line 63 in tests/WP_Ultimo/Models/Membership_Test.php

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Array double arrow not aligned correctly; expected 3 space(s) between "'description'" and double arrow, but found 2.
'pricing_type' => 'paid',

Check warning on line 64 in tests/WP_Ultimo/Models/Membership_Test.php

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Array double arrow not aligned correctly; expected 2 space(s) between "'pricing_type'" and double arrow, but found 1.
'amount' => 29.99,

Check warning on line 65 in tests/WP_Ultimo/Models/Membership_Test.php

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Array double arrow not aligned correctly; expected 8 space(s) between "'amount'" and double arrow, but found 7.
'currency' => 'USD',

Check warning on line 66 in tests/WP_Ultimo/Models/Membership_Test.php

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Array double arrow not aligned correctly; expected 6 space(s) between "'currency'" and double arrow, but found 5.
'duration' => 1,

Check warning on line 67 in tests/WP_Ultimo/Models/Membership_Test.php

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Array double arrow not aligned correctly; expected 6 space(s) between "'duration'" and double arrow, but found 5.
'duration_unit' => 'month',
'type' => 'plan',

Check warning on line 69 in tests/WP_Ultimo/Models/Membership_Test.php

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Array double arrow not aligned correctly; expected 10 space(s) between "'type'" and double arrow, but found 9.
'recurring' => true,

Check warning on line 70 in tests/WP_Ultimo/Models/Membership_Test.php

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Array double arrow not aligned correctly; expected 5 space(s) between "'recurring'" and double arrow, but found 4.
'active' => true,

Check warning on line 71 in tests/WP_Ultimo/Models/Membership_Test.php

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Array double arrow not aligned correctly; expected 8 space(s) between "'active'" and double arrow, but found 7.
]
);
$this->product->set_skip_validation(true);
Expand Down Expand Up @@ -1761,4 +1761,168 @@
$this->assertInstanceOf(Membership::class, $captured);
$this->assertSame($m->get_id(), $captured->get_id());
}

// ---------------------------------------------------------------
// publish_pending_site() — stale is_publishing flag handling
//
// Regression coverage for the "Creating your site" overlay hang
// caused by a duplicate caller bailing on a stuck is_publishing
// flag. See inc/models/class-membership.php::publish_pending_site().
// ---------------------------------------------------------------

/**
* Build a persisted membership via the wu_create_* helpers (which
* write through BerlinDB and return a model with a real ID) so that
* update_meta/get_meta operate against a real DB row.
*
* The class-level $this->membership built in setUp() uses raw Model
* constructors with skip_validation; in some environments the save()
* path leaves the in-memory ID at 0, which makes meta calls silently
* no-op (is_meta_available() returns false). Using the helper
* guarantees a real ID.
*
* @return Membership
*/
private function make_membership_with_pending_meta(): Membership {
$user_id = self::factory()->user->create(
[
'user_email' => 'pending_' . wp_generate_password(6, false) . '@example.com',
]
);

$customer = wu_create_customer(
[
'user_id' => $user_id,
'skip_validation' => true,
]
);

$product = wu_create_product(
[
'name' => 'Plan ' . wp_generate_password(6, false),
'slug' => 'plan-pending-' . wp_generate_password(6, false),
'type' => 'plan',
'amount' => 9.99,
'currency' => 'USD',
'duration' => 1,
'duration_unit' => 'month',
'recurring' => true,
'skip_validation' => true,
]
);

$membership = wu_create_membership(
[
'customer_id' => $customer->get_id(),
'plan_id' => $product->get_id(),
'status' => 'active',
'amount' => 9.99,
'currency' => 'USD',
'skip_validation' => true,
]
);

$this->assertNotWPError($membership, 'Helper failed to build a persisted membership for the pending-site fixture.');
$this->assertGreaterThan(0, $membership->get_id(), 'Helper produced a membership without an ID — meta will silently no-op.');

return $membership;
}

/**
* Attach a pending site to the given membership and stamp the
* is_publishing flag and timestamp to simulate a stuck publish.
*
* @param Membership $membership Persisted membership.
* @param bool $is_publishing Initial publishing flag value.
* @param int $started_seconds_ago Seconds ago the publish was started.
* @return Site
*/
private function attach_pending_site_with_publishing(Membership $membership, bool $is_publishing, int $started_seconds_ago = 0): Site {
$slug = 'pending' . substr((string) (microtime(true) * 1000), -8);

$pending = $membership->create_pending_site(
[
'title' => 'Pending ' . $slug,
'path' => '/' . $slug . '/',
'is_publishing' => false,
]
);

if ($is_publishing) {
$pending->set_publishing(true);

$reflection = new \ReflectionObject($pending);
$prop = $reflection->getProperty('publishing_started_at');
$prop->setAccessible(true);
$prop->setValue($pending, time() - $started_seconds_ago);
}

$membership->update_pending_site($pending);

return $pending;
}

/**
* Fresh is_publishing flag (set seconds ago, not stale) must cause
* publish_pending_site() to bail without touching the pending site.
*
* Guards against accidentally regressing to "always publish" if the
* stale-check is ever rewritten.
*/
public function test_publish_pending_site_bails_when_flag_is_fresh(): void {
$membership = $this->make_membership_with_pending_meta();
$pending = $this->attach_pending_site_with_publishing($membership, true, 60);

$path = $pending->get_path();

// Pre-condition: the pending site fixture is actually persisted.
$this->assertNotFalse($membership->get_pending_site(), 'Pending site meta should round-trip from BerlinDB.');

$result = $membership->publish_pending_site();

$this->assertTrue($result, 'publish_pending_site() should return true to short-circuit duplicate callers.');

$after = $membership->get_pending_site();
$this->assertNotFalse($after, 'Pending site should still exist after fresh-flag short-circuit.');
$this->assertTrue((bool) $after->is_publishing(), 'is_publishing should remain true on a fresh-flag short-circuit.');

global $wpdb;
$blog_count = (int) $wpdb->get_var(
$wpdb->prepare("SELECT COUNT(*) FROM {$wpdb->blogs} WHERE path = %s", $path)
);
$this->assertSame(0, $blog_count, 'No blog should be created when the flag is fresh.');
}

/**
* Stale is_publishing flag (started more than 5 minutes ago) must
* cause publish_pending_site() to fall through and complete the
* publish on behalf of the dead caller — the previous behaviour
* was to silently bail, leaving the "Creating your site" overlay
* stuck forever.
*
* This is the core regression test for the BUG 2 fix.
*/
public function test_publish_pending_site_proceeds_when_flag_is_stale(): void {
$membership = $this->make_membership_with_pending_meta();
$pending = $this->attach_pending_site_with_publishing($membership, true, 360);

// Sanity check: the fixture is in the expected stale state.
$this->assertTrue($pending->is_publishing_stale(), 'Pre-condition: pending site must be stale (>300s).');
$this->assertNotFalse($membership->get_pending_site(), 'Pre-condition: pending site meta should round-trip.');

$path = $pending->get_path();

$result = $membership->publish_pending_site();

$this->assertTrue($result, 'publish_pending_site() should return true after successful stale recovery.');

$after = $membership->get_pending_site();
$this->assertFalse($after, 'Pending site should have been deleted after successful publish.');

global $wpdb;
$blog_count = (int) $wpdb->get_var(
$wpdb->prepare("SELECT COUNT(*) FROM {$wpdb->blogs} WHERE path = %s", $path)
);
$this->assertSame(1, $blog_count, 'The pending site should have been published to a real blog.');
}
}
Loading