redcord 0.0.1.alpha → 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/lib/redcord.rb +30 -2
  3. data/lib/redcord.rbi +0 -16
  4. data/lib/redcord/actions.rb +171 -40
  5. data/lib/redcord/attribute.rb +126 -21
  6. data/lib/redcord/base.rb +15 -0
  7. data/lib/redcord/configurations.rb +4 -0
  8. data/lib/redcord/logger.rb +1 -1
  9. data/lib/redcord/lua_script_reader.rb +16 -5
  10. data/lib/redcord/migration.rb +2 -0
  11. data/lib/redcord/migration/index.rb +57 -0
  12. data/lib/redcord/migration/migrator.rb +1 -1
  13. data/lib/redcord/migration/ttl.rb +9 -4
  14. data/lib/redcord/migration/version.rb +3 -0
  15. data/lib/redcord/railtie.rb +18 -0
  16. data/lib/redcord/redis.rb +200 -0
  17. data/lib/redcord/redis_connection.rb +38 -29
  18. data/lib/redcord/relation.rb +214 -38
  19. data/lib/redcord/serializer.rb +147 -49
  20. data/lib/redcord/server_scripts/create_hash.erb.lua +81 -0
  21. data/lib/redcord/server_scripts/delete_hash.erb.lua +17 -8
  22. data/lib/redcord/server_scripts/find_by_attr.erb.lua +50 -16
  23. data/lib/redcord/server_scripts/find_by_attr_count.erb.lua +45 -14
  24. data/lib/redcord/server_scripts/shared/index_helper_methods.erb.lua +45 -16
  25. data/lib/redcord/server_scripts/shared/lua_helper_methods.erb.lua +20 -4
  26. data/lib/redcord/server_scripts/shared/query_helper_methods.erb.lua +86 -14
  27. data/lib/redcord/server_scripts/update_hash.erb.lua +40 -26
  28. data/lib/redcord/tasks/redis.rake +15 -0
  29. data/lib/redcord/tracer.rb +48 -0
  30. data/lib/redcord/vacuum_helper.rb +90 -0
  31. metadata +13 -11
  32. data/lib/redcord/prepared_redis.rb +0 -18
  33. data/lib/redcord/server_scripts.rb +0 -78
  34. data/lib/redcord/server_scripts/create_hash_returning_id.erb.lua +0 -68
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+ #
1
3
  # typed: strict
2
4
  #
3
5
  # A Redis ORM API inspired by ActiveRecord:
@@ -11,6 +13,7 @@ require 'redcord/configurations'
11
13
  require 'redcord/logger'
12
14
  require 'redcord/redis_connection'
13
15
  require 'redcord/serializer'
16
+ require 'redcord/tracer'
14
17
 
15
18
  module Redcord::Base
16
19
  extend T::Sig
@@ -23,6 +26,7 @@ module Redcord::Base
23
26
  include Redcord::Configurations
24
27
  include Redcord::Logger
25
28
  include Redcord::RedisConnection
29
+ include Redcord::Tracer
26
30
 
27
31
  abstract!
28
32
 
@@ -56,4 +60,15 @@ module Redcord::Base
56
60
  prop :updated_at, T.nilable(Time)
57
61
  end
58
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
+
59
74
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+ #
1
3
  # typed: strict
2
4
  #
3
5
  # This allows us to configure Redis connections for Redcord. Redis
@@ -40,6 +42,8 @@
40
42
  # ```
41
43
  #
42
44
  require 'redcord/redis_connection'
45
+ require 'redcord/tracer'
46
+
43
47
  module Redcord::Configurations
44
48
  extend T::Sig
45
49
  extend T::Helpers
@@ -12,7 +12,7 @@ module Redcord::Logger
12
12
  module ClassMethods
13
13
  extend T::Sig
14
14
 
15
- @@logger = T.let(Rails.logger, T.untyped)
15
+ @@logger = T.let(nil, T.untyped)
16
16
 
17
17
  sig { returns(T.untyped) }
