nose-cli 0.1.0pre

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 69482346fff002e27d56b7826eec5ab14f4626db
4
+ data.tar.gz: 07e536d085b0a88fb38f8650a940e4a54c18fbf6
5
+ SHA512:
6
+ metadata.gz: 1c0e8ab04a53a5107a6ff9559f218eea28db5437d21597720c508867d5d737ce065a742a331ab0742df81bb85efe5f1c6b6737ec00cab204cad1879444b1e73b
7
+ data.tar.gz: 3e7ab9f5d4b30a71697a15cface20dbf99998f738504f0d83cba5b165cb3cfbad588bc380378c3dd637e5e2f021acffc908d03fe3abaad17fb687f66ac9da93f
data/bin/nose ADDED
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Optionally enable debug logging
5
+ ENV['NOSE_LOG'] = 'debug' if ARGV.include?('--debug') || ARGV.include?('-d')
6
+
7
+ $LOAD_PATH.unshift File.dirname(__FILE__) + '/../lib'
8
+
9
+ require 'nose'
10
+ require 'nose_cli'
11
+
12
+ # Start profiling if asked
13
+ unless ENV['NOSE_PROFILE'].nil?
14
+ require 'ruby-prof'
15
+ Parallel.instance_variable_set(:@processor_count, 0)
16
+ RubyProf.start
17
+ end
18
+
19
+ NoSE::CLI::NoSECLI.start ARGV
20
+
21
+ # Stop profiling and output results
22
+ unless ENV['NOSE_PROFILE'].to_i == 0
23
+ result = RubyProf.stop
24
+ printer = RubyProf::CallTreePrinter.new(result)
25
+ printer.print
26
+ end
data/bin/random_rubis ADDED
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Get and print the seed which is used
4
+ seed = ::Random.new_seed
5
+ $stderr.puts "SEED #{seed}"
6
+ ::Random.srand seed
7
+
8
+ require 'nose'
9
+
10
+ # Record times for the longest running sets of methods
11
+ times = {
12
+ indexes_for_workload: 0,
13
+ query_costs: 0,
14
+ update_costs: 0,
15
+ setup_model: 0,
16
+ solve: 0
17
+ }
18
+ NoSE::Timer.enable do |_cls, method, time|
19
+ times[method] = time if times.key? method
20
+ end
21
+
22
+ factor = ARGV[0].to_i
23
+
24
+ # Create a random workload generator
25
+ network = NoSE::Random::WattsStrogatzNetwork.new(nodes_nb: 7 * factor)
26
+ workload = NoSE::Workload.new
27
+ network.entities.each { |entity| workload << entity }
28
+ sgen = NoSE::Random::StatementGenerator.new workload.model
29
+
30
+ # Add random queries
31
+ 1.upto(30 * factor).each do |i|
32
+ path_length = rand > 0.9 ? 2 : 1
33
+ path_length = 3 if i <= factor
34
+
35
+ r = rand
36
+ conditions = if r > 0.95
37
+ 3
38
+ elsif r > 0.75
39
+ 2
40
+ else
41
+ 1
42
+ end
43
+ q = sgen.random_query path_length,
44
+ 3,
45
+ conditions,
46
+ rand > 0.9
47
+ $stderr.puts q.unparse
48
+ workload.add_statement q, 10
49
+ end
50
+
51
+ # Add random updates
52
+ 1.upto(3 * factor).each do
53
+ u = sgen.random_update 1, 2, 1
54
+ $stderr.puts u.text
55
+ workload.add_statement u
56
+ end
57
+
58
+ # Add random inserts
59
+ 1.upto(5 * factor).each do
60
+ i = sgen.random_insert
61
+ $stderr.puts i.text
62
+ workload.add_statement i
63
+ end
64
+
65
+ # Uncomment the lines below to enable profiling
66
+ # (along with the lines above to save the output)
67
+ # require 'ruby-prof'
68
+ # Parallel.instance_variable_set(:@processor_count, 0)
69
+ # RubyProf.start
70
+
71
+ # Execute NoSE for the random workload and report the time
72
+ start = Time.now.utc
73
+ indexes = NoSE::IndexEnumerator.new(workload).indexes_for_workload.to_a
74
+ search = NoSE::Search::Search.new(workload,
75
+ NoSE::Cost::RequestCountCost.new)
76
+ search.search_overlap(indexes)
77
+ elapsed = Time.now.utc - start
78
+
79
+ # Output the timing values
80
+ total = 0
81
+ times[:costs] = times.delete(:query_costs) + times.delete(:update_costs)
82
+ times.each do |key, time|
83
+ puts "#{key},#{time}"
84
+ total += time
85
+ end
86
+ puts "other,#{elapsed - total}"
87
+
88
+ # Uncomment the lines below to save profile output
89
+ # (along with the lines above to enable profiling)
90
+ # result = RubyProf.stop
91
+ # result.eliminate_methods!([
92
+ # /NoSE::Field#hash/,
93
+ # /Range#/,
94
+ # /Array#/,
95
+ # /Set#/,
96
+ # /Hash#/,
97
+ # /Integer#downto/,
98
+ # /Hashids#/,
99
+ # /String#/,
100
+ # /Enumerable#/,
101
+ # /Integer#times/,
102
+ # /Class#new/
103
+ # ])
104
+ # printer = RubyProf::CallTreePrinter.new(result)
105
+ # printer.print(File.open('prof.out', 'w'))
@@ -0,0 +1,20 @@
1
+ #!/bin/bash
2
+
3
+ # Get the first data directory (we assume there's only one)
4
+ # The backup directory is just this directory with -bk appended
5
+ DATA_DIR=`grep data_file_directories -A 1 /etc/cassandra/cassandra.yaml | \
6
+ tail -n +2 | sed 's/^\s\+-\s\+//; s/\/[^/]*$//'`
7
+
8
+ # Ideally sudo should be usable without a password so this can be automated
9
+ # We simply stop Cassandra, restore old data from a backup and restart
10
+ time sudo sh -c "service cassandra stop; \
11
+ rm -rf $DATA_DIR; \
12
+ cp -r $DATA_DIR-bk $DATA_DIR; \
13
+ chown -R cassandra:cassandra $DATA_DIR; \
14
+ service cassandra start"
15
+
16
+ until cqlsh `hostname -i` -e "USE $1"
17
+ do
18
+ echo 'Waiting for Cassandra...'
19
+ sleep 5
20
+ done
@@ -0,0 +1,61 @@
1
+ #!/bin/sh
2
+
3
+ eval `bundle exec nose export`
4
+
5
+ # The first argument should be the directory where results are stored
6
+ RESULTS_DIR=$1
7
+ REPEAT=1
8
+ ITERATIONS=1000
9
+ COMMON_OPTIONS="--num-iterations=$ITERATIONS --repeat=$REPEAT --format=csv"
10
+
11
+ # Enable command output and fail on error
12
+ set -e
13
+ set -x
14
+
15
+ mkdir -p $RESULTS_DIR
16
+
17
+ # Passwordless SSH access must be set up to the backend host
18
+ restart_cassandra() {
19
+ ssh $BACKEND_HOSTS_0 `pwd`/bin/restart_cassandra.sh $BACKEND_KEYSPACE
20
+ }
21
+
22
+ run_nose_search() {
23
+ bundle exec nose search rubis --format=json --mix=$1 > $RESULTS_DIR/$1.json
24
+ }
25
+
26
+ run_nose_search bidding
27
+ restart_cassandra
28
+
29
+ bundle exec nose benchmark $COMMON_OPTIONS --mix=bidding \
30
+ $RESULTS_DIR/bidding.json > $RESULTS_DIR/bidding.csv
31
+
32
+ run_nose_search write_heavy
33
+ restart_cassandra
34
+
35
+ bundle exec nose benchmark $COMMON_OPTIONS --mix=write_heavy \
36
+ $RESULTS_DIR/write_heavy.json > $RESULTS_DIR/write_heavy.csv
37
+
38
+ restart_cassandra
39
+
40
+ bundle exec nose benchmark $COMMON_OPTIONS --mix=write_heavy \
41
+ $RESULTS_DIR/bidding.json > $RESULTS_DIR/bidding_write_heavy.csv
42
+
43
+ restart_cassandra
44
+
45
+ bundle exec nose execute $COMMON_OPTIONS --mix=bidding \
46
+ rubis_expert > $RESULTS_DIR/expert.csv
47
+
48
+ restart_cassandra
49
+
50
+ bundle exec nose execute $COMMON_OPTIONS --mix=write_heavy \
51
+ rubis_expert > $RESULTS_DIR/expert_write_heavy.csv
52
+
53
+ restart_cassandra
54
+
55
+ bundle exec nose execute $COMMON_OPTIONS --mix=bidding \
56
+ rubis_baseline > $RESULTS_DIR/baseline.csv
57
+
58
+ restart_cassandra
59
+
60
+ bundle exec nose execute $COMMON_OPTIONS --mix=write_heavy \
61
+ rubis_baseline > $RESULTS_DIR/baseline_write_heavy.csv
@@ -0,0 +1,32 @@
1
+ # Connection to the backend database being targeted, currently only Cassandra
2
+ backend:
3
+ name: cassandra
4
+ hosts:
5
+ - localhost
6
+ port: 9042
7
+ keyspace: nose
8
+
9
+ # Cost model name and parameters
10
+ cost_model:
11
+ name: request_count
12
+
13
+ # Loader-specific configuration
14
+ # The mysql loader is recommended, but csv might work as well if
15
+ # your generated indexes all have path length one
16
+ loader:
17
+ name: mysql
18
+ host: 127.0.0.1
19
+ database: rubis
20
+ username: root
21
+ password: root
22
+
23
+ # Query proxy
24
+ proxy:
25
+ name: mysql
26
+ port: 3307
27
+
28
+ # vim: set syntax=yaml:
29
+
30
+ # Local Variables:
31
+ # mode:yaml
32
+ # End:
data/lib/nose_cli.rb ADDED
@@ -0,0 +1,364 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'formatador'
5
+ require 'parallel'
6
+ require 'thor'
7
+ require 'yaml'
8
+
9
+ require 'nose'
10
+ require_relative 'nose_cli/measurements'
11
+
12
+ module NoSE
13
+ # CLI tools for running the advisor
14
+ module CLI
15
+ # A command-line interface to running the advisor tool
16
+ class NoSECLI < Thor
17
+ # The path to the configuration file in the working directory
18
+ CONFIG_FILE_NAME = 'nose.yml'
19
+
20
+ check_unknown_options!
21
+
22
+ class_option :debug, type: :boolean, aliases: '-d',
23
+ desc: 'enable detailed debugging information'
24
+ class_option :parallel, type: :boolean, default: false,
25
+ desc: 'run various operations in parallel'
26
+ class_option :colour, type: :boolean, default: nil, aliases: '-c',
27
+ desc: 'enabled coloured output'
28
+ class_option :interactive, type: :boolean, default: true,
29
+ desc: 'allow actions which require user input'
30
+
31
+ def initialize(_options, local_options, config)
32
+ super
33
+
34
+ # Set up a logger for this command
35
+ cmd_name = config[:current_command].name
36
+ @logger = Logging.logger["nose::#{cmd_name}"]
37
+
38
+ # Peek ahead into the options and prompt the user to create a config
39
+ check_config_file interactive?(local_options)
40
+
41
+ force_colour(options[:colour]) unless options[:colour].nil?
42
+
43
+ # Disable parallel processing if desired
44
+ Parallel.instance_variable_set(:@processor_count, 0) \
45
+ unless options[:parallel]
46
+ end
47
+
48
+ private
49
+
50
+ # Check if the user has disabled interaction
51
+ # @return [Boolean]
52
+ def interactive?(options = [])
53
+ parse_options = self.class.class_options
54
+ opts = Thor::Options.new(parse_options).parse(options)
55
+ opts[:interactive]
56
+ end
57
+
58
+ # Check if the user has created a configuration file
59
+ # @return [void]
60
+ def check_config_file(interactive)
61
+ return if File.file?(CONFIG_FILE_NAME)
62
+
63
+ if interactive
64
+ no_create = no? 'nose.yml is missing, ' \
65
+ 'create from nose.yml.example? [Yn]'
66
+ example_cfg = File.join Gem.loaded_specs['nose-cli'].full_gem_path,
67
+ 'data', 'nose-cli', 'nose.yml.example'
68
+ FileUtils.cp example_cfg, CONFIG_FILE_NAME unless no_create
69
+ else
70
+ @logger.warn 'Configuration file missing'
71
+ end
72
+ end
73
+
74
+ # Add the possibility to set defaults via configuration
75
+ # @return [Thor::CoreExt::HashWithIndifferentAccess]
76
+ def options
77
+ original_options = super
78
+ return original_options unless File.exist? CONFIG_FILE_NAME
79
+ defaults = YAML.load_file(CONFIG_FILE_NAME).deep_symbolize_keys || {}
80
+ Thor::CoreExt::HashWithIndifferentAccess \
81
+ .new(defaults.merge(original_options))
82
+ end
83
+
84
+ # Get a backend instance for a given configuration and dataset
85
+ # @return [Backend::BackendBase]
86
+ def get_backend(config, result)
87
+ be_class = get_class 'backend', config
88
+ be_class.new result.workload.model, result.indexes,
89
+ result.plans, result.update_plans, config[:backend]
90
+ end
91
+
92
+ # Get a class of a particular name from the configuration
93
+ # @return [Object]
94
+ def get_class(class_name, config)
95
+ name = config
96
+ name = config[class_name.to_sym][:name] if config.is_a? Hash
97
+ require "nose/#{class_name}/#{name}"
98
+ name = name.split('_').map(&:capitalize).join
99
+ full_class_name = ['NoSE', class_name.capitalize,
100
+ name + class_name.capitalize]
101
+ full_class_name.reduce(Object) do |mod, name_part|
102
+ mod.const_get name_part
103
+ end
104
+ end
105
+
106
+ # Get a class given a set of options
107
+ # @return [Object]
108
+ def get_class_from_config(options, name, type)
109
+ object_class = get_class name, options[type][:name]
110
+ object_class.new(**options[type])
111
+ end
112
+
113
+ # Collect all advisor results for schema design problem
114
+ # @return [Search::Results]
115
+ def search_result(workload, cost_model, max_space = Float::INFINITY,
116
+ objective = Search::Objective::COST,
117
+ by_id_graph = false)
118
+ enumerated_indexes = IndexEnumerator.new(workload) \
119
+ .indexes_for_workload.to_a
120
+ Search::Search.new(workload, cost_model, objective, by_id_graph) \
121
+ .search_overlap enumerated_indexes, max_space
122
+ end
123
+
124
+ # Load results of a previous search operation
125
+ # @return [Search::Results]
126
+ def load_results(plan_file, mix = 'default')
127
+ representer = Serialize::SearchResultRepresenter.represent \
128
+ Search::Results.new
129
+ json = File.read(plan_file)
130
+ result = representer.from_json(json)
131
+ result.workload.mix = mix.to_sym unless \
132
+ mix.nil? || (mix == 'default' && result.workload.mix != :default)
133
+
134
+ result
135
+ end
136
+
137
+ # Load plans either from an explicit file or the name
138
+ # of something in the plans/ directory
139
+ def load_plans(plan_file, options)
140
+ if File.exist? plan_file
141
+ result = load_results(plan_file, options[:mix])
142
+ else
143
+ schema = Schema.load plan_file
144
+ result = OpenStruct.new
145
+ result.workload = Workload.new schema.model
146
+ result.indexes = schema.indexes.values
147
+ end
148
+ backend = get_backend(options, result)
149
+
150
+ [result, backend]
151
+ end
152
+
153
+ # Output a list of indexes as text
154
+ # @return [void]
155
+ def output_indexes_txt(header, indexes, file)
156
+ file.puts Formatador.parse("[blue]#{header}[/]")
157
+ indexes.sort_by(&:key).each { |index| file.puts index.inspect }
158
+ file.puts
159
+ end
160
+
161
+ # Output a list of query plans as text
162
+ # @return [void]
163
+ def output_plans_txt(plans, file, indent, weights)
164
+ plans.each do |plan|
165
+ weight = (plan.weight || weights[plan.query || plan.name])
166
+ next if weight.nil?
167
+ cost = plan.cost * weight
168
+
169
+ file.puts "GROUP #{plan.group}" unless plan.group.nil?
170
+
171
+ weight = " * #{weight} = #{cost}"
172
+ file.puts ' ' * (indent - 1) + plan.query.label \
173
+ unless plan.query.nil? || plan.query.label.nil?
174
+ file.puts ' ' * (indent - 1) + plan.query.inspect + weight
175
+ plan.each { |step| file.puts ' ' * indent + step.inspect }
176
+ file.puts
177
+ end
178
+ end
179
+
180
+ # Output update plans as text
181
+ # @return [void]
182
+ def output_update_plans_txt(update_plans, file, weights, mix = nil)
183
+ unless update_plans.empty?
184
+ header = "Update plans\n" + '━' * 50
185
+ file.puts Formatador.parse("[blue]#{header}[/]")
186
+ end
187
+
188
+ update_plans.group_by(&:statement).each do |statement, plans|
189
+ weight = if weights.key?(statement)
190
+ weights[statement]
191
+ elsif weights.key?(statement.group)
192
+ weights[statement.group]
193
+ else
194
+ weights[statement.group][mix]
195
+ end
196
+ next if weight.nil?
197
+
198
+ total_cost = plans.sum_by(&:cost)
199
+
200
+ file.puts "GROUP #{statement.group}" unless statement.group.nil?
201
+
202
+ file.puts statement.label unless statement.label.nil?
203
+ file.puts "#{statement.inspect} * #{weight} = #{total_cost * weight}"
204
+ plans.each do |plan|
205
+ file.puts Formatador.parse(" for [magenta]#{plan.index.key}[/] " \
206
+ "[yellow]$#{plan.cost}[/]")
207
+ query_weights = Hash[plan.query_plans.map do |query_plan|
208
+ [query_plan.query, weight]
209
+ end]
210
+ output_plans_txt plan.query_plans, file, 2, query_weights
211
+
212
+ plan.update_steps.each do |step|
213
+ file.puts ' ' + step.inspect
214
+ end
215
+
216
+ file.puts
217
+ end
218
+
219
+ file.puts "\n"
220
+ end
221
+ end
222
+
223
+ # Output the results of advising as text
224
+ # @return [void]
225
+ def output_txt(result, file = $stdout, enumerated = false,
226
+ _backend = nil)
227
+ if enumerated
228
+ header = "Enumerated indexes\n" + '━' * 50
229
+ output_indexes_txt header, result.enumerated_indexes, file
230
+ end
231
+
232
+ # Output selected indexes
233
+ header = "Indexes\n" + '━' * 50
234
+ output_indexes_txt header, result.indexes, file
235
+
236
+ file.puts Formatador.parse(' Total size: ' \
237
+ "[blue]#{result.total_size}[/]\n\n")
238
+
239
+ # Output query plans for the discovered indices
240
+ header = "Query plans\n" + '━' * 50
241
+ file.puts Formatador.parse("[blue]#{header}[/]")
242
+ weights = result.workload.statement_weights
243
+ weights = result.weights if weights.nil? || weights.empty?
244
+ output_plans_txt result.plans, file, 1, weights
245
+
246
+ result.update_plans = [] if result.update_plans.nil?
247
+ output_update_plans_txt result.update_plans, file, weights,
248
+ result.workload.mix
249
+
250
+ file.puts Formatador.parse(' Total cost: ' \
251
+ "[blue]#{result.total_cost}[/]\n")
252
+ end
253
+
254
+ # Output an HTML file with a description of the search results
255
+ # @return [void]
256
+ def output_html(result, file = $stdout, enumerated = false,
257
+ backend = nil)
258
+ # Get an SVG diagram of the model
259
+ tmpfile = Tempfile.new %w(model svg)
260
+ result.workload.model.output :svg, tmpfile.path, true
261
+ svg = File.open(tmpfile.path).read
262
+
263
+ enumerated &&= result.enumerated_indexes
264
+ tmpl = File.read File.join(File.dirname(__FILE__),
265
+ '../../templates/report.erb')
266
+ ns = OpenStruct.new svg: svg,
267
+ backend: backend,
268
+ indexes: result.indexes,
269
+ enumerated_indexes: enumerated,
270
+ workload: result.workload,
271
+ update_plans: result.update_plans,
272
+ plans: result.plans,
273
+ total_size: result.total_size,
274
+ total_cost: result.total_cost
275
+
276
+ force_colour
277
+ file.write ERB.new(tmpl, nil, '>').result(ns.instance_eval { binding })
278
+ end
279
+
280
+ # Output the results of advising as JSON
281
+ # @return [void]
282
+ def output_json(result, file = $stdout, enumerated = false,
283
+ _backend = nil)
284
+ # Temporarily remove the enumerated indexes
285
+ if enumerated
286
+ enumerated = result.enumerated_indexes
287
+ result.delete_field :enumerated_indexes
288
+ end
289
+
290
+ file.puts JSON.pretty_generate \
291
+ Serialize::SearchResultRepresenter.represent(result).to_hash
292
+
293
+ result.enumerated_indexes = enumerated if enumerated
294
+ end
295
+
296
+ # Output the results of advising as YAML
297
+ # @return [void]
298
+ def output_yml(result, file = $stdout, enumerated = false,
299
+ _backend = nil)
300
+ # Temporarily remove the enumerated indexes
301
+ if enumerated
302
+ enumerated = result.enumerated_indexes
303
+ result.delete_field :enumerated_indexes
304
+ end
305
+
306
+ file.puts Serialize::SearchResultRepresenter.represent(result).to_yaml
307
+
308
+ result.enumerated_indexes = enumerated if enumerated
309
+ end
310
+
311
+ # Filter an options hash for those only relevant to a given command
312
+ # @return [Thor::CoreExt::HashWithIndifferentAccess]
313
+ def filter_command_options(opts, command)
314
+ Thor::CoreExt::HashWithIndifferentAccess.new(opts.select do |key|
315
+ self.class.commands[command].options \
316
+ .each_key.map(&:to_sym).include? key.to_sym
317
+ end)
318
+ end
319
+
320
+ # Enable forcing the colour or no colour for output
321
+ # We just lie to Formatador about whether or not $stdout is a tty
322
+ # @return [void]
323
+ def force_colour(colour = true)
324
+ stdout_metaclass = class << $stdout; self; end
325
+ method = colour ? ->() { true } : ->() { false }
326
+ stdout_metaclass.send(:define_method, :tty?, &method)
327
+ end
328
+ end
329
+ end
330
+ end
331
+
332
+ require_relative 'nose_cli/shared_options'
333
+
334
+ # Require the various subcommands
335
+ require_relative 'nose_cli/analyze'
336
+ require_relative 'nose_cli/benchmark'
337
+ require_relative 'nose_cli/collect_results'
338
+ require_relative 'nose_cli/create'
339
+ require_relative 'nose_cli/diff_plans'
340
+ require_relative 'nose_cli/dump'
341
+ require_relative 'nose_cli/export'
342
+ require_relative 'nose_cli/execute'
343
+ require_relative 'nose_cli/load'
344
+ require_relative 'nose_cli/genworkload'
345
+ require_relative 'nose_cli/graph'
346
+ require_relative 'nose_cli/plan_schema'
347
+ require_relative 'nose_cli/proxy'
348
+ require_relative 'nose_cli/random_plans'
349
+ require_relative 'nose_cli/reformat'
350
+ require_relative 'nose_cli/repl'
351
+ require_relative 'nose_cli/recost'
352
+ require_relative 'nose_cli/search'
353
+ require_relative 'nose_cli/search_all'
354
+ require_relative 'nose_cli/search_bench'
355
+ require_relative 'nose_cli/texify'
356
+ require_relative 'nose_cli/why'
357
+
358
+ # Only include the console command if pry is available
359
+ begin
360
+ require 'pry'
361
+ require_relative 'nose_cli/console'
362
+ rescue LoadError
363
+ nil
364
+ end