solid_cache 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +268 -0
- data/Rakefile +8 -0
- data/app/jobs/solid_cache/expiry_job.rb +9 -0
- data/app/models/solid_cache/entry.rb +157 -0
- data/app/models/solid_cache/record.rb +27 -0
- data/db/migrate/20230724121448_create_solid_cache_entries.rb +11 -0
- data/lib/active_support/cache/solid_cache_store.rb +5 -0
- data/lib/generators/solid_cache/install/USAGE +9 -0
- data/lib/generators/solid_cache/install/install_generator.rb +18 -0
- data/lib/solid_cache/cluster/connections.rb +51 -0
- data/lib/solid_cache/cluster/execution.rb +52 -0
- data/lib/solid_cache/cluster/expiry.rb +61 -0
- data/lib/solid_cache/cluster/stats.rb +32 -0
- data/lib/solid_cache/cluster.rb +14 -0
- data/lib/solid_cache/connections/sharded.rb +40 -0
- data/lib/solid_cache/connections/single.rb +37 -0
- data/lib/solid_cache/connections/unmanaged.rb +31 -0
- data/lib/solid_cache/connections.rb +31 -0
- data/lib/solid_cache/engine.rb +20 -0
- data/lib/solid_cache/maglev_hash.rb +77 -0
- data/lib/solid_cache/store/api.rb +153 -0
- data/lib/solid_cache/store/clusters.rb +85 -0
- data/lib/solid_cache/store/entries.rb +81 -0
- data/lib/solid_cache/store/failsafe.rb +28 -0
- data/lib/solid_cache/store.rb +18 -0
- data/lib/solid_cache/version.rb +3 -0
- data/lib/solid_cache.rb +33 -0
- data/lib/tasks/solid_cache_tasks.rake +6 -0
- metadata +116 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: '0649423e526a66fa9554f73d215e7e49c6ff093c004c1641d72a670a33688468'
|
4
|
+
data.tar.gz: ad56d9f53b6b0c6f9dcc049771a187e95c5e2cceeabeaa07286f96212f5b0fb7
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 900011771476bff6c3b3ec5abe087a0dcf00596f6e200dec961aa7e13f3ad4d978d9ac8858d3912b8bc0bc63a8742de4e925bd04b120d15badb890448946f4c2
|
7
|
+
data.tar.gz: c9fd66e658ec1e38f628c592053f330179571a7299a1d4e6bae88a959996c10c4d7df158c30c9827464e0c3af526a4b5c0100f55f51c5b3e563e73b5af93226a
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2023 37signals
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,268 @@
|
|
1
|
+
# Solid Cache
|
2
|
+
|
3
|
+
Solid Cache is a database-backed Active Support cache store implementation.
|
4
|
+
|
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
|
+
|
7
|
+
Testing on [HEY](https://hey.com) shows that reads and writes are 25%-50% slower than with a Redis cache (1.2ms vs 0.8-1ms per single-key read), but this is not a significant percentage of the overall request time.
|
8
|
+
|
9
|
+
If cache misses are expensive (up to 50x the cost of a hit on HEY), then there are big advantages to caches that can hold months rather than days of data.
|
10
|
+
|
11
|
+
## Usage
|
12
|
+
|
13
|
+
To set Solid Cache as your Rails cache, you should add this to your environment config:
|
14
|
+
|
15
|
+
```ruby
|
16
|
+
config.cache_store = :solid_cache_store
|
17
|
+
```
|
18
|
+
|
19
|
+
Solid Cache is a FIFO (first in, first out) cache. While this is not as efficient as an LRU cache, this is mitigated by the longer cache lifespans.
|
20
|
+
|
21
|
+
A FIFO cache is much easier to manage:
|
22
|
+
1. We don't need to track when items are read
|
23
|
+
2. We can estimate and control the cache size by comparing the maximum and minimum IDs.
|
24
|
+
3. By deleting from one end of the table and adding at the other end we can avoid fragmentation (on MySQL at least).
|
25
|
+
|
26
|
+
### Installation
|
27
|
+
Add this line to your application's Gemfile:
|
28
|
+
|
29
|
+
```ruby
|
30
|
+
gem "solid_cache"
|
31
|
+
```
|
32
|
+
|
33
|
+
And then execute:
|
34
|
+
```bash
|
35
|
+
$ bundle
|
36
|
+
```
|
37
|
+
|
38
|
+
Or install it yourself as:
|
39
|
+
```bash
|
40
|
+
$ gem install solid_cache
|
41
|
+
```
|
42
|
+
|
43
|
+
Add the migration to your app:
|
44
|
+
|
45
|
+
```bash
|
46
|
+
$ bin/rails solid_cache:install:migrations
|
47
|
+
```
|
48
|
+
|
49
|
+
Then run it:
|
50
|
+
```bash
|
51
|
+
$ bin/rails db:migrate
|
52
|
+
```
|
53
|
+
|
54
|
+
### Configuration
|
55
|
+
|
56
|
+
#### Engine configuration
|
57
|
+
|
58
|
+
There are two options that can be set on the engine:
|
59
|
+
|
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. Requires for sharding and/or using a separate cache database to the main app.
|
62
|
+
|
63
|
+
These can be set in your Rails configuration:
|
64
|
+
|
65
|
+
```ruby
|
66
|
+
Rails.application.configure do
|
67
|
+
config.solid_cache.connects_to = {
|
68
|
+
shards: {
|
69
|
+
shard1: { writing: :cache_primary_shard1 },
|
70
|
+
shard2: { writing: :cache_primary_shard2 }
|
71
|
+
}
|
72
|
+
}
|
73
|
+
end
|
74
|
+
```
|
75
|
+
|
76
|
+
#### Cache configuration
|
77
|
+
|
78
|
+
Solid Cache supports these options in addition to the standard `ActiveSupport::Cache::Store` options.
|
79
|
+
|
80
|
+
- `error_handler` - a Proc to call to handle any `ActiveRecord::ActiveRecordError`s that are raises (default: log errors as warnings)
|
81
|
+
- `expiry_batch_size` - the batch size to use when deleting old records (default: `100`)
|
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: `2.weeks.to_i`)
|
85
|
+
- `cluster` - a Hash of options for the cache database cluster, e.g `{ shards: [:database1, :database2, :database3] }`
|
86
|
+
- `clusters` - and Array of Hashes for multiple cache clusters (ignored if `:cluster` is set)
|
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? then `delete`)
|
89
|
+
- `max_key_bytesize` - the maximum size of a normalized key in bytes (default `1024`)
|
90
|
+
|
91
|
+
For more information on cache clusters see [Sharding the cache](#sharding-the-cache)
|
92
|
+
|
93
|
+
### Cache expiry
|
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 add a task to run on a background thread. That task will:
|
96
|
+
|
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
|
+
2. If we have it will delete `expiry_batch_size` entries
|
99
|
+
3. If not it will delete up to `expiry_batch_size` entries, provided they are all older than `max_age`.
|
100
|
+
|
101
|
+
Expiring when we reach 80% of the batch size allows us to expire records from the cache faster than we write to it when we need to reduce the cache size.
|
102
|
+
|
103
|
+
Only triggering expiry when we write means that the if the cache is idle, the background thread is also idle.
|
104
|
+
|
105
|
+
If you want the cache expiry to be run in a background job instead of a thread, you can set `expiry_method` to `:job`. This will enqueue a `SolidCache::ExpiryJob`.
|
106
|
+
|
107
|
+
### Using a dedicated cache database
|
108
|
+
|
109
|
+
Add database configuration to database.yml, e.g.:
|
110
|
+
|
111
|
+
```
|
112
|
+
development
|
113
|
+
cache:
|
114
|
+
database: cache_development
|
115
|
+
host: 127.0.0.1
|
116
|
+
migrations_paths: "db/cache/migrate"
|
117
|
+
```
|
118
|
+
|
119
|
+
Create database:
|
120
|
+
```
|
121
|
+
$ bin/rails db:create
|
122
|
+
```
|
123
|
+
|
124
|
+
Install migrations:
|
125
|
+
```
|
126
|
+
$ bin/rails solid_cache:install:migrations
|
127
|
+
```
|
128
|
+
|
129
|
+
Move migrations to custom migrations folder:
|
130
|
+
```
|
131
|
+
$ mkdir -p db/cache/migrate
|
132
|
+
$ mv db/migrate/*.solid_cache.rb db/cache/migrate
|
133
|
+
```
|
134
|
+
|
135
|
+
Set the engine configuration to point to the new database:
|
136
|
+
```
|
137
|
+
Rails.application.configure do
|
138
|
+
config.solid_cache.connects_to = { default: { writing: :cache } }
|
139
|
+
end
|
140
|
+
```
|
141
|
+
|
142
|
+
Run migrations:
|
143
|
+
```
|
144
|
+
$ bin/rails db:migrate
|
145
|
+
```
|
146
|
+
|
147
|
+
### Sharding the cache
|
148
|
+
|
149
|
+
Solid Cache uses the [Maglev](https://static.googleusercontent.com/media/research.google.com/en//pubs/archive/44824.pdf) consistent hashing scheme to shard the cache across multiple databases.
|
150
|
+
|
151
|
+
To shard:
|
152
|
+
|
153
|
+
1. Add the configuration for the database shards to database.yml
|
154
|
+
2. Configure the shards via `config.solid_cache.connects_to`
|
155
|
+
3. Pass the shards for the cache to use via the cluster option
|
156
|
+
|
157
|
+
For example:
|
158
|
+
```ruby
|
159
|
+
# config/database.yml
|
160
|
+
production:
|
161
|
+
cache_shard1:
|
162
|
+
database: cache1_production
|
163
|
+
host: cache1-db
|
164
|
+
cache_shard2:
|
165
|
+
database: cache2_production
|
166
|
+
host: cache2-db
|
167
|
+
cache_shard3:
|
168
|
+
database: cache3_production
|
169
|
+
host: cache3-db
|
170
|
+
|
171
|
+
|
172
|
+
# config/environment/production.rb
|
173
|
+
Rails.application.configure do
|
174
|
+
config.solid_cache.connects_to = {
|
175
|
+
shards: {
|
176
|
+
cache_shard1: { writing: :cache_shard1 },
|
177
|
+
cache_shard2: { writing: :cache_shard2 },
|
178
|
+
cache_shard3: { writing: :cache_shard3 },
|
179
|
+
}
|
180
|
+
}
|
181
|
+
|
182
|
+
config.cache_store = [ :solid_cache_store, cluster: { shards: [ :cache_shard1, :cache_shard2, :cache_shard3 ] } ]
|
183
|
+
end
|
184
|
+
```
|
185
|
+
|
186
|
+
### Secondary cache clusters
|
187
|
+
|
188
|
+
You can add secondary cache clusters. Reads will only be sent to the primary cluster (i.e. the first one listed).
|
189
|
+
|
190
|
+
Writes will go to all clusters. The writes to the primary cluster are synchronous, but asyncronous to the secondary clusters.
|
191
|
+
|
192
|
+
To specific multiple clusters you can do:
|
193
|
+
|
194
|
+
```ruby
|
195
|
+
Rails.application.configure do
|
196
|
+
config.solid_cache.connects_to = {
|
197
|
+
shards: {
|
198
|
+
cache_primary_shard1: { writing: :cache_primary_shard1 },
|
199
|
+
cache_primary_shard2: { writing: :cache_primary_shard2 },
|
200
|
+
cache_secondary_shard1: { writing: :cache_secondary_shard1 },
|
201
|
+
cache_secondary_shard2: { writing: :cache_secondary_shard2 },
|
202
|
+
}
|
203
|
+
}
|
204
|
+
|
205
|
+
primary_cluster = { shards: [ :cache_primary_shard1, :cache_primary_shard2 ] }
|
206
|
+
secondary_cluster = { shards: [ :cache_primary_shard1, :cache_primary_shard2 ] }
|
207
|
+
config.cache_store = [ :solid_cache_store, clusters: [ primary_cluster, secondary_cluster ] ]
|
208
|
+
end
|
209
|
+
```
|
210
|
+
|
211
|
+
### Named shard destinations
|
212
|
+
|
213
|
+
By default, the node key used for sharding is the name of the database in `database.yml`.
|
214
|
+
|
215
|
+
It is possible to add names for the shards in the cluster config. This will allow you to shuffle or remove shards without breaking consistent hashing.
|
216
|
+
|
217
|
+
```ruby
|
218
|
+
Rails.application.configure do
|
219
|
+
config.solid_cache.connects_to = {
|
220
|
+
shards: {
|
221
|
+
cache_primary_shard1: { writing: :cache_primary_shard1 },
|
222
|
+
cache_primary_shard2: { writing: :cache_primary_shard2 },
|
223
|
+
cache_secondary_shard1: { writing: :cache_secondary_shard1 },
|
224
|
+
cache_secondary_shard2: { writing: :cache_secondary_shard2 },
|
225
|
+
}
|
226
|
+
}
|
227
|
+
|
228
|
+
primary_cluster = { shards: { cache_primary_shard1: :node1, cache_primary_shard2: :node2 } }
|
229
|
+
secondary_cluster = { shards: { cache_primary_shard1: :node3, cache_primary_shard2: :node4 } }
|
230
|
+
config.cache_store = [ :solid_cache_store, clusters: [ primary_cluster, secondary_cluster ] ]
|
231
|
+
end
|
232
|
+
```
|
233
|
+
|
234
|
+
|
235
|
+
### Enabling encryption
|
236
|
+
|
237
|
+
Add this to an initializer:
|
238
|
+
|
239
|
+
```ruby
|
240
|
+
ActiveSupport.on_load(:solid_cache_entry) do
|
241
|
+
encrypts :value
|
242
|
+
end
|
243
|
+
```
|
244
|
+
|
245
|
+
### Index size limits
|
246
|
+
The Solid Cache migrations try to create an index with 1024 byte entries. If that is too big for your database, you should:
|
247
|
+
|
248
|
+
1. Edit the index size in the migration
|
249
|
+
2. Set `max_key_bytesize` on your cache to the new value
|
250
|
+
|
251
|
+
## Development
|
252
|
+
|
253
|
+
Run the tests with `bin/rails test`. These will run against SQLite.
|
254
|
+
|
255
|
+
You can also run the tests against MySQL and Postgres. First start up the databases:
|
256
|
+
|
257
|
+
```shell
|
258
|
+
$ docker compose up -d
|
259
|
+
```
|
260
|
+
|
261
|
+
Then run the tests for the target database
|
262
|
+
```
|
263
|
+
$ TARGET_DB=mysql bin/rails test
|
264
|
+
$ TARGET_DB=postgres bin/rails test
|
265
|
+
```
|
266
|
+
|
267
|
+
## License
|
268
|
+
Solid Cache is licensed under MIT.
|
data/Rakefile
ADDED
@@ -0,0 +1,157 @@
|
|
1
|
+
module SolidCache
|
2
|
+
class Entry < Record
|
3
|
+
# This is all quite awkward but it achieves a couple of performance aims
|
4
|
+
# 1. We skip the query cache
|
5
|
+
# 2. We avoid the overhead of building queries and active record objects
|
6
|
+
class << self
|
7
|
+
def write(key, value)
|
8
|
+
upsert_all_no_query_cache([ { key: key, value: value } ])
|
9
|
+
end
|
10
|
+
|
11
|
+
def write_multi(payloads)
|
12
|
+
upsert_all_no_query_cache(payloads)
|
13
|
+
end
|
14
|
+
|
15
|
+
def read(key)
|
16
|
+
select_all_no_query_cache(get_sql, to_binary(key)).first
|
17
|
+
end
|
18
|
+
|
19
|
+
def read_multi(keys)
|
20
|
+
serialized_keys = keys.map { |key| to_binary(key) }
|
21
|
+
select_all_no_query_cache(get_all_sql(serialized_keys), serialized_keys).to_h
|
22
|
+
end
|
23
|
+
|
24
|
+
def expire(ids)
|
25
|
+
delete_no_query_cache(:id, ids) if ids.any?
|
26
|
+
end
|
27
|
+
|
28
|
+
def delete_by_key(key)
|
29
|
+
delete_no_query_cache(:key, to_binary(key))
|
30
|
+
end
|
31
|
+
|
32
|
+
def delete_matched(matcher, batch_size:)
|
33
|
+
like_matcher = arel_table[:key].matches(matcher, nil, true)
|
34
|
+
where(like_matcher).select(:id).find_in_batches(batch_size: batch_size) do |entries|
|
35
|
+
delete_no_query_cache(:id, entries.map(&:id))
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def clear_truncate
|
40
|
+
connection.truncate(table_name)
|
41
|
+
end
|
42
|
+
|
43
|
+
def clear_delete
|
44
|
+
in_batches.delete_all
|
45
|
+
end
|
46
|
+
|
47
|
+
def increment(key, amount)
|
48
|
+
transaction do
|
49
|
+
uncached do
|
50
|
+
amount += lock.where(key: key).pick(:value).to_i
|
51
|
+
write(key, amount)
|
52
|
+
amount
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def decrement(key, amount)
|
58
|
+
increment(key, -amount)
|
59
|
+
end
|
60
|
+
|
61
|
+
def id_range
|
62
|
+
uncached do
|
63
|
+
pick(Arel.sql("max(id) - min(id) + 1")) || 0
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def expire(count, max_age:, max_entries:)
|
68
|
+
if (ids = expiry_candidate_ids(count, max_age: max_age, max_entries: max_entries)).any?
|
69
|
+
delete(ids)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
private
|
74
|
+
def upsert_all_no_query_cache(attributes)
|
75
|
+
insert_all = ActiveRecord::InsertAll.new(self, attributes, unique_by: upsert_unique_by, on_duplicate: :update, update_only: [ :value ])
|
76
|
+
sql = connection.build_insert_sql(ActiveRecord::InsertAll::Builder.new(insert_all))
|
77
|
+
|
78
|
+
message = +"#{self} "
|
79
|
+
message << "Bulk " if attributes.many?
|
80
|
+
message << "Upsert"
|
81
|
+
# exec_query does not clear the query cache, exec_insert_all does
|
82
|
+
connection.exec_query sql, message
|
83
|
+
end
|
84
|
+
|
85
|
+
def upsert_unique_by
|
86
|
+
connection.supports_insert_conflict_target? ? :key : nil
|
87
|
+
end
|
88
|
+
|
89
|
+
def get_sql
|
90
|
+
@get_sql ||= build_sql(where(key: "placeholder").select(:value))
|
91
|
+
end
|
92
|
+
|
93
|
+
def get_all_sql(keys)
|
94
|
+
if connection.prepared_statements?
|
95
|
+
@get_all_sql_binds ||= {}
|
96
|
+
@get_all_sql_binds[keys.count] ||= build_sql(where(key: keys).select(:key, :value))
|
97
|
+
else
|
98
|
+
@get_all_sql_no_binds ||= build_sql(where(key: [ "placeholder1", "placeholder2" ]).select(:key, :value)).gsub("?, ?", "?")
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def build_sql(relation)
|
103
|
+
collector = Arel::Collectors::Composite.new(
|
104
|
+
Arel::Collectors::SQLString.new,
|
105
|
+
Arel::Collectors::Bind.new,
|
106
|
+
)
|
107
|
+
|
108
|
+
connection.visitor.compile(relation.arel.ast, collector)[0]
|
109
|
+
end
|
110
|
+
|
111
|
+
def select_all_no_query_cache(query, values)
|
112
|
+
uncached do
|
113
|
+
if connection.prepared_statements?
|
114
|
+
result = connection.select_all(sanitize_sql(query), "#{name} Load", Array(values), preparable: true)
|
115
|
+
else
|
116
|
+
result = connection.select_all(sanitize_sql([ query, values ]), "#{name} Load", nil, preparable: false)
|
117
|
+
end
|
118
|
+
|
119
|
+
result.cast_values(SolidCache::Entry.attribute_types)
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def delete_no_query_cache(attribute, values)
|
124
|
+
uncached do
|
125
|
+
relation = where(attribute => values)
|
126
|
+
sql = connection.to_sql(relation.arel.compile_delete(relation.table[primary_key]))
|
127
|
+
|
128
|
+
# exec_delete does not clear the query cache
|
129
|
+
if connection.prepared_statements?
|
130
|
+
connection.exec_delete(sql, "#{name} Delete All", Array(values)).nonzero?
|
131
|
+
else
|
132
|
+
connection.exec_delete(sql, "#{name} Delete All").nonzero?
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
def to_binary(key)
|
138
|
+
ActiveModel::Type::Binary.new.serialize(key)
|
139
|
+
end
|
140
|
+
|
141
|
+
def expiry_candidate_ids(count, max_age:, max_entries:)
|
142
|
+
cache_full = max_entries && max_entries < id_range
|
143
|
+
min_created_at = max_age.seconds.ago
|
144
|
+
|
145
|
+
uncached do
|
146
|
+
order(:id)
|
147
|
+
.limit(count * 3)
|
148
|
+
.pluck(:id, :created_at)
|
149
|
+
.filter_map { |id, created_at| id if cache_full || created_at < min_created_at }
|
150
|
+
.sample(count)
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
ActiveSupport.run_load_hooks :solid_cache_entry, SolidCache::Entry
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module SolidCache
|
2
|
+
class Record < ActiveRecord::Base
|
3
|
+
NULL_INSTRUMENTER = ActiveSupport::Notifications::Instrumenter.new(ActiveSupport::Notifications::Fanout.new)
|
4
|
+
|
5
|
+
self.abstract_class = true
|
6
|
+
|
7
|
+
connects_to **SolidCache.connects_to if SolidCache.connects_to
|
8
|
+
|
9
|
+
class << self
|
10
|
+
def disable_instrumentation
|
11
|
+
connection.with_instrumenter(NULL_INSTRUMENTER) do
|
12
|
+
yield
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def with_shard(shard, &block)
|
17
|
+
if shard && shard != Record.current_shard
|
18
|
+
Record.connected_to(shard: shard, &block)
|
19
|
+
else
|
20
|
+
block.call
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
ActiveSupport.run_load_hooks :solid_cache, SolidCache::Record
|
@@ -0,0 +1,11 @@
|
|
1
|
+
class CreateSolidCacheEntries < ActiveRecord::Migration[7.0]
|
2
|
+
def change
|
3
|
+
create_table :solid_cache_entries do |t|
|
4
|
+
t.binary :key, null: false, limit: 1024
|
5
|
+
t.binary :value, null: false, limit: 512.megabytes
|
6
|
+
t.datetime :created_at, null: false
|
7
|
+
|
8
|
+
t.index :key, unique: true
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
class SolidCache::InstallGenerator < Rails::Generators::Base
|
2
|
+
class_option :skip_migrations, type: :boolean, default: nil,
|
3
|
+
desc: "Skip migrations"
|
4
|
+
|
5
|
+
def add_rails_cache
|
6
|
+
%w[development test production].each do |env_name|
|
7
|
+
if (env_config = Pathname(destination_root).join("config/environments/#{env_name}.rb")).exist?
|
8
|
+
gsub_file env_config, /(# )?config\.cache_store = (:(?!null_store).*)/, "config.cache_store = :solid_cache_store"
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
def create_migrations
|
14
|
+
unless options[:skip_migrations]
|
15
|
+
rails_command "railties:install:migrations FROM=solid_cache", inline: true
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
module SolidCache
|
2
|
+
class Cluster
|
3
|
+
module Connections
|
4
|
+
def initialize(options = {})
|
5
|
+
super(options)
|
6
|
+
@shard_options = options.fetch(:shards, nil)
|
7
|
+
|
8
|
+
if [ Hash, Array, NilClass ].none? { |klass| @shard_options.is_a? klass }
|
9
|
+
raise ArgumentError, "`shards` is a `#{@shard_options.class.name}`, it should be one of Array, Hash or nil"
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
def with_each_connection(async: false, &block)
|
14
|
+
return enum_for(:with_each_connection) unless block_given?
|
15
|
+
|
16
|
+
connections.with_each do
|
17
|
+
execute(async, &block)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def with_connection_for(key, async: false, &block)
|
22
|
+
connections.with_connection_for(key) do
|
23
|
+
execute(async, &block)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def with_connection(name, async: false, &block)
|
28
|
+
connections.with(name) do
|
29
|
+
execute(async, &block)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def group_by_connection(keys)
|
34
|
+
connections.assign(keys)
|
35
|
+
end
|
36
|
+
|
37
|
+
def connection_names
|
38
|
+
connections.names
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
def setup!
|
43
|
+
connections
|
44
|
+
end
|
45
|
+
|
46
|
+
def connections
|
47
|
+
@connections ||= SolidCache::Connections.from_config(@shard_options)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
module SolidCache
|
2
|
+
class Cluster
|
3
|
+
module Execution
|
4
|
+
def initialize(options = {})
|
5
|
+
super(options)
|
6
|
+
@background = Concurrent::SingleThreadExecutor.new(max_queue: 100, fallback_policy: :discard)
|
7
|
+
@active_record_instrumentation = options.fetch(:active_record_instrumentation, true)
|
8
|
+
end
|
9
|
+
|
10
|
+
private
|
11
|
+
def async(&block)
|
12
|
+
# Need current shard right now, not when block is called
|
13
|
+
current_shard = Entry.current_shard
|
14
|
+
@background << ->() do
|
15
|
+
wrap_in_rails_executor do
|
16
|
+
connections.with(current_shard) do
|
17
|
+
instrument(&block)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def execute(async, &block)
|
24
|
+
if async
|
25
|
+
async(&block)
|
26
|
+
else
|
27
|
+
instrument(&block)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def wrap_in_rails_executor(&block)
|
32
|
+
if SolidCache.executor
|
33
|
+
SolidCache.executor.wrap(&block)
|
34
|
+
else
|
35
|
+
block.call
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def active_record_instrumentation?
|
40
|
+
@active_record_instrumentation
|
41
|
+
end
|
42
|
+
|
43
|
+
def instrument(&block)
|
44
|
+
if active_record_instrumentation?
|
45
|
+
block.call
|
46
|
+
else
|
47
|
+
Record.disable_instrumentation(&block)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|