Skip to content

Persist reportAttributes recompute baselines so app restarts do not t…#96071

Draft
sumo-slonik wants to merge 4 commits into
Expensify:mainfrom
software-mansion-labs:perf/report-attributes-policy-signatures
Draft

Persist reportAttributes recompute baselines so app restarts do not t…#96071
sumo-slonik wants to merge 4 commits into
Expensify:mainfrom
software-mansion-labs:perf/report-attributes-policy-signatures

Conversation

@sumo-slonik

Copy link
Copy Markdown
Contributor

Explanation of Change

On the Sentry "P90 vital metrics - large accounts" dashboard, ManualNavigateToReportsContentLoad reaches ~24s p90 on the largest accounts (5k+ reports), with ManualNavigateToInboxTab p99 spiking to 76s. Production spans show single OnyxDerivedCompute runs for reportAttributes taking 10–70s of synchronous JS-thread time (median 11.3s in the worst cohort), and a local reproduction on a seeded 22k-report account pinpointed the trigger: the first policy_ merge after every app start rescans every report.

The root cause is that reportAttributes keeps its policy change-detection baseline (previousPolicies) in a module-level variable, which resets to undefined on every app restart. hasPolicyRelevantFieldChanged(undefined, policy) returns true, so when OpenApp/Reconnect re-delivers the policies collection right after startup — which it always does — every policy counts as "changed" and policyChangedReportKeys expands to essentially all reports. On a 22k-report account that is a single 12.5s synchronous compute blocking the JS thread, which everything else (first paint, search response merge, list render) queues behind. Two smaller triggers share the same restart-amnesia pattern: conciergeReportID and nvp_introSelected force a full recompute whenever the key merely appears in a merge, without checking whether the value changed.

Fix

  1. Policy change detection now compares signatures persisted in the derived value (same pattern as the existing locale guard). policyRelevantSignature serializes exactly the six fields the old field-by-field comparison tracked; signatures are stored per policy key in ReportAttributesDerivedValue.policySignatures, so the baseline survives restarts and the first post-startup merge diffs against disk instead of rescanning. A value written by an older app version (no signatures yet) seeds the baseline without recomputing — the stored attributes already reflect those policies. Real policy changes, including ones that happen while the app is closed, still recompute exactly the affected reports (covered by unit tests).
  2. Value guards for conciergeReportID and introSelected — both are persisted in the derived value and a full recompute happens only when the value actually differs, not whenever the key is re-delivered.
  3. Each OnyxDerivedCompute span now carries a derived_trigger attribute, so production can confirm the trigger distribution of long computes per derived key.

Performance results

Measured on web dev, spans ManualNavigateToReportsContentLoad and OnyxDerivedCompute (reportAttributes), read from the telemetry console logs. Each run is a scripted restart-cold driven by Playwright: delete the cached search snapshots, reload the page (Onyx persists on disk, as after a real app restart), wait for the app to be ready, click the Reports/Spend tab, and capture span durations until ContentLoad ends. Two runs per side, interleaved on the same machine; run-to-run spread was <3% on main and <16% on the branch.

Account — real heavy account, seeded to reproduce the Sentry cohort: 2,461 real reports (with 20,107 real personal details, 3,133 transactions, 132 policies) seeded with synthetic reports cloned from real records up to 22,461 reports (~250k report actions, +6k transactions), matching the reports_count: 10000+ bucket where production long computes have a 12.8s median. All bars share one scale (1 block ≈ 1s).

ContentLoad (restart-cold) Longest reportAttributes compute Sum of reportAttributes computes
Before (main) ███████████████░ ~15.3 s █████████████░░░ ~12.7 s █████████████░░░ ~13.0 s
After ███░░░░░░░░░░░░░ ~2.9 s ░░░░░░░░░░░░░░░░ ~0.05 s ░░░░░░░░░░░░░░░░ ~0.29 s
Δ −81% −99.6% −98%

