floraison / fugit

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

Question: how to find the end of the current cron "validity interval"? #50

Closed gr8bit closed 3 years ago

gr8bit commented 3 years ago

Challenge description

I'm using the cron syntax not only as an indicator for several points in time but as a duration as well. E.g. I interpret (and check via match?(Time.now)) the following as: '10-40 22 * * mon-fri' indicates "every Monday till Friday between 22:10 and 22:40". '* 0 * * mon' indicates "every Monday between 00:00 and 00:59". '* 8 * * *' indicates "every day between 08:00 and 08:59". ...you get the idea. ;)

Works fine so far, but instead of finding the time when the next "interval" will start I need to find (if in an active interval) when it will end. E.g. if current time was 2020-11-01 08:32:02, this interval '* 8 * * *' should return a time of 2020-11-01 08:59:59 (or even 2020-11-01 09:00:00).

Do you think that's possible? Any ideas appreciated! :)

jmettraux commented 3 years ago

Hello,

@gr8bit wrote:

E.g. if current time was 2020-11-01 08:32:02, this interval ' 8 ' should return a time of 2020-11-01 08:59:59 (or even 2020-11-01 09:00:00).

I am not sure I follow you on that one. * 8 * * * maps to 08:00:00, 08:01:00, 08:02:00, ... , 08:59:00.

Maybe something like

require 'fugit'

c = Fugit::Cron.parse('* 8 * * *')

MAXD = 366 * 24 * 3600 # 1 year

ds = []

t0 = Time.now
t = t0
t1 = nil
  #
loop do
  t = c.next_time(t)
  ds << [ t.to_s, t - t1 ] if t1
  t1 = t
  break if t > t0 + MAXD
end

#ds.each do |d|
#  p d
#end

longest = ds.collect { |t, d| d }.max
  # only pick the longest :-( ...

p [ :start, ds.first ]
p [ :end, ds.last ]
p [ :longest, longest ]

intervals =
  ds.inject([]) { |a, d|
    a << [] if a.empty? || d[1] == longest
    if a.last.empty?
      a.last << d
    else
      a.last[1] = d
    end
    a }

intervals.each do |i|
  p i
end

Closing the issue but not the conversation.

jmettraux commented 3 years ago

@gr8bit Are you a friend of the OP in #44 ?

gr8bit commented 3 years ago

Thanks a lot for the comment! I will look into it right after my answer.

E.g. if current time was 2020-11-01 08:32:02, this interval ' 8 ' should return a time of 2020-11-01 08:59:59 (or even 2020-11-01 09:00:00).

I am not sure I follow you on that one. * 8 * * * maps to 08:00:00, 08:01:00, 08:02:00, ... , 08:59:00.

That's right, it maps to the times you stated - so if I use match? when the current time is between 08:00:00 and even 08:59:59, it will return true. From 09:00:00 on, it will again return false until it will be 8:00h again. If I understand correctly, the next_time method will point to the next day at 8:00:00 in our example.

What I need is, when match? is true, the next_end_time of the "interval" I'm in. (I'm always quoting interval to indicate I'm aware cron notation is not for intervals ;)).

@gr8bit Are you a friend of the OP in #44 ?

No, I don't know him but I seems he had a quite similar use case. 😄

gr8bit commented 3 years ago

Aaahhh, I think I understood next_time wrong - it returns the next minute the cron would run at. If in an "interval" it would just be the next minute. That's why you loop in your example (although I'm not sure why MAXD is 366 * 24 * 3600 and not 366 * 24 * 60 - we have only minute (not second) precision, don't we?).

jmettraux commented 3 years ago

@gr8bit wrote:

What I need is, when match? is true, the next_end_time of the "interval" I'm in. (I'm always quoting interval to indicate I'm aware cron notation is not for intervals ;)).

I am sorry but the Fugit::Cron#match?(time) method is point based. But nothing prevents you building your own abstraction on top of it that determines given a cron string what are the intervals and has its own #match?(time). As you have seen in my code example, my routine said "let's consider that the longest delta is the delta between two intervals". There isn't much in the cron strings that says that and Fugit::Cron is for cron string, not for time intervals.

jmettraux commented 3 years ago

@gr8bit wrote:

although I'm not sure why MAXD is 366 24 3600 and not 366 24 60 - we have only minute (not second) precision, don't we?

MAXD is one year, because 0 0 1 1 * happens once per year. I could have gone further to accept 0 0 29 2 * which happens usually every four years... I was trying to accept not just your example * 8 * * * but other cron strings. I wanted to generalize a bit...

gr8bit commented 3 years ago

What a great library. I think I have a very quick way to determine the end of the interval. I'll post code here once it's done. :) Thank you so much for the kind help!!

Also, I now understand 366 * 24 * 3600 - Cron actually is at seconds precision, I passed one parameter less and it was set to 0, that's why next_time stepped minutes in my tests.

jmettraux commented 3 years ago

You're welcome!

