floraison / fugit

time tools (cron, parsing, durations, ...) for Ruby, rufus-scheduler, and flor
MIT License
353 stars 29 forks source link

#next_time breaks on DST end #97

Open trafium opened 3 months ago

trafium commented 3 months ago

Issue description

According to https://www.timeanddate.com/time/change/estonia?year=2012 In Estonia, 2012 DST ended on October 28 after 03:59 (EEST) clock turned backward to 03:00 (EET). Some evidence confirming this when working with Time class:

irb(main):030> Time.parse("2012-10-28 03:00:00 Europe/Tallinn")
=> 2012-10-28 03:00:00 +0200
irb(main):031> Time.parse("2012-10-28 02:00:00 Europe/Tallinn") + 60.minutes
=> 2012-10-28 03:00:00 +0300
irb(main):032> Time.parse("2012-10-28 02:00:00 Europe/Tallinn")
=> 2012-10-28 02:00:00 +0300
irb(main):033> Time.parse("2012-10-28 02:00:00 Europe/Tallinn") + 60.minutes
=> 2012-10-28 03:00:00 +0300
irb(main):034> Time.parse("2012-10-28 02:00:00 Europe/Tallinn") + 119.minutes
=> 2012-10-28 03:59:00 +0300
irb(main):035> Time.parse("2012-10-28 02:00:00 Europe/Tallinn") + 120.minutes
=> 2012-10-28 03:00:00 +0200
irb(main):036> Time.parse("2012-10-28 02:00:00 Europe/Tallinn").zone
=> "EEST"
irb(main):037> (Time.parse("2012-10-28 02:00:00 Europe/Tallinn") + 120.minutes).zone
=> "EET"

But Fugit::Cron breaks on an attempt to calculate next time from cron */1 * * * * starting from 03:00:00 EEST:

irb(main):044> Fugit::Cron.parse("*/1 * * * *").next_time(Time.parse("2012-10-28 02:59:00 EEST"))
=> #<EtOrbi::EoTime:0x000000010884ac98 @rday=nil, @rweek=nil, @seconds=1351382400.0, @time=nil, @zone=#<TZInfo::DataTimezone: Etc/UTC>>

irb(main):045> Fugit::Cron.parse("*/1 * * * *").next_time(Time.parse("2012-10-28 02:59:00 EEST") + 1.minute)
/Users/traf/.rbenv/versions/3.2.3/lib/ruby/gems/3.2.0/gems/et-orbi-1.2.11/lib/et-orbi/time.rb:82:in `initialize': Cannot determine timezone from "EEST" (ArgumentError)
(secs:1351382400.0,utc~:"2012-10-28 00:00:00.0",ltz~:"UTC")
(etz:nil,tnz:"EET",tziv:"2.0.6",tzidv:"1.2024.1",rv:"3.2.3",rp:"arm64-darwin23",win:false,rorv:"7.1.3.2",astz:[ActiveSupport::TimeZone, "Etc/UTC"],eov:"1.2.11",eotnz:#<TZInfo::DataTimezone: Etc/UTC>,eotnfz:"+0000",eotlzn:"Etc/UTC",eotnfZ:"UTC",
debian:nil,centos:nil,osx:"zoneinfo/Europe/Tallinn")
Try setting `ENV['TZ'] = 'Continent/City'` in your script (see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)

irb(main):046> Fugit::Cron.parse("*/1 * * * *").next_time(Time.parse("2012-10-28 03:00:00 EEST"))
/Users/traf/.rbenv/versions/3.2.3/lib/ruby/gems/3.2.0/gems/et-orbi-1.2.11/lib/et-orbi/time.rb:82:in `initialize': Cannot determine timezone from "EEST" (ArgumentError)
(secs:1351382400.0,utc~:"2012-10-28 00:00:00.0",ltz~:"UTC")
(etz:nil,tnz:"EET",tziv:"2.0.6",tzidv:"1.2024.1",rv:"3.2.3",rp:"arm64-darwin23",win:false,rorv:"7.1.3.2",astz:[ActiveSupport::TimeZone, "Etc/UTC"],eov:"1.2.11",eotnz:#<TZInfo::DataTimezone: Etc/UTC>,eotnfz:"+0000",eotlzn:"Etc/UTC",eotnfZ:"UTC",
debian:nil,centos:nil,osx:"zoneinfo/Europe/Tallinn")
Try setting `ENV['TZ'] = 'Continent/City'` in your script (see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)

