mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-23 14:30:46 +02:00
* iceberg: keep a default-CRS geometry column without a CRS after compaction Writers such as DuckDB write a GEOMETRY column whose Iceberg type has the default CRS (OGC:CRS84) with no CRS in the Parquet logical type. parquet-go spells the default out when it writes such a column (geometryType's LogicalType), even when the column it read declared none, so every compacted file declared GEOMETRY(crs=OGC:CRS84) where its inputs declared GEOMETRY. Both mean the same to the Parquet spec, but a reader may treat the file column as another type than the table column: DuckDB casts it to the table's GEOMETRY, and a cast column is no longer pruned by its row-group statistics, so a spatial filter read every row group of a compacted file. After a bin is merged, the footer's schema elements of every GEOMETRY column whose Iceberg type has the default CRS (top-level or nested) are rewritten to declare no CRS. The data, the page indexes and every offset stay byte for byte; only the footer is re-encoded. An explicit CRS is left as it is. Retyping the writer's column instead does not work: parquet-go refuses to convert between two GEOMETRY types that both mean the default, and its sorting writer converts. Tests: geometry_crs_test.go, a bin-packed and a sorted compaction of default-CRS inputs (one of them spelling the default out) write no CRS, with a control showing parquet-go spells it out on its own; an explicit CRS is kept; a nested geometry. On master the binpack and sort cases fail with CRS "OGC:CRS84". * iceberg: compress the row lineage columns of compacted v3 files A v3 compaction appends _row_id and _last_updated_sequence_number to the merged file. The two leaves were built without a codec, so they were written uncompressed, 16 bytes a row, whatever codec the data columns use; on a table of points the compacted files grew by about a third. The lineage leaves now take the codec of the reference file's first leaf that declares one, ZSTD when none does. Test: TestCompactCompressesLineageColumns; on master both columns are written UNCOMPRESSED next to ZSTD data columns. * iceberg: match list and map geometry by Iceberg name in files without field ids A Parquet footer path runs through the repeated group of a LIST or MAP (shapes.list.element, attrs.key_value.value), which Iceberg leaves out of its field names (shapes.element, attrs.value). The name lookup for a file without field ids therefore missed every geometry inside a list or a map and left the default CRS spelled out. Name the paths as Iceberg does, including the legacy two-level list forms. * iceberg: rewrite the geometry CRS footer inside the merged file's buffer Removing a CRS shortens the footer, so the new one fits where the old one was. Copying the whole merged file to replace a few hundred bytes of footer doubled the memory of a compaction near the target file size. * iceberg: take the lineage codec from the data columns, not an input's _row_id An input that already carries _row_id may list it before its data columns, and write it uncompressed; the new lineage columns then copied that codec instead of the data columns'. Choose from the columns the layout keeps. --------- Co-authored-by: Javier de la Torre <jatorre@lakehousebox.com>
361 lines
12 KiB
Go
361 lines
12 KiB
Go
package iceberg
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"path"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/apache/iceberg-go"
|
|
"github.com/apache/iceberg-go/table"
|
|
"github.com/parquet-go/parquet-go"
|
|
"github.com/parquet-go/parquet-go/compress/zstd"
|
|
"github.com/parquet-go/parquet-go/format"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3tables"
|
|
)
|
|
|
|
// Fixtures for v3 tables with a geometry column: Parquet files with field
|
|
// ids, ZSTD and a native GEOMETRY column, and manifests that carry the v3
|
|
// geometry bounds of each file, as DuckDB writes them.
|
|
|
|
const (
|
|
geoTestIDField = 1
|
|
geoTestGeomField = 2
|
|
)
|
|
|
|
func wkbPoint(x, y float64) []byte {
|
|
b := make([]byte, 21)
|
|
b[0] = 1 // little endian
|
|
binary.LittleEndian.PutUint32(b[1:], 1)
|
|
binary.LittleEndian.PutUint64(b[5:], math.Float64bits(x))
|
|
binary.LittleEndian.PutUint64(b[13:], math.Float64bits(y))
|
|
return b
|
|
}
|
|
|
|
// wkbBox is the polygon of one closed ring around the box.
|
|
func wkbBox(xmin, ymin, xmax, ymax float64) []byte {
|
|
ring := [][2]float64{{xmin, ymin}, {xmax, ymin}, {xmax, ymax}, {xmin, ymax}, {xmin, ymin}}
|
|
b := make([]byte, 0, 13+16*len(ring))
|
|
b = append(b, 1)
|
|
b = binary.LittleEndian.AppendUint32(b, 3)
|
|
b = binary.LittleEndian.AppendUint32(b, 1)
|
|
b = binary.LittleEndian.AppendUint32(b, uint32(len(ring)))
|
|
for _, p := range ring {
|
|
b = binary.LittleEndian.AppendUint64(b, math.Float64bits(p[0]))
|
|
b = binary.LittleEndian.AppendUint64(b, math.Float64bits(p[1]))
|
|
}
|
|
return b
|
|
}
|
|
|
|
func geoLE64(v int64) []byte {
|
|
b := make([]byte, 8)
|
|
binary.LittleEndian.PutUint64(b, uint64(v))
|
|
return b
|
|
}
|
|
|
|
func xyBound(x, y float64) []byte {
|
|
b := make([]byte, 16)
|
|
binary.LittleEndian.PutUint64(b, math.Float64bits(x))
|
|
binary.LittleEndian.PutUint64(b[8:], math.Float64bits(y))
|
|
return b
|
|
}
|
|
|
|
func decodeXYBound(t *testing.T, b []byte) (float64, float64) {
|
|
t.Helper()
|
|
if len(b) < 16 {
|
|
t.Fatalf("geometry bound of %d bytes, want 16", len(b))
|
|
}
|
|
return math.Float64frombits(binary.LittleEndian.Uint64(b)), math.Float64frombits(binary.LittleEndian.Uint64(b[8:]))
|
|
}
|
|
|
|
type geoRow struct {
|
|
ID int64
|
|
Geom []byte // nil: a null geometry
|
|
}
|
|
|
|
type geoFile struct {
|
|
Name string
|
|
Rows []geoRow
|
|
ParquetCRSSet bool // declare the default CRS in the Parquet logical type
|
|
}
|
|
|
|
type geoTableOptions struct {
|
|
icebergType iceberg.Type // the geometry column's Iceberg type (default: geometry with the default CRS)
|
|
parquetCRS string // the CRS of the Parquet GEOMETRY type
|
|
sortOrder *table.SortOrder // nil: unsorted
|
|
properties iceberg.Properties
|
|
// fixup, when set, rewrites each input file after it is written
|
|
fixup func(t *testing.T, data []byte) []byte
|
|
}
|
|
|
|
func geoTestSchema(geomType iceberg.Type) *iceberg.Schema {
|
|
return iceberg.NewSchema(0,
|
|
iceberg.NestedField{ID: geoTestIDField, Type: iceberg.PrimitiveTypes.Int64, Name: "id", Required: true},
|
|
iceberg.NestedField{ID: geoTestGeomField, Type: geomType, Name: "geom", Required: false},
|
|
)
|
|
}
|
|
|
|
func writeGeoParquet(t *testing.T, rows []geoRow, crs string) []byte {
|
|
t.Helper()
|
|
schema := parquet.NewSchema("table", parquet.Group{
|
|
"id": parquet.FieldID(parquet.Compressed(parquet.Int(64), &zstd.Codec{}), geoTestIDField),
|
|
"geom": parquet.FieldID(parquet.Compressed(parquet.Optional(parquet.Geometry(crs)), &zstd.Codec{}), geoTestGeomField),
|
|
})
|
|
geomCol, idCol := -1, -1
|
|
for i, p := range schema.Columns() {
|
|
switch strings.Join(p, ".") {
|
|
case "geom":
|
|
geomCol = i
|
|
case "id":
|
|
idCol = i
|
|
}
|
|
}
|
|
var buf bytes.Buffer
|
|
w := parquet.NewGenericWriter[any](&buf, schema)
|
|
for _, r := range rows {
|
|
row := make(parquet.Row, 2)
|
|
if r.Geom == nil {
|
|
row[geomCol] = parquet.NullValue().Level(0, 0, geomCol)
|
|
} else {
|
|
row[geomCol] = parquet.ByteArrayValue(r.Geom).Level(0, 1, geomCol)
|
|
}
|
|
row[idCol] = parquet.Int64Value(r.ID).Level(0, 0, idCol)
|
|
if _, err := w.WriteRows([]parquet.Row{row}); err != nil {
|
|
t.Fatalf("write row: %v", err)
|
|
}
|
|
}
|
|
if err := w.Close(); err != nil {
|
|
t.Fatalf("close writer: %v", err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// footerGeoBox is the union of the file's row-group geometry boxes.
|
|
func footerGeoBox(t *testing.T, data []byte) (xmin, ymin, xmax, ymax float64, ok bool) {
|
|
t.Helper()
|
|
f, err := parquet.OpenFile(bytes.NewReader(data), int64(len(data)))
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
xmin, ymin, xmax, ymax = math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1)
|
|
for _, rg := range f.Metadata().RowGroups {
|
|
for _, c := range rg.Columns {
|
|
if strings.Join(c.MetaData.PathInSchema, ".") != "geom" || len(c.MetaData.GeospatialStatistics.GeoSpatialTypes) == 0 {
|
|
continue
|
|
}
|
|
bb := c.MetaData.GeospatialStatistics.BBox
|
|
xmin, ymin = math.Min(xmin, bb.XMin), math.Min(ymin, bb.YMin)
|
|
xmax, ymax = math.Max(xmax, bb.XMax), math.Max(ymax, bb.YMax)
|
|
ok = true
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// footerGeometryCRS is the CRS the file's GEOMETRY logical type declares, and
|
|
// whether the column is GEOMETRY at all.
|
|
func footerGeometryCRS(t *testing.T, data []byte, column string) (string, bool) {
|
|
t.Helper()
|
|
f, err := parquet.OpenFile(bytes.NewReader(data), int64(len(data)))
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
for _, el := range f.Metadata().Schema {
|
|
if el.Name != column {
|
|
continue
|
|
}
|
|
if g, ok := el.LogicalType.Value.(*format.GeometryType); ok {
|
|
return g.CRS, true
|
|
}
|
|
return "", false
|
|
}
|
|
t.Fatalf("no column %s in the footer", column)
|
|
return "", false
|
|
}
|
|
|
|
// populateGeoTable writes a v3 geometry table with one append snapshot and
|
|
// one manifest per file.
|
|
func populateGeoTable(t *testing.T, fs *fakeFilerServer, setup tableSetup, files []geoFile, opt geoTableOptions) table.Metadata {
|
|
t.Helper()
|
|
geomType := opt.icebergType
|
|
if geomType == nil {
|
|
geomType = iceberg.GeometryType{}
|
|
}
|
|
schema := geoTestSchema(geomType)
|
|
spec := *iceberg.UnpartitionedSpec
|
|
location := "s3://" + setup.BucketName + "/" + setup.dataPath()
|
|
props := iceberg.Properties{"format-version": "3"}
|
|
for k, v := range opt.properties {
|
|
props[k] = v
|
|
}
|
|
sortOrder := table.UnsortedSortOrder
|
|
if opt.sortOrder != nil {
|
|
sortOrder = *opt.sortOrder
|
|
}
|
|
meta, err := table.NewMetadata(schema, &spec, sortOrder, location, props)
|
|
if err != nil {
|
|
t.Fatalf("create metadata: %v", err)
|
|
}
|
|
bucketPath := path.Join(s3tables.TablesPath, setup.BucketName)
|
|
tableFilerPath := path.Join(bucketPath, setup.dataPath())
|
|
metaDir := path.Join(tableFilerPath, "metadata")
|
|
dataDir := path.Join(tableFilerPath, "data")
|
|
putFile := func(dir, name string, data []byte) {
|
|
fs.putEntry(dir, name, &filer_pb.Entry{Name: name, Content: data,
|
|
Attributes: &filer_pb.FuseAttributes{Mtime: time.Now().Unix(), FileSize: uint64(len(data))}})
|
|
}
|
|
snapID := int64(1)
|
|
var manifests []iceberg.ManifestFile
|
|
for i, f := range files {
|
|
crs := opt.parquetCRS
|
|
if f.ParquetCRSSet && crs == "" {
|
|
crs = iceberg.DefaultGeoCRS
|
|
}
|
|
data := writeGeoParquet(t, f.Rows, crs)
|
|
if opt.fixup != nil && !f.ParquetCRSSet {
|
|
data = opt.fixup(t, data)
|
|
}
|
|
putFile(dataDir, f.Name, data)
|
|
dfb, err := iceberg.NewDataFileBuilder(spec, iceberg.EntryContentData, setup.fileRef("data", f.Name), iceberg.ParquetFile, map[int]any{}, nil, nil, int64(len(f.Rows)), int64(len(data)))
|
|
if err != nil {
|
|
t.Fatalf("data file %s: %v", f.Name, err)
|
|
}
|
|
lo, hi := int64(math.MaxInt64), int64(math.MinInt64)
|
|
for _, r := range f.Rows {
|
|
lo, hi = min(lo, r.ID), max(hi, r.ID)
|
|
}
|
|
lower := map[int][]byte{geoTestIDField: geoLE64(lo)}
|
|
upper := map[int][]byte{geoTestIDField: geoLE64(hi)}
|
|
if xmin, ymin, xmax, ymax, ok := footerGeoBox(t, data); ok {
|
|
lower[geoTestGeomField], upper[geoTestGeomField] = xyBound(xmin, ymin), xyBound(xmax, ymax)
|
|
}
|
|
dfb.LowerBoundValues(lower)
|
|
dfb.UpperBoundValues(upper)
|
|
entry := iceberg.NewManifestEntry(iceberg.EntryStatusADDED, &snapID, nil, nil, dfb.Build())
|
|
name := fmt.Sprintf("data-manifest-%d.avro", i)
|
|
var buf bytes.Buffer
|
|
mf, err := iceberg.WriteManifest(setup.fileRef("metadata", name), &buf, 3, spec, schema, snapID, []iceberg.ManifestEntry{entry})
|
|
if err != nil {
|
|
t.Fatalf("write manifest: %v", err)
|
|
}
|
|
putFile(metaDir, name, buf.Bytes())
|
|
manifests = append(manifests, mf)
|
|
}
|
|
var mlBuf bytes.Buffer
|
|
mlw, err := iceberg.NewManifestListWriterV3(&mlBuf, snapID, 1, 0, nil)
|
|
if err != nil {
|
|
t.Fatalf("manifest list writer: %v", err)
|
|
}
|
|
if err := mlw.AddManifests(manifests); err != nil {
|
|
t.Fatalf("add manifests: %v", err)
|
|
}
|
|
if err := mlw.Close(); err != nil {
|
|
t.Fatalf("close manifest list: %v", err)
|
|
}
|
|
putFile(metaDir, "snap-1.avro", mlBuf.Bytes())
|
|
firstRowID := int64(0)
|
|
addedRows := *mlw.NextRowID()
|
|
snap := table.Snapshot{SnapshotID: snapID, SequenceNumber: 1, TimestampMs: time.Now().UnixMilli(),
|
|
ManifestList: setup.fileRef("metadata", "snap-1.avro"),
|
|
Summary: &table.Summary{Operation: table.OpAppend, Properties: map[string]string{}},
|
|
FirstRowID: &firstRowID, AddedRows: &addedRows}
|
|
builder, err := table.MetadataBuilderFromBase(meta, location)
|
|
if err != nil {
|
|
t.Fatalf("metadata builder: %v", err)
|
|
}
|
|
if err := builder.AddSnapshot(&snap); err != nil {
|
|
t.Fatalf("add snapshot: %v", err)
|
|
}
|
|
if err := builder.SetSnapshotRef(table.MainBranch, snapID, table.BranchRef); err != nil {
|
|
t.Fatalf("set ref: %v", err)
|
|
}
|
|
if meta, err = builder.Build(); err != nil {
|
|
t.Fatalf("build metadata: %v", err)
|
|
}
|
|
full, _ := json.Marshal(meta)
|
|
xattr, _ := json.Marshal(map[string]interface{}{
|
|
"metadataVersion": 1,
|
|
"metadataLocation": setup.fileRef("metadata", "v1.metadata.json"),
|
|
"metadata": map[string]interface{}{"fullMetadata": json.RawMessage(full)},
|
|
})
|
|
fs.putEntry(s3tables.TablesPath, setup.BucketName, &filer_pb.Entry{
|
|
Name: setup.BucketName, IsDirectory: true, Extended: map[string][]byte{s3tables.ExtendedKeyTableBucket: []byte("true")},
|
|
})
|
|
fs.putEntry(bucketPath, setup.Namespace, &filer_pb.Entry{Name: setup.Namespace, IsDirectory: true})
|
|
fs.putEntry(path.Join(bucketPath, setup.Namespace), setup.TableName, &filer_pb.Entry{
|
|
Name: setup.TableName, IsDirectory: true, Extended: map[string][]byte{s3tables.ExtendedKeyMetadata: xattr},
|
|
})
|
|
return meta
|
|
}
|
|
|
|
func compactGeo(t *testing.T, client filer_pb.SeaweedFilerClient, setup tableSetup, config Config) string {
|
|
t.Helper()
|
|
if config.TargetFileSizeBytes == 0 {
|
|
config.TargetFileSizeBytes = 256 * 1024 * 1024
|
|
}
|
|
if config.MinInputFiles == 0 {
|
|
config.MinInputFiles = 2
|
|
}
|
|
if config.MaxCommitRetries == 0 {
|
|
config.MaxCommitRetries = 3
|
|
}
|
|
config.ApplyDeletes = true
|
|
result, _, err := NewHandler(nil).compactDataFiles(context.Background(), client, setup.BucketName, setup.tablePath(), config, nil)
|
|
if err != nil {
|
|
t.Fatalf("compactDataFiles: %v", err)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// compactedFiles is every compact-*.parquet in the table's data directory.
|
|
func compactedFiles(t *testing.T, fs *fakeFilerServer, setup tableSetup) map[string][]byte {
|
|
t.Helper()
|
|
out := map[string][]byte{}
|
|
for _, e := range fs.listDir(path.Join(s3tables.TablesPath, setup.BucketName, setup.tablePath(), "data")) {
|
|
if strings.HasPrefix(e.Name, "compact-") {
|
|
out[e.Name] = e.Content
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
t.Fatal("no compacted file")
|
|
}
|
|
return out
|
|
}
|
|
|
|
// currentDataEntries is every entry of the current snapshot's data manifests.
|
|
func currentDataEntries(t *testing.T, client filer_pb.SeaweedFilerClient, setup tableSetup) []iceberg.ManifestEntry {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
state, err := loadCurrentMetadata(ctx, client, setup.BucketName, setup.tablePath())
|
|
if err != nil {
|
|
t.Fatalf("loadCurrentMetadata: %v", err)
|
|
}
|
|
manifests, err := loadCurrentManifests(ctx, client, setup.BucketName, state.DataPath, state.Metadata)
|
|
if err != nil {
|
|
t.Fatalf("loadCurrentManifests: %v", err)
|
|
}
|
|
var out []iceberg.ManifestEntry
|
|
for _, mf := range manifests {
|
|
if mf.ManifestContent() != iceberg.ManifestContentData {
|
|
continue
|
|
}
|
|
data, err := loadFileByIcebergPath(ctx, client, setup.BucketName, state.DataPath, mf.FilePath())
|
|
if err != nil {
|
|
t.Fatalf("load manifest: %v", err)
|
|
}
|
|
entries, err := iceberg.ReadManifest(mf, bytes.NewReader(data), true)
|
|
if err != nil {
|
|
t.Fatalf("read manifest: %v", err)
|
|
}
|
|
out = append(out, entries...)
|
|
}
|
|
return out
|
|
}
|