From ab2f919faa6a8c3ed7c96114c86f225ce5c1a874 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Mon, 10 Aug 2026 14:05:47 +0200 Subject: [PATCH] fix!: stop static caches from pinning classes, class loaders and data sources Type-keyed caches move to ClassValue so cached reflection artifacts, metamodels, models and compiled plans share the lifetime of the class they describe. Provider and instantiator registries hold their class loader through a weak identity key and their per-loader value through a soft reference, breaking the value-to-key cycle that pinned redeployed applications. The database product name cache holds data sources weakly. The class-order cache is removed: provider lists are sorted once at load, and the Orderable.sort overloads with the cache flag are removed with it. Fixes #394 --- .../impl/DefaultORMReflectionImpl.java | 87 +++++++----- .../st/orm/core/spi/ClassLoaderCache.java | 118 ++++++++++++++++ .../java/st/orm/core/spi/Instantiators.java | 12 +- .../main/java/st/orm/core/spi/Orderable.java | 26 ---- .../java/st/orm/core/spi/OrderableHelper.java | 56 ++------ .../main/java/st/orm/core/spi/Providers.java | 110 ++++++++++----- .../core/template/impl/MetamodelFactory.java | 30 +++- .../orm/core/template/impl/ModelFactory.java | 17 ++- .../st/orm/core/template/impl/ModelImpl.java | 26 ++-- .../template/impl/ObjectMapperFactory.java | 17 ++- .../orm/core/template/impl/RecordMapper.java | 55 +++++--- .../core/template/impl/RecordReflection.java | 82 ++++++----- .../core/template/impl/RecordValidation.java | 20 ++- .../st/orm/core/spi/ClassLoaderCacheTest.java | 72 ++++++++++ .../spi/DatabaseProductNameCacheTest.java | 88 ++++++++++++ .../st/orm/core/spi/OrderableHelperTest.java | 22 +-- .../impl/StaticCacheUnloadingTest.java | 129 ++++++++++++++++++ .../core/template/impl/UnloadableUser.java | 26 ++++ 18 files changed, 752 insertions(+), 241 deletions(-) create mode 100644 storm-core/src/main/java/st/orm/core/spi/ClassLoaderCache.java create mode 100644 storm-core/src/test/java/st/orm/core/spi/ClassLoaderCacheTest.java create mode 100644 storm-core/src/test/java/st/orm/core/spi/DatabaseProductNameCacheTest.java create mode 100644 storm-core/src/test/java/st/orm/core/template/impl/StaticCacheUnloadingTest.java create mode 100644 storm-core/src/test/java/st/orm/core/template/impl/UnloadableUser.java 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 { +}