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