diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/change/loaded/CodeLoadedChangeBuilder.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/change/loaded/CodeLoadedChangeBuilder.java index 677ab904a..ef2dea64f 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/change/loaded/CodeLoadedChangeBuilder.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/change/loaded/CodeLoadedChangeBuilder.java @@ -30,8 +30,11 @@ import java.lang.reflect.Constructor; import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.LinkedHashSet; import java.util.List; import java.util.Optional; +import java.util.Set; public class CodeLoadedChangeBuilder implements LoadedChangeBuilder { @@ -229,13 +232,31 @@ private void setRecoveryFromClass(Class sourceClass) { } } + // Tried in order: the thread's context classloader is what frameworks like Spring Boot DevTools + // point at the "live" app classes (e.g. its RestartClassLoader), so it must win when present. + // This class's own loader is the pre-existing fallback, kept for environments (CLI, plain Java) + // where no context classloader is set. The system loader is a last resort. private Class getClassForName(String clazzName) { - try { - return Class.forName(clazzName); - } - catch (ClassNotFoundException e) { - throw new RuntimeException(e); + ClassNotFoundException lastException = null; + for (ClassLoader candidate : candidateClassLoaders()) { + if (candidate == null) { + continue; + } + try { + return Class.forName(clazzName, true, candidate); + } catch (ClassNotFoundException e) { + lastException = e; + } } + throw new RuntimeException(lastException); + } + + private Set candidateClassLoaders() { + return new LinkedHashSet<>(Arrays.asList( + Thread.currentThread().getContextClassLoader(), + getClass().getClassLoader(), + ClassLoader.getSystemClassLoader() + )); } private Constructor getConstructorFromPreview(CodePreviewChange preview) { diff --git a/core/flamingock-core/src/main/java/io/flamingock/internal/core/context/SimpleContext.java b/core/flamingock-core/src/main/java/io/flamingock/internal/core/context/SimpleContext.java index 7bd0fb2d3..9436e8f52 100644 --- a/core/flamingock-core/src/main/java/io/flamingock/internal/core/context/SimpleContext.java +++ b/core/flamingock-core/src/main/java/io/flamingock/internal/core/context/SimpleContext.java @@ -18,6 +18,8 @@ import io.flamingock.internal.common.core.context.Context; import io.flamingock.internal.common.core.context.Dependency; import io.flamingock.internal.util.Property; +import io.flamingock.internal.util.log.FlamingockLoggerFactory; +import org.slf4j.Logger; import java.io.File; import java.net.InetAddress; @@ -46,6 +48,8 @@ public class SimpleContext extends AbstractSimpleContextResolver implements Context { + private static final Logger logger = FlamingockLoggerFactory.getLogger("SimpleContext"); + private final Map dependenciesByName; private final Map, Dependency> dependenciesByExactType; @@ -64,9 +68,23 @@ protected Optional getByType(Class type) { Optional dependencyByExactClass = Optional.ofNullable(dependenciesByExactType.get(type)); if (dependencyByExactClass.isPresent()) { return dependencyByExactClass; - } else { - return getFirstAssignableDependency(type); } + Optional assignableDependency = getFirstAssignableDependency(type); + if (assignableDependency.isPresent()) { + return assignableDependency; + } + // Fallback for types with the same fully-qualified name loaded by different classloaders + // (e.g. Spring Boot DevTools' restart classloader), where Class identity/assignability + // checks above never match even though it's logically the same application type. + Optional sameNameDependency = getFirstSameNameDependency(type); + if (sameNameDependency.isPresent()) { + logger.warn("Dependency[{}] resolved by class name across a classloader boundary " + + "(requested type and registered type share the name but are different Class instances). " + + "This usually happens under a hot-reload classloader (e.g. Spring Boot DevTools). " + + "If this is unexpected, check for duplicate classes on the classpath.", + type.getName()); + } + return sameNameDependency; } private Optional getFirstAssignableDependency(Class type) { @@ -76,6 +94,13 @@ private Optional getFirstAssignableDependency(Class type) { .findFirst(); } + private Optional getFirstSameNameDependency(Class type) { + return dependenciesByExactType.entrySet().stream() + .filter(entry -> type.getName().equals(entry.getKey().getName())) + .map(Map.Entry::getValue) + .findFirst(); + } + @Override public void addDependency(Dependency dependency) { if (!dependency.isDefaultNamed()) { diff --git a/core/flamingock-core/src/test/java/io/flamingock/internal/core/context/ClassloaderMismatchReproTest.java b/core/flamingock-core/src/test/java/io/flamingock/internal/core/context/ClassloaderMismatchReproTest.java new file mode 100644 index 000000000..c7c22fde1 --- /dev/null +++ b/core/flamingock-core/src/test/java/io/flamingock/internal/core/context/ClassloaderMismatchReproTest.java @@ -0,0 +1,110 @@ +/* + * Copyright 2023 Flamingock (https://www.flamingock.io) + * + * 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 + * + * http://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 io.flamingock.internal.core.context; + +import io.flamingock.internal.common.core.context.Dependency; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.net.URL; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Reproduces https://github.com/flamingock/flamingock-java/issues/951 + *

