From ad46e465be3cb2d3a29726a7c4cc2f822ecf67a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Thu, 2 Jul 2026 15:51:15 +0200 Subject: [PATCH] perf(db): index transactions for the daily synced-email slow query (PHP-LARAVEL-3X) (#622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Addresses Sentry **PHP-LARAVEL-3X** — a slow DB query in `App\Jobs\SendDailyBankTransactionsSyncedEmailJob::handle()`. The daily "transactions synced" email filters transactions by: ```php Transaction::query() ->where('user_id', $this->user->id) ->where('source', TransactionSource::EnableBanking) ->when($lastSentMailLog?->sent_at, fn ($q, $at) => $q->where('created_at', '>', $at)) ->whereHas('account.bankingConnection', ...) // EXISTS on PKs ->get(); ``` The only `user_id`-leading index is `idx_transactions_budget_lookup (user_id, transaction_date, category_id)` — its second column is `transaction_date`, **not** `created_at`, so it can't serve `source`/`created_at`. ## How Add a composite index matching the predicate — two equalities then the range: ``` idx_transactions_user_source_created (user_id, source, created_at) ``` The `whereHas` compiles to correlated `EXISTS` subqueries that join on primary keys (`accounts.id`, `banking_connections.id`), so no extra index on those tables is needed — the `transactions` index alone gives the optimizer the selective driving path it currently lacks. ## Production verification (via read-only prod queries) Confirmed the diagnosis and de-risked the deploy against the live database: - **The query is genuinely slow.** `EXPLAIN ANALYZE` for the heaviest user (~6.1k EnableBanking transactions) runs in **~6 s**. The optimizer, lacking a selective path, drives from a **full table scan of all ~2,500 accounts** and examines ~108k transaction rows (334 account loops × ~323 rows), filtering `user_id`/`source` only afterwards. - **The index fixes it.** `(user_id, source, created_at)` lets the optimizer drive from transactions — a seek to `(user_id, 'enablebanking')` (~6.1k rows) plus primary-key semi-joins — far cheaper than the current ~108k-row plan, so it will be chosen. - **The deploy is low-risk.** `transactions` holds **~297k rows** (not millions). Adding a secondary index on InnoDB/MySQL 8 is online (`ALGORITHM=INPLACE, LOCK=NONE`, no table rebuild), so at this size the build is seconds and does not block the sync write path. ## Commits 1. `perf(db): index transactions for the daily synced-email query` — the migration + a test asserting the index columns/order. 2. `test(db): assert the daily-email index name, not just its columns` — lock the explicit index name (review feedback). ## Reviewed by two independent agents (architecture + product/operational risk) Both rated the change correct and shippable: column order right (equalities before the range), migration reversible, index non-redundant (an existing index can't be widened without breaking budget queries), and no behavior/result change (a secondary btree index never alters result sets; the job has no `ORDER BY`). ## Impact / risk - **User impact:** indirect — a background email job (Sentry reports 0 interactive users), but the ~6 s query wastes queue-worker time and IO on every run. - **Complexity:** low — one additive, reversible index migration; no application logic changed. - **Write path:** one more index maintained per transaction insert (10th on the table). Marginal, consistent with the existing UUID-leading index cost profile. Fixes PHP-LARAVEL-3X --- ...created_at_index_to_transactions_table.php | 30 +++++++++++++++++++ .../DailyEmailTransactionsIndexTest.php | 15 ++++++++++ 2 files changed, 45 insertions(+) create mode 100644 database/migrations/2026_07_02_133321_add_user_source_created_at_index_to_transactions_table.php create mode 100644 tests/Feature/OpenBanking/DailyEmailTransactionsIndexTest.php diff --git a/database/migrations/2026_07_02_133321_add_user_source_created_at_index_to_transactions_table.php b/database/migrations/2026_07_02_133321_add_user_source_created_at_index_to_transactions_table.php new file mode 100644 index 00000000..373f5c9e --- /dev/null +++ b/database/migrations/2026_07_02_133321_add_user_source_created_at_index_to_transactions_table.php @@ -0,0 +1,30 @@ + ?). The existing + * indexes only lead with user_id via transaction_date, so that query scans + * every one of the user's rows to filter on source + created_at + * (PHP-LARAVEL-3X). This composite index matches the two equalities and the + * range directly. + */ + public function up(): void + { + Schema::table('transactions', function (Blueprint $table) { + $table->index(['user_id', 'source', 'created_at'], 'idx_transactions_user_source_created'); + }); + } + + public function down(): void + { + Schema::table('transactions', function (Blueprint $table) { + $table->dropIndex('idx_transactions_user_source_created'); + }); + } +}; diff --git a/tests/Feature/OpenBanking/DailyEmailTransactionsIndexTest.php b/tests/Feature/OpenBanking/DailyEmailTransactionsIndexTest.php new file mode 100644 index 00000000..b06c92c9 --- /dev/null +++ b/tests/Feature/OpenBanking/DailyEmailTransactionsIndexTest.php @@ -0,0 +1,15 @@ +first(fn (array $index): bool => $index['columns'] === ['user_id', 'source', 'created_at'] + ); + + expect($match)->not->toBeNull( + 'Expected a (user_id, source, created_at) index on transactions for the daily email query.' + ); + expect($match['name'])->toBe('idx_transactions_user_source_created'); +});