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.
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "base"
5
+ require_relative "registry/sql"
6
+
7
+ module QueryStash
8
+ class ActiveRecord < Base
9
+ # What the patches ask of Rails' own objects.
10
+ #
11
+ # @rbs!
12
+ # # An ActiveRecord adapter, up to Rails 8.1.
13
+ # interface _InternalExecQueryAdapter
14
+ # def internal_exec_query: (String sql, ?String name, ?Array[untyped] binds, **untyped) -> ::ActiveRecord::Result
15
+ # end
16
+ #
17
+ # # An ActiveRecord adapter, from Rails 8.2.
18
+ # interface _IntentAdapter
19
+ # def execute_intent: (_QueryIntent intent) -> untyped
20
+ #
21
+ # def cast_result: (untyped raw_result) -> ::ActiveRecord::Result
22
+ # end
23
+ #
24
+ # # A query, from Rails 8.2.
25
+ # interface _QueryIntent
26
+ # def raw_sql: () -> String
27
+ #
28
+ # def has_binds?: () -> bool
29
+ #
30
+ # def deliver_result: (::ActiveRecord::Result result) -> untyped
31
+ #
32
+ # def cast_result: () -> ::ActiveRecord::Result
33
+ # end
34
+
35
+ CACHEABLE_SQL_REGEX = /\ASELECT/i #: Regexp
36
+ private_constant :CACHEABLE_SQL_REGEX
37
+
38
+ # Enables the stash for the duration of each request.
39
+ class Middleware
40
+ # @rbs @app: _RackApp
41
+ # @rbs @application: untyped
42
+
43
+ #: (_RackApp app, untyped application) -> void
44
+ def initialize(app, application)
45
+ @app = app
46
+ @application = application
47
+ end
48
+
49
+ #: (Hash[String, untyped] env) -> untyped
50
+ def call(env)
51
+ QueryStash::ActiveRecord[@application].enable! { @app.call(env) }
52
+ end
53
+ end
54
+
55
+ # Patch wraps queries run through `internal_exec_query` (rails 7.2 to 8.1).
56
+ # See `QueryStash::ActiveRecord.implementation`.
57
+ module InternalExecQuery
58
+ # @rbs module-self _InternalExecQueryAdapter
59
+ module AdapterPatch
60
+ # @rbs override
61
+ def internal_exec_query(sql, name = "SQL", binds = [], ...) # steep:ignore UnannotatedEmptyCollection
62
+ stash = QueryStash::ActiveRecord[Rails.application] #: QueryStash::ActiveRecord & Stash
63
+ stash.wrap(sql, binds) { super(sql, name, binds, ...) }
64
+ end
65
+ end
66
+
67
+ # @rbs module-self QueryStash::ActiveRecord
68
+ module Stash
69
+ # Runs the query through the stash, or just yields when the stash is off or the query isn't
70
+ # stashable.
71
+ #: (String sql, Array[untyped]? binds) { () -> ::ActiveRecord::Result } -> ::ActiveRecord::Result
72
+ def wrap(sql, binds)
73
+ rule = rule_for(sql, has_binds: binds&.any?)
74
+ return yield unless rule
75
+
76
+ cached_result = read(rule, sql)
77
+ return cached_result unless cached_result.nil?
78
+
79
+ write(rule, yield, sql)
80
+ end
81
+ end
82
+ end
83
+
84
+ # Rails 8.2 runs queries as intents: the adapter's `execute_intent` runs one and hands its raw result
85
+ # back to the intent, which turns it into an ActiveRecord::Result in `QueryIntent#cast_result`.
86
+ module ExecuteIntent
87
+ # @rbs module-self _IntentAdapter
88
+ module AdapterPatch
89
+ #: (_QueryIntent intent) -> untyped
90
+ def execute_intent(intent)
91
+ stash = QueryStash::ActiveRecord[Rails.application] #: QueryStash::ActiveRecord & Stash
92
+ cached_result = stash.read_intent(intent)
93
+ return super if cached_result.nil?
94
+
95
+ intent.deliver_result(cached_result)
96
+ end
97
+
98
+ private
99
+
100
+ # A stash hit is delivered as the ActiveRecord::Result it already is.
101
+ #: (untyped raw_result) -> ::ActiveRecord::Result
102
+ def cast_result(raw_result)
103
+ raw_result.is_a?(::ActiveRecord::Result) ? raw_result : super
104
+ end
105
+ end
106
+
107
+ # @rbs module-self _QueryIntent
108
+ module IntentPatch
109
+ #: () -> ::ActiveRecord::Result
110
+ def cast_result
111
+ stash = QueryStash::ActiveRecord[Rails.application] #: QueryStash::ActiveRecord & Stash
112
+ super.tap { |result| stash.write_intent(self, result) }
113
+ end
114
+ end
115
+
116
+ # @rbs module-self QueryStash::ActiveRecord
117
+ module Stash
118
+ # @rbs @intent_rules: ObjectSpace::WeakMap[_QueryIntent, Registry::Rule]
119
+
120
+ # The rule each intent that missed the stash is to be stored under, until its result is cast.
121
+ #: (QueryStash::ActiveRecord stash) -> void
122
+ def self.extended(stash)
123
+ stash.instance_variable_set(:@intent_rules, ObjectSpace::WeakMap.new)
124
+ end
125
+
126
+ # The stashed result of an intent (see AdapterPatch), or nil: when there is none, the intent is
127
+ # remembered for `write_intent` to store its result.
128
+ #: (_QueryIntent intent) -> ::ActiveRecord::Result?
129
+ def read_intent(intent)
130
+ rule = rule_for(intent.raw_sql, has_binds: intent.has_binds?)
131
+ return unless rule
132
+
133
+ read(rule, intent.raw_sql).tap { |cached_result| @intent_rules[intent] = rule if cached_result.nil? }
134
+ end
135
+
136
+ # Stores the result of an intent `read_intent` found nothing for.
137
+ #: (_QueryIntent intent, ::ActiveRecord::Result result) -> untyped
138
+ def write_intent(intent, result)
139
+ rule = @intent_rules[intent]
140
+ return unless rule
141
+
142
+ @intent_rules.delete(intent)
143
+ write(rule, result, intent.raw_sql)
144
+ end
145
+ end
146
+ end
147
+
148
+ # The implementation for the Rails in use: only to be asked once ActiveRecord is loaded.
149
+ #: () -> (singleton(InternalExecQuery) | singleton(ExecuteIntent))
150
+ def self.implementation
151
+ if ::ActiveRecord::ConnectionAdapters.const_defined?(:QueryIntent)
152
+ # Rails 8.2 or higher
153
+ ExecuteIntent
154
+ else
155
+ # Rails 8.1 or lower
156
+ InternalExecQuery
157
+ end
158
+ end
159
+
160
+ # The rule the query is stashed under, or nil when it isn't to be stashed: the stash is off, it has
161
+ # binds (not supported), it is not a SELECT, or no rule matches it.
162
+ #: (String sql, ?has_binds: bool?) -> Registry::Rule?
163
+ def rule_for(sql, has_binds: false)
164
+ return unless stashing?
165
+ return if has_binds
166
+ return unless CACHEABLE_SQL_REGEX.match?(sql)
167
+
168
+ rule = registry.match_rule(sql)
169
+ rule if rule && (rule.enabled.nil? || rule.enabled.call)
170
+ end
171
+
172
+ #: (String sql) -> String
173
+ def cache_key_for(sql)
174
+ # Note that the SQL is already normalized by the adapter here.
175
+ #
176
+ # Also note that SQL comments are applied AFTER this since
177
+ # this module is prepended.
178
+ "v1:#{::ActiveRecord::Base.current_shard}:#{Digest::SHA256.hexdigest(sql)}"
179
+ end
180
+
181
+ private
182
+
183
+ #: () -> Registry::Sql
184
+ def build_registry
185
+ Registry::Sql.new
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent/map"
4
+ require_relative "registry"
5
+
6
+ module QueryStash
7
+ class Base
8
+ # Where `enable!` and `disable!` keep what they set, when no `isolated_state` is
9
+ # given: a hash in a thread variable, so every fiber of a thread sees the same values and
10
+ # other threads see none.
11
+ #
12
+ # An isolated state is any object that responds to `[]`, `[]=`, `key?` and `delete`, like
13
+ # `ActiveSupport::IsolatedExecutionState`.
14
+ class ThreadVariableState
15
+ THREAD_VARIABLE = :query_stash_state #: Symbol
16
+
17
+ #: (untyped key) -> untyped
18
+ def [](key)
19
+ values[key]
20
+ end
21
+
22
+ #: (untyped key, untyped value) -> untyped
23
+ def []=(key, value)
24
+ values[key] = value
25
+ end
26
+
27
+ #: (untyped key) -> bool
28
+ def key?(key)
29
+ values.key?(key)
30
+ end
31
+
32
+ #: (untyped key) -> untyped
33
+ def delete(key)
34
+ values.delete(key)
35
+ end
36
+
37
+ private
38
+
39
+ #: () -> Hash[untyped, untyped]
40
+ def values
41
+ Thread.current.thread_variable_get(THREAD_VARIABLE) ||
42
+ Thread.current.thread_variable_set(THREAD_VARIABLE, {})
43
+ end
44
+ end
45
+
46
+ # Used when no `instrumenter` is given: runs the block and tells no one.
47
+ class NullInstrumenter
48
+ #: (String _name, ?Hash[Symbol, untyped] payload) ?{ (Hash[Symbol, untyped]) -> untyped } -> untyped
49
+ def instrument(_name, payload = {})
50
+ yield payload if block_given?
51
+ end
52
+ end
53
+
54
+ # Used when no `error_reporter` is given.
55
+ class WarnErrorReporter
56
+ #: (Exception error, ?handled: bool) -> void
57
+ def report(error, handled: true) # rubocop:disable Lint/UnusedMethodArgument
58
+ warn("QueryStash: #{error.class}: #{error.message}")
59
+ end
60
+ end
61
+
62
+ class << self
63
+ # @rbs self.@instances: Concurrent::Map[untyped, untyped]
64
+
65
+ #: (Class subclass) -> void
66
+ def inherited(subclass)
67
+ super
68
+ subclass.instance_variable_set(:@instances, Concurrent::Map.new)
69
+ end
70
+
71
+ #: (untyped app) -> instance
72
+ def [](app)
73
+ @instances.fetch(app)
74
+ end
75
+
76
+ #: (untyped app) -> bool
77
+ def key?(app)
78
+ !@instances.fetch(app, nil).nil?
79
+ end
80
+
81
+ # Makes `stash` the instance `app` uses.
82
+ #: (untyped app, instance stash) -> instance
83
+ def set(app, stash)
84
+ @instances[app] = stash
85
+ end
86
+ end
87
+
88
+ # @rbs @enabled: bool
89
+ # @rbs @isolated_state: _IsolatedState
90
+ # @rbs @instrumenter: _Instrumenter
91
+ # @rbs @error_reporter: _ErrorReporter
92
+ # @rbs @raise_on_cache_error: bool
93
+ # @rbs @registry: Registry
94
+ # @rbs @cache: _CacheStore
95
+ # @rbs @adapter: Symbol
96
+
97
+ attr_reader :registry #: Registry
98
+
99
+ attr_reader :cache #: _CacheStore
100
+
101
+ attr_reader :enabled #: bool
102
+
103
+ # What each stash is given:
104
+ #
105
+ # - `enabled`: whether queries are stashed outside of `enable!` and `disable!`.
106
+ # - `isolated_state`: where those keep what they set. By default, a thread variable.
107
+ # - `instrumenter`: told about what the stash does. By default, no one.
108
+ # - `error_reporter`: told about the errors it handles. By default, a warning on stderr.
109
+ # - `raise_on_cache_error`: raise the errors of the cache store, instead of reporting them.
110
+ # - `cache_store`: where results are kept, any object that responds to `read(key)`,
111
+ # `write(key, value, expires_in:)` and `clear`, like an ActiveSupport cache store.
112
+ #
113
+ # @rbs cache_store: _CacheStore
114
+ # @rbs enabled: bool
115
+ # @rbs isolated_state: _IsolatedState?
116
+ # @rbs instrumenter: _Instrumenter?
117
+ # @rbs error_reporter: _ErrorReporter?
118
+ # @rbs raise_on_cache_error: bool
119
+ # @rbs return: void
120
+ def initialize(
121
+ cache_store:,
122
+ enabled: true,
123
+ isolated_state: nil,
124
+ instrumenter: nil,
125
+ error_reporter: nil,
126
+ raise_on_cache_error: false
127
+ )
128
+ @enabled = enabled
129
+ @isolated_state = isolated_state || ThreadVariableState.new
130
+ @instrumenter = instrumenter || NullInstrumenter.new
131
+ @error_reporter = error_reporter || WarnErrorReporter.new
132
+ @raise_on_cache_error = raise_on_cache_error
133
+ @registry = build_registry
134
+ @cache = cache_store
135
+ @adapter = self.class.name.to_s.split("::").last.to_s.gsub(/(?<=[a-z])(?=[A-Z])/, "_").downcase.to_sym
136
+ end
137
+
138
+ #: () { (Registry registry) -> void } -> void
139
+ def configure
140
+ yield registry
141
+ end
142
+
143
+ # Turn the stash on or off for the block. Outside of either, it follows `enabled`.
144
+ #: [T] () { () -> T } -> T
145
+ def enable!(&block)
146
+ isolate(true, &block)
147
+ end
148
+
149
+ #: [T] () { () -> T } -> T
150
+ def disable!(&block)
151
+ isolate(false, &block)
152
+ end
153
+
154
+ #: (Registry::Rule rule) -> _CacheStore
155
+ def cache_for(rule)
156
+ rule.store || cache
157
+ end
158
+
159
+ # ---------------- What an adapter's stash provides ----------------
160
+ #
161
+ # An adapter patches whatever runs its queries so that it asks its stash, in this order:
162
+ #
163
+ # 1. `rule_for(*args)`: is this query stashed, and under which rule? When it isn't, the query just runs.
164
+ # 2. `read(rule, *args)`: is its result in the stash? When it is, that is the result.
165
+ # 3. `write(rule, result, *args)`: otherwise it runs, and its result goes in the stash.
166
+ #
167
+ # `*args` is whatever the adapter has that identifies a query (the SQL, a driver's view, ...). It is the
168
+ # same for `read`, `write` and `cache_key_for`. `rule_for` may be given more, for what only decides
169
+ # whether to stash.
170
+ #
171
+ # A stash gets `read` and `write` from here, which keep the reading and writing of the cache store, the
172
+ # instrumentation and the handling of errors in one place. It has to define `rule_for` and
173
+ # `cache_key_for`, and can override the others.
174
+
175
+ # The rule of the registry the query is to be stashed under, or nil when it isn't to be: the stash is off
176
+ # (see `stashing?`), the query is not one that can be, or no rule matches it.
177
+ #: (*untyped) -> Registry::Rule?
178
+ def rule_for(*)
179
+ raise NotImplementedError, "#{self.class} must define #rule_for"
180
+ end
181
+
182
+ # The key the query's result is stored under.
183
+ #: (*untyped) -> String
184
+ def cache_key_for(*)
185
+ raise NotImplementedError, "#{self.class} must define #cache_key_for"
186
+ end
187
+
188
+ # The result stored for the query, or nil when there is none (or the cache store failed: that is
189
+ # reported, or raised when `raise_on_cache_error` is set). Emits `cache_read` and, on a hit,
190
+ # `cache_hit`.
191
+ #: (Registry::Rule rule, *untyped args) -> untyped
192
+ def read(rule, *args)
193
+ cache_key = cache_key_for(*args)
194
+ cache_store = cache_for(rule)
195
+
196
+ rescue_cache_error do
197
+ cached_result = @instrumenter.instrument(
198
+ "cache_read.query_stash",
199
+ adapter: @adapter,
200
+ rule: rule.name
201
+ ) do |payload|
202
+ cache_store.read(cache_key).tap do |value|
203
+ payload[:hit] = !value.nil?
204
+ end
205
+ end
206
+
207
+ # Results are never nil, so nil means a miss.
208
+ unless cached_result.nil?
209
+ @instrumenter.instrument("cache_hit.query_stash", adapter: @adapter, rule: rule.name)
210
+ return cached_result
211
+ end
212
+ end
213
+
214
+ nil
215
+ end
216
+
217
+ # Stores `result` for the query when the rule keeps results for any time (else it is skipped), and
218
+ # returns it. Emits `cache_miss` or, when skipped, `cache_skip`.
219
+ # A failure of the cache store is reported (or raised), and does not lose the result.
220
+ #: (Registry::Rule rule, untyped result, *untyped args) -> untyped
221
+ def write(rule, result, *args)
222
+ ttl_seconds = rule.ttl_seconds_for(result)
223
+ ttl_jitter_seconds = rule.ttl_jitter_seconds_for(result)
224
+
225
+ rescue_cache_error do
226
+ if ttl_seconds.positive?
227
+ @instrumenter.instrument("cache_miss.query_stash", adapter: @adapter, rule: rule.name)
228
+ cache_for(rule).write(cache_key_for(*args), result, expires_in: ttl_seconds + rand(ttl_jitter_seconds))
229
+ else
230
+ @instrumenter.instrument("cache_skip.query_stash", adapter: @adapter, rule: rule.name)
231
+ end
232
+ end
233
+
234
+ result
235
+ end
236
+
237
+ private
238
+
239
+ # Whether the stash is on for the current execution context: what `enable!` or `disable!` set
240
+ # there, or else `enabled`.
241
+ #: () -> bool
242
+ def stashing?
243
+ @isolated_state.key?(self) ? @isolated_state[self] : enabled
244
+ end
245
+
246
+ # Sets the stash on or off in the isolated state for the block, then puts back what was there.
247
+ #: [T] (bool value) { () -> T } -> T
248
+ def isolate(value)
249
+ had_value = @isolated_state.key?(self)
250
+ previous = @isolated_state[self]
251
+ @isolated_state[self] = value
252
+ yield
253
+ ensure
254
+ if had_value
255
+ @isolated_state[self] = previous
256
+ else
257
+ @isolated_state.delete(self)
258
+ end
259
+ end
260
+
261
+ #: () -> Registry
262
+ def build_registry
263
+ Registry.new
264
+ end
265
+
266
+ #: [T] () { () -> T } -> untyped
267
+ def rescue_cache_error(&block)
268
+ block.call
269
+ rescue StandardError => e
270
+ raise e if @raise_on_cache_error
271
+
272
+ @error_reporter.report(e, handled: true)
273
+ end
274
+ end
275
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require_relative "base"
6
+
7
+ module QueryStash
8
+ class Mongo < Base
9
+ # What the patch and `wrap` ask of the driver's own objects.
10
+ #
11
+ # @rbs!
12
+ # # Has a name: a collection, or a database.
13
+ # interface _Named
14
+ # def name: () -> String
15
+ # end
16
+ #
17
+ # # A `Mongo::Collection::View`, or an aggregation view.
18
+ # interface _View
19
+ # def collection: () -> _Named
20
+ #
21
+ # def database: () -> _Named
22
+ #
23
+ # def filter: () -> untyped
24
+ #
25
+ # def options: () -> Hash[Symbol, untyped]
26
+ #
27
+ # def each: () ?{ (untyped document) -> untyped } -> untyped
28
+ # end
29
+ #
30
+ # # A selector to match rules against.
31
+ # type selector = Hash[Symbol, untyped]
32
+
33
+ # ---------------- Constants ----------------
34
+ # Wildcard sentinel for selector patterns
35
+ ANY = Object.new.freeze #: Object
36
+
37
+ class MongoFilterRegistry < Registry
38
+ private
39
+
40
+ # Pattern semantics:
41
+ #
42
+ # - ANY sentinel:
43
+ # pattern.equal?(ANY) => match anything as long as the key exists
44
+ #: (untyped actual, untyped pattern) -> bool
45
+ def value_matches_pattern?(actual, pattern)
46
+ # Wildcard – anything goes (key presence already checked by caller)
47
+ return true if pattern.equal?(ANY)
48
+
49
+ super
50
+ end
51
+ end
52
+
53
+ # Enables the stash for the duration of each request. `application` is the Rails application the
54
+ # middleware stack belongs to; `app` is the next middleware.
55
+ class Middleware
56
+ # @rbs @app: _RackApp
57
+ # @rbs @application: untyped
58
+
59
+ #: (_RackApp app, untyped application) -> void
60
+ def initialize(app, application)
61
+ @app = app
62
+ @application = application
63
+ end
64
+
65
+ #: (Hash[String, untyped] env) -> untyped
66
+ def call(env)
67
+ QueryStash::Mongo[@application].enable! { @app.call(env) }
68
+ end
69
+ end
70
+
71
+ # @rbs module-self _View
72
+ module AdapterPatch
73
+ #: () ?{ (untyped document) -> untyped } -> untyped
74
+ def each(&block)
75
+ QueryStash::Mongo[Rails.application].wrap(self, block) { |&iterate| super(&iterate) }
76
+ end
77
+ end
78
+
79
+ # Runs the view's cursor through the stash: `cursor` iterates the view's documents into the block
80
+ # it is given, and `consumer` is the block `each` was called with. (RBS can't say that a block takes
81
+ # a block, so the parameters of `cursor` are not typed.)
82
+ #: (_View view, ?(^(untyped) -> untyped)? consumer) { (?) -> untyped } -> untyped
83
+ def wrap(view, consumer = nil, &cursor)
84
+ rule = rule_for(view)
85
+ # Avoid eager loading the entire result set unless this query actually matches a cache rule.
86
+ # This can cause very quick OOMs if it has to load a whole collection at once.
87
+ return cursor.call(&consumer) unless rule
88
+
89
+ documents = read(rule, view) || write(rule, load_documents(&cursor), view)
90
+
91
+ consumer ? documents.each(&consumer) : documents.to_enum
92
+ end
93
+
94
+ # The rule the view is stashed under, or nil when it isn't to be stashed: the stash is off, or no rule
95
+ # matches its selector.
96
+ #: (_View view) -> Registry::Rule?
97
+ def rule_for(view)
98
+ return unless stashing?
99
+
100
+ registry.match_rule(selector_for(view))
101
+ end
102
+
103
+ # Based off of #cache_key from Mongo::QueryCache
104
+ # https://github.com/mongodb/mongo-ruby-driver/blob/master/lib/mongo/query_cache.rb#L19
105
+ #: (_View view) -> String
106
+ def cache_key_for(view)
107
+ selector = selector_for(view)
108
+ raw = [
109
+ selector[:collection_name],
110
+ selector[:filter],
111
+ selector[:limit],
112
+ selector[:skip],
113
+ selector[:sort],
114
+ selector[:projection],
115
+ selector[:pipeline]
116
+ ].to_json
117
+
118
+ "v1:#{view.database.name}:#{Digest::SHA256.hexdigest(raw)}"
119
+ end
120
+
121
+ private
122
+
123
+ #: () -> MongoFilterRegistry
124
+ def build_registry
125
+ MongoFilterRegistry.new
126
+ end
127
+
128
+ # Based off of #cache_options in Mongo::Collection::View::Iterable
129
+ # https://github.com/mongodb/mongo-ruby-driver/blob/v2.19.3/lib/mongo/collection/view/iterable.rb
130
+ #: (_View view) -> selector
131
+ def selector_for(view)
132
+ # An aggregation view has a pipeline where the others have a filter.
133
+ pipelined = view #: untyped
134
+ {
135
+ collection_name: view.collection.name,
136
+ filter: view.filter,
137
+ limit: view.options[:limit],
138
+ skip: view.options[:skip],
139
+ sort: view.options[:sort],
140
+ projection: view.options[:projection],
141
+ pipeline: pipelined.respond_to?(:pipeline) ? pipelined.pipeline : nil
142
+ }
143
+ end
144
+
145
+ #: () { (?) -> untyped } -> Array[untyped]
146
+ def load_documents(&cursor)
147
+ docs = [] #: Array[untyped]
148
+ cursor.call { |doc| docs << doc }
149
+ docs
150
+ end
151
+ end
152
+ end