jobba 1.2.0 → 1.3.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
  SHA1:
3
- metadata.gz: dcfba40866333af0570df573302aaac92ef2c985
4
- data.tar.gz: 031ee23b4e6ef49a80c46f539e68b7772a621edc
3
+ metadata.gz: 28b15024f2860e2b7cc60d8a70d0f39f1bd9a112
4
+ data.tar.gz: e2498a809fd6583a2fa56077c78f2ed04b094a12
5
5
  SHA512:
6
- metadata.gz: ddd79547cd5913d6b7c10962150ebec677055ab979ad0890685d2362fd0123cd312595efeb27ad8a39618ef4cc256cc99498bccfa889f95a6f8452db42a62fc0
7
- data.tar.gz: e9442849d2a665b3b0253d5f1ada4ac74f61441e7976b3a40d058e758f72bef9c80c6fc467caea97c3fa590f1f3cf6d7f05f9c6f3188418bb8410dae2f0cbce5
6
+ metadata.gz: 2f49f2e17aa3b5cc43422aabe408a908afcd17d80973d9859ad6144e3949465fbb5a4ef2afca680ae2f0b4f8eb57478aeec296278810e8da5575764dfddf1ef8
7
+ data.tar.gz: 04c416209f2864a203e5517da500c8fa1258535757a56d1a17bac38debc4504e54f2888a36c06778741134e10d54d9eba0780ff15018e975235deb290d726888
data/README.md CHANGED
@@ -123,6 +123,14 @@ There is also a special timestamp for when a kill is requested, `kill_requested_
123
123
 
124
124
  The order of states is not enforced, and you do not have to use all states. However, note that you'll only be able to query for states you use (Jobba doesn't automatically travel through states you skip) and if you're using an unusual order your time-based queries will have to reflect that order.
125
125
 
126
+ ### Restarts
127
+
128
+ Generally-speaking, you should only enter any state once. Jobba only records the timestamp the first time you enter a state.
129
+
130
+ The expection to this rule is that if call `started!` a second time, Jobba will note this as a restart. The current values in the status will be archived and your status will look like a `started` status, with the exception that the `attempt` count will be incremented. A restarted status can then enter `succeeded`, `failed`, or `killed` states and those timestamps will be stored. `job_name` and `job_args` survive the restart.
131
+
132
+ The `attempt` field is zero-indexed, so the first attempt is attempt `0`.
133
+
126
134
  ## Mark Progress
127
135
 
128
136
  If you want to have a way to track the progress of a job, you can call:
@@ -137,7 +145,18 @@ This is useful if you need to show a progress bar on your client, for example.
137
145
 
138
146
  ## Recording Job Errors
139
147
 
140
- ...
148
+ The status can keep track of a list of errors. Errors can be anything, as long as they are JSON-friendly.
149
+
150
+ ```ruby
151
+ my_status.add_error("oh nooo!!")
152
+ my_status.add_error(msg: "oh nooo!!", data: 42)
153
+ ```
154
+
155
+ Errors are available from an `errors` attribute
156
+
157
+ ```ruby
158
+ my_status.errors # => ["oh nooo!!", {"msg" => "oh nooo!!", "data" => 42}]
159
+ ```
141
160
 
142
161
  ## Saving Job-specific Data
143
162
 
@@ -148,7 +167,9 @@ my_status.save({a: 'blah', b: [1,2,3]})
148
167
  my_status.save("some string")
149
168
  ```
150
169
 
151
- Note that if you `save` a hash with symbol keys it will be converted to string keys. In fact, any argument passed in to `save` will be converted to JSON and parsed back so that the `data` attribute returns the same thing regardless of if `data` is retrieved immediately after being set or after being loaded from Redis.
170
+ ## Normalization of Saved Data and Errors
171
+
172
+ Note that if you `save` or `add_error` contains a hash with symbol keys, those keys will be converted to strings. In fact, any argument passed in to these methods will be converted to JSON and parsed back again so that the `data` and `errors` attributes returns the same thing regardless of if they are retrieved immediately after being set or after being loaded from Redis.
152
173
 
