diff --git a/client/src/com/aerospike/client/AerospikeException.java b/client/src/com/aerospike/client/AerospikeException.java index b8bf673ff..dad470abd 100644 --- a/client/src/com/aerospike/client/AerospikeException.java +++ b/client/src/com/aerospike/client/AerospikeException.java @@ -32,6 +32,8 @@ public class AerospikeException extends RuntimeException { protected transient Policy policy; protected List subExceptions; protected int resultCode = ResultCode.CLIENT_ERROR; + protected int subCode = SubCode.NONE; + protected ExpressionTrace expTrace; protected int iteration = -1; protected boolean inDoubt; @@ -40,6 +42,12 @@ public AerospikeException(int resultCode, String message) { this.resultCode = resultCode; } + public AerospikeException(int resultCode, String message, int subcode) { + super(message); + this.resultCode = resultCode; + this.subCode = subcode; + } + public AerospikeException(int resultCode, Throwable e) { super(e); this.resultCode = resultCode; @@ -184,6 +192,49 @@ public final int getResultCode() { return resultCode; } + /** + * Get the server-supplied error subcode, or {@link SubCode#NONE} (0) when the + * server did not return one (verbosity disabled, or the failing branch had no + * dispatchable subcode). + *

+ * A subcode is only meaningful when interpreted together with + * {@link #getResultCode()}: subcode integer values are scoped to their parent + * result code and are NOT globally unique. Dispatch on the + * {@code (resultCode, subcode)} pair. See {@link SubCode}. + */ + public final int getSubCode() { + return subCode; + } + + /** + * Set the server-supplied error subcode. + */ + public final void setSubCode(int subcode) { + this.subCode = subcode; + } + + /** + * Get the server-supplied expression build trace, or {@code null} when absent. + *

+ * Populated only at error-detail verbosity 3 (see + * {@link com.aerospike.client.policy.Policy#errorDetailVerbosity}) on an expression + * build failure — a metadata filter ({@code filter_exp}) or an {@code exp_read}/ + * {@code exp_write} operation that the server could not build. Such failures carry + * {@link ResultCode#PARAMETER_ERROR} and {@link SubCode#NONE}. {@code null} on every + * other failure (including non-expression failures at verbosity 3). See + * {@link ExpressionTrace}. + */ + public final ExpressionTrace getExpressionTrace() { + return expTrace; + } + + /** + * Set the server-supplied expression build trace. + */ + public final void setExpressionTrace(ExpressionTrace expTrace) { + this.expTrace = expTrace; + } + /** * Get number of attempts before failing. */ diff --git a/client/src/com/aerospike/client/ExpressionTrace.java b/client/src/com/aerospike/client/ExpressionTrace.java new file mode 100644 index 000000000..34ac51a03 --- /dev/null +++ b/client/src/com/aerospike/client/ExpressionTrace.java @@ -0,0 +1,245 @@ +/* + * Copyright 2012-2026 Aerospike, Inc. + * + * Portions may be licensed to Aerospike, Inc. under one or more contributor + * license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.aerospike.client; + +import java.io.Serializable; +import java.util.Arrays; + +/** + * Structured expression build/eval trace surfaced at error-detail verbosity 3. + *

+ * When extended error detail is requested at verbosity 3 (see + * {@link com.aerospike.client.policy.Policy#errorDetailVerbosity}) and the server + * fails to build an expression — a metadata/predicate filter ({@code filter_exp}) + * or an {@code exp_read}/{@code exp_write} operation — it attaches this trace as a + * nested map under the field-45 error-detail key {@link #AS_ERROR_DETAIL_KEY_EXP_TRACE}. + * It is surfaced on {@link AerospikeException#getExpressionTrace()}. + *

+ * Expression build failures carry {@link ResultCode#PARAMETER_ERROR} and + * {@link SubCode#NONE} (no subcode); the contextual message is on the exception. + * The trace is purely additive diagnostic detail — it never changes the result + * code, subcode, or message-string format. + *

+ * Every field is optional. The server caps the whole error-detail payload + * and drops {@code snippet} first, then {@code path}, when the budget is tight, so + * those may be absent even within a present trace. Absent integer fields read as + * {@code -1} (except {@link #getLang()}, which defaults to {@link #LANG_MSGPACK}); + * absent object fields read as {@code null}. Never require any field. + *

+ * Two coordinate spaces — do not conflate them. {@link #getByteOffset()} is a + * byte offset into the msgpack expression payload the client sent. The + * {@link #getAelOffset()}/{@link #getAelSpan()} pair are offsets into AEL source + * text — a different coordinate space, reserved for a future server branch and + * absent on today's msgpack build traces. + *

+ * The nested-map key/value constants below mirror the server's {@code proto.h} names + * so they stay greppable across repositories. They are append-only. + */ +public final class ExpressionTrace implements Serializable { + private static final long serialVersionUID = 1L; + + //------------------------------------------------------- + // Wire constants (mirror server proto.h). + //------------------------------------------------------- + + /** Top-level field-45 error-detail key carrying the nested expression-trace map. */ + public static final int AS_ERROR_DETAIL_KEY_EXP_TRACE = 3; + + /** Nested trace key: phase (uint; {@link #PHASE_BUILD} / {@link #PHASE_EVAL}). */ + public static final int KEY_PHASE = 1; + /** Nested trace key: byte_offset into the msgpack expression payload (uint). */ + public static final int KEY_BYTE_OFFSET = 2; + /** Nested trace key: failing op name (str). */ + public static final int KEY_OP = 3; + /** Nested trace key: true nesting depth of the fault (uint). */ + public static final int KEY_DEPTH = 4; + /** Nested trace key: op-name chain root→fault (array of str). */ + public static final int KEY_PATH = 5; + /** Nested trace key: human-only rendered snippet of the failing element (str). */ + public static final int KEY_SNIPPET = 6; + /** Nested trace key: eval-phase outcome (uint; reserved, SERVER-1138). */ + public static final int KEY_OUTCOME = 7; + /** Nested trace key: source language (uint; {@link #LANG_MSGPACK} / {@link #LANG_AEL}). */ + public static final int KEY_LANG = 8; + /** Nested trace key: char offset into AEL source text (uint; reserved). */ + public static final int KEY_AEL_OFFSET = 9; + /** Nested trace key: byte width of the offending AEL source region (uint; reserved). */ + public static final int KEY_AEL_SPAN = 10; + /** Nested trace key: 1-based line in AEL source (uint; reserved). */ + public static final int KEY_AEL_LINE = 11; + /** Nested trace key: 1-based column in AEL source (uint; reserved). */ + public static final int KEY_AEL_COL = 12; + + /** Phase value: expression build failed. */ + public static final int PHASE_BUILD = 1; + /** Phase value: expression evaluation failed (reserved, SERVER-1138). */ + public static final int PHASE_EVAL = 2; + + /** Source language: msgpack (the implied default when {@code lang} is absent). */ + public static final int LANG_MSGPACK = 1; + /** Source language: AEL DSL (reserved for a future server branch). */ + public static final int LANG_AEL = 2; + + /** + * The {@code "..."} sentinel the server splices into {@link #getPath()} when the + * true nesting depth exceeds the path-frame cap. {@link #getDepth()} still reports + * the true count. + */ + public static final String PATH_TRUNCATION_SENTINEL = "..."; + + //------------------------------------------------------- + // Fields (all optional; sentinels mark "absent"). + //------------------------------------------------------- + + private final int phase; + private final int byteOffset; + private final String op; + private final int depth; + private final String[] path; + private final String snippet; + private final int lang; + private final int aelOffset; + private final int aelSpan; + + /** + * Construct a trace. Use {@code -1} / {@code null} for any absent field. + * + * @param phase {@link #PHASE_BUILD} / {@link #PHASE_EVAL}, or {@code -1} if absent + * @param byteOffset byte offset into the msgpack expression payload, or {@code -1} + * @param op failing op name, or {@code null} + * @param depth true nesting depth of the fault, or {@code -1} + * @param path op-name chain root→fault, or {@code null} + * @param snippet rendered snippet of the failing element, or {@code null} + * @param lang {@link #LANG_MSGPACK} / {@link #LANG_AEL}, or {@code -1} (⇒ msgpack) + * @param aelOffset char offset into AEL source text, or {@code -1} + * @param aelSpan byte width of the offending AEL source region, or {@code -1} + */ + public ExpressionTrace(int phase, int byteOffset, String op, int depth, String[] path, + String snippet, int lang, int aelOffset, int aelSpan) { + this.phase = phase; + this.byteOffset = byteOffset; + this.op = op; + this.depth = depth; + this.path = path; + this.snippet = snippet; + this.lang = lang; + this.aelOffset = aelOffset; + this.aelSpan = aelSpan; + } + + /** + * Phase that failed: {@link #PHASE_BUILD} or {@link #PHASE_EVAL}. Returns {@code -1} + * when absent. Today the server emits build traces only ({@code PHASE_BUILD}). + */ + public int getPhase() { + return phase; + } + + /** + * Byte offset into the msgpack expression payload of the failing element, or + * {@code -1} when absent. This is a coordinate into the wire payload the client + * sent — not into AEL source text (see {@link #getAelOffset()}). + */ + public int getByteOffset() { + return byteOffset; + } + + /** + * Failing op name (pre-rendered server-side), or {@code null} when absent. + */ + public String getOp() { + return op; + } + + /** + * True nesting depth of the fault, or {@code -1} when absent. Reports the true + * count even when {@link #getPath()} was truncated to the frame cap. + */ + public int getDepth() { + return depth; + } + + /** + * Op-name chain from root to fault, or {@code null} when absent. May contain a + * {@link #PATH_TRUNCATION_SENTINEL} ({@code "..."}) element mid-array when the true + * nesting exceeded the server's path-frame cap; {@link #getDepth()} still reports + * the true count. + */ + public String[] getPath() { + return path; + } + + /** + * Human-only rendered snippet of the failing element, or {@code null} when absent + * (it is the first field the server drops under a tight byte budget). + */ + public String getSnippet() { + return snippet; + } + + /** + * Source language: {@link #LANG_MSGPACK} or {@link #LANG_AEL}. An absent {@code lang} + * key means msgpack (the default), so this returns {@link #LANG_MSGPACK} when the + * server omitted it. + */ + public int getLang() { + return (lang < 0) ? LANG_MSGPACK : lang; + } + + /** + * Char offset into the AEL source text, or {@code -1} when absent. Reserved for the + * AEL DSL branch; absent on today's msgpack build traces. A different coordinate + * space from {@link #getByteOffset()}. + */ + public int getAelOffset() { + return aelOffset; + } + + /** + * Byte width of the offending AEL source region, or {@code -1} when absent. + * Reserved for the AEL DSL branch. + */ + public int getAelSpan() { + return aelSpan; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(128); + sb.append("ExpressionTrace[phase=").append(phase); + sb.append(", byteOffset=").append(byteOffset); + if (op != null) { + sb.append(", op=").append(op); + } + sb.append(", depth=").append(depth); + if (path != null) { + sb.append(", path=").append(Arrays.toString(path)); + } + if (snippet != null) { + sb.append(", snippet=").append(snippet); + } + sb.append(", lang=").append(getLang()); + if (aelOffset >= 0) { + sb.append(", aelOffset=").append(aelOffset); + } + if (aelSpan >= 0) { + sb.append(", aelSpan=").append(aelSpan); + } + sb.append(']'); + return sb.toString(); + } +} diff --git a/client/src/com/aerospike/client/SubCode.java b/client/src/com/aerospike/client/SubCode.java new file mode 100644 index 000000000..57e913337 --- /dev/null +++ b/client/src/com/aerospike/client/SubCode.java @@ -0,0 +1,215 @@ +/* + * Copyright 2012-2026 Aerospike, Inc. + * + * Portions may be licensed to Aerospike, Inc. under one or more contributor + * license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.aerospike.client; + +/** + * Server error detail subcodes. + *

+ * When extended error detail is requested (see + * {@link com.aerospike.client.policy.Policy#errorDetailVerbosity}), the server may + * attach a numeric subcode to a failure response. The subcode is surfaced on + * {@link AerospikeException#getSubCode()}. + *

+ * Match on the {@code (resultCode, subcode)} pair. Subcode integer values are + * scoped to their parent {@link ResultCode} and are not globally unique — the + * value {@code 1}, for example, recurs under every parent status. A subcode is only + * meaningful when interpreted together with the result code. Always check the result + * code first: + *

{@code
+ * catch (AerospikeException ae) {
+ *     if (ae.getResultCode() == ResultCode.OP_NOT_APPLICABLE &&
+ *         ae.getSubcode() == SubCode.OPNOT_CDT_BOUNDED_LIST_OVERFLOW) {
+ *         // roll to a fresh partition / apply backpressure
+ *     }
+ * }
+ * }
+ *

+ * {@link #NONE} (0) means "no subcode" — it is reserved universally and is the value + * returned when the server did not send a subcode (verbosity disabled, or the failing + * branch had no dispatchable subcode). + *

+ * This catalogue mirrors the server's per-status enums in + * {@code as/include/base/proto.h} and is server-version-specific. It is append-only: + * published values are immutable and are never renumbered or reused. New failure modes + * get new values appended to their group. Treat any subcode value not declared here as + * an opaque integer rather than assuming it is absent. + */ +public final class SubCode { + /** + * No subcode (universal). Returned when the server did not supply a subcode. + */ + public static final int NONE = 0; + + //------------------------------------------------------- + // Pairs with ResultCode.PARAMETER_ERROR (4) [AS_ERR_PARAMETER] + //------------------------------------------------------- + + /** Per-record TTL exceeds the namespace's max-ttl. */ + public static final int PARAM_TTL_INVALID = 1; + + /** Bit op offset lands past the blob (or above the proto cap). */ + public static final int PARAM_BITS_OFFSET_OUT_OF_RANGE = 2; + + /** Bit op size is out of range (e.g. zero, or too large). */ + public static final int PARAM_BITS_SIZE_OUT_OF_RANGE = 3; + + /** Blob resize would exceed the maximum blob size. */ + public static final int PARAM_BITS_RESIZE_EXCEEDED = 4; + + /** Write would exceed the per-record bin-count limit (write path). */ + public static final int PARAM_BIN_COUNT_TOO_LARGE = 5; + + /** String op wire/expression args malformed or out of range. */ + public static final int PARAM_STRING_OP_PARAMS_INVALID = 6; + + /** String op code or modifier/read class mismatch on the wire path. */ + public static final int PARAM_STRING_OP_INVALID = 7; + + /** String context-eval path malformed. */ + public static final int PARAM_STRING_CTX_NOT_APPLICABLE = 8; + + /** String modify/read index or code-point range out of bounds. */ + public static final int PARAM_STRING_INDEX_OUT_OF_BOUNDS = 9; + + /** String regex pattern invalid (compile / ICU failure). */ + public static final int PARAM_STRING_REGEX_INVALID = 10; + + /** String or string op argument is not valid UTF-8. */ + public static final int PARAM_STRING_UTF8_INVALID = 11; + + //------------------------------------------------------- + // Pairs with ResultCode.PARTITION_UNAVAILABLE (11) [AS_ERR_UNAVAILABLE] + //------------------------------------------------------- + + /** Cluster is still resolving initial partition balance at startup. */ + public static final int UNAVAIL_INITIAL_BALANCE_UNRESOLVED = 1; + + /** A needed replica is unavailable (likely a partition split). */ + public static final int UNAVAIL_REPLICA_UNAVAILABLE = 2; + + //------------------------------------------------------- + // Pairs with ResultCode.UNSUPPORTED_FEATURE (16) [AS_ERR_UNSUPPORTED_FEATURE] + //------------------------------------------------------- + + /** MRT attempted against a non-SC (AP) namespace. */ + public static final int UNSUPP_FEAT_MRT_REQUIRES_STRONG_CONSISTENCY = 1; + + /** Requested feature is unsupported in this context (generic). */ + public static final int UNSUPP_FEAT_GENERIC = 2; + + //------------------------------------------------------- + // Pairs with ResultCode.BIN_NOT_FOUND (17) [AS_ERR_BIN_NOT_FOUND] + //------------------------------------------------------- + + /** HLL op needs an existing bin and can't auto-create one. */ + public static final int BIN_NOT_FOUND_HLL_CANNOT_CREATE_WITH_OP = 1; + + /** String modify on a missing bin (non-NO_FAIL path). */ + public static final int BIN_NOT_FOUND_STRING_VALUE_NOT_FOUND = 2; + + //------------------------------------------------------- + // Pairs with ResultCode.BIN_NAME_TOO_LONG (21) [AS_ERR_BIN_NAME] + //------------------------------------------------------- + + /** Write would exceed the per-record bin-count limit (UDF path). */ + public static final int BIN_NAME_COUNT_TOO_LARGE = 1; + + //------------------------------------------------------- + // Pairs with ResultCode.FAIL_FORBIDDEN (22) [AS_ERR_FORBIDDEN] + //------------------------------------------------------- + + /** Write bounced by an XDR ship filter at the destination. */ + public static final int FORBID_XDR_FILTER_BLOCKED = 1; + + /** Set-level record-count stop-writes limit reached. */ + public static final int FORBID_SET_COUNT_STOP_WRITES = 2; + + /** Set-level size stop-writes limit reached. */ + public static final int FORBID_SET_SIZE_STOP_WRITES = 3; + + /** Writes stopped due to cluster clock skew. */ + public static final int FORBID_CLOCK_SKEW_STOP_WRITES = 4; + + /** REPLACE / CREATE_OR_REPLACE forbidden while resolving conflicts. */ + public static final int FORBID_REPLACE_CONFLICT_RESOLVING = 5; + + /** Write forbidden because the set/namespace is mid-truncate. */ + public static final int FORBID_TRUNCATED = 6; + + // Note: server subcodes 7 and 9 in this family are retired (masking violations + // return ROLE_VIOLATION, not FORBIDDEN) and are intentionally not declared. + + /** Non-durable delete forbidden (would violate durability). */ + public static final int FORBID_DURABILITY_VIOLATION = 8; + + //------------------------------------------------------- + // Pairs with ResultCode.OP_NOT_APPLICABLE (26) [AS_ERR_OP_NOT_APPLICABLE] + //------------------------------------------------------- + + /** List index is outside the current element range. */ + public static final int OPNOT_CDT_INDEX_OUT_OF_BOUNDS = 1; + + /** Requested rank is past the current population. */ + public static final int OPNOT_CDT_RANK_OUT_OF_BOUNDS = 2; + + /** Insert would exceed an ordered+bounded list's cap. */ + public static final int OPNOT_CDT_BOUNDED_LIST_OVERFLOW = 3; + + /** HLL op needs index_bits but the sketch has none set. */ + public static final int OPNOT_HLL_INDEX_BITS_UNSET = 4; + + /** Union needs to reduce index_bits but folding isn't allowed. */ + public static final int OPNOT_HLL_CANNOT_REDUCE_INDEX_BITS = 5; + + /** As above, for the minhash dimension. */ + public static final int OPNOT_HLL_CANNOT_REDUCE_MINHASH_BITS = 6; + + /** Fold blocked because the sketch carries minhash bits. */ + public static final int OPNOT_HLL_CANNOT_FOLD_MINHASH = 7; + + /** Fold target index_bits >= current (fold can only reduce). */ + public static final int OPNOT_HLL_FOLD_INDEX_BITS_TOO_LARGE = 8; + + /** Intersect inputs have mismatched minhash parameters. */ + public static final int OPNOT_HLL_INTERSECT_MINHASH_MISMATCH = 9; + + /** String to numeric conversion failed (strtoll/strtod). */ + public static final int OPNOT_STRING_CONVERSION_FAILED = 10; + + /** Source blob/string is not valid UTF-8 for an OP_NOT_APPLICABLE path. */ + public static final int OPNOT_STRING_UTF8_INVALID = 11; + + //------------------------------------------------------- + // ResultCode.FILTERED_OUT (27) [AS_ERR_FILTERED_OUT] carries NO subcode: + // the server emits AS_SUB_NONE plus a contextual "filtered out ..." message. + // (The as_sub_filtered_t enum was removed server-side and never shipped, so + // no FILTERED_* constants are defined here. Match on the message, not a subcode.) + //------------------------------------------------------- + + //------------------------------------------------------- + // Pairs with ResultCode.MRT_BLOCKED (120) [AS_ERR_MRT_BLOCKED] + //------------------------------------------------------- + + /** Record is provisionally locked by another MRT. */ + public static final int MRT_BLOCKED_RECORD_LOCKED = 1; + + /** Op belongs to a different MRT than the one holding the lock. */ + public static final int MRT_BLOCKED_ID_MISMATCH = 2; + + private SubCode() { + } +} diff --git a/client/src/com/aerospike/client/async/AsyncDelete.java b/client/src/com/aerospike/client/async/AsyncDelete.java index 862afc109..8b65b21ae 100644 --- a/client/src/com/aerospike/client/async/AsyncDelete.java +++ b/client/src/com/aerospike/client/async/AsyncDelete.java @@ -20,6 +20,7 @@ import com.aerospike.client.Key; import com.aerospike.client.ResultCode; import com.aerospike.client.cluster.Cluster; +import com.aerospike.client.command.RecordParser; import com.aerospike.client.listener.DeleteListener; import com.aerospike.client.policy.WritePolicy; @@ -53,13 +54,13 @@ protected boolean parseResult() { if (resultCode == ResultCode.FILTERED_OUT) { if (policy.failOnFilteredOut) { - throw new AerospikeException(resultCode); + throw RecordParser.toException(resultCode, serverMessage, serverSubcode, expTrace); } existed = true; return true; } - throw new AerospikeException(resultCode); + throw RecordParser.toException(resultCode, serverMessage, serverSubcode, expTrace); } @Override diff --git a/client/src/com/aerospike/client/async/AsyncExecute.java b/client/src/com/aerospike/client/async/AsyncExecute.java index 36183747f..070999c99 100644 --- a/client/src/com/aerospike/client/async/AsyncExecute.java +++ b/client/src/com/aerospike/client/async/AsyncExecute.java @@ -74,12 +74,12 @@ record = rp.parseRecord(false); if (rp.resultCode == ResultCode.FILTERED_OUT) { if (policy.failOnFilteredOut) { - throw new AerospikeException(rp.resultCode); + throw RecordParser.toException(rp.resultCode, rp.serverMessage, rp.serverSubcode, rp.expTrace); } return true; } - throw new AerospikeException(rp.resultCode); + throw RecordParser.toException(rp.resultCode, rp.serverMessage, rp.serverSubcode, rp.expTrace); } private void handleUdfError(int resultCode) { diff --git a/client/src/com/aerospike/client/async/AsyncExists.java b/client/src/com/aerospike/client/async/AsyncExists.java index 81171902e..48899e527 100644 --- a/client/src/com/aerospike/client/async/AsyncExists.java +++ b/client/src/com/aerospike/client/async/AsyncExists.java @@ -55,13 +55,13 @@ protected boolean parseResult() { if (rp.resultCode == ResultCode.FILTERED_OUT) { if (policy.failOnFilteredOut) { - throw new AerospikeException(rp.resultCode); + throw RecordParser.toException(rp.resultCode, rp.serverMessage, rp.serverSubcode, rp.expTrace); } exists = true; return true; } - throw new AerospikeException(rp.resultCode); + throw RecordParser.toException(rp.resultCode, rp.serverMessage, rp.serverSubcode, rp.expTrace); } @Override diff --git a/client/src/com/aerospike/client/async/AsyncOperateWrite.java b/client/src/com/aerospike/client/async/AsyncOperateWrite.java index 4d0326d59..0f734191f 100644 --- a/client/src/com/aerospike/client/async/AsyncOperateWrite.java +++ b/client/src/com/aerospike/client/async/AsyncOperateWrite.java @@ -53,12 +53,12 @@ record = rp.parseRecord(true); if (rp.resultCode == ResultCode.FILTERED_OUT) { if (policy.failOnFilteredOut) { - throw new AerospikeException(rp.resultCode); + throw RecordParser.toException(rp.resultCode, rp.serverMessage, rp.serverSubcode, rp.expTrace); } return true; } - throw new AerospikeException(rp.resultCode); + throw RecordParser.toException(rp.resultCode, rp.serverMessage, rp.serverSubcode, rp.expTrace); } @Override diff --git a/client/src/com/aerospike/client/async/AsyncRead.java b/client/src/com/aerospike/client/async/AsyncRead.java index d95c8b378..02152c645 100644 --- a/client/src/com/aerospike/client/async/AsyncRead.java +++ b/client/src/com/aerospike/client/async/AsyncRead.java @@ -66,12 +66,12 @@ protected final boolean parseResult() { if (rp.resultCode == ResultCode.FILTERED_OUT) { if (policy.failOnFilteredOut) { - throw new AerospikeException(rp.resultCode); + throw RecordParser.toException(rp.resultCode, rp.serverMessage, rp.serverSubcode, rp.expTrace); } return true; } - throw new AerospikeException(rp.resultCode); + throw RecordParser.toException(rp.resultCode, rp.serverMessage, rp.serverSubcode, rp.expTrace); } @Override diff --git a/client/src/com/aerospike/client/async/AsyncReadHeader.java b/client/src/com/aerospike/client/async/AsyncReadHeader.java index 7ab259aab..166cf7a64 100644 --- a/client/src/com/aerospike/client/async/AsyncReadHeader.java +++ b/client/src/com/aerospike/client/async/AsyncReadHeader.java @@ -55,12 +55,12 @@ record = new Record(null, rp.generation, rp.expiration); if (rp.resultCode == ResultCode.FILTERED_OUT) { if (policy.failOnFilteredOut) { - throw new AerospikeException(rp.resultCode); + throw RecordParser.toException(rp.resultCode, rp.serverMessage, rp.serverSubcode, rp.expTrace); } return true; } - throw new AerospikeException(rp.resultCode); + throw RecordParser.toException(rp.resultCode, rp.serverMessage, rp.serverSubcode, rp.expTrace); } @Override diff --git a/client/src/com/aerospike/client/async/AsyncTouch.java b/client/src/com/aerospike/client/async/AsyncTouch.java index 2cdf57b1f..6fcd021ca 100644 --- a/client/src/com/aerospike/client/async/AsyncTouch.java +++ b/client/src/com/aerospike/client/async/AsyncTouch.java @@ -20,6 +20,7 @@ import com.aerospike.client.Key; import com.aerospike.client.ResultCode; import com.aerospike.client.cluster.Cluster; +import com.aerospike.client.command.RecordParser; import com.aerospike.client.listener.ExistsListener; import com.aerospike.client.listener.WriteListener; import com.aerospike.client.policy.WritePolicy; @@ -57,7 +58,7 @@ protected boolean parseResult() { if (resultCode == ResultCode.KEY_NOT_FOUND_ERROR) { if (existsListener == null) { - throw new AerospikeException(resultCode); + throw RecordParser.toException(resultCode, serverMessage, serverSubcode, expTrace); } touched = false; return true; @@ -65,13 +66,13 @@ protected boolean parseResult() { if (resultCode == ResultCode.FILTERED_OUT) { if (policy.failOnFilteredOut) { - throw new AerospikeException(resultCode); + throw RecordParser.toException(resultCode, serverMessage, serverSubcode, expTrace); } touched = false; return true; } - throw new AerospikeException(resultCode); + throw RecordParser.toException(resultCode, serverMessage, serverSubcode, expTrace); } @Override diff --git a/client/src/com/aerospike/client/async/AsyncWrite.java b/client/src/com/aerospike/client/async/AsyncWrite.java index a2f2883d2..6ad8fe011 100644 --- a/client/src/com/aerospike/client/async/AsyncWrite.java +++ b/client/src/com/aerospike/client/async/AsyncWrite.java @@ -22,6 +22,7 @@ import com.aerospike.client.Operation; import com.aerospike.client.ResultCode; import com.aerospike.client.cluster.Cluster; +import com.aerospike.client.command.RecordParser; import com.aerospike.client.listener.WriteListener; import com.aerospike.client.policy.WritePolicy; @@ -59,12 +60,12 @@ protected boolean parseResult() { if (resultCode == ResultCode.FILTERED_OUT) { if (policy.failOnFilteredOut) { - throw new AerospikeException(resultCode); + throw RecordParser.toException(resultCode, serverMessage, serverSubcode, expTrace); } return true; } - throw new AerospikeException(resultCode); + throw RecordParser.toException(resultCode, serverMessage, serverSubcode, expTrace); } @Override diff --git a/client/src/com/aerospike/client/async/AsyncWriteBase.java b/client/src/com/aerospike/client/async/AsyncWriteBase.java index 0768991a9..5c23ff9dd 100644 --- a/client/src/com/aerospike/client/async/AsyncWriteBase.java +++ b/client/src/com/aerospike/client/async/AsyncWriteBase.java @@ -16,6 +16,7 @@ */ package com.aerospike.client.async; +import com.aerospike.client.ExpressionTrace; import com.aerospike.client.Key; import com.aerospike.client.cluster.Cluster; import com.aerospike.client.cluster.Node; @@ -65,9 +66,16 @@ void onInDoubt() { } } + protected String serverMessage; + protected int serverSubcode; // SubCode.NONE (0) when absent + protected ExpressionTrace expTrace; + protected int parseHeader() { RecordParser rp = new RecordParser(dataBuffer, dataOffset, receiveSize); rp.parseFields(policy.txn, key, true); + this.serverMessage = rp.serverMessage; + this.serverSubcode = rp.serverSubcode; + this.expTrace = rp.expTrace; return rp.resultCode; } } diff --git a/client/src/com/aerospike/client/command/Command.java b/client/src/com/aerospike/client/command/Command.java index 8c1632c04..b05e18c82 100644 --- a/client/src/com/aerospike/client/command/Command.java +++ b/client/src/com/aerospike/client/command/Command.java @@ -109,6 +109,8 @@ public class Command { public static final int INFO4_TXN_ROLL_FORWARD = (1 << 1); // Roll forward transaction. public static final int INFO4_TXN_ROLL_BACK = (1 << 2); // Roll back transaction. public static final int INFO4_TXN_ON_LOCKING_ONLY = (1 << 4); // Must be able to lock record in transaction. + public static final int INFO4_ERROR_VERBOSITY_SHIFT = 5; // info4 bits 5-6: error detail verbosity level + public static final int INFO4_ERROR_VERBOSITY_MASK = 0x60; // 0b0110_0000: bits 5-6 only public static final byte STATE_READ_AUTH_HEADER = 1; public static final byte STATE_READ_HEADER = 2; @@ -2400,6 +2402,8 @@ private final void writeHeaderWrite(WritePolicy policy, int writeAttr, int field txnAttr |= Command.INFO4_TXN_ON_LOCKING_ONLY; } + txnAttr |= (policy.errorDetailVerbosity << Command.INFO4_ERROR_VERBOSITY_SHIFT) & Command.INFO4_ERROR_VERBOSITY_MASK; + if (policy.xdr) { readAttr |= Command.INFO1_XDR; } @@ -2478,6 +2482,8 @@ private final void writeHeaderReadWrite( txnAttr |= Command.INFO4_TXN_ON_LOCKING_ONLY; } + txnAttr |= (policy.errorDetailVerbosity << Command.INFO4_ERROR_VERBOSITY_SHIFT) & Command.INFO4_ERROR_VERBOSITY_MASK; + if (policy.xdr) { readAttr |= Command.INFO1_XDR; } @@ -2558,8 +2564,9 @@ private final void writeHeaderRead( dataBuffer[9] = (byte)readAttr; dataBuffer[10] = (byte)writeAttr; dataBuffer[11] = (byte)infoAttr; + dataBuffer[12] = (byte)((policy.errorDetailVerbosity << Command.INFO4_ERROR_VERBOSITY_SHIFT) & Command.INFO4_ERROR_VERBOSITY_MASK); - for (int i = 12; i < 18; i++) { + for (int i = 13; i < 18; i++) { dataBuffer[i] = 0; } Buffer.intToBytes(policy.readTouchTtlPercent, dataBuffer, 18); @@ -2598,8 +2605,9 @@ private final void writeHeaderReadHeader(Policy policy, int readAttr, int fieldC dataBuffer[9] = (byte)readAttr; dataBuffer[10] = (byte)0; dataBuffer[11] = (byte)infoAttr; + dataBuffer[12] = (byte)((policy.errorDetailVerbosity << Command.INFO4_ERROR_VERBOSITY_SHIFT) & Command.INFO4_ERROR_VERBOSITY_MASK); - for (int i = 12; i < 18; i++) { + for (int i = 13; i < 18; i++) { dataBuffer[i] = 0; } Buffer.intToBytes(policy.readTouchTtlPercent, dataBuffer, 18); diff --git a/client/src/com/aerospike/client/command/DeleteCommand.java b/client/src/com/aerospike/client/command/DeleteCommand.java index fcd8d3ec2..69c3fcda1 100644 --- a/client/src/com/aerospike/client/command/DeleteCommand.java +++ b/client/src/com/aerospike/client/command/DeleteCommand.java @@ -53,13 +53,13 @@ protected void parseResult(Node node, Connection conn) throws IOException { if (resultCode == ResultCode.FILTERED_OUT) { if (writePolicy.failOnFilteredOut) { - throw new AerospikeException(resultCode); + throw RecordParser.toException(resultCode, serverMessage, serverSubcode, expTrace); } existed = true; return; } - throw new AerospikeException(resultCode); + throw RecordParser.toException(resultCode, serverMessage, serverSubcode, expTrace); } public boolean existed() { diff --git a/client/src/com/aerospike/client/command/ExecuteCommand.java b/client/src/com/aerospike/client/command/ExecuteCommand.java index d7c867cb3..cb77947ff 100644 --- a/client/src/com/aerospike/client/command/ExecuteCommand.java +++ b/client/src/com/aerospike/client/command/ExecuteCommand.java @@ -74,12 +74,12 @@ record = rp.parseRecord(false); if (rp.resultCode == ResultCode.FILTERED_OUT) { if (policy.failOnFilteredOut) { - throw new AerospikeException(rp.resultCode); + throw RecordParser.toException(rp.resultCode, rp.serverMessage, rp.serverSubcode, rp.expTrace); } return; } - throw new AerospikeException(rp.resultCode); + throw RecordParser.toException(rp.resultCode, rp.serverMessage, rp.serverSubcode, rp.expTrace); } private void handleUdfError(int resultCode) { diff --git a/client/src/com/aerospike/client/command/ExistsCommand.java b/client/src/com/aerospike/client/command/ExistsCommand.java index 94428f605..84c290fd2 100644 --- a/client/src/com/aerospike/client/command/ExistsCommand.java +++ b/client/src/com/aerospike/client/command/ExistsCommand.java @@ -58,13 +58,13 @@ protected void parseResult(Node node, Connection conn) throws IOException { if (rp.resultCode == ResultCode.FILTERED_OUT) { if (policy.failOnFilteredOut) { - throw new AerospikeException(rp.resultCode); + throw RecordParser.toException(rp.resultCode, rp.serverMessage, rp.serverSubcode, rp.expTrace); } exists = true; return; } - throw new AerospikeException(rp.resultCode); + throw RecordParser.toException(rp.resultCode, rp.serverMessage, rp.serverSubcode, rp.expTrace); } public boolean exists() { diff --git a/client/src/com/aerospike/client/command/FieldType.java b/client/src/com/aerospike/client/command/FieldType.java index 9a188da8e..24ff7cf7e 100644 --- a/client/src/com/aerospike/client/command/FieldType.java +++ b/client/src/com/aerospike/client/command/FieldType.java @@ -43,4 +43,5 @@ public final class FieldType { public final static int QUERY_BINLIST = 40; public final static int BATCH_INDEX = 41; public final static int FILTER_EXP = 43; + public static final int ERROR_MESSAGE = 45; } diff --git a/client/src/com/aerospike/client/command/OperateCommandWrite.java b/client/src/com/aerospike/client/command/OperateCommandWrite.java index eece21e6d..9758d45bf 100644 --- a/client/src/com/aerospike/client/command/OperateCommandWrite.java +++ b/client/src/com/aerospike/client/command/OperateCommandWrite.java @@ -56,12 +56,12 @@ record = rp.parseRecord(true); if (rp.resultCode == ResultCode.FILTERED_OUT) { if (policy.failOnFilteredOut) { - throw new AerospikeException(rp.resultCode); + throw RecordParser.toException(rp.resultCode, rp.serverMessage, rp.serverSubcode, rp.expTrace); } return; } - throw new AerospikeException(rp.resultCode); + throw RecordParser.toException(rp.resultCode, rp.serverMessage, rp.serverSubcode, rp.expTrace); } public Record getRecord() { diff --git a/client/src/com/aerospike/client/command/ReadCommand.java b/client/src/com/aerospike/client/command/ReadCommand.java index 9f3c35f3b..5b335be66 100644 --- a/client/src/com/aerospike/client/command/ReadCommand.java +++ b/client/src/com/aerospike/client/command/ReadCommand.java @@ -73,12 +73,12 @@ protected void parseResult(Node node, Connection conn) throws IOException { if (rp.resultCode == ResultCode.FILTERED_OUT) { if (policy.failOnFilteredOut) { - throw new AerospikeException(rp.resultCode); + throw RecordParser.toException(rp.resultCode, rp.serverMessage, rp.serverSubcode, rp.expTrace); } return; } - throw new AerospikeException(rp.resultCode); + throw RecordParser.toException(rp.resultCode, rp.serverMessage, rp.serverSubcode, rp.expTrace); } public Record getRecord() { diff --git a/client/src/com/aerospike/client/command/ReadHeaderCommand.java b/client/src/com/aerospike/client/command/ReadHeaderCommand.java index b6a42aa0a..153846785 100644 --- a/client/src/com/aerospike/client/command/ReadHeaderCommand.java +++ b/client/src/com/aerospike/client/command/ReadHeaderCommand.java @@ -59,12 +59,12 @@ record = new Record(null, rp.generation, rp.expiration); if (rp.resultCode == ResultCode.FILTERED_OUT) { if (policy.failOnFilteredOut) { - throw new AerospikeException(rp.resultCode); + throw RecordParser.toException(rp.resultCode, rp.serverMessage, rp.serverSubcode, rp.expTrace); } return; } - throw new AerospikeException(rp.resultCode); + throw RecordParser.toException(rp.resultCode, rp.serverMessage, rp.serverSubcode, rp.expTrace); } public Record getRecord() { diff --git a/client/src/com/aerospike/client/command/RecordParser.java b/client/src/com/aerospike/client/command/RecordParser.java index e846c2799..341cd4776 100644 --- a/client/src/com/aerospike/client/command/RecordParser.java +++ b/client/src/com/aerospike/client/command/RecordParser.java @@ -23,8 +23,10 @@ import java.util.zip.Inflater; import com.aerospike.client.AerospikeException; +import com.aerospike.client.ExpressionTrace; import com.aerospike.client.Key; import com.aerospike.client.Record; +import com.aerospike.client.SubCode; import com.aerospike.client.Txn; import com.aerospike.client.cluster.Connection; import com.aerospike.client.command.Command.OpResults; @@ -39,6 +41,42 @@ public final class RecordParser { public final int opCount; public int dataOffset; public long bytesIn; + public String serverMessage; + public int serverSubcode = SubCode.NONE; + public ExpressionTrace expTrace; + + /** + * Build a failure exception that includes the server's extended-error + * detail when present. Route all non-OK throws through here so the + * detail is never silently dropped on special-case result codes such + * as FILTERED_OUT or KEY_NOT_FOUND_ERROR. + */ + public static AerospikeException toException(int resultCode, String serverMessage) { + return toException(resultCode, serverMessage, SubCode.NONE, null); + } + + /** + * Build a failure exception that carries the server's extended-error detail — + * the human-readable message and the numeric subcode — when present. + */ + public static AerospikeException toException(int resultCode, String serverMessage, int subcode) { + return toException(resultCode, serverMessage, subcode, null); + } + + /** + * Build a failure exception that carries the server's extended-error detail — + * the human-readable message, the numeric subcode, and (on expression + * build-failure paths at verbosity 3) the structured expression trace — when + * present. + */ + public static AerospikeException toException(int resultCode, String serverMessage, int subcode, ExpressionTrace expTrace) { + AerospikeException ae = (serverMessage != null) ? + new AerospikeException(resultCode, serverMessage) : + new AerospikeException(resultCode); + ae.setSubCode(subcode); + ae.setExpressionTrace(expTrace); + return ae; + } /** * Sync record parser. @@ -160,7 +198,7 @@ public RecordParser(byte[] buffer, int offset, int receiveSize) { public void parseFields(Txn txn, Key key, boolean hasWrite) { if (txn == null) { - skipFields(); + parseFieldsError(); return; } @@ -181,6 +219,9 @@ public void parseFields(Txn txn, Key key, boolean hasWrite) { throw new AerospikeException("Record version field has invalid size: " + size); } } + else if (type == FieldType.ERROR_MESSAGE && size > 0) { + serverMessage = parseErrorDetails(dataOffset, size); + } dataOffset += size; } @@ -207,12 +248,441 @@ public void parseTranDeadline(Txn txn) { } } - private void skipFields() { - // There can be fields in the response (setname etc). - // But for now, ignore them. Expose them to the API if needed in the future. + private void parseFieldsError() { for (int i = 0; i < fieldCount; i++) { - int fieldlen = Buffer.bytesToInt(dataBuffer, dataOffset); - dataOffset += 4 + fieldlen; + int len = Buffer.bytesToInt(dataBuffer, dataOffset); + dataOffset += 4; + + int type = dataBuffer[dataOffset++]; + int size = len - 1; + + if (type == FieldType.ERROR_MESSAGE && size > 0) { + serverMessage = parseErrorDetails(dataOffset, size); + } + dataOffset += size; + } + } + + /** + * Parse error detail msgpack map from server response. + * Map keys: 1 = subcode (uint), 2 = message (string). + * Returns formatted error message string. + */ + private String parseErrorDetails(int offset, int size) { + int end = offset + size; + + if (offset >= end) { + return null; + } + + // Read map header (fixmap, map16, map32). + int b = dataBuffer[offset++] & 0xFF; + int count; + + if ((b & 0xF0) == 0x80) { + count = b & 0x0F; + } + else if (b == 0xDE && offset + 2 <= end) { + count = Buffer.bytesToShort(dataBuffer, offset) & 0xFFFF; + offset += 2; + } + else if (b == 0xDF && offset + 4 <= end) { + count = Buffer.bytesToInt(dataBuffer, offset); + offset += 4; + } + else { + return null; + } + + if (count <= 0) { + return null; + } + + String message = null; + long subcode = -1; + + for (int i = 0; i < count && offset < end; i++) { + // Read key (positive fixint or uint8). + int key; + b = dataBuffer[offset++] & 0xFF; + + if (b <= 0x7F) { + key = b; + } + else if (b == 0xCC && offset < end) { + key = dataBuffer[offset++] & 0xFF; + } + else { + break; + } + + switch (key) { + case 1: // AS_ERROR_DETAIL_KEY_SUBCODE + subcode = unpackUint(offset, end); + offset = skipMsgpackValue(offset, end); + break; + + case 2: // AS_ERROR_DETAIL_KEY_MESSAGE + int[] strResult = unpackStr(offset, end); + if (strResult != null) { + message = new String(dataBuffer, strResult[0], strResult[1], java.nio.charset.StandardCharsets.UTF_8); + offset = strResult[0] + strResult[1]; + } + else { + offset = skipMsgpackValue(offset, end); + } + break; + + case ExpressionTrace.AS_ERROR_DETAIL_KEY_EXP_TRACE: // nested expression-trace map (verbosity 3) + expTrace = parseExpTrace(offset, end); + offset = skipMsgpackValue(offset, end); + break; + + default: + offset = skipMsgpackValue(offset, end); + break; + } + } + + // Retain the numeric subcode as a first-class value. The server only + // serializes subcodes >= 1 (AS_SUB_NONE = 0 is never sent), so a parsed + // subcode always overrides the SubCode.NONE default. + if (subcode >= 0) { + serverSubcode = (int)subcode; + } + + if (message != null && subcode >= 0) { + return message + " (subcode=" + subcode + ")"; + } + else if (subcode >= 0) { + return "error subcode=" + subcode; + } + else if (message != null) { + return message; + } + return null; + } + + /** + * Parse the nested expression-trace map (top-level error-detail key 3, only sent + * at verbosity 3 on expression build-failure paths) into an {@link ExpressionTrace}. + *

+ * Reuses the shared msgpack decoder. Treats every trace key as optional (never + * requires key 1 — build failures carry AS_SUB_NONE), skips unknown trace keys, + * tolerates the "..." path-truncation sentinel as an ordinary element, and never + * throws on a missing/truncated trace. {@code lang} absent is left as -1 and + * surfaces as msgpack via {@link ExpressionTrace#getLang()}. Returns null when the + * value is not a readable, non-empty map. + */ + private ExpressionTrace parseExpTrace(int offset, int end) { + if (offset >= end) { + return null; + } + + // Read nested map header (fixmap, map16, map32). + int b = dataBuffer[offset++] & 0xFF; + int count; + + if ((b & 0xF0) == 0x80) { + count = b & 0x0F; + } + else if (b == 0xDE && offset + 2 <= end) { + count = Buffer.bytesToShort(dataBuffer, offset) & 0xFFFF; + offset += 2; + } + else if (b == 0xDF && offset + 4 <= end) { + count = Buffer.bytesToInt(dataBuffer, offset); + offset += 4; + } + else { + return null; + } + + if (count <= 0) { + return null; + } + + int phase = -1; + int byteOffset = -1; + String op = null; + int depth = -1; + String[] path = null; + String snippet = null; + int lang = -1; + int aelOffset = -1; + int aelSpan = -1; + + for (int i = 0; i < count && offset < end; i++) { + // Read key (positive fixint or uint8). + int key; + b = dataBuffer[offset++] & 0xFF; + + if (b <= 0x7F) { + key = b; + } + else if (b == 0xCC && offset < end) { + key = dataBuffer[offset++] & 0xFF; + } + else { + break; + } + + switch (key) { + case ExpressionTrace.KEY_PHASE: + phase = (int)unpackUint(offset, end); + break; + case ExpressionTrace.KEY_BYTE_OFFSET: + byteOffset = (int)unpackUint(offset, end); + break; + case ExpressionTrace.KEY_OP: + op = unpackStrValue(offset, end); + break; + case ExpressionTrace.KEY_DEPTH: + depth = (int)unpackUint(offset, end); + break; + case ExpressionTrace.KEY_PATH: + path = unpackStrArray(offset, end); + break; + case ExpressionTrace.KEY_SNIPPET: + snippet = unpackStrValue(offset, end); + break; + case ExpressionTrace.KEY_LANG: + lang = (int)unpackUint(offset, end); + break; + case ExpressionTrace.KEY_AEL_OFFSET: + aelOffset = (int)unpackUint(offset, end); + break; + case ExpressionTrace.KEY_AEL_SPAN: + aelSpan = (int)unpackUint(offset, end); + break; + default: + // Unknown / reserved trace key (outcome, ael_line, ael_col, etc.) — skip. + break; + } + + // Advance past the value regardless of whether the key was recognized. + offset = skipMsgpackValue(offset, end); + } + + return new ExpressionTrace(phase, byteOffset, op, depth, path, snippet, lang, aelOffset, aelSpan); + } + + /** + * Unpack a msgpack string value to a Java String, or null if the value at the + * offset is not a readable string. + */ + private String unpackStrValue(int offset, int end) { + int[] r = unpackStr(offset, end); + if (r == null) { + return null; + } + return new String(dataBuffer, r[0], r[1], java.nio.charset.StandardCharsets.UTF_8); + } + + /** + * Unpack a msgpack array of strings (the expression-trace path). Preserves element + * order, keeps the "..." truncation sentinel as an ordinary element, and leaves a + * null slot for any element that is not a readable string. Returns null when the + * value is not a readable array. + */ + private String[] unpackStrArray(int offset, int end) { + if (offset >= end) { + return null; + } + + int b = dataBuffer[offset++] & 0xFF; + int len; + + if ((b & 0xF0) == 0x90) { + len = b & 0x0F; + } + else if (b == 0xDC && offset + 2 <= end) { + len = Buffer.bytesToShort(dataBuffer, offset) & 0xFFFF; + offset += 2; + } + else if (b == 0xDD && offset + 4 <= end) { + len = Buffer.bytesToInt(dataBuffer, offset); + offset += 4; + } + else { + return null; + } + + if (len < 0) { + return null; + } + + String[] result = new String[len]; + + for (int i = 0; i < len && offset < end; i++) { + result[i] = unpackStrValue(offset, end); + offset = skipMsgpackValue(offset, end); + } + return result; + } + + /** + * Unpack a msgpack unsigned integer value. Returns -1 on failure. + */ + private long unpackUint(int offset, int end) { + if (offset >= end) { + return -1; + } + + int b = dataBuffer[offset] & 0xFF; + + if (b <= 0x7F) { + return b; + } + else if (b == 0xCC && offset + 1 < end) { + return dataBuffer[offset + 1] & 0xFF; + } + else if (b == 0xCD && offset + 2 < end) { + return Buffer.bytesToShort(dataBuffer, offset + 1) & 0xFFFF; + } + else if (b == 0xCE && offset + 4 < end) { + return Buffer.bytesToInt(dataBuffer, offset + 1) & 0xFFFFFFFFL; + } + else if (b == 0xCF && offset + 8 < end) { + return Buffer.bytesToLong(dataBuffer, offset + 1); + } + return -1; + } + + /** + * Unpack a msgpack string. Returns [offset, length] or null on failure. + */ + private int[] unpackStr(int offset, int end) { + if (offset >= end) { + return null; + } + + int b = dataBuffer[offset++] & 0xFF; + int len; + + if ((b & 0xE0) == 0xA0) { + len = b & 0x1F; + } + else if (b == 0xD9 && offset < end) { + len = dataBuffer[offset++] & 0xFF; + } + else if (b == 0xDA && offset + 1 < end) { + len = Buffer.bytesToShort(dataBuffer, offset) & 0xFFFF; + offset += 2; + } + else if (b == 0xDB && offset + 3 < end) { + len = Buffer.bytesToInt(dataBuffer, offset); + offset += 4; + } + else { + return null; + } + + if (len < 0 || offset + len > end) { + return null; + } + + return new int[]{offset, len}; + } + + /** + * Skip a single msgpack value, returning the new offset. + */ + private int skipMsgpackValue(int offset, int end) { + if (offset >= end) { + return end; + } + + int b = dataBuffer[offset++] & 0xFF; + + // Positive fixint / negative fixint + if (b <= 0x7F || b >= 0xE0) { + return offset; + } + // fixstr + if ((b & 0xE0) == 0xA0) { + return offset + (b & 0x1F); + } + // fixmap + if ((b & 0xF0) == 0x80) { + int count = (b & 0x0F) * 2; + for (int i = 0; i < count && offset < end; i++) { + offset = skipMsgpackValue(offset, end); + } + return offset; + } + // fixarray + if ((b & 0xF0) == 0x90) { + int count = b & 0x0F; + for (int i = 0; i < count && offset < end; i++) { + offset = skipMsgpackValue(offset, end); + } + return offset; + } + + switch (b) { + case 0xC0: // nil + case 0xC2: // false + case 0xC3: // true + return offset; + case 0xCC: // uint8 + case 0xD0: // int8 + return offset + 1; + case 0xCD: // uint16 + case 0xD1: // int16 + return offset + 2; + case 0xCE: // uint32 + case 0xD2: // int32 + case 0xCA: // float32 + return offset + 4; + case 0xCF: // uint64 + case 0xD3: // int64 + case 0xCB: // float64 + return offset + 8; + case 0xD9: // str8 + case 0xC4: // bin8 + if (offset < end) { + return offset + 1 + (dataBuffer[offset] & 0xFF); + } + return end; + case 0xDA: // str16 + case 0xC5: // bin16 + if (offset + 1 < end) { + return offset + 2 + (Buffer.bytesToShort(dataBuffer, offset) & 0xFFFF); + } + return end; + case 0xDB: // str32 + case 0xC6: // bin32 + if (offset + 3 < end) { + return offset + 4 + Buffer.bytesToInt(dataBuffer, offset); + } + return end; + case 0xDC: // array16 + case 0xDE: { // map16 + if (offset + 1 >= end) { + return end; + } + int count = (Buffer.bytesToShort(dataBuffer, offset) & 0xFFFF) * ((b == 0xDE) ? 2 : 1); + offset += 2; + for (int i = 0; i < count && offset < end; i++) { + offset = skipMsgpackValue(offset, end); + } + return offset; + } + case 0xDD: // array32 + case 0xDF: { // map32 + if (offset + 3 >= end) { + return end; + } + int count = Buffer.bytesToInt(dataBuffer, offset) * ((b == 0xDF) ? 2 : 1); + offset += 4; + for (int i = 0; i < count && offset < end; i++) { + offset = skipMsgpackValue(offset, end); + } + return offset; + } + default: + return end; } } diff --git a/client/src/com/aerospike/client/command/SyncWriteCommand.java b/client/src/com/aerospike/client/command/SyncWriteCommand.java index 06b241260..37954ffc7 100644 --- a/client/src/com/aerospike/client/command/SyncWriteCommand.java +++ b/client/src/com/aerospike/client/command/SyncWriteCommand.java @@ -73,6 +73,15 @@ protected int parseHeader(Node node, Connection conn) throws IOException { if (node.areMetricsEnabled()) { node.addBytesIn(namespace, rp.bytesIn); } + if (rp.serverMessage != null) { + this.serverMessage = rp.serverMessage; + } + this.serverSubcode = rp.serverSubcode; + this.expTrace = rp.expTrace; return rp.resultCode; } + + protected String serverMessage; + protected int serverSubcode = SubCode.NONE; + protected ExpressionTrace expTrace; } diff --git a/client/src/com/aerospike/client/command/TouchCommand.java b/client/src/com/aerospike/client/command/TouchCommand.java index 49efeb5a2..6f7682995 100644 --- a/client/src/com/aerospike/client/command/TouchCommand.java +++ b/client/src/com/aerospike/client/command/TouchCommand.java @@ -51,7 +51,7 @@ protected void parseResult(Node node, Connection conn) throws IOException { if (resultCode == ResultCode.KEY_NOT_FOUND_ERROR) { if (failOnNotFound) { - throw new AerospikeException(resultCode); + throw RecordParser.toException(resultCode, serverMessage, serverSubcode, expTrace); } touched = false; return; @@ -59,13 +59,13 @@ protected void parseResult(Node node, Connection conn) throws IOException { if (resultCode == ResultCode.FILTERED_OUT) { if (writePolicy.failOnFilteredOut) { - throw new AerospikeException(resultCode); + throw RecordParser.toException(resultCode, serverMessage, serverSubcode, expTrace); } touched = false; return; } - throw new AerospikeException(resultCode); + throw RecordParser.toException(resultCode, serverMessage, serverSubcode, expTrace); } public boolean getTouched() { diff --git a/client/src/com/aerospike/client/command/WriteCommand.java b/client/src/com/aerospike/client/command/WriteCommand.java index 5c77e0a34..826ed6ecd 100644 --- a/client/src/com/aerospike/client/command/WriteCommand.java +++ b/client/src/com/aerospike/client/command/WriteCommand.java @@ -52,11 +52,11 @@ protected void parseResult(Node node, Connection conn) throws IOException { if (resultCode == ResultCode.FILTERED_OUT) { if (writePolicy.failOnFilteredOut) { - throw new AerospikeException(resultCode); + throw RecordParser.toException(resultCode, serverMessage, serverSubcode, expTrace); } return; } - throw new AerospikeException(resultCode); + throw RecordParser.toException(resultCode, serverMessage, serverSubcode, expTrace); } } diff --git a/client/src/com/aerospike/client/configuration/serializers/dynamicconfig/DynamicReadConfig.java b/client/src/com/aerospike/client/configuration/serializers/dynamicconfig/DynamicReadConfig.java index ae1a66f87..761b483d9 100644 --- a/client/src/com/aerospike/client/configuration/serializers/dynamicconfig/DynamicReadConfig.java +++ b/client/src/com/aerospike/client/configuration/serializers/dynamicconfig/DynamicReadConfig.java @@ -38,6 +38,7 @@ public class DynamicReadConfig { public IntProperty totalTimeout; public IntProperty maxRetries; public DoubleProperty sleepMultiplier; + public IntProperty errorDetailVerbosity; public DynamicReadConfig() {} @@ -63,6 +64,8 @@ public DynamicReadConfig() {} public void setSleepMultiplier(DoubleProperty sleepMultiplier) { this.sleepMultiplier = sleepMultiplier; } + public void setErrorDetailVerbosity(IntProperty errorDetailVerbosity) { this.errorDetailVerbosity = errorDetailVerbosity; } + public ReadModeAP getReadModeAP() { return readModeAP; } public ReadModeSC getReadModeSC() { return readModeSC; } @@ -85,6 +88,8 @@ public DynamicReadConfig() {} public DoubleProperty getSleepMultiplier() { return sleepMultiplier; } + public IntProperty getErrorDetailVerbosity() { return errorDetailVerbosity; } + @Override public String toString() { StringBuffer propsString = new StringBuffer("{"); @@ -100,6 +105,7 @@ public String toString() { propsString.append(" total_timeout=").append(totalTimeout.value).append(", "); propsString.append(" max_retries=").append(maxRetries.value).append(", "); propsString.append(" sleep_multiplier=").append(sleepMultiplier.value).append(", "); + propsString.append(" error_detail_verbosity=").append(errorDetailVerbosity != null ? errorDetailVerbosity.value : "null"); } catch (Exception e) { Log.error(e.toString()); } diff --git a/client/src/com/aerospike/client/configuration/serializers/dynamicconfig/DynamicWriteConfig.java b/client/src/com/aerospike/client/configuration/serializers/dynamicconfig/DynamicWriteConfig.java index dd52706a3..7a021cebb 100644 --- a/client/src/com/aerospike/client/configuration/serializers/dynamicconfig/DynamicWriteConfig.java +++ b/client/src/com/aerospike/client/configuration/serializers/dynamicconfig/DynamicWriteConfig.java @@ -36,6 +36,7 @@ public class DynamicWriteConfig { public IntProperty maxRetries; public BooleanProperty durableDelete; public DoubleProperty sleepMultiplier; + public IntProperty errorDetailVerbosity; public DynamicWriteConfig() {} @@ -63,6 +64,8 @@ public void setReplica(Replica replica) { public void setSleepMultiplier(DoubleProperty sleepMultiplier) { this.sleepMultiplier = sleepMultiplier; } + public void setErrorDetailVerbosity(IntProperty errorDetailVerbosity) { this.errorDetailVerbosity = errorDetailVerbosity; } + public IntProperty getConnectTimeout() { return connectTimeout; } public BooleanProperty getFailOnFilteredOut() { return failOnFilteredOut; } @@ -85,6 +88,8 @@ public void setReplica(Replica replica) { public DoubleProperty getSleepMultiplier() { return sleepMultiplier; } + public IntProperty getErrorDetailVerbosity() { return errorDetailVerbosity; } + @Override public String toString() { StringBuffer propsString = new StringBuffer("{"); @@ -99,7 +104,8 @@ public String toString() { propsString.append(" total_timeout=").append(totalTimeout.value).append(", "); propsString.append(" max_retries=").append(maxRetries.value).append(", "); propsString.append(" durable_delete=").append(durableDelete.value).append(", "); - propsString.append(" sleep_multiplier=").append(sleepMultiplier.value); + propsString.append(" sleep_multiplier=").append(sleepMultiplier.value).append(", "); + propsString.append(" error_detail_verbosity=").append(errorDetailVerbosity != null ? errorDetailVerbosity.value : "null"); } catch (Exception e) { Log.error(e.toString()); } diff --git a/client/src/com/aerospike/client/policy/Policy.java b/client/src/com/aerospike/client/policy/Policy.java index 98b23bfad..1456192bb 100644 --- a/client/src/com/aerospike/client/policy/Policy.java +++ b/client/src/com/aerospike/client/policy/Policy.java @@ -272,6 +272,21 @@ public class Policy { */ public boolean failOnFilteredOut; + /** + * Request server error detail fields in responses. + *

+ *

+ * Default: 0 + */ + public int errorDetailVerbosity; + /** * Copy policy from another policy. */ @@ -292,6 +307,7 @@ public Policy(Policy other) { this.sendKey = other.sendKey; this.compress = other.compress; this.failOnFilteredOut = other.failOnFilteredOut; + this.errorDetailVerbosity = other.errorDetailVerbosity; } /** @@ -407,6 +423,10 @@ public void setFailOnFilteredOut(boolean failOnFilteredOut) { this.failOnFilteredOut = failOnFilteredOut; } + public void setErrorDetailVerbosity(int errorDetailVerbosity) { + this.errorDetailVerbosity = errorDetailVerbosity; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -416,12 +436,12 @@ public boolean equals(Object o) { return false; } Policy policy = (Policy) o; - return connectTimeout == policy.connectTimeout && socketTimeout == policy.socketTimeout && totalTimeout == policy.totalTimeout && timeoutDelay == policy.timeoutDelay && maxRetries == policy.maxRetries && sleepBetweenRetries == policy.sleepBetweenRetries && Double.compare(policy.sleepMultiplier, sleepMultiplier) == 0 && readTouchTtlPercent == policy.readTouchTtlPercent && sendKey == policy.sendKey && compress == policy.compress && failOnFilteredOut == policy.failOnFilteredOut && Objects.equals(txn, policy.txn) && readModeAP == policy.readModeAP && readModeSC == policy.readModeSC && replica == policy.replica && Objects.equals(filterExp, policy.filterExp); + return connectTimeout == policy.connectTimeout && socketTimeout == policy.socketTimeout && totalTimeout == policy.totalTimeout && timeoutDelay == policy.timeoutDelay && maxRetries == policy.maxRetries && sleepBetweenRetries == policy.sleepBetweenRetries && Double.compare(policy.sleepMultiplier, sleepMultiplier) == 0 && readTouchTtlPercent == policy.readTouchTtlPercent && sendKey == policy.sendKey && compress == policy.compress && failOnFilteredOut == policy.failOnFilteredOut && errorDetailVerbosity == policy.errorDetailVerbosity && Objects.equals(txn, policy.txn) && readModeAP == policy.readModeAP && readModeSC == policy.readModeSC && replica == policy.replica && Objects.equals(filterExp, policy.filterExp); } @Override public int hashCode() { - return Objects.hash(txn, readModeAP, readModeSC, replica, filterExp, connectTimeout, socketTimeout, totalTimeout, timeoutDelay, maxRetries, sleepBetweenRetries, sleepMultiplier, readTouchTtlPercent, sendKey, compress, failOnFilteredOut); + return Objects.hash(txn, readModeAP, readModeSC, replica, filterExp, connectTimeout, socketTimeout, totalTimeout, timeoutDelay, maxRetries, sleepBetweenRetries, sleepMultiplier, readTouchTtlPercent, sendKey, compress, failOnFilteredOut, errorDetailVerbosity); } private void updateFromConfig(ConfigurationProvider configProvider, boolean log) { @@ -511,5 +531,11 @@ private void updateFromConfig(ConfigurationProvider configProvider, boolean log) Log.info("Set Policy.maxRetries = " + this.maxRetries); } } + if (dynRC.errorDetailVerbosity != null && this.errorDetailVerbosity != dynRC.errorDetailVerbosity.value) { + this.errorDetailVerbosity = dynRC.errorDetailVerbosity.value; + if (logUpdate) { + Log.info("Set Policy.errorDetailVerbosity = " + this.errorDetailVerbosity); + } + } } } diff --git a/client/src/com/aerospike/client/policy/WritePolicy.java b/client/src/com/aerospike/client/policy/WritePolicy.java index cbc1d460d..260818d6f 100644 --- a/client/src/com/aerospike/client/policy/WritePolicy.java +++ b/client/src/com/aerospike/client/policy/WritePolicy.java @@ -297,6 +297,12 @@ private void updateFromConfig(ConfigurationProvider configProvider, boolean log, Log.info("Set" + preText + " WritePolicy.durableDelete = " + this.durableDelete); } } + if (dynWC.errorDetailVerbosity != null && this.errorDetailVerbosity != dynWC.errorDetailVerbosity.value) { + this.errorDetailVerbosity = dynWC.errorDetailVerbosity.value; + if (logUpdate) { + Log.info("Set" + preText + " WritePolicy.errorDetailVerbosity = " + this.errorDetailVerbosity); + } + } } // Include setters to facilitate Spring's ConfigurationProperties. diff --git a/examples/src/com/aerospike/examples/ErrorMessage.java b/examples/src/com/aerospike/examples/ErrorMessage.java new file mode 100644 index 000000000..9b9ed8e9e --- /dev/null +++ b/examples/src/com/aerospike/examples/ErrorMessage.java @@ -0,0 +1,176 @@ +/* + * Copyright 2012-2025 Aerospike, Inc. + * + * Portions may be licensed to Aerospike, Inc. under one or more contributor + * license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.aerospike.examples; + +import com.aerospike.client.AerospikeException; +import com.aerospike.client.Bin; +import com.aerospike.client.IAerospikeClient; +import com.aerospike.client.Key; +import com.aerospike.client.Operation; +import com.aerospike.client.ResultCode; +import com.aerospike.client.operation.HLLOperation; +import com.aerospike.client.operation.HLLPolicy; +import com.aerospike.client.policy.GenerationPolicy; +import com.aerospike.client.policy.WritePolicy; +import com.aerospike.client.Value; + +import java.util.ArrayList; +import java.util.List; + +public class ErrorMessage extends Example { + + public ErrorMessage(Console console) { + super(console); + } + + @Override + public void runExample(IAerospikeClient client, Parameters params) throws Exception { + String binName = "test-bin"; + Key key = new Key(params.namespace, params.set, "error-message-key"); + + // Write a record with an integer bin. + WritePolicy writePolicy = new WritePolicy(); + writePolicy.errorDetailVerbosity = 2; + client.put(writePolicy, key, new Bin(binName, 1)); + console.info("Write succeeded, running error detail tests."); + + // Test 1: Append string to integer bin. + testAppendToIntegerBin(client, params, writePolicy, key, binName); + + // Test 2: Delete with wrong generation. + testDeleteGenerationMismatch(client, params, key); + + // Test 3: Increment a string bin. + testIncrementStringBin(client, params, writePolicy, binName); + + // Test 4: HLL add on integer bin. + testHllAddOnIntegerBin(client, params, writePolicy, key, binName); + + // Test 5: HLL refresh_count on nonexistent bin. + testHllRefreshCountMissingBin(client, params, writePolicy); + + console.info("Error message example completed successfully."); + } + + private void testAppendToIntegerBin( + IAerospikeClient client, + Parameters params, + WritePolicy writePolicy, + Key key, + String binName + ) { + try { + client.operate(writePolicy, key, Operation.append(new Bin(binName, "bad-append"))); + throw new RuntimeException("Expected error on append to integer bin"); + } + catch (AerospikeException ae) { + assertErrorDetails(ae, ResultCode.BIN_TYPE_ERROR, "cannot append", "subcode=1100"); + console.info("Test 1 passed: append to integer bin - %d: %s", ae.getResultCode(), ae.getBaseMessage()); + } + } + + private void testDeleteGenerationMismatch(IAerospikeClient client, Parameters params, Key key) { + WritePolicy rmPolicy = new WritePolicy(); + rmPolicy.errorDetailVerbosity = 2; + rmPolicy.generationPolicy = GenerationPolicy.EXPECT_GEN_EQUAL; + rmPolicy.generation = 777; + + try { + client.delete(rmPolicy, key); + throw new RuntimeException("Expected error on generation-mismatch delete"); + } + catch (AerospikeException ae) { + assertErrorDetails(ae, ResultCode.GENERATION_ERROR, "delete generation mismatch", "subcode=1701"); + console.info("Test 2 passed: generation mismatch delete - %d: %s", ae.getResultCode(), ae.getBaseMessage()); + } + } + + private void testIncrementStringBin( + IAerospikeClient client, + Parameters params, + WritePolicy writePolicy, + String binName + ) { + Key key2 = new Key(params.namespace, params.set, "error-message-key-2"); + client.put(writePolicy, key2, new Bin(binName, "hello")); + + try { + client.operate(writePolicy, key2, Operation.add(new Bin(binName, 1))); + throw new RuntimeException("Expected error on incr of string bin"); + } + catch (AerospikeException ae) { + assertErrorDetails(ae, ResultCode.BIN_TYPE_ERROR, "cannot increment", "subcode=1100"); + console.info("Test 3 passed: increment string bin - %d: %s", ae.getResultCode(), ae.getBaseMessage()); + } + } + + private void testHllAddOnIntegerBin( + IAerospikeClient client, + Parameters params, + WritePolicy writePolicy, + Key key, + String binName + ) { + List hllList = new ArrayList<>(); + hllList.add(Value.get("element1")); + + try { + client.operate(writePolicy, key, + HLLOperation.add(HLLPolicy.Default, binName, hllList, 8)); + throw new RuntimeException("Expected error on HLL add to integer bin"); + } + catch (AerospikeException ae) { + assertErrorDetails(ae, ResultCode.BIN_TYPE_ERROR, "bin is not hll type", "subcode=1138"); + console.info("Test 4 passed: HLL add on integer bin - %d: %s", ae.getResultCode(), ae.getBaseMessage()); + } + } + + private void testHllRefreshCountMissingBin( + IAerospikeClient client, + Parameters params, + WritePolicy writePolicy + ) { + Key key3 = new Key(params.namespace, params.set, "error-message-key-3"); + client.put(writePolicy, key3, new Bin("other-bin", 1)); + + try { + client.operate(writePolicy, key3, + HLLOperation.refreshCount("no-hll-bin")); + throw new RuntimeException("Expected error on HLL refresh_count of nonexistent bin"); + } + catch (AerospikeException ae) { + assertErrorDetails(ae, ResultCode.BIN_NOT_FOUND, "subcode=1134"); + console.info("Test 5 passed: HLL refresh_count missing bin - %d: %s", ae.getResultCode(), ae.getBaseMessage()); + } + } + + private void assertErrorDetails(AerospikeException ae, int expectedResultCode, String... expectedSubstrings) { + if (ae.getResultCode() != expectedResultCode) { + throw new RuntimeException( + "Expected result code " + expectedResultCode + " but got " + ae.getResultCode() + ": " + ae.getBaseMessage()); + } + + String msg = ae.getBaseMessage(); + + for (String expected : expectedSubstrings) { + if (!msg.contains(expected)) { + throw new RuntimeException( + "Expected '" + expected + "' in error message: " + msg); + } + } + } +} diff --git a/examples/src/com/aerospike/examples/Main.java b/examples/src/com/aerospike/examples/Main.java index 41e09877c..43a9ac5a0 100644 --- a/examples/src/com/aerospike/examples/Main.java +++ b/examples/src/com/aerospike/examples/Main.java @@ -52,6 +52,7 @@ public class Main extends JPanel { "Touch", "StoreKey", "DeleteBin", + "ErrorMessage", "ListMap", "Operate", "OperateBit", diff --git a/test/src/com/aerospike/test/SuiteAsync.java b/test/src/com/aerospike/test/SuiteAsync.java index e230894f3..a7b0ee005 100644 --- a/test/src/com/aerospike/test/SuiteAsync.java +++ b/test/src/com/aerospike/test/SuiteAsync.java @@ -44,7 +44,8 @@ TestAsyncScan.class, TestAsyncQuery.class, TestAsyncTxn.class, - TestAsyncUDF.class + TestAsyncUDF.class, + TestAsyncErrorDetailVerbosity.class }) public class SuiteAsync { public static IAerospikeClient client = null; diff --git a/test/src/com/aerospike/test/SuiteSync.java b/test/src/com/aerospike/test/SuiteSync.java index c58a563a5..ae5a38b3d 100644 --- a/test/src/com/aerospike/test/SuiteSync.java +++ b/test/src/com/aerospike/test/SuiteSync.java @@ -34,6 +34,8 @@ import com.aerospike.test.sync.basic.TestBitExp; import com.aerospike.test.sync.basic.TestConfigLoadYAML; import com.aerospike.test.sync.basic.TestDeleteBin; +import com.aerospike.test.sync.basic.TestErrorDetailParser; +import com.aerospike.test.sync.basic.TestErrorDetailVerbosity; import com.aerospike.test.sync.basic.TestExpOperation; import com.aerospike.test.sync.basic.TestExpire; import com.aerospike.test.sync.basic.TestFilterExp; @@ -83,6 +85,8 @@ TestCdtOperate.class, TestConfigLoadYAML.class, TestDeleteBin.class, + TestErrorDetailParser.class, + TestErrorDetailVerbosity.class, TestExpire.class, TestExpOperation.class, TestFilterExp.class, diff --git a/test/src/com/aerospike/test/async/TestAsyncErrorDetailVerbosity.java b/test/src/com/aerospike/test/async/TestAsyncErrorDetailVerbosity.java new file mode 100644 index 000000000..5c23a9cb3 --- /dev/null +++ b/test/src/com/aerospike/test/async/TestAsyncErrorDetailVerbosity.java @@ -0,0 +1,388 @@ +/* + * Copyright 2012-2026 Aerospike, Inc. + * + * Portions may be licensed to Aerospike, Inc. under one or more contributor + * license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.aerospike.test.async; + +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import com.aerospike.client.AerospikeException; +import com.aerospike.client.Bin; +import com.aerospike.client.ExpressionTrace; +import com.aerospike.client.Key; +import com.aerospike.client.Record; +import com.aerospike.client.ResultCode; +import com.aerospike.client.SubCode; +import com.aerospike.client.Value; +import com.aerospike.client.cdt.ListOperation; +import com.aerospike.client.cdt.ListOrder; +import com.aerospike.client.cdt.ListPolicy; +import com.aerospike.client.cdt.ListWriteFlags; +import com.aerospike.client.exp.Exp; +import com.aerospike.client.exp.ExpOperation; +import com.aerospike.client.exp.ExpWriteFlags; +import com.aerospike.client.listener.DeleteListener; +import com.aerospike.client.listener.ExistsListener; +import com.aerospike.client.listener.RecordListener; +import com.aerospike.client.listener.WriteListener; +import com.aerospike.client.policy.GenerationPolicy; +import com.aerospike.client.policy.Policy; +import com.aerospike.client.policy.WritePolicy; + +/** + * Validates that server-supplied error details (subcode + message) reach + * AerospikeException through every async command path. + */ +public class TestAsyncErrorDetailVerbosity extends TestAsync { + private static final String binName = "edv-bin"; + private static Key intKey; + private static Key listKey; + + @BeforeClass + public static void setup() { + // Extended error-detail (subcode/message) plus the verbosity-3 expression + // build trace (SERVER-1137). The SERVER-1137 feature branch is cut from the + // 8.1.1 line and reports its base version as 8.1.1.0-start-*, so gate at + // 8.1.1 rather than the 8.1.3 release that first shipped the base tier. + org.junit.Assume.assumeTrue("Extended error-detail requires server version 8.1.3 or later", + args.serverVersion.isGreaterOrEqual(8, 1, 3, 0)); + + WritePolicy wp = new WritePolicy(); + intKey = new Key(args.namespace, args.set, "edv-async-int-key"); + client.put(wp, intKey, new Bin(binName, 1)); + + listKey = new Key(args.namespace, args.set, "edv-async-list-key"); + List seed = new ArrayList<>(); + seed.add(Value.get(10)); + client.put(wp, listKey, new Bin(binName, seed)); + } + + // AsyncOperateWrite — a write op that fails surfaces subcode + message. + // A bounded ordered-list insert past the end yields OP_NOT_APPLICABLE with + // AS_SUB_OPNOT_CDT_BOUNDED_LIST_OVERFLOW = 3. + @Test + public void asyncOperateWriteSurfacesDetail() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + + ListPolicy bounded = new ListPolicy(ListOrder.ORDERED, ListWriteFlags.INSERT_BOUNDED); + + AtomicReference caught = new AtomicReference<>(); + + client.operate(eventLoop, new RecordListener() { + public void onSuccess(Key key, Record record) { + setError(new Exception("Expected OP_NOT_APPLICABLE, got success")); + notifyComplete(); + } + public void onFailure(AerospikeException e) { + caught.set(e); + notifyComplete(); + } + }, wp, listKey, ListOperation.insert(bounded, binName, 10, Value.get(5))); + + waitTillComplete(); + assertSubcode(caught.get(), ResultCode.OP_NOT_APPLICABLE, SubCode.OPNOT_CDT_BOUNDED_LIST_OVERFLOW); + } + + // AsyncDelete — generation mismatch surfaces the detail message. The status + // is maximally specific, so the server omits the subcode (AS_SUB_NONE). + @Test + public void asyncDeleteSurfacesDetail() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + wp.generationPolicy = GenerationPolicy.EXPECT_GEN_EQUAL; + wp.generation = 777; + + AtomicReference caught = new AtomicReference<>(); + + client.delete(eventLoop, new DeleteListener() { + public void onSuccess(Key key, boolean existed) { + setError(new Exception("Expected GENERATION_ERROR, got success")); + notifyComplete(); + } + public void onFailure(AerospikeException e) { + caught.set(e); + notifyComplete(); + } + }, wp, intKey); + + waitTillComplete(); + assertSubcodeAbsent(caught.get(), ResultCode.GENERATION_ERROR, "generation mismatch"); + } + + // AsyncWrite — generation mismatch surfaces the detail message (AS_SUB_NONE). + @Test + public void asyncWriteSurfacesDetail() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + wp.generationPolicy = GenerationPolicy.EXPECT_GEN_EQUAL; + wp.generation = 777; + + AtomicReference caught = new AtomicReference<>(); + + client.put(eventLoop, new WriteListener() { + public void onSuccess(Key key) { + setError(new Exception("Expected GENERATION_ERROR, got success")); + notifyComplete(); + } + public void onFailure(AerospikeException e) { + caught.set(e); + notifyComplete(); + } + }, wp, intKey, new Bin(binName, 2)); + + waitTillComplete(); + assertSubcodeAbsent(caught.get(), ResultCode.GENERATION_ERROR, "generation mismatch"); + } + + // AsyncTouch — generation mismatch surfaces the detail message (AS_SUB_NONE). + @Test + public void asyncTouchSurfacesDetail() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + wp.generationPolicy = GenerationPolicy.EXPECT_GEN_EQUAL; + wp.generation = 777; + + AtomicReference caught = new AtomicReference<>(); + + client.touch(eventLoop, new WriteListener() { + public void onSuccess(Key key) { + setError(new Exception("Expected GENERATION_ERROR, got success")); + notifyComplete(); + } + public void onFailure(AerospikeException e) { + caught.set(e); + notifyComplete(); + } + }, wp, intKey); + + waitTillComplete(); + assertSubcodeAbsent(caught.get(), ResultCode.GENERATION_ERROR, "generation mismatch"); + } + + // AsyncExists — uses Policy (not WritePolicy). Server should not error on plain exists; + // just verifies the configured verbosity does not break the happy path. + @Test + public void asyncExistsVerbositySetHappyPath() { + Policy p = new Policy(); + p.errorDetailVerbosity = 2; + + client.exists(eventLoop, new ExistsListener() { + public void onSuccess(Key key, boolean exists) { + if (! exists) { + setError(new Exception("Expected record to exist")); + } + notifyComplete(); + } + public void onFailure(AerospikeException e) { + setError(e); + notifyComplete(); + } + }, p, intKey); + + waitTillComplete(); + } + + // AsyncRead — verifies happy path with verbosity set + @Test + public void asyncReadVerbositySetHappyPath() { + Policy p = new Policy(); + p.errorDetailVerbosity = 2; + + client.get(eventLoop, new RecordListener() { + public void onSuccess(Key key, Record record) { + if (record == null || record.getInt(binName) != 1) { + setError(new Exception("Unexpected record: " + record)); + } + notifyComplete(); + } + public void onFailure(AerospikeException e) { + setError(e); + notifyComplete(); + } + }, p, intKey); + + waitTillComplete(); + } + + // AsyncReadHeader — verifies happy path with verbosity set + @Test + public void asyncReadHeaderVerbositySetHappyPath() { + Policy p = new Policy(); + p.errorDetailVerbosity = 2; + + client.getHeader(eventLoop, new RecordListener() { + public void onSuccess(Key key, Record record) { + if (record == null) { + setError(new Exception("Expected header")); + } + notifyComplete(); + } + public void onFailure(AerospikeException e) { + setError(e); + notifyComplete(); + } + }, p, intKey); + + waitTillComplete(); + } + + // --------------------------------------------------------------------- + // Verbosity 3: expression build-failure trace (SERVER-1137), async paths. + // A type-mismatched comparison fails to build on the server: as a filter_exp + // read it yields "invalid metadata expression in request"; as an exp_write op, + // "invalid expression in operation request". Both: PARAMETER_ERROR + NONE + + // a build-phase trace. Assert presence/shape, not exact byte_offset/snippet. + // --------------------------------------------------------------------- + + /** Expression with type-mismatched operands (int vs float) -> server build failure. */ + private static Exp badExp() { + return Exp.eq(Exp.val(5), Exp.val(6.0)); + } + + @Test + public void asyncFilterExpBuildFailureTrace() { + Policy p = new Policy(); + p.errorDetailVerbosity = 3; + p.filterExp = Exp.build(badExp()); + + AtomicReference caught = new AtomicReference<>(); + + client.get(eventLoop, new RecordListener() { + public void onSuccess(Key key, Record record) { + setError(new Exception("Expected PARAMETER_ERROR build failure, got success")); + notifyComplete(); + } + public void onFailure(AerospikeException e) { + caught.set(e); + notifyComplete(); + } + }, p, intKey); + + waitTillComplete(); + assertBuildTrace(caught.get(), "invalid metadata expression in request"); + } + + @Test + public void asyncExpWriteBuildFailureTrace() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 3; + + AtomicReference caught = new AtomicReference<>(); + + client.operate(eventLoop, new RecordListener() { + public void onSuccess(Key key, Record record) { + setError(new Exception("Expected PARAMETER_ERROR build failure, got success")); + notifyComplete(); + } + public void onFailure(AerospikeException e) { + caught.set(e); + notifyComplete(); + } + }, wp, intKey, ExpOperation.write(binName, Exp.build(badExp()), ExpWriteFlags.DEFAULT)); + + waitTillComplete(); + assertBuildTrace(caught.get(), "invalid expression in operation request"); + } + + @Test + public void asyncFilterExpBuildFailureVerbosity2HasNoTrace() { + // Additive-superset check: same inducer at verbosity 2 -> message, no trace. + Policy p = new Policy(); + p.errorDetailVerbosity = 2; + p.filterExp = Exp.build(badExp()); + + AtomicReference caught = new AtomicReference<>(); + + client.get(eventLoop, new RecordListener() { + public void onSuccess(Key key, Record record) { + setError(new Exception("Expected PARAMETER_ERROR build failure, got success")); + notifyComplete(); + } + public void onFailure(AerospikeException e) { + caught.set(e); + notifyComplete(); + } + }, p, intKey); + + waitTillComplete(); + + AerospikeException ae = caught.get(); + org.junit.Assert.assertNotNull("Expected AerospikeException to be captured", ae); + org.junit.Assert.assertEquals(ResultCode.PARAMETER_ERROR, ae.getResultCode()); + org.junit.Assert.assertEquals(SubCode.NONE, ae.getSubCode()); + String msg = ae.getBaseMessage(); + org.junit.Assert.assertNotNull(msg); + org.junit.Assert.assertTrue("Expected filter-build message in: " + msg, + msg.contains("invalid metadata expression in request")); + org.junit.Assert.assertNull("Verbosity 2 must surface NO expression trace", ae.getExpressionTrace()); + } + + /** + * Assert a verbosity-3 expression build failure: PARAMETER_ERROR + SubCode.NONE + + * the contextual message + a non-null build-phase trace. + */ + private static void assertBuildTrace(AerospikeException ae, String expectedSubstring) { + org.junit.Assert.assertNotNull("Expected AerospikeException to be captured", ae); + org.junit.Assert.assertEquals("Unexpected result code", ResultCode.PARAMETER_ERROR, ae.getResultCode()); + org.junit.Assert.assertEquals("Expected no subcode", SubCode.NONE, ae.getSubCode()); + + String msg = ae.getBaseMessage(); + org.junit.Assert.assertNotNull("Expected server error message, got null. ae=" + ae, msg); + org.junit.Assert.assertTrue("Expected '" + expectedSubstring + "' in: " + msg, msg.contains(expectedSubstring)); + + ExpressionTrace t = ae.getExpressionTrace(); + org.junit.Assert.assertNotNull("Expected a non-null expression trace at verbosity 3", t); + org.junit.Assert.assertEquals("Expected a build-phase trace", ExpressionTrace.PHASE_BUILD, t.getPhase()); + } + + /** + * Assert the server-supplied {@code (resultCode, subcode)} pair reached the + * async exception, including the first-class numeric subcode. + */ + private static void assertSubcode(AerospikeException ae, int expectedResultCode, int expectedSubcode) { + org.junit.Assert.assertNotNull("Expected AerospikeException to be captured", ae); + org.junit.Assert.assertEquals("Unexpected result code", expectedResultCode, ae.getResultCode()); + org.junit.Assert.assertEquals("Unexpected subcode", expectedSubcode, ae.getSubCode()); + + String msg = ae.getBaseMessage(); + org.junit.Assert.assertNotNull("Expected server error message, got null. ae=" + ae, msg); + org.junit.Assert.assertTrue("Expected 'subcode=" + expectedSubcode + "' in: " + msg, + msg.contains("subcode=" + expectedSubcode)); + } + + /** + * Assert that the server surfaced a contextual message but NO subcode + * (AS_SUB_NONE): {@link AerospikeException#getSubCode()} is {@link SubCode#NONE} + * and the "(subcode=...)" suffix must never appear. + */ + private static void assertSubcodeAbsent(AerospikeException ae, int expectedResultCode, String expectedSubstring) { + org.junit.Assert.assertNotNull("Expected AerospikeException to be captured", ae); + org.junit.Assert.assertEquals("Unexpected result code", expectedResultCode, ae.getResultCode()); + org.junit.Assert.assertEquals("Expected no subcode", SubCode.NONE, ae.getSubCode()); + + String msg = ae.getBaseMessage(); + org.junit.Assert.assertNotNull("Expected server error message, got null. ae=" + ae, msg); + org.junit.Assert.assertTrue("Expected '" + expectedSubstring + "' in: " + msg, msg.contains(expectedSubstring)); + org.junit.Assert.assertFalse("Expected NO subcode suffix in: " + msg, msg.contains("subcode=")); + } + +} diff --git a/test/src/com/aerospike/test/sync/basic/TestErrorDetailParser.java b/test/src/com/aerospike/test/sync/basic/TestErrorDetailParser.java new file mode 100644 index 000000000..803e5bd7d --- /dev/null +++ b/test/src/com/aerospike/test/sync/basic/TestErrorDetailParser.java @@ -0,0 +1,719 @@ +/* + * Copyright 2012-2026 Aerospike, Inc. + * + * Portions may be licensed to Aerospike, Inc. under one or more contributor + * license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.aerospike.test.sync.basic; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +import org.junit.Test; + +import com.aerospike.client.ExpressionTrace; +import com.aerospike.client.command.Command; +import com.aerospike.client.command.FieldType; +import com.aerospike.client.command.RecordParser; +import com.aerospike.test.util.TestBase; + +/** + * Unit-style tests that exercise RecordParser's msgpack error-detail decoding + * by feeding it synthetic wire-format buffers. No server connection required. + * + *

Also verifies the info4 verbosity bit math (Command.INFO4_ERROR_VERBOSITY_*). + */ +public class TestErrorDetailParser extends TestBase { + + // ---------- Verbosity bit math (fix #3) ---------- + + @Test + public void verbosityShiftAndMaskAreConsistent() { + assertEquals(5, Command.INFO4_ERROR_VERBOSITY_SHIFT); + assertEquals(0x60, Command.INFO4_ERROR_VERBOSITY_MASK); + // Mask must cover exactly two bits at shift position. + assertEquals(0x60, (0x03 << Command.INFO4_ERROR_VERBOSITY_SHIFT)); + } + + @Test + public void verbosityValueInRangeIsPreservedAfterMasking() { + for (int v = 0; v <= 3; v++) { + int actual = (v << Command.INFO4_ERROR_VERBOSITY_SHIFT) & Command.INFO4_ERROR_VERBOSITY_MASK; + assertEquals("v=" + v, v << Command.INFO4_ERROR_VERBOSITY_SHIFT, actual); + } + } + + @Test + public void verbosityOutOfRangeCannotCorruptOtherInfo4Bits() { + // Key invariant: regardless of input, masking guarantees only bits 5-6 can ever be set. + // No other info4 bit (TXN_VERIFY_READ, TXN_ROLL_FORWARD, TXN_ROLL_BACK, TXN_ON_LOCKING_ONLY, etc.) + // may flip from a stray verbosity value. + int otherBits = ~Command.INFO4_ERROR_VERBOSITY_MASK & 0xFF; + for (int v : new int[] {0, 1, 2, 3, 4, 8, 16, 255, Integer.MAX_VALUE, -1}) { + int written = (v << Command.INFO4_ERROR_VERBOSITY_SHIFT) & Command.INFO4_ERROR_VERBOSITY_MASK; + assertEquals("other-bit pollution for v=" + v, 0, written & otherBits); + assertTrue("result must fit in mask for v=" + v, written == (written & Command.INFO4_ERROR_VERBOSITY_MASK)); + } + + // Specific spot checks: values that, pre-mask, would set bits OUTSIDE 5-6. + // 4 << 5 = 0x80 (bit 7), 8 << 5 = 0x100 (bit 8), 16 << 5 = 0x200 (bit 9). + // All three masked → 0 because none have bits 5 or 6 lit. + assertEquals(0, (4 << Command.INFO4_ERROR_VERBOSITY_SHIFT) & Command.INFO4_ERROR_VERBOSITY_MASK); + assertEquals(0, (8 << Command.INFO4_ERROR_VERBOSITY_SHIFT) & Command.INFO4_ERROR_VERBOSITY_MASK); + assertEquals(0, (16 << Command.INFO4_ERROR_VERBOSITY_SHIFT) & Command.INFO4_ERROR_VERBOSITY_MASK); + } + + // ---------- Parser: fixmap (baseline) ---------- + + @Test + public void parsesFixmapWithSubcodeAndMessage() { + byte[] detail = fixmap2( + pair(intKey(1), fixint(99)), + pair(intKey(2), fixstr("cannot append")) + ); + RecordParser rp = parserFor(detail); + rp.parseFields(null, null, false); + assertEquals("cannot append (subcode=99)", rp.serverMessage); + } + + @Test + public void parsesFixmapWithSubcodeOnly() { + byte[] detail = fixmap1(pair(intKey(1), fixint(42))); + RecordParser rp = parserFor(detail); + rp.parseFields(null, null, false); + assertEquals("error subcode=42", rp.serverMessage); + } + + @Test + public void parsesFixmapWithMessageOnly() { + byte[] detail = fixmap1(pair(intKey(2), fixstr("oops"))); + RecordParser rp = parserFor(detail); + rp.parseFields(null, null, false); + assertEquals("oops", rp.serverMessage); + } + + @Test + public void parsesKeysInReverseOrder() { + // Server is allowed to emit the map keys in any order; result must be identical. + byte[] detail = fixmap2( + pair(intKey(2), fixstr("swap")), + pair(intKey(1), fixint(7)) + ); + RecordParser rp = parserFor(detail); + rp.parseFields(null, null, false); + assertEquals("swap (subcode=7)", rp.serverMessage); + } + + @Test + public void parsesMultiByteUtf8Message() { + // Mix BMP and supplementary-plane code points so we exercise both + // 2/3-byte and 4-byte UTF-8 sequences. + String multibyte = "αβγ · 测试 · 🚀"; + byte[] detail = fixmap2( + pair(intKey(1), fixint(1)), + pair(intKey(2), fixstr(multibyte)) + ); + RecordParser rp = parserFor(detail); + rp.parseFields(null, null, false); + assertEquals(multibyte + " (subcode=1)", rp.serverMessage); + } + + // ---------- Parser: msgpack types that the original hand-rolled decoder didn't handle (fix #2) ---------- + + @Test + public void parsesMap16Header() { + // Build with 16 entries to force map16. Real keys 1 and 2; pad rest with unknown keys 100..113 → uint8. + ByteArrayOutputStream payload = new ByteArrayOutputStream(); + // map16 header: 0xDE NN NN + payload.write(0xDE); + payload.write(0x00); + payload.write(16); + writeBytes(payload, pair(intKey(1), fixint(7))); + writeBytes(payload, pair(intKey(2), fixstr("boom"))); + for (int i = 0; i < 14; i++) { + // unknown key, uint8 (0xCC NN), value nil (0xC0) + payload.write(0xCC); + payload.write(100 + i); + payload.write(0xC0); + } + RecordParser rp = parserFor(payload.toByteArray()); + rp.parseFields(null, null, false); + assertEquals("boom (subcode=7)", rp.serverMessage); + } + + @Test + public void parsesMap32Header() { + // 0xDF + 4-byte big-endian count. Only 2 entries here — exercising the + // header path, not the count. + ByteArrayOutputStream payload = new ByteArrayOutputStream(); + payload.write(0xDF); + writeInt(payload, 2); + writeBytes(payload, pair(intKey(1), fixint(9))); + writeBytes(payload, pair(intKey(2), fixstr("m32"))); + RecordParser rp = parserFor(payload.toByteArray()); + rp.parseFields(null, null, false); + assertEquals("m32 (subcode=9)", rp.serverMessage); + } + + @Test + public void parsesStr32Message() { + // 100-char message that we choose to encode with str32 to verify that path works. + String big = repeat('x', 100); + ByteArrayOutputStream payload = new ByteArrayOutputStream(); + payload.write(0x82); // fixmap, 2 entries + writeBytes(payload, pair(intKey(1), fixint(5))); + // key=2 + writeBytes(payload, intKey(2)); + // str32 prefix + payload.write(0xDB); + payload.write(0x00); + payload.write(0x00); + payload.write(0x00); + payload.write(big.length()); + writeBytes(payload, big.getBytes(StandardCharsets.UTF_8)); + + RecordParser rp = parserFor(payload.toByteArray()); + rp.parseFields(null, null, false); + assertEquals(big + " (subcode=5)", rp.serverMessage); + } + + @Test + public void parsesSubcodeAsFixint() { + // 0..127 — encoded as a single positive-fixint byte. + byte[] detail = fixmap2( + pair(intKey(1), fixint(127)), + pair(intKey(2), fixstr("fx")) + ); + RecordParser rp = parserFor(detail); + rp.parseFields(null, null, false); + assertEquals("fx (subcode=127)", rp.serverMessage); + } + + @Test + public void parsesSubcodeAsUint8() { + // 200 doesn't fit in fixint (max 127) so server would emit uint8 (0xCC NN). + ByteArrayOutputStream payload = new ByteArrayOutputStream(); + payload.write(0x82); + writeBytes(payload, intKey(1)); + payload.write(0xCC); + payload.write(200); + writeBytes(payload, pair(intKey(2), fixstr("u8"))); + RecordParser rp = parserFor(payload.toByteArray()); + rp.parseFields(null, null, false); + assertEquals("u8 (subcode=200)", rp.serverMessage); + } + + @Test + public void parsesSubcodeAsUint16() { + ByteArrayOutputStream payload = new ByteArrayOutputStream(); + payload.write(0x82); + // key=1, subcode uint16 = 1100 (0x044C) + writeBytes(payload, intKey(1)); + payload.write(0xCD); + payload.write(0x04); + payload.write(0x4C); + writeBytes(payload, pair(intKey(2), fixstr("hi"))); + + RecordParser rp = parserFor(payload.toByteArray()); + rp.parseFields(null, null, false); + assertEquals("hi (subcode=1100)", rp.serverMessage); + } + + @Test + public void parsesSubcodeAsUint32() { + ByteArrayOutputStream payload = new ByteArrayOutputStream(); + payload.write(0x82); + // key=1, subcode uint32 = 70000 (0x00011170) + writeBytes(payload, intKey(1)); + payload.write(0xCE); + payload.write(0x00); + payload.write(0x01); + payload.write(0x11); + payload.write(0x70); + writeBytes(payload, pair(intKey(2), fixstr("x"))); + + RecordParser rp = parserFor(payload.toByteArray()); + rp.parseFields(null, null, false); + assertEquals("x (subcode=70000)", rp.serverMessage); + } + + @Test + public void parsesSubcodeAsUint64() { + // 5,000,000,000 doesn't fit in uint32; forces the 0xCF / 8-byte path. + long value = 5_000_000_000L; + ByteArrayOutputStream payload = new ByteArrayOutputStream(); + payload.write(0x82); + writeBytes(payload, intKey(1)); + payload.write(0xCF); + writeLong(payload, value); + writeBytes(payload, pair(intKey(2), fixstr("u64"))); + RecordParser rp = parserFor(payload.toByteArray()); + rp.parseFields(null, null, false); + assertEquals("u64 (subcode=" + value + ")", rp.serverMessage); + } + + @Test + public void parsesMessageAsStr8() { + // 0xD9 + 1-byte length. Server may pick str8 even for short strings; + // the parser must accept whichever encoding it gets. + String msg = "string8"; + byte[] data = msg.getBytes(StandardCharsets.UTF_8); + ByteArrayOutputStream payload = new ByteArrayOutputStream(); + payload.write(0x82); + writeBytes(payload, pair(intKey(1), fixint(3))); + writeBytes(payload, intKey(2)); + payload.write(0xD9); + payload.write(data.length); + writeBytes(payload, data); + RecordParser rp = parserFor(payload.toByteArray()); + rp.parseFields(null, null, false); + assertEquals(msg + " (subcode=3)", rp.serverMessage); + } + + @Test + public void parsesMessageAsStr16() { + // 0xDA + 2-byte length. + String msg = "string16"; + byte[] data = msg.getBytes(StandardCharsets.UTF_8); + ByteArrayOutputStream payload = new ByteArrayOutputStream(); + payload.write(0x82); + writeBytes(payload, pair(intKey(1), fixint(4))); + writeBytes(payload, intKey(2)); + payload.write(0xDA); + writeShort(payload, data.length); + writeBytes(payload, data); + RecordParser rp = parserFor(payload.toByteArray()); + rp.parseFields(null, null, false); + assertEquals(msg + " (subcode=4)", rp.serverMessage); + } + + // ---------- Parser: defensive/edge cases ---------- + + @Test + public void emptyMapProducesNoMessage() { + // 0x80 = fixmap, 0 entries → parseErrorDetails returns null. + RecordParser rp = parserFor(new byte[]{(byte)0x80}); + rp.parseFields(null, null, false); + assertNull(rp.serverMessage); + } + + @Test + public void truncatedValueReturnsNullNotThrow() { + // fixmap-1, key=1, uint16 prefix — but value bytes are missing. + // Parser must return null and MUST NOT throw or read past the buffer. + byte[] detail = new byte[]{(byte)0x81, 0x01, (byte)0xCD}; + RecordParser rp = parserFor(detail); + rp.parseFields(null, null, false); + assertNull(rp.serverMessage); + } + + @Test + public void truncatedMapHeaderReturnsNull() { + // 0xDE (map16 prefix) with NO count bytes following. + byte[] detail = new byte[]{(byte)0xDE}; + RecordParser rp = parserFor(detail); + rp.parseFields(null, null, false); + assertNull(rp.serverMessage); + } + + @Test + public void unknownKeysAreSkippedNotFatal() { + // 4-entry fixmap: unknown int, subcode, unknown nil, message + ByteArrayOutputStream payload = new ByteArrayOutputStream(); + payload.write(0x84); // fixmap, 4 entries + writeBytes(payload, pair(intKey(50), fixint(0))); // unknown key 50 → fixint value + writeBytes(payload, pair(intKey(1), fixint(7))); + writeBytes(payload, intKey(51)); + payload.write(0xC0); // nil + writeBytes(payload, pair(intKey(2), fixstr("z"))); + + RecordParser rp = parserFor(payload.toByteArray()); + rp.parseFields(null, null, false); + assertEquals("z (subcode=7)", rp.serverMessage); + } + + @Test + public void parseSucceedsWhenAdditionalNonErrorFieldsPresent() { + // Build buffer with fieldCount=2: one bogus field type, then ERROR_MESSAGE. + byte[] detail = fixmap2( + pair(intKey(1), fixint(1)), + pair(intKey(2), fixstr("ok")) + ); + byte[] buffer = bufferWithFields( + new int[]{0xCD, FieldType.ERROR_MESSAGE}, // first field type is unknown to parseFieldsError → skipped + new byte[][]{new byte[]{0x01, 0x02, 0x03}, detail} + ); + RecordParser rp = new RecordParser(buffer, 0, buffer.length); + rp.parseFields(null, null, false); + assertEquals("ok (subcode=1)", rp.serverMessage); + } + + @Test + public void missingErrorFieldYieldsNullMessage() { + // fieldCount = 0 + byte[] buffer = bufferWithFields(new int[0], new byte[0][]); + RecordParser rp = new RecordParser(buffer, 0, buffer.length); + rp.parseFields(null, null, false); + assertNull(rp.serverMessage); + } + + // ---------- Parser: verbosity-3 expression trace (nested key-3 map) ---------- + + @Test + public void parsesFullExpressionTrace() { + // Top-level: message (key 2) + nested expression-trace map (key 3). + byte[] trace = fixmap( + pair(intKey(ExpressionTrace.KEY_PHASE), fixint(ExpressionTrace.PHASE_BUILD)), + pair(intKey(ExpressionTrace.KEY_BYTE_OFFSET), fixint(7)), + pair(intKey(ExpressionTrace.KEY_OP), fixstr("cmp_eq")), + pair(intKey(ExpressionTrace.KEY_DEPTH), fixint(3)), + pair(intKey(ExpressionTrace.KEY_PATH), fixarray(fixstr("and"), fixstr("eq"), fixstr("cmp_eq"))), + pair(intKey(ExpressionTrace.KEY_SNIPPET), fixstr("eq(int,float)")) + ); + byte[] detail = fixmap( + pair(intKey(2), fixstr("bad exp")), + pair(intKey(3), trace) + ); + RecordParser rp = parserFor(detail); + rp.parseFields(null, null, false); + + // Message still surfaces unchanged; subcode absent (no key 1). + assertEquals("bad exp", rp.serverMessage); + + ExpressionTrace t = rp.expTrace; + assertNotNull("Expected a parsed expression trace", t); + assertEquals(ExpressionTrace.PHASE_BUILD, t.getPhase()); + assertEquals(7, t.getByteOffset()); + assertEquals("cmp_eq", t.getOp()); + assertEquals(3, t.getDepth()); + assertEquals("eq(int,float)", t.getSnippet()); + // path order preserved root -> fault. + assertArrayEquals(new String[]{"and", "eq", "cmp_eq"}, t.getPath()); + // lang absent => msgpack default. + assertEquals(ExpressionTrace.LANG_MSGPACK, t.getLang()); + } + + @Test + public void parsesTracePathTruncationSentinel() { + // True depth deeper than the path frame cap: server splices a "..." element + // between the outermost frames and the failing frame; depth reports the true count. + byte[] trace = fixmap( + pair(intKey(ExpressionTrace.KEY_PHASE), fixint(ExpressionTrace.PHASE_BUILD)), + pair(intKey(ExpressionTrace.KEY_DEPTH), fixint(20)), + pair(intKey(ExpressionTrace.KEY_PATH), + fixarray(fixstr("and"), fixstr("or"), fixstr("..."), fixstr("cmp_eq"))) + ); + byte[] detail = fixmap(pair(intKey(3), trace)); + RecordParser rp = parserFor(detail); + rp.parseFields(null, null, false); + + ExpressionTrace t = rp.expTrace; + assertNotNull(t); + assertEquals("depth reports the TRUE count, not the truncated path length", 20, t.getDepth()); + String[] path = t.getPath(); + assertNotNull(path); + assertEquals(4, path.length); + assertEquals(ExpressionTrace.PATH_TRUNCATION_SENTINEL, path[2]); + assertEquals("...", path[2]); + // Order preserved around the sentinel. + assertEquals("and", path[0]); + assertEquals("cmp_eq", path[3]); + } + + @Test + public void parsesTraceWithSnippetAndPathAbsent() { + // Budget can drop snippet first, then path, leaving the structural core only. + byte[] trace = fixmap( + pair(intKey(ExpressionTrace.KEY_PHASE), fixint(ExpressionTrace.PHASE_BUILD)), + pair(intKey(ExpressionTrace.KEY_BYTE_OFFSET), fixint(12)), + pair(intKey(ExpressionTrace.KEY_OP), fixstr("add")), + pair(intKey(ExpressionTrace.KEY_DEPTH), fixint(2)) + ); + byte[] detail = fixmap(pair(intKey(3), trace)); + RecordParser rp = parserFor(detail); + rp.parseFields(null, null, false); + + ExpressionTrace t = rp.expTrace; + assertNotNull(t); + assertEquals(ExpressionTrace.PHASE_BUILD, t.getPhase()); + assertEquals(12, t.getByteOffset()); + assertEquals("add", t.getOp()); + assertEquals(2, t.getDepth()); + assertNull("snippet absent within a present trace", t.getSnippet()); + assertNull("path absent within a present trace", t.getPath()); + } + + @Test + public void parsesTraceSkippingUnknownTraceKeys() { + // Reserved / future keys (outcome=7, ael_line=11, ael_col=12) and an unknown + // key 99 must be skipped without disturbing the recognized fields. + byte[] trace = fixmap( + pair(intKey(ExpressionTrace.KEY_OUTCOME), fixint(5)), // 7 reserved + pair(intKey(ExpressionTrace.KEY_PHASE), fixint(ExpressionTrace.PHASE_BUILD)), + pair(intKey(ExpressionTrace.KEY_AEL_LINE), fixint(9)), // 11 reserved + pair(intKey(ExpressionTrace.KEY_BYTE_OFFSET), fixint(4)), + pair(intKey(ExpressionTrace.KEY_AEL_COL), fixint(2)), // 12 reserved + pair(intKey(99), fixstr("ignored")) // wholly unknown + ); + byte[] detail = fixmap(pair(intKey(3), trace)); + RecordParser rp = parserFor(detail); + rp.parseFields(null, null, false); + + ExpressionTrace t = rp.expTrace; + assertNotNull(t); + assertEquals(ExpressionTrace.PHASE_BUILD, t.getPhase()); + assertEquals(4, t.getByteOffset()); + // Unknown keys did not corrupt absent fields. + assertNull(t.getOp()); + assertEquals(-1, t.getDepth()); + } + + @Test + public void parsesTraceLangAbsentIsMsgpack() { + byte[] trace = fixmap( + pair(intKey(ExpressionTrace.KEY_PHASE), fixint(ExpressionTrace.PHASE_BUILD)), + pair(intKey(ExpressionTrace.KEY_BYTE_OFFSET), fixint(1)) + ); + byte[] detail = fixmap(pair(intKey(3), trace)); + RecordParser rp = parserFor(detail); + rp.parseFields(null, null, false); + + ExpressionTrace t = rp.expTrace; + assertNotNull(t); + assertEquals("absent lang must be treated as msgpack", ExpressionTrace.LANG_MSGPACK, t.getLang()); + assertEquals(-1, t.getAelOffset()); + assertEquals(-1, t.getAelSpan()); + } + + @Test + public void parsesTraceLangAelWithOffsets() { + // The reserved AEL branch: lang=2 plus ael_offset/ael_span must be exposed if present. + byte[] trace = fixmap( + pair(intKey(ExpressionTrace.KEY_PHASE), fixint(ExpressionTrace.PHASE_BUILD)), + pair(intKey(ExpressionTrace.KEY_LANG), fixint(ExpressionTrace.LANG_AEL)), + pair(intKey(ExpressionTrace.KEY_AEL_OFFSET), fixint(42)), + pair(intKey(ExpressionTrace.KEY_AEL_SPAN), fixint(6)) + ); + byte[] detail = fixmap(pair(intKey(3), trace)); + RecordParser rp = parserFor(detail); + rp.parseFields(null, null, false); + + ExpressionTrace t = rp.expTrace; + assertNotNull(t); + assertEquals(ExpressionTrace.LANG_AEL, t.getLang()); + assertEquals(42, t.getAelOffset()); + assertEquals(6, t.getAelSpan()); + } + + @Test + public void noKey3YieldsNoTrace() { + // A normal subcode+message response (verbosity 2 shape) must leave expTrace null. + byte[] detail = fixmap2( + pair(intKey(1), fixint(4)), + pair(intKey(2), fixstr("plain")) + ); + RecordParser rp = parserFor(detail); + rp.parseFields(null, null, false); + + assertEquals("plain (subcode=4)", rp.serverMessage); + assertNull("no key 3 => no expression trace", rp.expTrace); + } + + @Test + public void messageStillSurfacesAlongsideTraceRegardlessOfKeyOrder() { + // Key 3 emitted BEFORE key 2: the message must still surface and the trace parse. + byte[] trace = fixmap( + pair(intKey(ExpressionTrace.KEY_PHASE), fixint(ExpressionTrace.PHASE_BUILD)), + pair(intKey(ExpressionTrace.KEY_OP), fixstr("eq")) + ); + byte[] detail = fixmap( + pair(intKey(3), trace), + pair(intKey(2), fixstr("bad exp")) + ); + RecordParser rp = parserFor(detail); + rp.parseFields(null, null, false); + + assertEquals("bad exp", rp.serverMessage); + assertNotNull(rp.expTrace); + assertEquals("eq", rp.expTrace.getOp()); + } + + @Test + public void emptyTraceMapYieldsNoTrace() { + // A present-but-empty nested map (0 entries) is not a usable trace. + byte[] detail = fixmap(pair(intKey(3), fixmap())); + RecordParser rp = parserFor(detail); + rp.parseFields(null, null, false); + assertNull(rp.expTrace); + } + + // ---------- helpers: wire format & msgpack composition ---------- + + /** + * Build a buffer containing a single ERROR_MESSAGE field with the given msgpack + * payload, then construct a parser sitting at the right offset. + */ + private static RecordParser parserFor(byte[] msgpackDetail) { + byte[] buf = bufferWithFields( + new int[]{FieldType.ERROR_MESSAGE}, + new byte[][]{msgpackDetail} + ); + // Async constructor expects receiveSize >= MSG_REMAINING_HEADER_SIZE (22). + return new RecordParser(buf, 0, buf.length); + } + + /** + * Lay out: [5-byte proto stub][22-byte msg header with fieldCount=N][fields...] + */ + private static byte[] bufferWithFields(int[] fieldTypes, byte[][] fieldDatas) { + assertEquals(fieldTypes.length, fieldDatas.length); + + // Msg-header layout per RecordParser async ctor (22 bytes from buf[0]): + // bytes 0..4 : header-size marker + 4 attr bytes (parser skips via offset += 5) + // byte 5 : result code + // bytes 6..9 : generation + // bytes 10..13 : expiration + // bytes 14..17 : skipped (parser does offset += 8 after expiration) + // bytes 18..19 : fieldCount + // bytes 20..21 : opCount + ByteArrayOutputStream out = new ByteArrayOutputStream(); + for (int i = 0; i < 5; i++) out.write(0); // size marker + 4 attrs + out.write(0); // result code + for (int i = 0; i < 4; i++) out.write(0); // generation + for (int i = 0; i < 4; i++) out.write(0); // expiration + for (int i = 0; i < 4; i++) out.write(0); // skip + writeShort(out, fieldTypes.length); // fieldCount + writeShort(out, 0); // opCount + + for (int i = 0; i < fieldTypes.length; i++) { + byte[] data = fieldDatas[i]; + int size = data.length + 1; // includes type byte + writeInt(out, size); + out.write(fieldTypes[i] & 0xFF); + writeBytes(out, data); + } + return out.toByteArray(); + } + + /** Variable-arity fixmap (0–15 key/value pairs). */ + private static byte[] fixmap(byte[]... kvs) { + assertTrue("fixmap supports up to 15 entries", kvs.length <= 15); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(0x80 | kvs.length); + for (byte[] kv : kvs) { + writeBytes(out, kv); + } + return out.toByteArray(); + } + + /** Variable-arity fixarray (0–15 elements). */ + private static byte[] fixarray(byte[]... elems) { + assertTrue("fixarray supports up to 15 entries", elems.length <= 15); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(0x90 | elems.length); + for (byte[] e : elems) { + writeBytes(out, e); + } + return out.toByteArray(); + } + + private static byte[] fixmap1(byte[] kv) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(0x81); + writeBytes(out, kv); + return out.toByteArray(); + } + + private static byte[] fixmap2(byte[] kv1, byte[] kv2) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(0x82); + writeBytes(out, kv1); + writeBytes(out, kv2); + return out.toByteArray(); + } + + private static byte[] pair(byte[] k, byte[] v) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + writeBytes(out, k); + writeBytes(out, v); + return out.toByteArray(); + } + + private static byte[] intKey(int v) { + // positive fixint (0–127) + assertTrue(v >= 0 && v <= 0x7F); + return new byte[]{(byte)v}; + } + + private static byte[] fixint(int v) { + assertTrue(v >= 0 && v <= 0x7F); + return new byte[]{(byte)v}; + } + + private static byte[] fixstr(String s) { + byte[] data = s.getBytes(StandardCharsets.UTF_8); + assertTrue("fixstr supports up to 31 bytes", data.length <= 31); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(0xA0 | data.length); + writeBytes(out, data); + return out.toByteArray(); + } + + private static void writeBytes(ByteArrayOutputStream out, byte[] b) { + try { + out.write(b); + } + catch (java.io.IOException e) { + throw new RuntimeException(e); + } + } + + private static void writeShort(ByteArrayOutputStream out, int v) { + out.write((v >> 8) & 0xFF); + out.write(v & 0xFF); + } + + private static void writeInt(ByteArrayOutputStream out, int v) { + out.write((v >> 24) & 0xFF); + out.write((v >> 16) & 0xFF); + out.write((v >> 8) & 0xFF); + out.write(v & 0xFF); + } + + private static void writeLong(ByteArrayOutputStream out, long v) { + out.write((int)((v >> 56) & 0xFF)); + out.write((int)((v >> 48) & 0xFF)); + out.write((int)((v >> 40) & 0xFF)); + out.write((int)((v >> 32) & 0xFF)); + out.write((int)((v >> 24) & 0xFF)); + out.write((int)((v >> 16) & 0xFF)); + out.write((int)((v >> 8) & 0xFF)); + out.write((int)(v & 0xFF)); + } + + private static String repeat(char c, int n) { + char[] arr = new char[n]; + java.util.Arrays.fill(arr, c); + return new String(arr); + } + + // Ensure assertNotNull is referenced (used for nullity checks in helpers). + @SuppressWarnings("unused") + private static void unused() { assertNotNull(""); } +} diff --git a/test/src/com/aerospike/test/sync/basic/TestErrorDetailVerbosity.java b/test/src/com/aerospike/test/sync/basic/TestErrorDetailVerbosity.java new file mode 100644 index 000000000..48e27fcdd --- /dev/null +++ b/test/src/com/aerospike/test/sync/basic/TestErrorDetailVerbosity.java @@ -0,0 +1,867 @@ +/* + * Copyright 2012-2026 Aerospike, Inc. + * + * Portions may be licensed to Aerospike, Inc. under one or more contributor + * license agreements WHICH ARE COMPATIBLE WITH THE APACHE LICENSE, VERSION 2.0. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.aerospike.test.sync.basic; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import static org.junit.Assert.assertNull; + +import com.aerospike.client.AerospikeException; +import com.aerospike.client.Bin; +import com.aerospike.client.ExpressionTrace; +import com.aerospike.client.Key; +import com.aerospike.client.Operation; +import com.aerospike.client.Record; +import com.aerospike.client.ResultCode; +import com.aerospike.client.SubCode; +import com.aerospike.client.Value; +import com.aerospike.client.cdt.CTX; +import com.aerospike.client.cdt.ListOperation; +import com.aerospike.client.cdt.ListOrder; +import com.aerospike.client.cdt.ListPolicy; +import com.aerospike.client.cdt.ListReturnType; +import com.aerospike.client.cdt.ListWriteFlags; +import com.aerospike.client.cdt.MapOperation; +import com.aerospike.client.cdt.MapOrder; +import com.aerospike.client.cdt.MapPolicy; +import com.aerospike.client.cdt.MapReturnType; +import com.aerospike.client.cdt.MapWriteFlags; +import com.aerospike.client.exp.Exp; +import com.aerospike.client.exp.ExpOperation; +import com.aerospike.client.exp.ExpWriteFlags; +import com.aerospike.client.operation.BitOperation; +import com.aerospike.client.operation.HLLOperation; +import com.aerospike.client.operation.HLLPolicy; +import com.aerospike.client.policy.GenerationPolicy; +import com.aerospike.client.policy.Policy; +import com.aerospike.client.policy.RecordExistsAction; +import com.aerospike.client.policy.WritePolicy; +import com.aerospike.test.sync.TestSync; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Validates the extended error-detail feature (CLIENT-4221) against a server + * that supports error detail verbosity. + * + *

Subcode expectations track the per-status enum numbering finalized on the + * C client (commits 8b6f8db4 / 2d2495a1) and confirmed against the live server: + * the old flat status-echo subcodes (1100, 1134, 1138, 1701, ...) are retired. + * Where the status is already maximally specific the server emits AS_SUB_NONE + * and omits the subcode map key entirely, so no "(subcode=...)" suffix appears. + */ +public class TestErrorDetailVerbosity extends TestSync { + + private static final String binName = "edv-bin"; + private static Key intKey; + private static Key strKey; + private static Key listKey; + + @BeforeClass + public static void setup() { + // Extended error-detail (subcode/message) plus the verbosity-3 expression + // build trace (SERVER-1137). The SERVER-1137 feature branch is cut from the + // 8.1.1 line and reports its base version as 8.1.1.0-start-*, so gate at + // 8.1.1 rather than the 8.1.3 release that first shipped the base tier. + org.junit.Assume.assumeTrue("Extended error-detail requires server version 8.1.3 or later", + args.serverVersion.isGreaterOrEqual(8, 1, 3, 0)); + + WritePolicy wp = new WritePolicy(); + intKey = new Key(args.namespace, args.set, "edv-int-key"); + strKey = new Key(args.namespace, args.set, "edv-str-key"); + listKey = new Key(args.namespace, args.set, "edv-list-key"); + + client.put(wp, intKey, new Bin(binName, 1)); + client.put(wp, strKey, new Bin(binName, "hello")); + + List seed = new ArrayList<>(); + seed.add(Value.get(10)); + seed.add(Value.get(20)); + seed.add(Value.get(30)); + client.put(wp, listKey, new Bin(binName, seed)); + } + + // --------------------------------------------------------------------- + // Verbosity level semantics. + // --------------------------------------------------------------------- + + @Test + public void testDefaultVerbosityIsZero() { + Policy p = new Policy(); + assertEquals(0, p.errorDetailVerbosity); + + WritePolicy wp = new WritePolicy(); + assertEquals(0, wp.errorDetailVerbosity); + } + + @Test + public void testVerbosityDisabled() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 0; + + try { + client.operate(wp, intKey, Operation.append(new Bin(binName, "bad"))); + } + catch (AerospikeException ae) { + assertEquals(ResultCode.BIN_TYPE_ERROR, ae.getResultCode()); + // With verbosity 0, the message should be the default ResultCode string. + String msg = ae.getBaseMessage(); + assertEquals(ResultCode.getResultString(ResultCode.BIN_TYPE_ERROR), msg); + return; + } + assertTrue("Expected AerospikeException", false); + } + + @Test + public void testVerbositySubcodeOnly() { + // Verbosity 1: server sends the subcode but not the message. A subcode + // that resolves to a value (BIN_NOT_FOUND from an HLL count op on a + // missing bin -> subcode 1) surfaces as the bare "error subcode=N" form. + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 1; + + Key key = new Key(args.namespace, args.set, "edv-subonly-key"); + client.put(new WritePolicy(), key, new Bin("other-bin", 1)); + + try { + client.operate(wp, key, HLLOperation.refreshCount("no-hll-bin")); + } + catch (AerospikeException ae) { + assertEquals(ResultCode.BIN_NOT_FOUND, ae.getResultCode()); + assertEquals(SubCode.BIN_NOT_FOUND_HLL_CANNOT_CREATE_WITH_OP, ae.getSubCode()); + String msg = ae.getBaseMessage(); + assertNotNull(msg); + assertTrue("Expected subcode in: " + msg, msg.contains("subcode=1")); + return; + } + assertTrue("Expected AerospikeException", false); + } + + @Test + public void testVerbositySubcodeAndMessage() { + // Verbosity 2: server sends both message and subcode, formatted as + // " (subcode=)". + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + + Key key = new Key(args.namespace, args.set, "edv-submsg-key"); + client.put(new WritePolicy(), key, new Bin("other-bin", 1)); + + try { + client.operate(wp, key, HLLOperation.refreshCount("no-hll-bin")); + } + catch (AerospikeException ae) { + assertEquals(ResultCode.BIN_NOT_FOUND, ae.getResultCode()); + assertEquals(SubCode.BIN_NOT_FOUND_HLL_CANNOT_CREATE_WITH_OP, ae.getSubCode()); + String msg = ae.getBaseMessage(); + assertNotNull(msg); + assertTrue("Expected message text in: " + msg, msg.contains("count op")); + assertTrue("Expected subcode in: " + msg, msg.contains("(subcode=1)")); + return; + } + assertTrue("Expected AerospikeException", false); + } + + // --------------------------------------------------------------------- + // Subcode-absent cases (AS_SUB_NONE): the status is already maximally + // specific, so the server omits the subcode map key and the client must + // never format a "(subcode=...)" suffix. The message carries the context. + // --------------------------------------------------------------------- + + @Test + public void testAppendToIntegerBin() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + + try { + client.operate(wp, intKey, Operation.append(new Bin(binName, "bad-append"))); + } + catch (AerospikeException ae) { + assertSubcodeAbsent(ae, ResultCode.BIN_TYPE_ERROR, "cannot append"); + return; + } + assertTrue("Expected AerospikeException", false); + } + + @Test + public void testIncrementStringBin() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + + try { + client.operate(wp, strKey, Operation.add(new Bin(binName, 1))); + } + catch (AerospikeException ae) { + assertSubcodeAbsent(ae, ResultCode.BIN_TYPE_ERROR, "cannot increment"); + return; + } + assertTrue("Expected AerospikeException", false); + } + + @Test + public void testHllAddOnIntegerBin() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + + List hllList = new ArrayList<>(); + hllList.add(Value.get("element1")); + + try { + client.operate(wp, intKey, + HLLOperation.add(HLLPolicy.Default, binName, hllList, 8)); + } + catch (AerospikeException ae) { + assertSubcodeAbsent(ae, ResultCode.BIN_TYPE_ERROR, "bin is not hll type"); + return; + } + assertTrue("Expected AerospikeException", false); + } + + @Test + public void testDeleteGenerationMismatch() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + wp.generationPolicy = GenerationPolicy.EXPECT_GEN_EQUAL; + wp.generation = 777; + + try { + client.delete(wp, intKey); + } + catch (AerospikeException ae) { + assertSubcodeAbsent(ae, ResultCode.GENERATION_ERROR, "generation"); + return; + } + assertTrue("Expected AerospikeException", false); + } + + // --------------------------------------------------------------------- + // Subcode-present cases: per-status enum subcode numbering. + // --------------------------------------------------------------------- + + @Test + public void testHllRefreshCountMissingBin() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + + Key key = new Key(args.namespace, args.set, "edv-no-hll-key"); + client.put(new WritePolicy(), key, new Bin("other-bin", 1)); + + try { + client.operate(wp, key, HLLOperation.refreshCount("no-hll-bin")); + } + catch (AerospikeException ae) { + // AS_SUB_BIN_NOT_FOUND_HLL_CANNOT_CREATE_WITH_OP = 1 + assertSubcode(ae, ResultCode.BIN_NOT_FOUND, SubCode.BIN_NOT_FOUND_HLL_CANNOT_CREATE_WITH_OP); + return; + } + assertTrue("Expected AerospikeException", false); + } + + @Test + public void testListGetIndexOutOfBounds() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + + try { + client.operate(wp, listKey, ListOperation.get(binName, 99)); + } + catch (AerospikeException ae) { + // AS_SUB_OPNOT_CDT_INDEX_OUT_OF_BOUNDS = 1 + assertSubcode(ae, ResultCode.OP_NOT_APPLICABLE, SubCode.OPNOT_CDT_INDEX_OUT_OF_BOUNDS); + return; + } + assertTrue("Expected AerospikeException", false); + } + + @Test + public void testListGetByRankOutOfBounds() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + + try { + client.operate(wp, listKey, ListOperation.getByRank(binName, 99, ListReturnType.VALUE)); + } + catch (AerospikeException ae) { + // AS_SUB_OPNOT_CDT_RANK_OUT_OF_BOUNDS = 2 + assertSubcode(ae, ResultCode.OP_NOT_APPLICABLE, SubCode.OPNOT_CDT_RANK_OUT_OF_BOUNDS); + return; + } + assertTrue("Expected AerospikeException", false); + } + + @Test + public void testListBoundedOverflow() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + + ListPolicy bounded = new ListPolicy(ListOrder.ORDERED, ListWriteFlags.INSERT_BOUNDED); + + try { + client.operate(wp, listKey, ListOperation.insert(bounded, binName, 10, Value.get(5))); + } + catch (AerospikeException ae) { + // AS_SUB_OPNOT_CDT_BOUNDED_LIST_OVERFLOW = 3 + assertSubcode(ae, ResultCode.OP_NOT_APPLICABLE, SubCode.OPNOT_CDT_BOUNDED_LIST_OVERFLOW); + return; + } + assertTrue("Expected AerospikeException", false); + } + + @Test + public void testHllFoldTargetTooLarge() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + + Key key = new Key(args.namespace, args.set, "edv-hll-fold-key"); + client.delete(new WritePolicy(), key); + client.operate(new WritePolicy(), key, HLLOperation.init(HLLPolicy.Default, binName, 8)); + + try { + client.operate(wp, key, HLLOperation.fold(binName, 14)); + } + catch (AerospikeException ae) { + // AS_SUB_OPNOT_HLL_FOLD_INDEX_BITS_TOO_LARGE = 8 + assertSubcode(ae, ResultCode.OP_NOT_APPLICABLE, SubCode.OPNOT_HLL_FOLD_INDEX_BITS_TOO_LARGE); + return; + } + assertTrue("Expected AerospikeException", false); + } + + @Test + public void testBitGetOffsetOutOfRange() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + + Key key = new Key(args.namespace, args.set, "edv-bits-key"); + client.put(new WritePolicy(), key, new Bin(binName, new byte[]{(byte)0xAA, (byte)0xBB, (byte)0xCC, (byte)0xDD})); + + try { + client.operate(wp, key, BitOperation.get(binName, 2000000000, 8)); + } + catch (AerospikeException ae) { + // AS_SUB_PARAM_BITS_OFFSET_OUT_OF_RANGE = 2 + assertSubcode(ae, ResultCode.PARAMETER_ERROR, SubCode.PARAM_BITS_OFFSET_OUT_OF_RANGE); + return; + } + assertTrue("Expected AerospikeException", false); + } + + @Test + public void testBitGetSizeZero() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + + Key key = new Key(args.namespace, args.set, "edv-bits-key2"); + client.put(new WritePolicy(), key, new Bin(binName, new byte[]{(byte)0xAA, (byte)0xBB, (byte)0xCC, (byte)0xDD})); + + try { + client.operate(wp, key, BitOperation.get(binName, 0, 0)); + } + catch (AerospikeException ae) { + // AS_SUB_PARAM_BITS_SIZE_OUT_OF_RANGE = 3 + assertSubcode(ae, ResultCode.PARAMETER_ERROR, SubCode.PARAM_BITS_SIZE_OUT_OF_RANGE); + return; + } + assertTrue("Expected AerospikeException", false); + } + + @Test + public void testReadFilteredOut() { + // FILTERED_OUT carries no subcode (AS_SUB_NONE) and a contextual message; + // the server's as_sub_filtered_t enum was removed, so there is no version gate. + Policy p = new Policy(); + p.errorDetailVerbosity = 2; + p.filterExp = Exp.build(Exp.eq(Exp.intBin(binName), Exp.val(99))); + p.failOnFilteredOut = true; + + try { + client.get(p, intKey); + } + catch (AerospikeException ae) { + assertSubcodeAbsent(ae, ResultCode.FILTERED_OUT, "filtered out"); + return; + } + assertTrue("Expected AerospikeException", false); + } + + // --------------------------------------------------------------------- + // Additional particle modify type mismatches (subcode absent). + // --------------------------------------------------------------------- + + @Test + public void testPrependToIntegerBin() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + + try { + client.operate(wp, intKey, Operation.prepend(new Bin(binName, "bad-prepend"))); + } + catch (AerospikeException ae) { + assertSubcodeAbsent(ae, ResultCode.BIN_TYPE_ERROR, "prepend"); + return; + } + assertTrue("Expected AerospikeException", false); + } + + @Test + public void testIncrementDoubleOnIntegerBin() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + + try { + client.operate(wp, strKey, Operation.add(new Bin(binName, 1.5))); + } + catch (AerospikeException ae) { + assertSubcodeAbsent(ae, ResultCode.BIN_TYPE_ERROR); + return; + } + assertTrue("Expected AerospikeException", false); + } + + // --------------------------------------------------------------------- + // Additional CDT list ops. + // --------------------------------------------------------------------- + + @Test + public void testListPopIndexOutOfBounds() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + + try { + client.operate(wp, listKey, ListOperation.pop(binName, 99)); + } + catch (AerospikeException ae) { + // AS_SUB_OPNOT_CDT_INDEX_OUT_OF_BOUNDS = 1 + assertSubcode(ae, ResultCode.OP_NOT_APPLICABLE, SubCode.OPNOT_CDT_INDEX_OUT_OF_BOUNDS); + return; + } + assertTrue("Expected AerospikeException", false); + } + + @Test + public void testListAddUniqueViolation() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + + // Seed [10,20,30]; appending an existing value with ADD_UNIQUE fails. + ListPolicy unique = new ListPolicy(ListOrder.UNORDERED, ListWriteFlags.ADD_UNIQUE); + + try { + client.operate(wp, listKey, ListOperation.append(unique, binName, Value.get(20))); + } + catch (AerospikeException ae) { + assertSubcodeAbsent(ae, ResultCode.ELEMENT_EXISTS); + return; + } + assertTrue("Expected AerospikeException", false); + } + + @Test + public void testListOpOnRawBytesBin() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + + // A raw-bytes bin is not a list -> list_get triggers the wrong-type path. + Key key = new Key(args.namespace, args.set, "edv-list-raw-key"); + client.put(new WritePolicy(), key, new Bin(binName, new byte[]{(byte)0xDE, (byte)0xAD, (byte)0xBE, (byte)0xEF})); + + try { + client.operate(wp, key, ListOperation.get(binName, 0)); + } + catch (AerospikeException ae) { + assertSubcodeAbsent(ae, ResultCode.BIN_TYPE_ERROR); + return; + } + assertTrue("Expected AerospikeException", false); + } + + // --------------------------------------------------------------------- + // CDT map ops. + // --------------------------------------------------------------------- + + @Test + public void testMapCreateOnlyExistingKey() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + + Key key = new Key(args.namespace, args.set, "edv-map-create-key"); + Map seed = new HashMap<>(); + seed.put(1, "a"); + client.put(new WritePolicy(), key, new Bin(binName, seed)); + + MapPolicy mp = new MapPolicy(MapOrder.UNORDERED, MapWriteFlags.CREATE_ONLY); + + try { + client.operate(wp, key, MapOperation.put(mp, binName, Value.get(1), Value.get("b"))); + } + catch (AerospikeException ae) { + assertSubcodeAbsent(ae, ResultCode.ELEMENT_EXISTS); + return; + } + assertTrue("Expected AerospikeException", false); + } + + @Test + public void testMapUpdateOnlyMissingKey() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + + Key key = new Key(args.namespace, args.set, "edv-map-update-key"); + Map seed = new HashMap<>(); + seed.put(1, "a"); + client.put(new WritePolicy(), key, new Bin(binName, seed)); + + MapPolicy mp = new MapPolicy(MapOrder.UNORDERED, MapWriteFlags.UPDATE_ONLY); + + try { + client.operate(wp, key, MapOperation.put(mp, binName, Value.get(99), Value.get("b"))); + } + catch (AerospikeException ae) { + assertSubcodeAbsent(ae, ResultCode.ELEMENT_NOT_FOUND); + return; + } + assertTrue("Expected AerospikeException", false); + } + + @Test + public void testMapOpOnListBin() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + + // listKey holds a list; a map op against it triggers the wrong-type path. + try { + client.operate(wp, listKey, MapOperation.getByKey(binName, Value.get(1), MapReturnType.VALUE)); + } + catch (AerospikeException ae) { + assertSubcodeAbsent(ae, ResultCode.BIN_TYPE_ERROR); + return; + } + assertTrue("Expected AerospikeException", false); + } + + @Test + public void testMapOpOnRawBytesBin() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + + Key key = new Key(args.namespace, args.set, "edv-map-raw-key"); + client.put(new WritePolicy(), key, new Bin(binName, new byte[]{0x42, 0x42})); + + try { + client.operate(wp, key, MapOperation.getByKey(binName, Value.get(1), MapReturnType.VALUE)); + } + catch (AerospikeException ae) { + assertSubcodeAbsent(ae, ResultCode.BIN_TYPE_ERROR); + return; + } + assertTrue("Expected AerospikeException", false); + } + + @Test + public void testListCtxIntoStringMapValue() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + + // Map value at key 1 is a string; descending into it with a list op and + // a map-key context is a type mismatch. + Key key = new Key(args.namespace, args.set, "edv-map-ctx-key"); + Map seed = new HashMap<>(); + seed.put(1, "leaf-string"); + client.put(new WritePolicy(), key, new Bin(binName, seed)); + + try { + client.operate(wp, key, ListOperation.get(binName, 0, CTX.mapKey(Value.get(1)))); + } + catch (AerospikeException ae) { + assertSubcodeAbsent(ae, ResultCode.BIN_TYPE_ERROR); + return; + } + assertTrue("Expected AerospikeException", false); + } + + // --------------------------------------------------------------------- + // Additional HLL ops. + // --------------------------------------------------------------------- + + @Test + public void testHllInitInvalidIndexBits() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + + Key key = new Key(args.namespace, args.set, "edv-hll-bad-bits-key"); + + try { + // Index bit count out of the legal [4,16] range -> server-side reject. + client.operate(wp, key, HLLOperation.init(HLLPolicy.Default, binName, 30)); + } + catch (AerospikeException ae) { + assertSubcodeAbsent(ae, ResultCode.PARAMETER_ERROR); + return; + } + assertTrue("Expected AerospikeException", false); + } + + @Test + public void testHllOpOnRawBytesBin() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + + Key key = new Key(args.namespace, args.set, "edv-hll-raw-key"); + client.put(new WritePolicy(), key, new Bin(binName, new byte[]{0x01, 0x02, 0x03})); + + try { + client.operate(wp, key, HLLOperation.getCount(binName)); + } + catch (AerospikeException ae) { + assertSubcodeAbsent(ae, ResultCode.BIN_TYPE_ERROR); + return; + } + assertTrue("Expected AerospikeException", false); + } + + // --------------------------------------------------------------------- + // Write / delete / read policy (subcode absent unless noted). + // --------------------------------------------------------------------- + + @Test + public void testWriteCreateOnlyExistingRecord() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + wp.recordExistsAction = RecordExistsAction.CREATE_ONLY; + + try { + // intKey already exists. + client.put(wp, intKey, new Bin(binName, 2)); + } + catch (AerospikeException ae) { + assertSubcodeAbsent(ae, ResultCode.KEY_EXISTS_ERROR); + return; + } + assertTrue("Expected AerospikeException", false); + } + + @Test + public void testWriteReplaceOnlyMissingRecord() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + wp.recordExistsAction = RecordExistsAction.REPLACE_ONLY; + + Key key = new Key(args.namespace, args.set, "edv-replace-missing-key"); + client.delete(new WritePolicy(), key); + + try { + client.put(wp, key, new Bin(binName, 1)); + } + catch (AerospikeException ae) { + assertSubcodeAbsent(ae, ResultCode.KEY_NOT_FOUND_ERROR); + return; + } + assertTrue("Expected AerospikeException", false); + } + + @Test + public void testWriteGenerationMismatch() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + wp.generationPolicy = GenerationPolicy.EXPECT_GEN_EQUAL; + wp.generation = 999; + + try { + client.put(wp, intKey, new Bin(binName, 2)); + } + catch (AerospikeException ae) { + assertSubcodeAbsent(ae, ResultCode.GENERATION_ERROR, "generation"); + return; + } + assertTrue("Expected AerospikeException", false); + } + + @Test + public void testOperateFilteredOut() { + // FILTERED_OUT carries no subcode (AS_SUB_NONE) and a contextual message; + // the server's as_sub_filtered_t enum was removed, so there is no version gate. + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + wp.filterExp = Exp.build(Exp.eq(Exp.intBin(binName), Exp.val(99))); + wp.failOnFilteredOut = true; + + try { + client.operate(wp, intKey, Operation.get(binName)); + } + catch (AerospikeException ae) { + assertSubcodeAbsent(ae, ResultCode.FILTERED_OUT, "filtered out"); + return; + } + assertTrue("Expected AerospikeException", false); + } + + // --------------------------------------------------------------------- + // Happy path: verbosity set on a successful command must not break. + // --------------------------------------------------------------------- + + @Test + public void testSuccessNoErrorDetails() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 2; + + Key key = new Key(args.namespace, args.set, "edv-success-key"); + client.put(wp, key, new Bin(binName, 42)); + + Policy rp = new Policy(); + rp.errorDetailVerbosity = 2; + Record record = client.get(rp, key); + + assertNotNull(record); + assertEquals(42, record.getInt(binName)); + } + + // --------------------------------------------------------------------- + // Verbosity 3: expression build-failure trace (SERVER-1137). + // + // A type-mismatched comparison expression fails to *build* on the server. + // As a filter_exp it yields "invalid metadata expression in request"; as an + // exp_write op it yields "invalid expression in operation request". Both carry + // PARAMETER_ERROR + SubCode.NONE and, at verbosity 3, a structured build trace. + // Assert trace PRESENCE and SHAPE, not exact byte_offset/snippet bytes. + // --------------------------------------------------------------------- + + /** Expression whose operands are type-mismatched (int vs float), so the server build fails. */ + private static Exp badExp() { + return Exp.eq(Exp.val(5), Exp.val(6.0)); + } + + @Test + public void testFilterExpBuildFailureTrace() { + Policy p = new Policy(); + p.errorDetailVerbosity = 3; + p.filterExp = Exp.build(badExp()); + + try { + client.get(p, intKey); + } + catch (AerospikeException ae) { + assertEquals(ResultCode.PARAMETER_ERROR, ae.getResultCode()); + assertEquals(SubCode.NONE, ae.getSubCode()); + + String msg = ae.getBaseMessage(); + assertNotNull(msg); + assertTrue("Expected filter-build message in: " + msg, + msg.contains("invalid metadata expression in request")); + + ExpressionTrace t = ae.getExpressionTrace(); + assertNotNull("Expected a non-null expression trace at verbosity 3", t); + assertEquals("Expected a build-phase trace", ExpressionTrace.PHASE_BUILD, t.getPhase()); + return; + } + assertTrue("Expected AerospikeException", false); + } + + @Test + public void testExpWriteBuildFailureTrace() { + WritePolicy wp = new WritePolicy(); + wp.errorDetailVerbosity = 3; + + try { + client.operate(wp, intKey, + ExpOperation.write(binName, Exp.build(badExp()), ExpWriteFlags.DEFAULT)); + } + catch (AerospikeException ae) { + assertEquals(ResultCode.PARAMETER_ERROR, ae.getResultCode()); + assertEquals(SubCode.NONE, ae.getSubCode()); + + String msg = ae.getBaseMessage(); + assertNotNull(msg); + assertTrue("Expected exp-op build message in: " + msg, + msg.contains("invalid expression in operation request")); + + ExpressionTrace t = ae.getExpressionTrace(); + assertNotNull("Expected a non-null expression trace at verbosity 3", t); + assertEquals("Expected a build-phase trace", ExpressionTrace.PHASE_BUILD, t.getPhase()); + return; + } + assertTrue("Expected AerospikeException", false); + } + + @Test + public void testFilterExpBuildFailureVerbosity2HasNoTrace() { + // Additive-superset check: the SAME inducer at verbosity 2 surfaces the same + // message but NO trace. Verbosity 3 = verbosity 2 + trace. + Policy p = new Policy(); + p.errorDetailVerbosity = 2; + p.filterExp = Exp.build(badExp()); + + try { + client.get(p, intKey); + } + catch (AerospikeException ae) { + assertEquals(ResultCode.PARAMETER_ERROR, ae.getResultCode()); + assertEquals(SubCode.NONE, ae.getSubCode()); + + String msg = ae.getBaseMessage(); + assertNotNull(msg); + assertTrue("Expected filter-build message in: " + msg, + msg.contains("invalid metadata expression in request")); + + assertNull("Verbosity 2 must surface NO expression trace", ae.getExpressionTrace()); + return; + } + assertTrue("Expected AerospikeException", false); + } + + /** + * Assert the server-supplied {@code (resultCode, subcode)} pair. The numeric + * subcode must be exposed first-class via {@link AerospikeException#getSubCode()} + * (not merely embedded in the message string), and the "subcode=N" suffix must + * still appear in the message for parity with the C client. + */ + private void assertSubcode(AerospikeException ae, int expectedResultCode, int expectedSubcode) { + assertEquals("Unexpected result code", expectedResultCode, ae.getResultCode()); + assertEquals("Unexpected subcode", expectedSubcode, ae.getSubCode()); + + String msg = ae.getBaseMessage(); + assertNotNull("Expected server error message", msg); + assertTrue("Expected 'subcode=" + expectedSubcode + "' in: " + msg, + msg.contains("subcode=" + expectedSubcode)); + } + + /** + * Assert that the server surfaced a contextual message but NO subcode + * (AS_SUB_NONE): {@link AerospikeException#getSubCode()} is {@link SubCode#NONE} + * and the "(subcode=...)" suffix must never appear. Any expectedSubstrings are + * required in the message; pass none to skip the message-text check (mirrors a + * NULL expected_msg_substr in the C example). + */ + private void assertSubcodeAbsent(AerospikeException ae, int expectedResultCode, String... expectedSubstrings) { + assertEquals("Unexpected result code", expectedResultCode, ae.getResultCode()); + assertEquals("Expected no subcode", SubCode.NONE, ae.getSubCode()); + + String msg = ae.getBaseMessage(); + assertNotNull("Expected server error message", msg); + + for (String expected : expectedSubstrings) { + assertTrue("Expected '" + expected + "' in: " + msg, msg.contains(expected)); + } + assertFalse("Expected NO subcode suffix in: " + msg, msg.contains("subcode=")); + } +}