smart_cache_tenant 0.2.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/.ruby-version +1 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE.txt +21 -0
- data/README.md +270 -0
- data/Rakefile +4 -0
- data/lib/smart_cache_tenant/cacheable_persistence.rb +38 -0
- data/lib/smart_cache_tenant/cacheable_relation.rb +189 -0
- data/lib/smart_cache_tenant/configuration.rb +22 -0
- data/lib/smart_cache_tenant/model_callbacks.rb +42 -0
- data/lib/smart_cache_tenant/railtie.rb +12 -0
- data/lib/smart_cache_tenant/smart_cache_logger.rb +42 -0
- data/lib/smart_cache_tenant/version.rb +5 -0
- data/lib/smart_cache_tenant/version_store.rb +31 -0
- data/lib/smart_cache_tenant.rb +13 -0
- data/sig/smart_cache_tenant.rbs +4 -0
- metadata +63 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a93f98aff4e2b9f648df7da645a638e272db90a42b4953fbf5496b9046f5f56a
|
|
4
|
+
data.tar.gz: 05bf862690128e59b1f154f272d1761ce9da8cdb156796b5dc782c2932081490
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 51849b0dcec7fd037cb92e0900159cd23da5dce5732b2f5bb562b367878bc8b0c7608e3b5038c9b0767f5b7dafa08d5c80651d886165ebd1b677a5521589731b
|
|
7
|
+
data.tar.gz: cd03d5da66ff96284e1c83effe0c8897acd2443dbb5839cf421d02b439207bca2feefde5f70af23b8314910d313cfd89b6f2304f3e0acf2715017f8e391af0af
|
data/.ruby-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.3.0
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Henrique A. Shiraishi
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# SmartCacheTenant
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/rb/smart_cache_tenant)
|
|
4
|
+
|
|
5
|
+
SmartCacheTenant adds tenant-aware query caching on top of `ActiveRecord` by storing cached read results in `Rails.cache` and invalidating them through lightweight version keys.
|
|
6
|
+
|
|
7
|
+
The gem is designed for multi-tenant Rails applications where the same models are queried repeatedly per tenant and cache invalidation must stay predictable.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Add the gem to your application:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
gem 'smart_cache_tenant'
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Then install it:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
bundle install
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
If you are not using Bundler:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
gem install smart_cache_tenant
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Configuration
|
|
30
|
+
|
|
31
|
+
Create the initializer below:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
# config/initializers/smart_cache_tenant.rb
|
|
35
|
+
SmartCacheTenant.configure do |config|
|
|
36
|
+
config.enabled = true
|
|
37
|
+
config.ttl = 1.hour
|
|
38
|
+
config.tenant_column = :tenant_id
|
|
39
|
+
config.log_queries = !Rails.env.production?
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### What each setting does
|
|
44
|
+
|
|
45
|
+
`config.enabled`
|
|
46
|
+
|
|
47
|
+
Globally enables or disables the gem. When it is `false`, relations run normally without cache reads or writes.
|
|
48
|
+
|
|
49
|
+
`config.ttl`
|
|
50
|
+
|
|
51
|
+
Defines how long cached query entries and internal version keys stay in `Rails.cache`. The default is `1.hour`.
|
|
52
|
+
|
|
53
|
+
`config.tenant_column`
|
|
54
|
+
|
|
55
|
+
Defines which column identifies the tenant, such as `:tenant_id` or `:account_id`.
|
|
56
|
+
|
|
57
|
+
This value is used in two places:
|
|
58
|
+
|
|
59
|
+
1. To build tenant-scoped version keys.
|
|
60
|
+
2. To resolve the current tenant from relation filters or bulk write payloads.
|
|
61
|
+
|
|
62
|
+
If this is not configured, the gem still works, but cache invalidation becomes global per model instead of per tenant.
|
|
63
|
+
|
|
64
|
+
`config.log_queries`
|
|
65
|
+
|
|
66
|
+
Enables debug logging for cache hits. When enabled, the gem logs cached operations and their SQL through `Rails.logger`.
|
|
67
|
+
|
|
68
|
+
In practice, logs are only emitted when all of the following are true:
|
|
69
|
+
|
|
70
|
+
1. `config.log_queries` is `true`.
|
|
71
|
+
2. `Rails.logger.debug?` is `true`.
|
|
72
|
+
3. The environment is not production.
|
|
73
|
+
|
|
74
|
+
## Application Setup
|
|
75
|
+
|
|
76
|
+
### 1. Include callbacks in `ApplicationRecord`
|
|
77
|
+
|
|
78
|
+
Add the callback module once so write operations can bump the model version after commit:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
# app/models/application_record.rb
|
|
82
|
+
class ApplicationRecord < ActiveRecord::Base
|
|
83
|
+
primary_abstract_class
|
|
84
|
+
|
|
85
|
+
include SmartCacheTenant::ModelCallbacks
|
|
86
|
+
end
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
This is required because the gem invalidates cached reads by updating a version token after records are committed.
|
|
90
|
+
|
|
91
|
+
### 2. Enable caching per model
|
|
92
|
+
|
|
93
|
+
Add `has_smart_cache` only to models that should participate in this cache mechanism:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
class Project < ApplicationRecord
|
|
97
|
+
has_smart_cache
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Only models marked with `has_smart_cache` are considered cache-enabled.
|
|
102
|
+
|
|
103
|
+
## How It Works
|
|
104
|
+
|
|
105
|
+
For cacheable reads, the gem generates a cache key from:
|
|
106
|
+
|
|
107
|
+
1. The current database name.
|
|
108
|
+
2. The SQL generated by the relation.
|
|
109
|
+
3. The current tenant identifier.
|
|
110
|
+
4. A version token for each model involved in the query.
|
|
111
|
+
5. The operation being executed, such as `load`, `calculate`, or `exists?`.
|
|
112
|
+
|
|
113
|
+
Instead of deleting cached query keys directly, the gem updates model version keys in `Rails.cache`. Once a version changes, all future reads produce a different cache key and the old cached entry becomes stale naturally.
|
|
114
|
+
|
|
115
|
+
This keeps invalidation simple and avoids tracking every query key that has ever been generated.
|
|
116
|
+
|
|
117
|
+
## Cached Operations
|
|
118
|
+
|
|
119
|
+
The gem currently caches the following relation operations when smart cache is enabled:
|
|
120
|
+
|
|
121
|
+
1. Record loading through relation `load`.
|
|
122
|
+
2. Aggregate calculations through `calculate`, including helpers such as `count`, `sum`, `average`, `minimum`, and `maximum`.
|
|
123
|
+
3. Existence checks through `exists?`.
|
|
124
|
+
|
|
125
|
+
If a relation is not cache-enabled, execution falls back to the default ActiveRecord behavior.
|
|
126
|
+
|
|
127
|
+
## Invalidation Behavior
|
|
128
|
+
|
|
129
|
+
### Normal model writes
|
|
130
|
+
|
|
131
|
+
When a model includes `SmartCacheTenant::ModelCallbacks` and declares `has_smart_cache`, the gem bumps that model version on `after_commit`.
|
|
132
|
+
|
|
133
|
+
That means create, update, and destroy operations invalidate future cached reads for the corresponding tenant.
|
|
134
|
+
|
|
135
|
+
### Bulk writes
|
|
136
|
+
|
|
137
|
+
The gem also bumps versions for bulk write operations:
|
|
138
|
+
|
|
139
|
+
1. `insert_all`
|
|
140
|
+
2. `upsert_all`
|
|
141
|
+
3. `update_all`
|
|
142
|
+
|
|
143
|
+
For bulk inserts and upserts, the gem tries to extract tenant IDs from the payload using `config.tenant_column`.
|
|
144
|
+
|
|
145
|
+
For `update_all`, the gem tries to infer the tenant from the relation filters.
|
|
146
|
+
|
|
147
|
+
If no tenant can be determined, the gem falls back to a model-wide version bump.
|
|
148
|
+
|
|
149
|
+
## Usage Examples
|
|
150
|
+
|
|
151
|
+
### Basic setup
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
class ApplicationRecord < ActiveRecord::Base
|
|
155
|
+
primary_abstract_class
|
|
156
|
+
|
|
157
|
+
include SmartCacheTenant::ModelCallbacks
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
class Project < ApplicationRecord
|
|
161
|
+
has_smart_cache
|
|
162
|
+
end
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Tenant-scoped reads
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
Project.where(tenant_id: current_tenant.id, active: true).to_a
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
On the first execution, Rails hits the database and stores the loaded records in `Rails.cache`.
|
|
172
|
+
|
|
173
|
+
On the next execution with the same SQL and the same tenant version, the gem returns the cached records.
|
|
174
|
+
|
|
175
|
+
### Aggregate queries
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
Project.where(tenant_id: current_tenant.id).count
|
|
179
|
+
Project.where(tenant_id: current_tenant.id).sum(:budget_cents)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
These calculations are cached independently from loaded records because the cache key also includes the operation name.
|
|
183
|
+
|
|
184
|
+
### Existence checks
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
Project.where(tenant_id: current_tenant.id, slug: params[:slug]).exists?
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
The boolean result is cached using the relation SQL, tenant, and model version.
|
|
191
|
+
|
|
192
|
+
### Automatic invalidation after commit
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
project = Project.create!(tenant_id: current_tenant.id, name: 'Alpha')
|
|
196
|
+
project.update!(name: 'Beta')
|
|
197
|
+
project.destroy!
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Each committed write bumps the cached version for `Project` and the current tenant, so subsequent reads use a new cache key.
|
|
201
|
+
|
|
202
|
+
### Bulk writes
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
Project.insert_all([
|
|
206
|
+
{ tenant_id: 1, name: 'Alpha' },
|
|
207
|
+
{ tenant_id: 1, name: 'Beta' }
|
|
208
|
+
])
|
|
209
|
+
|
|
210
|
+
Project.where(tenant_id: 1).update_all(active: false)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
These operations also bump the version and invalidate future cached reads.
|
|
214
|
+
|
|
215
|
+
## Query Scope and Multi-Model Reads
|
|
216
|
+
|
|
217
|
+
The gem tracks the main relation model and also inspects association-based query loading such as:
|
|
218
|
+
|
|
219
|
+
1. `joins`
|
|
220
|
+
2. `left_outer_joins`
|
|
221
|
+
3. `includes`
|
|
222
|
+
4. `eager_load`
|
|
223
|
+
|
|
224
|
+
When associations are resolved through ActiveRecord reflections, the cache key includes version tokens for all involved models.
|
|
225
|
+
|
|
226
|
+
This means a query like the one below can be invalidated by changes in either model, as long as both are cache-enabled and reachable through association metadata:
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
Project.includes(:tasks).where(tenant_id: current_tenant.id).to_a
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Logging Cache Hits
|
|
233
|
+
|
|
234
|
+
When `config.log_queries` is enabled, cache hits are logged in debug mode with:
|
|
235
|
+
|
|
236
|
+
1. The operation name.
|
|
237
|
+
2. The elapsed lookup time.
|
|
238
|
+
3. The generated SQL.
|
|
239
|
+
|
|
240
|
+
This is useful during rollout to confirm which queries are being served from cache.
|
|
241
|
+
|
|
242
|
+
## Notes and Operational Guidance
|
|
243
|
+
|
|
244
|
+
1. The gem depends on a configured `Rails.cache` backend. Use an appropriate production cache store such as Redis or Memcached.
|
|
245
|
+
2. Tenant scoping is strongest when all relevant queries explicitly filter by the configured tenant column.
|
|
246
|
+
3. If a query does not expose the tenant in a way the relation can resolve, invalidation may fall back to a broader model-level version bump.
|
|
247
|
+
4. Because cached records are stored directly from the relation load result, use this gem only when your cache backend and application lifecycle are compatible with caching ActiveRecord result objects.
|
|
248
|
+
|
|
249
|
+
## Recommended Integration Checklist
|
|
250
|
+
|
|
251
|
+
1. Add `gem 'smart_cache_tenant'` to your Gemfile.
|
|
252
|
+
2. Create `config/initializers/smart_cache_tenant.rb` and configure `enabled`, `ttl`, `tenant_column`, and `log_queries`.
|
|
253
|
+
3. Add `include SmartCacheTenant::ModelCallbacks` to `ApplicationRecord`.
|
|
254
|
+
4. Add `has_smart_cache` to each model that should participate in caching.
|
|
255
|
+
5. Confirm that your queries are tenant-scoped using the configured tenant column.
|
|
256
|
+
6. Verify that your `Rails.cache` backend is shared and persistent enough for your environment.
|
|
257
|
+
|
|
258
|
+
## Development
|
|
259
|
+
|
|
260
|
+
After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
261
|
+
|
|
262
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
263
|
+
|
|
264
|
+
## Contributing
|
|
265
|
+
|
|
266
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/henriqueshiraishi/smart_cache_tenant.
|
|
267
|
+
|
|
268
|
+
## License
|
|
269
|
+
|
|
270
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmartCacheTenant
|
|
4
|
+
module CacheablePersistence
|
|
5
|
+
def insert_all(attributes, returning: nil, unique_by: nil, record_timestamps: nil)
|
|
6
|
+
result = super
|
|
7
|
+
bump_smart_cache_for_class_bulk_write!(attributes)
|
|
8
|
+
result
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def upsert_all(attributes, on_duplicate: :update, update_only: nil, returning: nil, unique_by: nil, record_timestamps: nil)
|
|
12
|
+
result = super
|
|
13
|
+
bump_smart_cache_for_class_bulk_write!(attributes)
|
|
14
|
+
result
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def bump_smart_cache_for_class_bulk_write!(attributes)
|
|
20
|
+
return unless try(:smart_cache_enabled?)
|
|
21
|
+
return if attributes.blank?
|
|
22
|
+
|
|
23
|
+
tenant_column = SmartCacheTenant.config.tenant_column
|
|
24
|
+
tenant_ids = Array(attributes).filter_map do |row|
|
|
25
|
+
next unless row.respond_to?(:[])
|
|
26
|
+
next if tenant_column.blank?
|
|
27
|
+
|
|
28
|
+
row[tenant_column] || row[tenant_column.to_sym] || row[tenant_column.to_s]
|
|
29
|
+
end.compact.uniq
|
|
30
|
+
|
|
31
|
+
if tenant_ids.empty?
|
|
32
|
+
SmartCacheTenant::VersionStore.bump!(self)
|
|
33
|
+
else
|
|
34
|
+
tenant_ids.each { |tenant_id| SmartCacheTenant::VersionStore.bump!(self, tenant_id) }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmartCacheTenant
|
|
4
|
+
module CacheableRelation
|
|
5
|
+
def load(&block)
|
|
6
|
+
return super unless smart_cache_enabled?
|
|
7
|
+
|
|
8
|
+
key = smart_cache_key(operation: :load)
|
|
9
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
10
|
+
cached = Rails.cache.read(key)
|
|
11
|
+
|
|
12
|
+
unless cached.nil?
|
|
13
|
+
@records = cached
|
|
14
|
+
@loaded = true
|
|
15
|
+
SmartCacheTenant::Logger.log_cache_hit("#{klass.name} Load", elapsed_ms(started), arel_to_sql)
|
|
16
|
+
return self
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
super.tap do
|
|
20
|
+
Rails.cache.write(key, @records, expires_in: SmartCacheTenant.config.ttl)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def calculate(operation, column_name)
|
|
25
|
+
return super unless smart_cache_enabled?
|
|
26
|
+
|
|
27
|
+
key = smart_cache_key(operation: "calculate:#{operation}:#{column_name}")
|
|
28
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
29
|
+
cached = Rails.cache.read(key)
|
|
30
|
+
|
|
31
|
+
unless cached.nil?
|
|
32
|
+
SmartCacheTenant::Logger.log_cache_hit("#{klass.name} #{operation.to_s.capitalize}", elapsed_ms(started), arel_to_sql)
|
|
33
|
+
return cached
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
super.tap do |result|
|
|
37
|
+
Rails.cache.write(key, result, expires_in: SmartCacheTenant.config.ttl)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def exists?(conditions = :none)
|
|
42
|
+
return super unless smart_cache_enabled?
|
|
43
|
+
|
|
44
|
+
key = smart_cache_key(operation: "exists:#{conditions.inspect}")
|
|
45
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
46
|
+
cached = Rails.cache.read(key)
|
|
47
|
+
|
|
48
|
+
unless cached.nil?
|
|
49
|
+
SmartCacheTenant::Logger.log_cache_hit("#{klass.name} Exists?", elapsed_ms(started), arel_to_sql)
|
|
50
|
+
return cached
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
super.tap do |result|
|
|
54
|
+
Rails.cache.write(key, result, expires_in: SmartCacheTenant.config.ttl)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def update_all(updates)
|
|
59
|
+
result = super
|
|
60
|
+
bump_smart_cache_for_bulk_write!(affected_rows: result)
|
|
61
|
+
result
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def insert_all(attributes, returning: nil, unique_by: nil, record_timestamps: nil)
|
|
65
|
+
result = super
|
|
66
|
+
bump_smart_cache_for_bulk_write!(tenant_ids: tenant_ids_from_bulk_attributes(attributes)) if attributes.present?
|
|
67
|
+
result
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def upsert_all(attributes, on_duplicate: :update, update_only: nil, returning: nil, unique_by: nil, record_timestamps: nil)
|
|
71
|
+
result = super
|
|
72
|
+
bump_smart_cache_for_bulk_write!(tenant_ids: tenant_ids_from_bulk_attributes(attributes)) if attributes.present?
|
|
73
|
+
result
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def smart_cache_enabled?
|
|
79
|
+
SmartCacheTenant.config.enabled && involved_models.all? { |model| model.try(:smart_cache_enabled?) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def smart_cache_key(operation:)
|
|
83
|
+
versions = involved_models.map do |model|
|
|
84
|
+
"#{model.table_name}:#{SmartCacheTenant::VersionStore.current(model, resolve_tenant_id)}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
payload = {
|
|
88
|
+
versions: versions.sort,
|
|
89
|
+
tenant_id: resolve_tenant_id.to_s,
|
|
90
|
+
sql_fingerprint: arel_to_sql,
|
|
91
|
+
operation: operation.to_s
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
digest = if defined?(CityHash) && CityHash.respond_to?(:hash128)
|
|
95
|
+
CityHash.hash128(payload.to_json)
|
|
96
|
+
else
|
|
97
|
+
Digest::SHA1.hexdigest(payload.to_json)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
database_name = klass.connection_db_config.database
|
|
101
|
+
[database_name, 'smart_cache', 'query', digest].join(':').downcase
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def involved_models
|
|
105
|
+
@involved_models ||= begin
|
|
106
|
+
models = [klass]
|
|
107
|
+
|
|
108
|
+
if eager_load_values.any? || joins_values.any? || left_outer_joins_values.any? || includes_values.any?
|
|
109
|
+
all_associations = eager_load_values + joins_values + left_outer_joins_values + includes_values
|
|
110
|
+
all_associations.each do |association_tree|
|
|
111
|
+
collect_association_models(klass, association_tree, models)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
models.uniq
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def collect_association_models(current_model, association_tree, models)
|
|
120
|
+
case association_tree
|
|
121
|
+
when Symbol, String
|
|
122
|
+
add_association_model(current_model, association_tree, models)
|
|
123
|
+
when Array
|
|
124
|
+
association_tree.each do |nested_association|
|
|
125
|
+
collect_association_models(current_model, nested_association, models)
|
|
126
|
+
end
|
|
127
|
+
when Hash
|
|
128
|
+
association_tree.each do |association_name, nested_association|
|
|
129
|
+
reflection = add_association_model(current_model, association_name, models)
|
|
130
|
+
next unless reflection
|
|
131
|
+
collect_association_models(reflection.klass, nested_association, models)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def add_association_model(current_model, association_name, models)
|
|
137
|
+
return unless association_name.respond_to?(:to_sym)
|
|
138
|
+
|
|
139
|
+
reflection = current_model.reflect_on_association(association_name.to_sym)
|
|
140
|
+
return unless reflection
|
|
141
|
+
|
|
142
|
+
models << reflection.klass
|
|
143
|
+
reflection
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def arel_to_sql
|
|
147
|
+
klass.connection.unprepared_statement { to_sql }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def elapsed_ms(started)
|
|
151
|
+
(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def resolve_tenant_id
|
|
155
|
+
tenant_column = SmartCacheTenant.config.tenant_column
|
|
156
|
+
return if tenant_column.blank?
|
|
157
|
+
|
|
158
|
+
where_values_hash[tenant_column.to_s] || where_values_hash["#{klass.table_name}.#{tenant_column}"]
|
|
159
|
+
rescue StandardError
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def tenant_ids_from_bulk_attributes(attributes)
|
|
164
|
+
tenant_column = SmartCacheTenant.config.tenant_column
|
|
165
|
+
return [] if tenant_column.blank?
|
|
166
|
+
|
|
167
|
+
Array(attributes).filter_map do |row|
|
|
168
|
+
next unless row.respond_to?(:[])
|
|
169
|
+
|
|
170
|
+
row[tenant_column] || row[tenant_column.to_sym] || row[tenant_column.to_s]
|
|
171
|
+
end.uniq
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def bump_smart_cache_for_bulk_write!(tenant_ids: nil, affected_rows: nil)
|
|
175
|
+
return unless klass.try(:smart_cache_enabled?)
|
|
176
|
+
return if affected_rows.respond_to?(:zero?) && affected_rows.zero?
|
|
177
|
+
|
|
178
|
+
resolved_tenant_ids = Array(tenant_ids).compact
|
|
179
|
+
resolved_tenant_ids << resolve_tenant_id
|
|
180
|
+
resolved_tenant_ids = resolved_tenant_ids.compact.uniq
|
|
181
|
+
|
|
182
|
+
if resolved_tenant_ids.empty?
|
|
183
|
+
SmartCacheTenant::VersionStore.bump!(klass)
|
|
184
|
+
else
|
|
185
|
+
resolved_tenant_ids.each { |tenant_id| SmartCacheTenant::VersionStore.bump!(klass, tenant_id) }
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmartCacheTenant
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :enabled, :ttl, :tenant_column, :log_queries
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@enabled = true
|
|
9
|
+
@ttl = 1.hour
|
|
10
|
+
@tenant_column = nil
|
|
11
|
+
@log_queries = !Rails.env.production?
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.config
|
|
16
|
+
@config ||= SmartCacheTenant::Configuration.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.configure
|
|
20
|
+
yield config
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmartCacheTenant
|
|
4
|
+
module ModelCallbacks
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
after_commit :bump_smart_cache_version!
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class_methods do
|
|
12
|
+
def smart_cache_enabled?
|
|
13
|
+
@smart_cache_enabled == true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def has_smart_cache
|
|
17
|
+
@smart_cache_enabled = true
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def smart_cache_bump!(tenant_id = nil)
|
|
21
|
+
SmartCacheTenant::VersionStore.bump!(self, tenant_id)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def smart_cached_version(tenant_id = nil)
|
|
25
|
+
SmartCacheTenant::VersionStore.current(self, tenant_id)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def smart_cache_bump!
|
|
30
|
+
SmartCacheTenant::VersionStore.bump!(self.class, try(SmartCacheTenant.config.tenant_column))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def bump_smart_cache_version!
|
|
36
|
+
return unless self.class.smart_cache_enabled?
|
|
37
|
+
|
|
38
|
+
tenant_id = try(SmartCacheTenant.config.tenant_column)
|
|
39
|
+
SmartCacheTenant::VersionStore.bump!(self.class, tenant_id)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmartCacheTenant
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
initializer 'smart_cache.initialize' do
|
|
6
|
+
ActiveSupport.on_load(:active_record) do
|
|
7
|
+
ActiveRecord::Relation.prepend(SmartCacheTenant::CacheableRelation)
|
|
8
|
+
ActiveRecord::Base.singleton_class.prepend(SmartCacheTenant::CacheablePersistence)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmartCacheTenant
|
|
4
|
+
module Logger
|
|
5
|
+
RESET = "\e[0m"
|
|
6
|
+
BOLD = "\e[1m"
|
|
7
|
+
CYAN = "\e[36m"
|
|
8
|
+
BLUE = "\e[34m"
|
|
9
|
+
YELLOW = "\e[33m"
|
|
10
|
+
|
|
11
|
+
def self.log_cache_hit(operation_name, duration_ms, sql)
|
|
12
|
+
return unless should_log?
|
|
13
|
+
|
|
14
|
+
opt_part = colorize("SMART CACHE #{operation_name} (#{format_duration(duration_ms)})", CYAN + BOLD)
|
|
15
|
+
sql_part = colorize(" #{sql}", BLUE + BOLD)
|
|
16
|
+
|
|
17
|
+
Rails.logger.debug(" #{opt_part}#{sql_part}")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def self.should_log?
|
|
23
|
+
SmartCacheTenant.config.log_queries && Rails.logger.debug? && !Rails.env.production?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.format_duration(ms)
|
|
27
|
+
if ms >= 1000
|
|
28
|
+
"#{(ms / 1000.0).round(1)}s"
|
|
29
|
+
else
|
|
30
|
+
"#{ms.round(1)}ms"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.colorize(text, color_code)
|
|
35
|
+
if ActiveSupport::LogSubscriber.colorize_logging
|
|
36
|
+
"#{color_code}#{text}#{RESET}"
|
|
37
|
+
else
|
|
38
|
+
text
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SmartCacheTenant
|
|
4
|
+
class VersionStore
|
|
5
|
+
def self.current(model_klass, tenant_id = nil)
|
|
6
|
+
key = build_key(model_klass, tenant_id)
|
|
7
|
+
Rails.cache.fetch(key, expires_in: SmartCacheTenant.config.ttl) { generate_version }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.bump!(model_klass, tenant_id = nil)
|
|
11
|
+
key = build_key(model_klass, tenant_id)
|
|
12
|
+
new_version = generate_version
|
|
13
|
+
Rails.cache.write(key, new_version, expires_in: SmartCacheTenant.config.ttl)
|
|
14
|
+
|
|
15
|
+
new_version
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.build_key(model_klass, tenant_id = nil)
|
|
19
|
+
database_name = model_klass.connection_db_config.database
|
|
20
|
+
tenant_column = SmartCacheTenant.config.tenant_column
|
|
21
|
+
|
|
22
|
+
parts = [database_name, 'smart_cache', 'table_version', model_klass.table_name]
|
|
23
|
+
parts << "#{tenant_column}:#{tenant_id}" if tenant_id.present? && tenant_column.present?
|
|
24
|
+
parts.join(':').downcase
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.generate_version
|
|
28
|
+
Time.zone.now.strftime('%d%m%Y%H%M%S%L')
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'smart_cache_tenant/cacheable_persistence'
|
|
4
|
+
require_relative 'smart_cache_tenant/cacheable_relation'
|
|
5
|
+
require_relative 'smart_cache_tenant/configuration'
|
|
6
|
+
require_relative 'smart_cache_tenant/model_callbacks'
|
|
7
|
+
require_relative 'smart_cache_tenant/railtie'
|
|
8
|
+
require_relative 'smart_cache_tenant/smart_cache_logger'
|
|
9
|
+
require_relative 'smart_cache_tenant/version_store'
|
|
10
|
+
require_relative "smart_cache_tenant/version"
|
|
11
|
+
|
|
12
|
+
module SmartCacheTenant
|
|
13
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: smart_cache_tenant
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Henrique A. Shiraishi
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-05-01 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: SmartCacheTenant adds tenant-aware query caching on top of ActiveRecord
|
|
14
|
+
by storing cached read results in Rails.cache and invalidating them through lightweight
|
|
15
|
+
version keys.
|
|
16
|
+
email:
|
|
17
|
+
- henriqueashiraishi@gmail.com
|
|
18
|
+
executables: []
|
|
19
|
+
extensions: []
|
|
20
|
+
extra_rdoc_files: []
|
|
21
|
+
files:
|
|
22
|
+
- ".ruby-version"
|
|
23
|
+
- CHANGELOG.md
|
|
24
|
+
- LICENSE.txt
|
|
25
|
+
- README.md
|
|
26
|
+
- Rakefile
|
|
27
|
+
- lib/smart_cache_tenant.rb
|
|
28
|
+
- lib/smart_cache_tenant/cacheable_persistence.rb
|
|
29
|
+
- lib/smart_cache_tenant/cacheable_relation.rb
|
|
30
|
+
- lib/smart_cache_tenant/configuration.rb
|
|
31
|
+
- lib/smart_cache_tenant/model_callbacks.rb
|
|
32
|
+
- lib/smart_cache_tenant/railtie.rb
|
|
33
|
+
- lib/smart_cache_tenant/smart_cache_logger.rb
|
|
34
|
+
- lib/smart_cache_tenant/version.rb
|
|
35
|
+
- lib/smart_cache_tenant/version_store.rb
|
|
36
|
+
- sig/smart_cache_tenant.rbs
|
|
37
|
+
homepage: https://github.com/henriqueshiraishi/ruby-smart_cache_tenant
|
|
38
|
+
licenses:
|
|
39
|
+
- MIT
|
|
40
|
+
metadata:
|
|
41
|
+
homepage_uri: https://github.com/henriqueshiraishi/ruby-smart_cache_tenant
|
|
42
|
+
source_code_uri: https://github.com/henriqueshiraishi/ruby-smart_cache_tenant
|
|
43
|
+
changelog_uri: https://github.com/henriqueshiraishi/ruby-smart_cache_tenant/blob/main/CHANGELOG.md
|
|
44
|
+
post_install_message:
|
|
45
|
+
rdoc_options: []
|
|
46
|
+
require_paths:
|
|
47
|
+
- lib
|
|
48
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - ">="
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: 3.3.0
|
|
53
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
54
|
+
requirements:
|
|
55
|
+
- - ">="
|
|
56
|
+
- !ruby/object:Gem::Version
|
|
57
|
+
version: '0'
|
|
58
|
+
requirements: []
|
|
59
|
+
rubygems_version: 3.5.3
|
|
60
|
+
signing_key:
|
|
61
|
+
specification_version: 4
|
|
62
|
+
summary: Tenant-aware query caching for ActiveRecord.
|
|
63
|
+
test_files: []
|