dirty_pipeline 0.1.0 → 0.2.0

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: a3b3d599d44c9b6d8db7dee9fd91ebbebc77b8d25df0dd12c09f4e050a9f20af
4
- data.tar.gz: d0ad5ae7de3e9e6d5fdc295a9d039cfcb52c555787cfac7b1348a25eb4a1ca88
3
+ metadata.gz: c67b4adb4c6e578a8add63619a5836d1d9d820c1b4c07fd53f2dd1877f1851d0
4
+ data.tar.gz: 94d3c03c15d6e74079f3e64b985392f88b1c07247c394de1bf58e2feb1b94693
5
5
  SHA512:
6
- metadata.gz: d20cdfe6359c61edf2d6f5c0e0332f7697b25d6fedc4c3165b5051996ad63465379b6170bb4e97d495c1cb57e920517c8442989a53d3366593243028e0a3488e
7
- data.tar.gz: b08171b0290fe36d2c2dff8c5d9658be808f22e27280eabb5f35c79ff368cb4762fbc4b7e38686d06a0461dc51cd8b6bd1251eaa2921b3e42c30ea205b524831
6
+ metadata.gz: 52c62a8e38f6ab759bf7368b0f32e86008894b48e4cc4a4fd93643d84848c436fb8b2aa93d200faa29e98431bc8136805b45fa76ba4d76e72cd262450a5f16da
7
+ data.tar.gz: 0e83e12bdfb27820726ebd789b0d6226df9cdce1256d3a9cd5e4137656ef83ec8ccbaaf8cb6ccc12d4727c588bc5b8676760b3712df8bb601505301d92ba77e1
@@ -1,5 +1,13 @@
1
1
  require "dirty_pipeline/version"
2
2
 
3
3
  module DirtyPipeline
4
- # Your code goes here...
4
+ require_relative "dirty_pipeline/locker.rb"
5
+ require_relative "dirty_pipeline/storage.rb"
6
+ require_relative "dirty_pipeline/transition.rb"
7
+ require_relative "dirty_pipeline/base.rb"
8
+
9
+ # This method should yield raw Redis connection
10
+ def self.with_redis
11
+ fail NotImplementedError
12
+ end
5
13
  end
