Skip to content

fix: memory leak in sidekiq#1940

Open
ahnv wants to merge 5 commits into
we-promise:mainfrom
ahnv:fix/memory-leak-1935
Open

fix: memory leak in sidekiq#1940
ahnv wants to merge 5 commits into
we-promise:mainfrom
ahnv:fix/memory-leak-1935

Conversation

@ahnv

@ahnv ahnv commented May 23, 2026

Copy link
Copy Markdown
Contributor

Resolves #1935

This pull request introduces memory and performance optimizations for balance and holding materialization, and standardizes the data structures used for balances and holdings. The main changes include introducing lightweight Structs for in-memory calculations, batching upserts to reduce memory usage during sync, and updating code and tests to use the new data structures.

Memory and Performance Optimizations:

  • Both Balance::Materializer and Holding::Materializer now batch database upserts in chunks (PERSIST_BATCH_SIZE = 2_000) to reduce peak memory usage during sync operations. This prevents large attribute arrays from being held in memory simultaneously with the full in-memory collections. [1] [2] [3] [4]
  • The converted_entries method in Balance::SyncCache now mutates entry instances in place instead of duplicating them, significantly reducing memory allocations during sync.
  • The holdings association cache is now cleared with .reset instead of .reload after materialization, avoiding unnecessary eager loading of large collections.

Data Structure Standardization:

  • Introduces Balance::BaseCalculator::BalanceData and Holding::HoldingData as lightweight Structs for in-memory calculations, replacing previous uses of Balance and Holding model instances. [1] [2]
  • Updates calculators, materializers, and tests to use these new Structs, ensuring consistency and reducing unnecessary ActiveRecord object allocations. [1] [2] [3] [4] [5] [6] [7]

Test and Assertion Updates:

  • Test assertions for holdings now match on security_id instead of security objects, and error messages are updated for clarity. [1] [2] [3] [4]

Attribute Handling Improvements:

  • When persisting holdings, attributes are now built directly from the new Structs instead of relying on ActiveRecord's .attributes, ensuring only the necessary fields are included and improving performance.

These changes improve the efficiency and reliability of balance and holding calculations, especially for accounts with large historical data.

Summary by CodeRabbit

  • Performance

    • Persisted balances and holdings in bounded batches to reduce peak memory during syncs.
    • Reduced allocation during entry conversion by converting amounts in-place.
  • Refactor

    • Switched balance and holding calculation outputs to lightweight data objects for clearer, safer processing.
    • Simplified materialization flow to build persistable attribute hashes before bulk upserts.
  • Tests

    • Updated tests to assert on IDs/dates and field values instead of comparing full model instances.

Review Change Stack

@superagent-security superagent-security Bot added the contributor:verified Contributor passed trust analysis. label May 23, 2026
@coderabbitai

coderabbitai Bot commented May 23, 2026

Copy link
Copy Markdown
Contributor

Caution

Review failed

An error occurred during the review process. Please try again later.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai

coderabbitai Bot commented May 23, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Replace per-row AR model instantiation with lightweight Structs for balances and holdings, batch upserts using PERSIST_BATCH_SIZE to limit memory, and perform in-place FX conversions on entries instead of duplicating them.

Changes

Memory optimization through struct-based data and batched persistence

