Skip to content

Commit 88417f6

Browse files
Merge pull request #1244 from kennethshackleton/cache-column-label
Cache column-label indexes for wide result sets
2 parents f8745ee + 6ebb048 commit 88417f6

4 files changed

Lines changed: 203 additions & 2 deletions

File tree

‎selekt-java/src/main/kotlin/com/bloomberg/selekt/Cursors.kt‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,15 @@ internal fun interface CursorWindowRefill {
9494
fun refill(startPosition: Int): CursorWindowPage
9595
}
9696

97+
private const val MIN_COLUMNS_FOR_INDEX_CACHE = 16
98+
private const val LOOKUPS_BEFORE_INDEX_CACHE = 1
99+
private const val HASH_MAP_LOAD_FACTOR = 0.75f
100+
101+
private fun Array<out String>.columnIndexMap(): Map<String, Int> =
102+
HashMap<String, Int>((size / HASH_MAP_LOAD_FACTOR).toInt() + 1).also { indices ->
103+
forEachIndexed { index, name -> indices.putIfAbsent(name, index) }
104+
}
105+
97106
@NotThreadSafe
98107
internal class WindowedCursor(
99108
private val columnNames: Array<out String>,
@@ -108,6 +117,8 @@ internal class WindowedCursor(
108117

109118
private var window = page.window
110119
private var windowStart = page.startPosition
120+
private var columnIndexLookups = 0
121+
private var columnIndices: Map<String, Int>? = null
111122

112123
override val columnCount = columnNames.size
113124

@@ -152,7 +163,18 @@ internal class WindowedCursor(
152163
failure?.let { throw it }
153164
}
154165

155-
override fun columnIndex(name: String) = columnNames.indexOfFirst { it == name }
166+
override fun columnIndex(name: String): Int {
167+
val indices = columnIndices
168+
return when {
169+
indices != null -> indices[name] ?: -1
170+
columnNames.size < MIN_COLUMNS_FOR_INDEX_CACHE ||
171+
columnIndexLookups++ < LOOKUPS_BEFORE_INDEX_CACHE -> columnNames.indexOfFirst { it == name }
172+
else -> columnNames.columnIndexMap().let {
173+
columnIndices = it
174+
it[name] ?: -1
175+
}
176+
}
177+
}
156178

157179
override fun columnName(index: Int) = columnNames[index]
158180

@@ -232,6 +254,8 @@ internal class ForwardCursor(
232254
private var statement: SQLPreparedStatement? = statement
233255

234256
private val columnNames = statement.columnNames
257+
private var columnIndexLookups = 0
258+
private var columnIndices: Map<String, Int>? = null
235259

236260
override val columnCount = columnNames.size
237261

@@ -246,7 +270,18 @@ internal class ForwardCursor(
246270
releaseResources()
247271
}
248272

249-
override fun columnIndex(name: String) = columnNames.indexOfFirst { it == name }
273+
override fun columnIndex(name: String): Int {
274+
val indices = columnIndices
275+
return when {
276+
indices != null -> indices[name] ?: -1
277+
columnNames.size < MIN_COLUMNS_FOR_INDEX_CACHE ||
278+
columnIndexLookups++ < LOOKUPS_BEFORE_INDEX_CACHE -> columnNames.indexOfFirst { it == name }
279+
else -> columnNames.columnIndexMap().let {
280+
columnIndices = it
281+
it[name] ?: -1
282+
}
283+
}
284+
}
250285

251286
override fun columnName(index: Int) = columnNames[index]
252287

‎selekt-java/src/test/kotlin/com/bloomberg/selekt/ForwardCursorTest.kt‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,24 @@ internal class ForwardCursorTest {
5353
assertEquals(1, ForwardCursor(statement).columnIndex("xyz"))
5454
}
5555

56+
@Test
57+
fun cachedColumnIndexPreservesFirstDuplicateAndCase() {
58+
val columns = Array(16) { "column_$it" }.apply {
59+
this[1] = "duplicate"
60+
this[10] = "duplicate"
61+
this[11] = "DUPLICATE"
62+
}
63+
val statement = mock<SQLPreparedStatement>().apply {
64+
whenever(columnNames) doReturn columns
65+
}
66+
val cursor = ForwardCursor(statement)
67+
68+
assertEquals(1, cursor.columnIndex("duplicate"))
69+
assertEquals(1, cursor.columnIndex("duplicate"))
70+
assertEquals(11, cursor.columnIndex("DUPLICATE"))
71+
assertEquals(-1, cursor.columnIndex("Duplicate"))
72+
}
73+
5674
@Test
5775
fun columnName() {
5876
val statement = mock<SQLPreparedStatement>().apply {

‎selekt-java/src/test/kotlin/com/bloomberg/selekt/WindowedCursorTest.kt‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,21 @@ internal class WindowedCursorTest {
7474
assertEquals(-1, cursor.columnIndex("b"))
7575
}
7676

77+
@Test
78+
fun cachedColumnIndexPreservesFirstDuplicateAndCase() {
79+
val columns = Array(16) { "column_$it" }.apply {
80+
this[1] = "duplicate"
81+
this[10] = "duplicate"
82+
this[11] = "DUPLICATE"
83+
}
84+
val cursor = windowedCursor(columns, mock())
85+
86+
assertEquals(1, cursor.columnIndex("duplicate"))
87+
assertEquals(1, cursor.columnIndex("duplicate"))
88+
assertEquals(11, cursor.columnIndex("DUPLICATE"))
89+
assertEquals(-1, cursor.columnIndex("Duplicate"))
90+
}
91+
7792
@Test
7893
fun columnName() {
7994
val cursor = windowedCursor(arrayOf("a"), mock())
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/*
2+
* Copyright 2026 Bloomberg Finance L.P.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.bloomberg.selekt.jdbc.benchmarks;
18+
19+
import com.bloomberg.selekt.jdbc.driver.SelektDriver;
20+
import java.io.File;
21+
import java.io.IOException;
22+
import java.nio.file.Files;
23+
import java.sql.Connection;
24+
import java.sql.Driver;
25+
import java.sql.PreparedStatement;
26+
import java.sql.ResultSet;
27+
import java.sql.SQLException;
28+
import java.sql.Statement;
29+
import java.util.Properties;
30+
import java.util.concurrent.TimeUnit;
31+
import org.openjdk.jmh.annotations.Benchmark;
32+
import org.openjdk.jmh.annotations.BenchmarkMode;
33+
import org.openjdk.jmh.annotations.Fork;
34+
import org.openjdk.jmh.annotations.Level;
35+
import org.openjdk.jmh.annotations.Measurement;
36+
import org.openjdk.jmh.annotations.Mode;
37+
import org.openjdk.jmh.annotations.OutputTimeUnit;
38+
import org.openjdk.jmh.annotations.Param;
39+
import org.openjdk.jmh.annotations.Scope;
40+
import org.openjdk.jmh.annotations.Setup;
41+
import org.openjdk.jmh.annotations.State;
42+
import org.openjdk.jmh.annotations.TearDown;
43+
import org.openjdk.jmh.annotations.Warmup;
44+
import org.openjdk.jmh.infra.Blackhole;
45+
46+
@BenchmarkMode(Mode.AverageTime)
47+
@OutputTimeUnit(TimeUnit.MICROSECONDS)
48+
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
49+
@Measurement(iterations = 7, time = 1, timeUnit = TimeUnit.SECONDS)
50+
@Fork(1)
51+
@State(Scope.Thread)
52+
public class JdbcColumnLabelBenchmark {
53+
private static final int ROW_COUNT = 1_000;
54+
private static final Driver DRIVER = new SelektDriver();
55+
56+
@Param({"4", "16", "64", "256"})
57+
int columnCount;
58+
59+
private File databaseFile;
60+
private Connection connection;
61+
private PreparedStatement query;
62+
private String[] labels;
63+
64+
@Setup(Level.Trial)
65+
public void setUp() throws IOException, SQLException {
66+
databaseFile = Files.createTempFile("selekt-column-label-bench", ".db").toFile();
67+
databaseFile.deleteOnExit();
68+
connection = DRIVER.connect("jdbc:sqlite:" + databaseFile.getAbsolutePath(), new Properties());
69+
try (Statement statement = connection.createStatement()) {
70+
statement.execute("CREATE TABLE bench (value INTEGER NOT NULL)");
71+
}
72+
connection.setAutoCommit(false);
73+
try (PreparedStatement insert = connection.prepareStatement("INSERT INTO bench VALUES (?)")) {
74+
for (int row = 0; row < ROW_COUNT; ++row) {
75+
insert.setInt(1, row);
76+
insert.addBatch();
77+
}
78+
insert.executeBatch();
79+
connection.commit();
80+
} finally {
81+
connection.setAutoCommit(true);
82+
}
83+
84+
labels = new String[columnCount];
85+
final StringBuilder sql = new StringBuilder("SELECT ");
86+
for (int column = 0; column < columnCount; ++column) {
87+
if (column != 0) {
88+
sql.append(',');
89+
}
90+
labels[column] = "column_" + column;
91+
sql.append("value AS ").append(labels[column]);
92+
}
93+
query = connection.prepareStatement(sql.append(" FROM bench").toString());
94+
}
95+
96+
@TearDown(Level.Trial)
97+
public void tearDown() throws SQLException {
98+
if (query != null) {
99+
query.close();
100+
}
101+
if (connection != null) {
102+
connection.close();
103+
}
104+
if (databaseFile != null) {
105+
databaseFile.delete();
106+
new File(databaseFile.getPath() + "-journal").delete();
107+
new File(databaseFile.getPath() + "-wal").delete();
108+
new File(databaseFile.getPath() + "-shm").delete();
109+
}
110+
}
111+
112+
@Benchmark
113+
public void byIndex(final Blackhole blackhole) throws SQLException {
114+
try (ResultSet resultSet = query.executeQuery()) {
115+
while (resultSet.next()) {
116+
for (int column = 1; column <= columnCount; ++column) {
117+
blackhole.consume(resultSet.getInt(column));
118+
}
119+
}
120+
}
121+
}
122+
123+
@Benchmark
124+
public void byLabel(final Blackhole blackhole) throws SQLException {
125+
try (ResultSet resultSet = query.executeQuery()) {
126+
while (resultSet.next()) {
127+
for (int column = 0; column < columnCount; ++column) {
128+
blackhole.consume(resultSet.getInt(labels[column]));
129+
}
130+
}
131+
}
132+
}
133+
}

0 commit comments

Comments
 (0)