symphony-metronome/lib/symphony/metronome/intervalexpression.rl
mahlon@laika.com 9364c9df09 Whitespace is collapsed, so no need to match multiple in the parser.
Fix exact start times with recurring events, when the start time
precedes the interval in the expression, i.e:

	starting at 2015-01-01 09:00:00 run every other minute for 2 days

FossilOrigin-Name: 0fb500373ccb3d17bcc66ee66cc2c513f2abd343d90a516bb8e9ed2c957deda0
2014-10-30 19:57:02 +00:00

599 lines
16 KiB
Ragel

# vim: set noet nosta sw=4 ts=4 ft=ragel :
%%{
#
# Generate the actual code like so:
# ragel -R -T1 -Ls inputfile.rl
#
machine interval_expression;
########################################################################
### A C T I O N S
########################################################################
action set_mark { mark = p }
action set_valid { event.instance_variable_set( :@valid, true ) }
action set_invalid { event.instance_variable_set( :@valid, false ) }
action recurring { event.instance_variable_set( :@recurring, true ) }
action start_time {
time = event.send( :extract, mark, p - mark )
event.send( :set_starting, time, :time )
}
action start_interval {
interval = event.send( :extract, mark, p - mark )
event.send( :set_starting, interval, :interval )
}
action execute_time {
time = event.send( :extract, mark, p - mark )
event.send( :set_interval, time, :time )
}
action execute_interval {
interval = event.send( :extract, mark, p - mark )
event.send( :set_interval, interval, :interval )
}
action execute_multiplier {
multiplier = event.send( :extract, mark, p - mark ).sub( / times/, '' )
event.instance_variable_set( :@multiplier, multiplier.to_i )
}
action ending_time {
time = event.send( :extract, mark, p - mark )
event.send( :set_ending, time, :time )
}
action ending_interval {
interval = event.send( :extract, mark, p - mark )
event.send( :set_ending, interval, :interval )
}
########################################################################
### P R E P O S I T I O N S
########################################################################
recur_preposition = ( 'every' | 'each' | 'per' | 'once' ' per'? ) @recurring;
time_preposition = 'at' | 'on';
interval_preposition = 'in';
########################################################################
### K E Y W O R D S
########################################################################
interval_times =
( 'milli'? 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year' ) 's'?;
start_identifiers = ( 'start' | 'begin' 'n'? ) 'ing'?;
exec_identifiers = ('run' | 'exec' 'ute'? | 'do' );
ending_identifiers = ( ('for' | 'until' | 'during') | ('end'|'finish'|'stop'|'complet' 'e'?) 'ing'? );
########################################################################
### T I M E S P E C S
########################################################################
# 1st
# 202nd
# 2015th
# ...
#
ordinals = (
( (digit+ - '1')? '1' 'st' ) |
( digit+?
( '1' digit 'th' ) | # all '11s'
( '2' 'nd' ) |
( '3' 'rd' ) |
( [0456789] 'th' )
)
);
# 2014-05-01
# 2014-05-01 15:00
# 2014-05-01 15:00:30
#
fulldate = digit{4} '-' digit{2} '-' digit{2}
( space digit{2} ':' digit{2} ( ':' digit{2} )? )?;
# 10am
# 2:45pm
#
time = digit{1,2} ( ':' digit{2} )? ( 'am' | 'pm' );
# union of the above
date_or_time = fulldate | time;
# 20 seconds
# 5 hours
# 1 hour
# 2.5 hours
# an hour
# a minute
# other minute
#
interval = (
(( 'a' 'n'? | [1-9][0-9]* ( '.' [0-9]+ )? ) | 'other' | ordinals ) space
)? interval_times;
########################################################################
### A C T I O N C H A I N S
########################################################################
start_time = date_or_time >set_mark %start_time;
start_interval = interval >set_mark %start_interval;
start_expression = ( (time_preposition space)? start_time ) |
( (interval_preposition space)? start_interval );
execute_time = date_or_time >set_mark %/execute_time;
execute_interval = interval >set_mark %execute_interval;
execute_multiplier = ( digit+ space 'times' )
>set_mark %execute_multiplier @recurring;
execute_expression = (
# regular dates and intervals
( time_preposition space execute_time ) |
( ( interval_preposition | recur_preposition ) space execute_interval )
) | (
# count + interval (10 times every minute)
execute_multiplier space ( recur_preposition space )? execute_interval
) |
# count for 'timeboxed' intervals
execute_multiplier;
ending_time = date_or_time >set_mark %ending_time;
ending_interval = interval >set_mark %ending_interval;
ending_expression = ( (time_preposition space)? ending_time ) |
( (interval_preposition space)? ending_interval );
########################################################################
### M A C H I N E S
########################################################################
Start = (
start: start_identifiers space -> StartTime,
StartTime: start_expression -> final
);
Interval = (
start:
Decorators: ( exec_identifiers space )? -> ExecuteTime,
ExecuteTime: execute_expression -> final
);
Ending = (
start: space ending_identifiers space -> EndingTime,
EndingTime: ending_expression -> final
);
main := (
( Start space Interval Ending? ) |
( Interval ( space Start )? Ending? ) |
( Interval Ending space Start )
) %set_valid @!set_invalid;
}%%
require 'symphony' unless defined?( Symphony )
require 'symphony/metronome'
require 'symphony/metronome/mixins'
using Symphony::Metronome::TimeRefinements
### 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
###
class Symphony::Metronome::IntervalExpression
include Comparable,
Symphony::Metronome::TimeFunctions
extend Loggability
log_to :symphony
# Ragel accessors are injected as class methods/variables for some reason.
%% write data;
# Words/phrases in the expression that we'll strip/ignore before parsing.
COMMON_DECORATORS = [ 'and', 'then', /\s+from now/, 'the next' ];
########################################################################
### C L A S S M E T H O D S
########################################################################
### 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.
###
def self::parse( exp, time=nil )
# 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 || Time.now )
data = event.instance_variable_get( :@data )
# Ragel interface variables
#
key = ''
mark = 0
%% write init;
eof = pe
%% write exec;
# Attach final time logic and sanity checks.
event.send( :finalize )
return event
end
########################################################################
### I N S T A N C E M E T H O D S
########################################################################
### Instantiate a new TimeExpression, provided an +expression+ string
### that describes when this event will take place in natural english,
### and a +base+ Time to perform calculations against.
###
private_class_method :new
def initialize( expression, base ) # :nodoc:
@exp = expression
@data = expression.to_s.unpack( 'c*' )
@base = base
@valid = false
@recurring = false
@starting = nil
@interval = nil
@multiplier = nil
@ending = nil
end
######
public
######
# Is the schedule expression parsable?
attr_reader :valid
# Does this event repeat?
attr_reader :recurring
# The valid start time for the schedule (for recurring events)
attr_reader :starting
# The valid end time for the schedule (for recurring events)
attr_reader :ending
# The interval to wait before the event should be acted on.
attr_reader :interval
# An optional interval multipler for expressing counts.
attr_reader :multiplier
### 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.
###
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
### Just return the original event expression.
###
def to_s
return @exp
end
### Inspection string.
###
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
### Comparable interface, order by interval, 'soonest' first.
###
def <=>( other )
return self.interval <=> other.interval
end
#########
protected
#########
### Given a +start+ and +ending+ scanner position,
### return an ascii representation of the data slice.
###
def extract( start, ending )
slice = @data[ start, ending ]
return '' unless slice
return slice.pack( 'c*' )
end
### Parse and set the starting attribute, given a +time_arg+
### string and the +type+ of string (interval or exact time)
###
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
### 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.
###
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
### 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.
###
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
### Perform finishing logic and final sanity checks before returning
### a parsed object.
###
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
### Given a +time_arg+ string and a type (:interval or :time),
### dispatch to the appropriate parser.
###
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 a +time_arg+ string (anything parsable buy Time.parse())
### into a Time object.
###
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
### Parse a +time_arg+ interval string ("30 seconds") into an
### Integer.
###
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
end # class TimeExpression