amqp-client 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5cf775bb44cd9021dec5416472d52dec17a5e0ae847de7d56ea0d3acd1e27b11
4
- data.tar.gz: 589416e377d8d3bcc448991664666aab664b7dd194fb8990e0294251439d0838
3
+ metadata.gz: 337f82d8ebaf862fd8fd1acd17f9b71be2aaee6de02f0c7c59786b222d5c5c0c
4
+ data.tar.gz: 040adffbda90616864a845517a1904efa5c1dbb25e30e63febe3f34b3cf4f752
5
5
  SHA512:
6
- metadata.gz: c3c5672321ffadc0966a3dd549697699be445a58db1448ac2e2cb2f06ad4a9b456036b64bfd52a1c35a56dd88301b36dea4cd639ca2d90155ead57d40b9ca16b
7
- data.tar.gz: b23676715032b2fb4e909a550e6674155e1ef2be9fae08e543db6811b7f8a456c35ac09d57b0a0fa0c356e9eea954614221c33c27898beb30acd36f5b2c8e41d
6
+ metadata.gz: 5700b885356d0c36060ffb4a9ad819bc26672a3d89fef6283cb5c4789ae3ddeafeaa57917f3001947fbdc38954eabfb138b8a98ed4a187a66912e5fb47bb3200
7
+ data.tar.gz: 034c2aa88d93bf45063d41b87e92cb20545deadf7c98930a7b6626e448a84036d234274c8c1066a85e6fd35a1274dfb392128a037f6fac7a330aee0af9907a74
@@ -5,14 +5,31 @@ on: [push,pull_request]
5
5
  jobs:
6
6
  build:
7
7
  runs-on: ubuntu-latest
8
+ services:
9
+ rabbitmq:
10
+ image: rabbitmq:latest
11
+ ports:
12
+ - 5672/tcp
13
+ # needed because the rabbitmq container does not provide a healthcheck
14
+ options: >-
15
+ --health-cmd "rabbitmqctl node_health_check"
16
+ --health-interval 10s
17
+ --health-timeout 5s
18
+ --health-retries 5
19
+ strategy:
20
+ fail-fast: false
21
+ matrix:
22
+ ruby: ['2.7', '3.0']
8
23
  steps:
9
24
  - uses: actions/checkout@v2
10
25
  - name: Set up Ruby
11
26
  uses: ruby/setup-ruby@v1
12
27
  with:
13
- ruby-version: 3.0.0
28
+ ruby-version: ${{ matrix.ruby }}
14
29
  - name: Run the default task
15
30
  run: |
16
- gem install bundler -v 2.2.15
17
31
  bundle install
18
32
  bundle exec rake
33
+ env:
34
+ AMQP_PORT: ${{ job.services.rabbitmq.ports[5672] }}
35
+
data/.gitignore CHANGED
@@ -6,3 +6,4 @@
6
6
  /pkg/
7
7
  /spec/reports/
8
8
  /tmp/
9
+ Gemfile.lock
data/.rubocop_todo.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  # This configuration was generated by
2
2
  # `rubocop --auto-gen-config`
3
- # on 2021-04-29 10:30:28 UTC using RuboCop version 1.12.1.
3
+ # on 2021-06-01 23:54:39 UTC using RuboCop version 1.15.0.
4
4
  # The point is for the user to remove these configuration records
5
5
  # one by one as the offenses are removed from the code base.
6
6
  # Note that changes in the inspected code, or installation of new
@@ -11,64 +11,62 @@
11
11
  # Configuration parameters: AutoCorrect, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns.
12
12
  # URISchemes: http, https
13
13
  Layout/LineLength:
14
- Max: 129
14
+ Max: 123
15
15
 
