redcord 0.0.4 → 0.1.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: 85772724fda7e2a152acab1f04164ff1ee0146a15f13facebd7b5f7b18323aa9
4
- data.tar.gz: e1527ca0cfe6e7cc0a0aef6346bdb055cd47e6e6d7ca22aa8488608a02e5eeaa
3
+ metadata.gz: 16cb660da0ea843f300ff18cc090ccfed7883c9d1ca48ef0bc5ed8d4e805854c
4
+ data.tar.gz: f53002cb43b3e5286ba1f914c41ffd6b8a9722d2d33d9764deb7bcba88d2659c
5
5
  SHA512:
6
- metadata.gz: cd8b0d84aecba33d41fdad600f00c74a2005c566f439914b3a72f920effc87d344b6974fc18a236d4972fb6d21efa607f43ee121da2d5c70cc6d772a5b80e5c7
7
- data.tar.gz: c16a9836a3556bbfdf38b04e78e66827733c7e40cfb002ec2744c9748453968571c110dff24f00838b3b2dfa64b658b79c378c214dd82c936df0459c303ab269
6
+ metadata.gz: db443ffbf21066c549b30daed2ad44f836aa4469065b7bd8b6d0c13f45597bd7bdbf6c7ef4202ddf8103a1c2fbcfca28ca60f9f59b90584d0ec718d380d0f908
7
+ data.tar.gz: 40ded5853307f453fe7c5ab2962e29cc081c05ab7f0c2cfddb898259e207ae9c343dfee6a67967449ff43c227c88db6f7a7922c425d8e6df92466b5ed778c5c2
@@ -9,6 +9,7 @@ require 'redcord/relation'
9
9
  module Redcord
10
10
  # Raised by Model.find
11
11
  class RecordNotFound < StandardError; end
12
+ class InvalidAction < StandardError; end
12
13
  end
13
14
 
14
15
  module Redcord::Actions
@@ -24,15 +25,36 @@ module Redcord::Actions
24
25
  module ClassMethods
25
26
  extend T::Sig
26
27
 
28
+ sig { returns(Integer) }
29
+ def count
30
+ Redcord::Base.trace(
31
+ 'redcord_actions_class_methods_count',
32
+ model_name: name,
33
+ ) do
34
+ res = 0
35
+ redis.scan_each_shard("#{model_key}:id:*") { res += 1 }
36
+ res
37
+ end
38
+ end
39
+
27
40
  sig { params(args: T::Hash[Symbol, T.untyped]).returns(T.untyped) }
28
41
  def create!(args)
29
42
  Redcord::Base.trace(
30
43
  'redcord_actions_class_methods_create!',
31
44
  model_name: name,
32
45
  ) do
46
+ self.props.keys.each { |attr_key| args[attr_key] = nil unless args.key?(attr_key) }
33
47
  args[:created_at] = args[:updated_at] = Time.zone.now
34
48
  instance = TypeCoerce[self].new.from(args)
35
- id = redis.create_hash_returning_id(model_key, to_redis_hash(args))
49
+ id = redis.create_hash_returning_id(
50
+ model_key,
51
+ to_redis_hash(args),
52
+ ttl: _script_arg_ttl,
53
+ index_attrs: _script_arg_index_attrs,
54
+ range_index_attrs: _script_arg_range_index_attrs,
55
+ custom_index_attrs: _script_arg_custom_index_attrs,
56
+ hash_tag: instance.hash_tag,
57
+ )
36
58
  instance.send(:id=, id)
37
59
  instance
38
60
  end
@@ -75,7 +97,13 @@ module Redcord::Actions
75
97
  'redcord_actions_class_methods_destroy',
76
98
  model_name: name,
77
99
  ) do
78
- redis.delete_hash(model_key, id) == 1
100
+ redis.delete_hash(
101
+ model_key,
102
+ id,
103
+ index_attrs: _script_arg_index_attrs,
104
+ range_index_attrs: _script_arg_range_index_attrs,
105
+ custom_index_attrs: _script_arg_custom_index_attrs,
106
+ ) == 1
79
107
  end
