launchdarkly-server-sdk 5.8.2 → 6.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +28 -122
  3. data/.ldrelease/circleci/linux/execute.sh +18 -0
  4. data/.ldrelease/circleci/mac/execute.sh +18 -0
  5. data/.ldrelease/circleci/template/build.sh +29 -0
  6. data/.ldrelease/circleci/template/publish.sh +23 -0
  7. data/.ldrelease/circleci/template/set-gem-home.sh +7 -0
  8. data/.ldrelease/circleci/template/test.sh +10 -0
  9. data/.ldrelease/circleci/template/update-version.sh +8 -0
  10. data/.ldrelease/circleci/windows/execute.ps1 +19 -0
  11. data/.ldrelease/config.yml +7 -3
  12. data/CHANGELOG.md +9 -0
  13. data/CONTRIBUTING.md +1 -1
  14. data/Gemfile.lock +69 -42
  15. data/README.md +2 -2
  16. data/azure-pipelines.yml +1 -1
  17. data/launchdarkly-server-sdk.gemspec +16 -16
  18. data/lib/ldclient-rb.rb +0 -1
  19. data/lib/ldclient-rb/config.rb +15 -3
  20. data/lib/ldclient-rb/evaluation_detail.rb +293 -0
  21. data/lib/ldclient-rb/events.rb +1 -4
  22. data/lib/ldclient-rb/file_data_source.rb +1 -1
  23. data/lib/ldclient-rb/impl/evaluator.rb +225 -0
  24. data/lib/ldclient-rb/impl/evaluator_bucketing.rb +74 -0
  25. data/lib/ldclient-rb/impl/evaluator_operators.rb +160 -0
  26. data/lib/ldclient-rb/impl/event_sender.rb +56 -40
  27. data/lib/ldclient-rb/impl/integrations/consul_impl.rb +5 -5
  28. data/lib/ldclient-rb/impl/integrations/dynamodb_impl.rb +5 -5
  29. data/lib/ldclient-rb/impl/integrations/redis_impl.rb +5 -9
  30. data/lib/ldclient-rb/impl/model/serialization.rb +62 -0
  31. data/lib/ldclient-rb/impl/unbounded_pool.rb +34 -0
  32. data/lib/ldclient-rb/ldclient.rb +14 -9
  33. data/lib/ldclient-rb/polling.rb +1 -4
  34. data/lib/ldclient-rb/requestor.rb +25 -15
  35. data/lib/ldclient-rb/stream.rb +9 -6
  36. data/lib/ldclient-rb/util.rb +12 -8
  37. data/lib/ldclient-rb/version.rb +1 -1
  38. data/spec/evaluation_detail_spec.rb +135 -0
  39. data/spec/event_sender_spec.rb +20 -2
  40. data/spec/http_util.rb +11 -1
  41. data/spec/impl/evaluator_bucketing_spec.rb +111 -0
  42. data/spec/impl/evaluator_clause_spec.rb +55 -0
  43. data/spec/impl/evaluator_operators_spec.rb +141 -0
  44. data/spec/impl/evaluator_rule_spec.rb +96 -0
  45. data/spec/impl/evaluator_segment_spec.rb +125 -0
  46. data/spec/impl/evaluator_spec.rb +305 -0
  47. data/spec/impl/evaluator_spec_base.rb +75 -0
  48. data/spec/impl/model/serialization_spec.rb +41 -0
  49. data/spec/launchdarkly-server-sdk_spec.rb +1 -1
  50. data/spec/ldclient_end_to_end_spec.rb +34 -0
  51. data/spec/ldclient_spec.rb +10 -8
  52. data/spec/polling_spec.rb +2 -2
  53. data/spec/redis_feature_store_spec.rb +2 -2
  54. data/spec/requestor_spec.rb +11 -11
  55. metadata +89 -46
  56. data/lib/ldclient-rb/evaluation.rb +0 -462
  57. data/spec/evaluation_spec.rb +0 -789
