Issue #4920 Restore ability to invalidate sessions on shutdown (#4933)

Signed-off-by: Jan Bartel <janb@webtide.com>
This commit is contained in:
Jan Bartel 2020-06-10 18:40:19 +02:00 committed by GitHub
parent cbda92ab8c
commit cb09abe873
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 287 additions and 102 deletions

View File

@ -14,6 +14,7 @@
<Set name="saveOnCreate"><Property name="jetty.session.saveOnCreate" default="false" /></Set> <Set name="saveOnCreate"><Property name="jetty.session.saveOnCreate" default="false" /></Set>
<Set name="removeUnloadableSessions"><Property name="jetty.session.removeUnloadableSessions" default="false"/></Set> <Set name="removeUnloadableSessions"><Property name="jetty.session.removeUnloadableSessions" default="false"/></Set>
<Set name="flushOnResponseCommit"><Property name="jetty.session.flushOnResponseCommit" default="false"/></Set> <Set name="flushOnResponseCommit"><Property name="jetty.session.flushOnResponseCommit" default="false"/></Set>
<Set name="invalidateOnShutdown"><Property name="jetty.session.invalidateOnShutdown" default="false"/></Set>
</New> </New>
</Arg> </Arg>
</Call> </Call>

View File

@ -23,3 +23,4 @@ etc/sessions/session-cache-hash.xml
#jetty.session.saveOnCreate=false #jetty.session.saveOnCreate=false
#jetty.session.removeUnloadableSessions=false #jetty.session.removeUnloadableSessions=false
#jetty.session.flushOnResponseCommit=false #jetty.session.flushOnResponseCommit=false
#jetty.session.invalidateOnShutdown=false

View File

@ -18,4 +18,4 @@ etc/sessions/session-cache-null.xml
[ini-template] [ini-template]
#jetty.session.saveOnCreate=false #jetty.session.saveOnCreate=false
#jetty.session.removeUnloadableSessions=false #jetty.session.removeUnloadableSessions=false
#jetty.session.flushOnResponseCommit=false #jetty.session.flushOnResponseCommit=false

View File

@ -99,6 +99,12 @@ public abstract class AbstractSessionCache extends ContainerLifeCycle implements
* a dirty session will be flushed to the session store. * a dirty session will be flushed to the session store.
*/ */
protected boolean _flushOnResponseCommit; protected boolean _flushOnResponseCommit;
/**
* If true, when the server shuts down, all sessions in the
* cache will be invalidated before being removed.
*/
protected boolean _invalidateOnShutdown;
/** /**
* Create a new Session object from pre-existing session data * Create a new Session object from pre-existing session data
@ -815,6 +821,18 @@ public abstract class AbstractSessionCache extends ContainerLifeCycle implements
_saveOnInactiveEviction = saveOnEvict; _saveOnInactiveEviction = saveOnEvict;
} }
@Override
public void setInvalidateOnShutdown(boolean invalidateOnShutdown)
{
_invalidateOnShutdown = invalidateOnShutdown;
}
@Override
public boolean isInvalidateOnShutdown()
{
return _invalidateOnShutdown;
}
/** /**
* Whether we should save a session that has been inactive before * Whether we should save a session that has been inactive before
* we boot it from the cache. * we boot it from the cache.

View File

@ -31,6 +31,19 @@ public abstract class AbstractSessionCacheFactory implements SessionCacheFactory
boolean _saveOnCreate; boolean _saveOnCreate;
boolean _removeUnloadableSessions; boolean _removeUnloadableSessions;
boolean _flushOnResponseCommit; boolean _flushOnResponseCommit;
boolean _invalidateOnShutdown;
public abstract SessionCache newSessionCache(SessionHandler handler);
public boolean isInvalidateOnShutdown()
{
return _invalidateOnShutdown;
}
public void setInvalidateOnShutdown(boolean invalidateOnShutdown)
{
_invalidateOnShutdown = invalidateOnShutdown;
}
/** /**
* @return the flushOnResponseCommit * @return the flushOnResponseCommit
@ -111,4 +124,17 @@ public abstract class AbstractSessionCacheFactory implements SessionCacheFactory
{ {
_saveOnInactiveEvict = saveOnInactiveEvict; _saveOnInactiveEvict = saveOnInactiveEvict;
} }
@Override
public SessionCache getSessionCache(SessionHandler handler)
{
SessionCache cache = newSessionCache(handler);
cache.setEvictionPolicy(getEvictionPolicy());
cache.setSaveOnInactiveEviction(isSaveOnInactiveEvict());
cache.setSaveOnCreate(isSaveOnCreate());
cache.setRemoveUnloadableSessions(isRemoveUnloadableSessions());
cache.setFlushOnResponseCommit(isFlushOnResponseCommit());
cache.setInvalidateOnShutdown(isInvalidateOnShutdown());
return cache;
}
} }

View File

@ -132,29 +132,18 @@ public class DefaultSessionCache extends AbstractSessionCache
@Override @Override
public void shutdown() public void shutdown()
{ {
if (LOG.isDebugEnabled())
LOG.debug("Shutdown sessions, invalidating = {}", isInvalidateOnShutdown());
// loop over all the sessions in memory (a few times if necessary to catch sessions that have been // loop over all the sessions in memory (a few times if necessary to catch sessions that have been
// added while we're running // added while we're running
int loop = 100; int loop = 100;
while (!_sessions.isEmpty() && loop-- > 0) while (!_sessions.isEmpty() && loop-- > 0)
{ {
for (Session session : _sessions.values()) for (Session session : _sessions.values())
{ {
//if we have a backing store so give the session to it to write out if necessary if (isInvalidateOnShutdown())
if (_sessionDataStore != null)
{
session.willPassivate();
try
{
_sessionDataStore.store(session.getId(), session.getSessionData());
}
catch (Exception e)
{
LOG.warn(e);
}
doDelete(session.getId()); //remove from memory
session.setResident(false);
}
else
{ {
//not preserving sessions on exit //not preserving sessions on exit
try try
@ -166,6 +155,22 @@ public class DefaultSessionCache extends AbstractSessionCache
LOG.ignore(e); LOG.ignore(e);
} }
} }
else
{
//write out the session and remove from the cache
if (_sessionDataStore.isPassivating())
session.willPassivate();
try
{
_sessionDataStore.store(session.getId(), session.getSessionData());
}
catch (Exception e)
{
LOG.warn(e);
}
doDelete(session.getId()); //remove from memory
session.setResident(false);
}
} }
} }
} }

View File

@ -26,14 +26,8 @@ package org.eclipse.jetty.server.session;
public class DefaultSessionCacheFactory extends AbstractSessionCacheFactory public class DefaultSessionCacheFactory extends AbstractSessionCacheFactory
{ {
@Override @Override
public SessionCache getSessionCache(SessionHandler handler) public SessionCache newSessionCache(SessionHandler handler)
{ {
DefaultSessionCache cache = new DefaultSessionCache(handler); return new DefaultSessionCache(handler);
cache.setEvictionPolicy(getEvictionPolicy());
cache.setSaveOnInactiveEviction(isSaveOnInactiveEvict());
cache.setSaveOnCreate(isSaveOnCreate());
cache.setRemoveUnloadableSessions(isRemoveUnloadableSessions());
cache.setFlushOnResponseCommit(isFlushOnResponseCommit());
return cache;
} }
} }

View File

@ -485,7 +485,9 @@ public class DefaultSessionIdManager extends ContainerLifeCycle implements Sessi
{ {
for (Handler h : tmp) for (Handler h : tmp)
{ {
if (h.isStarted()) //This method can be called on shutdown when the handlers are STOPPING, so only
//check that they are not already stopped
if (!h.isStopped() && !h.isFailed())
handlers.add((SessionHandler)h); handlers.add((SessionHandler)h);
} }
} }

View File

@ -55,14 +55,23 @@ public class NullSessionCacheFactory extends AbstractSessionCacheFactory
if (LOG.isDebugEnabled()) if (LOG.isDebugEnabled())
LOG.debug("Ignoring eviction policy setting for NullSessionCaches"); LOG.debug("Ignoring eviction policy setting for NullSessionCaches");
} }
@Override
public boolean isInvalidateOnShutdown()
{
return false; //meaningless for NullSessionCache
}
@Override @Override
public SessionCache getSessionCache(SessionHandler handler) public void setInvalidateOnShutdown(boolean invalidateOnShutdown)
{ {
NullSessionCache cache = new NullSessionCache(handler); if (LOG.isDebugEnabled())
cache.setSaveOnCreate(isSaveOnCreate()); LOG.debug("Ignoring invalidateOnShutdown setting for NullSessionCaches");
cache.setRemoveUnloadableSessions(isRemoveUnloadableSessions()); }
cache.setFlushOnResponseCommit(isFlushOnResponseCommit());
return cache; @Override
public SessionCache newSessionCache(SessionHandler handler)
{
return new NullSessionCache(handler);
} }
} }

View File

@ -312,4 +312,13 @@ public interface SessionCache extends LifeCycle
* before the response is committed. * before the response is committed.
*/ */
boolean isFlushOnResponseCommit(); boolean isFlushOnResponseCommit();
/**
* If true, all existing sessions in the cache will be invalidated when
* the server shuts down. Default is false.
* @param invalidateOnShutdown
*/
void setInvalidateOnShutdown(boolean invalidateOnShutdown);
boolean isInvalidateOnShutdown();
} }

