rails_named_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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: '079d7672d3eb5ac855a1fd954bde9831ab34bef4f529cec6d7b17b393eb57629'
4
+ data.tar.gz: 64193c678525fc4c28f358c8a60fe6c85d9236620025e66ffb317888650c00fa
5
+ SHA512:
6
+ metadata.gz: b0ed82b9bad41e7607610f538d8df8a5d33bdf90e6eebf3ef7d19c1981aed08ad08a75eb798f64eda542a5a9bbcb38136f0974df901bdedc1bfd44a76f57ca3d
7
+ data.tar.gz: 6cc671bb1a3a633813db29c0eb29104b40fb6b1bc94ff70fcf917fcba44b045e513c25b9a88a48ddb453d4b861cd8258f1c72d2ff253795b3ec9970676ccbe74
data/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-07-30
10
+
11
+ ### Added
12
+
13
+ - `Rails.cache(:name)` returns a named `ActiveSupport::Cache::Store`; `Rails.cache` is unchanged.
14
+ - `config.named_cache_stores` configuration, accepting store instances or anything
15
+ `ActiveSupport::Cache.lookup_store` understands.
16
+ - Thread-safe `RailsNamedCache::Registry` with O(1) lookups.
17
+ - `RailsNamedCache::UnknownStoreError`, which lists the available stores and never falls back.
18
+ `Rails.cache(nil)` raises `ConfigurationError` instead of returning the default store.
19
+ - `rails generate rails_named_cache:install` generator, creating an initializer with a 1 MB
20
+ `MemoryStore` registered as `:memory`.
21
+ - Support for Rails 7.0 through 8.1 on Ruby 3.3 and 3.4.
22
+
23
+ [Unreleased]: https://github.com/igorkasyanchuk/rails_named_cache/compare/v0.1.0...HEAD
24
+ [0.1.0]: https://github.com/igorkasyanchuk/rails_named_cache/releases/tag/v0.1.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Igor Kasyanchuk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,364 @@
1
+ # rails_named_cache
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/rails_named_cache.svg)](https://rubygems.org/gems/rails_named_cache)
4
+ [![CI](https://github.com/igorkasyanchuk/rails_named_cache/actions/workflows/ci.yml/badge.svg)](https://github.com/igorkasyanchuk/rails_named_cache/actions/workflows/ci.yml)
5
+ [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
7
+ Multiple named cache stores for Rails, through `Rails.cache(:name)`.
8
+
9
+ ```ruby
10
+ Rails.cache.fetch("users") { User.all } # default store, unchanged
11
+ Rails.cache(:memory).fetch("countries") { ... } # in-process, ~3 µs per read
12
+ Rails.cache(:redis).fetch("session") { ... } # shared across servers
13
+ ```
14
+
15
+ **Why:**
16
+
17
+ - **Speed.** A named `MemoryStore` read is ~3 µs — no socket, no pool checkout, no wire protocol
18
+ ([numbers](#why-memorystore-is-so-fast)).
19
+ - **Isolation.** Per-domain stores, so page-fragment churn can no longer evict the pricing table.
20
+ `Rails.cache(:pricing).clear` touches nothing else.
21
+ - **No new API.** `Rails.cache(:name)` returns the configured store itself — no wrapper, no proxy.
22
+ Stores live in `config/` instead of global constants; `Rails.cache` is untouched.
23
+
24
+ **Contents:** [Install](#installation) · [Configuration](#configuration) · [Usage](#usage) ·
25
+ [Expiration](#expiration) · [L1/L2](#l1l2-memory-in-front-of-redis) ·
26
+ [MemoryStore speed](#why-memorystore-is-so-fast) · [Testing](#testing) ·
27
+ [Advanced](#advanced-configuration) · [FAQ](#faq) · [Be careful](#be-careful) ·
28
+ [Compatibility](#compatibility)
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ bundle add rails_named_cache
34
+ rails generate rails_named_cache:install # optional: a commented initializer to start from
35
+ ```
36
+
37
+ Ruby >= 3.3, Rails >= 7.0. Runtime deps are `activesupport`, `railties`, `concurrent-ruby`.
38
+
39
+ ## Configuration
40
+
41
+ One setting. Values are any `ActiveSupport::Cache::Store`:
42
+
43
+ ```ruby
44
+ # config/initializers/rails_named_cache.rb
45
+ Rails.application.configure do
46
+ config.named_cache_stores = {
47
+ memory: ActiveSupport::Cache::MemoryStore.new(size: 1.megabyte),
48
+ pricing: ActiveSupport::Cache::MemoryStore.new(size: 8.megabytes),
49
+ redis: ActiveSupport::Cache::RedisCacheStore.new(url: ENV.fetch("REDIS_URL")),
50
+ disk: ActiveSupport::Cache::FileStore.new(Rails.root.join("tmp/cache")),
51
+ null: ActiveSupport::Cache::NullStore.new
52
+ }
53
+ end
54
+ ```
55
+
56
+ Rails shorthand works too, resolved through `ActiveSupport::Cache.lookup_store`:
57
+
58
+ ```ruby
59
+ config.named_cache_stores = {
60
+ memory: :memory_store,
61
+ redis: [:redis_cache_store, { url: ENV.fetch("REDIS_URL") }],
62
+ disk: [:file_store, "tmp/cache"]
63
+ }
64
+ ```
65
+
66
+ Works from `config/application.rb` or any environment file too. An app that never sets it behaves
67
+ exactly as before. Per-environment stores, engines, boot ordering, error semantics and the registry
68
+ API are in [Advanced configuration](#advanced-configuration).
69
+
70
+ ## Usage
71
+
72
+ ```ruby
73
+ Rails.cache.fetch("key") { value } # default store, unchanged
74
+ Rails.cache(:memory).fetch("countries") { Country.all.to_a }
75
+ Rails.cache(:pricing).write("product/1", price)
76
+ Rails.cache(:null).fetch("anything") { computed } # never caches
77
+
78
+ store = Rails.cache(:memory) # a plain ActiveSupport::Cache::Store — full API, nothing wrapped
79
+ ```
80
+
81
+ Unknown and `nil` names raise instead of falling back to another store:
82
+
83
+ ```ruby
84
+ Rails.cache(:unknown)
85
+ # RailsNamedCache::UnknownStoreError: Unknown named cache store :unknown
86
+ #
87
+ # Available stores:
88
+ # :memory
89
+ # :pricing
90
+
91
+ Rails.cache(nil)
92
+ # RailsNamedCache::ConfigurationError: Cache store name must be a non-empty Symbol or String, got nil
93
+ ```
94
+
95
+ `UnknownStoreError` exposes `#name` and `#available`.
96
+
97
+ ## Expiration
98
+
99
+ Per-store defaults, so each domain gets its own policy:
100
+
101
+ ```ruby
102
+ config.named_cache_stores = {
103
+ memory: ActiveSupport::Cache::MemoryStore.new(size: 1.megabyte, expires_in: 10.seconds),
104
+ pricing: [:redis_cache_store, { url: ENV.fetch("REDIS_URL"), expires_in: 1.hour }],
105
+ geocode: [:redis_cache_store, { url: ENV.fetch("REDIS_URL"), expires_in: 30.days }]
106
+ }
107
+ ```
108
+
109
+ Per-call overrides are ordinary ActiveSupport:
110
+
111
+ ```ruby
112
+ Rails.cache(:memory).fetch("flags", expires_in: 1.second) { Flag.enabled.to_a }
113
+ Rails.cache(:pricing).write("product/#{id}", price, expires_in: 30.seconds)
114
+ Rails.cache(:pricing).fetch("rate", expires_in: 1.hour, race_condition_ttl: 10.seconds) { fetch_rate }
115
+ Rails.cache(:pricing).fetch("report", expires_at: Date.tomorrow.midnight) { build_report }
116
+ ```
117
+
118
+ No `expires_in` on a store means entries live until overwritten, deleted, or evicted by the backend.
119
+
120
+ ## L1/L2: memory in front of Redis
121
+
122
+ Not a gem feature — two named stores and two nested `fetch` calls:
123
+
124
+ ```ruby
125
+ # app/lib/layered_cache.rb
126
+ module LayeredCache
127
+ def self.fetch(key, l1_expires_in: 5.seconds, **options, &block)
128
+ Rails.cache(:memory).fetch(key, expires_in: l1_expires_in) do
129
+ Rails.cache(:redis).fetch(key, **options, &block)
130
+ end
131
+ end
132
+
133
+ def self.delete(key)
134
+ Rails.cache(:redis).delete(key)
135
+ Rails.cache(:memory).delete(key) # this process only
136
+ end
137
+ end
138
+ ```
139
+
140
+ ```ruby
141
+ LayeredCache.fetch("countries", expires_in: 1.day) { Country.order(:name).to_a }
142
+ # L1 hit (< 5s): ~3 µs, no Redis. L1 miss: one Redis read, refills L1. Both miss: runs the block.
143
+ ```
144
+
145
+ **The L1 TTL is your staleness window.** An L1 entry cannot be invalidated from another process, so
146
+ `delete` clears Redis at once while other workers keep serving their copy until it expires. Keep it
147
+ at seconds.
148
+
149
+ ## Why MemoryStore is so fast
150
+
151
+ | | Redis / Memcached | `MemoryStore` |
152
+ | --- | --- | --- |
153
+ | Network round trip | yes | **no** |
154
+ | Connection pool checkout | yes | **no** |
155
+ | Wire protocol encode/decode | yes | **no** |
156
+ | Value handling | serialize to bytes | deep-copy in memory |
157
+
158
+ Reading a small hash 200,000 times, Ruby 3.4.5 on Apple silicon:
159
+
160
+ ```
161
+ MemoryStore: 362,918 reads/sec — 2.8 µs/read
162
+ FileStore: 51,202 reads/sec — 19.5 µs/read
163
+ ```
164
+
165
+ Redis adds a socket round trip on top of the same bookkeeping. What is left in `MemoryStore` is the
166
+ deep copy that keeps callers from mutating cached state (`dup` for strings, `Marshal` otherwise), so
167
+ keep values small and plain.
168
+
169
+ The cost: it is per-process and dies with the process. Use it for cheap-to-recompute, read-constantly
170
+ data; use a shared store for anything that must agree across processes.
171
+
172
+ ## Testing
173
+
174
+ ```ruby
175
+ # disable caching for one domain, restore after
176
+ config.around(:each, :no_pricing_cache) do |example|
177
+ original = Rails.cache(:pricing)
178
+ RailsNamedCache.register(:pricing, ActiveSupport::Cache::NullStore.new)
179
+ example.run
180
+ RailsNamedCache.register(:pricing, original)
181
+ end
182
+
183
+ config.before { Rails.cache(:pricing).clear } # or clear one store between examples
184
+ ```
185
+
186
+ ## Advanced configuration
187
+
188
+ ### Boot order
189
+
190
+ Stores are registered after `config/initializers` run and before eager loading, so eager-loaded
191
+ code can call `Rails.cache(:name)`.
192
+
193
+ > **Not available inside `config/initializers`** — a lookup in an initializer body raises
194
+ > `UnknownStoreError`. Use `config.after_initialize`, `to_prepare`, or application code.
195
+
196
+ Per-environment stores are plain Rails configuration:
197
+
198
+ ```ruby
199
+ # config/environments/test.rb
200
+ config.named_cache_stores = { pricing: ActiveSupport::Cache::NullStore.new }
201
+ ```
202
+
203
+ ### Isolating stores that share a backend
204
+
205
+ Two stores on the same Redis, Memcached or directory share one keyspace — different objects, same
206
+ keys:
207
+
208
+ ```ruby
209
+ Rails.cache(:short).write("user/1", "a")
210
+ Rails.cache(:long).read("user/1") # => "a" — not isolated
211
+ Rails.cache(:long).clear # also wipes :short
212
+ ```
213
+
214
+ Give each a `namespace`. That isolates the keys and makes the store identifiable in logs and APM —
215
+ the `cache_read.active_support` payload carries `namespace`, otherwise both report only
216
+ `store: "ActiveSupport::Cache::RedisCacheStore"`:
217
+
218
+ ```ruby
219
+ config.named_cache_stores = {
220
+ short: [:redis_cache_store, { url: ENV.fetch("REDIS_URL"), namespace: "short", expires_in: 1.minute }],
221
+ long: [:redis_cache_store, { url: ENV.fetch("REDIS_URL"), namespace: "long", expires_in: 1.day }]
222
+ }
223
+ ```
224
+
225
+ Separate `MemoryStore` instances need no namespace.
226
+
227
+ ### Engines
228
+
229
+ Do not set `config.named_cache_stores` from an engine — railtie config is shared, so it replaces the
230
+ host app's stores. Register instead:
231
+
232
+ ```ruby
233
+ initializer "my_engine.caches", after: "rails_named_cache.register_stores" do
234
+ RailsNamedCache.register(:my_engine, ActiveSupport::Cache::MemoryStore.new)
235
+ end
236
+ ```
237
+
238
+ ### Configuration errors fail at boot
239
+
240
+ Nothing is registered if any entry is invalid:
241
+
242
+ ```ruby
243
+ config.named_cache_stores = { pricing: nil }
244
+ # ConfigurationError: Named cache store :pricing is nil
245
+
246
+ config.named_cache_stores = { "pricing" => a, pricing: b }
247
+ # ConfigurationError: Duplicate named cache store :pricing
248
+ ```
249
+
250
+ `nil` is rejected because `ActiveSupport::Cache.lookup_store(nil)` returns a fresh `MemoryStore` —
251
+ a typo would become a cache that quietly loses data. Names are non-empty symbols or strings, and
252
+ `"memory"` and `:memory` are the same store.
253
+
254
+ ### Registry API
255
+
256
+ ```ruby
257
+ RailsNamedCache.register(:memory, ActiveSupport::Cache::MemoryStore.new)
258
+ RailsNamedCache.fetch(:memory) # => the store (UnknownStoreError if missing)
259
+ RailsNamedCache.registered?(:redis) # => false
260
+ RailsNamedCache.names # => [:memory]
261
+ RailsNamedCache.registry.to_h # => { memory: #<MemoryStore> }
262
+ RailsNamedCache.reset! # empties the registry (tests)
263
+ ```
264
+
265
+ Registration is by name, so stores registered by hand from an initializer survive boot unless
266
+ `config.named_cache_stores` names the same store.
267
+
268
+ ## FAQ
269
+
270
+ **Why not global constants?**
271
+ Configuration ends up outside `config/`, tests stub constants, and nothing lists which caches exist.
272
+
273
+ **Forking servers (Puma workers, Unicorn, `preload_app!`)?**
274
+ Same caveat as `config.cache_store`: stores are built at boot, so with preloading they exist before
275
+ the fork. Pass connection-based stores as configuration (`[:redis_cache_store, { url: ... }]`)
276
+ rather than a live client — Rails connects lazily per worker, while a pre-connected socket shared
277
+ across forks corrupts.
278
+
279
+ **Do named stores get the per-request local cache?**
280
+ No. Rails inserts `LocalCache::Middleware` for the default `Rails.cache` only, so two identical
281
+ reads in one request hit the backend twice. Nothing leaks — the local cache is inert — and you can
282
+ opt in with `Rails.cache(:redis).with_local_cache { ... }`.
283
+
284
+ **Overhead?**
285
+ One `Concurrent::Map` read per lookup, lock-free. Stores are instantiated once at boot.
286
+
287
+ **Outside Rails?**
288
+ `RailsNamedCache.register` / `.fetch` work anywhere; `Rails.cache(:name)` and
289
+ `config.named_cache_stores` need an application. Requiring the gem always loads `rails/railtie`, so
290
+ load order never matters.
291
+
292
+ **Built-in L1/L2?**
293
+ No — [five lines](#l1l2-memory-in-front-of-redis) in app code, and a built-in version would have to
294
+ guess your staleness window. `Rails.cache(:memory, fallback: :redis)` stays possible later without
295
+ breaking the API.
296
+
297
+ ## Be careful
298
+
299
+ Named stores make it easy to add caches, which makes it easy to add these:
300
+
301
+ - **Memory multiplies.** `MemoryStore` defaults to `size: 32.megabytes` when you omit it — five
302
+ named stores is up to 160 MB *per process*, times your Puma workers, times your servers. Set
303
+ `size:` on every `MemoryStore` explicitly and size it against the container limit, not the host.
304
+ - **A `MemoryStore` is one lock.** A `Monitor` guards reads *and* writes, so all threads in the
305
+ process serialize on it; the ~3 µs above is single-threaded. Pruning also runs inside that lock
306
+ (bounded by `max_prune_time`, default 2 s), so an oversized store can stall every thread. Prefer
307
+ several small stores over one huge hot one.
308
+ - **TTL does not reclaim memory.** Expired entries are dropped when read or on `cleanup`; eviction
309
+ happens on write once the store exceeds `size`, pruning back to 75%. A store full of expired
310
+ entries nobody reads still occupies RAM.
311
+ - **No cross-process invalidation.** `Rails.cache(:memory).delete(key)` and `.clear` affect the
312
+ calling process only — a console or rake task cannot flush the running workers. Anything that
313
+ needs a coordinated purge belongs in a shared backend.
314
+ - **Two stores on one backend share one keyspace**, and `clear` on either wipes both. See
315
+ [Isolating stores that share a backend](#isolating-stores-that-share-a-backend).
316
+ - **No per-request local cache.** Repeated reads of the same key in one request hit a named store's
317
+ backend every time. On a Redis-backed store, wrap the work in `with_local_cache` or put a
318
+ [memory layer in front](#l1l2-memory-in-front-of-redis).
319
+ - **Cache plain data, not objects.** Storing ActiveRecord instances or anything class-bound in a
320
+ long-lived in-process store outlives code reloading in development and keeps stale classes alive.
321
+ Arrays, hashes, strings and numbers stay cheap to deep-copy too.
322
+ - **Never take a store name from user input.** `Rails.cache(params[:store])` raises, and the error
323
+ message lists every registered store name.
324
+ - **Do not create a store per model.** Splitting one working set across many small caches lowers
325
+ the overall hit rate, and every configured store is built at boot whether anything reads it or
326
+ not. Add a store when a value genuinely needs a different backend, size or TTL — otherwise
327
+ `Rails.cache` is still the right answer.
328
+ - **Pass connection-based stores as configuration**, not as a live client object, or a preloading
329
+ forking server will share one socket across workers.
330
+
331
+ ## Compatibility
332
+
333
+ | | |
334
+ | --- | --- |
335
+ | Ruby | 3.3, 3.4, 4.0 |
336
+ | Rails | 7.0, 7.1, 7.2, 8.0, 8.1 |
337
+
338
+ Every combination runs in CI. Note the Ruby floor: an older Rails is only supported on a modern
339
+ Ruby, so a Rails 7.0 app still on Ruby 3.1 cannot install this gem.
340
+
341
+ Rails 6.0 and 6.1 work but are neither supported nor tested — the runtime uses no API newer than
342
+ Rails 6.0, so relaxing the constraint locally is enough.
343
+
344
+ ## Development
345
+
346
+ ```bash
347
+ bundle exec rake # specs + rubocop
348
+
349
+ BUNDLE_GEMFILE=gemfiles/rails_7.0.gemfile bundle exec rspec # one Rails version
350
+ ls gemfiles/*.gemfile # rails_7.0 … rails_8.1
351
+ ```
352
+
353
+ The suite boots a real (tiny) Rails app in a tmpdir, so boot wiring is covered end to end. Design
354
+ rules live in [AGENTS.md](https://github.com/igorkasyanchuk/rails_named_cache/blob/main/AGENTS.md).
355
+
356
+ ## Contributing
357
+
358
+ Bug reports and pull requests welcome at
359
+ <https://github.com/igorkasyanchuk/rails_named_cache>. Keep `bundle exec rake` green and add a spec
360
+ for anything that changes behaviour.
361
+
362
+ ## License
363
+
364
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module RailsNamedCache
6
+ module Generators
7
+ # Creates +config/initializers/rails_named_cache.rb+ with a default
8
+ # 1 MB MemoryStore registered as +:memory+.
9
+ #
10
+ # rails generate rails_named_cache:install
11
+ class InstallGenerator < ::Rails::Generators::Base
12
+ source_root File.expand_path("templates", __dir__)
13
+
14
+ desc "Creates config/initializers/rails_named_cache.rb with a default memory store."
15
+
16
+ # @return [void]
17
+ def copy_initializer
18
+ template "rails_named_cache.rb", "config/initializers/rails_named_cache.rb"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Named cache stores, read back with Rails.cache(:name):
4
+ #
5
+ # Rails.cache(:memory).fetch("key") { expensive }
6
+ # Rails.cache(:memory).fetch("key", expires_in: 5.minutes) { expensive }
7
+ #
8
+ # Each store gets its own backend, size limit and default TTL. A store without
9
+ # expires_in keeps entries until they are overwritten, deleted or evicted.
10
+ # Rails.cache and config.cache_store are untouched.
11
+
12
+ Rails.application.configure do
13
+ config.named_cache_stores = {
14
+ # In-process and microsecond-fast, but per-process: every Puma worker has its
15
+ # own copy and it dies with the process. Nothing invalidates it from outside,
16
+ # so shorten the TTL for anything that changes and must not go stale.
17
+ memory: ActiveSupport::Cache::MemoryStore.new(size: 1.megabyte, expires_in: 1.day),
18
+
19
+ # Shared by every process and server. namespace: is required if another named
20
+ # store points at the same Redis — otherwise they share one keyspace and
21
+ # clearing either one wipes both.
22
+ # redis: ActiveSupport::Cache::RedisCacheStore.new(
23
+ # url: ENV.fetch("REDIS_URL"),
24
+ # namespace: "pricing",
25
+ # expires_in: 1.hour
26
+ # ),
27
+
28
+ # On disk: survives restarts, local to one machine. Suits large payloads that
29
+ # are cheap to store and expensive to rebuild.
30
+ # disk: ActiveSupport::Cache::FileStore.new(
31
+ # Rails.root.join("tmp/cache/reports"),
32
+ # expires_in: 1.day
33
+ # ),
34
+
35
+ # Caches nothing. Useful per environment, e.g. in config/environments/test.rb.
36
+ # null: ActiveSupport::Cache::NullStore.new
37
+ }
38
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNamedCache
4
+ # Turns the raw +config.named_cache_stores+ value into cache store instances.
5
+ #
6
+ # Values may be:
7
+ #
8
+ # * an +ActiveSupport::Cache::Store+ instance (used as-is)
9
+ # * anything +ActiveSupport::Cache.lookup_store+ understands, e.g.
10
+ # +:memory_store+ or +[:redis_cache_store, { url: ENV["REDIS_URL"] }]+
11
+ #
12
+ # Configuration only resolves and validates; it never stores anything. The
13
+ # {Registry} owns state.
14
+ #
15
+ # @example
16
+ # RailsNamedCache::Configuration.new(
17
+ # memory: ActiveSupport::Cache::MemoryStore.new(size: 1.megabyte),
18
+ # redis: [:redis_cache_store, { url: ENV.fetch("REDIS_URL") }]
19
+ # ).resolved_stores
20
+ # # => { memory: #<MemoryStore>, redis: #<RedisCacheStore> }
21
+ class Configuration
22
+ # @return [Hash] the raw configured value
23
+ attr_reader :stores
24
+
25
+ # @param stores [Hash{Symbol=>Object}, nil] raw +config.named_cache_stores+
26
+ # @raise [ConfigurationError] if +stores+ is not a Hash
27
+ def initialize(stores = {})
28
+ stores ||= {}
29
+
30
+ unless stores.is_a?(Hash)
31
+ raise ConfigurationError,
32
+ "config.named_cache_stores must be a Hash of name => cache store, got #{stores.class}"
33
+ end
34
+
35
+ @stores = stores
36
+ end
37
+
38
+ # @return [Hash{Symbol=>ActiveSupport::Cache::Store}] name => store instance
39
+ # @raise [ConfigurationError] for duplicate names or values that cannot become a cache store
40
+ def resolved_stores
41
+ stores.each_with_object({}) do |(name, value), resolved|
42
+ key = RailsNamedCache.normalize_name(name)
43
+
44
+ # "memory" and :memory are the same store; silently keeping the last one
45
+ # would hide a typo in config.
46
+ if resolved.key?(key)
47
+ raise ConfigurationError,
48
+ "Duplicate named cache store #{key.inspect} in config.named_cache_stores"
49
+ end
50
+
51
+ resolved[key] = resolve(key, value)
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ # @param name [Symbol]
58
+ # @param value [Object]
59
+ # @return [ActiveSupport::Cache::Store]
60
+ def resolve(name, value)
61
+ return value if value.is_a?(ActiveSupport::Cache::Store)
62
+
63
+ # nil would make lookup_store silently hand back a MemoryStore.
64
+ raise ConfigurationError, "Named cache store #{name.inspect} is nil" if value.nil?
65
+
66
+ store = ActiveSupport::Cache.lookup_store(value)
67
+ return store if store.is_a?(ActiveSupport::Cache::Store)
68
+
69
+ raise ConfigurationError,
70
+ "Named cache store #{name.inspect} must be an ActiveSupport::Cache::Store, " \
71
+ "got #{value.class}"
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNamedCache
4
+ # Prepended onto the +Rails+ singleton class so +Rails.cache+ takes an
5
+ # optional store name.
6
+ #
7
+ # This is the only monkey patch in the gem.
8
+ #
9
+ # Rails.cache # => the default Rails cache, untouched
10
+ # Rails.cache(:memory) # => the store registered as :memory
11
+ module RailsExt
12
+ # Splat rather than a +nil+ default, so +Rails.cache(name)+ with a +nil+
13
+ # name raises instead of quietly reading the default store.
14
+ #
15
+ # @param name [Array<Symbol, String>] zero arguments, or one registered store name
16
+ # @return [ActiveSupport::Cache::Store]
17
+ # @raise [ConfigurationError] if +name+ is present but not a non-empty Symbol or String
18
+ # @raise [UnknownStoreError] if +name+ was never registered
19
+ def cache(*name)
20
+ return super() if name.empty?
21
+
22
+ RailsNamedCache.fetch(*name)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module RailsNamedCache
6
+ # Wires the gem into the Rails boot process.
7
+ #
8
+ # Adds +config.named_cache_stores+ (default +{}+) and fills the {Registry}
9
+ # from it once, after +config/initializers+ have run and before eager loading,
10
+ # so eager-loaded code can already call +Rails.cache(:name)+.
11
+ class Railtie < ::Rails::Railtie
12
+ config.named_cache_stores = {}
13
+
14
+ initializer "rails_named_cache.register_stores", after: :load_config_initializers do |app|
15
+ # Registration is by name, so manual RailsNamedCache.register calls made
16
+ # from config/initializers survive unless config names the same store.
17
+ RailsNamedCache.load_configuration(app.config.named_cache_stores)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/map"
4
+
5
+ module RailsNamedCache
6
+ # Thread-safe store of named +ActiveSupport::Cache::Store+ instances.
7
+ #
8
+ # Lookups are O(1) and lock-free; writes happen once during boot.
9
+ #
10
+ # @example
11
+ # registry = RailsNamedCache::Registry.new
12
+ # registry.register(:memory, ActiveSupport::Cache::MemoryStore.new)
13
+ # registry.fetch(:memory) # => #<ActiveSupport::Cache::MemoryStore>
14
+ class Registry
15
+ def initialize
16
+ @stores = Concurrent::Map.new
17
+ end
18
+
19
+ # Registers +store+ under +name+, replacing any previous entry.
20
+ #
21
+ # @param name [Symbol, String] name used by +Rails.cache(name)+
22
+ # @param store [ActiveSupport::Cache::Store] the store instance
23
+ # @return [ActiveSupport::Cache::Store] the registered store
24
+ # @raise [ConfigurationError] if +name+ is blank or +store+ is not a cache store
25
+ def register(name, store)
26
+ key = RailsNamedCache.normalize_name(name)
27
+
28
+ unless store.is_a?(ActiveSupport::Cache::Store)
29
+ raise ConfigurationError,
30
+ "Named cache store #{key.inspect} must be an ActiveSupport::Cache::Store, " \
31
+ "got #{store.class}"
32
+ end
33
+
34
+ @stores[key] = store
35
+ end
36
+
37
+ # @param name [Symbol, String] registered store name
38
+ # @return [ActiveSupport::Cache::Store]
39
+ # @raise [ConfigurationError] if +name+ is not a non-empty Symbol or String
40
+ # @raise [UnknownStoreError] if nothing is registered under +name+
41
+ def fetch(name)
42
+ key = RailsNamedCache.normalize_name(name)
43
+ @stores.fetch(key) { raise UnknownStoreError.new(key, names) }
44
+ end
45
+
46
+ # @param name [Symbol, String]
47
+ # @return [Boolean]
48
+ # @raise [ConfigurationError] if +name+ is not a non-empty Symbol or String
49
+ def registered?(name)
50
+ @stores.key?(RailsNamedCache.normalize_name(name))
51
+ end
52
+
53
+ # @return [Array<Symbol>] every registered name
54
+ def names
55
+ @stores.keys
56
+ end
57
+
58
+ # @return [Integer] number of registered stores
59
+ def size
60
+ @stores.size
61
+ end
62
+
63
+ # Removes every registered store. Intended for tests and reboots.
64
+ #
65
+ # @return [void]
66
+ def clear
67
+ @stores.clear
68
+ end
69
+
70
+ # @return [Hash{Symbol=>ActiveSupport::Cache::Store}] snapshot of the registry
71
+ def to_h
72
+ @stores.each_pair.to_h
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsNamedCache
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/cache"
5
+
6
+ require_relative "rails_named_cache/version"
7
+ require_relative "rails_named_cache/registry"
8
+ require_relative "rails_named_cache/configuration"
9
+ require_relative "rails_named_cache/rails_ext"
10
+
11
+ # Named +ActiveSupport::Cache::Store+ instances for Rails applications.
12
+ #
13
+ # Register stores through +config.named_cache_stores+ and read them back with
14
+ # +Rails.cache(:name)+.
15
+ #
16
+ # @example
17
+ # Rails.cache.fetch("users") { User.all } # default Rails cache
18
+ # Rails.cache(:memory).fetch("countries") { ... } # named store
19
+ module RailsNamedCache
20
+ # Base class for every error raised by this gem.
21
+ class Error < StandardError; end
22
+
23
+ # Raised when +config.named_cache_stores+ holds something that cannot become
24
+ # a cache store.
25
+ class ConfigurationError < Error; end
26
+
27
+ # Raised when a named cache store is looked up but was never registered.
28
+ #
29
+ # Rails.cache(:nope)
30
+ # # => RailsNamedCache::UnknownStoreError:
31
+ # # Unknown named cache store :nope
32
+ # #
33
+ # # Available stores:
34
+ # # :memory
35
+ class UnknownStoreError < Error
36
+ # @return [Symbol] the requested name
37
+ attr_reader :name
38
+
39
+ # @return [Array<Symbol>] the registered names
40
+ attr_reader :available
41
+
42
+ # @param name [Symbol] requested store name
43
+ # @param available [Array<Symbol>] registered store names
44
+ def initialize(name, available)
45
+ @name = name
46
+ # Sorted so the message is deterministic regardless of registration order.
47
+ @available = available.sort
48
+ listed = @available.empty? ? "(none registered)" : @available.map(&:inspect).join("\n")
49
+ super("Unknown named cache store #{name.inspect}\n\nAvailable stores:\n#{listed}")
50
+ end
51
+ end
52
+
53
+ # The process-wide registry.
54
+ #
55
+ # @return [Registry]
56
+ REGISTRY = Registry.new
57
+
58
+ class << self
59
+ # @return [Registry] the process-wide registry
60
+ def registry
61
+ REGISTRY
62
+ end
63
+
64
+ # Registers a cache store under +name+.
65
+ #
66
+ # @param name [Symbol, String]
67
+ # @param store [ActiveSupport::Cache::Store]
68
+ # @return [ActiveSupport::Cache::Store]
69
+ def register(name, store)
70
+ registry.register(name, store)
71
+ end
72
+
73
+ # @param name [Symbol, String]
74
+ # @return [ActiveSupport::Cache::Store]
75
+ # @raise [UnknownStoreError]
76
+ def fetch(name)
77
+ registry.fetch(name)
78
+ end
79
+
80
+ # @param name [Symbol, String]
81
+ # @return [Boolean]
82
+ def registered?(name)
83
+ registry.registered?(name)
84
+ end
85
+
86
+ # @return [Array<Symbol>] every registered name
87
+ def names
88
+ registry.names
89
+ end
90
+
91
+ # Registers every store described by +config.named_cache_stores+.
92
+ #
93
+ # @param stores [Hash{Symbol=>Object}, nil] raw configuration value
94
+ # @return [Array<Symbol>] the names that were registered
95
+ def load_configuration(stores)
96
+ Configuration.new(stores).resolved_stores.each do |name, store|
97
+ register(name, store)
98
+ end.keys
99
+ end
100
+
101
+ # Teaches +Rails.cache+ to accept an optional name. Idempotent, and a no-op
102
+ # when the gem is used outside Rails.
103
+ #
104
+ # @return [void]
105
+ def install!
106
+ return if !defined?(::Rails) || ::Rails.singleton_class.include?(RailsExt)
107
+
108
+ ::Rails.singleton_class.prepend(RailsExt)
109
+ nil
110
+ end
111
+
112
+ # Empties the registry. Intended for tests.
113
+ #
114
+ # @return [void]
115
+ def reset!
116
+ registry.clear
117
+ end
118
+
119
+ # Coerces a store name to a Symbol.
120
+ #
121
+ # Shared by {Registry} and {Configuration} so a bad name fails the same way
122
+ # whether it came from configuration or from a direct call.
123
+ #
124
+ # @api private
125
+ # @param name [Symbol, String]
126
+ # @return [Symbol]
127
+ # @raise [ConfigurationError] if +name+ is not a non-empty Symbol or String
128
+ def normalize_name(name)
129
+ unless (name.is_a?(Symbol) || name.is_a?(String)) && !name.to_s.empty?
130
+ raise ConfigurationError,
131
+ "Cache store name must be a non-empty Symbol or String, got #{name.inspect}"
132
+ end
133
+
134
+ name.to_sym
135
+ end
136
+ end
137
+ end
138
+
139
+ # Unconditional: railties is a runtime dependency, and guarding this on
140
+ # defined?(Rails::Railtie) turns the gem into a silent no-op whenever it is
141
+ # required before Rails.
142
+ require_relative "rails_named_cache/railtie"
143
+
144
+ RailsNamedCache.install!
metadata ADDED
@@ -0,0 +1,121 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails_named_cache
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Igor Kasyanchuk
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activesupport
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: concurrent-ruby
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '1.1'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '2.0'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '1.1'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '2.0'
52
+ - !ruby/object:Gem::Dependency
53
+ name: railties
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '7.0'
59
+ - - "<"
60
+ - !ruby/object:Gem::Version
61
+ version: '9.0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '7.0'
69
+ - - "<"
70
+ - !ruby/object:Gem::Version
71
+ version: '9.0'
72
+ description: |
73
+ rails_named_cache lets a Rails application register several
74
+ ActiveSupport::Cache::Store instances by name and read them back with
75
+ Rails.cache(:memory), Rails.cache(:redis), Rails.cache(:pricing) and so on.
76
+ Rails.cache keeps working exactly as before, stores are never wrapped, and
77
+ no new caching API is introduced.
78
+ email:
79
+ - igorkasyanchuk@gmail.com
80
+ executables: []
81
+ extensions: []
82
+ extra_rdoc_files: []
83
+ files:
84
+ - CHANGELOG.md
85
+ - LICENSE
86
+ - README.md
87
+ - lib/generators/rails_named_cache/install/install_generator.rb
88
+ - lib/generators/rails_named_cache/install/templates/rails_named_cache.rb
89
+ - lib/rails_named_cache.rb
90
+ - lib/rails_named_cache/configuration.rb
91
+ - lib/rails_named_cache/rails_ext.rb
92
+ - lib/rails_named_cache/railtie.rb
93
+ - lib/rails_named_cache/registry.rb
94
+ - lib/rails_named_cache/version.rb
95
+ homepage: https://github.com/igorkasyanchuk/rails_named_cache
96
+ licenses:
97
+ - MIT
98
+ metadata:
99
+ homepage_uri: https://github.com/igorkasyanchuk/rails_named_cache
100
+ source_code_uri: https://github.com/igorkasyanchuk/rails_named_cache
101
+ changelog_uri: https://github.com/igorkasyanchuk/rails_named_cache/blob/main/CHANGELOG.md
102
+ bug_tracker_uri: https://github.com/igorkasyanchuk/rails_named_cache/issues
103
+ rubygems_mfa_required: 'true'
104
+ rdoc_options: []
105
+ require_paths:
106
+ - lib
107
+ required_ruby_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: 3.3.0
112
+ required_rubygems_version: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ requirements: []
118
+ rubygems_version: 3.7.2
119
+ specification_version: 4
120
+ summary: Multiple named cache stores for Rails, through Rails.cache(:name).
121
+ test_files: []