Description
When attempting to remove multiple elements from a list bin in Aerospike by specifying multiple indexes in a single operate() call, the removal does not behave as expected. It appears that Aerospike executes the individual remove operations sequentially, and since each removal shifts the list indexes, subsequent removals end up deleting incorrect elements.
Steps to reproduce:
Create a record with a list bin containing multiple string items, e.g. ["AAAA", "BBBB", "CCCC", "DDDD"]
Attempt to remove multiple indexes (e.g. 0 and 1) in a single client.operate() call with ListOperation.removeByIndex()
Verify that the intended items were removed
Expected behaviour:
Both specified indexes remove the corresponding items correctly (e.g. indexes 0 and 1 remove "AAAA" and "BBBB").
Actual behavior:
The first removal shifts the list, so the second removal deletes the wrong item or fails.
Likely cause:
Aerospike performs the list operations sequentially on the server without adjusting for index changes after each removal, causing index shifts to invalidate subsequent removals.
Versions Tested
JDK - 21 Aerospike Client - 6.1.7 & 8.0.0 Aerospike Server - aerospike-server-enterprise:6.2.0.1 (Docker)
Sample Code
import com.aerospike.client.*;
import com.aerospike.client.Record;
import com.aerospike.client.cdt.ListOperation;
import com.aerospike.client.cdt.ListReturnType;
import com.aerospike.client.policy.WritePolicy;
import lombok.extern.log4j.Log4j2;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.io.Serializable;
import java.util.List;
import java.util.stream.Stream;
/**
* @author rkyshz
* @Date 11/09/25
*/
@Log4j2
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@SuppressWarnings({"unchecked"})
class AerospikeListDemo {
private static AerospikeClient aerospikeClient;
private static final String SET_NAME = "list_demo";
private static final String NAMESPACE = "test";
private static final String BIN_NAME = "data";
private static final String PK = "rec001";
private static final List<String> ITEMS = List.of("AAAA", "BBBB", "CCCC", "DDDD");
@BeforeEach
void setup() {
aerospikeClient = new AerospikeClient("localhost", 3000);
aerospikeClient.delete(getDurableDeleteWritePolicy(), new Key(NAMESPACE, SET_NAME, PK));
appendToList(NAMESPACE, SET_NAME, PK, BIN_NAME, ITEMS);
}
@ParameterizedTest
@MethodSource("removalIndicesProvider")
void shouldRemoveItems(List<Integer> indicesToRemove) {
try {
removeFromListByIndex(NAMESPACE, SET_NAME, PK, BIN_NAME, indicesToRemove);
List<String> afterDelete = (List<String>) getRecord(NAMESPACE, SET_NAME, PK).getList("data");
for (int index : indicesToRemove) {
String removedItem = ITEMS.get(index);
Assertions.assertFalse(afterDelete.contains(removedItem),
removedItem + " should not be present");
}
} catch (Exception e) {
log.error(e);
Assertions.fail(e);
}
}
static Stream<Arguments> removalIndicesProvider() {
return Stream.of(
Arguments.of(List.of(0,1)),
Arguments.of(List.of(0,2)),
Arguments.of(List.of(1,3)),
Arguments.of(List.of(1,2)),
Arguments.of(List.of(0,3)),
Arguments.of(List.of(2,3)),
Arguments.of(List.of(0)),
Arguments.of(List.of(3))
);
}
public <T extends Serializable> void appendToList(String namespace, String set, String pk,
String bin, List<T> value) {
aerospikeClient.operate(getDurableDeleteWritePolicy(), new Key(namespace, set, pk),
ListOperation.appendItems(bin,
value.stream().map(Value::get).toList()));
}
public <T extends Serializable> void removeFromListByIndex(String namespace, String set, String pk,
String bin, List<Integer> indices) {
aerospikeClient.operate(getDurableDeleteWritePolicy(), new Key(namespace, set, pk),
indices.stream().map(index -> ListOperation.removeByIndex(bin, index, ListReturnType.VALUE)).toArray(Operation[]::new));
}
public <T extends Serializable> Record getRecord(String namespace, String set, String pk) {
return aerospikeClient.get(null, new Key(namespace, set, pk));
}
private static WritePolicy getDurableDeleteWritePolicy() {
WritePolicy writePolicy = new WritePolicy();
writePolicy.durableDelete = true;
writePolicy.sendKey = true;
return writePolicy;
}
}
Activity
5Hi. This is an incorrect assumption, as Brian suggested. If you want to remove the first two elements in a list, you use the removeByIndexRange. The operations execute one after the other, and a removeBy modifies the copy of the record, so the next operation is done on the modified record. It's similar to transaction on a single record.
So the record is loaded with the list being ["AAAA", "BBBB", "CCCC", "DDDD"]. When you remove index 0, you create a new list ["BBBB", "CCCC", "DDDD"] and its element [1] is "CCCC".
I think the server was designed to treat each operation independently which results in the behavior you have observed. Here are some alternate solutions:
- Call ListOperation.removeByIndexRange(bin, 0, 2, returnType) which deletes the first two values in the list.
- Call ListOperation.removeByValueList(bin, values, returnType) where the values list contains "AAAA" and "BBBB".
- Call removeByIndex(), but in reverse index order: ListOperation.removeByIndex(bin, 1, returnType), ListOperation.removeByIndex(bin, 0, returnType)
-
removeByIndexRange - works only if the elements to delete are contiguous (e.g., indexes 0, 1, 2). It does not work as expected for non-sequential elements such as indexes 1, 3, and 5.
-
removeByValueList - Refer [Issue] [Java Client] ListOperation.removeByValue does not delete elements from records inserted via AQL, but works for records inserted via code
Hi. This is an incorrect assumption, as Brian suggested. If you want to remove the first two elements in a list, you use the removeByIndexRange. The operations execute one after the other, and a removeBy modifies the copy of the record, so the next operation is done on the modified record. It's similar to transaction on a single record.
So the record is loaded with the list being
["AAAA", "BBBB", "CCCC", "DDDD"]. When you remove index 0, you create a new list["BBBB", "CCCC", "DDDD"]and its element [1] is "CCCC".
I'm aware of this behaviour which I've noted it under the section Actual behavior.
The actual behavior is not restricted to lists in Aerospike. Any list behaves this way - indexes are adjusted by any removal, they are relative, not static key-value pairs. In Python, for example:
>>> ex = ["AAAA", "BBBB", "CCCC", "DDDD"]
>>> ex.pop(0)
'AAAA'
>>> ex.pop(1)
'CCCC'
>>> ex
['BBBB', 'DDDD']It is true that removeByIndexRange is a range removal operation. Are you asking for a removeByIndexList to be added to the list API?
I think the server was designed to treat each operation independently which results in the behavior you have observed. Here are some alternate solutions: