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-query.php b/inc/database/engine/class-query.php index dab6f1cfe..643e385e4 100644 --- a/inc/database/engine/class-query.php +++ b/inc/database/engine/class-query.php @@ -105,6 +105,33 @@ 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})) { + $args = wp_parse_args($query); + + // 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); + } + /** * Get the plural name. * diff --git a/inc/database/engine/class-table.php b/inc/database/engine/class-table.php index d11404137..c2ab17df9 100644 --- a/inc/database/engine/class-table.php +++ b/inc/database/engine/class-table.php @@ -37,6 +37,20 @@ abstract class Table extends \BerlinDB\Database\Table { */ private bool $exists; + /** + * 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). + * + * 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 + */ + const NETWORK_TABLE_VERSIONS_OPTION = 'wu_network_table_versions'; + /** * Overrides the is_upgradeable method. * @@ -44,6 +58,10 @@ abstract class Table extends \BerlinDB\Database\Table { * early in the lifecycle, which means that upgrade.php is not * available. * + * 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 */ @@ -56,6 +74,57 @@ public function is_upgradeable(): bool { 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. * @@ -71,10 +140,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 +170,123 @@ 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; 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 + * @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(); + $state = self::get_network_setup_state($network_id); + + // 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; + } + + $retry_key = 'wu_table_setup_failed_' . $this->prefixed_name; + + if (get_site_transient($retry_key)) { + return; + } + + $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))) { + return; + } + + try { + // A version can be recorded for a table that no longer exists. + if ( ! parent::exists()) { + $this->install(); + } else { + $this->adopt_network_local_version($network_id); + + if ($this->needs_upgrade()) { + $this->upgrade(); + } + } + + if ($this->needs_upgrade() || ! parent::exists()) { + set_site_transient($retry_key, 1, HOUR_IN_SECONDS); + + return; + } + + 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); } /** diff --git a/inc/managers/class-email-manager.php b/inc/managers/class-email-manager.php index dc0dc2747..502ecaca3 100644 --- a/inc/managers/class-email-manager.php +++ b/inc/managers/class-email-manager.php @@ -79,6 +79,17 @@ class Email_Manager extends Base_Manager { */ const SCHEDULED_ARGS_MAX_LENGTH = 8000; + /** + * 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_SETUP_KEY = '_system_emails'; + /** * Instantiate the necessary hooks. * @@ -109,6 +120,75 @@ 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']); + + // Late init: default emails are translated, so text domains must be loaded. + add_action('init', [$this, 'maybe_create_secondary_network_system_emails'], 99); + } + + /** + * 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: + * 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 { + + if (is_main_network()) { + return; + } + + $network_id = get_current_network_id(); + $state = \WP_Ultimo\Database\Engine\Table::get_network_setup_state($network_id); + + 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 (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. + $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; + } + + 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'])) { + // Retry at most hourly instead of rendering every template on each request. + set_site_transient($retry_key, 1, HOUR_IN_SECONDS); + + return; + } + } + + \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/Query_Test.php b/tests/WP_Ultimo/Database/Engine/Query_Test.php index 0fe002b4f..dd4429ca4 100644 --- a/tests/WP_Ultimo/Database/Engine/Query_Test.php +++ b/tests/WP_Ultimo/Database/Engine/Query_Test.php @@ -48,4 +48,35 @@ 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( 0, $query->items ); + $this->assertSame( [], $query->query( [ + 'count' => true, + 'groupby' => 'type', + ] ) ); + $this->assertSame( '', $wpdb->last_error ); + } finally { + $current_site = $original; + } + } } diff --git a/tests/WP_Ultimo/Database/Engine/Table_Test.php b/tests/WP_Ultimo/Database/Engine/Table_Test.php index 1367d7a2c..10880103a 100644 --- a/tests/WP_Ultimo/Database/Engine/Table_Test.php +++ b/tests/WP_Ultimo/Database/Engine/Table_Test.php @@ -182,4 +182,141 @@ 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(); + + // 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 + delete_network_option($network_id + 1000, "wpdb_{$key}_version"); + 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); + } + } + + /** + * 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); + } + } }