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

zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-go.git


The following commit(s) were added to refs/heads/main by this push:
     new 5aadcffb fix(compute): preserve caller context during execution (#1154)
5aadcffb is described below

commit 5aadcffb2c32dac10b70fc757433a2e785b4a15c
Author: Minh Vu <[email protected]>
AuthorDate: Tue Aug 25 18:04:47 2026 +0200

    fix(compute): preserve caller context during execution (#1154)
    
    ## What
    
    Keep the supplied context attached to compute execution so cancellation
    reaches result collection. Drain pending results before returning so the
    executor is not reused while work is still finishing.
    
    ## Test
    
    - go test ./arrow/compute -count=1
    - go test -race ./arrow/compute -run
    ^TestCallFunctionPreservesCallerCancellation -count=1
---
 arrow/compute/exec.go          |  22 +++-
 arrow/compute/exec_test.go     | 259 +++++++++++++++++++++++++++++++++++++++++
 arrow/compute/executor.go      |  23 +++-
 arrow/compute/executor_test.go |  56 +++++++++
 4 files changed, 350 insertions(+), 10 deletions(-)

diff --git a/arrow/compute/exec.go b/arrow/compute/exec.go
index 0afb3b13..710631c8 100644
--- a/arrow/compute/exec.go
+++ b/arrow/compute/exec.go
@@ -158,7 +158,7 @@ func execInternal(ctx context.Context, fn Function, opts 
FunctionOptions, passed
 
        ectx := GetExecCtx(ctx)
 
-       ctx, cancel := context.WithCancel(context.Background())
+       ctx, cancel := context.WithCancel(ctx)
        defer cancel()
 
        ch := make(chan Datum, ectx.ExecChannelSize)
@@ -170,12 +170,22 @@ func execInternal(ctx context.Context, fn Function, opts 
FunctionOptions, passed
        }()
 
        result = executor.WrapResults(ctx, ch, haveChunkedArray(input.Values))
-       if err == nil {
-               debug.Assert(executor.CheckResultType(result) == nil, "invalid 
result type")
+       if ctx.Err() != nil {
+               for value := range ch {
+                       if value != nil {
+                               value.Release()
+                       }
+               }
+               if err == nil {
+                       err = context.Cause(ctx)
+               }
+               if result != nil {
+                       result.Release()
+                       result = nil
+               }
        }
-
-       if ctx.Err() == context.Canceled && result != nil {
-               result.Release()
+       if err == nil && result != nil {
+               debug.Assert(executor.CheckResultType(result) == nil, "invalid 
result type")
        }
 
        return
diff --git a/arrow/compute/exec_test.go b/arrow/compute/exec_test.go
index eb72a2bf..6f32abd3 100644
--- a/arrow/compute/exec_test.go
+++ b/arrow/compute/exec_test.go
@@ -19,15 +19,22 @@
 package compute
 
 import (
+       "context"
+       "errors"
        "strings"
+       "sync"
+       "sync/atomic"
        "testing"
+       "time"
 
        "github.com/apache/arrow-go/v18/arrow"
        "github.com/apache/arrow-go/v18/arrow/array"
        "github.com/apache/arrow-go/v18/arrow/bitutil"
        "github.com/apache/arrow-go/v18/arrow/compute/exec"
        "github.com/apache/arrow-go/v18/arrow/internal/debug"
+       "github.com/apache/arrow-go/v18/arrow/memory"
        "github.com/apache/arrow-go/v18/arrow/scalar"
+       "github.com/stretchr/testify/require"
        "github.com/stretchr/testify/suite"
 )
 
@@ -115,6 +122,258 @@ func ExecAddInt32(ctx *exec.KernelCtx, batch 
*exec.ExecSpan, out *exec.ExecResul
        return nil
 }
 
+func TestCallFunctionPreservesCallerCancellation(t *testing.T) {
+       started := make(chan struct{})
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       defer mem.AssertSize(t, 0)
+
+       fn := NewScalarFunction("test_preserve_caller_cancellation", Unary(), 
EmptyFuncDoc)
+       kernel := exec.NewScalarKernel(
+               
[]exec.InputType{exec.NewExactInput(arrow.PrimitiveTypes.Int32)},
+               exec.NewOutputType(arrow.PrimitiveTypes.Int32),
+               func(ctx *exec.KernelCtx, _ *exec.ExecSpan, _ *exec.ExecResult) 
error {
+                       close(started)
+                       <-ctx.Ctx.Done()
+                       return nil
+               }, nil)
+       require.NoError(t, fn.AddKernel(kernel))
+
+       execCtx := DefaultExecCtx()
+       execCtx.Registry = NewChildRegistry(execCtx.Registry)
+       require.True(t, execCtx.Registry.AddFunction(fn, false))
+
+       input, _, err := array.FromJSON(mem, arrow.PrimitiveTypes.Int32, 
strings.NewReader(`[1]`))
+       require.NoError(t, err)
+       defer input.Release()
+
+       cancellationErr := errors.New("caller canceled")
+       ctx, cancel := context.WithCancelCause(context.Background())
+       ctx = WithAllocator(ctx, mem)
+       ctx = SetExecCtx(ctx, execCtx)
+       defer cancel(nil)
+
+       done := make(chan struct{})
+       var (
+               result  Datum
+               callErr error
+       )
+       go func() {
+               result, callErr = CallFunction(ctx, fn.Name(), nil, 
&ArrayDatum{Value: input.Data()})
+               close(done)
+       }()
+
+       <-started
+       cancel(cancellationErr)
+       select {
+       case <-done:
+       case <-time.After(time.Second):
+               t.Fatal("CallFunction did not stop after caller cancellation")
+       }
+       require.Nil(t, result)
+       require.ErrorIs(t, callErr, cancellationErr)
+}
+
+func TestCallFunctionReleasesPartialResultOnCallerCancellation(t *testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       defer mem.AssertSize(t, 0)
+
+       secondSpanStarted := make(chan struct{})
+       fn := NewScalarFunction("test_release_partial_result_on_cancellation", 
Unary(), EmptyFuncDoc)
+       calls := 0
+       kernel := exec.NewScalarKernel(
+               
[]exec.InputType{exec.NewExactInput(arrow.PrimitiveTypes.Int32)},
+               exec.NewOutputType(arrow.PrimitiveTypes.Int32),
+               func(ctx *exec.KernelCtx, _ *exec.ExecSpan, _ *exec.ExecResult) 
error {
+                       calls++
+                       if calls == 2 {
+                               close(secondSpanStarted)
+                               <-ctx.Ctx.Done()
+                       }
+                       return nil
+               }, nil)
+       require.NoError(t, fn.AddKernel(kernel))
+
+       execCtx := DefaultExecCtx()
+       execCtx.ChunkSize = 1
+       execCtx.ExecChannelSize = 0
+       execCtx.PreallocContiguous = false
+       execCtx.Registry = NewChildRegistry(execCtx.Registry)
+       require.True(t, execCtx.Registry.AddFunction(fn, false))
+
+       input, _, err := array.FromJSON(mem, arrow.PrimitiveTypes.Int32, 
strings.NewReader(`[1, 2]`))
+       require.NoError(t, err)
+       defer input.Release()
+
+       cancellationErr := errors.New("caller canceled after partial output")
+       ctx, cancel := context.WithCancelCause(context.Background())
+       ctx = WithAllocator(ctx, mem)
+       ctx = SetExecCtx(ctx, execCtx)
+       defer cancel(nil)
+
+       done := make(chan struct{})
+       var (
+               result  Datum
+               callErr error
+       )
+       go func() {
+               result, callErr = CallFunction(ctx, fn.Name(), nil, 
&ArrayDatum{Value: input.Data()})
+               close(done)
+       }()
+
+       <-secondSpanStarted
+       cancel(cancellationErr)
+       select {
+       case <-done:
+       case <-time.After(time.Second):
+               t.Fatal("CallFunction did not stop after caller cancellation")
+       }
+
+       require.Nil(t, result)
+       require.ErrorIs(t, callErr, cancellationErr)
+}
+
+func TestCallFunctionDrainsResultsAfterCallerCancellation(t *testing.T) {
+       const numChunks = 2
+
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       defer mem.AssertSize(t, 0)
+
+       chunks := make([]arrow.Array, numChunks)
+       for i := range chunks {
+               builder := array.NewInt32Builder(mem)
+               builder.Append(int32(i))
+               chunks[i] = builder.NewInt32Array()
+               builder.Release()
+               defer chunks[i].Release()
+       }
+       chunked := arrow.NewChunked(arrow.PrimitiveTypes.Int32, chunks)
+       defer chunked.Release()
+
+       started := make(chan struct{})
+       secondStarted := make(chan struct{})
+       allowFinish := make(chan struct{})
+       var allowFinishOnce sync.Once
+       releaseSecond := func() {
+               allowFinishOnce.Do(func() { close(allowFinish) })
+       }
+       defer releaseSecond()
+
+       var calls atomic.Int32
+       fn := NewScalarFunction("test_drain_results_after_cancellation", 
Unary(), EmptyFuncDoc)
+       kernel := exec.NewScalarKernel(
+               
[]exec.InputType{exec.NewExactInput(arrow.PrimitiveTypes.Int32)},
+               exec.NewOutputType(arrow.PrimitiveTypes.Int32),
+               func(ctx *exec.KernelCtx, _ *exec.ExecSpan, _ *exec.ExecResult) 
error {
+                       switch calls.Add(1) {
+                       case 1:
+                               close(started)
+                               <-ctx.Ctx.Done()
+                       case 2:
+                               close(secondStarted)
+                               <-allowFinish
+                       }
+                       return nil
+               }, nil)
+       require.NoError(t, fn.AddKernel(kernel))
+
+       execCtx := DefaultExecCtx()
+       execCtx.Registry = NewChildRegistry(execCtx.Registry)
+       require.True(t, execCtx.Registry.AddFunction(fn, false))
+
+       cancellationErr := errors.New("caller canceled")
+       ctx, cancel := context.WithCancelCause(context.Background())
+       ctx = WithAllocator(ctx, mem)
+       ctx = SetExecCtx(ctx, execCtx)
+       defer cancel(nil)
+
+       done := make(chan struct{})
+       var (
+               result  Datum
+               callErr error
+       )
+       go func() {
+               result, callErr = CallFunction(ctx, fn.Name(), nil, 
&ChunkedDatum{Value: chunked})
+               close(done)
+       }()
+
+       <-started
+       cancel(cancellationErr)
+       select {
+       case <-secondStarted:
+       case <-time.After(time.Second):
+               releaseSecond()
+               <-done
+               t.Fatal("execution did not continue after caller cancellation")
+       }
+
+       select {
+       case <-done:
+               releaseSecond()
+               t.Fatal("CallFunction returned before execution finished")
+       default:
+       }
+
+       releaseSecond()
+       select {
+       case <-done:
+       case <-time.After(time.Second):
+               t.Fatal("CallFunction did not finish after execution was 
released")
+       }
+       require.Nil(t, result)
+       require.ErrorIs(t, callErr, cancellationErr)
+}
+
+func TestCallFunctionPreservesCallerCancellationForVectorFunction(t 
*testing.T) {
+       started := make(chan struct{})
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       defer mem.AssertSize(t, 0)
+
+       fn := NewVectorFunction("test_vector_preserve_caller_cancellation", 
Unary(), EmptyFuncDoc)
+       kernel := exec.NewVectorKernel(
+               
[]exec.InputType{exec.NewExactInput(arrow.PrimitiveTypes.Int32)},
+               exec.NewOutputType(arrow.PrimitiveTypes.Int32),
+               func(ctx *exec.KernelCtx, _ *exec.ExecSpan, _ *exec.ExecResult) 
error {
+                       close(started)
+                       <-ctx.Ctx.Done()
+                       return nil
+               }, nil)
+       require.NoError(t, fn.AddKernel(kernel))
+
+       execCtx := DefaultExecCtx()
+       execCtx.Registry = NewChildRegistry(execCtx.Registry)
+       require.True(t, execCtx.Registry.AddFunction(fn, false))
+
+       input, _, err := array.FromJSON(mem, arrow.PrimitiveTypes.Int32, 
strings.NewReader(`[1]`))
+       require.NoError(t, err)
+       defer input.Release()
+
+       cancellationErr := errors.New("caller canceled")
+       ctx, cancel := context.WithCancelCause(context.Background())
+       ctx = WithAllocator(ctx, mem)
+       ctx = SetExecCtx(ctx, execCtx)
+       defer cancel(nil)
+
+       done := make(chan struct{})
+       var (
+               result  Datum
+               callErr error
+       )
+       go func() {
+               result, callErr = CallFunction(ctx, fn.Name(), nil, 
&ArrayDatum{Value: input.Data()})
+               close(done)
+       }()
+
+       <-started
+       cancel(cancellationErr)
+       select {
+       case <-done:
+       case <-time.After(time.Second):
+               t.Fatal("vector CallFunction did not stop after caller 
cancellation")
+       }
+       require.Nil(t, result)
+       require.ErrorIs(t, callErr, cancellationErr)
+}
+
 type CallScalarFuncSuite struct {
        ComputeInternalsTestSuite
 }
diff --git a/arrow/compute/executor.go b/arrow/compute/executor.go
index 3468d640..febbec08 100644
--- a/arrow/compute/executor.go
+++ b/arrow/compute/executor.go
@@ -522,7 +522,14 @@ func (s *scalarExecutor) WrapResults(ctx context.Context, 
out <-chan Datum, hasC
        var (
                output Datum
                acc    []arrow.Array
+               ok     bool
        )
+       releaseAccumulated := func() {
+               for _, c := range acc {
+                       c.Release()
+               }
+               acc = nil
+       }
 
        toChunked := func() {
                acc = output.(ArrayLikeDatum).Chunks()
@@ -534,7 +541,13 @@ func (s *scalarExecutor) WrapResults(ctx context.Context, 
out <-chan Datum, hasC
        select {
        case <-ctx.Done():
                return nil
-       case output = <-out:
+       case output, ok = <-out:
+               if !ok || output == nil || ctx.Err() != nil {
+                       if output != nil {
+                               output.Release()
+                       }
+                       return nil
+               }
                // if the inputs contained at least one chunked array
                // then we want to return chunked output
                if hasChunked {
@@ -547,6 +560,10 @@ func (s *scalarExecutor) WrapResults(ctx context.Context, 
out <-chan Datum, hasC
                case <-ctx.Done():
                        // context is done, either cancelled or a timeout.
                        // either way, we end early and return what we've got 
so far.
+                       if output == nil {
+                               releaseAccumulated()
+                               return nil
+                       }
                        return output
                case o, ok := <-out:
                        if !ok { // channel closed, wrap it up
@@ -554,9 +571,7 @@ func (s *scalarExecutor) WrapResults(ctx context.Context, 
out <-chan Datum, hasC
                                        return output
                                }
 
-                               for _, c := range acc {
-                                       defer c.Release()
-                               }
+                               defer releaseAccumulated()
 
                                chkd := arrow.NewChunked(s.outType, acc)
                                defer chkd.Release()
diff --git a/arrow/compute/executor_test.go b/arrow/compute/executor_test.go
index dbc2312b..1b7bfbe6 100644
--- a/arrow/compute/executor_test.go
+++ b/arrow/compute/executor_test.go
@@ -39,6 +39,62 @@ func (d *signalChunkedDatum) Chunks() []arrow.Array {
        return d.Value.Chunks()
 }
 
+type signalArrayDatum struct {
+       *ArrayDatum
+       chunksCalled chan struct{}
+}
+
+func (d *signalArrayDatum) Chunks() []arrow.Array {
+       close(d.chunksCalled)
+       return d.ArrayDatum.Chunks()
+}
+
+func TestScalarExecutorWrapResultsReleasesAccumulatedOutputOnCancellation(t 
*testing.T) {
+       mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
+       defer mem.AssertSize(t, 0)
+
+       builder := array.NewInt32Builder(mem)
+       builder.Append(42)
+       value := builder.NewInt32Array()
+       builder.Release()
+       defer value.Release()
+
+       output := make(chan Datum)
+       ctx, cancel := context.WithCancel(context.Background())
+       defer cancel()
+
+       executor := &scalarExecutor{
+               nonAggExecImpl: nonAggExecImpl{outType: value.DataType()},
+       }
+
+       datum := &signalArrayDatum{
+               ArrayDatum:   NewDatum(value).(*ArrayDatum),
+               chunksCalled: make(chan struct{}),
+       }
+       result := make(chan Datum, 1)
+       go func() {
+               result <- executor.WrapResults(ctx, output, true)
+       }()
+
+       output <- datum
+       <-datum.chunksCalled
+       cancel()
+
+       require.Nil(t, <-result)
+       close(output)
+}
+
+func TestScalarExecutorWrapResultsHandlesClosedOutput(t *testing.T) {
+       output := make(chan Datum)
+       close(output)
+
+       executor := &scalarExecutor{
+               nonAggExecImpl: nonAggExecImpl{outType: 
arrow.PrimitiveTypes.Int32},
+       }
+
+       require.Nil(t, executor.WrapResults(context.Background(), output, 
false))
+}
+
 func TestVectorExecutorWrapResultsReleasesEmptyArrayOutput(t *testing.T) {
        mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
        defer mem.AssertSize(t, 0)

Reply via email to