diff --git a/example/src/benchmarks/cases.ts b/example/src/benchmarks/cases.ts index cf277d61..a6d9a68d 100644 --- a/example/src/benchmarks/cases.ts +++ b/example/src/benchmarks/cases.ts @@ -2,9 +2,15 @@ import type { BenchmarkCase } from './runner' import { writeCases } from './writeCases' import { readCases } from './readCases' import { mixedCase } from './mixedCase' +import { chatCases } from './chatCases' +import { drizzleCases } from './drizzleCases' +import { storageCases } from './storageCases' export const benchmarkCases: BenchmarkCase[] = [ ...writeCases, ...readCases, mixedCase, + ...chatCases, + ...drizzleCases, + ...storageCases, ] diff --git a/example/src/benchmarks/chatCases.ts b/example/src/benchmarks/chatCases.ts new file mode 100644 index 00000000..b155d08e --- /dev/null +++ b/example/src/benchmarks/chatCases.ts @@ -0,0 +1,240 @@ +import { open, type NitroSQLiteConnection } from 'react-native-nitro-sqlite' +import type { BenchmarkCase, BenchmarkFixture } from './runner' +import { assertBenchmark, disposeBenchmarkDatabase } from './database' +import { + createChatDatabase, + EDITED_MESSAGES, + expectedBrowse, + INBOX_SIZE, + inboxChecksum, + messageChecksum, + messageParams, + NEW_MESSAGES, + OPENED_CONVERSATIONS, + PAGE_SIZE, + restoreSeedCommands, + SCHEMA_VERSION, + SCROLLED_PAGES, + SEEDED_MESSAGES, + seededUnreadCount, + SETTINGS, + syncPayload, + type InboxRow, + type MessageRow, + type SyncPayload, +} from './chatData' + +const LAUNCHES = 20 +const LAUNCH_DATABASE = 'benchmark_chat_launch' +const INBOX_SQL = `SELECT c.id, c.title, c.unread_count AS unreadCount, m.body AS lastBody, m.sent_at AS lastSentAt, u.name AS lastAuthor +FROM conversations c +LEFT JOIN messages m ON m.id = c.last_message_id +LEFT JOIN users u ON u.id = m.author_id +ORDER BY c.updated_at DESC LIMIT ${INBOX_SIZE}` +const PAGE_SQL = `SELECT m.id, u.name AS authorName, m.body, m.sent_at AS sentAt, m.edited_at AS editedAt, m.reactions +FROM messages m JOIN users u ON u.id = m.author_id +WHERE m.conversation_id = ? AND m.sent_at < ? +ORDER BY m.sent_at DESC LIMIT ${PAGE_SIZE}` +const MARK_READ_SQL = 'UPDATE conversations SET unread_count = 0 WHERE id = ?' +const UPSERT_MESSAGE_SQL = `INSERT INTO messages (id, conversation_id, author_id, body, sent_at, edited_at, reactions) +VALUES (?, ?, ?, ?, ?, ?, ?) +ON CONFLICT (id) DO UPDATE SET body = excluded.body, edited_at = excluded.edited_at, reactions = excluded.reactions` +const UPDATE_CONVERSATION_SQL = + 'UPDATE conversations SET last_message_id = ?, updated_at = ?, unread_count = unread_count + ? WHERE id = ?' + +/** The queries behind a chat app's screens, so raw SQL and ORM clients do the same work. */ +export interface ChatClient { + inbox(): Promise + page(conversationId: number, before: number): Promise + markRead(conversationId: number): Promise + sync(payload: SyncPayload): Promise +} + +export const chatCases: BenchmarkCase[] = [ + ...createChatCases('chat', 'Chat', sqlClient), + { + id: 'chat-launch', + label: 'Chat · 20 launches: open, check schema, load inbox', + setup: setupLaunch, + }, +] + +export function createChatCases( + id: string, + label: string, + createClient: (db: NitroSQLiteConnection) => ChatClient, +): BenchmarkCase[] { + return [ + { + id: `${id}-sync`, + label: `${label} · store 500 synced messages in a transaction`, + setup: () => setupSync(`benchmark_${id}_sync`, createClient), + }, + { + id: `${id}-browse`, + label: `${label} · open inbox and 10 chats, scroll back 6 pages`, + setup: () => setupBrowse(`benchmark_${id}_browse`, createClient), + }, + ] +} + +function sqlClient(db: NitroSQLiteConnection): ChatClient { + return { + inbox: async () => (await db.executeAsync(INBOX_SQL)).rows._array, + page: async (conversationId, before) => + (await db.executeAsync(PAGE_SQL, [conversationId, before])) + .rows._array, + markRead: async (conversationId) => { + await db.executeAsync(MARK_READ_SQL, [conversationId]) + }, + sync: (payload) => + db.transaction(async (tx) => { + for (const message of payload.messages) { + await tx.executeAsync(UPSERT_MESSAGE_SQL, messageParams(message)) + } + for (const conversation of payload.conversations) { + await tx.executeAsync(UPDATE_CONVERSATION_SQL, [ + conversation.lastMessageId, + conversation.updatedAt, + conversation.newMessages, + conversation.id, + ]) + } + }), + } +} + +async function setupSync( + name: string, + createClient: (db: NitroSQLiteConnection) => ChatClient, +): Promise { + const db = await createChatDatabase(name) + try { + const client = createClient(db) + const payload = syncPayload() + const restore = restoreSeedCommands(payload) + return { + database: db, + reset: () => { + db.executeBatch(restore) + }, + run: () => client.sync(payload), + verify: () => { + const row = db + .execute<{ messages: number; edited: number; unread: number }>( + `SELECT (SELECT COUNT(*) FROM messages) AS messages, + (SELECT COUNT(*) FROM messages WHERE edited_at IS NOT NULL) AS edited, + (SELECT SUM(unread_count) FROM conversations) AS unread`, + ) + .rows.item(0) + assertBenchmark( + row?.messages === SEEDED_MESSAGES + NEW_MESSAGES, + 'Wrong synced message count', + ) + assertBenchmark(row.edited === EDITED_MESSAGES, 'Wrong edited count') + assertBenchmark( + row.unread === seededUnreadCount() + NEW_MESSAGES, + 'Wrong unread count', + ) + }, + dispose: () => disposeBenchmarkDatabase(db), + } + } catch (error) { + disposeBenchmarkDatabase(db) + throw error + } +} + +async function setupBrowse( + name: string, + createClient: (db: NitroSQLiteConnection) => ChatClient, +): Promise { + const db = await createChatDatabase(name) + try { + const client = createClient(db) + const expected = expectedBrowse() + let result = { rows: 0, checksum: 0 } + return { + database: db, + run: async () => { + result = await browse(client) + }, + verify: () => { + assertBenchmark(result.rows === expected.rows, 'Wrong browsed rows') + assertBenchmark( + result.checksum === expected.checksum, + 'Wrong browsed row checksum', + ) + }, + dispose: () => disposeBenchmarkDatabase(db), + } + } catch (error) { + disposeBenchmarkDatabase(db) + throw error + } +} + +async function setupLaunch(): Promise { + let db = await createChatDatabase(LAUNCH_DATABASE) + const expected = LAUNCHES * (SETTINGS + INBOX_SIZE + seededUnreadCount()) + let checksum = 0 + return { + get database() { + return db + }, + run: async () => { + checksum = 0 + for (let launch = 0; launch < LAUNCHES; launch++) { + db.close() + db = open({ name: LAUNCH_DATABASE }) + checksum += await loadFirstScreen(db) + } + }, + verify: () => assertBenchmark(checksum === expected, 'Wrong launch data'), + dispose: () => disposeBenchmarkDatabase(db), + } +} + +async function browse( + client: ChatClient, +): Promise<{ rows: number; checksum: number }> { + const inbox = await client.inbox() + let rows = inbox.length + let checksum = 0 + for (const conversation of inbox) checksum += inboxChecksum(conversation) + + for (const [position, conversation] of inbox + .slice(0, OPENED_CONVERSATIONS) + .entries()) { + let before = Number.MAX_SAFE_INTEGER + const pages = position === 0 ? 1 + SCROLLED_PAGES : 1 + for (let page = 0; page < pages; page++) { + const messages = await client.page(conversation.id, before) + rows += messages.length + for (const message of messages) checksum += messageChecksum(message) + before = messages[messages.length - 1]?.sentAt ?? 0 + } + await client.markRead(conversation.id) + } + return { rows, checksum } +} + +// The statements an app runs before its first screen. +async function loadFirstScreen(db: NitroSQLiteConnection): Promise { + db.execute('PRAGMA journal_mode = WAL') + db.execute('PRAGMA synchronous = NORMAL') + const version = db + .execute<{ user_version: number }>('PRAGMA user_version') + .rows.item(0)?.user_version + assertBenchmark(version === SCHEMA_VERSION, 'Wrong schema version') + const settings = db.execute<{ key: string; value: string }>( + 'SELECT key, value FROM settings', + ).rows._array + const inbox = (await db.executeAsync(INBOX_SQL)).rows._array + const badge = ( + await db.executeAsync<{ unread: number }>( + 'SELECT SUM(unread_count) AS unread FROM conversations', + ) + ).rows.item(0) + return settings.length + inbox.length + (badge?.unread ?? 0) +} diff --git a/example/src/benchmarks/chatData.ts b/example/src/benchmarks/chatData.ts new file mode 100644 index 00000000..6ef67874 --- /dev/null +++ b/example/src/benchmarks/chatData.ts @@ -0,0 +1,314 @@ +import type { + BatchQueryCommand, + NitroSQLiteConnection, + SQLiteQueryParams, +} from 'react-native-nitro-sqlite' +import { createAppDatabase, disposeBenchmarkDatabase } from './database' + +export const INBOX_SIZE = 50 +export const PAGE_SIZE = 50 +export const OPENED_CONVERSATIONS = 10 +export const SCROLLED_PAGES = 6 +export const SEEDED_MESSAGES = 32000 +export const NEW_MESSAGES = 400 +export const EDITED_MESSAGES = 100 +export const SETTINGS = 30 +export const SCHEMA_VERSION = 3 +const USERS = 200 +const CONVERSATIONS = 80 +const START_TIME = Date.UTC(2026, 0, 1) +// Chat text with accents, emoji, other scripts, quotes, and line breaks. +const WORDS = [ + 'hey', + 'are', + 'we', + 'still', + 'on', + 'for', + 'tonight?', + 'café', + 'Grüße', + 'naïve', + '👋', + '🎉', + 'okay', + 'see', + 'you', + 'at', + '7', + 'the', + 'new', + 'place', + '"Luigi\'s"', + 'sounds', + 'good', + 'send', + 'me', + 'the', + 'link', + 'thanks!', + 'let me check\n', + '日本', + 'привет', + 'tomorrow', + 'works', + '😂', + 'meeting', + 'moved', + 'to', + '3pm', +] +const FIRST_NAMES = ['Ana', 'José', 'Zoë', 'Søren', 'Mia', 'Noah', 'Aiko'] +const LAST_NAMES = ['García', 'Müller', 'Smith', 'Kowalski', 'Nguyen', 'Rossi'] + +const CHAT_SCHEMA = [ + 'CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, avatar_url TEXT) STRICT', + 'CREATE TABLE conversations (id INTEGER PRIMARY KEY, title TEXT NOT NULL, last_message_id TEXT, unread_count INTEGER NOT NULL, updated_at INTEGER NOT NULL) STRICT', + 'CREATE TABLE messages (id TEXT PRIMARY KEY, conversation_id INTEGER NOT NULL, author_id INTEGER NOT NULL, body TEXT NOT NULL, sent_at INTEGER NOT NULL, edited_at INTEGER, reactions TEXT) STRICT', + 'CREATE INDEX messages_by_conversation ON messages (conversation_id, sent_at)', + 'CREATE INDEX conversations_by_activity ON conversations (updated_at)', + 'CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT', + `PRAGMA user_version = ${SCHEMA_VERSION}`, +] + +export type ChatMessage = { + id: string + conversationId: number + authorId: number + body: string + sentAt: number + editedAt: number | null + reactions: string | null +} +export type InboxRow = { + id: number + title: string + unreadCount: number + lastBody: string | null + lastSentAt: number | null + lastAuthor: string | null +} +export type MessageRow = { + id: string + authorName: string + body: string + sentAt: number + editedAt: number | null + reactions: string | null +} +export type SyncPayload = { + messages: ChatMessage[] + conversations: { + id: number + lastMessageId: string + updatedAt: number + newMessages: number + }[] +} + +export async function createChatDatabase( + name: string, +): Promise { + const db = createAppDatabase(name, CHAT_SCHEMA) + try { + await db.executeBatchAsync([ + { + query: 'INSERT INTO users VALUES (?, ?, ?)', + params: Array.from({ length: USERS }, (_, i) => [ + i + 1, + userName(i + 1), + `https://cdn.example.com/avatars/${i + 1}.jpg`, + ]), + }, + { + query: 'INSERT INTO conversations VALUES (?, ?, ?, ?, ?)', + params: Array.from({ length: CONVERSATIONS }, (_, i) => + conversationParams(i + 1), + ), + }, + { + query: 'INSERT INTO messages VALUES (?, ?, ?, ?, ?, ?, ?)', + params: Array.from({ length: SEEDED_MESSAGES }, (_, i) => + messageParams(seededMessage(i)), + ), + }, + { + query: 'INSERT INTO settings VALUES (?, ?)', + params: Array.from({ length: SETTINGS }, (_, i) => [ + `setting.${i}`, + JSON.stringify({ enabled: i % 2 === 0, value: `option-${i}` }), + ]), + }, + ]) + return db + } catch (error) { + disposeBenchmarkDatabase(db) + throw error + } +} + +// 400 new messages spread over all conversations, plus 100 edits. +export function syncPayload(): SyncPayload { + const created = Array.from({ length: NEW_MESSAGES }, (_, i) => + seededMessage(SEEDED_MESSAGES + i), + ) + const edited = editedIndices().map((index) => { + const message = seededMessage(index) + return { + ...message, + body: `${message.body} (edited)`, + editedAt: message.sentAt + 60000, + } + }) + const conversations = Array.from({ length: CONVERSATIONS }, (_, i) => { + const latest = seededMessage( + SEEDED_MESSAGES + NEW_MESSAGES - CONVERSATIONS + i, + ) + return { + id: latest.conversationId, + lastMessageId: latest.id, + updatedAt: latest.sentAt, + newMessages: NEW_MESSAGES / CONVERSATIONS, + } + }) + return { messages: [...edited, ...created], conversations } +} + +export function restoreSeedCommands(payload: SyncPayload): BatchQueryCommand[] { + return [ + { + query: 'DELETE FROM messages WHERE sent_at >= ?', + params: [seededMessage(SEEDED_MESSAGES).sentAt], + }, + { + query: 'UPDATE messages SET body = ?, edited_at = NULL WHERE id = ?', + params: editedIndices().map((index) => { + const message = seededMessage(index) + return [message.body, message.id] + }), + }, + { + query: + 'UPDATE conversations SET last_message_id = ?, unread_count = ?, updated_at = ? WHERE id = ?', + params: payload.conversations.map(({ id }) => { + const [, , lastMessageId, unreadCount, updatedAt] = + conversationParams(id) + return [lastMessageId, unreadCount, updatedAt, id] + }), + }, + ] +} + +export function expectedBrowse(): { rows: number; checksum: number } { + let rows = 0 + let checksum = 0 + // The inbox lists the most recently active conversations first. + for (let id = CONVERSATIONS; id > CONVERSATIONS - INBOX_SIZE; id--) { + const last = seededMessage(latestSeededIndex(id)) + rows++ + checksum += inboxChecksum({ + id, + title: conversationTitle(id), + unreadCount: 0, + lastBody: last.body, + lastSentAt: last.sentAt, + lastAuthor: userName(last.authorId), + }) + } + for (let position = 0; position < OPENED_CONVERSATIONS; position++) { + const id = CONVERSATIONS - position + const count = PAGE_SIZE * (position === 0 ? 1 + SCROLLED_PAGES : 1) + for (let k = 0; k < count; k++) { + const message = seededMessage(latestSeededIndex(id) - k * CONVERSATIONS) + rows++ + checksum += messageChecksum({ + ...message, + authorName: userName(message.authorId), + }) + } + } + return { rows, checksum } +} + +export function seededUnreadCount(): number { + let total = 0 + for (let id = 1; id <= CONVERSATIONS; id++) total += id % 7 + return total +} + +export function inboxChecksum(row: InboxRow): number { + return ( + row.id + + row.title.length + + (row.lastBody?.length ?? 0) + + (row.lastAuthor?.length ?? 0) + ) +} + +export function messageChecksum(row: MessageRow): number { + return row.body.length + row.authorName.length + (row.reactions?.length ?? 0) +} + +export function messageParams(message: ChatMessage): SQLiteQueryParams { + return [ + message.id, + message.conversationId, + message.authorId, + message.body, + message.sentAt, + message.editedAt, + message.reactions, + ] +} + +function conversationParams(id: number): SQLiteQueryParams { + const last = seededMessage(latestSeededIndex(id)) + return [id, conversationTitle(id), last.id, id % 7, last.sentAt] +} + +function editedIndices(): number[] { + return Array.from( + { length: EDITED_MESSAGES }, + (_, i) => SEEDED_MESSAGES - 1 - i * 97, + ) +} + +// Messages alternate between conversations, so each conversation's newest +// seeded message is among the last CONVERSATIONS indices. +function latestSeededIndex(conversationId: number): number { + return SEEDED_MESSAGES - CONVERSATIONS + conversationId - 1 +} + +function seededMessage(index: number): ChatMessage { + return { + id: `00000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}`, + conversationId: (index % CONVERSATIONS) + 1, + authorId: ((index * 7) % USERS) + 1, + body: messageBody(index), + sentAt: START_TIME + index * 1000, + editedAt: null, + reactions: + index % 5 === 0 + ? JSON.stringify({ '👍': [`u${index % USERS}`], '❤️': ['u1', 'u2'] }) + : null, + } +} + +function messageBody(index: number): string { + const words: string[] = [] + const count = 3 + ((index * 7) % 28) + for (let k = 0; k < count; k++) { + words.push(WORDS[(index * 31 + k * 17) % WORDS.length] ?? '') + } + return words.join(' ') +} + +function conversationTitle(id: number): string { + return id % 3 === 0 + ? `Group ${id} · ${WORDS[id % WORDS.length]}` + : userName(id + 1) +} + +function userName(id: number): string { + return `${FIRST_NAMES[id % FIRST_NAMES.length]} ${LAST_NAMES[(id * 5) % LAST_NAMES.length]}` +} diff --git a/example/src/benchmarks/database.ts b/example/src/benchmarks/database.ts index 46e1e547..cd8a83dc 100644 --- a/example/src/benchmarks/database.ts +++ b/example/src/benchmarks/database.ts @@ -15,6 +15,28 @@ export function createBenchmarkDatabase( } } +// Apps usually run SQLite in WAL mode, where synchronous=NORMAL commits +// without waiting for the disk. +export function createAppDatabase( + name: string, + schema: string[], +): NitroSQLiteConnection { + open({ name }).delete() + const db = open({ name }) + try { + const mode = db + .execute<{ journal_mode: string }>('PRAGMA journal_mode = WAL') + .rows.item(0)?.journal_mode + assertBenchmark(mode?.toLowerCase() === 'wal', 'WAL mode unavailable') + db.execute('PRAGMA synchronous = NORMAL') + db.executeBatch(schema.map((query) => ({ query }))) + return db + } catch (error) { + disposeBenchmarkDatabase(db) + throw error + } +} + export function disposeBenchmarkDatabase(db: NitroSQLiteConnection): void { db.close() db.delete() diff --git a/example/src/benchmarks/drizzleCases.ts b/example/src/benchmarks/drizzleCases.ts new file mode 100644 index 00000000..b501b44f --- /dev/null +++ b/example/src/benchmarks/drizzleCases.ts @@ -0,0 +1,109 @@ +import { and, desc, eq, lt, sql } from 'drizzle-orm' +import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import type { NitroSQLiteConnection } from 'react-native-nitro-sqlite' +import { drizzle } from 'react-native-nitro-sqlite-drizzle' +import type { BenchmarkCase } from './runner' +import { createChatCases, type ChatClient } from './chatCases' +import { INBOX_SIZE, PAGE_SIZE } from './chatData' + +const users = sqliteTable('users', { + id: integer('id').primaryKey(), + name: text('name').notNull(), + avatarUrl: text('avatar_url'), +}) +const conversations = sqliteTable('conversations', { + id: integer('id').primaryKey(), + title: text('title').notNull(), + lastMessageId: text('last_message_id'), + unreadCount: integer('unread_count').notNull(), + updatedAt: integer('updated_at').notNull(), +}) +const messages = sqliteTable('messages', { + id: text('id').primaryKey(), + conversationId: integer('conversation_id').notNull(), + authorId: integer('author_id').notNull(), + body: text('body').notNull(), + sentAt: integer('sent_at').notNull(), + editedAt: integer('edited_at'), + reactions: text('reactions'), +}) + +// The chat workloads again, written with Drizzle's query builder. +export const drizzleCases: BenchmarkCase[] = createChatCases( + 'drizzle-chat', + 'Drizzle chat', + drizzleClient, +) + +function drizzleClient(connection: NitroSQLiteConnection): ChatClient { + const db = drizzle(connection) + return { + inbox: () => + db + .select({ + id: conversations.id, + title: conversations.title, + unreadCount: conversations.unreadCount, + lastBody: messages.body, + lastSentAt: messages.sentAt, + lastAuthor: users.name, + }) + .from(conversations) + .leftJoin(messages, eq(messages.id, conversations.lastMessageId)) + .leftJoin(users, eq(users.id, messages.authorId)) + .orderBy(desc(conversations.updatedAt)) + .limit(INBOX_SIZE), + page: (conversationId, before) => + db + .select({ + id: messages.id, + authorName: users.name, + body: messages.body, + sentAt: messages.sentAt, + editedAt: messages.editedAt, + reactions: messages.reactions, + }) + .from(messages) + .innerJoin(users, eq(users.id, messages.authorId)) + .where( + and( + eq(messages.conversationId, conversationId), + lt(messages.sentAt, before), + ), + ) + .orderBy(desc(messages.sentAt)) + .limit(PAGE_SIZE), + markRead: async (conversationId) => { + await db + .update(conversations) + .set({ unreadCount: 0 }) + .where(eq(conversations.id, conversationId)) + }, + sync: (payload) => + db.transaction(async (tx) => { + for (const message of payload.messages) { + await tx + .insert(messages) + .values(message) + .onConflictDoUpdate({ + target: messages.id, + set: { + body: sql`excluded.body`, + editedAt: sql`excluded.edited_at`, + reactions: sql`excluded.reactions`, + }, + }) + } + for (const conversation of payload.conversations) { + await tx + .update(conversations) + .set({ + lastMessageId: conversation.lastMessageId, + updatedAt: conversation.updatedAt, + unreadCount: sql`${conversations.unreadCount} + ${conversation.newMessages}`, + }) + .where(eq(conversations.id, conversation.id)) + } + }), + } +} diff --git a/example/src/benchmarks/readCases.ts b/example/src/benchmarks/readCases.ts index 42f93d20..ddc5c866 100644 --- a/example/src/benchmarks/readCases.ts +++ b/example/src/benchmarks/readCases.ts @@ -58,12 +58,12 @@ export const readCases: BenchmarkCase[] = [ { id: 'read-point-execute', label: '1k indexed lookups · execute', - setup: () => setupPointReads(false), + setup: () => setupPointReads('execute'), }, { id: 'read-point-prepared', label: '1k indexed lookups · prepared', - setup: () => setupPointReads(true), + setup: () => setupPointReads('prepared'), }, { id: 'read-narrow', @@ -92,6 +92,16 @@ export const readCases: BenchmarkCase[] = [ label: '100k fractional REAL rows · full read + access', setup: setupFractionalRead, }, + { + id: 'read-point-async', + label: '1k indexed lookups · async', + setup: () => setupPointReads('async'), + }, + { + id: 'read-narrow-raw', + label: '10k narrow rows · raw arrays read + access', + setup: setupNarrowRawRead, + }, ] export function resetLargeDbSchema(db: NitroSQLiteConnection): void { @@ -101,34 +111,49 @@ export function resetLargeDbSchema(db: NitroSQLiteConnection): void { ]) } -async function setupPointReads(prepared: boolean): Promise { - const db = createBenchmarkDatabase( - `benchmark_point_${prepared ? 'prepared' : 'execute'}`, - NARROW_SCHEMA, - ) +async function setupPointReads( + mode: 'execute' | 'prepared' | 'async', +): Promise { + const db = createBenchmarkDatabase(`benchmark_point_${mode}`, NARROW_SCHEMA) try { await seedNarrowRows(db) + const prepared = mode === 'prepared' const statement = prepared ? db.prepare('SELECT value FROM Bench WHERE id = ?') : undefined let checksum = 0 + const runAsync = async () => { + checksum = 0 + for (const id of lookupIds) { + const row = ( + await db.executeAsync<{ + value: number + }>('SELECT value FROM Bench WHERE id = ?', [id]) + ).rows.item(0) + assertBenchmark(row?.value === id, `Wrong value for row ${id}`) + checksum += row.value + } + } return { database: db, - run: () => { - checksum = 0 - for (const id of lookupIds) { - const row = prepared - ? statement?.execute<{ value: number }>([id]).rows.item(0) - : db - .execute<{ - value: number - }>('SELECT value FROM Bench WHERE id = ?', [id]) - .rows.item(0) - assertBenchmark(row?.value === id, `Wrong value for row ${id}`) - checksum += row.value - } - }, + run: + mode === 'async' + ? runAsync + : () => { + checksum = 0 + for (const id of lookupIds) { + const row = prepared + ? statement?.execute<{ value: number }>([id]).rows.item(0) + : db + .execute<{ + value: number + }>('SELECT value FROM Bench WHERE id = ?', [id]) + .rows.item(0) + assertBenchmark(row?.value === id, `Wrong value for row ${id}`) + checksum += row.value + } + }, verify: () => { const expected = lookupIds.reduce((sum, id) => sum + id, 0) assertBenchmark(checksum === expected, 'Wrong lookup checksum') @@ -186,6 +211,40 @@ async function setupNarrowRead( } } +async function setupNarrowRawRead(): Promise { + const db = createBenchmarkDatabase('benchmark_narrow_raw', NARROW_SCHEMA) + try { + await seedNarrowRows(db) + let count = 0 + let checksum = 0 + + return { + database: db, + run: () => { + const rows = db.executeRaw('SELECT * FROM Bench').rows as [ + number, + string, + number, + ][] + count = rows.length + checksum = 0 + for (const row of rows) checksum += row[0] + row[2] + row[1].length + }, + verify: () => { + assertBenchmark(count === NARROW_ROWS, 'Wrong raw read row count') + assertBenchmark( + checksum === narrowChecksum(), + 'Wrong raw read checksum', + ) + }, + dispose: () => disposeBenchmarkDatabase(db), + } + } catch (error) { + disposeBenchmarkDatabase(db) + throw error + } +} + async function setupWideRead(asynchronous: boolean): Promise { const db = open({ name: asynchronous ? 'benchmark_wide_async' : 'benchmark_wide', diff --git a/example/src/benchmarks/storageCases.ts b/example/src/benchmarks/storageCases.ts new file mode 100644 index 00000000..eac3cd10 --- /dev/null +++ b/example/src/benchmarks/storageCases.ts @@ -0,0 +1,291 @@ +import type { SQLiteQueryParams } from 'react-native-nitro-sqlite' +import type { BenchmarkCase, BenchmarkFixture } from './runner' +import { + assertBenchmark, + createAppDatabase, + disposeBenchmarkDatabase, +} from './database' + +const KV_KEYS = 500 +const KV_OPERATIONS = 1000 +const CACHE_ENTRIES = 200 +const CACHE_PRODUCTS = 20 +const THUMBNAILS = 120 +const THUMBNAIL_BYTES = 32 * 1024 +const ALBUMS = 6 +const START_TIME = Date.UTC(2026, 0, 1) + +const KV_SET = + 'INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value' +const KV_GET = 'SELECT value FROM kv WHERE key = ?' +const CACHE_PERSIST = + 'INSERT INTO query_cache (key, data, updated_at) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at' +const THUMBNAIL_INSERT = 'INSERT INTO thumbnails VALUES (?, ?, ?, ?, ?)' +const ALBUM_QUERY = + 'SELECT id, width, height, data FROM thumbnails WHERE album_id = ? ORDER BY id' + +type CacheRow = { key: string; data: string } +type ThumbnailRow = { + id: number + width: number + height: number + data: ArrayBuffer +} + +export const storageCases: BenchmarkCase[] = [ + { + id: 'storage-kv', + label: 'Key-value · 900 getItem + 100 setItem', + setup: setupKeyValue, + }, + { + id: 'storage-response-cache', + label: 'Response cache · persist + restore 200 × 9 KB JSON', + setup: setupResponseCache, + }, + { + id: 'storage-thumbnails', + label: 'Thumbnail cache · write + read 120 × 32 KB BLOBs', + setup: setupThumbnails, + }, +] + +// Synchronous storage like an AsyncStorage or MMKV replacement, with JSON values. +function setupKeyValue(): BenchmarkFixture { + const db = createAppDatabase('benchmark_kv', [ + 'CREATE TABLE kv (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT', + ]) + try { + const seed = Array.from({ length: KV_KEYS }, (_, i) => [ + kvKey(i), + kvValue(i, 0), + ]) + db.executeBatch([{ query: KV_SET, params: seed }]) + const operations = Array.from({ length: KV_OPERATIONS }, (_, i) => { + const index = (i * 37) % KV_KEYS + return { + key: kvKey(index), + value: i % 10 === 9 ? kvValue(index, i) : undefined, + } + }) + const written = new Set( + operations.filter((op) => op.value != null).map((op) => op.key), + ) + const restore = seed.filter(([key]) => written.has(key as string)) + const expected = expectedKeyValueChecksum(seed, operations) + let checksum = 0 + + return { + database: db, + reset: () => { + db.executeBatch([{ query: KV_SET, params: restore }]) + }, + run: () => { + checksum = 0 + for (const { key, value } of operations) { + if (value != null) { + db.execute(KV_SET, [key, value]) + continue + } + const stored = db + .execute<{ value: string }>(KV_GET, [key]) + .rows.item(0)?.value + assertBenchmark(stored != null, `Missing key ${key}`) + checksum += (JSON.parse(stored) as { version: number }).version + 1 + } + }, + verify: () => + assertBenchmark(checksum === expected, 'Wrong key-value checksum'), + dispose: () => disposeBenchmarkDatabase(db), + } + } catch (error) { + disposeBenchmarkDatabase(db) + throw error + } +} + +// Per-query persistence of API responses, as query cache persisters do, +// and the restore that parses every response at the next launch. +function setupResponseCache(): BenchmarkFixture { + const db = createAppDatabase('benchmark_response_cache', [ + 'CREATE TABLE query_cache (key TEXT PRIMARY KEY, data TEXT NOT NULL, updated_at INTEGER NOT NULL) STRICT', + ]) + try { + const entries: SQLiteQueryParams[] = Array.from( + { length: CACHE_ENTRIES }, + (_, page) => [ + `["products",{"page":${page}}]`, + JSON.stringify(productPage(page)), + START_TIME + page, + ], + ) + const originals = new Map(entries.map(([key, data]) => [key, data])) + let rows: CacheRow[] = [] + let products = 0 + + return { + database: db, + reset: () => { + db.execute('DELETE FROM query_cache') + }, + run: async () => { + const persistStart = performance.now() + await db.executeBatchAsync([{ query: CACHE_PERSIST, params: entries }]) + const persistMs = performance.now() - persistStart + + const restoreStart = performance.now() + rows = ( + await db.executeAsync('SELECT key, data FROM query_cache') + ).rows._array + products = 0 + for (const row of rows) { + products += (JSON.parse(row.data) as { items: unknown[] }).items + .length + } + return { persistMs, restoreMs: performance.now() - restoreStart } + }, + verify: () => { + assertBenchmark(rows.length === CACHE_ENTRIES, 'Wrong cached rows') + assertBenchmark( + products === CACHE_ENTRIES * CACHE_PRODUCTS, + 'Wrong cached products', + ) + for (const row of rows) { + assertBenchmark( + originals.get(row.key) === row.data, + `Wrong cached response ${row.key}`, + ) + } + }, + dispose: () => disposeBenchmarkDatabase(db), + } + } catch (error) { + disposeBenchmarkDatabase(db) + throw error + } +} + +// Image thumbnails stored as BLOBs and read back one album at a time. +function setupThumbnails(): BenchmarkFixture { + const db = createAppDatabase('benchmark_thumbnails', [ + 'CREATE TABLE thumbnails (id INTEGER PRIMARY KEY, album_id INTEGER NOT NULL, width INTEGER NOT NULL, height INTEGER NOT NULL, data BLOB NOT NULL) STRICT', + 'CREATE INDEX thumbnails_by_album ON thumbnails (album_id)', + ]) + try { + const thumbnails: SQLiteQueryParams[] = Array.from( + { length: THUMBNAILS }, + (_, id) => [ + id, + id % ALBUMS, + 256, + 256, + new Uint8Array(THUMBNAIL_BYTES).fill(id % 256).buffer, + ], + ) + let albums: ThumbnailRow[][] = [] + let bytes = 0 + + return { + database: db, + reset: () => { + db.execute('DELETE FROM thumbnails') + }, + run: async () => { + const writeStart = performance.now() + await db.executeBatchAsync([ + { query: THUMBNAIL_INSERT, params: thumbnails }, + ]) + const writeMs = performance.now() - writeStart + + const readStart = performance.now() + albums = [] + bytes = 0 + for (let album = 0; album < ALBUMS; album++) { + const rows = ( + await db.executeAsync(ALBUM_QUERY, [album]) + ).rows._array + for (const row of rows) bytes += row.data.byteLength + albums.push(rows) + } + return { writeMs, readMs: performance.now() - readStart } + }, + verify: () => { + const rows = albums.flat() + assertBenchmark(rows.length === THUMBNAILS, 'Wrong thumbnail count') + assertBenchmark( + bytes === THUMBNAILS * THUMBNAIL_BYTES, + 'Wrong thumbnail bytes', + ) + for (const row of rows) { + const data = new Uint8Array(row.data) + assertBenchmark( + data[0] === row.id % 256 && data[THUMBNAIL_BYTES - 1] === data[0], + `Wrong thumbnail ${row.id}`, + ) + } + }, + dispose: () => disposeBenchmarkDatabase(db), + } + } catch (error) { + disposeBenchmarkDatabase(db) + throw error + } +} + +function expectedKeyValueChecksum( + seed: SQLiteQueryParams[], + operations: { key: string; value?: string }[], +): number { + const values = new Map(seed.map(([key, value]) => [key, value as string])) + let checksum = 0 + for (const { key, value } of operations) { + if (value != null) { + values.set(key, value) + continue + } + const stored = values.get(key) + if (stored != null) { + checksum += (JSON.parse(stored) as { version: number }).version + 1 + } + } + return checksum +} + +function kvKey(index: number): string { + const prefixes = ['settings', 'user', 'feed', 'draft'] + return `${prefixes[index % prefixes.length]}:${index}` +} + +function kvValue(index: number, version: number): string { + return JSON.stringify({ + version, + id: index, + updatedAt: START_TIME + index * 1000 + version, + flags: { pinned: index % 3 === 0, muted: index % 5 === 0 }, + tags: Array.from({ length: 1 + (index % 8) }, (_, k) => `tag-${k}`), + note: 'Grüße aus Wien 👋 '.repeat(1 + (index % 6)), + }) +} + +function productPage(page: number) { + return { + items: Array.from({ length: CACHE_PRODUCTS }, (_, i) => { + const id = page * CACHE_PRODUCTS + i + return { + id, + sku: `SKU-${String(id).padStart(6, '0')}`, + title: `Wireless headphones "Pro ${id % 9}" – edition ${id % 4}`, + price: { amount: 1999 + (id % 200) * 100, currency: 'EUR' }, + rating: 3 + (id % 20) / 10, + reviewCount: (id * 37) % 5000, + tags: ['audio', 'bluetooth', id % 3 === 0 ? 'sale' : 'new'], + seller: { id: id % 97, name: 'Müller Elektronik', verified: true }, + imageUrl: `https://cdn.example.com/products/${id}/main.webp`, + description: + 'Kabellose Kopfhörer mit Geräuschunterdrückung, 30 Stunden Akku und Schnellladen über USB-C. Überall guter Klang 🎧', + inStock: id % 5 !== 0, + } + }), + nextCursor: `page-${page + 1}`, + } +} diff --git a/example/src/benchmarks/writeCases.ts b/example/src/benchmarks/writeCases.ts index 2cc398aa..92a9fa51 100644 --- a/example/src/benchmarks/writeCases.ts +++ b/example/src/benchmarks/writeCases.ts @@ -24,6 +24,7 @@ type WriteMethod = | 'asyncQueued' | 'transaction' | 'batchAsync' + | 'transactionSync' const methods: { method: WriteMethod; label: string }[] = [ { method: 'execute', label: '1k inserts · sync execute' }, @@ -33,6 +34,10 @@ const methods: { method: WriteMethod; label: string }[] = [ { method: 'asyncQueued', label: '1k inserts · async queued' }, { method: 'transaction', label: '1k inserts · async transaction' }, { method: 'batchAsync', label: '1k inserts · async batch' }, + { + method: 'transactionSync', + label: '1k inserts · sync execute in a transaction', + }, ] export const writeCases: BenchmarkCase[] = methods.map(({ method, label }) => ({ @@ -99,6 +104,8 @@ function runWriteCase( return db .executeBatchAsync([{ query: INSERT_SQL, params: rows }]) .then(() => undefined) + case 'transactionSync': + return runSyncTransaction(db, rows) } } @@ -124,3 +131,13 @@ async function runTransaction( for (const params of rows) await tx.executeAsync(INSERT_SQL, params) }) } + +// One commit for all rows, so the time is mostly per-call overhead. +async function runSyncTransaction( + db: NitroSQLiteConnection, + rows: SQLiteQueryParams[], +): Promise { + await db.transaction(async (tx) => { + for (const params of rows) tx.execute(INSERT_SQL, params) + }) +}