nose-cli 0.1.0pre

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NoSE
4
+ module CLI
5
+ # Add a command to run a proxy server capable of executing queries
6
+ class NoSECLI < Thor
7
+ desc 'proxy PLAN_FILE', 'start a proxy with the given PLAN_FILE'
8
+
9
+ long_desc <<-LONGDESC
10
+ `nose proxy` loads the schema and execution plans from the given file.
11
+ It then starts the configured proxy server which is able to execute
12
+ the given set of plans. Available proxy servers can be seen in the
13
+ `lib/nose/proxy` subdirectory.
14
+ LONGDESC
15
+
16
+ def proxy(plan_file)
17
+ result = load_results plan_file
18
+ backend = get_backend(options, result)
19
+
20
+ # Create a new instance of the proxy class
21
+ proxy_class = get_class 'proxy', options
22
+ proxy = proxy_class.new options[:proxy], result, backend
23
+
24
+ # Start the proxy server
25
+ trap 'INT' do
26
+ proxy.stop
27
+ end
28
+ proxy.start
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NoSE
4
+ module CLI
5
+ # Add a command to generate a graphic of the schema from a workload
6
+ class NoSECLI < Thor
7
+ desc 'random-plans WORKLOAD TAG',
8
+ 'output random plans for the statement with TAG in WORKLOAD'
9
+
10
+ long_desc <<-LONGDESC
11
+ `nose random-plans` produces a set of randomly chosen plans for a given
12
+ workload. This is useful when evaluating the cost model for a given
13
+ system as this should give a good range of different execution plans.
14
+ LONGDESC
15
+
16
+ shared_option :format
17
+ shared_option :output
18
+
19
+ option :count, type: :numeric, default: 5, aliases: '-n',
20
+ desc: 'the number of random plans to produce'
21
+ option :all, type: :boolean, default: false, aliases: '-a',
22
+ desc: 'whether to include all possible plans, if given ' \
23
+ 'then --count is ignored'
24
+
25
+ def random_plans(workload_name, tag)
26
+ # Find the statement with the given tag
27
+ workload = Workload.load workload_name
28
+ statement = workload.find_with_tag tag
29
+
30
+ # Generate a random set of plans
31
+ indexes = IndexEnumerator.new(workload).indexes_for_workload
32
+ cost_model = get_class('cost', options[:cost_model][:name]) \
33
+ .new(**options[:cost_model])
34
+ plans = find_random_plans statement, workload, indexes, cost_model,
35
+ options
36
+ results = random_plan_results workload, indexes, plans, cost_model
37
+ output_random_plans results, options[:output], options[:format]
38
+ end
39
+
40
+ private
41
+
42
+ # Generate random plans for a givne statement
43
+ # @return [Array]
44
+ def find_random_plans(statement, workload, indexes, cost_model, options)
45
+ planner = Plans::QueryPlanner.new workload, indexes, cost_model
46
+ plans = planner.find_plans_for_query(statement).to_a
47
+ plans = plans.sample options[:count] unless options[:all]
48
+
49
+ plans
50
+ end
51
+
52
+ # Build a results structure for these plans
53
+ # @return [OpenStruct]
54
+ def random_plan_results(workload, indexes, plans, cost_model)
55
+ results = OpenStruct.new
56
+ results.workload = workload
57
+ results.model = workload.model
58
+ results.enumerated_indexes = indexes
59
+ results.indexes = plans.map(&:indexes).flatten(1).to_set
60
+ results.plans = plans
61
+ results.cost_model = cost_model
62
+
63
+ results
64
+ end
65
+
66
+ # Output the results in the specified format
67
+ # @return [void]
68
+ def output_random_plans(results, output, format)
69
+ if output.nil?
70
+ file = $stdout
71
+ else
72
+ File.open(output, 'w')
73
+ end
74
+ begin
75
+ send(('output_' + format).to_sym, results, file, true)
76
+ ensure
77
+ file.close unless output.nil?
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NoSE
4
+ module CLI
5
+ # Add a command to recalculate costs with a new model
6
+ class NoSECLI < Thor
7
+ desc 'recost PLAN_FILE COST_MODEL',
8
+ 'recost the workload in PLAN_FILE with COST_MODEL'
9
+
10
+ long_desc <<-LONGDESC
11
+ `nose recost` takes a given plan file as input and then produces a new
12
+ JSON file with new values for the expected query cost from the named
13
+ cost model. This only works for cost models which don't require any
14
+ parameters such as `Cost::RequestCountCost`.
15
+ LONGDESC
16
+
17
+ def recost(plan_file, cost_model)
18
+ result = load_results plan_file
19
+
20
+ # Get the new cost model and recost all the queries
21
+ new_cost_model = get_class('cost', cost_model).new
22
+ result.plans.each { |plan| plan.cost_model = new_cost_model }
23
+
24
+ # Update the cost values
25
+ result.cost_model = new_cost_model
26
+ result.total_cost = total_cost result.workload, result.plans
27
+
28
+ output_json result
29
+ end
30
+
31
+ private
32
+
33
+ # Get the total cost for a set of plans in a given workload
34
+ # @return [Fixnum]
35
+ def total_cost(workload, plans)
36
+ plan_hash = Hash[plans.map { |plan| [plan.query, plan] }]
37
+
38
+ workload.statement_weights.map do |statement, weight|
39
+ next 0 unless statement.is_a? Query
40
+ weight * plan_hash[statement].cost
41
+ end.inject(0, &:+)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NoSE
4
+ module CLI
5
+ # Add a command to reformat a plan file
6
+ class NoSECLI < Thor
7
+ desc 'reformat PLAN_FILE',
8
+ 'reformat the data in PLAN_FILE'
9
+
10
+ shared_option :format
11
+ shared_option :mix
12
+
13
+ def reformat(plan_file)
14
+ result = load_results plan_file, options[:mix]
15
+ result.recalculate_cost
16
+
17
+ # Output the results in the specified format
18
+ send(('output_' + options[:format]).to_sym, result)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'formatador'
4
+
5
+ begin
6
+ require 'readline'
7
+ rescue LoadError
8
+ nil
9
+ end
10
+
11
+ module NoSE
12
+ module CLI
13
+ # Add a command to run a REPL which evaluates queries
14
+ class NoSECLI < Thor
15
+ desc 'repl PLAN_FILE', 'start the REPL with the given PLAN_FILE'
16
+
17
+ long_desc <<-LONGDESC
18
+ `nose repl` starts a terminal interface which loads execution plans
19
+ from the given file. It then loops continuously and accepts statements
20
+ from the workload and executes plans against the database. The result
21
+ of any query will be printed to the terminal.
22
+ LONGDESC
23
+
24
+ def repl(plan_file)
25
+ result = load_results plan_file
26
+ backend = get_backend(options, result)
27
+
28
+ load_history
29
+ loop do
30
+ begin
31
+ line = read_line
32
+ rescue Interrupt
33
+ line = nil
34
+ end
35
+ break if line.nil?
36
+
37
+ next if line.empty?
38
+
39
+ execute_statement line, result, backend
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ # Load the history file
46
+ # @return [void]
47
+ def load_history
48
+ return unless Object.const_defined? 'Readline'
49
+
50
+ history_file = File.expand_path '~/.nose_history'
51
+ begin
52
+ File.foreach(history_file) do |line|
53
+ Readline::HISTORY.push line.chomp
54
+ end
55
+ rescue Errno::ENOENT
56
+ nil
57
+ end
58
+ end
59
+
60
+ # Save the history file
61
+ # @return [void]
62
+ def save_history
63
+ return unless Object.const_defined? 'Readline'
64
+
65
+ history_file = File.expand_path '~/.nose_history'
66
+ File.open(history_file, 'w') do |f|
67
+ Readline::HISTORY.each do |line|
68
+ f.puts line
69
+ end
70
+ end
71
+ end
72
+
73
+ # Get the next inputted line in the REPL
74
+ # @return [String]
75
+ def read_line
76
+ prefix = '>> '
77
+
78
+ if Object.const_defined? 'Readline'
79
+ line = Readline.readline prefix
80
+ return if line.nil?
81
+
82
+ Readline::HISTORY.push line unless line.chomp.empty?
83
+ else
84
+ print prefix
85
+ line = gets
86
+ end
87
+
88
+ line.chomp
89
+ end
90
+
91
+ # Parse a statement from a given string of text
92
+ # @return [Statement]
93
+ def parse_statement(text, workload)
94
+ begin
95
+ statement = Statement.parse text, workload.model
96
+ rescue ParseFailed => e
97
+ puts '! ' + e.message
98
+ statement = nil
99
+ end
100
+
101
+ statement
102
+ end
103
+
104
+ # Try to execute a statement read from the REPL
105
+ # @return [void]
106
+ def execute_statement(line, result, backend)
107
+ # Parse the statement
108
+ statement = parse_statement line, result.workload
109
+ return if statement.nil?
110
+
111
+ begin
112
+ results, elapsed = backend_execute_statement backend, statement
113
+ rescue NotImplementedError, Backend::PlanNotFound => e
114
+ puts '! ' + e.message
115
+ else
116
+ display_statement_result results, elapsed
117
+ end
118
+ end
119
+
120
+ # Execute the statement on the provided backend and measure the runtime
121
+ def backend_execute_statement(backend, statement)
122
+ start_time = Time.now.utc
123
+
124
+ if statement.is_a? Query
125
+ results = backend.query statement
126
+ else
127
+ backend.update statement
128
+ results = []
129
+ end
130
+
131
+ elapsed = Time.now.utc - start_time
132
+
133
+ [results, elapsed]
134
+ end
135
+
136
+ # Show the results of executing a statement
137
+ # @return [void]
138
+ def display_statement_result(results, elapsed)
139
+ Formatador.display_compact_table results unless results.empty?
140
+ puts format('(%d rows in %.2fs)', results.length, elapsed)
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'formatador'
4
+ require 'ostruct'
5
+ require 'json'
6
+
7
+ module NoSE
8
+ module CLI
9
+ # Add a command to run the advisor for a given workload
10
+ class NoSECLI < Thor
11
+ desc 'search NAME', 'run the workload NAME'
12
+
13
+ long_desc <<-LONGDESC
14
+ `nose search` is the primary command to run the NoSE advisor. It will
15
+ load the given workload, enumerate indexes for each statement, and
16
+ construct and solve an ILP to produce statement execution plans.
17
+ LONGDESC
18
+
19
+ shared_option :mix
20
+ shared_option :format
21
+ shared_option :output
22
+
23
+ option :max_space, type: :numeric, default: Float::INFINITY,
24
+ aliases: '-s',
25
+ desc: 'maximum space allocated to indexes'
26
+ option :enumerated, type: :boolean, default: false, aliases: '-e',
27
+ desc: 'whether enumerated indexes should be output'
28
+ option :read_only, type: :boolean, default: false,
29
+ desc: 'whether to ignore update statements'
30
+ option :objective, type: :string, default: 'cost',
31
+ enum: %w(cost space indexes),
32
+ desc: 'the objective function to use in the ILP'
33
+ option :by_id_graph, type: :boolean, default: false,
34
+ desc: 'whether to group generated indexes in' \
35
+ 'graphs by ID',
36
+ aliases: '-i'
37
+
38
+ def search(name)
39
+ # Get the workload and cost model
40
+ workload = Workload.load name
41
+ workload.mix = options[:mix].to_sym \
42
+ unless options[:mix] == 'default' && workload.mix != :default
43
+ workload.remove_updates if options[:read_only]
44
+ cost_model = get_class_from_config options, 'cost', :cost_model
45
+
46
+ # Execute the advisor
47
+ objective = Search::Objective.const_get options[:objective].upcase
48
+ result = search_result workload, cost_model, options[:max_space],
49
+ objective, options[:by_id_graph]
50
+ output_search_result result, options unless result.nil?
51
+ end
52
+
53
+ private
54
+
55
+ # Output results from the search procedure
56
+ # @return [void]
57
+ def output_search_result(result, options)
58
+ # Output the results in the specified format
59
+ file = if options[:output].nil?
60
+ $stdout
61
+ else
62
+ File.open(options[:output], 'w')
63
+ end
64
+
65
+ begin
66
+ backend = get_backend options, result
67
+ send(('output_' + options[:format]).to_sym,
68
+ result, file, options[:enumerated], backend)
69
+ rescue
70
+ nil
71
+ ensure
72
+ file.close unless options[:output].nil?
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module NoSE
6
+ module CLI
7
+ # Add a command to reformat a plan file
8
+ class NoSECLI < Thor
9
+ desc 'search-all NAME DIRECTORY',
10
+ 'output all possible schemas for the workload NAME under ' \
11
+ 'different storage constraints to DIRECTORY'
12
+
13
+ long_desc <<-LONGDESC
14
+ `nose search-all` is a convenience for executing `nose search` with a
15
+ variety of different storage constraints. It will start with the
16
+ smallest and largest possible schemas and then perform a binary search
17
+ throughout the search space to discover schemas of various sizes.
18
+ LONGDESC
19
+
20
+ shared_option :format
21
+ shared_option :mix
22
+
23
+ option :enumerated, type: :boolean, aliases: '-e',
24
+ desc: 'whether enumerated indexes should be output'
25
+ option :read_only, type: :boolean, default: false,
26
+ desc: 'whether to ignore update statements'
27
+ option :max_results, type: :numeric, default: Float::INFINITY,
28
+ aliases: '-n',
29
+ desc: 'the maximum number of results to produce'
30
+ def search_all(name, directory)
31
+ # Load the workload and cost model and create the output directory
32
+ workload = Workload.load name
33
+ workload.mix = options[:mix].to_sym \
34
+ unless options[:mix] == 'default' && workload.mix != :default
35
+ workload.remove_updates if options[:read_only]
36
+ cost_model = get_class_from_config options, 'cost', :cost_model
37
+ FileUtils.mkdir_p(directory) unless Dir.exist?(directory)
38
+
39
+ # Run the search and output the results
40
+ results = search_results workload, cost_model, options[:max_results]
41
+ output_results results, directory, options
42
+ end
43
+
44
+ private
45
+
46
+ # Get a list of all possible search results
47
+ # @return [Array<Search::Results>]
48
+ def search_results(workload, cost_model, max_results)
49
+ # Start with the maximum possible size and divide in two
50
+ max_result = search_result workload, cost_model
51
+ max_size = max_result.total_size
52
+ min_result = search_result workload, cost_model,
53
+ Float::INFINITY, Search::Objective::SPACE
54
+ min_size = min_result.total_size
55
+ min_result = search_result workload, cost_model, min_size
56
+
57
+ # If we only have one result, return
58
+ return [max_result] if max_size == min_size
59
+
60
+ results = [max_result, min_result]
61
+ num_results = 2
62
+ sizes = Set.new [min_size, max_size]
63
+ size_queue = [(max_size - min_size) / 2.0 + min_size]
64
+ until size_queue.empty?
65
+ # Stop if we found the appropriate number of results
66
+ return results if num_results >= max_results
67
+
68
+ # Find a new size to examine
69
+ size = size_queue.pop
70
+
71
+ # Continue dividing the range of examined sizes
72
+ next_size = sizes.sort.detect { |n| n > size }
73
+ next_size = (next_size - size) / 2.0 + size
74
+
75
+ prev_size = sizes.sort.reverse_each.detect { |n| n < size }
76
+ prev_size = (size - prev_size) / 2.0 + prev_size
77
+
78
+ begin
79
+ @logger.info "Running search with size #{size}"
80
+
81
+ result = search_result workload, cost_model, size
82
+ next if sizes.include?(result.total_size) || result.nil?
83
+ rescue Search::NoSolutionException, Plans::NoPlanException
84
+ # No result was found, so only explore the larger side
85
+ @logger.info "No solution for size #{size}"
86
+ else
87
+ # Add the smaller size to the queue and save the result
88
+ size_queue.push prev_size unless sizes.include? prev_size
89
+ results.push result
90
+ num_results += 1
91
+ end
92
+
93
+ # Add the larger size to the queue
94
+ size_queue.push(next_size) unless sizes.include? next_size
95
+
96
+ # Note that we visited this size (and the result size)
97
+ sizes.add size
98
+ sizes.add result.total_size unless result.nil?
99
+ end
100
+
101
+ results
102
+ end
103
+
104
+ # Output all results to file
105
+ # @return [void]
106
+ def output_results(results, directory, options)
107
+ results.sort_by!(&:total_size)
108
+ results.each_with_index do |result, i|
109
+ file = File.open File.join(directory, "#{i}.#{options[:format]}"), 'w'
110
+ begin
111
+ send(('output_' + options[:format]).to_sym,
112
+ result, file, options[:enumerated])
113
+ ensure
114
+ file.close
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end