/*
* Copyright 2011 Martin Grotzke
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package de.javakaffee.web.msm;
import static de.javakaffee.web.msm.MemcachedUtil.toMemcachedExpiration;
import static de.javakaffee.web.msm.SessionValidityInfo.decode;
import static de.javakaffee.web.msm.SessionValidityInfo.encode;
import static de.javakaffee.web.msm.Statistics.StatsType.*;
import static java.lang.Math.min;
import static java.lang.Thread.sleep;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.regex.Pattern;
import javax.annotation.CheckForNull;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import org.apache.juli.logging.Log;
import org.apache.juli.logging.LogFactory;
import de.javakaffee.web.msm.BackupSessionTask.BackupResult;
import de.javakaffee.web.msm.MemcachedSessionService.LockStatus;
import de.javakaffee.web.msm.storage.StorageClient;
/**
* Represents the session locking hooks that must be implemented by the various locking strategies.
*
* @author <a href="mailto:martin.grotzke@javakaffee.de">Martin Grotzke</a>
*/
public abstract class LockingStrategy {
public static enum LockingMode {
/** Sessions are never locked. */
NONE,
/** Sessions are locked for each request. */
ALL,
/** Readonly requests are tracked and for requests that modify the session the session is locked. */
AUTO,
/** The application explicitely manages locks */
APP,
/** The session is locked for configured request patterns **/
URI_PATTERN
}
protected static final byte[] LOCK_VALUE = new byte[] { 'l', 'o', 'c', 'k', 'e', 'd' };
protected static final byte[] BYTE_1 = new byte[] { 1 };
protected static final int LOCK_RETRY_INTERVAL = 10;
protected static final int LOCK_MAX_RETRY_INTERVAL = 500;
protected final Log _log = LogFactory.getLog( getClass() );
protected MemcachedSessionService _manager;
protected final StorageClient _storage;
protected LRUCache<String, Boolean> _missingSessionsCache;
protected final SessionIdFormat _sessionIdFormat;
private final ExecutorService _executor;
private final boolean _storeSecondaryBackup;
protected final Statistics _stats;
protected final CurrentRequest _currentRequest;
protected final StorageKeyFormat _storageKeyFormat;
protected LockingStrategy( @Nonnull final MemcachedSessionService manager,
@Nonnull final MemcachedNodesManager memcachedNodesManager,
@Nonnull final StorageClient storage,
@Nonnull final LRUCache<String, Boolean> missingSessionsCache, final boolean storeSecondaryBackup,
@Nonnull final Statistics stats,
@Nonnull final CurrentRequest currentRequest ) {
_manager = manager;
_storage = storage;
_missingSessionsCache = missingSessionsCache;
_sessionIdFormat = memcachedNodesManager.getSessionIdFormat();
_storeSecondaryBackup = storeSecondaryBackup;
_stats = stats;
_currentRequest = currentRequest;
_storageKeyFormat = memcachedNodesManager.getStorageKeyFormat();
_executor = Executors.newFixedThreadPool( Runtime.getRuntime().availableProcessors(), new NamedThreadFactory("msm-2ndary-backup") );
}
/**
* Creates the appropriate {@link LockingStrategy} for the given {@link LockingMode}.
*/
@CheckForNull
public static LockingStrategy create( @Nullable final LockingMode lockingMode, @Nullable final Pattern uriPattern,
@Nullable final StorageClient storage, @Nonnull final MemcachedSessionService manager,
@Nonnull final MemcachedNodesManager memcachedNodesManager,
@Nonnull final LRUCache<String, Boolean> missingSessionsCache, final boolean storeSecondaryBackup,
@Nonnull final Statistics stats,
@Nonnull final CurrentRequest currentRequest ) {
if ( lockingMode == null || storage == null ) {
return null;
}
switch ( lockingMode ) {
case ALL:
return new LockingStrategyAll( manager, memcachedNodesManager, storage, missingSessionsCache, storeSecondaryBackup, stats, currentRequest );
case AUTO:
return new LockingStrategyAuto( manager, memcachedNodesManager, storage, missingSessionsCache, storeSecondaryBackup, stats, currentRequest );
case URI_PATTERN:
return new LockingStrategyUriPattern( manager, memcachedNodesManager, uriPattern, storage, missingSessionsCache, storeSecondaryBackup,
stats, currentRequest );
case NONE:
return new LockingStrategyNone( manager, memcachedNodesManager, storage, missingSessionsCache, storeSecondaryBackup, stats, currentRequest );
default:
throw new IllegalArgumentException( "LockingMode not yet supported: " + lockingMode );
}
}
/**
* Shutdown this lockingStrategy, which frees all resources / releases threads.
*/
public void shutdown() {
_executor.shutdown();
}
protected LockStatus lock( final String sessionId ) {
return lock( sessionId, _manager.getOperationTimeout(), TimeUnit.MILLISECONDS );
}
protected LockStatus lock( final String sessionId, final long timeout, final TimeUnit timeUnit ) {
if ( _log.isDebugEnabled() ) {
_log.debug( "Locking session " + sessionId );
}
final long start = System.currentTimeMillis();
try {
acquireLock( sessionId, LOCK_RETRY_INTERVAL, LOCK_MAX_RETRY_INTERVAL, timeUnit.toMillis( timeout ),
System.currentTimeMillis() );
_stats.registerSince( ACQUIRE_LOCK, start );
if ( _log.isDebugEnabled() ) {
_log.debug( "Locked session " + sessionId );
}
return LockStatus.LOCKED;
} catch ( final TimeoutException e ) {
_log.warn( "Reached timeout when trying to aquire lock for session " + sessionId
+ ". Will use this session without this lock." );
_stats.registerSince( ACQUIRE_LOCK_FAILURE, start );
return LockStatus.COULD_NOT_AQUIRE_LOCK;
} catch ( final InterruptedException e ) {
Thread.currentThread().interrupt();
throw new RuntimeException( "Got interrupted while trying to lock session.", e );
} catch ( final ExecutionException e ) {
_log.warn( "An exception occurred when trying to aquire lock for session " + sessionId );
_stats.registerSince( ACQUIRE_LOCK_FAILURE, start );
return LockStatus.COULD_NOT_AQUIRE_LOCK;
}
}
protected void acquireLock( @Nonnull final String sessionId, final long retryInterval, final long maxRetryInterval,
final long timeout, final long start ) throws InterruptedException, ExecutionException, TimeoutException {
final Future<Boolean> result = _storage.add( _sessionIdFormat.createLockName( sessionId ), _manager.getLockExpiration(), LOCK_VALUE );
if ( result.get().booleanValue() ) {
if ( _log.isDebugEnabled() ) {
_log.debug( "Locked session " + sessionId );
}
return;
}
else {
checkTimeoutAndWait( sessionId, retryInterval, timeout, start );
acquireLock( sessionId, min( retryInterval * 2, maxRetryInterval ), maxRetryInterval, timeout, start );
}
}
protected void checkTimeoutAndWait( @Nonnull final String sessionId, final long timeToWait,
final long timeout, final long start ) throws TimeoutException,
InterruptedException {
if ( System.currentTimeMillis() >= start + timeout ) {
throw new TimeoutException( "Reached timeout when trying to aquire lock for session " + sessionId );
}
if ( _log.isDebugEnabled() ) {
_log.debug( "Could not aquire lock for session " + sessionId + ", waiting " + timeToWait + " millis now..." );
}
sleep( timeToWait );
}
protected void releaseLock( @Nonnull final String sessionId ) {
try {
if ( _log.isDebugEnabled() ) {
_log.debug( "Releasing lock for session " + sessionId );
}
final long start = System.currentTimeMillis();
_storage.delete( _sessionIdFormat.createLockName( sessionId ) ).get();
_stats.registerSince( RELEASE_LOCK, start );
} catch ( final Exception e ) {
_log.warn( "Caught exception when trying to release lock for session " + sessionId, e );
}
}
/**
* Used to register the given requestId as readonly request (for mode auto), so that further
* requests like this won't acquire the lock.
* @param requestId the uri/id of the request for that the session backup shall be performed, used for readonly tracking.
*/
public void registerReadonlyRequest(final String requestId) {
// default empty
}
/**
* Is invoked for the backup of a non-sticky session that was not accessed for the current request.
*/
protected void onBackupWithoutLoadedSession( @Nonnull final String sessionId, @Nonnull final String requestId,
@Nonnull final BackupSessionService backupSessionService ) {
if ( !_sessionIdFormat.isValid( sessionId ) ) {
return;
}
try {
final long start = System.currentTimeMillis();
final String validityKey = _sessionIdFormat.createValidityInfoKeyName( sessionId );
final SessionValidityInfo validityInfo = loadSessionValidityInfoForValidityKey( validityKey );
if ( validityInfo == null ) {
_log.warn( "Found no validity info for session id " + sessionId );
return;
}
final int maxInactiveInterval = validityInfo.getMaxInactiveInterval();
final byte[] validityData = encode( maxInactiveInterval, System.currentTimeMillis(),
System.currentTimeMillis() );
// fix for #88, along with the change in session.getMemcachedExpirationTimeToSet
final int expiration = maxInactiveInterval <= 0 ? 0 : maxInactiveInterval;
final Future<Boolean> validityResult = _storage.set( validityKey, toMemcachedExpiration(expiration), validityData );
if ( !_manager.isSessionBackupAsync() ) {
validityResult.get( _manager.getSessionBackupTimeout(), TimeUnit.MILLISECONDS );
}
/*
* - ping session
* - ping session backup
* - save validity backup
*/
final Callable<?> backupSessionTask = new OnBackupWithoutLoadedSessionTask( sessionId,
_storeSecondaryBackup, validityKey, validityData, maxInactiveInterval );
_executor.submit( backupSessionTask );
if ( _log.isDebugEnabled() ) {
_log.debug( "Stored session validity info for session " + sessionId );
}
_stats.registerSince( NON_STICKY_ON_BACKUP_WITHOUT_LOADED_SESSION, start );
} catch( final Throwable e ) {
_log.warn( "An error when trying to load/update validity info.", e );
}
}
/**
* Is invoked after the backup of the session is initiated, it's represented by the provided backupResult. The
* requestId is identifying the request.
*/
protected void onAfterBackupSession( @Nonnull final MemcachedBackupSession session, final boolean backupWasForced,
@Nonnull final Future<BackupResult> result, @Nonnull final String requestId,
@Nonnull final BackupSessionService backupSessionService ) {
if ( !_sessionIdFormat.isValid( session.getIdInternal() ) ) {
return;
}
try {
final long start = System.currentTimeMillis();
final int maxInactiveInterval = session.getMaxInactiveInterval();
final byte[] validityData = encode( maxInactiveInterval, session.getLastAccessedTimeInternal(),
session.getThisAccessedTimeInternal() );
final String validityKey = _sessionIdFormat.createValidityInfoKeyName( session.getIdInternal() );
// fix for #88, along with the change in session.getMemcachedExpirationTimeToSet
final int expiration = maxInactiveInterval <= 0 ? 0 : maxInactiveInterval;
final Future<Boolean> validityResult = _storage.set( validityKey, toMemcachedExpiration(expiration), validityData );
if ( !_manager.isSessionBackupAsync() ) {
// TODO: together with session backup wait not longer than sessionBackupTimeout.
// Details: Now/here we're waiting the whole session backup timeout, even if (perhaps) some time
// was spent before when waiting for session backup result.
// For sync session backup it would be better to set both the session data and
// validity info and afterwards wait for both results (but in sum no longer than sessionBackupTimeout)
validityResult.get( _manager.getSessionBackupTimeout(), TimeUnit.MILLISECONDS );
}
if ( _log.isDebugEnabled() ) {
_log.debug( "Stored session validity info for session " + session.getIdInternal() );
}
/* The following task are performed outside of the request thread (includes waiting for the backup result):
* - ping session if the backup was skipped (depends on the backup result)
* - save secondary session backup if session was modified (backup not skipped)
* - ping secondary session backup if the backup was skipped
* - save secondary validity backup
*/
final boolean pingSessionIfBackupWasSkipped = !backupWasForced;
final boolean performAsyncTasks = pingSessionIfBackupWasSkipped || _storeSecondaryBackup;
if ( performAsyncTasks ) {
final Callable<?> backupSessionTask = new OnAfterBackupSessionTask( session, result,
pingSessionIfBackupWasSkipped, backupSessionService, _storeSecondaryBackup, validityKey, validityData );
_executor.submit( backupSessionTask );
}
_stats.registerSince( NON_STICKY_AFTER_BACKUP, start );
} catch( final Throwable e ) {
_log.warn( "An error occurred during onAfterBackupSession.", e );
}
}
@CheckForNull
protected SessionValidityInfo loadSessionValidityInfo( @Nonnull final String sessionId ) {
return loadSessionValidityInfoForValidityKey( _sessionIdFormat.createValidityInfoKeyName( sessionId ) );
}
@CheckForNull
protected SessionValidityInfo loadSessionValidityInfoForValidityKey( @Nonnull final String validityInfoKey ) {
final byte[] validityInfo = _storage.get( validityInfoKey );
return validityInfo != null ? decode( validityInfo ) : null;
}
@CheckForNull
protected SessionValidityInfo loadBackupSessionValidityInfo( @Nonnull final String sessionId ) {
final String key = _sessionIdFormat.createValidityInfoKeyName( sessionId );
final String backupKey = _sessionIdFormat.createBackupKey( key );
return loadSessionValidityInfoForValidityKey( backupKey );
}
/**
* Invoked before the session for this sessionId is loaded from memcached.
*/
@CheckForNull
protected abstract LockStatus onBeforeLoadFromMemcached( @Nonnull String sessionId ) throws InterruptedException,
ExecutionException;
/**
* Invoked after a non-sticky session is loaded from memcached, can be used to update some session fields based on
* separately stored information (e.g. session validity info).
*
* @param lockStatus
* the {@link LockStatus} that was returned from {@link #onBeforeLoadFromMemcached(String)}.
*/
protected void onAfterLoadFromMemcached( @Nonnull final MemcachedBackupSession session,
@Nullable final LockStatus lockStatus ) {
session.setLockStatus( lockStatus );
final long start = System.currentTimeMillis();
final SessionValidityInfo info = loadSessionValidityInfo( session.getIdInternal() );
if ( info != null ) {
_stats.registerSince( NON_STICKY_AFTER_LOAD_FROM_MEMCACHED, start );
session.setLastAccessedTimeInternal( info.getLastAccessedTime() );
session.setThisAccessedTimeInternal( info.getThisAccessedTime() );
}
else {
_log.warn( "No validity info available for session " + session.getIdInternal() );
}
}
/**
* Invoked after a non-sticky session is removed from memcached.
*/
protected void onAfterDeleteFromMemcached( @Nonnull final String sessionId ) {
final long start = System.currentTimeMillis();
final String validityInfoKey = _sessionIdFormat.createValidityInfoKeyName( sessionId );
_storage.delete( validityInfoKey );
if (_storeSecondaryBackup) {
try {
_storage.delete(_sessionIdFormat.createBackupKey(sessionId));
_storage.delete(_sessionIdFormat.createBackupKey(validityInfoKey));
} catch (Exception e) {
_log.info("Could not delete backup data for session " + sessionId + " (not critical, data will be evicted by memcached automatically).", e);
}
}
_stats.registerSince( NON_STICKY_AFTER_DELETE_FROM_MEMCACHED, start );
}
private boolean pingSession( @Nonnull final String sessionId ) throws InterruptedException {
final Future<Boolean> touchResult = _storage.add( _storageKeyFormat.format(sessionId), 1, BYTE_1 );
try {
if ( touchResult.get() ) {
_stats.nonStickySessionsPingFailed();
_log.warn( "The session " + sessionId
+ " should be touched in memcached, but it does not exist therein." );
return false;
}
_log.debug( "The session was ping'ed successfully." );
return true;
} catch ( final ExecutionException e ) {
_log.warn( "An exception occurred when trying to ping session " + sessionId, e );
return false;
}
}
private void pingSession( @Nonnull final MemcachedBackupSession session,
@Nonnull final BackupSessionService backupSessionService ) throws InterruptedException {
final Future<Boolean> touchResult = _storage.add( _storageKeyFormat.format(session.getIdInternal()), 5, BYTE_1 );
try {
if ( touchResult.get() ) {
_stats.nonStickySessionsPingFailed();
_log.warn( "The session " + session.getIdInternal()
+ " should be touched in memcached, but it does not exist"
+ " therein. Will store in memcached again." );
updateSession( session, backupSessionService );
}
else
_log.debug( "The session was ping'ed successfully." );
} catch ( final ExecutionException e ) {
_log.warn( "An exception occurred when trying to ping session " + session.getIdInternal(), e );
}
}
private void updateSession( @Nonnull final MemcachedBackupSession session,
@Nonnull final BackupSessionService backupSessionService ) throws InterruptedException {
final Future<BackupResult> result = backupSessionService.backupSession( session, true );
try {
if ( result.get().getStatus() != BackupResultStatus.SUCCESS ) {
_log.warn( "Update for session (after unsuccessful ping) did not return SUCCESS, but " + result.get() );
}
} catch ( final ExecutionException e ) {
_log.warn( "An exception occurred when trying to update session " + session.getIdInternal(), e );
}
}
private final class OnAfterBackupSessionTask implements Callable<Void> {
private final MemcachedBackupSession _session;
private final Future<BackupResult> _result;
private final boolean _pingSessionIfBackupWasSkipped;
private final boolean _storeSecondaryBackup;
private final BackupSessionService _backupSessionService;
private final String _validityKey;
private final byte[] _validityData;
private OnAfterBackupSessionTask( @Nonnull final MemcachedBackupSession session, @Nonnull final Future<BackupResult> result,
final boolean pingSessionIfBackupWasSkipped,
@Nonnull final BackupSessionService backupSessionService,
final boolean storeSecondaryBackup,
@Nonnull final String validityKey,
@Nonnull final byte[] validityData ) {
_session = session;
_result = result;
_pingSessionIfBackupWasSkipped = pingSessionIfBackupWasSkipped;
_storeSecondaryBackup = storeSecondaryBackup;
_validityKey = validityKey;
_validityData = validityData;
_backupSessionService = backupSessionService;
}
@Override
public Void call() throws Exception {
final BackupResult backupResult = _result.get();
if ( _pingSessionIfBackupWasSkipped ) {
if ( backupResult.getStatus() == BackupResultStatus.SKIPPED ) {
pingSession( _session, _backupSessionService );
}
}
/*
* For non-sticky sessions we store a backup of the session in a secondary memcached node (under a special key
* that's resolved by the SuffixBasedNodeLocator), but only when we have more than 1 memcached node configured...
*/
if ( _storeSecondaryBackup ) {
try {
if ( _log.isDebugEnabled() ) {
_log.debug( "Storing backup in secondary memcached for non-sticky session " + _session.getId() );
}
if ( backupResult.getStatus() == BackupResultStatus.SKIPPED ) {
pingSessionBackup( _session );
}
else {
saveSessionBackupFromResult( backupResult );
}
saveValidityBackup();
} catch( final RuntimeException e ) {
_log.info( "Could not store secondary backup of session " + _session.getIdInternal(), e );
}
}
return null;
}
public void saveSessionBackupFromResult( final BackupResult backupResult ) {
final byte[] data = backupResult.getData();
if ( data != null ) {
final String key = _sessionIdFormat.createBackupKey( _session.getId() );
_storage.set( key, toMemcachedExpiration(_session.getMemcachedExpirationTimeToSet()), data );
}
else {
_log.warn( "No data set for backupResultStatus " + backupResult.getStatus() + " for sessionId "
+ _session.getIdInternal() + ", skipping backup"
+ " of non-sticky session in secondary memcached." );
}
}
public void saveValidityBackup() {
final String backupValidityKey = _sessionIdFormat.createBackupKey( _validityKey );
final int maxInactiveInterval = _session.getMaxInactiveInterval();
// fix for #88, along with the change in session.getMemcachedExpirationTimeToSet
final int expiration = maxInactiveInterval <= 0 ? 0 : maxInactiveInterval;
_storage.set( backupValidityKey, toMemcachedExpiration(expiration), _validityData );
}
private void pingSessionBackup( @Nonnull final MemcachedBackupSession session ) throws InterruptedException {
final String key = _sessionIdFormat.createBackupKey( session.getId() );
final Future<Boolean> touchResultFuture = _storage.add( key, 5, BYTE_1 );
try {
final boolean touchResult = touchResultFuture.get(_manager.getOperationTimeout(), TimeUnit.MILLISECONDS);
if ( touchResult ) {
_log.warn( "The secondary backup for session " + session.getIdInternal()
+ " should be touched in memcached, but it seemed to be"
+ " not existing. Will store in memcached again." );
saveSessionBackup( session, key );
}
else
_log.debug( "The secondary session backup was ping'ed successfully." );
} catch ( final TimeoutException e ) {
_log.warn( "The secondary backup for session " + session.getIdInternal()
+ " could not be completed within " + _manager.getOperationTimeout() + " millis, was cancelled now." );
} catch ( final ExecutionException e ) {
_log.warn( "An exception occurred when trying to ping session " + session.getIdInternal(), e );
}
}
public void saveSessionBackup( @Nonnull final MemcachedBackupSession session, @Nonnull final String key )
throws InterruptedException {
try {
final byte[] data = _manager.serialize( session );
final Future<Boolean> backupResult = _storage.set( key, toMemcachedExpiration(session.getMemcachedExpirationTimeToSet()), data );
if ( !backupResult.get().booleanValue() ) {
_log.warn( "Update for secondary backup of session "+ session.getIdInternal() +" (after unsuccessful ping) did not return sucess." );
}
} catch ( final ExecutionException e ) {
_log.warn( "An exception occurred when trying to update secondary session backup for " + session.getIdInternal(), e );
}
}
}
private final class OnBackupWithoutLoadedSessionTask implements Callable<Void> {
private final String _sessionId;
private final boolean _storeSecondaryBackup;
private final String _validityKey;
private final byte[] _validityData;
private final int _maxInactiveInterval;
private OnBackupWithoutLoadedSessionTask( @Nonnull final String sessionId,
final boolean storeSecondaryBackup,
@Nonnull final String validityKey,
@Nonnull final byte[] validityData,
final int maxInactiveInterval ) {
_sessionId = sessionId;
_storeSecondaryBackup = storeSecondaryBackup;
_validityKey = validityKey;
_validityData = validityData;
_maxInactiveInterval = maxInactiveInterval;
}
@Override
public Void call() throws Exception {
pingSession( _sessionId );
/*
* For non-sticky sessions we store/ping a backup of the session in a secondary memcached node (under a special key
* that's resolved by the SuffixBasedNodeLocator), but only when we have more than 1 memcached node configured...
*/
if ( _storeSecondaryBackup ) {
try {
pingSessionBackup( _sessionId );
final String backupValidityKey = _sessionIdFormat.createBackupKey( _validityKey );
// fix for #88, along with the change in session.getMemcachedExpirationTimeToSet
final int expiration = _maxInactiveInterval <= 0 ? 0 : _maxInactiveInterval;
_storage.set( backupValidityKey, toMemcachedExpiration(expiration), _validityData );
} catch( final RuntimeException e ) {
_log.info( "Could not store secondary backup of session " + _sessionId, e );
}
}
return null;
}
private boolean pingSessionBackup( @Nonnull final String sessionId ) throws InterruptedException {
final String key = _sessionIdFormat.createBackupKey( sessionId );
final Future<Boolean> touchResultFuture = _storage.add( key, 1, BYTE_1 );
try {
final boolean touchResult = touchResultFuture.get(200, TimeUnit.MILLISECONDS);
if ( touchResult ) {
_log.warn( "The secondary backup for session " + sessionId
+ " should be touched in memcached, but it seemed to be"
+ " not existing." );
return false;
}
_log.debug( "The secondary session backup was ping'ed successfully." );
return true;
} catch ( final TimeoutException e ) {
_log.warn( "The secondary backup for session " + sessionId
+ " could not be completed within 200 millis, was cancelled now." );
return false;
} catch ( final ExecutionException e ) {
_log.warn( "An exception occurred when trying to ping session " + sessionId, e );
return false;
}
}
}
// ---------------- for testing
@Nonnull
ExecutorService getExecutorService() {
return _executor;
}
}