zeroshade commented on code in PR #1351:
URL: https://github.com/apache/iceberg-go/pull/1351#discussion_r3521453817


##########
table/rolling_data_writer.go:
##########
@@ -402,37 +441,155 @@ func (r *RollingDataWriter) stream(outputDataFilesCh 
chan<- iceberg.DataFile) {
                        sorted, err := compute.SortRecordBatch(r.ctx, 
converted, r.factory.sortKeys)
                        converted.Release()
                        if err != nil {
-                               record.Release()
-                               r.sendError(err)
-
-                               return
+                               return err
                        }
                        converted = sorted
                }
 
-               err = currentWriter.Write(converted)
+               err := currentWriter.Write(converted)
                converted.Release()
+               if err != nil {
+                       return err
+               }
+
+               if allowRoll && currentWriter.BytesWritten() >= 
r.factory.targetFileSize {
+                       return closeWriter()
+               }
+
+               return nil
+       }
+
+       // flushBootstrap infers the schema, opens the file, and replays the 
buffer.
+       flushBootstrap := func() error {
+               if len(buf) == 0 {
+                       bootstrapping = false
+
+                       return nil
+               }
+               fileArrowSchema = 
tblutils.ShreddedArrowSchema(r.factory.arrowSchema,
+                       inferShreddingFromBatches(buf, 
r.factory.shredBufferRows))
+               if err := openCurrent(); err != nil {
+                       releaseBuf()
+
+                       return err
+               }
+               // Detach so the deferred releaseBuf can't double-release the 
replayed batches.
+               batches := buf
+               buf = nil
+               bufRows = 0
+               bootstrapping = false
+               for i, b := range batches {
+                       if err := writeConverted(b, false); err != nil {
+                               for _, rest := range batches[i+1:] {
+                                       rest.Release()
+                               }
+
+                               return err
+                       }
+               }
+               if currentWriter != nil && currentWriter.BytesWritten() >= 
r.factory.targetFileSize {
+                       return closeWriter()
+               }
+
+               return nil
+       }
+
+       for record := range r.recordCh {
+               converted, err := ToRequestedSchema(r.ctx, r.factory.fileSchema,
+                       r.factory.taskSchema, record, SchemaOptions{
+                               DowncastTimestamp: true,
+                               IncludeFieldIDs:   true,
+                               UseWriteDefault:   true,
+                       })
                record.Release()
                if err != nil {
                        r.sendError(err)
 
                        return
                }
 
-               if currentWriter.BytesWritten() >= r.factory.targetFileSize {
-                       if err := closeWriter(); err != nil {
+               if bootstrapping {
+                       buf = append(buf, converted)

Review Comment:
   [MAJOR] `shredBufferRows` is documented as rows buffered per file, but this 
appends the entire converted batch before checking the row limit. A single 
large upstream batch (for example 1M rows with a small configured limit) will 
still be fully retained until bootstrap flushes, causing the memory spike the 
limit is meant to prevent and violating the property contract. Please either 
slice batches so only the first `shredBufferRows` rows are retained for 
bootstrap and write the remainder after the file opens, or document this as a 
batch-granular limit with an additional byte/batch bound. A regression test 
with one large batch and a small `write.parquet.variant-inference-buffer-size` 
would catch this.



##########
table/rolling_data_writer.go:
##########
@@ -402,37 +441,155 @@ func (r *RollingDataWriter) stream(outputDataFilesCh 
chan<- iceberg.DataFile) {
                        sorted, err := compute.SortRecordBatch(r.ctx, 
converted, r.factory.sortKeys)
                        converted.Release()
                        if err != nil {
-                               record.Release()
-                               r.sendError(err)
-
-                               return
+                               return err
                        }
                        converted = sorted
                }
 
-               err = currentWriter.Write(converted)
+               err := currentWriter.Write(converted)
                converted.Release()
+               if err != nil {
+                       return err
+               }
+
+               if allowRoll && currentWriter.BytesWritten() >= 
r.factory.targetFileSize {
+                       return closeWriter()
+               }
+
+               return nil
+       }
+
+       // flushBootstrap infers the schema, opens the file, and replays the 
buffer.
+       flushBootstrap := func() error {
+               if len(buf) == 0 {
+                       bootstrapping = false
+
+                       return nil
+               }
+               fileArrowSchema = 
tblutils.ShreddedArrowSchema(r.factory.arrowSchema,
+                       inferShreddingFromBatches(buf, 
r.factory.shredBufferRows))
+               if err := openCurrent(); err != nil {
+                       releaseBuf()
+
+                       return err
+               }
+               // Detach so the deferred releaseBuf can't double-release the 
replayed batches.
+               batches := buf
+               buf = nil
+               bufRows = 0
+               bootstrapping = false
+               for i, b := range batches {
+                       if err := writeConverted(b, false); err != nil {
+                               for _, rest := range batches[i+1:] {
+                                       rest.Release()
+                               }
+
+                               return err
+                       }
+               }
+               if currentWriter != nil && currentWriter.BytesWritten() >= 
r.factory.targetFileSize {
+                       return closeWriter()
+               }
+
+               return nil
+       }
+
+       for record := range r.recordCh {
+               converted, err := ToRequestedSchema(r.ctx, r.factory.fileSchema,
+                       r.factory.taskSchema, record, SchemaOptions{
+                               DowncastTimestamp: true,
+                               IncludeFieldIDs:   true,
+                               UseWriteDefault:   true,
+                       })
                record.Release()
                if err != nil {
                        r.sendError(err)
 
                        return
                }
 
-               if currentWriter.BytesWritten() >= r.factory.targetFileSize {
-                       if err := closeWriter(); err != nil {
+               if bootstrapping {
+                       buf = append(buf, converted)
+                       bufRows += converted.NumRows()
+                       if bufRows >= int64(r.factory.shredBufferRows) {
+                               if err := flushBootstrap(); err != nil {
+                                       r.sendError(err)
+
+                                       return
+                               }
+                       }
+
+                       continue
+               }
+
+               if currentWriter == nil {
+                       if err := openCurrent(); err != nil {
+                               converted.Release()
                                r.sendError(err)
 
                                return
                        }
                }
+               if err := writeConverted(converted, true); err != nil {
+                       r.sendError(err)
+
+                       return
+               }
        }
 
+       // Channel closed: flush any partial bootstrap buffer, then finalize.
+       if bootstrapping {
+               if err := flushBootstrap(); err != nil {
+                       r.sendError(err)
+
+                       return
+               }
+       }
        if err := closeWriter(); err != nil {
                r.sendError(err)
        }
 }
 
+// inferShreddingFromBatches infers the inner type per top-level variant 
column,
+// sampling up to limit non-null values from the buffer. Keyed by column index.
+func inferShreddingFromBatches(buf []arrow.RecordBatch, limit int) 
map[int]arrow.DataType {
+       if len(buf) == 0 {
+               return nil
+       }
+
+       inferred := make(map[int]arrow.DataType)

Review Comment:
   [NIT] Since this map is keyed by column index, future changes beyond 
top-level use may be easier to follow if the key carries the field ID or name 
instead.



##########
table/rolling_data_writer.go:
##########
@@ -402,37 +441,155 @@ func (r *RollingDataWriter) stream(outputDataFilesCh 
chan<- iceberg.DataFile) {
                        sorted, err := compute.SortRecordBatch(r.ctx, 
converted, r.factory.sortKeys)
                        converted.Release()
                        if err != nil {
-                               record.Release()
-                               r.sendError(err)
-
-                               return
+                               return err
                        }
                        converted = sorted
                }
 
-               err = currentWriter.Write(converted)
+               err := currentWriter.Write(converted)
                converted.Release()
+               if err != nil {
+                       return err
+               }
+
+               if allowRoll && currentWriter.BytesWritten() >= 
r.factory.targetFileSize {
+                       return closeWriter()
+               }
+
+               return nil
+       }
+
+       // flushBootstrap infers the schema, opens the file, and replays the 
buffer.
+       flushBootstrap := func() error {
+               if len(buf) == 0 {
+                       bootstrapping = false
+
+                       return nil
+               }
+               fileArrowSchema = 
tblutils.ShreddedArrowSchema(r.factory.arrowSchema,
+                       inferShreddingFromBatches(buf, 
r.factory.shredBufferRows))
+               if err := openCurrent(); err != nil {
+                       releaseBuf()
+
+                       return err
+               }
+               // Detach so the deferred releaseBuf can't double-release the 
replayed batches.
+               batches := buf
+               buf = nil
+               bufRows = 0
+               bootstrapping = false
+               for i, b := range batches {
+                       if err := writeConverted(b, false); err != nil {
+                               for _, rest := range batches[i+1:] {
+                                       rest.Release()
+                               }
+
+                               return err
+                       }
+               }
+               if currentWriter != nil && currentWriter.BytesWritten() >= 
r.factory.targetFileSize {
+                       return closeWriter()
+               }
+
+               return nil
+       }
+
+       for record := range r.recordCh {
+               converted, err := ToRequestedSchema(r.ctx, r.factory.fileSchema,
+                       r.factory.taskSchema, record, SchemaOptions{
+                               DowncastTimestamp: true,
+                               IncludeFieldIDs:   true,
+                               UseWriteDefault:   true,
+                       })
                record.Release()
                if err != nil {
                        r.sendError(err)
 
                        return
                }
 
-               if currentWriter.BytesWritten() >= r.factory.targetFileSize {
-                       if err := closeWriter(); err != nil {
+               if bootstrapping {
+                       buf = append(buf, converted)
+                       bufRows += converted.NumRows()
+                       if bufRows >= int64(r.factory.shredBufferRows) {
+                               if err := flushBootstrap(); err != nil {
+                                       r.sendError(err)
+
+                                       return
+                               }
+                       }
+
+                       continue
+               }
+
+               if currentWriter == nil {
+                       if err := openCurrent(); err != nil {
+                               converted.Release()
                                r.sendError(err)
 
                                return
                        }
                }
+               if err := writeConverted(converted, true); err != nil {
+                       r.sendError(err)
+
+                       return
+               }
        }
 
+       // Channel closed: flush any partial bootstrap buffer, then finalize.
+       if bootstrapping {
+               if err := flushBootstrap(); err != nil {
+                       r.sendError(err)
+
+                       return
+               }
+       }
        if err := closeWriter(); err != nil {
                r.sendError(err)
        }
 }
 
+// inferShreddingFromBatches infers the inner type per top-level variant 
column,
+// sampling up to limit non-null values from the buffer. Keyed by column index.
+func inferShreddingFromBatches(buf []arrow.RecordBatch, limit int) 
map[int]arrow.DataType {
+       if len(buf) == 0 {
+               return nil
+       }
+
+       inferred := make(map[int]arrow.DataType)
+       for col := 0; col < int(buf[0].NumCols()); col++ {
+               if _, ok := buf[0].Column(col).(*extensions.VariantArray); !ok {
+                       continue
+               }
+               var sample []variant.Value
+               for _, b := range buf {
+                       if len(sample) >= limit {
+                               break
+                       }
+                       va, ok := b.Column(col).(*extensions.VariantArray)
+                       if !ok {
+                               continue
+                       }
+                       for i := 0; i < va.Len() && len(sample) < limit; i++ {
+                               if va.Storage().IsNull(i) {
+                                       continue
+                               }
+                               v, err := va.Value(i)
+                               if err != nil {

Review Comment:
   [QUESTION] Should `va.Value(i)` errors be surfaced instead of silently 
skipped? Ignoring malformed values can infer shredding from a biased subset of 
rows. Returning an error, or at least logging/tracking skipped rows, would make 
this easier to diagnose.



##########
table/internal/variant_shredding.go:
##########
@@ -0,0 +1,421 @@
+// 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 internal
+
+import (
+       "sort"
+
+       "github.com/apache/arrow-go/v18/arrow"
+       "github.com/apache/arrow-go/v18/arrow/decimal"
+       "github.com/apache/arrow-go/v18/arrow/extensions"
+       "github.com/apache/arrow-go/v18/parquet/variant"
+)
+
+// Variant shredding inference: most-common-type selection with explicit
+// tie-break, integer/decimal widening, and frequency/depth/field-count bounds.
+const (
+       minFieldFrequency     = 0.10
+       maxShreddedFields     = 300
+       maxShreddingDepth     = 50
+       maxIntermediateFields = 1000
+)
+
+// integerPriority and decimalPriority pick the widest observed family member.
+var integerPriority = map[variant.Type]int{
+       variant.Int8: 0, variant.Int16: 1, variant.Int32: 2, variant.Int64: 3,
+}
+
+var decimalPriority = map[variant.Type]int{
+       variant.Decimal4: 0, variant.Decimal8: 1, variant.Decimal16: 2,
+}
+
+// tieBreakPriority breaks count ties in most-common-type selection; higher 
wins.
+// Types absent here (Null, Array, Object) resolve to -1.
+var tieBreakPriority = map[variant.Type]int{
+       variant.Bool: 0, variant.Int8: 1, variant.Int16: 2, variant.Int32: 3,
+       variant.Int64: 4, variant.Float: 5, variant.Double: 6, 
variant.Decimal4: 7,
+       variant.Decimal8: 8, variant.Decimal16: 9, variant.Date: 10, 
variant.Time: 11,
+       variant.TimestampMicros: 12, variant.TimestampMicrosNTZ: 13, 
variant.Binary: 14,
+       variant.String: 15, variant.TimestampNanos: 16, 
variant.TimestampNanosNTZ: 17,
+       variant.UUID: 18,
+}
+
+type fieldInfo struct {
+       typeCounts          map[variant.Type]int
+       observationCount    int
+       maxDecimalScale     int
+       maxDecimalIntDigits int
+}
+
+func newFieldInfo() *fieldInfo {
+       return &fieldInfo{typeCounts: make(map[variant.Type]int)}
+}
+
+// observe records one value's type at this node (per-value counting).
+func (f *fieldInfo) observe(v variant.Value) {
+       f.observationCount++
+
+       t := v.Type()
+       // arrow-go has a single Bool type, so no false-folds-to-true step.
+       f.typeCounts[t]++
+
+       if isDecimalType(t) {
+               intDigits, scale := decimalDigits(v.Value())
+               if scale > f.maxDecimalScale {
+                       f.maxDecimalScale = scale
+               }
+               if intDigits > f.maxDecimalIntDigits {
+                       f.maxDecimalIntDigits = intDigits
+               }
+       }
+}
+
+// mostCommonType collapses int/decimal families to the widest, then picks max 
by count.
+func (f *fieldInfo) mostCommonType() (variant.Type, bool) {
+       combined := make(map[variant.Type]int)
+
+       intTotal, decTotal := 0, 0
+       var widestInt, widestDec variant.Type
+       haveInt, haveDec := false, false
+
+       for t, c := range f.typeCounts {
+               switch {
+               case isIntegerType(t):
+                       intTotal += c
+                       if !haveInt || integerPriority[t] > 
integerPriority[widestInt] {
+                               widestInt, haveInt = t, true
+                       }
+               case isDecimalType(t):
+                       decTotal += c
+                       if !haveDec || decimalPriority[t] > 
decimalPriority[widestDec] {
+                               widestDec, haveDec = t, true
+                       }
+               default:
+                       combined[t] = c
+               }
+       }
+       if haveInt {
+               combined[widestInt] = intTotal
+       }
+       if haveDec {
+               combined[widestDec] = decTotal
+       }
+
+       if len(combined) == 0 {
+               return 0, false
+       }
+
+       // Max by count, then tieBreakPriority, then type value (stable, sorted 
keys).
+       var best variant.Type
+       bestCount, bestPrio := -1, -2
+       first := true
+       keys := make([]variant.Type, 0, len(combined))
+       for t := range combined {
+               keys = append(keys, t)
+       }
+       sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
+       for _, t := range keys {
+               c := combined[t]
+               p := tiePriority(t)
+               if first || c > bestCount || (c == bestCount && p > bestPrio) {
+                       best, bestCount, bestPrio, first = t, c, p, false
+               }
+       }
+
+       return best, true
+}
+
+func tiePriority(t variant.Type) int {
+       if p, ok := tieBreakPriority[t]; ok {
+               return p
+       }
+
+       return -1
+}
+
+func isIntegerType(t variant.Type) bool {
+       switch t {
+       case variant.Int8, variant.Int16, variant.Int32, variant.Int64:
+               return true
+       }
+
+       return false
+}
+
+func isDecimalType(t variant.Type) bool {
+       switch t {
+       case variant.Decimal4, variant.Decimal8, variant.Decimal16:
+               return true
+       }
+
+       return false
+}
+
+// decimalDigits returns the integer-digit count and scale of a variant 
decimal.
+func decimalDigits(val any) (intDigits, scale int) {
+       switch d := val.(type) {
+       case variant.DecimalValue[decimal.Decimal32]:
+               return coefficientDigits(d.Value.ToString(0)) - int(d.Scale), 
int(d.Scale)
+       case variant.DecimalValue[decimal.Decimal64]:
+               return coefficientDigits(d.Value.ToString(0)) - int(d.Scale), 
int(d.Scale)
+       case variant.DecimalValue[decimal.Decimal128]:
+               return coefficientDigits(d.Value.ToString(0)) - int(d.Scale), 
int(d.Scale)
+       }
+
+       return 0, 0
+}
+
+func coefficientDigits(s string) int {
+       if len(s) > 0 && s[0] == '-' {
+               s = s[1:]
+       }
+
+       return len(s)
+}
+
+type pathNode struct {
+       info           *fieldInfo
+       objectChildren map[string]*pathNode
+       arrayElement   *pathNode
+}
+
+func newPathNode() *pathNode {
+       return &pathNode{info: newFieldInfo(), objectChildren: 
make(map[string]*pathNode)}
+}
+
+// AnalyzeVariantShredding infers the inner Arrow type to shred the sample by, 
or
+// ok=false to not shred. The result is the INNER type for 
NewShreddedVariantType.
+func AnalyzeVariantShredding(sample []variant.Value) (arrow.DataType, bool) {
+       if len(sample) == 0 {
+               return nil, false
+       }
+
+       root := newPathNode()
+       for _, v := range sample {
+               traverseVariant(root, v, 0)
+       }
+
+       rootType, ok := root.info.mostCommonType()
+       if !ok {
+               return nil, false
+       }
+
+       pruneInfrequent(root, root.info.observationCount)
+
+       dt := buildInnerType(root, rootType)
+       if dt == nil {
+               return nil, false
+       }
+
+       return dt, true
+}
+
+func traverseVariant(node *pathNode, v variant.Value, depth int) {
+       t := v.Type()
+       if t == variant.Null {
+               return
+       }
+
+       node.info.observe(v)
+
+       switch {
+       case t == variant.Object && depth < maxShreddingDepth:
+               obj, ok := v.Value().(variant.ObjectValue)
+               if !ok {
+                       return
+               }
+               for name, fv := range obj.Values() {
+                       child := node.objectChildren[name]
+                       if child == nil {
+                               if len(node.objectChildren) >= 
maxIntermediateFields {
+                                       continue
+                               }
+                               child = newPathNode()
+                               node.objectChildren[name] = child
+                       }
+                       traverseVariant(child, fv, depth+1)
+               }
+       case t == variant.Array && depth < maxShreddingDepth:
+               arr, ok := v.Value().(variant.ArrayValue)
+               if !ok {
+                       return
+               }
+               if node.arrayElement == nil {
+                       node.arrayElement = newPathNode()
+               }
+               for ev := range arr.Values() {
+                       traverseVariant(node.arrayElement, ev, depth+1)
+               }
+       }
+}
+
+func pruneInfrequent(node *pathNode, totalRows int) {
+       if len(node.objectChildren) == 0 && node.arrayElement == nil {
+               return
+       }
+
+       // Frequency floor (strict <); exactly minFieldFrequency is kept.
+       for name, child := range node.objectChildren {
+               if float64(child.info.observationCount)/float64(totalRows) < 
minFieldFrequency {
+                       delete(node.objectChildren, name)
+               }
+       }
+
+       // Cap to maxShreddedFields, keeping the highest count; on a count tie 
keep the
+       // alphabetically-smaller name.
+       if len(node.objectChildren) > maxShreddedFields {
+               names := make([]string, 0, len(node.objectChildren))
+               for name := range node.objectChildren {
+                       names = append(names, name)
+               }
+               sort.Slice(names, func(i, j int) bool {
+                       ci := 
node.objectChildren[names[i]].info.observationCount
+                       cj := 
node.objectChildren[names[j]].info.observationCount
+                       if ci != cj {
+                               return ci > cj
+                       }
+
+                       return names[i] < names[j]
+               })
+               for _, name := range names[maxShreddedFields:] {
+                       delete(node.objectChildren, name)
+               }
+       }
+
+       for _, child := range node.objectChildren {
+               pruneInfrequent(child, totalRows)

Review Comment:
   [MINOR] The `maxIntermediateFields` and `maxShreddedFields` caps are applied 
per object node (see the child creation/pruning above), not as a global 
inferred-schema budget. Deep or broad samples can still infer a very large 
recursive schema. Please add a global field/node budget, or rename/document 
these as per-object caps and add a nested high-cardinality test.



##########
table/internal/parquet_files.go:
##########
@@ -305,6 +311,12 @@ func (parquetFormat) GetWriteProperties(props 
iceberg.Properties) any {
                writerProps = append(writerProps, 
parquet.WithBloomFilterEnabledFor(colName, enabled))
        }
 
+       // Shredded decimals need INT32/INT64/FLBA-by-precision 
(VariantShredding.md);
+       // arrow-go emits those only with StoreDecimalAsInteger. Gated to keep 
default writes unchanged.
+       if props.GetBool(ParquetShredVariantsKey, ParquetShredVariantsDefault) {

Review Comment:
   [MAJOR] This enables `StoreDecimalAsInteger` solely from the table property, 
not from the per-file inference result. As a result, 
`write.parquet.shred-variants=true` changes the physical encoding of ordinary 
decimal columns in unrelated files even when no shredded decimal `typed_value` 
is present. Please select decimal-as-integer per file after inference 
determines a shredded decimal is needed, or explicitly document the table-level 
encoding side effect and add property-on/no-variant-column coverage.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to