sqewer 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|