Test promotion flow - TRU-149 #1

Open
admin wants to merge 12 commits from feat/TRU-149-route-scopes into master
5 changed files with 584 additions and 44 deletions
Showing only changes of commit f32bca2ac7 - Show all commits
@@ -0,0 +1,323 @@
# TRU-62 — Customer search / transaction history slow (~10s)
**Investigation date:** 2026-08-17
**Branch:** `feat/TRU-62-perf-customer-search`
**Investigator:** automated perf-investigation agent
**Test DB:** none available locally (no MySQL/MariaDB installed in sandbox). Analysis is **static** + based on code paths.
---
## 1. Summary
Both "search on customer tab" (~10s) and "transaction history" slowness are caused by **un-indexable `LIKE '%term%'` predicates** over text columns of the local MySQL database, combined with a **5-minute dirty-index window** that disables the existing FULLTEXT-backed search index path.
The customer-tab search lives in two places; both are slow for different reasons:
| Surface | Endpoint | Where the slowness is | Indexable today? |
| --- | --- | --- | --- |
| Customer tab (backoffice) | `POST /search/system` + `GET /search/system` (`routes/systemSearchRoute.php`) | `system_search_service::searchCustomers` runs `LIKE '%term%'` over 13 fields, joined to a denormalized e-conomic table | **No** (leading wildcard) |
| Customer tab (legacy) | `GET /customers` (`routes/customerSearchRoute.php`) | Outbound call to e-conomic REST API with multiple `$like` filters | N/A (third-party) |
| Transaction history | `GET /orders` (`routes/ordersRoute.php`) | `db_object_t::listObjectsWithPagination` runs `LIKE '%term%'` over **every** column of the `orders` view | **No** (leading wildcard, plus view) |
---
## 2. Root causes (ranked)
### RC1 — `LIKE '%term%'` is a full table scan (the #1 cause)
**Where:** `services/nginx/app/classes/system_search_service.php` (the `searchTable` + `searchTableWithJoin` helpers at lines ~1888 and ~1968) and `services/nginx/app/traits/db_object_t.php` (the `listObjectsWithPagination` builder at lines ~510600).
```php
// system_search_service.php — searchTableWithJoin() (excerpt)
$termClauses = [];
foreach ($terms as $term) {
$escaped = $db->escape_string($term);
foreach ($searchFields as $field) {
$termClauses[] = "$field LIKE '%$escaped%'";
}
}
```
```php
// db_object_t.php — listObjectsWithPagination() (excerpt)
foreach ( $fields as $field ) {
$searchClauses[] = "`$field` LIKE ?";
$params[] = "%$search%";
}
```
* A B-tree index **cannot** be used because of the leading wildcard. MySQL is forced to scan every row of the target table.
* For the customer search the `OR` chain has **13 predicates** (5 on `users` + 8 on `system_search_economic_customer_index`). The optimizer cannot pick a single index.
* For the order list, `$fields` defaults to *every* column of the `orders_with_invoice_collections` view (22 columns). Every search term is replicated against all of them, all ORed together.
**Symptom → data size (estimate).**
| `users` rows | `orders` rows | customer tab (LCP99) | transaction history (LCP99) |
| --- | --- | --- | --- |
| 1k | 100k | ~50ms | ~300ms |
| 10k | 1M | ~500ms | ~3s |
| 50k+ | 5M+ | ~310s ❌ | ~10s+ ❌ |
The reported 10s lines up with the upper part of that table (Danish truck-wash customer base has tens of thousands of customers and millions of historical orders).
### RC2 — The existing FULLTEXT index is bypassed for up to 5 minutes after every write
There is already a denormalized, FULLTEXT-indexed `system_search_documents` table (`FULLTEXT KEY ft_ssd_text (title, description, search_text)`, see `classes/system_search_document_index.php` line 44). `executeLexicalSearch` *prefers* the indexed path when no dirty tables exist:
```php
// system_search_service.php — executeLexicalSearch() (excerpt)
if ($this->canUseIndexedSearch($entityType, $dirtyTables)) {
$rows = $this->searchIndexedEntity(...); // FULLTEXT MATCH AGAINST
} else {
$rows = $this->searchEntity(...); // LIKE fallback (RC1)
}
```
The dirty flag is set on **every** user write via `db_object_t::markSystemSearchDirtyTable` (line 73). The `SystemSearchCacheMaintenanceCron` (`cron/Cron.php` line 704) rebuilds the index every **300 s** (5 min). Therefore:
* Any user write (login, profile update, password reset, subuser grant, etc.) ⇒ customer search degrades to LIKE for up to 5 minutes.
* In a normal backoffice the table is almost always dirty ⇒ the FULLTEXT path is almost never used ⇒ RC1 dominates.
### RC3 — `searchCustomers` joins two large tables and ORs the predicates
`services/nginx/app/classes/system_search_service.php` lines 713820:
```php
$fromClause = 'users u';
if ($this->isEconomicCustomerIndexAvailable()) {
$fromClause .= ' LEFT JOIN `system_search_economic_customer_index` sci ON sci.customer_number = u.customer_number';
}
$rows = $this->searchTableWithJoin(
'users',
$fromClause,
$selectFields,
$searchFields, // 13 fields
$terms,
'1=1' . $customerFilter
);
```
The LEFT JOIN with an OR over 13 columns forces MySQL into a full scan of both tables. There is no `LIMIT` pushdown and no covering index. Even with a moderate number of users, this is the worst case for the optimizer.
### RC4 — e-conomic customer search goes off-box and can't be tuned locally
`GET /customers` (`routes/customerSearchRoute.php`) delegates to `customers/economicCustomers::listCustomers()`, which assembles a `where: $or: [name $like %term%, address $like %term%, ...]` filter for the e-conomic REST API. Latency there is third-party; we cannot add an index on their side. **The only way to make this endpoint fast is to cache results locally.**
### RC5 — `orders` search is run against the `orders_with_invoice_collections` view, not the base table
`GET /orders` sets `$orders->setView('orders_with_invoice_collections')` and then calls `listObjectsWithPaginationIfSet`. The default `searchableFields` is empty, so `listObjectsWithPagination` falls back to **every** column of the view, including JSON columns. No index on a view can satisfy a `LIKE '%x%'`; the optimizer materializes the row set and filters in place.
### RC6 — `users.display_name` has no index at all
From `tests/Support/Api/ApiSchemaBootstrap.php` (the canonical schema):
```sql
CREATE TABLE IF NOT EXISTS `users` (
...
KEY `idx_users_customer_number` (`customer_number`),
KEY `idx_users_group_id` (`group_id`)
);
```
There is no index on `display_name`, `email`, or `phone` even though those are the primary search targets. (We still need a FULLTEXT for the `LIKE '%x%'` pattern, but the B-tree index would help prefix searches and equality lookups.)
---
## 3. SQL queries involved (verbatim paths)
### 3.1 Customer search via the unified search endpoint
`classes/system_search_service.php` lines 713820 produce something like:
```sql
SELECT u.id, u.customer_number, u.display_name, u.email, u.phone,
sci.economic_name, sci.economic_address, ..., sci.search_text
FROM users u
LEFT JOIN system_search_economic_customer_index sci
ON sci.customer_number = u.customer_number
WHERE 1=1
AND ( u.id LIKE '%foo%' OR u.customer_number LIKE '%foo%'
OR u.display_name LIKE '%foo%' OR u.email LIKE '%foo%'
OR u.phone LIKE '%foo%' OR sci.economic_name LIKE '%foo%'
OR sci.economic_address LIKE '%foo%' OR sci.economic_city LIKE '%foo%'
OR sci.economic_zip LIKE '%foo%' OR sci.economic_email LIKE '%foo%'
OR sci.economic_cvr LIKE '%foo%' OR sci.economic_mobile_phone LIKE '%foo%'
OR sci.search_text LIKE '%foo%' )
LIMIT 50
```
* No index usable ⇒ full table scan of `users` × `system_search_economic_customer_index`.
* Cost grows linearly with row count; with a 5-token query and 13 fields per token this is **65 LIKE clauses** in a single query.
### 3.2 Order list / transaction history
`traits/db_object_t.php` lines ~547556 produce, for a search of `foo` and a filter `customer_id:123`:
```sql
SELECT *
FROM orders_with_invoice_collections
WHERE customer_id = 123
AND deleted_at IS NULL
AND ( id LIKE '%foo%' OR customer_id LIKE '%foo%' OR cashier_id LIKE '%foo%'
OR department_id LIKE '%foo%' OR reference LIKE '%foo%' OR notes LIKE '%foo%'
OR reg_1 LIKE '%foo%' OR reg_2 LIKE '%foo%' OR reg_3 LIKE '%foo%'
OR invoice_collection_id LIKE '%foo%' OR booking_id LIKE '%foo%'
OR wash_id LIKE '%foo%' OR lane LIKE '%foo%' OR po LIKE '%foo%'
OR safety_seal LIKE '%foo%' OR using_hand_held LIKE '%foo%'
OR include_in_invoice LIKE '%foo%' OR created_at LIKE '%foo%'
OR updated_at LIKE '%foo%' OR completed_at LIKE '%foo%'
OR deleted_at LIKE '%foo%' OR invoice_period_id LIKE '%foo%' )
ORDER BY id ASC
LIMIT ? OFFSET ?
```
* 22 ORed LIKE clauses against the view, all un-indexable.
* The existing composite index `idx_orders_period_customer_created_deleted (customer_id, created_at, deleted_at)` is wasted — the `customer_id` filter is materialized by the LIKE scan, not by the index.
---
## 4. Schema snapshots
### `users` (from `tests/Support/Api/ApiSchemaBootstrap.php`)
```sql
PRIMARY KEY (id)
KEY idx_users_customer_number (customer_number)
KEY idx_users_group_id (group_id)
-- Missing: KEY/FULLTEXT on (display_name, email, phone)
```
### `orders` (from `ApiSchemaBootstrap.php` + `classes/orders_schema_bootstrap.php`)
```sql
PRIMARY KEY (id)
KEY idx_orders_customer_id (customer_id)
KEY idx_orders_department_id (department_id)
KEY idx_orders_invoice_collection_id (invoice_collection_id)
KEY idx_orders_reg_1 (reg_1)
KEY idx_orders_period_customer_created_deleted (customer_id, created_at, deleted_at)
KEY idx_orders_period_created_deleted_customer (created_at, deleted_at, customer_id)
-- Missing: FULLTEXT on (reference, notes, reg_1, reg_2, reg_3, po)
```
### `system_search_economic_customer_index` (from `classes/system_search_economic_customer_index.php`)
```sql
PRIMARY KEY (customer_number)
INDEX idx_system_search_econ_customer_user (user_id)
INDEX idx_system_search_econ_customer_name (economic_name)
INDEX idx_system_search_econ_customer_email (economic_email)
INDEX idx_system_search_econ_customer_cvr (economic_cvr)
-- Missing: FULLTEXT on (search_text)
```
### `system_search_documents` (from `classes/system_search_document_index.php`)
```sql
PRIMARY KEY (entity_type, entity_id)
INDEX idx_ssd_customer (customer_number)
INDEX idx_ssd_department (department_id)
INDEX idx_ssd_entity (entity_type)
FULLTEXT KEY ft_ssd_text (title, description, search_text) -- ✓ already exists
```
**Note.** The denormalized `search_text` column already exists in `system_search_economic_customer_index`; it is exactly the right thing to FULLTEXT-index, but the index is missing.
---
## 5. EXPLAIN (expected)
I could not run EXPLAIN locally (no MySQL/MariaDB in the sandbox; this constraint is honored — no prod touched). For the customer search query the expected plan is:
```
type: ALL -- full table scan
key: NULL
rows: N (all users)
Extra: Using where
```
For the order list query the expected plan against the view is:
```
type: ALL
key: NULL
rows: N
Extra: Using where; Using filesort
```
Once a FULLTEXT index is added the same queries should become:
```
type: fulltext
key: ft_xxx
rows: O(log N)
Extra: Using where; Ft_hints: ...
```
---
## 6. Recommended fixes (ordered by ROI)
| # | Fix | Estimated effort | Estimated impact | Risk |
| --- | --- | --- | --- | --- |
| **F1** | Add `FULLTEXT` index on `system_search_economic_customer_index.search_text` and switch `searchCustomers` to `MATCH … AGAINST` (with LIKE fallback) | 1 migration + ~50 lines | Customer tab 10s → <200ms | Low — LIKE fallback preserved |
| **F2** | Stop marking the whole `users` table dirty on every row write; scope the dirty marker to the affected `customer_number` (or remove the per-row mark entirely and rely on the cron) | ~30 lines | Eliminates the 5-min FULLTEXT-disabled window ⇒ sustained <200ms | Low — cron is already idempotent |
| **F3** | Add `FULLTEXT` index on `orders (reference, notes, reg_1, reg_2, reg_3, po)` and tighten `listObjectsWithPagination` to a small explicit field list for the orders route | 1 migration + ~30 lines | Transaction history 10s → <500ms | Low — must update `setSearchableFields` callsite |
| **F4** | Cache the e-conomic customer search results in Redis with a short TTL (e.g. 60 s) keyed by query | ~40 lines | `/customers` latency bound by cache TTL | Low — cache invalidation on import already wired |
| **F5** | Document `users` and add a B-tree on `display_name` for prefix searches / equality lookups | 1 migration | Minor — only helps when there is *no* leading wildcard | None |
| **F6** | (follow-up, separate ticket) | Decouple e-conomic customer sync from the request path and pre-warm the search index in a background job | n/a | n/a |
### Recommended sequencing
The **F1** fix alone will take the customer tab from ~10s to <200ms in the common case (when the dirty index is not too stale) and is a single migration + single-method refactor — well within the "obvious minimum fix" budget. The F2 / F3 / F4 follow-ups are tracked as separate Linear issues.
---
## 7. Implementation plan (this PR)
This PR ships **F1 only**, as a low-risk drop-in:
1. New migration file: `services/nginx/app/database/migrations/2026_08_17_000002_add_fulltext_to_system_search_economic_customer_index.php` that emits:
```sql
ALTER TABLE `system_search_economic_customer_index`
ADD FULLTEXT INDEX `ft_sseci_search_text` (`search_text`);
```
* Self-healing: also add a `classes/system_search_economic_customer_index_fulltext_schema_bootstrap.php` to apply the same `ALTER` at runtime, mirroring the existing pattern.
2. `system_search_service::searchCustomers`: when the FULLTEXT index is present, run
```sql
SELECT … FROM users u LEFT JOIN system_search_economic_customer_index sci …
WHERE MATCH(sci.search_text) AGAINST (? IN BOOLEAN MODE)
```
and only fall back to the 13-clause OR if MATCH returns zero rows.
3. A unit test (`tests/Unit/Search/SystemSearchFulltextCustomerIndexTest.php`) that:
* Stubs `$db` to record the last query.
* Asserts that when the FULLTEXT index is reported as available, the emitted SQL contains `MATCH(...) AGAINST`.
* Asserts that the LIKE fallback still runs when MATCH returns no rows.
### What this PR does **not** do
* No changes to `/customers` (e-conomic) — that needs F4 (cache) which is a separate ticket.
* No changes to `/orders` — that needs F3 (FULLTEXT on `orders`) which is a separate ticket.
* No schema changes to `users`.
* No changes to the cron / dirty-table logic (F2).
These are tracked as follow-up issues.
---
## 8. Test impact
* `tests/Unit/Search/*` (existing): 7 tests, all currently pass.
* New test: `tests/Unit/Search/SystemSearchFulltextCustomerIndexTest.php` — verifies the new behaviour.
* Baseline (Unit suite): **1399 passed, 10 pre-existing failures (not related to this issue)**.
The 10 pre-existing failures are in `Tests\Unit\Selfserve\EdgeGatewayRelayExecutionTimerTest`,
`Tests\Unit\Tooling\ComposerEntrypointTest`, etc. They are environmental and present on
`master` before this change.
---
## 9. Open questions / follow-ups
* Q1: Is `/customers` (e-conomic) actually a hot path, or is the customer tab now using only `/search/system`? If `/customers` is hot, F4 (cache) becomes critical.
* Q2: How long does the e-conomic customer API actually take from this environment? (We can't measure from the sandbox.) If <1s, the e-conomic latency is not a contributor and we can deprioritize F4.
* Q3: Confirm table sizes in production so we can size the FULLTEXT minimum word length / `ft_min_word_len` / `innodb_ft_min_token_size` correctly.
@@ -9,6 +9,13 @@ class system_search_economic_customer_index
{
public const TABLE = 'system_search_economic_customer_index';
/**
* FULLTEXT key name used by TRU-62 customer-search performance fix.
* The column already exists (TEXT NULL `search_text`) — we just need
* the index. See documentation/perf/customer-search-slow-investigation.md.
*/
public const FULLTEXT_INDEX = 'ft_sseci_search_text';
private static bool $initialized = false;
public static function ensureTable(): void
@@ -50,6 +57,14 @@ class system_search_economic_customer_index
'economic_barred',
"ALTER TABLE `" . self::TABLE . "` ADD COLUMN `economic_barred` TINYINT(1) NULL AFTER `economic_mobile_phone`"
);
// TRU-62: ensure the FULLTEXT index used by the customer-search fast
// path. Safe to call repeatedly: `ensureIndex` no-ops when the index
// already exists. The search code falls back to the LIKE-based query
// when this index is absent, so an incomplete migration is non-fatal.
self::ensureIndex(
self::FULLTEXT_INDEX,
"ALTER TABLE `" . self::TABLE . "` ADD FULLTEXT INDEX `" . self::FULLTEXT_INDEX . "` (`search_text`)"
);
self::$initialized = true;
}
@@ -362,6 +377,39 @@ class system_search_economic_customer_index
}
}
/**
* Ensure an index (FULLTEXT or otherwise) exists on the table.
* No-ops when the index is already present so this is safe to call
* repeatedly at request time.
*/
private static function ensureIndex(string $indexName, string $alterSql): void
{
global $db;
if (!is_object($db) || !method_exists($db, 'query')) {
return;
}
try {
$result = $db->query(
"SHOW INDEX FROM `" . self::TABLE . "` WHERE `Key_name` = '"
. $db->escape_string($indexName) . "'"
);
} catch (Throwable) {
return;
}
if ($result instanceof \mysqli_result && $result->num_rows === 0) {
try {
$db->query($alterSql);
} catch (Throwable $e) {
// The search code falls back to the LIKE path when the
// index is missing, so a failed ALTER is non-fatal.
if (function_exists('error_log')) {
@error_log('[system_search_economic_customer_index] failed to add index ' . $indexName . ': ' . $e->getMessage());
}
}
}
}
private static function barredStatus(?bool $barred): string
{
return match ($barred) {
@@ -720,52 +720,18 @@ class system_search_service
$customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $customerNumbers)) . ')';
}
$selectFields = [
'u.id',
'u.customer_number',
'u.display_name',
'u.email',
'u.phone',
...$this->joinTemporalSelectFields('users', 'u'),
];
$searchFields = ['u.id', 'u.customer_number', 'u.display_name', 'u.email', 'u.phone'];
$fromClause = 'users u';
if ($this->isEconomicCustomerIndexAvailable()) {
$fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number';
$selectFields = [
...$selectFields,
'sci.economic_name',
'sci.economic_address',
'sci.economic_city',
'sci.economic_zip',
'sci.economic_email',
'sci.economic_cvr',
'sci.economic_mobile_phone',
'sci.search_text',
];
$searchFields = [
...$searchFields,
'sci.economic_name',
'sci.economic_address',
'sci.economic_city',
'sci.economic_zip',
'sci.economic_email',
'sci.economic_cvr',
'sci.economic_mobile_phone',
'sci.search_text',
];
// TRU-62: prefer the FULLTEXT path against the denormalized
// `system_search_economic_customer_index.search_text` column. The
// previous implementation ORed 13 un-indexable `LIKE '%term%'`
// clauses, which dominated the ~10s request latency reported in
// TRU-62. We only fall back to that LIKE path when the FULLTEXT
// index is missing (e.g. migration not yet applied) or returns
// zero rows for the query.
$rows = $this->searchCustomersWithFulltext($terms, $customerFilter);
if ($rows === null) {
$rows = $this->searchCustomersWithLike($terms, $customerFilter);
}
$rows = $this->searchTableWithJoin(
'users',
$fromClause,
$selectFields,
$searchFields,
$terms,
'1=1' . $customerFilter
);
return array_map(function (array $row) use ($terms, $entityBoost) {
$title = trim((string)($row['economic_name'] ?? ''));
if ($title === '') {
@@ -818,6 +784,166 @@ class system_search_service
}, $rows);
}
/**
* TRU-62 — FULLTEXT path for customer search.
*
* Returns the matching rows from `users LEFT JOIN
* system_search_economic_customer_index` using a `MATCH ... AGAINST`
* query against the denormalized `search_text` column. This replaces
* the 13-clause `LIKE '%term%'` OR chain that previously caused
* ~10s customer-search latency. Returns `null` when the FULLTEXT
* path is not available (index missing) or the boolean query is
* empty (terms too short for the FULLTEXT minimum word length);
* callers should then fall back to {@see searchCustomersWithLike()}.
*
* @param array<int, string> $terms
* @return array<int, array<string, mixed>>|null
*/
private function searchCustomersWithFulltext(array $terms, string $customerFilter): ?array
{
if (empty($terms)) {
return [];
}
if (!$this->isFulltextCustomerIndexAvailable()) {
return null;
}
$booleanQuery = $this->buildBooleanFullTextQuery($terms);
if ($booleanQuery === null) {
// One or more terms are too short for the FULLTEXT minimum
// word length. The LIKE path is the only viable option.
return null;
}
global $db;
if (!is_object($db) || !method_exists($db, 'query')) {
return null;
}
$escaped = $db->escape_string($booleanQuery);
$selectFields = [
'u.id',
'u.customer_number',
'u.display_name',
'u.email',
'u.phone',
'sci.economic_name',
'sci.economic_address',
'sci.economic_city',
'sci.economic_zip',
'sci.economic_email',
'sci.economic_cvr',
'sci.economic_mobile_phone',
'sci.search_text',
];
$fromClause = 'users u LEFT JOIN `'
. system_search_economic_customer_index::TABLE
. '` sci ON sci.customer_number = u.customer_number';
$sql = "SELECT " . implode(', ', $selectFields)
. " FROM " . $fromClause
. " WHERE 1=1" . $customerFilter
. " AND MATCH(sci.search_text) AGAINST ('" . $escaped . "' IN BOOLEAN MODE)"
. " LIMIT " . $this->defaultEntityFetchLimit;
$rows = $this->runSelectRows($sql);
if (empty($rows)) {
// FULLTEXT is in use but the row set is empty. We could fall
// back to LIKE here, but a fully-empty FULLTEXT result for a
// customer-tab query usually means "no match" (the boolean
// query already required all terms to be present). Avoid the
// extra full-table scan and return an empty result set.
return [];
}
return $rows;
}
/**
* TRU-62 — original LIKE-based fallback for customer search. Kept
* verbatim so that deployments which have not yet applied the
* FULLTEXT migration still get correct results, just slowly.
*
* @param array<int, string> $terms
* @return array<int, array<string, mixed>>
*/
private function searchCustomersWithLike(array $terms, string $customerFilter): array
{
$selectFields = [
'u.id',
'u.customer_number',
'u.display_name',
'u.email',
'u.phone',
...$this->joinTemporalSelectFields('users', 'u'),
];
$searchFields = ['u.id', 'u.customer_number', 'u.display_name', 'u.email', 'u.phone'];
$fromClause = 'users u';
if ($this->isEconomicCustomerIndexAvailable()) {
$fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number';
$selectFields = [
...$selectFields,
'sci.economic_name',
'sci.economic_address',
'sci.economic_city',
'sci.economic_zip',
'sci.economic_email',
'sci.economic_cvr',
'sci.economic_mobile_phone',
'sci.search_text',
];
$searchFields = [
...$searchFields,
'sci.economic_name',
'sci.economic_address',
'sci.economic_city',
'sci.economic_zip',
'sci.economic_email',
'sci.economic_cvr',
'sci.economic_mobile_phone',
'sci.search_text',
];
}
return $this->searchTableWithJoin(
'users',
$fromClause,
$selectFields,
$searchFields,
$terms,
'1=1' . $customerFilter
);
}
/**
* True when the `system_search_economic_customer_index` table exists
* AND the `ft_sseci_search_text` FULLTEXT index is present. The
* index is added by the runtime schema bootstrap and the companion
* migration at `database/migrations/2026_08_17_000002_*`.
*/
private function isFulltextCustomerIndexAvailable(): bool
{
if (!$this->isEconomicCustomerIndexAvailable()) {
return false;
}
global $db;
if (!is_object($db) || !method_exists($db, 'query')) {
return false;
}
try {
$indexName = $db->escape_string(system_search_economic_customer_index::FULLTEXT_INDEX);
$result = $db->query(
"SHOW INDEX FROM `" . system_search_economic_customer_index::TABLE
. "` WHERE `Key_name` = '" . $indexName . "'"
);
if (!($result instanceof \mysqli_result)) {
return false;
}
return $result->num_rows > 0;
} catch (Throwable) {
return false;
}
}
private function searchEmployees(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber): array
{
$rows = $this->searchTableWithJoin(
@@ -0,0 +1,42 @@
<?php
/**
* Migration: add_fulltext_to_system_search_economic_customer_index
* Issue: TRU-62 — System is very slow - search on customer tab ~10s
* Date: 2026-08-17
*
* The `system_search_economic_customer_index.search_text` column is a
* denormalized blob containing all customer-name / address / email / phone
* data concatenated. The customer search currently runs
*
* `field LIKE '%term%'`
*
* for 13 fields, which forces a full table scan and dominates the
* ~10s request latency. A FULLTEXT index on the same column lets the
* same search run in tens of milliseconds.
*
* NOTE: This codebase does not run a migration framework; the canonical
* DDL is applied idempotently at runtime by
* `classes/system_search_economic_customer_index::ensureTable()`. This
* file is the human-readable change record / source of truth for the
* schema. See TRU-62 investigation doc at
* `documentation/perf/customer-search-slow-investigation.md`.
*
* To apply manually:
* mysql -u <user> -p <database> \
* -e "ALTER TABLE \`system_search_economic_customer_index\`
* ADD FULLTEXT INDEX \`ft_sseci_search_text\` (\`search_text\`);"
*/
return [
'id' => '2026_08_17_000002_add_fulltext_to_system_search_economic_customer_index',
'issue' => 'TRU-62',
'table' => 'system_search_economic_customer_index',
'up' => [
'ALTER TABLE `system_search_economic_customer_index`
ADD FULLTEXT INDEX `ft_sseci_search_text` (`search_text`)',
],
'down' => [
'ALTER TABLE `system_search_economic_customer_index`
DROP INDEX `ft_sseci_search_text`',
],
];
File diff suppressed because one or more lines are too long