/* * Copyright (c) 2016 Couchbase, Inc. * * 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.couchbase.client.core.endpoint; import com.couchbase.client.core.CouchbaseException; import com.couchbase.client.core.RequestCancelledException; import com.couchbase.client.core.ResponseEvent; import com.couchbase.client.core.ResponseHandler; import com.couchbase.client.core.env.CoreEnvironment; import com.couchbase.client.core.env.CoreScheduler; import com.couchbase.client.core.logging.CouchbaseLogger; import com.couchbase.client.core.logging.CouchbaseLoggerFactory; import com.couchbase.client.core.message.CouchbaseRequest; import com.couchbase.client.core.message.CouchbaseResponse; import com.couchbase.client.core.message.KeepAlive; import com.couchbase.client.core.message.ResponseStatus; import com.couchbase.client.core.metrics.NetworkLatencyMetricsIdentifier; import com.couchbase.client.core.retry.RetryHelper; import com.couchbase.client.core.service.ServiceType; import com.lmax.disruptor.EventSink; import io.netty.buffer.ByteBuf; import io.netty.channel.Channel; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelPromise; import io.netty.handler.codec.DecoderException; import io.netty.handler.codec.MessageToMessageCodec; import io.netty.handler.codec.base64.Base64; import io.netty.handler.codec.http.HttpHeaders; import io.netty.handler.codec.http.HttpRequest; import io.netty.handler.timeout.IdleStateEvent; import io.netty.util.CharsetUtil; import rx.Scheduler; import rx.Subscriber; import rx.functions.Action0; import rx.subjects.Subject; import javax.net.ssl.SSLHandshakeException; import java.io.IOException; import java.net.InetSocketAddress; import java.net.SocketAddress; import java.nio.charset.Charset; import java.util.ArrayDeque; import java.util.IdentityHashMap; import java.util.List; import java.util.Map; import java.util.Queue; import static com.couchbase.client.core.utils.Observables.failSafe; /** * Generic handler which acts as the common base type for all implementing handlers. * * @author Michael Nitschinger * @since 1.0 */ public abstract class AbstractGenericHandler<RESPONSE, ENCODED, REQUEST extends CouchbaseRequest> extends MessageToMessageCodec<RESPONSE, REQUEST> { /** * The default charset to use for all requests and responses. */ protected static final Charset CHARSET = CharsetUtil.UTF_8; /** * The logger used. */ private static final CouchbaseLogger LOGGER = CouchbaseLoggerFactory.getInstance(AbstractGenericHandler.class); /** * Empty bytes to reuse. */ protected static final byte[] EMPTY_BYTES = new byte[] {}; /** * The response buffer to push response events into. */ private final EventSink<ResponseEvent> responseBuffer; /** * The endpoint held as a reference. */ private final AbstractEndpoint endpoint; /** * This queue keeps all currently outstanding requests. */ private final Queue<REQUEST> sentRequestQueue; /** * This queue keeps all timings for each request when it was sent off to the event loop. */ private final Queue<Long> sentRequestTimings; /** * If this handler is transient (will close after one request). */ private final boolean isTransient; /** * If TRACE level logging has been enabled at startup. */ private final boolean traceEnabled; /** * If the response need to be moved out of the event loop. */ private final boolean moveResponseOut; /** * A cache to avoid consistent string conversions for the request simple names. */ private final Map<Class<? extends CouchbaseRequest>, String> classNameCache; /** * The request which is expected to return next. */ private REQUEST currentRequest; private DecodingState currentDecodingState; /** * Contains the current round-trip-time for the last completed operation. Used for metrics. */ private long currentOpTime = -1; /** * Contains the stringified version of the remote node's hostname. Used for metrics. */ private String remoteHostname; /** * The future which is used to eventually signal a connected channel. */ private ChannelPromise connectFuture; /** * Returns the remote http host in usable format. */ private String remoteHttpHost; private final int sentQueueLimit; private final boolean pipeline; /** * Creates a new {@link AbstractGenericHandler} with the default queue. * * @param endpoint the endpoint reference. * @param responseBuffer the response buffer. */ protected AbstractGenericHandler(final AbstractEndpoint endpoint, final EventSink<ResponseEvent> responseBuffer, final boolean isTransient, final boolean pipeline) { this(endpoint, responseBuffer, new ArrayDeque<REQUEST>(), isTransient, pipeline); } /** * Creates a new {@link AbstractGenericHandler} with a custom queue. * * @param endpoint the endpoint reference. * @param responseBuffer the response buffer. * @param queue the queue. */ protected AbstractGenericHandler(final AbstractEndpoint endpoint, final EventSink<ResponseEvent> responseBuffer, final Queue<REQUEST> queue, final boolean isTransient, final boolean pipeline) { this.pipeline = pipeline; this.endpoint = endpoint; this.responseBuffer = responseBuffer; this.sentRequestQueue = queue; this.currentDecodingState = DecodingState.INITIAL; this.isTransient = isTransient; this.traceEnabled = LOGGER.isTraceEnabled(); this.sentRequestTimings = new ArrayDeque<Long>(); this.classNameCache = new IdentityHashMap<Class<? extends CouchbaseRequest>, String>(); this.moveResponseOut = env() == null || !env().callbacksOnIoPool(); this.sentQueueLimit = Integer.parseInt(System.getProperty("com.couchbase.sentRequestQueueLimit", "5120")); } /** * Encode the outgoing request and return it in encoded format. * * This method needs to be implemented by the child handler and is responsible for the actual conversion. * * @param ctx the context passed in. * @param msg the outgoing message. * @return the encoded request. * @throws Exception as a generic error. */ protected abstract ENCODED encodeRequest(ChannelHandlerContext ctx, REQUEST msg) throws Exception; /** * Decodes the incoming response and transforms it into a {@link CouchbaseResponse}. * * Note that the actual notification is handled by this generic handler, the implementing class only is concerned * about the conversion itself. * * @param ctx the context passed in. * @param msg the incoming message. * @return a response or null if nothing should be returned. * @throws Exception as a generic error. It will be bubbled up to the user (wrapped in a CouchbaseException) in the * onError of the request's Observable. */ protected abstract CouchbaseResponse decodeResponse(ChannelHandlerContext ctx, RESPONSE msg) throws Exception; /** * Returns the {@link ServiceType} associated with this handler. * * @return the service type. */ protected abstract ServiceType serviceType(); @Override public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception { if (!pipeline && (!sentRequestQueue.isEmpty() || currentDecodingState != DecodingState.INITIAL)) { if (traceEnabled) { LOGGER.trace("Rescheduling {} because pipelining disable and a request is in-flight.", msg); } RetryHelper.retryOrCancel(env(), (CouchbaseRequest) msg, responseBuffer); return; } if (sentRequestQueue.size() < sentQueueLimit) { super.write(ctx, msg, promise); } else { LOGGER.debug("Rescheduling {} because sentRequestQueueLimit reached.", msg); RetryHelper.retryOrCancel(env(), (CouchbaseRequest) msg, responseBuffer); } } @Override protected void encode(ChannelHandlerContext ctx, REQUEST msg, List<Object> out) throws Exception { ENCODED request; try { request = encodeRequest(ctx, msg); } catch (Exception ex) { msg.observable().onError(new RequestCancelledException("Error while encoding Request, cancelling.", ex)); // we need to re-throw the error because netty expects either an exception // or at least one message encoded. just returning won't work throw ex; } sentRequestQueue.offer(msg); out.add(request); sentRequestTimings.offer(System.nanoTime()); } @Override protected void decode(ChannelHandlerContext ctx, RESPONSE msg, List<Object> out) throws Exception { if (currentDecodingState == DecodingState.INITIAL) { initialDecodeTasks(ctx); } try { CouchbaseResponse response = decodeResponse(ctx, msg); if (response != null) { publishResponse(response, currentRequest.observable()); if (currentDecodingState == DecodingState.FINISHED) { writeMetrics(response); } } } catch (CouchbaseException e) { failSafe(env().scheduler(), moveResponseOut, currentRequest.observable(), e); } catch (Exception e) { failSafe(env().scheduler(), moveResponseOut, currentRequest.observable(), new CouchbaseException(e)); } if (currentDecodingState == DecodingState.FINISHED) { endpoint.notifyResponseDecoded(currentRequest instanceof KeepAlive); resetStatesAfterDecode(ctx); } } /** * Helper method which creates the metrics for the current response and publishes them if enabled. * * @param response the response which is needed as context. */ private void writeMetrics(final CouchbaseResponse response) { if (currentRequest != null && currentOpTime >= 0 && env() != null && env().networkLatencyMetricsCollector().isEnabled()) { try { Class<? extends CouchbaseRequest> requestClass = currentRequest.getClass(); String simpleName = classNameCache.get(requestClass); if (simpleName == null) { simpleName = requestClass.getSimpleName(); classNameCache.put(requestClass, simpleName); } NetworkLatencyMetricsIdentifier identifier = new NetworkLatencyMetricsIdentifier( remoteHostname, serviceType().toString(), simpleName, response.status().toString() ); env().networkLatencyMetricsCollector().record(identifier, currentOpTime); } catch (Throwable e) { LOGGER.warn("Could not collect latency metric for request + " + currentRequest + "(" + currentOpTime + ")", e); } } } /** * Helper method which performs the final tasks in the decoding process. * * @param ctx the channel handler context for logging purposes. */ private void resetStatesAfterDecode(final ChannelHandlerContext ctx) { if (traceEnabled) { LOGGER.trace("{}Finished decoding of {}", logIdent(ctx, endpoint), currentRequest); } currentRequest = null; currentDecodingState = DecodingState.INITIAL; } /** * Helper method which performs the initial decoding process. * * @param ctx the channel handler context for logging purposes. */ private void initialDecodeTasks(final ChannelHandlerContext ctx) { currentRequest = sentRequestQueue.poll(); currentDecodingState = DecodingState.STARTED; if (currentRequest != null) { Long st = sentRequestTimings.poll(); if (st != null) { currentOpTime = System.nanoTime() - st; } else { currentOpTime = -1; } } if (traceEnabled) { LOGGER.trace("{}Started decoding of {}", logIdent(ctx, endpoint), currentRequest); } } /** * Publishes a response with the attached observable. * * @param response the response to publish. * @param observable pushing into the event sink. */ protected void publishResponse(final CouchbaseResponse response, final Subject<CouchbaseResponse, CouchbaseResponse> observable) { if (response.status() != ResponseStatus.RETRY && observable != null) { if (moveResponseOut) { Scheduler scheduler = env().scheduler(); if (scheduler instanceof CoreScheduler) { scheduleDirect((CoreScheduler) scheduler, response, observable); } else { scheduleWorker(scheduler, response, observable); } } else { completeResponse(response, observable); } } else { responseBuffer.publishEvent(ResponseHandler.RESPONSE_TRANSLATOR, response, observable); } } /** * Fulfill and complete the response observable. * * When called directly, this method completes on the event loop, but it can also be used in a callback (see * {@link #scheduleDirect(CoreScheduler, CouchbaseResponse, Subject)} for example. */ private static void completeResponse(final CouchbaseResponse response, final Subject<CouchbaseResponse, CouchbaseResponse> observable) { try { observable.onNext(response); observable.onCompleted(); } catch (Exception ex) { LOGGER.warn("Caught exception while onNext on observable", ex); observable.onError(ex); } } /** * Optimized version of dispatching onto the core scheduler through direct scheduling. * * This method has less GC overhead compared to {@link #scheduleWorker(Scheduler, CouchbaseResponse, Subject)} * since no worker needs to be generated explicitly (but is not part of the public Scheduler interface). */ private static void scheduleDirect(CoreScheduler scheduler, final CouchbaseResponse response, final Subject<CouchbaseResponse, CouchbaseResponse> observable) { scheduler.scheduleDirect(new Action0() { @Override public void call() { completeResponse(response, observable); } }); } /** * Dispatches the response on a generic scheduler through creating a worker. */ private static void scheduleWorker(Scheduler scheduler, final CouchbaseResponse response, final Subject<CouchbaseResponse, CouchbaseResponse> observable) { final Scheduler.Worker worker = scheduler.createWorker(); worker.schedule(new Action0() { @Override public void call() { try { observable.onNext(response); observable.onCompleted(); } catch (Exception ex) { LOGGER.warn("Caught exception while onNext on observable", ex); observable.onError(ex); } finally { worker.unsubscribe(); } } }); } /** * Notify that decoding is finished. This needs to be called by the child handlers in order to * signal that operations are done. */ protected void finishedDecoding() { this.currentDecodingState = DecodingState.FINISHED; if (isTransient) { endpoint.disconnect(); } } @Override public void channelInactive(final ChannelHandlerContext ctx) throws Exception { LOGGER.debug(logIdent(ctx, endpoint) + "Channel Inactive."); endpoint.notifyChannelInactive(); ctx.fireChannelInactive(); } @Override public void channelActive(ChannelHandlerContext ctx) throws Exception { LOGGER.debug(logIdent(ctx, endpoint) + "Channel Active."); SocketAddress addr = ctx.channel().remoteAddress(); if (addr instanceof InetSocketAddress) { // Avoid lookup, so just use the address remoteHostname = ((InetSocketAddress) addr).getAddress().getHostAddress(); } else { // Should not happen in production, but in testing it might be different remoteHostname = addr.toString(); } ctx.fireChannelActive(); } @Override public void channelWritabilityChanged(final ChannelHandlerContext ctx) throws Exception { if (!ctx.channel().isWritable()) { ctx.flush(); } ctx.fireChannelWritabilityChanged(); } @Override public void connect(ChannelHandlerContext ctx, SocketAddress remoteAddress, SocketAddress localAddress, ChannelPromise future) throws Exception { connectFuture = future; ctx.connect(remoteAddress, localAddress, future); } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { if (cause instanceof IOException) { if (LOGGER.isDebugEnabled()) { LOGGER.debug(logIdent(ctx, endpoint) + "Connection reset by peer: " + cause.getMessage(), cause); } else { LOGGER.info(logIdent(ctx, endpoint) + "Connection reset by peer: " + cause.getMessage()); } handleOutstandingOperations(ctx); } else if (cause instanceof DecoderException && cause.getCause() instanceof SSLHandshakeException) { if (!connectFuture.isDone()) { connectFuture.setFailure(cause.getCause()); } else { // This should not be possible, since handshake is done before connecting. But just in case, we // can trap and log an error that might slip through for one reason or another. LOGGER.warn(logIdent(ctx, endpoint) + "Caught SSL exception after being connected: " + cause.getMessage(), cause); } } else { LOGGER.warn(logIdent(ctx, endpoint) + "Caught unknown exception: " + cause.getMessage(), cause); ctx.fireExceptionCaught(cause); } } @Override public void handlerRemoved(ChannelHandlerContext ctx) throws Exception { handleOutstandingOperations(ctx); } /** * Cancells any outstanding operations which are currently on the wire. * * @param ctx the handler context. */ private void handleOutstandingOperations(final ChannelHandlerContext ctx) { if (sentRequestQueue.isEmpty()) { LOGGER.trace(logIdent(ctx, endpoint) + "Not cancelling operations - sent queue is empty."); return; } LOGGER.debug(logIdent(ctx, endpoint) + "Cancelling " + sentRequestQueue.size() + " outstanding requests."); while (!sentRequestQueue.isEmpty()) { REQUEST req = sentRequestQueue.poll(); try { sideEffectRequestToCancel(req); failSafe(env().scheduler(), moveResponseOut, req.observable(), new RequestCancelledException("Request cancelled in-flight.")); } catch (Exception ex) { LOGGER.info("Exception thrown while cancelling outstanding operation: " + req, ex); } } sentRequestTimings.clear(); } /** * This method can be overridden as it is called every time an operation is cancelled. * * Overriding implementations may do some custom logic with them, for example freeing resources they know of * to avoid leaking. * * @param request the request to side effect on. */ protected void sideEffectRequestToCancel(final REQUEST request) { // Nothing to do in the generic implementation. } @Override public void userEventTriggered(final ChannelHandlerContext ctx, Object evt) throws Exception { if (evt instanceof IdleStateEvent) { if (!shouldSendKeepAlive()) { return; } CouchbaseRequest keepAlive = createKeepAliveRequest(); if (keepAlive != null) { keepAlive.observable().subscribe(new KeepAliveResponseAction(ctx)); onKeepAliveFired(ctx, keepAlive); Channel channel = ctx.channel(); if (channel.isActive() && channel.isWritable()) { ctx.pipeline().writeAndFlush(keepAlive); } } } else { super.userEventTriggered(ctx, evt); } } /** * Helper method to check if conditions are met to send a keepalive right now. * * @return true if keepalive can be sent, false otherwise. */ private boolean shouldSendKeepAlive() { if (pipeline) { return true; // always send if pipelining is enabled } // if pipelining is disabled, only send if the request queue is empty and no response // is currently being decoded. return sentRequestQueue.isEmpty() && currentDecodingState == DecodingState.INITIAL; } /** * Override to return a non-null request to be fired in the pipeline in case a keep alive is triggered. * * @return a CouchbaseRequest to be fired in case of keep alive (null by default). */ protected CouchbaseRequest createKeepAliveRequest() { return null; } /** * Override to customize the behavior when a keep alive has been triggered and a keep alive request sent. * * The default behavior is to log the event at debug level. * * @param ctx the channel context. * @param keepAliveRequest the keep alive request that was sent when keep alive was triggered */ protected void onKeepAliveFired(ChannelHandlerContext ctx, CouchbaseRequest keepAliveRequest) { if (LOGGER.isDebugEnabled()) { LOGGER.debug(logIdent(ctx, endpoint) + "KeepAlive fired"); } } /** * Override to customize the behavior when a keep alive has been responded to. * * The default behavior is to log the event and the response status at trace level. * * @param ctx the channel context. * @param keepAliveResponse the keep alive request that was sent when keep alive was triggered */ protected void onKeepAliveResponse(ChannelHandlerContext ctx, CouchbaseResponse keepAliveResponse) { if (traceEnabled) { LOGGER.trace(logIdent(ctx, endpoint) + "keepAlive was answered, status " + keepAliveResponse.status()); } } /** * Returns the current request if set. * * @return the current request. */ protected REQUEST currentRequest() { return currentRequest; } /** * Sets current request. * * FIXME this is temporary solution for {@link com.couchbase.client.core.endpoint.dcp.DCPHandler} * @param request request to become the current one */ protected void currentRequest(REQUEST request) { currentRequest = request; } /** * @return stringified version of the remote node's hostname */ protected String remoteHostname() { return remoteHostname; } /** * Returns environment. * * @return the environment */ protected CoreEnvironment env() { return endpoint.environment(); } /** * The parent endpoint. */ protected AbstractEndpoint endpoint() { return endpoint; } /** * Simple log helper to give logs a common prefix. * * @param ctx the context. * @param endpoint the endpoint. * @return a prefix string for logs. */ protected static String logIdent(final ChannelHandlerContext ctx, final Endpoint endpoint) { return "[" + ctx.channel().remoteAddress() + "][" + endpoint.getClass().getSimpleName() + "]: "; } private class KeepAliveResponseAction extends Subscriber<CouchbaseResponse> { private final ChannelHandlerContext ctx; KeepAliveResponseAction(ChannelHandlerContext ctx) { this.ctx = ctx; } @Override public void onCompleted() { // ignored on purpose. } @Override public void onError(Throwable e) { LOGGER.warn(logIdent(ctx, endpoint) + "Got error while consuming KeepAliveResponse.", e); } @Override public void onNext(CouchbaseResponse couchbaseResponse) { onKeepAliveResponse(this.ctx, couchbaseResponse); } } /** * Add basic authentication headers to a {@link HttpRequest}. * * The given information is Base64 encoded and the authorization header is set appropriately. Since this needs * to be done for every request, it is refactored out. * * @param ctx the handler context. * @param request the request where the header should be added. * @param user the username for auth. * @param password the password for auth. */ public static void addHttpBasicAuth(final ChannelHandlerContext ctx, final HttpRequest request, final String user, final String password) { final String pw = password == null ? "" : password; ByteBuf raw = ctx.alloc().buffer(user.length() + pw.length() + 1); raw.writeBytes((user + ":" + pw).getBytes(CHARSET)); ByteBuf encoded = Base64.encode(raw, false); request.headers().add(HttpHeaders.Names.AUTHORIZATION, "Basic " + encoded.toString(CHARSET)); encoded.release(); raw.release(); } /** * Helper method to return the remote http host, cached. * * @param ctx the handler context. * @return the remote http host. */ protected String remoteHttpHost(ChannelHandlerContext ctx) { if (remoteHttpHost == null) { SocketAddress addr = ctx.channel().remoteAddress(); if (addr instanceof InetSocketAddress) { InetSocketAddress inetAddr = (InetSocketAddress) addr; remoteHttpHost = inetAddr.getAddress().getHostAddress() + ":" + inetAddr.getPort(); } else { remoteHttpHost = addr.toString(); } } return remoteHttpHost; } public DecodingState getDecodingState() { return this.currentDecodingState; } }