package er.extensions.appserver;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.CancellationException;
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 org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.webobjects.appserver.WOApplication;
import com.webobjects.appserver.WORequest;
import com.webobjects.appserver.WORequestHandler;
import com.webobjects.appserver.WOResponse;
import com.webobjects.foundation.NSArray;
import com.webobjects.foundation.NSForwardException;
import com.webobjects.foundation.NSMutableArray;
import com.webobjects.foundation.NSTimestamp;
import er.extensions.foundation.ERXExpiringCache;
import er.extensions.foundation.ERXRuntimeUtilities;
/**
* When this request handler is set via <code>registerRequestHandlerForKey(new
* ERXDelayedRequestHandler(), ERXDelayedRequestHandler.KEY)</code>,
* then a request that takes too long is automatically detached and a poor man's
* long response is returned. It is pretty cool in that:
* <ul>
* <li>the users don't get the adaptor timeout and won't get redirected to an
* instance that doesn't know anything about the session.</li>
* <li>the users get immediate feedback with no code changes on your part.</li>
* <li>the handler tries to cancels active requests that take too long (default
* is maxRequestTimeSeconds*5), which should mean no more session deadlocks.</li>
* <li>you can subclass this handler to provide for better responses.</li>
* <li>you can provide a simple style sheet for the default refresh page.</li>
* </ul>
*
* @author ak
*/
public class ERXDelayedRequestHandler extends WORequestHandler {
private static final Logger log = LoggerFactory.getLogger(ERXDelayedRequestHandler.class);
public static String KEY = "_edr_";
private ERXExpiringCache<String, DelayedRequest> _futures;
private ERXExpiringCache<String, String> _urls;
private ExecutorService _executor;
private String _cssUrl;
private int _refreshTimeSeconds;
private int _maxRequestTimeMillis;
/**
* Helper to wrap a future and the accompanying request.
*
* @author ak
*
*/
public class DelayedRequest implements Callable<WOResponse> {
protected WORequest _request;
protected Future<WOResponse> _future;
protected String _id;
protected NSTimestamp _start;
private volatile Thread _currentThread;
public DelayedRequest(WORequest request) {
super();
_request = WOApplication.application().createRequest(request.method(), request.uri(), request.httpVersion(), request.headers(), request.content(), request.userInfo());
// _request = (WORequest) request.clone();
_future = _executor.submit(this);
_id = UUID.randomUUID().toString();
_start = new NSTimestamp();
}
public WOResponse call() throws Exception {
synchronized (this) {
_currentThread = Thread.currentThread();
}
try {
final ERXApplication app = ERXApplication.erxApplication();
WOResponse response = app.dispatchRequestImmediately(request());
// testing
// Thread.sleep(16000);
// log.info("Done: {}", this);
return response;
}
finally {
synchronized (this) {
ERXRuntimeUtilities.clearThreadInterrupt(_currentThread);
_currentThread = null;
}
}
}
public WORequest request() {
return _request;
}
public WOResponse response(long millis) throws InterruptedException, ExecutionException, TimeoutException {
return future().get(millis, TimeUnit.MILLISECONDS);
}
public String id() {
return _id;
}
public NSTimestamp start() {
return _start;
}
public Future<WOResponse> future() {
return _future;
}
public boolean isDone() {
return _currentThread == null;
}
public boolean cancel() {
// long start = System.currentTimeMillis();
synchronized (this) {
if (_currentThread != null) {
ERXRuntimeUtilities.addThreadInterrupt(_currentThread, "ERXDelayedRequestHandler: stop requested " + this);
// while(System.currentTimeMillis() - start < 5000 &&
// !isDone()) {
if (future().cancel(true)) {
log.info("Cancelled: {}: {}", _currentThread, isDone());
}
// }
log.info("Thread done after cancel: {}", isDone());
}
}
return isDone();
}
@Override
public String toString() {
return "<DelayedRequest: " + request().uri() + " id: " + id() + " isDone: " + future().isDone() + " start: " + start() + ">";
}
}
/**
* Creates a request handler instance.
*
* @param refreshTimeSeconds
* time in seconds for the refresh of the page
* @param maxRequestTimeSeconds
* time in seconds that a request can take at most before the delayed page
* is returned
* @param cancelRequestAfterSeconds
* time in seconds that a request can take at most before it is cancelled
* @param cssUrl
* url for a style sheet for the message page
*/
public ERXDelayedRequestHandler(int refreshTimeSeconds, int maxRequestTimeSeconds, int cancelRequestAfterSeconds, String cssUrl) {
_cssUrl = cssUrl;
_refreshTimeSeconds = refreshTimeSeconds;
_maxRequestTimeMillis = maxRequestTimeSeconds*1000;
_executor = Executors.newCachedThreadPool();
_futures = new ERXExpiringCache<String, DelayedRequest>(cancelRequestAfterSeconds) {
@Override
protected synchronized void removeEntryForKey(Entry<DelayedRequest> entry, String key) {
DelayedRequest request = entry.object();
synchronized (request) {
if (!request.isDone()) {
if (!request.cancel()) {
log.error("Delayed was running, but couldn't be cancelled: {}", request);
}
else {
log.info("Stopped delayed request that was still running: {}", request);
}
}
}
super.removeEntryForKey(entry, key);
}
};
_urls = new ERXExpiringCache<>(refresh() * 50);
}
/**
* Creates a handler with the supplied values for refreshTimeSeconds, maxRequestTimeSeconds and
* maxRequestTimeSeconds.
*
* @param refreshTimeSeconds
* @param maxRequestTimeSeconds
* @param cancelRequestAfterSeconds
*/
public ERXDelayedRequestHandler(int refreshTimeSeconds, int maxRequestTimeSeconds, int cancelRequestAfterSeconds) {
this(refreshTimeSeconds, maxRequestTimeSeconds, cancelRequestAfterSeconds, null);
}
/**
* Creates a handler with the supplied values for refreshTimeSeconds and
* maxRequestTimeSeconds. cancelRequestAfterSeconds is set o 5*maxRequestTimeSeconds.
*
* @param refreshTimeSeconds
* @param maxRequestTimeSeconds
*/
public ERXDelayedRequestHandler(int refreshTimeSeconds, int maxRequestTimeSeconds) {
this(refreshTimeSeconds, maxRequestTimeSeconds, maxRequestTimeSeconds*5, null);
}
/**
* Creates a handler with the default values of 5 second refresh and 5
* seconds maxRequestTime. Requests taking longer than 25 seconds are cancelled.
*/
public ERXDelayedRequestHandler() {
this(5, 5);
}
/**
* Handles the request and returns the applicable response.
*/
@Override
public WOResponse handleRequest(final WORequest request) {
ERXApplication app = ERXApplication.erxApplication();
WOResponse response = null;
if (canHandleRequest(request)) {
String uri = request.uri();
DelayedRequest delayedRequest;
String id;
log.debug("Handling: {}", uri);
String key = request.requestHandlerKey();
if (KEY.equals(key)) {
id = request.stringFormValueForKey("id");
delayedRequest = _futures.objectForKey(id);
if (delayedRequest == null) {
String url = _urls.objectForKey(id);
if (url == null) {
return createErrorResponse(request);
}
response = new ERXResponse(ERXHttpStatusCodes.FOUND);
response.setHeader(url, "location");
// refresh entry, so it doesn't time out
_urls.setObjectForKey(url, id);
return response;
}
// refresh entry, so it doesn't time out
_futures.setObjectForKey(delayedRequest, id);
}
else {
delayedRequest = new DelayedRequest(request);
id = delayedRequest.id();
_futures.setObjectForKey(delayedRequest, id);
}
response = handle(request, delayedRequest, id);
}
else {
// not handled
response = app.dispatchRequestImmediately(request);
}
return response;
}
/**
* Returns true if the request handler key can be handled.
*
* @param request
*/
protected boolean canHandleRequest(WORequest request) {
String contentType = request.headerForKey("content-type");
if (contentType != null && contentType.startsWith("multipart/form-data")) {
return false;
}
ERXApplication app = ERXApplication.erxApplication();
String key = request.requestHandlerKey();
return key == null || KEY.equals(key) || app.componentRequestHandlerKey().equals(key) || app.directActionRequestHandlerKey().equals(key);
}
/**
* Override to handle specific actions for the current future.
*
* @param request
* @param delayedRequest
* @param id
*/
protected WOResponse handle(WORequest request, DelayedRequest delayedRequest, String id) {
final ERXApplication app = ERXApplication.erxApplication();
WOResponse response = null;
try {
String action = request.stringFormValueForKey("action");
if (!delayedRequest.isDone()) {
if ("stop".equals(action)) {
if (delayedRequest.cancel()) {
_futures.removeObjectForKey(id);
_urls.setObjectForKey(delayedRequest.request().uri(), id);
response = createStoppedResponse(request);
return response;
}
}
else {
String url = request.uri();
if (!KEY.equals(request.requestHandlerKey())) {
String args = "id=" + id;
String sessionID = request.sessionID();
if (sessionID != null) {
args += "&" + WOApplication.application().sessionIdKey() + "=" + sessionID;
}
args += "&__start=" + delayedRequest.start().getTime();
args += "&__time=" + System.currentTimeMillis();
url = app.createContextForRequest((WORequest) request.clone()).urlWithRequestHandlerKey(KEY, "wait", args);
}
else {
url = url.replaceAll("__time=(.*)", "__time=" + System.currentTimeMillis());
}
log.debug("Delaying: {}", request.uri());
response = createRefreshResponse(request, url);
}
}
// AK: this double assignment is not an error. The future will try
// to get the value. When we time out, the old value from above will
// be returned. If we don't, then the real response is used.
response = delayedRequest.response(maxRequestTimeMillis());
_futures.removeObjectForKey(id);
_urls.setObjectForKey(delayedRequest.request().uri(), id);
}
catch (InterruptedException e1) {
throw NSForwardException._runtimeExceptionForThrowable(e1.getCause());
}
catch (ExecutionException e1) {
throw NSForwardException._runtimeExceptionForThrowable(e1.getCause());
}
catch (CancellationException e) {
log.info("Cancelled, redirecting: {}", request.uri());
response = createStoppedResponse(request);
}
catch (TimeoutException e) {
log.debug("Timed out, redirecting: {}", request.uri());
}
return response;
}
/**
* Create an error page when the future wasn't found anymore. This happens
* when the user backtracks and it is no longer in the cache. Note that the
* session has not been awakened.
*
* @param request the current request
* @return error response
*/
@SuppressWarnings("unchecked")
protected WOResponse createErrorResponse(WORequest request) {
final ERXApplication app = ERXApplication.erxApplication();
String args = (request.sessionID() != null ? "/" + request.sessionID() : "");
// dirty trick: use a non-existing context id to get the page-expired
// reply.
String url = request.applicationURLPrefix() + "/wo" + args + "/9999999999.0";
WORequest expired = app.createRequest("GET", url, "HTTP/1.0", request.headers(), null, null);
WOResponse result = app.dispatchRequestImmediately(expired);
return result;
}
/**
* Create a "stopped" page. Note that the session has not been awakened yet
* and you probably shouldn't do it either. The default implementation
* redirect to the entry.
*
* @param request the request object
* @return 302 response
*/
protected WOResponse createStoppedResponse(WORequest request) {
String sessionIdKey = WOApplication.application().sessionIdKey();
String args = (request.sessionID() != null ? sessionIdKey + "=" + request.sessionID() : "");
String url = request.applicationURLPrefix() + "?" + args;
ERXResponse result = new ERXResponse();
result.setHeader(url, "location");
result.setStatus(302);
return result;
}
protected String cssUrl(WORequest request) {
return _cssUrl;
}
/**
* Create a refresh page. Note that the session has not been awakened yet
* and you probably shouldn't do it either.
*
* @param request the current request
* @param url URL to open after refresh
* @return refresh page response
*/
protected WOResponse createRefreshResponse(WORequest request, String url) {
ERXResponse result = new ERXResponse();
result.setHeader(refresh() + "; url=" + url + "\"", "refresh");
// ak: create a simple template
result.appendContentString("<html>\n<head>\n<meta http-equiv=\"refresh\" content=\"" + refresh() + "; url=" + url + "\">\n");
result.appendContentString("<title>Please stand by...</title>\n");
String cssUrl = cssUrl(request);
if (cssUrl != null) {
result.appendContentString("<link rel=\"stylesheet\" href=\"" + cssUrl + "\"></link>\n");
}
result.appendContentString("</head>\n<body id=\"ERXDelayedRefreshPage\">");
result.appendContentString("<h1>Please stand by...</h1>\n");
result.appendContentString("<p class=\"busyMessage\">The action you selected is taking longer than " + (maxRequestTimeMillis() / 1000) + " seconds. The result will be shown as soon as it is ready.</p>\n");
result.appendContentString("<p class=\"refreshMessage\">This page will refresh automatically in " + refresh() + " seconds.</p>\n");
result.appendContentString("<p class=\"actions\">");
result.appendContentString("<a href=\"" + url + "\" class=\"refreshLink\">Refresh now</a> ");
result.appendContentString("<a href=\"" + url + "&action=stop\" class=\"stopLink\">Stop now</a>");
result.appendContentString("</p>\n</body>\n</html>");
return result;
}
/**
* Returns the refresh time in seconds for the message page.
*
* @return the refresh time in seconds
*/
protected int refresh() {
return _refreshTimeSeconds;
}
/**
* Returns the maximum time in milliseconds for allowed for a request before
* returning the message page.
*
* @return the maximum request time in milliseconds
*/
protected int maxRequestTimeMillis() {
return _maxRequestTimeMillis;
}
/**
* Returns all active delayed requests.
*
* @return array of delayed requests
*/
public NSArray<DelayedRequest> activeRequests() {
NSMutableArray<DelayedRequest> result = new NSMutableArray<>();
for (String id : _futures.allKeys()) {
DelayedRequest request = _futures.objectForKey(id);
if (request != null) {
result.addObject(request);
}
}
return result;
}
}