Skip to content

Commit bac9667

Browse files
committed
Measure personalDetailsList as single key vs collection
1 parent 0fb7478 commit bac9667

2 files changed

Lines changed: 234 additions & 30 deletions

File tree

src/libs/telemetry/instrumentPersonalDetailsMerge.ts

Lines changed: 123 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -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

2135
const 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+
2343
let 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

3055
function 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.
4174
function 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
*/
83177
function 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.
89188
Onyx.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;
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import ONYXKEYS from '@src/ONYXKEYS';
2+
import type {PersonalDetails} from '@src/types/onyx';
3+
4+
import Onyx from 'react-native-onyx';
5+
import {measureAsyncFunction} from 'reassure';
6+
7+
import createPersonalDetails from '../utils/collections/personalDetails';
8+
9+
/**
10+
* A/B for the two shapes `personalDetailsList` could have, on identical data:
11+
* - single key — one object at `personalDetailsList` holding every member (today)
12+
* - collection — one Onyx key per member under `personalDetailsShadow_`
13+
*
14+
* Jest resolves Onyx storage to `MemoryOnlyProvider`, so these numbers are the JS-side cost only:
15+
* `mergeChanges` allocating a full copy, `cache.hasValueChanged` deep-equalling the result, and the
16+
* subscriber broadcast. IndexedDB/SQLite write cost is not included — see
17+
* `src/libs/telemetry/instrumentPersonalDetailsMerge.ts` for real-device numbers.
18+
*/
19+
20+
const COLLECTION_KEY = ONYXKEYS.COLLECTION.PERSONAL_DETAILS_SHADOW;
21+
22+
/** Member counts to seed before appending, spanning a small account up to a large domain */
23+
const SIZES = [1000, 5000, 20000];
24+
25+
/**
26+
* Both shapes carry this many subscribers, and every one of them watches the member being written, so
27+
* both broadcast to the same 50 callbacks. That is deliberately conservative: it throws away the
28+
* collection's real advantage — a member change wakes only that member's watchers, where the single key
29+
* wakes all ~300 of its subscribers — so whatever gap remains is merge/deep-equal/storage cost alone.
30+
*/
31+
const SUBSCRIBER_COUNT = 50;
32+
33+
/** The member every write targets, and every subscriber watches. Must exist before subscribing. */
34+
const TARGET_ACCOUNT_ID = 0;
35+
36+
/** Non-nullable values, so the same data can seed both a `merge` and a `mergeCollection` without a cast */
37+
function buildMembers(size: number): Record<number, PersonalDetails> {
38+
const members: Record<number, PersonalDetails> = {};
39+
for (let i = 0; i < size; i++) {
40+
members[i] = createPersonalDetails(i);
41+
}
42+
return members;
43+
}
44+
45+
function toCollection(members: Record<number, PersonalDetails>): Record<`${typeof COLLECTION_KEY}${number}`, PersonalDetails> {
46+
const collection: Record<`${typeof COLLECTION_KEY}${number}`, PersonalDetails> = {};
47+
for (const [accountID, member] of Object.entries(members)) {
48+
collection[`${COLLECTION_KEY}${Number(accountID)}`] = member;
49+
}
50+
return collection;
51+
}
52+
53+
/**
54+
* `reuseConnection: false` matters: identical key + config would otherwise be deduped into one shared
55+
* connection by OnyxConnectionManager, collapsing the fleet to a single subscriber.
56+
*/
57+
function subscribeAll(key: typeof ONYXKEYS.PERSONAL_DETAILS_LIST | `${typeof COLLECTION_KEY}${number}`): () => void {
58+
const connections = Array.from({length: SUBSCRIBER_COUNT}, () =>
59+
Onyx.connectWithoutView({
60+
key,
61+
reuseConnection: false,
62+
callback: (value) => value,
63+
}),
64+
);
65+
66+
return () => {
67+
for (const connection of connections) {
68+
Onyx.disconnect(connection);
69+
}
70+
};
71+
}
72+
73+
/**
74+
* Each iteration has to write a value that differs from the last one, otherwise `hasValueChanged`
75+
* short-circuits and both the storage write and the broadcast are skipped — measuring nothing.
76+
*/
77+
function makeWrite(accountID: number): (iteration: number) => Partial<PersonalDetails> {
78+
return (iteration) => ({accountID, displayName: `written-${iteration}`});
79+
}
80+
81+
describe('personalDetailsList shape', () => {
82+
afterEach(() => Onyx.clear());
83+
84+
describe.each(SIZES)('%i existing members', (size) => {
85+
test('single key: write one member', async () => {
86+
const members = buildMembers(size);
87+
await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, members);
88+
const unsubscribe = subscribeAll(ONYXKEYS.PERSONAL_DETAILS_LIST);
89+
90+
const write = makeWrite(TARGET_ACCOUNT_ID);
91+
let iteration = 0;
92+
93+
await measureAsyncFunction(() => Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[TARGET_ACCOUNT_ID]: write(iteration++)}));
94+
95+
unsubscribe();
96+
});
97+
98+
test('collection: write one member', async () => {
99+
const members = buildMembers(size);
100+
await Onyx.mergeCollection(COLLECTION_KEY, toCollection(members));
101+
const unsubscribe = subscribeAll(`${COLLECTION_KEY}${TARGET_ACCOUNT_ID}`);
102+
103+
const write = makeWrite(TARGET_ACCOUNT_ID);
104+
let iteration = 0;
105+
106+
await measureAsyncFunction(() => Onyx.merge(`${COLLECTION_KEY}${TARGET_ACCOUNT_ID}`, write(iteration++)));
107+
108+
unsubscribe();
109+
});
110+
});
111+
});

0 commit comments

Comments
 (0)