769 lines
31 KiB
PHP
769 lines
31 KiB
PHP
<?php
|
|
|
|
namespace FluentCrm\App\Services;
|
|
|
|
/**
|
|
* DbPerformanceService
|
|
*
|
|
* Health-checks and repairs the small set of "critical" indexes that the
|
|
* hot-path queries (campaign sending, automation runner, dedupe-protecting
|
|
* unique keys) depend on.
|
|
*
|
|
* Why this exists: index creation runs on plugin activation / DB version bumps.
|
|
* On large or busy tables an ALTER can fail mid-flight (lock-wait timeout,
|
|
* killed request, insufficient privilege, a "Duplicate entry" left behind by
|
|
* un-deduped rows). When that happens the DB version option is still bumped, so
|
|
* the migrator never retries and the index stays missing — silently degrading
|
|
* performance or, for the unique keys, allowing duplicate rows to accumulate.
|
|
*
|
|
* This service is the single source of truth for how each critical index is
|
|
* built (sweep + dedupe + online-or-plain ALTER). The relevant migrations
|
|
* delegate to ensureCriticalIndex() instead of carrying their own copy of that
|
|
* logic, and the runtime path (getIndexHealth/repairIndex) uses the same code
|
|
* to detect drift on load — via a lightweight cached check — and heal it on
|
|
* demand. Migration and runtime therefore can never diverge.
|
|
*/
|
|
class DbPerformanceService
|
|
{
|
|
/**
|
|
* Build the health status for every critical index.
|
|
*
|
|
* @param bool $fromDb When false, serve from the cached snapshot if it is
|
|
* complete (covers every known index). When the cache
|
|
* is missing any index it is treated as stale and a
|
|
* live DB check is performed. Pass true to always hit
|
|
* the DB (e.g. right after a repair).
|
|
* @return array<string, array> Index name => meta + 'status' ('yes'|'no').
|
|
*/
|
|
public static function getIndexHealth($fromDb = true)
|
|
{
|
|
$indexActions = self::getCriticalIndexLists();
|
|
|
|
if (!$fromDb) {
|
|
$existing = fluentcrm_get_option('_db_index_health', []);
|
|
$broken = false;
|
|
if ($existing) {
|
|
foreach ($indexActions as $indexName => $indexData) {
|
|
if (!isset($existing[$indexName])) {
|
|
// A known index is absent from the cache — snapshot is
|
|
// stale (e.g. a new critical index was introduced), so
|
|
// fall through to a fresh DB check below.
|
|
$broken = true;
|
|
continue;
|
|
}
|
|
$indexData['name'] = $indexName;
|
|
$indexData['status'] = $existing[$indexName];
|
|
$indexActions[$indexName] = $indexData;
|
|
}
|
|
|
|
if (!$broken) {
|
|
return $indexActions;
|
|
}
|
|
}
|
|
}
|
|
|
|
global $wpdb;
|
|
|
|
// Group required indexes by table so we run a single SHOW INDEX per table.
|
|
$formattedIndex = [];
|
|
foreach ($indexActions as $indexName => $indexData) {
|
|
if (!isset($formattedIndex[$indexData['table']])) {
|
|
$formattedIndex[$indexData['table']] = [];
|
|
}
|
|
$formattedIndex[$indexData['table']][$indexName] = $indexData;
|
|
}
|
|
|
|
$indexedResults = [];
|
|
$cachedData = [];
|
|
|
|
foreach ($formattedIndex as $tableName => $requiredIndexes) {
|
|
$table = $wpdb->prefix . $tableName;
|
|
|
|
// Full per-key structure (uniqueness + ordered columns + prefix
|
|
// lengths), so a same-named index with the WRONG columns/order is
|
|
// reported broken rather than silently accepted as healthy.
|
|
$info = self::getIndexInfo($table);
|
|
|
|
foreach ($requiredIndexes as $indexName => $indexData) {
|
|
$status = self::indexIsHealthy($info, $indexName, $indexData) ? 'yes' : 'no';
|
|
$indexData['name'] = $indexName;
|
|
$indexData['status'] = $status;
|
|
$indexedResults[$indexName] = $indexData;
|
|
$cachedData[$indexName] = $status;
|
|
}
|
|
}
|
|
|
|
// Preserve the original ordering defined in getCriticalIndexLists().
|
|
$ordered = [];
|
|
foreach ($indexActions as $indexName => $indexData) {
|
|
if (isset($indexedResults[$indexName])) {
|
|
$ordered[$indexName] = $indexedResults[$indexName];
|
|
}
|
|
}
|
|
|
|
fluentcrm_update_option('_db_index_health', $cachedData);
|
|
|
|
return $ordered;
|
|
}
|
|
|
|
/**
|
|
* Whether any critical index is currently missing.
|
|
*
|
|
* @param bool $fromDb See getIndexHealth(). Defaults to the cached check so
|
|
* it is cheap to call on every app load.
|
|
* @return bool
|
|
*/
|
|
public static function hasBrokenIndex($fromDb = false)
|
|
{
|
|
foreach (self::getIndexHealth($fromDb) as $indexData) {
|
|
if ($indexData['status'] !== 'yes') {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Ensure a single critical index exists (and, for unique keys, is actually
|
|
* unique), creating it with the appropriate cleanup if not.
|
|
*
|
|
* This is the single source of truth for how each critical index is built.
|
|
* It is intentionally low-level — it touches only $wpdb (no option store, no
|
|
* WP_Error), so it is safe to call from the migration classes during plugin
|
|
* activation, before the framework is fully booted. repairIndex() wraps this
|
|
* with verification and health-cache refresh for the runtime/AJAX path.
|
|
*
|
|
* For the uniqueness constraints the table is swept + deduped first; without
|
|
* it the ADD UNIQUE KEY fails with "Duplicate entry" on any table that
|
|
* accumulated dupes while the constraint was absent.
|
|
*
|
|
* @param string $indexName One of the keys from getCriticalIndexLists().
|
|
* @return bool True if the index is known and the ensure ran (it may still
|
|
* no-op when already present); false for an unknown index.
|
|
*/
|
|
public static function ensureCriticalIndex($indexName)
|
|
{
|
|
global $wpdb;
|
|
|
|
$indexLists = self::getCriticalIndexLists();
|
|
if (!isset($indexLists[$indexName])) {
|
|
return false;
|
|
}
|
|
|
|
$index = $indexLists[$indexName];
|
|
$table = $wpdb->prefix . $index['table'];
|
|
|
|
if ($index['type'] === 'unique') {
|
|
self::ensureUniqueKey($table, $indexName, $index);
|
|
} else {
|
|
self::ensurePlainIndex($table, $indexName, $index['columns']);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Repair a single critical index from the runtime/AJAX path.
|
|
*
|
|
* Delegates the actual DDL to ensureCriticalIndex(), then verifies against
|
|
* the live DB and refreshes the health cache so the caller and any later
|
|
* cached read both see the true post-repair state.
|
|
*
|
|
* @param string $indexName One of the keys from getCriticalIndexLists().
|
|
* @return array|\WP_Error The refreshed index record on success, WP_Error
|
|
* if the index is unknown or still missing after
|
|
* the repair attempt.
|
|
*/
|
|
public static function repairIndex($indexName)
|
|
{
|
|
if (!self::ensureCriticalIndex($indexName)) {
|
|
return new \WP_Error(
|
|
'invalid_index',
|
|
__('The provided index is not a known critical index.', 'fluent-crm')
|
|
);
|
|
}
|
|
|
|
global $wpdb;
|
|
// Capture the ALTER's error before the verification queries below reset
|
|
// $wpdb->last_error, so a privilege/lock failure can still be surfaced.
|
|
$repairError = $wpdb->last_error;
|
|
|
|
// Verify against the live DB (and refresh the cache).
|
|
$health = self::getIndexHealth(true);
|
|
|
|
if (!isset($health[$indexName]) || $health[$indexName]['status'] !== 'yes') {
|
|
return new \WP_Error(
|
|
'repair_failed',
|
|
sprintf(
|
|
/* translators: %s: index name */
|
|
__('Could not create the "%s" index. Please check your database user privileges or repair it manually.', 'fluent-crm'),
|
|
$indexName
|
|
),
|
|
['db_error' => $repairError]
|
|
);
|
|
}
|
|
|
|
return $health[$indexName];
|
|
}
|
|
|
|
/**
|
|
* Repair every missing critical index in one pass.
|
|
*
|
|
* @return array{repaired: string[], failed: string[], health: array}
|
|
*/
|
|
public static function repairBrokenIndexes()
|
|
{
|
|
$health = self::getIndexHealth(true);
|
|
|
|
$repaired = [];
|
|
$failed = [];
|
|
foreach ($health as $indexName => $indexData) {
|
|
if ($indexData['status'] === 'yes') {
|
|
continue;
|
|
}
|
|
|
|
$result = self::repairIndex($indexName);
|
|
if (is_wp_error($result)) {
|
|
$failed[] = $indexName;
|
|
} else {
|
|
$repaired[] = $indexName;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'repaired' => $repaired,
|
|
'failed' => $failed,
|
|
'health' => self::getIndexHealth(false), // freshly cached by the calls above
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Ensure a plain (non-unique) composite index exists with exactly the
|
|
* expected columns. A same-named index whose column list/order/prefix has
|
|
* drifted is dropped and recreated, so the performance assumption it backs
|
|
* actually holds — name-presence alone is not enough.
|
|
*
|
|
* @param string $table Fully-prefixed table name.
|
|
* @param string $indexName Key name to create.
|
|
* @param array $columns Expected column spec — see getCriticalIndexLists().
|
|
* @return void
|
|
*/
|
|
private static function ensurePlainIndex($table, $indexName, array $columns)
|
|
{
|
|
$info = self::getIndexInfo($table);
|
|
$columnSql = self::buildColumnSql($columns);
|
|
|
|
if (isset($info[$indexName])) {
|
|
if (self::indexColumnsMatch($info[$indexName]['columns'], $columns)) {
|
|
return; // present with the right columns — nothing to do.
|
|
}
|
|
// Same name, wrong columns — replace it in one statement.
|
|
self::alterWithOnlineFallback($table, "DROP INDEX `{$indexName}`, ADD INDEX `{$indexName}` ({$columnSql})");
|
|
return;
|
|
}
|
|
|
|
self::alterWithOnlineFallback($table, "ADD INDEX `{$indexName}` ({$columnSql})");
|
|
}
|
|
|
|
/**
|
|
* Add a UNIQUE KEY, cleaning the table first so the ALTER can't fail with
|
|
* "Duplicate entry".
|
|
*
|
|
* This is the canonical convergence logic the owning migrations delegate to:
|
|
* a key of the same name that exists but is non-unique OR has drifted columns
|
|
* is dropped and re-added; offending rows are swept/deduped before the ALTER
|
|
* runs. The cleanup differs per table, so each has its own dedicated method
|
|
* ('cleanup' names which one). If cleanup fails (lock-wait timeout, missing
|
|
* privilege) we skip the ALTER rather than let it throw — the caller's
|
|
* verification step reports the index as still broken and a later attempt
|
|
* can retry.
|
|
*
|
|
* @param string $table Fully-prefixed table name.
|
|
* @param string $indexName Unique key name to create.
|
|
* @param array $index Index descriptor from getCriticalIndexLists().
|
|
* @return void
|
|
*/
|
|
private static function ensureUniqueKey($table, $indexName, array $index)
|
|
{
|
|
$info = self::getIndexInfo($table);
|
|
$existing = isset($info[$indexName]) ? $info[$indexName] : null;
|
|
|
|
// Already a correct unique key (unique AND the right columns) — done.
|
|
if ($existing
|
|
&& $existing['non_unique'] === 0
|
|
&& self::indexColumnsMatch($existing['columns'], $index['columns'])) {
|
|
return;
|
|
}
|
|
|
|
// Run the table-specific cleanup. Each method sweeps garbage rows and
|
|
// removes duplicates with the survivor rule appropriate for that table.
|
|
$clean = false;
|
|
switch ($index['cleanup']) {
|
|
case 'subscriber_pivot':
|
|
$clean = self::cleanupSubscriberPivot($table);
|
|
break;
|
|
case 'funnel_subscribers':
|
|
$clean = self::cleanupFunnelSubscribers($table);
|
|
break;
|
|
case 'funnel_metrics':
|
|
$clean = self::cleanupFunnelMetrics($table);
|
|
break;
|
|
}
|
|
|
|
if (!$clean) {
|
|
return; // cleanup failed — don't risk a "Duplicate entry" ALTER.
|
|
}
|
|
|
|
$columnSql = self::buildColumnSql($index['columns']);
|
|
|
|
if ($existing) {
|
|
// Same name but non-unique or wrong columns — replace it in one statement.
|
|
self::alterWithOnlineFallback($table, "DROP INDEX `{$indexName}`, ADD UNIQUE KEY `{$indexName}` ({$columnSql})");
|
|
} else {
|
|
self::alterWithOnlineFallback($table, "ADD UNIQUE KEY `{$indexName}` ({$columnSql})");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run an index ALTER, preferring a non-blocking online build but always
|
|
* falling back to a plain ALTER so the index is created on any server.
|
|
*
|
|
* Why both: ALGORITHM=INPLACE, LOCK=NONE lets the table stay readable AND
|
|
* writable during the build, which avoids stalling live traffic (e.g. the
|
|
* email pipeline on a multi-million-row fc_campaign_emails). But it is not
|
|
* universally available — the ALGORITHM/LOCK syntax did not exist before
|
|
* MySQL 5.6 / MariaDB 10.0 (where it is a hard parse error), and some
|
|
* engines/operations can't satisfy LOCK=NONE. FluentCRM runs on WordPress
|
|
* hosts we don't control, and these keys — the unique ones especially — are
|
|
* a correctness dependency for the app (INSERT IGNORE / firstOrCreate rely
|
|
* on them). So we try online first, then retry plain: a brief write-block on
|
|
* an old server is an acceptable price for a key that actually gets created.
|
|
*
|
|
* @param string $table Fully-prefixed table name.
|
|
* @param string $operations The ALTER body, e.g. "ADD UNIQUE KEY `x` (...)"
|
|
* or "DROP INDEX `x`, ADD UNIQUE KEY `x` (...)".
|
|
* @return int|bool Result of the successful query, or false if both failed.
|
|
*/
|
|
private static function alterWithOnlineFallback($table, $operations)
|
|
{
|
|
global $wpdb;
|
|
|
|
// Attempt the non-blocking online build first.
|
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
$result = $wpdb->query("ALTER TABLE {$table} {$operations}, ALGORITHM=INPLACE, LOCK=NONE");
|
|
if ($result !== false) {
|
|
return $result;
|
|
}
|
|
|
|
// Online DDL unavailable (old server, FK/engine constraint, etc.) — retry
|
|
// with a plain ALTER that works everywhere, even if it briefly blocks
|
|
// writes. If this also fails (real duplicate, no privilege) the caller's
|
|
// verification reports the index missing and a later run can retry.
|
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
return $wpdb->query("ALTER TABLE {$table} {$operations}");
|
|
}
|
|
|
|
/**
|
|
* Cleanup for fc_subscriber_pivot ahead of the subscriber_object_type_unique
|
|
* key, which enforces uniqueness on (subscriber_id, object_id, object_type).
|
|
*
|
|
* Sweeps rows where subscriber_id or object_id is NULL or 0 — these are
|
|
* never a valid list/tag relationship (they come from past writer bugs that
|
|
* lacked a real id) and would otherwise survive into the unique key and
|
|
* block legitimate future writes. The schema declares both columns
|
|
* BIGINT UNSIGNED NOT NULL, but we still guard IS NULL so the repair is
|
|
* correct even on an older live table whose definition has drifted.
|
|
*
|
|
* Then keeps the lowest id per (subscriber_id, object_id, object_type) group
|
|
* so the survivor has the earliest created_at ("we already had this
|
|
* attachment"). (SubscriberPivot::migrate() delegates here.)
|
|
*
|
|
* @param string $table Fully-prefixed fc_subscriber_pivot table name.
|
|
* @return bool True if the table is safe to receive the unique key.
|
|
*/
|
|
private static function cleanupSubscriberPivot($table)
|
|
{
|
|
global $wpdb;
|
|
|
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
$wpdb->query("DELETE FROM {$table} WHERE subscriber_id IS NULL OR subscriber_id = 0 OR object_id IS NULL OR object_id = 0");
|
|
|
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
$hasDuplicates = (int) $wpdb->get_var("SELECT COUNT(*) FROM (
|
|
SELECT subscriber_id, object_id, object_type
|
|
FROM {$table}
|
|
GROUP BY subscriber_id, object_id, object_type
|
|
HAVING COUNT(*) > 1
|
|
) dups");
|
|
|
|
if ($hasDuplicates <= 0) {
|
|
return true;
|
|
}
|
|
|
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
$deleted = $wpdb->query("DELETE sp FROM {$table} sp
|
|
INNER JOIN (
|
|
SELECT subscriber_id, object_id, object_type, MIN(id) as keep_id
|
|
FROM {$table}
|
|
GROUP BY subscriber_id, object_id, object_type
|
|
HAVING COUNT(*) > 1
|
|
) dups ON sp.subscriber_id = dups.subscriber_id
|
|
AND sp.object_id = dups.object_id
|
|
AND sp.object_type = dups.object_type
|
|
AND sp.id != dups.keep_id");
|
|
|
|
return $deleted !== false;
|
|
}
|
|
|
|
/**
|
|
* Cleanup for fc_funnel_subscribers ahead of the funnel_subscriber_idx key.
|
|
*
|
|
* Sweeps rows with NULL/0 funnel_id or subscriber_id, then dedupes keeping
|
|
* the row that has progressed FURTHEST through the funnel rather than the
|
|
* newest row. This is a state table, not a pure pivot: a naive MAX(id)
|
|
* survivor can discard a row that already executed sequences in favour of a
|
|
* newer, less advanced duplicate, which then re-fires sequences the contact
|
|
* already received. Survivor rule: highest IFNULL(last_sequence_id, 0) wins,
|
|
* tiebroken by MAX(id). The IFNULL is essential — last_sequence_id is NULL
|
|
* for freshly enrolled contacts, and SQL's three-valued logic would
|
|
* otherwise match no survivor for all-NULL groups, leaving the dupes in
|
|
* place and failing the ALTER. (FunnelSubscribers::migrate() delegates here.)
|
|
*
|
|
* @param string $table Fully-prefixed fc_funnel_subscribers table name.
|
|
* @return bool True if the table is safe to receive the unique key.
|
|
*/
|
|
private static function cleanupFunnelSubscribers($table)
|
|
{
|
|
global $wpdb;
|
|
|
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
$wpdb->query("DELETE FROM {$table} WHERE funnel_id IS NULL OR funnel_id = 0 OR subscriber_id IS NULL OR subscriber_id = 0");
|
|
|
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
$hasDuplicates = (int) $wpdb->get_var("SELECT COUNT(*) FROM (
|
|
SELECT funnel_id, subscriber_id
|
|
FROM {$table}
|
|
GROUP BY funnel_id, subscriber_id
|
|
HAVING COUNT(*) > 1
|
|
) dups");
|
|
|
|
if ($hasDuplicates <= 0) {
|
|
return true;
|
|
}
|
|
|
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
$deleted = $wpdb->query("DELETE fs FROM {$table} fs
|
|
INNER JOIN (
|
|
SELECT t1.funnel_id, t1.subscriber_id, MAX(t1.id) as keep_id
|
|
FROM {$table} t1
|
|
INNER JOIN (
|
|
SELECT funnel_id, subscriber_id, MAX(IFNULL(last_sequence_id, 0)) as max_seq
|
|
FROM {$table}
|
|
GROUP BY funnel_id, subscriber_id
|
|
HAVING COUNT(*) > 1
|
|
) dups ON t1.funnel_id = dups.funnel_id
|
|
AND t1.subscriber_id = dups.subscriber_id
|
|
AND IFNULL(t1.last_sequence_id, 0) = dups.max_seq
|
|
GROUP BY t1.funnel_id, t1.subscriber_id
|
|
) survivors ON fs.funnel_id = survivors.funnel_id
|
|
AND fs.subscriber_id = survivors.subscriber_id
|
|
AND fs.id != survivors.keep_id");
|
|
|
|
return $deleted !== false;
|
|
}
|
|
|
|
/**
|
|
* Cleanup for fc_funnel_metrics ahead of the funnel_seq_subscriber_unique
|
|
* key, which enforces uniqueness on (funnel_id, sequence_id, subscriber_id).
|
|
*
|
|
* Sweeps rows where any of the three key columns is NULL or 0 — a metric is
|
|
* only meaningful when tied to a real funnel, sequence and subscriber, so
|
|
* such rows are invalid leftovers. They are also collapsed into a single
|
|
* GROUP BY bucket by the dedupe below, so removing them up front keeps the
|
|
* survivor selection clean. (All three are BIGINT UNSIGNED NULL in the
|
|
* schema, so both NULL and 0 are reachable.)
|
|
*
|
|
* Then keeps the latest entry (MAX id) per (funnel_id, sequence_id,
|
|
* subscriber_id) group. (FunnelMetrics::migrate() delegates here; this adds
|
|
* the NULL/0 pre-sweep the old inline migration lacked.)
|
|
*
|
|
* @param string $table Fully-prefixed fc_funnel_metrics table name.
|
|
* @return bool True if the table is safe to receive the unique key.
|
|
*/
|
|
private static function cleanupFunnelMetrics($table)
|
|
{
|
|
global $wpdb;
|
|
|
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
$wpdb->query("DELETE FROM {$table} WHERE funnel_id IS NULL OR funnel_id = 0 OR sequence_id IS NULL OR sequence_id = 0 OR subscriber_id IS NULL OR subscriber_id = 0");
|
|
|
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
$hasDuplicates = (int) $wpdb->get_var("SELECT COUNT(*) FROM (
|
|
SELECT funnel_id, sequence_id, subscriber_id
|
|
FROM {$table}
|
|
GROUP BY funnel_id, sequence_id, subscriber_id
|
|
HAVING COUNT(*) > 1
|
|
) dups");
|
|
|
|
if ($hasDuplicates <= 0) {
|
|
return true;
|
|
}
|
|
|
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
$deleted = $wpdb->query("DELETE fm FROM {$table} fm
|
|
INNER JOIN (
|
|
SELECT funnel_id, sequence_id, subscriber_id, MAX(id) AS keep_id
|
|
FROM {$table}
|
|
GROUP BY funnel_id, sequence_id, subscriber_id
|
|
HAVING COUNT(*) > 1
|
|
) dups ON fm.funnel_id = dups.funnel_id
|
|
AND fm.sequence_id = dups.sequence_id
|
|
AND fm.subscriber_id = dups.subscriber_id
|
|
AND fm.id != dups.keep_id");
|
|
|
|
return $deleted !== false;
|
|
}
|
|
|
|
/**
|
|
* Read every index on a table into a structured map keyed by index name.
|
|
*
|
|
* SHOW INDEX emits one row per indexed column; we fold them into:
|
|
* [ Key_name => [
|
|
* 'non_unique' => 0|1,
|
|
* 'columns' => [ ['name' => ..., 'sub_part' => int|null], ... ] // ordered by Seq_in_index
|
|
* ] ]
|
|
* so callers can verify the full column sequence and prefix lengths, not
|
|
* just the index name.
|
|
*
|
|
* @param string $table Fully-prefixed table name.
|
|
* @return array<string, array{non_unique:int, columns:array}>
|
|
*/
|
|
private static function getIndexInfo($table)
|
|
{
|
|
global $wpdb;
|
|
|
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
$rows = $wpdb->get_results("SHOW INDEX FROM {$table}");
|
|
if (!$rows) {
|
|
return [];
|
|
}
|
|
|
|
$info = [];
|
|
foreach ($rows as $row) {
|
|
$name = $row->Key_name;
|
|
if (!isset($info[$name])) {
|
|
$info[$name] = ['non_unique' => (int) $row->Non_unique, 'columns' => []];
|
|
}
|
|
// Seq_in_index is 1-based; key by it so out-of-order rows still sort.
|
|
$info[$name]['columns'][(int) $row->Seq_in_index] = [
|
|
'name' => $row->Column_name,
|
|
'sub_part' => ($row->Sub_part === null) ? null : (int) $row->Sub_part,
|
|
];
|
|
}
|
|
|
|
foreach ($info as &$data) {
|
|
ksort($data['columns']);
|
|
$data['columns'] = array_values($data['columns']);
|
|
}
|
|
unset($data);
|
|
|
|
return $info;
|
|
}
|
|
|
|
/**
|
|
* Whether a required index is present, of the right kind, AND backed by the
|
|
* exact expected columns. This is the health gate that closes the
|
|
* "same-named index with the wrong columns reads as healthy" hole.
|
|
*
|
|
* @param array $info Output of getIndexInfo().
|
|
* @param string $indexName Required index name.
|
|
* @param array $index Index descriptor from getCriticalIndexLists().
|
|
* @return bool
|
|
*/
|
|
private static function indexIsHealthy($info, $indexName, array $index)
|
|
{
|
|
if (!isset($info[$indexName])) {
|
|
return false;
|
|
}
|
|
|
|
$actual = $info[$indexName];
|
|
|
|
// A same-named non-unique index does not enforce uniqueness.
|
|
if ($index['type'] === 'unique' && $actual['non_unique'] !== 0) {
|
|
return false;
|
|
}
|
|
|
|
return self::indexColumnsMatch($actual['columns'], $index['columns']);
|
|
}
|
|
|
|
/**
|
|
* Compare an index's actual column sequence (from getIndexInfo) against the
|
|
* expected one (from getCriticalIndexLists). Column names compare
|
|
* case-insensitively; order matters.
|
|
*
|
|
* Prefix handling: when a prefix length is expected (e.g. object_type(50)),
|
|
* a full-column index (Sub_part = NULL) is accepted because MySQL stores a
|
|
* prefix as a full-column index when the prefix length equals the column
|
|
* length — which is exactly what object_type(50) becomes on the current
|
|
* VARCHAR(50) schema, and a full column is never weaker than the prefix.
|
|
* A prefix where a full column is expected, or a different prefix length,
|
|
* is a mismatch.
|
|
*
|
|
* @param array $actualColumns Ordered [['name'=>, 'sub_part'=>], ...].
|
|
* @param array $expectedColumns Ordered [['name'=>, 'sub_part'=>?], ...].
|
|
* @return bool
|
|
*/
|
|
private static function indexColumnsMatch($actualColumns, array $expectedColumns)
|
|
{
|
|
if (count($actualColumns) !== count($expectedColumns)) {
|
|
return false;
|
|
}
|
|
|
|
foreach ($expectedColumns as $i => $expected) {
|
|
if (!isset($actualColumns[$i])) {
|
|
return false;
|
|
}
|
|
$actual = $actualColumns[$i];
|
|
|
|
if (strcasecmp($actual['name'], $expected['name']) !== 0) {
|
|
return false;
|
|
}
|
|
|
|
$expectedSub = isset($expected['sub_part']) ? (int) $expected['sub_part'] : null;
|
|
$actualSub = isset($actual['sub_part']) ? $actual['sub_part'] : null;
|
|
|
|
if ($expectedSub === null) {
|
|
// Full column expected — a prefix index is weaker.
|
|
if ($actualSub !== null) {
|
|
return false;
|
|
}
|
|
} else {
|
|
// Prefix expected — accept the exact prefix, or a full-column
|
|
// index (NULL) which equals/exceeds the prefix.
|
|
if ($actualSub !== null && $actualSub !== $expectedSub) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Build the SQL column spec — e.g. "`subscriber_id`, `object_id`,
|
|
* `object_type`(50)" — for the ADD [UNIQUE] INDEX statement from the
|
|
* structured column definition.
|
|
*
|
|
* @param array $columns Ordered [['name'=>, 'sub_part'=>?], ...].
|
|
* @return string
|
|
*/
|
|
private static function buildColumnSql(array $columns)
|
|
{
|
|
$parts = [];
|
|
foreach ($columns as $col) {
|
|
$sql = '`' . $col['name'] . '`';
|
|
if (!empty($col['sub_part'])) {
|
|
$sql .= '(' . (int) $col['sub_part'] . ')';
|
|
}
|
|
$parts[] = $sql;
|
|
}
|
|
|
|
return implode(', ', $parts);
|
|
}
|
|
|
|
/**
|
|
* The canonical list of performance- and integrity-critical indexes.
|
|
*
|
|
* Each entry is self-describing so repairIndex() can heal it without
|
|
* touching the migration classes:
|
|
* - type : 'index' | 'unique'
|
|
* - table : unprefixed table name
|
|
* - title : human label for the UI
|
|
* - columns : ordered column definition — [['name' => 'col', 'sub_part' =>
|
|
* 50?], ...]. Used both to build the ADD statement
|
|
* (buildColumnSql) and to verify the live index matches
|
|
* (indexColumnsMatch), so health and creation share one spec.
|
|
* - cleanup : (unique only) which dedicated cleanup method makes the table
|
|
* safe for the key — 'subscriber_pivot' | 'funnel_subscribers'
|
|
* | 'funnel_metrics'.
|
|
*
|
|
* Every index is created via alterWithOnlineFallback(), which prefers a
|
|
* non-blocking online ALTER and falls back to a plain one, so no per-index
|
|
* online flag is needed.
|
|
*
|
|
* @return array<string, array>
|
|
*/
|
|
private static function getCriticalIndexLists()
|
|
{
|
|
global $wpdb;
|
|
// The campaign-emails composite index is created with the table prefix
|
|
// baked into its name (see CampaignEmails migration), so we match that.
|
|
$campaignEmailIndexPrefix = $wpdb->prefix . 'fc_cam_';
|
|
|
|
$indexActions = [
|
|
'subscriber_object_type_unique' => [
|
|
'type' => 'unique',
|
|
'table' => 'fc_subscriber_pivot',
|
|
'title' => __('Subscriber Tags / Lists Relationship Uniqueness', 'fluent-crm'),
|
|
'columns' => [
|
|
['name' => 'subscriber_id'],
|
|
['name' => 'object_id'],
|
|
['name' => 'object_type', 'sub_part' => 50],
|
|
],
|
|
'cleanup' => 'subscriber_pivot',
|
|
],
|
|
$campaignEmailIndexPrefix . 'cid_status' => [
|
|
'type' => 'index',
|
|
'table' => 'fc_campaign_emails',
|
|
'title' => __('Campaign Emails Sending Index', 'fluent-crm'),
|
|
'columns' => [
|
|
['name' => 'campaign_id'],
|
|
['name' => 'status'],
|
|
],
|
|
],
|
|
'funnel_subscriber_idx' => [
|
|
'type' => 'unique',
|
|
'table' => 'fc_funnel_subscribers',
|
|
'title' => __('Per Automation Subscriber Uniqueness', 'fluent-crm'),
|
|
'columns' => [
|
|
['name' => 'funnel_id'],
|
|
['name' => 'subscriber_id'],
|
|
],
|
|
'cleanup' => 'funnel_subscribers',
|
|
],
|
|
'status_next_exec_idx' => [
|
|
'type' => 'index',
|
|
'table' => 'fc_funnel_subscribers',
|
|
'title' => __('Automation Runner Performance Indexing', 'fluent-crm'),
|
|
'columns' => [
|
|
['name' => 'status'],
|
|
['name' => 'next_execution_time'],
|
|
],
|
|
],
|
|
'type_action_name_idx' => [
|
|
'type' => 'index',
|
|
'table' => 'fc_funnel_sequences',
|
|
'title' => __('Automation Goal Quick Lookup Indexing', 'fluent-crm'),
|
|
'columns' => [
|
|
['name' => 'type'],
|
|
['name' => 'action_name'],
|
|
],
|
|
],
|
|
'funnel_seq_subscriber_unique' => [
|
|
'type' => 'unique',
|
|
'table' => 'fc_funnel_metrics',
|
|
'title' => __('Automation Subscriber Metrics Uniqueness', 'fluent-crm'),
|
|
'columns' => [
|
|
['name' => 'funnel_id'],
|
|
['name' => 'sequence_id'],
|
|
['name' => 'subscriber_id'],
|
|
],
|
|
'cleanup' => 'funnel_metrics',
|
|
],
|
|
];
|
|
|
|
return $indexActions;
|
|
}
|
|
}
|