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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0649423e526a66fa9554f73d215e7e49c6ff093c004c1641d72a670a33688468'
4
- data.tar.gz: ad56d9f53b6b0c6f9dcc049771a187e95c5e2cceeabeaa07286f96212f5b0fb7
3
+ metadata.gz: d92ba4d7c045821d0c54facd1329613e497a46a2b21daffe3a14931bf9621f86
4
+ data.tar.gz: 46c4b4b81c48a5a5598cc2460e8c1159e69497f130116e9f855a58f8f3e52a36
5
5
  SHA512:
6
- metadata.gz: 900011771476bff6c3b3ec5abe087a0dcf00596f6e200dec961aa7e13f3ad4d978d9ac8858d3912b8bc0bc63a8742de4e925bd04b120d15badb890448946f4c2
7
- data.tar.gz: c9fd66e658ec1e38f628c592053f330179571a7299a1d4e6bae88a959996c10c4d7df158c30c9827464e0c3af526a4b5c0100f55f51c5b3e563e73b5af93226a
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. Requires for sharding and/or using a separate cache database to the main app.
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: `2.weeks.to_i`)
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? then `delete`)
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 add a task to run on a background thread. That task will:
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 = { default: { writing: :cache } }
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
- ```ruby
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: [ :cache_primary_shard1, :cache_primary_shard2 ] }
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`. These will run against SQLite.
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 Postgres. First start up the databases:
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
- Then run the tests for the target database
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
  require "bundler/setup"
2
4
 
3
5
  APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module SolidCache
2
4
  class ExpiryJob < ActiveJob::Base
3
5
  def perform(count, shard: nil, max_age:, max_entries:)
@@ -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
- # exec_query does not clear the query cache, exec_insert_all does
82
- connection.exec_query sql, message
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", nil, preparable: false)
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
- min_created_at = max_age.seconds.ago
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
- .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)
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 **SolidCache.connects_to if SolidCache.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) do
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 && shard != Record.current_shard
18
- Record.connected_to(shard: shard, &block)
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
  class CreateSolidCacheEntries < ActiveRecord::Migration[7.0]
2
4
  def change
3
5
  create_table :solid_cache_entries do |t|
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ActiveSupport
2
4
  module Cache
3
5
  SolidCacheStore = SolidCache::Store
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class SolidCache::InstallGenerator < Rails::Generators::Base
2
4
  class_option :skip_migrations, type: :boolean, default: nil,
3
5
  desc: "Skip migrations"
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module SolidCache
2
4
  class Cluster
3
5
  module Connections
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module SolidCache
2
4
  class Cluster
3
5
  module Execution
@@ -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.to_h { |connection_name| [ connection_name, Counter.new(expire_every) ] }
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
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module SolidCache
2
4
  class Cluster
3
5
  module Stats
@@ -6,7 +8,7 @@ module SolidCache
6
8
  end
7
9
 
8
10
  def stats
9
- stats = {
11
+ {
10
12
  connections: connections.count,
11
13
  connection_stats: connections_stats
12
14
  }
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
 
2
3
  module SolidCache
3
4
  class Cluster
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module SolidCache
2
4
  module Connections
3
5
  class Sharded
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module SolidCache
2
4
  module Connections
3
5
  class Single
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module SolidCache
2
4
  module Connections
3
5
  class Unmanaged
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module SolidCache
2
4
  module Connections
3
5
  def self.from_config(options)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "active_support"
2
4
 
3
5
  module SolidCache
@@ -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
- #  Must be prime
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.to_h { |name| [ normalize_key(name, options), name ] }
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
- results[name] = entry.value
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) do
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) do
73
- yield
74
- end
72
+ cluster.with_connection(connection, async: async, &block)
75
73
  end
76
74
  end
77
75
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module SolidCache
2
4
  class Store
3
5
  module Entries
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module SolidCache
2
4
  class Store
3
5
  module Failsafe
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module SolidCache
2
4
  class Store < ActiveSupport::Cache::Store
3
5
  include Api, Clusters, Entries, Failsafe
@@ -1,3 +1,3 @@
1
1
  module SolidCache
2
- VERSION = "0.1.0"
2
+ VERSION = "0.3.0"
3
3
  end
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.connected_to(shard: shard) { yield }
27
+ Record.with_shard(shard, &block)
26
28
  end
27
29
  else
28
30
  yield
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  desc "Copy over the migration, and set cache"
2
4
  namespace :solid_cache do
3
5
  task :install do
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.1.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: 2023-10-02 00:00:00.000000000 Z
11
+ date: 2024-01-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: rails
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.4.17
140
+ rubygems_version: 3.5.1
113
141
  signing_key:
114
142
  specification_version: 4
115
143
  summary: A database backed ActiveSupport::Cache::Store