mirror of
https://github.com/we-promise/sure.git
synced 2026-05-09 21:54:58 +00:00
* perf(accounts): kill sidebar/sparkline N+1s and cache the sidebar The dashboard was issuing hundreds of per-account `SELECT 1` and polymorphic `accountable` lookups on every page load. Sidebar render alone hit the DB ~50–100× and ran twice per request (mobile + desktop). Changes: - AccountableSparklinesController: short-circuit `requires_normalized_aggregation?` to Investment/Crypto only and collapse the per-account `linked?` loop into a single `EXISTS`. Kills the N+1 `AccountProvider Exists?` queries on every sparkline endpoint. - BalanceSheet::AccountTotals#visible_accounts: preload `:accountable`, `:plaid_account`, `:simplefin_account`, and `account_providers: :provider` so the sidebar's `account.subtype` / `account.linked?` / `account.provider` calls don't trigger per-row polymorphic loads. - AccountsController#index: same preloads on `@manual_accounts`. - accounts/index/_account_groups.erb: extend the existing `Preloader` call to batch-load accountable + provider associations so the per-provider-item partials (Plaid, SimpleFIN, Coinbase, etc.) stop re-issuing N+1s when rendering account rows on /accounts. - accounts/_account_sidebar_tabs.html.erb: wrap the partial in a `cache` block keyed on the family's data-version, the current user, shares fingerprint, locale, mobile flag, active tab, and a path-derived "current account" component (`sidebar_active_account_id` helper). The sidebar is rendered on every page in the layout (twice — mobile + desktop drawers), so most navigations now serve the cached fragment instead of re-walking accounts/balances. Local impact (DZG family, 23 accounts, 6.1k transactions): - Dashboard `/`: ~6.5s → ~1.95s - /accounts: ~2.7s → ~0.85s on warm cache - /accountable_sparklines/*: per-request N+1s eliminated; remaining cost is request boilerplate which can be addressed by bumping `RAILS_MAX_THREADS` (the dashboard fans out 5 sparkline turbo frames in parallel and Puma's default 3 threads serialize them). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(perf): address PR review on sidebar/sparkline perf changes - AccountableSparklinesController#requires_normalized_aggregation? also matches legacy plaid_account_id / simplefin_account_id links, not just new-style account_providers, so investment/crypto accounts in the legacy linking state still get LinkedInvestmentSeriesNormalizer applied (Codex P1 / CodeRabbit major). - Sidebar share fingerprint includes both `count` and `max(updated_at)` so deleting a non-most-recent AccountShare invalidates the cached fragment for users who lost access (Codex P1). - Move the sidebar cache-key construction (incl. the AccountShare query) from the ERB into a new `account_sidebar_tabs_cache_key` helper, per the project's "no heavy logic in ERB" rule (CodeRabbit). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(perf): address human review on perf PR - Account.linked: new SQL-level scope mirroring `Account#linked?` so the controller and per-instance method share one definition. Removes the duplicated raw SQL string in `AccountableSparklinesController#requires_normalized_aggregation?`, which now reads `accounts.linked.exists?` (jjmata, sure-design). - AccountsHelper: move `sidebar_active_account_id` and `account_sidebar_tabs_cache_key` out of `ApplicationHelper`. The cache-key helper also collapses the AccountShare `count` + `max(updated_at)` fingerprint into a single `pick` query so we don't pay two round-trips on every render (jjmata, sure-design). - test/models/account/linkable_test.rb: pin the `Account.linked` scope against all three link types (account_providers, legacy plaid_account, legacy simplefin_account) so any future schema change that diverges the SQL definition from `linked?` breaks a test instead of silently serving wrong sparkline aggregations (sure-design). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(perf): correct shares cache fingerprint on raw-SQL pick `pick(Arel.sql("count(*), max(updated_at)"))` passes a single comma- separated fragment, which Rails returns as a String (per the documented behavior of `pluck` with SQL fragments). The previous `max_at&.to_i` silently truncated `"2025-05-06 12:34:56.789 UTC"` to `2025`, so the sidebar cache key would not change for share `updated_at` movements within the same calendar year — including share deletions — leaving revoked users with a stale sidebar until the 12h expiry. Pass the aggregates as two separate `Arel.sql` args and just concatenate the raw String values into the cache key. The values only need to be stable for a given DB state, not numerically meaningful. Caught by CodeRabbit on PR #1683. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
111 lines
3.5 KiB
Ruby
111 lines
3.5 KiB
Ruby
require "test_helper"
|
|
|
|
class Account::LinkableTest < ActiveSupport::TestCase
|
|
setup do
|
|
@family = families(:dylan_family)
|
|
@account = accounts(:depository)
|
|
end
|
|
|
|
test "linked? returns true when account has providers" do
|
|
plaid_account = plaid_accounts(:one)
|
|
AccountProvider.create!(account: @account, provider: plaid_account)
|
|
|
|
assert @account.linked?
|
|
end
|
|
|
|
test "linked? returns false when account has no providers" do
|
|
assert @account.unlinked?
|
|
end
|
|
|
|
test "providers returns all provider adapters" do
|
|
plaid_account = plaid_accounts(:one)
|
|
AccountProvider.create!(account: @account, provider: plaid_account)
|
|
|
|
providers = @account.providers
|
|
assert_equal 1, providers.count
|
|
assert_kind_of Provider::PlaidAdapter, providers.first
|
|
end
|
|
|
|
test "provider_for returns specific provider adapter" do
|
|
plaid_account = plaid_accounts(:one)
|
|
AccountProvider.create!(account: @account, provider: plaid_account)
|
|
|
|
adapter = @account.provider_for("PlaidAccount")
|
|
assert_kind_of Provider::PlaidAdapter, adapter
|
|
end
|
|
|
|
test "linked_to? checks if account is linked to specific provider type" do
|
|
plaid_account = plaid_accounts(:one)
|
|
AccountProvider.create!(account: @account, provider: plaid_account)
|
|
|
|
assert @account.linked_to?("PlaidAccount")
|
|
refute @account.linked_to?("SimplefinAccount")
|
|
end
|
|
|
|
test "can_delete_holdings? returns true for unlinked accounts" do
|
|
assert @account.unlinked?
|
|
assert @account.can_delete_holdings?
|
|
end
|
|
|
|
test "can_delete_holdings? returns false when any provider disallows deletion" do
|
|
plaid_account = plaid_accounts(:one)
|
|
AccountProvider.create!(account: @account, provider: plaid_account)
|
|
|
|
# PlaidAdapter.can_delete_holdings? returns false by default
|
|
refute @account.can_delete_holdings?
|
|
end
|
|
|
|
test "can_delete_holdings? returns true only when all providers allow deletion" do
|
|
plaid_account = plaid_accounts(:one)
|
|
AccountProvider.create!(account: @account, provider: plaid_account)
|
|
|
|
# Stub all providers to return true
|
|
@account.providers.each do |provider|
|
|
provider.stubs(:can_delete_holdings?).returns(true)
|
|
end
|
|
|
|
assert @account.can_delete_holdings?
|
|
end
|
|
|
|
# The `linked` scope mirrors `linked?` at the SQL level. These tests pin
|
|
# all three link types so a future schema or `linked?` change breaks the
|
|
# test instead of silently diverging (e.g. wrong sparkline aggregation).
|
|
test "linked scope matches accounts linked via account_providers" do
|
|
plaid_account = plaid_accounts(:one)
|
|
AccountProvider.create!(account: @account, provider: plaid_account)
|
|
|
|
assert_includes Account.linked, @account
|
|
end
|
|
|
|
test "linked scope matches accounts with legacy plaid_account_id" do
|
|
plaid_account = plaid_accounts(:one)
|
|
@account.update!(plaid_account: plaid_account)
|
|
|
|
assert_includes Account.linked, @account
|
|
end
|
|
|
|
test "linked scope matches accounts with legacy simplefin_account_id" do
|
|
simplefin_item = SimplefinItem.create!(
|
|
family: @family,
|
|
name: "Test SimpleFin",
|
|
access_url: "https://example.com/access_token"
|
|
)
|
|
simplefin_account = SimplefinAccount.create!(
|
|
simplefin_item: simplefin_item,
|
|
name: "Test Account",
|
|
account_id: "test-acct",
|
|
currency: "USD",
|
|
account_type: "checking",
|
|
current_balance: 0
|
|
)
|
|
@account.update!(simplefin_account: simplefin_account)
|
|
|
|
assert_includes Account.linked, @account
|
|
end
|
|
|
|
test "linked scope excludes manual accounts" do
|
|
assert @account.unlinked?
|
|
refute_includes Account.linked, @account
|
|
end
|
|
end
|