@@ -13,19 +13,44 @@ import Onyx from 'react-native-onyx';
1313 * with how many keys the object already holds. Patches Onyx at startup instead of touching every
1414 * call site, so both local merges and server-driven updates are covered.
1515 *
16- * Every append is then mirrored into the `personalDetailsShadow_` collection and timed the same way,
17- * so the single-key and collection shapes can be compared on identical data. The mirror is
18- * write-only — nothing subscribes to it, so it cannot affect app behaviour.
16+ * Every write is then mirrored into the `personalDetailsShadow_` collection and timed the same way, so
17+ * the single-key and collection shapes can be compared on identical data. Nothing in the app reads the
18+ * mirror, so it cannot affect app behaviour.
19+ *
20+ * REQUIRES A COLD START: clear site data (or at least every `personalDetailsShadow_` key) before each
21+ * run. The mirror is deliberately *not* pre-seeded from the existing list — it accumulates only from the
22+ * writes it observes, so both shapes see the identical write sequence starting from empty. If stale
23+ * mirror data survives from a previous run, every mirror write finds the member already byte-identical,
24+ * `hasValueChanged` short-circuits it, and the collection posts near-zero durations against real
25+ * single-key writes. That is a silent failure, so every collection sample logs `changedMembers`: pair a
26+ * single-key line with a collection line only when their changed counts match.
27+ *
28+ * A synthetic subscriber fleet is attached to the mirror because the comparison is otherwise rigged:
29+ * the single key broadcasts every write to its ~300 real subscribers, and a mirror with none would
30+ * win on that alone. Each synthetic subscriber watches one member key, which is what the migration
31+ * would produce. `shadowSubscribers` is logged on every line so a run is self-describing; set it to 0
32+ * to measure the write path in isolation.
1933 */
2034
2135const SHADOW_KEY = ONYXKEYS . COLLECTION . PERSONAL_DETAILS_SHADOW ;
2236
37+ /**
38+ * Kept in the same order of magnitude as the real `personalDetailsList` subscriber count. Grep
39+ * `ONYXKEYS.PERSONAL_DETAILS_LIST` under src/ to re-check it before trusting a run.
40+ */
41+ const SHADOW_SUBSCRIBER_COUNT = 300 ;
42+
2343let existingKeyCount = 0 ;
2444
25- /** Account IDs written to the shadow collection, so we can report its size without subscribing to it */
26- const shadowAccountIDs = new Set < string > ( ) ;
45+ /**
46+ * Account ID -> serialised value last written to the mirror. A Set of IDs was not enough: knowing an ID
47+ * was mirrored before says nothing about whether the incoming value differs, and Onyx short-circuits on
48+ * value equality, not key presence. Keeping the value lets each sample report how many members were
49+ * genuinely written (`changedMembers`) versus how many the collection path skipped for free.
50+ */
51+ const mirroredMembers = new Map < string , string > ( ) ;
2752
28- let hasSeededShadowCollection = false ;
53+ const shadowConnections : Array < ReturnType < typeof Onyx . connectWithoutView > > = [ ] ;
2954
3055function countKeys ( value : unknown ) : number {
3156 return typeof value === 'object' && value !== null ? Object . keys ( value ) . length : 0 ;
@@ -36,68 +61,134 @@ function isPersonalDetailsChanges(value: unknown): value is PersonalDetailsList
3661 return typeof value === 'object' && value !== null && ! Array . isArray ( value ) ;
3762}
3863
64+ /**
65+ * Measured writes currently in flight. Anything above zero means this sample is sharing the JS thread
66+ * and the IndexedDB write queue with another sample — and if the other one is a merge to the same key,
67+ * Onyx's `mergeQueue` hands both callers the *same* promise, so both "durations" end at one instant and
68+ * neither is the cost of its own write.
69+ */
70+ let inFlightWrites = 0 ;
71+
3972// console.log instead of Log.info: Log's client callback uses console.debug, which is hidden
4073// behind the Verbose level in Chrome DevTools.
4174function measure < T > ( source : string , existingKeys : number , incomingKeys : number , extraParams : Record < string , unknown > , promise : Promise < T > ) : Promise < T > {
4275 const startTime = performance . now ( ) ;
76+ const concurrentWrites = inFlightWrites ;
77+ inFlightWrites ++ ;
4378
4479 return promise . finally ( ( ) => {
45- console . log ( '[PersonalDetailsListPerf] append' , {
80+ inFlightWrites -- ;
81+ console . log ( '[PersonalDetailsListPerf] write' , {
4682 source,
4783 existingKeys,
4884 incomingKeys,
4985 durationMs : Math . round ( ( performance . now ( ) - startTime ) * 100 ) / 100 ,
86+ // Filter on this. `false` means the sample overlapped another measured write, so its duration
87+ // is contention plus possible `mergeQueue` promise-sharing, not the cost of the write it names.
88+ // It does NOT judge whether the paired write did equivalent work — compare `changedMembers`
89+ // between the two sources for that.
90+ comparable : concurrentWrites === 0 ,
91+ concurrentWrites,
5092 ...extraParams ,
5193 } ) ;
5294 } ) ;
5395}
5496
55- function mergeShadowCollection ( source : string , changes : PersonalDetailsList , extraParams : Record < string , unknown > ) {
97+ function mergeShadowCollection ( source : string , changes : PersonalDetailsList , extraParams : Record < string , unknown > ) : Promise < unknown > {
5698 const accountIDs = Object . keys ( changes ) ;
5799
58100 if ( accountIDs . length === 0 ) {
59- return ;
101+ return Promise . resolve ( ) ;
60102 }
61103
62104 // Read before mutating, so it matches how the single-key path reports `existingKeys`
63- const existingKeys = shadowAccountIDs . size ;
105+ const existingKeys = mirroredMembers . size ;
64106
107+ // `mergeCollection` cannot carry a null member, so removals go out as individual member merges.
108+ // They are applied for mirror correctness but left untimed — appends are what's being measured.
65109 const collection : OnyxMergeCollectionInput < typeof SHADOW_KEY > = { } ;
110+ let upsertCount = 0 ;
111+ let changedMembers = 0 ;
66112 for ( const accountID of accountIDs ) {
67- collection [ ` ${ SHADOW_KEY } ${ accountID } ` ] = changes [ accountID ] ;
113+ const member = changes [ accountID ] ;
68114
69- if ( changes [ accountID ] === null ) {
70- shadowAccountIDs . delete ( accountID ) ;
71- } else {
72- shadowAccountIDs . add ( accountID ) ;
115+ if ( member === null ) {
116+ mirroredMembers . delete ( accountID ) ;
117+ Onyx . merge ( ` ${ SHADOW_KEY } ${ accountID } ` , null ) ;
118+ continue ;
73119 }
120+
121+ // Onyx short-circuits a member whose value is unchanged, so only differing members cost anything.
122+ // This is what makes a collection sample comparable to the single-key one: the single key does real
123+ // work whenever *any* member differs, so the two are only equivalent if the changed counts match.
124+ const serialised = JSON . stringify ( member ) ;
125+ if ( mirroredMembers . get ( accountID ) !== serialised ) {
126+ changedMembers ++ ;
127+ }
128+ mirroredMembers . set ( accountID , serialised ) ;
129+
130+ collection [ `${ SHADOW_KEY } ${ accountID } ` ] = member ;
131+ upsertCount ++ ;
132+ }
133+
134+ if ( upsertCount === 0 ) {
135+ return Promise . resolve ( ) ;
74136 }
75137
76- measure ( source , existingKeys , accountIDs . length , extraParams , Onyx . mergeCollection ( SHADOW_KEY , collection ) ) ;
138+ return measure ( source , existingKeys , upsertCount , { ... extraParams , shadowSubscribers : shadowConnections . length , changedMembers } , Onyx . mergeCollection ( SHADOW_KEY , collection ) ) ;
77139}
78140
79141/**
80- * Mirrors an append after the single-key write settles. Running them concurrently would make the two
142+ * Attached once the mirror first holds members, spread across the members written so far, so later
143+ * writes land on a subscribed key as often as they would after a migration.
144+ */
145+ function attachShadowSubscribers ( ) {
146+ const mirroredAccountIDs = [ ...mirroredMembers . keys ( ) ] ;
147+
148+ if ( mirroredAccountIDs . length === 0 || shadowConnections . length > 0 ) {
149+ return ;
150+ }
151+
152+ for ( let i = 0 ; i < SHADOW_SUBSCRIBER_COUNT ; i ++ ) {
153+ const accountID = mirroredAccountIDs . at ( i % mirroredAccountIDs . length ) ;
154+ shadowConnections . push (
155+ Onyx . connectWithoutView ( {
156+ key : `${ SHADOW_KEY } ${ accountID } ` as const ,
157+ // reuseConnection: false, or identical key+config would collapse the fleet into one connection
158+ reuseConnection : false ,
159+ // Reading the value is the point: it's what a real per-member subscriber costs
160+ callback : ( member ) => member ?. accountID ,
161+ } ) ,
162+ ) ;
163+ }
164+ }
165+
166+ /**
167+ * Every mirror write runs through this one chain. Two single-key merges to the same key inside one tick
168+ * share a `mergeQueue` promise, so both `.finally` callbacks fire at the same instant — without the chain
169+ * their mirrors would run concurrently and each would time the other's contention.
170+ */
171+ let mirrorChain : Promise < unknown > = Promise . resolve ( ) ;
172+
173+ /**
174+ * Mirrors a write after the single-key write settles. Running them concurrently would make the two
81175 * shapes fight over the same JS thread and storage, so neither measurement would mean anything.
82176 */
83177function mirrorAfter < T > ( promise : Promise < T > , changes : PersonalDetailsList , extraParams : Record < string , unknown > ) : Promise < T > {
84178 return promise . finally ( ( ) => {
85- mergeShadowCollection ( 'collection' , changes , extraParams ) ;
179+ mirrorChain = mirrorChain
180+ . then ( ( ) => mergeShadowCollection ( 'collection' , changes , extraParams ) )
181+ . then ( attachShadowSubscribers )
182+ . catch ( ( ) => undefined ) ;
86183 } ) ;
87184}
88185
186+ // Tracks how many members the single key already holds, so each sample can be correlated with N.
187+ // The mirror is intentionally not seeded from this value — see the cold-start note at the top.
89188Onyx . connectWithoutView ( {
90189 key : ONYXKEYS . PERSONAL_DETAILS_LIST ,
91190 callback : ( value ) => {
92191 existingKeyCount = value ? Object . keys ( value ) . length : 0 ;
93-
94- // The shadow collection has to start from the same data as the single key, otherwise every
95- // measurement would compare an append to N keys against an append to an almost empty collection.
96- if ( hasSeededShadowCollection || ! value || existingKeyCount === 0 ) {
97- return ;
98- }
99- hasSeededShadowCollection = true ;
100- mergeShadowCollection ( 'collection-seed' , value , { } ) ;
101192 } ,
102193} ) ;
103194
@@ -121,7 +212,8 @@ export default function instrumentPersonalDetailsMerge() {
121212 return promise ;
122213 }
123214
124- const measuredPromise = measure ( 'single-key' , existingKeyCount , countKeys ( changes ) , { } , promise ) ;
215+ const existingKeys = existingKeyCount ;
216+ const measuredPromise = measure ( 'single-key' , existingKeys , countKeys ( changes ) , { } , promise ) ;
125217
126218 return isPersonalDetailsChanges ( changes ) ? mirrorAfter ( measuredPromise , changes , { } ) : measuredPromise ;
127219 } ) as typeof Onyx . merge ;
@@ -134,16 +226,17 @@ export default function instrumentPersonalDetailsMerge() {
134226 return promise ;
135227 }
136228
137- // ponytail: an Onyx.update batch resolves as a whole, so durationMs covers the sibling keys too.
138- // `updatesInBatch` is logged to spot the noisy samples; measure the isolated cost inside Onyx if that is not enough.
229+ // An Onyx.update batch resolves as a whole, so durationMs covers the sibling keys too.
230+ // `updatesInBatch` is logged to spot the noisy samples; measure inside Onyx if that is not enough.
139231 const extraParams = { updatesInBatch : updates . length } ;
140232 const changes : PersonalDetailsList = { } ;
141233 for ( const update of personalDetailsUpdates ) {
142234 if ( isPersonalDetailsChanges ( update . value ) ) {
143235 Object . assign ( changes , update . value ) ;
144236 }
145237 }
146- const measuredPromise = measure ( 'single-key' , existingKeyCount , countKeys ( changes ) , extraParams , promise ) ;
238+ const existingKeys = existingKeyCount ;
239+ const measuredPromise = measure ( 'single-key' , existingKeys , countKeys ( changes ) , extraParams , promise ) ;
147240
148241 return mirrorAfter ( measuredPromise , changes , extraParams ) ;
149242 } ) as typeof Onyx . update ;
0 commit comments