Layer / File(s) Summary
Balance data struct and calculator
app/models/balance/balance_data.rb, app/models/balance/base_calculator.rb
Adds Balance::BalanceData Struct and updates BaseCalculator#build_balance to return BalanceData (passes account: / date / currency).
Balance persistence batching
app/models/balance/materializer.rb
Adds PERSIST_BATCH_SIZE and persists balances in slices via each_slice; builds upsert rows from balance.to_h (removing :account, stringifying keys, setting updated_at) and calls upsert_all per slice.
SyncCache in-place FX conversion
app/models/balance/sync_cache.rb
converted_entries mutates entry objects in-place (computes optional custom_rate, calls Money#exchange_to, assigns amount/currency) instead of duplicating entries with dup.
Holding data struct and calculators
app/models/holding/holding_data.rb, app/models/holding/forward_calculator.rb, app/models/holding/reverse_calculator.rb, app/models/holding/gapfillable.rb
Adds Holding::HoldingData Struct and updates calculators/gapfill to construct HoldingData using account_id/security_id identifiers rather than AR model instances.
Holding persistence batching and attribute handling
app/models/holding/materializer.rb
Adds PERSIST_BATCH_SIZE, buffers upsert rows into two buffers (cost-basis paths), builds base_attrs per holding, flushes on batch size, and uses account.holdings.reset to clear association cache after persistence.
Test updates
test/models/balance/materializer_test.rb, test/models/holding/forward_calculator_test.rb, test/models/holding/reverse_calculator_test.rb
Tests now construct expected balances with Balance::BalanceData and match holdings by security_id + date instead of comparing AR association objects.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~40 minutes

Possibly related PRs

  • we-promise/sure#1818: Modifies Holding persistence/cost_basis upsert behavior; related to holding materializer persistence changes.

Suggested labels

contributor:verified

Suggested reviewers

  • jjmata
  • sokie

Poem

🐰 I nibble at heavy memory, light as air,
Structs for balances, holdings handled with care.
Batches march in thousands, flushing neat and small,
Entries change in place — fewer objects to haul.
Hop, sync, sleep: Sidekiq breathes easy after all.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The PR title describes a memory leak fix in Sidekiq, but the changes are specifically about reducing memory usage in balance and holding materialization through batching and struct usage, not directly fixing a Sidekiq-level leak. Clarify the title to specify the optimization targets: e.g., 'fix: reduce memory usage in balance and holding materialization' or 'fix: batch database upserts to reduce peak memory during syncs'.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed All primary coding objectives from issue #1935 are met: batched upserts in materializers [Balance::Materializer, Holding::Materializer], in-place mutation in SyncCache with safety documentation, struct-based lightweight data structures [Holding::HoldingData, Balance::BalanceData], and association cache clearing with reset.
Out of Scope Changes check ✅ Passed All changes are directly scoped to reducing memory allocations in balance/holding materialization. The new structs, batching logic, test updates, and SyncCache mutation optimization are all targeted at the identified hotspots from the flamegraph investigation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@superagent-security superagent-security Bot added the pr:verified PR passed security analysis. label May 23, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/models/holding/gapfillable.rb (1)

23-31: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Carry forward cost_basis on gap-filled holdings.

At Line 23, synthetic Holding::HoldingData omits cost_basis, which can zero out/lose per-share basis continuity on non-trade dates when these rows are persisted.

💡 Proposed fix
             filled_holdings << Holding::HoldingData.new(
               account_id: previous_holding.account_id,
               security_id: previous_holding.security_id,
               date: date,
               qty: previous_holding.qty,
               price: previous_holding.price,
               currency: previous_holding.currency,
-              amount: previous_holding.amount
+              amount: previous_holding.amount,
+              cost_basis: previous_holding.cost_basis
             )
Based on learnings: store `cost_basis` on `Holding` as a per-share value and preserve that value through importer/materialization paths.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/models/holding/gapfillable.rb` around lines 23 - 31, The synthetic
Holding::HoldingData created when gap-filling (in the block that appends to
filled_holdings) currently omits cost_basis and thus drops per-share basis
continuity; update the initializer call for Holding::HoldingData to include
cost_basis: previous_holding.cost_basis so the gap-filled record carries the
per-share cost basis, and ensure downstream importer/materialization paths
continue to persist and respect Holding.cost_basis as a per-share value (verify
handling in any import/materialize methods that consume Holding::HoldingData).
🧹 Nitpick comments (1)
app/models/balance/sync_cache.rb (1)

40-52: ⚡ Quick win

In-place mutation of AR objects: verify no downstream consumers expect original values.

Mutating loaded Entry instances directly is effective for reducing allocations, but these are still ActiveRecord objects that could be referenced elsewhere via AR's identity map or association caches. If any downstream code during the sync cycle fetches these same entries (e.g., via account.entries without a fresh query), it would observe the converted amount and currency values.

The comment correctly notes these aren't persisted, but consider adding readonly! to make accidental saves fail-fast:

💡 Optional defensive measure
      `@converted_entries` ||= account.entries.excluding_split_parents.includes(:entryable).order(:date).to_a.map do |e|
+       e.readonly!
        custom_rate = e.entryable.exchange_rate if e.entryable.respond_to?(:exchange_rate)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/models/balance/sync_cache.rb` around lines 40 - 52, The code mutates
Entry instances in place (see e.amount_money.exchange_to, e.amount, e.currency)
which can leak converted values to other consumers; after setting e.amount and
e.currency add a defensive e.readonly! (or otherwise freeze/mark as immutable)
before returning e so accidental saves fail-fast and downstream code cannot
persist the mutated state; ensure the call is placed in the same block that
performs the conversion and keep the existing comment explaining
non-persistence.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/models/holding/forward_calculator_test.rb`:
- Around line 134-141: The test helper assert_holdings finds calculated_entry
with calculated.find and then dereferences it without checking for nil; add an
assert_not_nil for calculated_entry (with a clear message including
expected_entry.security_id and expected_entry.date) immediately after the find
in assert_holdings to fail with a helpful assertion instead of raising
NoMethodError before asserting qty/price/amount.

In `@test/models/holding/reverse_calculator_test.rb`:
- Around line 240-248: The test helper assert_holdings is dereferencing
calculated_entry without checking it exists; add an assertion like
assert_not_nil calculated_entry (with a descriptive message referencing
expected_entry.security_id and expected_entry.date) immediately after finding
calculated_entry in the assert_holdings method so the subsequent assert_equal
calls don't raise NoMethodError, then keep the existing qty/price/amount
assertions unchanged.
- Around line 152-158: Add a nil-check for the lookup result before accessing
its fields: ensure you call assert_not_nil calculated_entry (with a helpful
message including expected_entry.security_id and expected_entry.date)
immediately after the calculated.find lookup so the subsequent assert_equal
checks for qty, price, and amount will not raise NoMethodError and will produce
a clear test failure if the calculator did not produce the expected holding.
- Around line 110-116: The test assumes calculated.find returns an entry; add an
explicit nil check: immediately after calculated_entry = calculated.find { |c|
c.security_id == expected_entry.security_id && c.date == expected_entry.date }
call add assert_not_nil calculated_entry, "No calculated entry for
security_id=#{expected_entry.security_id} on #{expected_entry.date}" so
subsequent assertions on calculated_entry.qty/price/amount don't raise
NoMethodError; keep the existing assert_equal lines unchanged.

---

Outside diff comments:
In `@app/models/holding/gapfillable.rb`:
- Around line 23-31: The synthetic Holding::HoldingData created when gap-filling
(in the block that appends to filled_holdings) currently omits cost_basis and
thus drops per-share basis continuity; update the initializer call for
Holding::HoldingData to include cost_basis: previous_holding.cost_basis so the
gap-filled record carries the per-share cost basis, and ensure downstream
importer/materialization paths continue to persist and respect
Holding.cost_basis as a per-share value (verify handling in any
import/materialize methods that consume Holding::HoldingData).

---

Nitpick comments:
In `@app/models/balance/sync_cache.rb`:
- Around line 40-52: The code mutates Entry instances in place (see
e.amount_money.exchange_to, e.amount, e.currency) which can leak converted
values to other consumers; after setting e.amount and e.currency add a defensive
e.readonly! (or otherwise freeze/mark as immutable) before returning e so
accidental saves fail-fast and downstream code cannot persist the mutated state;
ensure the call is placed in the same block that performs the conversion and
keep the existing comment explaining non-persistence.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: eef06f51-6f45-47d8-a82f-11259f8d16db

📥 Commits

Reviewing files that changed from the base of the PR and between 0497b1d and 8e5b80e.

📒 Files selected for processing (11)
  • app/models/balance/base_calculator.rb
  • app/models/balance/materializer.rb
  • app/models/balance/sync_cache.rb
  • app/models/holding/forward_calculator.rb
  • app/models/holding/gapfillable.rb
  • app/models/holding/holding_data.rb
  • app/models/holding/materializer.rb
  • app/models/holding/reverse_calculator.rb
  • test/models/balance/materializer_test.rb
  • test/models/holding/forward_calculator_test.rb
  • test/models/holding/reverse_calculator_test.rb

Comment on lines 134 to 141
def assert_holdings(expected, calculated)
expected.each do |expected_entry|
calculated_entry = calculated.find { |c| c.security == expected_entry.security && c.date == expected_entry.date }
calculated_entry = calculated.find { |c| c.security_id == expected_entry.security_id && c.date == expected_entry.date }

assert_equal expected_entry.qty, calculated_entry.qty, "Qty mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}"
assert_equal expected_entry.price, calculated_entry.price, "Price mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}"
assert_equal expected_entry.amount, calculated_entry.amount, "Amount mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}"
assert_equal expected_entry.qty, calculated_entry.qty, "Qty mismatch for security_id=#{expected_entry.security_id} on #{expected_entry.date}"
assert_equal expected_entry.price, calculated_entry.price, "Price mismatch for security_id=#{expected_entry.security_id} on #{expected_entry.date}"
assert_equal expected_entry.amount, calculated_entry.amount, "Amount mismatch for security_id=#{expected_entry.security_id} on #{expected_entry.date}"
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add nil check before accessing calculated_entry fields.

If calculated.find returns nil (e.g., calculator didn't produce expected holding), line 138 will raise NoMethodError instead of a clear assertion failure message. Add assert_not_nil before accessing fields to improve debugging experience.

🛡️ Proposed fix
 def assert_holdings(expected, calculated)
   expected.each do |expected_entry|
     calculated_entry = calculated.find { |c| c.security_id == expected_entry.security_id && c.date == expected_entry.date }
+    assert_not_nil calculated_entry, "No calculated holding found for security_id=#{expected_entry.security_id} on #{expected_entry.date}"
 
     assert_equal expected_entry.qty, calculated_entry.qty, "Qty mismatch for security_id=#{expected_entry.security_id} on #{expected_entry.date}"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/models/holding/forward_calculator_test.rb` around lines 134 - 141, The
test helper assert_holdings finds calculated_entry with calculated.find and then
dereferences it without checking for nil; add an assert_not_nil for
calculated_entry (with a clear message including expected_entry.security_id and
expected_entry.date) immediately after the find in assert_holdings to fail with
a helpful assertion instead of raising NoMethodError before asserting
qty/price/amount.

Comment thread test/models/holding/reverse_calculator_test.rb
Comment thread test/models/holding/reverse_calculator_test.rb
Comment thread test/models/holding/reverse_calculator_test.rb
@ahnv
ahnv force-pushed the fix/memory-leak-1935 branch from 8e5b80e to 0e93137 Compare May 23, 2026 11:53
@superagent-security superagent-security Bot added pr:verified PR passed security analysis. and removed pr:verified PR passed security analysis. labels May 23, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/models/holding/gapfillable.rb (1)

23-31: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve cost_basis during gap-filled holding generation.

Gap-filled records copy position fields from previous_holding but currently drop cost_basis. For days without trades, cost basis should carry forward unchanged.

Proposed fix
             filled_holdings << Holding::HoldingData.new(
               account_id: previous_holding.account_id,
               security_id: previous_holding.security_id,
               date: date,
               qty: previous_holding.qty,
               price: previous_holding.price,
               currency: previous_holding.currency,
-              amount: previous_holding.amount
+              amount: previous_holding.amount,
+              cost_basis: previous_holding.cost_basis
             )

Based on learnings: "store cost_basis on Holding as a per-share value ... Implement this normalization in the provider/import/materialization path."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/models/holding/gapfillable.rb` around lines 23 - 31, The gap-fill
creation of Holding::HoldingData is missing the cost_basis field, so update the
constructor call in the gap-filling logic to preserve
previous_holding.cost_basis (i.e., add cost_basis: previous_holding.cost_basis)
so days without trades carry forward the per-share cost basis; ensure the symbol
is added alongside account_id, security_id, date, qty, price, currency, and
amount in the Holding::HoldingData instantiation that builds filled_holdings
from previous_holding.
♻️ Duplicate comments (4)
test/models/holding/reverse_calculator_test.rb (3)

111-116: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add nil check before accessing calculated_entry fields.

The past review comment is still valid: if calculated.find returns nil, line 113 will raise NoMethodError instead of a clear assertion failure.

🛡️ Proposed fix
 expected.each do |expected_entry|
   calculated_entry = calculated.find { |c| c.security_id == expected_entry.security_id && c.date == expected_entry.date }
+  assert_not_nil calculated_entry, "No calculated holding found for security_id=#{expected_entry.security_id} on #{expected_entry.date}"
 
   assert_equal expected_entry.qty, calculated_entry.qty, "Qty mismatch for security_id=#{expected_entry.security_id} on #{expected_entry.date}"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/models/holding/reverse_calculator_test.rb` around lines 111 - 116, The
test currently calls calculated.find and immediately accesses
calculated_entry.qty/price/amount which will raise NoMethodError if
calculated_entry is nil; update the test (around the calculated_entry =
calculated.find { ... } line in reverse_calculator_test.rb) to assert the found
entry is not nil (e.g., assert_not_nil or assert with a clear message using
expected_entry.security_id and expected_entry.date) before asserting
qty/price/amount, so failures produce a clear assertion message instead of a
NoMethodError.

242-247: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add nil check before accessing calculated_entry fields.

This helper has the same issue - the past review comment is still valid. Add assert_not_nil before field assertions to prevent NoMethodError and provide clear failure messages.

🛡️ Proposed fix
 def assert_holdings(expected, calculated)
   expected.each do |expected_entry|
     calculated_entry = calculated.find { |c| c.security_id == expected_entry.security_id && c.date == expected_entry.date }
+    assert_not_nil calculated_entry, "No calculated holding found for security_id=#{expected_entry.security_id} on #{expected_entry.date}"
 
     assert_equal expected_entry.qty, calculated_entry.qty, "Qty mismatch for security_id=#{expected_entry.security_id} on #{expected_entry.date}"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/models/holding/reverse_calculator_test.rb` around lines 242 - 247, The
test accesses calculated_entry fields without checking for nil; add an assertion
before the field checks: insert assert_not_nil calculated_entry (with a clear
message like "No calculated entry for security_id=... on ...") immediately after
locating calculated_entry in the block that iterates expected_entry so the
subsequent assert_equal lines for qty, price, and amount (which reference
calculated_entry) cannot raise NoMethodError.

153-158: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add nil check before accessing calculated_entry fields.

Same issue as the previous assertion block - the past review comment is still valid. Add assert_not_nil before field access to provide clear error messages if calculator doesn't produce expected holding.

🛡️ Proposed fix
 expected.each do |expected_entry|
   calculated_entry = calculated.find { |c| c.security_id == expected_entry.security_id && c.date == expected_entry.date }
+  assert_not_nil calculated_entry, "No calculated holding found for security_id=#{expected_entry.security_id} on #{expected_entry.date}"
 
   assert_equal expected_entry.qty, calculated_entry.qty, "Qty mismatch for security_id=#{expected_entry.security_id} on #{expected_entry.date}"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/models/holding/reverse_calculator_test.rb` around lines 153 - 158, The
test accesses calculated_entry fields without verifying the lookup succeeded;
add an assert_not_nil for calculated_entry before asserting qty/price/amount so
failures show a clear message; specifically, in reverse_calculator_test where
calculated_entry = calculated.find { |c| c.security_id ==
expected_entry.security_id && c.date == expected_entry.date }, insert
assert_not_nil calculated_entry, "Missing calculated entry for
security_id=#{expected_entry.security_id} on #{expected_entry.date}" (or
similar) immediately before the three assert_equal lines to avoid nil method
calls.
test/models/holding/forward_calculator_test.rb (1)