irb(main):047> Fugit::Cron.parse("*/1 * * * *").next_time(Time.parse("2012-10-28 02:59:00 Europe/Tallinn") + 1.minute)
/Users/traf/.rbenv/versions/3.2.3/lib/ruby/gems/3.2.0/gems/et-orbi-1.2.11/lib/et-orbi/time.rb:82:in `initialize': Cannot determine timezone from "EEST" (ArgumentError)
(secs:1351382400.0,utc~:"2012-10-28 00:00:00.0",ltz~:"UTC")
(etz:nil,tnz:"EET",tziv:"2.0.6",tzidv:"1.2024.1",rv:"3.2.3",rp:"arm64-darwin23",win:false,rorv:"7.1.3.2",astz:[ActiveSupport::TimeZone, "Etc/UTC"],eov:"1.2.11",eotnz:#<TZInfo::DataTimezone: Etc/UTC>,eotnfz:"+0000",eotlzn:"Etc/UTC",eotnfZ:"UTC",
debian:nil,centos:nil,osx:"zoneinfo/Europe/Tallinn")
Try setting `ENV['TZ'] = 'Continent/City'` in your script (see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)

How to reproduce

The simplest piece of code that reproduces the issue:

require 'fugit'
Fugit::Cron.parse("*/1 * * * *").next_time(Time.parse("2012-10-28 03:00:00 EEST"))

Error and error backtrace (if any)

/Users/traf/.rbenv/versions/3.2.3/lib/ruby/gems/3.2.0/gems/et-orbi-1.2.11/lib/et-orbi/time.rb:82:in `initialize': Cannot determine timezone from "EEST" (ArgumentError)
(secs:1351382400.0,utc~:"2012-10-28 00:00:00.0",ltz~:"UTC")
(etz:nil,tnz:"EET",tziv:"2.0.6",tzidv:"1.2024.1",rv:"3.2.3",rp:"arm64-darwin23",win:false,rorv:"7.1.3.2",astz:[ActiveSupport::TimeZone, "Etc/UTC"],eov:"1.2.11",eotnz:#<TZInfo::DataTimezone: Etc/UTC>,eotnfz:"+0000",eotlzn:"Etc/UTC",eotnfZ:"UTC",
debian:nil,centos:nil,osx:"zoneinfo/Europe/Tallinn")
Try setting `ENV['TZ'] = 'Continent/City'` in your script (see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)

Expected behaviour

Not get error thrown

Given:

Fugit::Cron.parse("*/1 * * * *").next_time(Time.parse("2012-10-28 03:00:00 EEST")).to_t

Output: 2012-10-28 03:01:00 +0300 (+0300 signifies EEST)

Given:

Fugit::Cron.parse("*/1 * * * *").next_time(Time.parse("2012-10-28 03:59:00 EEST")).to_t

Output: 2012-10-28 03:00:00 +0200 (+0200 signifies EET)

Context

