From 51d2911a172a1b25e660f98bfd1a521963d4e982 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Mon, 25 Aug 2025 21:04:03 -0700 Subject: [PATCH 001/112] query.results(): reuse code from query.foreach() --- src/include/query.h | 5 ++ src/main/query/foreach.c | 145 +++++++++++++++++++++++++-------------- src/main/query/results.c | 115 +------------------------------ 3 files changed, 99 insertions(+), 166 deletions(-) diff --git a/src/include/query.h b/src/include/query.h index a00be4aa75..cd95145fda 100644 --- a/src/include/query.h +++ b/src/include/query.h @@ -150,3 +150,8 @@ PyObject *AerospikeQuery_Get_Partitions_status(AerospikeQuery *self); PyObject *StoreUnicodePyObject(AerospikeQuery *self, PyObject *obj); int64_t pyobject_to_int64(PyObject *py_obj); + +PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, + PyObject *py_callback, + PyObject *py_policy, + PyObject *py_options); diff --git a/src/main/query/foreach.c b/src/main/query/foreach.c index 643131e120..4c71b34acc 100644 --- a/src/main/query/foreach.c +++ b/src/main/query/foreach.c @@ -38,6 +38,9 @@ typedef struct { int partition_query; as_vector thread_errors; pthread_mutex_t thread_errors_mutex; + // TODO: perf overhead from adding this? + // Used by default if no Python callback is provided + PyObject *py_results; } LocalData; static bool each_result(const as_val *val, void *udata) @@ -51,6 +54,7 @@ static bool each_result(const as_val *val, void *udata) // Extract callback user-data LocalData *data = (LocalData *)udata; PyObject *py_callback = data->callback; + PyObject *py_results = data->py_results; // Python Function Arguments and Result Value PyObject *py_arglist = NULL; @@ -72,45 +76,59 @@ static bool each_result(const as_val *val, void *udata) goto EXIT_CALLBACK; } - // Build Python Function Arguments - if (data->partition_query) { + if (!py_callback) { + // query.results() + // TODO: negative path. py_result is an invalid type and set to NULL. + if (py_result) { + int retval = PyList_Append(py_results, py_result); + Py_DECREF(py_result); + if (retval == -1) { + // TODO: should fail, not return true + goto EXIT_CALLBACK; + } + } + } + else { + // Build Python Function Arguments + if (data->partition_query) { - uint32_t part_id = 0; + uint32_t part_id = 0; - as_record *rec = as_record_fromval(val); + as_record *rec = as_record_fromval(val); - if (rec->key.digest.init) { - part_id = - as_partition_getid(rec->key.digest.value, CLUSTER_NPARTITIONS); - } + if (rec->key.digest.init) { + part_id = as_partition_getid(rec->key.digest.value, + CLUSTER_NPARTITIONS); + } - py_arglist = PyTuple_New(2); + py_arglist = PyTuple_New(2); - PyTuple_SetItem(py_arglist, 0, PyLong_FromUnsignedLong(part_id)); - PyTuple_SetItem(py_arglist, 1, py_result); - } - else { - py_arglist = PyTuple_New(1); - PyTuple_SetItem(py_arglist, 0, py_result); - } + PyTuple_SetItem(py_arglist, 0, PyLong_FromUnsignedLong(part_id)); + PyTuple_SetItem(py_arglist, 1, py_result); + } + else { + py_arglist = PyTuple_New(1); + PyTuple_SetItem(py_arglist, 0, py_result); + } - // Invoke Python Callback - py_return = PyObject_Call(py_callback, py_arglist, NULL); - - // Release Python Function Arguments - Py_DECREF(py_arglist); - // handle return value - if (!py_return) { - // an exception was raised, handle it (someday) - // for now, we bail from the loop - as_error_update(&thread_err_local, AEROSPIKE_ERR_CLIENT, - "Callback function contains an error"); - retval = false; - } - else if (py_return == Py_False) { - retval = false; + // Invoke Python Callback + py_return = PyObject_Call(py_callback, py_arglist, NULL); + + // Release Python Function Arguments + Py_DECREF(py_arglist); + // handle return value + if (!py_return) { + // an exception was raised, handle it (someday) + // for now, we bail from the loop + as_error_update(&thread_err_local, AEROSPIKE_ERR_CLIENT, + "Callback function contains an error"); + retval = false; + } + else if (py_return == Py_False) { + retval = false; + } + Py_XDECREF(py_return); } - Py_XDECREF(py_return); EXIT_CALLBACK: if (thread_err_local.code != AEROSPIKE_OK) { @@ -129,26 +147,13 @@ static bool each_result(const as_val *val, void *udata) return retval; } -PyObject *AerospikeQuery_Foreach(AerospikeQuery *self, PyObject *args, - PyObject *kwds) +PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, + PyObject *py_callback, + PyObject *py_policy, + PyObject *py_options) { - // Python Function Arguments - PyObject *py_callback = NULL; - PyObject *py_policy = NULL; - PyObject *py_options = NULL; - // Python Function Keyword Arguments - static char *kwlist[] = {"callback", "policy", "options", NULL}; - - // Python Function Argument Parsing - if (PyArg_ParseTupleAndKeywords(args, kwds, "O|OO:foreach", kwlist, - &py_callback, &py_policy, - &py_options) == false) { - as_query_destroy(&self->query); - return NULL; - } - // Initialize callback user data - LocalData data; + LocalData data = {0}; data.callback = py_callback; data.client = self->client; data.partition_query = 0; @@ -185,6 +190,14 @@ PyObject *AerospikeQuery_Foreach(AerospikeQuery *self, PyObject *args, goto CLEANUP; } + if (!py_callback) { + // TODO: can be optimized by predicting the number of records from server? + data.py_results = PyList_New(0); + if (data.py_results == NULL) { + goto CLEANUP; + } + } + // Convert python policy object to as_policy_exists pyobject_to_policy_query( self->client, &err, py_policy, &query_policy, &query_policy_p, @@ -261,10 +274,38 @@ PyObject *AerospikeQuery_Foreach(AerospikeQuery *self, PyObject *args, pthread_mutex_destroy(&data.thread_errors_mutex); if (err.code != AEROSPIKE_OK) { + // TODO: results() used raise_exception(); + Py_XDECREF(data.py_results); raise_exception_base(&err, Py_None, Py_None, Py_None, Py_None, Py_None); return NULL; } - Py_INCREF(Py_None); - return Py_None; + if (data.py_results) { + Py_RETURN_NONE; + } + else { + return data.py_results; + } +} + +PyObject *AerospikeQuery_Foreach(AerospikeQuery *self, PyObject *args, + PyObject *kwds) +{ + // Python Function Arguments + PyObject *py_callback = NULL; + PyObject *py_policy = NULL; + PyObject *py_options = NULL; + // Python Function Keyword Arguments + static char *kwlist[] = {"callback", "policy", "options", NULL}; + + // Python Function Argument Parsing + if (PyArg_ParseTupleAndKeywords(args, kwds, "O|OO:foreach", kwlist, + &py_callback, &py_policy, + &py_options) == false) { + as_query_destroy(&self->query); + return NULL; + } + + return AerospikeQuery_Foreach_Invoke(self, py_callback, py_policy, + py_options); } diff --git a/src/main/query/results.c b/src/main/query/results.c index bc0994cb42..90f68bb0bd 100644 --- a/src/main/query/results.c +++ b/src/main/query/results.c @@ -32,38 +32,6 @@ #undef TRACE #define TRACE() -typedef struct { - PyObject *py_results; - AerospikeClient *client; -} LocalData; - -static bool each_result(const as_val *val, void *udata) -{ - if (!val) { - return false; - } - - PyObject *py_results = NULL; - LocalData *data = (LocalData *)udata; - py_results = data->py_results; - PyObject *py_result = NULL; - - as_error err; - - PyGILState_STATE gstate; - gstate = PyGILState_Ensure(); - - val_to_pyobject(data->client, &err, val, &py_result); - - if (py_result) { - PyList_Append(py_results, py_result); - Py_DECREF(py_result); - } - PyGILState_Release(gstate); - - return true; -} - PyObject *AerospikeQuery_Results(AerospikeQuery *self, PyObject *args, PyObject *kwds) { @@ -81,83 +49,7 @@ PyObject *AerospikeQuery_Results(AerospikeQuery *self, PyObject *args, return NULL; } - as_error err; - as_error_init(&err); - - as_policy_query query_policy; - as_policy_query *query_policy_p = NULL; - - // For converting expressions. - as_exp exp_list; - as_exp *exp_list_p = NULL; - - as_partition_filter partition_filter = {0}; - as_partition_filter *partition_filter_p = NULL; - as_partitions_status *ps = NULL; - - if (!self || !self->client->as) { - as_error_update(&err, AEROSPIKE_ERR_PARAM, "Invalid aerospike object"); - goto CLEANUP; - } - - if (!self->client->is_conn_16) { - as_error_update(&err, AEROSPIKE_ERR_CLUSTER, - "No connection to aerospike cluster"); - goto CLEANUP; - } - - // Convert python policy object to as_policy_query - pyobject_to_policy_query( - self->client, &err, py_policy, &query_policy, &query_policy_p, - &self->client->as->config.policies.query, &exp_list, &exp_list_p); - if (err.code != AEROSPIKE_OK) { - goto CLEANUP; - } - - if (set_query_options(&err, py_options, &self->query) != AEROSPIKE_OK) { - goto CLEANUP; - } - - if (py_policy) { - PyObject *py_partition_filter = - PyDict_GetItemString(py_policy, "partition_filter"); - if (py_partition_filter) { - if (convert_partition_filter(self->client, py_partition_filter, - &partition_filter, &ps, - &err) == AEROSPIKE_OK) { - partition_filter_p = &partition_filter; - } - else { - goto CLEANUP; - } - } - } - as_error_reset(&err); - - py_results = PyList_New(0); - data.py_results = py_results; - - Py_BEGIN_ALLOW_THREADS - - if (partition_filter_p) { - if (ps) { - as_partition_filter_set_partitions(partition_filter_p, ps); - } - - aerospike_query_partitions(self->client->as, &err, query_policy_p, - &self->query, partition_filter_p, - each_result, &data); - - if (ps) { - as_partitions_status_release(ps); - } - } - else { - aerospike_query_foreach(self->client->as, &err, query_policy_p, - &self->query, each_result, &data); - } - - Py_END_ALLOW_THREADS + return AerospikeQuery_Foreach_Invoke(self, NULL, py_policy, py_options); CLEANUP: /*??trace()*/ if (exp_list_p) { @@ -170,10 +62,5 @@ PyObject *AerospikeQuery_Results(AerospikeQuery *self, PyObject *args, return NULL; } - if (self->query.apply.arglist) { - as_arraylist_destroy((as_arraylist *)self->query.apply.arglist); - } - self->query.apply.arglist = NULL; - return py_results; } From 03709faa79dbc2d5c4eff0ba11c7f60e01d77f11 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Mon, 25 Aug 2025 21:07:49 -0700 Subject: [PATCH 002/112] finish cleanup --- src/main/query/results.c | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/main/query/results.c b/src/main/query/results.c index 90f68bb0bd..99a36e0cd4 100644 --- a/src/main/query/results.c +++ b/src/main/query/results.c @@ -50,17 +50,3 @@ PyObject *AerospikeQuery_Results(AerospikeQuery *self, PyObject *args, } return AerospikeQuery_Foreach_Invoke(self, NULL, py_policy, py_options); - -CLEANUP: /*??trace()*/ - if (exp_list_p) { - as_exp_destroy(exp_list_p); - } - - if (err.code != AEROSPIKE_OK) { - Py_XDECREF(py_results); - raise_exception(&err); - return NULL; - } - - return py_results; -} From 791aa2855d3a6b14ec457ba57199876f7dc74283 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 24 Sep 2025 19:45:25 -0700 Subject: [PATCH 003/112] Finish query.results() merge --- src/main/query/foreach.c | 35 ++++++++++++++++++----------------- src/main/scan/foreach.c | 2 +- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/main/query/foreach.c b/src/main/query/foreach.c index 4c71b34acc..a76718732a 100644 --- a/src/main/query/foreach.c +++ b/src/main/query/foreach.c @@ -33,14 +33,13 @@ // Struct for Python User-Data for the Callback typedef struct { - PyObject *callback; + PyObject *py_obj; AerospikeClient *client; int partition_query; as_vector thread_errors; pthread_mutex_t thread_errors_mutex; - // TODO: perf overhead from adding this? - // Used by default if no Python callback is provided - PyObject *py_results; + // If false, it is a python list + bool is_pyobj_callback; } LocalData; static bool each_result(const as_val *val, void *udata) @@ -53,8 +52,7 @@ static bool each_result(const as_val *val, void *udata) // Extract callback user-data LocalData *data = (LocalData *)udata; - PyObject *py_callback = data->callback; - PyObject *py_results = data->py_results; + PyObject *py_callback_or_list_of_results = data->py_obj; // Python Function Arguments and Result Value PyObject *py_arglist = NULL; @@ -76,11 +74,11 @@ static bool each_result(const as_val *val, void *udata) goto EXIT_CALLBACK; } - if (!py_callback) { + if (data->is_pyobj_callback == false) { // query.results() - // TODO: negative path. py_result is an invalid type and set to NULL. if (py_result) { - int retval = PyList_Append(py_results, py_result); + int retval = + PyList_Append(py_callback_or_list_of_results, py_result); Py_DECREF(py_result); if (retval == -1) { // TODO: should fail, not return true @@ -112,7 +110,8 @@ static bool each_result(const as_val *val, void *udata) } // Invoke Python Callback - py_return = PyObject_Call(py_callback, py_arglist, NULL); + py_return = + PyObject_Call(py_callback_or_list_of_results, py_arglist, NULL); // Release Python Function Arguments Py_DECREF(py_arglist); @@ -154,7 +153,6 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, { // Initialize callback user data LocalData data = {0}; - data.callback = py_callback; data.client = self->client; data.partition_query = 0; @@ -191,12 +189,15 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, } if (!py_callback) { - // TODO: can be optimized by predicting the number of records from server? - data.py_results = PyList_New(0); - if (data.py_results == NULL) { + data.py_obj = PyList_New(0); + if (data.py_obj == NULL) { goto CLEANUP; } } + else { + data.py_obj = py_callback; + } + data.is_pyobj_callback = py_callback != NULL; // Convert python policy object to as_policy_exists pyobject_to_policy_query( @@ -275,16 +276,16 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, if (err.code != AEROSPIKE_OK) { // TODO: results() used raise_exception(); - Py_XDECREF(data.py_results); + Py_XDECREF(data.py_obj); raise_exception_base(&err, Py_None, Py_None, Py_None, Py_None, Py_None); return NULL; } - if (data.py_results) { + if (data.is_pyobj_callback) { Py_RETURN_NONE; } else { - return data.py_results; + return data.py_obj; } } diff --git a/src/main/scan/foreach.c b/src/main/scan/foreach.c index b90c8c759c..2dfd0d7708 100644 --- a/src/main/scan/foreach.c +++ b/src/main/scan/foreach.c @@ -31,7 +31,7 @@ // Struct for Python User-Data for the Callback typedef struct { as_error error; - PyObject *callback; + PyObject *py_obj; AerospikeClient *client; int partition_scan; } LocalData; From 6aae29462f73b486590aa32b142fe3b3b614aded Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 24 Sep 2025 19:47:57 -0700 Subject: [PATCH 004/112] not used --- src/main/query/results.c | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/query/results.c b/src/main/query/results.c index 99a36e0cd4..f4558df512 100644 --- a/src/main/query/results.c +++ b/src/main/query/results.c @@ -41,9 +41,6 @@ PyObject *AerospikeQuery_Results(AerospikeQuery *self, PyObject *args, static char *kwlist[] = {"policy", "options", NULL}; - LocalData data; - data.client = self->client; - if (PyArg_ParseTupleAndKeywords(args, kwds, "|OO:results", kwlist, &py_policy, &py_options) == false) { return NULL; From 45534dad1df70c7be99e5e808bc7333aff871df4 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 24 Sep 2025 19:50:24 -0700 Subject: [PATCH 005/112] rm unused var --- src/main/query/results.c | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/query/results.c b/src/main/query/results.c index f4558df512..a166353d3b 100644 --- a/src/main/query/results.c +++ b/src/main/query/results.c @@ -36,7 +36,6 @@ PyObject *AerospikeQuery_Results(AerospikeQuery *self, PyObject *args, PyObject *kwds) { PyObject *py_policy = NULL; - PyObject *py_results = NULL; PyObject *py_options = NULL; static char *kwlist[] = {"policy", "options", NULL}; From 0d357c5d37a22d26bf9b1b6319f6d9d0017faa21 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 24 Sep 2025 19:54:22 -0700 Subject: [PATCH 006/112] Fix --- src/main/query/results.c | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/query/results.c b/src/main/query/results.c index a166353d3b..656aacc4f1 100644 --- a/src/main/query/results.c +++ b/src/main/query/results.c @@ -46,3 +46,4 @@ PyObject *AerospikeQuery_Results(AerospikeQuery *self, PyObject *args, } return AerospikeQuery_Foreach_Invoke(self, NULL, py_policy, py_options); +} From 45e62c2044570e6e18a839afd4d1fd8c6702e4bd Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 25 Sep 2025 08:40:06 -0700 Subject: [PATCH 007/112] mv --- src/main/scan/foreach.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/scan/foreach.c b/src/main/scan/foreach.c index 2dfd0d7708..b90c8c759c 100644 --- a/src/main/scan/foreach.c +++ b/src/main/scan/foreach.c @@ -31,7 +31,7 @@ // Struct for Python User-Data for the Callback typedef struct { as_error error; - PyObject *py_obj; + PyObject *callback; AerospikeClient *client; int partition_scan; } LocalData; From 83d477391aaf9beeafa5d2e017fb0491141786b9 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:25:29 -0700 Subject: [PATCH 008/112] Implement and test aerospikeERROR_DETAIL_EXP_TRACE. --- .gitmodules | 2 +- aerospike-client-c | 2 +- aerospike-stubs/aerospike.pyi | 1 + doc/aerospike.rst | 5 +++++ src/main/aerospike.c | 1 + test/new_tests/test_exception_subcode.py | 11 ++++++++++- 6 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.gitmodules b/.gitmodules index 136ba68cbe..ce8fed6949 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,4 +2,4 @@ path = aerospike-client-c # url = git@github.com:aerospike/aerospike-client-c.git url = https://github.com/aerospike/aerospike-client-c.git - branch = stage + branch = jnguyen/CLIENT-5211-add-AS_ERROR_DETAIL_KEY_EXP_TRACE diff --git a/aerospike-client-c b/aerospike-client-c index d777cef3d0..adb07714e2 160000 --- a/aerospike-client-c +++ b/aerospike-client-c @@ -1 +1 @@ -Subproject commit d777cef3d0627af11bddd00fd28fe4f970ebca60 +Subproject commit adb07714e2d3a4f8c182447a2b1546f96df31420 diff --git a/aerospike-stubs/aerospike.pyi b/aerospike-stubs/aerospike.pyi index 518475aae4..97f28be2b7 100644 --- a/aerospike-stubs/aerospike.pyi +++ b/aerospike-stubs/aerospike.pyi @@ -330,6 +330,7 @@ EXP_LOOPVAR_INDEX: Literal[2] ERROR_DETAIL_NONE: Literal[0] ERROR_DETAIL_SUBCODE: Literal[1] ERROR_DETAIL_MESSAGE: Literal[2] +ERROR_DETAIL_EXP_TRACE: Literal[3] SUB_NONE: Literal[0] diff --git a/doc/aerospike.rst b/doc/aerospike.rst index 9c288e8b90..5e88eb02fa 100644 --- a/doc/aerospike.rst +++ b/doc/aerospike.rst @@ -2041,6 +2041,11 @@ Set on :ref:`aerospike_base_policies` option ``error_detail_verbosity``. Request subcode and human-readable message from the server on error responses. +.. data:: ERROR_DETAIL_EXP_TRACE + + Request subcode and human-readable message from the server on error responses, + as well as expression build trace if an expression fails to build. + .. _subcodes: Subcodes diff --git a/src/main/aerospike.c b/src/main/aerospike.c index c592105045..e20f4fe2c2 100644 --- a/src/main/aerospike.c +++ b/src/main/aerospike.c @@ -629,6 +629,7 @@ static struct module_constant_name_to_value module_constants[] = { EXPOSE_AS_MACRO_WITHOUT_AS_PREFIX_AS_PUBLIC_FIELD(ERROR_DETAIL_NONE), EXPOSE_AS_MACRO_WITHOUT_AS_PREFIX_AS_PUBLIC_FIELD(ERROR_DETAIL_SUBCODE), EXPOSE_AS_MACRO_WITHOUT_AS_PREFIX_AS_PUBLIC_FIELD(ERROR_DETAIL_MESSAGE), + EXPOSE_AS_MACRO_WITHOUT_AS_PREFIX_AS_PUBLIC_FIELD(ERROR_DETAIL_EXP_TRACE), EXPOSE_AS_MACRO_WITHOUT_AS_PREFIX_AS_PUBLIC_FIELD(ERROR_DETAIL_NONE), EXPOSE_AS_MACRO_WITHOUT_AS_PREFIX_AS_PUBLIC_FIELD(ERROR_DETAIL_SUBCODE), diff --git a/test/new_tests/test_exception_subcode.py b/test/new_tests/test_exception_subcode.py index 9cae295823..3fdc96e019 100644 --- a/test/new_tests/test_exception_subcode.py +++ b/test/new_tests/test_exception_subcode.py @@ -3,6 +3,7 @@ import aerospike from aerospike import exception as e from aerospike_helpers.operations import list_operations as list_ops +from aerospike_helpers import expressions as expr from .test_base_class import TestBaseClass from . import as_errors @@ -78,9 +79,17 @@ def test_error_verbosity_levels(self, policy_w_verbosity_setting: dict, set_in_c SUBCODE_IN_QUOTES = "({}".format(EXPECTED_SUBCODE_IN_MESSAGE) assert SUBCODE_IN_QUOTES in excinfo.value.msg + def test_error_detail_exp_trace(self): + policy = { + ERROR_DETAIL_VERBOSITY_SETTING: aerospike.ERROR_DETAIL_EXP_TRACE, + "expressions": expr.GE(expr.Abs(expr.Val("a")), 1).compile() + } + with pytest.raises(e.InvalidRequest): + self.as_connection.get(KEY, policy=policy) + def test_invalid_verbosity(self): policy = { - ERROR_DETAIL_VERBOSITY_SETTING: 3 + ERROR_DETAIL_VERBOSITY_SETTING: 4 } with pytest.raises(e.ServerError): self.as_connection.operate(KEY, OPS, policy=policy) From 575e6c67d2d1f84f395f8b44216ec0c99d779e17 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Mon, 17 Aug 2026 08:29:37 -0700 Subject: [PATCH 009/112] Pull Andrei's latest changes --- aerospike-client-c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aerospike-client-c b/aerospike-client-c index adb07714e2..fc96dc0b15 160000 --- a/aerospike-client-c +++ b/aerospike-client-c @@ -1 +1 @@ -Subproject commit adb07714e2d3a4f8c182447a2b1546f96df31420 +Subproject commit fc96dc0b1503ec94836d4bdc2b1679b69f89a013 From bbb69e72a50f6d4d5b29049353ef935d6c1e8917 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:17:04 -0700 Subject: [PATCH 010/112] Pull C client for more changes --- aerospike-client-c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aerospike-client-c b/aerospike-client-c index fc96dc0b15..d6850e5b23 160000 --- a/aerospike-client-c +++ b/aerospike-client-c @@ -1 +1 @@ -Subproject commit fc96dc0b1503ec94836d4bdc2b1679b69f89a013 +Subproject commit d6850e5b230d25afda2fa075a929cbf7c226d20b From 40a6425670bb5e15bd4c81e5e7aa43b993195a0e Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:23:56 -0700 Subject: [PATCH 011/112] Implement BatchRecord.subcode and BatchRecord.message. TODO - tests. --- aerospike_helpers/batch/records.py | 6 +++ src/include/conversions.h | 2 + src/main/conversions.c | 71 +++++++++++++++++++++++------- 3 files changed, 63 insertions(+), 16 deletions(-) diff --git a/aerospike_helpers/batch/records.py b/aerospike_helpers/batch/records.py index 5bd9fd3696..57383209a6 100644 --- a/aerospike_helpers/batch/records.py +++ b/aerospike_helpers/batch/records.py @@ -46,6 +46,12 @@ class BatchRecord: key (:obj:`tuple`): The aerospike key to operate on. record (:ref:`aerospike_record_tuple`): The record corresponding to the requested key. result (int): The status code of the command. + error_subcode (int): Server error subcode for this record, or zero when absent. Set only when + result is not ``AEROSPIKE_OK`` and the client requested error details via + :ref:`aerospike_base_policies` ``error_detail_verbosity`` option. + error_message (str): Server error detail message for this record, or :py:obj:`None` when absent. Set only + when result is not ``AEROSPIKE_OK`` and :ref:`aerospike_base_policies` ``error_detail_verbosity`` option + is >= 2. in_doubt (bool): Is it possible that the write command completed even though an error was generated. \ This may be the case when a client error occurs (like timeout) after the command was sent \ to the server. diff --git a/src/include/conversions.h b/src/include/conversions.h index e33f1cdc88..08c26ca972 100644 --- a/src/include/conversions.h +++ b/src/include/conversions.h @@ -47,6 +47,8 @@ #define FIELD_NAME_BATCH_FUNCTION "function" #define FIELD_NAME_BATCH_ARGS "args" #define FIELD_NAME_BATCH_INDOUBT "in_doubt" +#define FIELD_NAME_BATCH_SUBCODE "subcode" +#define FIELD_NAME_BATCH_MESSAGE "message" #define BATCH_TYPE_READ 0 #define BATCH_TYPE_WRITE 1 diff --git a/src/main/conversions.c b/src/main/conversions.c index d02136baa7..22667285ab 100644 --- a/src/main/conversions.c +++ b/src/main/conversions.c @@ -2935,30 +2935,69 @@ as_status as_batch_result_to_BatchRecord(AerospikeClient *self, as_error *err, PyObject_SetAttrString(py_batch_record, FIELD_NAME_BATCH_RESULT, py_res); Py_DECREF(py_res); + PyObject *py_subcode = PyLong_FromUnsignedLong(bres->subcode); + if (!py_subcode) { + as_error_update(err, AEROSPIKE_ERR_CLIENT, + "Failed to convert BatchRecord.subcode"); + return err->code; + } + int retval = PyObject_SetAttrString(py_batch_record, + FIELD_NAME_BATCH_SUBCODE, py_subcode); + Py_DECREF(py_subcode); + if (retval == -1) { + as_error_update(err, AEROSPIKE_ERR_CLIENT, + "Failed to get BatchRecord.subcode"); + return err->code; + } + + if (bres->message) { + PyObject *py_message = PyUnicode_FromString(bres->message); + if (!py_message) { + as_error_update(err, AEROSPIKE_ERR_CLIENT, + "Failed to convert BatchRecord.message"); + } + + retval = PyObject_SetAttrString(py_batch_record, + FIELD_NAME_BATCH_MESSAGE, py_message); + Py_DECREF(py_message); + } + else { + retval = PyObject_SetAttrString(py_batch_record, + FIELD_NAME_BATCH_MESSAGE, Py_None); + } + + if (retval == -1) { + as_error_update(err, AEROSPIKE_ERR_CLIENT, + "Failed to get BatchRecord.message"); + return err->code; + } + PyObject *py_in_doubt = PyBool_FromLong((long)in_doubt); PyObject_SetAttrString(py_batch_record, FIELD_NAME_BATCH_INDOUBT, py_in_doubt); Py_DECREF(py_in_doubt); - if (*result_code == AEROSPIKE_OK) { - PyObject *rec = NULL; - if (!checking_if_records_exist) { - record_to_pyobject(self, err, result_rec, bres->key, &rec); - } - else { - PyObject *py_result_key = NULL; - PyObject *py_result_meta = NULL; + if (*result_code != AEROSPIKE_OK) { + return err->code; + } - key_to_pyobject(err, bres->key, &py_result_key); - metadata_to_pyobject(err, &(bres->record), &py_result_meta); + PyObject *py_rec = NULL; + if (!checking_if_records_exist) { + record_to_pyobject(self, err, result_rec, bres->key, &py_rec); + } + else { + PyObject *py_result_key = NULL; + PyObject *py_result_meta = NULL; - rec = PyTuple_New(2); - PyTuple_SetItem(rec, 0, py_result_key); - PyTuple_SetItem(rec, 1, py_result_meta); - } - PyObject_SetAttrString(py_batch_record, FIELD_NAME_BATCH_RECORD, rec); - Py_DECREF(rec); + key_to_pyobject(err, bres->key, &py_result_key); + metadata_to_pyobject(err, &(bres->record), &py_result_meta); + + py_rec = PyTuple_New(2); + PyTuple_SetItem(py_rec, 0, py_result_key); + PyTuple_SetItem(py_rec, 1, py_result_meta); } + PyObject_SetAttrString(py_batch_record, FIELD_NAME_BATCH_RECORD, py_rec); + Py_DECREF(py_rec); return err->code; } From 9bc2519abc1fb69871b5fa7221431bf11b942900 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:26:17 -0700 Subject: [PATCH 012/112] Address improper indentation causing doc build to fail. --- aerospike_helpers/batch/records.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aerospike_helpers/batch/records.py b/aerospike_helpers/batch/records.py index 57383209a6..f9d0167814 100644 --- a/aerospike_helpers/batch/records.py +++ b/aerospike_helpers/batch/records.py @@ -51,7 +51,7 @@ class BatchRecord: :ref:`aerospike_base_policies` ``error_detail_verbosity`` option. error_message (str): Server error detail message for this record, or :py:obj:`None` when absent. Set only when result is not ``AEROSPIKE_OK`` and :ref:`aerospike_base_policies` ``error_detail_verbosity`` option - is >= 2. + is >= 2. in_doubt (bool): Is it possible that the write command completed even though an error was generated. \ This may be the case when a client error occurs (like timeout) after the command was sent \ to the server. From 3ee483db813cc387a04eb91b7e16d743e95c5e90 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:02:05 -0700 Subject: [PATCH 013/112] Add additional module constants for server subcodes for parity with server master branch. --- aerospike-stubs/aerospike.pyi | 3 ++ doc/aerospike.rst | 18 +++++++++++ src/main/aerospike.c | 6 ++++ test/new_tests/test_exception_subcode.py | 40 ++++++++++++++++++++++++ 4 files changed, 67 insertions(+) diff --git a/aerospike-stubs/aerospike.pyi b/aerospike-stubs/aerospike.pyi index 5dd6640f6c..9a1ee5e380 100644 --- a/aerospike-stubs/aerospike.pyi +++ b/aerospike-stubs/aerospike.pyi @@ -369,6 +369,9 @@ SUB_OPNOT_HLL_CANNOT_REDUCE_MINHASH_BITS: Literal[6] SUB_OPNOT_HLL_CANNOT_FOLD_MINHASH: Literal[7] SUB_OPNOT_HLL_FOLD_INDEX_BITS_TOO_LARGE: Literal[8] SUB_OPNOT_HLL_INTERSECT_MINHASH_MISMATCH: Literal[9] +SUB_OPNOT_STRING_CONVERSION_FAILED: Literal[10] +SUB_OPNOT_STRING_UTF8_INVALID: Literal[11] +SUB_OPNOT_STRING_B64_INVALID: Literal[13] SUB_FILTERED_META: Literal[1] SUB_FILTERED_BINS: Literal[2] diff --git a/doc/aerospike.rst b/doc/aerospike.rst index eb9a974ade..ed8bbfeb18 100644 --- a/doc/aerospike.rst +++ b/doc/aerospike.rst @@ -2268,6 +2268,24 @@ Subcodes paired with :py:exc:`~aerospike.exception.OpNotApplicable` App use: harmonize sketches (fold/strip minhash) before retry. +.. data:: SUB_OPNOT_STRING_CONVERSION_FAILED + + String conversion failed for an :py:exc:`~aerospike.exception.OpNotApplicable` operation path. + + App use: inspect source and requested destination encoding/type. + +.. data:: SUB_OPNOT_STRING_UTF8_INVALID + + Source blob/string is not valid UTF-8 for an :py:exc:`~aerospike.exception.OpNotApplicable` operation path. + + App use: validate or transcode input before retry. + +.. data:: SUB_OPNOT_STRING_B64_INVALID + + Base64 input is malformed for an :py:exc:`~aerospike.exception.OpNotApplicable` string operation. + + App use: validate or sanitize base64 input before retry. + Subcodes paired with :py:exc:`~aerospike.exception.FilteredOut` ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/src/main/aerospike.c b/src/main/aerospike.c index e20f4fe2c2..b47e4d7912 100644 --- a/src/main/aerospike.c +++ b/src/main/aerospike.c @@ -726,6 +726,12 @@ static struct module_constant_name_to_value module_constants[] = { SUB_OPNOT_HLL_FOLD_INDEX_BITS_TOO_LARGE), EXPOSE_AS_MACRO_WITHOUT_AS_PREFIX_AS_PUBLIC_FIELD( SUB_OPNOT_HLL_INTERSECT_MINHASH_MISMATCH), + EXPOSE_AS_MACRO_WITHOUT_AS_PREFIX_AS_PUBLIC_FIELD( + SUB_OPNOT_STRING_CONVERSION_FAILED), + EXPOSE_AS_MACRO_WITHOUT_AS_PREFIX_AS_PUBLIC_FIELD( + SUB_OPNOT_STRING_UTF8_INVALID), + EXPOSE_AS_MACRO_WITHOUT_AS_PREFIX_AS_PUBLIC_FIELD( + SUB_OPNOT_STRING_B64_INVALID), //---------------------------------------------------------------- // Subcodes paired with AEROSPIKE_ERR_FILTERED_OUT diff --git a/test/new_tests/test_exception_subcode.py b/test/new_tests/test_exception_subcode.py index 3fdc96e019..99129db4d9 100644 --- a/test/new_tests/test_exception_subcode.py +++ b/test/new_tests/test_exception_subcode.py @@ -23,6 +23,46 @@ def setup(self, as_connection): yield self.as_connection.remove(KEY) + def test_subcode_constants(self): + # TODO: can't use pytest.mark.parametrize or else setup fixture will run for each + # constant + CONSTANTS = [ + aerospike.SUB_PARAM_TTL_INVALID, + aerospike.SUB_PARAM_BITS_OFFSET_OUT_OF_RANGE, + aerospike.SUB_PARAM_BITS_SIZE_OUT_OF_RANGE, + aerospike.SUB_PARAM_BITS_RESIZE_EXCEEDED, + aerospike.SUB_PARAM_BIN_COUNT_TOO_LARGE, + aerospike.SUB_UNAVAIL_INITIAL_BALANCE_UNRESOLVED, + aerospike.SUB_UNAVAIL_REPLICA_UNAVAILABLE, + aerospike.SUB_UNSUPP_FEAT_MRT_REQUIRES_STRONG_CONSISTENCY, + aerospike.SUB_UNSUPP_FEAT_GENERIC, + aerospike.SUB_BIN_NOT_FOUND_HLL_CANNOT_CREATE_WITH_OP, + aerospike.SUB_BIN_NAME_COUNT_TOO_LARGE, + aerospike.SUB_FORBID_XDR_FILTER_BLOCKED, + aerospike.SUB_FORBID_SET_COUNT_STOP_WRITES, + aerospike.SUB_FORBID_SET_SIZE_STOP_WRITES, + aerospike.SUB_FORBID_CLOCK_SKEW_STOP_WRITES, + aerospike.SUB_FORBID_REPLACE_CONFLICT_RESOLVING, + aerospike.SUB_FORBID_TRUNCATED, + aerospike.SUB_FORBID_MASKING_POLICY_BLOCKED, + aerospike.SUB_FORBID_DURABILITY_VIOLATION, + aerospike.SUB_FORBID_MASKING_ROLE_VIOLATION, + aerospike.SUB_OPNOT_CDT_INDEX_OUT_OF_BOUNDS, + aerospike.SUB_OPNOT_CDT_RANK_OUT_OF_BOUNDS, + aerospike.SUB_OPNOT_CDT_BOUNDED_LIST_OVERFLOW, + aerospike.SUB_OPNOT_HLL_INDEX_BITS_UNSET, + aerospike.SUB_OPNOT_HLL_CANNOT_REDUCE_INDEX_BITS, + aerospike.SUB_OPNOT_HLL_CANNOT_REDUCE_MINHASH_BITS, + aerospike.SUB_OPNOT_HLL_CANNOT_FOLD_MINHASH, + aerospike.SUB_OPNOT_HLL_FOLD_INDEX_BITS_TOO_LARGE, + aerospike.SUB_OPNOT_HLL_INTERSECT_MINHASH_MISMATCH, + aerospike.SUB_OPNOT_STRING_CONVERSION_FAILED, + aerospike.SUB_OPNOT_STRING_UTF8_INVALID, + aerospike.SUB_OPNOT_STRING_B64_INVALID + ] + for constant in CONSTANTS: + assert type(constant) == int + @pytest.mark.parametrize( "policy_w_verbosity_setting", [ From 5cad3fef18f0f232febf57b2bdc58a7719fb761b Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:05:23 -0700 Subject: [PATCH 014/112] Address redundant description since these new constants are already under the OpNotApplicable section. --- doc/aerospike.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/aerospike.rst b/doc/aerospike.rst index ed8bbfeb18..2c0467b856 100644 --- a/doc/aerospike.rst +++ b/doc/aerospike.rst @@ -2270,19 +2270,19 @@ Subcodes paired with :py:exc:`~aerospike.exception.OpNotApplicable` .. data:: SUB_OPNOT_STRING_CONVERSION_FAILED - String conversion failed for an :py:exc:`~aerospike.exception.OpNotApplicable` operation path. + String conversion failed. App use: inspect source and requested destination encoding/type. .. data:: SUB_OPNOT_STRING_UTF8_INVALID - Source blob/string is not valid UTF-8 for an :py:exc:`~aerospike.exception.OpNotApplicable` operation path. + Source blob/string is not valid UTF-8. App use: validate or transcode input before retry. .. data:: SUB_OPNOT_STRING_B64_INVALID - Base64 input is malformed for an :py:exc:`~aerospike.exception.OpNotApplicable` string operation. + Base64 input is malformed. App use: validate or sanitize base64 input before retry. From f9d960a87d86c7a5dc2b6db3130610450006f1e0 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:10:10 -0700 Subject: [PATCH 015/112] Address compiler error on macOS --- src/main/conversions.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/conversions.c b/src/main/conversions.c index 22667285ab..1df41ebd5f 100644 --- a/src/main/conversions.c +++ b/src/main/conversions.c @@ -2950,7 +2950,7 @@ as_status as_batch_result_to_BatchRecord(AerospikeClient *self, as_error *err, return err->code; } - if (bres->message) { + if (strlen(bres->message)) { PyObject *py_message = PyUnicode_FromString(bres->message); if (!py_message) { as_error_update(err, AEROSPIKE_ERR_CLIENT, From 8af57bacad77ed545606ffdb95d56fe4a2d8d081 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:10:34 -0700 Subject: [PATCH 016/112] Fix e2e test for expression tracing --- test/new_tests/test_exception_subcode.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/new_tests/test_exception_subcode.py b/test/new_tests/test_exception_subcode.py index 99129db4d9..6f1125bee9 100644 --- a/test/new_tests/test_exception_subcode.py +++ b/test/new_tests/test_exception_subcode.py @@ -124,9 +124,12 @@ def test_error_detail_exp_trace(self): ERROR_DETAIL_VERBOSITY_SETTING: aerospike.ERROR_DETAIL_EXP_TRACE, "expressions": expr.GE(expr.Abs(expr.Val("a")), 1).compile() } - with pytest.raises(e.InvalidRequest): + with pytest.raises(e.InvalidRequest) as excinfo: self.as_connection.get(KEY, policy=policy) + assert "; exp_trace={" in excinfo.value.msg + print(excinfo.value.msg) + def test_invalid_verbosity(self): policy = { ERROR_DETAIL_VERBOSITY_SETTING: 4 From d0d7217baea83f4dbf5d7966a9a38dc2533929cd Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:12:11 -0700 Subject: [PATCH 017/112] Address spellcheck error. --- doc/spelling_wordlist.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/spelling_wordlist.txt b/doc/spelling_wordlist.txt index e7f910be47..20ae38c9a9 100644 --- a/doc/spelling_wordlist.txt +++ b/doc/spelling_wordlist.txt @@ -113,3 +113,4 @@ MRT ns jittered IPv +transcode From 53a09288920f286f77d3728ed17806933a6c85f8 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:28:35 -0700 Subject: [PATCH 018/112] Address test regression due to lack of server check. --- test/new_tests/test_exception_subcode.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/new_tests/test_exception_subcode.py b/test/new_tests/test_exception_subcode.py index 6f1125bee9..6cfb30b8d5 100644 --- a/test/new_tests/test_exception_subcode.py +++ b/test/new_tests/test_exception_subcode.py @@ -120,6 +120,9 @@ def test_error_verbosity_levels(self, policy_w_verbosity_setting: dict, set_in_c assert SUBCODE_IN_QUOTES in excinfo.value.msg def test_error_detail_exp_trace(self): + if (TestBaseClass.major_ver, TestBaseClass.minor_ver, TestBaseClass.patch_ver) < (8, 1, 3): + pytest.skip("Expression tracing only supported in server 8.1.3 or higher") + policy = { ERROR_DETAIL_VERBOSITY_SETTING: aerospike.ERROR_DETAIL_EXP_TRACE, "expressions": expr.GE(expr.Abs(expr.Val("a")), 1).compile() From 5f82d78bdb7b02c07f75884b0784b9931549db1c Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:32:08 -0700 Subject: [PATCH 019/112] Add e2e test that reads a batch row's subcode and error message detail. --- aerospike_helpers/batch/records.py | 2 ++ test/new_tests/test_exception_subcode.py | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/aerospike_helpers/batch/records.py b/aerospike_helpers/batch/records.py index f9d0167814..301c8bbe2c 100644 --- a/aerospike_helpers/batch/records.py +++ b/aerospike_helpers/batch/records.py @@ -61,6 +61,8 @@ def __init__(self, key: tuple) -> None: self.key = key self.record = None self.result = 0 + self.error_message = None + self.error_subcode = 0 self.in_doubt = False diff --git a/test/new_tests/test_exception_subcode.py b/test/new_tests/test_exception_subcode.py index 6cfb30b8d5..d734a9fd47 100644 --- a/test/new_tests/test_exception_subcode.py +++ b/test/new_tests/test_exception_subcode.py @@ -4,6 +4,7 @@ from aerospike import exception as e from aerospike_helpers.operations import list_operations as list_ops from aerospike_helpers import expressions as expr +from aerospike_helpers.batch.records import BatchRecords, Write from .test_base_class import TestBaseClass from . import as_errors @@ -119,6 +120,17 @@ def test_error_verbosity_levels(self, policy_w_verbosity_setting: dict, set_in_c SUBCODE_IN_QUOTES = "({}".format(EXPECTED_SUBCODE_IN_MESSAGE) assert SUBCODE_IN_QUOTES in excinfo.value.msg + def test_batch_records_return_error_details(self): + brs = BatchRecords( + [ + Write(KEY, ops=OPS) + ] + ) + self.as_connection.batch_write(brs) + for br in brs.batch_records: + assert isinstance(br.error_message, str) + assert br.error_subcode > 0 + def test_error_detail_exp_trace(self): if (TestBaseClass.major_ver, TestBaseClass.minor_ver, TestBaseClass.patch_ver) < (8, 1, 3): pytest.skip("Expression tracing only supported in server 8.1.3 or higher") From b592ce1f3793105847ffb502fe0a588900374e75 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:43:35 -0700 Subject: [PATCH 020/112] Make tests be compatible with server < 8.1.3 --- aerospike_helpers/batch/records.py | 6 ++-- test/new_tests/test_exception_subcode.py | 39 ++++++++++++++---------- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/aerospike_helpers/batch/records.py b/aerospike_helpers/batch/records.py index 301c8bbe2c..b7cfcc28fe 100644 --- a/aerospike_helpers/batch/records.py +++ b/aerospike_helpers/batch/records.py @@ -49,9 +49,9 @@ class BatchRecord: error_subcode (int): Server error subcode for this record, or zero when absent. Set only when result is not ``AEROSPIKE_OK`` and the client requested error details via :ref:`aerospike_base_policies` ``error_detail_verbosity`` option. - error_message (str): Server error detail message for this record, or :py:obj:`None` when absent. Set only - when result is not ``AEROSPIKE_OK`` and :ref:`aerospike_base_policies` ``error_detail_verbosity`` option - is >= 2. + error_message (str | None): Server error detail message for this record, or :py:obj:`None` when absent. Set + only when result is not ``AEROSPIKE_OK`` and :ref:`aerospike_base_policies` ``error_detail_verbosity`` + option is >= 2. in_doubt (bool): Is it possible that the write command completed even though an error was generated. \ This may be the case when a client error occurs (like timeout) after the command was sent \ to the server. diff --git a/test/new_tests/test_exception_subcode.py b/test/new_tests/test_exception_subcode.py index d734a9fd47..fae575f274 100644 --- a/test/new_tests/test_exception_subcode.py +++ b/test/new_tests/test_exception_subcode.py @@ -17,17 +17,9 @@ class TestExceptionSubcode: - # TODO: need to reuse fixture in conftest.py using indirect params to set num of records - @pytest.fixture(autouse=True) - def setup(self, as_connection): - self.as_connection.put(KEY, bins={BIN_NAME: []}) - yield - self.as_connection.remove(KEY) - - def test_subcode_constants(self): - # TODO: can't use pytest.mark.parametrize or else setup fixture will run for each - # constant - CONSTANTS = [ + @pytest.mark.parametrize( + "constant", + [ aerospike.SUB_PARAM_TTL_INVALID, aerospike.SUB_PARAM_BITS_OFFSET_OUT_OF_RANGE, aerospike.SUB_PARAM_BITS_SIZE_OUT_OF_RANGE, @@ -61,8 +53,16 @@ def test_subcode_constants(self): aerospike.SUB_OPNOT_STRING_UTF8_INVALID, aerospike.SUB_OPNOT_STRING_B64_INVALID ] - for constant in CONSTANTS: - assert type(constant) == int + ) + def test_subcode_constants(self, constant): + assert type(constant) == int + + # TODO: need to reuse fixture in conftest.py using indirect params to set num of records + @pytest.fixture() + def setup(self, as_connection): + self.as_connection.put(KEY, bins={BIN_NAME: []}) + yield + self.as_connection.remove(KEY) @pytest.mark.parametrize( "policy_w_verbosity_setting", @@ -77,6 +77,7 @@ def test_subcode_constants(self): "set_in_client_config", [False, True] ) + @pytest.mark.usefixtures("setup") def test_error_verbosity_levels(self, policy_w_verbosity_setting: dict, set_in_client_config: bool): if set_in_client_config: config = { @@ -120,6 +121,7 @@ def test_error_verbosity_levels(self, policy_w_verbosity_setting: dict, set_in_c SUBCODE_IN_QUOTES = "({}".format(EXPECTED_SUBCODE_IN_MESSAGE) assert SUBCODE_IN_QUOTES in excinfo.value.msg + @pytest.mark.usefixtures("setup") def test_batch_records_return_error_details(self): brs = BatchRecords( [ @@ -128,9 +130,14 @@ def test_batch_records_return_error_details(self): ) self.as_connection.batch_write(brs) for br in brs.batch_records: - assert isinstance(br.error_message, str) - assert br.error_subcode > 0 - + if (TestBaseClass.major_ver, TestBaseClass.minor_ver, TestBaseClass.patch_ver) < (8, 1, 3): + assert br.error_message is None + assert br.error_subcode == 0 + else: + assert isinstance(br.error_message, str) + assert br.error_subcode > 0 + + @pytest.mark.usefixtures("setup") def test_error_detail_exp_trace(self): if (TestBaseClass.major_ver, TestBaseClass.minor_ver, TestBaseClass.patch_ver) < (8, 1, 3): pytest.skip("Expression tracing only supported in server 8.1.3 or higher") From 4c1174fbbe4bc5ecf422d249009c0ed4b1e72a22 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Fri, 21 Aug 2026 08:16:20 -0700 Subject: [PATCH 021/112] Add comment for clarity --- src/main/conversions.c | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/conversions.c b/src/main/conversions.c index 1df41ebd5f..ac9dbe0760 100644 --- a/src/main/conversions.c +++ b/src/main/conversions.c @@ -2978,6 +2978,7 @@ as_status as_batch_result_to_BatchRecord(AerospikeClient *self, as_error *err, Py_DECREF(py_in_doubt); if (*result_code != AEROSPIKE_OK) { + // Don't insert record tuple or 2-tuple containing key and meta return err->code; } From 4a39f2096d10080999c2a396e4067d7a8780320c Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:10:32 -0700 Subject: [PATCH 022/112] Address inconsistent naming between C client and Python client error message batch result attribute --- aerospike_helpers/batch/records.py | 4 +- src/include/conversions.h | 5 ++ src/main/client/batch_write.c | 6 +++ src/main/conversions.c | 69 ++++++++++++++---------- test/new_tests/test_exception_subcode.py | 6 +-- 5 files changed, 56 insertions(+), 34 deletions(-) diff --git a/aerospike_helpers/batch/records.py b/aerospike_helpers/batch/records.py index b7cfcc28fe..1ebeceb5c1 100644 --- a/aerospike_helpers/batch/records.py +++ b/aerospike_helpers/batch/records.py @@ -49,7 +49,7 @@ class BatchRecord: error_subcode (int): Server error subcode for this record, or zero when absent. Set only when result is not ``AEROSPIKE_OK`` and the client requested error details via :ref:`aerospike_base_policies` ``error_detail_verbosity`` option. - error_message (str | None): Server error detail message for this record, or :py:obj:`None` when absent. Set + message (str | None): Server error detail message for this record, or :py:obj:`None` when absent. Set only when result is not ``AEROSPIKE_OK`` and :ref:`aerospike_base_policies` ``error_detail_verbosity`` option is >= 2. in_doubt (bool): Is it possible that the write command completed even though an error was generated. \ @@ -61,7 +61,7 @@ def __init__(self, key: tuple) -> None: self.key = key self.record = None self.result = 0 - self.error_message = None + self.message = None self.error_subcode = 0 self.in_doubt = False diff --git a/src/include/conversions.h b/src/include/conversions.h index 08c26ca972..33e3d209e3 100644 --- a/src/include/conversions.h +++ b/src/include/conversions.h @@ -196,6 +196,11 @@ as_status as_batch_result_to_BatchRecord(AerospikeClient *self, as_error *err, PyObject *py_batch_record, bool checking_if_records_exist); +as_status set_error_details_in_py_batch_record(as_error *err, + PyObject *py_batch_record, + uint32_t subcode, + const char *message); + PyObject *create_py_cluster_from_as_cluster(as_error *error_p, struct as_cluster_s *cluster); PyObject *create_py_node_from_as_node(as_error *error_p, diff --git a/src/main/client/batch_write.c b/src/main/client/batch_write.c index 46154e56a6..ef6dfafd46 100644 --- a/src/main/client/batch_write.c +++ b/src/main/client/batch_write.c @@ -499,6 +499,12 @@ static PyObject *AerospikeClient_BatchWriteInvoke(AerospikeClient *self, py_in_doubt); Py_DECREF(py_in_doubt); + set_error_details_in_py_batch_record( + err, py_batch_record, batch_record->subcode, batch_record->message); + if (err->code != AEROSPIKE_OK) { + goto CLEANUP_ON_ERROR; + } + if (*result_code == AEROSPIKE_OK) { PyObject *rec = NULL; diff --git a/src/main/conversions.c b/src/main/conversions.c index ac9dbe0760..a1756a7e27 100644 --- a/src/main/conversions.c +++ b/src/main/conversions.c @@ -2919,42 +2919,30 @@ as_status get_int_from_py_int(as_error *err, PyObject *py_long, return AEROSPIKE_OK; } -// checking_if_records_exist: -// false if we want to get the record metadata and bins -// true if we only care about the record's metadata -as_status as_batch_result_to_BatchRecord(AerospikeClient *self, as_error *err, - as_batch_result *bres, - PyObject *py_batch_record, - bool checking_if_records_exist) +as_status set_error_details_in_py_batch_record(as_error *err, + PyObject *py_batch_record, + uint32_t subcode, + const char *message) { - as_status *result_code = &(bres->result); - as_record *result_rec = &(bres->record); - bool in_doubt = bres->in_doubt; - - PyObject *py_res = PyLong_FromLong((long)*result_code); - PyObject_SetAttrString(py_batch_record, FIELD_NAME_BATCH_RESULT, py_res); - Py_DECREF(py_res); - - PyObject *py_subcode = PyLong_FromUnsignedLong(bres->subcode); + PyObject *py_subcode = PyLong_FromUnsignedLong(subcode); if (!py_subcode) { - as_error_update(err, AEROSPIKE_ERR_CLIENT, - "Failed to convert BatchRecord.subcode"); - return err->code; + return as_error_update(err, AEROSPIKE_ERR_CLIENT, + "Failed to convert BatchRecord.subcode"); } int retval = PyObject_SetAttrString(py_batch_record, FIELD_NAME_BATCH_SUBCODE, py_subcode); Py_DECREF(py_subcode); + if (retval == -1) { - as_error_update(err, AEROSPIKE_ERR_CLIENT, - "Failed to get BatchRecord.subcode"); - return err->code; + return as_error_update(err, AEROSPIKE_ERR_CLIENT, + "Failed to get BatchRecord.subcode"); } - if (strlen(bres->message)) { - PyObject *py_message = PyUnicode_FromString(bres->message); + if (strlen(message)) { + PyObject *py_message = PyUnicode_FromString(message); if (!py_message) { - as_error_update(err, AEROSPIKE_ERR_CLIENT, - "Failed to convert BatchRecord.message"); + return as_error_update(err, AEROSPIKE_ERR_CLIENT, + "Failed to convert BatchRecord.message"); } retval = PyObject_SetAttrString(py_batch_record, @@ -2967,16 +2955,39 @@ as_status as_batch_result_to_BatchRecord(AerospikeClient *self, as_error *err, } if (retval == -1) { - as_error_update(err, AEROSPIKE_ERR_CLIENT, - "Failed to get BatchRecord.message"); - return err->code; + return as_error_update(err, AEROSPIKE_ERR_CLIENT, + "Failed to get BatchRecord.message"); } + return err->code; +} + +// checking_if_records_exist: +// false if we want to get the record metadata and bins +// true if we only care about the record's metadata +as_status as_batch_result_to_BatchRecord(AerospikeClient *self, as_error *err, + as_batch_result *bres, + PyObject *py_batch_record, + bool checking_if_records_exist) +{ + as_status *result_code = &(bres->result); + as_record *result_rec = &(bres->record); + bool in_doubt = bres->in_doubt; + + PyObject *py_res = PyLong_FromLong((long)*result_code); + PyObject_SetAttrString(py_batch_record, FIELD_NAME_BATCH_RESULT, py_res); + Py_DECREF(py_res); PyObject *py_in_doubt = PyBool_FromLong((long)in_doubt); PyObject_SetAttrString(py_batch_record, FIELD_NAME_BATCH_INDOUBT, py_in_doubt); Py_DECREF(py_in_doubt); + set_error_details_in_py_batch_record(err, py_batch_record, bres->subcode, + bres->message); + if (err->code != AEROSPIKE_OK) { + return err->code; + } + if (*result_code != AEROSPIKE_OK) { // Don't insert record tuple or 2-tuple containing key and meta return err->code; diff --git a/test/new_tests/test_exception_subcode.py b/test/new_tests/test_exception_subcode.py index fae575f274..2324afc398 100644 --- a/test/new_tests/test_exception_subcode.py +++ b/test/new_tests/test_exception_subcode.py @@ -128,13 +128,13 @@ def test_batch_records_return_error_details(self): Write(KEY, ops=OPS) ] ) - self.as_connection.batch_write(brs) + self.as_connection.batch_write(brs, policy_batch={ERROR_DETAIL_VERBOSITY_SETTING: aerospike.ERROR_DETAIL_MESSAGE}) for br in brs.batch_records: if (TestBaseClass.major_ver, TestBaseClass.minor_ver, TestBaseClass.patch_ver) < (8, 1, 3): - assert br.error_message is None + assert br.message is None assert br.error_subcode == 0 else: - assert isinstance(br.error_message, str) + assert isinstance(br.message, str) assert br.error_subcode > 0 @pytest.mark.usefixtures("setup") From b721e71c69926d73097d3e970939266cb2cd5d3a Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:16:04 -0700 Subject: [PATCH 023/112] Address additional inconsistent naming for subcode attribute --- aerospike_helpers/batch/records.py | 4 ++-- test/new_tests/test_exception_subcode.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/aerospike_helpers/batch/records.py b/aerospike_helpers/batch/records.py index 1ebeceb5c1..cf2b66353f 100644 --- a/aerospike_helpers/batch/records.py +++ b/aerospike_helpers/batch/records.py @@ -46,7 +46,7 @@ class BatchRecord: key (:obj:`tuple`): The aerospike key to operate on. record (:ref:`aerospike_record_tuple`): The record corresponding to the requested key. result (int): The status code of the command. - error_subcode (int): Server error subcode for this record, or zero when absent. Set only when + subcode (int): Server error subcode for this record, or zero when absent. Set only when result is not ``AEROSPIKE_OK`` and the client requested error details via :ref:`aerospike_base_policies` ``error_detail_verbosity`` option. message (str | None): Server error detail message for this record, or :py:obj:`None` when absent. Set @@ -62,7 +62,7 @@ def __init__(self, key: tuple) -> None: self.record = None self.result = 0 self.message = None - self.error_subcode = 0 + self.subcode = 0 self.in_doubt = False diff --git a/test/new_tests/test_exception_subcode.py b/test/new_tests/test_exception_subcode.py index 2324afc398..902ad51b01 100644 --- a/test/new_tests/test_exception_subcode.py +++ b/test/new_tests/test_exception_subcode.py @@ -132,10 +132,10 @@ def test_batch_records_return_error_details(self): for br in brs.batch_records: if (TestBaseClass.major_ver, TestBaseClass.minor_ver, TestBaseClass.patch_ver) < (8, 1, 3): assert br.message is None - assert br.error_subcode == 0 + assert br.subcode == 0 else: assert isinstance(br.message, str) - assert br.error_subcode > 0 + assert br.subcode > 0 @pytest.mark.usefixtures("setup") def test_error_detail_exp_trace(self): From fa217e1d2026015264e4d628e19fb8430e2e852f Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:21:27 -0700 Subject: [PATCH 024/112] Add test case that tests as_batch_result_to_BatchRecord -> set_error_details_in_py_batch_record code path. --- test/new_tests/test_exception_subcode.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/test/new_tests/test_exception_subcode.py b/test/new_tests/test_exception_subcode.py index 902ad51b01..99f41a4c4a 100644 --- a/test/new_tests/test_exception_subcode.py +++ b/test/new_tests/test_exception_subcode.py @@ -122,7 +122,7 @@ def test_error_verbosity_levels(self, policy_w_verbosity_setting: dict, set_in_c assert SUBCODE_IN_QUOTES in excinfo.value.msg @pytest.mark.usefixtures("setup") - def test_batch_records_return_error_details(self): + def test_batch_write_return_error_details(self): brs = BatchRecords( [ Write(KEY, ops=OPS) @@ -137,6 +137,17 @@ def test_batch_records_return_error_details(self): assert isinstance(br.message, str) assert br.subcode > 0 + @pytest.mark.usefixtures("setup") + def test_batch_operate_return_error_details(self): + brs = self.as_connection.batch_operate([KEY], OPS, policy_batch={ERROR_DETAIL_VERBOSITY_SETTING: aerospike.ERROR_DETAIL_MESSAGE}) + br = brs.batch_records[0] + if (TestBaseClass.major_ver, TestBaseClass.minor_ver, TestBaseClass.patch_ver) < (8, 1, 3): + assert br.message is None + assert br.subcode == 0 + else: + assert isinstance(br.message, str) + assert br.subcode > 0 + @pytest.mark.usefixtures("setup") def test_error_detail_exp_trace(self): if (TestBaseClass.major_ver, TestBaseClass.minor_ver, TestBaseClass.patch_ver) < (8, 1, 3): From 15c98cdcdfc8b84b9ef96c5b9f7128bee4f8a5d9 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:56:26 -0700 Subject: [PATCH 025/112] Update C client to stage with expression tracing support --- .gitmodules | 2 +- aerospike-client-c | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index ce8fed6949..136ba68cbe 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,4 +2,4 @@ path = aerospike-client-c # url = git@github.com:aerospike/aerospike-client-c.git url = https://github.com/aerospike/aerospike-client-c.git - branch = jnguyen/CLIENT-5211-add-AS_ERROR_DETAIL_KEY_EXP_TRACE + branch = stage diff --git a/aerospike-client-c b/aerospike-client-c index d6850e5b23..d7ae53e9d3 160000 --- a/aerospike-client-c +++ b/aerospike-client-c @@ -1 +1 @@ -Subproject commit d6850e5b230d25afda2fa075a929cbf7c226d20b +Subproject commit d7ae53e9d3c82937cbf8e2e4268d547d152567b4 From da71888c22912cda5ac4b3a3d6bcda954eb5c42a Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:10:43 -0700 Subject: [PATCH 026/112] Align documentation with C client --- aerospike_helpers/batch/records.py | 8 ++------ doc/aerospike.rst | 8 ++++++-- doc/exception.rst | 6 +++++- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/aerospike_helpers/batch/records.py b/aerospike_helpers/batch/records.py index cf2b66353f..7afe9c1c42 100644 --- a/aerospike_helpers/batch/records.py +++ b/aerospike_helpers/batch/records.py @@ -46,12 +46,8 @@ class BatchRecord: key (:obj:`tuple`): The aerospike key to operate on. record (:ref:`aerospike_record_tuple`): The record corresponding to the requested key. result (int): The status code of the command. - subcode (int): Server error subcode for this record, or zero when absent. Set only when - result is not ``AEROSPIKE_OK`` and the client requested error details via - :ref:`aerospike_base_policies` ``error_detail_verbosity`` option. - message (str | None): Server error detail message for this record, or :py:obj:`None` when absent. Set - only when result is not ``AEROSPIKE_OK`` and :ref:`aerospike_base_policies` ``error_detail_verbosity`` - option is >= 2. + subcode (int): Server error detail subcode for this record, or zero when absent. + message (str | None): Server error detail message for this record, or :py:obj:`None` when absent. in_doubt (bool): Is it possible that the write command completed even though an error was generated. \ This may be the case when a client error occurs (like timeout) after the command was sent \ to the server. diff --git a/doc/aerospike.rst b/doc/aerospike.rst index 2c0467b856..0adc73983d 100644 --- a/doc/aerospike.rst +++ b/doc/aerospike.rst @@ -2055,7 +2055,11 @@ Set on :ref:`aerospike_base_policies` option ``error_detail_verbosity``. .. data:: ERROR_DETAIL_EXP_TRACE Request subcode and human-readable message from the server on error responses, - as well as expression build trace if an expression fails to build. + as well as expression trace diagnostics appended to :py:attr:`aerospike.exception.AerospikeError.msg` when present. + + Expression trace text is best-effort diagnostic output. It may be truncated + to fit the maximum number of characters for :py:attr:`aerospike.exception.AerospikeError.msg`, may include operand + values, and is not a machine-readable API. .. _subcodes: @@ -2282,7 +2286,7 @@ Subcodes paired with :py:exc:`~aerospike.exception.OpNotApplicable` .. data:: SUB_OPNOT_STRING_B64_INVALID - Base64 input is malformed. + Base64 input is malformed for a string operation. App use: validate or sanitize base64 input before retry. diff --git a/doc/exception.rst b/doc/exception.rst index 110f0387e7..34a888400e 100644 --- a/doc/exception.rst +++ b/doc/exception.rst @@ -47,7 +47,11 @@ Base Class .. py:attribute:: msg - The human-readable error message. + The human-readable error message. When ``error_detail_verbosity`` is :py:data:`aerospike.ERROR_DETAIL_EXP_TRACE` + and the server returns expression trace diagnostics, a bounded, escaped + `; exp_trace={...}` suffix may be appended to this message. + + The maximum string size for this field is 1023 bytes. .. py:attribute:: file From ab900a5d55c69a21a4c5e15671cc907cd93128b1 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:28:52 -0700 Subject: [PATCH 027/112] fix: address segv when as_batch_result/as_batch_base_record.message is NULL --- src/main/conversions.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/conversions.c b/src/main/conversions.c index a1756a7e27..7f7385be42 100644 --- a/src/main/conversions.c +++ b/src/main/conversions.c @@ -2938,7 +2938,7 @@ as_status set_error_details_in_py_batch_record(as_error *err, "Failed to get BatchRecord.subcode"); } - if (strlen(message)) { + if (message) { PyObject *py_message = PyUnicode_FromString(message); if (!py_message) { return as_error_update(err, AEROSPIKE_ERR_CLIENT, From 20f440b8791b37442cdb7f60128ca1fc8084a012 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:43:09 -0700 Subject: [PATCH 028/112] Address test regression now that as_error messages include the node address now. (this isn't really a breaking API change) --- test/new_tests/test_get_put.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/new_tests/test_get_put.py b/test/new_tests/test_get_put.py index 795b81d259..46326087f7 100644 --- a/test/new_tests/test_get_put.py +++ b/test/new_tests/test_get_put.py @@ -726,7 +726,7 @@ def test_neg_put_with_policy_gen_GT_lesser(self): with pytest.raises(e.RecordGenerationError) as excinfo: self.as_connection.put(key, rec, meta, policy) assert excinfo.value.code == 3 - assert excinfo.value.msg == "AEROSPIKE_ERR_RECORD_GENERATION" + assert "AEROSPIKE_ERR_RECORD_GENERATION" in excinfo.value.msg (key, meta, bins) = self.as_connection.get(key) assert {"name": "John"} == bins From a3f11232434dabeb5a538ec70cad7e62ea2fae80 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:10:52 -0700 Subject: [PATCH 029/112] fix: address test regression where subcode is no longer included in error message due to being its own field --- test/new_tests/test_exception_subcode.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/test/new_tests/test_exception_subcode.py b/test/new_tests/test_exception_subcode.py index 99f41a4c4a..9930aed682 100644 --- a/test/new_tests/test_exception_subcode.py +++ b/test/new_tests/test_exception_subcode.py @@ -111,16 +111,6 @@ def test_error_verbosity_levels(self, policy_w_verbosity_setting: dict, set_in_c else: assert excinfo.value.subcode > 0 - EXPECTED_SUBCODE_IN_MESSAGE = "subcode=" - if excinfo.value.subcode == 0: - assert EXPECTED_SUBCODE_IN_MESSAGE not in excinfo.value.msg - elif policy_w_verbosity_setting[ERROR_DETAIL_VERBOSITY_SETTING] == aerospike.ERROR_DETAIL_SUBCODE: - assert EXPECTED_SUBCODE_IN_MESSAGE in excinfo.value.msg - else: - # There should be a message before the subcode - SUBCODE_IN_QUOTES = "({}".format(EXPECTED_SUBCODE_IN_MESSAGE) - assert SUBCODE_IN_QUOTES in excinfo.value.msg - @pytest.mark.usefixtures("setup") def test_batch_write_return_error_details(self): brs = BatchRecords( From f79ea1de8beebd67cf8c82d893831755fe389d8b Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:06:54 -0700 Subject: [PATCH 030/112] refactor: remove redundant bool flag in udata passed to C client's foreground query method. --- src/main/query/foreach.c | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/main/query/foreach.c b/src/main/query/foreach.c index 73c225d22a..393a45aae5 100644 --- a/src/main/query/foreach.c +++ b/src/main/query/foreach.c @@ -38,8 +38,6 @@ typedef struct { int partition_query; as_vector thread_errors; pthread_mutex_t thread_errors_mutex; - // If false, it is a python list - bool is_pyobj_callback; } LocalData; static bool each_result(const as_val *val, void *udata) @@ -74,7 +72,7 @@ static bool each_result(const as_val *val, void *udata) goto EXIT_CALLBACK; } - if (data->is_pyobj_callback == false) { + if (PyList_Check(py_callback_or_list_of_results)) { // query.results() if (py_result) { int retval = @@ -196,7 +194,6 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, else { data.py_obj = py_callback; } - data.is_pyobj_callback = py_callback != NULL; // Convert python policy object to as_policy_exists pyobject_to_policy_query( @@ -280,7 +277,7 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, return NULL; } - if (data.is_pyobj_callback) { + if (!py_callback) { Py_RETURN_NONE; } else { From e103f9b6632f8f5d7bd2dbd5a2ea6faccaec68bc Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:29:51 -0700 Subject: [PATCH 031/112] fix: address segv because of borrowed reference being free'd. --- src/main/query/foreach.c | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/query/foreach.c b/src/main/query/foreach.c index 393a45aae5..dfe8df259c 100644 --- a/src/main/query/foreach.c +++ b/src/main/query/foreach.c @@ -271,8 +271,11 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, pthread_mutex_destroy(&data.thread_errors_mutex); if (err.code != AEROSPIKE_OK) { + if (PyList_Check(data.py_obj)) { + Py_XDECREF(data.py_obj); + } + // TODO: results() used raise_exception(); - Py_XDECREF(data.py_obj); raise_exception_base(&err, Py_None, Py_None, Py_None, Py_None, Py_None); return NULL; } From 0d38e1d020a561b5363ee9f22a7c8066eb0e2d98 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:41:01 -0700 Subject: [PATCH 032/112] fix: address faulty boolean logic; query.foreach() returns None and query.results() returns a Python list that was passed to the callback --- src/main/query/foreach.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/query/foreach.c b/src/main/query/foreach.c index dfe8df259c..6bcc955361 100644 --- a/src/main/query/foreach.c +++ b/src/main/query/foreach.c @@ -280,7 +280,7 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, return NULL; } - if (!py_callback) { + if (py_callback) { Py_RETURN_NONE; } else { From e4448700a0151560d0010ceddeabbd46ca7ae9c0 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:32:17 -0700 Subject: [PATCH 033/112] fix: refactor scan.foreach() to use each_result in order to have each thread store its own local as_error without overriding the error object for other threads or potentially the main thread --- src/main/query/foreach.c | 12 +--- src/main/scan/foreach.c | 147 +++++++++------------------------------ 2 files changed, 35 insertions(+), 124 deletions(-) diff --git a/src/main/query/foreach.c b/src/main/query/foreach.c index 6bcc955361..444cbe3374 100644 --- a/src/main/query/foreach.c +++ b/src/main/query/foreach.c @@ -30,17 +30,9 @@ #include "exceptions.h" #include "query.h" #include "policy.h" +#include "foreach.h" -// Struct for Python User-Data for the Callback -typedef struct { - PyObject *py_obj; - AerospikeClient *client; - int partition_query; - as_vector thread_errors; - pthread_mutex_t thread_errors_mutex; -} LocalData; - -static bool each_result(const as_val *val, void *udata) +bool each_result(const as_val *val, void *udata) { bool retval = true; diff --git a/src/main/scan/foreach.c b/src/main/scan/foreach.c index 2d82eab761..75d29eb05c 100644 --- a/src/main/scan/foreach.c +++ b/src/main/scan/foreach.c @@ -27,98 +27,9 @@ #include "exceptions.h" #include "scan.h" #include "policy.h" +#include "foreach.h" -// Struct for Python User-Data for the Callback -typedef struct { - as_error error; - PyObject *callback; - AerospikeClient *client; - int partition_scan; -} LocalData; - -static bool each_result(const as_val *val, void *udata) -{ - bool rval = true; - - if (!val) { - return false; - } - - uint32_t part_id = 0; - - as_record *rec = as_record_fromval(val); - - if (rec->key.digest.init) { - part_id = - as_partition_getid(rec->key.digest.value, CLUSTER_NPARTITIONS); - } - - // Extract callback user-data - LocalData *data = (LocalData *)udata; - as_error *err = &data->error; - PyObject *py_callback = data->callback; - - // Python Function Arguments and Result Value - PyObject *py_arglist = NULL; - PyObject *py_result = NULL; - PyObject *py_return = NULL; - - // Lock Python State - PyGILState_STATE gstate; - gstate = PyGILState_Ensure(); - - // Convert as_val to a Python Object - val_to_pyobject(data->client, err, val, &py_result); - - if (!py_result) { - PyGILState_Release(gstate); - return true; - } - - if (data->partition_scan) { - // Build Python Function Arguments - py_arglist = PyTuple_New(2); - PyTuple_SetItem(py_arglist, 0, PyLong_FromUnsignedLong(part_id)); - PyTuple_SetItem(py_arglist, 1, py_result); - } - else { - // Build Python Function Arguments - py_arglist = PyTuple_New(1); - PyTuple_SetItem(py_arglist, 0, py_result); - } - // Invoke Python Callback - py_return = PyObject_Call(py_callback, py_arglist, NULL); - - // Release Python Function Arguments - Py_DECREF(py_arglist); - - // handle return value - if (!py_return) { - // an exception was raised, handle it (someday) - // for now, we bail from the loop - as_error_update(err, AEROSPIKE_ERR_CLIENT, - "Callback function raised an exception"); - rval = false; - } - else if (PyBool_Check(py_return)) { - if (Py_False == py_return) { - rval = false; - } - else { - rval = true; - } - Py_DECREF(py_return); - } - else { - rval = true; - Py_DECREF(py_return); - } - - // Release Python State - PyGILState_Release(gstate); - - return rval; -} +extern bool each_result(const as_val *val, void *udata); PyObject *AerospikeScan_Foreach(AerospikeScan *self, PyObject *args, PyObject *kwds) @@ -153,30 +64,34 @@ PyObject *AerospikeScan_Foreach(AerospikeScan *self, PyObject *args, // Create and initialize callback user-data LocalData data; - data.callback = py_callback; + data.py_obj = py_callback; data.client = self->client; - data.partition_scan = 0; + data.partition_query = 0; - as_error_init(&data.error); + as_error err; + as_error_init(&err); + + // Stores errors reported by individual threads when they call the each_result callback + as_vector_init(&data.thread_errors, sizeof(as_error *), 16); + pthread_mutex_init(&data.thread_errors_mutex, NULL); if (!self || !self->client->as) { - as_error_update(&data.error, AEROSPIKE_ERR_PARAM, - "Invalid aerospike object"); + as_error_update(&err, AEROSPIKE_ERR_PARAM, "Invalid aerospike object"); goto CLEANUP; } if (!self->client->is_conn_16) { - as_error_update(&data.error, AEROSPIKE_ERR_CLUSTER, + as_error_update(&err, AEROSPIKE_ERR_CLUSTER, "No connection to aerospike cluster"); goto CLEANUP; } // Convert python policy object to as_policy_exists pyobject_to_policy_scan( - self->client, &data.error, py_policy, &scan_policy, &scan_policy_p, + self->client, &err, py_policy, &scan_policy, &scan_policy_p, &self->client->as->config.policies.scan, &exp_list_p, false); - if (data.error.code != AEROSPIKE_OK) { + if (err.code != AEROSPIKE_OK) { goto CLEANUP; } @@ -186,17 +101,19 @@ PyObject *AerospikeScan_Foreach(AerospikeScan *self, PyObject *args, if (py_partition_filter) { if (convert_partition_filter(self->client, py_partition_filter, &partition_filter, &ps, - &data.error) == AEROSPIKE_OK) { + &err) == AEROSPIKE_OK) { partition_filter_p = &partition_filter; } - data.partition_scan = 1; + data.partition_query = 1; } } - as_error_reset(&data.error); + if (err.code != AEROSPIKE_OK) { + goto CLEANUP; + } if (py_options && PyDict_Check(py_options)) { - set_scan_options(&data.error, &self->scan, py_options); - if (data.error.code != AEROSPIKE_OK) { + set_scan_options(&err, &self->scan, py_options); + if (err.code != AEROSPIKE_OK) { goto CLEANUP; } } @@ -206,7 +123,7 @@ PyObject *AerospikeScan_Foreach(AerospikeScan *self, PyObject *args, nodename = (char *)PyUnicode_AsUTF8(py_nodename); } else { - as_error_update(&data.error, AEROSPIKE_ERR_PARAM, + as_error_update(&err, AEROSPIKE_ERR_PARAM, "nodename must be a string"); goto CLEANUP; } @@ -219,7 +136,7 @@ PyObject *AerospikeScan_Foreach(AerospikeScan *self, PyObject *args, if (ps) { as_partition_filter_set_partitions(partition_filter_p, ps); } - aerospike_scan_partitions(self->client->as, &data.error, scan_policy_p, + aerospike_scan_partitions(self->client->as, &err, scan_policy_p, &self->scan, partition_filter_p, each_result, &data); if (ps) { @@ -227,29 +144,31 @@ PyObject *AerospikeScan_Foreach(AerospikeScan *self, PyObject *args, } } else if (nodename) { - aerospike_scan_node(self->client->as, &data.error, scan_policy_p, - &self->scan, nodename, each_result, &data); + aerospike_scan_node(self->client->as, &err, scan_policy_p, &self->scan, + nodename, each_result, &data); } else { - aerospike_scan_foreach(self->client->as, &data.error, scan_policy_p, + aerospike_scan_foreach(self->client->as, &err, scan_policy_p, &self->scan, each_result, &data); } // We are done using multiple threads Py_END_ALLOW_THREADS - if (data.error.code != AEROSPIKE_OK) { - goto CLEANUP; + // Promote any thread-level error if the main error was not set + if (err.code == AEROSPIKE_OK && data.thread_errors.size > 0) { + as_error *vector_item = + (as_error *)as_vector_get_ptr(&data.thread_errors, 0); + as_error_copy(&err, vector_item); } CLEANUP: if (exp_list_p) { as_exp_destroy(exp_list_p); - ; } - if (data.error.code != AEROSPIKE_OK) { - raise_exception(&data.error); + if (err.code != AEROSPIKE_OK) { + raise_exception(&err); return NULL; } From 31d43631d3a47410b4beb93b3904bb57ab1cdff4 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:57:10 -0700 Subject: [PATCH 034/112] fix: refactor scan.results() to use the same implementation as scan.foreach() in order to have each thread store its own local as_error without overriding the error object for other threads or potentially the main thread --- src/include/scan.h | 6 ++ src/main/scan/foreach.c | 44 +++++++++---- src/main/scan/results.c | 141 +--------------------------------------- 3 files changed, 41 insertions(+), 150 deletions(-) diff --git a/src/include/scan.h b/src/include/scan.h index 5e25908204..86e735b447 100644 --- a/src/include/scan.h +++ b/src/include/scan.h @@ -132,3 +132,9 @@ PyObject *AerospikeScan_Get_Partitions_status(AerospikeScan *self); AerospikeScan *AerospikeScan_Type_New(PyTypeObject *type, AerospikeClient *client); + +PyObject *AerospikeScan_Foreach_Invoke(AerospikeScan *self, + PyObject *py_callback, + PyObject *py_policy, + PyObject *py_options, + PyObject *py_nodename); diff --git a/src/main/scan/foreach.c b/src/main/scan/foreach.c index 75d29eb05c..57cf78a5cf 100644 --- a/src/main/scan/foreach.c +++ b/src/main/scan/foreach.c @@ -34,12 +34,32 @@ extern bool each_result(const as_val *val, void *udata); PyObject *AerospikeScan_Foreach(AerospikeScan *self, PyObject *args, PyObject *kwds) { + // Python Function Keyword Arguments + static char *kwlist[] = {"callback", "policy", "options", "nodename", NULL}; + // Python Function Arguments PyObject *py_callback = NULL; PyObject *py_policy = NULL; PyObject *py_options = NULL; PyObject *py_nodename = NULL; + // Python Function Argument Parsing + if (PyArg_ParseTupleAndKeywords(args, kwds, "O|OOO:foreach", kwlist, + &py_callback, &py_policy, &py_options, + &py_nodename) == false) { + return NULL; + } + + return AerospikeScan_Foreach_Invoke(self, py_callback, py_policy, + py_options, py_nodename); +} + +PyObject *AerospikeScan_Foreach_Invoke(AerospikeScan *self, + PyObject *py_callback, + PyObject *py_policy, + PyObject *py_options, + PyObject *py_nodename) +{ char *nodename = NULL; as_policy_scan scan_policy; @@ -52,19 +72,17 @@ PyObject *AerospikeScan_Foreach(AerospikeScan *self, PyObject *args, as_partition_filter *partition_filter_p = NULL; as_partitions_status *ps = NULL; - // Python Function Keyword Arguments - static char *kwlist[] = {"callback", "policy", "options", "nodename", NULL}; - - // Python Function Argument Parsing - if (PyArg_ParseTupleAndKeywords(args, kwds, "O|OOO:foreach", kwlist, - &py_callback, &py_policy, &py_options, - &py_nodename) == false) { - return NULL; - } - // Create and initialize callback user-data LocalData data; - data.py_obj = py_callback; + if (py_callback) { + data.py_obj = py_callback; + } + else { + data.py_obj = PyList_New(0); + if (data.py_obj == NULL) { + goto CLEANUP; + } + } data.client = self->client; data.partition_query = 0; @@ -168,6 +186,10 @@ PyObject *AerospikeScan_Foreach(AerospikeScan *self, PyObject *args, } if (err.code != AEROSPIKE_OK) { + if (!py_callback) { + // Clear list from results() + Py_DECREF(data.py_obj); + } raise_exception(&err); return NULL; } diff --git a/src/main/scan/results.c b/src/main/scan/results.c index 9113621559..9365e3d2f7 100644 --- a/src/main/scan/results.c +++ b/src/main/scan/results.c @@ -32,156 +32,19 @@ #undef TRACE #define TRACE() -typedef struct { - PyObject *py_results; - AerospikeClient *client; -} LocalData; - -static bool each_result(const as_val *val, void *udata) -{ - if (!val) { - return false; - } - - PyObject *py_results = NULL; - LocalData *data = (LocalData *)udata; - py_results = data->py_results; - PyObject *py_result = NULL; - - as_error err; - - PyGILState_STATE gstate; - gstate = PyGILState_Ensure(); - - val_to_pyobject(data->client, &err, val, &py_result); - - if (py_result) { - PyList_Append(py_results, py_result); - Py_DECREF(py_result); - } - - PyGILState_Release(gstate); - - return true; -} - PyObject *AerospikeScan_Results(AerospikeScan *self, PyObject *args, PyObject *kwds) { PyObject *py_policy = NULL; - PyObject *py_results = NULL; PyObject *py_nodename = NULL; - as_static_pool static_pool; - memset(&static_pool, 0, sizeof(static_pool)); - - as_policy_scan scan_policy; - as_policy_scan *scan_policy_p = NULL; - - char *nodename = NULL; - LocalData data; - data.client = self->client; static char *kwlist[] = {"policy", "nodename", NULL}; - // For converting expressions. - as_exp *exp_list_p = NULL; - - as_partition_filter partition_filter = {0}; - as_partition_filter *partition_filter_p = NULL; - as_partitions_status *ps = NULL; - if (PyArg_ParseTupleAndKeywords(args, kwds, "|OO:results", kwlist, &py_policy, &py_nodename) == false) { return NULL; } - as_error err; - as_error_init(&err); - - if (!self || !self->client->as) { - as_error_update(&err, AEROSPIKE_ERR_PARAM, "Invalid aerospike object"); - goto CLEANUP; - } - if (!self->client->is_conn_16) { - as_error_update(&err, AEROSPIKE_ERR_CLUSTER, - "No connection to aerospike cluster"); - goto CLEANUP; - } - - // Convert python policy object to as_policy_scan - pyobject_to_policy_scan( - self->client, &err, py_policy, &scan_policy, &scan_policy_p, - &self->client->as->config.policies.scan, &exp_list_p, false); - if (err.code != AEROSPIKE_OK) { - as_error_update(&err, err.code, NULL); - goto CLEANUP; - } - - if (py_policy) { - PyObject *py_partition_filter = - PyDict_GetItemString(py_policy, "partition_filter"); - if (py_partition_filter) { - if (convert_partition_filter(self->client, py_partition_filter, - &partition_filter, &ps, - &err) == AEROSPIKE_OK) { - partition_filter_p = &partition_filter; - } - } - } - as_error_reset(&err); - - /* - * If the user specified a nodename, validate and convert it to a char* - */ - if (py_nodename) { - if (PyUnicode_Check(py_nodename)) { - nodename = (char *)PyUnicode_AsUTF8(py_nodename); - } - else { - as_error_update(&err, AEROSPIKE_ERR_PARAM, - "nodename must be a string"); - goto CLEANUP; - } - } - - py_results = PyList_New(0); - data.py_results = py_results; - - Py_BEGIN_ALLOW_THREADS - - if (partition_filter_p) { - if (ps) { - as_partition_filter_set_partitions(partition_filter_p, ps); - } - aerospike_scan_partitions(self->client->as, &err, scan_policy_p, - &self->scan, partition_filter_p, each_result, - &data); - if (ps) { - as_partitions_status_release(ps); - } - } - else if (nodename) { - aerospike_scan_node(self->client->as, &err, scan_policy_p, &self->scan, - nodename, each_result, &data); - } - else { - aerospike_scan_foreach(self->client->as, &err, scan_policy_p, - &self->scan, each_result, &data); - } - - Py_END_ALLOW_THREADS - -CLEANUP: - - if (exp_list_p) { - as_exp_destroy(exp_list_p); - } - - if (err.code != AEROSPIKE_OK) { - Py_XDECREF(py_results); - raise_exception(&err); - return NULL; - } - - return py_results; + return AerospikeScan_Foreach_Invoke(self, NULL, py_policy, NULL, + py_nodename); } From c607078acec345e3d182d385998c76ba38e52332 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:01:39 -0700 Subject: [PATCH 035/112] fix: add required missing header file --- src/include/foreach.h | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/include/foreach.h diff --git a/src/include/foreach.h b/src/include/foreach.h new file mode 100644 index 0000000000..80d6b4c1ec --- /dev/null +++ b/src/include/foreach.h @@ -0,0 +1,11 @@ +#include +#include "client.h" + +// Struct for Python User-Data for the Callback +typedef struct { + PyObject *py_obj; + AerospikeClient *client; + int partition_query; + as_vector thread_errors; + pthread_mutex_t thread_errors_mutex; +} LocalData; From 1d7f1af924ba37a91189e1cbad83533a576a840a Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:20:17 -0700 Subject: [PATCH 036/112] fix: address scan.results() returning the wrong value --- src/main/scan/foreach.c | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/scan/foreach.c b/src/main/scan/foreach.c index 57cf78a5cf..184cf14836 100644 --- a/src/main/scan/foreach.c +++ b/src/main/scan/foreach.c @@ -194,6 +194,11 @@ PyObject *AerospikeScan_Foreach_Invoke(AerospikeScan *self, return NULL; } - Py_INCREF(Py_None); - return Py_None; + if (!py_callback) { + return data.py_obj; + } + else { + Py_INCREF(Py_None); + return Py_None; + } } From 3e911e7b375258598755555da6b71e511fc65939 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:45:31 -0700 Subject: [PATCH 037/112] add additional test cases for batch_write and batch_operate that test the regular batch codepath instead of just the optimized single key batch codepath --- test/new_tests/test_exception_subcode.py | 47 +++++++++++++++++------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/test/new_tests/test_exception_subcode.py b/test/new_tests/test_exception_subcode.py index 9930aed682..850963f6a3 100644 --- a/test/new_tests/test_exception_subcode.py +++ b/test/new_tests/test_exception_subcode.py @@ -10,6 +10,7 @@ KEY = (TEST_NS, TEST_SET, 1) +KEY2 = (TEST_NS, TEST_SET, 2) OPS = [ list_ops.list_get_by_index(BIN_NAME, index=99, return_type=aerospike.LIST_RETURN_VALUE) ] @@ -61,8 +62,9 @@ def test_subcode_constants(self, constant): @pytest.fixture() def setup(self, as_connection): self.as_connection.put(KEY, bins={BIN_NAME: []}) + self.as_connection.put(KEY2, bins={BIN_NAME: []}) yield - self.as_connection.remove(KEY) + self.as_connection.batch_remove(keys=[KEY, KEY2]) @pytest.mark.parametrize( "policy_w_verbosity_setting", @@ -112,31 +114,48 @@ def test_error_verbosity_levels(self, policy_w_verbosity_setting: dict, set_in_c assert excinfo.value.subcode > 0 @pytest.mark.usefixtures("setup") - def test_batch_write_return_error_details(self): - brs = BatchRecords( + @pytest.mark.parametrize( + "brs", + [ [ - Write(KEY, ops=OPS) + Write(KEY, ops=OPS), + ], + [ + Write(KEY, ops=OPS), + Write(KEY2, ops=OPS), ] + ] + ) + def test_batch_write_return_error_details(self, brs): + brs = BatchRecords( + brs ) self.as_connection.batch_write(brs, policy_batch={ERROR_DETAIL_VERBOSITY_SETTING: aerospike.ERROR_DETAIL_MESSAGE}) for br in brs.batch_records: + assert isinstance(br.message, str) if (TestBaseClass.major_ver, TestBaseClass.minor_ver, TestBaseClass.patch_ver) < (8, 1, 3): - assert br.message is None assert br.subcode == 0 else: - assert isinstance(br.message, str) assert br.subcode > 0 @pytest.mark.usefixtures("setup") - def test_batch_operate_return_error_details(self): - brs = self.as_connection.batch_operate([KEY], OPS, policy_batch={ERROR_DETAIL_VERBOSITY_SETTING: aerospike.ERROR_DETAIL_MESSAGE}) - br = brs.batch_records[0] - if (TestBaseClass.major_ver, TestBaseClass.minor_ver, TestBaseClass.patch_ver) < (8, 1, 3): - assert br.message is None - assert br.subcode == 0 - else: + @pytest.mark.parametrize( + "keys", + [ + [KEY], + [KEY, KEY2] + ] + ) + def test_batch_operate_return_error_details(self, keys): + brs = self.as_connection.batch_operate( + keys, OPS, policy_batch={ERROR_DETAIL_VERBOSITY_SETTING: aerospike.ERROR_DETAIL_MESSAGE}) + + for br in brs.batch_records: assert isinstance(br.message, str) - assert br.subcode > 0 + if (TestBaseClass.major_ver, TestBaseClass.minor_ver, TestBaseClass.patch_ver) < (8, 1, 3): + assert br.subcode == 0 + else: + assert br.subcode > 0 @pytest.mark.usefixtures("setup") def test_error_detail_exp_trace(self): From 6c96b75d7ad2d81352385df20a1b95ae08239303 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:48:58 -0700 Subject: [PATCH 038/112] fix: pull C client to address test failures where the regular batch multikey codepath does not set a default error message and leaves it as NULL for server < 8.1.3 --- .gitmodules | 2 +- aerospike-client-c | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index 136ba68cbe..c5c5978ef2 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,4 +2,4 @@ path = aerospike-client-c # url = git@github.com:aerospike/aerospike-client-c.git url = https://github.com/aerospike/aerospike-client-c.git - branch = stage + branch = CLIENT-5211-error-default diff --git a/aerospike-client-c b/aerospike-client-c index d7ae53e9d3..ef2fb38ab0 160000 --- a/aerospike-client-c +++ b/aerospike-client-c @@ -1 +1 @@ -Subproject commit d7ae53e9d3c82937cbf8e2e4268d547d152567b4 +Subproject commit ef2fb38ab0c59677a30fd6c1050c4c3554eeb61e From 728bfe691af5b898d68d285ae708e9408e73c5bf Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:50:53 -0700 Subject: [PATCH 039/112] test: make final revisions to tests. batch_write should take Read BatchRecords for read ops instead of Write. Invalid verbosity level test case was passing because it expected a ServerError which was actually a RecordNotFound, but invalid levels should be clamped between [0..3] --- test/new_tests/test_exception_subcode.py | 29 ++++++++++++------------ 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/test/new_tests/test_exception_subcode.py b/test/new_tests/test_exception_subcode.py index 850963f6a3..e36a3ecf5f 100644 --- a/test/new_tests/test_exception_subcode.py +++ b/test/new_tests/test_exception_subcode.py @@ -2,9 +2,9 @@ from .conftest import TEST_NS, TEST_SET, BIN_NAME import aerospike from aerospike import exception as e -from aerospike_helpers.operations import list_operations as list_ops +from aerospike_helpers.operations import list_operations as list_ops, operations from aerospike_helpers import expressions as expr -from aerospike_helpers.batch.records import BatchRecords, Write +from aerospike_helpers.batch.records import BatchRecords, Read from .test_base_class import TestBaseClass from . import as_errors @@ -118,11 +118,11 @@ def test_error_verbosity_levels(self, policy_w_verbosity_setting: dict, set_in_c "brs", [ [ - Write(KEY, ops=OPS), + Read(KEY, ops=OPS), ], [ - Write(KEY, ops=OPS), - Write(KEY2, ops=OPS), + Read(KEY, ops=OPS), + Read(KEY2, ops=OPS), ] ] ) @@ -158,12 +158,20 @@ def test_batch_operate_return_error_details(self, keys): assert br.subcode > 0 @pytest.mark.usefixtures("setup") - def test_error_detail_exp_trace(self): + @pytest.mark.parametrize( + "verbosity_level", + [ + aerospike.ERROR_DETAIL_EXP_TRACE, + # Test that an invalid verbosity level gets clamped + 4 + ] + ) + def test_error_detail_exp_trace(self, verbosity_level): if (TestBaseClass.major_ver, TestBaseClass.minor_ver, TestBaseClass.patch_ver) < (8, 1, 3): pytest.skip("Expression tracing only supported in server 8.1.3 or higher") policy = { - ERROR_DETAIL_VERBOSITY_SETTING: aerospike.ERROR_DETAIL_EXP_TRACE, + ERROR_DETAIL_VERBOSITY_SETTING: verbosity_level, "expressions": expr.GE(expr.Abs(expr.Val("a")), 1).compile() } with pytest.raises(e.InvalidRequest) as excinfo: @@ -171,10 +179,3 @@ def test_error_detail_exp_trace(self): assert "; exp_trace={" in excinfo.value.msg print(excinfo.value.msg) - - def test_invalid_verbosity(self): - policy = { - ERROR_DETAIL_VERBOSITY_SETTING: 4 - } - with pytest.raises(e.ServerError): - self.as_connection.operate(KEY, OPS, policy=policy) From 5886e2f4a8371ea243af61298d5b63b4483bdd5f Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 26 Aug 2026 07:59:50 -0700 Subject: [PATCH 040/112] docs(doctest): address expected output regression due to error messages now containing the node address --- doc/client.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/client.rst b/doc/client.rst index b833f3e17b..7ee017ac05 100755 --- a/doc/client.rst +++ b/doc/client.rst @@ -477,7 +477,7 @@ Record Commands .. testoutput:: - Error: AEROSPIKE_ERR_RECORD_GENERATION [3] + Error: 127.0.0.1:3000 AEROSPIKE_ERR_RECORD_GENERATION [3] .. method:: remove_bin(key, list[, meta: dict[, policy: dict]]) From 9bb46c2caf3f18ed5e01fb64076a2abfeed18028 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:01:43 -0700 Subject: [PATCH 041/112] Revert "docs(doctest): address expected output regression due to error messages now containing the node address" This reverts commit 5886e2f4a8371ea243af61298d5b63b4483bdd5f. --- doc/client.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/client.rst b/doc/client.rst index 7ee017ac05..b833f3e17b 100755 --- a/doc/client.rst +++ b/doc/client.rst @@ -477,7 +477,7 @@ Record Commands .. testoutput:: - Error: 127.0.0.1:3000 AEROSPIKE_ERR_RECORD_GENERATION [3] + Error: AEROSPIKE_ERR_RECORD_GENERATION [3] .. method:: remove_bin(key, list[, meta: dict[, policy: dict]]) From 01576c83881551ec593f7051b5fd5c9327881e77 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 26 Aug 2026 07:59:50 -0700 Subject: [PATCH 042/112] docs(doctest): address expected output regression due to error messages now containing the node address --- doc/client.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/client.rst b/doc/client.rst index b833f3e17b..7ee017ac05 100755 --- a/doc/client.rst +++ b/doc/client.rst @@ -477,7 +477,7 @@ Record Commands .. testoutput:: - Error: AEROSPIKE_ERR_RECORD_GENERATION [3] + Error: 127.0.0.1:3000 AEROSPIKE_ERR_RECORD_GENERATION [3] .. method:: remove_bin(key, list[, meta: dict[, policy: dict]]) From 5d215173d13881a282633ae4e24cedafa44e951d Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:03:55 -0700 Subject: [PATCH 043/112] refactor: add comment to clarify why there is set_error_details_in_py_batch_record --- src/include/conversions.h | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/include/conversions.h b/src/include/conversions.h index 33e3d209e3..c7efa5d55d 100644 --- a/src/include/conversions.h +++ b/src/include/conversions.h @@ -196,6 +196,8 @@ as_status as_batch_result_to_BatchRecord(AerospikeClient *self, as_error *err, PyObject *py_batch_record, bool checking_if_records_exist); +// This is shared for Python client API calls where the C client API returns as_batch_result or as_batch_base_record +// Then it extracts the subcode and detailed error message and sets it in a BatchRecord instance. as_status set_error_details_in_py_batch_record(as_error *err, PyObject *py_batch_record, uint32_t subcode, From 313378c16912ceb2bf69171bba23e7647641b7d0 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:09:07 -0700 Subject: [PATCH 044/112] test: verify that BatchRecord.message is None and BatchRecord.subcode is 0 when batch_write succeeds --- test/new_tests/test_exception_subcode.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/new_tests/test_exception_subcode.py b/test/new_tests/test_exception_subcode.py index e36a3ecf5f..175522e911 100644 --- a/test/new_tests/test_exception_subcode.py +++ b/test/new_tests/test_exception_subcode.py @@ -113,6 +113,19 @@ def test_error_verbosity_levels(self, policy_w_verbosity_setting: dict, set_in_c else: assert excinfo.value.subcode > 0 + def test_batch_record_message_field_is_none_when_batch_succeeds(self): + brs = BatchRecords( + [ + Read(KEY, ops=[ + operations.read(BIN_NAME) + ]) + ] + ) + self.as_connection.batch_write(brs, policy_batch={ERROR_DETAIL_VERBOSITY_SETTING: aerospike.ERROR_DETAIL_MESSAGE}) + for br in brs.batch_records: + assert br.message is None + assert br.subcode == 0 + @pytest.mark.usefixtures("setup") @pytest.mark.parametrize( "brs", From 00eddf7b4a230d4a5f2562efaef8c5b1ff1a8902 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:12:34 -0700 Subject: [PATCH 045/112] test: setup fixture required for this e2e test --- test/new_tests/test_exception_subcode.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/new_tests/test_exception_subcode.py b/test/new_tests/test_exception_subcode.py index 175522e911..794a77f21d 100644 --- a/test/new_tests/test_exception_subcode.py +++ b/test/new_tests/test_exception_subcode.py @@ -113,6 +113,7 @@ def test_error_verbosity_levels(self, policy_w_verbosity_setting: dict, set_in_c else: assert excinfo.value.subcode > 0 + @pytest.mark.usefixtures("setup") def test_batch_record_message_field_is_none_when_batch_succeeds(self): brs = BatchRecords( [ From 1d4a2e5b9f56569c0ec43ea8334ccd7545801cda Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:33:47 -0700 Subject: [PATCH 046/112] test: dynamic config error_detail_verbosity on both reads and writes --- test/dyn_config.yml | 5 +++- test/new_tests/test_exception_subcode.py | 34 +++++++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/test/dyn_config.yml b/test/dyn_config.yml index b6824e5eb3..ba9d35c130 100644 --- a/test/dyn_config.yml +++ b/test/dyn_config.yml @@ -1,9 +1,12 @@ -version: 1.0.0 +version: 1.1.0 dynamic: metrics: enable: true + read: + error_detail_verbosity: 3 write: send_key: true + error_detail_verbosity: 3 batch_write: send_key: true batch_udf: diff --git a/test/new_tests/test_exception_subcode.py b/test/new_tests/test_exception_subcode.py index 794a77f21d..b1b1d1890f 100644 --- a/test/new_tests/test_exception_subcode.py +++ b/test/new_tests/test_exception_subcode.py @@ -1,5 +1,5 @@ import pytest -from .conftest import TEST_NS, TEST_SET, BIN_NAME +from .conftest import TEST_NS, TEST_SET, BIN_NAME, DYN_CONFIG_PATH import aerospike from aerospike import exception as e from aerospike_helpers.operations import list_operations as list_ops, operations @@ -193,3 +193,35 @@ def test_error_detail_exp_trace(self, verbosity_level): assert "; exp_trace={" in excinfo.value.msg print(excinfo.value.msg) + + @pytest.mark.parametrize( + "api_method, kwargs", + [ + ( + aerospike.Client.get, + {"key": KEY} + ), + ( + aerospike.Client.put, + {"key": KEY, "bins": {"a": 1}} + ) + ] + ) + @pytest.mark.usefixtures("setup") + def test_dyn_config(self, api_method, kwargs): + config = TestBaseClass.get_connection_config() + provider = aerospike.ConfigProvider(DYN_CONFIG_PATH) + config["config_provider"] = provider + + client = aerospike.client(config) + + policy = { + "expressions": expr.GE(expr.Abs(expr.Val("a")), 1).compile() + } + with pytest.raises(e.InvalidRequest) as excinfo: + api_method(client, **kwargs, policy=policy) + + assert "; exp_trace={" in excinfo.value.msg + print(excinfo.value.msg) + + client.close() From f559ffa61214a01d29557f7c105045d8994b8772 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:16:50 -0700 Subject: [PATCH 047/112] refactor: minimize overhead for including partition_query flag in udata parameter by downgrading it from a int flag to a bool --- src/include/foreach.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/include/foreach.h b/src/include/foreach.h index 80d6b4c1ec..0134a4aeb5 100644 --- a/src/include/foreach.h +++ b/src/include/foreach.h @@ -5,7 +5,7 @@ typedef struct { PyObject *py_obj; AerospikeClient *client; - int partition_query; as_vector thread_errors; pthread_mutex_t thread_errors_mutex; + bool partition_query; } LocalData; From f4a5d0b37dc59dbd4c8e884bb5cc19f987793ba0 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:21:14 -0700 Subject: [PATCH 048/112] refactor: make code easier to read whether it is called by query.results or query.foreach --- src/main/query/foreach.c | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/query/foreach.c b/src/main/query/foreach.c index 444cbe3374..c68d47ce1a 100644 --- a/src/main/query/foreach.c +++ b/src/main/query/foreach.c @@ -177,7 +177,8 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, goto CLEANUP; } - if (!py_callback) { + bool is_this_query_results = py_callback != NULL; + if (is_this_query_results) { data.py_obj = PyList_New(0); if (data.py_obj == NULL) { goto CLEANUP; @@ -263,20 +264,19 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, pthread_mutex_destroy(&data.thread_errors_mutex); if (err.code != AEROSPIKE_OK) { - if (PyList_Check(data.py_obj)) { + if (is_this_query_results) { Py_XDECREF(data.py_obj); } - // TODO: results() used raise_exception(); - raise_exception_base(&err, Py_None, Py_None, Py_None, Py_None, Py_None); + raise_exception(&err); return NULL; } - if (py_callback) { - Py_RETURN_NONE; + if (is_this_query_results) { + return data.py_obj; } else { - return data.py_obj; + Py_RETURN_NONE; } } From 4b775b4b43cddec1636dc558b17eada06956e896 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:36:19 -0700 Subject: [PATCH 049/112] refactor: also use is_scan_results flag similar to foreground query implementation --- src/main/scan/foreach.c | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/main/scan/foreach.c b/src/main/scan/foreach.c index 184cf14836..25310b1fea 100644 --- a/src/main/scan/foreach.c +++ b/src/main/scan/foreach.c @@ -74,15 +74,16 @@ PyObject *AerospikeScan_Foreach_Invoke(AerospikeScan *self, // Create and initialize callback user-data LocalData data; - if (py_callback) { - data.py_obj = py_callback; - } - else { + bool is_scan_results = py_callback == NULL; + if (is_scan_results) { data.py_obj = PyList_New(0); if (data.py_obj == NULL) { goto CLEANUP; } } + else { + data.py_obj = py_callback; + } data.client = self->client; data.partition_query = 0; @@ -186,7 +187,7 @@ PyObject *AerospikeScan_Foreach_Invoke(AerospikeScan *self, } if (err.code != AEROSPIKE_OK) { - if (!py_callback) { + if (is_scan_results) { // Clear list from results() Py_DECREF(data.py_obj); } @@ -194,7 +195,7 @@ PyObject *AerospikeScan_Foreach_Invoke(AerospikeScan *self, return NULL; } - if (!py_callback) { + if (is_scan_results) { return data.py_obj; } else { From bcf2a2f91d1c05fbaca8f0faecb67a246ddf2e41 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:43:01 -0700 Subject: [PATCH 050/112] refactor: scan.foreach() - move error check after convert_partition_filter in the nested else block --- src/main/scan/foreach.c | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/scan/foreach.c b/src/main/scan/foreach.c index 25310b1fea..853d971c29 100644 --- a/src/main/scan/foreach.c +++ b/src/main/scan/foreach.c @@ -122,13 +122,13 @@ PyObject *AerospikeScan_Foreach_Invoke(AerospikeScan *self, &partition_filter, &ps, &err) == AEROSPIKE_OK) { partition_filter_p = &partition_filter; + data.partition_query = 1; + } + else { + goto CLEANUP; } - data.partition_query = 1; } } - if (err.code != AEROSPIKE_OK) { - goto CLEANUP; - } if (py_options && PyDict_Check(py_options)) { set_scan_options(&err, &self->scan, py_options); From 0373fd3992b92b3e8627b1e5aa06c416911d0c87 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:47:59 -0700 Subject: [PATCH 051/112] fix: for scan.foreach(), add missing cleanup code for callback-level as_error's --- src/main/scan/foreach.c | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/main/scan/foreach.c b/src/main/scan/foreach.c index 853d971c29..6634f9b11d 100644 --- a/src/main/scan/foreach.c +++ b/src/main/scan/foreach.c @@ -74,16 +74,6 @@ PyObject *AerospikeScan_Foreach_Invoke(AerospikeScan *self, // Create and initialize callback user-data LocalData data; - bool is_scan_results = py_callback == NULL; - if (is_scan_results) { - data.py_obj = PyList_New(0); - if (data.py_obj == NULL) { - goto CLEANUP; - } - } - else { - data.py_obj = py_callback; - } data.client = self->client; data.partition_query = 0; @@ -105,6 +95,17 @@ PyObject *AerospikeScan_Foreach_Invoke(AerospikeScan *self, goto CLEANUP; } + bool is_scan_results = py_callback == NULL; + if (is_scan_results) { + data.py_obj = PyList_New(0); + if (data.py_obj == NULL) { + goto CLEANUP; + } + } + else { + data.py_obj = py_callback; + } + // Convert python policy object to as_policy_exists pyobject_to_policy_scan( self->client, &err, py_policy, &scan_policy, &scan_policy_p, @@ -186,6 +187,13 @@ PyObject *AerospikeScan_Foreach_Invoke(AerospikeScan *self, as_exp_destroy(exp_list_p); } + for (uint32_t i = 0; i < data.thread_errors.size; ++i) { + void *err_ptr = as_vector_get_ptr(&data.thread_errors, i); + cf_free(err_ptr); + } + as_vector_destroy(&data.thread_errors); + pthread_mutex_destroy(&data.thread_errors_mutex); + if (err.code != AEROSPIKE_OK) { if (is_scan_results) { // Clear list from results() From 22d17319e97c95bb7f685d9adee35a8fdc8d6c9c Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:53:56 -0700 Subject: [PATCH 052/112] fix: for query,scan.results(), if an item fails to be appended to the results list, fail out instead of silently ignoring it --- src/main/query/foreach.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/query/foreach.c b/src/main/query/foreach.c index c68d47ce1a..7c58ab2861 100644 --- a/src/main/query/foreach.c +++ b/src/main/query/foreach.c @@ -71,7 +71,8 @@ bool each_result(const as_val *val, void *udata) PyList_Append(py_callback_or_list_of_results, py_result); Py_DECREF(py_result); if (retval == -1) { - // TODO: should fail, not return true + as_error_update(&thread_err_local, AEROSPIKE_ERR_CLIENT, + "Failed to append item to results list"); goto EXIT_CALLBACK; } } From 56b703db343d7288c88ef24301771d24b2d69a92 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:00:50 -0700 Subject: [PATCH 053/112] fix: prevent SystemError exception when PyList_New() fails to be created for the results list. Fail out with a more helpful AerospikeError exception (this scenario should rarely happen, though) --- src/main/query/foreach.c | 2 ++ src/main/scan/foreach.c | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/main/query/foreach.c b/src/main/query/foreach.c index 7c58ab2861..ba69228c77 100644 --- a/src/main/query/foreach.c +++ b/src/main/query/foreach.c @@ -182,6 +182,8 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, if (is_this_query_results) { data.py_obj = PyList_New(0); if (data.py_obj == NULL) { + as_error_update(&err, AEROSPIKE_ERR_CLIENT, + "Was unable to construct results list"); goto CLEANUP; } } diff --git a/src/main/scan/foreach.c b/src/main/scan/foreach.c index 6634f9b11d..3c0c709345 100644 --- a/src/main/scan/foreach.c +++ b/src/main/scan/foreach.c @@ -99,6 +99,8 @@ PyObject *AerospikeScan_Foreach_Invoke(AerospikeScan *self, if (is_scan_results) { data.py_obj = PyList_New(0); if (data.py_obj == NULL) { + as_error_update(&err, AEROSPIKE_ERR_CLIENT, + "Was unable to construct results list"); goto CLEANUP; } } From 3bf35438accd2af75b08abc9454ec8a541bb6948 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:10:43 -0700 Subject: [PATCH 054/112] fix: prevent reading an undeclared boolean var --- src/main/query/foreach.c | 9 ++++----- src/main/scan/foreach.c | 3 ++- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/query/foreach.c b/src/main/query/foreach.c index ba69228c77..1751abba96 100644 --- a/src/main/query/foreach.c +++ b/src/main/query/foreach.c @@ -165,7 +165,7 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, as_partition_filter *partition_filter_p = NULL; as_partitions_status *ps = NULL; - // Initialize error + bool is_query_results = py_callback != NULL; if (!self || !self->client->as) { as_error_update(&err, AEROSPIKE_ERR_PARAM, "Invalid aerospike object"); @@ -178,8 +178,7 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, goto CLEANUP; } - bool is_this_query_results = py_callback != NULL; - if (is_this_query_results) { + if (is_query_results) { data.py_obj = PyList_New(0); if (data.py_obj == NULL) { as_error_update(&err, AEROSPIKE_ERR_CLIENT, @@ -267,7 +266,7 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, pthread_mutex_destroy(&data.thread_errors_mutex); if (err.code != AEROSPIKE_OK) { - if (is_this_query_results) { + if (is_query_results) { Py_XDECREF(data.py_obj); } @@ -275,7 +274,7 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, return NULL; } - if (is_this_query_results) { + if (is_query_results) { return data.py_obj; } else { diff --git a/src/main/scan/foreach.c b/src/main/scan/foreach.c index 3c0c709345..e2d70be1ce 100644 --- a/src/main/scan/foreach.c +++ b/src/main/scan/foreach.c @@ -84,6 +84,8 @@ PyObject *AerospikeScan_Foreach_Invoke(AerospikeScan *self, as_vector_init(&data.thread_errors, sizeof(as_error *), 16); pthread_mutex_init(&data.thread_errors_mutex, NULL); + bool is_scan_results = py_callback == NULL; + if (!self || !self->client->as) { as_error_update(&err, AEROSPIKE_ERR_PARAM, "Invalid aerospike object"); goto CLEANUP; @@ -95,7 +97,6 @@ PyObject *AerospikeScan_Foreach_Invoke(AerospikeScan *self, goto CLEANUP; } - bool is_scan_results = py_callback == NULL; if (is_scan_results) { data.py_obj = PyList_New(0); if (data.py_obj == NULL) { From cc9c2e1028d5074aa8cb9aa5aecb1d508eeea66c Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:18:44 -0700 Subject: [PATCH 055/112] fix: address regression/crash with foreground queries --- src/main/query/foreach.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/query/foreach.c b/src/main/query/foreach.c index 1751abba96..e458516f33 100644 --- a/src/main/query/foreach.c +++ b/src/main/query/foreach.c @@ -165,7 +165,7 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, as_partition_filter *partition_filter_p = NULL; as_partitions_status *ps = NULL; - bool is_query_results = py_callback != NULL; + bool is_query_results = py_callback == NULL; if (!self || !self->client->as) { as_error_update(&err, AEROSPIKE_ERR_PARAM, "Invalid aerospike object"); From cdde3347799a3006029851da5161d8583656baa3 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:34:01 -0700 Subject: [PATCH 056/112] fix: update c client to support error_detail_verbosity in dynamic config --- .gitmodules | 2 +- aerospike-client-c | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index c5c5978ef2..136ba68cbe 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,4 +2,4 @@ path = aerospike-client-c # url = git@github.com:aerospike/aerospike-client-c.git url = https://github.com/aerospike/aerospike-client-c.git - branch = CLIENT-5211-error-default + branch = stage diff --git a/aerospike-client-c b/aerospike-client-c index ef2fb38ab0..1d2aa2e922 160000 --- a/aerospike-client-c +++ b/aerospike-client-c @@ -1 +1 @@ -Subproject commit ef2fb38ab0c59677a30fd6c1050c4c3554eeb61e +Subproject commit 1d2aa2e92246812cbc7d6695e04801ad3258a0d3 From d6446e53f9807bd7aae7407ae847f6ca72c38955 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:02:43 -0700 Subject: [PATCH 057/112] tests: increase code coverage as much as possible --- test/new_tests/test_scan.py | 19 +++++++++++++++++++ .../test_scan_get_partitions_status.py | 7 +++++++ 2 files changed, 26 insertions(+) diff --git a/test/new_tests/test_scan.py b/test/new_tests/test_scan.py index e3cf2476f4..d910883e9b 100644 --- a/test/new_tests/test_scan.py +++ b/test/new_tests/test_scan.py @@ -38,6 +38,11 @@ def teardown(): class TestScan(TestBaseClass): + def test_scan_with_missing_required_args(self): + scan_obj = self.as_connection.scan(self.test_ns, self.test_set) + with pytest.raises(TypeError): + scan_obj.foreach() + def test_scan_with_existent_ns_and_set(self): records = [] @@ -484,3 +489,17 @@ def callback(input_tuple): def test_creating_scan_with_class_constructor_fails(self): with pytest.raises(TypeError): aerospike.Scan("test", "demo") + + def test_scan_invalid_options(self): + scan_obj = self.as_connection.scan(self.test_ns, self.test_set) + + def callback(input_tuple): + pass + + with pytest.raises(e.ParamError): + scan_obj.foreach(callback, options={1: False}) + + def test_invalid_nodename(self): + scan_obj = self.as_connection.scan(self.test_ns, self.test_set) + with pytest.raises(e.ParamError): + scan_obj.results(nodename=2) diff --git a/test/new_tests/test_scan_get_partitions_status.py b/test/new_tests/test_scan_get_partitions_status.py index 0b428a7d84..c8efe7ebb5 100644 --- a/test/new_tests/test_scan_get_partitions_status.py +++ b/test/new_tests/test_scan_get_partitions_status.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import pytest +from aerospike import exception as e from .test_base_class import TestBaseClass @@ -89,3 +90,9 @@ def callback(part_id, input_tuple): stats = scan_obj.get_partitions_status() assert stats + + def test_scan_invalid_partition_filter(self): + scan_obj = self.as_connection.scan(self.test_ns, self.test_set) + policy = {"partition_filter": []} + with pytest.raises(e.ParamError): + scan_obj.results(policy) From 707566e40fc52e3142dd2381ba5db3da5e772cfc Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:14:07 -0700 Subject: [PATCH 058/112] test: properly skip dynamic config test case for server versions that doesn't support expression tracing --- test/new_tests/test_exception_subcode.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/new_tests/test_exception_subcode.py b/test/new_tests/test_exception_subcode.py index b1b1d1890f..1ea90cbcfd 100644 --- a/test/new_tests/test_exception_subcode.py +++ b/test/new_tests/test_exception_subcode.py @@ -209,6 +209,9 @@ def test_error_detail_exp_trace(self, verbosity_level): ) @pytest.mark.usefixtures("setup") def test_dyn_config(self, api_method, kwargs): + if (TestBaseClass.major_ver, TestBaseClass.minor_ver, TestBaseClass.patch_ver) < (8, 1, 3): + pytest.skip("Expression tracing only supported in server 8.1.3 or higher") + config = TestBaseClass.get_connection_config() provider = aerospike.ConfigProvider(DYN_CONFIG_PATH) config["config_provider"] = provider From 9d72bd97847e8b82380ccb880ecd0ae0d91dd0ab Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:48:17 -0700 Subject: [PATCH 059/112] feat: implement part of list join operation and expression. TODO - need C client impl --- aerospike_helpers/expressions/list.py | 25 ++++++++++++++++ .../operations/list_operations.py | 29 +++++++++++++++++++ src/main/client/operate.c | 1 + src/main/client/operate_helper.c | 3 ++ src/main/convert_expressions.c | 5 ++++ 5 files changed, 63 insertions(+) diff --git a/aerospike_helpers/expressions/list.py b/aerospike_helpers/expressions/list.py index 4566716ec8..0951f1aa74 100644 --- a/aerospike_helpers/expressions/list.py +++ b/aerospike_helpers/expressions/list.py @@ -1312,3 +1312,28 @@ def __init__( if ctx is not None: self._fixed[_Keys.CTX_KEY] = ctx + + +class ListJoin(_BaseExpr): + """ + Creates an expression that takes in a list of string values and returns a string with all the values concatenated + together. + """ + + _op = aerospike.OP_LIST_JOIN + + def __init__( + self, ctx: "TypeCTX", + bin: "TypeBinName", + ): + """Args: + ctx (TypeCTX): An optional list of nested CDT :mod:`cdt_ctx ` context operation + objects. + bin (TypeBinName): bin expression, such as :class:`~aerospike_helpers.expressions.base.MapBin` or + :class:`~aerospike_helpers.expressions.base.ListBin`. + + :return: Expression. + """ + self._children = (bin if isinstance(bin, _BaseExpr) else ListBin(bin)) + if ctx is not None: + self._fixed[_Keys.CTX_KEY] = ctx diff --git a/aerospike_helpers/operations/list_operations.py b/aerospike_helpers/operations/list_operations.py index 9ba0d1f175..dc4c4e3411 100755 --- a/aerospike_helpers/operations/list_operations.py +++ b/aerospike_helpers/operations/list_operations.py @@ -1166,3 +1166,32 @@ def list_remove_by_value_rank_range_relative( op_dict[CTX_KEY] = ctx return op_dict + + +def list_join( + bin_name: str, ctx: Optional[list] = None +): + """Create a list_join operation. + + Takes in a list of string values and returns a string with all the values concatenated together. + + Args: + bin_name (str): The name of the bin containing the list. + ctx (list): An optional list of nested CDT :class:`cdt_ctx ` context operation + objects. + + Returns: + A dictionary usable in :meth:`~aerospike.Client.operate` and :meth:`~aerospike.Client.operate_ordered`.The + format of the dictionary should be considered an internal detail, and subject to change. + + Note: + This operation requires server version 8.1.3.0 or greater. + """ + op_dict = { + OP_KEY: aerospike.OP_LIST_REMOVE_BY_VALUE_RANK_RANGE_REL, + BIN_KEY: bin_name, + } + if ctx: + op_dict[CTX_KEY] = ctx + + return op_dict diff --git a/src/main/client/operate.c b/src/main/client/operate.c index d71ab87231..e1fe4a3308 100644 --- a/src/main/client/operate.c +++ b/src/main/client/operate.c @@ -219,6 +219,7 @@ static inline bool use_operate_conversion_helper(int op) op == OP_LIST_REMOVE_BY_VALUE_RANGE || op == OP_LIST_SET_ORDER || op == OP_LIST_SORT || op == OP_LIST_REMOVE_BY_VALUE_RANK_RANGE_REL || op == OP_LIST_GET_BY_VALUE_RANK_RANGE_REL || op == OP_LIST_CREATE || + op == OP_LIST_JOIN || (op >= OP_STRING_STRLEN && op <= OP_STRING_TO_STRING) || (op == OP_MAP_REMOVE_BY_KEY_INDEX_RANGE_REL || op == OP_MAP_REMOVE_BY_VALUE_RANK_RANGE_REL || diff --git a/src/main/client/operate_helper.c b/src/main/client/operate_helper.c index 91dd8d5714..bf024168f1 100644 --- a/src/main/client/operate_helper.c +++ b/src/main/client/operate_helper.c @@ -633,6 +633,9 @@ as_status as_operations_add_from_pyobject(AerospikeClient *self, as_error *err, ops, bin, ctx_ref, val1, rank, return_type); } break; + case OP_LIST_JOIN: + // TODO + break; case OP_STRING_STRLEN: success = as_operations_string_strlen(ops, bin, ctx_ref); break; diff --git a/src/main/convert_expressions.c b/src/main/convert_expressions.c index 10d439002f..1d104b76aa 100644 --- a/src/main/convert_expressions.c +++ b/src/main/convert_expressions.c @@ -307,6 +307,8 @@ static as_status get_expr_size(int *size_to_alloc, int *intermediate_exprs_size, EXP_SZ(as_exp_list_remove_by_rank_range_to_end(NULL, 0, NIL, NIL)), [OP_LIST_REMOVE_BY_RANK_RANGE] = EXP_SZ(as_exp_list_remove_by_rank_range(NULL, 0, NIL, NIL, NIL)), + // TODO + [OP_LIST_JOIN] = 0, [OP_MAP_PUT] = EXP_SZ(as_exp_map_put(NULL, NULL, NIL, NIL, NIL)), [OP_MAP_PUT_ITEMS] = EXP_SZ(as_exp_map_put_items(NULL, NULL, NIL, NIL)), [OP_MAP_INCREMENT] = @@ -1160,6 +1162,9 @@ add_expr_macros(AerospikeClient *self, as_static_pool *static_pool, temp_expr->ctx, lval1, NIL, NIL, NIL)); // - 3 for rank, count, bin break; + case OP_LIST_JOIN: + // TODO + break; case OP_MAP_PUT: APPEND_ARRAY(4, as_exp_map_put(temp_expr->ctx, temp_expr->map_policy, From 4cf77d2a013f124ab6a4624f4345186706a4c5a9 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:10:38 -0700 Subject: [PATCH 060/112] doc: clarify that when using NumericType.FLOAT, the input string must contain a decimal point and at least one digit after it or else the numeric type op/expr will return false --- aerospike_helpers/string_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aerospike_helpers/string_helpers.py b/aerospike_helpers/string_helpers.py index 1bd5fae737..18711800ab 100644 --- a/aerospike_helpers/string_helpers.py +++ b/aerospike_helpers/string_helpers.py @@ -70,7 +70,7 @@ class NumericType(IntEnum): ANY = 0 #: Match only integers. INT = 1 - #: Match only floating-point numbers. + #: Match only floating-point numbers. The string must contain a decimal point and at least one digit after it. FLOAT = 2 From 22e5894f92fee0a9adf93e4bc51681c72933c665 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:43:14 -0700 Subject: [PATCH 061/112] fix: update C client to pull latest PRD changes and address string ops test regressions where using a ctx list raises InvalidRequest --- .gitmodules | 2 +- aerospike-client-c | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index 136ba68cbe..b3b7786431 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,4 +2,4 @@ path = aerospike-client-c # url = git@github.com:aerospike/aerospike-client-c.git url = https://github.com/aerospike/aerospike-client-c.git - branch = stage + branch = CLIENT-5275_string-ops-changes diff --git a/aerospike-client-c b/aerospike-client-c index 1d2aa2e922..b57430afe0 160000 --- a/aerospike-client-c +++ b/aerospike-client-c @@ -1 +1 @@ -Subproject commit 1d2aa2e92246812cbc7d6695e04801ad3258a0d3 +Subproject commit b57430afe00e5fd0b9954c835989a93494493dae From 9de3705d43ba96e7619aabd4e8a05d8e3ecbca95 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:48:09 -0700 Subject: [PATCH 062/112] refactor: point c client to stage --- .gitmodules | 2 +- aerospike-client-c | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index b3b7786431..136ba68cbe 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,4 +2,4 @@ path = aerospike-client-c # url = git@github.com:aerospike/aerospike-client-c.git url = https://github.com/aerospike/aerospike-client-c.git - branch = CLIENT-5275_string-ops-changes + branch = stage diff --git a/aerospike-client-c b/aerospike-client-c index b57430afe0..0659f983cc 160000 --- a/aerospike-client-c +++ b/aerospike-client-c @@ -1 +1 @@ -Subproject commit b57430afe00e5fd0b9954c835989a93494493dae +Subproject commit 0659f983cc90d1318374a8942659f7e2218131c3 From 71e8cb29bc2859e1081aff997ff547ea4a658a86 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:03:46 -0700 Subject: [PATCH 063/112] feat: implement list_join and bit_b64_encode expression and operations --- aerospike_helpers/expressions/bitwise.py | 26 ++++++++++++++ aerospike_helpers/expressions/list.py | 13 +++++-- .../operations/bitwise_operations.py | 35 +++++++++++++++++++ .../operations/list_operations.py | 2 +- src/include/policy.h | 8 ++--- src/main/aerospike.c | 5 +-- src/main/client/bit_operate.c | 30 ++++++++++++++++ src/main/client/operate.c | 2 +- src/main/client/operate_helper.c | 2 +- src/main/convert_expressions.c | 27 ++++++++++---- 10 files changed, 131 insertions(+), 19 deletions(-) diff --git a/aerospike_helpers/expressions/bitwise.py b/aerospike_helpers/expressions/bitwise.py index 9e23103f2d..df10cf5c26 100644 --- a/aerospike_helpers/expressions/bitwise.py +++ b/aerospike_helpers/expressions/bitwise.py @@ -722,3 +722,29 @@ def __init__(self, bit_offset: int, bit_size: int, sign: bool, bin: "TypeBinName expr = exp.BitGetInt(8, 8, True, exp.BlobBin("c")).compile() """ self._children = (bit_offset, bit_size, 1 if sign else 0, bin if isinstance(bin, _BaseExpr) else BlobBin(bin)) + + +class BitB64Encode(_BaseExpr): + """ + Create an expression that performs a :py:meth:`~aerospike_helpers.operations.bitwise_operations.bit_b64_encode` + operation. + """ + + _op = aerospike.OP_BIT_B64_ENCODE + + def __init__( + self, + bin: "TypeBinName", + byte_offset: int = 0, + byte_size: int | None = None, + # TODO: missing invert_size param. + ): + """Args: + + :return: String expression. + """ + self._children = ( + byte_offset, + byte_size, + bin if isinstance(bin, _BaseExpr) else BlobBin(bin) + ) diff --git a/aerospike_helpers/expressions/list.py b/aerospike_helpers/expressions/list.py index 0951f1aa74..9c668872e2 100644 --- a/aerospike_helpers/expressions/list.py +++ b/aerospike_helpers/expressions/list.py @@ -1316,24 +1316,31 @@ def __init__( class ListJoin(_BaseExpr): """ - Creates an expression that takes in a list of string values and returns a string with all the values concatenated - together. + Create expression that concatenates the string items of a list and + returns the results as a single string. + + Every item must be a string. An empty list yields an empty string, + and a single-item list yields that item with no separator applied. """ - _op = aerospike.OP_LIST_JOIN + _op = aerospike._OP_LIST_JOIN def __init__( self, ctx: "TypeCTX", bin: "TypeBinName", + separator: str | None = None ): """Args: ctx (TypeCTX): An optional list of nested CDT :mod:`cdt_ctx ` context operation objects. bin (TypeBinName): bin expression, such as :class:`~aerospike_helpers.expressions.base.MapBin` or :class:`~aerospike_helpers.expressions.base.ListBin`. + separator (str | None): If set to a :class:`str`, this will be inserted between consecutive items. + If set to :py:obj:`None`, there will be no separator inserted between items. :return: Expression. """ self._children = (bin if isinstance(bin, _BaseExpr) else ListBin(bin)) + self._fixed = {aerospike._STR_EXP_SEPARATOR_KEY: separator} if ctx is not None: self._fixed[_Keys.CTX_KEY] = ctx diff --git a/aerospike_helpers/operations/bitwise_operations.py b/aerospike_helpers/operations/bitwise_operations.py index bd39f7450e..c8581b90a1 100644 --- a/aerospike_helpers/operations/bitwise_operations.py +++ b/aerospike_helpers/operations/bitwise_operations.py @@ -649,3 +649,38 @@ def bit_xor(bin_name: str, bit_offset, bit_size, value_byte_size, value, policy= VALUE_BYTE_SIZE_KEY: value_byte_size, VALUE_KEY: value, } + + +def bit_b64_encode( + bin_name: str, + byte_offset: int = 0, + byte_size: int | None = None, + invert_size: bool = False, + ctx: list | None = None +): + """ + Create bit "b64 encode" operation that returns the base64 text of ``byte_size`` bytes starting from ``byte_offset``. + + Requires server version 8.1.3 or later. + + Args: + bin_name (str): The name of the bin containing the map. + byte_offset (int): Which byte index to start from. A negative value counts back from the end of the blob. + byte_size (int | None): How many bytes starting from ``byte_offset`` to select. If :py:obj:`None`, selects from + ``byte_offset`` through the end of the blob. + invert_size (bool): When :py:obj:`True`, ``byte_size`` counts back from the + blob end rather than forward from ``byte_offset``, so a ``byte_size`` of 0 means to the + end of the blob. + + Returns: + A dictionary usable in :meth:`~aerospike.Client.operate` or :meth:`~aerospike.Client.operate_ordered`. The + format of the dictionary should be considered an internal detail, and subject to change. + """ + return { + OP_KEY: aerospike._OP_BIT_B64_ENCODE, + BIN_KEY: bin_name, + "byte_offset": byte_offset, + "byte_size": byte_size, + "invert_size": invert_size, + "ctx": ctx + } diff --git a/aerospike_helpers/operations/list_operations.py b/aerospike_helpers/operations/list_operations.py index dc4c4e3411..dc100ac5e2 100755 --- a/aerospike_helpers/operations/list_operations.py +++ b/aerospike_helpers/operations/list_operations.py @@ -1188,7 +1188,7 @@ def list_join( This operation requires server version 8.1.3.0 or greater. """ op_dict = { - OP_KEY: aerospike.OP_LIST_REMOVE_BY_VALUE_RANK_RANGE_REL, + OP_KEY: aerospike._OP_LIST_JOIN, BIN_KEY: bin_name, } if ctx: diff --git a/src/include/policy.h b/src/include/policy.h index 620b9fdcdd..2b7f81bb7d 100644 --- a/src/include/policy.h +++ b/src/include/policy.h @@ -61,7 +61,7 @@ enum Aerospike_send_bool_as_values { X(LIST_REMOVE_BY_REL_RANK_RANGE_TO_END), \ X(LIST_REMOVE_BY_REL_RANK_RANGE), \ X(LIST_REMOVE_BY_INDEX_RANGE_TO_END), \ - X(LIST_REMOVE_BY_RANK_RANGE_TO_END), X(LIST_CREATE) + X(LIST_REMOVE_BY_RANK_RANGE_TO_END), X(LIST_CREATE), X(LIST_JOIN) // clang-format off #define STRING_OP_NAMES \ @@ -103,8 +103,7 @@ enum Aerospike_send_bool_as_values { X(STRING_REGEX_REPLACE), \ X(STRING_APPEND), \ X(STRING_PREPEND), \ - X(STRING_TO_STRING), -// clang-format on + X(STRING_TO_STRING), // clang-format on enum { #define X(op_name) OP_##op_name @@ -179,7 +178,8 @@ enum Aerospike_map_operations { X(BIT_GET), \ X(BIT_COUNT), \ X(BIT_LSCAN), \ - X(BIT_RSCAN) + X(BIT_RSCAN), \ + X(BIT_B64_ENCODE) // clang-format on enum aerospike_bitwise_operations { diff --git a/src/main/aerospike.c b/src/main/aerospike.c index b47e4d7912..4f445ca47c 100644 --- a/src/main/aerospike.c +++ b/src/main/aerospike.c @@ -265,6 +265,7 @@ static struct module_constant_name_to_value module_constants[] = { .value.integer = OP_LIST_REMOVE_BY_INDEX_RANGE_TO_END}, {"OP_LIST_REMOVE_BY_RANK_RANGE_TO_END", .value.integer = OP_LIST_REMOVE_BY_RANK_RANGE_TO_END}, + EXPOSE_MACRO_AS_PRIVATE_FIELD(OP_LIST_JOIN), {"OP_MAP_SET_POLICY", .value.integer = OP_MAP_SET_POLICY}, {"OP_MAP_CREATE", .value.integer = OP_MAP_CREATE}, @@ -623,10 +624,10 @@ static struct module_constant_name_to_value module_constants[] = { EXPOSE_STRING_MACRO_FOR_AEROSPIKE_HELPERS(_STR_EXP_NUMERIC_TYPE_KEY), #define X(op_name) EXPOSE_MACRO_AS_PRIVATE_FIELD(OP_##op_name) - STRING_OP_NAMES + STRING_OP_NAMES X(BIT_B64_ENCODE), #undef X - EXPOSE_AS_MACRO_WITHOUT_AS_PREFIX_AS_PUBLIC_FIELD(ERROR_DETAIL_NONE), + EXPOSE_AS_MACRO_WITHOUT_AS_PREFIX_AS_PUBLIC_FIELD(ERROR_DETAIL_NONE), EXPOSE_AS_MACRO_WITHOUT_AS_PREFIX_AS_PUBLIC_FIELD(ERROR_DETAIL_SUBCODE), EXPOSE_AS_MACRO_WITHOUT_AS_PREFIX_AS_PUBLIC_FIELD(ERROR_DETAIL_MESSAGE), EXPOSE_AS_MACRO_WITHOUT_AS_PREFIX_AS_PUBLIC_FIELD(ERROR_DETAIL_EXP_TRACE), diff --git a/src/main/client/bit_operate.c b/src/main/client/bit_operate.c index 69a64d3c83..c384d78b94 100644 --- a/src/main/client/bit_operate.c +++ b/src/main/client/bit_operate.c @@ -326,6 +326,36 @@ as_status add_new_bit_op(AerospikeClient *self, as_error *err, as_operations_bit_xor(ops, bin, NULL, &bit_policy, bit_offset, bit_size, value_byte_size, uint8_array_value); break; + case OP_BIT_B64_ENCODE: { + bool ctx_in_use = false; + as_cdt_ctx ctx; + if (get_cdt_ctx(self, err, &ctx, op_dict, &ctx_in_use, static_pool, + serializer_type) != AEROSPIKE_OK) { + goto exit; + } + as_cdt_ctx *ctx_ref = (ctx_in_use ? &ctx : NULL); + + int byte_offset = 0; + if (get_int_from_py_dict(err, op_dict, "offset", &byte_offset) != + AEROSPIKE_OK) { + goto exit; + } + + int byte_size = 0; + if (get_int_from_py_dict(err, op_dict, BYTE_SIZE_KEY, &byte_size) != + AEROSPIKE_OK) { + goto exit; + } + + bool invert_size = false; + if (get_bool_from_pyargs(err, "invert_size", op_dict, &invert_size) != + AEROSPIKE_OK) { + goto exit; + } + + success = as_operations_bit_b64_encode_range_invert( + ops, bin, ctx_ref, byte_offset, byte_size, invert_size); + } default: // This should never be possible since we only get here if we know that the operation is valid. as_error_update(err, AEROSPIKE_ERR_PARAM, "Unknown operation"); diff --git a/src/main/client/operate.c b/src/main/client/operate.c index e1fe4a3308..8eec6676d6 100644 --- a/src/main/client/operate.c +++ b/src/main/client/operate.c @@ -230,7 +230,7 @@ static inline bool use_operate_conversion_helper(int op) static inline bool isBitOp(int op) { int bit_start = OP_BIT_RESIZE; - int bit_end = OP_BIT_RSCAN; + int bit_end = OP_BIT_B64_ENCODE; return (op >= bit_start && op <= bit_end); } diff --git a/src/main/client/operate_helper.c b/src/main/client/operate_helper.c index bf024168f1..8969d52737 100644 --- a/src/main/client/operate_helper.c +++ b/src/main/client/operate_helper.c @@ -634,7 +634,7 @@ as_status as_operations_add_from_pyobject(AerospikeClient *self, as_error *err, } break; case OP_LIST_JOIN: - // TODO + success = as_operations_list_join(ops, bin, ctx_ref); break; case OP_STRING_STRLEN: success = as_operations_string_strlen(ops, bin, ctx_ref); diff --git a/src/main/convert_expressions.c b/src/main/convert_expressions.c index 1d104b76aa..e97be3169f 100644 --- a/src/main/convert_expressions.c +++ b/src/main/convert_expressions.c @@ -307,8 +307,7 @@ static as_status get_expr_size(int *size_to_alloc, int *intermediate_exprs_size, EXP_SZ(as_exp_list_remove_by_rank_range_to_end(NULL, 0, NIL, NIL)), [OP_LIST_REMOVE_BY_RANK_RANGE] = EXP_SZ(as_exp_list_remove_by_rank_range(NULL, 0, NIL, NIL, NIL)), - // TODO - [OP_LIST_JOIN] = 0, + [OP_LIST_JOIN] = EXP_SZ(as_exp_list_join(NULL, NIL)), [OP_MAP_PUT] = EXP_SZ(as_exp_map_put(NULL, NULL, NIL, NIL, NIL)), [OP_MAP_PUT_ITEMS] = EXP_SZ(as_exp_map_put_items(NULL, NULL, NIL, NIL)), [OP_MAP_INCREMENT] = @@ -408,6 +407,8 @@ static as_status get_expr_size(int *size_to_alloc, int *intermediate_exprs_size, [OP_BIT_LSCAN] = EXP_SZ(as_exp_bit_lscan(NIL, NIL, NIL, NIL)), [OP_BIT_RSCAN] = EXP_SZ(as_exp_bit_rscan(NIL, NIL, NIL, NIL)), [OP_BIT_GET_INT] = EXP_SZ(as_exp_bit_get_int(NIL, NIL, 0, NIL)), + [OP_BIT_B64_ENCODE] = + EXP_SZ(as_exp_bit_b64_encode_range(NIL, NIL, NIL)), [OP_HLL_INIT] = EXP_SZ(as_exp_hll_init_mh(NULL, 0, 0, NIL)), [OP_HLL_ADD] = EXP_SZ(as_exp_hll_add_mh(NULL, NIL, 0, 0, NIL)), [OP_HLL_GET_COUNT] = EXP_SZ(as_exp_hll_update(NULL, NIL, NIL)), @@ -1162,9 +1163,6 @@ add_expr_macros(AerospikeClient *self, as_static_pool *static_pool, temp_expr->ctx, lval1, NIL, NIL, NIL)); // - 3 for rank, count, bin break; - case OP_LIST_JOIN: - // TODO - break; case OP_MAP_PUT: APPEND_ARRAY(4, as_exp_map_put(temp_expr->ctx, temp_expr->map_policy, @@ -1569,6 +1567,9 @@ add_expr_macros(AerospikeClient *self, as_static_pool *static_pool, case OP_BIT_GET_INT: APPEND_ARRAY(4, as_exp_bit_get_int(NIL, NIL, 0, NIL)); break; + case OP_BIT_B64_ENCODE: + APPEND_ARRAY(3, as_exp_bit_b64_encode_range(NIL, NIL, NIL)); + break; case OP_HLL_INIT: // NOTE: this case covers HLLInit and HLLInitMH. APPEND_ARRAY( 4, @@ -1910,16 +1911,28 @@ add_expr_macros(AerospikeClient *self, as_static_pool *static_pool, case OP_STRING_SPLIT: APPEND_ARRAY(1, as_exp_string_split(NIL)); break; + case OP_LIST_JOIN: case OP_STRING_SPLIT_SEPARATOR: { char *separator = NULL; as_status status = get_str(err, _STR_EXP_SEPARATOR_KEY, temp_expr->pydict, NULL, - &separator, false); + &separator, true); if (status != AEROSPIKE_OK) { return status; } - APPEND_ARRAY(1, as_exp_string_split_separator(separator, NIL)); + if (temp_expr->op == OP_LIST_JOIN) { + if (separator) { + APPEND_ARRAY(1, as_exp_list_join_separator(temp_expr->ctx, + separator, NIL)); + } + else { + APPEND_ARRAY(1, as_exp_list_join(temp_expr->ctx, NIL)); + } + } + else { + APPEND_ARRAY(1, as_exp_string_split_separator(separator, NIL)); + } break; } case OP_STRING_B64_DECODE: From 42ff0b07697f8ec58ea756ffb6c88dac85f34bef Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:13:42 -0700 Subject: [PATCH 064/112] feat: make snip op and expr's end parameter optional --- aerospike_helpers/expressions/string.py | 2 +- .../operations/string_operations.py | 7 +++--- src/main/client/operate_helper.c | 24 +++++++++++++++---- src/main/convert_expressions.c | 15 +++++++++--- 4 files changed, 36 insertions(+), 12 deletions(-) diff --git a/aerospike_helpers/expressions/string.py b/aerospike_helpers/expressions/string.py index b778fcf64f..59af98ecd9 100644 --- a/aerospike_helpers/expressions/string.py +++ b/aerospike_helpers/expressions/string.py @@ -543,7 +543,7 @@ def __init__(self, policy: StringPolicy, values: list[str], bin: "TypeBinName"): class Snip(_WriteOp): _op = aerospike._OP_STRING_SNIP - def __init__(self, policy: StringPolicy, start: int, end: int, bin: "TypeBinName"): + def __init__(self, policy: StringPolicy, start: int, end: int | None, bin: "TypeBinName"): """ Args: diff --git a/aerospike_helpers/operations/string_operations.py b/aerospike_helpers/operations/string_operations.py index 447a721322..bbe0ea1b81 100644 --- a/aerospike_helpers/operations/string_operations.py +++ b/aerospike_helpers/operations/string_operations.py @@ -539,7 +539,7 @@ def concat(bin_name: str, value_list: list[str], policy: StringPolicy | None = N } -def snip(bin_name: str, start: int, end: int, policy: StringPolicy | None = None, ctx: TypeCTX = None): +def snip(bin_name: str, start: int, end: int | None = None, policy: StringPolicy | None = None, ctx: TypeCTX = None): """ Create string ``snip`` operation that removes codepoints from start to end. @@ -549,8 +549,9 @@ def snip(bin_name: str, start: int, end: int, policy: StringPolicy | None = None bin_name: name of string bin. start: First codepoint to remove, inclusive. - end: One past the last codepoint to remove, exclusive. - policy: String policy. + end: One past the last codepoint to remove, exclusive. If :py:obj:`None`, remove from ``start`` to end of + string. + policy: String policy. If end is :py:obj:`None`, ``policy`` is not sent. TODO ctx: Optional path into a string nested inside a list or map. """ return { diff --git a/src/main/client/operate_helper.c b/src/main/client/operate_helper.c index 8969d52737..b776583b0d 100644 --- a/src/main/client/operate_helper.c +++ b/src/main/client/operate_helper.c @@ -311,8 +311,7 @@ as_status as_operations_add_from_pyobject(AerospikeClient *self, as_error *err, int64_t end = 0; switch (operation_code) { - case OP_STRING_SUBSTR_RANGE: - case OP_STRING_SNIP: { + case OP_STRING_SUBSTR_RANGE: { as_status status = get_int64_t(err, "end", op_dict, &end); if (status != AEROSPIKE_OK) { goto CLEANUP_VAL2_ON_ERROR; @@ -713,10 +712,25 @@ as_status as_operations_add_from_pyobject(AerospikeClient *self, as_error *err, success = as_operations_string_concat_list( ops, bin, ctx_ref, &str_policy, (as_list *)val1); break; - case OP_STRING_SNIP: - success = as_operations_string_snip(ops, bin, ctx_ref, &str_policy, - start, end); + case OP_STRING_SNIP: { + int64_t end = 0; + bool end_found = false; + as_status status = + get_optional_int64_t(err, "end", op_dict, &end, &end_found); + if (status != AEROSPIKE_OK) { + goto CLEANUP_VAL2_ON_ERROR; + } + + if (end_found) { + success = as_operations_string_snip(ops, bin, ctx_ref, &str_policy, + start, end); + } + else { + success = as_operations_string_snip_start(ops, bin, ctx_ref, + &str_policy, start); + } break; + } case OP_STRING_REPLACE: success = as_operations_string_replace( ops, bin, ctx_ref, &str_policy, str_attr_value1, str_attr_value2); diff --git a/src/main/convert_expressions.c b/src/main/convert_expressions.c index e97be3169f..928b6c2d6d 100644 --- a/src/main/convert_expressions.c +++ b/src/main/convert_expressions.c @@ -2065,11 +2065,20 @@ add_expr_macros(AerospikeClient *self, as_static_pool *static_pool, &lval1)) { return err->code; } - if (get_int64_t(err, _STR_EXP_END_KEY, temp_expr->pydict, - &lval2)) { + bool end_found = false; + if (get_optional_int64_t(err, _STR_EXP_END_KEY, + temp_expr->pydict, &lval2, + &end_found)) { return err->code; } - APPEND_ARRAY(1, as_exp_string_snip(&policy, lval1, lval2, NIL)); + if (end_found) { + APPEND_ARRAY( + 1, as_exp_string_snip(&policy, lval1, lval2, NIL)); + } + else { + APPEND_ARRAY(1, + as_exp_string_snip_start(&policy, lval1, NIL)); + } break; case OP_STRING_REPLACE: case OP_STRING_REPLACE_ALL: { From 3014dfc9d6637e74c5ae88a794644e3f21d63a3d Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:25:22 -0700 Subject: [PATCH 065/112] feat: expose CREATE_ONLY and UPDATE_ONLY string write flags --- aerospike_helpers/string_helpers.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/aerospike_helpers/string_helpers.py b/aerospike_helpers/string_helpers.py index 18711800ab..27728c1eb6 100644 --- a/aerospike_helpers/string_helpers.py +++ b/aerospike_helpers/string_helpers.py @@ -53,6 +53,29 @@ class WriteFlags(IntEnum): Default. Does not suppress an in-operation execution failure. """ + CREATE_ONLY = 1 + """ + Create new values only. Valid only on: + + - ``insert`` + - ``overwrite`` + - ``concat`` + - ``append`` + - ``prepend`` + - ``pad_start`` + - ``pad_end`` + - ``repeat``. + + Raises :py:exc:`~aerospike.exception.BinExistsError` if the bin already exists. Mutually exclusive with + :py:attr:`~aerospike_helpers.string_helpers.WriteFlags.UPDATE_ONLY`. Invalid with a CDT context path. + """ + + UPDATE_ONLY = 2 + """ + Update existing values only. Mutually exclusive with + :py:attr:`~aerospike_helpers.string_helpers.WriteFlags.CREATE_ONLY`. + """ + NO_FAIL = 4 """ Suppress an operation failure with the bin unchanged. From 2cbb571f3458b66bd48d15aec9b67eff48a883f3 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:32:42 -0700 Subject: [PATCH 066/112] feat: expose additional server subcodes SUB_OPNOT_STRING_REGEX_LIMIT_EXCEEDED and SUB_PARAM_STRING_CTX_MALFORMED --- doc/aerospike.rst | 12 ++++++++++++ src/main/aerospike.c | 4 ++++ 2 files changed, 16 insertions(+) diff --git a/doc/aerospike.rst b/doc/aerospike.rst index 0adc73983d..a1dc731ea2 100644 --- a/doc/aerospike.rst +++ b/doc/aerospike.rst @@ -2104,6 +2104,12 @@ Subcodes paired with :py:exc:`~aerospike.exception.ParamError` App use: prune least-valuable bins and retry. +.. data:: SUB_PARAM_STRING_CTX_MALFORMED + + String op ctx envelope is malformed. + + App use: verify the client emits `[0xFF, ctx_list, [sub_op, args...]]`. + Subcodes paired with :py:exc:`~aerospike.exception.ClusterError` ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -2284,6 +2290,12 @@ Subcodes paired with :py:exc:`~aerospike.exception.OpNotApplicable` App use: validate or transcode input before retry. +.. data:: SUB_OPNOT_STRING_REGEX_LIMIT_EXCEEDED + + Regex pattern exceeded a server limit for an ``OP_NOT_APPLICABLE`` string operation. + + App use: simplify the pattern or reduce input size before retry. + .. data:: SUB_OPNOT_STRING_B64_INVALID Base64 input is malformed for a string operation. diff --git a/src/main/aerospike.c b/src/main/aerospike.c index 4f445ca47c..e28e53829d 100644 --- a/src/main/aerospike.c +++ b/src/main/aerospike.c @@ -651,6 +651,8 @@ static struct module_constant_name_to_value module_constants[] = { SUB_PARAM_BITS_RESIZE_EXCEEDED), EXPOSE_AS_MACRO_WITHOUT_AS_PREFIX_AS_PUBLIC_FIELD( SUB_PARAM_BIN_COUNT_TOO_LARGE), + EXPOSE_AS_MACRO_WITHOUT_AS_PREFIX_AS_PUBLIC_FIELD( + SUB_PARAM_STRING_CTX_MALFORMED), //---------------------------------------------------------------- // Subcodes paired with AEROSPIKE_ERR_CLUSTER (ERR_UNAVAILABLE) @@ -731,6 +733,8 @@ static struct module_constant_name_to_value module_constants[] = { SUB_OPNOT_STRING_CONVERSION_FAILED), EXPOSE_AS_MACRO_WITHOUT_AS_PREFIX_AS_PUBLIC_FIELD( SUB_OPNOT_STRING_UTF8_INVALID), + EXPOSE_AS_MACRO_WITHOUT_AS_PREFIX_AS_PUBLIC_FIELD( + SUB_OPNOT_STRING_REGEX_LIMIT_EXCEEDED), EXPOSE_AS_MACRO_WITHOUT_AS_PREFIX_AS_PUBLIC_FIELD( SUB_OPNOT_STRING_B64_INVALID), From 2639189752b1389b585870081d6318a2060eb468 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:36:57 -0700 Subject: [PATCH 067/112] fix: address test runtime error due to incorrect constant naming in aerospike_helpers --- aerospike_helpers/expressions/bitwise.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aerospike_helpers/expressions/bitwise.py b/aerospike_helpers/expressions/bitwise.py index df10cf5c26..4b331d51ce 100644 --- a/aerospike_helpers/expressions/bitwise.py +++ b/aerospike_helpers/expressions/bitwise.py @@ -730,7 +730,7 @@ class BitB64Encode(_BaseExpr): operation. """ - _op = aerospike.OP_BIT_B64_ENCODE + _op = aerospike._OP_BIT_B64_ENCODE def __init__( self, From e56df0e2532d52febb605ee7ec29153a2e386319 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:21:31 -0700 Subject: [PATCH 068/112] fix: address stubtest errors by adding missing stubs --- aerospike-stubs/aerospike.pyi | 2 ++ 1 file changed, 2 insertions(+) diff --git a/aerospike-stubs/aerospike.pyi b/aerospike-stubs/aerospike.pyi index 9a1ee5e380..3bebb5bc02 100644 --- a/aerospike-stubs/aerospike.pyi +++ b/aerospike-stubs/aerospike.pyi @@ -339,6 +339,7 @@ SUB_PARAM_BITS_OFFSET_OUT_OF_RANGE: Literal[2] SUB_PARAM_BITS_SIZE_OUT_OF_RANGE: Literal[3] SUB_PARAM_BITS_RESIZE_EXCEEDED: Literal[4] SUB_PARAM_BIN_COUNT_TOO_LARGE: Literal[5] +SUB_PARAM_STRING_CTX_MALFORMED: Literal[8] SUB_UNAVAIL_INITIAL_BALANCE_UNRESOLVED: Literal[1] SUB_UNAVAIL_REPLICA_UNAVAILABLE: Literal[2] @@ -371,6 +372,7 @@ SUB_OPNOT_HLL_FOLD_INDEX_BITS_TOO_LARGE: Literal[8] SUB_OPNOT_HLL_INTERSECT_MINHASH_MISMATCH: Literal[9] SUB_OPNOT_STRING_CONVERSION_FAILED: Literal[10] SUB_OPNOT_STRING_UTF8_INVALID: Literal[11] +SUB_OPNOT_STRING_REGEX_LIMIT_EXCEEDED: Literal[12] SUB_OPNOT_STRING_B64_INVALID: Literal[13] SUB_FILTERED_META: Literal[1] From 817d823ea1cb44fbd4d934ef3fb06dd13e831c1e Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:27:18 -0700 Subject: [PATCH 069/112] fix: move Bit64Encode expression's bin parameter to the end to be consistent with the rest of the bitwise exprs --- aerospike_helpers/expressions/bitwise.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/aerospike_helpers/expressions/bitwise.py b/aerospike_helpers/expressions/bitwise.py index 4b331d51ce..182e031d91 100644 --- a/aerospike_helpers/expressions/bitwise.py +++ b/aerospike_helpers/expressions/bitwise.py @@ -734,12 +734,16 @@ class BitB64Encode(_BaseExpr): def __init__( self, + byte_offset: int, + byte_size: int | None, bin: "TypeBinName", - byte_offset: int = 0, - byte_size: int | None = None, # TODO: missing invert_size param. ): - """Args: + """ + Args: + byte_offset (int): Byte offset into the blob. Negative values count from the end. + byte_size (int): Number of bytes to encode. + bin (TypeBinName): A :class:`~aerospike_helpers.expressions.base.BlobBin` expression. :return: String expression. """ From f641b90f412a2add5138cd1b45feb995c1d09e24 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:31:49 -0700 Subject: [PATCH 070/112] fix: move ListJoin expression's bin parameter to the end to be consistent with the rest of the list exprs. Only inverted parameter seems to come after the bin parameter because it is optional, but the other list parameters seem to always come before the bin param --- aerospike_helpers/expressions/list.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aerospike_helpers/expressions/list.py b/aerospike_helpers/expressions/list.py index 9c668872e2..5d54d0ea5a 100644 --- a/aerospike_helpers/expressions/list.py +++ b/aerospike_helpers/expressions/list.py @@ -1327,16 +1327,16 @@ class ListJoin(_BaseExpr): def __init__( self, ctx: "TypeCTX", + separator: str | None, bin: "TypeBinName", - separator: str | None = None ): """Args: ctx (TypeCTX): An optional list of nested CDT :mod:`cdt_ctx ` context operation objects. - bin (TypeBinName): bin expression, such as :class:`~aerospike_helpers.expressions.base.MapBin` or - :class:`~aerospike_helpers.expressions.base.ListBin`. separator (str | None): If set to a :class:`str`, this will be inserted between consecutive items. If set to :py:obj:`None`, there will be no separator inserted between items. + bin (TypeBinName): bin expression, such as :class:`~aerospike_helpers.expressions.base.MapBin` or + :class:`~aerospike_helpers.expressions.base.ListBin`. :return: Expression. """ From ac40213d7db5e992259198f21c3775dcd8beb07e Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:34:45 -0700 Subject: [PATCH 071/112] docs: fix ListJoin docstring for bin parameter. Map bins are not valid --- aerospike_helpers/expressions/list.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aerospike_helpers/expressions/list.py b/aerospike_helpers/expressions/list.py index 5d54d0ea5a..94c031b793 100644 --- a/aerospike_helpers/expressions/list.py +++ b/aerospike_helpers/expressions/list.py @@ -1335,8 +1335,8 @@ def __init__( objects. separator (str | None): If set to a :class:`str`, this will be inserted between consecutive items. If set to :py:obj:`None`, there will be no separator inserted between items. - bin (TypeBinName): bin expression, such as :class:`~aerospike_helpers.expressions.base.MapBin` or - :class:`~aerospike_helpers.expressions.base.ListBin`. + bin (TypeBinName): bin expression, such as :class:`~aerospike_helpers.expressions.base.ListBin` or + an expression that returns a list value. :return: Expression. """ From ff0e9701d4976528fcef45e5ab31392b6996ce93 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:13:48 -0700 Subject: [PATCH 072/112] doc: align Python client with all string ops changes from C client latest commit. Have a separate op code for SNIP_START since as_exp_string_snip and as_exp_string_snip_start differ in how they use policy, and because they take up different amounts of memory --- aerospike_helpers/expressions/string.py | 27 ++++++++++++++----- .../operations/list_operations.py | 11 ++++++-- .../operations/string_operations.py | 17 +++++++----- aerospike_helpers/string_helpers.py | 25 ++++++++++------- src/include/policy.h | 1 + src/main/convert_expressions.c | 3 +++ 6 files changed, 60 insertions(+), 24 deletions(-) diff --git a/aerospike_helpers/expressions/string.py b/aerospike_helpers/expressions/string.py index 59af98ecd9..45d67fd03c 100644 --- a/aerospike_helpers/expressions/string.py +++ b/aerospike_helpers/expressions/string.py @@ -14,9 +14,16 @@ # limitations under the License. ########################################################################## """ -String expressions contain expressions for reading and modifying strings. Most of -these operations are from the standard :mod:`String API `. +String expressions contain expressions for reading and modifying strings. +These expressions mirror the operations from :mod:`String API `. + +Requires server version 8.1.3 or later. + +Unlike operate-level string ops, these macros do not take a `ctx` parameter. To target +a string nested inside a list or map, extract the leaf with +:py:class:`~aerospike_helpers.expressions.list.ListGetByIndex` or `~aerospike_helpers.expressions.map.MapGetByKey` and +pass the result as the operand expression. """ @@ -269,7 +276,7 @@ def __init__(self, numeric_type: NumericType, bin: "TypeBinName"): Returns: - true if the string is a numeric value, false otherwise. + (bool expression) """ self._fixed = { aerospike._STR_EXP_NUMERIC_TYPE_KEY: numeric_type @@ -410,10 +417,11 @@ def __init__(self, bin: "TypeBinName"): bin: A bin expression to apply this function to. If this argument is a string, the bin must contain a string. + Valid operand expressions are INT, FLOAT, STR, BOOL, or BLOB. Returns: - The string in the bin with the value converted to a string. + (String expression) """ self._children = (_convert_bin_name_to_expr(bin),) @@ -541,21 +549,26 @@ def __init__(self, policy: StringPolicy, values: list[str], bin: "TypeBinName"): class Snip(_WriteOp): - _op = aerospike._OP_STRING_SNIP def __init__(self, policy: StringPolicy, start: int, end: int | None, bin: "TypeBinName"): """ Args: - policy: String policy. + policy: String policy. ``policy`` is not sent if ``end`` is :py:obj:`None`. start: First codepoint to remove, inclusive. - end: One past the last codepoint to remove, exclusive. + end: One past the last codepoint to remove, exclusive. If :py:obj:`None`, + then remove from ``start`` through the end of the string. bin: A bin expression to apply this function to. Returns: The string in the bin with the value snipped. """ + if end: + self._op = aerospike._OP_STRING_SNIP + else: + self._op = aerospike._OP_STRING_SNIP_START + super().__init__(policy) self._fixed |= { aerospike._STR_EXP_START_KEY: start, diff --git a/aerospike_helpers/operations/list_operations.py b/aerospike_helpers/operations/list_operations.py index dc100ac5e2..ecf292c8ed 100755 --- a/aerospike_helpers/operations/list_operations.py +++ b/aerospike_helpers/operations/list_operations.py @@ -1169,14 +1169,21 @@ def list_remove_by_value_rank_range_relative( def list_join( - bin_name: str, ctx: Optional[list] = None + bin_name: str, separator: str | None = None, ctx: Optional[list] = None ): """Create a list_join operation. - Takes in a list of string values and returns a string with all the values concatenated together. + Server concatenates the string items of a list and returns the results as a single string. + Every item must be a string; a non-string item returns :py:exc:`~aerospike.exception.InvalidRequest`. + An empty list yields an empty string, and a single-item list yields that item with no separator applied. + This is the inverse of :py:meth:`~aerospike_helpers.operations.string_operations.split_separator`. + + Requires server version 8.1.3 or later. Args: bin_name (str): The name of the bin containing the list. + separator (str | None): If set to a :py:class:`str`, the separator is placed between + consecutive string items. If :py:obj:`None`, no separator is inserted between items. ctx (list): An optional list of nested CDT :class:`cdt_ctx ` context operation objects. diff --git a/aerospike_helpers/operations/string_operations.py b/aerospike_helpers/operations/string_operations.py index bbe0ea1b81..b05ec842dc 100644 --- a/aerospike_helpers/operations/string_operations.py +++ b/aerospike_helpers/operations/string_operations.py @@ -202,7 +202,7 @@ def ends_with(bin_name: str, suffix: str, ctx: TypeCTX = None): def to_integer(bin_name: str, ctx: TypeCTX = None): """ Create string ``to_integer`` operation that parses the string as an unsigned 64-bit integer. - Raises :exc:`~aerospike.exception.ParamError` if the bin cannot be parsed as an integer. + Raises :exc:`~aerospike.exception.OpNotApplicable` if the bin cannot be parsed as an integer. Args: @@ -219,7 +219,7 @@ def to_integer(bin_name: str, ctx: TypeCTX = None): def to_double(bin_name: str, ctx: TypeCTX = None): """ Create string ``to_double`` operation that parses the string as a 64-bit float. - Returns :exc:`~aerospike.exception.ParamError` if the bin cannot be parsed as a double. + Returns :exc:`~aerospike.exception.OpNotApplicable` if the bin cannot be parsed as a double. Args: @@ -252,8 +252,13 @@ def byte_length(bin_name: str, ctx: TypeCTX = None): def is_numeric(bin_name: str, numeric_type: NumericType = NumericType.ANY, ctx: TypeCTX = None): """ - Create string ``is_numeric`` operation that returns true if the bin contains a - valid integer or floating-point number. + Create string ``is_numeric`` operation that filters by ``numeric_type`` and returns true if a valid type, false + otherwise. + + This is a spelling check, not "parses as a number of that type": + :py:attr:`~aerospike_helpers.string_helpers.NumericType.FLOAT` requires a ``.`` followed by a digit, so + `"5"` is false under :py:attr:`~aerospike_helpers.string_helpers.NumericType.FLOAT` even though it parses as a + double. Args: @@ -399,7 +404,7 @@ def regex_compare(bin_name: str, pattern: str, regex_flags: RegexFlags = RegexFl def to_string(bin_name: str): """ - Create ``to_string`` operation that converts an integer, double, string, or blob + Create ``to_string`` operation that converts an integer, double, string, bool, or blob bin to its string representation. Raises :exc:`~aerospike.exception.BinIncompatibleType` for @@ -551,7 +556,7 @@ def snip(bin_name: str, start: int, end: int | None = None, policy: StringPolicy start: First codepoint to remove, inclusive. end: One past the last codepoint to remove, exclusive. If :py:obj:`None`, remove from ``start`` to end of string. - policy: String policy. If end is :py:obj:`None`, ``policy`` is not sent. TODO + policy: String policy. If end is :py:obj:`None`, ``policy`` is not sent. ctx: Optional path into a string nested inside a list or map. """ return { diff --git a/aerospike_helpers/string_helpers.py b/aerospike_helpers/string_helpers.py index 27728c1eb6..7ed54c1458 100644 --- a/aerospike_helpers/string_helpers.py +++ b/aerospike_helpers/string_helpers.py @@ -57,14 +57,16 @@ class WriteFlags(IntEnum): """ Create new values only. Valid only on: - - ``insert`` - - ``overwrite`` - - ``concat`` - - ``append`` - - ``prepend`` - - ``pad_start`` - - ``pad_end`` - - ``repeat``. + - :py:meth:`~aerospike_helpers.operations.string_operations.insert` + - `py:meth:`~aerospike_helpers.operations.string_operations.overwrite` + - :py:meth:`~aerospike_helpers.operations.string_operations.concat` + - :py:meth:`~aerospike_helpers.operations.string_operations.append` + - :py:meth:`~aerospike_helpers.operations.string_operations.prepend` + - :py:meth:`~aerospike_helpers.operations.string_operations.pad_start` + - :py:meth:`~aerospike_helpers.operations.string_operations.pad_end` + - :py:meth:`~aerospike_helpers.operations.string_operations.repeat` + + and their corresponding expressions. Raises :py:exc:`~aerospike.exception.BinExistsError` if the bin already exists. Mutually exclusive with :py:attr:`~aerospike_helpers.string_helpers.WriteFlags.UPDATE_ONLY`. Invalid with a CDT context path. @@ -93,8 +95,13 @@ class NumericType(IntEnum): ANY = 0 #: Match only integers. INT = 1 - #: Match only floating-point numbers. The string must contain a decimal point and at least one digit after it. + FLOAT = 2 + """ + Match only floating-point numbers. Stricter than parsing as a double: + # the string must contain a ``.`` followed by a digit, so ``"5"`` is false under + # this option, but true under :py:attr:`~aerospike_helpers.string_helpers.NumericType.ANY` + """ class StringPolicy: diff --git a/src/include/policy.h b/src/include/policy.h index 2b7f81bb7d..a0ede96179 100644 --- a/src/include/policy.h +++ b/src/include/policy.h @@ -88,6 +88,7 @@ enum Aerospike_send_bool_as_values { X(STRING_OVERWRITE), \ X(STRING_CONCAT), \ X(STRING_SNIP), \ + X(STRING_SNIP_START), \ X(STRING_REPLACE), \ X(STRING_REPLACE_ALL), \ X(STRING_UPPER), \ diff --git a/src/main/convert_expressions.c b/src/main/convert_expressions.c index 928b6c2d6d..e6ca124820 100644 --- a/src/main/convert_expressions.c +++ b/src/main/convert_expressions.c @@ -481,6 +481,7 @@ static as_status get_expr_size(int *size_to_alloc, int *intermediate_exprs_size, [OP_STRING_OVERWRITE] = EXP_SZ(as_exp_string_overwrite(NULL, 0, "", NIL)), [OP_STRING_CONCAT] = EXP_SZ(as_exp_string_concat_list(NULL, NIL, NIL)), + [OP_STRING_SNIP_START] = EXP_SZ(as_exp_string_snip_start(NULL, 0, NIL)), [OP_STRING_SNIP] = EXP_SZ(as_exp_string_snip(NULL, 0, 0, NIL)), [OP_STRING_REPLACE] = EXP_SZ(as_exp_string_replace(NULL, "", "", NIL)), [OP_STRING_REPLACE_ALL] = @@ -2060,11 +2061,13 @@ add_expr_macros(AerospikeClient *self, as_static_pool *static_pool, case OP_STRING_PREPEND: APPEND_ARRAY(1, as_exp_string_prepend(&policy, value, NIL)); break; + case OP_STRING_SNIP_START: case OP_STRING_SNIP: if (get_int64_t(err, _STR_EXP_START_KEY, temp_expr->pydict, &lval1)) { return err->code; } + bool end_found = false; if (get_optional_int64_t(err, _STR_EXP_END_KEY, temp_expr->pydict, &lval2, From 6cfd19928d2ff3162219b27395d79bfa57abf983 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:15:09 -0700 Subject: [PATCH 073/112] doc: address doc build errors --- aerospike_helpers/string_helpers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aerospike_helpers/string_helpers.py b/aerospike_helpers/string_helpers.py index 7ed54c1458..0f82b8a3f6 100644 --- a/aerospike_helpers/string_helpers.py +++ b/aerospike_helpers/string_helpers.py @@ -99,8 +99,8 @@ class NumericType(IntEnum): FLOAT = 2 """ Match only floating-point numbers. Stricter than parsing as a double: - # the string must contain a ``.`` followed by a digit, so ``"5"`` is false under - # this option, but true under :py:attr:`~aerospike_helpers.string_helpers.NumericType.ANY` + the string must contain a ``.`` followed by a digit, so ``"5"`` is false under + this option, but true under :py:attr:`~aerospike_helpers.string_helpers.NumericType.ANY` """ From 0047f8ab02a445fdb152f81a3a5a1615ec8d17ca Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:20:25 -0700 Subject: [PATCH 074/112] fix: SplitSeparator expression should fail if separator isn't present --- src/main/convert_expressions.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/convert_expressions.c b/src/main/convert_expressions.c index e6ca124820..6db4470c7a 100644 --- a/src/main/convert_expressions.c +++ b/src/main/convert_expressions.c @@ -1915,9 +1915,10 @@ add_expr_macros(AerospikeClient *self, as_static_pool *static_pool, case OP_LIST_JOIN: case OP_STRING_SPLIT_SEPARATOR: { char *separator = NULL; + bool is_separator_optional = temp_expr->op == OP_LIST_JOIN; as_status status = get_str(err, _STR_EXP_SEPARATOR_KEY, temp_expr->pydict, NULL, - &separator, true); + &separator, is_separator_optional); if (status != AEROSPIKE_OK) { return status; } From 71a1413173def2ecf6ab3c65f7fbcfcf6bea040a Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:22:44 -0700 Subject: [PATCH 075/112] docs: address invalid formatting for cross ref --- aerospike_helpers/expressions/string.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aerospike_helpers/expressions/string.py b/aerospike_helpers/expressions/string.py index 45d67fd03c..e53f547fad 100644 --- a/aerospike_helpers/expressions/string.py +++ b/aerospike_helpers/expressions/string.py @@ -20,10 +20,10 @@ Requires server version 8.1.3 or later. -Unlike operate-level string ops, these macros do not take a `ctx` parameter. To target +Unlike operate-level string ops, these macros do not take a ``ctx`` parameter. To target a string nested inside a list or map, extract the leaf with -:py:class:`~aerospike_helpers.expressions.list.ListGetByIndex` or `~aerospike_helpers.expressions.map.MapGetByKey` and -pass the result as the operand expression. +:py:class:`~aerospike_helpers.expressions.list.ListGetByIndex` or +:py:class:`~aerospike_helpers.expressions.map.MapGetByKey` and pass the result as the operand expression. """ From d6ee9377465a379535c3a05a88f2586232e9a1f9 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:28:36 -0700 Subject: [PATCH 076/112] docs: address invalid formatting for cross ref --- aerospike_helpers/string_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aerospike_helpers/string_helpers.py b/aerospike_helpers/string_helpers.py index 0f82b8a3f6..1672b03f87 100644 --- a/aerospike_helpers/string_helpers.py +++ b/aerospike_helpers/string_helpers.py @@ -58,7 +58,7 @@ class WriteFlags(IntEnum): Create new values only. Valid only on: - :py:meth:`~aerospike_helpers.operations.string_operations.insert` - - `py:meth:`~aerospike_helpers.operations.string_operations.overwrite` + - :py:meth:`~aerospike_helpers.operations.string_operations.overwrite` - :py:meth:`~aerospike_helpers.operations.string_operations.concat` - :py:meth:`~aerospike_helpers.operations.string_operations.append` - :py:meth:`~aerospike_helpers.operations.string_operations.prepend` From 4afc35a6918b3753c1c5f9a07fad634bf0e70203 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:30:58 -0700 Subject: [PATCH 077/112] docs: add period at end of sentence.. --- aerospike_helpers/string_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aerospike_helpers/string_helpers.py b/aerospike_helpers/string_helpers.py index 1672b03f87..5623bd12b6 100644 --- a/aerospike_helpers/string_helpers.py +++ b/aerospike_helpers/string_helpers.py @@ -100,7 +100,7 @@ class NumericType(IntEnum): """ Match only floating-point numbers. Stricter than parsing as a double: the string must contain a ``.`` followed by a digit, so ``"5"`` is false under - this option, but true under :py:attr:`~aerospike_helpers.string_helpers.NumericType.ANY` + this option, but true under :py:attr:`~aerospike_helpers.string_helpers.NumericType.ANY`. """ From a0bf5a94182419d1f1e5033f421369b1fdc75f7d Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:32:27 -0700 Subject: [PATCH 078/112] docs: address invalid formatting for wire protocol bytes description --- doc/aerospike.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/aerospike.rst b/doc/aerospike.rst index a1dc731ea2..84707a159e 100644 --- a/doc/aerospike.rst +++ b/doc/aerospike.rst @@ -2108,7 +2108,7 @@ Subcodes paired with :py:exc:`~aerospike.exception.ParamError` String op ctx envelope is malformed. - App use: verify the client emits `[0xFF, ctx_list, [sub_op, args...]]`. + App use: verify the client emits ``[0xFF, ctx_list, [sub_op, args...]]``. Subcodes paired with :py:exc:`~aerospike.exception.ClusterError` ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ From 96c62fe6e8e3a176f881924dbd589439e2c2e5da Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:44:47 -0700 Subject: [PATCH 079/112] fix: make sure as_exp_join_separator has enough memory --- aerospike_helpers/expressions/list.py | 7 +++++-- src/include/policy.h | 3 ++- src/main/client/operate.c | 2 +- src/main/convert_expressions.c | 19 +++++++++---------- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/aerospike_helpers/expressions/list.py b/aerospike_helpers/expressions/list.py index 94c031b793..41c24092b2 100644 --- a/aerospike_helpers/expressions/list.py +++ b/aerospike_helpers/expressions/list.py @@ -1323,8 +1323,6 @@ class ListJoin(_BaseExpr): and a single-item list yields that item with no separator applied. """ - _op = aerospike._OP_LIST_JOIN - def __init__( self, ctx: "TypeCTX", separator: str | None, @@ -1340,6 +1338,11 @@ def __init__( :return: Expression. """ + if separator: + self._op = aerospike._OP_LIST_JOIN + else: + self._op = aerospike._OP_LIST_JOIN_SEPARATOR + self._children = (bin if isinstance(bin, _BaseExpr) else ListBin(bin)) self._fixed = {aerospike._STR_EXP_SEPARATOR_KEY: separator} if ctx is not None: diff --git a/src/include/policy.h b/src/include/policy.h index a0ede96179..37198da2e9 100644 --- a/src/include/policy.h +++ b/src/include/policy.h @@ -61,7 +61,8 @@ enum Aerospike_send_bool_as_values { X(LIST_REMOVE_BY_REL_RANK_RANGE_TO_END), \ X(LIST_REMOVE_BY_REL_RANK_RANGE), \ X(LIST_REMOVE_BY_INDEX_RANGE_TO_END), \ - X(LIST_REMOVE_BY_RANK_RANGE_TO_END), X(LIST_CREATE), X(LIST_JOIN) + X(LIST_REMOVE_BY_RANK_RANGE_TO_END), X(LIST_CREATE), X(LIST_JOIN), \ + X(LIST_JOIN_SEPARATOR) // clang-format off #define STRING_OP_NAMES \ diff --git a/src/main/client/operate.c b/src/main/client/operate.c index 8eec6676d6..831a1ef2fa 100644 --- a/src/main/client/operate.c +++ b/src/main/client/operate.c @@ -219,7 +219,7 @@ static inline bool use_operate_conversion_helper(int op) op == OP_LIST_REMOVE_BY_VALUE_RANGE || op == OP_LIST_SET_ORDER || op == OP_LIST_SORT || op == OP_LIST_REMOVE_BY_VALUE_RANK_RANGE_REL || op == OP_LIST_GET_BY_VALUE_RANK_RANGE_REL || op == OP_LIST_CREATE || - op == OP_LIST_JOIN || + op == OP_LIST_JOIN || op == OP_LIST_JOIN_SEPARATOR || (op >= OP_STRING_STRLEN && op <= OP_STRING_TO_STRING) || (op == OP_MAP_REMOVE_BY_KEY_INDEX_RANGE_REL || op == OP_MAP_REMOVE_BY_VALUE_RANK_RANGE_REL || diff --git a/src/main/convert_expressions.c b/src/main/convert_expressions.c index 6db4470c7a..a13d847023 100644 --- a/src/main/convert_expressions.c +++ b/src/main/convert_expressions.c @@ -308,6 +308,8 @@ static as_status get_expr_size(int *size_to_alloc, int *intermediate_exprs_size, [OP_LIST_REMOVE_BY_RANK_RANGE] = EXP_SZ(as_exp_list_remove_by_rank_range(NULL, 0, NIL, NIL, NIL)), [OP_LIST_JOIN] = EXP_SZ(as_exp_list_join(NULL, NIL)), + [OP_LIST_JOIN_SEPARATOR] = + EXP_SZ(as_exp_list_join_separator(NULL, "", NIL)), [OP_MAP_PUT] = EXP_SZ(as_exp_map_put(NULL, NULL, NIL, NIL, NIL)), [OP_MAP_PUT_ITEMS] = EXP_SZ(as_exp_map_put_items(NULL, NULL, NIL, NIL)), [OP_MAP_INCREMENT] = @@ -1913,24 +1915,21 @@ add_expr_macros(AerospikeClient *self, as_static_pool *static_pool, APPEND_ARRAY(1, as_exp_string_split(NIL)); break; case OP_LIST_JOIN: + APPEND_ARRAY(1, as_exp_list_join(temp_expr->ctx, NIL)); + break; + case OP_LIST_JOIN_SEPARATOR: case OP_STRING_SPLIT_SEPARATOR: { char *separator = NULL; - bool is_separator_optional = temp_expr->op == OP_LIST_JOIN; as_status status = get_str(err, _STR_EXP_SEPARATOR_KEY, temp_expr->pydict, NULL, - &separator, is_separator_optional); + &separator, false); if (status != AEROSPIKE_OK) { return status; } - if (temp_expr->op == OP_LIST_JOIN) { - if (separator) { - APPEND_ARRAY(1, as_exp_list_join_separator(temp_expr->ctx, - separator, NIL)); - } - else { - APPEND_ARRAY(1, as_exp_list_join(temp_expr->ctx, NIL)); - } + if (temp_expr->op == OP_LIST_JOIN_SEPARATOR) { + APPEND_ARRAY(1, as_exp_list_join_separator(temp_expr->ctx, + separator, NIL)); } else { APPEND_ARRAY(1, as_exp_string_split_separator(separator, NIL)); From 45b57baa4a933b88edca94c1ad4f593aa1c63cd7 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:07:19 -0700 Subject: [PATCH 080/112] tests: enable error detail expression tracing by default for the client fixture. this makes it a lot easier to debug why expressions related test cases fail --- test/new_tests/test_base_class.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/new_tests/test_base_class.py b/test/new_tests/test_base_class.py index fd716c46bc..4cd1ed8921 100644 --- a/test/new_tests/test_base_class.py +++ b/test/new_tests/test_base_class.py @@ -240,6 +240,7 @@ def get_connection_config(): config["policies"][policy_name]["total_timeout"] = 180000 # Must hear back from server after a certain number of seconds config["policies"][policy_name]["socket_timeout"] = 180000 + config["policies"][policy_name]["error_detail_verbosity"] = aerospike.ERROR_DETAIL_EXP_TRACE # config["max_socket_idle"] = 60 config["policies"]["info"] = {} From 6a95108d0b26cfa0ac8c9651682c03d4ba727dc4 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:27:04 -0700 Subject: [PATCH 081/112] fix: only allocate bare minimum memory for as_exp_bit_b64_encode. Also test b64_encode expression --- aerospike_helpers/expressions/bitwise.py | 21 +++++++++++------ src/include/policy.h | 4 ++-- src/main/convert_expressions.c | 6 ++++- test/new_tests/test_exception_subcode.py | 2 ++ test/new_tests/test_expressions_bit.py | 29 ++++++++++++++++++++++-- 5 files changed, 50 insertions(+), 12 deletions(-) diff --git a/aerospike_helpers/expressions/bitwise.py b/aerospike_helpers/expressions/bitwise.py index 182e031d91..35dd5018a2 100644 --- a/aerospike_helpers/expressions/bitwise.py +++ b/aerospike_helpers/expressions/bitwise.py @@ -730,8 +730,6 @@ class BitB64Encode(_BaseExpr): operation. """ - _op = aerospike._OP_BIT_B64_ENCODE - def __init__( self, byte_offset: int, @@ -747,8 +745,17 @@ def __init__( :return: String expression. """ - self._children = ( - byte_offset, - byte_size, - bin if isinstance(bin, _BaseExpr) else BlobBin(bin) - ) + print("test") + bin = bin if isinstance(bin, _BaseExpr) else BlobBin(bin) + if byte_size: + self._op = aerospike._OP_BIT_B64_ENCODE_RANGE + self._children = ( + byte_offset, + byte_size, + bin + ) + else: + self._op = aerospike._OP_BIT_B64_ENCODE + self._children = ( + bin, + ) diff --git a/src/include/policy.h b/src/include/policy.h index 37198da2e9..45ba9487b9 100644 --- a/src/include/policy.h +++ b/src/include/policy.h @@ -181,8 +181,8 @@ enum Aerospike_map_operations { X(BIT_COUNT), \ X(BIT_LSCAN), \ X(BIT_RSCAN), \ - X(BIT_B64_ENCODE) -// clang-format on + X(BIT_B64_ENCODE), \ + X(BIT_B64_ENCODE_RANGE) // clang-format on enum aerospike_bitwise_operations { #define X(op_name) OP_##op_name diff --git a/src/main/convert_expressions.c b/src/main/convert_expressions.c index a13d847023..fa0420df9f 100644 --- a/src/main/convert_expressions.c +++ b/src/main/convert_expressions.c @@ -409,7 +409,8 @@ static as_status get_expr_size(int *size_to_alloc, int *intermediate_exprs_size, [OP_BIT_LSCAN] = EXP_SZ(as_exp_bit_lscan(NIL, NIL, NIL, NIL)), [OP_BIT_RSCAN] = EXP_SZ(as_exp_bit_rscan(NIL, NIL, NIL, NIL)), [OP_BIT_GET_INT] = EXP_SZ(as_exp_bit_get_int(NIL, NIL, 0, NIL)), - [OP_BIT_B64_ENCODE] = + [OP_BIT_B64_ENCODE] = EXP_SZ(as_exp_bit_b64_encode(NIL)), + [OP_BIT_B64_ENCODE_RANGE] = EXP_SZ(as_exp_bit_b64_encode_range(NIL, NIL, NIL)), [OP_HLL_INIT] = EXP_SZ(as_exp_hll_init_mh(NULL, 0, 0, NIL)), [OP_HLL_ADD] = EXP_SZ(as_exp_hll_add_mh(NULL, NIL, 0, 0, NIL)), @@ -1571,6 +1572,9 @@ add_expr_macros(AerospikeClient *self, as_static_pool *static_pool, APPEND_ARRAY(4, as_exp_bit_get_int(NIL, NIL, 0, NIL)); break; case OP_BIT_B64_ENCODE: + APPEND_ARRAY(1, as_exp_bit_b64_encode(NIL)); + break; + case OP_BIT_B64_ENCODE_RANGE: APPEND_ARRAY(3, as_exp_bit_b64_encode_range(NIL, NIL, NIL)); break; case OP_HLL_INIT: // NOTE: this case covers HLLInit and HLLInitMH. diff --git a/test/new_tests/test_exception_subcode.py b/test/new_tests/test_exception_subcode.py index 1ea90cbcfd..ca8b59b31d 100644 --- a/test/new_tests/test_exception_subcode.py +++ b/test/new_tests/test_exception_subcode.py @@ -26,6 +26,7 @@ class TestExceptionSubcode: aerospike.SUB_PARAM_BITS_SIZE_OUT_OF_RANGE, aerospike.SUB_PARAM_BITS_RESIZE_EXCEEDED, aerospike.SUB_PARAM_BIN_COUNT_TOO_LARGE, + aerospike.SUB_PARAM_STRING_CTX_MALFORMED, aerospike.SUB_UNAVAIL_INITIAL_BALANCE_UNRESOLVED, aerospike.SUB_UNAVAIL_REPLICA_UNAVAILABLE, aerospike.SUB_UNSUPP_FEAT_MRT_REQUIRES_STRONG_CONSISTENCY, @@ -52,6 +53,7 @@ class TestExceptionSubcode: aerospike.SUB_OPNOT_HLL_INTERSECT_MINHASH_MISMATCH, aerospike.SUB_OPNOT_STRING_CONVERSION_FAILED, aerospike.SUB_OPNOT_STRING_UTF8_INVALID, + aerospike.SUB_OPNOT_STRING_REGEX_LIMIT_EXCEEDED, aerospike.SUB_OPNOT_STRING_B64_INVALID ] ) diff --git a/test/new_tests/test_expressions_bit.py b/test/new_tests/test_expressions_bit.py index b1733ecb43..b7405d2201 100644 --- a/test/new_tests/test_expressions_bit.py +++ b/test/new_tests/test_expressions_bit.py @@ -21,8 +21,10 @@ BitSetInt, BitSubtract, BitXor, + BitB64Encode, Eq, ) +from aerospike_helpers.operations import expression_operations as expr_ops import aerospike from . import as_errors @@ -64,6 +66,10 @@ def __init__(self, i): self.data = i +BASE64_BYTES = b'1234' +import base64 + + class TestExpressions(TestBaseClass): @pytest.fixture(autouse=True) def setup(self, request, as_connection): @@ -72,8 +78,11 @@ def setup(self, request, as_connection): for i in range(_NUM_RECORDS): key = ("test", "demo", i) - rec = {"1bits_bin": bytearray([1] * 8)} - self.as_connection.put(key, rec) + self.rec = { + "1bits_bin": bytearray([1] * 8), + "base64_bytes": BASE64_BYTES + } + self.as_connection.put(key, self.rec) def teardown(): for i in range(_NUM_RECORDS): @@ -341,3 +350,19 @@ def test_bit_get_int_pos(self, bit_offset, bit_size, bin, expected): verify_multiple_expression_result( self.as_connection, self.test_ns, self.test_set, expr.compile(), bin, _NUM_RECORDS ) + + @pytest.mark.parametrize( + "byte_offset, byte_size, expected", + [ + (0, None, base64.b64encode(BASE64_BYTES).decode("utf-8")) + ] + ) + def test_bit_b64_encode(self, byte_offset, byte_size, expected): + bin = "base64_bytes" + expr = BitB64Encode(byte_offset, byte_size, bin).compile() + ops = [ + expr_ops.expression_read(bin, expr) + ] + key = ("test", "demo", 1) + _, _, bins = self.as_connection.operate(key, ops) + assert bins[bin] == expected From 7eb3902b3859cf4e090c413dabd140ff15ba204c Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:58:04 -0700 Subject: [PATCH 082/112] test: list_join expr and ops. fix bugs along the way --- aerospike_helpers/expressions/list.py | 6 ++-- src/main/aerospike.c | 1 + test/new_tests/test_expressions_list.py | 24 ++++++++++++++ .../test_new_list_operation_helpers.py | 33 ++++++++++++++++++- 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/aerospike_helpers/expressions/list.py b/aerospike_helpers/expressions/list.py index 41c24092b2..be4050eb2c 100644 --- a/aerospike_helpers/expressions/list.py +++ b/aerospike_helpers/expressions/list.py @@ -1339,11 +1339,11 @@ def __init__( :return: Expression. """ if separator: - self._op = aerospike._OP_LIST_JOIN - else: self._op = aerospike._OP_LIST_JOIN_SEPARATOR + else: + self._op = aerospike._OP_LIST_JOIN - self._children = (bin if isinstance(bin, _BaseExpr) else ListBin(bin)) + self._children = (bin if isinstance(bin, _BaseExpr) else ListBin(bin),) self._fixed = {aerospike._STR_EXP_SEPARATOR_KEY: separator} if ctx is not None: self._fixed[_Keys.CTX_KEY] = ctx diff --git a/src/main/aerospike.c b/src/main/aerospike.c index e28e53829d..ecb4da5fa2 100644 --- a/src/main/aerospike.c +++ b/src/main/aerospike.c @@ -266,6 +266,7 @@ static struct module_constant_name_to_value module_constants[] = { {"OP_LIST_REMOVE_BY_RANK_RANGE_TO_END", .value.integer = OP_LIST_REMOVE_BY_RANK_RANGE_TO_END}, EXPOSE_MACRO_AS_PRIVATE_FIELD(OP_LIST_JOIN), + EXPOSE_MACRO_AS_PRIVATE_FIELD(OP_LIST_JOIN_SEPARATOR), {"OP_MAP_SET_POLICY", .value.integer = OP_MAP_SET_POLICY}, {"OP_MAP_CREATE", .value.integer = OP_MAP_CREATE}, diff --git a/test/new_tests/test_expressions_list.py b/test/new_tests/test_expressions_list.py index 784beadf17..45c2526374 100644 --- a/test/new_tests/test_expressions_list.py +++ b/test/new_tests/test_expressions_list.py @@ -38,8 +38,10 @@ ListSet, ListSize, ListSort, + ListJoin, Or, ResultType, + Val ) import aerospike @@ -137,6 +139,7 @@ def setup(self, request, as_connection): "balance": i * 10, "key": i, "alt_name": "name%s" % (str(i)), + "empty_list": [], "list_bin": [ None, i, @@ -155,6 +158,7 @@ def setup(self, request, as_connection): 2, 6, ], + "list_of_one_str": ["b"], "slist_bin": ["b", "d", "f"], "llist_bin": [[1, 2], [1, 3], [1, 4]], "mlist_bin": [ @@ -943,3 +947,23 @@ def test_list_expr_inverted(self, bin_name: str, expr, expected): _, _, bins = self.as_connection.operate(key, ops) assert bins[bin_name] == expected + + @pytest.mark.parametrize( + "bin_name, expected", + [ + ("slist_bin", "bdf"), + (Val(["b", "d", "f"]), "bdf"), + # Edge cases + ("empty_list", ""), + ("list_of_one_str", "b"), + ] + ) + def test_list_join(self, bin_name, expected): + expr = ListJoin(None, None, bin_name).compile() + ops = [ + expr_ops.expression_read(bin_name, expr) + ] + key = (self.test_ns, self.test_set, 0) + _, _, bins = self.as_connection.operate(key, ops) + + assert bins[bin_name] == expected diff --git a/test/new_tests/test_new_list_operation_helpers.py b/test/new_tests/test_new_list_operation_helpers.py index 5f29dddea6..955b1108d0 100644 --- a/test/new_tests/test_new_list_operation_helpers.py +++ b/test/new_tests/test_new_list_operation_helpers.py @@ -26,7 +26,15 @@ def setup(self, request, as_connection): self.test_key = "test", "demo", "new_list_op" self.test_bin = "list" - self.as_connection.put(self.test_key, {self.test_bin: self.test_list}) + self.as_connection.put( + self.test_key, + { + self.test_bin: self.test_list, + "empty_list": [], + "list_of_one_str": ["a"], + "list_of_strs": ["a", "b", "c"] + } + ) self.keys.append(self.test_key) yield @@ -474,3 +482,26 @@ def test_list_create_neg(self, list_order, pad, persist_index): persist_index=persist_index, ctx=None) with pytest.raises(e.ParamError): self.as_connection.operate(self.test_key, [operation]) + + @pytest.mark.parametrize( + "bin_name, expected", + [ + ("list_of_strs", "abc"), + # Edge cases + ("empty_list", ""), + ("list_of_one_str", "a") + ] + ) + def test_list_join(self, bin_name, expected: str): + ops = [ + list_operations.list_join(bin_name=bin_name) + ] + _, _, bins = self.as_connection.operate(self.test_key, ops) + assert bins[bin_name] == expected + + def test_list_join_fail(self): + ops = [ + list_operations.list_join(bin_name="list") + ] + with pytest.raises(e.InvalidRequest): + self.as_connection.operate(self.test_key, ops) From 2c96ed0b23cb430bdc14e887ee39b25c667851f6 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:02:14 -0700 Subject: [PATCH 083/112] test: remove invalid test that passes in non-str bin name to expression_read --- test/new_tests/test_expressions_list.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test/new_tests/test_expressions_list.py b/test/new_tests/test_expressions_list.py index 45c2526374..d8fccb37e1 100644 --- a/test/new_tests/test_expressions_list.py +++ b/test/new_tests/test_expressions_list.py @@ -952,7 +952,6 @@ def test_list_expr_inverted(self, bin_name: str, expr, expected): "bin_name, expected", [ ("slist_bin", "bdf"), - (Val(["b", "d", "f"]), "bdf"), # Edge cases ("empty_list", ""), ("list_of_one_str", "b"), From 670f1002329edfe9f9cfd32827c37ea604d9ce51 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:34:43 -0700 Subject: [PATCH 084/112] test: snip expression with end set to none, and b64encode op. fix issues along the way --- .../operations/bitwise_operations.py | 2 +- src/include/cdt_operation_utils.h | 3 +- src/main/client/bit_operate.c | 19 ++++++++---- src/main/client/cdt_operation_utils.c | 4 +-- src/main/client/hll_operate.c | 10 +++---- src/main/conversions.c | 4 +-- src/main/convert_expressions.c | 3 +- test/new_tests/test_bitwise_operations.py | 30 +++++++++++++++++++ test/new_tests/test_expressions_string.py | 4 +++ 9 files changed, 61 insertions(+), 18 deletions(-) diff --git a/aerospike_helpers/operations/bitwise_operations.py b/aerospike_helpers/operations/bitwise_operations.py index c8581b90a1..86ab2d1bc7 100644 --- a/aerospike_helpers/operations/bitwise_operations.py +++ b/aerospike_helpers/operations/bitwise_operations.py @@ -680,7 +680,7 @@ def bit_b64_encode( OP_KEY: aerospike._OP_BIT_B64_ENCODE, BIN_KEY: bin_name, "byte_offset": byte_offset, - "byte_size": byte_size, + BYTE_SIZE_KEY: byte_size, "invert_size": invert_size, "ctx": ctx } diff --git a/src/include/cdt_operation_utils.h b/src/include/cdt_operation_utils.h index 1cff1bcc16..6f6491a391 100644 --- a/src/include/cdt_operation_utils.h +++ b/src/include/cdt_operation_utils.h @@ -88,7 +88,8 @@ as_status get_enum_from_py_dict(as_error *err, PyObject *py_dict, bool *int_was_found); as_status get_int_from_py_dict(as_error *err, PyObject *py_dict, - const char *key, int *int_pointer); + const char *key, int *int_pointer, + bool is_optional); as_status get_list_return_type(as_error *err, PyObject *op_dict, int *return_type); diff --git a/src/main/client/bit_operate.c b/src/main/client/bit_operate.c index c384d78b94..9b1fb0931d 100644 --- a/src/main/client/bit_operate.c +++ b/src/main/client/bit_operate.c @@ -336,14 +336,14 @@ as_status add_new_bit_op(AerospikeClient *self, as_error *err, as_cdt_ctx *ctx_ref = (ctx_in_use ? &ctx : NULL); int byte_offset = 0; - if (get_int_from_py_dict(err, op_dict, "offset", &byte_offset) != - AEROSPIKE_OK) { + if (get_int_from_py_dict(err, op_dict, "byte_offset", &byte_offset, + false) != AEROSPIKE_OK) { goto exit; } int byte_size = 0; - if (get_int_from_py_dict(err, op_dict, BYTE_SIZE_KEY, &byte_size) != - AEROSPIKE_OK) { + if (get_int_from_py_dict(err, op_dict, BYTE_SIZE_KEY, &byte_size, + true) != AEROSPIKE_OK) { goto exit; } @@ -353,8 +353,15 @@ as_status add_new_bit_op(AerospikeClient *self, as_error *err, goto exit; } - success = as_operations_bit_b64_encode_range_invert( - ops, bin, ctx_ref, byte_offset, byte_size, invert_size); + if (byte_size) { + success = as_operations_bit_b64_encode_from(ops, bin, ctx_ref, + byte_offset); + } + else { + success = as_operations_bit_b64_encode_range_invert( + ops, bin, ctx_ref, byte_offset, byte_size, invert_size); + } + break; } default: // This should never be possible since we only get here if we know that the operation is valid. diff --git a/src/main/client/cdt_operation_utils.c b/src/main/client/cdt_operation_utils.c index 2c46fd8318..8a6fc34094 100644 --- a/src/main/client/cdt_operation_utils.c +++ b/src/main/client/cdt_operation_utils.c @@ -266,10 +266,10 @@ as_status get_enum_from_py_dict( max_bound, is_optional, true, int_was_found); } -as_status get_int_from_py_dict(as_error *err, PyObject *py_dict, const char *key, int *int_pointer) +as_status get_int_from_py_dict(as_error *err, PyObject *py_dict, const char *key, int *int_pointer, bool is_optional) { return get_bound_int_from_py_dict(err, py_dict, key, int_pointer, INT_MIN, - INT_MAX, false, false, NULL); + INT_MAX, is_optional, false, NULL); } // clang-format on diff --git a/src/main/client/hll_operate.c b/src/main/client/hll_operate.c index 775712cc3f..8fd627052f 100644 --- a/src/main/client/hll_operate.c +++ b/src/main/client/hll_operate.c @@ -178,12 +178,12 @@ static as_status add_op_hll_add(AerospikeClient *self, as_error *err, char *bin, as_hll_policy *hll_policy_p = &hll_policy; if (get_int_from_py_dict(err, op_dict, AS_PY_HLL_INDEX_BIT_COUNT, - &index_bit_count) != AEROSPIKE_OK) { + &index_bit_count, false) != AEROSPIKE_OK) { goto cleanup; } if (get_int_from_py_dict(err, op_dict, AS_PY_HLL_MH_BIT_COUNT_KEY, - &mh_bit_count) != AEROSPIKE_OK) { + &mh_bit_count, false) != AEROSPIKE_OK) { goto cleanup; } @@ -242,12 +242,12 @@ static as_status add_op_hll_init(AerospikeClient *self, as_error *err, as_hll_policy *hll_policy_p = &hll_policy; if (get_int_from_py_dict(err, op_dict, AS_PY_HLL_INDEX_BIT_COUNT, - &index_bit_count) != AEROSPIKE_OK) { + &index_bit_count, false) != AEROSPIKE_OK) { goto cleanup; } if (get_int_from_py_dict(err, op_dict, AS_PY_HLL_MH_BIT_COUNT_KEY, - &mh_bit_count) != AEROSPIKE_OK) { + &mh_bit_count, false) != AEROSPIKE_OK) { goto cleanup; } @@ -318,7 +318,7 @@ static as_status add_op_hll_fold(AerospikeClient *self, as_error *err, int index_bit_count; if (get_int_from_py_dict(err, op_dict, AS_PY_HLL_INDEX_BIT_COUNT, - &index_bit_count) != AEROSPIKE_OK) { + &index_bit_count, false) != AEROSPIKE_OK) { goto cleanup; } diff --git a/src/main/conversions.c b/src/main/conversions.c index 6ed46b7813..5119037105 100644 --- a/src/main/conversions.c +++ b/src/main/conversions.c @@ -2733,8 +2733,8 @@ as_status as_cdt_ctx_add_from_pyobject(AerospikeClient *self, as_error *err, } int pad = 0; - status = - get_int_from_py_dict(err, py_extra_args, CDT_CTX_PAD_KEY, &pad); + status = get_int_from_py_dict(err, py_extra_args, CDT_CTX_PAD_KEY, &pad, + false); if (status != AEROSPIKE_OK) { goto CLEANUP_PY_EXTRA_ARGS; } diff --git a/src/main/convert_expressions.c b/src/main/convert_expressions.c index fa0420df9f..cdee70ad41 100644 --- a/src/main/convert_expressions.c +++ b/src/main/convert_expressions.c @@ -1999,7 +1999,8 @@ add_expr_macros(AerospikeClient *self, as_static_pool *static_pool, case OP_STRING_PAD_END: case OP_STRING_REPEAT: case OP_STRING_APPEND: - case OP_STRING_PREPEND: { + case OP_STRING_PREPEND: + case OP_STRING_SNIP_START: { PyObject *py_str_policy = PyDict_GetItemString(temp_expr->pydict, _STR_EXP_POLICY_KEY); as_string_policy policy; diff --git a/test/new_tests/test_bitwise_operations.py b/test/new_tests/test_bitwise_operations.py index a5344fc565..0f1d99404b 100644 --- a/test/new_tests/test_bitwise_operations.py +++ b/test/new_tests/test_bitwise_operations.py @@ -6,6 +6,7 @@ import aerospike from contextlib import nullcontext +import base64 random.seed(0) @@ -1648,6 +1649,35 @@ def test_bit_xor_with_policy(self): expected_result = bytearray([0] * 5) assert bins[self.test_bin_zeroes] == expected_result + @pytest.mark.parametrize( + "kwargs, expected", + [ + ( + {"bin_name": "bitwise1"}, + base64.b64encode(bytearray([1] * 5)).decode('utf-8') + ), + ( + {"bin_name": "bitwise1", "byte_offset": 1}, + base64.b64encode(bytearray([1] * 4)).decode('utf-8') + ), + ( + {"bin_name": "bitwise1", "byte_offset": 1, "byte_size": 2}, + base64.b64encode(bytearray([1] * 2)).decode('utf-8') + ), + ( + {"bin_name": "random_blob", "byte_offset": 1, "byte_size": 1, "invert_size": True}, + base64.b64encode(bytearray([0x42, 0x03, 0x04])).decode('utf-8') + ), + ] + ) + def test_bit_b64_encode(self, kwargs, expected): + ops = [ + bitwise_operations.bit_b64_encode(**kwargs) + ] + _, _, bins = self.as_connection.operate(self.test_key, ops) + bin_name = kwargs["bin_name"] + assert bins[bin_name] == expected + BIN_NAME_FOR_INVALID_PARAMS = "bitwise0" @pytest.mark.parametrize( diff --git a/test/new_tests/test_expressions_string.py b/test/new_tests/test_expressions_string.py index 8774049c6e..bcf815fa42 100644 --- a/test/new_tests/test_expressions_string.py +++ b/test/new_tests/test_expressions_string.py @@ -175,6 +175,10 @@ def test_expression_read_fail(self, expr): str_expr.Concat, {"values": [NEEDLE, NEEDLE], "bin": STR_BIN_NAME}, EXAMPLE_STR + NEEDLE + NEEDLE ), + ( + str_expr.Snip, {"start": START_IDX, "end": None, "bin": STR_BIN_NAME}, + EXAMPLE_STR[:START_IDX] + ), ( str_expr.Snip, {"start": START_IDX, "end": len(EXAMPLE_STR) - 1, "bin": STR_BIN_NAME}, EXAMPLE_STR[:START_IDX] + EXAMPLE_STR[-1] From fd6caefd3212eeccbf3e8cbb3367b224078de658 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:44:48 -0700 Subject: [PATCH 085/112] fix: b64encode failing due to byte_offset not being parsed properly --- src/include/cdt_operation_utils.h | 2 +- src/main/client/bit_operate.c | 17 +++++++++-------- src/main/client/cdt_operation_utils.c | 4 ++-- src/main/client/hll_operate.c | 10 +++++----- src/main/conversions.c | 2 +- 5 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/include/cdt_operation_utils.h b/src/include/cdt_operation_utils.h index 6f6491a391..e7d32ecc5a 100644 --- a/src/include/cdt_operation_utils.h +++ b/src/include/cdt_operation_utils.h @@ -89,7 +89,7 @@ as_status get_enum_from_py_dict(as_error *err, PyObject *py_dict, as_status get_int_from_py_dict(as_error *err, PyObject *py_dict, const char *key, int *int_pointer, - bool is_optional); + bool is_optional, bool *int_was_found); as_status get_list_return_type(as_error *err, PyObject *op_dict, int *return_type); diff --git a/src/main/client/bit_operate.c b/src/main/client/bit_operate.c index 9b1fb0931d..c035e1fb0e 100644 --- a/src/main/client/bit_operate.c +++ b/src/main/client/bit_operate.c @@ -337,13 +337,14 @@ as_status add_new_bit_op(AerospikeClient *self, as_error *err, int byte_offset = 0; if (get_int_from_py_dict(err, op_dict, "byte_offset", &byte_offset, - false) != AEROSPIKE_OK) { + false, NULL) != AEROSPIKE_OK) { goto exit; } int byte_size = 0; - if (get_int_from_py_dict(err, op_dict, BYTE_SIZE_KEY, &byte_size, - true) != AEROSPIKE_OK) { + bool was_byte_size_found = false; + if (get_int_from_py_dict(err, op_dict, BYTE_SIZE_KEY, &byte_size, true, + &was_byte_size_found) != AEROSPIKE_OK) { goto exit; } @@ -353,14 +354,14 @@ as_status add_new_bit_op(AerospikeClient *self, as_error *err, goto exit; } - if (byte_size) { - success = as_operations_bit_b64_encode_from(ops, bin, ctx_ref, - byte_offset); - } - else { + if (was_byte_size_found) { success = as_operations_bit_b64_encode_range_invert( ops, bin, ctx_ref, byte_offset, byte_size, invert_size); } + else { + success = as_operations_bit_b64_encode_from(ops, bin, ctx_ref, + byte_offset); + } break; } default: diff --git a/src/main/client/cdt_operation_utils.c b/src/main/client/cdt_operation_utils.c index 8a6fc34094..ca5125ca78 100644 --- a/src/main/client/cdt_operation_utils.c +++ b/src/main/client/cdt_operation_utils.c @@ -266,10 +266,10 @@ as_status get_enum_from_py_dict( max_bound, is_optional, true, int_was_found); } -as_status get_int_from_py_dict(as_error *err, PyObject *py_dict, const char *key, int *int_pointer, bool is_optional) +as_status get_int_from_py_dict(as_error *err, PyObject *py_dict, const char *key, int *int_pointer, bool is_optional, bool *int_was_found) { return get_bound_int_from_py_dict(err, py_dict, key, int_pointer, INT_MIN, - INT_MAX, is_optional, false, NULL); + INT_MAX, is_optional, false, int_was_found); } // clang-format on diff --git a/src/main/client/hll_operate.c b/src/main/client/hll_operate.c index 8fd627052f..b11b8568d2 100644 --- a/src/main/client/hll_operate.c +++ b/src/main/client/hll_operate.c @@ -178,12 +178,12 @@ static as_status add_op_hll_add(AerospikeClient *self, as_error *err, char *bin, as_hll_policy *hll_policy_p = &hll_policy; if (get_int_from_py_dict(err, op_dict, AS_PY_HLL_INDEX_BIT_COUNT, - &index_bit_count, false) != AEROSPIKE_OK) { + &index_bit_count, false, NULL) != AEROSPIKE_OK) { goto cleanup; } if (get_int_from_py_dict(err, op_dict, AS_PY_HLL_MH_BIT_COUNT_KEY, - &mh_bit_count, false) != AEROSPIKE_OK) { + &mh_bit_count, false, NULL) != AEROSPIKE_OK) { goto cleanup; } @@ -242,12 +242,12 @@ static as_status add_op_hll_init(AerospikeClient *self, as_error *err, as_hll_policy *hll_policy_p = &hll_policy; if (get_int_from_py_dict(err, op_dict, AS_PY_HLL_INDEX_BIT_COUNT, - &index_bit_count, false) != AEROSPIKE_OK) { + &index_bit_count, false, NULL) != AEROSPIKE_OK) { goto cleanup; } if (get_int_from_py_dict(err, op_dict, AS_PY_HLL_MH_BIT_COUNT_KEY, - &mh_bit_count, false) != AEROSPIKE_OK) { + &mh_bit_count, false, NULL) != AEROSPIKE_OK) { goto cleanup; } @@ -318,7 +318,7 @@ static as_status add_op_hll_fold(AerospikeClient *self, as_error *err, int index_bit_count; if (get_int_from_py_dict(err, op_dict, AS_PY_HLL_INDEX_BIT_COUNT, - &index_bit_count, false) != AEROSPIKE_OK) { + &index_bit_count, false, NULL) != AEROSPIKE_OK) { goto cleanup; } diff --git a/src/main/conversions.c b/src/main/conversions.c index 5119037105..383256a645 100644 --- a/src/main/conversions.c +++ b/src/main/conversions.c @@ -2734,7 +2734,7 @@ as_status as_cdt_ctx_add_from_pyobject(AerospikeClient *self, as_error *err, int pad = 0; status = get_int_from_py_dict(err, py_extra_args, CDT_CTX_PAD_KEY, &pad, - false); + false, NULL); if (status != AEROSPIKE_OK) { goto CLEANUP_PY_EXTRA_ARGS; } From 28195bbc449ded1988a83e887f95a5b33a55a16e Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:53:18 -0700 Subject: [PATCH 086/112] fix: list_join op separator param not being honored --- aerospike_helpers/operations/list_operations.py | 1 + src/main/client/operate_helper.c | 14 ++++++++++++-- test/new_tests/test_new_list_operation_helpers.py | 13 +++++++------ 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/aerospike_helpers/operations/list_operations.py b/aerospike_helpers/operations/list_operations.py index ecf292c8ed..a71d4ed49e 100755 --- a/aerospike_helpers/operations/list_operations.py +++ b/aerospike_helpers/operations/list_operations.py @@ -1197,6 +1197,7 @@ def list_join( op_dict = { OP_KEY: aerospike._OP_LIST_JOIN, BIN_KEY: bin_name, + "separator": separator } if ctx: op_dict[CTX_KEY] = ctx diff --git a/src/main/client/operate_helper.c b/src/main/client/operate_helper.c index b776583b0d..a0688520ef 100644 --- a/src/main/client/operate_helper.c +++ b/src/main/client/operate_helper.c @@ -373,6 +373,7 @@ as_status as_operations_add_from_pyobject(AerospikeClient *self, as_error *err, case OP_STRING_REGEX_REPLACE: case OP_STRING_APPEND: case OP_STRING_PREPEND: + case OP_LIST_JOIN: switch (operation_code) { case OP_STRING_FIND: case OP_STRING_CONTAINS: @@ -387,6 +388,7 @@ as_status as_operations_add_from_pyobject(AerospikeClient *self, as_error *err, str_attr_key = "suffix"; break; case OP_STRING_SPLIT_SEPARATOR: + case OP_LIST_JOIN: str_attr_key = "separator"; break; case OP_STRING_REGEX_COMPARE: @@ -405,8 +407,10 @@ as_status as_operations_add_from_pyobject(AerospikeClient *self, as_error *err, break; } + bool is_str_attr_value1_optional = operation_code == OP_LIST_JOIN; if (get_str(err, str_attr_key, op_dict, unicodeStrVector, - &str_attr_value1, false) != AEROSPIKE_OK) { + &str_attr_value1, + is_str_attr_value1_optional) != AEROSPIKE_OK) { goto CLEANUP_VAL2_ON_ERROR; } } @@ -633,7 +637,13 @@ as_status as_operations_add_from_pyobject(AerospikeClient *self, as_error *err, } break; case OP_LIST_JOIN: - success = as_operations_list_join(ops, bin, ctx_ref); + if (str_attr_value1) { + success = as_operations_list_join_separator(ops, bin, ctx_ref, + str_attr_value1); + } + else { + success = as_operations_list_join(ops, bin, ctx_ref); + } break; case OP_STRING_STRLEN: success = as_operations_string_strlen(ops, bin, ctx_ref); diff --git a/test/new_tests/test_new_list_operation_helpers.py b/test/new_tests/test_new_list_operation_helpers.py index 955b1108d0..9618025e5a 100644 --- a/test/new_tests/test_new_list_operation_helpers.py +++ b/test/new_tests/test_new_list_operation_helpers.py @@ -484,17 +484,18 @@ def test_list_create_neg(self, list_order, pad, persist_index): self.as_connection.operate(self.test_key, [operation]) @pytest.mark.parametrize( - "bin_name, expected", + "bin_name, separator, expected", [ - ("list_of_strs", "abc"), + ("list_of_strs", None, "abc"), + ("list_of_strs", "#", "a#b#c"), # Edge cases - ("empty_list", ""), - ("list_of_one_str", "a") + ("empty_list", None, ""), + ("list_of_one_str", None, "a") ] ) - def test_list_join(self, bin_name, expected: str): + def test_list_join(self, bin_name: str, separator: str | None, expected: str): ops = [ - list_operations.list_join(bin_name=bin_name) + list_operations.list_join(bin_name=bin_name, separator=separator) ] _, _, bins = self.as_connection.operate(self.test_key, ops) assert bins[bin_name] == expected From 03efbfc2d80b57bcfc854d1d09ac578a9d5834f1 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:54:41 -0700 Subject: [PATCH 087/112] refactor: use BYTE_OFFSET_KEY instead of hardcoded string for bit_b64_encode op --- aerospike_helpers/operations/bitwise_operations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aerospike_helpers/operations/bitwise_operations.py b/aerospike_helpers/operations/bitwise_operations.py index 86ab2d1bc7..cf442f942d 100644 --- a/aerospike_helpers/operations/bitwise_operations.py +++ b/aerospike_helpers/operations/bitwise_operations.py @@ -679,7 +679,7 @@ def bit_b64_encode( return { OP_KEY: aerospike._OP_BIT_B64_ENCODE, BIN_KEY: bin_name, - "byte_offset": byte_offset, + BYTE_OFFSET_KEY: byte_offset, BYTE_SIZE_KEY: byte_size, "invert_size": invert_size, "ctx": ctx From d81924f705a592dfe69a3d338cc5b047635acbf6 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:29:12 -0700 Subject: [PATCH 088/112] tests: gate 8.1.3 tests properly to avoid failing on older server versions --- test/new_tests/test_bitwise_operations.py | 11 ++++++++--- test/new_tests/test_expressions_bit.py | 9 +++++++-- test/new_tests/test_expressions_list.py | 1 + test/new_tests/test_new_list_operation_helpers.py | 3 +++ 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/test/new_tests/test_bitwise_operations.py b/test/new_tests/test_bitwise_operations.py index 0f1d99404b..16cdb01604 100644 --- a/test/new_tests/test_bitwise_operations.py +++ b/test/new_tests/test_bitwise_operations.py @@ -3,6 +3,7 @@ import random from aerospike import exception as e from aerospike_helpers.operations import bitwise_operations +from .conftest import expect_server_version_earlier_than_8_1_3_to_fail import aerospike from contextlib import nullcontext @@ -1670,13 +1671,17 @@ def test_bit_xor_with_policy(self): ), ] ) + @expect_server_version_earlier_than_8_1_3_to_fail + @pytest.mark.usefixtures("expect_earlier_than_server_version_to_fail") def test_bit_b64_encode(self, kwargs, expected): ops = [ bitwise_operations.bit_b64_encode(**kwargs) ] - _, _, bins = self.as_connection.operate(self.test_key, ops) - bin_name = kwargs["bin_name"] - assert bins[bin_name] == expected + with self.expected_context_for_pos_tests: + _, _, bins = self.as_connection.operate(self.test_key, ops) + + bin_name = kwargs["bin_name"] + assert bins[bin_name] == expected BIN_NAME_FOR_INVALID_PARAMS = "bitwise0" diff --git a/test/new_tests/test_expressions_bit.py b/test/new_tests/test_expressions_bit.py index b7405d2201..dc498b28a9 100644 --- a/test/new_tests/test_expressions_bit.py +++ b/test/new_tests/test_expressions_bit.py @@ -25,6 +25,7 @@ Eq, ) from aerospike_helpers.operations import expression_operations as expr_ops +from .conftest import expect_server_version_earlier_than_8_1_3_to_fail import aerospike from . import as_errors @@ -357,6 +358,8 @@ def test_bit_get_int_pos(self, bit_offset, bit_size, bin, expected): (0, None, base64.b64encode(BASE64_BYTES).decode("utf-8")) ] ) + @expect_server_version_earlier_than_8_1_3_to_fail + @pytest.mark.usefixtures("expect_earlier_than_server_version_to_fail") def test_bit_b64_encode(self, byte_offset, byte_size, expected): bin = "base64_bytes" expr = BitB64Encode(byte_offset, byte_size, bin).compile() @@ -364,5 +367,7 @@ def test_bit_b64_encode(self, byte_offset, byte_size, expected): expr_ops.expression_read(bin, expr) ] key = ("test", "demo", 1) - _, _, bins = self.as_connection.operate(key, ops) - assert bins[bin] == expected + + with self.expected_context_for_pos_tests: + _, _, bins = self.as_connection.operate(key, ops) + assert bins[bin] == expected diff --git a/test/new_tests/test_expressions_list.py b/test/new_tests/test_expressions_list.py index d8fccb37e1..5b69b413ff 100644 --- a/test/new_tests/test_expressions_list.py +++ b/test/new_tests/test_expressions_list.py @@ -957,6 +957,7 @@ def test_list_expr_inverted(self, bin_name: str, expr, expected): ("list_of_one_str", "b"), ] ) + @expect_server_version_earlier_than_8_1_3_to_fail def test_list_join(self, bin_name, expected): expr = ListJoin(None, None, bin_name).compile() ops = [ diff --git a/test/new_tests/test_new_list_operation_helpers.py b/test/new_tests/test_new_list_operation_helpers.py index 9618025e5a..aa9628e794 100644 --- a/test/new_tests/test_new_list_operation_helpers.py +++ b/test/new_tests/test_new_list_operation_helpers.py @@ -2,6 +2,7 @@ import pytest from aerospike import exception as e from aerospike_helpers.operations import list_operations +from .conftest import expect_server_version_earlier_than_8_1_3_to_fail import aerospike @@ -493,6 +494,8 @@ def test_list_create_neg(self, list_order, pad, persist_index): ("list_of_one_str", None, "a") ] ) + @expect_server_version_earlier_than_8_1_3_to_fail + @pytest.mark.usefixtures("expect_earlier_than_server_version_to_fail") def test_list_join(self, bin_name: str, separator: str | None, expected: str): ops = [ list_operations.list_join(bin_name=bin_name, separator=separator) From 4239f8c37db7f63b27357a481a9ae60e5c1fef6a Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:40:16 -0700 Subject: [PATCH 089/112] feat: add invert_size parameter to BitB64Encode expression --- aerospike-client-c | 2 +- aerospike_helpers/expressions/bitwise.py | 4 +++- src/main/client/bit_operate.c | 2 +- src/main/convert_expressions.c | 7 ++++--- test/new_tests/test_expressions_bit.py | 8 ++++---- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/aerospike-client-c b/aerospike-client-c index 0659f983cc..a68355ea8a 160000 --- a/aerospike-client-c +++ b/aerospike-client-c @@ -1 +1 @@ -Subproject commit 0659f983cc90d1318374a8942659f7e2218131c3 +Subproject commit a68355ea8a8a53bb24773831078ea27bc76aee26 diff --git a/aerospike_helpers/expressions/bitwise.py b/aerospike_helpers/expressions/bitwise.py index 35dd5018a2..cba3f07ff8 100644 --- a/aerospike_helpers/expressions/bitwise.py +++ b/aerospike_helpers/expressions/bitwise.py @@ -734,13 +734,14 @@ def __init__( self, byte_offset: int, byte_size: int | None, + invert_size: bool, bin: "TypeBinName", - # TODO: missing invert_size param. ): """ Args: byte_offset (int): Byte offset into the blob. Negative values count from the end. byte_size (int): Number of bytes to encode. + invert_size (bool): When :py:obj:`True`, counts back from the blob end instead of from ``byte_offset``. bin (TypeBinName): A :class:`~aerospike_helpers.expressions.base.BlobBin` expression. :return: String expression. @@ -752,6 +753,7 @@ def __init__( self._children = ( byte_offset, byte_size, + invert_size, bin ) else: diff --git a/src/main/client/bit_operate.c b/src/main/client/bit_operate.c index c035e1fb0e..79cffeef0b 100644 --- a/src/main/client/bit_operate.c +++ b/src/main/client/bit_operate.c @@ -355,7 +355,7 @@ as_status add_new_bit_op(AerospikeClient *self, as_error *err, } if (was_byte_size_found) { - success = as_operations_bit_b64_encode_range_invert( + success = as_operations_bit_b64_encode_range( ops, bin, ctx_ref, byte_offset, byte_size, invert_size); } else { diff --git a/src/main/convert_expressions.c b/src/main/convert_expressions.c index cdee70ad41..3acec97deb 100644 --- a/src/main/convert_expressions.c +++ b/src/main/convert_expressions.c @@ -411,7 +411,7 @@ static as_status get_expr_size(int *size_to_alloc, int *intermediate_exprs_size, [OP_BIT_GET_INT] = EXP_SZ(as_exp_bit_get_int(NIL, NIL, 0, NIL)), [OP_BIT_B64_ENCODE] = EXP_SZ(as_exp_bit_b64_encode(NIL)), [OP_BIT_B64_ENCODE_RANGE] = - EXP_SZ(as_exp_bit_b64_encode_range(NIL, NIL, NIL)), + EXP_SZ(as_exp_bit_b64_encode_range(NIL, NIL, true, NIL)), [OP_HLL_INIT] = EXP_SZ(as_exp_hll_init_mh(NULL, 0, 0, NIL)), [OP_HLL_ADD] = EXP_SZ(as_exp_hll_add_mh(NULL, NIL, 0, 0, NIL)), [OP_HLL_GET_COUNT] = EXP_SZ(as_exp_hll_update(NULL, NIL, NIL)), @@ -1574,9 +1574,10 @@ add_expr_macros(AerospikeClient *self, as_static_pool *static_pool, case OP_BIT_B64_ENCODE: APPEND_ARRAY(1, as_exp_bit_b64_encode(NIL)); break; - case OP_BIT_B64_ENCODE_RANGE: - APPEND_ARRAY(3, as_exp_bit_b64_encode_range(NIL, NIL, NIL)); + case OP_BIT_B64_ENCODE_RANGE: { + APPEND_ARRAY(4, as_exp_bit_b64_encode_range(NIL, NIL, false, NIL)); break; + } case OP_HLL_INIT: // NOTE: this case covers HLLInit and HLLInitMH. APPEND_ARRAY( 4, diff --git a/test/new_tests/test_expressions_bit.py b/test/new_tests/test_expressions_bit.py index dc498b28a9..489822e1d8 100644 --- a/test/new_tests/test_expressions_bit.py +++ b/test/new_tests/test_expressions_bit.py @@ -353,16 +353,16 @@ def test_bit_get_int_pos(self, bit_offset, bit_size, bin, expected): ) @pytest.mark.parametrize( - "byte_offset, byte_size, expected", + "byte_offset, byte_size, invert_size, expected", [ - (0, None, base64.b64encode(BASE64_BYTES).decode("utf-8")) + (0, None, False, base64.b64encode(BASE64_BYTES).decode("utf-8")) ] ) @expect_server_version_earlier_than_8_1_3_to_fail @pytest.mark.usefixtures("expect_earlier_than_server_version_to_fail") - def test_bit_b64_encode(self, byte_offset, byte_size, expected): + def test_bit_b64_encode(self, byte_offset, byte_size, invert_size, expected): bin = "base64_bytes" - expr = BitB64Encode(byte_offset, byte_size, bin).compile() + expr = BitB64Encode(byte_offset, byte_size, invert_size, bin).compile() ops = [ expr_ops.expression_read(bin, expr) ] From 440c8564df62882740fe784f57abdf34d782bc4f Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:53:09 -0700 Subject: [PATCH 090/112] tests: address missing import --- test/new_tests/test_expressions_list.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/new_tests/test_expressions_list.py b/test/new_tests/test_expressions_list.py index 5b69b413ff..6ecd857b58 100644 --- a/test/new_tests/test_expressions_list.py +++ b/test/new_tests/test_expressions_list.py @@ -43,6 +43,7 @@ ResultType, Val ) +from .conftest import expect_server_version_earlier_than_8_1_3_to_fail import aerospike from . import as_errors From 52d6ed7d469647e92b3c1b9a7c0e29c47940bf24 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:54:59 -0700 Subject: [PATCH 091/112] test: refactor test_string_operations.py by applying indirect fixture params at the class level to avoid doing it for each test case. --- test/new_tests/test_string_operations.py | 28 +----------------------- 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/test/new_tests/test_string_operations.py b/test/new_tests/test_string_operations.py index 67dc2120f8..fd0176d581 100644 --- a/test/new_tests/test_string_operations.py +++ b/test/new_tests/test_string_operations.py @@ -12,6 +12,7 @@ KEY = (TEST_NS, TEST_SET, 1) +@expect_server_version_earlier_than_8_1_3_to_fail class TestStringOperations: @pytest.fixture(autouse=True) def setup(self, request, as_connection, expect_earlier_than_server_version_to_fail): @@ -42,7 +43,6 @@ def setup(self, request, as_connection, expect_earlier_than_server_version_to_fa ) @root_level_and_nested_str - @expect_server_version_earlier_than_8_1_3_to_fail @pytest.mark.parametrize( "op_method, kwargs, expected_result", [ @@ -75,7 +75,6 @@ def test_string_read_op_on_str_value(self, op_method, bin_name: str, kwargs_with _, _, bins = self.as_connection.operate(KEY, ops) assert bins[bin_name] == expected_result - @expect_server_version_earlier_than_8_1_3_to_fail def test_find_not_found(self): ops = [ str_ops.find(bin_name=STR_BIN_NAME, needle=NOT_IN_EXAMPLE_STR) @@ -85,7 +84,6 @@ def test_find_not_found(self): assert bins[STR_BIN_NAME] == -1 - @expect_server_version_earlier_than_8_1_3_to_fail def test_contains_not_found(self): ops = [ str_ops.contains(bin_name=STR_BIN_NAME, needle=NOT_IN_EXAMPLE_STR) @@ -95,7 +93,6 @@ def test_contains_not_found(self): assert bins[STR_BIN_NAME] is False - @expect_server_version_earlier_than_8_1_3_to_fail def test_starts_with_returns_false(self): ops = [ str_ops.starts_with(bin_name=STR_BIN_NAME, prefix=NOT_IN_EXAMPLE_STR) @@ -105,7 +102,6 @@ def test_starts_with_returns_false(self): assert bins[STR_BIN_NAME] is False - @expect_server_version_earlier_than_8_1_3_to_fail def test_to_integer(self): ops = [ str_ops.to_integer(bin_name=STR_WITH_INT_BIN_NAME) @@ -122,7 +118,6 @@ def test_to_integer(self): str_ops.to_double ] ) - @expect_server_version_earlier_than_8_1_3_to_fail def test_to_numeric_fail(self, op): ops = [ op(bin_name=STR_BIN_NAME) @@ -131,7 +126,6 @@ def test_to_numeric_fail(self, op): with pytest.raises(e.ServerError): self.as_connection.operate(KEY, ops) - @expect_server_version_earlier_than_8_1_3_to_fail def test_to_double(self): ops = [ str_ops.to_double(bin_name=STR_WITH_DOUBLE_BIN_NAME) @@ -141,7 +135,6 @@ def test_to_double(self): assert bins[STR_WITH_DOUBLE_BIN_NAME] == float(STRING_WITH_DOUBLE) - @expect_server_version_earlier_than_8_1_3_to_fail def test_byte_length_for_multibyte_codepoint(self): ops = [ str_ops.byte_length(bin_name=MULTIBYTE_CODEPOINT_BIN_NAME) @@ -158,7 +151,6 @@ def test_byte_length_for_multibyte_codepoint(self): (STR_WITH_DOUBLE_BIN_NAME, True), ] ) - @expect_server_version_earlier_than_8_1_3_to_fail def test_is_numeric(self, bin_name: str, expected_result: bool): ops = [ str_ops.is_numeric(bin_name=bin_name) @@ -179,7 +171,6 @@ def test_is_numeric(self, bin_name: str, expected_result: bool): (NumericType.FLOAT, STR_WITH_INT_BIN_NAME, False) ] ) - @expect_server_version_earlier_than_8_1_3_to_fail def test_numeric_type(self, numeric_type: NumericType, bin_name: str, expected_result: bool): ops = [ str_ops.is_numeric(bin_name=bin_name, numeric_type=numeric_type) @@ -195,7 +186,6 @@ def test_numeric_type(self, numeric_type: NumericType, bin_name: str, expected_r (UPPERCASE_STR_BIN_NAME, True) ] ) - @expect_server_version_earlier_than_8_1_3_to_fail def test_is_upper(self, bin_name: str, expected_result: bool): ops = [ str_ops.is_upper(bin_name=bin_name) @@ -211,7 +201,6 @@ def test_is_upper(self, bin_name: str, expected_result: bool): (UPPERCASE_STR_BIN_NAME, False) ] ) - @expect_server_version_earlier_than_8_1_3_to_fail def test_is_lower(self, bin_name: str, expected_result: bool): ops = [ str_ops.is_lower(bin_name=bin_name) @@ -228,7 +217,6 @@ def test_is_lower(self, bin_name: str, expected_result: bool): "," ] ) - @expect_server_version_earlier_than_8_1_3_to_fail def test_split_with_separator(self, separator: str): ops = [ str_ops.split_separator(bin_name=STR_WITH_DOUBLE_BIN_NAME, separator=separator) @@ -242,7 +230,6 @@ def test_split_with_separator(self, separator: str): else: assert bins[STR_WITH_DOUBLE_BIN_NAME] == [STRING_WITH_DOUBLE] - @expect_server_version_earlier_than_8_1_3_to_fail def test_base64_decode(self): ops = [ str_ops.base64_decode(bin_name=BASE64_ENCODED_BIN_NAME) @@ -260,7 +247,6 @@ def test_base64_decode(self): ("π", False) ] ) - @expect_server_version_earlier_than_8_1_3_to_fail def test_regex_compare(self, pattern: str, expected_result: bool): ops = [ str_ops.regex_compare(bin_name=MULTIBYTE_CODEPOINT_BIN_NAME, pattern=pattern) @@ -271,7 +257,6 @@ def test_regex_compare(self, pattern: str, expected_result: bool): assert bins[MULTIBYTE_CODEPOINT_BIN_NAME] is expected_result - @expect_server_version_earlier_than_8_1_3_to_fail @pytest.mark.parametrize( "bin_name, expected_result", [ @@ -327,7 +312,6 @@ def add_read_op(self, ops, bin_name): ) @root_level_and_nested_str @kwargs_policy - @expect_server_version_earlier_than_8_1_3_to_fail def test_string_write_op_on_str_value(self, op, expected_value: str, kwargs: dict, kwargs_policy: dict, bin_name: str, kwargs_with_ctx: dict): ops = [ op(bin_name=bin_name, **kwargs, **kwargs_policy, **kwargs_with_ctx) @@ -355,7 +339,6 @@ def test_concat_with_non_str_in_list(self): ] ) @kwargs_policy - @expect_server_version_earlier_than_8_1_3_to_fail def test_lower(self, kwargs_policy: dict, bin_name: str, expected_result: str): ops = [ str_ops.lower(bin_name=bin_name, **kwargs_policy) @@ -368,7 +351,6 @@ def test_lower(self, kwargs_policy: dict, bin_name: str, expected_result: str): assert bins[bin_name] == expected_result @kwargs_policy - @expect_server_version_earlier_than_8_1_3_to_fail def test_casefold(self, kwargs_policy: dict): ops = [ str_ops.casefold(bin_name=MULTIBYTE_CODEPOINT_BIN_NAME, **kwargs_policy) @@ -381,7 +363,6 @@ def test_casefold(self, kwargs_policy: dict): assert bins[MULTIBYTE_CODEPOINT_BIN_NAME] == MULTIBYTE_CODEPOINT.casefold() @kwargs_policy - @expect_server_version_earlier_than_8_1_3_to_fail def test_normalize_nfc(self, kwargs_policy): ops = [ str_ops.normalize_nfc(bin_name=MULTIBYTE_CODEPOINT_BIN_NAME, **kwargs_policy) @@ -394,7 +375,6 @@ def test_normalize_nfc(self, kwargs_policy): assert bins[MULTIBYTE_CODEPOINT_BIN_NAME] == NORMALIZED_CODEPOINT @kwargs_policy - @expect_server_version_earlier_than_8_1_3_to_fail def test_trim_start(self, kwargs_policy): ops = [ str_ops.trim_start(bin_name=SURROUNDING_WHITESPACE_BIN_NAME, **kwargs_policy) @@ -407,7 +387,6 @@ def test_trim_start(self, kwargs_policy): assert bins[SURROUNDING_WHITESPACE_BIN_NAME] == EXAMPLE_STR_WITH_SURROUNDING_WHITESPACE[1:] @kwargs_policy - @expect_server_version_earlier_than_8_1_3_to_fail def test_trim_end(self, kwargs_policy): ops = [ str_ops.trim_end(bin_name=SURROUNDING_WHITESPACE_BIN_NAME, **kwargs_policy) @@ -421,7 +400,6 @@ def test_trim_end(self, kwargs_policy): @kwargs_policy - @expect_server_version_earlier_than_8_1_3_to_fail def test_trim(self, kwargs_policy): ops = [ str_ops.trim(bin_name=SURROUNDING_WHITESPACE_BIN_NAME, **kwargs_policy) @@ -435,7 +413,6 @@ def test_trim(self, kwargs_policy): @kwargs_policy @root_level_and_nested_str - @expect_server_version_earlier_than_8_1_3_to_fail def test_regex_replace(self, kwargs_policy: dict, bin_name: str, kwargs_with_ctx: dict): PATTERN = "asdf" ops = [ @@ -459,7 +436,6 @@ def test_regex_replace(self, kwargs_policy: dict, bin_name: str, kwargs_with_ctx (STR_BIN_NAME, RegexFlags.GLOBAL, "asdf", NEW_STR * 2) ] ) - @expect_server_version_earlier_than_8_1_3_to_fail def test_regex_flags(self, bin_name: str, regex_flags: RegexFlags, pattern: str, expected_results: str): ops = [ str_ops.regex_replace(bin_name=bin_name, pattern=pattern, replacement=NEW_STR, regex_flags=regex_flags) @@ -471,7 +447,6 @@ def test_regex_flags(self, bin_name: str, regex_flags: RegexFlags, pattern: str, assert bins[bin_name] == expected_results - @expect_server_version_earlier_than_8_1_3_to_fail def test_string_policy_no_fail(self): policy = StringPolicy(write_flags=WriteFlags.NO_FAIL) ops = [ @@ -512,7 +487,6 @@ def test_string_policy_no_fail(self): (str_ops.to_string, {}, False), ] ) - @expect_server_version_earlier_than_8_1_3_to_fail def test_string_ops_on_nonexistent_bin(self, op, kwargs: dict, creates_bin: bool): ops = [ op(bin_name=NON_EXISTENT_BIN_NAME, **kwargs), From 1a6cc0202e4ee44da662138bec882c738dd96cea Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:26:41 -0700 Subject: [PATCH 092/112] fix: add test cases for CREATE_ONLY and UPDATE_ONLY --- aerospike-stubs/exception.pyi | 3 +++ test/new_tests/test_string_operations.py | 25 +++++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/aerospike-stubs/exception.pyi b/aerospike-stubs/exception.pyi index 48021b69c8..608c585070 100644 --- a/aerospike-stubs/exception.pyi +++ b/aerospike-stubs/exception.pyi @@ -94,6 +94,9 @@ class BinNameError(RecordError): class BinIncompatibleType(RecordError): pass +class BinExistsError(RecordError): + pass + class IndexError(ServerError): name: Union[str, None] diff --git a/test/new_tests/test_string_operations.py b/test/new_tests/test_string_operations.py index fd0176d581..e4e2034e74 100644 --- a/test/new_tests/test_string_operations.py +++ b/test/new_tests/test_string_operations.py @@ -7,7 +7,7 @@ from aerospike import exception as e from aerospike_helpers import cdt_ctx -from .conftest import expect_server_version_earlier_than_8_1_3_to_fail, TEST_NS, TEST_SET +from .conftest import expect_server_version_earlier_than_8_1_3_to_fail, TEST_NS, TEST_SET, TestBaseClass from .string_helpers import * KEY = (TEST_NS, TEST_SET, 1) @@ -447,6 +447,29 @@ def test_regex_flags(self, bin_name: str, regex_flags: RegexFlags, pattern: str, assert bins[bin_name] == expected_results + def test_string_policy_create_only(self): + policy = StringPolicy(write_flags=WriteFlags.CREATE_ONLY) + ops = [ + str_ops.insert(bin_name=STR_BIN_NAME, index=0, value="a", policy=policy) + ] + + if (TestBaseClass.major_ver, TestBaseClass.minor_ver, TestBaseClass.patch_ver) < (8, 1, 3): + expected_exc = e.InvalidRequest + else: + expected_exc = e.BinExistsError + + with pytest.raises(expected_exc): + self.as_connection.operate(KEY, ops) + + def test_string_policy_update_only(self): + policy = StringPolicy(write_flags=WriteFlags.UPDATE_ONLY) + ops = [ + str_ops.insert(bin_name="aaaa", index=0, value="a", policy=policy) + ] + + with pytest.raises(e.InvalidRequest): + self.as_connection.operate(KEY, ops) + def test_string_policy_no_fail(self): policy = StringPolicy(write_flags=WriteFlags.NO_FAIL) ops = [ From e7db0bc7e55ab12fa65a03a37140506cdba76724 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:28:17 -0700 Subject: [PATCH 093/112] test: list join test case passed indirect params to fixture that it didn't request --- test/new_tests/test_expressions_list.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/new_tests/test_expressions_list.py b/test/new_tests/test_expressions_list.py index 6ecd857b58..ae4ea4cc1b 100644 --- a/test/new_tests/test_expressions_list.py +++ b/test/new_tests/test_expressions_list.py @@ -959,6 +959,7 @@ def test_list_expr_inverted(self, bin_name: str, expr, expected): ] ) @expect_server_version_earlier_than_8_1_3_to_fail + @pytest.mark.usefixtures("expect_earlier_than_server_version_to_fail") def test_list_join(self, bin_name, expected): expr = ListJoin(None, None, bin_name).compile() ops = [ From ece9dd9433beeffe5abd30a47cf110f645378c14 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:10:21 -0700 Subject: [PATCH 094/112] test: fix list_join test cases failing on server < 8.1.3 --- test/new_tests/test_expressions_list.py | 5 +++-- test/new_tests/test_new_list_operation_helpers.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/test/new_tests/test_expressions_list.py b/test/new_tests/test_expressions_list.py index ae4ea4cc1b..7e058147ea 100644 --- a/test/new_tests/test_expressions_list.py +++ b/test/new_tests/test_expressions_list.py @@ -966,6 +966,7 @@ def test_list_join(self, bin_name, expected): expr_ops.expression_read(bin_name, expr) ] key = (self.test_ns, self.test_set, 0) - _, _, bins = self.as_connection.operate(key, ops) + with self.expected_context_for_pos_tests: + _, _, bins = self.as_connection.operate(key, ops) - assert bins[bin_name] == expected + assert bins[bin_name] == expected diff --git a/test/new_tests/test_new_list_operation_helpers.py b/test/new_tests/test_new_list_operation_helpers.py index aa9628e794..09062e37b3 100644 --- a/test/new_tests/test_new_list_operation_helpers.py +++ b/test/new_tests/test_new_list_operation_helpers.py @@ -500,8 +500,9 @@ def test_list_join(self, bin_name: str, separator: str | None, expected: str): ops = [ list_operations.list_join(bin_name=bin_name, separator=separator) ] - _, _, bins = self.as_connection.operate(self.test_key, ops) - assert bins[bin_name] == expected + with pytest.raises(self.expected_context_for_pos_tests): + _, _, bins = self.as_connection.operate(self.test_key, ops) + assert bins[bin_name] == expected def test_list_join_fail(self): ops = [ From 297b84c78daa33006c205d66c4afd05feb81d154 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:21:53 -0700 Subject: [PATCH 095/112] test: change test_string_policy_update_only to only expect write op to be a no-op instead of also raising an exception. Currently waiting on product management to decide whether raising an exception is expected behavior --- test/new_tests/test_string_operations.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/test/new_tests/test_string_operations.py b/test/new_tests/test_string_operations.py index e4e2034e74..6fe7113469 100644 --- a/test/new_tests/test_string_operations.py +++ b/test/new_tests/test_string_operations.py @@ -16,15 +16,18 @@ class TestStringOperations: @pytest.fixture(autouse=True) def setup(self, request, as_connection, expect_earlier_than_server_version_to_fail): + try: + self.as_connection.remove(KEY) + except e.RecordNotFound: + pass + self.as_connection.put( key=KEY, - bins=BINS + bins=BINS, ) yield - self.as_connection.remove(KEY) - root_level_and_nested_str = pytest.mark.parametrize( "bin_name, kwargs_with_ctx", [ @@ -466,9 +469,10 @@ def test_string_policy_update_only(self): ops = [ str_ops.insert(bin_name="aaaa", index=0, value="a", policy=policy) ] + self.as_connection.operate(KEY, ops) - with pytest.raises(e.InvalidRequest): - self.as_connection.operate(KEY, ops) + _, _, bins = self.as_connection.get(KEY) + assert "aaaa" not in bins def test_string_policy_no_fail(self): policy = StringPolicy(write_flags=WriteFlags.NO_FAIL) From 23bbdfeeac2c89ffeb16d72a18b94248f691339f Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:24:47 -0700 Subject: [PATCH 096/112] fix: remove debug print --- aerospike_helpers/expressions/bitwise.py | 1 - 1 file changed, 1 deletion(-) diff --git a/aerospike_helpers/expressions/bitwise.py b/aerospike_helpers/expressions/bitwise.py index cba3f07ff8..36899041de 100644 --- a/aerospike_helpers/expressions/bitwise.py +++ b/aerospike_helpers/expressions/bitwise.py @@ -746,7 +746,6 @@ def __init__( :return: String expression. """ - print("test") bin = bin if isinstance(bin, _BaseExpr) else BlobBin(bin) if byte_size: self._op = aerospike._OP_BIT_B64_ENCODE_RANGE From 364dbb68c6166ee83f285fa5c85e6688c1c271f0 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:29:16 -0700 Subject: [PATCH 097/112] refactor: reduce payload size for ListJoin if separator is None --- aerospike_helpers/expressions/list.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/aerospike_helpers/expressions/list.py b/aerospike_helpers/expressions/list.py index be4050eb2c..4415812d76 100644 --- a/aerospike_helpers/expressions/list.py +++ b/aerospike_helpers/expressions/list.py @@ -1344,6 +1344,7 @@ def __init__( self._op = aerospike._OP_LIST_JOIN self._children = (bin if isinstance(bin, _BaseExpr) else ListBin(bin),) - self._fixed = {aerospike._STR_EXP_SEPARATOR_KEY: separator} + if self._op == aerospike._OP_LIST_JOIN_SEPARATOR: + self._fixed = {aerospike._STR_EXP_SEPARATOR_KEY: separator} if ctx is not None: self._fixed[_Keys.CTX_KEY] = ctx From 2bf860aecf341cea6a9502e26bda1b0b856b8021 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:31:30 -0700 Subject: [PATCH 098/112] style: format get_enum_from_py_dict and get_int_from_py_dict to make it easier to compare side-by-side for param order consistency --- src/include/cdt_operation_utils.h | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/include/cdt_operation_utils.h b/src/include/cdt_operation_utils.h index e7d32ecc5a..6679a3c24b 100644 --- a/src/include/cdt_operation_utils.h +++ b/src/include/cdt_operation_utils.h @@ -74,6 +74,8 @@ as_status get_optional_int64_t(as_error *err, const char *key, as_status get_uint64_t(as_error *err, const char *key, PyObject *op_dict, uint64_t *ui64_valptr); +// clang-format off + // This is used to validate enum arguments // In C99, enum values can be between INT_MIN and INT_MAX // So we define our min and max bound parameters as integer types @@ -82,14 +84,27 @@ as_status get_uint64_t(as_error *err, const char *key, PyObject *op_dict, // dereferenced and assigned. // min_bound and max_bound are inclusive. // int_was_found can be NULL. -as_status get_enum_from_py_dict(as_error *err, PyObject *py_dict, - const char *key, int *int_pointer, - int min_bound, int max_bound, bool is_optional, - bool *int_was_found); - -as_status get_int_from_py_dict(as_error *err, PyObject *py_dict, - const char *key, int *int_pointer, - bool is_optional, bool *int_was_found); +as_status get_enum_from_py_dict( + as_error *err, + PyObject *py_dict, + const char *key, + int *int_pointer, + int min_bound, + int max_bound, + bool is_optional, + bool *int_was_found +); + +as_status get_int_from_py_dict( + as_error *err, + PyObject *py_dict, + const char *key, + int *int_pointer, + bool is_optional, + bool *int_was_found +); + +// clang-format on as_status get_list_return_type(as_error *err, PyObject *op_dict, int *return_type); From 8b684c96759738eac377bc72c79967781529fda2 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:38:29 -0700 Subject: [PATCH 099/112] refactor: clear up why there are separate op codes LIST_JOIN and LIST_JOIN_SEPARATOR --- src/include/policy.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/include/policy.h b/src/include/policy.h index 45ba9487b9..8fccd4c0f7 100644 --- a/src/include/policy.h +++ b/src/include/policy.h @@ -42,6 +42,9 @@ enum Aerospike_send_bool_as_values { SEND_BOOL_AS_AS_BOOL, /* default for writing Python bools */ }; +// We have a separate op for list join with a non-None separator argument +// because the expressions for list join with a separator and the one without it +// take up different amounts of space in memory when allocating the list of as_exp_entry's #define LIST_OP_NAMES_EXCEPT_LIST_APPEND \ X(LIST_APPEND_ITEMS), X(LIST_INSERT), X(LIST_INSERT_ITEMS), X(LIST_POP), \ X(LIST_POP_RANGE), X(LIST_REMOVE), X(LIST_REMOVE_RANGE), \ From 21ee478d7c7e544fd0b18097d4b3f9d53eddca08 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:53:52 -0700 Subject: [PATCH 100/112] test: fix invalid test syntax --- test/new_tests/test_new_list_operation_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/new_tests/test_new_list_operation_helpers.py b/test/new_tests/test_new_list_operation_helpers.py index 09062e37b3..6f180c895d 100644 --- a/test/new_tests/test_new_list_operation_helpers.py +++ b/test/new_tests/test_new_list_operation_helpers.py @@ -500,7 +500,7 @@ def test_list_join(self, bin_name: str, separator: str | None, expected: str): ops = [ list_operations.list_join(bin_name=bin_name, separator=separator) ] - with pytest.raises(self.expected_context_for_pos_tests): + with self.expected_context_for_pos_tests: _, _, bins = self.as_connection.operate(self.test_key, ops) assert bins[bin_name] == expected From c0e0f5b074b4e15ed16f638b11e5f5cecaa5b250 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:47:21 -0700 Subject: [PATCH 101/112] test: make test compatible with server < 8.1.3 --- test/new_tests/test_string_operations.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/new_tests/test_string_operations.py b/test/new_tests/test_string_operations.py index 6fe7113469..19c6f51f7e 100644 --- a/test/new_tests/test_string_operations.py +++ b/test/new_tests/test_string_operations.py @@ -465,11 +465,16 @@ def test_string_policy_create_only(self): self.as_connection.operate(KEY, ops) def test_string_policy_update_only(self): + if (TestBaseClass.major_ver, TestBaseClass.minor_ver, TestBaseClass.patch_ver) >= (8, 1, 3): + pytest.xfail("Currently 8.1.3 does not raise a ParamError exception." \ + "This has already been raised to server team") + policy = StringPolicy(write_flags=WriteFlags.UPDATE_ONLY) ops = [ str_ops.insert(bin_name="aaaa", index=0, value="a", policy=policy) ] - self.as_connection.operate(KEY, ops) + with pytest.raises(e.InvalidRequest): + self.as_connection.operate(KEY, ops) _, _, bins = self.as_connection.get(KEY) assert "aaaa" not in bins From 47d2547d8dc922afdd84410c8ed8856ea59d1c63 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:52:05 -0700 Subject: [PATCH 102/112] Have query.results() back up the query's partitions status before performing the query. If it fails, then ensure it fails atomically by restoring the partitions status before the C client query API is called. This makes sure that no records collected during a failed query are dropped silently --- src/main/query/foreach.c | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/main/query/foreach.c b/src/main/query/foreach.c index e458516f33..93bec7826a 100644 --- a/src/main/query/foreach.c +++ b/src/main/query/foreach.c @@ -167,6 +167,9 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, bool is_query_results = py_callback == NULL; + bool is_ps_backed_up = false; + as_partitions_status *backed_up_part_status = NULL; + if (!self || !self->client->as) { as_error_update(&err, AEROSPIKE_ERR_PARAM, "Invalid aerospike object"); goto CLEANUP; @@ -218,6 +221,24 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, goto CLEANUP; } + if (is_query_results) { + if (self->query.parts_all) { + ssize_t num_bytes = + sizeof(as_partitions_status) + + sizeof(as_partition_status) * self->query.parts_all->part_count; + backed_up_part_status = cf_malloc(num_bytes); + if (!backed_up_part_status) { + as_error_update(&err, AEROSPIKE_ERR_CLIENT, + "Failed to back up partitions status"); + goto CLEANUP; + } + + memcpy(backed_up_part_status, self->query.parts_all, num_bytes); + backed_up_part_status->ref_count = 1; + } + is_ps_backed_up = true; + } + Py_BEGIN_ALLOW_THREADS // Invoke operation @@ -266,6 +287,14 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, pthread_mutex_destroy(&data.thread_errors_mutex); if (err.code != AEROSPIKE_OK) { + if (is_ps_backed_up) { + // If parts_all is non-NULL, the cursor has been moved + if (self->query.parts_all) { + as_partitions_status_release(self->query.parts_all); + } + self->query.parts_all = backed_up_part_status; + } + if (is_query_results) { Py_XDECREF(data.py_obj); } @@ -274,6 +303,10 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, return NULL; } + if (backed_up_part_status) { + as_partitions_status_release(backed_up_part_status); + } + if (is_query_results) { return data.py_obj; } From c6d1abe8dddde5406359bff6ec0bea4702a38b87 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:17:33 -0700 Subject: [PATCH 103/112] fix: handle case where paginated query is not currently tracking the partitions status and that partitions status is an empty dictionary and passed again to the query policy --- src/main/convert_partition_filter.c | 178 +++++++++++++++------------- 1 file changed, 96 insertions(+), 82 deletions(-) diff --git a/src/main/convert_partition_filter.c b/src/main/convert_partition_filter.c index a5ff85f8f6..f01043d8d2 100644 --- a/src/main/convert_partition_filter.c +++ b/src/main/convert_partition_filter.c @@ -181,114 +181,128 @@ as_status convert_partition_filter(AerospikeClient *self, &filter->digest); if (parts_stat && PyDict_Check(parts_stat)) { - - PyObject *py_done = - PyDict_GetItemString(parts_stat, PARTITIONS_STATUS_KEY_DONE); - if (!py_done) { - as_error_update(err, AEROSPIKE_ERR_PARAM, - "partition_status dict missing key '%s'", - PARTITIONS_STATUS_KEY_DONE); - goto ERROR_CLEANUP; - } - - if (PyLong_Check(py_done)) { - parts_all->done = (bool)PyLong_AsLong(py_done); - } - else { - as_error_update(err, AEROSPIKE_ERR_PARAM, - "partition_status dict key '%s' must be an int", - PARTITIONS_STATUS_KEY_DONE); - goto ERROR_CLEANUP; - } - - PyObject *py_retry = - PyDict_GetItemString(parts_stat, PARTITIONS_STATUS_KEY_RETRY); - if (!py_retry) { - as_error_update(err, AEROSPIKE_ERR_PARAM, - "partition_status dict missing key '%s'", - PARTITIONS_STATUS_KEY_RETRY); - goto ERROR_CLEANUP; - } - - if (PyLong_Check(py_retry)) { - parts_all->retry = (bool)PyLong_AsLong(py_retry); - } - else { - as_error_update(err, AEROSPIKE_ERR_PARAM, - "partition_status dict key '%s' must be an int", - PARTITIONS_STATUS_KEY_RETRY); + Py_ssize_t partitions_status_size = PyDict_Size(part_stat); + if (PyErr_Occurred()) { + as_error_update( + err, AEROSPIKE_ERR_CLIENT, + "Failed to check if partitions status dictionary is empty"); goto ERROR_CLEANUP; } - - for (uint16_t i = 0; i < parts_all->part_count; i++) { - ps = &parts_all->parts[i]; - - PyObject *key = PyLong_FromLong(ps->part_id); - PyObject *status_dict = PyDict_GetItem(parts_stat, key); - Py_DECREF(key); - - if (!status_dict || !PyTuple_Check(status_dict)) { - as_log_debug("invalid id for part_id: %d", ps->part_id); - continue; + else if (partitions_status_size > 0) { + PyObject *py_done = + PyDict_GetItemString(parts_stat, PARTITIONS_STATUS_KEY_DONE); + if (!py_done) { + as_error_update(err, AEROSPIKE_ERR_PARAM, + "partition_status dict missing key '%s'", + PARTITIONS_STATUS_KEY_DONE); + goto ERROR_CLEANUP; } - PyObject *init = PyTuple_GetItem(status_dict, 1); - if (init && PyLong_Check(init)) { - ps->digest.init = PyLong_AsLong(init); + if (PyLong_Check(py_done)) { + parts_all->done = (bool)PyLong_AsLong(py_done); } - else if (init) { + else { as_error_update(err, AEROSPIKE_ERR_PARAM, - "invalid init for part_id: %d", ps->part_id); + "partition_status dict key '%s' must be an int", + PARTITIONS_STATUS_KEY_DONE); goto ERROR_CLEANUP; } - PyObject *retry = PyTuple_GetItem(status_dict, 2); - if (retry && PyLong_Check(retry)) { - ps->retry = (bool)PyLong_AsLong(retry); - } - else if (retry) { + PyObject *py_retry = + PyDict_GetItemString(parts_stat, PARTITIONS_STATUS_KEY_RETRY); + if (!py_retry) { as_error_update(err, AEROSPIKE_ERR_PARAM, - "invalid retry for part_id: %d", ps->part_id); + "partition_status dict missing key '%s'", + PARTITIONS_STATUS_KEY_RETRY); goto ERROR_CLEANUP; } - PyObject *value = PyTuple_GetItem(status_dict, 3); - if (value && PyByteArray_Check(value)) { - uint8_t *bytes_array = (uint8_t *)PyByteArray_AsString(value); - //uint32_t bytes_array_len = (uint32_t)PyByteArray_Size(value); - memcpy(ps->digest.value, bytes_array, AS_DIGEST_VALUE_SIZE); + if (PyLong_Check(py_retry)) { + parts_all->retry = (bool)PyLong_AsLong(py_retry); } - else if (value) { + else { as_error_update(err, AEROSPIKE_ERR_PARAM, - "invalid digest value for part_id: %d", - ps->part_id); + "partition_status dict key '%s' must be an int", + PARTITIONS_STATUS_KEY_RETRY); goto ERROR_CLEANUP; } - PyObject *py_bval = PyTuple_GetItem(status_dict, 4); + for (uint16_t i = 0; i < parts_all->part_count; i++) { + ps = &parts_all->parts[i]; - // NOTE this is done to maintain backwards compatibility with old 4 elemnt tuples - // used when only partition scans were supported. - if (PyErr_Occurred() && PyErr_ExceptionMatches(PyExc_IndexError)) { - PyErr_Clear(); - } + PyObject *key = PyLong_FromLong(ps->part_id); + PyObject *status_dict = PyDict_GetItem(parts_stat, key); + Py_DECREF(key); + + if (!status_dict || !PyTuple_Check(status_dict)) { + as_log_debug("invalid id for part_id: %d", ps->part_id); + continue; + } + + PyObject *init = PyTuple_GetItem(status_dict, 1); + if (init && PyLong_Check(init)) { + ps->digest.init = PyLong_AsLong(init); + } + else if (init) { + as_error_update(err, AEROSPIKE_ERR_PARAM, + "invalid init for part_id: %d", + ps->part_id); + goto ERROR_CLEANUP; + } + + PyObject *retry = PyTuple_GetItem(status_dict, 2); + if (retry && PyLong_Check(retry)) { + ps->retry = (bool)PyLong_AsLong(retry); + } + else if (retry) { + as_error_update(err, AEROSPIKE_ERR_PARAM, + "invalid retry for part_id: %d", + ps->part_id); + goto ERROR_CLEANUP; + } - if (py_bval && PyLong_Check(py_bval)) { - ps->bval = PyLong_AsUnsignedLongLong(py_bval); + PyObject *value = PyTuple_GetItem(status_dict, 3); + if (value && PyByteArray_Check(value)) { + uint8_t *bytes_array = + (uint8_t *)PyByteArray_AsString(value); + //uint32_t bytes_array_len = (uint32_t)PyByteArray_Size(value); + memcpy(ps->digest.value, bytes_array, AS_DIGEST_VALUE_SIZE); + } + else if (value) { + as_error_update(err, AEROSPIKE_ERR_PARAM, + "invalid digest value for part_id: %d", + ps->part_id); + goto ERROR_CLEANUP; + } + + PyObject *py_bval = PyTuple_GetItem(status_dict, 4); + + // NOTE this is done to maintain backwards compatibility with old 4 elemnt tuples + // used when only partition scans were supported. if (PyErr_Occurred() && - PyErr_ExceptionMatches(PyExc_OverflowError)) { + PyErr_ExceptionMatches(PyExc_IndexError)) { + PyErr_Clear(); + } + + if (py_bval && PyLong_Check(py_bval)) { + ps->bval = PyLong_AsUnsignedLongLong(py_bval); + if (PyErr_Occurred() && + PyErr_ExceptionMatches(PyExc_OverflowError)) { + as_error_update( + err, AEROSPIKE_ERR_PARAM, + "invalid bval for partition id: %d, bval " + "must fit in unsigned long long", + ps->part_id); + goto ERROR_CLEANUP; + } + } + else if (py_bval) { as_error_update(err, AEROSPIKE_ERR_PARAM, - "invalid bval for partition id: %d, bval " - "must fit in unsigned long long", + "invalid bval for part_id: %d", ps->part_id); goto ERROR_CLEANUP; } } - else if (py_bval) { - as_error_update(err, AEROSPIKE_ERR_PARAM, - "invalid bval for part_id: %d", ps->part_id); - goto ERROR_CLEANUP; - } } } From 84f808ce1c6fcadb94f21d2030bfeebdfcba3d1e Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:19:47 -0700 Subject: [PATCH 104/112] fix: address compiler error due to var typo --- src/main/convert_partition_filter.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/convert_partition_filter.c b/src/main/convert_partition_filter.c index f01043d8d2..54bff45800 100644 --- a/src/main/convert_partition_filter.c +++ b/src/main/convert_partition_filter.c @@ -181,7 +181,7 @@ as_status convert_partition_filter(AerospikeClient *self, &filter->digest); if (parts_stat && PyDict_Check(parts_stat)) { - Py_ssize_t partitions_status_size = PyDict_Size(part_stat); + Py_ssize_t partitions_status_size = PyDict_Size(parts_stat); if (PyErr_Occurred()) { as_error_update( err, AEROSPIKE_ERR_CLIENT, From 211dbbce6c53449d8fd97fc51a86845905425e4e Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:00:48 -0700 Subject: [PATCH 105/112] fix: issue where a new paginated query that resumes from a partitions status arg fails and restores the original status of NULL instead of the partitions status arg --- src/main/query/foreach.c | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/src/main/query/foreach.c b/src/main/query/foreach.c index 93bec7826a..19d40f14b8 100644 --- a/src/main/query/foreach.c +++ b/src/main/query/foreach.c @@ -167,7 +167,6 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, bool is_query_results = py_callback == NULL; - bool is_ps_backed_up = false; as_partitions_status *backed_up_part_status = NULL; if (!self || !self->client->as) { @@ -222,21 +221,28 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, } if (is_query_results) { - if (self->query.parts_all) { - ssize_t num_bytes = - sizeof(as_partitions_status) + - sizeof(as_partition_status) * self->query.parts_all->part_count; - backed_up_part_status = cf_malloc(num_bytes); - if (!backed_up_part_status) { - as_error_update(&err, AEROSPIKE_ERR_CLIENT, - "Failed to back up partitions status"); - goto CLEANUP; - } + as_partitions_status *backup_source = NULL; + if (self->query.parts_all == NULL && ps) { + backup_source = ps; + } + else { + backup_source = self->query.parts_all; + } - memcpy(backed_up_part_status, self->query.parts_all, num_bytes); - backed_up_part_status->ref_count = 1; + // There is no user-provided partitions status + // So in case this query.results() call fails, just resume from the query's last known good state + ssize_t num_bytes = + sizeof(as_partitions_status) + + sizeof(as_partition_status) * backup_source->part_count; + backed_up_part_status = cf_malloc(num_bytes); + if (!backed_up_part_status) { + as_error_update(&err, AEROSPIKE_ERR_CLIENT, + "Failed to back up partitions status"); + goto CLEANUP; } - is_ps_backed_up = true; + + memcpy(backed_up_part_status, self->query.parts_all, num_bytes); + backed_up_part_status->ref_count = 1; } Py_BEGIN_ALLOW_THREADS @@ -287,15 +293,12 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, pthread_mutex_destroy(&data.thread_errors_mutex); if (err.code != AEROSPIKE_OK) { - if (is_ps_backed_up) { - // If parts_all is non-NULL, the cursor has been moved + if (is_query_results) { if (self->query.parts_all) { as_partitions_status_release(self->query.parts_all); } self->query.parts_all = backed_up_part_status; - } - if (is_query_results) { Py_XDECREF(data.py_obj); } From 273b6206cd914bcd305bc0da28dd44ef850e620e Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:16:15 -0700 Subject: [PATCH 106/112] fix: issue where query can fail before a backup of the internal partitions status is made and the latter is accidentally reset.. --- src/main/query/foreach.c | 47 +++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/src/main/query/foreach.c b/src/main/query/foreach.c index 19d40f14b8..f1023b2755 100644 --- a/src/main/query/foreach.c +++ b/src/main/query/foreach.c @@ -168,6 +168,7 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, bool is_query_results = py_callback == NULL; as_partitions_status *backed_up_part_status = NULL; + bool is_query_state_backed_up = false; if (!self || !self->client->as) { as_error_update(&err, AEROSPIKE_ERR_PARAM, "Invalid aerospike object"); @@ -222,27 +223,31 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, if (is_query_results) { as_partitions_status *backup_source = NULL; - if (self->query.parts_all == NULL && ps) { - backup_source = ps; - } - else { + if (self->query.parts_all) { + // This query is resuming. + // If there is a user-provided partitions status, the C client will still ignore it in this case. backup_source = self->query.parts_all; } - - // There is no user-provided partitions status - // So in case this query.results() call fails, just resume from the query's last known good state - ssize_t num_bytes = - sizeof(as_partitions_status) + - sizeof(as_partition_status) * backup_source->part_count; - backed_up_part_status = cf_malloc(num_bytes); - if (!backed_up_part_status) { - as_error_update(&err, AEROSPIKE_ERR_CLIENT, - "Failed to back up partitions status"); - goto CLEANUP; + else if (ps) { + // This is a new query object + backup_source = ps; } - memcpy(backed_up_part_status, self->query.parts_all, num_bytes); - backed_up_part_status->ref_count = 1; + if (backup_source) { + ssize_t num_bytes = + sizeof(as_partitions_status) + + sizeof(as_partition_status) * backup_source->part_count; + backed_up_part_status = cf_malloc(num_bytes); + if (!backed_up_part_status) { + as_error_update(&err, AEROSPIKE_ERR_CLIENT, + "Failed to back up partitions status"); + goto CLEANUP; + } + + memcpy(backed_up_part_status, backup_source, num_bytes); + backed_up_part_status->ref_count = 1; + } + is_query_state_backed_up = true; } Py_BEGIN_ALLOW_THREADS @@ -294,10 +299,12 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, if (err.code != AEROSPIKE_OK) { if (is_query_results) { - if (self->query.parts_all) { - as_partitions_status_release(self->query.parts_all); + if (is_query_state_backed_up) { + if (self->query.parts_all) { + as_partitions_status_release(self->query.parts_all); + } + self->query.parts_all = backed_up_part_status; } - self->query.parts_all = backed_up_part_status; Py_XDECREF(data.py_obj); } From 81dc932de06ac4518a72973fdcd82631d79cfcbc Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:33:24 -0700 Subject: [PATCH 107/112] perf: prevent repeated heap allocations for every query.results() call especially during a paginated query. reuse a one-time heap allocated buffer that belongs to the query instance. --- src/include/types.h | 2 ++ src/main/query/foreach.c | 36 +++++++++++++++++++++++++----------- src/main/query/type.c | 6 ++++++ 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/include/types.h b/src/include/types.h index 7397e9aebc..415bd114cc 100644 --- a/src/include/types.h +++ b/src/include/types.h @@ -84,6 +84,8 @@ typedef struct { typedef struct { PyObject_HEAD AerospikeClient *client; as_query query; + as_partitions_status *partitions_status_backup_buffer; + size_t partitions_status_backup_buffer_capacity; UnicodePyObjects u_objs; as_vector *unicodeStrVector; as_static_pool *static_pool; diff --git a/src/main/query/foreach.c b/src/main/query/foreach.c index f1023b2755..1845a5fb37 100644 --- a/src/main/query/foreach.c +++ b/src/main/query/foreach.c @@ -234,17 +234,30 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, } if (backup_source) { - ssize_t num_bytes = + size_t backup_size_bytes = sizeof(as_partitions_status) + sizeof(as_partition_status) * backup_source->part_count; - backed_up_part_status = cf_malloc(num_bytes); - if (!backed_up_part_status) { - as_error_update(&err, AEROSPIKE_ERR_CLIENT, - "Failed to back up partitions status"); - goto CLEANUP; + + if (self->partitions_status_backup_buffer_capacity < + backup_size_bytes) { + as_partitions_status *new_buffer = cf_malloc(backup_size_bytes); + if (!new_buffer) { + as_error_update(&err, AEROSPIKE_ERR_CLIENT, + "Failed to back up partitions status"); + goto CLEANUP; + } + + if (self->partitions_status_backup_buffer) { + as_partitions_status_release( + self->partitions_status_backup_buffer); + } + self->partitions_status_backup_buffer = new_buffer; + self->partitions_status_backup_buffer_capacity = + backup_size_bytes; } - memcpy(backed_up_part_status, backup_source, num_bytes); + backed_up_part_status = self->partitions_status_backup_buffer; + memcpy(backed_up_part_status, backup_source, backup_size_bytes); backed_up_part_status->ref_count = 1; } is_query_state_backed_up = true; @@ -304,6 +317,11 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, as_partitions_status_release(self->query.parts_all); } self->query.parts_all = backed_up_part_status; + if (backed_up_part_status) { + // query.parts_all is now the owner of the backup, not the query's backup buffer ptr + self->partitions_status_backup_buffer = NULL; + self->partitions_status_backup_buffer_capacity = 0; + } } Py_XDECREF(data.py_obj); @@ -313,10 +331,6 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, return NULL; } - if (backed_up_part_status) { - as_partitions_status_release(backed_up_part_status); - } - if (is_query_results) { return data.py_obj; } diff --git a/src/main/query/type.c b/src/main/query/type.c index d0e0ac1c7e..1615c46b24 100644 --- a/src/main/query/type.c +++ b/src/main/query/type.c @@ -166,6 +166,8 @@ AerospikeQuery *AerospikeQuery_Type_New(PyTypeObject *type, Py_INCREF((PyObject *)py_client); self->client = py_client; + self->partitions_status_backup_buffer = NULL; + self->partitions_status_backup_buffer_capacity = 0; return self; } @@ -233,6 +235,10 @@ static void AerospikeQuery_Type_Dealloc(AerospikeQuery *self) as_query_destroy(&self->query); + if (self->partitions_status_backup_buffer) { + as_partitions_status_release(self->partitions_status_backup_buffer); + } + if (self->unicodeStrVector != NULL) { for (unsigned int i = 0; i < self->unicodeStrVector->size; ++i) { free(as_vector_get_ptr(self->unicodeStrVector, i)); From 95d9de71424b90f9407af4c6c7e3d10ab90131aa Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:20:44 -0700 Subject: [PATCH 108/112] refactor: clear up why we reset the partitions status buffer --- src/main/query/foreach.c | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/query/foreach.c b/src/main/query/foreach.c index 1845a5fb37..da248ced72 100644 --- a/src/main/query/foreach.c +++ b/src/main/query/foreach.c @@ -319,6 +319,7 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, self->query.parts_all = backed_up_part_status; if (backed_up_part_status) { // query.parts_all is now the owner of the backup, not the query's backup buffer ptr + // If the query object is destroyed, we don't want to double free the partitions status self->partitions_status_backup_buffer = NULL; self->partitions_status_backup_buffer_capacity = 0; } From 5c229f10530d8081b811b16d5bd32b79b75bb7e5 Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:23:56 -0700 Subject: [PATCH 109/112] refactor: clear up why partitions_status_backup_buffer and partitions_status_backup_buffer_capacity belong to the query instance as private members --- src/include/types.h | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/include/types.h b/src/include/types.h index 415bd114cc..c65d34e66f 100644 --- a/src/include/types.h +++ b/src/include/types.h @@ -84,8 +84,13 @@ typedef struct { typedef struct { PyObject_HEAD AerospikeClient *client; as_query query; + + // This is for query.results() to restore its original partitions status before being called + // We don't want to heap allocate a new partitions status every time we call query.results(). + // And the stack size may be too small to hold the partitions status. as_partitions_status *partitions_status_backup_buffer; size_t partitions_status_backup_buffer_capacity; + UnicodePyObjects u_objs; as_vector *unicodeStrVector; as_static_pool *static_pool; From 7609636af271c5ac309dcfb5cb1774fd3a2c2a9e Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:26:33 -0700 Subject: [PATCH 110/112] refactor: clear up why we have logic to malloc for self->partitions_status_backup_buffer. We never change the number of partitions so this condition might be confusing --- src/main/query/foreach.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/query/foreach.c b/src/main/query/foreach.c index da248ced72..c49bada9fa 100644 --- a/src/main/query/foreach.c +++ b/src/main/query/foreach.c @@ -238,6 +238,8 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, sizeof(as_partitions_status) + sizeof(as_partition_status) * backup_source->part_count; + // The number of partitions is always 4096, but we have this code in case + // self->partitions_status_backup_buffer was never initialized before if (self->partitions_status_backup_buffer_capacity < backup_size_bytes) { as_partitions_status *new_buffer = cf_malloc(backup_size_bytes); From f7083aee1b66b809ffd064fc48a23763b6d7fdac Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:28:43 -0700 Subject: [PATCH 111/112] refactor: clear up why boolean flag exists in case reader finds it redundant --- src/main/query/foreach.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/query/foreach.c b/src/main/query/foreach.c index c49bada9fa..161969f95c 100644 --- a/src/main/query/foreach.c +++ b/src/main/query/foreach.c @@ -168,6 +168,8 @@ PyObject *AerospikeQuery_Foreach_Invoke(AerospikeQuery *self, bool is_query_results = py_callback == NULL; as_partitions_status *backed_up_part_status = NULL; + // query.results() may raise an exception before the backup is made + // so we don't want to reset the query's current partitions status in this case bool is_query_state_backed_up = false; if (!self || !self->client->as) { From 4ee89d174a05da0e31cdc6fbba359f0c71afb2ea Mon Sep 17 00:00:00 2001 From: Julian Nguyen <109386615+juliannguyen4@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:38:09 -0700 Subject: [PATCH 112/112] refactor: use Cursor to minimize line diffs for this file. Github doesn't render the indentation so it's harder to review --- src/main/convert_partition_filter.c | 180 +++++++++++++--------------- 1 file changed, 83 insertions(+), 97 deletions(-) diff --git a/src/main/convert_partition_filter.c b/src/main/convert_partition_filter.c index 54bff45800..d0d6572381 100644 --- a/src/main/convert_partition_filter.c +++ b/src/main/convert_partition_filter.c @@ -180,129 +180,115 @@ as_status convert_partition_filter(AerospikeClient *self, parts_setup(filter->begin, filter->count, //cluster->n_partitions, &filter->digest); - if (parts_stat && PyDict_Check(parts_stat)) { - Py_ssize_t partitions_status_size = PyDict_Size(parts_stat); - if (PyErr_Occurred()) { - as_error_update( - err, AEROSPIKE_ERR_CLIENT, - "Failed to check if partitions status dictionary is empty"); + if (parts_stat && PyDict_Check(parts_stat) && PyDict_Size(parts_stat) > 0) { + + PyObject *py_done = + PyDict_GetItemString(parts_stat, PARTITIONS_STATUS_KEY_DONE); + if (!py_done) { + as_error_update(err, AEROSPIKE_ERR_PARAM, + "partition_status dict missing key '%s'", + PARTITIONS_STATUS_KEY_DONE); goto ERROR_CLEANUP; } - else if (partitions_status_size > 0) { - PyObject *py_done = - PyDict_GetItemString(parts_stat, PARTITIONS_STATUS_KEY_DONE); - if (!py_done) { - as_error_update(err, AEROSPIKE_ERR_PARAM, - "partition_status dict missing key '%s'", - PARTITIONS_STATUS_KEY_DONE); - goto ERROR_CLEANUP; + + if (PyLong_Check(py_done)) { + parts_all->done = (bool)PyLong_AsLong(py_done); + } + else { + as_error_update(err, AEROSPIKE_ERR_PARAM, + "partition_status dict key '%s' must be an int", + PARTITIONS_STATUS_KEY_DONE); + goto ERROR_CLEANUP; + } + + PyObject *py_retry = + PyDict_GetItemString(parts_stat, PARTITIONS_STATUS_KEY_RETRY); + if (!py_retry) { + as_error_update(err, AEROSPIKE_ERR_PARAM, + "partition_status dict missing key '%s'", + PARTITIONS_STATUS_KEY_RETRY); + goto ERROR_CLEANUP; + } + + if (PyLong_Check(py_retry)) { + parts_all->retry = (bool)PyLong_AsLong(py_retry); + } + else { + as_error_update(err, AEROSPIKE_ERR_PARAM, + "partition_status dict key '%s' must be an int", + PARTITIONS_STATUS_KEY_RETRY); + goto ERROR_CLEANUP; + } + + for (uint16_t i = 0; i < parts_all->part_count; i++) { + ps = &parts_all->parts[i]; + + PyObject *key = PyLong_FromLong(ps->part_id); + PyObject *status_dict = PyDict_GetItem(parts_stat, key); + Py_DECREF(key); + + if (!status_dict || !PyTuple_Check(status_dict)) { + as_log_debug("invalid id for part_id: %d", ps->part_id); + continue; } - if (PyLong_Check(py_done)) { - parts_all->done = (bool)PyLong_AsLong(py_done); + PyObject *init = PyTuple_GetItem(status_dict, 1); + if (init && PyLong_Check(init)) { + ps->digest.init = PyLong_AsLong(init); } - else { + else if (init) { as_error_update(err, AEROSPIKE_ERR_PARAM, - "partition_status dict key '%s' must be an int", - PARTITIONS_STATUS_KEY_DONE); + "invalid init for part_id: %d", ps->part_id); goto ERROR_CLEANUP; } - PyObject *py_retry = - PyDict_GetItemString(parts_stat, PARTITIONS_STATUS_KEY_RETRY); - if (!py_retry) { + PyObject *retry = PyTuple_GetItem(status_dict, 2); + if (retry && PyLong_Check(retry)) { + ps->retry = (bool)PyLong_AsLong(retry); + } + else if (retry) { as_error_update(err, AEROSPIKE_ERR_PARAM, - "partition_status dict missing key '%s'", - PARTITIONS_STATUS_KEY_RETRY); + "invalid retry for part_id: %d", ps->part_id); goto ERROR_CLEANUP; } - if (PyLong_Check(py_retry)) { - parts_all->retry = (bool)PyLong_AsLong(py_retry); + PyObject *value = PyTuple_GetItem(status_dict, 3); + if (value && PyByteArray_Check(value)) { + uint8_t *bytes_array = (uint8_t *)PyByteArray_AsString(value); + //uint32_t bytes_array_len = (uint32_t)PyByteArray_Size(value); + memcpy(ps->digest.value, bytes_array, AS_DIGEST_VALUE_SIZE); } - else { + else if (value) { as_error_update(err, AEROSPIKE_ERR_PARAM, - "partition_status dict key '%s' must be an int", - PARTITIONS_STATUS_KEY_RETRY); + "invalid digest value for part_id: %d", + ps->part_id); goto ERROR_CLEANUP; } - for (uint16_t i = 0; i < parts_all->part_count; i++) { - ps = &parts_all->parts[i]; - - PyObject *key = PyLong_FromLong(ps->part_id); - PyObject *status_dict = PyDict_GetItem(parts_stat, key); - Py_DECREF(key); + PyObject *py_bval = PyTuple_GetItem(status_dict, 4); - if (!status_dict || !PyTuple_Check(status_dict)) { - as_log_debug("invalid id for part_id: %d", ps->part_id); - continue; - } - - PyObject *init = PyTuple_GetItem(status_dict, 1); - if (init && PyLong_Check(init)) { - ps->digest.init = PyLong_AsLong(init); - } - else if (init) { - as_error_update(err, AEROSPIKE_ERR_PARAM, - "invalid init for part_id: %d", - ps->part_id); - goto ERROR_CLEANUP; - } - - PyObject *retry = PyTuple_GetItem(status_dict, 2); - if (retry && PyLong_Check(retry)) { - ps->retry = (bool)PyLong_AsLong(retry); - } - else if (retry) { - as_error_update(err, AEROSPIKE_ERR_PARAM, - "invalid retry for part_id: %d", - ps->part_id); - goto ERROR_CLEANUP; - } - - PyObject *value = PyTuple_GetItem(status_dict, 3); - if (value && PyByteArray_Check(value)) { - uint8_t *bytes_array = - (uint8_t *)PyByteArray_AsString(value); - //uint32_t bytes_array_len = (uint32_t)PyByteArray_Size(value); - memcpy(ps->digest.value, bytes_array, AS_DIGEST_VALUE_SIZE); - } - else if (value) { - as_error_update(err, AEROSPIKE_ERR_PARAM, - "invalid digest value for part_id: %d", - ps->part_id); - goto ERROR_CLEANUP; - } - - PyObject *py_bval = PyTuple_GetItem(status_dict, 4); + // NOTE this is done to maintain backwards compatibility with old 4 elemnt tuples + // used when only partition scans were supported. + if (PyErr_Occurred() && PyErr_ExceptionMatches(PyExc_IndexError)) { + PyErr_Clear(); + } - // NOTE this is done to maintain backwards compatibility with old 4 elemnt tuples - // used when only partition scans were supported. + if (py_bval && PyLong_Check(py_bval)) { + ps->bval = PyLong_AsUnsignedLongLong(py_bval); if (PyErr_Occurred() && - PyErr_ExceptionMatches(PyExc_IndexError)) { - PyErr_Clear(); - } - - if (py_bval && PyLong_Check(py_bval)) { - ps->bval = PyLong_AsUnsignedLongLong(py_bval); - if (PyErr_Occurred() && - PyErr_ExceptionMatches(PyExc_OverflowError)) { - as_error_update( - err, AEROSPIKE_ERR_PARAM, - "invalid bval for partition id: %d, bval " - "must fit in unsigned long long", - ps->part_id); - goto ERROR_CLEANUP; - } - } - else if (py_bval) { + PyErr_ExceptionMatches(PyExc_OverflowError)) { as_error_update(err, AEROSPIKE_ERR_PARAM, - "invalid bval for part_id: %d", + "invalid bval for partition id: %d, bval " + "must fit in unsigned long long", ps->part_id); goto ERROR_CLEANUP; } } + else if (py_bval) { + as_error_update(err, AEROSPIKE_ERR_PARAM, + "invalid bval for part_id: %d", ps->part_id); + goto ERROR_CLEANUP; + } } }