realm-sns 0.7.3 → 0.7.4

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.
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'