From 478c9f59444c57ce9c57a99fc5f31086dfaaec08 Mon Sep 17 00:00:00 2001 From: Marco Collovati Date: Mon, 4 Nov 2024 11:44:34 +0100 Subject: [PATCH] fix: fix inspection and injection of Vaadin scoped beans Vaadin scoped beans requires VaadinSession and UI thread locals to be set in order to lookup beans. This change tracks Vaadin scoped beans during serialization and makes sure that required thread locals are set during deserialization. The serialVersionUID added to TransientDescriptor should keep it compatible with older version, preventing deserialization of previous data to fail. Fixes #140 Requires vaadin/flow#20394 --- ...imisticSerializationRequiredException.java | 43 +++ .../sessiontracker/SessionSerializer.java | 8 +- .../serialization/SpringTransientHandler.java | 142 ++++++-- .../serialization/TransientAwareHolder.java | 78 ++++ .../serialization/TransientDescriptor.java | 37 +- .../TransientInjectableObjectInputStream.java | 4 +- .../sessiontracker/SessionSerializerTest.java | 37 +- .../SpringTransientHandlerTest.java | 33 +- ...pringTransientHandlerVaadinScopesTest.java | 24 +- .../TransientAwareHolderTest.java | 118 ++++++ ...ScopeSerializationDeserializationTest.java | 337 ++++++++++++++++++ 11 files changed, 799 insertions(+), 62 deletions(-) create mode 100644 kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/PessimisticSerializationRequiredException.java create mode 100644 kubernetes-kit-starter/src/test/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/TransientAwareHolderTest.java create mode 100644 kubernetes-kit-starter/src/test/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/VaadinScopeSerializationDeserializationTest.java diff --git a/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/PessimisticSerializationRequiredException.java b/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/PessimisticSerializationRequiredException.java new file mode 100644 index 0000000..2f3ecc2 --- /dev/null +++ b/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/PessimisticSerializationRequiredException.java @@ -0,0 +1,43 @@ +/*- + * Copyright (C) 2022 Vaadin Ltd + * + * This program is available under Vaadin Commercial License and Service Terms. + * + * + * See for the full + * license. + */ +package com.vaadin.kubernetes.starter.sessiontracker; + +/** + * Exception raise during session serialization to indicate that VaadinSession + * lock is required to complete the operation. + */ +public class PessimisticSerializationRequiredException + extends RuntimeException { + + /** + * Constructs a new exception with the specified detail message. + * + * @param message + * the detail message. The detail message is saved for later + * retrieval by the {@link #getMessage()} method. + */ + public PessimisticSerializationRequiredException(String message) { + super(message); + } + + /** + * Constructs a new exception with the specified detail message and cause. + * + * @param message + * the detail message. + * @param cause + * the cause. (A {@code null} value is permitted, and indicates + * that the cause is nonexistent or unknown.) + */ + public PessimisticSerializationRequiredException(String message, + Throwable cause) { + super(message, cause); + } +} diff --git a/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/SessionSerializer.java b/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/SessionSerializer.java index f424de7..84147b3 100644 --- a/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/SessionSerializer.java +++ b/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/SessionSerializer.java @@ -313,6 +313,11 @@ private void handleSessionSerialization(String sessionId, return; } } + } catch (PessimisticSerializationRequiredException e) { + getLogger().warn( + "Optimistic serialization of session {} with distributed key {} cannot be completed " + + " because VaadinSession lock is required. Switching to pessimistic locking.", + sessionId, clusterKey, e); } catch (NotSerializableException e) { getLogger().error( "Optimistic serialization of session {} with distributed key {} failed," @@ -410,7 +415,8 @@ private SessionInfo serializeOptimisticLocking(String sessionId, logSessionDebugInfo("Serialized session " + sessionId + " with distributed key " + clusterKey, attributes); return info; - } catch (NotSerializableException e) { + } catch (NotSerializableException + | PessimisticSerializationRequiredException e) { throw e; } catch (Exception e) { getLogger().trace( diff --git a/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/SpringTransientHandler.java b/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/SpringTransientHandler.java index b48125c..5c7b584 100644 --- a/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/SpringTransientHandler.java +++ b/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/SpringTransientHandler.java @@ -13,9 +13,13 @@ import java.lang.reflect.InaccessibleObjectException; import java.lang.reflect.Modifier; import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; +import java.util.concurrent.TimeUnit; import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -25,6 +29,11 @@ import org.springframework.context.ApplicationContext; import com.vaadin.flow.internal.ReflectTools; +import com.vaadin.flow.server.VaadinSession; +import com.vaadin.flow.spring.annotation.RouteScope; +import com.vaadin.flow.spring.annotation.UIScope; +import com.vaadin.flow.spring.annotation.VaadinSessionScope; +import com.vaadin.kubernetes.starter.sessiontracker.PessimisticSerializationRequiredException; /** * Spring specific implementation of {@link TransientHandler}, capable to @@ -58,43 +67,56 @@ public void inject(Object obj, List transients) { } private void injectField(Object obj, TransientDescriptor descriptor) { - getLogger().debug("Injecting '{}' into transient field {} of type {}", + getLogger().debug( + "Injecting '{}' into transient field '{}' of type '{}'", descriptor.getInstanceReference(), descriptor.getName(), obj.getClass()); - ReflectTools.setJavaFieldValue(obj, descriptor.getField(), - appCtx.getBean(descriptor.getInstanceReference())); + try { + ReflectTools.setJavaFieldValue(obj, descriptor.getField(), + appCtx.getBean(descriptor.getInstanceReference())); + } catch (RuntimeException ex) { + getLogger().error( + "Failed injecting '{}' into transient field '{}' of type '{}'", + descriptor.getInstanceReference(), descriptor.getName(), + obj.getClass()); + throw ex; + } } public List inspect(Object target) { - return findTransientFields(target.getClass(), f -> true).stream() - .map(field -> detectBean(target, field)) - .filter(Objects::nonNull).collect(Collectors.toList()); + List injectables = findTransientFields(target.getClass(), + f -> true).stream().map(field -> detectBean(target, field)) + .filter(Objects::nonNull).toList(); + return createDescriptors(target, injectables); } - private TransientDescriptor detectBean(Object target, Field field) { + private Injectable detectBean(Object target, Field field) { Object value = getFieldValue(target, field); - if (value != null) { Class valueType = value.getClass(); getLogger().trace( "Inspecting field {} of class {} for injected beans", field.getName(), target.getClass()); - TransientDescriptor transientDescriptor = appCtx - .getBeansOfType(valueType).entrySet().stream() - .filter(e -> e.getValue() == value || matchesPrototype( - e.getKey(), e.getValue(), valueType)) - .map(Map.Entry::getKey).findFirst() - .map(beanName -> new TransientDescriptor(field, beanName)) - .orElse(null); - if (transientDescriptor != null) { - getLogger().trace("Bean {} found for field {} of class {}", - transientDescriptor.getInstanceReference(), - field.getName(), target.getClass()); - } else { - getLogger().trace("No bean detected for field {} of class {}", - field.getName(), target.getClass()); + Set beanNames = new LinkedHashSet<>(List + .of(appCtx.getBeanNamesForType(valueType, true, false))); + List vaadinScopedBeanNames = new ArrayList<>(); + Collections.addAll(vaadinScopedBeanNames, + appCtx.getBeanNamesForAnnotation(VaadinSessionScope.class)); + Collections.addAll(vaadinScopedBeanNames, + appCtx.getBeanNamesForAnnotation(UIScope.class)); + Collections.addAll(vaadinScopedBeanNames, + appCtx.getBeanNamesForAnnotation(RouteScope.class)); + + boolean vaadinScoped = beanNames.stream() + .anyMatch(vaadinScopedBeanNames::contains); + if (vaadinScoped && VaadinSession.getCurrent() == null) { + getLogger().warn( + "VaadinSession is not available when trying to inspect Vaadin scoped bean: {}." + + "Transient fields might not be registered for deserialization.", + beanNames); + beanNames.removeIf(vaadinScopedBeanNames::contains); } - return transientDescriptor; + return new Injectable(field, value, beanNames, vaadinScoped); } getLogger().trace( "No bean detected for field {} of class {}, field value is null", @@ -102,6 +124,80 @@ private TransientDescriptor detectBean(Object target, Field field) { return null; } + private record Injectable(Field field, Object value, Set beanNames, + boolean vaadinScoped) { + } + + private TransientDescriptor createDescriptor(Object target, + Injectable injectable) { + Field field = injectable.field; + Object value = injectable.value; + Class valueType = value.getClass(); + TransientDescriptor transientDescriptor; + transientDescriptor = injectable.beanNames.stream() + .map(beanName -> Map.entry(beanName, appCtx.getBean(beanName))) + .filter(e -> e.getValue() == value || matchesPrototype( + e.getKey(), e.getValue(), valueType)) + .map(Map.Entry::getKey).findFirst() + .map(beanName -> new TransientDescriptor(field, beanName, + injectable.vaadinScoped)) + .orElse(null); + if (transientDescriptor != null) { + getLogger().trace("Bean {} found for field {} of class {}", + transientDescriptor.getInstanceReference(), field.getName(), + target.getClass()); + } else { + getLogger().trace("No bean detected for field {} of class {}", + field.getName(), target.getClass()); + } + return transientDescriptor; + } + + private List createDescriptors(Object target, + List injectables) { + boolean sessionLocked = false; + if (injectables.stream().anyMatch(Injectable::vaadinScoped)) { + // Bean has Vaadin scope, lookup needs VaadinSession lock + VaadinSession vaadinSession = VaadinSession.getCurrent(); + if (vaadinSession != null) { + try { + sessionLocked = vaadinSession.getLockInstance().tryLock(1, + TimeUnit.SECONDS); + if (!sessionLocked) { + throw new PessimisticSerializationRequiredException( + "Unable to acquire VaadinSession lock to lookup Vaadin scoped beans. " + + collectVaadinScopedCandidates( + injectables)); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new PessimisticSerializationRequiredException( + "Unable to acquire VaadinSession lock to lookup Vaadin scoped beans. " + + collectVaadinScopedCandidates( + injectables), + e); + } + } + } + try { + return injectables.stream() + .map(injectable -> createDescriptor(target, injectable)) + .filter(Objects::nonNull).toList(); + } finally { + if (sessionLocked) { + VaadinSession.getCurrent().getLockInstance().unlock(); + } + } + } + + private String collectVaadinScopedCandidates(List injectables) { + return injectables.stream().filter(Injectable::vaadinScoped) + .map(injectable -> String.format( + "[Field: %s, bean candidates: %s]", + injectable.field.getName(), injectable.beanNames)) + .collect(Collectors.joining(", ")); + } + private boolean matchesPrototype(String beanName, Object beanDefinition, Class fieldValueType) { return appCtx.containsBeanDefinition(beanName) diff --git a/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/TransientAwareHolder.java b/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/TransientAwareHolder.java index 40d63d4..22b815e 100644 --- a/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/TransientAwareHolder.java +++ b/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/TransientAwareHolder.java @@ -10,9 +10,21 @@ package com.vaadin.kubernetes.starter.sessiontracker.serialization; import java.io.Serializable; +import java.lang.reflect.Field; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.vaadin.flow.component.UI; +import com.vaadin.flow.internal.CurrentInstance; +import com.vaadin.flow.internal.ReflectTools; +import com.vaadin.flow.server.VaadinSession; /** * A serializable class that holds information about an object to be @@ -28,10 +40,21 @@ final class TransientAwareHolder implements Serializable { private final List transientDescriptors; private final Object source; // NOSONAR + private final UI ui; + private final VaadinSession session; TransientAwareHolder(Object source, List descriptors) { this.source = source; this.transientDescriptors = new ArrayList<>(descriptors); + if (descriptors.stream() + .anyMatch(TransientDescriptor::isVaadinScoped)) { + this.ui = UI.getCurrent(); + this.session = ui != null ? ui.getSession() + : VaadinSession.getCurrent(); + } else { + this.ui = null; + this.session = null; + } } /** @@ -53,4 +76,59 @@ Object source() { return source; } + /** + * Executes the given runnable making sure that Vaadin thread locals are + * set, when they are available. + * + * @param runnable + * the action to execute. + */ + void inVaadinScope(Runnable runnable) { + Map, CurrentInstance> instanceMap = null; + if (ui != null) { + instanceMap = CurrentInstance.setCurrent(ui); + } else if (session != null) { + instanceMap = CurrentInstance.setCurrent(session); + } + Runnable cleaner = injectLock(session); + try { + runnable.run(); + } finally { + if (instanceMap != null) { + CurrentInstance.restoreInstances(instanceMap); + cleaner.run(); + } + } + } + + // VaadinSession lock is usually set by calling + // VaadinSession.refreshTransients(WrappedSession,VaadinService), but during + // deserialization none of the required objects are available. + // This method injects a temporary lock instance into the provided + // VaadinSession and returns a runnable that will remove it when executed. + private static Runnable injectLock(VaadinSession session) { + if (session != null) { + try { + Field field = VaadinSession.class.getDeclaredField("lock"); + Lock lock = new ReentrantLock(); + lock.lock(); + ReflectTools.setJavaFieldValue(session, field, lock); + return () -> removeLock(session, field); + } catch (NoSuchFieldException e) { + getLogger().debug("Cannot access lock field on VaadinSession", + e); + } + } + return () -> { + }; + } + + private static void removeLock(VaadinSession session, Field field) { + session.getLockInstance().unlock(); + ReflectTools.setJavaFieldValue(session, field, null); + } + + private static Logger getLogger() { + return LoggerFactory.getLogger(TransientAwareHolder.class); + } } diff --git a/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/TransientDescriptor.java b/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/TransientDescriptor.java index be4c3c5..c1212ab 100644 --- a/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/TransientDescriptor.java +++ b/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/TransientDescriptor.java @@ -9,6 +9,7 @@ */ package com.vaadin.kubernetes.starter.sessiontracker.serialization; +import java.io.Serial; import java.io.Serializable; import java.lang.reflect.Field; import java.util.Objects; @@ -17,26 +18,37 @@ * Holds transient field details and a symbolic reference to the actual value. */ public final class TransientDescriptor implements Serializable { + + @Serial + private static final long serialVersionUID = 3577574582136843045L; + private final Class declaringClass; private final String name; private final Class type; - private final String instanceReference; + private final boolean vaadinScoped; public TransientDescriptor(Field field, String reference) { + this(field, reference, false); + } + + public TransientDescriptor(Field field, String reference, + boolean vaadinScoped) { declaringClass = field.getDeclaringClass(); name = field.getName(); type = field.getType(); instanceReference = reference; + this.vaadinScoped = vaadinScoped; } // Visible for test TransientDescriptor(Class declaringClass, String name, Class type, - String instanceReference) { + String instanceReference, boolean vaadinScoped) { this.declaringClass = declaringClass; this.name = name; this.type = type; this.instanceReference = instanceReference; + this.vaadinScoped = vaadinScoped; } /** @@ -81,6 +93,17 @@ public String getInstanceReference() { return instanceReference; } + /** + * Gets if the instance value needs Vaadin thread locals to be set during + * injection phase. + * + * @return {@literal true} is Vaadin thread locals are required to perform + * injection, otherwise {@literal false}. + */ + boolean isVaadinScoped() { + return vaadinScoped; + } + /** * Gets the Field object for the transient field. * @@ -103,18 +126,20 @@ public boolean equals(Object o) { TransientDescriptor that = (TransientDescriptor) o; return declaringClass.equals(that.declaringClass) && name.equals(that.name) && type.equals(that.type) - && instanceReference.equals(that.instanceReference); + && instanceReference.equals(that.instanceReference) + && vaadinScoped == that.vaadinScoped; } @Override public int hashCode() { - return Objects.hash(declaringClass, name, type, instanceReference); + return Objects.hash(declaringClass, name, type, instanceReference, + vaadinScoped); } @Override public String toString() { return String.format( - "TransientDescriptor { field: %s.%s, type: %s, instance: %s }", - declaringClass, name, type, instanceReference); + "TransientDescriptor { field: %s.%s, type: %s, instance: %s, vaadinScope: %s }", + declaringClass, name, type, instanceReference, vaadinScoped); } } diff --git a/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/TransientInjectableObjectInputStream.java b/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/TransientInjectableObjectInputStream.java index 0660e29..e281c97 100644 --- a/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/TransientInjectableObjectInputStream.java +++ b/kubernetes-kit-starter/src/main/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/TransientInjectableObjectInputStream.java @@ -189,11 +189,11 @@ private void injectTransients(TransientAwareHolder holder) { obj.getClass(), descriptors); getLogger().debug("Try injection into {}", obj.getClass()); try { - injector.inject(obj, descriptors); + holder.inVaadinScope(() -> injector.inject(obj, descriptors)); } catch (Exception ex) { getLogger().error( "Failed to inject transient fields into type {}", - obj.getClass()); + obj.getClass(), ex); } } else { getLogger().trace("Ignoring NULL TransientAwareHolder"); diff --git a/kubernetes-kit-starter/src/test/java/com/vaadin/kubernetes/starter/sessiontracker/SessionSerializerTest.java b/kubernetes-kit-starter/src/test/java/com/vaadin/kubernetes/starter/sessiontracker/SessionSerializerTest.java index a48038d..e07eacd 100644 --- a/kubernetes-kit-starter/src/test/java/com/vaadin/kubernetes/starter/sessiontracker/SessionSerializerTest.java +++ b/kubernetes-kit-starter/src/test/java/com/vaadin/kubernetes/starter/sessiontracker/SessionSerializerTest.java @@ -34,7 +34,6 @@ import static java.util.concurrent.TimeUnit.MILLISECONDS; import static java.util.concurrent.TimeUnit.NANOSECONDS; - import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.fail; import static org.mockito.ArgumentMatchers.any; @@ -62,13 +61,15 @@ class SessionSerializerTest { private HttpSession httpSession; private String clusterSID; private MockVaadinService vaadinService; + private TransientHandler transientHandler; @BeforeEach void setUp() { serializationCallback = mock(SessionSerializationCallback.class); connector = mock(BackendConnector.class); - serializer = new SessionSerializer(connector, - mock(TransientHandler.class), serializationCallback, + transientHandler = mock(TransientHandler.class); + serializer = new SessionSerializer(connector, transientHandler, + serializationCallback, TEST_OPTIMISTIC_SERIALIZATION_TIMEOUT_MS); clusterSID = UUID.randomUUID().toString(); @@ -157,6 +158,36 @@ void serialize_optimisticLocking_sessionChanged() { verify(connector).sendSession(isNotNull()); } + @Test + void serialize_optimisticLocking_sessionLockRequired_immediatelySwitchToPessimisticLocking() { + AtomicBoolean serializationStarted = new AtomicBoolean(); + doAnswer(i -> serializationStarted.getAndSet(true)).when(connector) + .markSerializationStarted(clusterSID); + AtomicBoolean serializationCompleted = new AtomicBoolean(); + doAnswer(i -> serializationCompleted.getAndSet(true)).when(connector) + .markSerializationComplete(clusterSID); + + AtomicBoolean pessimisticLockingRequested = new AtomicBoolean(); + doAnswer(i -> pessimisticLockingRequested.getAndSet(true)) + .when(serializationCallback).onSerializationError( + any(PessimisticSerializationRequiredException.class)); + + when(transientHandler.inspect(any())) + .thenThrow(new PessimisticSerializationRequiredException( + "VaadinSession lock required")) + .thenReturn(List.of()); + + serializer.serialize(httpSession); + await().during(100, MILLISECONDS).untilTrue(serializationStarted); + verify(connector).markSerializationStarted(clusterSID); + + await().atMost(1000, MILLISECONDS) + .untilTrue(pessimisticLockingRequested); + + await().atMost(1000, MILLISECONDS).untilTrue(serializationCompleted); + verify(connector).sendSession(notNull()); + } + @Test void serialize_pendingSerialization_skip() { AtomicInteger serializationsCompleted = new AtomicInteger(); diff --git a/kubernetes-kit-starter/src/test/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/SpringTransientHandlerTest.java b/kubernetes-kit-starter/src/test/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/SpringTransientHandlerTest.java index 698816b..d59f329 100644 --- a/kubernetes-kit-starter/src/test/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/SpringTransientHandlerTest.java +++ b/kubernetes-kit-starter/src/test/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/SpringTransientHandlerTest.java @@ -54,9 +54,9 @@ void inspect_inheritedFields_beansAreDetected() { assertThat(transients).hasSize(2).containsExactlyInAnyOrder( new TransientDescriptor(Parent.class, "theService", - TestService.class, "alternativeImpl"), + TestService.class, "alternativeImpl", false), new TransientDescriptor(Child.class, "theService", - TestService.class, "defaultImpl")); + TestService.class, "defaultImpl", false)); } @Test @@ -67,7 +67,7 @@ void inspect_byName_beansAreDetected( assertThat(transients).containsExactlyInAnyOrder( new TransientDescriptor(TestConfig.NamedComponentTarget.class, "named", TestConfig.NamedComponent.class, - TestConfig.NamedComponent.NAME)); + TestConfig.NamedComponent.NAME, false)); } @@ -79,11 +79,12 @@ void inspect_prototypeScopedBeansWithInheritance_beansAreDetected( assertThat(transients).containsExactlyInAnyOrder( new TransientDescriptor(TestConfig.PrototypeTarget.class, "prototypeScoped", TestConfig.PrototypeComponent.class, - TestConfig.PrototypeComponent.class.getName()), + TestConfig.PrototypeComponent.class.getName(), false), new TransientDescriptor(TestConfig.PrototypeTarget.class, "extPrototypeScoped", TestConfig.PrototypeComponent.class, - TestConfig.PrototypeComponentExt.class.getName())); + TestConfig.PrototypeComponentExt.class.getName(), + false)); } @Test @@ -94,10 +95,12 @@ void inspect_prototypeScopedBeans_beansAreDetected( assertThat(transients).containsExactlyInAnyOrder( new TransientDescriptor(TestConfig.PrototypeServiceTarget.class, "prototypeServiceA", TestConfig.PrototypeService.class, - TestConfig.PrototypeServiceImplA.class.getName()), + TestConfig.PrototypeServiceImplA.class.getName(), + false), new TransientDescriptor(TestConfig.PrototypeServiceTarget.class, "prototypeServiceB", TestConfig.PrototypeService.class, - TestConfig.PrototypeServiceImplB.class.getName())); + TestConfig.PrototypeServiceImplB.class.getName(), + false)); } @Test @@ -109,13 +112,13 @@ void inspect_proxiedPrototypeScopedBeans_beansAreDetected( new TransientDescriptor( TestConfig.ProxiedPrototypeServiceTarget.class, "prototypeServiceA", TestConfig.PrototypeService.class, - TestConfig.ProxiedPrototypeServiceImplA.class - .getName()), + TestConfig.ProxiedPrototypeServiceImplA.class.getName(), + false), new TransientDescriptor( TestConfig.ProxiedPrototypeServiceTarget.class, "prototypeServiceB", TestConfig.PrototypeService.class, - TestConfig.ProxiedPrototypeServiceImplB.class - .getName())); + TestConfig.ProxiedPrototypeServiceImplB.class.getName(), + false)); } @Test @@ -125,7 +128,8 @@ void inspect_proxiedBeans_beansAreDetected( assertThat(transients).containsExactlyInAnyOrder( new TransientDescriptor(TestConfig.ProxiedBeanTarget.class, - "service", TestService.class, "transactionalService")); + "service", TestService.class, "transactionalService", + false)); } @Test @@ -142,9 +146,10 @@ void inject_detectedBeansAreInjected() { null, null); List descriptors = List.of( new TransientDescriptor(TestConfig.CtorInjectionTarget.class, - "defaultImpl", TestService.class, "defaultImpl"), + "defaultImpl", TestService.class, "defaultImpl", false), new TransientDescriptor(TestConfig.CtorInjectionTarget.class, - "alternative", TestService.class, "alternativeImpl")); + "alternative", TestService.class, "alternativeImpl", + false)); handler.inject(newTarget, descriptors); Assertions.assertSame(target.defaultImpl, newTarget.defaultImpl, diff --git a/kubernetes-kit-starter/src/test/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/SpringTransientHandlerVaadinScopesTest.java b/kubernetes-kit-starter/src/test/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/SpringTransientHandlerVaadinScopesTest.java index 42ec4c6..c0a4420 100644 --- a/kubernetes-kit-starter/src/test/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/SpringTransientHandlerVaadinScopesTest.java +++ b/kubernetes-kit-starter/src/test/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/SpringTransientHandlerVaadinScopesTest.java @@ -1,7 +1,7 @@ package com.vaadin.kubernetes.starter.sessiontracker.serialization; +import java.io.Serializable; import java.util.List; -import java.util.Map; import java.util.Set; import org.junit.jupiter.api.AfterEach; @@ -53,8 +53,6 @@ void setUp() { Set.of(UITestSpringLookupInitializer.class)); handler = new SpringTransientHandler(appCtx); - Map beans = appCtx.getBeansOfType(Object.class); - System.out.println(beans); } @AfterEach @@ -74,35 +72,35 @@ void inspect_scopedBeans_beansAreDetected() { assertThat(transients).containsExactlyInAnyOrder( new TransientDescriptor(TestConfig.TestView.class, "uiScoped", TestConfig.UIScopedComponent.class, - TestConfig.UIScopedComponent.class.getName()), + TestConfig.UIScopedComponent.class.getName(), true), new TransientDescriptor(TestConfig.TestView.class, "routeScoped", TestConfig.RouteScopedComponent.class, - TestConfig.RouteScopedComponent.class.getName()), + TestConfig.RouteScopedComponent.class.getName(), true), new TransientDescriptor(TestConfig.TestView.class, "sessionScoped", TestConfig.VaadinSessionScopedComponent.class, - TestConfig.VaadinSessionScopedComponent.class - .getName())); + TestConfig.VaadinSessionScopedComponent.class.getName(), + true)); } @Configuration static class TestConfig { @UIScope @SpringComponent - static class UIScopedComponent { - + static class UIScopedComponent implements Serializable { + String value = ""; } @VaadinSessionScope @SpringComponent - static class VaadinSessionScopedComponent { - + static class VaadinSessionScopedComponent implements Serializable { + String value = ""; } @RouteScope @Component - static class RouteScopedComponent { - + static class RouteScopedComponent implements Serializable { + String value = ""; } // @Component diff --git a/kubernetes-kit-starter/src/test/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/TransientAwareHolderTest.java b/kubernetes-kit-starter/src/test/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/TransientAwareHolderTest.java new file mode 100644 index 0000000..413ef3b --- /dev/null +++ b/kubernetes-kit-starter/src/test/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/TransientAwareHolderTest.java @@ -0,0 +1,118 @@ +/*- + * Copyright (C) 2022 Vaadin Ltd + * + * This program is available under Vaadin Commercial License and Service Terms. + * + * + * See for the full + * license. + */ +package com.vaadin.kubernetes.starter.sessiontracker.serialization; + +import java.lang.reflect.Field; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import com.vaadin.flow.component.UI; +import com.vaadin.flow.function.DeploymentConfiguration; +import com.vaadin.flow.internal.CurrentInstance; +import com.vaadin.flow.server.VaadinService; +import com.vaadin.flow.server.VaadinSession; +import com.vaadin.testbench.unit.internal.MockVaadin; +import com.vaadin.testbench.unit.mocks.MockService; +import com.vaadin.testbench.unit.mocks.MockVaadinSession; +import com.vaadin.testbench.unit.mocks.MockedUI; + +public class TransientAwareHolderTest { + + VaadinSession session; + UI ui; + Field field; + + @BeforeEach + void setUp() throws NoSuchFieldException { + MockVaadin.setup(); + session = VaadinSession.getCurrent(); + ui = new MockedUI(); + ui.getInternals().setSession(session); + MockVaadin.tearDown(); + CurrentInstance.clearAll(); + + field = Dummy.class.getDeclaredField("myField"); + } + + @AfterEach + void tearDown() { + CurrentInstance.clearAll(); + } + + @Test + void inVaadinScope_vaadinScoped_uiAvailable_threadLocalsSet() { + UI.setCurrent(ui); + TransientAwareHolder holder = new TransientAwareHolder(new Object(), + List.of(new TransientDescriptor(field, "REF", true))); + CurrentInstance.clearAll(); + + ThreadLocalsGrabber grabber = new ThreadLocalsGrabber(); + holder.inVaadinScope(grabber); + + Assertions.assertNotNull(grabber.session, + "Expected VaadinSession thread local to be set, but was not"); + Assertions.assertNotNull(grabber.ui, + "Expected UI thread local to be set, but was not"); + } + + @Test + void inVaadinScope_vaadinScoped_onlySession_threadLocalSet() { + VaadinSession.setCurrent(session); + TransientAwareHolder holder = new TransientAwareHolder(new Object(), + List.of(new TransientDescriptor(field, "REF", true))); + CurrentInstance.clearAll(); + + ThreadLocalsGrabber grabber = new ThreadLocalsGrabber(); + holder.inVaadinScope(grabber); + + Assertions.assertNotNull(grabber.session, + "Expected VaadinSession thread local to be set, but was not"); + Assertions.assertNull(grabber.ui, + "Expected UI thread local not to be set, but it was"); + } + + @Test + void inVaadinScope_notVaadinScoped_threadLocalNotSet() { + UI.setCurrent(ui); + TransientAwareHolder holder = new TransientAwareHolder(new Object(), + List.of(new TransientDescriptor(field, "REF", false))); + CurrentInstance.clearAll(); + + ThreadLocalsGrabber grabber = new ThreadLocalsGrabber(); + holder.inVaadinScope(grabber); + + Assertions.assertNull(grabber.session, + "Expected VaadinSession thread local not to be set, but it was"); + Assertions.assertNull(grabber.ui, + "Expected UI thread local not to be set, but it was"); + } + + private static class ThreadLocalsGrabber implements Runnable { + + VaadinSession session; + UI ui; + + @Override + public void run() { + session = VaadinSession.getCurrent(); + ui = UI.getCurrent(); + } + } + + private static class Dummy { + private Object myField; + } + +} diff --git a/kubernetes-kit-starter/src/test/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/VaadinScopeSerializationDeserializationTest.java b/kubernetes-kit-starter/src/test/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/VaadinScopeSerializationDeserializationTest.java new file mode 100644 index 0000000..49d1c8d --- /dev/null +++ b/kubernetes-kit-starter/src/test/java/com/vaadin/kubernetes/starter/sessiontracker/serialization/VaadinScopeSerializationDeserializationTest.java @@ -0,0 +1,337 @@ +package com.vaadin.kubernetes.starter.sessiontracker.serialization; + +import jakarta.servlet.ServletException; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.Serializable; +import java.io.UncheckedIOException; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.locks.ReentrantLock; + +import kotlin.jvm.functions.Function0; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestExecutionListeners; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import com.vaadin.flow.component.UI; +import com.vaadin.flow.function.DeploymentConfiguration; +import com.vaadin.flow.server.InitParameters; +import com.vaadin.flow.server.VaadinService; +import com.vaadin.flow.server.VaadinSession; +import com.vaadin.flow.server.WrappedSession; +import com.vaadin.flow.spring.VaadinScopesConfig; +import com.vaadin.kubernetes.starter.sessiontracker.PessimisticSerializationRequiredException; +import com.vaadin.kubernetes.starter.sessiontracker.serialization.SpringTransientHandlerVaadinScopesTest.TestConfig.TestView; +import com.vaadin.testbench.unit.UITestSpringLookupInitializer; +import com.vaadin.testbench.unit.internal.MockVaadin; +import com.vaadin.testbench.unit.internal.Routes; +import com.vaadin.testbench.unit.mocks.MockSpringServlet; +import com.vaadin.testbench.unit.mocks.MockedUI; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +@ContextConfiguration(classes = { + SpringTransientHandlerVaadinScopesTest.TestConfig.class, + VaadinScopesConfig.class }) +@ExtendWith(SpringExtension.class) +@TestExecutionListeners(listeners = UITestSpringLookupInitializer.class, mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS) +class VaadinScopeSerializationDeserializationTest { + + @Autowired + ApplicationContext appCtx; + + SpringTransientHandler handler; + + @BeforeEach + void setUp() { + System.setProperty("sun.io.serialization.extendedDebugInfo", "true"); + handler = new SpringTransientHandler(appCtx); + setupVaadin(); + } + + private void setupVaadin() { + Routes routes = new Routes() + .autoDiscoverViews(TestView.class.getPackageName()); + SerializableUIFactory uiFactory = MockedUI::new; + MockSpringServlet servlet = new MockSpringServlet(routes, appCtx, + uiFactory) { + @Override + protected DeploymentConfiguration createDeploymentConfiguration() + throws ServletException { + getServletContext().setInitParameter( + InitParameters.APPLICATION_PARAMETER_DEVMODE_ENABLE_SERIALIZE_SESSION, + "true"); + return super.createDeploymentConfiguration(); + } + }; + MockVaadin.setup(uiFactory, servlet, + Set.of(UITestSpringLookupInitializer.class)); + } + + interface SerializableUIFactory extends Function0, Serializable { + } + + @Test + void serialization_vaadinSessionAvailableAndUnlocked_acquireLock_beanInspected() + throws Exception { + TestView view = navigateToView(); + VaadinSession vaadinSession = VaadinSession.getCurrent(); + + ByteArrayOutputStream result = doSerialize(vaadinSession, 0); + vaadinSession.getLockInstance().lock(); + MockVaadin.tearDown(); + + setupVaadin(); + TestView deserializedView = doDeserialize(result); + + assertScopedBeansInjected(deserializedView, view); + } + + @Test + void serialization_vaadinSessionAvailableAndLocked_tryAcquireLockSucceed_beanInspected() + throws Exception { + TestView view = navigateToView(); + VaadinSession vaadinSession = VaadinSession.getCurrent(); + + ByteArrayOutputStream result = doSerialize(vaadinSession, 300); + vaadinSession.getLockInstance().lock(); + MockVaadin.tearDown(); + + setupVaadin(); + TestView deserializedView = doDeserialize(result); + + assertScopedBeansInjected(deserializedView, view); + } + + @Test + void serialization_vaadinSessionAvailableAndLocked_tryAcquireLockFail_requirePessimisticLock() + throws Exception { + navigateToView(); + VaadinSession vaadinSession = VaadinSession.getCurrent(); + + assertThatExceptionOfType(CompletionException.class) + .isThrownBy(() -> doSerialize(vaadinSession, 1200)) + .withCauseExactlyInstanceOf( + PessimisticSerializationRequiredException.class); + } + + @Test + void serialization_vaadinSessionNotAvailable_beansNotInspected() + throws Exception { + TestView view = navigateToView(); + view.removeFromParent(); + + ByteArrayOutputStream data = new ByteArrayOutputStream(); + TransientInjectableObjectOutputStream writer = TransientInjectableObjectOutputStream + .newInstance(data, handler, clazz -> clazz.getPackageName() + .startsWith("com.vaadin.kubernetes")); + CompletableFuture.runAsync(() -> { + try { + writer.writeWithTransients(view); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }).join(); + MockVaadin.tearDown(); + + TransientInjectableObjectInputStream reader = new TransientInjectableObjectInputStream( + new ByteArrayInputStream(data.toByteArray()), handler); + + TestView deserializedView = CompletableFuture.supplyAsync(() -> { + try { + return reader. readWithTransients(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }).join(); + + assertThat(deserializedView).extracting(v -> v.sessionScoped, + v -> v.uiScoped, v -> v.routeScoped).containsOnlyNulls(); + + } + + private static void assertScopedBeansInjected(TestView deserializedView, + TestView view) { + assertThat(deserializedView).extracting(v -> v.sessionScoped, + v -> v.uiScoped, v -> v.routeScoped).doesNotContainNull(); + assertThat(deserializedView) + .extracting(v -> v.sessionScoped.value, v -> v.uiScoped.value, + v -> v.routeScoped.value) + .containsExactly(view.sessionScoped.value, view.uiScoped.value, + view.routeScoped.value); + } + + private TestView navigateToView() { + UI ui = UI.getCurrent(); + TestView view = ui.navigate(TestView.class) + .orElseThrow(() -> new AssertionError( + "Cannot get instance of " + TestView.class)); + String randomValue = UUID.randomUUID().toString(); + view.sessionScoped.value = "SESSION-" + randomValue; + view.uiScoped.value = "UI-" + randomValue; + view.routeScoped.value = "ROUTE-" + randomValue; + return view; + } + + private ByteArrayOutputStream doSerialize(VaadinSession session, + int unlockAfterMillis) throws Exception { + + if (unlockAfterMillis == 0) { + session.getLockInstance().unlock(); + } + Map target = MapBasedWrappedSession + .asMap(session.getSession()); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + TransientInjectableObjectOutputStream writer = TransientInjectableObjectOutputStream + .newInstance(os, handler, clazz -> clazz.getPackageName() + .startsWith("com.vaadin.kubernetes")); + CompletableFuture future = CompletableFuture.runAsync(() -> { + try { + writer.writeWithTransients(target); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + if (unlockAfterMillis > 0) { + Thread.sleep(unlockAfterMillis); + session.getLockInstance().unlock(); + } + future.join(); + return os; + } + + private TestView doDeserialize(ByteArrayOutputStream data) + throws IOException { + VaadinService vaadinService = VaadinService.getCurrent(); + + TransientInjectableObjectInputStream reader = new TransientInjectableObjectInputStream( + new ByteArrayInputStream(data.toByteArray()), handler); + Map result; + result = CompletableFuture.supplyAsync(() -> { + try { + return reader.> readWithTransients(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }).join(); + + MapBasedWrappedSession wrappedSession = new MapBasedWrappedSession( + result); + ReentrantLock lockInstance = wrappedSession + .getLockInstance(vaadinService); + lockInstance.lock(); + VaadinSession session = wrappedSession.getVaadinSession(); + session.refreshTransients(wrappedSession, vaadinService); + try { + assertThat(session.getUIs()).hasSize(1); + + TestView deserializedView = getTestView( + session.getUIs().iterator().next()); + assertThat(deserializedView).isNotNull(); + return deserializedView; + } finally { + lockInstance.unlock(); + } + } + + private TestView getTestView(UI ui) { + return ui.getChildren().filter(TestView.class::isInstance) + .map(TestView.class::cast).findFirst().orElse(null); + } + + private static class MapBasedWrappedSession implements WrappedSession { + + private final Map map; + private final VaadinSession session; + + public MapBasedWrappedSession(Map map) { + this.map = map; + this.session = map.values().stream() + .filter(VaadinSession.class::isInstance) + .map(VaadinSession.class::cast).findFirst().orElse(null); + } + + @Override + public int getMaxInactiveInterval() { + return 0; + } + + @Override + public Object getAttribute(String name) { + return map.get(name); + } + + @Override + public void setAttribute(String name, Object value) { + map.put(name, value); + } + + @Override + public Set getAttributeNames() { + return Set.copyOf(map.keySet()); + } + + @Override + public void invalidate() { + + } + + @Override + public String getId() { + return ""; + } + + @Override + public long getCreationTime() { + return 0; + } + + @Override + public long getLastAccessedTime() { + return 0; + } + + @Override + public boolean isNew() { + return false; + } + + @Override + public void removeAttribute(String name) { + map.remove(name); + } + + @Override + public void setMaxInactiveInterval(int interval) { + + } + + ReentrantLock getLockInstance(VaadinService service) { + return (ReentrantLock) map.get(service.getServiceName() + ".lock"); + } + + VaadinSession getVaadinSession() { + return session; + } + + static Map asMap(WrappedSession wrappedSession) { + Map map = new HashMap<>(); + wrappedSession.getAttributeNames().forEach(attrName -> map + .put(attrName, wrappedSession.getAttribute(attrName))); + return map; + } + } +}