18
18
  def logger
@@ -1,16 +1,27 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # typed: strict
4
+
5
+ require 'erb'
6
+
2
7
  module Redcord::LuaScriptReader
3
8
  extend T::Sig
4
9
 
5
- sig {params(script_name: String).returns(String) }
10
+ sig { params(script_name: String).returns(String) }
6
11
  def self.read_lua_script(script_name)
7
- path = File.join(File.dirname(__FILE__), "server_scripts/#{script_name}.erb.lua")
12
+ path = File.join(
13
+ File.dirname(__FILE__),
14
+ "server_scripts/#{script_name}.erb.lua",
15
+ )
8
16
  ERB.new(File.read(path)).result(binding)
9
17
  end
10
18
 
11
- sig {params(relative_path: String).returns(String) }
19
+ sig { params(relative_path: String).returns(String) }
12
20
  def self.include_lua(relative_path)
13
- path = File.join(File.dirname(__FILE__), "server_scripts/#{relative_path}.erb.lua")
21
+ path = File.join(
22
+ File.dirname(__FILE__),
23
+ "server_scripts/#{relative_path}.erb.lua",
24
+ )
14
25
  File.read(path)
15
26
  end
16
- end
27
+ 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
@@ -50,7 +50,7 @@ class Redcord::Migration::Migrator
50
50
  MIGRATION_FILENAME_REGEX = /\A([0-9]+)_([_a-z0-9]*)\.?([_a-z0-9]*)?\.rb\z/
51
51
 
52
52
  @@migrations_paths = T.let(
53
- ['db/redisrecord/migrate'],
53
+ ['db/redcord/migrate'],
54
54
  T::Array[String],
55
55
  )
56
56
 
@@ -2,10 +2,15 @@
2
2
  module Redcord::Migration::TTL
3
3
  extend T::Sig
4
4
 
5
- # This won't change ttl until we call update on a record
5
+ sig { params(model: T.class_of(Redcord::Base)).returns(T.untyped) }
6
+ def _get_ttl(model)
7
+ model.class_variable_get(:@@ttl) || -1
8
+ end
9
+
6
10
  sig { params(model: T.class_of(Redcord::Base)).void }
7
- def change_ttl_passive(model)
8
- ttl = model.class_variable_get(:@@ttl)
9
- model.redis.set("#{model.model_key}:ttl", ttl ? ttl : -1)
11
+ def change_ttl_active(model)
12
+ model.redis.scan_each_shard("#{model.model_key}:id:*") do |key|
13
+ model.redis.expire(key, _get_ttl(model))
14
+ end
10
15
  end
11
16
  end
@@ -1,4 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # typed: strict
4
+
2
5
  class Redcord::Migration::Version
3
6
  extend T::Sig
4
7
 
@@ -1,5 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # typed: strict
4
+
2
5
  require 'rails'
6
+ require 'yaml'
3
7
 
4
8
  class Redcord::Railtie < Rails::Railtie
5
9
  railtie_name 'redcord'
@@ -13,4 +17,18 @@ class Redcord::Railtie < Rails::Railtie
13
17
  config.before_configuration do
14
18
  require 'redcord/base'
15
19
  end
20
+
21
+ config.after_initialize do
22
+ Redcord::Base.logger = Rails.logger
23
+
24
+ config_file = 'config/redcord.yml'
25
+
26
+ if File.file?(config_file)
27
+ Redcord::Base.configurations = YAML.load(
28
+ ERB.new(File.read(config_file)).result
29
+ )
30
+ end
31
+
32
+ Redcord._after_initialize!
33
+ end
16
34
  end
