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.
- checksums.yaml +7 -0
- data/README.rdoc +198 -0
- data/bin/metronome-exp +33 -0
- data/data/symphony-metronome/migrations/20140419_initial.rb +32 -0
- data/lib/symphony/metronome.rb +79 -0
- data/lib/symphony/metronome/intervalexpression.rb +2518 -0
- data/lib/symphony/metronome/mixins.rb +130 -0
- data/lib/symphony/metronome/scheduledevent.rb +174 -0
- data/lib/symphony/metronome/scheduler.rb +156 -0
- data/lib/symphony/tasks/scheduletask.rb +81 -0
- metadata +141 -0
@@ -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
|
+
|