rails-ai-context 5.35.0 → 5.35.2
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 +4 -4
- data/CHANGELOG.md +1024 -0
- data/CONTEXT.md +37 -7
- data/CONTRIBUTING.md +30 -1
- data/README.md +9 -9
- data/SECURITY.md +2 -2
- data/docs/ARCHITECTURE.md +12 -4
- data/docs/CLI.md +44 -15
- data/docs/COMPATIBILITY.md +34 -8
- data/docs/CONFIGURATION.md +28 -5
- data/docs/CUSTOM_TOOLS.md +8 -2
- data/docs/FAQ.md +5 -5
- data/docs/GUIDE.md +33 -65
- data/docs/INTROSPECTORS.md +4 -4
- data/docs/QUICKSTART.md +1 -1
- data/docs/RECIPES.md +5 -5
- data/docs/RELEASING.md +3 -1
- data/docs/SECURITY.md +255 -74
- data/docs/SETUP.md +1 -1
- data/docs/STANDALONE.md +2 -2
- data/docs/TOOLS.md +151 -90
- data/docs/TROUBLESHOOTING.md +123 -8
- data/docs/_config.yml +1 -1
- data/docs/adr/0008-query-runs-only-where-it-can-guard.md +38 -0
- data/docs/adr/0009-dbhub-shapes-in-ruby.md +26 -0
- data/docs/adr/0010-one-config-tool-with-sections.md +32 -0
- data/docs/index.md +2 -2
- data/exe/rails-ai-context +24 -9
- data/lib/generators/rails_ai_context/install/install_generator.rb +2 -0
- data/lib/rails_ai_context/app_databases.rb +377 -0
- data/lib/rails_ai_context/app_kind.rb +2 -0
- data/lib/rails_ai_context/cli/app_root.rb +1 -0
- data/lib/rails_ai_context/cli/tool_runner.rb +110 -99
- data/lib/rails_ai_context/configuration.rb +15 -8
- data/lib/rails_ai_context/database_family/mysql_runtime.rb +170 -0
- data/lib/rails_ai_context/database_family/postgresql_plan.rb +124 -0
- data/lib/rails_ai_context/database_family/postgresql_runtime.rb +211 -0
- data/lib/rails_ai_context/database_family/sqlite_runtime.rb +391 -0
- data/lib/rails_ai_context/database_family.rb +282 -0
- data/lib/rails_ai_context/database_file.rb +61 -7
- data/lib/rails_ai_context/database_yml.rb +7 -29
- data/lib/rails_ai_context/doctor.rb +15 -16
- data/lib/rails_ai_context/instrumentation.rb +1 -1
- data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +8 -23
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +6 -70
- data/lib/rails_ai_context/introspectors/declared_constant.rb +19 -7
- data/lib/rails_ai_context/introspectors/env_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_references.rb +1 -1
- data/lib/rails_ai_context/introspectors/job_introspector.rb +7 -7
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +8 -11
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +9 -9
- data/lib/rails_ai_context/introspectors/model_introspector.rb +33 -39
- data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +33 -30
- data/lib/rails_ai_context/introspectors/node_source.rb +2 -0
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +80 -24
- data/lib/rails_ai_context/introspectors/pg_partitions.rb +1 -4
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +6 -8
- data/lib/rails_ai_context/introspectors/schema_dump_path.rb +12 -14
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +47 -27
- data/lib/rails_ai_context/introspectors/schema_reader.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_scan.rb +39 -4
- data/lib/rails_ai_context/migration_status.rb +18 -46
- data/lib/rails_ai_context/payload.rb +48 -1
- data/lib/rails_ai_context/pending_migrations.rb +3 -4
- data/lib/rails_ai_context/run_cache.rb +5 -0
- data/lib/rails_ai_context/schema_adapter.rb +13 -18
- data/lib/rails_ai_context/secret_columns.rb +506 -0
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +2 -2
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +14 -10
- data/lib/rails_ai_context/serializers/section_facts.rb +12 -2
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +5 -2
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +35 -7
- data/lib/rails_ai_context/server.rb +143 -29
- data/lib/rails_ai_context/sql_gate.rb +1007 -0
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -2
- data/lib/rails_ai_context/test_helper.rb +3 -1
- data/lib/rails_ai_context/tools/analyze_feature.rb +11 -7
- data/lib/rails_ai_context/tools/base_tool.rb +71 -48
- data/lib/rails_ai_context/tools/dependency_graph.rb +1 -1
- data/lib/rails_ai_context/tools/diagnose.rb +105 -42
- data/lib/rails_ai_context/tools/generate_test.rb +4 -6
- data/lib/rails_ai_context/tools/get_active_support.rb +1 -6
- data/lib/rails_ai_context/tools/get_api.rb +1 -6
- data/lib/rails_ai_context/tools/get_autoload.rb +1 -6
- data/lib/rails_ai_context/tools/get_callbacks.rb +5 -5
- data/lib/rails_ai_context/tools/get_component_catalog.rb +6 -7
- data/lib/rails_ai_context/tools/get_concern.rb +6 -6
- data/lib/rails_ai_context/tools/get_config.rb +189 -12
- data/lib/rails_ai_context/tools/get_context.rb +111 -105
- data/lib/rails_ai_context/tools/get_controllers.rb +8 -5
- data/lib/rails_ai_context/tools/get_conventions.rb +1 -6
- data/lib/rails_ai_context/tools/get_engines.rb +1 -6
- data/lib/rails_ai_context/tools/get_env.rb +1 -6
- data/lib/rails_ai_context/tools/get_env_config.rb +2 -8
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +1 -6
- data/lib/rails_ai_context/tools/get_gems.rb +1 -6
- data/lib/rails_ai_context/tools/get_helper_methods.rb +4 -5
- data/lib/rails_ai_context/tools/get_i18n.rb +1 -7
- data/lib/rails_ai_context/tools/get_job_pattern.rb +1 -1
- data/lib/rails_ai_context/tools/get_mailers.rb +1 -1
- data/lib/rails_ai_context/tools/get_model_details.rb +5 -8
- data/lib/rails_ai_context/tools/get_partial_interface.rb +3 -4
- data/lib/rails_ai_context/tools/get_schema.rb +10 -8
- data/lib/rails_ai_context/tools/get_service_pattern.rb +60 -24
- data/lib/rails_ai_context/tools/get_turbo_map.rb +1 -1
- data/lib/rails_ai_context/tools/migration_advisor.rb +47 -32
- data/lib/rails_ai_context/tools/onboard.rb +24 -24
- data/lib/rails_ai_context/tools/performance_check.rb +15 -2
- data/lib/rails_ai_context/tools/query.rb +142 -1356
- data/lib/rails_ai_context/tools/read_logs.rb +1 -1
- data/lib/rails_ai_context/tools/review_changes.rb +6 -7
- data/lib/rails_ai_context/tools/runtime_info.rb +125 -88
- data/lib/rails_ai_context/tools/safe_call.rb +70 -28
- data/lib/rails_ai_context/tools/search_code.rb +0 -4
- data/lib/rails_ai_context/tools/search_docs.rb +3 -1
- data/lib/rails_ai_context/tools/security_scan.rb +9 -5
- data/lib/rails_ai_context/tools/session_context.rb +18 -12
- data/lib/rails_ai_context/tools/validate.rb +1 -0
- data/lib/rails_ai_context/tools/validate_semantics.rb +5 -5
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context.rb +4 -7
- data/server.json +2 -2
- metadata +14 -3
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module RailsAiContext
|
|
6
|
+
module DatabaseFamily
|
|
7
|
+
# sqlite3-ruby holds the interpreter lock while a statement steps, so only a killable child ends
|
|
8
|
+
# one at the deadline. A statement the child cannot run goes to the app's own connection, unbounded.
|
|
9
|
+
module SqliteRuntime
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
TIMED_OUT = "SQLite query exceeded the statement timeout"
|
|
13
|
+
|
|
14
|
+
# A row-capped result is far below this; a larger prefix is a corrupt one.
|
|
15
|
+
MAX_RESULT_BYTES = 1 << 30
|
|
16
|
+
|
|
17
|
+
# What the child writes where a result's length would stand, the moment it meets a lock.
|
|
18
|
+
MET_LOCK = [ (1 << 64) - 1 ].pack("Q>").freeze
|
|
19
|
+
# What it writes once it holds its read lock: no lock can stop the statement from there on.
|
|
20
|
+
PAST_LOCK = [ (1 << 64) - 2 ].pack("Q>").freeze
|
|
21
|
+
WAITS_ON_LOCK = { MET_LOCK => true, PAST_LOCK => false }.freeze
|
|
22
|
+
|
|
23
|
+
# A plan row that reads a table: the step, then the table's name or, for a table the statement
|
|
24
|
+
# renames, the alias alone ("SEARCH u USING INDEX i"). SQLite before 3.36 wrote "SCAN TABLE users AS u".
|
|
25
|
+
PLAN_STEP = /\A(SCAN|SEARCH)\s+(?:TABLE\s+)?(.+?)(?:\s+AS\s+\S+)?(?=\s+USING\b|\s+VIRTUAL TABLE\b|\z)/
|
|
26
|
+
|
|
27
|
+
# pragma_table_xinfo's hidden flag: 2 is a virtual generated column, 3 a stored one.
|
|
28
|
+
GENERATED = [ 2, 3 ].freeze
|
|
29
|
+
|
|
30
|
+
# COUNT(*) walks the whole table; a count that stops here reads at most this many rows.
|
|
31
|
+
COUNT_CAP = 100_000
|
|
32
|
+
|
|
33
|
+
def read_only(conn, sql, timeout)
|
|
34
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
35
|
+
# A child opens the file itself and would answer without the rows of a transaction open here.
|
|
36
|
+
if !conn.transaction_open? && (path = fork_path(conn)) && !DatabaseFamily.bounding { held_here?(conn, sql) }
|
|
37
|
+
result = in_child(conn, path, sql, deadline)
|
|
38
|
+
return Rows.new(result.columns, result.rows, false) if result
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
result = DatabaseFamily.bounding { on_app_connection(conn, sql, deadline) }
|
|
42
|
+
Rows.new(result.columns, result.rows, true)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The temp catalog, the temp schema, and a pragma function or dbstat: each reads any schema, by a name that can be put
|
|
46
|
+
# together as the statement runs.
|
|
47
|
+
CONNECTION_CATALOG = /(?<![\w$])(?:sqlite_temp_(?:master|schema)|temp["`'\]]?\s*\.|pragma_\w|dbstat(?![\w$]))/i
|
|
48
|
+
|
|
49
|
+
# Whether the statement names what the connection holds and a child would answer wrongly: a temp object, the temp schema or
|
|
50
|
+
# an attached database, in a string too. A table only an attached database holds needs no match: no child can prepare it.
|
|
51
|
+
def held_here?(conn, sql)
|
|
52
|
+
# Neither read waits on another connection's lock, which the pragma function for the same list does.
|
|
53
|
+
names = conn.select_rows("PRAGMA database_list").filter_map { |_seq, name, _file| name unless OWN_SCHEMAS.include?(name) } +
|
|
54
|
+
conn.select_values("SELECT name FROM sqlite_temp_master")
|
|
55
|
+
return false if names.empty?
|
|
56
|
+
|
|
57
|
+
sql.match?(CONNECTION_CATALOG) || SecretColumns.fold(sql).match?(Regexp.union(names.map { |name| SecretColumns.identifier(name) }))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The watchdog's interrupt lands when the driver is back in Ruby between two rows, so it ends a
|
|
61
|
+
# statement that returns rows slowly and cannot end one long step: the run counts as unbounded.
|
|
62
|
+
def on_app_connection(conn, sql, deadline)
|
|
63
|
+
left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
64
|
+
raise TimedOut, TIMED_OUT unless left.positive?
|
|
65
|
+
|
|
66
|
+
raw = conn.raw_connection
|
|
67
|
+
found = conn.select_value("PRAGMA query_only").to_i
|
|
68
|
+
watchdog = Thread.new { sleep(left) and raw.interrupt }
|
|
69
|
+
begin
|
|
70
|
+
conn.execute("PRAGMA query_only = ON")
|
|
71
|
+
conn.select_all(sql)
|
|
72
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
73
|
+
raise TimedOut, TIMED_OUT if e.cause.is_a?(SQLite3::InterruptException)
|
|
74
|
+
|
|
75
|
+
raise
|
|
76
|
+
ensure
|
|
77
|
+
watchdog.kill.join
|
|
78
|
+
DatabaseFamily.restore(conn, "PRAGMA query_only = #{found}")
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# These reads run on the app's own connection, where a wait on another writer ends at the
|
|
83
|
+
# connection's busy timeout (database.yml `timeout`; with none, a busy database fails at once).
|
|
84
|
+
def bounded(_conn, _timeout, &reads)
|
|
85
|
+
DatabaseFamily.bounding(&reads)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def explain(conn, sql, timeout)
|
|
89
|
+
result = read_only(conn, "EXPLAIN QUERY PLAN #{sql}", timeout)
|
|
90
|
+
scans = []
|
|
91
|
+
warnings = []
|
|
92
|
+
tables = conn.data_sources.map(&:downcase)
|
|
93
|
+
raw_lines = result.rows.map do |row|
|
|
94
|
+
# Columns: id, parent, notused, detail.
|
|
95
|
+
detail = row.last.to_s
|
|
96
|
+
step, name = detail.match(PLAN_STEP)&.captures
|
|
97
|
+
# "SCAN CONSTANT ROW" reads no table.
|
|
98
|
+
if step && name != "CONSTANT ROW"
|
|
99
|
+
table = tables.include?(name.downcase) ? name : "#{name} (a name the statement gives)"
|
|
100
|
+
type = if step == "SEARCH" then "index search"
|
|
101
|
+
elsif detail.match?(/USING.*INDEX/) then "index scan"
|
|
102
|
+
else "full table scan"
|
|
103
|
+
end
|
|
104
|
+
scans << { table: table, type: type, index: detail[/USING.*INDEX\s+(\w+)/, 1] }
|
|
105
|
+
warnings << "Full table scan on #{table}" if type == "full table scan"
|
|
106
|
+
end
|
|
107
|
+
detail
|
|
108
|
+
end
|
|
109
|
+
{ scan_types: scans, warnings: warnings, raw: raw_lines.join("\n"), unbounded: result.unbounded }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def column_sources(_conn, _sql, _plan)
|
|
113
|
+
[]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def plan(_conn, _sql, _timeout)
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The attached databases. A statement reaches their tables with no schema named, as it does main's and temp's.
|
|
121
|
+
def unqualified_schemas(conn)
|
|
122
|
+
conn.select_rows("PRAGMA database_list").map { |_seq, name, _file| name } | OWN_SCHEMAS
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def schemas_named(_conn, _names)
|
|
126
|
+
[]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
OWN_SCHEMAS = %w[main temp].freeze
|
|
130
|
+
|
|
131
|
+
# One SELECT for each schema, as one statement. Yields the schema as an identifier and as a string,
|
|
132
|
+
# which is how a pragma function takes it.
|
|
133
|
+
def across(schemas)
|
|
134
|
+
(schemas.empty? ? OWN_SCHEMAS : schemas).map { |schema| yield %("#{schema.gsub('"', '""')}"), "'#{schema.gsub("'", "''")}'" }.join(" UNION ALL ")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# A virtual table's hidden columns are left out (hidden = 1): a full-text table has one of its own name.
|
|
138
|
+
def relation_columns(conn, schemas = [])
|
|
139
|
+
DatabaseFamily.by_table(conn.select_rows(across(schemas) do |schema, name|
|
|
140
|
+
"SELECT m.name, p.name FROM #{schema}.sqlite_master m JOIN pragma_table_xinfo(m.name, #{name}) p " \
|
|
141
|
+
"WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' AND p.hidden <> 1"
|
|
142
|
+
end)).transform_values(&:flatten)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The body only: the header names the view itself.
|
|
146
|
+
def view_definitions(conn, schemas = [])
|
|
147
|
+
rows = conn.select_rows(across(schemas) { |schema, _| "SELECT name, sql FROM #{schema}.sqlite_master WHERE type = 'view'" })
|
|
148
|
+
DatabaseFamily.by_view(rows.map { |name, sql| [ name, sql.to_s.sub(/\A.*?\bAS\b/mi, "") ] })
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def traced_views(_conn, _schemas = [])
|
|
152
|
+
[]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# SQLite keeps no expression per column, so each one is cut from its table's definition. A column whose
|
|
156
|
+
# part of the definition is not found answers the whole definition.
|
|
157
|
+
def generated_columns(conn, schemas = [])
|
|
158
|
+
rows = conn.select_rows(across(schemas) do |schema, name|
|
|
159
|
+
"SELECT m.name, p.name, p.hidden, m.sql FROM #{schema}.sqlite_master m JOIN pragma_table_xinfo(m.name, #{name}) p " \
|
|
160
|
+
"WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' AND m.sql LIKE '%AS%(%'"
|
|
161
|
+
end)
|
|
162
|
+
# Tables of one name in two schemas each have a definition of their own.
|
|
163
|
+
rows.group_by { |table, _name, _hidden, definition| [ table, definition.to_s ] }.each_with_object({}) do |((table, definition), columns), found|
|
|
164
|
+
generated = columns.filter_map { |_table, name, hidden, _| name if GENERATED.include?(hidden.to_i) }
|
|
165
|
+
next if generated.empty?
|
|
166
|
+
|
|
167
|
+
expressions = column_expressions(definition)
|
|
168
|
+
DatabaseFamily.keep_expressions(found, generated.map { |name| [ table, name, expressions[name.downcase(:ascii)] || definition ] })
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
COLUMN_NAME = /\A(?:"((?:""|[^"])*)"|`((?:``|[^`])*)`|\[([^\]]*)\]|'((?:''|[^'])*)'|([^\s(]+))(.*)\z/m
|
|
173
|
+
# A bare one of these opens a table constraint. In quotes it is a column's name.
|
|
174
|
+
TABLE_CONSTRAINT = /\A(?:CONSTRAINT|PRIMARY|UNIQUE|CHECK|FOREIGN)\z/i
|
|
175
|
+
|
|
176
|
+
# @return [Hash{String => String, nil}] column name, folded as SQLite compares names (ASCII only) => what its
|
|
177
|
+
# part of a CREATE TABLE statement says after AS; nil where that cannot be told
|
|
178
|
+
def column_expressions(definition)
|
|
179
|
+
scanner = SqlGate::Scanner.new(SqlGate.comment_free(definition, SQLITE))
|
|
180
|
+
depth = 0
|
|
181
|
+
parts = [ +"" ]
|
|
182
|
+
until scanner.eos?
|
|
183
|
+
read = SqlGate.quoted(scanner, SQLITE)&.last || scanner.getch
|
|
184
|
+
depth -= 1 if read == ")"
|
|
185
|
+
if depth == 1 && read == "," then parts << +""
|
|
186
|
+
elsif depth.positive? then parts.last << read
|
|
187
|
+
end
|
|
188
|
+
depth += 1 if read == "("
|
|
189
|
+
end
|
|
190
|
+
parts.each_with_object({}) do |part, found|
|
|
191
|
+
double, tick, bracket, single, bare, rest = part.strip.match(COLUMN_NAME)&.captures
|
|
192
|
+
next if rest.nil? || bare&.match?(TABLE_CONSTRAINT)
|
|
193
|
+
|
|
194
|
+
# A quote is doubled only inside a name its own kind encloses, and a bracketed name doubles nothing.
|
|
195
|
+
name = (bare || bracket || double&.gsub('""', '"') || tick&.gsub("``", "`") || single.gsub("''", "'")).downcase(:ascii)
|
|
196
|
+
found[name] = found.key?(name) ? nil : rest.partition(/\bAS\b/i).last.presence
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# pragma_table_list, which types a shadow table, came with this version.
|
|
201
|
+
TYPED_TABLES = "3.37"
|
|
202
|
+
VOCABULARY = /\A\s*USING\s+["'`\[]?(fts5vocab|fts4aux)\b["'`\]]?\s*\((.*)\)/mi
|
|
203
|
+
|
|
204
|
+
# Modules that return the database file's pages or cells, whatever table they belong to.
|
|
205
|
+
PAGE_MODULES = %w[sqlite_dbpage sqlite_dbdata sqlite_dbptr].freeze
|
|
206
|
+
|
|
207
|
+
# Before pragma_table_list a shadow table is told by its name: any table that starts with a virtual table's.
|
|
208
|
+
def index_tables(conn, schemas = [])
|
|
209
|
+
tables = ->(kept) { across(schemas) { |schema, _| "SELECT name, sql FROM #{schema}.sqlite_master WHERE type = 'table' AND rootpage #{kept}" } }
|
|
210
|
+
virtual = conn.select_rows(tables.call("= 0")).to_h
|
|
211
|
+
stored = if conn.database_version >= TYPED_TABLES
|
|
212
|
+
conn.select_values(across(schemas) { |_, name| "SELECT name FROM pragma_table_list WHERE type = 'shadow' AND schema = #{name} COLLATE NOCASE" })
|
|
213
|
+
else conn.select_rows(tables.call("> 0")).map(&:first)
|
|
214
|
+
end
|
|
215
|
+
found = stored.each_with_object(PAGE_MODULES.to_h { |name| [ name, [ :pages, nil ] ] }) do |name, tables|
|
|
216
|
+
owner = virtual.keys.select { |table| name.downcase.start_with?("#{table.downcase}_") }.max_by(&:size)
|
|
217
|
+
tables[name] = [ :shadow, owner ] if owner
|
|
218
|
+
end
|
|
219
|
+
virtual.each do |name, sql|
|
|
220
|
+
clause = module_clause(SqlGate.comment_free(sql.to_s, SQLITE))
|
|
221
|
+
mod, args = clause.match(VOCABULARY)&.captures
|
|
222
|
+
# fts5vocab([schema,] table, type) and fts4aux([schema,] table).
|
|
223
|
+
names = args.to_s.scan(SecretColumns::MODULE_ARGUMENT).map { |arg| arg.strip.delete(%q('"`[])) }
|
|
224
|
+
found[name] = mod ? [ :vocabulary, mod.casecmp?("fts4aux") ? names.last : names[-2], clause ] : [ :virtual, name, clause ]
|
|
225
|
+
end
|
|
226
|
+
found
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# What a virtual table's definition says after the table's own name: `USING module(arguments)`.
|
|
230
|
+
# SQLite stores it from the name on, and a quoted name can hold the word USING.
|
|
231
|
+
def module_clause(sql)
|
|
232
|
+
scanner = SqlGate::Scanner.new(sql)
|
|
233
|
+
scanner.skip(/\s*CREATE\s+VIRTUAL\s+TABLE\s+/i)
|
|
234
|
+
SqlGate.quoted(scanner, SQLITE) || scanner.skip(/[^\s"'`\[]+/)
|
|
235
|
+
scanner.rest
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def table_sizes(conn)
|
|
239
|
+
# dbstat lists every b-tree, indexes and SQLite internals too.
|
|
240
|
+
app_tables = conn.select_values("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").to_set
|
|
241
|
+
|
|
242
|
+
begin
|
|
243
|
+
rows = conn.select_all("SELECT name, SUM(pgsize) AS bytes FROM dbstat GROUP BY name ORDER BY bytes DESC")
|
|
244
|
+
.filter_map { |r| { name: r["name"], bytes: r["bytes"].to_i } if app_tables.include?(r["name"]) }
|
|
245
|
+
return rows if rows.any?
|
|
246
|
+
rescue => e
|
|
247
|
+
RailsAiContext.debug_fail(e, label: "SqliteRuntime.table_sizes (dbstat)")
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Without dbstat, the whole file split across the tables.
|
|
251
|
+
total = conn.select_value("PRAGMA page_count").to_i * conn.select_value("PRAGMA page_size").to_i
|
|
252
|
+
tables = app_tables.to_a.sort
|
|
253
|
+
tables.map { |t| { name: t, bytes: total / [ tables.size, 1 ].max } }
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# SQLite keeps no row estimate a query can read without ANALYZE; a table past the cap is a floor.
|
|
257
|
+
def row_estimates(conn)
|
|
258
|
+
names = (conn.tables - Introspectors::SqliteVirtualTables.hidden(conn)).reject { |t| t.start_with?("ar_internal_metadata", "schema_migrations") }
|
|
259
|
+
names.map do |table|
|
|
260
|
+
count = conn.select_value("SELECT COUNT(*) FROM (SELECT 1 FROM #{conn.quote_table_name(table)} LIMIT #{COUNT_CAP + 1})").to_i
|
|
261
|
+
entry = { table: table, approximate_rows: [ count, COUNT_CAP ].min }
|
|
262
|
+
entry[:at_least] = true if count > COUNT_CAP
|
|
263
|
+
entry
|
|
264
|
+
end.sort_by { |t| [ -t[:approximate_rows], t[:table] ] }
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def index_usage(_conn)
|
|
268
|
+
nil
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# nil for an in-memory or temporary database, or where there is no fork.
|
|
272
|
+
def fork_path(conn)
|
|
273
|
+
return nil unless Process.respond_to?(:fork)
|
|
274
|
+
|
|
275
|
+
path = conn.raw_connection.filename("main").to_s
|
|
276
|
+
path unless path.empty?
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# @return [ActiveRecord::Result, nil] nil when the child could not prepare the statement, as when it
|
|
280
|
+
# names a function, module, collation, key or temporary table only the app's connection has
|
|
281
|
+
def in_child(conn, path, sql, deadline)
|
|
282
|
+
parent = Process.pid
|
|
283
|
+
extensions = Array(conn.pool.db_config.configuration_hash[:extensions]).map { |ext| (ext.is_a?(String) && ext.safe_constantize) || ext }
|
|
284
|
+
# sqlite3 2.x warns in every child that inherits a writable handle; Rails 8 silences it the same way.
|
|
285
|
+
SQLite3::ForkSafety.suppress_warnings! if defined?(SQLite3::ForkSafety)
|
|
286
|
+
|
|
287
|
+
timeout = [ deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0 ].max
|
|
288
|
+
reader, writer = IO.pipe.each(&:binmode)
|
|
289
|
+
pid = fork { answer_in_child(reader, writer, path, sql, timeout, extensions) }
|
|
290
|
+
writer.close
|
|
291
|
+
read_child_result(reader, deadline)
|
|
292
|
+
ensure
|
|
293
|
+
# Also reached in a child whose fork hooks raised: it must not carry on as a second server.
|
|
294
|
+
exit!(1) if parent && Process.pid != parent
|
|
295
|
+
reader&.close
|
|
296
|
+
writer&.close
|
|
297
|
+
reap_child(pid) if pid
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Writes the length-prefixed result and exits without the parent's at_exit hooks.
|
|
301
|
+
def answer_in_child(reader, writer, path, sql, timeout, extensions)
|
|
302
|
+
reader.close
|
|
303
|
+
payload = begin
|
|
304
|
+
db = SQLite3::Database.new(path, readonly: true, extensions: extensions)
|
|
305
|
+
wait_on_lock(db, writer, timeout * 0.8)
|
|
306
|
+
# A read inside a transaction keeps its lock, so the statement itself never waits on one.
|
|
307
|
+
db.execute("BEGIN")
|
|
308
|
+
db.execute("SELECT 1 FROM sqlite_master LIMIT 1")
|
|
309
|
+
writer.write(PAST_LOCK)
|
|
310
|
+
stmt = db.prepare(sql)
|
|
311
|
+
[ :ok, stmt.columns, stmt.to_a ]
|
|
312
|
+
rescue => e
|
|
313
|
+
# Which step failed is the child's own fact; the message can hold text from the statement.
|
|
314
|
+
status = if e.is_a?(SQLite3::BusyException) then :busy
|
|
315
|
+
else db && !stmt ? :unprepared : :error
|
|
316
|
+
end
|
|
317
|
+
[ status, "#{e.class}: #{e.message}" ]
|
|
318
|
+
end
|
|
319
|
+
bytes = Marshal.dump(payload)
|
|
320
|
+
writer.write([ bytes.bytesize ].pack("Q>"), bytes)
|
|
321
|
+
ensure
|
|
322
|
+
exit!(0)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Gives up under the deadline, so the child answers a database that stays locked. The parent is told at
|
|
326
|
+
# the first wait, for a child that started late and is still waiting at the deadline.
|
|
327
|
+
def wait_on_lock(db, writer, seconds)
|
|
328
|
+
met = nil
|
|
329
|
+
db.busy_handler do |count|
|
|
330
|
+
if count.zero?
|
|
331
|
+
met = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
332
|
+
writer.write(MET_LOCK)
|
|
333
|
+
end
|
|
334
|
+
sleep 0.01
|
|
335
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - met < seconds
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Read by length: any other process forked meanwhile holds a copy of the writer, so EOF may never come.
|
|
340
|
+
# A deadline that passes while the child waits on a lock is answered as locked; past the lock it is the timeout.
|
|
341
|
+
def read_child_result(reader, deadline)
|
|
342
|
+
on_lock = false
|
|
343
|
+
size = begin
|
|
344
|
+
loop do
|
|
345
|
+
prefix = read_before(reader, 8, deadline)
|
|
346
|
+
break prefix.unpack1("Q>") unless WAITS_ON_LOCK.key?(prefix)
|
|
347
|
+
|
|
348
|
+
on_lock = WAITS_ON_LOCK.fetch(prefix)
|
|
349
|
+
end
|
|
350
|
+
rescue TimedOut
|
|
351
|
+
raise unless on_lock
|
|
352
|
+
|
|
353
|
+
raise Busy, BUSY
|
|
354
|
+
end
|
|
355
|
+
status, *rest = begin
|
|
356
|
+
raise ArgumentError, "result length out of range" if size > MAX_RESULT_BYTES
|
|
357
|
+
|
|
358
|
+
Marshal.load(read_before(reader, size, deadline))
|
|
359
|
+
rescue ArgumentError, TypeError
|
|
360
|
+
raise ActiveRecord::StatementInvalid, "the SQLite query process exited without a result"
|
|
361
|
+
end
|
|
362
|
+
raise Busy, BUSY if status == :busy
|
|
363
|
+
raise ActiveRecord::StatementInvalid, rest.first if status == :error
|
|
364
|
+
return nil if status == :unprepared
|
|
365
|
+
|
|
366
|
+
ActiveRecord::Result.new(*rest)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def read_before(reader, size, deadline)
|
|
370
|
+
data = "".b
|
|
371
|
+
while data.bytesize < size
|
|
372
|
+
left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
373
|
+
raise TimedOut, TIMED_OUT unless left.positive? && IO.select([ reader ], nil, nil, left)
|
|
374
|
+
|
|
375
|
+
chunk = reader.read_nonblock([ size - data.bytesize, 1 << 16 ].min, exception: false)
|
|
376
|
+
raise ActiveRecord::StatementInvalid, "the SQLite query process exited without a result" if chunk.nil?
|
|
377
|
+
|
|
378
|
+
data << chunk unless chunk == :wait_readable
|
|
379
|
+
end
|
|
380
|
+
data
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# KILL: TERM cannot land while `step` holds the GVL, and would run inherited at_exit hooks.
|
|
384
|
+
# Runs in an ensure, so it rescues everything: nothing here may replace the query's own error.
|
|
385
|
+
def reap_child(pid)
|
|
386
|
+
Process.kill(:KILL, pid) rescue nil
|
|
387
|
+
Process.wait(pid) rescue nil
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
end
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiContext
|
|
4
|
+
# What a database is by its adapter's name: PostgreSQL, MySQL or SQLite, else no family (ADR-0008).
|
|
5
|
+
# A family also owns how its database runs a guarded read and the statistics other tools show.
|
|
6
|
+
module DatabaseFamily
|
|
7
|
+
# A statement ran past the timeout it was given.
|
|
8
|
+
class TimedOut < RailsAiContext::Error; end
|
|
9
|
+
|
|
10
|
+
# Another connection holds a lock on the database, so the statement did not run.
|
|
11
|
+
class Busy < RailsAiContext::Error; end
|
|
12
|
+
|
|
13
|
+
# The read cannot be guarded (an adapter with no family, a MySQL transaction already open), so nothing was sent.
|
|
14
|
+
class Unsupported < RailsAiContext::Error; end
|
|
15
|
+
|
|
16
|
+
# Rows from a guarded read; unbounded when no timeout could hold it.
|
|
17
|
+
Rows = Data.define(:columns, :rows, :unbounded)
|
|
18
|
+
|
|
19
|
+
Family = Data.define(:key, :name) do
|
|
20
|
+
def known?
|
|
21
|
+
key != :unknown
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def postgresql?
|
|
25
|
+
key == :postgresql
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def mysql?
|
|
29
|
+
key == :mysql
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def sqlite?
|
|
33
|
+
key == :sqlite
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def implicit_pk_type
|
|
37
|
+
sqlite? ? "integer" : "bigint"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A statement is checked against its plan before it runs, and a result column is traced to its table column.
|
|
41
|
+
def plan_checks?
|
|
42
|
+
postgresql?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The read-only transaction refuses FOR UPDATE and FOR SHARE itself.
|
|
46
|
+
def refuses_locking_reads?
|
|
47
|
+
postgresql?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Every string literal in a statement can be told from its identifiers.
|
|
51
|
+
def masks_literals?
|
|
52
|
+
postgresql?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def dollar_quoting?
|
|
56
|
+
postgresql?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A block comment inside a block comment needs a closer of its own.
|
|
60
|
+
def nested_comments?
|
|
61
|
+
postgresql?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# `/*! ... */` holds text the database runs.
|
|
65
|
+
def executable_comments?
|
|
66
|
+
mysql?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A backslash escapes the next character in every string.
|
|
70
|
+
def backslash_escapes?
|
|
71
|
+
mysql?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Regexp] a comment that runs to the end of its line: `#` too, and `--` only before a space, on MySQL
|
|
75
|
+
def line_comment
|
|
76
|
+
mysql? ? /--(?=\s|\z)[^\n]*|#[^\n]*/ : /--[^\n]*/
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @return [Rows] run in a read-only transaction, bounded by timeout seconds
|
|
80
|
+
# @raise [TimedOut, Unsupported]
|
|
81
|
+
def read_only(connection, sql, timeout:)
|
|
82
|
+
runtime.read_only(connection, sql, timeout)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Runs reads beside the statement so that each one ends at timeout seconds, a wait on a lock included.
|
|
86
|
+
# @raise [TimedOut, Unsupported]
|
|
87
|
+
def bounded(connection, timeout:, &reads)
|
|
88
|
+
runtime.bounded(connection, timeout, &reads)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @return [Hash] { scan_types:, warnings:, raw:, unbounded: }
|
|
92
|
+
def explain(connection, sql, timeout:)
|
|
93
|
+
runtime.explain(connection, sql, timeout)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @raise [TimedOut, ActiveRecord::StatementInvalid] when PostgreSQL cannot plan the statement
|
|
97
|
+
# @return [Hash, nil] the PostgreSQL JSON plan's top node; nil on other families or when the answer is no plan
|
|
98
|
+
def plan(connection, sql, timeout:)
|
|
99
|
+
runtime.plan(connection, sql, timeout)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @return [Array<Array(Integer, String, String)>] [result column index, table column, relation] from the
|
|
103
|
+
# plan; the column is nil where the relation's columns could not be read; [] where the family cannot say
|
|
104
|
+
def column_sources(connection, sql, plan:)
|
|
105
|
+
runtime.column_sources(connection, sql, plan)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# A schema read that is given no schemas reads at least what a statement reaches with no schema named:
|
|
109
|
+
# the PostgreSQL search path, the MySQL connection's database, SQLite's main and temp.
|
|
110
|
+
|
|
111
|
+
# @return [Array<String>] the schemas a statement reaches with no schema named, where the reads' own
|
|
112
|
+
# default does not cover them: SQLite's attached databases
|
|
113
|
+
def unqualified_schemas(connection)
|
|
114
|
+
runtime.unqualified_schemas(connection)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @return [Array<String>] the names among these that are schemas a statement can read and the reads' own
|
|
118
|
+
# default does not cover. System schemas are left out.
|
|
119
|
+
def schemas_named(connection, names)
|
|
120
|
+
names.empty? ? [] : runtime.schemas_named(connection, names)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# @return [Hash{String => Array<String>}] relation name => its column names, from one catalogue read. On
|
|
124
|
+
# PostgreSQL also under `schema.relation`, where the bare name holds the columns of every schema read.
|
|
125
|
+
def relation_columns(connection, schemas = [])
|
|
126
|
+
runtime.relation_columns(connection, schemas)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# @return [Hash{String => String, nil}] view name => the definition the database stores, nil for one it
|
|
130
|
+
# will not show. On PostgreSQL also under `schema.view`, as traced_views names it too.
|
|
131
|
+
def view_definitions(connection, schemas = [])
|
|
132
|
+
runtime.view_definitions(connection, schemas)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @return [Array<String>] the views whose result columns column_sources traces to their table columns
|
|
136
|
+
def traced_views(connection, schemas = [])
|
|
137
|
+
runtime.traced_views(connection, schemas)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @return [Hash{String => Hash{String => String}}] table name => { generated column => its expression }
|
|
141
|
+
def generated_columns(connection, schemas = [])
|
|
142
|
+
runtime.generated_columns(connection, schemas)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @return [Hash{String => Array}] what holds another table's rows or words under other names: table =>
|
|
146
|
+
# [:pages, :shadow, :vocabulary or :virtual, its virtual table, and for the last two kinds their definition]
|
|
147
|
+
def index_tables(connection, schemas = [])
|
|
148
|
+
runtime.index_tables(connection, schemas)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# @return [Array<Hash>, nil] { name:, bytes: } largest first; nil when the family has none
|
|
152
|
+
def table_sizes(connection)
|
|
153
|
+
runtime.table_sizes(connection)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# @return [Array<Hash>, nil] { table:, approximate_rows: } most rows first
|
|
157
|
+
def row_estimates(connection)
|
|
158
|
+
runtime.row_estimates(connection)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# @return [Array<Hash>, nil] { table:, index:, scans: } least used first
|
|
162
|
+
def index_usage(connection)
|
|
163
|
+
runtime.index_usage(connection)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
def runtime
|
|
169
|
+
case key
|
|
170
|
+
when :postgresql then PostgresqlRuntime
|
|
171
|
+
when :mysql then MysqlRuntime
|
|
172
|
+
when :sqlite then SqliteRuntime
|
|
173
|
+
else
|
|
174
|
+
raise Unsupported, "The #{name} adapter has no database family, so its read-only transaction " \
|
|
175
|
+
"and statement timeout are not known. Supported: PostgreSQL, MySQL (with MariaDB) and SQLite."
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
POSTGRESQL = Family.new(:postgresql, "PostgreSQL")
|
|
181
|
+
MYSQL = Family.new(:mysql, "MySQL")
|
|
182
|
+
SQLITE = Family.new(:sqlite, "SQLite")
|
|
183
|
+
|
|
184
|
+
# Live adapter_name values, database.yml adapters, URL schemes, display labels and dump dialects, downcased.
|
|
185
|
+
NAMES = {
|
|
186
|
+
"postgresql" => POSTGRESQL, "postgis" => POSTGRESQL, "postgres" => POSTGRESQL,
|
|
187
|
+
"mysql2" => MYSQL, "trilogy" => MYSQL, "mysql" => MYSQL, "mariadb" => MYSQL,
|
|
188
|
+
"sqlite3" => SQLITE, "sqlite" => SQLITE
|
|
189
|
+
}.freeze
|
|
190
|
+
|
|
191
|
+
DEFAULT_SUFFIX = " by database.yml default"
|
|
192
|
+
|
|
193
|
+
# 3024 MySQL max_execution_time, 1317 query interrupted, 1969 MariaDB max_statement_time, 1205 lock_wait_timeout.
|
|
194
|
+
TIMEOUT_CODES = [ 3024, 1317, 1969, 1205 ].freeze
|
|
195
|
+
|
|
196
|
+
module_function
|
|
197
|
+
|
|
198
|
+
# @param source [#adapter_name, #adapter, String, Symbol, nil]
|
|
199
|
+
# @return [Family] never raises; no family carries the raw name
|
|
200
|
+
def of(source)
|
|
201
|
+
raw = raw_name(source)
|
|
202
|
+
NAMES.fetch(raw.downcase.delete_suffix(DEFAULT_SUFFIX)) { Family.new(:unknown, raw.empty? ? "unknown" : raw) }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def raw_name(source)
|
|
206
|
+
source = source.adapter_name if source.respond_to?(:adapter_name)
|
|
207
|
+
source = source.adapter if source.respond_to?(:adapter)
|
|
208
|
+
source.is_a?(String) || source.is_a?(Symbol) ? source.to_s : ""
|
|
209
|
+
rescue StandardError
|
|
210
|
+
""
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# @return [Hash{String => Array<Array>}] catalogue rows grouped by their first field, the table, without it
|
|
214
|
+
def by_table(rows)
|
|
215
|
+
rows.group_by(&:first).transform_values { |found| found.map { |row| row.drop(1) } }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# @return [Hash{String => String, nil}] [view, definition] rows by view. Views of one name in two schemas
|
|
219
|
+
# are read as one text, and as unreadable when either is.
|
|
220
|
+
def by_view(rows)
|
|
221
|
+
rows.group_by(&:first).transform_values { |found| found.map(&:last).then { |texts| texts.all? ? texts.join("\n") : nil } }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Adds [table, generated column, expression] rows to found, as table => { column => expression }. Tables of one
|
|
225
|
+
# name in two schemas keep every expression their columns of one name have.
|
|
226
|
+
def keep_expressions(found, rows)
|
|
227
|
+
rows.each_with_object(found) do |(table, column, expression), kept|
|
|
228
|
+
(kept[table] ||= {}).merge!(column => expression.to_s) { |_column, known, read| known.include?(read) ? known : "#{known} #{read}" }
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
BUSY = "The database is locked by another connection, so the query did not run. Run it again when the other connection has finished."
|
|
233
|
+
|
|
234
|
+
# Runs the block and raises TimedOut for a database's timeout, Busy for a locked SQLite database. Message text is never read.
|
|
235
|
+
def bounding
|
|
236
|
+
yield
|
|
237
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
238
|
+
raise Busy, BUSY if busy?(e)
|
|
239
|
+
raise TimedOut, e.message if timed_out?(e)
|
|
240
|
+
|
|
241
|
+
raise
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Puts a session setting back. A failed restore never replaces the caller's own error, and the
|
|
245
|
+
# connection it left changed leaves the pool so no later statement inherits the setting.
|
|
246
|
+
def restore(connection, statement)
|
|
247
|
+
connection.execute(statement)
|
|
248
|
+
rescue StandardError
|
|
249
|
+
begin
|
|
250
|
+
connection.respond_to?(:throw_away!) ? connection.throw_away! : connection.disconnect!
|
|
251
|
+
rescue StandardError
|
|
252
|
+
nil
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# A connection the server dropped: the caller's to answer, and no reason to fall back to another read.
|
|
257
|
+
def lost_connection?(error)
|
|
258
|
+
error.is_a?(ActiveRecord::ConnectionNotEstablished) ||
|
|
259
|
+
(defined?(ActiveRecord::ConnectionFailed) && error.is_a?(ActiveRecord::ConnectionFailed))
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# A SQLite read that found the database locked, at once or after the connection's busy timeout.
|
|
263
|
+
# Rails 7.0 gives it no class of its own.
|
|
264
|
+
def busy?(error)
|
|
265
|
+
defined?(SQLite3::BusyException) && error.cause.is_a?(SQLite3::BusyException)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def timed_out?(error)
|
|
269
|
+
# A connection the server dropped is a QueryAborted on Rails 7.1 and later, and it is no timeout.
|
|
270
|
+
return false if defined?(ActiveRecord::ConnectionFailed) && error.is_a?(ActiveRecord::ConnectionFailed)
|
|
271
|
+
return true if defined?(ActiveRecord::QueryAborted) && error.is_a?(ActiveRecord::QueryAborted)
|
|
272
|
+
# A wait on a lock that the database's own lock timeout ended.
|
|
273
|
+
return true if error.is_a?(ActiveRecord::LockWaitTimeout)
|
|
274
|
+
|
|
275
|
+
cause = error.cause
|
|
276
|
+
code = if cause.respond_to?(:error_number) then cause.error_number
|
|
277
|
+
elsif cause.respond_to?(:error_code) then cause.error_code
|
|
278
|
+
end
|
|
279
|
+
TIMEOUT_CODES.include?(code)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|