solid_cache 0.1.0 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +44 -14
- data/Rakefile +2 -0
- data/app/jobs/solid_cache/expiry_job.rb +2 -0
- data/app/models/solid_cache/entry.rb +27 -13
- data/app/models/solid_cache/record.rb +7 -7
- data/db/migrate/20230724121448_create_solid_cache_entries.rb +2 -0
- data/lib/active_support/cache/solid_cache_store.rb +2 -0
- data/lib/generators/solid_cache/install/install_generator.rb +2 -0
- data/lib/solid_cache/cluster/connections.rb +2 -0
- data/lib/solid_cache/cluster/execution.rb +2 -0
- data/lib/solid_cache/cluster/expiry.rb +3 -1
- data/lib/solid_cache/cluster/stats.rb +3 -1
- data/lib/solid_cache/cluster.rb +1 -0
- data/lib/solid_cache/connections/sharded.rb +2 -0
- data/lib/solid_cache/connections/single.rb +2 -0
- data/lib/solid_cache/connections/unmanaged.rb +2 -0
- data/lib/solid_cache/connections.rb +2 -0
- data/lib/solid_cache/engine.rb +2 -0
- data/lib/solid_cache/maglev_hash.rb +3 -1
- data/lib/solid_cache/store/api.rb +11 -2
- data/lib/solid_cache/store/clusters.rb +6 -8
- data/lib/solid_cache/store/entries.rb +2 -0
- data/lib/solid_cache/store/failsafe.rb +2 -0
- data/lib/solid_cache/store.rb +2 -0
- data/lib/solid_cache/version.rb +1 -1
- data/lib/solid_cache.rb +4 -2
- data/lib/tasks/solid_cache_tasks.rake +2 -0
- metadata +32 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d92ba4d7c045821d0c54facd1329613e497a46a2b21daffe3a14931bf9621f86
|
4
|
+
data.tar.gz: 46c4b4b81c48a5a5598cc2460e8c1159e69497f130116e9f855a58f8f3e52a36
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4f3505047c30f24d6f8928d439172f826becc79054a05f32b0c670b3cbb227a10b52889b1e64e2430fe2d279e98c6615bb92634c475fdb6016cd7d3c9c170c07
|
7
|
+
data.tar.gz: 4cb0c61cddd64a4d8093b73b06524ae5585cf5d0c70233bd31fd688b837d38c500df28479bbbb7ffa99b24e26dc3230789dad0d108f9d7fc3df4e02b0eeb0d55
|
data/README.md
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
# Solid Cache
|
2
2
|
|
3
|
-
Solid Cache is a database-backed Active Support cache store implementation.
|
3
|
+
Solid Cache is a database-backed Active Support cache store implementation.
|
4
4
|
|
5
5
|
Using SQL databases backed by SSDs we can have caches that are much larger and cheaper than traditional memory only Redis or Memcached backed caches.
|
6
6
|
|
@@ -58,7 +58,7 @@ $ bin/rails db:migrate
|
|
58
58
|
There are two options that can be set on the engine:
|
59
59
|
|
60
60
|
- `executor` - the [Rails executor](https://guides.rubyonrails.org/threading_and_code_execution.html#executor) used to wrap asynchronous operations, defaults to the app executor
|
61
|
-
- `connects_to` - a custom connects to value for the abstract `SolidCache::Record` active record model.
|
61
|
+
- `connects_to` - a custom connects to value for the abstract `SolidCache::Record` active record model. Required for sharding and/or using a separate cache database to the main app.
|
62
62
|
|
63
63
|
These can be set in your Rails configuration:
|
64
64
|
|
@@ -80,19 +80,19 @@ Solid Cache supports these options in addition to the standard `ActiveSupport::C
|
|
80
80
|
- `error_handler` - a Proc to call to handle any `ActiveRecord::ActiveRecordError`s that are raises (default: log errors as warnings)
|
81
81
|
- `expiry_batch_size` - the batch size to use when deleting old records (default: `100`)
|
82
82
|
- `expiry_method` - what expiry method to use `thread` or `job` (default: `thread`)
|
83
|
-
- `max_age` - the maximum age of entries in the cache (default: `2.weeks.to_i`)
|
84
|
-
- `max_entries` - the maximum number of entries allowed in the cache (default: `
|
83
|
+
- `max_age` - the maximum age of entries in the cache (default: `2.weeks.to_i`). Can be set to `nil`, but this is not recommended unless using `max_entries` to limit the size of the cache.
|
84
|
+
- `max_entries` - the maximum number of entries allowed in the cache (default: `nil`, meaning no limit)
|
85
85
|
- `cluster` - a Hash of options for the cache database cluster, e.g `{ shards: [:database1, :database2, :database3] }`
|
86
86
|
- `clusters` - and Array of Hashes for multiple cache clusters (ignored if `:cluster` is set)
|
87
87
|
- `active_record_instrumentation` - whether to instrument the cache's queries (default: `true`)
|
88
|
-
- `clear_with` - clear the cache with `:truncate` or `:delete` (default `truncate`, except for when Rails.env.test
|
88
|
+
- `clear_with` - clear the cache with `:truncate` or `:delete` (default `truncate`, except for when `Rails.env.test?` then `delete`)
|
89
89
|
- `max_key_bytesize` - the maximum size of a normalized key in bytes (default `1024`)
|
90
90
|
|
91
91
|
For more information on cache clusters see [Sharding the cache](#sharding-the-cache)
|
92
92
|
|
93
93
|
### Cache expiry
|
94
94
|
|
95
|
-
Solid Cache tracks writes to the cache. For every write it increments a counter by 1. Once the counter reaches 80% of the `expiry_batch_size` it
|
95
|
+
Solid Cache tracks writes to the cache. For every write it increments a counter by 1. Once the counter reaches 80% of the `expiry_batch_size` it adds a task to run on a background thread. That task will:
|
96
96
|
|
97
97
|
1. Check if we have exceeded the `max_entries` value (if set) by subtracting the max and min IDs from the `SolidCache::Entry` table (this is an estimate that ignores any gaps).
|
98
98
|
2. If we have it will delete `expiry_batch_size` entries
|
@@ -109,7 +109,7 @@ If you want the cache expiry to be run in a background job instead of a thread,
|
|
109
109
|
Add database configuration to database.yml, e.g.:
|
110
110
|
|
111
111
|
```
|
112
|
-
development
|
112
|
+
development:
|
113
113
|
cache:
|
114
114
|
database: cache_development
|
115
115
|
host: 127.0.0.1
|
@@ -135,7 +135,7 @@ $ mv db/migrate/*.solid_cache.rb db/cache/migrate
|
|
135
135
|
Set the engine configuration to point to the new database:
|
136
136
|
```
|
137
137
|
Rails.application.configure do
|
138
|
-
config.solid_cache.connects_to = {
|
138
|
+
config.solid_cache.connects_to = { database: { writing: :cache } }
|
139
139
|
end
|
140
140
|
```
|
141
141
|
|
@@ -155,7 +155,7 @@ To shard:
|
|
155
155
|
3. Pass the shards for the cache to use via the cluster option
|
156
156
|
|
157
157
|
For example:
|
158
|
-
```
|
158
|
+
```yml
|
159
159
|
# config/database.yml
|
160
160
|
production:
|
161
161
|
cache_shard1:
|
@@ -167,8 +167,9 @@ production:
|
|
167
167
|
cache_shard3:
|
168
168
|
database: cache3_production
|
169
169
|
host: cache3-db
|
170
|
+
```
|
170
171
|
|
171
|
-
|
172
|
+
```ruby
|
172
173
|
# config/environment/production.rb
|
173
174
|
Rails.application.configure do
|
174
175
|
config.solid_cache.connects_to = {
|
@@ -203,7 +204,7 @@ Rails.application.configure do
|
|
203
204
|
}
|
204
205
|
|
205
206
|
primary_cluster = { shards: [ :cache_primary_shard1, :cache_primary_shard2 ] }
|
206
|
-
secondary_cluster = { shards: [ :
|
207
|
+
secondary_cluster = { shards: [ :cache_secondary_shard1, :cache_secondary_shard2 ] }
|
207
208
|
config.cache_store = [ :solid_cache_store, clusters: [ primary_cluster, secondary_cluster ] ]
|
208
209
|
end
|
209
210
|
```
|
@@ -250,19 +251,48 @@ The Solid Cache migrations try to create an index with 1024 byte entries. If tha
|
|
250
251
|
|
251
252
|
## Development
|
252
253
|
|
253
|
-
Run the tests with `bin/rails test`.
|
254
|
+
Run the tests with `bin/rails test`. By default, these will run against SQLite.
|
254
255
|
|
255
|
-
You can also run the tests against MySQL and
|
256
|
+
You can also run the tests against MySQL and PostgreSQL. First start up the databases:
|
256
257
|
|
257
258
|
```shell
|
258
259
|
$ docker compose up -d
|
259
260
|
```
|
260
261
|
|
261
|
-
|
262
|
+
Next, setup the database schema:
|
263
|
+
|
264
|
+
```shell
|
265
|
+
$ TARGET_DB=mysql bin/rails db:setup
|
266
|
+
$ TARGET_DB=postgres bin/rails db:setup
|
262
267
|
```
|
268
|
+
|
269
|
+
|
270
|
+
Then run the tests for the target database:
|
271
|
+
|
272
|
+
```shell
|
263
273
|
$ TARGET_DB=mysql bin/rails test
|
264
274
|
$ TARGET_DB=postgres bin/rails test
|
265
275
|
```
|
266
276
|
|
277
|
+
### Testing with multiple Rails version
|
278
|
+
|
279
|
+
Solid Cache relies on [appraisal](https://github.com/thoughtbot/appraisal/tree/main) to test
|
280
|
+
multiple Rails version.
|
281
|
+
|
282
|
+
To run a test for a specific version run:
|
283
|
+
|
284
|
+
```shell
|
285
|
+
bundle exec appraisal rails-7-1 bin/rails test
|
286
|
+
```
|
287
|
+
|
288
|
+
After updating the dependencies in then `Gemfile` please run:
|
289
|
+
|
290
|
+
```shell
|
291
|
+
$ bundle
|
292
|
+
$ appraisal update
|
293
|
+
```
|
294
|
+
|
295
|
+
This ensures that all the Rails versions dependencies are updated.
|
296
|
+
|
267
297
|
## License
|
268
298
|
Solid Cache is licensed under MIT.
|
data/Rakefile
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module SolidCache
|
2
4
|
class Entry < Record
|
3
5
|
# This is all quite awkward but it achieves a couple of performance aims
|
@@ -21,10 +23,6 @@ module SolidCache
|
|
21
23
|
select_all_no_query_cache(get_all_sql(serialized_keys), serialized_keys).to_h
|
22
24
|
end
|
23
25
|
|
24
|
-
def expire(ids)
|
25
|
-
delete_no_query_cache(:id, ids) if ids.any?
|
26
|
-
end
|
27
|
-
|
28
26
|
def delete_by_key(key)
|
29
27
|
delete_no_query_cache(:key, to_binary(key))
|
30
28
|
end
|
@@ -78,8 +76,12 @@ module SolidCache
|
|
78
76
|
message = +"#{self} "
|
79
77
|
message << "Bulk " if attributes.many?
|
80
78
|
message << "Upsert"
|
81
|
-
#
|
82
|
-
connection.
|
79
|
+
# exec_query_method does not clear the query cache, exec_insert_all does
|
80
|
+
connection.send exec_query_method, sql, message
|
81
|
+
end
|
82
|
+
|
83
|
+
def exec_query_method
|
84
|
+
connection.respond_to?(:internal_exec_query) ? :internal_exec_query : :exec_query
|
83
85
|
end
|
84
86
|
|
85
87
|
def upsert_unique_by
|
@@ -113,7 +115,7 @@ module SolidCache
|
|
113
115
|
if connection.prepared_statements?
|
114
116
|
result = connection.select_all(sanitize_sql(query), "#{name} Load", Array(values), preparable: true)
|
115
117
|
else
|
116
|
-
result = connection.select_all(sanitize_sql([ query, values ]), "#{name} Load",
|
118
|
+
result = connection.select_all(sanitize_sql([ query, values ]), "#{name} Load", Array(values), preparable: false)
|
117
119
|
end
|
118
120
|
|
119
121
|
result.cast_values(SolidCache::Entry.attribute_types)
|
@@ -140,14 +142,26 @@ module SolidCache
|
|
140
142
|
|
141
143
|
def expiry_candidate_ids(count, max_age:, max_entries:)
|
142
144
|
cache_full = max_entries && max_entries < id_range
|
143
|
-
|
145
|
+
return [] unless cache_full || max_age
|
146
|
+
|
147
|
+
# In the case of multiple concurrent expiry operations, it is desirable to
|
148
|
+
# reduce the overlap of entries being addressed by each. For that reason,
|
149
|
+
# retrieve more ids than are being expired, and use random
|
150
|
+
# sampling to reduce that number to the actual intended count.
|
151
|
+
retrieve_count = count * 3
|
144
152
|
|
145
153
|
uncached do
|
146
|
-
order(:id)
|
147
|
-
|
148
|
-
|
149
|
-
.
|
150
|
-
|
154
|
+
candidates = order(:id).limit(retrieve_count)
|
155
|
+
|
156
|
+
candidate_ids = if cache_full
|
157
|
+
candidates.pluck(:id)
|
158
|
+
else
|
159
|
+
min_created_at = max_age.seconds.ago
|
160
|
+
candidates.pluck(:id, :created_at)
|
161
|
+
.filter_map { |id, created_at| id if created_at < min_created_at }
|
162
|
+
end
|
163
|
+
|
164
|
+
candidate_ids.sample(count)
|
151
165
|
end
|
152
166
|
end
|
153
167
|
end
|
@@ -1,21 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module SolidCache
|
2
4
|
class Record < ActiveRecord::Base
|
3
5
|
NULL_INSTRUMENTER = ActiveSupport::Notifications::Instrumenter.new(ActiveSupport::Notifications::Fanout.new)
|
4
6
|
|
5
7
|
self.abstract_class = true
|
6
8
|
|
7
|
-
connects_to
|
9
|
+
connects_to(**SolidCache.connects_to) if SolidCache.connects_to
|
8
10
|
|
9
11
|
class << self
|
10
|
-
def disable_instrumentation
|
11
|
-
connection.with_instrumenter(NULL_INSTRUMENTER)
|
12
|
-
yield
|
13
|
-
end
|
12
|
+
def disable_instrumentation(&block)
|
13
|
+
connection.with_instrumenter(NULL_INSTRUMENTER, &block)
|
14
14
|
end
|
15
15
|
|
16
16
|
def with_shard(shard, &block)
|
17
|
-
if shard &&
|
18
|
-
|
17
|
+
if shard && SolidCache.connects_to
|
18
|
+
connected_to(shard: shard, role: default_role, prevent_writes: false, &block)
|
19
19
|
else
|
20
20
|
block.call
|
21
21
|
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require "concurrent/atomic/atomic_fixnum"
|
2
4
|
|
3
5
|
module SolidCache
|
@@ -34,7 +36,7 @@ module SolidCache
|
|
34
36
|
end
|
35
37
|
|
36
38
|
def expiry_counter
|
37
|
-
@expiry_counters ||= connection_names.
|
39
|
+
@expiry_counters ||= connection_names.index_with { |connection_name| Counter.new(expire_every) }
|
38
40
|
@expiry_counters[Entry.current_shard]
|
39
41
|
end
|
40
42
|
|
data/lib/solid_cache/cluster.rb
CHANGED
data/lib/solid_cache/engine.rb
CHANGED
@@ -1,10 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
# See https://static.googleusercontent.com/media/research.google.com/en//pubs/archive/44824.pdf
|
2
4
|
|
3
5
|
module SolidCache
|
4
6
|
class MaglevHash
|
5
7
|
attr_reader :nodes
|
6
8
|
|
7
|
-
#
|
9
|
+
# Must be prime
|
8
10
|
TABLE_SIZE = 2053
|
9
11
|
|
10
12
|
def initialize(nodes)
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module SolidCache
|
2
4
|
class Store
|
3
5
|
module Api
|
@@ -74,7 +76,7 @@ module SolidCache
|
|
74
76
|
end
|
75
77
|
|
76
78
|
def read_multi_entries(names, **options)
|
77
|
-
keys_and_names = names.
|
79
|
+
keys_and_names = names.index_by { |name| normalize_key(name, options) }
|
78
80
|
serialized_entries = read_serialized_entries(keys_and_names.keys)
|
79
81
|
|
80
82
|
keys_and_names.each_with_object({}) do |(key, name), results|
|
@@ -88,7 +90,14 @@ module SolidCache
|
|
88
90
|
if entry.expired?
|
89
91
|
delete_entry(key, **options)
|
90
92
|
elsif !entry.mismatched?(version)
|
91
|
-
|
93
|
+
if defined? ActiveSupport::Cache::DeserializationError
|
94
|
+
begin
|
95
|
+
results[name] = entry.value
|
96
|
+
rescue ActiveSupport::Cache::DeserializationError
|
97
|
+
end
|
98
|
+
else
|
99
|
+
results[name] = entry.value
|
100
|
+
end
|
92
101
|
end
|
93
102
|
end
|
94
103
|
end
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module SolidCache
|
2
4
|
class Store
|
3
5
|
module Clusters
|
@@ -20,11 +22,9 @@ module SolidCache
|
|
20
22
|
end
|
21
23
|
|
22
24
|
private
|
23
|
-
def reading_key(key, failsafe:, failsafe_returning: nil)
|
25
|
+
def reading_key(key, failsafe:, failsafe_returning: nil, &block)
|
24
26
|
failsafe(failsafe, returning: failsafe_returning) do
|
25
|
-
primary_cluster.with_connection_for(key)
|
26
|
-
yield
|
27
|
-
end
|
27
|
+
primary_cluster.with_connection_for(key, &block)
|
28
28
|
end
|
29
29
|
end
|
30
30
|
|
@@ -65,13 +65,11 @@ module SolidCache
|
|
65
65
|
end
|
66
66
|
end
|
67
67
|
|
68
|
-
def writing_all(failsafe:, failsafe_returning: nil)
|
68
|
+
def writing_all(failsafe:, failsafe_returning: nil, &block)
|
69
69
|
first_cluster_sync_rest_async do |cluster, async|
|
70
70
|
cluster.connection_names.each do |connection|
|
71
71
|
failsafe(failsafe, returning: failsafe_returning) do
|
72
|
-
cluster.with_connection(connection, async: async)
|
73
|
-
yield
|
74
|
-
end
|
72
|
+
cluster.with_connection(connection, async: async, &block)
|
75
73
|
end
|
76
74
|
end
|
77
75
|
end
|
data/lib/solid_cache/store.rb
CHANGED
data/lib/solid_cache/version.rb
CHANGED
data/lib/solid_cache.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require "zeitwerk"
|
2
4
|
require "solid_cache/engine"
|
3
5
|
|
@@ -17,12 +19,12 @@ module SolidCache
|
|
17
19
|
connects_to && connects_to[:shards]
|
18
20
|
end
|
19
21
|
|
20
|
-
def self.each_shard
|
22
|
+
def self.each_shard(&block)
|
21
23
|
return to_enum(:each_shard) unless block_given?
|
22
24
|
|
23
25
|
if (shards = all_shards_config&.keys)
|
24
26
|
shards.each do |shard|
|
25
|
-
Record.
|
27
|
+
Record.with_shard(shard, &block)
|
26
28
|
end
|
27
29
|
else
|
28
30
|
yield
|
metadata
CHANGED
@@ -1,17 +1,45 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: solid_cache
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Donal McBreen
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2024-01-08 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
|
-
name:
|
14
|
+
name: activerecord
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '7'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '7'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: activejob
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '7'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '7'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: railties
|
15
43
|
requirement: !ruby/object:Gem::Requirement
|
16
44
|
requirements:
|
17
45
|
- - ">="
|
@@ -109,7 +137,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
109
137
|
- !ruby/object:Gem::Version
|
110
138
|
version: '0'
|
111
139
|
requirements: []
|
112
|
-
rubygems_version: 3.
|
140
|
+
rubygems_version: 3.5.1
|
113
141
|
signing_key:
|
114
142
|
specification_version: 4
|
115
143
|
summary: A database backed ActiveSupport::Cache::Store
|