symphony-metronome 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+