dirty_pipeline 0.7.1 → 0.8.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f1d848ae9c9559b571abc4a8561be9f7deb9d7aaa4977f5c5c7993fd698881cd
4
- data.tar.gz: 95d0fe52b224ea34310a102cf2dd1d31cafb298cb8c9a25065b9ee41f819a238
3
+ metadata.gz: 9f3fd5ee73f6187ea39daa3d7b88371c84f3c6ca848b7ba65f58b7c8876c55b5
4
+ data.tar.gz: ab18cf256c02e3851a87c093523bffe204bc25bba6a31be8027a923ddbebaa69
5
5
  SHA512:
6
- metadata.gz: ae206aaf6b7b7cda711467775e912dfa888f3024d4fde8e4516eab8c07a9f6d8d5e983867a415ddb9765506acb1e3a4d7cb64ec9af828bb54b2d31404c587280
7
- data.tar.gz: 64f3e7ca995413684d0b77676c178877b893419775433faf2e855c29538feff1833f39f26671d4b9577ef29e660a9c50417f597cdada71109e51bcce36cf9631
6
+ metadata.gz: a818876d704b6dbb40ec6a0bad6ac45fb38a40ad87fce41859ea5cc2a29ddb8d72067aefdb63d16d888c6cef0fcd840f3b65ca00b0fc810133b081ed10bd25f7
7
+ data.tar.gz: e6e014a6f8b37f55a688c5797c49287642531d4cc3dc2373688936b1e9bc0c2e7263947e9cbdcf6e9e152294cc0cf6c34f24e5835bad782c5bd497c7960f897e
@@ -24,6 +24,7 @@ Gem::Specification.new do |spec|
24
24
  # temporary dependency
25
25
  spec.add_runtime_dependency "sidekiq", "~> 5.0"
26
26
  spec.add_runtime_dependency "redis", "~> 4.0"
27
+ spec.add_runtime_dependency "pg", "~> 1.0"
27
28
 
28
29
  spec.add_development_dependency "bundler", "~> 1.16"
29
30
  spec.add_development_dependency "rake", "~> 10.0"
@@ -4,12 +4,21 @@ require "securerandom"
4
4
  module DirtyPipeline
5
5
  require_relative "dirty_pipeline/ext/camelcase.rb"
6
6
  require_relative "dirty_pipeline/status.rb"
7
- require_relative "dirty_pipeline/storage.rb"
8
7
  require_relative "dirty_pipeline/worker.rb"
9
8
  require_relative "dirty_pipeline/transaction.rb"
10
9
  require_relative "dirty_pipeline/event.rb"
11
- require_relative "dirty_pipeline/queue.rb"
12
- require_relative "dirty_pipeline/railway.rb"
10
+
11
+ # Redis
12
+ require_relative "dirty_pipeline/redis/railway.rb"
13
+ require_relative "dirty_pipeline/redis/storage.rb"
14
+ require_relative "dirty_pipeline/redis/queue.rb"
15
+
16
+ # Postgres
17
+ require_relative "dirty_pipeline/pg.rb"
18
+ require_relative "dirty_pipeline/pg/railway.rb"
19
+ require_relative "dirty_pipeline/pg/storage.rb"
20
+ require_relative "dirty_pipeline/pg/queue.rb"
21
+
13
22
  require_relative "dirty_pipeline/base.rb"
14
23
  require_relative "dirty_pipeline/transition.rb"
15
24
 
@@ -17,4 +26,31 @@ module DirtyPipeline
17
26
  def self.with_redis
18
27
  fail NotImplementedError
19
28
  end
29
+
30
+ # This method should yield raw PG connection
31
+ def self.with_postgres
32
+ fail NotImplementedError
33
+ end
34
+
35
+ # def self.with_postgres
36
+ # yield(ActiveRecord::Base.connection.raw_connection)
37
+ # ensure
38
+ # ActiveRecord::Base.clear_active_connections!
39
+ # end
40
+
41
+ Queue = Redis::Queue
42
+ Storage = Redis::Storage
43
+ Railway = Redis::Railway
44
+
45
+ def self.create!(conn)
46
+ Queue.create!(conn) if Queue.respond_to?(:create!)
47
+ Storage.create!(conn) if Storage.respond_to?(:create!)
48
+ Railway.create!(conn) if Railway.respond_to?(:create!)
49
+ end
50
+
51
+ def self.destroy!(conn)
52
+ Queue.destroy!(conn) if Queue.respond_to?(:destroy!)
53
+ Storage.destroy!(conn) if Storage.respond_to?(:destroy!)
54
+ Railway.destroy!(conn) if Railway.respond_to?(:destroy!)
55
+ end
20
56
  end
@@ -1,4 +1,5 @@
1
1
  require 'json'
2
+ require 'time'
2
3
 
3
4
  module DirtyPipeline
4
5
  class Event
@@ -41,7 +42,7 @@ module DirtyPipeline
41
42
  "transaction_uuid" => @tx_id,
