diff --git a/proto/datetime64.go b/proto/datetime64.go index 6527eaef..0f54f880 100644 --- a/proto/datetime64.go +++ b/proto/datetime64.go @@ -52,11 +52,21 @@ func ToDateTime64(t time.Time, p Precision) DateTime64 { if t.IsZero() { return 0 } - return DateTime64(t.UnixNano() / p.Scale()) + // Compute the tick count at the column's own scale rather than via t.UnixNano(): the int64 + // nanosecond count overflows just past 2262-04-11, so far-future values (e.g. ClickHouse's + // 9999-12-31 ceiling for DateTime64(3)) would otherwise wrap to a garbage tick on the way in. + // secScale is ticks-per-second (10^precision); the result is the same int64 tick count ClickHouse + // stores, so each precision reaches exactly ClickHouse's DateTime64(precision) range. Nanosecond + // precision still tops out near 2262-04-11, where an int64 count of nanoseconds runs out — there + // this reduces to the old UnixNano computation. + secScale := int64(1e9) / p.Scale() + return DateTime64(t.Unix()*secScale + int64(t.Nanosecond())/p.Scale()) } // Time returns DateTime64 as time.Time. func (d DateTime64) Time(p Precision) time.Time { - nsec := int64(d) * p.Scale() - return time.Unix(nsec/1e9, nsec%1e9) + // Split the tick count into whole seconds and sub-second ticks before scaling up to + // nanoseconds, so a far-future value never forms an int64-overflowing nanosecond intermediate. + secScale := int64(1e9) / p.Scale() + return time.Unix(int64(d)/secScale, (int64(d)%secScale)*p.Scale()) } diff --git a/proto/datetime64_test.go b/proto/datetime64_test.go index 322b0615..9e292f60 100644 --- a/proto/datetime64_test.go +++ b/proto/datetime64_test.go @@ -49,3 +49,34 @@ func TestDateTime64_Time(t *testing.T) { assert.Equal(t, time.Nanosecond, PrecisionNano.Duration(), "ns") }) } + +// TestDateTime64_FarFuture guards against the t.UnixNano() overflow: int64 nanoseconds wrap just +// past 2262-04-11, which corrupted any later timestamp on both the write and read paths. For +// precisions coarser than a nanosecond the int64 tick count spans the full DateTime64 range, so +// values such as ClickHouse's 9999-12-31 ceiling must survive a round-trip. +func TestDateTime64_FarFuture(t *testing.T) { + values := []time.Time{ + time.Date(2262, 1, 1, 0, 0, 0, 0, time.UTC), // last value the old UnixNano path got right + time.Date(2263, 1, 1, 0, 0, 0, 0, time.UTC), // first value it corrupted + time.Date(2999, 12, 31, 23, 59, 59, 0, time.UTC), + time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC), // near ClickHouse's DateTime64 ceiling + } + // Precisions 8 and 9 are excluded: their int64 tick count tops out near years ~4900 and ~2262 + // respectively (the same limits ClickHouse's DateTime64(8)/(9) carry), so they cannot represent + // these values by construction. + for _, p := range []Precision{PrecisionSecond, 1, PrecisionMilli, PrecisionMicro, 7} { + t.Run(p.Duration().String(), func(t *testing.T) { + for _, v := range values { + got := ToDateTime64(v, p).Time(p) + assert.Truef(t, got.Equal(v), "precision %d: %s round-tripped to %s", p, v, got) + } + }) + } + + // Sub-second precision survives too: 9999-12-31 23:59:59.999 in DateTime64(3). + t.Run("millisecond_fraction", func(t *testing.T) { + v := time.Date(9999, 12, 31, 23, 59, 59, 999_000_000, time.UTC) + got := ToDateTime64(v, PrecisionMilli).Time(PrecisionMilli) + assert.True(t, got.Equal(v), got.String()) + }) +}