rails-pipeline 1.1.1

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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +227 -0
  4. data/Rakefile +27 -0
  5. data/bin/pipeline +138 -0
  6. data/bin/redis-to-ironmq.rb +20 -0
  7. data/lib/rails-pipeline.rb +34 -0
  8. data/lib/rails-pipeline/emitter.rb +121 -0
  9. data/lib/rails-pipeline/handlers/activerecord_crud.rb +35 -0
  10. data/lib/rails-pipeline/handlers/base_handler.rb +19 -0
  11. data/lib/rails-pipeline/handlers/logger.rb +13 -0
  12. data/lib/rails-pipeline/ironmq_publisher.rb +37 -0
  13. data/lib/rails-pipeline/ironmq_pulling_subscriber.rb +96 -0
  14. data/lib/rails-pipeline/ironmq_subscriber.rb +21 -0
  15. data/lib/rails-pipeline/pipeline_version.rb +40 -0
  16. data/lib/rails-pipeline/protobuf/encrypted_message.pb.rb +37 -0
  17. data/lib/rails-pipeline/protobuf/encrypted_message.proto +18 -0
  18. data/lib/rails-pipeline/redis_forwarder.rb +207 -0
  19. data/lib/rails-pipeline/redis_ironmq_forwarder.rb +12 -0
  20. data/lib/rails-pipeline/redis_publisher.rb +71 -0
  21. data/lib/rails-pipeline/sns_publisher.rb +62 -0
  22. data/lib/rails-pipeline/subscriber.rb +185 -0
  23. data/lib/rails-pipeline/symmetric_encryptor.rb +127 -0
  24. data/lib/rails-pipeline/version.rb +3 -0
  25. data/lib/tasks/rails-pipeline_tasks.rake +4 -0
  26. data/spec/emitter_spec.rb +141 -0
  27. data/spec/handlers/activerecord_crud_spec.rb +100 -0
  28. data/spec/handlers/logger_spec.rb +42 -0
  29. data/spec/ironmp_pulling_subscriber_spec.rb +98 -0
  30. data/spec/ironmq_publisher_spec.rb +37 -0
  31. data/spec/pipeline_version_spec.rb +35 -0
  32. data/spec/redis_forwarder_spec.rb +99 -0
  33. data/spec/redis_publisher_spec.rb +36 -0
  34. data/spec/sns_publisher_spec.rb +28 -0
  35. data/spec/subscriber_spec.rb +278 -0
  36. data/spec/symmetric_encryptor_spec.rb +21 -0
  37. metadata +175 -0
@@ -0,0 +1,121 @@
1
+ # A Pipeline emitter is an active record model that, when changed,
2
+ # will publish the changed fields to some sort of queue
3
+
4
+ require "rails-pipeline/symmetric_encryptor"
5
+
6
+ module RailsPipeline
7
+ module Emitter
8
+
9
+ def self.included(base)
10
+ RailsPipeline::SymmetricEncryptor.included(base)
11
+ base.send :include, InstanceMethods
12
+ base.extend ClassMethods
13
+ base.after_commit :emit_on_create, on: :create, if: :persisted?
14
+ base.after_commit :emit_on_update, on: :update
15
+ base.after_commit :emit_on_destroy, on: :destroy
16
+
17
+ if RailsPipeline::HAS_NEWRELIC
18
+ base.send :include, ::NewRelic::Agent::MethodTracer
19
+ base.add_method_tracer :emit, 'Pipeline/Emitter/emit'
20
+ end
21
+ end
22
+
23
+ module InstanceMethods
24
+ def emit_on_create
25
+ emit(:create)
26
+ end
27
+
28
+ def emit_on_update
29
+ emit(:update)
30
+ end
31
+
32
+ def emit_on_destroy
33
+ emit(:destroy)
34
+ end
35
+
36
+ def emit(event_type = RailsPipeline::EncryptedMessage::EventType::CREATED)
37
+ if ENV.has_key?("DISABLE_RAILS_PIPELINE") || ENV.has_key?("DISABLE_RAILS_PIPELINE_EMISSION")
38
+ RailsPipeline.logger.debug "Skipping outgoing pipeline messages (disabled by env vars)"
39
+ return
40
+ end
41
+ begin
42
+ self.class.pipeline_versions.each do |version|
43
+ enc_data = create_message(version, event_type)
44
+ self.publish(enc_data.topic, enc_data.to_s)
45
+ end
46
+ rescue Exception => e
47
+ RailsPipeline.logger.error("Error during emit(): #{e}")
48
+ puts e.backtrace.join("\n")
49
+ raise e
50
+ end
51
+ end
52
+
53
+ def create_message(version, event_type)
54
+ topic = self.class.topic_name(version)
55
+ RailsPipeline.logger.debug "Emitting to #{topic}"
56
+ data = self.send("to_pipeline_#{version}")
57
+ enc_data = self.class.encrypt(data.to_s, type_info: data.class.name, topic: topic, event_type: event_type)
58
+ return enc_data
59
+ end
60
+
61
+ end
62
+
63
+ module ClassMethods
64
+ # Get the list of versions to emit (all that are implemented, basically)
65
+ def pipeline_versions
66
+ if pipeline_method_cache.any?
67
+ return pipeline_method_cache.keys
68
+ end
69
+ versions = []
70
+ pipeline_methods = instance_methods.grep(/^to_pipeline/).sort
71
+ pipeline_methods.each do |pipeline_method|
72
+ version = pipeline_method.to_s.gsub("to_pipeline_", "")
73
+ if version.starts_with?("1_") && version != "1_0"
74
+ # Delete the default v1.0 emitter if a later v1 emitter is defined
75
+ i = versions.index("1_0")
76
+ versions.delete_at(i) if !i.nil?
77
+ end
78
+ pipeline_method_cache[version] = pipeline_method
79
+ versions << version
80
+ end
81
+ return versions
82
+ end
83
+
84
+ # Get pub/sub topic name to which changes to this model will be published
85
+ def topic_name(version="1_0")
86
+ return "harrys-#{Rails.env}-v#{major(version)}-#{table_name}"
87
+ end
88
+
89
+ # Get the major version number from a "1_1" style major/minor version string
90
+ def major(version)
91
+ if version.include? '_'
92
+ major, _ = version.split('_', 2)
93
+ else
94
+ major = version
95
+ end
96
+ return major
97
+ end
98
+
99
+ def _secret
100
+ ENV.fetch("PIPELINE_SECRET", Rails.application.config.secret_token)
101
+ end
102
+
103
+ def pipeline_method_cache
104
+ @pipeline_method_cache ||= {}
105
+ end
106
+
107
+ def pipeline_method_cache=(cache)
108
+ @pipeline_method_cache = cache
109
+ end
110
+
111
+ end
112
+ end
113
+
114
+
115
+ module RedisEmitter
116
+ def self.included(base)
117
+ RailsPipeline::Emitter.included(base)
118
+ RailsPipeline::RedisPublisher.included(base)
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,35 @@
1
+ module RailsPipeline
2
+ module SubscriberHandler
3
+ class ActiveRecordCRUD < BaseHandler
4
+ def handle_payload
5
+ begin
6
+ case event_type
7
+ when RailsPipeline::EncryptedMessage::EventType::CREATED
8
+ return target_class.create!(_attributes(payload), without_protection: true)
9
+ when RailsPipeline::EncryptedMessage::EventType::UPDATED
10
+ # We might want to allow confiugration of the primary key field
11
+ object = target_class.find(payload.id)
12
+ object.update_attributes!(_attributes(payload), without_protection: true)
13
+ return object
14
+ when RailsPipeline::EncryptedMessage::EventType::DELETED
15
+ object = target_class.find(payload.id)
16
+ object.destroy
17
+ return object
18
+ end
19
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::RecordNotFound => e
20
+ RailsPipeline.logger.error "Could not handle payload: #{payload.inspect}, event_type: #{event_type}"
21
+ end
22
+ end
23
+
24
+ def _attributes(payload)
25
+ attributes_hash = payload.to_hash
26
+ attributes_hash.each do |attribute_name, value|
27
+ if attribute_name.match /_at$/
28
+ attributes_hash[attribute_name] = Time.at(value).to_datetime
29
+ end
30
+ end
31
+ return attributes_hash
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,19 @@
1
+ module RailsPipeline
2
+ module SubscriberHandler
3
+ class BaseHandler
4
+ attr_reader :payload, :event_type, :target_class, :envelope
5
+
6
+ def initialize(payload, target_class: nil, envelope: nil, event_type: nil)
7
+ @payload = payload
8
+ @target_class = target_class
9
+ @envelope = envelope
10
+ if envelope
11
+ @event_type = envelope.event_type
12
+ else
13
+ @event_type = event_type
14
+ end
15
+ end
16
+
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,13 @@
1
+ module RailsPipeline
2
+ module SubscriberHandler
3
+ class Logger < BaseHandler
4
+ def handle_payload
5
+ # We'll need to GPG encrypt this
6
+ # Maybe it should encrypt using a public key set in environment variable
7
+ # Then the subscriber app would set it when installing the pipeline
8
+ # Would need to add configuration for that
9
+ RailsPipeline.logger.info envelope.to_s
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,37 @@
1
+ require 'base64'
2
+ require 'iron_mq'
3
+
4
+ # Backend for data pipeline that publishes to IronMQ
5
+ #
6
+ # Assumes the following env vars:
7
+ # - IRON_TOKEN=MY_TOKEN
8
+ # - IRON_PROJECT_ID=MY_PROJECT_ID
9
+
10
+ module RailsPipeline::IronmqPublisher
11
+
12
+ def self.included(base)
13
+ base.send :include, InstanceMethods
14
+ base.extend ClassMethods
15
+ end
16
+
17
+ module InstanceMethods
18
+ def publish(topic_name, data)
19
+ t0 = Time.now
20
+ queue = _iron.queue(topic_name)
21
+ queue.post({payload: Base64.strict_encode64(data)}.to_json)
22
+ t1 = Time.now
23
+ ::NewRelic::Agent.record_metric('Pipeline/IronMQ/publish', t1-t0) if RailsPipeline::HAS_NEWRELIC
24
+ RailsPipeline.logger.debug "Publishing to IronMQ: #{topic_name} took #{t1-t0}s"
25
+ end
26
+
27
+ def _iron
28
+ @iron = IronMQ::Client.new if @iron.nil?
29
+ return @iron
30
+ end
31
+
32
+ end
33
+
34
+ module ClassMethods
35
+ end
36
+
37
+ end
@@ -0,0 +1,96 @@
1
+ require 'base64'
2
+ require 'json'
3
+
4
+ module RailsPipeline
5
+ class IronmqPullingSubscriber
6
+ include RailsPipeline::Subscriber
7
+
8
+ attr_reader :queue_name
9
+
10
+ def initialize(queue_name)
11
+ @queue_name = queue_name
12
+ @subscription_status = false
13
+ end
14
+
15
+ # Valid Parameters at this time are
16
+ # wait_time - An integer indicating how long in seconds we should long poll on empty queues
17
+ # halt_on_error - A boolean indicating if we should stop our queue subscription if an error occurs
18
+ def start_subscription(params={wait_time: 2, halt_on_error: true}, &block)
19
+ activate_subscription
20
+
21
+ while active_subscription?
22
+ pull_message(params[:wait_time]) do |message|
23
+ process_message(message, params[:halt_on_error], block)
24
+ end
25
+ end
26
+ end
27
+
28
+
29
+ def process_message(message, halt_on_error, block)
30
+ begin
31
+ if message.nil? || JSON.parse(message.body).empty?
32
+ deactivate_subscription
33
+ else
34
+ payload = parse_ironmq_payload(message.body)
35
+ envelope = generate_envelope(payload)
36
+
37
+ process_envelope(envelope, message, block)
38
+ end
39
+ rescue Exception => e
40
+ if halt_on_error
41
+ deactivate_subscription
42
+ end
43
+
44
+ RailsPipeline.logger.error "A message was unable to be processed as was not removed from the queue."
45
+ RailsPipeline.logger.error "The message: #{message.inspect}"
46
+ raise e
47
+ end
48
+ end
49
+
50
+ def active_subscription?
51
+ @subscription_status
52
+ end
53
+
54
+ def activate_subscription
55
+ @subscription_status = true
56
+ end
57
+
58
+ def deactivate_subscription
59
+ @subscription_status = false
60
+ end
61
+
62
+ def process_envelope(envelope, message, block)
63
+ callback_status = block.call(envelope)
64
+
65
+ if callback_status
66
+ message.delete
67
+ end
68
+ end
69
+
70
+
71
+ #the wait time on this may need to be changed
72
+ #haven't seen rate limit info on these calls but didnt look
73
+ #all that hard either.
74
+ def pull_message(wait_time)
75
+ queue = _iron.queue(queue_name)
76
+ yield queue.get(:wait => wait_time)
77
+ end
78
+
79
+ private
80
+
81
+ def _iron
82
+ @iron = IronMQ::Client.new if @iron.nil?
83
+ return @iron
84
+ end
85
+
86
+ def parse_ironmq_payload(message_body)
87
+ payload = JSON.parse(message_body)["payload"]
88
+ Base64.strict_decode64(payload)
89
+ end
90
+
91
+ def generate_envelope(payload)
92
+ RailsPipeline::EncryptedMessage.parse(payload)
93
+ end
94
+
95
+ end
96
+ end
@@ -0,0 +1,21 @@
1
+ # Sinatra endpoints for consuming IronMQ push queues for the rails-pipeline
2
+
3
+ require 'sinatra'
4
+ require 'base64'
5
+
6
+ module RailsPipeline
7
+ class IronmqSubscriber < Sinatra::Base
8
+ include RailsPipeline::Subscriber
9
+
10
+ post '/' do
11
+ t0 = Time.now
12
+ data = request.body.read
13
+ payload = JSON.parse(Base64.strict_decode64(data))['payload']
14
+ envelope = RailsPipeline::EncryptedMessage.parse(payload)
15
+ handle_envelope(envelope)
16
+ t1 = Time.now
17
+ RailsPipeline.logger.debug "Consuming from IronMQ: #{envelope.topic} took #{t1-t0}s"
18
+ end
19
+
20
+ end
21
+ end
@@ -0,0 +1,40 @@
1
+ module RailsPipeline
2
+ # A thin wrapper around our version object
3
+ # A version has the form X_Y where
4
+ # - X is the major version
5
+ # - Y is the minor version
6
+ #
7
+ # example: 1_0, 2_1, etc...
8
+ class PipelineVersion
9
+
10
+ include Comparable
11
+
12
+ attr_reader :major, :minor
13
+
14
+ def initialize(version_string)
15
+ # raise error?
16
+ @major, @minor = version_string.split('_').map(&:to_i)
17
+ end
18
+
19
+ def to_s
20
+ "#{major}_#{minor}"
21
+ end
22
+
23
+ def <=>(other)
24
+ if major == other.major
25
+ return minor <=> other.minor
26
+ else
27
+ return major <=> other.major
28
+ end
29
+ end
30
+
31
+ def eql?(other)
32
+ return to_s.eql?(other.to_s)
33
+ end
34
+
35
+ def hash
36
+ return to_s.hash
37
+ end
38
+
39
+ end
40
+ end
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env ruby
2
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
3
+
4
+ require 'protocol_buffers'
5
+
6
+ module RailsPipeline
7
+ # forward declarations
8
+ class EncryptedMessage < ::ProtocolBuffers::Message; end
9
+
10
+ class EncryptedMessage < ::ProtocolBuffers::Message
11
+ # forward declarations
12
+
13
+ # enums
14
+ module EventType
15
+ include ::ProtocolBuffers::Enum
16
+
17
+ set_fully_qualified_name "RailsPipeline.EncryptedMessage.EventType"
18
+
19
+ CREATED = 0
20
+ UPDATED = 1
21
+ DELETED = 2
22
+ end
23
+
24
+ set_fully_qualified_name "RailsPipeline.EncryptedMessage"
25
+
26
+ required :string, :uuid, 1
27
+ required :string, :salt, 2
28
+ required :string, :iv, 3
29
+ required :string, :ciphertext, 4
30
+ optional :string, :owner_info, 5
31
+ optional :string, :type_info, 6
32
+ optional :string, :topic, 7
33
+ optional ::RailsPipeline::EncryptedMessage::EventType, :event_type, 8, :default => ::RailsPipeline::EncryptedMessage::EventType::CREATED
34
+ required :string, :api_key, 9
35
+ end
36
+
37
+ end
@@ -0,0 +1,18 @@
1
+ package RailsPipeline;
2
+
3
+ message EncryptedMessage {
4
+ required string uuid = 1;
5
+ required string salt = 2;
6
+ required string iv = 3;
7
+ required string ciphertext = 4;
8
+ optional string owner_info = 5; // e.g. user_id, if you store a key on the User
9
+ optional string type_info = 6; // ruby class name
10
+ optional string topic = 7; // useful for redis message forwarder
11
+ enum EventType {
12
+ CREATED = 0;
13
+ UPDATED = 1;
14
+ DELETED = 2;
15
+ }
16
+ optional EventType event_type = 8 [default = CREATED]; // indicates what kind of event this payload contains
17
+ required string api_key = 9;
18
+ }