This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/dev/pr-13043-50315b321bc68d910352d78f0fcbff80872451f5 in repository https://gitbox.apache.org/repos/asf/seatunnel.git
commit f7a575432fba5c35d36659c16e1590e83ad1f296 Author: Sepuri Sai Krishna <[email protected]> AuthorDate: Wed Oct 28 13:46:01 2026 +0000 [Fix][Connector-V2] Keep TIMESTAMP microseconds in the Lance sink (#13043) --- .../introduction/concepts/incompatible-changes.md | 7 ++ .../introduction/concepts/incompatible-changes.md | 7 ++ .../lance/sink/writers/BaseTypeWriter.java | 17 +++- .../lance/sink/writers/TimestampTypeWriter.java | 6 +- .../lance/utils/FragmentConverterTest.java | 90 ++++++++++++++++++++++ .../seatunnel/e2e/connector/lance/LanceIT.java | 51 ++++++++++++ .../resources/lance/fake_timestamp_to_lance.conf | 67 ++++++++++++++++ 7 files changed, 240 insertions(+), 5 deletions(-) diff --git a/docs/en/introduction/concepts/incompatible-changes.md b/docs/en/introduction/concepts/incompatible-changes.md index 39bdf4731f..b3753c0ba2 100644 --- a/docs/en/introduction/concepts/incompatible-changes.md +++ b/docs/en/introduction/concepts/incompatible-changes.md @@ -5,6 +5,13 @@ You need to check this document before you upgrade to related version. ## dev +### Lance sink: TIMESTAMP values keep their microseconds + +- **Behavior change: a `TIMESTAMP` written by the Lance sink keeps the digits below the millisecond** + - **Description**: The Lance sink declares a `TIMESTAMP` column with microsecond precision, as documented, but `BaseTypeWriter.convertToEpochMicro` converted `LocalDateTime` and `java.sql.Timestamp` values through milliseconds, so every stored value ended in `000` microseconds. Values are now converted from the instant directly. + - **Impact**: For the same input, a newly written value can differ from one written by an earlier version in its last three microsecond digits. Rows already in a dataset are not rewritten, so one dataset can hold both. Millisecond columns, and values with no digits below the millisecond, are unchanged. + - **Migration Guide**: No configuration change is needed. If a consumer compares or deduplicates new rows against rows written by an earlier version, compare at millisecond precision, or rewrite the older rows from the source. + ### JDBC MySQL: DATETIME writes preserve local date/time fields The MySQL JDBC sink now binds SeaTunnel `TIMESTAMP` (`LocalDateTime`) directly instead of converting it through `java.sql.Timestamp`. With different worker JVM and JDBC connection time zones, affected versions could shift `DATETIME` values or reject valid boundary values. Writes now preserve the local fields and the fractional seconds supported by the target column. The `DATETIME` source mapping and `TIMESTAMP_TZ` handling are unchanged. diff --git a/docs/zh/introduction/concepts/incompatible-changes.md b/docs/zh/introduction/concepts/incompatible-changes.md index 24b14e119e..6145c12f3d 100644 --- a/docs/zh/introduction/concepts/incompatible-changes.md +++ b/docs/zh/introduction/concepts/incompatible-changes.md @@ -4,6 +4,13 @@ ## dev +### Lance Sink:TIMESTAMP 值保留微秒 + +- **行为变更:Lance Sink 写入的 `TIMESTAMP` 保留毫秒以下的数字** + - **描述**:Lance Sink 按文档以微秒精度声明 `TIMESTAMP` 列,但 `BaseTypeWriter.convertToEpochMicro` 会先把 `LocalDateTime` 和 `java.sql.Timestamp` 值转换为毫秒,因此写入的每个值最后三位微秒都是 `000`。现在直接由时间点(Instant)转换。 + - **影响**:对同一输入,新写入的值可能与早期版本写入的值在最后三位微秒上不同。数据集中已有的行不会被重写,因此同一个数据集中可能同时存在两种值。毫秒精度的列,以及没有毫秒以下数字的值,不受影响。 + - **迁移指南**:无需修改配置。如果下游需要把新写入的行与早期版本写入的行进行比较或去重,请按毫秒精度比较,或从数据源重新写入旧数据。 + ### JDBC MySQL:DATETIME 写入保留本地日期和时间字段 MySQL JDBC Sink 现在直接绑定 SeaTunnel `TIMESTAMP`(`LocalDateTime`),不再通过 `java.sql.Timestamp` 转换。当 Worker JVM 与 JDBC 连接的时区不同时,受影响的版本可能导致 `DATETIME` 值偏移,或拒绝合法的边界值。现在写入会保留本地字段及目标列支持的小数秒精度。`DATETIME` 的源端映射和 `TIMESTAMP_TZ` 的处理保持不变。 diff --git a/seatunnel-connectors-v2/connector-lance/src/main/java/org/apache/seatunnel/connectors/seatunnel/lance/sink/writers/BaseTypeWriter.java b/seatunnel-connectors-v2/connector-lance/src/main/java/org/apache/seatunnel/connectors/seatunnel/lance/sink/writers/BaseTypeWriter.java index d854899a15..04b6b5ea19 100644 --- a/seatunnel-connectors-v2/connector-lance/src/main/java/org/apache/seatunnel/connectors/seatunnel/lance/sink/writers/BaseTypeWriter.java +++ b/seatunnel-connectors-v2/connector-lance/src/main/java/org/apache/seatunnel/connectors/seatunnel/lance/sink/writers/BaseTypeWriter.java @@ -21,6 +21,7 @@ import org.apache.arrow.memory.ArrowBuf; import org.apache.arrow.memory.BufferAllocator; import java.nio.charset.StandardCharsets; +import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; @@ -44,10 +45,9 @@ public abstract class BaseTypeWriter implements TypeWriter { protected long convertToEpochMicro(Object value) { if (value instanceof LocalDateTime) { - return ((LocalDateTime) value).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() - * 1000; + return toEpochMicro(((LocalDateTime) value).atZone(ZoneId.systemDefault()).toInstant()); } else if (value instanceof java.sql.Timestamp) { - return ((java.sql.Timestamp) value).getTime() * 1000; + return toEpochMicro(((java.sql.Timestamp) value).toInstant()); } else if (value instanceof java.util.Date) { return ((java.util.Date) value).getTime() * 1000; } else if (value instanceof Number) { @@ -57,4 +57,15 @@ public abstract class BaseTypeWriter implements TypeWriter { "Unsupported timestamp value type: " + value.getClass()); } } + + /** + * Converts an instant to microseconds since the epoch without passing through milliseconds, + * which would drop the sub-millisecond digits. {@code getEpochSecond()} rounds down and {@code + * getNano()} is never negative, so an instant before 1970 converts exactly as well. + */ + private static long toEpochMicro(Instant instant) { + return Math.addExact( + Math.multiplyExact(instant.getEpochSecond(), 1_000_000L), + instant.getNano() / 1_000); + } } diff --git a/seatunnel-connectors-v2/connector-lance/src/main/java/org/apache/seatunnel/connectors/seatunnel/lance/sink/writers/TimestampTypeWriter.java b/seatunnel-connectors-v2/connector-lance/src/main/java/org/apache/seatunnel/connectors/seatunnel/lance/sink/writers/TimestampTypeWriter.java index b504b3ed9f..e76d2eeb22 100644 --- a/seatunnel-connectors-v2/connector-lance/src/main/java/org/apache/seatunnel/connectors/seatunnel/lance/sink/writers/TimestampTypeWriter.java +++ b/seatunnel-connectors-v2/connector-lance/src/main/java/org/apache/seatunnel/connectors/seatunnel/lance/sink/writers/TimestampTypeWriter.java @@ -48,7 +48,9 @@ public class TimestampTypeWriter extends BaseTypeWriter { ((TimeStampMicroVector) vector).setSafe(rowIndex, epochMicro); } } else if (unit == TimeUnit.MILLISECOND) { - long epochMilli = epochMicro / 1000; + // floorDiv rather than /, so an instant before 1970 rounds down to the millisecond it + // falls in instead of up towards the epoch. + long epochMilli = Math.floorDiv(epochMicro, 1000L); if (timezone != null && !timezone.isEmpty()) { ((TimeStampMilliTZVector) vector).setSafe(rowIndex, epochMilli); } else { @@ -68,7 +70,7 @@ public class TimestampTypeWriter extends BaseTypeWriter { if (unit == TimeUnit.MICROSECOND) { writer.writeTimeStampMicro(epochMicro); } else if (unit == TimeUnit.MILLISECOND) { - writer.writeTimeStampMilli(epochMicro / 1000); + writer.writeTimeStampMilli(Math.floorDiv(epochMicro, 1000L)); } } diff --git a/seatunnel-connectors-v2/connector-lance/src/test/java/org/apache/seatunnel/connectors/seatunnel/lance/utils/FragmentConverterTest.java b/seatunnel-connectors-v2/connector-lance/src/test/java/org/apache/seatunnel/connectors/seatunnel/lance/utils/FragmentConverterTest.java index 67083313cb..3e042d6733 100644 --- a/seatunnel-connectors-v2/connector-lance/src/test/java/org/apache/seatunnel/connectors/seatunnel/lance/utils/FragmentConverterTest.java +++ b/seatunnel-connectors-v2/connector-lance/src/test/java/org/apache/seatunnel/connectors/seatunnel/lance/utils/FragmentConverterTest.java @@ -19,6 +19,7 @@ package org.apache.seatunnel.connectors.seatunnel.lance.utils; import org.apache.seatunnel.api.table.type.ArrayType; import org.apache.seatunnel.api.table.type.BasicType; +import org.apache.seatunnel.api.table.type.LocalTimeType; import org.apache.seatunnel.api.table.type.MapType; import org.apache.seatunnel.api.table.type.SeaTunnelDataType; import org.apache.seatunnel.api.table.type.SeaTunnelRow; @@ -26,10 +27,13 @@ import org.apache.seatunnel.api.table.type.SeaTunnelRowType; import org.apache.seatunnel.connectors.seatunnel.lance.exception.LanceConnectorException; import org.apache.arrow.memory.RootAllocator; +import org.apache.arrow.vector.TimeStampMicroTZVector; +import org.apache.arrow.vector.TimeStampMilliVector; import org.apache.arrow.vector.VarCharVector; import org.apache.arrow.vector.VectorSchemaRoot; import org.apache.arrow.vector.complex.ListVector; import org.apache.arrow.vector.types.FloatingPointPrecision; +import org.apache.arrow.vector.types.TimeUnit; import org.apache.arrow.vector.types.pojo.ArrowType; import org.apache.arrow.vector.types.pojo.Field; import org.apache.arrow.vector.types.pojo.FieldType; @@ -51,6 +55,11 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.SimpleFileVisitor; import java.nio.file.attribute.BasicFileAttributes; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -205,6 +214,87 @@ class FragmentConverterTest { exception.getMessage()); } + @Test + void shouldKeepTheMicrosecondsOfATimestamp() { + SeaTunnelRowType rowType = + new SeaTunnelRowType( + new String[] {"c_timestamp"}, + new SeaTunnelDataType<?>[] {LocalTimeType.LOCAL_DATE_TIME_TYPE}); + LocalDateTime recent = LocalDateTime.of(2025, 2, 15, 10, 21, 23, 123_456_000); + LocalDateTime beforeEpoch = LocalDateTime.of(1960, 6, 1, 12, 0, 0, 250_000); + List<SeaTunnelRow> rows = + Arrays.asList( + new SeaTunnelRow(new Object[] {recent}), + new SeaTunnelRow(new Object[] {beforeEpoch}), + new SeaTunnelRow(new Object[] {Timestamp.valueOf(recent)})); + // The schema the sink declares when it creates the dataset itself. + Schema schema = SchemaUtils.convertSchema(rows.get(0), rowType); + Assertions.assertEquals( + new ArrowType.Timestamp(TimeUnit.MICROSECOND, "Asia/Shanghai"), + schema.getFields().get(0).getType()); + + try (RootAllocator allocator = new RootAllocator(); + VectorSchemaRoot root = + FragmentConverter.convertToVectorSchemaRoot( + rows, rowType, schema, allocator)) { + TimeStampMicroTZVector vector = (TimeStampMicroTZVector) root.getVector("c_timestamp"); + Assertions.assertEquals(epochMicros(recent), vector.get(0)); + Assertions.assertEquals(epochMicros(beforeEpoch), vector.get(1)); + Assertions.assertEquals(epochMicros(recent), vector.get(2)); + // The fraction of a second does not depend on the zone the value is encoded in. + Assertions.assertEquals(123_456L, Math.floorMod(vector.get(0), 1_000_000L)); + Assertions.assertEquals(250L, Math.floorMod(vector.get(1), 1_000_000L)); + } + } + + @Test + void shouldRoundAMillisecondTimestampBeforeTheEpochDown() { + // An existing dataset keeps its own schema, so the sink can meet a millisecond timestamp + // column it did not create, on its own or inside a list. + SeaTunnelRowType rowType = + new SeaTunnelRowType( + new String[] {"c_millis", "c_millis_list"}, + new SeaTunnelDataType<?>[] { + LocalTimeType.LOCAL_DATE_TIME_TYPE, ArrayType.LOCAL_DATE_TIME_ARRAY_TYPE + }); + ArrowType millis = new ArrowType.Timestamp(TimeUnit.MILLISECOND, null); + Schema schema = + new Schema( + Arrays.asList( + Field.nullable("c_millis", millis), + new Field( + "c_millis_list", + FieldType.nullable(new ArrowType.List()), + Collections.singletonList( + Field.nullable("item", millis))))); + // 250 microseconds past a millisecond, before 1970 in every zone. + LocalDateTime beforeEpoch = LocalDateTime.of(1960, 6, 1, 12, 0, 0, 250_000); + long expected = beforeEpoch.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + + try (RootAllocator allocator = new RootAllocator(); + VectorSchemaRoot root = + FragmentConverter.convertToVectorSchemaRoot( + Collections.singletonList( + new SeaTunnelRow( + new Object[] { + beforeEpoch, + Collections.singletonList(beforeEpoch) + })), + rowType, + schema, + allocator)) { + Assertions.assertEquals( + expected, ((TimeStampMilliVector) root.getVector("c_millis")).get(0)); + ListVector list = (ListVector) root.getVector("c_millis_list"); + Assertions.assertEquals(expected, ((TimeStampMilliVector) list.getDataVector()).get(0)); + } + } + + private static long epochMicros(LocalDateTime value) { + Instant instant = value.atZone(ZoneId.systemDefault()).toInstant(); + return ChronoUnit.MICROS.between(Instant.EPOCH, instant); + } + private static void createEmptyDataset( RootAllocator allocator, String datasetPath, Schema schema) { try (Dataset ignored = diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-lance-e2e/src/test/java/org/apache/seatunnel/e2e/connector/lance/LanceIT.java b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-lance-e2e/src/test/java/org/apache/seatunnel/e2e/connector/lance/LanceIT.java index 5cb19b387f..dddca4518d 100644 --- a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-lance-e2e/src/test/java/org/apache/seatunnel/e2e/connector/lance/LanceIT.java +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-lance-e2e/src/test/java/org/apache/seatunnel/e2e/connector/lance/LanceIT.java @@ -24,6 +24,13 @@ import org.apache.seatunnel.e2e.common.container.TestContainerId; import org.apache.seatunnel.e2e.common.junit.DisabledOnContainer; import org.apache.seatunnel.e2e.common.junit.TestContainerExtension; +import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.memory.RootAllocator; +import org.apache.arrow.vector.IntVector; +import org.apache.arrow.vector.TimeStampVector; +import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.arrow.vector.ipc.ArrowReader; + import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.TestTemplate; import org.junit.jupiter.api.condition.DisabledOnOs; @@ -31,9 +38,12 @@ import org.junit.jupiter.api.condition.OS; import org.testcontainers.containers.Container; import com.lancedb.lance.Dataset; +import com.lancedb.lance.ipc.LanceScanner; import lombok.extern.slf4j.Slf4j; import java.io.IOException; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.TimeUnit; import static org.awaitility.Awaitility.given; @@ -57,6 +67,7 @@ public class LanceIT extends TestSuiteBase { private static final String DATASET_PATH = "/tmp/seatunnel_mnt/lanceTest/"; private static final String TABLE_NAME = "lance_sink_table"; + private static final String TIMESTAMP_TABLE_NAME = "lance_timestamp_table"; @TestContainerExtension protected final ContainerExtendedFactory extendedFactory = @@ -106,6 +117,46 @@ public class LanceIT extends TestSuiteBase { }); } + @TestTemplate + public void testTimestampKeepsItsMicroseconds(TestContainer container) + throws IOException, InterruptedException { + Container.ExecResult writeResult = + container.executeJob("/lance/fake_timestamp_to_lance.conf"); + Assertions.assertEquals(0, writeResult.getExitCode(), writeResult.getStderr()); + + // The fraction of a second, in microseconds, that each id must keep. Only the fraction is + // compared, because the whole seconds depend on the zone of the JVM that wrote the value. + Map<Integer, Long> expected = new HashMap<>(); + expected.put(1, 123_456L); + expected.put(2, 250L); + expected.put(3, 999_500L); + + // Unlike the row count check above, this does not skip when the dataset cannot be read + // in the test JVM, so a pass always means the stored values were checked. + given().ignoreExceptions() + .await() + .atMost(60000, TimeUnit.MILLISECONDS) + .untilAsserted(() -> Assertions.assertEquals(expected, readTimestampFractions())); + } + + private Map<Integer, Long> readTimestampFractions() throws Exception { + Map<Integer, Long> fractions = new HashMap<>(); + try (BufferAllocator allocator = new RootAllocator(); + Dataset dataset = Dataset.open(DATASET_PATH + TIMESTAMP_TABLE_NAME, allocator); + LanceScanner scanner = dataset.newScan(); + ArrowReader reader = scanner.scanBatches()) { + while (reader.loadNextBatch()) { + VectorSchemaRoot root = reader.getVectorSchemaRoot(); + IntVector ids = (IntVector) root.getVector("id"); + TimeStampVector timestamps = (TimeStampVector) root.getVector("c_timestamp"); + for (int i = 0; i < root.getRowCount(); i++) { + fractions.put(ids.get(i), Math.floorMod(timestamps.get(i), 1_000_000L)); + } + } + } + return fractions; + } + private long loadLanceTableCount() { long count = 0; try { diff --git a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-lance-e2e/src/test/resources/lance/fake_timestamp_to_lance.conf b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-lance-e2e/src/test/resources/lance/fake_timestamp_to_lance.conf new file mode 100644 index 0000000000..24f1ef5b1e --- /dev/null +++ b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-lance-e2e/src/test/resources/lance/fake_timestamp_to_lance.conf @@ -0,0 +1,67 @@ +# +# 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. +# + +env { + parallelism = 1 + job.mode = "BATCH" + + # You can set spark configuration here + spark.app.name = "SeaTunnel" + spark.executor.instances = 2 + spark.executor.cores = 1 + spark.executor.memory = "1g" + spark.master = local +} + +source { + FakeSource { + schema = { + fields { + id = int + c_timestamp = timestamp + } + } + # Each value has digits below the millisecond. The second and third are before 1970. + rows = [ + { + kind = INSERT + fields = [1, "2025-02-15T10:21:23.123456"] + }, + { + kind = INSERT + fields = [2, "1960-06-01T12:00:00.000250"] + }, + { + kind = INSERT + fields = [3, "1969-12-31T23:59:59.999500"] + } + ] + plugin_output = "fake" + } +} + +transform { +} + +sink { + Lance { + dataset_path = "/tmp/seatunnel_mnt/lanceTest/lance_timestamp_table" + namespace_type = "dir" + namespace_id = "root" + table = "lance_timestamp_table" + } +}
