mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-24 06:50:40 +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>
252 lines
10 KiB
Go
252 lines
10 KiB
Go
package iceberg
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"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"
|
|
)
|
|
|
|
// unsetDefaultCRS makes an input file declare the default CRS as no CRS at
|
|
// all, as DuckDB and other writers do; parquet.Geometry("") spells it out.
|
|
func unsetDefaultCRS(t *testing.T, data []byte) []byte {
|
|
t.Helper()
|
|
out, changed, err := withDefaultGeometryCRSUnset(data, geoTestSchema(iceberg.GeometryType{}))
|
|
if err != nil || !changed {
|
|
t.Fatalf("unset the input's default CRS: changed %v, %v", changed, err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func fiveGeoFiles() []geoFile {
|
|
var files []geoFile
|
|
for f := 0; f < 5; f++ {
|
|
var rows []geoRow
|
|
for i := 0; i < 40; i++ {
|
|
id := int64(f*40 + i)
|
|
rows = append(rows, geoRow{ID: id, Geom: wkbPoint(float64(id%20)-10, float64(id/20)-5)})
|
|
}
|
|
files = append(files, geoFile{Name: fmt.Sprintf("g%d.parquet", f), Rows: rows})
|
|
}
|
|
return files
|
|
}
|
|
|
|
// The Parquet CRS of a default-CRS geometry column survives compaction as the
|
|
// inputs declared it: none. The control shows what parquet-go does with the
|
|
// same schema on its own: it spells the default out.
|
|
func TestCompactWritesDefaultCRSGeometryWithoutCRS(t *testing.T) {
|
|
control := unsetDefaultCRS(t, writeGeoParquet(t, []geoRow{{1, wkbPoint(1, 2)}}, ""))
|
|
if crs, ok := footerGeometryCRS(t, control, "geom"); !ok || crs != "" {
|
|
t.Fatalf("setup: an input file declares CRS %q (geometry %v), want none", crs, ok)
|
|
}
|
|
f, err := parquet.OpenFile(bytes.NewReader(control), int64(len(control)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var plain bytes.Buffer
|
|
w := parquet.NewWriter(&plain, f.Schema())
|
|
if _, err := w.WriteRows([]parquet.Row{{parquet.ByteArrayValue(wkbPoint(1, 2)).Level(0, 1, 0), parquet.Int64Value(1).Level(0, 0, 1)}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := w.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if crs, _ := footerGeometryCRS(t, plain.Bytes(), "geom"); crs != iceberg.DefaultGeoCRS {
|
|
t.Fatalf("control: parquet-go copied the schema with CRS %q; the fix is for %q", crs, iceberg.DefaultGeoCRS)
|
|
}
|
|
|
|
for _, strategy := range []string{"binpack", "sort"} {
|
|
t.Run(strategy, func(t *testing.T) {
|
|
fs, client := startFakeFiler(t)
|
|
setup := tableSetup{BucketName: "tb", Namespace: "ns", TableName: "geo"}
|
|
opt := geoTableOptions{fixup: unsetDefaultCRS}
|
|
config := Config{}
|
|
if strategy == "sort" {
|
|
so, err := table.NewSortOrder(1, []table.SortField{{SourceIDs: []int{geoTestIDField}, Transform: iceberg.IdentityTransform{}, Direction: table.SortASC, NullOrder: table.NullsFirst}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
opt.sortOrder = &so
|
|
config.RewriteStrategy, config.SortBufferRows = "sort", minSortBufferRows
|
|
}
|
|
files := fiveGeoFiles()
|
|
files[2].ParquetCRSSet = true // an input that spells the default out
|
|
populateGeoTable(t, fs, setup, files, opt)
|
|
if result := compactGeo(t, client, setup, config); !strings.Contains(result, "compacted 5 files") {
|
|
t.Fatalf("result %q", result)
|
|
}
|
|
for name, data := range compactedFiles(t, fs, setup) {
|
|
if crs, ok := footerGeometryCRS(t, data, "geom"); !ok || crs != "" {
|
|
t.Errorf("%s: geometry column declares CRS %q (geometry %v), want none", name, crs, ok)
|
|
}
|
|
got, err := parquet.Read[struct {
|
|
ID int64 `parquet:"id"`
|
|
}](bytes.NewReader(data), int64(len(data)))
|
|
if err != nil || len(got) != 200 {
|
|
t.Errorf("%s: the rewritten file reads %d rows, %v", name, len(got), err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// An explicit CRS is not the default: it is written as the input declared it.
|
|
func TestCompactKeepsExplicitGeometryCRS(t *testing.T) {
|
|
fs, client := startFakeFiler(t)
|
|
setup := tableSetup{BucketName: "tb", Namespace: "ns", TableName: "geo"}
|
|
g, err := iceberg.GeometryTypeOf("EPSG:2913")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
populateGeoTable(t, fs, setup, fiveGeoFiles(), geoTableOptions{icebergType: g, parquetCRS: "EPSG:2913"})
|
|
compactGeo(t, client, setup, Config{})
|
|
for name, data := range compactedFiles(t, fs, setup) {
|
|
if crs, ok := footerGeometryCRS(t, data, "geom"); !ok || crs != "EPSG:2913" {
|
|
t.Errorf("%s: geometry CRS %q (geometry %v), want EPSG:2913", name, crs, ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A default-CRS geometry nested in a struct is written without a CRS too.
|
|
func TestDefaultCRSUnsetForNestedGeometry(t *testing.T) {
|
|
schema := parquet.NewSchema("table", parquet.Group{
|
|
"id": parquet.FieldID(parquet.Compressed(parquet.Int(64), &zstd.Codec{}), 1),
|
|
"loc": parquet.FieldID(parquet.Group{"geom": parquet.FieldID(parquet.Optional(parquet.Geometry("")), 3)}, 2),
|
|
})
|
|
var buf bytes.Buffer
|
|
w := parquet.NewGenericWriter[any](&buf, schema)
|
|
cols := map[string]int{}
|
|
for i, p := range schema.Columns() {
|
|
cols[strings.Join(p, ".")] = i
|
|
}
|
|
row := make(parquet.Row, 2)
|
|
row[cols["id"]] = parquet.Int64Value(1).Level(0, 0, cols["id"])
|
|
row[cols["loc.geom"]] = parquet.ByteArrayValue(wkbPoint(1, 2)).Level(0, 1, cols["loc.geom"])
|
|
if _, err := w.WriteRows([]parquet.Row{row}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := w.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if crs, ok := footerGeometryCRS(t, buf.Bytes(), "geom"); !ok || crs != iceberg.DefaultGeoCRS {
|
|
t.Fatalf("setup: the nested column declares %q (geometry %v); the control is the spelled-out default", crs, ok)
|
|
}
|
|
ice := iceberg.NewSchema(0,
|
|
iceberg.NestedField{ID: 1, Type: iceberg.PrimitiveTypes.Int64, Name: "id", Required: true},
|
|
iceberg.NestedField{ID: 2, Name: "loc", Type: &iceberg.StructType{FieldList: []iceberg.NestedField{{ID: 3, Name: "geom", Type: iceberg.GeometryType{}}}}})
|
|
out, changed, err := withDefaultGeometryCRSUnset(buf.Bytes(), ice)
|
|
if err != nil || !changed {
|
|
t.Fatalf("changed %v, %v", changed, err)
|
|
}
|
|
if crs, ok := footerGeometryCRS(t, out, "geom"); !ok || crs != "" {
|
|
t.Errorf("the nested geometry declares CRS %q (geometry %v), want none", crs, ok)
|
|
}
|
|
got, err := parquet.Read[struct {
|
|
ID int64 `parquet:"id"`
|
|
}](bytes.NewReader(out), int64(len(out)))
|
|
if err != nil || len(got) != 1 || got[0].ID != 1 {
|
|
t.Errorf("the rewritten file reads %v, %v", got, err)
|
|
}
|
|
}
|
|
|
|
// footerGeometryCRSs is the CRS of every GEOMETRY element in the footer, by
|
|
// schema element index and name.
|
|
func footerGeometryCRSs(t *testing.T, data []byte) map[string]string {
|
|
t.Helper()
|
|
f, err := parquet.OpenFile(bytes.NewReader(data), int64(len(data)))
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
out := map[string]string{}
|
|
for i, el := range f.Metadata().Schema {
|
|
if g, ok := el.LogicalType.Value.(*format.GeometryType); ok {
|
|
out[fmt.Sprintf("%d:%s", i, el.Name)] = g.CRS
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// A file without field ids is matched to the table by name: through the
|
|
// LIST and MAP wrapper groups too, which Iceberg's field names leave out.
|
|
func TestDefaultCRSUnsetWithoutFieldIDs(t *testing.T) {
|
|
geom := func() parquet.Node { return parquet.Optional(parquet.Geometry("")) }
|
|
element := func(id int, typ iceberg.Type) *iceberg.ListType {
|
|
return &iceberg.ListType{ElementID: id, Element: typ, ElementRequired: false}
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
parquet parquet.Group
|
|
iceberg []iceberg.NestedField
|
|
}{
|
|
{"top level", parquet.Group{"id": parquet.Int(64), "geom": geom()},
|
|
[]iceberg.NestedField{{ID: 1, Name: "id", Type: iceberg.PrimitiveTypes.Int64, Required: true}, {ID: 2, Name: "geom", Type: iceberg.GeometryType{}}}},
|
|
{"list", parquet.Group{"shapes": parquet.Optional(parquet.List(geom()))},
|
|
[]iceberg.NestedField{{ID: 1, Name: "shapes", Type: element(2, iceberg.GeometryType{})}}},
|
|
{"list of structs", parquet.Group{"items": parquet.Optional(parquet.List(parquet.Group{"geom": geom()}))},
|
|
[]iceberg.NestedField{{ID: 1, Name: "items", Type: element(2, &iceberg.StructType{FieldList: []iceberg.NestedField{{ID: 3, Name: "geom", Type: iceberg.GeometryType{}}}})}}},
|
|
{"map", parquet.Group{"attrs": parquet.Optional(parquet.Map(parquet.String(), geom()))},
|
|
[]iceberg.NestedField{{ID: 1, Name: "attrs", Type: &iceberg.MapType{KeyID: 2, KeyType: iceberg.PrimitiveTypes.String, ValueID: 3, ValueType: iceberg.GeometryType{}}}}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
w := parquet.NewGenericWriter[any](&buf, parquet.NewSchema("table", tc.parquet))
|
|
if err := w.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before := footerGeometryCRSs(t, buf.Bytes())
|
|
if len(before) != 1 {
|
|
t.Fatalf("setup: geometry columns %v, want one", before)
|
|
}
|
|
for path, crs := range before {
|
|
if crs != iceberg.DefaultGeoCRS {
|
|
t.Fatalf("setup: %s declares %q; the control is the spelled-out default", path, crs)
|
|
}
|
|
}
|
|
out, changed, err := withDefaultGeometryCRSUnset(buf.Bytes(), iceberg.NewSchema(0, tc.iceberg...))
|
|
if err != nil || !changed {
|
|
t.Fatalf("changed %v, %v", changed, err)
|
|
}
|
|
for path, crs := range footerGeometryCRSs(t, out) {
|
|
if crs != "" {
|
|
t.Errorf("%s declares CRS %q, want none", path, crs)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The footer is replaced inside the file's own buffer: a compacted file can be
|
|
// as large as the target file size, and a copy of it would double the memory
|
|
// the rewrite needs.
|
|
func TestDefaultCRSUnsetReusesBuffer(t *testing.T) {
|
|
data := writeGeoParquet(t, []geoRow{{1, wkbPoint(1, 2)}, {2, wkbPoint(3, 4)}}, "")
|
|
if crs, ok := footerGeometryCRS(t, data, "geom"); !ok || crs != iceberg.DefaultGeoCRS {
|
|
t.Fatalf("setup: CRS %q (geometry %v); the control is the spelled-out default", crs, ok)
|
|
}
|
|
first := &data[0]
|
|
out, changed, err := withDefaultGeometryCRSUnset(data, geoTestSchema(iceberg.GeometryType{}))
|
|
if err != nil || !changed {
|
|
t.Fatalf("changed %v, %v", changed, err)
|
|
}
|
|
if &out[0] != first {
|
|
t.Errorf("the rewrite copied the file into a new buffer")
|
|
}
|
|
if crs, ok := footerGeometryCRS(t, out, "geom"); !ok || crs != "" {
|
|
t.Errorf("geometry declares CRS %q (geometry %v), want none", crs, ok)
|
|
}
|
|
got, err := parquet.Read[struct {
|
|
ID int64 `parquet:"id"`
|
|
Geom []byte `parquet:"geom,optional"`
|
|
}](bytes.NewReader(out), int64(len(out)))
|
|
if err != nil || len(got) != 2 || got[1].ID != 2 || !bytes.Equal(got[1].Geom, wkbPoint(3, 4)) {
|
|
t.Errorf("the rewritten file reads %v, %v", got, err)
|
|
}
|
|
}
|