42
43
  "transition" => transition,
43
44
  "args" => args,
44
- "created_at" => Time.now,
45
+ "created_at" => Time.now.utc.iso8601,
45
46
  "cache" => {},
46
47
  "attempts_count" => 1,
47
48
  "status" => NEW,
@@ -71,7 +72,7 @@ module DirtyPipeline
71
72
  @error = {
72
73
  "exception" => exception.class.to_s,
73
74
  "exception_message" => exception.message,
74
- "created_at" => Time.now,
75
+ "created_at" => Time.now.utc.iso8601,
75
76
  }
76
77
  failure!
77
78
  end
@@ -81,7 +82,7 @@ module DirtyPipeline
81
82
  end
82
83
 
83
84
  def attempt_retry!
84
- @data["updated_at"] = Time.now
85
+ @data["updated_at"] = Time.now.utc.iso8601
85
86
  @data["attempts_count"] = attempts_count + 1
86
87
  end
87
88
 
@@ -89,7 +90,7 @@ module DirtyPipeline
89
90
  @data.merge!(
90
91
  "destination" => destination,
91
92
  "changes" => changes,
92
- "updated_at" => Time.now,
93
+ "updated_at" => Time.now.utc.iso8601,
93
94
  "status" => SUCCESS,
94
95
  )
95
96
  end
@@ -0,0 +1,12 @@
1
+ module DirtyPipeline
2
+ module PG
3
+ module_function
4
+ def multi(pg_result)
5
+ pg_result.first&.values
6
+ end
7
+
8
+ def single(pg_result)
9
+ pg_result.first&.values&.first
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,171 @@
1
+ module DirtyPipeline
2
+ module PG
3
+ class Queue
4
+ # decoder = PG::TextDecoder::Array.new
5
+ # see https://stackoverflow.com/questions/34886260/how-do-you-decode-a-json-field-using-the-pg-gem
6
+ def self.create!(connection)
7
+ connection.exec <<~SQL
8
+ CREATE TABLE dp_active_events (
9
+ key TEXT CONSTRAINT primary_event_queues_key PRIMARY KEY,
10
+ payload TEXT,
11
+ created_at TIMESTAMP NOT NULL DEFAULT now()
12
+ );
13
+
14
+ CREATE SEQUENCE dp_event_queues_id_seq START 1;
15
+ CREATE TABLE dp_event_queues (
16
+ id BIGINT PRIMARY KEY DEFAULT nextval('dp_event_queues_id_seq'),
17
+ key TEXT NOT NULL,
18
+ payload TEXT,
19
+ created_at TIMESTAMP NOT NULL DEFAULT now()
20
+ );
21
+ SQL
22
+ end
23
+
24
+ def self.destroy!(connection)
25
+ connection.exec <<~SQL
26
+ DROP TABLE IF EXISTS dp_active_events;
27
+ DROP TABLE IF EXISTS dp_event_queues;
28
+ DROP SEQUENCE IF EXISTS dp_event_queues_id_seq;
29
+ SQL
30
+ end
31
+
32
+ def initialize(operation, subject_class, subject_id, transaction_id)
33
+ @root = "dirty-pipeline-queue:#{subject_class}:#{subject_id}:" \
34
+ "op_#{operation}:txid_#{transaction_id}"
35
+ end
36
+
37
+ def with_postgres(&block)
38
+ DirtyPipeline.with_postgres(&block)
39
+ end
40
+
41
+ DELETE_ACTIVE = <<~SQL
42
+ DELETE FROM dp_active_events WHERE key = $1;
43
+ SQL
44
+ DELETE_EVENTS = <<~SQL
45
+ DELETE FROM dp_event_queues WHERE key = $1;
46
+ SQL
47
+
48
+ def clear!
49
+ with_postgres do |c|
50
+ c.transaction do
51
+ c.exec(DELETE_ACTIVE, [active_event_key])
52
+ c.exec(DELETE_EVENTS, [events_queue_key])
53
+ end
54
+ end
55
+ end
56
+
57
+ SELECT_ALL_EVENTS = <<~SQL
58
+ SELECT payload FROM dp_event_queues WHERE key = $1 ORDER BY id DESC;
59
+ SQL
60
+ def to_a
61
+ with_postgres do |c|
62
+ c.exec(SELECT_ALL_EVENTS, [events_queue_key]).to_a.map! do |row|
63
+ unpack(row.values.first)
64
+ end
65
+ end
66
+ end
67
+
68
+ PUSH_EVENT = <<~SQL
69
+ INSERT INTO dp_event_queues (id, key, payload)
70
+ VALUES (-nextval('dp_event_queues_id_seq'), $1, $2);
71
+ SQL
72
+ def push(event)
73
+ with_postgres do |c|
74
+ c.transaction do
75
+ c.exec(PUSH_EVENT, [events_queue_key, pack(event)])
76
+ end
77
+ end
78
+
79
+ self
80
+ end
81
+ alias :<< :push
82
+
83
+ UNSHIFT_EVENT = <<~SQL
84
+ INSERT INTO dp_event_queues (key, payload) VALUES ($1, $2);
85
+ SQL
86
+ def unshift(event)
87
+ with_postgres do |c|
88
+ c.transaction do
89
+ c.exec(UNSHIFT_EVENT, [events_queue_key, pack(event)])
90
+ end
91
+ end
92
+ self
93
+ end
94
+
95
+ SELECT_LAST_EVENT = <<~SQL
96
+ SELECT id, payload FROM dp_event_queues
97
+ WHERE key = $1
98
+ ORDER BY id DESC LIMIT 1;
99
+ SQL
100
+ DELETE_EVENT = <<~SQL
101
+ DELETE FROM dp_event_queues WHERE key = $1 AND id = $2;
102
+ SQL
103
+ DELETE_ACTIVE_EVENT = <<~SQL
104
+ DELETE FROM dp_active_events WHERE key = $1;
105
+ SQL
106
+ SET_EVENT_ACTIVE = <<~SQL
107
+ INSERT INTO dp_active_events (key, payload) VALUES ($1, $2)
108
+ ON CONFLICT (key) DO UPDATE SET payload = EXCLUDED.payload;
109
+ SQL
110
+ def pop
111
+ with_postgres do |c|
112
+ c.transaction do
113
+ event_id, raw_event =
114
+ PG.multi(c.exec(SELECT_LAST_EVENT, [events_queue_key]))
115
+ if raw_event.nil?
116
+ c.exec(DELETE_ACTIVE_EVENT, [active_event_key])
117
+ else
118
+ c.exec(DELETE_EVENT, [events_queue_key, event_id])
119
+ c.exec(SET_EVENT_ACTIVE, [active_event_key, raw_event])
120
+ end
121
+ unpack(raw_event)
122
+ end
123
+ end
124
+ end
125
+
126
+ SELECT_ACTIVE_EVENT = <<~SQL
127
+ SELECT payload FROM dp_active_events WHERE key = $1;
128
+ SQL
129
+ def processing_event
130
+ with_postgres do |c|
131
+ raw_event = PG.single(
132
+ c.exec(SELECT_ACTIVE_EVENT, [active_event_key])
133
+ )
134
+ unpack(raw_event)
135
+ end
136
+ end
137
+
138
+ private
139
+
140
+ def pack(event)
141
+ JSON.dump(
142
+ "evid" => event.id,
143
+ "txid" => event.tx_id,
144
+ "transit" => event.transition,
145
+ "args" => event.args,
146
+ )
147
+ end
148
+
149
+ def unpack(packed_event)
150
+ return unless packed_event
151
+ unpacked_event = JSON.load(packed_event)
152
+ Event.new(
153
+ data: {
154
+ "uuid" => unpacked_event["evid"],
155
+ "transaction_uuid" => unpacked_event["txid"],
156
+ "transition" => unpacked_event["transit"],
157
+ "args" => unpacked_event["args"],
158
+ }
159
+ )
160
+ end
161
+
162
+ def events_queue_key
163
+ "#{@root}:events"
164
+ end
165
+
166
+ def active_event_key
167
+ "#{@root}:active"
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,143 @@
1
+ module DirtyPipeline
2
+ module PG
3
+ class Railway
4
+ DEFAULT_OPERATIONS = %w(call undo finalize)
5
+
6
+ def self.create!(connection)
7
+ connection.exec <<~SQL
8
+ CREATE TABLE dp_active_operations (
9
+ key TEXT CONSTRAINT primary_dp_active_operations_key PRIMARY KEY,
10
+ name TEXT,
11
+ created_at TIMESTAMP NOT NULL DEFAULT now()
12
+ );
13
+ CREATE TABLE dp_active_transactions (
14
+ key TEXT CONSTRAINT primary_dp_active_tx_key PRIMARY KEY,
15
+ name TEXT,
16
+ created_at TIMESTAMP NOT NULL DEFAULT now()
17
+ );
18
+ SQL
19
+ end
20
+
21
+ def self.destroy!(connection)
22
+ connection.exec <<~SQL
23
+ DROP TABLE IF EXISTS dp_active_operations;
24
+ DROP TABLE IF EXISTS dp_active_transactions;
25
+ SQL
26
+ end
27
+
28
+ def initialize(subject, transaction_id)
29
+ @tx_id = transaction_id
30
+ @subject_class = subject.class.to_s
31
+ @subject_id = subject.id.to_s
32
+ @root = "dirty-pipeline-rail:#{subject.class}:#{subject.id}:"
33
+ @queues = Hash[
34
+ DEFAULT_OPERATIONS.map do |operation|
35
+ [operation, create_queue(operation)]
36
+ end
37
+ ]
38
+ end
39
+
40
+ DELETE_OPERATION = <<~SQL
41
+ DELETE FROM dp_active_operations WHERE key = $1;
42
+ SQL
43
+ DELETE_TRANSACTION = <<~SQL
44
+ DELETE FROM dp_active_transactions WHERE key = $1;
45
+ SQL
46
+ def clear!
47
+ @queues.values.each(&:clear!)
48
+ DirtyPipeline.with_postgres do |c|
49
+ c.transaction do
50
+ c.exec DELETE_OPERATION, [active_operation_key]
51
+ c.exec DELETE_TRANSACTION, [active_transaction_key]
52
+ end
53
+ end
54
+ end
55
+
56
+ def next
57
+ return if other_transaction_in_progress?
58
+ start_transaction! unless running_transaction
59
+
60
+ queue.pop.tap { |event| finish_transaction! if event.nil? }
61
+ end
62
+
63
+ def queue(operation_name = active)
64
+ @queues.fetch(operation_name.to_s) do
65
+ @queues.store(operation_name, create_queue(operation_name))
66
+ end
67
+ end
68
+ alias :[] :queue
69
+
70
+ SWITCH_OPERATION = <<~SQL
71
+ INSERT INTO dp_active_operations (key, name) VALUES ($1, $2)
72
+ ON CONFLICT (key)
73
+ DO UPDATE SET name = EXCLUDED.name;
74
+ SQL
75
+ def switch_to(name)
76
+ raise ArgumentError unless DEFAULT_OPERATIONS.include?(name.to_s)
77
+ return if name.to_s == active
78
+
79
+ DirtyPipeline.with_postgres do |c|
80
+ c.transaction do
81
+ c.exec(SWITCH_OPERATION, [active_operation_key, name])
82
+ end
83
+ end
84
+ end
85
+
86
+ SELECT_OPERATION = <<~SQL
87
+ SELECT name FROM dp_active_operations WHERE key = $1;
88
+ SQL
89
+ def active
90
+ DirtyPipeline.with_postgres do |c|
91
+ PG.single c.exec(SELECT_OPERATION, [active_operation_key])
92
+ end
93
+ end
94
+ alias :operation :active
95
+
96
+ SELECT_TRANSACTION = <<~SQL
97
+ SELECT name FROM dp_active_transactions WHERE key = $1;
98
+ SQL
99
+ def running_transaction
100
+ DirtyPipeline.with_postgres do |c|
101
+ PG.single c.exec(SELECT_TRANSACTION, [active_transaction_key])
102
+ end
103
+ end
104
+
105
+ def other_transaction_in_progress?
106
+ return false if running_transaction.nil?
107
+ running_transaction != @tx_id
108
+ end
109
+
110
+ private
111
+
112
+ def create_queue(operation_name)
113
+ Queue.new(operation_name, @subject_class, @subject_id, @tx_id)
114
+ end
115
+
116
+ def active_transaction_key
117
+ "#{@root}:active_transaction"
118
+ end
119
+
120
+ def active_operation_key
121
+ "#{@root}:active_operation"
122
+ end
123
+
124
+ SWITCH_TRANSACTION = <<~SQL
125
+ INSERT INTO dp_active_transactions (key, name) VALUES ($1, $2)
126
+ ON CONFLICT (key)
127
+ DO UPDATE SET name = EXCLUDED.name;
128
+ SQL
129
+ def start_transaction!
130
+ switch_to(DEFAULT_OPERATIONS.first) unless active
131
+ DirtyPipeline.with_postgres do |c|
132
+ c.transaction do
133
+ c.exec(SWITCH_TRANSACTION, [active_transaction_key, @tx_id])
134
+ end
135
+ end
136
+ end
137
+
138
+ def finish_transaction!
139
+ clear! if running_transaction == @tx_id
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,112 @@
1
+ module DirtyPipeline
2
+ # Storage structure
3
+ # {
4
+ # status: :errored,
5
+ # state: {
6
+ # field: "value",
7
+ # }
8
+ # }
9
+ module PG
10
+ class Storage
11
+ class InvalidPipelineStorage < StandardError; end
12
+
13
+ def self.create!(connection)
14
+ connection.exec <<~SQL
15
+ CREATE TABLE dp_events_store (
16
+ uuid TEXT CONSTRAINT primary_active_operations_key PRIMARY KEY,
17
+ context TEXT NOT NULL,
18
+ data TEXT,
19
+ error TEXT,
20
+ created_at TIMESTAMP NOT NULL DEFAULT now()
21
+ );
22
+ SQL
23
+ end
24
+
25
+ def self.destroy!(connection)
26
+ connection.exec <<~SQL
27
+ DROP TABLE IF EXISTS dp_events_store;
28
+ SQL
29
+ end
30
+
31
+ attr_reader :subject, :field, :store, :subject_key
32
+ alias :to_h :store
33
+ def initialize(subject, field)
34
+ @subject = subject
35
+ @field = field
36
+ @store = subject.send(@field).to_h
37
+ reset if @store.empty?
38
+ @subject_key = "#{subject.class}:#{subject.id}"
39
+ raise InvalidPipelineStorage, store unless valid_store?
40
+ end
41
+
42
+ def reset!
43
+ reset
44
+ save!
45
+ end
46
+
47
+ def status
48
+ store["status"]
49
+ end
50
+
51
+ SAVE_EVENT = <<~SQL
52
+ INSERT INTO dp_events_store (uuid, context, data, error)
53
+ VALUES ($1, $2, $3, $4)
54
+ ON CONFLICT (uuid)
55
+ DO UPDATE SET data = EXCLUDED.data, error = EXCLUDED.error;
56
+ SQL
57
+ def commit!(event)
58
+ store["status"] = event.destination if event.destination
59
+ store["state"].merge!(event.changes) unless event.changes.to_h.empty?
60
+ DirtyPipeline.with_postgres do |c|
61
+ data, error = {}, {}
62
+ data = event.data.to_h if event.data.respond_to?(:to_h)
63
+ error = event.error.to_h if event.error.respond_to?(:to_h)
64
+ c.transaction do
65
+ c.exec(
66
+ SAVE_EVENT,
67
+ [event.id, subject_key, JSON.dump(data), JSON.dump(error)]
68
+ )
69
+ end
70
+ end
71
+ save!
72
+ end
73
+
74
+ FIND_EVENT = <<-SQL
75
+ SELECT data, error FROM dp_events_store
76
+ WHERE uuid = $1 AND context = $2;
77
+ SQL
78
+ def find_event(event_id)
79
+ DirtyPipeline.with_postgres do |c|
80
+ found_event, found_error =
81
+ PG.multi(c.exec(FIND_EVENT, [event_id, subject_key]))
82
+ return unless found_event
83
+ Event.new(
84
+ data: JSON.parse(found_event), error: JSON.parse(found_error)
85
+ )
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ def valid_store?
92
+ (store.keys & %w(status state)).size.eql?(2)
93
+ end
94
+
95
+ # FIXME: save! - configurable method
96
+ def save!
97
+ subject.send("#{field}=", store)
98
+ subject.save!
99
+ end
100
+
101
+ def reset
102
+ @store = subject.send(
103
+ "#{field}=",
104
+ {
105
+ "status" => nil,
106
+ "state" => {},
107
+ }
108
+ )
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,80 @@
1
+ module DirtyPipeline
2
+ module Redis
3
+ class Queue
4
+ def initialize(operation, subject_class, subject_id, transaction_id)
5
+ @root = "dirty-pipeline-queue:#{subject_class}:#{subject_id}:" \
6
+ "op_#{operation}:txid_#{transaction_id}"
7
+ end
8
+
9
+ def clear!
10
+ DirtyPipeline.with_redis do |r|
11
+ r.del active_event_key
12
+ r.del events_queue_key
13
+ end
14
+ end
15
+
16
+ def to_a
17
+ DirtyPipeline.with_redis do |r|
18
+ r.lrange(events_queue_key, 0, -1).map! do |packed_event|
19
+ unpack(packed_event)
20
+ end
21
+ end
22
+ end
23
+
24
+ def push(event)
25
+ DirtyPipeline.with_redis { |r| r.rpush(events_queue_key, pack(event)) }
26
+ self
27
+ end
28
+ alias :<< :push
29
+
30
+ def unshift(event)
31
+ DirtyPipeline.with_redis { |r| r.lpush(events_queue_key, pack(event)) }
32
+ self
33
+ end
34
+
35
+ def pop
36
+ DirtyPipeline.with_redis do |r|
37
+ data = r.lpop(events_queue_key)
38
+ data.nil? ? r.del(active_event_key) : r.set(active_event_key, data)
39
+ unpack(data)
40
+ end
41
+ end
42
+
43
+ def processing_event
44
+ DirtyPipeline.with_redis { |r| unpack(r.get(active_event_key)) }
45
+ end
46
+
47
+ private
48
+
49
+ def pack(event)
50
+ JSON.dump(
51
+ "evid" => event.id,
52
+ "txid" => event.tx_id,
53
+ "transit" => event.transition,
54
+ "args" => event.args,
55
+ )
56
+ end
57
+
58
+ def unpack(packed_event)
59
+ return unless packed_event
60
+ unpacked_event = JSON.load(packed_event)
61
+ Event.new(
62
+ data: {
63
+ "uuid" => unpacked_event["evid"],
64
+ "transaction_uuid" => unpacked_event["txid"],
65
+ "transition" => unpacked_event["transit"],
66
+ "args" => unpacked_event["args"],
67
+ }
68
+ )
69
+ end
70
+
71
+ def events_queue_key
72
+ "#{@root}:events"
73
+ end
74
+
75
+ def active_event_key
76
+ "#{@root}:active"
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,86 @@
1
+ module DirtyPipeline
2
+ module Redis
3
+ class Railway
4
+ DEFAULT_OPERATIONS = %w(call undo finalize)
5
+
6
+ def initialize(subject, transaction_id)
7
+ @tx_id = transaction_id
8
+ @subject_class = subject.class.to_s
9
+ @subject_id = subject.id.to_s
10
+ @root = "dirty-pipeline-rail:#{subject.class}:#{subject.id}:"
11
+ @queues = Hash[
12
+ DEFAULT_OPERATIONS.map do |operation|
13
+ [operation, create_queue(operation)]
14
+ end
15
+ ]
16
+ end
17
+
18
+ def clear!
19
+ @queues.values.each(&:clear!)
20
+ DirtyPipeline.with_redis do |r|
21
+ r.multi do |mr|
22
+ mr.del(active_operation_key)
23
+ mr.del(active_transaction_key)
24
+ end
25
+ end
26
+ end
27
+
28
+ def next
29
+ return if other_transaction_in_progress?
30
+ start_transaction! unless running_transaction
31
+
32
+ queue.pop.tap { |event| finish_transaction! if event.nil? }
33
+ end
34
+
35
+ def queue(operation_name = active)
36
+ @queues.fetch(operation_name.to_s) do
37
+ @queues.store(operation_name, create_queue(operation_name))
38
+ end
39
+ end
40
+ alias :[] :queue
41
+
42
+ def switch_to(name)
43
+ raise ArgumentError unless DEFAULT_OPERATIONS.include?(name.to_s)
44
+ return if name.to_s == active
45
+ DirtyPipeline.with_redis { |r| r.set(active_operation_key, name) }
46
+ end
47
+
48
+ def active
49
+ DirtyPipeline.with_redis { |r| r.get(active_operation_key) }
50
+ end
51
+ alias :operation :active
52
+
53
+ def running_transaction
54
+ DirtyPipeline.with_redis { |r| r.get(active_transaction_key) }
55
+ end
56
+
57
+ def other_transaction_in_progress?
58
+ return false if running_transaction.nil?
59
+ running_transaction != @tx_id
60
+ end
61
+
62
+ private
63
+
64
+ def create_queue(operation_name)
65
+ Queue.new(operation_name, @subject_class, @subject_id, @tx_id)
66
+ end
67
+
68
+ def active_transaction_key
69
+ "#{@root}:active_transaction"
70
+ end
71
+
72
+ def active_operation_key
73
+ "#{@root}:active_operation"
74
+ end
75
+
76
+ def start_transaction!
77
+ switch_to(DEFAULT_OPERATIONS.first) unless active
78
+ DirtyPipeline.with_redis { |r| r.set(active_transaction_key, @tx_id) }
79
+ end
80
+
81
+ def finish_transaction!
82
+ clear! if running_transaction == @tx_id
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,94 @@
1
+ module DirtyPipeline
2
+ # Storage structure
3
+ # {
4
+ # status: :errored,
5
+ # state: {
6
+ # field: "value",
7
+ # },
8
+ # errors: {
9
+ # "<event_id>": {
10
+ # error: "RuPost::API::Error",
11
+ # error_message: "Timeout error",
12
+ # created_at: 2018-01-01T13:22Z
13
+ # },
14
+ # },
15
+ # events: {
16
+ # <event_id>: {
17
+ # transition: "Create",
18
+ # args: ...,
19
+ # changes: ...,
20
+ # created_at: ...,
21
+ # updated_at: ...,
22
+ # attempts_count: 2,
23
+ # },
24
+ # <event_id>: {...},
25
+ # }
26
+ # }
27
+ module Redis
28
+ class Storage
29
+ class InvalidPipelineStorage < StandardError; end
30
+
31
+ attr_reader :subject, :field, :store
32
+ alias :to_h :store
33
+ def initialize(subject, field)
34
+ @subject = subject
35
+ @field = field
36
+ @store = subject.send(@field).to_h
37
+ reset if @store.empty?
38
+ raise InvalidPipelineStorage, store unless valid_store?
39
+ end
40
+
41
+ def reset!
42
+ reset
43
+ save!
44
+ end
45
+
46
+ def status
47
+ store["status"]
48
+ end
49
+
50
+ def commit!(event)
51
+ store["status"] = event.destination if event.destination
52
+ store["state"].merge!(event.changes) unless event.changes.to_h.empty?
53
+
54
+ error = {}
55
+ error = event.error.to_h unless event.error.to_h.empty?
56
+ store["errors"][event.id] = error
57
+
58
+ data = {}
59
+ data = event.data.to_h unless event.data.to_h.empty?
60
+ store["events"][event.id] = data
61
+ save!
62
+ end
63
+
64
+ def find_event(event_id)
65
+ return unless (found_event = store.dig("events", event_id))
66
+ Event.new(data: found_event, error: store.dig("errors", event_id))
67
+ end
68
+
69
+ private
70
+
71
+ def valid_store?
72
+ (store.keys & %w(status events errors state)).size.eql?(4)
73
+ end
74
+
75
+ # FIXME: save! - configurable method
76
+ def save!
77
+ subject.send("#{field}=", store)
78
+ subject.save!
79
+ end
80
+
81
+ def reset
82
+ @store = subject.send(
83
+ "#{field}=",
84
+ {
85
+ "status" => nil,
86
+ "state" => {},
87
+ "events" => {},
88
+ "errors" => {}
89
+ }
90
+ )
91
+ end
92
+ end
93
+ end
94
+ end
@@ -19,7 +19,8 @@ module DirtyPipeline
19
19
  storage.commit!(event)
