query_stash 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +477 -0
- data/lib/query_stash/active_record.rb +188 -0
- data/lib/query_stash/base.rb +275 -0
- data/lib/query_stash/mongo.rb +152 -0
- data/lib/query_stash/railtie.rb +150 -0
- data/lib/query_stash/registry/sql.rb +17 -0
- data/lib/query_stash/registry.rb +176 -0
- data/lib/query_stash/sequel.rb +155 -0
- data/lib/query_stash/version.rb +6 -0
- data/lib/query_stash.rb +47 -0
- data/lib/sequel/extensions/query_stash.rb +5 -0
- data/sig/query_stash/active_record.rbs +118 -0
- data/sig/query_stash/base.rbs +151 -0
- data/sig/query_stash/mongo.rbs +90 -0
- data/sig/query_stash/railtie.rbs +24 -0
- data/sig/query_stash/registry/sql.rbs +11 -0
- data/sig/query_stash/registry.rbs +114 -0
- data/sig/query_stash/sequel.rbs +97 -0
- data/sig/query_stash/version.rbs +6 -0
- data/sig/query_stash.rbs +41 -0
- data/sig/sequel/extensions/query_stash.rbs +2 -0
- metadata +82 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d529606d76f063f11ac7a7abb456993d0246b0e3f2e36e7251e9ea5529fe5e29
|
|
4
|
+
data.tar.gz: 0e9bfa4fb79a9e94702592cbff12322e20c9183d3e696930aa560c0f936f6ab9
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 32dca414d0f63f2930d590bcdce47198606eeb2de6241ae14ebcffbfa81a2260b7dc657b9a3d4cd69371dc3e14e87f62f23a29b27fae712746cabda179eff105
|
|
7
|
+
data.tar.gz: 69e189314e960fa8671bbba95b60f61ef6dfe85c4678cece2d1c3d9d947df69c56938ea2f76691ee78f1bcdb5b072866556258c42ff6ac1bfad274047423dd30
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Persona
|
|
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,477 @@
|
|
|
1
|
+
# QueryStash
|
|
2
|
+
|
|
3
|
+
Cross-request query result caching for the handful of hot, slow-changing lookups you choose.
|
|
4
|
+
|
|
5
|
+
Currently shipping with adapters for [activerecord](https://rubygems.org/gems/activerecord), [sequel](https://rubygems.org/gems/sequel) and [mongo](https://rubygems.org/gems/mongo).
|
|
6
|
+
|
|
7
|
+
QueryStash can keep the result of an allowlisted query across requests and jobs, for a TTL you compute from the result itself, in a store of your choosing. It hooks the database adapter (and the Sequel and Mongo drivers), so call sites don't need to change: `Organization.find(id)` is served from the stash when a rule matches it.
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
QueryStash::ActiveRecord[Rails.application].configure do |rules|
|
|
11
|
+
rules.add_rule(
|
|
12
|
+
/\ASELECT "plans"\.\* FROM "plans" WHERE "plans"\."id" = /,
|
|
13
|
+
name: "plan_by_id",
|
|
14
|
+
ttl_seconds: ->(result) { result.rows.any? ? 60 : 0 }, # don't cache "not found"
|
|
15
|
+
ttl_jitter_seconds: 10
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
This gem was extracted from code we've been running in production at [Persona](https://withpersona.com).
|
|
21
|
+
|
|
22
|
+
## When to use it, and when not to
|
|
23
|
+
|
|
24
|
+
Use it for point lookups that are read far more often than they change, and where a bounded amount of staleness is acceptable.
|
|
25
|
+
|
|
26
|
+
What it does not do:
|
|
27
|
+
|
|
28
|
+
- **No invalidation.** Entries leave on TTL (plus jitter) or eviction. A write does not clear the stash, so a read after a write can return the old row until the TTL runs out. Wrap read-after-write code in `disable!`, or don't stash those queries.
|
|
29
|
+
- **Inlined SQL only.** Queries with bind parameters are passed through untouched. Use an adapter or configuration that inlines values (Trilogy and mysql2 do by default; set `prepared_statements: false` for SQLite or PostgreSQL).
|
|
30
|
+
- **`SELECT` only**, matched with `/\ASELECT/i`. SQL that starts with a comment, whitespace or a `WITH` clause is never stashed.
|
|
31
|
+
- **Not transaction-aware.** A matching read inside a transaction can be served from the stash instead of seeing the transaction's own writes.
|
|
32
|
+
- **`load_async` queries bypass it.**
|
|
33
|
+
- The ActiveRecord cache key is the SQL plus `ActiveRecord::Base.current_shard`. It does not include the role or the database name.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
bundle add query_stash
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
ActiveRecord 7.2 or more recent and Ruby 3.3 or more recent are supported.
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
### Turning it on and off
|
|
46
|
+
|
|
47
|
+
#### Rails
|
|
48
|
+
|
|
49
|
+
In rails, `config.query_stash.enabled`, (true by default) decides whether queries are stashed. With the default, every query that matches a rule is stashed, including in requests, active jobs, the console, rake tasks and migrations.
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
# config/development.rb
|
|
53
|
+
config.query_stash.enabled = false
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`enable!` and `disable!` can override it for the duration of a block:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
QueryStash::ActiveRecord[Rails.application].enable! do
|
|
60
|
+
run_job
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Opt out where reads must see their own writes:
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
QueryStash::ActiveRecord[Rails.application].disable! do
|
|
68
|
+
record.update!(status: "active")
|
|
69
|
+
Record.find(record.id) # straight from the database
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### ActiveRecord rules
|
|
74
|
+
|
|
75
|
+
Configure their rules from an initializer.
|
|
76
|
+
|
|
77
|
+
Rules match the SQL with a `Regexp`, first match wins:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
QueryStash::ActiveRecord[Rails.application].configure do |rules|
|
|
81
|
+
rules.add_rule(
|
|
82
|
+
/\ASELECT "templates"\.\* FROM "templates" WHERE "templates"\."id" = /,
|
|
83
|
+
name: "published_template_by_id",
|
|
84
|
+
ttl_seconds: ->(result) { result.to_a.all? { |row| row["status"] == "published" } ? 1.day : 0 },
|
|
85
|
+
ttl_jitter_seconds: 1.hour,
|
|
86
|
+
enabled: -> { Flipper.enabled?(:stash_templates) }
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
Besides the regex, the following parameters are accepted as keyword arguments:
|
|
91
|
+
- `name` identifies the rule.
|
|
92
|
+
- `ttl_seconds` and `ttl_jitter_seconds` are integers or callables that receive the uncached result. A TTL of 0 or less means "don't cache this result" (a *skip*). Entries are written with `expires_in: ttl_seconds + rand(ttl_jitter_seconds)`, so entries for a hot rule don't all expire at once.
|
|
93
|
+
- `enabled` turns a single rule off without removing it.
|
|
94
|
+
|
|
95
|
+
To avoid repeating options, set defaults in the `configure` block: the rules added after that take them for what they don't set.
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
QueryStash::ActiveRecord[Rails.application].configure do |registry|
|
|
99
|
+
registry.set_rule_defaults(ttl_seconds: 60, ttl_jitter_seconds: 10)
|
|
100
|
+
|
|
101
|
+
registry.add_rule(/\ASELECT .*"plans"/, name: "plans") # 60 seconds, 10 of jitter
|
|
102
|
+
registry.add_rule(/\ASELECT .*"users"/, name: "users", ttl_seconds: 5) # 5 seconds, 10 of jitter
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`ttl_seconds`, `ttl_jitter_seconds`, `enabled` and `store` can have defaults. Rules that exist are not changed by a later call, which only changes the options it names (`nil` takes a default away). The same goes for the Sequel and Mongo rules.
|
|
107
|
+
|
|
108
|
+
### Sequel rules
|
|
109
|
+
|
|
110
|
+
The Sequel adapter works like the ActiveRecord one: rules are regexps matched against the SQL a dataset is about to run, only `SELECT`s are stashed, and queries with bind variables or prepared statements (and PostgreSQL cursors) are passed through. A stashed query is loaded in full, so a rule is not for a streamed result set.
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
QueryStash::Sequel[Rails.application].configure do |rules|
|
|
114
|
+
rules.add_rule(
|
|
115
|
+
/\ASELECT \* FROM `plans` WHERE \(`id` = \d+\)/,
|
|
116
|
+
name: "plan_by_id",
|
|
117
|
+
ttl_seconds: ->(rows) { rows.any? ? 60 : 0 }, # don't cache "not found"
|
|
118
|
+
ttl_jitter_seconds: 10
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`ttl_seconds` and `ttl_jitter_seconds` callables receive the rows: an array of hashes, before any model is built from them. The cache key is the SQL, the database (adapter, host and name) and the server the dataset uses. Use whatever quoting your Sequel database does when you write the regexp: `db["SELECT 1"].sql` shows it.
|
|
124
|
+
|
|
125
|
+
A database is stashed once it loads the extension, which gives it a stash of its own:
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
DB = Sequel.connect(url, query_stash: { cache_store: MyCache.new, enabled: true })
|
|
129
|
+
DB.extension(:query_stash)
|
|
130
|
+
QueryStash::Sequel[DB].configure { |rules| rules.add_rule(...) }
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The `query_stash` option takes `cache_store:` (required) and the other options of `QueryStash::Base#initialize`.
|
|
134
|
+
|
|
135
|
+
With [sequel-rails](https://rubygems.org/gems/sequel-rails) in the app, the railtie does the rest: the middleware, the jobs, and the database sequel-rails connected (`Sequel::Model.db`), all with the app's stash (`QueryStash::Sequel[Rails.application]`, configured by `config.query_stash`). Another database of the app only needs `OTHER_DB.extension(:query_stash)`, and gets the app's stash too.
|
|
136
|
+
|
|
137
|
+
### Mongo rules
|
|
138
|
+
|
|
139
|
+
Mongo rules match a selector built from the view: `collection_name`, `filter`, `limit`, `skip`, `sort`, `projection` and `pipeline`.
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
QueryStash::Mongo[Rails.application].configure do |rules|
|
|
143
|
+
rules.add_rule(
|
|
144
|
+
{
|
|
145
|
+
collection_name: "accounts",
|
|
146
|
+
filter: { "token" => QueryStash::Mongo::ANY },
|
|
147
|
+
limit: nil, skip: nil, sort: nil, projection: nil, pipeline: nil
|
|
148
|
+
},
|
|
149
|
+
name: "account_by_token",
|
|
150
|
+
ttl_seconds: ->(result) { result.any? ? 10.minutes : 0 },
|
|
151
|
+
ttl_jitter_seconds: 10
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Patterns:
|
|
157
|
+
|
|
158
|
+
| Pattern | Matches when |
|
|
159
|
+
|---|---|
|
|
160
|
+
| `QueryStash::Mongo::ANY` | the key is present, with any value |
|
|
161
|
+
| a Proc | `pattern.call(actual)` is truthy |
|
|
162
|
+
| a Regexp | `actual.to_s` matches |
|
|
163
|
+
| a Hash | every key in the pattern is present and matches; extra keys in the query are ignored |
|
|
164
|
+
| an Array | it is a prefix of the actual array, element by element |
|
|
165
|
+
| anything else | `actual == pattern`, so `limit: nil` excludes limited queries |
|
|
166
|
+
|
|
167
|
+
Hash patterns are subset matches: `filter: { "token" => ANY }` also matches `{ token: x, deleted_at: nil }`. Mongo queries are easy to cache by accident; test every rule against the queries your app actually sends.
|
|
168
|
+
|
|
169
|
+
Queries that match no rule stream through the driver untouched. Matching queries are loaded fully into memory so they can be cached, so don't write rules for large result sets.
|
|
170
|
+
|
|
171
|
+
A Mongo rule can be routed to its own store with `store:`.
|
|
172
|
+
|
|
173
|
+
### Stores
|
|
174
|
+
|
|
175
|
+
Set the store with `config.query_stash.cache_store`. It takes the same values as Rails' `config.cache_store`: a store name followed by its
|
|
176
|
+
arguments, or a store instance. The default is `[:memory_store, { size: 128.megabytes, compress: true }]`, one store per process. ActiveRecord and Mongo each build their own store from it, except from a store instance, which they share:
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
# config/production.rb
|
|
180
|
+
config.query_stash.cache_store = :redis_cache_store, {
|
|
181
|
+
url: ENV["STASH_REDIS_URL"],
|
|
182
|
+
namespace: "query-stash:#{Rails.env}"
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
# or, shared by the processes on one machine:
|
|
186
|
+
config.query_stash.cache_store = :file_store, "tmp/query-stash"
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
- **MemoryStore**: one copy per process, no network hop. Keep `compress: true`; see the history for why.
|
|
190
|
+
- **Redis/Valkey**: shared across processes and hosts. If several environments share one cluster, put the environment in the namespace. The cache key doesn't include it, and two environments with the same database name would otherwise read each other's entries.
|
|
191
|
+
- **`file_store`**: Rails' own, shared by all processes on one machine. It has no LRU and no size cap, and nothing removes the entries that expired until `cleanup` is called on it, so size your rules so the directory can't fill the disk. ActiveRecord and Mongo would share the directory: give each its own with `config.query_stash.active_record.cache_store` and `config.query_stash.mongo.cache_store`.
|
|
192
|
+
|
|
193
|
+
### Options per adapter
|
|
194
|
+
|
|
195
|
+
`config.query_stash.active_record`, `config.query_stash.sequel` and `config.query_stash.mongo` (each one
|
|
196
|
+
exists when that framework is loaded) can set `enabled`, `isolated_state`,
|
|
197
|
+
`instrumenter`, `error_reporter` and `cache_store` for their adapter alone.
|
|
198
|
+
Whatever one of them doesn't set, it takes from `config.query_stash`, even if
|
|
199
|
+
that is set after it:
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
# config/application.rb
|
|
203
|
+
config.query_stash.cache_store = :redis_cache_store, { url: ENV["STASH_REDIS_URL"] }
|
|
204
|
+
config.query_stash.mongo.cache_store = :memory_store, { size: 32.megabytes }
|
|
205
|
+
config.query_stash.active_record.enabled = false
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Here ActiveRecord is only stashed inside `enable!` blocks, in the Redis store,
|
|
209
|
+
and Mongo is always stashed, in memory. An option that is set to `false` or
|
|
210
|
+
`nil` in a section counts as set. `raise_on_cache_error` is the same for both,
|
|
211
|
+
and read from `config.query_stash`.
|
|
212
|
+
|
|
213
|
+
They are read once, before `config/initializers` run, like the rest of
|
|
214
|
+
`config.query_stash`.
|
|
215
|
+
|
|
216
|
+
### Threads
|
|
217
|
+
|
|
218
|
+
What `enable!` and `disable!` set lives in `config.query_stash.isolated_state`, which is `ActiveSupport::IsolatedExecutionState` by default for Ruby on Rails, so threads spawned inside those blocks don't inherit it; they follow `config.query_stash.enabled` instead. Call `enable!` or `disable!` inside the thread to choose:
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
Thread.new do
|
|
222
|
+
QueryStash::ActiveRecord[Rails.application].enable! { Organization.find(id) }
|
|
223
|
+
end
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Any object that responds to `[]`, `[]=`, `key?` and `delete` can be the isolated state, if your app keeps its per-request state somewhere else:
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
config.query_stash.isolated_state = MyApp::CurrentContext
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Without one, the stash keeps it in a thread variable: shared by the fibers of a thread, and not by other threads.
|
|
233
|
+
|
|
234
|
+
### Instrumentation
|
|
235
|
+
|
|
236
|
+
| Event | Payload |
|
|
237
|
+
|---|---|
|
|
238
|
+
| `cache_read.query_stash` | `adapter`, `rule`, `hit` |
|
|
239
|
+
| `cache_hit.query_stash` | `adapter`, `rule` |
|
|
240
|
+
| `cache_miss.query_stash` | `adapter`, `rule` |
|
|
241
|
+
| `cache_skip.query_stash` | `adapter`, `rule` |
|
|
242
|
+
|
|
243
|
+
A stash hit never reaches the adapter, so it fires no `sql.active_record` event. Subscribe to `cache_hit.query_stash` if you count queries per request.
|
|
244
|
+
|
|
245
|
+
`cache_miss.query_stash` fires when a result is about to be stored, and `cache_skip.query_stash` when its TTL says not to store it.
|
|
246
|
+
|
|
247
|
+
To run something for a specific rule, subscribe to the event and filter on the rule's name, which is in the payload:
|
|
248
|
+
|
|
249
|
+
```ruby
|
|
250
|
+
ActiveSupport::Notifications.subscribe("cache_hit.query_stash") do |event|
|
|
251
|
+
next unless event.payload[:rule] == "published_template_by_id"
|
|
252
|
+
|
|
253
|
+
StatsD.increment("query_stash.hit", tags: ["rule:#{event.payload[:rule]}"])
|
|
254
|
+
end
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
The events go to `config.query_stash.instrumenter`, which is `ActiveSupport::Notifications` by default. Any object with `instrument(name, payload) { |payload| ... }` works.
|
|
258
|
+
|
|
259
|
+
### Errors
|
|
260
|
+
|
|
261
|
+
Cache errors never fail the query: the read falls back to the database and a failed write is dropped. Errors go to `config.query_stash.error_reporter` (`ActiveSupport.error_reporter` by default; any object with `report(error, handled: true)` works), except in development and test, where they are raised. Set `config.query_stash.raise_on_cache_error` to change that.
|
|
262
|
+
|
|
263
|
+
## Without Rails
|
|
264
|
+
|
|
265
|
+
Outside of rails, you must set your application stash manually, from the options the railtie would otherwise
|
|
266
|
+
read from `config.query_stash`:
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
QueryStash::ActiveRecord.set(
|
|
270
|
+
app,
|
|
271
|
+
QueryStash::ActiveRecord.new(
|
|
272
|
+
cache_store: MyCache.new, # required: any object with `read`, `write(key, value, expires_in:)` and `clear`
|
|
273
|
+
enabled: true,
|
|
274
|
+
isolated_state: MyApp::CurrentContext, # `[]`, `[]=`, `key?` and `delete`; by default, a thread variable
|
|
275
|
+
instrumenter: MyApp::Instrumenter, # `instrument(name, payload) { ... }`; by default, no one
|
|
276
|
+
error_reporter: MyApp::Errors, # `report(error, handled: true)`; by default, a warning on stderr
|
|
277
|
+
raise_on_cache_error: false
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### Write your own stash adapter
|
|
283
|
+
|
|
284
|
+
An adapter is a subclass of `QueryStash::Base` plus a patch of whatever runs its queries. The patch asks the
|
|
285
|
+
stash three things, in this order:
|
|
286
|
+
|
|
287
|
+
1. `rule_for(*args)`: the rule of the registry the query is stashed under, or `nil`, and then the query just runs.
|
|
288
|
+
2. `read(rule, *args)`: the stored result, or `nil`.
|
|
289
|
+
3. `write(rule, result, *args)`: stores the result, and returns it.
|
|
290
|
+
|
|
291
|
+
`*args` is whatever identifies a query to your adapter, and is the same for `read`, `write` and `cache_key_for`.
|
|
292
|
+
`rule_for` and `cache_key_for` are yours to define. `read` and `write` come from `QueryStash::Base`: they read and
|
|
293
|
+
write the cache store, emit the `cache_read`, `cache_hit`, `cache_miss` and `cache_skip` events
|
|
294
|
+
and handle cache errors, so override them only if you need something else. Results must never be `nil`,
|
|
295
|
+
since `nil` means a miss.
|
|
296
|
+
|
|
297
|
+
```ruby
|
|
298
|
+
class MyDriverStash < QueryStash::Base
|
|
299
|
+
# `stashing?` is false when the stash is off.
|
|
300
|
+
def rule_for(command)
|
|
301
|
+
registry.match_rule(command.to_h) if stashing?
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def cache_key_for(command)
|
|
305
|
+
"v1:#{Digest::SHA256.hexdigest(command.to_json)}"
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# The patch calls this in place of running the command.
|
|
309
|
+
def wrap(command)
|
|
310
|
+
rule = rule_for(command)
|
|
311
|
+
return yield unless rule
|
|
312
|
+
|
|
313
|
+
read(rule, command) || write(rule, yield, command)
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
MyDriverStash.set(app, MyDriverStash.new(cache_store: MyCache.new))
|
|
318
|
+
MyDriverStash[app].enable! { ... }
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Override `build_registry` to return a `QueryStash::Registry` subclass (`QueryStash::Registry::Sql` for rules that are regexps on SQL) if your rules need their own matching,
|
|
322
|
+
like the SQL and Mongo ones. The events carry the adapter's name, taken from the class name (`MyDriverStash` is
|
|
323
|
+
`:my_driver_stash`).
|
|
324
|
+
|
|
325
|
+
## Why this repo has history from before it existed
|
|
326
|
+
|
|
327
|
+
QueryStash ran in production as `ProcessQueryCache` inside Persona's Rails
|
|
328
|
+
monolith. When we extracted it, we did not start from one "initial commit."
|
|
329
|
+
We replayed the commits that shaped the library, each attributed to the
|
|
330
|
+
engineer who made the change, with Persona's internals removed.
|
|
331
|
+
|
|
332
|
+
Anyone can copy code now. What's hard to copy is why the code looks the way
|
|
333
|
+
it does, and that was learned in production:
|
|
334
|
+
|
|
335
|
+
- a TTL that never applied, because MemoryStore ignores `ttl:`
|
|
336
|
+
- a Rails upgrade that quietly routed every query around the patch
|
|
337
|
+
- spawned threads that bypassed the cache for months
|
|
338
|
+
- hand-rolled compression that left three copies of every blob on the heap
|
|
339
|
+
- a Mongo wrapper that turned streaming cursors into full loads and leaked
|
|
340
|
+
memory on a large job
|
|
341
|
+
- a fix that was lost when a refactor merged over it
|
|
342
|
+
|
|
343
|
+
`git log --reverse` reads as the record of what broke and how it was fixed.
|
|
344
|
+
Some things to know when reading it:
|
|
345
|
+
|
|
346
|
+
- **Authors and dates.** Each replayed commit keeps its original author and
|
|
347
|
+
author date. The committer is the person who replayed it, with the replay
|
|
348
|
+
date. That's why the scaffold commit (October 2026) comes before commits
|
|
349
|
+
authored in 2025.
|
|
350
|
+
- **Replay notes.** These explain where the gem had to differ from the
|
|
351
|
+
original, for example a feature flag became a callable. Messages are the
|
|
352
|
+
authors' own, edited only to remove internal details.
|
|
353
|
+
- **Scope.** Only commits that changed the library itself were replayed.
|
|
354
|
+
Rule tuning, rollouts and app wiring stayed behind; what they taught is in
|
|
355
|
+
the field notes below.
|
|
356
|
+
- **Bugs.** Bugs are replayed as they happened, so early commits contain
|
|
357
|
+
bugs that later commits fix.
|
|
358
|
+
- **Naming.** The code was a "global query cache" in its first draft and
|
|
359
|
+
`ProcessQueryCache` in production. It became QueryStash on extraction,
|
|
360
|
+
because by then the store could be a file shared by every process on a
|
|
361
|
+
machine, or a Redis cluster shared by every machine, and "process" no
|
|
362
|
+
longer described it.
|
|
363
|
+
|
|
364
|
+
## Field notes
|
|
365
|
+
|
|
366
|
+
These are lessons from running it, mostly from changes to rules and app
|
|
367
|
+
wiring that aren't in this repo's history.
|
|
368
|
+
|
|
369
|
+
- **Start with immutable rows.** The first rule cached published template
|
|
370
|
+
versions for a day and drafts not at all. Let the TTL callable decide from
|
|
371
|
+
the result (@RickCSong, @ErnestChiu2023).
|
|
372
|
+
- **Don't cache "not found."** Return a TTL of 0 for empty results, or a
|
|
373
|
+
lookup that runs before a row exists keeps missing it for the whole TTL
|
|
374
|
+
(@4ndypanda, @RickCSong).
|
|
375
|
+
- **The TTL callable is a second allowlist.** One broad rule over a routing
|
|
376
|
+
table cached only the class/key pairs its TTL lambda allowed, and skipped
|
|
377
|
+
the rest (@RickCSong).
|
|
378
|
+
- **Avoid IN-list rules.** `WHERE id IN (...)` keys change with list
|
|
379
|
+
membership on every request, so they churn the LRU with near-duplicate
|
|
380
|
+
multi-megabyte entries. Look up rows one id at a time so they hit
|
|
381
|
+
per-row entries (@4ndypanda, @HoneyryderChuck).
|
|
382
|
+
- **Size rules against real rows.** A rule over an 8 KiB-per-row table with
|
|
383
|
+
hundreds of thousands of rows can't live in a 128MB MemoryStore. The file
|
|
384
|
+
store has no size cap at all, so the same rule can fill a disk
|
|
385
|
+
(@malavbhavsar).
|
|
386
|
+
- **The TTL on credential lookups bounds revocation.** A cached API key
|
|
387
|
+
lookup keeps working until the TTL expires. Pick that TTL as a security
|
|
388
|
+
decision, not a performance one; it was halved from an hour to 30 minutes
|
|
389
|
+
(@cah6).
|
|
390
|
+
- **Turn it off where reads must see writes.** Low-traffic admin pages,
|
|
391
|
+
dashboards, and jobs that read back what they just wrote all run
|
|
392
|
+
`without_query_stash` (@RickCSong).
|
|
393
|
+
- **Big streaming jobs opt out.** Until queries without a matching rule
|
|
394
|
+
stopped being buffered, one large job leaked memory fast. It was opted out
|
|
395
|
+
first, then the root cause was fixed (@rpeng).
|
|
396
|
+
- **Workflow engines: wrap activities, not workflows.** Every database read
|
|
397
|
+
happens inside an activity, so stashing there covers all of the query
|
|
398
|
+
volume while keeping TTL-stale reads out of workflow decision code
|
|
399
|
+
(@malavbhavsar).
|
|
400
|
+
- **Watch your Redis client wrapper across Rails upgrades.**
|
|
401
|
+
`RedisCacheStore` changed how it builds connection pools in 7.1 (pass
|
|
402
|
+
`pool: false` if you hand it a pool) and probes `wrapped_pool` in 8.1
|
|
403
|
+
(@HoneyryderChuck, @segiddins).
|
|
404
|
+
- **Namespace shared stores by environment** (@ariel-ariel-liao,
|
|
405
|
+
@jgandt-wp).
|
|
406
|
+
- **To debug a high miss rate, make the misses loud.** The file store
|
|
407
|
+
swallowed its ENOENTs. Re-raising them for two days was a way to check
|
|
408
|
+
whether they lined up with the misses (@HoneyryderChuck).
|
|
409
|
+
|
|
410
|
+
## Known issues
|
|
411
|
+
|
|
412
|
+
These are documented here and will be fixed after 0.1.0.
|
|
413
|
+
|
|
414
|
+
- A jitter of 0 still adds a fraction of a second, because `rand(0)` returns
|
|
415
|
+
a Float.
|
|
416
|
+
- Mongo rules ignore `enabled:`.
|
|
417
|
+
- ActiveRecord rules ignore `store:`; only Mongo rules route per rule.
|
|
418
|
+
- Mongo aggregation views have no query filter; the selector's `filter` is
|
|
419
|
+
`Enumerable#filter` there, so filter patterns don't apply to aggregations.
|
|
420
|
+
- The file store's background threads are never stopped.
|
|
421
|
+
- The file store measures its size with GNU `du -b`, so on macOS it reports
|
|
422
|
+
0 and logs the error.
|
|
423
|
+
|
|
424
|
+
## Development
|
|
425
|
+
|
|
426
|
+
```sh
|
|
427
|
+
bin/setup
|
|
428
|
+
bundle exec rake # specs + RuboCop
|
|
429
|
+
BUNDLE_GEMFILE=gemfiles/activerecord_7_2.gemfile bundle exec rspec
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
The specs boot a Rails app in `spec/dummy`. It uses an in-memory SQLite
|
|
433
|
+
database unless `DATABASE_URL` points somewhere else. The database is
|
|
434
|
+
created if it doesn't exist:
|
|
435
|
+
|
|
436
|
+
```sh
|
|
437
|
+
DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/query_stash_test bundle exec rspec
|
|
438
|
+
DATABASE_URL=trilogy://root:mysql@127.0.0.1:3306/query_stash_test bundle exec rspec
|
|
439
|
+
DATABASE_URL=mongodb://127.0.0.1:27017/query_stash_test bundle exec rspec
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
Examples tagged `database: :active_record` or `database: :mongo` only run
|
|
443
|
+
against that kind of database.
|
|
444
|
+
|
|
445
|
+
### Types
|
|
446
|
+
|
|
447
|
+
The types are annotations in `lib/` ([rbs-inline](https://github.com/soutaro/rbs-inline)),
|
|
448
|
+
checked with [Steep](https://github.com/soutaro/steep), and exported to `sig/`
|
|
449
|
+
for the people who use the gem. Never edit `sig/` by hand:
|
|
450
|
+
|
|
451
|
+
```sh
|
|
452
|
+
bundle exec rbs collection init # once: writes the (git-ignored) rbs_collection.yaml
|
|
453
|
+
bundle exec rbs collection install # once: the types of the gems we call into
|
|
454
|
+
bundle exec rake sig:export # lib/ annotations -> sig/
|
|
455
|
+
bundle exec rake types # sig/ is up to date, and steep check passes
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
Every file in `lib/` is exported (`rbs-inline --opt-out`, which the Rake tasks use), so a new
|
|
459
|
+
file has signatures from the start, `untyped` ones until it is annotated. A file can opt out with
|
|
460
|
+
`# rbs_inline: disabled`.
|
|
461
|
+
|
|
462
|
+
CI runs the last one (`rake sig:verify`, then `steep check`), so a change to an
|
|
463
|
+
annotation has to come with the re-exported `sig/`.
|
|
464
|
+
|
|
465
|
+
- `sig_stubs/` has what neither the annotations nor the collection cover: a few
|
|
466
|
+
parts of the Mongo driver and Rails.
|
|
467
|
+
- The Rails DSL in `lib/query_stash/railtie.rb` is not type checked
|
|
468
|
+
(`# steep:ignore`): Rails runs those blocks with another `self`, and the
|
|
469
|
+
collection has no types for them.
|
|
470
|
+
- Annotate with `#: (String) -> Integer` above a `def`. Multi-line types need
|
|
471
|
+
`# @rbs param: Type` lines instead, and `Data.define` blocks are not read, so
|
|
472
|
+
reopen the class to add methods (see `Registry::Rule`).
|
|
473
|
+
|
|
474
|
+
## License
|
|
475
|
+
|
|
476
|
+
Available as open source under the terms of the
|
|
477
|
+
[MIT License](https://opensource.org/licenses/MIT).
|