This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new fae7056d fix(go): serialize record batch reader access (#844)
fae7056d is described below

commit fae7056df2a0ca7b2f0aa28e10cdd354ab3f95e8
Author: XiaoHongbo <[email protected]>
AuthorDate: Thu Sep 17 08:07:55 2026 +0800

    fix(go): serialize record batch reader access (#844)
---
 bindings/go/table_read.go                        |  13 ++-
 bindings/go/tests/table_read_concurrency_test.go | 142 +++++++++++++++++++++++
 2 files changed, 153 insertions(+), 2 deletions(-)

diff --git a/bindings/go/table_read.go b/bindings/go/table_read.go
index 111eaec7..883b1696 100644
--- a/bindings/go/table_read.go
+++ b/bindings/go/table_read.go
@@ -111,6 +111,7 @@ func (tr *TableRead) NewRecordBatchReader(splits 
[]DataSplit) (*RecordBatchReade
 type RecordBatchReader struct {
        ctx       context.Context
        lib       *libRef
+       mu        sync.Mutex
        readers   []*paimonRecordBatchReader
        current   int
        closeOnce sync.Once
@@ -119,8 +120,12 @@ type RecordBatchReader struct {
 // NextRecord returns the next Arrow record, or io.EOF when iteration is
 // complete. The underlying C batch is imported via the Arrow C Data Interface
 // and released automatically — the caller only needs to call Release on the
-// returned arrow.Record when done.
+// returned arrow.Record when done. Calls on one reader are serialized; use
+// separate readers for parallel reads.
 func (r *RecordBatchReader) NextRecord() (arrow.Record, error) {
+       r.mu.Lock()
+       defer r.mu.Unlock()
+
        if r.readers == nil {
                return nil, ErrClosed
        }
@@ -158,8 +163,12 @@ func (r *RecordBatchReader) next() (*arrowBatch, error) {
        return nil, io.EOF
 }
 
-// Close releases the underlying C record batch readers. Safe to call multiple 
times.
+// Close releases the underlying C record batch readers. Safe to call multiple 
times
+// and concurrently with NextRecord.
 func (r *RecordBatchReader) Close() {
+       r.mu.Lock()
+       defer r.mu.Unlock()
+
        r.closeOnce.Do(func() {
                freeFn := ffiRecordBatchReaderFree.symbol(r.ctx)
                for _, rd := range r.readers {
diff --git a/bindings/go/tests/table_read_concurrency_test.go 
b/bindings/go/tests/table_read_concurrency_test.go
new file mode 100644
index 00000000..a87099b9
--- /dev/null
+++ b/bindings/go/tests/table_read_concurrency_test.go
@@ -0,0 +1,142 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package paimon_test
+
+import (
+       "errors"
+       "io"
+       "path/filepath"
+       "sync"
+       "testing"
+
+       "github.com/apache/arrow-go/v18/arrow"
+       paimon "github.com/apache/paimon-rust/bindings/go"
+)
+
+type nextResult struct {
+       record arrow.Record
+       err    error
+}
+
+func newRecordBatchReader(t *testing.T) *paimon.RecordBatchReader {
+       t.Helper()
+
+       warehouse := t.TempDir()
+       source := filepath.Join("testdata", "map_blob_table")
+       if err := copyDirectory(source, filepath.Join(warehouse, "default.db", 
"map_blob_table")); err != nil {
+               t.Fatal(err)
+       }
+       table := openTableAt(t, warehouse, "map_blob_table")
+       builder, err := 
table.NewReadBuilderWithOptions(map[string]string{"blob-as-descriptor": "true"})
+       if err != nil {
+               t.Fatal(err)
+       }
+       t.Cleanup(builder.Close)
+       scan, err := builder.NewScan()
+       if err != nil {
+               t.Fatal(err)
+       }
+       t.Cleanup(scan.Close)
+       plan, err := scan.Plan()
+       if err != nil {
+               t.Fatal(err)
+       }
+       t.Cleanup(plan.Close)
+       read, err := builder.NewRead()
+       if err != nil {
+               t.Fatal(err)
+       }
+       t.Cleanup(read.Close)
+       reader, err := read.NewRecordBatchReader(plan.Splits())
+       if err != nil {
+               t.Fatal(err)
+       }
+       t.Cleanup(reader.Close)
+       return reader
+}
+
+func TestRecordBatchReaderConcurrentNext(t *testing.T) {
+       reader := newRecordBatchReader(t)
+       const workers = 16
+
+       start := make(chan struct{})
+       results := make(chan nextResult, workers)
+       for range workers {
+               go func() {
+                       <-start
+                       record, err := reader.NextRecord()
+                       results <- nextResult{record, err}
+               }()
+       }
+       close(start)
+
+       rows := int64(0)
+       for range workers {
+               result := <-results
+               if result.record != nil {
+                       rows += result.record.NumRows()
+                       result.record.Release()
+               }
+               if result.err != nil && !errors.Is(result.err, io.EOF) {
+                       t.Fatalf("NextRecord returned %v", result.err)
+               }
+       }
+       if rows != 3 {
+               t.Fatalf("read %d rows, want 3", rows)
+       }
+}
+
+func TestRecordBatchReaderConcurrentNextAndClose(t *testing.T) {
+       reader := newRecordBatchReader(t)
+       const workers = 16
+
+       start := make(chan struct{})
+       results := make(chan nextResult, workers)
+       var wg sync.WaitGroup
+       wg.Add(workers + 1)
+       for range workers {
+               go func() {
+                       defer wg.Done()
+                       <-start
+                       record, err := reader.NextRecord()
+                       results <- nextResult{record, err}
+               }()
+       }
+       go func() {
+               defer wg.Done()
+               <-start
+               reader.Close()
+       }()
+       close(start)
+       wg.Wait()
+       close(results)
+
+       for result := range results {
+               if result.record != nil {
+                       result.record.Release()
+               }
+               if result.err != nil && !errors.Is(result.err, io.EOF) && 
!errors.Is(result.err, paimon.ErrClosed) {
+                       t.Fatalf("NextRecord returned %v", result.err)
+               }
+       }
+       if _, err := reader.NextRecord(); !errors.Is(err, paimon.ErrClosed) {
+               t.Fatalf("NextRecord after Close returned %v", err)
+       }
+}

Reply via email to