From 443b9700b52b5eaba6392217758a889f89b96093 Mon Sep 17 00:00:00 2001 From: Marc Galbraith Date: Wed, 5 Aug 2026 06:30:17 +0000 Subject: [PATCH] fix(proto): store far-future DateTime64 without int64 overflow ToDateTime64 and DateTime64.Time convert through t.UnixNano() / an int64 nanosecond count, which overflows just past 2262-04-11 and corrupts any later timestamp on both the write and read paths (e.g. ClickHouse 26.7's 9999-12-31 ceiling for DateTime64(3) was written back as ~1900). Compute the tick count at the column's own scale instead, so each precision reaches exactly ClickHouse's DateTime64(precision) range. Nanosecond precision still caps near 2262 where an int64 count of nanoseconds runs out, matching ClickHouse itself; the result is identical for all in-range values. Add far-future round-trip tests covering the 2262 boundary and the 9999 ceiling across precisions. Co-Authored-By: Claude Opus 4.8 --- proto/datetime64.go | 16 +++++++++++++--- proto/datetime64_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) 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()) + }) +}