sqewer 1.0.0
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/.gitlab-ci.yml +12 -0
- data/.yardopts +1 -0
- data/DETAILS.md +180 -0
- data/FAQ.md +54 -0
- data/Gemfile +18 -0
- data/README.md +69 -0
- data/Rakefile +41 -0
- data/example.env +6 -0
- data/lib/sqewer.rb +11 -0
- data/lib/sqewer/atomic_counter.rb +22 -0
- data/lib/sqewer/cli.rb +44 -0
- data/lib/sqewer/connection.rb +66 -0
- data/lib/sqewer/contrib/appsignal_wrapper.rb +29 -0
- data/lib/sqewer/contrib/performable.rb +23 -0
- data/lib/sqewer/execution_context.rb +55 -0
- data/lib/sqewer/isolator.rb +33 -0
- data/lib/sqewer/middleware_stack.rb +44 -0
- data/lib/sqewer/null_logger.rb +9 -0
- data/lib/sqewer/serializer.rb +71 -0
- data/lib/sqewer/simple_job.rb +78 -0
- data/lib/sqewer/submitter.rb +18 -0
- data/lib/sqewer/version.rb +3 -0
- data/lib/sqewer/worker.rb +200 -0
- data/spec/conveyor_belt_spec.rb +14 -0
- data/spec/spec_helper.rb +49 -0
- data/spec/sqewer/atomic_counter_spec.rb +15 -0
- data/spec/sqewer/cli_app.rb +13 -0
- data/spec/sqewer/cli_spec.rb +57 -0
- data/spec/sqewer/connection_spec.rb +57 -0
- data/spec/sqewer/execution_context_spec.rb +43 -0
- data/spec/sqewer/middleware_stack_spec.rb +69 -0
- data/spec/sqewer/serializer_spec.rb +123 -0
- data/spec/sqewer/simple_job_spec.rb +69 -0
- data/spec/sqewer/submitter_spec.rb +59 -0
- data/spec/sqewer/worker_spec.rb +130 -0
- data/sqewer.gemspec +108 -0
- metadata +248 -0
@@ -0,0 +1,14 @@
|
|
1
|
+
require_relative 'spec_helper'
|
2
|
+
|
3
|
+
describe Sqewer do
|
4
|
+
it 'provides a #submit!() method that is a shortcut to the default submitter' do
|
5
|
+
fake_submitter = double('Submitter')
|
6
|
+
expect(Sqewer::Submitter).to receive(:default) { fake_submitter }
|
7
|
+
|
8
|
+
first_job = double('Job1')
|
9
|
+
second_job = double('Job2')
|
10
|
+
|
11
|
+
expect(fake_submitter).to receive(:submit!).with(first_job, second_job, {})
|
12
|
+
Sqewer.submit!(first_job, second_job)
|
13
|
+
end
|
14
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
2
|
+
$LOAD_PATH.unshift(File.dirname(__FILE__))
|
3
|
+
|
4
|
+
require 'rspec'
|
5
|
+
require 'simplecov'
|
6
|
+
|
7
|
+
require 'dotenv'
|
8
|
+
Dotenv.load
|
9
|
+
|
10
|
+
require 'aws-sdk'
|
11
|
+
|
12
|
+
SimpleCov.start
|
13
|
+
require 'sqewer'
|
14
|
+
|
15
|
+
module Polling
|
16
|
+
# Call the given block every N seconds, and return once the
|
17
|
+
# block returns a truthy value. If it still did not return
|
18
|
+
# the value after fail_after, fail the spec.
|
19
|
+
def poll(every: 0.5, fail_after:, &check_block)
|
20
|
+
started_polling = Time.now
|
21
|
+
loop do
|
22
|
+
return if check_block.call
|
23
|
+
sleep(every)
|
24
|
+
if (Time.now - started_polling) > fail_after
|
25
|
+
fail "Waited for #{fail_after} seconds for the operation to complete but it didnt"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
RSpec.configure do |config|
|
32
|
+
config.order = 'random'
|
33
|
+
config.include Polling
|
34
|
+
|
35
|
+
config.around :each do | example |
|
36
|
+
if example.metadata[:sqs]
|
37
|
+
queue_name = 'conveyor-belt-test-queue-%s' % SecureRandom.hex(6)
|
38
|
+
client = Aws::SQS::Client.new
|
39
|
+
resp = client.create_queue(queue_name: queue_name)
|
40
|
+
ENV['SQS_QUEUE_URL'] = resp.queue_url
|
41
|
+
example.run
|
42
|
+
resp = client.delete_queue(queue_url: ENV.fetch('SQS_QUEUE_URL'))
|
43
|
+
ENV.delete('SQS_QUEUE_URL')
|
44
|
+
else
|
45
|
+
example.run
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require_relative '../spec_helper'
|
2
|
+
|
3
|
+
describe Sqewer::AtomicCounter do
|
4
|
+
it 'is atomic' do
|
5
|
+
c = described_class.new
|
6
|
+
expect(c.to_i).to be_zero
|
7
|
+
|
8
|
+
threads = (1..64).map do
|
9
|
+
Thread.new { sleep(rand); c.increment! }
|
10
|
+
end
|
11
|
+
threads.map(&:join)
|
12
|
+
|
13
|
+
expect(c.to_i).to eq(threads.length)
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
require_relative '../../lib/sqewer'
|
2
|
+
|
3
|
+
class MyJob
|
4
|
+
include Sqewer::SimpleJob
|
5
|
+
attr_accessor :first_name
|
6
|
+
attr_accessor :last_name
|
7
|
+
|
8
|
+
def run(executor)
|
9
|
+
File.open("#{SecureRandom.hex(3)}-result", 'w') {|f| f << [first_name, last_name].join }
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
Sqewer::CLI.start
|
@@ -0,0 +1,57 @@
|
|
1
|
+
require_relative '../spec_helper'
|
2
|
+
|
3
|
+
describe Sqewer::CLI, :sqs => true do
|
4
|
+
describe 'runs the commandline app, executes jobs and then quits cleanly' do
|
5
|
+
it 'on a USR1 signal' do
|
6
|
+
submitter = Sqewer::Connection.default
|
7
|
+
|
8
|
+
stderr = Tempfile.new('worker-stderr')
|
9
|
+
|
10
|
+
pid = fork { $stderr.reopen(stderr); exec("ruby #{__dir__}/cli_app.rb") }
|
11
|
+
|
12
|
+
Thread.new do
|
13
|
+
20.times do
|
14
|
+
j = {job_class: 'MyJob', first_name: 'John', last_name: 'Doe'}
|
15
|
+
submitter.send_message(JSON.dump(j))
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
sleep 4
|
20
|
+
Process.kill("USR1", pid)
|
21
|
+
|
22
|
+
generated_files = Dir.glob('*-result')
|
23
|
+
expect(generated_files).not_to be_empty
|
24
|
+
generated_files.each{|path| File.unlink(path) }
|
25
|
+
|
26
|
+
stderr.rewind
|
27
|
+
log_output = stderr.read
|
28
|
+
expect(log_output).to include('Stopping (clean shutdown)')
|
29
|
+
end
|
30
|
+
|
31
|
+
it 'on a TERM signal' do
|
32
|
+
submitter = Sqewer::Connection.default
|
33
|
+
|
34
|
+
stderr = Tempfile.new('worker-stderr')
|
35
|
+
|
36
|
+
pid = fork { $stderr.reopen(stderr); exec("ruby #{__dir__}/cli_app.rb") }
|
37
|
+
|
38
|
+
Thread.new do
|
39
|
+
20.times do
|
40
|
+
j = {job_class: 'MyJob', first_name: 'John', last_name: 'Doe'}
|
41
|
+
submitter.send_message(JSON.dump(j))
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
sleep 4
|
46
|
+
Process.kill("TERM", pid)
|
47
|
+
|
48
|
+
generated_files = Dir.glob('*-result')
|
49
|
+
expect(generated_files).not_to be_empty
|
50
|
+
generated_files.each{|path| File.unlink(path) }
|
51
|
+
|
52
|
+
stderr.rewind
|
53
|
+
log_output = stderr.read
|
54
|
+
expect(log_output).to include('Stopping (clean shutdown)')
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
require_relative '../spec_helper'
|
2
|
+
|
3
|
+
describe Sqewer::Connection do
|
4
|
+
describe '.default' do
|
5
|
+
it 'returns a new Connection with the SQS queue location picked from SQS_QUEUE_URL envvar' do
|
6
|
+
expect(ENV).to receive(:fetch).with('SQS_QUEUE_URL').and_return('https://aws-fake-queue.com')
|
7
|
+
default = described_class.default
|
8
|
+
expect(default).to be_kind_of(described_class)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
describe '#send_message' do
|
13
|
+
it 'sends the message to the SQS client created with the URL given to the constructor' do
|
14
|
+
fake_sqs_client = double('Client')
|
15
|
+
expect(Aws::SQS::Client).to receive(:new) { fake_sqs_client }
|
16
|
+
expect(fake_sqs_client).to receive(:send_message).
|
17
|
+
with({:queue_url=>"https://fake-queue.com", :message_body=>"abcdef"})
|
18
|
+
|
19
|
+
conn = described_class.new('https://fake-queue.com')
|
20
|
+
conn.send_message('abcdef')
|
21
|
+
end
|
22
|
+
|
23
|
+
it 'passes keyword args to Aws::SQS::Client' do
|
24
|
+
fake_sqs_client = double('Client')
|
25
|
+
expect(Aws::SQS::Client).to receive(:new) { fake_sqs_client }
|
26
|
+
expect(fake_sqs_client).to receive(:send_message).
|
27
|
+
with({:queue_url=>"https://fake-queue.com", :message_body=>"abcdef", delay_seconds: 5})
|
28
|
+
|
29
|
+
conn = described_class.new('https://fake-queue.com')
|
30
|
+
conn.send_message('abcdef', delay_seconds: 5)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
describe '#poll' do
|
35
|
+
it 'uses the batched receive feature' do
|
36
|
+
s = described_class.new('https://fake-queue')
|
37
|
+
|
38
|
+
fake_poller = double('QueuePoller')
|
39
|
+
expect(::Aws::SQS::QueuePoller).to receive(:new).with('https://fake-queue') { fake_poller }
|
40
|
+
expect(fake_poller).to receive(:poll) {|*a, **k, &blk|
|
41
|
+
expect(k[:max_number_of_messages]).to be > 1
|
42
|
+
bulk = (1..5).map do
|
43
|
+
double('SQSMessage', receipt_handle: SecureRandom.hex(4), body: 'Some message')
|
44
|
+
end
|
45
|
+
# Yields arrays of messages, so...
|
46
|
+
blk.call(bulk)
|
47
|
+
}
|
48
|
+
|
49
|
+
receives = []
|
50
|
+
s.poll do | sqs_message_handle, sqs_message_body |
|
51
|
+
receives << [sqs_message_handle, sqs_message_body]
|
52
|
+
end
|
53
|
+
|
54
|
+
expect(receives.length).to eq(5)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
require_relative '../spec_helper'
|
2
|
+
|
3
|
+
describe Sqewer::ExecutionContext do
|
4
|
+
it 'offers a submit! that goes through the given Submitter argument' do
|
5
|
+
fake_submitter = double('Submitter')
|
6
|
+
expect(fake_submitter).to receive(:submit!).with(:fake_job, {})
|
7
|
+
|
8
|
+
subject = described_class.new(fake_submitter)
|
9
|
+
subject.submit!(:fake_job)
|
10
|
+
end
|
11
|
+
|
12
|
+
it 'offers arbitrary key/value storage' do
|
13
|
+
fake_submitter = double('Submitter')
|
14
|
+
subject = described_class.new(fake_submitter)
|
15
|
+
|
16
|
+
subject['foo'] = 123
|
17
|
+
expect(subject['foo']).to eq(123)
|
18
|
+
expect(subject[:foo]).to eq(123)
|
19
|
+
expect(subject.fetch(:foo)).to eq(123)
|
20
|
+
|
21
|
+
expect {
|
22
|
+
subject.fetch(:bar)
|
23
|
+
}.to raise_error(KeyError)
|
24
|
+
|
25
|
+
default_value = subject.fetch(:bar) { 123 }
|
26
|
+
expect(default_value).to eq(123)
|
27
|
+
end
|
28
|
+
|
29
|
+
it 'returns the NullLogger from #logger if no logger was passed to the constructor' do
|
30
|
+
fake_submitter = double('Submitter')
|
31
|
+
|
32
|
+
subject = described_class.new(fake_submitter)
|
33
|
+
expect(subject.logger).to eq(Sqewer::NullLogger)
|
34
|
+
end
|
35
|
+
|
36
|
+
it 'offers access to the given "logger" extra param if it was given to the constructor' do
|
37
|
+
fake_submitter = double('Submitter')
|
38
|
+
fake_logger = double('Logger')
|
39
|
+
|
40
|
+
subject = described_class.new(fake_submitter, {'logger' => fake_logger})
|
41
|
+
expect(subject.logger).to eq(fake_logger)
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
require_relative '../spec_helper'
|
2
|
+
|
3
|
+
describe Sqewer::MiddlewareStack do
|
4
|
+
it 'returns a default instance' do
|
5
|
+
stacks = (1..100).map { described_class.default }
|
6
|
+
expect(stacks.uniq.length).to eq(1)
|
7
|
+
end
|
8
|
+
|
9
|
+
describe '#around_deserialization' do
|
10
|
+
it 'works without any handlers added' do
|
11
|
+
stack = described_class.new
|
12
|
+
prepare_result = stack.around_deserialization(nil, 'msg-123', '{"body":"some text"}') { :prepared }
|
13
|
+
expect(prepare_result).to eq(:prepared)
|
14
|
+
end
|
15
|
+
|
16
|
+
it 'works with an entire stack' do
|
17
|
+
called = []
|
18
|
+
handler = double('Some middleware')
|
19
|
+
allow(handler).to receive(:around_deserialization){|*a, &blk|
|
20
|
+
called << a
|
21
|
+
blk.call
|
22
|
+
}
|
23
|
+
|
24
|
+
stack = described_class.new
|
25
|
+
stack << handler
|
26
|
+
stack << double('Object that does not handle around_deserialization')
|
27
|
+
stack << handler
|
28
|
+
stack << handler
|
29
|
+
result = stack.around_deserialization(nil, 'msg-123', '{"body":"some text"}') { :foo }
|
30
|
+
expect(result).to eq(:foo)
|
31
|
+
expect(called).not_to be_empty
|
32
|
+
|
33
|
+
expect(called).to eq([
|
34
|
+
[nil, "msg-123", "{\"body\":\"some text\"}"],
|
35
|
+
[nil, "msg-123", "{\"body\":\"some text\"}"],
|
36
|
+
[nil, "msg-123", "{\"body\":\"some text\"}"]]
|
37
|
+
)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
describe '#around_execution' do
|
42
|
+
it 'works without any handlers added' do
|
43
|
+
stack = described_class.new
|
44
|
+
prepare_result = stack.around_execution(nil, nil) { :prepared }
|
45
|
+
expect(prepare_result).to eq(:prepared)
|
46
|
+
end
|
47
|
+
|
48
|
+
it 'works with an entire stack' do
|
49
|
+
called = []
|
50
|
+
handler = double('Some middleware')
|
51
|
+
allow(handler).to receive(:around_execution){|*a, &blk|
|
52
|
+
called << a
|
53
|
+
blk.call
|
54
|
+
}
|
55
|
+
|
56
|
+
stack = described_class.new
|
57
|
+
stack << handler
|
58
|
+
stack << handler
|
59
|
+
stack << double('Object that does not handle around_execution')
|
60
|
+
stack << handler
|
61
|
+
|
62
|
+
result = stack.around_execution(:some_job, :some_context) { :executed }
|
63
|
+
expect(result).to eq(:executed)
|
64
|
+
expect(called).not_to be_empty
|
65
|
+
|
66
|
+
expect(called).to eq([[:some_job, :some_context], [:some_job, :some_context], [:some_job, :some_context]])
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,123 @@
|
|
1
|
+
require_relative '../spec_helper'
|
2
|
+
|
3
|
+
describe Sqewer::Serializer do
|
4
|
+
describe '.default' do
|
5
|
+
it 'returns the same Serializer instance' do
|
6
|
+
instances = (1..1000).map{ described_class.default }
|
7
|
+
instances.uniq!
|
8
|
+
expect(instances).to be_one
|
9
|
+
|
10
|
+
the_instance = instances[0]
|
11
|
+
expect(the_instance).to respond_to(:serialize)
|
12
|
+
expect(the_instance).to respond_to(:unserialize)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
describe '#serialize' do
|
17
|
+
|
18
|
+
it 'serializes a Job that has no to_h support without its kwargs' do
|
19
|
+
class JobWithoutToHash
|
20
|
+
end
|
21
|
+
job = JobWithoutToHash.new
|
22
|
+
expect(described_class.new.serialize(job)).to eq("{\"_job_class\":\"JobWithoutToHash\",\"_job_params\":null}")
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'serializes a Struct along with its members and the class name' do
|
26
|
+
class SomeJob < Struct.new :one, :two
|
27
|
+
end
|
28
|
+
|
29
|
+
job = SomeJob.new(123, [456])
|
30
|
+
|
31
|
+
expect(described_class.new.serialize(job)).to eq("{\"_job_class\":\"SomeJob\",\"_job_params\":{\"one\":123,\"two\":[456]}}")
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'raises an exception if the object is of an anonymous class' do
|
35
|
+
s = Struct.new(:foo)
|
36
|
+
o = s.new(1)
|
37
|
+
expect {
|
38
|
+
described_class.new.serialize(o)
|
39
|
+
}.to raise_error(described_class::AnonymousJobClass)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
it 'is able to roundtrip a job with a parameter' do
|
44
|
+
require 'ks'
|
45
|
+
|
46
|
+
class LeJob < Ks.strict(:some_data)
|
47
|
+
end
|
48
|
+
|
49
|
+
job = LeJob.new(some_data: 123)
|
50
|
+
|
51
|
+
subject = described_class.new
|
52
|
+
|
53
|
+
serialized = subject.serialize(job)
|
54
|
+
restored = subject.unserialize(serialized)
|
55
|
+
|
56
|
+
expect(restored).to be_kind_of(LeJob)
|
57
|
+
expect(restored.some_data).to eq(123)
|
58
|
+
end
|
59
|
+
|
60
|
+
describe '#unserialize' do
|
61
|
+
describe 'with the old ticket format' do
|
62
|
+
it 'builds a job without keyword arguments if its constructor does not need any kwargs' do
|
63
|
+
class VerySimpleJob; end
|
64
|
+
blob = '{"job_class": "VerySimpleJob"}'
|
65
|
+
built_job = described_class.new.unserialize(blob)
|
66
|
+
expect(built_job).to be_kind_of(VerySimpleJob)
|
67
|
+
end
|
68
|
+
|
69
|
+
it 'raises an error if the job does not accept the keyword arguments given in the ticket' do
|
70
|
+
class OtherVerySimpleJob; end
|
71
|
+
blob = '{"job_class": "OtherVerySimpleJob", "foo": 1}'
|
72
|
+
|
73
|
+
expect {
|
74
|
+
described_class.new.unserialize(blob)
|
75
|
+
}.to raise_error(described_class::ArityMismatch)
|
76
|
+
end
|
77
|
+
|
78
|
+
it 'instantiates the job with keyword arguments' do
|
79
|
+
ValidJob = Ks.strict(:foo)
|
80
|
+
|
81
|
+
blob = '{"job_class": "ValidJob", "foo": 1}'
|
82
|
+
built_job = described_class.new.unserialize(blob)
|
83
|
+
|
84
|
+
expect(built_job).to be_kind_of(ValidJob)
|
85
|
+
expect(built_job.foo).to eq(1)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
describe 'with the new ticket format' do
|
90
|
+
it 'builds a job without keyword arguments if its constructor does not need any kwargs' do
|
91
|
+
class EvenSimplerJob; end
|
92
|
+
|
93
|
+
blob = '{"_job_class": "EvenSimplerJob"}'
|
94
|
+
built_job = described_class.new.unserialize(blob)
|
95
|
+
|
96
|
+
expect(built_job).to be_kind_of(EvenSimplerJob)
|
97
|
+
|
98
|
+
blob = '{"_job_class": "EvenSimplerJob", "_job_params": null}'
|
99
|
+
built_job = described_class.new.unserialize(blob)
|
100
|
+
|
101
|
+
expect(built_job).to be_kind_of(EvenSimplerJob)
|
102
|
+
end
|
103
|
+
|
104
|
+
it 'raises an error if the job does not accept the keyword arguments given in the ticket' do
|
105
|
+
class MicroJob; end
|
106
|
+
blob = '{"_job_class": "MicroJob", "_job_params":{"foo": 1}}'
|
107
|
+
expect {
|
108
|
+
described_class.new.unserialize(blob)
|
109
|
+
}.to raise_error(described_class::ArityMismatch)
|
110
|
+
end
|
111
|
+
|
112
|
+
it 'instantiates the job with keyword arguments' do
|
113
|
+
OtherValidJob = Ks.strict(:foo)
|
114
|
+
|
115
|
+
blob = '{"_job_class": "OtherValidJob", "_job_params": {"foo": 1}}'
|
116
|
+
built_job = described_class.new.unserialize(blob)
|
117
|
+
|
118
|
+
expect(built_job).to be_kind_of(OtherValidJob)
|
119
|
+
expect(built_job.foo).to eq(1)
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|