procrastinator 0.6.1 → 1.0.0.pre.rc2

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.
@@ -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: []