@@ -0,0 +1,74 @@
1
+
2
+ module LaunchDarkly
3
+ module Impl
4
+ # Encapsulates the logic for percentage rollouts.
5
+ module EvaluatorBucketing
6
+ # Applies either a fixed variation or a rollout for a rule (or the fallthrough rule).
7
+ #
8
+ # @param flag [Object] the feature flag
9
+ # @param rule [Object] the rule
10
+ # @param user [Object] the user properties
11
+ # @return [Number] the variation index, or nil if there is an error
12
+ def self.variation_index_for_user(flag, rule, user)
13
+ variation = rule[:variation]
14
+ return variation if !variation.nil? # fixed variation
15
+ rollout = rule[:rollout]
16
+ return nil if rollout.nil?
17
+ variations = rollout[:variations]
18
+ if !variations.nil? && variations.length > 0 # percentage rollout
19
+ rollout = rule[:rollout]
20
+ bucket_by = rollout[:bucketBy].nil? ? "key" : rollout[:bucketBy]
21
+ bucket = bucket_user(user, flag[:key], bucket_by, flag[:salt])
22
+ sum = 0;
23
+ variations.each do |variate|
24
+ sum += variate[:weight].to_f / 100000.0
25
+ if bucket < sum
26
+ return variate[:variation]
27
+ end
28
+ end
29
+ # The user's bucket value was greater than or equal to the end of the last bucket. This could happen due
30
+ # to a rounding error, or due to the fact that we are scaling to 100000 rather than 99999, or the flag
31
+ # data could contain buckets that don't actually add up to 100000. Rather than returning an error in
32
+ # this case (or changing the scaling, which would potentially change the results for *all* users), we
33
+ # will simply put the user in the last bucket.
34
+ variations[-1][:variation]
35
+ else # the rule isn't well-formed
36
+ nil
37
+ end
38
+ end
39
+
40
+ # Returns a user's bucket value as a floating-point value in `[0, 1)`.
41
+ #
42
+ # @param user [Object] the user properties
43
+ # @param key [String] the feature flag key (or segment key, if this is for a segment rule)
44
+ # @param bucket_by [String|Symbol] the name of the user attribute to be used for bucketing
45
+ # @param salt [String] the feature flag's or segment's salt value
46
+ # @return [Number] the bucket value, from 0 inclusive to 1 exclusive
47
+ def self.bucket_user(user, key, bucket_by, salt)
48
+ return nil unless user[:key]
49
+
50
+ id_hash = bucketable_string_value(EvaluatorOperators.user_value(user, bucket_by))
51
+ if id_hash.nil?
52
+ return 0.0
53
+ end
54
+
55
+ if user[:secondary]
56
+ id_hash += "." + user[:secondary].to_s
57
+ end
58
+
59
+ hash_key = "%s.%s.%s" % [key, salt, id_hash]
60
+
61
+ hash_val = (Digest::SHA1.hexdigest(hash_key))[0..14]
62
+ hash_val.to_i(16) / Float(0xFFFFFFFFFFFFFFF)
63
+ end
64
+
65
+ private
66
+
67
+ def self.bucketable_string_value(value)
68
+ return value if value.is_a? String
69
+ return value.to_s if value.is_a? Integer
70
+ nil
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,160 @@
1
+ require "date"
2
+ require "semantic"
3
+ require "set"
4
+
5
+ module LaunchDarkly
6
+ module Impl
7
+ # Defines the behavior of all operators that can be used in feature flag rules and segment rules.
8
+ module EvaluatorOperators
9
+ # Applies an operator to produce a boolean result.
10
+ #
11
+ # @param op [Symbol] one of the supported LaunchDarkly operators, as a symbol
12
+ # @param user_value the value of the user attribute that is referenced in the current clause (left-hand
13
+ # side of the expression)
14
+ # @param clause_value the constant value that `user_value` is being compared to (right-hand side of the
15
+ # expression)
16
+ # @return [Boolean] true if the expression should be considered a match; false if it is not a match, or
17
+ # if the values cannot be compared because they are of the wrong types, or if the operator is unknown
18
+ def self.apply(op, user_value, clause_value)
19
+ case op
20
+ when :in
21
+ user_value == clause_value
22
+ when :startsWith
23
+ string_op(user_value, clause_value, lambda { |a, b| a.start_with? b })
24
+ when :endsWith
25
+ string_op(user_value, clause_value, lambda { |a, b| a.end_with? b })
26
+ when :contains
27
+ string_op(user_value, clause_value, lambda { |a, b| a.include? b })
28
+ when :matches
29
+ string_op(user_value, clause_value, lambda { |a, b|
30
+ begin
31
+ re = Regexp.new b
32
+ !re.match(a).nil?
33
+ rescue
34
+ false
35
+ end
36
+ })
37
+ when :lessThan
38
+ numeric_op(user_value, clause_value, lambda { |a, b| a < b })
39
+ when :lessThanOrEqual
40
+ numeric_op(user_value, clause_value, lambda { |a, b| a <= b })
41
+ when :greaterThan
42
+ numeric_op(user_value, clause_value, lambda { |a, b| a > b })
43
+ when :greaterThanOrEqual
44
+ numeric_op(user_value, clause_value, lambda { |a, b| a >= b })
45
+ when :before
46
+ date_op(user_value, clause_value, lambda { |a, b| a < b })
47
+ when :after
48
+ date_op(user_value, clause_value, lambda { |a, b| a > b })
49
+ when :semVerEqual
50
+ semver_op(user_value, clause_value, lambda { |a, b| a == b })
51
+ when :semVerLessThan
52
+ semver_op(user_value, clause_value, lambda { |a, b| a < b })
53
+ when :semVerGreaterThan
54
+ semver_op(user_value, clause_value, lambda { |a, b| a > b })
55
+ when :segmentMatch
56
+ # We should never reach this; it can't be evaluated based on just two parameters, because it requires
57
+ # looking up the segment from the data store. Instead, we special-case this operator in clause_match_user.
58
+ false
59
+ else
60
+ false
61
+ end
62
+ end
63
+
64
+ # Retrieves the value of a user attribute by name.
65
+ #
66
+ # Built-in attributes correspond to top-level properties in the user object. They are treated as strings and
67
+ # non-string values are coerced to strings, except for `anonymous` which is meant to be a boolean if present
68
+ # and is not currently coerced. This behavior is consistent with earlier versions of the Ruby SDK, but is not
69
+ # guaranteed to be consistent with other SDKs, since the evaluator specification is based on the strongly-typed
70
+ # SDKs where it is not possible for an attribute to have the wrong type.
71
+ #
72
+ # Custom attributes correspond to properties within the `custom` property, if any, and can be of any type.
73
+ #
74
+ # @param user [Object] the user properties
75
+ # @param attribute [String|Symbol] the attribute to get, for instance `:key` or `:name` or `:some_custom_attr`
76
+ # @return the attribute value, or nil if the attribute is unknown
77
+ def self.user_value(user, attribute)
78
+ attribute = attribute.to_sym
79
+ if BUILTINS.include? attribute
80
+ value = user[attribute]
81
+ return nil if value.nil?
82
+ (attribute == :anonymous) ? value : value.to_s
83
+ elsif !user[:custom].nil?
84
+ user[:custom][attribute]
85
+ else
86
+ nil
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ BUILTINS = Set[:key, :ip, :country, :email, :firstName, :lastName, :avatar, :name, :anonymous]
93
+ NUMERIC_VERSION_COMPONENTS_REGEX = Regexp.new("^[0-9.]*")
94
+
95
+ private_constant :BUILTINS
96
+ private_constant :NUMERIC_VERSION_COMPONENTS_REGEX
97
+
98
+ def self.string_op(user_value, clause_value, fn)
99
+ (user_value.is_a? String) && (clause_value.is_a? String) && fn.call(user_value, clause_value)
100
+ end
101
+
102
+ def self.numeric_op(user_value, clause_value, fn)
103
+ (user_value.is_a? Numeric) && (clause_value.is_a? Numeric) && fn.call(user_value, clause_value)
104
+ end
105
+
106
+ def self.date_op(user_value, clause_value, fn)
107
+ ud = to_date(user_value)
108
+ if !ud.nil?
109
+ cd = to_date(clause_value)
110
+ !cd.nil? && fn.call(ud, cd)
111
+ else
112
+ false
113
+ end
114
+ end
115
+
116
+ def self.semver_op(user_value, clause_value, fn)
117
+ uv = to_semver(user_value)
118
+ if !uv.nil?
119
+ cv = to_semver(clause_value)
120
+ !cv.nil? && fn.call(uv, cv)
121
+ else
122
+ false
123
+ end
124
+ end
125
+
126
+ def self.to_date(value)
127
+ if value.is_a? String
128
+ begin
129
+ DateTime.rfc3339(value).strftime("%Q").to_i
130
+ rescue => e
131
+ nil
132
+ end
133
+ elsif value.is_a? Numeric
134
+ value
135
+ else
136
+ nil
137
+ end
138
+ end
139
+
140
+ def self.to_semver(value)
141
+ if value.is_a? String
142
+ for _ in 0..2 do
143
+ begin
144
+ return Semantic::Version.new(value)
145
+ rescue ArgumentError
146
+ value = add_zero_version_component(value)
147
+ end
148
+ end
149
+ end
150
+ nil
151
+ end
152
+
153
+ def self.add_zero_version_component(v)
154
+ NUMERIC_VERSION_COMPONENTS_REGEX.match(v) { |m|
155
+ m[0] + ".0" + v[m[0].length..-1]
156
+ }
157
+ end
158
+ end
159
+ end
160
+ end
@@ -1,4 +1,7 @@
1
+ require "ldclient-rb/impl/unbounded_pool"
2
+
1
3
  require "securerandom"