80
108
  end
81
109
  end
@@ -115,10 +143,19 @@ module Redcord::Actions
115
143
  self.updated_at = Time.zone.now
116
144
  _id = id
117
145
  if _id.nil?
146
+ serialized_instance = serialize
147
+ self.class.props.keys.each do |attr_key|
148
+ serialized_instance[attr_key.to_s] = nil unless serialized_instance.key?(attr_key.to_s)
149
+ end
118
150
  self.created_at = T.must(self.updated_at)
119
151
  _id = redis.create_hash_returning_id(
120
152
  self.class.model_key,
121
- self.class.to_redis_hash(serialize),
153
+ self.class.to_redis_hash(serialized_instance),
154
+ ttl: self.class._script_arg_ttl,
155
+ index_attrs: self.class._script_arg_index_attrs,
156
+ range_index_attrs: self.class._script_arg_range_index_attrs,
157
+ custom_index_attrs: self.class._script_arg_custom_index_attrs,
158
+ hash_tag: hash_tag,
122
159
  )
123
160
  send(:id=, _id)
124
161
  else
@@ -126,17 +163,37 @@ module Redcord::Actions
126
163
  self.class.model_key,
127
164
  _id,
128
165
  self.class.to_redis_hash(serialize),
166
+ ttl: self.class._script_arg_ttl,
167
+ index_attrs: self.class._script_arg_index_attrs,
168
+ range_index_attrs: self.class._script_arg_range_index_attrs,
169
+ custom_index_attrs: self.class._script_arg_custom_index_attrs,
170
+ hash_tag: hash_tag,
129
171
  )
130
172
  end
131
173
  end
132
174
  end
133
175
 
176
+ sig { returns(T::Boolean) }
177
+ def save
178
+ save!
179
+
180
+ true
181
+ rescue Redis::CommandError
182
+ # TODO: break down Redis::CommandError by parsing the error message
183
+ false
184
+ end
185
+
134
186
  sig { params(args: T::Hash[Symbol, T.untyped]).void }
135
- def update!(args = {})
187
+ def update!(args)
136
188
  Redcord::Base.trace(
137
189
  'redcord_actions_instance_methods_update!',
138
190
  model_name: self.class.name,
139
191
  ) do
192
+ shard_by_attr = self.class.shard_by_attribute
193
+ if args.keys.include?(shard_by_attr)
194
+ raise Redcord::InvalidAction, "Cannot update shard_by attribute #{shard_by_attr}"
195
+ end
196
+
140
197
  _id = id
141
198
  if _id.nil?
142
199
  _set_args!(args)
@@ -148,11 +205,26 @@ module Redcord::Actions
148
205
  self.class.model_key,
149
206
  _id,
150
207
  self.class.to_redis_hash(args),
208
+ ttl: self.class._script_arg_ttl,
209
+ index_attrs: self.class._script_arg_index_attrs,
210
+ range_index_attrs: self.class._script_arg_range_index_attrs,
211
+ custom_index_attrs: self.class._script_arg_custom_index_attrs,
212
+ hash_tag: hash_tag,
151
213
  )
152
214
  end
153
215
  end
154
216
  end
155
217
 
218
+ sig { params(args: T::Hash[Symbol, T.untyped]).returns(T::Boolean) }
219
+ def update(args)
220
+ update!(args)
221
+
222
+ true
223
+ rescue Redis::CommandError
224
+ # TODO: break down Redis::CommandError by parsing the error message
225
+ false
226
+ end
227
+
156
228
  sig { returns(T::Boolean) }
157
229
  def destroy
