redcord 0.0.4 → 0.1.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: 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