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 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).