All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.yahoo.document.restapi.resource.DocumentV1ApiHandler Maven / Gradle / Ivy

There is a newer version: 8.441.21
Show newest version
// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
package com.yahoo.document.restapi.resource;

import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonFactoryBuilder;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.StreamReadConstraints;
import com.yahoo.cloud.config.ClusterListConfig;
import com.yahoo.component.annotation.Inject;
import com.yahoo.concurrent.DaemonThreadFactory;
import com.yahoo.concurrent.SystemTimer;
import com.yahoo.container.core.HandlerMetricContextUtil;
import com.yahoo.container.core.documentapi.VespaDocumentAccess;
import com.yahoo.container.jdisc.ContentChannelOutputStream;
import com.yahoo.document.Document;
import com.yahoo.document.DocumentId;
import com.yahoo.document.DocumentPut;
import com.yahoo.document.DocumentRemove;
import com.yahoo.document.DocumentTypeManager;
import com.yahoo.document.DocumentUpdate;
import com.yahoo.document.FixedBucketSpaces;
import com.yahoo.document.TestAndSetCondition;
import com.yahoo.document.config.DocumentmanagerConfig;
import com.yahoo.document.fieldset.DocIdOnly;
import com.yahoo.document.fieldset.DocumentOnly;
import com.yahoo.document.idstring.IdIdString;
import com.yahoo.document.json.DocumentOperationType;
import com.yahoo.document.json.JsonReader;
import com.yahoo.document.json.JsonWriter;
import com.yahoo.document.json.ParsedDocumentOperation;
import com.yahoo.document.restapi.DocumentOperationExecutorConfig;
import com.yahoo.document.select.parser.ParseException;
import com.yahoo.documentapi.AckToken;
import com.yahoo.documentapi.AsyncParameters;
import com.yahoo.documentapi.AsyncSession;
import com.yahoo.documentapi.DocumentAccess;
import com.yahoo.documentapi.DocumentOperationParameters;
import com.yahoo.documentapi.DocumentResponse;
import com.yahoo.documentapi.ProgressToken;
import com.yahoo.documentapi.Response.Outcome;
import com.yahoo.documentapi.Result;
import com.yahoo.documentapi.VisitorControlHandler;
import com.yahoo.documentapi.VisitorControlSession;
import com.yahoo.documentapi.VisitorDataHandler;
import com.yahoo.documentapi.VisitorParameters;
import com.yahoo.documentapi.VisitorSession;
import com.yahoo.documentapi.messagebus.protocol.DocumentProtocol;
import com.yahoo.documentapi.messagebus.protocol.PutDocumentMessage;
import com.yahoo.documentapi.messagebus.protocol.RemoveDocumentMessage;
import com.yahoo.documentapi.metrics.DocumentApiMetrics;
import com.yahoo.documentapi.metrics.DocumentOperationStatus;
import com.yahoo.jdisc.Metric;
import com.yahoo.jdisc.Request;
import com.yahoo.jdisc.Response;
import com.yahoo.jdisc.Response.Status;
import com.yahoo.jdisc.handler.AbstractRequestHandler;
import com.yahoo.jdisc.handler.BufferedContentChannel;
import com.yahoo.jdisc.handler.CompletionHandler;
import com.yahoo.jdisc.handler.ContentChannel;
import com.yahoo.jdisc.handler.ReadableContentChannel;
import com.yahoo.jdisc.handler.ResponseHandler;
import com.yahoo.jdisc.handler.UnsafeContentInputStream;
import com.yahoo.jdisc.http.HttpRequest;
import com.yahoo.jdisc.http.HttpRequest.Method;
import com.yahoo.messagebus.DynamicThrottlePolicy;
import com.yahoo.messagebus.Message;
import com.yahoo.messagebus.StaticThrottlePolicy;
import com.yahoo.messagebus.Trace;
import com.yahoo.messagebus.TraceNode;
import com.yahoo.metrics.simple.MetricReceiver;
import com.yahoo.restapi.Path;
import com.yahoo.search.query.ParameterParser;
import com.yahoo.text.Text;
import com.yahoo.vespa.config.content.AllClustersBucketSpacesConfig;
import com.yahoo.vespa.http.server.Headers;
import com.yahoo.vespa.http.server.MetricNames;
import com.yahoo.yolean.Exceptions;
import com.yahoo.yolean.Exceptions.RunnableThrowingIOException;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Collection;
import java.util.Collections;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Queue;
import java.util.StringJoiner;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.Executors;
import java.util.concurrent.Phaser;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BiFunction;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.logging.Logger;
import java.util.stream.Stream;

import static com.yahoo.documentapi.DocumentOperationParameters.parameters;
import static com.yahoo.jdisc.http.HttpRequest.Method.DELETE;
import static com.yahoo.jdisc.http.HttpRequest.Method.GET;
import static com.yahoo.jdisc.http.HttpRequest.Method.OPTIONS;
import static com.yahoo.jdisc.http.HttpRequest.Method.POST;
import static com.yahoo.jdisc.http.HttpRequest.Method.PUT;
import static java.util.Objects.requireNonNull;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static java.util.logging.Level.FINE;
import static java.util.logging.Level.WARNING;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toMap;
import static java.util.stream.Collectors.toUnmodifiableMap;

/**
 * Asynchronous HTTP handler for /document/v1
 *
 * @author jonmv
 */
public final class DocumentV1ApiHandler extends AbstractRequestHandler {

    private static final Duration defaultTimeout = Duration.ofSeconds(180); // Match document API default timeout.
    private static final Duration handlerTimeout = Duration.ofMillis(100); // Extra time to allow for handler, JDisc and jetty to complete.

    private static final Logger log = Logger.getLogger(DocumentV1ApiHandler.class.getName());
    private static final Parser integerParser = Integer::parseInt;
    private static final Parser unsignedLongParser = Long::parseUnsignedLong;
    private static final Parser timeoutMillisParser = value -> ParameterParser.asMilliSeconds(value, defaultTimeout.toMillis());
    private static final Parser booleanParser = Boolean::parseBoolean;

    private static final CompletionHandler logException = new CompletionHandler() {
        @Override public void completed() { }
        @Override public void failed(Throwable t) {
            log.log(FINE, "Exception writing or closing response data", t);
        }
    };

    private static final ContentChannel ignoredContent = new ContentChannel() {
        @Override public void write(ByteBuffer buf, CompletionHandler handler) { handler.completed(); }
        @Override public void close(CompletionHandler handler) { handler.completed(); }
    };

    private static final JsonFactory jsonFactory = new JsonFactoryBuilder()
            .streamReadConstraints(StreamReadConstraints.builder().maxStringLength(Integer.MAX_VALUE).build())
            .build();

    private static final String CREATE = "create";
    private static final String CONDITION = "condition";
    private static final String ROUTE = "route";
    private static final String FIELD_SET = "fieldSet";
    private static final String SELECTION = "selection";
    private static final String CLUSTER = "cluster";
    private static final String DESTINATION_CLUSTER = "destinationCluster";
    private static final String CONTINUATION = "continuation";
    private static final String WANTED_DOCUMENT_COUNT = "wantedDocumentCount";
    private static final String CONCURRENCY = "concurrency";
    private static final String BUCKET_SPACE = "bucketSpace";
    private static final String TIME_CHUNK = "timeChunk";
    private static final String TIMEOUT = "timeout";
    private static final String TRACELEVEL = "tracelevel";
    private static final String STREAM = "stream";
    private static final String SLICES = "slices";
    private static final String SLICE_ID = "sliceId";
    private static final String DRY_RUN = "dryRun";
    private static final String FROM_TIMESTAMP = "fromTimestamp";
    private static final String TO_TIMESTAMP = "toTimestamp";
    private static final String INCLUDE_REMOVES = "includeRemoves";

    private final Clock clock;
    private final Duration visitTimeout;
    private final Metric metric;
    private final DocumentApiMetrics metrics;
    private final DocumentOperationParser parser;
    private final long maxThrottled;
    private final long maxThrottledAgeNS;
    private final DocumentAccess access;
    private final AsyncSession asyncSession;
    private final Map clusters;
    private final Deque operations;
    private final Deque visitOperations = new ConcurrentLinkedDeque<>();
    private final AtomicLong enqueued = new AtomicLong();
    private final AtomicLong outstanding = new AtomicLong();
    private final Map visits = new ConcurrentHashMap<>();
    private final ScheduledExecutorService dispatcher = Executors.newSingleThreadScheduledExecutor(new DaemonThreadFactory("document-api-handler-"));
    private final ScheduledExecutorService visitDispatcher = Executors.newSingleThreadScheduledExecutor(new DaemonThreadFactory("document-api-handler-visit-"));
    private final Map> handlers = defineApi();

    @Inject
    public DocumentV1ApiHandler(Metric metric,
                                MetricReceiver metricReceiver,
                                VespaDocumentAccess documentAccess,
                                DocumentmanagerConfig documentManagerConfig,
                                ClusterListConfig clusterListConfig,
                                AllClustersBucketSpacesConfig bucketSpacesConfig,
                                DocumentOperationExecutorConfig executorConfig) {
        this(Clock.systemUTC(), Duration.ofSeconds(5), metric, metricReceiver, documentAccess,
             documentManagerConfig, executorConfig, clusterListConfig, bucketSpacesConfig);
    }

