Transactions endpoint /transactions Performance Improvement #2452

Closed
adeyinkaezra123 wants to merge 2 commits from transactions-perf-improvement into main
adeyinkaezra123 commented 2025-07-10 00:10:21 +08:00 (Migrated from github.com)

Overview

This PR implements comprehensive performance optimizations for the API endpoints, focusing on eliminating N+1 queries, improving database query efficiency, and enhancing response times for transaction-related operations.

Performance Issues Addressed

1. N+1 Query Elimination

Problem: Transfer associations were causing N+1 queries when accessing transaction data through the API. This was initially raised here and also validated using the Prosopite Gem

Solution:

  • Added memoization to Transfer#to_account and Transfer#from_account methods
  • Implemented eager loading scope with_transfer_details in the Transaction model
  • Updated API controller to use optimized includes

2. Query Optimization in API Controller

Problem: API endpoints were not efficiently loading all necessary associations.

Solution: Updated includes to load all required associations in a single query.

Before:

transactions_query = transactions_query.includes(
  { entry: :account },
  :category, :merchant, :tags
)

After:

transactions_query = transactions_query.includes(
  { entry: :account },
  :category, :merchant, :tags,
  transfer_as_outflow: { inflow_transaction: { entry: :account } },
  transfer_as_inflow: { outflow_transaction: { entry: :account } }
)

Performance Improvements

Query Count Reduction

  • Before: 15-20 queries for 100 transactions with transfers
  • After: 8 queries for 100 transactions with transfers (60-70% improvement)

Response Time Improvement

🧊 Before Optimization

Type IPS Deviation Time/Iteration Iterations Total Time
COLD 0.673 ± 0.00% 1.49 s/i 1.000 1.486093 s
WARM 6.814 ±14.70% 146.76 ms/i 67 .000 10.126497 s

After Optimization

Type IPS Deviation Time/Iteration Iterations Total Time
COLD 1.047 ± 0.00% 955.49 ms/i 1 0.955490 s
WARM 10.094 ± 9.90% 99.07 ms/i 96 10.085795 s

Comparison Summary

Metric Before After Improvement
Cold Start Time 1.49 s 955 ms ~36% faster
Warm Iterations/s 6.814 10.094 ~48% more
Warm Time/Iter 146.76 ms 99.07 ms ~32% faster
Deviation (Warm) ±14.70% ±9.90% More consistent

Query Count Validation

  • Used ActiveRecord query logging to verify N+1 elimination
  • Confirmed all transfer associations load in single queries
  • Validated no regression in functionality

API Endpoint Testing

  • Tested /api/v1/transactions endpoint with various filters
  • Verified that pagination works correctly with optimized queries
  • Confirmed all transaction data loads correctly

Additional Optimizations

I'm also considering adding indexes to the Transfers and Entries tables to speed up association lookups that were also introducing significant latency to the endpoint

class AddTransferAndEntriesIndexes < ActiveRecord::Migration[7.2]
  def change
    # Index for transfer lookups by transaction IDs
    # This speeds up the transfer association lookups that were causing N+1 queries
    add_index :transfers, [ :inflow_transaction_id, :outflow_transaction_id ],
              name: 'index_transfers_on_transaction_ids'

    # Index for entry lookups by entryable (if not already present)
    # This speeds up the entry.account lookups in transfer associations
    add_index :entries, [ :entryable_id, :entryable_type ],
              name: 'index_entries_on_entryable'
  end
end
## Overview This PR implements comprehensive performance optimizations for the API endpoints, focusing on eliminating N+1 queries, improving database query efficiency, and enhancing response times for transaction-related operations. ## Performance Issues Addressed ### 1. N+1 Query Elimination **Problem**: Transfer associations were causing N+1 queries when accessing transaction data through the API. This was initially raised [here](https://oss.skylight.io/app/applications/XDpPIXEX52oi/recent/6h/endpoints/TransactionsController%23index?responseType=html) and also validated using the [Prosopite Gem](https://github.com/charkost/prosopite) **Solution**: - Added memoization to `Transfer#to_account` and `Transfer#from_account` methods - Implemented eager loading scope `with_transfer_details` in the Transaction model - Updated API controller to use optimized includes ### 2. Query Optimization in API Controller **Problem**: API endpoints were not efficiently loading all necessary associations. **Solution**: Updated includes to load all required associations in a single query. **Before**: ```ruby transactions_query = transactions_query.includes( { entry: :account }, :category, :merchant, :tags ) ``` **After**: ```ruby transactions_query = transactions_query.includes( { entry: :account }, :category, :merchant, :tags, transfer_as_outflow: { inflow_transaction: { entry: :account } }, transfer_as_inflow: { outflow_transaction: { entry: :account } } ) ``` ## Performance Improvements ### Query Count Reduction - **Before**: 15-20 queries for 100 transactions with transfers - **After**: 8 queries for 100 transactions with transfers (60-70% improvement) ### Response Time Improvement ### 🧊 Before Optimization | Type | IPS | Deviation | Time/Iteration | Iterations | Total Time | |------|-------|-----------|----------------|------------|-------------| | COLD | 0.673 | ± 0.00% | 1.49 s/i | 1.000 | 1.486093 s | | WARM | 6.814 | ±14.70% | 146.76 ms/i | 67 .000 | 10.126497 s | ### ⚡ After Optimization | Type | IPS | Deviation | Time/Iteration | Iterations | Total Time | |------|---------|-----------|----------------|------------|-------------| | COLD | 1.047 | ± 0.00% | 955.49 ms/i | 1 | 0.955490 s | | WARM | 10.094 | ± 9.90% | 99.07 ms/i | 96 | 10.085795 s | #### Comparison Summary | Metric | Before | After | Improvement | |--------------------|---------------|---------------|-------------------| | Cold Start Time | 1.49 s | 955 ms | ~36% faster | | Warm Iterations/s | 6.814 | 10.094 | ~48% more | | Warm Time/Iter | 146.76 ms | 99.07 ms | ~32% faster | | Deviation (Warm) | ±14.70% | ±9.90% | More consistent | ### Query Count Validation - Used ActiveRecord query logging to verify N+1 elimination - Confirmed all transfer associations load in single queries - Validated no regression in functionality ### API Endpoint Testing - [x] Tested `/api/v1/transactions` endpoint with various filters - [x] Verified that pagination works correctly with optimized queries - [x] Confirmed all transaction data loads correctly ### Additional Optimizations I'm also considering adding indexes to the `Transfers` and `Entries` tables to speed up association lookups that were also introducing significant latency to the endpoint ```ruby class AddTransferAndEntriesIndexes < ActiveRecord::Migration[7.2] def change # Index for transfer lookups by transaction IDs # This speeds up the transfer association lookups that were causing N+1 queries add_index :transfers, [ :inflow_transaction_id, :outflow_transaction_id ], name: 'index_transfers_on_transaction_ids' # Index for entry lookups by entryable (if not already present) # This speeds up the entry.account lookups in transfer associations add_index :entries, [ :entryable_id, :entryable_type ], name: 'index_entries_on_entryable' end end ```
zachgoll (Migrated from github.com) reviewed 2025-07-10 00:10:21 +08:00
zachgoll (Migrated from github.com) reviewed 2025-07-10 22:44:52 +08:00
zachgoll (Migrated from github.com) left a comment

Thanks for taking a deep dive into this!

Left a few comments, and I have a few additional questions:

  1. What dataset was this benchmarked against / how much total data was in the DB?
  2. What benchmarking tool was used?
  3. Did you happen to identify where the N+1 queries were coming from? My guess is we've got it somewhere in a view / partial, but would be curious to pinpoint that and see if there's an alternative to all the preloading. I recently added the kind column to Transaction which should identify transfers and was hoping to move away from "reaching" through the transaction to get transfer/account/entry details. I'm wondering if there's a nicer way to pass these details through as-needed rather than the complex preloading this requires.
