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-12945-570eb976113567efc3cbaa8160dbf8c1cab3c2ac
in repository https://gitbox.apache.org/repos/asf/seatunnel.git

commit 5666e8d594ef09560a5d9e65ace4cc3748bdac2a
Author: zhengxiang378928908-code <[email protected]>
AuthorDate: Mon Oct 26 04:51:07 2026 +0000

    [Improve][Connector-V2][Doris] Warn when TIMESTAMP_TZ columns use the JVM 
default zone (#12945)
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 docs/en/connectors/sink/Doris.md                   |   2 +-
 docs/zh/connectors/sink/Doris.md                   |   2 +-
 .../doris/sink/writer/DorisSinkWriter.java         |  81 +++++++
 .../doris/sink/writer/DorisSinkWriterTest.java     | 239 ++++++++++++++++++++-
 .../seatunnel/e2e/connector/doris/DorisIT.java     |  89 ++++++++
 .../fake_timestamp_tz_to_doris_zone_explicit.conf  |  57 +++++
 .../fake_timestamp_tz_to_doris_zone_unset.conf     |  56 +++++
 7 files changed, 523 insertions(+), 3 deletions(-)

diff --git a/docs/en/connectors/sink/Doris.md b/docs/en/connectors/sink/Doris.md
index eab0e94383..958b35c10f 100644
--- a/docs/en/connectors/sink/Doris.md
+++ b/docs/en/connectors/sink/Doris.md
@@ -62,7 +62,7 @@ The internal implementation of Doris sink connector is cached 
and imported by st
 | doris.batch.size               | int     | No       | 1024                   
      | the batch size of the write to doris each http request, when the row 
reaches the size or checkpoint is executed, the data of cached will write to 
server.                                                                         
                                   |
 | needs_unsupported_type_casting | boolean | No       | false                  
      | Whether to enable the unsupported type casting, such as Decimal64 to 
Double                                                                          
                                                                                
                                |
 | case_sensitive                 | boolean | No       | true                   
      | Whether to preserve the original case of table and column names. When 
set to false, table and column names will be converted to lowercase.            
                                                                                
                                |
-| sink.datetime-timezone         | string  | No       | JVM default            
      | Session zone ID (a `java.time.ZoneId`-compatible ID such as 
`Asia/Shanghai` or `+08:00`) used to convert `TIMESTAMP_TZ` values to 
wall-clock strings for Doris `DATETIME` columns. When unset, the JVM default 
zone is used (legacy behavior). Set this to the Doris session zone to avoid 
JVM-default coupling. |
+| sink.datetime-timezone         | string  | No       | JVM default            
      | Session zone ID (a `java.time.ZoneId`-compatible ID such as 
`Asia/Shanghai` or `+08:00`) used to convert `TIMESTAMP_TZ` values to 
wall-clock strings for Doris `DATETIME` columns. When unset, the JVM default 
zone is used (legacy behavior). Set this to the Doris session zone to avoid 
JVM-default coupling. When the option is unset and the table has `TIMESTAMP_TZ` 
columns, the writer logs a warning that na [...]
 | schema_save_mode               | Enum    | no       | 
CREATE_SCHEMA_WHEN_NOT_EXIST | the schema save mode, please refer to 
`schema_save_mode` below                                                        
                                                                                
                                                               |
 | data_save_mode                 | Enum    | no       | APPEND_DATA            
      | the data save mode, please refer to `data_save_mode` below              
                                                                                
                                                                                
                             |
 | save_mode_create_template      | string  | no       | see below              
      | see below                                                               
                                                                                
                                                                                
                             |
diff --git a/docs/zh/connectors/sink/Doris.md b/docs/zh/connectors/sink/Doris.md
index b85c4feddc..5459370948 100644
--- a/docs/zh/connectors/sink/Doris.md
+++ b/docs/zh/connectors/sink/Doris.md
@@ -62,7 +62,7 @@ Doris Sink连接器的内部实现是通过stream load批量缓存和导入的
 | doris.batch.size               | int     | No       | 1024                   
      | 每次http请求写入doris的批量大小,当row达到该大小或者执行checkpoint时,缓存的数据就会写入服务器。             
                                                                              |
 | needs_unsupported_type_casting | boolean | No       | false                  
      | 是否启用不支持的类型转换,例如 Decimal64 到 Double。                                     
                                                                              |
 | case_sensitive                 | boolean | No       | true                   
      | 是否保留表名和字段名的原始大小写。当设置为 false 时,表名和字段名将被转换为小写。                            
                                                            |
-| sink.datetime-timezone         | string  | No       | JVM 默认时区               
 | 用于将 `TIMESTAMP_TZ` 值转换为 Doris `DATETIME` 列的 wall-clock 字符串的会话时区 ID(兼容 
`java.time.ZoneId`,例如 `Asia/Shanghai` 或 `+08:00`)。未设置时使用 JVM 默认时区(旧行为)。请将其设置为 
Doris 会话时区,以避免依赖 JVM 默认时区。 |
+| sink.datetime-timezone         | string  | No       | JVM 默认时区               
 | 用于将 `TIMESTAMP_TZ` 值转换为 Doris `DATETIME` 列的 wall-clock 字符串的会话时区 ID(兼容 
`java.time.ZoneId`,例如 `Asia/Shanghai` 或 `+08:00`)。未设置时使用 JVM 默认时区(旧行为)。请将其设置为 
Doris 会话时区,以避免依赖 JVM 默认时区。未设置且表中有 `TIMESTAMP_TZ` 列时,writer 会打印一条警告,列出这些列和实际使用的 
JVM 时区。 |
 | schema_save_mode               | Enum    | no       | 
CREATE_SCHEMA_WHEN_NOT_EXIST | schema保存模式,请参考下面的`schema_save_mode`              
                                                                                
                     |
 | data_save_mode                 | Enum    | no       | APPEND_DATA            
      | 数据保存模式,请参考下面的`data_save_mode`。                                          
                                                                              |
 | save_mode_create_template      | string  | no       | see below              
      | 见下文。                                                                    
                                                                              |
diff --git 
a/seatunnel-connectors-v2/connector-doris/src/main/java/org/apache/seatunnel/connectors/doris/sink/writer/DorisSinkWriter.java
 
b/seatunnel-connectors-v2/connector-doris/src/main/java/org/apache/seatunnel/connectors/doris/sink/writer/DorisSinkWriter.java
index bee0bab11f..8abed59058 100644
--- 
a/seatunnel-connectors-v2/connector-doris/src/main/java/org/apache/seatunnel/connectors/doris/sink/writer/DorisSinkWriter.java
+++ 
b/seatunnel-connectors-v2/connector-doris/src/main/java/org/apache/seatunnel/connectors/doris/sink/writer/DorisSinkWriter.java
@@ -28,6 +28,9 @@ import org.apache.seatunnel.api.table.catalog.TableSchema;
 import org.apache.seatunnel.api.table.schema.event.RestoreTableSchemaEvent;
 import org.apache.seatunnel.api.table.schema.event.SchemaChangeEvent;
 import 
org.apache.seatunnel.api.table.schema.handler.TableSchemaChangeEventDispatcher;
+import org.apache.seatunnel.api.table.type.ArrayType;
+import org.apache.seatunnel.api.table.type.MapType;
+import org.apache.seatunnel.api.table.type.SeaTunnelDataType;
 import org.apache.seatunnel.api.table.type.SeaTunnelRow;
 import org.apache.seatunnel.api.table.type.SeaTunnelRowType;
 import org.apache.seatunnel.connectors.doris.config.DorisSinkConfig;
@@ -46,6 +49,7 @@ import 
org.apache.seatunnel.connectors.doris.util.UnsupportedTypeConverterUtils;
 import lombok.extern.slf4j.Slf4j;
 
 import java.io.IOException;
+import java.time.ZoneId;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -84,6 +88,10 @@ public class DorisSinkWriter
     private SchemaChangeManager schemaChangeManager;
     private final DorisStreamLoadFactory streamLoadFactory;
     private String controlHostPort;
+    // Set once this writer instance has logged the TIMESTAMP_TZ zone warning, 
and only then: a
+    // schema without such columns leaves it unset, so a later schema change 
can still warn. It is
+    // not shared across writers or kept in state, so a restored writer may 
warn again.
+    private boolean timestampTzZoneWarningLogged;
 
     public DorisSinkWriter(
             SinkWriter.Context context,
@@ -369,9 +377,82 @@ public class DorisSinkWriter
 
     private DorisSerializer createSerializer(
             DorisSinkConfig dorisSinkConfig, SeaTunnelRowType 
seaTunnelRowType) {
+        if (!timestampTzZoneWarningLogged) {
+            Optional<String> warning =
+                    timestampTzZoneWarning(
+                            catalogTable.getTablePath(),
+                            seaTunnelRowType,
+                            dorisSinkConfig.getDatetimeTimezone(),
+                            ZoneId.systemDefault());
+            if (warning.isPresent()) {
+                log.warn(warning.get());
+                timestampTzZoneWarningLogged = true;
+            }
+        }
         return SeaTunnelRowSerializerFactory.createSerializer(dorisSinkConfig, 
seaTunnelRowType);
     }
 
+    /**
+     * Doris has no timezone-aware datetime type, so TIMESTAMP_TZ values are 
written as wall-clock
+     * time in {@code sink.datetime-timezone}, or in the JVM default zone when 
it is unset. The JVM
+     * zone can differ from the zone expected in Doris without any error, so 
say which zone is used.
+     */
+    static Optional<String> timestampTzZoneWarning(
+            TablePath tablePath,
+            SeaTunnelRowType rowType,
+            ZoneId configuredZone,
+            ZoneId jvmDefaultZone) {
+        if (configuredZone != null) {
+            return Optional.empty();
+        }
+        List<String> columns = new ArrayList<>();
+        for (int i = 0; i < rowType.getTotalFields(); i++) {
+            collectTimestampTzPaths(rowType.getFieldName(i), 
rowType.getFieldType(i), columns);
+        }
+        if (columns.isEmpty()) {
+            return Optional.empty();
+        }
+        return Optional.of(
+                String.format(
+                        "Doris sink table %s has TIMESTAMP_TZ columns %s and "
+                                + "'sink.datetime-timezone' is not set. These 
values are written "
+                                + "as wall-clock time in the JVM default zone 
%s. If Doris expects "
+                                + "another zone, set 
'sink.datetime-timezone'.",
+                        tablePath, columns, jvmDefaultZone));
+    }
+
+    /**
+     * Collects the TIMESTAMP_TZ columns of a type, including nested ones, for 
the warning message
+     * only. Nested paths use {@code .field} for row fields, {@code []} for 
array elements and
+     * {@code .<key>} / {@code .<value>} for map keys and values; they are not 
configuration paths.
+     */
+    private static void collectTimestampTzPaths(
+            String path, SeaTunnelDataType<?> type, List<String> paths) {
+        switch (type.getSqlType()) {
+            case TIMESTAMP_TZ:
+                paths.add(path);
+                break;
+            case ROW:
+                SeaTunnelRowType rowType = (SeaTunnelRowType) type;
+                for (int i = 0; i < rowType.getTotalFields(); i++) {
+                    collectTimestampTzPaths(
+                            path + "." + rowType.getFieldName(i), 
rowType.getFieldType(i), paths);
+                }
+                break;
+            case ARRAY:
+                collectTimestampTzPaths(
+                        path + "[]", ((ArrayType<?, ?>) 
type).getElementType(), paths);
+                break;
+            case MAP:
+                MapType<?, ?> mapType = (MapType<?, ?>) type;
+                collectTimestampTzPaths(path + ".<key>", mapType.getKeyType(), 
paths);
+                collectTimestampTzPaths(path + ".<value>", 
mapType.getValueType(), paths);
+                break;
+            default:
+                break;
+        }
+    }
+
     private InitializedStreamLoad initializeStreamLoad(
             List<String> nodes, String nodeType, boolean abortPreCommitOnInit) 
{
         int nodeCount = nodes.size();
diff --git 
a/seatunnel-connectors-v2/connector-doris/src/test/java/org/apache/seatunnel/connectors/doris/sink/writer/DorisSinkWriterTest.java
 
b/seatunnel-connectors-v2/connector-doris/src/test/java/org/apache/seatunnel/connectors/doris/sink/writer/DorisSinkWriterTest.java
index 3ba8eda2ab..236c923b79 100644
--- 
a/seatunnel-connectors-v2/connector-doris/src/test/java/org/apache/seatunnel/connectors/doris/sink/writer/DorisSinkWriterTest.java
+++ 
b/seatunnel-connectors-v2/connector-doris/src/test/java/org/apache/seatunnel/connectors/doris/sink/writer/DorisSinkWriterTest.java
@@ -27,7 +27,12 @@ import org.apache.seatunnel.api.table.catalog.TablePath;
 import org.apache.seatunnel.api.table.catalog.TableSchema;
 import org.apache.seatunnel.api.table.schema.event.AlterTableAddColumnEvent;
 import org.apache.seatunnel.api.table.schema.event.SchemaChangeEvent;
+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.SeaTunnelRowType;
 import org.apache.seatunnel.common.utils.function.RunnableWithException;
 import org.apache.seatunnel.connectors.doris.config.DorisSinkConfig;
 import 
org.apache.seatunnel.connectors.doris.exception.DorisSchemaChangeException;
@@ -36,11 +41,19 @@ import 
org.apache.seatunnel.connectors.doris.schema.SchemaChangeManager;
 import org.apache.seatunnel.connectors.doris.sink.LoadStatus;
 import org.apache.seatunnel.connectors.doris.sink.committer.DorisCommitInfo;
 
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.Logger;
+import org.apache.logging.log4j.core.appender.AbstractAppender;
+import org.apache.logging.log4j.core.config.Property;
+
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
 import org.mockito.InOrder;
 
+import java.time.ZoneId;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -397,7 +410,15 @@ public class DorisSinkWriterTest {
     }
 
     private DorisSinkConfig createSinkConfig(boolean directToBe, boolean 
enable2PC, String format) {
-        Map<String, Object> options = new HashMap<>();
+        return createSinkConfig(directToBe, enable2PC, format, 
Collections.emptyMap());
+    }
+
+    private DorisSinkConfig createSinkConfig(
+            boolean directToBe,
+            boolean enable2PC,
+            String format,
+            Map<String, Object> extraOptions) {
+        Map<String, Object> options = new HashMap<>(extraOptions);
         options.put("fenodes", "fe1:8030");
         options.put("benodes", "be1:8040");
         options.put("direct_to_be", directToBe);
@@ -480,4 +501,220 @@ public class DorisSinkWriterTest {
             return createdHosts;
         }
     }
+
+    @Test
+    void testTimestampTzZoneWarningNamesColumnsAndJvmZoneWhenZoneUnset() {
+        SeaTunnelRowType rowType =
+                new SeaTunnelRowType(
+                        new String[] {"id", "created_at", "updated_at", 
"local_ts"},
+                        new SeaTunnelDataType<?>[] {
+                            BasicType.LONG_TYPE,
+                            LocalTimeType.OFFSET_DATE_TIME_TYPE,
+                            LocalTimeType.OFFSET_DATE_TIME_TYPE,
+                            LocalTimeType.LOCAL_DATE_TIME_TYPE
+                        });
+
+        Optional<String> warning =
+                DorisSinkWriter.timestampTzZoneWarning(
+                        TablePath.of("db", "orders"), rowType, null, 
ZoneId.of("UTC"));
+
+        Assertions.assertTrue(warning.isPresent());
+        Assertions.assertTrue(warning.get().contains("db.orders"), 
warning.get());
+        Assertions.assertTrue(warning.get().contains("[created_at, 
updated_at]"), warning.get());
+        Assertions.assertTrue(warning.get().contains("JVM default zone UTC"), 
warning.get());
+        Assertions.assertFalse(warning.get().contains("local_ts"), 
warning.get());
+    }
+
+    @Test
+    void testNoTimestampTzZoneWarningWhenZoneSetOrNoTimestampTzColumn() {
+        SeaTunnelRowType withTz =
+                new SeaTunnelRowType(
+                        new String[] {"created_at"},
+                        new SeaTunnelDataType<?>[] 
{LocalTimeType.OFFSET_DATE_TIME_TYPE});
+        SeaTunnelRowType withoutTz =
+                new SeaTunnelRowType(
+                        new String[] {"id", "local_ts"},
+                        new SeaTunnelDataType<?>[] {
+                            BasicType.LONG_TYPE, 
LocalTimeType.LOCAL_DATE_TIME_TYPE
+                        });
+        TablePath tablePath = TablePath.of("db", "orders");
+
+        Assertions.assertFalse(
+                DorisSinkWriter.timestampTzZoneWarning(
+                                tablePath, withTz, ZoneId.of("Asia/Shanghai"), 
ZoneId.of("UTC"))
+                        .isPresent());
+        Assertions.assertFalse(
+                DorisSinkWriter.timestampTzZoneWarning(tablePath, withoutTz, 
null, ZoneId.of("UTC"))
+                        .isPresent());
+    }
+
+    @Test
+    void testTimestampTzZoneWarningFindsNestedColumns() {
+        SeaTunnelRowType payload =
+                new SeaTunnelRowType(
+                        new String[] {"name", "created_at"},
+                        new SeaTunnelDataType<?>[] {
+                            BasicType.STRING_TYPE, 
LocalTimeType.OFFSET_DATE_TIME_TYPE
+                        });
+        SeaTunnelRowType rowType =
+                new SeaTunnelRowType(
+                        new String[] {"id", "payload", "events", "attrs"},
+                        new SeaTunnelDataType<?>[] {
+                            BasicType.LONG_TYPE,
+                            payload,
+                            ArrayType.OFFSET_DATE_TIME_ARRAY_TYPE,
+                            new MapType<>(
+                                    BasicType.STRING_TYPE, 
LocalTimeType.OFFSET_DATE_TIME_TYPE)
+                        });
+
+        Optional<String> warning =
+                DorisSinkWriter.timestampTzZoneWarning(
+                        TablePath.of("db", "orders"), rowType, null, 
ZoneId.of("UTC"));
+
+        Assertions.assertTrue(warning.isPresent());
+        Assertions.assertTrue(
+                warning.get().contains("[payload.created_at, events[], 
attrs.<value>]"),
+                warning.get());
+    }
+
+    @Test
+    void 
testTimestampTzZoneWarningLoggedOnceWhenWriterCreatedWithTimestampTzColumn()
+            throws Exception {
+        List<String> warnings =
+                captureTimestampTzZoneWarnings(
+                        () -> {
+                            DorisSinkWriter writer =
+                                    new DorisSinkWriter(
+                                            new DefaultSinkWriterContext(0, 1),
+                                            new ArrayList<>(),
+                                            
mockCatalogTable(timestampTzColumn("created_at")),
+                                            createSinkConfig(false, false),
+                                            "job_1",
+                                            frontendOnlyFactory());
+                            try {
+                                
writer.setSchemaChangeManager(mock(SchemaChangeManager.class));
+                                
writer.applySchemaChange(addTimestampTzColumnEvent("updated_at"));
+                            } finally {
+                                writer.close();
+                            }
+                        });
+
+        Assertions.assertEquals(1, warnings.size(), warnings.toString());
+        Assertions.assertTrue(
+                warnings.get(0)
+                        .contains("test_db.test_table has TIMESTAMP_TZ columns 
[created_at]"),
+                warnings.get(0));
+    }
+
+    @Test
+    void 
testTimestampTzZoneWarningLoggedOnceWhenSchemaChangeAddsTimestampTzColumn()
+            throws Exception {
+        List<String> warnings =
+                captureTimestampTzZoneWarnings(
+                        () -> {
+                            DorisSinkWriter writer =
+                                    new DorisSinkWriter(
+                                            new DefaultSinkWriterContext(0, 1),
+                                            new ArrayList<>(),
+                                            mockCatalogTable(),
+                                            createSinkConfig(false, false),
+                                            "job_1",
+                                            frontendOnlyFactory());
+                            try {
+                                
writer.setSchemaChangeManager(mock(SchemaChangeManager.class));
+                                writer.applySchemaChange(addColumnEvent());
+                                
writer.applySchemaChange(addTimestampTzColumnEvent("created_at"));
+                                
writer.applySchemaChange(addTimestampTzColumnEvent("updated_at"));
+                            } finally {
+                                writer.close();
+                            }
+                        });
+
+        Assertions.assertEquals(1, warnings.size(), warnings.toString());
+        Assertions.assertTrue(
+                warnings.get(0).contains("TIMESTAMP_TZ columns [created_at]"), 
warnings.get(0));
+    }
+
+    @Test
+    void testNoTimestampTzZoneWarningLoggedWhenZoneConfigured() throws 
Exception {
+        Map<String, Object> options = new HashMap<>();
+        options.put("sink.datetime-timezone", "Asia/Shanghai");
+        List<String> warnings =
+                captureTimestampTzZoneWarnings(
+                        () ->
+                                new DorisSinkWriter(
+                                                new 
DefaultSinkWriterContext(0, 1),
+                                                new ArrayList<>(),
+                                                
mockCatalogTable(timestampTzColumn("created_at")),
+                                                createSinkConfig(false, false, 
"json", options),
+                                                "job_1",
+                                                frontendOnlyFactory())
+                                        .close());
+
+        Assertions.assertTrue(warnings.isEmpty(), warnings.toString());
+    }
+
+    private List<String> captureTimestampTzZoneWarnings(RunnableWithException 
action)
+            throws Exception {
+        List<String> warnings = Collections.synchronizedList(new 
ArrayList<>());
+        AbstractAppender appender =
+                new AbstractAppender(
+                        "timestamp-tz-zone-warning", null, null, true, 
Property.EMPTY_ARRAY) {
+                    @Override
+                    public void append(LogEvent event) {
+                        String message = 
event.getMessage().getFormattedMessage();
+                        if (event.getLevel() == Level.WARN
+                                && message.contains("'sink.datetime-timezone' 
is not set")) {
+                            warnings.add(message);
+                        }
+                    }
+                };
+        appender.start();
+        Logger logger = (Logger) LogManager.getLogger(DorisSinkWriter.class);
+        Level originalLevel = logger.getLevel();
+        logger.addAppender(appender);
+        logger.setLevel(Level.WARN);
+        try {
+            action.run();
+        } finally {
+            logger.removeAppender(appender);
+            logger.setLevel(originalLevel);
+            appender.stop();
+        }
+        return new ArrayList<>(warnings);
+    }
+
+    private PhysicalColumn timestampTzColumn(String name) {
+        return PhysicalColumn.of(
+                name, LocalTimeType.OFFSET_DATE_TIME_TYPE, (Long) null, true, 
null, null);
+    }
+
+    private AlterTableAddColumnEvent addTimestampTzColumnEvent(String name) {
+        return AlterTableAddColumnEvent.add(
+                TableIdentifier.of("doris", "test_db", null, "test_table"),
+                timestampTzColumn(name));
+    }
+
+    private RecordingStreamLoadFactory frontendOnlyFactory() throws Exception {
+        DorisStreamLoad frontendLoad = mock(DorisStreamLoad.class);
+        when(frontendLoad.stopLoad()).thenReturn(successRespContent(41L));
+        RecordingStreamLoadFactory factory = new RecordingStreamLoadFactory();
+        factory.register("fe1:8030", frontendLoad);
+        return factory;
+    }
+
+    private CatalogTable mockCatalogTable(PhysicalColumn extraColumn) {
+        TableSchema tableSchema =
+                TableSchema.builder()
+                        .column(
+                                PhysicalColumn.of(
+                                        "id", BasicType.LONG_TYPE, (Long) 
null, true, null, null))
+                        .column(extraColumn)
+                        .build();
+        CatalogTable catalogTable = mock(CatalogTable.class);
+        when(catalogTable.getTablePath()).thenReturn(TablePath.of("test_db", 
"test_table"));
+        when(catalogTable.getTableSchema()).thenReturn(tableSchema);
+        
when(catalogTable.getSeaTunnelRowType()).thenReturn(tableSchema.toPhysicalRowDataType());
+        return catalogTable;
+    }
 }
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-doris-e2e/src/test/java/org/apache/seatunnel/e2e/connector/doris/DorisIT.java
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-doris-e2e/src/test/java/org/apache/seatunnel/e2e/connector/doris/DorisIT.java
index 8cf7d69bba..84226f0343 100644
--- 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-doris-e2e/src/test/java/org/apache/seatunnel/e2e/connector/doris/DorisIT.java
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-doris-e2e/src/test/java/org/apache/seatunnel/e2e/connector/doris/DorisIT.java
@@ -46,6 +46,9 @@ import java.sql.PreparedStatement;
 import java.sql.ResultSet;
 import java.sql.SQLException;
 import java.sql.Statement;
+import java.time.LocalDateTime;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
@@ -67,6 +70,22 @@ public class DorisIT extends AbstractDorisIT {
     private static final String DUPLICATE_TABLE = "doris_duplicate_table";
     private static final String sourceDB = "e2e_source";
     private static final String sinkDB = "e2e_sink";
+    private static final String TZ_UNSET_TABLE = "doris_tz_unset";
+    private static final String TZ_EXPLICIT_TABLE = "doris_tz_explicit";
+    private static final OffsetDateTime TZ_SOURCE_VALUE =
+            OffsetDateTime.parse("2025-09-28T02:54:08+08:00");
+    private static final Pattern TIMESTAMP_TZ_WARNING =
+            Pattern.compile(
+                    Pattern.quote(
+                                    "Doris sink table "
+                                            + sinkDB
+                                            + "."
+                                            + TZ_UNSET_TABLE
+                                            + " has TIMESTAMP_TZ columns 
[F_TS] and"
+                                            + " 'sink.datetime-timezone' is 
not set. These values"
+                                            + " are written as wall-clock time 
in the JVM default"
+                                            + " zone ")
+                            + "(\\S+)\\. ");
     private Connection conn;
 
     private Map<String, String> checkColumnTypeMap = null;
@@ -243,6 +262,76 @@ public class DorisIT extends AbstractDorisIT {
         checkSinkData();
     }
 
+    @TestTemplate
+    public void testTimestampTzZoneWarning(TestContainer container)
+            throws IOException, InterruptedException {
+        initializeJdbcTable();
+        try {
+            Container.ExecResult unsetResult =
+                    
container.executeJob("/fake_timestamp_tz_to_doris_zone_unset.conf");
+            Assertions.assertEquals(0, unsetResult.getExitCode(), 
unsetResult.getStderr());
+            Container.ExecResult explicitResult =
+                    
container.executeJob("/fake_timestamp_tz_to_doris_zone_explicit.conf");
+            Assertions.assertEquals(0, explicitResult.getExitCode(), 
explicitResult.getStderr());
+
+            // Where the writer's log ends up depends on the engine, so search 
both.
+            String logs =
+                    unsetResult.getStdout()
+                            + unsetResult.getStderr()
+                            + explicitResult.getStdout()
+                            + explicitResult.getStderr()
+                            + container.getServerLogs();
+            Matcher warning = TIMESTAMP_TZ_WARNING.matcher(logs);
+            Assertions.assertTrue(
+                    warning.find(), "No TIMESTAMP_TZ zone warning for " + 
TZ_UNSET_TABLE);
+            Assertions.assertFalse(
+                    logs.contains("Doris sink table " + sinkDB + "." + 
TZ_EXPLICIT_TABLE),
+                    "Unexpected TIMESTAMP_TZ zone warning for " + 
TZ_EXPLICIT_TABLE);
+
+            // The warning names the zone the value was written in; the 
explicit zone is a control.
+            ZoneId jvmZone = ZoneId.of(warning.group(1));
+            LocalDateTime expectedUnset =
+                    
TZ_SOURCE_VALUE.atZoneSameInstant(jvmZone).toLocalDateTime();
+            LocalDateTime expectedExplicit =
+                    
TZ_SOURCE_VALUE.atZoneSameInstant(ZoneId.of("Asia/Shanghai")).toLocalDateTime();
+            await().atMost(60, TimeUnit.SECONDS)
+                    .pollInterval(2, TimeUnit.SECONDS)
+                    .ignoreExceptions()
+                    .untilAsserted(
+                            () -> {
+                                Assertions.assertEquals(
+                                        expectedUnset, 
readTimestampTz(TZ_UNSET_TABLE));
+                                Assertions.assertEquals(
+                                        expectedExplicit, 
readTimestampTz(TZ_EXPLICIT_TABLE));
+                            });
+        } finally {
+            dropTable(sinkDB, TZ_UNSET_TABLE);
+            dropTable(sinkDB, TZ_EXPLICIT_TABLE);
+        }
+    }
+
+    private LocalDateTime readTimestampTz(String table) throws SQLException {
+        try (Statement statement = conn.createStatement();
+                ResultSet resultSet =
+                        statement.executeQuery(
+                                String.format(
+                                        "select cast(F_TS as string) from 
%s.%s where F_ID = 1",
+                                        sinkDB, table))) {
+            Assertions.assertTrue(resultSet.next(), "No row in " + table);
+            String value = resultSet.getString(1);
+            Assertions.assertFalse(resultSet.next(), "More than one row in " + 
table);
+            return LocalDateTime.parse(value.substring(0, 19).replace(' ', 
'T'));
+        }
+    }
+
+    private void dropTable(String db, String table) {
+        try (Statement statement = conn.createStatement()) {
+            statement.execute(String.format("DROP TABLE IF EXISTS %s.%s", db, 
table));
+        } catch (SQLException e) {
+            throw new RuntimeException("Failed to drop " + db + "." + table, 
e);
+        }
+    }
+
     private void checkAllTypeSinkData() {
         try {
             assertHasData(sourceDB, DUPLICATE_TABLE);
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-doris-e2e/src/test/resources/fake_timestamp_tz_to_doris_zone_explicit.conf
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-doris-e2e/src/test/resources/fake_timestamp_tz_to_doris_zone_explicit.conf
new file mode 100644
index 0000000000..3a4cedb58c
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-doris-e2e/src/test/resources/fake_timestamp_tz_to_doris_zone_explicit.conf
@@ -0,0 +1,57 @@
+#
+# 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"
+}
+
+source {
+  FakeSource {
+    schema = {
+      fields {
+        F_ID = "bigint"
+        F_TS = "timestamp_tz"
+      }
+    }
+    rows = [
+      {
+        kind = INSERT
+        fields = [1, "2025-09-28T02:54:08+08:00"]
+      }
+    ]
+  }
+}
+
+sink {
+  Doris {
+          fenodes = "doris_e2e:8030"
+          schema_save_mode = "RECREATE_SCHEMA"
+          username = root
+          password = ""
+          table.identifier = "e2e_sink.doris_tz_explicit"
+          sink.datetime-timezone = "Asia/Shanghai"
+          sink.enable-2pc = "false"
+          sink.label-prefix = "test_tz_explicit"
+          doris.config = {
+              format = "json"
+              read_json_by_line = "true"
+          }
+          save_mode_create_template = """CREATE TABLE IF NOT EXISTS 
`${database}`.`${table}` (${rowtype_fields}) ENGINE=OLAP UNIQUE KEY (`F_ID`) 
DISTRIBUTED BY HASH (`F_ID`) PROPERTIES ("replication_allocation" = 
"tag.location.default: 1")"""
+      }
+}
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-doris-e2e/src/test/resources/fake_timestamp_tz_to_doris_zone_unset.conf
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-doris-e2e/src/test/resources/fake_timestamp_tz_to_doris_zone_unset.conf
new file mode 100644
index 0000000000..890346f07b
--- /dev/null
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-doris-e2e/src/test/resources/fake_timestamp_tz_to_doris_zone_unset.conf
@@ -0,0 +1,56 @@
+#
+# 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"
+}
+
+source {
+  FakeSource {
+    schema = {
+      fields {
+        F_ID = "bigint"
+        F_TS = "timestamp_tz"
+      }
+    }
+    rows = [
+      {
+        kind = INSERT
+        fields = [1, "2025-09-28T02:54:08+08:00"]
+      }
+    ]
+  }
+}
+
+sink {
+  Doris {
+          fenodes = "doris_e2e:8030"
+          schema_save_mode = "RECREATE_SCHEMA"
+          username = root
+          password = ""
+          table.identifier = "e2e_sink.doris_tz_unset"
+          sink.enable-2pc = "false"
+          sink.label-prefix = "test_tz_unset"
+          doris.config = {
+              format = "json"
+              read_json_by_line = "true"
+          }
+          save_mode_create_template = """CREATE TABLE IF NOT EXISTS 
`${database}`.`${table}` (${rowtype_fields}) ENGINE=OLAP UNIQUE KEY (`F_ID`) 
DISTRIBUTED BY HASH (`F_ID`) PROPERTIES ("replication_allocation" = 
"tag.location.default: 1")"""
+      }
+}

Reply via email to