    DocumentV1ApiHandler(Clock clock, Duration visitTimeout, Metric metric, MetricReceiver metricReceiver, DocumentAccess access,
                         DocumentmanagerConfig documentmanagerConfig, DocumentOperationExecutorConfig executorConfig,
                         ClusterListConfig clusterListConfig, AllClustersBucketSpacesConfig bucketSpacesConfig) {
        this.clock = clock;
        this.visitTimeout = visitTimeout;
        this.parser = new DocumentOperationParser(documentmanagerConfig);
        this.metric = metric;
        this.metrics = new DocumentApiMetrics(metricReceiver, "documentV1");
        this.maxThrottled = executorConfig.maxThrottled();
        this.maxThrottledAgeNS = (long) (executorConfig.maxThrottledAge() * 1_000_000_000.0);
        this.access = access;
        this.asyncSession = access.createAsyncSession(new AsyncParameters());
        this.clusters = parseClusters(clusterListConfig, bucketSpacesConfig);
        this.operations = new ConcurrentLinkedDeque<>();
        long resendDelayMS = SystemTimer.adjustTimeoutByDetectedHz(Duration.ofMillis(executorConfig.resendDelayMillis())).toMillis();

        // TODO: Here it would be better to have dedicated threads with different wait depending on blocked or empty.
        this.dispatcher.scheduleWithFixedDelay(this::dispatchEnqueued, resendDelayMS, resendDelayMS, MILLISECONDS);
        this.visitDispatcher.scheduleWithFixedDelay(this::dispatchVisitEnqueued, resendDelayMS, resendDelayMS, MILLISECONDS);
    }

    // ------------------------------------------------ Requests -------------------------------------------------

    @Override
    public ContentChannel handleRequest(Request rawRequest, ResponseHandler rawResponseHandler) {
        HandlerMetricContextUtil.onHandle(rawRequest, metric, getClass());
        ResponseHandler responseHandler = response -> {
            HandlerMetricContextUtil.onHandled(rawRequest, metric, getClass());
            return rawResponseHandler.handleResponse(response);
        };

        HttpRequest request = (HttpRequest) rawRequest;
        try {
            // Set a higher HTTP layer timeout than the document API timeout, to prefer triggering the latter.
            request.setTimeout(doomMillis(request) - clock.millis(), MILLISECONDS);

            Path requestPath = Path.withoutValidation(request.getUri()); // No segment validation here, as document IDs can be anything.
            for (String path : handlers.keySet()) {
                if (requestPath.matches(path)) {
                    Map methods = handlers.get(path);
                    if (methods.containsKey(request.getMethod()))
                        return methods.get(request.getMethod()).handle(request, new DocumentPath(requestPath, request.getUri().getRawPath()), responseHandler);

                    if (request.getMethod() == OPTIONS)
                        options(methods.keySet(), responseHandler);

                    methodNotAllowed(request, methods.keySet(), responseHandler);
                }
            }
            notFound(request, handlers.keySet(), responseHandler);
        }
        catch (IllegalArgumentException e) {
            badRequest(request, e, responseHandler);
        }
        catch (RuntimeException e) {
            serverError(request, e, responseHandler);
        }
        return ignoredContent;
    }

    @Override
    public void handleTimeout(Request request, ResponseHandler responseHandler) {
        HttpRequest httpRequest = (HttpRequest) request;
        timeout(httpRequest, "Timeout after " + (getProperty(httpRequest, TIMEOUT, timeoutMillisParser).orElse(defaultTimeout.toMillis())) + "ms", responseHandler);
    }

    @Override
    public void destroy() {
        Instant doom = clock.instant().plus(Duration.ofSeconds(30));

        // This blocks until all visitors are done. These, in turn, may require the asyncSession to be alive
        // to be able to run, as well as dispatch of operations against it, which is done by visitDispatcher.
        visits.values().forEach(VisitorSession::abort);
        visits.values().forEach(VisitorSession::destroy);

        // Shut down both dispatchers, so only we empty the queues of outstanding operations, and can be sure they're empty.
        dispatcher.shutdown();
        visitDispatcher.shutdown();
        while ( ! (operations.isEmpty() && visitOperations.isEmpty()) && clock.instant().isBefore(doom)) {
            dispatchEnqueued();
            dispatchVisitEnqueued();
        }

        if ( ! operations.isEmpty())
            log.log(WARNING, "Failed to empty request queue before shutdown timeout — " + operations.size() + " requests left");

        if ( ! visitOperations.isEmpty())
            log.log(WARNING, "Failed to empty visitor operations queue before shutdown timeout — " + operations.size() + " operations left");

        try {
            while (outstanding.get() > 0 && clock.instant().isBefore(doom))
                Thread.sleep(Math.max(1, Duration.between(clock.instant(), doom).toMillis()));

            if ( ! dispatcher.awaitTermination(Duration.between(clock.instant(), doom).toMillis(), MILLISECONDS))
                dispatcher.shutdownNow();

            if ( ! visitDispatcher.awaitTermination(Duration.between(clock.instant(), doom).toMillis(), MILLISECONDS))
                visitDispatcher.shutdownNow();
        }
        catch (InterruptedException e) {
            log.log(WARNING, "Interrupted waiting for /document/v1 executor to shut down");
        }
        finally {
            asyncSession.destroy();
            if (outstanding.get() != 0)
                log.log(WARNING, "Failed to receive a response to " + outstanding.get() + " outstanding document operations during shutdown");
        }
    }

    @FunctionalInterface
    interface Handler {
        ContentChannel handle(HttpRequest request, DocumentPath path, ResponseHandler handler);
    }

    /** Defines all paths/methods handled by this handler. */
    private Map> defineApi() {
        Map> handlers = new LinkedHashMap<>();

        handlers.put("/document/v1/",
                     Map.of(GET, this::getDocuments,
                            POST, this::postDocuments,
                            DELETE, this::deleteDocuments));

        handlers.put("/document/v1/{namespace}/{documentType}/docid/",
                     Map.of(GET, this::getDocuments,
                            POST, this::postDocuments,
                            PUT, this::putDocuments,
                            DELETE, this::deleteDocuments));

        handlers.put("/document/v1/{namespace}/{documentType}/group/{group}/",
                     Map.of(GET, this::getDocuments,
                            POST, this::postDocuments,
                            PUT, this::putDocuments,
                            DELETE, this::deleteDocuments));

        handlers.put("/document/v1/{namespace}/{documentType}/number/{number}/",
                     Map.of(GET, this::getDocuments,
                            POST, this::postDocuments,
                            PUT, this::putDocuments,
                            DELETE, this::deleteDocuments));

        handlers.put("/document/v1/{namespace}/{documentType}/docid/{*}",
                     Map.of(GET, this::getDocument,
                            POST, this::postDocument,
                            PUT, this::putDocument,
                            DELETE, this::deleteDocument));

        handlers.put("/document/v1/{namespace}/{documentType}/group/{group}/{*}",
                     Map.of(GET, this::getDocument,
                            POST, this::postDocument,
                            PUT, this::putDocument,
                            DELETE, this::deleteDocument));

        handlers.put("/document/v1/{namespace}/{documentType}/number/{number}/{*}",
                     Map.of(GET, this::getDocument,
                            POST, this::postDocument,
                            PUT, this::putDocument,
                            DELETE, this::deleteDocument));

        return Collections.unmodifiableMap(handlers);
    }

    private ContentChannel getDocuments(HttpRequest request, DocumentPath path, ResponseHandler handler) {
        disallow(request, DRY_RUN);
        enqueueAndDispatch(request, handler, () -> {
            boolean streamed = getProperty(request, STREAM, booleanParser).orElse(false);
            VisitorParameters parameters = parseGetParameters(request, path, streamed);
            return () -> {
                visitAndWrite(request, parameters, handler, streamed);
                return true; // VisitorSession has its own throttle handling.
            };
        });
        return ignoredContent;
    }

    private ContentChannel postDocuments(HttpRequest request, DocumentPath path, ResponseHandler handler) {
        disallow(request, DRY_RUN);
        enqueueAndDispatch(request, handler, () -> {
            StorageCluster destination = resolveCluster(Optional.of(requireProperty(request, DESTINATION_CLUSTER)), clusters);
            VisitorParameters parameters = parseParameters(request, path);
            parameters.setRemoteDataHandler("[Content:cluster=" + destination.name() + "]"); // Bypass indexing.
            parameters.setFieldSet(DocumentOnly.NAME);
            return () -> {
                visitWithRemote(request, parameters, handler);
                return true; // VisitorSession has its own throttle handling.
            };
        });
        return ignoredContent;
    }

    private ContentChannel putDocuments(HttpRequest request, DocumentPath path, ResponseHandler handler) {
        disallow(request, DRY_RUN);
        return new ForwardingContentChannel(in -> {
            enqueueAndDispatch(request, handler, () -> {
                StorageCluster cluster = resolveCluster(Optional.of(requireProperty(request, CLUSTER)), clusters);
                VisitorParameters parameters = parseParameters(request, path);
                parameters.setFieldSet(DocIdOnly.NAME);
                String type = path.documentType().orElseThrow(() -> new IllegalStateException("Document type must be specified for mass updates"));
                IdIdString dummyId = new IdIdString("dummy", type, "", "");
                ParsedDocumentOperation update = parser.parseUpdate(in, dummyId.toString());
                update.operation().setCondition(new TestAndSetCondition(requireProperty(request, SELECTION)));
                return () -> {
                    visitAndUpdate(request, parameters, update.fullyApplied(), handler, (DocumentUpdate)update.operation(), cluster.name());
                    return true; // VisitorSession has its own throttle handling.
                };
            });
        });
    }

    private ContentChannel deleteDocuments(HttpRequest request, DocumentPath path, ResponseHandler handler) {
        disallow(request, DRY_RUN);
        enqueueAndDispatch(request, handler, () -> {
            VisitorParameters parameters = parseParameters(request, path);
            parameters.setFieldSet(DocIdOnly.NAME);
            TestAndSetCondition condition = new TestAndSetCondition(requireProperty(request, SELECTION));
            StorageCluster cluster = resolveCluster(Optional.of(requireProperty(request, CLUSTER)), clusters);
            return () -> {
                visitAndDelete(request, parameters, handler, condition, cluster.name());
                return true; // VisitorSession has its own throttle handling.
            };
        });
        return ignoredContent;
    }

    private ContentChannel getDocument(HttpRequest request, DocumentPath path, ResponseHandler rawHandler) {
        ResponseHandler handler = new MeasuringResponseHandler(request, rawHandler, com.yahoo.documentapi.metrics.DocumentOperationType.GET, clock.instant());
        disallow(request, DRY_RUN);
        enqueueAndDispatch(request, handler, () -> {
            DocumentOperationParameters rawParameters = parametersFromRequest(request, CLUSTER, FIELD_SET);
            if (rawParameters.fieldSet().isEmpty())
                rawParameters = rawParameters.withFieldSet(path.documentType().orElseThrow() + ":[document]");
            DocumentOperationParameters parameters = rawParameters.withResponseHandler(response -> {
                outstanding.decrementAndGet();
                handle(path, request, handler, response, (document, jsonResponse) -> {
                    if (document != null) {
                        jsonResponse.writeSingleDocument(document);
                        jsonResponse.commit(Response.Status.OK);
                    }
                    else
                        jsonResponse.commit(Response.Status.NOT_FOUND);
                });
            });
            return () -> dispatchOperation(() -> asyncSession.get(path.id(), parameters));
        });
        return ignoredContent;
    }

    private ContentChannel postDocument(HttpRequest request, DocumentPath path, ResponseHandler rawHandler) {
        ResponseHandler handler = new MeasuringResponseHandler(
                request, rawHandler, com.yahoo.documentapi.metrics.DocumentOperationType.PUT, clock.instant());
        if (getProperty(request, DRY_RUN, booleanParser).orElse(false)) {
            handleFeedOperation(path, true, handler, new com.yahoo.documentapi.Response(-1));
            return ignoredContent;
        }

        return new ForwardingContentChannel(in -> {
            enqueueAndDispatch(request, handler, () -> {
                ParsedDocumentOperation parsed = parser.parsePut(in, path.id().toString());
                DocumentPut put = (DocumentPut)parsed.operation();
                getProperty(request, CONDITION).map(TestAndSetCondition::new).ifPresent(put::setCondition);
                getProperty(request, CREATE, booleanParser).ifPresent(put::setCreateIfNonExistent);
                DocumentOperationParameters parameters = parametersFromRequest(request, ROUTE)
                        .withResponseHandler(response -> {
                            outstanding.decrementAndGet();
                            updatePutMetrics(response.outcome(), latencyOf(request), put.getCreateIfNonExistent());
                            handleFeedOperation(path, parsed.fullyApplied(), handler, response);
                        });
                return () -> dispatchOperation(() -> asyncSession.put(put, parameters));
            });
        });
    }

    private ContentChannel putDocument(HttpRequest request, DocumentPath path, ResponseHandler rawHandler) {
        ResponseHandler handler = new MeasuringResponseHandler(
                request, rawHandler, com.yahoo.documentapi.metrics.DocumentOperationType.UPDATE, clock.instant());
        if (getProperty(request, DRY_RUN, booleanParser).orElse(false)) {
            handleFeedOperation(path, true, handler, new com.yahoo.documentapi.Response(-1));
            return ignoredContent;
        }

        return new ForwardingContentChannel(in -> {
            enqueueAndDispatch(request, handler, () -> {
                ParsedDocumentOperation parsed = parser.parseUpdate(in, path.id().toString());
                DocumentUpdate update = (DocumentUpdate)parsed.operation();
                getProperty(request, CONDITION).map(TestAndSetCondition::new).ifPresent(update::setCondition);
                getProperty(request, CREATE, booleanParser).ifPresent(update::setCreateIfNonExistent);
                DocumentOperationParameters parameters = parametersFromRequest(request, ROUTE)
                        .withResponseHandler(response -> {
                            outstanding.decrementAndGet();
                            updateUpdateMetrics(response.outcome(), latencyOf(request), update.getCreateIfNonExistent());
                            handleFeedOperation(path, parsed.fullyApplied(), handler, response);
                        });
                return () -> dispatchOperation(() -> asyncSession.update(update, parameters));
            });
        });
    }

    private ContentChannel deleteDocument(HttpRequest request, DocumentPath path, ResponseHandler rawHandler) {
        ResponseHandler handler = new MeasuringResponseHandler(
                request, rawHandler, com.yahoo.documentapi.metrics.DocumentOperationType.REMOVE, clock.instant());
        if (getProperty(request, DRY_RUN, booleanParser).orElse(false)) {
            handleFeedOperation(path, true, handler, new com.yahoo.documentapi.Response(-1));
            return ignoredContent;
        }

        enqueueAndDispatch(request, handler, () -> {
            DocumentRemove remove = new DocumentRemove(path.id());
            getProperty(request, CONDITION).map(TestAndSetCondition::new).ifPresent(remove::setCondition);
            DocumentOperationParameters parameters = parametersFromRequest(request, ROUTE)
                    .withResponseHandler(response -> {
                        outstanding.decrementAndGet();
                        updateRemoveMetrics(response.outcome(), latencyOf(request));
                        handleFeedOperation(path, true, handler, response);
                    });
            return () -> dispatchOperation(() -> asyncSession.remove(remove, parameters));
        });
        return ignoredContent;
    }

    private DocumentOperationParameters parametersFromRequest(HttpRequest request, String... names) {
        DocumentOperationParameters parameters = getProperty(request, TRACELEVEL, integerParser).map(parameters()::withTraceLevel)
                                                                                                .orElse(parameters());
        parameters = parameters.withDeadline(Instant.ofEpochMilli(doomMillis(request)).minus(handlerTimeout));
        for (String name : names)
            parameters = switch (name) {
                case CLUSTER ->
                        getProperty(request, CLUSTER)
                                .map(cluster -> resolveCluster(Optional.of(cluster), clusters).name())
                                .map(parameters::withRoute)
                                .orElse(parameters);
                case FIELD_SET -> getProperty(request, FIELD_SET).map(parameters::withFieldSet).orElse(parameters);
                case ROUTE -> getProperty(request, ROUTE).map(parameters::withRoute).orElse(parameters);
                default ->
                        throw new IllegalArgumentException("Unrecognized document operation parameter name '" + name + "'");
            };
        return parameters;
    }

    /** Dispatches enqueued requests until one is blocked. */
    void dispatchEnqueued() {
        try {
            while (dispatchFirst());
        }
        catch (Exception e) {
            log.log(WARNING, "Uncaught exception in /document/v1 dispatch thread", e);
        }
    }

    /** Attempts to dispatch the first enqueued operations, and returns whether this was successful. */
    private boolean dispatchFirst() {
        Operation operation = operations.poll();
        if (operation == null)
            return false;

        if (operation.dispatch()) {
            enqueued.decrementAndGet();
            return true;
        }
        operations.push(operation);
        return false;
    }

    /** Dispatches enqueued requests until one is blocked. */
    private void dispatchVisitEnqueued() {
        try {
            while (dispatchFirstVisit());
        }
        catch (Exception e) {
            log.log(WARNING, "Uncaught exception in /document/v1 dispatch thread", e);
        }
    }

    /** Attempts to dispatch the first enqueued visit operations, and returns whether this was successful. */
    private boolean dispatchFirstVisit() {
        BooleanSupplier operation = visitOperations.poll();
        if (operation == null)
            return false;

        if (operation.getAsBoolean())
            return true;

        visitOperations.push(operation);
        return false;
    }

    private long qAgeNS(HttpRequest request) {
        Operation oldest = operations.peek();
        return (oldest != null)
                ? (request.relativeCreatedAtNanoTime() - oldest.request.relativeCreatedAtNanoTime())
                : 0;
    }

    /**
     * Enqueues the given request and operation, or responds with "overload" if the queue is full,
     * and then attempts to dispatch an enqueued operation from the head of the queue.
     */
    private void enqueueAndDispatch(HttpRequest request, ResponseHandler handler, Supplier operationParser) {
        long numQueued = enqueued.incrementAndGet();
        if (numQueued > maxThrottled) {
            enqueued.decrementAndGet();
            overload(request, "Rejecting execution due to overload: "
                    + maxThrottled + " requests already enqueued", handler);
            return;
        }
        if (numQueued > 1) {
            long ageNS = qAgeNS(request);
            if (ageNS > maxThrottledAgeNS) {
                enqueued.decrementAndGet();
                overload(request, "Rejecting execution due to overload: "
                        + maxThrottledAgeNS / 1_000_000_000.0 + " seconds worth of work enqueued", handler);
                return;
            }
        }
        operations.offer(new Operation(request, handler, operationParser));
        dispatchFirst();
    }


    // ------------------------------------------------ Responses ------------------------------------------------

    /** Class for writing and returning JSON responses to document operations in a thread safe manner. */
    private static class JsonResponse implements AutoCloseable {

        private static final ByteBuffer emptyBuffer = ByteBuffer.wrap(new byte[0]);
        private static final int FLUSH_SIZE = 128;

        private final BufferedContentChannel buffer = new BufferedContentChannel();
        private final OutputStream out = new ContentChannelOutputStream(buffer);
        private final JsonGenerator json;
        private final ResponseHandler handler;
        private final HttpRequest request;
        private final Queue acks = new ConcurrentLinkedQueue<>();
        private final Queue docs = new ConcurrentLinkedQueue<>();
        private final AtomicLong documentsWritten = new AtomicLong();
        private final AtomicLong documentsFlushed = new AtomicLong();
        private final AtomicLong documentsAcked = new AtomicLong();
        private boolean documentsDone = false;
        private boolean first = true;
        private ContentChannel channel;

        private JsonResponse(ResponseHandler handler, HttpRequest request) throws IOException {
            this.handler = handler;
            this.request = request;
            json = jsonFactory.createGenerator(out);
            json.writeStartObject();
        }

        /** Creates a new JsonResponse with path and id fields written. */
        static JsonResponse create(DocumentPath path, ResponseHandler handler, HttpRequest request) throws IOException {
            JsonResponse response = new JsonResponse(handler, request);
            response.writePathId(path.rawPath());
            response.writeDocId(path.id());
            return response;
        }

        /** Creates a new JsonResponse with path field written. */
        static JsonResponse create(HttpRequest request, ResponseHandler handler) throws IOException {
            JsonResponse response = new JsonResponse(handler, request);
            response.writePathId(request.getUri().getRawPath());
            return response;
        }

        /** Creates a new JsonResponse with path and message fields written. */
        static JsonResponse create(HttpRequest request, String message, ResponseHandler handler) throws IOException {
            JsonResponse response = create(request, handler);
            response.writeMessage(message);
            return response;
        }

        synchronized void commit(int status) throws IOException {
            commit(status, true);
        }

        /** Commits a response with the given status code and some default headers, and writes whatever content is buffered. */
        synchronized void commit(int status, boolean fullyApplied) throws IOException {
            Response response = new Response(status);
            response.headers().add("Content-Type", List.of("application/json; charset=UTF-8"));
            if (! fullyApplied)
                response.headers().add(Headers.IGNORED_FIELDS, "true");
            try {
                channel = handler.handleResponse(response);
                buffer.connectTo(channel);
            }
            catch (RuntimeException e) {
                throw new IOException(e);
            }
        }

        /** Commits a response with the given status code and some default headers, writes buffered content, and closes this. */
        synchronized void respond(int status) throws IOException {
            try (this) {
                commit(status);
            }
        }

        /** Closes the JSON and the output content channel of this. */
        @Override
        public synchronized void close() throws IOException {
            documentsDone = true; // In case we were closed without explicitly closing the documents array.
            try {
                if (channel == null) {
                    log.log(WARNING, "Close called before response was committed, in " + getClass().getName());
                    commit(Response.Status.INTERNAL_SERVER_ERROR);
                }
                json.close(); // Also closes object and array scopes.
                out.close();  // Simply flushes the output stream.
            }
            finally {
                if (channel != null)
                    channel.close(logException); // Closes the response handler's content channel.
            }
        }

        synchronized void writePathId(String path) throws IOException {
            json.writeStringField("pathId", path);
        }

        synchronized void writeMessage(String message) throws IOException {
            json.writeStringField("message", message);
        }

        synchronized void writeDocumentCount(long count) throws IOException {
            json.writeNumberField("documentCount", count);
        }

        synchronized void writeDocId(DocumentId id) throws IOException {
            json.writeStringField("id", id.toString());
        }

        synchronized void writeTrace(Trace trace) throws IOException {
            if (trace != null && ! trace.getRoot().isEmpty()) {
                writeTrace(trace.getRoot());
            }
        }

        private void writeTrace(TraceNode node) throws IOException {
            if (node.hasNote())
                json.writeStringField("message", node.getNote());
            if ( ! node.isLeaf()) {
                json.writeArrayFieldStart(node.isStrict() ? "trace" : "fork");
                for (int i = 0; i < node.getNumChildren(); i++) {
                    json.writeStartObject();
                    writeTrace(node.getChild(i));
                    json.writeEndObject();
                }
                json.writeEndArray();
            }
        }

        private boolean tensorShortForm() {
            return request == null ||
                    !request.parameters().containsKey("format.tensors") ||
                    (!request.parameters().get("format.tensors").contains("long")
                            && !request.parameters().get("format.tensors").contains("long-value"));// default
        }

        private boolean tensorDirectValues() {
            return request != null &&
                    request.parameters().containsKey("format.tensors") &&
                    (request.parameters().get("format.tensors").contains("short-value")
                            || request.parameters().get("format.tensors").contains("long-value"));// TODO: Flip default on Vespa 9
        }

        synchronized void writeSingleDocument(Document document) throws IOException {
            new JsonWriter(json, tensorShortForm(), tensorDirectValues()).writeFields(document);
        }

        synchronized void writeDocumentsArrayStart() throws IOException {
            json.writeArrayFieldStart("documents");
        }

        private interface DocumentWriter {
            void write(ByteArrayOutputStream out) throws IOException;
        }

        /** Writes documents to an internal queue, which is flushed regularly. */
        void writeDocumentValue(Document document, CompletionHandler completionHandler) throws IOException {
            writeDocument(myOut -> {
                try (JsonGenerator myJson = jsonFactory.createGenerator(myOut)) {
                    new JsonWriter(myJson, tensorShortForm(), tensorDirectValues()).write(document);
                }
            }, completionHandler);
        }

        void writeDocumentRemoval(DocumentId id, CompletionHandler completionHandler) throws IOException {
            writeDocument(myOut -> {
                try (JsonGenerator myJson = jsonFactory.createGenerator(myOut)) {
                    myJson.writeStartObject();
                    myJson.writeStringField("remove", id.toString());
                    myJson.writeEndObject();
                }
            }, completionHandler);
        }

        /** Writes documents to an internal queue, which is flushed regularly. */
        void writeDocument(DocumentWriter documentWriter, CompletionHandler completionHandler) throws IOException {
            if (completionHandler != null) {
                acks.add(completionHandler);
                ackDocuments();
            }

            // Serialise document and add to queue, not necessarily in the order dictated by "written" above,
            // i.e., the first 128 documents in the queue are not necessarily the ones ack'ed early.
            ByteArrayOutputStream myOut = new ByteArrayOutputStream(1);
            myOut.write(','); // Prepend rather than append, to avoid double memory copying.
            documentWriter.write(myOut);
            docs.add(myOut);

            // Flush the first FLUSH_SIZE documents in the queue to the network layer if chunk is filled.
            if (documentsWritten.incrementAndGet() % FLUSH_SIZE == 0) {
                flushDocuments();
            }
        }

        void ackDocuments() {
            while (documentsAcked.incrementAndGet() <= documentsFlushed.get() + FLUSH_SIZE) {
                CompletionHandler ack = acks.poll();
                if (ack != null)
                    ack.completed();
                else
                    break;
            }
            documentsAcked.decrementAndGet(); // We overshoot by one above, so decrement again when done.
        }

        synchronized void flushDocuments() throws IOException {
            for (int i = 0; i < FLUSH_SIZE; i++) {
                ByteArrayOutputStream doc = docs.poll();
                if (doc == null)
                    break;

                if ( ! documentsDone) {
                    if (first) { // First chunk, remove leading comma from first document, and flush "json" to "buffer".
                        json.flush();
                        buffer.write(ByteBuffer.wrap(doc.toByteArray(), 1, doc.size() - 1), null);
                        first = false;
                    }
                    else {
                        buffer.write(ByteBuffer.wrap(doc.toByteArray()), null);
                    }
                }
            }

            // Ensure new, eligible acks are done, after flushing these documents.
            buffer.write(emptyBuffer, new CompletionHandler() {
                @Override public void completed() {
                    documentsFlushed.addAndGet(FLUSH_SIZE);
                    ackDocuments();
                }
                @Override public void failed(Throwable t) {
                    // This is typically caused by the client closing the connection during production of the response content.
                    log.log(FINE, "Error writing documents", t);
                    completed();
                }
            });
        }

        synchronized void writeArrayEnd() throws IOException {
            flushDocuments();
            documentsDone = true;
            json.writeEndArray();
        }

        synchronized void writeContinuation(String token) throws IOException {
            json.writeStringField("continuation", token);
        }

    }

    private static void options(Collection methods, ResponseHandler handler) {
        loggingException(() -> {
            Response response = new Response(Response.Status.NO_CONTENT);
            response.headers().add("Allow", methods.stream().sorted().map(Method::name).collect(joining(",")));
            handler.handleResponse(response).close(logException);
        });
    }

    private static void badRequest(HttpRequest request, IllegalArgumentException e, ResponseHandler handler) {
        loggingException(() -> {
            String message = Exceptions.toMessageString(e);
            log.log(FINE, () -> "Bad request for " + request.getMethod() + " at " + request.getUri().getRawPath() + ": " + message);
            JsonResponse.create(request, message, handler).respond(Response.Status.BAD_REQUEST);
        });
    }

    private static void notFound(HttpRequest request, Collection paths, ResponseHandler handler) {
        loggingException(() -> {
        JsonResponse.create(request,
                           "Nothing at '" + request.getUri().getRawPath() + "'. " +
                           "Available paths are:\n" + String.join("\n", paths),
                            handler)
                    .respond(Response.Status.NOT_FOUND);
        });
    }

    private static void methodNotAllowed(HttpRequest request, Collection methods, ResponseHandler handler) {
        loggingException(() -> {
            JsonResponse.create(request,
                               "'" + request.getMethod() + "' not allowed at '" + request.getUri().getRawPath() + "'. " +
                               "Allowed methods are: " + methods.stream().sorted().map(Method::name).collect(joining(", ")),
                                handler)
                        .respond(Response.Status.METHOD_NOT_ALLOWED);
        });
    }

    private static void overload(HttpRequest request, String message, ResponseHandler handler) {
        loggingException(() -> {
            log.log(FINE, () -> "Overload handling request " + request.getMethod() + " " + request.getUri().getRawPath() + ": " + message);
            JsonResponse.create(request, message, handler).respond(Response.Status.TOO_MANY_REQUESTS);
        });
    }

    private static void serverError(HttpRequest request, Throwable t, ResponseHandler handler) {
        loggingException(() -> {
            log.log(WARNING, "Uncaught exception handling request " + request.getMethod() + " " + request.getUri().getRawPath(), t);
            JsonResponse.create(request, Exceptions.toMessageString(t), handler).respond(Response.Status.INTERNAL_SERVER_ERROR);
        });
    }

    private static void timeout(HttpRequest request, String message, ResponseHandler handler) {
        loggingException(() -> {
            log.log(FINE, () -> "Timeout handling request " + request.getMethod() + " " + request.getUri().getRawPath() + ": " + message);
            JsonResponse.create(request, message, handler).respond(Response.Status.GATEWAY_TIMEOUT);
        });
    }

    private static void loggingException(RunnableThrowingIOException runnable) {
        try {
            runnable.run();
        }
        catch (Exception e) {
            log.log(FINE, "Failed writing response", e);
        }
    }

    // -------------------------------------------- Document Operations ----------------------------------------

    private static class Operation {

        private final Lock lock = new ReentrantLock();
        private final HttpRequest request;
        private final ResponseHandler handler;
        private BooleanSupplier operation; // The operation to attempt until it returns success.
        private Supplier parser; // The unparsed operation—getting this will parse it.

        Operation(HttpRequest request, ResponseHandler handler, Supplier parser) {
            this.request = request;
            this.handler = handler;
            this.parser = parser;
        }

        /**
         * Attempts to dispatch this operation to the document API, and returns whether this completed or not.
         * Returns {@code} true if dispatch was successful, or if it failed fatally; or {@code false} if
         * dispatch should be retried at a later time.
         */
        boolean dispatch() {
            if (request.isCancelled())
                return true;

            if ( ! lock.tryLock())
                throw new IllegalStateException("Concurrent attempts at dispatch — this is a bug");

            try {
                if (operation == null) {
                    operation = parser.get();
                    parser = null;
                }

                return operation.getAsBoolean();
            }
            catch (IllegalArgumentException e) {
                badRequest(request, e, handler);
            }
            catch (RuntimeException e) {
                serverError(request, e, handler);
            }
            finally {
                lock.unlock();
            }
            return true;
        }

    }

    /** Attempts to send the given document operation, returning false if this needs to be retried. */
    private boolean dispatchOperation(Supplier documentOperation) {
        Result result = documentOperation.get();
        if (result.type() == Result.ResultType.TRANSIENT_ERROR)
            return false;

        if (result.type() == Result.ResultType.FATAL_ERROR)
            throw new DispatchException(new Throwable(result.error().toString()));

        outstanding.incrementAndGet();
        return true;
    }

    private static class DispatchException extends RuntimeException {
        private DispatchException(Throwable cause) { super(cause); }
    }

    /** Readable content channel which forwards data to a reader when closed. */
    static class ForwardingContentChannel implements ContentChannel {

        private final ReadableContentChannel delegate = new ReadableContentChannel();
        private final Consumer reader;
        private volatile boolean errorReported = false;

        public ForwardingContentChannel(Consumer reader) {
            this.reader = reader;
        }

        /** Write is complete when we have stored the buffer — call completion handler. */
        @Override
        public void write(ByteBuffer buf, CompletionHandler handler) {
            try {
                delegate.write(buf, logException);
                handler.completed();
            }
            catch (Exception e) {
                handler.failed(e);
            }
        }

        /** Close is complete when we have closed the buffer. */
        @Override
        public void close(CompletionHandler handler) {
            try {
                delegate.close(logException);
                if (!errorReported) {
                    reader.accept(new UnsafeContentInputStream(delegate));
                }
                handler.completed();
            }
            catch (Exception e) {
                handler.failed(e);
            }
        }

        @Override
        public void onError(Throwable error) {
            // Jdisc will automatically generate an error response in this scenario
            log.log(FINE, error, () -> "ContentChannel.onError(): " + error.getMessage());
            errorReported = true;
        }
    }

    class DocumentOperationParser {

        private final DocumentTypeManager manager;

        DocumentOperationParser(DocumentmanagerConfig config) {
            this.manager = new DocumentTypeManager(config);
        }

        ParsedDocumentOperation parsePut(InputStream inputStream, String docId) {
            return parse(inputStream, docId, DocumentOperationType.PUT);
        }

        ParsedDocumentOperation parseUpdate(InputStream inputStream, String docId)  {
            return parse(inputStream, docId, DocumentOperationType.UPDATE);
        }

        private ParsedDocumentOperation parse(InputStream inputStream, String docId, DocumentOperationType operation) {
            try {
                return new JsonReader(manager, inputStream, jsonFactory).readSingleDocumentStreaming(operation, docId);
            } catch (IllegalArgumentException e) {
                incrementMetricParseError();
                throw e;
            }
        }

    }

    interface SuccessCallback {
        void onSuccess(Document document, JsonResponse response) throws IOException;
    }

    private static void handle(DocumentPath path,
                               HttpRequest request,
                               ResponseHandler handler,
                               com.yahoo.documentapi.Response response,
                               SuccessCallback callback) {
        try (JsonResponse jsonResponse = JsonResponse.create(path, handler, request)) {
            jsonResponse.writeTrace(response.getTrace());
            if (response.isSuccess())
                callback.onSuccess((response instanceof DocumentResponse) ? ((DocumentResponse) response).getDocument() : null, jsonResponse);
            else {
                jsonResponse.writeMessage(response.getTextMessage());
                switch (response.outcome()) {
                    case NOT_FOUND -> jsonResponse.commit(Response.Status.NOT_FOUND);
                    case CONDITION_FAILED -> jsonResponse.commit(Response.Status.PRECONDITION_FAILED);
                    case INSUFFICIENT_STORAGE -> jsonResponse.commit(Response.Status.INSUFFICIENT_STORAGE);
                    case TIMEOUT -> jsonResponse.commit(Response.Status.GATEWAY_TIMEOUT);
                    case ERROR -> {
                        log.log(FINE, () -> "Exception performing document operation: " + response.getTextMessage());
                        jsonResponse.commit(Status.INTERNAL_SERVER_ERROR);
                    }
                    default -> {
                        log.log(WARNING, "Unexpected document API operation outcome '" + response.outcome() + "' " + response.getTextMessage());
                        jsonResponse.commit(Status.INTERNAL_SERVER_ERROR);
                    }
                }
            }
        }
        catch (Exception e) {
            log.log(FINE, "Failed writing response", e);
        }
    }

    private static void handleFeedOperation(DocumentPath path,
                                            boolean fullyApplied,
                                            ResponseHandler handler,
                                            com.yahoo.documentapi.Response response) {
        handle(path, null, handler, response, (document, jsonResponse) -> jsonResponse.commit(Response.Status.OK, fullyApplied));
    }

    private static double latencyOf(HttpRequest r) { return (System.nanoTime() - r.relativeCreatedAtNanoTime()) / 1e+9d; }

    private void updatePutMetrics(Outcome outcome, double latency, boolean create) {
        if (create && outcome == Outcome.NOT_FOUND) outcome = Outcome.SUCCESS; // >_<
        incrementMetricNumOperations(); incrementMetricNumPuts(); sampleLatency(latency);
        switch (outcome) {
            case SUCCESS -> incrementMetricSucceeded();
            case NOT_FOUND -> incrementMetricNotFound();
            case CONDITION_FAILED -> incrementMetricConditionNotMet();
            case TIMEOUT -> { incrementMetricFailedTimeout(); incrementMetricFailed();}
            case INSUFFICIENT_STORAGE -> { incrementMetricFailedInsufficientStorage(); incrementMetricFailed(); }
            case ERROR -> { incrementMetricFailedUnknown(); incrementMetricFailed(); }
        }
    }

    private void updateUpdateMetrics(Outcome outcome, double latency, boolean create) {
        if (create && outcome == Outcome.NOT_FOUND) outcome = Outcome.SUCCESS; // >_<
        incrementMetricNumOperations(); incrementMetricNumUpdates(); sampleLatency(latency);
        switch (outcome) {
            case SUCCESS -> incrementMetricSucceeded();
            case NOT_FOUND -> incrementMetricNotFound();
            case CONDITION_FAILED -> incrementMetricConditionNotMet();
            case TIMEOUT -> { incrementMetricFailedTimeout(); incrementMetricFailed();}
            case INSUFFICIENT_STORAGE -> { incrementMetricFailedInsufficientStorage(); incrementMetricFailed(); }
            case ERROR -> { incrementMetricFailedUnknown(); incrementMetricFailed(); }
        }
    }

    private void updateRemoveMetrics(Outcome outcome, double latency) {
        incrementMetricNumOperations(); incrementMetricNumRemoves(); sampleLatency(latency);
        switch (outcome) {
            case SUCCESS,NOT_FOUND -> incrementMetricSucceeded();
            case CONDITION_FAILED -> incrementMetricConditionNotMet();
            case TIMEOUT -> { incrementMetricFailedTimeout(); incrementMetricFailed();}
            case INSUFFICIENT_STORAGE -> { incrementMetricFailedInsufficientStorage(); incrementMetricFailed(); }
            case ERROR -> { incrementMetricFailedUnknown(); incrementMetricFailed(); }
        }
    }

    private void sampleLatency(double latency) { setMetric(MetricNames.LATENCY, latency); }
    private void incrementMetricNumOperations() { incrementMetric(MetricNames.NUM_OPERATIONS); }
    private void incrementMetricNumPuts() { incrementMetric(MetricNames.NUM_PUTS); }
    private void incrementMetricNumRemoves() { incrementMetric(MetricNames.NUM_REMOVES); }
    private void incrementMetricNumUpdates() { incrementMetric(MetricNames.NUM_UPDATES); }
    private void incrementMetricFailed() { incrementMetric(MetricNames.FAILED); }
    private void incrementMetricConditionNotMet() { incrementMetric(MetricNames.CONDITION_NOT_MET); }
    private void incrementMetricSucceeded() { incrementMetric(MetricNames.SUCCEEDED); }
    private void incrementMetricNotFound() { incrementMetric(MetricNames.NOT_FOUND); }
    private void incrementMetricParseError() { incrementMetric(MetricNames.PARSE_ERROR); }
    private void incrementMetricFailedUnknown() { incrementMetric(MetricNames.FAILED_UNKNOWN); }
    private void incrementMetricFailedTimeout() { incrementMetric(MetricNames.FAILED_TIMEOUT); }
    private void incrementMetricFailedInsufficientStorage() { incrementMetric(MetricNames.FAILED_INSUFFICIENT_STORAGE); }
    private void incrementMetric(String n) { metric.add(n, 1, null); }
    private void setMetric(String n, Number v) { metric.set(n, v, null); }

    // ------------------------------------------------- Visits ------------------------------------------------

    private VisitorParameters parseGetParameters(HttpRequest request, DocumentPath path, boolean streamed) {
        int wantedDocumentCount = getProperty(request, WANTED_DOCUMENT_COUNT, integerParser)
                .orElse(streamed ? Integer.MAX_VALUE : 1);
        if (wantedDocumentCount <= 0)
            throw new IllegalArgumentException("wantedDocumentCount must be positive");

        Optional concurrency = getProperty(request, CONCURRENCY, integerParser);
        concurrency.ifPresent(value -> {
            if (value <= 0)
                throw new IllegalArgumentException("concurrency must be positive");
        });

        Optional cluster = getProperty(request, CLUSTER);
        if (cluster.isEmpty() && path.documentType().isEmpty())
            throw new IllegalArgumentException("Must set 'cluster' parameter to a valid content cluster id when visiting at a root /document/v1/ level");

        VisitorParameters parameters = parseCommonParameters(request, path, cluster);
        // TODO can the else-case be safely reduced to always be DocumentOnly.NAME?
        parameters.setFieldSet(getProperty(request, FIELD_SET).orElse(path.documentType().map(type -> type + ":[document]").orElse(DocumentOnly.NAME)));
        parameters.setMaxTotalHits(wantedDocumentCount);
        parameters.visitInconsistentBuckets(true);
        getProperty(request, INCLUDE_REMOVES, booleanParser).ifPresent(parameters::setVisitRemoves);
        if (streamed) {
            StaticThrottlePolicy throttlePolicy = new DynamicThrottlePolicy().setMinWindowSize(1).setWindowSizeIncrement(1);
            concurrency.ifPresent(throttlePolicy::setMaxPendingCount);
            parameters.setThrottlePolicy(throttlePolicy);
            parameters.setTimeoutMs(visitTimeout(request)); // Ensure visitor eventually completes.
        }
        else {
            parameters.setThrottlePolicy(new StaticThrottlePolicy().setMaxPendingCount(Math.min(100, concurrency.orElse(1))));
            parameters.setSessionTimeoutMs(visitTimeout(request));
        }
        return parameters;
    }

    private VisitorParameters parseParameters(HttpRequest request, DocumentPath path) {
        disallow(request, CONCURRENCY, FIELD_SET, ROUTE, WANTED_DOCUMENT_COUNT);
        requireProperty(request, SELECTION);
        VisitorParameters parameters = parseCommonParameters(request, path, Optional.of(requireProperty(request, CLUSTER)));
        parameters.setThrottlePolicy(new DynamicThrottlePolicy().setMinWindowSize(1).setWindowSizeIncrement(1));
        long timeChunk = getProperty(request, TIME_CHUNK, timeoutMillisParser).orElse(60_000L);
        parameters.setSessionTimeoutMs(Math.min(timeChunk, visitTimeout(request)));
        return parameters;
    }

    private long visitTimeout(HttpRequest request) {
        return Math.max(1,
                        Math.max(doomMillis(request) - clock.millis() - visitTimeout.toMillis(),
                                 9 * (doomMillis(request) - clock.millis()) / 10 - handlerTimeout.toMillis()));
    }

    private VisitorParameters parseCommonParameters(HttpRequest request, DocumentPath path, Optional cluster) {
        VisitorParameters parameters = new VisitorParameters(Stream.of(getProperty(request, SELECTION),
                                                                       path.documentType(),
                                                                       path.namespace().map(value -> "id.namespace=='" + value + "'"),
                                                                       path.group().map(Group::selection))
                                                                   .flatMap(Optional::stream)
                                                                   .reduce(new StringJoiner(") and (", "(", ")").setEmptyValue(""), // don't mind the lonely chicken to the right
                                                                           StringJoiner::add,
                                                                           StringJoiner::merge)
                                                                   .toString());

        getProperty(request, TRACELEVEL, integerParser).ifPresent(parameters::setTraceLevel);

        getProperty(request, CONTINUATION, ProgressToken::fromSerializedString).ifPresent(parameters::setResumeToken);
        parameters.setPriority(DocumentProtocol.Priority.NORMAL_4);

        getProperty(request, FROM_TIMESTAMP, unsignedLongParser).ifPresent(parameters::setFromTimestamp);
        getProperty(request, TO_TIMESTAMP, unsignedLongParser).ifPresent(ts -> {
            parameters.setToTimestamp(ts);
            if (Long.compareUnsigned(parameters.getFromTimestamp(), parameters.getToTimestamp()) > 0) {
                throw new IllegalArgumentException("toTimestamp must be greater than, or equal to, fromTimestamp");
            }
        });

        StorageCluster storageCluster = resolveCluster(cluster, clusters);
        parameters.setRoute(storageCluster.name());
        parameters.setBucketSpace(resolveBucket(storageCluster,
                                                path.documentType(),
                                                List.of(FixedBucketSpaces.defaultSpace(), FixedBucketSpaces.globalSpace()),
                                                getProperty(request, BUCKET_SPACE)));

        Optional slices = getProperty(request, SLICES, integerParser);
        Optional sliceId = getProperty(request, SLICE_ID, integerParser);
        if (slices.isPresent() && sliceId.isPresent())
            parameters.slice(slices.get(), sliceId.get());
        else if (slices.isPresent() != sliceId.isPresent())
            throw new IllegalArgumentException("None or both of '" + SLICES + "' and '" + SLICE_ID + "' must be set");

        return parameters;
    }

    private interface VisitCallback {
        /** Called at the start of response rendering. */
        default void onStart(JsonResponse response, boolean fullyApplied) throws IOException { }

        /** Called for every document or removal received from backend visitors—must call the ack for these to proceed. */
        default void onDocument(JsonResponse response, Document document, DocumentId removeId, long persistedTimestamp, Runnable ack, Consumer onError) { }

        /** Called at the end of response rendering, before generic status data is written. Called from a dedicated thread pool. */
        default void onEnd(JsonResponse response) throws IOException { }
    }

    @FunctionalInterface
    private interface VisitProcessingCallback {
        Result apply(DocumentId id, long persistedTimestamp, DocumentOperationParameters params);
    }

    private void visitAndDelete(HttpRequest request, VisitorParameters parameters, ResponseHandler handler,
                                TestAndSetCondition condition, String route) {
        visitAndProcess(request, parameters, true, handler, route, (id, timestamp, operationParameters) -> {
            DocumentRemove remove = new DocumentRemove(id);
            // If the backend provided a persisted timestamp, we set a condition that specifies _both_ the
            // original selection and the timestamp. If the backend supports timestamp-predicated TaS operations,
            // it will ignore the selection entirely and only look at the timestamp. If it does not, it will fall
            // back to evaluating the selection, which preserves legacy behavior.
            if (timestamp != 0) {
                remove.setCondition(TestAndSetCondition.ofRequiredTimestampWithSelectionFallback(
                        timestamp, condition.getSelection()));
            } else {
                remove.setCondition(condition);
            }
            return asyncSession.remove(remove, operationParameters);
        });
    }

    private void visitAndUpdate(HttpRequest request, VisitorParameters parameters, boolean fullyApplied,
                                ResponseHandler handler, DocumentUpdate protoUpdate, String route) {
        visitAndProcess(request, parameters, fullyApplied, handler, route, (id, timestamp, operationParameters) -> {
            DocumentUpdate update = new DocumentUpdate(protoUpdate);
            // See `visitAndDelete()` for rationale for sending down a timestamp _and_ the original condition.
            if (timestamp != 0) {
                update.setCondition(TestAndSetCondition.ofRequiredTimestampWithSelectionFallback(
                        timestamp, protoUpdate.getCondition().getSelection()));
            } // else: use condition already set from protoUpdate
            update.setId(id);
            return asyncSession.update(update, operationParameters);
        });
    }

    private void visitAndProcess(HttpRequest request, VisitorParameters parameters, boolean fullyApplied,
                                 ResponseHandler handler,
                                 String route, VisitProcessingCallback operation) {
        visit(request, parameters, false, fullyApplied, handler, new VisitCallback() {
            @Override public void onDocument(JsonResponse response, Document document, DocumentId removeId,
                                             long persistedTimestamp, Runnable ack, Consumer onError) {
                DocumentOperationParameters operationParameters = parameters().withRoute(route)
                        .withResponseHandler(operationResponse -> {
                            outstanding.decrementAndGet();
                            switch (operationResponse.outcome()) {
                                case SUCCESS:
                                case NOT_FOUND:
                                case CONDITION_FAILED:
                                    break; // This is all OK — the latter two are due to mitigating races.
                                case ERROR:
                                case INSUFFICIENT_STORAGE:
                                case TIMEOUT:
                                    onError.accept(operationResponse.getTextMessage());
                                    break;
                                default:
                                    onError.accept("Unexpected response " + operationResponse);
                            }
                        });
                visitOperations.offer(() -> {
                    Result result = operation.apply(document.getId(), persistedTimestamp, operationParameters);
                    if (result.type() == Result.ResultType.TRANSIENT_ERROR)
                        return false;

                    if (result.type() == Result.ResultType.FATAL_ERROR)
                        onError.accept(result.error().getMessage());
                    else
                        outstanding.incrementAndGet();

                    ack.run();
                    return true;
                });
                dispatchFirstVisit();
            }
        });
    }

    private void visitAndWrite(HttpRequest request, VisitorParameters parameters, ResponseHandler handler, boolean streamed) {
        visit(request, parameters, streamed, true, handler, new VisitCallback() {
            @Override public void onStart(JsonResponse response, boolean fullyApplied) throws IOException {
                if (streamed)
                    response.commit(Response.Status.OK, fullyApplied);

                response.writeDocumentsArrayStart();
            }
            @Override public void onDocument(JsonResponse response, Document document, DocumentId removeId,
                                             long persistedTimestamp, Runnable ack, Consumer onError) {
                try {
                    if (streamed) {
                        CompletionHandler completion = new CompletionHandler() {
                            @Override public void completed() { ack.run(); }
                            @Override public void failed(Throwable t) {
                                ack.run();
                                onError.accept(t.getMessage());
                            }
                        };
                        if (document != null) response.writeDocumentValue(document, completion);
                        else response.writeDocumentRemoval(removeId, completion);
                    }
                    else {
                        if (document != null) response.writeDocumentValue(document, null);
                        else response.writeDocumentRemoval(removeId, null);
                        ack.run();
                    }
                }
                catch (Exception e) {
                    onError.accept(e.getMessage());
                }
            }
            @Override public void onEnd(JsonResponse response) throws IOException {
                response.writeArrayEnd();
            }
        });
    }

    private void visitWithRemote(HttpRequest request, VisitorParameters parameters, ResponseHandler handler) {
        visit(request, parameters, false, true, handler, new VisitCallback() { });
    }

    @SuppressWarnings("fallthrough")
    private void visit(HttpRequest request, VisitorParameters parameters, boolean streaming, boolean fullyApplied, ResponseHandler handler, VisitCallback callback) {
        try {
            JsonResponse response = JsonResponse.create(request, handler);
            Phaser phaser = new Phaser(2); // Synchronize this thread (dispatch) with the visitor callback thread.
            AtomicReference error = new AtomicReference<>(); // Set if error occurs during processing of visited documents.
            callback.onStart(response, fullyApplied);
            final AtomicLong locallyReceivedDocCount = new AtomicLong(0);
            VisitorControlHandler controller = new VisitorControlHandler() {
                final ScheduledFuture abort = streaming ? visitDispatcher.schedule(this::abort, visitTimeout(request), MILLISECONDS) : null;
                final AtomicReference session = new AtomicReference<>();
                @Override public void setSession(VisitorControlSession session) { // Workaround for broken session API ಠ_ಠ
                    super.setSession(session);
                    if (session instanceof VisitorSession visitorSession) this.session.set(visitorSession);
                }
                @Override public void onDone(CompletionCode code, String message) {
                    super.onDone(code, message);
                    loggingException(() -> {
                        try (response) {
                            callback.onEnd(response);

                            // Locally tracked document count is only correct if we have a local data handler.
                            // Otherwise, we have to report the statistics received transitively from the content nodes.
                            long statsDocCount = (getVisitorStatistics() != null ? getVisitorStatistics().getDocumentsVisited() : 0);
                            response.writeDocumentCount(parameters.getLocalDataHandler() != null ? locallyReceivedDocCount.get() : statsDocCount);

                            if (session.get() != null)
                                response.writeTrace(session.get().getTrace());

                            int status = Status.INTERNAL_SERVER_ERROR;
                            switch (code) {
                                case TIMEOUT: // Intentional fallthrough.
                                case ABORTED:
                                    if (error.get() == null && ! hasVisitedAnyBuckets() && parameters.getVisitInconsistentBuckets()) {
                                        response.writeMessage("No buckets visited within timeout of " +
                                                              parameters.getSessionTimeoutMs() + "ms (request timeout -5s)");
                                        status = Response.Status.GATEWAY_TIMEOUT;
                                        break;
                                    }
                                case SUCCESS:
                                    if (error.get() == null) {
                                        ProgressToken progress = getProgress() != null ? getProgress() : parameters.getResumeToken();
                                        if (progress != null && ! progress.isFinished())
                                            response.writeContinuation(progress.serializeToString());

                                        status = Response.Status.OK;
                                        break;
                                    }
                                default:
                                    response.writeMessage(error.get() != null ? error.get() : message != null ? message : "Visiting failed");
                            }
                            if ( ! streaming)
                                response.commit(status, fullyApplied);
                        }
                    });
                    if (abort != null) abort.cancel(false); // Avoid keeping scheduled future alive if this completes in any other fashion.
                    visitDispatcher.execute(() -> {
                        phaser.arriveAndAwaitAdvance(); // We may get here while dispatching thread is still putting us in the map.
                        visits.remove(this).destroy();
                    });
                }
            };
            if (parameters.getRemoteDataHandler() == null) {
                parameters.setLocalDataHandler(new VisitorDataHandler() {
                    @Override public void onMessage(Message m, AckToken token) {
                        Document document = null;
                        DocumentId removeId = null;
                        long persistedTimestamp = 0;
                        if (m instanceof PutDocumentMessage put) {
                            document = put.getDocumentPut().getDocument();
                            persistedTimestamp = put.getPersistedTimestamp();
                        } else if (parameters.visitRemoves() && m instanceof RemoveDocumentMessage remove) {
                            removeId = remove.getDocumentId();
                            persistedTimestamp = remove.getPersistedTimestamp();
                        } else {
                            throw new UnsupportedOperationException("Got unsupported message type: " + m.getClass().getName());
                        }
                        locallyReceivedDocCount.getAndAdd(1);
                        callback.onDocument(response,
                                            document,
                                            removeId,
                                            persistedTimestamp,
                                            () -> ack(token),
                                            errorMessage -> {
                                                error.set(errorMessage);
                                                controller.abort();
                                            });
                    }
                });
            }
            parameters.setControlHandler(controller);
            visits.put(controller, access.createVisitorSession(parameters));
            phaser.arriveAndDeregister();
        }
        catch (ParseException e) {
            badRequest(request, new IllegalArgumentException(e), handler);
        }
        catch (IOException e) {
            log.log(FINE, "Failed writing response", e);
        }
    }

    // ------------------------------------------------ Helpers ------------------------------------------------

    private static long doomMillis(HttpRequest request) {
        long createdAtMillis = request.creationTime(MILLISECONDS);
        long requestTimeoutMillis = getProperty(request, TIMEOUT, timeoutMillisParser).orElse(defaultTimeout.toMillis());
        return createdAtMillis + requestTimeoutMillis;
    }

    private static String requireProperty(HttpRequest request, String name) {
        return getProperty(request, name)
                .orElseThrow(() -> new IllegalArgumentException("Must specify '" + name + "' at '" + request.getUri().getRawPath() + "'"));
    }

    /** Returns the last property with the given name, if present, or throws if this is empty or blank. */
    private static Optional getProperty(HttpRequest request, String name) {
        if ( ! request.parameters().containsKey(name))
            return Optional.empty();

        List values = request.parameters().get(name);
        String value;
        if (values == null || values.isEmpty() || (value = values.get(values.size() - 1)) == null || value.isEmpty())
            throw new IllegalArgumentException("Expected non-empty value for request property '" + name + "'");

        return Optional.of(value);
    }

    private static  Optional getProperty(HttpRequest request, String name, Parser parser) {
        return getProperty(request, name).map(parser::parse);
    }

    private static void disallow(HttpRequest request, String... properties) {
        for (String property : properties)
            if (request.parameters().containsKey(property))
                throw new IllegalArgumentException("May not specify '" + property + "' at '" + request.getUri().getRawPath() + "'");
    }

    @FunctionalInterface
    interface Parser extends Function {
        default T parse(String value) {
            try {
                return apply(value);
            }
            catch (RuntimeException e) {
                throw new IllegalArgumentException("Failed parsing '" + value + "': " + Exceptions.toMessageString(e));
            }
        }
    }

    private class MeasuringResponseHandler implements ResponseHandler {

        private final ResponseHandler delegate;
        private final com.yahoo.documentapi.metrics.DocumentOperationType type;
        private final Instant start;
        private final HttpRequest request;

        private MeasuringResponseHandler(HttpRequest request,
                                         ResponseHandler delegate,
                                         com.yahoo.documentapi.metrics.DocumentOperationType type,
                                         Instant start) {
            this.request = request;
            this.delegate = delegate;
            this.type = type;
            this.start = start;
        }

        @Override
        public ContentChannel handleResponse(Response response) {
            switch (response.getStatus()) {
                case 200 -> report(DocumentOperationStatus.OK);
                case 400 -> report(DocumentOperationStatus.REQUEST_ERROR);
                case 404 -> report(DocumentOperationStatus.NOT_FOUND);
                case 412 -> report(DocumentOperationStatus.CONDITION_FAILED);
                case 429 -> report(DocumentOperationStatus.TOO_MANY_REQUESTS);
                case 500,503,504,507 -> report(DocumentOperationStatus.SERVER_ERROR);
                default -> throw new IllegalStateException("Unexpected status code '%s'".formatted(response.getStatus()));
            }
            metrics.reportHttpRequest(clientVersion());
            return delegate.handleResponse(response);
        }

        private void report(DocumentOperationStatus... status) { metrics.report(type, start, status); }

        private String clientVersion() {
            return Optional.ofNullable(request.headers().get(Headers.CLIENT_VERSION))
                    .filter(l -> !l.isEmpty()).map(l -> l.get(0))
                    .orElse("unknown");
        }

    }

    static class StorageCluster {

        private final String name;
        private final Map documentBuckets;

        StorageCluster(String name, Map documentBuckets) {
            this.name = requireNonNull(name);
            this.documentBuckets = Map.copyOf(documentBuckets);
        }

        String name() { return name; }
        Optional bucketOf(String documentType) { return Optional.ofNullable(documentBuckets.get(documentType)); }

    }

    private static Map parseClusters(ClusterListConfig clusters, AllClustersBucketSpacesConfig buckets) {
        return clusters.storage().stream()
                       .collect(toUnmodifiableMap(ClusterListConfig.Storage::name,
                                                  storage -> new StorageCluster(storage.name(),
                                                                                buckets.cluster(storage.name())
                                                                                       .documentType().entrySet().stream()
                                                                                       .collect(toMap(Map.Entry::getKey,
                                                                                                      entry -> entry.getValue().bucketSpace())))));
    }

    static StorageCluster resolveCluster(Optional wanted, Map clusters) {
        if (clusters.isEmpty())
            throw new IllegalArgumentException("Your Vespa deployment has no content clusters, so the document API is not enabled");

        return wanted.map(cluster -> {
            if ( ! clusters.containsKey(cluster))
                throw new IllegalArgumentException("Your Vespa deployment has no content cluster '" + cluster + "', only '" +
                                                   String.join("', '", clusters.keySet()) + "'");

            return clusters.get(cluster);
        }).orElseGet(() -> {
            if (clusters.size() > 1)
                throw new IllegalArgumentException("Please specify one of the content clusters in your Vespa deployment: '" +
                                                   String.join("', '", clusters.keySet()) + "'");

            return clusters.values().iterator().next();
        });
    }

    static String resolveBucket(StorageCluster cluster, Optional documentType,
                                List bucketSpaces, Optional bucketSpace) {
        return documentType.map(type -> cluster.bucketOf(type)
                                               .orElseThrow(() -> new IllegalArgumentException("There is no document type '" + type + "' in cluster '" + cluster.name() +
                                                                                               "', only '" + String.join("', '", cluster.documentBuckets.keySet()) + "'")))
                           .or(() -> bucketSpace.map(space -> {
                               if ( ! bucketSpaces.contains(space))
                                   throw new IllegalArgumentException("Bucket space '" + space + "' is not a known bucket space; expected one of " +
                                                                      String.join(", ", bucketSpaces));
                               return space;
                           }))
                           .orElse(FixedBucketSpaces.defaultSpace());
    }

    private static class DocumentPath {

        private final Path path;
        private final String rawPath;
        private final Optional group;

        DocumentPath(Path path, String rawPath) {
            this.path = requireNonNull(path);
            this.rawPath = requireNonNull(rawPath);
            this.group = Optional.ofNullable(path.get("number")).map(unsignedLongParser::parse).map(Group::of)
                                 .or(() -> Optional.ofNullable(path.get("group")).map(Group::of));
        }

        DocumentId id() {
            return new DocumentId("id:" + requireNonNull(path.get("namespace")) +
                                  ":" + requireNonNull(path.get("documentType")) +
                                  ":" + group.map(Group::docIdPart).orElse("") +
                                  ":" + String.join("/", requireNonNull(path.getRest()).segments())); // :'(
        }

        String rawPath() { return rawPath; }
        Optional documentType() { return Optional.ofNullable(path.get("documentType")); }
        Optional namespace() { return Optional.ofNullable(path.get("namespace")); }
        Optional group() { return group; }

    }

    static class Group {

        private final String docIdPart;
        private final String selection;

        private Group(String docIdPart, String selection) {
            this.docIdPart = docIdPart;
            this.selection = selection;
        }

        public static Group of(long value) {
            String stringValue = Long.toUnsignedString(value);
            return new Group("n=" + stringValue, "id.user==" + stringValue);
        }

        public static Group of(String value) {
            Text.validateTextString(value)
                .ifPresent(codePoint -> { throw new IllegalArgumentException(String.format("Illegal code point U%04X in group", codePoint)); });

            return new Group("g=" + value, "id.group=='" + value.replaceAll("'", "\\\\'") + "'");
        }

        public String docIdPart() { return docIdPart; }
        public String selection() { return selection; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
            Group group = (Group) o;
            return docIdPart.equals(group.docIdPart) &&
                   selection.equals(group.selection);
        }

        @Override
        public int hashCode() {
            return Objects.hash(docIdPart, selection);
        }

        @Override
        public String toString() {
            return "Group{" +
                   "docIdPart='" + docIdPart + '\'' +
                   ", selection='" + selection + '\'' +
                   '}';
        }

    }

}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy