Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c70cf45
fix: GH#2009 create and upgrade Ultimate Multisite tables on secondar…
superdav42 Oct 21, 2026
272691a
fix: finish secondary network table provisioning
superdav42 Oct 22, 2026
a771c3f
Merge branch 'main' into feature/auto-20261021-091957-gh2012
superdav42 Oct 22, 2026
a1083b3
Merge branch 'main' into feature/auto-20261021-091957-gh2012
superdav42 Oct 23, 2026
2ef14d5
Merge branch 'main' into feature/auto-20261021-091957-gh2012
superdav42 Oct 23, 2026
fad2d9e
Merge branch 'main' into feature/auto-20261021-091957-gh2012
superdav42 Oct 24, 2026
29e5673
Merge branch 'main' into feature/auto-20261021-091957-gh2012
superdav42 Oct 25, 2026
52ec9cb
fix: keep secondary-network table setup off the request path when it …
superdav42 Oct 25, 2026
35a6290
fix: address review findings on secondary-network setup
superdav42 Oct 25, 2026
021355a
Merge branch 'main' into feature/auto-20261021-091957-gh2012
superdav42 Oct 25, 2026
793edaf
Merge branch 'main' into feature/auto-20261021-091957-gh2012
superdav42 Oct 26, 2026
48b8fc8
Merge branch 'main' into feature/auto-20261021-091957-gh2012
superdav42 Oct 26, 2026
7f347f2
Merge branch 'main' into feature/auto-20261021-091957-gh2012
superdav42 Oct 27, 2026
0e9e0db
Merge branch 'main' into feature/auto-20261021-091957-gh2012
superdav42 Oct 27, 2026
6a3f2dd
fix: address PR #2013 review findings
superdav42 Oct 27, 2026
58a56a4
fix: adopt network-local table versions and keep secondary-network se…
superdav42 Oct 27, 2026
3942d06
Merge remote-tracking branch 'origin/main' into feature/auto-20261021…
superdav42 Oct 27, 2026
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
16 changes: 16 additions & 0 deletions inc/class-requirements.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
27 changes: 27 additions & 0 deletions inc/database/engine/class-query.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
204 changes: 204 additions & 0 deletions inc/database/engine/class-table.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,31 @@ 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.
*
* We need to do this because we are using the table object
* 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
*/
Expand All @@ -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<string, string>
*/
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.
*
Expand All @@ -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();

Expand All @@ -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);
}

/**
Expand Down
80 changes: 80 additions & 0 deletions inc/managers/class-email-manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

\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.
}
}

/**
Expand Down
Loading
Loading