symphony-metronome 0.1.0 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,599 @@
1
+ # vim: set noet nosta sw=4 ts=4 ft=ragel :
2
+
3
+ %%{
4
+ #
5
+ # Generate the actual code like so:
6
+ # ragel -R -T1 -Ls inputfile.rl
7
+ #
8
+
9
+ machine interval_expression;
10
+
11
+ ########################################################################
12
+ ### A C T I O N S
13
+ ########################################################################
14
+
15
+ action set_mark { mark = p }
16
+
17
+ action set_valid { event.instance_variable_set( :@valid, true ) }
18
+ action set_invalid { event.instance_variable_set( :@valid, false ) }
19
+ action recurring { event.instance_variable_set( :@recurring, true ) }
20
+
21
+ action start_time {
22
+ time = event.send( :extract, mark, p - mark )
23
+ event.send( :set_starting, time, :time )
24
+ }
25
+
26
+ action start_interval {
27
+ interval = event.send( :extract, mark, p - mark )
28
+ event.send( :set_starting, interval, :interval )
29
+ }
30
+
31
+ action execute_time {
32
+ time = event.send( :extract, mark, p - mark )
33
+ event.send( :set_interval, time, :time )
34
+ }
35
+
36
+ action execute_interval {
37
+ interval = event.send( :extract, mark, p - mark )
38
+ event.send( :set_interval, interval, :interval )
39
+ }
40
+
41
+ action execute_multiplier {
42
+ multiplier = event.send( :extract, mark, p - mark ).sub( / times/, '' )
43
+ event.instance_variable_set( :@multiplier, multiplier.to_i )
44
+ }
45
+
46
+ action ending_time {
47
+ time = event.send( :extract, mark, p - mark )
48
+ event.send( :set_ending, time, :time )
49
+ }
50
+
51
+ action ending_interval {
52
+ interval = event.send( :extract, mark, p - mark )
53
+ event.send( :set_ending, interval, :interval )
54
+ }
55
+
56
+
57
+ ########################################################################
58
+ ### P R E P O S I T I O N S
59
+ ########################################################################
60
+
61
+ recur_preposition = ( 'every' | 'each' | 'per' | 'once' ' per'? ) @recurring;
62
+ time_preposition = 'at' | 'on';
63
+ interval_preposition = 'in';
64
+
65
+
66
+ ########################################################################
67
+ ### K E Y W O R D S
68
+ ########################################################################
69
+
70
+ interval_times =
71
+ ( 'milli'? 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year' ) 's'?;
72
+
73
+ start_identifiers = ( 'start' | 'begin' 'n'? ) 'ing'?;
74
+ exec_identifiers = ('run' | 'exec' 'ute'? | 'do' );
75
+ ending_identifiers = ( ('for' | 'until' | 'during') | ('end'|'finish'|'stop'|'complet' 'e'?) 'ing'? );
76
+
77
+
78
+ ########################################################################
79
+ ### T I M E S P E C S
80
+ ########################################################################
81
+
82
+ # 1st
83
+ # 202nd
84
+ # 2015th
85
+ # ...
86
+ #
87
+ ordinals = (
88
+ ( (digit+ - '1')? '1' 'st' ) |
89
+ ( digit+?
90
+ ( '1' digit 'th' ) | # all '11s'
91
+ ( '2' 'nd' ) |
92
+ ( '3' 'rd' ) |
93
+ ( [0456789] 'th' )
94
+ )
95
+ );
96
+
97
+ # 2014-05-01
98
+ # 2014-05-01 15:00
99
+ # 2014-05-01 15:00:30
100
+ #
101
+ fulldate = digit{4} '-' digit{2} '-' digit{2}
102
+ ( space digit{2} ':' digit{2} ( ':' digit{2} )? )?;
103
+
104
+ # 10am
105
+ # 2:45pm
106
+ #
107
+ time = digit{1,2} ( ':' digit{2} )? ( 'am' | 'pm' );
108
+
109
+ # union of the above
110
+ date_or_time = fulldate | time;
111
+
112
+ # 20 seconds
113
+ # 5 hours
114
+ # 1 hour
115
+ # 2.5 hours
116
+ # an hour
117
+ # a minute
118
+ # other minute
119
+ #
120
+ interval = (
121
+ (( 'a' 'n'? | [1-9][0-9]* ( '.' [0-9]+ )? ) | 'other' | ordinals ) space
122
+ )? interval_times;
123
+
124
+
125
+ ########################################################################
126
+ ### A C T I O N C H A I N S
127
+ ########################################################################
128
+
129
+ start_time = date_or_time >set_mark %start_time;
130
+ start_interval = interval >set_mark %start_interval;
131
+
132
+ start_expression = ( (time_preposition space)? start_time ) |
133
+ ( (interval_preposition space)? start_interval );
134
+
135
+ execute_time = date_or_time >set_mark %/execute_time;
136
+ execute_interval = interval >set_mark %execute_interval;
137
+ execute_multiplier = ( digit+ space 'times' )
138
+ >set_mark %execute_multiplier @recurring;
139
+
140
+ execute_expression = (
141
+ # regular dates and intervals
142
+ ( time_preposition space execute_time ) |
143
+ ( ( interval_preposition | recur_preposition ) space execute_interval )
144
+ ) | (
145
+ # count + interval (10 times every minute)
146
+ execute_multiplier space ( recur_preposition space )? execute_interval
147
+ ) |
148
+ # count for 'timeboxed' intervals
149
+ execute_multiplier;
150
+
151
+
152
+ ending_time = date_or_time >set_mark %ending_time;
153
+ ending_interval = interval >set_mark %ending_interval;
154
+
155
+ ending_expression = ( (time_preposition space)? ending_time ) |
156
+ ( (interval_preposition space)? ending_interval );
157
+
158
+
159
+ ########################################################################
160
+ ### M A C H I N E S
161
+ ########################################################################
162
+
163
+ Start = (
164
+ start: start_identifiers space -> StartTime,
165
+ StartTime: start_expression -> final
166
+ );
167
+
168
+ Interval = (
169
+ start:
170
+ Decorators: ( exec_identifiers space )? -> ExecuteTime,
171
+ ExecuteTime: execute_expression -> final
172
+ );
173
+
174
+ Ending = (
175
+ start: space ending_identifiers space -> EndingTime,
176
+ EndingTime: ending_expression -> final
177
+ );
178
+
179
+
180
+ main := (
181
+ ( Start space Interval Ending? ) |
182
+ ( Interval ( space Start )? Ending? ) |
183
+ ( Interval Ending space Start )
184
+ ) %set_valid @!set_invalid;
185
+ }%%
186
+
187
+
188
+ require 'symphony' unless defined?( Symphony )
189
+ require 'symphony/metronome'
190
+ require 'symphony/metronome/mixins'
191
+
192
+ using Symphony::Metronome::TimeRefinements
193
+
194
+
195
+ ### Parse natural English expressions of times and intervals.
196
+ ###
197
+ ### in 30 minutes
198
+ ### once an hour
199
+ ### every 15 minutes for 2 days
200
+ ### at 2014-05-01
201
+ ### at 2014-04-01 14:00:25
202
+ ### at 2pm
203
+ ### starting at 2pm once a day
204
+ ### start in 1 hour from now run every 5 seconds end at 11:15pm
205
+ ### every other hour
206
+ ### once a day ending in 1 week
207
+ ### run once a minute for an hour starting in 6 days
208
+ ### run each hour starting at 2010-01-05 09:00:00
209
+ ### 10 times a minute for 2 days
210
+ ### run 45 times every hour
211
+ ### 30 times per day
212
+ ### start at 2010-01-02 run 12 times and end on 2010-01-03
213
+ ### starting in an hour from now run 6 times a minute for 2 hours
214
+ ### beginning a day from now, run 30 times per minute and finish in 2 weeks
215
+ ### execute 12 times during the next 2 minutes
216
+ ###
217
+ class Symphony::Metronome::IntervalExpression
218
+ include Comparable,
219
+ Symphony::Metronome::TimeFunctions
220
+ extend Loggability
221
+
222
+ log_to :symphony
223
+
224
+ # Ragel accessors are injected as class methods/variables for some reason.
225
+ %% write data;
226
+
227
+ # Words/phrases in the expression that we'll strip/ignore before parsing.
228
+ COMMON_DECORATORS = [ 'and', 'then', /\s+from now/, 'the next' ];
229
+
230
+
231
+ ########################################################################
232
+ ### C L A S S M E T H O D S
233
+ ########################################################################
234
+
235
+ ### Parse a schedule expression +exp+.
236
+ ###
237
+ ### Parsing defaults to Time.now(), but if passed a +time+ object,
238
+ ### all contexual times (2pm) are relative to it. If you know when
239
+ ### an expression was generated, you can 'reconstitute' an interval
240
+ ### object this way.
241
+ ###
242
+ def self::parse( exp, time=nil )
243
+
244
+ # Normalize the expression before parsing
245
+ #
246
+ exp = exp.downcase.
247
+ gsub( /(?:[^[a-z][0-9][\.\-:]\s]+)/, '' ). # . : - a-z 0-9 only
248
+ gsub( Regexp.union(COMMON_DECORATORS), '' ). # remove common decorator words
249
+ gsub( /\s+/, ' ' ). # collapse whitespace
250
+ gsub( /([:\-])+/, '\1' ). # collapse multiple - or : chars
251
+ gsub( /\.+$/, '' ) # trailing periods
252
+
253
+ event = new( exp, time || Time.now )
254
+ data = event.instance_variable_get( :@data )
255
+
256
+ # Ragel interface variables
257
+ #
258
+ key = ''
259
+ mark = 0
260
+ %% write init;
261
+ eof = pe
262
+ %% write exec;
263
+
264
+ # Attach final time logic and sanity checks.
265
+ event.send( :finalize )
266
+
267
+ return event
268
+ end
269
+
270
+
271
+ ########################################################################
272
+ ### I N S T A N C E M E T H O D S
273
+ ########################################################################
274
+
275
+ ### Instantiate a new TimeExpression, provided an +expression+ string
276
+ ### that describes when this event will take place in natural english,
277
+ ### and a +base+ Time to perform calculations against.
278
+ ###
279
+ private_class_method :new
280
+ def initialize( expression, base ) # :nodoc:
281
+ @exp = expression
282
+ @data = expression.to_s.unpack( 'c*' )
283
+ @base = base
284
+
285
+ @valid = false
286
+ @recurring = false
287
+ @starting = nil
288
+ @interval = nil
289
+ @multiplier = nil
290
+ @ending = nil
291
+ end
292
+
293
+
294
+ ######
295
+ public
296
+ ######
297
+
298
+ # Is the schedule expression parsable?
299
+ attr_reader :valid
300
+
301
+ # Does this event repeat?
302
+ attr_reader :recurring
303
+
304
+ # The valid start time for the schedule (for recurring events)
305
+ attr_reader :starting
306
+
307
+ # The valid end time for the schedule (for recurring events)
308
+ attr_reader :ending
309
+
310
+ # The interval to wait before the event should be acted on.
311
+ attr_reader :interval
312
+
313
+ # An optional interval multipler for expressing counts.
314
+ attr_reader :multiplier
315
+
316
+
317
+ ### If this interval is on a stack somewhere and ready to
318
+ ### fire, is it okay to do so based on the specified
319
+ ### expression criteria?
320
+ ###
321
+ ### Returns +true+ if it should fire, +false+ if it should not
322
+ ### but could at a later attempt, and +nil+ if the interval has
323
+ ### expired.
324
+ ###
325
+ def fire?
326
+ now = Time.now
327
+
328
+ # Interval has expired.
329
+ return nil if self.ending && now > self.ending
330
+
331
+ # Interval is not yet in its current time window.
332
+ return false if self.starting - now > 0
333
+
334
+ # Looking good.
335
+ return true
336
+ end
337
+
338
+
339
+ ### Just return the original event expression.
340
+ ###
341
+ def to_s
342
+ return @exp
343
+ end
344
+
345
+
346
+ ### Inspection string.
347
+ ###
348
+ def inspect
349
+ return ( "<%s:0x%08x valid:%s recur:%s expression:%p " +
350
+ "starting:%p interval:%p ending:%p>" ) % [
351
+ self.class.name,
352
+ self.object_id * 2,
353
+ self.valid,
354
+ self.recurring,
355
+ self.to_s,
356
+ self.starting,
357
+ self.interval,
358
+ self.ending
359
+ ]
360
+ end
361
+
362
+
363
+ ### Comparable interface, order by interval, 'soonest' first.
364
+ ###
365
+ def <=>( other )
366
+ return self.interval <=> other.interval
367
+ end
368
+
369
+
370
+ #########
371
+ protected
372
+ #########
373
+
374
+ ### Given a +start+ and +ending+ scanner position,
375
+ ### return an ascii representation of the data slice.
376
+ ###
377
+ def extract( start, ending )
378
+ slice = @data[ start, ending ]
379
+ return '' unless slice
380
+ return slice.pack( 'c*' )
381
+ end
382
+
383
+
384
+ ### Parse and set the starting attribute, given a +time_arg+
385
+ ### string and the +type+ of string (interval or exact time)
386
+ ###
387
+ def set_starting( time_arg, type )
388
+ @starting_args ||= []
389
+ @starting_args << time_arg
390
+
391
+ # If we already have seen a start time, it's possible the parser
392
+ # was non-deterministic and this action has been executed multiple
393
+ # times. Re-parse the complete date string, overwriting any previous.
394
+ time_arg = @starting_args.join( ' ' )
395
+
396
+ start = self.get_time( time_arg, type )
397
+ @starting = start
398
+
399
+ # If start time is expressed as a post-conditional (we've
400
+ # already got an end time) we need to recalculate the end
401
+ # as an offset from the start. The original parsed ending
402
+ # arguments should have already been cached when it was
403
+ # previously set.
404
+ #
405
+ if self.ending && self.recurring
406
+ self.set_ending( *@ending_args )
407
+ end
408
+
409
+ return @starting
410
+ end
411
+
412
+
413
+ ### Parse and set the interval attribute, given a +time_arg+
414
+ ### string and the +type+ of string (interval or exact time)
415
+ ###
416
+ ### Perform consistency and sanity checks before returning an
417
+ ### integer representing the amount of time needed to sleep before
418
+ ### firing the event.
419
+ ###
420
+ def set_interval( time_arg, type )
421
+ interval = nil
422
+ if self.starting && type == :time
423
+ raise Symphony::Metronome::TimeParseError, "That doesn't make sense, just use 'at [datetime]' instead"
424
+ else
425
+ interval = self.get_time( time_arg, type )
426
+ interval = interval - @base
427
+ end
428
+
429
+ @interval = interval
430
+ return @interval
431
+ end
432
+
433
+
434
+ ### Parse and set the ending attribute, given a +time_arg+
435
+ ### string and the +type+ of string (interval or exact time)
436
+ ###
437
+ ### Perform consistency and sanity checks before returning a
438
+ ### Time object.
439
+ ###
440
+ def set_ending( time_arg, type )
441
+ ending = nil
442
+
443
+ # Ending dates only make sense for recurring events.
444
+ #
445
+ if self.recurring
446
+ @ending_args = [ time_arg, type ] # squirrel away for post-set starts
447
+
448
+ # Make the interval an offset of the start time, instead of now.
449
+ #
450
+ # This is the contextual difference between:
451
+ # every minute until 6 hours from now (ending based on NOW)
452
+ # and
453
+ # starting in a year run every minute for 1 month (ending based on start time)
454
+ #
455
+ if self.starting && type == :interval
456
+ diff = self.parse_interval( time_arg )
457
+ ending = self.starting + diff
458
+
459
+ # (offset from now)
460
+ #
461
+ else
462
+ ending = self.get_time( time_arg, type )
463
+ end
464
+
465
+ # Check the end time is after the start time.
466
+ #
467
+ if self.starting && ending < self.starting
468
+ raise Symphony::Metronome::TimeParseError, "recurring event ends before it begins"
469
+ end
470
+
471
+ else
472
+ self.log.debug "Ignoring ending date, event is not recurring."
473
+ end
474
+
475
+ @ending = ending
476
+ return @ending
477
+ end
478
+
479
+
480
+ ### Perform finishing logic and final sanity checks before returning
481
+ ### a parsed object.
482
+ ###
483
+ def finalize
484
+ raise Symphony::Metronome::TimeParseError, "unable to parse expression" unless self.valid
485
+
486
+ # Ensure start time is populated.
487
+ #
488
+ unless self.starting
489
+ if self.recurring
490
+ @starting = @base
491
+ else
492
+ raise Symphony::Metronome::TimeParseError, "non-deterministic expression" if self.interval.nil?
493
+ @starting = @base + self.interval
494
+ end
495
+ end
496
+
497
+ # Alter the interval if a multiplier was specified.
498
+ #
499
+ if self.multiplier
500
+ if self.ending
501
+
502
+ # Regular 'count' style multipler with end date.
503
+ # (run 10 times a minute for 2 days)
504
+ # Just divide the current interval by the count.
505
+ #
506
+ if self.interval
507
+ @interval = self.interval.to_f / self.multiplier
508
+
509
+ # Timeboxed multiplier (start [date] run 10 times end [date])
510
+ # Evenly spread the interval out over the time window.
511
+ #
512
+ else
513
+ diff = self.ending - self.starting
514
+ @interval = diff.to_f / self.multiplier
515
+ end
516
+
517
+ # Regular 'count' style multipler (run 10 times a minute)
518
+ # Just divide the current interval by the count.
519
+ #
520
+ else
521
+ raise Symphony::Metronome::TimeParseError, "An end date or interval is required" unless self.interval
522
+ @interval = self.interval.to_f / self.multiplier
523
+ end
524
+ end
525
+ end
526
+
527
+
528
+ ### Given a +time_arg+ string and a type (:interval or :time),
529
+ ### dispatch to the appropriate parser.
530
+ ###
531
+ def get_time( time_arg, type )
532
+ time = nil
533
+
534
+ if type == :interval
535
+ secs = self.parse_interval( time_arg )
536
+ time = @base + secs if secs
537
+ end
538
+
539
+ if type == :time
540
+ time = self.parse_time( time_arg )
541
+ end
542
+
543
+ raise Symphony::Metronome::TimeParseError, "unable to parse time" if time.nil?
544
+ return time
545
+ end
546
+
547
+
548
+ ### Parse a +time_arg+ string (anything parsable buy Time.parse())
549
+ ### into a Time object.
550
+ ###
551
+ def parse_time( time_arg )
552
+ time = Time.parse( time_arg, @base ) rescue nil
553
+
554
+ # Generated date is in the past.
555
+ #
556
+ if time && @base > time
557
+
558
+ # Ensure future dates for ambiguous times (2pm)
559
+ time = time + 1.day if time_arg.length < 8
560
+
561
+ # Still in the past, abandon all hope.
562
+ raise Symphony::Metronome::TimeParseError, "attempt to schedule in the past" if @base > time
563
+ end
564
+
565
+ self.log.debug "Parsed %p (time) to: %p" % [ time_arg, time ]
566
+ return time
567
+ end
568
+
569
+
570
+ ### Parse a +time_arg+ interval string ("30 seconds") into an
571
+ ### Integer.
572
+ ###
573
+ def parse_interval( interval_arg )
574
+ duration, span = interval_arg.split( /\s+/ )
575
+
576
+ # catch the 'a' or 'an' case (ex: "an hour")
577
+ duration = 1 if duration.index( 'a' ) == 0
578
+
579
+ # catch the 'other' case, ie: 'every other hour'
580
+ duration = 2 if duration == 'other'
581
+
582
+ # catch the singular case (ex: "hour")
583
+ unless span
584
+ span = duration
585
+ duration = 1
586
+ end
587
+
588
+ use_milliseconds = span.sub!( 'milli', '' )
589
+ interval = calculate_seconds( duration.to_f, span.to_sym )
590
+
591
+ # milliseconds
592
+ interval = duration.to_f / 1000 if use_milliseconds
593
+
594
+ self.log.debug "Parsed %p (interval) to: %p" % [ interval_arg, interval ]
595
+ return interval
596
+ end
597
+
598
+ end # class TimeExpression
599
+