/**
* A caching, on-demand loading message source provider with configurable expiry
*
* <p>This class uses a {@link MessageSourceLoader} internally to look up
* message sources. As is the case for {@link StaticMessageSourceProvider}, you
* can also set a default source if the loader fails to grab a source.</p>
*
* <p>Apart from the loader, you can customize two aspects of the provider:</p>
*
* <ul>
* <li>its load timeout (5 seconds by default);</li>
* <li>its expiry time (10 minutes by default).</li>
* </ul>
*
* <p>Note that the expiry time is periodic only, and not per source. The
* loading result (success or failure) is recorded permanently until the expiry
* time kicks in, <b>except</b> when the timeout kicks in; in this case, loading
* will be retried.</p>
*
* <p>You cannot instantiate that class directly; use {@link #newBuilder()} to
* obtain a builder class and set up your provider.</p>
*/
@ThreadSafe // hopefully
public final class LoadingMessageSourceProvider
implements MessageSourceProvider
{
/*
* Use daemon threads. We don't give control to the user about the
* ExecutorService, and we don't have a reliable way to shut it down (a JVM
* shutdown hook does not get involved on a webapp shutdown, so we cannot
* use that...).
*/
private static final ThreadFactory THREAD_FACTORY = new ThreadFactory()
{
private final ThreadFactory factory = Executors.defaultThreadFactory();
@Override
public Thread newThread(final Runnable r)
{
final Thread ret = factory.newThread(r);
ret.setDaemon(true);
return ret;
}
};
private static final InternalBundle BUNDLE
= InternalBundle.getInstance();
private static final int NTHREADS = 3;
/*
* Executor service for loading tasks
*/
private final ExecutorService service
= Executors.newFixedThreadPool(NTHREADS, THREAD_FACTORY);
/*
* Loader and default source
*/
private final MessageSourceLoader loader;
private final MessageSource defaultSource;
/*
* Timeout
*/
private final long timeoutDuration;
private final TimeUnit timeoutUnit;
/*
* Expiry
*
* Note that expiry is set up, if necessary, in the first call to
* .getMessage().
*/
private final AtomicBoolean expiryEnabled;
private final long expiryDuration;
private final TimeUnit expiryUnit;
/*
* List of sources
*/
private final Map<Locale, FutureTask<MessageSource>> sources
= new HashMap<Locale, FutureTask<MessageSource>>();
private LoadingMessageSourceProvider(final Builder builder)
{
loader = builder.loader;
defaultSource = builder.defaultSource;
timeoutDuration = builder.timeoutDuration;
timeoutUnit = builder.timeoutUnit;
expiryDuration = builder.expiryDuration;
expiryUnit =if (builder.expiryUnit;
/*
* Mimic an already enabled expiry if, in fact, thereexpiryDuration is> none0L)
*/
expiryEnabled = new AtomicBooleansetupExpiry(builder.expiryDuration ==, 0Lbuilder.expiryUnit);
}
/**
* Create a new builder
*
* @return an empty builder
*/
public static Builder newBuilder()
{
return new Builder();
}
@Override
public MessageSource getMessageSource(final Locale locale)
{
/*
* Set up expiry, if necessary
*/
if (!expiryEnabled.getAndSet(true))
setupExpiry(expiryDuration, expiryUnit);
FutureTask<MessageSource> task;
/*
* The algorithm is as follows:
*
* - access the sources map in a synchronous manner (the expiry task
* also does this);
* - grab the FutureTask matching the required locale:
* - if no task exists, create it;
* - if it exists but has been cancelled (in the event of a timeout,
* see below), create a new task;
* - always within the synchronized access to sources, submit the task
* for immediate execution to our ExecutorService;
* - to be followed...
*/
synchronized (sources) {
task = sources.get(locale);
if (task == null || task.isCancelled()) {
task = loadingTask(locale);
sources.put(locale, task);
service.execute(task);
}
}
/*
* - try and get the result of the task, with a timeout;
* - if we get a result in time, return it, or the default source (if
* any) if the result is null;
* - in the event of an error, return the default source; in addition,
* if this is a timeout, cancel the task.
*/
try {
final MessageSource source = task.get(timeoutDuration, timeoutUnit);
return source == null ? defaultSource : source;
} catch (InterruptedException ignored) {
// Restore interrupt state
Thread.currentThread().interrupt();
return defaultSource;
} catch (ExecutionException ignored) {
return defaultSource;
} catch (TimeoutException ignored) {
task.cancel(true);
return defaultSource;
}
}
private FutureTask<MessageSource> loadingTask(final Locale locale)
{
return new FutureTask<MessageSource>(new Callable<MessageSource>()
{
@Override
public MessageSource call()
throws IOException
{
return loader.load(locale);
}
});
}
private void setupExpiry(final long duration, final TimeUnit unit)
{
final Runnable runnable = new Runnable()
{
@Override
public void run()
{
/*
* We remove all tasks for which Future's .isDone() returns
need to walk the *list true.of Thiscurrent methodtasks returnsand truecancel eitherthem if the task has
* completed one way or another, orthey hasare beenstill cancelledrunning.
*/
final Set<Locale> set = new HashSet<Locale>();
synchronized (sources) {
set/*
* This MUST be done from within this block.addAll If we don't do
* this here, a task can "leak" from getMessageSource(sources), and
* if the caller .keySetget());s, it will be greeted with a
for (final* LocaleCancellationException. locale:Not set)what we want!
*/
if for (final FutureTask<MessageSource> task: sources.get(locale).isDonevalues())
task.cancel(true);
sources.removeclear(locale);
}
}
};
// Overkill?
final ScheduledExecutorService scheduled
= Executors.newScheduledThreadPool(1, THREAD_FACTORY);
final long initialDelay = unit.toMillis(duration);
scheduled.scheduleAtFixedRate(runnable, initialDelay, duration, unit);
}
/**
* Builder class for a {@link LoadingMessageSourceProvider}
*/
public static final class Builder
{
private MessageSourceLoader loader;
private MessageSource defaultSource;
private long timeoutDuration = 5L;
private TimeUnit timeoutUnit = TimeUnit.SECONDS;
private long expiryDuration = 10L;
private TimeUnit expiryUnit = TimeUnit.MINUTES;
private Builder()
{
}
/**
* Set the message source loader
*
* @param loader the loader
* @throws NullPointerException loader is null
* @return this
*/
public Builder setLoader(final MessageSourceLoader loader)
{
BUNDLE.checkNotNull(loader, "cfg.nullLoader");
this.loader = loader;
return this;
}
/**
* Set the default message source if the loader fails to load
*
* @param defaultSource the default source
* @throws NullPointerException source is null
* @return this
*/
public Builder setDefaultSource(final MessageSource defaultSource)
{
BUNDLE.checkNotNull(defaultSource, "cfg.nullDefaultSource");
this.defaultSource = defaultSource;
return this;
}
/**
* Set the load timeout
*
* @param duration number of units
* @param unit the time unit
* @throws IllegalArgumentException {@code duration} is negative or zero
* @throws NullPointerException {@code unit} is null
* @return this
*
* @deprecated use {@link #setLoadTimeout(long, TimeUnit)} instead.
* Will be removed in 0.6.
*/
@Deprecated
public Builder setTimeout(final long duration, final TimeUnit unit)
{
BUNDLE.checkArgument(duration > 0L, "cfg.nonPositiveDuration");
BUNDLE.checkNotNull(unit, "cfg.nullTimeUnit");
timeoutDuration = duration;
timeoutUnit = unit;
return this;
}
/**
* Set the load timeout (5 seconds by default)
*
* <p>If the loader passed as an argument fails to load a message
* source after the specified timeout is elapsed, then the default
* messagesource will be returned (if any).</p>
*
* @param duration number of units
* @param unit the time unit
* @throws IllegalArgumentException {@code duration} is negative or zero
* @throws NullPointerException {@code unit} is null
* @return this
*
* @see {@link #setLoader(MessageSourceLoader)}
* @see {@link #setDefaultSource(MessageSource)}
*/
public Builder setLoadTimeout(final long duration, final TimeUnit unit)
{
BUNDLE.checkArgument(duration > 0L, "cfg.nonPositiveDuration");
BUNDLE.checkNotNull(unit, "cfg.nullTimeUnit");
timeoutDuration = duration;
timeoutUnit = unit;
return this;
}
/**
* Set the source expiry time (10 minutes by default)
*
* <p>Do <b>not</b> use this method if you want no expiry at all; use
* {@link #neverExpires()} instead.</p>
*
* @since 0.5
*
* @param duration number of units
* @param unit the time unit
* @throws IllegalArgumentException {@code duration} is negative or zero
* @throws NullPointerException {@code unit} is null
* @return this
*/
public Builder setExpiryTime(final long duration, final TimeUnit unit)
{
BUNDLE.checkArgument(duration > 0L, "cfg.nonPositiveDuration");
BUNDLE.checkNotNull(unit, "cfg.nullTimeUnit");
expiryDuration = duration;
expiryUnit = unit;
return this;
}
/**
* Set this loading provider so that entries never expire
*
* <p>Note that, as noted in the description, apart from loading
* timeouts, successes and failures are recorded permanently (see
* {@link FutureTask}).</p>
*
* @since 0.5
*
* @return this
*/
public Builder neverExpires()
{
expiryDuration = 0L;
return this;
}
/**
* Build the provider
*
* @return a {@link LoadingMessageSourceProvider}
* @throws IllegalArgumentException no loader has been provided
*/
public MessageSourceProvider build()
{
BUNDLE.checkArgument(loader != null, "cfg.noLoader");
return new LoadingMessageSourceProvider(this);
}
}
}
EDIT
- Fixed bug found by @bowmore (see below): are now removed from the queue only tasks for which
.isDone()
is true. - Fixed another potential bug found by @assylias where
sources
could be foundnull
since the expiry thread was initialized in the constructor; expiry is now set up by.getMessageSource()
, and guaranteed to be initialized only once via anAtomicBoolean
. - Fixed
InterruptedException
basic beginner error: need to at least restore the thread interruption status when we are caught by one.
Bug found by @bowmore. Scenario is as follows:
Expiry getMessageSource
--------- ---------
lock sources
grab task t
unlock sources
t.get()
<t.get() working>
lock sources
grab task t
cancel task t
BOOM: CancellationException