From 096668a06c4470456ca141f243acb34fb4d6409c Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Thu, 16 Jul 2026 17:15:45 +0200 Subject: [PATCH] perf(core): cut per-row overhead on the result-mapping hot path Reduce work done while materializing result sets: - QueryImpl: resolve the JDBC column reader once per column per query (cached per target type via ClassValue) instead of re-running the target-type switch for every column of every row. Reference-returning getters skip the now-redundant wasNull() probe. - WeakInterner: key entities by concrete type then primary key in a two-level map, avoiding a Ref allocation per interned entity while keeping the same identity semantics. - RecordMapper: reuse the compiled plan's cached parameterTypes length instead of re-walking the record structure on every query, and short-circuit the dirty-tracking lookup for non-transactional reads. --- .../java/st/orm/core/spi/WeakInterner.java | 65 ++++--- .../st/orm/core/template/impl/QueryImpl.java | 183 +++++++++++------- .../orm/core/template/impl/RecordMapper.java | 34 ++-- 3 files changed, 177 insertions(+), 105 deletions(-) diff --git a/storm-core/src/main/java/st/orm/core/spi/WeakInterner.java b/storm-core/src/main/java/st/orm/core/spi/WeakInterner.java index 40a217c39..f839b07d4 100644 --- a/storm-core/src/main/java/st/orm/core/spi/WeakInterner.java +++ b/storm-core/src/main/java/st/orm/core/spi/WeakInterner.java @@ -24,22 +24,21 @@ import java.util.Map; import java.util.WeakHashMap; import st.orm.Entity; -import st.orm.Ref; /** * A weak interner that ensures canonical instances of objects while holding them weakly to permit garbage collection. * *

This class uses a dual-path interning strategy optimized for different object types:

* * *

The primary key-based lookup for entities avoids potentially expensive deep equality checks on complex entity - * objects, while maintaining correct identity semantics (same primary key = same canonical instance).

+ * objects, while maintaining correct identity semantics (same type and primary key = same canonical instance).

* *

This class is not thread-safe. A new instance is expected to be created for each result set processing call, * ensuring that interning is scoped to a single query execution.

