tobias 0.2.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 342518e2c3730a589319c0d16967f7e9fdc80affde011c3820ef2c1034fcbc3e
4
- data.tar.gz: adcdc3152a07d4e1203ad6ceff26901e999f5a8110e883675aa20c9e894ae932
3
+ metadata.gz: 6e8024f2a5efa0e4ffe41d2973817f1906fccfb32193782b86e55f822a82f5e7
4
+ data.tar.gz: 5b6b27888ed7341c76fea8503bc8e577662949903089970930fced41367583e0
5
5
  SHA512:
6
- metadata.gz: af1341883626cda785ba564368baad44e0cec9e55751bb7c00d204b1e12cf32ebc3817002f2c7fd81e7b57a6ddd2a78be74b11fa0e1f3f837741552f8064f338
7
- data.tar.gz: 58d79c4c82d5bf1cd21e42c6770dcc454c52bfab5a22a5fa1d15ed6dfe8b3abfd340d7a31b07c6205b7955787214395814176cd997307e2dad083256c29c5139
6
+ metadata.gz: 2e289f868dad239629020799f64c05be1f5658f2facd905d917ce162bcccbefc957e979c19bdb36ec37e1c21eb89ae26d7b2f2a458f11b8d1d335eb5b9a9a37c
7
+ data.tar.gz: fece84c5f4c2d65961c091ee4bb7e2879b43646e60a8c470079d20529541757e0e61d9f4a2163e0615660c38b62c758504bdde9009a35e426f0578d598089468
data/bin/tobias CHANGED
@@ -3,5 +3,8 @@
3
3
 
4
4
  require_relative "../lib/tobias"
5
5
 
6
- RubyVM::YJIT.enable
6
+ if defined?(RubyVM::YJIT)
7
+ RubyVM::YJIT.enable
8
+ end
9
+
7
10
  Tobias::CLI.start(ARGV, exit_on_failure: true)
data/lib/tobias/cli.rb CHANGED
@@ -8,14 +8,46 @@ module Tobias
8
8
  true
9
9
  end
10
10
 
11
+ desc "recommend", "recommend a work_mem setting for a database"
12
+ option :database_url, type: :string, required: true
13
+ option :debug, type: :boolean, default: false
14
+ def recommend
15
+ database = Sequel.connect(options[:database_url])
16
+ database.extension :pgvector
17
+ database.loggers << Logger.new(STDERR) if options[:debug]
18
+
19
+ parsed = TTY::Markdown.parse(<<~MARKDOWN)
20
+ # @tobias is thinking...
21
+ MARKDOWN
22
+ puts parsed
23
+
24
+ work_mem = Tobias::WorkMem.valid_for(database).sort_by(&:amount).reverse.first
25
+
26
+ parsed = TTY::Markdown.parse(<<~MARKDOWN)
27
+ # @tobias has sent you a new message
28
+
29
+ I've reviewed your database by analyzing your shared buffers and connection limits and
30
+ recommend setting `work_mem` to `#{work_mem.to_sql}`. To apply my recommendation, run the following SQL:
31
+
32
+ ```sql
33
+ ALTER SYSTEM SET work_mem = '#{work_mem.to_sql}';
34
+ SELECT pg_reload_conf();
35
+ ```
36
+
37
+ Regards,
38
+ ~ Tobias
39
+ MARKDOWN
40
+ puts parsed
41
+ end
42
+
11
43
  desc "profile SCRIPT", "profile"
12
44
  option :database_url, type: :string, required: true
13
- option :iterations, type: :numeric, default: 100
14
45
  option :debug, type: :boolean, default: false
15
46
  def profile(script)
16
- database = Sequel.connect(options[:database_url])
47
+ database = Sequel.connect(options[:database_url], max_connections: Etc.nprocessors + 2)
17
48
  database.loggers << Logger.new(STDERR) if options[:debug]
18
49
  database.extension :pg_json
50
+ database.extension :pgvector
19
51
 
20
52
  if File.exist?(script)
21
53
  code = File.read(script)
@@ -23,8 +55,7 @@ module Tobias
23
55
  raise "Script not found at: #{script}"
24
56
  end
25
57
 
26
- container = Container.new(code)
27
- work_mems = WorkMem.valid_for(database)
58
+ container = Container.new(code, database)
28
59
  results = {}
