From a5f1677f60f887aa81fda5027efbac90e3a06c7f Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Sun, 15 Jun 2025 10:09:46 -0400 Subject: [PATCH] perf(income statement): cache income statement queries (#2371) * Leftover cleanup from prior PR * Benchmark convenience task * Change default warm benchmark time * Cache income statement queries * Fix private method access --- app/models/account/syncer.rb | 7 + app/models/demo/generator.rb | 120 ++++++++---------- app/models/income_statement.rb | 36 +++++- .../pages/dashboard/_balance_sheet.html.erb | 2 +- lib/tasks/benchmarking.rake | 11 +- perf.rake | 4 +- 6 files changed, 103 insertions(+), 77 deletions(-) diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb index ab198a95..b8a63d41 100644 --- a/app/models/account/syncer.rb +++ b/app/models/account/syncer.rb @@ -13,6 +13,13 @@ class Account::Syncer def perform_post_sync account.family.auto_match_transfers! + + # Warm IncomeStatement caches so subsequent requests are fast + # TODO: this is a temporary solution to speed up pages. Long term we'll throw a materialized view / pre-computed table + # in for family stats. + income_statement = IncomeStatement.new(account.family) + Rails.logger.info("Warming IncomeStatement caches") + income_statement.warm_caches! end private diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index ee9cc14e..fb64c87d 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -29,9 +29,59 @@ class Demo::Generator # Expose the seed so callers can reproduce a run if necessary. attr_reader :seed - # --------------------------------------------------------------------------- - # Performance helpers - # --------------------------------------------------------------------------- + # Generate empty family - no financial data + def generate_empty_data!(skip_clear: false) + with_timing(__method__) do + unless skip_clear + puts "๐Ÿงน Clearing existing data..." + clear_all_data! + end + + puts "๐Ÿ‘ฅ Creating empty family..." + create_family_and_users!("Demo Family", "user@maybe.local", onboarded: true, subscribed: true) + + puts "โœ… Empty demo data loaded successfully!" + end + end + + # Generate new user family - no financial data, needs onboarding + def generate_new_user_data!(skip_clear: false) + with_timing(__method__) do + unless skip_clear + puts "๐Ÿงน Clearing existing data..." + clear_all_data! + end + + puts "๐Ÿ‘ฅ Creating new user family..." + create_family_and_users!("Demo Family", "user@maybe.local", onboarded: false, subscribed: false) + + puts "โœ… New user demo data loaded successfully!" + end + end + + # Generate comprehensive realistic demo data with multi-currency + def generate_default_data!(skip_clear: false, email: "user@maybe.local") + if skip_clear + puts "โญ๏ธ Skipping data clearing (appending new family)..." + else + puts "๐Ÿงน Clearing existing data..." + clear_all_data! + end + + with_timing(__method__, max_seconds: 1000) do + puts "๐Ÿ‘ฅ Creating demo family..." + family = create_family_and_users!("Demo Family", email, onboarded: true, subscribed: true) + + puts "๐Ÿ“Š Creating realistic financial data..." + create_realistic_categories!(family) + create_realistic_accounts!(family) + create_realistic_transactions!(family) + # Auto-fill current-month budget based on recent spending averages + generate_budget_auto_fill!(family) + + puts "โœ… Realistic demo data loaded successfully!" + end + end private @@ -58,66 +108,7 @@ class Demo::Generator @rng.rand(*args) end - # Generate empty family - no financial data - def generate_empty_data!(skip_clear: false) - with_timing(__method__) do - unless skip_clear - puts "๐Ÿงน Clearing existing data..." - clear_all_data! - end - puts "๐Ÿ‘ฅ Creating empty family..." - create_family_and_users!("Demo Family", "user@maybe.local", onboarded: true, subscribed: true) - - puts "โœ… Empty demo data loaded successfully!" - end - end - - # Generate new user family - no financial data, needs onboarding - def generate_new_user_data!(skip_clear: false) - with_timing(__method__) do - unless skip_clear - puts "๐Ÿงน Clearing existing data..." - clear_all_data! - end - - puts "๐Ÿ‘ฅ Creating new user family..." - create_family_and_users!("Demo Family", "user@maybe.local", onboarded: false, subscribed: false) - - puts "โœ… New user demo data loaded successfully!" - end - end - - # Generate comprehensive realistic demo data with multi-currency - def generate_default_data!(skip_clear: false, email: "user@maybe.local") - if skip_clear - puts "โญ๏ธ Skipping data clearing (appending new family)..." - else - puts "๐Ÿงน Clearing existing data..." - clear_all_data! - end - - with_timing(__method__, max_seconds: 1000) do - puts "๐Ÿ‘ฅ Creating demo family..." - family = create_family_and_users!("Demo Family", email, onboarded: true, subscribed: true) - - puts "๐Ÿ“Š Creating realistic financial data..." - create_realistic_categories!(family) - create_realistic_accounts!(family) - create_realistic_transactions!(family) - # Auto-fill current-month budget based on recent spending averages - generate_budget_auto_fill!(family) - - puts "โœ… Realistic demo data loaded successfully!" - end - end - - # Multi-currency support (keeping existing functionality) - def generate_multi_currency_data!(family_names) - with_timing(__method__) do - generate_for_scenario(:multi_currency, family_names) - end - end def clear_all_data! family_count = Family.count @@ -1226,8 +1217,3 @@ class Demo::Generator puts " โœ… Set property and vehicle valuations" end end - -# Expose public API after full class definition -Demo::Generator.public_instance_methods.include?(:generate_default_data!) or Demo::Generator.class_eval do - public :generate_empty_data!, :generate_new_user_data!, :generate_default_data!, :generate_multi_currency_data! -end diff --git a/app/models/income_statement.rb b/app/models/income_statement.rb index fba114e4..dc239ee3 100644 --- a/app/models/income_statement.rb +++ b/app/models/income_statement.rb @@ -53,6 +53,13 @@ class IncomeStatement family_stats(interval: interval).find { |stat| stat.classification == "income" }&.median || 0 end + def warm_caches!(interval: "month") + totals + family_stats(interval: interval) + category_stats(interval: interval) + nil + end + private ScopeTotals = Data.define(:transactions_count, :income_money, :expense_money, :missing_exchange_rates?) PeriodTotal = Data.define(:classification, :total, :currency, :missing_exchange_rates?, :category_totals) @@ -102,21 +109,40 @@ class IncomeStatement def family_stats(interval: "month") @family_stats ||= {} - @family_stats[interval] ||= FamilyStats.new(family, interval:).call + @family_stats[interval] ||= Rails.cache.fetch([ + "income_statement", "family_stats", family.id, interval, entries_cache_version + ]) { FamilyStats.new(family, interval:).call } end def category_stats(interval: "month") @category_stats ||= {} - @category_stats[interval] ||= CategoryStats.new(family, interval:).call + @category_stats[interval] ||= Rails.cache.fetch([ + "income_statement", "category_stats", family.id, interval, entries_cache_version + ]) { CategoryStats.new(family, interval:).call } end def totals_query(transactions_scope:) - @totals_query_cache ||= {} - cache_key = Digest::MD5.hexdigest(transactions_scope.to_sql) - @totals_query_cache[cache_key] ||= Totals.new(family, transactions_scope: transactions_scope).call + sql_hash = Digest::MD5.hexdigest(transactions_scope.to_sql) + + Rails.cache.fetch([ + "income_statement", "totals_query", family.id, sql_hash, entries_cache_version + ]) { Totals.new(family, transactions_scope: transactions_scope).call } end def monetizable_currency family.currency end + + # Returns a monotonically increasing integer based on the most recent + # update to any Entry that belongs to the family. Incorporated into cache + # keys so they expire automatically on data changes. + def entries_cache_version + @entries_cache_version ||= begin + ts = Entry.joins(:account) + .where(accounts: { family_id: family.id }) + .maximum(:updated_at) + + ts.present? ? ts.to_i : 0 + end + end end diff --git a/app/views/pages/dashboard/_balance_sheet.html.erb b/app/views/pages/dashboard/_balance_sheet.html.erb index 6d131782..60f7786b 100644 --- a/app/views/pages/dashboard/_balance_sheet.html.erb +++ b/app/views/pages/dashboard/_balance_sheet.html.erb @@ -91,7 +91,7 @@ # Calculate weight as percentage of classification total classification_total = classification_group.total_money.amount account_weight = classification_total.zero? ? 0 : account.converted_balance / classification_total * 100 - %> + %> <%= render "pages/dashboard/group_weight", weight: account_weight, color: account_group.color %> diff --git a/lib/tasks/benchmarking.rake b/lib/tasks/benchmarking.rake index 531ed7b7..4943cb5c 100644 --- a/lib/tasks/benchmarking.rake +++ b/lib/tasks/benchmarking.rake @@ -6,9 +6,16 @@ # 4. Run locally, find endpoint needed # 5. Run an endpoint, example: `ENDPOINT=/budgets/jun-2025/budget_categories/245637cb-129f-4612-b0a8-1de57559372b RAILS_ENV=production BENCHMARKING_ENABLED=true RAILS_LOG_LEVEL=debug rake benchmarking:ips` namespace :benchmarking do + desc "Shorthand task for running warm/cold benchmark" + task endpoint: :environment do + system( + "RAILS_ENV=production BENCHMARKING_ENABLED=true ENDPOINT=#{ENV.fetch("ENDPOINT", "/")} rake benchmarking:warm_cold_endpoint_ips" + ) + end + # When to use: Track overall endpoint speed improvements over time (recommended, most practical test) desc "Run cold & warm performance benchmarks and append to history" - task ips: :environment do + task warm_cold_endpoint_ips: :environment do path = ENV.fetch("ENDPOINT", "/") # ๐Ÿšซ Fail fast unless the benchmark is run in production mode @@ -23,7 +30,7 @@ namespace :benchmarking do cold_iterations = Integer(ENV.fetch("COLD_ITERATIONS", 1)) # requests to measure for the cold run warm_warmup = Integer(ENV.fetch("WARM_WARMUP", 5)) # seconds benchmark-ips uses to stabilise JIT/caches - warm_time = Integer(ENV.fetch("WARM_TIME", 30)) # seconds benchmark-ips samples for warm statistics + warm_time = Integer(ENV.fetch("WARM_TIME", 10)) # seconds benchmark-ips samples for warm statistics # --------------------------------------------------------------------------- setup_benchmark_env(path) diff --git a/perf.rake b/perf.rake index 07344a24..4f7cc67b 100644 --- a/perf.rake +++ b/perf.rake @@ -16,7 +16,7 @@ class CustomAuth < DerailedBenchmarks::AuthHelper # Make sure this user is created in the DB with realistic data before running benchmarks user = User.find_by!(email: "user@maybe.local") - puts "Found user for benchmarking: #{user.email}" + Rails.logger.debug "Found user for benchmarking: #{user.email}" # Mimic the way Rails handles browser cookies session = user.sessions.create! @@ -27,7 +27,7 @@ class CustomAuth < DerailedBenchmarks::AuthHelper env['HTTP_COOKIE'] = "session_token=#{signed_value}" - puts "Setting up session for user: #{user.email}" + Rails.logger.debug "Setting up session for user: #{user.email}" app.call(env) end