@@ -52,8 +51,11 @@ public final class WeakInterner { /** Queue for tracking garbage-collected entities to enable lazy cleanup of {@link #entityMap}. */ private ReferenceQueue> queue; - /** Map for entities, using {@link Ref} (primary key) for efficient lookup. Keys are held strongly. */ - private Map, RefWeakReference> entityMap; + /** + * Map for entities, keyed by concrete type then primary key. The two-level structure lets the hot path look up + * and store entities using the primary key value already in hand. + */ + private Map, Map> entityMap; /** * Creates a new weak interner. @@ -68,8 +70,8 @@ public WeakInterner() { * Interns the given object, ensuring that only one canonical instance exists. If an equivalent object is already * present, returns the existing instance. Otherwise, adds the new object to the interner and returns it. * - *

For {@link Entity} instances, lookup is performed using the entity's primary key (via {@link Ref}) for - * efficiency. For all other objects, lookup is based on object equality.

+ *

For {@link Entity} instances, lookup is performed using the entity's type and primary key for efficiency. + * For all other objects, lookup is based on object equality.

* * @param object the object to intern. * @param the type of the object. @@ -101,8 +103,11 @@ public > E get(@Nonnull Class entityType, @Nonnull Object return null; } drainQueue(); - Ref ref = Ref.of(entityType, pk); - WeakReference> existing = entityMap.get(ref); + Map byPk = entityMap.get(entityType); + if (byPk == null) { + return null; + } + PkWeakReference existing = byPk.get(pk); if (existing != null) { Entity result = existing.get(); if (result != null) { @@ -114,7 +119,7 @@ public > E get(@Nonnull Class entityType, @Nonnull Object } /** - * Interns an entity using its primary key (via {@link Ref}) for efficient lookup. + * Interns an entity using its type and primary key for efficient lookup. * *

This avoids expensive deep equality checks on complex entity objects. The entity is stored with a weak * reference, and cleanup is handled via {@link #drainQueue()} when entities are garbage collected.

@@ -130,8 +135,10 @@ private > E internEntity(@Nonnull E entity) { } else { drainQueue(); } - Ref ref = Ref.of(entity); - WeakReference> existing = entityMap.get(ref); + Class type = entity.getClass(); + Object pk = entity.id(); + Map byPk = entityMap.computeIfAbsent(type, k -> new HashMap<>()); + PkWeakReference existing = byPk.get(pk); if (existing != null) { var result = existing.get(); if (result != null) { @@ -139,7 +146,7 @@ private > E internEntity(@Nonnull E entity) { return (E) result; } } - entityMap.put(ref, new RefWeakReference(ref, entity, queue)); + byPk.put(pk, new PkWeakReference(type, pk, entity, queue)); return entity; } @@ -177,29 +184,37 @@ private T internObject(@Nonnull T object) { /** * Removes stale entries from {@link #entityMap} by polling the reference queue. * - *

When an entity is garbage collected, its {@link RefWeakReference} is enqueued. This method polls the queue + *

When an entity is garbage collected, its {@link PkWeakReference} is enqueued. This method polls the queue * and removes the corresponding entries from the map. Uses a two-argument remove to ensure only the exact * weak reference is removed, preventing removal of a newer entry with the same key.

*/ private void drainQueue() { - RefWeakReference weakReference; - while ((weakReference = (RefWeakReference) queue.poll()) != null) { - entityMap.remove(weakReference.ref, weakReference); + PkWeakReference weakReference; + while ((weakReference = (PkWeakReference) queue.poll()) != null) { + Map byPk = entityMap.get(weakReference.type); + if (byPk != null) { + // Two-argument remove ensures only the exact stale reference is removed, never a newer entry that + // reused the same primary key. + byPk.remove(weakReference.pk, weakReference); + } } } /** - * A weak reference to an entity that retains the associated {@link Ref} for map cleanup. + * A weak reference to an entity that retains its type and primary key for map cleanup. * *

When the entity is garbage collected, this reference is enqueued in the {@link ReferenceQueue}, allowing - * {@link #drainQueue()} to remove the corresponding entry from {@link #entityMap} using the stored ref.

+ * {@link #drainQueue()} to remove the corresponding entry from {@link #entityMap} using the stored type and + * primary key.

*/ - private static final class RefWeakReference extends WeakReference> { - final Ref ref; + private static final class PkWeakReference extends WeakReference> { + final Class type; + final Object pk; - RefWeakReference(Ref ref, Entity referent, ReferenceQueue> q) { + PkWeakReference(Class type, Object pk, Entity referent, ReferenceQueue> q) { super(referent, q); - this.ref = ref; + this.type = type; + this.pk = pk; } } } diff --git a/storm-core/src/main/java/st/orm/core/template/impl/QueryImpl.java b/storm-core/src/main/java/st/orm/core/template/impl/QueryImpl.java index 9f977ec3d..a6cfb4451 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/QueryImpl.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/QueryImpl.java @@ -625,6 +625,12 @@ protected Spliterator rowSpliterator(@Nonnull ResultSet resultSet, int co } var columnSkipper = mapper.columnSkipper(); var calendarSupplier = lazy(() -> Calendar.getInstance(TimeZone.getTimeZone(ZoneOffset.UTC))); + // Resolve the per-column reader once per query rather than re-evaluating the target-type dispatch for every + // column of every row. The column types are fixed for the lifetime of the result set. + ColumnReader[] readers = new ColumnReader[columnCount]; + for (int i = 0; i < columnCount; i++) { + readers[i] = columnReaderFor(types[i]); + } return new Spliterators.AbstractSpliterator<>(Long.MAX_VALUE, Spliterator.ORDERED) { @Override public boolean tryAdvance(@Nonnull Consumer action) { @@ -636,10 +642,10 @@ public boolean tryAdvance(@Nonnull Consumer action) { if (columnSkipper != null) { // Skip decoding non-key columns of entities that are already cached. columnSkipper.readRow(args, index -> - readColumnValue(resultSet, index + 1, types[index], calendarSupplier)); + readers[index].read(resultSet, index + 1, calendarSupplier)); } else { for (int i = 0; i < columnCount; i++) { - args[i] = readColumnValue(resultSet, i + 1, types[i], calendarSupplier); + args[i] = readers[i].read(resultSet, i + 1, calendarSupplier); } } action.accept(mapper.newInstance(args)); @@ -653,75 +659,116 @@ public boolean tryAdvance(@Nonnull Consumer action) { }; } - private static Object readColumnValue( - @Nonnull ResultSet rs, - int columnIndex, - @Nonnull Class targetType, - @Nonnull Supplier calendarSupplier - ) throws SQLException { - Object value = switch (targetType) { - // Primitives & basics. - case Class c when c == Short.TYPE || c == Short.class -> rs.getShort(columnIndex); - case Class c when c == Integer.TYPE || c == Integer.class -> rs.getInt(columnIndex); - case Class c when c == Long.TYPE || c == Long.class -> rs.getLong(columnIndex); - case Class c when c == Float.TYPE || c == Float.class -> rs.getFloat(columnIndex); - case Class c when c == Double.TYPE || c == Double.class -> rs.getDouble(columnIndex); - case Class c when c == Byte.TYPE || c == Byte.class -> rs.getByte(columnIndex); - case Class c when c == Boolean.TYPE || c == Boolean.class -> rs.getBoolean(columnIndex); - case Class c when c == String.class -> rs.getString(columnIndex); - case Class c when c == BigDecimal.class -> rs.getBigDecimal(columnIndex); - case Class c when c == ByteBuffer.class -> { - byte[] bytes = rs.getBytes(columnIndex); - yield bytes != null ? ByteBuffer.wrap(bytes).asReadOnlyBuffer() : null; - } - case Class c when c == UUID.class -> { - Object obj = rs.getObject(columnIndex); - yield obj instanceof UUID u ? u : obj != null ? UUID.fromString(obj.toString()) : null; - } - case Class c when c.isEnum() -> rs.getString(columnIndex); // Enum handled by mapper. - case Class c when c == java.util.Date.class -> { - Timestamp ts = rs.getTimestamp(columnIndex, calendarSupplier.get()); - yield ts != null ? new java.util.Date(ts.getTime()) : null; - } - case Class c when c == Calendar.class -> { - Timestamp ts = rs.getTimestamp(columnIndex, calendarSupplier.get()); - if (ts == null) yield null; - Calendar out = (Calendar) calendarSupplier.get().clone(); + /** + * Reads a single column value from a result set, decoding it to the resolved target type. One reader is resolved + * per column (see {@link #columnReaderFor}) and reused for every row, so the target-type dispatch is not repeated + * for every column of every row. + */ + @FunctionalInterface + private interface ColumnReader { + Object read(@Nonnull ResultSet rs, int columnIndex, @Nonnull Supplier calendarSupplier) + throws SQLException; + } + + /** + * Cache of resolved column readers, keyed by target type. Readers are stateless (the calendar is supplied per + * call), so a single instance per type is shared across every query for the lifetime of the VM. The value is + * computed at most once per type and read on a fast, lock-free path. + */ + private static final ClassValue COLUMN_READERS = new ClassValue<>() { + @Override + protected ColumnReader computeValue(@Nonnull Class type) { + return buildColumnReader(type); + } + }; + + /** + * Resolves the column reader for the given target type, computing it at most once per type for the lifetime of + * the VM. + */ + private static ColumnReader columnReaderFor(@Nonnull Class targetType) { + return COLUMN_READERS.get(targetType); + } + + /** + * Builds the column reader for the given target type. Primitive-returning JDBC getters honour + * {@link ResultSet#wasNull()} so that a SQL NULL maps to {@code null} rather than the getter's zero value; + * reference-returning getters already yield {@code null} for SQL NULL, so no {@code wasNull} probe is needed. + */ + private static ColumnReader buildColumnReader(@Nonnull Class targetType) { + return switch (targetType) { + // Ordered most-common-first. Resolution is paid once per column per query, so this mainly trims the + // setup path, but keeping the frequent types (identifiers, names, flags) at the front is free. + case Class c when c == String.class -> (rs, i, cal) -> rs.getString(i); + case Class c when c == Integer.TYPE || c == Integer.class -> + (rs, i, cal) -> { int v = rs.getInt(i); return rs.wasNull() ? null : v; }; + case Class c when c == Long.TYPE || c == Long.class -> + (rs, i, cal) -> { long v = rs.getLong(i); return rs.wasNull() ? null : v; }; + case Class c when c == Boolean.TYPE || c == Boolean.class -> + (rs, i, cal) -> { boolean v = rs.getBoolean(i); return rs.wasNull() ? null : v; }; + case Class c when c == Double.TYPE || c == Double.class -> + (rs, i, cal) -> { double v = rs.getDouble(i); return rs.wasNull() ? null : v; }; + // Remaining numeric primitives. wasNull distinguishes a genuine zero from a SQL NULL. + case Class c when c == Short.TYPE || c == Short.class -> + (rs, i, cal) -> { short v = rs.getShort(i); return rs.wasNull() ? null : v; }; + case Class c when c == Float.TYPE || c == Float.class -> + (rs, i, cal) -> { float v = rs.getFloat(i); return rs.wasNull() ? null : v; }; + case Class c when c == Byte.TYPE || c == Byte.class -> + (rs, i, cal) -> { byte v = rs.getByte(i); return rs.wasNull() ? null : v; }; + case Class c when c == BigDecimal.class -> (rs, i, cal) -> rs.getBigDecimal(i); + case Class c when c == ByteBuffer.class -> (rs, i, cal) -> { + byte[] bytes = rs.getBytes(i); + return bytes != null ? ByteBuffer.wrap(bytes).asReadOnlyBuffer() : null; + }; + case Class c when c == UUID.class -> (rs, i, cal) -> { + Object obj = rs.getObject(i); + return obj instanceof UUID u ? u : obj != null ? UUID.fromString(obj.toString()) : null; + }; + case Class c when c.isEnum() -> (rs, i, cal) -> rs.getString(i); // Enum handled by mapper. + case Class c when c == java.util.Date.class -> (rs, i, cal) -> { + Timestamp ts = rs.getTimestamp(i, cal.get()); + return ts != null ? new java.util.Date(ts.getTime()) : null; + }; + case Class c when c == Calendar.class -> (rs, i, cal) -> { + Timestamp ts = rs.getTimestamp(i, cal.get()); + if (ts == null) return null; + Calendar out = (Calendar) cal.get().clone(); out.setTimeInMillis(ts.getTime()); - yield out; - } - case Class c when c == Timestamp.class -> rs.getTimestamp(columnIndex, calendarSupplier.get()); - case Class c when c == java.sql.Date.class -> rs.getDate(columnIndex); - case Class c when c == Time.class -> rs.getTime(columnIndex); + return out; + }; + case Class c when c == Timestamp.class -> (rs, i, cal) -> rs.getTimestamp(i, cal.get()); + case Class c when c == java.sql.Date.class -> (rs, i, cal) -> rs.getDate(i); + case Class c when c == Time.class -> (rs, i, cal) -> rs.getTime(i); // java.time using vendor-safe approach. - case Class c when c == LocalDateTime.class -> { - Timestamp ts = rs.getTimestamp(columnIndex); - yield ts != null ? ts.toLocalDateTime() : null; - } - case Class c when c == LocalDate.class -> { - java.sql.Date d = rs.getDate(columnIndex); - yield d != null ? d.toLocalDate() : null; - } - case Class c when c == LocalTime.class -> { - Time t = rs.getTime(columnIndex); - yield t != null ? t.toLocalTime() : null; - } - case Class c when c == Instant.class -> { - Timestamp ts = rs.getTimestamp(columnIndex, calendarSupplier.get()); - yield ts != null ? ts.toInstant() : null; - } - case Class c when c == OffsetDateTime.class -> { - Timestamp ts = rs.getTimestamp(columnIndex, calendarSupplier.get()); - yield ts != null ? OffsetDateTime.ofInstant(ts.toInstant(), ZoneOffset.UTC) : null; - } - case Class c when c == ZonedDateTime.class -> { - Timestamp ts = rs.getTimestamp(columnIndex, calendarSupplier.get()); - yield ts != null ? ZonedDateTime.ofInstant(ts.toInstant(), ZoneOffset.UTC) : null; - } - default -> rs.getObject(columnIndex); + case Class c when c == LocalDateTime.class -> (rs, i, cal) -> { + Timestamp ts = rs.getTimestamp(i); + return ts != null ? ts.toLocalDateTime() : null; + }; + case Class c when c == LocalDate.class -> (rs, i, cal) -> { + java.sql.Date d = rs.getDate(i); + return d != null ? d.toLocalDate() : null; + }; + case Class c when c == LocalTime.class -> (rs, i, cal) -> { + Time t = rs.getTime(i); + return t != null ? t.toLocalTime() : null; + }; + case Class c when c == Instant.class -> (rs, i, cal) -> { + Timestamp ts = rs.getTimestamp(i, cal.get()); + return ts != null ? ts.toInstant() : null; + }; + case Class c when c == OffsetDateTime.class -> (rs, i, cal) -> { + Timestamp ts = rs.getTimestamp(i, cal.get()); + return ts != null ? OffsetDateTime.ofInstant(ts.toInstant(), ZoneOffset.UTC) : null; + }; + case Class c when c == ZonedDateTime.class -> (rs, i, cal) -> { + Timestamp ts = rs.getTimestamp(i, cal.get()); + return ts != null ? ZonedDateTime.ofInstant(ts.toInstant(), ZoneOffset.UTC) : null; + }; + default -> (rs, i, cal) -> { + Object value = rs.getObject(i); + return rs.wasNull() ? null : value; + }; }; - if (rs.wasNull()) return null; - return value; } @Override diff --git a/storm-core/src/main/java/st/orm/core/template/impl/RecordMapper.java b/storm-core/src/main/java/st/orm/core/template/impl/RecordMapper.java index bd2527a05..0ced231c7 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/RecordMapper.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/RecordMapper.java @@ -137,7 +137,9 @@ static Optional> getFactory(int columnCount, @Nonnull RecordType type, @Nonnull RefFactory refFactory, @Nullable TransactionContext transactionContext) throws SqlTemplateException { - if (getParameterCount(type) == columnCount) { + // The compiled plan already holds the flat column count (its parameterTypes length), cached per type. + // Reuse it instead of re-walking the record structure on every query, as getParameterCount would. + if (compiledFor(type, refFactory).parameterTypes().length == columnCount) { return Optional.of(wrapConstructor(type, refFactory, transactionContext)); } return empty(); @@ -446,6 +448,17 @@ private static ObjectMapper wrapConstructor(@Nonnull RecordType type, return wrapConstructor(type, refFactory, transactionContext, new WeakInterner()); } + /** + * Resolves the transaction-scoped entity cache for the top-level entity type. Only called when the type is an + * entity within a transaction and caching is required. + */ + @SuppressWarnings("unchecked") + private static EntityCache, ?> resolveEntityCache(@Nonnull TransactionContext transactionContext, + @Nonnull RecordType type) { + return (EntityCache, ?>) transactionContext.entityCache( + (Class>) type.type(), CacheRetention.fromConfig(StormConfig.defaults())); + } + private static ObjectMapper wrapConstructor(@Nonnull RecordType type, @Nonnull RefFactory refFactory, @Nullable TransactionContext transactionContext, @@ -454,18 +467,15 @@ private static ObjectMapper wrapConstructor(@Nonnull RecordType type, boolean isEntity = Entity.class.isAssignableFrom(type.type()); // Determine cache read/write policy. // Cache read: return cached instances (identity preservation) - only at REPEATABLE_READ+ - // Cache write: store for dirty tracking OR for identity preservation + // Cache write: store for dirty tracking OR for identity preservation. + // Only entities in a transaction can be cached; the leading conditions short-circuit the dirty-tracking + // lookup so it is never computed on a non-transactional read. boolean cacheReadEnabled = transactionContext != null && transactionContext.isRepeatableRead(); - boolean dirtyTrackingEnabled = getUpdateMode(type, StormConfig.defaults()) != OFF; - boolean cacheWriteEnabled = cacheReadEnabled || dirtyTrackingEnabled; - EntityCache, ?> entityCache; - if (transactionContext != null && isEntity && cacheWriteEnabled) { - //noinspection unchecked - entityCache = (EntityCache, ?>) transactionContext.entityCache( - (Class>) type.type(), CacheRetention.fromConfig(StormConfig.defaults())); - } else { - entityCache = null; - } + EntityCache, ?> entityCache = + transactionContext != null && isEntity + && (cacheReadEnabled || getUpdateMode(type, StormConfig.defaults()) != OFF) + ? resolveEntityCache(transactionContext, type) + : null; PkInfo pkInfo = compiled.pkInfo(); ColumnSkipper columnSkipper = createColumnSkipper(type, compiled, cacheReadEnabled, entityCache, interner, transactionContext);