29
60
 
30
61
  parsed = TTY::Markdown.parse(<<~MARKDOWN)
@@ -33,11 +64,7 @@ module Tobias
33
64
  puts parsed
34
65
 
35
66
  thinking_time = Benchmark.realtime do
36
- container.queries.each do |name, block|
37
- work_mem = Evaluation.new(database, work_mems).run(options, &block)
38
-
39
- results[name] = work_mem
40
- end
67
+ results = Evaluations.run(database, container, options)
41
68
  end
42
69
 
43
70
  parsed = TTY::Markdown.parse(<<~MARKDOWN)
@@ -45,18 +72,7 @@ module Tobias
45
72
 
46
73
  I thought about your queries for precisely #{thinking_time.round(2)} seconds and here is what I recommend:
47
74
 
48
- | Query | Required work_mem |
49
- |-------|-------------------|
50
- #{results.map { |name, work_mem| "| #{name} | #{work_mem.to_sql} |" }.join("\n")}
51
-
52
- Your application will need to run with at least #{results.values.max.to_sql} of work_mem.
53
-
54
- To apply my recommendations, run the following SQL:
55
-
56
- ```sql
57
- ALTER SYSTEM SET work_mem = '#{results.values.max.to_sql}';
58
- SELECT pg_reload_conf();
59
- ```
75
+ #{results.join("\n")}
60
76
 
61
77
  Regards,
62
78
  ~ Tobias
@@ -1,20 +1,95 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "open3"
4
+
3
5
  module Tobias
4
6
  class Container
5
- def initialize(code)
7
+ def initialize(code, database)
6
8
  @code = code
7
- @queries = {}
9
+ @database = database
10
+ @queries = Concurrent::Hash.new
11
+ @sql = Concurrent::Hash.new
12
+ @options = Concurrent::Hash.new
13
+ @setup = Proc.new { }
14
+ @teardown = Proc.new { }
15
+ @load_data = Proc.new { }
16
+ @helpers = Module.new
8
17
 
9
18
  eval(code, binding, __FILE__, __LINE__)
10
19
  end
11
20
 
21
+ module DefaultHelpers
22
+ def db
23
+ @database
24
+ end
25
+
26
+ def run_parallel(list = Etc.nprocessors.times, &block)
27
+ db.disconnect
28
+
29
+ Parallel.each(list, in_processes: Etc.nprocessors) do |item|
30
+ instance_exec(item, &block)
31
+ end
32
+ end
33
+ end
34
+
35
+ def run_setup
36
+ @database.run("CREATE EXTENSION IF NOT EXISTS pg_stat_statements")
37
+ run_action(@setup)
38
+ end
39
+
40
+ def run_query(query)
41
+ @database.run(run_action(query).sql)
42
+ end
43
+
44
+ def run_teardown
45
+ run_action(@teardown)
46
+ end
47
+
48
+ def options
49
+ Struct.new(*@options.keys).new(*@options.values)
50
+ end
51
+
52
+ def run_action(action)
53
+ helpers = @helpers
54
+
55
+ class_eval do
56
+ include DefaultHelpers
57
+ include helpers
58
+ end
59
+
60
+ instance_eval(&action)
61
+ end
62
+
12
63
  def queries
13
64
  @queries
14
65
  end
15
66
 
16
- def query(name, &block)
17
- @queries[name] = block
67
+ def option(name, default = nil, &block)
68
+ @options[name] = block || default
69
+ end
70
+
71
+ def helpers(&block)
72
+ @helpers.class_eval(&block) if block_given?
73
+ end
74
+
75
+ def setup(&block)
76
+ @setup = block
77
+ end
78
+
79
+ def teardown(&block)
80
+ @teardown = block
81
+ end
82
+
83
+ def load_data(&block)
84
+ @load_data = block
85
+ end
86
+
87
+ def query(name, sql = nil, &block)
88
+ if sql.is_a?(String)
89
+ @queries[name] = Proc.new { sql }
90
+ else
91
+ @queries[name] = block || Proc.new { raise "No SQL provided for query '#{name}'" }
92
+ end
18
93
  end
19
94
  end
