Symphony::Metronome::

IntervalExpression class

Parse natural English expressions of times and intervals.

in 30 minutes
once an hour
every 15 minutes for 2 days
at 2014-05-01
at 2014-04-01 14:00:25
at 2pm
starting at 2pm once a day
start in 1 hour from now run every 5 seconds end at 11:15pm
every other hour
once a day ending in 1 week
run once a minute for an hour starting in 6 days
run each hour starting at 2010-01-05 09:00:00
10 times a minute for 2 days
run 45 times every hour
30 times per day
start at 2010-01-02 run 12 times and end on 2010-01-03
starting in an hour from now run 6 times a minute for 2 hours
beginning a day from now, run 30 times per minute and finish in 2 weeks
execute 12 times during the next 2 minutes

Constants

COMMON_DECORATORS

Words/phrases in the expression that we’ll strip/ignore before parsing.

Attributes

interval_expression_en_main RW
interval_expression_error RW
interval_expression_first_final RW
interval_expression_start RW
ending R

The valid end time for the schedule (for recurring events)

interval R

The interval to wait before the event should be acted on.

multiplier R

An optional interval multipler for expressing counts.

recurring R

Does this event repeat?

starting R

The valid start time for the schedule (for recurring events)

valid R

Is the schedule expression parsable?

Public Class Methods

parse( exp, time=Time.now )

Parse a schedule expression exp.

Parsing defaults to Time.now(), but if passed a time object, all contexual times (2pm) are relative to it. If you know when an expression was generated, you can ‘reconstitute’ an interval object this way.

# File lib/symphony/metronome/intervalexpression.rb, line 1919
        def self::parse( exp, time=Time.now )

                # Normalize the expression before parsing
                #
                exp = exp.downcase.
                        gsub( /(?:[^[a-z][0-9][\.\-:]\s]+)/, '' ).   # . : - a-z 0-9 only
                        gsub( Regexp.union(COMMON_DECORATORS), '' ). # remove common decorator words
                        gsub( /\s+/, ' ' ).                          # collapse whitespace
                        gsub( /([:\-])+/, '\1' ).                    # collapse multiple - or : chars
                        gsub( /\.+$/, '' )                           # trailing periods

                event = new( exp, time )
                data  = event.instance_variable_get( :@data )

                # Ragel interface variables
                #
                key    = ''
                mark   = 0
                
begin
        p ||= 0
        pe ||= data.length
        cs = interval_expression_start
end

                eof = pe
                
begin
        testEof = false
        _klen, _trans, _keys = nil
        _goto_level = 0
        _resume = 10
        _eof_trans = 15
        _again = 20
        _test_eof = 30
        _out = 40
        while true
        if _goto_level <= 0
        if p == pe
                _goto_level = _test_eof
                next
        end
        if cs == 0
                _goto_level = _out
                next
        end
        end
        if _goto_level <= _resume
        _keys = _interval_expression_key_offsets[cs]
        _trans = _interval_expression_index_offsets[cs]
        _klen = _interval_expression_single_lengths[cs]
        _break_match = false
        
        begin
          if _klen > 0
             _lower = _keys
             _upper = _keys + _klen - 1

             loop do
                break if _upper < _lower
                _mid = _lower + ( (_upper - _lower) >> 1 )

                if data[p].ord < _interval_expression_trans_keys[_mid]
                   _upper = _mid - 1
                elsif data[p].ord > _interval_expression_trans_keys[_mid]
                   _lower = _mid + 1
                else
                   _trans += (_mid - _keys)
                   _break_match = true
                   break
                end
             end # loop
             break if _break_match
             _keys += _klen
             _trans += _klen
          end
          _klen = _interval_expression_range_lengths[cs]
          if _klen > 0
             _lower = _keys
             _upper = _keys + (_klen << 1) - 2
             loop do
                break if _upper < _lower
                _mid = _lower + (((_upper-_lower) >> 1) & ~1)
                if data[p].ord < _interval_expression_trans_keys[_mid]
                  _upper = _mid - 2
                elsif data[p].ord > _interval_expression_trans_keys[_mid+1]
                  _lower = _mid + 2
                else
                  _trans += ((_mid - _keys) >> 1)
                  _break_match = true
                  break
                end
             end # loop
             break if _break_match
             _trans += _klen
          end
        end while false
        cs = _interval_expression_trans_targs[_trans];

        if _interval_expression_trans_actions[_trans] != 0

                case _interval_expression_trans_actions[_trans] 
        when 2 then
                begin
 mark = p               end
        when 1 then
                begin
 event.instance_variable_set( :@valid, false )          end
        when 3 then
                begin
 event.instance_variable_set( :@recurring, true )               end
        when 4 then
                begin

                time = event.send( :extract, mark, p - mark )
                event.send( :set_starting, time, :time )
                        end
        when 5 then
                begin

                interval = event.send( :extract, mark, p - mark )
                event.send( :set_starting, interval, :interval )
                        end
        when 9 then
                begin

                interval = event.send( :extract, mark, p - mark )
                event.send( :set_interval, interval, :interval )
                        end
        when 7 then
                begin

                multiplier = event.send( :extract, mark, p - mark ).sub( / times/, '' )
                event.instance_variable_set( :@multiplier, multiplier.to_i )
                        end
        when 14 then
                begin

                time = event.send( :extract, mark, p - mark )
                event.send( :set_ending, time, :time )
                        end
        when 15 then
                begin

                interval = event.send( :extract, mark, p - mark )
                event.send( :set_ending, interval, :interval )
                        end
                end # action switch
        end

        end
        if _goto_level <= _again
        if cs == 0
                _goto_level = _out
                next
        end
        p += 1
        if p != pe
                _goto_level = _resume
                next
        end
        end
        if _goto_level <= _test_eof
        if p == eof
        begin
                case ( _interval_expression_eof_actions[cs] )
        when 1 then
                begin
 event.instance_variable_set( :@valid, false )          end
        when 10 then
                begin

                time = event.send( :extract, mark, p - mark )
                event.send( :set_starting, time, :time )
                        end
                begin
 event.instance_variable_set( :@valid, true )           end
        when 13 then
                begin

                interval = event.send( :extract, mark, p - mark )
                event.send( :set_starting, interval, :interval )
                        end
                begin
 event.instance_variable_set( :@valid, true )           end
        when 16 then
                begin

                time = event.send( :extract, mark, p - mark )
                event.send( :set_interval, time, :time )
                        end
                begin
 event.instance_variable_set( :@valid, true )           end
        when 8 then
                begin

                interval = event.send( :extract, mark, p - mark )
                event.send( :set_interval, interval, :interval )
                        end
                begin
 event.instance_variable_set( :@valid, true )           end
        when 6 then
                begin

                multiplier = event.send( :extract, mark, p - mark ).sub( / times/, '' )
                event.instance_variable_set( :@multiplier, multiplier.to_i )
                        end
                begin
 event.instance_variable_set( :@valid, true )           end
        when 11 then
                begin

                time = event.send( :extract, mark, p - mark )
                event.send( :set_ending, time, :time )
                        end
                begin
 event.instance_variable_set( :@valid, true )           end
        when 12 then
                begin

                interval = event.send( :extract, mark, p - mark )
                event.send( :set_ending, interval, :interval )
                        end
                begin
 event.instance_variable_set( :@valid, true )           end
                end
        end
        end

        end
        if _goto_level <= _out
                break
        end
