From a2fbf021d396cbc9ab9e599ce35c7e57614d2d4a Mon Sep 17 00:00:00 2001 From: Sebastian Zuchmanski Date: Fri, 3 Jul 2026 14:26:50 +0200 Subject: [PATCH] Report per-record errors in batch_operate instead of aborting the batch MultiCommand#parse_group raised Exceptions::Aerospike on any per-record result code other than OK/KEY_NOT_FOUND_ERROR/FILTERED_OUT, before parse_row could record the code. For batch_operate this meant a single bad record (e.g. RECORD_TOO_BIG, BIN_TYPE_ERROR) aborted the entire batch: successful writes were lost and no per-record context reached the caller. Extract the raise decision into an overridable handle_result_code and override it as a no-op in BatchOperateCommand, so non-OK codes fall through to parse_row and are captured on each BatchRecord#result_code. The call now returns normally and the caller inspects status per record, matching respond_all_keys semantics in the Java/C/Go clients. Scan/query (StreamCommand) and legacy batch_get (BatchIndexCommand) keep the existing raising behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../command/batch_operate_command.rb | 8 ++++++ lib/aerospike/command/multi_command.rb | 25 ++++++++++++------- spec/aerospike/batch_operate_spec.rb | 24 ++++++++++++++++++ 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/lib/aerospike/command/batch_operate_command.rb b/lib/aerospike/command/batch_operate_command.rb index 712324eb..53579e6b 100644 --- a/lib/aerospike/command/batch_operate_command.rb +++ b/lib/aerospike/command/batch_operate_command.rb @@ -125,6 +125,14 @@ def write_buffer mark_compressed(@policy) end + # Batch operate reports per-record status on each BatchRecord, so a non-OK + # result code for a single record must not abort the whole batch. Capture + # every code in parse_row instead of raising (matches respond_all_keys + # semantics of the Java/C/Go clients). + def handle_result_code(result_code) + # NOOP + end + # Parse all results in the batch. Add records to shared list. # If the record was not found, the bins will be nil. def parse_row(result_code) diff --git a/lib/aerospike/command/multi_command.rb b/lib/aerospike/command/multi_command.rb index 158c46e5..b68d74b9 100644 --- a/lib/aerospike/command/multi_command.rb +++ b/lib/aerospike/command/multi_command.rb @@ -98,15 +98,7 @@ def parse_group(receive_size) read_bytes(MSG_REMAINING_HEADER_SIZE) result_code = @data_buffer.read(5).ord & 0xFF - # The only valid server return codes are "ok", "not found" and "filtered out". - # If other return codes are received, then abort the batch. - if result_code != 0 - if [Aerospike::ResultCode::KEY_NOT_FOUND_ERROR, Aerospike::ResultCode::FILTERED_OUT].include?(result_code) - # NOOP - else - raise Aerospike::Exceptions::Aerospike.new(result_code, nil, [@node]) - end - end + handle_result_code(result_code) # If cmd is the end marker of the response, do not proceed further info3 = @data_buffer.read(3).ord @@ -118,6 +110,21 @@ def parse_group(receive_size) true end + # Decide what to do with a per-record result code before the row is parsed. + # + # The only valid server return codes are "ok", "not found" and "filtered out". + # If other return codes are received, then abort the batch by raising. + # + # Commands that report per-record status (e.g. batch operate) override this + # to treat non-OK codes as data captured on each record instead of aborting + # the whole batch. + def handle_result_code(result_code) + return if result_code == 0 + return if [Aerospike::ResultCode::KEY_NOT_FOUND_ERROR, Aerospike::ResultCode::FILTERED_OUT].include?(result_code) + + raise Aerospike::Exceptions::Aerospike.new(result_code, nil, [@node]) + end + def parse_key(field_count) # in Stream queries, there are no keys return unless field_count > 0 diff --git a/spec/aerospike/batch_operate_spec.rb b/spec/aerospike/batch_operate_spec.rb index 6933cb89..92b2e70c 100644 --- a/spec/aerospike/batch_operate_spec.rb +++ b/spec/aerospike/batch_operate_spec.rb @@ -203,6 +203,30 @@ expect(exists).to eql false end + + it 'reports per-record failures without aborting the whole batch' do + good_ops = [Aerospike::Operation.put(Aerospike::Bin.new("new_bin", "value"))] + # `add` on the pre-existing string bin "key" is a type mismatch, so the + # server rejects this single record with BIN_TYPE_ERROR. + bad_ops = [Aerospike::Operation.add(Aerospike::Bin.new("key", 1))] + + records = [ + Aerospike::BatchWrite.new(keys.first, good_ops), + Aerospike::BatchWrite.new(keys.last, bad_ops) + ] + + expect { client.batch_operate(records, batch_policy) }.not_to raise_error + + # The healthy record is applied and reported as OK... + expect(records[0].result_code).to eql Aerospike::ResultCode::OK + expect(records[0].record).not_to be_nil + + # ...while the failing record carries its own error code. + expect(records[1].result_code).to eql Aerospike::ResultCode::BIN_TYPE_ERROR + + # The successful write is durable and readable. + expect(client.get(keys.first).bins["new_bin"]).to eql "value" + end end context '#BatchDelete' do