procrastinator 0.6.1 → 1.0.0.pre.rc2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module Procrastinator
6
+ module TaskStore
7
+ # The general idea is that there may be two threads that need to do these actions on the same file:
8
+ # thread A: read
9
+ # thread B: read
10
+ # thread A/B: write
11
+ # thread A/B: write
12
+ #
13
+ # When this sequence happens, the second file write is based on old information and loses the info from
14
+ # the prior write. Using a global mutex per file path prevents this case.
15
+ #
16
+ # This situation can also occur with multi processing, so file locking is also used for solitary access.
17
+ # File locking is only advisory in some systems, though, so it may only work against other applications
18
+ # that request a lock.
19
+ #
20
+ # @author Robin Miller
21
+ class FileTransaction
22
+ # Holds the mutual exclusion locks for file paths by name
23
+ @file_mutex = {}
24
+
25
+ class << self
26
+ attr_reader :file_mutex
27
+ end
28
+
29
+ def initialize(path)
30
+ @path = ensure_path(path)
31
+ end
32
+
33
+ # Alias for transact(writable: false)
34
+ def read(&block)
35
+ transact(writable: false, &block)
36
+ end
37
+
38
+ # Alias for transact(writable: true)
39
+ def write(&block)
40
+ transact(writable: true, &block)
41
+ end
42
+
43
+ # Completes the given block as an atomic transaction locked using a global mutex table.
44
+ # The block is provided the current file contents.
45
+ # The block's result is written to the file.
46
+ def transact(writable: false)
47
+ semaphore = FileTransaction.file_mutex[@path.to_s] ||= Mutex.new
48
+
49
+ semaphore.synchronize do
50
+ @path.open(writable ? 'r+' : 'r') do |file|
51
+ file.flock(File::LOCK_EX)
52
+
53
+ yield_result = yield(file.read)
54
+ if writable
55
+ file.rewind
56
+ file.write yield_result
57
+ file.truncate(file.pos)
58
+ end
59
+ yield_result
60
+ end
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def ensure_path(path)
67
+ path = Pathname.new path
68
+ unless path.exist?
69
+ path.dirname.mkpath
70
+ FileUtils.touch path
71
+ end
72
+ path
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'csv'
4
+ require 'pathname'
5
+
6
+ module Procrastinator
7
+ module TaskStore
8
+ # Simple Task I/O adapter that writes task information (ie. TaskMetaData attributes) to a CSV file.
9
+ #
10
+ # SimpleCommaStore is not designed for efficiency or large loads (10,000+ tasks).
11
+ #
12
+ # For critical production environments, it is strongly recommended to use a more robust storage mechanism like a
13
+ # proper database.
14
+ #
15
+ # @author Robin Miller
16
+ class SimpleCommaStore
17
+ # ordered
18
+ HEADERS = [:id, :queue, :run_at, :initial_run_at, :expire_at,
19
+ :attempts, :last_fail_at, :last_error, :data].freeze
20
+
21
+ EXT = 'csv'
22
+ DEFAULT_FILE = Pathname.new("procrastinator-tasks.#{ EXT }").freeze
23
+
24
+ TIME_FIELDS = [:run_at, :initial_run_at, :expire_at, :last_fail_at].freeze
25
+
26
+ READ_CONVERTER = proc do |value, field_info|
27
+ if field_info.header == :data
28
+ value
29
+ elsif TIME_FIELDS.include? field_info.header
30
+ value.empty? ? nil : Time.parse(value)
31
+ else
32
+ begin
33
+ Integer(value)
34
+ rescue ArgumentError
35
+ value
36
+ end
37
+ end
38
+ end
39
+
40
+ attr_reader :path
41
+
42
+ def initialize(file_path = DEFAULT_FILE)
43
+ @path = Pathname.new(file_path)
44
+
45
+ if @path.directory? || @path.to_s.end_with?('/')
46
+ @path /= DEFAULT_FILE
47
+ elsif @path.extname.empty?
48
+ @path = @path.dirname / "#{ @path.basename }.csv"
49
+ end
50
+
51
+ @path = @path.expand_path
52
+
53
+ freeze
54
+ end
55
+
56
+ def read(filter = {})
57
+ FileTransaction.new(@path).read do |existing_data|
58
+ parse(existing_data).select do |row|
59
+ filter.keys.all? do |key|
60
+ row[key] == filter[key]
61
+ end
62
+ end
63
+ end
64
+ end
65
+
66
+ # Saves a task to the CSV file.
67
+ #
68
+ # @param queue [String] queue name
69
+ # @param run_at [Time, nil] time to run the task at
70
+ # @param initial_run_at [Time, nil] first time to run the task at. Defaults to run_at.
71
+ # @param expire_at [Time, nil] time to expire the task
72
+ def create(queue:, run_at:, expire_at:, data: '', initial_run_at: nil)
73
+ FileTransaction.new(@path).write do |existing_data|
74
+ tasks = parse(existing_data)
75
+ max_id = tasks.collect { |task| task[:id] }.max || 0
76
+
77
+ new_data = {
78
+ id: max_id + 1,
79
+ queue: queue,
80
+ run_at: run_at,
81
+ initial_run_at: initial_run_at || run_at,
82
+ expire_at: expire_at,
83
+ attempts: 0,
84
+ data: data
85
+ }
86
+
87
+ generate(tasks + [new_data])
88
+ end
89
+ end
90
+
91
+ def update(id, data)
92
+ FileTransaction.new(@path).write do |existing_data|
93
+ tasks = parse(existing_data)
94
+ task_data = tasks.find do |task|
95
+ task[:id] == id
96
+ end
97
+
98
+ task_data&.merge!(data)
99
+
100
+ generate(tasks)
101
+ end
102
+ end
103
+
104
+ def delete(id)
105
+ FileTransaction.new(@path).write do |file_content|
106
+ existing_data = parse(file_content)
107
+ generate(existing_data.reject { |task| task[:id] == id })
108
+ end
109
+ end
110
+
111
+ def generate(data)
112
+ lines = data.collect do |d|
113
+ TIME_FIELDS.each do |field|
114
+ d[field] = d[field]&.iso8601
115
+ end
116
+ CSV.generate_line(d, headers: HEADERS, force_quotes: true).strip
117
+ end
118
+
119
+ lines.unshift(HEADERS.join(','))
120
+
121
+ lines.join("\n") << "\n"
122
+ end
123
+
124
+ private
125
+
126
+ def parse(csv_string)
127
+ data = CSV.parse(csv_string,
128
+ headers: true,
129
+ header_converters: :symbol,
130
+ skip_blanks: true,
131
+ converters: READ_CONVERTER,
132
+ force_quotes: true).to_a
133
+
134
+ headers = data.shift || HEADERS
135
+
136
+ data = data.collect do |d|
137
+ headers.zip(d).to_h
138
+ end
139
+
140
+ correct_types(data)
141
+ end
142
+
143
+ def correct_types(data)
144
+ non_empty_keys = [:run_at, :expire_at, :attempts, :last_fail_at]
145
+
146
+ data.collect do |hash|
147
+ non_empty_keys.each do |key|
148
+ hash.delete(key) if hash[key].is_a?(String) && hash[key].empty?
149
+ end
150
+
151
+ hash[:attempts] ||= 0
152
+
153
+ # hash[:data] = (hash[:data] || '').gsub('""', '"')
154
+ hash[:queue] = hash[:queue].to_sym
155
+
156
+ hash
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Procrastinator
4
+ module Test
5
+ # Testing mock Task class
6
+ #
7
+ # You can use this like:
8
+ #
9
+ # require 'procrastinator/rspec/mocks'
10
+ # # ...
11
+ # Procrastinator.config do |c|
12
+ # c.define_queue :test_queue, Procrastinator::RSpec::MockTask
13
+ # end
14
+ #
15
+ # @see MockDataTask for data-accepting tasks
16
+ class MockTask
17
+ attr_accessor :container, :logger, :scheduler
18
+
19
+ def run
20
+ @run = true
21
+ end
22
+
23
+ def run?
24
+ @run
25
+ end
26
+ end
27
+
28
+ # Data-accepting MockTask
29
+ #
30
+ # @see MockTask
31
+ class MockDataTask < MockTask
32
+ attr_accessor :data
33
+ end
34
+ end
35
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Procrastinator
2
- VERSION = '0.6.1'
4
+ VERSION = '1.0.0-rc2'
3
5
  end
