Skip to content

Commit 618ced9

Browse files
committed
iceberg: address PR #4427 follow-up review
Three review comments on PR #4427 that landed after merge: - shredder: reject the schema/column mismatch where a Timestamp common reaches an Int32 column. coerceTemporalToNumeric returns int64 UnixMilli/Micros/Nanos (~10^12), which the Int32Type arm cast to int32 with no bounds check, silently truncating into a garbage year. The arm is intended for Date / TimeOfDay coercions whose values do fit; bound-check post-coerce and fail loudly when they don't, with a message pointing the operator at BIGINT or a schema-metadata fix. - output_iceberg: drop the redundant conf.Contains guard around require_schema_metadata parsing. The field declares Default(false) in the spec so FieldBool returns false-without-error on absence and the inline form fits cleanly. - shredder: expose StrictTemporalMode as an exported struct field instead of the SetStrictTemporalMode setter. It's a single bool with no validation hook, so the setter added ceremony without value. Updates the sole writer.go caller to assign the field directly. New regression test TestCoerceTemporalInt32OverflowGuard pins the Int32 overflow rejection and confirms in-range Date / TimeOfDay coercions still succeed.
1 parent 603ec27 commit 618ced9

4 files changed

Lines changed: 77 additions & 37 deletions

File tree

internal/impl/iceberg/output_iceberg.go

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -484,15 +484,14 @@ func parseSchemaEvolutionConfig(conf *service.ParsedConfig) (SchemaEvolutionConf
484484
}
485485
}
486486

487-
// Parse require_schema_metadata
488-
if conf.Contains(ioFieldSchemaEvolution, ioFieldSchemaEvolutionRequireSchemaMetadata) {
489-
cfg.RequireSchemaMetadata, err = conf.FieldBool(ioFieldSchemaEvolution, ioFieldSchemaEvolutionRequireSchemaMetadata)
490-
if err != nil {
491-
return cfg, err
492-
}
493-
if cfg.RequireSchemaMetadata && cfg.SchemaMetadata == "" {
494-
return cfg, fmt.Errorf("%s.%s requires %s.%s to be set", ioFieldSchemaEvolution, ioFieldSchemaEvolutionRequireSchemaMetadata, ioFieldSchemaEvolution, ioFieldSchemaEvolutionSchemaMetadata)
495-
}
487+
// Parse require_schema_metadata. The field carries Default(false) in the
488+
// spec so FieldBool returns false-without-error when absent, no Contains
489+
// guard required.
490+
if cfg.RequireSchemaMetadata, err = conf.FieldBool(ioFieldSchemaEvolution, ioFieldSchemaEvolutionRequireSchemaMetadata); err != nil {
491+
return cfg, err
492+
}
493+
if cfg.RequireSchemaMetadata && cfg.SchemaMetadata == "" {
494+
return cfg, fmt.Errorf("%s.%s requires %s.%s to be set", ioFieldSchemaEvolution, ioFieldSchemaEvolutionRequireSchemaMetadata, ioFieldSchemaEvolution, ioFieldSchemaEvolutionSchemaMetadata)
496495
}
497496

498497
return cfg, nil

internal/impl/iceberg/shredder/shredder.go

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,20 @@ type RecordShredder struct {
8787
// typed columns instead of guessing. nil entries fall back to the
8888
// pre-schema-metadata behavior — see [convertLeafValue].
8989
fieldCommons map[int]*schema.Common
90-
// strictTemporal causes [convertLeafValue] to refuse numeric inputs
91-
// into time-typed columns when no schema metadata has been
92-
// registered for that column. When false (the default), the value
93-
// converter falls back to [bloblang.ValueAsTimestamp]'s seconds
94-
// default — convenient but silently wrong if the upstream produced
95-
// a different unit. When true, the writer fails the batch loudly.
96-
strictTemporal bool
90+
// StrictTemporalMode causes [convertLeafValue] to refuse numeric
91+
// inputs into time-typed columns (TIMESTAMP / TIMESTAMPTZ / DATE /
92+
// TIME) when no schema metadata has been registered for that
93+
// column. When false (the default), the value converter falls back
94+
// to [bloblang.ValueAsTimestamp]'s seconds default — convenient but
95+
// silently wrong if the upstream produced a different unit. When
96+
// true, the writer fails the batch loudly.
97+
//
98+
// No effect on time.Time / time.Duration values, which carry their
99+
// own unit unambiguously, and no effect on non-time columns. Set
100+
// directly on the shredder after construction; operators that
101+
// cannot guarantee schema metadata flows end-to-end flip this on to
102+
// fail loudly instead of silently corrupting dates by ~50,000 years.
103+
StrictTemporalMode bool
97104
}
98105

99106
// NewRecordShredder creates a new shredder for the given schema.
@@ -107,22 +114,6 @@ func NewRecordShredder(schema *iceberg.Schema, caseSensitive bool) *RecordShredd
107114
}
108115
}
109116

110-
// SetStrictTemporalMode toggles whether numeric inputs into time-typed
111-
// columns require registered schema metadata. With strict mode on, a bare
112-
// int64 / float64 value reaching a TIMESTAMP / TIMESTAMPTZ / DATE / TIME
113-
// column with no [schema.Common] in the field map is rejected with a
114-
// per-field error rather than guessed-as-Unix-seconds.
115-
//
116-
// Strict mode has no effect on time.Time / time.Duration values, which
117-
// carry their own unit unambiguously, and no effect on non-time columns.
118-
//
119-
// Defaults to off (back-compat). Operators that cannot guarantee schema
120-
// metadata flows end-to-end can flip this on to fail loudly instead of
121-
// silently corrupting dates by ~50,000 years.
122-
func (rs *RecordShredder) SetStrictTemporalMode(on bool) {
123-
rs.strictTemporal = on
124-
}
125-
126117
// SetFieldSchemaMetadata supplies a field-ID → schema.Common map that the
127118
// leaf value converter consults when it sees a numeric input destined for a
128119
// time-typed Iceberg column (TIMESTAMP, TIMESTAMPTZ, TIMESTAMP_NS,
@@ -264,7 +255,7 @@ func (rs *RecordShredder) shredValue(
264255

265256
default:
266257
// Leaf/primitive type.
267-
pqVal, err := convertLeafValue(value, typ, rs.commonForField(fieldID), rs.strictTemporal)
258+
pqVal, err := convertLeafValue(value, typ, rs.commonForField(fieldID), rs.StrictTemporalMode)
268259
if err != nil {
269260
return err
270261
}
@@ -478,6 +469,14 @@ func convertLeafValue(value any, typ iceberg.Type, common *schema.Common, strict
478469
if strictTemporal {
479470
return parquet.NullValue(), fmt.Errorf("int column received %T while schema metadata declares type %v; require_schema_metadata=true demands the existing column type match the schema metadata — recreate the table to migrate", value, common.Type)
480471
}
472+
// A Timestamp common's UnixMilli/Micros/Nanos far exceeds the
473+
// int32 range, but the Int32Type arm is intended for Date /
474+
// TimeOfDay coercions whose values do fit. Reject the
475+
// schema/column mismatch loudly rather than silently
476+
// truncating into a garbage year.
477+
if n > math.MaxInt32 || n < math.MinInt32 {
478+
return parquet.NullValue(), fmt.Errorf("int column received %T with schema metadata type %v; coerced value %d overflows int32 — the column should be BIGINT or the schema metadata is wrong", value, common.Type, n)
479+
}
481480
return parquet.Int32Value(int32(n)), nil
482481
}
483482
i, err := bloblang.ValueAsInt64(value)

internal/impl/iceberg/shredder/temporal_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,50 @@ func TestCoerceTemporalIntoNumericColumn(t *testing.T) {
498498
})
499499
}
500500

501+
// TestCoerceTemporalInt32OverflowGuard covers the schema/column mismatch
502+
// where a TIMESTAMP schema common reaches an Int32 column — coerceTemporal-
503+
// ToNumeric returns a UnixMilli value (~10^12) that vastly exceeds int32
504+
// range. The Int32 arm is intended for Date / TimeOfDay coercions whose
505+
// values fit in int32; a Timestamp value silently truncating into a garbage
506+
// year is the failure mode this guard exists to prevent.
507+
//
508+
// The complementary Int64 arm has no bounds problem (Timestamp values fit
509+
// comfortably in int64) and is verified separately by
510+
// TestCoerceTemporalIntoNumericColumn.
511+
func TestCoerceTemporalInt32OverflowGuard(t *testing.T) {
512+
const tsMillis = int64(1_700_000_000_000) // 2023-11-14, ~10^12, well beyond int32
513+
514+
t.Run("Timestamp(Millis) into Int32 errors with overflow message", func(t *testing.T) {
515+
v := time.UnixMilli(tsMillis).UTC()
516+
_, err := convertLeafValue(v, iceberg.Int32Type{}, tsCommon(schema.TimeUnitMillis, true), false)
517+
require.Error(t, err)
518+
assert.Contains(t, err.Error(), "overflows int32", "must reject the schema/column mismatch loudly, not truncate to a garbage year")
519+
})
520+
521+
t.Run("Timestamp(Micros) into Int32 errors with overflow message", func(t *testing.T) {
522+
v := time.UnixMilli(tsMillis).UTC()
523+
_, err := convertLeafValue(v, iceberg.Int32Type{}, tsCommon(schema.TimeUnitMicros, true), false)
524+
require.Error(t, err)
525+
assert.Contains(t, err.Error(), "overflows int32")
526+
})
527+
528+
// Sanity check: the in-range coercions the Int32 arm was designed for
529+
// still succeed.
530+
t.Run("Date into Int32 still succeeds", func(t *testing.T) {
531+
v := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC)
532+
pq, err := convertLeafValue(v, iceberg.Int32Type{}, &schema.Common{Type: schema.Date}, false)
533+
require.NoError(t, err)
534+
assert.Equal(t, int32(19737), pq.Int32())
535+
})
536+
537+
t.Run("TimeOfDay into Int32 still succeeds", func(t *testing.T) {
538+
d := 8*time.Hour + 30*time.Minute
539+
pq, err := convertLeafValue(d, iceberg.Int32Type{}, todCommon(schema.TimeUnitMillis), false)
540+
require.NoError(t, err)
541+
assert.Equal(t, int32(8*3600+30*60)*1000, pq.Int32())
542+
})
543+
}
544+
501545
// TestCoerceTemporalRejectedInStrictMode confirms that the temporal->numeric
502546
// coerce path is disabled when require_schema_metadata=true. In strict mode
503547
// a type disagreement between the existing column and the schema metadata

internal/impl/iceberg/writer.go

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ type writer struct {
5555
// resolver supplies optional per-message schema metadata used by the shredder
5656
// to interpret numeric inputs into time-typed columns; pass nil to disable.
5757
// requireSchemaMetadata enables shredder strict mode — see
58-
// [shredder.RecordShredder.SetStrictTemporalMode].
58+
// [shredder.RecordShredder.StrictTemporalMode].
5959
func NewWriter(tbl *table.Table, comm *committer, caseSensitive bool, writerOpts []parquet.WriterOption, resolver *typeResolver, requireSchemaMetadata bool, logger *service.Logger) *writer {
6060
return &writer{
6161
table: tbl,
@@ -222,9 +222,7 @@ func (w *writer) messagesToParquet(batch service.MessageBatch) ([]partitionFile,
222222
// silently for messages 1..N; in that case the writer must be
223223
// extended to per-message metadata lookup with a small cache.
224224
rs := shredder.NewRecordShredder(schema, w.caseSensitive)
225-
if w.requireSchemaMetadata {
226-
rs.SetStrictTemporalMode(true)
227-
}
225+
rs.StrictTemporalMode = w.requireSchemaMetadata
228226
if w.resolver != nil && len(batch) > 0 {
229227
if common, err := w.resolver.parseSchemaMetadata(batch[0]); err != nil {
230228
w.logger.Warnf("parsing schema metadata for shredder: %v (falling back to schema-agnostic conversion)", err)

0 commit comments

Comments
 (0)