From 831e31220e5ad5362c3648a8db48f9f3abb66ccb Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 18 Sep 2026 21:22:34 +0200 Subject: [PATCH 1/3] perf: submit async SQLite operations through native FIFO --- README.md | 2 + .../tests/unit/specs/DatabaseQueue.spec.ts | 74 ++++++++++++++++-- .../cpp/hybridObjects/HybridNitroSQLite.cpp | 28 +++++-- .../cpp/operations.cpp | 27 +++++++ .../cpp/operations.hpp | 12 ++- .../src/DatabaseQueue.ts | 77 +++++++++++++++---- .../src/__tests__/DatabaseQueue.test.ts | 74 +++++++++++++++++- .../src/operations/execute.ts | 4 +- 8 files changed, 270 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 959bbf0f..6f8cf22a 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,8 @@ const db = open({ name: 'myDb.sqlite' }) Async operations submitted on the opened `db` connection outside a transaction callback run in call order. Async work waits for an active transaction to finish, while a conflicting sync operation or `close()` throws a busy error. +You can submit several `executeAsync` calls together with `Promise.all`. NitroSQLite sends them to a native FIFO on that connection, so the next query can start without waiting for JavaScript to process the previous result. A single connection still executes one SQL operation at a time. Transactions wait for earlier queries to finish and hold the connection until the callback completes. + `NitroSQLite.native` bypasses this JavaScript queue. Native calls keep each individual SQLite handle safe, but mixing them with a session transaction can still run statements inside that transaction. A build with `SQLITE_THREADSAFE=0` also remains unsafe when different database handles run concurrently unless the caller serializes every SQLite call globally. --- diff --git a/example/tests/unit/specs/DatabaseQueue.spec.ts b/example/tests/unit/specs/DatabaseQueue.spec.ts index e5822ce2..271a45a4 100644 --- a/example/tests/unit/specs/DatabaseQueue.spec.ts +++ b/example/tests/unit/specs/DatabaseQueue.spec.ts @@ -220,9 +220,11 @@ export default function registerDatabaseQueueUnitTests() { }) await transactionStarted.promise - const externalWrite = testDb.executeAsync( - 'INSERT INTO User (id, name, age, networth) VALUES (?, ?, ?, ?)', - [2, 'external', 2, 2], + const externalWrites = Array.from({ length: 24 }, (_, index) => + testDb.executeAsync( + 'INSERT INTO User (id, name, age, networth) VALUES (?, ?, ?, ?)', + [index + 2, `external-${index}`, 2, 2], + ), ) finishTransaction.resolve() @@ -231,11 +233,11 @@ export default function registerDatabaseQueueUnitTests() { } catch (error) { expect((error as Error).message).toContain('rollback transaction') } - await externalWrite + await Promise.all(externalWrites) expect( testDb.execute<{ id: number }>('SELECT id FROM User').results, - ).toEqual([{ id: 2 }]) + ).toEqual(Array.from({ length: 24 }, (_, index) => ({ id: index + 2 }))) }) it('returns distinct insert IDs from parallel async inserts', async () => { @@ -257,6 +259,68 @@ export default function registerDatabaseQueueUnitTests() { ) }) + it('starts a transaction after an earlier burst of async writes finishes', async () => { + testDb.execute('CREATE TABLE TransactionBarrier (value INTEGER)') + const writes = Array.from({ length: 24 }, (_, index) => + testDb.executeAsync( + 'INSERT INTO TransactionBarrier (value) VALUES (?)', + [index], + ), + ) + const transaction = testDb.transaction( + async (tx) => + tx.execute<{ total: number }>( + 'SELECT count(*) AS total FROM TransactionBarrier', + ).results[0]?.total, + ) + + await Promise.all(writes) + expect(await transaction).toBe(24) + }) + + it('runs native async statements in submission order', async () => { + const dbName = 'native-fifo-order' + dropDatabaseIfExists(dbName) + NitroSQLite.native.open(dbName) + + try { + NitroSQLite.native.execute( + dbName, + 'CREATE TABLE NativeQueueInsert (id INTEGER PRIMARY KEY AUTOINCREMENT, value INTEGER)', + ) + const results = await Promise.all( + Array.from({ length: 64 }, (_, index) => + NitroSQLite.native.executeAsync( + dbName, + 'INSERT INTO NativeQueueInsert (value) VALUES (?)', + [index], + ), + ), + ) + + expect(results.map((result) => result.insertId)).toEqual( + Array.from({ length: 64 }, (_, index) => index + 1), + ) + + const batch = NitroSQLite.native.executeBatchAsync(dbName, [ + { + query: 'INSERT INTO NativeQueueInsert (value) VALUES (?)', + params: [[64], [65]], + }, + ]) + const afterBatch = NitroSQLite.native.executeAsync( + dbName, + 'INSERT INTO NativeQueueInsert (value) VALUES (?)', + [66], + ) + await batch + expect((await afterBatch).insertId).toBe(67) + } finally { + NitroSQLite.native.close(dbName) + dropDatabaseIfExists(dbName) + } + }) + it('rejects sync work and close while async work is pending', async () => { const dbName = 'busy-close' dropDatabaseIfExists(dbName) diff --git a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp index 94c576dd..bec2da18 100644 --- a/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp +++ b/packages/react-native-nitro-sqlite/cpp/hybridObjects/HybridNitroSQLite.cpp @@ -13,13 +13,14 @@ #include #include #include +#include #include #include namespace margelo::nitro::rnnitrosqlite { // Copy any JS-backed ArrayBuffers on the JS thread so they can be safely -// accessed from the background thread used by Promise::async. +// accessed from the connection's background worker. static std::optional copyArrayBufferParamsForBackground(const std::optional& params) { if (!params) { return std::nullopt; @@ -59,6 +60,23 @@ static std::vector copyArrayBufferParamsForBackground(const std::vec return copiedCommands; } +template +static std::shared_ptr> enqueueConnectionOperation(const SQLiteConnectionPtr& connection, Operation&& operation) { + auto promise = Promise::create(); + try { + connection->enqueueAsync([promise, operation = std::forward(operation)]() mutable { + try { + promise->resolve(operation()); + } catch (...) { + promise->reject(std::current_exception()); + } + }); + } catch (...) { + promise->reject(std::current_exception()); + } + return promise; +} + const std::string getDocPath(const std::optional& location) { std::string tempDocPath = std::string(HybridNitroSQLite::docPath); if (location) { @@ -139,8 +157,8 @@ HybridNitroSQLite::executeAsync(const std::string& dbName, const std::string& qu return Promise>::rejected(std::current_exception()); } - return Promise>::async( - [connection, query, copiedParams]() -> std::shared_ptr { + return enqueueConnectionOperation>( + connection, [connection, query, copiedParams]() -> std::shared_ptr { auto result = sqliteExecute(connection, query, copiedParams); return result; }); @@ -166,7 +184,7 @@ std::shared_ptr> HybridNitroSQLite::executeBatchAsync( return Promise::rejected(std::current_exception()); } - return Promise::async([connection, copiedCommands]() -> BatchQueryResult { + return enqueueConnectionOperation(connection, [connection, copiedCommands]() -> BatchQueryResult { auto result = sqliteExecuteBatch(connection, copiedCommands); return BatchQueryResult(result.rowsAffected); }); @@ -184,7 +202,7 @@ std::shared_ptr> HybridNitroSQLite::loadFileAsync(const } catch (...) { return Promise::rejected(std::current_exception()); } - return Promise::async([connection, location]() -> FileLoadResult { + return enqueueConnectionOperation(connection, [connection, location]() -> FileLoadResult { const auto result = importSqlFile(connection, location); return FileLoadResult(result.commands, result.rowsAffected); }); diff --git a/packages/react-native-nitro-sqlite/cpp/operations.cpp b/packages/react-native-nitro-sqlite/cpp/operations.cpp index 8c99db01..6ac4d19b 100644 --- a/packages/react-native-nitro-sqlite/cpp/operations.cpp +++ b/packages/react-native-nitro-sqlite/cpp/operations.cpp @@ -4,6 +4,7 @@ #include "logs.hpp" #include "utils.hpp" #include +#include #include #include #include @@ -54,6 +55,32 @@ void SQLiteConnection::close() noexcept { database = nullptr; } +void SQLiteConnection::enqueueAsync(std::function operation) { + std::lock_guard lock(asyncQueueMutex); + if (!asyncWorkerRunning) { + // The worker holds this connection alive until it has drained every operation. + ThreadPool::shared().run([connection = shared_from_this()] { connection->drainAsync(); }); + asyncWorkerRunning = true; + } + asyncQueue.push(std::move(operation)); +} + +void SQLiteConnection::drainAsync() { + while (true) { + std::function operation; + { + std::lock_guard lock(asyncQueueMutex); + if (asyncQueue.empty()) { + asyncWorkerRunning = false; + return; + } + operation = std::move(asyncQueue.front()); + asyncQueue.pop(); + } + operation(); + } +} + void sqliteOpenDb(const std::string& dbName, const std::string& docPath) { std::lock_guard lifecycleLock(dbLifecycleMutex); { diff --git a/packages/react-native-nitro-sqlite/cpp/operations.hpp b/packages/react-native-nitro-sqlite/cpp/operations.hpp index 549d68e5..29642f0f 100644 --- a/packages/react-native-nitro-sqlite/cpp/operations.hpp +++ b/packages/react-native-nitro-sqlite/cpp/operations.hpp @@ -2,8 +2,10 @@ #include "hybridObjects/HybridNitroSQLiteQueryResult.hpp" #include "types.hpp" +#include #include #include +#include #include #include @@ -12,7 +14,7 @@ namespace margelo::rnnitrosqlite { // Calls against one connection are serialized by `mutex`. Separate connections // intentionally remain independent, so SQLITE_THREADSAFE=0 still requires the // caller to serialize SQLite calls globally. -struct SQLiteConnection final { +struct SQLiteConnection final : std::enable_shared_from_this { SQLiteConnection(std::string name, sqlite3* database); ~SQLiteConnection(); @@ -20,10 +22,18 @@ struct SQLiteConnection final { SQLiteConnection& operator=(const SQLiteConnection&) = delete; void close() noexcept; + void enqueueAsync(std::function operation); const std::string name; sqlite3* database; std::recursive_mutex mutex; + +private: + void drainAsync(); + + std::mutex asyncQueueMutex; + std::queue> asyncQueue; + bool asyncWorkerRunning = false; }; using SQLiteConnectionPtr = std::shared_ptr; diff --git a/packages/react-native-nitro-sqlite/src/DatabaseQueue.ts b/packages/react-native-nitro-sqlite/src/DatabaseQueue.ts index 1ad4d258..567f1910 100644 --- a/packages/react-native-nitro-sqlite/src/DatabaseQueue.ts +++ b/packages/react-native-nitro-sqlite/src/DatabaseQueue.ts @@ -1,6 +1,7 @@ import NitroSQLiteError from './NitroSQLiteError' export interface QueuedOperation { + kind: 'statement' | 'exclusive' /** * Starts the operation */ @@ -10,6 +11,8 @@ export interface QueuedOperation { export type DatabaseQueue = { queue: QueuedOperation[] inProgress: boolean + activeStatements: number + draining: boolean } const databaseQueues = new Map() @@ -21,7 +24,12 @@ export function openDatabaseQueue(dbName: string) { ) } - databaseQueues.set(dbName, { queue: [], inProgress: false }) + databaseQueues.set(dbName, { + queue: [], + inProgress: false, + activeStatements: 0, + draining: false, + }) } export function closeDatabaseQueue(dbName: string) { @@ -57,7 +65,22 @@ export function getDatabaseQueue(dbName: string) { export function queueOperationAsync( dbName: string, callback: () => Promise, -) { +): Promise { + return enqueueOperation(dbName, 'exclusive', callback) +} + +export function queueStatementAsync( + dbName: string, + callback: () => Promise, +): Promise { + return enqueueOperation(dbName, 'statement', callback) +} + +function enqueueOperation( + dbName: string, + kind: QueuedOperation['kind'], + callback: () => Promise, +): Promise { const databaseQueue = getDatabaseQueue(dbName) return new Promise((resolve, reject) => { @@ -68,32 +91,59 @@ export function queueOperationAsync( } catch (error) { reject(error) } finally { - databaseQueue.inProgress = false - startOperationAsync(databaseQueue) + if (kind === 'statement') { + databaseQueue.activeStatements-- + if (databaseQueue.activeStatements === 0) { + databaseQueue.inProgress = false + } + } else { + databaseQueue.inProgress = false + } + startNextOperations(databaseQueue) } } const operation: QueuedOperation = { + kind, start, } databaseQueue.queue.push(operation) - startOperationAsync(databaseQueue) + startNextOperations(databaseQueue) }) } -function startOperationAsync(queue: DatabaseQueue) { - // Queue is empty or in progress. Bail out. - if (queue.inProgress || queue.queue.length === 0) { +function startNextOperations(queue: DatabaseQueue) { + if (queue.draining || (queue.inProgress && queue.activeStatements === 0)) { return } - queue.inProgress = true + queue.draining = true + try { + while (queue.queue.length > 0) { + const exclusiveIndex = queue.queue.findIndex( + (operation) => operation.kind === 'exclusive', + ) + const statementCount = + exclusiveIndex === -1 ? queue.queue.length : exclusiveIndex + + if (statementCount > 0) { + const statements = queue.queue.splice(0, statementCount) + queue.inProgress = true + queue.activeStatements += statements.length + for (const statement of statements) statement.start() + continue + } - const operation = queue.queue.shift()! - setImmediate(() => { - operation.start() - }) + if (queue.activeStatements > 0) return + + queue.inProgress = true + queue.queue.shift()!.start() + return + } + } finally { + queue.draining = false + } } export function startOperationSync( @@ -115,5 +165,6 @@ export function startOperationSync( return callback() } finally { databaseQueue.inProgress = false + startNextOperations(databaseQueue) } } diff --git a/packages/react-native-nitro-sqlite/src/__tests__/DatabaseQueue.test.ts b/packages/react-native-nitro-sqlite/src/__tests__/DatabaseQueue.test.ts index fcf15fe1..35886ac6 100644 --- a/packages/react-native-nitro-sqlite/src/__tests__/DatabaseQueue.test.ts +++ b/packages/react-native-nitro-sqlite/src/__tests__/DatabaseQueue.test.ts @@ -4,6 +4,7 @@ import { isDatabaseOpen, openDatabaseQueue, queueOperationAsync, + queueStatementAsync, startOperationSync, throwIfDatabaseIsNotOpen, } from '../DatabaseQueue' @@ -25,7 +26,12 @@ describe('DatabaseQueue', () => { openDatabaseQueue(dbName) expect(isDatabaseOpen(dbName)).toBe(true) - expect(getDatabaseQueue(dbName)).toEqual({ queue: [], inProgress: false }) + expect(getDatabaseQueue(dbName)).toMatchObject({ + queue: [], + inProgress: false, + activeStatements: 0, + draining: false, + }) expect(() => openDatabaseQueue(dbName)).toThrow('already open') closeDatabaseQueue(dbName) @@ -80,7 +86,71 @@ describe('DatabaseQueue', () => { await expect(two).resolves.toBe(2) await expect(three).resolves.toBe(3) expect(started).toEqual([1, 2, 3]) - expect(getDatabaseQueue(dbName)).toEqual({ queue: [], inProgress: false }) + expect(getDatabaseQueue(dbName)).toMatchObject({ + queue: [], + inProgress: false, + activeStatements: 0, + draining: false, + }) + }) + + it('submits a burst of statements before waiting for native results', async () => { + openDatabaseQueue(dbName) + const first = deferred() + const started: number[] = [] + const operations = Array.from({ length: 64 }, (_, index) => + queueStatementAsync(dbName, () => { + started.push(index) + return index === 0 ? first.promise : Promise.resolve(index) + }), + ) + + expect(started).toEqual(Array.from({ length: 64 }, (_, index) => index)) + expect(getDatabaseQueue(dbName).activeStatements).toBe(64) + expect(() => startOperationSync(dbName, () => 1)).toThrow('busy') + expect(() => closeDatabaseQueue(dbName)).toThrow('busy') + + first.resolve(0) + expect(await Promise.all(operations)).toEqual(started) + expect(getDatabaseQueue(dbName).inProgress).toBe(false) + }) + + it('waits for every earlier statement before starting a transaction', async () => { + openDatabaseQueue(dbName) + const first = deferred() + const second = deferred() + const order: string[] = [] + const one = queueStatementAsync(dbName, () => { + order.push('first') + return first.promise + }) + const two = queueStatementAsync(dbName, () => { + order.push('second') + return second.promise + }) + const transaction = queueOperationAsync(dbName, async () => { + order.push('transaction') + }) + const after = queueStatementAsync(dbName, async () => { + order.push('after') + }) + + expect(order).toEqual(['first', 'second']) + second.resolve() + await two + expect(order).toEqual(['first', 'second']) + first.resolve() + await Promise.all([one, transaction, after]) + expect(order).toEqual(['first', 'second', 'transaction', 'after']) + }) + + it('starts queued async work after a synchronous operation completes', async () => { + openDatabaseQueue(dbName) + let pending: Promise | undefined + startOperationSync(dbName, () => { + pending = queueStatementAsync(dbName, async () => 42) + }) + await expect(pending).resolves.toBe(42) }) it('keeps queues for different databases independent', async () => { diff --git a/packages/react-native-nitro-sqlite/src/operations/execute.ts b/packages/react-native-nitro-sqlite/src/operations/execute.ts index bc75a84a..8cdcddc6 100644 --- a/packages/react-native-nitro-sqlite/src/operations/execute.ts +++ b/packages/react-native-nitro-sqlite/src/operations/execute.ts @@ -4,7 +4,7 @@ import NitroSQLiteError from '../NitroSQLiteError' import type { NitroSQLiteQueryResult } from '../specs/NitroSQLiteQueryResult.nitro' import { isDatabaseOpen, - queueOperationAsync, + queueStatementAsync, startOperationSync, } from '../DatabaseQueue' @@ -58,7 +58,7 @@ export async function executeAsyncManaged( query: string, params?: SQLiteQueryParams, ): Promise> { - return queueOperationAsync(dbName, () => + return queueStatementAsync(dbName, () => executeAsyncNative(dbName, query, params), ) } From 5d6895a910a1136ecd1cd71d36168113cdc3eeb5 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 18 Sep 2026 21:32:18 +0200 Subject: [PATCH 2/3] fix: schedule FIFO through public Nitro Promise API --- packages/react-native-nitro-sqlite/cpp/operations.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-native-nitro-sqlite/cpp/operations.cpp b/packages/react-native-nitro-sqlite/cpp/operations.cpp index 6ac4d19b..918dd3cd 100644 --- a/packages/react-native-nitro-sqlite/cpp/operations.cpp +++ b/packages/react-native-nitro-sqlite/cpp/operations.cpp @@ -4,7 +4,7 @@ #include "logs.hpp" #include "utils.hpp" #include -#include +#include #include #include #include @@ -59,7 +59,7 @@ void SQLiteConnection::enqueueAsync(std::function operation) { std::lock_guard lock(asyncQueueMutex); if (!asyncWorkerRunning) { // The worker holds this connection alive until it has drained every operation. - ThreadPool::shared().run([connection = shared_from_this()] { connection->drainAsync(); }); + Promise::async([connection = shared_from_this()] { connection->drainAsync(); }); asyncWorkerRunning = true; } asyncQueue.push(std::move(operation)); From e3ade179cb1a409be5232039bcdf3b39577eb586 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Fri, 18 Sep 2026 21:32:30 +0200 Subject: [PATCH 3/3] fix(ci): build iOS against a generic simulator --- .github/workflows/build-ios.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index c120e18e..9042c38c 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -112,7 +112,7 @@ jobs: -scheme NitroSQLiteExample \ -sdk iphonesimulator \ -configuration Debug \ - -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + -destination 'generic/platform=iOS Simulator' \ -showBuildTimingSummary \ ONLY_ACTIVE_ARCH=YES \ build \