flare 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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +148 -0
  5. data/app/controllers/flare/application_controller.rb +22 -0
  6. data/app/controllers/flare/jobs_controller.rb +55 -0
  7. data/app/controllers/flare/requests_controller.rb +73 -0
  8. data/app/controllers/flare/spans_controller.rb +101 -0
  9. data/app/helpers/flare/application_helper.rb +168 -0
  10. data/app/views/flare/jobs/index.html.erb +69 -0
  11. data/app/views/flare/jobs/show.html.erb +323 -0
  12. data/app/views/flare/requests/index.html.erb +120 -0
  13. data/app/views/flare/requests/show.html.erb +498 -0
  14. data/app/views/flare/spans/index.html.erb +112 -0
  15. data/app/views/flare/spans/show.html.erb +184 -0
  16. data/app/views/layouts/flare/application.html.erb +126 -0
  17. data/config/routes.rb +20 -0
  18. data/exe/flare +9 -0
  19. data/lib/flare/backoff_policy.rb +73 -0
  20. data/lib/flare/cli/doctor_command.rb +129 -0
  21. data/lib/flare/cli/output.rb +45 -0
  22. data/lib/flare/cli/setup_command.rb +404 -0
  23. data/lib/flare/cli/status_command.rb +47 -0
  24. data/lib/flare/cli.rb +50 -0
  25. data/lib/flare/configuration.rb +121 -0
  26. data/lib/flare/engine.rb +43 -0
  27. data/lib/flare/http_metrics_config.rb +101 -0
  28. data/lib/flare/metric_counter.rb +45 -0
  29. data/lib/flare/metric_flusher.rb +124 -0
  30. data/lib/flare/metric_key.rb +42 -0
  31. data/lib/flare/metric_span_processor.rb +470 -0
  32. data/lib/flare/metric_storage.rb +42 -0
  33. data/lib/flare/metric_submitter.rb +221 -0
  34. data/lib/flare/source_location.rb +113 -0
  35. data/lib/flare/sqlite_exporter.rb +279 -0
  36. data/lib/flare/storage/sqlite.rb +789 -0
  37. data/lib/flare/storage.rb +54 -0
  38. data/lib/flare/version.rb +5 -0
  39. data/lib/flare.rb +411 -0
  40. data/public/flare-assets/flare.css +1245 -0
  41. data/public/flare-assets/images/flipper.png +0 -0
  42. metadata +240 -0
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "zlib"
6
+ require "stringio"
7
+ require "securerandom"
8
+ require "socket"
9
+
10
+ module Flare
11
+ # Submits metrics to the Flare metrics service via HTTP.
12
+ # Handles gzip compression, retries with exponential backoff, and error handling.
13
+ class MetricSubmitter
14
+ SCHEMA_VERSION = "V1"
15
+ GZIP_ENCODING = "gzip"
16
+ USER_AGENT = "Flare Ruby/#{Flare::VERSION}"
17
+
18
+ # Default timeouts (in seconds)
19
+ DEFAULT_OPEN_TIMEOUT = 2
20
+ DEFAULT_READ_TIMEOUT = 5
21
+ DEFAULT_WRITE_TIMEOUT = 5
22
+
23
+ # Max retries before giving up
24
+ MAX_RETRIES = 3
25
+
26
+ class SubmissionError < StandardError
27
+ attr_reader :request_id, :response_code, :response_body
28
+
29
+ def initialize(message, request_id:, response_code: nil, response_body: nil)
30
+ @request_id = request_id
31
+ @response_code = response_code
32
+ @response_body = response_body
33
+ super(message)
34
+ end
35
+ end
36
+
37
+ class ClientError < StandardError
38
+ attr_reader :request_id, :response_code, :response_body
39
+
40
+ def initialize(message, request_id:, response_code: nil, response_body: nil)
41
+ @request_id = request_id
42
+ @response_code = response_code
43
+ @response_body = response_body
44
+ super(message)
45
+ end
46
+ end
47
+
48
+ attr_reader :endpoint, :api_key, :backoff_policy
49
+
50
+ def initialize(endpoint:, api_key:, project: nil, environment: nil, backoff_policy: nil, open_timeout: nil, read_timeout: nil, write_timeout: nil)
51
+ @endpoint = URI("#{endpoint.to_s.chomp('/')}/api/metrics")
52
+ @api_key = api_key
53
+ @project = project || default_project
54
+ @environment = environment || default_environment
55
+ @backoff_policy = backoff_policy || BackoffPolicy.new
56
+ @open_timeout = open_timeout || DEFAULT_OPEN_TIMEOUT
57
+ @read_timeout = read_timeout || DEFAULT_READ_TIMEOUT
58
+ @write_timeout = write_timeout || DEFAULT_WRITE_TIMEOUT
59
+ end
60
+
61
+ # Submit drained metrics to the server.
62
+ # Returns [success_count, error] where error may be nil on success.
63
+ def submit(drained)
64
+ return [0, nil] if drained.empty?
65
+
66
+ request_id = SecureRandom.uuid
67
+ Flare.log "Submitting #{drained.size} metrics to #{@endpoint} (request_id=#{request_id})"
68
+
69
+ body = build_body(drained, request_id)
70
+ return [0, nil] if body.nil?
71
+
72
+ @backoff_policy.reset
73
+ response, error = retry_with_backoff(MAX_RETRIES) { post(body, request_id) }
74
+
75
+ if error
76
+ Flare.log "Submission failed: #{error.message} (request_id=#{request_id})"
77
+ [0, error]
78
+ else
79
+ Flare.log "Submission succeeded: #{response.code} (request_id=#{request_id})"
80
+ [drained.size, nil]
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def build_body(drained, request_id)
87
+ metrics = drained.map do |key, values|
88
+ {
89
+ bucket: format_time(key.bucket),
90
+ namespace: key.namespace,
91
+ service: key.service,
92
+ target: key.target || "",
93
+ operation: key.operation,
94
+ count: values[:count],
95
+ sum_ms: values[:sum_ms],
96
+ error_count: values[:error_count]
97
+ }
98
+ end
99
+
100
+ payload = {
101
+ request_id: request_id,
102
+ schema_version: SCHEMA_VERSION,
103
+ project: @project,
104
+ environment: @environment,
105
+ metrics: metrics
106
+ }
107
+
108
+ gzip(JSON.generate(payload))
109
+ rescue => e
110
+ warn "[Flare] Failed to build submission body: #{e.message}"
111
+ nil
112
+ end
113
+
114
+ def post(body, request_id)
115
+ http = Net::HTTP.new(@endpoint.host, @endpoint.port)
116
+ http.use_ssl = @endpoint.scheme == "https"
117
+ http.open_timeout = @open_timeout
118
+ http.read_timeout = @read_timeout
119
+ http.write_timeout = @write_timeout if http.respond_to?(:write_timeout=)
120
+
121
+ request_uri = @endpoint.request_uri
122
+ request = Net::HTTP::Post.new(request_uri == "" ? "/" : request_uri)
123
+ request["Content-Type"] = "application/json"
124
+ request["Content-Encoding"] = GZIP_ENCODING
125
+ request["Authorization"] = "Bearer #{@api_key}"
126
+ request["User-Agent"] = USER_AGENT
127
+ request["X-Request-Id"] = request_id
128
+ request["X-Schema-Version"] = SCHEMA_VERSION
129
+
130
+ # Client metadata headers (like Flipper)
131
+ request["X-Client-Language"] = "ruby"
132
+ request["X-Client-Language-Version"] = RUBY_VERSION
133
+ request["X-Client-Platform"] = RUBY_PLATFORM
134
+ request["X-Client-Pid"] = Process.pid.to_s
135
+ request["X-Client-Hostname"] = Socket.gethostname rescue "unknown"
136
+
137
+ request.body = body
138
+ response = http.request(request)
139
+
140
+ code = response.code.to_i
141
+
142
+ # Success
143
+ if code >= 200 && code < 300
144
+ return [response, false] # [result, should_retry]
145
+ end
146
+
147
+ # Retriable errors: rate limiting, server errors
148
+ if code == 429 || code >= 500
149
+ raise SubmissionError.new(
150
+ "Retriable error: #{code}",
151
+ request_id: request_id,
152
+ response_code: code,
153
+ response_body: response.body
154
+ )
155
+ end
156
+
157
+ # Non-retriable client errors (4xx except 429)
158
+ raise ClientError.new(
159
+ "Client error: #{code}",
160
+ request_id: request_id,
161
+ response_code: code,
162
+ response_body: response.body
163
+ )
164
+ end
165
+
166
+ def retry_with_backoff(max_attempts)
167
+ attempts_remaining = max_attempts
168
+ last_error = nil
169
+
170
+ while attempts_remaining > 0
171
+ begin
172
+ result, should_retry = yield
173
+ return [result, nil] unless should_retry
174
+ rescue SubmissionError, Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, Errno::ECONNRESET => e
175
+ last_error = e
176
+ attempts_remaining -= 1
177
+
178
+ if attempts_remaining > 0
179
+ sleep_time = @backoff_policy.next_interval / 1000.0
180
+ sleep(sleep_time)
181
+ end
182
+ next
183
+ rescue => e
184
+ # Unexpected errors - don't retry
185
+ return [nil, e]
186
+ end
187
+ end
188
+
189
+ [nil, last_error]
190
+ end
191
+
192
+ def gzip(string)
193
+ io = StringIO.new
194
+ io.set_encoding("BINARY")
195
+ gz = Zlib::GzipWriter.new(io)
196
+ gz.write(string)
197
+ gz.close
198
+ io.string
199
+ end
200
+
201
+ def format_time(time)
202
+ time.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
203
+ end
204
+
205
+ def default_project
206
+ if defined?(Rails) && Rails.application
207
+ Rails.application.class.module_parent_name.underscore rescue "rails_app"
208
+ else
209
+ "app"
210
+ end
211
+ end
212
+
213
+ def default_environment
214
+ if defined?(Rails)
215
+ Rails.env.to_s
216
+ else
217
+ ENV.fetch("RACK_ENV", "development")
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flare
4
+ # Utility for finding the source location (file, line, method) of app code
5
+ # that triggered a database query or other instrumented operation.
6
+ module SourceLocation
7
+ # How many app code lines to capture for the backtrace
8
+ MAX_TRACE_LINES = 8
9
+
10
+ # Patterns to filter out from backtraces (gems, framework code)
11
+ IGNORE_PATTERNS = [
12
+ /\/gems\//,
13
+ /\/ruby\//,
14
+ /\/rubygems\//,
15
+ /lib\/active_record/,
16
+ /lib\/active_support/,
17
+ /lib\/action_/,
18
+ /opentelemetry/,
19
+ /flare/,
20
+ /<internal:/,
21
+ /\/bin\//,
22
+ ].freeze
23
+
24
+ module_function
25
+
26
+ # Find the first app source location from the current backtrace
27
+ # Returns a hash with :filepath, :lineno, :function or nil
28
+ def find
29
+ backtrace = caller(2, 50)
30
+ return nil unless backtrace
31
+
32
+ # Find the first line that's app code (not gems/framework)
33
+ app_line = backtrace.find { |line| app_code?(line) }
34
+ return nil unless app_line
35
+
36
+ parse_backtrace_line(app_line)
37
+ end
38
+
39
+ # Find multiple app source locations from the current backtrace
40
+ # Returns an array of cleaned backtrace lines (up to MAX_TRACE_LINES)
41
+ def find_trace
42
+ backtrace = caller(2, 100)
43
+ return [] unless backtrace
44
+
45
+ # Filter to only app code lines
46
+ app_lines = backtrace.select { |line| app_code?(line) }
47
+ return [] if app_lines.empty?
48
+
49
+ # Clean and format each line, limit to MAX_TRACE_LINES
50
+ app_lines.first(MAX_TRACE_LINES).map { |line| clean_backtrace_line(line) }
51
+ end
52
+
53
+ # Add source location attributes to a hash (for span attributes)
54
+ def add_to_attributes(attrs)
55
+ location = find
56
+ return attrs unless location
57
+
58
+ attrs["code.filepath"] = location[:filepath]
59
+ attrs["code.lineno"] = location[:lineno]
60
+ attrs["code.function"] = location[:function] if location[:function]
61
+
62
+ # Add full trace as a single string attribute
63
+ trace = find_trace
64
+ attrs["code.stacktrace"] = trace.join("\n") if trace.any?
65
+
66
+ attrs
67
+ end
68
+
69
+ def app_code?(line)
70
+ # Must contain /app/ (Rails convention) and not match ignore patterns
71
+ return false unless line.include?("/app/")
72
+
73
+ IGNORE_PATTERNS.none? { |pattern| line.match?(pattern) }
74
+ end
75
+
76
+ def parse_backtrace_line(line)
77
+ # Parse: /path/to/file.rb:123:in `method_name'
78
+ if line =~ /\A(.+):(\d+):in [`'](.+)'\z/
79
+ {
80
+ filepath: clean_path($1),
81
+ lineno: $2.to_i,
82
+ function: $3
83
+ }
84
+ elsif line =~ /\A(.+):(\d+)\z/
85
+ {
86
+ filepath: clean_path($1),
87
+ lineno: $2.to_i,
88
+ function: nil
89
+ }
90
+ end
91
+ end
92
+
93
+ def clean_backtrace_line(line)
94
+ # Parse and reformat: "app/models/user.rb:42:in `find_by_email'"
95
+ if line =~ /\A(.+):(\d+):in [`'](.+)'\z/
96
+ "#{clean_path($1)}:#{$2} in `#{$3}'"
97
+ elsif line =~ /\A(.+):(\d+)\z/
98
+ "#{clean_path($1)}:#{$2}"
99
+ else
100
+ clean_path(line)
101
+ end
102
+ end
103
+
104
+ def clean_path(path)
105
+ # Remove Rails.root prefix if present
106
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
107
+ path.sub(/\A#{Regexp.escape(Rails.root.to_s)}\//, "")
108
+ else
109
+ path
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sqlite3"
4
+ require "json"
5
+
6
+ module Flare
7
+ class SQLiteExporter
8
+ SUCCESS = OpenTelemetry::SDK::Trace::Export::SUCCESS
9
+ FAILURE = OpenTelemetry::SDK::Trace::Export::FAILURE
10
+ TIMEOUT = OpenTelemetry::SDK::Trace::Export::TIMEOUT
11
+
12
+ # Prune roughly every 100 exports (1% chance per export)
13
+ PRUNE_PROBABILITY = 0.01
14
+
15
+ def initialize(database_path)
16
+ @database_path = database_path
17
+ @mutex = Mutex.new
18
+ @setup = false
19
+ end
20
+
21
+ # Maximum number of retry attempts when the database is busy.
22
+ # Mirrors ActiveRecord's retry strategy for SQLite.
23
+ MAX_RETRIES = 3
24
+
25
+ def export(span_datas, timeout: nil)
26
+ setup_database unless @setup
27
+
28
+ retries = 0
29
+ exported = 0
30
+
31
+ begin
32
+ @mutex.synchronize do
33
+ connection.transaction do
34
+ span_datas.each do |span_data|
35
+ next if should_ignore_span?(span_data)
36
+
37
+ create_span(span_data)
38
+ exported += 1
39
+ end
40
+ end
41
+ end
42
+ rescue ::SQLite3::BusyException
43
+ retries += 1
44
+ if retries <= MAX_RETRIES
45
+ sleep 0.1 * retries
46
+ retry
47
+ end
48
+ warn "[Flare] SQLite export error: database is busy after #{MAX_RETRIES} retries"
49
+ return FAILURE
50
+ end
51
+
52
+ Flare.log "Exported #{exported} spans to SQLite" if exported > 0
53
+
54
+ # Periodically prune old data
55
+ maybe_prune
56
+
57
+ SUCCESS
58
+ rescue => e
59
+ warn "[Flare] SQLite export error: #{e.message}"
60
+ FAILURE
61
+ end
62
+
63
+ def force_flush(timeout: nil)
64
+ SUCCESS
65
+ end
66
+
67
+ def shutdown(timeout: nil)
68
+ SUCCESS
69
+ end
70
+
71
+ private
72
+
73
+ def maybe_prune
74
+ return unless rand < PRUNE_PROBABILITY
75
+
76
+ Flare.storage.prune(
77
+ retention_hours: Flare.configuration.retention_hours,
78
+ max_spans: Flare.configuration.max_spans
79
+ )
80
+ rescue => e
81
+ warn "[Flare] Prune error: #{e.message}"
82
+ end
83
+
84
+ def should_ignore_span?(span_data)
85
+ span_data.name&.start_with?("Flare::")
86
+ end
87
+
88
+ def create_span(span_data)
89
+ now = Time.now.iso8601(6)
90
+
91
+ sql = <<~SQL
92
+ INSERT INTO flare_spans (name, kind, span_id, trace_id, parent_span_id, start_timestamp, end_timestamp, total_recorded_links, total_recorded_events, total_recorded_properties, created_at, updated_at)
93
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
94
+ SQL
95
+
96
+ values = [
97
+ span_data.name,
98
+ span_data.kind.to_s,
99
+ span_data.hex_span_id,
100
+ span_data.hex_trace_id,
101
+ span_data.hex_parent_span_id,
102
+ span_data.start_timestamp,
103
+ span_data.end_timestamp,
104
+ span_data.total_recorded_links,
105
+ span_data.total_recorded_events,
106
+ span_data.total_recorded_attributes,
107
+ now,
108
+ now
109
+ ]
110
+
111
+ connection.execute(sql, values)
112
+ span_record_id = connection.last_insert_row_id
113
+
114
+ span_data.events&.each do |span_event|
115
+ create_event(span_record_id, span_event)
116
+ end
117
+
118
+ create_properties("Flare::Span", span_record_id, span_data.attributes)
119
+ end
120
+
121
+ def create_event(span_record_id, span_event)
122
+ now = Time.now.iso8601(6)
123
+ timestamp = span_event.timestamp ? Time.at(span_event.timestamp / 1_000_000_000.0).iso8601(6) : now
124
+
125
+ sql = <<~SQL
126
+ INSERT INTO flare_events (span_id, name, created_at, updated_at)
127
+ VALUES (?, ?, ?, ?)
128
+ SQL
129
+
130
+ connection.execute(sql, [span_record_id, span_event.name, timestamp, now])
131
+ event_record_id = connection.last_insert_row_id
132
+ create_properties("Flare::Event", event_record_id, span_event.attributes)
133
+ end
134
+
135
+ def create_properties(owner_type, owner_id, attributes)
136
+ return unless attributes
137
+
138
+ now = Time.now.iso8601(6)
139
+
140
+ sql = <<~SQL
141
+ INSERT INTO flare_properties (key, value, value_type, owner_type, owner_id, created_at, updated_at)
142
+ VALUES (?, ?, ?, ?, ?, ?, ?)
143
+ SQL
144
+
145
+ attributes.each do |key, value|
146
+ next if value.nil?
147
+
148
+ value_type = determine_value_type(value)
149
+ serialized_value = JSON.generate(value)
150
+ connection.execute(sql, [key, serialized_value, value_type, owner_type, owner_id, now, now])
151
+ end
152
+ end
153
+
154
+ def determine_value_type(value)
155
+ case value
156
+ when String then 0 # string
157
+ when Integer then 1 # integer
158
+ when Float then 2 # float
159
+ when TrueClass, FalseClass then 3 # boolean
160
+ when Array then 4 # array
161
+ else 0 # default to string
162
+ end
163
+ end
164
+
165
+ def setup_database
166
+ @mutex.synchronize do
167
+ return if @setup
168
+
169
+ db = connection
170
+ configure_pragmas(db)
171
+
172
+ db.execute(<<~SQL)
173
+ CREATE TABLE IF NOT EXISTS flare_spans (
174
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
175
+ name TEXT NOT NULL,
176
+ kind TEXT NOT NULL,
177
+ span_id TEXT NOT NULL,
178
+ trace_id TEXT NOT NULL,
179
+ parent_span_id TEXT,
180
+ start_timestamp INTEGER NOT NULL,
181
+ end_timestamp INTEGER NOT NULL,
182
+ total_recorded_properties INTEGER NOT NULL DEFAULT 0,
183
+ total_recorded_events INTEGER NOT NULL DEFAULT 0,
184
+ total_recorded_links INTEGER NOT NULL DEFAULT 0,
185
+ created_at TEXT NOT NULL,
186
+ updated_at TEXT NOT NULL
187
+ )
188
+ SQL
189
+
190
+ db.execute(<<~SQL)
191
+ CREATE INDEX IF NOT EXISTS idx_spans_span_id ON flare_spans(span_id)
192
+ SQL
193
+
194
+ db.execute(<<~SQL)
195
+ CREATE INDEX IF NOT EXISTS idx_spans_trace_id ON flare_spans(trace_id)
196
+ SQL
197
+
198
+ db.execute(<<~SQL)
199
+ CREATE INDEX IF NOT EXISTS idx_spans_parent_span_id ON flare_spans(parent_span_id)
200
+ SQL
201
+
202
+ db.execute(<<~SQL)
203
+ CREATE INDEX IF NOT EXISTS idx_spans_created_at ON flare_spans(created_at)
204
+ SQL
205
+
206
+ db.execute(<<~SQL)
207
+ CREATE TABLE IF NOT EXISTS flare_events (
208
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
209
+ span_id INTEGER NOT NULL,
210
+ name TEXT NOT NULL,
211
+ created_at TEXT NOT NULL,
212
+ updated_at TEXT NOT NULL,
213
+ FOREIGN KEY (span_id) REFERENCES flare_spans(id)
214
+ )
215
+ SQL
216
+
217
+ db.execute(<<~SQL)
218
+ CREATE INDEX IF NOT EXISTS idx_events_span_id ON flare_events(span_id)
219
+ SQL
220
+
221
+ db.execute(<<~SQL)
222
+ CREATE TABLE IF NOT EXISTS flare_properties (
223
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
224
+ key TEXT NOT NULL,
225
+ value TEXT,
226
+ value_type INTEGER NOT NULL DEFAULT 0,
227
+ owner_type TEXT NOT NULL,
228
+ owner_id INTEGER NOT NULL,
229
+ created_at TEXT NOT NULL,
230
+ updated_at TEXT NOT NULL
231
+ )
232
+ SQL
233
+
234
+ db.execute(<<~SQL)
235
+ CREATE INDEX IF NOT EXISTS idx_properties_owner ON flare_properties(owner_type, owner_id)
236
+ SQL
237
+
238
+ db.execute(<<~SQL)
239
+ CREATE INDEX IF NOT EXISTS idx_properties_key ON flare_properties(key)
240
+ SQL
241
+
242
+ close_connection # avoid inheriting connection across fork
243
+ @setup = true
244
+ end
245
+ end
246
+
247
+ # Applies the same SQLite pragmas that ActiveRecord uses for good
248
+ # concurrency and performance with threaded/multi-process access.
249
+ def configure_pragmas(db)
250
+ db.execute("PRAGMA journal_mode=WAL")
251
+ db.execute("PRAGMA synchronous=NORMAL")
252
+ db.execute("PRAGMA mmap_size=134217728") # 128MB
253
+ db.execute("PRAGMA journal_size_limit=67108864") # 64MB
254
+ db.execute("PRAGMA cache_size=2000")
255
+ end
256
+
257
+ def connection_key
258
+ :"flare_sqlite_db_#{@database_path.hash}"
259
+ end
260
+
261
+ def connection
262
+ Thread.current[connection_key] ||= begin
263
+ dir = File.dirname(@database_path)
264
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
265
+ db = ::SQLite3::Database.new(@database_path, results_as_hash: true)
266
+ db.busy_timeout = 5000
267
+ db
268
+ end
269
+ end
270
+
271
+ def close_connection
272
+ key = connection_key
273
+ if db = Thread.current[key]
274
+ db.close rescue nil
275
+ Thread.current[key] = nil
276
+ end
277
+ end
278
+ end
279
+ end