@@ -0,0 +1,272 @@
1
+ module DirtyPipeline
2
+ class Base
3
+ DEFAULT_RETRY_DELAY = 5 * 60 # 5 minutes
4
+ DEFAULT_CLEANUP_DELAY = 60 * 60 * 24 # 1 day
5
+ RESERVED_STATUSES = [
6
+ Storage::FAILED_STATUS,
7
+ Storage::PROCESSING_STATUS,
8
+ Storage::RETRY_STATUS,
9
+ Locker::CLEAN,
10
+ ]
11
+
12
+ class ReservedStatusError < StandardError; end
13
+ class InvalidTransition < StandardError; end
14
+
15
+ class << self
16
+ def find_subject(*args)
17
+ fail NotImplemented
18
+ end
19
+
20
+ attr_reader :transitions_map
21
+ def inherited(child)
22
+ child.instance_variable_set(:@transitions_map, Hash.new)
23
+ end
24
+ # PG JSONB column
25
+ # {
26
+ # status: :errored,
27
+ # state: {
28
+ # field: "value",
29
+ # },
30
+ # errors: [
31
+ # {
32
+ # error: "RuPost::API::Error",
33
+ # error_message: "Timeout error",
34
+ # created_at: 2018-01-01T13:22Z
35
+ # },
36
+ # ],
37
+ # events: [
38
+ # {
39
+ # action: Init,
40
+ # input: ...,
41
+ # created_at: ...,
42
+ # updated_at: ...,
43
+ # attempts_count: 2,
44
+ # },
45
+ # {...},
46
+ # ]
47
+ # }
48
+ attr_accessor :pipeline_storage, :retry_delay, :cleanup_delay
49
+
50
+ def transition(action, from:, to:, name: action.to_s, attempts: 1)
51
+ raise ReservedStatusError unless valid_statuses?(from, to)
52
+ @transitions_map[name] = {
53
+ action: action,
54
+ from: Array(from).map(&:to_s),
55
+ to: to.to_s,
56
+ attempts: attempts,
57
+ }
58
+ end
59
+
60
+ private
61
+
62
+ def valid_statuses?(from, to)
63
+ ((Array(to) + Array(from)) & RESERVED_STATUSES).empty?
64
+ end
65
+ end
66
+
67
+ def enqueue(transition_name, *args)
68
+ Shipping::PipelineWorker.perform_async(
69
+ "enqueued_pipeline" => self.class.to_s,
70
+ "find_subject_args" => find_subject_args,
71
+ "transition_args" => args.unshift(transition_name),
72
+ )
73
+ end
74
+
75
+ def reset!
76
+ storage.reset_pipeline_status!
77
+ end
78
+
79
+ attr_reader :subject, :error, :storage
80
+ def initialize(subject)
81
+ @subject = subject
82
+ @storage = Storage.new(subject, self.class.pipeline_storage)
83
+ @locker = Locker.new(@subject, @storage)
84
+ end
85
+
86
+ def call(*args)
87
+ return self if succeeded == false
88
+ self.succeeded = nil
89
+ after_commit = nil
90
+
91
+ # transaction with support of external calls
92
+ transaction(*args) do |destination, action, *transition_args|
93
+ output = {}
94
+ fail_cause = nil
95
+
96
+ output, *after_commit = catch(:success) do
97
+ fail_cause = catch(:fail_with_error) do
98
+ return Abort() if catch(:abort) do
99
+ throw :success, action.(subject, *transition_args)
100
+ end
101
+ end
102
+ nil
103
+ end
104
+
105
+ if fail_cause
106
+ ExpectedError(fail_cause)
107
+ else
108
+ Success(destination, output)
109
+ end
110
+ end
111
+
112
+ Array(after_commit).each { |cb| cb.call(subject) } if after_commit
113
+ self
114
+ end
115
+
116
+ def clear!
117
+ storage.clear!
118
+ end
119
+
120
+ def success?
121
+ succeeded
122
+ end
123
+
124
+ def when_success(callback = nil)
125
+ return self unless success?
126
+ if block_given?
127
+ yield(self)
128
+ else
129
+ callback.call(self)
130
+ end
131
+ self
132
+ end
133
+
134
+ def when_failed(callback = nil)
135
+ return self unless storage.failed?
136
+ if block_given?
137
+ yield(self)
138
+ else
139
+ callback.call(self)
140
+ end
141
+ self
142
+ end
143
+
144
+ def errored?
145
+ return if succeeded.nil?
146
+ ready? && !succeeded
147
+ end
148
+
149
+ def when_error(callback = nil)
150
+ return self unless errored?
151
+ if block_given?
152
+ yield(self)
153
+ else
154
+ callback.call(self)
155
+ end
156
+ self
157
+ end
158
+
159
+ def ready?
160
+ storage.pipeline_status.nil?
161
+ end
162
+
163
+ def when_processing(callback = nil)
164
+ return self unless storage.processing?
165
+ if block_given?
166
+ yield(self)
167
+ else
168
+ callback.call(self)
169
+ end
170
+ self
171
+ end
172
+
173
+ private
174
+
175
+ attr_writer :error
176
+ attr_reader :locker
177
+ attr_accessor :succeeded, :previous_status
178
+
179
+ def find_subject_args
180
+ subject.id
181
+ end
182
+
183
+ def retry_delay
184
+ self.class.retry_delay || DEFAULT_RETRY_DELAY
185
+ end
186
+
187
+ def cleanup_delay
188
+ self.class.cleanup_delay || DEFAULT_CLEANUP_DELAY
189
+ end
190
+
191
+ def Retry(error, *args)
192
+ storage.save_retry!(error)
193
+ Shipping::PipelineWorker.perform_in(
194
+ retry_delay,
195
+ "enqueued_pipeline" => self.class.to_s,
196
+ "find_subject_args" => find_subject_args,
197
+ "retry" => true,
198
+ )
199
+ end
200
+
201
+ def ExpectedError(cause)
202
+ self.error = cause
203
+ storage.fail_event!
204
+ self.succeeded = false
205
+ end
206
+
207
+ def Error(error)
208
+ storage.save_exception!(error)
209
+ self.error = error
210
+ self.succeeded = false
211
+ end
212
+
213
+ def Abort()
214
+ self.succeeded = false
215
+ throw :abort_transaction, true
216
+ end
217
+
218
+ def Success(destination, output)
219
+ storage.complete!(output, destination)
220
+ self.succeeded = true
221
+ end
222
+
223
+ def try_again?(max_attempts_count)
224
+ return unless max_attempts_count
225
+ storage.last_event["attempts_count"].to_i < max_attempts_count
226
+ end
227
+
228
+ def find_transition(name)
229
+ if (const_name = self.class.const_get(name) rescue nil)
230
+ name = const_name.to_s
231
+ end
232
+ self.class.transitions_map.fetch(name.to_s).tap do |from:, **kwargs|
233
+ next if from == Array(storage.status)
234
+ next if from.include?(storage.status.to_s)
235
+ raise InvalidTransition, "from `#{storage.status}` by `#{name}`"
236
+ end
237
+ end
238
+
239
+ def schedule_cleanup
240
+ Shipping::PipelineWorker.perform_in(
241
+ cleanup_delay,
242
+ "enqueued_pipeline" => self.class.to_s,
243
+ "find_subject_args" => find_subject_args,
244
+ "transition_args" => [Locker::CLEAN],
245
+ )
246
+ end
247
+
248
+ def transaction(*args)
249
+ locker.with_lock(*args) do |transition, *transition_args|
250
+ begin
251
+ schedule_cleanup
252
+ destination, action, max_attempts_count =
253
+ find_transition(transition).values_at(:to, :action, :attempts)
254
+
255
+ subject.transaction(requires_new: true) do
256
+ raise ActiveRecord::Rollback if catch(:abort_transaction) do
257
+ yield(destination, action, *transition_args); nil
258
+ end
259
+ end
260
+ rescue => error
261
+ if try_again?(max_attempts_count)
262
+ Retry(error)
263
+ else
264
+ # FIXME: Somehow :error is a Hash, all the time
265
+ Error(error)
266
+ end
267
+ raise
268
+ end
269
+ end
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,111 @@
1
+ module DirtyPipeline
2
+ class Locker
3
+ CLEAN = "clean".freeze
4
+
5
+ attr_reader :storage, :subject
6
+ def initialize(subject, storage)
7
+ @subject = subject
8
+ @storage = storage
9
+ end
10
+
11
+ class Normal
12
+ attr_reader :locker, :transition, :transition_args
13
+ def initialize(locker, args)
14
+ @locker = locker
15
+ @transition = args.shift
16
+ @transition_args = args
17
+ end
18
+
19
+ # NORMAL MODE
20
+ # if state is PROCESSING_STATE - finish
21
+ # if state is FAILED_STATE - finish
22
+ # otherwise - start
23
+ def skip_any_action?
24
+ [
25
+ Storage::PROCESSING_STATUS,
26
+ Storage::FAILED_STATUS,
27
+ ].include?(locker.storage.pipeline_status)
28
+ end
29
+
30
+ def start!
31
+ locker.storage.start!(transition, transition_args)
32
+ end
33
+
34
+ def lock!
35
+ return if skip_any_action?
36
+ start!
37
+ begin
38
+ yield(transition, *transition_args)
39
+ ensure
40
+ if locker.storage.pipeline_status == Storage::PROCESSING_STATUS
41
+ locker.storage.reset_pipeline_status!
42
+ end
43
+ end
44
+ rescue
45
+ if locker.storage.pipeline_status == Storage::PROCESSING_STATUS
46
+ locker.storage.commit_pipeline_status!(Storage::FAILED_STATUS)
47
+ end
48
+ raise
49
+ end
50
+ end
51
+
52
+ class Retry < Normal
53
+ def initialize(locker, _args)
54
+ @locker = locker
55
+ @transition = locker.storage.last_event["transition"]
56
+ @transition_args = locker.storage.last_event["input"]
57
+ end
58
+
59
+ # RETRY MODE
60
+ # if state is not RETRY_STATE - finish
61
+ # if state is RETRY_STATE - start
62
+ def skip_any_action?
63
+ storage.status != Storage::RETRY_STATUS
64
+ end
65
+
66
+ def start!
67
+ locker.storage.start_retry!
68
+ end
69
+ end
70
+
71
+ # run in time much more then time to process an item
72
+ class Clean < Retry
73
+ ONE_DAY = 60 * 60 * 24
74
+
75
+ def skip_any_action?
76
+ return true if storage.status != Storage::PROCESSING_STATUS
77
+ started_less_then_a_day_ago?
78
+ end
79
+
80
+ def started_less_then_a_day_ago?
81
+ return unless (updated_at = locker.storage.last_event["updated_at"])
82
+ updated_at > one_day_ago
83
+ end
84
+
85
+ def one_day_ago
86
+ Time.now - ONE_DAY
87
+ end
88
+ end
89
+
90
+ def with_lock(*args)
91
+ lock!(*args) do |transition, *transition_args|
92
+ yield(transition, *transition_args)
93
+ end
94
+ end
95
+
96
+ def lock!(*args)
97
+ locker_klass(*args).new(self, args).lock! { |*largs| yield(*largs) }
98
+ end
99
+
100
+ def locker_klass(transition, *)
101
+ case transition
102
+ when Storage::RETRY_STATUS
103
+ Retry
104
+ when CLEAN
105
+ Clean
106
+ else
107
+ Normal
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,193 @@
1
+ module DirtyPipeline
2
+ class Storage
3
+ FAILED_STATUS = "failed".freeze
4
+ RETRY_STATUS = "retry".freeze
5
+ PROCESSING_STATUS = "processing".freeze
6
+ class InvalidPipelineStorage < StandardError; end
7
+
8
+ attr_reader :subject, :field
9
+ attr_accessor :store
10
+ alias :to_h :store
11
+ def initialize(subject, field)
12
+ @subject = subject
13
+ @field = field
14
+ init_store(field)
15
+ end
16
+
17
+ def init_store(store_field)
18
+ self.store = subject.send(store_field).to_h
19
+ clear! if store.empty?
20
+ return if (store.keys & %w(status events errors state)).size == 4
21
+ raise InvalidPipelineStorage, store
22
+ end
23
+
24
+ def clear
25
+ self.store = subject.send(
26
+ "#{field}=",
27
+ "status" => nil,
28
+ "pipeline_status" => nil,
29
+ "state" => {},
30
+ "events" => [],
31
+ "errors" => [],
32
+ )
33
+ DirtyPipeline.with_redis { |r| r.del(pipeline_status_key) }
34
+ end
35
+
36
+ def clear!
37
+ clear
38
+ commit!
39
+ end
40
+
41
+ def start!(transition, args)
42
+ events << {
43
+ "transition" => transition,
44
+ "args" => args,
45
+ "created_at" => Time.now
46
+ }
47
+ increment_attempts_count
48
+ self.pipeline_status = PROCESSING_STATUS
49
+ # self.status = "processing", should be set by Locker
50
+ commit!
51
+ end
52
+
53
+ def start_retry!
54
+ last_event.merge!(updated_at: Time.now)
55
+ increment_attempts_count
56
+ self.pipeline_status = PROCESSING_STATUS
57
+ # self.status = "processing", should be set by Locker
58
+ commit!
59
+ end
60
+
61
+ def complete!(output, destination)
62
+ store["status"] = destination
63
+ state.merge!(output)
64
+ last_event.merge!(
65
+ "output" => output,
66
+ "updated_at" => Time.now,
67
+ "success" => true,
68
+ )
69
+ commit!
70
+ end
71
+
72
+ def fail_event!
73
+ fail_event
74
+ commit!
75
+ end
76
+
77
+ def fail_event
78
+ last_event["failed"] = true
79
+ end
80
+
81
+ def status
82
+ store["status"]
83
+ end
84
+
85
+ def pipeline_status_key
86
+ "pipeline-status:#{subject.class}:#{subject.id}:#{field}"
87
+ end
88
+
89
+ def pipeline_status=(value)
90
+ DirtyPipeline.with_redis do |r|
91
+ if value
92
+ r.set(pipeline_status_key, value)
93
+ else
94
+ r.del(pipeline_status_key)
95
+ end
96
+ end
97
+ store["pipeline_status"] = value
98
+ end
99
+
100
+ def commit_pipeline_status!(value = nil)
101
+ self.pipeline_status = value
102
+ commit!
103
+ end
104
+ alias :reset_pipeline_status! :commit_pipeline_status!
105
+
106
+ def pipeline_status
107
+ DirtyPipeline.with_redis do |r|
108
+ store["pipeline_status"] = r.get(pipeline_status_key)
109
+ end
110
+ store.fetch("pipeline_status")
111
+ end
112
+
113
+ def state
114
+ store.fetch("state")
115
+ end
116
+
117
+ def events
118
+ store.fetch("events")
119
+ end
120
+
121
+ def last_event
122
+ events.last.to_h
123
+ end
124
+
125
+ def last_event_error(event_idx = nil)
126
+ event = events[event_idx] if event_idx
127
+ event ||= last_event
128
+ errors[event["error_idx"]].to_h
129
+ end
130
+
131
+ def errors
132
+ store.fetch("errors")
133
+ end
134
+
135
+ def last_error
136
+ errors.last.to_h
137
+ end
138
+
139
+ def increment_attempts_count
140
+ last_event.merge!(
141
+ "attempts_count" => last_event["attempts_count"].to_i + 1
142
+ )
143
+ end
144
+
145
+ def increment_attempts_count!
146
+ increment_attempts_count
147
+ commit!
148
+ end
149
+
150
+ def save_retry(error)
151
+ save_error(error)
152
+ self.pipeline_status = RETRY_STATUS
153
+ end
154
+
155
+ def save_retry!(error)
156
+ save_retry(error)
157
+ commit!
158
+ end
159
+
160
+ def save_exception(exception)
161
+ errors << {
162
+ "error" => exception.class.to_s,
163
+ "error_message" => exception.message,
164
+ "created_at" => Time.current,
165
+ }
166
+ last_event["error_idx"] = errors.size - 1
167
+ fail_event
168
+ self.pipeline_status = FAILED_STATUS
169
+ end
170
+
171
+ def save_exception!(error)
172
+ save_exception(error)
173
+ commit!
174
+ end
175
+
176
+ def commit!
177
+ subject.assign_attributes(field => store)
178
+ subject.save!
179
+ end
180
+
181
+ def ready?
182
+ storage.pipeline_status.nil?
183
+ end
184
+
185
+ def failed?
186
+ pipeline_status == FAILED_STATUS
187
+ end
188
+
189
+ def processing?
190
+ [PROCESSING_STATUS, RETRY_STATUS].include?(pipeline_status)
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,25 @@
1
+ module DirtyPipeline
2
+ class Transition
3
+ def Abort()
4
+ throw :abort, true
5
+ end
6
+
7
+ def Error(error)
8
+ throw :fail_with_error, error
9
+ end
10
+
11
+ def Success(output = nil, after_commit: nil, &block)
12
+ result = [output.to_h]
13
+ after_commit = Array(after_commit) << block if block_given?
14
+ result += Array(after_commit) if after_commit
15
+ throw :success, result
16
+ end
17
+
18
+ def self.call(*args, **kwargs)
19
+ subject = args.shift
20
+ instance = new(*args, **kwargs)
21
+ instance.compensate(subject) if instance.respond_to?(:compensate)
22
+ instance.call(subject)
23
+ end
24
+ end
25
+ end
@@ -1,3 +1,3 @@
1
1
  module DirtyPipeline
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
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.1.0
4
+ version: 0.2.0
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-08-21 00:00:00.000000000 Z
11
+ date: 2018-08-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -71,6 +71,10 @@ files:
71
71
  - bin/setup
72
72
  - dirty_pipeline.gemspec
73
73
  - lib/dirty_pipeline.rb
74
+ - lib/dirty_pipeline/base.rb
75
+ - lib/dirty_pipeline/locker.rb
76
+ - lib/dirty_pipeline/storage.rb
77
+ - lib/dirty_pipeline/transition.rb
74
78
  - lib/dirty_pipeline/version.rb
75
79
  homepage: https://github.com/sclinede/dirty_pipeline
76
80
  licenses: