From c70cf453d99f695c5edcc7ef05e2039822e65be3 Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 21 Oct 2026 00:27:27 -0600 Subject: [PATCH 1/6] fix: GH#2009 create and upgrade Ultimate Multisite tables on secondary networks --- inc/database/engine/class-table.php | 103 +++++++++++++++++- .../WP_Ultimo/Database/Engine/Table_Test.php | 40 +++++++ 2 files changed, 139 insertions(+), 4 deletions(-) diff --git a/inc/database/engine/class-table.php b/inc/database/engine/class-table.php index d11404137..4e3071d5e 100644 --- a/inc/database/engine/class-table.php +++ b/inc/database/engine/class-table.php @@ -37,6 +37,18 @@ abstract class Table extends \BerlinDB\Database\Table { */ private bool $exists; + /** + * Network option holding the installed schema version of each of this + * network's tables, keyed by prefixed table name. + * + * One option per network, so checking every table costs a single + * (cached) option read per request. + * + * @since 2.18.0 + * @var string + */ + const NETWORK_TABLE_VERSIONS_OPTION = 'wu_network_table_versions'; + /** * Overrides the is_upgradeable method. * @@ -44,15 +56,14 @@ abstract class Table extends \BerlinDB\Database\Table { * early in the lifecycle, which means that upgrade.php is not * available. * + * Every network keeps its own tables (wu_{network_id}_* on secondary + * networks), so the main site of any network may install and upgrade them. + * * @since 2.0.0 * @return boolean */ public function is_upgradeable(): bool { - if ( ! is_main_network()) { - return false; - } - return is_main_site(); } @@ -71,10 +82,28 @@ public function exists(): bool { return $this->exists; } + /** + * Install the table, then drop the cached exists() result. + * + * A table checked before installation would otherwise keep reporting + * that it does not exist for the rest of the request. + * + * @since 2.18.0 + * @return void + */ + public function install() { + + parent::install(); + + unset($this->exists); + } + /** * Change the prefix if needed. */ public function __construct() { + $base_prefix = (string) $this->prefix; + $this->update_prefix_with_network_id(); parent::__construct(); @@ -83,6 +112,72 @@ public function __construct() { } $this->register_network_meta_table(); + + if ($base_prefix !== $this->prefix) { + $this->maybe_install_network_table(); + } + } + + /** + * Create or upgrade this secondary network's copy of the table. + * + * BerlinDB only installs and upgrades on admin_init, and only for the + * main site, so a new network's wu_{network_id}_* tables would not exist + * when its first (often front-end) request queries them. Run the same + * install/upgrade on the first request instead, once per schema version. + * + * The per-table versions BerlinDB records (on the main network) remain + * the source of truth; this network option only avoids re-checking them + * on every request. A MySQL named lock keeps concurrent first requests + * from creating or altering the same table twice. + * + * @since 2.18.0 + * @return void + */ + protected function maybe_install_network_table(): void { + + if (wp_installing() || empty($this->prefixed_name) || empty($this->table_name)) { + return; + } + + $network_id = get_current_network_id(); + $versions = get_network_option($network_id, self::NETWORK_TABLE_VERSIONS_OPTION, []); + $versions = is_array($versions) ? $versions : []; + + if (isset($versions[ $this->prefixed_name ]) && (string) $this->version === (string) $versions[ $this->prefixed_name ]) { + return; + } + + $db = $this->get_db(); + $lock = 'wu_tables_' . $network_id; + + // Wait briefly for a concurrent request that is installing this network's tables. + if ('1' !== (string) $db->get_var($db->prepare('SELECT GET_LOCK(%s, 10)', $lock))) { + return; + } + + try { + if ($this->needs_upgrade()) { + if (parent::exists()) { + $this->upgrade(); + } else { + $this->install(); + } + } + + if ($this->needs_upgrade() || ! parent::exists()) { + return; + } + + $versions = get_network_option($network_id, self::NETWORK_TABLE_VERSIONS_OPTION, []); + $versions = is_array($versions) ? $versions : []; + + $versions[ $this->prefixed_name ] = (string) $this->version; + + update_network_option($network_id, self::NETWORK_TABLE_VERSIONS_OPTION, $versions); + } finally { + $db->get_var($db->prepare('SELECT RELEASE_LOCK(%s)', $lock)); + } } /** diff --git a/tests/WP_Ultimo/Database/Engine/Table_Test.php b/tests/WP_Ultimo/Database/Engine/Table_Test.php index 1367d7a2c..95b77d0e4 100644 --- a/tests/WP_Ultimo/Database/Engine/Table_Test.php +++ b/tests/WP_Ultimo/Database/Engine/Table_Test.php @@ -182,4 +182,44 @@ public function test_secondary_network_model_meta_round_trips(): void { $wpdb->wu_customermeta = $canonical; } } + + /** + * GH#2009: a secondary network's tables are created on the first request + * (not only by the main network's admin_init), once per schema version. + */ + public function test_secondary_network_table_is_created_on_first_request(): void { + global $wpdb; + + $network_id = get_current_network_id(); + $main = static fn() => $network_id + 1000; + $table_name = $wpdb->base_prefix . 'wu_' . $network_id . '_webhooks'; + $key = 'wu_' . $network_id . '_webhooks'; + + // Create real tables: the test case rewrites CREATE/DROP TABLE to TEMPORARY, which SHOW TABLES cannot see. + remove_filter('query', [$this, '_create_temporary_tables']); + remove_filter('query', [$this, '_drop_temporary_tables']); + + add_filter('get_main_network_id', $main); + + try { + $wpdb->query("DROP TABLE IF EXISTS {$table_name}"); // phpcs:ignore WordPress.DB + delete_network_option($network_id + 1000, "wpdb_{$key}_version"); + delete_network_option($network_id, Table::NETWORK_TABLE_VERSIONS_OPTION); + + $table = new \WP_Ultimo\Database\Webhooks\Webhooks_Table(); + + $this->assertTrue($table->is_upgradeable()); + $this->assertSame($table_name, $table->table_name); + $this->assertTrue($table->exists()); + + $versions = get_network_option($network_id, Table::NETWORK_TABLE_VERSIONS_OPTION); + + $this->assertSame($this->get_table_property($table, 'version'), $versions[ $key ] ?? null); + } finally { + remove_filter('get_main_network_id', $main); + $wpdb->query("DROP TABLE IF EXISTS {$table_name}"); // phpcs:ignore WordPress.DB + delete_network_option($network_id + 1000, "wpdb_{$key}_version"); + delete_network_option($network_id, Table::NETWORK_TABLE_VERSIONS_OPTION); + } + } } From 272691af0fbc3de423330f543892480bc097eff4 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 22 Oct 2026 10:30:47 -0600 Subject: [PATCH 2/6] fix: finish secondary network table provisioning --- inc/database/engine/class-table.php | 7 ++- inc/managers/class-email-manager.php | 37 ++++++++++++++++ .../WP_Ultimo/Database/Engine/Table_Test.php | 43 +++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/inc/database/engine/class-table.php b/inc/database/engine/class-table.php index 4e3071d5e..b68cb0a2e 100644 --- a/inc/database/engine/class-table.php +++ b/inc/database/engine/class-table.php @@ -132,6 +132,7 @@ public function __construct() { * from creating or altering the same table twice. * * @since 2.18.0 + * @throws \RuntimeException When the table setup lock cannot be acquired. * @return void */ protected function maybe_install_network_table(): void { @@ -153,7 +154,11 @@ protected function maybe_install_network_table(): void { // Wait briefly for a concurrent request that is installing this network's tables. if ('1' !== (string) $db->get_var($db->prepare('SELECT GET_LOCK(%s, 10)', $lock))) { - return; + if ( ! $this->needs_upgrade() && parent::exists()) { + return; + } + + throw new \RuntimeException('Unable to acquire the Ultimate Multisite table setup lock.'); } try { diff --git a/inc/managers/class-email-manager.php b/inc/managers/class-email-manager.php index dc0dc2747..d017e7deb 100644 --- a/inc/managers/class-email-manager.php +++ b/inc/managers/class-email-manager.php @@ -79,6 +79,14 @@ class Email_Manager extends Base_Manager { */ const SCHEDULED_ARGS_MAX_LENGTH = 8000; + /** + * Network option marking default system emails as created. + * + * @since 2.18.0 + * @var string + */ + const SYSTEM_EMAILS_CREATED_OPTION = 'wu_system_emails_created'; + /** * Instantiate the necessary hooks. * @@ -109,6 +117,35 @@ public function init(): void { add_action('wp_mail_failed', [$this, 'log_mailer_failure']); add_action('wp_ajax_wu_get_event_payload_placeholders', [$this, 'get_event_placeholders']); + + add_action('wp_ultimo_load', [$this, 'maybe_create_secondary_network_system_emails']); + } + + /** + * Creates the default system emails after a secondary network's posts table exists. + * + * A secondary network can finish setup after its tables have been created outside + * the regular setup wizard. Creating the defaults here keeps its system emails + * available without repeating the idempotent creation check on every request. + * + * @since 2.18.0 + * @return void + */ + public function maybe_create_secondary_network_system_emails(): void { + + if (is_main_network() || get_network_option(null, self::SYSTEM_EMAILS_CREATED_OPTION, false)) { + return; + } + + $post_table = \WP_Ultimo\Loaders\Table_Loader::get_instance()->post_table; + + if ( ! $post_table->exists()) { + return; + } + + $this->create_all_system_emails(); + + update_network_option(null, self::SYSTEM_EMAILS_CREATED_OPTION, true); } /** diff --git a/tests/WP_Ultimo/Database/Engine/Table_Test.php b/tests/WP_Ultimo/Database/Engine/Table_Test.php index 95b77d0e4..5458e08ea 100644 --- a/tests/WP_Ultimo/Database/Engine/Table_Test.php +++ b/tests/WP_Ultimo/Database/Engine/Table_Test.php @@ -222,4 +222,47 @@ public function test_secondary_network_table_is_created_on_first_request(): void delete_network_option($network_id, Table::NETWORK_TABLE_VERSIONS_OPTION); } } + + /** + * A secondary table with an earlier recorded version is upgraded on its + * first request instead of only creating completely missing tables. + */ + public function test_secondary_network_table_is_upgraded_on_first_request(): void { + global $wpdb; + + $network_id = get_current_network_id(); + $main = static fn() => $network_id + 1000; + $table_name = $wpdb->base_prefix . 'wu_' . $network_id . '_webhooks'; + $key = 'wu_' . $network_id . '_webhooks'; + + remove_filter('query', [$this, '_create_temporary_tables']); + remove_filter('query', [$this, '_drop_temporary_tables']); + add_filter('get_main_network_id', $main); + + try { + $wpdb->query("DROP TABLE IF EXISTS {$table_name}"); // phpcs:ignore WordPress.DB + delete_network_option($network_id + 1000, "wpdb_{$key}_version"); + delete_network_option($network_id, Table::NETWORK_TABLE_VERSIONS_OPTION); + + new \WP_Ultimo\Database\Webhooks\Webhooks_Table(); + + $wpdb->query("ALTER TABLE {$table_name} MODIFY COLUMN `date_created` datetime NOT NULL"); // phpcs:ignore WordPress.DB + $wpdb->query("ALTER TABLE {$table_name} MODIFY COLUMN `date_modified` datetime NOT NULL"); // phpcs:ignore WordPress.DB + update_network_option($network_id + 1000, "wpdb_{$key}_version", '2.0.0'); + delete_network_option($network_id, Table::NETWORK_TABLE_VERSIONS_OPTION); + + new \WP_Ultimo\Database\Webhooks\Webhooks_Table(); + + $columns = $wpdb->get_results("SHOW COLUMNS FROM {$table_name}", ARRAY_A); // phpcs:ignore WordPress.DB + $nullability = wp_list_pluck($columns, 'Null', 'Field'); + + $this->assertSame('YES', $nullability['date_created'] ?? null); + $this->assertSame('YES', $nullability['date_modified'] ?? null); + } finally { + remove_filter('get_main_network_id', $main); + $wpdb->query("DROP TABLE IF EXISTS {$table_name}"); // phpcs:ignore WordPress.DB + delete_network_option($network_id + 1000, "wpdb_{$key}_version"); + delete_network_option($network_id, Table::NETWORK_TABLE_VERSIONS_OPTION); + } + } } From 52ec9cbfd326ea85b24c548ca0512c9a1952d6de Mon Sep 17 00:00:00 2001 From: David Stone Date: Sun, 25 Oct 2026 08:10:08 -0600 Subject: [PATCH 3/6] fix: keep secondary-network table setup off the request path when it cannot finish - Skip setup instead of waiting 10s when another request holds the lock; never throw from a Table constructor. - Retry a failed install/upgrade at most hourly instead of on every request. - Return no rows when a network's table is not registered (sunrise on networks where setup never finished), instead of querying an empty table name. --- inc/database/engine/class-query.php | 22 ++++++++++++++++ inc/database/engine/class-table.php | 23 +++++++++------- .../WP_Ultimo/Database/Engine/Query_Test.php | 26 +++++++++++++++++++ 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/inc/database/engine/class-query.php b/inc/database/engine/class-query.php index dab6f1cfe..aaec5708b 100644 --- a/inc/database/engine/class-query.php +++ b/inc/database/engine/class-query.php @@ -105,6 +105,28 @@ public function __construct($query = []) { parent::__construct($query); } + /** + * Return no items when this network's table is not registered. + * + * Sunrise domain mapping runs on every network, including networks where + * Ultimate Multisite stops before setting up its tables. Querying then + * would build SQL with an empty table name (`FROM wu_5_d`). + * + * @since 2.18.0 + * @param string|array $query Query parameters. + * @return array|int + */ + public function query($query = []) { + + $db = $this->get_db(); + + if ($db && empty($db->{$this->table_name})) { + return empty(wp_parse_args($query)['count']) ? [] : 0; + } + + return parent::query($query); + } + /** * Get the plural name. * diff --git a/inc/database/engine/class-table.php b/inc/database/engine/class-table.php index b68cb0a2e..5409ccdcf 100644 --- a/inc/database/engine/class-table.php +++ b/inc/database/engine/class-table.php @@ -129,10 +129,11 @@ public function __construct() { * The per-table versions BerlinDB records (on the main network) remain * the source of truth; this network option only avoids re-checking them * on every request. A MySQL named lock keeps concurrent first requests - * from creating or altering the same table twice. + * from creating or altering the same table twice; a request that finds + * the lock taken skips setup instead of waiting. A failed setup is retried + * at most hourly so a broken table never adds DDL to every request. * * @since 2.18.0 - * @throws \RuntimeException When the table setup lock cannot be acquired. * @return void */ protected function maybe_install_network_table(): void { @@ -149,16 +150,18 @@ protected function maybe_install_network_table(): void { return; } + $retry_key = 'wu_table_setup_failed_' . $this->prefixed_name; + + if (get_site_transient($retry_key)) { + return; + } + $db = $this->get_db(); $lock = 'wu_tables_' . $network_id; - // Wait briefly for a concurrent request that is installing this network's tables. - if ('1' !== (string) $db->get_var($db->prepare('SELECT GET_LOCK(%s, 10)', $lock))) { - if ( ! $this->needs_upgrade() && parent::exists()) { - return; - } - - throw new \RuntimeException('Unable to acquire the Ultimate Multisite table setup lock.'); + // Another request is setting up this network's tables: do not wait for it. + if ('1' !== (string) $db->get_var($db->prepare('SELECT GET_LOCK(%s, 0)', $lock))) { + return; } try { @@ -171,6 +174,8 @@ protected function maybe_install_network_table(): void { } if ($this->needs_upgrade() || ! parent::exists()) { + set_site_transient($retry_key, 1, HOUR_IN_SECONDS); + return; } diff --git a/tests/WP_Ultimo/Database/Engine/Query_Test.php b/tests/WP_Ultimo/Database/Engine/Query_Test.php index 0fe002b4f..51eb7edcb 100644 --- a/tests/WP_Ultimo/Database/Engine/Query_Test.php +++ b/tests/WP_Ultimo/Database/Engine/Query_Test.php @@ -48,4 +48,30 @@ public function test_main_network_uses_default_prefixes(): void { $this->assertSame( 'wu', $query->get_hook_prefix() ); $this->assertSame( 'wu_customer', $query->get_meta_type() ); } + + /** + * GH#2012: a network whose tables were never registered returns no rows + * instead of querying an empty table name (`FROM wu_987_c`). + */ + public function test_unregistered_network_table_returns_no_items(): void { + + global $current_site, $wpdb; + + $original = $current_site; + $fake = clone $current_site; + $fake->id = 987; + $current_site = $fake; + + try { + $query = new Customer_Query(); + + $wpdb->last_error = ''; + + $this->assertSame( [], $query->query( [ 'number' => 1 ] ) ); + $this->assertSame( 0, $query->query( [ 'count' => true ] ) ); + $this->assertSame( '', $wpdb->last_error ); + } finally { + $current_site = $original; + } + } } From 35a62905d74a975db0f39f59aca7dbdd29de0413 Mon Sep 17 00:00:00 2001 From: David Stone Date: Sun, 25 Oct 2026 09:29:30 -0600 Subject: [PATCH 4/6] fix: address review findings on secondary-network setup - Install a missing table even when a schema version is recorded. - Keep the array result for grouped counts on unregistered tables. - Create secondary-network system emails on late init, under a non-blocking lock, and mark them done only when every default exists. --- inc/database/engine/class-query.php | 5 +++- inc/database/engine/class-table.php | 11 ++++---- inc/managers/class-email-manager.php | 27 ++++++++++++++++--- .../WP_Ultimo/Database/Engine/Query_Test.php | 8 ++++-- 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/inc/database/engine/class-query.php b/inc/database/engine/class-query.php index aaec5708b..a597868f6 100644 --- a/inc/database/engine/class-query.php +++ b/inc/database/engine/class-query.php @@ -121,7 +121,10 @@ public function query($query = []) { $db = $this->get_db(); if ($db && empty($db->{$this->table_name})) { - return empty(wp_parse_args($query)['count']) ? [] : 0; + $args = wp_parse_args($query); + + // Grouped counts return an array, like BerlinDB. + return empty($args['count']) || ! empty($args['groupby']) ? [] : 0; } return parent::query($query); diff --git a/inc/database/engine/class-table.php b/inc/database/engine/class-table.php index 5409ccdcf..c672ad2ae 100644 --- a/inc/database/engine/class-table.php +++ b/inc/database/engine/class-table.php @@ -165,12 +165,11 @@ protected function maybe_install_network_table(): void { } try { - if ($this->needs_upgrade()) { - if (parent::exists()) { - $this->upgrade(); - } else { - $this->install(); - } + // A version can be recorded for a table that no longer exists. + if ( ! parent::exists()) { + $this->install(); + } elseif ($this->needs_upgrade()) { + $this->upgrade(); } if ($this->needs_upgrade() || ! parent::exists()) { diff --git a/inc/managers/class-email-manager.php b/inc/managers/class-email-manager.php index d017e7deb..4c3b1ab4d 100644 --- a/inc/managers/class-email-manager.php +++ b/inc/managers/class-email-manager.php @@ -118,7 +118,8 @@ public function init(): void { add_action('wp_ajax_wu_get_event_payload_placeholders', [$this, 'get_event_placeholders']); - add_action('wp_ultimo_load', [$this, 'maybe_create_secondary_network_system_emails']); + // Late init: default emails are translated, so text domains must be loaded. + add_action('init', [$this, 'maybe_create_secondary_network_system_emails'], 99); } /** @@ -143,9 +144,29 @@ public function maybe_create_secondary_network_system_emails(): void { return; } - $this->create_all_system_emails(); + global $wpdb; - update_network_option(null, self::SYSTEM_EMAILS_CREATED_OPTION, true); + // One request creates the defaults; others skip instead of duplicating them. + $lock = 'wu_system_emails_' . get_current_network_id(); + + if ('1' !== (string) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 0)', $lock))) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- named lock, never cached. + return; + } + + try { + // Skips slugs a previous lock holder already created. + $this->create_all_system_emails(); + + foreach (wu_get_default_system_emails() as $email) { + if (empty($email['slug']) || ! $this->is_created($email['slug'])) { + return; // Retry on a later request. + } + } + + update_network_option(null, self::SYSTEM_EMAILS_CREATED_OPTION, true); + } finally { + $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- named lock, never cached. + } } /** diff --git a/tests/WP_Ultimo/Database/Engine/Query_Test.php b/tests/WP_Ultimo/Database/Engine/Query_Test.php index 51eb7edcb..5734ac2e1 100644 --- a/tests/WP_Ultimo/Database/Engine/Query_Test.php +++ b/tests/WP_Ultimo/Database/Engine/Query_Test.php @@ -67,8 +67,12 @@ public function test_unregistered_network_table_returns_no_items(): void { $wpdb->last_error = ''; - $this->assertSame( [], $query->query( [ 'number' => 1 ] ) ); - $this->assertSame( 0, $query->query( [ 'count' => true ] ) ); + $this->assertSame( [], $query->query( ['number' => 1] ) ); + $this->assertSame( 0, $query->query( ['count' => true] ) ); + $this->assertSame( [], $query->query( [ + 'count' => true, + 'groupby' => 'type', + ] ) ); $this->assertSame( '', $wpdb->last_error ); } finally { $current_site = $original; From 6a3f2ddff2c3b0a3d94348942877632afff19c30 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 26 Oct 2026 21:37:31 -0600 Subject: [PATCH 5/6] fix: address PR #2013 review findings - Query: store the unregistered-network fallback result in items, as BerlinDB does. - Email_Manager: retry default system email creation at most hourly after a failed save. - Scope table and system-email named locks to this installation (DB_NAME + base prefix), matching Membership's pending-site publish lock. For #2012 --- inc/database/engine/class-query.php | 6 ++++-- inc/database/engine/class-table.php | 6 ++++-- inc/managers/class-email-manager.php | 13 ++++++++++--- tests/WP_Ultimo/Database/Engine/Query_Test.php | 1 + 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/inc/database/engine/class-query.php b/inc/database/engine/class-query.php index a597868f6..643e385e4 100644 --- a/inc/database/engine/class-query.php +++ b/inc/database/engine/class-query.php @@ -123,8 +123,10 @@ public function query($query = []) { if ($db && empty($db->{$this->table_name})) { $args = wp_parse_args($query); - // Grouped counts return an array, like BerlinDB. - return empty($args['count']) || ! empty($args['groupby']) ? [] : 0; + // Grouped counts return an array, like BerlinDB, which also stores the result in items. + $this->items = empty($args['count']) || ! empty($args['groupby']) ? [] : 0; + + return $this->items; } return parent::query($query); diff --git a/inc/database/engine/class-table.php b/inc/database/engine/class-table.php index c672ad2ae..2060a3396 100644 --- a/inc/database/engine/class-table.php +++ b/inc/database/engine/class-table.php @@ -156,8 +156,10 @@ protected function maybe_install_network_table(): void { return; } - $db = $this->get_db(); - $lock = 'wu_tables_' . $network_id; + $db = $this->get_db(); + + // Named locks are server-wide: scope them to this installation, like Membership's publish lock. + $lock = sprintf('wu_tables_%s_%d', substr(md5(DB_NAME . '|' . $db->base_prefix), 0, 12), $network_id); // Another request is setting up this network's tables: do not wait for it. if ('1' !== (string) $db->get_var($db->prepare('SELECT GET_LOCK(%s, 0)', $lock))) { diff --git a/inc/managers/class-email-manager.php b/inc/managers/class-email-manager.php index 4c3b1ab4d..88b0398d9 100644 --- a/inc/managers/class-email-manager.php +++ b/inc/managers/class-email-manager.php @@ -134,7 +134,10 @@ public function init(): void { */ public function maybe_create_secondary_network_system_emails(): void { - if (is_main_network() || get_network_option(null, self::SYSTEM_EMAILS_CREATED_OPTION, false)) { + // Site transients are per network, so a failure here never delays other networks. + $retry_key = 'wu_system_emails_failed'; + + if (is_main_network() || get_network_option(null, self::SYSTEM_EMAILS_CREATED_OPTION, false) || get_site_transient($retry_key)) { return; } @@ -147,7 +150,8 @@ public function maybe_create_secondary_network_system_emails(): void { global $wpdb; // One request creates the defaults; others skip instead of duplicating them. - $lock = 'wu_system_emails_' . get_current_network_id(); + // Named locks are server-wide: scope them to this installation, like Membership's publish lock. + $lock = sprintf('wu_system_emails_%s_%d', substr(md5(DB_NAME . '|' . $wpdb->base_prefix), 0, 12), get_current_network_id()); if ('1' !== (string) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 0)', $lock))) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- named lock, never cached. return; @@ -159,7 +163,10 @@ public function maybe_create_secondary_network_system_emails(): void { foreach (wu_get_default_system_emails() as $email) { if (empty($email['slug']) || ! $this->is_created($email['slug'])) { - return; // Retry on a later request. + // Retry at most hourly instead of rendering every template on each request. + set_site_transient($retry_key, 1, HOUR_IN_SECONDS); + + return; } } diff --git a/tests/WP_Ultimo/Database/Engine/Query_Test.php b/tests/WP_Ultimo/Database/Engine/Query_Test.php index 5734ac2e1..dd4429ca4 100644 --- a/tests/WP_Ultimo/Database/Engine/Query_Test.php +++ b/tests/WP_Ultimo/Database/Engine/Query_Test.php @@ -69,6 +69,7 @@ public function test_unregistered_network_table_returns_no_items(): void { $this->assertSame( [], $query->query( ['number' => 1] ) ); $this->assertSame( 0, $query->query( ['count' => true] ) ); + $this->assertSame( 0, $query->items ); $this->assertSame( [], $query->query( [ 'count' => true, 'groupby' => 'type', From 58a56a414b2fe6e23c535d3db3dc19bd43dc8d98 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 26 Oct 2026 21:54:17 -0600 Subject: [PATCH 6/6] fix: adopt network-local table versions and keep secondary-network setup query-free GH#2034: an existing secondary table whose version is recorded under its own network (not the main network, where BerlinDB reads it) now has that version copied to the main network before upgrading, so pending upgrades run instead of the table being stamped current. A table with no version anywhere is still stamped current, but logged. No extra per-request work once a network is set up: - Requirements::run_setup() loads wu_setup_finished and the table setup state in one query on secondary networks (wp_prime_network_option_caches). - The system-email marker lives in the same setup state instead of its own option, and the check no longer runs SHOW TABLES for the posts table. - is_upgradeable() is main-network only again, so BerlinDB's admin_init check (one version read per table) does not run on secondary networks' admin requests; maybe_install_network_table() owns their setup. - Table setup and email creation share one installation-scoped lock. Resolves #2034 --- inc/class-requirements.php | 16 ++ inc/database/engine/class-table.php | 140 +++++++++++++++--- inc/managers/class-email-manager.php | 35 +++-- .../WP_Ultimo/Database/Engine/Table_Test.php | 56 ++++++- 4 files changed, 215 insertions(+), 32 deletions(-) diff --git a/inc/class-requirements.php b/inc/class-requirements.php index 7f4ad9962..4b99dd18e 100644 --- a/inc/class-requirements.php +++ b/inc/class-requirements.php @@ -108,6 +108,22 @@ public static function run_setup() { return true; } + /* + * Secondary networks read their table setup state on every request + * (Table::maybe_install_network_table()). Load it in the same query as + * the setup flag, so an up-to-date network costs no extra query. The + * main network never reads it, so it is not primed there. + */ + if (function_exists('wp_prime_network_option_caches') && ! is_main_network()) { + wp_prime_network_option_caches( + null, + [ + \WP_Ultimo::NETWORK_OPTION_SETUP_FINISHED, + \WP_Ultimo\Database\Engine\Table::NETWORK_TABLE_VERSIONS_OPTION, + ] + ); + } + return get_network_option(null, \WP_Ultimo::NETWORK_OPTION_SETUP_FINISHED, false); } diff --git a/inc/database/engine/class-table.php b/inc/database/engine/class-table.php index 2060a3396..c2ab17df9 100644 --- a/inc/database/engine/class-table.php +++ b/inc/database/engine/class-table.php @@ -38,11 +38,13 @@ abstract class Table extends \BerlinDB\Database\Table { private bool $exists; /** - * Network option holding the installed schema version of each of this - * network's tables, keyed by prefixed table name. + * Network option holding a secondary network's setup state: the installed + * schema version of each table (keyed by prefixed table name) plus other + * one-time setup markers (e.g. Email_Manager::SYSTEM_EMAILS_SETUP_KEY). * - * One option per network, so checking every table costs a single - * (cached) option read per request. + * Requirements::run_setup() loads it in the same query as + * wu_setup_finished, so once every table is current, checking them adds + * no query to the request. * * @since 2.18.0 * @var string @@ -56,17 +58,73 @@ abstract class Table extends \BerlinDB\Database\Table { * early in the lifecycle, which means that upgrade.php is not * available. * - * Every network keeps its own tables (wu_{network_id}_* on secondary - * networks), so the main site of any network may install and upgrade them. + * Secondary networks' tables are installed and upgraded by + * maybe_install_network_table() instead, so BerlinDB's admin_init check + * (one version read per table) never runs on their admin requests. * * @since 2.0.0 * @return boolean */ public function is_upgradeable(): bool { + if ( ! is_main_network()) { + return false; + } + return is_main_site(); } + /** + * Name of the MySQL named lock that serializes a secondary network's setup. + * + * Named locks are server-wide, so the name is scoped to this installation + * (database name and table prefix), like Membership's publish lock. + * + * @since 2.18.0 + * @param int $network_id Network ID. + * @return string + */ + final public static function get_network_setup_lock(int $network_id): string { + + global $wpdb; + + return sprintf('wu_setup_%s_%d', substr(md5(DB_NAME . '|' . $wpdb->base_prefix), 0, 12), $network_id); + } + + /** + * Returns a network's setup state (see NETWORK_TABLE_VERSIONS_OPTION). + * + * @since 2.18.0 + * @param int $network_id Network ID. + * @return array + */ + final public static function get_network_setup_state(int $network_id): array { + + $state = get_network_option($network_id, self::NETWORK_TABLE_VERSIONS_OPTION, []); + + return is_array($state) ? $state : []; + } + + /** + * Records one completed setup step in a network's setup state. + * + * Call while holding get_network_setup_lock(), so concurrent steps do not + * overwrite each other's entries. + * + * @since 2.18.0 + * @param int $network_id Network ID. + * @param string $key Prefixed table name or setup marker. + * @param string $value Installed version or marker value. + * @return void + */ + final public static function set_network_setup_state(int $network_id, string $key, string $value): void { + + $state = self::get_network_setup_state($network_id); + $state[ $key ] = $value; + + update_network_option($network_id, self::NETWORK_TABLE_VERSIONS_OPTION, $state); + } + /** * Adds a caching layer to the parent exists method. * @@ -143,10 +201,10 @@ protected function maybe_install_network_table(): void { } $network_id = get_current_network_id(); - $versions = get_network_option($network_id, self::NETWORK_TABLE_VERSIONS_OPTION, []); - $versions = is_array($versions) ? $versions : []; + $state = self::get_network_setup_state($network_id); - if (isset($versions[ $this->prefixed_name ]) && (string) $this->version === (string) $versions[ $this->prefixed_name ]) { + // Fast path on every request once the table is current: no query beyond the primed option. + if (isset($state[ $this->prefixed_name ]) && (string) $this->version === (string) $state[ $this->prefixed_name ]) { return; } @@ -156,10 +214,8 @@ protected function maybe_install_network_table(): void { return; } - $db = $this->get_db(); - - // Named locks are server-wide: scope them to this installation, like Membership's publish lock. - $lock = sprintf('wu_tables_%s_%d', substr(md5(DB_NAME . '|' . $db->base_prefix), 0, 12), $network_id); + $db = $this->get_db(); + $lock = self::get_network_setup_lock($network_id); // Another request is setting up this network's tables: do not wait for it. if ('1' !== (string) $db->get_var($db->prepare('SELECT GET_LOCK(%s, 0)', $lock))) { @@ -170,8 +226,12 @@ protected function maybe_install_network_table(): void { // A version can be recorded for a table that no longer exists. if ( ! parent::exists()) { $this->install(); - } elseif ($this->needs_upgrade()) { - $this->upgrade(); + } else { + $this->adopt_network_local_version($network_id); + + if ($this->needs_upgrade()) { + $this->upgrade(); + } } if ($this->needs_upgrade() || ! parent::exists()) { @@ -180,17 +240,55 @@ protected function maybe_install_network_table(): void { return; } - $versions = get_network_option($network_id, self::NETWORK_TABLE_VERSIONS_OPTION, []); - $versions = is_array($versions) ? $versions : []; - - $versions[ $this->prefixed_name ] = (string) $this->version; - - update_network_option($network_id, self::NETWORK_TABLE_VERSIONS_OPTION, $versions); + self::set_network_setup_state($network_id, $this->prefixed_name, (string) $this->version); } finally { $db->get_var($db->prepare('SELECT RELEASE_LOCK(%s)', $lock)); } } + /** + * Use a schema version recorded under this network when the main network has none. + * + * BerlinDB reads every table's version from the main network. Some installs + * recorded secondary-network versions under the secondary network itself. + * Without a version, BerlinDB's upgrade() stamps an existing table as current + * without running its pending upgrades, so copy the network-local version + * to where BerlinDB reads it first. An existing table with no version + * anywhere is still stamped current, but the stamp is logged. + * + * Runs only during setup (under the setup lock), never on the fast path. + * + * @since 2.18.0 + * @param int $network_id Network ID. + * @return void + */ + private function adopt_network_local_version(int $network_id): void { + + $main_network_id = get_main_network_id(); + + if ($main_network_id === $network_id || false !== get_network_option($main_network_id, $this->db_version_key, false)) { + return; + } + + $local_version = get_network_option($network_id, $this->db_version_key, false); + + if (false === $local_version || '' === (string) $local_version) { + wu_log_add( + 'database', + sprintf( + 'Table %1$s exists without a recorded schema version; marking it as version %2$s without running upgrades.', + $this->table_name, + $this->version + ), + \Psr\Log\LogLevel::WARNING + ); + + return; + } + + update_network_option($main_network_id, $this->db_version_key, (string) $local_version); + } + /** * Point the canonical meta table property at this network's table. * diff --git a/inc/managers/class-email-manager.php b/inc/managers/class-email-manager.php index 88b0398d9..502ecaca3 100644 --- a/inc/managers/class-email-manager.php +++ b/inc/managers/class-email-manager.php @@ -80,12 +80,15 @@ class Email_Manager extends Base_Manager { const SCHEDULED_ARGS_MAX_LENGTH = 8000; /** - * Network option marking default system emails as created. + * Key in a secondary network's setup state marking its default system emails as created. + * + * Stored in Table::NETWORK_TABLE_VERSIONS_OPTION, which is already loaded on + * every secondary-network request, so the check costs no query. * * @since 2.18.0 * @var string */ - const SYSTEM_EMAILS_CREATED_OPTION = 'wu_system_emails_created'; + const SYSTEM_EMAILS_SETUP_KEY = '_system_emails'; /** * Instantiate the necessary hooks. @@ -127,31 +130,43 @@ public function init(): void { * * A secondary network can finish setup after its tables have been created outside * the regular setup wizard. Creating the defaults here keeps its system emails - * available without repeating the idempotent creation check on every request. + * available without repeating the idempotent creation check on every request: + * once created, the check reads only the network's already-loaded setup state. * * @since 2.18.0 * @return void */ public function maybe_create_secondary_network_system_emails(): void { - // Site transients are per network, so a failure here never delays other networks. - $retry_key = 'wu_system_emails_failed'; + if (is_main_network()) { + return; + } + + $network_id = get_current_network_id(); + $state = \WP_Ultimo\Database\Engine\Table::get_network_setup_state($network_id); - if (is_main_network() || get_network_option(null, self::SYSTEM_EMAILS_CREATED_OPTION, false) || get_site_transient($retry_key)) { + if ( ! empty($state[ self::SYSTEM_EMAILS_SETUP_KEY ])) { return; } + // The posts table records its version in the same state once it is set up; no SHOW TABLES needed. $post_table = \WP_Ultimo\Loaders\Table_Loader::get_instance()->post_table; - if ( ! $post_table->exists()) { + if (empty($state[ $post_table->prefixed_name ])) { + return; + } + + // Site transients are per network, so a failure here never delays other networks. + $retry_key = 'wu_system_emails_failed'; + + if (get_site_transient($retry_key)) { return; } global $wpdb; // One request creates the defaults; others skip instead of duplicating them. - // Named locks are server-wide: scope them to this installation, like Membership's publish lock. - $lock = sprintf('wu_system_emails_%s_%d', substr(md5(DB_NAME . '|' . $wpdb->base_prefix), 0, 12), get_current_network_id()); + $lock = \WP_Ultimo\Database\Engine\Table::get_network_setup_lock($network_id); if ('1' !== (string) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 0)', $lock))) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- named lock, never cached. return; @@ -170,7 +185,7 @@ public function maybe_create_secondary_network_system_emails(): void { } } - update_network_option(null, self::SYSTEM_EMAILS_CREATED_OPTION, true); + \WP_Ultimo\Database\Engine\Table::set_network_setup_state($network_id, self::SYSTEM_EMAILS_SETUP_KEY, '1'); } finally { $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- named lock, never cached. } diff --git a/tests/WP_Ultimo/Database/Engine/Table_Test.php b/tests/WP_Ultimo/Database/Engine/Table_Test.php index 5458e08ea..10880103a 100644 --- a/tests/WP_Ultimo/Database/Engine/Table_Test.php +++ b/tests/WP_Ultimo/Database/Engine/Table_Test.php @@ -208,13 +208,21 @@ public function test_secondary_network_table_is_created_on_first_request(): void $table = new \WP_Ultimo\Database\Webhooks\Webhooks_Table(); - $this->assertTrue($table->is_upgradeable()); + // Secondary networks are set up on first request, not by BerlinDB's admin_init check. + $this->assertFalse($table->is_upgradeable()); $this->assertSame($table_name, $table->table_name); $this->assertTrue($table->exists()); $versions = get_network_option($network_id, Table::NETWORK_TABLE_VERSIONS_OPTION); $this->assertSame($this->get_table_property($table, 'version'), $versions[ $key ] ?? null); + + // Once current, later requests' table setup runs no queries. + $queries = $wpdb->num_queries; + + new \WP_Ultimo\Database\Webhooks\Webhooks_Table(); + + $this->assertSame($queries, $wpdb->num_queries); } finally { remove_filter('get_main_network_id', $main); $wpdb->query("DROP TABLE IF EXISTS {$table_name}"); // phpcs:ignore WordPress.DB @@ -265,4 +273,50 @@ public function test_secondary_network_table_is_upgraded_on_first_request(): voi delete_network_option($network_id, Table::NETWORK_TABLE_VERSIONS_OPTION); } } + + /** + * GH#2034: a version recorded under the secondary network itself (not the + * main network, where BerlinDB reads it) is adopted, so pending upgrades + * run instead of the table being stamped current. + */ + public function test_secondary_network_table_adopts_network_local_version(): void { + global $wpdb; + + $network_id = get_current_network_id(); + $main_id = $network_id + 1000; + $main = static fn() => $main_id; + $table_name = $wpdb->base_prefix . 'wu_' . $network_id . '_webhooks'; + $key = 'wu_' . $network_id . '_webhooks'; + + remove_filter('query', [$this, '_create_temporary_tables']); + remove_filter('query', [$this, '_drop_temporary_tables']); + add_filter('get_main_network_id', $main); + + try { + $wpdb->query("DROP TABLE IF EXISTS {$table_name}"); // phpcs:ignore WordPress.DB + delete_network_option($main_id, "wpdb_{$key}_version"); + delete_network_option($network_id, "wpdb_{$key}_version"); + delete_network_option($network_id, Table::NETWORK_TABLE_VERSIONS_OPTION); + + new \WP_Ultimo\Database\Webhooks\Webhooks_Table(); + + $wpdb->query("ALTER TABLE {$table_name} MODIFY COLUMN `date_created` datetime NOT NULL"); // phpcs:ignore WordPress.DB + delete_network_option($main_id, "wpdb_{$key}_version"); + update_network_option($network_id, "wpdb_{$key}_version", '2.0.0'); + delete_network_option($network_id, Table::NETWORK_TABLE_VERSIONS_OPTION); + + $table = new \WP_Ultimo\Database\Webhooks\Webhooks_Table(); + + $columns = wp_list_pluck($wpdb->get_results("SHOW COLUMNS FROM {$table_name}", ARRAY_A), 'Null', 'Field'); // phpcs:ignore WordPress.DB + + $this->assertSame('YES', $columns['date_created'] ?? null); + $this->assertSame($this->get_table_property($table, 'version'), get_network_option($main_id, "wpdb_{$key}_version")); + } finally { + remove_filter('get_main_network_id', $main); + $wpdb->query("DROP TABLE IF EXISTS {$table_name}"); // phpcs:ignore WordPress.DB + delete_network_option($main_id, "wpdb_{$key}_version"); + delete_network_option($network_id, "wpdb_{$key}_version"); + delete_network_option($network_id, Table::NETWORK_TABLE_VERSIONS_OPTION); + } + } }