153
174
  ## Setting Job Name and Arguments
154
175
 
@@ -158,16 +179,24 @@ If you want to be able to query for all statuses for a certain kind of job, you
158
179
  my_status.set_job_name("MySpecialJob")
159
180
  ```
160
181
 
161
- If you want to be able to query for all statuses that take a certain argument as input, you can add job arguments to a status:
182
+ If you want to be able to query for all statuses that take a certain argument as input, you can set job arguments on a status:
162
183
 
163
184
  ```ruby
164
- my_status.add_job_arg(arg_name, arg)
185
+ my_status.set_job_args(arg_1_name: arg_2, arg_2_name: arg_2)
165
186
  ```
166
187
 
167
- where `arg_name` is what the argument is called in your job (e.g. `"input_1"`) and `arg` is a way to identify the argument (e.g. `"gid://app/Person/72"`).
188
+ where the keys are what the argument is called in your job (e.g. `"input_1"`) and the values are a way to identify the argument (e.g. `"gid://app/Person/72"`). The values must currently be strings.
168
189
 
169
190
  You probably will only want to track complex arguments, e.g. models in your application. E.g. you could have a `Book` model and a `PublishBook` background job and you may want to see all of the `PublishBook` jobs that have status for the `Book` with ID `53`.
170
191
 
192
+ Note that you can set job args with names that are either symbols or strings, but you can only read the args back by the string form of their name, e.g.
193
+
194
+ ```ruby
195
+ my_status.set_job_args(foo: "bar")
196
+ my_status.job_args['foo'] # => "bar"
197
+ my_status.job_args[:foo] # => nil
198
+ ```
199
+
171
200
  ## Killing Jobs
172
201
 
173
202
  While Jobba can't really kill jobs (it doesn't control your job-running library), it has a facility for marking that you'd like a job to be killed.
@@ -201,7 +230,7 @@ When you get hold of a `Status`, via `create!`, `find`, `find!`, or as the resul
201
230
  | `id` | A Jobba-created UUID |
202
231
  | `state` | one of the states above |
203
232
  | `progress` | a float between 0.0 and 1.0 |
204
- | `errors` | TBD |
233
+ | `errors` | an array of errors |
205
234
  | `data` | job-specific data |
206
235
  | `job_name` | The name of the job |
207
236
  | `job_args` | An hash of job arguments, {arg_name: arg, ...} |
@@ -414,11 +443,25 @@ Note that, in operations having to do with time, this gem ignores anything beyon
414
443
 
415
444
  Jobba strives to do all of its operations as efficiently as possible using built-in Redis operations. If you find a place where the efficiency can be improved, please submit an issue or a pull request.
416
445
 
446
+ ### Write from one; Read from many
447
+
448
+ Jobba assumes that any job is being run at one time by only one worker. Jobba makes no accomodations for multiple processes updating a Status at the same time; multiple processes reading of a Status are fine of course.
449
+
450
+ ## Development
451
+
452
+ By default, this gem uses `fakeredis` instead of real Redis. This is great most of the time, but occassionally `fakeredis` doesn't work exactly like real Redis. If you want to use real Redis, just set the `USE_REAL_REDIS` environment variable to `true`, e.g.
453
+
454
+ ```
455
+ $> USE_REAL_REDIS=true rspec
456
+ ```
457
+
458
+ Travis runs the specs with both `fakeredis` and real Redis.
459
+
417
460
  ## TODO
418
461
 
419
462
  1. Provide job min, max, and average durations.
420
- 2. Implement `add_error`.
421
463
  8. Specs that test scale.
464
+ 9. Move redis code in `set_job_args`, `set_job_name`, and `save` into `set` to match rest of code.
422
465
 
423
466
 
424
467
 
@@ -24,8 +24,6 @@ class Jobba::ClauseFactory
24
24
  end
25
25
  end
26
26
 
27
- protected
28
-
29
27
  def self.timestamp_clause(timestamp_name, options)
30
28
  validate_timestamp_name!(timestamp_name)
31
29
 
@@ -95,7 +95,7 @@ class Jobba::Query
95
95
  load_default_clause if clauses.empty?
96
96
  working_set = nil
97
97
 
98
- clauses.each_with_index do |clause, ii|
98
+ clauses.each do |clause|
99
99
  clause_set = clause.to_new_set
100
100
 
101
101
  if working_set.nil?
@@ -5,6 +5,11 @@ class Jobba::State
5
5
  def initialize(name, timestamp_name)
6
6
  @name = name
7
7
  @timestamp_name = timestamp_name
8
+ @timestamp_name_key = timestamp_name.to_sym
9
+ end
10
+
11
+ def timestamp_name_key
12
+ @timestamp_name_key
8
13
  end
9
14
 
10
15
  def self.from_name(state_name)
@@ -51,6 +56,11 @@ class Jobba::State
51
56
  UNKNOWN
52
57
  ].freeze
53
58
 
59
+ ALL_TIMESTAMP_SYMBOLS = ALL.collect{|state| state.timestamp_name_key}.freeze
60
+
61
+ def to_json
62
+ name.to_json
63
+ end
54
64
  end
55
65
 
56
66
 
@@ -20,21 +20,21 @@ module Jobba
20
20
  # Finds the job with the specified ID and returns it. If no such ID
21
21
  # exists in the store, returns nil.
22
22
  def self.find(id)
23
- if (hash = raw_redis_hash(id))
24
- new(raw: hash)
25
- else
23
+ if (hash = raw_redis_hash(id)).empty?
26
24
  nil
25
+ else
26
+ new(raw: hash)
27
27
  end
28
28
  end
29
29
 
30
30
  def self.local_attrs
31
- %w(id state progress errors data kill_requested_at job_name job_args) +
31
+ %w(id state progress errors data kill_requested_at job_name job_args attempt) +
32
32
  State::ALL.collect(&:timestamp_name)
33
33
  end
34
34
 
35
35
  def reload!
36
36
  @json_encoded_attrs = self.class.raw_redis_hash(id)
37
- self.class.local_attrs.each{|aa| send("#{aa}=",nil)}
37
+ clear_attrs
38
38
  self
39
39
  end
40
40
 
@@ -56,11 +56,12 @@ module Jobba
56
56
 
57
57
  State::ENTERABLE.each do |state|
58
58
  define_method("#{state.name}!") do
59
- return self if state == self.state
60
-
61
59
  redis.multi do
62
- leave_current_state!
63
- enter_state!(state)
60
+ if state == State::STARTED && did_start?
61
+ restart!
62
+ elsif state != self.state
63
+ move_to_state!(state)
64
+ end
64
65
  end
65
66
 
66
67
  self
@@ -81,6 +82,10 @@ module Jobba
81
82
  !completed?
82
83
  end
83
84
 
85
+ def did_start?
86
+ !self.started_at.nil?
87
+ end
88
+
84
89
  def request_kill!
85
90
  time, usec_int = now
86
91
  if redis.hsetnx(job_key, :kill_requested_at, usec_int)
@@ -99,37 +104,40 @@ module Jobba
99
104
 
100
105
  def set_job_name(job_name)
101
106
  raise ArgumentError, "`job_name` must not be blank" if job_name.nil? || job_name.empty?
102
- raise StandardError, "`job_name` can only be set once" if !self.job_name.nil?
103
107
 
104
108
  redis.multi do
109
+ redis.srem(job_name_key, id)
105
110
  set(job_name: job_name)
106
111
  redis.sadd(job_name_key, id)
107
112
  end
108
113
  end
109
114
 
110
- def add_job_arg(arg_name, arg)
111
- raise ArgumentError, "`arg_name` must not be blank" if arg_name.nil? || arg_name.empty?
112
- raise ArgumentError, "`arg` must not be blank" if arg.nil? || arg.empty?
115
+ def set_job_args(args_hash={})
116
+ raise ArgumentError, "All values in the hash passed to `set_job_args` must be strings" \
117
+ if args_hash.values.any?{|val| !val.is_a?(String)}
118
+
119
+ args_hash = normalize_for_json(args_hash)
113
120
 
114
121
  redis.multi do
115
- self.job_args[arg_name.to_sym] = arg
116
- redis.hset(job_args_key, arg_name, arg)
117
- redis.sadd(job_arg_key(arg), id)
122
+ delete_self_from_job_args_set!
123
+ set(job_args: args_hash)
124
+ add_self_to_job_args_set!
118
125
  end
119
126
  end
120
127
 
121
- # def add_error(error, options = { })
122
- # options = { is_fatal: false }.merge(options)
123
- # @errors << { is_fatal: options[:is_fatal],
124
- # code: error.code,
125
- # message: error.message,
126
- # data: error.data }
127
- # set(errors: @errors)
128
- # end
128
+ def add_error(error)
129
+ raise ArgumentError, "The argument to `add_error` cannot be nil" if error.nil?
130
+
131
+ errors.push(normalize_for_json(error))
132
+ set(errors: errors)
133
+ end
129
134
 
130
135
  def save(data)
131
- normalized_data = JSON.parse(data.to_json, quirks_mode: true)
132
- set(data: normalized_data)
136
+ set(data: normalize_for_json(data))
137
+ end
138
+
139
+ def normalize_for_json(input)
140
+ JSON.parse(input.to_json, quirks_mode: true)
133
141
  end
134
142
 
135
143
  def delete
@@ -141,21 +149,12 @@ module Jobba
141
149
  end
142
150
 
143
151
  def delete!
144
- redis.multi do
145
- redis.del(job_key)
146
-
147
- State::ALL.each do |state|
148
- redis.srem(state.name, id)
149
- redis.zrem(state.timestamp_name, id)
150
- end
151
-
152
- redis.srem(job_name_key, id)
152
+ delete_in_redis!
153
+ delete_locally!
154
+ end
153
155
 
154
- redis.del(job_args_key)
155
- job_args.marshal_dump.values.each do |arg|
156
- redis.srem(job_arg_key(arg), id)
157
- end
158
- end
156
+ def prior_attempts
157
+ [*0..attempt-1].collect{|ii| self.class.find!("#{id}:#{ii}")}
159
158
  end
160
159
 
161
160
  protected
@@ -165,28 +164,40 @@ module Jobba
165
164
  end
166
165
 
167
166
  def self.raw_redis_hash(id)
168
- main_hash, job_args_hash = redis.multi do
169
- redis.hgetall(job_key(id))
170
- redis.hgetall(job_args_key(id))
171
- end
167
+ redis.hgetall(job_key(id))
168
+ end
169
+
170
+ def restart!
171
+ # Identify the values we want the restarted status to have, archive
172
+ # the attempt (clears out redis and local for this status), then
173
+ # set the restarted values
174
+
175
+ restarted_values = {
176
+ id: id,
177
+ attempt: attempt+1,
178
+ recorded_at: recorded_at,
179
+ queued_at: queued_at,
180
+ progress: 0,
181
+ errors: [],
182
+ job_name: job_name,
183
+ job_args: job_args
184
+ }
172
185
 
173
- return nil if main_hash.empty?
186
+ archive_attempt!
174
187
 
175
- main_hash['job_args'] = job_args_hash.to_json if !job_args_hash.nil?
176
- main_hash
188
+ set(restarted_values)
189
+ move_to_state!(State::STARTED)
177
190
  end
178
191
 
179
- def leave_current_state!
180
- redis.srem(state.name, id)
192
+ def archive_attempt!
193
+ archived_job_key = job_key(attempt)
194
+ redis.rename(job_key, archived_job_key)
195
+ redis.hset(archived_job_key, :id, "#{id}:#{attempt}".to_json)
196
+ delete_locally!
181
197
  end
182
198
 
183
- def enter_state!(state)
184
- time, usec_int = now
185
- set(state: state.name, state.timestamp_name => usec_int)
186
- self.state = state
187
- self.send("#{state.timestamp_name}=",time)
188
- redis.zadd(state.timestamp_name, usec_int, id)
189
- redis.sadd(state.name, id)
199
+ def move_to_state!(state)
200
+ set(state: state, state.timestamp_name_key => Jobba::Time.now)
190
201
  end
191
202
 
192
203
  def initialize(attrs = {})
@@ -195,20 +206,25 @@ module Jobba
195
206
  @json_encoded_attrs = attrs[:raw]
196
207
 
197
208
  if @json_encoded_attrs.nil? || @json_encoded_attrs.empty?
198
- @id = attrs[:id] || attrs['id'] || SecureRandom.uuid
199
- @state = attrs[:state] || attrs['state'] || State::UNKNOWN
200
- @progress = attrs[:progress] || attrs['progress'] || 0
201
- @errors = attrs[:errors] || attrs['errors'] || []
202
- @data = attrs[:data] || attrs['data'] || {}
209
+
210
+ @id = attrs[:id] || SecureRandom.uuid
211
+ @state = attrs[:state] || State::UNKNOWN
212
+ @progress = attrs[:progress] || 0
213
+ @errors = attrs[:errors] || []
214
+ @data = attrs[:data] || {}
215
+ @attempt = attrs[:attempt] || 0
216
+ @job_args = attrs[:job_args] || {}
217
+ @job_name = attrs[:job_name]
203
218
 
204
219
  if attrs[:persist]
205
220
  redis.multi do
206
221
  set({
207
222
  id: id,
208
223
  progress: progress,
209
- errors: errors
224
+ errors: errors,
225
+ attempt: attempt
210
226
  })
211
- enter_state!(state)
227
+ move_to_state!(state)
212
228
  end
213
229
  end
214
230
  end
@@ -224,59 +240,122 @@ module Jobba
224
240
  when /.*_at/
225
241
  attribute.nil? ? nil : Jobba::Utils.time_from_usec_int(attribute.to_i)
226
242
  when 'job_args'
227
- OpenStruct.new(attribute)
243
+ attribute || {}
228
244
  else
229
245
  attribute
230
246
  end
231
247
  end
232
248
 
233
249
  def set(incoming_hash)
250
+ # in case the ID isn't set but is in the hash, set locally so other
251
+ # commands can use it
252
+ self.id = incoming_hash[:id] || id
253
+
234
254
  apply_consistency_rules!(incoming_hash)
235
- set_hash_locally(incoming_hash)
255
+
236
256
  set_hash_in_redis(incoming_hash)
257
+ set_state_in_redis(incoming_hash)
258
+ set_state_timestamps_in_redis(incoming_hash)
259
+
260
+ set_hash_locally(incoming_hash)
237
261
  end
238
262
 
239
263
  def apply_consistency_rules!(hash)
240
264
  hash[:progress] = 1.0 if hash[:state] == State::SUCCEEDED
241
265
  end
242
266
 
267
+ def set_hash_in_redis(hash)
268
+ redis_key_value_array =
269
+ hash.to_a.flat_map do |kv_array|
270
+ key = kv_array[0]
271
+ value = kv_array[1]
272
+ value = Jobba::Utils.time_to_usec_int(value) if value.is_a?(::Time)
273
+
274
+ [key, value.to_json]
275
+ end
276
+
277
+ Jobba.redis.hmset(job_key, *redis_key_value_array)
278
+ end
279
+
280
+ def set_state_in_redis(hash)
281
+ return unless hash[:state]
282
+ redis.srem(state.name, id) unless state.nil? # leave old state if set
283
+ redis.sadd(hash[:state].name, id) # enter new state
284
+ end
285
+
286
+ def set_state_timestamps_in_redis(hash)
287
+ timestamp_names = hash.keys & State::ALL_TIMESTAMP_SYMBOLS
288
+
289
+ timestamp_names.each do |timestamp_name|
290
+ usec_int = Utils.time_to_usec_int(hash[timestamp_name])
291
+ redis.zadd(timestamp_name, usec_int, id)
292
+ end
293
+ end
294
+
243
295
  def set_hash_locally(hash)
244
296
  hash.each{ |key, value| self.send("#{key}=", value) }
245
297
  end
246
298
 
247
- def set_hash_in_redis(hash)
248
- redis_key_value_array =
249
- hash.to_a.flat_map{|kv_array| [kv_array[0], kv_array[1].to_json]}
299
+ def delete_in_redis!
300
+ redis.multi do
301
+ redis.del(job_key)
250
302
 
251
- Jobba.redis.hmset(job_key, *redis_key_value_array)
303
+ State::ALL.each do |state|
304
+ redis.srem(state.name, id)
305
+ redis.zrem(state.timestamp_name, id)
306
+ end
307
+
308
+ redis.srem(job_name_key, id)
309
+
310
+ delete_self_from_job_args_set!
311
+ end
312
+
313
+ prior_attempts.each(&:delete!)
252
314
  end
253
315
 
254
- def job_name_key
255
- "job_name:#{job_name}"
316
+ def delete_locally!
317
+ clear_attrs
318
+ @json_encoded_attrs = nil
256
319
  end
257
320
 
258
- def job_key
259
- self.class.job_key(id)
321
+ def delete_self_from_job_args_set!
322
+ self.job_args.values.each do |arg|
323
+ redis.srem(job_arg_key(arg), id)
324
+ end
260
325
  end
261
326
 
262
- def self.job_key(id)
263
- raise(ArgumentError, "`id` cannot be nil") if id.nil?
264
- "id:#{id}"
327
+ def add_self_to_job_args_set!
328
+ self.job_args.values.each do |arg|
329
+ redis.sadd(job_arg_key(arg), id)
330
+ end
265
331
  end
266
332
 
267
- def job_args_key
268
- self.class.job_args_key(id)
333
+ def clear_attrs
334
+ self.class.local_attrs.each{|aa| send("#{aa}=",nil)}
335
+ end
336
+
337
+ def job_name_key
338
+ "job_name:#{job_name}"
269
339
  end
270
340
 
271
- def self.job_args_key(id)
341
+ def job_key(attempt=nil)
342
+ self.class.job_key(id, attempt)
343
+ end
344
+
345
+ def self.job_key(id, attempt=nil)
272
346
  raise(ArgumentError, "`id` cannot be nil") if id.nil?
273
- "job_args:#{id}"
347
+ attempt.nil? ? "id:#{id}" : "id:#{id}:#{attempt}"
274
348
  end
275
349
 
276
350
  def job_arg_key(arg)
277
351
  "job_arg:#{arg}"
278
352
  end
279
353
 
354
+ def job_errors_key(id)
355
+ raise(ArgumentError, "`id` cannot be nil") if id.nil?
356
+ "#{id}:errors"
357
+ end
358
+
280
359
  def compute_fractional_progress(at, out_of)
281
360
  if at.nil?
282
361
  raise ArgumentError, "Must specify at least `at` argument to `progress` call"
@@ -1,3 +1,3 @@
1
1
  module Jobba
2
- VERSION = "1.2.0"
2
+ VERSION = "1.3.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jobba
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - JP Slavinsky
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2016-01-03 00:00:00.000000000 Z
11
+ date: 2016-01-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: redis