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 +7 -0
- data/LICENSE +22 -0
- data/README.md +146 -0
- data/exe/anway +6 -0
- data/lib/active_job/queue_adapters/anway_adapter.rb +65 -0
- data/lib/anway/cli.rb +176 -0
- data/lib/anway/client.rb +151 -0
- data/lib/anway/configuration.rb +32 -0
- data/lib/anway/connection.rb +195 -0
- data/lib/anway/errors.rb +34 -0
- data/lib/anway/executor.rb +29 -0
- data/lib/anway/job.rb +81 -0
- data/lib/anway/pool.rb +85 -0
- data/lib/anway/queues.rb +21 -0
- data/lib/anway/railtie.rb +23 -0
- data/lib/anway/runner.rb +259 -0
- data/lib/anway/testing.rb +48 -0
- data/lib/anway/version.rb +5 -0
- data/lib/anway/worker.rb +127 -0
- data/lib/anway.rb +59 -0
- metadata +71 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'openssl'
|
|
6
|
+
require 'socket'
|
|
7
|
+
|
|
8
|
+
module Anway
|
|
9
|
+
# One socket. Timeouts use nonblocking calls, so that plain and TLS sockets
|
|
10
|
+
# follow the same path.
|
|
11
|
+
class Connection
|
|
12
|
+
PROTOCOL_VERSION = 2
|
|
13
|
+
READ_SIZE = 16_384
|
|
14
|
+
|
|
15
|
+
def initialize(endpoint, timeout:, tls: nil, wid: nil, labels: [])
|
|
16
|
+
@timeout = timeout
|
|
17
|
+
@buffer = String.new(encoding: Encoding::BINARY)
|
|
18
|
+
@io = open_socket(endpoint, tls)
|
|
19
|
+
|
|
20
|
+
handshake(endpoint, wid, labels)
|
|
21
|
+
rescue StandardError
|
|
22
|
+
close
|
|
23
|
+
|
|
24
|
+
raise
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The argument is a string, or an object that becomes JSON.
|
|
28
|
+
def command(verb, argument = nil, wait: 0)
|
|
29
|
+
deadline = monotonic + @timeout + wait
|
|
30
|
+
line = argument.nil? ? verb : "#{verb} #{argument.is_a?(String) ? argument : JSON.generate(argument)}"
|
|
31
|
+
|
|
32
|
+
write_all("#{line}\r\n", deadline)
|
|
33
|
+
read_reply(deadline)
|
|
34
|
+
rescue SystemCallError, IOError, SocketError, OpenSSL::SSL::SSLError => e
|
|
35
|
+
raise ConnectionError, e.message
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def close
|
|
39
|
+
@io&.close
|
|
40
|
+
rescue SystemCallError, IOError, OpenSSL::SSL::SSLError
|
|
41
|
+
nil
|
|
42
|
+
ensure
|
|
43
|
+
@io = nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def open_socket(endpoint, tls)
|
|
49
|
+
socket = Socket.tcp(endpoint.host, endpoint.port, connect_timeout: @timeout, resolv_timeout: @timeout)
|
|
50
|
+
|
|
51
|
+
return socket unless endpoint.tls
|
|
52
|
+
|
|
53
|
+
wrap_tls(socket, endpoint.host, tls)
|
|
54
|
+
rescue SystemCallError, SocketError, OpenSSL::SSL::SSLError => e
|
|
55
|
+
raise ConnectionError, e.message
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def wrap_tls(socket, host, tls)
|
|
59
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
60
|
+
|
|
61
|
+
context.set_params(tls || {})
|
|
62
|
+
|
|
63
|
+
secure = OpenSSL::SSL::SSLSocket.new(socket, context)
|
|
64
|
+
|
|
65
|
+
secure.hostname = host
|
|
66
|
+
secure.sync_close = true
|
|
67
|
+
|
|
68
|
+
connect_tls(secure)
|
|
69
|
+
|
|
70
|
+
secure
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def connect_tls(secure)
|
|
74
|
+
deadline = monotonic + @timeout
|
|
75
|
+
|
|
76
|
+
loop do
|
|
77
|
+
case secure.connect_nonblock(exception: false)
|
|
78
|
+
when :wait_readable then wait(secure, deadline, :read)
|
|
79
|
+
when :wait_writable then wait(secure, deadline, :write)
|
|
80
|
+
else return
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def handshake(endpoint, wid, labels)
|
|
86
|
+
deadline = monotonic + @timeout
|
|
87
|
+
greeting = JSON.parse(read_reply(deadline).delete_prefix('HI '))
|
|
88
|
+
|
|
89
|
+
raise Error, "unsupported protocol version #{greeting['v']}" unless greeting['v'] == PROTOCOL_VERSION
|
|
90
|
+
|
|
91
|
+
hello = { v: PROTOCOL_VERSION }
|
|
92
|
+
|
|
93
|
+
if greeting['s']
|
|
94
|
+
hello[:pwdhash] = password_hash(endpoint.password.to_s, greeting['s'], greeting['i'])
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
if wid
|
|
98
|
+
hello.merge!(wid: wid, hostname: Socket.gethostname, pid: Process.pid, labels: labels)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
command('HELLO', hello)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def password_hash(password, salt, iterations)
|
|
105
|
+
digest = Digest::SHA256.digest(password + salt)
|
|
106
|
+
|
|
107
|
+
(iterations - 1).times { digest = Digest::SHA256.digest(digest) }
|
|
108
|
+
|
|
109
|
+
digest.unpack1('H*')
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def write_all(data, deadline)
|
|
113
|
+
data = data.b
|
|
114
|
+
|
|
115
|
+
until data.empty?
|
|
116
|
+
written = @io.write_nonblock(data, exception: false)
|
|
117
|
+
|
|
118
|
+
case written
|
|
119
|
+
when :wait_readable then wait(@io, deadline, :read)
|
|
120
|
+
when :wait_writable then wait(@io, deadline, :write)
|
|
121
|
+
else data = data.byteslice(written..)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def read_reply(deadline)
|
|
127
|
+
line = read_line(deadline)
|
|
128
|
+
|
|
129
|
+
raise ConnectionError, 'empty reply' if line.empty?
|
|
130
|
+
|
|
131
|
+
body = line[1..]
|
|
132
|
+
|
|
133
|
+
case line[0]
|
|
134
|
+
when '+' then body.force_encoding(Encoding::UTF_8)
|
|
135
|
+
when '-' then raise server_error(body)
|
|
136
|
+
when '$' then read_bulk(Integer(body), deadline)
|
|
137
|
+
else raise ConnectionError, "unexpected reply: #{line.inspect}"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def server_error(body)
|
|
142
|
+
code, message = body.force_encoding(Encoding::UTF_8).split(' ', 2)
|
|
143
|
+
|
|
144
|
+
return NotUnique.new if code == 'NOTUNIQUE'
|
|
145
|
+
|
|
146
|
+
ServerError.new(code, message)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def read_line(deadline)
|
|
150
|
+
until (index = @buffer.index("\n"))
|
|
151
|
+
fill(deadline)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
@buffer.slice!(0..index).chomp
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def read_bulk(size, deadline)
|
|
158
|
+
return nil if size.negative?
|
|
159
|
+
|
|
160
|
+
fill(deadline) while @buffer.bytesize < size + 2
|
|
161
|
+
|
|
162
|
+
body = @buffer.slice!(0, size)
|
|
163
|
+
|
|
164
|
+
@buffer.slice!(0, 2)
|
|
165
|
+
|
|
166
|
+
body.force_encoding(Encoding::UTF_8)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def fill(deadline)
|
|
170
|
+
chunk = @io.read_nonblock(READ_SIZE, exception: false)
|
|
171
|
+
|
|
172
|
+
case chunk
|
|
173
|
+
when :wait_readable then wait(@io, deadline, :read)
|
|
174
|
+
when :wait_writable then wait(@io, deadline, :write)
|
|
175
|
+
when nil then raise ConnectionError, 'server closed the connection'
|
|
176
|
+
else @buffer << chunk
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def wait(io, deadline, mode)
|
|
181
|
+
remaining = deadline - monotonic
|
|
182
|
+
|
|
183
|
+
raise ConnectionError, 'timeout' unless remaining.positive?
|
|
184
|
+
|
|
185
|
+
readers = mode == :read ? [io] : nil
|
|
186
|
+
writers = mode == :write ? [io] : nil
|
|
187
|
+
|
|
188
|
+
IO.select(readers, writers, nil, remaining)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def monotonic
|
|
192
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
data/lib/anway/errors.rb
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anway
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class NotUnique < Error
|
|
7
|
+
def initialize(message = 'job not unique')
|
|
8
|
+
super
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class Closed < Error
|
|
13
|
+
def initialize(message = 'client is closed')
|
|
14
|
+
super
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class ConnectionError < Error; end
|
|
19
|
+
|
|
20
|
+
class UnknownJob < Error; end
|
|
21
|
+
|
|
22
|
+
class ServerError < Error
|
|
23
|
+
attr_reader :code
|
|
24
|
+
|
|
25
|
+
def initialize(code, message)
|
|
26
|
+
@code = code
|
|
27
|
+
|
|
28
|
+
super("#{code} #{message}")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Inherits from Exception, so that `rescue => e` in a job does not catch it.
|
|
33
|
+
class Shutdown < Exception; end # rubocop:disable Lint/InheritException
|
|
34
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anway
|
|
4
|
+
# Runs one job through the reloader and the middleware.
|
|
5
|
+
module Executor
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def call(job)
|
|
9
|
+
Anway.config.reloader.call do
|
|
10
|
+
klass = Worker.find(job.type)
|
|
11
|
+
|
|
12
|
+
chain(job) do
|
|
13
|
+
instance = klass.new
|
|
14
|
+
|
|
15
|
+
instance.job = job
|
|
16
|
+
instance.perform(*job.args)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def chain(job, &block)
|
|
22
|
+
runner = Anway.config.middleware.reverse.reduce(block) do |nextone, middleware|
|
|
23
|
+
-> { middleware.call(job) { nextone.call } }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
runner.call
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/anway/job.rb
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require 'time'
|
|
6
|
+
|
|
7
|
+
module Anway
|
|
8
|
+
class Job
|
|
9
|
+
ATTRIBUTES = %w[queue reserve_for at retry backtrace created_at enqueued_at failure].freeze
|
|
10
|
+
|
|
11
|
+
attr_accessor :jid, :type, :args, :custom, *ATTRIBUTES.map(&:to_sym)
|
|
12
|
+
|
|
13
|
+
def self.from_hash(hash)
|
|
14
|
+
job = allocate
|
|
15
|
+
|
|
16
|
+
job.jid = hash['jid']
|
|
17
|
+
job.type = hash['jobtype']
|
|
18
|
+
job.args = hash['args'] || []
|
|
19
|
+
job.custom = hash['custom']
|
|
20
|
+
|
|
21
|
+
ATTRIBUTES.each { |name| job.public_send("#{name}=", hash[name]) }
|
|
22
|
+
|
|
23
|
+
job
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Options: queue, retry, reserve_for, backtrace, at, custom, jid.
|
|
27
|
+
def initialize(type, args = [], **options)
|
|
28
|
+
validate_json!(args)
|
|
29
|
+
|
|
30
|
+
@jid = options[:jid] || SecureRandom.hex(12)
|
|
31
|
+
@type = type
|
|
32
|
+
@args = args
|
|
33
|
+
@queue = options[:queue]&.to_s
|
|
34
|
+
@retry = options[:retry]
|
|
35
|
+
@reserve_for = options[:reserve_for]
|
|
36
|
+
@backtrace = options[:backtrace]
|
|
37
|
+
@custom = options[:custom]&.transform_keys(&:to_s)
|
|
38
|
+
|
|
39
|
+
run_at(options[:at]) if options[:at]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def run_at(time)
|
|
43
|
+
@at = time.to_time.utc.iso8601(9)
|
|
44
|
+
|
|
45
|
+
self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def set_custom(key, value)
|
|
49
|
+
@custom = (@custom || {}).merge(key.to_s => value)
|
|
50
|
+
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def to_h
|
|
55
|
+
{
|
|
56
|
+
'jid' => jid, 'jobtype' => type, 'args' => args, 'custom' => custom,
|
|
57
|
+
**ATTRIBUTES.to_h { |name| [name, public_send(name)] }
|
|
58
|
+
}.compact
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Other languages read these args, so only JSON types are valid.
|
|
64
|
+
def validate_json!(value)
|
|
65
|
+
case value
|
|
66
|
+
when nil, true, false, String, Integer, Float
|
|
67
|
+
nil
|
|
68
|
+
when Array
|
|
69
|
+
value.each { |item| validate_json!(item) }
|
|
70
|
+
when Hash
|
|
71
|
+
value.each do |key, item|
|
|
72
|
+
raise ArgumentError, "job hash keys must be strings: #{key.inspect}" unless key.is_a?(String)
|
|
73
|
+
|
|
74
|
+
validate_json!(item)
|
|
75
|
+
end
|
|
76
|
+
else
|
|
77
|
+
raise ArgumentError, "job args must be JSON types: #{value.inspect}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
data/lib/anway/pool.rb
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anway
|
|
4
|
+
# Connections open at first use. A forked process starts with an empty pool
|
|
5
|
+
# and does not touch the sockets of its parent.
|
|
6
|
+
class Pool
|
|
7
|
+
# A wrapper, because a pop timeout also returns nil.
|
|
8
|
+
Slot = Data.define(:connection)
|
|
9
|
+
|
|
10
|
+
def initialize(size, &opener)
|
|
11
|
+
@size = size
|
|
12
|
+
@opener = opener
|
|
13
|
+
@lock = Mutex.new
|
|
14
|
+
@closed = false
|
|
15
|
+
|
|
16
|
+
reset
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def with(timeout:)
|
|
20
|
+
connection = checkout(timeout)
|
|
21
|
+
healthy = false
|
|
22
|
+
|
|
23
|
+
begin
|
|
24
|
+
connection ||= @opener.call
|
|
25
|
+
|
|
26
|
+
result = yield connection
|
|
27
|
+
healthy = true
|
|
28
|
+
|
|
29
|
+
result
|
|
30
|
+
rescue ServerError, NotUnique
|
|
31
|
+
healthy = true
|
|
32
|
+
|
|
33
|
+
raise
|
|
34
|
+
ensure
|
|
35
|
+
checkin(connection, healthy)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def close
|
|
40
|
+
@lock.synchronize do
|
|
41
|
+
return if @closed
|
|
42
|
+
|
|
43
|
+
@closed = true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
@slots.close
|
|
47
|
+
|
|
48
|
+
@size.times do
|
|
49
|
+
@slots.pop(timeout: 1)&.connection&.close
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def reset
|
|
56
|
+
@pid = Process.pid
|
|
57
|
+
@slots = Thread::SizedQueue.new(@size)
|
|
58
|
+
|
|
59
|
+
@size.times { @slots.push(Slot.new(nil)) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def checkout(timeout)
|
|
63
|
+
raise Closed if @closed
|
|
64
|
+
|
|
65
|
+
@lock.synchronize { reset unless @pid == Process.pid }
|
|
66
|
+
|
|
67
|
+
entry = @slots.pop(timeout: timeout)
|
|
68
|
+
|
|
69
|
+
raise Closed if entry.nil? && @slots.closed?
|
|
70
|
+
raise ConnectionError, 'no free connection' if entry.nil?
|
|
71
|
+
|
|
72
|
+
entry.connection
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def checkin(connection, healthy)
|
|
76
|
+
connection&.close unless healthy
|
|
77
|
+
|
|
78
|
+
return connection&.close if @slots.closed?
|
|
79
|
+
|
|
80
|
+
@slots.push(Slot.new(healthy ? connection : nil))
|
|
81
|
+
rescue ClosedQueueError
|
|
82
|
+
connection&.close
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
data/lib/anway/queues.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anway
|
|
4
|
+
# Entries are names, or [name, weight] pairs. Without weights, the order is
|
|
5
|
+
# strict. With weights, each fetch uses a new random order.
|
|
6
|
+
class Queues
|
|
7
|
+
attr_reader :names
|
|
8
|
+
|
|
9
|
+
def initialize(entries)
|
|
10
|
+
pairs = entries.map { |entry| Array(entry) }
|
|
11
|
+
|
|
12
|
+
@names = pairs.map { |name, _| name.to_s }.uniq
|
|
13
|
+
@weighted = pairs.any? { |pair| pair.size > 1 }
|
|
14
|
+
@pool = pairs.flat_map { |name, weight| [name.to_s] * Integer(weight || 1) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def order
|
|
18
|
+
@weighted ? @pool.shuffle.uniq : @names
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anway
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
# Rails looks up the adapter in active_job.set_configs.
|
|
6
|
+
initializer 'anway.active_job', before: 'active_job.set_configs' do
|
|
7
|
+
require 'active_job/queue_adapters/anway_adapter' if defined?(ActiveJob)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
initializer 'anway.transactions' do
|
|
11
|
+
ActiveSupport.on_load(:active_record) do
|
|
12
|
+
Anway.config.push_wrapper = ->(&block) { ActiveRecord.after_all_transactions_commit(&block) }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
config.after_initialize do |app|
|
|
17
|
+
Anway.config.logger = Rails.logger if Rails.logger
|
|
18
|
+
Anway.config.reloader = ->(&block) { app.reloader.wrap(&block) }
|
|
19
|
+
|
|
20
|
+
Anway.config.on_error { |error, _job| Rails.error.report(error, handled: true, source: 'anway') }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|