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