@@ -1,32 +1,38 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'procrastinator/version'
4
+ require 'procrastinator/task_meta_data'
5
+ require 'procrastinator/logged_task'
6
+ require 'procrastinator/queue'
2
7
  require 'procrastinator/queue_worker'
3
- require 'procrastinator/task_worker'
4
- require 'procrastinator/environment'
5
- require 'logger'
6
-
8
+ require 'procrastinator/config'
9
+ require 'procrastinator/task'
10
+ require 'procrastinator/scheduler'
11
+ require 'procrastinator/task_store/file_transaction'
12
+ require 'procrastinator/task_store/simple_comma_store'
7
13
 
14
+ require 'logger'
15
+ require 'pathname'
16
+
17
+ # Top-level module for the Procrastinator Gem.
18
+ #
19
+ # Call Procrastinator.setup with a block to configure task queues.
20
+ #
21
+ # See README for details.
22
+ #
23
+ # @author Robin Miller
24
+ #
25
+ # @see https://github.com/TenjinInc/procrastinator
8
26
  module Procrastinator
9
- @@test_mode = false
10
-
27
+ # Creates a configuration object and passes it into the given block.
28
+ #
29
+ # @yield the created configuration object
30
+ # @return [Scheduler] a scheduler object that can be used to interact with the queues
11
31
  def self.setup(&block)
