Persist reportAttributes recompute baselines so app restarts do not t…#96071
Draft
sumo-slonik wants to merge 4 commits into
Draft
Persist reportAttributes recompute baselines so app restarts do not t…#96071sumo-slonik wants to merge 4 commits into
sumo-slonik wants to merge 4 commits into
Conversation
…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 Report✅ Changes either increased or maintained existing code coverage, great job!
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Explanation of Change
On the Sentry "P90 vital metrics - large accounts" dashboard,
ManualNavigateToReportsContentLoadreaches ~24s p90 on the largest accounts (5k+ reports), withManualNavigateToInboxTabp99 spiking to 76s. Production spans show singleOnyxDerivedComputeruns forreportAttributestaking 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 firstpolicy_merge after every app start rescans every report.The root cause is that
reportAttributeskeeps its policy change-detection baseline (previousPolicies) in a module-level variable, which resets toundefinedon every app restart.hasPolicyRelevantFieldChanged(undefined, policy)returnstrue, so when OpenApp/Reconnect re-delivers the policies collection right after startup — which it always does — every policy counts as "changed" andpolicyChangedReportKeysexpands 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:conciergeReportIDandnvp_introSelectedforce a full recompute whenever the key merely appears in a merge, without checking whether the value changed.Fix
localeguard).policyRelevantSignatureserializes exactly the six fields the old field-by-field comparison tracked; signatures are stored per policy key inReportAttributesDerivedValue.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).conciergeReportIDandintroSelected— 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.OnyxDerivedComputespan now carries aderived_triggerattribute, so production can confirm the trigger distribution of long computes per derived key.Performance results
Measured on web dev, spans
ManualNavigateToReportsContentLoadandOnyxDerivedCompute(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% onmainand <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).reportAttributescomputereportAttributescomputesmain)███████████████░~15.3 s█████████████░░░~12.7 s█████████████░░░~13.0 s███░░░░░░░░░░░░░~2.9 s░░░░░░░░░░░░░░░░~0.05 s░░░░░░░░░░░░░░░░~0.29 sPer-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_triggerspan attribute will confirm the same distribution on production data.Fixed Issues
$
PROPOSAL:
Tests
Offline tests
Unnecessary — this PR only changes when the locally-derived
reportAttributesvalue 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.
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectionAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari