LanceIndexMutationOutcome.java

// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The ASF licenses this file
// to you 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 org.apache.doris.datasource.lance;

import org.apache.doris.common.AnalysisException;
import org.apache.doris.common.ErrorCode;

/**
 * The four-way client-facing outcome of one synchronous Lance index mutation statement (design
 * sections 6.2-6.5).
 *
 * <p>Classification trusts only a complete, identity-matching provider result; message text
 * never participates in the decision (design section 6.5). The four kinds are the whole
 * client-facing contract:
 *
 * <ul>
 * <li>{@link Kind#SUCCESS SUCCESS} ��� a complete trusted native success. The commit is a fact,
 *     and the required metadata refresh finished inside the statement.
 * <li>{@link Kind#CONFIRMED_FAILURE CONFIRMED_FAILURE} ��� nothing was committed, proven either
 *     before the send (nothing was sent: budget exhaustion, admission rejection, a backend
 *     connection that could not be established) or by a complete trusted result: a clean
 *     rejection before the mutating native invocation (for example NOT_IMPLEMENTED from a BE
 *     that has not learned the dispatch RPC) or the typed commit-conflict code.
 * <li>{@link Kind#INDETERMINATE INDETERMINATE} ��� dispatched, but no complete trusted result
 *     arrived (timeout, disconnect, worker loss, protocol ambiguity). The mutation may or may
 *     not have committed.
 * <li>{@link Kind#COMMITTED_REFRESH_INCOMPLETE COMMITTED_REFRESH_INCOMPLETE} ��� a complete
 *     trusted success was received, so the commit stands, but the metadata refresh did not
 *     complete. Reported as its own class so a refresh failure is never rewritten as a build
 *     failure (design section 6.4).
 * </ul>
 *
 * <p>Why INDETERMINATE exists as a distinct class instead of a generic timeout error: the
 * pinned provider ABI loses information by design ��� an aggregate error after the mutating
 * invocation began cannot prove that nothing committed. A blind retry of a possibly-committed
 * mutation could collide with the first attempt's own commit ("already exists") or replace an
 * index the first attempt created, so nothing in Doris retries this class automatically and the
 * client-facing text directs the user to the authoritative metadata
 * ({@code SHOW INDEX FROM} and {@code lance_index_entries()}) instead.
 */
public final class LanceIndexMutationOutcome {

    /** The classification of one mutation statement; exactly one kind per outcome. */
    public enum Kind {
        SUCCESS,
        CONFIRMED_FAILURE,
        INDETERMINATE,
        COMMITTED_REFRESH_INCOMPLETE
    }

    /** The lance result code of a native success; every other value is provider-defined. */
    public static final int LANCE_RESULT_OK = 0;

    /**
     * Bound of the carried message, in UTF-8 bytes. Matches the provider-message bound of
     * {@link LanceErrorMessages}: the message travels inside client-facing error text, so an
     * unbounded provider payload must never reach it.
     */
    public static final int MAX_MESSAGE_BYTES = 1024;

    private final Kind kind;
    private final int lanceResultCode;
    private final String message;

    private LanceIndexMutationOutcome(Kind kind, int lanceResultCode, String message) {
        this.kind = kind;
        this.lanceResultCode = lanceResultCode;
        // Defense in depth: the dispatch layer is expected to pass already-sanitized provider
        // text (LanceErrorMessages), but the model enforces its own bound so no future caller
        // can smuggle an unbounded raw provider payload or control characters into the carried
        // message. The raw text is never stored.
        this.message = LanceErrorMessages.truncateUtf8(
                LanceErrorMessages.removeControlCharacters(message == null ? "" : message),
                MAX_MESSAGE_BYTES);
    }

    /**
     * A complete trusted native success: lance result code 0, no residual diagnostic. The
     * statement returns normally; this outcome carries no client-facing exception.
     */
    public static LanceIndexMutationOutcome success() {
        return new LanceIndexMutationOutcome(Kind.SUCCESS, LANCE_RESULT_OK, "");
    }

    /**
     * A confirmed non-commit. {@code lanceResultCode} is the typed code of the trusted
     * rejecting result, or {@link #LANCE_RESULT_OK} when the failure predates dispatch and no
     * provider result exists (budget exhaustion, no backend, a backend connection that could
     * not be established, admission rejection).
     */
    public static LanceIndexMutationOutcome confirmedFailure(int lanceResultCode, String message) {
        return new LanceIndexMutationOutcome(Kind.CONFIRMED_FAILURE, lanceResultCode, message);
    }

    /**
     * Dispatched but unresolved: no complete trusted result was received. {@code
     * lanceResultCode} is whatever partial or mismatched code was last seen (0 when none was),
     * carried for diagnostics only ��� it never feeds the classification.
     */
    public static LanceIndexMutationOutcome indeterminate(int lanceResultCode, String message) {
        return new LanceIndexMutationOutcome(Kind.INDETERMINATE, lanceResultCode, message);
    }

    /**
     * The commit is a fact ��� a complete trusted native success was received ��� but the metadata
     * refresh did not complete inside the statement.
     */
    public static LanceIndexMutationOutcome committedRefreshIncomplete(int lanceResultCode,
            String message) {
        return new LanceIndexMutationOutcome(Kind.COMMITTED_REFRESH_INCOMPLETE, lanceResultCode,
                message);
    }

    public Kind getKind() {
        return kind;
    }

    /**
     * The typed lance result code of the trusted result that drove the classification: 0 for a
     * native success, 0 when the outcome was classified before any provider result existed,
     * otherwise the provider-defined code.
     */
    public int getLanceResultCode() {
        return lanceResultCode;
    }

    /**
     * The bounded sanitized diagnostic (control characters stripped, UTF-8 truncated to
     * {@link #MAX_MESSAGE_BYTES}); never the raw provider payload.
     */
    public String getMessage() {
        return message;
    }

    /**
     * The client-facing exception for this outcome, each kind on its own stable error code.
     * SUCCESS has no exception ��� the statement returns normally ��� so reaching this method on a
     * success outcome is a caller wiring bug and throws instead of inventing an error.
     */
    public AnalysisException toUserException() {
        switch (kind) {
            case CONFIRMED_FAILURE:
                return userException(ErrorCode.ERR_LANCE_INDEX_MUTATION_REJECTED);
            case INDETERMINATE:
                return userException(ErrorCode.ERR_LANCE_INDEX_MUTATION_INDETERMINATE);
            case COMMITTED_REFRESH_INCOMPLETE:
                return userException(ErrorCode.ERR_LANCE_INDEX_MUTATION_COMMITTED_REFRESH_INCOMPLETE);
            default:
                throw new IllegalStateException(
                        "A successful mutation outcome has no client-facing exception");
        }
    }

    /**
     * Builds the exception directly instead of going through ErrorReport:
     * reportAnalysisException throws the exception from inside the helper, while this model
     * returns the exception for the caller to throw at the statement boundary.
     */
    private AnalysisException userException(ErrorCode errorCode) {
        return new AnalysisException(errorCode.formatErrorMsg(detail()), errorCode);
    }

    private String detail() {
        return message.isEmpty()
                ? "lance result code " + lanceResultCode
                : "lance result code " + lanceResultCode + ", detail: " + message;
    }
}