redcord 0.0.3 → 0.1.3

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: 7612a04876a98367ab0b8a6d94a0254d0d79aa564b3bd43e5119785f114d962c
4
- data.tar.gz: 9b679659f7087bfd65d202a58be3f0836b1a7e8bc889f16d34e423340ce8c4ed
3
+ metadata.gz: 9f33907b35b938f14be13d1d20f89f1530d72a28cb55e7e38a53f61743a2bfe5
4
+ data.tar.gz: 1297ac81f293f35572a0015782aa621b1d5ba0e3e691ea86abadbdde26ca094a
5
5
  SHA512:
6
- metadata.gz: 44796213c268dfb63e59472c9b59470e3b01dd7a26b0acc63aa48e0f7ff537c25906957d3700b24639eb41d9526708898dc8ff82a4eeea193fc7ec1a3a5cfe43
7
- data.tar.gz: d656f40756c62507113ac660429e9dc10751f40158c8768d47ccdb238ac2d392ccdfc055744ef68837608e7ed33f5673527178a9d0ef77a928d4cabd49da055f
6
+ metadata.gz: f4d23f458c6f6de224293010b60f19d7c07199c66eb382e5bd5a7917a429900658bfd42202107c3e72ace588ea526bcf3edb6b56dfeaa7a6b58f3eea4b91dc81
7
+ data.tar.gz: d1aa8dedf1dccad372489072405725fe51e3712b634524c73c574130ab8473bab1fefdedfbd47466c956ab7e96e121153798f08a4250b30e7dc30d33039bf801
data/lib/redcord.rb CHANGED
@@ -36,3 +36,4 @@ require 'redcord/migration'
36
36
  require 'redcord/migration/migrator'
37
37
  require 'redcord/migration/version'
38
38
  require 'redcord/railtie'
39
+ require 'redcord/vacuum_helper'
@@ -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)
data/lib/redcord/base.rb CHANGED
@@ -56,9 +56,19 @@ module Redcord::Base
56
56
  # coerced to the specified attribute types. Like ActiveRecord,
57
57
  # Redcord manages the created_at and updated_at fields behind the
58
58
  # scene.
59
- attribute :id, T.nilable(Integer), index: true
60
- attribute :created_at, T.nilable(Time), index: true
61
- attribute :updated_at, T.nilable(Time), index: true
59
+ prop :created_at, T.nilable(Time)
60
+ prop :updated_at, T.nilable(Time)
62
61
  end
63
62
  end
63
+
64
+ sig { returns(T::Array[T.class_of(Redcord::Base)]) }
65
+ def self.descendants
66
+ descendants = []
67
+ # TODO: Use T::Struct instead of Class
68
+ ObjectSpace.each_object(Class) do |klass|
69
+ descendants << klass if klass < self
70
+ end
71
+ descendants
72
+ end
73
+
64
74
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+ require 'connection_pool'
3
+ require_relative 'redis'
4
+
5
+ class Redcord::ConnectionPool
6
+ def initialize(pool_size:, timeout:, **client_options)
7
+ @connection_pool = ::ConnectionPool.new(size: pool_size, timeout: timeout) do
8
+ # Construct a new client every time the block gets called
9
+ Redcord::Redis.new(**client_options, logger: Redcord::Logger.proxy)
10
+ end
11
+ end
12
+
13
+ # Avoid method_missing when possible for better performance
14
+ methods = Set.new(Redcord::Redis.instance_methods(false) + Redis.instance_methods(false))
15
+ methods.each do |method_name|
16
+ define_method method_name do |*args, &blk|
17
+ @connection_pool.with do |redis|
18
+ redis.send(method_name, *args, &blk)
19
+ end
20
+ end
21
+ end
22
+
23
+ def method_missing(method_name, *args, &blk)
24
+ @connection_pool.with do |redis|
25
+ redis.send(method_name, *args, &blk)
26
+ end
27
+ end
28
+ end
@@ -2,11 +2,13 @@
2
2
  class Redcord::Migration
3
3
  end
4
4
 
5
+ require 'redcord/migration/index'
5
6
  require 'redcord/migration/ttl'
6
7
 
7
8
  class Redcord::Migration
8
9
  extend T::Sig
9
10
  extend T::Helpers
11
+ include Redcord::Migration::Index
10
12
  include Redcord::Migration::TTL
11
13
 
12
14
  abstract!
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module Redcord::Migration::Index
6
+ extend T::Sig
7
+
8
+ sig { params(model: T.class_of(Redcord::Base), index_name: Symbol).void }
9
+ def remove_index(model, index_name)
10
+ model.redis.scan_each_shard("#{model.model_key}:#{index_name}:*") { |key| _del_set(model, key) }
11
+
12
+ attr_set = "#{model.model_key}:#{index_name}"
13
+ nil_attr_set = "#{attr_set}:"
14
+
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
18
+
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) }
25
+ end
26
+
27
+ sig {
28
+ params(
29
+ model: T.class_of(Redcord::Base),
30
+ attr_set_name: String,
31
+ index_name: Symbol,
32
+ ).void
33
+ }
34
+ def _remove_index_from_attr_set(model:, attr_set_name:, index_name:)
35
+ model.redis.srem("#{model.model_key}:#{attr_set_name}", index_name)
36
+ end
37
+
38
+ sig { params(model: T.class_of(Redcord::Base), key: String).void }
39
+ def _del_set(model, key)
40
+ # Use SPOP here to minimize blocking
41
+ loop do
42
+ break unless model.redis.spop(key)
43
+ end
44
+
45
+ model.redis.del(key)
46
+ end
47
+
48
+ sig { params(model: T.class_of(Redcord::Base), key: String).void }
49
+ def _del_zset(model, key)
50
+ # ZPOPMIN might not be avaliable on old redis servers
51
+ model.redis.zscan_each(match: key) do |id, _|
52
+ model.redis.zrem(key, id)
53
+ end
54
+
55
+ model.redis.del(key)
56
+ end
57
+ end