158
230
  Redcord::Base.trace(
@@ -177,14 +249,14 @@ module Redcord::Actions
177
249
  end
178
250
  end
179
251
 
180
- sig { returns(T.nilable(Integer)) }
252
+ sig { returns(T.nilable(String)) }
181
253
  def id
182
254
  instance_variable_get(:@_id)
183
255
  end
184
256
 
185
257
  private
186
258
 
187
- sig { params(id: Integer).returns(Integer) }
259
+ sig { params(id: String).returns(String) }
188
260
  def id=(id)
189
261
  instance_variable_set(:@_id, id)
190
262
  end
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # typed: strict
4
+ module Redcord
5
+ class InvalidAttribute < StandardError; end
6
+ end
4
7
 
5
8
  module Redcord::Attribute
6
9
  extend T::Sig
@@ -10,18 +13,33 @@ module Redcord::Attribute
10
13
  # type.
11
14
  RangeIndexType = T.type_alias {
12
15
  T.any(
13
- T.nilable(Float),
14
- T.nilable(Integer),
15
- T.nilable(Time),
16
+ Float,
17
+ Integer,
18
+ NilClass,
19
+ Numeric,
20
+ Time,
16
21
  )
17
22
  }
18
23
 
24
+ # Implicitly determine what data type can be a used in custom index on Redis based on Ruby type.
25
+ # Custom index currently supports positive integers with up to 19 characters in decimal notation,
26
+ # will raise error in Lua if bigger numbers are used.
27
+ CustomIndexType = T.type_alias {
28
+ T.any(
29
+ Integer,
30
+ Time,
31
+ )
32
+ }
33
+
19
34
  sig { params(klass: T.class_of(T::Struct)).void }
20
35
  def self.included(klass)
21
36
  klass.extend(ClassMethods)
37
+ klass.include(InstanceMethods)
22
38
  klass.class_variable_set(:@@index_attributes, Set.new)
23
39
  klass.class_variable_set(:@@range_index_attributes, Set.new)
40
+ klass.class_variable_set(:@@custom_index_attributes, Hash.new { |h, k| h[k] = [] })
24
41
  klass.class_variable_set(:@@ttl, nil)
42
+ klass.class_variable_set(:@@shard_by_attribute, nil)
25
43
  end
26
44
 
27
45
  module ClassMethods
@@ -46,30 +64,81 @@ module Redcord::Attribute
46
64
  def index_attribute(attr, type)
47
65
  if should_range_index?(type)
48
66
  class_variable_get(:@@range_index_attributes) << attr
49
- sadd_proc_on_redis_connection('range_index_attrs', attr.to_s)
50
67
  else
51
68
  class_variable_get(:@@index_attributes) << attr
52
- sadd_proc_on_redis_connection('index_attrs', attr.to_s)
53
69
  end
54
70
  end
71
+
72
+ sig { params(index_name: Symbol, attrs: T::Array[Symbol]).void }
73
+ def custom_index(index_name, attrs)
74
+ attrs.each do |attr|
75
+ type = props[attr][:type]
76
+ if !can_custom_index?(type)
77
+ raise(Redcord::WrongAttributeType, "Custom index doesn't support '#{type}' attributes.")
78
+ end
79
+ end
80
+ shard_by_attr = class_variable_get(:@@shard_by_attribute)
81
+ if shard_by_attr and shard_by_attr != attrs.first
82
+ raise(
83
+ Redcord::CustomIndexInvalidDesign,
84
+ "shard_by attribute '#{shard_by_attr}' must be placed first in '#{index_name}' index"
85
+ )
86
+ end
87
+ class_variable_get(:@@custom_index_attributes)[index_name] = attrs
88
+ end
55
89
 
56
90
  sig { params(duration: T.nilable(ActiveSupport::Duration)).void }
57
91
  def ttl(duration)
58
92
  class_variable_set(:@@ttl, duration)
59
93
  end
60
94
 
61
- private
95
+ def shard_by_attribute(attr=nil)
96
+ return class_variable_get(:@@shard_by_attribute) if attr.nil?
62
97
 
63
- sig { params(redis_key: String, item_to_add: String).void }
64
- def sadd_proc_on_redis_connection(redis_key, item_to_add)
65
- # TODO: Currently we're setting indexed attributes through procs that are
66
- # run when a RedisConnection is established. This should be replaced with
67
- # migrations
68
- Redcord::RedisConnection.procs_to_prepare << proc do |redis|
69
- redis.sadd("#{model_key}:#{redis_key}", item_to_add)
98
+ # attr must be an non-index attribute (index: false)
99
+ if class_variable_get(:@@index_attributes).include?(attr) ||
100
+ class_variable_get(:@@range_index_attributes).include?(attr)
101
+ raise Redcord::InvalidAttribute, "Cannot shard by an index attribute '#{attr}'"
70
102
  end
103
+
104
+ class_variable_get(:@@custom_index_attributes).each do |index_name, attrs|
105
+ if attr != attrs.first
106
+ raise(
107
+ Redcord::CustomIndexInvalidDesign,
108
+ "shard_by attribute '#{attr}' must be placed first in '#{index_name}' index"
109
+ )
110
+ end
111
+
112
+ # Delete the shard_by_attribute since it would be a constant in the
113
+ # custom index set
114
+ attrs.shift
115
+ end
116
+
117
+ class_variable_set(:@@shard_by_attribute, attr)
71
118
  end
72
119
 
120
+ sig { returns(Integer) }
121
+ def _script_arg_ttl
122
+ class_variable_get(:@@ttl)&.to_i || -1
123
+ end
124
+
125
+ sig { returns(T::Array[Symbol]) }
126
+ def _script_arg_index_attrs
127
+ class_variable_get(:@@index_attributes).to_a
128
+ end
129
+
130
+ sig { returns(T::Array[Symbol]) }
131
+ def _script_arg_range_index_attrs
132
+ class_variable_get(:@@range_index_attributes).to_a
133
+ end
134
+
135
+ sig { returns(T::Hash[Symbol, T::Array]) }
136
+ def _script_arg_custom_index_attrs
137
+ class_variable_get(:@@custom_index_attributes)
138
+ end
139
+
140
+ private
141
+
73
142
  sig { params(type: T.any(Class, T::Types::Base)).returns(T::Boolean) }
74
143
  def should_range_index?(type)
75
144
  # Change Ruby raw type to Sorbet type in order to call subtype_of?
@@ -77,6 +146,34 @@ module Redcord::Attribute
77
146
 
78
147
  type.subtype_of?(RangeIndexType)
79
148
  end
149
+
150
+ sig { params(type: T.any(Class, T::Types::Base)).returns(T::Boolean) }
151
+ def can_custom_index?(type)
152
+ # Change Ruby raw type to Sorbet type in order to call subtype_of?
153
+ type = T::Types::Simple.new(type) if type.is_a?(Class)
154
+ type.subtype_of?(CustomIndexType)
155
+ end
156
+ end
157
+
158
+ module InstanceMethods
159
+ extend T::Sig
160
+
161
+ sig { returns(T.nilable(String)) }
162
+ def hash_tag
163
+ attr = self.class.class_variable_get(:@@shard_by_attribute)
164
+
165
+ return nil if attr.nil?
166
+
167
+ # A blank hash tag would cause MOVED error in cluster mode
168
+ tag = send(attr)
169
+ default_tag = '__redcord_hash_tag_null__'
170
+
171
+ if tag == default_tag
172
+ raise Redcord::InvalidAttribute, "#{attr}=#{default_tag} conflicts with default hash_tag value"
173
+ end
174
+
175
+ "{#{tag || default_tag}}"
176
+ end
80
177
  end
81
178
 
82
179
  mixes_in_class_methods(ClassMethods)
@@ -7,32 +7,21 @@ module Redcord::Migration::Index
7
7
 
8
8
  sig { params(model: T.class_of(Redcord::Base), index_name: Symbol).void }
9
9
  def remove_index(model, index_name)
10
- if model.redis.sismember("#{model.model_key}:index_attrs", index_name)
11
- _remove_index_from_attr_set(
12
- model: model,
13
- attr_set_name: 'index_attrs',
14
- index_name: index_name,
15
- )
10
+ model.redis.scan_each_shard("#{model.model_key}:#{index_name}:*") { |key| _del_set(model, key) }
16
11
 
17
- model.redis.scan_each(match: "#{model.model_key}:#{index_name}:*") { |key| _del_set(model, key) }
18
- elsif model.redis.sismember("#{model.model_key}:range_index_attrs", index_name)
19
- _remove_index_from_attr_set(
20
- model: model,
21
- attr_set_name: 'range_index_attrs',
22
- index_name: index_name,
23
- )
12
+ attr_set = "#{model.model_key}:#{index_name}"
13
+ nil_attr_set = "#{attr_set}:"
24
14
 
25
- attr_set = "#{model.model_key}:#{index_name}"
26
- nil_attr_set = "#{attr_set}:"
15
+ model.redis.scan_each_shard("#{nil_attr_set}*") { |key| _del_set(model, key) }
16
+ model.redis.scan_each_shard("#{attr_set}*") { |key| _del_zset(model, key) }
17
+ end
27
18
 
28
- _del_set(model, nil_attr_set)
29
- _del_zset(model, attr_set)
30
- else
31
- raise(
32
- Redcord::AttributeNotIndexed,
33
- "#{index_name} is not an indexed attribute.",
34
- )
35
- end
19
+ sig { params(model: T.class_of(Redcord::Base), index_name: Symbol).void }
20
+ def remove_custom_index(model, index_name)
21
+ index_key = "#{model.model_key}:custom_index:#{index_name}"
22
+ index_content_key = "#{model.model_key}:custom_index:#{index_name}_content"
23
+ model.redis.scan_each_shard("#{index_key}*") { |key| model.redis.unlink(key) }
24
+ model.redis.scan_each_shard("#{index_content_key}*") { |key| model.redis.unlink(key) }
36
25
  end
37
26
 
38
27
  sig {
@@ -7,16 +7,9 @@ module Redcord::Migration::TTL
7
7
  model.class_variable_get(:@@ttl) || -1
8
8
  end
9
9
 
10
- # This won't change ttl until we call update on a record
11
- sig { params(model: T.class_of(Redcord::Base)).void }
12
- def change_ttl_passive(model)
13
- model.redis.set("#{model.model_key}:ttl", _get_ttl(model))
14
- end
15
-
16
10
  sig { params(model: T.class_of(Redcord::Base)).void }
17
11
  def change_ttl_active(model)
18
- change_ttl_passive(model)
19
- model.redis.scan_each(match: "#{model.model_key}:id:*") do |key|
12
+ model.redis.scan_each_shard("#{model.model_key}:id:*") do |key|
20
13
  model.redis.expire(key, _get_ttl(model))
21
14
  end
22
15
  end
@@ -29,7 +29,6 @@ class Redcord::Railtie < Rails::Railtie
29
29
  )
30
30
  end
31
31
 
32
- Redcord::PreparedRedis.load_server_scripts!
33
32
  Redcord._after_initialize!
34
33
  end
35
34
  end
@@ -0,0 +1,225 @@
1
+ # typed: true
2
+ require 'digest'
3
+ require 'redis'
4
+ require 'securerandom'
5
+
6
+ class Redcord::Redis < Redis
7
+ extend T::Sig
8
+
9
+ sig do
10
+ params(
11
+ key: T.any(String, Symbol),
12
+ args: T::Hash[T.untyped, T.untyped],
13
+ ttl: T.nilable(Integer),
14
+ index_attrs: T::Array[Symbol],
15
+ range_index_attrs: T::Array[Symbol],
16
+ custom_index_attrs: T::Hash[Symbol, T::Array],
17
+ hash_tag: T.nilable(String),
18
+ ).returns(String)
19
+ end
20
+ def create_hash_returning_id(key, args, ttl:, index_attrs:, range_index_attrs:, custom_index_attrs:, hash_tag: nil)
21
+ Redcord::Base.trace(
22
+ 'redcord_redis_create_hash_returning_id',
23
+ model_name: key,
24
+ ) do
25
+ id = "#{SecureRandom.uuid}#{hash_tag}"
26
+ custom_index_attrs_flat = custom_index_attrs.inject([]) do |result, (index_name, attrs)|
27
+ result << index_name
28
+ result << attrs.size
29
+ result + attrs
30
+ end
31
+ run_script(
32
+ :create_hash,
33
+ keys: [id, hash_tag],
34
+ argv: [key, ttl, index_attrs.size, range_index_attrs.size, custom_index_attrs_flat.size] +
35
+ index_attrs + range_index_attrs + custom_index_attrs_flat + args.to_a.flatten,
36
+ )
37
+ id
38
+ end
39
+ end
40
+
41
+ sig do
42
+ params(
43
+ model: String,
44
+ id: String,
45
+ args: T::Hash[T.untyped, T.untyped],
46
+ ttl: T.nilable(Integer),
47
+ index_attrs: T::Array[Symbol],
48
+ range_index_attrs: T::Array[Symbol],
49
+ custom_index_attrs: T::Hash[Symbol, T::Array],
50
+ hash_tag: T.nilable(String),
51
+ ).void
52
+ end
53
+ def update_hash(model, id, args, ttl:, index_attrs:, range_index_attrs:, custom_index_attrs:, hash_tag:)
54
+ Redcord::Base.trace(
55
+ 'redcord_redis_update_hash',
56
+ model_name: model,
57
+ ) do
58
+ custom_index_attrs_flat = custom_index_attrs.inject([]) do |result, (index_name, attrs)|
59
+ if !(args.keys.to_set & attrs.to_set).empty?
60
+ result << index_name
61
+ result << attrs.size
62
+ result + attrs
63
+ else
64
+ result
65
+ end
66
+ end
67
+ run_script(
68
+ :update_hash,
69
+ keys: [id, hash_tag],
70
+ argv: [model, ttl, index_attrs.size, range_index_attrs.size, custom_index_attrs_flat.size] +
71
+ index_attrs + range_index_attrs + custom_index_attrs_flat + args.to_a.flatten,
72
+ )
73
+ end
74
+ end
75
+
76
+ sig do
77
+ params(
78
+ model: String,
79
+ id: String,
80
+ index_attrs: T::Array[Symbol],
81
+ range_index_attrs: T::Array[Symbol],
82
+ custom_index_attrs: T::Hash[Symbol, T::Array],
83
+ ).returns(Integer)
84
+ end
85
+ def delete_hash(model, id, index_attrs:, range_index_attrs:, custom_index_attrs:)
86
+ Redcord::Base.trace(
87
+ 'redcord_redis_delete_hash',
88
+ model_name: model,
89
+ ) do
90
+ custom_index_names = custom_index_attrs.keys
91
+ run_script(
92
+ :delete_hash,
93
+ keys: [id, id.match(/\{.*\}$/)&.send(:[], 0)],
94
+ argv: [model, index_attrs.size, range_index_attrs.size] + index_attrs + range_index_attrs + custom_index_names,
95
+ )
96
+ end
97
+ end
98
+
99
+ sig do
100
+ params(
101
+ model: String,
102
+ query_conditions: T::Hash[T.untyped, T.untyped],
103
+ index_attrs: T::Array[Symbol],
104
+ range_index_attrs: T::Array[Symbol],
105
+ select_attrs: T::Set[Symbol],
106
+ custom_index_attrs: T::Array[Symbol],
107
+ hash_tag: T.nilable(String),
108
+ custom_index_name: T.nilable(Symbol),
109
+ ).returns(T::Hash[Integer, T::Hash[T.untyped, T.untyped]])
110
+ end
111
+ def find_by_attr(
112
+ model,
113
+ query_conditions,
114
+ select_attrs: Set.new,
115
+ index_attrs:,
116
+ range_index_attrs:,
117
+ custom_index_attrs: Array.new,
118
+ hash_tag: nil,
119
+ custom_index_name: nil
120
+ )
121
+ Redcord::Base.trace(
122
+ 'redcord_redis_find_by_attr',
123
+ model_name: model,
124
+ ) do
125
+ conditions = flatten_with_partial_sort(query_conditions.clone, custom_index_attrs)
126
+ res = run_script(
127
+ :find_by_attr,
128
+ keys: [hash_tag],
129
+ argv: [model, custom_index_name, index_attrs.size, range_index_attrs.size, custom_index_attrs.size, conditions.size] +
130
+ index_attrs + range_index_attrs + custom_index_attrs + conditions + select_attrs.to_a.flatten
131
+ )
132
+ # The Lua script will return this as a flattened array.
133
+ # Convert the result into a hash of {id -> model hash}
134
+ res_hash = res.each_slice(2)
135
+ res_hash.map { |key, val| [key, val.each_slice(2).to_h] }.to_h
136
+ end
137
+ end
138
+
139
+ sig do
140
+ params(
141
+ model: String,
142
+ query_conditions: T::Hash[T.untyped, T.untyped],
143
+ index_attrs: T::Array[Symbol],
144
+ range_index_attrs: T::Array[Symbol],
145
+ custom_index_attrs: T::Array[Symbol],
146
+ hash_tag: T.nilable(String),
147
+ custom_index_name: T.nilable(Symbol),
148
+ ).returns(Integer)
149
+ end
150
+ def find_by_attr_count(
151
+ model,
152
+ query_conditions,
153
+ index_attrs:,
154
+ range_index_attrs:,
155
+ custom_index_attrs: Array.new,
156
+ hash_tag: nil,
157
+ custom_index_name: nil
158
+ )
159
+ Redcord::Base.trace(
160
+ 'redcord_redis_find_by_attr_count',
161
+ model_name: model,
162
+ ) do
163
+ conditions = flatten_with_partial_sort(query_conditions.clone, custom_index_attrs)
164
+ run_script(
165
+ :find_by_attr_count,
166
+ keys: [hash_tag],
167
+ argv: [model, custom_index_name, index_attrs.size, range_index_attrs.size, custom_index_attrs.size] +
168
+ index_attrs + range_index_attrs + custom_index_attrs + conditions
169
+ )
170
+ end
171
+ end
172
+
173
+ def scan_each_shard(key, &blk)
174
+ clients = instance_variable_get(:@client)
175
+ &.instance_variable_get(:@node)
176
+ &.instance_variable_get(:@clients)
177
+ &.values
178
+
179
+ if clients.nil?
180
+ scan_each(match: key, &blk)
181
+ else
182
+ clients.each do |client|
183
+ cursor = 0
184
+ loop do
185
+ cursor, keys = client.call([:scan, cursor, 'match', key])
186
+ keys.each(&blk)
187
+ break if cursor == "0"
188
+ end
189
+ end
190
+ end
191
+ end
192
+
193
+ private
194
+
195
+ def run_script(script_name, *args)
196
+ # Use EVAL when a redis shard has not loaded the script before
197
+ hash_var_name = :"@script_sha_#{script_name}"
198
+ hash = instance_variable_get(hash_var_name)
199
+
200
+ begin
201
+ return evalsha(hash, *args) if hash
202
+ rescue Redis::CommandError => e
203
+ if e.message != 'NOSCRIPT No matching script. Please use EVAL.'
204
+ raise e
205
+ end
206
+ end
207
+
208
+ script_content = Redcord::LuaScriptReader.read_lua_script(script_name.to_s)
209
+ instance_variable_set(hash_var_name, Digest::SHA1.hexdigest(script_content))
210
+ self.eval(script_content, *args)
211
+ end
212
+
213
+ # When using custom index: On Lua side script expects query conditions sorted
214
+ # in the order of appearance of attributes in specified index
215
+ sig { params(query_conditions: T::Hash[T.untyped, T.untyped], partial_order: T::Array[Symbol]).returns(T::Array[T.untyped]) }
216
+ def flatten_with_partial_sort(query_conditions, partial_order)
217
+ conditions = partial_order.inject([]) do |result, attr|
218
+ if !query_conditions[attr].nil?
219
+ result << attr << query_conditions.delete(attr)
220
+ end
221
+ result.flatten
222
+ end
223
+ conditions += query_conditions.to_a.flatten
224
+ end
225
+ end