Core: Support local-timestamp-* in AvroSchemaUtil - #17196
Conversation
c14edcb to
8a4e895
Compare
|
Also added |
8a4e895 to
bad828f
Compare
bad828f to
03b11c8
Compare
AnatolyPopov
left a comment
There was a problem hiding this comment.
Could you clarify which related PRs are considered breaking here? PR #15437 did not change Iceberg’s Avro write encoding or any public API. It only recognized local-timestamp-* on input and updated the reader paths that previously rejected those logical types.
I’m also concerned that legacyTimestampMapping=false does more than enable standard local timestamps. It causes every timestamp-* to map to a zoned Iceberg timestamp, even when the schema contains adjust-to-utc=false. Therefore, the new mode cannot safely read schemas containing both legacy Iceberg timestamps and Avro local-timestamp-*.
For backward compatibility, Avro to Iceberg conversion should continue honoring adjust-to-utc while recognizing local-timestamp-* as timestamps without zone. These behaviors are not mutually exclusive and were both supported by #15437.
Also, no production caller currently passes false, and the actual Iceberg Avro readers/writers still reject local-timestamp-*. Is this PR intentionally limited to an external schema-conversion API rather than end-to-end read/write support?
All three PRs unconditionally add support for
To avoid this breaking change, I actually prefer having two separate modes:
By the way,
Yes, it is intentionally limited. I recently started a project to ingest data from Kafka using the Flink Dynamic Iceberg Sink. Kafka Connect serializes the data into Avro format, and Iceberg Flink converts the Avro |
I think they are exclusive in practice. A writer writes either the |
I made Avro-native mode backward compatible with Iceberg-specific mode in fa90be8. With Does it address your concern, @AnatolyPopov ? |
|
Oh yeah, now I see what you meant by the breaking change. But do we know any Iceberg engine path or downstream code that relies on the converted type being LongType? I saw this more as missing support for the logical type than an established contract and what I've seen in some places before is that logical type in this case was not recognized and underlying physical long type was used as a fallback. The other concern is addressed now, thanks! But it still seems to be missing tests for the new behavior. Could you add tests for legacyTimestampMapping=false with adjust-to-utc=false and with the property missing? A mixed-schema test would be useful too. |
While I agree that this is more of a missing feature, I would prefer not to make any assumptions about public API users. In a sense, even a missing feature in a public API is an established contract) I've updated the PR. This change is reflected in the updated test. Iceberg-specific and Avro-native timestamp conversions are now covered. Thanks for the review, @AnatolyPopov ! |
AnatolyPopov
left a comment
There was a problem hiding this comment.
Looks good to me know, thank for addressing the comments and hopefully this time the support local timestamps will come though. There were many attempts already.
|
@RussellSpitzer I've seen you had some concerns regarding similar things before. Would you mind to take a look? |
| return Types.TimestampNanoType.withoutZone(); | ||
| } | ||
|
|
||
| } else if (logical instanceof LogicalTypes.LocalTimestampMillis |
There was a problem hiding this comment.
These branches will fall through and return null if legacyTimeMapping is true. I think the assumption was that the reader is assuming legacy status of the writer, but i'm not sure you can do that. For example if my writer is set to "non-legacy" but my reader is set to "legacy" then it will break.
Consider the following test which would currently fail
@Test
public void testLocalTimestampWithLegacyMapping() {
Schema localTsMicros =
LogicalTypes.localTimestampMicros().addToSchema(Schema.create(Schema.Type.LONG));
Schema localTsNanos =
LogicalTypes.localTimestampNanos().addToSchema(Schema.create(Schema.Type.LONG));
// local-timestamp-* types are semantically unambiguous — always no timezone.
// legacyTimestampMapping should have no effect on them.
assertThat(AvroSchemaUtil.convert(localTsMicros))
.isEqualTo(Types.TimestampType.withoutZone());
assertThat(AvroSchemaUtil.convert(localTsNanos))
.isEqualTo(Types.TimestampNanoType.withoutZone());
}I Think this is fixed by just dropping the condition here (if !legacy)
There was a problem hiding this comment.
Dropping the condition introduces a breaking change: if someone uses AvroSchemaUtil.toIcebergSchema to write externally produced Avro data with local-timestamp-*, their pipeline will fail after the upgrade because the long columns will become timestamps. We discussed this above #17196 (comment). Does it look like a breaking change to you, @RussellSpitzer ? Do you prefer to support local-timestamp-* unconditionally?
I don't know any real consumer that relies on ignoring local-timestamp-* and I'm ready to drop the condition. Just wanted to highlight this first.
There was a problem hiding this comment.
I'm not sure I understand. How would we produce local-timestamp-* after they upgrade? It would only be produced by writers with the legacy flag off which would not be the default?
This is guarding against users who were manually creating local-timestamp in their Avro schemas (which I think has to come from outside our library) but are relying on this function to return a "long" in that use-case.
There is an issue with "timestamp-micros" (not local) but that is handled correctly by passing through the legacy mode
There was a problem hiding this comment.
This is guarding against users who were manually creating local-timestamp in their Avro schemas (which I think has to come from outside our library) but are relying on this function to return a "long" in that use-case.
Exactly. Do we care about this rather unlikely use-case?
There was a problem hiding this comment.
This is "core" and not "api" so I'd rather we not support what we know is an incorrect behavior imho. We can always send out a dev list thread noting the change but honestly it feel's like supporting a bug to me
There was a problem hiding this comment.
This is "core" and not "api" so I'd rather we not support what we know is an incorrect behavior imho. We can always send out a dev list thread noting the change but honestly it feel's like supporting a bug to me
I would rather agree to this.
I faced exactly this case when I started working on a previous outdated PR for this issue and concluded that it does not make sense to fully rely on the return type being long, except as a temporary fallback before this is implemented.
There was a problem hiding this comment.
I added the condition to avoid discussions about breaking changes, but now I look like the most conservative person in the room! 😄 No problem, I'll drop the condition.
There was a problem hiding this comment.
Started a thread here https://lists.apache.org/thread/qrr6hwzy70slxz24s3gr5dz68mxys9ls
There was a problem hiding this comment.
Thanks @RussellSpitzer !
There was a request on the PR to preserve this behavior in the API via a parameter
To be precise, I'm not requesting to preserve this behavior. As I mentioned above, I added the condition for local-timestamp-* only to avoid discussions about breaking changes. Personally, I am all for unconditional local-timestamp-* support!
| return convert(schema, tableName, true); | ||
| } | ||
|
|
||
| public static Schema convert( |
There was a problem hiding this comment.
I think this approach is trying to solve essetnially two problems at the same time but I think they should be fixed in different ways.
On Read, we shouldn't have any optional behavior, if you see the new types you see the new types. Not an issue. So there we just need to add support for the new types.
Reads
- See local timestamp - use withoutZone
- timestamp and adjust-to-utc
- timestamp and no prop - Check table Config
On the write side
Instead of a parameter we plumb through all these methods, we should just have a table property similar to AVRO_COMPRESSION and a private method which takes it's resolution. Something like
// In TableProperties:
String AVRO_TIMESTAMP_ENCODING = "write.avro.timestamp-encoding";
String AVRO_TIMESTAMP_ENCODING_DEFAULT = "legacy"; // or "local-timestamp"
// In Avro.WriteBuilder.build():
boolean legacy = !"local-timestamp".equals(config.get(AVRO_TIMESTAMP_ENCODING));
schema = AvroSchemaUtil.convert(icebergSchema, name, legacy); // internal call, not publicThere was a problem hiding this comment.
Please don't take my code above as gospel. Thinking about it for a half second more I think we should probably keep it a boolean and not a string...
// In TableProperties:
String AVRO_TIMESTAMP_ENCODING_LEGACY_MODE = "write.avro.timestamp-encoding";
Boolean AVRO_TIMESTAMP_ENCODING_LEGACY_MODE_DEFAULT = true; // or false (use local-timestamps)There was a problem hiding this comment.
boolean legacy = !"local-timestamp".equals(config.get(AVRO_TIMESTAMP_ENCODING));
schema = AvroSchemaUtil.convert(icebergSchema, name, legacy); // internal call, not public
I'd like to use AvroSchemaUtil.convert in my own project to convert external Avro data and write it into Iceberg, like in this example https://iceberg.apache.org/docs/latest/flink-writes/#write-with-avro-genericrecord. Not to add Avro data files to an Iceberg table. So I'd like to have a public method that supports local-timestamp-* and converts timestamp-* without adjust-to-utc property to timestamptz(_ns). So for me a table property is not an option.
@RussellSpitzer , is adding a public method still a possibility we can consider?
There was a problem hiding this comment.
Let's start a general dev list thread. My gut instinct here is no, we don't want to keep annotating this method with legacy flags because we don't actually want to support that behavior for outside consumers. If you use this method and we see an Iceberg type we know, we should get the right Iceberg type.
For precedent though you can check out
#12455
Where we similarly added recognition for new types (timestamp 9) which previously would have fell through and become Long.
There was a problem hiding this comment.
Even after dropping the condition for local-timestamp-*, setting legacyTimestampMapping to false still leads to the following differences compared to legacyTimestampMapping=true:
- Avro
timestamp-*without theadjust-to-utcproperty is converted to Icebergtimestamptz(_ns)(instead oftimestamp(_ns)) - Iceberg
timestamptz(_ns)is converted to Avrotimestamp-*without theadjust-to-utcproperty (instead oftimestamp-*withadjust-to-utc=true) - Iceberg
timestamp(_ns)is converted to Avrolocal-timestamp-*(instead oftimestamp-*withadjust-to-utc=false)
So, the question is not, "Do we want Avro local-timestamp-* to Iceberg timestamp(_ns) conversion support to be flagged with legacyTimestampMapping?". Rather, the question is, "Do we want to provide public methods that follow Avro semantics during round-trip conversions"?
Are we on the same page, @RussellSpitzer ?
upd: I saw your message below. Let's continue this discussion in the main thread.
RussellSpitzer
left a comment
There was a problem hiding this comment.
There are a few consumers that need to be updated so they don't break on the new types.
DataReader.java:163-164
PlannedDataReader.java:171-172
DataWriter.java:140-141
RussellSpitzer
left a comment
There was a problem hiding this comment.
There are some other downstream reader issues. DataReader.isTimestampZ hardcodes a "true" in the legacy mode parameter. The writer not in legacy mode would always be read incorrectly.
| return Types.VariantType.get(); | ||
| } | ||
|
|
||
| @SuppressWarnings("checkstyle:CyclomaticComplexity") |
There was a problem hiding this comment.
I don't think we need to supress the warning here. We can just extract the timestamp logic out into it's own helper if required after removing the !legacy checks.
There was a problem hiding this comment.
Added helpers and removed warning suppression (0661934)
| required(2, "ts_tz", Types.TimestampType.withZone()), | ||
| required(3, "ts_tz_ns", Types.TimestampNanoType.withZone())); | ||
|
|
||
| assertThat(AvroSchemaUtil.convert(avroSchema, false)) |
There was a problem hiding this comment.
This looks like 3 independent tests here to me. I'd split these up
There was a problem hiding this comment.
There are several other tests in TestSchemaConversions that combine avro->iceberg and iceberg->avro conversions in a single test, like testStructAndPrimitiveTypes. Please confirm if you want me to separate them.
There was a problem hiding this comment.
I didn't review the other tests :) but let's take a deeper look
Well if we really look into this we have two tests.
- Roundtrip on the schema
- Test of oneway legacy schema conversion to Iceberg
The "roundtrip test" here is redundant with the "testPrimitiveTypes" round trip testing above. So we can drop that entirely and make sure it's covered above. Then this keeps the one-way test and trims it down to just the legacyAvroSchema => icebergSchema
There was a problem hiding this comment.
I don't agree that the round trip is redundant because Avro timestamps in testPrimitiveTypes and testTimestampTypesWithLegacyMappingDisabled have different schemas.
I've added nano timestamps to testPrimitiveTypes. I've also added cases that are not covered by the round trips for both - legacy and Avro native - modes to separate tests: testAvroToIcebergTimestampTypes and testAvroToIcebergTimestampTypesWithLegacyMappingDisabled. I hope, all conversions are covered now.
| @@ -112,6 +112,53 @@ public void testAvroToIcebergTimestampTypeWithoutAdjustToUTC() { | |||
| assertThat(AvroSchemaUtil.convert(avroType)).isEqualTo(expectedIcebergType); | |||
| } | |||
|
|
|||
There was a problem hiding this comment.
A bit above here in testPrimitiveTypes we are missing entries for TimestampNanos.with and withoutZone. (Also check out addAdjustToUtc) I'm just noting this becasue we now only have tests for these with legacy mode false.
| } | ||
|
|
||
| public static org.apache.iceberg.Schema toIceberg(Schema schema, boolean legacyTimestampMapping) { | ||
| final List<Types.NestedField> fields = |
There was a problem hiding this comment.
Stylistically I tink we generally don't want final on local vars
| BiFunction<Integer, Types.StructType, String> namesFunction, boolean legacyTimestampMapping) { | ||
| this.namesFunction = namesFunction; | ||
| if (legacyTimestampMapping) { | ||
| timestampSchema = LEGACY_TIMESTAMP_SCHEMA; |
There was a problem hiding this comment.
I'd rather we use callsite selection of schema, but if we keep this the assignments should be
this.private_field = new_private_field_valuefor Iceberg style
| this.root = root; | ||
| if (root.getType() == Schema.Type.RECORD) { | ||
| this.nextId = root.getFields().size(); | ||
| } |
There was a problem hiding this comment.
nit: And i'm sorry this isn't in checkstyle, we fight about this alot. But there should be a linebreak after the } brace.
|
I've dropped the condition for All checks have passed. It looks like enabling The main question remains: do we want to introduce public methods on
Personally, I'm only interested in ingesting Avro encoded records from Kafka using Flink, similar to the official example. So, even if no one adds support for Avro native timestamps to |
|
I'm not sure why we would keep it scoped to just this class? As is, we are creating a function which other libraries could use but would produce records or schema which wouldn't work properly when the rest of this library worked with them. So for example if you wanted to change SchemaToType in isolation I think that's pretty safe. We never would create output that would break other parts of the library. The moment we touch TypeToSchema we are potentially producing records that would break the rest of the library. So I think we either follow the path of #Unknown and TimestampNanos |
I see your point. However, I suspect there are many places where schemas produced with Since the new behavior of
If this is an option, I'd like to go this route. Personally, I am only interested in the read side. I simply found that adding symmetric support to the write side was quite easy and might be useful for others (given that there are several similar threads). That is the only reason why I included the changes to Otherwise, I will need some help with the data side. @AnatolyPopov , is there a chance you could rebase your PR #15437 on my branch and add support for the changes in
@RussellSpitzer , are there other places where you'd like to have Avro native timestamp support? |
89c308b to
3b9bed5
Compare
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
3b9bed5 to
9ae7d2d
Compare
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
d3f6c40 to
e042c7f
Compare
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
173d21e to
ab02590
Compare
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
4c7fc1a to
4a00285
Compare
|
@AnatolyPopov @RussellSpitzer just following up on the changes I posted last week in the thread above: #17196 (comment). Whenever you have time for a review, I'm ready to keep pushing this PR forward. |
|
@AnatolyPopov @RussellSpitzer I'm still hoping to get another review from you and I'm ready to continue working on this PR. Current state:
Let me know if you would like to see the last piece - wiring table properties to the readers - included in this PR. |
|
@AnatolyPopov @RussellSpitzer , I'm still ready to continue working on this PR and would appreciate another review from you. If your bandwidth is full, could you please ask someone else for a review? |
|
I still don't think we want to add all these public methods. All the Convert(Iceberg Thing) -> Avro Thing should not have a legacy flag. There is no ambiguity here and we don't need to emulate the old behavior by producing an incorrect type just because we used to. For the other direction convert(Avro Thing) -> Iceberg Thing. We have ambiguity based on what the old writer of the avro schema produced. Only those are ambiguous and we need the ability to handle them in either direction. The no-arg options here should default to adjustUtcDefault = False to match AVRO_ADJUST_TO_UTC_DEFAULT_DEFAULT. There is also one other method |
|
Thanks @RussellSpitzer! Does this mean you want to get rid of the There is an edge case covered in
Could you please clarify your position on the default value for |
e89723e to
b9b6422
Compare
|
@RussellSpitzer, I've reread our discussion.
Based on this suggestion, I dropped legacy Iceberg->Avro public methods, but kept the
Two questions remain:
|
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
b9b6422 to
ee838a7
Compare
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
|
@RussellSpitzer, I reread your last message and realized that:
most probably was a description of the branch state at that time, rather than a suggestion to keep
The main question right now is: is it acceptable to encode partition fields A smaller question: is it OK to throw an exception when the PS: I found several more places in Flink where |
|
We can't change the Avro encoding in manifests from what the spec currently says, and we can't change the spec for V3. (We could update V4 to accept or require the new types, but that's a separate discussion and it's also somewhat moot since V4 manifests are no longer constrained to Avro, so that encoding question may not arise there.) The manifest writers aren't broken today because legacyTimestampMapping=true is the default. But that's the problem: the flag is in the wrong place. If we ever flip the default, or a caller adopts the new non-legacy path, the manifest writers silently produce spec-incompatible output with no indication at the call site that anything special is happening. My preference is to keep the converter semantically correct (withoutZone() always produces local-timestamp-micros) and push the legacy wire format requirement to the callers that actually need it: // Caller that is producing a schema that we want to write into AVRO
manifestPartitionSchema = toLegacyTimestamps(convert(icebergPartitionSchema));That way the exception is visible and explicit where it matters, rather than a hidden mode in the utility. The utility should only take a mode flag when the output is genuinely ambiguous from the input alone and local-timestamp-* is unambiguous. If you want to throw an exception when adjust-to-utc is not a boolean that sounds fine to me. Generally we would only do this at the moment of conversion though so make sure it's not a parse error but an error when we are trying to do something with the type that is undefined. |
Related:
Unlike similar PRs, this one has a very narrow scope and does not introduce any breaking changes.
With
legacyTimestampMappingflag set tofalse, theAvroSchemaUtil.toIcebergSchemamethod produces an Iceberg schema that is compatible with Flink'sAvroSchemaConverteroutput (convertToTypeInfo, convertToDataType).