12
- raise ArgumentError.new('Procrastinator.setup must be given a block') if block.nil?
13
-
14
- env = Environment.new(test_mode: @@test_mode)
32
+ raise ArgumentError, 'Procrastinator.setup must be given a block' unless block
15
33
 
16
- yield(env)
17
-
18
- raise RuntimeError.new('setup block must call #persister_factory on the environment') if env.persister.nil?
19
- raise RuntimeError.new('setup block must call #define_queue on the environment') if env.queue_definitions.empty?
20
- env.spawn_workers
21
-
22
- env
23
- end
24
-
25
- def self.test_mode=(value)
26
- @@test_mode = value
27
- end
34
+ config = Config.new(&block)
28
35
 
29
- def self.test_mode
30
- @@test_mode
36
+ Scheduler.new(config)
31
37
  end
32
38
  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,22 +10,27 @@ 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 to put off work until later'
14
+ spec.description = 'A flexible pure Ruby job queue. Tasks are reschedulable after failures.'
14
15
  spec.homepage = 'https://github.com/TenjinInc/procrastinator'
15
16
  spec.license = 'MIT'
17
+ spec.metadata = {
18
+ 'rubygems_mfa_required' => 'true'
19
+ }
16
20
 
17
21
  spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
18
22
  spec.bindir = 'exe'
19
23
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
20
24
  spec.require_paths = ['lib']
21
25
 
22
- spec.required_ruby_version = '> 2.0'
26
+ spec.required_ruby_version = '>= 2.4'
23
27
 
24
- 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
- spec.add_development_dependency 'fakefs', '~> 0.10'
28
+ spec.add_development_dependency 'bundler', '~> 2.3'
29
+ spec.add_development_dependency 'fakefs', '~> 1.8'
30
+ spec.add_development_dependency 'rake', '~> 13.0'
31
+ spec.add_development_dependency 'rspec', '~> 3.9'
32
+ spec.add_development_dependency 'rubocop', '~> 1.12'
33
+ spec.add_development_dependency 'rubocop-performance', '~> 1.10'
34
+ spec.add_development_dependency 'simplecov', '~> 0.18.0'
35
+ spec.add_development_dependency 'timecop', '~> 0.9'
30
36
  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: 1.0.0.pre.rc2
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: 2022-09-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -16,86 +16,113 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '1.11'
19
+ version: '2.3'
20
20
  type: :development
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '1.11'
26
+ version: '2.3'
27
+ - !ruby/object:Gem::Dependency
28
+ name: fakefs
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.8'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.8'
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: '13.0'
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: '13.0'
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: rspec
43
57
  requirement: !ruby/object:Gem::Requirement
44
58
  requirements:
45
59
  - - "~>"
46
60
  - !ruby/object:Gem::Version
47
- version: '3.0'
61
+ version: '3.9'
48
62
  type: :development
49
63
  prerelease: false
50
64
  version_requirements: !ruby/object:Gem::Requirement
51
65
  requirements:
52
66
  - - "~>"
53
67
  - !ruby/object:Gem::Version
54
- version: '3.0'
68
+ version: '3.9'
55
69
  - !ruby/object:Gem::Dependency
56
- name: timecop
70
+ name: rubocop
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.12'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.12'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rubocop-performance
57
85
  requirement: !ruby/object:Gem::Requirement
58
86
  requirements:
59
87
  - - "~>"
