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