Add SchedulerAgent, which periodically runs other agents.

Akinori MUSHA 10 年 前
コミット
e9fa1f2032

+ 89 - 0
app/models/agents/scheduler_agent.rb

@@ -0,0 +1,89 @@
1
+require 'rufus-scheduler'
2
+
3
+module Agents
4
+  class SchedulerAgent < Agent
5
+    cannot_be_scheduled!
6
+    cannot_receive_events!
7
+    cannot_create_events!
8
+    can_run_other_agents!
9
+
10
+    description <<-MD
11
+      This agent periodically triggers a run of each target Agent according to a user-defined schedule.
12
+
13
+      Select target Agents and set a cron-style schedule to `schedule`.
14
+      In the traditional cron format, a schedule part consists of these five columns: `minute hour day-of-month month day-of-week`.
15
+
16
+      * `0 22 * * 1-5`: every day of the week at 22:00 (10pm)
17
+
18
+      In this variant, you can also specify seconds:
19
+
20
+      * `30 0 22 * * 1-5`: every day of the week at 22:00:30
21
+
22
+      And timezones:
23
+
24
+      * `0 22 * * 1-5 Europe/Paris`: every day of the week when it's 22:00 in Paris
25
+
26
+      * `0 22 * * 1-5 Etc/GMT+2`: every day of the week when it's 22:00 in GMT+2
27
+
28
+      There's also a way to specify "last day of month":
29
+
30
+      * `0 22 L * *`: every month on the last day at 22:00
31
+
32
+      And "monthdays":
33
+
34
+      * `0 22 * * sun#1,sun#2`: every first and second sunday of the month, at 22:00
35
+
36
+      * `0 22 * * sun#L1`: every last sunday of the month, at 22:00
37
+    MD
38
+
39
+    def default_options
40
+      { 'schedule' => '0 * * * *' }
41
+    end
42
+
43
+    def working?
44
+      true
45
+    end
46
+
47
+    def check!
48
+      targets.active.each { |target|
49
+        log "Agent run queued for '#{target.name}'"
50
+        Agent.async_check(target.id)
51
+      }
52
+    end
53
+
54
+    def validate_options
55
+      if (spec = options['schedule']).present?
56
+        begin
57
+          Rufus::Scheduler::CronLine.new(spec)
58
+        rescue ArgumentError
59
+          errors.add(:base, "invalid schedule")
60
+        end
61
+      else
62
+        errors.add(:base, "schedule is missing")
63
+      end
64
+    end
65
+
66
+    before_save do
67
+      self.memory.delete('scheduled_at') if self.options_changed?
68
+    end
69
+
70
+    def scheduler_tag
71
+      '%s#%d' % [self.class.name, id]
72
+    end
73
+
74
+    class << self
75
+      def scheduler_tag_to_id(tag)
76
+        case tag
77
+        when /\A#{Regexp.quote(self.name)}\#(\d+)\z/o
78
+          $1.to_i
79
+        end
80
+      end
81
+
82
+      def from_scheduler_tag(tag)
83
+        if id = scheduler_tag_to_id
84
+          find_by(id: id)
85
+        end
86
+      end
87
+    end
88
+  end
89
+end

+ 14 - 0
app/views/agents/_form.html.erb

@@ -40,6 +40,20 @@
40 40
             </div>
41 41
           </div>
42 42
 
43
+          <div class="schedule-region" data-can-be-scheduled="<%= @agent.can_be_scheduled? %>">
44
+            <div class="can-be-scheduled">
45
+              <div class="form-group">
46
+                <%= f.label :runners %>
47
+                <span class="glyphicon glyphicon-question-sign hover-help" data-content="Other than the system-defined schedule above, this agent may be run by user-defined ScheduleAgents."></span>
48
+                <% eventRunners = current_user.agents.select(&:can_run_other_agents?) %>
49
+                <%= f.select(:runner_ids,
50
+                               options_for_select(eventRunners.map {|s| [s.name, s.id] },
51
+                                                  @agent.runner_ids),
52
+                               {}, { multiple: true, size: 5, class: 'select2 form-control' }) %>
53
+              </div>
54
+            </div>
55
+          </div>
56
+
43 57
           <div class="chain-region" data-can-run-other-agents="<%= @agent.can_run_other_agents? %>">
44 58
             <div class="can-run-other-agents">
45 59
               <div class="form-group">

+ 1 - 1
app/views/agents/_table.html.erb

@@ -25,7 +25,7 @@
25 25
         </td>
26 26
         <td class='<%= "agent-disabled" if agent.disabled? %>'>
27 27
           <% if agent.can_be_scheduled? %>
28
-            <%= agent.schedule.to_s.humanize.titleize %>
28
+            <%= agent_schedule(agent, ',<br/>') %>
29 29
           <% else %>
30 30
             <span class='not-applicable'></span>
31 31
           <% end %>

+ 1 - 1
app/views/agents/show.html.erb

@@ -72,7 +72,7 @@
72 72
             <% if @agent.can_be_scheduled? %>
73 73
               <p>
74 74
                 <b>Schedule:</b>
75
-                <%= (@agent.schedule || "n/a").humanize.titleize %>
75
+                <%= agent_schedule(@agent) %>
76 76
               </p>
77 77
 
78 78
               <p>

+ 88 - 0
lib/huginn_scheduler.rb

@@ -1,5 +1,87 @@
1 1
 require 'rufus/scheduler'
2 2
 
3
+class Rufus::Scheduler
4
+  SCHEDULER_AGENT_TAG = Agents::SchedulerAgent.name
5
+
6
+  class Job
7
+    # Extract an ID of SchedulerAgent if a matching tag is found.
8
+    def scheduler_agent_id
9
+      tags.each { |tag|
10
+        if agent_id = Agents::SchedulerAgent.scheduler_tag_to_id(tag)
11
+          return agent_id
12
+        end
13
+      }
14
+    end
15
+
16
+    # Return a SchedulerAgent tied to this job.  Return nil if it is
17
+    # not found or disabled.
18
+    def scheduler_agent
19
+      agent_id = scheduler_agent_id or return nil
20
+
21
+      Agent.of_type(Agents::SchedulerAgent).active.find_by(id: agent_id)
22
+    end
23
+  end
24
+
25
+  # Get all jobs tied to any SchedulerAgent
26
+  def scheduler_agent_jobs
27
+    jobs(tag: SCHEDULER_AGENT_TAG)
28
+  end
29
+
30
+  # Get a job tied to a given SchedulerAgent
31
+  def scheduler_agent_job(agent)
32
+    jobs(tags: [SCHEDULER_AGENT_TAG, agent.scheduler_tag]).first
33
+  end
34
+
35
+  # Schedule or reschedule a job for a given SchedulerAgent and return
36
+  # the running job.  Return nil if unscheduled.
37
+  def schedule_scheduler_agent(agent)
38
+    job = scheduler_agent_job(agent)
39
+
40
+    if agent.disabled?
41
+      if job
42
+        puts "Unscheduling SchedulerAgent##{agent.id} (disabled)"
43
+        job.unschedule
44
+      end
45
+      nil
46
+    else
47
+      if job
48
+        return job if agent.memory['scheduled_at'] == job.scheduled_at.to_i
49
+        puts "Rescheduling SchedulerAgent##{agent.id}"
50
+        job.unschedule
51
+      else
52
+        puts "Scheduling SchedulerAgent##{agent.id}"
53
+      end
54
+
55
+      job = schedule_cron agent.options['schedule'], tags: [SCHEDULER_AGENT_TAG, agent.scheduler_tag] do |job|
56
+        if scheduler_agent = job.scheduler_agent
57
+          scheduler_agent.check!
58
+        else
59
+          puts "Unscheduling SchedulerAgent##{job.scheduler_agent_id} (disabled or deleted)"
60
+          job.unschedule
61
+        end
62
+      end
63
+
64
+      agent.memory['scheduled_at'] = job.scheduled_at.to_i
65
+      agent.save
66
+
67
+      job
68
+    end
69
+  end
70
+
71
+  # Schedule or reschedule jobs for all SchedulerAgents and unschedule
72
+  # orphaned jobs if any.
73
+  def schedule_scheduler_agents
74
+    scheduled_jobs = Agent.of_type(Agents::SchedulerAgent).map { |scheduler_agent|
75
+      schedule_scheduler_agent(scheduler_agent)
76
+    }.compact
77
+
78
+    (scheduler_agent_jobs - scheduled_jobs).each { |job|
79
+      puts "Unscheduling SchedulerAgent##{job.scheduler_agent_id} (orphaned)"
80
+      job.unschedule
81
+    }
82
+  end
83
+end
84
+
3 85
 class HuginnScheduler
4 86
   attr_accessor :mutex
5 87
 
@@ -82,6 +164,12 @@ class HuginnScheduler
82 164
       end
83 165
     end
84 166
 
167
+    # Schedule Scheduler Agents
168
+
169
+    @rufus_scheduler.every '1m' do
170
+      @rufus_scheduler.schedule_scheduler_agents
171
+    end
172
+
85 173
     @rufus_scheduler.join
86 174
   end
87 175
 end

+ 54 - 0
spec/models/agents/scheduler_agent_spec.rb

@@ -0,0 +1,54 @@
1
+require 'spec_helper'
2
+
3
+describe Agents::SchedulerAgent do
4
+  before do
5
+    @agent = Agents::SchedulerAgent.new(name: 'Example', options: { 'schedule' => '0 * * * *' })
6
+    @agent.user = users(:bob)
7
+  end
8
+
9
+  describe "validation" do
10
+    it "should validate schedule" do
11
+      @agent.should be_valid
12
+
13
+      @agent.options.delete('schedule')
14
+      @agent.should_not be_valid
15
+
16
+      @agent.options['schedule'] = nil
17
+      @agent.should_not be_valid
18
+
19
+      @agent.options['schedule'] = ''
20
+      @agent.should_not be_valid
21
+
22
+      @agent.options['schedule'] = '0'
23
+      @agent.should_not be_valid
24
+
25
+      @agent.options['schedule'] = '*/15 * * * * * *'
26
+      @agent.should_not be_valid
27
+
28
+      @agent.options['schedule'] = '*/15 * * * * *'
29
+      @agent.should be_valid
30
+
31
+      @agent.options['schedule'] = '*/1 * * * *'
32
+      @agent.should be_valid
33
+
34
+      @agent.options['schedule'] = '*/1 * * *'
35
+      @agent.should_not be_valid
36
+    end
37
+  end
38
+
39
+  describe "check!" do
40
+    it "should run targets" do
41
+      targets = [agents(:bob_website_agent), agents(:bob_weather_agent)]
42
+      @agent.targets = targets
43
+      @agent.save!
44
+
45
+      target_ids = targets.map(&:id)
46
+      stub(Agent).async_check(anything) { |id|
47
+        target_ids.delete(id)
48
+      }
49
+
50
+      @agent.check!
51
+      target_ids.should be_empty
52
+    end
53
+  end
54
+end