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
|
@@ -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
|