solid_cache 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,61 @@
1
+ require "concurrent/atomic/atomic_fixnum"
2
+
3
+ module SolidCache
4
+ class Cluster
5
+ module Expiry
6
+ # For every write that we do, we attempt to delete EXPIRY_MULTIPLIER times as many records.
7
+ # This ensures there is downward pressure on the cache size while there is valid data to delete
8
+ EXPIRY_MULTIPLIER = 1.25
9
+
10
+ attr_reader :expiry_batch_size, :expiry_method, :expire_every, :max_age, :max_entries
11
+
12
+ def initialize(options = {})
13
+ super(options)
14
+ @expiry_batch_size = options.fetch(:expiry_batch_size, 100)
15
+ @expiry_method = options.fetch(:expiry_method, :thread)
16
+ @expire_every = [ (expiry_batch_size / EXPIRY_MULTIPLIER).floor, 1 ].max
17
+ @max_age = options.fetch(:max_age, 2.weeks.to_i)
18
+ @max_entries = options.fetch(:max_entries, nil)
19
+
20
+ raise ArgumentError, "Expiry method must be one of `:thread` or `:job`" unless [ :thread, :job ].include?(expiry_method)
21
+ end
22
+
23
+ def track_writes(count)
24
+ expire_later if expiry_counter.count(count)
25
+ end
26
+
27
+ private
28
+ def expire_later
29
+ if expiry_method == :job
30
+ ExpiryJob.perform_later(expiry_batch_size, shard: Entry.current_shard, max_age: max_age, max_entries: max_entries)
31
+ else
32
+ async { Entry.expire(expiry_batch_size, max_age: max_age, max_entries: max_entries) }
33
+ end
34
+ end
35
+
36
+ def expiry_counter
37
+ @expiry_counters ||= connection_names.to_h { |connection_name| [ connection_name, Counter.new(expire_every) ] }
38
+ @expiry_counters[Entry.current_shard]
39
+ end
40
+
41
+ class Counter
42
+ attr_reader :expire_every, :counter
43
+
44
+ def initialize(expire_every)
45
+ @expire_every = expire_every
46
+ @counter = Concurrent::AtomicFixnum.new(rand(expire_every).to_i)
47
+ end
48
+
49
+ def count(count)
50
+ value = counter.increment(count)
51
+ new_multiple_of_expire_every?(value - count, value)
52
+ end
53
+
54
+ private
55
+ def new_multiple_of_expire_every?(first_value, second_value)
56
+ first_value / expire_every != second_value / expire_every
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,32 @@
1
+ module SolidCache
2
+ class Cluster
3
+ module Stats
4
+ def initialize(options = {})
5
+ super()
6
+ end
7
+
8
+ def stats
9
+ stats = {
10
+ connections: connections.count,
11
+ connection_stats: connections_stats
12
+ }
13
+ end
14
+
15
+ private
16
+ def connections_stats
17
+ with_each_connection.to_h { |connection| [ Entry.current_shard, connection_stats ] }
18
+ end
19
+
20
+ def connection_stats
21
+ oldest_created_at = Entry.order(:id).pick(:created_at)
22
+
23
+ {
24
+ max_age: max_age,
25
+ oldest_age: oldest_created_at ? Time.now - oldest_created_at : nil,
26
+ max_entries: max_entries,
27
+ entries: Entry.id_range
28
+ }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,14 @@
1
+
2
+ module SolidCache
3
+ class Cluster
4
+ include Connections, Execution, Expiry, Stats
5
+
6
+ def initialize(options = {})
7
+ super(options)
8
+ end
9
+
10
+ def setup!
11
+ super
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,40 @@
1
+ module SolidCache
2
+ module Connections
3
+ class Sharded
4
+ attr_reader :names, :nodes, :consistent_hash
5
+
6
+ def initialize(names, nodes)
7
+ @names = names
8
+ @nodes = nodes
9
+ @consistent_hash = MaglevHash.new(@nodes.keys)
10
+ end
11
+
12
+ def with_each(&block)
13
+ return enum_for(:with_each) unless block_given?
14
+
15
+ names.each { |name| with(name, &block) }
16
+ end
17
+
18
+ def with(name, &block)
19
+ Record.with_shard(name, &block)
20
+ end
21
+
22
+ def with_connection_for(key, &block)
23
+ with(shard_for(key), &block)
24
+ end
25
+
26
+ def assign(keys)
27
+ keys.group_by { |key| shard_for(key.is_a?(Hash) ? key[:key] : key) }
28
+ end
29
+
30
+ def count
31
+ names.count
32
+ end
33
+
34
+ private
35
+ def shard_for(key)
36
+ nodes[consistent_hash.node(key)]
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,37 @@
1
+ module SolidCache
2
+ module Connections
3
+ class Single
4
+ attr_reader :name
5
+
6
+ def initialize(name)
7
+ @name = name
8
+ end
9
+
10
+ def with_each(&block)
11
+ return enum_for(:with_each) unless block_given?
12
+
13
+ with(name, &block)
14
+ end
15
+
16
+ def with(name, &block)
17
+ Record.with_shard(name, &block)
18
+ end
19
+
20
+ def with_connection_for(key, &block)
21
+ with(name, &block)
22
+ end
23
+
24
+ def assign(keys)
25
+ { name => keys }
26
+ end
27
+
28
+ def count
29
+ 1
30
+ end
31
+
32
+ def names
33
+ [ name ]
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,31 @@
1
+ module SolidCache
2
+ module Connections
3
+ class Unmanaged
4
+ def with_each
5
+ return enum_for(:with_each) unless block_given?
6
+
7
+ yield
8
+ end
9
+
10
+ def with(name)
11
+ yield
12
+ end
13
+
14
+ def with_connection_for(key)
15
+ yield
16
+ end
17
+
18
+ def assign(keys)
19
+ { default: keys }
20
+ end
21
+
22
+ def count
23
+ 1
24
+ end
25
+
26
+ def names
27
+ [ :default ]
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,31 @@
1
+ module SolidCache
2
+ module Connections
3
+ def self.from_config(options)
4
+ if options.present? || SolidCache.all_shards_config.present?
5
+ case options
6
+ when NilClass
7
+ names = SolidCache.all_shard_keys
8
+ nodes = names.to_h { |name| [ name, name ] }
9
+ when Array
10
+ names = options
11
+ nodes = names.to_h { |name| [ name, name ] }
12
+ when Hash
13
+ names = options.keys
14
+ nodes = options.invert
15
+ end
16
+
17
+ if (unknown_shards = names - SolidCache.all_shard_keys).any?
18
+ raise ArgumentError, "Unknown #{"shard".pluralize(unknown_shards)}: #{unknown_shards.join(", ")}"
19
+ end
20
+
21
+ if names.size == 1
22
+ Single.new(names.first)
23
+ else
24
+ Sharded.new(names, nodes)
25
+ end
26
+ else
27
+ Unmanaged.new
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,20 @@
1
+ require "active_support"
2
+
3
+ module SolidCache
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace SolidCache
6
+
7
+ config.solid_cache = ActiveSupport::OrderedOptions.new
8
+
9
+ initializer "solid_cache", before: :run_prepare_callbacks do |app|
10
+ config.solid_cache.executor ||= app.executor
11
+
12
+ SolidCache.executor = config.solid_cache.executor
13
+ SolidCache.connects_to = config.solid_cache.connects_to
14
+ end
15
+
16
+ config.after_initialize do
17
+ Rails.cache.setup! if Rails.cache.is_a?(Store)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,77 @@
1
+ # See https://static.googleusercontent.com/media/research.google.com/en//pubs/archive/44824.pdf
2
+
3
+ module SolidCache
4
+ class MaglevHash
5
+ attr_reader :nodes
6
+
7
+ #  Must be prime
8
+ TABLE_SIZE = 2053
9
+
10
+ def initialize(nodes)
11
+ raise ArgumentError, "No nodes specified" if nodes.count == 0
12
+ raise ArgumentError, "Maximum node count is #{TABLE_SIZE}" if nodes.count > TABLE_SIZE
13
+
14
+ @nodes = nodes.uniq.sort
15
+ @lookup = build_lookup
16
+ end
17
+
18
+ def node(key)
19
+ nodes[lookup[quick_hash(key) % TABLE_SIZE]]
20
+ end
21
+
22
+ private
23
+ attr_reader :lookup, :node_count
24
+
25
+ def build_lookup
26
+ lookup = Array.new(TABLE_SIZE, nil)
27
+
28
+ node_preferences = nodes.map { |node| build_preferences(node) }
29
+ node_count = nodes.count
30
+
31
+ TABLE_SIZE.times do |i|
32
+ node_index = i % node_count
33
+ preferences = node_preferences[node_index]
34
+ slot = preferences.preferred_free_slot(lookup)
35
+ lookup[slot] = node_index
36
+ end
37
+
38
+ lookup
39
+ end
40
+
41
+ def build_preferences(node)
42
+ offset = md5(node, :offset) % TABLE_SIZE
43
+ skip = md5(node, :skip) % (TABLE_SIZE - 1) + 1
44
+
45
+ Preferences.new(offset, skip)
46
+ end
47
+
48
+ def md5(*args)
49
+ ::Digest::MD5.digest(args.join).unpack1("L>")
50
+ end
51
+
52
+ def quick_hash(key)
53
+ Zlib.crc32(key.to_s)
54
+ end
55
+
56
+ class Preferences
57
+ def initialize(offset, skip)
58
+ @preferred_slots = TABLE_SIZE.times.map { |i| (offset + i * skip) % TABLE_SIZE }
59
+ @rank = 0
60
+ end
61
+
62
+ def preferred_free_slot(lookup)
63
+ loop do
64
+ slot = next_slot
65
+ return slot if lookup[slot].nil?
66
+ end
67
+ end
68
+
69
+ private
70
+ attr_reader :rank, :preferred_slots
71
+
72
+ def next_slot
73
+ preferred_slots[rank].tap { @rank += 1 }
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,153 @@
1
+ module SolidCache
2
+ class Store
3
+ module Api
4
+ DEFAULT_MAX_KEY_BYTESIZE = 1024
5
+ SQL_WILDCARD_CHARS = [ "_", "%" ]
6
+
7
+ attr_reader :max_key_bytesize
8
+
9
+ def initialize(options = {})
10
+ super(options)
11
+
12
+ @max_key_bytesize = options.fetch(:max_key_bytesize, DEFAULT_MAX_KEY_BYTESIZE)
13
+ end
14
+
15
+ def delete_matched(matcher, options = {})
16
+ instrument :delete_matched, matcher do
17
+ raise ArgumentError, "Only strings are supported: #{matcher.inspect}" unless String === matcher
18
+ raise ArgumentError, "Strings cannot start with wildcards" if SQL_WILDCARD_CHARS.include?(matcher[0])
19
+
20
+ options ||= {}
21
+ batch_size = options.fetch(:batch_size, 1000)
22
+
23
+ matcher = namespace_key(matcher, options)
24
+
25
+ entry_delete_matched(matcher, batch_size)
26
+ end
27
+ end
28
+
29
+ def increment(name, amount = 1, options = nil)
30
+ options = merged_options(options)
31
+ key = normalize_key(name, options)
32
+
33
+ entry_increment(key, amount)
34
+ end
35
+
36
+ def decrement(name, amount = 1, options = nil)
37
+ options = merged_options(options)
38
+ key = normalize_key(name, options)
39
+
40
+ entry_decrement(key, amount)
41
+ end
42
+
43
+ def cleanup(options = nil)
44
+ raise NotImplementedError.new("#{self.class.name} does not support cleanup")
45
+ end
46
+
47
+ def clear(options = nil)
48
+ entry_clear
49
+ end
50
+
51
+ private
52
+ def read_entry(key, **options)
53
+ deserialize_entry(read_serialized_entry(key, **options), **options)
54
+ end
55
+
56
+ def read_serialized_entry(key, raw: false, **options)
57
+ entry_read(key)
58
+ end
59
+
60
+ def write_entry(key, entry, raw: false, **options)
61
+ payload = serialize_entry(entry, raw: raw, **options)
62
+ # No-op for us, but this writes it to the local cache
63
+ write_serialized_entry(key, payload, raw: raw, **options)
64
+
65
+ entry_write(key, payload)
66
+ end
67
+
68
+ def write_serialized_entry(key, payload, raw: false, unless_exist: false, expires_in: nil, race_condition_ttl: nil, **options)
69
+ true
70
+ end
71
+
72
+ def read_serialized_entries(keys)
73
+ entry_read_multi(keys).reduce(&:merge!)
74
+ end
75
+
76
+ def read_multi_entries(names, **options)
77
+ keys_and_names = names.to_h { |name| [ normalize_key(name, options), name ] }
78
+ serialized_entries = read_serialized_entries(keys_and_names.keys)
79
+
80
+ keys_and_names.each_with_object({}) do |(key, name), results|
81
+ serialized_entry = serialized_entries[key]
82
+ entry = deserialize_entry(serialized_entry, **options)
83
+
84
+ next unless entry
85
+
86
+ version = normalize_version(name, options)
87
+
88
+ if entry.expired?
89
+ delete_entry(key, **options)
90
+ elsif !entry.mismatched?(version)
91
+ results[name] = entry.value
92
+ end
93
+ end
94
+ end
95
+
96
+ def write_multi_entries(entries, expires_in: nil, **options)
97
+ if entries.any?
98
+ serialized_entries = serialize_entries(entries, **options)
99
+ # to add them to the local cache
100
+ serialized_entries.each do |entries|
101
+ write_serialized_entry(entries[:key], entries[:value])
102
+ end
103
+
104
+ entry_write_multi(serialized_entries).all?
105
+ end
106
+ end
107
+
108
+ def delete_entry(key, **options)
109
+ entry_delete(key)
110
+ end
111
+
112
+ def delete_multi_entries(entries, **options)
113
+ entries.count { |key| delete_entry(key, **options) }
114
+ end
115
+
116
+ def serialize_entry(entry, raw: false, **options)
117
+ if raw
118
+ entry.value.to_s
119
+ else
120
+ super(entry, raw: raw, **options)
121
+ end
122
+ end
123
+
124
+ def serialize_entries(entries, **options)
125
+ entries.map do |key, entry|
126
+ { key: key, value: serialize_entry(entry, **options) }
127
+ end
128
+ end
129
+
130
+ def deserialize_entry(payload, raw: false, **)
131
+ if payload && raw
132
+ ActiveSupport::Cache::Entry.new(payload)
133
+ else
134
+ super(payload)
135
+ end
136
+ end
137
+
138
+ def normalize_key(key, options)
139
+ truncate_key super&.b
140
+ end
141
+
142
+ def truncate_key(key)
143
+ if key && key.bytesize > max_key_bytesize
144
+ suffix = ":hash:#{ActiveSupport::Digest.hexdigest(key)}"
145
+ truncate_at = max_key_bytesize - suffix.bytesize
146
+ "#{key.byteslice(0, truncate_at)}#{suffix}".b
147
+ else
148
+ key
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,85 @@
1
+ module SolidCache
2
+ class Store
3
+ module Clusters
4
+ attr_reader :primary_cluster, :clusters
5
+
6
+ def initialize(options = {})
7
+ super(options)
8
+
9
+ clusters_options = options.fetch(:clusters) { [ options.fetch(:cluster, {}) ] }
10
+
11
+ @clusters = clusters_options.map.with_index do |cluster_options, index|
12
+ Cluster.new(options.merge(cluster_options).merge(async_writes: index != 0))
13
+ end
14
+
15
+ @primary_cluster = clusters.first
16
+ end
17
+
18
+ def setup!
19
+ clusters.each(&:setup!)
20
+ end
21
+
22
+ private
23
+ def reading_key(key, failsafe:, failsafe_returning: nil)
24
+ failsafe(failsafe, returning: failsafe_returning) do
25
+ primary_cluster.with_connection_for(key) do
26
+ yield
27
+ end
28
+ end
29
+ end
30
+
31
+ def reading_keys(keys, failsafe:, failsafe_returning: nil)
32
+ connection_keys = primary_cluster.group_by_connection(keys)
33
+
34
+ connection_keys.map do |connection, keys|
35
+ failsafe(failsafe, returning: failsafe_returning) do
36
+ primary_cluster.with_connection(connection) do
37
+ yield keys
38
+ end
39
+ end
40
+ end
41
+ end
42
+
43
+
44
+ def writing_key(key, failsafe:, failsafe_returning: nil)
45
+ first_cluster_sync_rest_async do |cluster, async|
46
+ failsafe(failsafe, returning: failsafe_returning) do
47
+ cluster.with_connection_for(key, async: async) do
48
+ yield cluster
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ def writing_keys(entries, failsafe:, failsafe_returning: nil)
55
+ first_cluster_sync_rest_async do |cluster, async|
56
+ connection_entries = cluster.group_by_connection(entries)
57
+
58
+ connection_entries.map do |connection, entries|
59
+ failsafe(failsafe, returning: failsafe_returning) do
60
+ cluster.with_connection(connection, async: async) do
61
+ yield cluster, entries
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+
68
+ def writing_all(failsafe:, failsafe_returning: nil)
69
+ first_cluster_sync_rest_async do |cluster, async|
70
+ cluster.connection_names.each do |connection|
71
+ failsafe(failsafe, returning: failsafe_returning) do
72
+ cluster.with_connection(connection, async: async) do
73
+ yield
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+
80
+ def first_cluster_sync_rest_async
81
+ clusters.map.with_index { |cluster, index| yield cluster, index != 0 }.first
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,81 @@
1
+ module SolidCache
2
+ class Store
3
+ module Entries
4
+ attr_reader :clear_with
5
+
6
+ def initialize(options = {})
7
+ super(options)
8
+
9
+ # Truncating in test mode breaks transactional tests in MySQL (not in Postgres though)
10
+ @clear_with = options.fetch(:clear_with) { Rails.env.test? ? :delete : :truncate }&.to_sym
11
+
12
+ unless [ :truncate, :delete ].include?(clear_with)
13
+ raise ArgumentError, "`clear_with` must be either ``:truncate`` or ``:delete`"
14
+ end
15
+ end
16
+
17
+ private
18
+ def entry_delete_matched(matcher, batch_size)
19
+ writing_all(failsafe: :delete_matched) do
20
+ Entry.delete_matched(matcher, batch_size: batch_size)
21
+ end
22
+ end
23
+
24
+ def entry_clear
25
+ writing_all(failsafe: :clear) do
26
+ if clear_with == :truncate
27
+ Entry.clear_truncate
28
+ else
29
+ Entry.clear_delete
30
+ end
31
+ end
32
+ end
33
+
34
+ def entry_increment(key, amount)
35
+ writing_key(key, failsafe: :increment) do
36
+ Entry.increment(key, amount)
37
+ end
38
+ end
39
+
40
+ def entry_decrement(key, amount)
41
+ writing_key(key, failsafe: :decrement) do
42
+ Entry.decrement(key, amount)
43
+ end
44
+ end
45
+
46
+ def entry_read(key)
47
+ reading_key(key, failsafe: :read_entry) do
48
+ Entry.read(key)
49
+ end
50
+ end
51
+
52
+ def entry_read_multi(keys)
53
+ reading_keys(keys, failsafe: :read_multi_mget, failsafe_returning: {}) do |keys|
54
+ Entry.read_multi(keys)
55
+ end
56
+ end
57
+
58
+ def entry_write(key, payload)
59
+ writing_key(key, failsafe: :write_entry, failsafe_returning: false) do |cluster|
60
+ Entry.write(key, payload)
61
+ cluster.track_writes(1)
62
+ true
63
+ end
64
+ end
65
+
66
+ def entry_write_multi(entries)
67
+ writing_keys(entries, failsafe: :write_multi_entries, failsafe_returning: false) do |cluster, entries|
68
+ Entry.write_multi(entries)
69
+ cluster.track_writes(entries.count)
70
+ true
71
+ end
72
+ end
73
+
74
+ def entry_delete(key)
75
+ writing_key(key, failsafe: :delete_entry, failsafe_returning: false) do
76
+ Entry.delete_by_key(key)
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,28 @@
1
+ module SolidCache
2
+ class Store
3
+ module Failsafe
4
+ DEFAULT_ERROR_HANDLER = ->(method:, returning:, exception:) do
5
+ if Store.logger
6
+ Store.logger.error { "SolidCacheStore: #{method} failed, returned #{returning.inspect}: #{exception.class}: #{exception.message}" }
7
+ end
8
+ end
9
+
10
+ def initialize(options = {})
11
+ super(options)
12
+
13
+ @error_handler = options.fetch(:error_handler, DEFAULT_ERROR_HANDLER)
14
+ end
15
+
16
+ private
17
+ attr_reader :error_handler
18
+
19
+ def failsafe(method, returning: nil)
20
+ yield
21
+ rescue ActiveRecord::ActiveRecordError => error
22
+ ActiveSupport.error_reporter&.report(error, handled: true, severity: :warning)
23
+ error_handler&.call(method: method, exception: error, returning: returning)
24
+ returning
25
+ end
26
+ end
27
+ end
28
+ end