symphony-metronome 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,130 @@
1
+ # -*- ruby -*-
2
+ #encoding: utf-8
3
+ # vim: set nosta noet ts=4 sw=4:
4
+
5
+ require 'symphony' unless defined?( Symphony )
6
+ require 'symphony/metronome' unless defined?( Symphony::Metronome )
7
+
8
+
9
+ module Symphony::Metronome
10
+
11
+ # Functions for time calculations
12
+ module TimeFunctions
13
+
14
+ ###############
15
+ module_function
16
+ ###############
17
+
18
+ ### Calculate the (approximate) number of seconds that are in +count+ of the
19
+ ### given +unit+ of time.
20
+ ###
21
+ def calculate_seconds( count, unit )
22
+ return case unit
23
+ when :seconds, :second
24
+ count
25
+ when :minutes, :minute
26
+ count * 60
27
+ when :hours, :hour
28
+ count * 3600
29
+ when :days, :day
30
+ count * 86400
31
+ when :weeks, :week
32
+ count * 604800
33
+ when :fortnights, :fortnight
34
+ count * 1209600
35
+ when :months, :month
36
+ count * 2592000
37
+ when :years, :year
38
+ count * 31557600
39
+ else
40
+ raise ArgumentError, "don't know how to calculate seconds in a %p" % [ unit ]
41
+ end
42
+ end
43
+ end # module TimeFunctions
44
+
45
+
46
+ # Refinements to Numeric to add time-related convenience methods
47
+ module TimeRefinements
48
+ refine Numeric do
49
+
50
+ ### Number of seconds (returns receiver unmodified)
51
+ def seconds
52
+ return self
53
+ end
54
+ alias_method :second, :seconds
55
+
56
+ ### Returns number of seconds in <receiver> minutes
57
+ def minutes
58
+ return TimeFunctions.calculate_seconds( self, :minutes )
59
+ end
60
+ alias_method :minute, :minutes
61
+
62
+ ### Returns the number of seconds in <receiver> hours
63
+ def hours
64
+ return TimeFunctions.calculate_seconds( self, :hours )
65
+ end
66
+ alias_method :hour, :hours
67
+
68
+ ### Returns the number of seconds in <receiver> days
69
+ def days
70
+ return TimeFunctions.calculate_seconds( self, :day )
71
+ end
72
+ alias_method :day, :days
73
+
74
+ ### Return the number of seconds in <receiver> weeks
75
+ def weeks
76
+ return TimeFunctions.calculate_seconds( self, :weeks )
77
+ end
78
+ alias_method :week, :weeks
79
+
80
+ ### Returns the number of seconds in <receiver> fortnights
81
+ def fortnights
82
+ return TimeFunctions.calculate_seconds( self, :fortnights )
83
+ end
84
+ alias_method :fortnight, :fortnights
85
+
86
+ ### Returns the number of seconds in <receiver> months (approximate)
87
+ def months
88
+ return TimeFunctions.calculate_seconds( self, :months )
89
+ end
90
+ alias_method :month, :months
91
+
92
+ ### Returns the number of seconds in <receiver> years (approximate)
93
+ def years
94
+ return TimeFunctions.calculate_seconds( self, :years )
95
+ end
96
+ alias_method :year, :years
97
+
98
+
99
+ ### Returns the Time <receiver> number of seconds before the
100
+ ### specified +time+. E.g., 2.hours.before( header.expiration )
101
+ def before( time )
102
+ return time - self
103
+ end
104
+
105
+
106
+ ### Returns the Time <receiver> number of seconds ago. (e.g.,
107
+ ### expiration > 2.hours.ago )
108
+ def ago
109
+ return self.before( ::Time.now )
110
+ end
111
+
112
+
113
+ ### Returns the Time <receiver> number of seconds after the given +time+.
114
+ ### E.g., 10.minutes.after( header.expiration )
115
+ def after( time )
116
+ return time + self
117
+ end
118
+
119
+
120
+ ### Return a new Time <receiver> number of seconds from now.
121
+ def from_now
122
+ return self.after( ::Time.now )
123
+ end
124
+
125
+ end # refine Numeric
126
+ end # module TimeRefinements
127
+
128
+ end # module Symphony::Metronome
129
+
130
+
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env ruby
2
+ # vim: set nosta noet ts=4 sw=4:
3
+
4
+ require 'set'
5
+ require 'sequel'
6
+ require 'sqlite3'
7
+ require 'yajl'
8
+ require 'symphony/metronome'
9
+
10
+ Sequel.extension :migration
11
+
12
+
13
+ ### A class the represents the relationship between an interval and
14
+ ### an event.
15
+ ###
16
+ class Symphony::Metronome::ScheduledEvent
17
+ extend Loggability, Configurability
18
+ include Comparable
19
+
20
+ log_to :symphony
21
+ config_key :metronome
22
+
23
+
24
+ # Configure defaults.
25
+ #
26
+ CONFIG_DEFAULTS = {
27
+ db: 'sqlite:///tmp/metronome.db',
28
+ splay: 0
29
+ }
30
+
31
+ class << self
32
+ # A Sequel-style DB connection URI.
33
+ attr_reader :db
34
+
35
+ # Adjust recurring intervals by a random window.
36
+ attr_reader :splay
37
+ end
38
+
39
+
40
+ ######################################################################
41
+ # C L A S S M E T H O D S
42
+ ######################################################################
43
+
44
+ ### Configurability API.
45
+ ###
46
+ def self::configure( config=nil )
47
+ config = self.defaults.merge( config || {} )
48
+ @db = Sequel.connect( config.delete(:db) )
49
+ @splay = config.delete( :splay )
50
+
51
+ # Ensure the database is current.
52
+ #
53
+ migrations_dir = Symphony::Metronome::DATADIR + 'migrations'
54
+ unless Sequel::Migrator.is_current?( self.db, migrations_dir.to_s )
55
+ Sequel::Migrator.apply( self.db, migrations_dir.to_s )
56
+ end
57
+ end
58
+
59
+
60
+ ### Return a set of all known events, sorted by date of execution.
61
+ ### Delete any rows that are invalid expressions.
62
+ ###
63
+ def self::load
64
+ now = Time.now
65
+ events = SortedSet.new
66
+
67
+ # Force reset the DB handle.
68
+ self.db.disconnect
69
+
70
+ self.log.debug "Parsing/loading all actions."
71
+ self.db[ :metronome ].each do |event|
72
+ begin
73
+ event = new( event )
74
+ events << event
75
+ rescue ArgumentError, Symphony::Metronome::TimeParseError => err
76
+ self.log.error "%p while parsing \"%s\": %s" % [
77
+ err.class,
78
+ event[:expression],
79
+ err.message
80
+ ]
81
+ self.log.debug " " + err.backtrace.join( "\n " )
82
+ self.db[ :metronome ].filter( :id => event[:id] ).delete
83
+ end
84
+ end
85
+
86
+ return events
87
+ end
88
+
89
+
90
+ ######################################################################
91
+ # I N S T A N C E M E T H O D S
92
+ ######################################################################
93
+
94
+ ### Create a new ScheduledEvent object.
95
+ ###
96
+ def initialize( row )
97
+ @event = Symphony::Metronome::IntervalExpression.parse( row[:expression], row[:created] )
98
+ @options = row.delete( :options )
99
+ @id = row.delete( :id )
100
+ self.reset_runtime
101
+
102
+ unless self.class.splay.zero?
103
+ splay = Range.new( - self.class.splay, self.class.splay )
104
+ @runtime = self.runtime + rand( splay )
105
+ end
106
+ end
107
+
108
+ # The parsed interval expression.
109
+ attr_reader :event
110
+
111
+ # The unique ID number of the scheduled event.
112
+ attr_reader :id
113
+
114
+ # The options hash attached to this event.
115
+ attr_reader :options
116
+
117
+ # The exact time that this event will run.
118
+ attr_reader :runtime
119
+
120
+
121
+ ### Set the datetime that this event should fire next.
122
+ ###
123
+ def reset_runtime
124
+ now = Time.now
125
+
126
+ # Start time is in the future, so it's sufficent to be considered the run time.
127
+ #
128
+ if self.event.starting >= now
129
+ @runtime = self.event.starting
130
+ return
131
+ end
132
+
133
+ # Otherwise, the event should already be running (start time has already
134
+ # elapsed), so schedule it forward on it's next interval iteration.
135
+ #
136
+ @runtime = now + self.event.interval
137
+ end
138
+
139
+
140
+ ### Perform the action attached to the event. Yields the
141
+ ### deserialized options, the action ID to the supplied block if
142
+ ### this event is okay to execute.
143
+ ###
144
+ ### Automatically remove the event if it has expired.
145
+ ###
146
+ def fire
147
+ rv = self.event.fire?
148
+
149
+ if rv
150
+ opts = Yajl.load( self.options )
151
+ yield opts, self.id
152
+ end
153
+
154
+ self.delete if rv.nil?
155
+ return rv
156
+ end
157
+
158
+
159
+ ### Permanently remove this event from the database.
160
+ ###
161
+ def delete
162
+ self.log.debug "Removing action %p" % [ self.id ]
163
+ self.class.db[ :metronome ].filter( :id => self.id ).delete
164
+ end
165
+
166
+
167
+ ### Comparable interface, order by next run time, soonest first.
168
+ ###
169
+ def <=>( other )
170
+ return self.runtime <=> other.runtime
171
+ end
172
+
173
+ end # Symphony::Metronome::ScheduledEvent
174
+
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env ruby
2
+ # vim: set nosta noet ts=4 sw=4:
3
+
4
+ require 'symphony'
5
+ require 'symphony/metronome'
6
+
7
+
8
+ ### Manage the delta queue of events and associated actions.
9
+ ###
10
+ class Symphony::Metronome::Scheduler
11
+ extend Loggability, Configurability
12
+ include Symphony::SignalHandling
13
+
14
+ log_to :symphony
15
+ config_key :metronome
16
+
17
+ # Signals the daemon responds to.
18
+ SIGNALS = [ :HUP, :INT, :TERM ]
19
+
20
+ CONFIG_DEFAULTS = {
21
+ :listen => true
22
+ }
23
+
24
+ class << self
25
+ # Should Metronome register and schedule events via AMQP?
26
+ # If +false+, you'll need a separate way to add event actions
27
+ # to the database, and manually HUP the daemon.
28
+ attr_reader :listen
29
+ end
30
+
31
+ ### Configurability API
32
+ ###
33
+ def self::configure( config=nil )
34
+ config = self.defaults.merge( config || {} )
35
+ @listen = config.delete( :listen )
36
+ end
37
+
38
+
39
+ ### Create and start an instanced daemon.
40
+ ###
41
+ def self::run( &block )
42
+ return new( block )
43
+ end
44
+
45
+
46
+ ### Actions to perform when creating a new daemon.
47
+ ###
48
+ private_class_method :new
49
+ def initialize( block ) #:nodoc:
50
+
51
+ # Start the queue subscriber for schedule changes.
52
+ #
53
+ if self.class.listen
54
+ Symphony::Metronome::ScheduledEvent.db.disconnect
55
+ @child = fork do
56
+ $0 = 'Metronome (listener)'
57
+ Symphony::Metronome::ScheduleTask.run
58
+ end
59
+ Process.setpgid( @child, 0 )
60
+ end
61
+
62
+ # Signal handling for the master (this) process.
63
+ #
64
+ self.set_up_signal_handling
65
+ self.set_signal_traps( *SIGNALS )
66
+
67
+ @queue = Symphony::Metronome::ScheduledEvent.load
68
+ @proc = block
69
+
70
+ # Enter the main loop.
71
+ self.start
72
+
73
+ rescue => err
74
+ self.log.error "%p while running: %s" % [ err.class, err.message ]
75
+ self.log.debug " " + err.backtrace.join( "\n " )
76
+ Process.kill( 'TERM', @child ) if self.class.listen
77
+ end
78
+
79
+
80
+ # The sorted set of ScheduledEvent objects.
81
+ attr_reader :queue
82
+
83
+
84
+ #########
85
+ protected
86
+ #########
87
+
88
+ ### Main daemon sleep loop.
89
+ ###
90
+ def start
91
+ $0 = "Metronome%s" % [ self.class.listen ? ' (executor)' : '' ]
92
+ @running = true
93
+
94
+ loop do
95
+ wait = nil
96
+
97
+ if ev = self.queue.first
98
+ wait = ev.runtime - Time.now
99
+ wait = 0 if wait < 0
100
+ self.log.info "Next event in %0.3f second(s) (id: %d)..." % [ wait, ev.id ]
101
+ else
102
+ self.log.warn "No events scheduled. Waiting indefinitely..."
103
+ end
104
+
105
+ self.process_events unless self.wait_for_signals( wait )
106
+ break unless @running
107
+ end
108
+ end
109
+
110
+
111
+ ### Dispatch incoming signals to appropriate handlers.
112
+ ###
113
+ def handle_signal( sig )
114
+ case sig
115
+ when :TERM, :INT
116
+ @running = false
117
+ Process.kill( sig.to_s, @child ) if self.class.listen
118
+
119
+ when :HUP
120
+ @queue = Symphony::Metronome::ScheduledEvent.load
121
+ self.queue.each{|ev| ev.fire(&@proc) if ev.event.recurring }
122
+
123
+ else
124
+ self.log.debug "Unhandled signal: %s" % [ sig ]
125
+ end
126
+ end
127
+
128
+
129
+ ### Process all event that have reached their runtime.
130
+ ###
131
+ def process_events
132
+ now = Time.now
133
+
134
+ self.queue.each do |ev|
135
+ next unless now >= ev.runtime
136
+
137
+ self.queue.delete( ev )
138
+ rv = ev.fire( &@proc )
139
+
140
+ # Reschedule the event and place it back on the queue.
141
+ #
142
+ if ev.event.recurring
143
+ ev.reset_runtime
144
+ self.queue.add( ev ) unless rv.nil?
145
+
146
+ # It was a single run event, torch it!
147
+ #
148
+ else
149
+ ev.delete
150
+
151
+ end
152
+ end
153
+ end
154
+
155
+ end # Symphony::Metronome::Scheduler
156
+