View File

@ -20,9 +20,7 @@ package org.eclipse.jetty.server.session;
import java.util.Collections; import java.util.Collections;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import javax.servlet.http.HttpSession; import javax.servlet.http.HttpSession;
import javax.servlet.http.HttpSessionActivationListener; import javax.servlet.http.HttpSessionActivationListener;
import javax.servlet.http.HttpSessionEvent; import javax.servlet.http.HttpSessionEvent;
@ -119,11 +117,25 @@ public abstract class AbstractSessionCacheTest
++activateCalls; ++activateCalls;
} }
} }
public abstract AbstractSessionCacheFactory newSessionCacheFactory(int evictionPolicy, boolean saveOnCreate, public abstract AbstractSessionCacheFactory newSessionCacheFactory(int evictionPolicy,
boolean saveOnInactiveEvict, boolean removeUnloadableSessions, boolean saveOnCreate,
boolean saveOnInactiveEvict,
boolean removeUnloadableSessions,
boolean flushOnResponseCommit); boolean flushOnResponseCommit);
public abstract void checkSessionBeforeShutdown(String id,
SessionDataStore store,
SessionCache cache,
TestSessionActivationListener activationListener,
TestHttpSessionListener sessionListener) throws Exception;
public abstract void checkSessionAfterShutdown(String id,
SessionDataStore store,
SessionCache cache,
TestSessionActivationListener activationListener,
TestHttpSessionListener sessionListener) throws Exception;
/** /**
* Test that a session that exists in the datastore, but that cannot be * Test that a session that exists in the datastore, but that cannot be
* read will be invalidated and deleted, and thus a request to re-use that * read will be invalidated and deleted, and thus a request to re-use that
@ -243,11 +255,14 @@ public abstract class AbstractSessionCacheTest
assertEquals(now - 20, session.getCreationTime()); assertEquals(now - 20, session.getCreationTime());
} }
/**
* Test state of session with call to commit
*
* @throws Exception
*/
@Test @Test
public void testCommit() throws Exception public void testCommit() throws Exception
{ {
//Test state of session with call to commit
Server server = new Server(); Server server = new Server();
ServletContextHandler context = new ServletContextHandler(ServletContextHandler.SESSIONS); ServletContextHandler context = new ServletContextHandler(ServletContextHandler.SESSIONS);
@ -320,11 +335,14 @@ public abstract class AbstractSessionCacheTest
commitAndCheckSaveState(cache, store, session, false, true, false, true, 0, 0); commitAndCheckSaveState(cache, store, session, false, true, false, true, 0, 0);
} }
/**
* Test what happens with various states of a session when commit
* is called before release
* @throws Exception
*/
@Test @Test
public void testCommitAndRelease() throws Exception public void testCommitAndRelease() throws Exception
{ {
//test what happens with various states of a session when commit
//is called before release
Server server = new Server(); Server server = new Server();
ServletContextHandler context = new ServletContextHandler(ServletContextHandler.SESSIONS); ServletContextHandler context = new ServletContextHandler(ServletContextHandler.SESSIONS);
@ -421,7 +439,7 @@ public abstract class AbstractSessionCacheTest
assertFalse(session.getSessionData().isDirty()); assertFalse(session.getSessionData().isDirty());
assertTrue(session.getSessionData().isMetaDataDirty()); assertTrue(session.getSessionData().isMetaDataDirty());
} }
/** /**
* Test the exist method. * Test the exist method.
*/ */
@ -596,6 +614,92 @@ public abstract class AbstractSessionCacheTest
cache.newSession(null, "1234", now, TimeUnit.MINUTES.toMillis(10)); cache.newSession(null, "1234", now, TimeUnit.MINUTES.toMillis(10));
assertFalse(store.exists("1234")); assertFalse(store.exists("1234"));
} }
/**
* Test shutting down the server with invalidateOnShutdown==false
*
* @throws Exception
*/
@Test
public void testNoInvalidateOnShutdown()
throws Exception
{
Server server = new Server();
ServletContextHandler context = new ServletContextHandler(ServletContextHandler.SESSIONS);
context.setContextPath("/test");
context.setServer(server);
server.setHandler(context);
AbstractSessionCacheFactory cacheFactory = newSessionCacheFactory(SessionCache.NEVER_EVICT, false, false, false, false);
SessionCache cache = cacheFactory.getSessionCache(context.getSessionHandler());
TestSessionDataStore store = new TestSessionDataStore(true);//fake passivation
cache.setSessionDataStore(store);
context.getSessionHandler().setSessionCache(cache);
TestHttpSessionListener sessionListener = new TestHttpSessionListener();
context.getSessionHandler().addEventListener(sessionListener);
server.start();
//put a session in the cache and store
long now = System.currentTimeMillis();
SessionData data = store.newSessionData("1234", now - 20, now - 10, now - 20, TimeUnit.MINUTES.toMillis(10));
Session session = cache.newSession(data);
TestSessionActivationListener activationListener = new TestSessionActivationListener();
cache.add("1234", session);
session.setAttribute("aaa", activationListener);
cache.release("1234", session);
checkSessionBeforeShutdown("1234", store, cache, activationListener, sessionListener);
server.stop(); //calls shutdown
checkSessionAfterShutdown("1234", store, cache, activationListener, sessionListener);
}
/**
* Test shutdown of the server with invalidateOnShutdown==true
* @throws Exception
*/
@Test
public void testInvalidateOnShutdown()
throws Exception
{
Server server = new Server();
ServletContextHandler context = new ServletContextHandler(ServletContextHandler.SESSIONS);
context.setContextPath("/test");
server.setHandler(context);
//flushOnResponseCommit is true
AbstractSessionCacheFactory cacheFactory = newSessionCacheFactory(SessionCache.NEVER_EVICT, false, false, false, true);
cacheFactory.setInvalidateOnShutdown(true);
SessionCache cache = cacheFactory.getSessionCache(context.getSessionHandler());
TestSessionDataStore store = new TestSessionDataStore(true); //fake a passivating store
cache.setSessionDataStore(store);
context.getSessionHandler().setSessionCache(cache);
TestHttpSessionListener sessionListener = new TestHttpSessionListener();
context.getSessionHandler().addEventListener(sessionListener);
server.start();
//Make a session in the store and cache and check that it is invalidated on shutdown
long now = System.currentTimeMillis();
SessionData data = store.newSessionData("8888", now - 20, now - 10, now - 20, TimeUnit.MINUTES.toMillis(10));
Session session = cache.newSession(data);
cache.add("8888", session);
TestSessionActivationListener activationListener = new TestSessionActivationListener();
session.setAttribute("aaa", activationListener);
cache.release("8888", session);
checkSessionBeforeShutdown("8888", store, cache, activationListener, sessionListener);
server.stop();
checkSessionAfterShutdown("8888", store, cache, activationListener, sessionListener);
}
public void commitAndCheckSaveState(SessionCache cache, TestSessionDataStore store, Session session, public void commitAndCheckSaveState(SessionCache cache, TestSessionDataStore store, Session session,
boolean expectedBeforeDirty, boolean expectedBeforeMetaDirty, boolean expectedBeforeDirty, boolean expectedBeforeMetaDirty,
@ -611,7 +715,7 @@ public abstract class AbstractSessionCacheTest
assertEquals(expectedAfterMetaDirty, session.getSessionData().isMetaDataDirty()); assertEquals(expectedAfterMetaDirty, session.getSessionData().isMetaDataDirty());
assertEquals(expectedAfterNumSaves, store._numSaves.get()); assertEquals(expectedAfterNumSaves, store._numSaves.get());
} }
public Session createUnExpiredSession(SessionCache cache, SessionDataStore store, String id) public Session createUnExpiredSession(SessionCache cache, SessionDataStore store, String id)
{ {
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();

View File

@ -20,7 +20,6 @@ package org.eclipse.jetty.server.session;
import java.util.Random; import java.util.Random;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import javax.servlet.http.HttpSession; import javax.servlet.http.HttpSession;
import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.Request;
@ -54,6 +53,42 @@ public class DefaultSessionCacheTest extends AbstractSessionCacheTest
factory.setFlushOnResponseCommit(flushOnResponseCommit); factory.setFlushOnResponseCommit(flushOnResponseCommit);
return factory; return factory;
} }
@Override
public void checkSessionBeforeShutdown(String id,
SessionDataStore store,
SessionCache cache,
TestSessionActivationListener activationListener,
TestHttpSessionListener sessionListener) throws Exception
{
assertTrue(store.exists(id));
assertTrue(cache.contains(id));
assertFalse(sessionListener.destroyedSessions.contains(id));
assertEquals(1, activationListener.passivateCalls);
assertEquals(1, activationListener.activateCalls);
}
@Override
public void checkSessionAfterShutdown(String id,
SessionDataStore store,
SessionCache cache,
TestSessionActivationListener activationListener,
TestHttpSessionListener sessionListener) throws Exception
{
if (cache.isInvalidateOnShutdown())
{
assertFalse(store.exists(id));
assertFalse(cache.contains(id));
assertTrue(sessionListener.destroyedSessions.contains(id));
}
else
{
assertTrue(store.exists(id));
assertFalse(cache.contains(id));
assertEquals(2, activationListener.passivateCalls);
assertEquals(1, activationListener.activateCalls); //no re-activate on shutdown
}
}
@Test @Test
public void testRenewWithInvalidate() throws Exception public void testRenewWithInvalidate() throws Exception
@ -182,25 +217,26 @@ public class DefaultSessionCacheTest extends AbstractSessionCacheTest
/** /**
* Test sessions are saved when shutdown with a store. * Test sessions are saved when shutdown with a store.
*/ */
@Test /* @Test
public void testShutdownWithSessionStore() public void testNoInvalidateOnShutdown()
throws Exception throws Exception
{ {
Server server = new Server(); Server server = new Server();
ServletContextHandler context = new ServletContextHandler(ServletContextHandler.SESSIONS); ServletContextHandler context = new ServletContextHandler(ServletContextHandler.SESSIONS);
context.setContextPath("/test"); context.setContextPath("/test");
context.setServer(server); context.setServer(server);
server.setHandler(context);
AbstractSessionCacheFactory cacheFactory = newSessionCacheFactory(SessionCache.NEVER_EVICT, false, false, false, false); AbstractSessionCacheFactory cacheFactory = newSessionCacheFactory(SessionCache.NEVER_EVICT, false, false, false, false);
DefaultSessionCache cache = (DefaultSessionCache)cacheFactory.getSessionCache(context.getSessionHandler()); DefaultSessionCache cache = (DefaultSessionCache)cacheFactory.getSessionCache(context.getSessionHandler());
TestSessionDataStore store = new TestSessionDataStore(true);//fake passivation TestSessionDataStore store = new TestSessionDataStore(true);//fake passivation
cache.setSessionDataStore(store); cache.setSessionDataStore(store);
context.getSessionHandler().setSessionCache(cache); context.getSessionHandler().setSessionCache(cache);
context.start(); server.start();
//put a session in the cache and store //put a session in the cache and store
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
SessionData data = store.newSessionData("1234", now - 20, now - 10, now - 20, TimeUnit.MINUTES.toMillis(10)); SessionData data = store.newSessionData("1234", now - 20, now - 10, now - 20, TimeUnit.MINUTES.toMillis(10));
@ -210,17 +246,18 @@ public class DefaultSessionCacheTest extends AbstractSessionCacheTest
assertTrue(cache.contains("1234")); assertTrue(cache.contains("1234"));
session.setAttribute("aaa", listener); session.setAttribute("aaa", listener);
cache.release("1234", session); cache.release("1234", session);
assertTrue(store.exists("1234")); assertTrue(store.exists("1234"));
assertTrue(cache.contains("1234")); assertTrue(cache.contains("1234"));
context.stop(); //calls shutdown server.stop(); //calls shutdown
assertTrue(store.exists("1234")); assertTrue(store.exists("1234"));
assertFalse(cache.contains("1234")); assertFalse(cache.contains("1234"));
assertEquals(2, listener.passivateCalls); assertEquals(2, listener.passivateCalls);
assertEquals(1, listener.activateCalls); assertEquals(1, listener.activateCalls);
} }
*/
/** /**
* Test that a session id can be renewed. * Test that a session id can be renewed.

View File

@ -45,54 +45,33 @@ public class NullSessionCacheTest extends AbstractSessionCacheTest
factory.setFlushOnResponseCommit(flushOnResponseCommit); factory.setFlushOnResponseCommit(flushOnResponseCommit);
return factory; return factory;
} }
@Test @Override
public void testShutdownWithSessionStore() public void checkSessionBeforeShutdown(String id,
throws Exception SessionDataStore store,
SessionCache cache,
TestSessionActivationListener activationListener,
TestHttpSessionListener sessionListener) throws Exception
{ {
Server server = new Server(); assertFalse(cache.contains(id)); //NullSessionCache never caches
assertTrue(store.exists(id));
assertFalse(sessionListener.destroyedSessions.contains(id));
assertEquals(1, activationListener.passivateCalls);
assertEquals(0, activationListener.activateCalls); //NullSessionCache always evicts on release, so never reactivates
}
ServletContextHandler context = new ServletContextHandler(ServletContextHandler.SESSIONS); @Override
context.setContextPath("/test"); public void checkSessionAfterShutdown(String id,
context.setServer(server); SessionDataStore store,
SessionCache cache,
AbstractSessionCacheFactory cacheFactory = newSessionCacheFactory(SessionCache.NEVER_EVICT, false, false, false, false); TestSessionActivationListener activationListener,
SessionCache cache = cacheFactory.getSessionCache(context.getSessionHandler()); TestHttpSessionListener sessionListener) throws Exception
{
TestSessionDataStore store = new TestSessionDataStore(true);//fake passivation assertFalse(cache.contains(id)); //NullSessionCache never caches
cache.setSessionDataStore(store); assertTrue(store.exists(id)); //NullSessionCache doesn't do anything on shutdown
context.getSessionHandler().setSessionCache(cache); assertFalse(sessionListener.destroyedSessions.contains(id)); //NullSessionCache does nothing on shutdown
assertEquals(1, activationListener.passivateCalls);
context.start(); assertEquals(0, activationListener.activateCalls); //NullSessionCache always evicts on release, so never reactivates
//put a session in the cache and store
long now = System.currentTimeMillis();
SessionData data = store.newSessionData("1234", now - 20, now - 10, now - 20, TimeUnit.MINUTES.toMillis(10));
Session session = cache.newSession(data);
TestSessionActivationListener listener = new TestSessionActivationListener();
cache.add("1234", session);
//cache never contains the session
assertFalse(cache.contains("1234"));
session.setAttribute("aaa", listener);
//write session out on release
cache.release("1234", session);
assertEquals(1, store._numSaves.get());
assertEquals(1, listener.passivateCalls);
assertEquals(0, listener.activateCalls); //NullSessionCache always evicts on release, so never reactivates
assertTrue(store.exists("1234"));
//cache never contains session
assertFalse(cache.contains("1234"));
context.stop(); //calls shutdown
//session should still exist in store
assertTrue(store.exists("1234"));
//cache never contains the session
assertFalse(cache.contains("1234"));
//shutdown does not save session
assertEquals(1, listener.passivateCalls);
assertEquals(0, listener.activateCalls);
} }
@Test @Test