redcord 0.0.1.alpha → 0.1.1

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.
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