end
        end


                # Attach final time logic and sanity checks.
                event.send( :finalize )

                return event
        end

Public Instance Methods

<=>( other )

Comparable interface, order by interval, ‘soonest’ first.

# File lib/symphony/metronome/intervalexpression.rb, line 2257
def <=>( other )
        return self.interval <=> other.interval
end
fire?()

If this interval is on a stack somewhere and ready to fire, is it okay to do so based on the specified expression criteria?

Returns true if it should fire, false if it should not but could at a later attempt, and nil if the interval has expired.

# File lib/symphony/metronome/intervalexpression.rb, line 2217
def fire?
        now = Time.now

        # Interval has expired.
        return nil if self.ending && now > self.ending

        # Interval is not yet in its current time window.
        return false if self.starting - now > 0

        # Looking good.
        return true
end
inspect()

Inspection string.

# File lib/symphony/metronome/intervalexpression.rb, line 2240
def inspect
        return ( "<%s:0x%08x valid:%s recur:%s expression:%p " +
                                "starting:%p interval:%p ending:%p>" ) % [
                self.class.name,
                self.object_id * 2,
                self.valid,
                self.recurring,
                self.to_s,
                self.starting,
                self.interval,
                self.ending
        ]
end
to_s()

Just return the original event expression.

# File lib/symphony/metronome/intervalexpression.rb, line 2233
def to_s
        return @exp
end

Protected Instance Methods

extract( start, ending )

Given a start and ending scanner position, return an ascii representation of the data slice.

# File lib/symphony/metronome/intervalexpression.rb, line 2269
def extract( start, ending )
        slice = @data[ start, ending ]
        return '' unless slice
        return slice.pack( 'c*' )
end
finalize()

Perform finishing logic and final sanity checks before returning a parsed object.

# File lib/symphony/metronome/intervalexpression.rb, line 2375
def finalize
        raise Symphony::Metronome::TimeParseError, "unable to parse expression" unless self.valid

        # Ensure start time is populated.
        #
        unless self.starting
                if self.recurring
                        @starting = @base
                else
                        raise Symphony::Metronome::TimeParseError, "non-deterministic expression" if self.interval.nil?
                        @starting = @base + self.interval
                end
        end

        # Alter the interval if a multiplier was specified.
        #
        if self.multiplier
                if self.ending

                        # Regular 'count' style multipler with end date.
                        # (run 10 times a minute for 2 days)
                        # Just divide the current interval by the count.
                        #
                        if self.interval
                                @interval = self.interval.to_f / self.multiplier

                        # Timeboxed multiplier (start [date] run 10 times end [date])
                        # Evenly spread the interval out over the time window.
                        #
                        else
                                diff = self.ending - self.starting
                                @interval = diff.to_f / self.multiplier
                        end

                # Regular 'count' style multipler (run 10 times a minute)
                # Just divide the current interval by the count.
                #
                else
                        raise Symphony::Metronome::TimeParseError, "An end date or interval is required" unless self.interval
                        @interval = self.interval.to_f / self.multiplier
                end
        end
