sqewer 1.0.0

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