Darwin Dmitris-MacBook-Air.local 23.3.0 Darwin Kernel Version 23.3.0: Wed Dec 20 21:30:27 PST 2023; root:xnu-10002.81.5~7/RELEASE_ARM64_T8103 arm64
ruby 3.2.3 (2024-01-18 revision 52bb2ac0a6) [arm64-darwin23]
[:env_tz, nil]
(secs:1711380674.477833,utc~:"2024-03-25 15:31:14.4778330326080322",ltz~:"EET")
(etz:nil,tnz:"EET",tziv:"2.0.6",tzidv:"1.2024.1",rv:"3.2.3",rp:"arm64-darwin23",win:false,rorv:nil,astz:nil,eov:"1.2.11",eotnz:#<TZInfo::TimezoneProxy: Africa/Cairo>,eotnfz:"+0200",eotlzn:"Africa/Cairo",eotnfZ:"EET",debian:nil,centos:nil,osx:"zoneinfo/Europe/Tallinn")
[:fugit, "1.10.1"]
[:now, 2024-03-25 17:31:15.191495 +0200, :zone, "EET"]
jmettraux commented 3 months ago

Hello,

as a preliminary remark, a reminder that Time.parse(str) does not care about time zones:

# ruby 3.2.2 (2023-03-30 revision e51014f9c0) [x86_64-openbsd7.4]

require 'time'

ENV['TZ'] = 'Europe/Tallinn'

p Time.now
  # 2024-03-26 01:19:30.188064864 +0200

p Time.parse("2012-10-28 02:59:00 EEST")
p Time.parse("2012-10-28 02:59:00 Europe/Tallinn")
  # 2012-10-28 02:59:00 +0300
  # 2012-10-28 02:59:00 +0300

p Time.parse("2012-10-28 02:59:00 Some/Where")
p Time.parse("2012-10-28 02:59:00 XXX")
  # 2012-10-28 02:59:00 +0300
  # 2012-10-28 02:59:00 +0300

ENV['TZ'] = 'Asia/Tokyo'

p Time.now
  # 2024-03-26 08:19:30.18919636 +0900

p Time.parse("2012-10-28 02:59:00 EEST")
p Time.parse("2012-10-28 02:59:00 Europe/Tallinn")
  # 2012-10-28 02:59:00 +0900
  # 2012-10-28 02:59:00 +0900

p Time.parse("2012-10-28 02:59:00 Some/Where")
p Time.parse("2012-10-28 02:59:00 XXX")
  # 2012-10-28 02:59:00 +0900
  # 2012-10-28 02:59:00 +0900
jmettraux commented 3 months ago

Tinkering:

# ruby 3.2.2 (2023-03-30 revision e51014f9c0) [x86_64-openbsd7.4]

require 'fugit'

ENV['TZ'] = 'Europe/Tallinn'
  # approximating OP's situation...

p Fugit::Cron.parse("*/1 * * * *")
  .next_time(Time.parse("2012-10-28 02:59:00 EEST")).to_t
    # => 2012-10-28 03:00:00 +0300  ## because ENV TZ is Europe/Tallinn
p Fugit::Cron.parse("*/1 * * * *")
  .next_time(Fugit.parse("2012-10-28 02:59:00 EEST")).to_t
    # => 2012-10-28 10:00:00 +0200  ## off
p Fugit::Cron.parse("*/1 * * * *")
  .next_time(Fugit.parse("2012-10-28 02:59:00 Europe/Tallin")).to_t
    # => 2012-10-28 03:00:00 +0300 ## OK

##

#p Fugit::Cron.parse("*/1 * * * *")
#  .next_time(Time.parse("2012-10-28 02:59:00 EEST") + 1.minute)
#p Fugit::Cron.parse("*/1 * * * *")
#  .next_time(Time.parse("2012-10-28 02:59:00 EEST") + 60)
#p Fugit::Cron.parse("*/1 * * * *")
#  .next_time(Fugit.parse("2012-10-28 02:59:00 EEST") + 60).to_t
p Fugit::Cron.parse("*/1 * * * *")
  .next_time(Fugit.parse("2012-10-28 02:59:00 Europe/Tallin") + 60).to_t
    # => 2012-10-28 03:01:00 +0300
#/Users/traf/.rbenv/versions/3.2.3/lib/ruby/gems/3.2.0/gems/et-orbi-1.2.11/lib/et-orbi/time.rb:82
#   :in `initialize': Cannot determine timezone from "EEST" (ArgumentError)

##

#p Fugit::Cron.parse("*/1 * * * *")
#  .next_time(Time.parse("2012-10-28 03:00:00 EEST")).to_t
p Fugit::Cron.parse("*/1 * * * *")
  .next_time(Fugit.parse("2012-10-28 03:00:00 EEST")).to_t
    # => 2012-10-28 10:01:00 +0200
p Fugit::Cron.parse("*/1 * * * *")
  .next_time(Fugit.parse("2012-10-28 03:00:00 Europe/Tallinn")).to_t
    # => ~/.gem/ruby/3.2.2/gems/et-orbi-1.2.11/lib/et-orbi/make.rb:65
    #      :in `make_time': Cannot turn nil to a ::EtOrbi::EoTime instance
    #      (ArgumentError)
    #      from ~/w/fugit/lib/fugit/cron.rb:247:in `next_time'
p Fugit.parse("2012-10-28 03:00:00 Europe/Tallinn")
  # => nil
p EtOrbi.parse("2012-10-28 03:00:00 Europe/Tallinn")
  # => ~/.gem/ruby/3.2.2/gems/tzinfo-2.0.6/lib/tzinfo/timezone.rb:525:in `period_for_local':
  #         2012-10-28 03:00:00 is an ambiguous local time. (TZInfo::AmbiguousTime)
  # from ~/.gem/ruby/3.2.2/gems/tzinfo-2.0.6/lib/tzinfo/timezone.rb:652:in `block in local_to_utc'
  # from ~/.gem/ruby/3.2.2/gems/tzinfo-2.0.6/lib/tzinfo/timestamp.rb:144:in `for'
  # from ~/.gem/ruby/3.2.2/gems/tzinfo-2.0.6/lib/tzinfo/timezone.rb:648:in `local_to_utc'
  # from ~/.gem/ruby/3.2.2/gems/et-orbi-1.2.11/lib/et-orbi/make.rb:46:in `parse'
p (Fugit.parse("2012-10-28 02:59:00 Europe/Tallinn") + 60).to_t
  # => 2012-10-28 03:00:00 +0300

##

#p Fugit::Cron.parse("*/1 * * * *")
#  .next_time(Time.parse("2012-10-28 02:59:00 Europe/Tallinn") + 1.minute)
#p Fugit::Cron.parse("*/1 * * * *")
#  .next_time(Time.parse("2012-10-28 02:59:00 Europe/Tallinn") + 60)
p Fugit::Cron.parse("*/1 * * * *")
  .next_time(Fugit.parse("2012-10-28 02:59:00 Europe/Tallinn") + 60).to_t
    # => 2012-10-28 03:01:00 +0300
jmettraux commented 3 months ago

Further tinkering:

This works:

require 'fugit'

ENV['TZ'] = 'Europe/Tallinn'

#c = Fugit.parse_cron('*/1 * * * *') # equivalent to
c = Fugit.parse_cron('* * * * *')

t = Fugit.parse('2012-10-28 02:59:00 Europe/Tallinn')

70.times do |i|
  puts "%2d - %s" % [ i, t ? t.to_t.inspect : 'nil' ]
  t = c.next_time(t)
end

It transitions smoothly:

...
58 - 2012-10-28 03:57:00 +0300
59 - 2012-10-28 03:58:00 +0300
60 - 2012-10-28 03:59:00 +0300
61 - 2012-10-28 03:00:00 +0200
62 - 2012-10-28 03:01:00 +0200
63 - 2012-10-28 03:02:00 +0200
...

But making it start at '2012-10-28 03:00:00 Europe/Tallinn' instead of '2012-10-28 02:59:00 Europe/Tallinn' has nil for t and it fails immediately. Fugit.parse('2023-10-28 03:00:00') (in Europe/Tallinn) also returns nil.

I need to find a way in fugit or et-orbi to return the right time when parsing an ambiguous timezone.

I will go on investigating tonight after work. Thanks for reporting that.

trafium commented 3 months ago

Thanks @jmettraux, I was unaware of that weird behaviour of Time.parse with time zones. Still trying to grasp what is going on exactly though.

But keeping that in mind, I found out that I can use Time.find_zone!("Europe/Tallinn").parse("2012-10-28 02:59:00") which gives me ActiveSupport::TimeWithZone and it does not break in Fugit that way.

Time.find_zone!("Europe/Tallinn").parse("2012-10-28 02:59:00") + 1.minute
#=> Sun, 28 Oct 2012 03:00:00.000000000 EEST +03:00
(Time.find_zone!("Europe/Tallinn").parse("2012-10-28 02:59:00") + 1.minute).zone
#=> "EEST"
(Time.find_zone!("Europe/Tallinn").parse("2012-10-28 02:59:00") + 1.minute).dst?
#=> true
(Time.find_zone!("Europe/Tallinn").parse("2012-10-28 02:59:00") + 1.minute) == (Time.parse("2012-10-28 02:59:00 Europe/Tallinn") + 1.minute)
#=> true
(Time.find_zone!("Europe/Tallinn").parse("2012-10-28 02:59:00") + 1.minute).zone == (Time.parse("2012-10-28 02:59:00 Europe/Tallinn") + 1.minute).zone
#=> true
(Time.find_zone!("Europe/Tallinn").parse("2012-10-28 02:59:00") + 1.minute).dst? == (Time.parse("2012-10-28 02:59:00 Europe/Tallinn") + 1.minute).dst?
#=> true
Fugit::Cron.parse("*/1 * * * *").next_time(Time.find_zone!("Europe/Tallinn").parse("2012-10-28 02:59:00") + 1.minute).to_t
#=> 2012-10-28 03:01:00 +0300
Fugit::Cron.parse("*/1 * * * *").next_time(Time.find_zone!("Europe/Tallinn").parse("2012-10-28 03:59:00")).to_s
#=> "2012-10-28 03:00:00 +0200"