messaging 0.0.2 → 3.4.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.circleci/config.yml +68 -0
- data/.gitignore +11 -0
- data/.rspec +2 -0
- data/.ruby-version +1 -0
- data/Gemfile +15 -0
- data/Gemfile.lock +222 -0
- data/README.md +53 -0
- data/Rakefile +5 -30
- data/_config.yml +1 -0
- data/bin/console +11 -0
- data/bin/setup +8 -0
- data/exe/messaging +11 -0
- data/lib/messaging.rb +62 -11
- data/lib/messaging/adapters.rb +35 -0
- data/lib/messaging/adapters/kafka.rb +55 -0
- data/lib/messaging/adapters/kafka/consumer.rb +101 -0
- data/lib/messaging/adapters/kafka/producer.rb +52 -0
- data/lib/messaging/adapters/postgres.rb +23 -0
- data/lib/messaging/adapters/postgres/advisory_transaction_lock.rb +28 -0
- data/lib/messaging/adapters/postgres/serialized_message.rb +74 -0
- data/lib/messaging/adapters/postgres/store.rb +66 -0
- data/lib/messaging/adapters/postgres/stream.rb +37 -0
- data/lib/messaging/adapters/postgres/streams.rb +27 -0
- data/lib/messaging/adapters/test.rb +59 -0
- data/lib/messaging/adapters/test/consumer.rb +46 -0
- data/lib/messaging/adapters/test/store.rb +67 -0
- data/lib/messaging/adapters/test/stream.rb +21 -0
- data/lib/messaging/base_handler.rb +45 -0
- data/lib/messaging/cli.rb +62 -0
- data/lib/messaging/config.rb +66 -0
- data/lib/messaging/consumer_supervisor.rb +63 -0
- data/lib/messaging/exception_handler.rb +15 -0
- data/lib/messaging/expected_version.rb +31 -0
- data/lib/messaging/instrumentation.rb +21 -0
- data/lib/messaging/message.rb +103 -0
- data/lib/messaging/message/from_json.rb +31 -0
- data/lib/messaging/meter.rb +113 -0
- data/lib/messaging/middleware.rb +14 -0
- data/lib/messaging/middleware/after_active_record_transaction.rb +13 -0
- data/lib/messaging/middleware/rails_wrapper.rb +22 -0
- data/lib/messaging/publish.rb +33 -0
- data/lib/messaging/rails/railtie.rb +36 -0
- data/lib/messaging/resque_worker.rb +11 -0
- data/lib/messaging/routes.rb +50 -0
- data/lib/messaging/routing.rb +67 -0
- data/lib/messaging/routing/background_job_subscriber.rb +11 -0
- data/lib/messaging/routing/enqueue_message_handler.rb +15 -0
- data/lib/messaging/routing/message_matcher.rb +62 -0
- data/lib/messaging/routing/subscriber.rb +35 -0
- data/lib/messaging/sidekiq_worker.rb +12 -0
- data/lib/messaging/version.rb +1 -1
- data/messaging.gemspec +40 -0
- metadata +299 -102
- data/MIT-LICENSE +0 -20
- data/app/models/message.rb +0 -5
- data/app/models/receipt.rb +0 -13
- data/app/uploaders/attachment_uploader.rb +0 -9
- data/db/migrate/20130801214110_initial_migration.rb +0 -22
- data/lib/generators/messaging/install_generator.rb +0 -26
- data/lib/generators/messaging/templates/initializer.rb +0 -11
- data/lib/messaging/concerns/configurable_mailer.rb +0 -13
- data/lib/messaging/engine.rb +0 -9
- data/lib/messaging/models/messageable.rb +0 -24
- data/lib/tasks/messaging_tasks.rake +0 -4
- data/test/dummy/README.rdoc +0 -28
- data/test/dummy/Rakefile +0 -6
- data/test/dummy/app/assets/javascripts/application.js +0 -13
- data/test/dummy/app/assets/stylesheets/application.css +0 -13
- data/test/dummy/app/controllers/application_controller.rb +0 -5
- data/test/dummy/app/helpers/application_helper.rb +0 -2
- data/test/dummy/app/views/layouts/application.html.erb +0 -14
- data/test/dummy/bin/bundle +0 -3
- data/test/dummy/bin/rails +0 -4
- data/test/dummy/bin/rake +0 -4
- data/test/dummy/config.ru +0 -4
- data/test/dummy/config/application.rb +0 -23
- data/test/dummy/config/boot.rb +0 -5
- data/test/dummy/config/database.yml +0 -25
- data/test/dummy/config/environment.rb +0 -5
- data/test/dummy/config/environments/development.rb +0 -29
- data/test/dummy/config/environments/production.rb +0 -80
- data/test/dummy/config/environments/test.rb +0 -36
- data/test/dummy/config/initializers/backtrace_silencers.rb +0 -7
- data/test/dummy/config/initializers/filter_parameter_logging.rb +0 -4
- data/test/dummy/config/initializers/inflections.rb +0 -16
- data/test/dummy/config/initializers/mime_types.rb +0 -5
- data/test/dummy/config/initializers/secret_token.rb +0 -12
- data/test/dummy/config/initializers/session_store.rb +0 -3
- data/test/dummy/config/initializers/wrap_parameters.rb +0 -14
- data/test/dummy/config/locales/en.yml +0 -23
- data/test/dummy/config/routes.rb +0 -56
- data/test/dummy/public/404.html +0 -58
- data/test/dummy/public/422.html +0 -58
- data/test/dummy/public/500.html +0 -57
- data/test/dummy/public/favicon.ico +0 -0
- data/test/messaging_test.rb +0 -7
- data/test/test_helper.rb +0 -15
@@ -0,0 +1,63 @@
|
|
1
|
+
module Messaging
|
2
|
+
# @private
|
3
|
+
class ConsumerSupervisor
|
4
|
+
include Messaging::Instrumentation
|
5
|
+
|
6
|
+
def initialize
|
7
|
+
@threads = Concurrent::Array.new
|
8
|
+
Messaging.routes.define_consumers!
|
9
|
+
end
|
10
|
+
|
11
|
+
def start
|
12
|
+
Concurrent.use_simple_logger
|
13
|
+
Messaging.logger.info 'Consumers starting'
|
14
|
+
@signal_to_stop = false
|
15
|
+
@threads.clear
|
16
|
+
@thread_pool = Concurrent::FixedThreadPool.new(consumers.size, auto_terminate: false)
|
17
|
+
|
18
|
+
consumers.each do |consumer|
|
19
|
+
@thread_pool.post do
|
20
|
+
thread = Thread.current
|
21
|
+
thread.abort_on_exception = true
|
22
|
+
@threads << thread
|
23
|
+
run_consumer(consumer)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
true
|
28
|
+
end
|
29
|
+
|
30
|
+
def stop
|
31
|
+
return if @signal_to_stop
|
32
|
+
|
33
|
+
instrument('consumer_supervisor.stop') do
|
34
|
+
@signal_to_stop = true
|
35
|
+
consumers.map { |consumer| Thread.new { consumer.stop } }.join
|
36
|
+
@threads.select(&:alive?).each { |thread| thread&.wakeup }
|
37
|
+
@thread_pool&.shutdown
|
38
|
+
@thread_pool&.wait_for_termination(60)
|
39
|
+
Messaging.logger.info 'Consumers stopped'
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def status
|
44
|
+
consumers.map(&:log_current_status)
|
45
|
+
end
|
46
|
+
|
47
|
+
def consumers
|
48
|
+
Messaging.routes.consumers
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def run_consumer(consumer)
|
54
|
+
consumer.start
|
55
|
+
rescue StandardError => e
|
56
|
+
return if @signal_to_stop
|
57
|
+
|
58
|
+
ExceptionHandler.call(e)
|
59
|
+
sleep 2
|
60
|
+
retry
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Messaging
|
2
|
+
class ExceptionHandler
|
3
|
+
def self.call(exception, context = {})
|
4
|
+
Config.error_handlers.each do |handler|
|
5
|
+
begin
|
6
|
+
handler.call(exception, context)
|
7
|
+
rescue => e
|
8
|
+
Messaging.logger.error '!!! ERROR HANDLER THREW AN ERROR !!!'
|
9
|
+
Messaging.logger.error e
|
10
|
+
Messaging.logger.error e.backtrace.join("\n") unless e.backtrace.nil?
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module Messaging
|
2
|
+
class ExpectedVersion
|
3
|
+
Error = Class.new(StandardError)
|
4
|
+
|
5
|
+
attr_reader :version
|
6
|
+
|
7
|
+
def initialize(version)
|
8
|
+
@version = version
|
9
|
+
end
|
10
|
+
|
11
|
+
def any?
|
12
|
+
version == :any
|
13
|
+
end
|
14
|
+
|
15
|
+
def none?
|
16
|
+
version == :none || version == -1
|
17
|
+
end
|
18
|
+
|
19
|
+
def matches?(other_version)
|
20
|
+
return true if any?
|
21
|
+
return true if none? && other_version == -1
|
22
|
+
return true if version == other_version
|
23
|
+
false
|
24
|
+
end
|
25
|
+
|
26
|
+
def match!(other_version)
|
27
|
+
return true if matches?(other_version)
|
28
|
+
raise Error, "expected: #{version} actual: #{other_version}"
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module Messaging
|
2
|
+
module Instrumentation
|
3
|
+
NAMESPACE = 'messaging'
|
4
|
+
|
5
|
+
def instrument(event, extra = {})
|
6
|
+
ActiveSupport::Notifications.instrument("#{NAMESPACE}.#{event}", extra) do |extra|
|
7
|
+
yield(extra) if block_given?
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.subscribe(event)
|
12
|
+
ActiveSupport::Notifications.subscribe("#{NAMESPACE}.#{event}") do |*args|
|
13
|
+
yield ActiveSupport::Notifications::Event.new(*args) if block_given?
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.unsubscribe(subscriber)
|
18
|
+
ActiveSupport::Notifications.unsubscribe(subscriber)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
require 'active_support'
|
2
|
+
require 'active_support/concern'
|
3
|
+
require 'active_support/core_ext/class'
|
4
|
+
require 'active_support/core_ext/class/attribute'
|
5
|
+
require 'active_support/core_ext/time'
|
6
|
+
require 'dry-equalizer'
|
7
|
+
|
8
|
+
module Messaging
|
9
|
+
module Message
|
10
|
+
extend ActiveSupport::Concern
|
11
|
+
|
12
|
+
included do
|
13
|
+
include Virtus.model
|
14
|
+
include Dry::Equalizer(:attributes)
|
15
|
+
|
16
|
+
attr_accessor :stream_position
|
17
|
+
attr_accessor :expected_version
|
18
|
+
|
19
|
+
key_attribute :uuid
|
20
|
+
|
21
|
+
attribute :message_name, String, default: ->(message, _) { message.class.message_name }
|
22
|
+
|
23
|
+
attribute :timestamp, Time, default: ->(*) { Time.current.utc }
|
24
|
+
attribute :uuid, String, default: ->(*) { SecureRandom.uuid }
|
25
|
+
end
|
26
|
+
|
27
|
+
module ClassMethods
|
28
|
+
# By default the topic is the same as the name of the message.
|
29
|
+
# We change the / that would be set for a namespaced message as "/" isn't valid in a topic
|
30
|
+
# To change the topic for a message just set it to whatever you want in your class definition.
|
31
|
+
def topic(topic_name = nil)
|
32
|
+
@topic ||= topic_name&.to_s || default_topic_name
|
33
|
+
end
|
34
|
+
|
35
|
+
# The attribute which value should be used as the key of the message.
|
36
|
+
# Must specify an attribute if ordering is important.
|
37
|
+
def key_attribute(attribute = nil)
|
38
|
+
@key_attribute = attribute if attribute
|
39
|
+
@key_attribute
|
40
|
+
end
|
41
|
+
|
42
|
+
def default_topic_name
|
43
|
+
return superclass.topic if superclass.respond_to?(:topic)
|
44
|
+
|
45
|
+
message_name.gsub('/', '-')
|
46
|
+
end
|
47
|
+
|
48
|
+
# Shorcut for creating a new message and publishing it
|
49
|
+
#
|
50
|
+
# @param attributes [Hash] The attributes of the message
|
51
|
+
# @option attributes [:any, Integer] :expected_version Concurrency control
|
52
|
+
# @return [Message] the message just published
|
53
|
+
# @raise [ExpectedVersion::Error] If the expected_version does not match
|
54
|
+
def publish(attributes)
|
55
|
+
new(attributes).publish
|
56
|
+
end
|
57
|
+
|
58
|
+
def message_name
|
59
|
+
name.underscore
|
60
|
+
end
|
61
|
+
|
62
|
+
def message_type
|
63
|
+
to_s
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# We do our own conversion for datetimes as by default they will only
|
68
|
+
# be stored with microseconds, which makes us loose precision when we
|
69
|
+
# serialize to json and back to message objects
|
70
|
+
def attributes_as_json(*_args)
|
71
|
+
attributes.transform_values { |v| v.respond_to?(:nsec) ? v.iso8601(9) : v }
|
72
|
+
end
|
73
|
+
|
74
|
+
def as_json(*_args)
|
75
|
+
attributes_as_json.merge(stream_position: stream_position)
|
76
|
+
end
|
77
|
+
|
78
|
+
def message_key
|
79
|
+
attributes[key_attribute&.to_sym]
|
80
|
+
end
|
81
|
+
|
82
|
+
def key_attribute
|
83
|
+
self.class.key_attribute
|
84
|
+
end
|
85
|
+
|
86
|
+
def topic
|
87
|
+
self.class.topic
|
88
|
+
end
|
89
|
+
|
90
|
+
def stream_name
|
91
|
+
# define stream_name in your message class to override
|
92
|
+
nil
|
93
|
+
end
|
94
|
+
|
95
|
+
def message_type
|
96
|
+
self.class.to_s
|
97
|
+
end
|
98
|
+
|
99
|
+
def publish
|
100
|
+
Messaging::Publish.(message: self)
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module Messaging
|
2
|
+
module Message
|
3
|
+
class FromJson
|
4
|
+
|
5
|
+
def self.call(json)
|
6
|
+
new(json).to_message
|
7
|
+
end
|
8
|
+
|
9
|
+
def initialize(json)
|
10
|
+
@attributes = JSON.parse(json, symbolize_names: true)
|
11
|
+
@klass = message_class_from_name(@attributes[:message_name])
|
12
|
+
end
|
13
|
+
|
14
|
+
def empty_message
|
15
|
+
Hash[@klass.attribute_set.map { |a| [a.name, nil] }]
|
16
|
+
end
|
17
|
+
|
18
|
+
def new_without_defaults(attributes)
|
19
|
+
@klass.new(empty_message.merge(attributes))
|
20
|
+
end
|
21
|
+
|
22
|
+
def to_message
|
23
|
+
new_without_defaults(@attributes)
|
24
|
+
end
|
25
|
+
|
26
|
+
def message_class_from_name(message_name)
|
27
|
+
"::#{message_name.camelize}".constantize
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,113 @@
|
|
1
|
+
ActiveSupport::Notifications.subscribe /request.connection.kafka/ do |*args|
|
2
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
3
|
+
if event.payload.key?(:exception)
|
4
|
+
tags = {
|
5
|
+
api: event.payload.fetch(:api, 'unknown'),
|
6
|
+
broker: event.payload.fetch(:broker_host)
|
7
|
+
}
|
8
|
+
Meter.increment('messaging.api.errors', tags: tags)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
ActiveSupport::Notifications.subscribe /process_message.consumer.kafka/ do |*args|
|
13
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
14
|
+
|
15
|
+
tags = {
|
16
|
+
group_id: event.payload.fetch(:group_id),
|
17
|
+
topic: event.payload.fetch(:topic),
|
18
|
+
partition: event.payload.fetch(:partition),
|
19
|
+
}
|
20
|
+
|
21
|
+
if event.payload.key?(:exception)
|
22
|
+
Meter.increment('messaging.consumer.process_message.errors', tags: tags)
|
23
|
+
else
|
24
|
+
Meter.increment('messaging.consumer.messages', tags: tags)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
ActiveSupport::Notifications.subscribe /process_batch.consumer.kafka/ do |*args|
|
29
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
30
|
+
message_count = event.payload.fetch(:message_count)
|
31
|
+
|
32
|
+
tags = {
|
33
|
+
group_id: event.payload.fetch(:group_id),
|
34
|
+
topic: event.payload.fetch(:topic),
|
35
|
+
partition: event.payload.fetch(:partition),
|
36
|
+
}
|
37
|
+
|
38
|
+
if event.payload.key?(:exception)
|
39
|
+
Meter.increment('messaging.consumer.process_batch.errors', tags: tags)
|
40
|
+
else
|
41
|
+
Meter.increment('messaging.consumer.messages', value: message_count, tags: tags)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
ActiveSupport::Notifications.subscribe /produce_message.producer.kafka/ do |*args|
|
46
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
47
|
+
topic = event.payload.fetch(:topic)
|
48
|
+
buffer_size = event.payload.fetch(:buffer_size)
|
49
|
+
max_buffer_size = event.payload.fetch(:max_buffer_size)
|
50
|
+
buffer_fill_ratio = buffer_size.to_f / max_buffer_size.to_f
|
51
|
+
|
52
|
+
# This gets us the write rate.
|
53
|
+
Meter.increment('messaging.producer.produce.messages', tags: { topic: topic })
|
54
|
+
|
55
|
+
# This gets us the avg/max buffer size per producer.
|
56
|
+
Meter.histogram('messaging.producer.buffer.size', buffer_size)
|
57
|
+
|
58
|
+
# This gets us the avg/max buffer fill ratio per producer.
|
59
|
+
Meter.histogram('messaging.producer.buffer.fill_ratio', buffer_fill_ratio)
|
60
|
+
end
|
61
|
+
|
62
|
+
ActiveSupport::Notifications.subscribe /buffer_overflow.producer.kafka/ do |*args|
|
63
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
64
|
+
tags = {
|
65
|
+
topic: event.payload.fetch(:topic)
|
66
|
+
}
|
67
|
+
|
68
|
+
Meter.increment('messaging.producer.produce.errors', tags: tags)
|
69
|
+
end
|
70
|
+
|
71
|
+
ActiveSupport::Notifications.subscribe /deliver_messages.producer.kafka/ do |*args|
|
72
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
73
|
+
message_count = event.payload.fetch(:delivered_message_count)
|
74
|
+
attempts = event.payload.fetch(:attempts)
|
75
|
+
|
76
|
+
Meter.increment('messaging.producer.deliver.errors') if event.payload.key?(:exception)
|
77
|
+
|
78
|
+
# Messages delivered to Kafka:
|
79
|
+
Meter.increment('messaging.producer.deliver.messages', value: message_count)
|
80
|
+
|
81
|
+
# Number of attempts to deliver messages:
|
82
|
+
Meter.histogram('messaging.producer.deliver.attempts', attempts)
|
83
|
+
end
|
84
|
+
|
85
|
+
ActiveSupport::Notifications.subscribe /topic_error.producer.kafka/ do |*args|
|
86
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
87
|
+
tags = {
|
88
|
+
topic: event.payload.fetch(:topic)
|
89
|
+
}
|
90
|
+
|
91
|
+
Meter.increment('messaging.producer.ack.errors', tags: tags)
|
92
|
+
end
|
93
|
+
|
94
|
+
ActiveSupport::Notifications.subscribe /enqueue_message.async_producer.kafka/ do |*args|
|
95
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
96
|
+
queue_size = event.payload.fetch(:queue_size)
|
97
|
+
max_queue_size = event.payload.fetch(:max_queue_size)
|
98
|
+
queue_fill_ratio = queue_size.to_f / max_queue_size.to_f
|
99
|
+
|
100
|
+
# This gets us the avg/max queue size per producer.
|
101
|
+
Meter.histogram('messaging.async_producer.queue.size', queue_size)
|
102
|
+
|
103
|
+
# This gets us the avg/max queue fill ratio per producer.
|
104
|
+
Meter.histogram('messaging.async_producer.queue.fill_ratio', queue_fill_ratio)
|
105
|
+
end
|
106
|
+
|
107
|
+
ActiveSupport::Notifications.subscribe /buffer_overflow.async_producer.kafka/ do |*args|
|
108
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
109
|
+
tags = {
|
110
|
+
topic: event.payload.fetch(:topic)
|
111
|
+
}
|
112
|
+
Meter.increment('messaging.async_producer.produce.errors', tags: tags)
|
113
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Messaging
|
2
|
+
module Middleware
|
3
|
+
# Used for hot code reloading (in development) and connection handling.
|
4
|
+
# This makes Rails automatically return AR connections to the pool after we
|
5
|
+
# have used them when processing a messages.
|
6
|
+
#
|
7
|
+
# See https://guides.rubyonrails.org/threading_and_code_execution.html for more information.
|
8
|
+
class RailsWrapper
|
9
|
+
attr_accessor :app
|
10
|
+
|
11
|
+
def initialize(app)
|
12
|
+
self.app = app
|
13
|
+
end
|
14
|
+
|
15
|
+
def call(*_args)
|
16
|
+
app.reloader.wrap do
|
17
|
+
yield
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module Messaging
|
2
|
+
class Publish
|
3
|
+
include MethodObject
|
4
|
+
|
5
|
+
option :message
|
6
|
+
|
7
|
+
option :message_store, default: -> { Messaging.message_store }
|
8
|
+
option :dispatcher, default: -> { Messaging.dispatcher }
|
9
|
+
option :middlewares, default: -> { Config.dispatcher.middlewares }
|
10
|
+
|
11
|
+
class << self
|
12
|
+
# @!method call(message:, stream:)
|
13
|
+
# Publishes the message
|
14
|
+
#
|
15
|
+
# This will persist the message first (if given a stream),
|
16
|
+
# if that succeeds it will be dispatched to the configured
|
17
|
+
# dispatcher and all handlers configured in the routes.
|
18
|
+
# @param message [Message] The message to publish
|
19
|
+
# @return [Message] the message just published
|
20
|
+
end
|
21
|
+
|
22
|
+
# @see .call
|
23
|
+
# @!visibility private
|
24
|
+
def call
|
25
|
+
persisted_message = message_store.call(message)
|
26
|
+
Middleware.run(middlewares, persisted_message) do
|
27
|
+
dispatcher.call(persisted_message)
|
28
|
+
Messaging.routes.handle persisted_message
|
29
|
+
end
|
30
|
+
persisted_message
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|