20
20
 
21
21
  # FIXME: make configurable, now - hardcoded to AR API
22
- subject.transaction(requires_new: true) do
22
+ # subject.transaction(requires_new: true) do
23
+ subject.transaction do
23
24
  with_abort_handling { yield(destination, action, *event.args) }
24
25
  end
25
26
  rescue => exception
@@ -1,3 +1,3 @@
1
1
  module DirtyPipeline
2
- VERSION = "0.7.1"
2
+ VERSION = "0.8.1"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dirty_pipeline
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.1
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sergey Dolganov
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2018-10-08 00:00:00.000000000 Z
11
+ date: 2018-10-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: sidekiq
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: '4.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: pg
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.0'
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: bundler
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -144,10 +158,14 @@ files:
144
158
  - lib/dirty_pipeline/base.rb
145
159
  - lib/dirty_pipeline/event.rb
146
160
  - lib/dirty_pipeline/ext/camelcase.rb
147
- - lib/dirty_pipeline/queue.rb
148
- - lib/dirty_pipeline/railway.rb
161
+ - lib/dirty_pipeline/pg.rb
162
+ - lib/dirty_pipeline/pg/queue.rb
163
+ - lib/dirty_pipeline/pg/railway.rb
164
+ - lib/dirty_pipeline/pg/storage.rb
165
+ - lib/dirty_pipeline/redis/queue.rb
166
+ - lib/dirty_pipeline/redis/railway.rb
167
+ - lib/dirty_pipeline/redis/storage.rb
149
168
  - lib/dirty_pipeline/status.rb
