mirror of
https://github.com/we-promise/sure.git
synced 2026-09-09 00:24:15 +00:00
* perf(sync): reduce per-job memory peak in Balance/Holding materialization
Profiling of SyncJob (StackProf object mode + Sidekiq memory middleware)
showed peaks of 600k-1.1M live heap slots per job and ~196k retained
ActiveModel::Attribute::FromUser objects post-GC, driven by full-history
in-memory accumulation in the balance/holding sync pipeline.
Changes:
- Replace Holding.new / Balance.new in calculators with lightweight
Struct-based HoldingData / BalanceData. Skips AR attribute sets,
belongs_to proxies, dirty tracking, type casting, and callbacks
that were never used (upsert_all bypasses validations/callbacks
anyway). Eliminates ~30% of allocations and the bulk of retained
ActiveModel::Attribute::* instances.
- Build upsert payloads directly from struct fields instead of
Holding/Balance#attributes.slice(...).
- Batch upsert_all in PERSIST_BATCH_SIZE (2,000) slices in both
Balance::Materializer and Holding::Materializer so the intermediate
attribute-hash array is bounded instead of holding the full
multi-year history alongside the calculator output.
- Replace account.holdings.reload with account.holdings.reset in
Holding::Materializer. Same cache invalidation, no eager re-query;
the next consumer (Balance::SyncCache) loads on demand.
- Mutate entries in place in Balance::SyncCache#converted_entries
instead of Entry#dup. The instances are scoped to the throwaway
sync-cache and never persisted, so dup'ing was producing tens of
thousands of unused FromUser/FromDatabase attribute wrappers per
sync.
All persist paths run inside the existing Balance.transaction wrapper,
so batched upserts retain transactional atomicity. No production caller
of Balance::SyncCache or Holding::Materializer reuses the affected
instances outside the materializer's lifetime.
Test coverage: balance/{sync_cache,materializer,forward_calculator,
reverse_calculator} and holding/{materializer,forward_calculator,
reverse_calculator} plus account/syncer and sync (82 runs, 2,548
assertions, 0 failures).
* refactor(holding): stream materializer upserts to bound peak memory
Replace full-array accumulation + each_slice in Materializer#persist_holdings
with two flush-on-fill buffers (holdings_buffer_to_upsert_with_cost /
holdings_buffer_to_upsert_without_cost) that upsert and clear at
PERSIST_BATCH_SIZE, keeping peak RSS bounded to ~2x batch size.
Also add assert_not_nil guards in ReverseCalculatorTest before
dereferencing calculated.find results to surface clear failures
instead of NoMethodError.
* refactor(balance): promote BalanceData to Balance namespace and document sync mutation safety
- Extract Balance::BalanceData struct into its own file (app/models/balance/balance_data.rb)
so it is discoverable without knowing it lived inside BaseCalculator
- Remove inline Struct definition from Balance::BaseCalculator; update build_balance
to reference Balance::BalanceData explicitly (required because class Foo::Bar syntax
does not nest Foo in constant lookup)
- Add comment to SyncCache#converted_entries clarifying that to_a materialises
independent AR instances with no identity map active, making in-place mutation safe
- Update all Balance::BaseCalculator::BalanceData references in materializer_test
* test(balance): use to_h instead of attributes on BalanceData struct in waypoint test
* perf(balance,market_data): replace sort_by + first/last with minmax_by and push account-entry join to SQL
- Balance::Materializer#purge_stale_balances: replace sort_by(&:date) + first/last with minmax_by(&:date) to avoid full sort when only min/max are needed
- MarketDataImporter: replace Entry.group(:account_id).minimum(:date) (which loads all account IDs into a Hash) with a LEFT JOIN subquery that computes MIN(date) per account in SQL and exposes it as first_entry_date on the Account relation
* test(balance): use BalanceData struct in materializer purge test
* fix(holding): carry cost_basis forward onto gap-filled dates
135 lines
4.4 KiB
Ruby
135 lines
4.4 KiB
Ruby
class MarketDataImporter
|
||
# By default, our graphs show 1M as the view, so by fetching 31 days,
|
||
# we ensure we can always show an accurate default graph
|
||
SNAPSHOT_DAYS = 31
|
||
|
||
InvalidModeError = Class.new(StandardError)
|
||
|
||
def initialize(mode: :full, clear_cache: false)
|
||
@mode = set_mode!(mode)
|
||
@clear_cache = clear_cache
|
||
end
|
||
|
||
def import_all
|
||
import_security_prices
|
||
import_exchange_rates
|
||
end
|
||
|
||
# Syncs historical security prices (and details)
|
||
def import_security_prices
|
||
unless Security.providers.any?
|
||
Rails.logger.warn("No provider configured for MarketDataImporter.import_security_prices, skipping sync")
|
||
return
|
||
end
|
||
|
||
# Import all securities that aren't marked as "offline" (i.e. they're available from the provider)
|
||
Security.online.find_each do |security|
|
||
security.import_provider_prices(
|
||
start_date: get_first_required_price_date(security),
|
||
end_date: end_date,
|
||
clear_cache: clear_cache
|
||
)
|
||
|
||
security.import_provider_details(clear_cache: clear_cache)
|
||
end
|
||
end
|
||
|
||
def import_exchange_rates
|
||
unless ExchangeRate.provider
|
||
Rails.logger.warn("No provider configured for MarketDataImporter.import_exchange_rates, skipping sync")
|
||
return
|
||
end
|
||
|
||
required_exchange_rate_pairs.each do |pair|
|
||
# pair is a Hash with keys :source, :target, and :start_date
|
||
start_date = snapshot? ? default_start_date : pair[:start_date]
|
||
|
||
ExchangeRate.import_provider_rates(
|
||
from: pair[:source],
|
||
to: pair[:target],
|
||
start_date: start_date,
|
||
end_date: end_date,
|
||
clear_cache: clear_cache
|
||
)
|
||
end
|
||
end
|
||
|
||
private
|
||
attr_reader :mode, :clear_cache
|
||
|
||
def snapshot?
|
||
mode.to_sym == :snapshot
|
||
end
|
||
|
||
# Builds a unique list of currency pairs with the earliest date we need
|
||
# exchange rates for.
|
||
#
|
||
# Returns: Array of Hashes – [{ source:, target:, start_date: }, ...]
|
||
def required_exchange_rate_pairs
|
||
pair_dates = {} # { [source, target] => earliest_date }
|
||
|
||
# 1. ENTRY-BASED PAIRS – we need rates from the first entry date
|
||
Entry.joins(:account)
|
||
.where.not("entries.currency = accounts.currency")
|
||
.group("entries.currency", "accounts.currency")
|
||
.minimum("entries.date")
|
||
.each do |(source, target), date|
|
||
key = [ source, target ]
|
||
pair_dates[key] = [ pair_dates[key], date ].compact.min
|
||
end
|
||
|
||
# 2. ACCOUNT-BASED PAIRS – use the account's oldest entry date.
|
||
# The earliest entry date per account is resolved in SQL to avoid loading a
|
||
# potentially large Hash of all account IDs into Ruby memory.
|
||
Account.joins(:family)
|
||
.joins("LEFT JOIN (SELECT account_id, MIN(date) AS first_entry_date FROM entries GROUP BY account_id) AS entry_mins ON entry_mins.account_id = accounts.id")
|
||
.where.not("families.currency = accounts.currency")
|
||
.select("accounts.id, accounts.currency AS source, families.currency AS target, entry_mins.first_entry_date")
|
||
.find_each do |account|
|
||
earliest_entry_date = account.first_entry_date
|
||
|
||
chosen_date = [ earliest_entry_date, default_start_date ].compact.min
|
||
|
||
key = [ account.source, account.target ]
|
||
pair_dates[key] = [ pair_dates[key], chosen_date ].compact.min
|
||
end
|
||
|
||
# Convert to array of hashes for ease of use
|
||
pair_dates.map do |(source, target), date|
|
||
{ source: source, target: target, start_date: date }
|
||
end
|
||
end
|
||
|
||
def get_first_required_price_date(security)
|
||
return default_start_date if snapshot?
|
||
|
||
Trade.with_entry.where(security: security).minimum(:date) || default_start_date
|
||
end
|
||
|
||
# An approximation that grabs more than we likely need, but simplifies the logic
|
||
def get_first_required_exchange_rate_date(from_currency:)
|
||
return default_start_date if snapshot?
|
||
|
||
Entry.where(currency: from_currency).minimum(:date) || default_start_date
|
||
end
|
||
|
||
def default_start_date
|
||
SNAPSHOT_DAYS.days.ago.to_date
|
||
end
|
||
|
||
# Since we're querying market data from a US-based API, end date should always be today (EST)
|
||
def end_date
|
||
Date.current.in_time_zone("America/New_York").to_date
|
||
end
|
||
|
||
def set_mode!(mode)
|
||
valid_modes = [ :full, :snapshot ]
|
||
|
||
unless valid_modes.include?(mode.to_sym)
|
||
raise InvalidModeError, "Invalid mode for MarketDataImporter, can only be :full or :snapshot, but was #{mode}"
|
||
end
|
||
|
||
mode.to_sym
|
||
end
|
||
end
|