@@ -0,0 +1,200 @@
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
+ id = "#{SecureRandom.uuid}#{hash_tag}"
22
+ custom_index_attrs_flat = custom_index_attrs.inject([]) do |result, (index_name, attrs)|
23
+ result << index_name
24
+ result << attrs.size
25
+ result + attrs
26
+ end
27
+ run_script(
28
+ :create_hash,
29
+ keys: [id, hash_tag],
30
+ argv: [key, ttl, index_attrs.size, range_index_attrs.size, custom_index_attrs_flat.size] +
31
+ index_attrs + range_index_attrs + custom_index_attrs_flat + args.to_a.flatten,
32
+ )
33
+ id
34
+ end
35
+
36
+ sig do
37
+ params(
38
+ model: String,
39
+ id: String,
40
+ args: T::Hash[T.untyped, T.untyped],
41
+ ttl: T.nilable(Integer),
42
+ index_attrs: T::Array[Symbol],
43
+ range_index_attrs: T::Array[Symbol],
44
+ custom_index_attrs: T::Hash[Symbol, T::Array],
45
+ hash_tag: T.nilable(String),
46
+ ).void
47
+ end
48
+ def update_hash(model, id, args, ttl:, index_attrs:, range_index_attrs:, custom_index_attrs:, hash_tag:)
49
+ custom_index_attrs_flat = custom_index_attrs.inject([]) do |result, (index_name, attrs)|
50
+ if !(args.keys.to_set & attrs.to_set).empty?
51
+ result << index_name
52
+ result << attrs.size
53
+ result + attrs
54
+ else
55
+ result
56
+ end
57
+ end
58
+ run_script(
59
+ :update_hash,
60
+ keys: [id, hash_tag],
61
+ argv: [model, ttl, index_attrs.size, range_index_attrs.size, custom_index_attrs_flat.size] +
62
+ index_attrs + range_index_attrs + custom_index_attrs_flat + args.to_a.flatten,
63
+ )
64
+ end
65
+
66
+ sig do
67
+ params(
68
+ model: String,
69
+ id: String,
70
+ index_attrs: T::Array[Symbol],
71
+ range_index_attrs: T::Array[Symbol],
72
+ custom_index_attrs: T::Hash[Symbol, T::Array],
73
+ ).returns(Integer)
74
+ end
75
+ def delete_hash(model, id, index_attrs:, range_index_attrs:, custom_index_attrs:)
76
+ custom_index_names = custom_index_attrs.keys
77
+ run_script(
78
+ :delete_hash,
79
+ keys: [id, id.match(/\{.*\}$/)&.send(:[], 0)],
80
+ argv: [model, index_attrs.size, range_index_attrs.size] + index_attrs + range_index_attrs + custom_index_names,
81
+ )
82
+ end
83
+
84
+ sig do
85
+ params(
86
+ model: String,
87
+ query_conditions: T::Hash[T.untyped, T.untyped],
88
+ index_attrs: T::Array[Symbol],
89
+ range_index_attrs: T::Array[Symbol],
90
+ select_attrs: T::Set[Symbol],
91
+ custom_index_attrs: T::Array[Symbol],
92
+ hash_tag: T.nilable(String),
93
+ custom_index_name: T.nilable(Symbol),
94
+ ).returns(T::Hash[Integer, T::Hash[T.untyped, T.untyped]])
95
+ end
96
+ def find_by_attr(
97
+ model,
98
+ query_conditions,
99
+ select_attrs: Set.new,
100
+ index_attrs:,
101
+ range_index_attrs:,
102
+ custom_index_attrs: Array.new,
103
+ hash_tag: nil,
104
+ custom_index_name: nil
105
+ )
106
+ conditions = flatten_with_partial_sort(query_conditions.clone, custom_index_attrs)
107
+ res = run_script(
108
+ :find_by_attr,
109
+ keys: [hash_tag],
110
+ argv: [model, custom_index_name, index_attrs.size, range_index_attrs.size, custom_index_attrs.size, conditions.size] +
111
+ index_attrs + range_index_attrs + custom_index_attrs + conditions + select_attrs.to_a.flatten
112
+ )
113
+ # The Lua script will return this as a flattened array.
114
+ # Convert the result into a hash of {id -> model hash}
115
+ res_hash = res.each_slice(2)
116
+ res_hash.map { |key, val| [key, val.each_slice(2).to_h] }.to_h
117
+ end
118
+
119
+ sig do
120
+ params(
121
+ model: String,
122
+ query_conditions: T::Hash[T.untyped, T.untyped],
123
+ index_attrs: T::Array[Symbol],
124
+ range_index_attrs: T::Array[Symbol],
125
+ custom_index_attrs: T::Array[Symbol],
126
+ hash_tag: T.nilable(String),
127
+ custom_index_name: T.nilable(Symbol),
128
+ ).returns(Integer)
129
+ end
130
+ def find_by_attr_count(
131
+ model,
132
+ query_conditions,
133
+ index_attrs:,
134
+ range_index_attrs:,
135
+ custom_index_attrs: Array.new,
136
+ hash_tag: nil,
137
+ custom_index_name: nil
138
+ )
139
+ conditions = flatten_with_partial_sort(query_conditions.clone, custom_index_attrs)
140
+ run_script(
141
+ :find_by_attr_count,
142
+ keys: [hash_tag],
143
+ argv: [model, custom_index_name, index_attrs.size, range_index_attrs.size, custom_index_attrs.size] +
144
+ index_attrs + range_index_attrs + custom_index_attrs + conditions
145
+ )
146
+ end
147
+
148
+ def scan_each_shard(key, count: 1000, &blk)
149
+ clients = instance_variable_get(:@client)
150
+ &.instance_variable_get(:@node)
151
+ &.instance_variable_get(:@clients)
152
+ &.values
153
+
154
+ if clients.nil?
155
+ scan_each(match: key, count: count, &blk)
156
+ else
157
+ clients.each do |client|
158
+ cursor = 0
159
+ loop do
160
+ cursor, keys = client.call([:scan, cursor, 'match', key, 'count', count])
161
+ keys.each(&blk)
162
+ break if cursor == "0"
163
+ end
164
+ end
165
+ end
166
+ end
167
+
168
+ private
169
+
170
+ def run_script(script_name, *args)
171
+ # Use EVAL when a redis shard has not loaded the script before
172
+ hash_var_name = :"@script_sha_#{script_name}"
173
+ hash = instance_variable_get(hash_var_name)
174
+
175
+ begin
176
+ return evalsha(hash, *args) if hash
177
+ rescue Redis::CommandError => e
178
+ if e.message != 'NOSCRIPT No matching script. Please use EVAL.'
179
+ raise e
180
+ end
181
+ end
182
+
183
+ script_content = Redcord::LuaScriptReader.read_lua_script(script_name.to_s)
184
+ instance_variable_set(hash_var_name, Digest::SHA1.hexdigest(script_content))
185
+ self.eval(script_content, *args)
186
+ end
187
+
188
+ # When using custom index: On Lua side script expects query conditions sorted
189
+ # in the order of appearance of attributes in specified index
190
+ sig { params(query_conditions: T::Hash[T.untyped, T.untyped], partial_order: T::Array[Symbol]).returns(T::Array[T.untyped]) }
191
+ def flatten_with_partial_sort(query_conditions, partial_order)
192
+ conditions = partial_order.inject([]) do |result, attr|
193
+ if !query_conditions[attr].nil?
194
+ result << attr << query_conditions.delete(attr)
195
+ end
196
+ result.flatten
197
+ end
198
+ conditions += query_conditions.to_a.flatten
199
+ end
200
+ end
@@ -1,7 +1,11 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # typed: strict
4
+
2
5
  require 'rails'
