dirty_pipeline 0.1.0 → 0.2.0

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: 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: