You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iceberg.apache.org by ze...@apache.org on 2026/04/14 20:16:13 UTC

(iceberg-go) branch main updated: feat(compaction): add Analyze for dry-run compaction planning (#893)

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 df817dc2 feat(compaction): add Analyze for dry-run compaction planning (#893)
df817dc2 is described below

commit df817dc25d566f5f4f2b2a374759a9a3973af9a2
Author: Andrei Tserakhau <la...@gmail.com>
AuthorDate: Tue Apr 14 13:16:07 2026 -0700

    feat(compaction): add Analyze for dry-run compaction planning (#893)
    
    Add compaction.Analyze(ctx, tbl, cfg) as a read-only entry point that
    scans the table via PlanFiles and produces a compaction Plan without
    modifying anything. Useful for dry-run analysis, CLI tooling, and
    deciding whether compaction is worthwhile before executing.
    
    E2e tests with real Parquet files:
    - Small files detected as compaction candidates
    - Optimal files skipped when below MinInputFiles
    - Empty table produces empty plan
    - Invalid config returns error
    
    Part of #832 (table compaction).
---
 table/compaction/analyze.go      |  36 ++++++++
 table/compaction/analyze_test.go | 188 +++++++++++++++++++++++++++++++++++++++
 2 files changed, 224 insertions(+)

diff --git a/table/compaction/analyze.go b/table/compaction/analyze.go
new file mode 100644
index 00000000..9513836a
--- /dev/null
+++ b/table/compaction/analyze.go
@@ -0,0 +1,36 @@
+// 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 compaction
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/apache/iceberg-go/table"
+)
+
+// Analyze scans the table and produces a compaction Plan without
+// modifying anything. This is the dry-run / planning entry point.
+func Analyze(ctx context.Context, tbl *table.Table, cfg Config) (Plan, error) {
+	tasks, err := tbl.Scan().PlanFiles(ctx)
+	if err != nil {
+		return Plan{}, fmt.Errorf("plan files for compaction analysis: %w", err)
+	}
+
+	return cfg.PlanCompaction(tasks)
+}
diff --git a/table/compaction/analyze_test.go b/table/compaction/analyze_test.go
new file mode 100644
index 00000000..3609b91e
--- /dev/null
+++ b/table/compaction/analyze_test.go
@@ -0,0 +1,188 @@
+// 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 compaction_test
+
+import (
+	"context"
+	"fmt"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/apache/arrow-go/v18/arrow"
+	"github.com/apache/arrow-go/v18/arrow/array"
+	"github.com/apache/arrow-go/v18/arrow/memory"
+	"github.com/apache/arrow-go/v18/parquet"
+	"github.com/apache/arrow-go/v18/parquet/pqarrow"
+	"github.com/apache/iceberg-go"
+	iceio "github.com/apache/iceberg-go/io"
+	"github.com/apache/iceberg-go/table"
+	"github.com/apache/iceberg-go/table/compaction"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// analyzeCatalog is a minimal catalog for local e2e tests.
+type analyzeCatalog struct {
+	metadata table.Metadata
+}
+
+func (m *analyzeCatalog) LoadTable(ctx context.Context, ident table.Identifier) (*table.Table, error) {
+	return nil, nil
+}
+
+func (m *analyzeCatalog) CommitTable(ctx context.Context, ident table.Identifier, reqs []table.Requirement, updates []table.Update) (table.Metadata, string, error) {
+	meta, err := table.UpdateTableMetadata(m.metadata, updates, "")
+	if err != nil {
+		return nil, "", err
+	}
+
+	m.metadata = meta
+
+	return meta, "", nil
+}
+
+func newAnalyzeTestTable(t *testing.T) *table.Table {
+	t.Helper()
+
+	location := filepath.ToSlash(t.TempDir())
+
+	schema := iceberg.NewSchema(0,
+		iceberg.NestedField{ID: 1, Name: "id", Type: iceberg.PrimitiveTypes.Int64, Required: true},
+		iceberg.NestedField{ID: 2, Name: "data", Type: iceberg.PrimitiveTypes.String, Required: false},
+	)
+
+	meta, err := table.NewMetadata(schema, iceberg.UnpartitionedSpec,
+		table.UnsortedSortOrder, location,
+		iceberg.Properties{table.PropertyFormatVersion: "2"})
+	require.NoError(t, err)
+
+	return table.New(
+		table.Identifier{"db", "analyze_test"},
+		meta, location+"/metadata/v1.metadata.json",
+		func(ctx context.Context) (iceio.IO, error) {
+			return iceio.LocalFS{}, nil
+		},
+		&analyzeCatalog{metadata: meta},
+	)
+}
+
+func writeTestParquetFile(t testing.TB, path string, sc *arrow.Schema, jsonData string) {
+	t.Helper()
+
+	rec, _, err := array.RecordFromJSON(memory.DefaultAllocator, sc, strings.NewReader(jsonData))
+	require.NoError(t, err)
+	defer rec.Release()
+
+	fs := iceio.LocalFS{}
+	fw, err := fs.Create(path)
+	require.NoError(t, err)
+
+	tbl := array.NewTableFromRecords(sc, []arrow.RecordBatch{rec})
+	defer tbl.Release()
+
+	require.NoError(t, pqarrow.WriteTable(tbl, fw, rec.NumRows(),
+		parquet.NewWriterProperties(parquet.WithStats(true)),
+		pqarrow.DefaultWriterProps()))
+}
+
+func TestAnalyze_SmallFiles(t *testing.T) {
+	tbl := newAnalyzeTestTable(t)
+
+	arrowSc, err := table.SchemaToArrowSchema(tbl.Schema(), nil, false, false)
+	require.NoError(t, err)
+
+	for i := range 5 {
+		dataPath := tbl.Location() + fmt.Sprintf("/data/file-%d.parquet", i)
+		writeTestParquetFile(t, dataPath, arrowSc,
+			fmt.Sprintf(`[{"id": %d, "data": "row-%d"}]`, i+1, i+1))
+
+		tx := tbl.NewTransaction()
+		require.NoError(t, tx.AddFiles(t.Context(), []string{dataPath}, nil, false))
+		tbl, err = tx.Commit(t.Context())
+		require.NoError(t, err)
+	}
+
+	cfg := compaction.Config{
+		TargetFileSizeBytes: 10 * 1024 * 1024,
+		MinFileSizeBytes:    5 * 1024 * 1024,
+		MaxFileSizeBytes:    20 * 1024 * 1024,
+		MinInputFiles:       2,
+		DeleteFileThreshold: 5,
+		PackingLookback:     compaction.DefaultPackingLookback,
+	}
+
+	plan, err := compaction.Analyze(t.Context(), tbl, cfg)
+	require.NoError(t, err)
+
+	assert.Equal(t, 5, plan.TotalInputFiles)
+	assert.Greater(t, plan.TotalInputBytes, int64(0))
+	require.NotEmpty(t, plan.Groups)
+	assert.Equal(t, 0, plan.SkippedFiles)
+	assert.Greater(t, plan.EstOutputFiles, 0)
+}
+
+func TestAnalyze_AllOptimal(t *testing.T) {
+	tbl := newAnalyzeTestTable(t)
+
+	arrowSc, err := table.SchemaToArrowSchema(tbl.Schema(), nil, false, false)
+	require.NoError(t, err)
+
+	dataPath := tbl.Location() + "/data/file-0.parquet"
+	writeTestParquetFile(t, dataPath, arrowSc, `[{"id": 1, "data": "hello"}]`)
+
+	tx := tbl.NewTransaction()
+	require.NoError(t, tx.AddFiles(t.Context(), []string{dataPath}, nil, false))
+	tbl, err = tx.Commit(t.Context())
+	require.NoError(t, err)
+
+	cfg := compaction.Config{
+		TargetFileSizeBytes: 100,
+		MinFileSizeBytes:    1,
+		MaxFileSizeBytes:    1024 * 1024 * 1024,
+		MinInputFiles:       5,
+		DeleteFileThreshold: 5,
+		PackingLookback:     compaction.DefaultPackingLookback,
+	}
+
+	plan, err := compaction.Analyze(t.Context(), tbl, cfg)
+	require.NoError(t, err)
+
+	assert.Equal(t, 1, plan.TotalInputFiles)
+	assert.Empty(t, plan.Groups)
+}
+
+func TestAnalyze_EmptyTable(t *testing.T) {
+	tbl := newAnalyzeTestTable(t)
+
+	cfg := compaction.DefaultConfig()
+	plan, err := compaction.Analyze(t.Context(), tbl, cfg)
+	require.NoError(t, err)
+
+	assert.Equal(t, 0, plan.TotalInputFiles)
+	assert.Empty(t, plan.Groups)
+}
+
+func TestAnalyze_InvalidConfig(t *testing.T) {
+	tbl := newAnalyzeTestTable(t)
+
+	cfg := compaction.Config{TargetFileSizeBytes: 0}
+	_, err := compaction.Analyze(t.Context(), tbl, cfg)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "target file size must be positive")
+}