3
- require 'redcord/prepared_redis'
6
+
4
7
  require 'redcord/lua_script_reader'
8
+ require 'redcord/redis'
5
9
 
6
10
  module Redcord::RedisConnection
7
11
  extend T::Sig
@@ -25,19 +29,20 @@ module Redcord::RedisConnection
25
29
  (env_config[name.underscore] || env_config['default']).symbolize_keys
26
30
  end
27
31
 
28
- sig { returns(Redcord::PreparedRedis) }
32
+ sig { returns(Redcord::Redis) }
29
33
  def redis
30
34
  Redcord::RedisConnection.connections[name.underscore] ||= prepare_redis!
31
35
  end
32
36
 
33
- sig { returns(Redcord::PreparedRedis) }
37
+ sig { returns(Redcord::Redis) }
34
38
  def establish_connection
35
39
  Redcord::RedisConnection.connections[name.underscore] = prepare_redis!
36
40
  end
37
41
 
38
- sig { params(redis: Redis).returns(Redcord::PreparedRedis) }
42
+ sig { params(redis: Redis).returns(Redcord::Redis) }
39
43
  def redis=(redis)
40
- Redcord::RedisConnection.connections[name.underscore] = prepare_redis!(redis)
44
+ Redcord::RedisConnection.connections[name.underscore] =
45
+ prepare_redis!(redis)
41
46
  end
