Index the per-parent child fetches and the mail queue loop (db 2.7.6)

This commit is contained in:
johnnyq
2026-08-28 00:11:22 -04:00
parent 8f884f789a
commit 27c5502de2
2 changed files with 100 additions and 9 deletions

View File

@@ -0,0 +1,81 @@
<?php
/*
* ITFlow - Database update to version 2.7.6 (from 2.7.5)
* Included by admin/database_updates.php - do not access directly
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
// Second indexing pass. 2.7.5 covered client scoping - the columns every
// list page and the client side nav filter on. This one covers the other
// two shapes that were still scanning whole tables:
//
// 1. "give me the child rows of this one parent" - the queries a detail
// page fires. Opening a ticket cost three full table scans
// (ticket_history, tasks, ticket_watchers) and opening an invoice cost
// two (invoice_items, history). ticket_replies and sla_history were
// already indexed by ticket, so this finishes that set.
//
// 2. the worker loop. cron/mail_queue.php runs
// WHERE email_status = 0 AND email_queued_at <= NOW() every minute -
// 1440 scans a day of a table that keeps sent rows for 90 days.
//
// Same shape rule as 2.7.5: the selective column leads and anything
// low-cardinality trails. email_status leads because pending is a small
// slice of a queue that is almost entirely sent rows, and
// notification_dismissed_at trails because almost every live row is NULL.
//
// history gets two single-column indexes rather than one composite: the
// table serves invoices and quotes from the same rows, and the two detail
// pages each filter on their own column alone.
//
// NOT included, deliberately, and the reasoning is worth keeping:
// - categories.category_type and tags.tag_type look like the busiest
// columns in the codebase, but both tables hold a few dozen rows. A
// scan in the buffer pool beats an index lookup.
// - ticket_status / invoice_status have five or six distinct values, so
// the optimizer scans regardless.
// - the join keys on the big side of a join (tickets.ticket_status and
// friends) never get looked up - the lookup lands on the small table's
// primary key.
// - payments/expenses/revenues .*_account_id are real but serve one
// screen, so they are not worth the write amplification yet.
$itflow_indexes = [
['email_queue', 'email_status', ['email_status', 'email_queued_at']],
['ticket_history', 'ticket_history_ticket_id', ['ticket_history_ticket_id']],
['tasks', 'task_ticket_id', ['task_ticket_id']],
['ticket_watchers', 'watcher_ticket_id', ['watcher_ticket_id']],
['invoice_items', 'item_invoice_id', ['item_invoice_id']],
['quote_items', 'item_quote_id', ['item_quote_id']],
['history', 'history_invoice_id', ['history_invoice_id']],
['history', 'history_quote_id', ['history_quote_id']],
['notifications', 'notification_user_id', ['notification_user_id', 'notification_dismissed_at']],
['records', 'record_domain_id', ['record_domain_id']],
];
foreach ($itflow_indexes as $itflow_index) {
list($itflow_index_table, $itflow_index_name, $itflow_index_columns) = $itflow_index;
// MySQL has no ADD INDEX IF NOT EXISTS, and re-adding one is an error
// rather than a no-op, so check first. This also makes the migration
// safe on an instance where someone added the index by hand.
$itflow_index_exists = mysqli_query($mysqli, "SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = '$itflow_index_table'
AND INDEX_NAME = '$itflow_index_name'
LIMIT 1");
if ($itflow_index_exists && mysqli_num_rows($itflow_index_exists) > 0) {
continue;
}
$itflow_index_column_list = '`' . implode('`, `', $itflow_index_columns) . '`';
mysqli_query($mysqli, "ALTER TABLE `$itflow_index_table`
ADD KEY `$itflow_index_name` ($itflow_index_column_list)");
}
unset($itflow_indexes, $itflow_index, $itflow_index_table, $itflow_index_name,
$itflow_index_columns, $itflow_index_exists, $itflow_index_column_list);

28
db.sql
View File

@@ -1282,7 +1282,8 @@ CREATE TABLE `email_queue` (
`email_failed_at` datetime DEFAULT NULL,
`email_attempts` tinyint(1) NOT NULL DEFAULT 0,
`email_sent_at` datetime DEFAULT NULL,
PRIMARY KEY (`email_id`)
PRIMARY KEY (`email_id`),
KEY `email_status` (`email_status`, `email_queued_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
@@ -1373,7 +1374,9 @@ CREATE TABLE `history` (
`history_invoice_id` int(11) NOT NULL DEFAULT 0,
`history_recurring_invoice_id` int(11) NOT NULL DEFAULT 0,
`history_quote_id` int(11) NOT NULL DEFAULT 0,
PRIMARY KEY (`history_id`)
PRIMARY KEY (`history_id`),
KEY `history_invoice_id` (`history_invoice_id`),
KEY `history_quote_id` (`history_quote_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
@@ -1400,7 +1403,8 @@ CREATE TABLE `invoice_items` (
`item_tax_id` int(11) NOT NULL DEFAULT 0,
`item_product_id` int(11) NOT NULL DEFAULT 0,
`item_invoice_id` int(11) NOT NULL DEFAULT 0,
PRIMARY KEY (`item_id`)
PRIMARY KEY (`item_id`),
KEY `item_invoice_id` (`item_invoice_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
@@ -1598,7 +1602,8 @@ CREATE TABLE `notifications` (
`notification_client_id` int(11) NOT NULL DEFAULT 0,
`notification_user_id` int(11) NOT NULL DEFAULT 0,
`notification_entity_id` int(11) DEFAULT 0,
PRIMARY KEY (`notification_id`)
PRIMARY KEY (`notification_id`),
KEY `notification_user_id` (`notification_user_id`, `notification_dismissed_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
@@ -1809,7 +1814,8 @@ CREATE TABLE `quote_items` (
`item_tax_id` int(11) NOT NULL DEFAULT 0,
`item_product_id` int(11) NOT NULL DEFAULT 0,
`item_quote_id` int(11) NOT NULL,
PRIMARY KEY (`item_id`)
PRIMARY KEY (`item_id`),
KEY `item_quote_id` (`item_quote_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
@@ -1912,7 +1918,8 @@ CREATE TABLE `records` (
`record_updated_at` datetime NOT NULL DEFAULT '0000-00-00 00:00:00' ON UPDATE current_timestamp(),
`record_archived_at` datetime DEFAULT NULL,
`record_domain_id` int(11) NOT NULL,
PRIMARY KEY (`record_id`)
PRIMARY KEY (`record_id`),
KEY `record_domain_id` (`record_domain_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
@@ -2743,7 +2750,8 @@ CREATE TABLE `tasks` (
`task_created_at` datetime NOT NULL DEFAULT current_timestamp(),
`task_updated_at` datetime DEFAULT NULL ON UPDATE current_timestamp(),
`task_ticket_id` int(11) DEFAULT NULL,
PRIMARY KEY (`task_id`)
PRIMARY KEY (`task_id`),
KEY `task_ticket_id` (`task_ticket_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
@@ -2814,7 +2822,8 @@ CREATE TABLE `ticket_history` (
`ticket_history_description` varchar(255) NOT NULL,
`ticket_history_created_at` datetime NOT NULL DEFAULT current_timestamp(),
`ticket_history_ticket_id` int(11) NOT NULL,
PRIMARY KEY (`ticket_history_id`)
PRIMARY KEY (`ticket_history_id`),
KEY `ticket_history_ticket_id` (`ticket_history_ticket_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
@@ -2906,7 +2915,8 @@ CREATE TABLE `ticket_watchers` (
`watcher_name` varchar(255) DEFAULT NULL,
`watcher_email` varchar(255) NOT NULL,
`watcher_ticket_id` int(11) NOT NULL,
PRIMARY KEY (`watcher_id`)
PRIMARY KEY (`watcher_id`),
KEY `watcher_ticket_id` (`watcher_ticket_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;