anway 0.1.1

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,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Anway
6
+ # Runs jobs until stop, or until the server sends terminate.
7
+ #
8
+ # After quiet, the runner stops fetching and stays alive. At a stop, running
9
+ # jobs get `timeout` seconds to finish. Then Anway::Shutdown is raised in them,
10
+ # and they get `cancel_grace` seconds to return. Jobs that still run then fail.
11
+ class Runner
12
+ BEAT_INTERVAL = 15
13
+ ERROR_PAUSE = 1
14
+ EMPTY_FETCH_MINIMUM = 0.2
15
+ BACKTRACE_LINES = 30
16
+
17
+ attr_reader :wid
18
+
19
+ def initialize(queues: ['default'], concurrency: 10, timeout: 25, cancel_grace: 5,
20
+ labels: [], beat_interval: BEAT_INTERVAL, url: nil, logger: Anway.config.logger)
21
+ @queues = Queues.new(queues)
22
+ @concurrency = concurrency
23
+ @timeout = timeout
24
+ @cancel_grace = cancel_grace
25
+ @beat_interval = beat_interval
26
+ @logger = logger
27
+ @wid = SecureRandom.hex(12)
28
+ @fetching = Thread::Queue.new
29
+ @stopped_beat = Thread::Queue.new
30
+ @events_reader, @events_writer = IO.pipe
31
+ @running = {}
32
+ @lock = Mutex.new
33
+
34
+ @client = Client.new(
35
+ url || Anway.config.url,
36
+ pool_size: concurrency + 1,
37
+ timeout: Anway.config.timeout,
38
+ tls: Anway.config.tls,
39
+ wid: @wid,
40
+ labels: ['ruby', *labels]
41
+ )
42
+ end
43
+
44
+ # Safe in a signal handler.
45
+ def stop
46
+ signal('s')
47
+ end
48
+
49
+ # Safe in a signal handler.
50
+ def quiet
51
+ signal('q')
52
+ end
53
+
54
+ # Safe in a signal handler.
55
+ def dump
56
+ signal('d')
57
+ end
58
+
59
+ def run
60
+ @client.beat
61
+
62
+ @processors = Array.new(@concurrency) { start_processor }
63
+
64
+ beat_thread = start_beat
65
+
66
+ @logger.info("anway started queues=#{@queues.names.join(',')} concurrency=#{@concurrency}")
67
+
68
+ wait_for_stop
69
+ shutdown
70
+
71
+ @stopped_beat.close
72
+ beat_thread.join
73
+ ensure
74
+ @client.close
75
+ end
76
+
77
+ private
78
+
79
+ def signal(letter)
80
+ @events_writer.write_nonblock(letter, exception: false)
81
+ end
82
+
83
+ def wait_for_stop
84
+ loop do
85
+ @events_reader.wait_readable
86
+
87
+ case @events_reader.read_nonblock(1)
88
+ when 's' then return
89
+ when 'q' then stop_fetching
90
+ when 'd' then dump_threads
91
+ end
92
+ end
93
+ end
94
+
95
+ def dump_threads
96
+ Thread.list.each { |thread| @logger.info("#{thread.inspect}\n#{thread.backtrace&.first(10)&.join("\n")}") }
97
+ end
98
+
99
+ def stop_fetching
100
+ @fetching.close
101
+ end
102
+
103
+ def fetching?
104
+ !@fetching.closed?
105
+ end
106
+
107
+ def pause(seconds)
108
+ @fetching.pop(timeout: seconds)
109
+ end
110
+
111
+ def start_beat
112
+ Thread.new do
113
+ state = nil
114
+
115
+ until @stopped_beat.closed?
116
+ @stopped_beat.pop(timeout: @beat_interval)
117
+
118
+ break if @stopped_beat.closed?
119
+
120
+ state = beat(state)
121
+ end
122
+ end
123
+ end
124
+
125
+ def beat(state)
126
+ case @client.beat(state)
127
+ when 'quiet'
128
+ quiet
129
+
130
+ 'quiet'
131
+ when 'terminate'
132
+ stop
133
+
134
+ state
135
+ else
136
+ state
137
+ end
138
+ rescue Error => e
139
+ @logger.warn("beat failed: #{e.message}")
140
+
141
+ state
142
+ end
143
+
144
+ # Shutdown stays pending outside perform, so it cannot break a report.
145
+ def start_processor
146
+ Thread.new do
147
+ Thread.handle_interrupt(Shutdown => :never) { process_loop }
148
+ end
149
+ end
150
+
151
+ # A fetch in progress is not canceled, because its reply can hold a job.
152
+ def process_loop
153
+ while fetching?
154
+ started = monotonic
155
+ job = fetch_next
156
+
157
+ if job
158
+ process(job)
159
+ elsif monotonic - started < EMPTY_FETCH_MINIMUM
160
+ pause(EMPTY_FETCH_MINIMUM)
161
+ end
162
+ end
163
+ end
164
+
165
+ def fetch_next
166
+ @client.fetch(*@queues.order)
167
+ rescue Error => e
168
+ @logger.warn("fetch failed: #{e.message}")
169
+ pause(ERROR_PAUSE)
170
+
171
+ nil
172
+ end
173
+
174
+ def process(job)
175
+ @lock.synchronize { @running[job.jid] = { job: job, thread: Thread.current } }
176
+
177
+ error = perform(job)
178
+
179
+ return unless claim(job.jid)
180
+
181
+ report(job, error)
182
+ end
183
+
184
+ def perform(job)
185
+ Thread.handle_interrupt(Shutdown => :immediate) { Executor.call(job) }
186
+
187
+ nil
188
+ rescue Exception => e # rubocop:disable Lint/RescueException
189
+ notify_error(e, job) unless e.is_a?(Shutdown)
190
+
191
+ e
192
+ end
193
+
194
+ def notify_error(error, job)
195
+ @logger.warn("job failed jid=#{job.jid} jobtype=#{job.type} error=#{error.class}: #{error.message}")
196
+
197
+ Anway.config.error_handlers.each { |handler| handler.call(error, job) }
198
+ rescue StandardError => e
199
+ @logger.error("error handler failed: #{e.message}")
200
+ end
201
+
202
+ def claim(jid)
203
+ @lock.synchronize { !@running.delete(jid).nil? }
204
+ end
205
+
206
+ def report(job, error)
207
+ if error
208
+ @client.fail(
209
+ job.jid,
210
+ errtype: error.class.name,
211
+ message: error.message,
212
+ backtrace: error.backtrace&.first(BACKTRACE_LINES) || []
213
+ )
214
+ else
215
+ @client.ack(job.jid)
216
+ end
217
+ rescue Error => e
218
+ @logger.warn("job report failed, the reservation will expire jid=#{job.jid}: #{e.message}")
219
+ end
220
+
221
+ def shutdown
222
+ stop_fetching if fetching?
223
+
224
+ return if join_processors(@timeout)
225
+
226
+ running.each { |entry| entry[:thread].raise(Shutdown, 'the worker is stopping') }
227
+
228
+ return if join_processors(@cancel_grace)
229
+
230
+ running.each { |entry| abandon(entry) }
231
+ end
232
+
233
+ def running
234
+ @lock.synchronize { @running.values }
235
+ end
236
+
237
+ def abandon(entry)
238
+ job = entry[:job]
239
+
240
+ return unless claim(job.jid)
241
+
242
+ @logger.warn("job did not stop jid=#{job.jid} jobtype=#{job.type}")
243
+
244
+ entry[:thread].kill
245
+
246
+ report(job, Shutdown.new('the worker stopped before the job finished'))
247
+ end
248
+
249
+ def join_processors(limit)
250
+ deadline = monotonic + limit
251
+
252
+ @processors.all? { |thread| thread.join([deadline - monotonic, 0].max) }
253
+ end
254
+
255
+ def monotonic
256
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
257
+ end
258
+ end
259
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'anway'
4
+
5
+ module Anway
6
+ # Test modes for apps. No server is necessary.
7
+ #
8
+ # Anway::Testing.fake! keeps pushed jobs in a list
9
+ # Anway::Testing.inline! runs each job at push
10
+ # Anway::Testing.disable! uses the real client
11
+ module Testing
12
+ @mode = nil
13
+ @jobs = []
14
+
15
+ class << self
16
+ attr_reader :jobs, :mode
17
+
18
+ def fake!
19
+ @mode = :fake
20
+ end
21
+
22
+ def inline!
23
+ @mode = :inline
24
+ end
25
+
26
+ def disable!
27
+ @mode = nil
28
+ end
29
+
30
+ def clear
31
+ @jobs.clear
32
+ end
33
+
34
+ def enqueue(job)
35
+ copy = Job.from_hash(JSON.parse(JSON.generate(job.to_h)))
36
+
37
+ @mode == :inline ? Executor.call(copy) : @jobs << copy
38
+
39
+ job.jid
40
+ end
41
+
42
+ # Runs and removes all fake jobs. Jobs that they push also run.
43
+ def drain
44
+ Executor.call(@jobs.shift) until @jobs.empty?
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anway
4
+ VERSION = '0.1.1'
5
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anway
4
+ # Makes a class a job:
5
+ #
6
+ # class WelcomeJob
7
+ # include Anway::Worker
8
+ #
9
+ # job_options queue: 'mailers', retry: 5
10
+ #
11
+ # def perform(user_id); end
12
+ # end
13
+ #
14
+ # WelcomeJob.perform_async(42)
15
+ module Worker
16
+ DEFAULT_BACKTRACE = 30
17
+
18
+ @registry = {}
19
+ @lock = Mutex.new
20
+
21
+ class << self
22
+ def included(base)
23
+ base.extend(ClassMethods)
24
+
25
+ register(base) if base.name
26
+ end
27
+
28
+ def register(klass, old_type: nil)
29
+ @lock.synchronize do
30
+ @registry.delete(old_type) if old_type
31
+ @registry[klass.job_type] = klass
32
+ end
33
+ end
34
+
35
+ def classes
36
+ @lock.synchronize { @registry.values }
37
+ end
38
+
39
+ # Only registered classes run. The job type comes from the network, so it
40
+ # never becomes a constant lookup. The lookup by the class name below
41
+ # uses a registered name, and returns the newest class after a reload.
42
+ def find(type)
43
+ klass = @lock.synchronize { @registry[type] }
44
+
45
+ raise UnknownJob, "no handler for job type #{type.inspect}" unless klass
46
+
47
+ klass.name ? Object.const_get(klass.name) : klass
48
+ end
49
+ end
50
+
51
+ attr_accessor :job
52
+
53
+ def jid
54
+ job&.jid
55
+ end
56
+
57
+ # Updates the progress of a tracked job.
58
+ def track(percent: nil, desc: nil, reserve_until: nil)
59
+ Anway.client.track_set(jid, percent: percent, desc: desc, reserve_until: reserve_until)
60
+ end
61
+
62
+ module ClassMethods
63
+ def inherited(subclass)
64
+ super
65
+
66
+ Worker.register(subclass) if subclass.name
67
+ end
68
+
69
+ def job_type(name = nil)
70
+ return @job_type || self.name unless name
71
+
72
+ old_type = job_type
73
+ @job_type = name
74
+
75
+ Worker.register(self, old_type: old_type)
76
+ end
77
+
78
+ def job_options(options = nil)
79
+ inherited_options = superclass.respond_to?(:job_options) ? superclass.job_options : {}
80
+
81
+ @job_options = (@job_options || {}).merge(options) if options
82
+
83
+ inherited_options.merge(@job_options || {})
84
+ end
85
+
86
+ def perform_async(*args)
87
+ enqueue_with(args, {})
88
+ end
89
+
90
+ def perform_at(time, *args)
91
+ enqueue_with(args, { at: time })
92
+ end
93
+
94
+ def perform_in(seconds, *args)
95
+ enqueue_with(args, { at: Time.now + seconds.to_f })
96
+ end
97
+
98
+ # Changes the options for one push: WelcomeJob.set(queue: 'low').perform_async(1)
99
+ def set(**options)
100
+ Setter.new(self, options)
101
+ end
102
+
103
+ def enqueue_with(args, options)
104
+ Anway.enqueue(Job.new(job_type, args, backtrace: DEFAULT_BACKTRACE, **job_options, **options))
105
+ end
106
+ end
107
+
108
+ class Setter
109
+ def initialize(klass, options)
110
+ @klass = klass
111
+ @options = options
112
+ end
113
+
114
+ def perform_async(*args)
115
+ @klass.enqueue_with(args, @options)
116
+ end
117
+
118
+ def perform_at(time, *args)
119
+ @klass.enqueue_with(args, @options.merge(at: time))
120
+ end
121
+
122
+ def perform_in(seconds, *args)
123
+ @klass.enqueue_with(args, @options.merge(at: Time.now + seconds.to_f))
124
+ end
125
+ end
126
+ end
127
+ end
data/lib/anway.rb ADDED
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'anway/version'
4
+ require_relative 'anway/errors'
5
+ require_relative 'anway/configuration'
6
+ require_relative 'anway/job'
7
+ require_relative 'anway/connection'
8
+ require_relative 'anway/pool'
9
+ require_relative 'anway/client'
10
+ require_relative 'anway/worker'
11
+ require_relative 'anway/executor'
12
+ require_relative 'anway/queues'
13
+ require_relative 'anway/runner'
14
+ require_relative 'anway/railtie' if defined?(Rails::Railtie)
15
+
16
+ module Anway
17
+ @lock = Mutex.new
18
+
19
+ class << self
20
+ def config
21
+ @config ||= Configuration.new
22
+ end
23
+
24
+ def configure
25
+ yield config
26
+ end
27
+
28
+ def client
29
+ @lock.synchronize do
30
+ @client ||= Client.new(
31
+ config.url, pool_size: config.pool_size, timeout: config.timeout, tls: config.tls
32
+ )
33
+ end
34
+ end
35
+
36
+ # Sends a job with no local class, for a worker in any language.
37
+ def push(type, args = [], **options)
38
+ enqueue(Job.new(type, args, **options))
39
+ end
40
+
41
+ # Inside a database transaction, the push waits for the commit.
42
+ def enqueue(job)
43
+ testing = const_defined?(:Testing) ? Testing : nil
44
+
45
+ return testing.enqueue(job) if testing&.mode
46
+
47
+ config.push_wrapper.call { client.push(job) }
48
+
49
+ job.jid
50
+ end
51
+
52
+ def reset!
53
+ @lock.synchronize do
54
+ @client&.close
55
+ @client = nil
56
+ end
57
+ end
58
+ end
59
+ end
metadata ADDED
@@ -0,0 +1,71 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: anway
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Thadeu
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: logger
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.6'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.6'
26
+ executables:
27
+ - anway
28
+ extensions: []
29
+ extra_rdoc_files: []
30
+ files:
31
+ - LICENSE
32
+ - README.md
33
+ - exe/anway
34
+ - lib/active_job/queue_adapters/anway_adapter.rb
35
+ - lib/anway.rb
36
+ - lib/anway/cli.rb
37
+ - lib/anway/client.rb
38
+ - lib/anway/configuration.rb
39
+ - lib/anway/connection.rb
40
+ - lib/anway/errors.rb
41
+ - lib/anway/executor.rb
42
+ - lib/anway/job.rb
43
+ - lib/anway/pool.rb
44
+ - lib/anway/queues.rb
45
+ - lib/anway/railtie.rb
46
+ - lib/anway/runner.rb
47
+ - lib/anway/testing.rb
48
+ - lib/anway/version.rb
49
+ - lib/anway/worker.rb
50
+ homepage: https://github.com/thadeu/anway-ruby
51
+ licenses:
52
+ - MIT
53
+ metadata: {}
54
+ rdoc_options: []
55
+ require_paths:
56
+ - lib
57
+ required_ruby_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '3.4'
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ requirements: []
68
+ rubygems_version: 4.0.20
69
+ specification_version: 4
70
+ summary: Ruby and Rails client and worker for the Anway job server
71
+ test_files: []