diff --git a/storm-core/src/main/java/st/orm/core/repository/impl/DefaultORMReflectionImpl.java b/storm-core/src/main/java/st/orm/core/repository/impl/DefaultORMReflectionImpl.java index 11731c6a4..5ab0dfaea 100644 --- a/storm-core/src/main/java/st/orm/core/repository/impl/DefaultORMReflectionImpl.java +++ b/storm-core/src/main/java/st/orm/core/repository/impl/DefaultORMReflectionImpl.java @@ -30,9 +30,9 @@ import java.lang.reflect.Method; import java.lang.reflect.RecordComponent; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import st.orm.Data; import st.orm.PK; import st.orm.PersistenceException; @@ -42,17 +42,24 @@ import st.orm.mapping.RecordType; public final class DefaultORMReflectionImpl implements ORMReflection { - private static final Map, Optional> TYPE_CACHE = new ConcurrentHashMap<>(); - private static final Map, Optional> PK_FIELD_CACHE = new ConcurrentHashMap<>(); - private static final Map, Optional>> CONSTRUCTOR_CACHE = new ConcurrentHashMap<>(); - private static final Map ACCESSOR_CACHE = new ConcurrentHashMap<>(); private interface Accessor { Object get(Object receiver) throws Throwable; } + /** + * Accessors per declaring class, keyed by method. {@link ClassValue} ties each entry to the lifetime of the + * declaring class, so cached reflection artifacts never outlive the class or its class loader. + */ + private static final ClassValue> ACCESSOR_CACHE = new ClassValue<>() { + @Override + protected ConcurrentMap computeValue(@Nonnull Class type) { + return new ConcurrentHashMap<>(); + } + }; + private static Accessor accessorFor(Method m) { - return ACCESSOR_CACHE.computeIfAbsent(m, method -> { + return ACCESSOR_CACHE.get(m.getDeclaringClass()).computeIfAbsent(m, method -> { try { Class owner = method.getDeclaringClass(); MethodType mt = MethodType.methodType(method.getReturnType(), method.getParameterTypes()); @@ -73,13 +80,21 @@ private static Accessor accessorFor(Method m) { }); } + /** Primary key field per record class; empty when the record declares no {@link PK} field. */ + private static final ClassValue> PK_FIELD_CACHE = new ClassValue<>() { + @Override + protected Optional computeValue(@Nonnull Class type) { + return TYPE_CACHE.get(type) + .orElseThrow(() -> new PersistenceException("Record type expected: %s.".formatted(type.getName()))) + .fields().stream() + .filter(field -> field.isAnnotationPresent(PK.class)) + .findFirst(); + } + }; + @Override public Object getId(@Nonnull Data data) { - return PK_FIELD_CACHE.computeIfAbsent(data.getClass(), ignore -> - getRecordType(data.getClass()).fields().stream() - .filter(field -> field.isAnnotationPresent(PK.class)) - .findFirst() - ) + return PK_FIELD_CACHE.get(data.getClass()) .map(field -> invoke(field, data)) .orElseThrow(() -> new PersistenceException("No PK found for %s.".formatted(data.getClass().getName()))); } @@ -89,13 +104,14 @@ public Object getRecordValue(@Nonnull Object record, int index) { return invoke(getRecordType(record.getClass()).fields().get(index), record); } - @Override - public Optional findRecordType(@Nonnull Class type) { - return TYPE_CACHE.computeIfAbsent(type, ignore -> { + /** Record type descriptor per class; empty when the class is not a record. */ + private static final ClassValue> TYPE_CACHE = new ClassValue<>() { + @Override + protected Optional computeValue(@Nonnull Class type) { if (!type.isRecord()) { return empty(); } - return findCanonicalConstructor(type) + return CONSTRUCTOR_CACHE.get(type) .map(constructor -> new RecordType( type, constructor, @@ -115,11 +131,18 @@ public Optional findRecordType(@Nonnull Class type) { .toList() ) ); - }); + } + }; + + @Override + public Optional findRecordType(@Nonnull Class type) { + return TYPE_CACHE.get(type); } - private Optional> findCanonicalConstructor(@Nonnull Class type) { - return CONSTRUCTOR_CACHE.computeIfAbsent(type, ignore -> { + /** Canonical constructor per record class; empty when no constructor matches the record components. */ + private static final ClassValue>> CONSTRUCTOR_CACHE = new ClassValue<>() { + @Override + protected Optional> computeValue(@Nonnull Class type) { RecordComponent[] components = type.getRecordComponents(); Constructor[] constructors = type.getDeclaredConstructors(); for (Constructor constructor : constructors) { @@ -140,8 +163,8 @@ private Optional> findCanonicalConstructor(@Nonnull Class type } } return empty(); - }); - } + } + }; @Override public Class getType(@Nonnull Object o) { @@ -198,24 +221,26 @@ private boolean isPrimitiveDefaultValue(Object o) { return false; } - private static final ConcurrentHashMap, List> RECORD_COMPONENT_CACHE - = new ConcurrentHashMap<>(); + /** Record components per record class, cached to avoid repeated expensive reflection lookups. */ + private static final ClassValue> RECORD_COMPONENT_CACHE = new ClassValue<>() { + @Override + protected List computeValue(@Nonnull Class recordType) { + if (!recordType.isRecord()) { + throw new IllegalArgumentException("The specified class %s is not a record type.".formatted(recordType.getName())); + } + return List.of(recordType.getRecordComponents()); + } + }; /** - * Returns the record components for the specified record type. The result is cached to avoid repeated expensive - * reflection lookups. + * Returns the record components for the specified record type. * * @param recordType the record type to obtain the record components for. * @return the record components for the specified record type. * @throws IllegalArgumentException if the record type is not a record. */ private static List getRecordComponents(@Nonnull Class recordType) { - return RECORD_COMPONENT_CACHE.computeIfAbsent(recordType, ignore -> { - if (!recordType.isRecord()) { - throw new IllegalArgumentException("The specified class %s is not a record type.".formatted(recordType.getName())); - } - return List.of(recordType.getRecordComponents()); - }); + return RECORD_COMPONENT_CACHE.get(recordType); } private boolean areRecordComponentsDefault(Object record) { @@ -239,7 +264,7 @@ public boolean isSupportedType(@Nonnull Object clazz) { return clazz instanceof Class; } - private boolean isNonnull(@Nonnull RecordComponent component) { + private static boolean isNonnull(@Nonnull RecordComponent component) { return component.isAnnotationPresent(PK.class) || component.getType().isPrimitive() || Nullability.isNonNull(component, component.getAnnotatedType(), null, component.getDeclaringRecord()); diff --git a/storm-core/src/main/java/st/orm/core/spi/ClassLoaderCache.java b/storm-core/src/main/java/st/orm/core/spi/ClassLoaderCache.java new file mode 100644 index 000000000..fef1c0fb1 --- /dev/null +++ b/storm-core/src/main/java/st/orm/core/spi/ClassLoaderCache.java @@ -0,0 +1,118 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * 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 st.orm.core.spi; + +import static java.lang.System.identityHashCode; +import static java.util.Objects.requireNonNull; + +import jakarta.annotation.Nonnull; +import java.lang.ref.Reference; +import java.lang.ref.ReferenceQueue; +import java.lang.ref.SoftReference; +import java.lang.ref.WeakReference; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +/** + * A cache scoped to a class loader, holding the loader weakly and the value softly. + * + *

Values computed for a class loader inherently reference that loader: service instances and loaded classes keep + * their defining loader reachable. A cache that held such a value strongly would pin the loader for the lifetime of + * the JVM, which leaks every redeployed application in a container that discards class loaders. Holding the value + * softly breaks the cycle: once a loader is otherwise unreachable, the cache entry is the only path to it, and the + * collector clears the soft reference before memory runs out, after which the loader and its classes are reclaimed. + * Entries of live loaders survive until memory pressure clears them, in which case the value is recomputed on the + * next access.

+ * + *

Loaders are compared by identity. Stale keys are drained from a reference queue on each access.

+ * + * @param the type of the cached value; must not be {@code null}. + */ +final class ClassLoaderCache { + + /** A weak reference to a class loader with identity-based equality, usable as a map key. */ + private static final class LoaderKey extends WeakReference { + private final int hash; + + /** Creates a lookup key that is not registered with a reference queue. */ + LoaderKey(@Nonnull ClassLoader loader) { + super(loader); + this.hash = identityHashCode(loader); + } + + /** Creates a key for insertion, registered with the queue for cleanup when the loader is collected. */ + LoaderKey(@Nonnull ClassLoader loader, @Nonnull ReferenceQueue queue) { + super(loader, queue); + this.hash = identityHashCode(loader); + } + + @Override + public int hashCode() { + return hash; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + // Required to locate this key after its referent is cleared, so a stale entry can be removed. + return true; + } + return other instanceof LoaderKey key && get() != null && get() == key.get(); + } + } + + private final ReferenceQueue queue = new ReferenceQueue<>(); + private final Map> map = new ConcurrentHashMap<>(); + + /** + * Returns the value for the given class loader, computing and caching it if absent or already reclaimed. + * + *

The compute function may run while holding an internal lock for the loader's entry; concurrent lookups of + * the same loader wait for the computation, matching {@link ConcurrentHashMap#computeIfAbsent} semantics.

+ * + * @param loader the class loader to scope the value to. + * @param compute the function that computes the value; must not return {@code null}. + * @return the cached or computed value. + */ + V computeIfAbsent(@Nonnull ClassLoader loader, @Nonnull Function compute) { + drainQueue(); + var reference = map.get(new LoaderKey(loader)); + V value = reference == null ? null : reference.get(); + while (value == null) { + var updated = map.compute(new LoaderKey(loader, queue), (key, existing) -> + existing != null && existing.get() != null + ? existing + : new SoftReference<>(requireNonNull(compute.apply(loader), + "Compute function must not return null."))); + value = updated.get(); + } + return value; + } + + /** + * Removes stale entries whose class loader has been collected. Only the exact enqueued key matches its map + * entry, so a live entry for another loader is never removed. + */ + private void drainQueue() { + Reference stale; + while ((stale = queue.poll()) != null) { + if (stale instanceof LoaderKey key) { + map.remove(key); + } + } + } +} diff --git a/storm-core/src/main/java/st/orm/core/spi/Instantiators.java b/storm-core/src/main/java/st/orm/core/spi/Instantiators.java index 117548171..a9cb7a353 100644 --- a/storm-core/src/main/java/st/orm/core/spi/Instantiators.java +++ b/storm-core/src/main/java/st/orm/core/spi/Instantiators.java @@ -24,8 +24,6 @@ import java.util.HashMap; import java.util.Map; import java.util.ServiceLoader; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; import st.orm.mapping.Instantiator; /** @@ -38,9 +36,13 @@ */ public final class Instantiators { - /** Instantiators per class loader, keyed by the record type they construct. */ - private static final ConcurrentMap, Instantiator>> INSTANTIATOR_CACHE = - new ConcurrentHashMap<>(); + /** + * Instantiators per class loader, keyed by the record type they construct. The registered instantiators and + * record types keep their class loader reachable, so the registry is scoped to the loader's lifetime via + * {@link ClassLoaderCache} rather than pinned for the lifetime of the JVM. + */ + private static final ClassLoaderCache, Instantiator>> INSTANTIATOR_CACHE = + new ClassLoaderCache<>(); private Instantiators() { } diff --git a/storm-core/src/main/java/st/orm/core/spi/Orderable.java b/storm-core/src/main/java/st/orm/core/spi/Orderable.java index 0823531f2..43fde2168 100644 --- a/storm-core/src/main/java/st/orm/core/spi/Orderable.java +++ b/storm-core/src/main/java/st/orm/core/spi/Orderable.java @@ -88,19 +88,6 @@ static > List sort(@Nonnull List orderables) { return OrderableHelper.sort(orderables); } - /** - * Sorts the provided {@code Orderable} instances based on the ordering constraints defined by the orderable - * constraints. - * - * @param the type parameter for the {@code Orderable} instances. - * @param orderables the orderable instances to sort. - * @param cache use caching for improved performance. - * @return the sorted orderable instances. - */ - static > List sort(@Nonnull List orderables, boolean cache) { - return OrderableHelper.sort(orderables, cache); - } - /** * Sorts the provided {@code Orderable} instances based on the ordering constraints defined by the orderable * constraints. @@ -112,17 +99,4 @@ static > List sort(@Nonnull List orderables, boolea static > Stream sort(@Nonnull Stream orderableStream) { return OrderableHelper.sort(orderableStream); } - - /** - * Sorts the provided {@code Orderable} instances based on the ordering constraints defined by the orderable - * constraints. - * - * @param the type parameter for the {@code Orderable} instances. - * @param cache use caching for improved performance. - * @param orderableStream the orderable instances to sort. - * @return the sorted orderable instances. - */ - static > Stream sort(@Nonnull Stream orderableStream, boolean cache) { - return OrderableHelper.sort(orderableStream, cache); - } } diff --git a/storm-core/src/main/java/st/orm/core/spi/OrderableHelper.java b/storm-core/src/main/java/st/orm/core/spi/OrderableHelper.java index 5f613ffd0..a28f1439a 100644 --- a/storm-core/src/main/java/st/orm/core/spi/OrderableHelper.java +++ b/storm-core/src/main/java/st/orm/core/spi/OrderableHelper.java @@ -15,6 +15,7 @@ */ package st.orm.core.spi; +import static java.util.Comparator.comparingInt; import static java.util.stream.Collectors.counting; import static java.util.stream.Collectors.groupingBy; import static java.util.stream.Collectors.joining; @@ -22,7 +23,6 @@ import jakarta.annotation.Nonnull; import java.util.ArrayList; -import java.util.Comparator; import java.util.Deque; import java.util.HashMap; import java.util.HashSet; @@ -31,7 +31,6 @@ import java.util.List; import java.util.Map; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Stream; import st.orm.core.spi.Orderable.After; import st.orm.core.spi.Orderable.AfterAny; @@ -40,12 +39,13 @@ /** * Helper class for sorting objects that implement the {@link Orderable} interface. + * + *

The topological sort runs on each call. Inputs are small provider sets that callers sort once per load, so a + * global order cache would only keep provider classes, and their class loaders, reachable without a measurable + * win.

*/ final class OrderableHelper { - /** Cache for storing the order of classes. */ - private static final Map>, List>> CLASS_ORDER_CACHE = new ConcurrentHashMap<>(); - /** * Sorts a stream of orderables. * @@ -54,19 +54,7 @@ final class OrderableHelper { * @return Sorted stream of orderables. */ static > Stream sort(@Nonnull Stream orderables) { - return sort(orderables, true); // Always cache. - } - - /** - * Sorts a stream of orderables with an option to cache. - * - * @param orderables Stream of orderables to be sorted. - * @param cache Whether to cache the class order. - * @param Type of orderable. - * @return Sorted stream of orderables. - */ - static > Stream sort(@Nonnull Stream orderables, boolean cache) { - return sort(orderables.collect(toList()), cache).stream(); + return sort(orderables.toList()).stream(); } /** @@ -77,43 +65,17 @@ static > Stream sort(@Nonnull Stream orderables, bo * @return Sorted list of orderables. */ static > List sort(@Nonnull List orderables) { - return sort(orderables, true); // Always cache. - } - - /** - * Sorts a list of orderables with an option to cache. - * - * @param orderables List of orderables to be sorted. - * @param cache Whether to cache the class order. - * @param Type of orderable. - * @return Sorted list of orderables. - */ - static > List sort(@Nonnull List orderables, boolean cache) { if (orderables.size() <= 1) { return orderables; // A list of zero or one elements is already sorted; skip the graph and topological sort. } - List> classOrder = getClassOrder(orderables.stream() + List> classOrder = topologicalSort(buildClassDependencyGraph(orderables.stream() .map(Object::getClass) - .collect(toList()), cache); + .collect(toList()))); return orderables.stream() - .sorted(Comparator.comparingInt(o -> classOrder.indexOf(o.getClass()))) + .sorted(comparingInt(o -> classOrder.indexOf(o.getClass()))) .collect(toList()); } - /** - * Retrieves the order of classes, with an option to cache. - * - * @param classes List of classes to determine the order. - * @param cache Whether to cache the class order. - * @return Ordered list of classes. - */ - private static List> getClassOrder(@Nonnull List> classes, boolean cache) { - if (cache) { - return CLASS_ORDER_CACHE.computeIfAbsent(classes, cls -> topologicalSort(buildClassDependencyGraph(cls))); - } - return topologicalSort(buildClassDependencyGraph(classes)); - } - /** * Builds a dependency graph for the given classes. * diff --git a/storm-core/src/main/java/st/orm/core/spi/Providers.java b/storm-core/src/main/java/st/orm/core/spi/Providers.java index 10a1728da..578e6cb2c 100644 --- a/storm-core/src/main/java/st/orm/core/spi/Providers.java +++ b/storm-core/src/main/java/st/orm/core/spi/Providers.java @@ -15,6 +15,7 @@ */ package st.orm.core.spi; +import static java.lang.System.identityHashCode; import static java.lang.Thread.currentThread; import static java.util.Arrays.asList; import static java.util.Objects.requireNonNullElseGet; @@ -27,11 +28,13 @@ import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; +import java.lang.ref.Reference; +import java.lang.ref.ReferenceQueue; +import java.lang.ref.WeakReference; import java.sql.Connection; import java.sql.SQLException; import java.util.Collections; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.ServiceLoader; import java.util.concurrent.ConcurrentHashMap; @@ -72,7 +75,12 @@ public final class Providers { private static final Supplier> TRANSACTION_TEMPLATE_PROVIDERS = createProviders(TransactionTemplateProvider.class); private static final Supplier> EXTERNAL_TRANSACTION_PROVIDERS = createProviders(ExternalTransactionProvider.class); - private static final ConcurrentMap> PROVIDER_CACHE = new ConcurrentHashMap<>(); + /** + * Provider instances per class loader, keyed by provider class. The loaded instances keep their class loader + * reachable, so the per-loader map is scoped to the loader's lifetime via {@link ClassLoaderCache} rather than + * pinned for the lifetime of the JVM. + */ + private static final ClassLoaderCache, List>> PROVIDER_CACHE = new ClassLoaderCache<>(); /** * Returns a supplier that caches the provider instances responsible for providing the actual service @@ -87,9 +95,10 @@ private static Supplier> createProviders(Class p return () -> { ClassLoader contextClassLoader = currentThread().getContextClassLoader(); ClassLoader providersClassloader = Providers.class.getClassLoader(); - Object key = asList(providerClass, ofNullable(contextClassLoader).orElse(providersClassloader)); + ClassLoader loader = ofNullable(contextClassLoader).orElse(providersClassloader); + var providersByClass = PROVIDER_CACHE.computeIfAbsent(loader, ignore -> new ConcurrentHashMap<>()); // Prefetch all providers to prevent race conditions in case of parallel execution. - return (List) PROVIDER_CACHE.computeIfAbsent(key, ignore -> { + return (List) providersByClass.computeIfAbsent(providerClass, ignore -> { if (contextClassLoader != null) { // Try the context class loader first. List list = toUnmodifiableList(load(providerClass, contextClassLoader)); @@ -104,7 +113,9 @@ private static Supplier> createProviders(Class p } /** - * Returns a list of all services that are loaded by the specified {@code loader}. + * Returns a list of all services that are loaded by the specified {@code loader}, sorted by their + * {@link Orderable} constraints. Sorting once at load time keeps every resolution in provider order: a filtered + * subset of a valid topological order is itself a valid topological order. * *

Note that {@link Provider#isEnabled()} is deliberately not evaluated here: the returned list is cached for * the lifetime of the class loader, whereas enablement may depend on runtime state. Enablement is re-evaluated @@ -112,19 +123,19 @@ private static Supplier> createProviders(Class p * * @param loader loader of services. * @param service type. - * @return a list of all services loaded by the specified {@code loader}. + * @return a list of all services loaded by the specified {@code loader}, in provider order. */ private static List toUnmodifiableList(@Nonnull ServiceLoader loader) { return stream(loader.spliterator(), false) - .collect(collectingAndThen(toList(), Collections::unmodifiableList)); + .collect(collectingAndThen(toList(), list -> Collections.unmodifiableList(Orderable.sort(list)))); } /** - * Returns a stream of the currently enabled providers from the given cached provider list. + * Returns a stream of the currently enabled providers from the given cached provider list, in provider order. * * @param providers the cached provider list supplier. * @param provider type. - * @return a stream of enabled providers. + * @return a stream of enabled providers, in provider order. */ private static Stream enabled(@Nonnull Supplier> providers) { return providers.get().stream().filter(Provider::isEnabled); @@ -133,25 +144,26 @@ private static Stream enabled(@Nonnull Supplier> private static final AtomicReference ORM_REFLECTION = new AtomicReference<>(); /** - * Represents a key for a record field. + * Resolved converters per declaring record class, keyed by field name. {@link ClassValue} ties each entry to + * the lifetime of the declaring class, so cached converters never pin the class or its class loader. */ - record FieldKey(Class declaringType, String name) { - FieldKey(RecordField field) { - this(field.declaringType(), field.name()); + private static final ClassValue>> ORM_CONVERTERS = new ClassValue<>() { + @Override + protected ConcurrentMap> computeValue(@Nonnull Class type) { + return new ConcurrentHashMap<>(); } - } - private static final Map> ORM_CONVERTERS = new ConcurrentHashMap<>(); + }; public static ORMReflection getORMReflection() { - return ORM_REFLECTION.updateAndGet(value -> requireNonNullElseGet(value, () -> Orderable.sort(enabled(ORM_REFLECTION_PROVIDERS)) + return ORM_REFLECTION.updateAndGet(value -> requireNonNullElseGet(value, () -> enabled(ORM_REFLECTION_PROVIDERS) .map(ORMReflectionProvider::getReflection) .findFirst() .orElseThrow())); } public static Optional getORMConverter(@Nonnull RecordField field) { - return ORM_CONVERTERS.computeIfAbsent(new FieldKey(field), ignore -> - Orderable.sort(enabled(ORM_CONVERTER_PROVIDERS)) + return ORM_CONVERTERS.get(field.declaringType()).computeIfAbsent(field.name(), ignore -> + enabled(ORM_CONVERTER_PROVIDERS) .map(p -> p.getConverter(field)) .filter(Optional::isPresent) .map(Optional::get) @@ -162,7 +174,7 @@ public static > EntityRepository getEntityReposi @Nonnull ORMTemplate ormTemplate, @Nonnull Model model, @Nonnull Predicate filter) { - return Orderable.sort(enabled(ENTITY_REPOSITORY_PROVIDERS)) + return enabled(ENTITY_REPOSITORY_PROVIDERS) .filter(filter) .map(provider -> provider.getEntityRepository(ormTemplate, model)) .findFirst() @@ -173,7 +185,7 @@ public static > ProjectionRepository getProj @Nonnull ORMTemplate ormTemplate, @Nonnull Model model, @Nonnull Predicate filter) { - return Orderable.sort(enabled(PROJECTION_REPOSITORY_PROVIDERS)) + return enabled(PROJECTION_REPOSITORY_PROVIDERS) .filter(filter) .map(provider -> provider.getProjectionRepository(ormTemplate, model)) .findFirst() @@ -188,7 +200,7 @@ public static > ProjectionRepository getProj */ private static QueryBuilderProvider queryBuilderProvider() { return QUERY_BUILDER_PROVIDER.updateAndGet(value -> requireNonNullElseGet(value, () -> - Orderable.sort(enabled(QUERY_BUILDER_REPOSITORY_PROVIDERS)) + enabled(QUERY_BUILDER_REPOSITORY_PROVIDERS) .findFirst() .orElseThrow())); } @@ -224,7 +236,7 @@ public static SqlDialect getSqlDialect() { } public static SqlDialect getSqlDialect(@Nonnull StormConfig config) { - return Orderable.sort(enabled(SQL_DIALECT_PROVIDERS)) + return enabled(SQL_DIALECT_PROVIDERS) .map(p -> p.getSqlDialect(config)) .findFirst() .orElseThrow(); @@ -236,14 +248,44 @@ public static SqlDialect getSqlDialect(@Nonnull Predicate filter, @Nonnull StormConfig config) { - return Orderable.sort(enabled(SQL_DIALECT_PROVIDERS)) + return enabled(SQL_DIALECT_PROVIDERS) .filter(filter) .map(p -> p.getSqlDialect(config)) .findFirst() .orElseThrow(); } - private static final ConcurrentMap DATABASE_PRODUCT_NAMES = new ConcurrentHashMap<>(); + /** A weak reference to a data source with identity-based equality, usable as a map key. */ + private static final class DataSourceIdentity extends WeakReference { + private final int hash; + + DataSourceIdentity(@Nonnull DataSource dataSource, @Nonnull ReferenceQueue queue) { + super(dataSource, queue); + this.hash = identityHashCode(dataSource); + } + + @Override + public int hashCode() { + return hash; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + // Required to locate this key after its referent is cleared, so a stale entry can be removed. + return true; + } + return other instanceof DataSourceIdentity identity && get() != null && get() == identity.get(); + } + } + + private static final ReferenceQueue DATA_SOURCE_QUEUE = new ReferenceQueue<>(); + + /** + * Database product names per data source. The data source is held weakly so the cache never pins it, or the + * connection pool behind it, once the application discards it; stale entries are drained on each access. + */ + private static final ConcurrentMap DATABASE_PRODUCT_NAMES = new ConcurrentHashMap<>(); /** * Returns the database product name for the given data source, caching the result per data source identity. @@ -253,8 +295,14 @@ public static SqlDialect getSqlDialect(@Nonnull Predicate { - try (Connection connection = ds.getConnection()) { + Reference stale; + while ((stale = DATA_SOURCE_QUEUE.poll()) != null) { + if (stale instanceof DataSourceIdentity identity) { + DATABASE_PRODUCT_NAMES.remove(identity); + } + } + return DATABASE_PRODUCT_NAMES.computeIfAbsent(new DataSourceIdentity(dataSource, DATA_SOURCE_QUEUE), ignore -> { + try (Connection connection = dataSource.getConnection()) { return connection.getMetaData().getDatabaseProductName(); } catch (SQLException e) { throw new PersistenceException("Failed to determine database product name.", e); @@ -286,7 +334,7 @@ public static String getDatabaseProductName(@Nonnull Connection connection) { * @since 1.11 */ public static @Nullable SqlDialectProvider getSqlDialectProvider(@Nonnull String databaseProductName) { - return Orderable.sort(enabled(SQL_DIALECT_PROVIDERS)) + return enabled(SQL_DIALECT_PROVIDERS) .filter(p -> p.supports(databaseProductName)) .findFirst() .orElse(null); @@ -303,7 +351,7 @@ public static String getDatabaseProductName(@Nonnull Connection connection) { */ public static SqlDialect getSqlDialect(@Nonnull DataSource dataSource, @Nonnull StormConfig config) { String productName = getDatabaseProductName(dataSource); - return Orderable.sort(enabled(SQL_DIALECT_PROVIDERS)) + return enabled(SQL_DIALECT_PROVIDERS) .filter(p -> p.supports(productName)) .map(p -> p.getSqlDialect(config)) .findFirst() @@ -321,7 +369,7 @@ public static SqlDialect getSqlDialect(@Nonnull DataSource dataSource, @Nonnull */ public static SqlDialect getSqlDialect(@Nonnull Connection connection, @Nonnull StormConfig config) { String productName = getDatabaseProductName(connection); - return Orderable.sort(enabled(SQL_DIALECT_PROVIDERS)) + return enabled(SQL_DIALECT_PROVIDERS) .filter(p -> p.supports(productName)) .map(p -> p.getSqlDialect(config)) .findFirst() @@ -371,7 +419,7 @@ public static TransactionTemplateProvider getTransactionTemplateProvider() { * @since 1.13 */ public static List getExternalTransactionProviders() { - return Orderable.sort(enabled(EXTERNAL_TRANSACTION_PROVIDERS)).toList(); + return enabled(EXTERNAL_TRANSACTION_PROVIDERS).toList(); } /** @@ -383,7 +431,7 @@ public static List getExternalTransactionProviders( private static S selectUnique(@Nonnull Supplier> providers, @Nonnull String description, @Nonnull String remedy) { - var sorted = Orderable.sort(enabled(providers)).toList(); + var sorted = enabled(providers).toList(); if (sorted.isEmpty()) { throw new PersistenceException("No %s found on the classpath.".formatted(description)); } diff --git a/storm-core/src/main/java/st/orm/core/template/impl/MetamodelFactory.java b/storm-core/src/main/java/st/orm/core/template/impl/MetamodelFactory.java index 290950d67..cd015212c 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/MetamodelFactory.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/MetamodelFactory.java @@ -31,9 +31,9 @@ import java.lang.reflect.Method; import java.util.ArrayList; import java.util.List; -import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import st.orm.AbstractKeyMetamodel; import st.orm.AbstractMetamodel; import st.orm.Data; @@ -57,16 +57,32 @@ private MetamodelFactory() { // Prevent instantiation. } - private record CacheKey(@Nonnull Class table, @Nullable String path) { } - private static final Map, Metamodel> ROOT_METAMODEL_CACHE = new ConcurrentHashMap<>(); - private static final Map> METAMODEL_CACHE = new ConcurrentHashMap<>(); + /** + * Root metamodels per record type. {@link ClassValue} ties each entry to the lifetime of the record type, so + * cached metamodels never pin the type or its class loader. + */ + private static final ClassValue> ROOT_METAMODEL_CACHE = new ClassValue<>() { + @Override + @SuppressWarnings("unchecked") + protected Metamodel computeValue(@Nonnull Class table) { + return getRootModel((Class) table); + } + }; + + /** Metamodels per root table, keyed by path; entries die with the root table. */ + private static final ClassValue>> METAMODEL_CACHE = new ClassValue<>() { + @Override + protected ConcurrentMap> computeValue(@Nonnull Class table) { + return new ConcurrentHashMap<>(); + } + }; /** * Creates a new metamodel for the given record type. */ public static Metamodel root(@Nonnull Class table) { //noinspection unchecked - return (Metamodel) ROOT_METAMODEL_CACHE.computeIfAbsent(table, ignore -> getRootModel(table)); + return (Metamodel) ROOT_METAMODEL_CACHE.get(table); } /** @@ -112,8 +128,8 @@ public boolean isSame(@Nonnull T a, @Nullable T b) { */ public static Metamodel of(@Nonnull Class rootTable, @Nonnull String path) { //noinspection unchecked - return (Metamodel) METAMODEL_CACHE.computeIfAbsent( - new CacheKey(rootTable, path), ignore -> getModel(rootTable, path)); + return (Metamodel) METAMODEL_CACHE.get(rootTable) + .computeIfAbsent(path, ignore -> getModel(rootTable, path)); } /** diff --git a/storm-core/src/main/java/st/orm/core/template/impl/ModelFactory.java b/storm-core/src/main/java/st/orm/core/template/impl/ModelFactory.java index b05f6e62d..5eef211d1 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/ModelFactory.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/ModelFactory.java @@ -41,6 +41,7 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicInteger; import st.orm.Data; import st.orm.DbColumn; @@ -67,12 +68,16 @@ final class ModelFactory { /** - * The cached models, keyed by the record type together with the references the statement resolves. The plan - * changes the column list, so a model built for one plan cannot serve another. + * The cached models per record type, keyed by the references the statement resolves. The plan changes the + * column list, so a model built for one plan cannot serve another. {@link ClassValue} ties the models to the + * lifetime of the record type, so they never pin the type or its class loader. */ - private record ModelKey(@Nonnull Class type, @Nonnull FetchPlan fetchPlan) {} - - private static final ConcurrentHashMap> MODEL_CACHE = new ConcurrentHashMap<>(); + private static final ClassValue>> MODEL_CACHE = new ClassValue<>() { + @Override + protected ConcurrentMap> computeValue(@Nonnull Class type) { + return new ConcurrentHashMap<>(); + } + }; private ModelFactory() { } @@ -85,7 +90,7 @@ static Model getModel(@Nonnull ModelBuilderImpl buil try { validateDataType(type, requirePrimaryKey); //noinspection unchecked - return (Model) MODEL_CACHE.computeIfAbsent(new ModelKey(type, fetchPlan), ignore -> { + return (Model) MODEL_CACHE.get(type).computeIfAbsent(fetchPlan, ignore -> { try { return createModel(builder, type, requirePrimaryKey, fetchPlan); } catch (SqlTemplateException e) { diff --git a/storm-core/src/main/java/st/orm/core/template/impl/ModelImpl.java b/storm-core/src/main/java/st/orm/core/template/impl/ModelImpl.java index 1e3b5aa1e..9ab582c15 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/ModelImpl.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/ModelImpl.java @@ -49,8 +49,6 @@ import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; import java.util.function.BiConsumer; import st.orm.Data; import st.orm.DbEnum; @@ -94,8 +92,20 @@ public final class ModelImpl implements Model { /** * Caches field-name to component-index maps per concrete sealed subtype. Since the set of permitted subtypes is * small and fixed, this avoids rebuilding a HashMap on every row during sealed entity value extraction. + * {@link ClassValue} ties each entry to the lifetime of the subtype, so the cache never pins its class loader. */ - private static final ConcurrentMap, Map> FIELD_INDEX_CACHE = new ConcurrentHashMap<>(); + private static final ClassValue> FIELD_INDEX_CACHE = new ClassValue<>() { + @Override + protected Map computeValue(@Nonnull Class type) { + RecordType concreteRecordType = REFLECTION.getRecordType(type); + var concreteFields = concreteRecordType.fields(); + Map map = HashMap.newHashMap(concreteFields.size()); + for (int i = 0; i < concreteFields.size(); i++) { + map.put(concreteFields.get(i).name(), i); + } + return Map.copyOf(map); + } + }; private final RecordType recordType; private final Class typeOverride; @@ -751,15 +761,7 @@ private void forEachSealedEntityValue(@Nonnull List view, assert typeOverride != null && typeOverride.isSealed(); Class concreteType = record.getClass(); // Look up (or compute once) the field-name -> component-index map for the concrete type. - Map fieldIndexMap = FIELD_INDEX_CACHE.computeIfAbsent(concreteType, type -> { - RecordType concreteRecordType = REFLECTION.getRecordType(type); - var concreteFields = concreteRecordType.fields(); - Map map = HashMap.newHashMap(concreteFields.size()); - for (int i = 0; i < concreteFields.size(); i++) { - map.put(concreteFields.get(i).name(), i); - } - return Map.copyOf(map); - }); + Map fieldIndexMap = FIELD_INDEX_CACHE.get(concreteType); for (var column : view) { int index = column.index(); if (index == discriminatorColumnIndex) { diff --git a/storm-core/src/main/java/st/orm/core/template/impl/ObjectMapperFactory.java b/storm-core/src/main/java/st/orm/core/template/impl/ObjectMapperFactory.java index f533f3474..19736d2bf 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/ObjectMapperFactory.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/ObjectMapperFactory.java @@ -30,9 +30,9 @@ import java.lang.reflect.RecordComponent; import java.util.BitSet; import java.util.Collection; -import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.function.Supplier; import st.orm.Data; import st.orm.PK; @@ -214,7 +214,8 @@ static T construct(@Nonnull Constructor constructor, @Nonnull Object[] ar try { // Constructor metadata is precomputed and cached: per-invocation getParameterTypes/getParameters calls // clone their arrays, which is measurable on the row mapping hot path. - ConstructorMeta meta = CONSTRUCTOR_META.computeIfAbsent(constructor, ObjectMapperFactory::constructorMeta); + ConstructorMeta meta = CONSTRUCTOR_META.get(constructor.getDeclaringClass()) + .computeIfAbsent(constructor, ObjectMapperFactory::constructorMeta); boolean[] nonNull = meta.nonNull(); boolean[] primitive = meta.primitive(); for (int i = 0; i < nonNull.length; i++) { @@ -265,8 +266,16 @@ private record ConstructorMeta(@Nonnull Constructor constructor, @Nonnull boolean[] primitive, @Nullable Instantiator instantiator) {} - /** Cache of precomputed constructor metadata, keyed by constructor. Thread-safe for concurrent access. */ - private static final Map, ConstructorMeta> CONSTRUCTOR_META = new ConcurrentHashMap<>(); + /** + * Precomputed constructor metadata per declaring class, keyed by constructor. {@link ClassValue} ties each + * entry to the lifetime of the declaring class, so cached constructors never pin the class or its class loader. + */ + private static final ClassValue, ConstructorMeta>> CONSTRUCTOR_META = new ClassValue<>() { + @Override + protected ConcurrentMap, ConstructorMeta> computeValue(@Nonnull Class type) { + return new ConcurrentHashMap<>(); + } + }; private static ConstructorMeta constructorMeta(@Nonnull Constructor constructor) { Class[] parameterTypes = constructor.getParameterTypes(); 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 293803fb7..5cfae37cf 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 @@ -48,6 +48,7 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Pattern; import st.orm.Data; import st.orm.DbEnum; @@ -249,26 +250,35 @@ private record SubtypeInfo( int[] extensionColumnIndices // Subtype column indices that are extension-specific (not common) ) {} - /** Cache of sealed entity compiled plans, keyed by sealed interface class. */ - private static final ConcurrentMap, SealedCompiled> SEALED_COMPILED = new ConcurrentHashMap<>(); + /** + * Sealed entity compiled plans, held per sealed interface class. {@link ClassValue} ties each plan to the + * lifetime of the sealed class, so cached plans never pin the class or its class loader. The holder starts + * empty because the plan is compiled with a {@link RefFactory}, which is not available to + * {@link ClassValue#computeValue}. + */ + private static final ClassValue> SEALED_COMPILED = new ClassValue<>() { + @Override + protected AtomicReference computeValue(@Nonnull Class type) { + return new AtomicReference<>(); + } + }; /** * Returns the compiled sealed entity information, creating and caching it if necessary. */ private static SealedCompiled sealedCompiledFor(@Nonnull Class sealedType, @Nonnull RefFactory refFactory) throws SqlTemplateException { - try { - return SEALED_COMPILED.computeIfAbsent(sealedType, t -> { - try { - return compileSealedPlan(t, refFactory); - } catch (SqlTemplateException e) { - throw new RuntimeException(e); - } - }); - } catch (RuntimeException e) { - if (e.getCause() instanceof SqlTemplateException ste) throw ste; - throw e; + AtomicReference holder = SEALED_COMPILED.get(sealedType); + SealedCompiled compiled = holder.get(); + if (compiled == null) { + // Concurrent first calls may compile the plan more than once; the first published plan wins and the + // compilation is a pure function of the sealed type, so every candidate is equivalent. + compiled = compileSealedPlan(sealedType, refFactory); + if (!holder.compareAndSet(null, compiled)) { + compiled = holder.get(); + } } + return compiled; } /** @@ -377,14 +387,17 @@ private record Compiled(@Nonnull ArgumentPlan plan, @Nonnull List skipRegions) {} /** - * The key of a compiled plan: the record type together with the references the statement resolves. A resolved - * reference consumes the referenced table's columns rather than its foreign key column alone, so a plan compiled - * for one set of resolved references cannot read a row shaped by another. + * Compiled plans per record class, keyed by the references the statement resolves. A resolved reference + * consumes the referenced table's columns rather than its foreign key column alone, so a plan compiled for one + * set of resolved references cannot read a row shaped by another. {@link ClassValue} ties the plans to the + * lifetime of the record class, so they never pin the class or its class loader. */ - private record CompiledKey(@Nonnull Class type, @Nonnull FetchPlan fetchPlan) {} - - /** Global cache of compiled plans, keyed by record class and fetch plan. Thread-safe for concurrent access. */ - private static final ConcurrentMap COMPILED = new ConcurrentHashMap<>(); + private static final ClassValue> COMPILED = new ClassValue<>() { + @Override + protected ConcurrentMap computeValue(@Nonnull Class type) { + return new ConcurrentHashMap<>(); + } + }; /** * Returns the compiled plan for the given record type, creating and caching it if necessary. @@ -399,7 +412,7 @@ private static Compiled compiledFor(@Nonnull RecordType type, @Nonnull RefFactory refFactory, @Nonnull FetchPlan fetchPlan) throws SqlTemplateException { try { - return COMPILED.computeIfAbsent(new CompiledKey(type.type(), fetchPlan), t -> { + return COMPILED.get(type.type()).computeIfAbsent(fetchPlan, t -> { try { PkInfo pkInfo = Entity.class.isAssignableFrom(type.type()) ? calculatePkInfo(type, fetchPlan) diff --git a/storm-core/src/main/java/st/orm/core/template/impl/RecordReflection.java b/storm-core/src/main/java/st/orm/core/template/impl/RecordReflection.java index 43d9e5942..3441d0a7f 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/RecordReflection.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/RecordReflection.java @@ -33,6 +33,7 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.stream.Stream; import st.orm.Data; import st.orm.DbColumn; @@ -430,19 +431,20 @@ static List findRecordFieldsByTable(@Nonnull List fiel } /** - * Represents a key for a record field. + * Ref primary key types per declaring record class, keyed by field name. {@link ClassValue} ties each entry to + * the lifetime of the declaring class, so cached types never pin the class or its class loader. */ - record FieldKey(Class declaringType, String name) { - FieldKey(RecordField field) { - this(field.declaringType(), field.name()); + private static final ClassValue>> REF_PK_TYPE_CACHE = new ClassValue<>() { + @Override + protected ConcurrentMap> computeValue(@Nonnull Class type) { + return new ConcurrentHashMap<>(); } - } - private static final java.util.Map> REF_PK_TYPE_CACHE = new ConcurrentHashMap<>(); + }; @SuppressWarnings("unchecked") static Class getRefPkType(@Nonnull RecordField field) throws SqlTemplateException { try { - return REF_PK_TYPE_CACHE.computeIfAbsent(new FieldKey(field), ignore -> { + return REF_PK_TYPE_CACHE.get(field.declaringType()).computeIfAbsent(field.name(), ignore -> { try { var type = field.genericType(); if (type instanceof ParameterizedType parameterizedType) { @@ -476,12 +478,18 @@ static Class getRefPkType(@Nonnull RecordField field) throws SqlTemplateExcep } } - private static final Map> REF_RECORD_TYPE_CACHE = new ConcurrentHashMap<>(); + /** Ref data types per declaring record class, keyed by field name; entries die with the declaring class. */ + private static final ClassValue>> REF_RECORD_TYPE_CACHE = new ClassValue<>() { + @Override + protected ConcurrentMap> computeValue(@Nonnull Class type) { + return new ConcurrentHashMap<>(); + } + }; @SuppressWarnings("unchecked") static Class getRefDataType(@Nonnull RecordField field) throws SqlTemplateException { try { - return REF_RECORD_TYPE_CACHE.computeIfAbsent(new FieldKey(field), ignore -> { + return REF_RECORD_TYPE_CACHE.get(field.declaringType()).computeIfAbsent(field.name(), ignore -> { try { Class recordType = null; var type = field.genericType(); @@ -801,18 +809,11 @@ enum SealedPattern { } /** - * Cache for sealed pattern detection results. - */ - private static final Map, Optional> SEALED_PATTERN_CACHE = new ConcurrentHashMap<>(); - - /** - * Detects the polymorphic pattern for the given sealed type, if any. - * - * @param type the type to inspect. - * @return an Optional containing the detected SealedPattern, or empty if the type is not a sealed hierarchy. + * Sealed pattern detection results per type; entries die with the type. */ - static Optional detectSealedPattern(@Nonnull Class type) { - return SEALED_PATTERN_CACHE.computeIfAbsent(type, t -> { + private static final ClassValue> SEALED_PATTERN_CACHE = new ClassValue<>() { + @Override + protected Optional computeValue(@Nonnull Class t) { if (!t.isSealed()) { return Optional.empty(); } @@ -847,7 +848,17 @@ static Optional detectSealedPattern(@Nonnull Class type) { } } return Optional.empty(); - }); + } + }; + + /** + * Detects the polymorphic pattern for the given sealed type, if any. + * + * @param type the type to inspect. + * @return an Optional containing the detected SealedPattern, or empty if the type is not a sealed hierarchy. + */ + static Optional detectSealedPattern(@Nonnull Class type) { + return SEALED_PATTERN_CACHE.get(type); } /** @@ -1052,9 +1063,22 @@ private static Object convertDiscriminatorValue(@Nonnull String rawValue, @Nonnu } /** - * Cache for discriminator value to concrete type mappings. + * Discriminator value to concrete type mappings per sealed type; entries die with the sealed type. */ - private static final Map, Map>> DISCRIMINATOR_MAP_CACHE = new ConcurrentHashMap<>(); + private static final ClassValue>> DISCRIMINATOR_MAP_CACHE = new ClassValue<>() { + @Override + protected Map> computeValue(@Nonnull Class t) { + Map> m = new ConcurrentHashMap<>(); + Class[] permitted = t.getPermittedSubclasses(); + if (permitted != null) { + for (Class sub : permitted) { + Object value = getDiscriminatorValue(sub, t); + m.put(value, sub); + } + } + return m; + } + }; /** * Resolves a discriminator value to a concrete subtype for the given sealed type. @@ -1066,17 +1090,7 @@ private static Object convertDiscriminatorValue(@Nonnull String rawValue, @Nonnu */ static Class resolveConcreteType(@Nonnull Class sealedType, @Nonnull Object discriminatorValue) throws SqlTemplateException { - Map> map = DISCRIMINATOR_MAP_CACHE.computeIfAbsent(sealedType, t -> { - Map> m = new ConcurrentHashMap<>(); - Class[] permitted = t.getPermittedSubclasses(); - if (permitted != null) { - for (Class sub : permitted) { - Object value = getDiscriminatorValue(sub, t); - m.put(value, sub); - } - } - return m; - }); + Map> map = DISCRIMINATOR_MAP_CACHE.get(sealedType); Class resolved = map.get(discriminatorValue); if (resolved == null) { throw new SqlTemplateException("Unknown discriminator value '%s' for sealed type %s. Known values: %s." diff --git a/storm-core/src/main/java/st/orm/core/template/impl/RecordValidation.java b/storm-core/src/main/java/st/orm/core/template/impl/RecordValidation.java index e5b81aab8..bf19b73c6 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/RecordValidation.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/RecordValidation.java @@ -37,6 +37,7 @@ import java.util.TreeSet; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicReference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -74,8 +75,17 @@ final class RecordValidation { private RecordValidation() { } - record TypeValidationKey(@Nonnull Class type, boolean requirePrimaryKey) {} - private static final Map VALIDATE_RECORD_TYPE_CACHE = new ConcurrentHashMap<>(); + /** + * Validation messages per data type, keyed by the require-primary-key flag; an empty message marks a valid type. + * {@link ClassValue} ties each entry to the lifetime of the validated type, so the cache never pins the type or + * its class loader. + */ + private static final ClassValue> VALIDATE_RECORD_TYPE_CACHE = new ClassValue<>() { + @Override + protected ConcurrentMap computeValue(@Nonnull Class type) { + return new ConcurrentHashMap<>(); + } + }; private static volatile boolean validationCompleted = false; @@ -333,8 +343,8 @@ static void validateDataType(@Nonnull Class dataType, boolean re if (!Data.class.isAssignableFrom(dataType)) { throw new IllegalArgumentException("Not a data type: %s".formatted(dataType.getSimpleName())); } - String message = VALIDATE_RECORD_TYPE_CACHE.computeIfAbsent( - new TypeValidationKey(dataType, requirePrimaryKey), + String message = VALIDATE_RECORD_TYPE_CACHE.get(dataType).computeIfAbsent( + requirePrimaryKey, ignore -> doValidateDataType(dataType, requirePrimaryKey)); if (!message.isEmpty()) { throw new SqlTemplateException(message); @@ -374,8 +384,6 @@ private static String doValidateDataType(@Nonnull Class dataType return validate(dataType, requirePrimaryKey, new HashSet<>()); } - record GraphValidationKey(@Nonnull Class dataType) {} - /** * Validates that the provided record type does not contain cyclic dependencies. Specifically, it ensures that no * record type appears multiple times along any path from the specified {@code recordType}. diff --git a/storm-core/src/test/java/st/orm/core/spi/ClassLoaderCacheTest.java b/storm-core/src/test/java/st/orm/core/spi/ClassLoaderCacheTest.java new file mode 100644 index 000000000..b2494a822 --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/spi/ClassLoaderCacheTest.java @@ -0,0 +1,72 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * 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 st.orm.core.spi; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +import java.lang.ref.WeakReference; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class ClassLoaderCacheTest { + + @Test + void computeIfAbsentReturnsCachedValueForSameLoader() { + var cache = new ClassLoaderCache(); + var computeCount = new AtomicInteger(); + ClassLoader loader = new URLClassLoader(new URL[0]); + assertEquals("value-1", cache.computeIfAbsent(loader, ignore -> "value-" + computeCount.incrementAndGet())); + assertEquals("value-1", cache.computeIfAbsent(loader, ignore -> "value-" + computeCount.incrementAndGet())); + assertEquals(1, computeCount.get()); + } + + @Test + void computeIfAbsentDistinguishesLoadersByIdentity() { + var cache = new ClassLoaderCache(); + ClassLoader firstLoader = new URLClassLoader(new URL[0]); + ClassLoader secondLoader = new URLClassLoader(new URL[0]); + assertEquals("first", cache.computeIfAbsent(firstLoader, ignore -> "first")); + assertEquals("second", cache.computeIfAbsent(secondLoader, ignore -> "second")); + } + + @Test + void cacheDoesNotPinCollectedLoader() throws Exception { + var cache = new ClassLoaderCache(); + ClassLoader loader = new URLClassLoader(new URL[0]); + cache.computeIfAbsent(loader, ignore -> "value"); + var reference = new WeakReference<>(loader); + //noinspection UnusedAssignment + loader = null; + awaitCleared(reference); + // A later access drains the stale entry and the cache remains usable. + ClassLoader otherLoader = new URLClassLoader(new URL[0]); + assertEquals("other", cache.computeIfAbsent(otherLoader, ignore -> "other")); + } + + private static void awaitCleared(WeakReference reference) throws InterruptedException { + for (int attempt = 0; attempt < 200; attempt++) { + if (reference.get() == null) { + return; + } + System.gc(); + Thread.sleep(10); + } + fail("Referent was not collected; the cache still pins it."); + } +} diff --git a/storm-core/src/test/java/st/orm/core/spi/DatabaseProductNameCacheTest.java b/storm-core/src/test/java/st/orm/core/spi/DatabaseProductNameCacheTest.java new file mode 100644 index 000000000..22afe91fe --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/spi/DatabaseProductNameCacheTest.java @@ -0,0 +1,88 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * 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 st.orm.core.spi; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +import java.lang.ref.WeakReference; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.util.concurrent.atomic.AtomicInteger; +import javax.sql.DataSource; +import org.junit.jupiter.api.Test; + +class DatabaseProductNameCacheTest { + + @Test + void cachesProductNamePerDataSourceIdentity() { + var connectionCount = new AtomicInteger(); + DataSource dataSource = dataSource(connectionCount, "H2"); + assertEquals("H2", Providers.getDatabaseProductName(dataSource)); + assertEquals("H2", Providers.getDatabaseProductName(dataSource)); + assertEquals(1, connectionCount.get()); + } + + @Test + void cacheDoesNotPinCollectedDataSource() throws Exception { + DataSource dataSource = dataSource(new AtomicInteger(), "H2"); + assertEquals("H2", Providers.getDatabaseProductName(dataSource)); + var reference = new WeakReference<>(dataSource); + //noinspection UnusedAssignment + dataSource = null; + awaitCleared(reference); + } + + private static DataSource dataSource(AtomicInteger connectionCount, String productName) { + ClassLoader loader = DatabaseProductNameCacheTest.class.getClassLoader(); + InvocationHandler metaDataHandler = (proxy, method, args) -> { + if (method.getName().equals("getDatabaseProductName")) { + return productName; + } + throw new UnsupportedOperationException(method.getName()); + }; + DatabaseMetaData metaData = (DatabaseMetaData) Proxy.newProxyInstance( + loader, new Class[] {DatabaseMetaData.class}, metaDataHandler); + InvocationHandler connectionHandler = (proxy, method, args) -> switch (method.getName()) { + case "getMetaData" -> metaData; + case "close" -> null; + default -> throw new UnsupportedOperationException(method.getName()); + }; + Connection connection = (Connection) Proxy.newProxyInstance( + loader, new Class[] {Connection.class}, connectionHandler); + InvocationHandler dataSourceHandler = (proxy, method, args) -> { + if (method.getName().equals("getConnection") && (args == null || args.length == 0)) { + connectionCount.incrementAndGet(); + return connection; + } + throw new UnsupportedOperationException(method.getName()); + }; + return (DataSource) Proxy.newProxyInstance(loader, new Class[] {DataSource.class}, dataSourceHandler); + } + + private static void awaitCleared(WeakReference reference) throws InterruptedException { + for (int attempt = 0; attempt < 200; attempt++) { + if (reference.get() == null) { + return; + } + System.gc(); + Thread.sleep(10); + } + fail("Referent was not collected; the cache still pins it."); + } +} diff --git a/storm-core/src/test/java/st/orm/core/spi/OrderableHelperTest.java b/storm-core/src/test/java/st/orm/core/spi/OrderableHelperTest.java index 6cb1ac139..9b1ca1e04 100644 --- a/storm-core/src/test/java/st/orm/core/spi/OrderableHelperTest.java +++ b/storm-core/src/test/java/st/orm/core/spi/OrderableHelperTest.java @@ -68,7 +68,7 @@ public void testSortSingleElement() { public void testBeforeConstraint() { var beforeItem = new ABeforeB(); var orderableB = new OrderableB(); - List result = Orderable.sort(List.of(orderableB, beforeItem), false); + List result = Orderable.sort(List.of(orderableB, beforeItem)); int indexBefore = result.indexOf(beforeItem); int indexB = result.indexOf(orderableB); assertTrue(indexBefore < indexB, "ABeforeB should appear before OrderableB"); @@ -78,7 +78,7 @@ public void testBeforeConstraint() { public void testAfterConstraint() { var orderableA = new OrderableA(); var afterItem = new CAfterA(); - List result = Orderable.sort(List.of(afterItem, orderableA), false); + List result = Orderable.sort(List.of(afterItem, orderableA)); int indexA = result.indexOf(orderableA); int indexAfter = result.indexOf(afterItem); assertTrue(indexA < indexAfter, "OrderableA should appear before CAfterA"); @@ -89,7 +89,7 @@ public void testBeforeAnyConstraint() { var first = new FirstOrderable(); var orderableA = new OrderableA(); var orderableB = new OrderableB(); - List result = Orderable.sort(List.of(orderableA, orderableB, first), false); + List result = Orderable.sort(List.of(orderableA, orderableB, first)); assertEquals(first, result.getFirst(), "BeforeAny should place the item first"); } @@ -98,7 +98,7 @@ public void testAfterAnyConstraint() { var last = new LastOrderable(); var orderableA = new OrderableA(); var orderableB = new OrderableB(); - List result = Orderable.sort(List.of(last, orderableA, orderableB), false); + List result = Orderable.sort(List.of(last, orderableA, orderableB)); assertEquals(last, result.getLast(), "AfterAny should place the item last"); } @@ -107,7 +107,7 @@ public void testBeforeAnyAndAfterAnyTogether() { var first = new FirstOrderable(); var last = new LastOrderable(); var middle = new OrderableA(); - List result = Orderable.sort(List.of(last, middle, first), false); + List result = Orderable.sort(List.of(last, middle, first)); assertEquals(first, result.getFirst(), "BeforeAny item should be first"); assertEquals(last, result.getLast(), "AfterAny item should be last"); } @@ -117,7 +117,7 @@ public void testBothBeforeAndAfterConstraints() { var orderableA = new OrderableA(); var between = new BetweenAAndB(); var orderableB = new OrderableB(); - List result = Orderable.sort(List.of(orderableB, between, orderableA), false); + List result = Orderable.sort(List.of(orderableB, between, orderableA)); int indexA = result.indexOf(orderableA); int indexBetween = result.indexOf(between); int indexB = result.indexOf(orderableB); @@ -130,7 +130,7 @@ public void testCircularDependencyThrowsException() { var circularA = new CircularA(); var circularB = new CircularB(); assertThrows(IllegalStateException.class, - () -> Orderable.sort(List.of(circularA, circularB), false)); + () -> Orderable.sort(List.of(circularA, circularB))); } @Test @@ -138,7 +138,7 @@ public void testSortStream() { var first = new FirstOrderable(); var last = new LastOrderable(); var middle = new OrderableA(); - List result = Orderable.sort(Stream.of(last, middle, first), false).toList(); + List result = Orderable.sort(Stream.of(last, middle, first)).toList(); assertEquals(first, result.getFirst(), "BeforeAny item should be first in stream result"); assertEquals(last, result.getLast(), "AfterAny item should be last in stream result"); } @@ -164,7 +164,7 @@ public void testBeforeConstraintOnClassNotInList() { // When the @Before target is not in the list, the constraint should be ignored. var beforeItem = new ABeforeB(); var orderableA = new OrderableA(); - List result = Orderable.sort(List.of(beforeItem, orderableA), false); + List result = Orderable.sort(List.of(beforeItem, orderableA)); assertEquals(2, result.size()); } @@ -173,7 +173,7 @@ public void testAfterConstraintOnClassNotInList() { // When the @After target is not in the list, the constraint should be ignored. var afterItem = new CAfterA(); var orderableB = new OrderableB(); - List result = Orderable.sort(List.of(afterItem, orderableB), false); + List result = Orderable.sort(List.of(afterItem, orderableB)); assertEquals(2, result.size()); } @@ -182,7 +182,7 @@ public void testNoConstraintsPreservesRelativeOrder() { var orderableA = new OrderableA(); var orderableB = new OrderableB(); var orderableC = new OrderableC(); - List result = Orderable.sort(List.of(orderableA, orderableB, orderableC), false); + List result = Orderable.sort(List.of(orderableA, orderableB, orderableC)); assertEquals(3, result.size()); } } diff --git a/storm-core/src/test/java/st/orm/core/template/impl/StaticCacheUnloadingTest.java b/storm-core/src/test/java/st/orm/core/template/impl/StaticCacheUnloadingTest.java new file mode 100644 index 000000000..353f8fad0 --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/template/impl/StaticCacheUnloadingTest.java @@ -0,0 +1,129 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * 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 st.orm.core.template.impl; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.lang.ref.WeakReference; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import st.orm.Data; +import st.orm.core.spi.ORMReflection; +import st.orm.core.spi.Providers; + +/** + * Verifies that the static type-keyed caches do not pin an entity class or its class loader: after a class loaded + * through a discardable class loader has populated the reflection, metamodel and validation caches, dropping the + * loader must make the class collectable. + */ +class StaticCacheUnloadingTest { + + @Test + void entityClassUnloadsAfterPopulatingCaches() throws Exception { + WeakReference> reference = populateCaches(); + for (int attempt = 0; attempt < 200; attempt++) { + if (reference.get() == null) { + return; + } + System.gc(); + Thread.sleep(10); + } + fail("Entity class was not collected; a static cache still pins it."); + } + + /** + * Loads {@link UnloadableUser} a second time through a child-first loader, drives the loaded class through the + * cache-backed entry points, and returns a weak reference to it without retaining anything else. + */ + private WeakReference> populateCaches() throws Exception { + var loader = new ChildFirstClassLoader(UnloadableUser.class.getName(), getClass().getClassLoader()); + Class duplicate = Class.forName(UnloadableUser.class.getName(), true, loader); + assertNotSame(UnloadableUser.class, duplicate); + + // Reflection caches: record type, canonical constructor, primary key field and accessors. + ORMReflection reflection = Providers.getORMReflection(); + assertTrue(reflection.findRecordType(duplicate).isPresent()); + Object user = duplicate.getDeclaredConstructor(Integer.class, String.class).newInstance(1, "Alice"); + assertEquals(1, reflection.getId((Data) user)); + assertEquals("Alice", reflection.getRecordValue(user, 1)); + assertFalse(reflection.isDefaultValue(user)); + + @SuppressWarnings("unchecked") + var dataType = (Class) duplicate; + + // Metamodel caches: root metamodel and path-based metamodel. + assertNotNull(MetamodelFactory.root(dataType)); + assertNotNull(MetamodelFactory.of(dataType, "name")); + + // Validation and sealed pattern caches. + assertDoesNotThrow(() -> RecordValidation.validateDataType(dataType, true)); + assertEquals(Optional.empty(), RecordReflection.detectSealedPattern(duplicate)); + + return new WeakReference<>(duplicate); + } + + /** + * Defines the named class from its class file bytes instead of delegating to the parent, giving it a dedicated + * loader that can be discarded. All other classes resolve through the parent as usual. + */ + private static final class ChildFirstClassLoader extends ClassLoader { + private final String className; + + ChildFirstClassLoader(String className, ClassLoader parent) { + super(parent); + this.className = className; + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (!name.equals(className)) { + return super.loadClass(name, resolve); + } + synchronized (getClassLoadingLock(name)) { + Class loaded = findLoadedClass(name); + if (loaded == null) { + byte[] bytes = readClassBytes(name); + loaded = defineClass(name, bytes, 0, bytes.length); + } + if (resolve) { + resolveClass(loaded); + } + return loaded; + } + } + + private byte[] readClassBytes(String name) throws ClassNotFoundException { + String resource = name.replace('.', '/') + ".class"; + try (InputStream in = getParent().getResourceAsStream(resource)) { + if (in == null) { + throw new ClassNotFoundException(name); + } + return in.readAllBytes(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + } +} diff --git a/storm-core/src/test/java/st/orm/core/template/impl/UnloadableUser.java b/storm-core/src/test/java/st/orm/core/template/impl/UnloadableUser.java new file mode 100644 index 000000000..7596639b0 --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/template/impl/UnloadableUser.java @@ -0,0 +1,26 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * 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 st.orm.core.template.impl; + +import st.orm.Entity; +import st.orm.PK; + +/** + * Fixture for {@link StaticCacheUnloadingTest}: loaded a second time through a child-first class loader to verify + * that the static caches do not pin the class. + */ +public record UnloadableUser(@PK Integer id, String name) implements Entity { +}