end
get_time( time_arg, type )

Given a time_arg string and a type (:interval or :time), dispatch to the appropriate parser.

# File lib/symphony/metronome/intervalexpression.rb, line 2423
def get_time( time_arg, type )
        time = nil

        if type == :interval
                secs = self.parse_interval( time_arg )
                time = @base + secs if secs
        end

        if type == :time
                time = self.parse_time( time_arg )
        end

        raise Symphony::Metronome::TimeParseError, "unable to parse time" if time.nil?
        return time
end
parse_interval( interval_arg )

Parse a time_arg interval string (“30 seconds”) into an Integer.

# File lib/symphony/metronome/intervalexpression.rb, line 2465
def parse_interval( interval_arg )
        duration, span = interval_arg.split( /\s+/ )

        # catch the 'a' or 'an' case (ex: "an hour")
        duration = 1 if duration.index( 'a' ) == 0

        # catch the 'other' case, ie: 'every other hour'
        duration = 2 if duration == 'other'

        # catch the singular case (ex: "hour")
        unless span
                span = duration
                duration = 1
        end

        use_milliseconds = span.sub!( 'milli', '' )
        interval = calculate_seconds( duration.to_f, span.to_sym )

        # milliseconds
        interval = duration.to_f / 1000 if use_milliseconds

        self.log.debug "Parsed %p (interval) to: %p" % [ interval_arg, interval ]
        return interval
end
parse_time( time_arg )

Parse a time_arg string (anything parsable buy Time.parse()) into a Time object.

# File lib/symphony/metronome/intervalexpression.rb, line 2443
def parse_time( time_arg )
        time = Time.parse( time_arg, @base ) rescue nil

        # Generated date is in the past.
        #
        if time && @base > time

                # Ensure future dates for ambiguous times (2pm)
                time = time + 1.day if time_arg.length < 8

                # Still in the past, abandon all hope.
                raise Symphony::Metronome::TimeParseError, "attempt to schedule in the past" if @base > time
        end

        self.log.debug "Parsed %p (time) to: %p" % [ time_arg, time ]
        return time
end
set_ending( time_arg, type )

Parse and set the ending attribute, given a time_arg string and the type of string (interval or exact time)

Perform consistency and sanity checks before returning a Time object.

# File lib/symphony/metronome/intervalexpression.rb, line 2332
def set_ending( time_arg, type )
        ending = nil

        # Ending dates only make sense for recurring events.
        #
        if self.recurring
                @ending_args = [ time_arg, type ] # squirrel away for post-set starts

                # Make the interval an offset of the start time, instead of now.
                #
                # This is the contextual difference between:
                #   every minute until 6 hours from now (ending based on NOW)
                #   and
                #   starting in a year run every minute for 1 month (ending based on start time)
                #
                if self.starting && type == :interval
                        diff = self.parse_interval( time_arg )
                        ending = self.starting + diff

                # (offset from now)
                #
                else
                        ending = self.get_time( time_arg, type )
                end

                # Check the end time is after the start time.
                #
                if self.starting && ending < self.starting
                        raise Symphony::Metronome::TimeParseError, "recurring event ends before it begins"
                end

        else
                self.log.debug "Ignoring ending date, event is not recurring."
        end

        @ending = ending
        return @ending
end
set_interval( time_arg, type )

Parse and set the interval attribute, given a time_arg string and the type of string (interval or exact time)

Perform consistency and sanity checks before returning an integer representing the amount of time needed to sleep before firing the event.

# File lib/symphony/metronome/intervalexpression.rb, line 2312
def set_interval( time_arg, type )
        interval = nil
        if self.starting && type == :time
                raise Symphony::Metronome::TimeParseError, "That doesn't make sense, just use 'at [datetime]' instead"
        else
                interval = self.get_time( time_arg, type )
                interval = interval - @base
        end

        @interval = interval
        return @interval
end
set_starting( time_arg, type )

Parse and set the starting attribute, given a time_arg string and the type of string (interval or exact time)

# File lib/symphony/metronome/intervalexpression.rb, line 2279
def set_starting( time_arg, type )
        @starting_args ||= []
        @starting_args << time_arg

        # If we already have seen a start time, it's possible the parser
        # was non-deterministic and this action has been executed multiple
        # times. Re-parse the complete date string, overwriting any previous.
        time_arg = @starting_args.join( ' ' )

        start = self.get_time( time_arg, type )
        @starting = start

        # If start time is expressed as a post-conditional (we've
# already got an end time) we need to recalculate the end
        # as an offset from the start.  The original parsed ending
        # arguments should have already been cached when it was
        # previously set.
        #
        if self.ending && self.recurring
                self.set_ending( *@ending_args )
        end

        return @starting
end