Thanks for taking a deep dive into this! Left a few comments, and I have a few additional questions: 1. What dataset was this benchmarked against / how much total data was in the DB? 2. What benchmarking tool was used? 3. Did you happen to identify _where_ the N+1 queries were coming from? My guess is we've got it somewhere in a view / partial, but would be curious to pinpoint that and see if there's an alternative to all the preloading. I recently added the `kind` column to `Transaction` which should identify transfers and was hoping to move away from "reaching" through the transaction to get transfer/account/entry details. I'm wondering if there's a nicer way to pass these details through as-needed rather than the complex preloading this requires.
@@ -15,11 +15,8 @@ class TransactionsController < ApplicationController
zachgoll (Migrated from github.com) commented 2025-07-10 22:38:33 +08:00

Is this necessary? Might be missing something, but this would just move the query loading from the view to the controller; but not reduce the number of queries / load time.

Is this necessary? Might be missing something, but this would just move the query loading from the view to the controller; but not reduce the number of queries / load time.
@@ -94,3 +75,3 @@
end
all.size
# arbitrary cutoff date to avoid expensive sync operations
zachgoll (Migrated from github.com) commented 2025-07-10 22:41:48 +08:00

Moving the private keyword up here will cause all the other methods to be inaccessible, so we'll want to move this.

Moving the `private` keyword up here will cause all the other methods to be inaccessible, so we'll want to move this.
@@ -27,0 +30,4 @@
{ inflow_transaction: { entry: :account } },
{ outflow_transaction: { entry: :account } }
)
}
zachgoll (Migrated from github.com) commented 2025-07-10 22:40:47 +08:00

Is this being used anywhere?

Is this being used anywhere?
adeyinkaezra123 (Migrated from github.com) reviewed 2025-07-13 17:21:29 +08:00
@@ -27,0 +30,4 @@
{ inflow_transaction: { entry: :account } },
{ outflow_transaction: { entry: :account } }
)
}
adeyinkaezra123 (Migrated from github.com) commented 2025-07-13 17:21:28 +08:00

Yeah, this is used in the transactions controller #index method