4
+ require "http"
2
5
 
3
6
  module LaunchDarkly
4
7
  module Impl
@@ -9,62 +12,75 @@ module LaunchDarkly
9
12
  DEFAULT_RETRY_INTERVAL = 1
10
13
 
11
14
  def initialize(sdk_key, config, http_client = nil, retry_interval = DEFAULT_RETRY_INTERVAL)
12
- @client = http_client ? http_client : LaunchDarkly::Util.new_http_client(config.events_uri, config)
13
15
  @sdk_key = sdk_key
14
16
  @config = config
15
17
  @events_uri = config.events_uri + "/bulk"
16
18
  @diagnostic_uri = config.events_uri + "/diagnostic"
17
19
  @logger = config.logger
18
20
  @retry_interval = retry_interval
21
+ @http_client_pool = UnboundedPool.new(
22
+ lambda { LaunchDarkly::Util.new_http_client(@config.events_uri, @config) },
23
+ lambda { |client| client.close })
24
+ end
25
+
26
+ def stop
27
+ @http_client_pool.dispose_all()
19
28
  end
20
29
 
21
30
  def send_event_data(event_data, description, is_diagnostic)
22
31
  uri = is_diagnostic ? @diagnostic_uri : @events_uri
23
32
  payload_id = is_diagnostic ? nil : SecureRandom.uuid