16
- # Offense count: 2
17
- Lint/ShadowedException:
18
- Exclude:
19
- - 'lib/amqp/client.rb'
20
- - 'lib/amqp/client/connection.rb'
21
-
22
- # Offense count: 2
23
- # Cop supports --auto-correct.
24
- # Configuration parameters: AllowUnusedKeywordArguments, IgnoreEmptyMethods, IgnoreNotImplementedMethods.
25
- Lint/UnusedMethodArgument:
26
- Exclude:
27
- - 'lib/amqp/client/channel.rb'
28
-
29
- # Offense count: 6
16
+ # Offense count: 15
30
17
  # Configuration parameters: IgnoredMethods, CountRepeatedAttributes.
31
18
  Metrics/AbcSize:
32
- Max: 52
19
+ Max: 142
33
20
 
34
21
  # Offense count: 1
35
22
  # Configuration parameters: CountComments, CountAsOne, ExcludedMethods, IgnoredMethods.
36
23
  # IgnoredMethods: refine
37
24
  Metrics/BlockLength:
38
- Max: 76
25
+ Max: 37
39
26
 
40
- # Offense count: 3
27
+ # Offense count: 2
28
+ # Configuration parameters: CountBlocks.
29
+ Metrics/BlockNesting:
30
+ Max: 4
31
+
32
+ # Offense count: 2
41
33
  # Configuration parameters: CountComments, CountAsOne.
42
34
  Metrics/ClassLength:
43
- Max: 148
35
+ Max: 191
44
36
 
45
- # Offense count: 5
37
+ # Offense count: 9
46
38
  # Configuration parameters: IgnoredMethods.
47
39
  Metrics/CyclomaticComplexity:
48
- Max: 18
40
+ Max: 30
49
41
 
50
- # Offense count: 9
42
+ # Offense count: 26
51
43
  # Configuration parameters: CountComments, CountAsOne, ExcludedMethods, IgnoredMethods.
52
44
  Metrics/MethodLength:
53
- Max: 83
45
+ Max: 124
54
46
 
55
- # Offense count: 1
47
+ # Offense count: 3
48
+ # Configuration parameters: CountComments, CountAsOne.
49
+ Metrics/ModuleLength:
50
+ Max: 300
51
+
52
+ # Offense count: 5
56
53
  # Configuration parameters: CountKeywordArgs, MaxOptionalParameters.
57
54
  Metrics/ParameterLists:
58
- Max: 6
55
+ Max: 7
59
56
 
60
- # Offense count: 3
57
+ # Offense count: 7
61
58
  # Configuration parameters: IgnoredMethods.
62
59
  Metrics/PerceivedComplexity:
63
- Max: 12
60
+ Max: 18
64
61
 
65
62
  # Offense count: 1
66
63
  # Cop supports --auto-correct.
67
- # Configuration parameters: EnforcedStyle.
68
- # SupportedStyles: always, always_true, never
69
- Style/FrozenStringLiteralComment:
64
+ # Configuration parameters: EnforcedStyle, IgnoredMethods.
65
+ # SupportedStyles: predicate, comparison
66
+ Style/NumericPredicate:
70
67
  Exclude:
71
- - 'bm_append_string.rb'
68
+ - 'spec/**/*'
69
+ - 'lib/amqp/client/channel.rb'
72
70
 
73
71
  # Offense count: 1
74
72
  # Cop supports --auto-correct.
@@ -78,9 +76,17 @@ Style/RescueStandardError:
78
76
  Exclude:
79
77
  - 'lib/amqp/client.rb'
80
78
 
79
+ # Offense count: 1
80
+ # Cop supports --auto-correct.
81
+ # Configuration parameters: EnforcedStyle.
82
+ # SupportedStyles: forbid_for_all_comparison_operators, forbid_for_equality_operators_only, require_for_all_comparison_operators, require_for_equality_operators_only
83
+ Style/YodaCondition:
84
+ Exclude:
85
+ - 'lib/amqp/client/channel.rb'
86
+
81
87
  # Offense count: 1
82
88
  # Cop supports --auto-correct.
83
89
  # Configuration parameters: AutoCorrect, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns.
84
90
  # URISchemes: http, https
85
91
  Layout/LineLength:
86
- Max: 129
92
+ Max: 123
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  ## [Unreleased]
2
2
 
3
+
4
+ ## [0.2.0] - 2021-08-19
5
+
6
+ - Much improved and with a high level client
7
+
3
8
  ## [0.1.0] - 2021-04-13
4
9
 
5
10
  - Initial release
data/README.md CHANGED
@@ -20,6 +20,8 @@ Or install it yourself as:
20
20
 
21
21
  ## Usage
22
22
 
23
+ Low level API
24
+
23
25
  ```ruby
24
26
  require "amqp-client"
25
27
 
@@ -32,6 +34,35 @@ msg = ch.basic_get q[:queue_name]
32
34
  puts msg.body
33
35
  ```
34
36
 
37
+ High level API, is an easier and safer API, that only deal with durable queues and persisted messages. All methods are blocking in the case of connection loss etc. It's also fully thread-safe. Don't expect it to be extreme throughput, be expect 100% delivery guarantees (messages might be deliviered twice, in the unlikely event of a connection loss between message publish and message confirmed by the server).
38
+
39
+ ```ruby
40
+ amqp = AMQP::Client.new("amqp://localhost")
41
+ amqp.start
42
+
43
+ # Declares a durable queue
44
+ q = amqp.queue("myqueue")
45
+
46
+ # Bind the queue to any exchange, with any binding key
47
+ q.bind("amq.topic", "my.events.*")
48
+
49
+ # The message will be reprocessed if the client lost connection to the server
50
+ # between the message arrived and the message was supposed to be ack:ed.
51
+ q.subscribe(prefetch: 20) do |msg|
52
+ process(JSON.parse(msg.body))
53
+ msg.ack
54
+ rescue
55
+ msg.reject(requeue: false)
56
+ end
57
+
58
+ # Publish directly to the queue
59
+ q.publish { foo: "bar" }.to_json, content_type: "application/json"
60
+
61
+ # Publish to any exchange
62
+ amqp.publish("my message", "amq.topic", "topic.foo", headers: { foo: 'bar' })
63
+ amqp.publish(Zlib.gzip("an event"), "amq.topic", "my.event", content_encoding: 'gzip')
64
+ ```
65
+
35
66
  ## Development
36
67
 
37
68
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
data/lib/amqp/client.rb CHANGED
@@ -1,101 +1,169 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "socket"
4
- require "uri"
5
- require "openssl"
3
+ require "set"
6
4
  require_relative "client/version"
7
- require_relative "client/errors"
8
- require_relative "client/frames"
9
5
  require_relative "client/connection"
10
- require_relative "client/channel"
11
6
 
12
7
  module AMQP
13
8
  # AMQP 0-9-1 Client
14
9
  class Client
15
- def initialize(uri)
16
- @uri = URI.parse(uri)
17
- @tls = @uri.scheme == "amqps"
18
- @port = @uri.port || @tls ? 5671 : 5672
19
- @host = @uri.host || "localhost"
20
- @user = @uri.user || "guest"
21
- @password = @uri.password || "guest"
22
- @vhost = URI.decode_www_form_component(@uri.path[1..-1] || "/")
23
- @options = URI.decode_www_form(@uri.query || "").to_h
10
+ def initialize(uri, **options)
11
+ @uri = uri
12
+ @options = options
13
+
14
+ @queues = {}
15
+ @subscriptions = Set.new
16
+ @connq = SizedQueue.new(1)
24
17
  end
25
18
 