42
47
 
43
48
  # We prepare the model definition such as TTL, index, and uniq when we
@@ -45,29 +50,22 @@ module Redcord::RedisConnection
45
50
  # definitions in each Redis query.
46
51
  #
47
52
  # TODO: Replace this with Redcord migrations
48
- sig { params(client: T.nilable(Redis)).returns(Redcord::PreparedRedis) }
49
- def prepare_redis!(client=nil)
50
- return client if client.is_a?(Redcord::PreparedRedis)
51
-
52
- client = Redcord::PreparedRedis.new(
53
- **(client.nil? ? connection_config : client.instance_variable_get(:@options)),
53
+ sig { params(client: T.nilable(Redis)).returns(Redcord::Redis) }
54
+ def prepare_redis!(client = nil)
55
+ return client if client.is_a?(Redcord::Redis)
56
+
57
+ client = Redcord::Redis.new(
58
+ **(
59
+ if client.nil?
60
+ connection_config
61
+ else
62
+ client.instance_variable_get(:@options)
63
+ end
64
+ ),
54
65
  logger: Redcord::Logger.proxy,
55
66
  )
56
67
 
57
- client.pipelined do
58
- Redcord::RedisConnection.procs_to_prepare.each do |proc_to_prepare|
59
- proc_to_prepare.call(client)
60
- end
61
- end
62
-
63
- script_names = Redcord::ServerScripts.instance_methods
64
- res = client.pipelined do
65
- script_names.each do |script_name|
66
- client.script(:load, Redcord::LuaScriptReader.read_lua_script(script_name.to_s))
67
- end
68
- end
69
-
70
- client.redcord_server_script_shas = script_names.zip(res).to_h
68
+ client.ping
71
69
  client
72
70
  end
73
71
  end
@@ -75,17 +73,21 @@ module Redcord::RedisConnection
75
73
  module InstanceMethods
76
74
  extend T::Sig
77
75
 
78
- sig { returns(Redcord::PreparedRedis) }
76
+ sig { returns(Redcord::Redis) }
79
77
  def redis
80
78
  self.class.redis
81
79
  end
82
80
  end
83
81
 
84
- sig { params(config: T::Hash[String, T.untyped]).returns(T::Hash[String, T.untyped]) }
82
+ sig {
83
+ params(
84
+ config: T::Hash[String, T.untyped],
85
+ ).returns(T::Hash[String, T.untyped])
86
+ }
85
87
  def self.merge_and_resolve_default(config)
86
88
  env = Rails.env
87
- config[env] = {} if !config.include?(env)
88
- config[env]['default'] = {} if !config[env].include?('default')
89
+ config[env] = {} unless config.include?(env)
90
+ config[env]['default'] = {} unless config[env].include?('default')
89
91
  config
90
92
  end
91
93
 
@@ -101,3 +103,10 @@ module Redcord::RedisConnection
101
103
 
102
104
  mixes_in_class_methods(ClassMethods)
103
105
  end
106
+
107
+ module Redcord
108
+ sig { void }
109
+ def self.establish_connections
110
+ Redcord::Base.descendants.select(&:name).each(&:establish_connection)
111
+ end
112
+ end