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 5c2591f..ba36e31 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 @@ -321,6 +321,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," @@ -418,7 +423,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 4b13b95..c93d513 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; + } + } +}