realm-sns 0.7.3 → 0.7.4

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: dc34c9cede23ac937d62a7c1f496444a629427765d5bb3d7d4fc4f6eb8921507
4
- data.tar.gz: 56571d1ffcff0d8c418de54f9bb3d3b99ef867e4c514d84af73694fc8c05f8d5
3
+ metadata.gz: 3c3e08ba717bbc19dc0c3541de8e63eabe3107b1275912ca6d137407c1eff68d
4
+ data.tar.gz: 3acb987d8ddb0ef72dcfb3d4af7b7dd46e94059e29ca5e0d35d4ee399f168f06
5
5
  SHA512:
6
- metadata.gz: 972425f1c9c07b92209f2ad6eee95381ad8a346547cfe23981d8a936f5ca8d2dd726aa716ebc61db90d77cd0ce6fd6b7ee3f3350b2eb560edf516f975e94703a
7
- data.tar.gz: b22a548fa14c648aba403c219b9b32ffcd0336761f363eb6d3874511ca269a92d13794ead1f9f5f676a56803eadc46e9e86f3693b066972df8d24494f7fb2ef6
6
+ metadata.gz: 68ca252514fa6bc74b3a66d69971895d73824e1d545d627fc3939db7cc923c204664846ccb6637b6c6fde4e0bd172ea763ee0232d06c5e38df85ede8ec2c51a1
7
+ data.tar.gz: 3a0b06381af32201261843f969dee3ed6b288b8dc6ad413dd621bbfb69a85fce7e2c85effb2f8edb3460ff4b546275c3dade5a894d3db83c217268903cc4f2fd
data/lib/realm-sns.rb ADDED
@@ -0,0 +1,16 @@
1
+ # rubocop:disable Naming/FileName
2
+ # frozen_string_literal: true
3
+
4
+ require 'zeitwerk'
5
+
6
+ loader = Zeitwerk::Loader.for_gem
7
+ loader.ignore(__FILE__)
8
+ loader.inflector.inflect('sns' => 'SNS')
9
+ loader.setup
10
+
11
+ require 'realm-core'
12
+ require 'aws-sdk-sns'
13
+ require 'aws-sdk-sqs'
14
+ require 'realm/sns/plugin'
15
+
16
+ # rubocop:enable Naming/FileName
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Realm
6
+ module SNS
7
+ class Gateway < Realm::EventRouter::Gateway
8
+ def initialize(topic_arn:, queue_prefix: nil, event_processing_attempts: 3, **)
9
+ super
10
+ @topic = TopicAdapter.new(topic_arn)
11
+ @queue_prefix = queue_prefix
12
+ @event_processing_attempts = event_processing_attempts
13
+ @queue_map = {}
14
+ end
15
+
16
+ def add_listener(event_type, listener, queue_arn: nil)
17
+ queue = queue_arn ? queue_manager.get(arn: queue_arn) : provide_queue(event_type, listener)
18
+ @queue_map[queue] = listener
19
+ end
20
+
21
+ def trigger(event_type, attributes = {})
22
+ create_event(event_type, attributes).tap { |event| @topic.publish(event_type, event.to_json) }
23
+ end
24
+
25
+ def worker(**options)
26
+ @worker ||= Worker.new(
27
+ @queue_map,
28
+ event_factory: @event_factory,
29
+ logger: @runtime && @runtime.context[:logger],
30
+ event_processing_attempts: @event_processing_attempts,
31
+ **options,
32
+ )
33
+ end
34
+
35
+ def queues
36
+ @queue_map.keys
37
+ end
38
+
39
+ private
40
+
41
+ def provide_queue(event_type, listener)
42
+ queue_name = [event_type.to_s.gsub('.', '_'), queue_suffix(listener)].join('-')
43
+ queue = queue_manager.provide(queue_name)
44
+ @topic.subscribe(event_type, queue)
45
+ queue
46
+ end
47
+
48
+ def queue_manager
49
+ @queue_manager ||= QueueManager.new(prefix: @queue_prefix)
50
+ end
51
+
52
+ def queue_suffix(listener)
53
+ listener.try(:identifier) || SecureRandom.alphanumeric(16)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Realm
4
+ module SNS
5
+ class Gateway < Realm::EventRouter::Gateway
6
+ # Provides cleaner SDK over Aws::SQS::Queue
7
+ class QueueAdapter
8
+ include Mixins::Decorator[:@queue]
9
+
10
+ def arn
11
+ @queue.attributes['QueueArn']
12
+ end
13
+
14
+ def allow_send_messages(source_arn)
15
+ @queue.set_attributes(attributes: {
16
+ 'Policy' => {
17
+ 'Version' => '2012-10-17',
18
+ 'Statement' => policy_statement(source_arn),
19
+ }.to_json,
20
+ })
21
+ end
22
+
23
+ def publish(event_type, message)
24
+ @queue.send_message(
25
+ message_body: message,
26
+ message_attributes: { 'event_type' => { data_type: 'String', string_value: event_type.to_s } },
27
+ )
28
+ end
29
+
30
+ def empty?
31
+ attributes.slice(
32
+ 'ApproximateNumberOfMessages',
33
+ 'ApproximateNumberOfMessagesDelayed',
34
+ 'ApproximateNumberOfMessagesNotVisible',
35
+ ).all? { |_, val| val.to_i.zero? }
36
+ end
37
+
38
+ private
39
+
40
+ def policy_statement(source_arn)
41
+ {
42
+ 'Effect' => 'Allow',
43
+ 'Principal' => { 'AWS' => '*' },
44
+ 'Action' => 'sqs:SendMessage',
45
+ 'Resource' => arn,
46
+ 'Condition' => {
47
+ 'ArnEquals' => { 'aws:SourceArn' => source_arn },
48
+ },
49
+ }
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Realm
4
+ module SNS
5
+ class Gateway < Realm::EventRouter::Gateway
6
+ class QueueManager
7
+ class QueueNameTooLong < Realm::Error
8
+ def initialize(name, msg: "Queue name '#{name}' cannot be longer than 80 characters, " \
9
+ "please provide custom EventHandler identifier if it's auto generated")
10
+ super(msg)
11
+ end
12
+ end
13
+
14
+ CleanupWithoutPrefix = Realm::Error[
15
+ 'Cleaning up queues without prefix is not allowed, it can lead to deleting queues from other apps']
16
+
17
+ def initialize(prefix: nil, sqs: Aws::SQS::Resource.new)
18
+ @prefix = prefix
19
+ @sqs = sqs
20
+ end
21
+
22
+ def get(name: nil, arn: nil)
23
+ throw ArgumentError, 'You have to provide name or arn of the queue' unless name || arn
24
+
25
+ QueueAdapter.new(@sqs.get_queue_by_name(queue_name: name ? prefix_name(name) : arn.split(':')[-1]))
26
+ end
27
+
28
+ def create(queue_name)
29
+ name = prefix_name(queue_name)
30
+ raise QueueNameTooLong, name if name.size > 80
31
+
32
+ QueueAdapter.new(@sqs.create_queue(queue_name: name))
33
+ end
34
+
35
+ def provide(queue_name)
36
+ get(name: queue_name)
37
+ rescue Aws::SQS::Errors::NonExistentQueue
38
+ create(queue_name)
39
+ end
40
+
41
+ def cleanup(except: [])
42
+ raise CleanupWithoutPrefix unless @prefix
43
+
44
+ except_urls = Array(except).map(&:url)
45
+ @sqs.queues(queue_name_prefix: @prefix).each do |queue|
46
+ next if except_urls.include?(queue.url)
47
+
48
+ queue.delete if QueueAdapter.new(queue).empty?
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def prefix_name(name)
55
+ [@prefix, name].compact.join('-')
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Realm
4
+ module SNS
5
+ class Gateway < Realm::EventRouter::Gateway
6
+ # Provides cleaner SDK over Aws::SNS::Topic
7
+ class TopicAdapter
8
+ class SubscriptionError < Realm::Error
9
+ def initialize(queue_arn, subscription_attributes)
10
+ super("Cannot subscribe SQS queue #{queue_arn} with attributes #{subscription_attributes}")
11
+ end
12
+ end
13
+
14
+ def initialize(topic_or_arn)
15
+ @topic = topic_or_arn.is_a?(Aws::SNS::Topic) ? topic_or_arn : Aws::SNS::Resource.new.topic(topic_or_arn)
16
+ end
17
+
18
+ def publish(event_type, message)
19
+ @topic.publish(
20
+ message: message,
21
+ message_attributes: { 'event_type' => { data_type: 'String', string_value: event_type.to_s } },
22
+ )
23
+ end
24
+
25
+ def subscribe(event_type, queue)
26
+ queue.allow_send_messages(@topic.arn)
27
+ attributes = subscribe_attributes(event_type)
28
+ @topic.subscribe(protocol: 'sqs', endpoint: queue.arn, attributes: attributes)
29
+ rescue Aws::SNS::Errors::InvalidParameter
30
+ raise SubscriptionError.new(queue.arn, attributes)
31
+ end
32
+
33
+ private
34
+
35
+ def subscribe_attributes(event_type)
36
+ attrs = { 'RawMessageDelivery' => true }
37
+ attrs['FilterPolicy'] = { 'event_type' => [event_type] } unless event_type == :any
38
+ attrs.transform_values(&:to_json)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Realm
4
+ module SNS
5
+ class Gateway < Realm::EventRouter::Gateway
6
+ class Worker
7
+ def initialize(queue_map, event_factory:, event_processing_attempts: 3, logger: nil)
8
+ @queue_map = queue_map
9
+ @event_factory = event_factory
10
+ @event_processing_attempts = event_processing_attempts
11
+ @logger = logger || Logger.new($stdout)
12
+ @threads = []
13
+ end
14
+
15
+ def start(poller_options: {})
16
+ @signaler = { exiting: false }
17
+ @queue_map.each_pair do |queue, listener|
18
+ @threads << Thread.new { run_poller(queue, listener, @signaler, poller_options) }
19
+ end
20
+ self
21
+ end
22
+
23
+ def stop(timeout: 30)
24
+ Thread.new { @logger.info("Stopping worker (timeout: #{timeout}s)") }.join # Cannot log from trap context
25
+ @signaler[:exiting] = true
26
+ join(timeout)
27
+ @threads.clear
28
+ self
29
+ end
30
+
31
+ def join(timeout = nil)
32
+ @threads.each { |thread| thread.join(timeout) }
33
+ end
34
+
35
+ private
36
+
37
+ def run_poller(queue, listener, signaler, options)
38
+ @logger.info("Start polling #{queue.arn}")
39
+ init_poller(queue, signaler, options).poll do |messages, stats|
40
+ log_poller_stats(queue, stats)
41
+ messages.each { |msg| handle_message(listener, msg) }
42
+ end
43
+ @logger.info("Polling stopped #{queue.arn}")
44
+ end
45
+
46
+ def init_poller(queue, signaler, options = {})
47
+ Aws::SQS::QueuePoller.new(
48
+ queue.url,
49
+ max_number_of_messages: 10,
50
+ visibility_timeout: 60,
51
+ attribute_names: ['ApproximateReceiveCount'],
52
+ message_attribute_names: ['event_type'],
53
+ before_request: before_request_proc(queue, signaler),
54
+ **options,
55
+ )
56
+ end
57
+
58
+ def before_request_proc(queue, signaler)
59
+ proc {
60
+ if signaler[:exiting]
61
+ @logger.info("Stopping polling #{queue.arn}")
62
+ throw :stop_polling
63
+ end
64
+ }
65
+ end
66
+
67
+ def log_poller_stats(queue, stats)
68
+ @logger.info(
69
+ message: "Poller #{queue.arn} stats",
70
+ request_count: stats.request_count,
71
+ message_count: stats.received_message_count,
72
+ last_message_received_at: stats.last_message_received_at,
73
+ )
74
+ end
75
+
76
+ def handle_message(listener, msg)
77
+ event = message_to_event(msg)
78
+ listener.(event)
79
+ rescue StandardError => e
80
+ log_error(e, event, msg)
81
+ # Picks up the message again after visibility_timeout runs out:
82
+ throw :skip_delete if event && message_receive_count(msg) < @event_processing_attempts
83
+ end
84
+
85
+ def message_to_event(msg)
86
+ event_type = msg.message_attributes['event_type'].string_value
87
+ raise 'Message is missing event type' unless event_type
88
+
89
+ payload = JSON.parse(msg.body).deep_symbolize_keys
90
+ @event_factory.create_event(event_type, payload)
91
+ end
92
+
93
+ def message_receive_count(msg)
94
+ msg.attributes['ApproximateReceiveCount'].to_i
95
+ end
96
+
97
+ def log_error(error, event, msg)
98
+ return @logger.fatal("Unexpected message in queue: #{msg}; error: #{error.full_message}") unless event
99
+
100
+ attempt = message_receive_count(msg)
101
+ will_retry = attempt < @event_processing_attempts
102
+ log_line = [
103
+ "Processing of event failed type=#{event.type} id=#{event.head.id} attempt=#{attempt},",
104
+ "#{will_retry ? 'will retry' : 'final'}):\n#{error.full_message}",
105
+ ].join(' ')
106
+ @logger.send(will_retry ? :warn : :error, log_line)
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Realm
4
+ module SNS
5
+ class Plugin < Realm::Plugin
6
+ def self.setup(_config, container)
7
+ container.register('event_router.gateway_classes.sns', SNS::Gateway)
8
+ end
9
+ end
10
+ end
11
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: realm-sns
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.3
4
+ version: 0.7.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - developers@reevoo.com
@@ -114,8 +114,13 @@ executables: []
114
114
  extensions: []
115
115
  extra_rdoc_files: []
116
116
  files:
117
- - README.md
118
- - Rakefile
117
+ - lib/realm-sns.rb
118
+ - lib/realm/sns/gateway.rb
119
+ - lib/realm/sns/gateway/queue_adapter.rb
120
+ - lib/realm/sns/gateway/queue_manager.rb
121
+ - lib/realm/sns/gateway/topic_adapter.rb
122
+ - lib/realm/sns/gateway/worker.rb
123
+ - lib/realm/sns/plugin.rb
119
124
  homepage:
120
125
  licenses:
121
126
  - MIT
data/README.md DELETED
@@ -1,40 +0,0 @@
1
- # Realm
2
-
3
- Domain layer framework following Domain-driven/CQRS design principles.
4
-
5
- [![Build status](https://badge.buildkite.com/346cce75f6c31e0a41bb98b198e85eb6b722243624459fad9c.svg)](https://buildkite.com/reevoo/realm)
6
-
7
- ## Service layers
8
-
9
- We follow the standard MVC design pattern of Rails but giving the model layer more structure and guidance regarding where
10
- to put your code. The model is split into domain layer (using our [Realm](https://github.com/reevoo/smart-mono/tree/master/gems/realm) library)
11
- and persistence layer (using [ROM](https://rom-rb.org/) library). The individual components are explained in the following section.
12
-
13
- ![Service layers](https://confluence-connect.gliffy.net/embed/image/d02d04b1-5e40-415f-b7ba-3a631efa9bf3.png?utm_medium=live&utm_source=custom)
14
-
15
- Advanced components are shown in lighter color, those will be needed only later on as the service domain logic grows.
16
-
17
- ## Model layer components
18
-
19
- ![Service external components](https://confluence-connect.gliffy.net/embed/image/c593fcc2-304e-47c3-8e3c-b0cc09e0ed54.png?utm_medium=live&utm_source=custom)
20
-
21
- Each service has one **domain** module which consists of multiple [**aggregate**](https://martinfowler.com/bliki/DDD_Aggregate.html) modules.
22
- Aggregate is a cluster of domain objects that can be treated as a single unit. The only way for outer world to communicate
23
- with aggregate is by **queries** and **commands**. Query exposes aggregate's internal state and command changes it.
24
- The state of an aggregate is represented by tree of **entities** with one being the aggregate root and zero or more dependent
25
- entities with *belongs_to* relation to the root entity. The state of an aggregate (entity tree) is persisted
26
- and retrieved by **repository**. There is generally one repository per aggregate unless we split the read/write
27
- (query/command) persistence model for that particular domain. The repository uses **relations** to access the database
28
- tables. Each relation class represents one table.
29
-
30
-
31
- ## Where to put my code as it grows?
32
-
33
- TODO
34
-
35
-
36
- ## Roadmap
37
-
38
- - [ ] Support Ruby 3
39
- - [ ] Make it work outside of Rails engines
40
- - [ ] Support multiple persistence gateways in one runtime
data/Rakefile DELETED
@@ -1,19 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- begin
4
- require 'bundler/setup'
5
- rescue LoadError
6
- puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
7
- end
8
-
9
- require 'rdoc/task'
10
-
11
- RDoc::Task.new(:rdoc) do |rdoc|
12
- rdoc.rdoc_dir = 'rdoc'
13
- rdoc.title = 'Realm'
14
- rdoc.options << '--line-numbers'
15
- rdoc.rdoc_files.include('README.md')
16
- rdoc.rdoc_files.include('lib/**/*.rb')
17
- end
18
-
19
- require 'bundler/gem_tasks'