20
- end
95
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ class MarkdownTableBorder < TTY::Table::Border
4
+ def_border do
5
+ left "|"
6
+ center "|"
7
+ right "|"
8
+ bottom " "
9
+ bottom_mid " "
10
+ bottom_left " "
11
+ bottom_right " "
12
+ end
13
+ end
14
+
15
+ module Tobias
16
+ module Evaluations
17
+ Result = Struct.new(:name, :value, keyword_init: true) do
18
+ def <=>(other)
19
+ value <=> other.value
20
+ end
21
+ end
22
+
23
+ class Base
24
+ attr_reader :database, :container, :options
25
+
26
+ def initialize(database, container, options)
27
+ @database = database
28
+ @container = container
29
+ @options = options
30
+ end
31
+
32
+ def run(&block)
33
+ results = Concurrent::Array.new
34
+
35
+ container.run_setup
36
+ container.queries.each do |name, query|
37
+ result = run_each(name, query)
38
+ results << result if result
39
+ end
40
+
41
+ to_markdown(results)
42
+ ensure
43
+ container.run_teardown
44
+ end
45
+
46
+ def run_each(query)
47
+ raise NotImplementedError
48
+ end
49
+
50
+ def to_markdown(results)
51
+ raise NotImplementedError
52
+ end
53
+
54
+ def render_table(headers:, body:)
55
+ table = TTY::Table.new(header: headers)
56
+ body.each do |row|
57
+ table << row
58
+ end
59
+
60
+ table.render_with(MarkdownTableBorder)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tobias
4
+ module Evaluations
5
+ class WorkMem < Base
6
+ def work_mems
7
+ @work_mems ||= Tobias::WorkMem.valid_for(database)
8
+ end
9
+
10
+ def current_work_mem
11
+ @current_work_mem ||= Tobias::WorkMem.from_sql(database.fetch("SHOW work_mem").first[:work_mem])
12
+ end
13
+
14
+ def description
15
+ "Optimal work_mem settings"
16
+ end
17
+
18
+ def run_each(name, query)
19
+ database.run("CREATE EXTENSION IF NOT EXISTS pg_stat_statements")
20
+
21
+ work_mems.each do |value|
22
+ database.transaction do
23
+ database.run("SET LOCAL work_mem = '#{value.to_sql}'")
24
+ database.select(Sequel.function(:pg_stat_reset)).first
25
+ container.run_query(query)
26
+
27
+ stats = database[:pg_stat_database].
28
+ where(datname: Sequel.function(:current_database)).
29
+ first
30
+
31
+ if stats[:temp_files] == 0 && stats[:temp_bytes] == 0
32
+ return Result.new(name: name, value: value)
33
+ end
34
+ end
35
+ end
36
+
37
+ # Fallback to the highest work_mem setting if no results are found.
38
+ Result.new(name: name, value: work_mems.last)
39
+ end
40
+
41
+ def to_markdown(results)
42
+ <<~MARKDOWN
43
+ ## #{description}
44
+
45
+ #{render_table(headers: ["Query", "Required work_mem"], body: results.map { |r| [r.name, r.value.to_sql] })}
46
+
47
+ I see that your current `work_mem` setting is `#{current_work_mem.to_sql}`.
48
+
49
+ Your application will need to run with at least `#{results.max.value.to_sql}` of `work_mem`.
50
+
51
+ To apply my recommendations, run the following SQL:
52
+
53
+ ```sql
54
+ ALTER SYSTEM SET work_mem = '#{results.max.value.to_sql}';
55
+ SELECT pg_reload_conf();
56
+ ```
57
+ MARKDOWN
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tobias
4
+ module Evaluations
5
+ def self.run(database, container, options)
6
+ results = []
7
+ results << WorkMem.new(database, container, options).run
8
+ results
9
+ end
10
+ end
11
+ end
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tobias
4
- VERSION = "0.2.0"
5
-
4
+ VERSION = "0.4.0"
6
5
  SUMMARY = "Tobias is a tool to help you find the optimal work_mem for your queries."
7
6
  DESCRIPTION = "Tobias is a tool to help you find the optimal work_mem for your queries."
8
7
  end
@@ -4,6 +4,21 @@ module Tobias
4
4
  class WorkMem
5
5
  attr_reader :amount
6
6
 
