/* * Copyright © 2015 Cask Data, 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 co.cask.cdap.data.stream.service; import co.cask.cdap.api.data.format.FormatSpecification; import co.cask.cdap.api.data.format.RecordFormat; import co.cask.cdap.api.data.schema.Schema; import co.cask.cdap.api.data.schema.UnsupportedTypeException; import co.cask.cdap.api.metrics.MetricsCollectionService; import co.cask.cdap.api.metrics.MetricsContext; import co.cask.cdap.common.BadRequestException; import co.cask.cdap.common.NotFoundException; import co.cask.cdap.common.conf.CConfiguration; import co.cask.cdap.common.conf.Constants; import co.cask.cdap.common.namespace.AbstractNamespaceClient; import co.cask.cdap.data.stream.StreamCoordinatorClient; import co.cask.cdap.data.stream.StreamFileWriterFactory; import co.cask.cdap.data.stream.service.upload.ContentWriterFactory; import co.cask.cdap.data.stream.service.upload.LengthBasedContentWriterFactory; import co.cask.cdap.data.stream.service.upload.StreamBodyConsumerFactory; import co.cask.cdap.data2.transaction.stream.StreamAdmin; import co.cask.cdap.data2.transaction.stream.StreamConfig; import co.cask.cdap.format.RecordFormats; import co.cask.cdap.internal.io.SchemaTypeAdapter; import co.cask.cdap.proto.Id; import co.cask.cdap.proto.StreamProperties; import co.cask.http.AbstractHttpHandler; import co.cask.http.BodyConsumer; import co.cask.http.HandlerContext; import co.cask.http.HttpHandler; import co.cask.http.HttpResponder; import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; import com.google.common.collect.ImmutableMap; import com.google.common.io.Closeables; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonDeserializationContext; import com.google.gson.JsonDeserializer; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParseException; import com.google.gson.JsonSerializationContext; import com.google.gson.JsonSerializer; import com.google.inject.Inject; import com.google.inject.Singleton; import org.apache.twill.common.Threads; import org.jboss.netty.buffer.ChannelBufferInputStream; import org.jboss.netty.handler.codec.http.HttpHeaders; import org.jboss.netty.handler.codec.http.HttpRequest; import org.jboss.netty.handler.codec.http.HttpResponseStatus; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.InputStreamReader; import java.io.Reader; import java.lang.reflect.Type; import java.util.Map; import java.util.Properties; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ExecutorService; import java.util.concurrent.RejectedExecutionHandler; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import javax.ws.rs.DELETE; import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.PUT; import javax.ws.rs.Path; import javax.ws.rs.PathParam; /** * The {@link HttpHandler} for handling REST call to V3 stream APIs. */ @Singleton @Path(Constants.Gateway.API_VERSION_3 + "/namespaces/{namespace-id}/streams") public final class StreamHandler extends AbstractHttpHandler { private static final Logger LOG = LoggerFactory.getLogger(StreamHandler.class); private static final Gson GSON = new GsonBuilder() .registerTypeAdapter(StreamProperties.class, new StreamPropertiesAdapter()) .registerTypeAdapter(Schema.class, new SchemaTypeAdapter()) .create(); private final CConfiguration cConf; private final StreamAdmin streamAdmin; private final MetricsContext streamHandlerMetricsContext; private final LoadingCache<Id.Namespace, MetricsContext> streamMetricsCollectors; private final ConcurrentStreamWriter streamWriter; private final long batchBufferThreshold; private final StreamBodyConsumerFactory streamBodyConsumerFactory; private final AbstractNamespaceClient namespaceClient; // Executor for serving async enqueue requests private ExecutorService asyncExecutor; private final StreamWriterSizeCollector sizeCollector; @Inject public StreamHandler(CConfiguration cConf, StreamCoordinatorClient streamCoordinatorClient, StreamAdmin streamAdmin, StreamFileWriterFactory writerFactory, final MetricsCollectionService metricsCollectionService, StreamWriterSizeCollector sizeCollector, AbstractNamespaceClient namespaceClient) { this.cConf = cConf; this.streamAdmin = streamAdmin; this.sizeCollector = sizeCollector; this.batchBufferThreshold = cConf.getLong(Constants.Stream.BATCH_BUFFER_THRESHOLD); this.streamBodyConsumerFactory = new StreamBodyConsumerFactory(); this.streamHandlerMetricsContext = metricsCollectionService.getContext(getStreamHandlerMetricsContext()); streamMetricsCollectors = CacheBuilder.newBuilder() .build(new CacheLoader<Id.Namespace, MetricsContext>() { @Override public MetricsContext load(Id.Namespace namespaceId) { return metricsCollectionService.getContext(getStreamMetricsContext(namespaceId)); } }); StreamMetricsCollectorFactory metricsCollectorFactory = createStreamMetricsCollectorFactory(); this.streamWriter = new ConcurrentStreamWriter(streamCoordinatorClient, streamAdmin, writerFactory, cConf.getInt(Constants.Stream.WORKER_THREADS), metricsCollectorFactory); this.namespaceClient = namespaceClient; } @Override public void init(HandlerContext context) { super.init(context); int asyncWorkers = cConf.getInt(Constants.Stream.ASYNC_WORKER_THREADS); // The queue size config is size per worker, hence multiple by workers here int asyncQueueSize = cConf.getInt(Constants.Stream.ASYNC_QUEUE_SIZE) * asyncWorkers; // Creates a thread pool that will shrink inactive threads // Also, it limits how many tasks can get queue up to guard against out of memory if incoming requests are // coming too fast. // It uses the caller thread execution rejection policy so that it slows down request naturally by resorting // to sync enqueue (enqueue by caller thread is the same as sync enqueue) ThreadPoolExecutor executor = new ThreadPoolExecutor(asyncWorkers, asyncWorkers, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<Runnable>(asyncQueueSize), Threads.createDaemonThreadFactory("async-exec-%d"), createAsyncRejectedExecutionHandler()); executor.allowCoreThreadTimeOut(true); asyncExecutor = executor; } @Override public void destroy(HandlerContext context) { Closeables.closeQuietly(streamWriter); asyncExecutor.shutdownNow(); } @GET @Path("/{stream}") public void getInfo(HttpRequest request, HttpResponder responder, @PathParam("namespace-id") String namespaceId, @PathParam("stream") String stream) throws Exception { Id.Stream streamId = Id.Stream.from(namespaceId, stream); checkStreamExists(streamId); StreamProperties properties = streamAdmin.getProperties(streamId); responder.sendJson(HttpResponseStatus.OK, properties, StreamProperties.class, GSON); } @PUT @Path("/{stream}") public void create(HttpRequest request, HttpResponder responder, @PathParam("namespace-id") String namespaceId, @PathParam("stream") String stream) throws Exception { // Check for namespace existence. Throws NotFoundException if namespace doesn't exist namespaceClient.get(Id.Namespace.from(namespaceId)); Id.Stream streamId; try { // Verify stream name streamId = Id.Stream.from(namespaceId, stream); } catch (IllegalArgumentException e) { throw new BadRequestException(e); } Properties props = new Properties(); StreamProperties streamProperties; // If the request to create a stream contains a non-empty body, then construct and set StreamProperties if (request.getContent().readable()) { streamProperties = getAndValidateConfig(request, responder); if (streamProperties == null) { return; } if (streamProperties.getTTL() != null) { props.put(Constants.Stream.TTL, Long.toString(streamProperties.getTTL())); } if (streamProperties.getNotificationThresholdMB() != null) { props.put(Constants.Stream.NOTIFICATION_THRESHOLD, Integer.toString(streamProperties.getNotificationThresholdMB())); } if (streamProperties.getDescription() != null) { props.put(Constants.Stream.DESCRIPTION, streamProperties.getDescription()); } if (streamProperties.getFormat() != null) { props.put(Constants.Stream.FORMAT_SPECIFICATION, GSON.toJson(streamProperties.getFormat())); } } streamAdmin.create(streamId, props); responder.sendStatus(HttpResponseStatus.OK); } @POST @Path("/{stream}") public void enqueue(HttpRequest request, HttpResponder responder, @PathParam("namespace-id") String namespaceId, @PathParam("stream") String stream) throws Exception { Id.Stream streamId = Id.Stream.from(namespaceId, stream); try { streamWriter.enqueue(streamId, getHeaders(request, stream), request.getContent().toByteBuffer()); responder.sendStatus(HttpResponseStatus.OK); } catch (IOException e) { LOG.error("Failed to write to stream {}", stream, e); responder.sendString(HttpResponseStatus.INTERNAL_SERVER_ERROR, e.getMessage()); } } @POST @Path("/{stream}/async") public void asyncEnqueue(HttpRequest request, HttpResponder responder, @PathParam("namespace-id") String namespaceId, @PathParam("stream") String stream) throws Exception { Id.Stream streamId = Id.Stream.from(namespaceId, stream); // No need to copy the content buffer as we always uses a ChannelBufferFactory that won't reuse buffer. // See StreamHttpService streamWriter.asyncEnqueue(streamId, getHeaders(request, stream), request.getContent().toByteBuffer(), asyncExecutor); responder.sendStatus(HttpResponseStatus.ACCEPTED); } @POST @Path("/{stream}/batch") public BodyConsumer batch(HttpRequest request, HttpResponder responder, @PathParam("namespace-id") String namespaceId, @PathParam("stream") String stream) throws Exception { Id.Stream streamId = Id.Stream.from(namespaceId, stream); checkStreamExists(streamId); try { return streamBodyConsumerFactory.create(request, createContentWriterFactory(streamId, request)); } catch (UnsupportedOperationException e) { responder.sendString(HttpResponseStatus.NOT_ACCEPTABLE, e.getMessage()); return null; } } @POST @Path("/{stream}/truncate") public void truncate(HttpRequest request, HttpResponder responder, @PathParam("namespace-id") String namespaceId, @PathParam("stream") String stream) throws Exception { Id.Stream streamId = Id.Stream.from(namespaceId, stream); checkStreamExists(streamId); streamAdmin.truncate(streamId); responder.sendStatus(HttpResponseStatus.OK); } @DELETE @Path("/{stream}") public void delete(HttpRequest request, HttpResponder responder, @PathParam("namespace-id") String namespaceId, @PathParam("stream") String stream) throws Exception { Id.Stream streamId = Id.Stream.from(namespaceId, stream); checkStreamExists(streamId); // On Windows, we can not move the file if it is open, and the stream writer may have an open file in this dir. // Since Windows is only supported in SDK/standalone, we don't need to worry about multiple stream writers here. streamWriter.close(streamId); streamAdmin.drop(streamId); responder.sendStatus(HttpResponseStatus.OK); } @PUT @Path("/{stream}/properties") public void setConfig(HttpRequest request, HttpResponder responder, @PathParam("namespace-id") String namespaceId, @PathParam("stream") String stream) throws Exception { Id.Stream streamId = Id.Stream.from(namespaceId, stream); checkStreamExists(streamId); StreamProperties properties = getAndValidateConfig(request, responder); // null is returned if the requested config is invalid. An appropriate response will have already been written // to the responder so we just need to return. if (properties == null) { return; } streamAdmin.updateConfig(streamId, properties); responder.sendStatus(HttpResponseStatus.OK); } private void checkStreamExists(Id.Stream streamId) throws Exception { if (!streamAdmin.exists(streamId)) { throw new NotFoundException(streamId); } } private StreamMetricsCollectorFactory createStreamMetricsCollectorFactory() { return new StreamMetricsCollectorFactory() { @Override public StreamMetricsCollector createMetricsCollector(final Id.Stream streamId) { MetricsContext streamMetricsContext = streamMetricsCollectors.getUnchecked(streamId.getNamespace()); final MetricsContext childCollector = streamMetricsContext.childContext(Constants.Metrics.Tag.STREAM, streamId.getId()); return new StreamMetricsCollector() { @Override public void emitMetrics(long bytesWritten, long eventsWritten) { if (bytesWritten > 0) { childCollector.increment("collect.bytes", bytesWritten); sizeCollector.received(streamId, bytesWritten); } if (eventsWritten > 0) { childCollector.increment("collect.events", eventsWritten); } } }; } }; } private Map<String, String> getStreamHandlerMetricsContext() { return ImmutableMap.of(Constants.Metrics.Tag.NAMESPACE, Id.Namespace.SYSTEM.getId(), Constants.Metrics.Tag.COMPONENT, Constants.Gateway.METRICS_CONTEXT, Constants.Metrics.Tag.HANDLER, Constants.Gateway.STREAM_HANDLER_NAME, Constants.Metrics.Tag.INSTANCE_ID, cConf.get(Constants.Stream.CONTAINER_INSTANCE_ID, "0")); } private Map<String, String> getStreamMetricsContext(Id.Namespace namespaceId) { return ImmutableMap.of(Constants.Metrics.Tag.NAMESPACE, namespaceId.getId(), Constants.Metrics.Tag.COMPONENT, Constants.Gateway.METRICS_CONTEXT, Constants.Metrics.Tag.HANDLER, Constants.Gateway.STREAM_HANDLER_NAME, Constants.Metrics.Tag.INSTANCE_ID, cConf.get(Constants.Stream.CONTAINER_INSTANCE_ID, "0")); } /** * Gets stream properties from the request. If there is request is invalid, response will be made and {@code null} * will be return. */ private StreamProperties getAndValidateConfig(HttpRequest request, HttpResponder responder) { Reader reader = new InputStreamReader(new ChannelBufferInputStream(request.getContent())); StreamProperties properties; try { properties = GSON.fromJson(reader, StreamProperties.class); } catch (Exception e) { responder.sendString(HttpResponseStatus.BAD_REQUEST, "Invalid stream configuration. Please check that the " + "configuration is a valid JSON Object with a valid schema."); return null; } // Validate ttl Long ttl = properties.getTTL(); if (ttl != null && ttl < 0) { responder.sendString(HttpResponseStatus.BAD_REQUEST, "TTL value should be positive."); return null; } // Validate format FormatSpecification formatSpec = properties.getFormat(); if (formatSpec != null) { String formatName = formatSpec.getName(); if (formatName == null) { responder.sendString(HttpResponseStatus.BAD_REQUEST, "A format name must be specified."); return null; } try { // if a format is given, make sure it is a valid format, // check that we can instantiate the format class RecordFormat<?, ?> format = RecordFormats.createInitializedFormat(formatSpec); // the request may contain a null schema, in which case the default schema of the format should be used. // create a new specification object that is guaranteed to have a non-null schema. formatSpec = new FormatSpecification(formatSpec.getName(), format.getSchema(), formatSpec.getSettings()); } catch (UnsupportedTypeException e) { responder.sendString(HttpResponseStatus.BAD_REQUEST, "Format " + formatName + " does not support the requested schema."); return null; } catch (Exception e) { responder.sendString(HttpResponseStatus.BAD_REQUEST, "Invalid format, unable to instantiate format " + formatName); return null; } } // Validate notification threshold Integer threshold = properties.getNotificationThresholdMB(); if (threshold != null && threshold <= 0) { responder.sendString(HttpResponseStatus.BAD_REQUEST, "Threshold value should be greater than zero."); return null; } return new StreamProperties(ttl, formatSpec, threshold, properties.getDescription()); } private RejectedExecutionHandler createAsyncRejectedExecutionHandler() { return new RejectedExecutionHandler() { @Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { if (!executor.isShutdown()) { streamHandlerMetricsContext.increment("collect.async.reject", 1); r.run(); } } }; } /** * Same as calling {@link #getHeaders(HttpRequest, String, ImmutableMap.Builder)} with a new builder. */ private Map<String, String> getHeaders(HttpRequest request, String stream) { return getHeaders(request, stream, ImmutableMap.<String, String>builder()); } /** * Extracts event headers from the HTTP request. Only HTTP headers that are prefixed with "{@code <stream-name>.}" * will be included. The result will be stored in an Immutable map built by the given builder. */ private Map<String, String> getHeaders(HttpRequest request, String stream, ImmutableMap.Builder<String, String> builder) { // and transfer all other headers that are to be preserved String prefix = stream + "."; for (Map.Entry<String, String> header : request.getHeaders()) { if (header.getKey().startsWith(prefix)) { builder.put(header.getKey().substring(prefix.length()), header.getValue()); } } return builder.build(); } /** * Creates a {@link ContentWriterFactory} based on the request size. Used by the batch endpoint. */ private ContentWriterFactory createContentWriterFactory(Id.Stream streamId, HttpRequest request) throws IOException { String contentType = HttpHeaders.getHeader(request, HttpHeaders.Names.CONTENT_TYPE, ""); // The content-type is guaranteed to be non-empty, otherwise the batch request itself will fail. Map<String, String> headers = getHeaders(request, streamId.getId(), ImmutableMap.<String, String>builder().put("content.type", contentType)); StreamConfig config = streamAdmin.getConfig(streamId); return new LengthBasedContentWriterFactory(config, streamWriter, headers, batchBufferThreshold); } /** * Adapter class for {@link StreamProperties}. Its main purpose is to transform * the unit of TTL, which is second in JSON, but millisecond in the StreamProperties object. */ private static final class StreamPropertiesAdapter implements JsonSerializer<StreamProperties>, JsonDeserializer<StreamProperties> { @Override public JsonElement serialize(StreamProperties src, Type typeOfSrc, JsonSerializationContext context) { JsonObject json = new JsonObject(); if (src.getTTL() != null) { json.addProperty("ttl", TimeUnit.MILLISECONDS.toSeconds(src.getTTL())); } if (src.getFormat() != null) { json.add("format", context.serialize(src.getFormat(), FormatSpecification.class)); } if (src.getNotificationThresholdMB() != null) { json.addProperty("notification.threshold.mb", src.getNotificationThresholdMB()); } if (src.getDescription() != null) { json.addProperty("description", src.getDescription()); } return json; } @Override public StreamProperties deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { JsonObject jsonObj = json.getAsJsonObject(); Long ttl = jsonObj.has("ttl") ? TimeUnit.SECONDS.toMillis(jsonObj.get("ttl").getAsLong()) : null; FormatSpecification format = null; if (jsonObj.has("format")) { format = context.deserialize(jsonObj.get("format"), FormatSpecification.class); } Integer threshold = jsonObj.has("notification.threshold.mb") ? jsonObj.get("notification.threshold.mb").getAsInt() : null; String description = jsonObj.has("description") ? jsonObj.get("description").getAsString() : null; return new StreamProperties(ttl, format, threshold, description); } } }