diff --git a/AndroidLibBenchmark/src/androidTest/kotlin/com/bloomberg/selekt/android/benchmark/SQLiteDatabaseBenchmark.kt b/AndroidLibBenchmark/src/androidTest/kotlin/com/bloomberg/selekt/android/benchmark/SQLiteDatabaseBenchmark.kt index 2f0c5ae059..8f4dd13d58 100644 --- a/AndroidLibBenchmark/src/androidTest/kotlin/com/bloomberg/selekt/android/benchmark/SQLiteDatabaseBenchmark.kt +++ b/AndroidLibBenchmark/src/androidTest/kotlin/com/bloomberg/selekt/android/benchmark/SQLiteDatabaseBenchmark.kt @@ -194,7 +194,7 @@ internal class SQLiteJournalModeDatabaseBenchmark(inputs: Inputs) { @OptIn(Experimental::class) @Test - fun batchInsertInt(): Unit = databaseHelper.writableDatabase.run { + fun batchSequenceInsertInt(): Unit = databaseHelper.writableDatabase.run { val args = Array(1) { 0 } benchmarkRule.measureRepeated { batch("INSERT OR REPLACE INTO 'Foo' VALUES (?)", sequence { @@ -206,6 +206,17 @@ internal class SQLiteJournalModeDatabaseBenchmark(inputs: Inputs) { } } + @OptIn(Experimental::class) + @Test + fun batchArrayInsertInt(): Unit = databaseHelper.writableDatabase.run { + val bindArgs = Array(100) { + arrayOf(it) + } + benchmarkRule.measureRepeated { + batch("INSERT OR REPLACE INTO 'Foo' VALUES (?)", bindArgs) + } + } + @Test fun queryAndInsertIntWithOnConflict(): Unit = databaseHelper.writableDatabase.run { val values = ContentValues() diff --git a/selekt-android/src/main/kotlin/com/bloomberg/selekt/android/SQLiteDatabase.kt b/selekt-android/src/main/kotlin/com/bloomberg/selekt/android/SQLiteDatabase.kt index e6584b9341..0944b2d9e4 100644 --- a/selekt-android/src/main/kotlin/com/bloomberg/selekt/android/SQLiteDatabase.kt +++ b/selekt-android/src/main/kotlin/com/bloomberg/selekt/android/SQLiteDatabase.kt @@ -154,6 +154,21 @@ class SQLiteDatabase private constructor( } } + /** + * Transacts to the database in exclusive mode a batch of queries with the same underlying SQL statement. The + * prototypical use case is for database modifications inside a tight loop to which this is optimised. + * + * Each sub-array must have the same length, corresponding to the number of arguments in the SQL statement. + * Each sub-array must have the same types at corresponding indices. + * + * @param sql statement with ? placeholders for bind parameters. + * @param bindArgs arrays of arguments for binding to the statement; each sub-array must have the same types + * at corresponding indices. + * @return the number of rows affected. + */ + @Experimental + fun batch(@Language("RoomSql") sql: String, bindArgs: Array>): Int = database.batch(sql, bindArgs) + /** * Transacts to the database in exclusive mode a batch of queries with the same underlying SQL statement. The * prototypical use case is for database modifications inside a tight loop to which this is optimised. @@ -171,6 +186,23 @@ class SQLiteDatabase private constructor( @Experimental fun batch(@Language("RoomSql") sql: String, bindArgs: Sequence>): Int = database.batch(sql, bindArgs) + /** + * Transacts to the database in exclusive mode a batch of queries with the same underlying SQL statement. The + * prototypical use case is for database modifications inside a tight loop to which this is optimised. + * + * Each array in the iterable must have the same length, corresponding to the number of arguments in the SQL statement. + * It is safe for the iterable to recycle the array with each step. + * + * The transaction is not committed by this method until the iterable is exhausted. For long sequences you may therefore + * wish to yield the transaction periodically. + * + * @param sql statement with ? placeholders for bind parameters. + * @param bindArgs sequence of standard type arguments for binding to the statement. + * @return the number of rows affected. + */ + @Experimental + fun batch(@Language("RoomSql") sql: String, bindArgs: Iterable>): Int = database.batch(sql, bindArgs) + /** * Begins a transaction in exclusive mode. Prefer [transact] whenever possible. * diff --git a/selekt-java/build.gradle.kts b/selekt-java/build.gradle.kts index e59f46809e..76b85639af 100644 --- a/selekt-java/build.gradle.kts +++ b/selekt-java/build.gradle.kts @@ -46,6 +46,9 @@ sourceSets { runtimeClasspath += sourceSets.main.get().output resources.srcDir(layout.buildDirectory.dir("intermediates/libs")) } + named("jmh") { + resources.srcDir(layout.buildDirectory.dir("intermediates/libs")) + } } val integrationTestImplementation: Configuration by configurations.getting { @@ -58,7 +61,6 @@ val integrationTestRuntimeOnly: Configuration by configurations.getting { dependencies { implementation(projects.selektApi) implementation(projects.selektSqlite3Classes) - jmhImplementation(libs.kotlinx.coroutines.core) } jmh { @@ -67,6 +69,11 @@ jmh { } } +tasks.named("jmh") { + dependsOn("buildHostSQLite") + shouldRunAfter("test", "integrationTest") +} + publishing { publications.register("main") { from(components.getByName("java")) diff --git a/selekt-java/src/jmh/java/com/bloomberg/selekt/batch/benchmarks/BatchMethodBenchmark.java b/selekt-java/src/jmh/java/com/bloomberg/selekt/batch/benchmarks/BatchMethodBenchmark.java new file mode 100644 index 0000000000..44c0255cc9 --- /dev/null +++ b/selekt-java/src/jmh/java/com/bloomberg/selekt/batch/benchmarks/BatchMethodBenchmark.java @@ -0,0 +1,225 @@ +/* + * Copyright 2025 Bloomberg Finance L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bloomberg.selekt.batch.benchmarks; + +import com.bloomberg.selekt.CommonThreadLocalRandom; +import com.bloomberg.selekt.commons.DatabaseKt; +import com.bloomberg.selekt.SQLDatabase; +import com.bloomberg.selekt.SQLiteJournalMode; +import com.bloomberg.selekt.jvm.SQLite; +import kotlin.sequences.Sequence; +import kotlin.sequences.SequencesKt; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.annotations.OutputTimeUnit; + +import java.io.File; +import java.util.concurrent.TimeUnit; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Random; + +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MILLISECONDS) +@Warmup(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 10, time = 3, timeUnit = TimeUnit.SECONDS) +@Fork(value = 1) +@State(Scope.Benchmark) +public class BatchMethodBenchmark { + @Param({"5000", "10000", "25000"}) + public int batchSize; + + @Param({"SIMPLE", "MIXED", "LARGE_BLOBS"}) + public String dataType; + + private File databaseFile; + private SQLDatabase database; + private Object[][] arrayBatchData; + private Sequence sequenceBatchData; + private Object[][] lazyArrayData; + private Sequence lazySequenceData; + private Object[][] complexArrayData; + private Sequence complexSequenceData; + private Object[][] updateData; + private Sequence updateSequenceData; + private final Random random = new Random(42); + + private static final String SQL_INSERT = "INSERT OR REPLACE INTO batch_test (id, name, value, data) " + + "VALUES (?, ?, ?, ?)"; + + @Setup(Level.Iteration) + public void setup() throws IOException { + databaseFile = Files.createTempFile("benchmark-batch", ".db").toFile(); + database = new SQLDatabase( + databaseFile.getAbsolutePath(), + SQLite.INSTANCE, + SQLiteJournalMode.WAL.databaseConfiguration, + null, + CommonThreadLocalRandom.INSTANCE + ); + database.exec("DROP TABLE IF EXISTS batch_test", null); + database.exec("CREATE TABLE batch_test (" + + "id INTEGER PRIMARY KEY, " + + "name TEXT, " + + "value REAL, " + + "data BLOB" + + ")", null); + generateTestData(); + } + + @TearDown(Level.Iteration) + public void tearDown() { + database.close(); + DatabaseKt.deleteDatabase(databaseFile); + } + + private void generateTestData() { + arrayBatchData = new Object[batchSize][]; + for (int i = 0; i < batchSize; i++) { + arrayBatchData[i] = createRowData(i); + } + sequenceBatchData = SequencesKt.sequenceOf(arrayBatchData); + + lazyArrayData = new Object[batchSize][]; + for (int i = 0; i < batchSize; i++) { + lazyArrayData[i] = new Object[]{ + i, + "lazy_" + i, + i * Math.E, + (i % 10 == 0) ? generateRandomBytes(128) : generateRandomBytes(32) + }; + } + lazySequenceData = SequencesKt.sequenceOf(lazyArrayData); + + complexArrayData = new Object[batchSize][]; + for (int i = 0; i < batchSize; i++) { + switch (i % 4) { + case 0: + complexArrayData[i] = new Object[]{i, "type_a", i * 1.0, generateRandomBytes(8)}; + break; + case 1: + complexArrayData[i] = new Object[]{i, "type_b", i * 2.0, generateRandomBytes(32)}; + break; + case 2: + complexArrayData[i] = new Object[]{i, "type_c", i * 3.0, generateRandomBytes(64)}; + break; + default: + complexArrayData[i] = new Object[]{i, "type_d", i * 0.5, generateRandomBytes(16)}; + break; + } + } + complexSequenceData = SequencesKt.sequenceOf(complexArrayData); + + updateData = new Object[batchSize][]; + for (int i = 0; i < batchSize; i++) { + updateData[i] = new Object[]{ + "updated_" + i, + i * 10.0, + i + }; + } + updateSequenceData = SequencesKt.sequenceOf(updateData); + } + + private Object[] createRowData(int index) { + return switch (dataType) { + case "SIMPLE" -> new Object[]{ + index, + "item_" + index, + index * 1.5, + null + }; + case "MIXED" -> new Object[]{ + index, + (index % 3 == 0) ? "mixed_" + index : "default_" + index, + (index % 2 == 0) ? index * 2.5 : 0.0, + (index % 5 == 0) ? generateRandomBytes(64) : generateRandomBytes(16) + }; + case "LARGE_BLOBS" -> new Object[]{ + index, + "blob_item_" + index, + index * 1.0, + generateRandomBytes(2048) + }; + default -> new Object[]{index, "default", 1.0, null}; + }; + } + + private byte[] generateRandomBytes(int size) { + byte[] bytes = new byte[size]; + random.nextBytes(bytes); + return bytes; + } + + @Benchmark + public int arrayBatchMethod() { + return database.batch(SQL_INSERT, arrayBatchData); + } + + @Benchmark + public int sequenceBatchMethod() { + return database.batch(SQL_INSERT, sequenceBatchData); + } + + @Benchmark + public int arrayBatchUpdate() { + database.batch(SQL_INSERT, arrayBatchData); + return database.batch( + "UPDATE batch_test SET name = ?, value = ? WHERE id = ?", + updateData + ); + } + + @Benchmark + public int sequenceBatchUpdate() { + database.batch(SQL_INSERT, sequenceBatchData); + return database.batch( + "UPDATE batch_test SET name = ?, value = ? WHERE id = ?", + updateSequenceData + ); + } + + @Benchmark + public int arrayBatchLazyEquivalent() { + return database.batch(SQL_INSERT, lazyArrayData); + } + + @Benchmark + public int sequenceBatchLazy() { + return database.batch(SQL_INSERT, lazySequenceData); + } + + @Benchmark + public int arrayBatchComplex() { + return database.batch(SQL_INSERT, complexArrayData); + } + + @Benchmark + public int sequenceBatchComplex() { + return database.batch(SQL_INSERT, complexSequenceData); + } +} diff --git a/selekt-java/src/jmh/java/com/bloomberg/selekt/jvm/SQLite.kt b/selekt-java/src/jmh/java/com/bloomberg/selekt/jvm/SQLite.kt new file mode 100644 index 0000000000..7baa1fd8fd --- /dev/null +++ b/selekt-java/src/jmh/java/com/bloomberg/selekt/jvm/SQLite.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2020 Bloomberg Finance L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bloomberg.selekt.jvm + +import com.bloomberg.selekt.SQLite +import com.bloomberg.selekt.commons.loadEmbeddedLibrary +import com.bloomberg.selekt.externalSQLiteSingleton + +private val sqlite = externalSQLiteSingleton { + loadEmbeddedLibrary(SQLite::class.java.classLoader, "jni", "selekt") +} + +internal object SQLite : SQLite(sqlite) diff --git a/selekt-java/src/main/kotlin/com/bloomberg/selekt/BatchSQLExecutor.kt b/selekt-java/src/main/kotlin/com/bloomberg/selekt/BatchSQLExecutor.kt index 60a8626782..3a9a9e75d4 100644 --- a/selekt-java/src/main/kotlin/com/bloomberg/selekt/BatchSQLExecutor.kt +++ b/selekt-java/src/main/kotlin/com/bloomberg/selekt/BatchSQLExecutor.kt @@ -17,5 +17,16 @@ package com.bloomberg.selekt internal interface BatchSQLExecutor { - fun executeForChangedRowCount(sql: String, bindArgs: Sequence>): Int + /** + * @param sql SQL statement with ? placeholders for bind parameters. + * @param bindArgs arrays of arguments for binding to the statement; all sub-arrays must have + * the same length and the same types at corresponding indices (e.g., String, Int, ByteArray, null). + * @return the number of rows affected. + */ + fun executeBatchForChangedRowCount(sql: String, bindArgs: Array>): Int + + fun executeBatchForChangedRowCount(sql: String, bindArgs: Sequence>): Int + + fun executeBatchForChangedRowCount(sql: String, bindArgs: Iterable>): Int = + executeBatchForChangedRowCount(sql, bindArgs.asSequence()) } diff --git a/selekt-java/src/main/kotlin/com/bloomberg/selekt/Databases.kt b/selekt-java/src/main/kotlin/com/bloomberg/selekt/Databases.kt index bd1074a3ba..6aa15fb356 100644 --- a/selekt-java/src/main/kotlin/com/bloomberg/selekt/Databases.kt +++ b/selekt-java/src/main/kotlin/com/bloomberg/selekt/Databases.kt @@ -67,6 +67,14 @@ class SQLDatabase( SQLStatement.execute(session, sql, bindArgs) } + fun batch(sql: String, bindArgs: Iterable>): Int = transact { + SQLStatement.execute(session, sql, bindArgs) + } + + fun batch(sql: String, bindArgs: Array>): Int = transact { + SQLStatement.execute(session, sql, bindArgs) + } + override fun beginExclusiveTransaction() = pledge { session.get().beginExclusiveTransaction() } override fun beginExclusiveTransactionWithListener(listener: SQLTransactionListener) = pledge { diff --git a/selekt-java/src/main/kotlin/com/bloomberg/selekt/SQLBinder.kt b/selekt-java/src/main/kotlin/com/bloomberg/selekt/SQLBinder.kt new file mode 100644 index 0000000000..6131b54117 --- /dev/null +++ b/selekt-java/src/main/kotlin/com/bloomberg/selekt/SQLBinder.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2026 Bloomberg Finance L.P. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.bloomberg.selekt + +internal class SQLBinder( + private val statement: SQLPreparedStatement, + private val strategies: Array +) { + private var position = 1 + + val size = strategies.size + + fun bind(value: Any?) { + strategies[position - 1].bind(statement, position++, value) + } + + fun reset() { + position = 1 + } +} diff --git a/selekt-java/src/main/kotlin/com/bloomberg/selekt/SQLConnection.kt b/selekt-java/src/main/kotlin/com/bloomberg/selekt/SQLConnection.kt index 7de4b6f23d..4631e9ea68 100644 --- a/selekt-java/src/main/kotlin/com/bloomberg/selekt/SQLConnection.kt +++ b/selekt-java/src/main/kotlin/com/bloomberg/selekt/SQLConnection.kt @@ -18,6 +18,8 @@ package com.bloomberg.selekt import com.bloomberg.selekt.cache.LruCache import com.bloomberg.selekt.commons.forEachByPositionUntil +import com.bloomberg.selekt.commons.forEachOptimized +import com.bloomberg.selekt.commons.forEachUntil import com.bloomberg.selekt.commons.forUntil import javax.annotation.concurrent.NotThreadSafe @@ -96,11 +98,37 @@ internal class SQLConnection( } } - override fun executeForChangedRowCount(sql: String, bindArgs: Sequence>) = withPreparedStatement(sql) { + override fun executeBatchForChangedRowCount( + sql: String, + bindArgs: Array>) + : Int = if (bindArgs.isEmpty()) { + 0 + } else { + withPreparedStatement(sql) { + val changes = sqlite.totalChanges(pointer) + val binder = SQLBinder(this, SQLBindStrategyResolver.resolveAll(bindArgs.first())) + bindArgs.forEachOptimized { args -> + reset() + args.forEachUntil(parameterCount, binder::bind) + if (SQL_DONE != step()) { + return@withPreparedStatement -1 + } + binder.reset() + } + sqlite.totalChanges(pointer) - changes + } + } + + override fun executeBatchForChangedRowCount( + sql: String, + bindArgs: Sequence> + ) = withPreparedStatement(sql) { val changes = sqlite.totalChanges(pointer) bindArgs.forEach { reset() - bindArguments(it) + it.forEachByPositionUntil(parameterCount) { arg, i -> + SQLBindStrategy.Universal.bind(this, i, arg) + } if (SQL_DONE != step()) { return@withPreparedStatement -1 } diff --git a/selekt-java/src/main/kotlin/com/bloomberg/selekt/SQLStatement.kt b/selekt-java/src/main/kotlin/com/bloomberg/selekt/SQLStatement.kt index 2153f6e864..5ebdc6eb31 100644 --- a/selekt-java/src/main/kotlin/com/bloomberg/selekt/SQLStatement.kt +++ b/selekt-java/src/main/kotlin/com/bloomberg/selekt/SQLStatement.kt @@ -108,6 +108,19 @@ internal class SQLStatement private constructor( it.execute(sql, bindArgs) } + fun execute( + session: ThreadLocalSession, + sql: String, + bindArgs: Array> + ): Int { + require(SQLStatementType.UPDATE === sql.resolvedSqlStatementType()) { + "Only batched updates are permitted." + } + return session.get().execute(true, sql) { + it.executeBatchForChangedRowCount(sql, bindArgs) + } + } + fun execute( session: ThreadLocalSession, sql: String, @@ -117,10 +130,16 @@ internal class SQLStatement private constructor( "Only batched updates are permitted." } return session.get().execute(true, sql) { - it.executeForChangedRowCount(sql, bindArgs) + it.executeBatchForChangedRowCount(sql, bindArgs) } } + fun execute( + session: ThreadLocalSession, + sql: String, + bindArgs: Iterable> + ) = execute(session, sql, bindArgs.asSequence()) + fun executeForInt( session: ThreadLocalSession, sql: String, diff --git a/selekt-java/src/main/kotlin/com/bloomberg/selekt/commons/Arrays.kt b/selekt-java/src/main/kotlin/com/bloomberg/selekt/commons/Arrays.kt index f262cbb91f..42f65761be 100644 --- a/selekt-java/src/main/kotlin/com/bloomberg/selekt/commons/Arrays.kt +++ b/selekt-java/src/main/kotlin/com/bloomberg/selekt/commons/Arrays.kt @@ -43,6 +43,25 @@ internal inline fun Array.forEachByPositionUntil(index: Int, block: (T, I } } +@JvmSynthetic +internal inline fun Array.forEachOptimized(block: (T) -> Unit) { + var i = 0 + while (i < size) { + block(this[i++]) + } +} + +/** + * Iterates over the array elements until the specified index (exclusive). + */ +@JvmSynthetic +internal inline fun Array.forEachUntil(index: Int, block: (T) -> Unit) { + var i = 0 + while (i < index) { + block(this[i++]) + } +} + @JvmSynthetic internal fun Array.joinTo(buffer: A, separator: Char) = buffer.apply { forEachByIndex { index, value -> diff --git a/selekt-java/src/test/kotlin/com/bloomberg/selekt/SQLConnectionTest.kt b/selekt-java/src/test/kotlin/com/bloomberg/selekt/SQLConnectionTest.kt index ba9a8aaa50..10bc5fbf99 100644 --- a/selekt-java/src/test/kotlin/com/bloomberg/selekt/SQLConnectionTest.kt +++ b/selekt-java/src/test/kotlin/com/bloomberg/selekt/SQLConnectionTest.kt @@ -286,7 +286,24 @@ internal class SQLConnectionTest { whenever(sqlite.step(any())) doReturn SQL_ROW whenever(sqlite.changes(any())) doReturn 0 SQLConnection("file::memory:", sqlite, databaseConfiguration, 0, CommonThreadLocalRandom, null).use { - assertEquals(-1, it.executeForChangedRowCount("INSERT INTO Foo VALUES (42)", sequenceOf(emptyArray()))) + assertEquals(-1, it.executeBatchForChangedRowCount("INSERT INTO Foo VALUES (42)", arrayOf(emptyArray()))) + } + } + + @Test + fun batchExecuteForChangedRowCountSequenceChecksDone() { + whenever(sqlite.openV2(any(), any(), any())) doAnswer Answer { + (it.arguments[2] as LongArray)[0] = 42L + 0 + } + whenever(sqlite.prepareV2(any(), any(), any())) doAnswer Answer { + (it.arguments[2] as LongArray)[0] = 43L + 0 + } + whenever(sqlite.step(any())) doReturn SQL_ROW + whenever(sqlite.changes(any())) doReturn 0 + SQLConnection("file::memory:", sqlite, databaseConfiguration, 0, CommonThreadLocalRandom, null).use { + assertEquals(-1, it.executeBatchForChangedRowCount("INSERT INTO Foo VALUES (42)", sequenceOf(emptyArray()))) } }