/** * * Copyright 2016-2019, Optimizely and contributors * * 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 com.optimizely.ab.event; import com.optimizely.ab.NamedThreadFactory; import com.optimizely.ab.OptimizelyHttpClient; import com.optimizely.ab.annotations.VisibleForTesting; import com.optimizely.ab.internal.PropertyUtils; import org.apache.http.HttpResponse; import org.apache.http.client.ClientProtocolException; import org.apache.http.client.ResponseHandler; import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpPost; import org.apache.http.client.methods.HttpRequestBase; import org.apache.http.client.utils.URIBuilder; import org.apache.http.entity.StringEntity; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.net.URISyntaxException; import java.util.Map; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ExecutorService; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import javax.annotation.CheckForNull; /** * {@link EventHandler} implementation that queues events and has a separate pool of threads responsible * for the dispatch. */ public class AsyncEventHandler implements EventHandler, AutoCloseable { public static final String CONFIG_QUEUE_CAPACITY = "async.event.handler.queue.capacity"; public static final String CONFIG_NUM_WORKERS = "async.event.handler.num.workers"; public static final String CONFIG_MAX_CONNECTIONS = "async.event.handler.max.connections"; public static final String CONFIG_MAX_PER_ROUTE = "async.event.handler.event.max.per.route"; public static final String CONFIG_VALIDATE_AFTER_INACTIVITY = "async.event.handler.validate.after"; public static final int DEFAULT_QUEUE_CAPACITY = 10000; public static final int DEFAULT_NUM_WORKERS = 2; public static final int DEFAULT_MAX_CONNECTIONS = 200; public static final int DEFAULT_MAX_PER_ROUTE = 20; public static final int DEFAULT_VALIDATE_AFTER_INACTIVITY = 5000; private static final Logger logger = LoggerFactory.getLogger(AsyncEventHandler.class); private static final ProjectConfigResponseHandler EVENT_RESPONSE_HANDLER = new ProjectConfigResponseHandler(); private final OptimizelyHttpClient httpClient; private final ExecutorService workerExecutor; private final long closeTimeout; private final TimeUnit closeTimeoutUnit; /** * @deprecated Use the builder {@link Builder} */ @Deprecated public AsyncEventHandler(int queueCapacity, int numWorkers) { this(queueCapacity, numWorkers, 200, 20, 5000); } /** * @deprecated Use the builder {@link Builder} */ @Deprecated public AsyncEventHandler(int queueCapacity, int numWorkers, int maxConnections, int connectionsPerRoute, int validateAfter) { this(queueCapacity, numWorkers, maxConnections, connectionsPerRoute, validateAfter, Long.MAX_VALUE, TimeUnit.MILLISECONDS); } public AsyncEventHandler(int queueCapacity, int numWorkers, int maxConnections, int connectionsPerRoute, int validateAfter, long closeTimeout, TimeUnit closeTimeoutUnit) { queueCapacity = validateInput("queueCapacity", queueCapacity, DEFAULT_QUEUE_CAPACITY); numWorkers = validateInput("numWorkers", numWorkers, DEFAULT_NUM_WORKERS); maxConnections = validateInput("maxConnections", maxConnections, DEFAULT_MAX_CONNECTIONS); connectionsPerRoute = validateInput("connectionsPerRoute", connectionsPerRoute, DEFAULT_MAX_PER_ROUTE); validateAfter = validateInput("validateAfter", validateAfter, DEFAULT_VALIDATE_AFTER_INACTIVITY); this.httpClient = OptimizelyHttpClient.builder() .withMaxTotalConnections(maxConnections) .withMaxPerRoute(connectionsPerRoute) .withValidateAfterInactivity(validateAfter) .build(); this.workerExecutor = new ThreadPoolExecutor(numWorkers, numWorkers, 0L, TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(queueCapacity), new NamedThreadFactory("optimizely-event-dispatcher-thread-%s", true)); this.closeTimeout = closeTimeout; this.closeTimeoutUnit = closeTimeoutUnit; } @VisibleForTesting public AsyncEventHandler(OptimizelyHttpClient httpClient, ExecutorService workerExecutor) { this.httpClient = httpClient; this.workerExecutor = workerExecutor; this.closeTimeout = Long.MAX_VALUE; this.closeTimeoutUnit = TimeUnit.MILLISECONDS; } @Override public void dispatchEvent(LogEvent logEvent) { try { // attempt to enqueue the log event for processing workerExecutor.execute(new EventDispatcher(logEvent)); } catch (RejectedExecutionException e) { logger.error("event dispatch rejected"); } } /** * Attempts to gracefully terminate all event dispatch workers and close all resources. * This method blocks, awaiting the completion of any queued or ongoing event dispatches. * <p> * Note: termination of ongoing event dispatching is best-effort. * * @param timeout maximum time to wait for event dispatches to complete * @param unit the time unit of the timeout argument */ public void shutdownAndAwaitTermination(long timeout, TimeUnit unit) { // Disable new tasks from being submitted logger.info("event handler shutting down. Attempting to dispatch previously submitted events"); workerExecutor.shutdown(); try { // Wait a while for existing tasks to terminate if (!workerExecutor.awaitTermination(timeout, unit)) { int unprocessedCount = workerExecutor.shutdownNow().size(); logger.warn("timed out waiting for previously submitted events to be dispatched. " + "{} events were dropped. " + "Interrupting dispatch worker(s)", unprocessedCount); // Cancel currently executing tasks // Wait a while for tasks to respond to being cancelled if (!workerExecutor.awaitTermination(timeout, unit)) { logger.error("unable to gracefully shutdown event handler"); } } } catch (InterruptedException ie) { // (Re-)Cancel if current thread also interrupted workerExecutor.shutdownNow(); // Preserve interrupt status Thread.currentThread().interrupt(); } finally { try { httpClient.close(); } catch (IOException e) { logger.error("unable to close event dispatcher http client", e); } } logger.info("event handler shutdown complete"); } @Override public void close() { shutdownAndAwaitTermination(closeTimeout, closeTimeoutUnit); } //======== Helper classes ========// /** * Wrapper runnable for the actual event dispatch. */ private class EventDispatcher implements Runnable { private final LogEvent logEvent; EventDispatcher(LogEvent logEvent) { this.logEvent = logEvent; } @Override public void run() { if (logger.isDebugEnabled()) { logger.debug("Dispatching event to URL {} with params {} and payload \"{}\".", logEvent.getEndpointUrl(), logEvent.getRequestParams(), logEvent.getBody()); } try { HttpRequestBase request; if (logEvent.getRequestMethod() == LogEvent.RequestMethod.GET) { request = generateGetRequest(logEvent); } else { request = generatePostRequest(logEvent); } httpClient.execute(request, EVENT_RESPONSE_HANDLER); } catch (IOException e) { logger.error("event dispatch failed", e); } catch (URISyntaxException e) { logger.error("unable to parse generated URI", e); } } /** * Helper method that generates the event request for the given {@link LogEvent}. */ private HttpGet generateGetRequest(LogEvent event) throws URISyntaxException { URIBuilder builder = new URIBuilder(event.getEndpointUrl()); for (Map.Entry<String, String> param : event.getRequestParams().entrySet()) { builder.addParameter(param.getKey(), param.getValue()); } return new HttpGet(builder.build()); } private HttpPost generatePostRequest(LogEvent event) throws UnsupportedEncodingException { HttpPost post = new HttpPost(event.getEndpointUrl()); post.setEntity(new StringEntity(event.getBody())); post.addHeader("Content-Type", "application/json"); return post; } } /** * Handler for the event request. */ private static final class ProjectConfigResponseHandler implements ResponseHandler<Void> { @Override @CheckForNull public Void handleResponse(HttpResponse response) throws IOException { int status = response.getStatusLine().getStatusCode(); if (status >= 200 && status < 300) { // read the response, so we can close the connection response.getEntity(); return null; } else { throw new ClientProtocolException("unexpected response from event endpoint, status: " + status); } } } //======== Builder ========// public static Builder builder() { return new Builder(); } public static class Builder { int queueCapacity = PropertyUtils.getInteger(CONFIG_QUEUE_CAPACITY, DEFAULT_QUEUE_CAPACITY); int numWorkers = PropertyUtils.getInteger(CONFIG_NUM_WORKERS, DEFAULT_NUM_WORKERS); int maxTotalConnections = PropertyUtils.getInteger(CONFIG_MAX_CONNECTIONS, DEFAULT_MAX_CONNECTIONS); int maxPerRoute = PropertyUtils.getInteger(CONFIG_MAX_PER_ROUTE, DEFAULT_MAX_PER_ROUTE); int validateAfterInactivity = PropertyUtils.getInteger(CONFIG_VALIDATE_AFTER_INACTIVITY, DEFAULT_VALIDATE_AFTER_INACTIVITY); private long closeTimeout = Long.MAX_VALUE; private TimeUnit closeTimeoutUnit = TimeUnit.MILLISECONDS; public Builder withQueueCapacity(int queueCapacity) { if (queueCapacity <= 0) { logger.warn("Queue capacity cannot be <= 0. Keeping default value: {}", this.queueCapacity); return this; } this.queueCapacity = queueCapacity; return this; } public Builder withNumWorkers(int numWorkers) { if (numWorkers <= 0) { logger.warn("Number of workers cannot be <= 0. Keeping default value: {}", this.numWorkers); return this; } this.numWorkers = numWorkers; return this; } public Builder withMaxTotalConnections(int maxTotalConnections) { this.maxTotalConnections = maxTotalConnections; return this; } public Builder withMaxPerRoute(int maxPerRoute) { this.maxPerRoute = maxPerRoute; return this; } public Builder withValidateAfterInactivity(int validateAfterInactivity) { this.validateAfterInactivity = validateAfterInactivity; return this; } public Builder withCloseTimeout(long closeTimeout, TimeUnit unit) { this.closeTimeout = closeTimeout; this.closeTimeoutUnit = unit; return this; } public AsyncEventHandler build() { return new AsyncEventHandler( queueCapacity, numWorkers, maxTotalConnections, maxPerRoute, validateAfterInactivity, closeTimeout, closeTimeoutUnit ); } } private int validateInput(String name, int input, int fallback) { if (input <= 0) { logger.warn("Invalid value for {}: {}. Defaulting to {}", name, input, fallback); return fallback; } return input; } }