Skip to content

Commit 4af3a54

Browse files
committed
[fix](external) Preserve Hudi and Paimon timestamp semantics
Issue Number: None Related PR: #65446 Problem Summary: Hudi scanner v2 started applying the Hive INT96 compatibility timezone, which changed Hudi existing session-timezone behavior and could make native and JNI splits disagree. Paimon nested filter-only TIMESTAMP_LTZ columns could also lose their table-format timestamp annotation while building the file projection. In addition, a fuzzy session variable could disable COUNT pushdown for a multi-gigabyte MAP regression fixture and cause scanner v2 to materialize the payload under ASAN. Keep Hudi native and JNI timestamp decoding on the SQL session timezone, propagate Paimon timestamp semantics into filter-only projections, and cover the precision-9 INT96 path. Make COUNT pushdown deterministic only for the large MAP fixture so scanner v2 reads definition levels while scanner v1 retains its existing behavior.
1 parent b9c5839 commit 4af3a54

14 files changed

Lines changed: 127 additions & 50 deletions

File tree

be/src/format_v2/column_mapper_nested.cpp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,9 @@ Status build_file_child_projection_from_schema(const std::vector<ColumnDefinitio
545545
return Status::OK();
546546
}
547547
*projection = LocalColumnIndex::local(child->file_local_id());
548+
// Filter-only paths may have no ColumnMapping, so their schema projection must carry the
549+
// table-format timestamp semantic itself.
550+
projection->timestamp_is_adjusted_to_utc = child->timestamp_is_adjusted_to_utc;
548551
projection->project_all_children = selectors.size() == 1;
549552
projection->children.clear();
550553
if (selectors.size() == 1) {

be/src/format_v2/jni/hudi_jni_reader.cpp

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,12 +93,6 @@ Status HudiJniReader::build_scanner_params(std::map<std::string, std::string>* p
9393
(*params)["instant_time"] = hudi_params.instant_time;
9494
(*params)["serde"] = hudi_params.serde;
9595
(*params)["input_format"] = hudi_params.input_format;
96-
// Keep the INT96 compatibility zone separate because Hudi JNI also uses the session zone to
97-
// materialize logical INT64 timestamps, which must match native Parquet splits.
98-
(*params)["int96_time_zone"] = _scan_params->__isset.hive_parquet_time_zone &&
99-
!_scan_params->hive_parquet_time_zone.empty()
100-
? _scan_params->hive_parquet_time_zone
101-
: "UTC";
10296
if (_runtime_state != nullptr) {
10397
(*params)["query_id"] = print_id(_runtime_state->query_id());
10498
}

be/src/format_v2/table/hudi_reader.cpp

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
#include "exprs/vexpr_context.h"
2323
#include "format_v2/column_mapper.h"
2424
#include "format_v2/jni/hudi_jni_reader.h"
25+
#include "format_v2/parquet/parquet_reader.h"
2526
#include "format_v2/table/schema_history_util.h"
2627
#include "gen_cpp/PlanNodes_types.h"
2728

@@ -60,6 +61,31 @@ format::TableColumnMappingMode HudiReader::mapping_mode() const {
6061
: format::TableColumnMappingMode::BY_NAME;
6162
}
6263

64+
Status HudiReader::create_file_reader(std::unique_ptr<format::FileReader>* reader) {
65+
if (_format != format::FileFormat::PARQUET) {
66+
return format::TableReader::create_file_reader(reader);
67+
}
68+
DORIS_CHECK(reader != nullptr);
69+
const bool enable_mapping_timestamp_tz = _scan_params != nullptr &&
70+
_scan_params->__isset.enable_mapping_timestamp_tz &&
71+
_scan_params->enable_mapping_timestamp_tz;
72+
const bool enable_mapping_varbinary = _scan_params != nullptr &&
73+
_scan_params->__isset.enable_mapping_varbinary &&
74+
_scan_params->enable_mapping_varbinary;
75+
*reader = std::make_unique<format::parquet::ParquetReader>(
76+
_system_properties, _current_task->data_file, _io_ctx, _scanner_profile,
77+
_global_rowid_context, enable_mapping_timestamp_tz, enable_mapping_varbinary,
78+
parquet_int96_time_zone());
79+
return Status::OK();
80+
}
81+
82+
std::string HudiReader::parquet_int96_time_zone() const {
83+
DORIS_CHECK(_runtime_state != nullptr);
84+
// Hudi keeps its legacy session-timezone contract for native base files; Hive's INT96
85+
// compatibility property must not make native and JNI Hudi splits diverge.
86+
return _runtime_state->timezone();
87+
}
88+
6389
Status HudiReader::annotate_file_schema(std::vector<format::ColumnDefinition>* file_schema) {
6490
DORIS_CHECK(file_schema != nullptr);
6591
if (mapping_mode() != format::TableColumnMappingMode::BY_FIELD_ID) {

be/src/format_v2/table/hudi_reader.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,16 @@ class HudiReader final : public format::TableReader {
3636
#ifdef BE_TEST
3737
void TEST_set_scan_params(TFileScanRangeParams* params) { _scan_params = params; }
3838
format::TableColumnMappingMode TEST_mapping_mode() const { return mapping_mode(); }
39+
std::string TEST_parquet_int96_time_zone() const { return parquet_int96_time_zone(); }
3940
Status TEST_annotate_file_schema(std::vector<format::ColumnDefinition>* file_schema) {
4041
return annotate_file_schema(file_schema);
4142
}
4243
#endif
4344

4445
protected:
46+
Status create_file_reader(std::unique_ptr<format::FileReader>* reader) override;
4547
format::TableColumnMappingMode mapping_mode() const override;
48+
std::string parquet_int96_time_zone() const;
4649
Status annotate_file_schema(std::vector<format::ColumnDefinition>* file_schema) override;
4750

4851
private:

be/test/format_v2/column_mapper_test.cpp

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3062,6 +3062,43 @@ TEST(ColumnMapperScanRequestTest, PredicateProjectionRebuildsProjectedStructFile
30623062
EXPECT_FALSE(mapper.mappings()[0].is_trivial);
30633063
}
30643064

3065+
// Scenario: Paimon projects one struct child but filters on an unprojected TIMESTAMP_LTZ(9)
3066+
// child. The filter-only file projection must retain the history-schema timestamp semantic so an
3067+
// unannotated INT96 leaf is materialized as TIMESTAMPTZ instead of DATETIMEV2.
3068+
TEST(ColumnMapperScanRequestTest, FilterOnlyNestedTimestampRetainsTableFormatSemantic) {
3069+
const auto int_type = i32();
3070+
const auto ltz_type = timestamptz(9);
3071+
3072+
auto table_payload = field_id_col("payload", 1, int_type);
3073+
auto projected_table_struct = struct_col("s", 10, {table_payload});
3074+
auto table_ltz = field_id_col("ltz", 2, ltz_type);
3075+
auto full_table_struct = struct_col("s", 10, {table_payload, table_ltz});
3076+
3077+
auto file_payload = field_id_col("payload", 1, int_type, 0);
3078+
auto file_ltz = field_id_col("ltz", 2, ltz_type, 1);
3079+
file_ltz.timestamp_is_adjusted_to_utc = true;
3080+
auto file_struct = struct_col("s", 10, {file_payload, file_ltz}, 5);
3081+
3082+
TableColumnMapper mapper({.mode = TableColumnMappingMode::BY_FIELD_ID});
3083+
ASSERT_TRUE(mapper.create_mapping({projected_table_struct}, {}, {file_struct}).ok());
3084+
3085+
auto filter_expr = null_predicate(
3086+
struct_element(table_slot(0, 0, full_table_struct.type, "s"), ltz_type, "ltz"), false);
3087+
TableFilter filter {.conjunct = VExprContext::create_shared(filter_expr),
3088+
.global_indices = {GlobalIndex(0)}};
3089+
3090+
FileScanRequest request;
3091+
ASSERT_TRUE(mapper.create_scan_request({filter}, {projected_table_struct}, &request).ok());
3092+
3093+
ASSERT_EQ(request.predicate_columns.size(), 1);
3094+
const auto& root_projection = request.predicate_columns[0];
3095+
ASSERT_EQ(projection_ids(root_projection.children), std::vector<int32_t>({0, 1}));
3096+
const auto* ltz_projection = find_child_projection(&root_projection, 1);
3097+
ASSERT_NE(ltz_projection, nullptr);
3098+
ASSERT_TRUE(ltz_projection->timestamp_is_adjusted_to_utc.has_value());
3099+
EXPECT_TRUE(*ltz_projection->timestamp_is_adjusted_to_utc);
3100+
}
3101+
30653102
// Scenario: a filter references a top-level column that is not projected by the query; the mapper
30663103
// creates a hidden filter mapping without adding that hidden column to visible table mappings.
30673104
TEST(ColumnMapperScanRequestTest, PredicateOnlyTopLevelColumnUsesHiddenMapping) {

be/test/format_v2/jni/jni_table_reader_test.cpp

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ TEST(JniTableReaderTest, GenericConnectorUsesQuerySessionTimezone) {
165165
EXPECT_EQ(reader._scanner_params["time_zone"], "America/Los_Angeles");
166166
}
167167

168-
TEST(HudiJniReaderTest, CatalogInt96TimezoneIsSeparateFromQuerySession) {
168+
TEST(HudiJniReaderTest, CatalogInt96TimezoneDoesNotOverrideSessionTimezone) {
169169
TFileScanRangeParams scan_params;
170170
scan_params.__set_hive_parquet_time_zone("Asia/Shanghai");
171171
RuntimeState state {TQueryOptions(), TQueryGlobals()};
@@ -192,10 +192,10 @@ TEST(HudiJniReaderTest, CatalogInt96TimezoneIsSeparateFromQuerySession) {
192192
reader._scanner_params = std::move(params);
193193
reader._apply_common_scanner_params();
194194
EXPECT_EQ(reader._scanner_params["time_zone"], "America/Los_Angeles");
195-
EXPECT_EQ(reader._scanner_params["int96_time_zone"], "Asia/Shanghai");
195+
EXPECT_FALSE(reader._scanner_params.contains("int96_time_zone"));
196196
}
197197

198-
TEST(HudiJniReaderTest, UnconfiguredInt96TimezoneUsesRawWallClockPolicy) {
198+
TEST(HudiJniReaderTest, UnconfiguredInt96TimezoneUsesSessionTimezone) {
199199
TFileScanRangeParams scan_params;
200200
RuntimeState state {TQueryOptions(), TQueryGlobals()};
201201
state.set_timezone("America/Los_Angeles");
@@ -221,7 +221,7 @@ TEST(HudiJniReaderTest, UnconfiguredInt96TimezoneUsesRawWallClockPolicy) {
221221
reader._scanner_params = std::move(params);
222222
reader._apply_common_scanner_params();
223223
EXPECT_EQ(reader._scanner_params["time_zone"], "America/Los_Angeles");
224-
EXPECT_EQ(reader._scanner_params["int96_time_zone"], "UTC");
224+
EXPECT_FALSE(reader._scanner_params.contains("int96_time_zone"));
225225
}
226226

227227
TEST(JniTableReaderTest, CancellationStopsBeforeFetchingAnotherJavaBatch) {

be/test/format_v2/table/hudi_reader_test.cpp

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,25 @@ TEST(HudiReaderTest, FallsBackToByNameWhenSplitHistorySchemaIsMissing) {
214214
EXPECT_TRUE(file_schema[0].name_mapping.empty());
215215
}
216216

217+
TEST(HudiReaderTest, NativeInt96KeepsSessionTimezone) {
218+
TFileScanRangeParams scan_params;
219+
scan_params.__set_hive_parquet_time_zone("Asia/Shanghai");
220+
RuntimeState state {TQueryOptions(), TQueryGlobals()};
221+
state.set_timezone("America/Los_Angeles");
222+
223+
hudi::HudiReader reader;
224+
ASSERT_TRUE(reader.init({.projected_columns = {},
225+
.conjuncts = {},
226+
.format = FileFormat::PARQUET,
227+
.scan_params = &scan_params,
228+
.io_ctx = nullptr,
229+
.runtime_state = &state,
230+
.scanner_profile = nullptr})
231+
.ok());
232+
233+
EXPECT_EQ(reader.TEST_parquet_int96_time_zone(), "America/Los_Angeles");
234+
}
235+
217236
// Scenario: HudiReader must reset the previous split schema id before each split. Otherwise a
218237
// BY_FIELD_ID split could leak its schema id into the next split that carries no schema id.
219238
TEST(HudiReaderTest, ResetsSplitSchemaIdBeforePreparingNextSplit) {

fe/be-java-extensions/hadoop-hudi-scanner/src/main/java/org/apache/doris/hudi/HadoopHudiColumnValue.java

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,10 @@ public class HadoopHudiColumnValue implements ColumnValue {
4646
private ColumnType dorisType;
4747
private ObjectInspector fieldInspector;
4848
private Object fieldData;
49-
private final ZoneId sessionZoneId;
50-
private final ZoneId int96ZoneId;
49+
private final ZoneId zoneId;
5150

52-
public HadoopHudiColumnValue(ZoneId sessionZoneId, ZoneId int96ZoneId) {
53-
this.sessionZoneId = sessionZoneId;
54-
this.int96ZoneId = int96ZoneId;
51+
public HadoopHudiColumnValue(ZoneId zoneId) {
52+
this.zoneId = zoneId;
5553
}
5654

5755
public void setRow(Object record) {
@@ -135,7 +133,7 @@ public LocalDateTime getDateTime() {
135133
return ((Timestamp) fieldData).toLocalDateTime();
136134
} else if (fieldData instanceof TimestampWritableV2) {
137135
return LocalDateTime.ofInstant(Instant.ofEpochSecond((((TimestampObjectInspector) fieldInspector)
138-
.getPrimitiveJavaObject(fieldData)).toEpochSecond()), int96ZoneId);
136+
.getPrimitiveJavaObject(fieldData)).toEpochSecond()), zoneId);
139137
} else {
140138
long datetime = ((LongWritable) fieldData).get();
141139
long seconds;
@@ -150,9 +148,7 @@ public LocalDateTime getDateTime() {
150148
throw new RuntimeException("Hoodie timestamp only support milliseconds and microseconds, "
151149
+ "wrong precision = " + dorisType.getPrecision());
152150
}
153-
// Logical INT64 timestamps carry epoch values and must remain in the session zone;
154-
// applying the INT96 override would make native and JNI Hudi splits disagree.
155-
return LocalDateTime.ofInstant(Instant.ofEpochSecond(seconds, nanoseconds), sessionZoneId);
151+
return LocalDateTime.ofInstant(Instant.ofEpochSecond(seconds, nanoseconds), zoneId);
156152
}
157153
}
158154

@@ -183,7 +179,7 @@ public void unpackArray(List<ColumnValue> values) {
183179
ObjectInspector itemInspector = inspector.getListElementObjectInspector();
184180
for (int i = 0; i < items.size(); i++) {
185181
Object item = items.get(i);
186-
HadoopHudiColumnValue childValue = new HadoopHudiColumnValue(sessionZoneId, int96ZoneId);
182+
HadoopHudiColumnValue childValue = new HadoopHudiColumnValue(zoneId);
187183
childValue.setRow(item);
188184
childValue.setField(dorisType.getChildTypes().get(0), itemInspector);
189185
values.add(childValue);
@@ -196,12 +192,12 @@ public void unpackMap(List<ColumnValue> keys, List<ColumnValue> values) {
196192
ObjectInspector keyObjectInspector = inspector.getMapKeyObjectInspector();
197193
ObjectInspector valueObjectInspector = inspector.getMapValueObjectInspector();
198194
for (Map.Entry kv : inspector.getMap(fieldData).entrySet()) {
199-
HadoopHudiColumnValue key = new HadoopHudiColumnValue(sessionZoneId, int96ZoneId);
195+
HadoopHudiColumnValue key = new HadoopHudiColumnValue(zoneId);
200196
key.setRow(kv.getKey());
201197
key.setField(dorisType.getChildTypes().get(0), keyObjectInspector);
202198
keys.add(key);
203199

204-
HadoopHudiColumnValue value = new HadoopHudiColumnValue(sessionZoneId, int96ZoneId);
200+
HadoopHudiColumnValue value = new HadoopHudiColumnValue(zoneId);
205201
value.setRow(kv.getValue());
206202
value.setField(dorisType.getChildTypes().get(1), valueObjectInspector);
207203
values.add(value);
@@ -214,7 +210,7 @@ public void unpackStruct(List<Integer> structFieldIndex, List<ColumnValue> value
214210
List<? extends StructField> fields = inspector.getAllStructFieldRefs();
215211
for (int i = 0; i < structFieldIndex.size(); i++) {
216212
Integer idx = structFieldIndex.get(i);
217-
HadoopHudiColumnValue value = new HadoopHudiColumnValue(sessionZoneId, int96ZoneId);
213+
HadoopHudiColumnValue value = new HadoopHudiColumnValue(zoneId);
218214
Object obj = null;
219215
if (idx != null) {
220216
StructField sf = fields.get(idx);

fe/be-java-extensions/hadoop-hudi-scanner/src/main/java/org/apache/doris/hudi/HadoopHudiJniScanner.java

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -131,21 +131,14 @@ public HadoopHudiJniScanner(int fetchSize, Map<String, String> params) {
131131
}
132132
this.preExecutionAuthenticator = PreExecutionAuthenticatorCache.getAuthenticator(fsOptionsProps);
133133

134-
ZoneId sessionZoneId;
134+
ZoneId zoneId;
135135
if (Strings.isNullOrEmpty(params.get("time_zone"))) {
136-
sessionZoneId = ZoneId.systemDefault();
136+
zoneId = ZoneId.systemDefault();
137137
} else {
138-
sessionZoneId = ZoneId.of(params.get("time_zone"));
138+
zoneId = ZoneId.of(params.get("time_zone"));
139139
}
140-
ZoneId int96ZoneId;
141-
if (Strings.isNullOrEmpty(params.get("int96_time_zone"))) {
142-
// Scanner v1 does not send the v2-only override and must keep its legacy session-zone
143-
// behavior for every timestamp representation.
144-
int96ZoneId = sessionZoneId;
145-
} else {
146-
int96ZoneId = ZoneId.of(params.get("int96_time_zone"));
147-
}
148-
this.columnValue = new HadoopHudiColumnValue(sessionZoneId, int96ZoneId);
140+
// Hudi keeps one session zone for every timestamp encoding to preserve its JNI contract.
141+
this.columnValue = new HadoopHudiColumnValue(zoneId);
149142
this.fetchSize = fetchSize;
150143
this.classLoader = this.getClass().getClassLoader();
151144
}

fe/be-java-extensions/hadoop-hudi-scanner/src/test/java/org/apache/doris/hudi/HadoopHudiColumnValueTest.java

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,22 +32,20 @@
3232
public class HadoopHudiColumnValueTest {
3333
@Test
3434
public void testInt64TimestampUsesSessionTimezone() {
35-
HadoopHudiColumnValue value = new HadoopHudiColumnValue(
36-
ZoneId.of("America/Los_Angeles"), ZoneId.of("Asia/Shanghai"));
35+
HadoopHudiColumnValue value = new HadoopHudiColumnValue(ZoneId.of("America/Los_Angeles"));
3736
value.setField(ColumnType.parseType("ts", "datetimev2(6)"), null);
3837
value.setRow(new LongWritable(0));
3938

4039
Assert.assertEquals(LocalDateTime.of(1969, 12, 31, 16, 0), value.getDateTime());
4140
}
4241

4342
@Test
44-
public void testInt96TimestampUsesCompatibilityTimezone() {
45-
HadoopHudiColumnValue value = new HadoopHudiColumnValue(
46-
ZoneId.of("America/Los_Angeles"), ZoneId.of("Asia/Shanghai"));
43+
public void testInt96TimestampUsesSessionTimezone() {
44+
HadoopHudiColumnValue value = new HadoopHudiColumnValue(ZoneId.of("America/Los_Angeles"));
4745
value.setField(ColumnType.parseType("ts", "datetimev2(6)"),
4846
PrimitiveObjectInspectorFactory.writableTimestampObjectInspector);
4947
value.setRow(new TimestampWritableV2(Timestamp.ofEpochSecond(0)));
5048

51-
Assert.assertEquals(LocalDateTime.of(1970, 1, 1, 8, 0), value.getDateTime());
49+
Assert.assertEquals(LocalDateTime.of(1969, 12, 31, 16, 0), value.getDateTime());
5250
}
5351
}

0 commit comments

Comments
 (0)