procrastinator 0.6.1 → 0.9.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.
@@ -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