procrastinator 0.6.1 → 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,120 +1,100 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'yaml'
4
+ require 'ostruct'
5
+ require 'timeout'
2
6
 
3
7
  module Procrastinator
8
+ # Works on a given task by creating a new instance of the queue's task class and running the appropriate hooks.
9
+ #
10
+ # The behaviour outside of the actual user-defined task is guided by the provided metadata.
11
+ #
12
+ # @author Robin Miller
13
+ #
14
+ # @see TaskMetaData
4
15
  class TaskWorker
5
- attr_reader :id, :run_at, :initial_run_at, :expire_at, :task, :attempts, :last_fail_at, :last_error
6
-
7
- def initialize(id: nil,
8
- run_at: nil,
9
- initial_run_at: nil,
10
- expire_at: nil,
11
- attempts: 0,
12
- timeout: nil,
13
- max_attempts: nil,
14
- last_fail_at: nil,
15
- last_error: nil,
16
- task:,
17
- logger: Logger.new(StringIO.new))
18
- @id = id
19
- @run_at = run_at.nil? ? nil : run_at.to_i
20
- @initial_run_at = initial_run_at.to_i
21
- @expire_at = expire_at.nil? ? nil : expire_at.to_i
22
- @task = YAML.load(task)
23
- @attempts = attempts || 0
24
- @max_attempts = max_attempts
25
- @timeout = timeout
26
- @last_fail_at = last_fail_at
27
- @last_error = last_error
28
- @logger = logger
29
-
30
- raise(MalformedTaskError.new('given task does not support #run method')) unless @task.respond_to? :run
31
- raise(ArgumentError.new('timeout cannot be negative')) if timeout && timeout < 0
16
+ extend Forwardable
17
+
18
+ def_delegators :@metadata,
19
+ :id, :run_at, :initial_run_at, :expire_at,
20
+ :attempts, :last_fail_at, :last_error,
21
+ :data,
22
+ :to_h, :successful?
23
+
24
+ def initialize(metadata:,
25
+ queue:,
26
+ logger: Logger.new(StringIO.new),
27
+ context: nil,
28
+ scheduler: nil)
29
+ @queue = queue
30
+
31
+ @metadata = metadata
32
+ @task = queue.task_class.new
33
+
34
+ @task.data = @metadata.data if @task.respond_to?(:data=)
35
+ @task.context = context if @task.respond_to?(:context=)
36
+ @task.logger = logger if @task.respond_to?(:logger=)
37
+ @task.scheduler = scheduler if @task.respond_to?(:scheduler=)
38
+
39
+ @logger = logger
40
+ @context = context
41
+
42
+ raise MalformedTaskError, "task #{ @task.class } does not support #run method" unless @task.respond_to? :run
32
43
  end
33
44
 
34
45
  def work
35
- @attempts += 1
46
+ @metadata.add_attempt
36
47
 
37
48
  begin
38
- raise(TaskExpiredError.new("task is over its expiry time of #{@expire_at}")) if expired?
49
+ @metadata.verify_expiry!
39
50
 
40
- Timeout::timeout(@timeout) do
51
+ result = Timeout.timeout(@queue.timeout) do
41
52
  @task.run
42
53
  end
43
54
 
44
- try_hook(:success, @logger)
55
+ @logger&.debug("Task completed: #{ @task.class } [#{ @metadata.serialized_data }]")
45
56
 
46
- @logger.debug("Task completed: #{YAML.dump(@task)}")
57
+ @metadata.clear_fails
47
58
 
48
- @last_error = nil
49
- @last_fail_at = nil
50
- rescue StandardError => e
51
- @last_fail_at = Time.now.to_i
52
-
53
- if too_many_fails? || expired?
54
- try_hook(:final_fail, @logger, e)
55
-
56
- @run_at = nil
57
- @last_error = "#{expired? ? 'Task expired' : 'Task failed too many times'}: #{e.backtrace.join("\n")}"
58
-
59
- @logger.debug("Task failed permanently: #{YAML.dump(@task)}")
59
+ try_hook(:success, result)
60
+ rescue StandardError => error
61
+ if @metadata.final_fail?(@queue)
62
+ handle_final_failure(error)
60
63
  else
61
- try_hook(:fail, @logger, e)
62
-
63
- @last_error = %Q[Task failed: #{e.message}\n#{e.backtrace.join("\n")}]
64
- @logger.debug("Task failed: #{YAML.dump(@task)}")
65
-
66
- reschedule
64
+ handle_failure(error)
67
65
  end
68
66
  end
69
67
  end
70
68
 
71
- def successful?
72
- if !expired? && @attempts <= 0
73
- raise(RuntimeError, 'you cannot check for success before running #work')
74
- end
69
+ private
75
70
 
76
- @last_error.nil? && @last_fail_at.nil?
71
+ def try_hook(method, *params)
72
+ @task.send(method, *params) if @task.respond_to? method
73
+ rescue StandardError => e
74
+ warn "#{ method.to_s.capitalize } hook error: #{ e.message }"
77
75
  end
78
76
 
79
- def too_many_fails?
80
- !@max_attempts.nil? && @attempts >= @max_attempts
81
- end
77
+ def handle_failure(error)
78
+ @metadata.fail(%[Task failed: #{ error.message }\n#{ error.backtrace.join("\n") }])
79
+ @logger&.debug("Task failed: #{ @queue.name } with #{ @metadata.serialized_data }")
82
80
 
83
- def expired?
84
- !@expire_at.nil? && Time.now.to_i > @expire_at
85
- end
81
+ @metadata.reschedule
86
82
 
87
- def to_hash
88
- {id: @id,
89
- run_at: @run_at,
90
- initial_run_at: @initial_run_at,
91
- expire_at: @expire_at,
92
- attempts: @attempts,
93
- last_fail_at: @last_fail_at,
94
- last_error: @last_error,
95
- task: YAML.dump(@task)}
83
+ try_hook(:fail, error)
96
84
  end
97
85
 
98
- private
99
- def try_hook(method, *params)
100
- begin
101
- @task.send(method, *params) if @task.respond_to? method
102
- rescue StandardError => e
103
- $stderr.puts "#{method.to_s.capitalize} hook error: #{e.message}"
104
- end
105
- end
86
+ def handle_final_failure(error)
87
+ trace = error.backtrace.join("\n")
88
+ msg = "#{ @metadata.expired? ? 'Task expired' : 'Task failed too many times' }: #{ trace }"
106
89
 
107
- def reschedule
108
- # (30 + n_attempts^4) seconds is chosen to rapidly expand
109
- # but with the baseline of 30s to avoid hitting the disc too frequently.
90
+ @metadata.fail(msg, final: true)
110
91
 
111
- @run_at += 30 + (@attempts**4)
112
- end
113
- end
92
+ @logger&.debug("Task failed permanently: #{ YAML.dump(@task) }")
114
93
 
115
- class TaskExpiredError < StandardError
94
+ try_hook(:final_fail, error)
95
+ end
116
96
  end
117
97
 
118
98
  class MalformedTaskError < StandardError
119
99
  end
120
- end
100
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Procrastinator
2
- VERSION = '0.6.1'
4
+ VERSION = '0.9.0'
3
5
  end
@@ -0,0 +1,34 @@
1
+ require 'rake'
2
+ require 'pathname'
3
+
4
+ namespace :procrastinator do
5
+ desc 'Halt all Procrastinator processes'
6
+ task :stop, [:pid_dir] do |task, args|
7
+ pid_dir = args[:pid_dir] || Procrastinator::Config::DEFAULT_PID_DIRECTORY
8
+
9
+ if !pid_dir.exist? || pid_dir.empty?
10
+ raise <<~ERR
11
+ Default PID directory does not exist or is empty. Run:
12
+ rake procrastinator:stop[directory]
13
+ with the directory to search for pid files.
14
+ ERR
15
+ end
16
+
17
+ pid_dir.each_child do |file|
18
+ pid = file.read.to_i
19
+
20
+ begin
21
+ name = `ps -p #{pid} -o command`
22
+ print "Halting worker process #{name} (pid: #{ pid })... "
23
+ Process.kill('KILL', pid)
24
+ puts 'halted'
25
+ rescue Errno::ESRCH
26
+ warn "Expected worker process pid=#{ pid }, but none was found. Continuing."
27
+ end
28
+
29
+ file.delete
30
+ end
31
+
32
+ puts '<= procrastinator:stop executed'
33
+ end
34
+ end
@@ -1,5 +1,6 @@
1
- # coding: utf-8
2
- lib = File.expand_path('../lib', __FILE__)
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
3
4
  $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
5
  require 'procrastinator/version'
5
6
 
@@ -9,8 +10,8 @@ Gem::Specification.new do |spec|
9
10
  spec.authors = ['Robin Miller']
10
11
  spec.email = ['robin@tenjin.ca']
11
12
 
12
- spec.summary = %q{Delayed task queues made simple.}
13
- spec.description = %q{A strightforward job queue that is not dependent on Rails or any particular database or persistence mechanism.}
13
+ spec.summary = 'For apps that put work off until later'
14
+ spec.description = 'A straightforward, customizable Ruby job queue with zero dependencies.'
14
15
  spec.homepage = 'https://github.com/TenjinInc/procrastinator'
15
16
  spec.license = 'MIT'
16
17
 
@@ -19,12 +20,13 @@ Gem::Specification.new do |spec|
19
20
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
20
21
  spec.require_paths = ['lib']
21
22
 
22
- spec.required_ruby_version = '> 2.0'
23
+ spec.required_ruby_version = '>= 2.3'
23
24
 
24
25
  spec.add_development_dependency 'bundler', '~> 1.11'
25
- spec.add_development_dependency 'rake', '~> 10.0'
26
- spec.add_development_dependency 'rspec', '~> 3.0'
27
- spec.add_development_dependency 'timecop', '~> 0.8'
28
- spec.add_development_dependency 'simplecov', '~> 0.11'
29
26
  spec.add_development_dependency 'fakefs', '~> 0.10'
27
+ spec.add_development_dependency 'rake', '~> 12.3'
28
+ spec.add_development_dependency 'rspec', '~> 3.0'
29
+ spec.add_development_dependency 'rubocop', '~> 0.58'
30
+ spec.add_development_dependency 'simplecov', '~> 0.16.1'
31
+ spec.add_development_dependency 'timecop', '~> 0.9'
30
32
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: procrastinator
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robin Miller
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2016-12-01 00:00:00.000000000 Z
11
+ date: 2018-07-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -24,20 +24,34 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '1.11'
27
+ - !ruby/object:Gem::Dependency
28
+ name: fakefs
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.10'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.10'
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: rake
29
43
  requirement: !ruby/object:Gem::Requirement
30
44
  requirements:
31
45
  - - "~>"
32
46
  - !ruby/object:Gem::Version
33
- version: '10.0'
47
+ version: '12.3'
34
48
  type: :development
35
49
  prerelease: false
36
50
  version_requirements: !ruby/object:Gem::Requirement
37
51
  requirements:
38
52
  - - "~>"
39
53
  - !ruby/object:Gem::Version
40
- version: '10.0'
54
+ version: '12.3'
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: rspec
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -53,49 +67,48 @@ dependencies:
53
67
  - !ruby/object:Gem::Version
54
68
  version: '3.0'
55
69
  - !ruby/object:Gem::Dependency
56
- name: timecop
70
+ name: rubocop
57
71
  requirement: !ruby/object:Gem::Requirement
58
72
  requirements:
59
73
  - - "~>"
60
74
  - !ruby/object:Gem::Version
61
- version: '0.8'
75
+ version: '0.58'
62
76
  type: :development
63
77
  prerelease: false
64
78
  version_requirements: !ruby/object:Gem::Requirement
65
79
  requirements:
66
80
  - - "~>"
67
81
  - !ruby/object:Gem::Version
68
- version: '0.8'
82
+ version: '0.58'
69
83
  - !ruby/object:Gem::Dependency
70
84
  name: simplecov
71
85
  requirement: !ruby/object:Gem::Requirement
72
86
  requirements:
73
87
  - - "~>"
74
88
  - !ruby/object:Gem::Version
75
- version: '0.11'
89
+ version: 0.16.1
76
90
  type: :development
77
91
  prerelease: false
78
92
  version_requirements: !ruby/object:Gem::Requirement
79
93
  requirements:
80
94
  - - "~>"
81
95
  - !ruby/object:Gem::Version
82
- version: '0.11'
96
+ version: 0.16.1
83
97
  - !ruby/object:Gem::Dependency
84
- name: fakefs
98
+ name: timecop
85
99
  requirement: !ruby/object:Gem::Requirement
86
100
  requirements:
87
101
  - - "~>"
88
102
  - !ruby/object:Gem::Version
89
- version: '0.10'
103
+ version: '0.9'
90
104
  type: :development
91
105
  prerelease: false
92
106
  version_requirements: !ruby/object:Gem::Requirement
93
107
  requirements:
94
108
  - - "~>"
95
109
  - !ruby/object:Gem::Version
96
- version: '0.10'
97
- description: A strightforward job queue that is not dependent on Rails or any particular
98
- database or persistence mechanism.
110
+ version: '0.9'
111
+ description: A straightforward, customizable Ruby job queue with zero dependencies.
99
112
  email:
100
113
  - robin@tenjin.ca
101
114
  executables: []
@@ -104,6 +117,7 @@ extra_rdoc_files: []
104
117
  files:
105
118
  - ".gitignore"
106
119
  - ".rspec"
120
+ - ".rubocop.yml"
107
121
  - ".ruby-version"
108
122
  - ".travis.yml"
109
123
  - CODE_OF_CONDUCT.md
@@ -114,10 +128,17 @@ files:
114
128
  - bin/console
115
129
  - bin/setup
116
130
  - lib/procrastinator.rb
117
- - lib/procrastinator/environment.rb
131
+ - lib/procrastinator/config.rb
132
+ - lib/procrastinator/loaders/csv_loader.rb
133
+ - lib/procrastinator/queue.rb
134
+ - lib/procrastinator/queue_manager.rb
118
135
  - lib/procrastinator/queue_worker.rb
136
+ - lib/procrastinator/scheduler.rb
137
+ - lib/procrastinator/task.rb
138
+ - lib/procrastinator/task_meta_data.rb
119
139
  - lib/procrastinator/task_worker.rb
120
140
  - lib/procrastinator/version.rb
141
+ - lib/rake/procrastinator_task.rb
121
142
  - procrastinator.gemspec
122
143
  homepage: https://github.com/TenjinInc/procrastinator
123
144
  licenses:
@@ -129,9 +150,9 @@ require_paths:
129
150
  - lib
130
151
  required_ruby_version: !ruby/object:Gem::Requirement
131
152
  requirements:
132
- - - ">"
153
+ - - ">="
133
154
  - !ruby/object:Gem::Version
134
- version: '2.0'
155
+ version: '2.3'
135
156
  required_rubygems_version: !ruby/object:Gem::Requirement
136
157
  requirements:
137
158
  - - ">="
@@ -139,8 +160,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
139
160
  version: '0'
140
161
  requirements: []
141
162
  rubyforge_project:
142
- rubygems_version: 2.2.2
163
+ rubygems_version: 2.6.13
143
164
  signing_key:
144
165
  specification_version: 4
145
- summary: Delayed task queues made simple.
166
+ summary: For apps that put work off until later
146
167
  test_files: []
@@ -1,148 +0,0 @@
1
- module Procrastinator
2
- class Environment
3
- attr_reader :persister, :queue_definitions, :queue_workers, :processes, :test_mode
4
-
5
- DEFAULT_LOG_DIRECTORY = 'log/'
6
-
7
- def initialize(test_mode: false)
8
- @test_mode = test_mode
9
- @queue_definitions = {}
10
- @queue_workers = []
11
- @processes = []
12
- @log_dir = DEFAULT_LOG_DIRECTORY
13
- @log_level = Logger::INFO
14
- end
15
-
16
- def persister_factory(&block)
17
- @persister_factory = block
18
-
19
- build_persister
20
- end
21
-
22
- def define_queue(name, properties={})
23
- raise ArgumentError.new('queue name cannot be nil') if name.nil?
24
-
25
- @queue_definitions[name] = properties
26
- end
27
-
28
- def spawn_workers
29
- if @test_mode
30
- @queue_definitions.each do |name, props|
31
- @queue_workers << QueueWorker.new(props.merge(name: name,
32
- persister: @persister))
33
- end
34
- else
35
- @queue_definitions.each do |name, props|
36
- pid = fork do
37
- build_persister
38
- worker = QueueWorker.new(props.merge(name: name,
39
- persister: @persister,
40
- log_dir: @log_dir,
41
- log_level: @log_level))
42
-
43
- Process.setproctitle("#{@process_prefix ? "#{@process_prefix}-" : ''}#{worker.long_name}") # tODO: add an app name prefix
44
-
45
- monitor_parent(worker)
46
-
47
- worker.work
48
- end
49
-
50
- Process.detach(pid) unless pid.nil?
51
- @processes << pid
52
- end
53
- end
54
- end
55
-
56
- def act(*queue_names)
57
- unless @test_mode
58
- raise RuntimeError.new('Procrastinator.act called outside Test Mode. Enable test mode by setting Procrastinator.test_mode = true before running setup')
59
- end
60
-
61
- if queue_names.empty?
62
- @queue_workers.each do |worker|
63
- worker.act
64
- end
65
- else
66
- queue_names.each do |name|
67
- @queue_workers.find { |worker| worker.name == name }.act
68
- end
69
- end
70
- end
71
-
72
- def delay(queue: nil, run_at: Time.now.to_i, expire_at: nil, task:)
73
- raise ArgumentError.new('task may not be nil') if task.nil?
74
- raise MalformedTaskError.new('the provided task does not support #run method') unless task.respond_to? :run
75
-
76
- # We're checking these on init because it's one of those extremely rare cases where you'd want to know
77
- # incredibly early, because of the sub-processing. It's a bit belt-and suspenders, but UX is important for
78
- # devs, too.
79
- [:success, :fail, :final_fail].each do |method_name|
80
- if task.respond_to?(method_name) && task.method(method_name).arity <= 0
81
- raise MalformedTaskError.new("the provided task must accept a parameter to its ##{method_name} method")
82
- end
83
- end
84
-
85
- if queue.nil? && @queue_definitions.size > 1
86
- raise ArgumentError.new("queue must be specified when more than one is registered. Defined queues are: #{queue_definitions.keys.map { |k| ':' + k.to_s }.join(', ')}")
87
- else
88
- queue ||= @queue_definitions.keys.first
89
- raise ArgumentError.new(%Q{there is no "#{queue}" queue registered in this environment}) if @queue_definitions[queue].nil?
90
- end
91
-
92
- @persister.create_task(queue: queue,
93
- run_at: run_at.to_i,
94
- initial_run_at: run_at.to_i,
95
- expire_at: expire_at.nil? ? nil : expire_at.to_i,
96
- task: YAML.dump(task))
97
- end
98
-
99
- def enable_test_mode
100
- @test_mode = true
101
- end
102
-
103
- def log_dir(path)
104
- @log_dir = path
105
- end
106
-
107
- def log_level(lvl)
108
- @log_level = lvl
109
- end
110
-
111
- def process_prefix(prefix)
112
- @process_prefix = prefix
113
- end
114
-
115
- private
116
- def monitor_parent(worker)
117
- parent_pid = Process.ppid
118
-
119
- heartbeat_thread = Thread.new(parent_pid) do |ppid|
120
- loop do
121
- begin
122
- Process.kill(0, ppid) # kill with 0 flag checks if the process exists & has permissions
123
- rescue Errno::ESRCH
124
- worker.log_parent_exit(ppid: ppid, pid: Process.pid)
125
- exit
126
- end
127
-
128
- sleep(5)
129
- end
130
- end
131
-
132
- heartbeat_thread.abort_on_exception = true
133
- end
134
-
135
- def build_persister
136
- @persister = @persister_factory.call
137
-
138
- raise ArgumentError.new('persister cannot be nil') if @persister.nil?
139
-
140
- [:read_tasks, :create_task, :update_task, :delete_task].each do |method|
141
- raise MalformedPersisterError.new("persister must repond to ##{method}") unless @persister.respond_to? method
142
- end
143
- end
144
- end
145
-
146
- class MalformedPersisterError < StandardError
147
- end
148
- end