cloudist 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- data/.document +5 -0
- data/.rspec +1 -0
- data/Gemfile +18 -0
- data/Gemfile.lock +61 -0
- data/LICENSE.txt +20 -0
- data/README.md +113 -0
- data/Rakefile +63 -0
- data/VERSION +1 -0
- data/examples/queue_message.rb +19 -0
- data/examples/sandwich_client.rb +17 -0
- data/examples/sandwich_worker.rb +21 -0
- data/lib/cloudist.rb +141 -0
- data/lib/cloudist/basic_queue.rb +91 -0
- data/lib/cloudist/core_ext/string.rb +11 -0
- data/lib/cloudist/errors.rb +6 -0
- data/lib/cloudist/job.rb +54 -0
- data/lib/cloudist/job_queue.rb +28 -0
- data/lib/cloudist/listener.rb +30 -0
- data/lib/cloudist/payload.rb +128 -0
- data/lib/cloudist/publisher.rb +17 -0
- data/lib/cloudist/reply_queue.rb +32 -0
- data/lib/cloudist/request.rb +51 -0
- data/lib/cloudist/utils.rb +36 -0
- data/lib/cloudist/worker.rb +24 -0
- data/spec/cloudist/basic_queue_spec.rb +33 -0
- data/spec/cloudist/job_spec.rb +23 -0
- data/spec/cloudist/payload_spec.rb +126 -0
- data/spec/cloudist/request_spec.rb +41 -0
- data/spec/cloudist_spec.rb +24 -0
- data/spec/core_ext/string_spec.rb +16 -0
- data/spec/spec_helper.rb +15 -0
- metadata +270 -0
@@ -0,0 +1,91 @@
|
|
1
|
+
module Cloudist
|
2
|
+
class UnknownReplyTo < RuntimeError; end
|
3
|
+
class ExpiredMessage < RuntimeError; end
|
4
|
+
|
5
|
+
class BasicQueue
|
6
|
+
attr_reader :queue_name, :opts
|
7
|
+
attr_reader :q, :ex, :mq
|
8
|
+
|
9
|
+
def initialize(queue_name, opts = {})
|
10
|
+
opts = {
|
11
|
+
:auto_delete => true,
|
12
|
+
:durable => false,
|
13
|
+
:prefetch => 1
|
14
|
+
}.update(opts)
|
15
|
+
|
16
|
+
@queue_name, @opts = queue_name, opts
|
17
|
+
end
|
18
|
+
|
19
|
+
def setup
|
20
|
+
return if @setup == true
|
21
|
+
|
22
|
+
@mq = MQ.new
|
23
|
+
@q = @mq.queue(queue_name, opts)
|
24
|
+
# if we don't specify an exchange name it defaults to the queue_name
|
25
|
+
@ex = @mq.direct(opts[:exchange_name] || queue_name)
|
26
|
+
|
27
|
+
q.bind(ex) if ex
|
28
|
+
|
29
|
+
@setup = true
|
30
|
+
end
|
31
|
+
|
32
|
+
def log
|
33
|
+
Cloudist.log
|
34
|
+
end
|
35
|
+
|
36
|
+
def tag
|
37
|
+
s = "queue=#{q.name}"
|
38
|
+
s += " exchange=#{ex.name}" if ex
|
39
|
+
s
|
40
|
+
end
|
41
|
+
|
42
|
+
def subscribe(amqp_opts={}, opts={})
|
43
|
+
setup
|
44
|
+
|
45
|
+
q.subscribe(amqp_opts) do |queue_header, json_encoded_message|
|
46
|
+
return if Cloudist.closing?
|
47
|
+
|
48
|
+
request = Cloudist::Request.new(self, json_encoded_message, queue_header)
|
49
|
+
|
50
|
+
begin
|
51
|
+
raise Cloudist::ExpiredMessage if request.expired?
|
52
|
+
yield request if block_given?
|
53
|
+
finished = Time.now.utc.to_i
|
54
|
+
|
55
|
+
rescue Cloudist::ExpiredMessage
|
56
|
+
log.info "amqp_message action=timeout #{tag} ttl=#{request.ttl} age=#{request.age} #{request.inspect}"
|
57
|
+
request.ack if amqp_opts[:ack]
|
58
|
+
|
59
|
+
rescue => e
|
60
|
+
request.ack if amqp_opts[:ack]
|
61
|
+
Cloudist.handle_error(e)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
log.info "amqp_subscribe #{tag}"
|
65
|
+
self
|
66
|
+
end
|
67
|
+
|
68
|
+
def publish(payload)
|
69
|
+
payload.set_reply_to(queue_name)
|
70
|
+
body, headers = payload.formatted
|
71
|
+
ex.publish(body, headers)
|
72
|
+
payload.publish
|
73
|
+
end
|
74
|
+
|
75
|
+
def publish_to_q(payload)
|
76
|
+
body, headers = payload.formatted
|
77
|
+
q.publish(body, headers)
|
78
|
+
payload.publish
|
79
|
+
end
|
80
|
+
|
81
|
+
def teardown
|
82
|
+
@q.unsubscribe
|
83
|
+
@mq.close
|
84
|
+
log.debug "amqp_unsubscribe #{tag}"
|
85
|
+
end
|
86
|
+
|
87
|
+
def destroy
|
88
|
+
teardown
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
class String
|
2
|
+
# Returns true iff +other+ appears exactly at the start of +self+.
|
3
|
+
def starts_with? other
|
4
|
+
self[0, other.length] == other
|
5
|
+
end
|
6
|
+
|
7
|
+
# Returns true iff +other+ appears exactly at the end of +self+.
|
8
|
+
def ends_with? other
|
9
|
+
self[-other.length, other.length] == other
|
10
|
+
end
|
11
|
+
end
|
data/lib/cloudist/job.rb
ADDED
@@ -0,0 +1,54 @@
|
|
1
|
+
module Cloudist
|
2
|
+
class Job
|
3
|
+
attr_reader :payload
|
4
|
+
def initialize(payload)
|
5
|
+
@payload = payload
|
6
|
+
end
|
7
|
+
|
8
|
+
def id
|
9
|
+
payload.id
|
10
|
+
end
|
11
|
+
|
12
|
+
def data
|
13
|
+
payload.body
|
14
|
+
end
|
15
|
+
|
16
|
+
def log
|
17
|
+
Cloudist.log
|
18
|
+
end
|
19
|
+
|
20
|
+
def cleanup
|
21
|
+
|
22
|
+
end
|
23
|
+
|
24
|
+
def reply(data, headers = {})
|
25
|
+
# headers.update(:message_id => payload.headers[:message_id])
|
26
|
+
headers = {
|
27
|
+
:message_id => payload.headers[:message_id],
|
28
|
+
:reply_type => "reply"
|
29
|
+
}.update(headers)
|
30
|
+
|
31
|
+
reply_payload = Payload.new(data, headers)
|
32
|
+
|
33
|
+
reply_queue = ReplyQueue.new(payload.reply_to)
|
34
|
+
reply_queue.setup
|
35
|
+
reply_queue.publish_to_q(reply_payload)
|
36
|
+
|
37
|
+
# log.debug("Replying: #{data.inspect} - Payload: #{reply_payload.inspect}")
|
38
|
+
end
|
39
|
+
|
40
|
+
def event(event_name, data = {})
|
41
|
+
data = {} unless data
|
42
|
+
reply({:event => event_name}.merge(payload.body), {:reply_type => "event"})
|
43
|
+
end
|
44
|
+
|
45
|
+
def method_missing(meth, *args, &blk)
|
46
|
+
if meth.to_s.ends_with?("!")
|
47
|
+
event(meth.to_s.gsub(/(!)$/, ''), args.shift)
|
48
|
+
else
|
49
|
+
super
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module Cloudist
|
2
|
+
class JobQueue < BasicQueue
|
3
|
+
attr_reader :prefetch
|
4
|
+
|
5
|
+
def initialize(queue_name, opts={})
|
6
|
+
@prefetch = opts.delete(:prefetch) || 1
|
7
|
+
opts[:auto_delete] = false
|
8
|
+
|
9
|
+
super(queue_name, opts)
|
10
|
+
end
|
11
|
+
|
12
|
+
def setup
|
13
|
+
super
|
14
|
+
@mq.prefetch(self.prefetch)
|
15
|
+
end
|
16
|
+
|
17
|
+
def subscribe(amqp_opts={}, opts={})
|
18
|
+
amqp_opts[:ack] = true
|
19
|
+
super(amqp_opts, opts) do |request|
|
20
|
+
begin
|
21
|
+
yield request if block_given?
|
22
|
+
ensure
|
23
|
+
request.ack unless amqp_opts[:auto_ack] == false
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Cloudist
|
2
|
+
class Listener
|
3
|
+
|
4
|
+
attr_reader :job_queue_name, :job_id
|
5
|
+
|
6
|
+
def initialize(job_or_queue_name)
|
7
|
+
if job_or_queue_name.is_a?(Cloudist::Job)
|
8
|
+
@job_queue_name = Utils.reply_prefix(job_or_queue_name.payload.headers[:master_queue])
|
9
|
+
@job_id = job_or_queue_name.id
|
10
|
+
elsif job_or_queue_name.is_a?(String)
|
11
|
+
@job_queue_name = Utils.reply_prefix(job_or_queue_name)
|
12
|
+
@job_id = nil
|
13
|
+
else
|
14
|
+
raise ArgumentError, "Invalid listener type, accepts job queue name or Cloudist::Job instance"
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def subscribe(&block)
|
19
|
+
reply_queue = Cloudist::ReplyQueue.new(job_queue_name)
|
20
|
+
reply_queue.setup(job_id) if job_id
|
21
|
+
|
22
|
+
reply_queue.subscribe do |request|
|
23
|
+
job = Job.new(request.payload)
|
24
|
+
|
25
|
+
job.instance_eval(&block)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,128 @@
|
|
1
|
+
module Cloudist
|
2
|
+
DEFAULT_TTL = 300
|
3
|
+
|
4
|
+
class Payload
|
5
|
+
include Utils
|
6
|
+
|
7
|
+
attr_accessor :body, :headers
|
8
|
+
|
9
|
+
def initialize(data_hash_or_json, headers = {})
|
10
|
+
data_hash_or_json = parse_message(data_hash_or_json) if data_hash_or_json.is_a?(String)
|
11
|
+
|
12
|
+
raise Cloudist::BadPayload, "Expected Hash for payload" unless data_hash_or_json.is_a?(Hash)
|
13
|
+
|
14
|
+
@body, @headers = HashWithIndifferentAccess.new(data_hash_or_json), headers
|
15
|
+
update_headers
|
16
|
+
end
|
17
|
+
|
18
|
+
def formatted
|
19
|
+
body, headers = apply_custom_headers
|
20
|
+
|
21
|
+
# Return message formatted as JSON and headers ready for transport
|
22
|
+
[body.to_json, headers]
|
23
|
+
end
|
24
|
+
|
25
|
+
def id
|
26
|
+
@id ||= event_hash.to_s
|
27
|
+
end
|
28
|
+
|
29
|
+
def id=(new_id)
|
30
|
+
@id = new_id.to_s
|
31
|
+
update_headers
|
32
|
+
end
|
33
|
+
|
34
|
+
def frozen?
|
35
|
+
headers.frozen?
|
36
|
+
end
|
37
|
+
|
38
|
+
def freeze!
|
39
|
+
headers.freeze
|
40
|
+
body.freeze
|
41
|
+
end
|
42
|
+
|
43
|
+
def update_headers
|
44
|
+
raise StaleHeadersError, "Headers cannot be changed because payload has already been published" if published?
|
45
|
+
|
46
|
+
headers[:published_on] ||= body.delete('published_on') || Time.now.utc.to_i
|
47
|
+
headers[:ttl] ||= body.delete('ttl') || Cloudist::DEFAULT_TTL
|
48
|
+
|
49
|
+
# this is the event hash that gets transferred through various publish/reply actions
|
50
|
+
headers[:event_hash] ||= id
|
51
|
+
|
52
|
+
# this value should be unique for each published/received message pair
|
53
|
+
headers[:message_id] ||= id
|
54
|
+
|
55
|
+
# We use JSON for message transport exclusively
|
56
|
+
headers[:content_type] ||= 'application/json'
|
57
|
+
|
58
|
+
# some strange behavior with integers makes it better to
|
59
|
+
# convert all amqp headers to strings to avoid any problems
|
60
|
+
headers.each { |k,v| headers[k] = v.to_s }
|
61
|
+
end
|
62
|
+
|
63
|
+
def apply_custom_headers
|
64
|
+
update_headers
|
65
|
+
[body, headers]
|
66
|
+
end
|
67
|
+
|
68
|
+
def parse_custom_headers
|
69
|
+
return { } unless headers
|
70
|
+
|
71
|
+
h = headers.dup
|
72
|
+
|
73
|
+
h[:published_on] = h[:published_on].to_i
|
74
|
+
|
75
|
+
h[:ttl] = h[:ttl].to_i rescue -1
|
76
|
+
h[:ttl] = -1 if h[:ttl] == 0
|
77
|
+
|
78
|
+
h
|
79
|
+
end
|
80
|
+
|
81
|
+
def set_reply_to(queue_name)
|
82
|
+
headers[:reply_to] = reply_name(queue_name)
|
83
|
+
set_master_queue_name(queue_name)
|
84
|
+
end
|
85
|
+
|
86
|
+
def set_master_queue_name(queue_name)
|
87
|
+
headers[:master_queue] = queue_name
|
88
|
+
end
|
89
|
+
|
90
|
+
def reply_name(queue_name)
|
91
|
+
# "#{queue_name}.#{id}"
|
92
|
+
Utils.reply_prefix(queue_name)
|
93
|
+
end
|
94
|
+
|
95
|
+
def reply_to
|
96
|
+
headers[:reply_to]
|
97
|
+
end
|
98
|
+
|
99
|
+
def event_hash
|
100
|
+
@event_hash ||= headers[:event_hash] || body.delete('event_hash') || create_event_hash
|
101
|
+
end
|
102
|
+
|
103
|
+
def create_event_hash
|
104
|
+
s = Time.now.to_s + object_id.to_s + rand(100).to_s
|
105
|
+
Digest::MD5.hexdigest(s)
|
106
|
+
end
|
107
|
+
|
108
|
+
def parse_message(raw)
|
109
|
+
return { } unless raw
|
110
|
+
JSON.parse(raw)
|
111
|
+
end
|
112
|
+
|
113
|
+
def [](key)
|
114
|
+
body[key]
|
115
|
+
end
|
116
|
+
|
117
|
+
def published?
|
118
|
+
@published == true
|
119
|
+
end
|
120
|
+
|
121
|
+
def publish
|
122
|
+
return if published?
|
123
|
+
@published = true
|
124
|
+
freeze!
|
125
|
+
end
|
126
|
+
|
127
|
+
end
|
128
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module Cloudist
|
2
|
+
class Publisher
|
3
|
+
|
4
|
+
class << self
|
5
|
+
def enqueue(queue_name, data)
|
6
|
+
payload = Cloudist::Payload.new(data)
|
7
|
+
|
8
|
+
queue = Cloudist::JobQueue.new(queue_name)
|
9
|
+
queue.setup
|
10
|
+
queue.publish(payload)
|
11
|
+
|
12
|
+
return Job.new(payload)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module Cloudist
|
2
|
+
class ReplyQueue < Cloudist::BasicQueue
|
3
|
+
def initialize(queue_name, opts={})
|
4
|
+
opts[:auto_delete] = true
|
5
|
+
super
|
6
|
+
end
|
7
|
+
|
8
|
+
def setup(key = nil)
|
9
|
+
@mq = MQ.new
|
10
|
+
@q = @mq.queue(queue_name, opts)
|
11
|
+
@ex = @mq.direct
|
12
|
+
if key
|
13
|
+
@q.bind(@ex, :key => key)
|
14
|
+
else
|
15
|
+
@q.bind(@ex)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
# def subscribe(amqp_opts={}, opts={})
|
20
|
+
# super(amqp_opts, opts) do |request|
|
21
|
+
# yield request if block_given?
|
22
|
+
# self.destroy
|
23
|
+
# end
|
24
|
+
# end
|
25
|
+
#
|
26
|
+
# def teardown
|
27
|
+
# @q.delete
|
28
|
+
# super
|
29
|
+
# end
|
30
|
+
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
module Cloudist
|
2
|
+
class Request
|
3
|
+
attr_reader :queue_header, :qobj, :payload, :start, :headers
|
4
|
+
|
5
|
+
def initialize(queue, json_encoded_message, queue_header)
|
6
|
+
@qobj, @queue_header = queue, queue_header
|
7
|
+
|
8
|
+
@payload = Cloudist::Payload.new(json_encoded_message.dup, queue_header.properties.dup)
|
9
|
+
@headers = @payload.parse_custom_headers
|
10
|
+
|
11
|
+
@start = Time.now.utc.to_i
|
12
|
+
end
|
13
|
+
|
14
|
+
def q
|
15
|
+
qobj.q
|
16
|
+
end
|
17
|
+
|
18
|
+
def ex
|
19
|
+
qobj.ex
|
20
|
+
end
|
21
|
+
|
22
|
+
def mq
|
23
|
+
qobj.mq
|
24
|
+
end
|
25
|
+
|
26
|
+
def age
|
27
|
+
return -1 unless headers[:published_on]
|
28
|
+
start - headers[:published_on]
|
29
|
+
end
|
30
|
+
|
31
|
+
def ttl
|
32
|
+
headers[:ttl] || -1
|
33
|
+
end
|
34
|
+
|
35
|
+
def expired?
|
36
|
+
return false if ttl == -1
|
37
|
+
age > ttl
|
38
|
+
end
|
39
|
+
|
40
|
+
def acked?
|
41
|
+
@acked == true
|
42
|
+
end
|
43
|
+
|
44
|
+
def ack
|
45
|
+
return if acked?
|
46
|
+
queue_header.ack
|
47
|
+
@acked = true
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
51
|
+
end
|