mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-18 20:59:39 +02:00
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
This commit is contained in:
parent
84b2426e54
commit
a5f1677f60
6 changed files with 103 additions and 77 deletions
|
@ -13,6 +13,13 @@ class Account::Syncer
|
||||||
|
|
||||||
def perform_post_sync
|
def perform_post_sync
|
||||||
account.family.auto_match_transfers!
|
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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -29,35 +29,6 @@ class Demo::Generator
|
||||||
# Expose the seed so callers can reproduce a run if necessary.
|
# Expose the seed so callers can reproduce a run if necessary.
|
||||||
attr_reader :seed
|
attr_reader :seed
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Performance helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
# Simple timing helper. Pass a descriptive label and a block; the runtime
|
|
||||||
# will be printed automatically when the block completes.
|
|
||||||
# If max_seconds is provided, raise RuntimeError when the block exceeds that
|
|
||||||
# duration. Useful to keep CI/dev machines honest about demo-data perf.
|
|
||||||
def with_timing(label, max_seconds: nil)
|
|
||||||
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
||||||
result = yield
|
|
||||||
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
||||||
puts "⏱️ #{label} completed in #{duration.round(2)}s"
|
|
||||||
|
|
||||||
if max_seconds && duration > max_seconds
|
|
||||||
raise "Demo::Generator ##{label} exceeded #{max_seconds}s (#{duration.round(2)}s)"
|
|
||||||
end
|
|
||||||
|
|
||||||
result
|
|
||||||
end
|
|
||||||
|
|
||||||
# Override Kernel#rand so *all* `rand` calls inside this instance (including
|
|
||||||
# those already present in the file) are routed through the seeded PRNG.
|
|
||||||
def rand(*args)
|
|
||||||
@rng.rand(*args)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Generate empty family - no financial data
|
# Generate empty family - no financial data
|
||||||
def generate_empty_data!(skip_clear: false)
|
def generate_empty_data!(skip_clear: false)
|
||||||
with_timing(__method__) do
|
with_timing(__method__) do
|
||||||
|
@ -112,13 +83,33 @@ class Demo::Generator
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Multi-currency support (keeping existing functionality)
|
private
|
||||||
def generate_multi_currency_data!(family_names)
|
|
||||||
with_timing(__method__) do
|
# Simple timing helper. Pass a descriptive label and a block; the runtime
|
||||||
generate_for_scenario(:multi_currency, family_names)
|
# will be printed automatically when the block completes.
|
||||||
|
# If max_seconds is provided, raise RuntimeError when the block exceeds that
|
||||||
|
# duration. Useful to keep CI/dev machines honest about demo-data perf.
|
||||||
|
def with_timing(label, max_seconds: nil)
|
||||||
|
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||||
|
result = yield
|
||||||
|
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
||||||
|
puts "⏱️ #{label} completed in #{duration.round(2)}s"
|
||||||
|
|
||||||
|
if max_seconds && duration > max_seconds
|
||||||
|
raise "Demo::Generator ##{label} exceeded #{max_seconds}s (#{duration.round(2)}s)"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
result
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Override Kernel#rand so *all* `rand` calls inside this instance (including
|
||||||
|
# those already present in the file) are routed through the seeded PRNG.
|
||||||
|
def rand(*args)
|
||||||
|
@rng.rand(*args)
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def clear_all_data!
|
def clear_all_data!
|
||||||
family_count = Family.count
|
family_count = Family.count
|
||||||
if family_count > 50
|
if family_count > 50
|
||||||
|
@ -1226,8 +1217,3 @@ class Demo::Generator
|
||||||
puts " ✅ Set property and vehicle valuations"
|
puts " ✅ Set property and vehicle valuations"
|
||||||
end
|
end
|
||||||
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
|
|
||||||
|
|
|
@ -53,6 +53,13 @@ class IncomeStatement
|
||||||
family_stats(interval: interval).find { |stat| stat.classification == "income" }&.median || 0
|
family_stats(interval: interval).find { |stat| stat.classification == "income" }&.median || 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def warm_caches!(interval: "month")
|
||||||
|
totals
|
||||||
|
family_stats(interval: interval)
|
||||||
|
category_stats(interval: interval)
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
ScopeTotals = Data.define(:transactions_count, :income_money, :expense_money, :missing_exchange_rates?)
|
ScopeTotals = Data.define(:transactions_count, :income_money, :expense_money, :missing_exchange_rates?)
|
||||||
PeriodTotal = Data.define(:classification, :total, :currency, :missing_exchange_rates?, :category_totals)
|
PeriodTotal = Data.define(:classification, :total, :currency, :missing_exchange_rates?, :category_totals)
|
||||||
|
@ -102,21 +109,40 @@ class IncomeStatement
|
||||||
|
|
||||||
def family_stats(interval: "month")
|
def family_stats(interval: "month")
|
||||||
@family_stats ||= {}
|
@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
|
end
|
||||||
|
|
||||||
def category_stats(interval: "month")
|
def category_stats(interval: "month")
|
||||||
@category_stats ||= {}
|
@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
|
end
|
||||||
|
|
||||||
def totals_query(transactions_scope:)
|
def totals_query(transactions_scope:)
|
||||||
@totals_query_cache ||= {}
|
sql_hash = Digest::MD5.hexdigest(transactions_scope.to_sql)
|
||||||
cache_key = Digest::MD5.hexdigest(transactions_scope.to_sql)
|
|
||||||
@totals_query_cache[cache_key] ||= Totals.new(family, transactions_scope: transactions_scope).call
|
Rails.cache.fetch([
|
||||||
|
"income_statement", "totals_query", family.id, sql_hash, entries_cache_version
|
||||||
|
]) { Totals.new(family, transactions_scope: transactions_scope).call }
|
||||||
end
|
end
|
||||||
|
|
||||||
def monetizable_currency
|
def monetizable_currency
|
||||||
family.currency
|
family.currency
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -6,9 +6,16 @@
|
||||||
# 4. Run locally, find endpoint needed
|
# 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`
|
# 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
|
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)
|
# When to use: Track overall endpoint speed improvements over time (recommended, most practical test)
|
||||||
desc "Run cold & warm performance benchmarks and append to history"
|
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", "/")
|
path = ENV.fetch("ENDPOINT", "/")
|
||||||
|
|
||||||
# 🚫 Fail fast unless the benchmark is run in production mode
|
# 🚫 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
|
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_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)
|
setup_benchmark_env(path)
|
||||||
|
|
|
@ -16,7 +16,7 @@ class CustomAuth < DerailedBenchmarks::AuthHelper
|
||||||
# Make sure this user is created in the DB with realistic data before running benchmarks
|
# Make sure this user is created in the DB with realistic data before running benchmarks
|
||||||
user = User.find_by!(email: "user@maybe.local")
|
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
|
# Mimic the way Rails handles browser cookies
|
||||||
session = user.sessions.create!
|
session = user.sessions.create!
|
||||||
|
@ -27,7 +27,7 @@ class CustomAuth < DerailedBenchmarks::AuthHelper
|
||||||
|
|
||||||
env['HTTP_COOKIE'] = "session_token=#{signed_value}"
|
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)
|
app.call(env)
|
||||||
end
|
end
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue