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/iceberg-go.git


The following commit(s) were added to refs/heads/main by this push:
     new d3c4accf8 perf(rest): reuse adjacent scan task residuals (#2224)
d3c4accf8 is described below

commit d3c4accf83cd58de91dc5e42499da1309c456186
Author: Minh Vu <[email protected]>
AuthorDate: Tue Oct 20 19:16:03 2026 +0200

    perf(rest): reuse adjacent scan task residuals (#2224)
    
    * perf(rest): reuse consecutive scan task residual expressions
    
    * perf(rest): fast-path absent scan task residuals
    
    * chore: keep benchmark candidate free of temporary workflow
    
    * fix(rest): satisfy nlreturn lint in residual decoder
---
 catalog/rest/scan_task_decoder.go                  |  35 ++++++-
 .../rest/scan_task_residual_reuse_bench_test.go    | 110 +++++++++++++++++++++
 catalog/rest/scan_task_residual_reuse_test.go      |  84 ++++++++++++++++
 3 files changed, 228 insertions(+), 1 deletion(-)

diff --git a/catalog/rest/scan_task_decoder.go 
b/catalog/rest/scan_task_decoder.go
index 1ed3900a5..337eab18a 100644
--- a/catalog/rest/scan_task_decoder.go
+++ b/catalog/rest/scan_task_decoder.go
@@ -161,6 +161,7 @@ func decodeScanTasks(
        out := make([]table.FileScanTask, 0, len(wire.FileScanTasks))
        dvOwners := make(map[int]string)
        deleteRefTaskIDs := make([]int, len(deletes))
+       var residuals taskResidualDecoder
        for i := range wire.FileScanTasks {
                wireTask := wire.FileScanTasks[i]
                if wireTask.DataFile == nil {
@@ -172,7 +173,7 @@ func decodeScanTasks(
                        return nil, fmt.Errorf("%w: decoding scan tasks: 
file-scan-tasks[%d].data-file: %w", ErrRESTError, i, err)
                }
 
-               residual, err := decodeTaskResidual(wireTask.ResidualFilter, 
schema, fallbackResidual)
+               residual, err := residuals.decode(wireTask.ResidualFilter, 
schema, fallbackResidual)
                if err != nil {
                        return nil, fmt.Errorf("%w: decoding scan tasks: 
file-scan-tasks[%d].residual-filter: %w", ErrRESTError, i, err)
                }
@@ -809,6 +810,38 @@ func decodeJSONFloat(raw json.RawMessage, bitSize int) 
(float64, error) {
        return parsed, nil
 }
 
+// taskResidualDecoder avoids reparsing an identical residual for adjacent scan
+// tasks. It is local to one envelope: no global cache or retained task JSON.
+type taskResidualDecoder struct {
+       lastRaw   json.RawMessage
+       lastValue iceberg.BooleanExpression
+}
+
+func (d *taskResidualDecoder) decode(
+       raw json.RawMessage,
+       schema *iceberg.Schema,
+       fallback iceberg.BooleanExpression,
+) (iceberg.BooleanExpression, error) {
+       // Most scans omit per-task residuals. Preserve that zero-allocation
+       // fallback and do not retain a cached expression across missing 
filters.
+       if len(raw) == 0 {
+               d.lastRaw, d.lastValue = nil, nil
+
+               return fallback, nil
+       }
+       if len(d.lastRaw) > 0 && bytes.Equal(raw, d.lastRaw) {
+               return d.lastValue, nil
+       }
+
+       value, err := decodeTaskResidual(raw, schema, fallback)
+       if err != nil {
+               return nil, err
+       }
+       d.lastRaw, d.lastValue = raw, value
+
+       return value, nil
+}
+
 func decodeTaskResidual(
        raw json.RawMessage,
        schema *iceberg.Schema,
diff --git a/catalog/rest/scan_task_residual_reuse_bench_test.go 
b/catalog/rest/scan_task_residual_reuse_bench_test.go
new file mode 100644
index 000000000..d636a6316
--- /dev/null
+++ b/catalog/rest/scan_task_residual_reuse_bench_test.go
@@ -0,0 +1,110 @@
+// 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 rest
+
+import (
+       "encoding/json"
+       "fmt"
+       "testing"
+
+       "github.com/apache/iceberg-go"
+)
+
+var residualReuseBenchmarkSink iceberg.BooleanExpression
+
+func BenchmarkScanTaskResidualReuse(b *testing.B) {
+       metadata := newScanTaskDecoderMetadata()
+       for _, taskCount := range []int{64, 1024} {
+               for _, distribution := range []string{"identical", 
"runs-of-16", "unique", "missing"} {
+                       residuals := make([]json.RawMessage, taskCount)
+                       for i := range residuals {
+                               if distribution == "missing" {
+                                       continue
+                               }
+                               value := 34
+                               switch distribution {
+                               case "runs-of-16":
+                                       value += i / 16
+                               case "unique":
+                                       value += i
+                               }
+                               // Give each task its own JSON buffer, as in 
real decoded wire payloads.
+                               residuals[i] = 
json.RawMessage(fmt.Sprintf(`{"type":"eq","term":"id","value":%d}`, value))
+                       }
+                       for _, variant := range []struct {
+                               name   string
+                               cached bool
+                       }{{"uncached", false}, {"cached", true}} {
+                               b.Run(fmt.Sprintf("tasks=%d/%s/%s", taskCount, 
distribution, variant.name), func(b *testing.B) {
+                                       b.ReportAllocs()
+                                       b.ResetTimer()
+                                       for b.Loop() {
+                                               var cache taskResidualDecoder
+                                               for _, raw := range residuals {
+                                                       var (
+                                                               value 
iceberg.BooleanExpression
+                                                               err   error
+                                                       )
+                                                       if variant.cached {
+                                                               value, err = 
cache.decode(raw, metadata.schema, nil)
+                                                       } else {
+                                                               value, err = 
decodeTaskResidual(raw, metadata.schema, nil)
+                                                       }
+                                                       if err != nil {
+                                                               b.Fatal(err)
+                                                       }
+                                                       
residualReuseBenchmarkSink = value
+                                               }
+                                       }
+                               })
+                       }
+               }
+       }
+}
+
+func BenchmarkDecodeScanTasksWithResiduals(b *testing.B) {
+       metadata := newScanTaskDecoderMetadata()
+       base := validScanTasksWire().FileScanTasks[0].DataFile
+       for _, distribution := range []string{"identical", "unique", "missing"} 
{
+               const taskCount = 1024
+               wire := ScanTasks{FileScanTasks: make([]RESTFileScanTask, 
taskCount)}
+               for i := range wire.FileScanTasks {
+                       data := *base
+                       var raw json.RawMessage
+                       if distribution != "missing" {
+                               value := 34
+                               if distribution == "unique" {
+                                       value += i
+                               }
+                               raw = 
json.RawMessage(fmt.Sprintf(`{"type":"eq","term":"id","value":%d}`, value))
+                       }
+                       wire.FileScanTasks[i] = RESTFileScanTask{DataFile: 
&data, ResidualFilter: raw}
+               }
+               b.Run(distribution, func(b *testing.B) {
+                       b.ReportAllocs()
+                       b.ResetTimer()
+                       for b.Loop() {
+                               tasks, err := DecodeScanTasks(wire, metadata, 
metadata.schema, nil)
+                               if err != nil {
+                                       b.Fatal(err)
+                               }
+                               decodeScanTasksBenchmarkSink = len(tasks)
+                       }
+               })
+       }
+}
diff --git a/catalog/rest/scan_task_residual_reuse_test.go 
b/catalog/rest/scan_task_residual_reuse_test.go
new file mode 100644
index 000000000..1d60db741
--- /dev/null
+++ b/catalog/rest/scan_task_residual_reuse_test.go
@@ -0,0 +1,84 @@
+// 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 rest
+
+import (
+       "encoding/json"
+       "testing"
+
+       "github.com/apache/iceberg-go"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+func TestDecodeScanTasksReusesAdjacentResidualFilters(t *testing.T) {
+       t.Parallel()
+
+       metadata := newScanTaskDecoderMetadata()
+       base := validScanTasksWire().FileScanTasks[0].DataFile
+       fallback := iceberg.AlwaysTrue{}
+       filters := []string{
+               `{"type":"eq","term":"id","value":34}`,
+               `{"type":"eq","term":"id","value":34}`,
+               `{"type":"eq","term":"id","value":35}`,
+               `{"type":"eq","term":"id","value":34}`,
+               "",
+               `{"type":"eq","term":"id","value":34}`,
+       }
+       wire := ScanTasks{FileScanTasks: make([]RESTFileScanTask, len(filters))}
+       for i, filter := range filters {
+               data := *base
+               wire.FileScanTasks[i] = RESTFileScanTask{
+                       DataFile:       &data,
+                       ResidualFilter: json.RawMessage(filter),
+               }
+       }
+
+       tasks, err := DecodeScanTasks(wire, metadata, metadata.schema, fallback)
+       require.NoError(t, err)
+       require.Len(t, tasks, len(filters))
+
+       assert.True(t, 
tasks[0].Residual.Equals(iceberg.EqualTo(iceberg.Reference("id"), int64(34))))
+       assert.Same(t, tasks[0].Residual, tasks[1].Residual, "consecutive equal 
filters should reuse the parsed expression")
+       assert.True(t, 
tasks[2].Residual.Equals(iceberg.EqualTo(iceberg.Reference("id"), int64(35))))
+       assert.True(t, 
tasks[3].Residual.Equals(iceberg.EqualTo(iceberg.Reference("id"), int64(34))))
+       assert.NotSame(t, tasks[0].Residual, tasks[3].Residual, "different 
filters should invalidate the consecutive cache")
+       assert.Equal(t, fallback, tasks[4].Residual)
+       assert.True(t, 
tasks[5].Residual.Equals(iceberg.EqualTo(iceberg.Reference("id"), int64(34))))
+       assert.NotSame(t, tasks[3].Residual, tasks[5].Residual, "missing 
filters should reset the consecutive cache")
+}
+
+func TestDecodeScanTasksResidualReuseDoesNotHideErrors(t *testing.T) {
+       t.Parallel()
+
+       metadata := newScanTaskDecoderMetadata()
+       base := validScanTasksWire().FileScanTasks[0].DataFile
+       wire := ScanTasks{FileScanTasks: make([]RESTFileScanTask, 3)}
+       for i := range wire.FileScanTasks {
+               data := *base
+               wire.FileScanTasks[i] = RESTFileScanTask{
+                       DataFile:       &data,
+                       ResidualFilter: 
json.RawMessage(`{"type":"eq","term":"id","value":34}`),
+               }
+       }
+       wire.FileScanTasks[2].ResidualFilter = json.RawMessage(`null`)
+
+       _, err := DecodeScanTasks(wire, metadata, metadata.schema, nil)
+       require.ErrorContains(t, err, "file-scan-tasks[2].residual-filter")
+       require.ErrorContains(t, err, "explicit null residual-filter is 
invalid")
+}

Reply via email to