gr8bit commented 3 years ago

I think I got it. Comments please! :)

At first I've added a simple "change and clone" method to EoTime, I hope you don't mind.

module EtOrbi
  class EoTime

    def change(options = {})
      EtOrbi.make_time '%04d-%02d-%02dT%02d:%02d:%02d.%d%s' % [
        options[:year] || year,
        options[:month] || month,
        options[:day] || day,
        options[:hour] || hour,
        options[:min] || min,
        options[:sec] || sec,
        options[:usec] || usec,
        strfz('%:z')
      ]
    end

  end
end

And the main code for Fugit::Cron:

module Fugit
  class Cron

    ATTRS = {
      %i[seconds sec] => [0, 60, 1],
      %i[minutes min] => [0, 60, 60],
      %i[hours hour] => [0, 24, 60 * 60],
      %i[weekdays day] => [nil, 31, 24 * 60 * 60],
      %i[monthdays day] => [1, 31, 24 * 60 * 60],
      %i[months month] => [1, 12, nil]
    }.freeze

    def interval_end_time(from = ::EtOrbi::EoTime.now)
      return unless match?(from)

      # clone date and eliminate fractional seconds at once
      # @type till [EtOrbi::EoTime]
      till = ::EtOrbi.make_time(from.iso8601)
      # @type copy [Fugit::Cron]
      copy = self.class.new(original)

      changes = {}
      key, (_, incrs, secs) =
        ATTRS.detect do |(cron_attr, eo_attr), (default)|
          next true unless copy.instance_variable_get("@#{cron_attr}").nil?

          if default
            changes[eo_attr] = default
            copy.instance_variable_set("@#{cron_attr}", [default])
          end
          false
        end
      return unless key
      till = till.change(changes)

      incrs.times do
        till =
          if secs
            till.inc(secs)
          else
            # as months don't have the same amount of seconds, increase it differently
            till.change(month: till.month % 12 + 1, year: till.month % 12 == 0 ? till.year + 1 : nil)
          end
        break unless copy.match?(till)
      end

      till
    end

  end
end

It works like this: I need to sample future times until the first point in time the cron does not match. That point in time would be the end of the "interval" I'm in. I can't sample all seconds of a year though and I don't need to - in fact, I can decrease the precision from seconds to minutes if all seconds match (=are set as "*"). Same goes for minutes to hours, hours to days and days to months.

So, from seconds up to months, I find the first field that's not "*" (nil in the cron object). That's my precision I want to test against. All "*" until the first non-"*" are set tho their respective "first value" (0 for seconds, minutes, hours, 1 for days and months, nil for weekday as that one is special).

I need to test one loop cycle of the found property only as it would equal "*" if it included the whole cycle (I do not explicitly check that though). Each iteration I increment my time object by the fields unit (second or minute or day etc.). So I'll need 60 loops at maximum if someone set something like * 1-59 * * * *. Even * 30-29 * * * * is possible that way. The first loop iteration that doesn't match marks the end time which I wanted to find.

That was a hell of a brain teaser. ;)

jmettraux commented 3 years ago

Well done!

I am not sure about your use case, but I think that if I needed that, I'd wrap it in its own library, a library that uses fugit, not a fugit augmentation. After all, you might decide later on that you want to parse different interval representations like "0800-0900,1500-1600" with which fugit has no business. Your tool would use fugit when presented with a cron string and some other lib if necessary for other interval representations. Really, fugit is about point in times, not intervals.

Gute Nacht!

gr8bit commented 3 years ago

Thanks and good point. Initially, my code used many more of the internals of Fugit::Cron than it does now. However, I rely on internal method names (for instance_variable_set especially), that's why I left it as augmenting code for now (also, because I never intend to change it but we both know: never say never when it comes to software development ;)).

If you're interested in my use case: for a small custom shop (for a game to be precise), a recurring sales events feature was asked for by the game designer. While thinking about an own implementation the similarities to the cron syntax became more and more obvious (ability to select weekdays for recurrence, "every X days" etc.). So I figured if I did not "sample" the cron definition by the minute (like classic cron does) it would also be well suited for defining "a time pattern matching definition which you can match any point in time against and it will tell you if it matches or not". So there was my "is this offer active?"-method. When an offer came out as active for a certain point in time, I needed to know how long it would consecutively stay active as well - simply to show the user when this offer will end. That's where this issue's functionality came in. As the highest precision is "seconds", the brute force method would be to sample second by second until one came out as false, which would then be the end of the current "active interval". As you know, the code above is basically that except it's optimized to sample the highest used (as in: "not *") precision. With your hash and modulo extensions in place, this is close to game designer marketing heaven. ;) I might even think about allowing more than one cron definition per offer so multiple time ranges like 08:00h-09:00h, 15:30h-16:40h would easily be possible.

Thanks a lot again for the quick and helpful replies! You rock!

jmettraux commented 3 years ago

@gr8bit thanks for sharing your use case, such pieces of information yield so much learning!