This is an automated email from the ASF dual-hosted git repository.
cloud-fan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new 07144c0bfcb2 [SPARK-58237][SQL] Fix BIN BY multi-day civil-time bin
boundaries to use the absolute grid
07144c0bfcb2 is described below
commit 07144c0bfcb2a80b77a93631121ab31a743675de
Author: Nikolina Vraneš <[email protected]>
AuthorDate: Thu Jul 23 21:54:44 2026 +0800
[SPARK-58237][SQL] Fix BIN BY multi-day civil-time bin boundaries to use
the absolute grid
### What changes were proposed in this pull request?
`BinByExec` computed multi-day civil-time bin boundaries by forward-walking
(each bin's end reused as the next bin's start) instead of placing every
boundary on the absolute grid `bin_start(k) = timestampAddDayTime(origin, k *
width)`, the grid `time_bucket` uses. This PR switches it to the absolute grid:
- `DateTimeUtils.timeBucketDTInterval` is factored into
`timeBucketFromIndexDTInterval` (the grid boundary of a bucket index) and
`timeBucketFromTimestampDTInterval` (the `(index, startBoundary)` of the bucket
containing a timestamp), with `timeBucketDTInterval` now delegating to the
latter. Behavior-preserving for `time_bucket`.
- `BinByExec` advances the bucket index and reads each boundary off the
grid via `timeBucketFromIndexDTInterval`, instead of walking with
`timestampAddDayTime`.
### Why are the changes needed?
`timestampAddDayTime` adds the width's day part as calendar days
(DST-absorbing: a day is 23h or 25h across a transition) and the sub-day
remainder as real microseconds, so it is not associative across a DST/offset
transition:
```
addDayTime(addDayTime(origin, k*W), W) != addDayTime(origin, (k+1)*W)
```
So for a width with a sub-day part (e.g. `INTERVAL '36' HOUR`) spanning a
DST transition, the walk drifts off the grid by the transition hour from the
second bin onward. The same calendar bin then gets a different
`bin_start`/`bin_end` depending on where a row's range began, fragmenting
`GROUP BY bin_start`. Whole-day widths, sub-day widths, and `TIMESTAMP_NTZ` are
unaffected.
### Does this PR introduce _any_ user-facing change?
No, this is a bug fix for the BIN BY operator which is gated off by
default. Second-and-later bin boundaries of a range spanning a DST transition,
under a non-whole-day width in a non-UTC session, now land on the absolute grid
instead of drifting by the transition hour.
### How was this patch tested?
- `DateTimeUtilsSuite`: existing `timeBucketDTInterval` cases unchanged
(regression guard for the factoring), plus a round-trip test that the
containing bucket agrees with `timeBucketDTInterval` and encloses the timestamp.
- `BinBySuite`: new tests for multi-bin 36h grid boundaries across a
spring-forward, boundary agreement between rows reaching a bin from different
starts, a `Pacific/Apia` whole-day skip, and a zero-length range. Each fails on
the old walk and passes after the fix.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Anthropic)
Closes #57401 from vranes/bin-by-ltz-absolute-grid.
Authored-by: Nikolina Vraneš <[email protected]>
Signed-off-by: Wenchen Fan <[email protected]>
---
.../spark/sql/catalyst/util/DateTimeUtils.scala | 93 ++++++++++++------
.../sql/catalyst/util/DateTimeUtilsSuite.scala | 48 ++++++++++
.../org/apache/spark/sql/execution/BinByExec.scala | 21 +++--
.../scala/org/apache/spark/sql/BinBySuite.scala | 105 +++++++++++++++++++++
4 files changed, 230 insertions(+), 37 deletions(-)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/DateTimeUtils.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/DateTimeUtils.scala
index 2fe2e5f4c988..24aabdc988fb 100644
---
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/DateTimeUtils.scala
+++
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/util/DateTimeUtils.scala
@@ -1266,47 +1266,82 @@ object DateTimeUtils extends SparkDateTimeUtils {
}
/**
- * DayTimeInterval bucketing: bucket k starts at
- * `timestampAddDayTime(originMicros, k * bucketMicros, zoneId)`, matching
the instant that
- * `originMicros + INTERVAL '<k * bucketSize>'` would produce. For sub-day
buckets the
- * calendar-day component is zero, so the result is pure UTC-microsecond
floor division
- * and `zoneId` has no effect.
+ * Start boundary of DayTimeInterval bucket index `k`, on the fixed grid
anchored at
+ * `originMicros`. Sub-day buckets use pure UTC arithmetic; multi-day
buckets add the calendar-day
+ * part in `zoneId`.
*
* `bucketMicros` must be positive; `TimeBucket.checkInputDataTypes`
enforces this at
* analysis time.
*
* @param bucketMicros bucket size in microseconds.
- * @param tsMicros timestamp to bucket, in microseconds since the epoch
(UTC).
+ * @param k bucket index (may be negative).
* @param originMicros grid alignment anchor, in microseconds since the
epoch (UTC).
* @param zoneId zone in which calendar-day arithmetic is performed.
*/
- def timeBucketDTInterval(
- bucketMicros: Long, tsMicros: Long, originMicros: Long, zoneId: ZoneId):
Long = {
- val bucketDays = bucketMicros / MICROS_PER_DAY
-
- val diff = MathUtils.subtractExact(tsMicros, originMicros)
- var k = MathUtils.floorDiv(diff, bucketMicros)
+ def timeBucketFromIndexDTInterval(
+ bucketMicros: Long, k: Long, originMicros: Long, zoneId: ZoneId): Long =
{
+ val bucketOffset = MathUtils.multiplyExact(k, bucketMicros)
- if (bucketDays == 0) {
- val bucketOffset = MathUtils.multiplyExact(k, bucketMicros)
- MathUtils.addExact(originMicros, bucketOffset)
+ if (bucketMicros / MICROS_PER_DAY != 0) {
+ timestampAddDayTime(originMicros, bucketOffset, zoneId)
} else {
- // bucketMicros >= MICROS_PER_DAY, so DST offset shifts (a few hours at
most) can
- // move candidate(k) within one bucket of `origin + k*bucketMicros` but
no further.
- // One +/-1 step recovers the correct k.
- def candidate(kk: Long): Long =
- timestampAddDayTime(originMicros, MathUtils.multiplyExact(kk,
bucketMicros), zoneId)
-
- var c = candidate(k)
- if (c > tsMicros) {
- k -= 1
- c = candidate(k)
- } else {
- val cNext = candidate(MathUtils.addExact(k, 1L))
- if (cNext <= tsMicros) c = cNext
+ // Sub-day bucket stays zone-independent even when k*bucketMicros spans
days.
+ MathUtils.addExact(originMicros, bucketOffset)
+ }
+ }
+
+ /**
+ * The DayTimeInterval bucket containing `tsMicros`, as `(index,
startBoundary)`. The index is
+ * returned so callers walking to the next bucket need not search again.
+ *
+ * `bucketMicros` must be positive; `TimeBucket.checkInputDataTypes`
enforces this at
+ * analysis time.
+ *
+ * @param bucketMicros bucket size in microseconds.
+ * @param tsMicros timestamp to bucket, in microseconds since the epoch
(UTC).
+ * @param originMicros grid alignment anchor, in microseconds since the
epoch (UTC).
+ * @param zoneId zone in which calendar-day arithmetic is performed.
+ */
+ def timeBucketFromTimestampDTInterval(
+ bucketMicros: Long, tsMicros: Long, originMicros: Long, zoneId: ZoneId):
(Long, Long) = {
+ val diff = MathUtils.subtractExact(tsMicros, originMicros)
+ val k = MathUtils.floorDiv(diff, bucketMicros)
+
+ def boundary(kk: Long): Long =
+ timeBucketFromIndexDTInterval(bucketMicros, kk, originMicros, zoneId)
+
+ if (bucketMicros / MICROS_PER_DAY != 0) {
+ // Multi-day: calendar-day arithmetic makes boundaries drift off the
linear grid as offset
+ // shifts accumulate between origin and ts. A DST shift (e.g.
Pacific/Los_Angeles) is 1h, so
+ // the estimate is off by at most one; a date-line shift (e.g.
Pacific/Apia 2011) can reach a
+ // full bucket, so it can be off by more than one. Step the index until
the bucket contains
+ // ts: the first loop walks down while the start is past ts, the second
walks up while the
+ // next start is not, landing on boundary(k) <= ts < boundary(k + 1).
+ var adjusted = k
+ while (boundary(adjusted) > tsMicros) {
+ adjusted = MathUtils.subtractExact(adjusted, 1L)
}
- c
+ while (boundary(MathUtils.addExact(adjusted, 1L)) <= tsMicros) {
+ adjusted = MathUtils.addExact(adjusted, 1L)
+ }
+ return (adjusted, boundary(adjusted))
}
+ // Sub-day: pure UTC arithmetic, the linear estimate is exact.
+ (k, boundary(k))
+ }
+
+ /**
+ * DayTimeInterval bucketing: the start boundary of the bucket containing
`tsMicros`.
+ *
+ * @param bucketMicros bucket size in microseconds.
+ * @param tsMicros timestamp to bucket, in microseconds since the epoch
(UTC).
+ * @param originMicros grid alignment anchor, in microseconds since the
epoch (UTC).
+ * @param zoneId zone in which calendar-day arithmetic is performed.
+ */
+ def timeBucketDTInterval(
+ bucketMicros: Long, tsMicros: Long, originMicros: Long, zoneId: ZoneId):
Long = {
+ val (_, start) = timeBucketFromTimestampDTInterval(bucketMicros, tsMicros,
originMicros, zoneId)
+ start
}
/**
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/util/DateTimeUtilsSuite.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/util/DateTimeUtilsSuite.scala
index d07265c26e97..196d32e1a4f9 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/util/DateTimeUtilsSuite.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/util/DateTimeUtilsSuite.scala
@@ -2051,6 +2051,54 @@ class DateTimeUtilsSuite extends SparkFunSuite with
Matchers with SQLHelper {
}
}
+ test("timeBucketFromIndexDTInterval / timeBucketFromTimestampDTInterval") {
+ val utc = ZoneOffset.UTC
+ val la = DateTimeUtils.getZoneId("America/Los_Angeles")
+
+ // The containing bucket agrees with timeBucketDTInterval and encloses ts:
+ // boundary(k) == start <= ts < boundary(k + 1).
+ def checkContaining(bucket: Long, ts: Long, origin: Long, zone: ZoneId):
Unit = {
+ val (k, start) = timeBucketFromTimestampDTInterval(bucket, ts, origin,
zone)
+ assert(start === timeBucketFromIndexDTInterval(bucket, k, origin, zone))
+ assert(start === timeBucketDTInterval(bucket, ts, origin, zone))
+ assert(start <= ts)
+ assert(ts < timeBucketFromIndexDTInterval(bucket, k + 1, origin, zone))
+ }
+ // Sub-day, sub-day with custom origin, and multi-day (36h) across both LA
DST transitions.
+ checkContaining(15 * MICROS_PER_MINUTE, date(2024, 1, 1, 11, 27, 0), 0L,
la)
+ checkContaining(MICROS_PER_HOUR, date(2024, 1, 1, 11, 27, 0), date(1970,
1, 1, 0, 5, 0), utc)
+ val laOrigin = date(2024, 3, 9, 8, 0, 0) // 08:00 UTC = 2024-03-09 00:00
PST
+ checkContaining(36 * MICROS_PER_HOUR, date(2024, 3, 12, 9, 0, 0),
laOrigin, la)
+ checkContaining(MICROS_PER_DAY, date(2024, 11, 4, 2, 0, 0), date(2024, 11,
1, 7, 0, 0), la)
+
+ // Pacific/Apia's 2011 date-line shift throws the 1-day estimate off by
one bucket (epoch
+ // origin) or two (1900 origin), so the search must step more than once.
+ val apiaZone = DateTimeUtils.getZoneId("Pacific/Apia")
+ checkContaining(MICROS_PER_DAY, date(2012, 1, 2, 0, 0, 0, 0, apiaZone),
+ date(1970, 1, 1), apiaZone)
+ checkContaining(MICROS_PER_DAY, date(2012, 1, 2, 0, 0, 0, 0, apiaZone),
+ date(1900, 1, 1, 0, 0, 0, 0, apiaZone), apiaZone)
+
+ // Boundaries land on the grid across the spring-forward, not 1h off as a
walk would drift.
+ val (idx, _) = timeBucketFromTimestampDTInterval(36 * MICROS_PER_HOUR,
+ date(2024, 3, 12, 9, 0, 0), laOrigin, la)
+ assert(timeBucketFromIndexDTInterval(36 * MICROS_PER_HOUR, idx, laOrigin,
la)
+ === date(2024, 3, 12, 7, 0, 0)) // 2024-03-12 00:00 PDT, the grid
(not walked) value
+ assert(timeBucketFromIndexDTInterval(36 * MICROS_PER_HOUR, idx + 1,
laOrigin, la)
+ === date(2024, 3, 13, 19, 0, 0)) // 2024-03-13 12:00 PDT
+
+ // Whole-day zone skip: Apia skipped 2011-12-30, so two consecutive 1-day
grid boundaries
+ // collapse to the same instant (civil "2011-12-30 00:00" and "2011-12-31
00:00" are identical).
+ val apia = DateTimeUtils.getZoneId("Pacific/Apia")
+ val apiaOrigin = date(2011, 12, 25, 10, 0, 0) // 2011-12-25 00:00 Apia
(UTC-11 -> 10:00 UTC)
+ // This ts lands in the bucket whose start collapsed onto the previous
one's, so
+ // boundary(k - 1) == boundary(k).
+ val (collapsed, _) = timeBucketFromTimestampDTInterval(MICROS_PER_DAY,
+ date(2011, 12, 30, 12, 0, 0), apiaOrigin, apia)
+ assert(timeBucketFromIndexDTInterval(MICROS_PER_DAY, collapsed - 1,
apiaOrigin, apia)
+ === timeBucketFromIndexDTInterval(MICROS_PER_DAY, collapsed, apiaOrigin,
apia))
+ }
+
test("timeBucketYMInterval") {
val utc = ZoneOffset.UTC
// 1-month bucket default origin
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/BinByExec.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/BinByExec.scala
index 22898f6dfdbd..d44092ac2c9e 100644
--- a/sql/core/src/main/scala/org/apache/spark/sql/execution/BinByExec.scala
+++ b/sql/core/src/main/scala/org/apache/spark/sql/execution/BinByExec.scala
@@ -33,9 +33,9 @@ import org.apache.spark.sql.types.DoubleType
* bin overlapping `[rangeStart, rangeEnd)`, scaling the DISTRIBUTE UNIFORM
columns by the overlap
* fraction and appending `bin_start`, `bin_end`, `bin_distribute_ratio`.
*
- * Bin boundaries reuse [[DateTimeUtils.timeBucketDTInterval]] /
- * [[DateTimeUtils.timestampAddDayTime]], matching `time_bucket`: sub-day
widths use UTC microsecond
- * arithmetic, multi-day widths use civil-time arithmetic in the session zone
(UTC for
+ * Bin boundaries lie on the grid `bin_start(k) =
+ * [[DateTimeUtils.timeBucketFromIndexDTInterval]](width, k, origin, zone)`.
Sub-day widths use UTC
+ * microsecond arithmetic, multi-day widths use civil-time arithmetic in the
session zone (UTC for
* TIMESTAMP_NTZ). DISTRIBUTE UNIFORM columns are FLOAT or DOUBLE only and
scale by multiplication.
*/
case class BinByExec(
@@ -107,8 +107,9 @@ case class BinByExec(
if (rs > re) {
throw
QueryExecutionErrors.binByInvalidRangeError(fmt.format(rs), fmt.format(re))
} else if (rs == re) {
- val binStart = DateTimeUtils.timeBucketDTInterval(width, rs,
origin, zone)
- val binEnd = DateTimeUtils.timestampAddDayTime(binStart, width,
zone)
+ val (k, binStart) =
DateTimeUtils.timeBucketFromTimestampDTInterval(width, rs, origin,
+ zone)
+ val binEnd = DateTimeUtils.timeBucketFromIndexDTInterval(width,
k + 1, origin, zone)
appended.update(0, binStart)
appended.update(1, binEnd)
appended.update(2, 1.0d)
@@ -116,17 +117,21 @@ case class BinByExec(
} else {
val total = Math.subtractExact(re, rs)
new Iterator[InternalRow] {
- private var curStart =
- DateTimeUtils.timeBucketDTInterval(width, rs, origin, zone)
+ // Advance the bucket index and read each boundary off the
grid, rather than
+ // walking one bin end to the next start.
+ private var (k, curStart) =
+ DateTimeUtils.timeBucketFromTimestampDTInterval(width, rs,
origin, zone)
override def hasNext: Boolean = curStart < re
override def next(): InternalRow = {
- val curEnd = DateTimeUtils.timestampAddDayTime(curStart,
width, zone)
+ val curEnd =
+ DateTimeUtils.timeBucketFromIndexDTInterval(width, k + 1,
origin, zone)
val overlap = math.min(re, curEnd) - math.max(rs, curStart)
appended.update(0, curStart)
appended.update(1, curEnd)
appended.update(2, overlap.toDouble / total.toDouble)
+ k += 1
curStart = curEnd
proj(joined.withRight(appended))
}
diff --git a/sql/core/src/test/scala/org/apache/spark/sql/BinBySuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/BinBySuite.scala
index db01411c75f7..7ec687e3d953 100644
--- a/sql/core/src/test/scala/org/apache/spark/sql/BinBySuite.scala
+++ b/sql/core/src/test/scala/org/apache/spark/sql/BinBySuite.scala
@@ -259,6 +259,111 @@ class BinBySuite extends QueryTest with
SharedSparkSession {
}
}
+ test("BIN BY places multi-bin civil-time boundaries on the absolute grid
across DST") {
+ val la = ZoneId.of("America/Los_Angeles")
+ withSQLConf(
+ SQLConf.BIN_BY_ENABLED.key -> "true",
+ SQLConf.SESSION_LOCAL_TIMEZONE.key -> "America/Los_Angeles") {
+ // Boundaries of a 36h width must lie on the grid bin_start(k) =
ALIGN_TO + k * 36h. Across
+ // the 2024-03-10 spring-forward, a forward walk would drift 1h off the
grid from bin 1 on.
+ // This range spans four bins.
+ val df = spark.sql(
+ """SELECT bin_start, bin_end, bin_distribute_ratio, value
+ |FROM VALUES
+ | (TIMESTAMP '2024-03-09 06:00:00', TIMESTAMP '2024-03-13
18:00:00', 100.0D)
+ | AS metrics(ts_start, ts_end, value)
+ |BIN BY (
+ | RANGE ts_start TO ts_end BIN WIDTH INTERVAL '36' HOUR
+ | ALIGN TO TIMESTAMP '2024-03-09 00:00:00' DISTRIBUTE UNIFORM
(value))
+ |ORDER BY bin_start""".stripMargin)
+ val h = 3600L * 1000000L
+ val total = 107 * h // 30h + 35h + 36h + 6h
+ // Bin 2 starts at 2024-03-12 00:00 LA (07:00 UTC); a forward walk would
report 08:00 UTC.
+ checkAnswer(df, Seq(
+ Row(tsAt("2024-03-09 00:00:00", la), tsAt("2024-03-10 13:00:00", la),
+ ratio(30 * h, total), 100.0 * ratio(30 * h, total)),
+ Row(tsAt("2024-03-10 13:00:00", la), tsAt("2024-03-12 00:00:00", la),
+ ratio(35 * h, total), 100.0 * ratio(35 * h, total)),
+ Row(tsAt("2024-03-12 00:00:00", la), tsAt("2024-03-13 12:00:00", la),
+ ratio(36 * h, total), 100.0 * ratio(36 * h, total)),
+ Row(tsAt("2024-03-13 12:00:00", la), tsAt("2024-03-15 00:00:00", la),
+ ratio(6 * h, total), 100.0 * ratio(6 * h, total))))
+ }
+ }
+
+ test("BIN BY reports the same civil-time bin boundary regardless of where a
row starts") {
+ val la = ZoneId.of("America/Los_Angeles")
+ withSQLConf(
+ SQLConf.BIN_BY_ENABLED.key -> "true",
+ SQLConf.SESSION_LOCAL_TIMEZONE.key -> "America/Los_Angeles") {
+ // Both rows span the bin starting 2024-03-12 00:00 LA: one walks into
it from bin 0, the
+ // other starts inside it. Both must report the same bin_start; a
forward walk would report it
+ // an hour apart, splitting GROUP BY bin_start.
+ val df = spark.sql(
+ """SELECT ts_start, bin_start, bin_end
+ |FROM VALUES
+ | (TIMESTAMP '2024-03-09 06:00:00', TIMESTAMP '2024-03-13
06:00:00', 1.0D),
+ | (TIMESTAMP '2024-03-12 03:00:00', TIMESTAMP '2024-03-13
06:00:00', 1.0D)
+ | AS metrics(ts_start, ts_end, value)
+ |BIN BY (
+ | RANGE ts_start TO ts_end BIN WIDTH INTERVAL '36' HOUR
+ | ALIGN TO TIMESTAMP '2024-03-09 00:00:00' DISTRIBUTE UNIFORM
(value))
+ |WHERE bin_start = TIMESTAMP '2024-03-12 00:00:00'
+ |ORDER BY ts_start""".stripMargin)
+ checkAnswer(df, Seq(
+ Row(tsAt("2024-03-09 06:00:00", la), tsAt("2024-03-12 00:00:00", la),
+ tsAt("2024-03-13 12:00:00", la)),
+ Row(tsAt("2024-03-12 03:00:00", la), tsAt("2024-03-12 00:00:00", la),
+ tsAt("2024-03-13 12:00:00", la))))
+ }
+ }
+
+ test("BIN BY emits a zero-width bin at a whole-day zone skip") {
+ withSQLConf(
+ SQLConf.BIN_BY_ENABLED.key -> "true",
+ SQLConf.SESSION_LOCAL_TIMEZONE.key -> "Pacific/Apia") {
+ // Apia skipped all of 2011-12-30 (UTC-11 -> UTC+13), so two grid
boundaries land on the same
+ // instant, emitting a zero-width ratio-0 bin (bin_start == bin_end).
The real bins still tile
+ // the range, so ratios sum to 1.0. Compared as UTC instants: a civil
label on the skipped day
+ // is ambiguous.
+ val utc = ZoneOffset.UTC
+ val df = spark.sql(
+ """SELECT bin_start, bin_end, bin_distribute_ratio, value
+ |FROM VALUES
+ | (TIMESTAMP '2011-12-29 12:00:00', TIMESTAMP '2011-12-31
12:00:00', 100.0D)
+ | AS metrics(ts_start, ts_end, value)
+ |BIN BY (
+ | RANGE ts_start TO ts_end BIN WIDTH INTERVAL '1' DAY
+ | ALIGN TO TIMESTAMP '2011-12-25 00:00:00' DISTRIBUTE UNIFORM
(value))
+ |ORDER BY bin_start, bin_end""".stripMargin)
+ checkAnswer(df, Seq(
+ Row(tsAt("2011-12-29 10:00:00", utc), tsAt("2011-12-30 10:00:00",
utc), 0.5, 50.0),
+ Row(tsAt("2011-12-30 10:00:00", utc), tsAt("2011-12-30 10:00:00",
utc), 0.0, 0.0),
+ Row(tsAt("2011-12-30 10:00:00", utc), tsAt("2011-12-31 10:00:00",
utc), 0.5, 50.0)))
+ }
+ }
+
+ test("BIN BY places the zero-length range bin on the grid for a
non-whole-day width") {
+ val la = ZoneId.of("America/Los_Angeles")
+ withSQLConf(
+ SQLConf.BIN_BY_ENABLED.key -> "true",
+ SQLConf.SESSION_LOCAL_TIMEZONE.key -> "America/Los_Angeles") {
+ // A zero-length range emits one ratio-1.0 row for its bin. Its bin's
36h step crosses the
+ // spring-forward, so bin_end must be the grid boundary (2024-03-12
00:00), not the walked
+ // bin_start + 36h (2024-03-12 01:00).
+ val df = spark.sql(
+ """SELECT bin_start, bin_end, bin_distribute_ratio, value
+ |FROM VALUES
+ | (TIMESTAMP '2024-03-11 12:00:00', TIMESTAMP '2024-03-11
12:00:00', 100.0D)
+ | AS metrics(ts_start, ts_end, value)
+ |BIN BY (
+ | RANGE ts_start TO ts_end BIN WIDTH INTERVAL '36' HOUR
+ | ALIGN TO TIMESTAMP '2024-03-09 00:00:00' DISTRIBUTE UNIFORM
(value))""".stripMargin)
+ checkAnswer(df, Seq(
+ Row(tsAt("2024-03-10 13:00:00", la), tsAt("2024-03-12 00:00:00", la),
1.0, 100.0)))
+ }
+ }
+
test("BIN BY executes on NTZ inputs with the epoch default origin") {
withSQLConf(SQLConf.BIN_BY_ENABLED.key -> "true") {
val df = spark.sql(
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]