|
| 1 | +/** |
| 2 | + * MultiGet Real Data Benchmark |
| 3 | + * |
| 4 | + * Measures multiGet performance on real OnyxDB data (not synthetic). |
| 5 | + * Use this to get before/after numbers when changing SQLiteProvider.multiGet. |
| 6 | + * |
| 7 | + * Workflow: |
| 8 | + * 1. Run "Measure current" → records baseline |
| 9 | + * 2. Change the multiGet implementation in SQLiteProvider |
| 10 | + * 3. Rebuild app |
| 11 | + * 4. Run "Measure current" again → compare to baseline |
| 12 | + * |
| 13 | + * Access via Test Tools Modal. |
| 14 | + */ |
| 15 | +import Clipboard from '@react-native-clipboard/clipboard'; |
| 16 | +import React, {useCallback, useState} from 'react'; |
| 17 | +import {Platform, StyleSheet, View} from 'react-native'; |
| 18 | +// eslint-disable-next-line no-restricted-imports |
| 19 | +import storage from 'react-native-onyx/dist/storage'; |
| 20 | +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; |
| 21 | +import Text from '@components/Text'; |
| 22 | + |
| 23 | +const RUNS = 40; |
| 24 | +const LOOPS = 6; |
| 25 | +const MIN_COLLECTION_SIZE = 200; // only benchmark collections with enough keys to be meaningful |
| 26 | +const PAUSE_BETWEEN_COLLECTIONS_MS = 200; |
| 27 | + |
| 28 | +const localStyles = StyleSheet.create({ |
| 29 | + title: {fontSize: 16, fontWeight: 'bold', marginTop: 20, marginBottom: 2}, |
| 30 | + sub: {fontSize: 11, color: '#666', marginBottom: 12}, |
| 31 | + btn: {backgroundColor: '#0366d6', padding: 12, borderRadius: 8, alignItems: 'center', marginBottom: 8}, |
| 32 | + btnOff: {backgroundColor: '#999'}, |
| 33 | + btnTxt: {color: '#fff', fontWeight: '600', fontSize: 14}, |
| 34 | + status: {fontSize: 11, color: '#666', marginBottom: 12, fontStyle: 'italic'}, |
| 35 | + group: {marginBottom: 12, borderWidth: 1, borderColor: '#ddd', borderRadius: 6, padding: 10}, |
| 36 | + collectionTitle: {fontSize: 12, fontWeight: '600', marginBottom: 4}, |
| 37 | + row: {flexDirection: 'row', paddingVertical: 2}, |
| 38 | + c: {flex: 1, fontSize: 11, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace'}, |
| 39 | + cWide: {flex: 2, fontSize: 11, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace'}, |
| 40 | + hRow: {flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#ddd', paddingBottom: 3, marginBottom: 4}, |
| 41 | + hTxt: {fontWeight: '600', fontSize: 10}, |
| 42 | +}); |
| 43 | + |
| 44 | +type CollectionResult = { |
| 45 | + prefix: string; |
| 46 | + keyCount: number; |
| 47 | + median: number; |
| 48 | + min: number; |
| 49 | + max: number; |
| 50 | +}; |
| 51 | + |
| 52 | +// eslint-disable-next-line no-promise-executor-return |
| 53 | +const pause = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)); |
| 54 | + |
| 55 | + |
| 56 | +/** Extract collection prefix from a key like "reportActions_12345" → "reportActions_" */ |
| 57 | +function getPrefix(key: string): string { |
| 58 | + const match = key.match(/^([a-zA-Z]+_)/); |
| 59 | + return match ? match[1] : key; |
| 60 | +} |
| 61 | + |
| 62 | +async function measureCollection(keys: string[]): Promise<{median: number; min: number; max: number}> { |
| 63 | + // 3 warmup runs |
| 64 | + await storage.multiGet(keys); |
| 65 | + await storage.multiGet(keys); |
| 66 | + await storage.multiGet(keys); |
| 67 | + |
| 68 | + const loopMedians: number[] = []; |
| 69 | + let overallMin = Infinity; |
| 70 | + let overallMax = 0; |
| 71 | + |
| 72 | + for (let loop = 0; loop < LOOPS; loop++) { |
| 73 | + const times: number[] = []; |
| 74 | + for (let i = 0; i < RUNS; i++) { |
| 75 | + const start = performance.now(); |
| 76 | + // eslint-disable-next-line no-await-in-loop |
| 77 | + await storage.multiGet(keys); |
| 78 | + times.push(performance.now() - start); |
| 79 | + } |
| 80 | + times.sort((a, b) => a - b); |
| 81 | + const loopMedian = times.at(Math.floor(times.length / 2)) ?? 0; |
| 82 | + loopMedians.push(loopMedian); |
| 83 | + overallMin = Math.min(overallMin, times.at(0) ?? 0); |
| 84 | + overallMax = Math.max(overallMax, times.at(-1) ?? 0); |
| 85 | + // eslint-disable-next-line no-await-in-loop |
| 86 | + await pause(100); |
| 87 | + } |
| 88 | + |
| 89 | + loopMedians.sort((a, b) => a - b); |
| 90 | + return { |
| 91 | + median: loopMedians.at(Math.floor(loopMedians.length / 2)) ?? 0, |
| 92 | + min: overallMin, |
| 93 | + max: overallMax, |
| 94 | + }; |
| 95 | +} |
| 96 | + |
| 97 | +function MultiGetBenchmark() { |
| 98 | + const [results, setResults] = useState<CollectionResult[]>([]); |
| 99 | + const [baseline, setBaseline] = useState<CollectionResult[] | null>(null); |
| 100 | + const [running, setRunning] = useState(false); |
| 101 | + const [status, setStatus] = useState('Ready — loads real OnyxDB collections'); |
| 102 | + |
| 103 | + const runMeasurement = useCallback( |
| 104 | + async (saveAsBaseline: boolean) => { |
| 105 | + if (Platform.OS === 'web') { |
| 106 | + setStatus('Only runs on iOS/Android'); |
| 107 | + return; |
| 108 | + } |
| 109 | + setRunning(true); |
| 110 | + setResults([]); |
| 111 | + |
| 112 | + try { |
| 113 | + setStatus('Loading all keys from OnyxDB...'); |
| 114 | + const allKeys = await storage.getAllKeys(); |
| 115 | + |
| 116 | + // Group keys by collection prefix |
| 117 | + const prefixMap = new Map<string, string[]>(); |
| 118 | + for (const key of allKeys) { |
| 119 | + const prefix = getPrefix(key); |
| 120 | + const existing = prefixMap.get(prefix) ?? []; |
| 121 | + if (!prefixMap.has(prefix)) { |
| 122 | + prefixMap.set(prefix, existing); |
| 123 | + } |
| 124 | + existing.push(key); |
| 125 | + } |
| 126 | + |
| 127 | + // Only measure collections large enough to matter |
| 128 | + const collections = Array.from(prefixMap.entries()) |
| 129 | + .filter(([, keys]) => keys.length >= MIN_COLLECTION_SIZE) |
| 130 | + .sort(([, a], [, b]) => b.length - a.length); // largest first |
| 131 | + |
| 132 | + if (collections.length === 0) { |
| 133 | + setStatus(`No collections with ${MIN_COLLECTION_SIZE}+ keys found. Try with a loaded account.`); |
| 134 | + setRunning(false); |
| 135 | + return; |
| 136 | + } |
| 137 | + |
| 138 | + setStatus(`Found ${collections.length} collections. Benchmarking...`); |
| 139 | + const collectionResults: CollectionResult[] = []; |
| 140 | + |
| 141 | + for (const [prefix, keys] of collections) { |
| 142 | + setStatus(`Measuring ${prefix} (${keys.length} keys)...`); |
| 143 | + // eslint-disable-next-line no-await-in-loop |
| 144 | + await new Promise<void>((resolve) => requestAnimationFrame(() => resolve())); |
| 145 | + // eslint-disable-next-line no-await-in-loop |
| 146 | + await pause(PAUSE_BETWEEN_COLLECTIONS_MS); |
| 147 | + |
| 148 | + // eslint-disable-next-line no-await-in-loop |
| 149 | + const r = await measureCollection(keys); |
| 150 | + collectionResults.push({prefix, keyCount: keys.length, ...r}); |
| 151 | + setResults([...collectionResults]); |
| 152 | + } |
| 153 | + |
| 154 | + if (saveAsBaseline) { |
| 155 | + setBaseline(collectionResults); |
| 156 | + setStatus(`Baseline saved! ${collections.length} collections measured.`); |
| 157 | + } else { |
| 158 | + setStatus(`Done! ${collections.length} collections measured.`); |
| 159 | + } |
| 160 | + } catch (e) { |
| 161 | + const msg = e instanceof Error ? e.message : String(e); |
| 162 | + setStatus(`Error: ${msg}`); |
| 163 | + // eslint-disable-next-line no-console |
| 164 | + console.error('MultiGet benchmark error:', e); |
| 165 | + } finally { |
| 166 | + setRunning(false); |
| 167 | + } |
| 168 | + }, |
| 169 | + [], |
| 170 | + ); |
| 171 | + |
| 172 | + const copyResults = useCallback(() => { |
| 173 | + const output = JSON.stringify( |
| 174 | + { |
| 175 | + platform: Platform.OS, |
| 176 | + timestamp: new Date().toISOString(), |
| 177 | + config: {runs: RUNS, minCollectionSize: MIN_COLLECTION_SIZE}, |
| 178 | + baseline: baseline ?? [], |
| 179 | + current: results, |
| 180 | + }, |
| 181 | + null, |
| 182 | + 2, |
| 183 | + ); |
| 184 | + Clipboard.setString(output); |
| 185 | + setStatus('Copied to clipboard!'); |
| 186 | + }, [baseline, results]); |
| 187 | + |
| 188 | + const baselineMap = new Map(baseline?.map((r) => [r.prefix, r]) ?? []); |
| 189 | + |
| 190 | + return ( |
| 191 | + <View> |
| 192 | + <Text style={localStyles.title}>multiGet — Real Data Benchmark</Text> |
| 193 | + <Text style={localStyles.sub}> |
| 194 | + Measures Storage.multiGet on real OnyxDB collections.{'\n'} |
| 195 | + {LOOPS} loops x {RUNS} runs, median of medians. Collections with {MIN_COLLECTION_SIZE}+ keys only. |
| 196 | + </Text> |
| 197 | + |
| 198 | + <PressableWithFeedback |
| 199 | + accessibilityRole="button" |
| 200 | + accessibilityLabel="Save as baseline" |
| 201 | + style={[localStyles.btn, {backgroundColor: '#6f42c1'}, running && localStyles.btnOff]} |
| 202 | + onPress={() => runMeasurement(true)} |
| 203 | + disabled={running} |
| 204 | + > |
| 205 | + <Text style={localStyles.btnTxt}>{running ? 'Running...' : 'Save as Baseline'}</Text> |
| 206 | + </PressableWithFeedback> |
| 207 | + |
| 208 | + <PressableWithFeedback |
| 209 | + accessibilityRole="button" |
| 210 | + accessibilityLabel="Measure current" |
| 211 | + style={[localStyles.btn, running && localStyles.btnOff]} |
| 212 | + onPress={() => runMeasurement(false)} |
| 213 | + disabled={running} |
| 214 | + > |
| 215 | + <Text style={localStyles.btnTxt}>{running ? 'Running...' : 'Measure Current'}</Text> |
| 216 | + </PressableWithFeedback> |
| 217 | + |
| 218 | + {results.length > 0 && !running && ( |
| 219 | + <PressableWithFeedback |
| 220 | + accessibilityRole="button" |
| 221 | + accessibilityLabel="Copy results" |
| 222 | + style={[localStyles.btn, {backgroundColor: '#28a745'}]} |
| 223 | + onPress={copyResults} |
| 224 | + > |
| 225 | + <Text style={localStyles.btnTxt}>Copy Results</Text> |
| 226 | + </PressableWithFeedback> |
| 227 | + )} |
| 228 | + |
| 229 | + <Text style={localStyles.status}>{status}</Text> |
| 230 | + |
| 231 | + {results.length > 0 && ( |
| 232 | + <View style={localStyles.group}> |
| 233 | + <View style={localStyles.hRow}> |
| 234 | + <Text style={[localStyles.cWide, localStyles.hTxt]}>Collection</Text> |
| 235 | + <Text style={[localStyles.c, localStyles.hTxt]}>Keys</Text> |
| 236 | + <Text style={[localStyles.c, localStyles.hTxt]}>Median</Text> |
| 237 | + <Text style={[localStyles.c, localStyles.hTxt]}>vs baseline</Text> |
| 238 | + </View> |
| 239 | + {results.map((r) => { |
| 240 | + const base = baselineMap.get(r.prefix); |
| 241 | + const diff = base ? ((r.median - base.median) / base.median) * 100 : null; |
| 242 | + const diffStr = diff == null ? '—' : `${diff > 0 ? '+' : ''}${diff.toFixed(1)}%`; |
| 243 | + // eslint-disable-next-line no-nested-ternary |
| 244 | + const clr = diff == null ? '#666' : diff < -5 ? '#2d8a4e' : diff > 5 ? '#d32f2f' : '#666'; |
| 245 | + return ( |
| 246 | + <View |
| 247 | + key={r.prefix} |
| 248 | + style={localStyles.row} |
| 249 | + > |
| 250 | + <Text style={localStyles.cWide}>{r.prefix}</Text> |
| 251 | + <Text style={localStyles.c}>{r.keyCount}</Text> |
| 252 | + <Text style={localStyles.c}>{r.median.toFixed(2)}ms</Text> |
| 253 | + <Text style={[localStyles.c, {color: clr}]}>{diffStr}</Text> |
| 254 | + </View> |
| 255 | + ); |
| 256 | + })} |
| 257 | + </View> |
| 258 | + )} |
| 259 | + </View> |
| 260 | + ); |
| 261 | +} |
| 262 | + |
| 263 | +MultiGetBenchmark.displayName = 'MultiGetBenchmark'; |
| 264 | + |
| 265 | +export default MultiGetBenchmark; |
0 commit comments