-
Notifications
You must be signed in to change notification settings - Fork 65
Description
Summary
The DuckDB JDBC driver has timezone bugs in java.sql.Timestamp handling. Both write and read paths have compensating bugs that make JDBC roundtrips appear correct while silently corrupting the stored data.
For now, I can get around this issue by using LocalDateTime or OffsetDateTime types.
Reproduction
import java.sql.*;
import java.time.*;
import java.util.TimeZone;
public class TimestampBug {
public static void main(String[] args) throws Exception {
TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles")); // UTC-8
try (Connection conn = DriverManager.getConnection("jdbc:duckdb:")) {
// Create timestamp: 2024-01-01 12:00 LA time = 2024-01-01 20:00 UTC
Timestamp original = Timestamp.valueOf("2024-01-01 12:00:00");
System.out.println("Original: " + original + " (epoch ms: " + original.getTime() + ")");
System.out.println("This represents UTC time: " + LocalDateTime.ofInstant(
Instant.ofEpochMilli(original.getTime()), ZoneOffset.UTC));
try (Statement s = conn.createStatement()) {
s.execute("CREATE TABLE test(ts TIMESTAMP)");
}
try (PreparedStatement ps = conn.prepareStatement("INSERT INTO test VALUES (?)")) {
ps.setTimestamp(1, original);
ps.execute();
}
// Check what's actually stored in DuckDB
try (Statement s = conn.createStatement();
ResultSet rs = s.executeQuery("SELECT epoch_us(ts) as micros FROM test")) {
rs.next();
long storedMicros = rs.getLong(1);
long expectedMicros = original.getTime() * 1000;
System.out.println("\nStored epoch micros: " + storedMicros);
System.out.println("Expected epoch micros: " + expectedMicros);
System.out.println("Stored as UTC time: " + LocalDateTime.ofEpochSecond(
storedMicros / 1000000, 0, ZoneOffset.UTC));
System.out.println("\nBUG: Storage is " +
((expectedMicros - storedMicros) / 3600000000L) + " hours off!");
}
}
}
}To run:
javac -cp duckdb_jdbc.jar TimestampBug.java
java -cp duckdb_jdbc.jar:. TimestampBugExpected output
Original: 2024-01-01 12:00:00.0 (epoch ms: 1704139200000)
This represents UTC time: 2024-01-01T20:00
Stored epoch micros: 1704139200000000
Expected epoch micros: 1704139200000000
Stored as UTC time: 2024-01-01T20:00
Actual output (v1.4.3.0)
Original: 2024-01-01 12:00:00.0 (epoch ms: 1704139200000)
This represents UTC time: 2024-01-01T20:00
Stored epoch micros: 1704110400000000
Expected epoch micros: 1704139200000000
Stored as UTC time: 2024-01-01T12:00
BUG: Storage is 8 hours off!
The timestamp is shifted by +8 hours (the UTC offset of America/Los_Angeles applied incorrectly).
Root Cause
The bug is in DuckDBTimestamp.java. DuckDB stores TIMESTAMP as UTC epoch microseconds internally, but the Java conversion code uses timezone-dependent methods:
Write path (DuckDBTimestamp constructor):
public DuckDBTimestamp(Timestamp sqlTimestamp) {
// BUG: toLocalDateTime() returns wall-clock time in system timezone but RefLocalDateTime is a UTC wall-clock time.
this.timeMicros = DuckDBTimestamp.RefLocalDateTime.until(
sqlTimestamp.toLocalDateTime(), ChronoUnit.MICROS);
}The issue is that Timestamp.toLocalDateTime() returns the wall-clock time in the system timezone, but RefLocalDateTime.until() measures microseconds as if it were UTC.
Read path (DuckDBTimestamp.toSqlTimestamp()):
public Timestamp toSqlTimestamp() {
// BUG: Timestamp.valueOf() interprets LocalDateTime in system timezone
return Timestamp.valueOf(this.toLocalDateTime());
}Similarly, toLocalDateTime() returns UTC wall-clock time, but Timestamp.valueOf() interprets it in the system timezone.