From a2fbf021d396cbc9ab9e599ce35c7e57614d2d4a Mon Sep 17 00:00:00 2001
From: Sebastian Zuchmanski <sebastian.zuchmanski@castle.io>
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) <noreply@anthropic.com>
---
 .../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