24
- res = nil
25
- (0..1).each do |attempt|
26
- if attempt > 0
27
- @logger.warn { "[LDClient] Will retry posting events after #{@retry_interval} second" }
28
- sleep(@retry_interval)
29
- end
30
- begin
31
- @client.start if !@client.started?
32
- @logger.debug { "[LDClient] sending #{description}: #{event_data}" }
33
- req = Net::HTTP::Post.new(uri)
34
- req.content_type = "application/json"
35
- req.body = event_data
36
- Impl::Util.default_http_headers(@sdk_key, @config).each { |k, v| req[k] = v }
37
- if !is_diagnostic
38
- req["X-LaunchDarkly-Event-Schema"] = CURRENT_SCHEMA_VERSION.to_s
39
- req["X-LaunchDarkly-Payload-ID"] = payload_id
33
+ begin
34
+ http_client = @http_client_pool.acquire()
35
+ response = nil
36
+ (0..1).each do |attempt|
37
+ if attempt > 0
38
+ @logger.warn { "[LDClient] Will retry posting events after #{@retry_interval} second" }
39
+ sleep(@retry_interval)
40
40
  end
41
- req["Connection"] = "keep-alive"
42
- res = @client.request(req)
43
- rescue StandardError => exn
44
- @logger.warn { "[LDClient] Error sending events: #{exn.inspect}." }
45
- next
46
- end
47
- status = res.code.to_i
48
- if status >= 200 && status < 300
49
- res_time = nil
50
- if !res["date"].nil?
51
- begin
52
- res_time = Time.httpdate(res["date"])
53
- rescue ArgumentError
41
+ begin
42
+ @logger.debug { "[LDClient] sending #{description}: #{event_data}" }
43
+ headers = {}
44
+ headers["content-type"] = "application/json"
45
+ Impl::Util.default_http_headers(@sdk_key, @config).each { |k, v| headers[k] = v }
46
+ if !is_diagnostic
47
+ headers["X-LaunchDarkly-Event-Schema"] = CURRENT_SCHEMA_VERSION.to_s
48
+ headers["X-LaunchDarkly-Payload-ID"] = payload_id
54
49
  end
50
+ response = http_client.request("POST", uri, {
51
+ headers: headers,
52
+ body: event_data
53
+ })
54
+ rescue StandardError => exn
55
+ @logger.warn { "[LDClient] Error sending events: #{exn.inspect}." }
56
+ next
57
+ end
58
+ status = response.status.code
59
+ # must fully read body for persistent connections
60
+ body = response.to_s
61
+ if status >= 200 && status < 300
62
+ res_time = nil
63
+ if !response.headers["date"].nil?
64
+ begin
65
+ res_time = Time.httpdate(response.headers["date"])
66
+ rescue ArgumentError
67
+ end
68
+ end
69
+ return EventSenderResult.new(true, false, res_time)
70
+ end
71
+ must_shutdown = !LaunchDarkly::Util.http_error_recoverable?(status)
72
+ can_retry = !must_shutdown && attempt == 0
73
+ message = LaunchDarkly::Util.http_error_message(status, "event delivery", can_retry ? "will retry" : "some events were dropped")
74
+ @logger.error { "[LDClient] #{message}" }
75
+ if must_shutdown
76
+ return EventSenderResult.new(false, true, nil)
55
77
  end
56
- return EventSenderResult.new(true, false, res_time)
57
- end
58
- must_shutdown = !LaunchDarkly::Util.http_error_recoverable?(status)
59
- can_retry = !must_shutdown && attempt == 0
60
- message = LaunchDarkly::Util.http_error_message(status, "event delivery", can_retry ? "will retry" : "some events were dropped")
61
- @logger.error { "[LDClient] #{message}" }
62
- if must_shutdown
63
- return EventSenderResult.new(false, true, nil)
64
78
  end
79
+ # used up our retries
80
+ return EventSenderResult.new(false, false, nil)
81
+ ensure
82
+ @http_client_pool.release(http_client)
65
83
  end
66
- # used up our retries
67
- return EventSenderResult.new(false, false, nil)
68
84
  end
69
85
  end
70
86
  end
@@ -39,7 +39,7 @@ module LaunchDarkly
39
39
  # Insert or update every provided item
40
40
  all_data.each do |kind, items|
41
41
  items.values.each do |item|
42
- value = item.to_json
42
+ value = Model.serialize(kind, item)
43
43
  key = item_key(kind, item[:key])
44
44
  ops.push({ 'KV' => { 'Verb' => 'set', 'Key' => key, 'Value' => value } })
45
45
  unused_old_keys.delete(key)
@@ -62,7 +62,7 @@ module LaunchDarkly
62
62
 
63
63
  def get_internal(kind, key)
64
64
  value = Diplomat::Kv.get(item_key(kind, key), {}, :return) # :return means "don't throw an error if not found"
65
- (value.nil? || value == "") ? nil : JSON.parse(value, symbolize_names: true)
65
+ (value.nil? || value == "") ? nil : Model.deserialize(kind, value)
66
66
  end
67
67
 
68
68
  def get_all_internal(kind)
@@ -71,7 +71,7 @@ module LaunchDarkly
71
71
  (results == "" ? [] : results).each do |result|
72
72
  value = result[:value]
73
73
  if !value.nil?
74
- item = JSON.parse(value, symbolize_names: true)
74
+ item = Model.deserialize(kind, value)
75
75
  items_out[item[:key].to_sym] = item
76
76
  end
77
77
  end
@@ -80,7 +80,7 @@ module LaunchDarkly
80
80
 
81
81
  def upsert_internal(kind, new_item)
82
82
  key = item_key(kind, new_item[:key])
83
- json = new_item.to_json
83
+ json = Model.serialize(kind, new_item)
84
84
 
85
85
  # We will potentially keep retrying indefinitely until someone's write succeeds
86
86
  while true
@@ -88,7 +88,7 @@ module LaunchDarkly
88
88
  if old_value.nil? || old_value == ""
89
89
  mod_index = 0
90
90
  else
91
- old_item = JSON.parse(old_value[0]["Value"], symbolize_names: true)
91
+ old_item = Model.deserialize(kind, old_value[0]["Value"])
92
92
  # Check whether the item is stale. If so, don't do the update (and return the existing item to
93
93
  # FeatureStoreWrapper so it can be cached)
94
94
  if old_item[:version] >= new_item[:version]
@@ -77,7 +77,7 @@ module LaunchDarkly
77
77
 
78
78
  def get_internal(kind, key)
79
79
  resp = get_item_by_keys(namespace_for_kind(kind), key)
80
- unmarshal_item(resp.item)
80
+ unmarshal_item(kind, resp.item)
81
81
  end
82
82
 
83
83
  def get_all_internal(kind)
@@ -86,7 +86,7 @@ module LaunchDarkly
86
86
  while true
87
87
  resp = @client.query(req)
88
88
  resp.items.each do |item|
89
- item_out = unmarshal_item(item)
89
+ item_out = unmarshal_item(kind, item)
90
90
  items_out[item_out[:key].to_sym] = item_out
91
91
  end
92
92
  break if resp.last_evaluated_key.nil? || resp.last_evaluated_key.length == 0
@@ -196,15 +196,15 @@ module LaunchDarkly
196
196
  def marshal_item(kind, item)
197
197
  make_keys_hash(namespace_for_kind(kind), item[:key]).merge({
198
198
  VERSION_ATTRIBUTE => item[:version],
199
- ITEM_JSON_ATTRIBUTE => item.to_json
199
+ ITEM_JSON_ATTRIBUTE => Model.serialize(kind, item)
200
200
  })
201
201
  end
202
202
 
203
- def unmarshal_item(item)
203
+ def unmarshal_item(kind, item)
204
204
  return nil if item.nil? || item.length == 0
205
205
  json_attr = item[ITEM_JSON_ATTRIBUTE]
206
206
  raise RuntimeError.new("DynamoDB map did not contain expected item string") if json_attr.nil?
207
- JSON.parse(json_attr, symbolize_names: true)
207
+ Model.deserialize(kind, json_attr)
208
208
  end
209
209
  end
210
210
 
@@ -55,7 +55,7 @@ module LaunchDarkly
55
55
  multi.del(items_key(kind))
56
56
  count = count + items.count
57
57
  items.each do |key, item|
58
- multi.hset(items_key(kind), key, item.to_json)
58
+ multi.hset(items_key(kind), key, Model.serialize(kind,item))
59
59
  end
60
60
  end
61
61
  multi.set(inited_key, inited_key)
@@ -75,8 +75,7 @@ module LaunchDarkly
75
75
  with_connection do |redis|
76
76
  hashfs = redis.hgetall(items_key(kind))
77
77
  hashfs.each do |k, json_item|
78
- f = JSON.parse(json_item, symbolize_names: true)
79
- fs[k.to_sym] = f
78
+ fs[k.to_sym] = Model.deserialize(kind, json_item)
80
79
  end
81
80
  end
82
81
  fs
@@ -95,7 +94,7 @@ module LaunchDarkly
95
94
  before_update_transaction(base_key, key)
96
95
  if old_item.nil? || old_item[:version] < new_item[:version]
97
96
  result = redis.multi do |multi|
98
- multi.hset(base_key, key, new_item.to_json)
97
+ multi.hset(base_key, key, Model.serialize(kind, new_item))
99
98
  end
100
99
  if result.nil?
101
100
  @logger.debug { "RedisFeatureStore: concurrent modification detected, retrying" }
@@ -115,9 +114,7 @@ module LaunchDarkly
115
114
  end
116
115
 
117
116
  def initialized_internal?
118
- with_connection do |redis|
119
- redis.respond_to?(:exists?) ? redis.exists?(inited_key) : redis.exists(inited_key)
120
- end
117
+ with_connection { |redis| redis.exists?(inited_key) }
121
118
  end
122
119
 
123
120
  def stop
@@ -150,8 +147,7 @@ module LaunchDarkly
150
147
  end
151
148
 
152
149
  def get_redis(redis, kind, key)
153
- json_item = redis.hget(items_key(kind), key)
154
- json_item.nil? ? nil : JSON.parse(json_item, symbolize_names: true)
150
+ Model.deserialize(kind, redis.hget(items_key(kind), key))
155
151
  end
156
152
  end
157
153
  end