async-rabbitmq 0.4.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,309 @@
1
+ require "async"
2
+ require "async/queue"
3
+ require "async/limited_queue"
4
+ require "async/semaphore"
5
+ require "socket"
6
+ require "amq/protocol"
7
+ require_relative "errors"
8
+
9
+ module AsyncRabbitMQ
10
+ # Handles low-level AMQP frame reading and writing over a native Ruby socket.
11
+ # async 2.x's fiber scheduler makes blocking socket reads yield automatically.
12
+ #
13
+ # Architecture:
14
+ # Reader Task — reads frames, routes to per-channel Async::Queue.
15
+ # Writer Task — drains a bounded Async::LimitedQueue, serializes all writes.
16
+ # Heartbeat — writes directly via Async::Semaphore (bypasses write queue).
17
+ #
18
+ # AMQ::Protocol::Frame.decode raises NotImplementedError (abstract stub), so
19
+ # frames are read in three steps: 7-byte header (decoded by
20
+ # AMQ::Protocol::Frame.decode_header), payload, 0xCE terminator.
21
+ class FrameIO
22
+ FRAME_HEADER_SIZE = 7
23
+ FRAME_TERMINATOR = "\xCE".b.freeze
24
+ WRITE_QUEUE_LIMIT = 1024
25
+
26
+ # AMQP soft-error codes: broker closes the channel, connection stays alive.
27
+ # 311 content-too-large, 312 no-route, 313 no-consumers are channel-level errors.
28
+ # 403 access-refused, 404 not-found, 405 resource-locked, 406 precondition-failed.
29
+ SOFT_ERROR_CODES = [311, 312, 313, 403, 404, 405, 406].freeze
30
+
31
+ def initialize(socket, logger: Log.new)
32
+ @socket = socket
33
+ @logger = logger
34
+ @channels = {}
35
+ @channel_sem = Async::Semaphore.new(1)
36
+ @write_queue = Async::LimitedQueue.new(WRITE_QUEUE_LIMIT)
37
+ @socket_sem = Async::Semaphore.new(1)
38
+ @running = false
39
+ @reader_task = nil
40
+ @writer_task = nil
41
+ @content_state = {}
42
+ @on_frame = nil # optional proc called each time a frame is read
43
+ @blocked = false
44
+ @unblocked_condition = nil
45
+ end
46
+
47
+ # Set a callback invoked immediately after each frame is successfully read.
48
+ # Used by Session to update its heartbeat timestamp.
49
+ def on_frame=(proc)
50
+ @on_frame = proc
51
+ end
52
+
53
+ def register_channel(channel_id)
54
+ @channel_sem.acquire do
55
+ q = @channels[channel_id] = Async::Queue.new
56
+ q
57
+ end
58
+ end
59
+
60
+ def unregister_channel(channel_id)
61
+ @channel_sem.acquire do
62
+ @channels.delete(channel_id)
63
+ @content_state.delete(channel_id)
64
+ end
65
+ end
66
+
67
+ def channel_queue(channel_id)
68
+ @channels[channel_id]
69
+ end
70
+
71
+ # +spawn+ makes the reader and writer tasks. It defaults to the calling
72
+ # task, which is only right for a throwaway connection: the session passes
73
+ # one that parents them at the reactor, so a short-lived task that happened
74
+ # to call connect cannot take the connection's IO down with it.
75
+ def start(task: Async::Task.current, spawn: ->(&block) { task.async(&block) })
76
+ @running = true
77
+ @writer_task = spawn.call { writer_loop }
78
+ @reader_task = spawn.call { reader_loop }
79
+ end
80
+
81
+ def stop
82
+ @running = false
83
+ @socket.close rescue nil
84
+ # Publishers parked on the connection.blocked gate can never proceed now.
85
+ @blocked = false
86
+ release_blocked_writers(ConnectionError.new(code: 0, text: "Connection closed while blocked by the broker"))
87
+ # Push nil sentinel to every channel queue FIRST so blocked poppers
88
+ # (e.g. wait_channel0_method) are unblocked before we cancel tasks.
89
+ # Cancelling the CURRENT task raises Async::Cancel (< Exception, not
90
+ # StandardError), which bypasses inline `rescue nil` and would skip
91
+ # the push if we did it after.
92
+ @channels.each_value { |q| q.push(nil) rescue nil }
93
+ @writer_task&.cancel rescue nil
94
+ @reader_task&.cancel rescue nil
95
+ end
96
+
97
+ # Enqueue raw frame bytes for writing. Yields the fiber when the queue is
98
+ # full. Content publishes (+publish: true+) additionally wait while the
99
+ # broker has the connection blocked (connection.blocked). Control frames
100
+ # (close, close-ok, acks, ...) are never gated, so shutdown cannot hang on
101
+ # a resource alarm. Raises ConnectionError if the connection is closed
102
+ # while a publish is parked.
103
+ def write_frame(data, publish: false)
104
+ if publish
105
+ while @blocked
106
+ @unblocked_condition ||= Async::Condition.new
107
+ outcome = @unblocked_condition.wait
108
+ raise outcome if outcome.is_a?(Exception)
109
+ end
110
+ end
111
+ @write_queue.push(data)
112
+ end
113
+
114
+ # Called by the Session channel-0 monitor when connection.blocked is received.
115
+ def set_blocked(reason)
116
+ @blocked = true
117
+ @logger.warn("Connection blocked: #{reason}")
118
+ end
119
+
120
+ # Called by the Session channel-0 monitor when connection.unblocked is received.
121
+ def set_unblocked
122
+ @blocked = false
123
+ release_blocked_writers
124
+ @logger.info("Connection unblocked")
125
+ end
126
+
127
+ def blocked?
128
+ @blocked
129
+ end
130
+
131
+ # How many writes are queued for the writer fiber but not yet on the socket.
132
+ # One entry is one write_frame call, so a batch published with
133
+ # basic_publish_batch counts once however many messages it carries. Used at
134
+ # close time to report what is about to be thrown away.
135
+ def pending_writes
136
+ @write_queue.size
137
+ end
138
+
139
+ # Write heartbeat directly to socket, bypassing the write queue so
140
+ # heartbeats are never starved by backpressure.
141
+ # Silently swallows IO errors — the reader_loop will detect and recover.
142
+ def write_heartbeat
143
+ heartbeat = AMQ::Protocol::HeartbeatFrame.encode
144
+ @socket_sem.acquire { @socket.write(heartbeat) }
145
+ rescue IOError, Errno::EPIPE, Errno::EBADF
146
+ # Socket is closed; reader_loop will raise and call trigger_recovery.
147
+ end
148
+
149
+ private
150
+
151
+ # Wake every publisher waiting on the blocked gate. With +outcome+ nil they
152
+ # re-check the gate; with an exception they raise it.
153
+ def release_blocked_writers(outcome = nil)
154
+ cond = @unblocked_condition
155
+ @unblocked_condition = nil
156
+ cond&.signal(outcome)
157
+ end
158
+
159
+ def writer_loop
160
+ while @running
161
+ data = @write_queue.pop
162
+ @socket_sem.acquire { @socket.write(data) }
163
+ end
164
+ rescue IOError, Errno::EPIPE, Errno::EBADF, Errno::ECONNRESET
165
+ # Socket closed — reader_loop will detect and call trigger_recovery.
166
+ rescue => e
167
+ # The writer is gone either way; without recovery every publisher would
168
+ # block forever once the write queue fills, with nothing to drain it.
169
+ @logger.error("FrameIO writer: #{e.class}: #{e.message}")
170
+ trigger_recovery(e) if @running
171
+ end
172
+
173
+ def reader_loop
174
+ while @running
175
+ frame = read_frame
176
+ @on_frame.call if @on_frame
177
+ dispatch(frame)
178
+ end
179
+ rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError, Errno::EBADF => e
180
+ if @running
181
+ @logger.warn("FrameIO reader closed: #{e.class}")
182
+ trigger_recovery(e)
183
+ else
184
+ # #stop closed the socket under us: an orderly shutdown, not a failure.
185
+ @logger.debug("FrameIO reader stopped (#{e.class})")
186
+ end
187
+ rescue AMQ::Protocol::Error => e
188
+ @logger.error("FrameIO AMQP error: #{e.class}: #{e.message}")
189
+ trigger_recovery(e)
190
+ rescue => e
191
+ @logger.error("FrameIO reader error: #{e.class}: #{e.message}")
192
+ trigger_recovery(e)
193
+ end
194
+
195
+ # Read one AMQP frame via manual 3-step IO.
196
+ # Returns an AMQ::Protocol::MethodFrame / HeaderFrame / BodyFrame / HeartbeatFrame.
197
+ def read_frame
198
+ header_bytes = read_exactly(FRAME_HEADER_SIZE)
199
+ # Raises AMQ::Protocol::FrameTypeError (an AMQ::Protocol::Error) on an
200
+ # unknown frame type, which reader_loop turns into recovery.
201
+ type, channel_id, payload_size = AMQ::Protocol::Frame.decode_header(header_bytes)
202
+ payload = read_exactly(payload_size)
203
+ terminator = read_exactly(1)
204
+
205
+ unless terminator == FRAME_TERMINATOR
206
+ raise AMQ::Protocol::Error,
207
+ "Invalid frame terminator: #{terminator.inspect} (expected 0xCE)"
208
+ end
209
+
210
+ AMQ::Protocol::Frame::CLASSES[AMQ::Protocol::Frame::TYPES[type]].new(payload, channel_id)
211
+ end
212
+
213
+ # Read exactly n bytes, yielding the fiber at each blocking read.
214
+ def read_exactly(n)
215
+ return "".b if n == 0
216
+ buf = "".b
217
+ while buf.bytesize < n
218
+ chunk = @socket.read(n - buf.bytesize)
219
+ raise EOFError, "Socket closed mid-frame" if chunk.nil? || chunk.empty?
220
+ buf << chunk
221
+ end
222
+ buf
223
+ end
224
+
225
+ def dispatch(frame_obj)
226
+ channel_id = frame_obj.channel
227
+
228
+ case frame_obj
229
+ when AMQ::Protocol::MethodFrame
230
+ method = frame_obj.decode_payload
231
+
232
+ # Broker-initiated Connection::Close must be acknowledged immediately.
233
+ # Without a timely CloseOk the broker waits (up to ~30 s) before
234
+ # force-closing the TCP connection, e.g. when a vhost is deleted while
235
+ # a connection is still open. We send CloseOk synchronously here,
236
+ # then route the method to channel 0 (so wait_channel0_method can also
237
+ # see it and raise a proper error if it is active), and finally trigger
238
+ # recovery so the session reconnects.
239
+ if channel_id == 0 && method.is_a?(AMQ::Protocol::Connection::Close)
240
+ begin
241
+ @socket_sem.acquire do
242
+ @socket.write(AMQ::Protocol::Connection::CloseOk.encode.encode)
243
+ end
244
+ rescue => e
245
+ @logger.warn("FrameIO: could not send Connection::CloseOk — #{e.message}")
246
+ end
247
+ route_to_channel(0, [:method, method])
248
+ trigger_recovery(
249
+ ConnectionError.new(code: method.reply_code, text: method.reply_text)
250
+ )
251
+ return
252
+ end
253
+
254
+ route_to_channel(channel_id, [:method, method])
255
+
256
+ when AMQ::Protocol::HeaderFrame
257
+ # A zero-length body has no body frames at all: neither RabbitMQ nor
258
+ # amq-protocol emits one. Route now, or the content would sit here
259
+ # forever and the next method frame would be taken as its body.
260
+ if frame_obj.body_size == 0
261
+ route_to_channel(channel_id, [:content, frame_obj, "".b])
262
+ return
263
+ end
264
+
265
+ @content_state[channel_id] = {
266
+ header: frame_obj,
267
+ body_parts: [],
268
+ remaining: frame_obj.body_size,
269
+ }
270
+
271
+ when AMQ::Protocol::BodyFrame
272
+ state = @content_state[channel_id]
273
+ unless state
274
+ @logger.warn("Body frame on channel #{channel_id} without content header — dropped")
275
+ return
276
+ end
277
+ chunk = frame_obj.payload
278
+ state[:body_parts] << chunk
279
+ state[:remaining] -= chunk.bytesize
280
+
281
+ if state[:remaining] <= 0
282
+ body = state[:body_parts].join.b
283
+ @content_state.delete(channel_id)
284
+ route_to_channel(channel_id, [:content, state[:header], body])
285
+ end
286
+
287
+ when AMQ::Protocol::HeartbeatFrame
288
+ route_to_channel(0, [:heartbeat])
289
+
290
+ else
291
+ @logger.warn("Unknown frame type #{frame_obj.class} on channel #{channel_id} — dropped")
292
+ end
293
+ end
294
+
295
+ def route_to_channel(channel_id, message)
296
+ queue = @channels[channel_id]
297
+ if queue
298
+ queue.push(message)
299
+ else
300
+ @logger.debug("No queue for channel #{channel_id} — frame dropped (#{message.first})")
301
+ end
302
+ end
303
+
304
+ # Overridden by Session to initiate reconnect.
305
+ def trigger_recovery(error)
306
+ @running = false
307
+ end
308
+ end
309
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsyncRabbitMQ
4
+ # The default logger: warnings and errors, one line each, to $stderr.
5
+ #
6
+ # Anything that responds to +debug+, +info+, +warn+ and +error+ can be passed
7
+ # as +logger:+ instead, and that is the expected thing to do in an
8
+ # application: Ruby's Logger, Rails.logger, a Console wrapper, your own
9
+ # object. The gem deliberately does not depend on the logger gem, which
10
+ # stopped being a default gem in Ruby 4.0, so the choice of logging stack
11
+ # stays with the application.
12
+ #
13
+ # AsyncRabbitMQ::Session.new(logger: Rails.logger)
14
+ # AsyncRabbitMQ::Session.new(logger: Logger.new($stdout, level: Logger::INFO))
15
+ # AsyncRabbitMQ::Session.new(logger: AsyncRabbitMQ::Log.silent)
16
+ # AsyncRabbitMQ::Session.new(logger: AsyncRabbitMQ::Log.new($stdout, level: :debug))
17
+ class Log
18
+ LEVELS = { debug: 0, info: 1, warn: 2, error: 3, fatal: 4 }.freeze
19
+ DEFAULT_LEVEL = :warn
20
+
21
+ attr_reader :level
22
+
23
+ # +io+ nil discards everything.
24
+ def initialize(io = $stderr, level: DEFAULT_LEVEL)
25
+ @io = io
26
+ self.level = level
27
+ end
28
+
29
+ # Accepts a symbol (:debug) or Logger's integer constants (Logger::DEBUG).
30
+ def level=(value)
31
+ @level = value.is_a?(Integer) ? (LEVELS.key(value) || DEFAULT_LEVEL) : value.to_sym
32
+ @threshold = LEVELS.fetch(@level, LEVELS[DEFAULT_LEVEL])
33
+ end
34
+
35
+ # Discards everything. Useful in tests and in one-shot scripts.
36
+ def self.silent
37
+ new(nil)
38
+ end
39
+
40
+ LEVELS.each_key do |name|
41
+ define_method(name) do |message = nil, &block|
42
+ return nil if @io.nil? || LEVELS.fetch(name) < @threshold
43
+
44
+ text = message.nil? && block ? block.call : message
45
+ @io.puts("#{name.to_s.upcase.ljust(5)} async-rabbitmq: #{text}")
46
+ nil
47
+ end
48
+
49
+ define_method(:"#{name}?") { !@io.nil? && LEVELS.fetch(name) >= @threshold }
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsyncRabbitMQ
4
+ # Structured events for metrics, tracing and debugging.
5
+ #
6
+ # Nothing is emitted until something subscribes. Every call site goes through
7
+ # Instrumented#instrument, which checks for subscribers before running the
8
+ # block that builds the payload, so an unwatched session pays one method call
9
+ # and one array check per event and allocates nothing.
10
+ #
11
+ # session.on_event { |name, payload| logger.info("#{name} #{payload}") }
12
+ # session.on_event("message.") { |name, payload| statsd.increment(name) }
13
+ # session.on_event("channel.rpc") { |_, p| histogram.record(p[:duration]) }
14
+ # session.on_event(/recovery/) { |name, _| pager.note(name) }
15
+ #
16
+ # A pattern is nil for everything, a String for one event name or, ending in
17
+ # a dot, a prefix, or a Regexp. Subscribe returns a handle for #unsubscribe.
18
+ #
19
+ # A subscriber that raises is logged and skipped: instrumentation must never
20
+ # take the connection down with it.
21
+ class Notifier
22
+ # The events the client emits. Names are API: they do not change without a
23
+ # major version. Payload keys are documented in README.md.
24
+ EVENTS = %w[
25
+ connection.open
26
+ connection.closed
27
+ connection.lost
28
+ connection.blocked
29
+ connection.unblocked
30
+ recovery.attempt
31
+ recovery.succeeded
32
+ recovery.exhausted
33
+ heartbeat.sent
34
+ channel.open
35
+ channel.closed
36
+ channel.rpc
37
+ consumer.registered
38
+ consumer.cancelled
39
+ message.published
40
+ message.confirmed
41
+ message.returned
42
+ message.consumed
43
+ ].freeze
44
+
45
+ def initialize(logger: nil)
46
+ @subscribers = []
47
+ @logger = logger
48
+ end
49
+
50
+ def subscribe(pattern = nil, &block)
51
+ raise ArgumentError, "a subscriber block is required" unless block
52
+
53
+ @subscribers << [pattern, block]
54
+ block
55
+ end
56
+
57
+ # Remove a subscriber using the handle subscribe returned.
58
+ def unsubscribe(handle)
59
+ @subscribers.reject! { |(_, block)| block.equal?(handle) }
60
+ nil
61
+ end
62
+
63
+ def subscribed?
64
+ !@subscribers.empty?
65
+ end
66
+
67
+ def subscriber_count
68
+ @subscribers.size
69
+ end
70
+
71
+ # Called by Instrumented#instrument once the payload exists.
72
+ def publish(name, payload)
73
+ # Snapshot: a subscriber that unsubscribes itself (or another) mutates
74
+ # @subscribers under the iterator, which skips the next one along.
75
+ @subscribers.dup.each do |(pattern, block)|
76
+ next unless match?(pattern, name)
77
+
78
+ begin
79
+ block.call(name, payload)
80
+ rescue => e
81
+ @logger&.error("Event subscriber for #{name} raised #{e.class}: #{e.message}")
82
+ end
83
+ end
84
+ nil
85
+ end
86
+
87
+ private
88
+
89
+ def match?(pattern, name)
90
+ case pattern
91
+ when nil then true
92
+ when String then pattern.end_with?(".") ? name.start_with?(pattern) : name == pattern
93
+ when Regexp then pattern.match?(name)
94
+ else pattern === name
95
+ end
96
+ end
97
+ end
98
+
99
+ # Mixed into Session and Channel, both of which hold a @notifier.
100
+ module Instrumented
101
+ private
102
+
103
+ # Emit +name+ with the payload the block returns. The block runs only when
104
+ # something is subscribed, so building the payload costs nothing otherwise.
105
+ def instrument(name)
106
+ notifier = @notifier
107
+ return unless notifier&.subscribed?
108
+
109
+ notifier.publish(name, yield)
110
+ end
111
+
112
+ # For call sites that need to time the work: returns nil when nobody is
113
+ # listening, so the caller can skip the clock reads too.
114
+ def instrument_clock
115
+ @notifier&.subscribed? ? Process.clock_gettime(Process::CLOCK_MONOTONIC) : nil
116
+ end
117
+
118
+ def instrument_elapsed(started)
119
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,43 @@
1
+ begin
2
+ require "async/pool"
3
+ rescue LoadError
4
+ raise LoadError, "AsyncRabbitMQ::Pool needs the async-pool gem, which this gem does not " \
5
+ "depend on: add gem \"async-pool\" to your Gemfile"
6
+ end
7
+ require_relative "session"
8
+
9
+ module AsyncRabbitMQ
10
+ # Opt-in connection pool backed by Async::Pool::Controller.
11
+ #
12
+ # Each pooled resource is a connected Session. A Session advertises its
13
+ # negotiated channel_max as its +concurrency+, so the controller hands the
14
+ # same Session to many fibers at once and only opens another connection
15
+ # when every existing one is saturated or unusable.
16
+ #
17
+ # Usage:
18
+ # pool = AsyncRabbitMQ::Pool.new(max: 5, host: "localhost")
19
+ # pool.acquire { |session| session.with_channel { |ch| ch.basic_publish(...) } }
20
+ # pool.close
21
+ class Pool
22
+ def initialize(max: 4, **session_opts)
23
+ @session_opts = session_opts
24
+ constructor = -> { Session.new(**@session_opts).tap(&:connect) }
25
+ @pool = Async::Pool::Controller.new(constructor, limit: max)
26
+ end
27
+
28
+ # Acquire a connected Session. With a block, the Session is released when
29
+ # the block returns; without one, the caller must call #release.
30
+ def acquire(&block)
31
+ @pool.acquire(&block)
32
+ end
33
+
34
+ def release(session)
35
+ @pool.release(session)
36
+ end
37
+
38
+ # Close every pooled Session.
39
+ def close
40
+ @pool.close
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,88 @@
1
+ module AsyncRabbitMQ
2
+ # Represents an AMQP queue bound to a channel.
3
+ # Created via channel.queue("name", durable: true).
4
+ class Queue
5
+ # Queue types RabbitMQ knows, for Channel#durable_queue(name, type).
6
+ module Types
7
+ CLASSIC = "classic"
8
+ QUORUM = "quorum"
9
+ STREAM = "stream"
10
+ end
11
+
12
+ attr_reader :name, :message_count, :consumer_count
13
+
14
+ def initialize(name, message_count, consumer_count, channel, durable: false, exclusive: false, auto_delete: false)
15
+ @name = name
16
+ @message_count = message_count
17
+ @consumer_count = consumer_count
18
+ @channel = channel
19
+ @durable = durable
20
+ @exclusive = exclusive
21
+ @auto_delete = auto_delete
22
+ end
23
+
24
+ def durable?
25
+ @durable
26
+ end
27
+
28
+ def exclusive?
29
+ @exclusive
30
+ end
31
+
32
+ def auto_delete?
33
+ @auto_delete
34
+ end
35
+
36
+ # Internal: topology recovery re-declared this server-named queue under a
37
+ # new broker-generated name.
38
+ def update_name_to(new_name)
39
+ @name = new_name
40
+ end
41
+
42
+ def server_named?
43
+ @name.start_with?("amq.gen-")
44
+ end
45
+
46
+ # Re-declare with passive: true to refresh message_count and consumer_count.
47
+ def status
48
+ q = @channel.queue(@name, passive: true)
49
+ @message_count = q.message_count
50
+ @consumer_count = q.consumer_count
51
+ { message_count: @message_count, consumer_count: @consumer_count }
52
+ end
53
+
54
+ # Synchronously fetch one message: [delivery_info, header, body], or nil if
55
+ # the queue is empty. Defaults to manual acknowledgement like Bunny: a
56
+ # message fetched and then dropped by the caller is requeued, not lost.
57
+ def pop(manual_ack: true)
58
+ @channel.basic_get(@name, manual_ack: manual_ack)
59
+ end
60
+ alias get pop
61
+
62
+ def subscribe(manual_ack: false, **opts, &block)
63
+ @channel.basic_consume(@name, manual_ack: manual_ack, **opts, &block)
64
+ end
65
+
66
+ def publish(payload, routing_key: @name, **opts)
67
+ @channel.basic_publish(payload, routing_key: routing_key, **opts)
68
+ end
69
+
70
+ def bind(exchange:, routing_key: "", arguments: {})
71
+ @channel.queue_bind(@name, exchange: exchange, routing_key: routing_key, arguments: arguments)
72
+ self
73
+ end
74
+
75
+ def unbind(exchange:, routing_key: "", arguments: {})
76
+ @channel.queue_unbind(@name, exchange: exchange, routing_key: routing_key, arguments: arguments)
77
+ self
78
+ end
79
+
80
+ def delete(if_unused: false, if_empty: false)
81
+ @channel.queue_delete(@name, if_unused: if_unused, if_empty: if_empty)
82
+ end
83
+
84
+ def purge
85
+ @channel.queue_purge(@name)
86
+ end
87
+ end
88
+ end