diff --git a/src/components/TestToolMenu.tsx b/src/components/TestToolMenu.tsx index d7a12f398499..fa8d24e06b22 100644 --- a/src/components/TestToolMenu.tsx +++ b/src/components/TestToolMenu.tsx @@ -11,6 +11,8 @@ import useWaitForNavigation from '@hooks/useWaitForNavigation'; import {revokeMultifactorAuthenticationCredentials} from '@libs/actions/MultifactorAuthentication'; import {isUsingStagingApi} from '@libs/ApiUtils'; import Navigation from '@libs/Navigation/Navigation'; +import MultiGetBenchmark from '@pages/Debug/MultiGetBenchmark'; +import SQLiteBenchmark from '@pages/Debug/SQLiteBenchmark'; import {setShouldFailAllRequests, setShouldForceOffline, setShouldSimulatePoorConnection} from '@userActions/Network'; import {expireSessionWithDelay, invalidateAuthToken, invalidateCredentials} from '@userActions/Session'; import {setIsDebugModeEnabled, setShouldUseStagingServer} from '@userActions/User'; @@ -199,6 +201,8 @@ function TestToolMenu() { + + ); } diff --git a/src/pages/Debug/MultiGetBenchmark.tsx b/src/pages/Debug/MultiGetBenchmark.tsx new file mode 100644 index 000000000000..0ed1643c0d28 --- /dev/null +++ b/src/pages/Debug/MultiGetBenchmark.tsx @@ -0,0 +1,454 @@ +/** + * MultiGet Real Data Benchmark + * + * Measures Storage.multiGet on real OnyxDB collections for each SQL strategy. + * Toggle which collections to include, then tap "Run All Strategies". + * + * Access via Test Tools Modal. + */ +import Clipboard from '@react-native-clipboard/clipboard'; +import React, {useCallback, useRef, useState} from 'react'; +import {Platform, StyleSheet, View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import storage from 'react-native-onyx/dist/storage'; +// eslint-disable-next-line no-restricted-imports +import {setMultiGetStrategy, explainQueryPlan} from 'react-native-onyx/dist/storage/providers/SQLiteProvider'; +import type {MultiGetStrategy} from 'react-native-onyx/dist/storage/providers/SQLiteProvider'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import Text from '@components/Text'; + +const RUNS = 20; +const LOOPS = 3; +const PAUSE_BETWEEN_COLLECTIONS_MS = 300; + +// Hardcoded collections — toggle which ones to include in the benchmark +const ALL_COLLECTIONS = [ + 'transactions_', + 'reportActions_', + 'report_', + 'reportNameValuePairs_', + 'transactionViolations_', +]; + +const STRATEGIES: Array<{key: MultiGetStrategy; label: string}> = [ + {key: 'in_clause', label: 'IN clause'}, + {key: 'json_each_join', label: 'json_each JOIN'}, + {key: 'chunked_500', label: 'Chunk 500'}, + {key: 'temp_table', label: 'Temp table'}, +]; + +type CollectionResult = { + prefix: string; + keyCount: number; + median: number; + min: number; + max: number; +}; + +type AllResults = Partial>; +type SizeResult = {prefix: string; keyCount: number; avgKB: number; maxKB: number; avgKeyLen: number; sampleKey: string}; + +const localStyles = StyleSheet.create({ + title: {fontSize: 16, fontWeight: 'bold', marginTop: 20, marginBottom: 2}, + sub: {fontSize: 11, color: '#666', marginBottom: 8}, + sectionLabel: {fontSize: 11, fontWeight: '600', color: '#333', marginBottom: 4, marginTop: 8}, + toggleRow: {flexDirection: 'row', flexWrap: 'wrap', marginBottom: 8, gap: 6}, + toggle: {paddingHorizontal: 10, paddingVertical: 6, borderRadius: 6, borderWidth: 1, borderColor: '#0366d6'}, + toggleOn: {backgroundColor: '#0366d6'}, + toggleOff: {backgroundColor: '#fff'}, + toggleTxtOn: {color: '#fff', fontSize: 11, fontWeight: '600'}, + toggleTxtOff: {color: '#0366d6', fontSize: 11}, + btn: {backgroundColor: '#0366d6', padding: 12, borderRadius: 8, alignItems: 'center', marginBottom: 8}, + btnAll: {backgroundColor: '#e36209', padding: 12, borderRadius: 8, alignItems: 'center', marginBottom: 8}, + btnGray: {backgroundColor: '#6c757d', padding: 12, borderRadius: 8, alignItems: 'center', marginBottom: 8}, + btnOff: {backgroundColor: '#999'}, + btnTxt: {color: '#fff', fontWeight: '600', fontSize: 14}, + status: {fontSize: 11, color: '#666', marginBottom: 12, fontStyle: 'italic'}, + group: {marginBottom: 12, borderWidth: 1, borderColor: '#ddd', borderRadius: 6, padding: 10}, + groupTitle: {fontSize: 12, fontWeight: '600', marginBottom: 6, color: '#333'}, + row: {flexDirection: 'row', paddingVertical: 2}, + c: {flex: 1, fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace'}, + cWide: {flex: 1.8, fontSize: 10, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace'}, + hRow: {flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#ddd', paddingBottom: 3, marginBottom: 4}, + hTxt: {fontWeight: '600', fontSize: 10}, +}); + +// eslint-disable-next-line no-promise-executor-return +const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function getKeysForPrefix(prefix: string): Promise { + const allKeys = await storage.getAllKeys(); + return allKeys.filter((k) => k.startsWith(prefix)); +} + +async function measureCollection(keys: string[], prefix: string): Promise { + await storage.multiGet(keys); + await storage.multiGet(keys); + await storage.multiGet(keys); + + const loopMedians: number[] = []; + let overallMin = Infinity; + let overallMax = 0; + + for (let loop = 0; loop < LOOPS; loop++) { + const times: number[] = []; + for (let i = 0; i < RUNS; i++) { + const start = performance.now(); + // eslint-disable-next-line no-await-in-loop + await storage.multiGet(keys); + times.push(performance.now() - start); + } + times.sort((a, b) => a - b); + loopMedians.push(times.at(Math.floor(times.length / 2)) ?? 0); + overallMin = Math.min(overallMin, times.at(0) ?? 0); + overallMax = Math.max(overallMax, times.at(-1) ?? 0); + // eslint-disable-next-line no-await-in-loop + await pause(100); + } + + loopMedians.sort((a, b) => a - b); + return { + prefix, + keyCount: keys.length, + median: loopMedians.at(Math.floor(loopMedians.length / 2)) ?? 0, + min: overallMin, + max: overallMax, + }; +} + +function MultiGetBenchmark() { + const [selectedCollections, setSelectedCollections] = useState>(new Set(ALL_COLLECTIONS)); + const [allResults, setAllResults] = useState({}); + const [sizeResults, setSizeResults] = useState([]); + const [running, setRunning] = useState(false); + const [status, setStatus] = useState('Ready'); + const allResultsRef = useRef({}); + + const toggleCollection = useCallback((prefix: string) => { + setSelectedCollections((prev) => { + const next = new Set(prev); + if (next.has(prefix)) { + next.delete(prefix); + } else { + next.add(prefix); + } + return next; + }); + }, []); + + const runStrategy = useCallback( + async (strategy: MultiGetStrategy) => { + const label = STRATEGIES.find((s) => s.key === strategy)?.label ?? strategy; + setMultiGetStrategy(strategy); + + const collections = ALL_COLLECTIONS.filter((p) => selectedCollections.has(p)); + const strategyResults: CollectionResult[] = []; + + for (const prefix of collections) { + setStatus(`[${label}] Loading ${prefix}...`); + // eslint-disable-next-line no-await-in-loop + const keys = await getKeysForPrefix(prefix); + if (keys.length === 0) { + continue; + } + setStatus(`[${label}] ${prefix} (${keys.length} keys)...`); + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + // eslint-disable-next-line no-await-in-loop + await pause(PAUSE_BETWEEN_COLLECTIONS_MS); + // eslint-disable-next-line no-await-in-loop + const r = await measureCollection(keys, prefix); + strategyResults.push(r); + } + + allResultsRef.current = {...allResultsRef.current, [strategy]: strategyResults}; + setAllResults({...allResultsRef.current}); + setMultiGetStrategy('in_clause'); + }, + [selectedCollections], + ); + + const runAll = useCallback(() => { + setRunning(true); + setAllResults({}); + allResultsRef.current = {}; + + const run = async () => { + for (const strat of STRATEGIES) { + // eslint-disable-next-line no-await-in-loop + await runStrategy(strat.key); + } + setStatus(`All ${STRATEGIES.length} strategies complete!`); + }; + + run() + .catch((e) => setStatus(`Error: ${e instanceof Error ? e.message : String(e)}`)) + .finally(() => setRunning(false)); + }, [runStrategy]); + + const runSingle = useCallback( + (strategy: MultiGetStrategy) => { + setRunning(true); + runStrategy(strategy) + .then(() => setStatus('Done!')) + .catch((e) => setStatus(`Error: ${e instanceof Error ? e.message : String(e)}`)) + .finally(() => setRunning(false)); + }, + [runStrategy], + ); + + const runExplainQueryPlan = useCallback(() => { + setRunning(true); + setStatus('Running EXPLAIN QUERY PLAN...'); + + const run = async () => { + const output: Record = {}; + const collections = ALL_COLLECTIONS.filter((p) => selectedCollections.has(p)); + for (const prefix of collections) { + // eslint-disable-next-line no-await-in-loop + const keys = await getKeysForPrefix(prefix); + if (keys.length === 0) { + continue; + } + // Use ALL keys — query planner makes different decisions based on actual count + // eslint-disable-next-line no-await-in-loop + const plans = await explainQueryPlan(keys); + output[prefix] = {keyCount: keys.length, plans}; + } + const json = JSON.stringify({platform: Platform.OS, timestamp: new Date().toISOString(), queryPlans: output}, null, 2); + Clipboard.setString(json); + setStatus('Query plans copied to clipboard!'); + // eslint-disable-next-line no-console + console.log('EXPLAIN QUERY PLAN:\n', json); + }; + + run() + .catch((e) => setStatus(`Error: ${e instanceof Error ? e.message : String(e)}`)) + .finally(() => setRunning(false)); + }, [selectedCollections]); + + const inspectSizes = useCallback(() => { + setRunning(true); + setSizeResults([]); + setStatus('Inspecting value sizes...'); + + const run = async () => { + const collections = ALL_COLLECTIONS.filter((p) => selectedCollections.has(p)); + const results: SizeResult[] = []; + for (const prefix of collections) { + // eslint-disable-next-line no-await-in-loop + const keys = await getKeysForPrefix(prefix); + if (keys.length === 0) { + continue; + } + const step = Math.max(1, Math.floor(keys.length / 200)); + const sampleKeys = keys.filter((_, i) => i % step === 0).slice(0, 200); + // eslint-disable-next-line no-await-in-loop + const pairs = await storage.multiGet(sampleKeys); + const sizes = pairs.map(([, v]) => JSON.stringify(v).length); + const avg = sizes.reduce((s, n) => s + n, 0) / sizes.length; + const max = Math.max(...sizes); + const avgKeyLen = Math.round(sampleKeys.reduce((s, k) => s + k.length, 0) / sampleKeys.length); + results.push({prefix, keyCount: keys.length, avgKB: Math.round((avg / 1024) * 10) / 10, maxKB: Math.round((max / 1024) * 10) / 10, avgKeyLen, sampleKey: sampleKeys.at(0) ?? ''}); + setSizeResults([...results]); + } + setStatus('Done inspecting sizes.'); + }; + + run() + .catch((e) => setStatus(`Error: ${e instanceof Error ? e.message : String(e)}`)) + .finally(() => setRunning(false)); + }, [selectedCollections]); + + const copySizeResults = useCallback(() => { + Clipboard.setString(JSON.stringify({platform: Platform.OS, timestamp: new Date().toISOString(), valueSizes: sizeResults}, null, 2)); + setStatus('Sizes copied!'); + }, [sizeResults]); + + const copyResults = useCallback(() => { + Clipboard.setString( + JSON.stringify( + { + platform: Platform.OS, + timestamp: new Date().toISOString(), + config: {runs: RUNS, loops: LOOPS, collections: Array.from(selectedCollections)}, + results: allResults, + }, + null, + 2, + ), + ); + setStatus('Copied!'); + }, [allResults, selectedCollections]); + + const measuredPrefixes = ALL_COLLECTIONS.filter((p) => selectedCollections.has(p) && Object.values(allResults).some((r) => r?.some((res) => res.prefix === p))); + const baseline = allResults.in_clause; + const baselineMap = new Map(baseline?.map((r) => [r.prefix, r]) ?? []); + + return ( + + multiGet — Real Data Benchmark + + Real OnyxDB. {LOOPS}×{RUNS} runs, median of medians. + + + Collections to test: + + {ALL_COLLECTIONS.map((prefix) => { + const on = selectedCollections.has(prefix); + return ( + toggleCollection(prefix)} + disabled={running} + > + {prefix.replace(/_$/, '')} + + ); + })} + + + + {running ? 'Running...' : `Run All ${STRATEGIES.length} Strategies`} + + + {STRATEGIES.map((s) => ( + runSingle(s.key)} + disabled={running} + > + {running ? '...' : s.label} + + ))} + + + Inspect Value Sizes + + + + Explain Query Plan (copy) + + + {Object.keys(allResults).length > 0 && !running && ( + + Copy Results + + )} + + {status} + + {sizeResults.length > 0 && ( + + + Value sizes (200-key sample) + + Copy + + + + Collection + Keys + Avg KB + Key len + + {sizeResults.map((r) => ( + + + {r.prefix} + {r.keyCount} + {r.avgKB} KB + {r.avgKeyLen} ch + + + {r.sampleKey} + + + ))} + + )} + + {measuredPrefixes.map((prefix) => { + const baseResult = baselineMap.get(prefix); + return ( + + + {prefix} ({baseResult?.keyCount ?? '?'} keys) + + + Strategy + Median + vs IN + + {STRATEGIES.map((s) => { + const r = allResults[s.key]?.find((res) => res.prefix === prefix); + if (!r) { + return null; + } + const diff = baseResult && s.key !== 'in_clause' ? ((r.median - baseResult.median) / baseResult.median) * 100 : null; + const diffStr = diff == null ? '—' : `${diff > 0 ? '+' : ''}${diff.toFixed(1)}%`; + // eslint-disable-next-line no-nested-ternary + const clr = diff == null ? '#666' : diff < -5 ? '#2d8a4e' : diff > 5 ? '#d32f2f' : '#666'; + return ( + + {s.label} + {r.median.toFixed(1)}ms + {diffStr} + + ); + })} + + ); + })} + + ); +} + +MultiGetBenchmark.displayName = 'MultiGetBenchmark'; + +export default MultiGetBenchmark; diff --git a/src/pages/Debug/SQLiteBenchmark.tsx b/src/pages/Debug/SQLiteBenchmark.tsx new file mode 100644 index 000000000000..9db3d22e515d --- /dev/null +++ b/src/pages/Debug/SQLiteBenchmark.tsx @@ -0,0 +1,458 @@ +/** + * SQLite multiGet Benchmark Screen + * + * Measures query strategies in two phases to isolate SQL performance from JSON.parse noise: + * Phase 1 (SQL only): executeAsync → return raw _array (measures query + bridge overhead) + * Phase 2 (Full pipeline): executeAsync → JSON.parse each row (measures total cost as in real Onyx) + * + * Reports "best of N" (minimum) — GC/system load can only ADD time, never subtract, + * so the minimum is the most reliable indicator of true performance. + * + * Access via Test Tools Modal. + */ +import Clipboard from '@react-native-clipboard/clipboard'; +import React, {useCallback, useState} from 'react'; +import {Platform, StyleSheet, View} from 'react-native'; +import {open} from 'react-native-nitro-sqlite'; +import type {NitroSQLiteConnection} from 'react-native-nitro-sqlite'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import Text from '@components/Text'; + +const BENCH_DB_NAME = 'BenchmarkDB'; +const TABLE = 'keyvaluepairs'; +const COLLECTION_PREFIX = 'reportActions_'; +const PREFIXES = ['reportActions_', 'reports_', 'transactions_', 'policies_', 'accounts_']; +const RUNS = 40; +const PAUSE_BETWEEN_STRATEGIES_MS = 100; +const RECORD_COUNTS = [500, 1000, 5000, 10000]; + +const localStyles = StyleSheet.create({ + title: {fontSize: 16, fontWeight: 'bold', marginTop: 20, marginBottom: 2}, + sub: {fontSize: 11, color: '#666', marginBottom: 12}, + btn: {backgroundColor: '#0366d6', padding: 12, borderRadius: 8, alignItems: 'center', marginBottom: 8}, + btnOff: {backgroundColor: '#999'}, + btnTxt: {color: '#fff', fontWeight: '600', fontSize: 14}, + status: {fontSize: 11, color: '#666', marginBottom: 12, fontStyle: 'italic'}, + group: {marginBottom: 16, borderWidth: 1, borderColor: '#ddd', borderRadius: 6, padding: 10}, + gTitle: {fontSize: 13, fontWeight: '600', marginBottom: 6}, + hRow: {flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#ddd', paddingBottom: 3, marginBottom: 3}, + hTxt: {fontWeight: '600', fontSize: 10}, + row: {flexDirection: 'row', paddingVertical: 2}, + c: {flex: 1, fontSize: 11, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace'}, + sc: {flex: 1.5}, +}); + +type BenchResult = { + strategy: string; + count: number; + bestOf: number; + median: number; + min: number; + max: number; +}; + +// --------------------------------------------------------------------------- +// Test data +// --------------------------------------------------------------------------- + +function createRecord(index: number): string { + return JSON.stringify({ + actionName: `action_${index}`, + reportActionID: String(index), + actorAccountID: index, + person: [{type: 'TEXT', style: 'strong', text: `User ${index}`}], + created: new Date(Date.now() - Math.random() * 1e10).toISOString(), + message: [{type: 'COMMENT', html: `

Message ${index}

`, text: `Message ${index}`, isEdited: index % 3 === 0}], + originalMessage: {html: `

Original ${index}

`, lastModified: new Date().toISOString()}, + avatar: `avatar_${index}.png`, + automatic: false, + shouldShow: true, + lastModified: new Date().toISOString(), + pendingAction: null, + delegateAccountID: index, + errors: {}, + isAttachmentOnly: false, + }); +} + +// --------------------------------------------------------------------------- +// Database helpers +// --------------------------------------------------------------------------- + +function openBenchDB(): NitroSQLiteConnection { + const db = open({name: BENCH_DB_NAME}); + db.execute(`CREATE TABLE IF NOT EXISTS ${TABLE} (record_key TEXT NOT NULL PRIMARY KEY, valueJSON JSON NOT NULL) WITHOUT ROWID;`); + db.execute('PRAGMA CACHE_SIZE=-20000;'); + db.execute('PRAGMA synchronous=NORMAL;'); + db.execute('PRAGMA journal_mode=WAL;'); + return db; +} + +function seedDB(db: NitroSQLiteConnection, count: number, prefix: string): string[] { + const keys: string[] = []; + const params: string[][] = []; + for (let i = 0; i < count; i++) { + const key = `${prefix}${i}`; + keys.push(key); + params.push([key, createRecord(i)]); + } + db.executeBatch([{query: `REPLACE INTO ${TABLE} (record_key, valueJSON) VALUES (?, ?)`, params}]); + return keys; +} + +// --------------------------------------------------------------------------- +// Strategy definitions — each returns a function for SQL-only and full pipeline +// --------------------------------------------------------------------------- + +type StrategyFn = () => Promise; + +function makeStrategies(db: NitroSQLiteConnection, keys: string[], prefix: string) { + // Return raw rows — JSON.parse cost is identical for all strategies so we exclude it + // eslint-disable-next-line @typescript-eslint/naming-convention + const getRows = (rows: {_array: unknown[]} | undefined) => { + // eslint-disable-next-line no-underscore-dangle + return rows?._array ?? []; + }; + + const inClause: StrategyFn = async () => { + const placeholders = keys.map(() => '?').join(','); + const {rows} = await db.executeAsync(`SELECT record_key, valueJSON FROM ${TABLE} WHERE record_key IN (${placeholders})`, keys); + return getRows(rows); + }; + + const tempTable: StrategyFn = async () => { + const tableName = `temp_multiGet_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + await db.executeAsync(`CREATE TEMP TABLE ${tableName} (record_key TEXT PRIMARY KEY);`); + await db.executeBatchAsync([{query: `INSERT INTO ${tableName} (record_key) VALUES (?);`, params: keys.map((k) => [k])}]); + const {rows} = await db.executeAsync(`SELECT k.record_key, k.valueJSON FROM ${TABLE} AS k INNER JOIN ${tableName} AS t ON k.record_key = t.record_key;`); + const result = getRows(rows); + db.executeAsync(`DROP TABLE IF EXISTS ${tableName};`); + return result; + }; + + const makeChunkedIN = + (chunkSize: number): StrategyFn => + async () => { + const allResults: unknown[] = []; + for (let i = 0; i < keys.length; i += chunkSize) { + const chunk = keys.slice(i, i + chunkSize); + const placeholders = chunk.map(() => '?').join(','); + // eslint-disable-next-line no-await-in-loop + const {rows} = await db.executeAsync(`SELECT record_key, valueJSON FROM ${TABLE} WHERE record_key IN (${placeholders})`, chunk); + allResults.push(...getRows(rows)); + } + return allResults; + }; + + /** json_each JOIN: pass all keys as a single JSON array, JOIN against json_each() virtual table */ + const jsonEachJoin: StrategyFn = async () => { + const jsonArray = JSON.stringify(keys); + const {rows} = await db.executeAsync(`SELECT kv.record_key, kv.valueJSON FROM ${TABLE} kv INNER JOIN json_each(?) je ON kv.record_key = je.value`, [jsonArray]); + return getRows(rows); + }; + + /** json_each subselect: keys as JSON array, used in WHERE IN subquery */ + const jsonEachSubselect: StrategyFn = async () => { + const jsonArray = JSON.stringify(keys); + const {rows} = await db.executeAsync(`SELECT record_key, valueJSON FROM ${TABLE} WHERE record_key IN (SELECT value FROM json_each(?))`, [jsonArray]); + return getRows(rows); + }; + + const glob: StrategyFn = async () => { + const {rows} = await db.executeAsync(`SELECT record_key, valueJSON FROM ${TABLE} WHERE record_key GLOB ?`, [`${prefix}*`]); + return getRows(rows); + }; + + return [ + {name: 'IN clause', fn: inClause}, + {name: 'Temp table', fn: tempTable}, + {name: 'Chunk 500', fn: makeChunkedIN(500)}, + {name: 'Chunk 1000', fn: makeChunkedIN(1000)}, + {name: 'json_each JOIN', fn: jsonEachJoin}, + {name: 'json_each SUB', fn: jsonEachSubselect}, + {name: 'GLOB', fn: glob}, + ]; +} + +// --------------------------------------------------------------------------- +// Benchmark runner — reports min (best of N) and median +// --------------------------------------------------------------------------- + +// eslint-disable-next-line no-promise-executor-return +const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + + +async function runStrategyAsync(fn: StrategyFn, runs: number): Promise<{bestOf: number; median: number; min: number; max: number}> { + // 3 warmup + await fn(); + await fn(); + await fn(); + + const times: number[] = []; + for (let i = 0; i < runs; i++) { + const start = performance.now(); + // eslint-disable-next-line no-await-in-loop + await fn(); + times.push(performance.now() - start); + } + times.sort((a, b) => a - b); + + const median = times.at(Math.floor(times.length / 2)) ?? 0; + + return { + bestOf: times.at(0) ?? 0, + median, + min: times.at(0) ?? 0, + max: times.at(-1) ?? 0, + }; +} + +// --------------------------------------------------------------------------- +// Run both phases for a single record count +// --------------------------------------------------------------------------- + +async function benchmarkForCount(db: NitroSQLiteConnection, count: number): Promise { + db.execute(`DELETE FROM ${TABLE}`); + const allKeys: Record = {}; + for (const pfx of PREFIXES) { + allKeys[pfx] = seedDB(db, count, pfx); + } + const targetKeys = allKeys[COLLECTION_PREFIX]; + + const results: BenchResult[] = []; + + // SQL only — JSON.parse cost is identical for all strategies so we exclude it + const strategies = makeStrategies(db, targetKeys, COLLECTION_PREFIX); + for (const strat of strategies) { + // eslint-disable-next-line no-await-in-loop + await pause(PAUSE_BETWEEN_STRATEGIES_MS); + // eslint-disable-next-line no-await-in-loop + const r = await runStrategyAsync(strat.fn, RUNS); + results.push({strategy: strat.name, count, ...r}); + } + + return results; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +const MULTI_RUN_COUNT = 6; + +/** Run one full benchmark pass (all record counts, all strategies). Returns results array. */ +async function runSinglePass(setStatus: (s: string) => void, passNumber?: number): Promise { + const passLabel = passNumber != null ? ` (pass ${passNumber})` : ''; + const db = openBenchDB(); + const passResults: BenchResult[] = []; + + try { + for (const count of RECORD_COUNTS) { + setStatus(`Benchmarking ${count} records${passLabel}...`); + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + + // eslint-disable-next-line no-await-in-loop + const stepResults = await benchmarkForCount(db, count); + passResults.push(...stepResults); + } + } finally { + try { + db.execute(`DROP TABLE IF EXISTS ${TABLE}`); + db.close(); + db.delete(); + } catch { + // ignore cleanup errors + } + } + + return passResults; +} + +function SQLiteBenchmark() { + const [results, setResults] = useState([]); + const [allRuns, setAllRuns] = useState([]); + const [running, setRunning] = useState(false); + const [status, setStatus] = useState('Ready — tap to run'); + + const startSingleRun = useCallback(() => { + if (Platform.OS === 'web') { + setStatus('SQLite benchmarks only run on iOS/Android'); + return; + } + setRunning(true); + setResults([]); + setAllRuns([]); + + const run = async () => { + try { + const passResults = await runSinglePass(setStatus); + setResults(passResults); + setAllRuns([passResults]); + setStatus(`Done! ${RECORD_COUNTS.length} sizes x 7 strategies`); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setStatus(`Error: ${msg}`); + // eslint-disable-next-line no-console + console.error('Benchmark error:', e); + } finally { + setRunning(false); + } + }; + + run(); + }, []); + + const startMultiRun = useCallback(() => { + if (Platform.OS === 'web') { + setStatus('SQLite benchmarks only run on iOS/Android'); + return; + } + setRunning(true); + setResults([]); + setAllRuns([]); + + const run = async () => { + const collectedRuns: BenchResult[][] = []; + try { + for (let i = 0; i < MULTI_RUN_COUNT; i++) { + // eslint-disable-next-line no-await-in-loop + const passResults = await runSinglePass(setStatus, i + 1); + collectedRuns.push(passResults); + // Show latest run results on screen + setResults(passResults); + setAllRuns([...collectedRuns]); + } + setStatus(`Done! ${MULTI_RUN_COUNT} runs complete — tap Copy All to export`); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setStatus(`Error: ${msg}`); + // eslint-disable-next-line no-console + console.error('Benchmark error:', e); + } finally { + setRunning(false); + } + }; + + run(); + }, []); + + const copyResults = useCallback(() => { + const jsonOutput = JSON.stringify( + { + platform: Platform.OS, + timestamp: new Date().toISOString(), + totalRuns: allRuns.length, + config: {runsPerPass: RUNS, collections: PREFIXES.length, recordCounts: RECORD_COUNTS}, + runs: allRuns.map((passResults, i) => ({run: i + 1, results: passResults})), + }, + null, + 2, + ); + Clipboard.setString(jsonOutput); + setStatus(`Copied ${allRuns.length} run(s) to clipboard!`); + }, [allRuns]); + + // Group by count + const grouped = new Map(); + for (const r of results) { + const list = grouped.get(r.count) ?? []; + if (!grouped.has(r.count)) { + grouped.set(r.count, list); + } + list.push(r); + } + + const renderTable = (rows: BenchResult[], baselineStrategy: string) => { + const baselineMedian = rows.find((r) => r.strategy === baselineStrategy)?.median ?? 1; + return ( + <> + + Strategy + Median + Best + vs IN + + {rows.map((r) => { + const diff = ((r.median - baselineMedian) / baselineMedian) * 100; + const isBase = r.strategy === baselineStrategy; + const diffStr = isBase ? '—' : `${diff > 0 ? '+' : ''}${diff.toFixed(1)}%`; + // eslint-disable-next-line no-nested-ternary + const clr = isBase ? '#666' : diff < -5 ? '#2d8a4e' : diff > 5 ? '#d32f2f' : '#666'; + return ( + + {r.strategy} + {r.median.toFixed(2)} + {r.min.toFixed(2)} + {diffStr} + + ); + })} + + ); + }; + + return ( + + SQLite multiGet Benchmark + + SQL query only (JSON.parse excluded — identical cost for all strategies).{'\n'} + {RUNS} runs, median. {PREFIXES.length} collections x N records. + + + + {running ? 'Running...' : 'Run 1x'} + + + + {running ? 'Running...' : `Run ${MULTI_RUN_COUNT}x + Copy`} + + + {allRuns.length > 0 && !running && ( + + Copy All ({allRuns.length} runs) + + )} + + {status} + + {Array.from(grouped.entries()).map(([count, rows]) => ( + + + {count} records (DB: {count * PREFIXES.length}) + + {renderTable(rows, 'IN clause')} + + ))} + + ); +} + +SQLiteBenchmark.displayName = 'SQLiteBenchmark'; + +export default SQLiteBenchmark;