Yeah, this is used in the transactions controller `#index` [method](https://github.com/adeyinkaezra123/maybe/blob/e6c9077c6028814fdff55562f7baf416dc6798a0/app/controllers/transactions_controller.rb#L18)
adeyinkaezra123 (Migrated from github.com) reviewed 2025-07-13 17:21:41 +08:00
@@ -94,3 +75,3 @@
end
all.size
# arbitrary cutoff date to avoid expensive sync operations
adeyinkaezra123 (Migrated from github.com) commented 2025-07-13 17:21:41 +08:00

Actually, invalidate_caches is an internal callback method in this case.

Methods defined inside the class << self block aren't affected by the instance-level private keyword. They maintain their own visibility scope.

Actually, `invalidate_caches` is an internal callback method in this case. Methods defined inside the `class << self` block aren't affected by the instance-level private keyword. They maintain their own visibility scope.
adeyinkaezra123 (Migrated from github.com) reviewed 2025-07-13 17:40:13 +08:00
@@ -15,11 +15,8 @@ class TransactionsController < ApplicationController
adeyinkaezra123 (Migrated from github.com) commented 2025-07-13 17:40:13 +08:00

You're right, I was thinking it could avoid overfetching since we might only need the account references from the transfer association in the views, not all the transfer attributes.

I'll switch back to using includes for a cleaner approach.

You're right, I was thinking it could avoid overfetching since we might only need the account references from the transfer association in the views, not all the transfer attributes. I'll switch back to using includes for a cleaner approach.
adeyinkaezra123 commented 2025-07-13 18:05:57 +08:00 (Migrated from github.com)

Note about setup: Benchmarking results may vary based on the data in the DB. The best way to benchmark is setting up a production-mirror with anonymized / scrubbed data, which is a lengthy process that we cannot provide in an OSS context. The best alternative we have is synthetic demo data, which can be generated via rake demo_data:default. Benchmarks can still be run on this small demo family, but time/iteration will be much smaller and performance issues caused by inefficient queries running on large datasets will not show up in the results.

I followed the instructions from the benchmarking setup PR, which I believe uses the derailed_benchmark gem. I had written a simple custom benchmark myself, but once I saw the existing rake task, I went with that instead.

I ran the demo_data:default rake task to populate the maybe_production database, and here’s a snapshot of the resulting table sizes:

              table_name               | estimated_rows 
---------------------------------------+----------------
 public.balances                       |          28627
 public.entries                        |           8018
 public.transactions                   |           7487
 public.sessions                       |           6176
 public.holdings                       |           4406
 public.trades                         |            526
 public.transfers                      |            433
 public.schema_migrations              |            195
 public.categories                     |             21
 public.syncs                          |             20
 public.accounts                       |             20
 public.budget_categories              |             16
 public.valuations                     |              5
 public.investments                    |              5
 public.depositories                   |              4
 public.securities                     |              3
 public.loans                          |              3
 public.users                          |              2
 public.credit_cards                   |              2
 public.vehicles                       |              2
...
(other tables are with only 0-5 rows)
  1. I ran into multiple N+1 query issues just by hitting the /transactions endpoint. I was using the Prosopite gem, which flagged several repeated queries. Here’s an excerpt from the logs: from the main branch
12:16:09 web.1    | N+1 queries detected:
12:16:09 web.1    |   SELECT "transactions".* FROM "transactions" WHERE "transactions"."id" = $1 LIMIT $2
12:16:09 web.1    |   SELECT "transactions".* FROM "transactions" WHERE "transactions"."id" = $1 LIMIT $2
12:16:09 web.1    |   SELECT "transactions".* FROM "transactions" WHERE "transactions"."id" = $1 LIMIT $2
12:16:09 web.1    | Call stack:
12:16:09 web.1    |   app/models/transfer.rb:57:in 'Transfer#to_account'
12:16:09 web.1    |   app/models/transfer.rb:100:in 'Transfer#categorizable?'
12:16:09 web.1    |   app/views/transactions/_transaction_category.html.erb:4
12:16:09 web.1    |   app/views/transactions/_transaction.html.erb:96
12:16:09 web.1    |   app/views/transactions/_transaction.html.erb:6
12:16:09 web.1    |   app/views/transactions/_transaction.html.erb:5
12:16:09 web.1    |   app/views/entries/_entry.html.erb:3
12:16:09 web.1    |   app/views/transactions/index.html.erb:84
12:16:09 web.1    |   app/helpers/entries_helper.rb:23:in 'block (2 levels) in EntriesHelper#entries_by_date'
12:16:09 web.1    |   app/helpers/entries_helper.rb:22:in 'block in EntriesHelper#entries_by_date'
12:16:09 web.1    |   app/helpers/entries_helper.rb:21:in 'Array#reverse_each'
12:16:09 web.1    |   app/helpers/entries_helper.rb:21:in 'Enumerator#each'
12:16:09 web.1    |   app/helpers/entries_helper.rb:21:in 'Enumerable#map'
12:16:09 web.1    |   app/helpers/entries_helper.rb:21:in 'EntriesHelper#entries_by_date'
12:16:09 web.1    |   app/views/transactions/index.html.erb:83
12:16:09 web.1    |   app/controllers/application_controller.rb:17:in 'ApplicationController#n_plus_one_detection'
12:16:09 web.1    |   app/controllers/concerns/localize.rb:17:in 'Localize#switch_timezone'
12:16:09 web.1    |   app/controllers/concerns/localize.rb:12:in 'Localize#switch_locale'
12:16:09 web.1    | 
12:16:09 web.1    | N+1 queries detected:
12:16:09 web.1    |   SELECT "entries".* FROM "entries" WHERE "entries"."entryable_id" = $1 AND "entries"."entryable_type" = $2 LIMIT $3
12:16:09 web.1    |   SELECT "entries".* FROM "entries" WHERE "entries"."entryable_id" = $1 AND "entries"."entryable_type" = $2 LIMIT $3
12:16:09 web.1    |   SELECT "entries".* FROM "entries" WHERE "entries"."entryable_id" = $1 AND "entries"."entryable_type" = $2 LIMIT $3
12:16:09 web.1    | Call stack:
12:16:09 web.1    |   app/models/transfer.rb:57:in 'Transfer#to_account'
12:16:09 web.1    |   app/models/transfer.rb:100:in 'Transfer#categorizable?'
12:16:09 web.1    |   app/views/transactions/_transaction_category.html.erb:4
12:16:09 web.1    |   app/views/transactions/_transaction.html.erb:96
12:16:09 web.1    |   app/views/transactions/_transaction.html.erb:6
12:16:09 web.1    |   app/views/transactions/_transaction.html.erb:5
12:16:09 web.1    |   app/views/entries/_entry.html.erb:3
12:16:09 web.1    |   app/views/transactions/index.html.erb:84
12:16:09 web.1    |   app/helpers/entries_helper.rb:23:in 'block (2 levels) in EntriesHelper#entries_by_date'
12:16:09 web.1    |   app/helpers/entries_helper.rb:22:in 'block in EntriesHelper#entries_by_date'
12:16:09 web.1    |   app/helpers/entries_helper.rb:21:in 'Array#reverse_each'
12:16:09 web.1    |   app/helpers/entries_helper.rb:21:in 'Enumerator#each'
12:16:09 web.1    |   app/helpers/entries_helper.rb:21:in 'Enumerable#map'
12:16:09 web.1    |   app/helpers/entries_helper.rb:21:in 'EntriesHelper#entries_by_date'
12:16:09 web.1    |   app/views/transactions/index.html.erb:83
12:16:09 web.1    |   app/controllers/application_controller.rb:17:in 'ApplicationController#n_plus_one_detection'
12:16:09 web.1    |   app/controllers/concerns/localize.rb:17:in 'Localize#switch_timezone'
12:16:09 web.1    |   app/controllers/concerns/localize.rb:12:in 'Localize#switch_locale'
12:16:09 web.1    | 
12:16:09 web.1    | N+1 queries detected:
12:16:09 web.1    |   SELECT "accounts".* FROM "accounts" WHERE "accounts"."id" = $1 LIMIT $2
12:16:09 web.1    |   SELECT "accounts".* FROM "accounts" WHERE "accounts"."id" = $1 LIMIT $2
12:16:09 web.1    | Call stack:
12:16:09 web.1    |   app/models/transfer.rb:57:in 'Transfer#to_account'
12:16:09 web.1    |   app/models/transfer.rb:100:in 'Transfer#categorizable?'
12:16:09 web.1    |   app/views/transactions/_transaction_category.html.erb:4
12:16:09 web.1    |   app/views/transactions/_transaction.html.erb:96
12:16:10 web.1    |   app/views/transactions/_transaction.html.erb:6
12:16:10 web.1    |   app/views/transactions/_transaction.html.erb:5
12:16:10 web.1    |   app/views/entries/_entry.html.erb:3
12:16:10 web.1    |   app/views/transactions/index.html.erb:84

In total, I saw repeated SELECT calls for transactions, entries, and accounts, all triggered through nested partials in the transactions/index view.

Skylight also highlighted this endpoint as a hot spot, and Prosopite helped confirm the source as N+1 queries:
image

> Note about setup: Benchmarking results may vary based on the data in the DB. The best way to benchmark is setting up a production-mirror with anonymized / scrubbed data, which is a lengthy process that we cannot provide in an OSS context. The best alternative we have is synthetic demo data, which can be generated via rake demo_data:default. Benchmarks can still be run on this small demo family, but time/iteration will be much smaller and performance issues caused by inefficient queries running on large datasets will not show up in the results. I followed the instructions from the benchmarking setup [PR](https://github.com/maybe-finance/maybe/pull/2366 ), which I believe uses the `derailed_benchmark` gem. I had written a simple custom benchmark myself, but once I saw the existing rake task, I went with that instead. I ran the `demo_data:default` rake task to populate the maybe_production database, and here’s a snapshot of the resulting table sizes: ``` table_name | estimated_rows ---------------------------------------+---------------- public.balances | 28627 public.entries | 8018 public.transactions | 7487 public.sessions | 6176 public.holdings | 4406 public.trades | 526 public.transfers | 433 public.schema_migrations | 195 public.categories | 21 public.syncs | 20 public.accounts | 20 public.budget_categories | 16 public.valuations | 5 public.investments | 5 public.depositories | 4 public.securities | 3 public.loans | 3 public.users | 2 public.credit_cards | 2 public.vehicles | 2 ... (other tables are with only 0-5 rows) ``` 3. I ran into multiple N+1 query issues just by hitting the `/transactions` endpoint. I was using the Prosopite gem, which flagged several repeated queries. Here’s an excerpt from the logs: from the main branch ```log 12:16:09 web.1 | N+1 queries detected: 12:16:09 web.1 | SELECT "transactions".* FROM "transactions" WHERE "transactions"."id" = $1 LIMIT $2 12:16:09 web.1 | SELECT "transactions".* FROM "transactions" WHERE "transactions"."id" = $1 LIMIT $2 12:16:09 web.1 | SELECT "transactions".* FROM "transactions" WHERE "transactions"."id" = $1 LIMIT $2 12:16:09 web.1 | Call stack: 12:16:09 web.1 | app/models/transfer.rb:57:in 'Transfer#to_account' 12:16:09 web.1 | app/models/transfer.rb:100:in 'Transfer#categorizable?' 12:16:09 web.1 | app/views/transactions/_transaction_category.html.erb:4 12:16:09 web.1 | app/views/transactions/_transaction.html.erb:96 12:16:09 web.1 | app/views/transactions/_transaction.html.erb:6 12:16:09 web.1 | app/views/transactions/_transaction.html.erb:5 12:16:09 web.1 | app/views/entries/_entry.html.erb:3 12:16:09 web.1 | app/views/transactions/index.html.erb:84 12:16:09 web.1 | app/helpers/entries_helper.rb:23:in 'block (2 levels) in EntriesHelper#entries_by_date' 12:16:09 web.1 | app/helpers/entries_helper.rb:22:in 'block in EntriesHelper#entries_by_date' 12:16:09 web.1 | app/helpers/entries_helper.rb:21:in 'Array#reverse_each' 12:16:09 web.1 | app/helpers/entries_helper.rb:21:in 'Enumerator#each' 12:16:09 web.1 | app/helpers/entries_helper.rb:21:in 'Enumerable#map' 12:16:09 web.1 | app/helpers/entries_helper.rb:21:in 'EntriesHelper#entries_by_date' 12:16:09 web.1 | app/views/transactions/index.html.erb:83 12:16:09 web.1 | app/controllers/application_controller.rb:17:in 'ApplicationController#n_plus_one_detection' 12:16:09 web.1 | app/controllers/concerns/localize.rb:17:in 'Localize#switch_timezone' 12:16:09 web.1 | app/controllers/concerns/localize.rb:12:in 'Localize#switch_locale' 12:16:09 web.1 | 12:16:09 web.1 | N+1 queries detected: 12:16:09 web.1 | SELECT "entries".* FROM "entries" WHERE "entries"."entryable_id" = $1 AND "entries"."entryable_type" = $2 LIMIT $3 12:16:09 web.1 | SELECT "entries".* FROM "entries" WHERE "entries"."entryable_id" = $1 AND "entries"."entryable_type" = $2 LIMIT $3 12:16:09 web.1 | SELECT "entries".* FROM "entries" WHERE "entries"."entryable_id" = $1 AND "entries"."entryable_type" = $2 LIMIT $3 12:16:09 web.1 | Call stack: 12:16:09 web.1 | app/models/transfer.rb:57:in 'Transfer#to_account' 12:16:09 web.1 | app/models/transfer.rb:100:in 'Transfer#categorizable?' 12:16:09 web.1 | app/views/transactions/_transaction_category.html.erb:4 12:16:09 web.1 | app/views/transactions/_transaction.html.erb:96 12:16:09 web.1 | app/views/transactions/_transaction.html.erb:6 12:16:09 web.1 | app/views/transactions/_transaction.html.erb:5 12:16:09 web.1 | app/views/entries/_entry.html.erb:3 12:16:09 web.1 | app/views/transactions/index.html.erb:84 12:16:09 web.1 | app/helpers/entries_helper.rb:23:in 'block (2 levels) in EntriesHelper#entries_by_date' 12:16:09 web.1 | app/helpers/entries_helper.rb:22:in 'block in EntriesHelper#entries_by_date' 12:16:09 web.1 | app/helpers/entries_helper.rb:21:in 'Array#reverse_each' 12:16:09 web.1 | app/helpers/entries_helper.rb:21:in 'Enumerator#each' 12:16:09 web.1 | app/helpers/entries_helper.rb:21:in 'Enumerable#map' 12:16:09 web.1 | app/helpers/entries_helper.rb:21:in 'EntriesHelper#entries_by_date' 12:16:09 web.1 | app/views/transactions/index.html.erb:83 12:16:09 web.1 | app/controllers/application_controller.rb:17:in 'ApplicationController#n_plus_one_detection' 12:16:09 web.1 | app/controllers/concerns/localize.rb:17:in 'Localize#switch_timezone' 12:16:09 web.1 | app/controllers/concerns/localize.rb:12:in 'Localize#switch_locale' 12:16:09 web.1 | 12:16:09 web.1 | N+1 queries detected: 12:16:09 web.1 | SELECT "accounts".* FROM "accounts" WHERE "accounts"."id" = $1 LIMIT $2 12:16:09 web.1 | SELECT "accounts".* FROM "accounts" WHERE "accounts"."id" = $1 LIMIT $2 12:16:09 web.1 | Call stack: 12:16:09 web.1 | app/models/transfer.rb:57:in 'Transfer#to_account' 12:16:09 web.1 | app/models/transfer.rb:100:in 'Transfer#categorizable?' 12:16:09 web.1 | app/views/transactions/_transaction_category.html.erb:4 12:16:09 web.1 | app/views/transactions/_transaction.html.erb:96 12:16:10 web.1 | app/views/transactions/_transaction.html.erb:6 12:16:10 web.1 | app/views/transactions/_transaction.html.erb:5 12:16:10 web.1 | app/views/entries/_entry.html.erb:3 12:16:10 web.1 | app/views/transactions/index.html.erb:84 ``` In total, I saw repeated SELECT calls for transactions, entries, and accounts, all triggered through nested partials in the transactions/index view. [Skylight](https://oss.skylight.io/app/applications/XDpPIXEX52oi/recent/6h/endpoints/TransactionsController%23index?responseType=html) also highlighted this endpoint as a hot spot, and Prosopite helped confirm the source as N+1 queries: <img width="1266" height="766" alt="image" src="https://github.com/user-attachments/assets/dc578576-b691-42d7-9428-4577e6a549f0" />

Pull request closed

Sign in to join this conversation.