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? |
5f1686f to
760c76d
Compare
I added package-private
The actual schema is written via Thanks @RussellSpitzer for the review! Could you please take another look at the PR when you get a chance? |
a560f91 to
92724c6
Compare
|
@RussellSpitzer, I'd be happy to keep working on this and hopefully get the PR wrapped up this week. Please take another look when you get a chance. I've rebased my branch to resolve conflicts. There are no other changes since my last comment. Status:
upd: I saw on the mailing list that the 1.12.0 RC release is planned for this week. With your help, I'd like to focus on this PR so we can get it merged into main before the release. upd2: Do you want to keep |
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
Co-authored-by: Anatolii Popov <anatolii.popov@aiven.io> Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
Signed-off-by: Sergei Nikolaev <kinolaev@gmail.com>
92724c6 to
bf8da73
Compare
|
I rebased the branch to resolve a conflict - I started thinking about a proposal for v4. My personal preference is to get rid of all inconsistencies between the specs at once:
I realized that we don't actually need custom table properties. On the write side, we can just use int fileFormatVersion = entry.fileFormatVersion() != null
? entry.fileFormatVersion()
: manifest.formatVersion() != null ? manifest.formatVersion() : 1;
if (fileFormatVersion < 4) {
// read `adjust-to-utc` with default `false`
} else {
// follow the Avro spec, ignore `adjust-to-utc`
}As a bonus, it allows us to add external Avro data files with I'm still exploring alternatives. Just wanted to share the best idea I have for now, in case you'd like to give early feedback - I'd really appreciate it! :) |
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).