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,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
@@ -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
@@ -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