From 6ca07285303485a4ecac8064beb329a21d4a0512 Mon Sep 17 00:00:00 2001
From: Timur Safin <tsafin@tarantool.org>
Date: Wed, 21 Sep 2022 17:08:10 +0300
Subject: [PATCH] datetime: fix interval arithmetic for DST

We did not take into consideration the fact that
as result of date/time arithmetic we could get
in a different timezone, if DST boundary has been
crossed during operation.

```
tarantool> datetime.new{year=2008, month=1, day=1,
			tz='Europe/Moscow'} +
	   datetime.interval.new{month=6}
---
- 2008-07-01T01:00:00 Europe/Moscow
...
```

Now we resolve tzoffset at the end of operation if
tzindex is not 0.

Fixes #7700

NO_DOC=bugfix
---
 .../gh-7700-interval-arithmetic-across-dst.md | 19 +++++++++
 src/lib/core/datetime.c                       | 26 ++++++++----
 test/app-tap/datetime.test.lua                | 41 ++++++++++++++++++-
 3 files changed, 75 insertions(+), 11 deletions(-)
 create mode 100644 changelogs/unreleased/gh-7700-interval-arithmetic-across-dst.md

diff --git a/changelogs/unreleased/gh-7700-interval-arithmetic-across-dst.md b/changelogs/unreleased/gh-7700-interval-arithmetic-across-dst.md
new file mode 100644
index 0000000000..05b1a0d0d0
--- /dev/null
+++ b/changelogs/unreleased/gh-7700-interval-arithmetic-across-dst.md
@@ -0,0 +1,19 @@
+## bugfix/datetime
+
+* Fixed interval arithmetic for boundaries crossing DST (gh-7700).
+
+  We did not take into consideration the fact that
+  as result of date/time arithmetic we could get
+  in a different timezone, if DST boundary has been
+  crossed during operation.
+
+  ```
+  tarantool> datetime.new{year=2008, month=1, day=1,
+                          tz='Europe/Moscow'} +
+             datetime.interval.new{month=6}
+  ---
+  - 2008-07-01T01:00:00 Europe/Moscow
+  ...
+  ```
+  Now we resolve tzoffset at the end of operation if
+  tzindex is not 0.
diff --git a/src/lib/core/datetime.c b/src/lib/core/datetime.c
index ab4f5ce6e5..e1e85457a9 100644
--- a/src/lib/core/datetime.c
+++ b/src/lib/core/datetime.c
@@ -49,18 +49,19 @@ local_secs(const struct datetime *date)
 
 /**
  * Resolve tzindex encoded timezone from @sa date using Olson facilities.
- * @param[in] date decode input datetime value.
+ * @param[in] epoch decode input epoch time (in seconds).
+ * @param[in] tzindex use timezone index for decode.
  * @param[out] gmtoff return resolved timezone offset (in seconds).
  * @param[out] isdst return resolved daylight saving time status for the zone.
  */
 static inline bool
-datetime_timezone_lookup(const struct datetime *date, long *gmtoff, int *isdst)
+epoch_timezone_lookup(int64_t epoch, int16_t tzindex, long *gmtoff, int *isdst)
 {
-	if (date->tzindex == 0)
+	if (tzindex == 0)
 		return false;
 
-	struct tnt_tm tm = {.tm_epoch = date->epoch};
-	if (!timezone_tzindex_lookup(date->tzindex, &tm))
+	struct tnt_tm tm = {.tm_epoch = epoch};
+	if (!timezone_tzindex_lookup(tzindex, &tm))
 		return false;
 
 	*gmtoff = tm.tm_gmtoff;
@@ -75,7 +76,8 @@ datetime_isdst(const struct datetime *date)
 	int isdst = 0;
 	long gmtoff = 0;
 
-	return datetime_timezone_lookup(date, &gmtoff, &isdst) && (isdst != 0);
+	epoch_timezone_lookup(date->epoch, date->tzindex, &gmtoff, &isdst);
+	return isdst != 0;
 }
 
 long
@@ -84,8 +86,7 @@ datetime_gmtoff(const struct datetime *date)
 	int isdst = 0;
 	long gmtoff = date->tzoffset * 60;
 
-	datetime_timezone_lookup(date, &gmtoff, &isdst);
-
+	epoch_timezone_lookup(date->epoch, date->tzindex, &gmtoff, &isdst);
 	return gmtoff;
 }
 
@@ -704,6 +705,7 @@ datetime_increment_by(struct datetime *self, int direction,
 	int64_t dt = local_dt(secs);
 	int nsec = self->nsec;
 	int offset = self->tzoffset;
+	int tzindex = self->tzindex;
 
 	bool is_ymd_updated = false;
 	int64_t years = ival->year;
@@ -789,9 +791,15 @@ datetime_increment_by(struct datetime *self, int direction,
 	if (rc != 0)
 		return rc;
 
+	if (tzindex != 0) {
+		int isdst = 0;
+		long gmtoff = offset * 60;
+		epoch_timezone_lookup(secs, tzindex, &gmtoff, &isdst);
+		offset = gmtoff / 60;
+	}
 	self->epoch = utc_secs(secs, offset);
 	self->nsec = nsec;
-	self->tzoffset = datetime_gmtoff(self) / 60;
+	self->tzoffset = offset;
 	return 0;
 }
 
diff --git a/test/app-tap/datetime.test.lua b/test/app-tap/datetime.test.lua
index a7089fc9e7..b08b0dd26a 100755
--- a/test/app-tap/datetime.test.lua
+++ b/test/app-tap/datetime.test.lua
@@ -1395,11 +1395,12 @@ Matrix of addition operands eligibility and their result type
 | table           |          |          |          |
 ]]
 test:test("Matrix of allowed time and interval additions", function(test)
-    test:plan(67)
+    test:plan(79)
 
     -- check arithmetic with leap dates
     local T1970 = date.new{year = 1970, month = 1, day = 1}
-    local T2000 = date.new{year = 2000, month = 1, day = 1}
+    local T2000 = date.new{year = 2000, month = 1, day = 1,
+                           tz = 'Europe/Moscow'}
 
     local I1 = date.interval.new{day = 1}
     local M2 = date.interval.new{month = 2}
@@ -1462,6 +1463,42 @@ test:test("Matrix of allowed time and interval additions", function(test)
     test:is(tostring(Y1 + T1970), "1971-01-01T00:00:00Z", "value: Y + T")
     test:is(tostring(Y5 + Y1), "+6 years", "Y + Y")
 
+    -- Check winter/DST sensitive operations with intervals.
+    -- We use 2000 year here, because then Moscow still were
+    -- switching between winter and summer time.
+    local msk_offset = 180 -- expected winter time offset
+    local msd_offset = 240 -- expected daylight saving time offset
+
+    local res = T2000 + I1
+    test:is(tostring(res), "2000-01-02T00:00:00 Europe/Moscow",
+        "value: 2000 + I")
+    test:is(res.tzoffset, msk_offset, "2000-01-02T00:00:00 - winter")
+
+    res = T2000 + i1
+    test:is(tostring(res), "2000-01-02T00:00:00 Europe/Moscow",
+        "value: 2000 + i")
+    test:is(res.tzoffset, msk_offset, "2000-01-02T00:00:00 - winter")
+
+    res = T2000 + M2
+    test:is(tostring(res), "2000-03-01T00:00:00 Europe/Moscow",
+        "value: 2000 + 2M")
+    test:is(res.tzoffset, msk_offset, "2000-03-01T00:00:00 - winter")
+
+    res = T2000 + M2 + M2 + M2
+    test:is(tostring(res), "2000-07-01T00:00:00 Europe/Moscow",
+        "value: 2000 + 6M")
+    test:is(res.tzoffset, msd_offset, "2000-07-01T00:00:00 - summer")
+
+    res = T2000 + m2
+    test:is(tostring(res), "2000-03-01T00:00:00 Europe/Moscow",
+        "value: 2000 + 2m")
+    test:is(res.tzoffset, msk_offset, "2000-03-01T00:00:00 - winter")
+
+    res = T2000 + m2 + M2 + m2
+    test:is(tostring(res), "2000-07-01T00:00:00 Europe/Moscow",
+        "value: 2000 + 6m")
+    test:is(res.tzoffset, msd_offset, "2000-07-01T00:00:00 - summer")
+
     assert_raises_like(test, expected_interval_but,
                        function() return T1970 + 123 end)
     assert_raises_like(test, expected_interval_but,
-- 
GitLab