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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 334855b00a1b57308782f76328e3d72d2339169713022fe5cc5421961a4acfc9
4
+ data.tar.gz: d63ddc5085f28acc3d00174f77632f3f2a95a6c827b4d901629496ccfc0251a0
5
+ SHA512:
6
+ metadata.gz: 56e3d4d168da46531849c13c713e85483d624336fd30f42eace765adbbce119c8c97e09072375e5373410750e5cded72a315eda6d6b917bb137ab57a887aa09a
7
+ data.tar.gz: a97fb1df5b38f972d9165fbc9eee86045abec79b5ec543684cb008dbee60f1ec0c222e7a39d7ea99ffdf3b72c80687f1747f79c5f9ebf70d76c9b0be04486bf2
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thadeu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
data/README.md ADDED
@@ -0,0 +1,146 @@
1
+ # anway-ruby
2
+
3
+ Ruby and Rails client and worker for [Anway](https://github.com/thadeu/anway). Ruby 3.4 or later. Rails 8.1 for the Rails integration.
4
+
5
+ ```ruby
6
+ gem 'anway'
7
+ ```
8
+
9
+ The server address comes from `ANWAY_URL`, for example `tcp://:password@localhost:7419`. Use `tcp+tls://` for TLS.
10
+
11
+ ## Worker
12
+
13
+ ```ruby
14
+ class WelcomeJob
15
+ include Anway::Worker
16
+
17
+ job_type 'email.send'
18
+ job_options queue: 'mailers', retry: 5
19
+
20
+ def perform(address)
21
+ # ...
22
+ end
23
+ end
24
+
25
+ WelcomeJob.perform_async('a@b.com')
26
+ WelcomeJob.perform_in(300, 'a@b.com')
27
+ WelcomeJob.set(queue: 'low').perform_async('a@b.com')
28
+ ```
29
+
30
+ - `job_type` is optional. The default is the class name. Use a name when a worker in another language runs the job.
31
+ - `job_options` accepts `queue`, `retry`, `reserve_for`, `backtrace`, and `custom`, such as `custom: { unique_for: 600 }`.
32
+ - Args must be JSON types with string hash keys. Symbols, times, and objects raise `ArgumentError`.
33
+ - Delivery is at least once, so jobs must be idempotent.
34
+
35
+ To send a job that a worker in any language runs:
36
+
37
+ ```ruby
38
+ Anway.push('image.resize', [42], queue: 'media')
39
+ ```
40
+
41
+ ## Rails
42
+
43
+ ```ruby
44
+ config.active_job.queue_adapter = :anway
45
+ ```
46
+
47
+ - Existing ActiveJob classes work. Each one runs in the Rails reloader.
48
+ - In a database transaction, a push waits for the commit, and a rollback cancels it. This also applies to `perform_async`.
49
+ - A job with `retry_on` keeps ActiveJob attempts, and its final error goes to the dead set.
50
+ - A job with no `retry_on` uses server retries.
51
+
52
+ Tasks in `recurring.yml` and jobs that wait in Solid Queue do not move automatically. Move recurring tasks to the server cron, and drain the old queue first.
53
+
54
+ ## Process
55
+
56
+ ```bash
57
+ bundle exec anway -q critical,mailers,default -c 10
58
+ ```
59
+
60
+ | Flag | Default | Purpose |
61
+ |---|---|---|
62
+ | `-q`, `--queues` | Queues of the job classes, and `default` | Queues in order, as `name` or `name:weight` |
63
+ | `-c`, `--concurrency` | `10` | Job threads |
64
+ | `-t`, `--timeout` | `25` | Seconds for jobs to finish at a stop |
65
+ | `-r`, `--require` | `config/environment.rb` | File that loads the jobs. Required outside Rails. |
66
+ | `-C`, `--config` | `config/anway.yml` | YAML config file |
67
+ | `-e`, `--environment` | `RAILS_ENV`, `APP_ENV` | Environment section of the YAML file |
68
+ | `-l`, `--label` | None | Worker label |
69
+ | `-v`, `--verbose` | Off | Debug logs |
70
+ | `-V`, `--version` | | Print the version |
71
+
72
+ ```yaml
73
+ concurrency: <%= ENV.fetch('WORKER_THREADS', 10) %>
74
+ timeout: 25
75
+ queues:
76
+ - [critical, 4]
77
+ - [mailers, 2]
78
+ - [default, 1]
79
+
80
+ production:
81
+ concurrency: 20
82
+ ```
83
+
84
+ Flags have priority over the environment section, and the environment section has priority over top-level keys. A list without weights uses strict order.
85
+
86
+ | Signal | Effect |
87
+ |---|---|
88
+ | `TERM`, `INT` | Stop |
89
+ | `TSTP` | Quiet: stop fetching and stay alive |
90
+ | `TTIN` | Write thread data to the log |
91
+
92
+ Exit codes: `0` for a normal stop, `1` for a boot failure, `2` for a usage error.
93
+
94
+ At a stop, jobs get `--timeout` seconds to finish. Then `Anway::Shutdown` is raised in them, and they get 5 seconds to return. The process then fails jobs that still run and stops. Set the container stop timeout above 30 seconds.
95
+
96
+ Keep the database pool at the concurrency value or more. A job queue with no worker has no error, and its jobs wait.
97
+
98
+ ## Tests in your app
99
+
100
+ ```ruby
101
+ require 'anway/testing'
102
+
103
+ Anway::Testing.fake!
104
+ WelcomeJob.perform_async('a@b.com')
105
+ Anway::Testing.jobs.size
106
+ Anway::Testing.drain
107
+
108
+ Anway::Testing.inline!
109
+ ```
110
+
111
+ ## Client
112
+
113
+ `Anway::Client` has the operations of [anway-go](https://github.com/thadeu/anway-go), in snake_case. It is safe for threads and for forks.
114
+
115
+ ```ruby
116
+ client = Anway::Client.new('tcp://localhost:7419')
117
+ client.push(Anway::Job.new('email.send', ['a@b.com']))
118
+ ```
119
+
120
+ The error kinds are `Anway::NotUnique`, `Anway::Closed`, `Anway::ServerError`, and `Anway::ConnectionError`. A failed call does not resend.
121
+
122
+ ## Configuration
123
+
124
+ ```ruby
125
+ Anway.configure do |config|
126
+ config.pool_size = 8
127
+ config.timeout = 10
128
+ config.tls = { ca_file: 'ca.pem' }
129
+ config.use MyMiddleware.new
130
+ config.on_error { |error, job| report(error, job) }
131
+ end
132
+ ```
133
+
134
+ Middleware has `call(job)` and yields. The first one added is the outermost.
135
+
136
+ ## Tests
137
+
138
+ ```bash
139
+ SERVER_BIN=/path/to/anway bundle exec rspec
140
+ ```
141
+
142
+ The specs map to the [conformance scenarios](https://github.com/thadeu/anway/blob/main/conformance/scenarios.md).
143
+
144
+ ## License
145
+
146
+ MIT
data/exe/anway ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'anway/cli'
5
+
6
+ exit Anway::CLI.new(ARGV).run
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_job'
4
+ require 'anway'
5
+
6
+ module Anway
7
+ # Runs serialized ActiveJob payloads.
8
+ class ActiveJobWrapper
9
+ include Anway::Worker
10
+
11
+ job_type 'ActiveJob'
12
+
13
+ def perform(data)
14
+ ActiveJob::Base.execute(data)
15
+ end
16
+ end
17
+
18
+ # Records the use of retry_on, because rescue_handlers also holds rescue_from.
19
+ module ActiveJobRetries
20
+ def retry_on(...)
21
+ self.anway_retry_on = true
22
+
23
+ super
24
+ end
25
+ end
26
+ end
27
+
28
+ ActiveSupport.on_load(:active_job) do
29
+ class_attribute :anway_retry_on, default: false
30
+ singleton_class.prepend(Anway::ActiveJobRetries)
31
+ end
32
+
33
+ module ActiveJob
34
+ module QueueAdapters
35
+ class AnwayAdapter < AbstractAdapter
36
+ BACKTRACE = 30
37
+ DEAD_SET = -1
38
+
39
+ def enqueue(job)
40
+ push(job, {})
41
+ end
42
+
43
+ def enqueue_at(job, timestamp)
44
+ push(job, { at: Time.at(timestamp) })
45
+ end
46
+
47
+ private
48
+
49
+ # Each push has a new id. A retry_on attempt keeps its job_id while the
50
+ # first attempt is still reserved, and the server rejects a repeated id.
51
+ def push(job, options)
52
+ options = options.merge(queue: job.queue_name, backtrace: BACKTRACE)
53
+
54
+ # retry_on owns the attempts, so its final error goes to the dead set.
55
+ options[:retry] = DEAD_SET if job.class.anway_retry_on
56
+
57
+ pushed = Anway::Job.new('ActiveJob', [job.serialize.as_json], **options)
58
+
59
+ job.provider_job_id = pushed.jid
60
+
61
+ Anway.enqueue(pushed)
62
+ end
63
+ end
64
+ end
65
+ end
data/lib/anway/cli.rb ADDED
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'optparse'
5
+ require 'yaml'
6
+
7
+ # The Railtie registers only when Rails loads before the gem.
8
+ begin
9
+ require 'rails'
10
+ rescue LoadError
11
+ nil
12
+ end
13
+
14
+ require 'anway'
15
+
16
+ module Anway
17
+ # bundle exec anway -q critical,mailers:2,default -c 10
18
+ class CLI
19
+ EXIT_OK = 0
20
+ EXIT_BOOT = 1
21
+ EXIT_USAGE = 2
22
+ DEFAULTS = { concurrency: 10, timeout: 25, labels: [] }.freeze
23
+ DEFAULT_CONFIG = 'config/anway.yml'
24
+ RAILS_ENTRY = 'config/environment.rb'
25
+
26
+ def initialize(argv)
27
+ $stdout.sync = true
28
+
29
+ @argv = argv.dup
30
+ @flags = {}
31
+ end
32
+
33
+ def run
34
+ return EXIT_OK if parse == :done
35
+
36
+ boot
37
+ start
38
+ EXIT_OK
39
+ rescue OptionParser::ParseError, ArgumentError => e
40
+ warn("anway: #{e.message}")
41
+
42
+ EXIT_USAGE
43
+ rescue Error, ScriptError, StandardError => e
44
+ warn("anway: #{e.class}: #{e.message}")
45
+
46
+ EXIT_BOOT
47
+ end
48
+
49
+ private
50
+
51
+ def parse
52
+ parser.parse!(@argv)
53
+
54
+ raise ArgumentError, "unexpected argument: #{@argv.first}" unless @argv.empty?
55
+
56
+ @flags[:done] ? :done : nil
57
+ end
58
+
59
+ def parser
60
+ OptionParser.new do |options|
61
+ options.banner = 'Usage: anway [options]'
62
+
63
+ options.on('-q', '--queues LIST', 'Queues in order, as name or name:weight') do |list|
64
+ @flags[:queues] = list.split(',').map { |entry| entry.split(':') }
65
+ end
66
+
67
+ options.on('-c', '--concurrency N', Integer, 'Number of job threads') { |n| @flags[:concurrency] = n }
68
+ options.on('-t', '--timeout N', Float, 'Seconds for jobs to finish at stop') { |n| @flags[:timeout] = n }
69
+ options.on('-r', '--require PATH', 'File that loads the jobs') { |path| @flags[:require] = path }
70
+ options.on('-C', '--config PATH', 'YAML config file') { |path| @flags[:config] = path }
71
+ options.on('-e', '--environment NAME', 'Environment name') { |name| @flags[:environment] = name }
72
+ options.on('-l', '--label NAME', 'Worker label') { |name| (@flags[:labels] ||= []) << name }
73
+ options.on('-v', '--verbose', 'Debug logs') { @flags[:verbose] = true }
74
+
75
+ options.on('-V', '--version', 'Print the version') do
76
+ puts "anway #{VERSION}"
77
+
78
+ @flags[:done] = true
79
+ end
80
+
81
+ options.on('-h', '--help', 'Print this help') do
82
+ puts options
83
+
84
+ @flags[:done] = true
85
+ end
86
+ end
87
+ end
88
+
89
+ def boot
90
+ environment = @flags[:environment] || ENV['RAILS_ENV'] || ENV['APP_ENV'] || 'development'
91
+
92
+ ENV['RAILS_ENV'] ||= environment
93
+ ENV['APP_ENV'] ||= environment
94
+
95
+ @options = DEFAULTS.merge(file_options(environment)).merge(@flags)
96
+
97
+ load_app
98
+
99
+ Anway.config.logger.level = Logger::DEBUG if @options[:verbose]
100
+
101
+ check_database_pool
102
+ end
103
+
104
+ def file_options(environment)
105
+ path = @flags[:config] || DEFAULT_CONFIG
106
+
107
+ unless File.exist?(path)
108
+ raise ArgumentError, "config file not found: #{path}" if @flags[:config]
109
+
110
+ return {}
111
+ end
112
+
113
+ content = YAML.safe_load(ERB.new(File.read(path)).result, aliases: true) || {}
114
+ section = content.fetch(environment, {})
115
+ common = content.reject { |_key, value| value.is_a?(Hash) }
116
+
117
+ common.merge(section).transform_keys(&:to_sym)
118
+ end
119
+
120
+ def load_app
121
+ path = @options[:require]
122
+ path ||= RAILS_ENTRY if File.exist?(RAILS_ENTRY)
123
+
124
+ raise ArgumentError, 'use -r to load the jobs outside Rails' unless path
125
+
126
+ require File.expand_path(path)
127
+
128
+ Rails.application.eager_load! if defined?(Rails) && Rails.application
129
+ end
130
+
131
+ def check_database_pool
132
+ return unless defined?(ActiveRecord::Base) && ActiveRecord::Base.connected?
133
+
134
+ size = ActiveRecord::Base.connection_pool.size
135
+
136
+ return if size >= @options[:concurrency]
137
+
138
+ Anway.config.logger.warn("database pool #{size} is smaller than concurrency #{@options[:concurrency]}")
139
+ end
140
+
141
+ def start
142
+ runner = Runner.new(
143
+ queues: queues,
144
+ concurrency: @options[:concurrency],
145
+ timeout: @options[:timeout],
146
+ labels: @options[:labels]
147
+ )
148
+
149
+ trap_signals(runner)
150
+ runner.run
151
+ end
152
+
153
+ def trap_signals(runner)
154
+ %w[TERM INT].each { |name| Signal.trap(name) { runner.stop } }
155
+
156
+ Signal.trap('TSTP') { runner.quiet }
157
+ Signal.trap('TTIN') { runner.dump }
158
+ end
159
+
160
+ # Without a list, the worker uses the queues of its job classes.
161
+ def queues
162
+ return @options[:queues] if @options[:queues]
163
+
164
+ names = Worker.classes.map { |klass| klass.job_options[:queue]&.to_s }
165
+ names.concat(active_job_queues)
166
+
167
+ [*names.compact, 'default'].uniq
168
+ end
169
+
170
+ def active_job_queues
171
+ return [] unless defined?(ActiveJob::Base)
172
+
173
+ ActiveJob::Base.descendants.map(&:queue_name).grep(String)
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'uri'
5
+
6
+ module Anway
7
+ # Same operations as the anway-go client. It is safe for concurrent use.
8
+ class Client
9
+ DEFAULT_URL = 'tcp://127.0.0.1:7419'
10
+ DEFAULT_PORT = 7419
11
+ FETCH_WAIT = 2
12
+
13
+ Endpoint = Data.define(:host, :port, :password, :tls)
14
+
15
+ # A blank URL uses ANWAY_URL, then the local default.
16
+ def initialize(url = nil, pool_size: 8, timeout: 10, tls: nil, wid: nil, labels: [])
17
+ @endpoint = parse_url(url || ENV.fetch('ANWAY_URL', nil) || DEFAULT_URL)
18
+ @timeout = timeout
19
+ @wid = wid
20
+
21
+ @pool = Pool.new(pool_size) do
22
+ Connection.new(@endpoint, timeout: timeout, tls: tls, wid: wid, labels: labels)
23
+ end
24
+ end
25
+
26
+ def close
27
+ @pool.close
28
+ end
29
+
30
+ # Raises NotUnique when a unique lock blocks the job.
31
+ def push(job)
32
+ call('PUSH', job.to_h)
33
+
34
+ job.jid
35
+ end
36
+
37
+ # Returns the next job, or nil when no job arrives in about 2 seconds.
38
+ def fetch(*queues)
39
+ reply = call('FETCH', queues.join(' '), wait: FETCH_WAIT)
40
+
41
+ reply && Job.from_hash(JSON.parse(reply))
42
+ end
43
+
44
+ def ack(jid)
45
+ call('ACK', { jid: jid })
46
+
47
+ true
48
+ end
49
+
50
+ def fail(jid, errtype:, message:, backtrace: [])
51
+ call('FAIL', { jid: jid, errtype: errtype, message: message, backtrace: backtrace })
52
+
53
+ true
54
+ end
55
+
56
+ # Returns nil, "quiet", or "terminate".
57
+ def beat(current_state = nil)
58
+ reply = call('BEAT', { wid: @wid, current_state: current_state }.compact)
59
+
60
+ reply == 'OK' ? nil : JSON.parse(reply)['state']
61
+ end
62
+
63
+ def info
64
+ JSON.parse(call('INFO'))
65
+ end
66
+
67
+ def queue_sizes
68
+ info.dig('faktory', 'queues') || {}
69
+ end
70
+
71
+ def pause_queues(*queues)
72
+ queue_command('PAUSE', queues)
73
+ end
74
+
75
+ def resume_queues(*queues)
76
+ queue_command('RESUME', queues)
77
+ end
78
+
79
+ def remove_queues(*queues)
80
+ queue_command('REMOVE', queues)
81
+ end
82
+
83
+ # The command is "kill", "discard", "requeue", or "clear".
84
+ # The target is "retries", "scheduled", or "dead".
85
+ def mutate(command, target, jobtype: nil, jids: nil, regexp: nil)
86
+ filter = { jobtype: jobtype, jids: jids, regexp: regexp }.compact
87
+
88
+ call('MUTATE', { cmd: command, target: target, filter: filter })
89
+
90
+ true
91
+ end
92
+
93
+ def batch_new(success: nil, complete: nil, parent_bid: nil, description: nil)
94
+ definition = {
95
+ success: success&.to_h, complete: complete&.to_h, parent_bid: parent_bid, description: description
96
+ }.compact
97
+
98
+ call('BATCH', "NEW #{JSON.generate(definition)}")
99
+ end
100
+
101
+ def batch_open(bid)
102
+ call('BATCH', "OPEN #{bid}")
103
+
104
+ true
105
+ end
106
+
107
+ def batch_commit(bid)
108
+ call('BATCH', "COMMIT #{bid}")
109
+
110
+ true
111
+ end
112
+
113
+ def batch_status(bid)
114
+ JSON.parse(call('BATCH', "STATUS #{bid}"))
115
+ end
116
+
117
+ def track_get(jid)
118
+ JSON.parse(call('TRACK', "GET #{jid}"))
119
+ end
120
+
121
+ def track_set(jid, percent: nil, desc: nil, reserve_until: nil)
122
+ update = { jid: jid, percent: percent, desc: desc, reserve_until: reserve_until&.to_time&.utc&.iso8601(9) }
123
+
124
+ call('TRACK', "SET #{JSON.generate(update.compact)}")
125
+
126
+ true
127
+ end
128
+
129
+ private
130
+
131
+ def queue_command(action, queues)
132
+ call('QUEUE', "#{action} #{queues.join(' ')}")
133
+
134
+ true
135
+ end
136
+
137
+ def call(verb, argument = nil, wait: 0)
138
+ @pool.with(timeout: @timeout) { |connection| connection.command(verb, argument, wait: wait) }
139
+ end
140
+
141
+ def parse_url(url)
142
+ uri = URI.parse(url)
143
+
144
+ raise ArgumentError, "unsupported scheme: #{uri.scheme}" unless %w[tcp tcp+tls].include?(uri.scheme)
145
+
146
+ password = uri.password && URI.decode_www_form_component(uri.password)
147
+
148
+ Endpoint.new(host: uri.hostname, port: uri.port || DEFAULT_PORT, password: password, tls: uri.scheme == 'tcp+tls')
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module Anway
6
+ class Configuration
7
+ attr_accessor :url, :pool_size, :timeout, :tls, :logger, :push_wrapper, :reloader
8
+ attr_reader :middleware, :error_handlers
9
+
10
+ def initialize
11
+ @url = nil
12
+ @pool_size = 8
13
+ @timeout = 10
14
+ @tls = nil
15
+ @logger = Logger.new($stdout, level: Logger::INFO)
16
+ @push_wrapper = ->(&block) { block.call }
17
+ @reloader = ->(&block) { block.call }
18
+ @middleware = []
19
+ @error_handlers = []
20
+ end
21
+
22
+ # The first middleware added is the outermost. An object has `call(job)`
23
+ # and yields to run the next one.
24
+ def use(middleware)
25
+ @middleware << middleware
26
+ end
27
+
28
+ def on_error(&block)
29
+ @error_handlers << block
30
+ end
31
+ end
32
+ end