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