26
- def connect
27
- socket = Socket.tcp @host, @port, connect_timeout: 20, resolv_timeout: 5
28
- enable_tcp_keepalive(socket)
29
- if @tls
30
- context = OpenSSL::SSL::SSLContext.new
31
- context.verify_mode = OpenSSL::SSL::VERIFY_PEER unless @options["verify_peer"] == "none"
32
- socket = OpenSSL::SSL::SSLSocket.new(socket, context)
33
- socket.sync_close = true # closing the TLS socket also closes the TCP socket
34
- end
35
- channel_max, frame_max, heartbeat = establish(socket)
36
- Connection.new(socket, channel_max, frame_max, heartbeat)
19
+ def connect(read_loop_thread: true)
20
+ Connection.connect(@uri, **@options.merge(read_loop_thread: read_loop_thread))
37
21
  end
38
22
 
39
- private
23
+ def start
24
+ @stopped = false
25
+ Thread.new do
26
+ loop do
27
+ break if @stopped
40
28
 
41
- def establish(socket)
42
- channel_max, frame_max, heartbeat = nil
43
- socket.write "AMQP\x00\x00\x09\x01"
44
- buf = String.new(capacity: 4096)
45
- loop do
46
- begin
47
- socket.readpartial(4096, buf)
48
- rescue EOFError, IOError, OpenSSL::Error => e
49
- raise Error, "Could not establish AMQP connection: #{e.message}"
29
+ conn = connect(read_loop_thread: false)
30
+ Thread.new do
31
+ # restore connection in another thread, read_loop have to run
32
+ conn.channel(1) # reserve channel 1 for publishes
33
+ @subscriptions.each { |args| subscribe(*args) }
34
+ @connq << conn
35
+ end
36
+ conn.read_loop # blocks until connection is closed, then reconnect
37
+ rescue => e
38
+ warn "AMQP-Client reconnect error: #{e.inspect}"
39
+ sleep @options[:reconnect_interval] || 1
50
40
  end
41
+ end
42
+ self
43
+ end
51
44
 
52
- type, channel_id, frame_size = buf.unpack("C S> L>")
53
- frame_end = buf.unpack1("@#{frame_size + 7} C")
54
- raise UnexpectedFrameEndError, frame_end if frame_end != 206
55
-
56
- case type
57
- when 1 # method frame
58
- class_id, method_id = buf.unpack("@7 S> S>")
59
- case class_id
60
- when 10 # connection
61
- raise Error, "Unexpected channel id #{channel_id} for Connection frame" if channel_id != 0
62
-
63
- case method_id
64
- when 10 # connection#start
65
- socket.write FrameBytes.connection_start_ok "\u0000#{@user}\u0000#{@password}"
66
- when 30 # connection#tune
67
- channel_max, frame_max, heartbeat = buf.unpack("@11 S> L> S>")
68
- channel_max = [channel_max, 2048].min
69
- frame_max = [frame_max, 4096].min
70
- heartbeat = [heartbeat, 0].min
71
- socket.write FrameBytes.connection_tune_ok(channel_max, frame_max, heartbeat)
72
- socket.write FrameBytes.connection_open(@vhost)
73
- when 41 # connection#open-ok
74
- return [channel_max, frame_max, heartbeat]
75
- when 50 # connection#close
76
- code, text_len = buf.unpack("@11 S> C")
77
- text, error_class_id, error_method_id = buf.unpack("@14 a#{text_len} S> S>")
78
- socket.write FrameBytes.connection_close_ok
79
- raise Error, "Could not establish AMQP connection: #{code} #{text} #{error_class_id} #{error_method_id}"
80
- else raise Error, "Unexpected class/method: #{class_id} #{method_id}"
81
- end
82
- else raise Error, "Unexpected class/method: #{class_id} #{method_id}"
45
+ def stop
46
+ @stopped = true
47
+ conn = @connq.pop
48
+ conn.close
49
+ nil
50
+ end
51
+
52
+ def queue(name, arguments: {})
53
+ raise ArgumentError, "Currently only supports named, durable queues" if name.empty?
54
+
55
+ @queues.fetch(name) do
56
+ with_connection do |conn|
57
+ conn.with_channel do |ch| # use a temp channel in case the declaration fails
58
+ ch.queue_declare(name, arguments: arguments)
83
59
  end
84
- else raise Error, "Unexpected frame type: #{type}"
85
60
  end
61
+ @queues[name] = Queue.new(self, name)
86
62
  end
87
- rescue StandardError
88
- socket.close
89
- raise
90
63
  end
91
64
 
92
- def enable_tcp_keepalive(socket)
93
- socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
94
- socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPIDLE, 60)
95
- socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPINTVL, 10)
96
- socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPCNT, 3)
65
+ def subscribe(queue_name, no_ack: false, prefetch: 1, worker_threads: 1, arguments: {}, &blk)
66
+ @subscriptions.add? [queue_name, no_ack, prefetch, arguments, blk]
67
+
68
+ with_connection do |conn|
69
+ ch = conn.channel
70
+ ch.basic_qos(prefetch)
71
+ ch.basic_consume(queue_name, no_ack: no_ack, worker_threads: worker_threads, arguments: arguments) do |msg|
72
+ blk.call(msg)
73
+ end
74
+ end
75
+ end
76
+
77
+ def publish(body, exchange, routing_key, **properties)
78
+ with_connection do |conn|
79
+ # Use channel 1 for publishes
80
+ conn.channel(1).basic_publish_confirm(body, exchange, routing_key, **properties)
81
+ rescue
82
+ conn.channel(1) # reopen channel 1 if it raised
83
+ raise
84
+ end
97
85
  rescue => e
98
- warn "amqp-client: Could not enable TCP keepalive on socket. #{e.inspect}"
86
+ warn "AMQP-Client error publishing, retrying (#{e.inspect})"
87
+ retry
88
+ end
89
+
90
+ def bind(queue, exchange, routing_key, **headers)
91
+ with_connection do |conn|
92
+ conn.channel(1).queue_bind(queue, exchange, routing_key, **headers)
93
+ end
94
+ end
95
+
96
+ def unbind(queue, exchange, routing_key, **headers)
97
+ with_connection do |conn|
98
+ conn.channel(1).queue_unbind(queue, exchange, routing_key, **headers)
99
+ end
100
+ end
101
+
102
+ def purge(queue)
103
+ with_connection do |conn|
104
+ conn.channel(1).queue_purge(queue)
105
+ end
106
+ end
107
+
108
+ def delete_queue(queue)
109
+ with_connection do |conn|
110
+ conn.channel(1).queue_delete(queue)
111
+ end
112
+ end
113
+
114
+ # Queue abstraction
115
+ class Queue
116
+ def initialize(client, name)
117
+ @client = client
118
+ @name = name
119
+ end
120
+
121
+ def publish(body, **properties)
122
+ @client.publish(body, "", @name, **properties)
123
+ self
124
+ end
125
+
126
+ def subscribe(prefetch: 1, arguments: {}, &blk)
127
+ @client.subscribe(@name, prefetch: prefetch, arguments: arguments, &blk)
128
+ self
129
+ end
130
+
131
+ def bind(exchange, routing_key, **headers)
132
+ @client.bind(@name, exchange, routing_key, **headers)
133
+ self
134
+ end
135
+
136
+ def unbind(exchange, routing_key, **headers)
137
+ @client.unbind(@name, exchange, routing_key, **headers)
138
+ self
139
+ end
140
+
141
+ def purge
142
+ @client.purge(@name)
143
+ self
144
+ end
145
+
146
+ def delete
147
+ @client.delete_queue(@name)
148
+ nil
149
+ end
150
+ end
151
+
152
+ private
153
+
154
+ def with_connection
155
+ conn = nil
156
+ loop do
157
+ conn = @connq.pop
158
+ next if conn.closed?
159
+
160
+ break
161
+ end
162
+ begin
163
+ yield conn
164
+ ensure
165
+ @connq << conn unless conn.closed?
166
+ end
99
167
  end
100
168
  end
101
169
  end