+ * Simulates what Spring Boot DevTools' RestartClassLoader does: the same + * fully-qualified class is loaded twice by two different classloaders, + * producing two distinct {@code Class} instances with the same name. + * SimpleContext keys its dependency map by exact {@code Class} identity, + * so a dependency registered under one loader's Class instance is not found + * when looked up with the other loader's Class instance. + */ +class ClassloaderMismatchReproTest { + + private static final String TARGET_CLASS = "io.flamingock.internal.core.context.repro.MigrationConfiguration"; + + @Test + void dependencyRegisteredUnderOneClassloaderIsNotFoundUnderAnother() throws Exception { + ClassLoader appLoader = ClassloaderMismatchReproTest.class.getClassLoader(); + Class typeFromRegistration = loadIsolated(appLoader).loadClass(TARGET_CLASS); + Class typeFromLookup = loadIsolated(appLoader).loadClass(TARGET_CLASS); + + assertFalse(typeFromRegistration.equals(typeFromLookup), + "precondition: the two loaders must produce distinct Class instances"); + + SimpleContext context = new SimpleContext(); + Object instance = typeFromRegistration.getConstructor(String.class) + .newInstance("some-config"); + context.addDependency(new Dependency(typeFromRegistration, instance)); + + boolean found = context.getDependency(typeFromLookup).isPresent(); + + assertTrue(found, "dependency should be found by name across classloader boundaries"); + } + + private static IsolatedClassLoader loadIsolated(ClassLoader parent) { + return new IsolatedClassLoader(parent); + } + + /** Loads only classes under the repro package in isolation; delegates everything else to parent. */ + private static class IsolatedClassLoader extends ClassLoader { + private final ClassLoader resourceLoader; + + IsolatedClassLoader(ClassLoader resourceLoader) { + super(null); + this.resourceLoader = resourceLoader; + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (name.equals(TARGET_CLASS)) { + synchronized (getClassLoadingLock(name)) { + Class loaded = findLoadedClass(name); + if (loaded == null) { + String path = name.replace('.', '/') + ".class"; + try (InputStream is = resourceLoader.getResourceAsStream(path)) { + if (is == null) { + throw new ClassNotFoundException(name); + } + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int n; + while ((n = is.read(buf)) != -1) { + baos.write(buf, 0, n); + } + byte[] bytes = baos.toByteArray(); + loaded = defineClass(name, bytes, 0, bytes.length); + } catch (Exception e) { + throw new ClassNotFoundException(name, e); + } + } + if (resolve) { + resolveClass(loaded); + } + return loaded; + } + } + return Class.forName(name, resolve, resourceLoader); + } + + @Override + public URL getResource(String name) { + return resourceLoader.getResource(name); + } + } +} diff --git a/core/flamingock-core/src/test/java/io/flamingock/internal/core/context/repro/MigrationConfiguration.java b/core/flamingock-core/src/test/java/io/flamingock/internal/core/context/repro/MigrationConfiguration.java new file mode 100644 index 000000000..01838cd90 --- /dev/null +++ b/core/flamingock-core/src/test/java/io/flamingock/internal/core/context/repro/MigrationConfiguration.java @@ -0,0 +1,28 @@ +/* + * Copyright 2023 Flamingock (https://www.flamingock.io) + * + * 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 + * + * http://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 io.flamingock.internal.core.context.repro; + +public class MigrationConfiguration { + private final String configCollection; + + public MigrationConfiguration(String configCollection) { + this.configCollection = configCollection; + } + + public String getConfigCollection() { + return configCollection; + } +}