laskoviymishka commented on code in PR #2029:
URL: https://github.com/apache/iceberg-go/pull/2029#discussion_r4064199682


##########
data_file_codec.go:
##########
@@ -333,3 +334,84 @@ func manifestEntrySchemaFor(spec PartitionSpec, schema 
*Schema, version int) (*a
 
        return entry.schema, entry.maps, nil
 }
+
+func partitionSchemaFingerprint(spec PartitionSpec, schema *Schema) (string, 
error) {
+       var key strings.Builder
+       var buf [20]byte
+       writeInt := func(value int) {

Review Comment:
   This closure does exactly what `writePartitionFingerprintInt` does a few 
lines down, just with a reused buffer instead of a fresh one. Both are 
stack-local and the standalone one inlines, so there's no perf difference.
   
   I'd drop the closure and call `writePartitionFingerprintInt` here too, so 
the format lives in one place if the separator or base ever changes.



##########
data_file_codec.go:
##########
@@ -333,3 +334,84 @@ func manifestEntrySchemaFor(spec PartitionSpec, schema 
*Schema, version int) (*a
 
        return entry.schema, entry.maps, nil
 }
+
+func partitionSchemaFingerprint(spec PartitionSpec, schema *Schema) (string, 
error) {
+       var key strings.Builder
+       var buf [20]byte
+       writeInt := func(value int) {
+               key.Write(strconv.AppendInt(buf[:0], int64(value), 10))
+               key.WriteByte(':')
+       }
+       for _, field := range spec.fields {
+               sourceType := Type(UnknownType{})
+               // ResultType only inspects the source type here; borrow it to 
avoid
+               // cloning nested source types on every schema-cache hit.
+               if sourceField, ok := schema.FindFieldByIDRef(field.SourceID(), 
internal.SchemaRef{}); ok {
+                       sourceType = sourceField.Type
+               }
+               resultType := field.Transform.ResultType(sourceType)
+               key.Grow(len(field.Name) + 32)
+
+               // Length prefixes keep names unambiguous. The compact type tag 
also
+               // includes fixed lengths and decimal precision/scale without 
formatting
+               // a temporary type string on every cache hit.
+               writeInt(field.FieldID)
+               writeInt(len(field.Name))
+               key.WriteString(field.Name)
+               if err := writePartitionTypeFingerprint(&key, resultType); err 
!= nil {
+                       return "", err
+               }
+       }
+
+       return key.String(), nil
+}
+
+func writePartitionTypeFingerprint(key *strings.Builder, typ Type) error {

Review Comment:
   I think there's a maintenance trap here worth closing before we merge. This 
switch and `partitionTypeToAvroSchema` now have to enumerate the exact same 
type set, but neither references the other and nothing fails if they drift.
   
   The concrete failure mode: someone adds `timestamp_ns` support to 
`partitionTypeToAvroSchema` (those are valid V3 primitives, and the reject test 
already lists `TimestampNsType`/`TimestampTzNsType` as the ones we don't handle 
yet). If they don't also touch this switch, the `default` fires and 
`manifestEntrySchemaFor` starts returning "unsupported partition type" for 
every encode/decode on those tables. That's a regression the old single-path 
design couldn't produce.
   
   I'd add a test that feeds each type `partitionTypeToAvroSchema` accepts 
through `writePartitionTypeFingerprint` and asserts no error, so the two 
switches fail loudly at test time the moment they fall out of sync. A short 
comment on each function naming the other as its counterpart would help too. 
wdyt?



##########
data_file_codec.go:
##########
@@ -333,3 +334,84 @@ func manifestEntrySchemaFor(spec PartitionSpec, schema 
*Schema, version int) (*a
 
        return entry.schema, entry.maps, nil
 }
+
+func partitionSchemaFingerprint(spec PartitionSpec, schema *Schema) (string, 
error) {
+       var key strings.Builder
+       var buf [20]byte
+       writeInt := func(value int) {
+               key.Write(strconv.AppendInt(buf[:0], int64(value), 10))
+               key.WriteByte(':')
+       }
+       for _, field := range spec.fields {
+               sourceType := Type(UnknownType{})
+               // ResultType only inspects the source type here; borrow it to 
avoid
+               // cloning nested source types on every schema-cache hit.
+               if sourceField, ok := schema.FindFieldByIDRef(field.SourceID(), 
internal.SchemaRef{}); ok {
+                       sourceType = sourceField.Type
+               }
+               resultType := field.Transform.ResultType(sourceType)
+               key.Grow(len(field.Name) + 32)

Review Comment:
   The 32 here is a tight-but-fine budget (fieldID up to 7, name-length prefix 
up to 4, tag 1, decimal params up to 14 all fit, and Builder grows anyway if it 
doesn't). A named const or a one-line comment on what the 32 covers would save 
the next reader the arithmetic.



##########
data_file_codec.go:
##########
@@ -333,3 +334,84 @@ func manifestEntrySchemaFor(spec PartitionSpec, schema 
*Schema, version int) (*a
 
        return entry.schema, entry.maps, nil
 }
+
+func partitionSchemaFingerprint(spec PartitionSpec, schema *Schema) (string, 
error) {
+       var key strings.Builder
+       var buf [20]byte
+       writeInt := func(value int) {
+               key.Write(strconv.AppendInt(buf[:0], int64(value), 10))
+               key.WriteByte(':')
+       }
+       for _, field := range spec.fields {
+               sourceType := Type(UnknownType{})
+               // ResultType only inspects the source type here; borrow it to 
avoid
+               // cloning nested source types on every schema-cache hit.
+               if sourceField, ok := schema.FindFieldByIDRef(field.SourceID(), 
internal.SchemaRef{}); ok {
+                       sourceType = sourceField.Type
+               }
+               resultType := field.Transform.ResultType(sourceType)
+               key.Grow(len(field.Name) + 32)
+
+               // Length prefixes keep names unambiguous. The compact type tag 
also
+               // includes fixed lengths and decimal precision/scale without 
formatting
+               // a temporary type string on every cache hit.
+               writeInt(field.FieldID)
+               writeInt(len(field.Name))
+               key.WriteString(field.Name)
+               if err := writePartitionTypeFingerprint(&key, resultType); err 
!= nil {
+                       return "", err
+               }
+       }
+
+       return key.String(), nil
+}
+
+func writePartitionTypeFingerprint(key *strings.Builder, typ Type) error {
+       // These tags are internal to the cache key. Parameterized types append 
their
+       // parameters so equal Avro partition shapes still produce equal keys.
+       switch t := typ.(type) {
+       case Int32Type:
+               key.WriteByte('i')
+       case Int64Type:
+               key.WriteByte('j')
+       case Float32Type:
+               key.WriteByte('k')
+       case Float64Type:
+               key.WriteByte('d')
+       case StringType:
+               key.WriteByte('S')
+       case DateType:
+               key.WriteByte('D')
+       case TimeType:
+               key.WriteByte('T')
+       case TimestampType:
+               key.WriteByte('t')
+       case TimestampTzType:
+               key.WriteByte('z')
+       case UUIDType:
+               key.WriteByte('u')
+       case BooleanType:
+               key.WriteByte('b')
+       case BinaryType:
+               key.WriteByte('B')
+       case FixedType:
+               key.WriteByte('f')
+               writePartitionFingerprintInt(key, t.Len())
+       case DecimalType:
+               key.WriteByte('q')
+               writePartitionFingerprintInt(key, t.Precision())
+               writePartitionFingerprintInt(key, t.Scale())
+       case UnknownType:
+               key.WriteByte('U')
+       default:
+               return fmt.Errorf("unsupported partition type: %s", 
typ.String())

Review Comment:
   `%s` on `typ.String()` with no `%w` is a pure format call, which perfsprint 
will flag if it's in the lint config. `errors.New("unsupported partition type: 
" + typ.String())` sidesteps it.
   
   One catch: `partitionTypeToAvroSchema` uses the same `fmt.Errorf` shape and 
`TestManifestEntrySchemaForRejectsInvalidTypesAfterCacheHit` asserts the two 
error strings are `EqualError`, so if you change this one you'll want to change 
that one too or the test breaks. Worth checking whether perfsprint is actually 
on before bothering.



##########
data_file_codec_test.go:
##########
@@ -343,3 +345,166 @@ func BenchmarkMarshalAvroEntry(b *testing.B) {
                })
        }
 }
+
+func TestManifestEntrySchemaForMatchesPartitionAvroShape(t *testing.T) {

Review Comment:
   Every case here is a single-field spec, so this proves per-type correctness 
but never exercises multi-field ordering or the delimiter logic that keeps 
adjacent fields from bleeding into each other.
   
   Could we add one case pairing two parameterized types (say Decimal(10,2) 
then Fixed(16)) and assert the returned schema matches the directly computed 
one? That doubles as a regression test for the length and colon delimiters, 
which is exactly where a subtle off-by-one would hide.



##########
data_file_codec_test.go:
##########
@@ -343,3 +345,166 @@ func BenchmarkMarshalAvroEntry(b *testing.B) {
                })
        }
 }
+
+func TestManifestEntrySchemaForMatchesPartitionAvroShape(t *testing.T) {
+       types := []Type{
+               Int32Type{},
+               Int64Type{},
+               Float32Type{},
+               Float64Type{},
+               StringType{},
+               DateType{},
+               TimeType{},
+               TimestampType{},
+               TimestampTzType{},
+               UUIDType{},
+               BooleanType{},
+               BinaryType{},
+               FixedTypeOf(8), FixedTypeOf(16),
+               DecimalTypeOf(10, 2), DecimalTypeOf(11, 2), DecimalTypeOf(10, 
3),
+               UnknownType{},
+       }
+       for _, version := range []int{1, 2, 3} {
+               for _, typ := range types {
+                       t.Run("v"+strconv.Itoa(version)+"/"+typ.String(), 
func(t *testing.T) {
+                               schema := NewSchema(1, NestedField{ID: 1, Name: 
"source", Type: typ})
+                               spec := NewPartitionSpec(PartitionField{
+                                       SourceIDs: []int{1}, FieldID: 1000, 
Name: "partition", Transform: IdentityTransform{},
+                               })
+                               partition, err := 
partitionTypeToAvroSchema(spec.PartitionType(schema))
+                               require.NoError(t, err)
+                               want, err := 
internal.NewManifestEntrySchema(partition, version)
+                               require.NoError(t, err)
+                               for range 2 {
+                                       got, maps, err := 
manifestEntrySchemaFor(spec, schema, version)
+                                       require.NoError(t, err)
+                                       require.Equal(t, want.String(), 
got.String())
+                                       require.Equal(t, getFieldIDMap(want), 
maps)
+                               }
+                       })
+               }
+       }
+}
+
+func TestManifestEntrySchemaForPartitionShapeEquivalence(t *testing.T) {
+       schema := NewSchema(1,
+               NestedField{ID: 1, Name: "source", Type: Int32Type{}},
+               NestedField{ID: 2, Name: "ts", Type: TimestampType{}},
+       )
+       field := PartitionField{SourceIDs: []int{1}, FieldID: 1000, Name: 
"partition", Transform: IdentityTransform{}}
+       spec := NewPartitionSpec(field)
+       original, _, err := manifestEntrySchemaFor(spec, schema, 2)
+       require.NoError(t, err)
+
+       for _, tc := range []struct {
+               name   string
+               spec   PartitionSpec
+               schema *Schema
+       }{
+               {"spec_id", NewPartitionSpecID(99, field), schema},
+               {"schema_metadata", spec, NewSchema(99,
+                       NestedField{
+                               ID: 1, Name: "renamed_source", Type: 
Int32Type{}, Required: true,
+                               Doc: "documentation", InitialDefault: int32(1), 
WriteDefault: int32(2),
+                       },
+                       NestedField{ID: 3, Name: "unrelated", Type: 
StringType{}},
+               )},
+               {"bucket", NewPartitionSpec(PartitionField{
+                       SourceIDs: []int{1}, FieldID: 1000, Name: "partition", 
Transform: BucketTransform{NumBuckets: 8},
+               }), schema},
+               {"year", NewPartitionSpec(PartitionField{
+                       SourceIDs: []int{2}, FieldID: 1000, Name: "partition", 
Transform: YearTransform{},
+               }), schema},
+               {"dropped_bucket_source", NewPartitionSpec(PartitionField{
+                       SourceIDs: []int{3}, FieldID: 1000, Name: "partition", 
Transform: BucketTransform{NumBuckets: 16},
+               }), schema},
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       got, _, err := manifestEntrySchemaFor(tc.spec, 
tc.schema, 2)
+                       require.NoError(t, err)
+                       require.Same(t, original, got)
+               })
+       }
+
+       for _, tc := range []struct {
+               name    string
+               fields  []PartitionField
+               version int
+       }{
+               {"field_id", []PartitionField{{SourceIDs: []int{1}, FieldID: 
1001, Name: "partition", Transform: IdentityTransform{}}}, 2},
+               {"field_name", []PartitionField{{SourceIDs: []int{1}, FieldID: 
1000, Name: "renamed", Transform: IdentityTransform{}}}, 2},
+               {"field_count", []PartitionField{field, {SourceIDs: []int{2}, 
FieldID: 1001, Name: "year", Transform: YearTransform{}}}, 2},
+               {"empty", nil, 2},
+               {"v1", []PartitionField{field}, 1},
+               {"v3", []PartitionField{field}, 3},
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       got, _, err := 
manifestEntrySchemaFor(NewPartitionSpec(tc.fields...), schema, tc.version)
+                       require.NoError(t, err)
+                       require.NotSame(t, original, got)
+               })
+       }
+
+       other := PartitionField{SourceIDs: []int{2}, FieldID: 1001, Name: 
"year", Transform: YearTransform{}}
+       ordered, _, err := manifestEntrySchemaFor(NewPartitionSpec(field, 
other), schema, 2)
+       require.NoError(t, err)
+       reversed, _, err := manifestEntrySchemaFor(NewPartitionSpec(other, 
field), schema, 2)
+       require.NoError(t, err)
+       require.NotSame(t, ordered, reversed)
+}
+
+type unsupportedCodecPartitionType struct{ Int64Type }
+
+func TestManifestEntrySchemaForRejectsInvalidTypesAfterCacheHit(t *testing.T) {
+       spec := NewPartitionSpec(PartitionField{
+               SourceIDs: []int{1}, FieldID: 1000, Name: "partition", 
Transform: IdentityTransform{},
+       })
+       _, _, err := manifestEntrySchemaFor(spec, NewSchema(1, NestedField{ID: 
1, Name: "source", Type: Int64Type{}}), 2)
+       require.NoError(t, err)
+
+       for _, typ := range []Type{
+               unsupportedCodecPartitionType{},
+               TimestampNsType{},
+               TimestampTzNsType{},
+               VariantType{},
+               &StructType{}, &ListType{ElementID: 2, Element: Int64Type{}},
+               &MapType{KeyID: 2, KeyType: StringType{}, ValueID: 3, 
ValueType: Int64Type{}},
+       } {
+               schema := NewSchema(1, NestedField{ID: 1, Name: "source", Type: 
typ})
+               _, wantErr := 
partitionTypeToAvroSchema(spec.PartitionType(schema))
+               require.Error(t, wantErr)
+               _, _, err := manifestEntrySchemaFor(spec, schema, 2)
+               require.EqualError(t, err, wantErr.Error())
+       }
+}
+
+func TestManifestEntrySchemaForConcurrentTableLocalIDs(t *testing.T) {
+       spec := NewPartitionSpec(PartitionField{
+               SourceIDs: []int{1}, FieldID: 1000, Name: "partition", 
Transform: IdentityTransform{},
+       })
+       schemas := []*Schema{
+               NewSchema(1, NestedField{ID: 1, Name: "source", Type: 
Int64Type{}}),
+               NewSchema(1, NestedField{ID: 1, Name: "source", Type: 
StringType{}}),
+       }
+       const workers = 32
+       results := make([]struct {
+               schema string
+               err    error
+       }, workers)
+       var wg sync.WaitGroup
+       for i := range results {
+               wg.Go(func() {
+                       got, _, err := manifestEntrySchemaFor(spec, 
schemas[i%len(schemas)], 2)
+                       results[i].err = err
+                       if err == nil {
+                               results[i].schema = got.String()
+                       }
+               })
+       }
+       wg.Wait()
+       for i, result := range results {
+               require.NoError(t, result.err)
+               require.Equal(t, results[i%len(schemas)].schema, result.schema)

Review Comment:
   For i<2 this compares `results[i]` against itself, so the equality check is 
a tautology for the first two workers. The real signal is the NotEqual below 
plus the even/odd grouping for i>=2.
   
   I'd compare against the schema string computed directly from 
`schemas[i%len(schemas)]` (a known-good value) so every index gets a 
non-vacuous check.



##########
data_file_codec_test.go:
##########
@@ -343,3 +345,166 @@ func BenchmarkMarshalAvroEntry(b *testing.B) {
                })
        }
 }
+
+func TestManifestEntrySchemaForMatchesPartitionAvroShape(t *testing.T) {
+       types := []Type{
+               Int32Type{},
+               Int64Type{},
+               Float32Type{},
+               Float64Type{},
+               StringType{},
+               DateType{},
+               TimeType{},
+               TimestampType{},
+               TimestampTzType{},
+               UUIDType{},
+               BooleanType{},
+               BinaryType{},
+               FixedTypeOf(8), FixedTypeOf(16),
+               DecimalTypeOf(10, 2), DecimalTypeOf(11, 2), DecimalTypeOf(10, 
3),
+               UnknownType{},
+       }
+       for _, version := range []int{1, 2, 3} {
+               for _, typ := range types {
+                       t.Run("v"+strconv.Itoa(version)+"/"+typ.String(), 
func(t *testing.T) {
+                               schema := NewSchema(1, NestedField{ID: 1, Name: 
"source", Type: typ})
+                               spec := NewPartitionSpec(PartitionField{
+                                       SourceIDs: []int{1}, FieldID: 1000, 
Name: "partition", Transform: IdentityTransform{},
+                               })
+                               partition, err := 
partitionTypeToAvroSchema(spec.PartitionType(schema))
+                               require.NoError(t, err)
+                               want, err := 
internal.NewManifestEntrySchema(partition, version)
+                               require.NoError(t, err)
+                               for range 2 {
+                                       got, maps, err := 
manifestEntrySchemaFor(spec, schema, version)
+                                       require.NoError(t, err)
+                                       require.Equal(t, want.String(), 
got.String())
+                                       require.Equal(t, getFieldIDMap(want), 
maps)
+                               }
+                       })
+               }
+       }
+}
+
+func TestManifestEntrySchemaForPartitionShapeEquivalence(t *testing.T) {
+       schema := NewSchema(1,
+               NestedField{ID: 1, Name: "source", Type: Int32Type{}},
+               NestedField{ID: 2, Name: "ts", Type: TimestampType{}},
+       )
+       field := PartitionField{SourceIDs: []int{1}, FieldID: 1000, Name: 
"partition", Transform: IdentityTransform{}}
+       spec := NewPartitionSpec(field)
+       original, _, err := manifestEntrySchemaFor(spec, schema, 2)
+       require.NoError(t, err)
+
+       for _, tc := range []struct {
+               name   string
+               spec   PartitionSpec
+               schema *Schema
+       }{
+               {"spec_id", NewPartitionSpecID(99, field), schema},
+               {"schema_metadata", spec, NewSchema(99,
+                       NestedField{
+                               ID: 1, Name: "renamed_source", Type: 
Int32Type{}, Required: true,
+                               Doc: "documentation", InitialDefault: int32(1), 
WriteDefault: int32(2),
+                       },
+                       NestedField{ID: 3, Name: "unrelated", Type: 
StringType{}},
+               )},
+               {"bucket", NewPartitionSpec(PartitionField{
+                       SourceIDs: []int{1}, FieldID: 1000, Name: "partition", 
Transform: BucketTransform{NumBuckets: 8},
+               }), schema},
+               {"year", NewPartitionSpec(PartitionField{
+                       SourceIDs: []int{2}, FieldID: 1000, Name: "partition", 
Transform: YearTransform{},
+               }), schema},
+               {"dropped_bucket_source", NewPartitionSpec(PartitionField{
+                       SourceIDs: []int{3}, FieldID: 1000, Name: "partition", 
Transform: BucketTransform{NumBuckets: 16},
+               }), schema},
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       got, _, err := manifestEntrySchemaFor(tc.spec, 
tc.schema, 2)
+                       require.NoError(t, err)
+                       require.Same(t, original, got)
+               })
+       }
+
+       for _, tc := range []struct {
+               name    string
+               fields  []PartitionField
+               version int
+       }{
+               {"field_id", []PartitionField{{SourceIDs: []int{1}, FieldID: 
1001, Name: "partition", Transform: IdentityTransform{}}}, 2},
+               {"field_name", []PartitionField{{SourceIDs: []int{1}, FieldID: 
1000, Name: "renamed", Transform: IdentityTransform{}}}, 2},
+               {"field_count", []PartitionField{field, {SourceIDs: []int{2}, 
FieldID: 1001, Name: "year", Transform: YearTransform{}}}, 2},
+               {"empty", nil, 2},
+               {"v1", []PartitionField{field}, 1},
+               {"v3", []PartitionField{field}, 3},
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       got, _, err := 
manifestEntrySchemaFor(NewPartitionSpec(tc.fields...), schema, tc.version)
+                       require.NoError(t, err)
+                       require.NotSame(t, original, got)
+               })
+       }
+
+       other := PartitionField{SourceIDs: []int{2}, FieldID: 1001, Name: 
"year", Transform: YearTransform{}}
+       ordered, _, err := manifestEntrySchemaFor(NewPartitionSpec(field, 
other), schema, 2)
+       require.NoError(t, err)
+       reversed, _, err := manifestEntrySchemaFor(NewPartitionSpec(other, 
field), schema, 2)
+       require.NoError(t, err)
+       require.NotSame(t, ordered, reversed)
+}
+
+type unsupportedCodecPartitionType struct{ Int64Type }
+
+func TestManifestEntrySchemaForRejectsInvalidTypesAfterCacheHit(t *testing.T) {

Review Comment:
   With the new code the fingerprint returns the error before the cache is ever 
queried, so "AfterCacheHit" doesn't describe what this checks anymore. 
Something like `TestManifestEntrySchemaForRejectsUnsupportedPartitionTypes` 
would read truer.



-- 
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