perf(db): index transactions for the daily synced-email slow query (PHP-LARAVEL-3X) (#622)

## 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
This commit is contained in:
Víctor Falcón 2026-07-02 15:51:15 +02:00 committed by GitHub
parent 84bad76316
commit ad46e465be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 45 additions and 0 deletions

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Support the daily "transactions synced" email query, which filters
* transactions by (user_id = ?, source = ?, created_at > ?). 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');
});
}
};

View File

@ -0,0 +1,15 @@
<?php
use Illuminate\Support\Facades\Schema;
it('has a composite index supporting the daily synced-email transaction query', function () {
$indexes = collect(Schema::getIndexes('transactions'));
$match = $indexes->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');
});