7
+ def self.from_sql(sql)
8
+ case sql
9
+ when /^\d+B$/
10
+ new(sql.to_i)
11
+ when /^\d+kB$/
12
+ new(sql.to_i * 1024)
13
+ when /^\d+MB$/
14
+ new(sql.to_i * 1024 * 1024)
15
+ when /^\d+GB$/
16
+ new(sql.to_i * 1024 * 1024 * 1024)
17
+ else
18
+ raise "Invalid work_mem setting: #{sql}"
19
+ end
20
+ end
21
+
7
22
  def initialize(amount)
8
23
  @amount = amount
9
24
  end
@@ -52,6 +67,7 @@ module Tobias
52
67
  [
53
68
  new(64.kilobytes),
54
69
  new(128.kilobytes),
70
+ new(256.kilobytes),
55
71
  new(512.kilobytes),
56
72
  new(1.megabyte),
57
73
  new(4.megabytes),
@@ -101,4 +117,4 @@ module Tobias
101
117
  self.all.select { |work_mem| work_mem.amount < bytes_per_connection.to_i }
102
118
  end
103
119
  end
104
- end
120
+ end
data/lib/tobias.rb CHANGED
@@ -11,21 +11,31 @@ if RUBY_ENGINE == "ruby"
11
11
  end
12
12
  end
13
13
 
14
+ # See: https://github.com/ged/ruby-pg/issues/538#issuecomment-1591629049
15
+ ENV["PGGSSENCMODE"] = "disable"
16
+
14
17
  require "bundler/setup"
15
18
  Bundler.require(:default)
16
19
 
17
20
  require "thor"
18
21
  require "active_support/all"
19
22
  require "sequel"
23
+ require "pgvector"
20
24
  require "enumerable-stats"
21
25
  require "benchmark"
26
+ require "parquet"
27
+ require "parallel"
22
28
  require "tty-markdown"
29
+ require "tty-table"
23
30
 
24
31
  $LOAD_PATH.unshift File.dirname(__FILE__)
25
32
 
33
+ require "tobias/evaluations"
34
+ require "tobias/evaluations/base"
35
+ require "tobias/evaluations/work_mem"
36
+
26
37
  module Tobias
27
38
  autoload :CLI, "tobias/cli"
28
39
  autoload :Container, "tobias/container"
29
- autoload :Evaluation, "tobias/evaluation"
30
40
  autoload :WorkMem, "tobias/work_mem"
31
- end
41
+ end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tobias
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon Daniel
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 2025-08-03 00:00:00.000000000 Z
11
+ date: 2025-08-23 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: activesupport
@@ -189,6 +190,66 @@ dependencies:
189
190
  - - ">="
190
191
  - !ruby/object:Gem::Version
191
192
  version: 5.76.0
193
+ - !ruby/object:Gem::Dependency
194
+ name: parquet
195
+ requirement: !ruby/object:Gem::Requirement
196
+ requirements:
197
+ - - "~>"
198
+ - !ruby/object:Gem::Version
199
+ version: '0.7'
200
+ - - ">="
201
+ - !ruby/object:Gem::Version
202
+ version: 0.7.3
203
+ type: :runtime
204
+ prerelease: false
205
+ version_requirements: !ruby/object:Gem::Requirement
206
+ requirements:
207
+ - - "~>"
208
+ - !ruby/object:Gem::Version
209
+ version: '0.7'
210
+ - - ">="
211
+ - !ruby/object:Gem::Version
212
+ version: 0.7.3
213
+ - !ruby/object:Gem::Dependency
214
+ name: pgvector
215
+ requirement: !ruby/object:Gem::Requirement
216
+ requirements:
217
+ - - "~>"
218
+ - !ruby/object:Gem::Version
219
+ version: '0.3'
220
+ - - ">="
221
+ - !ruby/object:Gem::Version
222
+ version: 0.3.0
223
+ type: :runtime
224
+ prerelease: false
225
+ version_requirements: !ruby/object:Gem::Requirement
226
+ requirements:
227
+ - - "~>"
228
+ - !ruby/object:Gem::Version
229
+ version: '0.3'
230
+ - - ">="
231
+ - !ruby/object:Gem::Version
232
+ version: 0.3.0
233
+ - !ruby/object:Gem::Dependency
234
+ name: parallel
235
+ requirement: !ruby/object:Gem::Requirement
236
+ requirements:
237
+ - - "~>"
238
+ - !ruby/object:Gem::Version
239
+ version: '1.20'
240
+ - - ">="
241
+ - !ruby/object:Gem::Version
242
+ version: 1.20.0
243
+ type: :runtime
244
+ prerelease: false
245
+ version_requirements: !ruby/object:Gem::Requirement
246
+ requirements:
247
+ - - "~>"
248
+ - !ruby/object:Gem::Version
249
+ version: '1.20'
250
+ - - ">="
251
+ - !ruby/object:Gem::Version
252
+ version: 1.20.0
192
253
  - !ruby/object:Gem::Dependency
193
254
  name: thor
194
255
  requirement: !ruby/object:Gem::Requirement
@@ -229,6 +290,26 @@ dependencies:
229
290
  - - ">="
230
291
  - !ruby/object:Gem::Version
231
292
  version: 0.7.0
293
+ - !ruby/object:Gem::Dependency
294
+ name: tty-table
295
+ requirement: !ruby/object:Gem::Requirement
296
+ requirements:
297
+ - - "~>"
298
+ - !ruby/object:Gem::Version
299
+ version: '0.12'
300
+ - - ">="
301
+ - !ruby/object:Gem::Version
302
+ version: 0.12.0
303
+ type: :runtime
304
+ prerelease: false
305
+ version_requirements: !ruby/object:Gem::Requirement
306
+ requirements:
307
+ - - "~>"
308
+ - !ruby/object:Gem::Version
309
+ version: '0.12'
310
+ - - ">="
311
+ - !ruby/object:Gem::Version
312
+ version: 0.12.0
232
313
  description: Tobias is a tool to help you find the optimal work_mem for your queries.
233
314
  email: binarycleric@gmail.com
234
315
  executables:
@@ -240,7 +321,9 @@ files:
240
321
  - lib/tobias.rb
241
322
  - lib/tobias/cli.rb
242
323
  - lib/tobias/container.rb
243
- - lib/tobias/evaluation.rb
324
+ - lib/tobias/evaluations.rb
325
+ - lib/tobias/evaluations/base.rb
326
+ - lib/tobias/evaluations/work_mem.rb
244
327
  - lib/tobias/version.rb
245
328
  - lib/tobias/work_mem.rb
246
329
  homepage: https://github.com/binarycleric/tobias
@@ -249,6 +332,7 @@ licenses:
249
332
  metadata:
250
333
  source_code_uri: https://github.com/binarycleric/tobias
251
334
  rubygems_mfa_required: 'true'
335
+ post_install_message:
252
336
  rdoc_options: []
253
337
  require_paths:
254
338
  - lib
@@ -263,7 +347,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
263
347
  - !ruby/object:Gem::Version
264
348
  version: '0'
265
349
  requirements: []
266
- rubygems_version: 3.6.2
350
+ rubygems_version: 3.5.22
351
+ signing_key:
267
352
  specification_version: 4
268
353
  summary: Tobias is a tool to help you find the optimal work_mem for your queries.
269
354
  test_files: []
@@ -1,36 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Tobias
4
- class Evaluation
5
- attr_reader :database
6
-
7
- def initialize(database, work_mems)
8
- @database = database
9
- @work_mems = work_mems
10
- end
11
-
12
- def run(options, &block)
13
- @work_mems.each do |value|
14
- database.transaction do
15
- database.run("CREATE EXTENSION IF NOT EXISTS pg_stat_statements")
16
- database.run("SET LOCAL work_mem = '#{value.to_sql}'")
17
- database.select(Sequel.function(:pg_stat_reset)).first
18
- database.instance_eval(&block)
19
-
20
- query = database.instance_eval(&block)
21
- options[:iterations].to_i.times do
22
- database.run(query.sql)
23
- end
24
-
25
- stats = database[:pg_stat_database].where(datname: Sequel.function(:current_database)).first
26
-
27
- if stats[:temp_files] == 0 && stats[:temp_bytes] == 0
28
- return value
29
- end
30
- end
31
- end
32
-
33
- raise "No work_mem found."
34
- end
35
- end
36
- end