60
88
  - !ruby/object:Gem::Version
61
- version: '0.8'
89
+ version: '1.10'
62
90
  type: :development
63
91
  prerelease: false
64
92
  version_requirements: !ruby/object:Gem::Requirement
65
93
  requirements:
66
94
  - - "~>"
67
95
  - !ruby/object:Gem::Version
68
- version: '0.8'
96
+ version: '1.10'
69
97
  - !ruby/object:Gem::Dependency
70
98
  name: simplecov
71
99
  requirement: !ruby/object:Gem::Requirement
72
100
  requirements:
73
101
  - - "~>"
74
102
  - !ruby/object:Gem::Version
75
- version: '0.11'
103
+ version: 0.18.0
76
104
  type: :development
77
105
  prerelease: false
78
106
  version_requirements: !ruby/object:Gem::Requirement
79
107
  requirements:
80
108
  - - "~>"
81
109
  - !ruby/object:Gem::Version
82
- version: '0.11'
110
+ version: 0.18.0
83
111
  - !ruby/object:Gem::Dependency
84
- name: fakefs
112
+ name: timecop
85
113
  requirement: !ruby/object:Gem::Requirement
86
114
  requirements:
87
115
  - - "~>"
88
116
  - !ruby/object:Gem::Version
89
- version: '0.10'
117
+ version: '0.9'
90
118
  type: :development
91
119
  prerelease: false
92
120
  version_requirements: !ruby/object:Gem::Requirement
93
121
  requirements:
94
122
  - - "~>"
95
123
  - !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.
124
+ version: '0.9'
125
+ description: A flexible pure Ruby job queue. Tasks are reschedulable after failures.
99
126
  email:
100
127
  - robin@tenjin.ca
101
128
  executables: []
@@ -104,43 +131,54 @@ extra_rdoc_files: []
104
131
  files:
105
132
  - ".gitignore"
106
133
  - ".rspec"
134
+ - ".rubocop.yml"
107
135
  - ".ruby-version"
108
136
  - ".travis.yml"
109
137
  - CODE_OF_CONDUCT.md
110
138
  - Gemfile
111
139
  - LICENSE.txt
112
140
  - README.md
141
+ - RELEASE_NOTES.md
113
142
  - Rakefile
114
143
  - bin/console
115
144
  - bin/setup
116
145
  - lib/procrastinator.rb
117
- - lib/procrastinator/environment.rb
146
+ - lib/procrastinator/config.rb
147
+ - lib/procrastinator/logged_task.rb
148
+ - lib/procrastinator/queue.rb
118
149
  - lib/procrastinator/queue_worker.rb
119
- - lib/procrastinator/task_worker.rb
150
+ - lib/procrastinator/rake/daemon_tasks.rb
151
+ - lib/procrastinator/rake/tasks.rb
152
+ - lib/procrastinator/scheduler.rb
153
+ - lib/procrastinator/task.rb
154
+ - lib/procrastinator/task_meta_data.rb
155
+ - lib/procrastinator/task_store/file_transaction.rb
156
+ - lib/procrastinator/task_store/simple_comma_store.rb
157
+ - lib/procrastinator/test/mocks.rb
120
158
  - lib/procrastinator/version.rb
121
159
  - procrastinator.gemspec
122
160
  homepage: https://github.com/TenjinInc/procrastinator
123
161
  licenses:
124
162
  - MIT
125
- metadata: {}
163
+ metadata:
164
+ rubygems_mfa_required: 'true'
126
165
  post_install_message:
127
166
  rdoc_options: []
128
167
  require_paths:
129
168
  - lib
130
169
  required_ruby_version: !ruby/object:Gem::Requirement
131
170
  requirements:
132
- - - ">"
171
+ - - ">="
133
172
  - !ruby/object:Gem::Version
134
- version: '2.0'
173
+ version: '2.4'
135
174
  required_rubygems_version: !ruby/object:Gem::Requirement
136
175
  requirements:
137
- - - ">="
176
+ - - ">"
138
177
  - !ruby/object:Gem::Version
139
- version: '0'
178
+ version: 1.3.1
140
179
  requirements: []
141
- rubyforge_project:
142
- rubygems_version: 2.2.2
180
+ rubygems_version: 3.1.2
143
181
  signing_key:
144
182
  specification_version: 4
145
- summary: Delayed task queues made simple.
183
+ summary: For apps to put off work until later
146
184
  test_files: []