Per-trigger breakdown (branch, restart-cold): the policy_-triggered compute drops from 12,512ms to 10–14ms (seed/diff path); every remaining compute in the boot window is 9–72ms. The persisted baseline was verified end-to-end: after the run the derived value on disk contains 131 policy signatures plus the concierge/intro baselines, and a repeated restart takes the signature-diff path.

Small/medium accounts are unaffected: on the same account before seeding (2,461 reports), the restart rescan cost only ~0.3s inside a ~2s ContentLoad, and the compute path is unchanged whenever anything actually changed. The gain scales with report count — exactly the accounts the Sentry dashboard flags — with the pathological rescan eliminated rather than made faster.

Conclusions. The entire tail of ContentLoad on large accounts in this scenario was one synchronous full rescan triggered by restart amnesia, not by any real data change. Persisting the three recompute baselines in the derived value removes it while keeping every legitimate invalidation path (locale change, display-name changes, real policy/concierge/intro changes) intact. The new derived_trigger span attribute will confirm the same distribution on production data.

Fixed Issues

$
PROPOSAL:

Tests

  1. Sign in with an account that has multiple workspaces, chats and expenses (ideally a High Traffic account).
  2. Let the app settle, then reload the page (web) or restart the app (native) — this exercises the restart path the PR changes.
  3. Verify the Inbox (LHN) shows correct report names, unread states and RBR/GBR indicators, exactly as before the reload.
  4. Open the Reports tab — verify the list loads and report names are correct.
  5. In Settings > Workspaces, change a tracked policy field (e.g. switch Workflows > Approvals mode) — verify reports of that workspace update their indicators/names accordingly (recompute on real change still works).
  6. Change the app language in Settings > Preferences — verify report names in the LHN and Reports switch to the new language (full recompute on locale change still works).
  7. Reload once more — verify names/indicators stay correct after the restart with the new baselines persisted.
  • Verify that no errors appear in the JS console

Offline tests

Unnecessary — this PR only changes when the locally-derived reportAttributes value recomputes (change detection baselines are now persisted with the value); it does not touch any network or persistence logic beyond the derived value itself.

QA Steps

Same as tests.

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari

…rigger full rescans

Policy change detection now compares signatures stored in the derived value
instead of a module-level snapshot that resets on every app restart, so the
first post-startup policy merge no longer rescans every report. The same
value-comparison guard is applied to conciergeReportID and introSelected,
which previously forced a full recompute whenever the key was re-delivered.
Each OnyxDerivedCompute span now carries a derived_trigger attribute.
Policy signatures now hash the whole policy content (minus Onyx write-bookkeeping
keys) via a stable stringify, so no consumed policy field can fall outside change
detection; the old full-rescan-on-reconnect was the accidental healer for fields
like policy.name. Signatures advance only when the recompute they imply actually
ran (or had no reports to touch), a missing baseline with computed attributes
falls back to recomputing the delivered policies' reports, a full scan snapshots
the baseline even without a policy trigger, and the concierge/introSelected
baselines advance only on full-recompute passes so a drifted value still heals
on its next delivery.
The concierge baseline sentinel is an empty string because Onyx.set strips
nested null values on persist. Policy signatures additionally exclude loading
flags, lastModified and the accounting connections subtree (volatile and never
read by the compute), and carry a format version so older stored signatures
degrade to one scoped recompute. The all-policies snapshot is built once per
pass and useIncrementalUpdates reuses hasComputedReports.
@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ Changes either increased or maintained existing code coverage, great job!

Files with missing lines Coverage Δ
src/CONST/index.ts 94.81% <ø> (ø)
src/libs/actions/OnyxDerived/index.ts 97.05% <100.00%> (+0.04%) ⬆️
...bs/actions/OnyxDerived/configs/reportAttributes.ts 98.08% <97.70%> (+0.02%) ⬆️
... and 34 files with indirect coverage changes

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant