Skip to content

Commit 3f5e9fc

Browse files
authored
[python] Normalize nested naive timestamps when writing Avro to object stores and HDFS (#10356)
1 parent a0226d3 commit 3f5e9fc

5 files changed

Lines changed: 78 additions & 33 deletions

File tree

‎paimon-python/pypaimon/common/file_io.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import os
2020
import uuid
2121
from abc import ABC, abstractmethod
22+
from datetime import datetime, timezone
2223
from pathlib import Path
2324
from typing import Dict, List, Optional
2425

@@ -30,6 +31,27 @@
3031
_LOG = logging.getLogger(__name__)
3132

3233

34+
def normalize_naive_datetimes(value):
35+
"""Recursively attach UTC to naive datetimes, including ones nested inside
36+
ROW (dict) and ARRAY/MAP (list/tuple) values.
37+
38+
fastavro converts a naive datetime using the host timezone, which corrupts
39+
the stored instant on a non-UTC host; Paimon timestamps are UTC-based. The
40+
Avro ``write_avro`` paths must normalize nested timestamps, not just
41+
top-level column values, so a naive datetime inside a struct or list is not
42+
written shifted by the host offset.
43+
"""
44+
if isinstance(value, datetime):
45+
if value.tzinfo is None:
46+
return value.replace(tzinfo=timezone.utc)
47+
return value
48+
if isinstance(value, dict):
49+
return {key: normalize_naive_datetimes(val) for key, val in value.items()}
50+
if isinstance(value, (list, tuple)):
51+
return type(value)(normalize_naive_datetimes(val) for val in value)
52+
return value
53+
54+
3355
def supports_pread(stream) -> bool:
3456
"""Check if the stream supports position-based reads (thread-safe I/O)."""
3557
if hasattr(stream, 'read_at'):

‎paimon-python/pypaimon/filesystem/hdfs_native_file_io.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
import pyarrow
2929
import pyarrow.fs as pafs
3030

31-
from pypaimon.common.file_io import FileIO
31+
from pypaimon.common.file_io import FileIO, normalize_naive_datetimes
3232
from pypaimon.common.options import Options
3333
from pypaimon.common.options.config import HdfsOptions, SecurityOptions
3434
from pypaimon.common.uri_reader import UriReaderFactory
@@ -594,9 +594,7 @@ def record_generator():
594594
record = {}
595595
for col in records_dict.keys():
596596
value = records_dict[col][i]
597-
if isinstance(value, datetime) and value.tzinfo is None:
598-
value = value.replace(tzinfo=timezone.utc)
599-
record[col] = value
597+
record[col] = normalize_naive_datetimes(value)
600598
yield record
601599

602600
codec_map = {

‎paimon-python/pypaimon/filesystem/local_file_io.py‎

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,42 +19,20 @@
1919
import os
2020
import shutil
2121
import threading
22-
from datetime import datetime, timezone
2322
from pathlib import Path
2423
from typing import Any, Dict, Optional
2524
from urllib.parse import unquote, urlparse
2625

2726
import pyarrow
2827
import pyarrow.fs as pafs
2928

30-
from pypaimon.common.file_io import FileIO, create_temp_path
29+
from pypaimon.common.file_io import FileIO, create_temp_path, normalize_naive_datetimes
3130
from pypaimon.common.options import Options
3231
from pypaimon.common.uri_reader import UriReaderFactory
3332
from pypaimon.filesystem.local import PaimonLocalFileSystem
3433
from pypaimon.schema.data_types import PyarrowFieldParser
3534

3635

37-
def _normalize_naive_datetimes(value):
38-
"""Recursively attach UTC to naive datetimes, including ones nested inside
39-
ROW (dict) and ARRAY/MAP (list/tuple) values.
40-
41-
fastavro converts a naive datetime using the host timezone, which corrupts
42-
the stored instant on a non-UTC host; Paimon timestamps are UTC-based.
43-
``write_avro`` previously normalized only top-level column values, so a
44-
naive datetime inside a struct or list was written shifted by the host
45-
offset. Normalizing recursively keeps nested timestamps correct.
46-
"""
47-
if isinstance(value, datetime):
48-
if value.tzinfo is None:
49-
return value.replace(tzinfo=timezone.utc)
50-
return value
51-
if isinstance(value, dict):
52-
return {key: _normalize_naive_datetimes(val) for key, val in value.items()}
53-
if isinstance(value, (list, tuple)):
54-
return type(value)(_normalize_naive_datetimes(val) for val in value)
55-
return value
56-
57-
5836
def _file_uri_path(parsed, windows=None) -> str:
5937
"""Decode a file URI path, preserving Windows drives and UNC hosts."""
6038
if windows is None:
@@ -378,7 +356,7 @@ def record_generator():
378356
record = {}
379357
for col in records_dict.keys():
380358
value = records_dict[col][i]
381-
record[col] = _normalize_naive_datetimes(value)
359+
record[col] = normalize_naive_datetimes(value)
382360
yield record
383361

384362
records = record_generator()

‎paimon-python/pypaimon/filesystem/pyarrow_file_io.py‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
import tempfile
2626
import threading
2727
import time
28-
from datetime import datetime, timezone
2928
from itertools import islice
3029
from pathlib import PurePosixPath
3130
from typing import Any, Dict, Iterable, List, Optional
@@ -36,7 +35,7 @@
3635
from packaging.version import parse
3736
from pyarrow._fs import FileSystem
3837

39-
from pypaimon.common.file_io import FileIO, create_temp_path
38+
from pypaimon.common.file_io import FileIO, create_temp_path, normalize_naive_datetimes
4039
from pypaimon.common.options import Options
4140
from pypaimon.common.options.config import OssOptions, S3Options, SecurityOptions
4241
from pypaimon.common.options.options_utils import OptionsUtils
@@ -957,9 +956,7 @@ def record_generator():
957956
record = {}
958957
for col in records_dict.keys():
959958
value = records_dict[col][i]
960-
if isinstance(value, datetime) and value.tzinfo is None:
961-
value = value.replace(tzinfo=timezone.utc)
962-
record[col] = value
959+
record[col] = normalize_naive_datetimes(value)
963960
yield record
964961

965962
records = record_generator()

‎paimon-python/pypaimon/tests/data_types_test.py‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,3 +432,53 @@ def test_avro_nested_timestamp_roundtrips_on_non_utc_host(self):
432432
self.assertEqual(rows[0]["ts"].replace(tzinfo=None), ts)
433433
self.assertEqual(rows[0]["r"]["ts"].replace(tzinfo=None), ts)
434434
self.assertEqual(rows[0]["arr"][0].replace(tzinfo=None), ts)
435+
436+
def test_pyarrow_file_io_avro_normalizes_nested_naive_timestamps(self):
437+
# Same contract as the LocalFileIO case, but for the object-store base
438+
# PyArrowFileIO (OSS/S3/GCS): it previously normalized only top-level
439+
# column values, so a naive TIMESTAMP nested in a ROW/ARRAY written as
440+
# Avro to object storage on a non-UTC host was shifted by the host
441+
# offset. The output stream is mocked to a local file so the real
442+
# write_avro record path runs without cloud credentials.
443+
import datetime
444+
import os
445+
import tempfile
446+
import time
447+
from unittest import mock
448+
449+
import fastavro
450+
451+
from pypaimon.filesystem.pyarrow_file_io import PyArrowFileIO
452+
453+
if not hasattr(time, "tzset"):
454+
self.skipTest("time.tzset is unavailable on this platform")
455+
456+
ts = datetime.datetime(2024, 1, 2, 3, 4, 5)
457+
table = pa.table({
458+
"ts": pa.array([ts], pa.timestamp('s')),
459+
"r": pa.array([{"ts": ts}], pa.struct([("ts", pa.timestamp('s'))])),
460+
"arr": pa.array([[ts]], pa.list_(pa.timestamp('s'))),
461+
})
462+
file_io = PyArrowFileIO.__new__(PyArrowFileIO)
463+
previous_tz = os.environ.get("TZ")
464+
os.environ["TZ"] = "Asia/Shanghai"
465+
time.tzset()
466+
try:
467+
with tempfile.TemporaryDirectory() as d:
468+
path = os.path.join(d, "data.avro")
469+
with mock.patch.object(
470+
PyArrowFileIO, "new_output_stream",
471+
side_effect=lambda p: open(p, "wb")):
472+
file_io.write_avro(path, table)
473+
with open(path, 'rb') as f:
474+
rows = list(fastavro.reader(f))
475+
finally:
476+
if previous_tz is None:
477+
os.environ.pop("TZ", None)
478+
else:
479+
os.environ["TZ"] = previous_tz
480+
time.tzset()
481+
482+
self.assertEqual(rows[0]["ts"].replace(tzinfo=None), ts)
483+
self.assertEqual(rows[0]["r"]["ts"].replace(tzinfo=None), ts)
484+
self.assertEqual(rows[0]["arr"][0].replace(tzinfo=None), ts)

0 commit comments

Comments
 (0)