136-141: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add nil check before accessing calculated_entry fields.

The past review comment on this helper is still valid: if calculated.find returns nil (when the calculator doesn't produce an expected holding), line 138 will raise NoMethodError instead of a clear assertion failure message.

🛡️ Proposed fix
 def assert_holdings(expected, calculated)
   expected.each do |expected_entry|
     calculated_entry = calculated.find { |c| c.security_id == expected_entry.security_id && c.date == expected_entry.date }
+    assert_not_nil calculated_entry, "No calculated holding found for security_id=#{expected_entry.security_id} on #{expected_entry.date}"
 
     assert_equal expected_entry.qty, calculated_entry.qty, "Qty mismatch for security_id=#{expected_entry.security_id} on #{expected_entry.date}"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/models/holding/forward_calculator_test.rb` around lines 136 - 141, The
test currently calls calculated.find and immediately accesses
calculated_entry.qty/price/amount which will raise NoMethodError if
calculated_entry is nil; before the three assert_equal checks add an assertion
that calculated_entry is not nil (e.g. assert_not_nil calculated_entry, "Missing
calculated entry for security_id=#{expected_entry.security_id} on
#{expected_entry.date}") so failures report a clear message, then proceed to
assert_equal expected_entry.qty/price/amount against
calculated_entry.qty/price/amount.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/models/holding/materializer.rb`:
- Around line 4-7: The current implementation builds the full
holdings_to_upsert_with_cost and holdings_to_upsert_without_cost arrays for all
holdings before calling each_slice, so peak memory still holds the entire upsert
payload; change the construction to stream/buffer upserts and flush every
PERSIST_BATCH_SIZE items instead: while iterating `@holdings` (or the method that
populates these arrays), append each transformed upsert hash to a temporary
buffer and when buffer.size >= PERSIST_BATCH_SIZE call the existing
persist/upsert logic (the code currently invoked inside each_slice) and clear
the buffer, then continue; ensure you update places referencing
holdings_to_upsert_with_cost and holdings_to_upsert_without_cost (the build
loops and the points that currently call each_slice at lines around where those
variables are used) so they no longer accumulate the full collection but instead
use the incremental buffer+flush approach using the PERSIST_BATCH_SIZE constant.

---

Outside diff comments:
In `@app/models/holding/gapfillable.rb`:
- Around line 23-31: The gap-fill creation of Holding::HoldingData is missing
the cost_basis field, so update the constructor call in the gap-filling logic to
preserve previous_holding.cost_basis (i.e., add cost_basis:
previous_holding.cost_basis) so days without trades carry forward the per-share
cost basis; ensure the symbol is added alongside account_id, security_id, date,
qty, price, currency, and amount in the Holding::HoldingData instantiation that
builds filled_holdings from previous_holding.

---

Duplicate comments:
In `@test/models/holding/forward_calculator_test.rb`:
- Around line 136-141: The test currently calls calculated.find and immediately
accesses calculated_entry.qty/price/amount which will raise NoMethodError if
calculated_entry is nil; before the three assert_equal checks add an assertion
that calculated_entry is not nil (e.g. assert_not_nil calculated_entry, "Missing
calculated entry for security_id=#{expected_entry.security_id} on
#{expected_entry.date}") so failures report a clear message, then proceed to
assert_equal expected_entry.qty/price/amount against
calculated_entry.qty/price/amount.

In `@test/models/holding/reverse_calculator_test.rb`:
- Around line 111-116: The test currently calls calculated.find and immediately
accesses calculated_entry.qty/price/amount which will raise NoMethodError if
calculated_entry is nil; update the test (around the calculated_entry =
calculated.find { ... } line in reverse_calculator_test.rb) to assert the found
entry is not nil (e.g., assert_not_nil or assert with a clear message using
expected_entry.security_id and expected_entry.date) before asserting
qty/price/amount, so failures produce a clear assertion message instead of a
NoMethodError.
- Around line 242-247: The test accesses calculated_entry fields without
checking for nil; add an assertion before the field checks: insert
assert_not_nil calculated_entry (with a clear message like "No calculated entry
for security_id=... on ...") immediately after locating calculated_entry in the
block that iterates expected_entry so the subsequent assert_equal lines for qty,
price, and amount (which reference calculated_entry) cannot raise NoMethodError.
- Around line 153-158: The test accesses calculated_entry fields without
verifying the lookup succeeded; add an assert_not_nil for calculated_entry
before asserting qty/price/amount so failures show a clear message;
specifically, in reverse_calculator_test where calculated_entry =
calculated.find { |c| c.security_id == expected_entry.security_id && c.date ==
expected_entry.date }, insert assert_not_nil calculated_entry, "Missing
calculated entry for security_id=#{expected_entry.security_id} on
#{expected_entry.date}" (or similar) immediately before the three assert_equal
lines to avoid nil method calls.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7fd89fb8-78c9-4da4-b2eb-0921557779b8

📥 Commits

Reviewing files that changed from the base of the PR and between 8e5b80e and 0e93137.

📒 Files selected for processing (11)
  • app/models/balance/base_calculator.rb
  • app/models/balance/materializer.rb
  • app/models/balance/sync_cache.rb
  • app/models/holding/forward_calculator.rb
  • app/models/holding/gapfillable.rb
  • app/models/holding/holding_data.rb
  • app/models/holding/materializer.rb
  • app/models/holding/reverse_calculator.rb
  • test/models/balance/materializer_test.rb
  • test/models/holding/forward_calculator_test.rb
  • test/models/holding/reverse_calculator_test.rb

Comment thread app/models/holding/materializer.rb
@superagent-security superagent-security Bot removed the pr:verified PR passed security analysis. label May 23, 2026

jjmata commented May 24, 2026

Copy link
Copy Markdown
Collaborator

Review: fix memory leak in Sidekiq

The batched upsert approach and Struct replacement for in-memory calculations are solid improvements. A few things worth flagging:

In-place mutation of ActiveRecord objects in converted_entries

The change from e.dup to mutating e directly:

e.amount = new_amount
e.currency = account.currency
e

…works safely today because to_a materializes a fresh array of AR instances and background sync jobs have no long-lived identity map. However, amount= and currency= call ActiveRecord's attribute writers, which mark those attributes as dirty and may fire before_save/after_save callbacks if the object is ever inadvertently passed to save!. More critically, if Rails ever re-enables query-result caching (or if the sync code path is invoked inside a request where the association cache is warm), a caller holding a reference to the same entry object will see the mutated amount/currency.

The existing comment explains the intent, but the safety assumption is invisible to future readers. Consider adding a brief note that these objects come from to_a and are never referenced via an association cache:

# to_a creates independent instances; no AR identity map is active during sync.

account.holdings.reset vs .reload

The switch from reload to reset is the right call — avoid the unnecessary eager load when the next consumer reloads on demand. The comment explains it well.

BalanceData as a nested struct on BaseCalculator

Balance::BaseCalculator::BalanceData is the canonical struct now, but it's buried inside a private calculator class. If other materializers or tests need to construct test doubles, they'll need the full path. Consider whether Balance::BalanceData (top-level in the Balance namespace) would be a more discoverable home.


Generated by Claude Code

@jjmata

jjmata commented May 24, 2026

Copy link
Copy Markdown
Collaborator

Can you take a look at the PR review comments and address them, @ahnv? 🙏

@superagent-security superagent-security Bot added the pr:verified PR passed security analysis. label May 24, 2026
@ahnv

ahnv commented May 24, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — all three points addressed in 8d769363.

In-place mutation / to_a safety
Agreed the assumption was invisible. Added the note directly to the mutation block in SyncCache#converted_entries:

# to_a materialises independent instances; no AR identity map is active during sync,
# so callers holding a reference to the same association will never see these mutations.

The broader risk you flagged (dirty attributes, accidental save!) is real. Worth noting the monetize macro also redefines amount= — if that ever gains side effects, the in-place approach would need revisiting. For now the comment captures the contract.

account.holdings.reset vs .reload
No change needed — already in place.

Balance::BalanceData promotion
Moved the struct into its own file (app/models/balance/balance_data.rb) under Balance::BalanceData. One subtlety: class Balance::BaseCalculator uses the Foo::Bar open-class syntax, which only pushes Balance::BaseCalculator onto the nesting — not Balance — so bare BalanceData inside that class would no longer resolve via nesting. Updated build_balance to reference Balance::BalanceData explicitly and updated all six test references in materializer_test.rb to match.

@jjmata jjmata added this to the v0.7.2 milestone May 24, 2026
@ahnv
ahnv force-pushed the fix/memory-leak-1935 branch from 8d76936 to e2f82f1 Compare May 29, 2026 18:29
@superagent-security superagent-security Bot removed the contributor:verified Contributor passed trust analysis. label May 29, 2026
@ahnv

ahnv commented May 29, 2026

Copy link
Copy Markdown
Contributor Author

Hey @jjmata, I have rebased main into this branch and resolved the conflicts

@superagent-security superagent-security Bot removed the pr:verified PR passed security analysis. label May 29, 2026
@jjmata

jjmata commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Can you fix the broken unit tests @ahnv?

@ahnv

ahnv commented Jun 21, 2026

Copy link
Copy Markdown
Contributor Author

Can you fix the broken unit tests @ahnv?

Sure thing, sorry i was afk for a while

ahnv added 3 commits June 22, 2026 00:10
…tion

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).
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.
…ent 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
@ahnv
ahnv force-pushed the fix/memory-leak-1935 branch from e2f82f1 to fd21371 Compare June 21, 2026 18:40
@superagent-security

Copy link
Copy Markdown

Superagent didn't find any vulnerabilities or security issues in this PR.

@ahnv

ahnv commented Jun 21, 2026

Copy link
Copy Markdown
Contributor Author

@jjmata fixed

…y 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Development

Successfully merging this pull request may close these issues.

Memory Leak in Sidekiq Worker Processes

3 participants