150
- - lib/dirty_pipeline/storage.rb
151
169
  - lib/dirty_pipeline/transaction.rb
152
170
  - lib/dirty_pipeline/transition.rb
153
171
  - lib/dirty_pipeline/version.rb
@@ -1,78 +0,0 @@
1
- module DirtyPipeline
2
- class Queue
3
- def initialize(operation, subject_class, subject_id, transaction_id)
4
- @root = "dirty-pipeline-queue:#{subject_class}:#{subject_id}:" \
5
- "op_#{operation}:txid_#{transaction_id}"
6
- end
7
-
8
- def clear!
9
- DirtyPipeline.with_redis do |r|
10
- r.del active_event_key
11
- r.del events_queue_key
12
- end
13
- end
14
-
15
- def to_a
16
- DirtyPipeline.with_redis do |r|
17
- r.lrange(events_queue_key, 0, -1).map! do |packed_event|
18
- unpack(packed_event)
19
- end
20
- end
21
- end
22
-
23
- def push(event)
24
- DirtyPipeline.with_redis { |r| r.rpush(events_queue_key, pack(event)) }
25
- self
26
- end
27
- alias :<< :push
28
-
29
- def unshift(event)
30
- DirtyPipeline.with_redis { |r| r.lpush(events_queue_key, pack(event)) }
31
- self
32
- end
33
-
34
- def pop
35
- DirtyPipeline.with_redis do |r|
36
- data = r.lpop(events_queue_key)
37
- data.nil? ? r.del(active_event_key) : r.set(active_event_key, data)
38
- unpack(data)
39
- end
40
- end
41
-
42
- def processing_event
43
- DirtyPipeline.with_redis { |r| unpack(r.get(active_event_key)) }
44
- end
45
-
46
- private
47
-
48
- def pack(event)
49
- JSON.dump(
50
- "evid" => event.id,
51
- "txid" => event.tx_id,
52
- "transit" => event.transition,
53
- "args" => event.args,
54
- )
55
- end
56
-
57
- def unpack(packed_event)
58
- return unless packed_event
59
- unpacked_event = JSON.load(packed_event)
60
- Event.new(
61
- data: {
62
- "uuid" => unpacked_event["evid"],
63
- "transaction_uuid" => unpacked_event["txid"],
64
- "transition" => unpacked_event["transit"],
65
- "args" => unpacked_event["args"],
66
- }
67
- )
68
- end
69
-
70
- def events_queue_key
71
- "#{@root}:events"
72
- end
73
-
74
- def active_event_key
75
- "#{@root}:active"
76
- end
77
- end
78
- end
@@ -1,84 +0,0 @@
1
- module DirtyPipeline
2
- class Railway
3
- DEFAULT_OPERATIONS = %w(call undo finalize)
4
-
5
- def initialize(subject, transaction_id)
6
- @tx_id = transaction_id
7
- @subject_class = subject.class.to_s
8
- @subject_id = subject.id.to_s
9
- @root = "dirty-pipeline-rail:#{subject.class}:#{subject.id}:"
10
- @queues = Hash[
11
- DEFAULT_OPERATIONS.map do |operation|
12
- [operation, create_queue(operation)]
13
- end
14
- ]
15
- end
16
-
17
- def clear!
18
- @queues.values.each(&:clear!)
19
- DirtyPipeline.with_redis do |r|
20
- r.multi do |mr|
21
- mr.del(active_operation_key)
22
- mr.del(active_transaction_key)
23
- end
24
- end
25
- end
26
-
27
- def next
28
- return if other_transaction_in_progress?
29
- start_transaction! unless running_transaction
30
-
31
- queue.pop.tap { |event| finish_transaction! if event.nil? }
32
- end
33
-
34
- def queue(operation_name = active)
35
- @queues.fetch(operation_name.to_s) do
36
- @queues.store(operation_name, create_queue(operation_name))
37
- end
38
- end
39
- alias :[] :queue
40
-
41
- def switch_to(name)
42
- raise ArgumentError unless DEFAULT_OPERATIONS.include?(name.to_s)
43
- return if name.to_s == active
44
- DirtyPipeline.with_redis { |r| r.set(active_operation_key, name) }
45
- end
46
-
47
- def active
48
- DirtyPipeline.with_redis { |r| r.get(active_operation_key) }
49
- end
50
- alias :operation :active
51
-
52
- def running_transaction
53
- DirtyPipeline.with_redis { |r| r.get(active_transaction_key) }
54
- end
55
-
56
- def other_transaction_in_progress?
57
- return false if running_transaction.nil?
58
- running_transaction != @tx_id
59
- end
60
-
61
- private
62
-
63
- def create_queue(operation_name)
64
- Queue.new(operation_name, @subject_class, @subject_id, @tx_id)
65
- end
66
-
67
- def active_transaction_key
68
- "#{@root}:active_transaction"
69
- end
70
-
71
- def active_operation_key
72
- "#{@root}:active_operation"
73
- end
74
-
75
- def start_transaction!
76
- switch_to(DEFAULT_OPERATIONS.first) unless active
77
- DirtyPipeline.with_redis { |r| r.set(active_transaction_key, @tx_id) }
78
- end
79
-
80
- def finish_transaction!
81
- clear! if running_transaction == @tx_id
82
- end
83
- end
84
- end
@@ -1,86 +0,0 @@
1
- module DirtyPipeline
2
- # Storage structure
3
- # {
4
- # status: :errored,
5
- # state: {
6
- # field: "value",
7
- # },
8
- # errors: {
9
- # "<event_id>": {
10
- # error: "RuPost::API::Error",
11
- # error_message: "Timeout error",
12
- # created_at: 2018-01-01T13:22Z
13
- # },
14
- # },
15
- # events: {
16
- # <event_id>: {
17
- # transition: "Create",
18
- # args: ...,
19
- # changes: ...,
20
- # created_at: ...,
21
- # updated_at: ...,
22
- # attempts_count: 2,
23
- # },
24
- # <event_id>: {...},
25
- # }
26
- # }
27
- class Storage
28
- class InvalidPipelineStorage < StandardError; end
29
-
30
- attr_reader :subject, :field, :store
31
- alias :to_h :store
32
- def initialize(subject, field)
33
- @subject = subject
34
- @field = field
35
- @store = subject.send(@field).to_h
36
- reset if @store.empty?
37
- raise InvalidPipelineStorage, store unless valid_store?
38
- end
39
-
40
- def reset!
41
- reset
42
- save!
43
- end
44
-
45
- def status
46
- store["status"]
47
- end
48
-
49
- def commit!(event)
50
- store["status"] = event.destination if event.destination
51
- store["state"].merge!(event.changes) unless event.changes.to_h.empty?
52
- store["errors"][event.id] = event.error unless event.error.to_h.empty?
53
- store["events"][event.id] = event.data unless event.data.to_h.empty?
54
- save!
55
- end
56
-
57
- def find_event(event_id)
58
- return unless (found_event = store.dig("events", event_id))
59
- Event.new(data: found_event, error: store.dig("errors", event_id))
60
- end
61
-
62
- private
63
-
64
- def valid_store?
65
- (store.keys & %w(status events errors state)).size.eql?(4)
66
- end
67
-
68
- # FIXME: save! - configurable method
69
- def save!
70
- subject.send("#{field}=", store)
71
- subject.save!
72
- end
73
-
74
- def reset
75
- @store = subject.send(
76
- "#{field}=",
77
- {
78
- "status" => nil,
79
- "state" => {},
80
- "events" => {},
81
- "errors" => {}
82
- }
83
- )
84
- end
85
- end
86
- end